Help us understand the problem. What is going on with this article?

Zeitwerkの壊し方

この記事はRuby on Rails Advent Calendar 2019の13日目です。

インフルエンザにやられて完全に出遅れました。ごめんなさいごめんなさい。

Zeitwerkとは

みなさん、定数解決してますか?

Rails6から導入されたZeitwerk、旧来のconst missingを利用した仕組み(classic mode)で見られた困った挙動のほとんどを解決してくれる素晴らしいヤツですね。

クラスを定義しても定義しても一向に参照されず、星空見つめてすすり泣いたあの日はもう過去のもの 🎉

ここでは、Zeitwerkのドキュメント、およびコードリーディングを通じて気づいた、導入の注意点やTIPSなどを紹介してみたいと思います。

(検証環境)
  • Ruby 2.6.5
  • Ruby on Rails 6.0.0

前提知識として、Rubyの定数解決の仕組みを多少理解していると良い・・かもしれません。

https://qiita.com/fursich/items/a1b742795cf10eebc73f

(手前味噌ですが、昨年のアドベントに解説を書いたので参考になれば)

そもそも何のための仕組み

開発環境で使える便利機能です。
具体的には、Railsの提供するオートロードは以下の2つのニーズに応えたものだと考えられます。

1) 遅延ロードによる起動の高速化

全ファイルをロードするとRailsの起動が重くなります。
そこで、開発環境では最低限必要なモノだけ読み込んで起動、後は必要に応じて遅延ロードという作戦をとっています。

ちなみに手元の適当なプロジェクト(開発歴3年くらいアプリケーション)で実験してみると・・

  • デフォルト設定であるオートロード(config.eager_load = false)で起動
> ObjectSpace.count_objects
=> {:TOTAL=>1741661,
 :FREE=>461662,
 :T_OBJECT=>63968,
 :T_CLASS=>15941,
 :T_MODULE=>2280,
 # ...
 :T_ICLASS=>3236}
  • 本番環境で使われれるeager_load(config.eager_load = true)で起動
> ObjectSpace.count_objects
=> {:TOTAL=>2426444,
 :FREE=>107,
 :T_OBJECT=>110574,
 :T_CLASS=>24366,
 :T_MODULE=>4400,
 # ...
 :T_ICLASS=>18198}

起動直後に定義されるクラス数は1.5倍くらいに増えるのがわかります。
オートロードがない場合、これらの読み込みやオブジェクト生成コストが起動時間を増加させ、メモリ消費量を圧迫します。

2) コード修正時の再ロード

モデルやコントローラを書き換えるたびに サーバを落として再起動をかけるのは面倒です。
Railsは、クラス定義ファイルを遅延読み込みするだけではなく、再ロードによる(擬似的な)更新を行ってくれます。

  • ファイルを監視して変更があったらreloadを行う
  • reload時に定義された定数を一度削除して新たに定義し直す

逆にデプロイ後にコードが変更されることがなく、スレッドセーフであることが大切な本番環境においては必要とされない機能になります。

オートロード有効時にconfig.cache_classes = false という設定によりリロード可能になっています。

Zeitwerkで何が変わったのか (TL;DR)

Rubyによる定数解決の失敗を意味する const_missing によって起動していたRailsのオートロード機能が、Rubyの Module#autoload を利用するようになりました。
これにより、端的に言えば「直感的にわかりにくいコーナーケースに悩むことが減る」という話になります。

             Zeitwerk    classic mode 
発火タイミング autoload
(定数参照)
const_missing
(定数探索失敗)
探索方法 ファイル名から
定数を推測
定数名から
ファイルを探索
定数探索順 Rubyの
探索順に従う
Rails独自
定数のpreload Kernel.#require
または
Zeitwerk::Loader
#preload1
require_dependency
マルチスレッド スレッドセーフ2 非スレッドセーフ

このあたりの挙動をつかむためには、まずRubyの定数解決の仕組みを把握した上で、さらにRailsのautoloadはRubyのautoloadとは異なっていて・・・などという魔境が広がっていましたが、Rubyの挙動を理解していればすむようになりました。

Zeitwerkの壊し方

本題です。

Zeitwerkはほとんどのケースで何も考えずに使えるスグレものですが、いくつかの注意点が存在します。

Rubyistの皆さまに楽しい定数解決ライフをエンジョイしていただくべく、ドキュメントやソースコードから読み取れる失敗例をみていきたいと思います。

1. 名前空間にClass.new/Module.newを使う

名前空間として利用されるクラス・モジュールの定義については、 class / moduleキーワードを使う必要があります。
つまり、Foo::Barというクラスを定義したいなら、Fooという名前空間の定義については

app/models/foo.rb
Foo = Module.new do
  # ...
end

という定義を使うとうまくオートロードが効かなくなります。

> Foo::Bar
NameError: uninitialized constant Foo::Bar

(Zeitwerkが、Foo::BarといったFoo配下のクラスについて正しくロードできなくなる)

app/models/foo.rb
module Foo
  # ...
end

という定義が必要です。(classでも同様)

(理由)

ネストされた定数のオートロードを設定するためには、まず名前空間を与えるクラス(モジュール)が定義されたタイミングで、そのクラス(モジュール)配下の定数についてオートロードを設定しに行く必要があります。

Zeitwerkは名前空間となるクラス(モジュール)定義のイベントをフックするために・・

module Zeitwerk
  module ExplicitNamespace # :nodoc: all
    class << self
      include RealModName
      # ...

      def tracepoint_class_callback(event)
        # ...snip
      end
    end

    @tracer = TracePoint.new(:class, &method(:tracepoint_class_callback)) # コレ
  end
end

TracePointで引っ掛けています(ビックリ)

このイベント:classはModule.newやClass.newには反応しないため、名前空間の定義にmodule/classキーワードが必要という実装になっています。

2. ファイル名と異なる定数を定義する

ハマりやすいポイントですね。

上で述べた通り、今までのオートロードが定数名からファイル名を推測してロードしていたのに対し、ZeitwerkはまずActiveSupport::Dependencies.autoload_pathsに存在するファイル名(含む相対パス)から定数名を推定していきます。

たとえばフォルダ構成だと・・・

デフォルト設定では、Zeitwerkはapp/controllersフォルダをルートフォルダとして、その配下にある.rbファイルのパスを収集します。

 app
  └── controllers
       └── admins
            └── items_controller.rb

admins/items_controller.rbというファイルを発見すると、そのパス名から、ここに 「Admins::ItemsController の定義がある」ことを推測し、オートロードを設定します。
(いままでは [捜索したい定数名].underscore をして該当するファイルを読みに行ってましたが、Zeitwerkでは [パス名].camelize をして該当する定数のオートロードを設定する)

問題になるケース

以下のように、定数とパス名が一致しないケースがあります。

A)大文字略語(Acronym)を使いたい場合

たとえば、 api_controller.rb に、 APIController という定数名が定義されている場合です。

> APIController.name.underscore # 定数名からファイル名は正しく類推できる
=> "api_controller"

> 'api_controller'.camelize # ファイル名から定数名の推測は失敗
=> "ApiController" #APIではなくApiとなってしまう

この手の大文字からなる略語(典型的にはAPI、HTMLといった単語)について、camelizeが失敗します。
これはActiveSupport::Infectorにacronymを定義することで対応可能です。

config/initializers/zeitwerk.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "API"
end

グローバルでルールを上書きすることを避けて、この変更の影響をZeitwerk内にとどめる方法は、公式を参照してください。

B)ファイル名と異なる定数名を使いたい場合

一部のファイルに異なる定数名を使いたい場合、camelizeに独自ルールを上書きしたInflectorを作成することで対応できます。

config/initializers/zeitwerk.rb
class MyInflector < Zeitwerk::Inflector
  def camelize(basename, abspath)
    if abspath.match? /\/foobar\//
      basename.gsub!('foobar', 'hoge')
    end
    super
  end
end

Rails.autoloaders.each do |autoloader|
  autoloader.inflector = MyInflector.new
end

たとえばprotobufにおける自動生成ファイル(*_pb.rb)がこのケースにあてはまりそうです。
こちらの記事に詳しい対処法が書いてあります。

https://qiita.com/moonstruckdrops@github/items/ad467f3149e0154b5b61#rails6%E3%81%AE%E5%A0%B4%E5%90%88

(参考にさせていただきました。ありがとうございます)

3. オートロード対象パスでrequireを使う

定数名とファイルパスが違うとオートロードが面倒くさいので、「もう直接ファイルをrequireすればええやん」という気分になってきます。

app/lib/wrappers/credit_card_api.rb
class CreditCardAPIWrapper
 # ...
end
app/controllers/payments_controller.rb
require 'wrappers/credit_card_api.rb' # コレ

class PaymentsController < ApplicationController
  def create
    # ..snip
    CreditCardAPIWrapper.call(foo,bar)
  end
end

コードを見ると、実はこれでも動くようですが、require以前に定数を叩かれると、間違ったオートロードが発動する可能性があることに注意が必要です。

> Wrappers::CreditCardApi
=> Zeitwerk::NameError: expected file /Users/hogehoge/my_rails_project/app/lib/wrapper/credit_card_api.rb to define constant Wrapper::CreditCardApi, but didn't

(さらに細かい話をいうと、下で触れているオプションconfig.add_autoload_paths_to_load_path=falseを設定した時に、app以下のrequireが探索対象から外れるため死にます)

ここは真っ当にZeitwerk::Inflectorを調教するか、もしくはオートロード対象から外す(たとえば、デフォルトでautoload_pathsに入っていないmy_rails_project/lib/以下に入れた上でrequireする)といった対応が考えられます。

4. require_dependencyを利用する

これは以前のRails(classic mode)において、preloadをするために存在した特殊メソッドです。
これは「ある場所において、特定のクラス・モジュールが必ずロードされていることを保証したい」というユースケースで使われていました。(たとえば2段以上の継承があるSTIにおいて必要)

というのも、classic_modeではファイル読み込みにrequireではなくKernel.#loadを使うことでリロード可能にするという仕組みがあるため、うっかり require / require relative を直接使うと

  • ファイルが$LOADED_FEATURESに加わることでリロード対象外になってしまう(リロードしても変更が反映されない)
  • 一方、対象ファイルがオートロード済みだった場合には、二重ロードされてしまう

という問題がありました。
つまりrequire_dependencyは、「開発環境でのオートロードと喧嘩しないように計算されたpreload用のメソッド」です。

Zeitwerkは Kernal.#require をいい感じに上書きしてしまうことで、require_dependencyを不要にしています。

  • Zeitwerkは、requireされたファイルを記憶していて、リロード直前に$LOADED_FEATURESから削除する
  • すでに対象ファイルがオートロード済みだった場合には、requireが失敗するため単純にロードされない
  • (そもそもの話として)RailsのオートロードがRubyの仕組みに乗っているため、おかしな定数解決がおこらず、人為的にpreloadする必要性はない

そんなわけでrequire_dependencyのユースケースは撲滅した、という話になっているようです。

止むを得ない場合はrequireを直接叩く手もありますが、先ほども書いたとおり、なるべくautoloadの仕組みにまるっと乗せるのがよいと思われます。

5. 名前空間としてクラスを使う

これはどういうことかというと、

 app
  └── controllers
       └── admins
            └── items_controller.rb

仮にこういうフォルダ構成があったとすると、Zeitwerkは、items_controller には以下のような定数が定義されていると予測します。

app/controllers/admins/items_controller.rb
module Admins # この名前空間はモジュールと仮定される
  class ItemsController # ここはclass、moduleどちらでも構わない
  end
end

app/controllersにはadmins.rbというファイルがないため、定数Adminsがmoduleなのかclassなのかは(items_controller.rbをロードしてみるまでは)判断できません。

しかし、先にAdminsを定義してオートロードを設定する必要があるため、Zeitwerkは事前定義されてない名前空間は全てmoduleであるとして決め打ちします。

つまり、こういうものを書いていると・・・

app/controllers/admins/items_controller.rb
class Admins # 外側をクラスにする
  class ItemsController # ここはclass・moduleどちらでも構わない
  end
end
> Admins::ItemsController
=> TypeError: Admins is not a class

怒られてしまいます。

> Admins::ItemsController
=> TypeError: Admins is not a class

なお、外側の名前空間をclassにしたい場合は、明示的な定義ファイルをおけば対応可能です。

 app
  └── controllers
       ├── admins.rb # コレ
       └── admins
           └── items_controller.rb
app/controllers/admins.rb
class Admins # Adminsがクラスであることを明示する
end

この時Zeitwerkは、以下のような名前空間を仮定します。

app/controllers/admins/items_controller.rb
class Admins # 名前空間は上で定義されたクラス
  class ItemsController # ここはclass・moduleどちらでも構わない
  end
end

なぜこんなことになるかというと、「items_controller.rbのオートロードを設定するために、オートロードを設定する対象となるクラス・モジュールが必要で、定数Adminsは先行して定義せざるを得ない」というハナシだと思われます。

6. オートロード対象外の定数を参照する

典型的にはlib以下に定義した定数を参照する、みたいな話ですね。
ここはclassic mode同様、デフォルトではオートロード対象になっていないので、requireしなければuninitialized constantになります。

この対処については(以前と変わらず)、config.autoload_pathsに対象フォルダを加えることで実現できます。(ただしconfig.eager_load_pathsにも加えないと本番で死にます)

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#autoload-paths

use at your own risk. という注意書きがありますね。

7. STIで2段以上の継承をする

ほとんどのケースをいい感じにしてくれたZeitwerkですが、残念ながらここだけは難しくなってしまいました。

以前β版のドキュメントには全ての子クラスをpreloadしておくというシンプルな対処法が書かれていたのですが、いつの間にやらこの方法は消えてしまい、zeitwerkのドキュメントからもsoft deprecateされてしまいました。

というわけで、現時点ではかなりゴニョゴニョしたやり方で、dbからtypeカラムを全部抜いてきてeager_loadすることを強いられるようです。

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#single-table-inheritance

(ぱっと見、constantizeが冗長に見えるけどどうなんだろう)

1層の継承であればこれが必要になることはなさそうですが、手元で試せていないので自己責任で・・

8. config/initializersで定数を使う

initialiizers実行時はZeitwerkのオートロードが有効になっていません。

Railsは一連のinitializerを実行した最後に、Rails::Application::Finisherを呼び出しますが、ここで初めてZeitwerkが起動する仕組みになっています。

(移行措置として、Rails6.0時点ではclassic modeの仕組みでオートロードを発動してくれますが、deprecatoin warningが出ます。またFinisherの中でオートロードされた定数はいったんunloadされてしまうので、注意が必要です。)

考えてみると、initializersで定数を使うときは、リロードを考えないため、オートロードの必要がありません。
したがって、他のファイルで定義される定数を使いたい場合は、普通にrequireする必要があります。

(おまけ) ちょっとした高速化

オートロードの対象ファイルについては明示的にrequireすることがないため、$LOAD_PATHに含めないという設定が可能です。

  config.add_autoload_paths_to_load_path = false

これにより、gemなどのrequire時の探索範囲にapp以下が含まれなくなるため、ほんのり高速化や省メモリ化が見込めるようです。もちろん開発環境の話。

ただこのオプションを有効にした場合は、app/以下のファイルに対してrequireを叩くと見つからないので怒られます。(オートロードしましょう)

最後に

Zeitwerkの発音がわかならい?
そんなあなたのために、公式リポジトリには音声ファイルが存在します。

https://github.com/fxn/zeitwerk/tree/master/extras

レッツZeitwerk!


  1. preloadingはsoft deprecateされました。(コードは存在するがドキュメントから消されています) 

  2. 公式にそう書いてありますが、細かい挙動は未確認・・ 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした