Go 語言錯誤碼設計與管理實踐

1. 引言

1.1 背景

最近在做一個和前端、第三方平臺(可以簡單理解爲公司別的部門或者客戶軟件)直接交互的服務,涉及到用戶註冊、登錄、數據處理等模塊。架構圖大概如下:

拿到需求後,結合團隊內部熟悉的技術棧,我們確定了後臺服務【業務邏輯層】使用 Golang 語言來開發,用到的框架有 Gin 來做 HTTP 交互,Swaggo 自動生成接口文檔,Redis 和 MySQL 作爲 K-V 和 DB 存儲。

值得注意的是,應用要求我們對第三方平臺和 Web 端的錯誤具體化和規範化,比如:Web 端的錯誤碼信息給到第三方平臺也是可用的。

所以,錯誤碼的規範設計與管理成了我們首要解決的問題。

1.2 特性

Go 語言本身提供了比較簡單的錯誤處理機制:error 類型。error 是一個接口類型,定義如下:

type error interface {
    Error() string
}

error 的使用在代碼中隨處可見,比如:數據庫三方包 Gorm 自動增表,Gin 獲取參數等:

// AutoMigrate run auto migration for given models
func (db *DB) AutoMigrate(dst ...interface{}) error {
    return db.Migrator().AutoMigrate(dst...)
}

除了 Go 本身和三方包的使用,我們可以也通過 errors.New() 實現具體的錯誤信息:

func div(a, b int) (float, error) {
   if b == 0 {
       return 0, errrors.New("除數不能爲0")
  }
   return float(a)/float(b)
}

但是,新的問題又來了。

如果我們每次遇到相同的錯誤,都用類似的 errors.New() 定義一次。不僅會有很多重複代碼,而且在梳理我們的錯誤信息給 Web 端開發或者第三方平臺時,會非常困難。

想象一下,10 萬行的代碼,一個一個去找 errors.New() 信息,多少有點不體面了!

2. 定義錯誤碼和消息

2.1 錯誤碼設計規範

於是我們想到把錯誤信息統一管理起來,用錯誤碼的方式去唯一化標識。即:一個錯誤碼對應一條錯誤信息,每次需要時直接用錯誤碼就行了。

業界的錯誤碼一版採用 5~7 位整型數字(節省空間)的常量來定義,故我們採用 5 位數字錯誤碼,中文錯誤信息,根據業務模塊來劃分錯誤碼範圍。

模塊說明

KSvmAT

2.2 錯誤碼定義

新建 err_code 包,新增 error_handle.go 文件:

package err_code

import "github.com/pkg/errors"

// Response 錯誤時返回自定義結構
// 自定義error結構體,並重寫Error()方法
type Response struct {
    Code      ErrCode `json:"code"`       // 錯誤碼
    Msg       string  `json:"msg"`           // 錯誤信息
    RequestId string  `json:"request_id"` // 請求ID
}

新增錯誤碼和錯誤信息:

type ErrCode int //錯誤碼

// 定義errorCode
const (
// ServerError 1開頭爲服務級錯誤碼
ServerError    ErrCode = 10001
ParamBindError ErrCode = 10002

// IllegalDatasetName 2開頭爲業務級錯誤碼
// 其中數據集管理爲201開頭
IllegalDatasetName ErrCode = 20101 // 無效的數據集名稱
ParamNameError     ErrCode = 20102 // 參數name錯誤

// IllegalPhoneNum 用戶管理模塊:202開頭
IllegalPhoneNum         ErrCode = 20201 // 手機號格式不正確
IllegalVerifyCode       ErrCode = 20202 // 無效的驗證碼
PhoneRepeatedRegistered ErrCode = 20203 // 手機號不可重複註冊
PhoneIsNotRegistered    ErrCode = 20204 // 該手機號未註冊
PhoneRepeatedApproved   ErrCode = 20205 // 手機號不可重複審批
PhoneIsNotApproved      ErrCode = 20206 // 該手機號未審批

// IllegalModelName 預訓練模塊:203開頭
IllegalModelName 20301 // 非法模型名稱
)

2.2 Map 映射錯誤信息

根據錯誤碼,我們使用 Map 映射來定義中文錯誤信息

// 定義errorCode對應的文本信息
var errorMsg = map[int]string{
ServerError:          "服務內部錯誤",
ParamBindError:     "參數信息有誤",
IllegalDatasetName: "無效的數據集名稱",
ParamNameError:     "參數name錯誤",
IllegalPhoneNum:    "手機號格式不正確",
IllegalModelName:   "非法模型名稱",
}

// Text 根據錯誤碼獲取錯誤信息
func Text(code int) string {
    return errorMsg[code]
}

// NewCustomError 新建自定義error實例化
func NewCustomError(code ErrCode) error {
    // 初次調用得用Wrap方法,進行實例化
    return errors.Wrap(&Response{
        Code: code,
        Msg:  code.String(),
    }, "")
}

使用錯誤碼信息:

// CheckMobile 檢驗手機號
func CheckMobile(phone string) bool {
// 匹配規則
// ^1第一位爲一
// [345789]{1} 後接一位345789 的數字
// \\d \d的轉義 表示數字 {9} 接9位
// $ 結束符
regRuler := "^1[345789]{1}\\d{9}$"

// 正則調用規則
reg := regexp.MustCompile(regRuler)

// 返回 MatchString 是否匹配
return reg.MatchString(phone)

}

// 保存手機號
func savePhoneNum(phone string) error {
   if phone == "" || !CheckMobile(phone) {
      // 無效的手機號
return NewCustomError(err_code.IllegalPhoneNum)
}
}

這樣,我們的錯誤碼機制就有效建立起來了,好處在於:

但是,有聰明好學的朋友可能發現了。每次定義一個新的錯誤碼,都需要加錯誤碼數字和 Map 映射錯誤信息,有沒有更簡潔的方式去定義呢?

答案當然是有!作爲一個常常都想偷懶的程序員,簡潔高效自動化纔是我們追求的目標。

3. 自動化生成錯誤碼和錯誤信息

3.1 stringer

stringer 是 Go 語言開源的一個工具包,安裝命令爲:

go install golang.org/x/tools/cmd/stringer

除了工具包,我們還需要藉助 Go 的 iota 計數器,進行常量數字自動累加:

PS:iotago 語言的常量計數器,只能在常量的表達式中使用。

其值從0開始,在 const 中每新增一行 iota 自己增長1,其值一直自增1直到遇到下一個 const 關鍵字,其值才被重新置爲0。

3.2 定義錯誤信息

package err_code

import "github.com/pkg/errors"

// Response 錯誤時返回自定義結構
// 自定義error結構體,並重寫Error()方法
type Response struct {
  Code      ErrCode `json:"code"`       // 錯誤碼
  Msg       string  `json:"msg"`        // 錯誤信息
  RequestId string  `json:"request_id"` // 請求ID
}

func (e *Response) Error() string {
  return e.Code.String()
}

type ErrCode int //錯誤碼

// 1、安裝stringer工具:go install golang.org/x/tools/cmd/stringer
// 2、定義好errorCode以及Message之後,運行以下命令自動生成新的錯誤碼和錯誤信息
//go:generate stringer -type ErrCode -linecomment

// 1開頭:服務級錯誤碼
const (
// ServerError 內部錯誤
ServerError     ErrCode = iota + 10001 // 服務內部錯誤
ParamBindError                         // 參數信息有誤
TokenAuthFail                          // Token鑑權失敗
TokenIsNotExist                        // Token不存在
)

// 2開頭:業務模塊級錯誤碼
const (
// IllegalDatasetName 數據集模塊
IllegalDatasetName ErrCode = iota + 20101 // 非法數據集名稱
)

// 201開頭:用戶管理模塊
const (
// IllegalPhoneNum 無效的手機號
IllegalPhoneNum         ErrCode = iota + 20201 // 手機號格式不正確
IllegalVerifyCode                              // 無效的驗證碼
PhoneRepeatedRegistered                        // 手機號不可重複註冊
PhoneIsNotRegistered                           // 該手機號未註冊
PhoneRepeatedApproved                          // 手機號不可重複審批
PhoneIsNotApproved                             // 該手機號未審批
)

// 202開頭:預訓練模塊
const (
// IllegalModelName 無效的模型名稱
IllegalModelName ErrCode = iota + 20301 // 非法模型名稱
)

// NewCustomError 新建自定義error實例化
func NewCustomError(code ErrCode) error {
// 初次調用得用Wrap方法,進行實例化
return errors.Wrap(&Response{
Code: code,
Msg:  code.String(),
}, "")
}

通過上述對錯誤碼的定義 const 常量 + 錯誤碼名稱 + 錯誤信息註釋,其中 iota 會自動進行常量累加。

即:ParamBindError10002TokenAuthFail10003

// 1開頭:服務級錯誤碼
const (
   // ServerError 內部錯誤
   ServerError     ErrCode = iota + 10001 // 服務內部錯誤
   ParamBindError                         // 參數信息有誤
   TokenAuthFail                          // Token鑑權失敗
   TokenIsNotExist                        // Token不存在
)

我們可以運用兩種方式生成錯誤碼映射的錯誤信息。

1)在 Goland 中運行 stringer 工具

2)執行命令運行 stringer 工具

我們對 err_code/error_handle.go 文件執行如下命令:

go generate internal/protocols/err_code/error_handle.go

便可以新生成一個 errcode_string.go 文件,文件中是 err_codeerr_msg 的映射:

// Code generated by "stringer -type ErrCode -linecomment"; DO NOT EDIT.

package err_code

import "strconv"

func _() {
   // An "invalid array index" compiler error signifies that the constant values have changed.
   // Re-run the stringer command to generate them again.
   var x [1]struct{}
   _ = x[ServerError-10001]
   _ = x[ParamBindError-10002]
   _ = x[TokenAuthFail-10003]
   _ = x[TokenIsNotExist-10004]
   _ = x[IllegalDatasetName-20101]
   _ = x[IllegalPhoneNum-20201]
   _ = x[IllegalVerifyCode-20202]
   _ = x[PhoneRepeatedRegistered-20203]
   _ = x[PhoneIsNotRegistered-20204]
   _ = x[PhoneRepeatedApproved-20205]
   _ = x[PhoneIsNotApproved-20206]
   _ = x[IllegalModelName-20301]
}

const (
   _ErrCode_name_0 = "服務內部錯誤參數信息有誤Token鑑權失敗Token不存在"
   _ErrCode_name_1 = "非法數據集名稱"
   _ErrCode_name_2 = "手機號格式不正確無效的驗證碼手機號不可重複註冊該手機號未註冊手機號不可重複審批該手機號未審批"
   _ErrCode_name_3 = "非法模型名稱"
)

var (
   _ErrCode_index_0 = [...]uint8{0, 18, 36, 53, 67}
   _ErrCode_index_2 = [...]uint8{0, 24, 42, 69, 90, 117, 138}
)

func (i ErrCode) String() string {
   switch {
   case 10001 <= i && i <= 10004:
      i -= 10001
      return _ErrCode_name_0[_ErrCode_index_0[i]:_ErrCode_index_0[i+1]]
   case i == 20101:
      return _ErrCode_name_1
   case 20201 <= i && i <= 20206:
      i -= 20201
      return _ErrCode_name_2[_ErrCode_index_2[i]:_ErrCode_index_2[i+1]]
   case i == 20301:
      return _ErrCode_name_3
   default:
      return "ErrCode(" + strconv.FormatInt(int64(i), 10) + ")"
   }
}

這樣,我們就不用再手動去新建 Map 維護映射關係了!

注意:每次新增、刪除或修改錯誤碼之後,都需要執行 go generate 生成新的映射文件 errcode_string.go

這個文件是錯誤碼和錯誤信息的映射文件,不要手動修改和刪除!

4. 錯誤碼實踐

綜上,我們已經定義好了錯誤碼信息。接下來,我們用一個用戶註冊的接口來簡單示範下使用方式。

go.mod 一部分依賴包如下:

module wanx-llm-server

go 1.20

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/pkg/errors v0.9.1
    github.com/spf13/viper v1.16.0
    github.com/swaggo/gin-swagger v1.6.0
    github.com/swaggo/swag v1.16.1
    go.uber.org/zap v1.25.0
    golang.org/x/arch v0.4.0 // indirect
    golang.org/x/tools v0.12.0 // indirect
    google.golang.org/protobuf v1.31.0 // indirect
    gorm.io/driver/mysql v1.5.1
    gorm.io/gorm v1.25.4
)

新增 main.go 作爲服務啓動入口,代碼如下:

package main

import (
   "flag"
   "fmt"
   "os"

   "go.uber.org/zap"
   _ "wanx-llm-server/docs"
   "wanx-llm-server/internal/cmd"
   "wanx-llm-server/internal/global"
   "wanx-llm-server/internal/initialize"
   util "wanx-llm-server/internal/utils"
)

// @title 大模型平臺服務
// @version 1.0
// @description 大模型平臺服務
func main() {
   configPath := flag.String("conf", "./config/config.yaml", "config path")
   flag.Parse()

   // 初始化配置
   err := initialize.Init(*configPath)
   if err != nil {
      global.Logger.Error("server init failed", zap.Any(util.ErrKey, err))
      fmt.Printf("server init failed, %v\n", err)
      os.Exit(1)
   }

   // 創建一個gin路由引擎
   r := cmd.SetupRouter()

   // 啓動HTTP服務,默認在0.0.0.0:8088啓動服務
   addr := fmt.Sprintf(":%v", 8088)
   if err := r.Run(addr); err != nil {
      global.Logger.Error(fmt.Sprintf("gin run failed, %v", err))
      return
   }
}

server.go 作爲 HTTP 請求入口,關鍵代碼如下:

func SetupRouter() *gin.Engine {
    r := gin.Default()
    r.POST("/api/v1/user/register", userRegister)
    return r
}

// @Tags 用戶管理模塊
// @Summary 註冊新用戶
// @Description 註冊新用戶
// @Accept json
// @Produce json
// @Param request body user.RegisterUserReq true "用戶註冊參數json"
// @Success 200 {object} user.RegisterUserResp  "用戶註冊響應json"
// @Router /api/v1/user/register [post]
func userRegister(c *gin.Context) {
	requestId := c.Writer.Header().Get("X-Request-Id")
	resp := &err_code.Response{RequestId: requestId}

	defer func() {
		if resp.Code != 0 {
			c.JSONP(http.StatusOK, &user.GenerateCodeResp{Response: resp})
		}
	}()

	req := &user.RegisterUserReq{}
	err := c.BindJSON(req)
	if err != nil {
		errors.As(err_code.NewCustomError(err_code.ParamBindError), &resp)
		return
	}

        // 接口具體實現
	err = service.RegisterUser(requestId, req)
	if err != nil {
		// 默認錯誤碼
		if !errors.As(err, &resp) {
			errors.As(err_code.NewCustomError(err_code.ServerError), &resp)
		}
		return
	}

	c.JSONP(http.StatusOK, &user.RegisterUserResp{
		Response: resp,
		Data:     user.RegisterUser{State: service.RegisteredState},
	})
}

service/user.go 實現具體業務,關鍵代碼如下:

 // RegisterUser 接口具體實現
func RegisterUser(requestId string, req *user.RegisterUserReq) error {
	if req.Phone == "" || !CheckMobile(req.Phone) {
		return err_code.NewCustomError(err_code.IllegalPhoneNum)
	}

	// 手機驗證碼校驗
	smsOBj := &sms.SMS{
		Phone:      req.Phone,
		Code:       req.Code,
		CodeExpire: global.Config.CodeSMS.VerifyCodeExpire,
	}
	codePass, msg, err := smsOBj.VerifyCode(global.RedisClient)
	if err != nil {
		return err_code.NewCustomError(err_code.ServerError)
	}

	// 驗證碼未通過
	if !codePass {
		return err_code.NewCustomError(err_code.IllegalVerifyCode)
	}

	exist, err := (&model.ApprovedTable{}).IsExistByPhone(req.Phone)
	if err != nil {
		return err_code.NewCustomError(err_code.ServerError)
	}

	// 已註冊
	if exist {
		return err_code.NewCustomError(err_code.PhoneRepeatedRegistered)
	}

	ur := &model.UserApproved{
		Phone: req.Phone,
		State: RegisteredState,
	}

	_, err = (&model.ApprovedTable{}).Insert(ur)
	if err != nil {
		return err_code.NewCustomError(err_code.ServerError)
	}
	return nil
}

示例中,通過直接對錯誤碼的調用,我們避免了頻繁的拋出和接收錯誤,然後再進行 error_code 拼裝的步驟。

如此一來,一套規範的錯誤碼體系就建立起來了!

完結,撒花!

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