Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Zeitwerkオートロードの「1ファイルにクラスを複数置けない」問題を回避する

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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. オートロードをやめる

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動画コース」以外にもさまざまなコースが新設中です。

関連記事

Railsコンソールの思いがけない便利技(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。