DDD应用架构

Posted by Kaka Blog on December 23, 2020

好的架构

一个好的架构应该需要实现以下几个目标:

  • 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
  • 独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
  • 独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
  • 可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。

今天我们在做业务研发时,更多的会去关注一些宏观的架构,比如SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现。

传统的分层架构

大多数封层架构都由一下三个标准层组成:显示层、业务层、基础设施层。

分层架构模式中的每一层都在应用中扮演特定的角色。比如说,显示层会负责处理所有的用户接口和交互逻辑,而一个业务层主要负责执行特定的业务和用户请求密切联系的任务。架构中的每一层都是满足某种业务请求需要做的事情的一个抽象。举例来说,显示层不需要知道或者关心怎么获取用户数据;它只需要以某种格式在屏幕上显示信息。类似地,业务层也不需要关心数据是以什么格式显示的甚至数据时从哪来的;它只需要从持久层拿到数据,对数据按照业务逻辑进行处理,然后将信息传给显示层。

上层对于下层有直接的依赖关系,导致耦合度过高。在业务层中对于下层的基础设施有强依赖,耦合度高。一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在Martin Fowler的 P of EAA书中,这种很常见的代码样式被叫做Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。

问题1:可维护性差

可维护性 = 当依赖变化时,有多少代码需要随之改变

事务脚本类的代码很难维护因为以下几点:

  • 数据结构的不稳定性:数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做Sharding,或者换一个表设计,或者改变字段名。
  • 依赖库的升级:依赖MyBatis的实现,如果MyBatis未来升级版本,可能会造成用法的不同(可以参考iBatis升级到基于注解的MyBatis的迁移成本)。同样的,如果未来换一个ORM体系,迁移成本也是巨大的。
  • 第三方服务依赖的不确定性:轻则API签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
  • 第三方服务API的接口变化:谁能保证未来第三方服务接口不会改变?
  • 中间件更换:今天我们用Kafka发消息,明天如果要用RocketMQ该怎么办?后天如果消息的序列化方式从String改为Binary该怎么办?如果需要消息分片该怎么改?

问题2:可拓展性差

可扩展性 = 做新需求或改逻辑时,需要新增/修改多少代码

  • 数据来源被固定、数据格式不兼容:原有的AccountDO是从本地获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写。
  • 业务逻辑无法复用:数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的if-else语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成bug。
  • 逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大。

问题3:可测试性能差

可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量

  • 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
  • 运行耗时长:大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring,启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
  • 耦合度高:假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N N N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。

DDD架构

先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design(领域驱动设计,或DDD)。

  • domain(领域层):处理核心业务逻辑,不依赖任何外部服务和框架,而是纯内存中的数据和操作,属于经常被修改的地方。
    • entity(实体对象,拥有数据和行为):避免了其他业务逻辑代码和数据库的直接耦合,避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
    • types(Domain Primitive):跟实体无关的无状态计算逻辑,参数校验。
    • service(Domain Service):封装多对象逻辑
    • exception(异常类)
  • application(应用层):属于Use Case(业务用例),负责组件编排,仅依赖了一些抽象出来的Domain Service类、ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。一般都是描述比较大方向的需求,接口相对稳定。
    • service(业务操作接口和实现)
    • repository(对象存储接口,只负责Entity对象的存储和读取):改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。
  • web(基础设施层):属于最低频变更的,包括Controller、Repository、Mapper、ACL等的具体实现,这些实现通常依赖外部具体的技术实现和框架。
    • persistence(数据库实现)
      • dao(ORM Mapper)
      • entity(Data Object):单纯的和数据库表的映射关系
      • transfer(Entity到DO的转化类)
    • repository.impl(repository实现类)
    • controller(控制器)

参考