DDD系列第五讲:聊聊如何避免写流水账代码

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

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

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

规范:Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常

规范:Application层的所有接口返回值为DTO,不负责处理异常

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

举个例子:

@PostMapping(“checkout”)

public Result 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 checkout(Long itemId, Integer quantity) {

CheckoutCommand cmd = new CheckoutCommand();

OrderDTO orderDTO = checkoutService.checkout(cmd);

return Result.success(orderDTO);

}


  接口层的接口的数量和业务间的隔离


在传统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层

===

  Application层的组成部分


Application层的几个核心类:

  • ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑

  • DTO Assembler:负责将内部领域模型转化为可对外的DTO

  • Command、Query、Event对象:作为ApplicationService的入参

  • 返回的DTO:作为ApplicationService的出参

Application层最核心的对象是ApplicationService,它的核心功能是承接“业务流程“。但是在讲ApplicationService的规范之前,必须要先重点的讲几个特殊类型的对象,即Command、Query和Event。

  Command、Query、Event对象



从本质上来看,这几种对象都是Value Object,但是从语义上来看有比较大的差异:

  • Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。

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

  • Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。

简单总结下:


Command

Query

Event

语意

”希望“能触发的操作

各种条件的查询

已经发生过的事情

读/写

只读

通常是写

返回值

DTO 或 Boolean

DTO 或 Collection

Void

  • 为什么要用CQE对象?

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

Result checkout(Long itemId, Integer quantity);

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

Result checkout(Long itemId, Integer quantity);

Result checkout(Long itemId, Integer quantity, Integer channel);

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

List queryByItemId(Long itemId);

List queryBySellerId(Long sellerId);

List queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);

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

  1. 接口膨胀:一个查询条件一个方法

  2. 难以扩展:每新增一个参数都有可能需要调用方升级

  3. 难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护

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

  • CQE的规范

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

规范:ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建

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

public interface CheckoutService {

OrderDTO checkout(@Valid CheckoutCommand cmd);

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

}


  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 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:

// Application层

// 在这里框架通常可以根据接口识别到这个负责处理PaymentReceivedEvent

// 也可以通过增加注解识别

@Component

public class PaymentReceivedHandler implements EventHandler {

@Override

public void process(PaymentReceivedEvent event) {

//

}

}

// Interface层,这个是RocketMQ的Listener

public class OrderMessageListener implements MessageListenerOrderly {

@Resource

private EventBus eventBus;

@Override

public ConsumeOrderlyStatus consumeMessage(List 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;

}

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
img

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

}

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-TRUOKWz1-1710783272103)]
[外链图片转存中…(img-lcZEJuw5-1710783272104)]
[外链图片转存中…(img-JA7Tq4S2-1710783272105)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
[外链图片转存中…(img-V1MxNKmR-1710783272105)]

  • 16
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值