Rails: Zeitwerkオートロードの「1ファイルにクラスを複数置けない」問題を回避する(翻訳)
Railsのクラシックオートローダーは非推奨化されました。今後はデフォルトでzeitwerk gemが新しいオートロードに使われ、クラシックオートローダーは今後のリリースで廃止される予定です。クラシックオートローダーには既知の問題がいくつもあり、ドキュメントが揃っているにも関わらず厄介な落とし穴があったので、これはよいニュースです。この歓迎すべき変更によって、オートローダーの健全性がある程度取り戻されました。
惜しいことに、初期のZeitwerkには私が最も欲しい機能が含まれていませんでした。欲しかったのは「1ファイルに複数のクラスを置ける機能」です。
問題点
数年前の私は、これまで社内で使ってきたある「コンポーネント」(というか境界を持つコンテキストを表現する方法)のために、とあるパターンを記述しました。
以下は、そうしたコンポーネントのひとつであるScannerコンテキストのディレクトリ構成です。これはRailsアプリのディレクトリのトップレベルに配置されていてオートロードされます。
scanner/
├── lib
│ ├── scanner
│ │ ├── event.rb
│ │ ├── event_db.rb
│ │ ├── domain_events.rb
│ │ ├── scan_tickets_command.rb
│ │ ├── scanner_service.rb
│ │ ├── ticket.rb
│ │ ├── ticket_db.rb
│ │ └── version.rb
│ └── scanner.rb
└── spec
├── scan_tickets_command_spec.rb
├── scan_tickets_flow_spec.rb
└── spec_helper.rb
# config/application.rb
config.paths.add 'scanner/lib', eager_load: true
このscanner/lib/scanner/domain_events.rbに注目してみましょう。このファイルには、以下のようにScannerのサブドメイン内のドメインイベントを記述する小さなクラスがいくつも含まれています。
# scanner/lib/scanner/domain_events.rb
module Scanner
class TicketScanned < Fact
SCHEMA = {
vendor: String,
event_id: String,
barcode: String,
ticket_type: String,
scanned_at: Time,
terminal_name: String
}
end
class TicketAlreadyScanned < Fact
SCHEMA = {
vendor: String,
event_id: String,
barcode: String,
ticket_type: String,
scanned_at: Time,
terminal_name: String
}
end
# 以下省略
end
Scanner
モジュールファイルの末尾には以下のようにrequire_dependency
が置かれているので、このコードはクラシックオートローダーでは動きました。
# scanner/lib/scanner.rb
module Scanner
end
require_dependency 'scanner/version'
require_dependency 'scanner/domain_events'
require_dependency 'scanner/scanner_service'
require_dependency 'scanner/scan_tickets_command'
require_dependency 'scanner/ticket'
require_dependency 'scanner/ticket_db'
require_dependency 'scanner/event'
require_dependency 'scanner/event_db'
しかしZeitwerkベースのオートローダーでは、もはやrequire_dependency
が使えません。
回避方法
この制約はissue #51で既に知られており、Railsアップグレードガイドにも記載されています。
Rails 6.1以降に移行する場合、同じようなコードがあったらどんな手が使えるでしょうか?
1. 単一ファイル内の複数クラスが共通の名前空間を共有する
1つのファイルに含まれる複数のクラスが、ファイル名に対応付けられた共通のモジュール内でネストしていれば、Zeitwerkでも問題なく動きます。
# scanner/lib/scanner/domain_events.rb
module Scanner
module DomainEvents # ファイル名と一致させる
TicketScanned = Class.new(Fact)
TicketAlreadyScanned = Class.new(Fact)
# ...
end
end
私はネストを深くするのが好きではなく、名前空間はできるだけフラットにしておきたい方です。Scanner::DomainEvents::TicketScanned
という3階層のネストはかなり冗長です。
この方法は私の肌には合いませんが、これで問題ない人もいるでしょう。
2. collapse
したディレクトリ内のファイルごとに1つのクラスを配置する
別の方法は、「1ファイル1クラス」の哲学を貫くことです。これなら、関連するクラス同士をひとつのディレクトリ内でグループ化する形で編成できるようになります。このとき、Zeitwerkのcollapse
で除外されるディレクトリが不要な名前空間を表さないようにしておきます。
scanner/
├── lib
│ ├── scanner
│ │ └── domain_events
│ │ ├── ticket_scanned.rb
│ │ └── ticker_already_scanned.rb
│ │ ├── event.rb
│ │ ├── event_db.rb
│ │ ├── scan_tickets_command.rb
│ │ ├── scanner_service.rb
│ │ ├── ticket.rb
│ │ ├── ticket_db.rb
│ │ └── version.rb
│ └── scanner.rb
└── spec
├── scan_tickets_command_spec.rb
├── scan_tickets_flow_spec.rb
└── spec_helper.rb
そしてscanner/lib/scanner/domain/events
ディレクトリのdomain/events
を名前空間から除外するようオートローダーに指示します。
# config/initializers/zeitwerk.rb
SUBDOMAINS = %w(
scanner
# ...
)
Rails.autoloaders.each do |autoloader|
SUBDOMAINS.each do |sub|
domain_events_dir =
Rails.root.join("#{sub}/lib/#{sub}/domain_events")
autoloader.collapse(domain_events_dir)
end
end
この方法のメリットは、Scanner::TicketScanned
のように名前空間をそのまま維持できることです。複数のクラスも、単一ファイルに置かれているわけではありませんが、グループ化されます。
明らかなデメリットは「1ファイル1クラス」という宗教です。この点がまったく気にならない人もいるでしょうし、実際何の問題もありません。
3. オートロードをやめる
Remove zeitwerk, explicit require list. ;)
— Markus Schirp (@_m_b_j_) March 18, 2021
Railsのオートロードをオプトアウトするのは、おそらくこれまで以上に簡単です。Zeitwerkでは特定のディレクトリを無視するようオートローダーに指定できます。
# config/initializers/zeitwerk.rb
SUBDOMAINS = %w(
scanner
# ...
)
Rails.autoloaders.each do |autoloader|
SUBDOMAINS.each do |sub|
autoloader.ignore(Rails.root.join(sub))
end
end
本記事の方法は自由にご活用いただけます。お気づきの点がありましたら私のTwitterまでコメントをどうぞ。
お知らせ
ARKADEMY.DEVに参加してArkencyのトップクラス教育プログラムコースにアクセスしましょう!「Railsアーキテクトマスタークラス」「アンチ"IF"コース」「忙しいプログラマーのためのブログ執筆コース」「Async Remoteコース」「TDD動画クラス」「ドメイン駆動Rails動画コース」以外にもさまざまなコースが新設中です。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。