微服務從程式碼到k8s部署應有盡有系列(十、錯誤處理)

語言: CN / TW / HK

我們用一個系列來講解從需求到上線、從程式碼到k8s部署、從日誌到監控等各個方面的微服務完整實踐。

整個專案使用了go-zero開發的微服務,基本包含了go-zero以及相關go-zero作者開發的一些中介軟體,所用到的技術棧基本是go-zero專案組的自研元件,基本是go-zero全家桶了。

實戰專案地址:https://github.com/Mikaelemmmm/go-zero-looklook

1、概述

我們在平時開發時候,程式在出錯時,希望可以通過錯誤日誌能快速定位問題(那麼傳遞進來的引數、包括堆疊資訊肯定就要都要列印到日誌),但同時又想返回給前端使用者比較友善、能看得懂的錯誤提示,那這兩點如果只通過一個fmt.Error、errors.new等返回一個錯誤資訊肯定是無法做到的,除非在返回前端錯誤提示的地方同時在記錄log,這樣的話日誌滿天飛,程式碼難看不說,日誌到時候也會很難看。

那麼我們想一下,如果有一個統一的地方記錄日誌,同時在業務程式碼中只需要一個return err 就能將返回給前端的錯誤提示資訊、日誌記錄相信資訊分開提示跟記錄,如果按照這個思路實現,那簡直不要太爽,是的 go-zero-looklook就是這麼處理的,接下來我們看下。

2、rpc錯誤處理

按照正常情況下,go-zero的rpc服務是基於grpc的,預設返回的錯誤是grpc的status.Error 沒法給我們自定義的錯誤合併,並且也不適合我們自定義的錯誤,它的錯誤碼、錯誤型別都是定義死在grpc包中的,ok ,如果我們在rpc中能用自定義錯誤返回,然後在攔截器統一返回時候轉成grpc的status.Error , 那麼我們rpc的err跟api的err是不是可以統一管理我們自己的錯誤了呢?

我們看一下grpc的status.Error的code裡面是什麼

package codes // import "google.golang.org/grpc/codes"

import (
    "fmt"
    "strconv"
)

// A Code is an unsigned 32-bit error code as defined in the gRPC spec.
type Code uint32
.......

grpc的err對應的錯誤碼其實就是一個uint32 , 我們自己定義錯誤用uint32然後在rpc的全域性攔截器返回時候轉成grpc的err,就可以了

所以我們自己定義全域性錯誤碼在app/common/xerr

errCode.go

package xerr

// 成功返回
const OK uint32 = 200

// 前3位代表業務,後三位代表具體功能

// 全域性錯誤碼
const SERVER_COMMON_ERROR uint32 = 100001
const REUQES_PARAM_ERROR uint32 = 100002
const TOKEN_EXPIRE_ERROR uint32 = 100003
const TOKEN_GENERATE_ERROR uint32 = 100004
const DB_ERROR uint32 = 100005

// 使用者模組

errMsg.go

package xerr

var message map[uint32]string

func init() {
   message = make(map[uint32]string)
   message[OK] = "SUCCESS"
   message[SERVER_COMMON_ERROR] = "伺服器開小差啦,稍後再來試一試"
   message[REUQES_PARAM_ERROR] = "引數錯誤"
   message[TOKEN_EXPIRE_ERROR] = "token失效,請重新登陸"
   message[TOKEN_GENERATE_ERROR] = "生成token失敗"
   message[DB_ERROR] = "資料庫繁忙,請稍後再試"
}

func MapErrMsg(errcode uint32) string {
   if msg, ok := message[errcode]; ok {
      return msg
   } else {
      return "伺服器開小差啦,稍後再來試一試"
   }
}

func IsCodeErr(errcode uint32) bool {
   if _, ok := message[errcode]; ok {
      return true
   } else {
      return false
   }
}

errors.go

package xerr

import "fmt"

// 常用通用固定錯誤
type CodeError struct {
   errCode uint32
   errMsg  string
}

// 返回給前端的錯誤碼
func (e *CodeError) GetErrCode() uint32 {
   return e.errCode
}

// 返回給前端顯示端錯誤資訊
func (e *CodeError) GetErrMsg() string {
   return e.errMsg
}

func (e *CodeError) Error() string {
   return fmt.Sprintf("ErrCode:%d,ErrMsg:%s", e.errCode, e.errMsg)
}

func NewErrCodeMsg(errCode uint32, errMsg string) *CodeError {
   return &CodeError{errCode: errCode, errMsg: errMsg}
}
func NewErrCode(errCode uint32) *CodeError {
   return &CodeError{errCode: errCode, errMsg: MapErrMsg(errCode)}
}

func NewErrMsg(errMsg string) *CodeError {
   return &CodeError{errCode: SERVER_COMMON_ERROR, errMsg: errMsg}
}

比如我們在使用者註冊時候的rpc程式碼

package logic

import (
    "context"

    "looklook/app/identity/cmd/rpc/identity"
    "looklook/app/usercenter/cmd/rpc/internal/svc"
    "looklook/app/usercenter/cmd/rpc/usercenter"
    "looklook/app/usercenter/model"
    "looklook/common/xerr"

    "github.com/pkg/errors"
    "github.com/tal-tech/go-zero/core/logx"
    "github.com/tal-tech/go-zero/core/stores/sqlx"
)

var ErrUserAlreadyRegisterError = xerr.NewErrMsg("該使用者已被註冊")

type RegisterLogic struct {
    ctx    context.Context
    svcCtx *svc.ServiceContext
    logx.Logger
}

func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
    return &RegisterLogic{
        ctx:    ctx,
        svcCtx: svcCtx,
        Logger: logx.WithContext(ctx),
    }
}

func (l *RegisterLogic) Register(in *usercenter.RegisterReq) (*usercenter.RegisterResp, error) {

    user, err := l.svcCtx.UserModel.FindOneByMobile(in.Mobile)
    if err != nil && err != model.ErrNotFound {
        return nil, errors.Wrapf(xerr.ErrDBError, "mobile:%s,err:%v", in.Mobile, err)
    }

    if user != nil {
        return nil, errors.Wrapf(ErrUserAlreadyRegisterError, "使用者已經存在 mobile:%s,err:%v", in.Mobile, err)
    }

    var userId int64

    if err := l.svcCtx.UserModel.Trans(func(session sqlx.Session) error {

        user := new(model.User)
        user.Mobile = in.Mobile
        user.Nickname = in.Nickname
        insertResult, err := l.svcCtx.UserModel.Insert(session, user)
        if err != nil {
            return errors.Wrapf(xerr.ErrDBError, "err:%v,user:%+v", err, user)
        }
        lastId, err := insertResult.LastInsertId()
        if err != nil {
            return errors.Wrapf(xerr.ErrDBError, "insertResult.LastInsertId err:%v,user:%+v", err, user)
        }
        userId = lastId

        userAuth := new(model.UserAuth)
        userAuth.UserId = lastId
        userAuth.AuthKey = in.AuthKey
        userAuth.AuthType = in.AuthType
        if _, err := l.svcCtx.UserAuthModel.Insert(session, userAuth); err != nil {
            return errors.Wrapf(xerr.ErrDBError, "err:%v,userAuth:%v", err, userAuth)
        }
        return nil
    }); err != nil {
        return nil, err
    }

    // 2、生成token.
    resp, err := l.svcCtx.IdentityRpc.GenerateToken(l.ctx, &identity.GenerateTokenReq{
        UserId: userId,
    })
    if err != nil {
        return nil, errors.Wrapf(ErrGenerateTokenError, "IdentityRpc.GenerateToken userId : %d , err:%+v", userId, err)
    }

    return &usercenter.RegisterResp{
        AccessToken:  resp.AccessToken,
        AccessExpire: resp.AccessExpire,
        RefreshAfter: resp.RefreshAfter,
    }, nil
}
errors.Wrapf(ErrUserAlreadyRegisterError, "使用者已經存在 mobile:%s,err:%v", in.Mobile, err)

這裡我們使用go預設的errors的包的errors.Wrapf ( 如果這裡不明白就去查一下go的errors包下的Wrap、 Wrapf等)

第一個引數, ErrUserAlreadyRegisterError 定義在上方 就是使用xerr.NewErrMsg("該使用者已被註冊") , 返回給前端友好的提示,要記住這裡用的是我們xerr包下的方法

第二個引數,就是記錄在伺服器日誌,可以寫詳細一點都沒關係只會記錄在伺服器不會被返回給前端

那我們來看看為什麼第一個引數就能是返回給前端的,第二個引數就是記錄日誌的

⚠️【注】我們在rpc的啟動檔案main方法中,加了grpc的全域性攔截器,這個很重要 ,如果不加這個沒辦法實現

package main

......

func main() {
    ........

    //rpc log,grpc的全域性攔截器
    s.AddUnaryInterceptors(rpcserver.LoggerInterceptor)

    .......
}

我們看看rpcserver.LoggerInterceptor的具體實現

import (
    ...
    "github.com/pkg/errors"
)

func LoggerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
   resp, err = handler(ctx, req)
   if err != nil {
      causeErr := errors.Cause(err)                // err型別
      if e, ok := causeErr.(*xerr.CodeError); ok { //自定義錯誤型別
         logx.WithContext(ctx).Errorf("【RPC-SRV-ERR】 %+v", err)

         //轉成grpc err
         err = status.Error(codes.Code(e.GetErrCode()), e.GetErrMsg())
      } else {
         logx.WithContext(ctx).Errorf("【RPC-SRV-ERR】 %+v", err)
      }
   }

   return resp, err
}

當有請求進入到rpc服務時候,先進入攔截器然後就是執行handler方法,如果你想在進入之前處理某些事情就可以寫在handler方法之前,那我們想處理的是返回結果如果有錯誤的情況,所以我們在handler下方使用了github.com/pkg/errors這個包,這個包處理錯誤是go中經常用到的這不是官方的errors包,但是設計的很好,go官方的Wrap、Wrapf等就是借鑑了這個包的思路。

因為我們grpc內部業務在返回錯誤時候

​ 1)如果是我們自己業務錯誤,我們會統一用xerr生成錯誤,這樣就可以拿到我們定義的錯誤資訊,因為前面我們自己錯誤也是用的uint32,所以在這裡統一轉成 grpc錯誤err = status.Error(codes.Code(e.GetErrCode()), e.GetErrMsg()),那這裡獲取到的,e.GetErrCode()就是我們定義的code,e.GetErrMsg() 就是我們之前定義返回的錯誤第二個引數

2)但是還有一種情況是rpc服務異常了底部丟擲來的錯誤,本身就是grpc錯誤了,那這種的我們直接就記錄異常就好了

3、api錯誤

當我們api在logic中呼叫rpc的Register時候,rpc返回了上面第2步的錯誤資訊 程式碼如下

......
func (l *RegisterLogic) Register(req types.RegisterReq) (*types.RegisterResp, error) {
    registerResp, err := l.svcCtx.UsercenterRpc.Register(l.ctx, &usercenter.RegisterReq{
        Mobile:   req.Mobile,
        Nickname: req.Nickname,
        AuthKey:  req.Mobile,
        AuthType: model.UserAuthTypeSystem,
    })
    if err != nil {
        return nil, errors.Wrapf(err, "req: %+v", req)
    }

    var resp types.RegisterResp
    _ = copier.Copy(&resp, registerResp)

    return &resp, nil
}

這裡同樣是使用標準包的errors.Wrapf , 也就是說所有我們業務中返回錯誤都適用標準包的errors,但是內部引數要使用我們xerr定義的錯誤

這裡有2個注意點

1)api服務想把rpc返回給前端友好的錯誤提示資訊,我們想直接返回給前端不做任何處理(比如rpc已經返回了“使用者已存在”,api不想做什麼處理,就想把這個錯誤資訊直接返回給前端)

針對這種情況,直接就像上圖這種寫就可以了,將rpc呼叫處的err直接作為errors.Wrapf 第一個引數扔出去,但是第二個引數最好記錄一下自己需要的詳細日誌方便後續在api log裡檢視

2)api服務不管rpc返回的是什麼錯誤資訊,我就想自己再重新定義給前端返回錯誤資訊(比如rpc已經返回了“使用者已存在”,api想呼叫rpc時只要有錯誤我就返回給前端“使用者註冊失敗”)

針對這種情況,如下這樣寫即可(當然你可以將xerr.NewErrMsg("使用者註冊失敗") 放到程式碼上方使用一個變數,這裡放變數也可以)

func (l *RegisterLogic) Register(req types.RegisterReq) (*types.RegisterResp, error) {
    .......
    if err != nil {
        return nil, errors.Wrapf(xerr.NewErrMsg("使用者註冊失敗"), "req: %+v,rpc err:%+v", req,err)
    }
    .....
}

接下來我們看最終返回給前端怎麼處理的,我們接著看app/usercenter/cmd/api/internal/handler/user/registerHandler.go

func RegisterHandler(ctx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req types.RegisterReq
        if err := httpx.Parse(r, &req); err != nil {
            httpx.Error(w, err)
            return
        }

        l := user.NewRegisterLogic(r.Context(), ctx)
        resp, err := l.Register(req)
        result.HttpResult(r, w, resp, err)
    }
}

這裡可以看到,go-zero-looklook生成的handler程式碼 有2個地方跟預設官方的goctl生成的程式碼不一樣,就是在處理錯誤處理的時候,這裡替換成我們自己的錯誤處理了,在common/result/httpResult.go

【注】有人會說,每次使用goctl都要過來手動改,那不是要麻煩死了,這裡我們使用go-zero給我們提供的template模版功能(還不知道這個的就要去官方文件學習一下了),修改一下handler生成模版即可,整個專案的模版檔案放在deploy/goctl下,這裡hanlder修改的模版在deploy/goctl/1.2.3-cli/api/handler.tpl

ParamErrorResult很簡單,專門處理引數錯誤的

// http 引數錯誤返回
func ParamErrorResult(r *http.Request, w http.ResponseWriter, err error) {
   errMsg := fmt.Sprintf("%s ,%s", xerr.MapErrMsg(xerr.REUQES_PARAM_ERROR), err.Error())
   httpx.WriteJson(w, http.StatusBadRequest, Error(xerr.REUQES_PARAM_ERROR, errMsg))
}

我們主要來看HttpResult , 業務返回的錯誤處理的

// http返回
func HttpResult(r *http.Request, w http.ResponseWriter, resp interface{}, err error) {
    if err == nil {
        // 成功返回
        r := Success(resp)
        httpx.WriteJson(w, http.StatusOK, r)
    } else {
        // 錯誤返回
        errcode := xerr.SERVER_COMMON_ERROR
        errmsg := "伺服器開小差啦,稍後再來試一試"

        causeErr := errors.Cause(err) // err型別
        if e, ok := causeErr.(*xerr.CodeError); ok {
            // 自定義錯誤型別
            // 自定義CodeError
            errcode = e.GetErrCode()
            errmsg = e.GetErrMsg()
        } else {
            if gstatus, ok := status.FromError(causeErr); ok {
                // grpc err錯誤
                grpcCode := uint32(gstatus.Code())
                if xerr.IsCodeErr(grpcCode) {
                    // 區分自定義錯誤跟系統底層、db等錯誤,底層、db錯誤不能返回給前端
                    errcode = grpcCode
                    errmsg = gstatus.Message()
                }
            }
        }

        logx.WithContext(r.Context()).Errorf("【API-ERR】 : %+v ", err)
        httpx.WriteJson(w, http.StatusBadRequest, Error(errcode, errmsg))
    }
}

err : 要記錄的日誌錯誤

errcode : 返回給前端的錯誤碼

errmsg :返回給前端的友好的錯誤提示資訊

成功直接返回,如果遇到錯誤了,也是使用github.com/pkg/errors這個包來判斷錯誤,是不是我們自己定義的錯誤(api中定義的錯誤直接使用我們自己定義的xerr),還是grpc錯誤(rpc業務丟擲來的),如果是grpc錯誤在通過uint32轉成我們自己錯誤碼,根據錯誤碼再去我們自己定義錯誤資訊中找到定義的錯誤資訊返回給前端,如果是api錯誤直接返回給前端我們自己定義的錯誤資訊,都找不到那就返回預設錯誤“伺服器開小差了” ,

4、結尾

到這裡錯誤處理已經訊息描述清楚了,接下來我們要看列印了服務端的錯誤日誌,我們該如何收集檢視,就涉及到日誌收集系統。

專案地址

https://github.com/zeromicro/go-zero

https://gitee.com/kevwan/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。