Safie Engineers' Blog!

Safieのエンジニアが書くブログです

国際化対応した話 (Webフロントエンド編)

こんにちは、フロントエンドエンジニアの阿部です。

弊社では今後のグローバル進出を見据えて、今年から海外への事業を展開しています。 その一環としてSafieViewer・マイページの国際化対応を行いました。

言語は日本語、英語、ベトナム語、タイ語の4言語に対応しており、現段階では主要な機能においてタイムゾーンにも対応しています。 今回はWebフロントの実装に焦点を当て、SafieViewerとマイページの国際化対応について振り返りたいと思います。
なお、Angularフレームワークを使用しているため、実装の説明はAngularに依存します。

多言語対応とタイムゾーン対応

今回の国際化対応のため、主に行ったのは以下の2点です。

多言語対応
ユーザーが利用する言語に応じて、アプリケーションの表示言語を切り替える機能です。国際化対応の中核を成す要素の一つであり、異なる母語のユーザーに対して、より親しみやすいサービスを提供することが目的です。

タイムゾーン対応
異なる地域のユーザーが同じアプリケーションを利用する際に、それぞれの地域の時間に合わせて正確な時刻情報を提供する機能です。異なるタイムゾーンを考慮することで、ユーザーは常に正確な時刻情報を得ることができます。

それぞれについて対応したことや課題について書いていきます。

多言語対応

辞書の作成

多言語対応を実現するためには、各言語に対応した辞書を作成する必要があります。
辞書には言語ごとにキーと対応するテキストが設定されています。 例えば、英語の場合は「こんにちは」に対して「Hello」、また「おはよう」に対して「Good morning」というように対応付けられます。

// ja.json
{
  "こんにちは": "こんにちは",
  "おはよう": "おはよう"
}

// en.json
{
  "こんにちは": "Hello",
  "おはよう": "Good mornig"
}

上記のように日本語がキーとなる構成の辞書を作成しました。 この辞書のメリット・デメリットは以下です。

メリット

キー名を考える必要がなくなる
よく見る辞書の構成は以下のように、英語がキーとなる構成です。
「こんにちは」「おはよう」であれば単純ですがもっと長いテキストの場合はキー名を考えるのがそもそも大変になります。 その点日本語がキーであれば、キー名を考える手間が省けます。

// ja.json
{
  "HELLO": "こんにちは",
  "GOOD_MORNING": "おはよう"
}

// en.json
{
  "HELLO": "Hello",
  "GOOD_MORNING": "Good mornig"
}

テンプレート上で辞書の探索をしなくても表示される文言がわかる
例えば、以下のようにキーが英語の場合、実際にアプリ上に表示されている文言が一目ではわかりません。表示されている文言を知るには辞書を調べる必要があります。
対して日本語の場合は一目でどんな文言が表示されているかがわかる為、辞書を調べる手間が省けます。

// キーが英語の場合
<div>{{ 'APP_SETTING' | translate }}</div>

// キーが日本語の場合
<div>{{ 'アプリケーションの設定' | translate }}</div>

デメリット

文言を修正する際にキーとセットで変更する必要がある
日本語がキーの辞書の場合、「 アプリケーションの設定 」というテキストを「 アプリケーション設定 」に変更したい場合、キーとテキストの両方を変更する必要があり手間が増えます。
対して英語がキーの場合は、テキストの意味合いが大きく変わらない場合はキーを変更する必要がありません。

// ja.json 

// キーが日本語の場合
{
  'アプリケーションの設定': 'アプリケーションの設定'
}{
  // キーの値を変更する必要がある
  'アプリケーション設定': 'アプリケーション設定'
}

// キーが英語の場合
{
  'APP_SETTING': 'アプリケーションの設定'
}{
  // キーの変更は必要でなない
  'APP_SETTING': 'アプリケーション設定'
}

注意点

この構成の辞書では以下のような場合に注意が必要です。
「月」という日本語を英語にした際に「Monday」と訳すか「Moon」と訳すかは場合によって異なります。 この場合は以下のようにサフィックスをつけるようにして翻訳後の値を明確にすること必要です。

// ja.json
{
  "月_monday": "月",
  "月_moon": "月"
}

// en.json
{
  "月_monday": "monday",
  "月_moon": "moon"
}

どんな辞書の構成にもメリット・デメリットはあると思うので、色々な構成を検討することが大切です。アプリケーションにマッチする辞書構成を考えてみてください。

翻訳サービスの実装

実装

アプリケーションの言語を切り替えるには、翻訳サービスが必要です。
このサービスではアプリケーション初期化時 or 言語を変更した際にユーザーが選択した言語で辞書をロードし、辞書から対応するテキストを取得します。
実装は以下です。

@Injectable({
  providerIn: 'root'
})
export class TranslateService {
 // アプリケーション初期化時 or 言語変更時にinitalizeを呼ぶ
 initialize(){
   // 辞書をロードし、ロードした辞書を保持しておく
  }

  getValue(key: string) {
   // ロードした辞書からkeyで探索した値を返す
  }
}

componentではTranslateSeriviceをDI( DependecyInjection )してTransetService#getValueで翻訳を行います。

// component
this.translateService.getValue('こんにちは')

テンプレート上ではpipeを介して、TranslateService#getValueで翻訳を行います。
translatePipeの中でTranslateService#getValueを呼びます。

// html
<div>{{ "こんにちは" | translate }}</div>
// translate.pipe.ts
@Pipe({
  name: 'translate'
})
export TranslatePipe implements PipeTransform {
  constructor(private translateService: TranslateService){}

  transform(key: string): string {
   return translateService.getValue(key);
  }
}

特殊な翻訳パターンへの対応

翻訳といっても単純にテキストを翻訳するだけではありません。
翻訳時に考えるべき特別なパターンについて説明します。

翻訳するテキストに変数が入る場合

以下のようにユーザーが任意の値にできるデータ名が入った文言を翻訳する場合です。

「dataName」を削除しますか?

こちらはtranslateService#getValueの引数に変数に当たる値を渡すことで実現しました。

// component
this.translateService.getValue('「dataName」を削除しますか?', { dataName: 'テスト' })
// html
<div>{{ '「dataName」を削除しますか?' | translate: { dataName: 'テスト' } }}</div>

// → 「テスト」を削除しますか?

辞書のvalueでは変数部分を${{}}で囲むことで、明示的にこの値が変数であることがわかるようにします。
getValue関数で${{}}で囲まれた値を探索して渡ってきた値を挿入するようにすることで実現しました。 辞書は以下のような形になります。

// ja.json
{
  "${{dataName}}を削除しますか?": "${{dataName}}を削除しますか?"
}

// en.json
{
  "${{dataName}}を削除しますか?": "Do you want to delete ${{dataName}}?"
}

複数形対応

主に単位を翻訳する場合に複数形対応が必要になります。
例えば日本語でカメラの数を表示する場合は1台、2台と表記します。これを英語表記にすると1device, 2devicesとなります。

複数形対応が必要な場合はTranslateService#getValueにoptionとしてアイテムのカウントを渡すようにして実現しました。 テンプレートでは以下のようにして利用します。

// html 
// itemCountから複数形表示にするかどうかを判断する
<div>{{ deviceCount }}{{ '台' | translate: { itemCount: deviceCount } }}</div>

辞書のvalueは、オブジェクトとして複数形と単数形の両方の値を持つようにしました。

// ja.json
{
  "台": "台",
}

// en.json
{
  "台": { "singular": "device", "plural": "devices" },
}

TranslateServiceはitemCountから複数形表示にするかどうかを判断します。
複数形は言語毎にルールが異なるのでそれぞれ対応が必要になります。

※今回は独自にロジックを実装しましたが、Intl.PluralRulesを上手く使えばそれぞれの言語のルールを気にする必要がなくなりそうなので移行していきたい

Serviceで実装することの問題点

翻訳の仕組みをサービスで実装することでいくつかの問題点がありました。 一番の問題はServiceとして実装することで、DIしなければならないことです。
以下で問題点について説明します。

Angular外では利用できない

例えばServiceWorkerなどでプッシュ通知を実装しており、ServiceWorker内でプッシュ通知のテキストを定義している場合などです。 ServiceWorkerはAngular外の話なので、サービスを利用することができず翻訳できないという問題があります。

野良関数ではDIができない

例えば以下のようにtypeによって表示するテキストを返す関数の場合です。

export function typeToString(type: string) {
  if (type === 'hoge') {
    return 'こんにちは'
  } else if (type === 'huga') {
    return 'おはよう'
  }
}

このような関数の場合は、使う側でTranslateServcieをDIして、この関数にTranslateServiceのインスタンスを渡せば翻訳自体は可能です。
しかし、テキストを返す関数を実装する場合は、以下のようにtranslateServiceを引数に渡せるように実装しなければならなくなります。

// 利用する側
const text = typeToString('hoge', this.translateService);

クラスの外だとDIできない

以下のように定数として定義しており、オブジェクトのテキストをキーに応じて表示する場合などです。 この場合はDIができない為、このままでは翻訳が難しいです。

// component
const TEXT_MAP = {
  'huga': 'ふが',
  'hoge': 'ほげ'
}

※関数や定数に関しては、戻り値を翻訳する方法もありますが、使用する側で翻訳処理を挟まなければならないことや翻訳の漏れを考慮すると、文言を定義した場所で翻訳する方が良いと考えています。

// これだと使う側で必ず翻訳をしなければならない
// typeToStringの中だけを見た時に翻訳されているかどうかはわからない
const text = typeToString('hoge')
const translatedText = translateService.getValue(text);

解決方法

詳細は省きますが、TranslateServiceとほとんど中身は同じtranslate関数という関数をjsで実装しました。(DIせずに基本的にはどこからでも呼び出せる。) jsで実装することでServiceWorker内でも使用できるようになります。

translate関数を実装したことで上記の課題は解決できました。

タイムゾーン対応

タイムゾーンの取得

ユーザーが居住している地域のタイムゾーンを正確に取得することは、タイムゾーン対応の実現に重要です。
通常、ブラウザやデバイスから提供される情報を利用して、ユーザーの現在のタイムゾーンを特定します。 ブラウザのタイムゾーンIDを取得するには、Intl.DateTimeFormat().resolvedOptions().timeZoneを使用します。
このプロパティはタイムゾーンIDが取得できない場合にEtc/Unknownという値を返す可能性があるため、この値が返ってくる場合の考慮も必要になります。

タイムゾーン表示

アプリケーション内で時刻情報を表示する際には、ユーザーのタイムゾーンに合わせて適切な時刻を表示する必要があります。これにより、ユーザーは自分の地域に合った時刻を確認することができます。
国際化対応においてUnixtimeで時間を扱うことはとても重要になってきます。

例えば以下のようにユーザーが2024年4月29日午後1時の映像を再生することを考えます。この日付はユーザーが異なるタイムゾーンに住んでいる場合、表示される時刻が異なります。

SafieViewer

具体的には以下のように時刻が異なるため表示を変更する必要があります。

東京 (日本標準時、JST) の場合: 2024年4月29日午後1時
ベトナム (ベトナム標準時, ICT)の場合: 2024年4月29日午前11時
ニューヨーク(東部標準時、EST)の場合: 2024年4月29日午前12時

UnixTimeで時間を扱っていれば、JavaScriptのDateオブジェクトがブラウザのタイムゾーンに合わせて適切に表示を変更してくれます。

APIレスポンスへの対応

Unixtimeで時間を扱っている場合は問題ないですが、既存のAPIがJST(日本標準時)のレスポンスを持つ場合があります。 このような場合、API側に表示すべきタイムゾーンを知らせるため、リクエストにtzパラメータとして取得したタイムゾーンIDを渡し、API側でそれに応じたレスポンスを返すように修正する等の対応が必要になります。(今回はこの対応を行いました)

新規にAPIを実装する場合は、時間関連のレスポンスをUnixTimeで扱い、フロントエンド側でタイムゾーンIDに応じて時刻表示を切り替えることでより実装が効率的になると思います。

まとめ

今回のブログでは、簡単に多言語対応とタイムゾーン対応についてそれぞれの実装方法や課題について解説しました。
国際化対応では言語や地域の違いを考慮し、ユーザーにとって使いやすいアプリケーションを提供することが重要です。 今回の取り組みを通じて、国際化対応の重要性や実装方法について深く理解することができました。 今回の内容を基に、さらなる機能改善や拡張を行い、より多くのユーザーに価値を提供していきたいと考えています。

セーフィーではエンジニアを積極的に採用しています。
興味がある方は是非下記サイトを一度覗いてみてください。
https://safie.co.jp/teams/engineering/

© Safie Inc.