当我们开始学习 Golang 编程的时候,通常第一步是写一个 hello world 程序,大概 5 行左右。然后第二步通常是写一个简单的 HTTP 服务器,一般不超过 100 行。接下来,基本就一下子跨越到了几千甚至上万行代码的项目,中间却很少有人告诉你如何组织代码,怎么编写测试。这感觉就像有人给了你一个桨和一条独木舟,然后告诉你,去吧,去穿越太平洋吧。
其实这中间的空白并非没有资料可参考,只是首先它们非常零散,需要到处搜索寻找。其次它们往往各执观点,容易找不到重点。而如果试图从一些开源项目中寻找答案,那只会更加迷惑,因为基本没有统一的方式。
在刚开始编写 Golang 项目的时候,我们也一直被这两个基本的问题所困扰。经过多种尝试和复返调整后,我们最终得到了对这两个问题的 SmartX 版本的答案。在我们的组织方式下,各功能层次的代码被良好的隔离开来以降低维护难度和测试难度。此外,我们总结了一些测试相关的实践,达到了较好的测试效率和测试质量。
三种未被采用的代码组织方式
在介绍我们最终采用的代码组织方式前,不妨先说说我们曾经尝试过哪些方式,以及它们为什么没有被采用。
方式一:单一 package
非常多的 Golang tutorial 都采用这种组织方式,有些甚至是单一源代码文件。必须承认的是,对于代码量不大的项目,或者不需要团队协作的项目,这样的组织方式足够高效,也不会遇到太大的问题。
但是如果代码量稍大,或者存在团队协作情形的项目,这样的组织方式就暴露无法隐藏结构体内部实现的问题了。不同于 C++ 或 Java,Golang 的代码可见性的最小粒度是 package。例如在下面的 C++ 代码中,我们 Foo 类的外部是无法调用它的私有函数 PrivateFunc 的:
由于 C++ 具有类粒度的可见性,因此就算是和这个类实现在同一个源代码文件下,依然可以实现对类外隐藏内部实现,以达到代码封装的目的。那么再来看看 Golang。在下面的例子中,我们可以调用到 foo 结构体的内部函数 internalFunc,只要调用方和 foo 结构体在同一个 package 下:
这也就意味着在同一个包下,相互之间是无法隐藏内部实现的。为何 Golang 抛弃了类粒度的可见性特性已经超过了本文讨论的范围,但这至少意味着我们需要将代码分包才能进行有效的封装,以避免内部实现细节被依赖的意外情况。
方式二:按 MVC 划分
MVC 是一个比较经典的代码功能划分模式,不少框架都是以此为依据来进行代码组织,比如 web framework 的集大成者 Ruby on Rails。而在 Golang 的范畴内,比较受欢迎的 Beego 框架也是采用这种方式。既然考虑分包,那么第一个想法就是按照 MVC 来进行划分。
以 Beego 的 Todo 为例,在 MVC 的划分下,controllers 包和 models 包的内容大体为:
进行这样的分包之后,controller 层只能访问到 model 层提供的公共接口,初步达成我们隐藏实现的目的。当然,它们各自的包内部还是无法隐藏实现的,但至少层次间的隔离已经达成。
不过,这个做法有一个非常别扭的地方。考虑当我们需要在 controllers 包外引用 TaskController 的情形,代码会出现 controllers.TaskContoller 这样的形式。Golang 官方的 Effective Go 对于这样会造成引用方出现重复短语的包名称,认为这并不是好的命名方式。这个问题的本质其实又涉及到 Golang 的另一个独特之处,就是从其他包中引入的常量、变量、函数、结构体以及接口,都需要加上包的前缀来进行引用。
有的同学可能会说,Golang 也可以 dot import 来去掉这个前缀。不幸的是,这个做法并不常规,并且不被建议。或许 Golang 有类似 Python 的 from controllers import TaskController 或者 Java 的 import controllers.TaskController 这样的可选择性 import 机制的话,这个情况会改善很多。
方式三:按模块划分
MVC 是按照功能层次进行横向划分,相对的,另外一种常见的划分方式是按照模块进行垂直划分。如果继续以上面的 Todo 为例,那么 task 相关的 controller 和 model 都会被放到 tasks package 下:
在 tasks 包外引用其中的一些定义时,原先的 controllers.TaskController 变成了 tasks.Controller,看起来好多了。但是原先的 models.Task 也变成了 tasks.Task,可真是按下葫芦浮起瓢。
此外,另一个不采用这种方式的重要原因,就是我们的项目都是采用微服务的理念,所以通常一个项目只包含了一个模块。在这个前提下,如果继续采用这种方式,那么几乎就回到了单一 package 的样子了。
较好的组织方式:按依赖划分
在抛弃了上面三种不理想的方式后,我们只得在搜索引擎中寻找更好的答案。并不困难的,我们找到了这篇 技术文章,其中介绍了一个非常不错的组织方式。巧的是文章作者也经历了和我们一样的困惑和尝试,才最终形成了他文章中的结论。为了方便无法访问原链接的同学们进行理解,截取原文中的一些代码来简要介绍下。
首先,根 package 需要定义整个项目的 domain,并且不依赖项目中的任何其他 package:
接下来,按照外部依赖对实现代码进行包的划分。例如如果 UserService 是依赖 PostgreSQL 作为存储实现的,那么可以用一个 postgres 的子 package 来包含实现代码:
从 MVC 的角度看,这里的 UserService 属于 model 层次。在其上还有对接 HTTP API 的 view-controller 层。可以想象 view-controller 层需要依赖并利用 UserService,这里不再展开代码。这里的关键是,包的命名不再是 models 或这 controllers,而是按照外部依赖而命名。那么当包外引用 UserService 的时候,它的形式会是 postgres.UserService,非常简洁易理解。
此外,需要看到的是,这种组织方式并不只是简单的给包换了个合适的名字,它还抽象出来了 domain。这一层抽象带来了一定的灵活性。比如想象一下,如果此时需要迁移到 MongoDB 作为数据存储,那么新的基于 MongoDB 的 UserService 实现,对于上层的 view-controller 来说是透明的。因为不管是基于什么实现的 UserService,只要它符合 UserService 的接口,那么对它的使用者都是可以无缝替换的。更进一步的,这一层抽象也给我们的测试带来了很多的便利,这方面我们会在后面关于测试的部分进行更多展开。
可以说这种方式解决了前三种方式中各自的问题,是一个非常不错的思路。在我们一些项目的早期,直接采纳了这种组织方式。不过,随着越来越多的业务逻辑加入到 service 的实现中,我们发现有必要在这基础上进行一些改进。
改进:分离业务逻辑
随着功能的添加,我们的 service 实现代码中加入了越来越多的业务逻辑,包括用户认证、权限验证、字段默认值填充、数据合法性检查、外部服务调用、邮件发送等。此时,如果我们希望在 PostgreSQL 前面加一层内存 cache,就会出现两难选择:
如果还是像上面的 UserCache 那样的实现来包装 UserService,那么当数据在缓存中存在时,会跳过下层 UserService 中用户认证、权限验证等重要的业务逻辑而直接返回数据
否则就只好将 cache 的逻辑写到已有的 UserService 中,但这样会降低可维护性以及可测试性
简单分析后,可以发现问题出在我们把外部依赖(数据库、邮件服务等)和业务逻辑混到了一起。大部分情况下,业务逻辑和外部依赖是两个独立变化的东西。比如我们对某个 API 加入新的权限规则,通常和我们使用哪种数据库是无关的;而我们给数据库前面加一层 cache 也通常不会影响数据合法性检查的逻辑。如果这两个东西是分离的,那么上面的两难局面应不会出现。
继续沿用上面的例子,我们可以将数据库操作抽离成一个 UserRepository 接口,postgres 包改为实现 UserRepository 接口:
然后将 UserService 放到另外的 package,并且仅包含纯粹的业务逻辑:
换句话说,我们将原来的 model 加 view-controller 的两层结构,进一步差分成了如下图所示的三层结构:
最上面的是 API 协议层,图上举例常见的 RESTful 风格 API 以及 GRPC。在这一层中,我们对请求进行解析,例如从 URL 中取出参数,从 request body 中 decode 出请求数据等。然后调用适当的 service 接口,对于接口的返回数据或者错误信息进行一定的编码,最后从网络返回给客户端。
中间蓝色的是业务逻辑层。它通常用来实现用户认证、权限验证、数据合法性检查、外部接口(例如数据存取、邮件发送)调用等操作。经过我们上面的抽象和剥离,它不包含外部接口的具体实现,而仅仅是这些接口的使用者。
最下面蓝色的部分则是外部接口的实现层。这一层会对接到具体依赖的外部服务,例如数据库、缓存、邮件服务、支付服务等。在我们的例子中仅是一个 UserRepository 接口,但在实际的项目中可以按照需要抽象出更多的外部接口,如图中右下的灰色部分所示。
如果此时我们想给 UserRepository 加一层 cache,那么 UserService 和其他 UserRepository 实现都不需要变动,只需要增加实现带 cache 的 UserRepository 接口即可,也就是图中的 UserCache 部分。可以看到,这增加的一层进一步隔离了业务逻辑和外部依赖,使得代码更加灵活,也更容易进行维护。目前我们 SmartX 的大部分 Golang 项目都采用了这种思路进行代码组织,实际效果上也非常不错。典型的目录结构如下:
在上面的目录结构中,我们还看到了一些测试相关的文件甚至 package。接下来,我们来说说关于测试的组织问题。
测什么以及怎么测
在讨论如何进行测试之前,首先简单区分下单元测试和集成测试的概念。
单元测试 vs 集成测试
单元测试通常由代码的开发者编写,需要基于对代码内部的了解进行,属于白盒测试。典型来讲,我们通常会针对某个被测试函数,给定一系列输入以及外部对象的 mock,然后测试它和外部对象的交互行为以及最终的输出是否符合预期。因为外部依赖通常都被 mock 了,因此单元测试往往不需要配置数据库、邮件服务器等外部依赖,所以通常都较为容易编写,也较为容易执行。它可以在开发的时候随时执行,大部分的代码错误都可以通过单元测试所发现。
集成测试则是用来判断系统几个模块组合起来后,它们整体的行为是否符合预期。集成测试属于黑盒测试,它并不以了解实现细节为基础,而仅测试外部可见的公共接口。此外,集成测试一般也不使用 mock,而是将它的外部依赖都纳入进来进行测试。换句话说,要执行集成测试,大都需要配置数据库、邮件服务器等外部依赖。这些外部依赖的交互过程通常要比执行几行代码慢上几个量级,因此集成测试的执行成本通常要比单元测试高不少。
端到端测试,也就是 E2E 测试,其实是一种特殊的集成测试。它特指将程序的所有部分都连接起来,然后站在整个程序的外部,从用户的视角测试程序的输入输出是否符合预期。
我们的测试策略是尽可能多的使用单元测试并达到足够的覆盖率,然后配合集成测试以及端到端测试来进一步保证模块间的正确耦合。这样能尽可能提高我们的测试效率,并且不以降低测试质量为代价。
单元测试 API 协议层
API 协议层的主要外部依赖是 domain interface 和具体的传输协议实现。对于 domain interface,可以很容易通过像 gomock 这样的工具进行 mock。对于传输协议,如果是单纯的 HTTP,那么利用 Golang 标准库中的 httptest 包可以很方便的对这部分进行测试。
在这一层的测试中,我们主要关注它是否可以处理不同的请求输入,例如非法的链接、错误的 body 编码等,以及在处理 domain interface 所返回的不同数据或错误的时候,是否正确转化成了我们期望的响应,包括 HTTP response code 和 response body。通过调整输入和 mock 行为,不同情形下的处理逻辑都是可以被覆盖到的。
单元测试业务逻辑层
业务逻辑层的主要外部依赖是它所定义的一系列像 UserRepository 这样的外部接口。我们同样可以用 gomock 来为这些接口生成 mock 代码。
在这一层的测试中,我们主要关注它是否正确处理的了用户认证、权限验证、数据合法性检查等内部逻辑,以及和外部接口的交互行为,以及基于外部接口的响应所进行的进一步行为。配合函数的输入以及接口的 mock 行为,我们同样能够覆盖到其中的每一个内部逻辑。
集成测试外部依赖
对于外部依赖,我们采用将外部依赖集成进来,对它们整体进行集成测试。这里不采用单元测试的主要原因是如果使用 mock 将外部依赖 mock 掉的话,测试的大部分意义就丧失了。例如如果 mock 掉 SQL 接口而单元测试 PostgreSQL 的 UserRepository 实现,那么 SQL 语句是否合法可能就得不到测试,而它的实际执行结果同样也无法被测试到。如果抛弃这部分代码的验证,那么 UserRepository 剩下的代码只占了一小部分,并不能达到测试的目标。
在这一层的集成测试中,我们主要关注它每个接口的功能是否正确,实际上是一种功能测试。例如 User 函数在给定某个已有用户的 id 时应能返回这个用户,而当给定的 id 不存在时,应返回适当的错误。不管接口的具体实现是怎么样的,从使用的角度它们应该具备一致的功能,因此对于同一个接口的不同实现,它们的集成测试样例可以共享。这就对应我们上面目录结构中的 repositorytest 包的作用,它包含了 UserRepository 通用的功能测试样例:
注意到其中的 Test_UserRepository_User 函数的指纹并不符合 Golang 的测试函数的指纹(多了 tester 参数),因此它并不会被当作测试执行。如果你到 repositorytest 包下执行 go test,会告诉你 no test file。真正的测试是在每个 UserRepository 的包下:
E2E 测试整个程序
上面的三类测试针对各自的层次进行了较为隔离的测试,但到目前为止,还没有对程序的整体进行功能测试,也就是 E2E 测试。之前提到了,E2E 测试是站在用户的角度对程序的对外接口进行测试。作为典型的后端程序,它的对外接口就是暴露在网络上的 API 接口,而用户就是 API 接口的调用者。因此 E2E 测试主要测试 API 接口的功能。
具体的测试方法一般都是将服务器程序运行起来,然后用某个 API 客户端对其进行对接测试。如果是 RESTful API,那么 API 客户端的选择可以有很多,包括一些 RESTful API 的开发调试工具(例如 Insomnia),也可以是 Python 这样的脚本语言,当然用 Golang 也可以编写 E2E 测试。如果是 GRPC API,那么可以根据 proto 文件生成一个合适语言的客户端,然后编写一些测试样例进行测试。
值得指出的是,试图在 E2E 测试中覆盖所有的 corner case 并不是一件坏事,但通常比较费力费时,有时甚至完全无法达成。考虑到单元测试更容易进行 corner case 的覆盖,因此我们对 E2E 测试的要求是覆盖主要的功能路径,并不要求面面俱到。此外,当每次遇到 bug 时,需要将覆盖这个 bug 的测试样例补充进来,以保证相同的问题在后续都能被测试到。
结语
如果问最近几年发展最快的编程语言是什么,相信 Golang 应该会出现在不少人的答案之中。作为一门在 2009 年才诞生的编程语言来说,Golang 的确发展的非常迅速。目前来看,在 Google 和社区的推动下,Golang 已经成为云基础架构的主要语言,Docker、Kubernetes 等一些主流的开源项目均是采用 Golang 编写。
在我们 SmartX,C++ 和 Python 一直是我们主要使用的语言。C++ 是我们的分布式存储 ZBS 的开发语言,使用 C++ 能让我们完全掌控内存的使用,并且也能更彻底的进行一些性能优化;Python 则是我们超融合产品中其他部分的开发语言,包括我们的虚拟化平台 Elf。去年设计新版本架构时,我们确定了一些要新增的功能模块,以及一些不理想需要替换的旧有模块。在比较和尝试了一些潜在的方案后,我们最终确定了使用 Golang 来开发这些新的模块。
在刚上手用 Golang 来写一些后端项目的时候,我们一方面惊喜于 Golang 在开发后端项目时的简洁和高效,另一方面却对项目代码的组织形式感到困惑。为此,我们曾尝试过使用其他语言或框架所推崇的组织形式,也曾苦苦搜索和实践他人所建议的组织形式,但始终没有得到令人满意的结果。此外,对于如何在 Golang 中实践高效率、高覆盖率的自动化测试也是一个没有现成答案的问题。在借鉴了他人的思路,并且经过了我们几个项目的实践和调整后,最终形成了一套我们 SmartX 自己的 Golang 代码组织和测试的方法论。我们希望通过本文分享我们在这两个问题上的实践、经验和结论,希望抛砖引玉的同时也对初识 Golang 的同学们有一定帮助。
作者介绍
叶丰,现任 SmartX 平台组研发负责人。SmartX 拥有国内最顶尖的分布式存储和超融合架构研发团队,是国内超融合领域的技术领导者。
最后,向爱学习的你推荐我们精心打磨的 CI/CD 专栏,希望能帮你的团队快速构建一个高效的持续交付体系。
领取专属 10元无门槛券
私享最新 技术干货