Files
errorx/README.md
T
yun 21293673e1 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
2026-09-21 00:43:43 +08:00

257 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```