セットプチフォッカ

勉強したアウトプット、ときどきフォッカチオ作っていました

Formオブジェクトのロケールファイルの定義方法(i18n)

Formオブジェクトを利用した場合の翻訳キーはどれ?

通常DBに紐づくモデル、つまりActiveRecord::Baseを継承したクラスに対応するフォームを作成する場合、次のようなロケールファイルを作成することで、バリデーションメッセージや表示される項目を多言語化することができます。

ja:
  activerecord: # activerecordをキーにする
    attributes:
      user:
        name: 名前
        password: パスワード

一方DBへの保存だけではなく、複雑なユースケースを実装する場合にはFormオブジェクトを作って対処します。例えばフォームから入力されたデータを用いて、メール送信を行うような場合です。

Formオブジェクトは複数のモデルを扱ったり、DB以外の操作が伴うため、基本的にはActiveModel::Modelをincludeした形で実装します。

また、form_withからの送信先判定を行うために、to_modelをオーバーライドし、メインとなるModelクラスに向けることもあります。

...こうなってくると、i18n用のロケールファイルをどうやって書いたらいいのかよくわからなくなってきたので、Railsがどのようにロケールファイルを解釈しているのか調べました。

要約

  • ActiveModelとActiveRecordはそれぞれi18n_scopeを実装していて、これによりロケールファイルのトップレベルの探索キーが決まる。
  • Formオブジェクト側で行っている処理(例:バリデーション)はactivemodelをキーにする
  • to_modelをオーバーライドしてActiveRecord::Baseを継承したモデルを参照している場合、それらの表示に対する翻訳はactiverecordをキーにする

前提

対象バージョン

サンプルケース

モデル:User

class User < ApplicationRecord
end

フォームオブジェクト:UserForm

class UserForm
    include ActiveModel::Model
  include ActiveModel::Attributes

  attr_reader :user

  def initialize(user = User.new, **attributes)
    @user = user
    attributes = default_attributes if attributes.empty?
    super(attributes)
  end

  validates :name, presence: true
  # バリデーションがいっぱい

  attribute :name, :string
  # カラム定義も様々

  def save
    # 実際の処理
  end

  def to_model
    user
  end
end

RailsがModelのi18nを探索する手順

バリデーションメッセージ

バリデーション用のエラーメッセージの組み立ては、ActiveModel::Error.generata_messageで行われています。

https://github.com/rails/rails/blob/main/activemodel/lib/active_model/error.rb#L64

def self.generate_message(attribute, type, base, options) # :nodoc:
      type = options.delete(:message) if options[:message].is_a?(Symbol)
      value = (attribute != :base ? base.read_attribute_for_validation(attribute) : nil)

      options = {
        model: base.model_name.human,
        attribute: base.class.human_attribute_name(attribute, { base: base }),
        value: value,
        object: base
      }.merge!(options)

      if base.class.respond_to?(:i18n_scope)
        i18n_scope = base.class.i18n_scope.to_s
        attribute = attribute.to_s.remove(/\[\d+\]/)

        defaults = base.class.lookup_ancestors.flat_map do |klass|
          [ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
            :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
        end
        defaults << :"#{i18n_scope}.errors.messages.#{type}"

        catch(:exception) do
          translation = I18n.translate(defaults.first, **options.merge(default: defaults.drop(1), throw: true))
          return translation unless translation.nil?
        end unless options[:message]
      else
        defaults = []
      end

      defaults << :"errors.attributes.#{attribute}.#{type}"
      defaults << :"errors.messages.#{type}"

      key = defaults.shift
      defaults = options.delete(:message) if options[:message]
      options[:default] = defaults

      I18n.translate(key, **options)
    end

https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-generate_message

抜粋するとこんな感じです。

def self.generate_message(attribute, type, base, options) # :nodoc:
    # 1: 対象のクラスについて、i18n_scopeを実行し、キーを取得する。
  if base.class.respond_to?(:i18n_scope)
    i18n_scope = base.class.i18n_scope.to_s
    attribute = attribute.to_s.remove(/\[\d+\]/)

    defaults = base.class.lookup_ancestors.flat_map do |klass|
      [ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
        :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
    end
    # 省略
  end

  # 2. 取得できた情報でデータを翻訳文を作成し、つっこむ
  key = defaults.shift
  defaults = options.delete(:message) if options[:message]
  options[:default] = defaults

  # 3. 2で作成されたデータで翻訳処理を行う(ロケールに応じたファイルを選択する)
  I18n.translate(key, **options)
end

ここで肝になるのはi18n_scopeというメソッドです。このメソッドにより、activerecordを見にいくのか、activemodelを見にいくのかが決まります。

ではその実装はどうなっているのかというと、ActiveModelの場合、次のようになっています。

# Returns the +i18n_scope+ for the class. Override if you want custom lookup.
def i18n_scope
  :activemodel
end

https://github.com/rails/rails/blob/75a9e1be75769ae633a938d81d51e06852a69ea3/activemodel/lib/active_model/translation.rb#L26

一方で、ActiveRecordでも同じメソッドがオーバーライドされており、次のように実装されています。

# Set the i18n scope to override ActiveModel.
def i18n_scope # :nodoc:
  :activerecord
end

https://github.com/rails/rails/blob/75a9e1be75769ae633a938d81d51e06852a69ea3/activerecord/lib/active_record/translation.rb#L20

これにより、翻訳対象のクラスが

  • ActiveModelの場合は、activemodel
  • ActiveRecordの場合は、activerecord

が翻訳キーとして採用されることになります。

ここで、バリデーションメッセージについては、Formオブジェクト、つまりActiveModelで実装されたものです。そのため、エラーメッセージの翻訳キーはactivemodel始まりになります。

ja:
    activemodel:
        user_form:
            name: 名前
            password: パスワード

カラム(画面表示項目)

カラム(画面表示項目)の翻訳についても先のi18n_scopeで説明がつきます。

form_withでフォームを作成する際に、to_modelによって、ActiveRecordであるUser側にクラス判定が向くため、ActiveRecordi18n_scopeが採用されます。

そのため、以下のようなロケールファイルを記述します。

ja:
    activerecord:
        user:
            name: 名前
            password: パスワード

なおUserモデルに含まれない項目をFormオブジェクトに定義した場合も、翻訳ファイルはactiverecord下に記述することに注意が必要です。


以上Formオブジェクトでの翻訳キーの参照先についての調査でした。