Gin 搭配 I18N 功能

什么是 Internationalization 和 localization

I18N is used for making computer software of different languages and is widely used for number formating, Currency Formating massage formatting. with the help of I18N we can develop user friendly application.

在阅读正文之前建议看这些文章:

两种场景

在实际开发中,我们可以简单的把遇到的问题归纳为如下两个场景:

  1. 动态信息,如新闻、产品资料的多语言版本。
  2. 静态信息,如错误提示、页面固定元素的翻译。

三个步骤

不论是动态还是静态信息,都要做国际化都可以拆分成三步:

  1. 如何确定用户的 locale
  2. 如何把字符串按照不同的 locale 各保存一份
  3. 如何根据用户的 locale 来渲染不同语言版本的字符串

实现过程

确定用户的 locale

先看一些准备知识:

现在我们知道了,Go 的语言标签遵循 IETF 语言标签 BCP 47 标准。虽然 BCP 47 对语言标签描述的很丰富,但我目前只关心 language abbreviation 和 country specifier,也就是 en-US 这种。

locale 的来源方式

  1. 根据子域名来判断来源,类似 en.domain.com
  2. 根据用户的 IP 地址来判断
  3. 根据用户在数据库或 cookies 中保存的个人信息来判断
  4. 根据用户的客户端浏览器设置
  5. 根据用户请求的中携带的参数

4 和 5 其实算一回事。我这里就介绍一下 4,当用户的请求 header 中携带 Accept-Language 参数,我们可以来解析它。

func getAcceptLanguage(acceptLanguate string) {
	var serverLangs = []language.Tag{
		language.SimplifiedChinese, // zh-Hans fallback
		language.AmericanEnglish,   // en-US
		language.Korean,            // de
	}

  // 也可以不定义 serverLangs 用下面一行选择支持所有语种。
	// var matcher = language.NewMatcher(message.DefaultCatalog.Languages())
	var matcher = language.NewMatcher(serverLangs)
	t, _, _ := language.ParseAcceptLanguage(acceptLanguate)
	tag, index, confidence := matcher.Match(t...)

	fmt.Printf("best match: %s (%s) index=%d confidence=%v\n",
		display.English.Tags().Name(tag),
		display.Self.Name(tag),
		index, confidence)

	str := fmt.Sprintf("tag is %s", tag)
	fmt.Println(str)
	fmt.Printf("best match: %s\n", display.Self.Name(tag))
}

保存不同语言版本的字符串

动态信息

在数据库对应表中添加 locale 字段,默认值为 zh-Hans。根据 request 的 header 或 paramters 的 locale 值从数据库中获取不同的语言版本数据集。若 locale 参数或 Accept-Language header 为空的时候,能直接用默认值去捞数据。

var supported = []language.Tag{
    language.AmericanEnglish,    // en-US: first language is fallback
    language.German,             // de
    language.Dutch,              // nl
    language.Portuguese          // pt (defaults to Brazilian)
    language.EuropeanPortuguese, // pt-pT
    language.Romanian            // ro
    language.Serbian,            // sr (defaults to Cyrillic script)
    language.SerbianLatin,       // sr-Latn
    language.SimplifiedChinese,  // zh-Hans
    language.TraditionalChinese, // zh-Hant
}
var matcher = language.NewMatcher(supported)

静态信息

其实需要翻译的东西还是挺多的:文本内容、时间和日期、货币单位、图片、特定文件和 view 之类的。

总得来说技巧就是两点:

  • 做一个映射给不同的语言文字翻译好不同的字符串
  • 写个帮助方法根据入参,从映射中拿到对应的翻译
package main

import "fmt"

var locales map[string]map[string]string

func main() {
    locales = make(map[string]map[string]string, 2)
    en := make(map[string]string, 10)
    en["pea"] = "pea"
    en["bean"] = "bean"
    locales["en"] = en
    cn := make(map[string]string, 10)
    cn["pea"] = "豌豆"
    cn["bean"] = "毛豆"
    locales["zh-CN"] = cn
    lang := "zh-CN"
    fmt.Println(msg(lang, "pea"))
    fmt.Println(msg(lang, "bean"))
}

func msg(locale, key string) string {
    if v, ok := locales[locale]; ok {
        if v2, ok := v[key]; ok {
            return v2
        }
    }
    return ""
}

利用 fmt.Printf 接受动态变量。

en["how old"] = "I am %d years old"
cn["how old"] = "我今年%d岁了"

fmt.Printf(msg(lang, "how old"), 30)

时区和货币单位

不介绍了,直接看 Localized Resources

Gin middleware

package locale

import (
	"fmt"

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

var serverLangs = []language.Tag{
	language.SimplifiedChinese, // zh-Hans fallback
	language.AmericanEnglish,   // en-US
	language.Korean,            // ko
}

func getAcceptLanguage(acceptLanguate string) (lang string) {
	var matcher = language.NewMatcher(serverLangs)
	t, _, _ := language.ParseAcceptLanguage(acceptLanguate)
	_, idx, _ := matcher.Match(t...)

	return serverLangs[idx].String()
}

func createI18nBundle() *i18n.Bundle {
	bundle := i18n.NewBundle(language.SimplifiedChinese)
	bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
	for _, l := range serverLangs {
		messageFile := fmt.Sprintf("pkg/locales/active.%s.toml", l.String())
		bundle.MustLoadMessageFile(messageFile)
	}

	return bundle
}
func Localizer(c *gin.Context) *i18n.Localizer {
	val, ok := c.Get("localizer")

	if !ok {
		return &i18n.Localizer{}
	}

	return val.(*i18n.Localizer)
}

func I18nMiddleware() gin.HandlerFunc {
	// NOTE: Create a go-i18n Bundle to use for the lifetime of your application.
	bundle := createI18nBundle()

	return func(c *gin.Context) {
		locale := c.Query("locale")
		if locale != "" {
			c.Request.Header.Set("Accept-Language", locale)
		}
		lang := getAcceptLanguage(c.GetHeader("Accept-Language"))

		// NOTE: On June 2012, the deprecation of recommendation to use the "X-" prefix has become official as RFC 6648.
		// https://stackoverflow.com/questions/3561381/custom-http-headers-naming-conventions
		// c.Request.Header.Set("I18n-Language", lang)
		c.Set("i18n", lang)

		// NOTE: Create a go-i18n Localizer to use for a set of language preferences.
		localizer := i18n.NewLocalizer(bundle, lang, c.GetHeader("Accept-Language"))
		c.Set("localizer", localizer)

		c.Next()
	}
}

废弃

language + region 的编码组合,因为即便是使用相同的文字,不同的地区语法习惯仍然会不一致,有些场景要求我们针对不同的地区提供不同的翻译版本,以便软件用户获得更好的体验。更多阐述可以参考 Rails Internationalization (I18n) API 中的解释。

locale 的编码有两部分组成:

  1. The language tag which is generally defined by ISO 639-1 alpha-2
  2. The region tag which is generally defined by ISO 3166-1 alpha-2

这里有相关的讨论 Where can I find a list of language + region codes?。为了快速查询对应编码,也可访问 Online Browsing Platform

相关链接

还参考了如下代码:

  • https://gist.github.com/hnakamur/92d283d5700507cc2a0df7bb1401478a
  • https://siongui.github.io/2015/02/22/go-parse-accept-language/

如果觉得我的文章对您有用,请在支付宝公益平台找个项目捐点钱。 @Victor Aug 26, 2019

奉献爱心