概要
本記事では、AxumとTeraを組み立ててURLに含まれている言語のコードを使ってHTMLをl10nする方法を紹介します。
l10nシリーズの記事
TeraをAxumのRouterで使う
まず最初に、Teraを初期化してAxumのRouterのステートとして使えるようにしましょう。AxumのRouteで使われるステートのstruct
を定義しますが、前回の記事の通りにTeraを立ち上げます。
use tera::Tera;
#[derive(Debug, Clone)]
struct ViewRouterState {
tera: Arc<Tera>,
}
impl ViewRouterState {
fn new() -> Self {
let mut tera = Tera::new("src/views/templates/**/*").expect("tera parsing error");
let localizer = Localizer::new();
tera.register_function("fluent", localizer);
Self {
tera: Arc::new(tera),
}
}
}
Localizer
は後ほどハンドラー関数でテンプレートのl10nに使います。
このステートを使ってl10nしたいパスをRouterに入れていきます。
use axum::{
extract::State,
response::Html,
routing::get,
Router,
};
let app = Router::new().route(
"/login",
get(|State(state): State<ViewRouterState>| async move {
let ctx = Context::new();
let html = state
.tera
.render("login.html", &ctx)
.expect("Template failed");
Html(html)
}),
)
.with_state(ViewRouterState::new());
これで基本的なテンプレート駆動WebサーバーがAxumでできるのです。SQLサーバーなどへのコネクションプールも上記のViewRouterState
に入れてしまえば典型的なWebアプリの土台が整います。
言語コードをURLに含める
バックエンドでl10nをする場合、バックエンドにどのロケールにl10nすればいいかがわかるようにしておかなければなりません。通常は、その言語・地域のコードをURLに含めますが、Cookieなどでステートを伝達することも可能です。ただ、SEOの観点から、一つのURLに一つの言語があっていいはずなので、Cookieなどは望ましくないと思います。
URLに言語コードを含める場合、以下の二つの手法が主流です。
- サブドメイン(en.myapp.com)
- サブダイレクトリー(myapp.com/en/*)
今回の記事はサブダイレクトリーの手法を用いますが、紹介する方法はどちらにも使えますので、SEOの観点から適切な手法を選んでいただければと思います。
Axumにおいてサブダイレクトリーをパスに含める良い方法は今も模索中ですが、とりあえず/:lang
をパスに追加しておきます。Router::nest
を使えばいいのかもしれません。
let app = Router::new().route(
"/:lang/login",
get(|State(state): State<ViewRouterState>| async move {
// 中略
Html(html)
}),
)
.with_state(ViewRouterState::new());
これでAxumは/en/*
でも、/jp/*
でも同じハンドラーにルーティングしてくれますのでOKです。
URLからロケールをLanguageIdentifierとして抽出してAxumのExtensionに保管する
ここからが本記事の主なネタになりますが、本題に入る前に、Axumの構造について説明します。AxumはRocketなどのクレートと同様にRustでREST API等を構築することを念頭に作られたフレームワークですが、他のRustのWebフレームワークと異なって特徴的になっているのは、他のクレートを組み合わせた上で構成されていることです。
Axumは基本的に、tower
というクレートを基本にリクエストの処理を整理しています。Towerは、クレートなのですが、クレートのほとんどがService
というトレートの定義です。Towerはリクエストにレスポンスを返すというソフトウェアモデルをRustでどう表現したらいいかという問題を解決するためにそのtower::Service
のトレートを発明しました。tower::Service
の発明について素晴らしい記事があるので、ぜひ目を通していただければと思います。
Axumはtower::Service
をベースに設計および実装されているので、Rustの他のWeb系クレートと相性が非常にいいです。また、Axumを実装するにあたって、Rustのエコシステムからあれこれのクレートを縫い目なくつなぎ合わせているので、Axum自体は実は思うほど大きいフレームワークではありません。
Axumはtower::Service
の延長線で構築されていると説明しましたが、今回それが重要なのは、URLからロケールをパースするミドルウェアを実装したいからです。全てのリクエストのURLを捌く時に共通する処理になるので、自前のService
を導入して、AxumのRouter
に組み込むのです。
本記事では、Axumの正式ドキュメントでも紹介されている方法を元に実装します。
まず、第一章で実装したLocalizer
があるi18n
モジュールにlanguage_identifier
のサブモジュールを追加します。
前回の実装も含めてここでまとめて共有します。
use std::collections::HashMap;
use fluent::FluentArgs;
use fluent::{bundle::FluentBundle, FluentResource};
use unic_langid::subtags::Language;
use unic_langid::{langid, LanguageIdentifier};
mod language_identifier;
pub const ENGLISH: LanguageIdentifier = langid!("en");
pub const JAPANESE: LanguageIdentifier = langid!("ja");
pub type Locales =
HashMap<Language, FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>>;
pub struct Localizer {
locales: Locales,
}
impl Localizer {
pub fn new() -> Self {
let locales = init();
Self { locales }
}
}
impl tera::Function for Localizer {
fn call(&self, args: &HashMap<String, serde_json::Value>) -> tera::Result<serde_json::Value> {
let lang_arg = args
.get("lang")
.and_then(|lang| lang.as_str())
.and_then(|str| str.parse::<LanguageIdentifier>().ok())
.ok_or(tera::Error::msg("missing lang param"))?;
let ftl_key = args
.get("key")
.and_then(|key| key.as_str())
.ok_or(tera::Error::msg("missing ftl key"))?;
let bundle = self
.locales
.get(&lang_arg.language)
.ok_or(tera::Error::msg("locale not registered"))?;
let msg = bundle
.get_message(ftl_key)
.ok_or(tera::Error::msg("FTL key not in locale"))?;
let pattern = msg
.value()
.ok_or(tera::Error::msg("No value in fluent message"))?;
let fluent_args: FluentArgs = args
.iter()
.filter(|(key, _)| key.as_str() != "lang" && key.as_str() != "key")
.filter_map(|(key, val)| val.as_str().map(|val| (key, val)))
.collect();
let mut errs = Vec::new();
let res = bundle.format_pattern(pattern, Some(&fluent_args), &mut errs);
if errs.len() > 0 {
dbg!(errs);
}
Ok(serde_json::Value::String(res.into()))
}
fn is_safe(&self) -> bool {
true
}
}
macro_rules! create_bundle {
($locales: expr, $($path: expr),+) => {{
let mut bundle = FluentBundle::new_concurrent($locales);
$({
let ftl = std::fs::read_to_string($path).expect("FTL File not found");
let ftl = FluentResource::try_new(ftl).expect("FTL Parse Error");
bundle.add_resource(ftl).expect("unable to add resource");
})+
bundle
}};
($locales: expr, $($path: expr,)+) => {
create_bundle!($locales, $($path),+)
};
}
fn init() -> Locales {
let mut locales = HashMap::new();
let en = create_bundle!(vec![ENGLISH], "locales/en/main.ftl", "locales/en/login.ftl");
locales.insert(ENGLISH.language, en);
let ja = create_bundle!(
vec![JAPANESE],
"locales/ja/main.ftl",
"locales/ja/login.ftl",
);
locales.insert(JAPANESE.language, ja);
locales
}
そして、今回実装するロケールをパースするService
は以下のstruct
定義から始めます。
#[derive(Clone)]
pub struct LanguageIdentifierExtractor<S> {
inner: S,
}
inner
とは、tower::Service
モデルにおける他のService、もしくは実際のハンドラーオブジェクトです。Service
で構築されているWebサーバーは何層も何層もService
を実装しているレイヤーでできていると考えればいいです。ハンバーガーに噛みつけば必ず肉は口に入ってくるのですが、どんなハンバーガーかによって、バンズも違えば調味料、チーズなども変わってくるように、tower::Service
は何層重ねるかは自由として、リクエストがあればそれをさまざまな処理にかけて最終的にレスポンスを返すというふうになっています。
今回のService
を実装するLanguageIdentifierExtractor
は、SをService
の実装で制約します。このServiceは以下のロジックを行います。
- URLからロケールを探してみる
- 見つかればそのロケールをAxumのExtensionsというHashMapに保管する
- 見つからなければ、リクエストの
Accept-Language
クッキーから言語を選定してリダイレクトする
use axum::response::{IntoResponse, Redirect, Response};
use http::{HeaderMap, Request, Uri};
use std::future::Future;
use std::pin::Pin;
use tower::Service;
use unic_langid::LanguageIdentifier;
use super::{ENGLISH, JAPANESE};
// 今回のServiceはHTTPリクエストを処理するものに制約します。BはリクエストのBodyの
impl<S, B> Service<Request<B>> for LanguageIdentifierExtractor<S>
where
// innerは他のHTTPリクエストを処理する`Service`に限定する
// また、Axumのaxum::response::Responseを返すことにも限定します。これだと
// Axumでしか使えないのですが、これはリダイレクトをしているためです。
S: Service<Request<B>, Response = Response> + Send + 'static,
// Send + 'staticを制約に含めるのは、マルチスレッドのWebサーバーにおいて、
// 実際にどのスレッドがこれらの`Service`の集合体を持って非同期の処理をするのかは
// わからないから、スレッドセーフでないといけないからです。
S::Future: Send + 'static,
{
type Error = S::Error;
// dynを使って、ロジックによって違うFutureを返せるようにします。
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
// このServiceが作る全ての結果はaxum::response::Responseになる
type Response = Response;
// 今回はこれが無関係ですが、リクエストにレスポンスを返す時に、
// リクエストが多すぎて他のリソース(データベース等)が間に合わないことがわかれば、
// サーバーがパンクしないように何らかの条件的ブレーキをかけるためのメソッドです。
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<B>) -> Self::Future {
let lang_ident = lang_code_from_uri(req.uri());
// ロケールが見つかったらAxumのextensionsに追加して処理を続ける
// 見つからなければ、ブラウザの言語クッキーから好みの言語を見つけてリダイレクトする
if let Some(lang_ident) = lang_ident {
let ext = req.extensions_mut();
ext.insert(lang_ident);
Box::pin(self.inner.call(req))
} else {
let headers = req.headers();
let ident = lang_code_from_headers(headers);
let target_lang = ident.unwrap_or(ENGLISH);
let redirect_uri = String::from("/") + target_lang.language.as_str() + req.uri().path();
Box::pin(async move { Ok(Redirect::permanent(&redirect_uri).into_response()) })
}
}
}
/// パスを解読してロケールがあればそれを返す
/// サポートされている言語じゃないとNoneを返す
fn lang_code_from_uri(uri: &Uri) -> Option<LanguageIdentifier> {
todo!("implement lang code extractor")
}
/// Cookiesからリクエストが好む言語を選定して返す
fn lang_code_from_headers(headers: &HeaderMap) -> Option<LanguageIdentifier> {
todo!("implement lang code extractor")
}
上記のサービスをレイヤーとしてAxumのサーバーに組み込むためには、tower::Layer
も実装すしますが、こちらは簡単です。レイヤーになるstruct
をまた別に用意して、tower::Layer
を実装します。
use tower::{Layer, Service};
#[derive(Debug, Clone)]
pub struct LanguageIdentifierExtractorLayer;
impl<S> Layer<S> for LanguageIdentifierExtractorLayer {
type Service = LanguageIdentifierExtractor<S>;
fn layer(&self, inner: S) -> Self::Service {
LanguageIdentifierExtractor { inner }
}
}
これで上記のRouter
にレイヤーとして追加することができます。
use self::i18n::{LanguageIdentifierExtractorLayer, Localizer, TeraLanguageIdentifier};
let app = Router::new().route(
"/:lang/login",
get(|State(state): State<ViewRouterState>| async move {
// 中略
Html(html)
}),
)
.layer(LanguageIdentifierExtractorLayer)
.with_state(ViewRouterState::new());
これで全てのリクエストがパースされて、言語ロケールがAxumのExtensionsに保管されて、ハンドラーで使えるようになるか、適切な言語のURLにリダイレクトされるかです。
ちなみに、サブダイレクトリーではなく、サブドメインでロケールデータを表現しているなら、リダイレクトのロジックと下記のURIをパースするロジックを適切に修正するだけで上記の考え方を使い回せるかと思います。
LanguageIdentifierExtractorで使われるi18n関連の関数を実装する
ここは興味のある方が参照にするための実装なので、不要な方は飛ばしてください。Teraのtera::Context
にLanguageIdentifier
を追加するために、newtypeのTeraLanguageIdentifierも実装しています。
use serde::ser::Serialize;
#[derive(Debug, Clone)]
pub struct TeraLanguageIdentifier(LanguageIdentifier);
impl Serialize for TeraLanguageIdentifier {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.language.to_string().as_str())
}
}
impl std::ops::Deref for TeraLanguageIdentifier {
type Target = LanguageIdentifier;
fn deref(&self) -> &Self::Target {
&self.0
}
}
lang_code_from_uri
URIの最初のパスをLanguageIdentifier
に変換してみて、さらにそれがこのWebアプリでサポートされている言語かどうかもチェックします。
fn supported(lang: &LanguageIdentifier) -> bool {
lang.language == ENGLISH.language || lang.language == JAPANESE.language
}
fn lang_code_from_uri(uri: &Uri) -> Option<TeraLanguageIdentifier> {
let mut path_parts = uri.path().split('/');
path_parts.next();
path_parts
.next()
.and_then(|code| code.parse::<LanguageIdentifier>().ok())
.and_then(|ident| if supported(&ident) { Some(ident) } else { None })
.map(|ident| TeraLanguageIdentifier(ident))
}
lang_code_from_headers
Accept-Languageのクッキーから最もユーザーに合う言語を探すロジックです。
fn lang_code_from_headers(headers: &HeaderMap) -> Option<LanguageIdentifier> {
let accept_lang = headers
.get("Accept-Language")
.and_then(|val| val.to_str().ok())?;
accept_lang
.parse::<LanguageIdentifier>()
.ok()
.and_then(|ident| if supported(&ident) { Some(ident) } else { None })
.or_else(|| {
accept_lang
.split(',')
.filter(|part| !part.is_empty())
// 優先度の;q=は判定に使わないので無視
.map(|part| part.find(|c| c == ';').map(|i| &part[..i]).unwrap_or(part))
.filter_map(|ident_str| ident_str.parse::<LanguageIdentifier>().ok())
.find(|ident| supported(ident))
})
}
ハンドラー関数でAxumのExtensionからLanguageIdentifierを取り出してHTMLテンプレートをl10nする
最後に、最終的なAxumのハンドラー関数でロケールをTeraに渡してテンプレートをレンダーするロジックを紹介して〆たいと思います。
let app = Router::new().route(
"/:lang/login",
get(|State(state): State<ViewRouterState>,
Extension(lang): Extension<TeraLanguageIdentifier>,
| async move {
let mut ctx = Context::new();
ctx.insert("lang", &lang);
let html = state
.tera
.render("login.html", &ctx)
.expect("Template failed");
Html(html)
}),
)
.layer(LanguageIdentifierExtractorLayer)
.with_state(ViewRouterState::new());
ちなみにlogin.html
では以下のように、前章で紹介した通りに使っています。
<div class="button-group">
<button class="button button-left inactive" id="webauthn-button">
{{ fluent(key="listo-registration-webauthn-button", lang=lang) }}
</button>
<button class="button button-right" id="webauthn-new-button">
{{ fluent(key="listo-registration-webauthn-new-button", lang=lang) }}
</button>
</div>
結果は以下のような感じです。筆者は筆者初のWebAuthn実装も誇らしくも映っています。
英語
日本語
ブラウザ任せ
筆者はMacの設定が英語、日本語の順に優先が決まっているので、/login
から/en/login
にリダイレクトされました。
まとめ
第三章で、第一章、第二章で培った知識を活かしてAxumの力を借りて完成携帯に持ってきましたが、いかがでしょうか?
他にもさまざまなi18nおよびl10nの実装の仕方とシステム設計の考え方がありますが、このシリーズでRustでいかにしてl10nが可能なWebアプリを作れるかをかなり深掘りできたかと思います。他の方がどのようにしてl10nを解決しているのかぜひコメントで聞かせてください。
また、今回は、非常にクラシックなJavaScriptフレームワークなしで王道のやり方にしましたが、Angular 17が非常に有望で、筆者はRust + Axum + Fluentのバックエンドi18n REST APIに、Angular 17 SSRのi18nを組み合わせることも楽しいかもしれないと、やってみたくなりました。Angularは昔から非常に優れたi18nシステムを導入していて、筆者は絶賛します。ただ、Angular 17で完成したSSRでどれほどAngularの初期読み込み問題を解決できているか気になるし、低パフォーマンスの端末でAngular 17がどう改善できたのか、まだ確認していません。
純粋なHTML + JavaScriptのWebComponentsに勝つフレームワークは存在しないのと、HTML + Vanilla JavaScript/CSSはタイムレス(技術負債にならない)。なので、この王道のやり方にも非常に魅力があるかと思います。成功したWebアプリを開発している大手の会社のほとんどはJavaScriptフレームワークを使っていないか、途中から脱出していることを念頭に置いての話です。
いずれにしても、i18nは険しい道のりなので、それを少しでも助けてくれるツールがあれば使いたいです。今回のFluentの魅力を少しでも共有できたらと思います。