你之前的代码感觉奇怪,这是非常敏锐的直觉。

作为一名高级开发专家,我认为“奇怪”的核心点在于:你的 Error 结构体和 response 包的职责耦合了,但又没有完全耦合好。

  • errcode.Error 结构体中有一个 details 字段,但 response.Fail 函数却忽略了它。
  • 这迫使你创建了一个 response.FailWithDetails 函数,它再次忽略err.details,而是要求你传入一个新的 details 参数。
  • 这导致调用者必须“记住”使用哪个 Fail 函数,并且 errcode.Error 对象本身携带的信息(details)被浪费了。

一个更高级、更健壮的设计遵循以下原则:

  1. 错误即数据(Error as Data): errcode.Error 对象应该是一个不可变的数据载体。它应该能通过链式方法(如 WithDetails创建新的副本来携带所有上下文(自定义消息、详细数据等)。
  2. 响应即渲染(Response as Render): response 包的职责应该极其单一:它只负责将 errordata 渲染为 HTTP 响应。
  3. 单一失败处理(Single Fail Handler): 只应该有一个 response.Fail(err error) 函数。它应该足够“聪明”,能够处理任何 error
    • 如果 err 是我们自定义的 ecode.Error,它就按 ecode.Error 中的 Code, Message, DetailsStatusCode 来渲染。
    • 如果 err 是一个未知的 error(例如 sql.ErrNoRows),它应该自动将其渲染为 ServerError (500) 并隐藏内部错误细节(但应该在服务端打印日志)。

基于这个理念,这是我的重构方案。


🚀 重构方案:职责分离与链式错误

我将包名 errcode 改为 ecode(Error Code,更简洁)。

1. pkg/ecode/error.go (错误定义)

这是变化最大的地方。我们移除全局的 codes map 和 panic,因为错误码重复是开发期问题,应该由测试或 Linter 解决,而不是在运行时 panic

我们添加了 Is() 方法以兼容标准库 errors.Is,并提供了强大的 WithMessageWithDetails 链式方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package ecode

import (
	"fmt"
	"net/http"
)

// Error 是自定义的错误结构
// 字段公开,方便 json 序列化和外部访问
type Error struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Details any    `json:"details,omitempty"` // 将 details 改为 any,支持结构化数据
}

// NewError 是一个简单的构造函数
// 不再使用全局 map 注册,也不再 panic
func NewError(code int, msg string) *Error {
	return &Error{Code: code, Message: msg}
}

// Error 实现了标准 error 接口
func (e *Error) Error() string {
	return fmt.Sprintf("code: %d, message: %s", e.Code, e.Message)
}

// Is 实现了 errors.Is 接口,允许 errors.Is(err, ecode.InvalidParams)
func (e *Error) Is(target error) bool {
	if other, ok := target.(*Error); ok {
		return e.Code == other.Code
	}
	return false
}

// WithMessage 创建一个错误副本,并使用新的消息
// 这是 "不可变" 思想的体现
func (e *Error) WithMessage(msg string) *Error {
	err := *e // 复制
	err.Message = msg
	return &err
}

// WithMessagef 创建一个错误副本,并使用格式化的新消息
func (e *Error) WithMessagef(format string, args ...any) *Error {
	err := *e // 复制
	err.Message = fmt.Sprintf(format, args...)
	return &err
}

// WithDetails 创建一个错误副本,并附加上下文数据
func (e *Error) WithDetails(details any) *Error {
	err := *e // 复制
	err.Details = details
	return &err
}

// StatusCode 将业务错误码映射为 HTTP 状态码
// (基本保持不变)
func (e *Error) StatusCode() int {
	switch e.Code {
	case Success.Code:
		return http.StatusOK
	case ServerError.Code:
		return http.StatusInternalServerError
	case InvalidParams.Code:
		return http.StatusBadRequest
	case UnauthorizedAuthNotExist.Code,
		UnauthorizedTokenError.Code,
		UnauthorizedTokenGenerate.Code,
		UnauthorizedTokenTimeout.Code:
		return http.StatusUnauthorized
	case TooManyRequests.Code:
		return http.StatusTooManyRequests
	case NotFound.Code:
		return http.StatusNotFound
	}
	// 默认返回 500
	return http.StatusInternalServerError
}

2. pkg/ecode/code.go (错误码常量)

这个文件几乎不变,只是调用了新的 NewError 构造函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package ecode

// 不再有全局 map 和 NewError 中的 panic
var (
	Success                   = NewError(0, "成功")
	ServerError               = NewError(10000000, "服务内部错误")
	InvalidParams             = NewError(10000001, "入参错误")
	NotFound                  = NewError(10000002, "找不到")
	UnauthorizedAuthNotExist  = NewError(10000003, "鉴权失败,找不到对应的 AppKey 和 AppSecret")
	UnauthorizedTokenError    = NewError(10000004, "鉴权失败,Token 错误")
	UnauthorizedTokenTimeout  = NewError(10000005, "鉴权失败,Token 超时")
	UnauthorizedTokenGenerate = NewError(10000006, "鉴权失败,Token 生成失败")
	TooManyRequests           = NewError(10000007, "请求过多")
)

3. pkg/response/response.go (HTTP 响应层)

这是最能体现新设计优势的地方。Fail 函数现在是唯一的错误出口,并且变得极其强大。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package response

import (
	"errors" // 导入标准 errors 包
	"net/http"

	"einscat.com/ecmp/pkg/ecode" // 假设这是你的 ecode 包路径
	"github.com/gin-gonic/gin"
)

// Result 标准 API 响应结构
type Result struct {
	Code    int    `json:"code"`
	Message string `json:"message"`          // 字段名改为 Message
	Data    any    `json:"data,omitempty"` // 保持 omitempty
}

// Pager ... (保持不变)
type Pager struct {
	Page      int `json:"page"`
	PageSize  int `json:"page_size"`
	TotalRows int `json:"total_rows"`
}

// ListResult ... (保持不变)
type ListResult struct {
	List  any   `json:"list"`
	Pager Pager `json:"pager"`
}

// respond 内部响应函数
func respond(c *gin.Context, httpStatus int, code int, msg string, data any) {
	c.JSON(httpStatus, Result{
		Code:    code,
		Message: msg,
		Data:    data,
	})
}

// Success 发送一个带数据的成功响应
// (原 SuccessData)
func Success(c *gin.Context, data any) {
	respond(c, http.StatusOK, ecode.Success.Code, ecode.Success.Message, data)
}

// SuccessMessage 发送一个只带消息的成功响应
// (原 SuccessMsg,解决了 gin.H{} 的问题)
func SuccessMessage(c *gin.Context, msg string) {
	respond(c, http.StatusOK, ecode.Success.Code, msg, nil)
}

// SuccessList 发送列表成功响应
func SuccessList(c *gin.Context, list any, pager Pager) {
	Success(c, ListResult{
		List:  list,
		Pager: pager,
	})
}

// Fail 是唯一的失败响应函数
// 它可以处理任何类型的 error
func Fail(c *gin.Context, err error) {
	var (
		ec *ecode.Error
	)

	// 核心:使用 errors.As 检查错误是否为我们自定义的 ecode.Error
	if errors.As(err, &ec) {
		// 是我们定义的错误:
		// 1. 使用 ecode 的 StatusCode
		// 2. 使用 ecode 的 Code
		// 3. 使用 ecode 的 Message
		// 4. 自动将 ecode 的 Details 作为响应的 Data 字段
		respond(c, ec.StatusCode(), ec.Code, ec.Message, ec.Details)
	} else {
		// 是一个未知的 error (如: sql.ErrNoRows, 或其他 panic)
		// 1. 在服务端打印详细日志(重要!)
		//    log.Errorf("unknown error: %v", err) // 示例
		
		// 2. 向客户端返回通用的 "服务内部错误",隐藏实现细节
		respond(
			c,
			ecode.ServerError.StatusCode(),
			ecode.ServerError.Code,
			ecode.ServerError.Message,
			nil, // 不暴露任何 data
		)
	}
}

✨ 新的调用方式(天壤之别)

现在你的 Handler 层代码会变得极其干净。你不再需要 FailWithMsgFailWithDetails

场景1:登录成功,只需提示

  • 优化前: response.SuccessWithMsg(ctx, gin.H{}, "登录成功")
  • 优化后: response.SuccessMessage(ctx, "登录成功")

场景2:获取用户信息成功

  • 优化前: response.Success(ctx, user)
  • 优化后: response.Success(ctx, user)

场景3:参数错误(标准消息)

  • Service 层: return ecode.InvalidParams
  • Handler 层: response.Fail(ctx, err)
  • JSON 响应: {"code": 10000001, "message": "入参错误"}

场景4:参数错误(自定义消息)

  • Service 层: return ecode.InvalidParams.WithMessage("用户名不能为空")
  • Handler 层: response.Fail(ctx, err)
  • JSON 响应: {"code": 10000001, "message": "用户名不能为空"}

场景5:参数错误(带详细数据,如表单验证失败)

  • Service 层:
    1
    2
    3
    4
    5
    
    details := map[string]string{
        "username": "用户名不能为空",
        "password": "密码长度不能少于8位",
    }
    return ecode.InvalidParams.WithDetails(details)
    
  • Handler 层: response.Fail(ctx, err)
  • JSON 响应:
    1
    2
    3
    4
    5
    6
    7
    8
    
    {
      "code": 10000001,
      "message": "入参错误",
      "data": {
        "username": "用户名不能为空",
        "password": "密码长度不能少于8位"
      }
    }
    

场景6:数据库查询为空(未知错误)

  • Service 层: return sql.ErrNoRows
  • Handler 层: response.Fail(ctx, err)
  • JSON 响应: {"code": 10000000, "message": "服务内部错误"} (同时服务端会打印 sql.ErrNoRows 的日志)

总结

这个设计的好处是:

  1. 高度解耦: ecode 只负责定义错误数据,response 只负责渲染。
  2. 调用简单: Handler 层永远只有一个 response.Fail(ctx, err),它不关心 err 是什么类型、是否带 Details
  3. 功能强大: Service 层可以通过链式调用 WithMessageWithDetails 自由地为错误附加任何上下文。
  4. 安全健壮: 自动隐藏未知错误,防止内部实现细节泄露。