怎樣寫好業務程式碼——那些年領域建模教會我的東西

TheHope發表於2017-07-17

本文主要作為筆者閱讀Eric Evans的《Domain-Driven Design領域驅動設計》一書,同時拜讀了我司大神針對業務程式碼封裝的一套業務框架後,對於如何編寫複雜業務程式碼的一點粗淺理解和思考。

ps,如有錯誤及疏漏,歡迎探討,知道自己錯了才好成長麼,我是這麼認為的,哈哈~

背景介紹

忘記在哪裡看到的句子了,有 “看花是花,看花不是花,看花還是花” 三種境界。這三個句子恰好代表了我從初入公司到現在,對於公司程式碼的看法的三重心路歷程。

學習動機

“看花是花”

得益於我司十幾年前一幫大神資料庫表模型設計的優異,一開始剛進公司的時候,很是驚歎。通過客戶端配配屬性,一個查詢頁面和一個資源實體的屬性控制元件頁面就生成好了。

框架本身負責管理頁面屬性和查詢頁面的顯示內容,以及右鍵選單與 js 函式的繫結關係,同時當其他頁面需要呼叫查詢及屬性頁面時,將頁面的實現和呼叫者頁面做了隔離,僅通過預留的簡單的呼叫模式及引數進行呼叫。這樣複雜功能的實現則不受框架限制與影響,留給業務開發人員自己去實現,客觀上滿足了日常的開發需求。

我司將繁雜而同質化的查詢及屬性頁面開發簡化,確實客觀上減輕了業務開發人員的工作壓力,使其留出了更多精力進行業務程式碼的研究及開發工作。

這套開發機制的發現,對我來說收穫是巨大的,具體的實現思路,與本文無關,這裡就不作過多贅述了。
這種新奇感和驚歎感,就是剛開始說的 “看花是花” 的境界吧。

“看花不是花”

那麼 “看花不是花” 又該從何說起呢?前面說了,框架完美的簡化了大量重複基礎頁面的開發工作,同時,框架本身又十分的剋制,並不干涉業務程式碼的開發工作。

但是從客觀上而言,業務程式碼本身由於包含了業務領域的知識,複雜可以說是先天的屬性。隨著自己工作所負責業務的深入,接觸更多的業務必然也不再是框架所能涵蓋的資源查詢與屬性編輯頁面。

同時考慮到業務編寫人員本身相對於框架人員技術上的弱勢,以及業務領域本身具有的複雜性的提升,我一開始所面對的,就是各種的長達幾百行的函式,隨處可見的判斷語句,參差不齊的錯誤提示流程,混亂的資料庫訪問語句。在這個階段,對業務程式碼開始感到失望,這也就是之後的 “看花不是花” 的境界吧

“看花還是花”

有一天,我突然發現面對紛繁而又雜亂的業務程式碼,總還有一個模組 “濯清漣而不妖”,其中規範了常用的常量物件,封裝了前後端互動機制,約定了異常處理流程,統一了資料庫訪問方式,更重要的是,思考並實現了一套程式碼層面的業務模型,並最終實現了業務程式碼基本上都是幾十行內解決戰鬥,常年沒有 bug,即便有,也是改動三兩行內就基本解決的神一般效果(有所誇張,酌情理解:P)

這是一個寶庫。原來業務程式碼也可以這麼簡潔而優雅。

唯一麻煩的就是該模組的業務複雜,相應的程式碼層面的業務模型的類層次結構也複雜,一開始看不太懂,直到我看了 Eric Evans的《Domain-Driven Design領域驅動設計》才逐漸有所理解。

因為此內部業務框架做的事情很多,篇幅有限,這裡僅對最具借鑑意義的領域建模思考作介紹。

業務場景

我主要負責傳輸網資源管理中的傳輸模組管理。這個部分涉及相對來說比較複雜的關聯關係,所以如果程式碼組織不夠嚴謹的話,極易繞暈和出錯,下面以一張簡單的概念圖來描述一下部分實體物件間的關聯關係。

clipboard.png

如圖,簡單來說時隙和通道是一對多的父子關係,同時業務電路和多段通道間的下級時隙,存在更復雜的一對多承載關係。

所以這個關係中複雜的地方在哪裡呢?理解上經常會繞混,電路建立要選多段通道時隙,通道本身要管理多個時隙。這樣時隙和通道以及電路同時存在了一對多的聯絡,如何去準確的理解和區分這種聯絡,並將之有效的梳理在程式碼層面就很需要一定技巧。

稍微擴充一下,改改資源型別,把業務電路換為傳輸通道,把傳輸通道換為傳輸段,這套關係同樣成立。

另外,真實的業務場景中,業務電路的下層路由,不僅支援高階通道,還支援段時隙,埠等資源。

整體設計

從業務場景中我們可以看到,資源實體間的模型關係其實有很多相類似的地方。比如大體上總是分為路由關係,和層級關係這麼兩種,那麼如何才能高效的對這兩種關係進行程式碼層面的建模以高效的進行復用,同時又保留有每個資源足夠的擴充空間呢?

傳統思路

我們先來考慮一下,按照傳統的貧血模型去處理傳輸通道這個資源,針對傳輸通道的需求,它是如何處理的呢?
圖片描述

最粗陋的常規模型,其實就是依據不同型別的資源對需求進行簡單分流,然後按照管理劃分 Controller 層,Service 層,Dao 層。各層之間的互動,搞得好一點的會通過抽象出的一個薄薄的 domain 域物件,搞的不好的直接就是 List,Map,Object 物件的粗陋組合。

程式碼示例

/**
  * 刪除通道 不呼叫ejb了
  *   業務邏輯:
  *       判斷是否被用,被用不能刪除
  *       判斷是否是高階通道且已被拆分,被拆不能刪除
  *       邏輯刪除通道路由表
  *       清空關聯時隙、波道的channel_id欄位
  *          將埠的狀態置為空閒   
  *       邏輯刪除通道表
  * @param paramLists 被刪除通道,<b>必填屬性:channelID</b>
  * @return 是否刪除成功
  * @throws BOException 業務邏輯判斷不滿足刪除條件引發的刪除失敗<br>
  *                     <b>通道非空閒狀態</b><br>
  *                     <b>高階通道已被拆分</b><br>
  *                     <b>刪除通道資料庫操作失敗</b><br>
  *                     <b>刪除HDSL系統失敗</b><br>
  * @return 成功與否
  */
 public String deleteChannel(String channelId){
        String returnResult = "true:刪除成功";
        Map<String,String> condition = new HashMap<String, String>();
        condition.put("channelID",channelId);
        condition.put("min_index","0");
        condition.put("max_index","1");
        boolean flag=true;
        List<Map<String,Object>> channel = this.channelJdbcDao.queryChannel(condition);
        if(channel==null||channel.size()==0){
            return "false:未查詢到通道資訊";
        }
        //判斷是否被用,被用不能刪除
        String oprStateId = channel.get(0).get("OPR_STATE_ID").toString();
        if(!"170001".equals(oprStateId)){
            return "false:通道狀態非空閒,不能刪除";
        }
        //判斷是否是高階通道且已被拆分,被拆不能刪除
        flag=this.channelJdbcDao.isSplited(channelId);
        if(!flag){
            return "false:高階通道已被拆分,不能刪除";
        }
        //邏輯刪除通道路由表 並且清空關聯時隙、波道的channel_id欄位
        this.channelJdbcDao.deleteChannelRoute(channelId,oprStateId);
        //將通道埠的埠狀態置為空閒
        this.channelJdbcDao.occupyPort(String.valueOf(channel.get(0).get("A_PORT_ID")),"170001");
        this.channelJdbcDao.occupyPort(String.valueOf(channel.get(0).get("Z_PORT_ID")),"170001");
        //邏輯刪除通道表
        this.channelJdbcDao.delete(channelId);
        //如果通道走了HDSL時隙則刪除HDSL系統及下屬資源 ,這裡重新呼叫了傳輸系統的刪除的ejb。
        List<Map<String,Object>> syss=this.channelJdbcDao.findSysByChannel(channelId);
        for(int i=0;i<syss.size();i++){
            if("56".equals(syss.get(i).get("SYS_TYPE").toString())){
                List<Map<String,String>> paramLists = new ArrayList<Map<String,String>>();
                List paramList = new ArrayList();
                Map map = new HashMap();
                map.put("res_type_id", "1001");
                map.put("type", "MAIN");
                paramList.add(map);
                map = new HashMap();
                map.put("sys_id", syss.get(i).get("SYS_ID"));
                paramList.add(map);
                //EJB裡面從第二個資料開始讀取要刪除的系統id,所以下面又加了一層 。
                map = new HashMap();
                map.put("res_type_id", "1001");
                map.put("type", "SUB");
                paramList.add(map);
                map = new HashMap();
                map.put("sys_id", syss.get(i).get("SYS_ID"));
                paramLists.add(map);
                String inputXml = this.createInputXML("1001", "deleteTrsSys",
                        "TrsSysService", paramLists);
                String result = this.getEJBResult(inputXml);
                if(result==null||"".equals(result)){//如果ejb處理失敗是以拋異常形式,被底層捕獲而未丟擲,導致返回結果為空
                    return "false:刪除HDSL系統失敗";
                }
                Document document = XMLUtils.createDocumentFromXmlString(result);
                Element rootElement = XMLUtils.getRootElement(document);
                if (!TransferUtil.getResultSign(rootElement).equals("success")){
                    result =rootElement.attribute("description").getValue();
                    return "false:刪除HDSL系統失敗"+result;
                }
            }
        }
        return returnResult;
}

上面這些程式碼,是我司n年前的一段已廢棄程式碼。其實也是很典型的一種業務程式碼編寫方式。

可以看到,比較關鍵的幾個流程是 :

空閒不能刪除(狀態驗證)—>路由刪除->埠置為空閒(路由資源置為空閒)->資源實體刪除

其中各個步驟的具體實現,基本上都是通過呼叫 dao 層的方法,同時配合若干行service層程式碼來實現的。這就帶來了第一個弊端,方法實現和 dao層實現過於緊密,而dao層的實現又是和各個資源所屬的表緊密耦合的。因此即便電路的刪除邏輯和通道的刪除邏輯有很相似的邏輯,也必然不可能進行程式碼複用了。

如果非要將不同資源刪除方法統一起來,那也必然是充斥著各種的 if/else 語句的硬性判斷,總程式碼量卻甚至沒有減少反而增加了,得不償失。

擴充思考

筆者曾經看過前人寫的一段傳輸資源的儲存方法的程式碼。

方法目的是支援傳輸通道/段/電路三個資源的儲存,方法引數是一些複雜的 List,Map 結構組合。由於一次支援了三種資源,每種資源又有自己獨特的業務判斷規則,多情況組合以後複雜度直接爆炸,再外原本方法的編寫人員沒有定期重構的習慣,所以到了筆者接手的時候,是一個長達500多行的方法,其間充斥著各式各樣的 if 跳轉,迴圈處理,以及業務邏輯驗證。

解決辦法

面對如此棘手的情況,筆者先是參考《重構·改善既有程式碼設計》一書中的一些簡單套路,拆解重構了部分程式碼。將原本的 500 行變成了十來個幾十行左右的小方法,重新組合。

方案侷限

  • 重構難度及時間成本巨大。
  • 有大量的 if/else 跳轉根本沒法縮減,因為程式碼直接呼叫 dao 層方法,必然要有一些 if/else 方法用來驗證資源型別然後呼叫不同的 dao 方法
  • 也因為上一點,重構僅是小修小補,化簡了一些輔助性程式碼的呼叫(引數提取,錯誤處理等),對於業務邏輯呼叫的核心程式碼卻無法進行簡化。service層程式碼量還是爆炸

小結

站在分層的角度思考下,上述流程按照技術特點將需求處理邏輯分為了三個層次,可是為什麼只有 Service 層會出現上述複雜度爆炸的情況呢?

看到這樣的程式碼,不由讓我想到了小學時候老師教寫文章,講文章要鳳頭,豬肚,豹尾。還真是貼切呢 :-)

換做學生時代的我,可能也就接受了,但是見識過高手的程式碼後,才發現寫程式碼並不應該是簡單的行數堆砌。

業務情景再分析

對於一個具體的傳輸通道A的物件而言,其內部都要管理哪些資料呢?

  • 資源物件層面

    • 自身屬性資訊
  • 路由層面

    • 下級路由物件列表
  • 層次關係層面

    • 上級資源物件
    • 下級資源物件列表

可以看到,所有這些資料其實分為了三個層面:

  1. 作為普通資源,傳輸通道需要管理自身的屬性資訊,比如速率,兩端網元,兩端埠,通道型別等。
  2. 作為帶有路由的資源,傳輸通道需要管理關聯的路由資訊,比如承載自己的下層傳輸段,下層傳輸通道等。
  3. 作為帶有層次關係的資源,傳輸通道需要管理關聯的上下級資源資訊,比如自己拆分出來的時隙列表。

更進一步,將傳輸通道的這幾種職責的適用範圍關係進行全業務物件級別彙總整理,如下所示:
圖片描述

各種職責對應的業務物件範圍如下:

  • 同時具有路由和層次關係的實體:

    • 傳輸時隙、傳輸通道、傳輸段、傳輸電路
  • 具有路由關係的實體:

    • 文字路由
  • 具有層次結構關係的物件:

    • 裝置、機房、埠
  • 僅作為資源的實體:

    • 傳輸網管、傳輸子網、傳輸系統

擴充思考

微觀層面

以傳輸通道這樣一個具體的業務物件來看,傳統的貧血模型基本不會考慮到傳輸通道本身的這三個層次的職責。但是物件的職責並不設計者沒意識到而變得不存在。如前所述的儲存方法,因為要兼顧物件屬性的儲存,物件路由資料的儲存,物件層次結構資料的儲存,再乘上通道,段,電路三種資源,很容易導致複雜度的飆升和耦合的嚴重。

因此,500行的函式出現某種程度上也是一種必然。因為原本業務的領域知識就是如此複雜,將這種複雜性簡單對映在 Service 層中必然導致邏輯的複雜和程式碼維護成本的上升。

巨集觀層面

以各個資源的職責分類來看,具備路由或層次關係的資源並不在少數。也就是說,貧血模型中,承擔類似路由管理職責的程式碼總是平均的分散在通道,段,電路的相關 Service 層中。

每種資源都不同程度的實現了一遍,而並沒有有效的進行抽象。這是在業務物件的程式碼模型角度來說,是個敗筆。

在這種情況下就算使用重構的小技巧,所能做的也只是對於各資源的部分重複程式碼進行抽取,很難自然而然的在路由的業務層面進行概念抽象。

既然傳統的貧血模型沒法應對複雜的業務邏輯,那麼我們又該怎麼辦呢?

新的架構

程式碼示例

@Transactional
public int deleteResRoute(ResIdentify operationRes) {
    int result = ResCommConst.ZERO;
    
    //1:獲得需要儲存物件的Entity
    OperationRouteResEntity resEntity = context.getResEntity(operationRes,OperationRouteResEntity.class);
    
    //2:獲得路由物件
    List<OperationResEntity> entityRoutes = resEntity.loadRouteData();

    //3:刪除路由
    result = resEntity.delRoute();
    
    //4:釋放刪除的路由資源狀態為空閒
    this.updateEntitysOprState(entityRoutes, ResDictValueConst.OPR_STATE_FREE);

    //日誌記錄
    resEntity.loadPropertys();
    String resName = resEntity.getResName();
    String resNo = resEntity.getResCode();
    String eport = "刪除[" + ResSpecConst.getResSpecName(operationRes.getResSpcId()) + ": " + resNo + "]路由成功!";
    ResEntityUtil.recordOprateLog(operationRes, resName, resNo, ResEntityUtil.LOGTYPE_DELETE, eport);
    
    return result;
}

上述程式碼是我們傳輸業務模組的刪除功能的service層程式碼片段,可以看到相較先前介紹的程式碼示例而言,最大的不同,就是多出來了個 entity 物件,路由資源的獲取是通過這個物件,路由資源的刪除也是通過這個物件。所有操作都只需要一行程式碼即可完成。對電路如此,對通道也是如此。

當然,別的service層程式碼也可以很方便的獲取這個entity物件,呼叫相關的方法組合實現自己的業務邏輯以實現複用。

那麼這種效果又是如何實現的呢?

概念揭示

首先我們得思考一下,作為一個類而言,最重要的本質是什麼?

答案是資料和行為。

照這個思路,對於一個業務物件,比如傳輸通道而言,進行分析:

  • 在資料層面,每個通道記錄了自身屬性資訊及其關聯的傳輸時隙、傳輸段、傳輸電路等資訊資料。
  • 在行為層面,每個通道都應該有增刪改查自身屬性、路由、下級資源、繫結/解綁上級資源等行為。

那麼在具體的業務建模時又該如何理解這兩點呢?

答案就是這張圖:
圖片描述

可以看到大體分為了三種型別的元素,

  • Context(上下文容器):

    1. 程式啟動時,開始持有各個 DataOperation 物件
    2. 程式執行時,負責建立 Entity 物件,並將相應的 DataOperation 物件裝配進 Entity 物件例項中
  • Entity(實體物件):每個用到的資源物件都生成一個 Entity 例項,以存放這個物件特有的例項資料。
  • DataOperation(資料操作物件):不同於 Entity,每類用到的資源物件對應一個相應的 DataOperation 子型別,用以封裝該類物件特有的資料操作行為

ps,雖然我這裡畫的 Entity&DataOperation 物件只是一個方框,但實際上 Entity&DataOperation 都有屬於他們自己的 N 多個適用與不同場景的介面和模板類

資料管理

筆者是個宅男,因為並木有女朋友,又不喜歡逛街,所以買東西都是網購。這就產生了一個很有意思的影響——隔三差五就要取快遞。

可是快遞點大媽不認識我,我也不是每天出門帶身份證。這就很尷尬,因為我每次總是需要和大媽圍繞 “Thehope 就是我” 解釋半天。

所以每次解釋的時候,我都在想,如果我帶了身份證或者其他類似的證件,該有多方便。

什麼是 Entity

我們一般認為,一個人有一個標識,這個標識會陪伴他走完一生(甚至死後)。這個人的物理屬性會發生變化,最後消失。他的名字可能改變,財務關係也會發生變化,沒有哪個屬性是一生不變的。然而,標識卻是永久的。我跟我5歲時是同一個人嗎?這種聽上去像是純哲學的問題在探索有效的領域模型時非常重要。

稍微變換一下問題的角度:應用程式的使用者是否關心現在的我和5歲的我是不是同一個人?
—— Eric Evans《領域驅動設計》

簡單的取快遞或許使你覺得帶有標識的物件概念並沒有什麼了不起。但是我們把場景擴充下,你不光要完成取快遞的場景,如果你需要買火車票呢?如果還要去考試呢?
伴隨著業務場景的複雜化,你會越來越發現,有個統一而清晰的標識概念的物件是多麼的方便。

再來看看 Eric Evans 在《領域驅動設計》如何介紹 Entity 這個概念的:

一些物件主要不是由它們的屬性定義的。它們實際上表示了一條“標識線”(A Thread of Identity),這條線經過了一個時間跨度,而且物件在這條線上通常經歷了多種不同的表示。

這種主要由標識定義的物件被稱作 Entity。它們的類定義、職責、屬性和關聯必須圍繞標識來變化,而不會隨著特殊屬性來變化。即使對於哪些不發生根本變化或者生命週期不太複雜的 Entity ,也應該在語義上把它們作為 Entity 來對待,這樣可以得到更清晰的模型和更健壯的實現。

確定標識

得益於我司資料庫模型管理的細緻,對於每條資源資料都可以通過他的規格型別id,以及資料庫主鍵id,獲得一個唯一確定標識特徵。

如圖:

clipboard.png

這裡舉出的 Entity 的屬性及方法僅僅是最簡單的一個示例,實際業務程式碼中的 Entity,還包括許多具備各種能力的子介面。

引入Entity

如圖:
圖片描述

可以看到 entity 物件實際上分為了兩個主要的介面,RouteEntity 和 HierarchyEntity。
其中 RouteEntity 主要規定要實現的方法是 addRoute(), 即新增路由方法
其中 HierarchyEntity 主要規定要實現的方法是 addLowerRes() 與 setUpperRes() ,即新增子資源物件和設定父資源兩種方法。

那麼這兩個介面是如何抽象建模得到的呢?

確定功能的邊界

從微觀的例項物件層面來看,因為每個例項都可能擁有完全不一樣的路由和層級關係,所以我們建模時候,用抽象出的 Entity 概念,表示哪些每個需要維護自己的屬性/路由/層次關聯資料的物件例項。

從高一層的類的層次去分析,我們可以發現,對路由的管理,對層次結構的管理,貫穿了傳輸電路,傳輸通道,傳輸段,傳輸時隙等很多業務型別。所以這個時候就需要我們在介面層面,根據業務特徵,抽象出管理不同型別資料的 Entity 型別,以實現內在關聯關係的複用。

因此我們對 Entity 介面進行細化而建立了的 RouteEntity 和 HierarchyEntity 兩個子介面,比如

  • Entity 需要維護自己的 id 標識,屬性資訊。
  • RouteEntity 就需要內部維護一個路由資料列表。
  • HierarchyEntity 就需要維護一個父物件和子資源列表。

這樣通過對不同的 Entity 管理的資料的職責與型別的進一步明確,保證在不同場景下,做到使用不同的 Entity 就可以滿足相應需求。。。。的資料前提 :P

擴充思考

既然 Entity 概念的引入是為了解決各資源物件具體例項的例項資料的儲存問題。那麼各個資源物件特有的行為操作怎麼滿足呢?比如傳輸通道和傳輸電路都有自己的表,起碼在dao層的操作就肯定不一樣,再加上各個資源自己獨特的增刪改查驗證邏輯,如果這些行為都放在 Entity 中。。。妥妥的型別爆炸啊~

另外,將資料與行為的職責耦合在一起,從領域建模的思想上就是一個容易混淆而不明智的決定。
站在微觀角度來說,每個 Entity 例項所管理的例項資料是不同的,而同一類資源的行為操作(它的方法)卻是無狀態的。
站在巨集觀角度來說,具有路由特徵的或者具有層次特徵一簇實體,有抽象共性的價值(比如都需要管理路由列表或者父子物件資訊),而涉及具體的行為實現,每種具體的資源又天然不完全相同。

小結

這裡我們可以再思考下前文貼的兩段程式碼,當我們沒有 Entity 物件時,許多應該由 Entity 進行儲存和管理的資料,就不得不通過 map/list 去實現,比如上文的第一段程式碼。這就帶來第一個弊端,不到執行時,你根本不知道這個容器記憶體放的是哪種業務規格的資源。

第二個弊端就是,當你使用 map/list 來代替本應存在的 Entity 物件時,你也拒絕了將物件的行為和資料整合在一起的可能(即不可能寫出resEntity.loadRouteData() 這樣清晰的程式碼,實現類似的邏輯只能是放在 Service 層中去實現,不過放在 Service 又增加了與具體資源邏輯的耦合)

所以,以資料和行為分離的視角,將業務物件以策略模式進行解耦,抽離成專職資料管理的 Entity 物件,以及專職行為實現的 DataOperation 簇物件,就顯得非常有價值了。

行為管理

引入 DataOperation

接下來有請出我們的 DataOperation 元素登場~

以傳輸通道為例,對於傳輸通道的所屬路由而言,常用的功能無非就是的增刪改查這幾個動作。

確定變化的邊界

還是從微觀的例項物件層面先進行分析
業務行為邏輯會因為操作的實體資料是傳輸通道A,或者傳輸通道B 而變得不同嗎?答案是不會。
正如資料庫行記錄的變化不引起表結構的變化一樣,本質上一類資源所擁有的行為和物件例項的關係,應該是一對多的。
所以只要都是傳輸通道,那麼其路由增刪改查的行為邏輯總是一致的。

結合某一著名設計原則:

找出應用中可能需要變化之處,把它們獨立出來,不要和那些不需要變化的程式碼混在一起

所以我們應該將資源不變的行為邏輯抽離出來,以保證 Entity 可以專注於自己對資料的管理義務,達到更高階的一種複用。

這也就是為什麼需要抽象 DataOperation 概念的原因之一。

進一步從類的層次去分析
不同種類的資源,其具體的資料操作行為必然是存在差別的(比如與資料庫互動時,不同資源對應的表就不同)。
所以不同種類的業務物件都必然會有自己的 DataOperation 子類,比如 TrsChannelDataOperation、TrsSegDataOperation 等,以確保每類業務物件獨特的資料操作邏輯的靈活性。

再進一步去分析
在更高的層級上去分析,靈活性我們因為實現類的細化已經具備了,那麼複用的需求又該怎麼去滿足呢?
與 Entity 物件一樣,我們可以在具體的 TrsChannelDataOperation、TrsSegDataOperation 等實體類之上,抽象出 RouteResDataOperation、HierarchyResDataOperation 等介面,規定好應該具備的方法。

Entity 物件面對需要呼叫 DataOperation 的場景,就以這些介面作為引用,從而使路由或者層次處理的業務程式碼從細節的實現中解放出來。

擴充思考

這裡可以仔細思考一下,Entity 和 DataOperation 應該在什麼時候建立好二者之間的聯絡呢?

小結

我們已經分析好了物件的資料和行為該如何建模,那麼,我們又該如何將這二者統一起來呢?

有請我們的第三類元素,Context 登場~

組裝

先來看看這樣一個例子:

汽車發動機是一種複雜的機械裝置,它由數十個零件共同協作來侶行發動機的職責 — 使軸轉動。我們可以試著設計一種發動機組,讓它自己抓取一組活塞並塞到氣缸中,火花塞也可以自己找到插孔並把自己擰進去。但這樣組裝的複雜機器可能沒有我們常見的發動機那樣可靠或高效。相反,我們用其他東西來裝配發動機。或許是一個機械師,或者是一個工業機器人。無論是機器還是人,實際上都比二者要裝配的發動機複雜。裝配零件的工作與使軸旋轉的工作完全無關。裝配者的功能只是在生產汽車時才需要,我們駕駛時並不需要機器人或機械師。由於汽車的裝配和駕駛永遠不會同事發生。因此將這兩種功能合併到同一個機制中是毫無意義的。同理,裝配複雜的複合物件的工作也最好與物件要執行的工作分開。

——Eric Evans《領域驅動設計》

與發動機小栗子相類似,程式碼中我們當然可以通過構造器的方式用到哪個物件再組裝哪個物件。不過比較一下這樣兩段程式碼:

沒有 Context 元素的程式碼:

@Transactional
public int deleteResRoute(ResIdentify operationRes, boolean protectFlag) {
  ...
  //1.獲取需要儲存物件的Entity
  OperationRouteResEntity resEntity = new TrsChannelResEntity();
  if(ResSpecConst.isChannelEntity(operationRes.getResSpcId())){
    ComponentsDefined component = new TrsChannelDataOperation();
    resEntity.initResEntityComponent(conponent);
  }
  ...
}

有了 Context 元素以後

@Transactional
public int deleteResRoute(ResIdentify operationRes, boolean protectFlag) {
  ...
  //1.獲取需要儲存物件的Entity
  OperationRouteResEntity resEntity = context.getResEntity(operationRes,OperationRouteResEntity.class);
  ...
}

是不是立竿見影的效果!

為什麼需要 Context

事實上前文對 Entity 和 DataOperation 只是領域建模的第一步,只是個雛形。而這裡的 context 物件,才是畫龍點睛的那一筆。

為什麼這麼說呢?在此之前,我也見過公司內許多其他的模組對業務邏輯做過的複雜抽象,但是因為呼叫的時候需要呼叫者親自呼叫構造器生成例項,導致使用成本太高。尤其是人員流動比較大的模組,新人天然不懂複雜的業務物件關係,這就使的業務開發人員很難持續使用業務模型物件,最終導致程式碼模型形同虛設。

物件的功能主要體現在其複雜的內部配置以及關聯方面。我們應該一直對一個物件進行提煉,直到所有與其意義或在互動中的角色無關的內容已經完全被剔除為止,一個物件在它的生命週期中要承擔大量的職責。如果再讓複雜物件負責其自身的建立,那麼職責的過載將會導致問題產生。——Eric Evans《領域驅動設計》

為了避免這樣的問題,我們有必要對 Entity 等實體物件的裝配與執行進行解耦實現,這也即是我們的 Context 元素的主要職責之一。比如上述兩段程式碼,在其他元素不做改變的情況下,僅僅出於對職責的明確而引入的 Context 元素,對業務程式碼編寫卻有了質的提升。

但實際上,正如一開始的小栗子說的,“無論是裝配機還是裝配師,都比要裝配的發動機要複雜”,我們的 context 所執行的邏輯其實也是相當複雜的,但只要對客戶(這裡的客戶指的是使用 context 的業務程式碼,下文同)幫助更大,即便再複雜也構不成不去做的理由,下面我們就來聊聊這個實質上的複雜工廠是如何運作的。

引入 Context

OperationResEntity resEntity =  context.getResEntity(resIdentify);

在 Context 中,載入操作僅有一行,看起來是不是非常清晰,非常簡單?可惜背後所需思考的問題可一點都不簡單:)

首先,我們先來思考下 Context 在這行程式碼中都完成了哪些事情吧:

public OperationResEntity getResEntity(ResIdentify identify) {
    OperationResEntity entity = getResEntity(identify.getResSpcId());
    entity.setIdentify(identify);
    return entity;
}
public OperationResEntity getResEntity(String resSpcId) {
    ResEntity entity = factory.getResEntity(resSpcId);
    if(entity instanceof OperationResEntity){
        ResComponentHolder holder = componentSource.getComponent(resSpcId);
        if(entity instanceof ContextComponentInit){
            ((ContextComponentInit)entity).initResEntityComponent(holder);
        }
    }else{
        throw new ResEntityContextException("資源規格:"+resSpcId+",實體類:"+entity.getClass().getName()+"未實現OperationResEntity介面");
    }
    return (OperationResEntity)entity;
}

上面就是 context 在獲取目標 entity 物件時所做的一些具體操作,可以看到,主要完成了這麼三件事:

  1. 獲取 Entity 例項
  2. 獲取 DataOpeartion 例項(持有於上述方法中的 hoder 物件中)
  3. 將 Entity 和 DataOperation 裝配起來

那接下來我們就仔細分析下這三個步驟應該怎麼實現吧~

獲取 Entity

在本節的一開始,我們就舉了兩個例子,對比了有 context 幫我們封裝 Entity 與 DataOperation 組合關係,與缺少 context 幫我們封裝組合關係時的區別。具體來說,優勢在與這麼兩點:

  • 簡化了業務開發人員使用 Entity 物件的成本,使其天然傾向於呼叫框架模型,便於保證後期業務領域模型的統一性
  • 減少了客戶程式碼(service)中類似 new TrsChannelDataOperation() 這樣的硬編碼,客觀上便於 service 層構建更為通用而健壯的實現

轉過頭來再思考下,我們的 Entity 物件與 DataOperation 物件又是否天然存在一種非常複雜多變的動態組合關係呢?

通常,我們在實際執行時才能確定 service 中某個 Enity 的具體規格及其應該持有的 DataOperation物件。如果由業務程式碼開發人員在呼叫處手動初始化,未免太過複雜,也不可避免的需要通過許多 If/ELSE 判斷來調整執行分支,這樣看程式碼複雜度還是居高不下,那我們前面洋洋灑灑分析那麼多又還有什麼意義呢。

實際上,類似 EntityDataOperation 之類的動態(呼叫處才知道具體的組合關係)組合關係在很多優秀的程式碼中都有應用,比如我們熟知的 Spring。

或許我們也需要借鑑一波 Spring 的處理思路 ^_^

從 Spring 延伸開來

我們都知道 Spring 最著名的一個賣點就是 IOC,也就是我們俗稱的控制反轉/依賴注入。

它將 Bean 物件中域的宣告和例項化過程解耦,將物件域例項的管理與注入責任,從開發人員移交至 Spring 容器。也正因如此,這種設計從源頭上即減少了開發人員在域例項化過程中的硬編碼,為物件間的組合提供了更為清晰便捷的實現。 ——TheHope:P

先明確了 IOC 的最大功用之一就是將物件間如何組合的責任從開發者肩上卸下,我們再繼續分析這個過程的實現中的兩個要點。

  • 首先容器必須具有建立各個物件的能力
  • 其次容器必須知道各個物件間的關聯關係是怎樣的

來,我們看看 Spring 載入 Bean 的步驟:

  • bean工廠初始化時期: 載入配置檔案 --> 初始化 bean 工廠 --> 初始化 bean 關聯關係解析器 --> 載入並快取 beanDefinition
  • bean工廠初始化完成之後: 獲取 beanDefinition --> 根據 beanDefinition 生成相應的 bean 例項 --> 初始化 bean 例項中的屬性資訊 --> 呼叫 bean 的生命週期函式

可以看到 bean 工廠初始化時,便解析好了所有 bean 的 beanDefinition ,同時維護好了一個 beanName 與 beanDefinition 的 map 對映關係,而 beanDefinition 內部儲存好了 bean 物件例項化所需的所有資訊。同時也解析好了 bean 之間的注入關係。

因此,當 beanFacory 初始化完備的時候,實際上,Spring 就已經具備獲取任意一個的 Bean 例項物件的所有基礎資訊了。

擴充思考

看到這裡你有沒有發現,Spring 載入 bean 的第二步操作,根據某種標識獲取目標物件例項的過程,不就是常規情況下一個工廠的目標作用嗎,那 Spring 在流程上要加一步初始化工廠的操作呢?Spring 的工廠與普通的工廠模式又有什麼異同呢?

為了遮蔽程式碼中 new 一個構造器之類的硬編碼,我們都學習過工廠模式,當型別變化不是很多的時候,可以使用工廠模式進行封裝,當變化再多些的時候我們可以藉助抽象類,用個抽象工廠進行封裝,將這種組合變換關係的複雜度分散到組合關係與繼承關係中去。

只是 Spring 中的 bean 比較特別,其屬性資訊變化的情況實在是太多了,甚至 bean 之間的組合關係都是不固定的,很有可能出現 A 關聯了 B ,B 又關聯了 C,C 又...這時候如果還使用抽象工廠,業務上為了支援自己需要的組合情況,每多一層組合關係,那就需要我們動態繼承抽象類,相比XML,這顯然太過複雜了。

所以 Spring 為了支援這樣的變化,也是為了職責的清晰,將 BeanFactory 生成一個具體的 bean 時所需的資訊專門抽象出來,用 XML 去由框架使用者自行維護,XML 內的資訊在 Spring 內部即轉化為一簇 BeanDefinition 物件來管理,BeanFactory 的職責,則圍繞 BeanDefinition 劃分為了兩個階段:

  • 讀取並快取 beanDefinition 資訊的 beanFactory 初始化階段
  • 使用 beanDefinition 資訊動態生成 bean 例項的後 beanFactory 初始化階段

小結

如此這般,ABC問題中最為靈活且複雜的關聯關係,即由工廠/抽象工廠中預先設計轉化為了框架使用者自行維護。嘿,好一招騰籠換鳥~

組裝Entity

一不小心就講多了,我們的 EntityDataOperation 的關聯關係遠沒有那麼複雜,不過我們可以仿照 Spring 建立 BeanNameBeanDefinition 對映關係的思想,在容器啟動時將我們的 EntityDataOperation 組合關係載入好,實現後續使用時,獲取確定的 Entity 同時容器自己幫我們注入好需要的 DataOperation

待續

小結

待續。。。

參考資料

Eric Evans的《Domain-Driven Design領域驅動設計》

聯絡作者

zhihu.com
segmentfault.com
oschina.net

相關文章