Go优雅的进行错误处理
这篇文章是对如何优雅的在Golang中进行错误处理的记录和学习
同时也是在Go开发中对错误处理的一种实践。
1. 基本概念知识
- Golang的错误机制实际上有两种:错误(error)与异常(panic),换个说法相当于是可恢复故障和不可恢复故障
- Golang有panic的概念,也就是指不可恢复的故障,一般遇到了panic基本上就是退出程序了,这个非常好理解,比如数组越界了,内存满了,堆栈爆了,几乎这里碰到panic就很少有恢复的可能。
- Golang的可恢复故障,就是error,所谓可恢复,就是指虽然无法顺利执行当前流程,但并不会影响整个应用,消费方可以根据自己的意愿去处理出错后的逻辑。
- 实践中经常碰到的可恢复故障有这几类:
-
前置检查失败
大多数指参数没有按约定提供,参数不为空或校验失败等,这是属于调用方的bug -
程序错误
比如通过req.(sometype)进行类型转换,在运行时无法转过去等,这是属于自身的bug -
依赖服务调用错误
比如调用数据库发生了异常,往往都是第三方产生的运行时错误,是最经常处理的错误 -
业务执行错误
比如一个发送验证码的函数,在执行过程中发现某个用户的发送频率超过阈值,这是属于一个特定业务的失败基本上所有开发过程中碰到的错误都能归入以上4种,需要重点关注的是后两种,前两种属于bug,在上线前就必须清理完毕的。
2. 错误处理:可恢复故障具体该怎么抛
- 首先,错误不应该只有错误的文本,在实践中,我们往往会将出错时的调用栈信息也附加上。
- 调用栈对于消费方是没有意义的,消费方唯一需要关心的就是错误文本和错误类型,调用栈对实现者自身才是有价值的。
- 因此,如果一个方法需要返回错误,我们一般会使用
errors.WithStack(err)或者errors.Wrap(err, "xxx")
将调用栈加到error中,再在某个统一地方记录日志,方便开发者快速定位问题 - 其次,如果是业务执行时的错误,只有错误消息是不够的,需要有某种机制来告诉调用者一些业务上的原因
- 对于同一个服务内的调用,可以使用特定的错误类型,在消费方拿到错误后,可以简单的判断错误的具体类型
var (
ErrInventoryInsufficient = errors.New("product inventory insufficient")
ErrProductSalesTerritoryLimit = errors.New("product sales torritory limit")
)
func Ordering(userId string, preOrder *PreOrder) (*model.Order, error) {
order := &model.Order{}
shippingAddress := preOrder.Shipping
for _, item := range preOrder.Items {
if findInventory(item.Product.Id) <= 0 {
return nil, ErrInventoryInsufficient
}
if !isValidSalesTerritory(item.Product.Id, shippingAddress) {
return nil, ErrProductSalesTerritoryLimit
}
order.AddItem(item)
}
// other processing
return order, nil
}
// 消费方调用
func UserOrderController(ctx context.Context, preOrder *PreOrder) {
// some preparing
user := FromContext(ctx)
order, err := service.Ordering(user.userId, preOrder)
if err != nil {
switch err {
case service.ErrInventoryInsufficient: // handling
case service.ErrProductSalesTerritoryLimit: // handling
}
}
// ...
}
- 如果不是同一个应用的,比如跨边界的RPC调用,就无法使用这种方式了,因为错误类型是无法有效序列化的,即使序列化了也失去了类型判断的能力
- 因此在集成有边界的服务时,往往采用另一种方式:错误标记,用来表示某种类型的业务错误,比如错误码,消费方拿到错误后,里面包含了标记,查询文档分别做处理就可以了。
3. 错误信息应该暴露多少
暴露多少错误细节,取决于对这个错误感兴趣的一方是谁
-
如果感兴趣的一方是其他开发者,那么事情就会变得比较简单,因为开发者感兴趣的错误,一般都是bug或缺陷,我们不必把所有的细节都解释给开发者,但是必要的信息是要提供的,比如一个简单的错误文本
举例子:我们正在写一个包,其中有一个用于发送短信的方法import ( "regexp" "github.com/pkg/errors" ) var ( phoneRegexp = regexp.MustCompile("^((\\+86)|(86))?\\d{11}$") ErrPhoneSmsExceedLimit = errors.New("target phone exceed send limits") ) func SendSms(phone string, content string) error { if phone == "" { return errors.New("phone is required") } if content == "" { return errors.New("content is required") } if !phoneRegexp.MatchString(phone) { return errors.New("phone format incorrect") } if exceedLimits(phone) { return ErrPhoneSmsExceedLimit } // ... }
因为调用SendSms的人只可能是开发者,所以简单的将错误信息返回即可,无需再多处理
在这里例子中,我们已经要求了phone和content不应该为空字符串,消费方还给空字符串,那这就是bug
但如果手机号超过了每日发送的条数限制,这种不是bug,而是业务错误,所以我们用
ErrPhoneSmsExceedLimit
提醒开发者
如果调用者和SendSms实现者是处于同一个进程,那它只需要判断err == ErrPhoneSmsExceedLimit就可以捕捉到业务错误了但如果调用者是在另一个微服务中,不在同一个进程,那就用上面提到的,使用错误标记
var ErrPhoneSmsExceedLimit = NewBusinessError("310001", "target phone exceed send limits") func SendSms(phone string, content string) error { // ... if exceedLimits(phone) { return ErrPhoneSmsExceedLimit } // ... }
然后,如果这个SendSms方法内还需要调用一些的别服务的方法,并且调用有可能会有错误,应该怎么处理?(包装它)
func SendSms(phone string, content string) error { // ... provider := service.NewSmsProvider("appid", "appsecret") res, err := provider.Send(phone, content) if err != nil { return errors.Wrapf(err, "send sms to phone %s failed", phone) } // ... }
这样一来,消费方看到的就是:send sms to phone xxx failed (包装进去的底层err会在边界处切掉),这样既不影响我们服务打印出堆栈,方便我们调试知道是其他服务报错,对于消费方,我们也不必告诉错误细节
继续往下思考,如果调用RPC成功返回了,就一定代表成功了吗?当然不是,没有err很可能只是说明整个RPC成功完成,但没说业务一定是成功的,所以还需要对业务res进一步分析:func SendSms(phone string, content string) error { // ... res, err := provider.Send(phone, content) if err != nil { // ... } switch res.Code { case "0000": return nil case "1001": log.Printf("sms provider report [%s] insufficient balance", res.code) default: log.Printf("sms provider report [%s] %s", res.Code, res.Msg) } return errors.New("send sms failed") }
假设我们已知的业务码只有0000表示成功,那么返回nil表示本次调用成功;
1001代表余额不足,其他的我们可能并不关心,那么在简单的记录日志后,返回给调用方的只有"send sms failed"
这是因为,我的错误我知道,我依赖的服务的错误我也应该知道,但是依赖我的服务如果不是使用姿势不对,或者业务不正确的话,没有理由了解背后的过多细节,唯一需要让消费方知道的就是:没成功
与此同时,我们记录了所有的细节,log.Printf或者调用栈都能帮助我们分析错误。那么,如果此时SendSms方法还需要调用并处理另一个内部的方法darkMagic(phone string) error返回的错误呢?
我们仍然用errors.Wrap(err, “cannot perform such operation”)包装就好了另一个小问题,darkMagic()里如果调用spellForce()又得到error了怎么办?
答案是,直接return err,堆栈信息在spellForce()扔出的error里就有了,错误信息也很明确,着实不用再包装一层。
也就是说,进程内遇到的error,只在离边界最近的地方才需要errors.Wrap()成对调用方友好(和隐藏细节)的error,其他的都直白的往上return err就好 -
总结一下
- 你使用我的姿势不对,例如空字符串,会造成我的错误,那么直接返回errors.New(),这是bug。你去处理
- 你使用我的姿势是对的,我一看是业务上的问题,给你一个让你有机会通过错误类型或者错误码知道的原因,你酌情处理
- 你使用我的姿势是对的,我检查发现业务也没毛病,但是我依赖的一些服务(比如数据库)出问题了,那么我会Wrap成一个既方便我查原因,同时不让你关注过多细节的前提下告诉你:失败了,你酌情处理
- 如果我觉得这一定是个很严重的问题,并且我也没法解决,同时认为你也不该尝试解决,那就panic吧(这一点在在线业务上几乎遇不到)
4. 可恢复故障如何处理(在边界处如何处理遇到的error)
所谓边界,就是离调用方最近的地方,调用方可以是某个服务,也可以是用户使用的某种客户端,总之是在消费你在边界处提供的服务。
边界以内,只有进程内可见。
通常情况下,在边界处,我们就需要对下游产生的错误做出判断,同时,对一些非业务错误一些包装,隐藏错误细节。
如果边界不是面向最终用户的,那么也会提供一些开发者友好的错误文本。
- 面向非用户的边界
对于一个用户微服务的GetUserById(),他的消费方一般不会是最终用户,我们通常会这样处理:
import (
"context"
"github.com/pkg/errors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
var ErrUserNotValid = NewBusinessError("500213", "user is not valid")
func GetUserById(userId string) (*model.User, error) {
if userId == "" {
return errors.New("userId is required")
}
uid, err := primitive.ObjectIDFromHex(userId)
if err != nil {
return nil, errors.Wrap(err, "userId format incorrect")
}
user := &model.User{}
coll := db.Collection("users")
if err := coll.FindOne(context.TODO(), bson.M{"_id": uid}).Decode(user); err != nil {
if err == mongo.ErrNoDocuments {
// maybe return nil, nil is fine
// but, depends on design, be careful
}
return nil, errors.Wrap(err, "cannot perform such operation")
}
// maybe do local business check
if localBusinessCheck(user) {
return nil, ErrUserNotValid
}
// maybe call RPC to do business action
fine, err := rpc.BusinessAction(user)
if err != nil {
// err usually wrapped in rpc particular message type
// so we need abstract real error from wrapper type
rpcStatus := rpc.Convert(err)
if rpcStatus.Type == rpc.Status_Business_Error {
code := rpcStatus.GetMeta("code")
msg := rpcStatus.GetMeta("msg")
return nil, NewBusinessError(code, msg)
}
cause := rpcStatus.Error()
return nil, errors.Wrap(cause, "service unavailable")
}
if !fine {
return nil, ErrUserNotValid
}
return user, nil
}
对于这段示例,首先如何处理下游支撑服务返回的异常?支撑服务(比如数据库,缓存,中间件等等)往往没有业务,他们返回的错误就是单纯的错误,所以在这里直接包装并返回
其次,本地的业务检查如果失败,我们直接返回一个预定义好的ErrUserNotValid,表示一个业务上的失败。
最后,如果涉及进一步的远程RPC调用,多数RPC框架会将错误信息打包成某种转由的结构,所以我们需要一些手段从中这些专有结构中提取出我们需要的信息出来。
通过rpc.Convert()类似的工具函数,我们能从RPC的error中拿到原始的结构数据,然后通过判断,确定是否为业务上的错误(所代表的类型),进而将原始的业务错误重新向外扔出,不需要做额外的处理。如果不是业务上的错误,那么就是bug、缺陷或者传输级别的故障,我们仍旧可以通过包装扔出,留下堆栈和详细信息在微服务内。
- 面向用户的边界
很明确的就是,首先用户很大程度上是关心业务码的,至少用户使用的客户端是关心的,其次用户是不关心什么连接字符串错误,userId is required等等这些错误的
所以,业务错误需要明确给出,前置检查错误只给开发者,其他不可预料的错误全部简单转换为“服务当前不可用”
几个简单的观点:
- 有业务码错误的才需要对用户显示信息,其他的一律可显示为:出错了,请稍后重试
- 有业务码的,说明是非技术的错误,其他一切要么是bug,需要开发人员在上线前处理完毕,要么是运行错误,比如数据库异常,需要告诉用户的只有出错了,请稍后重试,不会耶不能再告诉更多
- 身份证号格式不对,电话号码格式不对,这种错误在严格意义上算是bug,应该在调用API前就检验好的。如果设计不那么严格,可以适当的返回业务码帮助以下,但也只是友情帮助,该客户端做的验证还是得做的
最后几个小细节:
- 对参数的校验还是必要的,不能因为微服务校验过参数,消费方就不做校验了
- 除了参数校验的错误,仍然需要对下游服务返回的业务错误同步的向上返回
- 除了参数错误和业务错误,其他的错误会包装成serviceunavailable,不向用户泄露任何的技术细节
4. 总结
我们开发处理的是可恢复故障,也即是error。
- 错误的抛出对消费方分为两种
- 同一服务内的,可使用错误类型
- 不同服务之间的,可使用错误标识
- 错误信息的暴露,分为bug,内部依赖服务的错误,业务错误,远程RPC调用错误
- 对于bug错误,直接返回错误信息
- 对于服务内部依赖的其他服务的错误,使用Wrap包装,给自己留下堆栈信息,给消费方提供错误信息
- 对于业务错误,看情况给予错误类型或错误标识的错误抛出
- 涉及远程RPC调用,从专有架构中提取信息,拿到错误信息,判断是否为业务上的错误,重新扔出,不是的话还是wrap后扔出
- 进程内遇到的error,只有在离边界最近的地方才Wrap为友好的error,其他的都是直接return err
- 小结
- 你使用我的姿势不对,例如空字符串,会造成我的错误,那么直接返回errors.New(),这是bug。你去处理
- 你使用我的姿势是对的,我一看是业务上的问题,给你一个让你有机会通过错误类型或者错误码知道的原因,你酌情处理
- 你使用我的姿势是对的,我检查发现业务也没毛病,但是我依赖的一些服务(比如数据库)出问题了,那么我会Wrap成一个既方便我查原因,同时不让你关注过多细节的前提下告诉你:失败了,你酌情处理
- 如果我觉得这一定是个很严重的问题,并且我也没法解决,同时认为你也不该尝试解决,那就panic吧(这一点在在线业务上几乎遇不到)
- 面向非用户的边界
使用第2点提到的 - 面向用户的边界
- 有业务码错误的才需要对用户显示信息,其他的一律可显示为:出错了,请稍后重试
- 有业务码的,说明是非技术的错误,其他一切要么是bug,需要开发人员在上线前处理完毕,要么是运行错误,比如数据库异常,需要告诉用户的只有出错了,请稍后重试,不会耶不能再告诉更多
- 身份证号格式不对,电话号码格式不对,这种错误在严格意义上算是bug,应该在调用API前就检验好的。如果设计不那么严格,可以适当的返回业务码帮助以下,但也只是友情帮助,该客户端做的验证还是得做的
总的来说就是,视情况抛出error,但给调用方的不需要携带堆栈信息,这个信息只是我们自己调试查找问题时使用。
然后丢给调用方的error,视情况而定,可能是带错误码标识的,也可能是错误类型让调用方直接判断,其他的通通返回服务不可用之类的错误,不提供细节。