天天看點

如何寫出優雅的 Golang 代碼

原文: https://draveness.me/golang-101.html

Go 語言是一門簡單、易學的程式設計語言,對于有程式設計背景的工程師來說,學習 Go 語言并寫出能夠運作的代碼并不是一件困難的事情,對于之前有過其他語言經驗的開發者來說,寫什麼語言都像自己學過的語言其實是有問題的,想要真正融入生态寫出優雅的代碼就一定要花一些時間和精力了解語言背後的設計哲學和最佳實踐。

如果你之前沒有 Go 語言的開發經曆,正在學習和使用 Go 語言,相信這篇文章能夠幫助你更快地寫出優雅的 Go 語言代碼;在這篇文章中,我們并不會給一個長長地清單介紹變量、方法和結構體應該怎麼命名,這些 Go 語言的代碼規範可以在 Go Code Review Comments 中找到,它們非常重要但并不是這篇文章想要介紹的重點,我們将從代碼結構、最佳實踐以及單元測試幾個不同的方面介紹如何寫出優雅的 Go 語言代碼。

寫在前面

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

  • 容易閱讀和了解;
  • 容易測試、維護和擴充;
  • 命名清晰、無歧義、注釋完善清楚;

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

  • 幫助 Go 語言的開發者了解生态中的規範與工具,寫出更優雅的代碼;
  • 為代碼和項目的管理提供被社群廣泛認同的規則、共識以及最佳實踐;

代碼規範

代碼規範其實是一個老生常态的問題,我們也不能免俗還是要簡單介紹一下相關的内容,Go 語言比較常見并且使用廣泛的代碼規範就是官方提供的 Go Code Review Comments,無論你是短期還是長期使用 Go 語言程式設計,都應該至少完整地閱讀一遍這個官方的代碼規範指南,它既是我們在寫代碼時應該遵守的規則,也是在代碼審查時需要注意的規範。

學習 Go 語言相關的代碼規範是一件非常重要的事情,也是讓我們的項目遵循統一規範的第一步,雖然閱讀代碼規範相關的文檔非常重要,但是在實際操作時我們并不能靠工程師自覺地遵守以及經常被當做形式的代碼審查,而是需要借助工具來輔助執行。

輔助工具

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

我們在這一節中就會介紹兩種非常切實有效的辦法幫助我們在項目中自動化地進行一些代碼規範檢查和靜态檢查保證項目的品質。

goimports

goimports 是 Go 語言官方提供的工具,它能夠為我們自動格式化 Go 語言代碼并對所有引入的包進行管理,包括自動增删依賴的包引用、将依賴包按字母序排序并分類。相信很多人使用的 IDE 都會将另一個官方提供的工具 gofmt 對代碼進行格式化,而 

goimports

 就是等于 

gofmt

 加上依賴包管理。

建議所有 Go 語言的開發者都在開發時使用 

goimports

,雖然 

goimports

 有時會引入錯誤的包,但是與帶來的好處相比,這些偶爾出現的錯誤在作者看來也是可以接受的;當然,不想使用 

goimports

 的開發者也一定要在 IDE 或者編輯器中開啟自動地 

gofmt

(儲存時自動格式化)。

在 IDE 和 CI 檢查中開啟自動地 

gofmt

 或者 

goimports

 檢查是沒有、也不應該有讨論的必要的,這就是一件使用和開發 Go 語言必須要做的事情。

golint

另一個比較常用的靜态檢查工具就是 

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 語言社群中鼓勵統一、一緻的程式設計風格,某些開發者也許不會同意其中的某些規範,但是使用統一的風格對于 Go 語言社群有比較強的好處,而能夠開關指定規則的功能會導緻 

    golint

     不能夠有效地完成這個工作;
  • 有一些靜态檢查的規則會導緻一些錯誤的警告,這些情況确實非常讓人頭疼,但是我會選擇支援在 golint 中直接保留或者删除這些規則,而不是提供随意增删規則的能力;
  • 能夠通過 

    min_confidence

     過濾一些靜态檢查規則,但是需要我們選擇合适的值;

golint

 作者的觀點在 issue 中得到了非常多的 👎,但是這件事情很難說對錯;在社群中保證一緻的程式設計規範是一件非常有益的事情,不過對于很多公司内部的服務或者項目,可能在業務服務上就會發生一些比較棘手的情況,使用這種過強的限制沒有太多明顯地收益。

更推薦的方法是在基礎庫或者架構中使用 

golint

 進行靜态檢查(或者同時使用 

golint

 和 golangci-lint),在其他的項目中使用可定制化的 

golangci-lint

 來進行靜态檢查,因為在基礎庫和架構中施加強限制對于整體的代碼品質有着更大的收益。

作者會在自己的 Go 項目中使用 

golint

 + 

golangci-lint

 并開啟全部的檢查盡量盡早發現代碼中包含文檔在内的全部缺陷。

自動化

無論是用于檢查代碼規範和依賴包的 

goimports

 還是靜态檢查工具 

glint

golangci-lint

,隻要我們在項目中引入這些工具就一定要在代碼的 CI 流程中加入對應的自動化檢查:

  • 在 GitHub 上我們可以使用 Travis CI 或者 CircleCI;
  • 在 Gitlab 上我們可以使用 Gitlab 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

/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

 的目錄,這種按層拆分子產品的設計其實有以下的幾方面原因:

  1. MVC 架構模式 — MVC 本身就強調了按層劃分職責的設計,是以遵循該模式設計的架構自然有着一脈相承的思路;
  2. 扁平的命名空間 — 無論是 Spring MVC 還是 Rails,同一個項目中命名空間非常扁平,跨檔案夾使用其他檔案夾中定義的類或者方法不需要引入新的包,使用其他檔案定義的類時也不需要增加額外的字首,多個檔案定義的類被『合并』到了同一個命名空間中;
  3. 單體服務的場景 — 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 語言中會顯得非常備援,并且如果對項目依賴包的管理不夠謹慎時,很容易發生引用循環,出現這些問題的最根本原因其實也非常簡單:

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

    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 語言中處理錯誤相關的邏輯時,最重要的其實就是以下幾點:

  1. 使用 

    error

     實作錯誤處理 — 盡管這看起來非常啰嗦;
  2. 将錯誤抛給上層處理 — 對于一個方法是否需要傳回 

    error

     也需要我們仔細地思考,向上抛出錯誤時可以通過 

    errors.Wrap

     攜帶一些額外的資訊友善上層進行判斷;
  3. 處理所有可能傳回的錯誤 — 所有可能傳回錯誤的地方最終一定會傳回錯誤,考慮全面才能幫助我們建構更加健壯的項目;

作者在使用 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
}
           
  1. 通過接口 

    Service

     暴露對外的 

    ListPosts

     方法;
  2. NewService

     函數初始化 

    Service

     接口的實作并通過私有的結構體 

    service

     持有 grpc 連接配接;
  3. 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 語言中非常常見,我們應該在代碼中盡可能地使用這種思想和模式對外提供功能:

  1. 使用大寫的 

    Service

     對外暴露方法;
  2. 使用小寫的 

    service

     實作接口中定義的方法;
  3. 通過 

    NewService

    Service

     接口;

當我們使用上述方法組織代碼之後,其實就對不同子產品的依賴進行了解耦,也正遵循了軟體設計中經常被提到的一句話 — 『依賴接口,不要依賴實作』,也就是面向接口程式設計。

在這一小節中總共介紹了 Go 語言中三個經常會打交道的『元素』— 

init

 函數、

error

 和接口,我們在這裡主要是想通過三個不同的例子為大家傳達的一個主要思想就是盡量使用顯式的(explicit)的方式編寫 Go 語言代碼。

單元測試

一個代碼品質和工程品質有保證的項目一定有比較合理的單元測試覆寫率,沒有單元測試的項目一定是不合格的或者不重要的,單元測試應該是所有項目都必須有的代碼,每一個單元測試都表示一個可能發生的情況,單元測試就是業務邏輯。

作為軟體工程師,重構現有的項目對于我們來說應該是一件比較正常的事情,如果項目中沒有單元測試,我們很難在不改變已有業務邏輯的情況對項目進行重構,一些業務的邊界情況很可能會在重構的過程中丢失,當時參與相應 

case

 開發的工程師可能已經不在團隊中,而項目相關的文檔可能也消失在了歸檔的 

wiki

 中(更多的項目可能完全沒有文檔),我們能夠在重構中相信的東西其實隻有目前的代碼邏輯(很可能是錯誤的)以及單元測試(很可能是沒有的)。

簡單總結一下,單元測試的缺失不僅會意味着較低的工程品質,而且意味着重構的難以進行,一個有單元測試的項目尚且不能夠保證重構前後的邏輯完全相同,一個沒有單元測試的項目很可能本身的項目品質就堪憂,更不用說如何在不丢失業務邏輯的情況下進行重構了。

可測試

寫代碼并不是一件多困難的事情,不過想要在項目中寫出可以測試的代碼并不容易,而優雅的代碼一定是可以測試的,我們在這一節中需要讨論的就是什麼樣的代碼是可以測試的。

如果想要想清楚什麼樣的才是可測試的,我們首先要知道測試是什麼?作者對于測試的了解就是控制變量,在我們隔離了待測試方法中一些依賴之後,當函數的入參确定時,就應該得到期望的傳回值。

如何控制待測試方法中依賴的子產品是寫單元測試時至關重要的,控制依賴也就是對目标函數的依賴進行 

Mock

 消滅不确定性,為了減少每一個單元測試的複雜度,我們需要:

  1. 盡可能減少目标方法的依賴,讓目标方法隻依賴必要的子產品;
  2. 依賴的子產品也應該非常容易地進行 

    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 的不同方法之前,我們首先要清楚一些常見的依賴,一個函數或者方法的常見依賴可以有以下幾種:

  1. 資料庫
  2. HTTP 請求
  3. 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 實作的過程中,作者總結了一些可以分享的經驗:

  1. 在 

    test/mocks

     目錄中放置所有的 Mock 實作,子目錄與接口所在檔案的二級目錄相同,在這裡源檔案的位置在 

    pkg/blog/blog.go

    ,它的二級目錄就是 

    blog/

    ,是以對應的 Mock 實作會被生成到 

    test/mocks/blog/

     目錄中;
  2. 指定 

    package

     為 

    mxxx

    ,預設的 

    mock_xxx

     看起來非常備援,上述 

    blog

     包對應的 Mock 包也就是 

    mblog

  3. 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 語言鼓勵的開發方式,也能夠為我們寫單元測試提供友善,我們應該遵循固定的模式對外提供功能;
      1. Service

      2. service

      3. func NewService(...) (Service, error)

        Service

  • 單元測試:保證項目工程品質的最有效辦法;
    • 可測試:意味着面向接口程式設計以及減少單個函數中包含的邏輯,使用『小方法』;
    • 組織方式:使用 Go 語言預設的 Test 架構、開源的 

      suite

       或者 BDD 的風格對單元測試進行合理組織;
    • Mock 方法:四種不同的單元測試 Mock 方法;
      • gomock:最标準的也是最被鼓勵的方式;
      • sqlmock:處理依賴的資料庫;
      • httpmock:處理依賴的 HTTP 請求;
      • monkey:萬能的方法,但是隻在萬不得已時使用,類似的代碼寫起來非常冗長而且不直覺;
    • 斷言:使用社群的 testify 快速驗證方法的傳回值;

想要寫出優雅的代碼本身就不是一件容易的事情,它需要我們不斷地對自己的知識體系進行更新和優化,推倒之前的經驗并對項目持續進行完善和重構,而隻有真正經過思考和設計的代碼才能夠經過時間的檢驗(代碼是需要不斷重構的),随意堆砌代碼的行為是不能鼓勵也不應該發生的,每一行代碼都應該按照最高的标準去設計和開發,這是我們保證工程品質的唯一方法。

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

Reference

  • goimports vs gofmt
  • Style guideline for Go packages
  • Standard Package Layout
  • Internal packages in Go
  • The init function · Effective Go
-- 關注我, 讓你跑得更快