I18nのtranslateメソッドを調べてみた

この記事は [SmartHR Advent Calendar 2019 22日目](https://qiita.com/advent-calendar/2019/smarthr)の記事です。

こんにちは、SmartHRでサーバーサイドエンジニアをしているwakasaです。
SmartHRでは社会保険や雇用保険などの行政手続きを扱っており、各種手続きには様々な書類が必要になります。

その際に、 `I18n` を使って、項目を日本語化したりすることが多いのですが、 `I18n` を普段から何気なく使っているけど、どういう仕組みかを全く理解していないなと思ったので、ソースコードリーディングしてみました！

# 環境
Ruby: 2.6.5

# I18nの基本
I18nの機能は主に二つで、Railsガイドに記載があります。
[パブリックI18n API](https://railsguides.jp/i18n.html#%E3%83%91%E3%83%96%E3%83%AA%E3%83%83%E3%82%AFi18n-api)

> translate # 訳文を参照する
localize  # DateオブジェクトやTimeオブジェクトを現地のフォーマットに変換する

今回はこのうち、 `translate` に絞ってみていきます。
試しに、 `Gem` をインストールして使ってみましょう。まず下記のような `yaml` を用意します。

```yaml:food.yml
ja:
  sushi: 寿司
```

続いて、`I18n` のGemをインストールし（`gem install i18n`）、 `irb` を立ち上げます。
あとは、[I18nのレポジトリに書いてあるように操作](https://github.com/ruby-i18n/i18n#ruby-without-rails)します。

```ruby:console
$ 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](https://github.com/ruby-i18n/i18n/blob/master/lib/i18n.rb)

```ruby: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](https://github.com/ruby-i18n/i18n/blob/06aa9ac0685aa74eca8cdb4cd7eb10c75969eb6d/lib/i18n/backend/base.rb#L27)


```ruby: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](https://github.com/ruby-i18n/i18n/blob/master/lib/i18n/backend/simple.rb#L89)

```ruby: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](https://github.com/ruby-i18n/i18n/blob/06aa9ac0685aa74eca8cdb4cd7eb10c75969eb6d/lib/i18n/backend/base.rb#L16)

```ruby: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](https://github.com/ruby-i18n/i18n/blob/06aa9ac0685aa74eca8cdb4cd7eb10c75969eb6d/lib/i18n/backend/base.rb#L222)

```ruby: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の便利機能大全！](https://qiita.com/Kta-M/items/bd4ba36a58ad602a9d8b#ruby%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%A7%E8%A8%B3%E6%96%87%E3%82%92%E5%AE%9A%E7%BE%A9%E3%81%99%E3%82%8B)

また `yaml` の書き方は下記が参考になりました。配列も表せるとは知らなかったです。
[プログラマーのための YAML 入門 (初級編)](https://magazine.rubyist.net/articles/0009/0009-YAML.html)

### `normalize_keys`
[lib/i18n.rb](https://github.com/ruby-i18n/i18n/blob/master/lib/i18n.rb#L307)

```ruby:lib/i18n.rb
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` 部分

```ruby
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` を書いたとします。

```yaml:food.yml
ja:
  sushi: 寿司
  ramen: :noodle
  noodle: 麺類
```

この時、 `ramen` を参照すると、 `:noodle` ではなく、きちんと `麺類` が返ってきます。

```ruby:console
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](https://github.com/ruby-i18n/i18n/blob/06aa9ac0685aa74eca8cdb4cd7eb10c75969eb6d/lib/i18n/backend/base.rb#L141)

```ruby: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` というオプションがある

などなど発見がたくさんあり面白かったです。

最後までお読みいただき、ありがとうございます！
少しでも読まれた方に参考になる部分があれば幸いです。