1. 案例简介
下单链路,假设我们在做一个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);
}
}
2. 重构方法
使用DDD的分层思想去重构以上代码,具体步骤:
1)分离出独立的Interface接口层,负责处理网络协议相关的逻辑;
2)从真实业务场景中,找出具体用例,然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接;
3)分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点;
4)处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等。
2.1 Interface接口层(适配层)
2.1.1 接口层的主要功能:
1)网络协议的转化
2)统一鉴权
3)Session管理
4)限流配置
5)前置缓存
6)异常处理
7)日志
2.1.2 返回值和异常处理规范
Interface层的Http和RPC接口,返回值为Result,捕捉所有异常
Application层的所有接口返回值为DTO,不负责处理异常
- Interface层改造:
使用切面统一处理异常
@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;
}
}
简化后的interface
@PostMapping("checkout")
@ResultHandler
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
}
2.1.3 接口层的接口数量和业务间的隔离
传统的REST和RPC的接口规范中,会追求一个领域的方法放在一个领域的服务或Controller中。但是在实际做业务的过程中,特别是当支撑的上游业务比较多时,刻意去追求接口的统一通常会导致方法中的参数膨胀,未来一个业务的变更可能导致整个Service/Controller的变更,最终变得难以维护。
这里提出另一个规范:一个Interface层的类应该是“小而美”的,应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。
举个例子:假设有一个宠物卡和一个亲子卡的业务公用一个开卡服务,但是宠物需要传入宠物类型,亲子的需要传入宝宝年龄。
反面示例:
// 可以是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);
}
正面示例:
public interface PetCardService {
Result openPetCard(int petType);
}
public interface BabyCardService {
Result openBabyCard(int babyAge);
}
这样会不会产生大量的接口类,导致代码逻辑重复?
答案是:不会,真实的业务逻辑会沉淀到应用层,Interface和Application的关系是多对多的,Application层的逻辑是相对稳定的。
2.2 Application层
2.2.1 Application核心类
1)ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑;
2)DTO Assambler:负责将内部领域模型转化为可对外的DTO;
3)Command、Query、Event对象:作为ApplicationService的入参;
4)返回的DTO:作为ApplicationService的出参。
2.2.2 CQE的规范
- 规范一:ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。
CQE和DTO对象的区别:
CQE是“意图”,可以有无限个;DTO作为模型数据容器,是有限的。
- 规范二:CQE对象的校验应该前置,避免在ApplicationService里做参数的校验,可以通过JSR303/380和Spring Validation来实现。
- 规范三:针对于不同语意的指令,要避免CQE对象的复用,比如新增和更新。
2.2.3 ApplicationService
ApplicationService是业务流程的封装,不处理业务逻辑
1)不要有if/else分支逻辑
2)不要有任何计算
3)数据转化可以交给单独的类
ApplicationService不做任何决策,把所有的决策交给DomainService或Entity,把跟外部交互的交给Infrastructure接口。
ApplicationService返回的永远是DTO而不是Entity,转换是通过MapStruct这个库来极大的减小转换成本。
Application层、Domain层以及Infrastructure层遇到错误直接抛异常,Interface层统一捕捉异常避免异常堆栈信息泄露到API之外。
2.3 防腐层
- 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类;
- 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类;
- 针对外部系统调用,同样用Facade方法封装外部调用链路。
Facade服务实现示例:
// 自定义的内部值类
@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);
}
}
改造后的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");
}
// ...
}
}
3. 两种模式Orchestration vs Choreography
Orchestration | Choreography | |
驱动力 | 指令驱动Command-Driven | 事件驱动Event-Driven |
调用依赖 | 上游强依赖下游 | 无直接调用依赖 但是有代码依赖 可以认为是下游依赖上游 |
灵活性 | 较差 | 较高 |
业务职责 | 上游为业务负责 | 无全局责任人 |
如何选择?
1)明确依赖的方向:如果上游必须要对你有感知,可以走指令驱动,否则可以走事件驱动;
2)找出业务的负责人。