一、实际需求

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)传统方式开发

  1. public void pay(BigDecimal money, Long recipientId) {
  2. BankService.transfer(money, "CNY", recipientId);
  3. }

2) 领域模型开发

  1. public class Money {
  2. private BigDecimal amount;
  3. private Currency currency;
  4. public Money(BigDecimal amount, Currency currency) {
  5. this.amount = amount;
  6. this.currency = currency;
  7. }
  8. }
  9. public void pay(Money money,Long recipientId){
  10. BankService.transfer(money,recipientId);
  11. }


3.需求三期,支持跨国转账,手动计算汇率

一期的时候,一切顺利进行,到了二期,产品说:小开发呀,我们的业务越做越大了,已经扩展到海外了,现在需要考虑跨国转账了,还得计算汇率。作为开发,你虽然心里把产品骂开了花,但是不得不跟产品说,行,没问题,我们定个排期。(你个xxxxx,我xxxxx)

1)传统方式开发

  1. public void pay(Money money, Currency targetCurrency, Long recipientId) {
  2. if (money.getCurrency().equals(targetCurrency)) {
  3. BankService.transfer(money, recipientId);
  4. } else {
  5. BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
  6. BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
  7. Money targetMoney = new Money(targetAmount, targetCurrency);
  8. BankService.transfer(targetMoney, recipientId);
  9. }
  10. }

2)领域模型开发

  1. @Value //ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:
  2. public class ExchangeRate {
  3. private BigDecimal rate;
  4. private Currency from;
  5. private Currency to;
  6. public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
  7. this.rate = rate;
  8. this.from = from;
  9. this.to = to;
  10. }
  11. public Money exchange(Money fromMoney) {
  12. notNull(fromMoney);
  13. isTrue(this.from.equals(fromMoney.getCurrency()));
  14. BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
  15. return new Money(targetAmount, to);
  16. }
  17. }
  18. public void pay(Money money, Currency targetCurrency, Long recipientId) {
  19. ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
  20. Money targetMoney = rate.exchange(money);
  21. BankService.transfer(targetMoney, recipientId);
  22. }

4.需求四期,需要保留转账存根,调用第三方接口计算汇率

1)传统方式开发

  1. public class TransferController {
  2. private TransferService transferService;
  3. public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
  4. Long userId = (Long) session.getAttribute("userId");
  5. return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
  6. }
  7. }
  8. public class TransferServiceImpl implements TransferService {
  9. private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
  10. private AccountMapper accountDAO;
  11. private KafkaTemplate<String, String> kafkaTemplate;
  12. private YahooForexService yahooForex;
  13. @Override
  14. public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
  15. // 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
  16. AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
  17. AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);
  18. // 2. 业务参数校验
  19. if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
  20. throw new InvalidCurrencyException();
  21. }
  22. // 3. 获取外部数据,并且包含一定的业务逻辑
  23. // exchange rate = 1 source currency = X target currency
  24. BigDecimal exchangeRate = BigDecimal.ONE;
  25. if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
  26. exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
  27. }
  28. BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
  29. // 4. 业务参数校验
  30. if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
  31. throw new InsufficientFundsException();
  32. }
  33. if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
  34. throw new DailyLimitExceededException();
  35. }
  36. // 5. 计算新值,并且更新字段
  37. BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
  38. BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
  39. sourceAccountDO.setAvailable(newSource);
  40. targetAccountDO.setAvailable(newTarget);
  41. // 6. 更新到数据库
  42. accountDAO.update(sourceAccountDO);
  43. accountDAO.update(targetAccountDO);
  44. // 7. 发送审计消息
  45. String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
  46. kafkaTemplate.send(TOPIC_AUDIT_LOG, message);
  47. return Result.success(true);
  48. }
  49. }

2) 领域模型开发

①抽象数据存储层

  1. //抽象数据存储层
  2. @Data
  3. public class Account {
  4. private AccountId id;
  5. private AccountNumber accountNumber;
  6. private UserId userId;
  7. private Money available;
  8. private Money dailyLimit;
  9. public void withdraw(Money money) {
  10. // 转出
  11. }
  12. public void deposit(Money money) {
  13. // 转入
  14. }
  15. }
  16. public interface AccountRepository {
  17. Account find(AccountId id);
  18. Account find(AccountNumber accountNumber);
  19. Account find(UserId userId);
  20. Account save(Account account);
  21. }
  22. public class AccountRepositoryImpl implements AccountRepository {
  23. @Autowired
  24. private AccountMapper accountDAO;
  25. @Autowired
  26. private AccountBuilder accountBuilder;
  27. @Override
  28. public Account find(AccountId id) {
  29. AccountDO accountDO = accountDAO.selectById(id.getValue());
  30. return accountBuilder.toAccount(accountDO);
  31. }
  32. @Override
  33. public Account find(AccountNumber accountNumber) {
  34. AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
  35. return accountBuilder.toAccount(accountDO);
  36. }
  37. @Override
  38. public Account find(UserId userId) {
  39. AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
  40. return accountBuilder.toAccount(accountDO);
  41. }
  42. @Override
  43. public Account save(Account account) {
  44. AccountDO accountDO = accountBuilder.fromAccount(account);
  45. if (accountDO.getId() == null) {
  46. accountDAO.insert(accountDO);
  47. } else {
  48. accountDAO.update(accountDO);
  49. }
  50. return accountBuilder.toAccount(accountDO);
  51. }
  52. }

②抽象第三方服务

  1. public interface ExchangeRateService {
  2. ExchangeRate getExchangeRate(Currency source, Currency target);
  3. }
  4. public class ExchangeRateServiceImpl implements ExchangeRateService {
  5. @Autowired
  6. private YahooForexService yahooForexService;
  7. @Override
  8. public ExchangeRate getExchangeRate(Currency source, Currency target) {
  9. if (source.equals(target)) {
  10. return new ExchangeRate(BigDecimal.ONE, source, target);
  11. }
  12. BigDecimal forex = yahooForexService.getExchangeRate(source.getValue(), target.getValue());
  13. return new ExchangeRate(forex, source, target);
  14. }

③抽象中间件

  1. @Value
  2. @AllArgsConstructor
  3. public class AuditMessage {
  4. private UserId userId;
  5. private AccountNumber source;
  6. private AccountNumber target;
  7. private Money money;
  8. private Date date;
  9. public String serialize() {
  10. return userId + "," + source + "," + target + "," + money + "," + date;
  11. }
  12. public static AuditMessage deserialize(String value) {
  13. // todo
  14. return null;
  15. }
  16. }
  17. public interface AuditMessageProducer {
  18. SendResult send(AuditMessage message);
  19. }
  20. public class AuditMessageProducerImpl implements AuditMessageProducer {
  21. private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
  22. @Autowired
  23. private KafkaTemplate<String, String> kafkaTemplate;
  24. @Override
  25. public SendResult send(AuditMessage message) {
  26. String messageBody = message.serialize();
  27. kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
  28. return SendResult.success();
  29. }
  30. }

④封装业务逻辑

  1. //封装业务逻辑
  2. ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
  3. Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
  1. @Data//封装转账方法
  2. public class Account {
  3. private AccountId id;
  4. private AccountNumber accountNumber;
  5. private UserId userId;
  6. private Money available;
  7. private Money dailyLimit;
  8. public Currency getCurrency() {
  9. return this.available.getCurrency();
  10. }
  11. // 转入
  12. public void deposit(Money money) {
  13. if (!this.getCurrency().equals(money.getCurrency())) {
  14. throw new InvalidCurrencyException();
  15. }
  16. this.available = this.available.add(money);
  17. }
  18. // 转出
  19. public void withdraw(Money money) {
  20. if (this.available.compareTo(money) < 0) {
  21. throw new InsufficientFundsException();
  22. }
  23. if (this.dailyLimit.compareTo(money) < 0) {
  24. throw new DailyLimitExceededException();
  25. }
  26. this.available = this.available.subtract(money);
  27. }
  28. }
  1. public interface AccountTransferService {
  2. void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
  3. }
  4. public class AccountTransferServiceImpl implements AccountTransferService {
  5. private ExchangeRateService exchangeRateService;
  6. @Override
  7. public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
  8. Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
  9. sourceAccount.deposit(sourceMoney);
  10. targetAccount.withdraw(targetMoney);
  11. }
  12. }

⑤最终业务逻辑

  1. public class TransferServiceImplNew implements TransferService {
  2. private AccountRepository accountRepository;
  3. private AuditMessageProducer auditMessageProducer;
  4. private ExchangeRateService exchangeRateService;
  5. private AccountTransferService accountTransferService;
  6. @Override
  7. public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
  8. // 参数校验
  9. Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
  10. // 读数据
  11. Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
  12. Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
  13. ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
  14. // 业务逻辑
  15. accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
  16. // 保存数据
  17. accountRepository.save(sourceAccount);
  18. accountRepository.save(targetAccount);
  19. // 发送审计消息
  20. AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
  21. auditMessageProducer.send(message);
  22. return Result.success(true);
  23. }
  24. }

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