复杂业务代码怎么写-张建飞
- Author
- 张建飞 Frank Zhang
- Principal SDE at Alibaba
- Reference
01 Context 业务背景
零售通是给线下小店供货的 B2B 模式,我们希望通过数字化重构传统供应链渠道,提升供应链效率,为新零售助力。阿里在中间是一个平台角色,提供的是 Bsbc 中的 service 的功能。
一个商品在零售通的生命周期如下图所示:
在上图中红框标识的是一个运营操作的“上架”动作,这是非常关键的业务操 作。上架之后,商品就能在零售通上面对小店进行销售了。因为上架操作非常关键,所以也是商品域中最复杂的业务之一,涉及很多的数据校验和关联操作。
针对上架,一个简化的业务流程如下所示:
02 如何抽象和分解业务过程?
除非你的应用有极强的流程可视化和编排的诉求,否则我非常不推荐使用流程引擎等工具。第一,它会引入额外的复杂度,特别是那些需要持久化状态的流程引擎;第二,它会割裂代码,导致阅读代码的不顺畅。大胆断言一下,全天下估计 80%对流程引擎的使用都是得不偿失的。
回到商品上架的问题,这里问题核心是工具吗?是设计模式带来的代码灵活性吗?显然不是,问题的核心应该是如何分解问题和抽象问题,知道金字塔原理的应该知道,此处,我们可以使用结构化分解将问题解构成一个有层级的金字塔结构:
按照这种分解写的代码,就像一本书,目录和内容清晰明了。
2.1 使用组合模式来分解
以商品上架 为例,程序的入口是一个上架命令(OnSaleCommand), 它由三个阶段(Phase)组成。
@Command
public class OnSaleNormalItemCmdExe {
@Resource
private OnSaleContextInitPhase onSaleContextInitPhase;
@Resource
private OnSaleDataCheckPhase onSaleDataCheckPhase;
@Resource
private OnSaleProcessPhase onSaleProcessPhase;
@Override
public Response execute(OnSaleNormalItemCmd cmd) {
OnSaleContext onSaleContext = init(cmd);
checkData(onSaleContext);
process(onSaleContext);
return Response.buildSuccess();
}
private OnSaleContext init(OnSaleNormalItemCmd cmd) {
return onSaleContextInitPhase.init(cmd);
}
private void checkData(OnSaleContext onSaleContext) {
onSaleDataCheckPhase.check(onSaleContext);
}
private void process(OnSaleContext onSaleContext) {
onSaleProcessPhase.process(onSaleContext);
}
}
每个 Phase 又可以拆解成多个步骤(Step),以 OnSaleProcessPhase 为例,它是由一系列 Step 组成的:
@Phase
public class OnSaleProcessPhase {
@Resource
private PublishOfferStep publishOfferStep;
@Resource
private BackOfferBindStep backOfferBindStep;
//省略其它step
public void process(OnSaleContext onSaleContext){
SupplierItem supplierItem = onSaleContext.getSupplierItem();
// 生成OfferGroupNo
generateOfferGroupNo(supplierItem);
// 发布商品
publishOffer(supplierItem);
// 前后端库存绑定 backoffer域
bindBackOfferStock(supplierItem);
// 同步库存路由 backoffer域
syncStockRoute(supplierItem);
// 设置虚拟商品拓展字段
setVirtualProductExtension(supplierItem);
// 发货保障打标 offer域
markSendProtection(supplierItem);
// 记录变更内容ChangeDetail
recordChangeDetail(supplierItem);
// 同步供货价到BackOffer
syncSupplyPriceToBackOffer(supplierItem);
// 如果是组合商品打标,写扩展信息
setCombineProductExtension(supplierItem);
// 去售罄标
removeSellOutTag(offerId);
// 发送领域事件
fireDomainEvent(supplierItem);
// 关闭关联的待办事项
closeIssues(supplierItem);
}
}
看到了吗,这就是商品上架这个复杂业务的业务流程。需要流程引擎吗?不需要,需要设计模式支撑吗?也不需要。对于这种业 务流程的表达,简单朴素的组合方法模式(Composed Method)是再合适不过的了。
因此,在做过程分解的时候,我建议工程师不要把太多精力放在工具上,放在设计模式带来的灵活性上。而是应该多花时间在对问题分析,结构化分解,最后通过合理的抽象,形成合适的阶段(Phase)和步骤(Step)上。
2.2 过程分解后的两个问题
使用过程分解之后的代码,已经比以前的代码更清晰、更容易维护了。不过,还有两个问题值得我们去关注一下:
领域知识被割裂肢解
什么叫被肢解?因为我们到目前为止做的都是过程化拆解,导致没有一个聚合领域知识的地方。每个 Use Case 的代码只关心自己的处理流程,知识没有沉淀。
相同的业务逻辑会在多个 Use Case 中被重复实现,导致代码重复度高,即使有复用,最多也就是抽取一个 util,代码对业务语义的表达能力很弱,从而影响代码的可读性和可理解性。
代码的业务表达能力缺失
试想下,在过程式的代码中,所做的事情无外乎就是取数据--做计算--存数据,在这种情况下,要如何通过代码显性化的表达我们的业务呢?说实话,很难做到,因为我们缺失了模型,以及模型之间的关系。脱离模型的业务表达,是缺少韵律和灵魂的。
举个例子,在上架过程中,有一个校验是检查库存的,其中对于组合品(CombineBackOffer)其库存的处理会和普通品不一样。原来的代码是这么写的:
boolean isCombineProduct = supplierItem.getSign().isCombProductQuote();
// supplier.usc 仓库不需要检查
if (WarehouseTypeEnum.isAliWarehouse(supplierItem.getWarehouseType())) {
// 检查是否选择了仓库
if (CollectionUtil.isEmpty(supplierItem.getWarehouseIdList()) && !isCombineProduct) {
throw ExceptionFactory.makeFault(ServiceExceptionCode.SYSTEM_ERROR, "亲,不能发布Offer,请联系仓配运营人员,建立品仓关系!");
}
// 检查库存数量
Long sellableAmount = 0L;
if (!isCombineProduct) {
// 获取可销售库存量
sellableAmount = normalBiz.acquireSellableAmount(supplierItem.getBackOfferId(), supplierItem.getWarehouseIdList());
} else {
// 组套商品
OfferModel backOffer = backOfferQueryService.getBackOffer(supplierItem.getBackOfferId());
if (backOffer != null) {
// 获取组套商品的可销售库存量
sellableAmount = backOffer.getOffer().getTradeModel().getTradeCondition().getAmountOnSale();
}
}
// 如果库存量小于 1,抛出异常
if (sellableAmount < 1) {
throw ExceptionFactory.makeFault(ServiceExceptionCode.SYSTEM_ERROR, "亲,实仓库存必须大于0才能发布,请确认已补货.\r[id:" + supplierItem.getId() + "]");
}
}
然而,如果我们在系统中引入领域模型之后,其代码会简化为如下:
if (backOffer.isNonInWarehouse()){
throw new BizException("亲,不能发布Offer,请联系仓配运营人员,建立品仓关系!");
}
if (backOffer.getStockAmount() < 1){
throw new BizException("亲,实仓库存必须大于0才能发布,请确认已补货.\r[id:" + backOffer.getSupplierItem().getCspuCode() + "]");
}
有没有发现,使用模型的表达要清晰易懂很多,而且也不需要做关于组合品的判断了,因为我们在系统中引入了更加贴近现实的对象模型(CombineBackOffer 继承 BackOffer),通过对象的多态可以消除我们代码中的大部分的 if-else。
2.3 过程分解+对象模型
通过上面的案例,我们可以看到有过程分解要好于没有分解,过程分解+对象模型要好于仅仅是过程分解。对于商品上架这个 case,如果采用过程分解+对象模型的方式,最终我们会得到一个如下的系统结构:
03 写复杂业务的方法论
通过上面案例的讲解,我想说,我已经交代了复杂业务代码要怎么写:
即自上而下的结构化分解 + 自下而上的面向对象分析
接下来,让我们把上面 的案例进行进一步的提炼,形成一个可落地的方法论,从而可以泛化到更多的复杂业务场景。
3.1 上下结合
- 所谓上下结合,是指我们要结合自上而下的过程分解和自下而上的对象建模,螺旋式的构建我们的应用系统。这是一个动态的过程,两个步骤可以交替进行、也可以同时进行。
- 这两个步骤是相辅相成的,上面的分析可以帮助我们更好的理清模型之间的关系,而下面的模型表达可以提升我们代码的复用度和业务语义表达能力。
3.2 能力下沉
一般来说实践 DDD 有两个过程:
- 套概念阶段: 了解了一些 DDD 的概念,然后在代码中“使用”Aggregation Root,Bounded Context,Repository 等等这些概念。更进一步,也会使用一定的分层策略。然而这种做法一般对复杂度的治理并没有多大作用。
- 融会贯通阶段: 术语已经不再重要,理解 DDD 的本质是统一语言、边界划分和面向对象分析的方法。
大体上而言,我大概是在 1.7 的阶段,因为有一个问题一直在困扰我,就是哪些能力应该放在 Domain 层,是不是按照传统的做法,将所有的业务都收拢到 Domain 上,这样做合理吗?说实话,这个问题我一直没有想清楚。
因为在现实业务中,很多的功能 都是用例特有的(Use case specific)的,如果“盲目”的使用 Domain 收拢业务并不见得能带来多大的益处。相反,这种收拢会导致 Domain 层的膨胀过厚,不够纯粹,反而会影响复用性和表达能力。
鉴于此,我最近的思考是我们应该采用能力下沉的策略。
所谓的能力下沉,是指我们不强求一次就能设计出 Domain 的能力,也不需要强制要求把所有的业务功能都放到 Domain 层,而是采用实用主义的态度,即只对那些需要在多个场景中需要被复用的能力进行抽象下沉,而不需要复用的,就暂时放在 App 层的 Use Case 里就好了。
注:Use Case 是《架构整洁之道》里面的术语,简单理解就是响应一个 Request 的处理过程。
通过实践,我发现这种循序渐进的能力下沉策略,应该是一种更符合实际、更敏捷的方法。因为我们承认模型不是一次性设计出来的,而是迭代演化出来的。
下沉的过程如下图所示,假设两个 use case 中,我们发现 uc1 的 step3 和 uc2 的 step1 有类似的功能,我们就可以考虑让其下沉到 Domain 层,从而增加代码的复用性。
指导下沉有两个关键指标:
- 复用性
- 内聚性
复用性是告诉我们 When(什么时候该下沉了),即有重复代码的时候。内聚性是告诉我们 How(要下沉到哪里),功能有没有内聚到恰当的实体上,有没有放到合适的层次上(因为 Domain 层的能力也是有两个层次的,一个是 Domain Service 这是相对比较粗的粒度,另一个是 Domain 的 Model 这个是最细粒度的复用)。
比如,在我们的商品域,经常需要判断一个商品是不是最小单位,是不是中包商品。像这种能力就非常有必要直接挂载在 Model 上。
public class CSPU {
private String code;
private String baseCode;
//省略其它属性
/**
* 单品是否为最小单位。
*
*/
public boolean isMinimumUnit(){
return StringUtils.equals(code, baseCode);
}
/**
* 针对中包的特殊处理
*
*/
public boolean isMidPackage(){
return StringUtils.equals(code, midPackageCode);
}
}
之前,因为老系统中没有领域模型,没有 CSPU 这个实体。你会发现像判断单品是否为最小单位的逻辑是以 StringUtils.equals(code, baseCode)
的形式散落在代码的各个角落。这种代码的可理解性是可想而知的,至少我在第一眼看到这个代码的时候,是完全不知道什么意思。
04 业务技术要怎么做
业务技术和底层技术的区别主要在于 问题域。业务技术的复杂性不亚于底层技术,关键区别在于:
- 业务技术面对的是快速变化的问题域和庞杂的人员,需要灵活应对业务的复杂性。
- 底层技术的问题域相对更加稳定,但对技术的深度要求更高,比 如开发底层系统时需要对底层机制(如
Classloader
)有深入理解。
尽管两者面对的问题不同,但它们之间有一些共通的能力和思维方式:
- 问题分解能力: 有效地将复杂问题分解成可处理的小问题。
- 抽象思维: 通过抽象化简问题,提升代码的可维护性和复用性。
- 结构化思维: 有条理地组织和设计系统,确保系统架构清晰和高效。
业务技术的技术性体现在应对变化和复杂性上,写好业务代码同样需要深厚的技术功底。