首頁 > 軟體

Go 程式碼規範錯誤處理範例經驗總結

2022-08-15 18:01:41

引言

編寫程式碼應該要有極客追求,不要一味的只為了完成功能不加思索而噼裡啪啦一頓操作,我認為應該要像一位設計者一樣去設計程式碼完成功能,因為好的程式碼設計清晰可讀易擴充套件、好修復、更少的學習成本。

因此我們應該學習並制定一些程式碼規範,如下是我學習Go語言中實戰總結的一些經驗,僅代表個人觀點,大家可以互相討論一下

一、相關聯的宣告放到一起

1、導包規範

// Bad
import "logics/user_logic"
import "logics/admin_logic"
import "logics/goods_logic"
// good
import (
  "logics/user_logic"
  "logics/admin_logic"
  "logics/goods_logic"
)

分組導包

內建庫

其他庫

相關聯庫放在一起

// Bad
import (
   "fmt"
   "logics/user_logic"
   "logics/admin_logic"
   "strings"
)
// Good
import (
   "fmt"
   "strings"
   // 邏輯處理
   "logics/user_logic"
   "logics/admin_logic"
   // 資料庫相關操作
   "db/managers"
   "db/models"
)

2、常數、變數、型別宣告

在定義一些常數、變數與型別宣告的時候,也是一樣可以把相關聯放到一起

常數

// Bad
const YearMonthDay = "2006-01-02"                    
const YearMonthDayHourMinSec = "2006-01-02 15:04:05"
const DefaultTimeFmt = YearMonthDayHourMinSec
// Good
// TimeFormat 時間格式化
type TimeFormat string
const (
   YearMonthDay TimeFormat = "2006-01-02"           // 年月日 yyyy-mm-dd
   YearMonthDayHourMinSec TimeFormat = "2006-01-02 15:04:05"  // 年月年時分秒 yyyy-mm-dd HH:MM:SS
   DefaultTimeFmt TimeFormat = YearMonthDayHourMinSec // 預設時間格式化
)

變數

// Bad
var querySQL string
var queryParams []interface{}
// Good
var (
   querySQL string
   queryParams []interface{}
)

型別宣告

// Bad
type Area float64
type Volume float64
type Perimeter float64
// Good
type (
   Area      float64 // 面積
   Volume    float64 // 體積
   Perimeter float64 // 周長
)

列舉常數

// TaskAuditState 任務稽核狀態
type TaskAuditState int8
// Bad
const TaskWaitHandle TaskAuditState = 0   // 待稽核
const TaskSecondReview TaskAuditState = 1 // 複審
const TaskPass TaskAuditState = 2         // 通過
const TaskRefuse TaskAuditState = 3       // 拒絕
// Good
const (
   TaskWaitHandle   TaskAuditState = iota     // 待稽核
   TaskSecondReview // 複審
   TaskPass // 通過
   TaskRefuse // 拒絕
)

在進行Go開發時,指定一些非必選的入參時,不好區別空值是否有意義

如下 AuditState 是非必選引數,而 AuditState 在後端定義是 0 稽核中、1複審、2通過、3拒絕,這都沒什麼問題,但框架解析引數時會把入參模型結構沒有傳值的引數設定成預設值,字串型別是空串、數位型別是0等, 這樣就會有問題如果前端傳遞引數的值是0、空值或者沒有傳遞時,則無法判斷是前端傳遞過來的還是框架預設設定的,導致後續邏輯不好寫。

// QueryAuditTaskIn 查詢任務入參
type QueryAuditTaskIn struct {
   TeamCode   string                   `query:"team_code" validate:"required"` // 團隊編碼
   TaskType   enums.RiskType           `query:"task_type" validate:"required"` // 任務型別
   TagId      int                      `query:"tag_id" validate:"required"`    // 標籤id
   AuditState constants.TaskAuditState `query:"audit_state"`                   // 稽核狀態
}

解決辦法就是設計時讓前端不要傳遞一些空值,整型列舉常數設定成 從1開始,這樣更好的處理後續邏輯。

// TaskAuditState 定義稽核狀態型別
type TaskAuditState int8
const (
   TaskWaitHandle   TaskAuditState = iota + 1 // 待稽核
   TaskSecondReview                           // 複審
   TaskPass                                   // 通過
   TaskRefuse                                 // 拒絕
)

二、Go錯誤處理

在Go開發中會出現好多if err != nil 的判斷

尤其我在使用 manager 運算元據庫時一呼叫方法就要處理錯誤,還要向上層依次傳遞

manager(資料庫操作層) -> logic(邏輯層) -> api(介面層),每一層都要處理錯誤從而導致

一大堆的 if err != nil

// DelSensitive 刪除內部敏感詞
func (sl SensitiveLogic) DelSensitive(banWordId uint32) error {
   banWordManager, err := managers.NewBanWordsManager()
   if err != nil {
      return err
   }
   banWords, err := banWordManager.GetById(banWordId)
   if err != nil{
      return err
   }
   if banWords == nil {
      return exceptions.NewBizError("遮蔽詞不存在")
   }
   _, err = banWordManager.DeleteById(banWordId)
   if err != nil {
      return err
   }
   // 刪除對應的敏感詞字首樹,下一次文字稽核任務進來的時候會重新構造敏感詞字首樹
   banWordsModel := banWords.(*models.BanWordsModel)
   sensitive.DelTrie(banWordsModel.Scene, banWordsModel.TeamCode)
   return nil
}

這樣程式碼太不美觀瞭如果改成如下看看

// DelSensitive 刪除內部敏感詞
func (sl SensitiveLogic) DelSensitive(banWordId uint32) {
   banWordManager := managers.NewBanWordsManager()
   banWords := banWordManager.GetById(banWordId)
   if banWords == nil {
      return
   }
   banWordManager.DeleteById(banWordId)
   // 刪除對應的敏感詞字首樹,下一次文字稽核任務進來的時候會重新構造敏感詞字首樹
   banWordsModel := banWords.(*models.BanWordsModel)
   sensitive.DelTrie(banWordsModel.Scene, banWordsModel.TeamCode)
}

是不是美觀多了,但這樣出現出錯誤不能很好的定位到錯誤的位置以及紀錄檔記錄,還會 panic 拋錯誤出來,導致協程終止執行,要等到 recover 恢復協程來中止 panic 造成的程式崩潰,從而影響效能。處理與不處理各有好處,我個人認為錯誤應該要處理但不要無腦的 if err != nil , 從而

可以在設計與規範上面來解決,對於一些嚴重的一定會導致程式奔潰的錯誤,可以自己統一設計錯誤型別,例如 資料庫error 和 網路error 等,這種是很難避免的,即是避免了,系統也不能正常處理邏輯,因此對於這些 嚴重的錯誤可以手動 panic 然後在全域性錯誤處理中記錄紀錄檔資訊,從而減少程式碼中的 if err != nil 的次數。如下

這樣就不用一層一層傳遞 error ,但缺乏紀錄檔資訊,雖然可以在上面的程式碼中列印紀錄檔資訊,這樣不太好,因此可以到全域性錯誤那統一處理

這裡是之前的想法可以考慮下,但像一些業務異常太多了就會頻繁 panic,導致效能不佳以及後續的一些協程問題,所以我上文提到自己設計錯誤以及規範,什麼錯誤、異常可以 panic 什麼不可以,從而來減少 if err != nil。

其次就是在設計函數的來避免錯誤的出現

1、失敗的原因只有一個時,不使用 error

我們看一個案例:

func (self *AgentContext) CheckHostType(host_type string) error {
  switch host_type {
  case "virtual_machine":
    return nil
  case "bare_metal":
    return nil
  }
  return errors.New("CheckHostType ERROR:" + host_type)
}

我們可以看出,該函數失敗的原因只有一個,所以返回值的型別應該為 bool,而不是 error,重構一下程式碼:

func (self *AgentContext) IsValidHostType(hostType string) bool {
  return hostType == "virtual_machine" || hostType == "bare_metal"
}

說明:大多數情況,導致失敗的原因不止一種,尤其是對 I/O 操作而言,使用者需要了解更多的錯誤資訊,這時的返回值型別不再是簡單的 bool,而是 error。

2、沒有失敗時,不使用 error

error 在 Golang 中是如此的流行,以至於很多人設計函數時不管三七二十一都使用 error,即使沒有一個失敗原因。我們看一下範例程式碼:

func (self *CniParam) setTenantId() error {
  self.TenantId = self.PodNs
  return nil
}

對於上面的函數設計,就會有下面的呼叫程式碼:

err := self.setTenantId()
if err != nil {
  // log
  // free resource return errors.New(...)
}

根據我們的正確姿勢,重構一下程式碼:

func (self *CniParam) setTenantId() {
  self.TenantId = self.PodNs
}

於是呼叫程式碼變為:

self.setTenantId()

3、錯誤值統一定義

很多人寫程式碼時,到處 return errors.New(value),而錯誤 value 在表達同一個含義時也可能形式不同,比如“記錄不存在”的錯誤 value 可能為:

errors.New("record is not existed.")
errors.New("record is not exist!")
errors.New("訂單不存在")

這使得相同的錯誤 value 撒在一大片程式碼裡,當上層函數要對特定錯誤 value 進行統一處理時,需要漫遊所有下層程式碼,以保證錯誤 value 統一,不幸的是有時會有漏網之魚,而且這種方式嚴重阻礙了錯誤 value 的重構。

在每個業務系統中維護一個錯誤物件定義檔案,一些公用的錯誤則封裝到Go的公用庫中

業務系統錯誤封裝:

package exceptions
// err_struct.go
// OrderBizError 訂單系統業務錯誤結構體
type OrderBizError struct {
   message string    // 錯誤資訊
   code    ErrorCode // 響應碼
   sysName string    // 系統名稱
}
func NewOrderBizError(message string, errorCode ...ErrorCode) *OrderBizError {
   code := FailCode
if len(errorCode) > 0 {
      code = errorCode[0]
   }
   return &OrderBizError{
      code:    code,
      message: message,
      sysName: "HuiYiMall—OrderSystem", // 可抽到微服務公用庫中
   }
}
// Code 狀態碼
func (b OrderBizError) Code() ErrorCode {
   return b.code
}
// Message 錯誤資訊
func (b OrderBizError) Message() string {
   return b.message
}
// err_const.go
// ErrorCode 定義錯誤code型別
type ErrorCode string
const (
   OrderTimeoutErrCode ErrorCode = "4000" // 訂單超時
   OrderPayFailErrCode ErrorCode = "4001" // 訂單支付失敗
)
var (
   OrderTimeoutErr = NewOrderBizError("order timeout", OrderTimeoutErrCode)
   OrderPayFailErr = NewOrderBizError("order pay fail", OrderPayFailErrCode)
)

返回錯誤資訊給前端則返回狀態碼和資訊,紀錄檔則記錄全部的錯誤資訊

Go公用庫錯誤封裝:

// err_struct.go
// BizError 業務錯誤結構體
type BizError struct {
   message string    // 錯誤資訊
   code    ErrorCode // 響應碼
}
// Code 狀態碼
func (b BizError) Code() ErrorCode {
   return b.code
}
// Message 錯誤資訊
func (b BizError) Message() string {
   return b.message
}
func NewBizError(message string, errorCode ...ErrorCode) *BizError {
   code := FailCode
if len(errorCode) > 0 {
      code = errorCode[0]
   }
   return &BizError{
      code:    code,
      message: message,
   }
}
// err_const.go
const (
   SuccessCode ErrorCode = "0000" // 成功
   FailCode ErrorCode = "0403" // 失敗
   AuthorizationCode ErrorCode = "0403" // 認證錯誤
   // ...
)
var (
   Success          = NewOrderBizError("Success", SuccessCode)
   FailErr          = NewOrderBizError("Fail", FailCode)
   AuthorizationErr = NewOrderBizError("Authorization Error", AuthorizationCode)
   // ...
)

其實每個業務系統的結構體可以繼承公用的

// BizError 業務錯誤結構體
type BizError struct {
   message string    // 錯誤資訊
   code    ErrorCode // 響應碼
}
// OrderBizError 訂單系統業務錯誤結構體
type OrderBizError struct {
   BizError
   sysName string    // 系統名稱
}

然後使用的時候就可以不要每次都自己單獨的定義錯誤碼和資訊

三、程式碼規範與實踐

1、良好的命名與註釋

生成Swaager介面檔案註釋儘量對齊

// QueryAuditTask 查詢已領取的稽核任務
// @Summary 查詢已領取的稽核任務
// @Tags 稽核管理介面
// @Accept json
// @Produce json
// @Param team_code   query  string true  "團隊編碼"
// @Param task_type   query  string true  "風控型別"
// @Param tag_id      query  string true  "稽核任務型別標籤ID"
// @Param audit_state query  int    false "任務稽核狀態 1待稽核 2複審 3通過 4拒絕"
// @Success 200 {object} rsp.ResponseData
// @Router /task.audit.list_get [get]
func QueryAuditTask(ctx *fiber.Ctx) error {

路由註釋少不

入參出參結構體註釋少不了

// QueryAuditTaskIn 領取任務入參
type QueryAuditTaskIn struct {
   TeamCode   string                   `query:"team_code" validate:"required"` // 團隊編碼
   TaskType   enums.RiskType           `query:"task_type" validate:"required"` // 任務型別
   TagId      int                      `query:"tag_id" validate:"required"`    // 標籤id
   AuditState constants.TaskResultType `query:"audit_state"`                   // 稽核狀態
}
// TaskListItem 任務列表項
type TaskListItem struct {
   Id          uint32                   `json:"id"`           // 主鍵id
   TeamCode    string                   `json:"team_code"`    // 團隊編碼
   ObjectType  enums.ObjectType         `json:"object_type"`  // 物件型別
   ObjectId    string                   `json:"object_id"`    // 物件ID
   TaskType    enums.RiskType           `json:"task_type"`    // 任務型別
   Content     datatypes.JSON           `json:"content"`      // 任務內容
   TagId       uint32                   `json:"tag_id"`       // 標籤ID
   TaskResult  constants.TaskResultType `json:"task_result"`  // 任務稽核結果
   ReviewerId  uint32                   `json:"reviewer_id"`  // 領取任務人ID
   AuditReason string                   `json:"audit_reason"` // 稽核理由
   SourceList  []interface{}            `json:"source_list"`  // 溯源列表
   CreateTs    int64                    `json:"create_ts"`    // 任務建立的時間戳
}

一些複雜的巢狀結構最好寫上樣例

// 獲取全部的團佇列表
teamSlice := rmc.getAllTeam()
// 統計各稽核任務未領取數量
tagTaskCountMap := rmc.getTagTaskCount()
// 獲取所有task_group標籤與其二級標籤
tagMenuSlice := rmc.getTagMenu()
// 將風控稽核選單資訊組裝到各個團隊中並填充統計數量
// eg: [
//    {
//       "team_code": "lihua",
//       "team_name": "梨花"
//       "task_count": 1
//       "tag_menus": [{"tag_id": 1, "tag_name": "文字", "tag_type": "task_group", "task_count":1, "child_tags": []}, ...]
//    },
//    ...
//]
riskMenuSlice := make([]*TeamMenuItem, 0)
for _, team := range *teamSlice {
   // 填充各稽核型別未領取任務數量
   newTagMenuSlice := rmc.FillTagTaskCount(tagTaskCountMap, team, tagMenuSlice)
   // 填充各團隊未領取任務總數
   teamTaskCount := uint32(0)
   if tagCountMap, ok := tagTaskCountMap[team.TeamCode]; ok {
      for _, tagTaskCount := range tagCountMap {
         teamTaskCount += tagTaskCount
      }
   }
   teamMenuItem := TeamMenuItem{
      TeamCode:  team.TeamCode,
      TeamName:  team.TeamName,
      TaskCount: teamTaskCount,
      TagMenus:  newTagMenuSlice,
   }
   riskMenuSlice = append(riskMenuSlice, &teamMenuItem)
}
riskMenuMap := map[string]interface{}{
   "work_menu": riskMenuSlice,
}

2、美化SQL語句,避免 Select 

一些長的SQL語句不要寫到一行裡面去,可以使用 `` 原生字串 達到在字串中換行的效果從而美化SQL語句,然後就是儘量需要什麼業務資料就查什麼,避免Select * 後再邏輯處理去篩選

queryField := `
      task.id AS task_id,
      tag_name,
      staff.real_name         AS staff_real_name,
      staff_tar.audit_reason  AS staff_audit_reason,
      staff_tar.review_result AS staff_review_result,
      staff_tar.review_ts     AS staff_review_ts,
      chief.real_name         AS chief_real_name,
      chief_tar.audit_reason  AS chief_audit_reason,
      chief_tar.review_result AS chief_review_result,
      chief_tar.review_ts     AS chief_review_ts,
      task.content            AS task_content,
      task.json_extend        AS task_json_ext,
      tar.json_extend         AS audit_record_json_ext`
querySQL := `
   SELECT
      %s
   FROM
      task_audit_log AS tar
      JOIN task ON tar.task_id = task.id
      JOIN tag  ON task.tag_id = tag.id
      LEFT JOIN task_audit_log AS staff_tar ON task.id = staff_tar.task_id AND staff_tar.reviewer_role = "staff"
      LEFT JOIN reviewer       AS staff     ON staff.account_id = staff_tar.reviewer_id
      LEFT JOIN task_audit_log AS chief_tar ON task.id = chief_tar.task_id AND chief_tar.reviewer_role = "chief"
      LEFT JOIN reviewer       AS chief     ON chief.account_id = chief_tar.reviewer_id
   WHERE
      team_code = ?`
queryParams := []interface{}{taskAuditLogIn.TeamCode}

3、避免階梯縮排與程式碼緊湊

階梯縮排、程式碼緊湊會導致程式碼不易閱讀,理解更難,可以通過一些反向判斷來拒絕一些操作,從而減少階梯縮排,程式碼緊湊則可以把一些相關的邏輯放到一起,不同的處理步驟適當換行。

// Bad
// 校驗引數
VerifyParams(requestIn)
// 獲取資訊
orderSlice := GetDBInfo(params)
// 邏輯處理
// ...
// 組織返參
for _, order := range(orderSlice){
    ...
}
// Good
// 校驗引數
VerifyParams(requestIn)
// 獲取資訊
orderSlice := GetDBInfo(params)
// 邏輯處理
// ...
// 組織返參
for _, order := range(orderSlice){
    ...
}

同一步驟的邏輯太長可以封裝成函數、方法。

// Bad
for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}
// Good
for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }
  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

不必要的else

// Bad
var a int
if b {
  a = 100
} else {
  a = 10
}
// Good
a := 10
if b {
  a = 100
}

4、避免迴圈IO、上下文無關聯的耗時動作採用Go協程

避免迴圈IO,可以用批次就改用批次。

func (itm InspectionTaskManager) BatchCreateInspectionTask(taskIdList []uint32) error {
   inspectionTaskList := make([]models.InspectionTaskModel, 0)
   // 組裝好批次建立的抽查任務
   for _, id := range taskIdList {
      inspectionTaskList = append(inspectionTaskList, models.InspectionTaskModel{
         TaskId: id,
      })
   }
   // 批次建立
   _, err := itm.BulkCreate(inspectionTaskList)
   return err
}

有些資料庫表結構可以使用自關聯的方式簡化查詢從而避免迴圈IO、減少查詢次數。

// GetTagMenu 獲取所有task_group標籤與其二級標籤
func (tm *TagManager) GetTagMenu() []*TagMenuResult {
   querySql := `
      SELECT
         t1.id, t1.tag_name, t1.tag_type,
         t2.id as two_tag_id, t2.tag_name as two_tag_name,
         t2.tag_type as two_tag_type, 
         t2.pid
      FROM
         tag AS t1
         INNER JOIN tag AS t2 ON t2.pid = t1.id
      WHERE
         t1.tag_type = "task_group"`
   tagMenuSlice := make([]*TagMenuResult, 0)
   tm.Conn.Raw(querySql).Scan(&tagMenuSlice)
   return tagMenuSlice
}

然後就是上下文無關聯的可以並行執行,提高效能。

// GetPageWithTotal 獲取分頁並返回總數
func (bm BaseManager) GetPageWithTotal(condition *Condition) (*PageResult, error) {
   errChan := make(chan error)
   resultChan := make(chan PageResult)
   defer close(errChan)
   defer close(resultChan)
   var pageResult PageResult
   pageResult.Total = -1 // 設定預設值為-1, 用於判斷沒有獲取到資料的時候
   go func() {
      // 獲取總數
      total, err := bm.GetCount(condition)
      if err != nil {
         errChan <- err
         return
      }
      pageResult.Total = total
      resultChan <- pageResult
   }()
   go func() {
      // 獲取分頁資料
      result, err := bm.GetPage(condition)
      if err != nil {
         errChan <- err
         return
      }
      pageResult.ResultList = result
      resultChan <- pageResult
   }()
   for {
      select {
      case err := <-errChan:
         return nil, err
      case result := <-resultChan:
         if result.Total != -1 && result.ResultList != nil {
            return &result, nil
         }
      case <-time.After(time.Second * 5):
         return nil, exceptions.NewInterError(fmt.Sprintf("超時,分頁查詢失敗"))
      }
   }
}

以上是借鑑網上一些處理方法和自己的一些想法與實踐經驗,可以互相探討與學習,更多關於Go 程式碼規範錯誤處理的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com