てくすた

ピクスタ株式会社のエンジニア・デザイナーがつづるよもやまテクニカルブログです

サイトの多言語化対応でskip_default_locale!を使った話

エンジニアの佐々木です。

皆さんは写真で一言ボケてでおなじみの"ボケて"を知っていますか?

ここ最近身近なところで"ボケて"が流行ってます。 自分も少し前にボケる側としてボケてデビューしました。アカウントはさらしません。殿堂入りできたら考えます。

多言語化対応中に直面した課題

今回は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)を選択してくれるというRailsInternationalization (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なしの無印)で

  1. _hoge.en.html.erb
  2. _hoge.ja.html.erb
  3. _hoge.html.erb

このような順番、つまり、ロケールだけで見ると

  1. I18n.locale値
  2. I18n.default_locale値
  3. 無印

の順番でのファイル探索となっており、コードリーディングの結果、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値が正しく設定されている前提ですが、この挙動は期待と合致しそうでした。

期待する挙動にしてくれそうな他の案を含めて、それぞれ比較検討を行いました。

  1. I18n.default_locale = I18n.localeの状態を維持する
  2. I18n.default_localeを定義しない
  3. controllerでlookup_context.skip_default_locale!で無効化する

1は実は既に他のマイクロサービスで採用されているという事もあり、導入までの見通しはたて易かったのですが、そこに至るまでのコストが他の案に比べて高いという理由で不採用にしました。 2はそもそもいけてない匂いがしたために却下。 最終的にはWebの処理に閉じれ影響範囲の見極めが容易く低コスト感のある3を採用する方向で進めました。

今回は以上となりますが、pixtaでは海外事業の成長、海外拠点における開発促進向上等から、さらなる多言語化対応が進んでいます。 いずれ関連する内容が記事化されると思います。お楽しみに。

最後に一句。

いいボケが 出ないと嘆く 事業部長

ボケての話ですが、オフが充実しているからこそ良い仕事ができるという訳ですね。

ごきげんよう。