# langx 轻量的 Go 多语言响应库:注册响应码与多语言译文,按语言(支持区域回退)取值,并支持占位符替换。零第三方依赖,`go 1.19` 起可用。 ```go code, msg := langx.GetTransFormatCtx(ctx, "login_success", map[string]string{"name": "张三"}) // code = 200, msg = "你好 张三, 登录成功" ``` ## 特性 - **资源来源灵活**:`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 文件,需要仓库作者确定许可协议后再补充。