SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

i18n定義を自動整理するための話

こんにちは!スマートキャンプのエンジニア、瀧川です。

私は今BOXILの開発を担当していて、Railsアプリケーションとしてローンチから6年近く経つプロダクトとなります。

その間に溜まった技術的な負債については、タスクの中で併せて解消したり、プロジェクトの合間でまとめて時間をとったりと前向きには取り組んでいる(先日Rails6, Ruby2.7にあげました👏🏻)のですが、どうしても優先度が下がる改善がいくつかあるなと思っています。

今回は改善の優先度が低かったけど、割とストレスに感じていたi18n定義の自動整理をやってみた話を紹介しようと思います!

地味に苦戦したんですが、結果として全体の1/3を占めていた未使用のi18n定義を自動削除することができました!

i18n(internationalization)とは

i18nとはinternationalizationの略で、日本語だと国際化・多言語化となり、アプリケーションの中の文言を別の言語に切り替える仕組みや定義のことを指します。

流れとしては、クライアントからリクエストがきて、そのリクエストのロケール(言語)が判別されて、ロケール毎定義された辞書ファイルから文言が取得されるようになります。

また別の側面として、表記のゆれをなくし文言の統一をしたり、Enum値(文字の定数)などを管理したりと、そもそもの辞書としてのメリットもあります。

今回やること

BOXILのi18n状況としては、基本的にはアプリケーションコード内に日本語は記述せず、i18n定義をして呼び出すようにしています。

しかし特にルール化されておらず、定義ファイルがいろんな軸で分割されていて、定義の重複があったり、コード修正時に不要になったi18n定義を削除していなかったりと、実装しているときにどの定義を使えばよいか判断に迷うことが多々ありました。

そこで今回は、まず不要なi18n定義を削除することを目的にしました。

解決方法

アプリケーションの関連する各バージョンは以下の通りです。

ruby(2.7.1)
rails(6.0.3.2)
rails-i18n (6.0.0)

今回は便利そうなi18n-tasksというGemを見つけたので、以下を導入して自動削除できるようにしました!

ただ、本来設定ファイルを置いてやればうまく動いてくれるはずだったのですが、現状のi18nと定義方法や呼び出し方だとうまく動かないところがあり結構ハマりました...。

そのため最終的には、i18n-tasksのAPIを使った、独自のスクリプトを書いて対応しました。

i18n-tasks(0.9.31)

i18n-tasks

Railsのi18n定義の管理を手助けしてくれるGemです。

具体的には、「足りていない定義の抽出」「未使用の定義を抽出・削除」「DeepL Proを利用して別ロケールファイルの生成(すごい)」などが設定ファイルを用意することで自動でできるようになります。

導入方法はリポジトリのReadmeを呼んでいただくのがいいかと思いますが、Gemをインストールして、設定ファイルの雛形をコピーしてくる必要があります。

(RSpecでチェックするための雛形も用意されているので、CIに組み込むのも簡単でいいですね!)

echo "gem 'i18n-tasks', '~> 0.9.31'" > Gemfile
cp $(i18n-tasks gem-path)/templates/config/i18n-tasks.yml config/

ハマったところ

前述した通り、設定ファイルを配置してコマンドを叩くだけだと、いくつか必要な定義が消えてしまったり、意図していない変更がされたりとハマりどころがあったので紹介します。

i18n定義からHashやArrayで取得している箇所が検知されない

あまり良くない使い方かもしれませんが、以下のようなi18n定義のときに、 I18n.t('enums.prefecture') でHashを取得して使っている箇所が多々ありました。

そしてi18n-tasksのunusedを実行すると I18n.t(enums.prefecture.1) など、配下の要素が未使用扱いされてしまいました。

enums:
  prefecture:
    1: 北海道
    2: 青森県
    3: 岩手県
I18n.t('enums.prefecture').each do |key, val|
    # 例: セレクトボックスの選択肢を生成
end

解決

これについてはi18n-tasksのused(使用している定義)の抽出で、Hash利用時に I18n.t('enums.prefecture') は検出されるため、usedで検出されたパスがプレフィックスにあるキーを削除対象から除くことで回避しました。

スニペット

i18n = I18n::Tasks::BaseTask.new
i18n.data.config = i18n.data.config.merge(sort: false)

unused_tree = i18n.unused_keys(strict: false)
used_keys = i18n.used_tree(strict: false).key_names.map { |x| "ja.#{x}" }

# 使われているprefixのkeyを削除する
unused_tree = unused_tree.subtract_keys(used_keys)

数値キーが文字列キーに変換されてしまう

以下のようなi18n定義が使われているときに、i18n-tasksでremove-unused(未使用定義削除)を実行するとキーが文字列に変換される挙動になりました。

これによって I18n.t('enums.prefecture.1') は問題なく取得できるのですが、 I18n.t('enums.prefecture')[1] はうまく取得できなくなってしまいました...。

enums:
  prefecture:
    1: 北海道
    2: 青森県
    3: 岩手県

実行後

enums:
  prefecture:
    "1": 北海道
    "2": 青森県
    "3": 岩手県

解決

全然いい方法が思いつかず、筋肉質な解決方法を取りました...。

以下のようにすべてのlocalesファイルでキーが数値にできるのであれば数値に変換し、ファイルを更新する処理を入れました。

そんなに頻繁に実行しないので許されるかな...という気持ちです。

Dir[Rails.root.join('config', 'locales', '**', '*.yml').to_s].each do |file_path|
  converted_yml = YAML.load_file(file_path).deep_transform_keys do |key|
    Integer(key)
  rescue StandardError
    key
  end
  YAML.dump(converted_yml, File.open(file_path, 'w'))
end

キーの相対パス表現の記述ミス

知っている人は知っているrails-i18nの機能で、以下のようにi18n定義がされている場合、対応する app/views/entries/show.html.slim のファイル内であれば I18n.t('.hoge')I18n.t('entries.show.hoge') が等価になる仕様があります。

これ自体はi18n-tasksでも正しく検出されます。

問題になったのは、実は I18n.t('.hoge')I18n.t('..hoge') でも I18n.t('...hoge') でも正しく動作するような実装になっているらしく、なぜかプロダクトコード内にそうやって呼び出されているやつらが紛れていたため、i18n-tasksで検出が漏れることとなりました。 (どうしてそうなった...)

locales/views/entries

ja:
  entries:
    show:
      hoge: hoge

解決

プロダクトコードを修正しました。

そもそもi18n定義をページごと分けてしまうと文言の統一が難しく管理コストが高いため、そんなにメリットがないと思い、基本的に相対パス表記はやめることにしました。

未使用i18n定義自動削除スクリプト(完成版)

完成したスクリプト(Rakeタスクにしました)がこちらになります。

lib/tasks/my_i18n.rake

namespace :my_i18n do
  # `rake my_i18n:remove_unused`
  task remove_unused: :environment do |_task, _args|
    i18n = I18n::Tasks::BaseTask.new
    i18n.data.config = i18n.data.config.merge(sort: false)

    unused_tree = i18n.unused_keys(strict: false)
    # MEMO: t('range.prefecture')のように途中までの指定でHashを取得している場合、unused_keysに'range.prefecture.1'などが検出されてしまう
    #       used_keysでは'range.prefecture'が検出されるため、それを使ってunused_keysからtree自体を削除(subtract)することで必要な定義が削除されるのを回避している
    #       また、t('range.prefecture.#{hoge}')のようにDynamicに値を入れているものはうまく判定してくれないので、gsubでその箇所を除外して判定する
    used_keys = i18n.used_tree(strict: false).key_names.map { |x| "ja.#{x}".gsub(/\.\#\{.*\}$/, '') }
    unused_tree = unused_tree.subtract_keys(used_keys)

    i18n.data.remove_by_key!(unused_tree)

    # MEMO: t('range.prefecture.1')などのキーが数値のものは、上記の処理で自動で'1'のようにクォートされてしまう
    #       アプリロジックでは数値として使いたいため、以下で無理やりキーを数値に変換している

    Dir[Rails.root.join('config', 'locales', '**', '*.yml').to_s].each do |file_path|
      converted_yml = YAML.load_file(file_path).deep_transform_keys do |key|
        Integer(key)
      rescue StandardError
        key
      end
      YAML.dump(converted_yml, File.open(file_path, 'w'))
    end
  end
end

一応設定ファイルも抜粋して載せておきます。

そんなに変わったこともないですが、安全でかつコストを掛けすぎないようにしたかったので、excludeとignoreを多めに設定していました。

config/i18n-tasks.yml

base_locale: ja
data:
  read:
    - config/locales/**/%{locale}.yml
search:
  paths:
    - app
  exclude:
    - app/assets/images
    - app/assets/fonts
    - app/assets/videos
    - app/services/hoge_service.rb ## 検査中にエラーが発生するため
  ## t("categories.#{category}.title")なども検知するため
  strict: false
## ある程度面倒そうなものはignore
ignore:
 - will_paginate.*
 - validation.*
 - simple_form.*
 - devise.*
 - activerecord.*
 - date.*
 - datetime.*
 - errors.*
 - helpers.*
 - number.*
 - support.*
 - time.*

まとめ

最近月1くらいでやっている開発改善デーで実施した内容を紹介させていただきました。

(同じ日にこちらも取り組んでいたのでぜひ一読いただければ嬉しいです!)

スマートキャンプに入社しました!& Chrome拡張機能をVue.jsで作りました! - SMARTCAMP Engineer Blog

今回i18n-tasksというGemを使わせていただきましたが、とても便利でもっと早く導入してCIに組み込んでいればなぁ...と感じました。

ぜひこれからi18nと向き合っていこうと考えている方は検討してみてください!