golang編碼技巧總結

golang編碼技巧總結

面向接口

面向接口編程是一個老生常談的話題,接口 的做用其實就是爲不一樣層級的模塊提供了一個定義好的中間層,上游再也不須要依賴下游的具體實現,充分地對上下游進行了解耦。git

golang-interfacegithub

這種編程方式不只是在 Go 語言中是被推薦的,在幾乎全部的編程語言中,咱們都會推薦這種編程的方式,它爲咱們的程序提供了很是強的靈活性,想要構建一個穩定、健壯的 Go 語言項目,不使用接口是徹底沒法作到的。golang

若是一個略有規模的項目中沒有出現任何 type ... interface 的定義,那麼做者能夠推測出這在很大的機率上是一個工程質量堪憂而且沒有多少單元測試覆蓋的項目,咱們確實須要認真考慮一下如何使用接口對項目進行重構。數據庫

單元測試是一個項目保證工程質量最有效而且投資回報率最高的方法之一,做爲靜態語言的 Go,想要寫出覆蓋率足夠(最少覆蓋核心邏輯)的單元測試自己就比較困難,由於咱們不能像動態語言同樣隨意修改函數和方法的行爲,而接口就成了咱們的救命稻草,寫出抽象良好的接口並經過接口隔離依賴可以幫助咱們有效地提高項目的質量和可測試性,咱們會在下一節中詳細介紹如何寫單元測試。編程

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 方法:bash

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 接口;
當咱們使用上述方法組織代碼以後,其實就對不一樣模塊的依賴進行了解耦,也正遵循了軟件設計中常常被提到的一句話 — 『依賴接口,不要依賴實現』,也就是面向接口編程。編程語言

單元測試

項目中的單元測試應該是穩定的而且不依賴任何的外部項目,它只是對項目中函數和方法的測試,因此咱們須要在單元測試中對全部的第三方的不穩定依賴進行 Mock,也就是模擬這些第三方服務的接口;除此以外,爲了簡化一次單元測試的上下文,在同一個項目中咱們也會對其餘模塊進行 Mock,模擬這些依賴模塊的返回值。

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

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

  1. 接口
  2. 數據庫
  3. HTTP 請求
  4. 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方法。

golang-interface-blog-example

當咱們定義好了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. 指定packagemxxx,默認的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 語言的單元測試就會很是難寫,這也是爲何從項目中是否有接口就能判斷工程質量的緣由了。

奇技淫巧

猴子補丁

最後要介紹的猴子補丁其實就是一個大殺器了,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.Int63ntime.Now,編譯器可能會直接將這種函數內聯到調用實際發生的代碼處並不會調用原有的方法,因此使用這種方式每每須要咱們在測試時額外指定-gcflags=-l禁止編譯器的內聯優化。

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

bouk/monkey的 README 對於它的使用給出了一些注意事項,除了內聯編譯以外,咱們須要注意的是不要在單元測試以外的地方使用猴子補丁,咱們應該只在必要的時候使用這種方法,例如依賴的第三方庫沒有提供interface或者修改time.Now以及rand.Int63n等內置函數的返回值用於測試時。

從理論上來講,經過猴子補丁這種方式咱們可以在運行時 Mock Go 語言中的一切函數,這也爲咱們提供了單元測試 Mock 依賴的最終解決方案。
參考文章:
https://draveness.me/golang-101

相關文章
相關標籤/搜索