ブラウザに複数の言語設定がある場合、Safari は次のようなヘッダーを送信することがある:
Accept-Language: ja-KS, ja;q=0.9, en-GB;q=0.7, en;q=0.6, *;q=0.1, de;q=0
このヘッダーの意味:関西方言を最も希望し、次に標準日本語、イギリス英語も許容、他の言語も最低限なら受け入れるが、ドイツ語は明示的に拒否する。
多くの Phoenix アプリケーションはこのヘッダーを正しく処理できていない。その理由を見ていこう。
罠 1:最初の言語だけを取得する
よくある素朴な実装:
accept = get_req_header(conn, "accept-language") |> List.first()
locale = accept |> String.split(",") |> List.first() |> String.trim()
# => "ja-KS"
結果は "ja-KS" になる。サーバーが [:ja, :en] をサポートしている場合、完全一致するものがないため、デフォルトの英語が返される。日本語が通じるにもかかわらず。
問題は、ja-KS がプレフィックス一致で :ja に対応すべきだという点にある。RFC 4647 Section 3.3.1 では、range が tag と一致するのは、完全一致するか、tag のプレフィックスとして一致し、その直後の文字がハイフン - である場合と定義されている。
罠 2:quality value を無視する
もう少し改善したコードでは、すべての言語を抽出するかもしれないが、q-value を無視してしまう:
accept
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&(String.split(&1, ";") |> List.first()))
# => ["ja-KS", "ja", "en-GB", "en", "*", "de"]
リストを順に走査して最初に一致したものを選ぶ。この例ではうまくいくが、ヘッダーが en;q=0.7, fr;q=0.9 の場合、フランス語が優先されるべきところで英語が選ばれてしまう。
罠 3:偽のプレフィックス一致
プレフィックス zh は zh-TW(台湾華語)に一致すべきだが、zhx(別の言語)には一致してはならない。境界はハイフンでなければならない。String.starts_with?/2 を使った実装では誤判定が起きる:
String.starts_with?("zhx", "zh")
# => true — 誤検出!
罠 4:除外指定を忘れる
上記のヘッダーで de;q=0 は「ドイツ語は明示的に受け入れ不可」を意味する。サーバーが [:de, :en] をサポートし、ワイルドカード * が存在する場合でも、ドイツ語を選んではならない。q=0 による除外はプレフィックス一致にも適用される:en;q=0 は en-US や en-GB も除外する。
正しい解決策
accept_language ライブラリは RFC 4647 の Basic Filtering スキームを実装し、上記のケースをすべて正しく処理する。2019年に公開された同名の Ruby gem(当時 Qiita でも紹介)に基づいた Elixir 実装である。
AcceptLanguage.negotiate("ja-KS, ja;q=0.9, en-GB;q=0.7, en;q=0.6, *;q=0.1, de;q=0", [:ja, :en])
# => :ja
# ハイフン境界を尊重したプレフィックス一致
AcceptLanguage.negotiate("zh", [:"zh-TW"])
# => :"zh-TW"
AcceptLanguage.negotiate("zh", [:zhx])
# => nil
# ワイルドカードは除外を尊重する
AcceptLanguage.negotiate("*, de;q=0", [:de, :fr])
# => :fr
# q=0 はプレフィックスでも除外される
AcceptLanguage.negotiate("*, en;q=0", [:"en-GB", :fr])
# => :fr
依存関係ゼロ、公開関数は negotiate/2 のみ、Elixir ~> 1.14 対応。
インストール
mix.exs に追加:
def deps do
[
{:accept_language, "~> 0.1.0"}
]
end
Phoenix との統合
実際のアプリケーションでは、表示言語はコンテキストによって異なる:
-
ログイン済み — アカウントに保存された言語設定を優先し、
Accept-Languageヘッダーは無視する - 未ログイン、ヘッダーあり — サポート言語の中から最適な言語をネゴシエーションする
- 未ログイン、ヘッダーなし — 初期化時に指定したデフォルト言語を使用する
このロジックを実装する Plug を示す。lib/my_app_web/plugs/locale.ex:
defmodule MyAppWeb.Plugs.Locale do
@moduledoc """
Sets the Gettext locale for each request.
Resolution order:
1. **Authenticated** — uses the account's `lang_preference`
unconditionally. The `Accept-Language` header is ignored.
2. **Unauthenticated, with `Accept-Language` header** — negotiates
the best locale among all supported locales.
3. **Unauthenticated, without header** — falls back to the default
locale given at plug initialization.
## Usage in the router
plug MyAppWeb.Plugs.Locale, "en"
"""
import Plug.Conn
@supported_locales ~w(en ja)a
@supported_locale_strings Enum.map(@supported_locales, &Atom.to_string/1)
def init(default) when default in @supported_locale_strings, do: default
def call(conn, default) do
locale = extract_locale(conn, default)
Gettext.put_locale(MyAppWeb.Gettext, locale)
assign(conn, :locale, locale)
end
defp extract_locale(conn, default) do
case conn.assigns[:current_scope] do
%{account: %{lang_preference: lang}} when not is_nil(lang) ->
to_string(lang)
_ ->
conn
|> get_req_header("accept-language")
|> List.first()
|> negotiate(default)
end
end
defp negotiate(nil, default), do: default
defp negotiate(header, default) do
case AcceptLanguage.negotiate(header, @supported_locales) do
nil -> default
locale -> Atom.to_string(locale)
end
end
end
lib/my_app_web/router.ex の :browser パイプラインに追加:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.Locale, "en"
end
@supported_locales が言語を追加する際の唯一の変更箇所になる。デフォルトロケール "en" は init/1 の guard でコンパイル時に検証される。