1. 程式人生 > 其它 >數字農業WMS庫存操作重構及思考

數字農業WMS庫存操作重構及思考

簡介:數字農業庫存管理系統在2020年時,部門對產地倉生鮮水果生產加工數字化的背景下應運而生。專案一期的數農WMS中的各類庫存操作均為單獨編寫。而伴隨著後續的不斷迭代,這些庫存操作間慢慢積累了大量的共性邏輯:如引數校驗、冪等性控制、操作明細構建、同步任務構建、資料庫操作CAS重試、庫存動賬事件釋出等等……大量重複或相似的程式碼不利於後續維護及高效迭代,因此我們決定借鑑並比較模板方法(Template Method)和回撥(Callback)的思路進行重構:我們需要為各類庫存操作搭建一個統一的框架,對其中固定不變的共性邏輯進行復用,而對會隨場景變化的部分提供靈活擴充套件的能力支援。

作者 | 在田

來源 | 阿里技術公眾號

一 問題背景

數字農業庫存管理系統(以下簡稱數農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)實現,我們結合系統實際情況,並基於「組合優先於繼承」的考量,最終選擇了使用回撥的方式進行程式碼重構。其原因大致如下:

  1. 繼承打破封裝性:《Effective Java》在《第18條:複合優先於繼承》中提到,繼承是實現程式碼重用的有力手段,但它並非永遠是完成這項工作的最佳工具。使用不當會導致軟體變得很脆弱。與方法呼叫不同的是,繼承打破了封裝性。換句話說,子類依賴於其超類中特定功能的實現細節。超類的實現有可能會隨著發行版本的不同而有所變化,如果真的發生了變化,子類可能會遭到破壞,即使它的程式碼完全沒有改變。同時,子類可能繼承了定義在父類,但其自身並不需要的方法,有違最小知識原則(Least Knowledge Principle)。子類可能因此錯誤地覆蓋並改變了父類中的方法實現,導致父類功能的封裝性被破壞。而如果我們使用物件間組合的方式,則可以避免此類問題的出現。
  2. 介面優於抽象類:仍舊是《Effective Java》,在《第20條:介面優於抽象類》中提到,因為Java只允許單繼承,所以用抽象類(模板方法便是基於抽象類實現)作為型別定義受到了限制。而現有的類可以很容易被更新,以實現新的介面。介面是定義混合型別(mixin)的理想選擇,允許構造非層次結構的型別框架。與之相反的做法是編寫一個臃腫的類層次,對於每一種要被支援的屬性組合,都包含一個單獨的類。如果整個型別系統中有n個屬性,那麼就必須支援2n種可能的組合,這種現象被稱為「組合爆炸」,即需要定義過多的類。
  3. 組合替代繼承:最後,王爭的《設計模式之美》中提到,繼承主要有三個作用:表示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框架的程式碼中也有所體現)。比如,庫存增加操作在某些場景下需要在開啟資料庫事務前獲取分散式鎖,庫存佔用操作需要判斷相關單據是否已經佔用了庫存等。而模板程式碼中的骨架邏輯需要為所有這些流程分支提供擴充套件點,從而支援各種型別的庫存操作。此外,修改模板骨架邏輯的程式碼時也需要小心謹慎,因為一旦模板程式碼本身出錯,可能會影響所有的庫存操作。這些都對我們程式碼編寫的質量和可維護性提出更高的要求。

六 結語

程式碼重構並且總結成文的過程要求不斷地學習、思辨和實踐,也讓自己獲益良多。

註解

  1. 對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
  2. 參考維基百科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)
  3. 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.java - Why is JdbcTemplate an example of the Template method design pattern - Stack Overflow
  4. 參考維基百科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
  5. 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

參考資料

  • 《設計模式》

設計模式 (豆瓣)

  • The java.util.concurrent Synchronizer Framework

http://gee.cs.oswego.edu/dl/papers/aqs.pdf

  • 《Java併發程式設計實戰》

Java併發程式設計實戰 (豆瓣)

  • 維基百科Callback詞條

https://en.wikipedia.org/wiki/Callback_(computer_programming)

  • why is jdbctemplate an example of the template method design pattern

java - Why is JdbcTemplate an example of the Template method design pattern - Stack Overflow

  • 《Effective Java 3》

Effective Java (豆瓣)

  • 《設計模式之美》

設計模式之美_設計模式_程式碼重構-極客時間

  • 維基百科Strategy pattern詞條

https://en.wikipedia.org/wiki/Strategy_pattern

原文連結
本文為阿里雲原創內容,未經允許不得轉載。