📚

多言語対応の辞書キーをフラットな構造にしてみたら良かった話

多言語対応の辞書キーをフラットな構造にしてみたら良かった話

こんにちは、@sukechannnn です。

私たちは2022年7月25日にノンデスクワーカー向けプロジェクト管理アプリKANNAの英語版をリリースしました

今回、英語版の開発を進めるに当たって I18n の辞書をゼロから作りました。その際、作成する辞書の構造について色々悩んだのですが、元々あった日本語の文言をキーにしてフラットな構造の辞書にしました。今のところいい感じなので、なんでそうしたのかを書いてみます。

よくある辞書の構成

よくある辞書の構成として、ドメインやページ毎に分割/階層化してキーを設計していくというやり方があります。Rails とかはデフォルトでこんな感じですね。

{
  "user": {
    "name": "氏名",
    "gender": "性別"
  },
  "company": {
    "name": "会社名",
    "phone_number": "代表電話番号",
    "representative": {
      "name": "代表者名"
    }
  },
  "client": {
    "name": "氏名"
  }
}

しっかり整理されていて綺麗な構造です。しかし、以前このやり方で開発していた時に以下のような課題を感じていました。

  • キーが pearent.child.grandchild となっていると、grandchild の訳を見つけるために json や yaml の階層を辿っていかなければならず、どういう文言が表示されるのかを確認するのが大変
    • ↑の辞書を例にすると、company.representative.name の日本語文言を確認するには、json ファイルを開いて「company→representative→name」と辿らねばならず、何らかのキーで grep することができない(name とかで grep しても引っかかり過ぎてしまう)
  • 同じ文言でも別のキーとして登録されるので、辞書がどんどん大きくなる
    • user.nameclient.name は同じ「氏名」で変わることはないのに、別で定義しないといけない

これがフロントエンドの開発をする際にけっこうつらみを感じていたので、もっと良い方法はないかな?と思い、今回はフラットな辞書の構造を検討しました。

キーがフラットな辞書

キーをフラットしようということで、以下の選択肢を検討しました。

案1: キーの登録方法はそのまま、構造だけフラットにする

{
  "user.name": "氏名",
  "user.gender": "性別",
  "company.name": "会社名",
  "company.phone_number": "代表電話番号",
  "company.representative.name": "代表者名",
  "client.name": "氏名"
}

案2: キーを日本語の文言にする

{
  "氏名": "氏名",
  "性別": "性別",
  "会社名": "会社名",
  "代表電話番号": "代表電話番号",
  "代表者名": "代表者名",
}

最初は案1が良いかなと思ったのですが、構造化されていない状態でドメイン毎にキーを分類をするのは難易度の高さを感じました。フラットにするならとことんフラットにしてしまった方が分かりやすいと判断し、最終的には案2を採用しました。

日本語のフラットなキーにしてみて

案2を採用して良かったことは、

  • 全てのキーをフラットな日本語の文言にしたので、どういう文言が表示されるかは辞書を見なくても分かる
  • 辞書がフラットでキーの重複もないので grep がしやすい
  • t関数が導入された後もそれまでと近い感覚で開発できるので、開発コストが以前とあまり変わらない状態を維持できた

注意しなければならないのが、辞書のキーを全体的に共有しているため、例えば日本語ではどのUIでも同じ文言だが、英語では異なる文言を表示したい場合です。

例えば、以下のような場合です。

  • 日本語では: 会社情報ページでもメンバー一覧ページでも {n}名 と表示したい
  • 英語では: 会社情報ページでは数字のみ {n} 、メンバー一覧ページでは Members: {n} と表示したい

こういった場合は以下のように suffix を付けることで、キーの検索性を維持しつつ翻訳内容を出し分けられるようにしました。

ja.json
{
  ...
  "n名": "{{number}}名",
  "n名__members": "{{number}}名",
  ...
}
en.json
{
  ...
  "n名": "{{number}}",
  "n名__members": "Members: {{number}}",
  ...
}

あともう1つ注意点。このやり方だと文言を変更する場合はキーごと変更した方が分かりやすさを維持できるので、一度適用した文言が後から何度も差し替わるような場合は、きちんとキーを設計した方が良いとは思います(幸い私たちは課題になってないです)。

まとめ

多言語の辞書をフラットにし、キーを日本語の文言にするというあまり聞かない方法を取ってみましたが、今のところはストレスなく開発ができています。

キーの言語については、今後様々な言語にも対応していくに当たって、海外の翻訳者の方が翻訳しやすいように日本語から英語にする予定です。そうした後も、上記のメリットは基本的には変わらず得られるのかなと思っています。

また、Locize のようなサービスを導入した時にも、重複して翻訳を入れる必要がないので翻訳者の方もやりやすいのではないかと思っています。

多言語対応する方の参考になれば幸いです 🙏

おまけ: フロントエンドの辞書化を半自動化した話

「すけちゃん、これから世界展開したいからKANNAの英語化よろしく!」と言われた時、フロントエンド(React, React Native)の文言は日本語でベタ書きされている状態でした。

<HeadTag title="案件作成" />

私たちはWebもアプリも React を用いて開発しているため、ライブラリは react
-i18next
を利用すれば良かったのですが、問題はどうやってt関数を適用していくか?という部分でした。確認しなければならないファイルが数千あるため、愚直にやるととても大変な作業です。

そこで、以下のようなスクリプトを Ruby で実装して、日本語を用いている箇所を自動でt関数に置き換えるようにしました。

i18n_converter.rb
# React のコンポーネントの日本語の部分を t() で囲うスクリプトです。
# コード内でコメントアウトしてる部分は変換しません。
#
# Usage
#
# ./src/pages/home.tsx に適用する
# $ ruby path/to/i18n_converter.rb -f ./src/pages/home.tsx
#
# ./src/pages/ 以下の全てのファイルに適用する
# $ ruby path/to/i18n_converter.rb -d ./src/pages

require 'optparse'

params = ARGV.getopts('f:', 'd:')
file = params['f']
dir = params['d']

def select_ja_words(text, regex)
  ja_words = []
  matched = text.match(regex)
  ja_words << matched.to_s
  loop do
    post_matched = $'.match(regex)
    break unless post_matched.kind_of?(MatchData)
    ja_words << post_matched.to_s
  end
  ja_words
end

def convert(filename, backup_file)
  File.rename(filename, backup_file)

  file = File.open(filename , 'w')
  ja_regex = /\w*(?:\p{Hiragana}|\p{Katakana}|[ー-、。 ・?()]|[一-龠々]).*(?:\p{Hiragana}|\p{Katakana}|[ー-、。 ・?()]|[一-龠々])\w*/
  one_line_t_function_regex = /[^\w]t(<[^<>]+>)?\('[^']+'/
  multiple_lines_t_function_head_regex = /[^\w]t(<[^<>]+>)?\([^\)]*$/
  comment = false
  should_skip = false

  File.foreach(backup_file) do |text|
    # コメントの場合は日本語があっても変換しない(jsx)
    if comment == true
      file.write(text)
      comment = false if text.include?('*/')
      next
    end
    if text.include?('/*')
      file.write(text)
      comment = true unless text.include?('*/')
      next
    end

    # 既にt関数で囲ってある場合は変換しない
    next file.write(text) if text.match?(one_line_t_function_regex)

    # 複数行に渡るt関数を検知した場合、次の行をスキップする
    if should_skip
      should_skip = false
      file.write(text)
      next
    else
      should_skip = text.match?(multiple_lines_t_function_head_regex)
    end

    if text.gsub(%r{//.*}, '').match?(ja_regex)
      ja_words = []
      t_fun_words = []
      bracket_regex = /("|')[^"']*#{ja_regex}[^"']*("|')/
      if text.match?(bracket_regex)
        ja_words = select_ja_words(text, bracket_regex)
        t_fun_words = ja_words.map { |e| "{t(#{e.gsub(/("|')/, '\'')})}" }
      else
        ja_words = select_ja_words(text, ja_regex)
        t_fun_words = ja_words.map {|e| "{t('#{e}')}"}
      end
      ja_words.zip(t_fun_words) do |ja_word, t_fun_word|
        p ja_word
        text.gsub!(ja_word, t_fun_word)
      end
      next file.write(text)
    end
    file.write(text)
  end

  file.close
  File.delete(backup_file)
end

unless file.nil?
  backup_file = file + '.bak'
  convert(file, backup_file)
end

unless dir.nil?
  Dir.glob('**/*', File::FNM_DOTMATCH, base: dir)
  .select { |f| ['.tsx', '.ts'].any? { |i| f.include?(i) } }
  .each do |f|
    filename = File.join(dir, f)
    backup_file = File.join(dir, "#{f}.bak")
    convert(filename, backup_file)
  end
end

これを実行すると、以下のようにt関数が適用されます。

<HeadTag title="案件作成" /><HeadTag title={t('案件作成')} />

本当は JS の AST を使って変換した方が確実に変換できるのですが、ずっと利用し続けるものでもないので得意な Ruby でサッとスクリプトを書いてしまいました。これで99%は自動でt関数を適用できたので、残りの変換できなかった部分やt関数の読み込みは手動で直していきました。

また、辞書ファイル(ja.json や en.json)を生成する部分は、babel-plugin-i18next-extract というBabelプラグインを利用することで自動化しました。このプラグインはt関数が適用してあったら自動で辞書のキーに起してくれる素敵プラグインで、既に辞書にキーがあればそのままですし、辞書にソートもかけてくれるので機械的に辞書を管理できます。

結果、ほとんど手を動かさずに辞書ベースで開発できるようになりました。

アルダグラム Tech Blog

Discussion