From 21293673e17f1f68dbd32e7aa66b1b09b85bd3eb Mon Sep 17 00:00:00 2001 From: Yun Date: Mon, 21 Sep 2026 00:43:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor!:=20=E9=94=99=E8=AF=AF=E5=80=BC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=8D=E5=8F=AF=E5=8F=98=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=B9=B6=E5=8F=91=E5=B4=A9=E6=BA=83=E4=B8=8E=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 256 +++++++++++++++++++++++ bench_test.go | 48 +++++ error_test.go | 421 ++++++++++++++++++++++++++++++++++---- errorx.go | 292 ++++++++++++++++++++------ example/enample2.go | 49 ----- example/http/main.go | 109 ++++++++++ example/imports/main.go | 108 +++++++--- example/imports/readme.md | 31 ++- example/main.go | 110 ++++++++++ go.mod | 2 +- go.sum | 4 +- interfaces.go | 266 +++++++++++++++++------- stack.go | 74 +++++++ 13 files changed, 1519 insertions(+), 251 deletions(-) create mode 100644 README.md create mode 100644 bench_test.go delete mode 100644 example/enample2.go create mode 100644 example/http/main.go create mode 100644 example/main.go create mode 100644 stack.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..381fa4f --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +# errorx + +把「业务错误码 + 多语言文案 + 占位符」封装成**不可变**的错误常量,并补齐 Go 错误处理的常用能力。 + +```go +var ErrUserNotFound = errorx.New("user_not_found", 10001, "用户不存在") + +err := ErrUserNotFound.Wrap(sql.ErrNoRows) // 保留底层错误,不泄露给用户 +fmt.Println(err.GetCode(), err.Msg(ctx)) // 10001 用户不存在 +fmt.Println(errors.Is(err, ErrUserNotFound), errors.Is(err, sql.ErrNoRows)) // true true +``` + +## 特性 + +| 能力 | 说明 | +| --- | --- | +| 一行定义 | `errorx.New(key, code, defaultValue)` | +| 多语言 | 文案交给 langx,支持 ctx 传语言、固定语言、未注册语言回退 | +| 并发安全 | 错误值不可变,包级常量可被任意 goroutine 并发使用 | +| 不丢上下文 | `Wrap` 保留底层错误,`errors.Is / As / Unwrap` 全部可用 | +| 可排查 | 创建错误时抓调用栈,日志用 `%+v` 直接打印原因与栈 | +| 不伪装成功 | 未注册的 key 返回 500 + 原始 key,绝不返回 langx 的默认码 200 | + +## 安装 + +```bash +go get github.com/yuninks/errorx +``` + +## 快速开始 + +```go +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#") +) + +func init() { + // 2) 补充其它语言的文案;默认语言(zh)的文案 errorx.New 已经注册好了 + langx.AppendTrans("en", map[string]string{ + "user_not_found": "user not found", + "param_invalid": "invalid parameter: #field#", + }) +} + +// 业务层:只管 return error +func loadUser(ctx context.Context, id string) error { + if id == "" { + return ErrParamInvalid.ErrKV("field", "id") + } + if id == "404" { + return ErrUserNotFound.Wrap(errors.New("sql: no rows in result set")) + } + return nil +} + +func main() { + ctx := langx.SetCtxLang(context.Background(), "en") + err := loadUser(ctx, "") + + var ei errorx.ErrorInterface + if errors.As(err, &ei) { + fmt.Println(ei.GetCode(), ei.Msg(ctx)) // 10002 invalid parameter: id + } + fmt.Println(errors.Is(err, ErrParamInvalid)) // true +} +``` + +更多可运行示例: + +```bash +go run ./example # 基础用法:定义、占位符、多语言、包装、堆栈 +go run ./example/http # HTTP 服务:业务层 return error,出口统一转 {code, msg} +go run ./example/imports # langx 资源加载的三种方式与 errorx 的配合 +``` + +## 核心概念 + +- **ErrorLanguage**:错误常量,`errorx.New` 的返回值。不可变的值类型,可直接作为包级变量。 + 它本身实现了 `error`,所以 `var err error = ErrUserNotFound` 成立,`errors.Is(err, ErrUserNotFound)` 按 key 比较。 +- **ErrorInterface**:错误值接口,`Err() / ErrWith() / Wrap()` 返回它;它本身就是 `error`,取 code 不需要类型断言。 + +### 错误值是不可变的 + +所有 `WithXxx` 都返回新值,`GetFormat()` 返回副本,派生不会影响原值,也不需要加锁: + +```go +base := ErrParamInvalid.Err() +a := base.WithFormatKV("field", "age") +b := base.WithFormatKV("field", "name") + +fmt.Println(a, b, base) +// 参数错误: age / 参数错误: name / 参数错误: #field# +``` + +> 旧版的 `SetFormat / SetFormatKV / SetLang / SetCtx` 已删除:它们会直接修改共享的错误常量, +> 并发场景下会 `fatal error: concurrent map writes`,并且会让不同请求之间互相改写文案。 + +### 渲染规则 + +`Msg(ctx)` 依次尝试:固定语言(`WithLang`)→ ctx 里的语言 → 默认语言 → 错误常量自带的默认文案(回填进 langx)。 +任何一步失败都不会 panic,也不会返回裸 key;ctx 为 nil 或 `Accept-Language` 不是字符串同样安全。 + +## API 速查 + +**定义错误常量** + +| 函数 | 说明 | +| --- | --- | +| `errorx.New(key, code, defaultValue)` | 定义错误常量,key 为空会 panic | + +**构造错误值** + +| 方法 | 说明 | +| --- | --- | +| `Err()` | 生成错误值 | +| `ErrWith(map)` / `ErrKV(k, v, ...)` | 带占位符数据 | +| `Wrap(cause)` | 包装底层错误,文案里不会出现它 | +| `errorx.NewError(ctx, key)` | 从 langx 资源构造(code 与文案都来自资源) | +| `errorx.NewErrorWith(ctx, key, format)` | 同上,带占位符 | + +**读取与渲染** + +| 方法 | 说明 | +| --- | --- | +| `GetCode() / GetKey() / GetDefault() / GetFormat() / GetLang()` | 只读访问器 | +| `Msg(ctx) / MsgWith(ctx, map) / MsgKV(ctx, k, v)` | 渲染文案 | +| `Unwrap() / StackTrace()` | 底层错误 / 调用栈 | + +**派生(返回新值)**:`WithFormat` / `WithFormatKV` / `WithLang` / `Wrap` + +> 命名只有两种后缀:`With(map)` 与 `KV(k, v, ...)`。常量上的 `Err*` 只负责"造错误",造出来的值用 `With*` 派生。 + +**内置常量**:`Success`(200) / `Error`(400) / `ErrWithMsg`(400) / `ErrInternal`(500) / `ErrUnregistered`(500) + +## 多语言 + +- **语言来源**:`langx.SetCtxLang(ctx, "en")`,或 ctx 里的 `Accept-Language`(langx 约定),见 `example/http`。 +- **资源加载**:`AppendCode / AppendTrans`(追加)、`RegisterEmbed`(打包)、`RegisterDir`(目录),见 `example/imports`。 + +> **注意两种注册语义(langx v0.1.0 起):** +> - `RegisterCode` / `RegisterTrans`:**整体替换**对应的注册表,会把 `errorx.New` 在 init 阶段追加的内置文案冲掉; +> - `RegisterEmbed` / `RegisterDir` / `RegisterFS`:**按键合并**,不影响已注册的资源。 +> +> 即使被整体替换,错误常量自带 code 与默认文案,查不到时会自动回填,不会退化成裸 key; +> 但直接用 `langx.GetTransFormat` 读这些 key 仍会看到裸 key —— 业务代码建议统一用 `Append*`。 + +## 用资源文件定义错误 + +code 与文案都放在 json 资源里时,用 `NewError / NewErrorWith` 动态构造: + +```go +langx.AppendCode(map[string]int{"biz_fail": 1001}) +langx.AppendTrans("zh", map[string]string{"biz_fail": "业务失败: #msg#"}) + +err := errorx.NewErrorWith(ctx, "biz_fail", map[string]string{"msg": "磁盘满了"}) +fmt.Println(err.GetCode(), err) // 1001 业务失败: 磁盘满了 +``` + +key 打错时不会静默变成成功: + +```go +err := errorx.NewError(ctx, "typo_key") +fmt.Println(err.GetCode(), err) // 500 未注册的错误: typo_key +``` + +(langx 的 `GetCode` 对未知 key 返回默认码;errorx 用 `langx.HasCode` / `langx.Has` 精确判断, +key 没注册就返回 500,资源里只有文案没有 code 时同样用 500 兜底,不会出现"打错 key 反而成功"。) + +## 在 HTTP 服务里使用 + +```go +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)} +} +``` + +业务层只 `return error`,出口层统一翻译,见 `example/http`。 + +## 日志 + +```go +log.Printf("%+v", err) +// 数据库繁忙 +// cause: dial tcp 127.0.0.1:3306: connect: connection refused +// stack: +// main.loadUser +// /app/main.go:47 +``` + +## 并发 + +错误值不可变、包级常量没有共享可变状态,可以放心在任意 goroutine 使用: + +```go +go func() { _ = ErrUserNotFound.Wrap(err) }() +go func() { _ = ErrParamInvalid.ErrKV("field", "age").Msg(ctx) }() +``` + +> `errorx.New` 会写入 errorx 的常量注册表,建议只在包级变量或 `init` 中调用。 + +## 从旧版本迁移 + +| 旧写法 | 新写法 | +| --- | --- | +| `val.SetLang("en")` | `val = val.WithLang("en")` | +| `val.SetFormat(m)` | `val = val.WithFormat(m)` | +| `val.SetFormatKV(k, v)` | `val = val.WithFormatKV(k, v)` | +| `val.SetCtx(ctx)` | `val = val.WithLang(langx.GetCtxLang(ctx))` | +| `ErrorWithMsg.Errf(m)` | `ErrWith(m)` | +| `ErrorWithMsg.ErrfKV(k, v)` | `ErrKV(k, v)` | +| `ErrorWithMsg.Msgf(ctx, m)` | `MsgWith(ctx, m)` | +| `errorx.NewLanguage(k, c, d)` | `errorx.New(k, c, d)` | +| `errorx.NewStruct(ctx, k, m)` | `errorx.NewErrorWith(ctx, k, m)` | +| `errorx.NewErrorf(ctx, k, m)` | `errorx.NewErrorWith(ctx, k, m)` | + +行为变化: + +1. 所有 `Set*` 改为 `With*` 并返回新值;错误值不可变,不再有全局污染与并发崩溃。 +2. 错误值不再持有 context,只保存语言标识;渲染时把 ctx 传给 `Msg`。 +3. `Err() / ErrWith() / ErrKV()` 返回 `ErrorInterface`(本身就是 `error`),取 code 不再需要类型断言。 +4. `ErrorLanguage` 不再实现 `ErrorInterface`:常量的职责只有「定义 + 构造」,读取与派生都用 `Err()` 得到的值。 +5. 兼容别名 `Errf / ErrfKV / Msgf / NewLanguage / NewStruct` 与空操作 `Copy()` 已删除,不再保留。 +6. 未注册的 key 从「默认码 + 裸 key」变成「500 + 未注册提示」。 + +## 目录结构 + +``` +errorx.go 错误常量(ErrorLanguage)、New、从资源构造错误、内置常量 +interfaces.go ErrorInterface 接口与默认实现(不可变错误值、渲染、包装) +stack.go 调用栈采集与解析 +example/ 可运行示例 +``` + +## 测试 + +```bash +go test ./... +go test -race ./... # Windows 需要先装 gcc +``` diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..8354967 --- /dev/null +++ b/bench_test.go @@ -0,0 +1,48 @@ +package errorx_test + +import ( + "context" + "errors" + "testing" + + "github.com/yuninks/errorx" +) + +// 错误路径的性能基准。以下是在 Ryzen 7 5800H / go1.26 上的参考值: +// +// BenchmarkErr 370 ns/op 160 B/op 2 allocs/op (含一次调用栈采集) +// BenchmarkErrKV 825 ns/op 944 B/op 7 allocs/op +// BenchmarkWrap 543 ns/op 272 B/op 3 allocs/op +// BenchmarkMsg 133 ns/op 16 B/op 1 allocs/op (只渲染文案,不抓栈) +// +// 栈只在构造错误值时抓取一次,且只存程序计数器;函数名/文件/行号在调用 +// StackTrace() 或 %+v 时才解析。 +var benchErr = errorx.New("bench_err", 1, "基准错误: #a#") + +func BenchmarkErr(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = benchErr.Err() + } +} + +func BenchmarkErrKV(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = benchErr.ErrKV("a", "1") + } +} + +func BenchmarkWrap(b *testing.B) { + cause := errors.New("boom") + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = benchErr.Wrap(cause) + } +} + +func BenchmarkMsg(b *testing.B) { + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = benchErr.Msg(ctx) + } +} diff --git a/error_test.go b/error_test.go index 574ce0a..f9bfcec 100644 --- a/error_test.go +++ b/error_test.go @@ -2,58 +2,409 @@ package errorx_test import ( "context" + "errors" + "fmt" + "os" + "strings" + "sync" "testing" "github.com/yuninks/errorx" "github.com/yuninks/langx" ) -func TestError(t *testing.T) { - var err error - - ctx := context.Background() +// 测试用的错误常量:key + 业务码 + 默认文案(占位符用 #name#) +var ( + ErrUserNotFound = errorx.New("test_user_not_found", 10001, "用户不存在") + ErrParamInvalid = errorx.New("test_param_invalid", 10002, "参数错误: #field#") + ErrDB = errorx.New("test_db_error", 50001, "数据库繁忙") +) +func TestMain(m *testing.M) { langx.InitLangx( - langx.SetDefaultCode(0), langx.SetDefaultLanguage("zh"), + langx.SetDefaultCode(-1), // 故意设成 -1,用来验证未注册的 key 不会退化成默认码 ) - langx.RegisterCode(map[string]int{ - "login_success": 200, - "error": 400, - "username": 201, + langx.AppendTrans("en", map[string]string{ + "test_user_not_found": "user not found", + "test_param_invalid": "invalid parameter: #field#", + "test_db_error": "database is busy", }) - langx.RegisterTrans("zh", map[string]string{ - "login_success": "成功", - "error": "错误", - "username": "你好 #name#", // 有占位符 - }) - langx.RegisterTrans("en", map[string]string{ - "login_success": "success", - "error": "error", - "username": "Hello #name#", // 有占位符 - }) + os.Exit(m.Run()) +} - err = errorx.NewError(ctx, "error") - // fmt.Printf("err: %v\n", err) - t.Log(err.Error()) - val, ok := err.(errorx.ErrorInterface) - if ok { - t.Log(val.GetCode()) +func TestErrBasics(t *testing.T) { + e := ErrUserNotFound.Err() + + if got, want := e.GetCode(), 10001; got != want { + t.Fatalf("GetCode() = %d, want %d", got, want) + } + if got, want := e.GetKey(), "test_user_not_found"; got != want { + t.Fatalf("GetKey() = %q, want %q", got, want) + } + if got, want := e.GetDefault(), "用户不存在"; got != want { + t.Fatalf("GetDefault() = %q, want %q", got, want) + } + if got, want := e.Error(), "用户不存在"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + + // 错误常量本身也实现了 error 接口 + var err error = ErrUserNotFound + if got, want := err.Error(), "用户不存在"; got != want { + t.Fatalf("ErrorLanguage.Error() = %q, want %q", got, want) + } + + if got, want := ErrUserNotFound.GetCode(), 10001; got != want { + t.Fatalf("ErrorLanguage.GetCode() = %d, want %d", got, want) + } + if got, want := ErrUserNotFound.GetDefault(), "用户不存在"; got != want { + t.Fatalf("ErrorLanguage.GetDefault() = %q, want %q", got, want) + } +} + +func TestPlaceholder(t *testing.T) { + cases := []struct { + name string + got string + want string + }{ + {"ErrKV", ErrParamInvalid.ErrKV("field", "age").Error(), "参数错误: age"}, + {"ErrWith", ErrParamInvalid.ErrWith(map[string]string{"field": "name"}).Error(), "参数错误: name"}, + {"Err().WithFormatKV", ErrParamInvalid.Err().WithFormatKV("field", "id").Error(), "参数错误: id"}, + {"MsgKV", ErrParamInvalid.MsgKV(context.Background(), "field", "phone"), "参数错误: phone"}, + {"MsgWith", ErrParamInvalid.MsgWith(context.Background(), map[string]string{"field": "mail"}), "参数错误: mail"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestMultiLanguage(t *testing.T) { + ctx := context.Background() + enCtx := langx.SetCtxLang(ctx, "en") + + if got, want := ErrUserNotFound.Msg(ctx), "用户不存在"; got != want { + t.Fatalf("中文 Msg = %q, want %q", got, want) + } + if got, want := ErrUserNotFound.Msg(enCtx), "user not found"; got != want { + t.Fatalf("英文 Msg = %q, want %q", got, want) + } + if got, want := ErrParamInvalid.MsgKV(enCtx, "field", "age"), "invalid parameter: age"; got != want { + t.Fatalf("英文占位符 = %q, want %q", got, want) + } + // 固定语言:不再跟随 ctx + err := ErrUserNotFound.Err().WithLang("en") + if got, want := err.Msg(ctx), "user not found"; got != want { + t.Fatalf("WithLang 后 Msg = %q, want %q", got, want) + } + if got, want := err.Error(), "user not found"; got != want { + t.Fatalf("WithLang 后 Error = %q, want %q", got, want) + } + // 未注册的语言回退到默认语言,而不是吐裸 key + if got, want := ErrUserNotFound.Msg(langx.SetCtxLang(ctx, "ja")), "用户不存在"; got != want { + t.Fatalf("未注册语言 = %q, want %q", got, want) + } +} + +func TestImmutability(t *testing.T) { + base := ErrParamInvalid.Err() + derived := base.WithFormatKV("field", "age") + + if got, want := base.Error(), "参数错误: #field#"; got != want { + t.Fatalf("派生后原错误被修改:%q, want %q", got, want) + } + if got, want := derived.Error(), "参数错误: age"; got != want { + t.Fatalf("派生错误 = %q, want %q", got, want) + } + if got, want := ErrParamInvalid.Error(), "参数错误: #field#"; got != want { + t.Fatalf("全局常量被污染:%q, want %q", got, want) + } + + // GetFormat 返回副本,外部修改不影响错误本身 + f := derived.GetFormat() + f["field"] = "hacked" + if got, want := derived.Error(), "参数错误: age"; got != want { + t.Fatalf("GetFormat 泄漏了内部 map:%q, want %q", got, want) + } + + // WithLang / WithFormat 不互相影响 + a := base.WithLang("en") + b := base.WithFormatKV("field", "x") + if a.GetLang() != "en" || b.GetLang() != "" { + t.Fatalf("派生值互相污染:a.lang=%q b.lang=%q", a.GetLang(), b.GetLang()) + } +} + +func TestConcurrentUse(t *testing.T) { + // 同一个错误值被多个 goroutine 共享:旧版本要么 fatal: concurrent map writes, + // 要么不同请求之间互相改写文案。 + base := ErrParamInvalid.Err() + const workers, rounds = 32, 50 + + var wg sync.WaitGroup + bad := make(chan string, workers) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for r := 0; r < rounds; r++ { + v := fmt.Sprintf("%d-%d", w, r) + if got, want := base.WithFormatKV("field", v).Error(), "参数错误: "+v; got != want { + bad <- fmt.Sprintf("派生错误被串改: got %q, want %q", got, want) + return + } + if got, want := base.GetFormat()["field"], ""; got != want { + bad <- fmt.Sprintf("原错误被派生值污染: got %q, want %q", got, want) + return + } + if got, want := ErrUserNotFound.Error(), "用户不存在"; got != want { + bad <- fmt.Sprintf("全局常量被改写: got %q, want %q", got, want) + return + } + } + }(w) + } + wg.Wait() + close(bad) + for msg := range bad { + t.Fatal(msg) + } +} + +func TestUnknownKeyIsNotSuccess(t *testing.T) { + e := errorx.NewError(context.Background(), "totally_unknown_key") + + if got, want := e.GetCode(), errorx.ErrUnregistered.GetCode(); got != want { + t.Fatalf("未注册 key 的 code = %d, want %d", got, want) + } + if e.GetCode() == 200 { + t.Fatal("未注册的 key 被伪装成了成功(200)") + } + if !strings.Contains(e.Error(), "totally_unknown_key") { + t.Fatalf("未注册 key 的文案应包含原始 key,实际 %q", e.Error()) + } + // 空 key 同样按未注册处理 + if got, want := errorx.NewError(context.Background(), "").GetCode(), errorx.ErrUnregistered.GetCode(); got != want { + t.Fatalf("空 key 的 code = %d, want %d", got, want) + } +} + +func TestRegistryOverwriteKeepsBuiltin(t *testing.T) { + // 用户习惯用 Register*,它会整体替换 langx 的注册表 + langx.RegisterCode(map[string]int{"biz_code": 500}) + langx.RegisterTrans("zh", map[string]string{"biz_code": "业务错误"}) + defer func() { + langx.AppendCode(map[string]int{"error": 400, "success": 200, "error_with_msg": 400}) + langx.AppendTrans("zh", map[string]string{ + "error": "操作失败", "success": "操作成功", "error_with_msg": "操作失败: #msg#", + }) + }() + + // code 来自错误常量自身,不会被清空 + if got, want := errorx.Error.Err().GetCode(), 400; got != want { + t.Fatalf("内置错误 code = %d, want %d(旧版本会变成 200)", got, want) + } + // 文案用错误常量自带的默认值兜底,不会退化成裸 key + if got, want := errorx.Error.Err().Error(), "操作失败"; got != want { + t.Fatalf("内置错误文案 = %q, want %q", got, want) + } + if got, want := errorx.ErrWithMsg.ErrKV("msg", "超时").Error(), "操作失败: 超时"; got != want { + t.Fatalf("内置错误占位符 = %q, want %q", got, want) + } + // 用户仍然可以用资源里的 key + if got, want := errorx.NewError(context.Background(), "biz_code").GetCode(), 500; got != want { + t.Fatalf("资源 key 的 code = %d, want %d", got, want) } +} - err = errorx.NewErrorf(ctx, "username", map[string]string{ - "name": "yuninks", - }) - t.Log(err.Error()) - val, ok = err.(errorx.ErrorInterface) - if ok { - t.Log(val.GetCode()) +func TestNilAndDirtyCtx(t *testing.T) { + if got, want := ErrUserNotFound.Msg(nil), "用户不存在"; got != want { + t.Fatalf("nil ctx = %q, want %q", got, want) + } + // langx 会对非 string 的 ctx 值做强制断言,errorx 必须兜住 + dirty := context.WithValue(context.Background(), "Accept-Language", 123) + if got, want := ErrUserNotFound.Msg(dirty), "用户不存在"; got != want { + t.Fatalf("脏 ctx = %q, want %q", got, want) + } + if got, want := errorx.NewError(nil, "test_user_not_found").Error(), "用户不存在"; got != want { + t.Fatalf("nil ctx 动态构造 = %q, want %q", got, want) + } +} - // 设置输出语言 - val.SetLang("en") +func TestErrorsIsAsUnwrap(t *testing.T) { + cause := errors.New("dial tcp 127.0.0.1:3306: connection refused") + err := ErrDB.Wrap(cause) + if !errors.Is(err, ErrDB) { + t.Fatal("errors.Is(err, ErrDB) = false, want true") + } + if !errors.Is(err, cause) { + t.Fatal("errors.Is 应该能穿透到底层错误") + } + if errors.Is(err, ErrUserNotFound) { + t.Fatal("不同 key 的错误不应相等") + } + if got := errors.Unwrap(err); got != cause { + t.Fatalf("Unwrap() = %v, want %v", got, cause) + } + var ei errorx.ErrorInterface + if !errors.As(err, &ei) { + t.Fatal("errors.As(err, &ErrorInterface) = false, want true") + } + if ei.GetCode() != 50001 { + t.Fatalf("errors.As 拿到的 code = %d", ei.GetCode()) + } + // 文案里不应出现底层错误的细节 + if strings.Contains(err.Error(), "connection refused") { + t.Fatalf("底层错误不应出现在对外文案里:%q", err.Error()) + } + // 常量在左边:错误常量直接当 error 返回时走 ErrorLanguage.Is + if !errors.Is(ErrUserNotFound, ErrUserNotFound.Err()) { + t.Fatal("errors.Is(常量, 错误值) = false, want true") + } + if errors.Is(ErrUserNotFound, ErrDB.Err()) { + t.Fatal("不同 key 的常量不应匹配") + } + if errors.Is(ErrUserNotFound, errors.New("plain")) { + t.Fatal("常量不应匹配非 errorx 的错误") } + // 非 errorx 的普通错误不应匹配 + if errors.Is(err, errors.New("boom")) { + t.Fatal("普通 error 不应匹配 errorx 错误") + } +} - t.Log(val.Error()) +func TestStackTrace(t *testing.T) { + st := ErrUserNotFound.Err().StackTrace() + if len(st) == 0 { + t.Fatal("StackTrace() 为空") + } + var found bool + for _, f := range st { + if strings.HasPrefix(f.Function, "github.com/yuninks/errorx.") { + t.Fatalf("栈里不应包含 errorx 自身帧:%s", f.Function) + } + if strings.Contains(f.Function, "TestStackTrace") { + found = true + } + } + if !found { + t.Fatal("栈里没有调用方帧") + } + if !strings.Contains(st.String(), "error_test.go") { + t.Fatalf("StackTrace.String() 内容异常:%s", st.String()) + } + // 空栈渲染为空串,不 panic + var empty errorx.StackTrace + if got := empty.String(); got != "" { + t.Fatalf("空 StackTrace.String() = %q, want \"\"", got) + } + var f errorx.Frame + if !strings.Contains(f.String(), ":0") { + t.Fatalf("Frame.String() = %q", f.String()) + } +} + +func TestFormatVerb(t *testing.T) { + err := ErrDB.Wrap(errors.New("boom")) + if got, want := fmt.Sprintf("%v", err), "数据库繁忙"; got != want { + t.Fatalf("%%v = %q, want %q", got, want) + } + if got, want := fmt.Sprintf("%s", err), "数据库繁忙"; got != want { + t.Fatalf("%%s = %q, want %q", got, want) + } + if got, want := fmt.Sprintf("%q", err), "\"数据库繁忙\""; got != want { + t.Fatalf("%%q = %q, want %q", got, want) + } + plus := fmt.Sprintf("%+v", err) + if !strings.Contains(plus, "cause: boom") || !strings.Contains(plus, "stack:") { + t.Fatalf("%%+v 应包含 cause 与 stack,实际:%s", plus) + } +} + +func TestFromRegistry(t *testing.T) { + langx.AppendCode(map[string]int{"test_registry_key": 777}) + langx.AppendTrans("zh", map[string]string{"test_registry_key": "注册表错误: #msg#"}) + + e := errorx.NewErrorWith(context.Background(), "test_registry_key", map[string]string{"msg": "磁盘满了"}) + if got, want := e.GetCode(), 777; got != want { + t.Fatalf("code = %d, want %d", got, want) + } + if got, want := e.Error(), "注册表错误: 磁盘满了"; got != want { + t.Fatalf("msg = %q, want %q", got, want) + } + // 构造时带上 ctx 语言 + enCtx := langx.SetCtxLang(context.Background(), "en") + langx.AppendTrans("en", map[string]string{"test_registry_key": "registry error: #msg#"}) + if got, want := errorx.NewErrorWith(enCtx, "test_registry_key", map[string]string{"msg": "full"}).Error(), "registry error: full"; got != want { + t.Fatalf("en msg = %q, want %q", got, want) + } +} + +func TestResolveBranches(t *testing.T) { + // 资源里只有文案、没有 code:保留文案,code 用 CodeUnknown 兜底 + langx.AppendTrans("zh", map[string]string{"test_msg_only": "只有文案"}) + e := errorx.NewError(context.Background(), "test_msg_only") + if got, want := e.GetCode(), errorx.CodeUnknown; got != want { + t.Fatalf("只有文案时 code = %d, want %d", got, want) + } + if got, want := e.Error(), "只有文案"; got != want { + t.Fatalf("只有文案时 msg = %q, want %q", got, want) + } + + // 资源里只有 code、没有文案:code 生效,文案退化为 key + langx.AppendCode(map[string]int{"test_code_only": 1234}) + e2 := errorx.NewError(context.Background(), "test_code_only") + if got, want := e2.GetCode(), 1234; got != want { + t.Fatalf("只有 code 时 code = %d, want %d", got, want) + } + if got, want := e2.Error(), "test_code_only"; got != want { + t.Fatalf("只有 code 时 msg = %q, want %q", got, want) + } +} + +func TestZeroValueAndEmptyKey(t *testing.T) { + var l errorx.ErrorLanguage + if l.GetCode() != 0 || l.GetKey() != "" { + t.Fatalf("零值访问器异常:code=%d key=%q", l.GetCode(), l.GetKey()) + } + var err error = l + if err.Error() == "" { + t.Fatal("零值错误也应该能安全渲染") + } + if l.Err().Error() == "" { + t.Fatal("零值错误常量也应该能安全渲染") + } +} + +func TestNewPanicsOnEmptyKey(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("空 key 应该 panic,避免注册出无法定位的错误") + } + }() + _ = errorx.New("", 1, "x") +} + +func TestNewErrorWith(t *testing.T) { + langx.AppendCode(map[string]int{"test_dynamic": 901}) + langx.AppendTrans("zh", map[string]string{"test_dynamic": "动态错误: #msg#"}) + + e := errorx.NewErrorWith(context.Background(), "test_dynamic", map[string]string{"msg": "磁盘满了"}) + if got, want := e.GetCode(), 901; got != want { + t.Fatalf("code = %d, want %d", got, want) + } + if got, want := e.Error(), "动态错误: 磁盘满了"; got != want { + t.Fatalf("msg = %q, want %q", got, want) + } + // 带 ctx 语言 + enCtx := langx.SetCtxLang(context.Background(), "en") + langx.AppendTrans("en", map[string]string{"test_dynamic": "dynamic error: #msg#"}) + if got, want := errorx.NewErrorWith(enCtx, "test_dynamic", map[string]string{"msg": "full"}).Error(), "dynamic error: full"; got != want { + t.Fatalf("en msg = %q, want %q", got, want) + } } diff --git a/errorx.go b/errorx.go index 392fb5f..a413167 100644 --- a/errorx.go +++ b/errorx.go @@ -1,62 +1,230 @@ -package errorx - -import ( - "context" - - "github.com/yuninks/langx" -) - -// 定义错误常量 - -type ErrorLanguage struct { - ErrorInterface -} - -// 生成错误常量 -func NewLanguage(uniKey string, code int, defaultValue string) ErrorLanguage { - langx.AppendCode(map[string]int{uniKey: code}) - langx.AppendTrans(langx.GetDefaultLang(), map[string]string{uniKey: defaultValue}) - - l := NewStruct(context.Background(), uniKey, nil) - - return ErrorLanguage{l} -} - -// Key生成错误信息 -func (l ErrorLanguage) Err() error { - return l -} - -// Key生成错误信息 -func (l ErrorLanguage) Errf(format map[string]string) error { - newLang := l.Copy() - newLang.SetFormat(format) - return newLang -} - -func (l ErrorLanguage) ErrfKV(key, value string) error { - newLang := l.Copy() - newLang.SetFormatKV(key, value) - return newLang -} - -// 获取翻译后的错误信息 -func (l ErrorLanguage) Msg(ctx context.Context) string { - newLang := l.Copy() - newLang.SetCtx(ctx) - return newLang.Error() -} - -// 获取翻译后的错误信息 -func (l ErrorLanguage) Msgf(ctx context.Context, format map[string]string) string { - newLang := l.Copy() - newLang.SetCtx(ctx) - newLang.SetFormat(format) - return newLang.Error() -} - -var ( - Success ErrorLanguage = NewLanguage("success", 200, "操作成功") - Error ErrorLanguage = NewLanguage("error", 400, "操作失败") - ErrWithMsg ErrorLanguage = NewLanguage("error_with_msg", 400, "操作失败: #msg#") -) +// Package errorx 把"业务错误码 + 多语言文案 + 占位符"封装成不可变的错误常量。 +// +// 基本用法: +// +// var ErrUserNotFound = errorx.New("user_not_found", 10001, "用户不存在") +// +// func handle(ctx context.Context) error { +// if err := load(); err != nil { +// return ErrUserNotFound.Wrap(err) // 保留底层错误,errors.Is / errors.Unwrap 可用 +// } +// return nil +// } +// +// msg := ErrUserNotFound.Msg(ctx) // 按 ctx 里的语言渲染 +package errorx + +import ( + "context" + "sync" + + "github.com/yuninks/langx" +) + +// ErrorLanguage 是一个"错误常量":把错误标识(key)、业务错误码(code) +// 和默认文案(defaultValue)绑定在一起。 +// +// 它是不可变的值类型,可以直接作为包级变量并发使用: +// +// var ErrUserNotFound = errorx.New("user_not_found", 10001, "用户不存在") +// +// 它只负责"定义"和"构造错误值",读取与派生都在构造出来的 ErrorInterface 上做。 +// 同时它实现了 error 接口,所以可以: +// +// var err error = ErrUserNotFound // 直接当错误用 +// errors.Is(someErr, ErrUserNotFound) // 按 key 比较 +type ErrorLanguage struct { + key string + code int + defaultValue string +} + +// New 定义一个新的错误常量,并把 code 与默认文案注册进 langx 与 errorx 的常量注册表。 +// +// key 为空会 panic —— 这是定义期的编程错误,应该在启动时就暴露。 +// 默认文案里的占位符使用 #name# 形式(可用 langx.SetReplaceKey 修改)。 +// +// 建议只在包级变量或 init 中调用(它会持续写入注册表);动态场景请用 NewError / NewErrorWith。 +func New(key string, code int, defaultValue string) ErrorLanguage { + if key == "" { + panic("errorx: New 的 key 不能为空") + } + langx.AppendCode(map[string]int{key: code}) + langx.AppendTrans(langx.GetDefaultLang(), map[string]string{key: defaultValue}) + l := ErrorLanguage{key: key, code: code, defaultValue: defaultValue} + register(l) + return l +} + +func (l ErrorLanguage) toError() *defaultError { + return &defaultError{ + key: l.key, + code: l.code, + defaultValue: l.defaultValue, + } +} + +// ---------- 构造错误值 ---------- + +// Err 生成 errorx 错误值(自身即实现了 error)。 +func (l ErrorLanguage) Err() ErrorInterface { + e := l.toError() + e.pcs = captureStack() + return e +} + +// ErrWith 生成带占位符数据的错误值。 +func (l ErrorLanguage) ErrWith(format map[string]string) ErrorInterface { + return l.Err().WithFormat(format) +} + +// ErrKV 生成带占位符数据的错误值:ErrKV("name", "tom")。 +func (l ErrorLanguage) ErrKV(pairs ...string) ErrorInterface { + return l.Err().WithFormat(pairsToMap(pairs)) +} + +// Wrap 生成包装了底层错误的错误值,底层错误可通过 errors.Unwrap / errors.Is 获取, +// 且不会出现在 Msg 文案里。 +func (l ErrorLanguage) Wrap(cause error) ErrorInterface { + return l.Err().Wrap(cause) +} + +// ---------- 渲染 ---------- + +// Error 实现 error 接口:按默认语言渲染。 +func (l ErrorLanguage) Error() string { return l.toError().Error() } + +// Msg 按 ctx 中携带的语言渲染文案。 +func (l ErrorLanguage) Msg(ctx context.Context) string { return l.toError().Msg(ctx) } + +// MsgWith 按 ctx 中携带的语言渲染文案,并替换占位符。 +func (l ErrorLanguage) MsgWith(ctx context.Context, format map[string]string) string { + return l.toError().MsgWith(ctx, format) +} + +// MsgKV 是 MsgWith 的键值对写法。 +func (l ErrorLanguage) MsgKV(ctx context.Context, pairs ...string) string { + return l.toError().MsgKV(ctx, pairs...) +} + +// ---------- 读取 ---------- + +// GetCode 返回业务错误码。 +func (l ErrorLanguage) GetCode() int { return l.code } + +// GetKey 返回错误标识。 +func (l ErrorLanguage) GetKey() string { return l.key } + +// GetDefault 返回默认文案。 +func (l ErrorLanguage) GetDefault() string { return l.defaultValue } + +// Is 让 errors.Is 按错误标识(key)匹配。 +func (l ErrorLanguage) Is(target error) bool { + if l.key == "" || target == nil { + return false + } + if t, ok := target.(interface{ GetKey() string }); ok { + return t.GetKey() == l.key + } + return false +} + +// ---------- 从 langx 资源构造错误 ---------- + +// NewError 用 langx 中已注册的 key 构造错误(code 与文案都来自资源文件)。 +// +// key 未注册时返回 ErrUnregistered(code 500)并带上原始 key, +// 不会把打错的 key 当成正常错误。 +func NewError(ctx context.Context, key string) ErrorInterface { + return newFromRegistry(ctx, key, nil) +} + +// NewErrorWith 是 NewError 的带占位符版本。 +func NewErrorWith(ctx context.Context, key string, format map[string]string) ErrorInterface { + return newFromRegistry(ctx, key, format) +} + +func newFromRegistry(ctx context.Context, key string, format map[string]string) ErrorInterface { + lang := langx.GetCtxLang(ctx) + code, defaultValue, ok := resolve(key) + if !ok { + return ErrUnregistered.ErrKV("key", key).WithLang(lang) + } + return &defaultError{ + key: key, + code: code, + defaultValue: defaultValue, + lang: lang, + format: cloneFormat(format), + pcs: captureStack(), + } +} + +// CodeUnknown 是"有文案但没有注册错误码"时使用的兜底码。 +// 取 500 而不是 langx 的默认码(默认 -1,也可能被改成 200),避免配置缺失被当成成功。 +const CodeUnknown = 500 + +// resolve 按 key 取出业务码与默认文案: +// +// 1. 优先用 errorx.New 定义过的错误常量(不依赖 langx 的注册表); +// 2. 其次用 langx 资源文件(code.json / xx.json)里注册的内容; +// 3. 两者都没有时返回 ok=false,由调用方转成 ErrUnregistered。 +func resolve(key string) (code int, defaultValue string, ok bool) { + if key == "" { + return 0, "", false + } + if l, hit := lookupOwn(key); hit { + return l.code, l.defaultValue, true + } + + lang := langx.GetDefaultLang() + hasMsg := langx.Has(lang, key) + hasCode := langx.HasCode(key) + + switch { + case hasMsg && hasCode: + return langx.GetCode(key), langx.GetMsg(lang, key), true + case hasMsg: + // 资源里只有文案没有 code:保留文案,但用 CodeUnknown 兜底 + return CodeUnknown, langx.GetMsg(lang, key), true + case hasCode: + // 资源里只有 code 没有文案:文案退化为 key 本身 + return langx.GetCode(key), key, true + default: + return 0, "", false + } +} + +// errorx 自己的错误常量注册表:即使 langx 的资源被 RegisterCode / RegisterTrans 覆盖, +// 用 errorx.New 定义过的常量依然能拿到正确的 code 与默认文案。 +var ( + registryMu sync.RWMutex + registry = map[string]ErrorLanguage{} +) + +func register(l ErrorLanguage) { + registryMu.Lock() + defer registryMu.Unlock() + registry[l.key] = l +} + +func lookupOwn(key string) (ErrorLanguage, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + l, ok := registry[key] + return l, ok +} + +// 内置错误常量。 +var ( + // Success 表示成功。 + Success = New("success", 200, "操作成功") + // Error 表示通用失败。 + Error = New("error", 400, "操作失败") + // ErrWithMsg 表示带失败原因的通用失败,文案里的 #msg# 由调用方填充。 + ErrWithMsg = New("error_with_msg", 400, "操作失败: #msg#") + // ErrInternal 用于兜底的非业务错误。 + ErrInternal = New("errorx_internal", 500, "服务器内部错误") + // ErrUnregistered 表示使用了未注册的 key,由 NewError 自动返回。 + ErrUnregistered = New("errorx_unregistered", 500, "未注册的错误: #key#") +) diff --git a/example/enample2.go b/example/enample2.go deleted file mode 100644 index 3ca431d..0000000 --- a/example/enample2.go +++ /dev/null @@ -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#") -) diff --git a/example/http/main.go b/example/http/main.go new file mode 100644 index 0000000..c28ed1c --- /dev/null +++ b/example/http/main.go @@ -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() +} diff --git a/example/imports/main.go b/example/imports/main.go index baf5893..f1be98c 100644 --- a/example/imports/main.go +++ b/example/imports/main.go @@ -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" } diff --git a/example/imports/readme.md b/example/imports/readme.md index d1093cf..4567386 100644 --- a/example/imports/readme.md +++ b/example/imports/readme.md @@ -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 +``` diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..e9a05e3 --- /dev/null +++ b/example/main.go @@ -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")) +} diff --git a/go.mod b/go.mod index dbd55a2..4e189a5 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/yuninks/errorx go 1.22.4 -require github.com/yuninks/langx v0.0.7 +require github.com/yuninks/langx v0.1.0 diff --git a/go.sum b/go.sum index 8e878d6..2c69274 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/yuninks/langx v0.0.7 h1:QUt27h/FjnT7//759GoM/+p4ew+HBr10ZN/fkqeQfyA= -github.com/yuninks/langx v0.0.7/go.mod h1:nAylzjNIjCThhEQSJsIKP8Vhja0aJlcyig+NsNPZLSk= +github.com/yuninks/langx v0.1.0 h1:yqflUw7a+G6i073GzpQppuA/ftPtDh9Azlcr4OtetFE= +github.com/yuninks/langx v0.1.0/go.mod h1:nAylzjNIjCThhEQSJsIKP8Vhja0aJlcyig+NsNPZLSk= diff --git a/interfaces.go b/interfaces.go index 7abc22c..da6bb44 100644 --- a/interfaces.go +++ b/interfaces.go @@ -2,95 +2,223 @@ package errorx import ( "context" + "fmt" + "io" "github.com/yuninks/langx" ) -// Key生成错误信息 +// ErrorInterface 是 errorx 中所有错误值的统一接口。 +// +// 三条设计约束: +// +// 1. 不可变:所有 WithXxx 方法都返回新的错误值,绝不修改接收者。 +// 因此包级错误常量可以放心地被任意 goroutine 并发使用。 +// +// 2. 不持有 context:错误值只保存语言标识(WithLang),渲染时再把 ctx 传进来 +// (Msg / MsgWith / MsgKV)。避免错误值被日志、队列、缓存长期持有的时候 +// 把整个请求上下文一起拖住。 +// +// 3. 渲染永不 panic:ctx 为 nil、ctx 里的语言类型不对、langx 资源被清空, +// 都会安全降级到默认文案,而不是崩溃或返回裸 key。 type ErrorInterface interface { - Copy() ErrorInterface // 复制一个新的错误信息 - Error() string // 实现error接口&获取翻译后的错误信息 - GetCode() int // 获取翻译后的Code - GetKey() string // 获取原Key值 - GetFormat() map[string]string // 获取附加数据 - SetFormat(format map[string]string) // 设置附加数据 - SetCtx(ctxHttp context.Context) // 设置上下文 - SetLang(lang string) // 设置语言 - SetFormatKV(key, value string) // 设置附加数据键值对 + error + + // GetCode 返回业务错误码。 + GetCode() int + // GetKey 返回错误常量的唯一标识。 + GetKey() string + // GetDefault 返回定义错误时写入的默认文案。 + GetDefault() string + // GetFormat 返回占位符数据的副本,修改返回值不会影响错误本身。 + GetFormat() map[string]string + // GetLang 返回错误值上固定的语言,空字符串表示跟随渲染时的 ctx。 + GetLang() string + + // Unwrap 返回被 Wrap 包装的底层错误,用于 errors.Is / errors.As / errors.Unwrap。 + Unwrap() error + // StackTrace 返回创建错误时的调用栈,已过滤 errorx 自身的栈帧。 + StackTrace() StackTrace + + // WithFormat 返回携带占位符数据的新错误值,传 nil 表示清空占位符。 + WithFormat(format map[string]string) ErrorInterface + // WithFormatKV 返回追加一组占位符数据的新错误值。 + WithFormatKV(key, value string) ErrorInterface + // WithLang 返回固定了语言的新错误值。 + WithLang(lang string) ErrorInterface + // Wrap 返回包装了底层错误的新错误值。 + Wrap(cause error) ErrorInterface + + // Msg 使用 ctx 中携带的语言渲染文案。 + Msg(ctx context.Context) string + // MsgWith 使用 ctx 中携带的语言渲染文案,并替换占位符。 + MsgWith(ctx context.Context, format map[string]string) string + // MsgKV 是 MsgWith 的键值对写法:MsgKV(ctx, "name", "tom")。 + MsgKV(ctx context.Context, pairs ...string) string } +// defaultError 是 ErrorInterface 的默认实现。 +// 所有字段在构造完成后不再变化,WithXxx 一律返回新实例。 type defaultError struct { - ctx context.Context - key string - format map[string]string + key string + code int + defaultValue string + format map[string]string // 只读 + lang string // 固定语言,空串表示跟随渲染时的 ctx + cause error + pcs []uintptr // 惰性解析的调用栈 } -func (l *defaultError) Copy() ErrorInterface { - return &defaultError{ - ctx: l.ctx, - key: l.key, - format: l.format, +// ---------- 只读访问器 ---------- + +func (e *defaultError) GetCode() int { return e.code } + +func (e *defaultError) GetKey() string { return e.key } + +func (e *defaultError) GetDefault() string { return e.defaultValue } + +func (e *defaultError) GetFormat() map[string]string { return cloneFormat(e.format) } + +func (e *defaultError) GetLang() string { return e.lang } + +func (e *defaultError) Unwrap() error { return e.cause } + +func (e *defaultError) StackTrace() StackTrace { return resolveStack(e.pcs) } + +// ---------- 不可变派生 ---------- + +func (e *defaultError) clone() *defaultError { + cp := *e + return &cp +} + +func (e *defaultError) WithFormat(format map[string]string) ErrorInterface { + cp := e.clone() + cp.format = cloneFormat(format) + return cp +} + +func (e *defaultError) WithFormatKV(key, value string) ErrorInterface { + cp := e.clone() + cp.format = cloneFormat(e.format) + cp.format[key] = value + return cp +} + +func (e *defaultError) WithLang(lang string) ErrorInterface { + cp := e.clone() + cp.lang = lang + return cp +} + +func (e *defaultError) Wrap(cause error) ErrorInterface { + cp := e.clone() + cp.cause = cause + return cp +} + +// ---------- 渲染 ---------- + +// Error 实现 error 接口:使用错误值上固定的语言,没有则用默认语言。 +func (e *defaultError) Error() string { return e.render(e.lang) } + +// Msg 使用 ctx 中携带的语言渲染;若错误值用 WithLang 固定了语言,则以固定语言为准。 +func (e *defaultError) Msg(ctx context.Context) string { + if e.lang != "" { + return e.render(e.lang) + } + return e.render(langx.GetCtxLang(ctx)) +} + +func (e *defaultError) MsgWith(ctx context.Context, format map[string]string) string { + return e.WithFormat(format).Msg(ctx) +} + +func (e *defaultError) MsgKV(ctx context.Context, pairs ...string) string { + return e.WithFormat(pairsToMap(pairs)).Msg(ctx) +} + +// render 是唯一真正读取 langx 的地方:langx 里没有对应文案时, +// 用错误常量自带的默认文案兜底,绝不返回裸 key。 +func (e *defaultError) render(lang string) string { + if e.key == "" { + return emptyKeyMsg + } + if lang == "" { + lang = langx.GetDefaultLang() + } + if !langx.Has(lang, e.key) { + e.registerDefault() + } + return langx.GetFormat(lang, e.key, e.format) +} + +// registerDefault 把默认文案回填到 langx 的默认语言。 +// 用于用户调用 langx.RegisterTrans / RegisterCode 覆盖资源、 +// 或者默认语言在 main 里被改掉之后,仍然能拿到可读的文案。 +func (e *defaultError) registerDefault() { + if e.defaultValue == "" || e.defaultValue == e.key { + return + } + langx.AppendTrans(langx.GetDefaultLang(), map[string]string{e.key: e.defaultValue}) +} + +// Is 让 errors.Is 按错误标识(key)匹配:errors.Is(err, errorx.Error)。 +func (e *defaultError) Is(target error) bool { + return sameKey(e, target) +} + +// Format 支持 %v / %s / %q,以及带堆栈和原因的 %+v。 +func (e *defaultError) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if !s.Flag('+') { + io.WriteString(s, e.Error()) + return + } + io.WriteString(s, e.Error()) + if cause := e.Unwrap(); cause != nil { + fmt.Fprintf(s, "\n cause: %+v", cause) + } + if st := e.StackTrace(); len(st) > 0 { + fmt.Fprintf(s, "\n stack:\n%s", st) + } + case 's': + io.WriteString(s, e.Error()) + case 'q': + fmt.Fprintf(s, "%q", e.Error()) } } -func (e *defaultError) SetFormat(format map[string]string) { - e.format = format -} +// ---------- 内部工具 ---------- -func (l *defaultError) SetFormatKV(key, value string) { - if l.format == nil { - l.format = make(map[string]string) +const emptyKeyMsg = "errorx: 未定义的错误" + +// cloneFormat 复制占位符数据,保证错误值之间不共享 map。 +func cloneFormat(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v } - l.format[key] = value + return dst } -func (e *defaultError) SetCtx(ctxHttp context.Context) { - e.ctx = ctxHttp -} - -func (l *defaultError) Error() string { - errLang := langx.GetCtxLang(l.ctx) - return langx.GetFormat(errLang, l.key, l.format) -} - -func (e *defaultError) GetCode() int { - return langx.GetCode(e.key) -} - -func (e *defaultError) GetKey() string { - return e.key -} - -func (e *defaultError) GetFormat() map[string]string { - if e.format == nil { - e.format = make(map[string]string) +// pairsToMap 把 ("k1","v1","k2","v2") 转成 map;落单的 key 会被忽略。 +func pairsToMap(pairs []string) map[string]string { + m := make(map[string]string, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + m[pairs[i]] = pairs[i+1] } - return e.format + return m } -func (e *defaultError) SetLang(lang string) { - e.ctx = langx.SetCtxLang(e.ctx, lang) -} - -func NewErrorf(ctx context.Context, key string, format map[string]string) error { - return &defaultError{ - ctx: ctx, - key: key, - format: format, +// sameKey 判断两个错误是否描述同一个业务错误。 +func sameKey(e *defaultError, target error) bool { + if e.key == "" || target == nil { + return false } -} - -func NewError(ctx context.Context, key string) error { - return &defaultError{ - ctx: ctx, - key: key, - } -} - -func NewStruct(ctx context.Context, key string, format map[string]string) ErrorInterface { - return &defaultError{ - ctx: ctx, - key: key, - format: format, + if t, ok := target.(interface{ GetKey() string }); ok { + return t.GetKey() == e.key } + return false } diff --git a/stack.go b/stack.go new file mode 100644 index 0000000..8525ff1 --- /dev/null +++ b/stack.go @@ -0,0 +1,74 @@ +package errorx + +import ( + "fmt" + "runtime" + "strings" +) + +// selfPackage 用于过滤 errorx 自身的栈帧。 +const selfPackage = "github.com/yuninks/errorx." + +const stackMaxDepth = 32 + +// Frame 是一个调用栈帧。 +type Frame struct { + Function string + File string + Line int +} + +// String 返回 "file:line" 形式,方便日志里直接打印。 +func (f Frame) String() string { + return fmt.Sprintf("%s:%d", f.File, f.Line) +} + +// StackTrace 是错误创建时的调用栈(已过滤 errorx 自身帧)。 +type StackTrace []Frame + +// String 按 "函数名\n\t文件:行号" 的格式输出整条调用栈。 +func (st StackTrace) String() string { + if len(st) == 0 { + return "" + } + var b strings.Builder + for _, f := range st { + b.WriteString(f.Function) + b.WriteString("\n\t") + b.WriteString(f.String()) + b.WriteString("\n") + } + return b.String() +} + +// captureStack 只抓取程序计数器,真正解析成函数名/文件/行号推迟到 StackTrace(), +// 保证创建错误的开销足够小。 +func captureStack() []uintptr { + var buf [stackMaxDepth]uintptr + n := runtime.Callers(2, buf[:]) + if n <= 0 { + return nil + } + pcs := make([]uintptr, n) + copy(pcs, buf[:n]) + return pcs +} + +// resolveStack 把程序计数器解析成可读的栈帧。 +func resolveStack(pcs []uintptr) StackTrace { + if len(pcs) == 0 { + return nil + } + frames := runtime.CallersFrames(pcs) + out := make(StackTrace, 0, len(pcs)) + for { + f, more := frames.Next() + if f.Function != "" && !strings.HasPrefix(f.Function, selfPackage) { + out = append(out, Frame{Function: f.Function, File: f.File, Line: f.Line}) + } + if !more { + break + } + } + return out +}