寫出可測試的 Go 程式碼,SOLID原則,go方法和介面
寫出可測試的 Go 程式碼
https://mp.weixin.qq.com/s/addWJ6zVj1vZgNjh3xeRQg
剔除干擾因素
假設我們現在有一個根據時間判斷報警資訊傳送速率的模組,白天工作時間允許大量傳送報警資訊,而晚上則減小發送速率,凌晨不允許傳送報警簡訊。
// judgeRate 報警速率決策函式 func judgeRate() int { now := time.Now() switch hour := now.Hour(); { case hour >= 8 && hour < 20: return 10 case hour >= 20 && hour <= 23: return 1 } return -1 }
函式現在隱式包含了一個不確定因素——時間
(當然可以使用打樁工具對time.Now
進行打樁,但那不是本文要強調的重點)。
// judgeRateByTime 報警速率決策函式
func judgeRateByTime(now time.Time) int {
switch hour := now.Hour(); {
case hour >= 8 && hour < 20:
return 10
case hour >= 20 && hour <= 23:
return 1
}
return -1
}
介面抽象進行解耦
假設我們實現了一個獲取店鋪客單價的需求,它完成的功能就像下面的示例函式。
// GetAveragePricePerStore 每家店的人均價 func GetAveragePricePerStore(storeName string) (int64, error) { res, err := http.Get("https://liwenzhou.com/api/orders?storeName=" + storeName) if err != nil { return 0, err } defer res.Body.Close() var orders []Order if err := json.NewDecoder(res.Body).Decode(&orders); err != nil { return 0, err } if len(orders) == 0 { return 0, nil } var ( p int64 n int64 ) for _, order := range orders { p += order.Price n += order.Num } return p / n, nil }
在之前的章節中我們介紹瞭如何為上面的程式碼編寫單元測試,但是我們如何避免每次單元測試時都發起真實的HTTP請求呢?亦或者後續我們改變了獲取資料的方式(直接讀取快取或改為RPC呼叫)這個函式該怎麼相容呢?
我們將函式中獲取資料的部分抽象為介面型別來優化我們的程式,使其支援模組化的資料來源配置。
// OrderInfoGetter 訂單資訊提供者
type OrderInfoGetter interface {
GetOrders(string) ([]Order, error)
}
然後定義一個API型別,它擁有一個通過HTTP請求獲取訂單資料的GetOrders
方法,正好實現OrderInfoGetter
介面。
// HttpApi HTTP API型別
type HttpApi struct{}
// GetOrders 通過HTTP請求獲取訂單資料的方法
func (a HttpApi) GetOrders(storeName string) ([]Order, error) {
res, err := http.Get("https://liwenzhou.com/api/orders?storeName=" + storeName)
if err != nil {
return nil, err
}
defer res.Body.Close()
var orders []Order
if err := json.NewDecoder(res.Body).Decode(&orders); err != nil {
return nil, err
}
return orders, nil
}
將原來的 GetAveragePricePerStore
函式修改為以下實現。
// GetAveragePricePerStore 每家店的人均價
func GetAveragePricePerStore(getter OrderInfoGetter, storeName string) (int64, error) {
orders, err := getter.GetOrders(storeName)
if err != nil {
return 0, err
}
if len(orders) == 0 {
return 0, nil
}
var (
p int64
n int64
)
for _, order := range orders {
p += order.Price
n += order.Num
}
return p / n, nil
}
經過這番改動之後,我們的程式碼就能很容易地寫出單元測試程式碼。例如,對於不方便直接請求的HTTP API, 我們就可以進行 mock 測試。
依賴注入代替隱式依賴
我們可能經常會看到類似下面的程式碼,在應用程式中使用全域性變數的方式引入日誌庫或資料庫連線例項等。
package main
import (
"github.com/sirupsen/logrus"
)
var log = logrus.New()
type App struct{}
func (a *App) Start() {
log.Info("app start ...")
}
func (a *app) Start() {
a.Logger.Info("app start ...")
// ...
}
func main() {
app := &App{}
app.Start()
}
我們應該將依賴項解耦出來,並且將依賴注入到我們的 App 例項中,而不是在其內部隱式呼叫全域性變數。
type App struct {
Logger
}
func (a *App) Start() {
a.Logger.Info("app start ...")
// ...
}
// NewApp 建構函式,將依賴項注入
func NewApp(lg Logger) *App {
return &App{
Logger: lg, // 使用傳入的依賴項完成初始化
}
}
上面的程式碼就很容易 mock log例項,完成單元測試。
依賴注入就是指在建立元件(Go 中的 struct)的時候接收它的依賴項,而不是它的初始化程式碼中引用外部或自行建立依賴項。
// Config 配置項結構體
type Config struct {
// ...
}
// LoadConfFromFile 從配置檔案中載入配置
func LoadConfFromFile(filename string) *Config {
return &Config{}
}
// Server server 程式
type Server struct {
Config *Config
}
// NewServer Server 建構函式
func NewServer() *Server {
return &Server{
// 隱式建立依賴項
Config: LoadConfFromFile("./config.toml"),
}
}
上面的程式碼片段中就通過在建構函式中隱式建立依賴項,這樣的程式碼強耦合、不易擴充套件,也不容易編寫單元測試。我們完全可以通過使用依賴注入的方式,將建構函式中的依賴作為引數傳遞給建構函式。
// NewServer Server 建構函式
func NewServer(conf *Config) *Server {
return &Server{
// 隱式建立依賴項
Config: conf,
}
}
不要隱式引用外部依賴(全域性變數、隱式輸入等),而是通過依賴注入的方式引入依賴。經過這樣的修改之後,建構函式NewServer
的依賴項就很清晰,同時也方便我們編寫 mock 測試程式碼。
使用依賴注入的方式能夠讓我們的程式碼看起來更清晰,但是過多的建構函式也會讓主函式的程式碼迅速膨脹,好在Go 語言提供了一些依賴注入工具(例如 wire ,可以幫助我們更好的管理依賴注入的程式碼。
SOLID原則
最後我們補充一個程式設計的SOLID
原則,我們在程式設計時踐行以下幾個原則會幫助我們寫出可測試的程式碼。
首字母 | 指代 | 概念 |
---|---|---|
S | 單一職責原則 | 每個類都應該只有一個職責。 |
O | 開閉原則 | 一個軟體實體,如類、模組和函式應該對擴充套件開放,對修改關閉。 |
L | 裡式替換原則 | 認為“程式中的物件應該是可以在不改變程式正確性的前提下被它的子類所替換的”的概念。 |
I | 介面隔離原則 | 許多特定於客戶端的介面優於一個通用介面。 |
D | 依賴反轉原則 | 應該依賴抽象,而不是某個具體示例。 |
有時候在寫程式碼之前多考慮一下程式碼的設計是否符合上述原則。
go方法和介面
方法
方法與物件繫結,簡單的來講只是將物件傳遞給函式使其成為一種特殊(只屬於該物件)的函式,因為Golang
是沒有類
這個概念(在Golang
裡,結構體
是類
的簡化版),所以也可以將方法理解為類的成員函式
,但需要注意的是,在Golang
裡幾乎所有資料型別都可以與方法繫結。
指標或者值作為繫結物件的區別
指標和值都可以繫結方法,並且我們不需要手動區分,這是因為Golang
會自動解引用。
只讀物件的內部變數
指標和值是沒有區別的,下面的程式碼分別使用了值和指標繫結:
func (t *Test1) Sum() int {
return t.aaa + t.bbb
}
func (t Test1) Mul() int {
return t.aaa * t.bbb
}
然後我們定義一個物件來分別呼叫上面的兩個方法:
ttt := Test1{aaa: 5, bbb: 2}
fmt.Println("Sum:", ttt.Sum())
fmt.Println("Mul:", ttt.Mul())
// output:
// Sum: 7
// Mul: 10
修改物件的內部變數
如果需要修改物件的內部變數,就必須在物件的指標型別上定義該方法,下面的程式碼分別使用了值和指標繫結:
func (t *Test1) modifyByAddr(a int) {
t.aaa = a
}
func (t Test1) modifyByValue(a int) {
t.aaa = a
}
然後我們定義一個物件來分別呼叫上面的兩個方法:
fmt.Println("old value:", ttt)
ttt.modifyByValue(222)
fmt.Println("modifyByValue:", ttt)
ttt.modifyByAddr(111)
fmt.Println("modifyByAddr:", ttt)
// output
// old value: aaa:5, bbb:2
// modifyByValue: aaa:5, bbb:2
// modifyByAddr: aaa:111, bbb:2
函式與方法的區別
通過上面的例子來說明
-
函式
將變數當做引數傳入Test1Sum(ttt)
-
方法
是被變數呼叫ttt.Mul()
和ttt.Sum()
介面
介面定義了一組方法,但這些方法並沒有實現,使用該介面的前提是物件實現了介面內部的方法,這裡需要特別注意,物件必須實現接口裡的所以方法,或者會報錯。
package main
import (
"fmt"
"testing"
)
type Test1 struct {
aaa int
bbb int
}
type TestInterface interface {
Sum() int
modify(int, int)
}
func (t *Test1) modify(a, b int) {
t.aaa = a
t.bbb = b
}
func (t *Test1) Sum() int {
return t.aaa + t.bbb
}
func (t Test1) Mul() int {
return t.aaa * t.bbb
}
func (t *Test1) modifyByAddr(a int) {
t.aaa = a
}
func (t Test1) modifyByValue(a int) {
t.aaa = a
}
func TestAmain(t *testing.T) {
// ttt := Test1{aaa: 5, bbb: 2}
// fmt.Println("Sum:", ttt.Sum())
// fmt.Println("Mul:", ttt.Mul())
// ttt := Test1{aaa: 5, bbb: 2}
// fmt.Println("old value:", ttt)
// ttt.modifyByValue(222)
// fmt.Println("modifyByValue:", ttt)
// ttt.modifyByAddr(111)
// fmt.Println("modifyByAddr:", ttt)
ttt := new(Test1)
ttt.aaa = 5
ttt.bbb = 2
var test1Face TestInterface
test1Face = ttt
test1Face.modify(123, 456)
fmt.Println("test1Face", test1Face)
}