エンジニアの佐々木です。
皆さんは写真で一言ボケてでおなじみの"ボケて"を知っていますか?
ここ最近身近なところで"ボケて"が流行ってます。 自分も少し前にボケる側としてボケてデビューしました。アカウントはさらしません。殿堂入りできたら考えます。
多言語化対応中に直面した課題
今回はpixtaのサイトの多言語化対応にまつわる記事となります。
といいましても、サイトは既に6言語(日本語、英語、繁体字、簡体字、タイ語、韓国語)に対応しており、その対応自体も既に終わっているのですが、その過程で直面したとある技術的課題とその課題に対してどう対処したかの話となります。
いきなりですが、問題です。
問題1. 下記のロケール設定、ファイル構成で「render "/search/hoge"」とした場合に表示されるhogeファイルはA、Bどちらでしょうか? Rails 4.2.10 I18n.default_locale = :ja I18n.locale = :en app/views/search/ _hoge.ja.html.erb _hoge.html.erb A. _hoge.ja.html.erb B. _hoge.html.erb
現在のロケールが:en
という事で、無印のB. _hoge.html.erb
が表示されてほしいところですが、結果はA. _hoge.ja.html.erb
となります。
個人的な直感としては現在のロケールに特化したビューが存在しなければ、無印のビューが選択されるだろうと考えていたのですが、そうではありませんでした。
これがまさに当時直面した課題となります。
ローカライズドビューの切り出し方に問題があるのでは?と思われた方もいるかもしれません。 ですが、pixtaのサイトは日本国内から成長し、その後に最初の多言語化対応として英語化が行われ、英語版を元に他言語用に展開していくという流れがありました。 そのため、日本語と英語含めた他言語の2種類の形で切り出す事が多かったのです。
調査します
「設定中のロケールに合致するビューが存在しない場合の無印なビューとデフォルトロケールなビューの表示優先度について」
動作の結果は既に出ていますが、理解を確かなものにするべく調べる事にしました。
まずはRails Guide
からLocalized Views
について確認しました。
Rails Internationalization (I18n) API — Ruby on Rails Guides
Localized Views
を簡単に説明すると、esロケール
用にindex.es.html.erb
の拡張子の形でビューを用意、配置する事でレンダリングの際にRailsがアプリケーションの設定ロケール(例. es)に従ってビュー(例. index.es.html.erb)を選択してくれるというRails
のInternationalization (I18n)
の中の機能です。
ですが、課題を解決する方法がガイドからは見つけられなかったため、次にRails
のソースコードを追ってみる事にしました。
Localized Views
におけるロケールの扱いを確認していきます。
#actionview-4.2.10/lib/action_view/template/resolver.rb def query(path, details, formats, outside_app_allowed) query = build_query(path, details) template_paths = find_template_paths query template_paths = reject_files_external_to_app(template_paths) unless outside_app_allowed template_paths.map { |template| handler, format, variant = extract_handler_and_format_and_variant(template, formats) contents = File.binread(template) Template.new(contents, File.expand_path(template), handler, :virtual_path => path.virtual, :format => format, :variant => variant, :updated_at => mtime(template) ) } end
ざっくりな解説ですが、パスのパターンを導出し(build_query)、そのパターンにマッチするファイルを探し(find_template_paths)、からのロード的な流れ(Template.new)と思われます。
パスのパターン導出について知りたいので、build_queryを掘り下げてみます。
#actionview-4.2.10/lib/action_view/template/resolver.rb # An Optimized resolver for Rails' most common case. class OptimizedFileSystemResolver < FileSystemResolver #:nodoc: def build_query(path, details) query = escape_entry(File.join(@path, path)) exts = EXTENSIONS.map do |ext, prefix| "{#{details[ext].compact.uniq.map { |e| "#{prefix}#{e}," }.join}}" end.join query + exts end end
出現する定数ですが、下記のようにパスのパターン、拡張子のパターン(構成要素)という形で定義されており
#actionview-4.2.10/lib/action_view/template/resolver.rb EXTENSIONS = { :locale => ".", :formats => ".", :variants => "+", :handlers => "." } DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"
build_queryメソッドでは、EXTENSIONSを順番に処理、つまりはlocale -> formats -> variants -> handlerの順番で値の肉付けを行っています。
肉付けされる値はメソッド引数のdetails
が元になっているようですが
# I18n.default_locale = :ja # I18n.locale = :enのときのdetailsの中身の例 {:locale=>[:en, :ja], :formats=>[:html], :variants=>[], :handlers=>[:erb, :builder, :raw, :ruby, :coffee, :jbuilder]}
このようになっているようで、details
を構築するコードはActionView::LookupContext
に存在し、その中のlocale
についてはさらにこのように構築していました。
#actionview-4.2.10/lib/action_view/lookup_context.rb register_detail(:locale) do locales = [I18n.locale] locales.concat(I18n.fallbacks[I18n.locale]) if I18n.respond_to? :fallbacks locales << I18n.default_locale locales.uniq! locales end
I18n.locale
を代入、(fallbacks設定が無ければ)続けてI18n.default_locale
の順番で代入。これは例えば[:en, :ja]
のような結果となります。
そんなこんなでbuild_queryが動き、最終的にはとあるビューのパス("/search/hoge")が
# I18n.default_locale = :ja # I18n.locale = :enのときの"/search/hoge"のパス探索 "/home/sasaki/demo/app/views/search/_hoge{.en,.ja,}{.html,}{}{.erb,.builder,.raw,.ruby,.coffee,.jbuilder,}"
となり、locale
部分を抜粋すると
_hoge{.en,.ja,}...
これはカンマ区切りで3つの要素で構成されており、左から.en、.ja、(localeなしの無印)で
- _hoge.en.html.erb
- _hoge.ja.html.erb
- _hoge.html.erb
このような順番、つまり、ロケールだけで見ると
- I18n.locale値
- I18n.default_locale値
- 無印
の順番でのファイル探索となっており、コードリーディングの結果、I18n.default_locale
のビューは、無印のビューよりも探索の優先度が高い事がわかりました。
これがI18n.locale = :en
な状況で_hoge.ja.html.erb
が表示される原因でした。
解決へ向けて
ここまでで動作(ロケールの優先度)の理解はできました。
ここからどうすれば今回実現したい状況に持っていけるかを考えるのですが、ここまでのコードリーディングの過程で
skip_default_locale! (ActionView::LookupContext)
という処理の存在に気付きました。
# Do not use the default locale on template lookup. def skip_default_locale! @skip_default_locale = true self.locale = nil end
コメントからもドンピシャだったのですが、このskip_default_locale!
はパス導出過程におけるlocale
部分の定義
_hoge{{I18n.locale},{I18n.default_locale},{無印}}...
これを
_hoge{{I18n.locale},{無印}}...
この形のようにパス導出中のロケールの候補から、I18n.default_locale
を取り除いてくれるようです。
各言語用のサイトにてI18n.locale値が正しく設定されている前提ですが、この挙動は期待と合致しそうでした。
期待する挙動にしてくれそうな他の案を含めて、それぞれ比較検討を行いました。
- I18n.default_locale = I18n.localeの状態を維持する
- I18n.default_localeを定義しない
- controllerでlookup_context.skip_default_locale!で無効化する
1は実は既に他のマイクロサービスで採用されているという事もあり、導入までの見通しはたて易かったのですが、そこに至るまでのコストが他の案に比べて高いという理由で不採用にしました。 2はそもそもいけてない匂いがしたために却下。 最終的にはWebの処理に閉じれ影響範囲の見極めが容易く低コスト感のある3を採用する方向で進めました。
今回は以上となりますが、pixtaでは海外事業の成長、海外拠点における開発促進向上等から、さらなる多言語化対応が進んでいます。 いずれ関連する内容が記事化されると思います。お楽しみに。
最後に一句。
いいボケが 出ないと嘆く 事業部長
ボケての話ですが、オフが充実しているからこそ良い仕事ができるという訳ですね。
ごきげんよう。