一、实际需求
1.需求一期,用户注册
假如现在需要很多业务员推广一个银行app,让人注册,当用户注册的时候,根据电话号的区号将用户划分给对应区的业务员,方便后面算业务员的绩效。
1)传统模式下的代码
public class User{
Long UserId;
String name;
String phone;
String address;
Long repId;
}
public class UserService{
private SalesRepRepository salesRepRepository;
private User Repository;
public User register(String name ,String phone , String address){
//检验逻辑
if(name == null || name.length == 0){
throw new Exception("注册用户名不能为空!")
}
//此处省略校验电话号,地址逻辑
//取电话号里面的区号,然后通过区号找到区域内的SalesRep
String areaCode=null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepository.findRep(areaCode);
//最后创建用户,落盘,然后返回
User user = new User();
user.name=name;
user.phone=phone;
user.address=address;
if(rep!=null){
user.repId = rep.repId;
return userRepo.save(user);
}
}
2)领域模块设计后的代码
public class PhoneNumber{
private final String number;
public String getNumber(){
return number;
}
public PhoneNumber(String number){
if(number == null){
throw new Exception("number is empty!");
}else if(isValid(number)){
throw new Exception("number format is error")
}
}
public String getAreaCode() {
for (int i = 0; i < number.length(); i++) {
String prefix = number.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}
private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021", "010"};
return Arrays.asList(areas).contains(prefix);
}
public static boolean isValid(String number) {
String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
return number.matches(pattern);
}
}
public class User{
UserId userId;
Name name;
PhoneNumber phone;
Address address;
RepId repId;
}
public User register(Name name,PhoneNumber phone,Address address){
//根据手机号查找业务员
SalesRep rep=salesRepRepository.findRep(phone.getAreaCode);
User user=new User();
user.name=name;
user.phone=phone;
user.address = address;
if(rep!=null){
user.repId=rep.repId;
}
return userRepo.saveUser(user);
}
3)对比
①接口清晰度
传统代码传参三个String类型的参数,如果顺序错了,这种问题,code review就能发现么?
②业务逻辑清晰度
参数校验和错误处理全部写在service,这样的做法,是不是会让业务逻辑看起来并不明确?
假如我现在增加一个字段,是不是还得继续加参数校验逻辑,假如我有十个地方需要传这四个参数,是不是十个地方都要改?代码冗余,可能忘记修改某一个地方,业务逻辑不清晰。
③单元测试
很多时候,我们接口的传参都是允许不传或者有默认值的,或者我现在需求改动,又加了或者较少了一个参数,单元测试的覆盖率怎么样?能否保证所有情况全部被覆盖?
2.需求二期,国内转账
业务员很给力,推广了很多用户都来注册,接下来要开始做真正的业务需求了。
1)传统方式开发
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
2) 领域模型开发
public class Money {
private BigDecimal amount;
private Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}
public void pay(Money money,Long recipientId){
BankService.transfer(money,recipientId);
}
3.需求三期,支持跨国转账,手动计算汇率
一期的时候,一切顺利进行,到了二期,产品说:小开发呀,我们的业务越做越大了,已经扩展到海外了,现在需要考虑跨国转账了,还得计算汇率。作为开发,你虽然心里把产品骂开了花,但是不得不跟产品说,行,没问题,我们定个排期。(你个xxxxx,我xxxxx)
1)传统方式开发
public void pay(Money money, Currency targetCurrency, Long recipientId) {
if (money.getCurrency().equals(targetCurrency)) {
BankService.transfer(money, recipientId);
} else {
BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
Money targetMoney = new Money(targetAmount, targetCurrency);
BankService.transfer(targetMoney, recipientId);
}
}
2)领域模型开发
@Value //ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:
public class ExchangeRate {
private BigDecimal rate;
private Currency from;
private Currency to;
public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
this.rate = rate;
this.from = from;
this.to = to;
}
public Money exchange(Money fromMoney) {
notNull(fromMoney);
isTrue(this.from.equals(fromMoney.getCurrency()));
BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
return new Money(targetAmount, to);
}
}
public void pay(Money money, Currency targetCurrency, Long recipientId) {
ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
Money targetMoney = rate.exchange(money);
BankService.transfer(targetMoney, recipientId);
}
4.需求四期,需要保留转账存根,调用第三方接口计算汇率
1)传统方式开发
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);
}
}
2) 领域模型开发
①抽象数据存储层
//抽象数据存储层
@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) {
// 转入
}
}
public interface AccountRepository {
Account find(AccountId id);
Account find(AccountNumber accountNumber);
Account find(UserId userId);
Account save(Account account);
}
public class AccountRepositoryImpl implements AccountRepository {
@Autowired
private AccountMapper accountDAO;
@Autowired
private AccountBuilder accountBuilder;
@Override
public Account find(AccountId id) {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(AccountNumber accountNumber) {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(UserId userId) {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account save(Account account) {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
}
②抽象第三方服务
public interface ExchangeRateService {
ExchangeRate getExchangeRate(Currency source, Currency target);
}
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);
}
③抽象中间件
@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;
}
}
public interface AuditMessageProducer {
SendResult send(AuditMessage message);
}
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();
}
}
④封装业务逻辑
//封装业务逻辑
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
@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);
}
}
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);
}
}
⑤最终业务逻辑
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);
}
}
3) 对比
传统开发:
一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。在Martin Fowler的 P of EAA书中,这种很常见的代码样式被叫做Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。
领域模型:
- 业务逻辑清晰,数据存储和业务逻辑完全分隔。
- 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(应用层)。应用层 依赖 领域层,但不依赖具体实现。
- 最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层)。Web框架里的对象如Controller之类的通常也属于基础设施层。
二、感想
写这段代码,考虑到最终的依赖关系,我们可能先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design(领域驱动设计,或DDD)。
DDD不是一个什么特殊的架构,而是任何传统代码经过合理的重构之后最终一定会抵达的终点。DDD的架构能够有效的解决传统架构中的问题:
- 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
- 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。
- 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
- 代码结构清晰:通过POM module可以解决模块间的依赖关系, 所有外接模块都可以单独独立成Jar包被复用。当团队形成规范后,可以快速的定位到相关代码。
传统的面向对象设计,对象里面只定义了属性,不包含行为方法,在领域模型里,对象里面应该包含着属性还有行为方法,通过对不同种类对象的划分,在业务层进行组合完成功能,就像映射到一个实体的人类,人代表一个类,之前我们只标记了他有名字,照片,年龄,现在我想把行为也定义进来,喝水,吃饭。走路,抽烟。我在完成一件事的时候,实际上可能是很多人,很多动作的组合。
领域模型设计,可以理解成就是将业务拆分成小单元(基本行为),划分给每一个对象,业务层只需要关心如何组装对象,让开发人员做到简洁开发。说的简单一点,我现在有一个箱子,里面有很多积木,有圆的,长方形的,正方形的,我现在需要把他们组装在一起,变成一个变形金刚或者房子,汽车。
依个人浅薄意见,由于业务场景的不同,模型的定义和功能(行为)就不同,领域的边界划分是重点,只有边界明确了。才能更好的实施,表面上看,项目结构好像更加复杂化了,实际上,如果经历了长期的迭代,需求变更,他只会带来轻松,灵活,易扩展,很少情况下会重构代码。
这个理念是否要落地成一个框架?框架怎么设计?比如Dubbo的SPI机制,是不是也是一种领域模型设计,因为要完成的功能不同,如果设计成框架,很难进行统一,是不是可以只提供核心规范,定义一些标准的格式,剩下的都通过扩展点留给开发者根据需求扩展。
其实不应该设计成框架的,他应该是一种理念或者规范,我们的一切目的都是为了简化开发,所以应该是模型更正确,我们定义好一组模型,按照这个模型规范进行开发,针对不同的需求,架构师划分领域边界,规定好入参出参,开发人员负责开发具体代码,在service层进行简单组装,清晰明了,如果领域划分的明确,开发应该变得很简单。
传统的应用开发,很大程度上,业务驱动技术和架构,也就是业务驱动模型,在DDD里,我们换了一个角度,以模型驱动业务,通过不同模型里面方法的拼装,完成业务功能。就比如我们为什么要定义VO,DTO,PO,还不是为了适应业务需求,可能单纯映射数据库的实体类,并不能满足业务需求,需要扩展,再比如,数据库里,订单是一张表,每一条记录都是独立的,但是实际上,会涉及到拆单,拼单,但是加入我们提前定义好了模型,模型领域划分明确,是不是在业务层只需要调用order的方法就可以了?
展望未来,如果DDD可以大行其道,是不是以后会多很多模型jar包,里面封装这各种各样的模型,开发人员根据业务需求引入各种各样的模型jar,只需要在service层简单拼装,就可以完成很多复杂的需求。
转自:https://www.yuque.com/yinhuidong/uko54z/wdgp0f