refactor!: 错误值改为不可变,修复并发崩溃与兜底缺陷

BREAKING CHANGE: 相对 v0.0.1 的 API 变更
- 删除全部 Set* 方法,改为返回新值的 With*;错误值不可变,包级常量可并发使用
- 错误值不再持有 context,只保存语言标识(WithLang),渲染时再传 ctx
- 删除兼容别名 Errf / ErrfKV / Msgf / NewLanguage / NewStruct 与空操作 Copy()
- Msgf 改为 MsgWith,NewErrorf 改为 NewErrorWith;ErrorLanguage 不再实现 ErrorInterface

修复
- Copy 浅拷贝共享 format map,并发下 fatal error: concurrent map writes
- 包级常量被 Set* 污染,请求之间互相改写错误文案
- langx.RegisterCode / RegisterTrans 覆盖注册表后,运行时错误码退化为默认码、文案退化为裸 key
- 未注册 key 静默返回默认码 + 裸 key,现返回 500 + 未注册提示
- NewError(nil, ...) 与非 string 的 Accept-Language 触发 panic

新增
- Wrap / Unwrap / errors.Is 按 key 匹配、调用栈(惰性解析)、%+v 输出 cause 与栈
- 错误常量自带默认文案兜底 + errorx 侧常量注册表(不依赖 langx 可变状态)
- 根 README、3 个可运行示例、17 个断言测试(语句覆盖率 97.2%)、基准测试

依赖
- github.com/yuninks/langx v0.0.7 -> v0.1.0
This commit is contained in:
yun
2026-09-21 00:43:43 +08:00
parent 8eaa8a6933
commit 21293673e1
13 changed files with 1519 additions and 251 deletions
-49
View File
@@ -1,49 +0,0 @@
package main
import (
"context"
"github.com/yuninks/errorx"
"github.com/yuninks/langx"
)
func main() {
err := ErrorWithMsg.Error()
// 输出:错误
println(err.Error())
err = ErrorWithMsg.Errorf(map[string]string{"msg": "错误"})
// 输出:错误
println(err.Error())
}
type Language string
// 添加key+默认语言
func newLanguage(uniKey string, code int, defaultValue string) Language {
langx.AppendCode(map[string]int{uniKey: code})
langx.AppendTrans("zh_hans", map[string]string{uniKey: defaultValue})
return Language(uniKey)
}
func (l Language) String() string {
return string(l)
}
func (l Language) Error() error {
return errorx.NewError(context.Background(), l.String())
}
func (l Language) Errorf(format map[string]string) error {
return errorx.NewErrorf(context.Background(), l.String(), format)
}
var (
Success Language = newLanguage("success", 200, "成功")
Error Language = newLanguage("error", 400, "错误")
ErrorWithMsg Language = newLanguage("error_with_msg", 400, "错误 #msg#")
)
+109
View File
@@ -0,0 +1,109 @@
// Package main 演示在 HTTP 服务里使用 errorx:
// 业务层只管 return error,出口统一翻译成 {code, msg}。
//
// 运行:go run ./example/http
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/yuninks/errorx"
"github.com/yuninks/langx"
)
var (
ErrParamInvalid = errorx.New("param_invalid", 10002, "参数错误: #field#")
ErrUserNotFound = errorx.New("user_not_found", 10001, "用户不存在")
ErrDB = errorx.New("db_error", 50001, "数据库繁忙")
)
func init() {
langx.AppendTrans("en", map[string]string{
"param_invalid": "invalid parameter: #field#",
"user_not_found": "user not found",
"db_error": "database is busy",
})
}
// Response 是对外的统一响应体
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// 业务层:只返回 error,完全不关心 HTTP 与语言
func loadUser(ctx context.Context, id string) (string, error) {
switch id {
case "":
return "", ErrParamInvalid.ErrKV("field", "id")
case "404":
return "", ErrUserNotFound.Err()
case "500":
return "", ErrDB.Wrap(errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"))
default:
return "用户-" + id, nil
}
}
// 出口层:把 error 统一翻译成响应
func userHandler(w http.ResponseWriter, r *http.Request) {
ctx := withLang(r)
name, err := loadUser(ctx, r.URL.Query().Get("id"))
if err != nil {
writeJSON(w, toResponse(ctx, err))
return
}
writeJSON(w, Response{Code: errorx.Success.GetCode(), Msg: name})
}
// withLang 把请求头里的语言放进 ctx。
// langx 约定从 ctx 里读 "Accept-Language",也可以自己用 langx.SetCtxLang 指定。
func withLang(r *http.Request) context.Context {
return context.WithValue(r.Context(), "Accept-Language", r.Header.Get("Accept-Language"))
}
// toResponse 是所有 error 的唯一出口:errorx 错误取业务码,其它错误按 500 处理
func toResponse(ctx context.Context, err error) Response {
var ei errorx.ErrorInterface
if errors.As(err, &ei) {
return Response{Code: ei.GetCode(), Msg: ei.Msg(ctx)}
}
return Response{Code: errorx.ErrInternal.GetCode(), Msg: errorx.ErrInternal.Msg(ctx)}
}
func writeJSON(w http.ResponseWriter, resp Response) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
}
func main() {
fmt.Println("=== 同一个接口,不同入参与语言 ===")
cases := []struct{ id, lang string }{
{"", "zh"},
{"404", "zh"},
{"404", "en"},
{"500", "en"},
{"42", "zh"},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodGet, "/user?id="+c.id, nil)
r.Header.Set("Accept-Language", c.lang)
w := httptest.NewRecorder()
userHandler(w, r)
// 统一返回 HTTP 200,业务状态放在 code 字段(国内常见约定),
// 需要真实 HTTP 状态码时,可以在 toResponse 里按 code 映射。
fmt.Printf("GET /user?id=%-4s [%s] -> HTTP %d %s", c.id, c.lang, w.Code, w.Body.String())
}
fmt.Println()
fmt.Println("=== 日志里打印完整信息(带 cause 和调用栈)===")
r := httptest.NewRequest(http.MethodGet, "/user?id=500", nil)
_, err := loadUser(withLang(r), "500")
fmt.Printf("%+v", err)
fmt.Println()
}
+79 -29
View File
@@ -1,54 +1,104 @@
// Package main 演示 langx 的三种资源加载方式,以及它们和 errorx 的配合。
//
// 运行:go run ./example/imports
package main
import (
"context"
"embed"
"fmt"
"os"
"github.com/yuninks/errorx"
"github.com/yuninks/langx"
)
//go:embed lang
var assetsFs embed.FS
// errorx 定义的错误常量:code 与默认文案都跟着常量走
var (
ErrSuccess = errorx.New("success", 200, "操作成功")
ErrError = errorx.New("error", 400, "操作失败")
)
func main() {
regByAppend()
regByEmbed()
regByDir()
withErrorx()
}
// 导入语言包 基于Append
// 1) Append:零散追加,不会覆盖已有资源,推荐业务代码使用
func regByAppend() {
langx.AppendCode(map[string]int{
"success": 200,
})
langx.AppendTrans("zh-CN", map[string]string{
"success": "成功",
})
code, msg := langx.GetTransFormat("zh-CN", "success", map[string]string{})
fmt.Println(code, msg)
fmt.Println("=== 1) Append:追加注册 ===")
langx.AppendCode(map[string]int{"append_key": 1001})
langx.AppendTrans("zh", map[string]string{"append_key": "追加的错误"})
fmt.Println("zh:", trans("zh", "append_key", nil))
fmt.Println("en:", trans("en", "append_key", nil), "(en 没有,回退到默认语言)")
}
// 导入语言包 基于Embed
// 2) Embed://go:embed 把 lang 目录打进二进制
func regByEmbed() {
err := langx.RegisterEmbed(assetsFs)
fmt.Println(err)
code, msg := langx.GetTransFormat("zh", "success", map[string]string{})
fmt.Println(code, msg)
code, msg = langx.GetTransFormat("en", "error", map[string]string{
"msg": "这是失败的原因",
})
fmt.Println(code, msg)
fmt.Println()
fmt.Println("=== 2) Embed:打包资源 ===")
if err := langx.RegisterEmbed(assetsFs); err != nil {
fmt.Println("RegisterEmbed:", err)
return
}
fmt.Println("zh:", trans("zh", "success", nil))
fmt.Println("en:", trans("en", "error", map[string]string{"msg": "disk full"}))
fmt.Println("第 1 步的 append_key 仍在:", trans("zh", "append_key", nil), "(RegisterEmbed 是合并,不覆盖)")
}
// 导入语言包 基于文件
// 3) Dir:运行时从目录读取,适合运维改文案不用重新编译
func regByDir() {
langx.RegisterDir("./lang")
code, msg := langx.GetTransFormat("zh", "success", map[string]string{})
fmt.Println(code, msg)
code, msg = langx.GetTransFormat("en", "error", map[string]string{
"msg": "这是失败的原因",
})
fmt.Println(code, msg)
fmt.Println()
fmt.Println("=== 3) Dir:读取目录 ===")
dir := langDir()
if err := langx.RegisterDir(dir); err != nil {
fmt.Println("RegisterDir:", err)
return
}
fmt.Println("zh:", trans("zh", "success", nil), "(dir =", dir+")")
}
// 4) 与 errorx 组合。
// langx 有两种注册语义(v0.1.0 起):
// - RegisterCode / RegisterTrans:整体替换对应注册表
// - RegisterEmbed / RegisterDir / RegisterFS:按键合并,不动已有资源
//
// 上面用的是后者,所以 errorx 在 init 阶段追加的内置资源还在。
// 即使用户改用 RegisterCode/RegisterTrans 覆盖掉,错误常量自带 code 与默认文案,
// 也不会退化成裸 key —— code 永远来自常量自身。
func withErrorx() {
fmt.Println()
fmt.Println("=== 4) 与 errorx 组合 ===")
ctx := context.Background()
enCtx := langx.SetCtxLang(ctx, "en")
// code 来自错误常量自身,msg 优先用 langx 资源里的文案
fmt.Println("code :", ErrError.Err().GetCode())
fmt.Println("zh(资源优先) :", ErrError.ErrKV("msg", "磁盘满了").Msg(ctx))
fmt.Println("en(资源优先) :", ErrError.ErrKV("msg", "disk full").Msg(enCtx))
fmt.Println("资源里的 key :", errorx.NewError(ctx, "error"))
fmt.Println("成功 :", ErrSuccess.Err().GetCode(), ErrSuccess.Msg(ctx))
// error_with_msg 没有写进资源文件,回退到错误常量自带的默认文案
fmt.Println("en(未注册回退):", errorx.ErrWithMsg.ErrKV("msg", "disk full").Msg(enCtx))
}
func trans(lang, key string, format map[string]string) string {
code, msg := langx.GetTransFormat(lang, key, format)
return fmt.Sprintf("[%d] %s", code, msg)
}
// langDir 兼容两种运行方式:仓库根目录 go run ./example/imports,或进入目录 go run .
func langDir() string {
for _, p := range []string{"lang", "example/imports/lang"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "example/imports/lang"
}
+27 -4
View File
@@ -1,7 +1,30 @@
# 导入资源
# langx 资源加载示例
# 通过embed导入
本目录演示 langx 的三种资源加载方式,以及它们与 errorx 的配合。
# 通过文件导入
| 方式 | 函数 | 语义 |
| --- | --- | --- |
| 追加 | `langx.AppendCode` / `langx.AppendTrans` | 按键合并,**业务代码推荐** |
| 打包 | `langx.RegisterEmbed(fs)` | 按键合并,用 //go:embed 把 lang 目录打进二进制,随程序发布 |
| 目录 | `langx.RegisterDir(dir)` | 按键合并,运行时读目录,改文案不用重新编译 |
# 追加导入
## 资源文件格式
- `code.json`:`{"key": 业务码}`
- `zh.json` / `en.json`:`{"key": "文案 #占位符#"}`
- 子目录的第一段是语言名:`zh/common.json`、`zh/errors.json` 都会并入 `zh`
## 注意两种注册语义(langx v0.1.0 起)
- `RegisterCode` / `RegisterTrans`:**整体替换**对应的注册表,会冲掉 `errorx.New` 在 init 阶段追加的内置文案;
- `RegisterEmbed` / `RegisterDir` / `RegisterFS`:**按键合并**,不影响已经注册的资源。
即使被整体替换,errorx 的错误常量自带 code 与默认文案,查不到时会自动回填,
所以 `errorx.Error.Err().GetCode()` 永远是 400、文案永远可读,不会退化成裸 key。
但如果直接用 `langx.GetTransFormat` 读这些 key,就会看到裸 key。
运行:
```bash
go run ./example/imports
```
+110
View File
@@ -0,0 +1,110 @@
// Package main 演示 errorx 的基础用法。
//
// 运行:go run ./example
package main
import (
"context"
"errors"
"fmt"
"github.com/yuninks/errorx"
"github.com/yuninks/langx"
)
// 1) 定义错误常量:key + 业务码 + 默认文案。
// 占位符写成 #name#,渲染的时候再替换。
var (
ErrUserNotFound = errorx.New("user_not_found", 10001, "用户不存在")
ErrParamInvalid = errorx.New("param_invalid", 10002, "参数错误: #field#")
ErrDB = errorx.New("db_error", 50001, "数据库繁忙")
)
func init() {
// 2) 补充其它语言的文案。
// errorx.New 已经把默认文案注册进默认语言(langx 默认是 zh),这里只补 en。
langx.AppendTrans("en", map[string]string{
"user_not_found": "user not found",
"param_invalid": "invalid parameter: #field#",
"db_error": "database is busy",
})
}
func main() {
demoBasic()
demoFormat()
demoI18n()
demoWrap()
demoStack()
}
func demoBasic() {
fmt.Println()
fmt.Println("=== 1. 定义与取值 ===")
e := ErrUserNotFound.Err() // Err() 返回 ErrorInterface,它本身就是 error
fmt.Println("error :", e)
fmt.Println("GetCode() :", e.GetCode())
fmt.Println("GetKey() :", e.GetKey())
// 错误常量本身也实现了 error,可以直接 return / 打印
var raw error = ErrUserNotFound
fmt.Println("常量当error:", raw)
}
func demoFormat() {
fmt.Println()
fmt.Println("=== 2. 占位符 ===")
fmt.Println("ErrKV :", ErrParamInvalid.ErrKV("field", "age"))
fmt.Println("ErrWith :", ErrParamInvalid.ErrWith(map[string]string{"field": "name"}))
fmt.Println("MsgKV :", ErrParamInvalid.MsgKV(context.Background(), "field", "phone"))
// 错误值是不可变的:派生不会影响错误常量,也不会影响别的请求
derived := ErrParamInvalid.ErrKV("field", "age")
fmt.Println("常量未被改:", ErrParamInvalid)
fmt.Println("派生值 :", derived)
}
func demoI18n() {
fmt.Println()
fmt.Println("=== 3. 多语言 ===")
ctx := context.Background()
enCtx := langx.SetCtxLang(ctx, "en") // 也可以由 HTTP 头 Accept-Language 决定
fmt.Println("zh :", ErrUserNotFound.Msg(ctx))
fmt.Println("en :", ErrUserNotFound.Msg(enCtx))
fmt.Println("固定 en :", ErrUserNotFound.Err().WithLang("en"))
fmt.Println("未注册语言回退:", ErrUserNotFound.Msg(langx.SetCtxLang(ctx, "ja")))
}
func demoWrap() {
fmt.Println()
fmt.Println("=== 4. 包装底层错误 ===")
sqlErr := errors.New("dial tcp 127.0.0.1:3306: connect: connection refused")
err := ErrDB.Wrap(sqlErr)
fmt.Println("对外文案 :", err) // 只暴露业务文案,不泄露底层细节
fmt.Println("errors.Is :", errors.Is(err, ErrDB))
fmt.Println("errors.Is :", errors.Is(err, sqlErr))
fmt.Println("底层错误 :", errors.Unwrap(err))
var ei errorx.ErrorInterface
if errors.As(err, &ei) {
fmt.Println("errors.As :", ei.GetCode())
}
}
func demoStack() {
fmt.Println()
fmt.Println("=== 5. 调用栈(错误日志输出)===")
err := loadConfig()
fmt.Printf("%+v", err)
fmt.Println()
}
func loadConfig() error {
return errorx.ErrInternal.Wrap(errors.New("config.yaml: no such file or directory"))
}