文章目录
- 1.认识领域驱动设计
- 1.1 简介
- 1.2 发展历史
- 1.3 DDD 的兴起
- 2.从一个简单案例
- 2.1 转账需求
- 2.2 设计的问题
- 2.3 违反的设计原则
- 3.使用 DDD 进行重构
- 抽象数据存储层
- 抽象第三方服务
- 抽象中间件
- 封装业务逻辑
- 重构后的架构
- 4.小结
- 参考文献
1.认识领域驱动设计
1.1 简介
领域驱动设计(Domain-Driven Design,DDD)是一种复杂软件系统建模与设计方法论。
领域驱动设计最早由程序员 Eric Evans 于 2003 年在他的同名书籍《Domain-Driven Design: Tackling Complexity in Software》中提出。
领域驱动设计可以指导我们将复杂系统进行拆分,拆分出各个子系统间的关联以及是如何运转的,帮助我们解决大型的复杂系统在落地中遇到的问题。
1.2 发展历史
在复杂软件系统设计的领域,有许多重要的著作和方法论,它们提供了设计模式、架构风格、领域驱动设计、微服务等,为软件开发人员和架构师提供丰富的理论和实践指导。
- 94 年 GoF 的 《Design Patterns》
这本书介绍了23种设计模式,为面向对象设计提供了标准化的解决方案。
- 99 年 Martin Fowler 的 《Refactoring》
本书强调通过重构来改善代码的结构和可维护性,提供了多种重构技术和实例。
- 02 年 Martin Fowler《Patterns of Enterprise Application Architecture》
该书介绍了企业级应用程序的架构模式,帮助开发人员设计可扩展和可维护的企业应用程序。
- 03 年 Gregor Hohpe, Bobby Woolf 的 《Enterprise Integration Patterns》
这本书专注于企业应用中的集成模式,提供了大量的消息传递解决方案和设计模式。
后来软件设计理论逐渐开始注重业务,从业务角度给出架构设计理论。
- 03 年 Eric Evans 的《Domain Driven Design》
这本书是领域驱动设计(DDD)的奠基之作,Eric Evans 在书中提出了一系列概念和原则,旨在帮助开发人员解决复杂软件系统中的业务问题。书中强调了与业务专家的紧密合作、领域模型的创建,以及通过聚合、实体和值对象等概念来管理复杂性。
DDD 提供了一种将业务逻辑与技术实现分离的方法,使得软件设计更具灵活性和可维护性。这本书极大地影响了软件开发领域,特别是在设计复杂系统时。
- 13 年 Vaughn Vernon 的《Implementing DDD》
这本书基于 Eric Evans 的 DDD 理论,提供了更详细的实施指南。Vaughn Vernon 结合了实际案例和代码示例,介绍了如何在真实项目中应用 DDD 的原则和模式,包括聚合、领域事件、命令查询职责分离(CQRS)等。
重要性: 本书为开发人员提供了实际的、可操作的建议,帮助他们在项目中有效地实施 DDD。它也涵盖了现代软件架构的相关主题,如微服务架构和事件驱动设计。
- 17 年 Uncle Bob 的《Clean Architecture》
这本书讨论了软件架构的基本原则,强调了如何构建可维护、可测试和可扩展的系统。Uncle Bob 提出了“清洁架构”的概念,强调应当将业务逻辑与外部系统(如数据库、用户界面等)分离,以便于维护和测试。
本书提供了一种清晰的架构设计思路,强调了关注点分离和依赖倒置等原则,适用于各种规模的项目。它是软件架构师和开发人员在构建复杂系统时的重要参考。
1.3 DDD 的兴起
DDD 在 03 年问世,一直没有火起来,最近开始流行的原因,主要是借着微服务的东风。
Martin Fowler 于 2014 详细阐述了微服务架构,随后微服务架构逐渐兴起。
DDD 随着微服务架构的流行而火起来,主要是因为两者在设计理念、服务划分、复杂性管理、团队协作等方面存在天然的契合。DDD 提供的业务驱动设计和领域建模方法论为微服务架构的有效实施提供了理论支持,使得开发团队能够更好地管理复杂性,快速响应业务变化。
随着企业对灵活性和可扩展性的需求不断增长,很多大型互联网企业已经将 DDD 设计方法作为微服务的主流设计方法了。
后面伴随微服务逐渐进入大家的视野。
2.从一个简单案例
2.1 转账需求
举一个转账的业务场景。
用户可以通过 APP 转账给另一个账号,且支持跨币种转账。同时因为监管和对账要求,需要记录本次转账活动。
具体的实现步骤如下:
-
查询账户信息: 从数据库中获取涉及的账户信息,包括转出账户和转入账户。
-
获取汇率信息: 从第三方服务(如 Yahoo、XE 或其他汇率提供者)获取当前汇率,通过开放的 HTTP 接口进行调用。
-
计算转账金额: 根据获取的汇率计算需要转出的金额,检查转出账户的余额是否足够,并确认转账金额不超过每日限额。
-
执行转账操作: 完成转账,扣除相应的手续费,并将更新后的账户信息保存到数据库中。
-
发送审计消息: 通过消息队列(如 RabbitMQ)发送审计消息,以便后续进行审计和对账。
伪代码实现如下:
class TransferService {private yahooRateService;private rabbitMqClient;private mysqlClient;public func transfer(srcAccId, tgtAccId, amount, currency) error {// step 1 从 db 获取数据(账户与余额信息)// step 2 获取外部数据// step 3 根据获取到的数据进行业务参数校验// step 4 计算账户余额// step 5 变更账户余额至 DB// step 6 发送审计消息return nil}
}
一段业务代码里经常包含了参数校验、数据读取存储、调用外部服务、业务计算、发送消息等多种逻辑。
2.2 设计的问题
这种是很常见的实现代码,短时间并没有什么问题,并且可以满足业务需求,快速上线但长久以往有如下几个问题。
(1)可维护性差
可维护性 = 依赖变化时有多少代码需要随之改变。
应用程序的生命周期通常包括开发阶段和维护阶段,其中维护阶段的成本往往占据了更大的比例。
开发阶段: 通常占据整个生命周期成本的 20% - 30%。
维护阶段: 通常占据整个生命周期成本的 70% - 80%。
依赖的变更可能会有如下情况:
- 数据结构的不稳定性
账户表对应在代码中会有一个类与之对应,比如 AccModel。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做分片,或者变更表字段名等。
还有可能依赖的 ORM 库的升级或者迁移至新的 ORM 库,都会有维护成本。
- 第三方服务依赖的不确定性
第三方服务,比如汇率服务的变更。轻则API签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
- 中间件更换
加入今天使用 RabbitMQ 发消息,明天如果要上腾讯云用 RabbitMQ 该怎么办?后面如果消息的序列化方式从String改为Binary又该怎么办?如果需要消息分片该怎么改?
(2)可扩展性差
可扩展性 = 新增/变更功能时需要新增/修改多少代码。
虽然如果业务逻辑简单,那么面向过程的代码实现也非常高效简单,但是当业务功能变多时,其扩展性会变得越来越差。
如果之后要加一个转账到外部银行,原来的代码还可以复用吗?
原来的实现面对扩展需求时,有如下问题:
- 数据来源被固定、数据格式不兼容
原有的 AccModel 是从 DB 获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、异常处理到金额计算等逻辑都要重写。
- 业务逻辑无法复用
数据格式与数据源的不同,导致核心业务逻辑无法复用。主流程代码会出现很多 if-else 分支,导致代码逻辑混乱,难以维护。
- 业务逻辑和数据存储的相互依赖
当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。
在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大。
(3)可测试性差
可测试性 = 单个测试用例执行时间 * 每个需求所需要增加的测试用例数量。
根据这个定义,上面的实现方便做单元测试嘛?
- 业务逻辑与基础设施耦合严重
当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
- 测试用例运行耗时长
大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。当一个测试用例需要花超过10秒钟,会降低测试用例的执行频率,这回降低代码可靠性。
- 业务逻辑复杂
假如一段代码中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。当子步骤越多时,需要的测试用例呈指数级增长。
2.3 违反的设计原则
Uncle Bob 在其著作《Agile Software Development, Principles, Patterns, and Practices》提出了面向对象设计的 SOLID 原则。
这里至少违背了如下三个原则:
- 单一职责原则
一个类应该仅负责一个功能或任务,避免承担多个职责,以便于维护和理解。
但是在这个案例里,类 TransferService 的设计包含了多个功能,查询数据库,调用第三方服务,向中间件发送消息,一个类承担多个职责会导致类变得复杂,难以理解和维护。
- 开放封闭原则
软件实体应对扩展开放,但对修改封闭,即可以通过扩展现有代码而不是修改它来增加新功能。
在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现。
- 依赖倒置原则
高层模块不应依赖于低层模块,二者应依赖于抽象,从而减少模块间的耦合,提高系统灵活性。
在这个案例里外部依赖都是具体的实现,比如 yahooRateService 虽然是一个接口类,但是它对应的是依赖了 Yahoo 提供的具体服务,所以也算是依赖了实现。同样的 mysqlClient 和 rabbitMqClient 实现都属于具体实现。
3.使用 DDD 进行重构
下面是重构前的架构:
抽象数据存储层
新建一个 Account 实体类。
一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。
class Account{private id;private user_id;private card_no;private daily_limit;
}
Account 实体类和 AccountModel 数据类的区别:
AccountModel 是单纯和数据库表做映射的数据类,每个字段对应数据库表的一个列。这种对象叫 Data Object(贫血模型)。
Account 是领域逻辑的实体类,包含属性,同时也包含行为,属于实体(充血模型)。
新建账户存储接口类 AccRepo,只负责实体的存储和读取,而 AccRepo 的实现类完成数据库存储的细节。通过加入 AccRepo 接口,底层的数据库连接可以通过不同的实现类来替换。
interface AccRepo {func getById(id) Account;func saveToDb(Acc) error;
}// 具体实现
class AccRepoImpl implements AccRepo {func getById(id) Account {// ...}func saveToDb(Acc) error {// ...}
}
DAO 和 AccRepo 类的区别:
DAO 对应的是一个特定的数据库类型的操作,相当于 SQL 的封装。所操作的对象都是 Data Object 类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作。
AccRepo 对应的是实体对象读取/储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个 Entity 对象,但至于具体是 insert 还是 update 并不关心。
通过 Account 类,避免了其他业务逻辑代码和数据库的直接耦合,避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
通过 AccRepo 接口类,改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。实现了实体与 DB 的解耦。
通过 AccRepoImpl 实现类,由于其职责被单一出来,只需要关注 Account 到 AccountModel 的映射关系和 AccRepo 方法到 DAO 方法之间的映射关系。
抽象第三方服务
可以新建一个汇率类 ExRate,汇率接口类 ExRateService 和具体实现类 ExRateServiceYahoo。
// 汇率类
class ExRate {private srcCurrency;private tgtCurrency;private rate;// 根据汇率 rate 将金额转为 tgtCurrency 币种的金额public func exchangeTo(amount) float64;
}// 汇率接口类
interface ExRateService{func getExchangeRate(srcCurrency, tgtCurrency) ExRate;
}// 汇率实现类
class ExRateServiceYahoo implements ExRateService {//...
}
这是一种常见的设计模式叫做防腐层(Anti-Corruption Layer,ACL)。
防腐层是依赖倒置原则的一种体现:高层模块不应该依赖底层模块,二者都该依赖于抽象。
很多时候我们的系统会去依赖第三方系统,而被依赖的系统会有不兼容的协议或技术实现,如果对外部系统强依赖,如果第三方放生变更,我们的系统也会收到影响,那么会导致我们的系统被“腐蚀”。
这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
ACL 有如下好处:
- 适配器。
防腐层可以充当适配器,将不同系统的接口和数据格式转换为适合当前系统的格式,降低了系统间的耦合度。
- 缓存
防腐层可以实现缓存机制,减少对外部服务的频繁调用,从而提高系统性能和响应速度。
- 兜底
防腐层提供了兜底机制,能够处理外部系统的异常和错误,确保当前系统的稳定性和可靠性。
- 功能开关
防腐层可以实现功能开关,允许动态启用或禁用某些功能,从而灵活应对需求变化和系统演进。
- 易于测试
防腐层通过隔离外部依赖,使得单元测试和集成测试变得更加简单和高效。
抽象中间件
同样的,我们可以将具体的中间件实现与业务逻辑解耦。
可以新建一个审计消息类 AuditMsg,审计消息发送接口类 AuditMsgProducer 和具体实现类 AuditMsgProducerRabbit。
// 审计消息类
class AuditMsg {private srcAccId;private tgtAccId;private moneyAmount;public func serialize() string;public func deserialize(msg) AuditMsg;
}// 审计消息生产接口
interface AuditMsgProducer {func send(AuditMsg msg);
}// 审计消息生产接口实现类
class AuditMsgProducerRabbit implements AuditMsgProducer{// ...
}
通过对中间件抽象,使得业务逻辑依赖于抽象,而不是具体的中间件。
因为中间件通常需要有通用型,中间件的接口通常是 string 或 byte[] 类型的,导致序列化/反序列化逻辑通常和业务逻辑混杂在一起,造成胶水代码。通过中间件的 ACL 抽象,可以减少重复的胶水代码。
封装业务逻辑
金额没有 ID,是一个属性的集合,可以设计为一个值对象(Value Object)。
class Money {private amount;private currency;
}
账户有 ID,有属性,也有行为,转入和转出,可以设计为一个实体(Entity)。这个在上文已经完成设计,这里补充上方法。
我们发现这两个账号的转出和转入实际上是一体的,也就是说这种行为应该被封装到一个对象中去。
class Account{private id;private user_id;private card_no;private daily_limit;// 转出func withdraw();// 存入func deposit();
}
因为未来可能有功能上的扩展:比如增加一个扣手续费的逻辑。
这个时候在原有的 TransferService 中做并不合适,在任何一个域对象都不合适,需要有一个新的类去包含跨域对象的行为。这种对象叫做领域服务(Domain Service)。
我们可以新建一个 AccTransferService 接口,并给出一个具体的实现 AccTransferServiceImpl。
interface AccTransferService {function transfer(srcAcc, tgtAcc, money, exchageRate);
}class AccTranferServiceImpl implements AccTransferService{public func transfer(srcAcc, tgtAcc, money, exRate) {tgtMoney = exRate.exchangeTo(money);srcAcc->withdraw(money);tgtAcc->deposit(tgtMoney);}
}
原有的 TransferService 将变成:
class TransferServiceNew {private accRepo;private exRateService;private auditMsgProducer;private accTransferService;public func transfer(srcAccId, tgtAccId, amount, tgtCurrency) {// 读取数据accRepo.getById(srcAccId)accRepo.getById(trgAccId)// 获取外部数据exRate = exRateService.getExchangeRate()// 校验参数// ...// 业务逻辑:转账accTransferService.transfer()// 发送审计消息accTransferService.send(AuditMsg)}
}
重构后的架构
按照 DDD 的理论进行重构。
重构后最底层不再是数据库,而是领域对象:实体(Entity)、值对象(Value Object)和领域服务(Domain Service)。
这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作,打包为领域层(Domain Layer)。领域层没有任何外部依赖关系。
再其次的是负责组件编排的应用服务(Application Service),归到应用层(Application Layer)。
但是这些服务仅仅依赖了一些抽象出来的 ACL 类和 Repository 类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL 等我们归属为应用层。
应用层依赖领域层,但不依赖具体实现。
最后是 ACL,Repository 等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为基础设施层(Infrastructure Layer)。Web 框架里的对象如 Controller 之类的通常也属于基础设施层。
如果一开始使用 DDD 作为理论指导,重新写这段代码,考虑到最终的依赖关系,我们可能先写 Domain 层的业务逻辑,然后再写 Application 层的组件编排,最后才写每个外部依赖的具体实现。
这种架构思路和代码组织结构就叫做 领域驱动设计(Domain Driven Design)。
4.小结
DDD 是一种软件开发方法论,旨在通过深入理解业务领域,并将其与软件设计相结合,来解决复杂系统的开发问题。
DDD 强调在软件设计中聚焦于领域模型和领域逻辑,以便更好地满足业务需求。
以 DDD 作为理论指导,我们可以设计出具有如下优点的软件系统:
- 高可维护性
业务代码与外部依赖解耦,当外部依赖变更时,业务代码只用变更跟外部对接的模块,其他业务逻辑不变。
- 高可扩展性
DDD 鼓励将系统划分为多个聚合和模块,便于对系统进行扩展和重构。开发者可以在不干扰其他模块的情况下添加新功能。
- 高可测试性
每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
- 代码结构清晰
统一语言(Ubiquitous Language):DDD 强调开发团队与业务专家之间使用统一的语言,确保代码与业务概念一致,从而使代码更易于理解。
合理的分层架构:DDD 通常采用分层架构,如表示层、应用层、领域层和基础设施层,这种结构使得各个层次的职责分明,便于维护和开发。
当团队形成规范后,可以快速的定位到相关代码。
参考文献
DDD 概念参考 - 领域驱动设计