阿里大佬:DDD中Interface层、Application层的设计规范

说在前面

在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:

谈谈你的DDD落地经验?

谈谈你对DDD的理解?

如何保证RPC代码不会腐烂,升级能力强?

最近有小伙伴在字节,又遇到了相关的面试题。小伙伴懵了, 他从来没有用过DDD,面挂了。关于DDD,尼恩之前给大家梳理过一篇很全的文章: 阿里一面:谈一下你对DDD的理解?2W字,帮你实现DDD自由

但是尼恩的文章, 太过理论化,不适合刚入门的人员。所以,尼恩也在不断的为大家找更好的学习资料。

前段时间,尼恩在阿里的技术公众号上看到了一篇文章《殷浩详解DDD:领域层设计规范》 作者是阿里 技术大佬殷浩,非常适合于初学者入门,同时也足够的有深度。

美中不足的是, 殷浩那篇文章的行文风格,对初学者不太友好, 尼恩刚开始看的时候,也比较晦涩。

于是,尼恩在读的过程中,把那些晦涩的内容,给大家用尼恩的语言, 浅化了一下, 这样大家更容易懂。

本着技术学习、技术交流的目的,这里,把尼恩修改过的 《殷浩详解DDD:领域层设计规范》,通过尼恩的公众号《技术自由圈》发布出来。

特别声明,由于没有殷浩同学的联系方式,这里没有找殷浩的授权,

如果殷浩同学或者阿里技术公众号不同意我的修改,不同意我的发布, 我即刻从《技术自由圈》公众号扯下来。

另外, 文章也特别长, 我也特别准备了PDF版本。如果需要尼恩修改过的PDF版本,也可以通过《技术自由圈》公众号找到尼恩来获取。

文章目录

    • 说在前面
    • 尼恩总结:DDD的本质和最终收益
    • 本文说明:
    • 第6篇 - Interface层、Application层的设计规范
      • 1. 传统的四层架构模式
      • 2. 下单链路案例简介
      • 3. Interface接口层
        • 3.1 接口层的组成
        • 3.2 返回值和异常处理规范,Result vs Exception
        • 3.3 接口层的接口的数量和业务间的隔离
      • 4. Application层
        • 4.1 Application层的组成部分
        • 4.2 使用Command、Query、Event对象语意化我们的参数
          • 为什么要用CQE对象?
          • CQE的规范
          • CQE的校验
        • 4.3 ApplicationService 流程编排
          • 要点1:ApplicationService 负责了业务流程的编排,
          • 要点2:Application Service 是业务流程的封装,不处理业务逻辑
          • 判断是否业务流程的几个点
          • 常用的ApplicationService“套路”
        • 4.4 DTO Assembler
        • 4.5 Result vs Exception
        • 4.6 Anti-Corruption Layer防腐层
      • 5. Orchestration编排 vs Choreography协作
        • 5.1 模式简介
        • 5.2 案例
        • 5.3 模式的区别和选择
        • 5.4 跟DDD分层架构的关系
      • 总结
    • 未完待续,尼恩说在最后
    • 尼恩技术圣经系列PDF

尼恩总结:DDD的本质和最终收益

在正式开始第6篇之前,尼恩说一下自己对DDD的 亲身体验、和深入思考。

DDD的本质:

  • 大大提升 核心代码 业务纯度

    老的mvc架构,代码中紧紧的耦合着特定ORM框架、特定DB存储、特定的缓存、特定的事务框架、特定中间件,特定对外依赖解耦, 很多很多。

    总之就是 业务和技术紧密耦合,代码的 业务纯度低, 导致软件“固化”, 没法做快速扩展和升级。

  • 大大提升 代码工程 测维扩 能力

    DDD进行了多个层次的解耦,包括 持久层的DB解耦,第三方依赖的隔离解耦,大大提升了 可测试度、可维护度、可扩展度

  • 更大限度 积累 业务领域模型 资产

    由于spring mvc 模式下, 代码的业务纯度不高, 导致尼恩的曾经一个项目,10年多时间, 衍生出 50多个不同的版本,推导重来5次,付出巨大的 时间成本、经济成本

DDD的收益:

  • 极大的降低升级的工作量
  • 极大的降低推到重来的风险
  • 极大提升代码的核心代码业务纯度,积累更多的代码资产

不用DDD的反面案例,尼恩曾经见过一个项目:

  • 10年多时间, 衍生出 50多个不同的版本, 每个版本80%的功能相同,但是代码各种冲突,没有合并
  • 10年多时间,经历过至少 5次推倒重来, 基本换一个领导,恨不得推导重来一次, 感觉老的版本都是不行,只有自己设计的才好
  • 5次推倒重来,每次都是 风风火火/加班到进ICU, 投入了大量的人力/财力。其实大多是重复投入、重复建设
  • 可谓, 一将不才累死三军, 所以, 一个优秀的架构师,对一个项目来说是多么的重要

本文说明:

本文是 《阿里DDD大佬:从0到1,带大家精通DDD》系列的第6篇

本文是 《从0到1,带大家精通DDD》系列的的链接地址是:

《阿里DDD大佬:从0到1,带大家精通DDD》

《阿里大佬:DDD 落地两大步骤,以及Repository核心模式》

《阿里面试:让代码不腐烂,DDD是怎么做的?》

《阿里大佬:DDD 领域层,该如何设计?》

《极兔面试:微服务爆炸,如何解决?Uber 是怎么解决2200个微服务爆炸的?》

大家可以先看前面的文章,再来看本篇,效果更佳。

另外,尼恩会结合一个工业级的DDD实操项目,在第34章视频《DDD的顶奢面经》中,给大家彻底介绍一下DDD的实操、COLA 框架、DDD的面试题。

第6篇 - Interface层、Application层的设计规范

在日常工作中我观察到,面对老系统重构和迁移场景,有大量代码属于流水账代码,

通常能看到开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。

本文主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码,改造为逻辑清晰、职责分明的模块

领域驱动设计没有特定的架构风格,它的核心是域模型驱动业务的思想,常见的领域驱动设计架构有传统的四层架构模式、事件驱动架构、CQRS架构、六边形架构等。

1. 传统的四层架构模式

  • User Interface为用户界面层(对外访问层API),负责向用户显示信息和解释用户命令。
  • Application为应用层,定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。
  • Domain为领域层(或模型层),负责表达业务概念,业务状态信息以及业务规则。
  • Infrastructure层为基础实施层,向其他层提供通用的技术能力。
    DDD四层架构图
  • 基础设施层:基本上都是需要固化的代码,一次写成,需要变动的次数很少,一旦变动,就需要大量谨慎的回归测试。将所有的存储调用、中间件调用都沉淀在这一层中。

2. 下单链路案例简介

这里举一个简单的常见案例:下单链路。

假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:

一个比较典型的代码如下:

@RestController
@RequestMapping("/")
public class CheckoutController {

    @Resource
    private ItemService itemService;

    @Resource
    private InventoryService inventoryService;

    @Resource
    private OrderRepository orderRepository;

    @PostMapping("checkout")
    public Result<OrderDO> checkout(Long itemId, Integer quantity) {
        // 1) Session管理
        Long userId = SessionUtils.getLoggedInUserId();
        if (userId <= 0) {
            return Result.fail("Not Logged In");
        }
        
        // 2)参数校验
        if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
            return Result.fail("Invalid Args");
        }

        // 3)外部数据补全
        ItemDO item = itemService.getItem(itemId);
        if (item == null) {
            return Result.fail("Item Not Found");
        }

        // 4)调用外部服务
        boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
        if (!withholdSuccess) {
            return Result.fail("Inventory not enough");
        }
      
        // 5)领域计算
        Long cost = item.getPriceInCents() * quantity;

        // 6)领域对象操作
        OrderDO order = new OrderDO();
        order.setItemId(itemId);
        order.setBuyerId(userId);
        order.setSellerId(item.getSellerId());
        order.setCount(quantity);
        order.setTotalCost(cost);

        // 7)数据持久化
        orderRepository.createOrder(order);

        // 8)返回
        return Result.success(order);
    }
}

为什么这种典型的流水账代码在实际应用中会有问题呢?

其本质问题是违背了SRP(Single Responsbility Principle)单一职责原则。

这段代码里混杂了业务计算、校验逻辑、基础设施、和通信协议等,在未来无论哪一部分的逻辑变更都会直接影响到这段代码,当后人不断地在上面叠加新的逻辑时,会使代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构的历史包袱。

所以我们才需要用DDD的分层思想去重构一下以上的代码,通过不同的代码分层和规范,拆分出逻辑清晰,职责明确的分层和模块,也便于一些通用能力的沉淀。

主要的几个步骤分为:

  • 分离出独立的Interface接口层,负责处理网络协议相关的逻辑。
  • 从真实业务场景中,找出具体用例(Use Cases),然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接。
  • 分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点。
  • 处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等。

下面会针对每个点做详细的解释。

3. Interface接口层

随着REST和MVC架构的普及,经常能看到开发同学直接在Controller中写业务逻辑,如上面的典型案例,但实际上MVC Controller不是唯一的重灾区。

以下的几种常见的代码写法通常都可能包含了同样的问题:

  • HTTP 框架:如Spring MVC框架,Spring Cloud等。
  • RPC 框架:如Dubbo、HSF、gRPC等。
  • 消息队列MQ的“消费者”:比如JMS的 onMessage,RocketMQ的MessageListener等。
  • Socket通信:Socket通信的receive、WebSocket的onMessage等。
  • 文件系统:WatcherService等。
  • 分布式任务调度:SchedulerX等。

这些的方法都有一个共同的点就是都有自己的网络协议,而如果我们的业务代码和网络协议混杂在一起,则会直接导致代码跟网络协议绑定,无法被复用。

所以,在DDD的分层架构中,我们单独会抽取出来Interface接口层,作为所有对外的门户,将网络协议和业务逻辑解耦。

3.1 接口层的组成

接口层主要由以下几个功能组成:

  • 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的bean,要么是继承了某个接口的bean。
  • 统一鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
  • Session管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务。
  • 限流配置:对接口做限流避免大流量打到下游服务
  • 前置缓存:针对变更不是很频繁的只读场景,可以前置结果缓存到接口层
  • 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式
  • 日志:在接口层打调用日志,用来做统计和debug等。一般微服务框架可能都直接包含了这些功能。

当然,如果有一个独立的网关设施/应用,则可以抽离出鉴权、Session、限流、日志等逻辑,但是目前来看API网关也只能解决一部分的功能,即使在有API网关的场景下,应用里独立的接口层还是有必要的。

在Interface层,鉴权、Session、限流、缓存、日志等都比较直接,只有一个异常处理的点需要重点说下。

3.2 返回值和异常处理规范,Result vs Exception

注:这部分主要还是面向REST和RPC接口,其他的协议需要根据协议的规范产生返回值。

在我见过的一些代码里,接口的返回值比较多样化,有些直接返回DTO甚至DO,另一些返回Result。

接口层的核心价值是对外,所以如果只是返回DTO或DO会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。

所以,这里提出一个规范:

  • Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常
  • Application层的所有接口返回值为DTO,不负责处理异常

Application层的具体规范等下再讲,在这里先展示Interface层的逻辑。

举个例子:

@PostMapping("checkout")
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
    try {
        CheckoutCommand cmd = new CheckoutCommand();
        OrderDTO orderDTO = checkoutService.checkout(cmd);    
        return Result.success(orderDTO);
    } catch (ConstraintViolationException cve) {
        // 捕捉一些特殊异常,比如Validation异常
        return Result.fail(cve.getMessage());
    } catch (Exception e) {
        // 兜底异常捕获
        return Result.fail(e.getMessage());
    }
}

当然,每个接口都要写异常处理逻辑会比较烦,所以可以用AOP做个注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResultHandler {

}

@Aspect
@Component
public class ResultAspect {
    @Around("@annotation(ResultHandler)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = null;
        try {
            proceed = joinPoint.proceed();
        } catch (ConstraintViolationException cve) {
            return Result.fail(cve.getMessage());
        } catch (Exception e) {
            return Result.fail(e.getMessage());
        }
        return proceed;
    }
}

然后最终代码则简化为:

@PostMapping("checkout")
@ResultHandler
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
    CheckoutCommand cmd = new CheckoutCommand();
    OrderDTO orderDTO = checkoutService.checkout(cmd);
    return Result.success(orderDTO);
}
3.3 接口层的接口的数量和业务间的隔离

在传统REST和RPC的接口规范中,同一个领域的方法放在一个领域的服务或Controller中。

具体来说:通常一个领域的接口,无论是REST的Resource资源的GET/POST/DELETE,还是RPC的方法,是追求相对固定的,统一的,而且会追求同一个领域的方法放在一个领域的服务或Controller中。

但是我发现在实际做业务的过程中,特别是当支撑的上游业务比较多时,刻意去追求接口的统一会有一个严重后果,通常会导致方法中的参数膨胀,或者导致方法的膨胀。

举个例子:假设有一个宠物卡和一个亲子卡的业务公用一个开卡服务,但是宠物需要传入宠物类型,亲子的需要传入宝宝年龄。

// 可以是RPC Provider 或者 Controller
public interface CardService {

    // 1)统一接口,参数膨胀
    Result openCard(int petType, int babyAge);

    // 2)统一泛化接口,参数语意丢失
    Result openCardV2(Map<String, Object> params);

    // 3)不泛化,同一个类里的接口膨胀
    Result openPetCard(int petType);
    Result openBabyCard(int babyAge);
}

可以看出来,无论怎么操作,都有可能导致CardService这个服务未来越来越难以维护,方法越来越多,一个业务的变更有可能会导致整个服务/Controller的变更,最终变得无法维护。

我曾经参与过的一个服务,提供了几十个方法,上万行代码,可想而知,无论是使用方对接口的理解成本还是对代码的维护成本都是极高的。

所以,这里提出另一个规范:

  • 一个Interface层的类应该是“小而美”的,
  • 应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。

基于上面的这个规范,可以发现宠物卡和亲子卡虽然看起来像是类似的需求,但并非是“同样需求”的,可以预见到在未来的某个时刻,这两个业务的需求和需要提供的接口会越走越远,所以需要将这两个接口类拆分开:

public interface PetCardService {
    Result openPetCard(int petType);
}

public interface BabyCardService {
    Result openBabyCard(int babyAge);
}

这个的好处是符合了Single Responsibility Principle单一职责原则,也就是说一个接口类仅仅会因为一个(或一类)业务的变化而变化。一个建议是当一个现有的接口类过度膨胀时,可以考虑对接口类做拆分,拆分原则和SRP(Single Responsibility Principle,简称 SRP)一致。

也许会有人问,如果按照这种做法,会不会产生大量的接口类,导致代码逻辑重复?

答案是不会,因为在DDD分层架构里,接口类的核心作用仅仅是协议层,每类业务的协议可以是不同的,而真实的业务逻辑会沉淀到应用层。

也就是说Interface和Application的关系是多对多的:

因为业务需求是快速变化的,所以接口层也要跟着快速变化,通过独立的接口层可以避免业务间相互影响,但我们希望相对稳定的是Application层的逻辑。所以我们接下来看一下Application层的一些规范。

4. Application层

4.1 Application层的组成部分

Application层的几个核心类:

  • ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑。
  • DTO Assembler:负责将内部领域模型转化为可对外的DTO。
  • Command、Query、Event对象:作为ApplicationService的入参。
  • 返回的DTO:作为ApplicationService的出参。

Application层最核心的对象是ApplicationService,它的核心功能是承接“业务流程“。

但是在讲ApplicationService的规范之前,必须要先重点的讲几个特殊类型的对象,即Command、Query和Event。

4.2 使用Command、Query、Event对象语意化我们的参数

首先,回顾一下基础 的 CQRS 模式

  • CQRS(Command and Query Responsibility Segregation)命令查询职责分离模式,分别对读和写建模。

  • CQRS从定义上要求:

    • 一个方法修改了对象的状态,该方法便是一个Command,它不应该返回数据。
    • 一个方法返回了数据,该方法便是一个Query,此时它不应该通过直接的或间接的手段修改对象的状态。

    CQRS架构图

从本质上来看,Command、Query、Event 对象都是Value Object,但是从语义上来看有比较大的差异:

  • Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。

    通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。

  • Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。

  • Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。

    事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。

简单总结下:

为什么要用CQE对象?

通常在很多代码里,能看到接口上有多个参数,比如上文中的案例:

如果需要在接口上增加参数,考虑到向前兼容,则需要增加一个方法:

或者常见的查询方法,由于条件的不同导致多个方法:

List < OrderDO> queryByItemId(Long itemId);
List < OrderDO> queryBySellerId(Long sellerId);
List < OrderDO> queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);

可以看出来,传统的接口写法有几个问题:

  • 接口膨胀:一个查询条件一个方法。
  • 难以扩展:每新增一个参数都有可能需要调用方升级。
  • 难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护。

但是另外一个最重要的问题是:这种类型的参数罗列,本身没有任何业务上的”语意“,只是一堆参数而已,无法明确的表达出来意图。

CQE的规范

所以在Application层的接口里,强力建议的一个规范是:

  • ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。

  • 唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建。

按照上面的规范,实现案例是:

public interface CheckoutService {
    OrderDTO checkout(@Valid CheckoutCommand cmd);
    List<OrderDTO> query(OrderQuery query);
    OrderDTO getOrder(Long orderId); // 注意单一ID查询可以不用Query
}

@Data
public class CheckoutCommand {
    private Long userId;
    private Long itemId;
    private Integer quantity;
}

@Data
public class OrderQuery {
    private Long sellerId;
    private Long itemId;
    private int currentPage;
    private int pageSize;
}

这个规范的好处是:

  • 提升了接口的稳定性、降低低级的重复,
  • 并且让接口入参更加语意化。

CQE vs DTO

从上面的代码能看出来,ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?

  • CQE:CQE对象是ApplicationService的输入,是有明确的”意图“的,所以这个对象必须保证其”正确性“。
  • DTO:DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。

但可能最重要的一点:

  • 因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;
  • 但是DTO作为模型数据容器,和模型一一对应,所以是有限的。
CQE的校验

CQE作为ApplicationService的输入,必须保证其正确性,那么这个校验是放在哪里呢?

在最早的代码里,曾经有这样的校验逻辑,当时写在了服务里:

if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
    return Result.fail("Invalid Args");
}

这种代码在日常非常常见,但其最大的问题就是,大量的非业务代码混杂在业务代码中

很明显的违背了单一职责原则。

但,因为当时入参仅仅是简单的int,所以这个逻辑只能出现在服务里。

现在当入参改为了CQE之后,我们可以利用java标准JSR303或JSR380的Bean Validation来前置这个校验逻辑。

CQE对象的校验应该前置,避免在ApplicationService里做参数的校验。可以通过JSR303/380和Spring Validation来实现。

前面的例子可以改造为:

@Validated // Spring的注解
public class CheckoutServiceImpl implements CheckoutService {
    OrderDTO checkout(@Valid CheckoutCommand cmd) { // 这里@Valid是JSR-303/380的注解
        // 如果校验失败会抛异常,在interface层被捕捉
    }
}

@Data
public class CheckoutCommand {

    @NotNull(message = "用户未登陆")
    private Long userId;

    @NotNull
    @Positive(message = "需要是合法的itemId")
    private Long itemId;

    @NotNull
    @Min(value = 1, message = "最少1件")
    @Max(value = 1000, message = "最多不能超过1000件")
    private Integer quantity;
}

这种做法的好处是,让ApplicationService更加清爽,同时各种错误信息可以通过Bean Validation的API做各种个性化定制。

避免复用CQE

因为CQE是有“意图”和“语意”的,我们需要尽量避免CQE对象的复用,哪怕所有的参数都一样,只要他们的语意不同,尽量还是要用不同的对象。

规范:针对于不同语意的指令,要避免CQE对象的复用。

反例:一个常见的场景是“Create创建”和“Update更新”,一般来说这两种类型的对象唯一的区别是一个ID,创建没有ID,而更新则有。

所以经常能看见有的同学用同一个对象来作为两个方法的入参,唯一区别是ID是否赋值。

这个是错误的用法,因为这两个操作的语意完全不一样,他们的校验条件可能也完全不一样,所以不应该复用同一个对象。

正确的做法是:产出两个对象:

public interface CheckoutService {
    OrderDTO checkout(@Valid CheckoutCommand cmd);
    OrderDTO updateOrder(@Valid UpdateOrderCommand cmd);
}

@Data
public class UpdateOrderCommand {

    @NotNull(message = "用户未登陆")
    private Long userId;

    @NotNull(message = "必须要有OrderID")
    private Long orderId;

    @NotNull
    @Positive(message = "需要是合法的itemId")
    private Long itemId;

    @NotNull
    @Min(value = 1, message = "最少1件")
    @Max(value = 1000, message = "最多不能超过1000件")
    private Integer quantity;

}
4.3 ApplicationService 流程编排
要点1:ApplicationService 负责了业务流程的编排,

具体来说 ,ApplicationService 是将原有业务流水账代码剥离了校验逻辑、领域计算、持久化等逻辑之后剩余的流程,是“胶水层”代码。

参考一个简易的交易流程:

在这个案例里可以看出来,交易这个领域一共有5个用例:下单、支付成功、支付失败关单、物流信息更新、关闭订单。

这5个用例可以用5个Command/Event对象代替,也就是对应了5个方法。

我见过3种ApplicationService的组织形态:

(1)一个ApplicationService类是一个完整的业务流程,其中每个方法负责处理一个Use Case。

  • 这种的好处是可以完整的收敛整个业务逻辑,从接口类即可对业务逻辑有一定的掌握,适合相对简单的业务流程。
  • 坏处就是对于复杂的业务流程会导致一个类的方法过多,有可能代码量过大。

这种类型的具体案例如:

public interface CheckoutService {

    // 下单
    OrderDTO checkout(@Valid CheckoutCommand cmd);

    // 支付成功
    OrderDTO payReceived(@Valid PaymentReceivedEvent event);

    // 支付取消
    OrderDTO payCanceled(@Valid PaymentCanceledEvent event);

    // 发货
    OrderDTO packageSent(@Valid PackageSentEvent event);

    // 收货
    OrderDTO delivered(@Valid DeliveredEvent event);

    // 批量查询
    List<OrderDTO> query(OrderQuery query);

    // 单个查询
    OrderDTO getOrder(Long orderId);
}

(2)针对于比较复杂的业务流程,可以通过增加独立的CommandHandler、EventHandler来降低一个类中的代码量:

@Component
public class CheckoutCommandHandler implements CommandHandler<CheckoutCommand, OrderDTO> {
    @Override
    public OrderDTO handle(CheckoutCommand cmd) {
        //
    }
}

public class CheckoutServiceImpl implements CheckoutService {
    @Resource
    private CheckoutCommandHandler checkoutCommandHandler;
    
    @Override
    public OrderDTO checkout(@Valid CheckoutCommand cmd) {
        return checkoutCommandHandler.handle(cmd);
    }
}

(3)比较激进一点,通过CommandBus、EventBus,直接将指令或事件抛给对应的Handler,EventBus比较常见。

具体案例代码如下,通过消息队列收到MQ消息后,生成Event,然后由EventBus做路由到对应的Handler:

// 在这里框架通常可以根据接口识别到这个负责处理PaymentReceivedEvent
// 也可以通过增加注解识别
@Component
public class PaymentReceivedHandler implements EventHandler<PaymentReceivedEvent> {
    @Override
    public void process(PaymentReceivedEvent event) {
        //
    }
}

// Interface层,这个是RocketMQ的Listener
public class OrderMessageListener implements MessageListenerOrderly {

    @Resource
    private EventBus eventBus;
    
    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        
        PaymentReceivedEvent event = new PaymentReceivedEvent();
        eventBus.dispatch(event); // 不需要指定消费者
        
        return ConsumeOrderlyStatus.SUCCESS;
    }
}

不建议:这种做法可以实现Interface层和某个具体的ApplicationService或Handler的完全静态解藕,在运行时动态dispatch,做的比较好的框架如AxonFramework。

虽然看起来很便利,但是根据我们自己业务的实践和踩坑发现,当代码中的CQE对象越来越多,handler越来越复杂时,运行时的dispatch缺乏了静态代码间的关联关系导致代码很难读懂,特别是当你需要trace一个复杂调用链路时,因为dispatch是运行时的,很难摸清楚具体调用到的对象。

所以我们虽然曾经有过这种尝试,但现在已经不建议这么做了。

要点2:Application Service 是业务流程的封装,不处理业务逻辑

虽然之前曾经无数次重复ApplicationService只负责业务流程串联,不负责业务逻辑,但如何判断一段代码到底是业务流程还是逻辑呢?

举个之前的例子,最初的代码重构后:

@Service
@Validated
public class CheckoutServiceImpl implements CheckoutService {

    private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;
    @Resource
    private ItemService itemService;
    @Resource
    private InventoryService inventoryService;
    @Resource
    private OrderRepository orderRepository;

    @Override
    public OrderDTO checkout(@Valid CheckoutCommand cmd) {
        ItemDO item = itemService.getItem(cmd.getItemId());
        if (item == null) {
            throw new IllegalArgumentException("Item not found");
        }

        boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
        if (!withholdSuccess) {
            throw new IllegalArgumentException("Inventory not enough");
        }

        Order order = new Order();
        order.setBuyerId(cmd.getUserId());
        order.setSellerId(item.getSellerId());
        order.setItemId(item.getItemId());
        order.setItemTitle(item.getTitle());
        order.setItemUnitPrice(item.getPriceInCents());
        order.setCount(cmd.getQuantity());

        Order savedOrder = orderRepository.save(order);

        return orderDtoAssembler.orderToDTO(savedOrder);
    }
}
判断是否业务流程的几个点

(1)不要有if/else分支逻辑

也就是说代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1。

通常情况下,如果有分支逻辑的,都代表一些业务判断,那么,应该将逻辑封装到DomainService或者Entity里。

但,这不代表Application Service 完全不能有if逻辑,比如,在这段代码里:

boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
    throw new IllegalArgumentException("Inventory not enough");
}

虽然CC > 1,但是仅仅代表了中断条件,具体的业务逻辑处理并没有受影响。可以把它看作为Precondition。

(2)不要有任何计算

在最早的代码里有这个计算:

// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
order.setTotalCost(cost);

通过将这个计算逻辑封装到实体里,避免在ApplicationService里做计算:

@Data
public class Order {

    private Long itemUnitPrice;
    private Integer count;

    // 把原来一个在ApplicationService的计算迁移到Entity里
    public Long getTotalCost() {
        return itemUnitPrice * count;
    }
}

order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());

(3)一些数据的转化可以交给其他对象来做

比如DTO Assembler,将对象间转化的逻辑抽取和剥离在单独的类中,降低ApplicationService的复杂度。

OrderDTO dto = orderDtoAssembler.orderToDTO(savedOrder);
常用的ApplicationService“套路”

我们可以看出来,ApplicationService的代码通常有类似的结构:

  • AppService通常不做任何决策(Precondition除外),
  • 仅仅是把所有决策交给DomainService或Entity,
  • 把跟外部交互的交给Infrastructure接口,如Repository或防腐层。

一般ApplicationService的“套路”如下:

  • 准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
  • 执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
  • 持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。

如果涉及到对多个外部系统(包括自身的DB)都有变更的情况,这个时候通常处在“分布式事务”的场景里,无论是用分布式TX、TCC、还是Saga模式,取决于具体场景的设计,在此处暂时略过。

4.4 DTO Assembler

由于字数限制,此处省略

完整内容,请参见尼恩的《DDD圣经》,pdf 找尼恩获取,方式见文末

4.5 Result vs Exception

由于字数限制,此处省略

完整内容,请参见尼恩的《DDD圣经》,pdf 找尼恩获取,方式见文末

4.6 Anti-Corruption Layer防腐层

由于字数限制,此处省略

完整内容,请参见尼恩的《DDD圣经》,pdf 找尼恩获取,方式见文末

5. Orchestration编排 vs Choreography协作

在复杂的业务流程里,我们通常面临两种模式:Orchestration 和 Choreography。

很无奈,这两个英文单词的百度翻译/谷歌翻译,都是“编排”,但实际上这两种模式是完全不一样的设计模式。

  • Orchestration的编排(比如SOA/微服务的服务编排Service Orchestration)是我们通常熟悉的用法
  • Choreography是最近出现了事件驱动架构EDA才慢慢流行起来。
5.1 模式简介
  • Orchestration:通常出现在脑海里的是一个交响乐团(Orchestra,注意这两个词的相似性)。交响乐团的核心是一个唯一的指挥家Conductor,在一个交响乐中,所有的音乐家必须听从Conductor的指挥做操作,不可以独自发挥。所以在Orchestration模式中,所有的流程都是由一个节点或服务触发的。我们常见的业务流程代码,包括调用外部服务,就是Orchestration,由我们的服务统一触发。
  • Choreography:通常会出现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros)。其中每个不同的舞蹈家都在做自己的事,但是没有一个中心化的指挥。通过协作配合,每个人做好自己的事,整个舞蹈可以展现出一个完整的、和谐的画面。所以在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。
5.2 案例

用一个常见的例子:下单后支付并发货。

如果这个案例是Orchestration编排,则业务逻辑为:下单时从一个预存的账户里扣取资金,并且生成物流单发货,从图上看是这样的:

如果这个案例是Choreography协作,则业务逻辑为:下单,然后等支付成功事件,然后再发货,类似这样:

5.3 模式的区别和选择

由于字数限制,此处省略

完整内容,请参见尼恩的《DDD圣经》,pdf 找尼恩获取,方式见文末

5.4 跟DDD分层架构的关系

最后,讲了这么多O vs C,跟DDD有啥关系?很简单:

  • O&C其实是Interface层的关注点,Orchestration = 对外的API,而Choreography = 消息或事件。当你决策了O还是C之后,需要在Interface层承接这些“驱动力”。
  • 无论O&C如何设计,Application层都“无感知”,因为ApplicationService天生就可以处理Command、Query和Event,至于这些对象怎么来,是Interface层的决策。

所以,虽然Orchestration 和 Choreography是两种完全不同的业务设计模式,但最终落到Application层的代码应该是一致的,这也是为什么Application层是“用例”而不是“接口”,是相对稳定的存在。

总结

只要是做业务的,一定会需要写业务流程和服务编排,但不代表这种代码一定质量差。

通过DDD的分层架构里的Interface层和Application层的合理拆分,代码可以变得优雅、灵活,能更快的响应业务但同时又能更好的沉淀。

本文主要介绍了一些代码的设计规范,帮助大家掌握一定的技巧。

Interface层:

  • 职责:主要负责承接网络协议的转化、Session管理等。
  • 接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参。
  • 接口出参:统一返回Result。
  • 异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。

Application层:

  • 入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
  • CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用。
  • 入参校验:基础校验通过Bean Validation api解决。Spring Validation自带Validation的AOP,也可以自己写AOP。
  • 出参:统一返回DTO,而不是Entity或DO。
  • DTO转化:用DTO Assembler负责Entity/VO到DTO的转化。
  • 异常处理:不统一捕捉异常,可以随意抛异常。

部分Infra层:

  • 用ACL防腐层将外部依赖转化为内部代码,隔离外部的影响。

业务流程设计模式:

  • 没有最好的模式,取决于业务场景、依赖关系、以及是否有业务“负责人”。避免拿着锤子找钉子。

未完待续,尼恩说在最后

DDD 面试题,是非常常见的面试题。 DDD的学习材料, 汗牛塞屋,又缺乏经典。

《殷浩详解DDD:领域层设计规范》做到从0到1带大家精通DDD,非常难得。

这里,把尼恩修改过的 《殷浩详解DDD:领域层设计规范》,通过尼恩的公众号《技术自由圈》发布出来。

大家面试的时候, 可以参考以上的内容去组织答案,如果大家能做到对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。

另外在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,并且在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。

最终,让面试官爱到 “不能自已、口水直流”。offer, 也就来了。

当然,关于DDD,尼恩即将给大家发布一波视频 《第34章:DDD的顶奢面经》。

尼恩技术圣经系列PDF

  • 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
  • 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
  • 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
  • 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
  • 《大数据HBase学习圣经:一本书实现HBase学习自由》
  • 《大数据Flink学习圣经:一本书实现大数据Flink自由》
  • 《响应式圣经:10W字,实现Spring响应式编程自由》
  • 《Go学习圣经:Go语言实现高并发CRUD业务开发》

……完整版尼恩技术圣经PDF集群,请找尼恩领取

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/132934.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C语言 音乐播放器项目(综合)

1.main.c文件 #include<stdio.h> #include<stdlib.h> #include<string.h> #include <unistd.h>//休眠所需的头文件 #include "./pos/console.h"//光标使用所需的头文件 #include "lrc.h" #include "./mplayer/start_mplayer…

2023-11-11 LeetCode每日一题(情侣牵手)

2023-11-11每日一题 一、题目编号 765. 情侣牵手二、题目链接 点击跳转到题目位置 三、题目描述 n 对情侣坐在连续排列的 2n 个座位上&#xff0c;想要牵到对方的手。 人和座位由一个整数数组 row 表示&#xff0c;其中 row[i] 是坐在第 i 个座位上的人的 ID。情侣们按顺…

2023年03月 Python(四级)真题解析#中国电子学会#全国青少年软件编程等级考试

Python等级考试(1~6级)全部真题・点这里 一、单选题(共25题,每题2分,共50分) 第1题 运行下列程序,输出的结果是?( ) def wenhao(name = zhejiang): print(hello + name) wenhao

【JavaEE初阶】 TCP协议详细解析

文章目录 &#x1f332;TCP协议的概念&#x1f6a9;TCP协议段格式&#x1f6a9;TCP的特性 &#x1f333;TCP原理&#x1f6a9;确认应答机制&#xff08;安全机制&#xff09;&#x1f6a9;超时重传机制&#xff08;安全机制&#xff09;&#x1f6a9;三次握手四次挥手&#xff…

软件测试需要学习什么?好学吗?需要学多久?到底是报班好还是自学好?

前言&#xff1a; 我发现很多的小伙伴刚刚毕业和想转行的小伙伴对于软件测试很陌生&#xff0c;其中很有很多的小伙伴还踩不少的坑&#xff0c;花费了大量的精力和时间去探索&#xff0c;结果还是一无所获。这里给大家出一期关于软件测试萌新的疑惑&#xff0c;看完这篇文章你就…

pytorch中对nn.BatchNorm2d()函数的理解

pytorch中对BatchNorm2d函数的理解 简介计算3. Pytorch的nn.BatchNorm2d()函数4 代码示例 简介 机器学习中&#xff0c;进行模型训练之前&#xff0c;需对数据做归一化处理&#xff0c;使其分布一致。在深度神经网络训练过程中&#xff0c;通常一次训练是一个batch&#xff0c…

SPSS:卡方检验(交叉表)

第一步 打开SPSS软件&#xff0c;在工具栏中选中【打开-文件-数据】&#xff0c;然后选择一份要打开的数据表(如图所示)。 第二步 在工具栏中找到【分析-描述统计-交叉表】打开交叉表对话框(如图所示)。 第三步 接着将【行-列】相关变量放在对应对话框中(如图所示)。 第四步 在…

web3 React Dapp书写订单 买入/取消操作

好 上文web3 前端dapp从redux过滤出 (我创建与别人创建&#xff09;正在执行的订单 并展示在Table上中 我们过滤出了 我创建的 与 别人创建的 且 未完成 未取消的订单数据 这边 我们起一下 ganache 环境 ganache -d然后 我们项目 发布一下智能合约 truffle migrate --reset然…

C++ 常用方法,刷oj必备(持续更新!!!)

输出结果保留小数点后n位(4位) #include<iostream> #include <iomanip> using namespace std;int main(){double s ;cin >> s ;cout<<fixed << setprecision(4) << s ;return 0; } 类型转换 string 转 int #include <iostream> …

深度学习工具的安装 CUDA Anaconda

深度学习工具安装 CUDA与CUDNN的安装 查看计算机是否支持CUDA 主要参考: 一看就懂的 CUDA安装教程及Pytorch GPU版本安装教程 次要参考: cuda安装 &#xff08;windows版&#xff09; cuDNN的验证 Anaconda的包装 anaconda下载安装包国内镜像源

jenkins分步式构建环境(agent)

rootjenkins:~# netstat -antp|grep 50000 tcp6 0 0 :::50000 ::&#x1f617; LISTEN 5139/java 1.52 安装Jenkins rootubuntu20:~# dpkg -i jenkins_2.414.3_all.deb 配置各种类型的Agent的关键之处在于启动Agent的方式 ◼ JNLP Agent对应着“通过Java Web启动代理”这种方…

企业计算机中了mkp勒索病毒怎么办,服务器中了勒索病毒如何处理

计算机技术的不断发展给企业的生产生活提供了极大便利&#xff0c;但也为企业带来了网络安全威胁。近期&#xff0c;云天数据恢复中心陆续接到很多企业的求助&#xff0c;企业的计算机服务器遭到了mkp勒索病毒攻击&#xff0c;导致企业的所有工作无法正常开展&#xff0c;给企业…

kubernetes etcd

目录 一、备份 二、回复 官网&#xff1a; https://v1-25.docs.kubernetes.io/zh-cn/docs/tasks/administer-cluster/configure-upgrade-etcd/#restoring-an-etcd-cluster 一、备份 从镜像中拷贝etcdctl二进制命令 输入ctrlpq快捷键&#xff0c;把容器打入后台 docker run…

数据的属性与数据集,相似度,数据的质量,OLAP

数据的属性与数据集&#xff1a; 2022找工作是学历、能力和运气的超强结合体&#xff0c;遇到寒冬&#xff0c;大厂不招人&#xff0c;可能很多算法学生都得去找开发&#xff0c;测开 测开的话&#xff0c;你就得学数据库&#xff0c;sql&#xff0c;oracle&#xff0c;尤其sq…

徒步“三色”泸溪 共赏冬日胜景

&#xff08;金笛 胡灵芝&#xff09;11月11日&#xff0c;“中国体育彩票”2023年“走红军走过的路”徒步穿越系列活动&#xff08;泸溪站&#xff09;暨泸溪文旅推荐活动在泸溪县举行&#xff0c;来自全国各地千余名户外爱好者通过徒步的方式&#xff0c;传承红色基因&#x…

C语言全部关键字解析

前言 C语言具有以下关键字&#xff1a; 这些关键字如下: 关键字autobreakcasecharconstcontinuedefaultdodoubleelseenumexternfloatforgotoifintlongregisterreturnshortsignedsizeofstaticstructswitchtypedefunionunsignedvoidvolatilewhile 对于这些关键字&#xff0c;大…

《RN移动开发实战》3出版了,文末抽奖

前言 众所周知&#xff0c;传統的原生Android、iOS开发技术虽然比较成熟&#xff0c;但是多端重复开发的成本和开发效率的低下也是很多企业不愿意看到的&#xff0c;而不断崛起的跨平台技术让企业看到了曙光&#xff0c;“一次编写&#xff0c;处处运行”也不再是难以企及的目…

代码随想录算法训练营第四十六天|139. 单词拆分、多重背包问题、总结

第九章 动态规划part08 139. 单词拆分 给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。 注意&#xff1a;不要求字典中出现的单词全部都使用&#xff0c;并且字典中的单词可以重复使用。 关于字符串类型的题目还是…

大数据治理——为业务提供持续的、可度量的价值(二)

第二部分&#xff1a;元数据集成体系结构 在明确了元数据管理策略后需要确定实现该管理策略所需的技术体系结构&#xff0c;即元数据集成体系结构。元数据集成体系结构涉及到多个概念&#xff0c;如元模型、元-元模型、公共仓库元模型&#xff08;CWM&#xff09;等&#xff0…

Shopee活动取消规则是什么?shopee官方促销活动怎么取消?

作为一家知名的电商平台&#xff0c;shopee官方对于消费者取消促销活动的请求给予了相应的规定和处理流程。 shopee活动取消规则是什么&#xff1f; 首先&#xff0c;消费者应该明确了解虾皮的促销活动取消规则。根据虾皮的官方规定&#xff0c;消费者在参与促销活动之前&…