DDD系列:二、应用架构设计演变
作用:
通过规定一个固定的架构设计,可以让团队内有一个统一的开发规范,降低沟通成本,提升效率和代码质量。
目标:
在做架构设计时,一个好的架构应该需要实现以下几个目标:
- 独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
- 独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
- 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
- 可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。
贫血模型架构下的问题
贫血模型下的跨币种转账流程图:
一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。
贫血模型下,通常会在业务处理层中,将流程按方法模块进行拼接,组成一个完整的业务动作。这种写法在功能上没有什么问题,但是长久来看,有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。
可维护性(当依赖变化时,有多少代码需要随之改变)
- 提高数据结构的稳定性:Service 层不能直接使用 DO,避免 DO 变化影响到 Service 层。DO类是一个纯数据结构,映射了数据库中的一个表,表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做 Sharding,或者换一个表设计,或者改变字段名等。
- 依赖库的升级:Service 层不能直接使用 Data Access Layer(DAL)层。例如 xxxMapper 依赖 MyBatis 的实现,如果MyBatis未来升级版本,可能会造成用法的不同(可以参考iBatis升级到基于注解的MyBatis的迁移成本)。同样的,如果未来换一个ORM体系,迁移成本也是巨大的。
- 第三方服务依赖的不确定性:轻则API签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
- 中间件更换:中间件的升级、变更、能力拓展等问题。
上述几点都表达的一点,需要将依赖解耦。
可扩展性(做新需求或改逻辑时,需要新增/修改多少代码)
- 数据来源、格式的变化
- 业务逻辑无法复用:贫血模型下,数据格式变更,带来的不兼容问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的if-else语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成bug。
- 逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。
可测试性(每个需求需要增加的测试用例数 与 编写测试用例的耗时)
- 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
- 运行耗时长
- 耦合度高:当耦合的子步骤越多时,需要的测试用例呈指数级增长。假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。
DDD模型下的架构
上面列举了贫血模型带来的问题,现我们使用DDD来尝试解决。
抽象数据存储层:引入 Repository 和 Entity
- 新建Entity模型:Account实体对象,一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
- 新建Repository层:对象储存接口类AccountRepository,Repository只负责Entity对象的存储和读取,而Repository的实现类完成Entity -> DO 的转换、DB存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。
抽象第三方服务或中间件:引入Anti-Corruption Layer(防腐层或ACL)
ACL中可以做的事情:
- 适配器:外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。在这个案例里,我们通过封装了ExchangeRate和Currency对象,转化了对方的入参和出参,让入参出参更符合我们的标准。
- 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
- 兜底:当外部依赖稳定性较差时,可以通过 ACL 层实现兜底的功能。比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
- 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
- 功能开关:在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL中配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Mock测试,而不需要真正物理性的关闭外部依赖。
封装业务逻辑:通过Entity、Domain Primitive和Domain Service封装
用 DP 封装跟实体无关的无状态计算逻辑:
ExchangeRate 中封装汇率计算逻辑,返回应转入的金额对象
用Entity封装单对象的有状态的行为,包括业务校验
Account实体类封装所有Account的行为,包括业务逻辑校验
用Domain Service封装多对象逻辑
AccountTransferService中提供转账接口,完成源头账户的转出、目标账户的转入能力
DDD重构结果
代码:
public class TransferServiceImplNew implements TransferService {
private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);
return Result.success(true);
}
}
分层结构图:
通过对外部依赖的抽象和内部逻辑的封装重构,应用整体的依赖关系变了:
一、Domain Layer:
最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象**不依赖任何外部服务和框架,而是纯内存中的数据和操作。**领域层没有任何外部依赖关系。
Application Layer
再其次的是负责组件编排的Application Service,但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer(应用层)。应用层 依赖 领域层,但不依赖具体实现。
Infrastructure Layer
最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层)。Web框架里的对象如Controller之类的通常也属于基础设施层
DDD的下的架构建议
- Types模块(facade):对外暴露的Domain Primitives的地方。DP因为是无状态的逻辑,可以对外暴露,所以经常被包含在对外的API接口中,需要单独成为模块。Types模块不依赖任何类库,纯POJO。
- Domain模块(core-model 和 core-service):是核心业务逻辑的集中地,包含有状态的Entity、领域服务Domain Service、以及各种外部依赖的接口类(如Repository、ACL、中间件等)。
- Application模块(biz-shared 和 biz-service):主要包含Application Service和一些相关的类。Application模块依赖Domain模块。
- Infrastructure模块(dal 和 integration):包含了Persistence、Messaging、External等模块。比如:Persistence模块包含数据库DAO的实现,包含Data Object、ORM Mapper、Entity到DO的转化类等。
- Web模块(web):Web模块包含Controller等相关代码
代码的演进/变化速度
在传统架构中,代码从上到下的变化速度基本上是一致的,改个需求需要从接口、到业务逻辑、到数据库全量变更,而第三方变更可能会导致整个代码的重写。但在DDD中不同模块的代码的演进速度是不一样的:
- Domain 和 Application 层属于业务逻辑,属于经常被修改的地方。通过Entity能够解决基于单个对象的逻辑变更,通过Domain Service解决多个对象间的业务逻辑变更。
- Infrastructure 和 Types 层属于最低频变更的。Infrastructure 层的模块只有在外部依赖变更了之后才会跟着升级,而外部依赖的变更频率一般远低于业务逻辑的变更频率。
所以在DDD架构中,能明显看出越外层的代码越稳定,越内层的代码演进越快,真正体现了领域“驱动”的核心思想。