
作者 | 在田
來源 | 阿裡技術公衆号
一 問題背景
數字農業庫存管理系統(以下簡稱數農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以容器托盤作為基本的庫存管理單元)。
@Service
public 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代碼重構的推進,以及對更多庫存操作業務場景的覆寫,我們不斷發現這套重構後的代碼架構具備優秀的可擴充性。例如,當我們需要為上遊系統提供「庫存增加并占用」的庫存操作原子能力支援時,我們發現可以使用組合委托的方式複用「庫存增加」和「庫存占用」的基本庫存操作能力,進而簡潔高效地完成功能開發。而這點若是單純基于模闆方法的類間繼承的方式是無法實作的。具體代碼和類圖如下:
// 庫存增加并占用
@Component
public 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.
參考資料
- 《設計模式》
- The java.util.concurrent Synchronizer Framework
- 《Java并發程式設計實戰》
- 維基百科Callback詞條
- why is jdbctemplate an example of the template method design pattern
- 《Effective Java 3》
- 《設計模式之美》
- 維基百科Strategy pattern詞條
Hadoop 分布式計算架構 MapReduce
點選這裡,檢視詳情!