こんにちは。マネーフォワード クラウド会計Plus (以下会計Plus)でエンジニアをしているぽっけです。
しばらく前に、会計PlusのRails 7へのアップグレードが完了しました。その中では様々な対応を行いましたが、この記事では特に印象的だったSTIとautoloadingの対応についてご紹介しようと思います。
STIとautoloadingは相性が悪いです。Rails 7以前は簡単な修正でこれらが共存して動いていましたが、Rails 7ではそのコードが動かなくなってしまいました。この問題は最終的には修正されましたが、それまでに紆余曲折あり修正までに何回ものPull Requestが必要になりました。
対象読者
Ruby on Railsを使用した開発経験があることを前提としています。また、STIやautoloadingについて詳細な説明はしません。それらを知らない場合は、該当するRails Guideの記事を事前に眺めるとより理解が深まるでしょう。
この記事では、Rails 7アップグレードの際の試行錯誤を紹介します。Rails 7のアップデートを検討している方、試行錯誤の過程を知るのが好きな方には興味深く読んでいただけるかもしれません。
目次
- STIとautoloadingはなぜ相性が悪いのか
- Rails 6.1までの対応 - StiPreload
- Rails 7では、StiPreloadは動かない
- 試行錯誤
- Rails 7でSTIとautoloadを共存させた、最終的な方法
- まとめ
STIとautoloadingはなぜ相性が悪いのか
これはautoloadingについて書かれたRails Guideの記事でよく説明されています。
単一テーブル継承機能は、lazy loadingとの相性があまりよくありません。一般に単一テーブル継承のAPIが正しく動作するには、STI階層を正しく列挙できる必要があるためです。lazy loadingでは、クラスが参照されるまでクラス読み込みは遅延されます。まだ参照されていないものは列挙できないのです。
https://railsguides.jp/autoloading_and_reloading_constants.html#sti(単一テーブル継承)
(筆者注: 「単一テーブル継承」はSTIのこと。)
STIは、子孫クラスを列挙できる必要があります。この列挙にはClass#descendants
が使われますが、このメソッドはロード済みのクラスのみを返します。そのため、クラスのロードを遅延するautoloadingでは、全てのクラスを正しく列挙できないのです。
相性の悪い具体例
具体的には孫クラスまであるSTIで問題となります。次のコードを見てみましょう。
require "bundler/inline" gemfile(true) do source "https://rubygems.org" gem "activerecord", "~> 7.0.0" gem "sqlite3" end require "active_record" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Schema.define do create_table :as, force: true do |t| t.string :type, null: false end end class A < ActiveRecord::Base end class B < A end class C1 < B end class C2 < B end puts B.all.to_sql # => SELECT "as".* FROM "as" WHERE "as"."type" IN ('B', 'C2', 'C1')
STIのルートにA
があり、そこ子にB
が、更にその子にC1
とC2
がいます。 B.all
のように、ルートではなく子を持ったモデルについてクエリを実行したときが問題となります。
このクエリの最後には、"as"."type" IN ('B', 'C2', 'C1')
という条件がついています。これは「B
とその子孫クラス」を取るための条件です。この子孫クラスを取得するために、事前ロードが必要なのです。
もしDBにB
を継承する C3
クラスに対応するレコードが保存されていたとしても、そのクラスがRubyプロセスで定義されていなければ(ロードされていなければ)クエリからは見えなくなってしまいます。
このような理由から、STIとautoloadingは相性があまり良くありません。そのため、この問題をうまく回避しつつautoloadingをする必要があります。
Rails 6.1までの対応方法 - StiPreload
この問題に対処するため、Rails 6.1まではStiPreload
というモジュールを定義する方法が取られていました。次にコードを引用します。
# https://railsguides.jp/autoloading_and_reloading_constants.html#sti%EF%BC%88%E5%8D%98%E4%B8%80%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E7%B6%99%E6%89%BF%EF%BC%89 module StiPreload unless Rails.application.config.eager_load extend ActiveSupport::Concern included do cattr_accessor :preloaded, instance_accessor: false end class_methods do def descendants preload_sti unless preloaded super end # データベース内にあるすべての型を定数化する。 # その分ディスク容量が余分に必要だが、 # STIのAPIに配慮されていれば実際には問題ではない。 # # store_full_sti_classがtrue(デフォルト)であることが前提 def preload_sti types_in_db = \ base_class. unscoped. select(inheritance_column). distinct. pluck(inheritance_column). compact types_in_db.each do |type| logger.debug("Preloading STI type #{type}") type.constantize end self.preloaded = true end end end end
このモジュールをSTIのトップレベルクラスにincludeすると、問題が解決します。このモジュールではdescendants
メソッドを上書きして、これが呼ばれる前に必要となるクラスを事前にロードしています。
この必要となるクラスは、DBのtype
カラムからクラス名を取得しています。この「DBからクラス名を取得」という点が後々大きな問題となるので、覚えておいてください。
Rails 7では、StiPreloadは動かない
Rails 7ではStiPreload
モジュールが動作しなくなってしまいました。具体的には、特定のケースでuninitialized constantエラーが発生してしまいます。
例えばB < A
という継承関係のSTIがある時、Aよりも先にBを読み込もうとするとエラーになってしまいます。深くは調べていませんが、次のような順序でエラーになってしまうようです。
B
クラスを読み込もうとするB
が定義されているb.rb
を読み込むclass B < A
の行に到達するB
の定義より前にsuperclassのA
の定義が必要なので、A
クラスを読み込もうとするA
が定義されているa.rb
を読み込む- ここで
A.descendants
が呼ばれてしまう- ここがおそらくRails 7からの変更点
A
の子クラスであるB
を読み込もうとする- 循環参照となってしまい、エラーになる。
そのため、Rails 7にアップデートする前にはStiPreloadの問題を解決する必要があります。
試行錯誤
この問題に対処するため、私達は多くの試行錯誤を繰り返しました。結果的には「rails appの起動時のDBアクセスをやめる」という解決方法に至ったのですが、それを選択するまでに時間がかかってしまいました。このセクションでは、その試行錯誤の経緯を紹介しようと思います。
このセクションは長いため、最終的なコードだけを知りたい方は次のセクションまでスキップしてください。
rails/rails#45307 で紹介されている方法を試す
まず、 rails/rails#45307 のコメント紹介されている方法を試しました。ここでは @fxn (Zeitwerkの開発者)が次のコードを提案しています。
# config/initializers/eager_load_stis.rb unless Rails.configuration.eager_load Rails.configuration.to_prepare do [Animal, Shape].each do |sti_root| # perform the query + constantize on sti_root end end end
Rails 7対応の最初の段階では、まずこのコードをベースに対応をしました。"perform the query + constantize on sti_root"とコメントされている部分は、Rails 7以前に使っていたStiPreload
モジュールのpreload_sti
メソッドをそのまま使っています。また、StiPreload
モジュールからはdescendants
メソッドの上書きをなくしてあります。
この変更によって、STIの定数は起動時に、親から子の順序で読み込まれることになります。そのため、子が先に読み込まれることでエラーになってしまう事象を防ぐことができます。
CIのassets:precompileが動かない
問題
しかし、上記コードのままだと問題がありました。CI上でのassets:precompile
が動かないのです。
私達はCircleCIのworkflowを使用しています。そして並列化のために、assets:precompile
を行うjobと、RSpecを実行するjobは分かれています。そのためassets:precompile
を行うjobではRDBMSのサーバが立ち上がっていません。
一方でSTIのロードのためにはDBへのアクセスが必要です。つまりassets:precompile
もDBにアクセスしてしまいます。立ち上がっていないDBにアクセスしようとしたため、エラーになってしまいました。
解決方法
この問題を解決するため、CI
環境変数が存在する場合にはeager_load_stis.rb
での処理を行わないようにしました。これによってassets:precompile
が通るようになりました。
docker buildが通らない
問題
しかし、次はdocker build
が失敗してしまいました。私達はdocker build
の中でもassets:precompile
を行っています。docker build
の環境でもDBが立ち上がっていないため、先程と同様の問題が起きてしまいました。
解決方法
次のようなパッチで対応しました。すこしdirty hack感が強くなってきましたね。
Rails.configuration.to_prepare do [Animal, Shape].each do |sti_root| # perform the query + constantize on sti_root end + rescue ActiveRecord::ConnectionNotEstablished + # DBに繋げないときは、諦める。 end
これでDBに繋げないときは何もしないようになったため、docker build
も通るようになりました。
stagingへのデプロイが通らない
問題
今度はstagingへのデプロイが失敗してしまいました。具体的には、デプロイのプロセスのdb:migrate
が失敗していました。
これはstagingにゴミデータが入ってしまっていたためです。
前述したとおり、このコードはtype
カラムからクラス名を取得しています。ところが私達のstaging環境のデータベースには、type
カラムに空文字列が入っているレコードが存在していました。空文字列をクラス名として解釈しようとするため、constantize
がエラーとなってしまいました。
解決方法
STIをロードする条件を変更し、Rails.env
を見るようにしました。この変更でconfig/initializers/eager_load_stis.rb
内の条件は次のように変わりました。
if (Rails.env.development? || Rails.env.test?) && !ENV['CI'] # ... end
これによってstaging環境(Rails.env
はproduction
)ではこのコードが実行されなくなるため、問題が起きなくなります。
コラム: rake_eager_loadについて
ここまでの話を聞いて、「Rails.configuration.eager_load
が false
のときにしか実行しないのに、なんでstagingに影響が出るのだろう?」と思った方もいるかもしれません。
私達は多くのRails appと同様に、staging環境(Rails.env == "production"
)ではRails.application.eager_load
をtrue
に設定しています。そのため今回問題となったコードはそもそも実行されないはずです。ですが、実際には実行されてしまっていました。
これはrake_eager_load
という設定項目が影響しています。Railsではeager loadの設定を、rake taskとそれ以外で別々に設定できるようになっています。そしてRails.env == "production"
では、eager_load
はtrue
、rake_eager_load
はfalse
にデフォルトでなっています。そのためrake db:migrate
ではeager loadは行われないため、DBを見に行くコードが実行されてしまい、問題が出ていました。
db:createが動かない
この変更を当ててしばらく経った後、新たな問題が発覚しました。新しく開発環境のセットアップをしている人から「rake db:create
が動かない」という報告を受けました。
db:create
もRailsアプリケーションのコードを読み込みます。そのためSTIの子孫クラスのロードが実行されてしまい、まだ作られていないDBにアクセスしようとして失敗していました。
ここで私達は今までの方針を大きく変更しました。これらの問題はRailsアプリケーションの起動時にSTIの子孫クラスを読み込もうとすることに起因します。そのため起動時に子孫クラスを読み込まないような方法を取ることで、問題を回避することにしました。
Rails 7でSTIとautoloadを共存させた、最終的な方法
最終的に私達は愚直な方法を採用しました。STIの子孫クラスを列挙するスクリプトを追加し、その結果を予めJSONファイルに出力します。そしてRailsアプリケーションの起動時に、そのJSONファイルを読み込みます。
実装
具体的なコードは以下のようになりました。
まず、次のスクリプトを追加します。このスクリプトはSTIの子孫クラスを列挙し、それをconfig/sti_descendants.json
に保存します。コード中のroot_classes
には、STIのルートクラスをすべて列挙します。
# script/tool/extract_sti_descendants.rb # Usage: The following command writes config/sti_descendants.json. # bin/rails r script/tool/extract_sti_descendants.rb # # If you add an STI root class, please add the class to `root_classes` Array and re-run this script. # If you add an STI child class, please re-run this script. Rails.application.eager_load! root_classes = [StiClass1, StiClass2, ...] classes = root_classes.flat_map { |root_class| [root_class, *root_class.descendants] }.map(&:name).sort data = { '//': 'This file is generated by `bin/rails r script/tool/extract_sti_descendants.rb`', classes:, } json = JSON.pretty_generate(data) Rails.root.join('config/sti_descendants.json').write(json)
そして、eager_load_stis.rb
は次のように変更しました。生成したJSONファイルを読み込んで、それをロードします。
# config/initializers/eager_load_stis.rb if (Rails.env.development? || Rails.env.test?) && !ENV['CI'] data = JSON.parse(Rails.root.join('config/sti_descendants.json').read) Rails.configuration.to_prepare do data['classes'].each do |klass| klass.constantize end end end
この方法の利点・欠点
この方法では、マジカルなことをしていないため動作が非常に安定します。
今まで挙げてきた問題は、Railsアプリケーションの起動時にデータベースにクエリを発行することが問題でした。対してこの方法ではアプリケーションの起動時にはデータベースに接続しません。実装が単純で、意図しない動きもしづらいでしょう。このコードに変更してから2ヶ月以上が経過しましたが、問題は発生していません。
一方でこの方法ではSTI関連のクラスが追加・削除されるたびに、JSONの生成をやり直す必要があります。私達のアプリケーションでは、STI関連のクラスはあまり更新されることがないため、この問題は許容できました。また、万が一JSONの生成を忘れても本番環境には影響がないことも、許容できる理由の1つです。
まとめ
STIとautoloadingの相性の悪さと、それに起因した問題を紹介しました。また、最後には私達が取った解決方法を紹介しました。愚直な方法ではありますが、必要な条件は満たせていて悪くない解決方法ではないかなと思っています。
参考リンク
この記事の内容を実装するにあたり、以下の情報が参考になりました。
- Active Record の関連付け - Railsガイド
- 定数の自動読み込みと再読み込み (Zeitwerk) - Railsガイド
- StiPreload implementation from Rails Guides results in uninitialized constant errors on Rails 7 · Issue #44252 · rails/rails · GitHub
- Documentation issue: StiPreload causes circular dependency issues · Issue #45307 · rails/rails · GitHub
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【会社情報】 ■Wantedly ■株式会社マネーフォワード ■福岡開発拠点 ■関西開発拠点(大阪/京都)
【SNS】 ■マネーフォワード公式note ■Twitter - 【公式】マネーフォワード ■Twitter - Money Forward Developers ■connpass - マネーフォワード ■YouTube - Money Forward Developers