如何規範 RESTful API 的業務錯誤處理

現如今,主流的 Web API 都採用 RESTful 設計風格,對於接口返回的 HTTP 狀態碼和響應內容都有統一的規範。針對接口錯誤響應,一般都會返回一個 Code(錯誤碼)和 Message(錯誤消息內容),通常錯誤碼 Code 用來定位一個唯一的錯誤,錯誤消息 Message 用來展示錯誤信息。

本文就來詳細介紹下,如何將 RESTful API 的錯誤處理進行規範化。

錯誤碼

爲什麼需要業務錯誤碼

雖然 RESTful API 能夠通過 HTTP 狀態碼來標記一個請求的成功或失敗,但 HTTP 狀態碼作爲一個通用的標準,並不能很好的表達業務錯誤。

比如一個 500 的錯誤響應,可能是由後端數據庫連接異常引起的、也可能由內部代碼邏輯錯誤引起,這些都無法通過 HTTP 狀態碼感知到,如果程序出現錯誤,不方便開發人員 Debug。

因此我們有必要設計一套用來標識業務錯誤的錯誤碼,這有別於 HTTP 狀態碼,是跟系統具體業務息息相關的。

錯誤碼功能

在設計錯誤碼之前,我們需要明確下錯誤碼應該具備哪些屬性,以滿足業務需要。

錯誤碼設計

錯誤碼調研

錯誤碼的設計我們可以參考業內使用量比較大的開放 API 設計,比較有代表性的是阿里雲和新浪網的開放 API。

如以下是一個阿里雲 ECS 接口錯誤的返回:

{
 "RequestId": "5E571499-13C5-55E3-9EA6-DEFA0DBC85E4",
 "HostId": "ecs-cn-hangzhou.aliyuncs.com",
 "Code": "InvalidOperation.NotSupportedEndpoint",
 "Message": "The specified endpoint can't operate this region. Please use API DescribeRegions to get the appropriate endpoint, or upgrade your SDK to latest version.",
 "Recommend": "https://next.api.aliyun.com/troubleshoot?q=InvalidOperation.NotSupportedEndpoint&product=Ecs"
}

可以發現,Code 和 Message 都爲字符串類型,並且還有 RequestId(當前請求唯一標識)、HostId(Host 唯一標識)、Recommend(錯誤診斷地址),可以說這個錯誤信息非常全面了。

再來看下新浪網開放 API 錯誤返回結果的設計:

{
 "request": "/statuses/home_timeline.json",
 "error_code": "20502",
 "error": "Need you follow uid."
}

相比阿里雲,新浪網的錯誤返回更簡潔一些。其中 request 爲請求路徑,error_code 即爲錯誤碼 Code,error 則表示錯誤信息 Message。

錯誤代碼 20502 說明如下:

aPht56

新浪網的錯誤碼爲數字類型的字符串,相比阿里雲的錯誤碼要簡短不少,並且對程序更加友好,也是我個人更推薦的設計。

業務錯誤碼

結合市面上這些優秀的開放 API 錯誤碼設計,以及我在實際開發中的工作總結,我設計的錯誤碼規則如下:

yJEp2x

錯誤碼設計爲純數字主要是爲了程序中使用起來更加方便,比如根據錯誤碼計算 HTTP 狀態碼,只需要通過簡單的數學取模計算就能做到。

使用兩位數字來標記不同組件,最多能表示 99 個組件,即使項目全部採用微服務開發,一般來說也是足夠用的。

最後三位代表組件內部錯誤碼,最多能表示 1000 個錯誤。其實通常來說一個組件內部是用不上這麼多錯誤的,如果組件較小,完全可以設計成兩位數字。

另外,有些廠商中還會設計一些公共的錯誤碼,可以稱爲「全局錯誤碼」,這些錯誤碼在各組件間通用,以此來減少定義重複錯誤。在我們的錯誤碼設計中,可以將組件編號爲 00 的標記爲全局錯誤碼,其他組件編號從 01 開始。

錯誤格式

有了錯誤碼,還需要定義錯誤響應格式,設計一個標準的 API 錯誤響應格式如下:

{
 "code": 50000000,
 "message": "系統錯誤",
 "reference": "https://github.com/jianghushinian/gokit/tree/main/errors"
}

code 即爲錯誤碼,message 爲錯誤信息,reference 則是錯誤文檔地址,用來告知用戶如何解決這個錯誤,對標的是阿里雲錯誤響應中的 Recommend 字段。

錯誤碼實現

因爲每一個錯誤碼和錯誤信息以及錯誤文檔地址都是一一對應的,所以我們需要一個對象來保存這些信息,在 Go 中可以使用結構體。

可以設計如下結構體:

type apiCode struct {
 code int
 msg  string
 ref  string
}

這是一個私有結構體,外部項目要想使用,則需要一個構造函數:

func NewAPICode(code int, message string, reference ...string) APICoder {
 ref := ""
 if len(reference) > 0 {
  ref = reference[0]
 }
 return &apiCode{
  code: code,
  msg:  message,
  ref:  ref,
 }
}

其中 reference 被設計爲可變參數,如果不傳則默認爲空。

NewAPICode 返回值 APICoder 是一個接口,這在 Go 中是一種慣用做法。通過接口可以解耦,方便依賴 apiCode 的代碼編寫測試,用戶可以對 APICoder 進行 Mock;另一方面,我們稍後會爲 apiCode 實現對應的錯誤包,使用接口來表示錯誤碼可以方便用戶定義自己的 apiCode 類型。

爲了便於使用,apiCode 提供瞭如下幾個能力:

func (a *apiCode) Code() int {
 return a.code
}

func (a *apiCode) Message() string {
 return a.msg
}

func (a *apiCode) Reference() string {
 return a.ref
}

func (a *apiCode) HTTPStatus() int {
 v := a.Code()
 for v >= 1000 {
  v /= 10
 }
 return v
}

至此 APICoder 接口接口的定義也就有了:

type APICoder interface {
 Code() int
 Message() string
 Reference() string
 HTTPStatus() int
}

apiCode 則實現了 APICoder 接口。

現在我們可以通過如下方式創建錯誤碼結構體對象:

var (
 CodeBadRequest   = NewAPICode(40001001, "請求不合法")
 CodeUnknownError = NewAPICode(50001001, "系統錯誤", "https://github.com/jianghushinian/gokit/tree/main/errors")
)

錯誤包

設計好了錯誤碼,並不能直接使用,我們還需要一個與之配套的錯誤包來簡化錯誤碼的使用。

錯誤包功能

錯誤包設計

一個錯誤對象結構體設計如下:

type apiError struct {
 coder APICoder
 cause error
 *stack
}

其中 coder 用來保存實現了 APICoder 接口的對象,cause 用來記錄錯誤原因,stack 用來展示錯誤堆棧。

錯誤對象的構造函數如下:

var WrapC = NewAPIError

func NewAPIError(coder APICoder, cause ...error) error {
 var c error
 if len(cause) > 0 {
  c = cause[0]
 }
 return &apiError{
  coder: coder,
  cause: c,
  stack: callers(),
 }
}

NewAPIError 通過 APICoder 來創建錯誤對象,第二個參數爲一個可選的錯誤原因。

其實構造一個錯誤對象也就是對一個錯誤進行 Wrap 的過程,所以我還爲構造函數 NewAPIError 定義了一個別名 WrapC,表示使用錯誤碼將一個錯誤包裝成一個新的錯誤。

一個錯誤對象必須要實現 Error 方法:

func (a *apiError) Error() string {
 return fmt.Sprintf("[%d] - %s", a.coder.Code(), a.coder.Message())
}

默認情況下,獲取到的錯誤內容只包含錯誤碼 Code 和錯誤信息 Message。

爲了方便獲取被包裝錯誤的原始錯誤,還要實現 Unwrap 方法:

func (a *apiError) Unwrap() error {
 return a.cause
}

爲了能在打印或寫入日誌時展示不同信息,則要實現 Format 方法:

func (a *apiError) Format(s fmt.State, verb rune) {
 switch verb {
 case 'v':
  if s.Flag('+') {
   str := a.Error()
   if a.Unwrap() != nil {
    str += " " + a.Unwrap().Error()
   }
   _, _ = io.WriteString(s, str)
   a.stack.Format(s, verb)
   return
  }
  if s.Flag('#') {
   cause := ""
   if a.Unwrap() != nil {
    cause = a.Unwrap().Error()
   }
   data, _ := json.Marshal(errorMessage{
    Code:      a.coder.Code(),
    Message:   a.coder.Message(),
    Reference: a.coder.Reference(),
    Cause:     cause,
    Stack:     fmt.Sprintf("%+v", a.stack),
   })
   _, _ = io.WriteString(s, string(data))
   return
  }
  fallthrough
 case 's':
  _, _ = io.WriteString(s, a.Error())
 case 'q':
  _, _ = fmt.Fprintf(s, "%q", a.Error())
 }
}

Format 方法能夠支持在使用 fmt.Printf("%s", apiError) 格式化輸出時打印定製化的信息。

Format 方法支持的不同格式輸出如下:

IoGu7L

這些錯誤格式基本上能滿足所有業務開發中的需求了,如果還有其他格式需要,則可以在此基礎上進一步開發 Format 方法。

用來進行 JSON 序列化和反序列化的 MarshalJSON/UnmarshalJSON 方法實現如下:

func (a *apiError) MarshalJSON() ([]byte, error) {
 return json.Marshal(&errorMessage{
  Code:      a.coder.Code(),
  Message:   a.coder.Message(),
  Reference: a.coder.Reference(),
 })
}

func (a *apiError) UnmarshalJSON(data []byte) error {
 e := &errorMessage{}
 if err := json.Unmarshal(data, e); err != nil {
  return err
 }
 a.coder = NewAPICode(e.Code, e.Message, e.Reference)
 return nil
}

type errorMessage struct {
 Code      int    `json:"code"`
 Message   string `json:"message"`
 Reference string `json:"reference,omitempty"`
 Cause     string `json:"cause,omitempty"`
 Stack     string `json:"stack,omitempty"`
}

爲了不對外部暴露敏感信息,對外的 HTTP API 只會返回 CodeMessageReference(可選)三個字段,對內需要額外展示錯誤原因以及錯誤堆棧。所以 errorMessageReferenceCauseStack 字段都帶有 omitempty 屬性,這樣在 MarshalJSON 時只會序列化 CodeMessageReference 這三個字段。

至此,我們就實現了錯誤包的設計。

錯誤碼及錯誤包的使用

使用示例

通過上面的講解,我們瞭解了錯誤碼和錯誤包的設計規範,接下來看看如何使用它們。這裏以錯誤碼及錯誤包在 Gin 中的使用爲例進行講解。

使用 Gin 構建一個簡單的 Web Server 如下:

package main

import (
 "errors"
 "fmt"
 "strconv"

 "github.com/gin-gonic/gin"

 apierr "github.com/jianghushinian/gokit/errors"
)

var (
 ErrAccountNotFound = errors.New("account not found")
 ErrDatabase        = errors.New("database error")
)

var (
 CodeBadRequest   = NewAPICode(40001001, "請求不合法")
 CodeNotFound     = NewAPICode(40401001, "資源未找到")
 CodeUnknownError = NewAPICode(50001001, "系統錯誤", "https://github.com/jianghushinian/gokit/tree/main/errors")
)

type Account struct {
 ID   int    `json:"id"`
 Name string `json:"name"`
}

func AccountOne(id int) (*Account, error) {
 for _, v := range accounts {
  if id == v.ID {
   return &v, nil
  }
 }

 // 模擬返回數據庫錯誤
 if id == 500 {
  return nil, ErrDatabase
 }
 return nil, ErrAccountNotFound
}

var accounts = []Account{
 {ID: 1, Name: "account_1"},
 {ID: 2, Name: "account_2"},
 {ID: 3, Name: "account_3"},
}

func ShowAccount(c *gin.Context) {
 id := c.Param("id")
 aid, err := strconv.Atoi(id)
 if err != nil {
  // 將 errors 包裝成 APIError 返回
  ResponseError(c, apierr.WrapC(CodeBadRequest, err))
  return
 }

 account, err := AccountOne(aid)
 if err != nil {
  switch {
  case errors.Is(err, ErrAccountNotFound):
   err = apierr.NewAPIError(CodeNotFound, err)
  case errors.Is(err, ErrDatabase):
   err = apierr.NewAPIError(CodeUnknownError, fmt.Errorf("account %d: %w", aid, err))
  }
  ResponseError(c, err)
  return
 }
 ResponseOK(c, account)
}

func main() {
 r := gin.Default()

 r.GET("/accounts/:id", ShowAccount)

 if err := r.Run(":8080"); err != nil {
  panic(err)
 }
}

在這個 Web Server 中定義了一個 ShowAccount 函數,用來處理獲取賬號邏輯,在 ShowAccount 內部程序執行成功返回 ResponseOK(c, account),失敗則返回 ResponseError(c, err)

在處理返回失敗的響應時,都會通過 apierr.WrapCapierr.NewAPIError 將底層函數返回的初始錯誤進行一層包裝,根據錯誤級別,包裝成不同的錯誤碼進行返回。

其中 ResponseOKResponseError 定義如下:

func ResponseOK(c *gin.Context, spec interface{}) {
 if spec == nil {
  c.Status(http.StatusNoContent)
  return
 }
 c.JSON(http.StatusOK, spec)
}

func ResponseError(c *gin.Context, err error) {
 log(err)
 e := apierr.ParseCoder(err)
 httpStatus := e.HTTPStatus()
 if httpStatus >= 500 {
  // send error msg to email/feishu/sentry...
  go fakeSendErrorEmail(err)
 }
 c.AbortWithStatusJSON(httpStatus, err)
}

// log 打印錯誤日誌,輸出堆棧
func log(err error) {
 fmt.Println("========== log start ==========")
 fmt.Printf("%+v\n", err)
 fmt.Println("========== log end ==========")
}

// fakeSendErrorEmail 模擬將錯誤信息發送到郵件,JSON 格式
func fakeSendErrorEmail(err error) {
 fmt.Println("========== error start ==========")
 fmt.Printf("%#v\n", err)
 fmt.Println("========== error end ==========")
}

ResponseOK 其實就是 Gin 框架的正常返回,ResponseError 則專門用來處理並返回 API 錯誤。

ResponseError 中首先通過 log(err) 來記錄錯誤日誌,在其內部使用 fmt.Printf("%+v\n", err) 進行打印。

之後我們還對 HTTP 狀態碼進行了判斷,大於 500 的錯誤將會發送郵件通知,這裏使用 fmt.Printf("%#v\n", err) 進行模擬。

其中 apierr.ParseCoder(err) 能夠從一個錯誤對象中獲取到實現了 APICoder 的錯誤碼對象,實現如下:

func ParseCoder(err error) APICoder {
 for {
  if e, ok := err.(interface {
   Coder() APICoder
  }); ok {
   return e.Coder()
  }
  if errors.Unwrap(err) == nil {
   return CodeUnknownError
  }
  err = errors.Unwrap(err)
 }
}

這樣,我們就能夠通過一個簡單的 Web Server 示例程序來演示如何使用錯誤碼和錯誤包了。

可以通過 go run main.go 啓動這個 Web Server。

先來看下在這個 Web Server 中一個正常的返回結果是什麼樣,使用 cURL 來發送一個請求:curl http://localhost:8080/accounts/1

客戶端得到如下響應結果:

{
 "id": 1,
 "name": "account_1"
}

服務端打印正常的請求日誌:

Server Log

再來測試下請求一個不存在的賬號:curl http://localhost:8080/accounts/12

客戶端得到如下響應結果:

{
 "code": 40401001,
 "message": "資源未找到"
}

返回結果中沒有 reference 字段,是因爲對於 reference 爲空的情況,在 JSON 序列化過程中會被隱藏。

服務端打印的錯誤日誌如下:

========== log start ==========
[40401001] - 資源未找到 account not found
main.ShowAccount
        /app/errors/examples/main.go:56
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/recovery.go:102
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.LoggerWithConfig.func1
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/logger.go:240
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.(*Engine).handleHTTPRequest
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:620
github.com/gin-gonic/gin.(*Engine).ServeHTTP
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:576
net/http.serverHandler.ServeHTTP
        /usr/local/go/src/net/http/server.go:2947
net/http.(*conn).serve
        /usr/local/go/src/net/http/server.go:1991
runtime.goexit
        /usr/local/go/src/runtime/asm_arm64.s:1165
========== log end ==========

可以發現,錯誤日誌中不僅打印了錯誤碼([40401001])和錯誤信息(資源未找到),還打印了錯誤原因(account not found)以及下面的錯誤堆棧。

如此清晰的錯誤日誌得益於我們實現的 Format 函數的強大功能。

現在再來觸發一個 HTTP 狀態碼爲 500 的錯誤響應:curl http://localhost:8080/accounts/500

客戶端得到如下響應結果:

{
 "code": 50001001,
 "message": "系統錯誤",
 "reference": "https://github.com/jianghushinian/gokit/tree/main/errors"
}

這次得到一個帶有 reference 字段的完整錯誤響應。

服務端打印的錯誤日誌如下:

========== log start ==========
[50001001] - 系統錯誤 account 500: database error
main.ShowAccount
        /app/errors/examples/main.go:58
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/recovery.go:102
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.LoggerWithConfig.func1
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/logger.go:240
github.com/gin-gonic/gin.(*Context).Next
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174
github.com/gin-gonic/gin.(*Engine).handleHTTPRequest
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:620
github.com/gin-gonic/gin.(*Engine).ServeHTTP
        /go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:576
net/http.serverHandler.ServeHTTP
        /usr/local/go/src/net/http/server.go:2947
net/http.(*conn).serve
        /usr/local/go/src/net/http/server.go:1991
runtime.goexit
        /usr/local/go/src/runtime/asm_arm64.s:1165
========== log end ==========
[GIN] 2023/03/05 - 02:02:28 | 500 |     426.292µs |       127.0.0.1 | GET      "/accounts/500"
========== error start ==========
{"code":50001001,"message":"系統錯誤","reference":"https://github.com/jianghushinian/gokit/tree/main/errors","cause":"account 500: database error","stack":"\nmain.ShowAccount\n\t/app/errors/examples/main.go:58\ngithub.com/gin-gonic/gin.(*Context).Next\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174\ngithub.com/gin-gonic/gin.CustomRecoveryWithWriter.func1\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/recovery.go:102\ngithub.com/gin-gonic/gin.(*Context).Next\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174\ngithub.com/gin-gonic/gin.LoggerWithConfig.func1\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/logger.go:240\ngithub.com/gin-gonic/gin.(*Context).Next\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/context.go:174\ngithub.com/gin-gonic/gin.(*Engine).handleHTTPRequest\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:620\ngithub.com/gin-gonic/gin.(*Engine).ServeHTTP\n\t/go/pkg/mod/github.com/gin-gonic/gin@v1.9.0/gin.go:576\nnet/http.serverHandler.ServeHTTP\n\t/usr/local/go/src/net/http/server.go:2947\nnet/http.(*conn).serve\n\t/usr/local/go/src/net/http/server.go:1991\nruntime.goexit\n\t/usr/local/go/src/runtime/asm_arm64.s:1165"}
========== error end ==========

這一次除了 log 函數打印的日誌,還能看到 fakeSendErrorEmail 函數打印的日誌,正是一個 JSON 格式的結構化日誌。

以上便是我們設計的錯誤碼及錯誤包在實際開發場景中的應用。

使用建議

根據我的經驗,總結了一些錯誤碼及錯誤包的使用建議,現在將其分享給你。

使用盡量少的 HTTP 狀態碼

HTTP 狀態碼大概分爲 5 大類,分別是 1XX、2XX、3XX、4XX、5XX。根據我的實際工作經驗,我們並不會使用全部的狀態碼,最常用的狀態碼不超過 10 個。

所以即使我們設計的業務錯誤碼支持攜帶 HTTP 狀態碼,但也不推薦使用過多的 HTTP 狀態碼,以免加重前端工作量。

推薦在錯誤碼中使用的 HTTP 狀態碼如下:

其中 4XX 代表客戶端錯誤,而如果是服務端錯誤,則統一使用 500 狀態碼,具體錯誤原因可以通過業務錯誤碼定位。

使用中間件來記錄錯誤日誌

由於我們設計的錯誤包支持 Unwrap 操作,所以建議出現錯誤時的處理流程如下:

  1. 最底層代碼遇到錯誤時通過 errors.New/fmt.Errorf 來創建一個錯誤對象,然後將錯誤返回(可選擇性的記錄一條日誌)。
func Query(id int) (obj, error) {
    // do something
    return nil, fmt.Errorf("%d not found", id)
}
  1. 中間過程中處理函數遇到下層函數返回的錯誤,不做任何額外處理,直接將其向上層返回。
if err != nil {
    return err
}
  1. 在處理用戶請求的 Handler 函數中(如 ShowAccount)通過 apierr.WrapC 將錯誤包裝成一個 APIError 返回。
if err != nil {
    return apierr.WrapC(CodeNotFound, err)
}
  1. 最上層代碼通過在框架層面實現的中間件(如實現一個 after hook middleware)來統一處理錯誤,打印完整錯誤日誌、發送郵件提醒等,並將安全的錯誤信息返回給前端。如我們實現的 ResponseError 函數功能。

總結

本篇文章講解了如何設計一個規範的錯誤碼以及與之配套的錯誤包。

我參考了一些開源的 API 錯誤碼設計方案,並結合我自己的實際工作經驗,給出了我認爲比較合理的錯誤碼設計方案。

同時也針對這個錯誤碼方案,設計了一個配套的錯誤包,來簡化使用過程,並給出了我的一些使用建議。

錯誤包中記錄錯誤堆棧部分的代碼參考了 pkg/errors 包實現,感興趣的同學可以點擊進去進行進一步學習。

本文完整代碼實現我放在了 Github 上,供你參考使用。

希望本文對你有所啓發,如果你有更好的錯誤碼設計方案,歡迎一起交流討論。

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IZUTUhr8UOmzLfWHWqXFcg