第11节 设计一套科学的错误码



❤️💕💕During the winter vacation, I followed up and learned two projects: tiktok project and IAM project, and summarized and practiced the CloudNative project and Go language. I learned a lot in the process.Myblog:http://nsddd.topopen in new window


[TOC]

如何设计一套科学的错误码

现代的软件架构,很多都是对外暴露 RESTful API 接口,内部系统通信采用 RPC 协议。因为 RESTful API 接口有一些天生的优势,比如规范、调试友好、易懂,所以通常作为直接面向用户的通信规范。

既然是直接面向用户,那么首先就要求消息返回格式是规范的;其次,如果接口报错,还要能给用户提供一些有用的报错信息,通常需要包含 Code 码(用来唯一定位一次错误)和 Message(用来展示出错的信息)。这就需要我们设计一套规范的、科学的错误码。

对于错误码处理有以下的建议:

  1. 唯一性:确保每个错误码都是唯一的,这有助于减少混淆和误解。
  2. 可读性:尽可能让错误码的含义清晰易懂,以便于程序员或用户快速了解错误的类型和含义。
  3. 层级结构:将错误码分为不同的层级结构,以便于根据错误类型进行分类和处理。
  4. 简洁性:错误码应该尽可能简短,以节省存储空间并提高处理效率。
  5. 可扩展性:考虑到未来可能出现新的错误类型,应该为错误码设计一个可扩展的架构。
  6. 易于维护:为了方便维护和管理,建议将错误码统一保存在一个文件或数据库中,以便于更新和维护。
  7. 易于定位:错误码应该包含足够的信息,以便于程序员或用户能够快速定位错误的原因和位置。
  8. 语义化:错误码应该尽可能地与实际错误相关,以便于程序员或用户更好地理解错误。

期望错误码实现的功能

RESTful API 是基于 HTTP 协议的一系列 API 开发规范,HTTP 请求结束后,无论 API 请求成功或失败,都需要让客户端感知到,以便客户端决定下一步该如何处理。

为了让用户拥有最好的体验,需要有一个比较好的错误码实现方式。这里我介绍下在设计错误码时,期望能够实现的功能。

第一个功能是有业务 Code 码标识。

因为 HTTP Code 码有限,并且都是跟 HTTP Transport 层相关的 Code 码,所以我们希望能有自己的错误 Code 码。一方面,可以根据需要自行扩展,另一方面也能够精准地定位到具体是哪个错误。同时,因为 Code 码通常是对计算机友好的 10 进制整数,基于 Code 码,计算机也可以很方便地进行一些分支处理。当然了,业务码也要有一定规则,可以通过业务码迅速定位出是哪类错误。

第二个功能,考虑到安全,希望能够对外对内分别展示不同的错误信息。

当开发一个对外的系统,业务出错时,需要一些机制告诉用户出了什么错误,如果能够提供一些帮助文档会更好。但是,我们不可能把所有的错误都暴露给外部用户,这不仅没必要,也不安全。所以也需要能让我们获取到更详细的内部错误信息的机制,这些内部错误信息可能包含一些敏感的数据,不宜对外展示,但可以协助我们进行问题定位。

所以,我们需要设计的错误码应该是规范的,能方便客户端感知到 HTTP 是否请求成功,并带有业务码和出错信息。

常见的错误码设计方式

在业务中,大致有三种错误码实现方式。我用一次因为用户账号没有找到而请求失败的例子,分别给你解释一下:

第一种方式,不论请求成功或失败,始终返回 200 http status code,在 HTTP Body 中包含用户账号没有找到的错误信息。

例如 Facebook API 的错误 Code 设计,始终返回 200 http status code:

{
  "error": {
    "message": "Syntax error \"Field picture specified more than once. This is only possible before version 2.1\" at character 23: id,name,picture,picture",
    "type": "OAuthException",
    "code": 2500,
    "fbtrace_id": "xxxxxxxxxxx"
  }
}

采用固定返回200 http status code的方式,有其合理性。比如,HTTP Code 通常代表 HTTP Transport 层的状态信息。当我们收到 HTTP 请求,并返回时,HTTP Transport 层是成功的,所以从这个层面上来看,HTTP Status 固定为 200 也是合理的。

但是这个方式的缺点也很明显:对于每一次请求,我们都要去解析 HTTP Body,从中解析出错误码和错误信息。实际上,大部分情况下,我们对于成功的请求,要么直接转发,要么直接解析到某个结构体中;对于失败的请求,我们也希望能够更直接地感知到请求失败。这种方式对性能会有一定的影响,对客户端不友好。所以我不建议你使用这种方式。

第二种方式,返回http 404 Not Found错误码,并在 Body 中返回简单的错误信息。

例如:Twitter API 的错误设计,会根据错误类型,返回合适的 HTTP Code,并在 Body 中返回错误信息和自定义业务 Code。

HTTP/1.1 400 Bad Request
x-connection-hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
set-cookie: guest_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Date: Thu, 01 Jun 2017 03:04:23 GMT
Content-Length: 62
x-response-time: 5
strict-transport-security: max-age=631138519
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Server: tsa_b

{"errors":[{"code":215,"message":"Bad Authentication data."}]}

这种方式比第一种要好一些,通过http status code可以使客户端非常直接地感知到请求失败,并且提供给客户端一些错误信息供参考。但是仅仅靠这些信息,还不能准确地定位和解决问题。

第三种方式,返回http 404 Not Found错误码,并在 Body 中返回详细的错误信息。

例如:微软 Bing API 的错误设计,会根据错误类型,返回合适的 HTTP Code,并在 Body 中返回详尽的错误信息。

HTTP/1.1 400
Date: Thu, 01 Jun 2017 03:40:55 GMT
Content-Length: 276
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Server: Microsoft-IIS/10.0
X-Content-Type-Options: nosniff

{"SearchResponse":{"Version":"2.2","Query":{"SearchTerms":"api error codes"},"Errors":[{"Code":1001,"Message":"Required parameter is missing.","Parameter":"SearchRequest.AppId","HelpUrl":"http\u003a\u002f\u002fmsdn.microsoft.com\u002fen-us\u002flibrary\u002fdd251042.aspx"}]}}

这是我比较推荐的一种方式,既能通过http status code使客户端方便地知道请求出错,又可以使用户根据返回的信息知道哪里出错,以及如何解决问题。同时,返回了机器友好的业务 Code 码,可以在有需要时让程序进一步判断处理。

错误码设计建议

综合刚才讲到的,我们可以总结出一套优秀的错误码设计思路:

  • 有区别于http status code的业务码,业务码需要有一定规则,可以通过业务码判断出是哪类错误。
  • 请求出错时,可以通过http status code直接感知到请求出错。
  • 需要在请求出错时,返回详细的信息,通常包括 3 类信息:
    • 业务 Code 码
    • 错误信息
    • 参考文档(可选)
  • 返回的错误信息,需要是可以直接展示给用户的安全信息,也就是说不能包含敏感信息;同时也要有内部更详细的错误信息,方便 debug。
  • 返回的数据格式应该是固定的、规范的。
  • 错误信息要保持简洁,并且提供有用的信息。

这里其实还有两个功能点需要我们实现:业务 Code 码设计,以及请求出错时,如何设置http status code。

业务 Code 码设计

在实际开发中,引入业务Code码有下面几个好处:

  • 可以非常方便地定位问题和定位代码行(看到错误码知道什么意思、grep错误码可以定位到错误码所在行、某个错误类型的唯一标识)。
  • 错误码包含一定的信息,通过错误码可以判断出错误级别、错误模块和具体错误信息。
  • Go中的HTTP服务器开发都是引用 net/http 包,该包中只有60个错误码,基本都是跟HTTP请求相关的错误码,在一个大型系统中,这些错误码完全不够用,而且这些错误码跟业务没有任何关联,满足不了业务的需求。引入业务的Code码,则可以解决这些问题。
  • 业务开发过程中,可能需要判断错误是哪种类型,以便做相应的逻辑处理,通过定制的错误可以很容易做到这点,例如:
if err == code.ErrBind {
	...
}

这里要注意,业务Code码可以是一个整数,也可以是一个整型字符串,还可以是一个字符型字符串,它是错误的唯一标识。

⚠️ Code 码设计规范:纯数字表示,不同部位代表不同的服务,不同的模块。

错误代码说明:100101:

  • 10: 服务。
  • 01: 某个服务下的某个模块。
  • 01: 模块下的错误码序号,每个模块可以注册100个错误。

通过100101可以知道这个错误是服务 A数据库模块下的记录没有找到错误

你可能会问:按这种设计,每个模块下最多能注册100个错误,是不是有点少?其实在我看来,如果每个模块的错误码超过100个,要么说明这个模块太大了,建议拆分;要么说明错误码设计得不合理,共享性差,需要重新设计。

如何设置HTTP Status Code

Go net/http包提供了60个错误码,大致分为如下5类:

  • 1XX - (指示信息)表示请求已接收,继续处理。
  • 2XX - (请求成功)表示成功处理了请求的状态代码。
  • 3XX - (请求被重定向)表示要完成请求,需要进一步操作。通常,这些状态代码用来重定向。
  • 4XX - (请求错误)这些状态代码表示请求可能出错,妨碍了服务器的处理,通常是客户端出错,需要客户端做进一步的处理。
  • 5XX - (服务器错误)这些状态代码表示服务器在尝试处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是客户端的问题。

可以看到HTTP Code有很多种,如果每个Code都做错误映射,会面临很多问题。比如,研发同学不太好判断错误属于哪种http status code,到最后很可能会导致错误或者http status code不匹配,变成一种形式。而且,客户端也难以应对这么多的HTTP错误码。

所以,这里建议http status code不要太多,基本上只需要这3个HTTP Code:

  • 200 - 表示请求成功执行。
  • 400 - 表示客户端出问题。
  • 500 - 表示服务端出问题。

如果觉得这3个错误码不够用,最多可以加如下3个错误码:

  • 401 - 表示认证失败。
  • 403 - 表示授权失败。
  • 404 - 表示资源找不到,这里的资源可以是URL或者RESTful资源。

将错误码控制在适当的数目内,客户端比较容易处理和判断,开发也比较容易进行错误码映射。

IAM项目错误码设计规范

接下来,我们来看下IAM项目的错误码是如何设计的。

Code 设计规范

先来看下IAM项目业务的Code码设计规范,具体实现可参考internal/pkg/code目录open in new window。IAM项目的错误码设计规范符合上面介绍的错误码设计思路和规范,具体规范见下。

Code 代码从 100001 开始,1000 以下为 github.com/marmotedu/errors 保留 code。

比如错误代码100101,其中 10 代表服务;中间的 01 代表某个服务下的某个模块;最后的 01 代表模块下的错误码序号,每个模块可以注册 100 个错误。

错误代码说明:100001

image-20230221222717542

服务和模块说明

img通用:说明所有服务都适用的错误,提高复用性,避免重复造轮子。

错误信息规范说明

  • 对外暴露的错误,统一大写开头,结尾不要加.
  • 对外暴露的错误要简洁,并能准确说明问题。
  • 对外暴露的错误说明,应该是 该怎么做 而不是 哪里错了

这里你需要注意,错误信息是直接暴露给用户的,不能包含敏感信息。

IAM API接口返回值说明

如果返回结果中存在 code 字段,则表示调用 API 接口失败。例如:

{
  "code": 100101,
  "message": "Database error",
  "reference": "https://github.com/marmotedu/iam/tree/master/docs/guide/zh-CN/faq/iam-apiserver"
}

上述返回中 code 表示错误码,message 表示该错误的具体信息。每个错误同时也对应一个 HTTP 状态码。比如上述错误码对应了 HTTP 状态码 500(Internal Server Error)。另外,在出错时,也返回了reference字段,该字段包含了可以解决这个错误的文档链接地址。

关于IAM 系统支持的错误码,我给你列了一个表格,你可以看看:

image-20230221223015911

image-20230221223033185

如何设计错误包

在 Go 项目开发中,错误是我们必须要处理的一个事项。除了我们上一讲学习过的错误码,处理错误也离不开错误包。

业界有很多优秀的、开源的错误包可供选择,例如 Go 标准库自带的errors包、github.com/pkg/errors包。但是这些包目前还不支持业务错误码,很难满足生产级应用的需求。所以,在实际开发中,我们有必要开发出适合自己错误码设计的错误包。当然,我们也没必要自己从 0 开发,可以基于一些优秀的包来进行二次封装。

要想设计一个优秀的错误包,我们首先得知道一个优秀的错误包需要具备哪些功能。在我看来,至少需要有下面这六个功能:

**首先,应该能支持错误堆栈。**我们来看下面一段代码,假设保存在bad.goopen in new window文件中:

package main

import (
  "fmt"
  "log"
)

func main() {
  if err := funcA(); err != nil {
    log.Fatalf("call func got failed: %v", err)
    return
  }

  log.Println("call func success")
}

func funcA() error {
  if err := funcB(); err != nil {
    return err
  }

  return fmt.Errorf("func called error")
}

func funcB() error {
  return fmt.Errorf("func called error")
}

执行上面的代码

$ go run bad.go
2021/07/02 08:06:55 call func got failed: func called error
exit status 1

这时我们想定位问题,但不知道具体是哪行代码报的错误,只能靠猜,还不一定能猜到。为了解决这个问题,我们可以加一些Debug信息,来协助我们定位问题。这样做在测试环境是没问题的,但是在线上环境,一方面修改、发布都比较麻烦,另一方面问题可能比较难重现。这时候我们会想,要是能打印错误的堆栈就好了。例如:


2021/07/02 14:17:03 call func got failed: func called error
main.funcB
  /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:27
main.funcA
  /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:19
main.main
  /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:10
runtime.main
  /home/colin/go/go1.16.2/src/runtime/proc.go:225
runtime.goexit
  /home/colin/go/go1.16.2/src/runtime/asm_amd64.s:1371
exit status 1

通过上面的错误输出,我们可以很容易地知道是哪行代码报的错,从而极大提高问题定位的效率,降低定位的难度。所以,在我看来,一个优秀的errors包,首先需要支持错误堆栈。

**其次,能够支持不同的打印格式。**例如%+v%v%s等格式,可以根据需要打印不同丰富度的错误信息。

**再次,能支持Wrap/Unwrap功能,也就是在已有的错误上,追加一些新的信息。**例如errors.Wrap(err, "open file failed") 。Wrap通常用在调用函数中,调用函数可以基于被调函数报错时的错误Wrap一些自己的信息,丰富报错信息,方便后期的错误定位,例如:

func funcA() error {
    if err := funcB(); err != nil {
        return errors.Wrap(err, "call funcB failed")
    }

    return errors.New("func called error")
}

func funcB() error {
    return errors.New("func called error")
}

这里要注意,不同的错误类型,Wrap函数的逻辑也可以不同。另外,在调用Wrap时,也会生成一个错误堆栈节点。我们既然能够嵌套error,那有时候还可能需要获取被嵌套的error,这时就需要错误包提供Unwrap函数。

还有,错误包应该有Is方法。在实际开发中,我们经常需要判断某个error是否是指定的error。在Go 1.13之前,也就是没有wrapping error的时候,我们要判断error是不是同一个,可以使用如下方法:

if err == os.ErrNotExist {	// normal code}

但是现在,因为有了wrapping error,这样判断就会有问题。因为你根本不知道返回的err是不是一个嵌套的error,嵌套了几层。这种情况下,我们的错误包就需要提供Is函数:

func Is(err, target error) bool

当err和target是同一个,或者err是一个wrapping error的时候,如果target也包含在这个嵌套error链中,返回true,否则返回fasle。

另外,错误包应该支持 As 函数。

在Go 1.13之前,没有wrapping error的时候,我们要把error转为另外一个error,一般都是使用type assertion或者type switch,也就是类型断言。例如:

if perr, ok := err.(*os.PathError); ok {	fmt.Println(perr.Path)}

但是现在,返回的err可能是嵌套的error,甚至好几层嵌套,这种方式就不能用了。所以,我们可以通过实现 As 函数来完成这种功能。现在我们把上面的例子,用 As 函数实现一下:

var perr *os.PathError
if errors.As(err, &perr) {
  fmt.Println(perr.Path)
}

这样就可以完全实现类型断言的功能,而且还更强大,因为它可以处理 wrapping error

**最后,能够支持两种错误创建方式:非格式化创建和格式化创建。**例如:

errors.New("file not found")
errors.Errorf("file %s not found", "iam-apiserver")

上面,我们介绍了一个优秀的错误包应该具备的功能。一个好消息是,Github上有不少实现了这些功能的错误包,其中github.com/pkg/errors包最受欢迎。所以,我基于github.com/pkg/errors包进行了二次封装,用来支持上一讲所介绍的错误码。

错误包实现

明确优秀的错误包应该具备的功能后,我们来看下错误包的实现。实现的源码存放在github.com/marmotedu/errorsopen in new window

我通过在文件github.com/pkg/errors/errors.goopen in new window中增加新的withCode结构体,来引入一种新的错误类型,该错误类型可以记录错误码、stack、cause和具体的错误信息。


type withCode struct {
    err   error // error 错误
    code  int // 业务错误码
    cause error // cause error
    *stack // 错误堆栈
}

下面,我们通过一个示例,来了解下github.com/marmotedu/errors所提供的功能。假设下述代码保存在errors.go文件中:


package main

import (
  "fmt"

  "github.com/marmotedu/errors"
  code "github.com/marmotedu/sample-code"
)

func main() {
  if err := bindUser(); err != nil {
    // %s: Returns the user-safe error string mapped to the error code or the error message if none is specified.
    fmt.Println("====================> %s <====================")
    fmt.Printf("%s\n\n", err)

    // %v: Alias for %s.
    fmt.Println("====================> %v <====================")
    fmt.Printf("%v\n\n", err)

    // %-v: Output caller details, useful for troubleshooting.
    fmt.Println("====================> %-v <====================")
    fmt.Printf("%-v\n\n", err)

    // %+v: Output full error stack details, useful for debugging.
    fmt.Println("====================> %+v <====================")
    fmt.Printf("%+v\n\n", err)

    // %#-v: Output caller details, useful for troubleshooting with JSON formatted output.
    fmt.Println("====================> %#-v <====================")
    fmt.Printf("%#-v\n\n", err)

    // %#+v: Output full error stack details, useful for debugging with JSON formatted output.
    fmt.Println("====================> %#+v <====================")
    fmt.Printf("%#+v\n\n", err)

    // do some business process based on the error type
    if errors.IsCode(err, code.ErrEncodingFailed) {
      fmt.Println("this is a ErrEncodingFailed error")
    }

    if errors.IsCode(err, code.ErrDatabase) {
      fmt.Println("this is a ErrDatabase error")
    }

    // we can also find the cause error
    fmt.Println(errors.Cause(err))
  }
}

func bindUser() error {
  if err := getUser(); err != nil {
    // Step3: Wrap the error with a new error message and a new error code if needed.
    return errors.WrapC(err, code.ErrEncodingFailed, "encoding user 'Lingfei Kong' failed.")
  }

  return nil
}

func getUser() error {
  if err := queryDatabase(); err != nil {
    // Step2: Wrap the error with a new error message.
    return errors.Wrap(err, "get user failed.")
  }

  return nil
}

func queryDatabase() error {
  // Step1. Create error with specified error code.
  return errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.")
}

上述代码中,通过WithCodeopen in new window函数来创建新的withCode类型的错误;通过WrapCopen in new window来将一个error封装成一个withCode类型的错误;通过IsCodeopen in new window来判断一个error链中是否包含指定的code。

withCode错误实现了一个func (w *withCode) Format(state fmt.State, verb rune)方法,该方法用来打印不同格式的错误信息,见下表:

image-20230221225636739

例如,%+v会打印以下错误信息:

get user failed. - #1 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:19 (main.getUser)] (100101) Database error; user 'Lingfei Kong' not found. - #0 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:26 (main.queryDatabase)] (100101) Database error

那么你可能会问,这些错误信息中的100101错误码,还有Database error这种对外展示的报错信息等等,是从哪里获取的?这里我简单解释一下。

首先, withCode 中包含了int类型的错误码,例如100101

其次,当使用github.com/marmotedu/errors包的时候,需要调用Register或者MustRegister,将一个Coder注册到github.com/marmotedu/errors开辟的内存中,数据结构为:

var codes = map[int]Coder{}

Coder是一个接口,定义为:


type Coder interface {
    // HTTP status that should be used for the associated error code.
    HTTPStatus() int

    // External (user) facing error text.
    String() string

    // Reference returns the detail documents for user.
    Reference() string

    // Code returns the code of the coder
    Code() int
}

这样 withCodeFormat方法,就能够通过 withCode 中的code字段获取到对应的Coder,并通过Coder提供的HTTPStatus、String、Reference、Code函数,来获取 withCode 中code的详细信息,最后格式化打印。

这里要注意,我们实现了两个注册函数:RegisterMustRegister,二者唯一区别是:当重复定义同一个错误Code时,MustRegister会panic,这样可以防止后面注册的错误覆盖掉之前注册的错误。在实际开发中,建议使用MustRegister

XXX()MustXXX()的函数命名方式,是一种Go代码设计技巧,在Go代码中经常使用,例如Go标准库中regexp包提供的CompileMustCompile函数。和XXX相比,MustXXX 会在某种情况不满足时panic。因此使用MustXXX的开发者看到函数名就会有一个心理预期:使用不当,会造成程序panic。

最后,我还有一个建议:在实际的生产环境中,我们可以使用JSON格式打印日志,JSON格式的日志可以非常方便的供日志系统解析。我们可以根据需要,选择%#-v%#+v两种格式。

错误包在代码中,经常被调用,所以我们要保证错误包一定要是高性能的,否则很可能会影响接口的性能。这里,我们再来看下github.com/marmotedu/errors包的性能。

在这里,我们把这个错误包跟go标准库的 errors 包,以及 github.com/pkg/errors 包进行对比,来看看它们的性能:

$  go test -test.bench=BenchmarkErrors -benchtime="3s"
goos: linux
goarch: amd64
pkg: github.com/marmotedu/errors
BenchmarkErrors/errors-stack-10-8           57658672          61.8 ns/op        16 B/op         1 allocs/op
BenchmarkErrors/pkg/errors-stack-10-8        2265558        1547 ns/op       320 B/op         3 allocs/op
BenchmarkErrors/marmot/errors-stack-10-8     1903532        1772 ns/op       360 B/op         5 allocs/op
BenchmarkErrors/errors-stack-100-8           4883659         734 ns/op        16 B/op         1 allocs/op
BenchmarkErrors/pkg/errors-stack-100-8       1202797        2881 ns/op       320 B/op         3 allocs/op
BenchmarkErrors/marmot/errors-stack-100-8    1000000        3116 ns/op       360 B/op         5 allocs/op
BenchmarkErrors/errors-stack-1000-8           505636        7159 ns/op        16 B/op         1 allocs/op
BenchmarkErrors/pkg/errors-stack-1000-8       327681       10646 ns/op       320 B/op         3 allocs/op
BenchmarkErrors/marmot/errors-stack-1000-8             304160       11896 ns/op       360 B/op         5 allocs/op
PASS
ok    github.com/marmotedu/errors  39.200s

可以看到github.com/marmotedu/errorsgithub.com/pkg/errors包的性能基本持平。在对比性能时,重点关注ns/op,也即每次error操作耗费的纳秒数。另外,我们还需要测试不同error嵌套深度下的error操作性能,嵌套越深,性能越差。例如:在嵌套深度为10的时候, github.com/pkg/errorsns/op 值为1547, github.com/marmotedu/errorsns/op 值为1772。可以看到,二者性能基本保持一致。

具体性能数据对比见下表:

imgasdfas

我们是通过BenchmarkErrorsopen in new window测试函数来测试error包性能的,你感兴趣可以打开链接看看。

如何记录错误?

上面,我们一起看了怎么设计一个优秀的错误包,那如何用我们设计的错误包来记录错误呢?

根据我的开发经验,我推荐两种记录错误的方式,可以帮你快速定位问题。

方式一:通过github.com/marmotedu/errors包提供的错误堆栈能力,来跟踪错误。

具体你可以看看下面的代码示例。以下代码保存在errortrack_errors.goopen in new window中。


package main

import (
  "fmt"

  "github.com/marmotedu/errors"

  code "github.com/marmotedu/sample-code"
)

func main() {
  if err := getUser(); err != nil {
    fmt.Printf("%+v\n", err)
  }
}

func getUser() error {
  if err := queryDatabase(); err != nil {
    return errors.Wrap(err, "get user failed.")
  }

  return nil
}

func queryDatabase() error {
  return errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.")
}

执行上述的代码:

$ go run errortrack_errors.go
get user failed. - #1 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:19 (main.getUser)] (100101) Database error; user 'Lingfei Kong' not found. - #0 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:26 (main.queryDatabase)] (100101) Database error

可以看到,打印的日志中打印出了详细的错误堆栈,包括错误发生的函数、文件名、行号和错误信息,通过这些错误堆栈,我们可以很方便地定位问题。

你使用这种方法时,我推荐的用法是,在错误最开始处使用 errors.WithCode() 创建一个 withCode类型的错误。上层在处理底层返回的错误时,可以根据需要,使用Wrap函数基于该错误封装新的错误信息。如果要包装的error不是用github.com/marmotedu/errors包创建的,建议用 errors.WithCode() 新建一个error。

方式二:在错误产生的最原始位置调用日志包记录函数,打印错误信息,其他位置直接返回(当然,也可以选择性的追加一些错误信息,方便故障定位)。示例代码(保存在errortrack_log.goopen in new window)如下:


package main

import (
  "fmt"

  "github.com/marmotedu/errors"
  "github.com/marmotedu/log"

  code "github.com/marmotedu/sample-code"
)

func main() {
  if err := getUser(); err != nil {
    fmt.Printf("%v\n", err)
  }
}

func getUser() error {
  if err := queryDatabase(); err != nil {
    return err
  }

  return nil
}

func queryDatabase() error {
  opts := &log.Options{
    Level:            "info",
    Format:           "console",
    EnableColor:      true,
    EnableCaller:     true,
    OutputPaths:      []string{"test.log", "stdout"},
    ErrorOutputPaths: []string{},
  }

  log.Init(opts)
  defer log.Flush()

  err := errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.")
  if err != nil {
    log.Errorf("%v", err)
  }
  return err
}

执行以上代码:

$ go run errortrack_log.go
2021-07-03 14:37:31.597  ERROR  errors/errortrack_log.go:41  Database error
Database error

当错误发生时,调用log包打印错误。通过log包的caller功能,可以定位到log语句的位置,也就是定位到错误发生的位置。你使用这种方式来打印日志时,我有两个建议。

  • 只在错误产生的最初位置打印日志,其他地方直接返回错误,一般不需要再对错误进行封装。
  • 当代码调用第三方包的函数时,第三方包函数出错时打印错误信息。比如:
if err := os.Chdir("/root"); err != nil {    log.Errorf("change dir failed: %v", err)}

一个错误码的具体实现

接下来,我们看一个依据上一讲介绍的错误码规范的具体错误码实现github.com/marmotedu/sample-code

sample-code实现了两类错误码,分别是通用错误码(sample-code/base.goopen in new window)和业务模块相关的错误码(sample-code/apiserver.goopen in new window)。

首先,我们来看通用错误码的定义:


// 通用: 基本错误
// Code must start with 1xxxxx
const (
    // ErrSuccess - 200: OK.
    ErrSuccess int = iota + 100001

    // ErrUnknown - 500: Internal server error.
    ErrUnknown

    // ErrBind - 400: Error occurred while binding the request body to the struct.
    ErrBind

    // ErrValidation - 400: Validation failed.
    ErrValidation

    // ErrTokenInvalid - 401: Token invalid.
    ErrTokenInvalid
)

在代码中,我们通常使用整型常量(ErrSuccess)来代替整型错误码(100001),因为使用ErrSuccess时,一看就知道它代表的错误类型,可以方便开发者使用。

错误码用来指代一个错误类型,该错误类型需要包含一些有用的信息,例如对应的HTTP Status Code、对外展示的Message,以及跟该错误匹配的帮助文档。所以,我们还需要实现一个Coder来承载这些信息。这里,我们定义了一个实现了github.com/marmotedu/errors.Coder接口的ErrCode结构体:


// ErrCode implements `github.com/marmotedu/errors`.Coder interface.
type ErrCode struct {
    // C refers to the code of the ErrCode.
    C int

    // HTTP status that should be used for the associated error code.
    HTTP int

    // External (user) facing error text.
    Ext string

    // Ref specify the reference document.
    Ref string
}

可以看到ErrCode结构体包含了以下信息:

  • int类型的业务码。
  • 对应的HTTP Status Code。
  • 暴露给外部用户的消息。
  • 错误的参考文档。

下面是一个具体的Coder示例:

coder := &ErrCode{
    C:    100001,
    HTTP: 200,
    Ext:  "OK",
    Ref:  "https://github.com/marmotedu/sample-code/blob/master/README.md",
}

接下来,我们就可以调用github.com/marmotedu/errors包提供的Register或者MustRegister函数,将Coder注册到github.com/marmotedu/errors包维护的内存中。

一个项目有很多个错误码,如果每个错误码都手动调用MustRegister函数会很麻烦,这里我们通过代码自动生成的方法,来生成register函数调用:

//go:generate codegen -type=int
//go:generate codegen -type=int -doc -output ./error_code_generated.md

//go:generate codegen -type=int 会调用codegenopen in new window工具,生成sample_code_generated.goopen in new window源码文件:

func init() {
  register(ErrSuccess, 200, "OK")
  register(ErrUnknown, 500, "Internal server error")
  register(ErrBind, 400, "Error occurred while binding the request body to the struct")
  register(ErrValidation, 400, "Validation failed")
    // other register function call
}

这些registeropen in new window调用放在init函数中,在加载程序的时候被初始化。

这里要注意,在注册的时候,我们会检查HTTP Status Code,只允许定义200、400、401、403、404、500这6个HTTP错误码。这里通过程序保证了错误码是符合HTTP Status Code使用要求的。

//go:generate codegen -type=int -doc -output ./error_code_generated.md会生成错误码描述文档 error_code_generated.mdopen in new window。当我们提供API文档时,也需要记着提供一份错误码描述文档,这样客户端才可以根据错误码,知道请求是否成功,以及具体发生哪类错误,好针对性地做一些逻辑处理。

codegen工具会根据错误码注释生成sample_code_generated.goerror_code_generated.md文件:

// ErrSuccess - 200: OK. ErrSuccess int = iota + 100001

codegen工具之所以能够生成sample_code_generated.goerror_code_generated.md,是因为我们的错误码注释是有规定格式的:// <错误码整型常量> - <对应的HTTP Status Code>: <External Message>.

codegen工具可以在IAM项目根目录下,执行以下命令来安装:

$ make tools.install.codegen

安装完 codegen 工具后,可以在 github.com/marmotedu/sample-code 包根目录下执行 go generate 命令,来生成sample_code_generated.goerror_code_generated.md。这里有个技巧需要你注意:生成的文件建议统一用 xxxx_generated.xx 来命名,这样通过 generated ,我们就知道这个文件是代码自动生成的,有助于我们理解和使用。

在实际的开发中,我们可以将错误码独立成一个包,放在 internal/pkg/code/目录下,这样可以方便整个应用调用。例如IAM的错误码就放在IAM项目根目录下的internal/pkg/code/open in new window目录下。

我们的错误码是分服务和模块的,所以这里建议你把相同的服务放在同一个Go源文件中,例如IAM的错误码存放文件:

$ ls base.go apiserver.go authzserver.go apiserver.go  authzserver.go  base.go

一个应用中会有多个服务,例如IAM应用中,就包含了iam-apiserveriam-authz-serveriam-pump三个服务。这些服务有一些通用的错误码,为了便于维护,可以将这些通用的错误码统一放在base.go源码文件中。其他的错误码,我们可以按服务分别放在不同的文件中:iam-apiserver服务的错误码统一放在apiserver.go文件中;iam-authz-server的错误码统一存放在authzserver.go文件中。其他服务以此类推。

另外,同一个服务中不同模块的错误码,可以按以下格式来组织:相同模块的错误码放在同一个const代码块中,不同模块的错误码放在不同的const代码块中。每个const代码块的开头注释就是该模块的错误码定义。例如:

// iam-apiserver: user errors.
const (
    // ErrUserNotFound - 404: User not found.
    ErrUserNotFound int = iota + 110001

    // ErrUserAlreadyExist - 400: User already exist.
    ErrUserAlreadyExist
)

// iam-apiserver: secret errors.
const (
    // ErrEncrypt - 400: Secret reach the max count.
    ErrReachMaxCount int = iota + 110101

    //  ErrSecretNotFound - 404: Secret not found.
    ErrSecretNotFound
)

最后,我们还需要将错误码定义记录在项目的文件中,供开发者查阅、遵守和使用,例如IAM项目的错误码定义记录文档为code_specification.mdopen in new window。这个文档中记录了错误码说明、错误描述规范和错误记录规范等。

错误码实际使用方法示例

上面,我讲解了错误包和错误码的实现方式,那你一定想知道在实际开发中我们是如何使用的。这里,我就举一个在gin web框架中使用该错误码的例子:

// Response defines project response format which in marmotedu organization.
type Response struct {
    Code      errors.Code `json:"code,omitempty"`
    Message   string      `json:"message,omitempty"`
    Reference string      `json:"reference,omitempty"`
    Data      interface{} `json:"data,omitempty"`
}

// WriteResponse used to write an error and JSON data into response.
func WriteResponse(c *gin.Context, err error, data interface{}) {
    if err != nil {
        coder := errors.ParseCoder(err)

        c.JSON(coder.HTTPStatus(), Response{
            Code:      coder.Code(),
            Message:   coder.String(),
            Reference: coder.Reference(),
            Data:      data,
        })
    }

    c.JSON(http.StatusOK, Response{Data: data})
}

func GetUser(c *gin.Context) {
    log.Info("get user function called.", "X-Request-Id", requestid.Get(c))
    // Get the user by the `username` from the database.
    user, err := store.Client().Users().Get(c.Param("username"), metav1.GetOptions{})
    if err != nil {
        core.WriteResponse(c, errors.WithCode(code.ErrUserNotFound, err.Error()), nil)
        return
    }

    core.WriteResponse(c, nil, user)
}

上述代码中,通过WriteResponse统一处理错误。在 WriteResponse 函数中,如果err != nil,则从error中解析出Coder,并调用Coder提供的方法,获取错误相关的Http Status Code、int类型的业务码、暴露给用户的信息、错误的参考文档链接,并返回JSON格式的信息。如果 err == nil 则返回200和数据。

END 链接