多语言翻译

This commit is contained in:
yun
2026-09-20 22:20:16 +08:00
parent 4edb2d244e
commit 287fa9ed36
20 changed files with 1616 additions and 320 deletions
+9
View File
@@ -0,0 +1,9 @@
# 统一使用 LF, 避免 gofmt 与 CRLF 互相拉锯
* text=auto eol=lf
# 二进制文件不做换行转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.zip binary
+37
View File
@@ -0,0 +1,37 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ["1.19.x", "stable"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "以下文件未格式化:"
echo "$unformatted"
exit 1
fi
- name: vet
run: go vet ./...
- name: test
run: go test -race -count=1 -coverprofile=coverage.out ./...
- name: build example
run: go build ./example
+9
View File
@@ -0,0 +1,9 @@
# 测试与覆盖率产物
coverage.out
cover.out
*.test
# 编辑器/系统文件
.idea/
.vscode/
.DS_Store
+124 -21
View File
@@ -1,35 +1,138 @@
package langx
import "context"
import (
"context"
"sort"
"strconv"
"strings"
)
const ctxLangKey string = "ctxLang"
// ctxKey 使用私有类型做 context 的键, 避免与调用方使用的字符串键冲突
type ctxKey struct{ name string }
var langCtxKey = ctxKey{"lang"}
// acceptLanguageKey 是读取请求语言时使用的键。
// 这里故意保留字符串 "Accept-Language", 以兼容把 HTTP 头直接放进 context 的既有用法。
const acceptLanguageKey = "Accept-Language"
// SetCtxLang 把语言写入 context, 其优先级高于 Accept-Language 请求头。
// ctx 为 nil 时的行为与 context.WithValue 一致(panic)。
func SetCtxLang(ctx context.Context, lang string) context.Context {
return context.WithValue(ctx, ctxLangKey, lang)
return context.WithValue(ctx, langCtxKey, lang)
}
// GetCtxLang 按 显式设置 > Accept-Language 协商 > 默认语言 的顺序返回语言。
//
// 该函数不会 panic: ctx 为 nil、context 中的值类型不是 string/[]string 都会安全回退。
func GetCtxLang(ctx context.Context) string {
// 指定的
lang := ctx.Value(ctxLangKey)
l := ""
if lang != nil {
l = lang.(string)
if ctx == nil {
return GetDefaultLang()
}
// HTTP头部的
if l == "" {
lang = ctx.Value("Accept-Language")
if lang != nil {
l = lang.(string)
if v, ok := ctx.Value(langCtxKey).(string); ok {
if lang := strings.TrimSpace(v); lang != "" {
return lang
}
}
// 默认的
if l == "" {
l = GetDefaultLang()
// 兼容直接塞 HTTP 头的用法
if header := headerFromCtx(ctx); header != "" {
if lang := NegotiateLang(header); lang != "" {
return lang
}
}
return GetDefaultLang()
}
return l
// headerFromCtx 读取 context 中的 Accept-Language, 类型不匹配时返回空串
func headerFromCtx(ctx context.Context) string {
switch v := ctx.Value(acceptLanguageKey).(type) {
case string:
return strings.TrimSpace(v)
case []string:
if len(v) > 0 {
return strings.TrimSpace(v[0])
}
}
return ""
}
// ParseAcceptLanguage 解析 Accept-Language 请求头, 按 q 值从高到低返回候选语言(已归一化为小写)。
//
// 无 q 值的项按 1.0 处理; q=0 表示不接受, 会被丢弃; 通配符 "*" 原样保留, 由调用方决定如何处理。
func ParseAcceptLanguage(header string) []string {
type candidate struct {
lang string
q float64
}
candidates := make([]candidate, 0, 4)
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
lang, q := part, 1.0
if i := strings.IndexByte(part, ';'); i >= 0 {
lang = strings.TrimSpace(part[:i])
for _, param := range strings.Split(part[i+1:], ";") {
param = strings.TrimSpace(param)
if len(param) > 2 && (param[0] == 'q' || param[0] == 'Q') && param[1] == '=' {
if v, err := strconv.ParseFloat(strings.TrimSpace(param[2:]), 64); err == nil {
q = v
}
}
}
}
lang = normalizeLang(lang)
if lang == "" {
continue
}
if q <= 0 { // q=0 表示明确不接受该语言
continue
}
candidates = append(candidates, candidate{lang: lang, q: q})
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].q > candidates[j].q })
langs := make([]string, 0, len(candidates))
for _, c := range candidates {
langs = append(langs, c.lang)
}
return langs
}
// NegotiateLang 在已注册的语言中挑出与 Accept-Language 最匹配的一个;
// 匹配时先精确匹配, 再逐级去掉区域后缀(zh-CN -> zh); 没有匹配时返回空串。
func NegotiateLang(header string) string {
s := load()
if len(s.trans) == 0 {
return ""
}
for _, lang := range ParseAcceptLanguage(header) {
if lang == "*" {
return ""
}
if hit, ok := matchLang(s, lang); ok {
return hit
}
}
return ""
}
// matchLang 返回已注册语言中匹配 lang 的那一个:
// 先精确匹配, 再逐级去掉区域后缀, 最后按语言前缀匹配(请求 en-US 可以命中已注册的 en-GB)。
func matchLang(s *state, lang string) (string, bool) {
l := normalizeLang(lang)
for l != "" {
if _, ok := s.trans[l]; ok {
return l, true
}
if best := bestPrefixLang(s.trans, l); best != "" {
return best, true
}
i := strings.LastIndexByte(l, '-')
if i <= 0 {
break
}
l = l[:i]
}
return "", false
}
+117
View File
@@ -0,0 +1,117 @@
package langx_test
import (
"context"
"testing"
"github.com/yuninks/langx"
)
func TestGetCtxLang(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{"k": "中文"})
langx.RegisterTrans("en", map[string]string{"k": "English"})
mustInit(t, langx.SetDefaultLanguage("zh"))
// Accept-Language 会与已注册语言协商, 而不是原样返回
ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9")
if got := langx.GetCtxLang(ctx); got != "en" {
t.Fatalf("GetCtxLang = %q, 期望 en", got)
}
if got := langx.GetMsgCtx(ctx, "k"); got != "English" {
t.Fatalf("GetMsgCtx = %q", got)
}
// []string 形式(http.Header 风格)
ctx = context.WithValue(context.Background(), "Accept-Language", []string{"en-GB"})
if got := langx.GetMsgCtx(ctx, "k"); got != "English" {
t.Fatalf("[]string 形式 = %q", got)
}
// 空 []string
ctx = context.WithValue(context.Background(), "Accept-Language", []string{})
if got := langx.GetCtxLang(ctx); got != "zh" {
t.Fatalf("空 []string = %q", got)
}
// 无法协商时回落默认语言
ctx = context.WithValue(context.Background(), "Accept-Language", "fr-FR,de;q=0.8")
if got := langx.GetCtxLang(ctx); got != "zh" {
t.Fatalf("无法协商 = %q", got)
}
// 通配符
ctx = context.WithValue(context.Background(), "Accept-Language", "*")
if got := langx.GetCtxLang(ctx); got != "zh" {
t.Fatalf("通配符 = %q", got)
}
// 显式设置的语言即使没注册也照用
if got := langx.GetCtxLang(langx.SetCtxLang(context.Background(), "fr")); got != "fr" {
t.Fatalf("显式未注册语言 = %q", got)
}
// 空字符串的显式设置会被忽略
if got := langx.GetCtxLang(langx.SetCtxLang(context.Background(), " ")); got != "zh" {
t.Fatalf("空显式语言 = %q", got)
}
}
// 旧实现在这里会 panic, 现在必须安全回退
func TestGetCtxLangNeverPanics(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{"k": "中文"})
mustInit(t, langx.SetDefaultLanguage("zh"))
if got := langx.GetCtxLang(nil); got != "zh" {
t.Fatalf("nil ctx = %q", got)
}
if got := langx.GetMsgCtx(nil, "k"); got != "中文" {
t.Fatalf("nil ctx GetMsgCtx = %q", got)
}
for _, value := range []interface{}{123, []int{1}, struct{}{}, true} {
ctx := context.WithValue(context.Background(), "Accept-Language", value)
if got := langx.GetCtxLang(ctx); got != "zh" {
t.Fatalf("非法类型 %T = %q", value, got)
}
}
}
func TestParseAcceptLanguage(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"zh-CN,zh;q=0.9,en;q=0.8", []string{"zh-cn", "zh", "en"}},
{"en;q=0.5,zh;q=1.0", []string{"zh", "en"}},
{"en", []string{"en"}},
{" EN-US , zh ", []string{"en-us", "zh"}},
{"*", []string{"*"}},
{"de;q=0", nil},
{"", nil},
{" , ,, ", nil},
{"garbage;;q=x,en", []string{"garbage", "en"}},
{"zh;q=1;charset=utf-8,en;q=0.7", []string{"zh", "en"}},
}
for _, c := range cases {
got := langx.ParseAcceptLanguage(c.in)
assertStrings(t, "ParseAcceptLanguage("+c.in+")", got, c.want)
}
}
func TestNegotiateLang(t *testing.T) {
reset(t)
// 未注册任何语言时无法协商
if got := langx.NegotiateLang("en"); got != "" {
t.Fatalf("无语言时 = %q", got)
}
langx.RegisterTrans("zh", map[string]string{"k": "中文"})
langx.RegisterTrans("en-GB", map[string]string{"k": "English"})
if got := langx.NegotiateLang("en-GB,en;q=0.9"); got != "en-gb" {
t.Fatalf("精确匹配 = %q", got)
}
if got := langx.NegotiateLang("en-US,en;q=0.9"); got != "en-gb" {
t.Fatalf("区域回退 = %q", got)
}
if got := langx.NegotiateLang("fr,zh;q=0.3"); got != "zh" {
t.Fatalf("跳过不支持的语言 = %q", got)
}
if got := langx.NegotiateLang("fr"); got != "" {
t.Fatalf("无匹配 = %q", got)
}
}
+1
View File
@@ -0,0 +1 @@
{"login_success":200,"error":400,"welcome":200}
+1
View File
@@ -0,0 +1 @@
{"login_success":"login success","error":"operation failed","welcome":"Hello #name#, welcome back"}
+1
View File
@@ -0,0 +1 @@
{"login_success":"登录成功","error":"操作失败","welcome":"你好 #name#, 欢迎回来"}
+58
View File
@@ -0,0 +1,58 @@
// 本示例演示 langx 的典型用法: 用 embed 装载资源、按语言取值、从 context 协商语言。
//
// 运行: go run ./example
package main
import (
"context"
"embed"
"fmt"
"github.com/yuninks/langx"
)
//go:embed langs
var langFS embed.FS
func main() {
// 1. 配置(可选): 默认语言、未知 key 的响应码、漏翻回调
if err := langx.InitLangx(
langx.SetDefaultLanguage("zh"),
langx.SetDefaultCode(500), // code 会被当作 HTTP 状态码时, 建议让缺失的 key 以失败收场
langx.SetMissingHandler(func(lang, key string) {
fmt.Printf("[warn] 缺少译文: lang=%s key=%s\n", lang, key)
}),
); err != nil {
panic(err)
}
// 2. 装载资源, 四选一:
// langx.RegisterEmbed(langFS) // embed.FS(推荐, 单文件部署)
// langx.RegisterDir("langs") // 磁盘目录
// langx.RegisterFS(os.DirFS("langs")) // 任意 fs.FS
// langx.RegisterTrans("zh", map[string]string{"hello": "你好"}) // 直接给 map
if err := langx.RegisterEmbed(langFS); err != nil {
panic(err)
}
// 3. 按语言直接取值
code, msg := langx.GetTrans("zh", "login_success")
fmt.Printf("zh login_success => code=%d msg=%s\n", code, msg)
code, msg = langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"})
fmt.Printf("en welcome => code=%d msg=%s\n", code, msg)
// 4. 从请求 context 取语言: 显式设置 > Accept-Language 协商 > 默认语言
// 实际项目中可把 http.Request.Header.Get("Accept-Language") 放进 context
ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9")
fmt.Printf("请求头 en-US => 协商语言=%s, welcome=%s\n",
langx.GetCtxLang(ctx), langx.GetFormatCtx(ctx, "welcome", map[string]string{"name": "Tom"}))
ctx = langx.SetCtxLang(context.Background(), "zh")
fmt.Printf("显式指定 zh => welcome=%s\n",
langx.GetFormatCtx(ctx, "welcome", map[string]string{"name": "张三"}))
// 5. 其它
fmt.Println("已注册语言:", langx.Langs())
fmt.Println("未知 key 的 code:", langx.GetCode("not_exists"))
}
+278 -237
View File
@@ -1,287 +1,328 @@
// Package langx 提供轻量的多语言响应能力: 注册响应码与多语言译文, 按语言(含区域回退)取值,
// 并支持占位符替换。
//
// 典型用法:
//
// _ = langx.InitLangx(langx.SetDefaultLanguage("zh"), langx.SetDefaultCode(500))
// _ = langx.RegisterDir("langs") // 也可以是 RegisterEmbed / RegisterFS / RegisterTrans
// code, msg := langx.GetTransFormatCtx(ctx, "login_success", map[string]string{"name": "张三"})
//
// 并发说明: 注册与读取可以安全地并发进行。读取路径无锁, 并且总是看到某个完整快照,
// 不会出现读到一半被写坏的情况; 所有写入都串行执行并原子发布新快照。
// 注册时会对传入的 map 做深拷贝, 注册之后调用方修改自己的 map 不会影响库内数据。
package langx
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
)
type langx struct {
ops *options
codeMap map[string]int
transMap map[string]map[string]string
mut sync.RWMutex
// state 是一份不可变快照: 一旦被 current 发布, 其中的 map 就不会再被修改。
// 读路径直接使用它, 因此无需加锁; 写路径总是构造新快照后再发布。
type state struct {
ops options
code map[string]int
trans map[string]map[string]string // key 为归一化后的语言名
}
var l *langx = &langx{
var (
current atomic.Pointer[state]
writeMu sync.Mutex
)
func newState() *state {
return &state{
ops: defaultOptions(),
codeMap: make(map[string]int),
transMap: make(map[string]map[string]string),
mut: sync.RWMutex{},
code: map[string]int{},
trans: map[string]map[string]string{},
}
}
// 这是语言的Code
func init() {
current.Store(newState())
}
// load 返回当前快照, 一次调用内应复用同一个快照以保证数据一致
func load() *state { return current.Load() }
// update 串行执行写操作并发布新快照
func update(fn func(old *state) *state) {
writeMu.Lock()
defer writeMu.Unlock()
current.Store(fn(current.Load()))
}
// updateErr 与 update 相同, 但 fn 返回错误时不发布任何变更
func updateErr(fn func(old *state) (*state, error)) error {
writeMu.Lock()
defer writeMu.Unlock()
next, err := fn(current.Load())
if err != nil {
return err
}
current.Store(next)
return nil
}
// ---- 快照构造: 只创建新 map, 绝不修改已经发布的 map ----
func setOptions(s *state, ops options) *state {
return &state{ops: ops, code: s.code, trans: s.trans}
}
func setCodes(s *state, m map[string]int, merge bool) *state {
next := &state{ops: s.ops, trans: s.trans}
if !merge {
next.code = make(map[string]int, len(m))
for k, v := range m {
next.code[k] = v
}
return next
}
next.code = make(map[string]int, len(s.code)+len(m))
for k, v := range s.code {
next.code[k] = v
}
for k, v := range m {
next.code[k] = v
}
return next
}
func setTrans(s *state, lang string, m map[string]string, merge bool) *state {
lang = normalizeLang(lang)
next := &state{ops: s.ops, code: s.code}
next.trans = make(map[string]map[string]string, len(s.trans)+1)
for k, v := range s.trans {
next.trans[k] = v // 内层 map 可以共享, 但绝不会被就地修改
}
src := s.trans[lang]
if !merge {
src = nil
}
inner := make(map[string]string, len(src)+len(m))
for k, v := range src {
inner[k] = v
}
for k, v := range m {
inner[k] = v
}
next.trans[lang] = inner
return next
}
// ---- 注册 ----
// RegisterCode 整体替换响应码表, 传 nil 表示清空。
func RegisterCode(datas map[string]int) {
l.mut.Lock()
defer l.mut.Unlock()
if datas == nil {
datas = make(map[string]int)
}
l.codeMap = datas
update(func(old *state) *state { return setCodes(old, datas, false) })
}
// 追加覆盖Code
// AppendCode 追加响应码, 已存在的 key 会被覆盖。
func AppendCode(datas map[string]int) {
l.mut.Lock()
defer l.mut.Unlock()
if datas == nil {
datas = make(map[string]int)
}
if l.codeMap == nil {
l.codeMap = map[string]int{}
update(func(old *state) *state { return setCodes(old, datas, true) })
}
for k, v := range datas {
l.codeMap[k] = v
}
}
// 注册语言翻译
// RegisterTrans 整体替换某个语言的译文, 传 nil 表示清空该语言。
func RegisterTrans(langName string, trans map[string]string) {
l.mut.Lock()
defer l.mut.Unlock()
l.transMap[langName] = trans
update(func(old *state) *state { return setTrans(old, langName, trans, false) })
}
// 追加覆盖翻译
// AppendTrans 追加某个语言的译文, 已存在的 key 会被覆盖。
func AppendTrans(langName string, trans map[string]string) {
l.mut.Lock()
defer l.mut.Unlock()
for k, v := range trans {
if l.transMap[langName] == nil {
l.transMap[langName] = map[string]string{}
}
l.transMap[langName][k] = v
}
update(func(old *state) *state { return setTrans(old, langName, trans, true) })
}
// 直接读取文件夹获取配置
// 要求:
// 1.json格式文件
// 2.code.json为自定义响应码 格式map[string]int{}
// 3.其他的json文件为对应语音 格式map[string]string{}
// 4.如果json解析错误将会panic
func RegisterDir(dir string) error {
// 遍历dir获取.json的文件
err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
if info.IsDir() {
return nil
}
if strings.HasSuffix(info.Name(), ".json") {
// 读取文件
fileName := strings.Replace(info.Name(), ".json", "", 1)
by, err := os.ReadFile(path)
if err != nil {
return err
}
if fileName == "code" {
data := map[string]int{}
err = json.Unmarshal(by, &data)
if err != nil {
return err
}
RegisterCode(data)
} else {
data := map[string]string{}
err = json.Unmarshal(by, &data)
if err != nil {
return err
}
RegisterTrans(fileName, data)
}
}
return nil
})
// code文件为状态码,其他为对应的语言文件,文件名为语言名
return err
}
func RegisterEmbed(asset embed.FS) error {
paths, err := readEmbedAllPath(asset, "")
if err != nil {
return err
}
for _, ff := range paths {
// 获取文件的后缀
fileName := strings.Replace(ff.path, ".json", "", 1)
if fileName == "code" {
data := map[string]int{}
err = json.Unmarshal(ff.datas, &data)
if err != nil {
return err
}
RegisterCode(data)
} else {
data := map[string]string{}
err = json.Unmarshal(ff.datas, &data)
if err != nil {
return err
}
RegisterTrans(fileName, data)
}
}
return nil
}
type fileData struct {
path string
datas []byte
}
func readEmbedAllPath(asset embed.FS, path string) ([]fileData, error) {
newRoot := ""
if path == "" {
newRoot = "."
} else {
newRoot = path
}
fs, err := asset.ReadDir(newRoot)
if err != nil {
return nil, err
}
var files []fileData
for _, f := range fs {
if f.IsDir() {
chaild := ""
if newRoot == "." {
chaild = f.Name()
} else {
chaild = newRoot + "/" + f.Name()
}
df, err := readEmbedAllPath(asset, chaild)
if err != nil {
return nil, err
}
files = append(files, df...)
continue
}
by, err := asset.ReadFile(newRoot + "/" + f.Name())
if err != nil {
return nil, err
}
ff := fileData{
path: f.Name(),
datas: by,
}
files = append(files, ff)
}
return files, nil
}
// 获取翻译
// 包含Code和Message
func GetTransFormat(lang string, key string, format map[string]string) (code int, str string) {
code = GetCode(key)
str = GetFormat(lang, key, format)
return
}
// ---- 读取 ----
// GetTrans 取 key 对应的响应码与译文。
func GetTrans(lang string, key string) (code int, str string) {
return GetTransFormat(lang, key, map[string]string{})
return GetTransFormat(lang, key, nil)
}
func GetTransCtx(ctx context.Context, key string) (code int, str string) {
return GetTrans(getLangFromCtx(ctx), key)
}
func GetTransFormatCtx(ctx context.Context, key string, format map[string]string) (code int, str string) {
return GetTransFormat(getLangFromCtx(ctx), key, format)
}
// 根据Key获取code
func GetCode(key string) int {
l.mut.RLock()
defer l.mut.RUnlock()
code, ok := l.codeMap[key]
// GetTransFormat 取 key 对应的响应码与译文, 并把占位符替换为 format 中的值。
// code 与 str 来自同一份快照, 不会出现撕裂。
func GetTransFormat(lang string, key string, format map[string]string) (code int, str string) {
s := load()
code = codeOf(s, key)
str, ok := findMsg(s, lang, key)
if !ok {
return l.ops.defaultCode
notifyMissing(s, lang, key)
return code, key
}
return code
return code, applyFormat(s.ops, str, format)
}
// 获取翻译
// GetTransCtx 从 context 中取语言, 再取响应码与译文。
func GetTransCtx(ctx context.Context, key string) (code int, str string) {
return GetTrans(GetCtxLang(ctx), key)
}
// GetTransFormatCtx 从 context 中取语言, 再取响应码与译文并替换占位符。
func GetTransFormatCtx(ctx context.Context, key string, format map[string]string) (code int, str string) {
return GetTransFormat(GetCtxLang(ctx), key, format)
}
// GetCode 根据 key 取响应码, 未注册时返回默认码(默认 -1, 见 SetDefaultCode)。
func GetCode(key string) int {
return codeOf(load(), key)
}
// GetMsg 取译文: 先按语言查找(逐级去掉区域后缀), 再按默认语言查找, 都没有时返回 key 本身。
func GetMsg(lang string, key string) string {
l.mut.RLock()
defer l.mut.RUnlock()
// 找指定语言
str, ok := l.transMap[lang]
if ok {
val, ok := str[key]
if ok {
return val
s := load()
if v, ok := findMsg(s, lang, key); ok {
return v
}
}
// 找默认语言
str, ok = l.transMap[l.ops.defaultLang]
if ok {
val, ok := str[key]
if ok {
return val
}
}
notifyMissing(s, lang, key)
return key
}
// 从ctx里面获取语言
// GetMsgCtx 从 context 中取语言, 再取译文。
func GetMsgCtx(ctx context.Context, key string) string {
return GetMsg(getLangFromCtx(ctx), key)
return GetMsg(GetCtxLang(ctx), key)
}
// 拼接回复
// GetFormat 取译文并替换占位符, 未提供值的占位符会原样保留在结果里。
func GetFormat(lang string, key string, arr map[string]string) string {
str := GetMsg(lang, key)
s := load()
str, ok := findMsg(s, lang, key)
if !ok {
notifyMissing(s, lang, key)
str = key
}
return applyFormat(s.ops, str, arr)
}
// GetFormatCtx 从 context 中取语言, 再取译文并替换占位符。
func GetFormatCtx(ctx context.Context, key string, arr map[string]string) string {
return GetFormat(GetCtxLang(ctx), key, arr)
}
// Has 判断按回退链(语言 -> 逐级去区域 -> 默认语言)能否取到该 key 的译文。
func Has(lang string, key string) bool {
_, ok := findMsg(load(), lang, key)
return ok
}
// HasCode 判断响应码表中是否注册了该 key。
func HasCode(key string) bool {
_, ok := load().code[key]
return ok
}
// Langs 返回已注册的语言名(已归一化为小写, 升序排列)。
func Langs() []string {
s := load()
langs := make([]string, 0, len(s.trans))
for name := range s.trans {
langs = append(langs, name)
}
sort.Strings(langs)
return langs
}
// GetDefaultCode 返回未知 key 使用的响应码。
func GetDefaultCode() int {
return load().ops.defaultCode
}
// GetDefaultLang 返回默认(兜底)语言。
func GetDefaultLang() string {
return load().ops.defaultLang
}
// ---- 内部实现 ----
func codeOf(s *state, key string) int {
if code, ok := s.code[key]; ok {
return code
}
return s.ops.defaultCode
}
// findMsg 依次尝试: 指定语言(逐级去区域后缀) -> 默认语言(同样逐级), 命中即返回
func findMsg(s *state, lang string, key string) (string, bool) {
if v, ok := lookupMsg(s.trans, lang, key); ok {
return v, true
}
if normalizeLang(lang) == normalizeLang(s.ops.defaultLang) {
return "", false
}
return lookupMsg(s.trans, s.ops.defaultLang, key)
}
// lookupMsg 在语言及其各级父语言中查找 key, 例如 zh-Hans-CN -> zh-hans -> zh。
// 精确命中时不产生任何分配, 属于快路径。
func lookupMsg(trans map[string]map[string]string, lang string, key string) (string, bool) {
l := normalizeLang(lang)
if v, ok := msgOf(trans[l], key); ok {
return v, true
}
for l != "" {
if v, ok := msgOf(trans[l], key); ok {
return v, true
}
// RFC 4647 basic filtering: 请求 en 时可以用 en-GB 兜底
if best := bestPrefixLang(trans, l); best != "" {
if v, ok := msgOf(trans[best], key); ok {
return v, true
}
}
i := strings.LastIndexByte(l, '-')
if i <= 0 {
break
}
l = l[:i]
}
return "", false
}
func msgOf(m map[string]string, key string) (string, bool) {
v, ok := m[key]
return v, ok
}
// bestPrefixLang 返回以 "lang-" 开头且字典序最小的已注册语言, 保证结果稳定
func bestPrefixLang(trans map[string]map[string]string, lang string) string {
if lang == "" {
return ""
}
prefix := lang + "-"
best := ""
for name := range trans {
if strings.HasPrefix(name, prefix) && (best == "" || name < best) {
best = name
}
}
return best
}
// applyFormat 用 arr 替换 str 中的占位符
func applyFormat(ops options, str string, arr map[string]string) string {
if str == "" || len(arr) == 0 {
return str
}
if ops.replacePrefix != "" && !strings.Contains(str, ops.replacePrefix) {
return str
}
for k, v := range arr {
str = strings.ReplaceAll(str, fmt.Sprintf(l.ops.replaceKey, k), v)
str = strings.ReplaceAll(str, ops.replacePrefix+k+ops.replaceSuffix, v)
}
return str
}
func GetFormatCtx(ctx context.Context, key string, arr map[string]string) string {
return GetFormat(getLangFromCtx(ctx), key, arr)
// notifyMissing 在锁外回调用户注册的缺失处理器
func notifyMissing(s *state, lang, key string) {
if s.ops.missing != nil {
s.ops.missing(lang, key)
}
}
// 获取默认Code
func GetDefaultCode() int {
return l.ops.defaultCode
}
// 获取默认语言
func GetDefaultLang() string {
return l.ops.defaultLang
}
func getLangFromCtx(ctx context.Context) string {
lang := GetCtxLang(ctx)
if lang == "" {
lang = l.ops.defaultLang
}
return lang
// normalizeLang 归一化语言名: 去首尾空白并转小写
func normalizeLang(lang string) string {
return strings.ToLower(strings.TrimSpace(lang))
}
+334 -34
View File
@@ -1,49 +1,349 @@
package langx_test
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/yuninks/langx"
)
const (
Lang string = "s"
)
var MapCode = map[string]int{
Lang: 200,
// reset 保证每个用例都在干净状态下运行(本库是全局单例)
func reset(t *testing.T) {
t.Helper()
langx.Reset()
t.Cleanup(langx.Reset)
}
func TestLangx(t *testing.T) {
langx.InitLangx(
langx.SetDefaultCode(0),
langx.SetDefaultLanguage("zh"),
)
func mustInit(t *testing.T, ops ...langx.Option) {
t.Helper()
if err := langx.InitLangx(ops...); err != nil {
t.Fatalf("InitLangx() 返回错误: %v", err)
}
}
langx.RegisterCode(map[string]int{
"login_success": 200,
"error": 400,
})
func assertStrings(t *testing.T, what string, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s = %v, 期望 %v", what, got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%s = %v, 期望 %v", what, got, want)
}
}
}
func TestRegisterAndGet(t *testing.T) {
reset(t)
langx.RegisterCode(map[string]int{"login_success": 200, "error": 400})
langx.RegisterTrans("zh", map[string]string{"login_success": "成功", "username": "你好 #name#"})
langx.RegisterTrans("en", map[string]string{"login_success": "success"})
code, msg := langx.GetTrans("zh", "login_success")
if code != 200 || msg != "成功" {
t.Fatalf("GetTrans(zh) = (%d, %q), 期望 (200, 成功)", code, msg)
}
// 指定语言缺失时回落默认语言
if got := langx.GetMsg("en", "username"); got != "你好 #name#" {
t.Fatalf("默认语言兜底 = %q", got)
}
// 语言名归一化: 大小写与首尾空格
if got := langx.GetMsg(" ZH ", "login_success"); got != "成功" {
t.Fatalf("语言名归一化 = %q", got)
}
// 区域回退: zh-CN -> zh, zh-Hans-CN -> zh-hans -> zh
if got := langx.GetMsg("zh-CN", "login_success"); got != "成功" {
t.Fatalf("zh-CN 回退 = %q", got)
}
if got := langx.GetMsg("zh-Hans-CN", "login_success"); got != "成功" {
t.Fatalf("zh-Hans-CN 回退 = %q", got)
}
// 未注册语言走默认语言
if got := langx.GetMsg("fr", "login_success"); got != "成功" {
t.Fatalf("未注册语言 = %q", got)
}
// 未注册 key 返回 key 本身
if got := langx.GetMsg("zh", "nope"); got != "nope" {
t.Fatalf("未注册 key = %q", got)
}
if langx.Has("zh", "nope") || !langx.Has("en", "login_success") {
t.Fatal("Has 判断不正确")
}
}
func TestUnknownKeyCode(t *testing.T) {
reset(t)
langx.RegisterCode(map[string]int{"ok": 200})
// 默认不再返回成功码
if got := langx.GetCode("nope"); got != -1 {
t.Fatalf("未知 key 的 code = %d, 期望 -1", got)
}
if code, msg := langx.GetTrans("zh", "nope"); code != -1 || msg != "nope" {
t.Fatalf("GetTrans(未知 key) = (%d, %q)", code, msg)
}
if langx.HasCode("nope") {
t.Fatal("HasCode(未知 key) 应为 false")
}
if !langx.HasCode("ok") {
t.Fatal("HasCode(ok) 应为 true")
}
// 可配置为失败码
mustInit(t, langx.SetDefaultCode(500))
if got := langx.GetCode("nope"); got != 500 {
t.Fatalf("SetDefaultCode(500) 后 = %d", got)
}
if got := langx.GetDefaultCode(); got != 500 {
t.Fatalf("GetDefaultCode() = %d", got)
}
}
func TestMissingHandler(t *testing.T) {
reset(t)
var calls []string
mustInit(t, langx.SetMissingHandler(func(lang, key string) {
// 回调内再次调用本库不应死锁
_ = langx.Langs()
calls = append(calls, lang+"|"+key)
}))
langx.RegisterTrans("zh", map[string]string{"a": "A"})
if got := langx.GetMsg("zh", "missing"); got != "missing" {
t.Fatalf("GetMsg = %q", got)
}
if len(calls) != 1 || calls[0] != "zh|missing" {
t.Fatalf("缺失回调 = %v", calls)
}
// 命中时不触发
_ = langx.GetMsg("zh", "a")
_, _ = langx.GetTransFormat("zh", "a", nil)
if len(calls) != 1 {
t.Fatalf("命中时不应触发回调, 实际 %v", calls)
}
// 取消回调
mustInit(t, langx.SetMissingHandler(nil))
_ = langx.GetMsg("zh", "missing2")
if len(calls) != 1 {
t.Fatalf("取消回调后仍被调用: %v", calls)
}
}
func TestFormat(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{
"login_success": "成功",
"error": "错误",
"username": "你好 #name#", // 有占位符
"greet": "你好 #name#, 欢迎来到 #place#, #name#",
"plain": "没有占位符",
})
langx.RegisterTrans("en", map[string]string{
"login_success": "success",
"error": "error",
"username": "Hello #name#", // 有占位符
})
// 获取翻译码
code, msg := langx.GetTransFormat("zh", "login_success", nil)
t.Log(code, msg)
code, msg = langx.GetTransFormat("en", "error", nil)
t.Log(code, msg)
// 获取翻译码,有占位符
code, msg = langx.GetTransFormat("zh", "username", map[string]string{
"name": "张三",
})
t.Log(code, msg)
if got := langx.GetFormat("zh", "greet", map[string]string{"name": "张三", "place": "北京"}); got != "你好 张三, 欢迎来到 北京, 张三" {
t.Fatalf("GetFormat = %q", got)
}
// 没有提供值的占位符原样保留, 便于发现问题
if got := langx.GetFormat("zh", "greet", map[string]string{"name": "张三"}); !strings.Contains(got, "#place#") {
t.Fatalf("缺失参数时应保留占位符, 实际 %q", got)
}
// 模板没有占位符 / 参数为 nil / 空参数
if got := langx.GetFormat("zh", "plain", map[string]string{"name": "张三"}); got != "没有占位符" {
t.Fatalf("无占位符模板 = %q", got)
}
if got := langx.GetFormat("zh", "plain", nil); got != "没有占位符" {
t.Fatalf("nil 参数 = %q", got)
}
// 未注册的 key 也能得到替换后的 key(保持向后兼容)
if got := langx.GetFormat("zh", "who_#name#", map[string]string{"name": "x"}); got != "who_x" {
t.Fatalf("未注册 key = %q", got)
}
// 自定义占位符模式
mustInit(t, langx.SetReplaceKey("{{%s}}"))
langx.RegisterTrans("zh", map[string]string{"t": "hi {{name}}"})
if got := langx.GetFormat("zh", "t", map[string]string{"name": "x"}); got != "hi x" {
t.Fatalf("自定义模式 = %q", got)
}
// 模式只写占位符本身
mustInit(t, langx.SetReplaceKey("%s"))
if got := langx.GetFormat("zh", "t", map[string]string{"{{name}}": "x"}); got != "hi x" {
t.Fatalf("裸 %%s 模式 = %q", got)
}
}
func TestInitLangxRejectsInvalidOption(t *testing.T) {
reset(t)
mustInit(t, langx.SetReplaceKey("{{%s}}"))
langx.RegisterTrans("zh", map[string]string{"t": "hi {{name}}"})
for _, bad := range []string{"", "#{}#", "#%d#", "%s%s", "no-verb"} {
if err := langx.InitLangx(langx.SetReplaceKey(bad)); err == nil {
t.Fatalf("SetReplaceKey(%q) 应当返回错误", bad)
}
// 非法配置必须完全不生效
if got := langx.GetFormat("zh", "t", map[string]string{"name": "x"}); got != "hi x" {
t.Fatalf("非法配置生效了: replaceKey=%q, 结果 %q", bad, got)
}
}
// 合法模式仍然可用
mustInit(t, langx.SetReplaceKey("#%s#"))
if err := langx.InitLangx(langx.SetDefaultLanguage("zh")); err != nil {
t.Fatalf("合法配置报错: %v", err)
}
if err := langx.InitLangx(langx.SetDefaultLanguage(" ")); err == nil {
t.Fatal("空默认语言应当报错")
}
if got := langx.GetDefaultLang(); got != "zh" {
t.Fatalf("非法默认语言生效了: %q", got)
}
}
func TestRegistrationDoesNotAliasCaller(t *testing.T) {
reset(t)
trans := map[string]string{"k": "v1"}
langx.RegisterTrans("zh", trans)
trans["k"] = "v2"
trans["added"] = "x"
if got := langx.GetMsg("zh", "k"); got != "v1" {
t.Fatalf("注册后调用方修改 map 影响了库内数据: %q", got)
}
if langx.Has("zh", "added") {
t.Fatal("注册后调用方新增的 key 进入了库内")
}
codes := map[string]int{"a": 1}
langx.RegisterCode(codes)
codes["a"] = 2
if got := langx.GetCode("a"); got != 1 {
t.Fatalf("RegisterCode 后调用方修改 map 影响了库内数据: %d", got)
}
// AppendTrans 不得污染调用方传入的 map
caller := map[string]string{"a": "1"}
langx.RegisterTrans("en", caller)
langx.AppendTrans("en", map[string]string{"b": "2"})
if _, ok := caller["b"]; ok {
t.Fatalf("AppendTrans 污染了调用方的 map: %v", caller)
}
if got := langx.GetMsg("en", "b"); got != "2" {
t.Fatalf("AppendTrans 未生效: %q", got)
}
}
func TestRegisterSemantics(t *testing.T) {
reset(t)
langx.AppendTrans("zh", map[string]string{"a": "1"})
langx.AppendTrans("zh", map[string]string{"b": "2"})
assertStrings(t, "Langs()", langx.Langs(), []string{"zh"})
if langx.GetMsg("zh", "a") != "1" || langx.GetMsg("zh", "b") != "2" {
t.Fatal("AppendTrans 应累加")
}
// RegisterTrans 整体替换
langx.RegisterTrans("zh", map[string]string{"c": "3"})
if langx.Has("zh", "a") || langx.GetMsg("zh", "c") != "3" {
t.Fatal("RegisterTrans 应整体替换该语言")
}
// 传 nil 清空
langx.RegisterTrans("zh", nil)
assertStrings(t, "Langs()", langx.Langs(), []string{"zh"})
if langx.Has("zh", "c") {
t.Fatal("RegisterTrans(nil) 应清空该语言")
}
// AppendCode 累加, RegisterCode 替换
langx.AppendCode(map[string]int{"a": 1})
langx.AppendCode(map[string]int{"b": 2})
if langx.GetCode("a") != 1 || langx.GetCode("b") != 2 {
t.Fatal("AppendCode 应累加")
}
langx.RegisterCode(map[string]int{"c": 3})
if langx.HasCode("a") || langx.GetCode("c") != 3 {
t.Fatal("RegisterCode 应整体替换")
}
// 多语言按名排序
langx.RegisterTrans("en", map[string]string{"x": "y"})
langx.RegisterTrans("ZH-CN", map[string]string{"x": "z"})
assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh", "zh-cn"})
}
func TestContextPriority(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{"k": "中文"})
langx.RegisterTrans("en", map[string]string{"k": "English"})
mustInit(t, langx.SetDefaultLanguage("zh"))
// 显式设置优先于请求头
ctx := langx.SetCtxLang(context.Background(), "en")
ctx = context.WithValue(ctx, "Accept-Language", "zh-CN,zh;q=0.9")
if got := langx.GetMsgCtx(ctx, "k"); got != "English" {
t.Fatalf("显式语言应优先, 实际 %q", got)
}
if _, got := langx.GetTransFormatCtx(ctx, "k", nil); got != "English" {
t.Fatalf("GetTransFormatCtx = %q", got)
}
// ctx 中显式指定的是 en, 因此取到英文
if got := langx.GetFormatCtx(ctx, "k", map[string]string{"n": "1"}); got != "English" {
t.Fatalf("GetFormatCtx = %q", got)
}
}
func TestConcurrentAccess(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{"k": "v0"})
langx.RegisterCode(map[string]int{"ok": 200})
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_, msg := langx.GetTrans("zh-CN", "k")
if msg == "" {
t.Error("读取到空译文")
return
}
_ = langx.GetCode("ok")
_ = langx.Langs()
_ = langx.GetDefaultLang()
_ = langx.Has("zh", "k")
}
}()
}
for i := 0; i < 200; i++ {
langx.AppendTrans("zh", map[string]string{"k": fmt.Sprintf("v%d", i)})
langx.AppendCode(map[string]int{"n": i})
langx.AppendTrans("en", map[string]string{"k": fmt.Sprintf("e%d", i)})
if err := langx.InitLangx(langx.SetDefaultCode(i)); err != nil {
t.Errorf("InitLangx: %v", err)
break
}
}
close(stop)
wg.Wait()
}
func TestReset(t *testing.T) {
reset(t)
langx.RegisterTrans("zh", map[string]string{"k": "v"})
langx.RegisterCode(map[string]int{"c": 1})
mustInit(t, langx.SetDefaultCode(500), langx.SetDefaultLanguage("en"))
langx.Reset()
assertStrings(t, "Langs()", langx.Langs(), nil)
if langx.HasCode("c") {
t.Fatal("Reset 未清空响应码")
}
if got := langx.GetDefaultLang(); got != "zh" {
t.Fatalf("Reset 未恢复默认语言: %q", got)
}
if got := langx.GetDefaultCode(); got != -1 {
t.Fatalf("Reset 未恢复默认码: %d", got)
}
}
+172
View File
@@ -0,0 +1,172 @@
package langx
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"strings"
)
// codeFileName 是响应码文件名
const codeFileName = "code.json"
// RegisterDir 从磁盘目录加载响应码与译文, 等价于对 os.DirFS(dir) 调用 RegisterFS。
//
// 资源文件的组织方式:
// - 根目录下的 <语言>.json (如 zh.json、en-US.json) 是该语言的译文;
// - 根目录下的 code.json 是响应码, 内容为 map[string]int;
// - 子目录的第一段是语言名, 例如 zh/common.json、zh/errors.json 都会并入 zh(按键合并);
// - 非 .json 文件直接忽略; 任何文件解析失败都会返回错误, 并且不会改变已加载的数据。
//
// 如果目录下没有 .json 文件且只有一个子目录(常见于 langs/、i18n/ 这类容器目录),
// 会自动向内继续查找(最多 8 层), 因此 RegisterDir("assets") 也能装载 assets/langs/zh.json。
func RegisterDir(dir string) error {
if strings.TrimSpace(dir) == "" {
return fmt.Errorf("langx: 目录不能为空")
}
return RegisterFS(os.DirFS(dir))
}
// RegisterFS 与 RegisterDir 规则相同, 但资源来自任意 fs.FS。
//
// 已加载的数据是按键合并的: 多次调用会累加, 同一个 key 以最后一次为准。
func RegisterFS(fsys fs.FS) error {
if fsys == nil {
return fmt.Errorf("langx: fs.FS 不能为 nil")
}
data, err := loadFS(fsys)
if err != nil {
return err
}
return updateErr(func(old *state) (*state, error) {
next := setCodes(old, data.code, true)
for lang, trans := range data.trans {
next = setTrans(next, lang, trans, true)
}
return next, nil
})
}
// RegisterEmbed 从 embed.FS 加载, 规则与 RegisterFS 完全一致。
func RegisterEmbed(asset embed.FS) error {
return RegisterFS(asset)
}
// loadedData 是一次加载的结果, 只有全部解析成功才会写入快照
type loadedData struct {
code map[string]int
trans map[string]map[string]string
}
func loadFS(fsys fs.FS) (*loadedData, error) {
root, err := resolveRoot(fsys)
if err != nil {
return nil, fmt.Errorf("langx: 读取根目录失败: %w", err)
}
data := &loadedData{
code: map[string]int{},
trans: map[string]map[string]string{},
}
err = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !isJSONName(d.Name()) {
return nil
}
rel := relativePath(root, p)
raw, err := fs.ReadFile(fsys, p)
if err != nil {
return fmt.Errorf("langx: 读取 %s 失败: %w", p, err)
}
// 根目录下的 code.json 是响应码; 更深层的 code.json 视为该语言的普通文件
if !strings.Contains(rel, "/") && strings.EqualFold(rel, codeFileName) {
var codes map[string]int
if err := json.Unmarshal(raw, &codes); err != nil {
return fmt.Errorf("langx: 解析响应码文件 %s 失败: %w", p, err)
}
for k, v := range codes {
data.code[k] = v
}
return nil
}
lang := langOfRelativePath(rel)
if lang == "" {
return nil
}
var trans map[string]string
if err := json.Unmarshal(raw, &trans); err != nil {
return fmt.Errorf("langx: 解析语言文件 %s 失败: %w", p, err)
}
dst := data.trans[lang]
if dst == nil {
dst = map[string]string{}
data.trans[lang] = dst
}
for k, v := range trans {
dst[k] = v
}
return nil
})
if err != nil {
return nil, err
}
return data, nil
}
// resolveRoot 跳过纯粹的容器目录: 当前目录没有 .json 文件且只有一个子目录时继续向内
func resolveRoot(fsys fs.FS) (string, error) {
root := "."
for i := 0; i < 8; i++ {
entries, err := fs.ReadDir(fsys, root)
if err != nil {
return "", err
}
hasJSON, subDir, dirs := false, "", 0
for _, e := range entries {
if e.IsDir() {
dirs++
subDir = e.Name()
continue
}
if isJSONName(e.Name()) {
hasJSON = true
}
}
if hasJSON || dirs != 1 {
return root, nil
}
if root == "." {
root = subDir
} else {
root = root + "/" + subDir
}
}
return root, nil
}
// relativePath 返回 p 相对于 root 的路径
func relativePath(root string, p string) string {
if root == "." {
return p
}
return strings.TrimPrefix(strings.TrimPrefix(p, root), "/")
}
// langOfRelativePath 推导语言名: 顶层文件取文件名, 子目录取第一段路径
func langOfRelativePath(rel string) string {
if i := strings.IndexByte(rel, '/'); i >= 0 {
return normalizeLang(rel[:i])
}
return normalizeLang(strings.TrimSuffix(rel, path.Ext(rel)))
}
// isJSONName 判断文件名是否为 .json(不区分大小写)
func isJSONName(name string) bool {
return strings.EqualFold(path.Ext(name), ".json")
}
+195
View File
@@ -0,0 +1,195 @@
package langx_test
import (
"embed"
"os"
"path/filepath"
"testing"
"testing/fstest"
"github.com/yuninks/langx"
)
//go:embed testdata/langs
var embeddedLangs embed.FS
func writeFile(t *testing.T, dir, rel, body string) {
t.Helper()
p := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatalf("写入文件失败: %v", err)
}
}
func TestRegisterDir(t *testing.T) {
reset(t)
dir := t.TempDir()
writeFile(t, dir, "code.json", "{\"login_success\":200,\"error\":400}")
writeFile(t, dir, "zh.json", "{\"login_success\":\"成功\",\"welcome\":\"你好 #name#\"}")
writeFile(t, dir, "en-US.json", "{\"login_success\":\"success\"}")
writeFile(t, dir, "notes.txt", "这不是 json")
writeFile(t, dir, "zh/extra.json", "{\"bye\":\"再见\"}") // 子目录并入 zh
writeFile(t, dir, "en-US/more.json", "{\"bye\":\"bye\"}") // 子目录并入 en-US
if err := langx.RegisterDir(dir); err != nil {
t.Fatalf("RegisterDir: %v", err)
}
assertStrings(t, "Langs()", langx.Langs(), []string{"en-us", "zh"})
if got := langx.GetCode("login_success"); got != 200 {
t.Fatalf("code.json 未生效: %d", got)
}
if got := langx.GetMsg("zh", "bye"); got != "再见" {
t.Fatalf("子目录文件未并入 zh: %q", got)
}
if got := langx.GetMsg("en-US", "bye"); got != "bye" {
t.Fatalf("子目录文件未并入 en-US: %q", got)
}
if got := langx.GetMsg("en-GB", "login_success"); got != "success" {
t.Fatalf("区域回退失败: %q", got)
}
// 再次加载同一目录: 合并语义, 不报错也不丢数据
if err := langx.RegisterDir(dir); err != nil {
t.Fatalf("重复加载: %v", err)
}
if got := langx.GetMsg("zh", "login_success"); got != "成功" {
t.Fatalf("重复加载后数据异常: %q", got)
}
}
// 旧实现在目录不存在时会 panic
func TestRegisterDirMissing(t *testing.T) {
reset(t)
if err := langx.RegisterDir(filepath.Join(t.TempDir(), "not-exist")); err == nil {
t.Fatal("目录不存在时应返回错误")
}
if err := langx.RegisterDir(" "); err == nil {
t.Fatal("空目录名应返回错误")
}
if err := langx.RegisterDir(t.TempDir()); err != nil {
t.Fatalf("空目录应加载成功(无数据): %v", err)
}
}
// 解析失败必须整体失败, 不能留下半加载状态
func TestRegisterDirInvalidJSON(t *testing.T) {
reset(t)
dir := t.TempDir()
writeFile(t, dir, "zh.json", "{\"k\":\"v\"}")
writeFile(t, dir, "en.json", "{坏掉的 json")
writeFile(t, dir, "code.json", "{\"k\":200}")
if err := langx.RegisterDir(dir); err == nil {
t.Fatal("非法 json 应返回错误")
}
if len(langx.Langs()) != 0 || langx.HasCode("k") {
t.Fatalf("加载失败却写入了数据: langs=%v", langx.Langs())
}
}
// 结构不符合约定时也应报错
func TestRegisterDirWrongSchema(t *testing.T) {
reset(t)
dir := t.TempDir()
writeFile(t, dir, "code.json", "{\"k\":\"not-int\"}")
if err := langx.RegisterDir(dir); err == nil {
t.Fatal("code.json 类型错误应返回错误")
}
dir2 := t.TempDir()
writeFile(t, dir2, "zh.json", "[\"not\",\"object\"]")
if err := langx.RegisterDir(dir2); err == nil {
t.Fatal("语言文件类型错误应返回错误")
}
}
func TestRegisterFSContainer(t *testing.T) {
reset(t)
fsys := fstest.MapFS{
"i18n/code.json": {Data: []byte("{\"ok\":200}")},
"i18n/zh.json": {Data: []byte("{\"hello\":\"你好\"}")},
"i18n/en.json": {Data: []byte("{\"hello\":\"Hello\"}")},
"i18n/README.md": {Data: []byte("忽略我")},
"i18n/zh/extra.json": {Data: []byte("{\"bye\":\"再见\"}")},
}
if err := langx.RegisterFS(fsys); err != nil {
t.Fatalf("RegisterFS: %v", err)
}
// 容器目录 i18n 被自动跳过, 非 json 文件被忽略
assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"})
if got := langx.GetCode("ok"); got != 200 {
t.Fatalf("容器内的 code.json 未生效: %d", got)
}
if got := langx.GetMsg("zh", "hello"); got != "你好" {
t.Fatalf("GetMsg = %q", got)
}
if got := langx.GetMsg("zh", "bye"); got != "再见" {
t.Fatalf("语言子目录未合并: %q", got)
}
}
// 只有语言目录、根目录没有 json 的布局
func TestRegisterFSLanguageDirs(t *testing.T) {
reset(t)
fsys := fstest.MapFS{
"langs/zh/a.json": {Data: []byte("{\"a\":\"甲\"}")},
"langs/zh/b.json": {Data: []byte("{\"b\":\"乙\"}")},
"langs/en/a.json": {Data: []byte("{\"a\":\"A\"}")},
"langs/code.json": {Data: []byte("{\"ok\":200}")},
}
if err := langx.RegisterFS(fsys); err != nil {
t.Fatalf("RegisterFS: %v", err)
}
assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"})
if got := langx.GetMsg("zh", "a"); got != "甲" {
t.Fatalf("zh/a.json = %q", got)
}
if got := langx.GetMsg("zh", "b"); got != "乙" {
t.Fatalf("zh/b.json 未合并 = %q", got)
}
if got := langx.GetMsg("en", "a"); got != "A" {
t.Fatalf("en/a.json = %q", got)
}
if got := langx.GetCode("ok"); got != 200 {
t.Fatalf("code.json 未生效: %d", got)
}
}
func TestRegisterFSErrors(t *testing.T) {
reset(t)
if err := langx.RegisterFS(nil); err == nil {
t.Fatal("nil fs.FS 应返回错误")
}
fsys := fstest.MapFS{"zh.json": {Data: []byte("{坏")}}
if err := langx.RegisterFS(fsys); err == nil {
t.Fatal("非法 json 应返回错误")
}
if len(langx.Langs()) != 0 {
t.Fatalf("失败后不应有数据: %v", langx.Langs())
}
// 没有任何 json 文件
empty := fstest.MapFS{"readme.md": {Data: []byte("x")}}
if err := langx.RegisterFS(empty); err != nil {
t.Fatalf("无 json 文件不应报错: %v", err)
}
}
func TestRegisterEmbed(t *testing.T) {
reset(t)
if err := langx.RegisterEmbed(embeddedLangs); err != nil {
t.Fatalf("RegisterEmbed: %v", err)
}
// testdata/langs 是容器目录, 会被自动跳过; notes.txt 会被忽略
assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"})
if got := langx.GetCode("login_success"); got != 200 {
t.Fatalf("响应码未加载: %d", got)
}
code, msg := langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"})
if code != 200 || msg != "Hello Tom" {
t.Fatalf("GetTransFormat = (%d, %q)", code, msg)
}
if got := langx.GetMsg("zh", "bye"); got != "再见" {
t.Fatalf("testdata/langs/zh/extra.json 未合并: %q", got)
}
}
+97 -27
View File
@@ -1,54 +1,124 @@
package langx
import (
"fmt"
"strings"
)
// options 是库的全局配置。
//
// 它是值类型, 一旦随快照发布就不再被修改: 任何配置变更都必须构造一份新的 options
// 并通过 update/updateErr 原子替换, 这样读路径可以完全不加锁, 也不会与写入产生竞态。
type options struct {
// defaultCode 是未知 key 返回的响应码。
// 默认 -1, 表示"未定义", 避免 key 拼写错误时静默返回成功码。
defaultCode int
// defaultLang 是默认语言(已归一化), 兜底时使用。
defaultLang string
// replaceKey 是占位符模式, 形如 "#%s#"。
replaceKey string
// ctxLangKey string
// replacePrefix/replaceSuffix 由 replaceKey 推导, 避免每次替换都做一次 Sprintf。
replacePrefix string
replaceSuffix string
// missing 是译文缺失时的回调, 可为 nil。
missing func(lang, key string)
}
func defaultOptions() *options {
return &options{
defaultCode: 200,
func defaultOptions() options {
o := options{
defaultCode: -1,
defaultLang: "zh",
replaceKey: "#%s#",
// ctxLangKey: "language",
}
o.derive()
return o
}
// 设置
func InitLangx(ops ...Option) {
for _, opt := range ops {
opt(l.ops)
// derive 由 replaceKey 推导出占位符的前缀与后缀
func (o *options) derive() {
const probe = "\x00langx\x00"
out := fmt.Sprintf(o.replaceKey, probe)
i := strings.Index(out, probe)
if i < 0 {
o.replacePrefix, o.replaceSuffix = "", ""
return
}
o.replacePrefix = out[:i]
o.replaceSuffix = out[i+len(probe):]
}
func (o options) validate() error {
if strings.TrimSpace(o.defaultLang) == "" {
return fmt.Errorf("langx: 默认语言不能为空")
}
return validateReplaceKey(o.replaceKey)
}
// validateReplaceKey 要求模式中有且只有一个 %s
func validateReplaceKey(pattern string) error {
if pattern == "" {
return fmt.Errorf("langx: replaceKey 不能为空, 形如 %q", "#%s#")
}
out := fmt.Sprintf(pattern, "x")
if strings.Contains(out, "%!") || !strings.Contains(out, "x") {
return fmt.Errorf("langx: replaceKey=%q 非法, 需要且只需要一个 %%s 占位符, 例如 %q", pattern, "#%s#")
}
return nil
}
// Option 是配置项, 通过 InitLangx 应用。
type Option func(*options)
// 设置默认的code
// InitLangx 应用配置项, 可重复调用, 后一次在前一次的配置上叠加。
//
// 与旧版本的区别: 配置非法时返回错误, 且本次传入的所有 Option 都不会生效(不会写入一半)。
// 不需要错误处理时可以直接当语句使用: langx.InitLangx(...)
func InitLangx(ops ...Option) error {
return updateErr(func(old *state) (*state, error) {
next := old.ops // 值拷贝, 不影响已经发布的配置
for _, opt := range ops {
if opt == nil {
continue
}
opt(&next)
}
if err := next.validate(); err != nil {
return nil, err
}
next.derive()
return setOptions(old, next), nil
})
}
// Reset 清空所有已注册的响应码与译文, 并恢复默认配置。便于测试与重新初始化。
func Reset() {
update(func(*state) *state { return newState() })
}
// SetDefaultCode 设置未知 key 返回的响应码, 默认 -1。
//
// 如果 code 会被直接当作 HTTP 状态码使用, 建议设置为 500, 让缺失的 key 以失败收场。
func SetDefaultCode(code int) Option {
return func(o *options) {
o.defaultCode = code
}
return func(o *options) { o.defaultCode = code }
}
// 从ctx里面获取语言的key
// func SetCtxLangKey(key string) Option {
// return func(o *options) {
// o.ctxLangKey = key
// }
// }
// 默认语言
// SetDefaultLanguage 设置默认(兜底)语言, 默认 "zh"。
func SetDefaultLanguage(lang string) Option {
return func(o *options) {
o.defaultLang = lang
}
return func(o *options) { o.defaultLang = normalizeLang(lang) }
}
// 替换规则 %s 为占位符 需要填进去
// SetReplaceKey 设置占位符模式, 必须包含且只包含一个 %s, 默认 "#%s#"(即 #name#)。
func SetReplaceKey(key string) Option {
return func(o *options) {
o.replaceKey = key
return func(o *options) { o.replaceKey = key }
}
// SetMissingHandler 设置译文缺失时的回调, 便于记录漏翻的 key。传 nil 表示取消回调。
//
// 回调在读取路径上同步执行, 且不在任何锁内, 因此可以安全地再次调用本库的函数。
func SetMissingHandler(fn func(lang, key string)) Option {
return func(o *options) { o.missing = fn }
}
+181 -4
View File
@@ -1,9 +1,186 @@
# langx
# 简介
这是简单的一个多语言响应工具,能够通过多种方式加载资源文件,然后进行匹配。同时支持占位符替换的形式在多语言响应的时候替换特定字符,让响应更友好。
轻量的 Go 多语言响应库:注册响应码与多语言译文,按语言(支持区域回退)取值,并支持占位符替换。零第三方依赖,`go 1.19` 起可用。
# 使用方法
```go
code, msg := langx.GetTransFormatCtx(ctx, "login_success", map[string]string{"name": "张三"})
// code = 200, msg = "你好 张三, 登录成功"
```
看example
## 特性
- **资源来源灵活**:`embed.FS`、磁盘目录、任意 `fs.FS`,或直接传 map。
- **语言协商**:解析 `Accept-Language`(q 值排序 + `zh-CN → zh` 区域回退 + RFC 4647 前缀匹配,请求 `en-US` 可命中已注册的 `en-GB`)。
- **占位符替换**:默认 `#name#` 形式,模式可自定义;缺少参数时占位符原样保留,便于及早发现。
- **并发安全**:读取路径无锁,注册与读取可安全并行,永远不会读到"写了一半"的数据。
- **明确的失败**:未知 key 默认不再返回成功码;资源文件解析失败时整体失败,不会写入半个文件。
- **不篡改调用方数据**:注册时深拷贝,注册之后调用方修改自己的 map 不会影响库内数据。
## 安装
```bash
go get github.com/yuninks/langx
```
## 快速开始
完整可运行示例见 [example/main.go](example/main.go),执行 `go run ./example` 即可查看输出。
```go
package main
import (
"context"
"embed"
"fmt"
"github.com/yuninks/langx"
)
//go:embed langs
var langFS embed.FS
func main() {
// 1. 可选配置:默认语言、未知 key 的响应码、漏翻回调
if err := langx.InitLangx(
langx.SetDefaultLanguage("zh"),
langx.SetDefaultCode(500), // code 若会作为 HTTP 状态码,建议让缺失的 key 以失败收场
langx.SetMissingHandler(func(lang, key string) {
fmt.Printf("缺少译文: lang=%s key=%s\n", lang, key)
}),
); err != nil {
panic(err)
}
// 2. 装载资源(四选一)
if err := langx.RegisterEmbed(langFS); err != nil { // 或 RegisterDir("langs") / RegisterFS(fsys)
panic(err)
}
// 3. 取值
code, msg := langx.GetTrans("zh", "login_success")
// 4. 带占位符
code, msg = langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"})
// 5. 从请求 context 取语言:显式设置 > Accept-Language 协商 > 默认语言
ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9")
msg = langx.GetMsgCtx(ctx, "login_success")
}
```
## 资源文件约定
`RegisterDir` / `RegisterFS` / `RegisterEmbed` 使用同一套规则:
| 路径 | 含义 |
| --- | --- |
| `zh.json` | 语言 `zh` 的译文,格式 `map[string]string` |
| `en-US.json` | 语言 `en-US` 的译文(语言名不区分大小写,统一按小写归一化) |
| `code.json`(根目录) | 响应码,格式 `map[string]int` |
| `zh/common.json`、`zh/errors.json` | 子目录第一段是语言名,多个文件按键合并进 `zh` |
| `README.md` 等非 json 文件 | 直接忽略 |
| `langs/`(唯一子目录且自身没有 json) | 视为容器目录,自动向内继续查找(最多 8 层) |
规则要点:
- 加载是**合并**语义:多次调用会累加,同一个 key 以最后一次为准。
- 加载是**事务**的:任意文件解析失败都会返回错误,且不会改变已加载的数据。
- 根目录下的 `code.json` 是保留文件名;更深层的 `code.json`(如 `zh/code.json`)按该语言的普通文件处理。
## API 一览
### 注册
| 函数 | 说明 |
| --- | --- |
| `RegisterCode(map[string]int)` | 整体替换响应码表,传 `nil` 清空 |
| `AppendCode(map[string]int)` | 追加/覆盖响应码 |
| `RegisterTrans(lang, map[string]string)` | 整体替换某语言译文 |
| `AppendTrans(lang, map[string]string)` | 追加/覆盖某语言译文 |
| `RegisterDir(dir string) error` | 从磁盘目录加载 |
| `RegisterFS(fsys fs.FS) error` | 从任意 `fs.FS` 加载 |
| `RegisterEmbed(asset embed.FS) error` | 从 `embed.FS` 加载 |
### 取值
| 函数 | 说明 |
| --- | --- |
| `GetTrans(lang, key) (int, string)` | 取响应码与译文 |
| `GetTransFormat(lang, key, map[string]string) (int, string)` | 同上,并替换占位符 |
| `GetCode(key) int` | 只取响应码 |
| `GetMsg(lang, key) string` | 只取译文 |
| `GetFormat(lang, key, map[string]string) string` | 只取译文并替换占位符 |
| `GetTransCtx` / `GetTransFormatCtx` / `GetMsgCtx` / `GetFormatCtx` | 从 `context.Context` 取语言 |
| `Has(lang, key) bool` / `HasCode(key) bool` | 判断译文/响应码是否存在 |
| `Langs() []string` | 已注册语言(小写、升序) |
### 配置
| 函数 | 默认值 | 说明 |
| --- | --- | --- |
| `InitLangx(...Option) error` | — | 应用配置;可重复调用;配置非法时返回错误且完全不生效 |
| `SetDefaultLanguage(lang)` | `zh` | 默认(兜底)语言 |
| `SetDefaultCode(code)` | `-1` | 未知 key 的响应码 |
| `SetReplaceKey(pattern)` | `#%s#` | 占位符模式,必须包含且只包含一个 `%s` |
| `SetMissingHandler(fn)` | `nil` | 译文缺失回调,便于记录漏翻的 key |
| `Reset()` | — | 清空全部数据并恢复默认配置(测试用) |
## 语言协商
`GetCtxLang(ctx)` 按以下优先级返回语言:
1. `langx.SetCtxLang(ctx, "en")` 显式设置的值(即使该语言没有注册也照用);
2. `context` 中的 `Accept-Language`(`string` 或 `[]string`,后者兼容 `http.Header` 风格),交给 `NegotiateLang` 与已注册语言协商;
3. 默认语言 `GetDefaultLang()`。
协商规则(`ParseAcceptLanguage` / `NegotiateLang` 也已导出,可单独使用):
- 按 `q` 值从高到低排序,`q=0` 表示明确不接受、会被丢弃,`*` 视为"任意"并回落默认语言;
- 每个候选先精确匹配,再逐级去掉区域后缀(`zh-Hans-CN → zh-hans → zh`),最后按语言前缀匹配(请求 `en-US` 可命中已注册的 `en-GB`)。
取值时的回退链同样是:指定语言 → 逐级去区域 → 语言前缀 → 默认语言(同样逐级);全都取不到时返回 key 本身,并触发 `SetMissingHandler` 回调。
## 占位符
```go
langx.RegisterTrans("zh", map[string]string{"welcome": "你好 #name#, 欢迎回来"})
langx.GetFormat("zh", "welcome", map[string]string{"name": "张三"}) // 你好 张三, 欢迎回来
```
- 模式由 `SetReplaceKey` 配置,必须包含且只包含一个 `%s`;写成 `#{}#`、`#%d#` 这类非法模式会在 `InitLangx` 时报错,而不是静默失效。
- 同一个占位符出现多次会全部替换;没有提供值的占位符原样保留。
- 未注册的 key 也会走替换逻辑,方便过渡期使用动态 key。
## 并发模型
- 读取(`Get*`)基于不可变快照,**完全无锁**;一次调用内使用同一份快照,因此 `GetTransFormat` 返回的 code 与 msg 一定是一致的。
- 写入(注册与配置)串行执行并原子发布新快照:读取要么看到旧数据,要么看到新数据,不会看到中间状态,也不存在数据竞态。
- 频繁写入会重建索引,建议把注册放在启动阶段;运行期更新适合用 `AppendTrans` 做增量补丁。
## 从 v0.0.7 升级
本次修复包含以下**行为变更**,请按需调整:
| 变更 | 说明 |
| --- | --- |
| `InitLangx` 现在返回 `error` | 当语句调用不受影响;非法 `replaceKey`(如 `#{}#`)此前会静默失效,现在返回错误且配置完全不生效 |
| 未知 key 的默认响应码 `200` → `-1` | 旧行为会让拼错的错误码静默变成"成功";需要旧行为可显式 `SetDefaultCode(200)`,HTTP 场景建议 `SetDefaultCode(500)` |
| 注册不再与调用方共享 map | 依赖"注册后修改自己的 map 仍然生效"的代码需要改为再次调用注册函数 |
| 加载器只处理 `.json`,子目录参与语言名推导 | 此前子目录中的同名文件会互相覆盖、`en/common.json` 会被当成语言 `common`;现在子目录第一段即语言名并按键合并 |
| 加载失败不再留下半加载状态 | 此前 `RegisterEmbed` 遇到非 json 文件会中断,已注册的部分仍然生效 |
| `GetCtxLang` 返回协商后的语言 | 此前会原样返回请求头字符串(如 `en-US,en;q=0.9`),并在 context 值不是 string 时 panic |
| `RegisterCode` / `RegisterTrans` / `AppendCode` / `AppendTrans` 语义未变 | 整体替换 / 追加覆盖的语义与旧版一致 |
## 开发
```bash
gofmt -l . # 应为空
go vet ./...
go test -race ./...
go run ./example
```
## License
尚未添加 LICENSE 文件,需要仓库作者确定许可协议后再补充。
+1
View File
@@ -0,0 +1 @@
{"login_success":200,"error":400,"welcome":200}
+1
View File
@@ -0,0 +1 @@
{"login_success":"success","welcome":"Hello #name#"}
+1
View File
@@ -0,0 +1 @@
这不是 json, 应当被忽略
+1
View File
@@ -0,0 +1 @@
{"login_success":"成功","welcome":"你好 #name#"}
+1
View File
@@ -0,0 +1 @@
{"bye":"再见"}