LoginSignup
12
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-22

この記事は 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 を用意します。

food.yml
ja:
  sushi: 寿司

続いて、I18n のGemをインストールし(gem install i18n)、 irb を立ち上げます。
あとは、I18nのレポジトリに書いてあるように操作します。

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

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

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

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_keysinject している部分です。

各メソッドの役割をざっくりまとめると下記になります。

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

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

ruby
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 ファイルが指定可能なので、 HashvalueProc オブジェクトを指定することもできます。

実際の参考例は、下記の記事が参考になります。
あなたはいくつ知っている?Rails I18nの便利機能大全!

また yaml の書き方は下記が参考になりました。配列も表せるとは知らなかったです。
プログラマーのための YAML 入門 (初級編)

normalize_keys

lib/i18n.rb

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 で分割した値のうち数字や真偽値の場合はそれぞれ IntegerBoolean に、その他は 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 を書いたとします。

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

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

console
irb(main):005:0> I18n.t(:ramen)
=> "麺類"
irb(main):006:0> I18n.t(:noodle)
=> "麺類"

resolve メソッド

さて、 lookup メソッドで key を元に巨大な Hash を参照し、目的の値を取り出すことができました。その値が文字列であれば良いですが、先の例にもある通り、 SymbolProc が返ってくることがあります。それを処理するのが resolve メソッドです。
lib/i18n/backend/base.rb

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

上記のように、 subjectSymbolProc かそれ以外かで処理を分岐しています。

Symbol の場合は、先ほども書いたように、再度、 translate メソッドを呼び出しています。
Proc の場合は、 call して実行して、再度、 resolve メソッドを呼び出します。
それ以外の単純な文字列等の場合は、そのまま返します。

このようにしてようやく、求める値が返ってくる形になっています。

最後に

普段何気なく使っている、 I18n ですが、ソースコードを呼んでみると様々な発見がありました!

yaml だけではなく、 rbjson もかける
yaml の書き方は色々とあり、配列も表現できる
Symbol を値に設定するとエイリアスっぽく作用する
Proc オブジェクトを値に設定することもできる
今回の解説では出していませんが、 count というオプションがある

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

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

12
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6