Zeitwerk を使いこなす
はじめに
ドメイン駆動設計(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つのファイルがある。
require "zeitwerk"
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
loader.setup
module Foo
p Bar
end
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.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
だった場合──
- パスカルケースに直せないか検討する
MAX -> Max
値の定数なのにパスカルケースなのは気持ち悪いというか Rubocop に怒られる? - それが厭なら
max.rb
をconfig.rb
に変更してFoo::Config::MAX
またはFoo::Config[:max]
の形にできないか検討する - それが厭なら親の
foo.rb
のなかでFoo::MAX
を定義する - それも厭なら
inflector.inflect("max" => "MAX")
とする
トップレベルのネームスペースを増やす
- 増やすというか省略?
- 普通の gem を作っているならパッケージ名と異なるネームスペースを生やしてはいけない
- Rails みたいなやつだったら
push_dir("#{__dir__}/models")
を追加する - すると
User
でmodels/user.rb
が反応する
トップレベルのパスとネームスペースが異なる
- foo ディレクトリなのに、そこのモジュールは Bar になっている場合
- まずそのおかしな構造を直そう
- どうしても無理なら
push_dir("foo", namespace: Bar)
とする
ネームスペースに含まれないディレクトリを含む
- 本家では「折りたたみ」と表現している機能
-
foo/a/x.rb
にFoo::X
がある場合-
collapse("foo/a")
でa
を省略できる
-
-
foo/a/b/x.rb
にFoo::X
がある場合-
collapse("foo/a", "foo/a/b")
でa/b
を省略できる -
collapse
を2つに分けて呼んでもいい
-
パスとネームスペースがまったく合ってない
- 合わせよう
- どうしても無理なら
push_dir("path/to/staff", namespace: Admin)
とする - と、
Admin::User
でpath/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)
すべてのファイルを先に読み込むには?
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 でありそうなエントリーポイントの例
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
テスト時にはすべてが正しくロードできるか検証したい
Zeitwerk::Loader.eager_load_all
とりあえず production で事故らないように1行入れておきたい。
正しくロードできるか検証したい (Rake編)
desc "ファイル名とモジュール名の対応付けが正しいことを検証する"
task "test:loader" do
require "my_gem"
Zeitwerk::Loader.eager_load_all
puts "Success"
end
本家
Discussion