golang獲取執行函數名,執行文件名與所在行數

本文首發於我的個人博客

這篇文章介紹了作者在參與一個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.Callerruntime.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.Callerruntime.FuncForPC這一組合擊的時候,實際上的輸入參數只有一個,那就是runtime.Callerskip

如何確定skip呢?在實踐中,我一般使用兩種方式:

  1. 寫死
  2. 嘗試自動獲取

聽起來第二種方法要比第一種方法好,但是事實上並不是這樣的,在看完實現之後,大家就會明白了

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

但是這次在開發日誌系統時,遇到了這樣的場景:

日誌擁有接口log1log2log2調用log1,業務代碼既可能調用log2,也可能直接調用log1
log1下層調用runtime.Callerruntime.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
        }
    }
}

這樣就算是一個能夠解決問題的方案了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章