LoginSignup
4
3

More than 3 years have 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:

4
3
0

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
  3. You can use dark theme
What you can do with signing up
4
3