golang编码技巧总结

面向接口

面向接口编程是一个老生常谈的话题,接口 的作用其实就是为不同层级的模块提供了一个定义好的中间层,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦。

golang-interface

这种编程方式不仅是在 Go 语言中是被推荐的,在几乎所有的编程语言中,我们都会推荐这种编程的方式,它为我们的程序提供了非常强的灵活性,想要构建一个稳定、健壮的 Go 语言项目,不使用接口是完全无法做到的。

如果一个略有规模的项目中没有出现任何 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 方法:

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{}
}
jekyllwordpressListsPostsPostBlogListPosts

golang-interface-blog-example

BlogServiceBlog
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
}
ServicemockgenMockBlog
$ 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:
     rm -rf test/mocks
        
     mkdir -p test/mocks/blog
     mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go
ServiceNewMockBlogBlogEXPECTListPostsPost

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())
}
ServiceBlog[]Post{}ListPosts
package

奇技淫巧

猴子补丁

最后要介绍的猴子补丁其实就是一个大杀器了,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 ./...
interfacetime.Nowrand.Int63n

从理论上来说,通过猴子补丁这种方式我们能够在运行时 Mock Go 语言中的一切函数,这也为我们提供了单元测试 Mock 依赖的最终解决方案。
参考文章:
https://draveness.me/golang-101