freeeの開発情報ポータルサイト

freee 会計の Rails アプリケーションを Zeitwerk モードに移行しました

こんにちは。freee 会計の開発をしている chandai です。

この記事は freee Developers Advent Calendar 2023 の16日目の投稿になります。
早いもので今年も残り僅かですね。

私からは今年行った freee 会計の Zeitwerk モード移行について共有させていただきます。

目次

はじめに

freee 会計では現在 Rails 6.1 を使用しています。来たる Rails 7 系へのアップグレードに向けて、Rails のオートローダーを Zeitwerk モードに移行する必要がありました。
昨年行われた Rails 6.0 → Rails 6.1 へのアップグレードは以下の記事をご覧ください。

developers.freee.co.jp

詳細な移行方法については Rails ガイド 様の Classic から Zeitwerk への移行 がとても分かりやすいため、本記事では上記ガイドに則り、移行に際して実施した修正作業について記載していきます。

事前準備

移行時の準備として、手元の開発環境で以下の修正を行いました。

  • オートローダーを classic モードから zeitwerk モードに変更
# config/application.rb
config.autoloader = :zeitwerk
  • ローカル環境の config.eager_load を true に
# config/environments/development.rb
config.eager_load = true
  • bin/rails zeitwerk:check 実行時に非互換な箇所を全て出力するようローカルの gem にパッチをあてる

チェックコマンド実行時に非互換な箇所、または名前解決のできないコードに遭遇すると raise ないし NameError が発生してそこで止まってしまうため、
修正箇所の全体感を把握するためローカルに bundle install した zeitwerk gem に以下の修正を加えました。

# ../gems/zeitwerk-x.x.x/lib/zeitwerk/loader/callbacks.rb

-- raise Zeitwerk::NameError.new(msg, cref.last)
++ puts Zeitwerk::NameError.new(msg, cref.last)
# .../gems/zeitwerk-x.x.x/lib/zeitwerk/loader/helpers.rb


private def cget(parent, cname)
  parent.const_get(cname, false)
endprivate def cget(parent, cname)
  begin
    parent.const_get(cname, false)
  rescue NameError
    puts "NameError: uninitialized constant #{parent}::#{cname}"
  end
end

Zeitwerk 非互換な箇所の修正

bin/rails zeitwerk:check を実行して検知された Zeitwerk 非互換なコードの修正について羅列します。

大文字略語(Acronym) の修正

CSVImporter、SQLBuilder といった大文字略語(Acronym)のクラス(定数)名は zeitwerk モードによるクラスの推測が効かなくなるため CsvImporter、SqlBuilder のようなアッパーキャメルケースにするか、
下記の通り ActiveSupport:: Inflector を用いて独自のルールを設定することができます。

# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
  autoloader.inflector.inflect("csv" => "CSV")
  autoloader.inflector.inflect("sql" => "SQL")
end

私達としては基本的には公式の設定に準拠する方針であったため、特殊なケースを除いてはルールを設定せずアッパーキャメルケースにしていました。
一方で OmniAuth のように Gem 名がアッパーキャメルケースになっていないものをラップしているようなクラスでは、認知負荷を下げるためルールを設定してそのままにしました(omniauth はアッパーキャメルケースにすると Omniauth になります)。

ファイル名と定数名の不一致の修正

例として hoges_service.rb というファイル名にも関わらず HogeService というクラスが設定されている場合になります。
このようなケースでは bin/rails zeitwerk:check 実行時に以下のような分かりやすいメッセージが出力されるため、しらみ潰しに修正していました。

expected file /repository_name/app/services/hoges_service.rb to define constant HogesService, but didn't

また、以下のような既存のクラスを拡張したオープンクラスのファイルも同じく修正の対象になりました。

# app/models/user.rb
class User
  ...
end

# app/models/users/foo.rb
# MEMO: クラス名には Users::Foo が設定されるべきだがオープンクラスのためそうなっていない
class User
  self.foo
    puts "bar"
  end
end

こちらのケースではオープンクラスをモジュール化して、既存の挙動を壊さないよう対応しました。

# app/models/user.rb
class User
  include Users::Foo
  ...
end

# app/models/concerns/users/foo.rb
module Users::Foo
  extend ActiveSupport::Concern

  class_methods do
    def foo
      puts "bar"
    end
  end
end

クラスのないファイルにクラスを追加

以下のような定数のみ置かれているファイルも修正の対象になりました。

# lib/constant.rb
MAGIC_NUMBER = 100

# app/models/user.rb
require 'lib/constant.rb'

class User
  def number
    MAGIC_NUMBER
  end
end

こういったファイルは require して読み込んでいましたが、明示的に require せず、以下のようにクラスを付与してオートロードの対象になるよう変更しました。

# lib/constant.rb
class Constant
  MAGIC_NUMBER = 100
end

# app/models/user.rb
class User
  def number
    Constant::MAGIC_NUMBER 
  end
end

検証環境での動作確認中に起きた出来事

さて、一通り非互換な箇所の修正が完了し、動作確認のため検証環境にデプロイする際、一部の Gem で LoadError が発生してしまいました。

rake aborted!
LoadError: cannot load such file -- xxx
...

こちらの原因としては development にのみ bundle install していた Gem を require していたファイルが eager_load により読み込まれてしまい、インストールされていない検証環境で LoadError が発生したものになります。
上記のような Gem の require は development 下でのみ行うよう修正して事なきを得ました。

おわりに

1つ1つの修正の難易度は高くないものの、修正範囲がとても多く、移行作業にはそれなりの時間を要しました。とはいえ細かい単位でマージ & リリースを重ねたおかげか本番環境への反映後も zeitwerk 起因によるバグは見つかっておらず、今では胸を撫で下ろすばかりです。

さて、freee Developers Advent Calendar も後半戦に入っていますが明日からもまだまだ続きます!
Developers だけでなく SRE さんが主体となった freee 基盤チーム、QA エンジニアの皆さんによる freee QA、デザイナーの皆さんによる freee Designers のアドベントカレンダーも並行して執筆されておりますので、こちらも併せてご覧ください。

それでは明日の更新もお楽しみに〜👋