1. 程式人生 > >清晰架構(Clean Architecture)的Go微服務: 日誌管理

清晰架構(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(”%+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-g