🦁

半自動で多言語・国際化を実現できるAngular i18nがすごい。DeepLでの自動翻訳も紹介

2022/10/31に公開

Angular公式のi18nパッケージ(パッケージ名 @angular/locale)がとても出来がよく、DeepLを使った自動翻訳も簡単にできたので、何がよかったかをご紹介したいと思います。「いやいやそんなのまだまだだ」「こっちのツールの方が強強だ」という方はぜひ記事を書いていただき、知見を共有できれば幸いです。

Angular i18nというと「Angular公式のi18nパッケージ」( @angular/locale )と「Angularのi18n化(国際化)」の2つの意味がありややこしいですが、本文では前者の「Angular公式のi18nパッケージ」を指すことにします。

Angular i18nのよかったこと4選

私が採用してとても開発者体験がよかった点を4つご紹介したいと思います。

1. compiletime i18nによりパフォーマンスの問題がない

まず、SPAの多言語化には2つの方法があることを紹介したいと思います。mhevery氏の https://github.com/robisim74/qwik-speak/discussions/8#discussioncomment-3610200 を参考にしながら、2つの方法の違いについてみていきましょう。

runtime i18n

runtime i18n は、ブラウザでのSPA実行時に翻訳ファイルを読み込み、対象となる文字列を置き換える方法です。とても簡単な例ですが、以下で localStoragelang を切り替えることを考えるとイメージしやすいと思います。

hello.js
const dictionaryEn = {
  HELLO: `hello`
};
const dictionaryJa = {
  HELLO: `こんにちは`
};

return dictionary[localStorage.getItem(`lang`)].HELLO;

i18next などで採用されており、また実装も容易なため多くのライブラリがこの方法で多言語化を実装しています。ただ、ビューをレンダリングする前に翻訳ファイルを読み込む必要があるため、パフォーマンスの問題がある場合があります。

compiletime i18n

compiletime i18n は、対象となる文字列を言語毎にコンパイル時に置き換え、別ファイルとして出力します。なので、上記の例だと、2つのファイルがコンパイル時に出力されます。

en/hello.js
return 'hello';
ja/hello.js
return 'こんにちは';

runtime i18n のように実行時のパフォーマンスの影響はなく、また辞書ファイルを持たないので(言語あたりは)、軽量です。一方でコンパイルに時間がかかるため、開発時は単一言語で実行し、リリース時は複数言語で出力するという必要があります。

Angular i18n は compiletime i18n

Angular i18nは compiletime i18n を採用しており、辞書がどれだけ膨れ上がってもパフォーマンス上の問題は起きません。また、開発時はソースとなる言語のみを利用し、多言語化を行いませんので、開発時にserveが遅いということもありませんでした。

一方で注意が必要なのは、ビルド後のフォルダ構成でしょう。例えば、日本語と英語を出力した時のフォルダ構成は以下のようになります。

  • wwwroot/en-US/
  • wwwroot/ja/

wwwroot や言語のフォルダ名は設定で変更できますが、 必ず言語ごとにフォルダができる 点には留意が必要です。 ドキュメント にはアクセスするユーザの Accept-Language ヘッダーを参照して、言語を判定してレスポンスを返す(判定不可の場合はデフォルト言語にフォールバック)する方法が紹介されています。 これはFirebase Hostingにもある機能で、ユーザが任意の言語に変えたい場合はCookieなどを使って判定を切り替えます。

もちろん、index.htmlを自分でおいてリダイレクトをかけて(例: https://github.com/ionic-team/capacitor/issues/3912#issuecomment-1272498658 )、言語毎に別のパスでユーザに提供も可能です。以下ディレクトリにおくならこんな感じ。

別ドメインにしてしまうなら、こういう形もできます。

JSレイヤーだけではなく、サーバまわりも考慮する必要があるという点には注意が必要です。

2. 翻訳対象の言語を簡易抽出。また、xlfフォーマットにより翻訳するのはどこの文字列かがすぐわかる

Angularは翻訳対象の文字列にマーカーをつけることで、文字列を自動抽出することができます。テンプレートファイルなら、i18nをつけます。

<h1>こんにちは</h1><h1 i18n>こんにちは</h1>

また、コード内の文字列にも $localize をつけることで抽出が可能です。

@Component({
  ...
})
export class AppComponent implements OnInit {
  title = $localize`こんにちは`;
}

これで、 ng extract-i18n コマンドを実行すると、自動的に以下のようなファイルを抽出することができます。

messages.xlf
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="ja" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="1129199713308059907" datatype="html">
        <source>こんにちは</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">10</context>
        </context-group>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.ts</context>
          <context context-type="linenumber">5</context>
        </context-group>
      </trans-unit>

簡単に説明すると、 file source-language="ja" は、元言語が日本語(ja)であることを示しています。 trans-unit id="1129199713308059907" は、翻訳対象の文字列のIDで、自動で割り振られます(固定化することもできます)。 source は、翻訳対象の文字列です。 context-group purpose="location" は、翻訳対象の文字列がどのファイルのどの行にあるかを示しています。

以前多言語化した時は、対象文字列を全部コピペして、相対表を自分でつくらないといけなかったのでめっちゃ便利!!!!ちなみに、xlfで翻訳する時は以下のようにします。

xml
  <?xml version="1.0" encoding="UTF-8" ?>
  <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
    <file source-language="ja" datatype="plaintext" original="ng2.template">
      <body>
        <trans-unit id="1129199713308059907" datatype="html">
          <source>こんにちは</source>
+         <target>Hello</target>

3. jsonフォーマットを利用することで、DeepLを使った自動翻訳が簡単にできる

ただこれは私の調査不足かもしれないのですが、xlfファイルってプログラムによる制御でフォーマットを維持するのが難しいんですよね。私は途中で諦めた。具体的には、 messages.xlfxliffxml2json などで読み込んでJSのオブジェクト化し、それを再度xml形式にして messages.xlf に上書きすると改行や空白が元のものと異なります。

https://twitter.com/rdlabo/status/1578652224439615488

これだと翻訳する毎に行数変わってdiffを追いにくい上にプログラムに読ませると毎回形が変わり、かつ空行などで翻訳前文字列と翻訳後文字列が一致できないという問題があるのでどうしようかなーと思ったら、Angular i18nには翻訳ファイルをxlfではなくJSONで用意する機能もあるんですね。

{
  "locale": "ja",
  "translations": {
    "1129199713308059907": "こんにちは",

JSONなので、もちろんNodeで読み込んでオブジェクトにしたあと上書きしてもdiffはでませんでした。やったー。
なので、これをループで回すことによって、 "こんにちは" をそのままDeepLのAPIに投げて翻訳してもらうことができました。

4. 日付を自動変換

これは翻訳するまで気づかなかったのですが、各国で日付などの表記って異なるんですよね。例えば、日本語と英語だと半角だけ使っても以下のように異なります。

  • 日本語: 2022/07/19
  • 英 語: Jul 19, 2022

これは完全に盲点だったのですが、Angularの日付を変換するDate PipeにはAngular i18nを使うことで自動的に表記を変更する機能がありました。やったー。ちなみに通貨単位は自動的に変換されないので、 ¥1,000$1,000 になるという超インフレは起こらなかったです(笑)

Angular i18nとDeepLを使うガイド

最後にとてもシンプルではありますが、Angular i18nとDeepLを使うガイドをご紹介します。

1. インストール

ng add @angular/locale でインストールします。インストール後、 angular.json に、「元ファイルの言語は何を使っていて、翻訳するファイルは何を使うか」を指定する必要があります。
以下は私の実アプリで使ってる angular.json で、プロジェクト名 app (Ionic Angularのデフォルト)で、日本語をソースとして英語を出力する設定です。

angular.json
{
...
  "projects": {
    "app": {
    ...
      "i18n":{
        "sourceLocale": {
          "code": "ja",
          "baseHref": "/ja/"
        },
        "locales": {
          "en-US": {
            "translation": "src/locale/messages.en-US.json",
            "baseHref": "/en-us/"
          }
        }
      },

完全版については https://gist.github.com/rdlabo/6a5c96ebaffd0996f38e521edbdb500a をご覧ください。 細かい設定もありますが、そこあたりは公式ドキュメントを確認するのがはやいです。

https://angular.jp/guide/i18n-common-merge

2. 翻訳ファイルの作成

まず、がんばってテンプレートには i18 、TypeScriptのファイル内には $localize を書き込みます。こればっかりは翻訳の対象と非対象をつけるために仕方がない。

終わったら、以下のコマンドで翻訳ファイルを抽出します。

% ng extract-i18n --output-path src/locale --format=json && ng extract-i18n --output-path src/locale

私の場合は、翻訳ファイルは加工で使うJSONとどこの文字列かを確認するようのxlfファイルの2つを出力しています。xlfファイルは参考にするだけなので加工は行いません。

また、最初だけ翻訳対象のファイルを用意しておかないといけないので、cpコマンドで上記JSONファイルをコピーしましょう。 en-US に翻訳するので、ファイル名は messages.en-US.json としています。

% cp src/locale/messages.json src/locale/messages.en-US.json

3. DeepLを使った翻訳

DeepLを用いて翻訳するので、DeepLの設定ファイルである deepl.config.json を用意します。 authKey はご自身のものをご利用ください。

{
  "source": "src/locale/messages.json",
  "outputDir": "src/locale/",
  "fromLanguage": "ja",
  "toLanguage": [
    "en-US"
  ],
  "ulr": "https://www.deepl.com/docs-api",
  "authKey": "****************************"
}

ソースファイルとソースとなる言語、出力する言語をここで設定しています。続いて、DeepLのAPIに文字列を渡して翻訳したものをJSONに書き込むスクリプトを作成します。将来的にフランス語なども訳せれるように、 BCP47 はオブジェクトで指定しています。

import deeplConfig from 'deepl.config.json';
const translate = require('deepl');
const fs = require('fs');

const BCP47 = {
  'en-US': 'EN',
};

(async () => {
  const sourceFile = fs.readFileSync(deeplConfig.source).toString();

  const translated = await Promise.all(
    deeplConfig.toLanguage.map(async (language) => {
      const sourceMessage = JSON.parse(sourceFile);
      sourceMessage.locale = language;

      const toFile = fs.readFileSync(deeplConfig.outputDir + `messages.${language}.json`).toString();
      const translatedMessage = JSON.parse(toFile);

      const translatedValue: {
        key: string;
        value: string;
      }[] = [];

      await Promise.all(
        Object.keys(sourceMessage.translations).map(async (item) => {
          let text = sourceMessage.translations[item];

          if (
            translatedMessage.translations.hasOwnProperty(item) &&
            !translatedMessage.translations[item].match(/[---]/)
          ) {
            // すでに翻訳されている
            text = translatedMessage.translations[item];
          } else if (!text.includes('{$') && !text.includes('https://') && !text.includes('<') && text) {
            // textを翻訳
            const response = await translate({
              free_api: true,
              text,
              source_lang: 'JA',
              // @ts-ignore
              target_lang: BCP47[language],
              auth_key: deeplConfig.authKey,
            });
            text = response.data.translations[0].text;
            console.log(`${text}を翻訳しました`);
          }

          translatedValue.push({
            key: item,
            value: text,
          });
        }),
      );

      for (const item of Object.keys(sourceMessage.translations)) {
        sourceMessage.translations[item] = translatedValue.find((v) => v.key === item)!.value;
      }

      return {
        locale: language,
        translations: sourceMessage.translations,
      };
    }),
  );

  translated.forEach((item) => {
    const content = JSON.stringify(item, null, '  ');
    fs.writeFileSync(deeplConfig.outputDir + `messages.${item.locale}.json`, content);
  });
})();

/[亜-熙ぁ-んァ-ヶ]/ は日本語が含まれてるかを正規表現で確認しています。日本語が含まれていない場合は翻訳済みとして、再翻訳しないようにしています。また、 {$https://< が含まれている場合は翻訳しないようにしています。{$ は変数を表しているので、翻訳すると意味が変わってしまいます。 https:// はURLを表しているので、翻訳すると意味が変わってしまいます。 < も同様です。

このファイルを実行すると、 src/locale/messages.en-US.json に翻訳されたJSONが出力されます。

以下のような感じになります。

json
  {
    "locale": "ja",
    "translations": {
-     "1129199713308059907": "ログアウトしました",
-     "1847641466768577553": "アカウントを削除しました",
-     "1568991857305935308": "パスワードリセットのためのメールを送信しました",
-     "342060829965180955": "認証のためのメールを送信しました",
-     "7333828782410849414": "メールアドレスが必要です",
+     "1129199713308059907": "Logged out.",
+     "1847641466768577553": "Account deleted.",
+     "1568991857305935308": "Email sent to reset password",
+     "342060829965180955": "Email sent for authentication.",
+     "7333828782410849414": "Email address is required",

問題なく翻訳できていますね。

まとめ

Angularでruntime i18n型のライブラリである ngx-translate を利用する方法もありますが、私としては文字列の自動抽出と、xlfとjson併用による翻訳作業、またライブラリを追加しなくても日付のフォーマットを言語に応じて変わるのがとても快適だったので、今後も @angular/locale を使った方法で翻訳を行っていきたいと思います。 index.htmllang 属性も自動的に切り替わって出力してくれますしね。

結構おもしろかったので、ぜひ多言語・国際化挑戦してみてください!

Discussion