Railsアプリケーションが日本語のみだとしても、I18nを使うと文言を一箇所に集約出来ます。これにより表記揺れし難くなる利点があります。この際に翻訳が存在しない場合に例外を投げるように設定しておくと、開発中に必ず気付けるので便利です。

# > https://guides.rubyonrails.org/configuring.html#config-i18n-raise-on-missing-translations
# > Determines whether an error should be raised for missing translations. This defaults to false.
config.i18n.raise_on_missing_translations = true

こうしておくとview/controllerで存在しないI18nのkeyを指定した場合に例外が発生します。

<%= t "key_that_does_not_exist" %>
#=> Translation missing: en.key_that_does_not_exist
def create
  redirect_to @post, notice: t("key_that_does_not_exist")
end
#=> Translation missing: en.key_that_does_not_exist

ただしmodelは例外を投げてくれません。

Post.human_attribute_name("title")
#=> "Title"

この挙動によりviewで長いI18nのkeyを指定する必要があり面倒です。

<!-- 本当はこう書きたいが、翻訳がなくても例外が発生しない。 -->
<%= Post.human_attribute_name("title") %>

<!-- I18nのkeyを完全に指定する必要があり面倒。特にnestしたmodelの場合は長大になりがち。 -->
<%= t "activerecord.attributes.post.title" %>

そこでhuman_attribute_nameが例外を投げるようにRails本体に修正を入れました。1今回はその修正について解説します。

修正の要点は2つあります。

  1. ActiveModel::Translationが例外を投げるオプションを加える。
  2. Railtiesからそのオプションを設定する。

1点目は単純にオプションを追加して、そのオプションが有効の場合に例外を投げるようにするだけです。

--- a/activemodel/lib/active_model/translation.rb
+++ b/activemodel/lib/active_model/translation.rb
@@ -22,6 +22,8 @@ module ActiveModel
   module Translation
     include ActiveModel::Naming

+    singleton_class.attr_accessor :raise_on_missing_translations
+
     # Returns the +i18n_scope+ for the class. Override if you want custom lookup.
     def i18n_scope
       :activemodel
@@ -60,13 +62,17 @@ def human_attribute_name(attribute, options = {})
         end
       end

+      raise_on_missing = options.fetch(:raise, Translation.raise_on_missing_translations)
+
       defaults << :"attributes.#{attribute}"
       defaults << options[:default] if options[:default]
-      defaults << MISSING_TRANSLATION
+      defaults << MISSING_TRANSLATION unless raise_on_missing

-      translation = I18n.translate(defaults.shift, count: 1, **options, default: defaults)
+      translation = I18n.translate(defaults.shift, count: 1, raise: raise_on_missing, **options, default: defaults)
       translation = attribute.humanize if translation == MISSING_TRANSLATION
       translation
     end
   end

2点目はRailtiesからconfig.i18n.raise_on_missing_translationsActiveModel::Translationに伝播させてやります。ただしRailsは機能ごとに有効化・無効化出来るので直接伝播させることは出来ません。今回の場合だとActive Modelが無効化されている場合があるので、それを考慮してやる必要があります。そこでActiveSupport::LazyLoadHooks2の出番です。

Railsは機能ごとに有効化・無効化をするために内部的に粗結合に作られています。例えばAction CableではAction Viewがloadされた際に以下のコードを実行しています。これによりviewからaction_cable_meta_tagが呼べるようになっています。

# https://github.com/rails/rails/blob/9f80efc79119037fc4421d06e94a0d7e076876a4/actioncable/lib/action_cable/engine.rb#L19-L23
initializer "action_cable.helpers" do
  ActiveSupport.on_load(:action_view) do
    include ActionCable::Helpers::ActionCableHelper
  end
end

この仕組みは外部のgemでも使われています。例えばturbo-railsを使うとturbo_frame_tagなどのhelperが使えるようになるのは、以下のコードによりRails本体が拡張されているからです。

# https://github.com/hotwired/turbo-rails/blob/b0e7ebf2c7e2925c4d5fee4bf7d527c53ff4c1e3/lib/turbo/engine.rb#L59-L64
initializer "turbo.helpers", before: :load_config_initializers do
  ActiveSupport.on_load(:action_controller_base) do
    include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
    helper Turbo::Engine.helpers
  end
end

さて話を元に戻します。今回はActiveModel::Translationがloadされた場合に限りraise_on_missing_translationsを設定したいです。そこでActiveModel::Translationがloadされた際のhookを追加します。

--- a/activemodel/lib/active_model/translation.rb
+++ b/activemodel/lib/active_model/translation.rb
@@ -68,5 +68,7 @@ def human_attribute_name(attribute, options = {})
       translation = attribute.humanize if translation == MISSING_TRANSLATION
       translation
     end
+
+    ActiveSupport.run_load_hooks(:active_model_translation, Translation)
   end
 end

次にload時に実行されるコードを追加します。

--- a/activesupport/lib/active_support/i18n_railtie.rb
+++ b/activesupport/lib/active_support/i18n_railtie.rb
@@ -83,6 +83,10 @@ def self.setup_raise_on_missing_translations_config(app)
         ActionView::Helpers::TranslationHelper.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
       end

+      ActiveSupport.on_load(:active_model_translation) do
+        ActiveModel::Translation.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
+      end
+
       if app.config.i18n.raise_on_missing_translations &&
           I18n.exception_handler.is_a?(I18n::ExceptionHandler) # Only override the i18n gem's default exception handler.

以上で翻訳がない場合にhuman_attribute_nameが例外を投げるようになりました。

# ActiveModel::Translation.raise_on_missing_translations = true
Post.human_attribute_name("title")
=> Translation missing. Options considered were: (I18n::MissingTranslationData)
    - en.activerecord.attributes.post.title
    - en.attributes.title

            raise exception.respond_to?(:to_exception) ? exception.to_exception : exception
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

これにてviewで長大なI18nのkeyを書かずとも済むようになりました。めでたし、めでたし。

余談ですが、この変更は既存のアプリケーションへの影響が大きいとの指摘が入りました。そこで追加で以下の変更を加えようとしています。なおこの記事を書いている段階では取り込まれていません。

  • raise_on_missing_translations:strictの場合に限り例外を投げる。3
  • modelごとに例外を投げる・投げないを設定出来る。4