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
+256
View File
@@ -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
```
+48
View File
@@ -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)
}
}
+385 -34
View File
@@ -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#", // 有占位符
os.Exit(m.Run())
}
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#",
})
langx.RegisterTrans("en", map[string]string{
"login_success": "success",
"error": "error",
"username": "Hello #name#", // 有占位符
})
}()
// 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)
}
}
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)
}
}
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 错误")
}
}
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)
}
}
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 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)
}
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())
// 资源里只有 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)
}
}
// 设置输出语言
val.SetLang("en")
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")
}
t.Log(val.Error())
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)
}
}
+206 -38
View File
@@ -1,62 +1,230 @@
// 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 {
ErrorInterface
key string
code int
defaultValue string
}
// 生成错误常量
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 {
// 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
}
// Key生成错误信息
func (l ErrorLanguage) Errf(format map[string]string) error {
newLang := l.Copy()
newLang.SetFormat(format)
return newLang
func (l ErrorLanguage) toError() *defaultError {
return &defaultError{
key: l.key,
code: l.code,
defaultValue: l.defaultValue,
}
}
func (l ErrorLanguage) ErrfKV(key, value string) error {
newLang := l.Copy()
newLang.SetFormatKV(key, value)
return newLang
// ---------- 构造错误值 ----------
// Err 生成 errorx 错误值(自身即实现了 error)。
func (l ErrorLanguage) Err() ErrorInterface {
e := l.toError()
e.pcs = captureStack()
return e
}
// 获取翻译后的错误信息
func (l ErrorLanguage) Msg(ctx context.Context) string {
newLang := l.Copy()
newLang.SetCtx(ctx)
return newLang.Error()
// ErrWith 生成带占位符数据的错误值。
func (l ErrorLanguage) ErrWith(format map[string]string) ErrorInterface {
return l.Err().WithFormat(format)
}
// 获取翻译后的错误信息
func (l ErrorLanguage) Msgf(ctx context.Context, format map[string]string) string {
newLang := l.Copy()
newLang.SetCtx(ctx)
newLang.SetFormat(format)
return newLang.Error()
// 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 (
Success ErrorLanguage = NewLanguage("success", 200, "操作成功")
Error ErrorLanguage = NewLanguage("error", 400, "操作失败")
ErrWithMsg ErrorLanguage = NewLanguage("error_with_msg", 400, "操作失败: #msg#")
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#")
)
-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"))
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+196 -68
View File
@@ -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
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
}
+74
View File
@@ -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
}