Zeitwerkの壊し方

この記事は[Ruby on Rails Advent Calendar 2019](https://qiita.com/advent-calendar/2019/rails)の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の提供するオートロードは以下の２つのニーズに応えたものだと考えられます。

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

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

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

* デフォルト設定であるオートロード（`config.eager_load = false`）で起動

```ruby
> 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`）で起動

```ruby
> ObjectSpace.count_objects
=> {:TOTAL=>2426444,
 :FREE=>107,
 :T_OBJECT=>110574,
 :T_CLASS=>24366,
 :T_MODULE=>4400,
 # ...
 :T_ICLASS=>18198}
```

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

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

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

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

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

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

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

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

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

[^1]: preloadingは[soft deprecate](https://github.com/fxn/zeitwerk/commit/8e3226511083c6e2ee261e5753effc79f9e28393)されました。（コードは存在するがドキュメントから消されています）
[^2]: 公式に[そう書いてあります](https://github.com/fxn/zeitwerk#introduction)が、細かい挙動は未確認・・

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

# Zeitwerkの壊し方

本題です。

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

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

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

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

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

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

```ruby
> Foo::Bar
NameError: uninitialized constant Foo::Bar
```


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

```ruby:app/models/foo.rb
module Foo
  # ...
end
```

という定義が必要です。（classでも同様）

#### （理由）

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

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

```ruby
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`ファイルのパスを収集します。

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

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

### 問題になるケース

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

#### A)大文字略語（Acronym）を使いたい場合

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

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

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

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

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

グローバルでルールを上書きすることを避けて、この変更の影響をZeitwerk内にとどめる方法は、[公式を参照](https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#customizing-inflections)してください。

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

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

```ruby: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すればええやん」という気分になってきます。

```ruby:app/lib/wrappers/credit_card_api.rb
class CreditCardAPIWrapper
 # ...
end
```

```ruby:app/controllers/payments_controller.rb
require 'wrappers/credit_card_api.rb' # コレ

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

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

```ruby
> 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を[調教する](https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#customizing-inflections)か、もしくはオートロード対象から外す（たとえば、デフォルトでautoload_pathsに入っていない`my_rails_project/lib/`以下に入れた上でrequireする）といった対応が考えられます。

## 4. require_dependencyを利用する

これは以前のRails（classic mode）において、preloadをするために存在した特殊メソッドです。
これは「ある場所において、特定のクラス・モジュールが必ずロードされていることを保証したい」というユースケースで使われていました。（たとえば２段以上の継承がある[STI](https://guides.rubyonrails.org/autoloading_and_reloading_constants_classic_mode.html#autoloading-and-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のユースケースは撲滅した](https://weblog.rubyonrails.org/2019/2/22/zeitwerk-integration-in-rails-6-beta-2/#require_dependency)、という話になっているようです。

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

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

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

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

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

```ruby: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である**として決め打ちします。

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

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

```ruby
> Admins::ItemsController
=> TypeError: Admins is not a class
```
怒られてしまいます。

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

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

```bash
 app
  └── controllers
       ├── admins.rb # コレ
       └── admins
           └── items_controller.rb
```

```ruby:app/controllers/admins.rb
class Admins # Adminsがクラスであることを明示する
end
```

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

```ruby: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で２段以上の継承をする

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

以前β版のドキュメントには全ての子クラスをpreloadしておくという[シンプルな対処法が書かれていたのですが](https://weblog.rubyonrails.org/2019/2/22/zeitwerk-integration-in-rails-6-beta-2/#stis)、いつの間にやらこの方法は消えてしまい、zeitwerkのドキュメントからも[soft deprecate](https://github.com/fxn/zeitwerk/commit/8e3226511083c6e2ee261e5753effc79f9e28393)されてしまいました。

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

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

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

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

### 8. config/initializersで定数を使う

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

Railsは一連のinitializerを実行した最後に、Rails::Application::Finisherを[呼び出しますが](https://github.com/rails/rails/blob/ed5f2c6192bd00c0552fcc98ad16463cf8ec293f/railties/lib/rails/application.rb#L368-L372)、ここで初めて[Zeitwerkが起動する](https://github.com/rails/rails/blob/02ecdf61d151ec3c852585c93942d0ea2ed2bd22/railties/lib/rails/application/finisher.rb#L30-L72)仕組みになっています。

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

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

### （おまけ）　ちょっとした高速化

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

```ruby
  config.add_autoload_paths_to_load_path = false
```

これにより、gemなどのrequire時の探索範囲にapp以下が含まれなくなるため、ほんのり[高速化や省メモリ化が見込める](https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#$load-path)ようです。もちろん開発環境の話。

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

## 最後に

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

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

レッツZeitwerk!