Go語言最佳實踐

這些最佳實踐更像是 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

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

  • prometheus 上報和存儲指標的時序數據庫
  • istio 服務網格 2.0
  • kubernetes 容器調度管理系統
  • grafana 展示監控和指標的儀表盤
    這個目錄中存放的就是項目中可以被外部應用使用的代碼庫,其他的項目可以直接通過 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 包並不會出現這種錯誤。

/src

在 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

/cmd 目錄中存儲的都是當前項目中的可執行文件,該目錄下的每一個子目錄都應該包含我們希望有的可執行文件,如果我們的項目是一個 grpc 服務的話,可能在 /cmd/server/main.go 中就包含了啓動服務進程的代碼,編譯後生成的可執行文件就是 server。

我們不應該在 /cmd 目錄中放置太多的代碼,我們應該將公有代碼放置到 /pkg 中並將私有代碼放置到 /internal 中並在 /cmd 中引入這些包,保證 main 函數中的代碼儘可能簡單和少。

/api

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

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

二級目錄的主要作用就是在一個項目同時提供了多種不同的訪問方式時,用這種辦法避免可能存在的潛在衝突問題,也可以讓項目結構的組織更加清晰。

Makefile

最後要介紹的 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 的目錄,這種按層拆分模塊的設計其實有以下的幾方面原因:

  • MVC 架構模式 — MVC 本身就強調了按層劃分職責的設計,所以遵循該模式設計的框架自然有着一脈相承的思路;
  • 扁平的命名空間 — 無論是 Spring MVC 還是 Rails,同一個項目中命名空間非常扁平,跨文件夾使用其他文件夾中定義的類或者方法不需要引入新的包,使用其他文件定義的類時也不需要增加額外的前綴,多個文件定義的類被『合併』到了同一個命名空間中;
  • 單體服務的場景 — Spring MVC 和 Rails 剛出現時,SOA 和微服務架構還不像今天這麼普遍,絕大多數的場景也不需要通過拆分服務;

上面的幾個原因共同決定了 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 語言中會顯得非常冗餘,並且如果對項目依賴包的管理不夠謹慎時,很容易發生引用循環,出現這些問題的最根本原因其實也非常簡單:

  • Go 語言對同一個項目中不同目錄的命名空間做了隔離,整個項目中定義的類和方法並不是在同一個命名空間下的,這也就需要工程師自己維護不同包之間的依賴關係;
    按照職責垂直拆分的方式在單體服務遇到瓶頸時非常容易對微服務進行拆分,我們可以直接將一個負責獨立功能的 package 拆出去,對這部分性能熱點單獨進行擴容;

小結

項目是按照層級還是按照職責對模塊進行拆分其實並沒有絕對的好與不好,語言和框架層面的設計最終決定了我們應該採用哪種方式對項目和代碼進行組織。

Java 和 Ruby 這些語言在框架中往往採用水平拆分的方式劃分不同層級的職責,而 Go 語言項目的最佳實踐就是按照職責對模塊進行垂直拆分,將代碼按照功能的方式分到多個 package 中,這並不是說 Go 語言中不存在模塊的水平拆分,只是因爲 package作爲一個 Go 語言訪問控制的最小粒度,所以我們應該遵循頂層的設計使用這種方式構建高內聚的模塊。

顯式與隱式

從開始學習、使用 Go 語言到參與社區上一些開源的 Golang 項目,作者發現 Go 語言社區對於顯式的初始化、方法調用和錯誤處理非常推崇,類似 Spring Boot 和 Rails 的框架其實都廣泛地採納了『約定優於配置』的中心思想,簡化了開發者和工程師的工作量。

然而 Go 語言社區雖然達成了很多的共識與約定,但是從語言的設計以及工具上的使用我們就能發現顯式地調用方法和錯誤處理是被鼓勵的。

init

我們在這裏先以一個非常常見的函數 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 中做過重的初始化邏輯,而是做一些簡單、輕量的前置條件判斷。

error

另一個要介紹的就是 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

單元測試的最常見以及默認組織方式就是寫在以 _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 條件判斷語句影響我們對測試的快速理解。

作者通常會在測試代碼比較簡單時使用第一種組織方式,而在依賴較多、函數功能較爲複雜時使用第二種方式,不過這也不是定論,我們需要根據實際情況決定如何對測試進行設計。

Suite

第二種比較常見的方式是按照簇進行組織,其實就是對 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

最後一種組織代碼的方式就是使用 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,也就是模擬這些第三方服務的接口;除此之外,爲了簡化一次單元測試的上下文,在同一個項目中我們也會對其他模塊進行 Mock,模擬這些依賴模塊的返回值。

單元測試的核心就是隔離依賴並驗證輸入和輸出的正確性,Go 語言作爲一個靜態語言提供了比較少的運行時特性,這也讓我們在 Go 語言中 Mock 依賴變得非常困難。

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

接口
數據庫
HTTP 請求
Redis、緩存以及其他依賴
這些不同的場景基本涵蓋了寫單元測試時會遇到的情況,我們會在接下來的內容中分別介紹如何處理以上幾種不同的依賴。

接口

首先要介紹的其實就是 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 語言的單元測試就會非常難寫,這也是爲什麼從項目中是否有接口就能判斷工程質量的原因了。

SQL

另一個項目中比較常見的依賴其實就是數據庫,在遇到數據庫的依賴時,我們一般都會使用 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

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 語言代碼,作者儘可能地給出了最容易操作和最有效的方法:

代碼規範:使用輔助工具幫助我們在每次提交 PR 時自動化地對代碼進行檢查,減少工程師人工審查的工作量;
最佳實踐
目錄結構:遵循 Go 語言社區中被廣泛達成共識的 目錄結構,減少項目的溝通成本;
模塊拆分:按照職責對不同的模塊進行拆分,Go 語言的項目中也不應該出現 model、controller 這種違反語言頂層設計思路的包名;
顯示與隱式:儘可能地消滅項目中的 init 函數,保證顯式地進行方法的調用以及錯誤的處理;
面向接口:面向接口是 Go 語言鼓勵的開發方式,也能夠爲我們寫單元測試提供方便,我們應該遵循固定的模式對外提供功能;
使用大寫的 Service 對外暴露方法;
使用小寫的 service 實現接口中定義的方法;
通過 func NewService(…) (Service, error) 函數初始化 Service 接口;
單元測試:保證項目工程質量的最有效辦法;
gomock:最標準的也是最被鼓勵的方式;
sqlmock:處理依賴的數據庫;
httpmock:處理依賴的 HTTP 請求;
monkey:萬能的方法,但是隻在萬不得已時使用,類似的代碼寫起來非常冗長而且不直觀;
可測試:意味着面向接口編程以及減少單個函數中包含的邏輯,使用『小方法』;
組織方式:使用 Go 語言默認的 Test 框架、開源的 suite 或者 BDD 的風格對單元測試進行合理組織;
Mock 方法:四種不同的單元測試 Mock 方法;
斷言:使用社區的 testify 快速驗證方法的返回值;
想要寫出優雅的代碼本身就不是一件容易的事情,它需要我們不斷地對自己的知識體系進行更新和優化,推倒之前的經驗並對項目持續進行完善和重構,而只有真正經過思考和設計的代碼才能夠經過時間的檢驗(代碼是需要不斷重構的),隨意堆砌代碼的行爲是不能鼓勵也不應該發生的,每一行代碼都應該按照最高的標準去設計和開發,這是我們保證工程質量的唯一方法。

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

Reference

goimports vs gofmt
Style guideline for Go packages
Standard Package Layout
Internal packages in Go
The init function · Effective Go

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章