2018-02-21

I18nコードリーディング

I18n(0.9.3)のコードリーディングをしました。

まず簡単な使い方から。

require 'i18n'

I18n.load_path << 'config/locales/en.yml'
# I18n.locale = :ja
I18n.t('hoge')

Railsを使っていると特に何も考えなくてもconfig/locales/から設定ファイルをロードしたり色々やってくれて便利なんですが、I18n単体でもこんなにシンプルに書けます。Railsでやっている処理に関しては後半に紹介します。

まずI18n.load_pathの処理をみてみます。load_pathはconfigに処理を委譲しており、configはI18n::Configのインスタンスです。

module I18n
  class Config
    def load_path
      @@load_path ||= []
    end

    def load_path=(load_path)
      @@load_path = load_path
      @@available_locales_set = nil
      backend.reload!
    end

load_pathはI18n::Configのクラス変数の@@load_pathの操作になります。

translate

#tは#translateのエイリアスです。config.backendはBackend::Simpleのインスタンスが入ります。
module I18n
  module Base
    def translate(*args)
      options  = args.last.is_a?(Hash) ? args.pop.dup : {}
      key      = args.shift
      backend  = config.backend
      locale   = options.delete(:locale) || config.locale
      handling = options.delete(:throw) && :throw || options.delete(:raise) && :raise # TODO deprecate :raise

      enforce_available_locales!(locale)

      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
      result.is_a?(MissingTranslation) ? handle_exception(handling, result, locale, key, options) : result
    end
    alias :t :translate

#enforce_available_locales!はconfig.enforce_available_localesがtrueの場合、ロケールが利用可能かどうかをチェックします。@@enforce_available_localesはデフォルトtrueなので未設定の場合は必ず#locale_available?が呼ばれます。

    # Returns true when the passed locale, which can be either a String or a
    # Symbol, is in the list of available locales. Returns false otherwise.
    def locale_available?(locale)
      I18n.config.available_locales_set.include?(locale)
    end

    # Raises an InvalidLocale exception when the passed locale is not available.
    def enforce_available_locales!(locale)
      if config.enforce_available_locales
        raise I18n::InvalidLocale.new(locale) if !locale_available?(locale)
      end
    end

#locale_available?はさらにI18n::Config#available_locales_set経由でbackend.available_localesを呼び出します。

module I18n
  class Config
    def available_locales
      @@available_locales ||= nil
      @@available_locales || backend.available_locales
    end

    def available_locales_set #:nodoc:
      @@available_locales_set ||= available_locales.inject(Set.new) do |set, locale|
        set << locale.to_s << locale.to_sym
      end
    end

I18n::Backend::Simple#available_localesは初期化されていない場合は#init_translations経由で#load_translationsを呼び出します。

module I18n
  module Backend
    class Simple
      (class << self; self; end).class_eval { public :include }

      module Implementation
        include Base

        def initialized?
          @initialized ||= false
        end

        def available_locales
          init_translations unless initialized?
          translations.inject([]) do |locales, (locale, data)|
            locales << locale unless data.size <= 1 && (data.empty? || data.has_key?(:i18n))
            locales
          end
        end

      protected

        def init_translations
          load_translations
          @initialized = true
        end

#load_translationsはI18n.load_pathに設定されたファイルパスを#load_fileの引数に渡します。

module I18n
  module Backend
    module Base
      include I18n::Backend::Transliterator

      def load_translations(*filenames)
        filenames = I18n.load_path if filenames.empty?
        filenames.flatten.each { |filename| load_file(filename) }
      end

      protected

        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

        def load_yml(filename)
          begin
            YAML.load_file(filename)
          rescue TypeError, ScriptError, StandardError => e
            raise InvalidLocaleData.new(filename, e.inspect)
          end
        end

#load_fileは"load_#{ファイルの拡張子}"のメソッドを呼び出します。予め定義されているのはload_ymlとload_rbになります。YAMLの拡張子に.yamlが使えないのはこれが理由だったりします。普通にalias貼れば解決しそうな感じはしますが…。

読み込んだデータはstore_translationsによってメモリ上に保存されます。

module I18n
  module Backend
    class Simple
      module Implementation
        def store_translations(locale, data, options = {})
          if I18n.available_locales_initialized? &&
            !I18n.available_locales.include?(locale.to_sym) &&
            !I18n.available_locales.include?(locale.to_s)
            return data
          end
          locale = locale.to_sym
          translations[locale] ||= {}
          data = data.deep_symbolize_keys
          translations[locale].deep_merge!(data)
        end

Hash#deep_symbolize_keysによって全てのキーがシンボル化された上で#translations経由で翻訳データが保存されます。

#translateの後半が実際の翻訳処理になります(以下、再掲)

module I18n
  module Base
    def translate(*args)
# ...
      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
      result.is_a?(MissingTranslation) ? handle_exception(handling, result, locale, key, options) : result

backend.translateを呼び出します。色々と処理しているのですが本質的なところだけ抜き出しました↓

module I18n
  module Backend
    module Base
      def translate(locale, key, options = {})
# ...
        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
# ...        
        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

I18n::Backend::Simple#lookupは以下のようになっており、

def lookup(locale, key, scope = [], options = {})
  init_translations unless initialized?
  keys = I18n.normalize_keys(locale, key, scope, options[:separator])

  keys.inject(translations) do |result, _key|
    _key = _key.to_sym
    return nil unless result.is_a?(Hash) && result.has_key?(_key)
    result = result[_key]
    result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol)
    result
  end
end

#normalize_keyはキーをセパレータで区切って配列にします

def normalize_key(key, separator)
  @@normalized_key_cache[separator][key] ||=
    case key
    when Array
      key.map { |k| normalize_key(k, separator) }.flatten
    else
      keys = key.to_s.split(separator)
      keys.delete('')
      keys.map! { |k| k.to_sym }
      keys
    end
end

#resolveは値がシンボルやProcだった場合、追加処理をするものです。シンボルの場合は、その値をキーとしてI18n.tを再帰的に呼び出すリンク的な処理をしたり、Procの場合はcallして評価した上でさらにresolveします。ProcはYAMLで表現できないので、Rubyの翻訳ファイルを読み込んだときだけ利用可能な機能っぽいです(コード読んで初めて知った)

def resolve(locale, object, subject, options = {})
  return subject if options[:resolve] == false
  result = catch(:exception) do
    case subject
    when Symbol
      I18n.translate(subject, options.merge(:locale => locale, :throw => true))
    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

#lookup内のresolveと#translate内のresolveは役割が異なっており、キー評価の途中の解決は#lookup、キー評価後の最後の値に対して解決するのが#translateです。

例えば、キーがfoo.bar.bazでfooの値がシンボルの場合は、そのシンボルを解決するのが#lookup内のresolve、キーがfoo.bar.bazでbazの値がシンボルの場合は、そのシンボルを解決するのが#translate内のresolveとなります。

さらに、#translate内ではパラメータをバインドするinterpolationの処理も行います。

localize

#lは#localizeのエイリアスです。まず、backend.localizeを呼び出します。
def localize(object, options = nil)
  options = options ? options.dup : {}
  locale = options.delete(:locale) || config.locale
  format = options.delete(:format) || :default
  enforce_available_locales!(locale)
  config.backend.localize(locale, object, format, options)
end

objectにはDate、DateTime、Timeオブジェクトが入ります。formatは"#{type}.formats.#{key}"のキーで取得したデータを利用して、strftimeで文字列に置き換えます。

def localize(locale, object, format = :default, options = {})
  if object.nil? && options.include?(:default)
    return options[:default]
  end
  raise ArgumentError, "Object must be a Date, DateTime or Time object. #{object.inspect} given." unless object.respond_to?(:strftime)

  if Symbol === format
    key  = format
    type = object.respond_to?(:sec) ? 'time' : 'date'
    options = options.merge(:raise => true, :object => object, :locale => locale)
    format  = I18n.t(:"#{type}.formats.#{key}", options)
  end

  format = translate_localization_format(locale, object, format, options)
  object.strftime(format)
end

#translate_localization_formatではロケールごとの月や曜日などの名前をString#gsubで変換していきます。

def translate_localization_format(locale, object, format, options)
  format.to_s.gsub(/%[aAbBpP]/) do |match|
    case match
    when '%a' then I18n.t(:"date.abbr_day_names",                  :locale => locale, :format => format)[object.wday]
    when '%A' then I18n.t(:"date.day_names",                       :locale => locale, :format => format)[object.wday]
    when '%b' then I18n.t(:"date.abbr_month_names",                :locale => locale, :format => format)[object.mon]
    when '%B' then I18n.t(:"date.month_names",                     :locale => locale, :format => format)[object.mon]
    when '%p' then I18n.t(:"time.#{object.hour < 12 ? :am : :pm}", :locale => locale, :format => format).upcase if object.respond_to? :hour
    when '%P' then I18n.t(:"time.#{object.hour < 12 ? :am : :pm}", :locale => locale, :format => format).downcase if object.respond_to? :hour
    end
  end
end

Railsでやっていること

Railsの場合、I18n::RailtieというRailtieが定義されており、I18nの初期設定やconfigで設定したものを読み出します。
module I18n
  class Railtie < Rails::Railtie
    def self.initialize_i18n(app)
# ...
      reloadable_paths = []
      app.config.i18n.each do |setting, value|
        case setting
        when :railties_load_path
          reloadable_paths = value
          app.config.i18n.load_path.unshift(*value.map(&:existent).flatten)
        when :load_path
          I18n.load_path += value
        else
          I18n.send("#{setting}=", value)
        end
      end
# ...
    end

またRails::Engineではi18n.railties_load_pathにpaths["config/locales"]を追加しています。これによってconfig/locales以下のファイルがI18n.load_pathに追加されます。

module Rails
  class Engine < Railtie
    initializer :add_locales do
      config.i18n.railties_load_path << paths["config/locales"]
    end

また、ビューファイル内ではI18n.lではなくlがエイリアスとして使えます。これはビューをレンダリングするコンテキストのクラスがActionView::Baseを継承していて、ActionView::BaseがActionView::Helpers(HelpersはさらにTranslationHelperをinclude)をincludeしているためです。

module ActionView
  module Helpers
    module TranslationHelper

      def translate(key, options = {})
# ...
      end
      alias :t :translate

      def localize(*args)
        I18n.localize(*args)
      end
      alias :l :localize


module ActionView #:nodoc:
  class Base
    include Helpers, ::ERB::Util, Context


module ActionView
  module Rendering
    module ClassMethods
      def view_context_class
# ...
          Class.new(ActionView::Base) do
            if routes
              include routes.url_helpers(supports_path)
              include routes.mounted_helpers
            end

            if helpers
              include helpers
            end
          end
このエントリーをはてなブックマークに追加