阿里技术专家详解DDD系列 第二弹 - 应用架构

image.png

做者|殷浩
出品|阿里巴巴新零售淘系技术部数据库

架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构。而今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通讯方式。在应用开发中架构之因此是最重要的第一步,由于一个好的架构能让系统安全、稳定、快速迭代。在一个团队内经过规定一个固定的架构设计,可让团队内能力良莠不齐的同窗们都能有一个统一的开发规范,下降沟通成本,提高效率和代码质量。编程

在作架构设计时,一个好的架构应该须要实现如下几个目标:设计模式

  • 独立于框架:架构不该该依赖某个外部的库或框架,不该该被框架的结构所束缚。
  • 独立于UI:前台展现的样式可能会随时发生变化(今天多是网页、明天可能变成console、后天是独立app),可是底层架构不该该随之而变化。
  • 独立于底层数据源:不管今天你用MySQL、Oracle仍是MongoDB、CouchDB,甚至使用文件系统,软件架构不该该由于不一样的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖:不管外部依赖如何变动、升级,业务的核心逻辑不该该随之而大幅变化。
  • 可测试:不管外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都可以快速被验证正确性。

这就好像是建筑中的楼宇,一个好的楼宇,不管内部承载了什么人、有什么样的活动、仍是外部有什么风雨,一栋楼都应该屹立不倒,并且能够确保它不会倒。可是今天咱们在作业务研发时,更多的会去关注一些宏观的架构,好比SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易致使代码逻辑混乱,很难维护,容易产生bug并且很难发现。今天,我但愿可以经过案例的分析和重构,来推演出一套高质量的DDD架构。跨域

一、案例分析

咱们先看一个简单的案例需求以下:缓存

用户能够经过银行网页转帐给另外一个帐号,支持跨币种转帐。安全

同时由于监管和对帐需求,须要记录本次转帐活动。网络

拿到这个需求以后,一个开发可能会经历一些技术选型,最终可能拆解需求以下:session

一、从MySql数据库中找到转出和转入的帐户,选择用 MyBatis 的 mapper 实现 DAO;二、从 Yahoo(或其余渠道)提供的汇率服务获取转帐的汇率信息(底层是 http 开放接口);数据结构

三、计算须要转出的金额,确保帐户有足够余额,而且没超出每日转帐上限;架构

四、实现转入和转出操做,扣除手续费,保存数据库;

五、发送 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);
    }

}

咱们能够看到,一段业务代码里常常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在这个案例里虽然是写在了同一个方法里,在真实代码中常常会被拆分红多个子方法,但实际效果是同样的,而在咱们平常的工做中,绝大部分代码都或多或少的接近于此类结构。在Martin Fowler的 P of EAA书中,这种很常见的代码样式被叫作Transaction Script(事务脚本)。虽然这种相似于脚本的写法在功能上没有什么问题,可是长久来看,他有如下几个很大的问题:可维护性差、可扩展性差、可测试性差。

问题1-可维护性能差

一个应用最大的成本通常都不是来自于开发阶段,而是应用整个生命周期的总维护成本,因此代码的可维护性表明了最终成本。

**可维护性 = 当依赖变化时,有多少代码须要随之改变
**
参考以上的案例代码,事务脚本类的代码很难维护由于如下几点:

  • 数据结构的不稳定性:AccountDO类是一个纯数据结构,映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,好比数据库要作Sharding,或者换一个表设计,或者改变字段名。
  • 依赖库的升级:AccountMapper依赖MyBatis的实现,若是MyBatis将来升级版本,可能会形成用法的不一样(能够参考iBatis升级到基于注解的MyBatis的迁移成本)。一样的,若是将来换一个ORM体系,迁移成本也是巨大的。
  • 第三方服务依赖的不肯定性:第三方服务,好比Yahoo的汇率服务将来颇有可能会有变化:轻则API签名变化,重则服务不可用须要寻找其余可替代的服务。在这些状况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都须要随之改变。
  • 第三方服务API的接口变化:YahooForexService.getExchangeRate返回的结果是小数点仍是百分比?入参是(source, target)仍是(target, source)?谁能保证将来接口不会改变?若是改变了,核心的金额计算逻辑必须跟着改,不然会形成资损。
  • 中间件更换:今天咱们用Kafka发消息,明天若是要上阿里云用RocketMQ该怎么办?后天若是消息的序列化方式从String改成Binary该怎么办?若是须要消息分片该怎么改?

咱们发现案例里的代码对于任何外部依赖的改变都会有比较大的影响。若是你的应用里有大量的此类代码,你每一天的时间基本上会被各类库升级、依赖服务升级、中间件升级、jar包冲突占满,最终这个应用变成了一个不敢升级、不敢部署、不敢写新功能、而且随时会爆发的炸弹,终有一天会给你带来惊喜。

问题2-可拓展性差

事务脚本式代码的第二大缺陷是:虽然写单个用例的代码很是高效简单,可是当用例多起来时,其扩展性会变得愈来愈差。

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

参考以上的代码,若是今天须要增长一个跨行转帐的能力,你会发现基本上须要从新开发,基本上没有任何的可复用性:

  • 数据来源被固定、数据格式不兼容:原有的AccountDO是从本地获取的,而跨行转帐的数据可能须要从一个第三方服务获取,而服务之间数据格式不太多是兼容的,致使从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写。
  • 业务逻辑没法复用:数据格式不兼容的问题会致使核心业务逻辑没法复用。每一个用例都是特殊逻辑的后果是最终会形成大量的if-else语句,而这种分支多的逻辑会让分析代码很是困难,容易错过边界状况,形成bug。
  • 逻辑和数据存储的相互依赖:当业务逻辑增长变得愈来愈复杂时,新加入的逻辑颇有可能须要对数据库schema或消息格式作变动。而变动了数据格式后会致使原有的其余逻辑须要一块儿跟着动。在最极端的场景下,一个新功能的增长会致使全部原有功能的重构,成本巨大。

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

问题3-可测试性能差

除了部分工具类、框架类和中间件类的代码有比较高的测试覆盖以外,咱们在平常工做中很难看到业务代码有比较好的测试覆盖,而绝大部分的上线前的测试属于人肉的“集成测试”。低测试率致使咱们对代码质量很难有把控,容易错过边界条件,异常case只有线上爆发了才被动发现。而低测试覆盖率的主要缘由是业务代码的可测试性比较差。

可测试性 = 运行每一个测试用例所花费的时间 * 每一个需求所须要增长的测试用例数量

参考以上的一段代码,这种代码有极低的可测试性:

  • 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖以后,想要完整跑通一个测试用例须要确保全部依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会因为各类系统的不稳定性而致使测试没法经过。
  • 运行耗时长:大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时须要耗时好久。另外一个常常依赖的是笨重的框架如Spring,启动Spring容器一般须要好久。当一个测试用例须要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
  • 耦合度高:假如一段脚本中有A、B、C三个子步骤,而每一个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖全部用例,最多须要有N N个测试用例。当耦合的子步骤越多时,须要的测试用例呈指数级增加。

在事务脚本模式下,当测试用例复杂度远大于真实代码复杂度,当运行测试用例的耗时超出人肉测试时,绝大部分人会选择不写完整的测试覆盖,而这种状况一般就是bug很难被早点发现的缘由。

总结分析

咱们从新来分析一下为何以上的问题会出现?由于以上的代码违背了至少如下几个软件设计的原则:

  • 单一性原则(Single Responsibility Principle):单一性原则要求一个对象/类应该只有一个变动的缘由。可是在这个案例里,代码可能会由于任意一个外部依赖或计算逻辑的改变而改变。
  • 依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现,好比YahooForexService虽然是一个接口类,可是它对应的是依赖了Yahoo提供的具体服务,因此也算是依赖了实现。一样的KafkaTemplate、MyBatis的DAO实现都属于具体实现。
  • 开放封闭原则(Open Closed Principle):开放封闭原则指开放扩展,可是封闭修改。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该须要被包装成为不可修改的计算类,新功能经过计算类的拓展实现。

咱们须要对代码重构才能解决这些问题。

二、重构方案

在重构以前,咱们先画一张流程图,描述当前代码在作的每一个步骤:

image.png

这是一个传统的三层分层结构:UI层、业务层、和基础设施层。上层对于下层有直接的依赖关系,致使耦合度太高。在业务层中对于下层的基础设施有强依赖,耦合度高。咱们须要对这张图上的每一个节点作抽象和整理,来下降对外部依赖的耦合度。

2.1 - 抽象数据存储层

第一步常见的操做是将Data Access层作抽象,下降系统对数据库的直接依赖。具体的方法以下:

  • 新建Account实体对象:一个实体(Entity)是拥有ID的域对象,除了拥有数据以外,同时拥有行为。Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
  • 新建对象储存接口类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);
    }

}

Account实体类和AccountDO数据类的对好比下:

  • Data Object数据类:AccountDO是单纯的和数据库表的映射关系,每一个字段对应数据库表的一个column,这种对象叫Data Object。DO只有数据,没有行为。AccountDO的做用是对数据库作快速映射,避免直接在代码里写SQL。不管你用的是MyBatis仍是Hibernate这种ORM,从数据库来的都应该先直接映射到DO上,可是代码里应该彻底避免直接操做 DO。
  • Entity实体类:Account 是基于领域逻辑的实体类,它的字段和数据库储存不须要有必然的联系。Entity包含数据,同时也应该包含行为。在 Account 里,字段也不只仅是String等基础类型,而应该尽量用上一讲的 Domain Primitive 代替,能够避免大量的校验代码。

DAO 和 Repository 类的对好比下:

  • DAO对应的是一个特定的数据库类型的操做,至关于SQL的封装。全部操做的对象都是DO类,全部接口均可以根据数据库实现的不一样而改变。好比,insert 和 update 属于数据库专属的操做。
  • Repository对应的是Entity对象读取储存的抽象,在接口层面作统一,不关注底层实现。好比,经过 save 保存一个Entity对象,但至于具体是 insert 仍是 update 并不关心。Repository的具体实现类经过调用DAO来实现各类操做,经过Builder/Factory对象实现AccountDO 到 Account之间的转化

2.1.1 Repository和Entity

  • 经过Account对象,避免了其余业务逻辑代码和数据库的直接耦合,避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
  • 经过Repository,改变业务代码的思惟方式,让业务逻辑再也不面向数据库编程,而是面向领域模型编程。
  • Account属于一个完整的内存中对象,能够比较容易的作完整的测试覆盖,包含其行为。
  • Repository做为一个接口类,能够比较容易的实现Mock或Stub,能够很容易测试。
  • AccountRepositoryImpl实现类,因为其职责被单一出来,只须要关注Account到AccountDO的映射关系和Repository方法到DAO方法之间的映射关系,相对于来讲更容易测试。

image.png

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

2.2.1 防腐层(ACL)

这种常见的设计模式叫作Anti-Corruption Layer(防腐层或ACL)。不少时候咱们的系统会去依赖其余的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,若是对外部系统强依赖,会致使咱们的系统被”腐蚀“。这个时候,经过在系统间加入一个防腐层,可以有效的隔离外部依赖和内部逻辑,不管外部如何变动,内部代码能够尽量的保持不变。

image.png

ACL 不只仅只是多了一层调用,在实际开发中ACL可以提供更多强大的功能:

  • 适配器:不少时候外部依赖的数据、接口和协议并不符合内部规范,经过适配器模式,能够将数据转化逻辑封装到ACL内部,下降对业务代码的侵入。在这个案例里,咱们经过封装了ExchangeRate和Currency对象,转化了对方的入参和出参,让入参出参更符合咱们的标准。
  • 缓存:对于频繁调用且数据变动不频繁的外部依赖,经过在ACL里嵌入缓存逻辑,可以有效的下降对于外部依赖的请求压力。同时,不少时候缓存逻辑是写在业务代码里的,经过将缓存逻辑嵌入ACL,可以下降业务代码的复杂度。
  • 兜底:若是外部依赖的稳定性较差,一个可以有效提高咱们系统稳定性的策略是经过ACL起到兜底的做用,好比当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑通常都比较复杂,若是散落在核心业务代码中会很难维护,经过集中在ACL中,更加容易被测试和修改。
  • 易于测试:相似于以前的Repository,ACL的接口类可以很容易的实现Mock或Stub,以便于单元测试。
  • 功能开关:有些时候咱们但愿能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,咱们能够在ACL配置功能开关来实现,而不会对真实业务代码形成影响。同时,使用功能开关也能让咱们容易的实现Monkey测试,而不须要真正物理性的关闭外部依赖。

image.png

2.3 - 抽象中间件

相似于2.2的第三方服务的抽象,对各类中间件的抽象的目的是让业务代码再也不依赖中间件的实现逻辑。由于中间件一般须要有通用型,中间件的接口一般是String或Byte[] 类型的,致使序列化/反序列化逻辑一般和业务逻辑混杂在一块儿,形成胶水代码。经过中间件的ACL抽象,减小重复胶水代码。

在这个案例里,咱们经过封装一个抽象的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();
    }
}

具体的分析和2.2相似,在此略过。

image.png

2.4 - 封装业务逻辑

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

2.4.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.4.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);

2.4.3 - 用Domain Service封装多对象逻辑

在这个案例里,咱们发现这两个帐号的转出和转入其实是一体的,也就是说这种行为应该被封装到一个对象中去。特别是考虑到将来这个逻辑可能会产生变化:好比增长一个扣手续费的逻辑。这个时候在原有的TransferService中作并不合适,在任何一个Entity或者Domain Primitive里也不合适,须要有一个新的类去包含跨域对象的行为。这种对象叫作Domain Service。

咱们建立一个AccountTransferService的类:

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

image.png

2.5 - 重构后结果分析

这个案例重构后的代码以下:

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

能够看出来,通过重构后的代码有如下几个特征:

  • 业务逻辑清晰,数据存储和业务逻辑彻底分隔。
  • Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,可是却包含了全部核心业务逻辑,能够单独完整测试。
  • 原有的TransferService再也不包括任何计算逻辑,仅仅做为组件编排,全部逻辑均delegate到其余组件。这种仅包含Orchestration(编排)的服务叫作Application Service(应用服务)。

咱们能够根据新的结构从新画一张图:

image.png

而后经过从新编排后该图变为:

image.png

咱们能够发现,经过对外部依赖的抽象和内部逻辑的封装重构,应用总体的依赖关系变了:

  • 最底层再也不是数据库,而是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不是一个特殊的架构设计,而是全部Transction Script代码通过合理重构后必定会抵达的终点。

三、DDD的六边形架构

在咱们传统的代码里,咱们通常都很注重每一个外部依赖的实现细节和规范,可是今天咱们须要勇于抛弃掉原有的理念,从新审视代码结构。在上面重构的代码里,若是抛弃掉全部Repository、ACL、Producer等的具体实现细节,咱们会发现每个对外部的抽象类其实就是输入或输出,相似于计算机系统中的I/O节点。这个观点在CQRS架构中也一样适用,将全部接口分为Command(输入)和Query(输出)两种。除了I/O以外其余的内部逻辑,就是应用业务的核心逻辑。基于这个基础,Alistair Cockburn在2005年提出了Hexagonal Architecture(六边形架构),又被称之为Ports and Adapters(端口和适配器架构)。

image.png

在这张图中:

  • I/O的具体实如今模型的最外层
  • 每一个I/O的适配器在灰色地带
  • 每一个Hex的边是一个端口
  • Hex的中央是应用的核心领域模型

在Hex中,架构的组织关系第一次变成了一个二维的内外关系,而不是传统一维的上下关系。同时在Hex架构中咱们第一次发现UI层、DB层、和各类中间件层其实是没有本质上区别的,都只是数据的输入和输出,而不是在传统架构中的最上层和最下层。

除了2005年的Hex架构,2008年 Jeffery Palermo的Onion Architecture(洋葱架构)和2017年 Robert Martin的Clean Architecture(干净架构),都是极为相似的思想。除了命名不同、切入点不同以外,其余的总体架构都是基于一个二维的内外关系。这也说明了基于DDD的架构最终的形态都是相似的。Herberto Graca有一个很全面的图包含了绝大部分现实中的端口类,值得借鉴。

image.png

3.1 - 代码组织结构

为了有效的组织代码结构,避免下层代码依赖到上层实现的状况,在Java中咱们能够经过POM Module和POM依赖来处理相互的关系。经过Spring/SpringBoot的容器来解决运行时动态注入具体实现的依赖的问题。一个简单的依赖关系图以下:

image.png
image.png

3.1.1 - Types 模块

Types模块是保存能够对外暴露的Domain Primitives的地方。Domain Primitives由于是无状态的逻辑,能够对外暴露,因此常常被包含在对外的API接口中,须要单独成为模块。Types模块不依赖任何类库,纯 POJO 。

image.png

3.1.2 - Domain 模块

Domain 模块是核心业务逻辑的集中地,包含有状态的Entity、领域服务Domain Service、以及各类外部依赖的接口类(如Repository、ACL、中间件等。Domain模块仅依赖Types模块,也是纯 POJO 。

image.png

3.1.3 - Application模块

Application模块主要包含Application Service和一些相关的类。Application模块依赖Domain模块。仍是不依赖任何框架,纯POJO。

image.png

3.1.4 - Infrastructure模块

Infrastructure模块包含了Persistence、Messaging、External等模块。好比:Persistence模块包含数据库DAO的实现,包含Data Object、ORM Mapper、Entity到DO的转化类等。Persistence模块要依赖具体的ORM类库,好比MyBatis。若是须要用Spring-Mybatis提供的注解方案,则须要依赖Spring。

image.png

3.1.5 - Web模块

Web模块包含Controller等相关代码。若是用SpringMVC则须要依赖Spring。

image.png

3.1.6 - Start模块

Start模块是SpringBoot的启动类。

3.2 - 测试

  • Types,Domain模块都属于无外部依赖的纯POJO,基本上均可以100%的被单元测试覆盖。
  • Application模块的代码依赖外部抽象类,须要经过测试框架去Mock全部外部依赖,但仍然能够100%被单元测试。
  • Infrastructure的每一个模块的代码相对独立,接口数量比较少,相对比较容易写单测。可是因为依赖了外部I/O,速度上不可能很快,但好在模块的变更不会很频繁,属于一劳永逸。
  • Web模块有两种测试方法:经过Spring的MockMVC测试,或者经过HttpClient调用接口测试。可是在测试时最好把Controller依赖的服务类都Mock掉。通常来讲当你把Controller的逻辑都后置到Application Service中时,Controller的逻辑变得极为简单,很容易100%覆盖。
  • Start模块:一般应用的集成测试写在start里。当其余模块的单元测试都能100%覆盖后,集成测试用来验证总体链路的真实性。

3.3 - 代码的演进/变化速度

在传统架构中,代码从上到下的变化速度基本上是一致的,改个需求须要从接口、到业务逻辑、到数据库全量变动,而第三方变动可能会致使整个代码的重写。可是在DDD中不一样模块的代码的演进速度是不同的:

  • Domain层属于核心业务逻辑,属于常常被修改的地方。好比:原来不须要扣手续费,如今须要了之类的。经过Entity可以解决基于单个对象的逻辑变动,经过Domain Service解决多个对象间的业务逻辑变动。
  • Application层属于Use Case(业务用例)。业务用例通常都是描述比较大方向的需求,接口相对稳定,特别是对外的接口通常不会频繁变动。添加业务用例能够经过新增Application Service或者新增接口实现功能的扩展。
  • Infrastructure层属于最低频变动的。通常这个层的模块只有在外部依赖变动了以后才会跟着升级,而外部依赖的变动频率通常远低于业务逻辑的变动频率。

因此在DDD架构中,能明显看出越外层的代码越稳定,越内层的代码演进越快,真正体现了领域“驱动”的核心思想。

四、总结

DDD不是一个什么特殊的架构,而是任何传统代码通过合理的重构以后最终必定会抵达的终点。DDD的架构可以有效的解决传统架构中的问题:

  • 高可维护性:当外部依赖变动时,内部代码只用变动跟外部对接的模块,其余业务逻辑不变。
  • 高可扩展性:作新功能时,绝大部分的代码都能复用,仅须要增长核心业务逻辑便可。
  • 高可测试性:每一个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,能够快速的单元测试,作到100%覆盖。
  • 代码结构清晰:经过POM module能够解决模块间的依赖关系, 全部外接模块均可以单独独立成Jar包被复用。当团队造成规范后,能够快速的定位到相关代码。


阅读原文

本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索