清晰架構(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

[4]Let’s talk about logging

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM