likes
comments
collection
share

Java Web 代码架构 —— 从按层组织演进到按功能组织

作者站长头像
站长
· 阅读数 3

设计原则

本文以一些被广泛共识的设计原则为标尺,来衡量与演进 Java Web 代码架构。

高内聚、松耦合思想

高内聚:相近的功能应该放到同一个单元中,不相近的功能不要放到同一个单元中。这里的单元可以指方法、类、包、模块。 松耦合:某单元的改动不会影响依赖该单元的其他单元的改动。

高内聚、低耦合是一个比较底层的思想,后面的许多原则都是这一思想的体现。所以本文中不单独考察。

单一职责原则

描述:一个单元只负责完成一个职责。

引理:下游功能不应该依赖上游功能,观察者模式是专门来解决这一问题的。例如,用户注册后发放优惠券,不应该让用户单元依赖优惠券单元。

开闭原则

描述:对扩展开放,对修改关闭。在遇到一个新功能的时候,应该在已有单元上扩展代码(新增单元),而非修改已有单元。

里氏替换原则

描述:实现类的逻辑,要遵守接口的行为约定。

接口隔离原则

描述:调用者不应该被强迫依赖它不需要的接口。

引理:组合优于继承。即实现类应该实现多个接口来实现多个功能,而不是在一个接口中定义尽可能多的行为。

依赖反转原则

描述:高层单元不依赖低层单元,而是依赖一个共同的抽象。

DRY 原则

描述:不要写重复的代码。这里的重复不是指代码的重复,而是指语义的重复。有的代码有20多条语句、结构相同,但是代表不同步骤的校验,也不能算作重复代码;有的代码,虽然只有一条语句,也可能违背 DRY 原则。

引理:充血模型优于贫血模型。例如,判断订单过期的逻辑Instant.now().compare(order.getExpireTime()) >= 0,这是典型的贫血模型的写法。虽然只有一句话,但是如果被多个地方反复使用,修改逻辑时也需要改很多地方。充血模型的做法则是为订单类声明方法public boolean isExpired() { return Instant.now().compare(this.expireTime) >= 0 }

从按层组织演进到按功能组织

按层组织的代码架构

先看一个简化版的按层组织的代码架构:

- 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 原则。

按功能组织的代码架构

先看修改后的结果,再逐一解释过程与内容:

- 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

操作步骤:

  1. 将原本 controller、service 中的类,分别按功能放入各自的包中。
  2. 删除只有一个实现类的接口,所有依赖这些接口的地方暂且都改为直接依赖其实现类。
  3. 将 service 层向 controller 层提供的方法的访问级别修改为 protected。
  4. 向其他包提供按需的接口。例如 IInventoryService4Product 是库存向产品提供查询库存方法,IInventoryService4Order 是库存向订单提供锁定库存、扣减库存方法。这些接口可以放在源功能包(inventory)或者目标功能包(product、order)内,这里选择放在源功能包内,由 InventoryService 实现。注意不要改动先前的 protected 方法的访问级别。另外,如果向多个包提供相同的功能,那么可以用相同的签名。
  5. 按需提供接口模型。将原本的 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 的理论强调充血模型,但是我看过的所有文章全部都是贫血模型;其二,造了很多词,但是这些词是为了解决什么问题、如何解决的,完全不知所名;其三,一个新的理论,既没有证伪原有理论(指设计原则),也经受不起设计原则的考验。在更进一步了解其本质前,我对其保留反对意见。

如果想要了解更多设计原则的细节,可以去看一下王争老师的《设计模式之美》专栏。

Java Web 代码架构 —— 从按层组织演进到按功能组织