Skip to main content

项目结构划分

较好的组织方式:按依赖划分

在抛弃了上面三种不理想的方式后,我们只得在搜索引擎中寻找更好的答案。并不困难的,我们找到了这篇 技术文章,其中介绍了一个非常不错的组织方式。巧的是文章作者也经历了和我们一样的困惑和尝试,才最终形成了他文章中的结论。为了方便无法访问原链接的同学们进行理解,截取原文中的一些代码来简要介绍下。

首先,根 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。接下来,我们来说说关于测试的组织问题。