LoginSignup
3

More than 1 year has passed since last update.

Goのhtml/templateでi18nやる方法

Last updated at Posted at 2020-12-20

これは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です。

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

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

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 (1つの場合)と accesses.other (その他の場合)を定義します。

テンプレート

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

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ならではの独自感があります。今回はそこまで複雑なことをやらないけど、ifrangeなど色々な機能が付いています。自動的にHTMLエスケープしてくれるのでPHPみたいにひたすらhtmlspecialchars()を呼ぶ必要はありません。template.HTMLtemplate.URLtemplate.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}}はその値をそのまま表示します。

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

読み込み編

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

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

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を読み込みます。デフォルトの言語は日本語にします。デフォルトの言語を指定すれば、他言語で未対応のメッセージは日本語で表示されます。

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 を読み込みます。

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を取り出してテンプレートで使った ttc の関数を定義します。

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),
    }
}

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

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.NewLocalizerAccept-Languageのフォーマットをパースしてくれます。

middleware

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を入れる・取る関数を定義します。

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が入っていれば、その言語を使ってくれます。これでリクエスト毎に国際化の関数を変えられます

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"を使い分けてくれます。やった!

英語版 日本語版

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

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

改善編

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

言語の指定

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

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を足します。

ja.toml
time_layout = "2006年1月2日 15時04分"
current_time = "ただいまの時間は{{.v0}}です。"
en.toml
time_layout = "Jan 2 2006 3:04PM"
current_time = "The current time is {{.v0}}."

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

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)と同じです。

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

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

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://github.com/guregu/i18n-example

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

余談

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3