STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES Rails アプリを Zeitwerk 有効化するまでの道のり

こんにちは、ヘイ株式会社でエンジニアをしている id:hogelog です。

2021年6月に入社し CTO 室という部署に所属しつつなんだかあちこちの部署に首を突っ込むような役割をしています。まだ入社したばかりで把握してないものも多いですが、ビジネスの勢い、人の活気、やらなきゃいけないことばかりという雰囲気をとても楽しんでいます。

さてここは技術ブログ。なので技術の話をします。今回は STORES https://stores.jp/ec を支えるなかなか大きなモノリシック Rails アプリケーションのオートローダーを Zeitwerk へと切り替えた業務について紹介します。最新技術でもなく、Rails の設定項目の一つ Rails.application.config.autoloader の値を :classic から :zeitwerk に切り替えるというだけの地味な内容ですが、どこかの誰かの参考になれば幸いです。

前提

STORES https://stores.jp/ec の多くの機能を担うモノリシック Rails アプリケーションは今年2021年3月に Rails 6.0 にアップグレードを実施しました。*1 この時はアップグレードの差分を減らすためオートローダーは Classic のままとしましたが Rails 6.0 以降のデフォルトのオートローダーは Zeitwerk であり、Rails 7.0 では Classic オートローダーは削除されます。*2

STORES Rails アプリは 2012年5月の最初のコミットから始まり、2021年8月現在に至るまでアクティブに開発が続けられているアプリケーションです。世の中の Rails アプリケーションの大部分が Rails 7 となる頃にも開発され続けていることは想像にかたくありません。

入社したばかりなのでちょうど良いサイズのタスクに取り組み STORES Rails アプリに慣れ親しみたい自分、システムにとっても意味のあるちょうど良い技術負債解消として、表題の通り Zeitwerk 有効化に取り組みました。

Zeitwerk 非互換を見つける

Rails 6.0 の Zeitwerk を有効化するため、いくつかのドキュメントを参考とさせていただきました。

Rails アップグレードガイド、Zeitwerk のドキュメントはもちろんのこと、@fursich さんの「Zeitwerkの壊し方」も公式ドキュメントなどで触れられていないケースの説明なども含み、非常に参考となりました。

Zeitwerk 非互換を見つけるため、具体的には以下のような作業をしました。

  • bundle exec rails zeitwerk:check を実行
  • development environment で config.eager_load = true にして起動
  • 検証環境にデプロイ、動作確認
  • 本番環境以外で有効化する変更を master に取り込みしばらく寝かせる

非互換は基本的には zeitwerk:check タスクで見つけられましたが、一部 unless Rails.env.production? 分岐の中にあるなど見つけにくい非互換もありました。また本番環境以外を config.autoloader = :zeitwerk として開発を進めることで、自分がうっかり見過ごしていた非互換を他チームメンバーが日常の作業の中で見つけるといったこともありました。

今回実施した修正作業

Zeitwerk を有効化するにあたり、最終的には以下のような修正が必要となりました。

Zeitwerk 有効化に必須だった変更

必要だった差分の大部分は名前解決失敗の解消でしたが、それ以外でもいくつかの修正をしました。

大文字略語 (Acronym) が使われていた

https://guides.rubyonrails.org/v6.0/upgrading_ruby_on_rails.html#project-structure でも言及されていますが、Classic オートローダーは定数名からファイル名を推測しますが、Zeitwerk オートローダーではファイル名から定数名を推測します。

irb(main):001:0> "SSL::FooBar".underscore
=> "ssl/foo_bar"
irb(main):002:0> "Ssl::FooBar".underscore
=> "ssl/foo_bar"
irb(main):003:0> "ssl/foo_bar".camelize
=> "Ssl::FooBar"

STORES アプリの中でも SSL, CSV, DB のような略語を含む定数が多数存在しており、Zeitwerk での名前解決に失敗していました。

https://guides.rubyonrails.org/v6.0/autoloading_and_reloading_constants.html#customizing-inflections などで説明されている通り活用形をカスタマイズすることも可能ですが、sslSSL としている定数と Ssl としている定数どちらも存在したり、そもそも通常と違う規則が設定されているというのはわかりにくいだろうと判断し、Zeitwerk の通常の名前解決で成功するよう全ての定数名を変更しました。

一つのファイルに複数のクラス/モジュール定義

app/models/foo.rb というファイルに class Foo, class FooBar, class FooBarBaz のように複数のモデルが定義されている箇所がありました。これは Zeitwerk 以前の Rails においても適切なコードではないですが、Classic オートローダーではたまたま問題が発生していませんでした。

こちらも Zeitwerk オートローダーで問題となったため、それぞれ一つのファイルで一つのモデルを定義するようファイルを分割しました。

config.eager_load_paths に設定されていた lib/

STORES アプリは config.eager_load_paths, config.autoload_pathslib/ を追加していましたが、lib/ 以下に存在する assets/, generators/, tasks/, capistrano/などのディレクトリの中には Zeitwerk の推測とは異なる定数を定義するファイルがありました。

こちらは lib/ ではなく lib/autoloads/config.eager_load_paths, config.autoload_paths に追加し、オートロード対象としたいファイルのみ lib/autoloads/ 以下に移動することで非互換を解消しました。

production では定義されない開発用クラス

本番環境で実行してはいけない開発用のクラス定義が unless Rails.env.production? 分岐の中で実装されている箇所がありました。

unless Rails.env.production?
  class DevelopmentTool
    ...

こちらは production でもクラス定義はされるが、実行すると例外を投げるよう修正しました。

class DevelopmentTool
  def initialize
    raise "error!!!!!!!" if Rails.env.production?
    …

eager_load 処理中の eager_load 呼び出し

STORES アプリの中には並列処理バッチの並列処理中のオートロードを避けるため、Rails.application.eager_load! を呼び出すコードが存在します。修正前は以下のようにクラス定義中に eager_load! 呼び出しが記述されていました。

class ParallelBatch
  Rails.application.eager_load! unless Rails.application.config.eager_load
  ...
  def initialize(...)
    ...

この記述はオートロードによる ParallelBatch クラスのロードで実行された場合には意図通りの挙動をします。Rails.application.config.eager_load が真のときは eager_load! が実行されないので問題はありません。しかし Rails.application.config.eager_load が偽、かつ eager_load により ParallelBatch クラスのロードで実行されると eager_load 中に eager_load が実行されてしまいます。

たとえば mongoid gem で提供される mongoid:load_models タスクを実行すると Rails.application.eager_load! を実行します。rake environment タスク実行時は Rails.application.config.eager_load = false となる*3ため、mongoid:load_models タスクを実行すると config/environements/*.rb の設定によらずこの条件に一致してしまいます。

eager_load 中の eager_load 呼び出しは Classic オートローダーではエラーになっていなかったようですが、Zeitwerk では deadlock となってしまいます。こちらは以下のようにクラスロードとは別のタイミングで実行されるように修正しました。

class ParallelBatchdef initialize(...)
    Rails.application.eager_load! unless Rails.application.config.eager_load
    ...

Rails 初期化中のオートロードの削除

config/initializers/ 以下の記述など、アプリケーションの初期化中にオートロード対象の定数を参照するコードがいくつか存在しました。こういったコードがあると以下のような警告が log/{environment}.log に出力されます。

DEPRECATION WARNING: Initialization autoloaded the constant FooBar.

Being able to do this is deprecated. Autoloading during initialization is going
to be an error condition in future versions of Rails.

Reloading does not reboot the application, and therefore code executed during
initialization does not run again. So, if you reload Development, for example,
the expected changes won't be reflected in that stale Module object.

This autoloaded constant has been unloaded.

Please, check the "Autoloading and Reloading Constants" guide for solutions.

https://github.com/rails/rails/blob/v6.0.4/railties/lib/rails/application/finisher.rb#L27-L29

メッセージやコードのコメントが示すように Rails 7 ではエラーになってしまうようです。なにより Rails 6 においても Zeitwerk 有効化している場合、初期化中に参照された定数は Classic オートローダーによりロードされたあと一度アンロードされ、 Zeitwerk によりロードされます。つまり以下のようなコードは意図したように動きません。

# config/initializers/foo_bar.rb
FooBar.set(:foo, 100)

# app/models/foo_bar.rb
class FooBar
  SET = {}

  def self.set(key, value)
    SET[key] = value
  end

  def self.get(key)
    SET[key]
  end
end

Rails が出すメッセージは警告ですが、このような仕様のため config/initializers/*.rb で記述した設定が正しく動いていなかったため、これらのコードは以下のようにアプリケーション初期化後に参照するよう修正しました。

Rails.application.config.after_initialize do
  FooBar.set(:foo, 100)
end

Zeitwerk 有効化には必須ではないが実施した変更

Zeitwerk 有効化するために必須だったわけではないですが、 Zeitwerk に切り替えるに辺りオートローダー周りの非推奨な記述もいくつか解消しておきました。

オートロード対象ファイルの require の削除

オートロード対象の定数を require している箇所がいくつかありました。オートロード対象の定数はオートローダーでロードさせるのが適切です。

Rails 6 のデフォルト設定では autoload_paths に追加されたディレクトリはロードパスにも追加されますが、この設定 (config.add_autoload_paths_to_load_path = true) は Rails Guides でも推奨されていません。 https://guides.rubyonrails.org/v6.0/configuring.html#rails-general-configuration

また、条件もよくわかっていないのですが不要な require があるとオートロードが正しく動作しないような挙動もあったため不要な require は全て削除しました。

おわりに

最終的な差分の量は 205 files changed, 583 insertions(+), 643 deletions(-) となりました。差分の行数はあまり多くないですが触ったファイルの数はそれなりの数になったかなという印象です。

実際はここに記述した各種変更についても一つずつ、場合によってはそれらもいくつかの Pull Request に分割し従来の開発の中に混ぜ込んで段階的にマージ&デプロイを進めていきました。現在のところ幸いにユーザに影響するような不具合が出ることもなく、無事に動いている様です。

今回の Zeitwerk 有効化は顧客への直接的な利益は全く生み出していませんが、システムの健康を回復させ、将来の開発の負荷を減らす一手ぐらいにはなったのではないかと思います。

このエントリを読んで興味を持った方は是非もう一歩進んで hey という会社に興味を持っていただきたければ幸いです。オンラインでのワイワイした Hello hey という会社説明会 も定期開催していますし、また個別にカジュアルにお喋りしたいという方は @hogelog へのメッセージもお待ちしております。

hey.jp

hello.hey.jp

*1:Rails 6.0へアップグレードしました - STORES Tech Blog

*2:はっきりと記述するドキュメントはまだ無いようですがhttps://github.com/rails/rails/commit/8db23109bf61052eef437629b6ef27a94e0b5bd9 などのコミット内容からするとそう進む様子が伺えます

*3:rails 6.1.0 から導入された config.rake_eager_load 設定をしていない限り