一文帶你了解Go中跟蹤函數(shù)調(diào)用鏈的實(shí)現(xiàn)
一、引入
defer 是 Gopher 們都喜歡的語言機(jī)制,除了捕捉 panic、延遲釋放資源外,我們?nèi)粘>幋a中還有哪些使用 defer 的小技巧呢?
在平時(shí),defer 的一個(gè)常見的使用技巧就是使用 defer 可以跟蹤函數(shù)的執(zhí)行過程。這的確是很多 Go 教程在講解 defer 時(shí)也會(huì)經(jīng)常使用這個(gè)用途舉例。那么,我們具體是怎么用 defer 來實(shí)現(xiàn)函數(shù)執(zhí)行過程的跟蹤呢?這里,給出了一個(gè)最簡(jiǎn)單的實(shí)現(xiàn):
// trace.go
package main
func Trace(name string) func() {
println("enter:", name)
return func() {
println("exit:", name)
}
}
func foo() {
defer Trace("foo")()
bar()
}
func bar() {
defer Trace("bar")()
}
func main() {
defer Trace("main")()
foo()
}
我們先看一下這段代碼的執(zhí)行結(jié)果,直觀感受一下什么是函數(shù)調(diào)用跟蹤:
enter: main
enter: foo
enter: bar
exit: bar
exit: foo
exit: main
我們看到,這個(gè) Go 程序的函數(shù)調(diào)用的全過程一目了然地展現(xiàn)在了我們面前:程序按 main -> foo -> bar 的函數(shù)調(diào)用次序執(zhí)行,代碼在函數(shù)的入口與出口處分別輸出了跟蹤日志。
那這段代碼是怎么做到的呢?我們簡(jiǎn)要分析一下。
在這段代碼中,我們?cè)诿總€(gè)函數(shù)的入口處都使用 defer 設(shè)置了一個(gè) deferred 函數(shù)。根據(jù) defer 的運(yùn)作機(jī)制,Go 會(huì)在 defer 設(shè)置 deferred 函數(shù)時(shí)對(duì) defer 后面的表達(dá)式進(jìn)行求值。
我們以 foo 函數(shù)中的 defer Trace("foo")() 這行代碼為例,Go 會(huì)對(duì) defer 后面的表達(dá)式 Trace("foo")() 進(jìn)行求值。由于這個(gè)表達(dá)式包含一個(gè)函數(shù)調(diào)用 Trace("foo"),所以這個(gè)函數(shù)會(huì)被執(zhí)行。
上面的 Trace 函數(shù)只接受一個(gè)參數(shù),這個(gè)參數(shù)代表函數(shù)名。Trace 會(huì)首先打印進(jìn)入某函數(shù)的日志,比如:“enter: foo”。然后返回一個(gè)閉包函數(shù),這個(gè)閉包函數(shù)一旦被執(zhí)行,就會(huì)輸出離開某函數(shù)的日志。在 foo 函數(shù)中,這個(gè)由 Trace 函數(shù)返回的閉包函數(shù)就被設(shè)置為了 deferred 函數(shù),于是當(dāng) foo 函數(shù)返回后,這個(gè)閉包函數(shù)就會(huì)被執(zhí)行,輸出 “exit: foo”的日志。
搞清楚上面跟蹤函數(shù)調(diào)用鏈的實(shí)現(xiàn)原理后,我們?cè)賮砜纯催@個(gè)實(shí)現(xiàn)。我們會(huì)發(fā)現(xiàn)這里還是有一些“瑕疵”,也就是離我們期望的“跟蹤函數(shù)調(diào)用鏈”的實(shí)現(xiàn)還有一些不足之處。這里我列舉了幾點(diǎn):
- 調(diào)用
Trace時(shí)需手動(dòng)顯式傳入要跟蹤的函數(shù)名; - 如果是并發(fā)應(yīng)用,不同 Goroutine 中函數(shù)鏈跟蹤混在一起無法分辨;
- 輸出的跟蹤結(jié)果缺少層次感,調(diào)用關(guān)系不易識(shí)別;
- 對(duì)要跟蹤的函數(shù),需手動(dòng)調(diào)用
Trace函數(shù)。
所以,本文就是逐一分析并解決上面提出的這幾點(diǎn)問題進(jìn)行,經(jīng)過逐步地代碼演進(jìn),最終實(shí)現(xiàn)一個(gè)自動(dòng)注入跟蹤代碼,并輸出有層次感的函數(shù)調(diào)用鏈跟蹤命令行工具。
我們先來解決第一個(gè)問題。
二、自動(dòng)獲取所跟蹤函數(shù)的函數(shù)名
要解決“調(diào)用 Trace 時(shí)需要手動(dòng)顯式傳入要跟蹤的函數(shù)名”的問題,也就是要讓我們的 Trace 函數(shù)能夠自動(dòng)獲取到它跟蹤函數(shù)的函數(shù)名信息。我們以跟蹤 foo 為例,看看這樣做能給我們帶來什么好處。
在手動(dòng)顯式傳入的情況下,我們需要用下面這個(gè)代碼對(duì) foo 進(jìn)行跟蹤:
defer Trace("foo")()
一旦實(shí)現(xiàn)了自動(dòng)獲取函數(shù)名,所有支持函數(shù)調(diào)用鏈跟蹤的函數(shù)都只需使用下面調(diào)用形式的 Trace 函數(shù)就可以了:
defer Trace()()
這種一致的 Trace 函數(shù)調(diào)用方式也為后續(xù)的自動(dòng)向代碼中注入 Trace 函數(shù)奠定了基礎(chǔ)。那么如何實(shí)現(xiàn) Trace 函數(shù)對(duì)它跟蹤函數(shù)名的自動(dòng)獲取呢?我們需要借助 Go 標(biāo)準(zhǔn)庫 runtime 包的幫助。
這里,我給出了新版 Trace 函數(shù)的實(shí)現(xiàn)以及它的使用方法,我們先看一下:
// trace1/trace.go
func Trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
fn := runtime.FuncForPC(pc)
name := fn.Name()
println("enter:", name)
return func() { println("exit:", name) }
}
func foo() {
defer Trace()()
bar()
}
func bar() {
defer Trace()()
}
func main() {
defer Trace()()
foo()
}
在這一版 Trace 函數(shù)中,我們通過 runtime.Caller 函數(shù)獲得當(dāng)前 Goroutine 的函數(shù)調(diào)用棧上的信息,runtime.Caller 的參數(shù)標(biāo)識(shí)的是要獲取的是哪一個(gè)棧幀的信息。當(dāng)參數(shù)為 0 時(shí),返回的是 Caller 函數(shù)的調(diào)用者的函數(shù)信息,在這里就是 Trace 函數(shù)。但我們需要的是 Trace 函數(shù)的調(diào)用者的信息,于是我們傳入 1。
Caller 函數(shù)有四個(gè)返回值:
- 第一個(gè)返回值代表的是程序計(jì)數(shù)(pc)。
- 第二個(gè)和第三個(gè)參數(shù)代表對(duì)應(yīng)函數(shù)所在的源文件名以及所在行數(shù),這里我們暫時(shí)不需要。
- 最后一個(gè)參數(shù)代表是否能成功獲取這些信息,如果獲取失敗,我們拋出
panic。
接下來,我們通過 runtime.FuncForPC 函數(shù)和程序計(jì)數(shù)器(PC)得到被跟蹤函數(shù)的函數(shù)名稱。我們運(yùn)行一下改造后的代碼:
enter: main.main
enter: main.foo
enter: main.bar
exit: main.bar
exit: main.foo
exit: main.main
接下來,我們來解決第二個(gè)問題,也就是當(dāng)程序中有多 Goroutine 時(shí),Trace 輸出的跟蹤信息混雜在一起難以分辨的問題。
三、增加 Goroutine 標(biāo)識(shí)
上面的 Trace 函數(shù)在面對(duì)只有一個(gè) Goroutine 的時(shí)候,還是可以支撐的,但當(dāng)程序中并發(fā)運(yùn)行多個(gè) Goroutine 的時(shí)候,多個(gè)函數(shù)調(diào)用鏈的出入口信息輸出就會(huì)混雜在一起,無法分辨。
那么,接下來我們還繼續(xù)對(duì) Trace 函數(shù)進(jìn)行改造,讓它支持多 Goroutine 函數(shù)調(diào)用鏈的跟蹤。我們的方案就是在輸出的函數(shù)出入口信息時(shí),帶上一個(gè)在程序每次執(zhí)行時(shí)能唯一區(qū)分 Goroutine 的 Goroutine ID。
到這里,你可能會(huì)說,Goroutine 也沒有 ID 信息?。〉拇_如此,Go 核心團(tuán)隊(duì)為了避免 Goroutine ID 的濫用,故意沒有將 Goroutine ID 暴露給開發(fā)者。但在 Go 標(biāo)準(zhǔn)庫的 h2_bundle.go 中,我們卻發(fā)現(xiàn)了一個(gè)獲取 Goroutine ID 的標(biāo)準(zhǔn)方法,看下面代碼:
// $GOROOT/src/net/http/h2_bundle.go
var http2goroutineSpace = []byte("goroutine ")
func http2curGoroutineID() uint64 {
bp := http2littleBuf.Get().(*[]byte)
defer http2littleBuf.Put(bp)
b := *bp
b = b[:runtime.Stack(b, false)]
// Parse the 4707 out of "goroutine 4707 ["
b = bytes.TrimPrefix(b, http2goroutineSpace)
i := bytes.IndexByte(b, ' ')
if i < 0 {
panic(fmt.Sprintf("No space found in %q", b))
}
b = b[:i]
n, err := http2parseUintBytes(b, 10, 64)
if err != nil {
panic(fmt.Sprintf("Failed to parse goroutine ID out of %q: %v", b, err))
}
return n
}
不過,由于 http2curGoroutineID 不是一個(gè)導(dǎo)出函數(shù),我們無法直接使用。我們可以把它復(fù)制出來改造一下:
// trace2/trace.go
var goroutineSpace = []byte("goroutine ")
func curGoroutineID() uint64 {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
// Parse the 4707 out of "goroutine 4707 ["
b = bytes.TrimPrefix(b, goroutineSpace)
i := bytes.IndexByte(b, ' ')
if i < 0 {
panic(fmt.Sprintf("No space found in %q", b))
}
b = b[:i]
n, err := strconv.ParseUint(string(b), 10, 64)
if err != nil {
panic(fmt.Sprintf("Failed to parse goroutine ID out of %q: %v", b, err))
}
return n
}
這里,我們改造了兩個(gè)地方。一個(gè)地方是通過直接創(chuàng)建一個(gè) byte 切片賦值給 b,替代原 http2curGoroutineID 函數(shù)中從一個(gè) pool 池獲取 byte 切片的方式,另外一個(gè)是使用 strconv.ParseUint 替代了原先的 http2parseUintBytes。改造后,我們就可以直接使用 curGoroutineID 函數(shù)來獲取 Goroutine 的 ID 信息了。
好,接下來,我們?cè)?nbsp;Trace 函數(shù)中添加 Goroutine ID 信息的輸出:
// trace2/trace.go
func Trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
fn := runtime.FuncForPC(pc)
name := fn.Name()
gid := curGoroutineID()
fmt.Printf("g[%05d]: enter: [%s]\n", gid, name)
return func() { fmt.Printf("g[%05d]: exit: [%s]\n", gid, name) }
}
從上面代碼看到,我們?cè)诔鋈肟谳敵龅母櫺畔⒅屑尤肓?nbsp;Goroutine ID 信息,我們輸出的 Goroutine ID 為 5 位數(shù)字,如果 ID 值不足 5 位,則左補(bǔ)零,這一切都是 Printf 函數(shù)的格式控制字符串“%05d”幫助我們實(shí)現(xiàn)的。這樣對(duì)齊 Goroutine ID 的位數(shù),為的是輸出信息格式的一致性更好。如果你的 Go 程序中 Goroutine 的數(shù)量超過了 5 位數(shù)可以表示的數(shù)值范圍,也可以自行調(diào)整控制字符串。
接下來,我們也要對(duì)示例進(jìn)行一些調(diào)整,將這個(gè)程序由單 Goroutine 改為多 Goroutine 并發(fā)的,這樣才能驗(yàn)證支持多 Goroutine 的新版 Trace 函數(shù)是否好用:
// trace2/trace.go
func A1() {
defer Trace()()
B1()
}
func B1() {
defer Trace()()
C1()
}
func C1() {
defer Trace()()
D()
}
func D() {
defer Trace()()
}
func A2() {
defer Trace()()
B2()
}
func B2() {
defer Trace()()
C2()
}
func C2() {
defer Trace()()
D()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
A2()
wg.Done()
}()
A1()
wg.Wait()
}
新示例程序共有兩個(gè) Goroutine,main goroutine 的調(diào)用鏈為 A1 -> B1 -> C1 -> D,而另外一個(gè) Goroutine 的函數(shù)調(diào)用鏈為 A2 -> B2 -> C2 -> D。我們來看一下這個(gè)程序的執(zhí)行結(jié)果是否和原代碼中兩個(gè) Goroutine 的調(diào)用鏈一致:
g[00001]: enter: [main.A1]
g[00001]: enter: [main.B1]
g[00018]: enter: [main.A2]
g[00001]: enter: [main.C1]
g[00001]: enter: [main.D]
g[00001]: exit: [main.D]
g[00001]: exit: [main.C1]
g[00001]: exit: [main.B1]
g[00001]: exit: [main.A1]
g[00018]: enter: [main.B2]
g[00018]: enter: [main.C2]
g[00018]: enter: [main.D]
g[00018]: exit: [main.D]
g[00018]: exit: [main.C2]
g[00018]: exit: [main.B2]
g[00018]: exit: [main.A2]
我們看到,新示例程序輸出了帶有 Goroutine ID 的出入口跟蹤信息,通過 Goroutine ID 我們可以快速確認(rèn)某一行輸出是屬于哪個(gè) Goroutine 的。
但由于 Go 運(yùn)行時(shí)對(duì) Goroutine 調(diào)度順序的不確定性,各個(gè) Goroutine 的輸出還是會(huì)存在交織在一起的問題,這會(huì)給你查看某個(gè) Goroutine 的函數(shù)調(diào)用鏈跟蹤信息帶來阻礙。這里我提供一個(gè)小技巧:你可以將程序的輸出重定向到一個(gè)本地文件中,然后通過 Goroutine ID 過濾出(可使用 grep 工具)你想查看的 goroutine 的全部函數(shù)跟蹤信息。
到這里,我們就實(shí)現(xiàn)了輸出帶有 Goroutine ID 的函數(shù)跟蹤信息,不過,你是不是也覺得輸出的函數(shù)調(diào)用鏈信息還是不夠美觀,缺少層次感,體驗(yàn)依舊不那么優(yōu)秀呢?至少我是這么覺得的。所以下面我們就來美化一下信息的輸出形式。
四、讓輸出的跟蹤信息更具層次感
對(duì)于程序員來說,縮進(jìn)是最能體現(xiàn)出“層次感”的方法,如果我們將上面示例中 Goroutine 00001 的函數(shù)調(diào)用跟蹤信息以下面的形式展示出來,函數(shù)的調(diào)用順序是不是更加一目了然了呢?
g[00001]: ->main.A1
g[00001]: ->main.B1
g[00001]: ->main.C1
g[00001]: ->main.D
g[00001]: <-main.D
g[00001]: <-main.C1
g[00001]: <-main.B1
g[00001]: <-main.A1
那么我們就以這個(gè)形式為目標(biāo),考慮如何實(shí)現(xiàn)輸出這種帶縮進(jìn)的函數(shù)調(diào)用跟蹤信息。我們還是直接上代碼吧:
// trace3/trace.go
func printTrace(id uint64, name, arrow string, indent int) {
indents := ""
for i := 0; i < indent; i++ {
indents += " "
}
fmt.Printf("g[%05d]:%s%s%s\n", id, indents, arrow, name)
}
var mu sync.Mutex
var m = make(map[uint64]int)
func Trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
fn := runtime.FuncForPC(pc)
name := fn.Name()
gid := curGoroutineID()
mu.Lock()
indents := m[gid] // 獲取當(dāng)前gid對(duì)應(yīng)的縮進(jìn)層次
m[gid] = indents + 1 // 縮進(jìn)層次+1后存入map
mu.Unlock()
printTrace(gid, name, "->", indents+1)
return func() {
mu.Lock()
indents := m[gid] // 獲取當(dāng)前gid對(duì)應(yīng)的縮進(jìn)層次
m[gid] = indents - 1 // 縮進(jìn)層次-1后存入map
mu.Unlock()
printTrace(gid, name, "<-", indents)
}
}
在上面這段代碼中,我們使用了一個(gè) map 類型變量 m 來保存每個(gè) Goroutine 當(dāng)前的縮進(jìn)信息:m 的 key 為 Goroutine 的 ID,值為縮進(jìn)的層次。然后,考慮到 Trace 函數(shù)可能在并發(fā)環(huán)境中運(yùn)行,根據(jù)Go 中的“map 不支持并發(fā)寫”的特性,我們?cè)黾恿艘粋€(gè) sync.Mutex 實(shí)例 mu 用于同步對(duì) m 的寫操作。
這樣,對(duì)于一個(gè) Goroutine 來說,每次剛進(jìn)入一個(gè)函數(shù)調(diào)用,我們就在輸出入口跟蹤信息之前,將縮進(jìn)層次加一,并輸出入口跟蹤信息,加一后的縮進(jìn)層次值也保存到 map 中。然后,在函數(shù)退出前,我們?nèi)〕霎?dāng)前縮進(jìn)層次值并輸出出口跟蹤信息,之后再將縮進(jìn)層次減一后保存到 map 中。
除了增加縮進(jìn)層次信息外,在這一版的 Trace 函數(shù)實(shí)現(xiàn)中,我們也把輸出出入口跟蹤信息的操作提取到了一個(gè)獨(dú)立的函數(shù) printTrace 中,這個(gè)函數(shù)會(huì)根據(jù)傳入的 Goroutine ID、函數(shù)名、箭頭類型與縮進(jìn)層次值,按預(yù)定的格式拼接跟蹤信息并輸出。
運(yùn)行新版示例代碼,我們會(huì)得到下面的結(jié)果:
g[00001]: ->main.A1
g[00001]: ->main.B1
g[00001]: ->main.C1
g[00001]: ->main.D
g[00001]: <-main.D
g[00001]: <-main.C1
g[00001]: <-main.B1
g[00001]: <-main.A1
g[00018]: ->main.A2
g[00018]: ->main.B2
g[00018]: ->main.C2
g[00018]: ->main.D
g[00018]: <-main.D
g[00018]: <-main.C2
g[00018]: <-main.B2
g[00018]: <-main.A2
顯然,通過這種帶有縮進(jìn)層次的函數(shù)調(diào)用跟蹤信息,我們可以更容易地識(shí)別某個(gè) Goroutine 的函數(shù)調(diào)用關(guān)系。
到這里,我們的函數(shù)調(diào)用鏈跟蹤已經(jīng)支持了多 Goroutine,并且可以輸出有層次感的跟蹤信息了,但對(duì)于 Trace 特性的使用者而言,他們依然需要手工在自己的函數(shù)中添加對(duì) Trace 函數(shù)的調(diào)用。那么我們是否可以將 Trace 特性自動(dòng)注入特定項(xiàng)目下的各個(gè)源碼文件中呢?接下來我們繼續(xù)來改進(jìn)我們的 Trace 工具。
五、利用代碼生成自動(dòng)注入 Trace 函數(shù)
要實(shí)現(xiàn)向目標(biāo)代碼中的函數(shù) / 方法自動(dòng)注入 Trace 函數(shù),我們首先要做的就是將上面 Trace 函數(shù)相關(guān)的代碼打包到一個(gè) module 中以方便其他 module 導(dǎo)入。下面我們就先來看看將 Trace 函數(shù)放入一個(gè)獨(dú)立的 module 中的步驟。
5.1 將 Trace 函數(shù)放入一個(gè)獨(dú)立的 module 中
我們創(chuàng)建一個(gè)名為 instrument_trace 的目錄,進(jìn)入這個(gè)目錄后,通過 go mod init 命令創(chuàng)建一個(gè)名為 github.com/bigwhite/instrument_trace 的 module:
$mkdir instrument_trace
$cd instrument_trace
$go mod init github.com/bigwhite/instrument_trace
go: creating new go.mod: module github.com/bigwhite/instrument_trace
接下來,我們將最新版的 trace.go 放入到該目錄下,將包名改為 trace,并僅保留 Trace 函數(shù)、Trace 使用的函數(shù)以及包級(jí)變量,其他函數(shù)一律刪除掉。這樣,一個(gè)獨(dú)立的 trace 包就提取完畢了。
作為 trace 包的作者,我們有義務(wù)告訴大家如何使用 trace 包。在 Go 中,通常我們會(huì)用一個(gè) example_test.go 文件來編寫使用 trace 包的演示代碼,下面就是我們?yōu)?nbsp;trace 包提供的 example_test.go 文件:
// instrument_trace/example_test.go
package trace_test
import (
trace "github.com/bigwhite/instrument_trace"
)
func a() {
defer trace.Trace()()
b()
}
func b() {
defer trace.Trace()()
c()
}
func c() {
defer trace.Trace()()
d()
}
func d() {
defer trace.Trace()()
}
func ExampleTrace() {
a()
// Output:
// g[00001]: ->github.com/bigwhite/instrument_trace_test.a
// g[00001]: ->github.com/bigwhite/instrument_trace_test.b
// g[00001]: ->github.com/bigwhite/instrument_trace_test.c
// g[00001]: ->github.com/bigwhite/instrument_trace_test.d
// g[00001]: <-github.com/bigwhite/instrument_trace_test.d
// g[00001]: <-github.com/bigwhite/instrument_trace_test.c
// g[00001]: <-github.com/bigwhite/instrument_trace_test.b
// g[00001]: <-github.com/bigwhite/instrument_trace_test.a
}
在 example_test.go 文件中,我們用 ExampleXXX 形式的函數(shù)表示一個(gè)示例,go test 命令會(huì)掃描 example_test.go 中的以 Example 為前綴的函數(shù)并執(zhí)行這些函數(shù)。
每個(gè) ExampleXXX 函數(shù)需要包含預(yù)期的輸出,就像上面 ExampleTrace 函數(shù)尾部那樣,我們?cè)谝淮蠖巫⑨屩刑峁┻@個(gè)函數(shù)執(zhí)行后的預(yù)期輸出,預(yù)期輸出的內(nèi)容從 // Output: 的下一行開始。go test 會(huì)將 ExampleTrace 的輸出與預(yù)期輸出對(duì)比,如果不一致,會(huì)報(bào)測(cè)試錯(cuò)誤。從這一點(diǎn),我們可以看出 example_test.go 也是 trace 包單元測(cè)試的一部分。
現(xiàn)在 Trace 函數(shù)已經(jīng)被放入到獨(dú)立的包中了,接下來我們就來看看如何將它自動(dòng)注入到要跟蹤的函數(shù)中去。
5.2 自動(dòng)注入 Trace 函數(shù)
現(xiàn)在,我們?cè)?nbsp;instrument_trace module 下面增加一個(gè)命令行工具,這個(gè)工具可以以一個(gè) Go 源文件為單位,自動(dòng)向這個(gè) Go 源文件中的所有函數(shù)注入 Trace 函數(shù)。
我們?cè)俑鶕?jù)之前介紹的帶有【可執(zhí)行文件的 Go 項(xiàng)目布局】,在 instrument_trace module 中增加 cmd/instrument 目錄,這個(gè)工具的 main 包就放在這個(gè)目錄下,而真正實(shí)現(xiàn)自動(dòng)注入 Trace 函數(shù)的代碼呢,被我們放在了 instrumenter 目錄下。
下面是變化后的 instrument_trace module 的目錄結(jié)構(gòu):
$tree ./instrument_trace -F
./instrument_trace
├── Makefile
├── cmd/
│ └── instrument/
│ └── main.go # instrument命令行工具的main包
├── example_test.go
├── go.mod
├── go.sum
├── instrumenter/ # 自動(dòng)注入邏輯的相關(guān)結(jié)構(gòu)
│ ├── ast/
│ │ └── ast.go
│ └── instrumenter.go
└── trace.go
我們先來看一下 cmd/instrument/main.go 源碼,然后自上而下沿著 main 函數(shù)的調(diào)用邏輯逐一看一下這個(gè)功能的實(shí)現(xiàn)。下面是 main.go 的源碼:
// instrument_trace/cmd/instrument/main.go
... ...
var (
wrote bool
)
func init() {
flag.BoolVar(&wrote, "w", false, "write result to (source) file instead of stdout")
}
func usage() {
fmt.Println("instrument [-w] xxx.go")
flag.PrintDefaults()
}
func main() {
fmt.Println(os.Args)
flag.Usage = usage
flag.Parse() // 解析命令行參數(shù)
if len(os.Args) < 2 { // 對(duì)命令行參數(shù)個(gè)數(shù)進(jìn)行校驗(yàn)
usage()
return
}
var file string
if len(os.Args) == 3 {
file = os.Args[2]
}
if len(os.Args) == 2 {
file = os.Args[1]
}
if filepath.Ext(file) != ".go" { // 對(duì)源文件擴(kuò)展名進(jìn)行校驗(yàn)
usage()
return
}
var ins instrumenter.Instrumenter // 聲明instrumenter.Instrumenter接口類型變量
// 創(chuàng)建以ast方式實(shí)現(xiàn)Instrumenter接口的ast.instrumenter實(shí)例
ins = ast.New("github.com/bigwhite/instrument_trace", "trace", "Trace")
newSrc, err := ins.Instrument(file) // 向Go源文件所有函數(shù)注入Trace函數(shù)
if err != nil {
panic(err)
}
if newSrc == nil {
// add nothing to the source file. no change
fmt.Printf("no trace added for %s\n", file)
return
}
if !wrote {
fmt.Println(string(newSrc)) // 將生成的新代碼內(nèi)容輸出到stdout上
return
}
// 將生成的新代碼內(nèi)容寫回原Go源文件
if err = ioutil.WriteFile(file, newSrc, 0666); err != nil {
fmt.Printf("write %s error: %v\n", file, err)
return
}
fmt.Printf("instrument trace for %s ok\n", file)
}
作為命令行工具,instrument 使用標(biāo)準(zhǔn)庫的 flag 包實(shí)現(xiàn)對(duì)命令行參數(shù)(這里是 -w)的解析,通過 os.Args 獲取待注入的 Go 源文件路徑。在完成對(duì)命令行參數(shù)個(gè)數(shù)與值的校驗(yàn)后,instrument 程序聲明了一個(gè) instrumenter.Instrumenter 接口類型變量 ins,然后創(chuàng)建了一個(gè)實(shí)現(xiàn)了 Instrumenter 接口類型的 ast.instrumenter 類型的實(shí)例,并賦值給變量 ins。
instrumenter.Instrumenter 接口類型的聲明放在了 instrumenter/instrumenter.go 中:
type Instrumenter interface {
Instrument(string) ([]byte, error)
}
這里我們看到,這個(gè)接口類型的方法列表中只有一個(gè)方法 Instrument,這個(gè)方法接受一個(gè) Go 源文件路徑,返回注入了 Trace 函數(shù)的新源文件內(nèi)容以及一個(gè) error 類型值,作為錯(cuò)誤狀態(tài)標(biāo)識(shí)。我們之所以要抽象出一個(gè)接口類型,考慮的就是注入 Trace 函數(shù)的實(shí)現(xiàn)方法不一,為后續(xù)的擴(kuò)展做好預(yù)留。
在這個(gè)例子中,我們默認(rèn)提供了一種自動(dòng)注入 Trace 函數(shù)的實(shí)現(xiàn),那就是 ast.instrumenter,它注入 Trace 的實(shí)現(xiàn)原理是這樣的:

從原理圖中我們可以清楚地看到,在這一實(shí)現(xiàn)方案中,我們先將傳入的 Go 源碼轉(zhuǎn)換為抽象語法樹。
在計(jì)算機(jī)科學(xué)中,抽象語法樹(abstract syntax tree,AST)是源代碼的抽象語法結(jié)構(gòu)的樹狀表現(xiàn)形式,樹上的每個(gè)節(jié)點(diǎn)都表示源代碼中的一種結(jié)構(gòu)。因?yàn)?Go 語言是開源編程語言,所以它的抽象語法樹的操作包也和語言一起開放給了 Go 開發(fā)人員,我們可以基于 Go 標(biāo)準(zhǔn)庫以及 Go 實(shí)驗(yàn)工具庫 提供的 ast 相關(guān)包,快速地構(gòu)建基于 AST 的應(yīng)用,這里的 ast.instrumenter 就是一個(gè)應(yīng)用 AST 的典型例子。
一旦我們通過 ast 相關(guān)包解析 Go 源碼得到相應(yīng)的抽象語法樹后,我們便可以操作這棵語法樹,并按我們的邏輯在語法樹中注入我們的 Trace 函數(shù),最后我們?cè)賹⑿薷暮蟮某橄笳Z法樹轉(zhuǎn)換為 Go 源碼,就完成了整個(gè)自動(dòng)注入的工作了。
了解了原理后,我們?cè)倏匆幌戮唧w的代碼實(shí)現(xiàn)。下面是 ast.instrumenter 的 Instructment 方法的代碼:
// instrument_trace/instrumenter/ast/ast.go
func (a instrumenter) Instrument(filename string) ([]byte, error) {
fset := token.NewFileSet()
curAST, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) // 解析Go源碼,得到AST
if err != nil {
return nil, fmt.Errorf("error parsing %s: %w", filename, err)
}
if !hasFuncDecl(curAST) { // 如果整個(gè)源碼都不包含函數(shù)聲明,則無需注入操作,直接返回。
return nil, nil
}
// 在AST上添加包導(dǎo)入語句
astutil.AddImport(fset, curAST, a.traceImport)
// 向AST上的所有函數(shù)注入Trace函數(shù)
a.addDeferTraceIntoFuncDecls(curAST)
buf := &bytes.Buffer{}
err = format.Node(buf, fset, curAST) // 將修改后的AST轉(zhuǎn)換回Go源碼
if err != nil {
return nil, fmt.Errorf("error formatting new code: %w", err)
}
return buf.Bytes(), nil // 返回轉(zhuǎn)換后的Go源碼
}
通過代碼,我們看到 Instrument 方法的基本步驟與上面原理圖大同小異。Instrument 首先通過 go/parser 的 ParserFile 函數(shù)對(duì)傳入的 Go 源文件中的源碼進(jìn)行解析,并得到對(duì)應(yīng)的抽象語法樹 AST,然后向 AST 中導(dǎo)入 Trace 函數(shù)所在的包,并向這個(gè) AST 的所有函數(shù)聲明注入 Trace 函數(shù)調(diào)用。
實(shí)際的注入操作發(fā)生在 instrumenter 的 addDeferTraceIntoFuncDecls 方法中,我們來看一下這個(gè)方法的實(shí)現(xiàn):
// instrument_trace/instrumenter/ast/ast.go
func (a instrumenter) addDeferTraceIntoFuncDecls(f *ast.File) {
for _, decl := range f.Decls { // 遍歷所有聲明語句
fd, ok := decl.(*ast.FuncDecl) // 類型斷言:是否為函數(shù)聲明
if ok {
// 如果是函數(shù)聲明,則注入跟蹤設(shè)施
a.addDeferStmt(fd)
}
}
}
這個(gè)方法的邏輯十分清晰,就是遍歷語法樹上所有聲明語句,如果是函數(shù)聲明,就調(diào)用 instrumenter 的 addDeferStmt 方法進(jìn)行注入,如果不是,就直接返回。addDeferStmt 方法的實(shí)現(xiàn)如下:
// instrument_trace/instrumenter/ast/ast.go
func (a instrumenter) addDeferStmt(fd *ast.FuncDecl) (added bool) {
stmts := fd.Body.List
// 判斷"defer trace.Trace()()"語句是否已經(jīng)存在
for _, stmt := range stmts {
ds, ok := stmt.(*ast.DeferStmt)
if !ok {
// 如果不是defer語句,則繼續(xù)for循環(huán)
continue
}
// 如果是defer語句,則要進(jìn)一步判斷是否是defer trace.Trace()()
ce, ok := ds.Call.Fun.(*ast.CallExpr)
if !ok {
continue
}
se, ok := ce.Fun.(*ast.SelectorExpr)
if !ok {
continue
}
x, ok := se.X.(*ast.Ident)
if !ok {
continue
}
if (x.Name == a.tracePkg) && (se.Sel.Name == a.traceFunc) {
// defer trace.Trace()()已存在,返回
return false
}
}
// 沒有找到"defer trace.Trace()()",注入一個(gè)新的跟蹤語句
// 在AST上構(gòu)造一個(gè)defer trace.Trace()()
ds := &ast.DeferStmt{
Call: &ast.CallExpr{
Fun: &ast.CallExpr{
Fun: &ast.SelectorExpr{
X: &ast.Ident{
Name: a.tracePkg,
},
Sel: &ast.Ident{
Name: a.traceFunc,
},
},
},
},
}
newList := make([]ast.Stmt, len(stmts)+1)
copy(newList[1:], stmts)
newList[0] = ds // 注入新構(gòu)造的defer語句
fd.Body.List = newList
return true
}
雖然 addDeferStmt 函數(shù)體略長(zhǎng),但邏輯也很清晰,就是先判斷函數(shù)是否已經(jīng)注入了 Trace,如果有,則略過;如果沒有,就構(gòu)造一個(gè) Trace 語句節(jié)點(diǎn),并將它插入到 AST 中。
Instrument 的最后一步就是將注入 Trace 后的 AST 重新轉(zhuǎn)換為 Go 代碼,這就是我們期望得到的帶有 Trace 特性的 Go 代碼了。
5.3 利用 instrument 工具注入跟蹤代碼
有了 instrument 工具后,我們?cè)賮砜纯慈绾问褂眠@個(gè)工具,在目標(biāo) Go 源文件中自動(dòng)注入跟蹤設(shè)施。
這里,我在 instrument_trace 項(xiàng)目的 examples 目錄下建立了一個(gè)名為 demo 的項(xiàng)目,我們就來看看如何使用 instrument 工具為 demo 項(xiàng)目下的 demo.go 文件自動(dòng)注入跟蹤設(shè)施。demo.go 文件內(nèi)容很簡(jiǎn)單:
// instrument_trace/examples/demo/demo.go
package main
func foo() {
bar()
}
func bar() {
}
func main() {
foo()
}
我們首先構(gòu)建一下 instrument_trace 下的 instrument 工具:
$cd instrument_trace
$go build github.com/bigwhite/instrument_trace/cmd/instrument
$instrument version
[instrument version]
instrument [-w] xxx.go
-w write result to (source) file instead of stdout
接下來,我們使用 instrument 工具向 examples/demo/demo.go 源文件中的函數(shù)自動(dòng)注入跟蹤設(shè)施:
$instrument -w examples/demo/demo.go
[instrument -w examples/demo/demo.go]
instrument trace for examples/demo/demo.go ok
注入后的 demo.go 文件變?yōu)榱讼旅孢@個(gè)樣子:
// instrument_trace/examples/demo/demo.go
package main
import "github.com/bigwhite/instrument_trace"
func foo() {
defer trace.Trace()()
bar()
}
func bar() {
defer trace.Trace()()
}
func main() {
defer trace.Trace()()
foo()
}
此時(shí),如果我們?cè)賹?duì)已注入 Trace 函數(shù)的 demo.go 執(zhí)行一次 instrument 命令,由于 instrument 會(huì)判斷 demo.go 各個(gè)函數(shù)已經(jīng)注入了 Trace,demo.go 的內(nèi)容將保持不變。
由于 github.com/bigwhite/instrument_trace 并沒有真正上傳到 github.com 上,所以如果你要運(yùn)行 demo.go,我們可以為它配置一個(gè)下面這樣的 go.mod:
// instrument_trace/examples/demo/go.mod module demo go 1.17 require github.com/bigwhite/instrument_trace v1.0.0 replace github.com/bigwhite/instrument_trace v1.0.0 => ../../
這樣運(yùn)行 demo.go 就不會(huì)遇到障礙了:
$go run demo.go
g[00001]: ->main.main
g[00001]: ->main.foo
g[00001]: ->main.bar
g[00001]: <-main.bar
g[00001]: <-main.foo
g[00001]: <-main.main
到此這篇關(guān)于一文帶你了解Go中跟蹤函數(shù)調(diào)用鏈的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)Go跟蹤函數(shù)調(diào)用鏈內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一文教你打造一個(gè)簡(jiǎn)易的Golang日志庫
這篇文章主要為大家詳細(xì)介紹了如何使用不超過130行的代碼,通過一系列g(shù)olang的特性,來打造一個(gè)簡(jiǎn)易的golang日志庫,感興趣的小伙伴可以了解一下2023-06-06
windows安裝部署go超詳細(xì)實(shí)戰(zhàn)記錄(實(shí)測(cè)有用!)
Golang語言在近年來因?yàn)槠涓咝阅堋⒕幾g速度快、開發(fā)成本低等特點(diǎn)逐漸得到大家的青睞,這篇文章主要給大家介紹了關(guān)于windows安裝部署go超詳細(xì)實(shí)戰(zhàn)的相關(guān)資料,需要的朋友可以參考下2023-02-02
Golang Cron 定時(shí)任務(wù)的實(shí)現(xiàn)示例
這篇文章主要介紹了Golang Cron 定時(shí)任務(wù)的實(shí)現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-05-05
Golang實(shí)現(xiàn)Trie(前綴樹)的示例
本文主要介紹了Golang實(shí)現(xiàn)Trie(前綴樹)的示例,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-01-01

