0x00 背景
本篇文章来分析下 Kratos 对错误码的封装(HTTP && RPC)。一般而言,错误码封装的方式:
- 整形值的错误码
- 错误码对应的出错信息
- HTTP 或 RPC 的方便定义
错误码,一般被用来进行异常传递,且需要具有携带 message 文案信息的能力。
0x01 Kratos 的错误码使用
先从用例入手,然后再简单分析下 ecode 内部实现及其与 RPC 协议的封装。
import (
"fmt"
"github.com/go-kratos/kratos/pkg/ecode"
)
var _ ecode.Codes
// 用户错误码定义
var (
UserNotLogin = ecode.New(123)
UserLoginAuthError = ecode.New(-304)
)
var cms = map[int]string{
0: "SUCC",
-304: "NOT MODIFIED",
-404: "NOT FOUND",
123: "USER DEFINE MESSAGE",
}
// 调用 ecode 包的 Register 方法注册错误码 Map
func init() {
ecode.Register(cms)
}
func main() {
fmt.Println(UserNotLogin.Error(), UserNotLogin.Message()) // 输出为 `123 USER DEFINE MESSAGE
}
同时,Kratos 也提供了 工具生成的方式,使用 proto 协议定义错误码,格式如下:
// user.proto
syntax = "proto3";
package ecode;
enum UserErrCode {
UserUndefined = 0; // 因 protobuf 协议限制必须存在!!!无意义的 0,工具生成代码时会忽略该参数
UserNotLogin = 123; // 正式错误码
}
需要注意以下几点:
- 必须是 enum 类型,且名字规范必须以 “ErrCode” 结尾,如:
UserErrCode - 因为 protobuf 协议限制,第一个 enum 值必须为无意义的
0
使用 kratos tool protoc --ecode user.proto 进行生成,生成如下代码:
package ecode
import (
"github.com/go-kratos/kratos/pkg/ecode"
)
var _ ecode.Codes
// UserErrCode
var (
UserNotLogin = ecode.New(123);
)
0x02 错误码的设计 && 实现
在 Kratos 中,使用包中定义的全局变量 _codes 来存储错误码,使用 _messages 来存储错误码的 map,使用 ecode.Register 方法向 _messages 存储一个 map:
var (
_messages atomic.Value // NOTE: stored map[int]string
_codes = map[int]struct{}{} // register codes.
)
// Register register ecode message map.
func Register(cm map[int]string) {
_messages.Store(cm)
}
Code 封装
在 kratos 里,错误码被设计成 Codes 接口(Interface{}),声明如下:
// Codes ecode error interface which has a code & message.
type Codes interface {
// sometimes Error return Code in string form
// NOTE: don't use Error in monitor report even it also work for now
Error() string
// Code get error code.
Code() int
// Message get code message.
Message() string
//Detail get error detail,it may be nil.
Details() []interface{}
}
// A Code is an int error code spec.
type Code int
可以看到该 Codes 接口一共有四个方法,且 type Code int 结构体实现了该接口。
Error():返回错误码字符串(类似于err.Error())Code():返回错误码整形值
封装的方法代码如下,着重看下 Message() 方法,它的过程是从全局 _messages 中取出 map,再从 map 中取出 e Code 对应的错误码字符串:
func (e Code) Error() string {
return strconv.FormatInt(int64(e), 10)
}
// Code return error code
func (e Code) Code() int { return int(e) }
// Message return error message
func (e Code) Message() string {
if cm, ok := _messages.Load().(map[int]string); ok {
if msg, ok := cm[e.Code()]; ok {
return msg
}
}
return e.Error()
}
// Details return details.
func (e Code) Details() []interface{} { return nil }
注册 Message
一个 Code 错误码可以对应一个 message,默认实现会从全局变量 _messages 中获取,业务可以将自定义 Code 对应的 message 通过调用 Register 方法的方式传递进去,如前面的例子,使用的错误码映射关系为 int==>string,当然这也不是绝对的,比如有业务要支持多语言的场景就可以扩展为类似 map[int]LangStruct 的结构,因为全局变量 _messages 是 atomic.Value 类型,只需要修改对应的 Message 方法实现即可。
UserNotLogin = ecode.New(123)
var cms = map[int]string{
0: "SUCC",
-304: "NOT MODIFIED",
-404: "NOT FOUND",
123: "USER DEFINE MESSAGE",
}
ecode.Register(cms)
fmt.Println(ecode.UserNotLogin.Message())
String 和 Int 方法
ecode 包提供了 Int 和 String 两个方法,用来将 int 型和 string 转换为 Code 类型:
// Int parse code int to error.
func Int(i int) Code { return Code(i) }
// String parse code string to error.
func String(e string) Code {
if e == "" {
return OK
}
// try error string
i, err := strconv.Atoi(e)
if err != nil {
return ServerErr
}
return Code(i)
}
Details 接口的作用
Details 接口为 gRPC 预留,gRPC 传递异常会将服务端的错误码 pb 序列化之后赋值给 Details,客户端拿到之后反序列化得到,这里在下面的章节中详细分析下。
如何转换为 ecode?
在开发中,可以按照如下方式将 errors 或错误码转换为 ecode 类型,通常而言,错误码转换有以下两种情况:
- 因为框架传递错误是靠
ecode错误码,比如 bm 框架返回的code字段默认就是数字,那么客户端接收到如{"code":-404}的话,可以使用ec := ecode.Int(-404)或ec := ecode.String("-404")来进行转换 - 在项目中
dao层返回一个错误码,往往返回参数类型建议为error而不是ecode.Codes,因为error更通用,那么上层service就可以使用ec := ecode.Cause(err)进行转换(为ecode)
ecode.Cause() 方法实现如下,将标准的 error 转为 ecode,注意,这里调用了 errors.Cause 来获取底层的错误:
// Cause cause from error to ecode.
func Cause(e error) Codes {
if e == nil {
return OK
}
ec, ok := errors.Cause(e).(Codes) // 调用 errors.Cause 方法获取到最底层的错误
if ok {
return ec
}
//e.Error() 返回是字符串的错误码,通过 String() 方法转为 Codes(int)类型
return String(e.Error())
}
错误码之比较
ecode 包提供了 Equal() 和 EqualError 方法,来判断(两个)错误码是否相等:
ecode与ecode判断:使用ecode.Equal(ec1, ec2)方法ecode与标注库的error判断:使用ecode.EqualError(ec, err)方法
// Equal equal a and b by code int.
func Equal(a, b Codes) bool {
if a == nil {
a = OK
}
if b == nil {
b = OK
}
return a.Code() == b.Code()
}
// EqualError equal error
func EqualError(code Codes, err error) bool {
return Cause(err).Code() == code.Code()
}
0x03 ecode 与 RPC 返回码的封装
在 RPC 中,可以使用 ecode 包来简化 RPC 返回码。如下面这个 SayHello 的实现中,传递异常会将服务端的错误码 pb 序列化之后赋值给 Details,客户端拿到之后反序列化得到错误码及错误信息:
func (s *helloServer) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
if in.Name == "err_detail_test" {
err, _ := ecode.Error(ecode.AccessDenied, "AccessDenied").WithDetails(&pb.HelloReply{Success: true, Message: "this is test detail"})
return nil, err
}
count++
if count%3==0{
return nil,errors.New("rpc error")
}
return &pb.HelloReply{Message: fmt.Sprintf("hello %s from %s", in.Name, s.addr)}, nil
}
ecode包内的Status结构体实现了Codes接口 代码位置warden/internal/status包内包装了ecode.Status和grpc.Status进行互相转换的方法 代码位置warden的client和server则使用转换方法将gRPC底层返回的error最终转换为ecode.Status代码位置
0x04 错误码的一个细节
在分析客户端熔断 breaker 拦截器的时候,MarkFailed 需要对服务端返回的错误进行筛选。见如下 代码:
func onBreaker(breaker breaker.Breaker, err *error) {
if err != nil && *err != nil {
if ecode.EqualError(ecode.ServerErr, *err) || ecode.EqualError(ecode.ServiceUnavailable, *err) || ecode.EqualError(ecode.Deadline, *err) || ecode.EqualError(ecode.LimitExceed, *err) {
breaker.MarkFailed()
return
}
}
// 其余错误,包含非错误会 MarkSuccess
breaker.MarkSuccess()
}
从上面的代码可以知道,onBreaker 中通过 ecode.EqualError 方法从 err 中剥离中最原始的 error 并与下述错误码比较。通过触发熔断器 MarkFailed 技数更新的错误仅限于如下几种,其余的错误(比如类似业务逻辑错误等)熔断器 MarkSuccess:
ecode.ServerErrecode.ServiceUnavailableecode.Deadline:针对接口调用超时context.DeadlineExceeded的转换型ecode.Deadlineecode.LimitExceed:服务端限流拦截器返回的 错误
0x05 优雅的 golang 错误处理
在前文中,Cause 方法是调用了 github.com/pkg/errors 错误处理库提供的实现来处理,这是非常优雅的处理办法(对于需要比较错误,或者是获取底层错误的场景十分方便),如下例:
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
)
func ori()error{
return sql.ErrNoRows
}
func foo() error {
return errors.Wrap(sql.ErrNoRows, "foo failed")
}
func bar() error {
return errors.WithMessage(foo(), "bar failed")
}
func main() {
err := bar()
if errors.Cause(err) == sql.ErrNoRows {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
} else if err != nil {
// unknown error
}
err1 := ori()
if errors.Cause(err1) == sql.ErrNoRows {
fmt.Println("hit on 2")
}
}
使用 errors.WithMessage 或者 errors.Wrap 封装底层的错误,封装完暴露给上层,上层拿到 err 后,再通过 errors.Cause(err) 就可以获取到底层的错误类型比进行比较了。errors 库提供了如下 3 个方法:
// 只附加新的信息
func WithMessage(err error, message string) error
// 只附加调用堆栈信息
func WithStack(err error) error
// 同时附加堆栈和信息
func Wrap(err error, message string) error
Cause 的实现
从源码来看,Cause 使用 for 循环一直找到最根本(最底层)的那个 error 并返回给上层:
func Cause(err error) error {
type causer interface {
Cause() error
}
for err != nil {
cause, ok := err.(causer)
if !ok {
break
}
err = cause.Cause()
}
return err
}