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
#loadfileは”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