设计原则
本文以一些被广泛共识的设计原则为标尺,来衡量与演进 Java Web 代码架构。
高内聚、松耦合思想
高内聚:相近的功能应该放到同一个单元中,不相近的功能不要放到同一个单元中。这里的单元可以指方法、类、包、模块。 松耦合:某单元的改动不会影响依赖该单元的其他单元的改动。
高内聚、低耦合是一个比较底层的思想,后面的许多原则都是这一思想的体现。所以本文中不单独考察。
单一职责原则
描述:一个单元只负责完成一个职责。
引理:下游功能不应该依赖上游功能,观察者模式是专门来解决这一问题的。例如,用户注册后发放优惠券,不应该让用户单元依赖优惠券单元。
开闭原则
描述:对扩展开放,对修改关闭。在遇到一个新功能的时候,应该在已有单元上扩展代码(新增单元),而非修改已有单元。
里氏替换原则
描述:实现类的逻辑,要遵守接口的行为约定。
接口隔离原则
描述:调用者不应该被强迫依赖它不需要的接口。
引理:组合优于继承。即实现类应该实现多个接口来实现多个功能,而不是在一个接口中定义尽可能多的行为。
依赖反转原则
描述:高层单元不依赖低层单元,而是依赖一个共同的抽象。
DRY 原则
描述:不要写重复的代码。这里的重复不是指代码的重复,而是指语义的重复。有的代码有20多条语句、结构相同,但是代表不同步骤的校验,也不能算作重复代码;有的代码,虽然只有一条语句,也可能违背 DRY 原则。
引理:充血模型优于贫血模型。例如,判断订单过期的逻辑Instant.now().compare(order.getExpireTime()) >= 0
,这是典型的贫血模型的写法。虽然只有一句话,但是如果被多个地方反复使用,修改逻辑时也需要改很多地方。充血模型的做法则是为订单类声明方法public boolean isExpired() { return Instant.now().compare(this.expireTime) >= 0 }
。
从按层组织演进到按功能组织
按层组织的代码架构
先看一个简化版的按层组织的代码架构:
markdown
- controller
- InventoryController # 库存相关的 API 接口
- ProductController # 商品相关的 API 接口
- OrderController # 订单相关的 API 接口
- service
- InventoryService # 库存相关的业务逻辑
- ProductService # 商品相关的业务逻辑
- OrderService # 订单相关的业务逻辑
- impl
- InventoryServiceImpl
- ProductServiceImpl
- OrderServiceImpl
- repository
- InventoryRepository # 库存表的数据库操作
- ProductRepository # 商品表的数据库操作
- OrderRepository # 订单表的数据库操作
- model
- bo
- InventoryBO
- ProductBO
- OrderBO
- vo
- InventoryVO
- ProductVO
- OrderVO
- Inventory # 库存实体类
- Product # 商品实体类
- Order # 订单实体类
现在来逐一考察这样的代码框架在什么程度上符合/违背了设计原则。
单一职责原则:方法、类级别的单一职责原则涉及到了代码实现,这里不做考察。在每一个包(controller、service 等)中,都分别包含了库存的逻辑、商品的逻辑、订单的逻辑。所以在包的级别上违背了单一职责原则。
开闭原则:假设一种场景------现在要对商品增加一个排序字段与排序功能。这会在 ProductController、ProductService、ProductRepository 增加一个方法,Product、ProductBO、ProductVO 中各增加一个字段;共增加了3个方法,改动了6个类,改动范围涉及到4个包(bo、vo中的类实际上可以都放在model包中,所以计为同一个包)。
接口隔离原则:虽然 service 层用的是接口+实现类的模式,但是事实上更像是继承模式而不是组合模式。ProductService 依赖 InventoryService 实际上只需要查询库存方法,但是被强迫依赖了锁库存、扣减库存等其他方法,所以违背了接口隔离原则。
依赖反转原则:controller层没有依赖service层的实现类而是接口,service层也是,所以再依赖反转这一原则上,按层组织的架构做的尚可。
DRY 原则:BO、VO 类是没有自己独立的语义的,几乎model实体每增加修改一个属性,BO、VO 也需要同步的修改。所以 BO、VO 的存在违背了 DRY 原则。
按功能组织的代码架构
先看修改后的结果,再逐一解释过程与内容:
markdown
- inventory # 库存包
- InventoryController # 库存相关的 API 接口
- InventoryService # 库存相关的业务逻辑
- Inventory # 库存实体模型(充血模型)
- service # 库存包向其他包开放的接口
- IInventoryService4Product # 库存包向商品包开放的接口,包括查询库存方法
- IInventoryService4Order # 库存包向订单包开放的接口,包括锁库存、扣减库存
- repository
- InventoryRepository # 库存表的数据库操作
- product # 商品包
- ProductController # 商品相关的 API 接口
- ProductService # 商品相关的业务逻辑
- Product # 商品实体模型(充血模型)
- repository
- ProductRepository # 商品表的数据库操作
- model
- ProductAddReq # 商品新增请求的模型
- ProductModifyReq # 商品修改描述请求的模型
- ProductResortReq # 商品排序请求的模型
- ProductResp4Customer # 给买方返回的实体模型,相对充血模型少了一些字段
- order # 订单包
- OrderController # 订单相关的 API 接口
- OrderService # 订单相关的业务逻辑
- Order # 订单实体模型(充血模型)
- repository
- OrderRepository # 订单表相关的数据库操作
- event # 事件类
- OrderCanceledEvent
- core
- model
- OrderDTO
操作步骤:
- 将原本 controller、service 中的类,分别按功能放入各自的包中。
- 删除只有一个实现类的接口,所有依赖这些接口的地方暂且都改为直接依赖其实现类。
- 将 service 层向 controller 层提供的方法的访问级别修改为 protected。
- 向其他包提供按需的接口。例如 IInventoryService4Product 是库存向产品提供查询库存方法,IInventoryService4Order 是库存向订单提供锁定库存、扣减库存方法。这些接口可以放在源功能包(inventory)或者目标功能包(product、order)内,这里选择放在源功能包内,由 InventoryService 实现。注意不要改动先前的 protected 方法的访问级别。另外,如果向多个包提供相同的功能,那么可以用相同的签名。
- 按需提供接口模型。将原本的 BO、VO 进行细化,细化到为每个 API 接口提供独立的请求模型、响应模型,精准适应需要的属性,比如商品排序字段只需要两个属性------id、ordinal,那么就只提供两个属性,再通过 MapStruct 转换为充血模型。实际操作中倒也不必很刻板,能重复利用的就重复利用吧,很多时候返回值模型可以直接用充血模型。
注意点:
- 实际上,可以完全将所有内容都放在同一个包下,不需要再建子包,因为内容不会像按层组织那样无限膨胀下去。但是为了组织的美观,所以将 repository、model 等还是设置为子包。
- 理论上,不同包之间的交互,service 应该用 DTO 模型,这里为了简化没有这样做,而是直接用了充血模型,只在不同服务间交互时用 DTO 模型。这就意味着,如果一个功能要从一个服务移动到另一个服务,需要改用 RPC 交互,那么就要算上从充血模型改为 DTO 这一改动。
- 一个 service 未必只对应一个实体,也未必只对应一个 repository。我也遇到过将文档和文档内容分为两个实体,但是在同一个 service 中进行操作的场景。
- controller 和 service、service 和 repository 的方法未必要一一对应。前面举的商品排序的例子,说是增加了3个方法,实际实现时只需要在 ProductController 增加一个方法即可,可以复用 ProductService 的修改商品方法。所以代码设计的方法论还是得讲究灵活变通。
现在再来考察一下新的架构什么程度上符合/违背了设计原则。
单一职责原则:在之前的架构的基础之上,在包一级保证了职责的单一。
开闭原则:还是之前的例子,增加一个商品排序功能。现在需要在 ProductController、ProductService、ProductRepository 增加一个方法,Product 中各增加一个字段,增加一个 ProductOrderReq 类;共增加了3个方法、1个类,改动了4个类,改动范围涉及到1个包(前面说过所有类其实可以放在一个包中,所以这里的改动范围计为1),改动范围较之前小。
接口隔离原则:通过提供按需的接口,调用方只需要知道有限的知识,实现了接口隔离原则。
依赖反转原则:与之前的架构相反,演进后的架构已经在极大程度上违背了依赖反转原则(说是极大违背而不是完全违背,一则在于包之间还是通过接口进行交互而不是实现类;二则只是单实现的接口被移除了而不是所有)。以牺牲依赖反转原则为代价,换来的是更好的封装特性。
DRY 原则:将 BO、VO 细化之后,虽然仍然存在代码重复,但是已经不存在语义上的重复了。而且改动之后,一些参数校验的逻辑实现也更方便了。
向框架的妥协
Spring @Transactional
上面提到 service 层的方法申明为 protected 供上层使用,然而在 Spring 6 之前的版本(也就是 SpringBoot 3 之前的版本),@Transactional 注解是不能用于 protected 方法的,所以不得不妥协地将用到事务的方法可见性改为 public。当然,如果是 SpringBoot 3 及以上的版本或者用 Micronaut 框架就不用担心这个问题。
JPA Repository
JPA 的 Repository 是以接口的形式存在的,而接口的所有方法都是公开的,所以事实上其他包也可以使用本包的 Repository,然而并没有办法从架构设计层面阻止这件事情,所以这一点只能依靠代码编写人员的自觉了。也正是因为如此,得以将 repository 单独设为一个子包。
Java Bean
Java Bean 已经违背了面向对象的封装特性,实际使用的时候几乎是将属性全部暴露了。然而这却是是众多框架所依据的规范,可操作范围极小,想要跳出这些框架又不得不面临开发效率问题,所以这一点是比较无奈的。
没有银弹
上述所论的按功能组织代码是一种方法论,方法论就有其适用范围,并不存在标准答案。比如说上述情形中service层只存在一个实现类,所以让controller层直接依赖实现类。有的场景,比如SSO,需要实现多个厂商的逻辑,应用运行时选择其中一个实现;又比如通知,可能需要按配置发送多个通知(短信、邮箱、站内信等)。这些场景下就不适合直接依赖实现,还是需要接口的(参考依赖倒置原则)。所以在代码设计时要根据实际场景,用设计原则来衡量设计是否合适。
更极端地说,本文所述的设计原则有一些是适用于面向对象范式的,放在其他的语言下也未必适用。不过本文所述的范围限于 Java Web 代码架构,所以可以认为这些设计原则就是公理。
题外话
我在很多关于 DDD 的文章表达了不满,并不是对 DDD 本身的不满,毕竟我没有读过原文。就事论事,从我读到过的这些文章来看,我还是认为 DDD 的炒作成分大于实际价值。其一,理论与实践并不自洽,以充血模型为例,虽然 DDD 的理论强调充血模型,但是我看过的所有文章全部都是贫血模型;其二,造了很多词,但是这些词是为了解决什么问题、如何解决的,完全不知所名;其三,一个新的理论,既没有证伪原有理论(指设计原则),也经受不起设计原则的考验。在更进一步了解其本质前,我对其保留反对意见。
如果想要了解更多设计原则的细节,可以去看一下王争老师的《设计模式之美》专栏。