GoFrame 最佳化介面的錯誤碼和異常的思路

灯火消逝的码头發表於2024-03-13

前言

你是否想在使用 GoFrame 的過程中,擁有一個能列印異常堆疊,能自定義響應狀態碼,能統一處理響應資料的介面。如果你回答是,那麼,請耐心看完本文,或許會對你有所啟發。若文中由表達不當之處,懇請不吝賜教。

異常都需要錯誤堆疊嗎

為什麼會問這個問題呢,所有的介面錯誤都會向日志中丟擲堆疊資訊,這不是好事嗎?答案是否定的。

業務開發中,通常有業務異常系統異常兩種 err,我這裡暫且這麼稱呼,也有的稱為業務異常為"錯誤",系統異常為"異常"。業務異常是由使用者輸入不當引起的,比如說賬號密碼錯誤,這種 err 通常只返回給使用者即可,不需要列印堆疊資訊。而系統異常是由系統內部自發引起的,比如說 SQL 語句不當,這種錯誤需要列印堆疊資訊,且不能把 err 返回到使用者那裡,不然會暴露程式碼結構,嚴重的可能會暴露資料庫結構。

GoFrame 中,因為有著強大的 gerror 元件,所以只要接收了任何元件方法中的 err,不論業務異常系統異常,都會列印堆疊資訊,這與我們的設計目標不符合,需要解決它。

狀態碼

此處的狀態碼區別與 HTTP 狀態碼,它是我們自定義的一套業務碼,比如這樣:

{
	"code": 10001,
	"message": "使用者名稱密碼錯誤",
	"data": null
}

{
	"code": 10002,
	"message": "使用者不存在",
	"data": null
}

它們的 HTTP 狀態碼都是 200,代表響應成功,但是業務狀態碼不同,用以區分不同的業務異常。

一個例子

我們來編寫一個完整的示例:

介面檔案:/api/exception/v1/exception.go:

// 模擬業務異常
type BusinessReq struct {
	g.Meta `path:"/business" method:"get"`
}

type BusinessRes struct {
	Name string
	Age  int
}

// 模擬系統異常
type SystemReq struct {
	g.Meta `path:"/system" method:"get"`
}

type SystemRes struct {
	Name string
	Age  int
}

控制器檔案:/internal/controller/exception/exception_v1_*.go:

func (c *ControllerV1) Business(ctx context.Context, req *v1.BusinessReq) (res *v1.BusinessRes, err error) {
	err = service.Exception().Business()
	if err != nil {
		return nil, err
	}
	return &v1.BusinessRes{
		Name: "business",
		Age:  1,
	}, nil
}

func (c *ControllerV1) System(ctx context.Context, req *v1.SystemReq) (res *v1.SystemRes, err error) {
	err = service.Exception().System()
	if err != nil {
		return nil, err
	}
	return &v1.SystemRes{
		Name: "system",
		Age:  1,
	}, nil
}

服務檔案:/internal/logic/exception/exception.go:

func (s *sException) Business() error {
	return gerror.New("使用者名稱密碼錯誤")
}

// System 這裡我們對 gjson.Decode() 傳入錯誤資料,用來模擬元件內部丟擲err
func (s *sException) System() error {
	_, err := gjson.Decode("")
	if err != nil {
		return err
	}
	return nil
}

這個例子模擬了一個完整的介面,從 apicontrollerlogic,然後我們請求它們,分別從響應資訊和控制檯兩個角度看看它們的結果。

業務異常 Business

curl http://127.0.0.1:8000/business

控制檯:

GoFrame 最佳化介面的錯誤碼和異常的思路

介面響應:

{
	"code": 50,
	"message": "使用者名稱密碼錯誤",
	"data": null
}

系統異常 System

curl http://127.0.0.1:8000/system

控制檯:

GoFrame 最佳化介面的錯誤碼和異常的思路

介面響應:

{
	"code": 50,
	"message": "json Decode failed: EOF",
	"data": null
}

最佳化方案

此時,我們的介面中有三個不足:

  1. 業務異常不應該丟擲堆疊,因為使用者名稱或密碼錯誤的堆疊沒有意義;
  2. 系統異常的響應資訊中, message 不應該丟擲 "json Decode failed: EOF",應該使用 未知錯誤 或者 系統錯誤 這類字眼;
  3. 業務異常和系統異常的業務碼,也就是響應資訊中的 code,不應該都使用 50,應當做以區分。

設計統一 err

在 GoFrame 的工程目錄中,有一個包 /internal/packed,我們可以在此處編寫我們自己的 err 處理,後面的程式碼可以做為參考,也可以直接複製過去用:

/internal/packed/err.go:

type pErr struct {
	maps map[int]string
}

var Err = &pErr{
	maps: map[int]string{
		0:     "請求成功",
		10001: "使用者名稱或密碼錯誤",
		10002: "使用者不存在",
		99999: "未知錯誤",
	},
}

// GetMsg 獲取code碼對應的msg
func (c *pErr) GetMsg(code int) string {
	return c.maps[code]
}

// Skip 丟擲一個業務級別的錯誤,不會列印錯誤堆疊資訊
func (c *pErr) Skip(code int, msg ...string) (err error) {
	var msgStr string
	if len(msg) == 0 {
		msgStr = c.GetMsg(code)
	} else {
		msg = append([]string{c.GetMsg(code)}, msg...)
		msgStr = strings.Join(msg, ", ")
	}
	// NewWithOption 在低版本的 gf 上不存在,請改用 NewOption
	return gerror.NewWithOption(gerror.Option{
		Stack: false,
		Text:  msgStr,
		Code:  gcode.New(code, "", nil),
	})
}

// Sys 丟擲一個系統級別的錯誤,使用code碼:99999,會列印錯誤堆疊資訊
// msg 接受string和error型別
// !!! 使用該方法傳入error型別時,一定要注意不要洩露系統資訊
func (c *pErr) Sys(msg ...interface{}) error {
	var (
		code     = 99999
		msgSlice = []string{
			c.GetMsg(code),
		}
	)

	if len(msg) != 0 {
		for _, v := range msg {
			switch a := v.(type) {
			case error:
				msgSlice = append(msgSlice, a.Error())
			case string:
				msgSlice = append(msgSlice, a)
			}
		}
	}

	msgStr := strings.Join(msgSlice, ", ")
	return gerror.NewCode(gcode.New(code, "", nil), msgStr)
}

統一響應資料中介軟體

設計統一響應資料的中介軟體,並且注入到 HTTP 請求流程中:

/internal/logic/middleware/response.go

type sMiddleware struct {
}

func init() {
	service.RegisterMiddleware(New())
}

func New() *sMiddleware {
	return &sMiddleware{}
}

type Response struct {
	Code    int         `json:"code"    dc:"業務碼"`
	Message string      `json:"message" dc:"業務碼說明"`
	Data    interface{} `json:"data"    dc:"返回的資料"`
}

func (s *sMiddleware) Response(r *ghttp.Request) {
	r.Middleware.Next()

	if r.Response.BufferLength() > 0 {
		return
	}

	// 先過濾掉伺服器內部錯誤
	if r.Response.Status >= http.StatusInternalServerError {
		// 清除掉快取區,防止伺服器資訊洩露到客戶端
		r.Response.ClearBuffer()
		r.Response.Writeln("伺服器打盹了,請稍後再來找他!")
	}

	var (
		res  = r.GetHandlerResponse()
		err  = r.GetError()
		code = gerror.Code(err)
		msg  string
	)

	if err != nil {
		msg = err.Error()
	} else {
		code = gcode.CodeOK
		msg = packed.Err.GetMsg(code.Code())
	}

	r.Response.WriteJson(Response{
		Code:    code.Code(),
		Message: msg,
		Data:    res,
	})
}

/internal/cmd/cmd.go

s.Group("/", func(group *ghttp.RouterGroup) {
	group.Middleware(service.Middleware().Response)
	group.Bind(
		exception.NewV1(),
	)
})

結果

然後在服務檔案中呼叫 packed/err

/internal/logic/exception/exception.go:

func (s *sException) Business() error {
	return packed.Err.Skip(10001)
}

// System 這裡我們對 gjson.Decode() 傳入錯誤資料,用來模擬元件內部丟擲err
func (s *sException) System() error {
	_, err := gjson.Decode("")
	if err != nil {
		return packed.Err.Sys("可選自定義資訊")
	}
	return nil
}

結果展示:

Business
{
	"code": 10001,
	"message": "使用者名稱或密碼錯誤",
	"data": null
}

System
{
	"code": 99999,
	"message": "未知錯誤, 可選自定義資訊",
	"data": null
}

使用者名稱或密碼錯誤的業務異常也不會再丟擲堆疊異常了:

GoFrame 最佳化介面的錯誤碼和異常的思路

尾聲

上述的程式碼例子已經開源在:Github

本部落格原始碼使用的也是這種 err 設計,想要了解更多可以檢視:Github/oldme-api

相關文章