LoginSignup
6
2

RustのWeb開発でl10nの道を探る、第一章:Fluent

Last updated at Posted at 2023-11-02

ハイサイ!オースティンやいびーん!

概要

Rustアプリケーションの国際化および地域化(i18nとl10n)について考慮し、FluentとFluentのRust実装を紹介する

本記事の動機

筆者はRustでのWeb開発において、国際化の技術とノウハウを取得したく最近調査と勉強を繰り返しています。

その学習のまとめとして本記事を皮切りに、Rustで国際化できるWebアプリケーションの模索を複数の記事に分けて紹介したいと思っています。

l10nシリーズの記事

Rustにおけるl18nライブラリ

どの言語においてもl18nは非常に複雑で困難を極めています。なぜなら、人と話す言語とコンピューターと話す言語の両方に振り回されるからです。これらの言語は人間が思い描ける最大の煩雑さを持ち合わせているし、特に人の言葉は言語が変われば文法も文字も変わるので、それをプログラミング言語で表現するのもまた大変なことです。

gettext

その煩雑さを少しでも減らすためにいくつかのl18nに対する考え方がありますが、主に浸透しているのは1993年に(筆者が生まれる一年前:flushed:)Sun社が発案した「gettext」の手法です。

Gettext手法は、よく見かける.poファイルに翻訳したテキストを入れて、.moファイルにコンパイルし、実行時にはコンパイル済みの.moファイルを参照しながらメッセージのl10nをする仕組みです。

Rustにもgettextを実装したライブラリがあり、gettext-sysをRustで安全に使えるように包んだ実装になります。

gettextは時代遅れ?

筆者もgettextを以前から使っているのでRustで引き続きGettextを使おうと一瞬迷いましたが、もう少し深掘りしたらどうやらこのgettext手法が時代遅れと言われているらしいです。

時代遅れは過言かもしれませんが、どうやら、言語を訳す時に文法や数量詞が文の構造を大きく変えることがあるらしく、gettextのように静的コンパイルが相応しくないことがあるらしいのです。

筆者は英語、日本語、中国語とスペイン語しか馴染みはないのですが、確かに英語を日本語や中国語に訳す時に数量詞がややこしくなるのは経験したことがあります。それで不自然な文にするのか、訳し方を工夫するのか、いずれにしても翻訳としては妥協していることはあるかもしれないなと頷けます。

Fluentプロジェクト:現代的なi10nシステム

もう一つRustで一般的なi18nライブラリはfluentです。fluentというクレートはgettext-rsと同様に、あくまでもFluentというソフトウェアi18nのシステムをRustで実装したものです。

Fluentプロジェクトが提案するi18nシステムは、ソフトウェア開発におけるi18nのパラダイムを変えるべくMozilla Foundationが発明したシステムです。基本的な概念は、gettextのように英文とその翻訳を静的な一対一の関係から脱出して、翻訳をより動的に捉えることです。開発者に一いちいち頭を下げることなく、動的に翻訳するツールを、翻訳家たちに与えるためのシステムです。

FluentにはFTL構文があり、このFTL構文はキーと値の集まったテキストファイルです。FTLの値は、FTL構文によって変数を渡したり、変数によって文の構成を変えたりすることができます。

具体的には FluentプロジェクトのHPを見ていただければと思います。

アプリケーション上でのFluentの考え方

Fluentがいいかどうか、学ぶ価値はあるかどうかは読者にお任せしますが、パラダイムを変えるi18nシステムというだけあって、アプリケーション構築の観点からどう捉えるべきか筆者はとても理解するのに苦労しました。FTL構文を理解するための資料はたくさんありますが、じゃあ、どうやって.ftlのファイルを言語ごとにアプリケーションで使うのか、というところが乏しいです。

なので、できれば自分が発見したアプリケーション構成におけるFluentの考え方を共有したいです。

Fluentの最小単位:Message

Fluentをアプリケーションで実装するときに、最小の部品として出てくるのはFTLメッセージです。以下、メッセージの例です。

locales/en/main.ftl
time-elapsed = Time elapsed: { $duration }s.
locales/ja/main.ftl
time-elapsed = 経過時間: { $duration }秒。

FTLメッセージは、有効なキーとそのFTL構文の値です。これを.ftlファイルでたくさん書きます。

FTLメッセージを集めたFluentResource

FluentResourceは、複数のFTLメッセージが記載されているFTL構文の文字列を処理したオブジェクトです。正しいFTL構文を読み込んで最小単位の部品であるFluentResourceとしてメモリに置くような感じです。

FluentResourceには言語と地域の情報がなく、あくまでも有効なFTL構文をアプリケーションに取り込んだものです。

FluentResourceを集めたFluentBundle

一つ、もしくは複数のFluentResourceを言語・地域ごとに集めたものをFluentBundleと言います。開発者がどのFluentResourceがどのFluentBundleに含めるべきかを実装時に指定することになります。

基本的には一つのロケールには一つのFluentBundleを作って、ロケールコードなどをキーにしたMapに保管します。

.ftlファイルのフォルダー構成は自由だが

.ftlファイルをどこにどう置くべきか、筆者はとても悩みました。色々と調べましたが、結局、Fluent自体はまだローレベルのところしか取り決めがなく、また、実装されているライブラリーもローレベルのツールしか提供していないので、自分らのアプリケーションを設計する時には、Fluentのツールをどう使ってi10nできるかを検討する必要があります。

基本的に、以下のようなファイルおよびフォルダー構成が望ましいかと思います:

-- locales
--- en-US
---- main.ftl
---- hoge.ftl
--- en
---- main.ftl
--- ja
---- main.ftl
---- hoge.ftl

en-UKなどがあった場合は、en/main.ftlen-*と地域と関係のないFTLメッセージを入れば良いかと思います。

FluentBundleをHashMapに保管しておく

RustでFluentを使うことが非常にRustらしく感じてきたので、思い切って勉強の労力をかけた筆者ですが、案の定その甲斐はありました。Mozillaさんもとてもいいソフトウェアを作っています。

そこで、RustアプリケーションにどうFluentを組み込むか、筆者が考案した例を紹介したいです。

主なポイントは、ロケールごとにバンドルしたFluentResourceHashMapに入れることです。HashMapのキーはロケールの言語で、値はFluentBundleになります。これを実現するためにはFluentでも使われるunic-langidというクレートも使います。

そして、intl-memoizerというクレートも追加します。

このクレートもFluentプロジェクト(Mozilla)が管理しているのです。なぜこのクレートを指定する必要があるか後々説明しますが、このクレートに入っているのは、l10nのフォーマッターの初期化を最適化したソフトウェアです。内部でRefCellを使って、一度初期化したロケールのフォーマッターがアプリ全体で使い回せるようにするような役割があるかと思いますが深掘りはしていません。

Rustでロケールごとのバンドルを持ったマップを表現すると以下の型になります:

use std::collections::HashMap;
use unic_langid::subtags::Language;
use fluent::{bundle::FluentBundle, FluentResource};

type Locales =
    HashMap<
        Language, 
        FluentBundle<
            FluentResource,
            intl_memoizer::concurrent::IntlLangMemoizer
        >
    >;

intl_memoizer::concurrent::IntlLangMemoizerを使うのには理由があります。上記でintl-memoizerRefCellを内部で使っている仕組みらしいことに触れましたが、読者もご存知の通り、RefCellSendを実装していません!

Screenshot 2023-11-02 at 10.12.27.png

引用:https://docs.rs/intl-memoizer/0.5.1/intl_memoizer/struct.IntlLangMemoizer.html#synthetic-implementations

RefCellは内部的に一つのスレッドで実行されることを前提にしているので、Mutexで守る必要があります。

つまり、通常のintl_memoizer::IntlLangMemoizerを使ってしまうと、非同期のアプリケーション、複数のスレッドを使ったアプリケーションではHashMapが共有できなくなってしまいます。おそらくintl_memoizer::concurrent::IntlLangMemoizerの実装ではMutexをつかってRefCellの問題を解決しているのでしょう。ドキュメントを確認しても、やはりSendSyncが実装されていることが確認できるので、安心してこのLocaleSend + Syncだと断定できます。

Screenshot 2023-11-02 at 10.13.48.png
引用:https://docs.rs/intl-memoizer/0.5.1/intl_memoizer/concurrent/struct.IntlLangMemoizer.html

実際にこのLocales.ftlファイルを読み読んで生成するロジックを書きましょう。

use fluent::{bundle::FluentBundle, FluentResource};
use unic_langid::subtags::Language;
use unic_langid::{langid, LanguageIdentifier};

const ENGLISH: LanguageIdentifier = langid!("en");


fn init() -> Locales {
    let mut locales = HashMap::new();

    let en_bundle = {
        let mut bundle = FluentBundle::new_concurrent(vec![ENGLISH]);
        let ftl = std::fs::read_to_string("locales/en/main.ftl").expect("FTL File not found");
        let ftl = FluentResource::try_new(ftl).expect("FTL Parse Error");
        bundle.add_resource(ftl).expect("unable to add resource");
    };
    locales.insert(ENGLISH.language, en_bundle);

    locales
}

複数のファイルと言語で上記のロジックを繰り返してコピペしたらだらしなくなるので、macro_rules!でメタプログラミングしましょう。

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
}

これで複数のパスがあっても簡単に含められます!ちなみに、FluentBundle::newではなくFluentBundle::new_concurrentを使っているのは、上記の理由でSend + Syncが実装されていることを意識しているからです。Arc<Locales>で共有できたらいいと思います。

new_concurrent(もしくはnewでも)に引数のVec<unic_langid::LanguageIdentifier>を渡しているのは、Fluentが内部的関数で日付などを変換するときにどのフォーマッターを使えばいいかを示すためです。配列の順にフォールバックを渡せます。例えば、日本語のFluentBundleja-JPen-JPの順に渡したら、日本語でうまくできないフォーマットがあったら英語のフォーマッターを使うようにしてくれるらしいです。

これを使うとしたら以下のような使い方ができます。

let locales = init();

let bundle = locales.get(JAPANESE.language).unwrap();

let msg = bundle.get_message("hello-world")
        .expect("Message doesn't exist.");
let mut errors = vec![];
let pattern = msg.value
        .expect("Message has no value.");
let value = bundle.format_pattern(&pattern, None, &mut errors);

引用:正式ドキュメントの例を借りています。

まとめ

以上、Rustでのi18nライブラリと筆者がFluentを選定した背景を説明しました。また、軽くFluentを使ったアプリケーションの設計にも触れました。

次の章では、HTMLテンプレートエンジンのTeraにFluentを組み込むコードを書いて、HTMLテンプレートを言語ごとに変換する方法を紹介します。

6
2
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
6
2