一文帶你了解Go中跟蹤函數(shù)調(diào)用鏈的實現(xiàn)
一、引入
defer
是 Gopher 們都喜歡的語言機制,除了捕捉 panic
、延遲釋放資源外,我們?nèi)粘>幋a中還有哪些使用 defer
的小技巧呢?
在平時,defer
的一個常見的使用技巧就是使用 defer
可以跟蹤函數(shù)的執(zhí)行過程。這的確是很多 Go 教程在講解 defer
時也會經(jīng)常使用這個用途舉例。那么,我們具體是怎么用 defer
來實現(xiàn)函數(shù)執(zhí)行過程的跟蹤呢?這里,給出了一個最簡單的實現(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í)行結果,直觀感受一下什么是函數(shù)調(diào)用跟蹤:
enter: main
enter: foo
enter: bar
exit: bar
exit: foo
exit: main
我們看到,這個 Go
程序的函數(shù)調(diào)用的全過程一目了然地展現(xiàn)在了我們面前:程序按 main -> foo -> bar
的函數(shù)調(diào)用次序執(zhí)行,代碼在函數(shù)的入口與出口處分別輸出了跟蹤日志。
那這段代碼是怎么做到的呢?我們簡要分析一下。
在這段代碼中,我們在每個函數(shù)的入口處都使用 defer
設置了一個 deferred 函數(shù)。根據(jù) defer
的運作機制,Go 會在 defer
設置 deferred 函數(shù)時對 defer
后面的表達式進行求值。
我們以 foo 函數(shù)中的 defer Trace("foo")()
這行代碼為例,Go 會對 defer
后面的表達式 Trace("foo")()
進行求值。由于這個表達式包含一個函數(shù)調(diào)用 Trace("foo")
,所以這個函數(shù)會被執(zhí)行。
上面的 Trace
函數(shù)只接受一個參數(shù),這個參數(shù)代表函數(shù)名。Trace
會首先打印進入某函數(shù)的日志,比如:“enter: foo”。然后返回一個閉包函數(shù),這個閉包函數(shù)一旦被執(zhí)行,就會輸出離開某函數(shù)的日志。在 foo
函數(shù)中,這個由 Trace
函數(shù)返回的閉包函數(shù)就被設置為了 deferred 函數(shù),于是當 foo
函數(shù)返回后,這個閉包函數(shù)就會被執(zhí)行,輸出 “exit: foo”的日志。
搞清楚上面跟蹤函數(shù)調(diào)用鏈的實現(xiàn)原理后,我們再來看看這個實現(xiàn)。我們會發(fā)現(xiàn)這里還是有一些“瑕疵”,也就是離我們期望的“跟蹤函數(shù)調(diào)用鏈”的實現(xiàn)還有一些不足之處。這里我列舉了幾點:
- 調(diào)用
Trace
時需手動顯式傳入要跟蹤的函數(shù)名; - 如果是并發(fā)應用,不同 Goroutine 中函數(shù)鏈跟蹤混在一起無法分辨;
- 輸出的跟蹤結果缺少層次感,調(diào)用關系不易識別;
- 對要跟蹤的函數(shù),需手動調(diào)用
Trace
函數(shù)。
所以,本文就是逐一分析并解決上面提出的這幾點問題進行,經(jīng)過逐步地代碼演進,最終實現(xiàn)一個自動注入跟蹤代碼,并輸出有層次感的函數(shù)調(diào)用鏈跟蹤命令行工具。
我們先來解決第一個問題。
二、自動獲取所跟蹤函數(shù)的函數(shù)名
要解決“調(diào)用 Trace 時需要手動顯式傳入要跟蹤的函數(shù)名”的問題,也就是要讓我們的 Trace
函數(shù)能夠自動獲取到它跟蹤函數(shù)的函數(shù)名信息。我們以跟蹤 foo
為例,看看這樣做能給我們帶來什么好處。
在手動顯式傳入的情況下,我們需要用下面這個代碼對 foo 進行跟蹤:
defer Trace("foo")()
一旦實現(xiàn)了自動獲取函數(shù)名,所有支持函數(shù)調(diào)用鏈跟蹤的函數(shù)都只需使用下面調(diào)用形式的 Trace
函數(shù)就可以了:
defer Trace()()
這種一致的 Trace
函數(shù)調(diào)用方式也為后續(xù)的自動向代碼中注入 Trace
函數(shù)奠定了基礎。那么如何實現(xiàn) Trace
函數(shù)對它跟蹤函數(shù)名的自動獲取呢?我們需要借助 Go 標準庫 runtime
包的幫助。
這里,我給出了新版 Trace 函數(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ù)獲得當前 Goroutine 的函數(shù)調(diào)用棧上的信息,runtime.Caller
的參數(shù)標識的是要獲取的是哪一個棧幀的信息。當參數(shù)為 0 時,返回的是 Caller
函數(shù)的調(diào)用者的函數(shù)信息,在這里就是 Trace
函數(shù)。但我們需要的是 Trace
函數(shù)的調(diào)用者的信息,于是我們傳入 1。
Caller
函數(shù)有四個返回值:
- 第一個返回值代表的是程序計數(shù)(pc)。
- 第二個和第三個參數(shù)代表對應函數(shù)所在的源文件名以及所在行數(shù),這里我們暫時不需要。
- 最后一個參數(shù)代表是否能成功獲取這些信息,如果獲取失敗,我們拋出
panic
。
接下來,我們通過 runtime.FuncForPC
函數(shù)和程序計數(shù)器(PC)得到被跟蹤函數(shù)的函數(shù)名稱。我們運行一下改造后的代碼:
enter: main.main
enter: main.foo
enter: main.bar
exit: main.bar
exit: main.foo
exit: main.main
接下來,我們來解決第二個問題,也就是當程序中有多 Goroutine 時,Trace 輸出的跟蹤信息混雜在一起難以分辨的問題。
三、增加 Goroutine 標識
上面的 Trace
函數(shù)在面對只有一個 Goroutine
的時候,還是可以支撐的,但當程序中并發(fā)運行多個 Goroutine
的時候,多個函數(shù)調(diào)用鏈的出入口信息輸出就會混雜在一起,無法分辨。
那么,接下來我們還繼續(xù)對 Trace
函數(shù)進行改造,讓它支持多 Goroutine
函數(shù)調(diào)用鏈的跟蹤。我們的方案就是在輸出的函數(shù)出入口信息時,帶上一個在程序每次執(zhí)行時能唯一區(qū)分 Goroutine
的 Goroutine ID
。
到這里,你可能會說,Goroutine
也沒有 ID
信息?。〉拇_如此,Go 核心團隊為了避免 Goroutine ID 的濫用,故意沒有將 Goroutine ID
暴露給開發(fā)者。但在 Go 標準庫的 h2_bundle.go
中,我們卻發(fā)現(xiàn)了一個獲取 Goroutine ID
的標準方法,看下面代碼:
// $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
不是一個導出函數(shù),我們無法直接使用。我們可以把它復制出來改造一下:
// 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 }
這里,我們改造了兩個地方。一個地方是通過直接創(chuàng)建一個 byte
切片賦值給 b
,替代原 http2curGoroutineID
函數(shù)中從一個 pool 池獲取 byte
切片的方式,另外一個是使用 strconv.ParseUint
替代了原先的 http2parseUintBytes
。改造后,我們就可以直接使用 curGoroutineID
函數(shù)來獲取 Goroutine
的 ID
信息了。
好,接下來,我們在 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) } }
從上面代碼看到,我們在出入口輸出的跟蹤信息中加入了 Goroutine ID
信息,我們輸出的 Goroutine ID
為 5 位數(shù)字,如果 ID
值不足 5 位,則左補零,這一切都是 Printf
函數(shù)的格式控制字符串“%05d”幫助我們實現(xiàn)的。這樣對齊 Goroutine ID
的位數(shù),為的是輸出信息格式的一致性更好。如果你的 Go 程序中 Goroutine
的數(shù)量超過了 5 位數(shù)可以表示的數(shù)值范圍,也可以自行調(diào)整控制字符串。
接下來,我們也要對示例進行一些調(diào)整,將這個程序由單 Goroutine
改為多 Goroutine
并發(fā)的,這樣才能驗證支持多 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() }
新示例程序共有兩個 Goroutine
,main
goroutine
的調(diào)用鏈為 A1 -> B1 -> C1 -> D
,而另外一個 Goroutine
的函數(shù)調(diào)用鏈為 A2 -> B2 -> C2 -> D
。我們來看一下這個程序的執(zhí)行結果是否和原代碼中兩個 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
我們可以快速確認某一行輸出是屬于哪個 Goroutine
的。
但由于 Go 運行時對 Goroutine
調(diào)度順序的不確定性,各個 Goroutine
的輸出還是會存在交織在一起的問題,這會給你查看某個 Goroutine
的函數(shù)調(diào)用鏈跟蹤信息帶來阻礙。這里我提供一個小技巧:你可以將程序的輸出重定向到一個本地文件中,然后通過 Goroutine ID
過濾出(可使用 grep
工具)你想查看的 goroutine
的全部函數(shù)跟蹤信息。
到這里,我們就實現(xiàn)了輸出帶有 Goroutine ID
的函數(shù)跟蹤信息,不過,你是不是也覺得輸出的函數(shù)調(diào)用鏈信息還是不夠美觀,缺少層次感,體驗依舊不那么優(yōu)秀呢?至少我是這么覺得的。所以下面我們就來美化一下信息的輸出形式。
四、讓輸出的跟蹤信息更具層次感
對于程序員來說,縮進是最能體現(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
那么我們就以這個形式為目標,考慮如何實現(xià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] // 獲取當前gid對應的縮進層次 m[gid] = indents + 1 // 縮進層次+1后存入map mu.Unlock() printTrace(gid, name, "->", indents+1) return func() { mu.Lock() indents := m[gid] // 獲取當前gid對應的縮進層次 m[gid] = indents - 1 // 縮進層次-1后存入map mu.Unlock() printTrace(gid, name, "<-", indents) } }
在上面這段代碼中,我們使用了一個 map
類型變量 m
來保存每個 Goroutine
當前的縮進信息:m
的 key 為 Goroutine
的 ID
,值為縮進的層次。然后,考慮到 Trace
函數(shù)可能在并發(fā)環(huán)境中運行,根據(jù)Go 中的“map 不支持并發(fā)寫”的特性,我們增加了一個 sync.Mutex
實例 mu
用于同步對 m
的寫操作。
這樣,對于一個 Goroutine
來說,每次剛進入一個函數(shù)調(diào)用,我們就在輸出入口跟蹤信息之前,將縮進層次加一,并輸出入口跟蹤信息,加一后的縮進層次值也保存到 map
中。然后,在函數(shù)退出前,我們?nèi)〕霎斍翱s進層次值并輸出出口跟蹤信息,之后再將縮進層次減一后保存到 map
中。
除了增加縮進層次信息外,在這一版的 Trace
函數(shù)實現(xiàn)中,我們也把輸出出入口跟蹤信息的操作提取到了一個獨立的函數(shù) printTrace
中,這個函數(shù)會根據(jù)傳入的 Goroutine ID
、函數(shù)名、箭頭類型與縮進層次值,按預定的格式拼接跟蹤信息并輸出。
運行新版示例代碼,我們會得到下面的結果:
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
顯然,通過這種帶有縮進層次的函數(shù)調(diào)用跟蹤信息,我們可以更容易地識別某個 Goroutine
的函數(shù)調(diào)用關系。
到這里,我們的函數(shù)調(diào)用鏈跟蹤已經(jīng)支持了多 Goroutine
,并且可以輸出有層次感的跟蹤信息了,但對于 Trace
特性的使用者而言,他們依然需要手工在自己的函數(shù)中添加對 Trace
函數(shù)的調(diào)用。那么我們是否可以將 Trace
特性自動注入特定項目下的各個源碼文件中呢?接下來我們繼續(xù)來改進我們的 Trace
工具。
五、利用代碼生成自動注入 Trace 函數(shù)
要實現(xiàn)向目標代碼中的函數(shù) / 方法自動注入 Trace 函數(shù),我們首先要做的就是將上面 Trace 函數(shù)相關的代碼打包到一個 module 中以方便其他 module 導入。下面我們就先來看看將 Trace 函數(shù)放入一個獨立的 module 中的步驟。
5.1 將 Trace 函數(shù)放入一個獨立的 module 中
我們創(chuàng)建一個名為 instrument_trace
的目錄,進入這個目錄后,通過 go mod init
命令創(chuàng)建一個名為 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ù)以及包級變量,其他函數(shù)一律刪除掉。這樣,一個獨立的 trace
包就提取完畢了。
作為 trace
包的作者,我們有義務告訴大家如何使用 trace
包。在 Go 中,通常我們會用一個 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ù)表示一個示例,go test
命令會掃描 example_test.go
中的以 Example
為前綴的函數(shù)并執(zhí)行這些函數(shù)。
每個 ExampleXXX
函數(shù)需要包含預期的輸出,就像上面 ExampleTrace
函數(shù)尾部那樣,我們在一大段注釋中提供這個函數(shù)執(zhí)行后的預期輸出,預期輸出的內(nèi)容從 // Output:
的下一行開始。go test
會將 ExampleTrace
的輸出與預期輸出對比,如果不一致,會報測試錯誤。從這一點,我們可以看出 example_test.go
也是 trace
包單元測試的一部分。
現(xiàn)在 Trace
函數(shù)已經(jīng)被放入到獨立的包中了,接下來我們就來看看如何將它自動注入到要跟蹤的函數(shù)中去。
5.2 自動注入 Trace 函數(shù)
現(xiàn)在,我們在 instrument_trace
module 下面增加一個命令行工具,這個工具可以以一個 Go 源文件為單位,自動向這個 Go 源文件中的所有函數(shù)注入 Trace
函數(shù)。
我們再根據(jù)之前介紹的帶有【可執(zhí)行文件的 Go 項目布局】,在 instrument_trace
module 中增加 cmd/instrument
目錄,這個工具的 main
包就放在這個目錄下,而真正實現(xiàn)自動注入 Trace
函數(shù)的代碼呢,被我們放在了 instrumenter
目錄下。
下面是變化后的 instrument_trace module
的目錄結構:
$tree ./instrument_trace -F
./instrument_trace
├── Makefile
├── cmd/
│ └── instrument/
│ └── main.go # instrument命令行工具的main包
├── example_test.go
├── go.mod
├── go.sum
├── instrumenter/ # 自動注入邏輯的相關結構
│ ├── ast/
│ │ └── ast.go
│ └── instrumenter.go
└── trace.go
我們先來看一下 cmd/instrument/main.go
源碼,然后自上而下沿著 main
函數(shù)的調(diào)用邏輯逐一看一下這個功能的實現(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 { // 對命令行參數(shù)個數(shù)進行校驗 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" { // 對源文件擴展名進行校驗 usage() return } var ins instrumenter.Instrumenter // 聲明instrumenter.Instrumenter接口類型變量 // 創(chuàng)建以ast方式實現(xiàn)Instrumenter接口的ast.instrumenter實例 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
使用標準庫的 flag
包實現(xiàn)對命令行參數(shù)(這里是 -w
)的解析,通過 os.Args
獲取待注入的 Go 源文件路徑。在完成對命令行參數(shù)個數(shù)與值的校驗后,instrument
程序聲明了一個 instrumenter.Instrumenter
接口類型變量 ins
,然后創(chuàng)建了一個實現(xiàn)了 Instrumenter
接口類型的 ast.instrumenter
類型的實例,并賦值給變量 ins
。
instrumenter.Instrumenter
接口類型的聲明放在了 instrumenter/instrumenter.go
中:
type Instrumenter interface { Instrument(string) ([]byte, error) }
這里我們看到,這個接口類型的方法列表中只有一個方法 Instrument
,這個方法接受一個 Go 源文件路徑,返回注入了 Trace
函數(shù)的新源文件內(nèi)容以及一個 error
類型值,作為錯誤狀態(tài)標識。我們之所以要抽象出一個接口類型,考慮的就是注入 Trace
函數(shù)的實現(xiàn)方法不一,為后續(xù)的擴展做好預留。
在這個例子中,我們默認提供了一種自動注入 Trace
函數(shù)的實現(xiàn),那就是 ast.instrumenter
,它注入 Trace
的實現(xiàn)原理是這樣的:
從原理圖中我們可以清楚地看到,在這一實現(xiàn)方案中,我們先將傳入的 Go 源碼轉(zhuǎn)換為抽象語法樹。
在計算機科學中,抽象語法樹(abstract syntax tree,AST)是源代碼的抽象語法結構的樹狀表現(xiàn)形式,樹上的每個節(jié)點都表示源代碼中的一種結構。因為 Go 語言是開源編程語言,所以它的抽象語法樹的操作包也和語言一起開放給了 Go 開發(fā)人員,我們可以基于 Go 標準庫以及 Go 實驗工具庫 提供的 ast
相關包,快速地構建基于 AST 的應用,這里的 ast.instrumenter
就是一個應用 AST 的典型例子。
一旦我們通過 ast
相關包解析 Go 源碼得到相應的抽象語法樹后,我們便可以操作這棵語法樹,并按我們的邏輯在語法樹中注入我們的 Trace
函數(shù),最后我們再將修改后的抽象語法樹轉(zhuǎn)換為 Go 源碼,就完成了整個自動注入的工作了。
了解了原理后,我們再看一下具體的代碼實現(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) { // 如果整個源碼都不包含函數(shù)聲明,則無需注入操作,直接返回。 return nil, nil } // 在AST上添加包導入語句 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ù)對傳入的 Go 源文件中的源碼進行解析,并得到對應的抽象語法樹 AST,然后向 AST 中導入 Trace
函數(shù)所在的包,并向這個 AST 的所有函數(shù)聲明注入 Trace
函數(shù)調(diào)用。
實際的注入操作發(fā)生在 instrumenter
的 addDeferTraceIntoFuncDecls
方法中,我們來看一下這個方法的實現(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ù)聲明,則注入跟蹤設施 a.addDeferStmt(fd) } } }
這個方法的邏輯十分清晰,就是遍歷語法樹上所有聲明語句,如果是函數(shù)聲明,就調(diào)用 instrumenter
的 addDeferStmt
方法進行注入,如果不是,就直接返回。addDeferStmt
方法的實現(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語句,則要進一步判斷是否是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()()",注入一個新的跟蹤語句 // 在AST上構造一個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 // 注入新構造的defer語句 fd.Body.List = newList return true }
雖然 addDeferStmt
函數(shù)體略長,但邏輯也很清晰,就是先判斷函數(shù)是否已經(jīng)注入了 Trace
,如果有,則略過;如果沒有,就構造一個 Trace
語句節(jié)點,并將它插入到 AST 中。
Instrument
的最后一步就是將注入 Trace
后的 AST 重新轉(zhuǎn)換為 Go 代碼,這就是我們期望得到的帶有 Trace
特性的 Go 代碼了。
5.3 利用 instrument 工具注入跟蹤代碼
有了 instrument
工具后,我們再來看看如何使用這個工具,在目標 Go 源文件中自動注入跟蹤設施。
這里,我在 instrument_trace
項目的 examples
目錄下建立了一個名為 demo
的項目,我們就來看看如何使用 instrument
工具為 demo
項目下的 demo.go
文件自動注入跟蹤設施。demo.go
文件內(nèi)容很簡單:
// instrument_trace/examples/demo/demo.go package main func foo() { bar() } func bar() { } func main() { foo() }
我們首先構建一下 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ù)自動注入跟蹤設施:
$instrument -w examples/demo/demo.go
[instrument -w examples/demo/demo.go]
instrument trace for examples/demo/demo.go ok
注入后的 demo.go
文件變?yōu)榱讼旅孢@個樣子:
// 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() }
此時,如果我們再對已注入 Trace
函數(shù)的 demo.go
執(zhí)行一次 instrument
命令,由于 instrument
會判斷 demo.go
各個函數(shù)已經(jīng)注入了 Trace
,demo.go
的內(nèi)容將保持不變。
由于 github.com/bigwhite/instrument_trace
并沒有真正上傳到 github.com
上,所以如果你要運行 demo.go
,我們可以為它配置一個下面這樣的 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 => ../../
這樣運行 demo.go 就不會遇到障礙了:
$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
到此這篇關于一文帶你了解Go中跟蹤函數(shù)調(diào)用鏈的實現(xiàn)的文章就介紹到這了,更多相關Go跟蹤函數(shù)調(diào)用鏈內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
windows安裝部署go超詳細實戰(zhàn)記錄(實測有用!)
Golang語言在近年來因為其高性能、編譯速度快、開發(fā)成本低等特點逐漸得到大家的青睞,這篇文章主要給大家介紹了關于windows安裝部署go超詳細實戰(zhàn)的相關資料,需要的朋友可以參考下2023-02-02