1. 程式人生 > 實用技巧 >一文教會你如何寫複雜業務程式碼

一文教會你如何寫複雜業務程式碼

作者 | 張建飛 阿里巴巴高階技術專家

瞭解我的人都知道,我一直在致力於應用架構和程式碼複雜度的治理。

這兩天在看零售通商品域的程式碼。面對零售通如此複雜的業務場景,如何在架構和程式碼層面進行應對,是一個新課題。針對該命題,我進行了比較細緻的思考和研究。結合實際的業務場景,我沉澱了一套“如何寫複雜業務程式碼”的方法論,在此分享給大家。

我相信,同樣的方法論可以複製到大部分複雜業務場景。

一個複雜業務的處理過程

業務背景

簡單的介紹下業務背景,零售通是給線下小店供貨的 B2B 模式,我們希望通過數字化重構傳統供應鏈渠道,提升供應鏈效率,為新零售助力。阿里在中間是一個平臺角色,提供的是 Bsbc 中的 service 的功能。

商品力是零售通的核心所在,一個商品在零售通的生命週期如下圖所示:

在上圖中紅框標識的是一個運營操作的“上架”動作,這是非常關鍵的業務操作。上架之後,商品就能在零售通上面對小店進行銷售了。因為上架操作非常關鍵,所以也是商品域中最複雜的業務之一,涉及很多的資料校驗和關聯操作

針對上架,一個簡化的業務流程如下所示:

過程分解

像這麼複雜的業務,我想應該沒有人會寫在一個 service 方法中吧。一個類解決不了,那就分治吧。

說實話,能想到分而治之的工程師,已經做的不錯了,至少比沒有分治思維要好很多。我也見過複雜程度相當的業務,連分解都沒有,就是一堆方法和類的堆砌。

不過,這裡存在一個問題:即很多同學過度的依賴工具或是輔助手段來實現分解。比如在我們的商品域中,類似的分解手段至少有 3 套以上,有自制的流程引擎,有依賴於資料庫配置的流程處理:

本質上來講,這些輔助手段做的都是一個 pipeline 的處理流程,沒有其它。因此,我建議此處最好保持 KISS(Keep It Simple and Stupid),即最好是什麼工具都不要用,次之是用一個極簡的 Pipeline 模式,最差是使用像流程引擎這樣的重方法

除非你的應用有極強的流程視覺化和編排的訴求,否則我非常不推薦使用流程引擎等工具。第一,它會引入額外的複雜度,特別是那些需要持久化狀態的流程引擎;第二,它會割裂程式碼,導致閱讀程式碼的不順暢。大膽斷言一下,全天下估計 80% 對流程引擎的使用都是得不償失的

回到商品上架的問題,這裡問題核心是工具嗎?是設計模式帶來的程式碼靈活性嗎?顯然不是,問題的核心應該是如何分解問題和抽象問題

,知道金字塔原理的應該知道,此處,我們可以使用結構化分解將問題解構成一個有層級的金字塔結構:

按照這種分解寫的程式碼,就像一本書,目錄和內容清晰明瞭。

以商品上架為例,程式的入口是一個上架命令(OnSaleCommand), 它由三個階段(Phase)組成。

@Command
public class OnSaleNormalItemCmdExe {

    @Resource
    private OnSaleContextInitPhase onSaleContextInitPhase;
    @Resource
    private OnSaleDataCheckPhase onSaleDataCheckPhase;
    @Resource
    private OnSaleProcessPhase onSaleProcessPhase;

    @Override
    public Response execute(OnSaleNormalItemCmd cmd) {

        OnSaleContext onSaleContext = init(cmd);

        checkData(onSaleContext);

        process(onSaleContext);

        return Response.buildSuccess();
    }

    private OnSaleContext init(OnSaleNormalItemCmd cmd) {
        return onSaleContextInitPhase.init(cmd);
    }

    private void checkData(OnSaleContext onSaleContext) {
        onSaleDataCheckPhase.check(onSaleContext);
    }

    private void process(OnSaleContext onSaleContext) {
        onSaleProcessPhase.process(onSaleContext);
    }
}

每個 Phase 又可以拆解成多個步驟(Step),以 OnSaleProcessPhase 為例,它是由一系列 Step 組成的:

@Phase
public class OnSaleProcessPhase {

    @Resource
    private PublishOfferStep publishOfferStep;
    @Resource
    private BackOfferBindStep backOfferBindStep;
    //省略其它step

    public void process(OnSaleContext onSaleContext){
        SupplierItem supplierItem = onSaleContext.getSupplierItem();

        // 生成OfferGroupNo
        generateOfferGroupNo(supplierItem);

       // 釋出商品
        publishOffer(supplierItem);

        // 前後端庫存繫結 backoffer域
        bindBackOfferStock(supplierItem);

        // 同步庫存路由 backoffer域
        syncStockRoute(supplierItem);

        // 設定虛擬商品拓展欄位
        setVirtualProductExtension(supplierItem);

        // 發貨保障打標 offer域
        markSendProtection(supplierItem);

        // 記錄變更內容ChangeDetail
        recordChangeDetail(supplierItem);

        // 同步供貨價到BackOffer
        syncSupplyPriceToBackOffer(supplierItem);

        // 如果是組合商品打標,寫擴充套件資訊
        setCombineProductExtension(supplierItem);

        // 去售罄標
        removeSellOutTag(offerId);

        // 傳送領域事件
        fireDomainEvent(supplierItem);

        // 關閉關聯的待辦事項
        closeIssues(supplierItem);
    }
}

看到了嗎,這就是商品上架這個複雜業務的業務流程。需要流程引擎嗎?不需要;需要設計模式支撐嗎?也不需要。對於這種業務流程的表達,簡單樸素的組合方法模式(Composed Method)是再合適不過的了。

因此,在做過程分解的時候,我建議工程師不要把太多精力放在工具上,放在設計模式帶來的靈活性上。而是應該多花時間在對問題分析,結構化分解,最後通過合理的抽象,形成合適的階段(Phase)和步驟(Step)上。

過程分解後的兩個問題

的確,使用過程分解之後的程式碼,已經比以前的程式碼更清晰、更容易維護了。不過,還有兩個問題值得我們去關注一下:

1、領域知識被割裂肢解

什麼叫被肢解?因為我們到目前為止做的都是過程化拆解,導致沒有一個聚合領域知識的地方。每個 Use Case 的程式碼只關心自己的處理流程,知識沒有沉澱。

相同的業務邏輯會在多個 Use Case 中被重複實現,導致程式碼重複度高,即使有複用,最多也就是抽取一個 util,程式碼對業務語義的表達能力很弱,從而影響程式碼的可讀性和可理解性。

2、程式碼的業務表達能力缺失

試想下,在過程式的程式碼中,所做的事情無外乎就是取資料 -- 做計算 -- 存資料,在這種情況下,要如何通過程式碼顯性化的表達我們的業務呢? 說實話,很難做到,因為我們缺失了模型,以及模型之間的關係。脫離模型的業務表達,是缺少韻律和靈魂的。


舉個例子,在上架過程中,有一個校驗是檢查庫存的,其中對於組合品(CombineBackOffer)其庫存的處理會和普通品不一樣。原來的程式碼是這麼寫的:

boolean isCombineProduct = supplierItem.getSign().isCombProductQuote();

// supplier.usc warehouse needn't check
if (WarehouseTypeEnum.isAliWarehouse(supplierItem.getWarehouseType())) {
// quote warehosue check
if (CollectionUtil.isEmpty(supplierItem.getWarehouseIdList()) && !isCombineProduct) {
    throw ExceptionFactory.makeFault(ServiceExceptionCode.SYSTEM_ERROR, "親,不能釋出Offer,請聯絡倉配運營人員,建立品倉關係!");
}
// inventory amount check
Long sellableAmount = 0L;
if (!isCombineProduct) {
    sellableAmount = normalBiz.acquireSellableAmount(supplierItem.getBackOfferId(), supplierItem.getWarehouseIdList());
} else {
    //組套商品
    OfferModel backOffer = backOfferQueryService.getBackOffer(supplierItem.getBackOfferId());
    if (backOffer != null) {
        sellableAmount = backOffer.getOffer().getTradeModel().getTradeCondition().getAmountOnSale();
    }
}
if (sellableAmount < 1) {
    throw ExceptionFactory.makeFault(ServiceExceptionCode.SYSTEM_ERROR, "親,實倉庫存必須大於0才能釋出,請確認已補貨.\r[id:" + supplierItem.getId() + "]");
}
}

然而,如果我們在系統中引入領域模型之後,其程式碼會簡化為如下:

if(backOffer.isCloudWarehouse()){
    return;
}

if (backOffer.isNonInWarehouse()){
    throw new BizException("親,不能釋出Offer,請聯絡倉配運營人員,建立品倉關係!");
}

if (backOffer.getStockAmount() < 1){
    throw new BizException("親,實倉庫存必須大於0才能釋出,請確認已補貨.\r[id:" + backOffer.getSupplierItem().getCspuCode() + "]");
}  

有沒有發現,使用模型的表達要清晰易懂很多,而且也不需要做關於組合品的判斷了,因為我們在系統中引入了更加貼近現實的物件模型(CombineBackOffer 繼承 BackOffer),通過物件的多型可以消除我們程式碼中的大部分的 if-else。

過程分解+物件模型

通過上面的案例,我們可以看到有過程分解要好於沒有分解過程分解+物件模型要好於僅僅是過程分解。對於商品上架這個 case,如果採用過程分解+物件模型的方式,最終我們會得到一個如下的系統結構:

寫複雜業務的方法論

通過上面案例的講解,我想說,我已經交代了複雜業務程式碼要怎麼寫:即自上而下的結構化分解+自下而上的面向物件分析

接下來,讓我們把上面的案例進行進一步的提煉,形成一個可落地的方法論,從而可以泛化到更多的複雜業務場景。

上下結合

所謂上下結合,是指我們要結合自上而下的過程分解和自下而上的物件建模,螺旋式的構建我們的應用系統。這是一個動態的過程,兩個步驟可以交替進行、也可以同時進行。

這兩個步驟是相輔相成的,上面的分析可以幫助我們更好的理清模型之間的關係,而下面的模型表達可以提升我們程式碼的複用度和業務語義表達能力

其過程如下圖所示:

使用這種上下結合的方式,我們就有可能在面對任何複雜的業務場景,都能寫出乾淨整潔、易維護的程式碼。

能力下沉

一般來說實踐 DDD 有兩個過程:

1. 套概念階段

瞭解了一些 DDD 的概念,然後在程式碼中“使用”Aggregation Root,Bounded Context,Repository 等等這些概念。更進一步,也會使用一定的分層策略。然而這種做法一般對複雜度的治理並沒有多大作用。

2. 融會貫通階段

術語已經不再重要,理解 DDD 的本質是統一語言、邊界劃分和麵向物件分析的方法。

大體上而言,我大概是在 1.7 的階段,因為有一個問題一直在困擾我,就是哪些能力應該放在 Domain 層,是不是按照傳統的做法,將所有的業務都收攏到 Domain 上,這樣做合理嗎?說實話,這個問題我一直沒有想清楚。

因為在現實業務中,很多的功能都是用例特有的(Use case specific)的,如果“盲目”的使用 Domain 收攏業務並不見得能帶來多大的益處。相反,這種收攏會導致 Domain 層的膨脹過厚,不夠純粹,反而會影響複用性和表達能力。

鑑於此,我最近的思考是我們應該採用能力下沉的策略。

所謂的能力下沉,是指我們不強求一次就能設計出 Domain 的能力,也不需要強制要求把所有的業務功能都放到 Domain 層,而是採用實用主義的態度,即只對那些需要在多個場景中需要被複用的能力進行抽象下沉,而不需要複用的,就暫時放在 App 層的 Use Case 裡就好了。

注:Use Case 是《架構整潔之道》裡面的術語,簡單理解就是響應一個 Request 的處理過程。

通過實踐,我發現這種循序漸進的能力下沉策略,應該是一種更符合實際、更敏捷的方法。因為我們承認模型不是一次性設計出來的,而是迭代演化出來的。
**
下沉的過程如下圖所示,假設兩個 use case 中,我們發現 uc1 的 step3 和 uc2 的 step1 有類似的功能,我們就可以考慮讓其下沉到 Domain 層,從而增加程式碼的複用性。

指導下沉有兩個關鍵指標:程式碼的複用性和內聚性。

複用性是告訴我們 When(什麼時候該下沉了),即有重複程式碼的時候。內聚性是告訴我們 How(要下沉到哪裡),功能有沒有內聚到恰當的實體上,有沒有放到合適的層次上(因為 Domain 層的能力也是有兩個層次的,一個是 Domain Service 這是相對比較粗的粒度,另一個是 Domain 的 Model 這個是最細粒度的複用)。

比如,在我們的商品域,經常需要判斷一個商品是不是最小單位,是不是中包商品。像這種能力就非常有必要直接掛載在 Model 上。

public class CSPU {
    private String code;
    private String baseCode;
    //省略其它屬性

    /**
     * 單品是否為最小單位。
     *
     */
    public boolean isMinimumUnit(){
        return StringUtils.equals(code, baseCode);
    }

    /**
     * 針對中包的特殊處理
     *
     */
    public boolean isMidPackage(){
        return StringUtils.equals(code, midPackageCode);
    }
}

之前,因為老系統中沒有領域模型,沒有 CSPU 這個實體。你會發現像判斷單品是否為最小單位的邏輯是以 StringUtils.equals(code, baseCode) 的形式散落在程式碼的各個角落。這種程式碼的可理解性是可想而知的,至少我在第一眼看到這個程式碼的時候,是完全不知道什麼意思。

業務技術要怎麼做

寫到這裡,我想順便回答一下很多業務技術同學的困惑,也是我之前的困惑:即業務技術到底是在做業務,還是做技術?業務技術的技術性體現在哪裡?

通過上面的案例,我們可以看到業務所面臨的複雜性並不亞於底層技術,要想寫好業務程式碼也不是一件容易的事情。業務技術和底層技術人員唯一的區別是他們所面臨的問題域不一樣。

業務技術面對的問題域變化更多、面對的人更加龐雜。而底層技術面對的問題域更加穩定、但對技術的要求更加深。比如,如果你需要去開發 Pandora,你就要對 Classloader 有更加深入的瞭解才行。

但是,不管是業務技術還是底層技術人員,有一些思維和能力都是共通的。比如,分解問題的能力,抽象思維,結構化思維等等。

用我的話說就是:“做不好業務開發的,也做不好技術底層開發,反之亦然。業務開發一點都不簡單,只是我們很多人把它做“簡單”了

因此,如果從變化的角度來看,業務技術的難度一點不遜色於底層技術,其面臨的挑戰甚至更大。因此,我想對廣大的從事業務技術開發的同學說:沉下心來,夯實自己的基礎技術能力、OO 能力、建模能力... 不斷提升抽象思維、結構化思維、思辨思維... 持續學習精進,寫好程式碼。我們可以在業務技術崗做的很”技術“!

阿里巴巴雲原生關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的公眾號。”