Rails: 失われたメッセージを巡る冒険


2024年 04月 09日

はじまり

我々が開発している Rails アプリのひとつに、国際化が必要なものがあります。
Rails には国際化(i18n)機能があるため、それを利用すると簡単にロケールに応じてメッセージに切り替えができます。
国際化機能の詳細はここでは触れないため、Rails ガイドの「Rails 国際化(I18n) API」を参照してください。

少し前、その Rails アプリでとあるメッセージが正しく表示されていないことに気づきました。
画面には “translation missing” と表示されてしまっていました。
このときはロケールファイルのメッセージのキーを typo していて、view で参照しているキーと一致していなかったので
メッセージが表示できていませんでした。

<%# view 側では title を参照しているが… %>
<% t('.title') %>
# config/locales/en.yml
en:
  users:
    index:
      titles: "Users"  # ロケールファイルでは titles と複数形になっていた (typo)

Rails のデフォルト設定では、メッセージのキーが見つからない場合には “translation missing” と表示されますが、
ワーニングメッセージなどは出力されないため、見落としが起こることがあります。
今回発生したのもモーダルウィンドウのメッセージだったため、バグに気付くのが少し遅れました。
ミスが発生してしまうのは致し方ないのですが、それに気づきづらいのはあまり良い状況ではありません。

ということで、この翻訳メッセージ不足の対策について調べてみました。

対策を調べてみた

raise_on_missing_translations

Rails ガイドでは、 config.i18n.raise_on_missing_translations という設定が紹介されています。
この設定を有効にすると、メッセージが見つからない場合に例外が発生するようになります。

この挙動を活かし、Rails の test モードのときに有効にしておくと、CI でメッセージの不足に気づけます。

# config/environments/test.rb
Rails.application.configure do
  config.i18n.raise_on_missing_translations = true
end

なお、この設定は「実行時に」メッセージ不足を検出して例外を発生させるため、テストケースが不足している場合は問題は検出できません。
残念なことに、今回のケースでは view のテストが足りていなかったため、この設定を有効にしてもエラーは発生しませんでした。

また、Rails のバージョンにも注意が必要です。この設定は Rails 6.1 から導入されたものですが、Rails 7.1 未満の場合は I18n.t でのメッセージ不足ではエラーが発生しません
view やコントローラの #t メソッドだけを使っている場合は問題ありませんが、 I18n.t を併用している場合は問題が検出できない可能性があります。

I18n.exception_handler

Rails ガイドには、 config.i18n.raise_on_missing_translations の他に I18n.exception_handler という設定が紹介されています。
メッセージが見つからない場合に、独自の例外ハンドラを呼び出すことができます。

# config/initializers/i18n.rb
module I18n
  class RaiseExceptForSpecificKeyExceptionHandler
    def call(exception, locale, key, options)
      if key == "special.key"
        "translation missing!" # raiseせずにこれを返すこと
      elsif exception.is_a?(MissingTranslation)
        raise exception.to_exception
      else
        raise exception
      end
    end
  end
end

I18n.exception_handler = I18n::RaiseExceptForSpecificKeyExceptionHandler.new

Rails ガイドの説明では、メッセージが見つからない場合の挙動全般をこの設定でハンドリングできるように説明されていますが、実はこの説明は正しくありません。
現状では I18n.t で翻訳する場合には例外ハンドラが呼び出されますが、view やコントローラの #t メソッドで翻訳する場合は例外ハンドラは呼び出されず、
これまで通り “translation missing” が返されます。

コードを調べてみた限りだと、view やコントローラ側では I18n.t を直接呼ぶのではなく、薄いラッパー層が挟まれており、
メッセージが見つからない場合は “translation missing” と返すになっているようです。
この設定に関しては i18n gem と Rails が統合されきっていない印象があります。

I18n.exception_handler + ActionView のハック

I18n.exception_handler が view に対応していないのなら、ハックしてしまえばよいのではないか、ということでこんなモンキーパッチを書いてみました。

# NOTE: translation missing が発生した場合にログを記録する
#       I18n.exception_handler 経由でログを出力するよう設定しても、view 内では効果がないため、
#       ActionView::Helpers::TranslationHelper#missing_translation をオーバーライドしてログ出力させる
module ActionView
  module Helpers
    module TranslationHelper
      alias orig_missing_translation missing_translation

      def missing_translation(key, options)
        Rails.logger.tagged("i18n") { Rails.logger.warn("translation missing: #{I18n.locale}, #{key}, #{options}") }
        orig_missing_translation(key, options)
      end
    end
  end
end

このようなモンキーパッチを当てることで、メッセージが見つからない場合のハンドリングが可能です。
この例ではログを出力するようにしていますが、メッセージを差し替えることや例外を発生させることももちろん可能です。
なお、このモンキーパッチは ActionView 向けです。これに加えて ActionController 向けのモンキーパッチも用意する必要があります。

i18n-tasks gem

ここまでは Rails 本体の機能を使った対策を紹介しましたが、外部ツールを使ったアプローチをひとつ紹介します。
ロケールファイルをメンテナンスするツールとして i18n-tasks という gem があります。
この i18n-tasks はメッセージの過不足の検出や自動翻訳、ロケールファイルのリファクタリング(削除やソート、リネームなど)を行うことができます。

今回の用途には、メッセージ不足の検出を行う i18n-tasks missing コマンドが利用できます。

$ bundle exec i18n-tasks missing --types=used
#StandWithUkraine
Missing translations (2) | i18n-tasks v1.0.13
+--------+-----+----------------------------------------------------------------------+
| Locale | Key | Value in other locales or source                                     |
+--------+-----+----------------------------------------------------------------------+
|  all   | bar | app/controllers/signup_controller.rb:19                              |
|  all   | foo | app/views/users/show.html.erb:75                                     |
+--------+-----+----------------------------------------------------------------------+
(ファイル名は編集しました)

今回の目的に一番マッチしているように思えたのですが、動的にキーを組み立てているコードには対応していないようです。
たとえば、以下のようなコードには対応していません。

<% t("user.#{current_account.status}.name") %>

README の Dynamic Keys によると i18n-tasks-useというコメントを書くことで、外部からヒントを与えることができるようです。
また、 search.strict を false に設定することで、あいまいな検出もできるようです。

僕らの選択

当初、なにかいい対策はないかと Rails ガイドを読んだ際は

  • config.i18n.raise_on_missing_translations を有効にして、CI でメッセージ不足を検出する
  • production モードでは I18n.exception_handler を使ってログを出力し、テストで見つけられなかったメッセージ不足を検出する

というふたつの方法を併用しようと考えていました。

しかし、後者については view やコントローラをサポートしていないことがわかったため、採用を見送りました。
Rails ガイドで紹介しているのだし、なにか方法があるのではとコードを読んだ結果、モンキーパッチを当てる方法を編み出しましたが、
これはあくまでハックであり、将来の Rails のバージョンアップで動かなくなる可能性があるため、採用を見送りました。

最終的には i18n-tasks gem を見つけたので、 raise_on_missing_translations と i18n-tasks を併用することにしました。

まとめ

  • Rails の国際化機能では、メッセージが見つからなかったことに気づきづらい
  • メッセージ不足を気づく方法を調査した
    • config.i18n.raise_on_missing_translations
    • I18n.exception_handler
    • モンキーパッチ
    • i18n-tasks gem
  • 最終的には raise_on_missing_translations と i18n-tasks gem を併用することにした

みなさんも Rails の設定を調整して、翻訳メッセージ不足をやっつけましょう。