GitHub Actions を使って継続的ローカライゼーションをスクラッチから構築した話

こんにちは。先月アムステルダムに行って 初 Uber で UX に感動した @kazupon です。

PLAID がプロダクトとして提供する KARTE の管理コンソールは Vue.js で作られた Web アプリケーションです。そんな Vue.js で作られた Web アプリケーションを国際化対応するために、vue-i18n という国際化するためのライブラリを入れて日本語、英語対応できるようになっています。

この記事は、国際対応されたプロダクトを継続的ローカライゼーションのワークフローの仕組みをスクラッチから作り、作り上げたそのワークフローを導入した話です。

継続的ローカライゼーションとは?

本題に入る前に、今回導入する継続的ローカライゼーションという用語、初めて知る人もいるかと思いますのでそれについて簡単に話します。

継続的ローカライゼーションとは、ソフトウェアで提供される UI に表示するようなメッセージのテキストやデータベースに登録されたコンテンツを翻訳者がローカライズ (Localize: 注) して、各地域に最適化されたプロダクトを継続的に提供するサイクルのことです。

注: ローカライズと翻訳の違いについて:0ローカライズ と翻訳は同義ではないかという声をよく聞きますが、厳密には違います。ソフトウェアの国際化、グローバル化の観点では、翻訳 (Transration) はローカライズの一部です。ローカライズとはその国、地域の文化、法律、そして志向などに合わせてソフトウェアを最適化することを意味します。

継続的ローカライゼーションという用語は英語では Continuous Localization と呼びます。継続的インテグレーションの英語 Continuous IntegrationCI と略すように、継続的ローカライゼーションを CL と略すことがあります。

クラウドコンピューティングが出現してから、ソフトウェアの提供がフロッピーディスク、CD、そして DVD といった物理記憶メディアパッケージからクラウドへシフトしたにあたり、ソフトウェアの提供がオンラインベースで継続的にできるようになりました。

また、ソフトウェア開発のスタイルもウォータフォールからアジャイルによる継続的に行う開発スタイルが主流になり、CI / CD (Continuous Delivery) という新しいソフトウェア開発プロセスが一般的になっていきました。

これに伴いソフトウェアのローカライズプロセスも同様に、現在のソフトウェア開発プロセスに合わせて、継続的にローカライズプロセスの回す継続的ローカライゼーションという概念が自然に生まれました。

CI / CD そして 継続的ローカライゼーションを図に表すと以下のようになります。

CI / CD と継続的ローカライゼーション

ソフトウェアがクラウドベースの SaaS というサービスという形での利用が一般的になるとともに、ソフトウェアのローカライズも SaaS が使われるようになってきています。SaaS ベンダーとしては有名所だと、OSS プロジェクトの国際化対応でよく使われている crowdwintransifex といったベンダーなどがあります。

翻訳などのローカライズする人たちは、そういったサービスを使ってローカライズするようなりつつあり、企業によってはクラウドソーシングという形でローカライズするするようになってきています。

ローカライゼーションを SaaS として提供するベンダーが、GitHub などのようなコード管理サービスと連携して継続的ローカライゼーションを機能として提供しているところもあります。

こうして、ソフトウェア開発の基盤となっている GitHub と連携したローカライゼーションサービス ベンダーを利用することで、既に GitHub 上で構築された CI / CD のワークフローといっしょに、継続的ローカライゼーションを利用して国際化対応のワークフローも導入することが容易になっています。

なぜ、わざわざ自前で作ったのか

ローカライゼーションサービスベンダーが GitHub と連携した継続的ローカライゼーションを提供しているなか、PLAID では敢えてローカライゼーションサービスベンダーが提供している継続的ローカライゼーションできる仕組みを、なぜ使わなかったのか。

自前で作るに至った理由としては以下になります。

開発者以外の外部翻訳者によってローカライズできる状態になっていなかった

KARTE 管理コンソールを国際化対応した後、PLAID では i18n リソースを開発者以外の外部翻訳者によってローカライズできていませんでした。その結果、単一ファイルコンポーネントを実装する開発者にも、日本語だけでなく英語の i18n リソースも開発者自身で翻訳して i18n リソースに定義しているという、ローカライズという開発以外のこともタスクも負担になっていたという現状でした。

さらに、KARTE 管理コンソールは、機能拡張等で新しい画面の追加や既存画面の改善に伴い、日々開発が続いており、新しい i18n リソースの追加や変更が発生しています。デプロイも毎日行われているので、この状況をそのまま放置しておくと、割れ窓化しやすい i18n リソースを継続的にローカライズする仕組みが社内で望まれていました。

単一ファイルコンポーネントに定義された i18n リソースを認識できない

一般的な i18n リソースの管理

vue-i18n のような国際化対応を行うライブラリは、 一般的に、Web アプリケーションに表示されるメッセージを jsonyaml などといった一般的なフォーマットで定義された i18n のリソース (この記事では以降 i18n リソース と呼ぶことにします) としてファイルに外部化します。

i18n リソースをファイルとして外部化することで、ローカライゼーションサービス側でそれら i18n リソースをインポートとしてデータベースに登録され、そのサービス上で専用のUIを利用してローカライズできるようになります。また、ローカライズが終わったら、逆にローカライゼーションサービスから json または yaml などといったフォーマットでエクスポートし、Web アプリケーションに組み込むことができるようにしています。

一般的な i18n リリースの管理

単一ファイルコンポーネントによる i18n リソースの管理

KARTE 管理コンソールの国際化対応として導入した vue-i18n は、i18n リソースのフォーマットとして jsonyaml といったフォーマットで i18n リソースを外部ファイルで扱うことができます。

KARTE 管理コンソールは、こちらのブログにあるように 1,100 以上も単一ファイルコンポーネントで構築された巨大な Web アプリケーションです。共通・汎用的に使われる一部 i18n リソースは、 json でプロジェクトディレクトリにおいて locales というディレクトリで外部ファイルで管理しています。

しかしながら、単一ファイルコンポーネントが1,100 以上のものとなると、単一ファイルコンポーネント内で i18n リソースを管理した方が、単一ファイルコンポーネントのコンテキストが理解しやすいため、開発スピードを維持しやすいことが国際化対応している最中にわかりました。

そのため、現在では、DX (Developer Experience: 開発体験) を優先して、単一ファイルコンポーネント内に i18n リソースを定義してます。具体的には、 <i18n> ブロック(block) を使って i18n リソースを定義することで、コンポーネント単位でローカライズできるように国際化対応しています。

<i18n> ブロックとは、vue-i18n が単一ファイルコンポーネント内で国際化対応できるようにするために、単一ファイルコンポーネントが提供するカスタムブロック (Custom Blocks) 機能を利用して、単一ファイルコンポーネント内に i18n リソースを定義できるようにしたもの。

<template>
  <p>{{ $t('hello') }}</p>
</template>

<script>
export default {
  name: 'HelloI18n'
}
</script>

<i18n>
{
  "en": {
    "hello": "Hello i18n in SFC!"
  },
  "ja": {
    "hello": "こんにちは i18n!!!!!!!!"
  }
}
</i18n>

今回作り上げた継続的ローカライゼーション

今回作り上げた継続的ローカライゼーションのワークフローですが、以下のワークフローになりました。

継続的ローカライゼーションのワークフロー

図中の番号は、以下リストの番号に対応しています。

基本的なワークフローの流れは以上ですが、必要に応じて以下フローも別に実行します。

  1. i18nチームが Slack で /i18n-sync コマンドを実行する

  2. /i18n-sync コマンドを受けて Slack 側は、GCP の Cloud functions に HTTP リクエストする

このような流れになっています。以降では、今回のワークフローについて構成や詳細について話します。

POEditor で開発フローからローカライズフローを分離する

i18n リソースの翻訳 (ローカライズ) を開発フロー(作業・タスク)から分離したいので、開発者以外でも翻訳できるように、 ローカライゼーションサービスを利用しています。

PLAID では、POEditor というローカライゼーションサービスを採用しています。POEditor では、以下のような UI を備えた画面で、KARTE 管理コンソールの UI のメッセージの翻訳します。

POEditor のローカライズするときのUI

POEditor ではサービス上での UI を通したローカライズ機能だけでなく API も提供しています。今回の継続的ローカライゼーションでは、i18n リソースをインポート・エクスポートしたり、翻訳状況を情報として提供するために API も利用します。

また、POEditor には同サービス内で定義されているイベントが発生したら指定された URL をコールバックする callbacks という機能を提供してます。

callbacksの指定方法

POEditor は翻訳完了時のイベント language.completed を提供しています。この callbacks 機能で、 language.completed を契機に POEditor から i18n リソースをエクスポートとして、次で説明する GitHub Actions で作ったワークフローで、GitHub 上の単一ファイルコンポーネントへ適用できるようにしています。

GitHub Actions で i18n リソースを同期させる

今回導入している GitHub Actions は、今回実装した 継続的ローカライゼーションのワークフローを実現するために中核となる部分です。

以下のワークフローを実装しました。

今回作成したこれら GitHub Actions ワークフローの実装詳細は、筆者がデモ・検証用に作ったリポジトリの .github/workflows で確認できます。

作成したこれら GitHub Actions のワークフローでは、vue-i18n-locale-message と poeditor-service-provider という筆者が作った 2 つの OSS で i18n リソースを GitHub と POEditor 上の i18n リソースを同期させるようにしています。

vue-i18n-locale-mesasge は、単一ファイルコンポーネントに埋め込まれた i18n リソースを抽出して json フォーマットで外部ファイル化、逆に外部ファイル化された json から単一ファイルコンポーネントの i18n リソースへ適用させるような vue-i18n 向けの i18n リソースを管理できる CLI ツールです。

以下の図は、 vue-i18n-locale-message を使って単一ファイルコンポーネントから i18n リソースを抽出、そして適用させる様子をイメージしたものです。( vue-i18n-locale-message に掲載されている図を借用しています)

単一ファイルコンポーネントから i18n リソースを抽出、そして適用させる様子

また、 vue-i18n-locale-mesasge はそれだけでなく、POEditor といったローカライゼーションサービスとの i18n リソースを管理する CLI ツールとなっています。 poeditor-service-provider は世の中にいろいろあるローカライゼーションサービスと API を通して i18n リソースをやり取りするための、 vue-i18n-locale-mesasge で利用されるプロバイダです。

以下の図は、 vue-i18n-locale-message 、プロバイダ、そしてローカライゼーションサービスにおいて、i18n リソースがどのようにやり取りされるのか示したものです。

vue-i18n-locale-message、プロバイダ、ローカライゼーションサービス間の i18n リソースのやりとり

今回作成したこれら GitHub Actions のワークフローでは、今回作り上げる継続的ローカライゼーションは開発ワークフローとは別にプルリクエストベースで非同期に稼働します。このため、GitHub と POEditor 上でやり取りする i18n リソースにそれぞれに不整合が発生しないように、GitHub Labels を使って GitHub Issues 上に、 Push ワークフローそして Pull ワークフローで、i18n リソースを同期するプルリクエストが既に存在しないかどうか、チェックするようにしています。

GitActions で生成された i18n リリースを同期するプルリクエスト

GCP Cloud functions 経由で GitHub Actions のワークフローを起動する

POEditor 上で i18n リソースの翻訳が完了すると、先に説明した POEditor の callbacks 機能で、指定した URL をコールバックすることができます。そして、GitHub Actions は、 repository_dispatch イベントを GitHub API 経由で起動させることができます。

POEditor の callbacks に指定する URL として、今回継続的ローカライゼーションを対象とするレポジトリの repository_dispatch イベントを GitHub API でトリガーする API のエンドポイント URL を指定すれば、翻訳完了時に、今回作成した GitHub Actions の Pull ワークフローを起動できそうです。

残念なことに、POEditor の callbacks によるコールバック機能は、HTTP POST で発行するペイロードをカスタマイズできないため、このまま GitHub API のエンドポイント URL を指定しても、GitHub Actions の Pull ワークフローを起動することはできません。

Poeditor の callbacks が発行するペイロード:

{
    "event": {
        "name": "language.completed"
    },
    "project": {
        "id": ******,
        "name": "Load data #2",
        "public": 0,
        "open": 0,
        "created": "2019-05-17T10:31:50+0000"
    },
    "language": {
        "name": "German",
        "code": "de"
    },
    "stats": {
        "strings": {
            "translated": 3,
            "fuzzy": 0,
            "proofread": 0
        }
    }
}

GitHub API が要求するペイロード:

{
  "event_type": "on-demand-test",
  "client_payload": {
      // ...
  }
}

こうした事情から、POEditor の callbacks を仲介して対象レポジトリの repository_dispatch イベントを GitHub API でトリガーするために、GCP が提供する Cloud Functions を導入して実現しました。

Cloud Functions は、処理させたい関数を Cloud Functions に登録しておくことで、 Cloud Functions が公開するエンドポイント URL に HTTP リクエストするだけで登録した関数を実行できる、サーバーレスなクラウドサービスです。

Cloud Functions は、HTTP リクエストドリブンで稼働するため、大量の HTTP リクエストの処理が要求されるユースケースには合いませんが、今回の継続的ローカライゼーションのワークフローにおいてはユースケースでの使用は問題ないと判断し導入しました。

Cloud Functions に登録した関数は、シンプルに POEditor の callbacks によって発行された HTTP リクエストをハンドリングして、対象レポジトリの repository_dispatch イベントを GitHub API のエンドポイントに HTTP リクエストするような処理をしています。また、次で説明する、 Slack コマンドによる HTTP リクエストもハンドリングするようにしています。

Slack の Slash コマンドで手動で POEditor から i18n リソースを同期させる

POEditor から GitHub 上の単一ファイルコンポーネントへの i18n リソースの同期は、基本的には翻訳全て完了を想定して継続的ローカライゼーションのワークフローを組んでいます。

ただ、 i18n リソースの翻訳が全て完了していない状態でも、現状の翻訳された i18n リソースの内容を単一ファイルコンポーネントに反映させて、プロダクトとして提供させたいというユースケースもあります。

このユースケースに対応するため、以下のように、Slack 上に登録した Slash コマンドを実行することで、手動で POEditor から GitHub 上の単一ファイルコンポーネントへの i18n リソースの同期できるようにしています。

slash コマンド実行の様子

Slack に登録した Slash コマンド /i18n-sync を実行すると、Slack 側から今回導入した GCP の Cloud Functions に HTTP リクエストが発行されます。Cloud Functions 上でその HTTP リクエストがハンドリングされると、POEditor の callbacks のときと同様に、後は、対象レポジトリの repository_dispatch イベントを GitHub API のエンドポイントに HTTP リクエストしているだけです。

継続的ローカライゼーションを導入後の i18n リソースの状況

POEditor に登録された i18n リソースは以下のようになりました。(本記事執筆時点)

i18n リソースの状況 (全体)

PLAID では言語の基本言語を日本語にしています。100% なっていないのは、i18n リソースのキーだけで定義されてメッセージが未定義のものがあるゴミ的なものがありました。

英語の方が 100% になっていないのは、英語にローカライズしなくてよい部分があるためです。それを覗いても、80% を超えているので、KARTE 管理コンソールを最初国際化対応したときに、それで割合が占められている感じです。

グラフで Feb 7, 2020 のあたりで数値が大きくなっているのは、ここで i18n リソースを POEditor に投入したためです。

継続的ローカライゼーションを稼働させ始めたのは、 Feb 19, 2020 の当たりからなので、以下にそれ以降から本記事執筆時点までの状況は以下になります。

i18n リソースの状況 (稼働後)

数値の変化があるので、今回導入した継続的ローカライゼーションのワークフローが動いているのを確認できます。

継続的ローカライゼーションを自前で作って大変だったこと

POEditor が扱える i18n リソース構造に正規化が必要だったこと

vue-i18nrails-i18n のような国際化ライブラリでは、階層構造で i18n リソースを扱うことできます。以下は、 json の場合の i18n リソースの例です。

{
  "world": "foo!",
  "hello": "hello!",
  "nest": {
    "bar": "foo",
    "more": {
      "foo": "f"
    }
  },
}

このような階層構造になっている i18n リソースの場合は、ローカライゼーションサービスによっては、key-value 形式のような、いわゆるシンプルな辞書型の構造しか取り扱いできない場合があります。

POEditor はまさにそのケースであり、上記の例のような i18n リソースをインポートすると、以下のように POEditor では "context" 扱いになってしまいました。

階層構造リソースの扱い

この問題を解消するために、今回の継続的ローカライゼーション導入のために使用した vue-i18n-locale-message のローカライゼーションサービスに i18n リソースを push する際にリソース構造を、階層構造からシンプルな key-value 形式に flatten するオプション指定できるようにしています。

リソース構造の正規化は、実際には vue-i18n-locale-message で利用されるプロバイダである poeditor-service-provicer 側で正規化する債務となっています。

同様に、POEditor から i18n リソースをエクスポートするときも同様で、key-value 形式 から階層構造に i18n リソースを逆変換する必要もあります。

継続的ローカライゼーション導入後の課題

今回スクラッチから仕組みを作って導入した継続的ローカライゼーションのワークフロー、導入前に問題となっていた課題を解決することができましたが、1 つ問題がでてきました。

翻訳コンテキストが分からない問題

POEditor にインポートされた i18n リソースは、以下のようにテキスト情報のみです。POEditor には翻訳する際にコンテキスト情報を i18n リソースのインポート時に付加できますがテキストのみです。

一般的に、特に今回のようにアプリケーションの画面の場合は、ユーザーに使いやすい UI / UX を提供するために、画面に表示されるメッセージは以下のような簡潔なものになります。

翻訳コンテキストがないローカライズの様子

上記のように、画面の UI のコンテキスト情報がない状態で、テキスト情報のみで翻訳するのは、別途提供される画面の UI 情報や機能説明に関する情報がないとかなり厳しいです。

今後のローカライゼーションの課題として、今後取り組んで行く次第です。