🌐

多言語対応を半自動化する 〜Rails 前編〜

2024/12/02に公開

はじめに

RYDE ではデジタル乗車券アプリ「RYDE PASS」の開発・運用を行っています。

https://pass.ryde-go.com/

サービスの概要については以下の記事をご覧ください。

https://zenn.dev/ryde/articles/5c2ba40d930577

RYDE PASS では、バックエンド(Rails)とモバイルアプリ(React Native)で日・英・中(簡)・中(繁)・韓の多言語対応を行っています。
具体的には、以下の 3 種類の項目を多言語化しています。

  1. バックエンド: DB の値
  2. バックエンド: locale ファイル
  3. モバイルアプリ: locale ファイル

いずれも 5 言語分の多言語対応を行っているので、手動での翻訳対応は現実的ではなく、

  • Cloud Translation により日本語から他言語の翻訳を生成する
  • 明らかな誤訳等を手動で修正する

というように半自動化して運用しています。

今回は、「1.バックエンド: DB の値」を半自動で多言語化する方法を紹介します。

Cloud Translation について

Cloud Translation は Google Cloud で提供されている機械翻訳サービスです。
基本的な機械翻訳だけではなく、用語集機能により特定の翻訳の入出力をカスタマイズすることも可能です。

また、料金体系は翻訳文字数に対しての課金となっており、毎月一定の文字数までは無料で利用できます。

https://cloud.google.com/translate/?hl=ja

RYDE PASS ではアプリの特性上、交通業界に関する用語も多く使用されるため、用語集機能のある Cloud Translation を使っています。

多言語化の仕組み

DB の多言語対応 gem としては Globalize, Traco なども有名ですが、RYDE PASS では mobility を使っています。

https://github.com/shioyama/mobility

mobility では複数の多言語化方式があり、総称して"backend"と呼ばれています。
backend の種類には以下のようなものがあります。

Table backend

  • Globalize に近い方式
  • 多言語化対象のテーブル毎に、_translacionsという suffix の付いた別テーブルを作成する
    • e.g. productsテーブルに対してproduct_translationsなど
  • 元テーブルの 1 レコード・1 言語に対して別テーブルに 1 レコード作成する
  • 毎回 join する必要がある

Column backend

  • Traco に近い方式
  • 多言語化対象の属性に対して、各言語に対応するカラムを作成する
    • e.g. productsテーブルのname_ja, name_enカラムなど
  • 多言語化対象の属性や言語が増えるとカラムの数が多くなる

Default backend

  • mobility 独自でデフォルトの方式
  • table 方式のように「1 つの元テーブルに対して 1 つの別テーブル」ではなく、全ての元テーブルの多言語データを別テーブルに集約している
  • 元テーブルの 1 レコード・1 言語・1 カラムに対して別テーブルに 1 レコード作成する
    • 対象カラムの型(string / text)に応じて 2 種類の別テーブル(mobility_string_translations, mobility_text_translations)にレコードが作成される
    • 下図のようなポリモーフィック関連
  • 毎回 join する必要がある

json / jsonb / hstore backend

  • PostgreSQL でのみ使用可能な方式
  • 多言語対象属性に対応した json / jsonb / hstore 型のカラムを作成し、locale 名を key、locale に対応した属性値を value に格納する
  • DBMS に依存した方式になる

上記の他にも独自のカスタム backend を使用することができます。

RYDE PASS においては、各方式で以下のようなつらみがあり、いずれもジャストフィットしませんでした。

  • Table backend
    • join が必要なのでパフォーマンスが低下する
    • 値が別テーブルに入っていると DBMS 上や管理画面上で視認性が悪い
  • Column backend
    • カラム数が多くなりすぎる
    • 言語が増えた時の対応が大変
  • Default backend
    • Table backend と同様
  • json / jsonb / hstore bacnend
    • そもそも安易に json 型等を多用するのはアンチパターン
    • ActiveRecord のバリデーションである程度厳格にはできるが、DB レベルの制約が適切に設定しづらい

そこで、jsonb backend をベースに以下のようなカスタム backend としました。

  • 多言語化対象属性ごとにjaカラムとi18nカラムを作成する

  • i18nカラムには、日本語以外の各言語と"base"キーを作る

    • e.g. name_i18n: { base: 'サンプル商品', en: 'sample product', ko: '샘플 상품', ... }
    • baseキーの値は後述の翻訳自動化で使用します
  • locale が日本語の場合はjaカラムから、その他の場合はi18nカラムの対応するキーから値を読み込む

こうすることで、

  • データが別テーブルに分散しない
  • 言語が増える時は、jsonb カラムのキーを増やしさえすれば良い
  • 日本語データに関しては DB レベルの制約も設定しやすい

といった各方式のメリットをいいとこ取りできました。

実装は以下の通りです。
ここでは Jsonb backend を継承しつつ、日本語のデータ呼び出しには Column backend の方式を使っています。

require 'mobility/backends/active_record/jsonb'
require 'mobility/backends/column'

module Mobility
  class CustomBackend < Backends::ActiveRecord::Jsonb # Jsonb backendを継承
    include ::Mobility::Backend
    include ::Mobility::Backends::Column

    def read(locale, _options = nil)
      if locale == :ja
        # localeがjaならばjaカラムの値を返す(Column backendと同じ方法)
        model.read_attribute(column(:ja))
      else
        # その他の場合は、i18nカラムから対応する値を返す(対応するものがなければjaカラムの値を返す)
        super.presence || model.read_attribute(column(:ja))
      end
    end

    def write(locale, value, _options = nil)
      # localeがjaならばjaカラムに、そうでなければi18nカラムに書き込む
      locale == :ja ? model.send(:write_attribute, column(locale), value) : super
    end
  end
end

適用先の model クラスでこのカスタム backend を読み込むことにより使用できます。

class Product < ApplicationRecord
  extend Mobility
  # name, description属性を多言語化する場合
  translates :name, :description, column_suffix: '_i18n', backend: Mobility::CustomBackend
end

多言語化の半自動化方法

前述のように、翻訳は Cloud Translation を使っています。
Ruby 用にはgoogle-cloud-translate gem が用意されています。

# Gemfile
gem 'google-cloud-translate'

この gem をラップしたクラスで、用語集を使った翻訳を行います。

# 実際のコードより簡略化しています

class Translator
  # @param [Array<String>] texts 翻訳対象のテキスト
  # @param [String] source 入力の言語コード(ja)
  # @param [String] target 出力の言語コード(en, zh-CN, zh-TW, ko, ...)
  # @return [Array<String>]
  def translate(texts:, source:, target:)
    return texts if source == target

    request = {
      contents: texts,
      mime_type: 'text/plain',
      source_language_code: source,
      target_language_code: target,
      parent: "projects/#{your_project_id}/locations/#{your_location}",
      # 用語集の設定
      glossary_config: {
        glossary: client.glossary_path(
          project: your_project_id, location: your_location, glossary: your_glossary_id
        )
      }
    }

    Google::Cloud::Translate.translation_service.translate_text request
  end
end

翻訳はレコードの作成/保存時のコールバックで行います。

モデル内で直接コールバックを定義しても良いですが、複数のモデルで共通化するため、Concern で翻訳処理を定義します。
また、Cloud Translation への過剰なリクエストを防ぐため、日本語で差分のあるカラムのみ検出して翻訳を行うようにします。

# 実際はパフォーマンスの観点から翻訳は非同期で行っていますが、この例では簡略化のため同期的に行っています

module Translatable
  extend ActiveSupport::Concern

  included do
    extend Mobility

    after_commit :execute_translation
  end

  private

  def execute_translation
    # 差分のあるカラムを検出
    changed_attributes = self.class.mobility_attributes.select do |attribute|
      # 前回の翻訳実行時の日本語をi18nカラム(jsonb型)のbaseキーに格納している
      # この値とjaカラムの値を比較することにより差分を検出する
      public_send(:"#{attribute}_ja") != public_send(:"#{attribute}_i18n")['base']
    end # e.g. name, description, ....

    # 言語ごとに翻訳を実行
    result = ['en', 'zh-CN', 'zh-TW', 'ko'].map do |target|
      translated_texts = Translator.new.translate(
        texts: changed_attributes.map { |attribute| public_send(:"#{attribute}_ja") },
        source: 'ja',
        target:
      )
      { locale: target, texts: translated_texts }
    end

    # resultのHash構造を、update_columnsに渡すために以下のような形式に変換
    # baseキーの値は、次回の翻訳実行時の差分検知のために使う
    # {
    #   name_i18n: { en: 'sample product', ..., base: 'サンプル商品' },
    #   description_i18n: { en: 'sample description', ..., base: '商品説明' }
    # }
    translated_attributes = convert_hash_for_update(result)

    update_columns(translated_attributes) unless translated_attributes.empty?
  end
end

多言語対象の各モデルで Mobility を直接呼ばずに、この Concern を呼ぶようにします。

class Product < ApplicationRecord
- extend Mobility
+ include Translatable
  # name, description属性を多言語化する場合
  translates :name, :description, column_suffix: '_i18n', backend: Mobility::CustomBackend
end

これによりレコードの作成/更新時に、差分のあるカラムだけ、複数言語分の自動翻訳が行われます。
翻訳対象を最小限に抑えているので、Cloud Translation の料金もかなり安く抑えられています。

翻訳結果に誤りがある場合や、明示的に翻訳結果を指定したい場合は、直接 i18n カラムを更新すれば OK です。

このようにして、「ほぼ自動化」「翻訳結果の柔軟なカスタマイズ」「低コスト」という条件を満たした多言語対応を実現しています。

次回はバックエンドの locale ファイルの多言語化について紹介します。

最後に

私たちは、RYDE PASS の進化と成長を支えるエンジニアリングに情熱を注いでいます。
現在、新機能の開発に加えて以下のような取り組みを進めています:

  • バックエンドのモジュラーモノリス化と型定義の強化
  • モバイルアプリの Expo 化と開発体験の向上
  • 管理画面のパフォーマンス最適化とフォーム関連のリファクタリング

エンジニア採用募集中!

私たちと一緒に「移動の未来」を創りませんか?技術が好きな方、挑戦を楽しめる方をお待ちしています!

https://rydeinc.notion.site/RYDE-99302bd5277c4582975d358e9cce4c6c

RYDE株式会社

Discussion