【Golang】go-i18n/v2 の動作サンプル（2021年03月版）【国際化対応】

# `internationalization` 略して `i18n`

> **`go-i18n` v2.x の動くサンプルが欲しい**。しかも Go v1.15 以降で動作確認済みの。
>
> - [github.com/nicksnyder/go-i18n](https://github.com/nicksnyder/go-i18n) @ GitHub
>
> TOML じゃたまらんので JSON が良い。

2021/03/07 現在の `go-i18n` のバージョンは v2.1.1 なのですが、[公式のサンプル](https://github.com/nicksnyder/go-i18n/blob/703c3bde3ea09b5f449581f4cb30f681e11351a3/v2/i18n/example_test.go)をみても、なんか「従来バージョン（v1.x）を理解していた人向け」なのかと思うほど、前提条件がわらんちんなのです。

[「Golang go-i18n v2 動作サンプル」とググって](https://www.google.com/search?q=Golang+go-i18n+v2+%E5%8B%95%E4%BD%9C%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB)も `go-i18n` v1.x 時代のものや、Go v1.15 以上で動かなかったり、`typo` で動作しないものが多いので自分のググラビリティとして。

:::note info
【追記】 2024/06/02 <font color=darkorange>**現在、`go-i18n` は v2.4.0 になっておりリポジトリも整理され、とても見やすくなりました**</font>。

また、最新の `main` ブランチでは、下位互換のため `v2` ディレクトリに設置されていたパッケージが、ルートに移動しました。スッキリしたものの、今後のリリースでインポートエラーが出るかもしれません。その時は、この記事も更新します。

とはいえ、この記事は依然と有効なので自分でも試していただいて、「`go-i18n` 良いじゃん」と思ったら、ぜひスターを付けに行ってあげてください。
:::

:::note info
🐒 &nbsp; 本記事で紹介している [go-i18n](https://github.com/nicksnyder/go-i18n) はサード・パーティー製のパッケージです。

Go（以下 golang）の純正モジュールには、テキスト関連の処理に特化した [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) というモジュールがあります。

その中の [golang.org/x/text/message](https://pkg.go.dev/golang.org/x/text/message) パッケージで、ローカライズ（地域化）機能を持たせられます。例えば `1000` という数値を、日本なら `1,000`、フランスなら `1 000` といった[地域にあわせた表記に変換する機能](https://qiita.com/KEINOS/items/d8c14f0951ec890ae3ff)です。

実は、`golang.org/x/text` のドキュメントの [Translation](https://pkg.go.dev/golang.org/x/text/message#hdr-Translation) セクションを読むと、この地域化機能と [catalog.Builder](https://pkg.go.dev/golang.org/x/text/message/catalog#Builder) 型の構造体を利用すれば独自の `catalog`（対応辞書）を作れたり、[plural.Selectf()](https://pkg.go.dev/golang.org/x/text/feature/plural#Selectf) 関数で複数形にも対応できそうなのです。

しかし、こちらのドキュメントもいまいち使い方がわからんのです。ʕ◔ϖ◔ʔ

とりあえず、**この記事は `go-i18n` に注力し、`golang.org/x/text` を使った国際化の方法がわかりしだい別記事にしたい**と思います。「いいね」が 10 超えたら重い腰をあげると思います。
<!-- REF: https://phrase.com/blog/posts/internationalization-i18n-go/ -->
:::

## TL; DR <sub><sup>（今北産業）</sup></sub>

1. `Bundle` が辞書を保持するもの。
2. `Localizer` が辞書を引くもの。
    - 入力（メッセージ ID や設定）から、対応するメッセージを辞書から引っ張ってくる関数が `Localize()` です。`MustLocalize()` のように `Must*` が付く関数は、エラー発生時にエラーを返さずパニクるタイプです。
3. `go-i18n` のバージョンによって `Bundle` や`Localizer` に渡す言語指定方法が異なる。
    - この記事は `go-i18n` v2.1 のサンプルです。
    - とりま、[動くサンプルをオンラインでみる](https://go.dev/play/p/gpwn8LdJ7ts) @ Go Playground

---

:::note info
**🐒 &nbsp; 言語指定の書式について**

現在の `go-i18n` では、言語の指定時に使われる <code>en-US</code> や <code>ja-JP</code> は <code>tag</code>（言語タグ）と呼ばれ、一般的に「<a href="https://ja.wikipedia.org/wiki/IETF%E8%A8%80%E8%AA%9E%E3%82%BF%E3%82%B0" target="_blank">IETF 言語タグ</a>」を指します。

IETF 言語タグとは、インターネットの標準化団体の 1 つである <a href="https://ja.wikipedia.org/wiki/Internet_Engineering_Task_Force" target="_blank">IETF</a> によって <strong><a href="https://tools.ietf.org/html/rfc5646" target="_blank">RFC 5646</a> と <a href="https://tools.ietf.org/html/rfc4647" target="_blank">RFC 4647</a> で制定された言語タグ</strong>です。

しかし、習慣的に <code>BCP47</code> といった言い方もされます。`BCP` とは、制定前の「その時点でベスト」とされる <a href="https://en.wikipedia.org/wiki/Best_current_practice" target="_blank">BCP</a>（<code>Best Current Practice</code>）のうち <a href="https://tools.ietf.org/html/bcp47" target="_blank">47 番目に「言語タグの指定」のベスト・プラクティスとされた</a>ためで、当時の習慣が残っているだけです。参照・参考にする場合は RFC の方を利用してください。

また、**IETF 言語タグは <code>"en-US"</code> や <code>"ja-JP"</code> のように <code>"&lt;language&gt;-&lt;REGION&gt;"</code> のようなハイフン構成になっている**ので、OS の <code>Locale</code> などのアンダースコア（アンダーバー）を使った <code>"ja_JP"</code> や <code>"ja_JP.UTF-8"</code> と記法が異なるので注意が必要です。 ←（俺
:::

---

## TS; DR  <sub><sup>（kwsk）</sup></sub>

### ... うん？国際化？

日本語でアプリを作ったものの、海外の人にも需要がでてきたので「国際化対応が必要」と言われると、嫌〜な臭いを感じると思います。

しかし「国際化対応」言っても、

1. 言語ごとに用語辞書を用意しておく。
1. OS の Locale などで、実行環境に合わせて使用する言語の辞書を決める。
1. 用語の識別子（メッセージ ID）を指定すると、指定した辞書の言語で該当用語が返ってくる。
1. それを Print する。

と言うだけの、単純なものです。<sub><sup>簡単とは言ってない</sup></sub>

つまり、イメージとしては連想配列に近いものです。「言語ごとに配列（`map`）を用意しておき、該当言語の配列のキーを指定すると値が取得できる」ようなもの、と考えると入りやすいと思います。

以下は `map`（Go の連想配列）で簡単に実装したものです。

```go:国際化対応の基本概念
en := map[string]string{
	"helloworld": "hello, world",
}

ja := map[string]string{
	"helloworld": "こんにちは, 世界",
}

fmt.Println(en["helloworld"])
fmt.Println(ja["helloworld"])

// Output:
// hello, world
// こんにちは, 世界
```
- [オンラインで動作をみる](https://go.dev/play/p/CpCK3KTeJ7d) @ Go Playground

### `Internationalization` と `Localization` の違い

アプリを多言語対応しようとすると、`Internationalization`（`i18n`）や `Localization`（`l10n`）といった用語が出てきます。

単純に日本語に訳すと `Internationalization` は「国際化」で、`Localization` は「地域化」です。アプリにおいては、どちらも「他の言語でも表示できる機能への対応」です。

何が違うのでしょうか。

「多言語表示対応」を「国際化対応」とした場合、基本的に同じことです。その場合、`Internationalization` は「国際化対応の準備」で、`Localization` は「国際化対応の適用」という意味合いが強くなります。

つまり「他の言語でも表示できる環境を準備（機能実装）する」ことを `Internationalization` と呼び、英語表示を日本語表示に対応させるなど「特定地域に最適化（言語実装）する」ことを `Localization` と呼びます。

`Internationalization` の方が、より汎用的な作業なので「グローバル対応」とも呼ばれます。「国際化」を「グローバル化」と呼ぶのも、「よりグローバル（汎用的）に対応できるようにする」を格好よく言っているだけなのです。

例えば、リファクタリングにおける `Internationalization` は「表示は従来通りだが、辞書さえ用意すれば表示言語をいつでも切り替えられるようにする」という意味になり、「特定言語の辞書作成と表示確認」が `Localization` ということになります。

IME のいらない ASCII コード圏（特に英語圏）のリポジトリで `Internationalization` の `issue` 騒ぎが発生することがあります。この場合、`UTF-8` 対応させることなどが `Internationalization` になり、日本語対応が `Localization` ということになります。

さて、国際化・地域化を進めるには、先の例のように連想配列を使って「俺様国際化対応」させても良いのですが、専用のライブラリを使うことで空いた時間を辞書作成に費やすことができます。

### Go の `Internationalization` 向けパッケージ

Linux には [GNU Gettext](https://ja.wikipedia.org/wiki/Gettext) という老舗の C 言語のライブラリがあります。PHP（特に Wordpress）ユーザーの場合、翻訳関数 "`_()`" を目にしたことがあると思いますが、アレの原点です。

Go 言語には、[国際化](https://ja.wikipedia.org/wiki/%E5%9B%BD%E9%9A%9B%E5%8C%96%E3%81%A8%E5%9C%B0%E5%9F%9F%E5%8C%96)対応（`internationalization`）するのに有名な `go-i18n` と言うパッケージがあります。

- `go-i18n` パッケージ
   - [github.com/nicksnyder/go-i18n](https://github.com/nicksnyder/go-i18n) @ GitHub

これをモジュールとして組み込んで、文書の出力の箇所を翻訳関数に置き換えれば、辞書を用意するだけで他言語に対応する準備が整います。

しかし、このパッケージ、[（v2.1.1 時代の）公式のサンプル](https://github.com/nicksnyder/go-i18n/blob/703c3bde3ea09b5f449581f4cb30f681e11351a3/v2/i18n/example_test.go)がわかりづらいのです。

そこで、実際に動作するシンプルなサンプルを見た方が早いと思うので、まずは以下のソースをご覧ください。

### ヘルプなどの固定メッセージを JSON 形式で定義して利用する

```golang:JSON辞書を使った例（メッセージIDで引く。シンプルでオススメ）
package main

import (
	"encoding/json"
	"fmt"

	"github.com/nicksnyder/go-i18n/v2/i18n"
	"golang.org/x/text/language"
)

func main() {
	// デフォルト言語を英語に設定
	bundle := i18n.NewBundle(language.English)

	// 辞書の拡張子 ".json" に対してパーサーエンジンを json.Unmarshal にセットする
	bundle.RegisterUnmarshalFunc("json", json.Unmarshal)

	// JSON の辞書を登録
	bundle.MustParseMessageFileBytes([]byte(`{"MSG001": "Hello World!"}`), "en.json")
	bundle.MustParseMessageFileBytes([]byte(`{"MSG001": "Hola Mundo!"}`), "es.json")
	bundle.MustParseMessageFileBytes([]byte(`{"MSG001": "ようこそ、世界！"}`), "ja.json")

	// 辞書から引きたい内容（MSG001 を引きたい）
	referTo := &i18n.LocalizeConfig{MessageID: "MSG001"}

	// 辞書を引く
	{
		// 英語の辞書を使う
		localizer := i18n.NewLocalizer(bundle, "en-US")
		fmt.Println(localizer.MustLocalize(referTo))
	}
	{
		// スペイン語の辞書を使う
		localizer := i18n.NewLocalizer(bundle, "es-ES")
		fmt.Println(localizer.MustLocalize(referTo))
	}
	{
		// 日本語の辞書を使う
		localizer := i18n.NewLocalizer(bundle, "ja-JP")
		fmt.Println(localizer.MustLocalize(referTo))
	}
	{
		// 未定義の辞書を使う（デフォルトの英語を使う）
		localizer := i18n.NewLocalizer(bundle, "ko-KR")
		fmt.Println(localizer.MustLocalize(referTo))
	}
}
// Output:
// Hello World!
// Hola Mundo!
// ようこそ、世界！
// Hello World!
```

- [オンラインで動作をみる](https://play.golang.org/p/mmHmHl0-3DO) @ Go Playground

### 複数形・単数形バリエーション

上記 JSON を使った例のように、通常は「タイトルやヘルプ・メッセージといった、単純な置き換えで十分」というケースが多いと思います。

しかし「●件のメッセージがあります」といった場合、言語によっては単数形・複数形を加味しないと不自然な言語もあります。

下記は、翻訳時（Localize 時）に `PluralCount` の値よって単数形・複数形を使い分けをする例です。

ぶっちゃけ、登録用語は 1 つだし、JSON でなく構造体を使っているので「`if` 文で分岐させた方が早えぇな」と感じるかもしれません。

しかし「メッセージ ID による単純な置き換え」に限界が来た時に、メッセージ（用語）にバリエーションを持たせることができることに注目してください。

```golang:基本（i18n.LocalizeConfigの設定情報で引く）
package main

import (
	"fmt"

	"github.com/nicksnyder/go-i18n/v2/i18n"
	"golang.org/x/text/language"
)

func main() {
	// 辞書セットを作成しデフォルトを英語に設定
	bundle := i18n.NewBundle(language.English)
	// 使う辞書の言語設定
	loc := i18n.NewLocalizer(bundle, language.English.String())

	// 辞書用語を設定（単数形・複数形対応）
	//   i18n.Message 型は Localize から呼び出された際の個数情報（PluralCount）
    //   によって単数（One:）、複数（Other:）でメッセージ内容が変わる。
    //   "One" と "Other" にある "{{.Name}}" と "{{.Count}}" は、
    //   Localize() から呼び出された際に渡される map のキー名。（下記「辞書翻訳の実行」参照）
	message := &i18n.Message{
        // メッセージ ID（この設定を呼び出すキー）
		ID: "Emails",
        // メモ
		Description: "The number of unread emails a user has",
        // 単数形用の構文定義（いわるゆテンプレート）
		One: "{{.Name}} has {{.Count}} email.",
        // 複数形用の構文定義（いわるゆテンプレート）
		Other: "{{.Name}} has {{.Count}} emails.",
	}

	// 個数を指定（単数・複数でメッセージ内容が変わるかの確認）
    emailCount := 2

	// 辞書翻訳の実行
    // MustLocalize と Localize は同じですが、MustLocalize はエラー時に
    // パニックになります。
	translation := loc.MustLocalize(&i18n.LocalizeConfig{
		// デフォルトで使う辞書用語を定義
		DefaultMessage: message,
		// 辞書の用語（のテンプレート）に流し込むデータ。
        // "Name", "Count" を持つ辞書登録（用語）すべてに "KEINOS" と個数
        // をテンプレートとしてマッピング（割り当てを）する。
		TemplateData: map[string]interface{}{
			"Name":  "KEINOS",
			"Count": emailCount,
		},
		// データの個数情報
		PluralCount: emailCount,
	})

	fmt.Println(translation)
}

// Output: KEINOS has 2 emails.
```

- [オンラインで動作をみる](https://go.dev/play/p/XL52v30Syzm) @ Go Playground（Go version: 1.16）


## 関連文献

- [【Golang】JSON などの外部ファイルをバイナリに埋め込むサンプル【Go 1.16+】](https://qiita.com/KEINOS/items/78b55ef720ec2f469a77) @ Qiita