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 的不同方法之前,我们首先要清楚一些常见的依赖,一个函数或者方法的常见依赖可以有以下几种:
- 接口
- 数据库
- 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 语言的单元测试就会非常难写,这也是为什么从项目中是否有接口就能判断工程质量的原因了。
奇技淫巧
猴子补丁
最后要介绍的猴子补丁其实就是一个大杀器了,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 依赖的最终解决方案。
参考文章:
https://draveness.me/golang-101
有疑问加站长微信联系(非本文作者)