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
+386 -35
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#", // 有占位符
})
langx.RegisterTrans("en", map[string]string{
"login_success": "success",
"error": "error",
"username": "Hello #name#", // 有占位符
})
os.Exit(m.Run())
}
err = errorx.NewError(ctx, "error")
// fmt.Printf("err: %v\n", err)
t.Log(err.Error())
val, ok := err.(errorx.ErrorInterface)
if ok {
t.Log(val.GetCode())
func TestErrBasics(t *testing.T) {
e := ErrUserNotFound.Err()
if got, want := e.GetCode(), 10001; got != want {
t.Fatalf("GetCode() = %d, want %d", got, want)
}
if got, want := e.GetKey(), "test_user_not_found"; got != want {
t.Fatalf("GetKey() = %q, want %q", got, want)
}
if got, want := e.GetDefault(), "用户不存在"; got != want {
t.Fatalf("GetDefault() = %q, want %q", got, want)
}
if got, want := e.Error(), "用户不存在"; got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
// 错误常量本身也实现了 error 接口
var err error = ErrUserNotFound
if got, want := err.Error(), "用户不存在"; got != want {
t.Fatalf("ErrorLanguage.Error() = %q, want %q", got, want)
}
if got, want := ErrUserNotFound.GetCode(), 10001; got != want {
t.Fatalf("ErrorLanguage.GetCode() = %d, want %d", got, want)
}
if got, want := ErrUserNotFound.GetDefault(), "用户不存在"; got != want {
t.Fatalf("ErrorLanguage.GetDefault() = %q, want %q", got, want)
}
}
func TestPlaceholder(t *testing.T) {
cases := []struct {
name string
got string
want string
}{
{"ErrKV", ErrParamInvalid.ErrKV("field", "age").Error(), "参数错误: age"},
{"ErrWith", ErrParamInvalid.ErrWith(map[string]string{"field": "name"}).Error(), "参数错误: name"},
{"Err().WithFormatKV", ErrParamInvalid.Err().WithFormatKV("field", "id").Error(), "参数错误: id"},
{"MsgKV", ErrParamInvalid.MsgKV(context.Background(), "field", "phone"), "参数错误: phone"},
{"MsgWith", ErrParamInvalid.MsgWith(context.Background(), map[string]string{"field": "mail"}), "参数错误: mail"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
}
func TestMultiLanguage(t *testing.T) {
ctx := context.Background()
enCtx := langx.SetCtxLang(ctx, "en")
if got, want := ErrUserNotFound.Msg(ctx), "用户不存在"; got != want {
t.Fatalf("中文 Msg = %q, want %q", got, want)
}
if got, want := ErrUserNotFound.Msg(enCtx), "user not found"; got != want {
t.Fatalf("英文 Msg = %q, want %q", got, want)
}
if got, want := ErrParamInvalid.MsgKV(enCtx, "field", "age"), "invalid parameter: age"; got != want {
t.Fatalf("英文占位符 = %q, want %q", got, want)
}
// 固定语言:不再跟随 ctx
err := ErrUserNotFound.Err().WithLang("en")
if got, want := err.Msg(ctx), "user not found"; got != want {
t.Fatalf("WithLang 后 Msg = %q, want %q", got, want)
}
if got, want := err.Error(), "user not found"; got != want {
t.Fatalf("WithLang 后 Error = %q, want %q", got, want)
}
// 未注册的语言回退到默认语言,而不是吐裸 key
if got, want := ErrUserNotFound.Msg(langx.SetCtxLang(ctx, "ja")), "用户不存在"; got != want {
t.Fatalf("未注册语言 = %q, want %q", got, want)
}
}
func TestImmutability(t *testing.T) {
base := ErrParamInvalid.Err()
derived := base.WithFormatKV("field", "age")
if got, want := base.Error(), "参数错误: #field#"; got != want {
t.Fatalf("派生后原错误被修改:%q, want %q", got, want)
}
if got, want := derived.Error(), "参数错误: age"; got != want {
t.Fatalf("派生错误 = %q, want %q", got, want)
}
if got, want := ErrParamInvalid.Error(), "参数错误: #field#"; got != want {
t.Fatalf("全局常量被污染:%q, want %q", got, want)
}
// GetFormat 返回副本,外部修改不影响错误本身
f := derived.GetFormat()
f["field"] = "hacked"
if got, want := derived.Error(), "参数错误: age"; got != want {
t.Fatalf("GetFormat 泄漏了内部 map:%q, want %q", got, want)
}
// WithLang / WithFormat 不互相影响
a := base.WithLang("en")
b := base.WithFormatKV("field", "x")
if a.GetLang() != "en" || b.GetLang() != "" {
t.Fatalf("派生值互相污染:a.lang=%q b.lang=%q", a.GetLang(), b.GetLang())
}
}
func TestConcurrentUse(t *testing.T) {
// 同一个错误值被多个 goroutine 共享:旧版本要么 fatal: concurrent map writes,
// 要么不同请求之间互相改写文案。
base := ErrParamInvalid.Err()
const workers, rounds = 32, 50
var wg sync.WaitGroup
bad := make(chan string, workers)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for r := 0; r < rounds; r++ {
v := fmt.Sprintf("%d-%d", w, r)
if got, want := base.WithFormatKV("field", v).Error(), "参数错误: "+v; got != want {
bad <- fmt.Sprintf("派生错误被串改: got %q, want %q", got, want)
return
}
if got, want := base.GetFormat()["field"], ""; got != want {
bad <- fmt.Sprintf("原错误被派生值污染: got %q, want %q", got, want)
return
}
if got, want := ErrUserNotFound.Error(), "用户不存在"; got != want {
bad <- fmt.Sprintf("全局常量被改写: got %q, want %q", got, want)
return
}
}
}(w)
}
wg.Wait()
close(bad)
for msg := range bad {
t.Fatal(msg)
}
}
func TestUnknownKeyIsNotSuccess(t *testing.T) {
e := errorx.NewError(context.Background(), "totally_unknown_key")
if got, want := e.GetCode(), errorx.ErrUnregistered.GetCode(); got != want {
t.Fatalf("未注册 key 的 code = %d, want %d", got, want)
}
if e.GetCode() == 200 {
t.Fatal("未注册的 key 被伪装成了成功(200)")
}
if !strings.Contains(e.Error(), "totally_unknown_key") {
t.Fatalf("未注册 key 的文案应包含原始 key,实际 %q", e.Error())
}
// 空 key 同样按未注册处理
if got, want := errorx.NewError(context.Background(), "").GetCode(), errorx.ErrUnregistered.GetCode(); got != want {
t.Fatalf("空 key 的 code = %d, want %d", got, want)
}
}
func TestRegistryOverwriteKeepsBuiltin(t *testing.T) {
// 用户习惯用 Register*,它会整体替换 langx 的注册表
langx.RegisterCode(map[string]int{"biz_code": 500})
langx.RegisterTrans("zh", map[string]string{"biz_code": "业务错误"})
defer func() {
langx.AppendCode(map[string]int{"error": 400, "success": 200, "error_with_msg": 400})
langx.AppendTrans("zh", map[string]string{
"error": "操作失败", "success": "操作成功", "error_with_msg": "操作失败: #msg#",
})
}()
// code 来自错误常量自身,不会被清空
if got, want := errorx.Error.Err().GetCode(), 400; got != want {
t.Fatalf("内置错误 code = %d, want %d(旧版本会变成 200)", got, want)
}
// 文案用错误常量自带的默认值兜底,不会退化成裸 key
if got, want := errorx.Error.Err().Error(), "操作失败"; got != want {
t.Fatalf("内置错误文案 = %q, want %q", got, want)
}
if got, want := errorx.ErrWithMsg.ErrKV("msg", "超时").Error(), "操作失败: 超时"; got != want {
t.Fatalf("内置错误占位符 = %q, want %q", got, want)
}
// 用户仍然可以用资源里的 key
if got, want := errorx.NewError(context.Background(), "biz_code").GetCode(), 500; got != want {
t.Fatalf("资源 key 的 code = %d, want %d", got, want)
}
}
err = errorx.NewErrorf(ctx, "username", map[string]string{
"name": "yuninks",
})
t.Log(err.Error())
val, ok = err.(errorx.ErrorInterface)
if ok {
t.Log(val.GetCode())
func TestNilAndDirtyCtx(t *testing.T) {
if got, want := ErrUserNotFound.Msg(nil), "用户不存在"; got != want {
t.Fatalf("nil ctx = %q, want %q", got, want)
}
// langx 会对非 string 的 ctx 值做强制断言,errorx 必须兜住
dirty := context.WithValue(context.Background(), "Accept-Language", 123)
if got, want := ErrUserNotFound.Msg(dirty), "用户不存在"; got != want {
t.Fatalf("脏 ctx = %q, want %q", got, want)
}
if got, want := errorx.NewError(nil, "test_user_not_found").Error(), "用户不存在"; got != want {
t.Fatalf("nil ctx 动态构造 = %q, want %q", got, want)
}
}
// 设置输出语言
val.SetLang("en")
func TestErrorsIsAsUnwrap(t *testing.T) {
cause := errors.New("dial tcp 127.0.0.1:3306: connection refused")
err := ErrDB.Wrap(cause)
if !errors.Is(err, ErrDB) {
t.Fatal("errors.Is(err, ErrDB) = false, want true")
}
if !errors.Is(err, cause) {
t.Fatal("errors.Is 应该能穿透到底层错误")
}
if errors.Is(err, ErrUserNotFound) {
t.Fatal("不同 key 的错误不应相等")
}
if got := errors.Unwrap(err); got != cause {
t.Fatalf("Unwrap() = %v, want %v", got, cause)
}
var ei errorx.ErrorInterface
if !errors.As(err, &ei) {
t.Fatal("errors.As(err, &ErrorInterface) = false, want true")
}
if ei.GetCode() != 50001 {
t.Fatalf("errors.As 拿到的 code = %d", ei.GetCode())
}
// 文案里不应出现底层错误的细节
if strings.Contains(err.Error(), "connection refused") {
t.Fatalf("底层错误不应出现在对外文案里:%q", err.Error())
}
// 常量在左边:错误常量直接当 error 返回时走 ErrorLanguage.Is
if !errors.Is(ErrUserNotFound, ErrUserNotFound.Err()) {
t.Fatal("errors.Is(常量, 错误值) = false, want true")
}
if errors.Is(ErrUserNotFound, ErrDB.Err()) {
t.Fatal("不同 key 的常量不应匹配")
}
if errors.Is(ErrUserNotFound, errors.New("plain")) {
t.Fatal("常量不应匹配非 errorx 的错误")
}
// 非 errorx 的普通错误不应匹配
if errors.Is(err, errors.New("boom")) {
t.Fatal("普通 error 不应匹配 errorx 错误")
}
}
t.Log(val.Error())
func TestStackTrace(t *testing.T) {
st := ErrUserNotFound.Err().StackTrace()
if len(st) == 0 {
t.Fatal("StackTrace() 为空")
}
var found bool
for _, f := range st {
if strings.HasPrefix(f.Function, "github.com/yuninks/errorx.") {
t.Fatalf("栈里不应包含 errorx 自身帧:%s", f.Function)
}
if strings.Contains(f.Function, "TestStackTrace") {
found = true
}
}
if !found {
t.Fatal("栈里没有调用方帧")
}
if !strings.Contains(st.String(), "error_test.go") {
t.Fatalf("StackTrace.String() 内容异常:%s", st.String())
}
// 空栈渲染为空串,不 panic
var empty errorx.StackTrace
if got := empty.String(); got != "" {
t.Fatalf("空 StackTrace.String() = %q, want \"\"", got)
}
var f errorx.Frame
if !strings.Contains(f.String(), ":0") {
t.Fatalf("Frame.String() = %q", f.String())
}
}
func TestFormatVerb(t *testing.T) {
err := ErrDB.Wrap(errors.New("boom"))
if got, want := fmt.Sprintf("%v", err), "数据库繁忙"; got != want {
t.Fatalf("%%v = %q, want %q", got, want)
}
if got, want := fmt.Sprintf("%s", err), "数据库繁忙"; got != want {
t.Fatalf("%%s = %q, want %q", got, want)
}
if got, want := fmt.Sprintf("%q", err), "\"数据库繁忙\""; got != want {
t.Fatalf("%%q = %q, want %q", got, want)
}
plus := fmt.Sprintf("%+v", err)
if !strings.Contains(plus, "cause: boom") || !strings.Contains(plus, "stack:") {
t.Fatalf("%%+v 应包含 cause 与 stack,实际:%s", plus)
}
}
func TestFromRegistry(t *testing.T) {
langx.AppendCode(map[string]int{"test_registry_key": 777})
langx.AppendTrans("zh", map[string]string{"test_registry_key": "注册表错误: #msg#"})
e := errorx.NewErrorWith(context.Background(), "test_registry_key", map[string]string{"msg": "磁盘满了"})
if got, want := e.GetCode(), 777; got != want {
t.Fatalf("code = %d, want %d", got, want)
}
if got, want := e.Error(), "注册表错误: 磁盘满了"; got != want {
t.Fatalf("msg = %q, want %q", got, want)
}
// 构造时带上 ctx 语言
enCtx := langx.SetCtxLang(context.Background(), "en")
langx.AppendTrans("en", map[string]string{"test_registry_key": "registry error: #msg#"})
if got, want := errorx.NewErrorWith(enCtx, "test_registry_key", map[string]string{"msg": "full"}).Error(), "registry error: full"; got != want {
t.Fatalf("en msg = %q, want %q", got, want)
}
}
func TestResolveBranches(t *testing.T) {
// 资源里只有文案、没有 code:保留文案,code 用 CodeUnknown 兜底
langx.AppendTrans("zh", map[string]string{"test_msg_only": "只有文案"})
e := errorx.NewError(context.Background(), "test_msg_only")
if got, want := e.GetCode(), errorx.CodeUnknown; got != want {
t.Fatalf("只有文案时 code = %d, want %d", got, want)
}
if got, want := e.Error(), "只有文案"; got != want {
t.Fatalf("只有文案时 msg = %q, want %q", got, want)
}
// 资源里只有 code、没有文案:code 生效,文案退化为 key
langx.AppendCode(map[string]int{"test_code_only": 1234})
e2 := errorx.NewError(context.Background(), "test_code_only")
if got, want := e2.GetCode(), 1234; got != want {
t.Fatalf("只有 code 时 code = %d, want %d", got, want)
}
if got, want := e2.Error(), "test_code_only"; got != want {
t.Fatalf("只有 code 时 msg = %q, want %q", got, want)
}
}
func TestZeroValueAndEmptyKey(t *testing.T) {
var l errorx.ErrorLanguage
if l.GetCode() != 0 || l.GetKey() != "" {
t.Fatalf("零值访问器异常:code=%d key=%q", l.GetCode(), l.GetKey())
}
var err error = l
if err.Error() == "" {
t.Fatal("零值错误也应该能安全渲染")
}
if l.Err().Error() == "" {
t.Fatal("零值错误常量也应该能安全渲染")
}
}
func TestNewPanicsOnEmptyKey(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("空 key 应该 panic,避免注册出无法定位的错误")
}
}()
_ = errorx.New("", 1, "x")
}
func TestNewErrorWith(t *testing.T) {
langx.AppendCode(map[string]int{"test_dynamic": 901})
langx.AppendTrans("zh", map[string]string{"test_dynamic": "动态错误: #msg#"})
e := errorx.NewErrorWith(context.Background(), "test_dynamic", map[string]string{"msg": "磁盘满了"})
if got, want := e.GetCode(), 901; got != want {
t.Fatalf("code = %d, want %d", got, want)
}
if got, want := e.Error(), "动态错误: 磁盘满了"; got != want {
t.Fatalf("msg = %q, want %q", got, want)
}
// 带 ctx 语言
enCtx := langx.SetCtxLang(context.Background(), "en")
langx.AppendTrans("en", map[string]string{"test_dynamic": "dynamic error: #msg#"})
if got, want := errorx.NewErrorWith(enCtx, "test_dynamic", map[string]string{"msg": "full"}).Error(), "dynamic error: full"; got != want {
t.Fatalf("en msg = %q, want %q", got, want)
}
}