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

Rails: Zeitwerkで使えるカスタムinflectorの作り方4種(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

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

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#camelizeActiveSupport::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されています。マッチする場合は不規則語形変化を使い、マッチしない場合はデフォルトの実装にフォールバックします。


RestRESTを共存させるコード例はわざとらしく見えるかもしれませんが、要点を説明するうえでは有用です。私たちが手掛けているプロジェクトでカスタムinflectorが非常に有用であることが判明したのと同様に、現実の業務でもカスタムinflectorを実装する理由付けに説得力が生じるでしょう。

関連記事

Rails: Zeitwerk対応のためイニシャライザで大量のrequire_dependecyを削除(翻訳)

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


CONTACT

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