Golang APIサーバーで多言語対応を自作で対応した話
こんにちは、Sally社 CTO の @aitaro です。普段はマーダーミステリーアプリ「ウズ」とマダミス制作ツール「ウズスタジオ」、マダミス情報サイト「マダミス.jp」を全力で開発しています。
この記事では、Golangで開発しているAPIサーバーの多言語化対応について、私が取り組んだ内容を紹介します。
はじめに
多言語対応は、グローバル市場でのプロダクト展開において避けて通れない課題です。
技術的には主にwebフロントエンドやモバイルアプリなど、ユーザーインターフェース部分についての対応が多いですが、APIサーバーでも、ユーザーへの通知処理まわり、フロントエンドへのエラー文言返却、レスポンスの動的文字列構築等でユーザーに見える形で文字列を返却する場面があります。APIサーバーでの多言語対応部分について、統一的なアプローチで対応することが今回の目的になります。
私自身、多言語化対応を実際のプロダクトで行なってきたのは初めてであり、フレームワークの支援なしに多言語化を行う方法を手探りでトライしました。この記事では、その経験と知見を共有できればと思います。
Golangでの多言語対応の基本概念
多言語対応の要素
多言語対応には次の要素が含まれます。
- 言語ファイル: 各言語に対応するテキストを格納したファイル。これらのファイルは、アプリケーション内のテキストを言語ごとに管理するために使用されます。
- 言語選択機能: ユーザーが自分の希望する言語を選択できる機能。これには、ブラウザのAccept-Languageヘッダー、Cookie、またはURLパラメータを使用して自動的に言語を切り替える方法が含まれます。
- フォールバックメカニズム: 指定された言語がサポートされていない場合に、デフォルト言語に切り替える仕組み。これにより、ユーザーが意味不明なテキストを表示されないようにします。
Golangでの多言語対応の基本手法
Golangで多言語対応を実現するには、主に以下の方法があります。
手動管理
言語ごとにテキストを直接コード内に埋め込み、手動で管理する方法。これは小規模なプロジェクトでは可能ですが、拡張性に欠けます。
専用ライブラリの利用
go-i18nなどのライブラリを使用して、言語ファイルの管理やテキストの動的切り替えを効率的に行う方法。これにより、手間を大幅に削減でき、大規模プロジェクトにも適しています。go-i18n の他には universal-translator, qor/i18n 等が挙げられます。
多言語管理ツールを内製する
特定の要件やカスタマイズが必要な場合、ライブラリを利用せずに自作で多言語対応機能を実装することも可能です。このアプローチでは、言語ファイルの管理、ユーザーの言語設定の検出、テキストの動的な置き換えなどを自前で構築します。これにより、プロジェクトに特化した最適な多言語対応が可能となり、柔軟性を高めることができますが、その分実装に手間がかかる点には注意が必要です。
言語ファイルの管理
一般的に、多言語対応では言語ファイルが重要な役割を果たします。これらのファイルは通常、JSONやYAML形式で保存され、各言語に対応するテキストをキーと値のペアで格納します。例えば、en.jsonというファイルに英語のテキストを、ja.jsonというファイルに日本語のテキストを格納します。
言語ファイルの管理には、以下の点に注意が必要です。
- 一貫したキーの使用: すべての言語ファイルで一貫したキーを使用し、異なる言語間でテキストの対応を簡単にする。
- バージョン管理: 言語ファイルをGitなどのバージョン管理システムで管理し、変更履歴を追跡できるようにする。
設計方針と要件定義
先行してフロントエンドの多言語対応を行なっていたので、その時の知見から多言語対応にあたって以下の要件を定義しました。
辞書ファイルとして json, yaml といった標準的な構造化データフォーマットの利用
- 標準的な構造化データフォーマットを利用することで、将来的に外部ツールと連携してエンジニア以外でも辞書ファイルを修正できるようにするといった要件が発生した時に対応を行いやすい。
- 人間が読み書きしやすいという点、先行する Flutter では slang を選定し yaml フォーマットが用いられているという点より yaml の方がベター。
変数埋め込みが可能
- 言語によって語順が変わることが多々ある、動的アプリケーションを開発する際は必須の要件になる。
型安全に扱うことが可能
- 多言語のキーはプロジェクト全体で何千と存在するので、人の手で運用する以上確実にミスが起こりうる。Golang を利用しているので、静的型付けの特性を活かして、多言語キーを型安全に扱うべきである。これにより存在しないkeyを指定してしまうというミスは避けられたり、静的解析を行うことで実装より参照されていないkeyを検出することが可能になる。
弊社の場合、スタートアップとして高速に価値デリバリーをすることは重要であり、そのためには良くテストされたOSSの活用は第一に考えるべき選択肢ですが、今回の場合は、「型安全に扱うことが可能」の要件を満たすライブラリはなく、多言語対応ツールを自作することに決めました。
(正確には、qor/i18n はストレージモードというモードがあり、不足キーが自動でDBに追加されることで、多言語キーの取り違えというのは発生しづらくはなりますが、弊社の開発プロセスとは合わなかったので断念しました。)
自作にあたって、以下の設計で進めることにし、前述の要件を満たすようにしました。
-
多言語化マスターとなる言語(baseLang)を一つと、対応している言語のリスト(supportedLangs)を定義する。
-
対応している言語のリストそれぞれについて辞書ファイルをyamlで作成
-
baseLang の辞書ファイルのキーを元に、構造体をコード生成する。
-
各goファイルからは、生成された構造体のコードを参照することで、文字列型でキーをかかない。
-
各goファイルからアクセスする時、明示的に取得したい言語を示すことも、contextに埋め込まれた言語情報を参照しても、どちらでも取得できるようにする。
-
自作した i18n package からアプリケーション側のコードを参照しないように依存関係を限定する。
実装ステップ
1. meta.yaml を用意
baseLangとsupportedLangsを定義する以下のようなファイルを用意します。言語情報は BCP47 で標準化されているので、これに沿って用意します。
BCP47はIETFが策定した言語タグの標準であり、[言語]-[地域]
といった形で表せます。
baseLang: ja
supportedLangs:
- ja
- en
- zh-TW
- zh-CN
基本的には各言語に沿って、 ja
en
と用意していきますが、同じ言語でも地域によって表記を出し分けたいというニーズが存在するため、zh-TW
, zh-CN
と用意する必要があります。
2. 辞書ファイルを用意
辞書ファイルを以下のような構造化yamlで定義していきます。golang のpackage や file 名とできる限り構造が一致するようにします。
resolvers:
user:
completed: 新規登録完了
delete(nickname): ${nickname} を削除しました。
3. 辞書ファイルに沿って構造体を生成
baseLangの辞書ファイルの構造に沿って構造体を適宜していきます。
例えば、ステップ2のようなコードからは以下のような構造体が定義されます。
type Root struct {
Resolvers Resolvers
}
type Resolvers struct {
User User
}
type User struct {
Completed string
Delete func(nickname string) string
}
コード生成には github.com/dave/jennifer/jen
を利用しています。Go 標準の text/template
と比較して、Golangのコード生成に特化している分、書きやすい、メンテしやすいというメリットがあります。
実際のコード生成用のコード詳細はここでは載せませんが、辞書ファイルでのキーの定義は階層構造を持つ可能性があるため、再帰を利用して型定義を実装します。
4. 辞書ファイルから各言語の構造体定義
keyを抜き出して構造体を定義したのち、以下のような各言語の辞書変数を定義します。
var JaDict = Root{User: User{Completed: "新規登録完了", Delete: func(nickname string) string {
return fmt.Sprintf("%[1]sを削除しました。", nickname)
}}
こちらも同様に再帰を利用して、辞書変数を組み立ていきますが、変数埋め込みをサポートしたいので、変数埋め込みのあるkeyに関しては fmt.Sprintf を利用して埋め組まれるようにしています。
変数が一つまでなら、多言語化利用先で fmt.Sprintf を呼び出して変数埋め込んだら十分なのですが、変数が2つ以上埋め込みたくなった場合は、文字列内での変数出現順序を適切に管理しないといけなくなるので、パッケージ側でサポートするようにしました。
あまり知られていない機能ですが、%[1]s, %[2]sといった形でフォーマットする引数を指定できるので、順序が変わっても適切にフォーマットされるようにできるので、内部でこの機能を活用しています。
5. 辞書構造体アクセス部分を実装
定義された、JaDict, EnDict 等にアクセスするために、以下のようなコードを生成します。
この package で扱う言語の型を Lang 型に制限した上で、Lang から辞書構造体にアクセスするための GetDict
関数、および 実世界では ja
や ja-JP
でリクエストがきたりするのでその表記揺れを吸収するためのParseLang
関数を適宜しています。
type Lang string
const Ja = Lang("ja")
const En = Lang("en")
const ZhTw = Lang("zh-TW")
const ZhCn = Lang("zh-CN")
const Ko = Lang("ko")
var SupportedLangs = []Lang{Ja, En, ZhTw, ZhCn, Ko}
const BaseLang = Lang("ja")
func GetDict(lang Lang) Root {
switch lang {
case En:
return EnDict
case Ja:
return JaDict
case Ko:
return KoDict
case ZhCn:
return ZhCnDict
case ZhTw:
return ZhTwDict
default:
return JaDict
}
}
func ParseLang(lang string) Lang {
switch lang {
case "ja":
return Ja
case "en":
return En
case "zh-TW":
return ZhTw
case "zh-CN":
return ZhCn
case "ko":
return Ko
default:
if len(lang) >= 2 {
switch lang[0:2] {
case "ja":
return Ja
case "en":
return En
case "ko":
return Ko
}
}
return Ja
}
}
5. context によるアクセス部分を実装
言語情報は、Accept-Language Header かユーザーが保存した言語設定情報を参照することが多いです。特に前者の場合は、request時にAccept-Language Headerから言語情報を抜き出し、それを引数でバケツリレーしなくてもいいように context に埋め込むことが一般的です。
今回自作した package では context による言語情報の埋め込み、およびアクセスをサポートしています。
package i18n
import "context"
type keyType string
const KeyLocale keyType = "I18nLocale"
func GetLocale(ctx context.Context) Lang {
val := ctx.Value(KeyLocale)
if val == nil {
return ""
}
return val.(Lang)
}
func SetLocale(ctx context.Context, lang Lang) context.Context {
return context.WithValue(ctx, KeyLocale, lang)
}
func GetDictContext(context context.Context) Root {
lang := GetLocale(context)
return GetDict(lang)
}
この package は HTTPサーバーに用途を限定するものではなく、単一責務の原則から、Accept-Language から実際に言語情報を抜き出す処理等は含まれていません。
なので利用側で以下のように利用することができます。
middleware 部分での言語情報取得
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// BCP 47 に沿った 1つの item のみを受け付ける。
acceptLang := r.Header.Get("Accept-Language")
normalizedLang := i18n.ParseLang(acceptLang)
ctx = i18n.SetLocale(ctx, normalizedLang)
next.ServeHTTP(w, r.WithContext(ctx))
})
context から keyの取得
18n.GetDictContext(ctx).Resolvers.User.Completed
はまりどころ・今後の懸念
Golangのおせっかいな標準ライブラリの挙動により、辞書ファイルのキーがランダムに並べ替えられてしまう現象が発生しました(注1)。これにより、出力結果が安定せず、GitHubのプルリクエストで頻繁にコンフリクトが発生する問題が生じました。この問題を解決するため、コード生成の実行結果をキー順にソートする処理を追加しました。具体的には、以下のような関数を用いてキーを並べ替えることにしました。
func sortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
また、バックエンドにおける辞書ファイルのサイズは、ユーザーインターフェース(UI)と比べて比較的小さいため、現時点ではパフォーマンスへの影響は軽微です。しかし、今後、辞書ファイルのキーや対応言語が増加することで、パフォーマンスに影響を及ぼす可能性があります。そのため、適切にファイルを分割するなどの対策を検討していく必要があります。
注1) 詳しくは調査していませんが、https://zenn.dev/koya_iwamura/articles/def382a87dfce1 こちらでも言及されている、「Go の初期の実装では乱数は用いられていなかったのですが、map から要素を取り出す際の順序が固定されていることに依存した処理を書かせないために乱数が用いられるようになりました。」これが関連してるのではと思っております。
まとめ
本記事では、GolangでのAPIサーバーにおける多言語対応の実装について解説しました。型安全性を重視しつつ、自作ツールで柔軟な多言語対応を実現しました。特に、構造体をコード生成することで、プロジェクト全体でのミスを減らし、メンテナンス性を向上させました。
実装過程では、辞書ファイルのキーがランダムに並べ替えられる問題に直面しましたが、キーのソートを導入することで安定した出力を確保しました。
本プロジェクトでは、他にもAIを利用した多言語対応の効率化や、コードの変更なしで非エンジニアでも辞書ファイルを修正できるような環境を整備してきたので、次はそれらの記事も書いていく予定です!
採用情報
Sally社では、新しいエンタメ領域のプロダクト開発に興味を持ったエンジニアを募集しています!
この記事を読んで、話を聞いてみたいという方は↓よりカジュアル面談をお申し込みください。
Discussion