Go 语言是一门简单、易学的编程语言,对于有编程背景的工程师来说,学习 Go 语言并写出能够运行的代码并不是一件困难的事情,对于之前有过其他语言经验的开发者来说,写什么语言都像自己学过的语言其实是有问题的,想要真正融入生态写出优雅的代码就一定要花一些时间和精力了解语言背后的设计哲学和最佳实践。
如果你之前没有 Go 语言的开发经历,正在学习和使用 Go 语言,相信这篇文章能够帮助你更快地写出优雅的 Go 语言代码;在这篇文章中,我们并不会给一个长长地列表介绍变量、方法和结构体应该怎么命名,这些 Go 语言的代码规范可以在 Go Code Review Comments 中找到,它们非常重要但并不是这篇文章想要介绍的重点,我们将从代码结构、最佳实践以及单元测试几个不同的方面介绍如何写出优雅的 Go 语言代码。
相信读完了这篇文章,我们也不能立刻写出优雅的 Go 语言代码,但是如果我们遵循这里介绍几个的容易操作并且切实可行的方法,就帮助我们走出第一步,作者写这篇文章有以下的几个目的:
帮助 Go 语言的开发者了解生态中的规范与工具,写出更优雅的代码;
为代码和项目的管理提供被社区广泛认同的规则、共识以及最佳实践;
代码规范
代码规范其实是一个老生常谈的问题,我们也不能免俗还是要简单介绍一下相关的内容,Go 语言比较常见并且使用广泛的代码规范就是官方提供的 Go Code Review Comments,无论你是短期还是长期使用 Go 语言编程,都应该至少完整地阅读一遍这个官方的代码规范指南,它既是我们在写代码时应该遵守的规则,也是在代码审查时需要注意的规范。
学习 Go 语言相关的代码规范是一件非常重要的事情,也是让我们的项目遵循统一规范的第一步,虽然阅读代码规范相关的文档非常重要,但是在实际操作时我们并不能靠工程师自觉地遵守以及经常被当做形式的代码审查,而是需要借助工具来辅助执行。
goimports 是 Go 语言官方提供的工具,它能够为我们自动格式化 Go 语言代码并对所有引入的包进行管理,包括自动增删依赖的包引用、将依赖包按字母序排序并分类。相信很多人使用的 IDE 都会将另一个官方提供的工具 gofmt 对代码进行格式化,而 goimports 就是等于 gofmt 加上依赖包管理。
建议所有 Go 语言的开发者都在开发时使用 goimports,虽然 goimports 有时会引入错误的包,但是与带来的好处相比,这些偶尔出现的错误在作者看来也是可以接受的;当然,不想使用 goimports 的开发者也一定要在 IDE 或者编辑器中开启自动地 gofmt(保存时自动格式化)。
在 IDE 和 CI 检查中开启自动地 gofmt 或者 goimports 检查是没有、也不应该有讨论的必要的,这就是一件使用和开发 Go 语言必须要做的事情。
$ golint ./pkg/...
pkg/liquidity/liquidity_pool.go:18:2: exported var ErrOrderBookNotFound should have comment or be unexported
pkg/liquidity/liquidity_pool.go:23:6: exported type LiquidityPool should have comment or be unexported
pkg/liquidity/liquidity_pool.go:23:6: type name will be used as liquidity.LiquidityPool by other packages, and that stutters; consider calling this Pool
pkg/liquidity/liquidity_pool.go:31:1: exported function NewLiquidityPool should have comment or be unexported
...
An import of a path containing the element “internal” is disallowed
if the importing code is outside the tree rooted at the parent of the
"internal" directory.
Java 和 Ruby 这些语言在框架中往往采用水平拆分的方式划分不同层级的职责,而 Go 语言项目的最佳实践就是按照职责对模块进行垂直拆分,将代码按照功能的方式分到多个 package 中,这并不是说 Go 语言中不存在模块的水平拆分,只是因为 package 作为一个 Go 语言访问控制的最小粒度,所以我们应该遵循顶层的设计使用这种方式构建高内聚的模块。
显式与隐式
从开始学习、使用 Go 语言到参与社区上一些开源的 Go 语言项目,作者发现 Go 语言社区对于显式的初始化、方法调用和错误处理非常推崇,类似 Spring Boot 和 Rails 的框架其实都广泛地采纳了『约定优于配置』的中心思想,简化了开发者和工程师的工作量。
然而 Go 语言社区虽然达成了很多的共识与约定,但是从语言的设计以及工具上的使用我们就能发现显式地调用方法和错误处理是被鼓励的。
init
我们在这里先以一个非常常见的函数 init 为例,介绍 Go 语言社区对显式调用的推崇;相信很多人都在一些 package 中阅读过这样的代码:
当然这并不是说我们一定不能使用 init 函数,作为 Go 语言赋予开发者的能力,因为它能在包被引入时隐式地执行了一些代码,所以我们更应该慎重地使用它们。
一些框架会在 init 中判断是否满足使用的前置条件,但是对于很多的 Web 或者 API 服务来说,大量使用 init 往往意味着代码质量的下降以及不合理的设计。
funcinit(){if user ==""{
log.Fatal("$USER not set")}if home ==""{
home ="/home/"+ user
}if gopath ==""{
gopath = home +"/go"}// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath,"gopath", gopath,"override default GOPATH")}
上述代码其实是 Effective Go 在介绍 init 方法使用是展示的实例代码,这是一个比较合理地 init 函数使用示例,我们不应该在 init 中做过重的初始化逻辑,而是做一些简单、轻量的前置条件判断。
error
另一个要介绍的就是 Go 语言的错误处理机制了,虽然 Go 语言的错误处理被开发者诟病已久,但是工程师每天都在写 if err != nil { return nil, err } 的错误处理逻辑其实就是在显式地对错误处理,关注所有可能会发生错误的方法调用并在无法处理时抛给上层模块。
package post
type Service interface{ListPosts()([]*Post,error)}type service struct{
conn *grpc.ClientConn
}funcNewService(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...funcmain(){
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 语言中非常常见,我们应该在代码中尽可能地使用这种思想和模式对外提供功能:
作为软件工程师,重构现有的项目对于我们来说应该是一件比较正常的事情,如果项目中没有单元测试,我们很难在不改变已有业务逻辑的情况对项目进行重构,一些业务的边界情况很可能会在重构的过程中丢失,当时参与相应 case 开发的工程师可能已经不在团队中,而项目相关的文档可能也消失在了归档的 wiki 中(更多的项目可能完全没有文档),我们能够在重构中相信的东西其实只有当前的代码逻辑(很可能是错误的)以及单元测试(很可能是没有的)。
var_=Describe("Book",func(){var(
book Book
err error)BeforeEach(func(){
book, err =NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488
}`)})Describe("loading from JSON",func(){Context("when the JSON fails to parse",func(){BeforeEach(func(){
book, err =NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488oops
}`)})It("should return the zero-value for the book",func(){Expect(book).To(BeZero())})It("should error",func(){Expect(err).To(HaveOccurred())})})})})
BDD 框架中一般都包含 Describe、Context 以及 It 等代码块,其中 Describe 的作用是描述代码的独立行为、Context 是在一个独立行为中的多个不同上下文,最后的 It 用于描述期望的行为,这些代码块最终都构成了类似『描述……,当……时,它应该……』的句式帮助我们快速地理解测试代码。
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
}funcNewService(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 instancefuncNewMockBlog(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 usefunc(m *MockBlog)EXPECT()*MockBlogMockRecorder {return m.recorder
}// ListPosts mocks base methodfunc(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 ListPostsfunc(mr *MockBlogMockRecorder)ListPosts()*gomock.Call {
mr.mock.ctrl.T.Helper()return mr.mock.ctrl.RecordCallWithMethodType(mr.mock,"ListPosts", reflect.TypeOf((*MockBlog)(nil).ListPosts))}
funcTestFetchArticles(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"}`))...}
funcmain(){
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*?}
funcTestSomething(t *testing.T){
assert.Equal(t,123,123,"they should be equal")
assert.NotEqual(t,123,456,"they should not be equal")
assert.Nil(t, object)if assert.NotNil(t, object){
assert.Equal(t,"Something", object.Value)}}