多语言翻译

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
+274 -233
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{
ops: defaultOptions(),
codeMap: make(map[string]int),
transMap: make(map[string]map[string]string),
mut: sync.RWMutex{},
}
var (
current atomic.Pointer[state]
writeMu sync.Mutex
)
// 这是语言的Code
func RegisterCode(datas map[string]int) {
l.mut.Lock()
defer l.mut.Unlock()
if datas == nil {
datas = make(map[string]int)
}
l.codeMap = datas
}
// 追加覆盖Code
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{}
}
for k, v := range datas {
l.codeMap[k] = v
func newState() *state {
return &state{
ops: defaultOptions(),
code: map[string]int{},
trans: map[string]map[string]string{},
}
}
// 注册语言翻译
func RegisterTrans(langName string, trans map[string]string) {
l.mut.Lock()
defer l.mut.Unlock()
l.transMap[langName] = trans
func init() {
current.Store(newState())
}
// 追加覆盖翻译
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
}
// 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()))
}
// 直接读取文件夹获取配置
// 要求:
// 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, "")
// 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
}
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)
}
}
current.Store(next)
return nil
}
type fileData struct {
path string
datas []byte
// ---- 快照构造: 只创建新 map, 绝不修改已经发布的 map ----
func setOptions(s *state, ops options) *state {
return &state{ops: ops, code: s.code, trans: s.trans}
}
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
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
}
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 next
}
return files, nil
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
}
// 获取翻译
// 包含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
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) {
update(func(old *state) *state { return setCodes(old, datas, false) })
}
// AppendCode 追加响应码, 已存在的 key 会被覆盖。
func AppendCode(datas map[string]int) {
update(func(old *state) *state { return setCodes(old, datas, true) })
}
// RegisterTrans 整体替换某个语言的译文, 传 nil 表示清空该语言。
func RegisterTrans(langName string, trans map[string]string) {
update(func(old *state) *state { return setTrans(old, langName, trans, false) })
}
// AppendTrans 追加某个语言的译文, 已存在的 key 会被覆盖。
func AppendTrans(langName string, trans map[string]string) {
update(func(old *state) *state { return setTrans(old, langName, trans, true) })
}
// ---- 读取 ----
// 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)
}
// 获取翻译
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
}
}
// 找默认语言
str, ok = l.transMap[l.ops.defaultLang]
if ok {
val, ok := str[key]
if ok {
return val
}
}
// 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 {
s := load()
if v, ok := findMsg(s, lang, key); ok {
return v
}
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)
}
// 获取默认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
// notifyMissing 在锁外回调用户注册的缺失处理器
func notifyMissing(s *state, lang, key string) {
if s.ops.missing != nil {
s.ops.missing(lang, key)
}
return lang
}
// normalizeLang 归一化语言名: 去首尾空白并转小写
func normalizeLang(lang string) string {
return strings.ToLower(strings.TrimSpace(lang))
}