本文参考《阿里技术专家详解DDD系列 第二弹 - 应用架构》,而写,实际为其学习笔记。
数据库脚本型代码
case:
用户可以通过银行网页转账给另一个账号,支持跨币种转账。
同时因为监管和对账需求,需要记录本次转账活动。
拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:
1、从MySql数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;2、从 Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是 http 开放接口);
3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限;
4、实现转入和转出操作,扣除手续费,保存数据库;
5、发送 Kafka 审计消息,以便审计和对账用;
而一个简单的代码实现如下:
public class TransferController {
private TransferService transferService;
public Result<Boolean> 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 KafkaTemplate<String, String> kafkaTemplate;
/**
* 雅虎外汇服务
*/
private YahooForexService yahooForex;
@Override
public Result<Boolean> 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 currency
BigDecimal 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);
}
}
其具体问题,可以参照原文进行学习。
本文重点为其改造的学习笔记。
重构方案
抽象数据存储层
1.通过Account实体类代替AccountDO,避免直接对数据库表映射的依赖。
一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
这里的实体是聚合根,聚合了转账业务的所有对象。
Account实体类(非DP):
package com.example.bank.domain.entity;
import com.example.bank.exception.DailyLimitExceededException;
import com.example.bank.exception.InsufficientFundsException;
import com.example.bank.exception.InvalidCurrencyException;
import com.example.bank.exception.MoneyAmoutNotNullException;
import com.example.bank.types.*;
import lombok.Data;
/**
* <p>
*
* @Description: 账户
* </p>
* @ClassName Account
* @Author pl
* @Date 2020/4/17
* @Version V1.0.0
*/
@Data
public class Account {
/**
* 账户id
*/
private AccountId id;
/**
* 账号
*/
private AccountNumber accountNumber;
private UserId userId;
/**
* 可使用的钱
*/
private Money available;
/**
* 每日涨停
*/
private Money dailyLimit;
/**
* 货币
*/
private Currency currency;
// 转出
public void withdraw(Money money) throws Exception, DailyLimitExceededException {
if (this.available.compareTo(money) < 0){
throw new InsufficientFundsException();
}
if (this.dailyLimit.compareTo(money) < 0){
throw new DailyLimitExceededException();
}
this.available = this.available.subtract(money);
}
// 转入
public void deposit(Money money) throws InvalidCurrencyException, MoneyAmoutNotNullException {
if (!this.getCurrency().equals(money.getCurrency())){
throw new InvalidCurrencyException();
}
this.available = this.available.add(money);
}
}
位置
notice:
n1.领域模型仅依赖Types模块,Types模块是保存可以对外暴露的Domain Primitives的地方
AccountId
package com.example.bank.types;
public class AccountId {
private Long value;
public AccountId(Long value) {
this.value = value;
}
public Long getValue() {
return value;
}
public void setValue(Long value) {
this.value = value;
}
}
AccountNumber
package com.example.bank.types;
public class AccountNumber {
private String value;
public AccountNumber(String value) {
if (value == null || "".equals(value)){
throw new IllegalArgumentException("账号不能为空");
}
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
UserId
package com.example.bank.types;
public class UserId {
private Long id;
public UserId(Long id) {
this.id = id;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}
Money
package com.example.bank.types;
import com.example.bank.exception.MoneyAmoutNotNullException;
import java.math.BigDecimal;
public class Money {
private BigDecimal amout;
private Currency currency;
public Money(BigDecimal amout, Currency currency) {
if (amout == null){
throw new MoneyAmoutNotNullException("金额不能为空");
}
this.amout = amout;
this.currency = currency;
}
public Currency getCurrency() {
return currency;
}
public BigDecimal getAmout() {
return amout;
}
public int compareTo(Money money) {
return this.amout.compareTo(money.getAmout());
}
public Money subtract(Money money) throws Exception {
BigDecimal resultAmout = this.amout.subtract(money.getAmout());
return new Money(resultAmout, this.currency);
}
public Money add(Money money) throws MoneyAmoutNotNullException {
BigDecimal resultAmout = this.amout.add(money.getAmout());
return new Money(resultAmout, this.currency);
}
}
Currency
package com.example.bank.types;
public class Currency {
private String value;
public Currency(String value) {
if (value == null || "".equals(value)){
throw new IllegalArgumentException("货币不能为空");
}
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
@Override
public String toString() {
return "Currency{" +
"value='" + value + '\'' +
'}';
}
}
n2.Account实体类和AccountDO数据类的对比
Data Object数据类:
AccountDO是单纯的和数据库表的映射关系,每个字段对应数据库表的一个column,这种对象叫Data Object。DO只有数据,没有行为。AccountDO的作用是对数据库做快速映射,避免直接在代码里写SQL。无论你用的是MyBatis还是Hibernate这种ORM,从数据库来的都应该先直接映射到DO上,但是代码里应该完全避免直接操作 DO。
Entity实体类:
Account 是基于领域逻辑的实体类,它的字段和数据库储存不需要有必然的联系。Entity包含数据,同时也应该包含行为。在 Account 里,字段也不仅仅是String等基础类型,而应该尽可能用上一讲的 Domain Primitive 代替,可以避免大量的校验代码。
Data Objec是数据库表的直接映射,业务中应避免直接操作他,降低和数据库表的耦合,DP是领域的实体类,和数据库设计解耦。
2.通过AccountRepository替代accountDAO
新建对象储存接口类AccountRepository,Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。
Repository替代mapper,避免直接操作数据库,Repository只负责封账对实体对象的操作接口,其实现类负责完成对数据库的操作。
AccountRepository
package com.example.bank.repository;
import com.example.bank.domain.entity.Account;
import com.example.bank.types.AccountId;
import com.example.bank.types.AccountNumber;
import com.example.bank.types.UserId;
public interface AccountRepository {
Account find(AccountId id) throws Exception;
Account find(AccountNumber accountNumber) throws Exception;
Account find(UserId userId) throws Exception;
Account save(Account account) throws Exception;
}
其代码放在领域层
AccountRepositoryImpl
package com.example.bank.repository.impl;
import com.example.bank.domain.entity.Account;
import com.example.bank.exception.BusinessException;
import com.example.bank.persistence.AccountBuilder;
import com.example.bank.persistence.AccountDO;
import com.example.bank.persistence.AccountMapper;
import com.example.bank.repository.AccountRepository;
import com.example.bank.types.AccountId;
import com.example.bank.types.AccountNumber;
import com.example.bank.types.UserId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
@Repository
public class AccountRepositoryImpl implements AccountRepository {
@Autowired
private AccountMapper accountDAO;
@Autowired
private AccountBuilder accountBuilder;
@Override
public Account find(AccountId id) throws Exception {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(AccountNumber accountNumber) throws Exception {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
if (accountDO == null){
throw new BusinessException(String.format("账户[%s]不存在", accountNumber.getValue()));
}
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(UserId userId) throws Exception {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
if (accountDO == null){
throw new BusinessException("账户不存在");
}
return accountBuilder.toAccount(accountDO);
}
@Override
public Account save(Account account) throws Exception {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
}
其代码放在基础层
notice:
n1.DAO 和 Repository 类的对比
DAO对应的是一个特定的数据库类型的操作,相当于SQL的封装。所有操作的对象都是DO类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作。
Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个Entity对象,但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作,通过Builder/Factory对象实现AccountDO 到 Account之间的转化。
dao是对于sql的封装,Repository是对领域操作数据库行为的封装,避免直接依赖数据库。
n2.所有对象的转换都需要通过工厂来实现
领域对象DP和数据对象DO之间的转换必须通过工厂来实现,进一步将业务逻辑和数据库解耦。
@Override
public Account save(Account account) throws Exception {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
AccountBuilder采用抽象工厂模式
AccountBuilder
package com.example.bank.persistence;
import com.example.bank.domain.entity.Account;
public interface AccountBuilder {
Account toAccount(AccountDO accountDO) throws Exception;
AccountDO fromAccount(Account account);
}
AccountBuilderImpl
package com.example.bank.persistence.impl;
import com.example.bank.domain.entity.Account;
import com.example.bank.persistence.AccountBuilder;
import com.example.bank.persistence.AccountDO;
import com.example.bank.types.*;
import org.springframework.stereotype.Component;
@Component
public class AccountBuilderImpl implements AccountBuilder {
@Override
public Account toAccount(AccountDO accountDO) throws Exception {
Account account = new Account();
account.setId(new AccountId(accountDO.getId()));
account.setAccountNumber(new AccountNumber(accountDO.getAccountNumber()));
account.setUserId(new UserId(accountDO.getUserId()));
Currency currency = new Currency(accountDO.getCurrency());
account.setAvailable(new Money(accountDO.getAvailableAmout(), currency));
account.setDailyLimit(new Money(accountDO.getDailyLimitAmout(), currency));
return account;
}
@Override
public AccountDO fromAccount(Account account) {
AccountDO accountDO = new AccountDO();
if (account.getId() != null){
accountDO.setId(account.getId().getValue());
}
accountDO.setUserId(account.getUserId().getId());
accountDO.setAccountNumber(account.getAccountNumber().getValue());
accountDO.setAvailableAmout(account.getAvailable().getAmout());
accountDO.setDailyLimitAmout(account.getDailyLimit().getAmout());
accountDO.setCurrency(account.getCurrency().getValue());
return accountDO;
}
}
代码放在基础层
3.此时架构流程图
总结
抽象数据存储层,这一步一直在做的就是和数据库解耦,通过实体替代DO,避免直接ORM,通过仓库替代dao操作,通过工厂转换DP和DO。领域驱动设计就是将核心逻辑代码和外部依赖的解耦,外部依赖包括数据库,第三方服务,中间件。
抽象第三方服务
抽象出第三方交互服务用交换汇率服务ExchangeRateService接口替代在业务逻辑代码中直接使用雅虎客户端服务YahooForexService,从而规避第三方服务不可控的风险,解决入参出参的强耦合问题。
ExchangeRateService
package com.example.bank.external;
import com.example.bank.types.Currency;
import com.example.bank.types.ExchangeRate;
public interface ExchangeRateService {
ExchangeRate getExchangeRate(Currency source, Currency target);
}
通过对象传参也能在一定程度上解耦入参耦合的问题,领域层为了最大程度的将业务逻辑和外界以来解耦,大量使用接口,替代外部依赖。完全服务ocp规则。
代码位置,领域层的外部依赖。
ExchangeRateServiceImpl
package com.example.bank.external;
import com.example.bank.types.Currency;
import com.example.bank.types.ExchangeRate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class ExchangeRateServiceImpl implements ExchangeRateService{
@Autowired
private YahooForexService yahooForexService;
@Override
public 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);
}
}
这种设计模式也叫防腐层,具体防腐层的设计我会在后面的文章中来介绍。
总结
通过防腐层解耦核心逻辑与第三方服务。可以看出,领域驱动一直都在解耦,凡是影响到核心逻辑的点都需要抽象出来。
此时架构流程图
抽象中间件
领域驱动的一个特点,就是将业务逻辑和外部依赖直接的联系解耦到极致。
对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑。因为中间件通常需要有通用型,中间件的接口通常是String或Byte[] 类型的,导致序列化/反序列化逻辑通常和业务逻辑混杂在一起,造成胶水代码。通过中间件的ACL抽象,减少重复胶水代码。
通过封装一个抽象的AuditMessageProducer和AuditMessage DP对象来实现对kafkaTemplate的直接依赖·。
大部分ACL抽象都是通过接口和入参出参为对象来实现的。
代码如下:
AuditMessageProducer
代码如下:
AuditMessageProducer
package com.example.bank.messaging;
import com.example.bank.domain.types.AuditMessage;
public interface AuditMessageProducer {
void send(AuditMessage message);
}
代码位置
AuditMessageProducerImpl
package com.example.bank.middleware;
import com.example.bank.domain.types.AuditMessage;
import com.example.bank.messaging.AuditMessageProducer;
import org.springframework.beans.factory.annotation.Autowired;
/**
* <p>
*
* @Description: TODO
* </p>
* @ClassName AuditMessageProducerImpl
* @Author pl
* @Date 2020/12/28
* @Version V1.0.0
*/
public class AuditMessageProducerImpl implements AuditMessageProducer {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Override
public SendResult send(AuditMessage message) {
String messageBody = message.serialize();
kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
return SendResult.success();
}
}
代码位置
入参
AuditMessage
@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) {
// todo
return null;
}
}
代码位置
总结
抽象中间件和前面抽象第三方服务一样,都是采用ACL的模式抽象,通过定义接口和入参为对象的形式,最大力度的解耦。至此已经从数据库,第三方依赖,中间件的角度对其与核心业务逻辑进行解耦。
此时架构流程图
通过对象来封装业务逻辑
DP封装无状态逻辑
所谓的无状态逻辑,即实体逻辑业务不影响实体,也就是不影响实体属性值变化的业务逻辑。
通过这里可以看出,阿里的这一套文章,将DP的定位更加明显,就是一个加强版的VO,这个VO既有校验功能,也封装了无状态逻辑。
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
第五行逻辑可以封装到exchangeRate实体中,结合之前封装的第三方接口变为
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
用Entity封装单对象的有状态的行为,包括业务校验
有状态行为,即直接影响对象属性值的行为。
@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);
总结
通过DP封装无状态行为,Entity封装有状态行为。
用Domain Service封装多对象逻辑
跨越多个领域,即多个聚合根的业务需要在抽取出来一层,放在Domain Service中。
在这个案例里,我们发现这两个账号的转出和转入实际上是一体的,也就是说这种行为应该被封装到一个对象中去。特别是考虑到未来这个逻辑可能会产生变化:比如增加一个扣手续费的逻辑。这个时候在原有的TransferService中做并不合适,在任何一个Entity或者Domain Primitive里也不合适,需要有一个新的类去包含跨域对象的行为。这种对象叫做Domain Service。
public interface AccountTransferService {
void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}
public class AccountTransferServiceImpl implements AccountTransferService {
private ExchangeRateService exchangeRateService;
@Override
public 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);
总结
其实这里想说的是,跨多个领域的业务需要再抽取出来一层,放在应用层。
此时业务架构流程
整体重构后的代码
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);
}
}
可以看出来,经过重构后的代码有以下几个特征:
业务逻辑清晰,数据存储和业务逻辑完全分隔。
Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)
总结
DDD的代码重构主要分为三部分:
1.将主业务逻辑和外部依赖解耦
通过抽象数据存储层解耦数据库:抽象实体,dp避免直接使用DO;抽象Repository替代DAO;通过工厂模式转换DP,Entity和DO的关系。
通过抽象第三方服务解耦第三方应用,多采用ACL模式,定义接口,入参出参多是对象,非简单基本类型数据。
通过抽象中间接,解耦对中间接的依赖,也是采用ACL模式。
2.采用充血模型,聚合领域行为。
DP聚合领域无状态行为,Entity聚合有状态行为,且拥有唯一标识。
3.通过Application层的Service,实现跨领域业务。