ハイサイ!オースティンやいびーん!
概要
Rustアプリケーションの国際化および地域化(i18nとl10n)について考慮し、FluentとFluentのRust実装を紹介する
本記事の動機
筆者はRustでのWeb開発において、国際化の技術とノウハウを取得したく最近調査と勉強を繰り返しています。
その学習のまとめとして本記事を皮切りに、Rustで国際化できるWebアプリケーションの模索を複数の記事に分けて紹介したいと思っています。
l10nシリーズの記事
Rustにおけるl18nライブラリ
どの言語においてもl18nは非常に複雑で困難を極めています。なぜなら、人と話す言語とコンピューターと話す言語の両方に振り回されるからです。これらの言語は人間が思い描ける最大の煩雑さを持ち合わせているし、特に人の言葉は言語が変われば文法も文字も変わるので、それをプログラミング言語で表現するのもまた大変なことです。
gettext
その煩雑さを少しでも減らすためにいくつかのl18nに対する考え方がありますが、主に浸透しているのは1993年に(筆者が生まれる一年前)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メッセージ
です。以下、メッセージの例です。
time-elapsed = Time elapsed: { $duration }s.
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.ftl
にen-*
と地域と関係のないFTLメッセージを入れば良いかと思います。
FluentBundleをHashMapに保管しておく
RustでFluentを使うことが非常にRustらしく感じてきたので、思い切って勉強の労力をかけた筆者ですが、案の定その甲斐はありました。Mozillaさんもとてもいいソフトウェアを作っています。
そこで、RustアプリケーションにどうFluentを組み込むか、筆者が考案した例を紹介したいです。
主なポイントは、ロケールごとにバンドルしたFluentResource
をHashMap
に入れることです。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-memoizer
がRefCell
を内部で使っている仕組みらしいことに触れましたが、読者もご存知の通り、RefCell
はSend
を実装していません!
RefCell
は内部的に一つのスレッドで実行されることを前提にしているので、Mutex
で守る必要があります。
つまり、通常のintl_memoizer::IntlLangMemoizer
を使ってしまうと、非同期のアプリケーション、複数のスレッドを使ったアプリケーションではHashMap
が共有できなくなってしまいます。おそらくintl_memoizer::concurrent::IntlLangMemoizer
の実装ではMutex
をつかってRefCell
の問題を解決しているのでしょう。ドキュメントを確認しても、やはりSend
とSync
が実装されていることが確認できるので、安心してこのLocale
もSend + Sync
だと断定できます。
引用: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が内部的関数で日付などを変換するときにどのフォーマッターを使えばいいかを示すためです。配列の順にフォールバックを渡せます。例えば、日本語のFluentBundle
にja-JP
とen-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テンプレートを言語ごとに変換する方法を紹介します。