深色模式
Goa 错误处理
概述
Goa 的错误处理不是“接口里返回一个 JSON 就行”,而是把错误也放进契约里。一个方法可能返回什么错误、这些错误在 HTTP 层对应什么状态码、客户端能不能按错误类型做分支,Goa 都希望在设计阶段先说清楚。
中小项目里,这套机制最适合用来解决两个问题:第一,别让业务层到处写裸字符串错误;第二,别让接口层靠 err.Error() 猜错误类型。先把错误边界立住,后面再决定要不要扩展成更完整的错误码体系。
先分清两套错误
Goa 项目里,通常会同时存在两类错误:
- 内部业务错误:服务内部判断和传递,比如
ErrUserNotFound - 对外接口错误:通过 DSL 定义并映射到 HTTP 的错误,比如
not_found
一个比较稳的分工是:
- 业务层返回内部错误
- 接口实现层把内部错误翻译成 Goa 错误
- Goa 再把它序列化成 HTTP 响应
这样做的好处是,业务代码不用关心 404、409 这些协议细节,接口层也不需要知道数据库或仓储层的实现细节。
中小项目先定义一套够用的错误
如果项目还不大,最实用的起步方式通常是这几个:
unauthorizedforbiddennot_foundconflictinternal_error
再加上 Goa 自己在边界上帮你做的参数校验错误,已经够支撑大部分中小型接口。
一开始不必急着上数字错误码表。先把错误名称和状态码稳定下来,比先设计几十个业务码更重要。
在 DSL 里定义错误
例如在用户服务里,可以这样定义:
go
var _ = Service("user", func() {
Error("not_found", ErrorResult, "资源不存在")
Error("conflict", ErrorResult, "资源冲突")
Error("internal_error", ErrorResult, func() {
Description("内部错误")
Fault()
})
Method("show", func() {
Payload(func() {
Field(1, "id", UInt64)
Required("id")
})
Result(User)
HTTP(func() {
GET("/users/{id}")
Response("not_found", StatusNotFound)
Response("internal_error", StatusInternalServerError)
})
})
})这里做的事有两件:
- 声明这个服务可能返回哪些错误
- 明确这些错误在 HTTP 层的状态码
Goa 官方文档说明,默认错误类型 ErrorResult 会生成标准结构,并提供辅助函数把普通错误包装成 ServiceError。
业务层和接口层怎么配合
业务层先定义可判断的错误:
go
package user
import "errors"
var ErrUserNotFound = errors.New("user not found")到了接口实现层,再把它映射成 Goa 错误:
go
func (s *UserService) Show(ctx context.Context, p *genuser.ShowPayload) (*genuser.User, error) {
u, err := s.repo.FindByID(ctx, p.ID)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
return nil, genuser.MakeNotFound(err)
}
return nil, genuser.MakeInternalError(err)
}
return toResult(u), nil
}这种分层很适合中小项目:
- 业务层只表达业务含义
- 接口层负责对外契约
- 客户端收到的是稳定的错误名称和状态码
要不要先上业务错误码
对中小项目来说,通常不急。先用稳定的错误名称就够了,例如:
not_foundemail_existspermission_denied
如果后面客户端增多,或者前端确实需要更稳定的机器可读字段,再考虑把错误序列化成带 code、message 的自定义结构。Goa 官方文档也提供了覆盖错误序列化的方式。
换句话说,小项目里先把“可判断的错误类型”做好,通常比先建一张庞大的错误码表更划算。
什么时候该用自定义错误类型
默认的 ErrorResult 已经能覆盖很多场景。只有在错误本身需要附带额外上下文时,才值得上自定义类型,例如:
- 哪个字段冲突
- 哪个状态不允许当前操作
- 哪个资源 ID 出错
如果一个方法里定义了多个自定义错误,官方文档要求通过 Meta("struct:error:name") 指定哪个字段代表错误名称。这种能力很强,但对大多数中小项目来说,不一定是第一天就要用上的东西。
这些坑尽量别踩
直接把 fmt.Errorf 原样往外抛
内部错误消息常常带实现细节,对外返回时最好通过 Goa 错误重新包一层,不要把数据库或第三方调用细节直接泄露出去。
用错误文案做分支判断
下面这种写法很脆:
go
if err.Error() == "用户不存在" {
}文案是给人看的,不适合拿来做程序判断。
一开始就把错误体系设计得过重
如果项目目前只有后台管理端和一个前端,先把 4 到 6 个常用错误定义稳住,通常就够了。真的有多端、多语言、多团队协作需求,再往上加。
