DDD优秀实践及总结 Part Ⅴ——避免写流水账代码

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)找出业务的负责人。

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值