STIとautoloadingとRails 7

こんにちは。マネーフォワード クラウド会計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のアップデートを検討している方、試行錯誤の過程を知るのが好きな方には興味深く読んでいただけるかもしれません。

⁠目次

  1. STIとautoloadingはなぜ相性が悪いのか
  2. Rails 6.1までの対応 – StiPreload
  3. Rails 7では、StiPreloadは動かない
  4. ⁠試行錯誤
  5. ⁠Rails 7でSTIとautoloadを共存させた、最終的な方法
  6. まとめ

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が、更にその子にC1C2がいます。 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を読み込もうとするとエラーになってしまいます。深くは調べていませんが、次のような順序でエラーになってしまうようです。

  1. Bクラスを読み込もうとする
  2. Bが定義されているb.rbを読み込む
  3. class B < Aの行に到達する
  4. Bの定義より前にsuperclassのAの定義が必要なので、Aクラスを読み込もうとする
  5. Aが定義されているa.rbを読み込む
  6. ここでA.descendantsが呼ばれてしまう
    1. ここがおそらくRails 7からの変更点
  7. Aの子クラスであるBを読み込もうとする
  8. 循環参照となってしまい、エラーになる。

そのため、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.envproduction)ではこのコードが実行されなくなるため、問題が起きなくなります。

コラム: rake_eager_loadについて

ここまでの話を聞いて、「Rails.configuration.eager_loadfalseのときにしか実行しないのに、なんでstagingに影響が出るのだろう?」と思った方もいるかもしれません。

私達は多くのRails appと同様に、staging環境(Rails.env == "production")ではRails.application.eager_loadtrueに設定しています。そのため今回問題となったコードはそもそも実行されないはずです。ですが、実際には実行されてしまっていました。

これはrake_eager_loadという設定項目が影響しています。Railsではeager loadの設定を、rake taskとそれ以外で別々に設定できるようになっています。そしてRails.env == "production"では、eager_loadtruerake_eager_loadfalseにデフォルトでなっています。そのため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の相性の悪さと、それに起因した問題を紹介しました。また、最後には私達が取った解決方法を紹介しました。愚直な方法ではありますが、必要な条件は満たせていて悪くない解決方法ではないかなと思っています。

⁠参考リンク

この記事の内容を実装するにあたり、以下の情報が参考になりました。


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【会社情報】
Wantedly
株式会社マネーフォワード
福岡開発拠点
関西開発拠点(大阪/京都)

【SNS】
マネーフォワード公式note
Twitter – 【公式】マネーフォワード
Twitter – Money Forward Developers
connpass – マネーフォワード
YouTube – Money Forward Developers

Pocket