Kratos 源码分析:Ecode 错误代码

分析 Kratos 的 Error-code

Posted by pandaychen on July 10, 2020

0x00 背景

本篇文章来分析下 Kratos 对错误码的封装(HTTP && RPC)。一般而言,错误码封装的方式:

  1. 整形值的错误码
  2. 错误码对应的出错信息
  3. 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; // 正式错误码
}

需要注意以下几点:

  1. 必须是 enum 类型,且名字规范必须以 “ErrCode” 结尾,如:UserErrCode
  2. 因为 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 的结构,因为全局变量 _messagesatomic.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())

StringInt 方法

ecode 包提供了 IntString 两个方法,用来将 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 类型,通常而言,错误码转换有以下两种情况:

  1. 因为框架传递错误是靠 ecode 错误码,比如 bm 框架返回的 code 字段默认就是数字,那么客户端接收到如 {"code":-404} 的话,可以使用 ec := ecode.Int(-404)ec := ecode.String("-404") 来进行转换
  2. 在项目中 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 方法,来判断(两个)错误码是否相等:

  • ecodeecode 判断:使用 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
}
  1. ecode 包内的 Status 结构体实现了 Codes 接口 代码位置
  2. warden/internal/status 包内包装了 ecode.Statusgrpc.Status 进行互相转换的方法 代码位置
  3. wardenclientserver 则使用转换方法将 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.ServerErr
  • ecode.ServiceUnavailable
  • ecode.Deadline:针对接口调用超时 context.DeadlineExceeded 的转换型 ecode.Deadline
  • ecode.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
}

0x06 参考