コンテンツにスキップ

ロケールルーティング

ページの翻訳を導入すると、次は「このリクエストはどの言語で 返すのか」を誰かが決める必要が出てきます。この判断はハンドラではなくルートのもの です。同じ判断が「その応答をどうキャッシュしてよいか」も決めていて、ここを間違えた ページはある読者の言語を次の読者に返します

1言語で運用しているプロジェクトにはこれは要りません。[i18n] ブロックがなければ 方式も交渉も Vary も存在しない。2つ目の言語ができた瞬間から効いてくる話です。

方式は3つ、パス接頭辞ごとに宣言します。

[i18n]
locales = ["ja", "en"]
default_locale = "ja"
path_routes = ["/"]
cookie_routes = ["/admin/"]
header_routes = ["/api/"]

最長一致が勝つので、上の /api// に覆われていてもヘッダー交渉になります。

公開ページは path_routes 言語がパスセグメントなので、2言語は2つの URL です。 Vary が付かず、共有キャッシュが普通に効き、hreflang に指す先ができます。検索エンジンが 言語ごとにインデックスできるのはこの方式だけです。

認証後のアプリケーションは cookie_routes 読者が設定画面で選んだ言語は残るべき なので、保存された選択がブラウザのヘッダーより優先されます。代償は本物です。応答が Cookie に Vary し、HTTP は「この Cookie にだけ Vary する」を表現できないので、 共有キャッシュがセッションごとに割れます。認証済みページはもともとそういうものなので、 このコストが公開ツリーではなくここに落ちるのは筋が通っています。

API は header_routes ネイティブクライアントは端末が報告する言語を送り、Cookie を 管理しません。そして本文が Cookie の状態で変わる API は、クライアントが言っていない 理由で同じリクエストに2通りの答えを返していることになります。

宣言したどの接頭辞にも当たらないパスは、URL に何も出さずに既定言語で返します。 だから一部のサブツリーにだけ i18n を入れても、残りは今まで通りです。

方式 決め方 Vary
path URL の接頭辞 なし
cookie ロケール Cookie、次に Accept-Language Cookie, Accept-Language
header Accept-Language Accept-Language

交渉するルートは、そのシグナルを実際に受け取った応答だけでなく全応答に Vary を 付けます。ユーザー設定の扱いとはここが違っていて、 理由を書いておく価値があります。カラースキームには床があります — CSS がサーバー抜きで 正しく答えるので、シグナルなしで組み立てた応答は誰にとっても正しい。言語には床が ありません。 Cookie を持たない読者が来ると、Vary なしの既定言語が共有キャッシュに 入り、それを Cookie で別の言語を選んでいる次の読者が受け取ります。

どの方式でも Content-Language は付きます。

path_routes では /about はどの言語も名乗っていません。交渉して /ja/about へ リダイレクトします。301 ではなく 302 です。行き先は誰が聞いたかで変わるので、 恒久ステータスにすると同じプロキシの後ろにいる全員に、ある1人の交渉結果がキャッシュ されてしまいます。

接頭辞の位置は常に言語として読みます。ドイツ語を宣言していないときの /de/about は 壊れたロケールではなく普通のパスです。これが、ドイツ語を追加した日に /de/ という 名前のルートが壊れるのを防いでいます。

prefix_default = false

/about が日本語になり、/ja/about はそこへ恒久リダイレクトします。ルートで交渉 リダイレクトが要らなくなるので、最も重要な訪問で往復が1回減ります

既定は true です。理由は後から効いてきます。false のもとで既定言語を変えると、 サイトの全 URL が移動しますtrue ならルートのリダイレクト先が変わるだけです。 主要言語がこの先変わらないと確信できるときに false を取ってください。

生成されたメッセージは第1引数にロケールを取ります。だから同じ関数がハンドラでも、 バッチでも、メール生成でも、プッシュ通知でも動きます。「ライブラリ用の別モード」は ありません — 生成される surface がすでにそれです。

package handlers
import (
"net/http"
"github.com/shibukawa/popcornweb/pw"
"example.com/app/messages"
)
func Welcome(w http.ResponseWriter, r *http.Request) {
greeting := messages.ShopGreeting(pw.LocaleContext(r.Context()), "Ada")
pw.WriteHTML(w, r, Page(greeting))
}

リクエストの外には読むべきコンテキストがないので、記録されている値から解決します。

func SendReceipt(ctx context.Context, account Account) error {
locale, ok := pw.ParseLocale(account.Language)
if !ok {
locale = pw.DefaultLocale()
}
return mail.Send(account.Address, messages.MailReceipt(locale, account.Name))
}

ParseLocale は既定値で埋めずに「無い」と報告します。保存された設定が宣言済み言語で なくなっていることは、知る価値のある事実だからです。マッチングは RFC 4647 に従うので、 ja-JPja に当たります。

テンプレートの外で URL を組むとき — リダイレクト先、メール本文中のリンク、プッシュの ディープリンク — は pw.LocalePath がテンプレートのバインディングと同じ接頭辞規則を 適用するので、呼び出し側が方式で分岐する必要はありません。

API の応答をサーバーで翻訳すべきか

Section titled “API の応答をサーバーで翻訳すべきか”

たいていは不要です。Problem レスポンスはすでに機械 可読なコードを持っていて、それを自分で訳すネイティブクライアントは、端末の言語を正確に 知り、オフラインでも動き、アプリのバージョンと揃った文言を持っています。サーバーが 選んだ文字列はこの3点すべてで劣ります。

サーバーが訳すのは、クライアントにできない場合です。メール、プッシュ通知、帳票、 管理者が設定した文言、そして自前のカタログを持てないクライアント。フレームワークが 自分の判断で訳すことはありません — 訳文が欲しいときは、組み立てた文字列を Problem の コンストラクタに渡します。

ロケールは必ず宣言済みのもので、リクエストから反射した値は使えません。 ParseLocale がそれを保証します。任意のタグが URL に届くと、マッチしなかった Accept-Language が片端からオリジンで別アドレスになり、これは表示崩れではなく 上限のないキャッシュ面です。

Cookie 方式はパス方式の安価版ではありません。 共有キャッシュを完全に手放します。 ページが公開向けなら、コストの向きが逆です。

キャッシュされたコンポーネントは読んだ言語をキーに含めます。 これは自動で、 メッセージを描画するコンポーネントは言語間で共有されなくなります。メッセージを 読まないコンポーネントのキーは以前のままです。

カタログの形式、複数形、言語切り替えは ページの翻訳にあります。