一、实际需求
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("注册用户名不能为空!")}//此处省略校验电话号,地址逻辑//取电话号里面的区号,然后通过区号找到区域内的SalesRepString 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;@Overridepublic 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 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);}}
2) 领域模型开发
①抽象数据存储层
//抽象数据存储层@Datapublic 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 {@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);}}
②抽象第三方服务
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);}
③抽象中间件
@Value@AllArgsConstructorpublic 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 KafkaTemplate<String, String> kafkaTemplate;@Overridepublic 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;@Overridepublic 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;@Overridepublic 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
