Goのhtml/templateでi18nやる方法

これはGo 2 Advent Calendar 2020の20日目の記事です！

GoはJSON返すだけの言語だと思ってませんか？実はHTMLも返せます。SSRはもちろん、メール配信などでも役立てます。標準ライブラリと外部パッケージの[go-i18n](https://github.com/nicksnyder/go-i18n)を使って、リクエスト毎に適切な国際化する方法を紹介します。

GitHubでこの記事のコードを確認できます → https://github.com/guregu/i18n-example

### 今回使うパッケージ一覧
- [net/http](https://godoc.org/net/http)
- [html/template](https://godoc.org/html/template)
- [context](https://godoc.org/context)
- [github.com/nicksnyder/go-i18n](https://godoc.org/github.com/nicksnyder/go-i18n/v2/i18n)

## やりたいこと
国際化されたアクセスカウンターを作ります。
`html/template`の細かい使い方は書く時間がないですが、今回紹介するパターンを使うことで、もう少し自由にtemplateを扱えるようになれるかと思います

### サーバー起動時
- templateを読み込む
- i18n用のメッセージを読み込む
- httpサーバーを開始

### HTTPリクエスト時

- middlewareで適切な言語を検知し、その情報をcontextに突っ込む
- contextとtemplateで国際化されたHTMLを表示

# 構成編

分かりやすいため、フラットなパッケージ構成にします。最終的にこうなります。

```
i18n-example
├── assets
│   ├── templates
│   │   └── index.gohtml
│   └── text
│       ├── en.toml
│       └── ja.toml
├── context.go
├── go.mod
├── go.sum
├── i18n.go
├── index.go
├── main.go
└── templates.go
```

### i18n
`assets/text`というダイレクトリーに国際化のメッセージを置きます。今回TOMLを使うが、JSONなども使用可能です。ファイル名はIETF BCP 47 language tagにします。例えば、日本語は`ja`、英語は`en`です。

```toml:assets/text/ja.toml
greeting = "こんにちは、世界"
server_os = "サーバーのOSは{{.v0}}です。"
accesses = "あなたは{{.ct}}番目のお客様なのです☆"
```

日本語のメッセージです。go-i18nのメッセージは標準ライブラリの`text/template`なので、`html/template`と同様に変数を使えます。`v0`と`ct`は後で説明します。日本の文化に合わせてアクセスカウンターを可愛くしています。

```toml:assets/text/en.toml
greeting = "hello world"
server_os = "The server's OS is {{.v0}}."

[accesses]
one = "This page has been accessed {{.ct}} time."
other = "This page has been accessed {{.ct}} times."
```

英語のメッセージです。複数形を正しく表示するために`accesses.one` (１つの場合）と `accesses.other` (その他の場合）を定義します。

### テンプレート
`assets/templates`というダイレクトリーに `html/template` のファイルを置きます。ファイル名は自由です。今回は拡張子を`.gohtml`にします。

```handlebars:assets/templates/index.gohtml
<!doctype html>
<html>
	<head>
		<title>i18n test page</title>
	</head>
	<body>
		<h2>{{t "greeting"}}</h2> <!-- 関数を呼ぶ: t("greeting")の結果を表示する -->
		<p>{{t "server_os" $.OS}}</p> <!-- $.Hogeはハンドラーが渡した変数 -->
		<footer>{{tc "accesses" $.AccessCount}}</footer>
	</body>
</html>
```

GoのテンプレートはHandlebarsやJinjaに少し似てるがGoならではの独自感があります。今回はそこまで複雑なことをやらないけど、`if`や`range`など色々な機能が付いています。自動的にHTMLエスケープしてくれるのでPHPみたいにひたすら[`htmlspecialchars()`](https://www.php.net/manual/ja/function.htmlspecialchars.php)を呼ぶ必要はありません。[`template.HTML`](https://godoc.org/html/template#HTML)、[`template.URL`](https://godoc.org/html/template#URL)、[`template.CSS`](https://godoc.org/html/template#CSS)などの場所によってエスケープされない型もあるが、それ以外の型はエスケープされます。それで怖がらずに`string`型の変数を表示できます。[動的Javascript](https://godoc.org/html/template#JS)の生成も可能です！

`{{t "server_os" $.OS}}`は`t`という関数に`"server_os"`と`$.OS`の引数を渡して呼んだ結果を表示するという意味です。
`"server_os"`は文字列リテラルです。`$.OS`はハンドラーから渡されたデータを指します。精密にいうと、`$`はハンドラーからのデータで、`.OS`はその構造体の`OS`というフィールドを指しています。
Javascriptでいうと、`document.write(t("server_os", window.OS))`でしょうか。もちろん、サーバー側でレンダリングしますが。
ちなみに、かならず関数を呼ぶ必要はありません。例えば、`{{$.OS}}`はその値をそのまま表示します。

`t` と `tc` という関数は標準では入っていません。これから実装します。`t`はtranslate (翻訳）で、`tc`はtranslate count（複数形を対応するため数付き）という意味です。関数名はあくまで私の好みで、`translate` とかにしてもいい問題ないです。

# 読み込み編

テンプレートとi18nのデータを読み込みましょう。とりあえずグローバル変数を使います。ガチ仕事の場合は、構造体などに入れるといいでしょう。

mainはよくあるmain関数です。それぞれのデータを読み込んだらHTTPサーバーを立ち上げます。ハンドラー周りはこの後説明します。

```go:main.go
package main

import (
	"flag"
	"log"
	"net/http"
)

var bindFlag = flag.String("bind", ":8000", "address listen on")

func main() {
	if err := loadTranslations(); err != nil {
		log.Fatal(err)
	}

	if err := loadTemplates(); err != nil {
		log.Fatal(err)
	}

	http.Handle("/", personalize(http.HandlerFunc(index)))
	http.Handle("/favicon.ico", http.NotFoundHandler())

	log.Println("Starting server", *bindFlag)
	if err := http.ListenAndServe(*bindFlag, nil); err != nil {
		log.Fatal(err)
	}
}
```

### i18n
`assets/templates/text/*.toml`を読み込みます。デフォルトの言語は日本語にします。デフォルトの言語を指定すれば、他言語で未対応のメッセージは日本語で表示されます。

```go:i18n.go
package main

import (
	"os"
	"path/filepath"

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

var (
	defaultLang = language.Japanese
)

var translations *i18n.Bundle
var defaultLocalizer *i18n.Localizer

func loadTranslations() error {
	dir := filepath.Join("assets", "text")
	bundle := i18n.NewBundle(defaultLang)
	bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() || filepath.Ext(path) != ".toml" {
			return nil
		}
		_, err = bundle.LoadMessageFile(path)
		return err
	})
	if err != nil {
		return err
	}

	translations = bundle
	defaultLocalizer = i18n.NewLocalizer(bundle, defaultLang.String())
	return nil
}
```

### テンプレート
`assets/templates/*.gohtml` を読み込みます。

```go:templates.go
func loadTemplates() error {
	glob := filepath.Join("assets", "templates", "*.gohtml")

	t := template.New("root").Funcs(templateFuncs(context.Background()))
	var err error
	t, err = t.ParseGlob(glob)
	if err != nil {
		return err
	}

	templates = t
	return nil
}
```

`template.Funcs` を使ってテンプレートで使えるカスタムな関数を指定します。contextからlocalizerを取り出してテンプレートで使った `t` と `tc` の関数を定義します。

```go:templates.go
func templateFuncs(ctx context.Context) template.FuncMap {
	localizer, ok := localizerFrom(ctx)
	if !ok {
		localizer = defaultLocalizer
	}

	return template.FuncMap{
		"t":  translateFunc(localizer),
		"tc": translateCountFunc(localizer),
	}
}
```

`translateFunc`と`translateCountFunc`はlocalizerを使って国際化されたメッセージを返す変数（を作る変数）です。
`translateFunc`は引数を`{{.v0}}, {{.v1}}, {{.v2}}...`としてTOMLで書いたメッセージテンプレートを実行してその結果を返します。
`translateCountFunc`は第一引数を`{{.ct}}`、その他を `{{.v0}}, {{.v1}}...`とします。
エラー時は"[TL err: hoge]"を返すが、好みによってpanicするか戻り値を`(string, error)`に変えてもいいです。
個人的に、エラーで死ぬよりは描画した方が開発しやすいです。

```go:i18n.go
func translateFunc(localizer *i18n.Localizer) interface{} {
	return func(id string, args ...interface{}) string {
		var data map[string]interface{}
		if len(args) > 0 {
			data = make(map[string]interface{}, len(args))
			for n, iface := range args {
				data["v"+strconv.Itoa(n)] = iface
			}
		}
		str, _, err := localizer.LocalizeWithTag(&i18n.LocalizeConfig{
			MessageID:    id,
			TemplateData: data,
		})
		if str == "" && err != nil {
			return "[TL err: " + err.Error() + "]"
		}
		return str
	}
}

func translateCountFunc(localizer *i18n.Localizer) interface{} {
	return func(id string, ct int, args ...interface{}) string {
		data := make(map[string]interface{}, len(args)+1)
		if len(args) > 0 {
			for n, iface := range args {
				data["v"+strconv.Itoa(n)] = iface
			}
		}
		data["ct"] = ct
		str, _, err := localizer.LocalizeWithTag(&i18n.LocalizeConfig{
			MessageID:    id,
			TemplateData: data,
			PluralCount:  ct, // 複数形対応
		})
		if str == "" && err != nil {
			return "[TL err: " + err.Error() + "]"
		}
		return str
	}
}
```

# ハンドラー編
`personalize`というmiddlewareを作ります。ここでリクエストの`Accept-Language`ヘッダを見てcontextに適切な`*i18n.Localizer`を入れます。
`i18n.NewLocalizer`は`Accept-Language`のフォーマットをパースしてくれます。

### middleware

```go:context.go
package main

import (
	"context"
	"net/http"

	"github.com/nicksnyder/go-i18n/v2/i18n"
)

func personalize(next http.Handler) http.Handler {
	fn := func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		acceptLang := r.Header.Get("Accept-Language")
		localizer := i18n.NewLocalizer(translations, acceptLang)
		ctx = withLocalizer(ctx, localizer)
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	}
	return http.HandlerFunc(fn)
}
```

contextをtype-safeに使えるようにlocalizerを入れる・取る関数を定義します。

```go:context.go
type localizerKey struct{}

func withLocalizer(ctx context.Context, loc *i18n.Localizer) context.Context {
	return context.WithValue(ctx, localizerKey{}, loc)
}

func localizerFrom(ctx context.Context) (*i18n.Localizer, bool) {
	loc, ok := ctx.Value(localizerKey{}).(*i18n.Localizer)
	return loc, ok
}
```

### テンプレート

contextを見て`html/template.Template`を返す変数を作ります。
`t.Clone()`と`t.Funcs()`はテンプレートをコピーして`loadTemplates`で指定したデフォルトのテンプレート関数を上書きします。
`ctx`にlocalizerが入っていれば、その言語を使ってくれます。これで**リクエスト毎に国際化の関数を変えられます**。

```go:templates.go
func getTemplate(ctx context.Context, name string) *template.Template {
	t := templates.Lookup(name + ".gohtml")
	if t == nil {
		panic("no template: " + name)
	}

	t, err := t.Clone()
	if err != nil {
		panic(err)
	}
	t.Funcs(templateFuncs(ctx))
	return t
}
```

### HTTPハンドラー

やっと本題に辿り着いた！テンプレートをレンダリングします。
`data`はテンプレートの`$`になるデータです。
おまけに簡単なアクセスカウンターを実装します。

```index.go
package main

import (
	"net/http"
	"runtime"
	"sync/atomic"
)

var accessCount = new(int64)

func index(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	access := atomic.AddInt64(accessCount, 1)
	data := struct {
		OS          string
		AccessCount int
	}{
		OS:          runtime.GOOS,
		AccessCount: int(access),
	}

	if err := getTemplate(ctx, "index").Execute(w, data); err != nil {
		panic(err)
	}
}
```
# 完成！

完成です！ `go build && ./i18n-example` を実行すると、`http://localhost:8000` で国際化されたアクセスカウンターとサーバーのOSが表示されています。`Accept-Language`を見ているので、OSの言語で表示されます。英語版はちゃんと複数形を意識して"1 time"と"2 times"を使い分けてくれます。やった！

![英語版](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52098/c8e96a58-ff5f-2fe8-fa3b-6256422cf9d1.png)　![日本語版](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52098/def69fae-6788-ff2a-11ca-ce253a0cb507.png)

### メッセージが未対応だったら…

デフォルトの言語を日本語にしたので、英語版で未対応のメッセージがあったら、日本語で表示されます。
例えば`ja.toml`に`wabisabi = "わびさび"`を入れて、`{{t "wabisabi"}}`をやったら、英語でも「わびさび」になります。
`go-i18n`は一つのデフォルト言語しか設定出来ないようです。

# 改善編

他にも色々出来るのでおまけとしてちょっと改善してみます。

### 言語の指定

`Accept-Language`以外でも言語を指定出来るようにします。URLの`?lang=hoge`パラメータを優先します。

```diff:context.go
func personalize(next http.Handler) http.Handler {
        fn := func(w http.ResponseWriter, r *http.Request) {
                ctx := r.Context()
                acceptLang := r.Header.Get("Accept-Language")
+               if q := r.URL.Query().Get("lang"); q != "" {
+                       acceptLang = q
+               }
                localizer := i18n.NewLocalizer(translations, acceptLang)
```
http://localhost:8000/?lang=en は英語になります。
同じようにCookieやDBから言語設定を取るとユーザーが喜ぶでしょう。

### 日付

言語に応じて日付を表示してみましょう。

i18nのファイルに日付のlayoutを足します。

```toml:ja.toml
time_layout = "2006年1月2日 15時04分"
current_time = "ただいまの時間は{{.v0}}です。"
```

```toml:en.toml
time_layout = "Jan 2 2006 3:04PM"
current_time = "The current time is {{.v0}}."
```

`time`というテンプレート関数を実装します。

```diff:templates.go
templateFuncs(ctx context.Context) template.FuncMap {
        if !ok {
                localizer = defaultLocalizer
        }
+       // TODO: check error
+       timeLayout, _, _ := localizer.LocalizeWithTag(&i18n.LocalizeConfig{MessageID: "time_layout"})

        return template.FuncMap{
                "t":  translateFunc(localizer),
                "tc": translateCountFunc(localizer),
+               "time": func(t time.Time) string {
+                       return t.Format(timeLayout)
+               },
        }
 }
```

HTMLのテンプレートでtimeを呼びます. `($.Now | time)` は `(time $.Now) `と同じです。

```handlebars:index.gohtml
		<p>{{t "current_time" ($.Now | time)}}</p>
```

`Now`をデータに追加します。

```go:index.go
	data := struct {
		Now         time.Time
		OS          string
		AccessCount int
	}{
		Now:         time.Now(),
		OS:          runtime.GOOS,
		AccessCount: int(access),
	}
```

「The current time is Dec 20 2020 1:15PM」または「ただいまの時間は2020年12月20日 13時15分です」が表示されます！
この風に表示するメッセージだけじゃなくて、国際化されたメタデータも管理できます。

# おわりに
思ったより長くなってしまいました :bow: 
`html/template`についてもっと書きたいですが、これまでにしておきます。
もし興味があったら、また書きますのでTwitter ([@guregu](https://twitter.com/guregu)) で連絡してください！
質問も気軽にどうぞ～

ここで今回のコードを載せました↓
https://github.com/guregu/i18n-example

ちなみに標準のnet/httpだけじゃなくて、go-chiなどのフレームワークに同じコードが使えます。

### 余談

個人開発で同じパターンを使って楽しく開発してます。
実はGoのSSRを使えば簡単にめっちゃ高いLighthouseのスコアが取れます。数メガバイトのJSをダウンロード・パース・実行するより、SSRを使った方が圧倒的に速いです。
「とりあえずSPAにするわｗ」という思考停止をやめて、古き良きSSRをやろうぜ :wink: