深入Go代碼覆蓋率使用、場景與原理
一般我們會使用代碼覆蓋率來判斷代碼書寫的質(zhì)量,識別無效代碼。
識別靜態(tài)靜態(tài)的代碼
對于靜態(tài)的代碼,要識別代碼沒有被使用,可以使用golangci-lint工具
golangci-lint run --disable-all --enable unused對于通過單元測試 測試函數(shù)代碼的覆蓋率,在go生態(tài)中,go1.2提供了cover工具。
cover 基本用法
首先來看看go test -cover 統(tǒng)計代碼覆蓋率做的事情,借鑒一下它的思路。go test -cover 能夠統(tǒng)計出代碼的覆蓋率,這是一種比統(tǒng)計函數(shù)是否被調(diào)用更強悍的手法。
% go test -cover
PASS
coverage: 42.9% of statements
ok size 0.026s
%另外,還可以收集覆蓋率進行,并進行可視化的展示。
go test -coverprofile=coverage.out如下,使用 go tool cover 可視化分析代碼覆蓋率信息。
$ go tool cover -html=coverage.out能夠識別和統(tǒng)計出未調(diào)用過的分支。

測試環(huán)境下代碼覆蓋率
go test -cover 本身是運行xxx_test.go文件的測試函數(shù)使用的,但是我們可以使用一些奇淫技巧將這種方式用于測試環(huán)境下的代碼覆蓋率測試。假設(shè)我們的代碼是一個web服務(wù)器,我們可以新建一個main_test.go文件,執(zhí)行main()函數(shù),就好像在執(zhí)行程序一樣。并在退出程序后,正常完成coverprofile文件的生成。這種好處是可以在測試環(huán)境調(diào)用http請求,并最終統(tǒng)計代碼覆蓋率。如下為一段main_test.go代碼示例
func TestSystem(t *testing.T) {
handleSignals()
endRunning = make(chan bool, 1)
go func() {
main()
}()
<-endRunning
}線上代碼覆蓋率的思路
假設(shè)我們的需求是想看線上的代碼哪一些函數(shù)沒有被調(diào)用?
運行中的程序要檢測某一個函數(shù)是否被調(diào)用似乎沒有什么好的辦法,原因是沒有好的手段能夠檢測并記錄當前函數(shù)已經(jīng)被調(diào)用了。
可以將線上的代碼覆蓋率分為傾入性和非傾入性兩種方式。
傾入性方案一種最直接的方式是為代碼打樁,在每一個函數(shù)開頭埋點記錄下調(diào)用信息。我們可以參考下go test -cover
的實現(xiàn)方案。
cover代碼覆蓋率的原理
go test -cover 會對代碼進行打樁。
package size
func Size(a int) string {
switch {
case a < 0:
return "negative"
case a == 0:
return "zero"
case a < 10:
return "small"
case a < 100:
return "big"
case a < 1000:
return "huge"
}
return "enormous"
}如上代碼可以通過下面的命令生成打樁后的結(jié)果
go tool cover -mode=set -var=size xxx.go打樁后的結(jié)果如下:
//line xxx.go:1
package tt
func Size(a int) string {size.Count[0] = 1;
switch {
case a < 0:size.Count[2] = 1;
return "negative"
case a == 0:size.Count[3] = 1;
return "zero"
case a < 10:size.Count[4] = 1;
return "small"
case a < 100:size.Count[5] = 1;
return "big"
case a < 1000:size.Count[6] = 1;
return "huge"
}
size.Count[1] = 1;return "enormous"
}
var size = struct {
Count [7]uint32
Pos [3 * 7]uint32
NumStmt [7]uint16
} {
Pos: [3 * 7]uint32{
3, 4, 0x90019, // [0]
16, 16, 0x130002, // [1]
5, 6, 0x14000d, // [2]
7, 8, 0x10000e, // [3]
9, 10, 0x11000e, // [4]
11, 12, 0xf000f, // [5]
13, 14, 0x100010, // [6]
},
NumStmt: [7]uint16{
1, // 0
1, // 1
1, // 2
1, // 3
1, // 4
1, // 5
1, // 6
},
}而要實現(xiàn)go test -coverprofile=coverage.out打樁略微復雜一些,改命令會生成一個_testmain.go的中間文件,將文件名信息以及花括號的信息記錄并注冊,并最終生成coverprofile協(xié)議文件。具體的執(zhí)行過程可以通過如下命令查看。
go test -n -x -test.coverprofile=coverage.outcoverprofile文件的協(xié)議遵循如下格式:
第一行為"mode: foo", foo is "set", "count", or "atomic".
中間為記錄的位置:name.go:line.column,line.column numberOfStatements count
// 例如:encoding/base64/base64.go:34.44,37.40 3 1具體協(xié)議可以參考:go1.16.14/src/cmd/cover/profile.go:44
coverprofile文件協(xié)議需要的信息可以通過go test -cover自動生成的代碼進行整理。在這個地方,可以參考開源項目https://github.com/qiniu/goc 的實現(xiàn)邏輯,goc 本身就是對go test -cover 命令的封裝。
借助如下命令得到的在臨時目錄生成的文件,可以查看到如何將go test -cover對應的結(jié)構(gòu)轉(zhuǎn)換為coverprofile 文件的協(xié)議。
goc build --debug
cover 打樁原理:AST語法樹打樁
go test -cover 打樁的原理是借助AST語法樹遍歷整個文件,在識別到花括號、swith、select等地方,插入一行。
// go1.16.14/src/cmd/cover/cover.go:202
func (f *File) Visit(node ast.Node) ast.Visitor {
switch n := node.(type) {
case *ast.BlockStmt:
// If it's a switch or select, the body is a list of case clauses; don't tag the block itself.
if len(n.List) > 0 {
switch n.List[0].(type) {
case *ast.CaseClause: // switch
for _, n := range n.List {
clause := n.(*ast.CaseClause)
f.addCounters(clause.Colon+1, clause.Colon+1, clause.End(), clause.Body, false)
}
return f
case *ast.CommClause: // select
for _, n := range n.List {
clause := n.(*ast.CommClause)
f.addCounters(clause.Colon+1, clause.Colon+1, clause.End(), clause.Body, false)
}
return f
}
}
f.addCounters(n.Lbrace, n.Lbrace+1, n.Rbrace+1, n.List, true) // +1 to step past closing brace.
...借助于操作AST語法樹這種思路,我們也可以做相應改造,完成在代碼特定位置插入一行用于統(tǒng)計,等其他功能。
如果只是統(tǒng)計函數(shù)是否被調(diào)用,那么可以借助AST語法樹,在識別到函數(shù)的下一行,插入一行統(tǒng)一的函數(shù)調(diào)用,全局Record函數(shù)可以是一個map,用于全局函數(shù)的統(tǒng)計。參考資料中給出了操作AST的2篇文章。
func A(){
Record("A")
...
}腳本
如果我們只是想實現(xiàn)簡單的功能,例如在函數(shù)下方插入一行,也許我們可以用時間成本最小的方式來處理。如下的sed命令,直接在文件中函數(shù)下方插入一行,這種方式最快。
sed -i 's/.*\(func \([a-z].*\)()\).*/\1\n Record \2/g' xxx.go其適用于大部分函數(shù)場景:但是上面的這種方法不適用于有換行的函數(shù),如下統(tǒng)計個數(shù)。
find . -type f -name '*.go' -not -path "./vendor/*" | xargs ggrep -oP "func \w*\(.*?,$" | wc -l非傾入性方案-pprof
上面的方式比較直接,但是代碼有侵入性容易出錯。不想有傾入性可以借助于中斷信號處理的方式。
Go pprof CPU 分析器使用 定時發(fā)送SIGPROF 信號中斷代碼執(zhí)行。當調(diào)用 pprof.StartCPUProfile 函數(shù)時,SIGPROF 信號處理程序?qū)⒈蛔詾槟J每 10 毫秒間隔調(diào)用一次(100 Hz)。在 Unix 上,它使用 setitimer(2) 系統(tǒng)調(diào)用來設(shè)置信號計時器。當內(nèi)核態(tài)返回到用戶態(tài)調(diào)用注冊好的sighandler 函數(shù),sighandler 函數(shù)識別到信號為_SIGPROF 時,執(zhí)行sigprof 函數(shù)記錄該CPU樣本,并以此機會獲取當前代碼的棧幀數(shù)據(jù)。

這些堆棧信息可以合并為profile文件并最終被pprof程序分析處理。

這種方式記錄了每一個堆棧樣本

并且通過合并統(tǒng)計,借助概率的方式,能夠知道程序大部分時間在哪個函數(shù)運行。
但是如果要統(tǒng)計哪一個函數(shù)沒有被調(diào)用卻比較困難,原因是如果某一個A函數(shù)執(zhí)行的時間很短,ns級別的,那么當觸發(fā)定時任務(wù)時,有多大的概率能夠在該A函數(shù)停留?這種不確定性增加了識別不可用函數(shù)或者代碼的難度。
由于頻繁的中斷,pprof 不能一直打開,一般是抽樣60ms以下的數(shù)據(jù)。一種思路是定時抽樣并合并profile數(shù)據(jù)。
curl -o cpu.out http://localhost:9981/debug/pprof/profile?seconds=30
go tool pprof -http=localhost:8000 cpu.out總結(jié)
代碼覆蓋率是判斷代碼書寫的質(zhì)量,識別無效代碼的重要工具。在go生態(tài)中,go1.2提供了測試代碼覆蓋率的cover工具。
靜態(tài)代碼
對于靜態(tài)的代碼,要識別代碼沒有被使用,可以使用golangci-lint工具
golangci-lint run --disable-all --enable unused對于線下的單元測試
可以使用go test -cover工具
測試環(huán)境下的代碼
對于測試環(huán)境下有請求或長時間運行程序的單元覆蓋率,可以借助cover工具使用文中巧妙的方式來測試。如果測試環(huán)境不充分完備,沒有辦法測試出來。線上統(tǒng)計函數(shù)是否被調(diào)用有兩種方式傾入性和非傾入性。
對于線上傾入性的方式
主要是在關(guān)鍵位置注入一行函數(shù)進行統(tǒng)計??梢允呛瘮?shù)級別的,甚至可以借助go test -cover 在每一個邏輯分支注入了函數(shù)。
如果只是考慮函數(shù)級別的,可以考慮直接shel腳本注入函數(shù),這樣做時間成本最低。也可以考慮借助AST抽象語法樹的方式,成本略高。
如果是邏輯分支級別的,可以考慮借鑒開源庫https://github.com/qiniu/goc 的手法來生成打樁后的go文件。它是對go test -cover 代碼的封裝,借助AST抽象語法樹,在特定位置插入一行。
對于線上非傾入性的方式
通過pprof,信號中斷處理的手法,抽樣獲取堆棧信息。一些處理時間很短的函數(shù),將很難被檢測到。這種方式對于檢測到經(jīng)常使用的函數(shù)有用,但是不適合推斷沒有使用過的函數(shù)。
參考資料
官方文檔:https://go.dev/blog/cover
合并profile:https://github.com/rakyll/pprof-merge
七牛云goc:https://github.com/qiniu/goc
利用 go/ast 語法樹做代碼生成: https://segmentfault.com/a/1190000039215176
Golang AST語法樹使用教程及示例:https://juejin.cn/post/6844903982683389960
hook思路:https://github.com/brahma-adshonor/gohook
推薦閱讀
