この記事は 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
というオプションがある
などなど発見がたくさんあり面白かったです。
最後までお読みいただき、ありがとうございます!
少しでも読まれた方に参考になる部分があれば幸いです。