如何寫出優(yōu)雅的 Golang 代碼

原文鏈接:https://draveness.me/golang-101

Go 語言是一門簡(jiǎn)單、易學(xué)的編程語言,對(duì)于有編程背景的工程師來說,學(xué)習(xí) Go 語言并寫出能夠運(yùn)行的代碼并不是一件困難的事情,對(duì)于之前有過其他語言經(jīng)驗(yàn)的開發(fā)者來說,寫什么語言都像自己學(xué)過的語言其實(shí)是有問題的,想要真正融入生態(tài)寫出優(yōu)雅的代碼就一定要花一些時(shí)間和精力了解語言背后的設(shè)計(jì)哲學(xué)和最佳實(shí)踐。

bottle-of-wate

如果你之前沒有 Go 語言的開發(fā)經(jīng)歷,正在學(xué)習(xí)和使用 Go 語言,相信這篇文章能夠幫助你更快地寫出優(yōu)雅的 Go 語言代碼;在這篇文章中,我們并不會(huì)給一個(gè)長(zhǎng)長(zhǎng)地列表介紹變量、方法和結(jié)構(gòu)體應(yīng)該怎么命名,這些 Go 語言的代碼規(guī)范可以在 Go Code Review Comments 中找到,它們非常重要但并不是這篇文章想要介紹的重點(diǎn),我們將從代碼結(jié)構(gòu)、最佳實(shí)踐以及單元測(cè)試幾個(gè)不同的方面介紹如何寫出優(yōu)雅的 Go 語言代碼。

寫在前面

想要寫出好的代碼并不是一件容易的事情,它需要我們不斷地對(duì)現(xiàn)有的代碼進(jìn)行反思 — 如何改寫這段代碼才能讓它變得更加優(yōu)雅。優(yōu)雅聽起來是一個(gè)非常感性、難以量化的結(jié)果,然而這卻是好的代碼能夠帶來的最直觀感受,它可能隱式地包含了以下特性:

  • 容易閱讀和理解;
  • 容易測(cè)試、維護(hù)和擴(kuò)展;
  • 命名清晰、無歧義、注釋完善清楚;
  • ...

相信讀完了這篇文章,我們也不能立刻寫出優(yōu)雅的 Go 語言代碼,但是如果我們遵循這里介紹幾個(gè)的容易操作并且切實(shí)可行的方法,就幫助我們走出第一步,作者寫這篇文章有以下的幾個(gè)目的:

  • 幫助 Go 語言的開發(fā)者了解生態(tài)中的規(guī)范與工具,寫出更優(yōu)雅的代碼;
  • 為代碼和項(xiàng)目的管理提供被社區(qū)廣泛認(rèn)同的規(guī)則、共識(shí)以及最佳實(shí)踐;

代碼規(guī)范

代碼規(guī)范其實(shí)是一個(gè)老生常態(tài)的問題,我們也不能免俗還是要簡(jiǎn)單介紹一下相關(guān)的內(nèi)容,Go 語言比較常見并且使用廣泛的代碼規(guī)范就是官方提供的 Go Code Review Comments,無論你是短期還是長(zhǎng)期使用 Go 語言編程,都應(yīng)該至少完整地閱讀一遍這個(gè)官方的代碼規(guī)范指南,它既是我們?cè)趯懘a時(shí)應(yīng)該遵守的規(guī)則,也是在代碼審查時(shí)需要注意的規(guī)范。

學(xué)習(xí) Go 語言相關(guān)的代碼規(guī)范是一件非常重要的事情,也是讓我們的項(xiàng)目遵循統(tǒng)一規(guī)范的第一步,雖然閱讀代碼規(guī)范相關(guān)的文檔非常重要,但是在實(shí)際操作時(shí)我們并不能靠工程師自覺地遵守以及經(jīng)常被當(dāng)做形式的代碼審查,而是需要借助工具來輔助執(zhí)行。

輔助工具

使用自動(dòng)化的工具保證項(xiàng)目遵守一些最基本的代碼規(guī)范是非常容易操作和有效的事情,相比之下人肉審查代碼的方式更加容易出錯(cuò),也會(huì)出現(xiàn)一些違反規(guī)則和約定的特例,維護(hù)代碼規(guī)范的最好方式就是『盡量自動(dòng)化一切能夠自動(dòng)化的步驟,讓工程師審查真正重要的邏輯和設(shè)計(jì)』。

我們?cè)谶@一節(jié)中就會(huì)介紹兩種非常切實(shí)有效的辦法幫助我們?cè)陧?xiàng)目中自動(dòng)化地進(jìn)行一些代碼規(guī)范檢查和靜態(tài)檢查保證項(xiàng)目的質(zhì)量。

goimports

goimports 是 Go 語言官方提供的工具,它能夠?yàn)槲覀冏詣?dòng)格式化 Go 語言代碼并對(duì)所有引入的包進(jìn)行管理,包括自動(dòng)增刪依賴的包引用、將依賴包按字母序排序并分類。相信很多人使用的 IDE 都會(huì)將另一個(gè)官方提供的工具 gofmt 對(duì)代碼進(jìn)行格式化,而 goimports 就是等于 gofmt 加上依賴包管理。

golang-goimports

建議所有 Go 語言的開發(fā)者都在開發(fā)時(shí)使用 goimports,雖然 goimports 有時(shí)會(huì)引入錯(cuò)誤的包,但是與帶來的好處相比,這些偶爾出現(xiàn)的錯(cuò)誤在作者看來也是可以接受的;當(dāng)然,不想使用 goimports 的開發(fā)者也一定要在 IDE 或者編輯器中開啟自動(dòng)地 gofmt(保存時(shí)自動(dòng)格式化)。

在 IDE 和 CI 檢查中開啟自動(dòng)地 gofmt 或者 goimports 檢查是沒有、也不應(yīng)該有討論的必要的,這就是一件使用和開發(fā) Go 語言必須要做的事情。

golint

另一個(gè)比較常用的靜態(tài)檢查工具就是 golint 了,作為官方提供的工具,它在可定制化上有著非常差的支持,我們只能通過如下所示的方式運(yùn)行 golint 對(duì)我們的項(xiàng)目進(jìn)行檢查:

$ golint ./pkg/...
pkg/liquidity/liquidity_pool.go:18:2: exported var ErrOrderBookNotFound should have comment or be unexported
pkg/liquidity/liquidity_pool.go:23:6: exported type LiquidityPool should have comment or be unexported
pkg/liquidity/liquidity_pool.go:23:6: type name will be used as liquidity.LiquidityPool by other packages, and that stutters; consider calling this Pool
pkg/liquidity/liquidity_pool.go:31:1: exported function NewLiquidityPool should have comment or be unexported
...

社區(qū)上有關(guān)于 golint 定制化的 討論,golint 的開發(fā)者給出了以下的幾個(gè)觀點(diǎn)解釋為什么 golint 不支持定制化的功能:

  • lint 的目的就是在 Go 語言社區(qū)中鼓勵(lì)統(tǒng)一、一致的編程風(fēng)格,某些開發(fā)者也許不會(huì)同意其中的某些規(guī)范,但是使用統(tǒng)一的風(fēng)格對(duì)于 Go 語言社區(qū)有比較強(qiáng)的好處,而能夠開關(guān)指定規(guī)則的功能會(huì)導(dǎo)致 golint 不能夠有效地完成這個(gè)工作;
  • 有一些靜態(tài)檢查的規(guī)則會(huì)導(dǎo)致一些錯(cuò)誤的警告,這些情況確實(shí)非常讓人頭疼,但是我會(huì)選擇支持在 golint 中直接保留或者刪除這些規(guī)則,而不是隨提供意增刪規(guī)則的能力;
  • 能夠通過 min_confidence 過濾一些靜態(tài)檢查規(guī)則,但是需要我們選擇合適的值;

golint 作者的觀點(diǎn)在 issue 中得到了非常多的 ??,但是這件事情很難說對(duì)錯(cuò);在社區(qū)中保證一致的編程規(guī)范是一件非常有益的事情,不過對(duì)于很多公司內(nèi)部的服務(wù)或者項(xiàng)目,可能在業(yè)務(wù)服務(wù)上就會(huì)發(fā)生一些比較棘手的情況,使用這種過強(qiáng)的約束沒有太多明顯地收益。

golang-lint

更推薦的方法是在基礎(chǔ)庫或者框架中使用 golint 進(jìn)行靜態(tài)檢查(或者同時(shí)使用 golintgolangci-lint),在其他的項(xiàng)目中使用可定制化的 golangci-lint 來進(jìn)行靜態(tài)檢查,因?yàn)樵诨A(chǔ)庫和框架中施加強(qiáng)限制對(duì)于整體的代碼質(zhì)量有著更大的收益。

作者會(huì)在自己的 Go 項(xiàng)目中使用 golint + golangci-lint 并開啟全部的檢查盡量盡早發(fā)現(xiàn)代碼中包含文檔在內(nèi)的全部缺陷。

自動(dòng)化

無論是用于檢查代碼規(guī)范和依賴包的 goimports 還是靜態(tài)檢查工具 glint 或者 golangci-lint,只要我們?cè)陧?xiàng)目中引入這些工具就一定要在代碼的 CI 流程中加入對(duì)應(yīng)的自動(dòng)化檢查:

在自建的或者其他的代碼托管平臺(tái)上也應(yīng)該想盡辦法尋找合適的工具,現(xiàn)代的代碼托管工具應(yīng)該都會(huì)對(duì) CI/CD 有著非常不錯(cuò)的支持;我們需要通過這些 CI 工具將代碼的自動(dòng)化檢查變成 PR 合并和發(fā)版的一個(gè)前置條件,減少工程師 Review 代碼時(shí)可能發(fā)生的疏漏。

最佳實(shí)踐

我們?cè)谏弦还?jié)中介紹了一些能通過自動(dòng)化工具發(fā)現(xiàn)的問題,這一節(jié)提到的最佳實(shí)踐可能就沒有辦法通過自動(dòng)化工具進(jìn)行保證,這些最佳實(shí)踐更像是 Go 語言社區(qū)內(nèi)部發(fā)展過程中積累的一些工程經(jīng)驗(yàn)和共識(shí),遵循這些最佳實(shí)踐能夠幫助我們寫出符合 Go 語言『味道』的代碼,我們將在這一小節(jié)覆蓋以下的幾部分內(nèi)容:

  • 目錄結(jié)構(gòu);
  • 模塊拆分;
  • 顯式調(diào)用;
  • 面向接口;

這四部分內(nèi)容是在社區(qū)中相對(duì)來說比較常見的約定,如果我們學(xué)習(xí)并遵循了這些約定,同時(shí)在 Go 語言的項(xiàng)目中實(shí)踐這幾部分內(nèi)容,相信一定會(huì)對(duì)我們?cè)O(shè)計(jì) Go 語言項(xiàng)目有所幫助。

目錄結(jié)構(gòu)

目錄結(jié)構(gòu)基本上就是一個(gè)項(xiàng)目的門面,很多時(shí)候我們從目錄結(jié)構(gòu)中就能夠看出開發(fā)者對(duì)這門語言是否有足夠的經(jīng)驗(yàn),所以在這里首先要介紹的最佳實(shí)踐就是如何在 Go 語言的項(xiàng)目或者服務(wù)中組織代碼。

官方并沒有給出一個(gè)推薦的目錄劃分方式,很多項(xiàng)目對(duì)于目錄結(jié)構(gòu)的劃分也非常隨意,這其實(shí)也是沒有什么問題的,但是社區(qū)中還是有一些比較常見的約定,例如:golang-standards/project-layout 項(xiàng)目中就定義了一個(gè)比較標(biāo)準(zhǔn)的目錄結(jié)構(gòu)。

├── LICENSE.md
├── Makefile
├── README.md
├── api
├── assets
├── build
├── cmd
├── configs
├── deployments
├── docs
├── examples
├── githooks
├── init
├── internal
├── pkg
├── scripts
├── test
├── third_party
├── tools
├── vendor
├── web
└── website

我們?cè)谶@里就像簡(jiǎn)單介紹其中幾個(gè)比較常見并且重要的目錄和文件,幫助我們快速理解如何使用如上所示的目錄結(jié)構(gòu),如果各位讀者想要了解使用其他目錄的原因,可以從 golang-standards/project-layout 項(xiàng)目中的 README 了解更詳細(xì)的內(nèi)容。

/pkg

/pkg 目錄是 Go 語言項(xiàng)目中非常常見的目錄,我們幾乎能夠在所有知名的開源項(xiàng)目(非框架)中找到它的身影,例如:

  • prometheus 上報(bào)和存儲(chǔ)指標(biāo)的時(shí)序數(shù)據(jù)庫
  • istio 服務(wù)網(wǎng)格 2.0
  • kubernetes 容器調(diào)度管理系統(tǒng)
  • grafana 展示監(jiān)控和指標(biāo)的儀表盤

這個(gè)目錄中存放的就是項(xiàng)目中可以被外部應(yīng)用使用的代碼庫,其他的項(xiàng)目可以直接通過 import 引入這里的代碼,所以當(dāng)我們將代碼放入 pkg 時(shí)一定要慎重,不過如果我們開發(fā)的是 HTTP 或者 RPC 的接口服務(wù)或者公司的內(nèi)部服務(wù),將私有和公有的代碼都放到 /pkg 中也沒有太多的不妥,因?yàn)樽鳛樽铐攲拥捻?xiàng)目來說很少會(huì)被其他應(yīng)用直接依賴,當(dāng)然嚴(yán)格遵循公有和私有代碼劃分是非常好的做法,作者也建議各位開發(fā)者對(duì)項(xiàng)目中公有和私有的代碼進(jìn)行妥善的劃分。

私有代碼

私有代碼推薦放到 /internal 目錄中,真正的項(xiàng)目代碼應(yīng)該寫在 /internal/app 里,同時(shí)這些內(nèi)部應(yīng)用依賴的代碼庫應(yīng)該在 /internal/pkg 子目錄和 /pkg 中,下圖展示了一個(gè)使用 /internal 目錄的項(xiàng)目結(jié)構(gòu):

golang-internal-app-and-pkg

當(dāng)我們?cè)谄渌?xiàng)目引入包含 internal 的依賴時(shí),Go 語言會(huì)在編譯時(shí)報(bào)錯(cuò):

An import of a path containing the element “internal” is disallowed
if the importing code is outside the tree rooted at the parent of the 
"internal" directory.

這種錯(cuò)誤只有在被引入的 internal 包不存在于當(dāng)前項(xiàng)目樹中才會(huì)發(fā)生,如果在同一個(gè)項(xiàng)目中引入該項(xiàng)目的 internal 包并不會(huì)出現(xiàn)這種錯(cuò)誤。

/src

在 Go 語言的項(xiàng)目最不應(yīng)該有的目錄結(jié)構(gòu)其實(shí)就是 /src 了,社區(qū)中的一些項(xiàng)目確實(shí)有 /src 文件夾,但是這些項(xiàng)目的開發(fā)者之前大多數(shù)都有 Java 的編程經(jīng)驗(yàn),這在 Java 和其他語言中其實(shí)是一個(gè)比較常見的代碼組織方式,但是作為一個(gè) Go 語言的開發(fā)者,我們不應(yīng)該允許項(xiàng)目中存在 /src 目錄。

最重要的原因其實(shí)是 Go 語言的項(xiàng)目在默認(rèn)情況下都會(huì)被放置到 $GOPATH/src 目錄下,這個(gè)目錄中存儲(chǔ)著我們開發(fā)和依賴的全部項(xiàng)目代碼,如果我們?cè)谧约旱捻?xiàng)目中使用 /src 目錄,該項(xiàng)目的 PATH 中就會(huì)出現(xiàn)兩個(gè) src

$GOPATH/src/github.com/draveness/project/src/code.go

上面的目錄結(jié)構(gòu)看起來非常奇怪,這也是我們?cè)?Go 語言中不建議使用 /src 目錄的最重要原因。

當(dāng)然哪怕我們?cè)?Go 語言的項(xiàng)目中使用 /src 目錄也不會(huì)導(dǎo)致編譯不通過或者其他問題,如果堅(jiān)持這種做法對(duì)于項(xiàng)目的可用性也沒有任何的影響,但是如果想讓我們『看起來』更專業(yè),還是遵循社區(qū)中既定的約定減少其他 Go 語言開發(fā)者的理解成本,這對(duì)于社區(qū)來說是一件好事。

平鋪

另一種在 Go 語言中組織代碼的方式就是項(xiàng)目的根目錄下放項(xiàng)目的代碼,這種方式在很多框架或者庫中非常常見,如果想要引入一個(gè)使用 pkg 目錄結(jié)構(gòu)的框架時(shí),我們往往需要使用 github.com/draveness/project/pkg/somepkg,當(dāng)代碼都平鋪在項(xiàng)目的根目錄時(shí)只需要使用 github.com/draveness/project,很明顯地減少了引用依賴包語句的長(zhǎng)度。

所以對(duì)于一個(gè) Go 語言的框架或者庫,將代碼平鋪在根目錄下也很正常,但是在一個(gè) Go 語言的服務(wù)中使用這種代碼組織方法可能就沒有那么合適了。

/cmd

/cmd 目錄中存儲(chǔ)的都是當(dāng)前項(xiàng)目中的可執(zhí)行文件,該目錄下的每一個(gè)子目錄都應(yīng)該包含我們希望有的可執(zhí)行文件,如果我們的項(xiàng)目是一個(gè) grpc 服務(wù)的話,可能在 /cmd/server/main.go 中就包含了啟動(dòng)服務(wù)進(jìn)程的代碼,編譯后生成的可執(zhí)行文件就是 server

我們不應(yīng)該在 /cmd 目錄中放置太多的代碼,我們應(yīng)該將公有代碼放置到 /pkg 中并將私有代碼放置到 /internal 中并在 /cmd 中引入這些包,保證 main 函數(shù)中的代碼盡可能簡(jiǎn)單和少。

/api

/api 目錄中存放的就是當(dāng)前項(xiàng)目對(duì)外提供的各種不同類型的 API 接口定義文件了,其中可能包含類似 /api/protobuf-spec、/api/thrift-spec 或者 /api/http-spec 的目錄,這些目錄中包含了當(dāng)前項(xiàng)目對(duì)外提供的和依賴的所有 API 文件:

$ tree ./api
api
└── protobuf-spec
    └── oceanbookpb
        ├── oceanbook.pb.go
        └── oceanbook.proto

二級(jí)目錄的主要作用就是在一個(gè)項(xiàng)目同時(shí)提供了多種不同的訪問方式時(shí),用這種辦法避免可能存在的潛在沖突問題,也可以讓項(xiàng)目結(jié)構(gòu)的組織更加清晰。

Makefile

最后要介紹的 Makefile 文件也非常值得被關(guān)注,在任何一個(gè)項(xiàng)目中都會(huì)存在一些需要運(yùn)行的腳本,這些腳本文件應(yīng)該被放到 /scripts 目錄中并由 Makefile 觸發(fā),將這些經(jīng)常需要運(yùn)行的命令固化成腳本減少『祖?zhèn)髅睢坏某霈F(xiàn)。

小結(jié)

總的來說,每一個(gè)項(xiàng)目都應(yīng)該按照固定的組織方式進(jìn)行實(shí)現(xiàn),這種約定雖然并不是強(qiáng)制的,但是無論是組內(nèi)、公司內(nèi)還是整個(gè) Go 語言社區(qū)中,只要達(dá)成了一致,對(duì)于其他工程師快速梳理和理解項(xiàng)目都是很有幫助的。

這一節(jié)介紹的 Go 語言項(xiàng)目的組織方式也并不是強(qiáng)制要求的,這只是 Go 語言社區(qū)中經(jīng)常出現(xiàn)的項(xiàng)目組織方式,一個(gè)大型項(xiàng)目在使用這種目錄結(jié)構(gòu)時(shí)也會(huì)對(duì)其進(jìn)行微調(diào),不過這種組織方式確實(shí)更為常見并且合理。

模塊拆分

我們既然已經(jīng)介紹過了如何從頂層對(duì)項(xiàng)目的結(jié)構(gòu)進(jìn)行組織,接下來就會(huì)深入到項(xiàng)目的內(nèi)部介紹 Go 語言對(duì)模塊的一些拆分方法。

Go 語言的一些頂層設(shè)計(jì)最終導(dǎo)致了它在劃分模塊上與其他的編程語言有著非常明顯的不同,很多其他語言的 Web 框架都采用 MVC 的架構(gòu)模式,例如 Rails 和 Spring MVC,Go 語言對(duì)模塊劃分的方法就與 Ruby 和 Java 完全不同。

按層拆分

無論是 Java 還是 Ruby,它們最著名的框架都深受 MVC 架構(gòu)模式 的影響,我們從 Spring MVC 的名字中就能體會(huì)到 MVC 對(duì)它的影響,而 Ruby 社區(qū)的 Rails 框架也與 MVC 的關(guān)系非常緊密,這是一種 Web 框架的最常見架構(gòu)方式,將服務(wù)中的不同組件分成了 Model、View 和 Controller 三層。

divide-by-laye

這種模塊拆分的方式其實(shí)就是按照層級(jí)進(jìn)行拆分,Rails 腳手架默認(rèn)生成的代碼其實(shí)就是將這三層不同的源文件放在對(duì)應(yīng)的目錄下:modelsviewscontrollers,我們通過 rails new example 生成一個(gè)新的 Rails 項(xiàng)目后可以看到其中的目錄結(jié)構(gòu):

$ tree -L 2 app
app
├── controllers
│   ├── application_controller.rb
│   └── concerns
├── models
│   ├── application_record.rb
│   └── concerns
└── views
    └── layouts

而很多 Spring MVC 的項(xiàng)目中也會(huì)出現(xiàn)類似 model、dao、view 的目錄,這種按層拆分模塊的設(shè)計(jì)其實(shí)有以下的幾方面原因:

  1. MVC 架構(gòu)模式 — MVC 本身就強(qiáng)調(diào)了按層劃分職責(zé)的設(shè)計(jì),所以遵循該模式設(shè)計(jì)的框架自然有著一脈相承的思路;
  2. 扁平的命名空間 — 無論是 Spring MVC 還是 Rails,同一個(gè)項(xiàng)目中命名空間非常扁平,跨文件夾使用其他文件夾中定義的類或者方法不需要引入新的包,使用其他文件定義的類時(shí)也不需要增加額外的前綴,多個(gè)文件定義的類被『合并』到了同一個(gè)命名空間中;
  3. 單體服務(wù)的場(chǎng)景 — Spring MVC 和 Rails 剛出現(xiàn)時(shí),SOA 和微服務(wù)架構(gòu)還不像今天這么普遍,絕大多數(shù)的場(chǎng)景也不需要通過拆分服務(wù);

上面的幾個(gè)原因共同決定了 Spring MVC 和 Rails 會(huì)出現(xiàn) models、viewscontrollers 的目錄并按照層級(jí)的方式對(duì)模塊進(jìn)行拆分。

按職責(zé)拆分

Go 語言在拆分模塊時(shí)就使用了完全不同的思路,雖然 MVC 架構(gòu)模式是在我們寫 Web 服務(wù)時(shí)無法避開的,但是相比于橫向地切分不同的層級(jí),Go 語言的項(xiàng)目往往都按照職責(zé)對(duì)模塊進(jìn)行拆分:

divide-by-responsibility

對(duì)于一個(gè)比較常見的博客系統(tǒng),使用 Go 語言的項(xiàng)目會(huì)按照不同的職責(zé)將其縱向拆分成 postuser、comment 三個(gè)模塊,每一個(gè)模塊都對(duì)外提供相應(yīng)的功能,post 模塊中就包含相關(guān)的模型和視圖定義以及用于處理 API 請(qǐng)求的控制器(或者服務(wù)):

$ tree pkg
pkg
├── comment
├── post
│   ├── handler.go
│   └── post.go
└── user

Go 語言項(xiàng)目中的每一個(gè)文件目錄都代表著一個(gè)獨(dú)立的命名空間,也就是一個(gè)單獨(dú)的包,當(dāng)我們想要引用其他文件夾的目錄時(shí),首先需要使用 import 關(guān)鍵字引入相應(yīng)的文件目錄,再通過 pkg.xxx 的形式引用其他目錄定義的結(jié)構(gòu)體、函數(shù)或者常量,如果我們?cè)?Go 語言中使用 modelview?controller 來劃分層級(jí),你會(huì)在其他的模塊中看到非常多的 model.Post、model.Commentview.PostView。

這種劃分層級(jí)的方法在 Go 語言中會(huì)顯得非常冗余,并且如果對(duì)項(xiàng)目依賴包的管理不夠謹(jǐn)慎時(shí),很容易發(fā)生引用循環(huán),出現(xiàn)這些問題的最根本原因其實(shí)也非常簡(jiǎn)單:

  1. Go 語言對(duì)同一個(gè)項(xiàng)目中不同目錄的命名空間做了隔離,整個(gè)項(xiàng)目中定義的類和方法并不是在同一個(gè)命名空間下的,這也就需要工程師自己維護(hù)不同包之間的依賴關(guān)系;
  2. 按照職責(zé)垂直拆分的方式在單體服務(wù)遇到瓶頸時(shí)非常容易對(duì)微服務(wù)進(jìn)行拆分,我們可以直接將一個(gè)負(fù)責(zé)獨(dú)立功能的 package 拆出去,對(duì)這部分性能熱點(diǎn)單獨(dú)進(jìn)行擴(kuò)容;

小結(jié)

項(xiàng)目是按照層級(jí)還是按照職責(zé)對(duì)模塊進(jìn)行拆分其實(shí)并沒有絕對(duì)的好與不好,語言和框架層面的設(shè)計(jì)最終決定了我們應(yīng)該采用哪種方式對(duì)項(xiàng)目和代碼進(jìn)行組織。

Java 和 Ruby 這些語言在框架中往往采用水平拆分的方式劃分不同層級(jí)的職責(zé),而 Go 語言項(xiàng)目的最佳實(shí)踐就是按照職責(zé)對(duì)模塊進(jìn)行垂直拆分,將代碼按照功能的方式分到多個(gè) package 中,這并不是說 Go 語言中不存在模塊的水平拆分,只是因?yàn)?package 作為一個(gè) Go 語言訪問控制的最小粒度,所以我們應(yīng)該遵循頂層的設(shè)計(jì)使用這種方式構(gòu)建高內(nèi)聚的模塊。

顯式與隱式

從開始學(xué)習(xí)、使用 Go 語言到參與社區(qū)上一些開源的 Golang 項(xiàng)目,作者發(fā)現(xiàn) Go 語言社區(qū)對(duì)于顯式的初始化、方法調(diào)用和錯(cuò)誤處理非常推崇,類似 Spring Boot 和 Rails 的框架其實(shí)都廣泛地采納了『約定優(yōu)于配置』的中心思想,簡(jiǎn)化了開發(fā)者和工程師的工作量。

然而 Go 語言社區(qū)雖然達(dá)成了很多的共識(shí)與約定,但是從語言的設(shè)計(jì)以及工具上的使用我們就能發(fā)現(xiàn)顯式地調(diào)用方法和錯(cuò)誤處理是被鼓勵(lì)的。

init

我們?cè)谶@里先以一個(gè)非常常見的函數(shù) init 為例,介紹 Go 語言社區(qū)對(duì)顯式調(diào)用的推崇;相信很多人都在一些 package 中閱讀過這樣的代碼:

var grpcClient *grpc.Client

func init() {
    var err error
    grpcClient, err = grpc.Dial(...)
    if err != nil {
        panic(err)
    }
}

func GetPost(postID int64) (*Post, error) {
    post, err := grpcClient.FindPost(context.Background(), &pb.FindPostRequest{PostID: postID})
    if err != nil {
        return nil, err
    }
    
    return post, nil
}

這種代碼雖然能夠通過編譯并且正常工作,然而這里的 init 函數(shù)其實(shí)隱式地初始化了 grpc 的連接資源,如果另一個(gè) package 依賴了當(dāng)前的包,那么引入這個(gè)依賴的工程師可能會(huì)在遇到錯(cuò)誤時(shí)非常困惑,因?yàn)樵?init 函數(shù)中做這種資源的初始化是非常耗時(shí)并且容易出現(xiàn)問題的。

一種更加合理的做法其實(shí)是這樣的,首先我們定義一個(gè)新的 Client 結(jié)構(gòu)體以及一個(gè)用于初始化結(jié)構(gòu)的 NewClient 函數(shù),這個(gè)函數(shù)接收了一個(gè) grpc 連接作為入?yún)⒎祷匾粋€(gè)用于獲取 Post 資源的客戶端,GetPost 成為了這個(gè)結(jié)構(gòu)體的方法,每當(dāng)我們調(diào)用 client.GetPost 時(shí)都會(huì)用到結(jié)構(gòu)體中保存的 grpc 連接:

// pkg/post/client.go
type Client struct {
    grpcClient *grpc.ClientConn    
}

func NewClient(grpcClient *grpcClientConn) Client {
    return &Client{
        grpcClient: grpcClient,
    }
}

func (c *Client) GetPost(postID int64) (*Post, error) {
    post, err := c.grpcClient.FindPost(context.Background(), &pb.FindPostRequest{PostID: postID})
    if err != nil {
        return nil, err
    }
    
    return post, nil
}

初始化 grpc 連接的代碼應(yīng)該放到 main 函數(shù)或者 main 函數(shù)調(diào)用的其他函數(shù)中執(zhí)行,如果我們?cè)?main 函數(shù)中顯式的初始化這種依賴,對(duì)于其他的工程師來說就非常易于理解,我們從 main 函數(shù)開始就能梳理出程序啟動(dòng)的整個(gè)過程。

// cmd/grpc/main.go
func main() {
    grpcClient, err := grpc.Dial(...)
    if err != nil {
        panic(err)
    }
    
    postClient := post.NewClient(grpcClient)
    // ...
}

各個(gè)模塊之間會(huì)構(gòu)成一種樹形的結(jié)構(gòu)和依賴關(guān)系,上層的模塊會(huì)持有下層模塊中的接口或者結(jié)構(gòu)體,不會(huì)存在孤立的、不被引用的對(duì)象。

golang-project-and-tree-structure

上圖中出現(xiàn)的兩個(gè) Database 其實(shí)是在 main 函數(shù)中初始化的數(shù)據(jù)庫連接,在項(xiàng)目運(yùn)行期間,它們可能表示同一個(gè)內(nèi)存中的數(shù)據(jù)庫連接

當(dāng)我們使用 golangci-lint 并開啟 gochecknoinitsgochecknoglobals 靜態(tài)檢查時(shí),它其實(shí)嚴(yán)格地限制我們對(duì) init 函數(shù)和全局變量的使用。

當(dāng)然這并不是說我們一定不能使用 init 函數(shù),作為 Go 語言賦予開發(fā)者的能力,因?yàn)樗茉诎灰霑r(shí)隱式地執(zhí)行了一些代碼,所以我們更應(yīng)該慎重地使用它們。

一些框架會(huì)在 init 中判斷是否滿足使用的前置條件,但是對(duì)于很多的 Web 或者 API 服務(wù)來說,大量使用 init 往往意味著代碼質(zhì)量的下降以及不合理的設(shè)計(jì)。

func init() {
    if user == "" {
        log.Fatal("$USER not set")
    }
    if home == "" {
        home = "/home/" + user
    }
    if gopath == "" {
        gopath = home + "/go"
    }
    // gopath may be overridden by --gopath flag on command line.
    flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

上述代碼其實(shí)是 Effective Go 在介紹 init 方法使用是展示的實(shí)例代碼,這是一個(gè)比較合理地 init 函數(shù)使用示例,我們不應(yīng)該在 init 中做過重的初始化邏輯,而是做一些簡(jiǎn)單、輕量的前置條件判斷。

error

另一個(gè)要介紹的就是 Go 語言的錯(cuò)誤處理機(jī)制了,雖然 Golang 的錯(cuò)誤處理被開發(fā)者詬病已久,但是工程師每天都在寫 if err != nil { return nil, err } 的錯(cuò)誤處理邏輯其實(shí)就是在顯式地對(duì)錯(cuò)誤處理,關(guān)注所有可能會(huì)發(fā)生錯(cuò)誤的方法調(diào)用并在無法處理時(shí)拋給上層模塊。

func ListPosts(...) ([]Post, error) {
    conn, err := gorm.Open(...)
    if err != nil {
        return []Post{}, err
    }
    
    var posts []Post
    if err := conn.Find(&posts).Error; err != nil {
        return []Post{}, err
    }
    
    return posts, nil
}

上述代碼只是簡(jiǎn)單展示 Go 語言常見的錯(cuò)誤處理邏輯,我們不應(yīng)該在這種方法中初始化數(shù)據(jù)庫的連接。

雖然 Golang 中也有類似 Java 或者 Ruby try/catch 關(guān)鍵字,但是很少有人會(huì)在代碼中使用 panicrecover 來實(shí)現(xiàn)錯(cuò)誤和異常的處理,與 init 函數(shù)一樣,Go 語言對(duì)于 panicrecover 的使用也非常謹(jǐn)慎。

當(dāng)我們?cè)?Go 語言中處理錯(cuò)誤相關(guān)的邏輯時(shí),最重要的其實(shí)就是以下幾點(diǎn):

  1. 使用 error 實(shí)現(xiàn)錯(cuò)誤處理 — 盡管這看起來非常啰嗦;
  2. 將錯(cuò)誤拋給上層處理 — 對(duì)于一個(gè)方法是否需要返回 error 也需要我們仔細(xì)地思考,向上拋出錯(cuò)誤時(shí)可以通過 errors.Wrap 攜帶一些額外的信息方便上層進(jìn)行判斷;
  3. 處理所有可能返回的錯(cuò)誤 — 所有可能返回錯(cuò)誤的地方最終一定會(huì)返回錯(cuò)誤,考慮全面才能幫助我們構(gòu)建更加健壯的項(xiàng)目;

小結(jié)

作者在使用 Go 語言的這段時(shí)間,能夠深刻地體會(huì)到它對(duì)于顯式方法調(diào)用與錯(cuò)誤處理的鼓勵(lì),這不僅能夠幫助項(xiàng)目的其他開發(fā)者快速地理解上下文,也能夠幫助我們構(gòu)建更加健壯、容錯(cuò)性與可維護(hù)性更好的工程。

面向接口

面向接口編程是一個(gè)老生常談的話題,接口 的作用其實(shí)就是為不同層級(jí)的模塊提供了一個(gè)定義好的中間層,上游不再需要依賴下游的具體實(shí)現(xiàn),充分地對(duì)上下游進(jìn)行了解耦。

golang-interface

這種編程方式不僅是在 Go 語言中是被推薦的,在幾乎所有的編程語言中,我們都會(huì)推薦這種編程的方式,它為我們的程序提供了非常強(qiáng)的靈活性,想要構(gòu)建一個(gè)穩(wěn)定、健壯的 Go 語言項(xiàng)目,不使用接口是完全無法做到的。

如果一個(gè)略有規(guī)模的項(xiàng)目中沒有出現(xiàn)任何 type ... interface 的定義,那么作者可以推測(cè)出這在很大的概率上是一個(gè)工程質(zhì)量堪憂并且沒有多少單元測(cè)試覆蓋的項(xiàng)目,我們確實(shí)需要認(rèn)真考慮一下如何使用接口對(duì)項(xiàng)目進(jìn)行重構(gòu)。

單元測(cè)試是一個(gè)項(xiàng)目保證工程質(zhì)量最有效并且投資回報(bào)率最高的方法之一,作為靜態(tài)語言的 Golang,想要寫出覆蓋率足夠(最少覆蓋核心邏輯)的單元測(cè)試本身就比較困難,因?yàn)槲覀儾荒芟駝?dòng)態(tài)語言一樣隨意修改函數(shù)和方法的行為,而接口就成了我們的救命稻草,寫出抽象良好的接口并通過接口隔離依賴能夠幫助我們有效地提升項(xiàng)目的質(zhì)量和可測(cè)試性,我們會(huì)在下一節(jié)中詳細(xì)介紹如何寫單元測(cè)試。

package post

var client *grpc.ClientConn

func init() {
    var err error
    client, err = grpc.Dial(...)
    if err != nil {
        panic(err)
    }
}

func ListPosts() ([]*Post, error) {
    posts, err := client.ListPosts(...)
    if err != nil {
        return []*Post{}, err
    }
    
    return posts, nil
}

上述代碼其實(shí)就不是一個(gè)設(shè)計(jì)良好的代碼,它不僅在 init 函數(shù)中隱式地初始化了 grpc 連接這種全局變量,而且沒有將 ListPosts 通過接口的方式暴露出去,這會(huì)讓依賴 ListPosts 的上層模塊難以測(cè)試。

我們可以使用下面的代碼改寫原有的邏輯,使得同樣地邏輯變得更容易測(cè)試和維護(hù):

package post

type Service interface {
    ListPosts() ([]*Post, error)
}

type service struct {
    conn *grpc.ClientConn
}

func NewService(conn *grpc.ClientConn) Service {
    return &service{
        conn: conn,
    }
}

func (s *service) ListPosts() ([]*Post, error) {
    posts, err := s.conn.ListPosts(...)
    if err != nil {
        return []*Post{}, err
    }
    
    return posts, nil
}
  1. 通過接口 Service 暴露對(duì)外的 ListPosts 方法;
  2. 使用 NewService 函數(shù)初始化 Service 接口的實(shí)現(xiàn)并通過私有的接口體 service 持有 grpc 連接;
  3. ListPosts 不再依賴全局變量,而是依賴接口體 service 持有的連接;

當(dāng)我們使用這種方式重構(gòu)代碼之后,就可以在 main 函數(shù)中顯式的初始化 grpc 連接、創(chuàng)建 Service 接口的實(shí)現(xiàn)并調(diào)用 ListPosts 方法:

package main

import ...

func main() {
    conn, err = grpc.Dial(...)
    if err != nil {
        panic(err)
    }
    
    svc := post.NewService(conn)
    posts, err := svc.ListPosts()
    if err != nil {
        panic(err)
    }
    
    fmt.Println(posts)
}

這種使用接口組織代碼的方式在 Go 語言中非常常見,我們應(yīng)該在代碼中盡可能地使用這種思想和模式對(duì)外提供功能:

  1. 使用大寫的 Service 對(duì)外暴露方法;
  2. 使用小寫的 service 實(shí)現(xiàn)接口中定義的方法;
  3. 通過 NewService 函數(shù)初始化 Service 接口;

當(dāng)我們使用上述方法組織代碼之后,其實(shí)就對(duì)不同模塊的依賴進(jìn)行了解耦,也正遵循了軟件設(shè)計(jì)中經(jīng)常被提到的一句話 — 『依賴接口,不要依賴實(shí)現(xiàn)』,也就是面向接口編程

小結(jié)

在這一小節(jié)中總共介紹了 Go 語言中三個(gè)經(jīng)常會(huì)打交道的『元素』— init 函數(shù)、error 和接口,我們?cè)谶@里主要是想通過三個(gè)不同的例子為大家傳達(dá)的一個(gè)主要思想就是盡量使用顯式的(explicit)的方式編寫 Go 語言代碼。

單元測(cè)試

一個(gè)代碼質(zhì)量和工程質(zhì)量有保證的項(xiàng)目一定有比較合理的單元測(cè)試覆蓋率,沒有單元測(cè)試的項(xiàng)目一定是不合格的或者不重要的,單元測(cè)試應(yīng)該是所有項(xiàng)目都必須有的代碼,每一個(gè)單元測(cè)試都表示一個(gè)可能發(fā)生的情況,單元測(cè)試就是業(yè)務(wù)邏輯

作為軟件工程師,重構(gòu)現(xiàn)有的項(xiàng)目對(duì)于我們來說應(yīng)該是一件比較正常的事情,如果項(xiàng)目中沒有單元測(cè)試,我們很難在不改變已有業(yè)務(wù)邏輯的情況對(duì)項(xiàng)目進(jìn)行重構(gòu),一些業(yè)務(wù)的邊界情況很可能會(huì)在重構(gòu)的過程中丟失,當(dāng)時(shí)參與相應(yīng) case 開發(fā)的工程師可能已經(jīng)不在團(tuán)隊(duì)中,而項(xiàng)目相關(guān)的文檔可能也消失在了歸檔的 wiki 中(更多的項(xiàng)目可能完全沒有文檔),我們能夠在重構(gòu)中相信的東西其實(shí)只有當(dāng)前的代碼邏輯(很可能是錯(cuò)誤的)以及單元測(cè)試(很可能是沒有的)。

簡(jiǎn)單總結(jié)一下,單元測(cè)試的缺失不僅會(huì)意味著較低的工程質(zhì)量,而且意味著重構(gòu)的難以進(jìn)行,一個(gè)有單元測(cè)試的項(xiàng)目尚且不能夠保證重構(gòu)前后的邏輯完全相同,一個(gè)沒有單元測(cè)試的項(xiàng)目很可能本身的項(xiàng)目質(zhì)量就堪憂,更不用說如何在不丟失業(yè)務(wù)邏輯的情況下進(jìn)行重構(gòu)了

可測(cè)試

寫代碼并不是一件多困難的事情,不過想要在項(xiàng)目中寫出可以測(cè)試的代碼并不容易,而優(yōu)雅的代碼一定是可以測(cè)試的,我們?cè)谶@一節(jié)中需要討論的就是什么樣的代碼是可以測(cè)試的。

如果想要想清楚什么樣的才是可測(cè)試的,我們首先要知道測(cè)試是什么?作者對(duì)于測(cè)試的理解就是控制變量,在我們隔離了待測(cè)試方法中一些依賴之后,當(dāng)函數(shù)的入?yún)⒋_定時(shí),就應(yīng)該得到期望的返回值。

golang-unit-test

如何控制待測(cè)試方法中依賴的模塊是寫單元測(cè)試時(shí)至關(guān)重要的,控制依賴也就是對(duì)目標(biāo)函數(shù)的依賴進(jìn)行 Mock 消滅不確定性,為了減少每一個(gè)單元測(cè)試的復(fù)雜度,我們需要:

  1. 盡可能減少目標(biāo)方法的依賴,讓目標(biāo)方法只依賴必要的模塊;
  2. 依賴的模塊也應(yīng)該非常容易地進(jìn)行 Mock;

單元測(cè)試的執(zhí)行不應(yīng)該依賴于任何的外部模塊,無論是調(diào)用外部的 HTTP 請(qǐng)求還是數(shù)據(jù)庫中的數(shù)據(jù),我們都應(yīng)該想盡辦法模擬可能出現(xiàn)的情況,因?yàn)閱卧獪y(cè)試不是集成測(cè)試的,它的運(yùn)行不應(yīng)該依賴除項(xiàng)目代碼外的其他任何系統(tǒng)。

接口

在 Go 語言中如果我們完全不使用接口,是寫不出易于測(cè)試的代碼的,作為靜態(tài)語言的 Golang,只有我們使用接口才能脫離依賴具體實(shí)現(xiàn)的窘境,接口的使用能夠?yàn)槲覀儙砀逦某橄?,幫助我們思考如何?duì)代碼進(jìn)行設(shè)計(jì),也能讓我們更方便地對(duì)依賴進(jìn)行 Mock。

我們?cè)賮砘仡櫼幌律弦还?jié)對(duì)接口進(jìn)行介紹時(shí)展示的常見模式:

type Service interface { ... }

type service struct { ... }

func NewService(...) (Service, error) {
    return &service{...}, nil
}

上述代碼在 Go 語言中是非常常見的,如果你不知道應(yīng)不應(yīng)該使用接口對(duì)外提供服務(wù),這時(shí)就應(yīng)該無腦地使用上述模式對(duì)外暴露方法了,這種模式可以在絕大多數(shù)的場(chǎng)景下工作,至少作者到目前還沒有見到過不適用的。

函數(shù)簡(jiǎn)單

另一個(gè)建議就是保證每一個(gè)函數(shù)盡可能簡(jiǎn)單,這里的簡(jiǎn)單不止是指功能上的簡(jiǎn)單、單一,還意味著函數(shù)容易理解并且命名能夠自解釋。

一些語言的 lint 工具其實(shí)會(huì)對(duì)函數(shù)的理解復(fù)雜度(PerceivedComplexity)進(jìn)行檢查,也就是檢查函數(shù)中出現(xiàn)的 if/elseswitch/case 分支以及方法的調(diào)用的數(shù)量,一旦超過約定的閾值就會(huì)報(bào)錯(cuò),Ruby 社區(qū)中的 Rubocop 和上面提到的 golangci-lint 都有這個(gè)功能。

Ruby 社區(qū)中的 Rubocop 對(duì)于函數(shù)的長(zhǎng)度和理解復(fù)雜度都有著非常嚴(yán)格的限制,在默認(rèn)情況下函數(shù)的行數(shù)不能超過 10 行,理解復(fù)雜度也不能超過 7,除此之外,Rubocop 其實(shí)還有其他的復(fù)雜度限制,例如循環(huán)復(fù)雜度(CyclomaticComplexity),這些復(fù)雜度的限制都是為了保證函數(shù)的簡(jiǎn)單和容易理解。

組織方式

如何對(duì)測(cè)試進(jìn)行組織也是一個(gè)值得討論的話題,Golang 中的單元測(cè)試文件和代碼都是與源代碼放在同一個(gè)目錄下按照 package 進(jìn)行組織的,server.go 文件對(duì)應(yīng)的測(cè)試代碼應(yīng)該放在同一目錄下的 server_test.go 文件中。

如果文件不是以 _test.go 結(jié)尾,當(dāng)我們運(yùn)行 go test ./pkg 時(shí)就不會(huì)找到該文件中的測(cè)試用例,其中的代碼也就不會(huì)被執(zhí)行,這也是 Go 語言對(duì)于測(cè)試組織方法的一個(gè)約定。

Test

單元測(cè)試的最常見以及默認(rèn)組織方式就是寫在以 _test.go 結(jié)尾的文件中,所有的測(cè)試方法也都是以 Test 開頭并且只接受一個(gè) testing.T 類型的參數(shù):

func TestAuthor(t *testing.T) {
    author := blog.Author()
    assert.Equal(t, "draveness", author)
}

如果我們要給函數(shù)名為 Add 的方法寫單元測(cè)試,那么對(duì)應(yīng)的測(cè)試方法一般會(huì)被寫成 TestAdd,為了同時(shí)測(cè)試多個(gè)分支的內(nèi)容,我們可以通過以下的方式組織 Add 函數(shù)相關(guān)的測(cè)試:

func TestAdd(t *testing.T) {
    assert.Equal(t, 5, Add(2, 3))
}

func TestAddWithNegativeNumber(t *testing.T) {
    assert.Equal(t, -2, Add(-1, -1))
}

除了這種將一個(gè)函數(shù)相關(guān)的測(cè)試分散到多個(gè) Test 方法之外,我們可以使用 for 循環(huán)來減少重復(fù)的測(cè)試代碼,這在邏輯比較復(fù)雜的測(cè)試中會(huì)非常好用,能夠減少大量的重復(fù)代碼,不過也需要我們小心地進(jìn)行設(shè)計(jì):

func TestAdd(t *testing.T) {
    tests := []struct{
        name     string
        first    int64
        second   int64
        expected int64
    } {
        {
            name:     "HappyPath":
            first:    2,
            second:   3,
            expected: 5,
        },
        {
            name:     "NegativeNumber":
            first:    -1,
            second:   -1,
            expected: -2,
        },
    }
    
    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            assert.Equal(t, test.expected, Add(test.first, test.second))
        })
    }
}

這種方式其實(shí)也能生成樹形的測(cè)試結(jié)果,將 Add 相關(guān)的測(cè)試分成一組方便我們進(jìn)行觀察和理解,不過這種測(cè)試組織方法需要我們保證測(cè)試代碼的通用性,當(dāng)函數(shù)依賴的上下文較多時(shí)往往需要我們寫很多的 if/else 條件判斷語句影響我們對(duì)測(cè)試的快速理解。

作者通常會(huì)在測(cè)試代碼比較簡(jiǎn)單時(shí)使用第一種組織方式,而在依賴較多、函數(shù)功能較為復(fù)雜時(shí)使用第二種方式,不過這也不是定論,我們需要根據(jù)實(shí)際情況決定如何對(duì)測(cè)試進(jìn)行設(shè)計(jì)。

Suite

第二種比較常見的方式是按照簇進(jìn)行組織,其實(shí)就是對(duì) Go 語言默認(rèn)的測(cè)試方式進(jìn)行簡(jiǎn)單的封裝,我們可以使用 stretchr/testify 中的 suite 包對(duì)測(cè)試進(jìn)行組織:

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

type ExampleTestSuite struct {
    suite.Suite
    VariableThatShouldStartAtFive int
}

func (suite *ExampleTestSuite) SetupTest() {
    suite.VariableThatShouldStartAtFive = 5
}

func (suite *ExampleTestSuite) TestExample() {
    suite.Equal(suite.VariableThatShouldStartAtFive, 5)
}

func TestExampleTestSuite(t *testing.T) {
    suite.Run(t, new(ExampleTestSuite))
}

我們可以使用 suite 包,以結(jié)構(gòu)體的方式對(duì)測(cè)試簇進(jìn)行組織,suite 提供的 SetupTest/SetupSuiteTearDownTest/TearDownSuite 是執(zhí)行測(cè)試前后以及執(zhí)行測(cè)試簇前后的鉤子方法,我們能在其中完成一些共享資源的初始化,減少測(cè)試中的初始化代碼。

BDD

最后一種組織代碼的方式就是使用 BDD 的風(fēng)格對(duì)單元測(cè)試進(jìn)行組織,ginkgo 就是 Golang 社區(qū)最常見的 BDD 框架了,這里提到的行為驅(qū)動(dòng)開發(fā)(BDD)和測(cè)試驅(qū)動(dòng)開發(fā)(TDD)都是一種保證工程質(zhì)量的方法論。想要在項(xiàng)目中實(shí)踐這種思想還是需要一些思維上的轉(zhuǎn)變和適應(yīng),也就是先通過寫單元測(cè)試或者行為測(cè)試約定方法的 Spec,再實(shí)現(xiàn)方法讓我們的測(cè)試通過,這是一種比較科學(xué)的方法,它能為我們帶來比較強(qiáng)的信心。

我們雖然不一定要使用 BDD/TDD 的思想對(duì)項(xiàng)目進(jìn)行開發(fā),但是卻可以使用 BDD 的風(fēng)格方式組織非常易讀的測(cè)試代碼:

var _ = Describe("Book", func() {
    var (
        book Book
        err error
    )

    BeforeEach(func() {
        book, err = NewBookFromJSON(`{
            "title":"Les Miserables",
            "author":"Victor Hugo",
            "pages":1488
        }`)
    })

    Describe("loading from JSON", func() {
        Context("when the JSON fails to parse", func() {
            BeforeEach(func() {
                book, err = NewBookFromJSON(`{
                    "title":"Les Miserables",
                    "author":"Victor Hugo",
                    "pages":1488oops
                }`)
            })

            It("should return the zero-value for the book", func() {
                Expect(book).To(BeZero())
            })

            It("should error", func() {
                Expect(err).To(HaveOccurred())
            })
        })
    })
})

BDD 框架中一般都包含 DescribeContext 以及 It 等代碼塊,其中 Describe 的作用是描述代碼的獨(dú)立行為、Context 是在一個(gè)獨(dú)立行為中的多個(gè)不同上下文,最后的 It 用于描述期望的行為,這些代碼塊最終都構(gòu)成了類似『描述......,當(dāng)......時(shí),它應(yīng)該......』的句式幫助我們快速地理解測(cè)試代碼。

Mock 方法

項(xiàng)目中的單元測(cè)試應(yīng)該是穩(wěn)定的并且不依賴任何的外部項(xiàng)目,它只是對(duì)項(xiàng)目中函數(shù)和方法的測(cè)試,所以我們需要在單元測(cè)試中對(duì)所有的第三方的不穩(wěn)定依賴進(jìn)行 Mock,也就是模擬這些第三方服務(wù)的接口;除此之外,為了簡(jiǎn)化一次單元測(cè)試的上下文,在同一個(gè)項(xiàng)目中我們也會(huì)對(duì)其他模塊進(jìn)行 Mock,模擬這些依賴模塊的返回值。

單元測(cè)試的核心就是隔離依賴并驗(yàn)證輸入和輸出的正確性,Go 語言作為一個(gè)靜態(tài)語言提供了比較少的運(yùn)行時(shí)特性,這也讓我們?cè)?Go 語言中 Mock 依賴變得非常困難。

Mock 的主要作用就是保證待測(cè)試方法依賴的上下文固定,在這時(shí)無論我們對(duì)當(dāng)前方法運(yùn)行多少次單元測(cè)試,如果業(yè)務(wù)邏輯不改變,它都應(yīng)該返回完全相同的結(jié)果,在具體介紹 Mock 的不同方法之前,我們首先要清楚一些常見的依賴,一個(gè)函數(shù)或者方法的常見依賴可以有以下幾種:

  1. 接口
  2. 數(shù)據(jù)庫
  3. HTTP 請(qǐng)求
  4. Redis、緩存以及其他依賴

這些不同的場(chǎng)景基本涵蓋了寫單元測(cè)試時(shí)會(huì)遇到的情況,我們會(huì)在接下來的內(nèi)容中分別介紹如何處理以上幾種不同的依賴。

接口

首先要介紹的其實(shí)就是 Go 語言中最常見也是最通用的 Mock 方法,也就是能夠?qū)涌谶M(jìn)行 Mock 的 golang/mock 框架,它能夠根據(jù)接口生成 Mock 實(shí)現(xiàn),假設(shè)我們有以下代碼:

package blog

type Post struct {}

type Blog interface {
    ListPosts() []Post
}

type jekyll struct {}

func (b *jekyll) ListPosts() []Post {
    return []Post{}
}

type wordpress struct{}

func (b *wordpress) ListPosts() []Post {
    return []Post{}
}

我們的博客可能使用 jekyll 或者 wordpress 作為引擎,但是它們都會(huì)提供 ListsPosts 方法用于返回全部的文章列表,在這時(shí)我們就需要定義一個(gè) Post 接口,接口要求遵循 Blog 的結(jié)構(gòu)體必須實(shí)現(xiàn) ListPosts 方法。

golang-interface-blog-example

當(dāng)我們定義好了 Blog 接口之后,上層 Service 就不再需要依賴某個(gè)具體的博客引擎實(shí)現(xiàn)了,只需要依賴 Blog 接口就可以完成對(duì)文章的批量獲取功能:

package service

type Service interface {
    ListPosts() ([]Post, error)
}

type service struct {
    blog blog.Blog
}

func NewService(b blog.Blog) *Service {
    return &service{
        blog: b,
    }
}

func (s *service) ListPosts() ([]Post, error) {
    return s.blog.ListPosts(), nil
}

如果我們想要對(duì) Service 進(jìn)行測(cè)試,我們就可以使用 gomock 提供的 mockgen 工具命令生成 MockBlog 結(jié)構(gòu)體,使用如下所示的命令:

$ mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go

$ cat test/mocks/blog/blog.go
// Code generated by MockGen. DO NOT EDIT.
// Source: blog.go

// Package mblog is a generated GoMock package.
...
// NewMockBlog creates a new mock instance
func NewMockBlog(ctrl *gomock.Controller) *MockBlog {
    mock := &MockBlog{ctrl: ctrl}
    mock.recorder = &MockBlogMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockBlog) EXPECT() *MockBlogMockRecorder {
    return m.recorder
}

// ListPosts mocks base method
func (m *MockBlog) ListPosts() []Post {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "ListPosts")
    ret0, _ := ret[0].([]Post)
    return ret0
}

// ListPosts indicates an expected call of ListPosts
func (mr *MockBlogMockRecorder) ListPosts() *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPosts", reflect.TypeOf((*MockBlog)(nil).ListPosts))
}

這段 mockgen 生成的代碼非常長(zhǎng)的,所以我們只展示了其中的一部分,它的功能就是幫助我們驗(yàn)證任意接口的輸入?yún)?shù)并且模擬接口的返回值;而在生成 Mock 實(shí)現(xiàn)的過程中,作者總結(jié)了一些可以分享的經(jīng)驗(yàn):

  1. test/mocks 目錄中放置所有的 Mock 實(shí)現(xiàn),子目錄與接口所在文件的二級(jí)目錄相同,在這里源文件的位置在 pkg/blog/blog.go,它的二級(jí)目錄就是 blog/,所以對(duì)應(yīng)的 Mock 實(shí)現(xiàn)會(huì)被生成到 test/mocks/blog/ 目錄中;

  2. 指定 packagemxxx,默認(rèn)的 mock_xxx 看起來非常冗余,上述 blog 包對(duì)應(yīng)的 Mock 包也就是 mblog

  3. mockgen 命令放置到 Makefile 中的 mock 下統(tǒng)一管理,減少祖?zhèn)髅畹某霈F(xiàn);

    mock:
        rm -rf test/mocks
        
        mkdir -p test/mocks/blog
        mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go
    

當(dāng)我們生成了上述的 Mock 實(shí)現(xiàn)代碼之后,就可以使用如下的方式為 Service 寫單元測(cè)試了,這段代碼通過 NewMockBlog 生成一個(gè) Blog 接口的 Mock 實(shí)現(xiàn),然后通過 EXPECT 方法控制該實(shí)現(xiàn)會(huì)在調(diào)用 ListPosts 時(shí)返回空的 Post 數(shù)組:


func TestListPosts(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockBlog := mblog.NewMockBlog(ctrl)
    mockBlog.EXPECT().ListPosts().Return([]Post{})
  
    service := NewService(mockBlog)
  
    assert.Equal(t, []Post{}, service.ListPosts())
}

由于當(dāng)前 Service 只依賴于 Blog 的實(shí)現(xiàn),所以在這時(shí)我們就能夠斷言當(dāng)前方法一定會(huì)返回 []Post{},這時(shí)我們的方法的返回值就只與傳入的參數(shù)有關(guān)(雖然 ListPosts 方法沒有入?yún)?,我們能夠減少一次關(guān)注的上下文并保證測(cè)試的穩(wěn)定和可信。

這是 Go 語言中最標(biāo)準(zhǔn)的單元測(cè)試寫法,所有依賴的 package 無論是項(xiàng)目?jī)?nèi)外都應(yīng)該使用這種方式處理(在有接口的情況下),如果沒有接口 Go 語言的單元測(cè)試就會(huì)非常難寫,這也是為什么從項(xiàng)目中是否有接口就能判斷工程質(zhì)量的原因了。

SQL

另一個(gè)項(xiàng)目中比較常見的依賴其實(shí)就是數(shù)據(jù)庫,在遇到數(shù)據(jù)庫的依賴時(shí),我們一般都會(huì)使用 sqlmock 來模擬數(shù)據(jù)庫的連接,當(dāng)我們使用 sqlmock 時(shí)會(huì)寫出如下所示的單元測(cè)試:

func (s *suiteServerTester) TestRemovePost() {
    entry := pb.Post{
        Id: 1,
    }

    rows := sqlmock.NewRows([]string{"id", "author"}).AddRow(1, "draveness")

    s.Mock.ExpectQuery(`SELECT (.+) FROM "posts"`).WillReturnRows(rows)
    s.Mock.ExpectExec(`DELETE FROM "posts"`).
        WithArgs(1).
        WillReturnResult(sqlmock.NewResult(1, 1))

    response, err := s.server.RemovePost(context.Background(), &entry)

    s.NoError(err)
    s.EqualValues(response, &entry)
    s.NoError(s.Mock.ExpectationsWereMet())
}

最常用的幾個(gè)方法就是 ExpectQueryExpectExec,前者主要用于模擬 SQL 的查詢語句,后者用于模擬 SQL 的增刪,從上面的實(shí)例中我們可以看到這個(gè)這兩種方法的使用方式,建議各位先閱讀相關(guān)的 文檔 再嘗試使用。

HTTP

HTTP 請(qǐng)求也是我們?cè)陧?xiàng)目中經(jīng)常會(huì)遇到的依賴,httpmock 就是一個(gè)用于 Mock 所有 HTTP 依賴的包,它使用模式匹配的方式匹配 HTTP 請(qǐng)求的 URL,在匹配到特定的請(qǐng)求時(shí)就會(huì)返回預(yù)先設(shè)置好的響應(yīng)。

func TestFetchArticles(t *testing.T) {
    httpmock.Activate()
    defer httpmock.DeactivateAndReset()

    httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles",
        httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`))

    httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`,
        httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`))

    ...
}

如果遇到 HTTP 請(qǐng)求的依賴時(shí),就可以使用上述 httpmock 包模擬依賴的 HTTP 請(qǐng)求。

猴子補(bǔ)丁

最后要介紹的猴子補(bǔ)丁其實(shí)就是一個(gè)大殺器了,bouk/monkey 能夠通過替換函數(shù)指針的方式修改任意函數(shù)的實(shí)現(xiàn),所以如果上述的幾種方法都不能滿足我們的需求,我們就只能夠通過猴子補(bǔ)丁這種比較 hack 的方法 Mock 依賴了:

func main() {
    monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
        s := make([]interface{}, len(a))
        for i, v := range a {
            s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
        }
        return fmt.Fprintln(os.Stdout, s...)
    })
    fmt.Println("what the hell?") // what the *bleep*?
}

然而這種方法的使用其實(shí)有一些限制,由于它是在運(yùn)行時(shí)替換了函數(shù)的指針,所以如果遇到一些簡(jiǎn)單的函數(shù),例如 rand.Int63ntime.Now,編譯器可能會(huì)直接將這種函數(shù)內(nèi)聯(lián)到調(diào)用實(shí)際發(fā)生的代碼處并不會(huì)調(diào)用原有的方法,所以使用這種方式往往需要我們?cè)跍y(cè)試時(shí)額外指定 -gcflags=-l 禁止編譯器的內(nèi)聯(lián)優(yōu)化。

$ go test -gcflags=-l ./...

bouk/monkey 的 README 對(duì)于它的使用給出了一些注意事項(xiàng),除了內(nèi)聯(lián)編譯之外,我們需要注意的是不要在單元測(cè)試之外的地方使用猴子補(bǔ)丁,我們應(yīng)該只在必要的時(shí)候使用這種方法,例如依賴的第三方庫沒有提供 interface 或者修改 time.Now 以及 rand.Int63n 等內(nèi)置函數(shù)的返回值用于測(cè)試時(shí)。

從理論上來說,通過猴子補(bǔ)丁這種方式我們能夠在運(yùn)行時(shí) Mock Go 語言中的一切函數(shù),這也為我們提供了單元測(cè)試 Mock 依賴的最終解決方案。

斷言

在最后,我們簡(jiǎn)單介紹一下輔助單元測(cè)試的 assert 包,它提供了非常多的斷言方法幫助我們快速對(duì)期望的返回值進(jìn)行測(cè)試,減少我們的工作量:

func TestSomething(t *testing.T) {
  assert.Equal(t, 123, 123, "they should be equal")

  assert.NotEqual(t, 123, 456, "they should not be equal")

  assert.Nil(t, object)

  if assert.NotNil(t, object) {
    assert.Equal(t, "Something", object.Value)
  }
}

在這里我們也是簡(jiǎn)單展示一下 assert 的示例,更詳細(xì)的內(nèi)容可以閱讀它的相關(guān)文檔,在這里也就不多做展示了。

小結(jié)

如果之前完全沒有寫過單元測(cè)試或者沒有寫過 Go 語言的單元測(cè)試,相信這篇文章已經(jīng)給了足夠多的上下文幫助我們開始做這件事情,我們要知道的是單元測(cè)試其實(shí)并不會(huì)阻礙我們的開發(fā)進(jìn)度,它能夠?yàn)槲覀兊纳暇€提供信心,也是質(zhì)量保證上投資回報(bào)率最高的方法。

學(xué)習(xí)寫好單元測(cè)試一定會(huì)有一些學(xué)習(xí)曲線和不適應(yīng),甚至?xí)诙唐趦?nèi)影響我們的開發(fā)效率,但是熟悉了這一套流程和接口之后,單元測(cè)試對(duì)我們的幫助會(huì)非常大,每一個(gè)單元測(cè)試都表示一個(gè)業(yè)務(wù)邏輯,每次提交時(shí)執(zhí)行單元測(cè)試就能夠幫助我們確定新的代碼大概率上不會(huì)影響已有的業(yè)務(wù)邏輯,能夠明顯地降低重構(gòu)的風(fēng)險(xiǎn)以及線上事故的數(shù)量

總結(jié)

在這篇文章中我們從三個(gè)方面分別介紹了如何寫優(yōu)雅的 Go 語言代碼,作者盡可能地給出了最容易操作和最有效的方法:

  • 代碼規(guī)范:使用輔助工具幫助我們?cè)诿看翁峤?PR 時(shí)自動(dòng)化地對(duì)代碼進(jìn)行檢查,減少工程師人工審查的工作量;
  • 最佳實(shí)踐
    • 目錄結(jié)構(gòu):遵循 Go 語言社區(qū)中被廣泛達(dá)成共識(shí)的 目錄結(jié)構(gòu),減少項(xiàng)目的溝通成本;
    • 模塊拆分:按照職責(zé)對(duì)不同的模塊進(jìn)行拆分,Go 語言的項(xiàng)目中也不應(yīng)該出現(xiàn) model、controller 這種違反語言頂層設(shè)計(jì)思路的包名;
    • 顯示與隱式:盡可能地消滅項(xiàng)目中的 init 函數(shù),保證顯式地進(jìn)行方法的調(diào)用以及錯(cuò)誤的處理;
    • 面向接口:面向接口是 Go 語言鼓勵(lì)的開發(fā)方式,也能夠?yàn)槲覀儗憜卧獪y(cè)試提供方便,我們應(yīng)該遵循固定的模式對(duì)外提供功能;
      1. 使用大寫的 Service 對(duì)外暴露方法;
      2. 使用小寫的 service 實(shí)現(xiàn)接口中定義的方法;
      3. 通過 func NewService(...) (Service, error) 函數(shù)初始化 Service 接口;
  • 單元測(cè)試:保證項(xiàng)目工程質(zhì)量的最有效辦法;
    • 可測(cè)試:意味著面向接口編程以及減少單個(gè)函數(shù)中包含的邏輯,使用『小方法』;
    • 組織方式:使用 Go 語言默認(rèn)的 Test 框架、開源的 suite 或者 BDD 的風(fēng)格對(duì)單元測(cè)試進(jìn)行合理組織;
    • Mock 方法:四種不同的單元測(cè)試 Mock 方法;
      • gomock:最標(biāo)準(zhǔn)的也是最被鼓勵(lì)的方式;
      • sqlmock:處理依賴的數(shù)據(jù)庫;
      • httpmock:處理依賴的 HTTP 請(qǐng)求;
      • monkey:萬能的方法,但是只在萬不得已時(shí)使用,類似的代碼寫起來非常冗長(zhǎng)而且不直觀;
    • 斷言:使用社區(qū)的 testify 快速驗(yàn)證方法的返回值;

想要寫出優(yōu)雅的代碼本身就不是一件容易的事情,它需要我們不斷地對(duì)自己的知識(shí)體系進(jìn)行更新和優(yōu)化,推倒之前的經(jīng)驗(yàn)并對(duì)項(xiàng)目持續(xù)進(jìn)行完善和重構(gòu),而只有真正經(jīng)過思考和設(shè)計(jì)的代碼才能夠經(jīng)過時(shí)間的檢驗(yàn)(代碼是需要不斷重構(gòu)的),隨意堆砌代碼的行為是不能鼓勵(lì)也不應(yīng)該發(fā)生的,每一行代碼都應(yīng)該按照最高的標(biāo)準(zhǔn)去設(shè)計(jì)和開發(fā),這是我們保證工程質(zhì)量的唯一方法。

作者也一直在努力學(xué)習(xí)如何寫出更加優(yōu)雅的代碼,寫出好的代碼真的不是一件容易的事情,作者也希望能通過這篇文章幫助使用 Go 語言的工程師寫出更有 Golang 風(fēng)格的項(xiàng)目。

Reference

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 環(huán)境搭建 Golang在Mac OS上的環(huán)境配置 使用Visual Studio Code輔助Go源碼編寫 VS ...
    隕石墜滅閱讀 5,846評(píng)論 0 5
  • 從百度文庫下載下來的,這里保存一份 別人的原代碼程序員怎樣閱讀 源碼就是指編寫的最原始程序的代碼。 運(yùn)行的軟件是要...
    Albert陳凱閱讀 3,475評(píng)論 0 15
  • 小W和小Z,是我去年校園招聘認(rèn)識(shí)的兩個(gè)男孩,都是應(yīng)屆碩士畢業(yè)生。 兩個(gè)人都是普通家庭的孩子,學(xué)校都很不錯(cuò),小W是河...
    漫姐的生活筆記閱讀 452評(píng)論 7 0
  • 《重逢》 詩/長(zhǎng)松 . 還是 那一陣 暖風(fēng) 微微吹送 一池寂靜 心情 . 恍若隔世 畫面里 卻依然溫馨 從容 . ...
    長(zhǎng)松閱讀 170評(píng)論 0 0
  • 今天開啟新的一天
    小雪最機(jī)智閱讀 83評(píng)論 0 0

友情鏈接更多精彩內(nèi)容