現在是個國際化的時代,軟體程式如果能夠支援多國語言,想必可以有效地加快傳播速度。讓軟體程式擁有多國語言的能力有很多種方式,像是使用相依於作業系統環境本身所提供的Locale機制的工具,再搭配語言設定檔,例如GNU的gettext工具搭配PO和MO檔。或是使用能跨作業系統的獨立函式庫和語言設定檔,例如fluentd搭配其定義好的特殊語法的語言設定檔。當然也可以很單純地在程式內將所有的文字使用某種key-value的資料結構來儲存,在程式執行階段輸出文字的時候再決定要使用哪個語系實體的值。



筆者在以前使用PHP做開發的時候都是使用PO和MO檔,但存取它們實在是太麻煩了。所以後來使用Java做開發時,就直接改用key-value的資料結構來儲存多國語言用的文字,直接把文字以hardcode的形式寫在一個獨立的原始碼檔案內,雖然很方便,但不太好修改,因為一旦修改就要進行重新編譯的動作。再後來用Node.js做開發時,決定改用JSON格式作為語言設定檔,在程式初始化的時候就將JSON資料轉成key-value的資料結構來儲存,經過一段時間的嘗試,發現這樣的方式是最簡單、最容易維護、且又可以輕鬆跨作業系統的,甚至還能夠在不同程式語言間或是網頁瀏覽器上無痛共享相同的JSON語言設定檔,也不用專門為了搞多國語言又要去學習新的工具和語法!至於開發Android時,多國語言文字的部份筆者還是只靠那難用的XML檔案。

那麼,在Rust上要如何實現以JSON格式作為語言設定檔的多國語言功能呢?

JSON Get Text

「JSON Get Text」是筆者開發的套件,可以利用JSON格式作為語言設定檔,讓Rust程式輕鬆支援多國語言。

Crates.io

Cargo.toml

json-gettext = "*"

JSON語言設定檔

JSON語言設定檔是一個JSON物件的純文字檔案,鍵值對應的值通常是一個字串,但是也可以是其他任意的JSON值。檔案名稱隨意,方便辨識即可,一般會使用Locale格式的字串,例如en_US,再搭配JSON檔案的副檔名.json。以下舉例美國英文和繁體中文的語言設定檔撰寫的方式:

{
  "hello": "Hello, world!",
  "rust": "Rust!"
}
{
  "hello": "哈囉,世界!"
}

JSON Get Text擁有預設語言的功能,所有非預設語言的語言設定檔的鍵值均必須包含在預設語言的語言設定檔中。以這個例子來說,我們設定en_US.json為預設語言,所以en_US.json可以擁有zh_TW.json所沒有的rust鍵值。

建立語庫(Context)與搜尋文字

json-gettext這個crate底下的JSONGetText結構體的每個實體即為一個多國語庫,在同一支Rust程式中,可以建立多個JSONGetText結構實體來創造不同的多國語庫。如果要建立JSONGetText結構實體,除了可以使用json-gettext這個crate底下的JSONGetTextBuilder結構實體外,一般情況下會比較建議直接使用static_json_gettext_build巨集。

static_json_gettext_build巨集的第一個參數為預設的語言名稱,接下來的參數兩兩一組,至少要有一組。每一組的第一個參數表示一個語言的名稱,可以隨意取,能夠辨識即可。第二個參數表示該語言名稱的語言設定檔路徑,路徑相對於CARGO_MANIFEST_DIR也就是Cargo.toml檔案的所在目錄的路徑。static_json_gettext_build巨集在編譯階段,就會將JSON檔案一同編譯進程式執行檔中。

假設Cargo.toml檔案位於/path/to目錄中,我們將en_US.jsonzh_TW.json放置在/path/to/langs下,範例程式如下:

#[macro_use] extern crate json_gettext;

let ctx = static_json_gettext_build!("en_US",
    "en_US", "langs/en_US.json",
    "zh_TW", "langs/zh_TW.json"
).unwrap();

assert_eq!("Hello, world!", get_text!(ctx, "hello").unwrap());
assert_eq!("哈囉,世界!", get_text!(ctx, "zh_TW", "hello").unwrap());

透過get_text巨集,第一個參數傳入要使用的語庫(JSONGetText結構實體),第二個參數傳入要使用的語言名稱,如果不傳的話則表示使用預設的語言名稱。第三個參數(若不指定語言名稱,則為第二個參數)則是傳入要尋找的文字或其它JSON值的鍵值。如果有找到值,就會回傳Option列舉的Some變體,其包裹的值的型別為json-gettext這個crate底下的JSONGetTextValue列舉。假如無法在指定的語言名稱,找到指定的鍵值時,則會嘗試去預設的語言名稱找同樣的鍵值。如果還是找不到,則會回傳Option列舉的None變體。

如果想要一次取得多個鍵值對應的值,可以直接替get_text巨集加上更多參數,傳入更多的鍵值。此時get_text巨集回傳的Option列舉的Some變體就不會只單純包裹JSONGetTextValue列舉的實體,而會是HashMap<&str, Value>。範例程式如下:

#[macro_use] extern crate json_gettext;

use std::collections::HashMap;

use json_gettext::JSONGetTextValue;

let ctx = static_json_gettext_build!("en_US",
    "en_US", "langs/en_US.json",
    "zh_TW", "langs/zh_TW.json"
).unwrap();

let map_en: HashMap<&str, JSONGetTextValue> = get_text!(ctx, "en_US", "hello", "rust").unwrap();

assert_eq!(&"Hello, world!", map_en.get("hello").unwrap());
assert_eq!(&"Rust!", map_en.get("rust").unwrap());

let map_de: HashMap<&str, JSONGetTextValue> = get_text!(ctx, "de", "hello", "rust").unwrap();

assert_eq!(&"Hello, world!", map_de.get("hello").unwrap());
assert_eq!(&"Rust!", map_de.get("rust").unwrap());

let map_zh: HashMap<&str, JSONGetTextValue> = get_text!(ctx, "zh_TW", "hello", "rust").unwrap();

assert_eq!(&"哈囉,世界!", map_zh.get("hello").unwrap());
assert_eq!(&"Rust!", map_zh.get("rust").unwrap());

Rocket框架支援

Rocket是Rust的一個Web框架(Web Framework),如果想要讓Rocket能夠支援多國語言的話,可以考慮與這個JSON Get Text一同使用,程式如下:

#[macro_use] extern crate json_gettext;

#[macro_use] extern crate rocket;

use rocket::response::Redirect;
use rocket::State;

use json_gettext::JSONGetText;

#[get("/")]
fn index(ctx: &State<JSONGetText>) -> Redirect {
    Redirect::temporary(uri!(hello(lang = ctx.get_default_key())))
}

#[get("/<lang>")]
fn hello(ctx: &State<JSONGetText>, lang: String) -> String {
    format!("Ron: {}", get_text!(ctx, lang, "hello").unwrap().as_str().unwrap())
}

#[launch]
fn rocket() -> _ {
    let ctx = static_json_gettext_build!(
        "en_US";
        "en_US" => "langs/en_US.json",
        "zh_TW" => "langs/zh_TW.json"
    )
    .unwrap();

    rocket::build().manage(ctx).mount("/", routes![index, hello])
}

不過由於static_json_gettext_build巨集在編譯階段就會把JSON語言檔一同編譯進Rocket應用程式中,因此在開發這個Rocket應用程式時,一旦要套用修改後的JSON語言檔,就必須得重新編譯整個Rocket應用程式才行,這樣效率實在不是很好。為了解決這個問題,可以啟用JSON Get Text的rocket特色,使其能與Rocket框架相容。Cargo.toml設定檔的寫法如下:

[dependencies.json-gettext]
version = "*"
features = ["rocket"]

然後用static_json_gettext_build_for_rocket巨集來替代static_json_gettext_build巨集,JSONGetTextManager結構體來替代JSONGetText結構體,程式如下:

#[macro_use] extern crate json_gettext;

#[macro_use] extern crate rocket;

use rocket::State;
use rocket::response::Redirect;

use json_gettext::JSONGetTextManager;

#[get("/")]
fn index(ctx: &State<JSONGetTextManager>) -> Redirect {
    Redirect::temporary(uri!(hello(lang = ctx.get_default_key())))
}

#[get("/<lang>")]
fn hello(ctx: &State<JSONGetTextManager>, lang: String) -> String {
    format!("Ron: {}", get_text!(ctx, lang, "hello").unwrap().as_str().unwrap())
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(static_json_gettext_build_for_rocket!(
            "en_US";
            "en_US" => "langs/en_US.json",
            "zh_TW" => "langs/zh_TW.json"
        ))
        .mount("/", routes![index, hello])
}

如此一來,在使用非release模式來編譯Rocket程式的時候,就不會把JSON語言檔也跟著編譯進Rocket應用程式中,而且一旦JSON語言檔有進行修改的話,JSONGetTextManager會自動做重新載入的動作。

如果您還不會Rocket框架,可以參考《Rocket入門指南》系列文章

unic-langid套件支援

unic-langid套件提供了用來表示語言及地區的結構體,有著不錯的效能,所以很適合拿來當作JSONGetText多國語庫的鍵值,就不需要去做字串比對了。想要使用unic-langid套件提供的結構體來作為鍵值型別,可以啟用json-gettextlanguage_region_pairlanguage或是region特色,鍵值型別會分別變成(Language, Option)LanguageRegion

在這個情況下,json_gettext提供的key巨集可以直接將定數字串轉成鍵值,用起來會很方便。

例如:

[dependencies.json-gettext]
version = "*"
features = ["language_region_pair", "rocket"]
#[macro_use]
extern crate rocket;

#[macro_use]
extern crate rocket_accept_language;

#[macro_use]
extern crate json_gettext;

use rocket::State;

use rocket_accept_language::unic_langid::subtags::Language;
use rocket_accept_language::AcceptLanguage;

use json_gettext::{JSONGetTextManager, Key};

const LANGUAGE_EN: Language = language!("en");

#[get("/")]
fn index(ctx: &State<JSONGetTextManager>, accept_language: &AcceptLanguage) -> String {
    let (language, region) =
        accept_language.get_first_language_region().unwrap_or((LANGUAGE_EN, None));

    format!("Ron: {}", get_text!(ctx, Key(language, region), "hello").unwrap().as_str().unwrap())
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(static_json_gettext_build_for_rocket!(
            key!("en");
            key!("en") => "langs/en_US.json",
            key!("zh_TW") => "langs/zh_TW.json",
        ))
        .mount("/", routes![index])
}