当我们开始学习 Golang 编程的时候,通常第一步是写一个 hello world 程序,大概 5 行左右。然后第二步通常是写一个简单的 HTTP 服务器,一般不超过 100 行。接下来,基本就一下子跨越到了几千甚至上万行代码的项目,中间却很少有人告诉你如何组织代码,怎么编写测试。这感觉就像有人给了你一个桨和一条独木舟,然后告诉你,去吧,去穿越太平洋吧。
其实这中间的空白并非没有资料可参考,只是首先它们非常零散,需要到处搜索寻找。其次它们往往各执观点,容易找不到重点。而如果试图从一些开源项目中寻找答案,那只会更加迷惑,因为基本没有统一的方式。
在刚开始编写 Golang 项目的时候,我们也一直被这两个基本的问题所困扰。经过多种尝试和复返调整后,我们最终得到了对这两个问题的 SmartX 版本的答案。在我们的组织方式下,各功能层次的代码被良好的隔离开来以降低维护难度和测试难度。此外,我们总结了一些测试相关的实践,达到了较好的测试效率和测试质量。
在介绍我们最终采用的代码组织方式前,不妨先说说我们曾经尝试过哪些方式,以及它们为什么没有被采用。
非常多的 Golang tutorial 都采用这种组织方式,有些甚至是单一源代码文件。必须承认的是,对于代码量不大的项目,或者不需要团队协作的项目,这样的组织方式足够高效,也不会遇到太大的问题。
但是如果代码量稍大,或者存在团队协作情形的项目,这样的组织方式就暴露无法隐藏结构体内部实现的问题了。不同于 C++ 或 Java,Golang 的代码可见性的最小粒度是 package。例如在下面的 C++ 代码中,我们 Foo 类的外部是无法调用它的私有函数 PrivateFunc 的:
由于 C++ 具有类粒度的可见性,因此就算是和这个类实现在同一个源代码文件下,依然可以实现对类外隐藏内部实现,以达到代码封装的目的。那么再来看看 Golang。在下面的例子中,我们可以调用到 foo 结构体的内部函数 internalFunc,只要调用方和 foo 结构体在同一个 package 下:
这也就意味着在同一个包下,相互之间是无法隐藏内部实现的。为何 Golang 抛弃了类粒度的可见性特性已经超过了本文讨论的范围,但这至少意味着我们需要将代码分包才能进行有效的封装,以避免内部实现细节被依赖的意外情况。
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,就会出现两难选择:
简单分析后,可以发现问题出在我们把外部依赖(数据库、邮件服务等)和业务逻辑混到了一起。大部分情况下,业务逻辑和外部依赖是两个独立变化的东西。比如我们对某个 API 加入新的权限规则,通常和我们使用哪种数据库是无关的;而我们给数据库前面加一层 cache 也通常不会影响数据合法性检查的逻辑。如果这两个东西是分离的,那么上面的两难局面应不会出现。
继续沿用上面的例子,我们可以将数据库操作抽离成一个 UserRepository 接口,postgres 包改为实现 UserRepository 接口:
然后将 UserService 放到另外的 package,并且仅包含纯粹的业务逻辑:
换句话说,我们将原来的 model 加 view-controller 的两层结构,进一步差分成了如下图所示的三层结构: