Rails: Zeitwerkで使えるカスタムinflectorの作り方4種(翻訳)
前回の記事では、クラシックオートローダーとZeitwerkオートローダーがファイル名を照合するときの違いについて解説しました。両者の違いを取り急ぎ以下にまとめておきます。
- クラシックオートローダー
Report::PL::X123
という定数名が見つからない場合、Report::PL::X123.to_s.underscore
を呼び出すことでファイル名にマッピングする。- Zeitwerkオートローダー
lib/report/pl/x123/products.rb
を見つけ、Zeitwerkで定義されている語形変化(inflection)ルールを利用してReport::PL::X123::Products
という定数名にマッピングする。
🔗 inflectorとは何か
一般に、inflectorは事前定義済みのルールに沿って語を変換するソフトウェアコンポーネントです。Ruby on RailsのようなWebフレームワークのコンテキストでは、「複数形への変換」「単数形への変換」「頭字語の処理」「属性名をhumanize
で総合的に変換する」といった言語上の変換にinflectorが使われます。
Rails::Autoloader::Inflector
は、ZeitwerkとRailsの統合ではデフォルトで使われます。
module Rails
class Autoloaders
module Inflector # :nodoc:
@overrides = {}
def self.camelize(basename, _abspath)
@overrides[basename] || basename.camelize
end
def self.inflect(overrides)
@overrides.merge!(overrides)
end
end
end
end
上のcamelize
メソッドは、@overrides
に名前basename
があるかどうかをチェックして、あればそれを使い、なければString#camelize
メソッドを呼び出します。String#camelize
メソッドは、Active SupportのString
コア拡張です。
def camelize(first_letter = :upper)
case first_letter
when :upper
ActiveSupport::Inflector.camelize(self, true)
when :lower
ActiveSupport::Inflector.camelize(self, false)
else
raise ArgumentError, "Invalid option, use either :upper or :lower."
end
end
上のコードでわかるように、String#camelize
は内部でActiveSupport::Inflector
に委譲しています。
ActiveSupport::Inflector
は最初期のRailsから存在しており、語を単数形から複数形に変換したり、クラス名をテーブル名に変換したり、モジュール化されたクラス名をモジュール化されていないクラス名に変換したり、クラス名を外部キーに変換したりするのに使われます。
しかしZeitwerkのコンテキストにおけるinflectorの重要な機能は、頭字語(2個以上の大文字でできている語)の処理です。
頭字語の例として「REST」(Representational State Transfer)を考えてみましょう。API::REST::Client
などのように頭字語が定数に含まれることは珍しくありません。
クラシックオートローダーの場合、未定義の定数API::REST::Client
に遭遇すると、 API::REST::Client.to_s.underscore
を呼び出して、オートロード済みのディレクトリの中から api/rest/client.rb
ファイルを見つけます。
一方Zeitwerkは、api/rest/client.rb
ファイルを見つけて'api/rest/client'.camelize
を呼び出します。頭字語を処理するルールがない場合は、Api::Rest::Client
という定数名を得ます。
頭字語を小文字に変えないAPI::REST::Client
という結果を得るには、頭字語処理ルールにinflectorを提供する必要があります。本記事では、この結果を得られる方法を4通り紹介します。
🔗 1. ActiveSupport::Inflector
を直接設定する
よく使われる直感的な方法は、ActiveSupport::Inflector
を直接設定する方法です。ただしこの方法では、Active Supportのグローバルな語形変化に影響するので、必ずしも望ましくない可能性があります。
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'API'
inflect.acronym 'REST'
end
2. Rails::Autoloader::Inflector
をコンフィグでオーバーライドする
場合によっては、特定のクラス名やモジュール名に関するルールをActive Supportの語形変化に加えたくないこともあります。実はそんなことをしなくても、Zeitwerkでのみ特定の語形変化をオーバーライドし、Railsのグローバルな語形変化を変更しないようにする方法があります。ただし、指定のキーが見つからない場合はString#camelize
とActiveSupport::Inflector
に引き続きフォールバックします。
# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector.inflect(
"api" => "API",
"rest" => "REST",
)
end
🔗 3. Zeitwerk::Inflector
を利用する
Zeitwerk gemはRailsから独立して利用できるよう設計されており、Rails::Autoloader::Inflector
の代わりに利用可能なinflectorの別実装を提供しています。これを使うことで、モジュール名やクラス名の命名規則で使う頭字語を1箇所で完全に制御できるようになります。さらに、Active Supportの汎用inflectorがオートローダー固有のルールで汚染されることも避けられるようになります。
# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector = Zeitwerk::Inflector.new
autoloader.inflector.inflect(
"api" => "API",
"rest" => "REST",
)
end
🔗 4. カスタムinflectorを実装する
API::REST::Client
定数に加えてUser::Activities::Rest
定数もコードベースに存在する状況を考えてみましょう。
どちらの定数にも/rest/i
という部分文字列が含まれていますが、同じ語形変化規則を使ってファイル名からこれらの定数名を導出することはできません。
これは、カスタムinflector実装を提供する必要が生じる良い例となります。
理解を深めるために、Rails標準のRails::Autoloader::Inflector#camelize
メソッドの実装をもう一度見てみましょう。
def self.camelize(basename, _abspath)
@overrides[basename] || basename.camelize
end
見てのとおり、このメソッドはbasename
と_abspath
という2つの引数を受け取ります。basename
は拡張子を除いたファイル名、_abspath
はそのファイルへの絶対パスです。
この_abspath
は、Rails::Autoloader::Inflector
実装でもZeitwerk::Inflector
実装でも未使用になっている点にご注目ください。
カスタム実装では、この_abspath
引数の存在を利用できます。
# config/initializers/zeitwerk.rb
class UnconventionalInflector
def self.conditional_inflection_for(basename:, inflection:, path:)
Module.new do
define_method :camelize do |basename_, abspath|
if basename_ == basename && path.match?(abspath)
inflection
else
super(basename_, abspath)
end
end
end
end
prepend conditional_inflection_for(
basename: 'rest',
inflection: 'REST',
path: /\A#{Rails.root.join('lib', 'api')}/,
)
# ...
def initialize
@inflector = Rails::Autoloader::Inflector
end
def camelize(basename, abspath)
@inflector.camelize(basename, abspath)
end
def inflect(overrides)
@inflector.inflect(overrides)
end
end
Rails.autoloaders.each do |autoloader|
autoloader.inflector = UnconventionalInflector.new
autoloader.inflector.inflect(
'api' => 'API'
)
end
上の実装ではRails::Autoloader::Inflector
モジュールを利用していますが、そのcamelize
実装には、ファイルパスが不規則語形活用ルールにマッチするかどうかをチェックするコードがprepend
されています。マッチする場合は不規則語形変化を使い、マッチしない場合はデフォルトの実装にフォールバックします。
Rest
とREST
を共存させるコード例はわざとらしく見えるかもしれませんが、要点を説明するうえでは有用です。私たちが手掛けているプロジェクトでカスタムinflectorが非常に有用であることが判明したのと同様に、現実の業務でもカスタムinflectorを実装する理由付けに説得力が生じるでしょう。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
inflectorは英ママとしました。