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:
+386
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user