本文首發於我的個人博客
這篇文章介紹了作者在參與一個golang日誌系統的開發的時候,解決需要打印出執行日誌打印操作時的業務函數名,業務文件名與所在行數的需求過程中,遇到的問題和解決方案
需求場景
在平日裏使用日誌的時候,一個好的日誌系統,往往會打印出類似如下的信息
<log_level>:<log_message>:<package_path>/<filename>:<line_no>:<function_name>
比如
INFO:connect to sql:/users/admin/home/go/src/io/rivers/demoProject/main.go:45:io.rivers.demoProject.testFunction
這樣子在打印出日誌等級,日誌消息的同時,輸出業務邏輯所在的文件,行數,函數,對後期的bug排查,性能分析都有很大的幫助
那麼,如何在golang中實現這一功能呢?
實現方式
golang的runtime包提供了與之相應的函數接口,主要是runtime.Caller
和runtime.FuncForPC
先看一下二者的函數簽名
func Caller(skip int) (pc uintptr, file string, line int, ok bool)
func FuncForPC(pc uintptr) *Func
單看函數簽名就比較容易瞭解到:
runtime.Caller
能夠返回在函數棧中的PC(指令寄存器,可以認爲存儲了當前執行到了哪裏),所在的文件,所在文件的具體哪一行runtime.FuncForPC
能夠根據給定的指令寄存器給出其所在的行數
其中runtime.FuncForPC
的參數比較容易理解,就是指指令寄存器,但是runtime.Caller
的參數需要解釋一下
這裏的skip
指的是跳過多少個函數棧:
skip == 0
,不跳過函數棧,返回當前函數PC,文件名,所在行skip == 1
,跳過當前函數棧,返回上層調用者調用當前函數時的PC,文件名,所在行skip == 2
,以此類推
一般情況下這兩個函數都是連在一起使用,如
// 獲取上層調用者PC,文件名,所在行
pc, codePath, codeLine, ok := runtime.Caller(1)
if !ok{
// 不ok,函數棧用盡了
code = "-"
func = "-"
} else {
// 拼接文件名與所在行
code = fmt.Sprintf("%s:%d", codePath, codeLine)
// 根據PC獲取函數名
func = runtime.FuncForPC(pc).Name()
}
實現重點與自動獲取的優化
可以看到,在我們使用runtime.Caller
和runtime.FuncForPC
這一組合擊的時候,實際上的輸入參數只有一個,那就是runtime.Caller
的skip
。
如何確定skip
呢?在實踐中,我一般使用兩種方式:
- 寫死
- 嘗試自動獲取
聽起來第二種方法要比第一種方法好,但是事實上並不是這樣的,在看完實現之後,大家就會明白了
將skip
寫死
這種方式是比較常見的,通常適用於設計時確定了調用層數的情況,以日誌系統爲例,我們現在要提供一個接口log
,那麼我知道外界肯定是要直接調用log
的,我最終要打印的就是調用log
的函數的文件名,所在行,函數名
那麼如果我是在log
裏使用runtime.Caller
,那麼我的skip
就應該是1
func log(logLevel int, logMessage string) {
//....
pc, file, line, ok := runtime.Caller(1)
//....
}
如果我還做了封裝,那麼就要根據編寫代碼時的封裝層數調整skip
,比如
func log(logLevel int, logMessage string) {
//....
logHelper(logLevel, logMessage)
//....
}
func logHelper(logLevel int, logMessage string) {
//....
logReal(logLevel, logMessage)
//....
}
func logReal(logLevel int, logMessage string) {
//...
pc, file, line, ok := runtime.Caller(3)
//...
}
上述示例中,由於多了兩層封裝,所以要把skip
更改爲3
嘗試自動獲取
這次的嘗試自動獲取是我在編寫日誌系統時遇到的一個比較特殊的情況
在上面說的#將skip寫死中,其實我們有一個重要的前提,那就是
業務函數全部直接調用日誌接口
log
但是這次在開發日誌系統時,遇到了這樣的場景:
日誌擁有接口
log1
和log2
,log2
調用log1
,業務代碼既可能調用log2
,也可能直接調用log1
log1
下層調用runtime.Caller
和runtime.FuncForPC
組合
這種情況下,skip
是不可能寫死在源代碼裏的,於是採取的解決方案如下
由於日誌系統在一個獨立的包裏,所以在
FuncForPC
將函數名取出來以後,判斷是否是日誌包中的函數,如果是,就增加skip
的值
實現:
for skip := 1; true; skip++ {
pc, codePath, codeLine, ok := runtime.Caller(skip)
if !ok{
// 不ok,函數棧用盡了
auto.Code = prevCode
auto.Func = prevFunc
return auto
} else{
prevCode = fmt.Sprintf("%s:%d", codePath, codeLine)
prevFunc = runtime.FuncForPC(pc).Name()
auto.Code = prevCode
auto.Func = prevFunc
if !strings.Contains(prevFunc, "<package_name>") {
// 找到包外的函數了
return auto
}
}
}
這樣就算是一個能夠解決問題的方案了