关于code:殷浩详解DDD如何避免写流水账代码

10次阅读

共计 20840 个字符,预计需要花费 53 分钟才能阅读完成。

简介:在日常工作中我察看到,面对老零碎重构和迁徙场景,有大量代码属于流水账代码,通常能看到开发在对外的 API 接口里间接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑理论无奈收敛,接口复用性比拟差。所以本文次要想系统性的解释一下如何通过 DDD 的重构,将原有的流水账代码革新为逻辑清晰、职责明显的模块。

作者 | 殷浩
起源 | 阿里技术公众号

在日常工作中我察看到,面对老零碎重构和迁徙场景,有大量代码属于流水账代码,通常能看到开发在对外的 API 接口里间接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑理论无奈收敛,接口复用性比拟差。所以本文次要想系统性的解释一下如何通过 DDD 的重构,将原有的流水账代码革新为逻辑清晰、职责明显的模块。

一 案例简介

这里举一个简略的常见案例:下单链路。假如咱们在做一个 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。每个应用层的办法应该代表整个业务流程中的一个节点。
  • 解决一些跨层的横切关注点,如鉴权、异样解决、校验、缓存、日志等。
  • 上面会针对每个点做具体的解释。

二 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 接口层,作为所有对外的门户,将网络协议和业务逻辑解耦。

1 接口层的组成

接口层次要由以下几个性能组成:

  • 网络协议的转化:通常这个曾经由各种框架给封装掉了,咱们须要构建的类要么是被注解的 bean,要么是继承了某个接口的 bean。
  • 对立鉴权:比方在一些须要 AppKey+Secret 的场景,须要针对某个租户做鉴权的,包含一些加密串的校验
  • Session 治理:个别在面向用户的接口或者有登陆态的,通过 Session 或者 RPC 上下文能够拿到以后调用的用户,以便传递给上游服务。
  • 限流配置:对接口做限流防止大流量打到上游服务
  • 前置缓存:针对变更不是很频繁的只读场景,能够前置后果缓存到接口层
  • 异样解决:通常在接口层要防止将异样间接裸露给调用端,所以须要在接口层做对立的异样捕捉,转化为调用端能够了解的数据格式
  • 日志:在接口层打调用日志,用来做统计和 debug 等。个别微服务框架可能都间接蕴含了这些性能。
    当然,如果有一个独立的网关设施 / 利用,则能够抽离出鉴权、Session、限流、日志等逻辑,然而目前来看 API 网关也只能解决一部分的性能,即便在有 API 网关的场景下,利用里独立的接口层还是有必要的。

在 Interface 层,鉴权、Session、限流、缓存、日志等都比拟间接,只有一个异样解决的点须要重点说下。

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 接口层的接口的数量和业务间的隔离
在传统 REST 和 RPC 的接口标准中,通常一个畛域的接口,无论是 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 统一。

兴许会有人问,如果依照这种做法,会不会产生大量的接口类,导致代码逻辑反复?答案是不会,因为在 DDD 分层架构里,接口类的核心作用仅仅是协定层,每类业务的协定能够是不同的,而实在的业务逻辑会积淀到应用层。也就是说 Interface 和 Application 的关系是多对多的:

因为业务需要是疾速变动的,所以接口层也要跟着疾速变动,通过独立的接口层能够防止业务间相互影响,但咱们心愿绝对稳固的是 Application 层的逻辑。所以咱们接下来看一下 Application 层的一些标准。

三 Application 层

1 Application 层的组成部分
Application 层的几个外围类:

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

Application 层最外围的对象是 ApplicationService,它的外围性能是承接“业务流程“。然而在讲 ApplicationService 的标准之前,必须要先重点的讲几个非凡类型的对象,即 Command、Query 和 Event。

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

}
3 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 是运行时的,很难摸清楚具体调用到的对象。所以咱们尽管已经有过这种尝试,但当初曾经不倡议这么做了。

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 里。但这不代表齐全不能有 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 或防腐层。

个别的“套路”如下:

  • 筹备数据:包含从内部服务或长久化源取出绝对应的 Entity、VO 以及内部服务返回的 DTO。
  • 执行操作:包含新对象的创立、赋值,以及调用畛域对象的办法对其进行操作。须要留神的是这个时候通常都是纯内存操作,非长久化。
  • 长久化:将操作后果长久化,或操作内部零碎产生相应的影响,包含发消息等异步操作。

如果波及到对多个内部零碎(包含本身的 DB)都有变更的状况,这个时候通常处在“分布式事务”的场景里,无论是用分布式 TX、TCC、还是 Saga 模式,取决于具体场景的设计,在此处临时略过。

4 DTO Assembler

一个常常被忽视的问题是 ApplicationService 应该返回 Entity 还是 DTO?这里提出一个标准,在 DDD 分层架构中:

ApplicationService 应该永远返回 DTO 而不是 Entity。
为什么呢?

  • 构建畛域边界:ApplicationService 的入参是 CQE 对象,出参是 DTO,这些基本上都属于简略的 POJO,来确保 Application 层的内外相互不影响。
  • 升高规定依赖:Entity 外面通常会蕴含业务规定,如果 ApplicationService 返回 Entity,则会导致调用方间接依赖业务规定。如果外部规定变更可能间接影响到内部。
  • 通过 DTO 组合降低成本:Entity 是无限的,DTO 能够是多个 Entity、VO 的自由组合,一次性封装成简单 DTO,或者有抉择的抽取局部参数封装成 DTO 能够升高对外的老本。

因为咱们操作的对象是 Entity,然而输入的对象是 DTO,这里就须要一个专属类型的对象叫 DTO Assembler。DTO Assembler 的惟一职责是将一个或多个 Entity/VO,转化为 DTO。留神:DTO Assembler 通常不倡议有反操作,也就是不会从 DTO 到 Entity,因为通常一个 DTO 转化为 Entity 时是无奈保障 Entity 的准确性的。

通常,Entity 转 DTO 是有老本的,无论是代码量还是运行时的操作。手写转换代码容易出错,为了节俭代码量用 Reflection 会造成极大的性能损耗。所以这里我还是不遗余力的举荐 MapStruct 这个库。MapStruct 通过动态编译时代码生成,通过写接口和配置注解就能够生成对应的代码,且因为生成的代码是间接赋值,其性能损耗根本能够忽略不计。

通过 MapStruct,代码即可简化为:

import org.mapstruct.Mapper;
@Mapper
public interface OrderDtoAssembler {

OrderDtoAssembler INSTANCE = Mappers.getMapper(OrderDtoAssembler.class);
OrderDTO orderToDTO(Order order);

}

public class CheckoutServiceImpl implements CheckoutService {

private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;

@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
    // ...
    Order order = new Order();  
    // ...
    Order savedOrder = orderRepository.save(order);
    return orderDtoAssembler.orderToDTO(savedOrder);
}

}
联合之前的 Data Mapper,DTO、Entity 和 DataObject 之间的关系如下图:

5 Result vs Exception

最初,上文已经提及在 Interface 层应该返回 Result,在 Application 层应该返回 DTO,在这里再次反复提出标准:

Application 层只返回 DTO,能够间接抛异样,不必对立解决。所有调用到的服务也都能够间接抛异样,除非须要非凡解决,否则不须要刻意捕获异样。
异样的益处是能明确的晓得谬误的起源,堆栈等,在 Interface 层对立捕获异样是为了防止异样堆栈信息透露到 API 之外,然而在 Application 层,异样机制依然是信息量最大,代码构造最清晰的办法,防止了 Result 的一些常见且繁冗的 Result.isSuccess 判断。所以在 Application 层、Domain 层,以及 Infrastructure 层,遇到谬误间接抛异样是最正当的办法。

6 Anti-Corruption Layer 防腐层

本文仅仅简略形容一下 ACL 的原理和作用,具体的施行标准可能要等到另外一篇文章。

在 ApplicationService 中,常常会依赖内部服务,从代码层面对外部零碎产生了依赖。比方上文中的:

ItemDO item = itemService.getItem(cmd.getItemId());
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
会发现咱们的 ApplicationService 会强依赖 ItemService、InventoryService 以及 ItemDO 这个对象。如果任何一个服务的办法变更,或者 ItemDO 字段变更,都会有可能影响到 ApplicationService 的代码。也就是说,咱们本人的代码会因为强依赖了内部零碎的变动而变更,这个在简单零碎中应该是尽量避免的。那么如何做到对外部零碎的隔离呢?须要退出 ACL 防腐层。

ACL 防腐层的简略原理如下:

  • 对于依赖的内部对象,咱们抽取出所须要的字段,生成一个外部所需的 VO 或 DTO 类。
  • 构建一个新的 Facade,在 Facade 中封装调用链路,将外部类转化为外部类。
  • 针对内部零碎调用,同样的用 Facade 办法封装内部调用链路。

无防腐层的状况:

有防腐层的状况:

具体简略实现,假如所有内部依赖都命名为 ExternalXXXService:

@Data
public class ItemDTO {

private Long itemId;
private Long sellerId;
private String title;
private Long priceInCents;

}

// 商品 Facade 接口
public interface ItemFacade {

ItemDTO getItem(Long itemId);

}
// 商品 facade 实现
@Service
public class ItemFacadeImpl implements ItemFacade {

@Resource
private ExternalItemService externalItemService;

@Override
public ItemDTO getItem(Long itemId) {ItemDO itemDO = externalItemService.getItem(itemId);
    if (itemDO != null) {ItemDTO dto = new ItemDTO();
        dto.setItemId(itemDO.getItemId());
        dto.setTitle(itemDO.getTitle());
        dto.setPriceInCents(itemDO.getPriceInCents());
        dto.setSellerId(itemDO.getSellerId());
        return dto;
    }
    return null;
}

}

// 库存 Facade
public interface InventoryFacade {

boolean withhold(Long itemId, Integer quantity);

}
@Service
public class InventoryFacadeImpl implements InventoryFacade {

@Resource
private ExternalInventoryService externalInventoryService;

@Override
public boolean withhold(Long itemId, Integer quantity) {return externalInventoryService.withhold(itemId, quantity);
}

}
通过 ACL 革新之后,咱们 ApplicationService 的代码改为:

@Service
public class CheckoutServiceImpl implements CheckoutService {

@Resource
private ItemFacade itemFacade;
@Resource
private InventoryFacade inventoryFacade;

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

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

    // ...
}

}
很显然,这么做的益处是 ApplicationService 的代码曾经齐全不再间接依赖内部的类和办法,而是依赖了咱们本人外部定义的值类和接口。如果将来内部服务有任何的变更,须要批改的是 Facade 类和数据转化逻辑,而不须要批改 ApplicationService 的逻辑。

Repository 能够认为是一种非凡的 ACL,屏蔽了具体数据操作的细节,即便底层数据库构造变更,数据库类型变更,或者退出其余的长久化形式,Repository 的接口保持稳定,ApplicationService 就能放弃不变。

在一些实践框架里 ACL Facade 也被叫做 Gateway,含意是一样的。

四 Orchestration vs Choreography

在本文最初想聊一下简单业务流程的设计规范。在简单的业务流程里,咱们通常面临两种模式:Orchestration 和 Choreography。很无奈,这两个英文单词的百度翻译 / 谷歌翻译,都是“编排”,但实际上这两种模式是齐全不一样的设计模式。

Orchestration 的编排(比方 SOA/ 微服务的服务编排 Service Orchestration)是咱们通常相熟的用法,Choreography 是最近呈现了事件驱动架构 EDA 才缓缓流行起来。网上可能会有其余的翻译,比方编制、编舞、合作等,但感觉都没有真正的把英文单词的意思表达出来,所以为了防止误会,在下文我尽量还是用英文原词。如果谁有更好的翻译办法欢送分割我。

1 模式简介

  • Orchestration:通常呈现在脑海里的是一个交响乐团(Orchestra,留神这两个词的相似性)。交响乐团的外围是一个惟一的指挥家 Conductor,在一个交响乐中,所有的音乐家必须服从 Conductor 的指挥做操作,不能够单独施展。所以在 Orchestration 模式中,所有的流程都是由一个节点或服务触发的。咱们常见的业务流程代码,包含调用内部服务,就是 Orchestration,由咱们的服务对立触发。
  • Choreography:通常会呈现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros)。其中每个不同的舞蹈家都在做本人的事,然而没有一个中心化的指挥。通过合作配合,每个人做好本人的事,整个舞蹈能够展现出一个残缺的、谐和的画面。所以在 Choreography 模式中,每个服务都是独立的个体,可能会响应内部的一些事件,但整个零碎是一个整体。

2 案例
用一个常见的例子:下单后领取并发货。

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

如果这个案例是 Choreography,则业务逻辑为:下单,而后等领取胜利事件,而后再发货,相似这样:

3 模式的区别和抉择
尽管看起来这两种模式都能达到一样的业务目标,然而在理论开发中他们有微小的差别。

从代码依赖关系来看:

  • Orchestration:波及到一个服务调用到另外的服务,对于调用方来说,是强依赖的服务提供方。
  • Choreography:每一个服务只是做好本人的事,而后通过事件触发其余的服务,服务之间没有间接调用上的依赖。但要留神的是上游还是会依赖上游的代码(比方事件类),所以能够认为是上游对上游有依赖。
  • 从代码灵活性来看:
  • Orchestration:因为服务间的依赖关系是写死的,减少新的业务流程必然须要批改代码。
  • Choreography:因为服务间没有间接调用关系,能够减少或替换服务,而不须要改上游代码。
  • 从调用链路来看:
  • Orchestration:是从一个服务被动调用另一个服务,所以是 Command-Driven 指令驱动的。
  • Choreography:是每个服务被动的被内部事件触发,所以是 Event-Driven 事件驱动的。
  • 从业务职责来看:
  • Orchestration:有被动的调用方(比方:下单服务)。无论上游的依赖是谁,被动的调用方都须要为整个业务流程和后果负责。
  • Choreography:没有被动调用方,每个服务只关怀本人的触发条件和后果,没有任何一个服务会为整个业务链路负责。

小结:

另外须要重点明确的:“指令驱动”和“事件驱动”的区别不是“同步”和“异步”。指令能够是同步调用,也能够是异步音讯触发(但异步指令不是事件);反过来事件能够是异步音讯,但也齐全能够是过程内的同步调用。所以指令驱动和事件驱动差别的实质不在于调用形式,而是一件事件是否“曾经”产生。

所以在日常业务中当你碰到一个需要时,该如何抉择是用 Orchestration 还是 Choreography?

这里给出两个判断办法:

(1)明确依赖的方向

在代码中的依赖是比拟明确的:如果你是上游,上游对你无感知,则只能走事件驱动;如果上游必须要对你有感知,则能够走指令驱动。反过来,如果你是上游,须要对上游强依赖,则是指令驱动;如果上游是谁无所谓,则能够走事件驱动。

(2)找出业务中的“负责人”

第二种办法是依据业务场景找出其中的“负责人”。比方,如果业务须要告诉卖家,下单零碎的繁多职责不应该为音讯告诉负责,但订单管理系统须要依据订单状态的推动被动触发音讯,所以是这个性能的负责人。

在一个简单业务流程里,通常两个模式都要有,但也很容易设计谬误。如果呈现依赖关系很奇怪,或者代码里调用链路 / 负责人梳理不分明的状况,能够尝试转换一下模式,可能会好很多。

哪个模式更好?

很显然,没有最好的模式,只有最合适本人业务场景的模式。

反例:最近几年比拟风行的 Event-Driven Architecture(EDA)事件驱动架构,以及 Reactive-Programming 响应式编程(比方 RxJava),尽管有很多翻新,但在肯定水平上是“当你有把锤子,所有问题都是钉子”的典型案例。他们对一些基于事件的、流解决的问题有奇效,但如果拿这些框架硬套指令驱动的业务,就会感到代码极其“不协调”,认知老本进步。所以在日常选型中,还是要先依据业务场景梳理进去是哪些流程中的局部是 Orchestration,哪些是 Choreography,而后再抉择绝对应的框架。

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 防腐层将内部依赖转化为外部代码,隔离内部的影响。

业务流程设计模式:

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

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0