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

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

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

一 案例简介

这里举一个简略的常见案例:下单链路。假如咱们在做一个checkout接口,须要做各种校验、查问商品信息、调用库存服务扣库存、而后生成订单:

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

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

@Resourceprivate ItemService itemService;@Resourceprivate InventoryService inventoryService;@Resourceprivate 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> {

@Overridepublic OrderDTO handle(CheckoutCommand cmd) {    //}

}

public class CheckoutServiceImpl implements CheckoutService {

@Resourceprivate CheckoutCommandHandler checkoutCommandHandler;@Overridepublic 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> {

@Overridepublic void process(PaymentReceivedEvent event) {    //}

}

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

@Resourceprivate EventBus eventBus;@Overridepublic 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;@Resourceprivate ItemService itemService;@Resourceprivate InventoryService inventoryService;@Resourceprivate OrderRepository orderRepository;@Overridepublic 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;@Overridepublic 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 {

@Resourceprivate ExternalItemService externalItemService;@Overridepublic 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 {

@Resourceprivate ExternalInventoryService externalInventoryService;@Overridepublic boolean withhold(Long itemId, Integer quantity) {    return externalInventoryService.withhold(itemId, quantity);}

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

@Service
public class CheckoutServiceImpl implements CheckoutService {

@Resourceprivate ItemFacade itemFacade;@Resourceprivate InventoryFacade inventoryFacade;@Overridepublic 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防腐层将内部依赖转化为外部代码,隔离内部的影响。

业务流程设计模式:

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

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