DDD优秀实践及总结 Part Ⅱ——应用架构

Part Ⅱ. 应用架构

  • 好的应用架构目标:

独立于框架、独立于UI、独立于底层数据源、独立于外部依赖、可测试。

  • 案例分析

案例:用户可以通过银行网页转账给另一个账号,支持跨币种转账。同时因为监管和对账需求,需要记录本次转账活动。

需求拆解:

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-可维护性差

可维护性=当依赖变化时,有多少代码需要随之变化

1)数据结构的不稳定性:AccountDO类是一个纯数据结构,数据库的表结构和设计时应用的外部依赖,长远来看都有可能会改变;

2)依赖库的升级:MyBatis升级,或更换ORM体系;

3)第三方服务依赖的不确定性:第三方服务未来很有可能会变化,轻则API签名变化,重则服务不可用;

4)中间件更换。

问题2-可扩展性差

可扩展性=做新需求或改逻辑时,需要新增/修改多少代码

如果今天需要增加一个跨行转账的能力

1)数据来源被固定、数据格式不兼容:原有的AccountDO和第三方服务获取的数据格式不兼容;

2)业务逻辑无法复用:数据格式不兼容导致核心业务逻辑无法复用,最终会造成大量的if-else语句;

3)逻辑和数据存储的相互依赖:变更了数据结构后会导致原有的其他逻辑需要一起跟着变动。

在事务脚本式的架构下,一般做第一个需求都非常的快,但是做第N个需求时需要的时间很有可能是呈指数级上升的,绝大部分时间花费在老功能的重构和兼容上,最终你的创新速度会跌为0,促使老应用被推翻重构。

问题3-可测试性差

1)设施搭建困难:代码中强依赖了数据库、第三方服务、中间件等外部依赖,早期跑通测试用例极其困难,后期也会由于各种系统的不稳定性而导致测试无法通过;

2)运行耗时长;

3)耦合度高:多个子步骤耦合度高时,为了完整覆盖所有用例,用例呈指数级增长。

 

  • 重构方案

原始架构图

业务层对于下层基础设施有强依赖,耦合度高

 

步骤1-抽象数据存储层

将DataAccess层做抽象,降低系统对数据库的直接依赖:

1)新建Account实体对象:一个实体Entity是拥有ID的域对象,拥有数据和行为;

2)新建对象存储接口类AccountRepository:Repository只负责对Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层数据库连接可以通过不同的实现类替换。

Account实体类:

@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) {
        // 转入
    }
}

AccountRepository及MyBatis实现类:

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

}

Entity实体类:Account是基于领域逻辑的实体类,他的字段和数据库存储不需要有必然的联系,应该尽可能用Domain Primitive代替,可以避免大量的校验代码。

通过Account对象,避免了其他业务逻辑代码和数据库直接耦合。

Repository类:Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如通过save保存一个Entity对象,但至于具体是insert还是update并不关心。

通过Repository,让业务逻辑不在面向数据库编程,而是面向领域型编程。

步骤1改造后的架构图:

 

步骤2-抽象第三方服务

类似对于数据库的抽象,所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。

在这个例子里我们抽象出 ExchangeRateService 的服务,和一个ExchangeRate的Domain Primitive类:

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

这种常见的设计模式叫做Anti-Corruption Layer(防腐层或ACL)

ACL提供的功能:

1)适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据装换逻辑风状态ACL内部,降低了对业务代码的入侵;

2)缓存:对于频繁调用且数据变更不频繁的外部依赖,通过ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求与压力;

3)兜底:可以将复杂的兜底逻辑在ACL中集中维护,比如当外部逻辑出现问题,可以返回最近一次成功的缓存和业务兜底数据;

4)易于测试;

5)功能开关。

步骤2改造后的架构图:

 

步骤3-抽象中间件

对各种中间件的抽象的目的是让业务代码不在依赖中间件的实现逻辑。

在这个案例里,我们通过封装一个抽象的AuditMessageProducer和AuditMessage DP对象,实现对底层kafka实现的隔离:

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

步骤3改造后的架构图:

 

步骤4-封装业务逻辑

在这个案例里,有很多业务逻辑是跟外部依赖的代码混合的,包括金额计算、账户余额的校验、转账限制、金额增减等。这种逻辑混淆导致了核心计算逻辑无法被有效的测试和复用。在这里,我们的解法是通过Entity、Domain Primitive和Domain Service封装所有的业务逻辑:

1)用Domain Primitive封装跟实体无关的无状态计算逻辑

在这个案例里使用ExchangeRate来封装汇率计算逻辑:

BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
    exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

变为:

ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);

2)用Entity封装单对象的有状态的行为,包括业务校验

用Account实体类封装所有Account的行为,包括业务校验如下:

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

3)用Domain Service封装多对象逻辑

使用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);

步骤4改造后的架构图:

 

重构后的结果:

代码如下:

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

重构后的代码有以下特征:

1)业务逻辑清晰,数据存储和业务逻辑完全分隔;

2)Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试;

3)原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。

 

重新编排后的架构图:

通过外部依赖的抽象和内部逻辑的封装重构,整体依赖关系改变了:

1)最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作;

2)再其次负责组件编排的Application Service,仅仅依赖了一些抽象出来的ACL类和Repository类,其具体实现是通过依赖注入进来的。应用层依赖领域层,但不依赖具体实现;

3)最后是ACL,Repository等的具体实现,这些通常依赖外部具体的技术实现和框架,统称为Infrustructure Layer(基础设置层)。这里Controller层我认为也可单独抽成一层。

 

应用的代码组织结构图(参考)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值