手把手教你如何进行 Golang 单元测试
作者:stevennzhou,騰訊 PCG 前端開發(fā)工程師
本篇是對(duì)單元測(cè)試的一個(gè)總結(jié),通過完整的單元測(cè)試手把手教學(xué),能夠讓剛接觸單元測(cè)試的開發(fā)者從整體上了解一個(gè)單元測(cè)試編寫的全過程。最終通過兩個(gè)問題,也能讓寫過單元測(cè)試的開發(fā)者收獲單測(cè)執(zhí)行時(shí)的一些底層細(xì)節(jié)知識(shí)。
引入
隨著工程化開發(fā)在司內(nèi)大力的推廣,單元測(cè)試越來越受到廣大開發(fā)者的重視。在學(xué)習(xí)的過程中,發(fā)現(xiàn)網(wǎng)上針對(duì) Golang 單元測(cè)試大多從理論角度出發(fā)介紹,缺乏完整的實(shí)例說明,晦澀難懂的 API 讓初學(xué)接觸者難以下手。
本篇不準(zhǔn)備大而全的談?wù)搯卧獪y(cè)試、籠統(tǒng)的介紹 Golang 的單測(cè)工具,而將從 Golang 單測(cè)的使用場(chǎng)景出發(fā),以最簡(jiǎn)單且實(shí)際的例子講解如何進(jìn)行單測(cè),最終由淺入深探討 go 單元測(cè)試的兩個(gè)比較細(xì)節(jié)的問題。
在閱讀本文時(shí),請(qǐng)務(wù)必對(duì) Golang 的單元測(cè)試有最基本的了解。
一段需要單測(cè)的 Golang 代碼
package?unitimport?("encoding/json""errors""github.com/gomodule/redigo/redis""regexp" )type?PersonDetail?struct?{Username?string?`json:"username"`Email????string?`json:"email"` }//?檢查用戶名是否非法 func?checkUsername(username?string)?bool?{const?pattern?=?`^[a-z0-9_-]{3,16}$`reg?:=?regexp.MustCompile(pattern)return?reg.MatchString(username) }//?檢查用戶郵箱是否非法 func?checkEmail(email?string)?bool?{const?pattern?=?`^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$`reg?:=?regexp.MustCompile(pattern)return?reg.MatchString(email) }//?通過?redis?拉取對(duì)應(yīng)用戶的資料信息 func?getPersonDetailRedis(username?string)?(*PersonDetail,?error)?{result?:=?&PersonDetail{}client,?err?:=?redis.Dial("tcp",?":6379")defer?client.Close()data,?err?:=?redis.Bytes(client.Do("GET",?username))if?err?!=?nil?{return?nil,?err}err?=?json.Unmarshal(data,?result)if?err?!=?nil?{return?nil,?err}return?result,?nil }//?拉取用戶資料信息并校驗(yàn) func?GetPersonDetail(username?string)?(*PersonDetail,?error)?{//?檢查用戶名是否有效if?ok?:=?checkUsername(username);?!ok?{return?nil,?errors.New("invalid?username")}//?從?redis?接口獲取信息detail,?err?:=?getPersonDetailRedis(username)if?err?!=?nil?{return?nil,?err}//?校驗(yàn)if?ok?:=?checkEmail(detail.Email);?!ok?{return?nil,?errors.New("invalid?email")}return?detail,?nil }這是一段典型的有 I/O 的功能代碼,主體功能是傳入用戶名,校驗(yàn)合法性之后通過 redis 獲取信息,之后校驗(yàn)獲取值內(nèi)容的合法性后并返回。
后臺(tái)服務(wù)單測(cè)場(chǎng)景
對(duì)于一個(gè)傳統(tǒng)的后端服務(wù),它主要有以下幾點(diǎn)的職責(zé)和功能:
接收外部請(qǐng)求,controller 層分發(fā)請(qǐng)求、校驗(yàn)請(qǐng)求參數(shù)
請(qǐng)求有效分發(fā)后,在 service 層與 dao 層進(jìn)行交互后做邏輯處理
dao 層負(fù)責(zé)數(shù)據(jù)操作,主要是數(shù)據(jù)庫或持久化存儲(chǔ)相關(guān)的操作
因此,從職責(zé)出發(fā)來看,在做后臺(tái)單測(cè)中,核心主要是驗(yàn)證 service 層和 dao 層的相關(guān)邏輯,此外 controller 層的參數(shù)校驗(yàn)也在單測(cè)之中。
細(xì)分來看,對(duì)于相關(guān)邏輯的單元測(cè)試,筆者傾向于把單測(cè)分為兩種:
無第三方依賴,純邏輯代碼
有第三方依賴,如文件、網(wǎng)絡(luò) I/O、第三方依賴庫、數(shù)據(jù)庫操作相關(guān)的代碼
注:單元測(cè)試中只是針對(duì)單個(gè)函數(shù)的測(cè)試,關(guān)注其內(nèi)部的邏輯,對(duì)于網(wǎng)絡(luò)/數(shù)據(jù)庫訪問等,需要通過相應(yīng)的手段進(jìn)行 mock。
Golang 單測(cè)工具選型
由于我們把單測(cè)簡(jiǎn)單的分為了兩種:
對(duì)于無第三方依賴的純邏輯代碼,我們只需要驗(yàn)證相關(guān)邏輯即可,這里只需要使用 assert (斷言),通過控制輸入輸出比對(duì)結(jié)果即可。
對(duì)于有第三方依賴的代碼,在驗(yàn)證相關(guān)代碼邏輯之前,我們需要將相關(guān)的依賴 mock (模擬),之后才能通過斷言驗(yàn)證邏輯。這里需要借助第三方工具庫來處理。
因此,對(duì)于 assert (斷言)工具,可以選擇 testify 或 convery,筆者這里選擇了 testify。對(duì)于 mock (模擬)工具,筆者這里選擇了 gomock 和 gomonkey。關(guān)于 mock 工具同時(shí)使用 gomock 和 gomonkey,這里跟 Golang 的語言特性有關(guān),下面會(huì)詳細(xì)的說明。
完善測(cè)試用例
這里我們開始對(duì)示例代碼中的函數(shù)做單元測(cè)試。
生成單測(cè)模板代碼
首先在 Goland 中打開項(xiàng)目,加載對(duì)應(yīng)文件后右鍵找到 Generate 項(xiàng),點(diǎn)擊后選擇 Tests for package,之后生成以 _test.go 結(jié)尾的單測(cè)文件。(如果想針對(duì)某一特定函數(shù)做單測(cè),請(qǐng)選擇對(duì)應(yīng)的函數(shù)后右鍵選定 Generate 項(xiàng)執(zhí)行 Tests for selection。)
這里展示通過 IDE 生成的 TestGetPersonDetail 測(cè)試函數(shù):
package?unitimport?("reflect""testing" )func?TestGetPersonDetail(t?*testing.T)?{type?args?struct?{username?string}tests?:=?[]struct?{name????stringargs????argswant????*PersonDetailwantErr?bool}{//?TODO:?Add?test?cases.}for?_,?tt?:=?range?tests?{t.Run(tt.name,?func(t?*testing.T)?{got,?err?:=?GetPersonDetail(tt.args.username)if?(err?!=?nil)?!=?tt.wantErr?{t.Errorf("GetPersonDetail()?error?=?%v,?wantErr?%v",?err,?tt.wantErr)return}if?!reflect.DeepEqual(got,?tt.want)?{t.Errorf("GetPersonDetail()?got?=?%v,?want?%v",?got,?tt.want)}})} }由 Goland 生成的單測(cè)模板代碼使用的是官方的 testing 框架,為了更方便的斷言,我們把 testing 改造成 testify 的斷言方式。
這里其實(shí)只需要引入 testify 后修改 test 函數(shù)最后的斷言代碼即可,這里我們以 TestGetPersonDetail 為例子,其他函數(shù)不贅述。
package?unit import?("github.com/stretchr/testify/assert"?//?這里引入了?testify"reflect""testing" )func?TestGetPersonDetail(t?*testing.T)?{type?args?struct?{username?string}tests?:=?[]struct?{name????stringargs????argswant????*PersonDetailwantErr?bool}{//?TODO:?Add?test?cases.}for?_,?tt?:=?range?tests?{got,?err?:=?GetPersonDetail(tt.args.username)//?改寫這里斷言的方式即可assert.Equal(t,?tt.want,?got)assert.Equal(t,?tt.wantErr,?err?!=?nil)} }分析代碼生成測(cè)試用例
對(duì) checkUsername 、 checkEmail 純邏輯函數(shù)編寫測(cè)試用例,這里以 checkEmail 為例。
func?Test_checkEmail(t?*testing.T)?{type?args?struct?{email?string}tests?:=?[]struct?{name?stringargs?argswant?bool}{{name:?"email?valid",args:?args{email:?"1234567@qq.com",},want:?true,},{name:?"email?invalid",args:?args{email:?"test.com",},want:?false,},}for?_,?tt?:=?range?tests?{got?:=?checkEmail(tt.args.email)assert.Equal(t,?tt.want,?got)} }使用 gomonkey 打樁
對(duì)于 GetPersonDetail 函數(shù)而言,該函數(shù)調(diào)用了 getPersonDetailRedis 函數(shù)獲取具體的 PersonDetail 信息。為此,我們需要為它打一個(gè)“樁”。
所謂的“樁”,也叫做“樁代碼”,是指用來代替關(guān)聯(lián)代碼或者未實(shí)現(xiàn)代碼的代碼。
對(duì)于函數(shù)、成員方法或者是變量的打樁,我們通常使用 gomonkey 來進(jìn)行打樁。具體 API 請(qǐng)參考:https://pkg.go.dev/github.com/agiledragon/gomonkey
//?拉取用戶資料信息并校驗(yàn) func?GetPersonDetail(username?string)?(*PersonDetail,?error)?{//?檢查用戶名是否有效if?ok?:=?checkUsername(username);?!ok?{return?nil,?errors.New("invalid?username")}//?從?redis?接口獲取信息detail,?err?:=?getPersonDetailRedis(username)if?err?!=?nil?{return?nil,?err}//?校驗(yàn)if?ok?:=?checkEmail(detail.Email);?!ok?{return?nil,?errors.New("invalid?email")}return?detail,?nil }從 GetPersonDetail 函數(shù)可見,為了能夠完全覆蓋該函數(shù),我們需要控制 getPersonDetailRedis 函數(shù)不同的輸出來保證后續(xù)代碼都能夠被覆蓋運(yùn)行到。因此,這里需要使用 gomonkey 來給 getPersonDetailRedis 函數(shù)打一個(gè)“樁序列”。
所謂的函數(shù)“樁序列”指的是提前指定好調(diào)用函數(shù)的返回值序列,當(dāng)該函數(shù)多次調(diào)用時(shí)候,能夠按照原先指定的返回值序列依次返回。
func?TestGetPersonDetail(t?*testing.T)?{type?args?struct?{username?string}tests?:=?[]struct?{name????stringargs????argswant????*PersonDetailwantErr?bool}{{name:?"invalid?username",?args:?args{username:?"steven?xxx"},?want:?nil,?wantErr:?true},{name:?"invalid?email",?args:?args{username:?"invalid_email"},?want:?nil,?wantErr:?true},{name:?"throw?err",?args:?args{username:?"throw_err"},?want:?nil,?wantErr:?true},{name:?"valid?return",?args:?args{username:?"steven"},?want:?&PersonDetail{Username:?"steven",?Email:?"12345678@qq.com"},?wantErr:?false},}//?為函數(shù)打樁序列//?使用?gomonkey?打函數(shù)樁序列//?第一個(gè)用例不會(huì)調(diào)用?getPersonDetailRedis,所以只需要?3?個(gè)值outputs?:=?[]gomonkey.OutputCell{{Values:?gomonkey.Params{&PersonDetail{Username:?"invalid_email",?Email:?"test.com"},?nil},},{Values:?gomonkey.Params{nil,?errors.New("request?err")},},{Values:?gomonkey.Params{&PersonDetail{Username:?"steven",?Email:?"12345678@qq.com"},?nil},},}patches?:=?gomonkey.ApplyFuncSeq(getPersonDetailRedis,?outputs)//?執(zhí)行完畢后釋放樁序列defer?patches.Reset()for?_,?tt?:=?range?tests?{got,?err?:=?GetPersonDetail(tt.args.username)assert.Equal(t,?tt.want,?got)assert.Equal(t,?tt.wantErr,?err?!=?nil)} }當(dāng)使用樁序列時(shí),要分析好單元測(cè)試用例和序列值的對(duì)應(yīng)關(guān)系,保證最終被測(cè)試的代碼塊都能被完整覆蓋。
使用 gomock 打樁
最后剩下 getPersonDetailRedis 函數(shù),我們先來看一下這個(gè)函數(shù)的邏輯。
//?通過?redis?拉取對(duì)應(yīng)用戶的資料信息 func?getPersonDetailRedis(username?string)?(*PersonDetail,?error)?{result?:=?&PersonDetail{}client,?err?:=?redis.Dial("tcp",?":6379")defer?client.Close()data,?err?:=?redis.Bytes(client.Do("GET",?username))if?err?!=?nil?{return?nil,?err}err?=?json.Unmarshal(data,?result)if?err?!=?nil?{return?nil,?err}return?result,?nil }getPersonDetailRedis 函數(shù)的核心在于生成了 client 調(diào)用了它的 Do 方法,通過分析得知 client 實(shí)際上是一個(gè)符合 Conn 接口的結(jié)構(gòu)體。如果我們使用 gomonkey 來進(jìn)行打樁,需要先聲明一個(gè)結(jié)構(gòu)體并實(shí)現(xiàn) Client 接口擁有的方法,之后才能使用 gomonkey 給函數(shù)打樁。
//?redis?包中關(guān)于?Conn?的定義 //?Conn?represents?a?connection?to?a?Redis?server. type?Conn?interface?{//?Close?closes?the?connection.Close()?error//?Err?returns?a?non-nil?value?when?the?connection?is?not?usable.Err()?error//?Do?sends?a?command?to?the?server?and?returns?the?received?reply.Do(commandName?string,?args?...interface{})?(reply?interface{},?err?error)//?Send?writes?the?command?to?the?client's?output?buffer.Send(commandName?string,?args?...interface{})?error//?Flush?flushes?the?output?buffer?to?the?Redis?server.Flush()?error//?Receive?receives?a?single?reply?from?the?Redis?serverReceive()?(reply?interface{},?err?error) }//?實(shí)現(xiàn)接口 type?Client?struct?{} func?(c?*Client)?Close()?error?{return?nil } func?(c?*Client)?Err()?error?{return?nil } func?(c?*Client)?Do(commandName?string,?args?...interface{})?(interface{},?error)?{return?nil,?nil } func?(c?*Client)?Send(commandName?string,?args?...interface{})?error?{return?nil } func?(c?*Client)?Flush()?error?{return?nil } func?(c?*Client)?Receive()?(interface{},?error)?{return?nil,?nil }//?實(shí)現(xiàn)接口 type?Client?struct?{} func?(c?*Client)?Close()?error?{return?nil } func?(c?*Client)?Err()?error?{return?nil } func?(c?*Client)?Do(commandName?string,?args?...interface{})?(interface{},?error)?{return?nil,?nil } func?(c?*Client)?Send(commandName?string,?args?...interface{})?error?{return?nil } func?(c?*Client)?Flush()?error?{return?nil } func?(c?*Client)?Receive()?(interface{},?error)?{return?nil,?nil } //?進(jìn)行測(cè)試 func?test()?{c?:=?&Client{}gomonkey.ApplyFunc(redis.Dial,?func(_?string,?_?string,?_?...redis.DialOption)?(redis.Conn,?error)?{return?c,?nil})gomonkey.ApplyMethod(reflect.TypeOf(c),?"Do",?func(commandName?string,?args?...interface{})?(interface{},?error)?{var?result?interface{}return?result,?nil}) }可見,如果接口實(shí)現(xiàn)的方法更多,那么打樁需要手寫的代碼會(huì)更多。因此這里需要一種能自動(dòng)根據(jù)原接口的定義生成接口的 mock 代碼以及更方便的接口 mock 方式。于是這里我們使用 gomock 來解決這個(gè)問題。
本地安裝 gomock
#?打開終端后依次執(zhí)行 go?get?-u?github.com/golang/mock/gomock go?install?github.com/golang/mock/mockgen #?備注說明,很重要!!! #?安裝完成之后,執(zhí)行?mockgen?看命令是否生效?#?如果顯示命令無效,請(qǐng)找到本機(jī)的?GOPATH?安裝目錄下的?bin?文件夾是否有?mockgen?二進(jìn)制文件 #?GOPATH?可以執(zhí)行?go?env?命令找到 #?如果命令無效但是?GOPATH?路徑下的?bin?文件夾中存在?mockgen,請(qǐng)將?GOPATH?下?bin?文件夾的絕對(duì)路徑添加到全局?PATH?中生成 gomock 樁代碼
安裝完畢后,找到要進(jìn)行打樁的接口,這里是 github.com/gomodule/redigo/redis 包里面的 Conn 接口。
在當(dāng)前代碼目錄下執(zhí)行以下指令,這里我們只對(duì)某個(gè)特定的接口生成 mock 代碼。
mockgen?-destination=mock_redis.go?-package=unit?github.com/gomodule/redigo/redis?Conn #?更多指令參考:https://github.com/golang/mock#flags生成的代碼參考 mock_redis.go
完善 gomock 相關(guān)邏輯
func?Test_getPersonDetailRedis(t?*testing.T)?{tests?:=?[]struct?{name????stringwant????*PersonDetailwantErr?bool}{{name:?"redis.Do?err",?want:?nil,?wantErr:?true},{name:?"json.Unmarshal?err",?want:?nil,?wantErr:?true},{name:?"success",?want:?&PersonDetail{Username:?"steven",Email:????"1234567@qq.com",},?wantErr:?false},}ctrl?:=?gomock.NewController(t)defer?ctrl.Finish()//?1.?生成符合?redis.Conn?接口的?mockConnmockConn?:=?NewMockConn(ctrl)//?2.?給接口打樁序列g(shù)omock.InOrder(mockConn.EXPECT().Do("GET",?gomock.Any()).Return("",?errors.New("redis.Do?err")),mockConn.EXPECT().Close().Return(nil),mockConn.EXPECT().Do("GET",?gomock.Any()).Return("123",?nil),mockConn.EXPECT().Close().Return(nil),mockConn.EXPECT().Do("GET",?gomock.Any()).Return([]byte(`{"username":?"steven",?"email":?"1234567@qq.com"}`),?nil),mockConn.EXPECT().Close().Return(nil),)//?3.?給?redis.Dail?函數(shù)打樁outputs?:=?[]gomonkey.OutputCell{{Values:?gomonkey.Params{mockConn,?nil},Times:??3,?//?3?個(gè)用例},}patches?:=?gomonkey.ApplyFuncSeq(redis.Dial,?outputs)//?執(zhí)行完畢之后釋放樁序列defer?patches.Reset()//?4.?斷言for?_,?tt?:=?range?tests?{actual,?err?:=?getPersonDetailRedis(tt.name)//?注意,equal?函數(shù)能夠?qū)Y(jié)構(gòu)體進(jìn)行?deap?diffassert.Equal(t,?tt.want,?actual)assert.Equal(t,?tt.wantErr,?err?!=?nil)} }從上面可以看到,給 getPersonDetailRedis 函數(shù)做單元測(cè)試主要做了四件事情:
生成符合 redis.Conn 接口的 mockConn
給接口打樁序列
給函數(shù) redis.Dial 打樁
斷言
這里面同時(shí)使用了 gomock、gomonkey 和 testify 三個(gè)包作為壓測(cè)工具,日常使用中,由于復(fù)雜的調(diào)用邏輯帶來繁雜的單測(cè),也無外乎使用這三個(gè)包協(xié)同完成。
查看單測(cè)報(bào)告
單元測(cè)試編寫完畢之后,我們可以調(diào)用相關(guān)的指令來查看覆蓋范圍,幫助我們查看單元測(cè)試是否已經(jīng)完全覆蓋邏輯代碼,以便我們及時(shí)調(diào)整單測(cè)邏輯和用例。本文中完整的單測(cè)代碼參考:get_person_detail_test.go
使用 go test 指令
默認(rèn)情況下,我們?cè)诋?dāng)前代碼目錄下執(zhí)行 go test 指令,會(huì)自動(dòng)的執(zhí)行當(dāng)前目錄下面帶 _test.go 后綴的文件進(jìn)行測(cè)試。如若想展示具體的測(cè)試函數(shù)以及覆蓋率,可以添加 -v 和 -cover 參數(shù),如下所示:
????go_unit_test?[master]???????go?test?-v?-cover ===?RUN???TestGetPersonDetail ---?PASS:?TestGetPersonDetail?(0.00s) ===?RUN???Test_checkEmail ---?PASS:?Test_checkEmail?(0.00s) ===?RUN???Test_checkUsername ---?PASS:?Test_checkUsername?(0.00s) ===?RUN???Test_getPersonDetailRedis ---?PASS:?Test_getPersonDetailRedis?(0.00s) PASS coverage:?60.8%?of?statements ok??????unit????0.131s如果想指定測(cè)試某一個(gè)函數(shù),可以在指令后面添加 -run ${test文件內(nèi)函數(shù)名} 來指定執(zhí)行。
????go_unit_test?[master]???????go?test?-cover?-v??-run?Test_getPersonDetailRedis ===?RUN???Test_getPersonDetailRedis ---?PASS:?Test_getPersonDetailRedis?(0.00s) PASS coverage:?41.9%?of?statements ok??????unit????0.369s在執(zhí)行 go test 命令時(shí),需要加上 -gcflags=all=-l 防止編譯器內(nèi)聯(lián)優(yōu)化導(dǎo)致單測(cè)出現(xiàn)問題,這跟打樁代碼存在密切的關(guān)系,后面我們會(huì)詳細(xì)的介紹這一點(diǎn)。
因此,一個(gè)完整的單測(cè)指令可以是 go test -v -cover -gcflags=all=-l -coverprofile=coverage.out
生成覆蓋報(bào)告
最后,我們可以執(zhí)行 go tool cover -html=coverage.out ,查看代碼的覆蓋情況,使用前請(qǐng)先安裝好 go tool 工具。
可以看到待測(cè)的代碼覆蓋率達(dá)到 100% 了,完整的代碼倉庫可以參考:https://github.com/xunan007/go_unit_test
關(guān)于 go test 更多的使用方法,可以參考:
https://golang.org/pkg/cmd/go/internal/test/
思考
上面我們已經(jīng)詳細(xì)的介紹了如何對(duì) go 代碼進(jìn)行單元測(cè)試。下面探討兩個(gè)問題,幫助我們深入理解 go 單元測(cè)試的過程。
Q1:樁代碼在單測(cè)中是如何執(zhí)行的
在上面的案例中,針對(duì) interface 我們通過 gomock 來幫我們自動(dòng)生成符合接口的類后,只需要通過 gomock 約定的 API 就能夠?qū)?interface 中的函數(shù)按期望和需要來模擬,這個(gè)很好理解。
對(duì)于函數(shù)以及方法的 mock,由于本身代碼邏輯已經(jīng)聲明好(go 是靜態(tài)強(qiáng)類型語言),我們很難通過編碼的方式將其 mock 掉,這對(duì)我們做單元測(cè)試提供了很大的挑戰(zhàn)。實(shí)際上 gomonkey 提供了讓我們?cè)谶\(yùn)行時(shí)替換原函數(shù)/方法的能力。雖然說我們?cè)谡Z言層面很難去替換運(yùn)行中的函數(shù)體,但是本身代碼最終都會(huì)轉(zhuǎn)換成機(jī)器可以理解的匯編指令,我們可以通過創(chuàng)建指令來改寫函數(shù)。
在 gomonkey 打樁的過程中,其核心函數(shù)其實(shí)是 ApplyCore。
func?(this?*Patches)?ApplyCore(target,?double?reflect.Value)?*Patches?{this.check(target,?double)if?_,?ok?:=?this.originals[target];?ok?{panic("patch?has?been?existed")}this.valueHolders[double]?=?doubleoriginal?:=?replace(*(*uintptr)(getPointer(target)),?uintptr(getPointer(double)))this.originals[target]?=?originalreturn?this }不管是對(duì)函數(shù)打樁還是對(duì)方法打樁,實(shí)際上最后都會(huì)調(diào)用這個(gè) ApplyCore 函數(shù)。
在第 8 行的位置,獲取到傳入的原始函數(shù)和替換函數(shù)做了一個(gè) replace 的操作,這里就是替換的邏輯所在了。
func?replace(target,?double?uintptr)?[]byte?{code?:=?buildJmpDirective(double)bytes?:=?entryAddress(target,?len(code))original?:=?make([]byte,?len(bytes))copy(original,?bytes)modifyBinary(target,?code)return?original }//?關(guān)鍵函數(shù):構(gòu)建跳轉(zhuǎn)指令 func?buildJmpDirective(double?uintptr)?[]byte?{d0?:=?byte(double)d1?:=?byte(double?>>?8)d2?:=?byte(double?>>?16)d3?:=?byte(double?>>?24)d4?:=?byte(double?>>?32)d5?:=?byte(double?>>?40)d6?:=?byte(double?>>?48)d7?:=?byte(double?>>?56)return?[]byte{0x48,?0xBA,?d0,?d1,?d2,?d3,?d4,?d5,?d6,?d7,?//?MOV?rdx,?double0xFF,?0x22,?????//?JMP?[rdx]} }//?關(guān)鍵函數(shù):重寫目標(biāo)函數(shù) func?modifyBinary(target?uintptr,?bytes?[]byte)?{function?:=?entryAddress(target,?len(bytes))page?:=?entryAddress(pageStart(target),?syscall.Getpagesize())err?:=?syscall.Mprotect(page,?syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)if?err?!=?nil?{panic(err)}copy(function,?bytes)err?=?syscall.Mprotect(page,?syscall.PROT_READ|syscall.PROT_EXEC)if?err?!=?nil?{panic(err)} }從上面的代碼可以看出,buildJmpDirective 構(gòu)建了一個(gè)函數(shù)跳轉(zhuǎn)的指令,把目標(biāo)函數(shù)指針移動(dòng)到寄存器 rdx 中,然后跳轉(zhuǎn)到寄存器 rdx 中函數(shù)指針指向的地址。之后通過 modifyBinary 函數(shù),先通過 entryAddress 方法獲取到原函數(shù)所在的內(nèi)存地址,之后通過 syscall.Mprotect 方法打開內(nèi)存保護(hù),將函數(shù)跳轉(zhuǎn)指令以 bytes 數(shù)組的形式調(diào)用 copy 方法寫入到原函數(shù)所在內(nèi)存之中,最終達(dá)到替換的目的。此外,這里 replace 方法還保留了原函數(shù)的副本,方便后續(xù)函數(shù) mock 的恢復(fù)。
為什么 buildJmpDirective 要構(gòu)建這樣的跳轉(zhuǎn)指令呢?這里只說結(jié)論,具體的推導(dǎo)過程可以參考:https://bou.ke/blog/monkey-patching-in-go
package?main func?a()?int?{?return?1?} func?main()?{f?:=?af() }上面這段代碼,a 是一個(gè)指向函數(shù)實(shí)體的指針,f 是指向函數(shù) a 指針的指針。把上面函數(shù)的調(diào)用反匯編,能夠看到操作寄存器的具體細(xì)節(jié)。( 如果對(duì)匯編不是很了解,可以先閱讀 http://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html )
第一行,lea 為 load effective address,這里是將 f 變量這個(gè)值直接賦給 rdx 寄存器, f 變量的值是指向 a 函數(shù)的地址。
第二行,mov 表示移動(dòng),這里是取到內(nèi)存地址為 rdx 的數(shù)據(jù)賦值給 rbx,此時(shí)內(nèi)存地址 rbx 指向的剛好就是 a 函數(shù)。
最后,調(diào)用 rbx 里面的內(nèi)容,其實(shí)也就是執(zhí)行函數(shù)體。
因此,我們想改寫函數(shù),只要想辦法把需要跳轉(zhuǎn)的函數(shù)的地址加載到 rdx 寄存器中,之后使用指令跳轉(zhuǎn)執(zhí)行。
MOV?rdx,?double JMP?[rdx]最終,把匯編指令翻譯成 go 能夠識(shí)別的版本。
這其實(shí)也是匯編里面很常見的熱補(bǔ)丁,多用于進(jìn)程中函數(shù)的替換。
Q2:執(zhí)行 -gcflags=all=-l 具體有什么作用
-gcflags 用于在 go 編譯構(gòu)建時(shí)進(jìn)行參數(shù)的傳遞,all 表示覆蓋所有在 GOPATH 中的包,-l 表示禁止編譯的內(nèi)聯(lián)優(yōu)化。該指令可以防止編譯時(shí)代碼內(nèi)聯(lián)優(yōu)化使得 mock 失敗,最終導(dǎo)致執(zhí)行單元測(cè)試不通過。下面我們具體來探討一下“內(nèi)聯(lián)”以及給單元測(cè)試帶來的影響。
通俗來講,內(nèi)聯(lián)指的是把簡(jiǎn)短的函數(shù)在調(diào)用它的地方展開。由于函數(shù)調(diào)用有固定的開銷(棧和搶占檢查),在編譯過程中,編譯器可以針對(duì)代碼進(jìn)行內(nèi)聯(lián),減少函數(shù)調(diào)用開銷。內(nèi)聯(lián)優(yōu)化是高性能編程的一種重要手段。
在 go 中,編譯器不會(huì)對(duì)所有簡(jiǎn)單函數(shù)進(jìn)行內(nèi)聯(lián)優(yōu)化。go 在決策是否要對(duì)函數(shù)進(jìn)行內(nèi)聯(lián)時(shí)有一個(gè)標(biāo)準(zhǔn):函數(shù)體內(nèi)包含:閉包調(diào)用,select ,for ,defer,go 關(guān)鍵字的的函數(shù)不會(huì)進(jìn)行內(nèi)聯(lián)。并且除了這些,還有其它的限制。當(dāng)解析 AST 時(shí),Go 申請(qǐng)了 80 個(gè)節(jié)點(diǎn)作為內(nèi)聯(lián)的預(yù)算。每個(gè)節(jié)點(diǎn)都會(huì)消耗一個(gè)預(yù)算。當(dāng)一個(gè)函數(shù)的開銷超過了這個(gè)預(yù)算,就無法內(nèi)聯(lián)。( 參考自:https://juejin.cn/post/6924888439577903117 )
下面我們通過一段簡(jiǎn)短的代碼來理解 go 編譯過程的內(nèi)聯(lián)優(yōu)化過程。我們從 gomonkey 關(guān)于內(nèi)聯(lián)的 issue 摘取了一段代碼:
package?main import?"fmt" func?G2()?string?{??return?"G2"?} func?G()?string?{??return?G2()?} func?main()?{g?:=?G()fmt.Println(g) }上面這段代碼很簡(jiǎn)單,main 函數(shù)中調(diào)用了 G 函數(shù)拿到返回值賦值變量給 g 后打印結(jié)果。其中 G 函數(shù)調(diào)用了 G2 函數(shù),G2 函數(shù)返回了字符串 "G2"。
然而,經(jīng)過編譯器內(nèi)聯(lián)優(yōu)化后的代碼,G 函數(shù)實(shí)際被展開了,最終 main 函數(shù)被內(nèi)聯(lián)優(yōu)化成:
func?main()?{//?展開?g?:=?G()//?=>?g?:=?"G2"//?展開?fmt.Println(g)//?=>?相關(guān) }可見,G 函數(shù)和 G2 函數(shù)原本執(zhí)行時(shí)候帶來函數(shù)棧申請(qǐng)回收,優(yōu)化過后將不再有。
這里我們執(zhí)行 go run -gcflags="-m -m" main.go 來查看編譯在進(jìn)行以上代碼的內(nèi)聯(lián)優(yōu)化。
????test??go?run?-gcflags="-m?-m"?main.go #?command-line-arguments ./main.go:5:6:?can?inline?G2?as:?func()?string?{?return?"G2"?}?./main.go:9:6:?can?inline?G?as:?func()?string?{?return?G2()?}?./main.go:10:11:?inlining?call?to?G2?func()?string?{?return?"G2"?}?./main.go:13:6:?cannot?inline?main:?function?too?complex:?cost?87?exceeds?budget?80 ./main.go:14:8:?inlining?call?to?G?func()?string?{?return?G2()?}?./main.go:14:8:?inlining?call?to?G2?func()?string?{?return?"G2"?}?./main.go:15:13:?inlining?call?to?fmt.Println?func(...interface?{})?(int,?error)?{?var?fmt..autotmp_3?int;?fmt..autotmp_3?=?<N>;?var?fmt..autotmp_4?error;?fmt..autotmp_4?=?<N>;?fmt..autotmp_3,?fmt..autotmp_4?=?fmt.Fprintln(io.Writer(os.Stdout),?fmt.a...);?return?fmt..autotmp_3,?fmt..autotmp_4?} ./main.go:15:13:?g?escapes?to?heap?./main.go:15:13:?main?[]interface?{}?literal?does?not?escape ./main.go:15:13:?io.Writer(os.Stdout)?escapes?to?heap?<autogenerated>:1:?(*File).close?.this?does?not?escape?G2從打印出的內(nèi)容可以看,G2\G\fmt.Println 都被內(nèi)聯(lián)了。
上面提到了 gomokey 打樁的邏輯,它是在函數(shù)調(diào)用的時(shí)候通過機(jī)器指令將函數(shù)的指向替換了。由于函數(shù)編譯后被內(nèi)聯(lián),實(shí)際上不存在函數(shù)的調(diào)用,導(dǎo)致單測(cè)執(zhí)行不通過,這也是內(nèi)聯(lián)導(dǎo)致 gomonkey 打樁無效的問題所在。
參考
內(nèi)聯(lián)函數(shù)和編譯器對(duì) Go 代碼的優(yōu)化
monkey patching in go
阮一峰--匯編入門
最近好文:
GPU虛擬化,算力隔離,和qGPU
一文入門 Kafka
騰訊代碼安全指南開源,涉及 C/C++、Go 等六門編程語言
“碼上有趣” 視頻挑戰(zhàn)活動(dòng)來了!
上傳視頻贏HHKB鍵盤和羅技鼠標(biāo)!
了解活動(dòng)可加微信:teg_helper(備注碼上有趣)
最新視頻
總結(jié)
以上是生活随笔為你收集整理的手把手教你如何进行 Golang 单元测试的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 十个问题理解Linux epoll工作原
- 下一篇: 微信小程序基础架构浅析