多个产品共用一套流程:骨架、扩展点与规则归属
公司先有课程业务,下单流程写得很顺。后来加了会员,再后来加了实物商品。每加一种产品,就在下单流程里多几个
if (product == …)。第四种产品上线时,一个漏改的分支让它按实物的费率收了手续费;第五种产品的团队为了提高转化率,在自己的分支里跳过了风控。
多个产品共用一套流程是平台化最常见的起点:下单、支付、开票、审批,大部分步骤相同,少数步骤因产品而异。难点不在「怎样复用」,而在谁拥有哪条规则:哪些步骤是平台的,产品不能改;哪些是产品的,平台不该管;两者之间的接口长什么样,平台怎样确认产品的实现符合约定。
本文用一个共用的下单流程,在 JDK 25 上比较三种写法:按产品分支、每一步都可替换的流水线、平台持有骨架并开放少数扩展点。实验见文末。
一、先分清规则归属
动手之前,把流程里的每一步按「谁对它负责」分类:
| 步骤 | 归属 | 理由 |
|---|---|---|
| 参数校验、风控、支付、记账 | 平台 | 所有产品必须一致;出问题是公司层面的风险 |
| 占座、锁库存 | 产品 | 只有产品知道自己的资源是什么 |
| 手续费 | 产品计算,平台约束范围 | 费率是产品的商业决定,但不能为负、不能超过金额 |
| 开通权限、发货 | 产品 | 履约方式因产品而异 |
| 步骤的顺序 | 平台 | 先风控后支付、先支付后履约,是平台的承诺 |
这张表决定了后面的一切。平台的规则应该无法被跳过,产品的规则应该无需平台改代码就能变化,介于两者之间的(手续费)由产品实现、平台用契约约束。
二、按产品分支:变化散落在共用代码里
void place(Order o) {
if (o.product.equals("COURSE")) checkSeat(o);
if (o.product.equals("GOODS")) reserveStock(o);
riskCheck(o);
if (o.product.equals("MEMBERSHIP")) fee = BigDecimal.ZERO; else fee = amount.multiply(RATE);
if (o.product.equals("GOODS")) createShipment(o);
if (o.product.equals("COURSE") || o.product.equals("MEMBERSHIP")) grantAccess(o);
}共用流程里按产品判断的分支 5 处(三种产品);新增「直播」时,每一处都要判断要不要加,
按这段代码,漏改时直播会落到 else 按默认费率收手续费,也不会开通权限只有两三种产品、差异很小时,这样写最直接。问题在于它的增长方式:每多一种产品,所有分支都要重新检查一遍;漏掉的分支不会报错,只会落到 else。共用流程的代码由所有产品团队共同修改,谁都能改风控那一行附近的代码。这种「新增一种情况要改多处」的信号,和子类型、类型字段还是组合里带 default 的分支是同一个问题。
三、每一步都可替换:灵活过头
为了不改共用代码,下一步常见的做法是把流程拆成一串步骤,每个产品可以增加、替换任意步骤:
流水线允许产品替换任一步骤,秒杀产品换掉了第 2 步:执行轨迹 [validate, fast-approve, pay],riskChecked=false共用代码确实不用改了,但平台也失去了对流程的控制。风控是平台的规则,却被做成了和「占座」同等地位的可替换步骤,任何产品都可以把它换掉。这种设计把「扩展」理解成了「什么都能改」,结果是平台无法对外承诺任何东西:它不知道一个订单有没有经过风控。
可替换的流水线本身是有用的结构,适合每一步都由同一方负责、只是组合方式不同的场景。责任链与流水线的取舍,见策略、回调与责任链。
四、平台持有骨架,只开放声明好的扩展点
interface ProductExtension {
String product();
void prepare(Order o); // 下单前准备(占座、锁库存)
BigDecimal fee(Order o); // 手续费,契约:0 ≤ fee ≤ amount,退款单反号
void fulfil(Order o); // 履约(开通、发货)
}
final class Platform {
final void place(Order o) {
ProductExtension e = ext.get(o.product);
if (e == null) throw new IllegalArgumentException("未注册的产品:" + o.product);
e.prepare(o);
riskCheck(o); // 平台步骤,不是扩展点
o.fee = e.fee(o); pay(o);
e.fulfil(o);
}
}平台骨架下单课程:轨迹 [prepare, risk, pay, fulfil],riskChecked=true
新增直播:注册一个扩展,平台代码不变;直播订单轨迹 [prepare, risk, pay, fulfil]
没有注册扩展的产品下单:被拒绝(未注册的产品:GIFT_CARD),而不是落到某个默认分支骨架方法由平台持有,风控和支付写死在骨架里,产品没有任何入口能跳过它们。产品只能实现三个扩展点,每个扩展点的含义和约束都写在接口上。新增产品就是注册一个扩展;未注册的产品直接拒绝,不会落到默认行为。
这是模板方法的思路,但不必用继承:骨架是一个持有扩展的对象,扩展是一个接口,组合比继承更容易测试,也避免了产品子类覆盖父类其他方法。扩展点设计有几条原则:
- 少而窄:只为第一节表里「产品」那几行开扩展点。每多一个扩展点,平台就多一个对外承诺,以后很难收回。
- 输入输出明确:扩展点接收它需要的数据、返回结果,而不是拿到整个上下文随意修改。上面的
fee返回一个金额,平台负责使用它;如果把它写成void adjust(Order o),产品就能顺手改掉订单的其他字段。 - 写清契约:返回值的范围、能否抛异常、是否允许有副作用、调用次数。
五、契约测试:平台检查每一个扩展
扩展点有了契约,还要确认每个产品的实现真的遵守它:
平台对 5 个扩展跑手续费契约(0 ≤ 手续费 ≤ 金额,退款单反号):失败 1 项 [GROUP_BUY 金额 -50 的手续费 3]团购团队的手续费实现对金额取了绝对值,退款单也收了正的手续费。他们自己的测试只覆盖了正常订单。平台写一组契约测试,对每一个注册的扩展运行同样的检查:正常金额、零、退款。新扩展注册时自动纳入,不依赖各团队记得写这些用例。
这是里氏替换原则在平台上的落地:所有扩展都要能替换进骨架而不破坏平台的承诺。契约测试由平台维护,因为契约是平台定义的。
六、什么时候不需要平台
把共用流程做成骨架加扩展点有明确的成本:扩展点一旦开放就要保持兼容,平台团队要维护契约测试和注册机制,每个产品的开发者都要理解骨架。下面这些情况,按产品分支或者干脆复制一份流程可能更合适:
- 只有两种产品,且看不出第三种:抽象出来的扩展点很可能是错的,等第三种出现再提取。
- 产品之间的差异不在少数几步,而在整个流程:比如一个需要审批、一个不需要,硬套同一个骨架会让扩展点越来越多。
- 产品由同一个团队维护、一起发布:规则归属的问题没有那么尖锐。
判断的依据仍然是第一节那张表:如果能清楚地分出「平台的」和「产品的」,并且产品的那部分确实会独立变化,才值得做成扩展点。可复用组件的一般性讨论,见可复用的服务端组件。
七、检查清单
- 列出流程每一步的归属:平台、产品,或产品实现、平台约束。
- 平台的步骤写在骨架里,不作为扩展点。
- 扩展点少而窄,输入输出明确,契约写在接口上。
- 未注册的产品被拒绝,而不是落到默认分支。
- 平台对所有扩展运行同一组契约测试,新扩展自动纳入。
- 产品少于三种、差异在整个流程时,先不做平台。
配套实验
- codesphere-labs/design/shared-flow-extension-points:JDK 25.0.4.1:按产品分支、可替换任意步骤的流水线跳过风控、平台骨架与三个扩展点、对所有扩展的手续费契约测试(验证记录)
参考资料
- Erich Gamma 等,《Design Patterns》:Template Method、Strategy
- Martin Fowler,Consumer-Driven Contracts
- Barbara Liskov、Jeannette Wing,A Behavioral Notion of Subtyping