数字农业 WMS 库存操作重构及思考(数字农业系统)
一 问题背景
数字农业库存管理系统(以下简称数农WMS)是在2020年时,部门对产地仓生鲜水果生产加工数字化的背景下应运而生。项目一期的数农WMS中的各类库存操作(如库存增加、占用、转移等)均为单独编写。而伴随着后续的不断迭代,这些库存操作间慢慢积累了大量的共性逻辑:如参数校验、幂等性控制、操作明细构建、同步任务构建、数据库操作CAS重试、库存动账事件发布等等……大量重复或相似的代码不利于后续维护及高效迭代,因此我们决定借鉴并比较模板方法(Template Method)和回调(Callback)的思路进行重构:我们需要为各类库存操作搭建一个统一的框架,对其中固定不变的共性逻辑进行复用,而对会随场景变化的部分提供灵活扩展的能力支持。
二 模板方法
GoF的《设计模式》一书中对模板方法的定义是:「定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。」 —— 其核心是对算法或业务逻辑骨架的复用,以及其中部分操作的个性化扩展。在正式介绍对数农WMS库存操作的重构工作前,我们先以一个具体案例 —— AbstractQueuedSynchronizer(注1)(以下简称AQS) —— 来了解模板方法设计模式。虽然通过AQS这个相对复杂的例子来介绍模板方法显得有些小题大做,但由于AQS一方面是Java并发包的核心框架,另一方面也是模板方法在JDK中的现实案例,对它的剖析能使我们了解其背后精心的设计思路,同时与下文将介绍的回调的重构方式进行对比,值得我们多花一些时间研究。
《Java并发编程实战》中对AQS的描述是:AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构造出来。不仅ReentrantLock和Semaphore是基于AQS构建的,还包括CountDownLatch、ReentrantReadWriteLock等。AQS解决了在实现同步器时涉及的大量细节问题(例如等待线程采用FIFO队列操作顺序)。在基于AQS构建的同步器类中,最基本的操作包括各种形式的「获取操作」和「释放操作」。在不同的同步器中可以定义一些灵活的标准,来判断某个线程是应该通过还是需要等待。比如当使用锁或信号量时,获取操作的含义就很直观,即「获取的是锁或者许可」。AQS负责管理同步器类中的状态(synchronization state),它管理了一个整数状态信息,用于表示任意状态。例如,ReentrantLock用它来表示所有者线程已经重复获取该锁的次数,Semaphore用它来表示剩余的可被获取的许可数量。
对照我们在前文中引用的GoF对模板模式的定义,这里提到的「锁和同步器的框架」即对应「算法的骨架」,「灵活的标准」即对应「重定义该算法的某些特定步骤」;而synchronization state(以下简称「同步状态」)可以说是这两者之间交互的桥梁。Doug Lea对AQS框架的「获取操作」和「释放操作」的算法骨架的基本思路描述如下方伪代码所示。可以看到,在获取和释放操作中,对同步状态的判断和更新,是算法骨架中可被各类同步器灵活扩展的部分;而相应的对操作线程的入队、阻塞、唤起和出队操作,则是算法骨架中被各类同步器所复用的部分。
// 「获取操作」伪代码While(synchronization state does not allow acquire) { // * 骨架扩展点 enqueue current thread if not already queued; // 线程结点入队 possibly block current thread; // 阻塞当前线程}dequeue current thread if it was queued; // 线程结点出队// 「释放操作」伪代码update synchronization state // * 骨架扩展点if (state may permit a blocked thread to acquire) { // * 骨架扩展点 unblock one or more queued threads; // 唤起被阻塞的线程}
下面我们以大家熟悉的ReentrantLock为例具体分析。ReentrantLock实例内部维护了一个AQS的具体实现,用户的lock/unlock请求最终是借助AQS实例的acquire/release方法实现。同时,AQS实例在被构造时有两种选择:非公平性锁实现和公平性锁实现。我们来看下AQS算法骨架部分的代码:
// AQS acquire/release 操作算法骨架代码public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { // 同步状态 synchronization state private volatile int state; // 排他式「获取操作」 public final void acquire(int arg) { if (!tryAcquire(arg) && // * 骨架扩展点 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 线程结点入队 selfInterrupt(); } // 针对已入队线程结点的排他式「获取操作」 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { // * 骨架扩展点 setHead(node); // 线程结点出队(队列head为哑结点) p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) // 阻塞当前线程 interrupted = true; } } finally { if (failed) cancelAcquire(node); } } // 排他式「释放操作」 public final boolean release(int arg) { if (tryRelease(arg)) { // * 骨架扩展点 Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); // 唤起被阻塞的线程 return true; } return false; } // * 排他式「获取操作」骨架扩展点 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } // * 排他式「释放操作」骨架扩展点 protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }}
可以看到,AQS骨架代码为其子类的具体实现封装并屏蔽了复杂的FIFO队列和线程控制逻辑。ReentrantLock中的AQS实例只需实现其中的个性化逻辑部分:tryAcquire和tryRelease方法。比如在tryAcquire方法中,如果发现同步状态为0,会尝试以CAS的方式更新同步状态为1,以获取锁;如果发现同步状态大于0,且当前线程就是持有锁的线程,则会将同步状态加1,表示锁的重入;否则方法返回false,表示获取锁失败。而其中非公平性锁(ReentrantLock.NonfairSync)和公平性锁(ReentrantLock.FairSync)的区别主要在于,公平性锁在尝试获取锁时,会检查是否已有其他线程先于当前线程等待获取锁,如果没有,才会按照前述的方式尝试加锁。下图是ReentrantLock中AQS具体实现的类图(中间有一层额外的ReentrantLock.Sync,主要是为了部分代码的复用而设计)。
三 回调方式
但是,数农WMS最终使用的重构方式,实际上并不是模板方法模式,而是借鉴了Spring的风格,基于回调(Callback)的方式实现算法骨架中的扩展点。维基百科中对回调的定义是:「一段可执行代码被作为参数传递到另一段代码中,并将在某个时机被这段代码回调(执行)」。回调虽然不属于GoF的书中总结的某种特定的设计模式,但是在观察者(Observer)、策略(Strategy)和访问者(Visitor)这些模式中都可以发现它的身影(注2),可以说是一种常见的编程方式。
如下述RedisTemplate中的管道模式命令执行方法,其中的RedisCallback< ?> action参数即是作为函数式回调接口,接收用户传入的具体实现(自定义Redis命令操作),并在管道模式下进行回调执行(action.doInRedis或session.execute)。同时,管道的打开和关闭(connection.openPipeline/connection.closePipeline)也支持不同的实现方式:如我们熟悉的JedisConnection和Spring Boot 2开始默认使用的LettuceConnection。值得注意的是,虽然在Spring框架中存在各类以Template后缀命名的类(如RedisTemplate、TransactionTemplate、JdbcTemplate等),但是仔细观察可以发现,它们实际上使用的并不是模板方法,而是回调的方式(注3)。
public class RedisTemplate< K, V> extends RedisAccessor implements RedisOperations< K, V>, BeanClassLoaderAware { // 管道模式命令执行,RedisCallback @Override public List< Object> executePipelined(RedisCallback< ?> action, @Nullable RedisSerializer< ?> resultSerializer) { return execute((RedisCallback< List< Object>>) connection -> { connection.openPipeline(); // * 扩展点:开启管道模式 boolean pipelinedClosed = false; try { Object result = action.doInRedis(connection); // * 扩展点:回调执行用户自定义操作 if (result != null) { throw new InvalidDataAccessApiUsageException( "Callback cannot return a non-null value as it gets overwritten by the pipeline"); } List< Object> closePipeline = connection.closePipeline(); // * 扩展点:关闭管道模式 pipelinedClosed = true; return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer); } finally { if (!pipelinedClosed) { connection.closePipeline(); } } }); } // 事务 管道模式命令执行 @Override public List< Object> executePipelined(SessionCallback< ?> session, @Nullable RedisSerializer< ?> resultSerializer) { // 具体代码省略 } }
类似地,在数农WMS的库存操作重构中,我们定义了ContainerInventoryOperationTemplate「模板类」,作为承载库存操作业务逻辑的框架。下述为其中的库存操作核心代码片段。可以看到,框架统一定义了库存操作流程,并对其中的通用逻辑提供了支持,使各类不同的库存操作得以复用:如构建库存操作明细、持久化操作明细及同步任务、并发冲突重试等;而对于其中随不同库存操作类型变动的逻辑 —— 如操作库存数据、确认前置操作、持久化库存数据等 —— 则通过对ContainerInventoryOperationHandler接口实例的回调实现,它们可以被看作是库存操作框架代码中的扩展点。接口由不同类型的库存操作分别实现,如库存增加、库存占用、库存转移、库存释放等等。如此,如果我们后续需要添加某种新类型的库存操作,只需要实现ContainerInventoryOperationHandler接口中定义的个性化逻辑即可;而如果我们需要对整个库存操作流程进行迭代,也只需要修改ContainerInventoryOperationTemplate中的框架代码,而不是像先前那样,需要同时修改多处代码(这里模板类和库存操作handler的命名均以Container作为前缀,是因为数农WMS以容器托盘作为基本的库存管理单元)。
@Servicepublic class ContainerInventoryOperationTemplate { private Boolean doOperateInTransaction(OperationContext context) { final Boolean transactionSuccess = transactionTemplate.execute(transactionStatus -> { try { ContainerInventoryOperationHandler handler = context.getHandler(); // 库存操作回调handler handler.getAndCheckCurrentInventory(context); // 获取并校验库存数据 buildInventoryDetail(context); // 构建库存操作明细 handler.operateInventory(context); // * 扩展点:操作库存数据 handler.confirmPreOperationIfNecessary(context); // * 扩展点:确认前置操作(如库存占用) handler.persistInventoryOperation(context); // * 扩展点:持久化库存数据 persistInventoryDetailAndSyncTask(context); // 持久化操作明细及同步任务 doSyncOperationIfNecessary(context); // 库存同步操作 return Boolean.TRUE; } catch (WhException we) { context.setWhException(we); // 遇到并发冲突异常,需要重试 if (Objects.equals(we.getErrorCode(), ErrorCodeEnum.CAS_SAVE_ERROR.getCode())) { context.setCanRetry(true); } } // 省略部分代码 transactionStatus.setRollbackOnly(); return Boolean.FALSE; }); // 省略部分代码 return transactionSuccess; } }
四 组合与继承
为什么我们选择了基于回调,而非模板方法的方式,来实现数农WMS的库存操作重构呢?由于回调是基于对象之间的组合关系(composition)实现,而模板方法是基于类之间的继承关系(inheritance)实现,我们结合系统实际情况,并基于「组合优先于继承」的考量,最终选择了使用回调的方式进行代码重构。其原因大致如下:
- 继承打破封装性:《Effective Java》在《第18条:复合优先于继承》中提到,继承是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。与方法调用不同的是,继承打破了封装性。换句话说,子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。同时,子类可能继承了定义在父类,但其自身并不需要的方法,有违最小知识原则(Least Knowledge Principle)。子类可能因此错误地覆盖并改变了父类中的方法实现,导致父类功能的封装性被破坏。而如果我们使用对象间组合的方式,则可以避免此类问题的出现。
- 接口优于抽象类:仍旧是《Effective Java》,在《第20条:接口优于抽象类》中提到,因为Java只允许单继承,所以用抽象类(模板方法便是基于抽象类实现)作为类型定义受到了限制。而现有的类可以很容易被更新,以实现新的接口。接口是定义混合类型(mixin)的理想选择,允许构造非层次结构的类型框架。与之相反的做法是编写一个臃肿的类层次,对于每一种要被支持的属性组合,都包含一个单独的类。如果整个类型系统中有n个属性,那么就必须支持2n种可能的组合,这种现象被称为「组合爆炸」,即需要定义过多的类。
- 组合替代继承:最后,王争的《设计模式之美》中提到,继承主要有三个作用:表示is-a关系,支持多态性,以及代码复用。而这三个作用都可以通过其他手段达成:is-a关系可以通过组合和接口的has-a关系来替代;多态性可以利用接口来实现;代码复用则可以通过组合和委托来实现。因此从理论上讲,通过组合、接口、委托三个技术手段,我们可以替换掉继承,在项目中不用或者少用复杂的继承关系。这种对象间组合的设计方式比类间继承的方式更加符合开闭原则(Open-Closed Principle)(注4)。
结合我们前文中介绍的AbstractQueuedSynchronizer的案例,仔细阅读其源码可以发现,作者通过代码上的精心设计规避了上文提到的「继承打破封装性」的问题。比如,为了不使模板中的骨架逻辑错误地被子类覆盖,相关方法(如acquire和release)均使用了final关键字进行修饰;而对于某些必须由子类实现的扩展点,在AQS抽象类中均会抛出UnsupportedOperationException异常。然而此处不将扩展点定义为抽象方法,而是提供抛出异常的默认实现的原因,个人认为是由于AQS中定义了不同形式的获取和释放操作,而其锁和同步器的具体实现虽然会继承所有这些方法,但依据自身的应用场景往往只关心其中某种版本。比如ReentrantLock中的AQS实现仅关心排他式的版本(即tryAcquire和tryRelease),而Semaphore中的AQS实现仅关心共享式的版本(即tryAcquireShared和tryReleaseShared)。解决这类问题的另一种思路便是对这些不同形式的扩展方法进行拆分,归置到不同的接口,并以回调的方式进行具体功能实现,从而避免暴露不必要的方法。
此外,AQS内部维护的等待线程队列采用的是基于CLH思想实现的FIFO队列。如果我们同时需要一种优先级队列的内部实现(注5),并严格按照模板方法的模式对AQS进行扩展,则最终可能得到的是一个稍显臃肿的类层次,如下图所示:
AQS作为JDK的底层并发框架,应用场景相对固定,且更加侧重性能方面的考虑,其扩展性较低无可厚非。而对于如Spring的上层框架,在设计时就必须更多地考虑可扩展性的支持。如前文提到的RedisTemplate,借助其维护的RedisConnectionFactory即可获得不同类型的底层Redis连接实现;而对于其不同形式的管道执行方法(管道/事务 管道),用户只需要实现并传入对应的回调接口(RedisCallback/SessionCallback)即可,而不必感知其不需要的方法定义。这两点便是通过组合委托和回调的方式实现的,相较AQS而言显得更加灵活简洁,如下图所示:
五 再论重构
回到我们的数农WMS库存操作重构,虽然ContainerInventoryOperationTemplate与ContainerInventoryOperationHandler之间的关系非常接近策略模式(Strategy),但由于我们的「模板类」使用Spring的单例模式进行管理,其中并没有单独维护某个指定的库存操作handler,而是通过方法传参的方式触达它们,因此笔者更倾向于使用回调描述两者之间的代码结构。不过读者不必对两者命名的差异过于纠结,因为它们的思路是非常相近的。
随着数农WMS代码重构的推进,以及对更多库存操作业务场景的覆盖,我们不断发现这套重构后的代码框架具备优秀的可扩展性。例如,当我们需要为上游系统提供「库存增加并占用」的库存操作原子能力支持时,我们发现可以使用组合委托的方式复用「库存增加」和「库存占用」的基本库存操作能力,从而简洁高效地完成功能开发。而这点若是单纯基于模板方法的类间继承的方式是无法实现的。具体代码和类图如下:
// 库存增加并占用@Componentpublic class IncreaseAndOccupyOperationHandler implements ContainerInventoryOperationHandler { @Resource private IncreaseOperationHandler increaseOperationHandler; // 组合「库存增加」操作handler @Resource private OccupyOperationHandler occupyOperationHandler; // 组合「库存占用」操作handler // 委托「库存占用」操作handler进行前置操作校验,判断是否单据占用已存在 @Override public void checkPreOperationIfNecessary(ContainerInventoryOperationTemplate.OperationContext context) { occupyOperationHandler.checkPreOperationIfNecessary(context); } // 委托「库存增加」操作handler进行库存信息校验 @Override public void getAndCheckCurrentInventory(ContainerInventoryOperationTemplate.OperationContext context) { increaseOperationHandler.getAndCheckCurrentInventory(context); } // 委托「库存增加」、「库存占用」操作handler进行「库存增加并占用」操作 @Override public void operateInventory(ContainerInventoryOperationTemplate.OperationContext context) { increaseOperationHandler.operateInventory(context); occupyOperationHandler.operateInventory(context); } // 其余代码略}
最后,无论是基于模板方法还是回调的方式对库存操作进行重构,虽然我们可以获得代码复用以及扩展便利的好处,但是「模板类」中骨架逻辑的复杂性,其实是所有库存操作复杂性的总和(个人认为这一点在Spring框架的代码中也有所体现)。比如,库存增加操作在某些场景下需要在开启数据库事务前获取分布式锁,库存占用操作需要判断相关单据是否已经占用了库存等。而模板代码中的骨架逻辑需要为所有这些流程分支提供扩展点,从而支持各种类型的库存操作。此外,修改模板骨架逻辑的代码时也需要小心谨慎,因为一旦模板代码本身出错,可能会影响所有的库存操作。这些都对我们代码编写的质量和可维护性提出更高的要求。
六 结语
代码重构并且总结成文的过程要求不断地学习、思辨和实践,也让自己获益良多。
注解
- 对AQS使用了模板方法设计模式的「官方论断」可见于其作者Doug Lea在The java.util.concurrent Synchronizer Framework一文中的论述:Class AbstractQueuedSynchronizer ties together the above functionality and serves as a "template method pattern" base class for synchronizers. Subclasses define only the methods that implement the state inspections and updates that control acquire and release. 此外,文中还包含了对等待线程FIFO队列(CLH变体)、公平性、框架性能等方面的详细讨论。 http://gee.cs.oswego.edu/dl/papers/aqs.pdf
- 参考维基百科Callback词条:In object-oriented programming languages without function-valued arguments, such as in Java before its 8 version, callbacks can be simulated by passing an instance of an abstract class or interface, of which the receiver will call one or more methods, while the calling end provides a concrete implementation. Such objects are effectively a bundle of callbacks, plus the data they need to manipulate. They are useful in implementing various design patterns such as Visitor, Observer, and Strategy.
https://en.wikipedia.org/wiki/Callback_(computer_programming) - Stack Overflow上的某个问答可作为参考:I concur – JdbcTemplate isn't an example of template method design pattern. The design pattern used is callback. Note that the goal and effect of both patterns is very similar, the main difference is that template method uses inheritance while callback uses composition (sort of) – by Jiri Tousekh. https://stackoverflow.com/questions/33153252/why-is-jdbctemplate-an-example-of-the-template-method-design-pattern
- 参考维基百科Strategy pattern词条:The strategy pattern uses composition instead of inheritance. In the strategy pattern, behaviors are defined as separate interfaces and specific classes that implement these interfaces. This allows better decoupling between the behavior and the class that uses the behavior. The behavior can be changed without breaking the classes that use it, and the classes can switch between behaviors by changing the specific implementation used without requiring any significant code changes. This is compatible with the open/closed principle (OCP), which proposes that classes should be open for extension but closed for modification. https://en.wikipedia.org/wiki/Strategy_pattern
- Doug Lea在The java.util.concurrent Synchronizer Framework中提到:The heart of the framework is maintenance of queues of blocked threads, which are restricted here to FIFO queues. Thus, the framework does not support priority-based synchronization.
http://gee.cs.oswego.edu/dl/papers/aqs.pdf
参考资料
- 《设计模式》
https://book.douban.com/subject/1052241/
- The java.util.concurrent Synchronizer Framework
http://gee.cs.oswego.edu/dl/papers/aqs.pdf
- 《Java并发编程实战》
https://book.douban.com/subject/10484692/
- 维基百科Callback词条
https://en.wikipedia.org/wiki/Callback_(computer_programming)
- why is jdbctemplate an example of the template method design pattern
https://stackoverflow.com/questions/33153252/why-is-jdbctemplate-an-example-of-the-template-method-design-pattern
- 《Effective Java 3》
https://book.douban.com/subject/27047716/
- 《设计模式之美》
https://time.geekbang.org/column/intro/250
- 维基百科Strategy pattern词条
https://en.wikipedia.org/wiki/Strategy_pattern
作者 | 在田
原文链接:http://click.aliyun.com/m/1000305749/
本文为阿里云原创内容,未经允许不得转载。