🦎

Zeitwerk を使いこなす

2022/12/03に公開約8,900字

ドメイン駆動設計(DDD)を実戦するには躊躇なくクラスを作れないといけない。ところがクラスを作るとなるとまずファイルを作るので、そのファイルをどこで require するかという瑣末な悩みが生まれる。

require はエントリーポイントで書くべきか、それとも特定のスコープで書くべきか。またクラス名の変更に合わせてファイル名を変更すると、さらに require した場所まで書き換えないといけない。require する順番にも気をつかわないといけない。この煩わしさが少しでも障壁になってしまうと気持ちよくクラスを作れなくなる。

そこで Zeitwerk が役立つ。require にまつわる悩みなくし、新しいクラスの作成だけに集中できる。つまり Ruby で DDD するには Zeitwerk が欠かせない。

主用なメソッドまとめ

基本
Method 意味
push_dir("path/to") 指定パス以下を対象とする
setup ファイル名から推測して autoload を設定する
どうにかして辻褄を合わせる系
Method 意味
collapse("a/b") a/b/c.rb は A::C なので b を捨てたい
push_dir("x", namespace: Y) x ディレクトリなのにモジュール名は Y になっている
inflector.inflect("foo" => "FOO") foo.rb に FOO が定義されている
先に読み込んでおく系
Method 意味
eager_load すべて読み込む (手動ですべて require するのと同じ)
do_not_eager_load("path/to") eager_load 対象外とする。滅多に使わない重いやつを指定する
eager_load(force: true) 限界まですべて読み込む (do_not_eager_load に勝つ)
eager_load_dir("path/to") 指定のディレクトリ以下だけすべて読み込む
load_file("path/to/x.rb") 指定のファイルを読み込む
eager_load_namespace(Foo) 指定のモジュール名以下だけすべて読み込む
.eager_load_all すべてのインスタンスに eager_load を指示する (Class Method)
リロード系
Method 意味
enable_reloading あとで reload を呼びたいときは先に有効にしておく
reload 読み直す。setup 前に enable_reloading しておく
フック系
Method 意味
on_setup setup 直後に呼ぶ
on_load 特定の定数を解決した直後に呼ぶ
on_unload reload によって定数を remove_const する直前に呼ぶ
デバッグ用
Method 意味
log! ログを表示する (デフォルトでは $stdout へ)
tag = "xxx" インスタンスが複数あったときにログが見やすくなる
dirs どこが管理下になっているか? 何を push_dir したか? を確認できる
.all_dirs すべてのインスタンスの dirs を集める (Class Method)
その他
Method 意味
ignore("path/to") 指定のファイルは除外する

エントリーポイントを直接実行してはいけない

これはドキュメントに書かれていないので Rails 以外で使いたいときに躓く方は多いと思われる。
まず2つのファイルがある。

foo.rb
require "zeitwerk"
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
loader.setup

module Foo
  p Bar
end
foo/bar.rb
module Foo
  Bar = "OK"
end

ここでエントリーポイントの foo.rb を実行するとエラーになる。

$ ruby foo.rb
in `block (3 levels) in raise_if_conflicting_directory': loader (Zeitwerk::Error)
wants to manage directory foo, which is already managed by (snip)

エントリーポイントであるにもかかわらずエラーになる。
もっと言えばドキュメント通りに書いたのにエラーになる。
で、結局原因がわからなくて作者の方に教えて頂いた方法がこれ。

$ ruby -I. -rfoo -e1
"OK"

書き方ではなく実行方法が間違っていた。

どうしても require できないときは ignore(__FILE__) として Zeitwerk の管理下から外す手もあるが、作者が意図した ignore の使い方とは異なる。

まとめると push_dir(__dir__) によって自分も Zeitwerk の管理下にあるとき、自分は require によって読み込まれないといけない。

モンキーパッチを当てるだけみたいなファイルはどうする?

foo_ext.rb
Foo.module_eval do
  # ...
end

このようなファイルは読み込んだからといって FooExt が定義されるわけではない。
こういうときどうするか?

  • ignore("path/to/foo_ext.rb") として Zeitwerk の管理下から外す
  • foo_ext.rb は自分で require する

テストファイルも lib 以下に置いてますが?

関心ごとが同じものは同じ場所にあるべき。実コードとそれをテストするコードは一心同体なのにテストだけ spec に分けるのはおかしい。同じファイルにテスト書いちゃってる Rust を見習え。技術駆動パッケージングは悪。という考えで例えば lib のなかに foo.rb と foo_spec.rb が混在していたとする。

この場合も foo_spec.rb を読み込んだからといって FooSpec ができるとは思えないので ignore("path/to/**/*_spec.rb") として Zeitwerk の管理下から外す。

どうにかして辻褄を合わせる系

アッパーケースな定数だけあるファイルにどう対処する?

foo/max.rb を読み込んで解決する定数が Foo::Max ではなく Foo::MAX だった場合──

  1. パスカルケースに直せないか検討する MAX -> Max
    値の定数なのにパスカルケースなのは気持ち悪いというか Rubocop に怒られる?
  2. それが厭なら max.rbconfig.rb に変更して Foo::Config::MAX または Foo::Config[:max] の形にできないか検討する
  3. それが厭なら親の foo.rb のなかで Foo::MAX を定義する
  4. それも厭なら inflector.inflect("max" => "MAX") とする

トップレベルのネームスペースを増やす

  • 増やすというか省略?
  • 普通の gem を作っているならパッケージ名と異なるネームスペースを生やしてはいけない
  • Rails みたいなやつだったら push_dir("#{__dir__}/models") を追加する
  • すると Usermodels/user.rb が反応する

トップレベルのパスとネームスペースが異なる

  • foo ディレクトリなのに、そこのモジュールは Bar になっている場合
  • まずそのおかしな構造を直そう
  • どうしても無理なら push_dir("foo", namespace: Bar) とする

ネームスペースに含まれないディレクトリを含む

  • 本家では「折りたたみ」と表現している機能
  • foo/a/x.rbFoo::X がある場合
    • collapse("foo/a")a を省略できる
  • foo/a/b/x.rbFoo::X がある場合
    • collapse("foo/a", "foo/a/b")a/b を省略できる
    • collapse を2つに分けて呼んでもいい

パスとネームスペースがまったく合ってない

  • 合わせよう
  • どうしても無理なら push_dir("path/to/staff", namespace: Admin) とする
  • と、Admin::Userpath/to/staff/user.rb が反応する
    • push_dir する前に Admin モジュールは定義しておく
    • path/to/staff/user.rb では Admin::User と書いておく

複数の Zeitwerk を共存できる?

  • できる
  • Loader.new はいろんなところで実行してもいい
  • Rails 側で使っているので利用している外部 gem で Zeitwerk を使えないなんてことはない

Zeitwerk を入れ子にできる?

  • できない
  • 親ディレクトリで有効にし子ディレクトリでも再度有効にするとエラーになる
in `block (3 levels) in raise_if_conflicting_directory': loader (Zeitwerk::Error)

すべてのファイルを先に読み込むには?

foo.rb
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
# loader.do_not_eager_load("#{__dir__}/path/to")
loader.setup

module Foo
end

loader.eager_load
# loader.eager_load(force: true) なら do_not_eager_load に勝つ
  • 通常は Foo::Bar を参照したときに foo/bar.rb を読み込むが eager_load するとそのタイミングですべて読み込む
  • ただし do_not_eager_load であらかじめ指定しておいたものは除外できる
    • do_not_eager_load("#{__dir__}/foo/bar.rb") のようにディレクトリやファイルを指定する
  • そこで eager_load(force: true) とした場合は do_not_eager_load の指定も無視して本当にすべて読み込む
  • Zeitwerk::Loader.eager_load_all だとすべてのインスタンスが反応する
  • 読み込み方法はいろいろある
    • 特定のディレクトリ以下だけ: eager_load_dir("#{__dir__}/path/to")
    • 特定のファイルだけ: load_file("#{__dir__}/path/to/foo.rb")
    • 特定のモジュール名以下だけ: eager_load_namespace(Foo)
      • foo/bar.rb を読み込まないと Foo モジュールにメソッドが生えない場合などで使う

リロードするには?

loader.enable_reloading
loader.setup

loader.reload # 任意のタイミングで呼ぶ
  • setup 前に enable_reloading が必要
  • reload は eager_load するわけではない
  • 単に setup 直後の状態になる

何かのタイミングで何かする系

setup 直後に何かするには?

loader.on_setup do
  # ...
end

指定の定数がロードされたあとで何かするには?

# 特定の定数の場合
loader.on_load("Foo::Bar") do |klass, abspath|
  [klass, abspath] # => [Foo::Bar, "/path/to/foo/bar.rb"]
end

# すべて
loader.on_load do |name, klass, abspath|
  [name, klass, abspath] # => ["Foo::Bar", Foo::Bar, "/path/to/foo/bar.rb"]
end
  • 引数を klass としているがクラスとは限らない
  • reload したあと、再度定数を参照するとまた呼ばれる

reload する直前に何かするには?

  • on_unload になるだけで on_load と書き方は同じ
  • ただしアンロードの途中で呼ばれるので再ロード可能な定数を参照してはいけないらしい

対象のディレクトリを確認するには?

push_dir したものをあとで確認したいときに使う

# 基本
loader.dirs                     # => ["/path/to/my_gem/lib"]

# 対応する定数つき
loader.dirs(namespaces: true)   # => {"/path/to/my_gem/lib"=>Object}

# 2.6.7 で対応予定
loader.dirs(ignored: true)      # => ["/path/to/my_gem/lib"]

# すべてのインスタンスが対象
Zeitwerk::Loader.all_dirs       # => ["/path/to/my_gem/lib"]

挙動を把握するには?

loader.log!
loader.logger = -> msg { Pathname("zeitwerk.log").open("a") { |f| f.puts(msg) } }
loader.logger = Logger.new("zeitwerk.log")
  • log! だけで標準出力にログを吐くようになる
  • logger でカスタマイズできる
  • インスタンスが複数あるときは logger.tag = "xxx" で判別しやすくなる

実際の gem でありそうなエントリーポイントの例

~/src/my_gem/lib/my_gem.rb
require "active_support/tagged_logging"
require "kconv"

require "zeitwerk"
loader = Zeitwerk::Loader.for_gem
loader.ignore("#{__dir__}/my_gem/logger.rb")
loader.ignore("#{__dir__}/my_gem/errors.rb")
loader.setup

require "my_gem/logger"
require "my_gem/errors"

loader.eager_load

テスト時にはすべてが正しくロードできるか検証したい

spec_helper.rb
Zeitwerk::Loader.eager_load_all

とりあえず production で事故らないように1行入れておきたい

正しくロードできるか検証したい (Rake編)

Rakefile
desc "ファイル名とモジュール名の対応付けが正しいことを検証する"
task "test:loader" do
  require "my_gem"
  Zeitwerk::Loader.eager_load_all
  puts "OK"
end

本家

https://github.com/fxn/zeitwerk

Discussion

ログインするとコメントできます