代码规范
Go Code Review Comments
goimports = gofmt + import 检查代码规范和依赖包
golint / golangci-lint 静态检查工具
在自建的或者其他的代码托管平台上也应该想尽办法寻找合适的工具,现代的代码托管工具应该都会对 CI/CD 有着非常不错的支持;我们需要通过这些 CI 工具将代码的自动化检查变成 PR 合并和发版的一个前置条件,减少工程师 Review 代码时可能发生的疏漏。
最佳实践
目录结构
├── LICENSE.md
├── Makefile 在任何一个项目中都会存在一些需要运行的脚本,这些脚本文件应该被放到 /scripts 目录中并由 Makefile 触发
├── README.md
├── api 存放的就是当前项目对外提供的各种不同类型的 API 接口定义文件
├── assets
├── build
├── cmd 目录中存储的都是当前项目中的可执行文件
├── configs
├── deployments
├── docs
├── examples
├── githooks
├── init
├── internal 私有代码推荐放到 /internal 目录中,真正的项目代码应该写在 /internal/app 里
├── pkg 存放的就是项目中可以被外部应用使用的代码库,其他的项目可以直接通过 import 引入这里的代码
├── scripts
├── test
├── third_party
├── tools
├── vendor
├── web
└── website
模块拆分
- 按层拆分 MVC
$ tree -L 2 app
app
├── controllers
│ ├── application_controller.rb
│ └── concerns
├── models
│ ├── application_record.rb
│ └── concerns
└── views
└── layouts
- 按职责拆分
按照职责垂直拆分的方式在单体服务遇到瓶颈时非常容易对微服务进行拆分,我们可以直接将一个负责独立功能的 package 拆出去,对这部分性能热点单独进行扩容;
// 按照不同的职责将其纵向拆分成 post、user、comment 三个模块,每一个模块都对外提供相应的功能
// post 模块中就包含相关的模型和视图定义以及用于处理 API 请求的控制器
// Go 语言项目中的每一个文件目录都代表着一个独立的命名空间
$ tree pkg
pkg
├── comment
├── post
│ ├── handler.go
│ └── post.go
└── user
显示调用
- 显示初始化
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
}
- 方法调用
- 错误处理
1.将错误抛给上层处理 — 对于一个方法是否需要返回 error 也需要我们仔细地思考,向上抛出错误时可以通过 errors.Wrap 携带一些额外的信息方便上层进行判断;
2.处理所有可能返回的错误 — 所有可能返回错误的地方最终一定会返回错误,考虑全面才能帮助我们构建更加健壮的项目
面向接口
- 使用大写的 Service 对外暴露方法
- 使用小写的 service 实现接口中定义的方法
- 通过 NewService 函数初始化 Service 接口
type Service interface { ... }
type service struct { ... }
func NewService(...) (Service, error) {
return &service{...}, nil
}
`想要构建一个稳定、健壮的 Go 语言项目,不使用接口是完全无法做到的`
`思想和模式:当我们使用下面方法组织代码之后,其实就对不同模块的依赖进行了解耦`
`只有我们使用接口才能脱离依赖具体实现的窘境,接口的使用能够为我们带来更清晰的抽象,帮助我们思考如何对代码进行设计,也能让我们更方便地对依赖进行 Mock`
`如果一个略有规模的项目中没有出现任何 type ... interface 的定义,那么作者可以推测出这在很大的概率上是一个工程质量堪忧并且没有多少单元测试覆盖的项目,我们确实需要认真考虑一下如何使用接口对项目进行重构`
// 它不仅在 init 函数中隐式地初始化了 grpc 连接这种全局变量
// 而且没有将 ListPosts 通过接口的方式暴露出去,这会让依赖 ListPosts 的上层模块难以测试。
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
}
package post
type Service interface {
ListPosts() ([]*Post, error)
}
// 通过接口 Service 暴露对外的 ListPosts 方法;
// 使用 NewService 函数初始化 Service 接口的实现并通过私有的接口体 service 持有 grpc 连接;
// ListPosts 不再依赖全局变量,而是依赖接口体 service 持有的连接;
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
}
// 当我们使用这种方式重构代码之后,就可以在 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)
}
单元测试
- 单元测试是一个项目保证工程质量最有效并且投资回报率最高的方法之一
- 每一个单元测试都表示一个可能发生的情况,单元测试就是业务逻辑
可测试
想要想清楚什么样的才是可测试的,我们首先要知道测试是什么?
作者对于测试的理解就是控制变量,在我们隔离了待测试方法中一些依赖之后,当函数的入参确定时,就应该得到期望的返回值
- 单元测试的执行不应该依赖于任何的外部模块,无论是调用外部的 HTTP 请求还是数据库中的数据
- 想尽办法模拟可能出现的情况
函数简单
长度和理解复杂度都有着非常严格的限制,在默认情况下函数的行数不能超过 10 行
组织方式
- Test: 最常见以及默认组织方式就是写在以 _test.go 结尾的文件中
- Suite: 按照簇进行组织
- BDD: 『描述…,当…时,它应该…』
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{}
}
// 如果我们想要对 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))
}
// 当我们生成了上述的 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())
}
- 数据库
使用 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())
}
- 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"}`))
...
}
- Redis、缓存以及其他依赖
- 猴子补丁
- 测试辅助工具 断言
总结
- 代码规范:使用辅助工具帮助我们在每次提交 PR 时自动化地对代码进行检查,减少工程师人工审查的工作量;
- 最佳实践
- 目录结构:遵循 Go 语言社区中被广泛达成共识的 目录结构,减少项目的沟通成本;
- 模块拆分:按照职责对不同的模块进行拆分,Go 语言的项目中也不应该出现 model、controller 这种违反语言顶层设计思路的包名;
- 显示与隐式:尽可能地消灭项目中的 init 函数,保证显式地进行方法的调用以及错误的处理;
- 面向接口:面向接口是 Go 语言鼓励的开发方式,也能够为我们写单元测试提供方便,我们应该遵循固定的模式对外提供功能;
- 使用大写的 Service 对外暴露方法;
- 使用小写的 service 实现接口中定义的方法;
- 通过 func NewService(…) (Service, error) 函数初始化 Service 接口;
- 单元测试:保证项目工程质量的最有效办法;
- gomock:最标准的也是最被鼓励的方式;
- sqlmock:处理依赖的数据库;
- httpmock:处理依赖的 HTTP 请求;
- monkey:万能的方法,但是只在万不得已时使用,类似的代码写起来非常冗长而且不直观;
- 可测试:意味着面向接口编程以及减少单个函数中包含的逻辑,使用『小方法』;
- 组织方式:使用 Go 语言默认的 Test 框架、开源的 suite 或者 BDD 的风格对单元测试进行合理组织;
- Mock 方法:四种不同的单元测试 Mock 方法;
- 断言:使用社区的 testify 快速验证方法的返回值;