清晰架構(Clean Architecture)的Go微服務: 日誌管理

良好的日誌記錄能夠提供豐富的日誌數據,便於在調試時發現問題,從而大大提升編碼效率。 記錄器提供的自動化信息越多越好,日誌信息也須要以簡潔的方式呈現,便於找到重要的數據。git

日誌需求:
  1. 無需修改業務代碼便可切換到其餘日誌庫程序員

  2. 不需直接依賴任何日誌庫github

  3. 整個應用程序只有一個日誌庫的全局實例,所以你能夠在一個位置更改日誌配置並將其應用於整個程序。sql

  4. 能夠在不修改代碼的狀況下輕鬆更改日誌記錄選項,例如,日誌級別數據庫

  5. 可以在程序運行時動態更改日誌級別服務器

資源句柄:爲何日誌記錄與數據庫不一樣

當應用程序須要處理外部資源時,例如數據庫,文件系統,網絡鏈接, SMTP服務器時,它一般須要一個資源句柄(Resource Handler)。在依賴注入中,容器建立一個資源句柄並將其注入每一個業務函數,所以它可使用資源句柄來訪問底層資源。在此應用程序中,資源句柄是一個接口,所以業務層不會直接依賴於資源句柄的任何具體實現。數據庫和gRPC連接都以這種方式處理。網絡

可是,日誌記錄器稍有不一樣,由於幾乎每一個函數都須要它,但數據庫不是。在Java中,咱們爲每一個Java類初始化一個記錄器(Logger)實例。 Java日誌記錄框架使用層次關係來管理不一樣的記錄器,所以它們從父日誌記錄器繼承相同的日誌配置。在Go中,不一樣的記錄器之間沒有層次關係,所以你要麼建立一個記錄器,要麼具備許多彼此不相關的不一樣記錄器。爲了得到一致的日誌記錄配置,最好建立一個全局記錄器並將其注入每一個函數。但者將須要作不少工做,因此我決定在一箇中心位置建立一個全局記錄器,每一個函數能夠直接引用它。app

爲了避免將應用程序緊密綁定到特定的記錄器,我建立了一個通用的記錄器接口,所以應用程序對於具體的記錄器透明的。如下是記錄器(Logger)接口。框架

// Log is a package level variable, every program should access logging function through "Log"
var Log Logger

// Logger represent common interface for logging function
type Logger interface {
    Errorf(format string, args ...interface{})
    Fatalf(format string, args ...interface{})
    Fatal(args ...interface{})
    Infof(format string, args ...interface{})
    Info( args ...interface{})
    Warnf(format string, args ...interface{})
    Debugf(format string, args ...interface{})
    Debug(args ...interface{})
}

由於每一個文件都依賴於日誌記錄,很容易產生循環依賴,因此我在「容器」包裏面建立了一個單獨的子包「logger」來避免這個問題。 它只有一個「Log」變量和「Logger」接口。 每一個文件都經過這個變量和接口訪問日誌功能。函數

記錄器封裝

支持一個日誌庫的標準方法(例如ZAP¹或Logrus²) 是建立一個封裝來實現已經建立的記錄器接口。 這很簡單,如下是代碼。

type loggerWrapper struct {
    lw *zap.SugaredLogger
}
func (logger *loggerWrapper) Errorf(format string, args ...interface{}) {
    logger.lw.Errorf(format, args)
}
func (logger *loggerWrapper) Fatalf(format string, args ...interface{}) {
    logger.lw.Fatalf(format, args)
}
func (logger *loggerWrapper) Fatal(args ...interface{}) {
    logger.lw.Fatal(args)
}
func (logger *loggerWrapper) Infof(format string, args ...interface{}) {
    logger.lw.Infof(format, args)
}
func (logger *loggerWrapper) Warnf(format string, args ...interface{}) {
    logger.lw.Warnf(format, args)
}
func (logger *loggerWrapper) Debugf(format string, args ...interface{}) {
    logger.lw.Debugf(format, args)
}
func (logger *loggerWrapper) Printf(format string, args ...interface{}) {
    logger.lw.Infof(format, args)
}
func (logger *loggerWrapper) Println(args ...interface{}) {
    logger.lw.Info(args, "\n")
}

可是日誌記錄存在一個問題。日誌記錄的一個功能是在日誌消息中打印記錄者名字。在對接口封裝以後,方法的調用者不是打印日誌的程序,而是封裝程序。要解決該問題,你能夠直接更改日誌庫的源代碼,但在升級日誌庫時會致使兼容性問題。最終的解決方案是要求日誌記錄庫建立一個新功能,該功能能夠根據方法是否使用封裝來返回合適的調用方。

爲了讓代碼如今能正常工做,我走了捷徑。由於ZAP和Logrus之間的大多數函數簽名是類似的,因此我提取了經常使用的簽名並建立了一個共享接口,由於兩個日誌庫都已經有了這些函數,它們自動實現這些接口。 Go接口設計的優勢在於,你能夠先建立具體實現,而後再建立接口,若是函數簽名相互匹配,則自動實現接口。這有點做弊,但很是有效。若是要用的記錄器不支持公共的接口,則仍是要對它進行封裝, 這樣就只能暫時先犧牲調用者功能或修改源代碼。

日誌庫比較:

不一樣的日誌庫提供不一樣的功能,其中一些功能對於調試很重要。

須要記錄的重要信息(須要如下數據):

  1. 文件名和行號

  2. 方法名稱和調用文件名

  3. 消息記錄級別

  4. 時間戳

  5. 錯誤堆棧跟蹤

  6. 自動記錄每一個函數調用包括參數和結果

我但願日誌庫自動提供這些數據,例如調用方法名稱,而不編寫顯式代碼來實現。對於上述6個功能,目前沒有日誌庫提供#6,但它們都提供1到5箇中的部分或所有。我嘗試了兩個很是流行的日誌庫Logrus和ZAP。 Logrus提供了全部功能,可是個人控制檯上的格式不正確(它在個人Windows控制檯上顯示「 n  t」而不是新行)而且輸出格式不像ZAP那樣乾淨。 ZAP不提供#2,但其餘一切看起來都不錯,因此我決定暫時使用它。

使人驚訝的是,本程序被證實是一個很是好的工具來測試不一樣的日誌庫,由於你能夠切換到不一樣的日誌庫來比較輸出結果,而只須要更改配置文件中的一行。這不是本程序的功能,而是一個好的反作用。

實際上,我最須要的功能是自動記錄每一個函數調用包括參數和結果(#6),可是尚未日誌庫提供該功能提供。我但願未來可以獲得它。

錯誤(error)處理:

錯誤處理與日誌記錄直接相關,因此我也在這裏討論一下。如下是我在處理錯誤時遵循的規則。

1.使用堆棧跟蹤建立錯誤
錯誤消息自己須要包含堆棧跟蹤信息。若是錯誤源自你的程序,你能夠導入「github.com/pkg/errors」庫來建立錯誤以包含堆棧跟蹤。可是若是它是從另外一個庫生成的而且該庫沒有使用「pkg/errors」,你須要用「errors.Wrap(err,message)」語句包裝該錯誤,以獲取堆棧跟蹤信息。因爲咱們沒法控制第三方庫,所以最好的解決方案是在咱們的程序中對全部錯誤進行包裝。詳情請見這裏³。

2.使用堆棧跟蹤打印錯誤
你須要使用「logger.Log.Errorf(」%+v\n「,err)」或「fmt.Printf(」%+v\n「,err)」以便打印堆棧跟蹤信息,關鍵是「+v」選項(固然你必須已經使用#1)。

3.只有頂級函數才能處理錯誤
「處理」表示記錄錯誤並將錯誤返回給調用者。由於只有頂級函數處理錯誤,因此錯誤只在程序中記錄一次。頂層的調用者一般是面向用戶的程序,它是用戶界面程序(UI)或另外一個微服務。你但願記錄錯誤消息(所以你的程序中具備記錄),而後將消息返回到UI或其餘微服務,以便他們能夠重試或對錯誤執行某些操做。

4.全部其餘級別函數應只是將錯誤傳播到較高級別
底層或中間層函數不要記錄或處理錯誤,也不要丟棄錯誤。你能夠向錯誤中添加更多數據,而後傳播它。當出現錯誤時,你不但願中止整個應用程序。

恐慌(Panic):

除了在本地的「main.go」以外,我從未使用過恐慌(Panic)。它更像是一個bug而不是一個功能。在讓咱們談談日誌⁴中,Dave Cheney寫道「人們廣泛認爲應用庫不該該使用恐慌」。另外一個錯誤是log.Fatal,它具備與恐慌相同的效果,也應該被禁止。 「log.Fatal」更糟糕,它看起來像一個日誌,可是在輸出日誌後它「恐慌」,這違反了單一責任規則。

恐慌有兩個問題。首先,它與錯誤的處理方式不一樣,但它其實是一個錯誤,一個錯誤的子類型。如今,錯誤處理代碼須要處理錯誤和恐慌,例如事務處理代碼⁵中的錯誤處理代碼。其次,它會中止應用程序,這很是糟糕。只有頂級主控制程序才能決定如何處理錯誤,全部其餘被調用的函數應該只將錯誤傳播到上層。特別是如今,服務網格層(Service Mesh)能夠提供重試等功能,恐慌使其更加複雜。

若是你正在調用第三方庫而且它在代碼中產生恐慌,那麼爲了防止代碼中止,你須要截獲恐慌並從中恢復。如下是代碼示例,你須要爲每一個可能發生恐慌的頂級函數執行此操做(在每一個函數中放置「defer catchPanic()」)。在下面的代碼中,咱們有一個函數「catchPanic」來捕獲並從恐慌中恢復。函數「RegisterUser」在代碼的第一行調用「defer catchPanic()」。有關恐慌的詳細討論,請參閱此處⁶。

func catchPanic() {
    if p := recover(); p != nil {
        logger.Log.Errorf("%+v\n", p)
    }
}

func (uss *UserService) RegisterUser(ctx context.Context, req *uspb.RegisterUserReq)
    (*uspb.RegisterUserResp, error) {
    
    defer catchPanic()
    ruci, err := getRegistrationUseCase(uss.container)
    if err != nil {
        logger.Log.Errorf("%+v\n", err)
        return nil, errors.Wrap(err, "")
    }
    mu, err := userclient.GrpcToUser(req.User)
...
}
結論:

良好的日誌記錄可使程序員更有效。你但願使用堆棧跟蹤記錄錯誤。 只有頂級函數才能處理錯誤,全部其餘級別函數只應將錯誤傳播到上一級。 不要使用恐慌。

源程序:

完整的源程序連接 github: https://github.com/jfeng45/servicetmpl

索引:

[1] zap

[2] Logrus

[3][Stack traces and the errors package](https://dave.cheney.net/2016/06/12/stack-traces-and-the-errors-package)

[4][Let’s talk about logging](https://dave.cheney.net/2015/11/05/lets-talk-about-logging)

[5][database/sql Tx — detecting Commit or Rollback](https://stackoverflow.com/questions/16184238/database-sql-tx-detecting-commit-or-rollback/23502629#23502629)

[6][On the uses and misuses of panics in Go](https://eli.thegreenplace.net/2018/on-the-uses-and-misuses-of-panics-in-go/)

相關文章
相關標籤/搜索