Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goで多言語化を実装してみた

サーバーサイドエンジニアの田実です!

Yappliでは多言語に対応したアプリを公開できます。 ネイティブ向けのAPIでは一部Go言語を使っており、エラーメッセージなどの多言語化の機構が実装されています。

本記事では、Goで多言語化を実装する方法を紹介したいと思います。

実装方法

準標準パッケージであるgolang.org/x/textを使って多言語化が実装できます。

package main

import (
    "fmt"

    "golang.org/x/text/language"
    "golang.org/x/text/message"
)

var languages = []language.Tag{
    language.Japanese,
    language.English,
}

func main() {
    _ = message.SetString(language.Japanese, "hello", "こんにちわ")
    _ = message.SetString(language.English, "hello", "Hello!")

    translate("ja-jp", "hello") // => こんにちわ
    translate("ja", "hello")    // => こんにちわ
    translate("en-US", "hello") // => Hello!
    translate("ko", "hello")    // => こんにちわ(フォールバック)
}

func translate(acceptLanguage string, msg string, args ...interface{}) {
    t, _, _ := language.ParseAcceptLanguage(acceptLanguage)
    matcher := language.NewMatcher(languages)
    tag, _, _ := matcher.Match(t...)
    p := message.NewPrinter(tag)
    fmt.Println(p.Sprintf(msg, args...))
}

上記例では簡略化のためベタ書きしていますが、実際のコードでは言語設定をYAMLファイルに切り出してビルド時にgo:embedで埋め込んで使っています。 また、matcherは対応言語が変わらないのであれば一回だけ初期化すれば良いのでパッケージ変数として定義して、init時に1回だけ初期化しています。

以下で各コードの解説をしていきたいと思います!

解説

message.SetString()を使って多言語化のメッセージを登録できます。第1引数は対象のlanguage.Tag、第2引数・第3引数にはキー・バリューを登録します。

_ = message.SetString(language.Japanese, "hello", "こんにちわ")
_ = message.SetString(language.English, "hello", "Hello!")

メッセージを登録したら、Accept-Languageヘッダの値から対象言語を取り出してそれにマッチする値を取り出します。

t, _, _ := language.ParseAcceptLanguage(acceptLanguage)

language.ParseAcceptLanguage() ではAccept-Languageヘッダの値からlanguage.Tagのリストを取得します。

優先度も考慮してくれて、例えば

zh;q=0.5,ko;q=0.9,zh-TW;q=0.6,ja

という文字列を渡した場合は

[ja ko zh-TW zh]

と優先度順にlanguage.Tagを返してくれます。

第2戻り値はweightの配列になりますが、第1戻り値はweightの高い順に並んでおり matcher.Match() の引数にそのまま入れられるので使っていません。 第3戻り値は不正なAccept-Languageが指定された場合にエラーが返ります。こちらも matcher.Match() でフォールバックされるので無視していますが、実コードではエラー時はログを入れた方が良いと思います。

次にmatcher.Match()でサポート対象のlanguage.Tag(今回の例だと日本語と英語)とAccept-Languageから取得したlanguage.Tagを比較してマッチするものを返します。

matcher := language.NewMatcher(languages)
tag, _, _ := matcher.Match(t...)

マッチするものがない場合は NewMatcher() で指定した一番目のlanguage.Tagがフォールバックとして利用されます。今回の例だとフォールバックは日本語になります。

第2戻り値はマッチしたタグの配列のindex、第3戻り値はConfidenceという戻り値の確実性を表す指標で今回の実装では無視しています。

あとはmessage.NewPrinter() にlanguage.Tagを入れてPrinterを生成して、Sprintf()を呼び出すと、language.Tagと引数のキーに一致するメッセージを取得できます。

p := message.NewPrinter(tag)
fmt.Println(p.Sprintf(msg, args...))

Sprintfなので、%dなどの書式指定子を使って任意の値をバインドすることも可能です。

_ = message.SetString(language.Japanese, "items", "%d個")
// ...
p.Sprintf("items", 10) // => 10個

コードリーディング

内部の処理も追ってみたので紹介したいと思います。golang.org/x/textのバージョンはv0.3.6です。

message.SetString() は以下のようにcatalog.NewBuilder() で生成したdefaultCatalogに対してSetString()する関数となっています。

// DefaultCatalog is used by SetString.
var DefaultCatalog catalog.Catalog = defaultCatalog

var defaultCatalog = catalog.NewBuilder()

// SetString calls SetString on the initial default Catalog.
func SetString(tag language.Tag, key string, msg string) error {
    return defaultCatalog.SetString(tag, key, msg)
}

Builder.SetString()Builder.set() を呼び出します。mutexで排他処理しつつmapに値をセットしています。

func (c *Builder) set(tag language.Tag, key string, s *store, msg ...Message) error {
    data, err := catmsg.Compile(tag, &dict{&c.macros, tag}, firstInSequence(msg))

    s.mutex.Lock()
    defer s.mutex.Unlock()

    m := s.index[tag]
    if m == nil {
        m = msgMap{}
        if s.index == nil {
            s.index = map[language.Tag]msgMap{}
        }
        c.matcher = nil
        s.index[tag] = m
    }

    m[key] = data
    return err
}

Priner.Sprintf()message.SetString() で予め設定した文字列を lookupAndFormat() を使って取得します。

// Sprintf is like fmt.Sprintf, but using language-specific formatting.
func (p *Printer) Sprintf(key Reference, a ...interface{}) string {
    pp := newPrinter(p)
    lookupAndFormat(pp, key, a)
    s := pp.String()
    pp.free()
    return s
}

キーがカタログにない場合は printer.Render() によってメッセージがそのまま表示されます。

func lookupAndFormat(p *printer, r Reference, a []interface{}) {
    p.fmt.Reset(a)
    var id, msg string
    switch v := r.(type) {
    case string:
        id, msg = v, v
    case key:
        id, msg = v.id, v.fallback
    default:
        panic("key argument is not a Reference")
    }

    if p.catContext.Execute(id) == catalog.ErrNotFound {
        if p.catContext.Execute(msg) == catalog.ErrNotFound {
            p.Render(msg)
            return
        }
    }
}

まとめ

Goの準標準パッケージを使って多言語化実装する方法を紹介しました。

多言語化実装を進めるにあたり3rd Partyなi18nライブラリの利用も検討したのですが、Yappliの要件的にtoo muchなものが多かったため採用を見送りました。 golang.org/x/textAccept-Languageのパース タグにマッチした文言の抽出というシンプルな機能を提供しており、今後の要件に応じて柔軟に多言語化のインターフェースや実装を変更できそうなことも採用をした理由になります。

Goで多言語化を実装する際の参考になれば幸いです!