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:
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#")
|
||||
)
|
||||
|
||||
@@ -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#")
|
||||
)
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user