Java微服务的模块划分

近段时间在推进公司内Java微服务的框架升级,有机会直面一线业务的很多服务,在这个过程中也发现一些有趣的事情:不同时间段,不同团队,大家对于Java服务的模块划分、模块或者Jar包发布、模块包的依赖管理做法都有所不同,今天正好借这篇文章,和大家聊一下。

从再熟悉不过的模块结构划分开始

在盘点业务服务时发现,大多数项目都采取了如下或者与之类似的项目模块结构:

  • business-api —— 对外发布的API接口,例如Dubbo协议的Service
  • business-dao —— 数据访问对象,多数项目使用了MyBatis,这里是Mapper的聚集地
  • business-model —— 模型,包括各类的实体、DTO等
  • business-service —— 业务逻辑层,包括各类Service、接口、实现等
  • business-web —— Web请求处理层,包括Controller等

它的命名规则可以概括为<服务名>-<模块名>,除了上面列出的模块外,常见的可能还有像business-core、business-common等。这种模块划分模式很常见,在大量的Java服务项目中得以采用。模块划分的依据也就是应用的分层结构,例如阿里巴巴的Java开发手册中推荐的应用分层模式为:

来自阿里巴巴Java开发手册,建议的应用分层

我们可以把这种类型的项目模块划分看做对这种分层模式的一种落地,所以要探讨模块划分的问题,我们需要先了解一下应用分层模式的由来。

应用分层的由来

俗话说“盘古开天辟地”,在一片混乱之中挥动斧头,结束了混沌,才有了天地日月。最初的Web项目开发也是在混沌之中,一个脚本中完成处理请求、查询数据库、读写文件、渲染HTML模板,并没有所谓的分层和模式。早期包括ASP、PHP,Java中Servlet和JSP,都是这种模式来处理外部请求的:

初始时期,应用直接处理请求、访问数据库。

随着业务的复杂性的快速增加,大量的逻辑被塞入Web应用程序,耦合严重复用性差的代码弊端初现,系统的结构也开始逐渐初现了分层:

出现了分层,JSP+JavaBean的模式,JSP负责页面显示和跳转,JavaBean来负责业务逻辑的处理。

随着MVC模式引入Web项目后逐渐风靡,Web项目有了今天大家熟悉的分层模式:

MVC模式引入Web项目中,视图-控制器-模型等主要元素出现。

MVC模式并不是在Web项目中首创的,早在上世纪70年代末期就被人提出。MVC作为一种架构模式,规定了3种基本的角色,并且划定了各自应当承担的职责范围。早期在GUI项目中流行,后来伴随着Web应用的流行而大放异彩,也逐渐成为了Web框架的标准模式——任何一个Web框架的项目,几乎没有不使用MVC模式的。

在Java技术栈,MVC的出现不是终点,这反而是一个开始,随着生态的不断繁荣,以MVC模式为骨架,逐渐演化出这样的分层结构:

Web层(视图-控制器)、业务逻辑层、数据访问层、模型层的分层模式逐渐清晰,也是当前大家所熟知的应用分层模式了。

模块划分的异味

有了应用分层结构,那么接下来就是把业务拆解映射到各个层次上了。我们都知道,按照OO的观点来讲,组成业务的基本单元是用例,所以一个业务用例,例如下订单,按照分层设计的观点大致包括以下几类:

  • controller – web模块
    • OrderController
  • service – service模块
    • OrderService
  • dao – dao模块
    • OrderMapper
  • model – model模块
    • domain
      • OrderEntity

如果这个服务包含了几十个,甚至上百个用例,那么几乎每个业务用例都无可避免地要在几层中展开这些元素了,我们来深入思考一下。

按照最开始我们提到的api、dao、model、service、web的模块划分,他们之间的依赖关系为:

虽然划分了层次,但是组件包之间的依赖仍然是紧密的,这也是无可避免的——毕竟业务是一体的。

我们审视这个依赖关系发现,整个系统按照先按体系结构分层、再按业务逻辑映射各组件单元其实是有问题的,这几乎是设计的最高原则——高内聚、低耦合的完美反例。

所谓内聚性,简单的说就是模块内部组件与组件之间的是否具备相互支撑的关系,我们审视每个模块,以dao为例,假如系统中同时存在OrderMapper和AdminMapper这两个Mapper接口,这两个Mapper接口之间显然不存在支撑性关系,按照内聚性的评价标准来看,应当属于逻辑内聚,这种内聚性仅比偶然内聚稍高一点点,距离真正的内聚性也有很大的差距

再看耦合性,所谓耦合性,可以理解为模块与模块之间彼此依赖的程度,对于这种模块划分模式来讲,除model外,任一模块都无法独立完成一个业务动作,属于内容耦合或者共享耦合,耦合性很强的那种。

简单的说,我们折腾了半天,搞出了一个“低内聚、高耦合”的模块划分模式,并且在年复一年地使用它。

小朋友,你是否有很多问号?

那么这种模式是怎么来的呢?

我理解,这种模块划分模式的出现和初期使用MVC时留下的印象、直接套用展示框架运行的示例代码、对现有项目依葫芦画瓢是密不可分的。

重构模块结构

喷了半天,不提怎么解决就成了令人厌恶的键盘侠。我们来思考一下,怎样重构才能实现模块之间的“高内聚、低耦合”呢?首先我们还得回到面向对象的软件设计上来。

业务系统的设计通常都是从用例分析入手,我们划定了系统边界,盘完了主要业务流程,识别了系统中的用例之后,如何把一个个的用例落地到系统之中呢?答案其实很简单,就是按照体系结构将用例拆分为边界类、控制类、实体类三种基本元素。对于用例与用例之间可能存在协作性关系,目标是降低彼此之间的耦合性,我们把他们通过接口进行交互;用例内部的边界类、控制类、实体类等元素,彼此之间是内聚性关系,应该让他们尽可能地靠近。

垂直分模块、水平分层;先按业务的组织关系,按模块切一刀,模块内部再按层组织相应的组件。

在一个传统的单体项目里面,我们可以看到这样的模块组织结构:

  • user – 用户模块
    • controller
    • model
    • service
    • dao
  • product – 商品模块
    • controller
    • model
    • service
    • dao

无疑这样的模块组织方式会更合理一些,模块与模块之间减少了依赖,提高了内聚性。我在跟一些团队交流业务的微服务化时发现,他们提到自己的单体项目进行服务化拆分非常困难,原因之一也是在于项目初期的模块划分,按分层划分了模块,而不是按业务,所以很难抽提出一个彼此依赖较少的业务模块重构为独立的微服务。

那么对于微服务架构下的应用服务来说,一个服务内可能只包含一个独立的业务单元,单一微服务该如何进行模块拆分呢?我的观点是,保持克制,不拆分任何模块,除非模块本身必须保持独立。

例如我们推荐的一种服务模块组织形式:

  • business-api —— 对外发布的API接口,例如Dubbo协议的Service及其传输对象
  • business-web —— 微服务模块,单一模块内部按照业务划分子域作为一级包名
    • process —— 业务子域包,例如“流程”
      • controller —— 控制器
      • domain —— 领域
      • mapper —— DAO
      • service —— 业务服务

那么,还有没有其他的思路呢?

领域驱动设计中的模块划分

领域驱动设计指导微服务设计开发已经被广为接受了,我们在新项目生成向导中也提供领域驱动风格的项目模块布局,形式例如:

  • business-microservice —— 微服务模块
    • application —— 应用层服务
    • domain.model —— 领域层,包括聚合根、实体、值对象、事件等
    • port.adapter —— 防腐层适配器,实现领域层要求的能力接口,对应到具体的实现
      • persistance —— 存储适配器,包括mybatis mapper、redis访问等
      • service —— 外部服务适配器,例如外部的Feign客户端、SDK对接等
      • messaging —— 消息队列适配器,例如kafka等
    • resource —— 资源访问层,对Web提供的接口就是Controller

这样分层和布局项目中的模块,很大程度上源于领域驱动设计中系统分层的一个很重要的原——依赖倒置,即:

高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

Robert C. Martin, https://en.wikipedia.org/wiki/Dependency_inversion_principle

这里提到的高层模块和低层模块,直观一点说,越接近原始的基础设施,例如数据库、消息队列,就是越低层,越接近业务,就是越高层。

举个例子,一个业务需要访问数据库,完成业务的操作,我们可以这样构造:

箭头方向表示依赖的方向,例如Controller中需要持有一个Service的实例。

我们按照依赖倒置的原则审视一下,这个业务的整条依赖链路是直接依赖于基础设施,也就是说,基础设施的一些特性可能会导致我们使用特定的方式编写业务代码,不利于各层之间的解耦。一旦基础设施需要做大的改造,势必需要对业务逻辑进行整体的重构。

按照依赖倒置原则,我们可以这样优化我们的设计:

领域层不依赖任何其他层,应用层服务依赖于领域层,用户接口Controller依赖于应用层,基础设施层依赖于以上所有层提供的接口。

总结起来,根据依赖倒置原则设计的系统分层,我们的各层之间的依赖关系是:

箭头所指的是依赖性关系,依赖的方式是通过实现接口。

依赖倒置的好处在于,领域层无需对特定的基础设施能提供的能力或者特性做出任何假设,只需要定义对他们的要求——也就是接口,任意一种基础设施,只要按照接口契约进行实现即可。领域层可以充分发挥定义业务规则,并且可以独立进行测试,基础设施层可以按照明确的需求进行开发,方便验证。

总结

模块划分相当于给应用服务的开发画田字格,表面上是分模块、分包的问题,其实背后关联的是系统分析与设计的方法。不要让这些设计原则变成了“面试造火箭”时候才有的技能,还是要多深入思考现象背后的本质呀。