房地产集团网站欣赏,鸿蒙系统开发app入门系列,wordpress主页添加广告,做效果图需要什么软件填坑。谢谢大家对这个系列的期待#xff0c;持续更新#xff0c;欢迎关注此账号。
第一篇内容附地址#xff1a;
阿里巴巴淘系技术#xff1a;阿里技术专家详解 DDD 系列 第一讲- Domain Primitivezhuanlan.zhihu.com 架构这个词源于英文里的“Architecture“#xff…填坑。谢谢大家对这个系列的期待持续更新欢迎关注此账号。
第一篇内容附地址
阿里巴巴淘系技术阿里技术专家详解 DDD 系列 第一讲- Domain Primitivezhuanlan.zhihu.com 架构这个词源于英文里的“Architecture“源头是土木工程里的“建筑”和“结构”而架构里的”架“同时又包含了”架子“scaffolding的含义意指能快速搭建起来的固定结构。而今天的应用架构意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计可以让团队内能力参差不齐的同学们都能有一个统一的开发规范降低沟通成本提升效率和代码质量。
在做架构设计时一个好的架构应该需要实现以下几个目标
独立于框架架构不应该依赖某个外部的库或框架不应该被框架的结构所束缚。独立于UI前台展示的样式可能会随时发生变化今天可能是网页、明天可能变成console、后天是独立app但是底层架构不应该随之而变化。独立于底层数据源无论今天你用MySQL、Oracle还是MongoDB、CouchDB甚至使用文件系统软件架构不应该因为不同的底层数据储存方式而产生巨大改变。独立于外部依赖无论外部依赖如何变更、升级业务的核心逻辑不应该随之而大幅变化。可测试无论外部依赖了什么数据库、硬件、UI或者服务业务的逻辑应该都能够快速被验证正确性。
这就好像是建筑中的楼宇一个好的楼宇无论内部承载了什么人、有什么样的活动、还是外部有什么风雨一栋楼都应该屹立不倒而且可以确保它不会倒。但是今天我们在做业务研发时更多的会去关注一些宏观的架构比如SOA架构、微服务架构而忽略了应用内部的架构设计很容易导致代码逻辑混乱很难维护容易产生bug而且很难发现。今天我希望能够通过案例的分析和重构来推演出一套高质量的DDD架构。 1.案例分析
我们先看一个简单的案例需求如下
用户可以通过银行网页转账给另一个账号支持跨币种转账。
同时因为监管和对账需求需要记录本次转账活动。
拿到这个需求之后一个开发可能会经历一些技术选型最终可能拆解需求如下 1、从MySql数据库中找到转出和转入的账户选择用 MyBatis 的 mapper 实现 DAO2、从 Yahoo或其他渠道提供的汇率服务获取转账的汇率信息底层是 http 开放接口
3、计算需要转出的金额确保账户有足够余额并且没超出每日转账上限
4、实现转入和转出操作扣除手续费保存数据库
5、发送 Kafka 审计消息以便审计和对账用 而一个简单的代码实现如下
public class TransferController {private TransferService transferService;public ResultBoolean transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {Long userId (Long) session.getAttribute(userId);return transferService.transfer(userId, targetAccountNumber, amount, CNY);}
}public class TransferServiceImpl implements TransferService {private static final String TOPIC_AUDIT_LOG TOPIC_AUDIT_LOG;private AccountMapper accountDAO;private KafkaTemplateString, String kafkaTemplate;private YahooForexService yahooForex;Overridepublic ResultBoolean transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {// 1. 从数据库读取数据忽略所有校验逻辑如账号是否存在等AccountDO sourceAccountDO accountDAO.selectByUserId(sourceUserId);AccountDO targetAccountDO accountDAO.selectByAccountNumber(targetAccountNumber);// 2. 业务参数校验if (!targetAccountDO.getCurrency().equals(targetCurrency)) {throw new InvalidCurrencyException();}// 3. 获取外部数据并且包含一定的业务逻辑// exchange rate 1 source currency X target currencyBigDecimal exchangeRate BigDecimal.ONE;if (sourceAccountDO.getCurrency().equals(targetCurrency)) {exchangeRate yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);}BigDecimal sourceAmount targetAmount.divide(exchangeRate, RoundingMode.DOWN);// 4. 业务参数校验if (sourceAccountDO.getAvailable().compareTo(sourceAmount) 0) {throw new InsufficientFundsException();}if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) 0) {throw new DailyLimitExceededException();}// 5. 计算新值并且更新字段BigDecimal newSource sourceAccountDO.getAvailable().subtract(sourceAmount);BigDecimal newTarget targetAccountDO.getAvailable().add(targetAmount);sourceAccountDO.setAvailable(newSource);targetAccountDO.setAvailable(newTarget);// 6. 更新到数据库accountDAO.update(sourceAccountDO);accountDAO.update(targetAccountDO);// 7. 发送审计消息String message sourceUserId , targetAccountNumber , targetAmount , targetCurrency;kafkaTemplate.send(TOPIC_AUDIT_LOG, message);return Result.success(true);}}
我们可以看到一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在这个案例里虽然是写在了同一个方法里在真实代码中经常会被拆分成多个子方法但实际效果是一样的而在我们日常的工作中绝大部分代码都或多或少的接近于此类结构。在Martin Fowler的 P of EAA书中这种很常见的代码样式被叫做Transaction Script事务脚本。虽然这种类似于脚本的写法在功能上没有什么问题但是长久来看他有以下几个很大的问题可维护性差、可扩展性差、可测试性差。 问题1-可维护性能差
一个应用最大的成本一般都不是来自于开发阶段而是应用整个生命周期的总维护成本所以代码的可维护性代表了最终成本。
可维护性 当依赖变化时有多少代码需要随之改变
参考以上的案例代码事务脚本类的代码很难维护因为以下几点
数据结构的不稳定性AccountDO类是一个纯数据结构映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖长远来看都有可能会改变比如数据库要做Sharding或者换一个表设计或者改变字段名。依赖库的升级AccountMapper依赖MyBatis的实现如果MyBatis未来升级版本可能会造成用法的不同可以参考iBatis升级到基于注解的MyBatis的迁移成本。同样的如果未来换一个ORM体系迁移成本也是巨大的。第三方服务依赖的不确定性第三方服务比如Yahoo的汇率服务未来很有可能会有变化轻则API签名变化重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时外部依赖的兜底、限流、熔断等方案都需要随之改变。第三方服务API的接口变化YahooForexService.getExchangeRate返回的结果是小数点还是百分比入参是source, target还是target, source谁能保证未来接口不会改变如果改变了核心的金额计算逻辑必须跟着改否则会造成资损。中间件更换今天我们用Kafka发消息明天如果要上阿里云用RocketMQ该怎么办后天如果消息的序列化方式从String改为Binary该怎么办如果需要消息分片该怎么改
我们发现案例里的代码对于任何外部依赖的改变都会有比较大的影响。如果你的应用里有大量的此类代码你每一天的时间基本上会被各种库升级、依赖服务升级、中间件升级、jar包冲突占满最终这个应用变成了一个不敢升级、不敢部署、不敢写新功能、并且随时会爆发的炸弹终有一天会给你带来惊喜。 问题2-可拓展性差
事务脚本式代码的第二大缺陷是虽然写单个用例的代码非常高效简单但是当用例多起来时其扩展性会变得越来越差。
可扩展性 做新需求或改逻辑时需要新增/修改多少代码
参考以上的代码如果今天需要增加一个跨行转账的能力你会发现基本上需要重新开发基本上没有任何的可复用性
数据来源被固定、数据格式不兼容原有的AccountDO是从本地获取的而跨行转账的数据可能需要从一个第三方服务获取而服务之间数据格式不太可能是兼容的导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写。业务逻辑无法复用数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的if-else语句而这种分支多的逻辑会让分析代码非常困难容易错过边界情况造成bug。逻辑和数据存储的相互依赖当业务逻辑增加变得越来越复杂时新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下一个新功能的增加会导致所有原有功能的重构成本巨大。
在事务脚本式的架构下一般做第一个需求都非常的快但是做第N个需求时需要的时间很有可能是呈指数级上升的绝大部分时间花费在老功能的重构和兼容上最终你的创新速度会跌为0促使老应用被推翻重构。 问题3-可测试性能差
除了部分工具类、框架类和中间件类的代码有比较高的测试覆盖之外我们在日常工作中很难看到业务代码有比较好的测试覆盖而绝大部分的上线前的测试属于人肉的“集成测试”。低测试率导致我们对代码质量很难有把控容易错过边界条件异常case只有线上爆发了才被动发现。而低测试覆盖率的主要原因是业务代码的可测试性比较差。
可测试性 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量
参考以上的一段代码这种代码有极低的可测试性
设施搭建困难当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后想要完整跑通一个测试用例需要确保所有依赖都能跑起来这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。运行耗时长大多数的外部依赖调用都是I/O密集型如跨网络调用、磁盘调用等而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时绝大部分开发都不会很频繁的测试。耦合度高假如一段脚本中有A、B、C三个子步骤而每个步骤有N个可能的状态当多个子步骤耦合度高时为了完整覆盖所有用例最多需要有N * N * N个测试用例。当耦合的子步骤越多时需要的测试用例呈指数级增长。
在事务脚本模式下当测试用例复杂度远大于真实代码复杂度当运行测试用例的耗时超出人肉测试时绝大部分人会选择不写完整的测试覆盖而这种情况通常就是bug很难被早点发现的原因。 总结分析 我们重新来分析一下为什么以上的问题会出现因为以上的代码违背了至少以下几个软件设计的原则
单一性原则Single Responsibility Principle单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。依赖反转原则Dependency Inversion Principle依赖反转原则要求在代码中依赖抽象而不是具体的实现。在这个案例里外部依赖都是具体的实现比如YahooForexService虽然是一个接口类但是它对应的是依赖了Yahoo提供的具体服务所以也算是依赖了实现。同样的KafkaTemplate、MyBatis的DAO实现都属于具体实现。开放封闭原则Open Closed Principle开放封闭原则指开放扩展但是封闭修改。在这个案例里的金额计算属于可能会被修改的代码这个时候该逻辑应该需要被包装成为不可修改的计算类新功能通过计算类的拓展实现。我们需要对代码重构才能解决这些问题。 2.重构方案
在重构之前我们先画一张流程图描述当前代码在做的每个步骤 这是一个传统的三层分层结构UI层、业务层、和基础设施层。上层对于下层有直接的依赖关系导致耦合度过高。在业务层中对于下层的基础设施有强依赖耦合度高。我们需要对这张图上的每个节点做抽象和整理来降低对外部依赖的耦合度。 2.1 - 抽象数据存储层 第一步常见的操作是将Data Access层做抽象降低系统对数据库的直接依赖。具体的方法如下
新建Account实体对象一个实体Entity是拥有ID的域对象除了拥有数据之外同时拥有行为。Entity和数据库储存格式无关在设计中要以该领域的通用严谨语言Ubiquitous Language为依据。新建对象储存接口类AccountRepositoryRepository只负责Entity对象的存储和读取而Repository的实现类完成数据库存储的细节。通过加入Repository接口底层的数据库连接可以通过不同的实现类而替换。具体的简单代码实现如下
Account实体类
Data
public class Account {private AccountId id;private AccountNumber accountNumber;private UserId userId;private Money available;private Money dailyLimit;public void withdraw(Money money) {// 转出}public void deposit(Money money) {// 转入}
}
和AccountRepository及MyBatis实现类
public interface AccountRepository {Account find(AccountId id);Account find(AccountNumber accountNumber);Account find(UserId userId);Account save(Account account);
}public class AccountRepositoryImpl implements AccountRepository {Autowiredprivate AccountMapper accountDAO;Autowiredprivate AccountBuilder accountBuilder;Overridepublic Account find(AccountId id) {AccountDO accountDO accountDAO.selectById(id.getValue());return accountBuilder.toAccount(accountDO);}Overridepublic Account find(AccountNumber accountNumber) {AccountDO accountDO accountDAO.selectByAccountNumber(accountNumber.getValue());return accountBuilder.toAccount(accountDO);}Overridepublic Account find(UserId userId) {AccountDO accountDO accountDAO.selectByUserId(userId.getId());return accountBuilder.toAccount(accountDO);}Overridepublic Account save(Account account) {AccountDO accountDO accountBuilder.fromAccount(account);if (accountDO.getId() null) {accountDAO.insert(accountDO);} else {accountDAO.update(accountDO);}return accountBuilder.toAccount(accountDO);}}
Account实体类和AccountDO数据类的对比如下
Data Object数据类AccountDO是单纯的和数据库表的映射关系每个字段对应数据库表的一个column这种对象叫Data Object。DO只有数据没有行为。AccountDO的作用是对数据库做快速映射避免直接在代码里写SQL。无论你用的是MyBatis还是Hibernate这种ORM从数据库来的都应该先直接映射到DO上但是代码里应该完全避免直接操作 DO。Entity实体类Account 是基于领域逻辑的实体类它的字段和数据库储存不需要有必然的联系。Entity包含数据同时也应该包含行为。在 Account 里字段也不仅仅是String等基础类型而应该尽可能用上一讲的 Domain Primitive 代替可以避免大量的校验代码。DAO 和 Repository 类的对比如下
DAO对应的是一个特定的数据库类型的操作相当于SQL的封装。所有操作的对象都是DO类所有接口都可以根据数据库实现的不同而改变。比如insert 和 update 属于数据库专属的操作。Repository对应的是Entity对象读取储存的抽象在接口层面做统一不关注底层实现。比如通过 save 保存一个Entity对象但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作通过Builder/Factory对象实现AccountDO 到 Account之间的转化2.1.1 Repository和Entity
通过Account对象避免了其他业务逻辑代码和数据库的直接耦合避免了当数据库字段变化时大量业务逻辑也跟着变的问题。通过Repository改变业务代码的思维方式让业务逻辑不再面向数据库编程而是面向领域模型编程。Account属于一个完整的内存中对象可以比较容易的做完整的测试覆盖包含其行为。Repository作为一个接口类可以比较容易的实现Mock或Stub可以很容易测试。AccountRepositoryImpl实现类由于其职责被单一出来只需要关注Account到AccountDO的映射关系和Repository方法到DAO方法之间的映射关系相对于来说更容易测试。2.2 - 抽象第三方服务 类似对于数据库的抽象所有第三方服务也需要通过抽象解决第三方服务不可控入参出参强耦合的问题。在这个例子里我们抽象出 ExchangeRateService 的服务和一个ExchangeRate的Domain Primitive类
public interface ExchangeRateService {ExchangeRate getExchangeRate(Currency source, Currency target);
}public class ExchangeRateServiceImpl implements ExchangeRateService {Autowiredprivate YahooForexService yahooForexService;Overridepublic ExchangeRate getExchangeRate(Currency source, Currency target) {if (source.equals(target)) {return new ExchangeRate(BigDecimal.ONE, source, target);}BigDecimal forex yahooForexService.getExchangeRate(source.getValue(), target.getValue());return new ExchangeRate(forex, source, target);}
2.2.1 防腐层ACL
这种常见的设计模式叫做Anti-Corruption Layer防腐层或ACL。很多时候我们的系统会去依赖其他的系统而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现如果对外部系统强依赖会导致我们的系统被”腐蚀“。这个时候通过在系统间加入一个防腐层能够有效的隔离外部依赖和内部逻辑无论外部如何变更内部代码可以尽可能的保持不变。 ACL 不仅仅只是多了一层调用在实际开发中ACL能够提供更多强大的功能
适配器很多时候外部依赖的数据、接口和协议并不符合内部规范通过适配器模式可以将数据转化逻辑封装到ACL内部降低对业务代码的侵入。在这个案例里我们通过封装了ExchangeRate和Currency对象转化了对方的入参和出参让入参出参更符合我们的标准。缓存对于频繁调用且数据变更不频繁的外部依赖通过在ACL里嵌入缓存逻辑能够有效的降低对于外部依赖的请求压力。同时很多时候缓存逻辑是写在业务代码里的通过将缓存逻辑嵌入ACL能够降低业务代码的复杂度。兜底如果外部依赖的稳定性较差一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用比如当外部依赖出问题后返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂如果散落在核心业务代码中会很难维护通过集中在ACL中更加容易被测试和修改。易于测试类似于之前的RepositoryACL的接口类能够很容易的实现Mock或Stub以便于单元测试。功能开关有些时候我们希望能在某些场景下开放或关闭某个接口的功能或者让某个接口返回一个特定的值我们可以在ACL配置功能开关来实现而不会对真实业务代码造成影响。同时使用功能开关也能让我们容易的实现Monkey测试而不需要真正物理性的关闭外部依赖。2.3 - 抽象中间件
类似于2.2的第三方服务的抽象对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑。因为中间件通常需要有通用型中间件的接口通常是String或Byte[] 类型的导致序列化/反序列化逻辑通常和业务逻辑混杂在一起造成胶水代码。通过中间件的ACL抽象减少重复胶水代码。
在这个案例里我们通过封装一个抽象的AuditMessageProducer和AuditMessage DP对象实现对底层kafka实现的隔离
Value
AllArgsConstructor
public class AuditMessage {private UserId userId;private AccountNumber source;private AccountNumber target;private Money money;private Date date;public String serialize() {return userId , source , target , money , date; }public static AuditMessage deserialize(String value) {// todoreturn null;}
}public interface AuditMessageProducer {SendResult send(AuditMessage message);
}public class AuditMessageProducerImpl implements AuditMessageProducer {private static final String TOPIC_AUDIT_LOG TOPIC_AUDIT_LOG;Autowiredprivate KafkaTemplateString, String kafkaTemplate;Overridepublic SendResult send(AuditMessage message) {String messageBody message.serialize();kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);return SendResult.success();}
}
具体的分析和2.2类似在此略过。 2.4 - 封装业务逻辑
在这个案例里有很多业务逻辑是跟外部依赖的代码混合的包括金额计算、账户余额的校验、转账限制、金额增减等。这种逻辑混淆导致了核心计算逻辑无法被有效的测试和复用。在这里我们的解法是通过Entity、Domain Primitive和Domain Service封装所有的业务逻辑 2.4.1 - 用Domain Primitive封装跟实体无关的无状态计算逻辑
在这个案例里使用ExchangeRate来封装汇率计算逻辑
BigDecimal exchangeRate BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {exchangeRate yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount targetAmount.divide(exchangeRate, RoundingMode.DOWN);
变为
ExchangeRate exchangeRate exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney exchangeRate.exchangeTo(targetMoney);
2.4.2 - 用Entity封装单对象的有状态的行为包括业务校验
用Account实体类封装所有Account的行为包括业务校验如下
Data
public class Account {private AccountId id;private AccountNumber accountNumber;private UserId userId;private Money available;private Money dailyLimit;public Currency getCurrency() {return this.available.getCurrency();}// 转入public void deposit(Money money) {if (!this.getCurrency().equals(money.getCurrency())) {throw new InvalidCurrencyException();}this.available this.available.add(money);}// 转出public void withdraw(Money money) {if (this.available.compareTo(money) 0) {throw new InsufficientFundsException();}if (this.dailyLimit.compareTo(money) 0) {throw new DailyLimitExceededException();}this.available this.available.subtract(money);}
}
原有的业务代码则可以简化为
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);
2.4.3 - 用Domain Service封装多对象逻辑 在这个案例里我们发现这两个账号的转出和转入实际上是一体的也就是说这种行为应该被封装到一个对象中去。特别是考虑到未来这个逻辑可能会产生变化比如增加一个扣手续费的逻辑。这个时候在原有的TransferService中做并不合适在任何一个Entity或者Domain Primitive里也不合适需要有一个新的类去包含跨域对象的行为。这种对象叫做Domain Service。
我们创建一个AccountTransferService的类
public interface AccountTransferService {void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}public class AccountTransferServiceImpl implements AccountTransferService {private ExchangeRateService exchangeRateService;Overridepublic void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {Money sourceMoney exchangeRate.exchangeTo(targetMoney);sourceAccount.deposit(sourceMoney);targetAccount.withdraw(targetMoney);}
}
而原始代码则简化为一行
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate); 2.5 - 重构后结果分析
这个案例重构后的代码如下
public class TransferServiceImplNew implements TransferService {private AccountRepository accountRepository;private AuditMessageProducer auditMessageProducer;private ExchangeRateService exchangeRateService;private AccountTransferService accountTransferService;Overridepublic ResultBoolean 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);}
}
可以看出来经过重构后的代码有以下几个特征
业务逻辑清晰数据存储和业务逻辑完全分隔。Entity、Domain Primitive、Domain Service都是独立的对象没有任何外部依赖但是却包含了所有核心业务逻辑可以单独完整测试。原有的TransferService不再包括任何计算逻辑仅仅作为组件编排所有逻辑均delegate到其他组件。这种仅包含Orchestration编排的服务叫做Application Service应用服务。我们可以根据新的结构重新画一张图 然后通过重新编排后该图变为 我们可以发现通过对外部依赖的抽象和内部逻辑的封装重构应用整体的依赖关系变了
最底层不再是数据库而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架而是纯内存中的数据和操作。这些对象我们打包为Domain Layer领域层。领域层没有任何外部依赖关系。再其次的是负责组件编排的Application Service但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer应用层。应用层 依赖 领域层但不依赖具体实现。最后是ACLRepository等的具体实现这些实现通常依赖外部具体的技术实现和框架所以统称为Infrastructure Layer基础设施层。Web框架里的对象如Controller之类的通常也属于基础设施层。
如果今天能够重新写这段代码考虑到最终的依赖关系我们可能先写Domain层的业务逻辑然后再写Application层的组件编排最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design领域驱动设计或DDD。所以DDD不是一个特殊的架构设计而是所有Transction Script代码经过合理重构后一定会抵达的终点。 3. DDD的六边形架构
在我们传统的代码里我们一般都很注重每个外部依赖的实现细节和规范但是今天我们需要敢于抛弃掉原有的理念重新审视代码结构。在上面重构的代码里如果抛弃掉所有Repository、ACL、Producer等的具体实现细节我们会发现每一个对外部的抽象类其实就是输入或输出类似于计算机系统中的I/O节点。这个观点在CQRS架构中也同样适用将所有接口分为Command输入和Query输出两种。除了I/O之外其他的内部逻辑就是应用业务的核心逻辑。基于这个基础Alistair Cockburn在2005年提出了Hexagonal Architecture六边形架构又被称之为Ports and Adapters端口和适配器架构。 在这张图中
I/O的具体实现在模型的最外层每个I/O的适配器在灰色地带每个Hex的边是一个端口Hex的中央是应用的核心领域模型
在Hex中架构的组织关系第一次变成了一个二维的内外关系而不是传统一维的上下关系。同时在Hex架构中我们第一次发现UI层、DB层、和各种中间件层实际上是没有本质上区别的都只是数据的输入和输出而不是在传统架构中的最上层和最下层。
除了2005年的Hex架构2008年 Jeffery Palermo的Onion Architecture洋葱架构和2017年 Robert Martin的Clean Architecture干净架构都是极为类似的思想。除了命名不一样、切入点不一样之外其他的整体架构都是基于一个二维的内外关系。这也说明了基于DDD的架构最终的形态都是类似的。Herberto Graca有一个很全面的图包含了绝大部分现实中的端口类值得借鉴。 3.1 - 代码组织结构
为了有效的组织代码结构避免下层代码依赖到上层实现的情况在Java中我们可以通过POM Module和POM依赖来处理相互的关系。通过Spring/SpringBoot的容器来解决运行时动态注入具体实现的依赖的问题。一个简单的依赖关系图如下 3.1.1 - Types 模块
Types模块是保存可以对外暴露的Domain Primitives的地方。Domain Primitives因为是无状态的逻辑可以对外暴露所以经常被包含在对外的API接口中需要单独成为模块。Types模块不依赖任何类库纯 POJO 。 3.1.2 - Domain 模块
Domain 模块是核心业务逻辑的集中地包含有状态的Entity、领域服务Domain Service、以及各种外部依赖的接口类如Repository、ACL、中间件等。Domain模块仅依赖Types模块也是纯 POJO 。 3.1.3 - Application模块
Application模块主要包含Application Service和一些相关的类。Application模块依赖Domain模块。还是不依赖任何框架纯POJO。 3.1.4 - Infrastructure模块
Infrastructure模块包含了Persistence、Messaging、External等模块。比如Persistence模块包含数据库DAO的实现包含Data Object、ORM Mapper、Entity到DO的转化类等。Persistence模块要依赖具体的ORM类库比如MyBatis。如果需要用Spring-Mybatis提供的注解方案则需要依赖Spring。 3.1.5 - Web模块
Web模块包含Controller等相关代码。如果用SpringMVC则需要依赖Spring。 3.1.6 - Start模块
Start模块是SpringBoot的启动类。 3.2 - 测试 TypesDomain模块都属于无外部依赖的纯POJO基本上都可以100%的被单元测试覆盖。Application模块的代码依赖外部抽象类需要通过测试框架去Mock所有外部依赖但仍然可以100%被单元测试。Infrastructure的每个模块的代码相对独立接口数量比较少相对比较容易写单测。但是由于依赖了外部I/O速度上不可能很快但好在模块的变动不会很频繁属于一劳永逸。Web模块有两种测试方法通过Spring的MockMVC测试或者通过HttpClient调用接口测试。但是在测试时最好把Controller依赖的服务类都Mock掉。一般来说当你把Controller的逻辑都后置到Application Service中时Controller的逻辑变得极为简单很容易100%覆盖。Start模块通常应用的集成测试写在start里。当其他模块的单元测试都能100%覆盖后集成测试用来验证整体链路的真实性。3.3 - 代码的演进/变化速度
在传统架构中代码从上到下的变化速度基本上是一致的改个需求需要从接口、到业务逻辑、到数据库全量变更而第三方变更可能会导致整个代码的重写。但是在DDD中不同模块的代码的演进速度是不一样的
Domain层属于核心业务逻辑属于经常被修改的地方。比如原来不需要扣手续费现在需要了之类的。通过Entity能够解决基于单个对象的逻辑变更通过Domain Service解决多个对象间的业务逻辑变更。Application层属于Use Case业务用例。业务用例一般都是描述比较大方向的需求接口相对稳定特别是对外的接口一般不会频繁变更。添加业务用例可以通过新增Application Service或者新增接口实现功能的扩展。Infrastructure层属于最低频变更的。一般这个层的模块只有在外部依赖变更了之后才会跟着升级而外部依赖的变更频率一般远低于业务逻辑的变更频率。
所以在DDD架构中能明显看出越外层的代码越稳定越内层的代码演进越快真正体现了领域“驱动”的核心思想。 4 总结 DDD不是一个什么特殊的架构而是任何传统代码经过合理的重构之后最终一定会抵达的终点。DDD的架构能够有效的解决传统架构中的问题
高可维护性当外部依赖变更时内部代码只用变更跟外部对接的模块其他业务逻辑不变。高可扩展性做新功能时绝大部分的代码都能复用仅需要增加核心业务逻辑即可。高可测试性每个拆分出来的模块都符合单一性原则绝大部分不依赖框架可以快速的单元测试做到100%覆盖。代码结构清晰通过POM module可以解决模块间的依赖关系 所有外接模块都可以单独独立成Jar包被复用。当团队形成规范后可以快速的定位到相关代码。本系列 持续更新欢迎关注我们蹲守下一篇内容~ ——————————————————————————————————————
阿里巴巴集团淘系技术部官方账号。淘系技术部是阿里巴巴新零售技术的王牌军支撑淘宝、天猫核心电商以及淘宝直播、闲鱼、躺平、阿里汽车、阿里房产等创新业务服务9亿用户赋能各行业1000万商家。我们打造了全球领先的线上新零售技术平台并作为核心技术团队保障了11次双十一购物狂欢节的成功。
点击下方主页关注我们你将收获更多来自阿里一线工程师的技术实战技巧成长经历心得。另不定期更新最新岗位招聘信息和简历内推通道欢迎各位以最短路径加入我们。
阿里巴巴淘系技术www.zhihu.com 发布于 01-11