RustのWeb開発でl10nの道を探る、第三章：AxumでURLからロケールを選定してHTMLテンプレートをl10nする

# 概要

本記事では、AxumとTeraを組み立ててURLに含まれている言語のコードを使ってHTMLをl10nする方法を紹介します。


## l10nシリーズの記事

- [第一章：Fluent](https://qiita.com/tronicboy/items/67d3cd52385044ba159f)
- [第二章：FluentをTeraで使ってHTMLテンプレートをl10nする](https://qiita.com/tronicboy/items/7e192513520237e907cc)
- [第三章：AxumでURLからロケールを選定して](https://qiita.com/tronicboy/items/30791bffce3723f0fc1f) (本記事)

# TeraをAxumのRouterで使う

まず最初に、Teraを初期化してAxumのRouterのステートとして使えるようにしましょう。AxumのRouteで使われるステートの`struct`を定義しますが、前回の記事の通りにTeraを立ち上げます。

https://qiita.com/tronicboy/items/7e192513520237e907cc

```rust
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に入れていきます。

```rust
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`を使えばいいのかもしれません。

```rust
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`の発明について素晴らしい[記事](https://tokio.rs/blog/2021-05-14-inventing-the-service-trait)があるので、ぜひ目を通していただければと思います。

Axumは`tower::Service`をベースに設計および実装されているので、Rustの他のWeb系クレートと相性が非常にいいです。また、Axumを実装するにあたって、Rustのエコシステムからあれこれのクレートを縫い目なくつなぎ合わせているので、Axum自体は実は思うほど大きいフレームワークではありません。

Axumは`tower::Service`の延長線で構築されていると説明しましたが、今回それが重要なのは、URLからロケールをパースするミドルウェアを実装したいからです。全てのリクエストのURLを捌く時に共通する処理になるので、自前の`Service`を導入して、Axumの`Router`に組み込むのです。

本記事では、Axumの正式ドキュメントでも紹介されている方法を元に実装します。

https://docs.rs/axum/latest/axum/middleware/index.html#towerservice-and-pinboxdyn-future

まず、第一章で実装した`Localizer`がある`i18n`モジュールに`language_identifier`のサブモジュールを追加します。

前回の実装も含めてここでまとめて共有します。

```rust:src/i18n.rs
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`定義から始めます。

```rust:src/i18n/language_identifier.rs
#[derive(Clone)]
pub struct LanguageIdentifierExtractor<S> {
    inner: S,
}
```

`inner`とは、`tower::Service`モデルにおける他のService、もしくは実際のハンドラーオブジェクトです。`Service`で構築されているWebサーバーは何層も何層も`Service`を実装しているレイヤーでできていると考えればいいです。ハンバーガーに噛みつけば必ず肉は口に入ってくるのですが、どんなハンバーガーかによって、バンズも違えば調味料、チーズなども変わってくるように、`tower::Service`は何層重ねるかは自由として、リクエストがあればそれをさまざまな処理にかけて最終的にレスポンスを返すというふうになっています。

今回の`Service`を実装する`LanguageIdentifierExtractor`は、Sを`Service`の実装で制約します。このServiceは以下のロジックを行います。

1. URLからロケールを探してみる
1. 見つかればそのロケールをAxumのExtensionsというHashMapに保管する
1. 見つからなければ、リクエストの`Accept-Language`クッキーから言語を選定してリダイレクトする

```rust
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`を実装します。

```rust
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`にレイヤーとして追加することができます。

```rust
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も実装しています。

```rust
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アプリでサポートされている言語かどうかもチェックします。

```rust
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のクッキーから最もユーザーに合う言語を探すロジックです。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Accept-Language


```rust
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に渡してテンプレートをレンダーするロジックを紹介して〆たいと思います。

```rust
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`では以下のように、前章で紹介した通りに使っています。

```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実装も誇らしくも映っています。

### 英語

![Screenshot 2023-11-16 at 16.52.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/c6813b68-53ca-ff9f-4d1b-926e3ebdbf27.png)


### 日本語

![Screenshot 2023-11-16 at 16.53.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/418a5597-1d0d-5df2-f694-415106f15f63.png)


### ブラウザ任せ

筆者はMacの設定が英語、日本語の順に優先が決まっているので、`/login`から`/en/login`にリダイレクトされました。

![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323334343935362f64366162653937362d643530642d313736382d343032322d6339643764383835366161652e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/2aeedca0-3649-a7a3-3933-e09ee19db89b.png)


# まとめ

第三章で、第一章、第二章で培った知識を活かして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の魅力を少しでも共有できたらと思います。