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

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

日誌需求:
  1. 無需修改業務代碼即可切換到其他日誌庫
  2. 不需直接依賴任何日誌庫
  3. 整個應用程序只有一個日誌庫的全局實例,因此你可以在一個位置更改日誌配置並將其應用於整個程序。
  4. 可以在不修改代碼的情況下輕鬆更改日誌記錄選項,例如,日誌級別
  5. 能夠在程序運行時動態更改日誌級別
資源句柄:爲什麼日誌記錄與數據庫不同

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

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

爲了不將應用程序緊密綁定到特定的記錄器,我創建了一個通用的記錄器接口,因此應用程序對於具體的記錄器透明的。以下是記錄器(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(”% vn“,err)” 或“fmt.Printf(”% vn“,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

[4]Let’s talk about logging

[5]database/sql Tx — detecting Commit or Rollback

[6]On the uses and misuses of panics in Go

不堆砌術語,不羅列架構,不迷信權威,不盲從流行,堅持獨立思考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://blog.csdn.net/weixin_38748858/article/details/103822111