Go 语言是一门简单、易学的编程语言,对于有编程背景的工程师来讲,学习 Go 语言并写出可以运行的代码并非一件困难的事情,对于以前有过其余语言经验的开发者来讲,写什么语言都像本身学过的语言实际上是有问题的,想要真正融入生态写出优雅的代码就必定要花一些时间和精力了解语言背后的设计哲学和最佳实践。html
若是你以前没有 Go 语言的开发经历,正在学习和使用 Go 语言,相信这篇文章可以帮助你更快地写出优雅的 Go 语言代码;在这篇文章中,咱们并不会给一个长长地列表介绍变量、方法和结构体应该怎么命名,这些 Go 语言的代码规范能够在 Go Code Review Comments 中找到,它们很是重要但并非这篇文章想要介绍的重点,咱们将从代码结构、最佳实践以及单元测试几个不一样的方面介绍如何写出优雅的 Go 语言代码。git
想要写出好的代码并非一件容易的事情,它须要咱们不断地对现有的代码进行反思 — 如何改写这段代码才能让它变得更加优雅。优雅听起来是一个很是感性、难以量化的结果,然而这倒是好的代码可以带来的最直观感觉,它可能隐式地包含了如下特性:github
相信读完了这篇文章,咱们也不能马上写出优雅的 Go 语言代码,可是若是咱们遵循这里介绍几个的容易操做而且切实可行的方法,就帮助咱们走出第一步,做者写这篇文章有如下的几个目的:golang
代码规范实际上是一个老生常态的问题,咱们也不能免俗仍是要简单介绍一下相关的内容,Go 语言比较常见而且使用普遍的代码规范就是官方提供的 Go Code Review Comments,不管你是短时间仍是长期使用 Go 语言编程,都应该至少完整地阅读一遍这个官方的代码规范指南,它既是咱们在写代码时应该遵照的规则,也是在代码审查时须要注意的规范。web
学习 Go 语言相关的代码规范是一件很是重要的事情,也是让咱们的项目遵循统一规范的第一步,虽然阅读代码规范相关的文档很是重要,可是在实际操做时咱们并不能靠工程师自觉地遵照以及常常被当作形式的代码审查,而是须要借助工具来辅助执行。sql
使用自动化的工具保证项目遵照一些最基本的代码规范是很是容易操做和有效的事情,相比之下人肉审查代码的方式更加容易出错,也会出现一些违反规则和约定的特例,维护代码规范的最好方式就是『尽可能自动化一切可以自动化的步骤,让工程师审查真正重要的逻辑和设计』。数据库
咱们在这一节中就会介绍两种很是切实有效的办法帮助咱们在项目中自动化地进行一些代码规范检查和静态检查保证项目的质量。编程
goimports 是 Go 语言官方提供的工具,它可以为咱们自动格式化 Go 语言代码并对全部引入的包进行管理,包括自动增删依赖的包引用、将依赖包按字母序排序并分类。相信不少人使用的 IDE 都会将另外一个官方提供的工具 gofmt 对代码进行格式化,而 goimports
就是等于 gofmt
加上依赖包管理。api
建议全部 Go 语言的开发者都在开发时使用 goimports
,虽然 goimports
有时会引入错误的包,可是与带来的好处相比,这些偶尔出现的错误在做者看来也是能够接受的;固然,不想使用 goimports
的开发者也必定要在 IDE 或者编辑器中开启自动地 gofmt
(保存时自动格式化)。数组
在 IDE 和 CI 检查中开启自动地
gofmt
或者goimports
检查是没有、也不该该有讨论的必要的,这就是一件使用和开发 Go 语言必需要作的事情。
另外一个比较经常使用的静态检查工具就是 golint
了,做为官方提供的工具,它在可定制化上有着很是差的支持,咱们只能经过以下所示的方式运行 golint
对咱们的项目进行检查:
$ 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 ...
社区上有关于 golint
定制化的 讨论,golint
的开发者给出了如下的几个观点解释为何 golint
不支持定制化的功能:
lint
的目的就是在 Go 语言社区中鼓励统1、一致的编程风格,某些开发者也许不会赞成其中的某些规范,可是使用统一的风格对于 Go 语言社区有比较强的好处,而可以开关指定规则的功能会致使golint
不可以有效地完成这个工做;- 有一些静态检查的规则会致使一些错误的警告,这些状况确实很是让人头疼,可是我会选择支持在 golint 中直接保留或者删除这些规则,而不是提供随意增删规则的能力;
- 可以经过
min_confidence
过滤一些静态检查规则,可是须要咱们选择合适的值;
golint
做者的观点在 issue 中获得了很是多的 👎,可是这件事情很难说对错;在社区中保证一致的编程规范是一件很是有益的事情,不过对于不少公司内部的服务或者项目,可能在业务服务上就会发生一些比较棘手的状况,使用这种过强的约束没有太多明显地收益。
更推荐的方法是在基础库或者框架中使用 golint
进行静态检查(或者同时使用 golint
和 golangci-lint),在其余的项目中使用可定制化的 golangci-lint
来进行静态检查,由于在基础库和框架中施增强限制对于总体的代码质量有着更大的收益。
做者会在本身的 Go 项目中使用
golint
+golangci-lint
并开启所有的检查尽可能尽早发现代码中包含文档在内的所有缺陷。
不管是用于检查代码规范和依赖包的 goimports
仍是静态检查工具 glint
或者 golangci-lint
,只要咱们在项目中引入这些工具就必定要在代码的 CI 流程中加入对应的自动化检查:
在自建的或者其余的代码托管平台上也应该想尽办法寻找合适的工具,现代的代码托管工具应该都会对 CI/CD 有着很是不错的支持;咱们须要经过这些 CI 工具将代码的自动化检查变成 PR 合并和发版的一个前置条件,减小工程师 Review 代码时可能发生的疏漏。
咱们在上一节中介绍了一些能经过自动化工具发现的问题,这一节提到的最佳实践可能就没有办法经过自动化工具进行保证,这些最佳实践更像是 Go 语言社区内部发展过程当中积累的一些工程经验和共识,遵循这些最佳实践可以帮助咱们写出符合 Go 语言『味道』的代码,咱们将在这一小节覆盖如下的几部份内容:
这四部份内容是在社区中相对来讲比较常见的约定,若是咱们学习并遵循了这些约定,同时在 Go 语言的项目中实践这几部份内容,相信必定会对咱们设计 Go 语言项目有所帮助。
目录结构基本上就是一个项目的门面,不少时候咱们从目录结构中就可以看出开发者对这门语言是否有足够的经验,因此在这里首先要介绍的最佳实践就是如何在 Go 语言的项目或者服务中组织代码。
官方并无给出一个推荐的目录划分方式,不少项目对于目录结构的划分也很是随意,这其实也是没有什么问题的,可是社区中仍是有一些比较常见的约定,例如:golang-standards/project-layout 项目中就定义了一个比较标准的目录结构。
├── LICENSE.md
├── Makefile
├── README.md
├── api
├── assets
├── build
├── cmd
├── configs
├── deployments
├── docs
├── examples
├── githooks
├── init
├── internal
├── pkg
├── scripts
├── test ├── third_party ├── tools ├── vendor ├── web └── website
咱们在这里就先简单介绍其中几个比较常见而且重要的目录和文件,帮助咱们快速理解如何使用如上所示的目录结构,若是各位读者想要了解使用其余目录的缘由,能够从 golang-standards/project-layout 项目中的 README 了解更详细的内容。
/pkg
目录是 Go 语言项目中很是常见的目录,咱们几乎可以在全部知名的开源项目(非框架)中找到它的身影,例如:
这个目录中存放的就是项目中能够被外部应用使用的代码库,其余的项目能够直接经过 import
引入这里的代码,因此当咱们将代码放入 pkg
时必定要慎重,不过若是咱们开发的是 HTTP 或者 RPC 的接口服务或者公司的内部服务,将私有和公有的代码都放到 /pkg
中也没有太多的不妥,由于做为最顶层的项目来讲不多会被其余应用直接依赖,固然严格遵循公有和私有代码划分是很是好的作法,做者也建议各位开发者对项目中公有和私有的代码进行妥善的划分。
私有代码推荐放到 /internal
目录中,真正的项目代码应该写在 /internal/app
里,同时这些内部应用依赖的代码库应该在 /internal/pkg
子目录和 /pkg
中,下图展现了一个使用 /internal
目录的项目结构:
当咱们在其余项目引入包含 internal
的依赖时,Go 语言会在编译时报错:
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.
这种错误只有在被引入的 internal
包不存在于当前项目树中才会发生,若是在同一个项目中引入该项目的 internal
包并不会出现这种错误。
在 Go 语言的项目最不该该有的目录结构其实就是 /src
了,社区中的一些项目确实有 /src
文件夹,可是这些项目的开发者以前大多数都有 Java 的编程经验,这在 Java 和其余语言中实际上是一个比较常见的代码组织方式,可是做为一个 Go 语言的开发者,咱们不该该容许项目中存在 /src
目录。
最重要的缘由实际上是 Go 语言的项目在默认状况下都会被放置到 $GOPATH/src
目录下,这个目录中存储着咱们开发和依赖的所有项目代码,若是咱们在本身的项目中使用 /src
目录,该项目的 PATH
中就会出现两个 src
:
$GOPATH/src/github.com/draveness/project/src/code.go
上面的目录结构看起来很是奇怪,这也是咱们在 Go 语言中不建议使用 /src
目录的最重要缘由。
固然哪怕咱们在 Go 语言的项目中使用 /src
目录也不会致使编译不经过或者其余问题,若是坚持这种作法对于项目的可用性也没有任何的影响,可是若是想让咱们『看起来』更专业,仍是遵循社区中既定的约定减小其余 Go 语言开发者的理解成本,这对于社区来讲是一件好事。
另外一种在 Go 语言中组织代码的方式就是项目的根目录下放项目的代码,这种方式在不少框架或者库中很是常见,若是想要引入一个使用 pkg
目录结构的框架时,咱们每每须要使用 github.com/draveness/project/pkg/somepkg
,当代码都平铺在项目的根目录时只须要使用 github.com/draveness/project
,很明显地减小了引用依赖包语句的长度。
因此对于一个 Go 语言的框架或者库,将代码平铺在根目录下也很正常,可是在一个 Go 语言的服务中使用这种代码组织方法可能就没有那么合适了。
/cmd
目录中存储的都是当前项目中的可执行文件,该目录下的每个子目录都应该包含咱们但愿有的可执行文件,若是咱们的项目是一个 grpc
服务的话,可能在 /cmd/server/main.go
中就包含了启动服务进程的代码,编译后生成的可执行文件就是 server
。
咱们不该该在 /cmd
目录中放置太多的代码,咱们应该将公有代码放置到 /pkg
中并将私有代码放置到 /internal
中并在 /cmd
中引入这些包,保证 main
函数中的代码尽量简单和少。
/api
目录中存放的就是当前项目对外提供的各类不一样类型的 API 接口定义文件了,其中可能包含相似 /api/protobuf-spec
、/api/thrift-spec
或者 /api/http-spec
的目录,这些目录中包含了当前项目对外提供的和依赖的全部 API 文件:
$ tree ./api api └── protobuf-spec └── oceanbookpb ├── oceanbook.pb.go └── oceanbook.proto
二级目录的主要做用就是在一个项目同时提供了多种不一样的访问方式时,用这种办法避免可能存在的潜在冲突问题,也可让项目结构的组织更加清晰。
最后要介绍的 Makefile
文件也很是值得被关注,在任何一个项目中都会存在一些须要运行的脚本,这些脚本文件应该被放到 /scripts
目录中并由 Makefile
触发,将这些常常须要运行的命令固化成脚本减小『祖传命令』的出现。
总的来讲,每个项目都应该按照固定的组织方式进行实现,这种约定虽然并非强制的,可是不管是组内、公司内仍是整个 Go 语言社区中,只要达成了一致,对于其余工程师快速梳理和理解项目都是颇有帮助的。
这一节介绍的 Go 语言项目的组织方式也并非强制要求的,这只是 Go 语言社区中常常出现的项目组织方式,一个大型项目在使用这种目录结构时也会对其进行微调,不过这种组织方式确实更为常见而且合理。
咱们既然已经介绍过了如何从顶层对项目的结构进行组织,接下来就会深刻到项目的内部介绍 Go 语言对模块的一些拆分方法。
Go 语言的一些顶层设计最终致使了它在划分模块上与其余的编程语言有着很是明显的不一样,不少其余语言的 Web 框架都采用 MVC 的架构模式,例如 Rails 和 Spring MVC,Go 语言对模块划分的方法就与 Ruby 和 Java 彻底不一样。
不管是 Java 仍是 Ruby,它们最著名的框架都深受 MVC 架构模式 的影响,咱们从 Spring MVC 的名字中就能体会到 MVC 对它的影响,而 Ruby 社区的 Rails 框架也与 MVC 的关系很是紧密,这是一种 Web 框架的最多见架构方式,将服务中的不一样组件分红了 Model、View 和 Controller 三层。
这种模块拆分的方式其实就是按照层级进行拆分,Rails 脚手架默认生成的代码其实就是将这三层不一样的源文件放在对应的目录下:models
、views
和 controllers
,咱们经过 rails new example
生成一个新的 Rails 项目后能够看到其中的目录结构:
$ tree -L 2 app app ├── controllers │ ├── application_controller.rb │ └── concerns ├── models │ ├── application_record.rb │ └── concerns └── views └── layouts
而不少 Spring MVC 的项目中也会出现相似 model
、dao
、view
的目录,这种按层拆分模块的设计其实有如下的几方面缘由:
上面的几个缘由共同决定了 Spring MVC 和 Rails 会出现 models
、views
和 controllers
的目录并按照层级的方式对模块进行拆分。
Go 语言在拆分模块时就使用了彻底不一样的思路,虽然 MVC 架构模式是在咱们写 Web 服务时没法避开的,可是相比于横向地切分不一样的层级,Go 语言的项目每每都按照职责对模块进行拆分:
对于一个比较常见的博客系统,使用 Go 语言的项目会按照不一样的职责将其纵向拆分红 post
、user
、comment
三个模块,每个模块都对外提供相应的功能,post
模块中就包含相关的模型和视图定义以及用于处理 API 请求的控制器(或者服务):
$ tree pkg
pkg
├── comment
├── post
│ ├── handler.go │ └── post.go └── user
Go 语言项目中的每个文件目录都表明着一个独立的命名空间,也就是一个单独的包,当咱们想要引用其余文件夹的目录时,首先须要使用 import
关键字引入相应的文件目录,再经过 pkg.xxx
的形式引用其余目录定义的结构体、函数或者常量,若是咱们在 Go 语言中使用 model
、view
和 controller
来划分层级,你会在其余的模块中看到很是多的 model.Post
、model.Comment
和 view.PostView
。
这种划分层级的方法在 Go 语言中会显得很是冗余,而且若是对项目依赖包的管理不够谨慎时,很容易发生引用循环,出现这些问题的最根本缘由其实也很是简单:
package
拆出去,对这部分性能热点单独进行扩容;项目是按照层级仍是按照职责对模块进行拆分其实并无绝对的好与很差,语言和框架层面的设计最终决定了咱们应该采用哪一种方式对项目和代码进行组织。
Java 和 Ruby 这些语言在框架中每每采用水平拆分的方式划分不一样层级的职责,而 Go 语言项目的最佳实践就是按照职责对模块进行垂直拆分,将代码按照功能的方式分到多个 package
中,这并非说 Go 语言中不存在模块的水平拆分,只是由于 package
做为一个 Go 语言访问控制的最小粒度,因此咱们应该遵循顶层的设计使用这种方式构建高内聚的模块。
从开始学习、使用 Go 语言到参与社区上一些开源的 Golang 项目,做者发现 Go 语言社区对于显式的初始化、方法调用和错误处理很是推崇,相似 Spring Boot 和 Rails 的框架其实都普遍地采纳了『约定优于配置』的中心思想,简化了开发者和工程师的工做量。
然而 Go 语言社区虽然达成了不少的共识与约定,可是从语言的设计以及工具上的使用咱们就能发现显式地调用方法和错误处理是被鼓励的。
咱们在这里先以一个很是常见的函数 init
为例,介绍 Go 语言社区对显式调用的推崇;相信不少人都在一些 package
中阅读过这样的代码:
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 }
初始化 grpc 链接的代码应该放到 main
函数或者 main
函数调用的其余函数中执行,若是咱们在 main
函数中显式的初始化这种依赖,对于其余的工程师来讲就很是易于理解,咱们从 main
函数开始就能梳理出程序启动的整个过程。
// cmd/grpc/main.go func main() { grpcClient, err := grpc.Dial(...) if err != nil { panic(err) } postClient := post.NewClient(grpcClient) // ... }
各个模块之间会构成一种树形的结构和依赖关系,上层的模块会持有下层模块中的接口或者结构体,不会存在孤立的、不被引用的对象。
上图中出现的两个
Database
实际上是在main
函数中初始化的数据库链接,在项目运行期间,它们可能表示同一个内存中的数据库链接
当咱们使用 golangci-lint 并开启 gochecknoinits
和 gochecknoglobals
静态检查时,它其实严格地限制咱们对 init
函数和全局变量的使用。
固然这并非说咱们必定不能使用 init
函数,做为 Go 语言赋予开发者的能力,由于它能在包被引入时隐式地执行了一些代码,因此咱们更应该慎重地使用它们。
一些框架会在 init
中判断是否知足使用的前置条件,可是对于不少的 Web 或者 API 服务来讲,大量使用 init
每每意味着代码质量的降低以及不合理的设计。
func init() { 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
中作太重的初始化逻辑,而是作一些简单、轻量的前置条件判断。
另外一个要介绍的就是 Go 语言的错误处理机制了,虽然 Golang 的错误处理被开发者诟病已久,可是工程师天天都在写 if err != nil { return nil, err }
的错误处理逻辑其实就是在显式地对错误处理,关注全部可能会发生错误的方法调用并在没法处理时抛给上层模块。
func ListPosts(...) ([]Post, error) { conn, err := gorm.Open(...) if err != nil { return []Post{}, err } var posts []Post if err := conn.Find(&posts).Error; err != nil { return []Post{}, err } return posts, nil }
上述代码只是简单展现 Go 语言常见的错误处理逻辑,咱们不该该在这种方法中初始化数据库的链接。
虽然 Golang 中也有相似 Java 或者 Ruby try/catch
关键字,可是不多有人会在代码中使用 panic
和 recover
来实现错误和异常的处理,与 init
函数同样,Go 语言对于 panic
和 recover
的使用也很是谨慎。
当咱们在 Go 语言中处理错误相关的逻辑时,最重要的其实就是如下几点:
error
实现错误处理 — 尽管这看起来很是啰嗦;error
也须要咱们仔细地思考,向上抛出错误时能够经过 errors.Wrap
携带一些额外的信息方便上层进行判断;做者在使用 Go 语言的这段时间,可以深入地体会到它对于显式方法调用与错误处理的鼓励,这不只可以帮助项目的其余开发者快速地理解上下文,也可以帮助咱们构建更加健壮、容错性与可维护性更好的工程。
面向接口编程是一个老生常谈的话题,接口 的做用其实就是为不一样层级的模块提供了一个定义好的中间层,上游再也不须要依赖下游的具体实现,充分地对上下游进行了解耦。
这种编程方式不只是在 Go 语言中是被推荐的,在几乎全部的编程语言中,咱们都会推荐这种编程的方式,它为咱们的程序提供了很是强的灵活性,想要构建一个稳定、健壮的 Go 语言项目,不使用接口是彻底没法作到的。
若是一个略有规模的项目中没有出现任何 type ... interface
的定义,那么做者能够推测出这在很大的几率上是一个工程质量堪忧而且没有多少单元测试覆盖的项目,咱们确实须要认真考虑一下如何使用接口对项目进行重构。
单元测试是一个项目保证工程质量最有效而且投资回报率最高的方法之一,做为静态语言的 Golang,想要写出覆盖率足够(最少覆盖核心逻辑)的单元测试自己就比较困难,由于咱们不能像动态语言同样随意修改函数和方法的行为,而接口就成了咱们的救命稻草,写出抽象良好的接口并经过接口隔离依赖可以帮助咱们有效地提高项目的质量和可测试性,咱们会在下一节中详细介绍如何写单元测试。
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
接口;当咱们使用上述方法组织代码以后,其实就对不一样模块的依赖进行了解耦,也正遵循了软件设计中常常被提到的一句话 — 『依赖接口,不要依赖实现』,也就是面向接口编程。
在这一小节中总共介绍了 Go 语言中三个常常会打交道的『元素』— init
函数、error
和接口,咱们在这里主要是想经过三个不一样的例子为你们传达的一个主要思想就是尽可能使用显式的(explicit)的方式编写 Go 语言代码。
一个代码质量和工程质量有保证的项目必定有比较合理的单元测试覆盖率,没有单元测试的项目必定是不合格的或者不重要的,单元测试应该是全部项目都必须有的代码,每个单元测试都表示一个可能发生的状况,单元测试就是业务逻辑。
做为软件工程师,重构现有的项目对于咱们来讲应该是一件比较正常的事情,若是项目中没有单元测试,咱们很难在不改变已有业务逻辑的状况对项目进行重构,一些业务的边界状况极可能会在重构的过程当中丢失,当时参与相应 case
开发的工程师可能已经不在团队中,而项目相关的文档可能也消失在了归档的 wiki
中(更多的项目可能彻底没有文档),咱们可以在重构中相信的东西其实只有当前的代码逻辑(极可能是错误的)以及单元测试(极可能是没有的)。
简单总结一下,单元测试的缺失不只会意味着较低的工程质量,并且意味着重构的难以进行,一个有单元测试的项目尚且不可以保证重构先后的逻辑彻底相同,一个没有单元测试的项目极可能自己的项目质量就堪忧,更不用说如何在不丢失业务逻辑的状况下进行重构了。
写代码并非一件多困难的事情,不过想要在项目中写出能够测试的代码并不容易,而优雅的代码必定是能够测试的,咱们在这一节中须要讨论的就是什么样的代码是能够测试的。
若是想要想清楚什么样的才是可测试的,咱们首先要知道测试是什么?做者对于测试的理解就是控制变量,在咱们隔离了待测试方法中一些依赖以后,当函数的入参肯定时,就应该获得指望的返回值。
如何控制待测试方法中依赖的模块是写单元测试时相当重要的,控制依赖也就是对目标函数的依赖进行 Mock
消灭不肯定性,为了减小每个单元测试的复杂度,咱们须要:
Mock
;单元测试的执行不该该依赖于任何的外部模块,不管是调用外部的 HTTP 请求仍是数据库中的数据,咱们都应该想尽办法模拟可能出现的状况,由于单元测试不是集成测试的,它的运行不该该依赖除项目代码外的其余任何系统。
在 Go 语言中若是咱们彻底不使用接口,是写不出易于测试的代码的,做为静态语言的 Golang,只有咱们使用接口才能脱离依赖具体实现的窘境,接口的使用可以为咱们带来更清晰的抽象,帮助咱们思考如何对代码进行设计,也能让咱们更方便地对依赖进行 Mock
。
咱们再来回顾一下上一节对接口进行介绍时展现的常见模式:
type Service interface { ... } type service struct { ... } func NewService(...) (Service, error) { return &service{...}, nil }
上述代码在 Go 语言中是很是常见的,若是你不知道应不该该使用接口对外提供服务,这时就应该无脑地使用上述模式对外暴露方法了,这种模式能够在绝大多数的场景下工做,至少做者到目前尚未见到过不适用的。
另外一个建议就是保证每个函数尽量简单,这里的简单不止是指功能上的简单、单一,还意味着函数容易理解而且命名可以自解释。
一些语言的 lint
工具其实会对函数的理解复杂度(PerceivedComplexity)进行检查,也就是检查函数中出现的 if/else
、switch/case
分支以及方法的调用的数量,一旦超过约定的阈值就会报错,Ruby 社区中的 Rubocop 和上面提到的 golangci-lint 都有这个功能。
Ruby 社区中的 Rubocop 对于函数的长度和理解复杂度都有着很是严格的限制,在默认状况下函数的行数不能超过 10
行,理解复杂度也不能超过 7
,除此以外,Rubocop 其实还有其余的复杂度限制,例如循环复杂度(CyclomaticComplexity),这些复杂度的限制都是为了保证函数的简单和容易理解。
如何对测试进行组织也是一个值得讨论的话题,Golang 中的单元测试文件和代码都是与源代码放在同一个目录下按照 package
进行组织的,server.go
文件对应的测试代码应该放在同一目录下的 server_test.go
文件中。
若是文件不是以 _test.go
结尾,当咱们运行 go test ./pkg
时就不会找到该文件中的测试用例,其中的代码也就不会被执行,这也是 Go 语言对于测试组织方法的一个约定。
单元测试的最多见以及默认组织方式就是写在以 _test.go
结尾的文件中,全部的测试方法也都是以 Test
开头而且只接受一个 testing.T
类型的参数:
func TestAuthor(t *testing.T) { author := blog.Author() assert.Equal(t, "draveness", author) }
若是咱们要给函数名为 Add
的方法写单元测试,那么对应的测试方法通常会被写成 TestAdd
,为了同时测试多个分支的内容,咱们能够经过如下的方式组织 Add
函数相关的测试:
func TestAdd(t *testing.T) { assert.Equal(t, 5, Add(2, 3)) } func TestAddWithNegativeNumber(t *testing.T) { assert.Equal(t, -2, Add(-1, -1)) }
除了这种将一个函数相关的测试分散到多个 Test
方法以外,咱们可使用 for
循环来减小重复的测试代码,这在逻辑比较复杂的测试中会很是好用,可以减小大量的重复代码,不过也须要咱们当心地进行设计:
func TestAdd(t *testing.T) { tests := []struct{ name string first int64 second int64 expected int64 } { { name: "HappyPath": first: 2, second: 3, expected: 5, }, { name: "NegativeNumber": first: -1, second: -1, expected: -2, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { assert.Equal(t, test.expected, Add(test.first, test.second)) }) } }
这种方式其实也能生成树形的测试结果,将 Add
相关的测试分红一组方便咱们进行观察和理解,不过这种测试组织方法须要咱们保证测试代码的通用性,当函数依赖的上下文较多时每每须要咱们写不少的 if/else
条件判断语句影响咱们对测试的快速理解。
做者一般会在测试代码比较简单时使用第一种组织方式,而在依赖较多、函数功能较为复杂时使用第二种方式,不过这也不是定论,咱们须要根据实际状况决定如何对测试进行设计。
第二种比较常见的方式是按照簇进行组织,其实就是对 Go 语言默认的测试方式进行简单的封装,咱们可使用 stretchr/testify 中的 suite
包对测试进行组织:
import ( "testing" "github.com/stretchr/testify/suite" ) type ExampleTestSuite struct { suite.Suite VariableThatShouldStartAtFive int } func (suite *ExampleTestSuite) SetupTest() { suite.VariableThatShouldStartAtFive = 5 } func (suite *ExampleTestSuite) TestExample() { suite.Equal(suite.VariableThatShouldStartAtFive, 5) } func TestExampleTestSuite(t *testing.T) { suite.Run(t, new(ExampleTestSuite)) }
咱们可使用 suite
包,以结构体的方式对测试簇进行组织,suite
提供的 SetupTest
/SetupSuite
和 TearDownTest
/TearDownSuite
是执行测试先后以及执行测试簇先后的钩子方法,咱们能在其中完成一些共享资源的初始化,减小测试中的初始化代码。
最后一种组织代码的方式就是使用 BDD 的风格对单元测试进行组织,ginkgo 就是 Golang 社区最多见的 BDD 框架了,这里提到的行为驱动开发(BDD)和测试驱动开发(TDD)都是一种保证工程质量的方法论。想要在项目中实践这种思想仍是须要一些思惟上的转变和适应,也就是先经过写单元测试或者行为测试约定方法的 Spec,再实现方法让咱们的测试经过,这是一种比较科学的方法,它能为咱们带来比较强的信心。
咱们虽然不必定要使用 BDD/TDD 的思想对项目进行开发,可是却可使用 BDD 的风格方式组织很是易读的测试代码:
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
用于描述指望的行为,这些代码块最终都构成了相似『描述……,当……时,它应该……』的句式帮助咱们快速地理解测试代码。
项目中的单元测试应该是稳定的而且不依赖任何的外部项目,它只是对项目中函数和方法的测试,因此咱们须要在单元测试中对全部的第三方的不稳定依赖进行 Mock,也就是模拟这些第三方服务的接口;除此以外,为了简化一次单元测试的上下文,在同一个项目中咱们也会对其余模块进行 Mock,模拟这些依赖模块的返回值。
单元测试的核心就是隔离依赖并验证输入和输出的正确性,Go 语言做为一个静态语言提供了比较少的运行时特性,这也让咱们在 Go 语言中 Mock 依赖变得很是困难。
Mock 的主要做用就是保证待测试方法依赖的上下文固定,在这时不管咱们对当前方法运行多少次单元测试,若是业务逻辑不改变,它都应该返回彻底相同的结果,在具体介绍 Mock 的不一样方法以前,咱们首先要清楚一些常见的依赖,一个函数或者方法的常见依赖能够有如下几种:
这些不一样的场景基本涵盖了写单元测试时会遇到的状况,咱们会在接下来的内容中分别介绍如何处理以上几种不一样的依赖。
首先要介绍的其实就是 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 语言的单元测试就会很是难写,这也是为何从项目中是否有接口就能判断工程质量的缘由了。
另外一个项目中比较常见的依赖其实就是数据库,在遇到数据库的依赖时,咱们通常都会使用 sqlmock 来模拟数据库的链接,当咱们使用 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()) }
最经常使用的几个方法就是 ExpectQuery
和 ExpectExec
,前者主要用于模拟 SQL 的查询语句,后者用于模拟 SQL 的增删,从上面的实例中咱们能够看到这个这两种方法的使用方式,建议各位先阅读相关的 文档 再尝试使用。
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"}`)) ... }
若是遇到 HTTP 请求的依赖时,就可使用上述 httpmock 包模拟依赖的 HTTP 请求。
最后要介绍的猴子补丁其实就是一个大杀器了,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 依赖的最终解决方案。
在最后,咱们简单介绍一下辅助单元测试的 assert 包,它提供了很是多的断言方法帮助咱们快速对指望的返回值进行测试,减小咱们的工做量:
func TestSomething(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) } }
在这里咱们也是简单展现一下 assert
的示例,更详细的内容能够阅读它的相关文档,在这里也就很少作展现了。
若是以前彻底没有写过单元测试或者没有写过 Go 语言的单元测试,相信这篇文章已经给了足够多的上下文帮助咱们开始作这件事情,咱们要知道的是单元测试其实并不会阻碍咱们的开发进度,它可以为咱们的上线提供信心,也是质量保证上投资回报率最高的方法。
学习写好单元测试必定会有一些学习曲线和不适应,甚至会在短时间内影响咱们的开发效率,可是熟悉了这一套流程和接口以后,单元测试对咱们的帮助会很是大,每个单元测试都表示一个业务逻辑,每次提交时执行单元测试就可以帮助咱们肯定新的代码大几率上不会影响已有的业务逻辑,可以明显地下降重构的风险以及线上事故的数量
在这篇文章中咱们从三个方面分别介绍了如何写优雅的 Go 语言代码,做者尽量地给出了最容易操做和最有效的方法:
model
、controller
这种违反语言顶层设计思路的包名;init
函数,保证显式地进行方法的调用以及错误的处理;Service
对外暴露方法;service
实现接口中定义的方法;func NewService(...) (Service, error)
函数初始化 Service
接口;想要写出优雅的代码自己就不是一件容易的事情,它须要咱们不断地对本身的知识体系进行更新和优化,推倒以前的经验并对项目持续进行完善和重构,而只有真正通过思考和设计的代码才可以通过时间的检验(代码是须要不断重构的),随意堆砌代码的行为是不能鼓励也不该该发生的,每一行代码都应该按照最高的标准去设计和开发,这是咱们保证工程质量的惟一方法。
做者也一直在努力学习如何写出更加优雅的代码,写出好的代码真的不是一件容易的事情,做者也但愿能经过这篇文章帮助使用 Go 语言的工程师写出更有 Golang 风格的项目。