一,前言-我对DDD的一点小看法

什么也不说,从一个小需求开始,事实胜于雄辩。

二,实际需求

1.需求一期,用户注册

假如现在需要很多业务员推广一个银行app,让人注册,当用户注册的时候,根据电话号的区号讲用户划分给对应区的业务员,方便后面算业务员的绩效。

1)传统模式下的代码

  1. public class User{
  2. Long UserId;
  3. String name;
  4. String phone;
  5. String address;
  6. Long repId;
  7. }
  8. public class UserService{
  9. private SalesRepRepository salesRepRepository;
  10. private User Repository;
  11. public User register(String name ,String phone , String address){
  12. //检验逻辑
  13. if(name == null || name.length == 0){
  14. throw new Exception("注册用户名不能为空!")
  15. }
  16. //此处省略校验电话号,地址逻辑
  17. //取电话号里面的区号,然后通过区号找到区域内的SalesRep
  18. String areaCode=null;
  19. String[] areas = new String[]{"0571", "021", "010"};
  20. for (int i = 0; i < phone.length(); i++) {
  21. String prefix = phone.substring(0, i);
  22. if (Arrays.asList(areas).contains(prefix)) {
  23. areaCode = prefix;
  24. break;
  25. }
  26. }
  27. SalesRep rep = salesRepRepository.findRep(areaCode);
  28. //最后创建用户,落盘,然后返回
  29. User user = new User();
  30. user.name=name;
  31. user.phone=phone;
  32. user.address=address;
  33. if(rep!=null){
  34. user.repId = rep.repId;
  35. return userRepo.save(user);
  36. }
  37. }

2)领域模块设计后的代码

  1. public class PhoneNumber{
  2. private final String number;
  3. public String getNumber(){
  4. return number;
  5. }
  6. public PhoneNumber(String number){
  7. if(number == null){
  8. throw new Exception("number is empty!");
  9. }else if(isValid(number)){
  10. throw new Exception("number format is error")
  11. }
  12. }
  13. public String getAreaCode() {
  14. for (int i = 0; i < number.length(); i++) {
  15. String prefix = number.substring(0, i);
  16. if (isAreaCode(prefix)) {
  17. return prefix;
  18. }
  19. }
  20. return null;
  21. }
  22. private static boolean isAreaCode(String prefix) {
  23. String[] areas = new String[]{"0571", "021", "010"};
  24. return Arrays.asList(areas).contains(prefix);
  25. }
  26. public static boolean isValid(String number) {
  27. String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
  28. return number.matches(pattern);
  29. }
  30. }
  31. public class User{
  32. UserId userId;
  33. Name name;
  34. PhoneNumber phone;
  35. Address address;
  36. RepId repId;
  37. }
  38. public User register(Name name,PhoneNumber phone,Address address){
  39. //根据手机号查找业务员
  40. SalesRep rep=salesRepRepository.findRep(phone.getAreaCode);
  41. User user=new User();
  42. user.name=name;
  43. user.phone=phone;
  44. user.address = address;
  45. if(rep!=null){
  46. user.repId=rep.repId;
  47. }
  48. return userRepo.saveUser(user);
  49. }

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)网络键盘侠

此时好像领域模型开发代码量更大,项目结构更复杂,垃圾!

真的是这样么?走着瞧。

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);
}

3)网络键盘侠

到了此时,传统模式开发的代码已经需求重构了,业务逻辑也开始增加,不再清晰。

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层简单拼装,就可以完成很多复杂的需求。