LoginSignup
0
0

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

Last updated at Posted at 2023-11-16

概要

本記事では、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のサブモジュールを追加します。

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

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定義から始めます。

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からロケールを探してみる
  2. 見つかればそのロケールをAxumのExtensionsというHashMapに保管する
  3. 見つからなければ、リクエストの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::ContextLanguageIdentifierを追加するために、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実装も誇らしくも映っています。

英語

Screenshot 2023-11-16 at 16.52.27.png

日本語

Screenshot 2023-11-16 at 16.53.57.png

ブラウザ任せ

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

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323334343935362f64366162653937362d643530642d313736382d343032322d6339643764383835366161652e706e67.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の魅力を少しでも共有できたらと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0