我15年初加入阿里的前半年,观察到当时的交易平台已经无法很好的对业务进行敏捷支撑:人手不够,加班严重;排期时间很长,经常延期;共建机制,协同困难等问题。本文是在当时背景之下,提出中台概念以及构建TMF2.0框架之前,所记录我观察到的问题和一些初步想法。PS:本文的示例代码,与实际代码没有任何关系。
电商业务五花八门,各个部门业务的定位也不一样。有的业务是面向特定“垂直”行业的,比如天猫服装业务、天猫电器业务、天猫汽车业务、飞猪度假业务、阿里通信业务等。也有些业务的定位是为所有的行业提供业务支撑的平台型业务,比如聚划算、导购宝等。在早期,各个业务的处理逻辑是一致的,但随着各自业务发展的需求,业务处理逻辑产生了差异化的需求。
随着业务越做越多,代码也逐渐开始从局部开始腐化,最终系统变得不堪重负、难以为继。
一次代码腐化的演进过程
我们以“减库存策略”为例子,在早期的平台减库存处理逻辑中,减库存策略都是“拍下商品后减库存”。但对于一些库存比较少并且价格比较昂贵的商品,比如空调、冰箱等大家电,如果采用“拍下商品后减库存”这种策略,会导致一些有竞争关系商家的恶拍,即只拍下商品,但不付款。这种情况,会导致卖家电的商家库存中无货可卖,而实际商品还积压在仓库中,造成大量的资金占用、仓库占用等。所以,天猫电器业务,他们就希望能自定义本业务的减库存策略为“付款后减库存”。这时,原先处理逻辑是一致的代码就演变成下面这种形式:
public ReduceTypeEnum getProductInventoryReducePolicy(OrderLine orderLine){
if (orderLine.getProduct().hasTag(9527) ){
//如果下单的商品是大家电
return ReduceTypeEnum.AFTER_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
业务的发展是如此的迅猛,上面的这段代码根本就无法稳定下来。当一些虚拟商品出现之后,有些虚拟商品是没有库存限制,不需要买家购买之后进行库存扣减。这时,上面这段代码又变成了这样:
public ReduceTypeEnum getProductInventoryReducePolicy(OrderLine orderLine){
if ( orderLine.getProduct().isVirtual() ){ //如果是虚拟商品,就不减库存
return ReduceTypeEnum.NO_REDUCATION;
}
if ( orderLine.getProduct().hasTag(9527) ){ //如是大家电商品,是付款减少
return ReduceTypeEnum.AFTER_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
做虚拟商品业务的同学添加完这段逻辑之后,经过验证没问题后就发布上线了。然而,过了几个月后,大家电业务增加了一种“门店自提”的收货方式。对于部分家电,消费者是希望自己能力在门店里挑一台自己满意无瑕疵的商品。对于门店自提的商品,发的也是虚拟的提货码,而不是实物物流。但为了确保收到提货码能在门店中能提到货,这个提货码对应的商品库存依然也是要扣减的。但上面改动之后的代码,对于这种场景就失效了,因为虚拟商品不减库存的逻辑始终在大家电业务前面。大家电的业务开发分析完后,对上面代码又进一步调整如下:
public ReduceTypeEnum getProductInventoryReducePolicy(OrderLine orderLine){
if ( orderLine.getProduct().isVirtual() ){ //如果是虚拟商品,就不减库存
if ( !orderLine.getProduct().hasTag(9527) ){ //不是大家电商品,才进去
return ReduceTypeEnum.NO_REDUCATION;
}
}
if ( orderLine.getProduct().hasTag(9527) ){ //如是大家电商品,是付款减少
return ReduceTypeEnum.AFTER_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
随着电器业务里的商品品类越来越丰富,商品数量越来越多,家电业务减库存策略统一采用“付款减库存”也不能满意需求。家电业务减库存策略变成了“如果商品单价大于5000,是付款减,否则是拍下减”。上面的代码又进一步腐化成下面样子:
public ReduceTypeEnum getProductInventoryReducePolicy(OrderLine orderLine){
if ( orderLine.getProduct().isVirtual() ){ //如果是虚拟商品,就不减库存
if ( !orderLine.getProduct().hasTag(9527) ){ //不是大家电商品,才进去
return ReduceTypeEnum.NO_REDUCATION;
}
}
if ( orderLine.getProduct().hasTag(9527) ){ //如是大家电商品,是付款减少
if( orderLine.getProduct().getPrice().getCent() > 500000L ) {
return ReduceTypeEnum.AFTER_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
上面这个代码腐化的过程不是一天形成的,他也经过了多年的日积月累。这还是非常简单的一个业务在减库存策略上的定制。而整个阿里巴巴电商业务成百上千,我曾经见过最极端的一个方法竟然长达几千行。里面有各种分支逻辑的判断,代码缩进嵌套层次也非常深。每一个程序员在面对一个新的扣减库存场景时,都小心翼翼的找到一个看似合适的位置,加上自己的if语句,并祈祷千万别影响到其他不相关业务。
引入设计模式,但依然没有银弹
随着定制化的业务逻辑越来越多,有追求的程序员开始考虑如何引入设计模式来解决代码腐化问题。还是以上面业务定制减库存策略为例子。有经验的程序员会针对这个定制点,定义一个SPI接口,如下:
public interface GetCustomInventoryReducePolicySpi {
boolean filter( ReducePolicySettingReq request );
ReduceTypeEnum execute( ReducePolicySettingReq request );
}
由于实现了该SPI接口的实现类会有多个,为了准确的执行正确的接口实现类,SPI接口会定义两个方法:
- filter:用于根据当前上下文请求参数,来判断当前SPI的实现类是否生效
- execute:当前SPI实现类生效时,平台会调用当前SPI实例的execute方法获取自定义的减库存策略
通过这种方式,我们可以将腐坏代码中每个if语句定义成SPI的实现类,平台负责对这些SPI实现类进行遍历并找到第一个返回值不为空的。如下:
public class VirtualProductReduceInventoryPolicyImpl
implements GetCustomInventoryReducePolicySpi {
public boolean filter( ReducePolicySettingReq request ){
return request.getOrderLine().getProduct().isVirtual();
}
public ReduceTypeEnum execute( ReducePolicySettingReq request ){
return ReduceTypeEnum.NO_REDUCATION;
}
}
public class TmallAppliancesReduceInventoryPolicyImpl
implements GetCustomInventoryReducePolicySpi {
public boolean filter( ReducePolicySettingReq request ){
return request.getOrderLine().getProduct().hasTag(9527);
}
public ReduceTypeEnum execute( ReducePolicySettingReq request ){
OrderLine orderLine = request.getOrderLine();
if( orderLine.getProduct().getPrice().getCent() > 500000L ) {
return ReduceTypeEnum.AFTER_PAYMENT;
}
return ReduceTypeEnum.BEFORE_PAYMENT;
}
}
public class InventoryReduceProcessor extends SpiProcessor{
//简易方式注册一个减库存策略的SPI列表
private List<GetCustomInventoryReducePolicySpi> reducePolicySpis =
Lists.newArrayList(
new TmallAppliancesReduceInventoryPolicyImpl(),
new VirtualProductReduceInventoryPolicyImpl()
......
);
public ReduceTypeEnum getProductInventoryReducePolicy(OrderLine orderLine){
//对减库存SPI的实现类遍历,返回第一个条件成立的SPI实例的值
return reducePolicySpis.stream().filter( p -> p.filter(request))
.map( p-> p.execute(p))
.findFirst()
.orElse(ReduceTypeEnum.AFTER_PAYMENT);
}
}
经过这种方式处理之后,平台的核心处理代码消除了大量的if..else… ,大家欢欣鼓舞。事实也证明,当一个SPI接口的实现类不是很多的情况下,这种方式还是非常奏效,也的确能大幅度的提升系统的可扩展性。但电商平台所支撑的行业非常多,最终会导致SPI实现类注册的列表会不断增长膨胀。后续,每当来一个需求涉及到减库存策略逻辑定制时,都需要仔细的分析与评估如何对这个SPI注册树进行修改。如:
- 是需要对现有的SPI实现做修改,还是需要新注册一个SPI实例?
- 对于需要新增SPI实例,其注册的顺序放在第几个?
- 对于新增加的SPI或者对已有的SPI做变动后,对于其他SPI的影响如何评估?
随着SPI实现类的增多,如何在新增SPI实例而又不对已有实现产生影响成为非常巨大的挑战。技术人员需要仔细的翻阅代码,看每个已经注册的SPI实例的filter方法是如何编写的,以确保新增的SPI实例的生效条件不至于太大而影响到其他SPI实例,同时生效条件也不能太小,导致自己的SPI实例始终无法生效。
我们的技术人员非常聪明,他们会利用更加高级的技术,比如规则引擎、远程RPC调用等来写filter方法。这个时候,想通过阅读代码来评估这些SPI实例何时会生效几乎成为不可能。
一个复杂的业务系统中,类似这样的SPI的接口定义会多达近千个,每个SPI接口上都会注册几十个实现类。以这种方式注册的SPI实现类,无论是显性注册还是以配置文件方式动态注册,在运行期平台都是以遍历方式找到并执行匹配到的SPI实现类。这种机制导致了不同业务与业务之间产生了巨大的耦合。任何一次新增或者修改SPI,对于其他业务是否有影响都有着巨大的不确定性。随着业务规模的不断变大,这种SPI注册与管理机制开始失效,并成为阻碍业务快速迭代与发展的关键障碍。
在15年,交易平台已经大量使用SPI机制来应对业务复杂度,本文只讲了SPI带来的问题。具体如何去解,敬请期待后文!
2023-03-30 at 下午5:40
public class VirtualProductReduceInventoryPolicyImpl
implements GetCustomInventoryReducePolicySpi {
public boolean filter( ReducePolicySettingReq request ){
return request.getOrderLine().getProduct().isVirtual();
}
public ReduceTypeEnum execute( ReducePolicySettingReq request ){
return ReduceTypeEnum.NO_REDUCATION;
}
}
——
个人认为,这里还有一个问题,就接口定义讲,返回结果是清晰,而入参是非常“臃肿”的。就造成订单的任何信息有可能决定最后的“库存策略“。
从编码原则看,这个接口并非很完美;就可维护性影响看,日后的问题就是订单的一些信息设置,到底会影响哪些逻辑,要看很多地方。
输入+计算=输出,如果要进一步优化入参,要从业务层面规划,哪些维度影响库存策略。
2023-03-30 at 下午5:43
Awaiting moderation
public class VirtualProductReduceInventoryPolicyImpl
implements GetCustomInventoryReducePolicySpi {
public boolean filter( ReducePolicySettingReq request ){
return request.getOrderLine().getProduct().isVirtual();
}
public ReduceTypeEnum execute( ReducePolicySettingReq request ){
return ReduceTypeEnum.NO_REDUCATION;
}
}
——
个人认为,这里还有一个问题,就接口定义讲,返回结果是清晰,而入参是非常“臃肿”的。就造成订单的任何信息有可能决定最后的“库存策略“。
从编码原则看,这个接口并非很完美;就可维护性影响看,日后的问题就是订单的一些信息设置,到底会影响哪些逻辑,要看很多地方。
输入+计算=输出,如果要进一步优化入参,要从业务层面规划,哪些维度影响库存策略。
2023-06-07 at 下午6:34
平台侧加载业务插件:如果平台侧和业务插件运行在不同的Docker容器中,平台侧插件管理框架或者动态类加载的机制怎么来加载业务插件?
2023-06-07 at 下午8:28
有几种方式:
1、平台的物理架构可以其物理部署件(物理部署件可根据需要做合并部署或者分成多个部署件),插件包可在 starter 的pom中,被平台某个部署件打包进来。这种方式,对于插件包而言,缺少动态性,插件包更新,平台需要重新打包
2、在上面这一点,可以以进一步演进。 平台定义一个目录,这个目录下放插件包。平台容器启动时,可以定义一个URLClassLoader,对这个目录下的插件包进行扫描并动态加载,这个是阿里交易平台后来演进的做法。
3、再演进一步,对于扩展点相对是粗粒度方式扩展的,插件包可以部署成一个独立的服务,与平台容器是独立的。扩展点调用,通过远程接口调用方式实现对平台的扩展。这种方式,可以看我另一个文章,我自己的实践:https://www.ryu.xin/2023/01/10/era-low-code/
2023-06-13 at 下午8:09
确实很牛叉,个人谈一下不成熟的想法。
1、一的这种方式,落地上没有问题,缺点:不能做到丝滑不停服发布,不能自主发布
2、如果平台定义成一个共享目录,那类似与挂载的方式,事先为每一个业务分配好插件包的目录,平台启动时,可以实现plugin的动态加载
缺点:中台应用如果是分布式集群部署的话,前台plugin,怎样动态加载到多个节点上?
3、第三个方案,堪称完美的方案,个人愚见,平台包和业务插件包运行在不同的Docker容器中,扩展点的调用,如果使用远程调用方式,比如Dubbo,HSF,JSF等,是否需要依赖,全局nacos、zk等注册中心,做服务的注册和发现,对于插件包而已,需要考虑到部署在一个机房或者集群里面,流程编排放在哪一侧,业务侧是否需要考虑分布式事务等问题