Catalyst+Template-Toolkit(Catalyst::View::TT)で国際化

はてぶコメントより、typesterさん曰くCatalyst::Plugin::I18N - I18N for Catalyst - metacpan.orgでできるよとのこと。お〜。名前は昔見たことがあったのですが中身まで見てませんでした。今回の話(と次に書こうと思っていた話)のほとんどがCatalyst::Plugin::I18Nに含まれていますね。Template周りをちょこっといじるだけで良さそうです。コメント本当にありがとうございました。助かります。

このエントリは「Locale::Maketextの使い方」ということでお願いします。


はじめに

同じ内容のページを、ユーザの環境に応じて英語版と日本語版を切り替えたい。という話です。

よくある「使用するテンプレートを言語毎に用意する」という方法では、英語版テンプレートと日本語版テンプレートの同期を取るのが非常に面倒になります。そこで「テンプレートは英語版を一つだけ用意して、実行時に内容を書き換える」という方法でやってみます。

GNUな環境ではgettextを使うのがデファクトスタンダードです。gettextを一言で説明すると「言語毎のメッセージファイルを用いて、例えば"Hello"という文字列リテラルを"こんにちわ"に実行時に変換するライブラリ」ということになります。(gettextについてはhttp://home.catv.ne.jp/pp/ginoue/memo/gettext.html辺りを参考に)

Perlのコアモジュールには、gettextと同じようなことを行うLocale::Maketextが含まれているのでそれを使います。

Locale::Maketext

まずLocale::Maketextを準備します。

lib/MyApp/L10N.pm
package MyApp::L10N;
use base qw(Locale::Maketext);
1;

これはパッケージを定義してMyApp専用の名前空間を用意しているだけです。Locale::Maketextは「my $handle = MyApp::L10N->get_handle('ja');」のようにハンドルを取得して使うのですが、この場合「MyApp::L10N::ja」が使用されることになります。
"L10N"の部分は何でもいいのですが、変更した場合は以下のメッセージファイルのパッケージ名も併せて変更してください。

lib/MyApp/L10N/ja.pm

これがメッセージファイル(gettextでのpoファイル)になります。

package MyApp::L10N::ja;
use base qw(MyApp::L10N);
our %Lexicon = (
    '_AUTO' => 1,
    'Hello' => 'こんにちわ',
);

この%Lexiconに翻訳文字列を定義していきます。
「'_AUTO' => 1」は、翻訳文字列が見つからなかった場合にエラーとならないようにするためのものです。
もし上の例で_AUTOが偽の場合、

フレーズ 結果
Hello こんにちわ
World ×

Worldが定義されていないので「maketext doesn't know how to say: World」というメッセージでcroakしてしまいます。
_AUTOが真の場合、

フレーズ 結果
Hello こんにちわ
World World

と、翻訳こそされないもののエラーにはなりません。

「全て翻訳済みでなければならない」という要件がある場合は、_AUTOを偽にしておいて未翻訳のものがあったらcroakするようにしておくと良いでしょう。普通は_AUTO=>1で良いと思います。

lib/MyApp/L10N/en.pm
package MyApp::L10N::en;
use base qw(MyApp::L10N);
our %Lexicon = (
    '_AUTO' => 1,
);

Locale::Maketextにはfallbackの仕組みが備わっていて、ハンドルを取得する際に指定した言語が見つからない場合、i-default(i_default.pm), en(en.pm), en-US(en_US.pm)がこの順番で試行されます。
今回は英語版テンプレートを基本とするため、フレーズは全て英語となります。なので_AUTOを真にしておくだけで英語版の翻訳(?)は完了です。

Locale::Maketextの準備は完了

以上でLocale::Maketextの準備は完了です。この段階で

use MyApp::L10N;
my $language = 'ja';
my $lh = MyApp::L10N->get_handle($language) or die;
print $lh->maketext('Hello');

とjaを指定すると「こんにちわ」が出力され、

use MyApp::L10N;
my $language = 'en';
my $lh = MyApp::L10N->get_handle($language) or die;
print $lh->maketext('Hello');

とenを指定すると「Hello」が出力されるようになっています。

Catalyst+Template-Toolkitに適用する

テンプレートをこのように書いてみます。

Hello
<br>
_(Hello)

gettextでは、慣例で翻訳すべき文字列リテラルを_(...)と囲むようになっていますのでそれに合わせています。この目印の付け方はLocale::Maketextの管轄ではないので好きな形式にできます。<span lang="x-auto">...</span>みたいにしても構いません。(ちなみにちなむと"x-"はlang属性の私的利用です。RFC3066、RFC3282を参照)

lib/MyApp/View/TT.pm

あとはCatalyst::View::TT#processを上書き(MyApp::View::TTで再定義)すればOKです。

package MyApp::View::TT;
use base qw(Catalyst::View::TT);
use MyApp::L10N;

sub process {
    my ( $self, $c ) = @_;

    $self->SUPER::process($c);

    my @languages = split /,/msx, $c->req->header('Accept-Language');
    foreach my $language (@languages) {
        $language =~ s/;.*//msx;
    }

    my $lh = MyApp::L10N->get_handle(@languages) or die;

    my $output = $c->res->body;
    $output =~ s{_\((.+?)\)}{$lh->maketext($1)}egmsx;
    $c->res->body($output);
    return;
}

1;

ここでは、通常のCatalyst::View::TT#processを行なった後に、Locale::Maketextを使ってbodyを書き換えています。言語の指定にはAccept-Languageヘッダを使用しています。

実行

この状態で実行すると

英語環境 日本語環境
Hello
Hello
Hello
こんにちわ

と表示されます。

Firefoxでは「ツール」→「オプション」→「詳細」→「言語設定」で「日本語 [ja]」を削除したり追加したりすると確認できると思います。

おわりに

ところで、Accept-Languageは優先度の高い順番に記述されているとは限りません。優先順位がenよりjaの方が高い場合でも、Accept-Languageに現れる順番がen,jaとなっていることもあります。これを解決するにはHTTP::Negotiateを使う方法があります。それはまた別エントリで書きます。



また、今回の例では「翻訳すべき文字列を抽出してlib/MyApp/L10N/ja.pmに追加する」という作業が面倒です。まあそれ用のスクリプトを書いてもいいのですが、翻訳を変更する度にpmファイルを書き換えるというのもナンセンスです。gettext用のメッセージファイルをそのままLocale::Maketextで使用するLocale::Maketext::Gettext - Joins the gettext and Maketext frameworks - metacpan.orgというモジュールを使えば、gettextのmoファイルを読み込めるようになりpmファイルの更新が不要になります。またxgettext、msgmergeなどのgettext周りのツールが使えるようになります。msgmergeは単純な更新ではなく差分更新ができ、使用されなくなった文字列をマーキングしたりもしてくれます。
「xgettextでpotファイルを更新」→「msgmergeでpoファイルを差分更新」→「誰かに翻訳させる」→「msgfmtでmoファイルを生成」という流れが最強です。翻訳以外はMakefileで自動的に実行されるようにしておけば完璧です。Locale::Maketext::Gettextに関してもまた別エントリで書きます。