Go 語言是一門簡單、易學的編程語言,對於有編程背景的工程師來講,學習 Go 語言並寫出可以運行的代碼並非一件困難的事情,對於以前有過其餘語言經驗的開發者來講,寫什麼語言都像本身學過的語言實際上是有問題的,想要真正融入生態寫出優雅的代碼就必定要花一些時間和精力瞭解語言背後的設計哲學和最佳實踐。html
若是你以前沒有 Go 語言的開發經歷,正在學習和使用 Go 語言,相信這篇文章可以幫助你更快地寫出優雅的 Go 語言代碼;在這篇文章中,咱們並不會給一個長長地列表介紹變量、方法和結構體應該怎麼命名,這些 Go 語言的代碼規範能夠在 Go Code Review Comments 中找到,它們很是重要但並非這篇文章想要介紹的重點,咱們將從代碼結構、最佳實踐以及單元測試幾個不一樣的方面介紹如何寫出優雅的 Go 語言代碼。git
想要寫出好的代碼並非一件容易的事情,它須要咱們不斷地對現有的代碼進行反思 — 如何改寫這段代碼才能讓它變得更加優雅。優雅聽起來是一個很是感性、難以量化的結果,然而這倒是好的代碼可以帶來的最直觀感覺,它可能隱式地包含了如下特性:github
相信讀完了這篇文章,咱們也不能馬上寫出優雅的 Go 語言代碼,可是若是咱們遵循這裏介紹幾個的容易操做而且切實可行的方法,就幫助咱們走出第一步,做者寫這篇文章有如下的幾個目的:golang
代碼規範實際上是一個老生常態的問題,咱們也不能免俗仍是要簡單介紹一下相關的內容,Go 語言比較常見而且使用普遍的代碼規範就是官方提供的 Go Code Review Comments,不管你是短時間仍是長期使用 Go 語言編程,都應該至少完整地閱讀一遍這個官方的代碼規範指南,它既是咱們在寫代碼時應該遵照的規則,也是在代碼審查時須要注意的規範。web
學習 Go 語言相關的代碼規範是一件很是重要的事情,也是讓咱們的項目遵循統一規範的第一步,雖然閱讀代碼規範相關的文檔很是重要,可是在實際操做時咱們並不能靠工程師自覺地遵照以及常常被當作形式的代碼審查,而是須要藉助工具來輔助執行。sql
使用自動化的工具保證項目遵照一些最基本的代碼規範是很是容易操做和有效的事情,相比之下人肉審查代碼的方式更加容易出錯,也會出現一些違反規則和約定的特例,維護代碼規範的最好方式就是『儘可能自動化一切可以自動化的步驟,讓工程師審查真正重要的邏輯和設計』。數據庫
咱們在這一節中就會介紹兩種很是切實有效的辦法幫助咱們在項目中自動化地進行一些代碼規範檢查和靜態檢查保證項目的質量。編程
goimports 是 Go 語言官方提供的工具,它可以爲咱們自動格式化 Go 語言代碼並對全部引入的包進行管理,包括自動增刪依賴的包引用、將依賴包按字母序排序並分類。相信不少人使用的 IDE 都會將另外一個官方提供的工具 gofmt 對代碼進行格式化,而 goimports
就是等於 gofmt
加上依賴包管理。api
建議全部 Go 語言的開發者都在開發時使用 goimports
,雖然 goimports
有時會引入錯誤的包,可是與帶來的好處相比,這些偶爾出現的錯誤在做者看來也是能夠接受的;固然,不想使用 goimports
的開發者也必定要在 IDE 或者編輯器中開啓自動地 gofmt
(保存時自動格式化)。數組
在 IDE 和 CI 檢查中開啓自動地
gofmt
或者goimports
檢查是沒有、也不該該有討論的必要的,這就是一件使用和開發 Go 語言必需要作的事情。
另外一個比較經常使用的靜態檢查工具就是 golint
了,做爲官方提供的工具,它在可定製化上有着很是差的支持,咱們只能經過以下所示的方式運行 golint
對咱們的項目進行檢查:
$ 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 ...
社區上有關於 golint
定製化的 討論,golint
的開發者給出瞭如下的幾個觀點解釋爲何 golint
不支持定製化的功能:
lint
的目的就是在 Go 語言社區中鼓勵統1、一致的編程風格,某些開發者也許不會贊成其中的某些規範,可是使用統一的風格對於 Go 語言社區有比較強的好處,而可以開關指定規則的功能會致使golint
不可以有效地完成這個工做;- 有一些靜態檢查的規則會致使一些錯誤的警告,這些狀況確實很是讓人頭疼,可是我會選擇支持在 golint 中直接保留或者刪除這些規則,而不是提供隨意增刪規則的能力;
- 可以經過
min_confidence
過濾一些靜態檢查規則,可是須要咱們選擇合適的值;
golint
做者的觀點在 issue 中獲得了很是多的 👎,可是這件事情很難說對錯;在社區中保證一致的編程規範是一件很是有益的事情,不過對於不少公司內部的服務或者項目,可能在業務服務上就會發生一些比較棘手的狀況,使用這種過強的約束沒有太多明顯地收益。
更推薦的方法是在基礎庫或者框架中使用 golint
進行靜態檢查(或者同時使用 golint
和 golangci-lint),在其餘的項目中使用可定製化的 golangci-lint
來進行靜態檢查,由於在基礎庫和框架中施增強限制對於總體的代碼質量有着更大的收益。
做者會在本身的 Go 項目中使用
golint
+golangci-lint
並開啓所有的檢查儘可能儘早發現代碼中包含文檔在內的所有缺陷。
不管是用於檢查代碼規範和依賴包的 goimports
仍是靜態檢查工具 glint
或者 golangci-lint
,只要咱們在項目中引入這些工具就必定要在代碼的 CI 流程中加入對應的自動化檢查:
在自建的或者其餘的代碼託管平臺上也應該想盡辦法尋找合適的工具,現代的代碼託管工具應該都會對 CI/CD 有着很是不錯的支持;咱們須要經過這些 CI 工具將代碼的自動化檢查變成 PR 合併和發版的一個前置條件,減小工程師 Review 代碼時可能發生的疏漏。
咱們在上一節中介紹了一些能經過自動化工具發現的問題,這一節提到的最佳實踐可能就沒有辦法經過自動化工具進行保證,這些最佳實踐更像是 Go 語言社區內部發展過程當中積累的一些工程經驗和共識,遵循這些最佳實踐可以幫助咱們寫出符合 Go 語言『味道』的代碼,咱們將在這一小節覆蓋如下的幾部份內容:
這四部份內容是在社區中相對來講比較常見的約定,若是咱們學習並遵循了這些約定,同時在 Go 語言的項目中實踐這幾部份內容,相信必定會對咱們設計 Go 語言項目有所幫助。
目錄結構基本上就是一個項目的門面,不少時候咱們從目錄結構中就可以看出開發者對這門語言是否有足夠的經驗,因此在這裏首先要介紹的最佳實踐就是如何在 Go 語言的項目或者服務中組織代碼。
官方並無給出一個推薦的目錄劃分方式,不少項目對於目錄結構的劃分也很是隨意,這其實也是沒有什麼問題的,可是社區中仍是有一些比較常見的約定,例如:golang-standards/project-layout 項目中就定義了一個比較標準的目錄結構。
├── 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
咱們在這裏就先簡單介紹其中幾個比較常見而且重要的目錄和文件,幫助咱們快速理解如何使用如上所示的目錄結構,若是各位讀者想要了解使用其餘目錄的緣由,能夠從 golang-standards/project-layout 項目中的 README 瞭解更詳細的內容。
/pkg
目錄是 Go 語言項目中很是常見的目錄,咱們幾乎可以在全部知名的開源項目(非框架)中找到它的身影,例如:
這個目錄中存放的就是項目中能夠被外部應用使用的代碼庫,其餘的項目能夠直接經過 import
引入這裏的代碼,因此當咱們將代碼放入 pkg
時必定要慎重,不過若是咱們開發的是 HTTP 或者 RPC 的接口服務或者公司的內部服務,將私有和公有的代碼都放到 /pkg
中也沒有太多的不妥,由於做爲最頂層的項目來講不多會被其餘應用直接依賴,固然嚴格遵循公有和私有代碼劃分是很是好的作法,做者也建議各位開發者對項目中公有和私有的代碼進行妥善的劃分。
私有代碼推薦放到 /internal
目錄中,真正的項目代碼應該寫在 /internal/app
裏,同時這些內部應用依賴的代碼庫應該在 /internal/pkg
子目錄和 /pkg
中,下圖展現了一個使用 /internal
目錄的項目結構:
當咱們在其餘項目引入包含 internal
的依賴時,Go 語言會在編譯時報錯:
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.
這種錯誤只有在被引入的 internal
包不存在於當前項目樹中才會發生,若是在同一個項目中引入該項目的 internal
包並不會出現這種錯誤。
在 Go 語言的項目最不該該有的目錄結構其實就是 /src
了,社區中的一些項目確實有 /src
文件夾,可是這些項目的開發者以前大多數都有 Java 的編程經驗,這在 Java 和其餘語言中實際上是一個比較常見的代碼組織方式,可是做爲一個 Go 語言的開發者,咱們不該該容許項目中存在 /src
目錄。
最重要的緣由實際上是 Go 語言的項目在默認狀況下都會被放置到 $GOPATH/src
目錄下,這個目錄中存儲着咱們開發和依賴的所有項目代碼,若是咱們在本身的項目中使用 /src
目錄,該項目的 PATH
中就會出現兩個 src
:
$GOPATH/src/github.com/draveness/project/src/code.go
上面的目錄結構看起來很是奇怪,這也是咱們在 Go 語言中不建議使用 /src
目錄的最重要緣由。
固然哪怕咱們在 Go 語言的項目中使用 /src
目錄也不會致使編譯不經過或者其餘問題,若是堅持這種作法對於項目的可用性也沒有任何的影響,可是若是想讓咱們『看起來』更專業,仍是遵循社區中既定的約定減小其餘 Go 語言開發者的理解成本,這對於社區來講是一件好事。
另外一種在 Go 語言中組織代碼的方式就是項目的根目錄下放項目的代碼,這種方式在不少框架或者庫中很是常見,若是想要引入一個使用 pkg
目錄結構的框架時,咱們每每須要使用 github.com/draveness/project/pkg/somepkg
,當代碼都平鋪在項目的根目錄時只須要使用 github.com/draveness/project
,很明顯地減小了引用依賴包語句的長度。
因此對於一個 Go 語言的框架或者庫,將代碼平鋪在根目錄下也很正常,可是在一個 Go 語言的服務中使用這種代碼組織方法可能就沒有那麼合適了。
/cmd
目錄中存儲的都是當前項目中的可執行文件,該目錄下的每個子目錄都應該包含咱們但願有的可執行文件,若是咱們的項目是一個 grpc
服務的話,可能在 /cmd/server/main.go
中就包含了啓動服務進程的代碼,編譯後生成的可執行文件就是 server
。
咱們不該該在 /cmd
目錄中放置太多的代碼,咱們應該將公有代碼放置到 /pkg
中並將私有代碼放置到 /internal
中並在 /cmd
中引入這些包,保證 main
函數中的代碼儘量簡單和少。
/api
目錄中存放的就是當前項目對外提供的各類不一樣類型的 API 接口定義文件了,其中可能包含相似 /api/protobuf-spec
、/api/thrift-spec
或者 /api/http-spec
的目錄,這些目錄中包含了當前項目對外提供的和依賴的全部 API 文件:
$ tree ./api api └── protobuf-spec └── oceanbookpb ├── oceanbook.pb.go └── oceanbook.proto
二級目錄的主要做用就是在一個項目同時提供了多種不一樣的訪問方式時,用這種辦法避免可能存在的潛在衝突問題,也可讓項目結構的組織更加清晰。
最後要介紹的 Makefile
文件也很是值得被關注,在任何一個項目中都會存在一些須要運行的腳本,這些腳本文件應該被放到 /scripts
目錄中並由 Makefile
觸發,將這些常常須要運行的命令固化成腳本減小『祖傳命令』的出現。
總的來講,每個項目都應該按照固定的組織方式進行實現,這種約定雖然並非強制的,可是不管是組內、公司內仍是整個 Go 語言社區中,只要達成了一致,對於其餘工程師快速梳理和理解項目都是頗有幫助的。
這一節介紹的 Go 語言項目的組織方式也並非強制要求的,這只是 Go 語言社區中常常出現的項目組織方式,一個大型項目在使用這種目錄結構時也會對其進行微調,不過這種組織方式確實更爲常見而且合理。
咱們既然已經介紹過了如何從頂層對項目的結構進行組織,接下來就會深刻到項目的內部介紹 Go 語言對模塊的一些拆分方法。
Go 語言的一些頂層設計最終致使了它在劃分模塊上與其餘的編程語言有着很是明顯的不一樣,不少其餘語言的 Web 框架都採用 MVC 的架構模式,例如 Rails 和 Spring MVC,Go 語言對模塊劃分的方法就與 Ruby 和 Java 徹底不一樣。
不管是 Java 仍是 Ruby,它們最著名的框架都深受 MVC 架構模式 的影響,咱們從 Spring MVC 的名字中就能體會到 MVC 對它的影響,而 Ruby 社區的 Rails 框架也與 MVC 的關係很是緊密,這是一種 Web 框架的最多見架構方式,將服務中的不一樣組件分紅了 Model、View 和 Controller 三層。
這種模塊拆分的方式其實就是按照層級進行拆分,Rails 腳手架默認生成的代碼其實就是將這三層不一樣的源文件放在對應的目錄下:models
、views
和 controllers
,咱們經過 rails new example
生成一個新的 Rails 項目後能夠看到其中的目錄結構:
$ tree -L 2 app app ├── controllers │ ├── application_controller.rb │ └── concerns ├── models │ ├── application_record.rb │ └── concerns └── views └── layouts
而不少 Spring MVC 的項目中也會出現相似 model
、dao
、view
的目錄,這種按層拆分模塊的設計其實有如下的幾方面緣由:
上面的幾個緣由共同決定了 Spring MVC 和 Rails 會出現 models
、views
和 controllers
的目錄並按照層級的方式對模塊進行拆分。
Go 語言在拆分模塊時就使用了徹底不一樣的思路,雖然 MVC 架構模式是在咱們寫 Web 服務時沒法避開的,可是相比於橫向地切分不一樣的層級,Go 語言的項目每每都按照職責對模塊進行拆分:
對於一個比較常見的博客系統,使用 Go 語言的項目會按照不一樣的職責將其縱向拆分紅 post
、user
、comment
三個模塊,每個模塊都對外提供相應的功能,post
模塊中就包含相關的模型和視圖定義以及用於處理 API 請求的控制器(或者服務):
$ tree pkg
pkg
├── comment
├── post
│ ├── handler.go │ └── post.go └── user
Go 語言項目中的每個文件目錄都表明着一個獨立的命名空間,也就是一個單獨的包,當咱們想要引用其餘文件夾的目錄時,首先須要使用 import
關鍵字引入相應的文件目錄,再經過 pkg.xxx
的形式引用其餘目錄定義的結構體、函數或者常量,若是咱們在 Go 語言中使用 model
、view
和 controller
來劃分層級,你會在其餘的模塊中看到很是多的 model.Post
、model.Comment
和 view.PostView
。
這種劃分層級的方法在 Go 語言中會顯得很是冗餘,而且若是對項目依賴包的管理不夠謹慎時,很容易發生引用循環,出現這些問題的最根本緣由其實也很是簡單:
package
拆出去,對這部分性能熱點單獨進行擴容;項目是按照層級仍是按照職責對模塊進行拆分其實並無絕對的好與很差,語言和框架層面的設計最終決定了咱們應該採用哪一種方式對項目和代碼進行組織。
Java 和 Ruby 這些語言在框架中每每採用水平拆分的方式劃分不一樣層級的職責,而 Go 語言項目的最佳實踐就是按照職責對模塊進行垂直拆分,將代碼按照功能的方式分到多個 package
中,這並非說 Go 語言中不存在模塊的水平拆分,只是由於 package
做爲一個 Go 語言訪問控制的最小粒度,因此咱們應該遵循頂層的設計使用這種方式構建高內聚的模塊。
從開始學習、使用 Go 語言到參與社區上一些開源的 Golang 項目,做者發現 Go 語言社區對於顯式的初始化、方法調用和錯誤處理很是推崇,相似 Spring Boot 和 Rails 的框架其實都普遍地採納了『約定優於配置』的中心思想,簡化了開發者和工程師的工做量。
然而 Go 語言社區雖然達成了不少的共識與約定,可是從語言的設計以及工具上的使用咱們就能發現顯式地調用方法和錯誤處理是被鼓勵的。
咱們在這裏先以一個很是常見的函數 init
爲例,介紹 Go 語言社區對顯式調用的推崇;相信不少人都在一些 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
函數其實隱式地初始化了 grpc 的鏈接資源,若是另外一個 package
依賴了當前的包,那麼引入這個依賴的工程師可能會在遇到錯誤時很是困惑,由於在 init
函數中作這種資源的初始化是很是耗時而且容易出現問題的。
一種更加合理的作法實際上是這樣的,首先咱們定義一個新的 Client
結構體以及一個用於初始化結構的 NewClient
函數,這個函數接收了一個 grpc 鏈接做爲入參返回一個用於獲取 Post
資源的客戶端,GetPost
成爲了這個結構體的方法,每當咱們調用 client.GetPost
時都會用到結構體中保存的 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 鏈接的代碼應該放到 main
函數或者 main
函數調用的其餘函數中執行,若是咱們在 main
函數中顯式的初始化這種依賴,對於其餘的工程師來講就很是易於理解,咱們從 main
函數開始就能梳理出程序啓動的整個過程。
// cmd/grpc/main.go func main() { grpcClient, err := grpc.Dial(...) if err != nil { panic(err) } postClient := post.NewClient(grpcClient) // ... }
各個模塊之間會構成一種樹形的結構和依賴關係,上層的模塊會持有下層模塊中的接口或者結構體,不會存在孤立的、不被引用的對象。
上圖中出現的兩個
Database
實際上是在main
函數中初始化的數據庫鏈接,在項目運行期間,它們可能表示同一個內存中的數據庫鏈接
當咱們使用 golangci-lint 並開啓 gochecknoinits
和 gochecknoglobals
靜態檢查時,它其實嚴格地限制咱們對 init
函數和全局變量的使用。
固然這並非說咱們必定不能使用 init
函數,做爲 Go 語言賦予開發者的能力,由於它能在包被引入時隱式地執行了一些代碼,因此咱們更應該慎重地使用它們。
一些框架會在 init
中判斷是否知足使用的前置條件,可是對於不少的 Web 或者 API 服務來講,大量使用 init
每每意味着代碼質量的降低以及不合理的設計。
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") }
上述代碼實際上是 Effective Go 在介紹 init
方法使用是展現的實例代碼,這是一個比較合理地 init
函數使用示例,咱們不該該在 init
中作太重的初始化邏輯,而是作一些簡單、輕量的前置條件判斷。
另外一個要介紹的就是 Go 語言的錯誤處理機制了,雖然 Golang 的錯誤處理被開發者詬病已久,可是工程師天天都在寫 if err != nil { return nil, err }
的錯誤處理邏輯其實就是在顯式地對錯誤處理,關注全部可能會發生錯誤的方法調用並在沒法處理時拋給上層模塊。
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 }
上述代碼只是簡單展現 Go 語言常見的錯誤處理邏輯,咱們不該該在這種方法中初始化數據庫的鏈接。
雖然 Golang 中也有相似 Java 或者 Ruby try/catch
關鍵字,可是不多有人會在代碼中使用 panic
和 recover
來實現錯誤和異常的處理,與 init
函數同樣,Go 語言對於 panic
和 recover
的使用也很是謹慎。
當咱們在 Go 語言中處理錯誤相關的邏輯時,最重要的其實就是如下幾點:
error
實現錯誤處理 — 儘管這看起來很是囉嗦;error
也須要咱們仔細地思考,向上拋出錯誤時能夠經過 errors.Wrap
攜帶一些額外的信息方便上層進行判斷;做者在使用 Go 語言的這段時間,可以深入地體會到它對於顯式方法調用與錯誤處理的鼓勵,這不只可以幫助項目的其餘開發者快速地理解上下文,也可以幫助咱們構建更加健壯、容錯性與可維護性更好的工程。
面向接口編程是一個老生常談的話題,接口 的做用其實就是爲不一樣層級的模塊提供了一個定義好的中間層,上游再也不須要依賴下游的具體實現,充分地對上下游進行了解耦。
這種編程方式不只是在 Go 語言中是被推薦的,在幾乎全部的編程語言中,咱們都會推薦這種編程的方式,它爲咱們的程序提供了很是強的靈活性,想要構建一個穩定、健壯的 Go 語言項目,不使用接口是徹底沒法作到的。
若是一個略有規模的項目中沒有出現任何 type ... interface
的定義,那麼做者能夠推測出這在很大的機率上是一個工程質量堪憂而且沒有多少單元測試覆蓋的項目,咱們確實須要認真考慮一下如何使用接口對項目進行重構。
單元測試是一個項目保證工程質量最有效而且投資回報率最高的方法之一,做爲靜態語言的 Golang,想要寫出覆蓋率足夠(最少覆蓋核心邏輯)的單元測試自己就比較困難,由於咱們不能像動態語言同樣隨意修改函數和方法的行爲,而接口就成了咱們的救命稻草,寫出抽象良好的接口並經過接口隔離依賴可以幫助咱們有效地提高項目的質量和可測試性,咱們會在下一節中詳細介紹如何寫單元測試。
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 }
上述代碼其實就不是一個設計良好的代碼,它不只在 init
函數中隱式地初始化了 grpc 鏈接這種全局變量,並且沒有將 ListPosts
經過接口的方式暴露出去,這會讓依賴 ListPosts
的上層模塊難以測試。
咱們可使用下面的代碼改寫原有的邏輯,使得一樣地邏輯變得更容易測試和維護:
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 }
Service
暴露對外的 ListPosts
方法;NewService
函數初始化 Service
接口的實現並經過私有的結構體 service
持有 grpc 鏈接;ListPosts
再也不依賴全局變量,而是依賴接口體 service
持有的鏈接;當咱們使用這種方式重構代碼以後,就能夠在 main
函數中顯式的初始化 grpc 鏈接、建立 Service
接口的實現並調用 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 語言中很是常見,咱們應該在代碼中儘量地使用這種思想和模式對外提供功能:
Service
對外暴露方法;service
實現接口中定義的方法;NewService
函數初始化 Service
接口;當咱們使用上述方法組織代碼以後,其實就對不一樣模塊的依賴進行了解耦,也正遵循了軟件設計中常常被提到的一句話 — 『依賴接口,不要依賴實現』,也就是面向接口編程。
在這一小節中總共介紹了 Go 語言中三個常常會打交道的『元素』— init
函數、error
和接口,咱們在這裏主要是想經過三個不一樣的例子爲你們傳達的一個主要思想就是儘可能使用顯式的(explicit)的方式編寫 Go 語言代碼。
一個代碼質量和工程質量有保證的項目必定有比較合理的單元測試覆蓋率,沒有單元測試的項目必定是不合格的或者不重要的,單元測試應該是全部項目都必須有的代碼,每個單元測試都表示一個可能發生的狀況,單元測試就是業務邏輯。
做爲軟件工程師,重構現有的項目對於咱們來講應該是一件比較正常的事情,若是項目中沒有單元測試,咱們很難在不改變已有業務邏輯的狀況對項目進行重構,一些業務的邊界狀況極可能會在重構的過程當中丟失,當時參與相應 case
開發的工程師可能已經不在團隊中,而項目相關的文檔可能也消失在了歸檔的 wiki
中(更多的項目可能徹底沒有文檔),咱們可以在重構中相信的東西其實只有當前的代碼邏輯(極可能是錯誤的)以及單元測試(極可能是沒有的)。
簡單總結一下,單元測試的缺失不只會意味着較低的工程質量,並且意味着重構的難以進行,一個有單元測試的項目尚且不可以保證重構先後的邏輯徹底相同,一個沒有單元測試的項目極可能自己的項目質量就堪憂,更不用說如何在不丟失業務邏輯的狀況下進行重構了。
寫代碼並非一件多困難的事情,不過想要在項目中寫出能夠測試的代碼並不容易,而優雅的代碼必定是能夠測試的,咱們在這一節中須要討論的就是什麼樣的代碼是能夠測試的。
若是想要想清楚什麼樣的纔是可測試的,咱們首先要知道測試是什麼?做者對於測試的理解就是控制變量,在咱們隔離了待測試方法中一些依賴以後,當函數的入參肯定時,就應該獲得指望的返回值。
如何控制待測試方法中依賴的模塊是寫單元測試時相當重要的,控制依賴也就是對目標函數的依賴進行 Mock
消滅不肯定性,爲了減小每個單元測試的複雜度,咱們須要:
Mock
;單元測試的執行不該該依賴於任何的外部模塊,不管是調用外部的 HTTP 請求仍是數據庫中的數據,咱們都應該想盡辦法模擬可能出現的狀況,由於單元測試不是集成測試的,它的運行不該該依賴除項目代碼外的其餘任何系統。
在 Go 語言中若是咱們徹底不使用接口,是寫不出易於測試的代碼的,做爲靜態語言的 Golang,只有咱們使用接口才能脫離依賴具體實現的窘境,接口的使用可以爲咱們帶來更清晰的抽象,幫助咱們思考如何對代碼進行設計,也能讓咱們更方便地對依賴進行 Mock
。
咱們再來回顧一下上一節對接口進行介紹時展現的常見模式:
type Service interface { ... } type service struct { ... } func NewService(...) (Service, error) { return &service{...}, nil }
上述代碼在 Go 語言中是很是常見的,若是你不知道應不該該使用接口對外提供服務,這時就應該無腦地使用上述模式對外暴露方法了,這種模式能夠在絕大多數的場景下工做,至少做者到目前尚未見到過不適用的。
另外一個建議就是保證每個函數儘量簡單,這裏的簡單不止是指功能上的簡單、單一,還意味着函數容易理解而且命名可以自解釋。
一些語言的 lint
工具其實會對函數的理解複雜度(PerceivedComplexity)進行檢查,也就是檢查函數中出現的 if/else
、switch/case
分支以及方法的調用的數量,一旦超過約定的閾值就會報錯,Ruby 社區中的 Rubocop 和上面提到的 golangci-lint 都有這個功能。
Ruby 社區中的 Rubocop 對於函數的長度和理解複雜度都有着很是嚴格的限制,在默認狀況下函數的行數不能超過 10
行,理解複雜度也不能超過 7
,除此以外,Rubocop 其實還有其餘的複雜度限制,例如循環複雜度(CyclomaticComplexity),這些複雜度的限制都是爲了保證函數的簡單和容易理解。
如何對測試進行組織也是一個值得討論的話題,Golang 中的單元測試文件和代碼都是與源代碼放在同一個目錄下按照 package
進行組織的,server.go
文件對應的測試代碼應該放在同一目錄下的 server_test.go
文件中。
若是文件不是以 _test.go
結尾,當咱們運行 go test ./pkg
時就不會找到該文件中的測試用例,其中的代碼也就不會被執行,這也是 Go 語言對於測試組織方法的一個約定。
單元測試的最多見以及默認組織方式就是寫在以 _test.go
結尾的文件中,全部的測試方法也都是以 Test
開頭而且只接受一個 testing.T
類型的參數:
func TestAuthor(t *testing.T) { author := blog.Author() assert.Equal(t, "draveness", author) }
若是咱們要給函數名爲 Add
的方法寫單元測試,那麼對應的測試方法通常會被寫成 TestAdd
,爲了同時測試多個分支的內容,咱們能夠經過如下的方式組織 Add
函數相關的測試:
func TestAdd(t *testing.T) { assert.Equal(t, 5, Add(2, 3)) } func TestAddWithNegativeNumber(t *testing.T) { assert.Equal(t, -2, Add(-1, -1)) }
除了這種將一個函數相關的測試分散到多個 Test
方法以外,咱們可使用 for
循環來減小重複的測試代碼,這在邏輯比較複雜的測試中會很是好用,可以減小大量的重複代碼,不過也須要咱們當心地進行設計:
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)) }) } }
這種方式其實也能生成樹形的測試結果,將 Add
相關的測試分紅一組方便咱們進行觀察和理解,不過這種測試組織方法須要咱們保證測試代碼的通用性,當函數依賴的上下文較多時每每須要咱們寫不少的 if/else
條件判斷語句影響咱們對測試的快速理解。
做者一般會在測試代碼比較簡單時使用第一種組織方式,而在依賴較多、函數功能較爲複雜時使用第二種方式,不過這也不是定論,咱們須要根據實際狀況決定如何對測試進行設計。
第二種比較常見的方式是按照簇進行組織,其實就是對 Go 語言默認的測試方式進行簡單的封裝,咱們可使用 stretchr/testify 中的 suite
包對測試進行組織:
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
包,以結構體的方式對測試簇進行組織,suite
提供的 SetupTest
/SetupSuite
和 TearDownTest
/TearDownSuite
是執行測試先後以及執行測試簇先後的鉤子方法,咱們能在其中完成一些共享資源的初始化,減小測試中的初始化代碼。
最後一種組織代碼的方式就是使用 BDD 的風格對單元測試進行組織,ginkgo 就是 Golang 社區最多見的 BDD 框架了,這裏提到的行爲驅動開發(BDD)和測試驅動開發(TDD)都是一種保證工程質量的方法論。想要在項目中實踐這種思想仍是須要一些思惟上的轉變和適應,也就是先經過寫單元測試或者行爲測試約定方法的 Spec,再實現方法讓咱們的測試經過,這是一種比較科學的方法,它能爲咱們帶來比較強的信心。
咱們雖然不必定要使用 BDD/TDD 的思想對項目進行開發,可是卻可使用 BDD 的風格方式組織很是易讀的測試代碼:
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 框架中通常都包含 Describe
、Context
以及 It
等代碼塊,其中 Describe
的做用是描述代碼的獨立行爲、Context
是在一個獨立行爲中的多個不一樣上下文,最後的 It
用於描述指望的行爲,這些代碼塊最終都構成了相似『描述……,當……時,它應該……』的句式幫助咱們快速地理解測試代碼。
項目中的單元測試應該是穩定的而且不依賴任何的外部項目,它只是對項目中函數和方法的測試,因此咱們須要在單元測試中對全部的第三方的不穩定依賴進行 Mock,也就是模擬這些第三方服務的接口;除此以外,爲了簡化一次單元測試的上下文,在同一個項目中咱們也會對其餘模塊進行 Mock,模擬這些依賴模塊的返回值。
單元測試的核心就是隔離依賴並驗證輸入和輸出的正確性,Go 語言做爲一個靜態語言提供了比較少的運行時特性,這也讓咱們在 Go 語言中 Mock 依賴變得很是困難。
Mock 的主要做用就是保證待測試方法依賴的上下文固定,在這時不管咱們對當前方法運行多少次單元測試,若是業務邏輯不改變,它都應該返回徹底相同的結果,在具體介紹 Mock 的不一樣方法以前,咱們首先要清楚一些常見的依賴,一個函數或者方法的常見依賴能夠有如下幾種:
這些不一樣的場景基本涵蓋了寫單元測試時會遇到的狀況,咱們會在接下來的內容中分別介紹如何處理以上幾種不一樣的依賴。
首先要介紹的其實就是 Go 語言中最多見也是最通用的 Mock 方法,也就是可以對接口進行 Mock 的 golang/mock 框架,它可以根據接口生成 Mock 實現,假設咱們有如下代碼:
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
做爲引擎,可是它們都會提供 ListsPosts
方法用於返回所有的文章列表,在這時咱們就須要定義一個 Post
接口,接口要求遵循 Blog
的結構體必須實現 ListPosts
方法。
當咱們定義好了 Blog
接口以後,上層 Service
就再也不須要依賴某個具體的博客引擎實現了,只須要依賴 Blog
接口就能夠完成對文章的批量獲取功能:
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 }
若是咱們想要對 Service
進行測試,咱們就可使用 gomock 提供的 mockgen
工具命令生成 MockBlog
結構體,使用以下所示的命令:
$ 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
生成的代碼很是長的,因此咱們只展現了其中的一部分,它的功能就是幫助咱們驗證任意接口的輸入參數而且模擬接口的返回值;而在生成 Mock 實現的過程當中,做者總結了一些能夠分享的經驗:
test/mocks
目錄中放置全部的 Mock 實現,子目錄與接口所在文件的二級目錄相同,在這裏源文件的位置在 pkg/blog/blog.go
,它的二級目錄就是 blog/
,因此對應的 Mock 實現會被生成到 test/mocks/blog/
目錄中;package
爲 mxxx
,默認的 mock_xxx
看起來很是冗餘,上述 blog
包對應的 Mock 包也就是 mblog
;mockgen
命令放置到 Makefile
中的 mock
下統一管理,減小祖傳命令的出現;
mock:
rm -rf test/mocks mkdir -p test/mocks/blog mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go
當咱們生成了上述的 Mock 實現代碼以後,就可使用以下的方式爲 Service
寫單元測試了,這段代碼經過 NewMockBlog
生成一個 Blog
接口的 Mock 實現,而後經過 EXPECT
方法控制該實現會在調用 ListPosts
時返回空的 Post
數組:
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()) }
因爲當前 Service
只依賴於 Blog
的實現,因此在這時咱們就可以斷言當前方法必定會返回 []Post{}
,這時咱們的方法的返回值就只與傳入的參數有關(雖然 ListPosts
方法沒有入參),咱們可以減小一次關注的上下文並保證測試的穩定和可信。
這是 Go 語言中最標準的單元測試寫法,全部依賴的 package
不管是項目內外都應該使用這種方式處理(在有接口的狀況下),若是沒有接口 Go 語言的單元測試就會很是難寫,這也是爲何從項目中是否有接口就能判斷工程質量的緣由了。
另外一個項目中比較常見的依賴其實就是數據庫,在遇到數據庫的依賴時,咱們通常都會使用 sqlmock 來模擬數據庫的鏈接,當咱們使用 sqlmock 時會寫出以下所示的單元測試:
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()) }
最經常使用的幾個方法就是 ExpectQuery
和 ExpectExec
,前者主要用於模擬 SQL 的查詢語句,後者用於模擬 SQL 的增刪,從上面的實例中咱們能夠看到這個這兩種方法的使用方式,建議各位先閱讀相關的 文檔 再嘗試使用。
HTTP 請求也是咱們在項目中常常會遇到的依賴,httpmock 就是一個用於 Mock 全部 HTTP 依賴的包,它使用模式匹配的方式匹配 HTTP 請求的 URL,在匹配到特定的請求時就會返回預先設置好的響應。
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 請求的依賴時,就可使用上述 httpmock 包模擬依賴的 HTTP 請求。
最後要介紹的猴子補丁其實就是一個大殺器了,bouk/monkey 可以經過替換函數指針的方式修改任意函數的實現,因此若是上述的幾種方法都不能知足咱們的需求,咱們就只可以經過猴子補丁這種比較 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*? }
然而這種方法的使用其實有一些限制,因爲它是在運行時替換了函數的指針,因此若是遇到一些簡單的函數,例如 rand.Int63n
和 time.Now
,編譯器可能會直接將這種函數內聯到調用實際發生的代碼處並不會調用原有的方法,因此使用這種方式每每須要咱們在測試時額外指定 -gcflags=-l
禁止編譯器的內聯優化。
$ go test -gcflags=-l ./...
bouk/monkey 的 README 對於它的使用給出了一些注意事項,除了內聯編譯以外,咱們須要注意的是不要在單元測試以外的地方使用猴子補丁,咱們應該只在必要的時候使用這種方法,例如依賴的第三方庫沒有提供 interface
或者修改 time.Now
以及 rand.Int63n
等內置函數的返回值用於測試時。
從理論上來講,經過猴子補丁這種方式咱們可以在運行時 Mock Go 語言中的一切函數,這也爲咱們提供了單元測試 Mock 依賴的最終解決方案。
在最後,咱們簡單介紹一下輔助單元測試的 assert 包,它提供了很是多的斷言方法幫助咱們快速對指望的返回值進行測試,減小咱們的工做量:
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) } }
在這裏咱們也是簡單展現一下 assert
的示例,更詳細的內容能夠閱讀它的相關文檔,在這裏也就很少作展現了。
若是以前徹底沒有寫過單元測試或者沒有寫過 Go 語言的單元測試,相信這篇文章已經給了足夠多的上下文幫助咱們開始作這件事情,咱們要知道的是單元測試其實並不會阻礙咱們的開發進度,它可以爲咱們的上線提供信心,也是質量保證上投資回報率最高的方法。
學習寫好單元測試必定會有一些學習曲線和不適應,甚至會在短時間內影響咱們的開發效率,可是熟悉了這一套流程和接口以後,單元測試對咱們的幫助會很是大,每個單元測試都表示一個業務邏輯,每次提交時執行單元測試就可以幫助咱們肯定新的代碼大機率上不會影響已有的業務邏輯,可以明顯地下降重構的風險以及線上事故的數量
在這篇文章中咱們從三個方面分別介紹瞭如何寫優雅的 Go 語言代碼,做者儘量地給出了最容易操做和最有效的方法:
model
、controller
這種違反語言頂層設計思路的包名;init
函數,保證顯式地進行方法的調用以及錯誤的處理;Service
對外暴露方法;service
實現接口中定義的方法;func NewService(...) (Service, error)
函數初始化 Service
接口;想要寫出優雅的代碼自己就不是一件容易的事情,它須要咱們不斷地對本身的知識體系進行更新和優化,推倒以前的經驗並對項目持續進行完善和重構,而只有真正通過思考和設計的代碼纔可以通過時間的檢驗(代碼是須要不斷重構的),隨意堆砌代碼的行爲是不能鼓勵也不該該發生的,每一行代碼都應該按照最高的標準去設計和開發,這是咱們保證工程質量的惟一方法。
做者也一直在努力學習如何寫出更加優雅的代碼,寫出好的代碼真的不是一件容易的事情,做者也但願能經過這篇文章幫助使用 Go 語言的工程師寫出更有 Golang 風格的項目。