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
+230 -62
View File
@@ -1,62 +1,230 @@
package errorx
import (
"context"
"github.com/yuninks/langx"
)
// 定义错误常量
type ErrorLanguage struct {
ErrorInterface
}
// 生成错误常量
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 {
return l
}
// Key生成错误信息
func (l ErrorLanguage) Errf(format map[string]string) error {
newLang := l.Copy()
newLang.SetFormat(format)
return newLang
}
func (l ErrorLanguage) ErrfKV(key, value string) error {
newLang := l.Copy()
newLang.SetFormatKV(key, value)
return newLang
}
// 获取翻译后的错误信息
func (l ErrorLanguage) Msg(ctx context.Context) string {
newLang := l.Copy()
newLang.SetCtx(ctx)
return newLang.Error()
}
// 获取翻译后的错误信息
func (l ErrorLanguage) Msgf(ctx context.Context, format map[string]string) string {
newLang := l.Copy()
newLang.SetCtx(ctx)
newLang.SetFormat(format)
return newLang.Error()
}
var (
Success ErrorLanguage = NewLanguage("success", 200, "操作成功")
Error ErrorLanguage = NewLanguage("error", 400, "操作失败")
ErrWithMsg ErrorLanguage = NewLanguage("error_with_msg", 400, "操作失败: #msg#")
)
// 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 {
key string
code int
defaultValue string
}
// 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
}
func (l ErrorLanguage) toError() *defaultError {
return &defaultError{
key: l.key,
code: l.code,
defaultValue: l.defaultValue,
}
}
// ---------- 构造错误值 ----------
// Err 生成 errorx 错误值(自身即实现了 error)。
func (l ErrorLanguage) Err() ErrorInterface {
e := l.toError()
e.pcs = captureStack()
return e
}
// ErrWith 生成带占位符数据的错误值。
func (l ErrorLanguage) ErrWith(format map[string]string) ErrorInterface {
return l.Err().WithFormat(format)
}
// 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 (
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#")
)