Gin 项目里的错误处理:让 Controller 不必知道错误是怎么来的

从一段很常见的 Controller 说起

刚开始写 Gin 项目的时候,Controller 基本都长这样:

go 复制代码
func (c *UserController) Update(ctx *gin.Context) {
    var req UpdateReq
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(400, gin.H{"code": 400, "message": "参数错误"})
        return
    }
    user, err := userService.Get(req.UserID)
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            ctx.JSON(400, gin.H{"code": 400, "message": "用户不存在"})
            return
        }
        ctx.JSON(500, gin.H{"code": 500, "message": "服务器错误"})
        return
    }
    // ...
}

问题不在于它能不能跑,而在于:Controller 被迫知道了错误是从哪一层、因为什么原因产生的gorm.ErrRecordNotFound 是数据访问层的概念,它不该泄漏到 HTTP 层。等哪天你把 GORM 换掉,或者给这个查询加一层缓存,所有 Controller 都得跟着改。

而且这段判断会在每个 Controller 里复制一遍。二十个接口就是二十份。

只区分两类错误

我们最后收敛到一个很简单的模型:所有错误只分两类

一类是「能直接说给用户听的」------参数不合法、余额不足、名字重复。这类错误的 Message 本身就是要展示到界面上的文案。

另一类是「不能说给用户听的」------数据库连不上、下游超时、空指针。这类一律返回一句通用提示,真实原因只进日志。

于是错误类型就只需要一个:

go 复制代码
type BusinessError struct {
    Message string
}

func (e *BusinessError) Error() string {
    return e.Message
}

func NewBusinessError(message string) *BusinessError {
    return &BusinessError{Message: message}
}

func IsBusinessError(err error) bool {
    var bizErr *BusinessError
    return errors.As(err, &bizErr)
}

注意 IsBusinessError 用的是 errors.As 而不是类型断言。Service 层很可能会用 fmt.Errorf("查询用户失败: %w", err) 把错误包一层再往上抛,直接断言会漏掉被包装的情况。这个坑我们踩过。

Controller 只剩一行

统一的收口函数:

go 复制代码
func HandleError(c *gin.Context, err error, messages ...string) {
    requestID := getRequestID(c)
    if IsBusinessError(err) {
        ErrorLogger.Warn("business error",
            zap.String("request_id", requestID), zap.Error(err))
        BadRequest(c, err.Error())      // 400,直接把 Message 给用户
    } else {
        message := "服务器错误,请稍后重试"
        if len(messages) > 0 {
            message = strings.Join(messages, "")
        }
        ErrorLogger.Error("server error",
            zap.String("request_id", requestID), zap.Error(err))
        InternalServerError(c, message) // 500,通用提示
    }
}

Controller 就退化成这样:

go 复制代码
user, err := userService.Get(req.UserID)
if err != nil {
    util.HandleError(ctx, err)
    return
}

Service 层该做的是把「用户不存在」这个判断收进去:

go 复制代码
func (s *UserService) Get(id int64) (*model.User, error) {
    user, err := s.repo.FindByID(id)
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, util.NewBusinessError("用户不存在")
    }
    if err != nil {
        return nil, fmt.Errorf("查询用户失败: %w", err)   // 原样上抛,走 500
    }
    return user, nil
}

GORM 的错误类型现在只出现在 Service 层,再没往上跑。

两条我们定死的纪律

第一,NewBusinessError 里绝不拼接 err.Error()

见过太多这种写法:

go 复制代码
return util.NewBusinessError("保存失败: " + err.Error())

这会把 Error 1062: Duplicate entry 'xxx' for key 'uk_email' 原样怼到用户脸上,既看不懂又泄漏表结构。原始错误记日志就够了,用户看到的必须是人话。

第二,日志里带 request_id。

响应体里也返回同一个 request_id。用户来报障时只要给这串 ID,直接就能捞出当时那条日志,不用问"你大概几点操作的"。这条的收益远超实现成本。

这套方案的局限,说清楚

它没有错误码。

前端拿到的只有 400/500 和一句文案,没法按 code 做分支处理。如果你的产品需要「余额不足时弹充值弹窗、未实名时跳实名页」这种差异化逻辑,或者要做多语言,这套就不够用了------得在 BusinessError 上再加一个 Code 字段,并且维护一张码表。

我们目前故意没做,因为现阶段绝大多数场景就是「把一句话展示给用户」,加了码表反而是净成本:每加一个错误就要想一个码、登记、同步给前端。等真出现第三类需求了再加不迟。

它也不区分错误的严重级别。 所有非业务错误都是 500 + 一条 Error 日志。下游超时和空指针在告警系统里长得一样,真要做分级告警还得另外加东西。

这两个局限我们是知情的,不是没想到。选型这事没有普适最优解,只有"当前这个项目值不值得为它付复杂度"。


这套东西跑在 forxi.cn 的后端上,一年多没出过"用户看到一串 SQL 报错"这类事故。如果你的 Controller 现在还在到处 if errors.Is(...),可以试试这个方向。

相关推荐
n8n1 小时前
Spring AI Alibaba Graph 实战:用图编排复杂业务,替代硬编码,支持分支、并行与人工确认
后端
Lyy1 小时前
DevOps平台 — 第十一篇:工作项的设计与实现
后端·devops
用户526835677901 小时前
Alertmanager 的告警怎么“喊“出来:Webhook 到声光语音终端的路由与抑制设计
后端
福兮说2 小时前
我们那个 IP 限流中间件,跑久了会把内存吃光
go
衝鋒壹号2 小时前
鸿蒙 PC 能跑 Docker 吗?一次从安装失败到成功运行的实测记录
后端·harmonyos
用户8181870627462 小时前
第27章 消息丢失/重复消费的全链路排查(生产者→Broker→消费者)
java·后端
程序员cxuan2 小时前
ChatGPT 开启无限 token
人工智能·后端·程序员
Gopher_HBo2 小时前
beego ORM 源码(上):模型元数据与注册
后端
索隆zoro2 小时前
Army 对 jOOQ
java·后端