この記事は SmartHR Advent Calendar 2019 22日目の記事です。
こんにちは、SmartHRでサーバーサイドエンジニアをしているwakasaです。
SmartHRでは社会保険や雇用保険などの行政手続きを扱っており、各種手続きには様々な書類が必要になります。
その際に、 I18n を使って、項目を日本語化したりすることが多いのですが、 I18n を普段から何気なく使っているけど、どういう仕組みかを全く理解していないなと思ったので、ソースコードリーディングしてみました!
環境
Ruby: 2.6.5
I18nの基本
I18nの機能は主に二つで、Railsガイドに記載があります。
パブリックI18n API
translate # 訳文を参照する
localize # DateオブジェクトやTimeオブジェクトを現地のフォーマットに変換する
今回はこのうち、 translate に絞ってみていきます。
試しに、 Gem をインストールして使ってみましょう。まず下記のような yaml を用意します。
ja:
sushi: 寿司
続いて、I18n のGemをインストールし(gem install i18n)、 irb を立ち上げます。
あとは、I18nのレポジトリに書いてあるように操作します。
$ irb
irb(main):001:0> require 'i18n'
=> true
irb(main):002:0> I18n.load_path << 'food.yml'
=> ["food.yml"]
irb(main):003:0> I18n.default_locale = :ja
=> :ja
irb(main):004:0> I18n.t(:sushi)
=> "寿司"
このように translate メソッドでは、指定した訳文を参照することができます。
translate メソッド
それでは早速、 I18n.translate メソッドから読んでいきたいと思います。 I18n.translate メソッドはlocale等の設定をしたあと、 config.backend.translate メソッドを呼んでいます。
lib/i18n.rb
def translate(key = nil, *, throw: false, raise: false, locale: nil, **options) # TODO deprecate :raise
locale ||= config.locale
raise Disabled.new('t') if locale == false
enforce_available_locales!(locale)
backend = config.backend
result = catch(:exception) do
if key.is_a?(Array)
key.map { |k| backend.translate(locale, k, options) }
else
backend.translate(locale, key, options)
end
end
if result.is_a?(MissingTranslation)
handle_exception((throw && :throw || raise && :raise), result, locale, key, options)
else
result
end
end
alias :t :translate
そのため、 I18n::Backend::Base#translate をみてみます。下記がそのメソッドになります。
lib/i18n/backend/base.rb
def translate(locale, key, options = EMPTY_HASH)
raise I18n::ArgumentError if (key.is_a?(String) || key.is_a?(Symbol)) && key.empty?
raise InvalidLocale.new(locale) unless locale
return nil if key.nil? && !options.key?(:default)
entry = lookup(locale, key, options[:scope], options) unless key.nil?
if entry.nil? && options.key?(:default)
entry = default(locale, key, options[:default], options)
else
entry = resolve(locale, key, entry, options)
end
count = options[:count]
if entry.nil? && (subtrees? || !count)
if (options.key?(:default) && !options[:default].nil?) || !options.key?(:default)
throw(:exception, I18n::MissingTranslation.new(locale, key, options))
end
end
entry = entry.dup if entry.is_a?(String)
entry = pluralize(locale, entry, count) if count
if entry.nil? && !subtrees?
throw(:exception, I18n::MissingTranslation.new(locale, key, options))
end
deep_interpolation = options[:deep_interpolation]
values = options.except(*RESERVED_KEYS)
if values
entry = if deep_interpolation
deep_interpolate(locale, entry, values)
else
interpolate(locale, entry, values)
end
end
entry
end
とても長いですが、重要な部分は lookup メソッドを呼び出している部分と、 resolve メソッドを呼び出している部分です。その名の通り、 lookup メソッドで、指定したkeyで中身を参照し、 resolve で参照してきた中身の解決を行います。あとの部分は、 options 次第で処理が追加されていきます。
lookup メソッド
それでは lookup メソッドをみていきます。
lib/i18n/backend/simple.rb
def lookup(locale, key, scope = [], options = EMPTY_HASH)
init_translations unless initialized?
keys = I18n.normalize_keys(locale, key, scope, options[:separator])
keys.inject(translations) do |result, _key|
return nil unless result.is_a?(Hash)
unless result.has_key?(_key)
_key = _key.to_s.to_sym
return nil unless result.has_key?(_key)
end
result = result[_key]
result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol)
result
end
end
ここで重要になるのは、 init_translations と、 normalize_keys と inject している部分です。
各メソッドの役割をざっくりまとめると下記になります。
init_translation:ファイルを読み込み、 巨大なHashデータに変換。
normalize_keys: food.sushi のようなkeyを [:ja, :food, :sushi] のようなSymbolの配列に変換
inject部分:ハッシュデータを keysで繰り返し参照し、中身を取り出す。
それでは各部分を細かくみていきます。
init_translation
ここでは、 load_translations が呼ばれており、 load_translations は下記のようになります。
lib/i18n/backend/base.rb
def load_translations(*filenames)
filenames = I18n.load_path if filenames.empty?
filenames.flatten.each { |filename| load_file(filename) }
end
最初に、 I18n.load_path を参照しています。
これは最初の例で、 I18n.load_path << 'food.yml' のようにファイル名を追加した配列ですね。これらに対して一律に、 load_file メソッドを呼んでいます。
load_file メソッドは下記です。
ruby:lib/i18n/backend/base.rb
def load_file(filename)
type = File.extname(filename).tr('.', '').downcase
raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true)
data = send(:"load_#{type}", filename)
unless data.is_a?(Hash)
raise InvalidLocaleData.new(filename, 'expects it to return a hash, but does not')
end
data.each { |locale, d| store_translations(locale, d || {}) }
end
このメソッドでは、ファイルの拡張子で type を判別し、それによって、 send で呼び出しメソッドを変えています。
ちなみに I18n で読み込み可能なファイルは、 rb/yaml/json の3種類です。
yaml は拡張子が、 yml でも、 yaml でもどちらでもOKです。
(alias_method :load_yaml, :load_ymlになっている)
rb ファイルが指定可能なので、 Hash の value に Proc オブジェクトを指定することもできます。
実際の参考例は、下記の記事が参考になります。
あなたはいくつ知っている?Rails I18nの便利機能大全!
また yaml の書き方は下記が参考になりました。配列も表せるとは知らなかったです。
プログラマーのための YAML 入門 (初級編)
normalize_keys
def normalize_keys(locale, key, scope, separator = nil)
separator ||= I18n.default_separator
keys = []
keys.concat normalize_key(locale, separator)
keys.concat normalize_key(scope, separator)
keys.concat normalize_key(key, separator)
keys
end
ここでは、与えられた各種引数を normalize_key に渡し locale scope key の順番で配列に concat していきます。I18n.default_separator はご存知の通り . です。
normalize_key メソッドでは、separator で分割した値のうち数字や真偽値の場合はそれぞれ Integer や Boolean に、その他は Symbol に変換しています。
その結果、 food.sushi のような key が、 [:ja, :food, :sushi] のような keys に変換されます。
inject 部分
result = result[_key]
result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol)
result
ここではファイルから取り出した巨大なHashに対して、分割された各 _key で参照していきます。基本的には途中段階であれば Hash が、最終的な値であれば文字列が返ってきます。しかし if 文で書かれている部分がある通り、 Symbol が返ってくる可能性も考慮されています。 Symbol が返ってきた場合は resolve の中で再度、 translate メソッドが呼び出されます。
例えば下記のように yaml を書いたとします。
ja:
sushi: 寿司
ramen: :noodle
noodle: 麺類
この時、 ramen を参照すると、 :noodle ではなく、きちんと 麺類 が返ってきます。
irb(main):005:0> I18n.t(:ramen)
=> "麺類"
irb(main):006:0> I18n.t(:noodle)
=> "麺類"
resolve メソッド
さて、 lookup メソッドで key を元に巨大な Hash を参照し、目的の値を取り出すことができました。その値が文字列であれば良いですが、先の例にもある通り、 Symbol や Proc が返ってくることがあります。それを処理するのが resolve メソッドです。
lib/i18n/backend/base.rb
def resolve(locale, object, subject, options = EMPTY_HASH)
return subject if options[:resolve] == false
result = catch(:exception) do
case subject
when Symbol
I18n.translate(subject, **options.merge(:locale => locale, :throw => true).except(:count))
when Proc
date_or_time = options.delete(:object) || object
resolve(locale, object, subject.call(date_or_time, options))
else
subject
end
end
result unless result.is_a?(MissingTranslation)
end
上記のように、 subject が Symbol か Proc かそれ以外かで処理を分岐しています。
Symbol の場合は、先ほども書いたように、再度、 translate メソッドを呼び出しています。
Proc の場合は、 call して実行して、再度、 resolve メソッドを呼び出します。
それ以外の単純な文字列等の場合は、そのまま返します。
このようにしてようやく、求める値が返ってくる形になっています。
最後に
普段何気なく使っている、 I18n ですが、ソースコードを呼んでみると様々な発見がありました!
yaml だけではなく、 rb や json もかける
yaml の書き方は色々とあり、配列も表現できる
Symbol を値に設定するとエイリアスっぽく作用する
Proc オブジェクトを値に設定することもできる
今回の解説では出していませんが、 count というオプションがある
などなど発見がたくさんあり面白かったです。
最後までお読みいただき、ありがとうございます!
少しでも読まれた方に参考になる部分があれば幸いです。