2014年7月1日火曜日

Internationalization with Polymer.dart and intl

2014/7/6追記

もともとintl 0.9.10向けにかかれていたのを0.11.0向けに更新

GUIアプリを作る上で国際化をどうするか、というのは意外と面倒な問題で、後からやろうとすると大変な目に遭うことが多い。Polymer.dartを使ってウェブアプリを作る場合でも同様で、本格的に作りこむ前にうまい方法を確立しておきたい。

幸いDartには公式な国際化ライブラリ(intl)が存在していて基本的にはそれを使えば良さそうな状況になっている。ただこの国際化ライブラリ使ってみると手順が複雑で、案外ハマりどころがあったのでここでまとめておく。

サンプルプロジェクト

今回も動作するサンプルプロジェクトをGithubに置いといた。Github Pagesで動作するウェブサイトも作ろうとしたら何かが原因でコンパイラが落ちるため、今回はそこまでやらない(ちなみにIssueは上げてある)。とりあえずローカルで動かした時の画像だけは貼っておく。上が英語、下が日本語で、ロケールのselectorを変えることで言語を動的に変えられる。

国際化の流れ

簡単に国際化の流れを説明しておくと下記のようになる。intlのドキュメントにも色々書いてあるのでそちらも参照するとわかりやすい。

  1. pubspec.yamlでpolymer、intl、petitparserの使用を宣言する
  2. 各Componentの中で国際化したい文字列のidとデフォルトの表記を記述する。このときIntl.message関数を使う
  3. intlパッケージに含まれているextract_to_arb.dartを使い、Intl.message関数で指定した内容をarbファイルに吸い出す
  4. 各文字列idに対応する言語ごとの表記を言語ごとのarbファイルに記載する
  5. intlパッケージに含まれているgenerate_from_arb.dartに対して下記の項目を渡し、国際化用のクラスを自動生成する
    • 元のComponentのソースコード
    • 吸いだされたarbファイル
    • 作成した各言語ごとのarbファイル
  6. 生成された国際化用のクラスを使い、ロケールに応じた文字列が表示されるようpolymerのバインディングを書く

pubspec.yaml

pubspec.yamlは今回はこんな感じ。重要なのはpolymer、intl、とあとpetitparserが記載されていることで、最後のtransformersはdart2js用のものだからあまり本質的ではない。petitparserはあとで使うgenerate_from_arb.dartが依存しているためここで追加しておく。

name: polymer_intl_sample
dependencies:
  logging: any
  polymer: any
  intl: any
  serialization: any
  petitparser: any
transformers:
- polymer:
   entry_points:
   - web/index.html

Intl.message関数

Intl.message関数は文字列の翻訳処理を行うとともに、あとで使う各種スクリプトにとっての目印にもなる重要な関数だ。ドキュメントを読むと指定できる項目が色々あるようなんだけど、今回は最小限で行く。

下記のコードは今回のサンプルアプリの本体であるmain-appコンポーネントの一部で、下の方にIntl.message関数が使われている。ここでは最低限指定する必要があるデフォルトの表記"Hello"と文字列自体のID"hello"だけを指定している。

CustomTag("main-app")
class MainApp extends PolymerElement {
  ...
  /// Locale of language
  @observable String currentLocale;
  @observable String helloStr;
  @observable String userStr;
  @observable String localeStr;
  @observable String japaneseStr;
  @observable String englishStr;
  @observable int selectedLocale;

  ...

  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }

  ...
  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

  hello() => Intl.message("Hello",name:"hello",args:[]);

  MainMenu _menu;
}

公式のドキュメントを読む限りIntl.message関数は上記の"hello() => Intl.Message(...)"のように関数にラップして使うんだけど、どうもその関数名(今回で言えばhello)と文字列のID("hello")は一致していないとあとのスクリプトが上手く動かないため注意が必要。

extract_to_arb.dartによる吸い出し

Intl.message関数で翻訳したい文字列をコンポーネント内に記述したあとは、そのソースコードをextract_to_arb.dartというスクリプトに渡すことで翻訳用のarbファイルを抽出することができる。

このarb(App Resource Bundle)ファイルは文字列リソースを格納するための独自フォーマットらしく、公式サイトの説明を読むと使用するプログラミング言語に非依存なようだ。

extract_to_arb.dartはintlパッケージに含まれているので、pubのキャッシュディレクトリから持ってくることができる。Windowsの場合pubのキャッシュは"C:\Users\ユーザー名\AppData\Roaming\Pub\Cache\hosted\pub.dartlang.org"の下にあるので、そこの"intl-*.*.*\bin"ディレクトリ内から持ってくることができる(ちなみに他のOSはどこにキャッシュが置いてあるか知らない)。

extract_to_arb.dartは下記のように実行する。

dart --package-root=packages tool/extract_to_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart

これでlib/components/messages_allの下にintl_messages.arbというファイルが生成される。intl_messages.arbの中身は下記の通り。Intl.message関数で指定したことをそのまま持ってきたような内容だ。

{
  "hello": "Hello",
  "@hello": {
    "type": "text",
    "placeholders": {
      
    }
  },
  "setting": "Setting",
  "@setting": {
    "type": "text",
    "placeholders": {
      
    }
  }
  ...

翻訳作業

ここに来てようやく翻訳作業に入れる。先ほどのintl_messages.arbが出力されているlib/components/messages_allの中にtranslation_jp.arbというファイルを作り、そのファイルに翻訳された単語を書いていく。書き方は下記の通り。

{
  "_locale":"ja",
  "user":"ユーザー",
  "hello":"こんにちは",
  "setting":"設定",
  "locale":"ロケール",
  "japanese":"日本語",
  "english":"英語"
}

最初にロケールを指定して、あとは単語のIDとロケールに応じた翻訳を記載していく形だ。このarbファイルは翻訳したい言語に応じて増やすことができるみたいなんだけど、今回は日本語のみにしてる。

generate_from_arb.dart

翻訳作業が終わったら、次にgenerate_from_arb.dartを使って翻訳用のクラスを生成する。generate_from_arb.dartはextract_to_arb.dartと同じようにintlパッケージに含まれているのでそこから持ってくることができる。

generate_from_arb.dartは下記のように実行する。

dart --package-root=packages tool/generate_from_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart lib/components/messages_all/translation_jp.arb

これを実行すると今度はmessages_all.dartとmessages_ja.dartというファイルが生成される。このクラスの中身に関しては何も知らなくても使えるのでここでは特に紹介しない。ちなみに今回のサンプルでは先ほどのextract_to_arb.dartとgenerate_from_arb.dartを一発で実行するPythonスクリプトtranslate.pyを用意してるので、それを実行すれば比較的楽に翻訳できる。

polymerによるバインディング

翻訳用のクラスが生成されたので、ついにUIに翻訳した文字列が表示できる。最初のmain-appに対応するhtmlを用意し、生成したクラスをimportして初期化処理を書いた後、翻訳した文字列とバインドすればおしまいだ。今回はhtmlを下記のようにした。

<link rel="import" href="../../../packages/polymer/polymer.html">
<link rel="import" href="main_menu.html">
<polymer-element name="main-app">
  <template>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" >
    <main-menu id="mainMenu" ></main-menu>
    <div class="container">
      <h2>{{helloStr}} {{userStr}}</h2>
      <label>{{localeStr}}</label>
      <select selectedIndex={{selectedLocale}}>
        <option value="jp">{{japaneseStr}}</option>
        <option value="en_US">{{englishStr}}</option>
      </select>
    </div>
  </template>
  <script type="application/dart" src="main_app.dart"></script>
</polymer-element>

初期化はintlライブラリと生成されたmessages_allライブラリをインポートしてからinitializeMessages関数を呼ぶだけ。initializeMessages関数を呼ぶときは有効なロケール(今回だと"ja"かデフォルトの"en_US")を指定する必要がある点に注意。

import 'package:intl/intl.dart';
import 'package:polymer_intl_sample/components/messages_all/messages_all.dart';
  ...
  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }
  ...  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

あとは上記のupdateLocale関数のようにIntl.defaultLocaleを変更してからIntl.messageで対応する文字列を取り出して、UIとバインドした文字列を更新してやれば動的に言語を変えることができる。

複数コンポーネントで翻訳を共有したい場合

複数のコンポーネントで文字列を共有したい場合というのは良くあって、今回のサンプルではそれの例も示している。やり方は単純で、Intl.message関数のみを含むlibraryを作ってそれを各コンポーネントで共有する、というものだ。今回でいえばcommon_messages.dartがその共有されるライブラリとなる。

library common_messages;

import 'package:intl/intl.dart';

user() => Intl.message("User",name:"user",args:[]);
locale() => Intl.message("Locale",name:"locale",args:[]);
japanese() => Intl.message("Japanese",name:"japanese",args:[]);
english() => Intl.message("English",name:"english",args:[]);

これを他のコンポーネントと同じようにextract_to_arb.dartとgenerate_from_arb.dartに食わせて、あとはさきほどのupdateLocale関数のようにライブラリの関数を呼び出してやればいい。

import 'package:polymer_intl_sample/components/common_messages.dart' as msg;
  ...
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

なおこれは公式に用意されている方法ではなく、なんとなくやってみたら出来たものなのでそのうち上手くいかなくなるかも。

感想

長々と説明してきたようにDartでの国際化は手順が多くて少し複雑だ。実際、Issueとしてintlパッケージが使いづらいから何とかしてくれ、というのが提出されていたこともある。

まだそんなにヘビーに使ってるわけではないのでメリット・デメリットを示せるわけではないけど、ある程度慣れてしまえばそんなに大変では無い気がする。それより混沌としがちな国際化方法をきっちりライブラリとして用意してくれている点はありがたいと思う。

0 件のコメント:

コメントを投稿