これはGo 2 Advent Calendar 2020の20日目の記事です!
GoはJSON返すだけの言語だと思ってませんか?実はHTMLも返せます。SSRはもちろん、メール配信などでも役立てます。標準ライブラリと外部パッケージのgo-i18nを使って、リクエスト毎に適切な国際化する方法を紹介します。
GitHubでこの記事のコードを確認できます → https://github.com/guregu/i18n-example
今回使うパッケージ一覧
やりたいこと
国際化されたアクセスカウンターを作ります。
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
です。
greeting = "こんにちは、世界"
server_os = "サーバーのOSは{{.v0}}です。"
accesses = "あなたは{{.ct}}番目のお客様なのです☆"
日本語のメッセージです。go-i18nのメッセージは標準ライブラリのtext/template
なので、html/template
と同様に変数を使えます。v0
とct
は後で説明します。日本の文化に合わせてアクセスカウンターを可愛くしています。
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
(1つの場合)と accesses.other
(その他の場合)を定義します。
テンプレート
assets/templates
というダイレクトリーに html/template
のファイルを置きます。ファイル名は自由です。今回は拡張子を.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()
を呼ぶ必要はありません。template.HTML
、template.URL
、template.CSS
などの場所によってエスケープされない型もあるが、それ以外の型はエスケープされます。それで怖がらずにstring
型の変数を表示できます。動的Javascriptの生成も可能です!
{{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サーバーを立ち上げます。ハンドラー周りはこの後説明します。
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
を読み込みます。デフォルトの言語は日本語にします。デフォルトの言語を指定すれば、他言語で未対応のメッセージは日本語で表示されます。
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
を読み込みます。
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
の関数を定義します。
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)
に変えてもいいです。
個人的に、エラーで死ぬよりは描画した方が開発しやすいです。
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
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を入れる・取る関数を定義します。
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が入っていれば、その言語を使ってくれます。これでリクエスト毎に国際化の関数を変えられます。
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
はテンプレートの$
になるデータです。
おまけに簡単なアクセスカウンターを実装します。
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"を使い分けてくれます。やった!
メッセージが未対応だったら…
デフォルトの言語を日本語にしたので、英語版で未対応のメッセージがあったら、日本語で表示されます。
例えばja.toml
にwabisabi = "わびさび"
を入れて、{{t "wabisabi"}}
をやったら、英語でも「わびさび」になります。
go-i18n
は一つのデフォルト言語しか設定出来ないようです。
改善編
他にも色々出来るのでおまけとしてちょっと改善してみます。
言語の指定
Accept-Language
以外でも言語を指定出来るようにします。URLの?lang=hoge
パラメータを優先します。
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を足します。
time_layout = "2006年1月2日 15時04分"
current_time = "ただいまの時間は{{.v0}}です。"
time_layout = "Jan 2 2006 3:04PM"
current_time = "The current time is {{.v0}}."
time
というテンプレート関数を実装します。
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)
と同じです。
<p>{{t "current_time" ($.Now | time)}}</p>
Now
をデータに追加します。
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分です」が表示されます!
この風に表示するメッセージだけじゃなくて、国際化されたメタデータも管理できます。
おわりに
思ったより長くなってしまいました
html/template
についてもっと書きたいですが、これまでにしておきます。
もし興味があったら、また書きますのでTwitter (@guregu) で連絡してください!
質問も気軽にどうぞ~
ここで今回のコードを載せました↓
https://github.com/guregu/i18n-example
ちなみに標準のnet/httpだけじゃなくて、go-chiなどのフレームワークに同じコードが使えます。
余談
個人開発で同じパターンを使って楽しく開発してます。
実はGoのSSRを使えば簡単にめっちゃ高いLighthouseのスコアが取れます。数メガバイトのJSをダウンロード・パース・実行するより、SSRを使った方が圧倒的に速いです。
「とりあえずSPAにするわw」という思考停止をやめて、古き良きSSRをやろうぜ