軟件系統解耦:理解依賴關係

轉自:https://zhuanlan.zhihu.com/p/31391535html

 

在實際工做中,複雜度上來後,各模塊之間錯綜複雜,調用關係網千頭萬緒。即便有各類設計模式作指導,作出合理的設計也並不容易。程序員每天疲於應對層出不窮的變化,在不斷緊逼的deadline壓力下,面對巨大的重構工做量每每感到愛莫能助。linux

系統複雜度的根源除了業務自己的複雜度,就是設計了不恰當的耦合關係。本文試圖探討依賴關係的主要類型,並總結應對依賴的編程範式。git

耦合:依賴和變化

耦合是一個有歧義的詞語(爲何「耦合」概念該要摒棄)。當咱們說「A和B耦合」的時候,咱們是想表達A和B之間有緊密的聯繫。具體是什麼,不容易講清楚。程序員

在我看來,耦合至少包含了兩個方面的含義:依賴和變化。github

業務邏輯固有的複雜度決定了,模塊之間必然存在着依賴。規範模塊間的依賴關係,就是梳理業務複雜度的過程。最終的成果反映在代碼中,表明了對業務複雜度的一種認識。這種認識隨着業務需求的變化而演化,隨着設計者的能力提高而深化。依賴不能被消除,可是能夠被優化。探討一些應對的範式有助於規避已知的陷阱。編程

變化則來源於兩個方面:發展中的用戶需求,完善中的系統模型。用戶的需求是咱們努力的方向。系統模型則表明了咱們對需求的理解,是經驗和智慧的結晶。一個完善的系統模型,表達能力要足夠強,對業務的適應能力要足夠強。變化,意味着工做量,意味着成本,應該儘可能下降。若是咱們把「系統變動」和「業務需求變動」寫成函數:設計模式

SystemChange = f(RequirementChange)

咱們但願自變量不變的狀況下,「系統變動」這個函數值越小越好。特別是「業務需求變動」在當前系統設計假設條件下產生調整的時候,「系統變動」應該侷限在很小的範圍內。網絡

依賴的種類

在UML類圖中,依賴關係被標記爲<<use>>。A依賴B意味着,A模塊能夠調用B模塊暴露的API,但B模塊毫不容許調用A模塊的API(IBM Knowledge Center)。閉包

在類圖中,依賴關係是指更改一個類(供應者)可能會致使更改另外一個類(客戶)。供應者是獨立的,這是由於更改使用者並不會影響供應者。異步

例如,Cart 類依賴於 Product 類,由於 Product 類被用做 Cart 類中的「添加」操做的參數。在類圖中,依賴關係是從 Cart 類指向 Product 類。換句話說,Cart 類是使用者元素,而 Product 類是供應者元素。更改 Product 類可能會致使更改 Cart 類。

在類圖中,C/C++ 應用程序中的依賴關係將兩個類鏈接起來,以指示這兩個類之間存在鏈接,而且該鏈接比關聯關係更加具備臨時性。依賴關係是指使用者類執行下列其中一項操做:

  • 臨時使用具備全局做用域的供應者類,
  • 將供應者類臨時用做它的某個操做的參數,
  • 將供應者類臨時用做它的某個操做的局部變量,
  • 將消息發送至供應者類。

模塊之間產生依賴的主要方式是數據引用和函數調用。檢驗模塊依賴程度是否合理,則主要看「變動」的容易程度。軟件模塊之間的調用方式能夠分爲三種:同步調用、回調和異步調用(異步消息的傳遞-回調機制)。同步調用是一種單向依賴關係。回調是一種雙向依賴關係。異步調用每每伴隨着消息註冊操做,因此本質上也是一種雙向依賴。

三種調用方式

有一種觀點將「依賴」直接總結爲人腦中的依賴(爲何「耦合」概念該要摒棄),我很是認同。文中提到:

只要程序員編寫模塊A時,須要知道模塊B的存在,須要知道模塊B提供哪些功能,A對B依賴就存在。甚至就算經過所謂的依賴注入、命名查找之類的「解耦」手段,讓模塊A不須要import B或者include "B.h",人腦中的依賴仍舊一點都沒有變化。惟一的做用是會騙事後文會提到的代碼打分工具,讓工具誤覺得兩個模塊間沒有依賴。

代碼的複雜度更主要的體如今閱讀和理解,若是隻是糾結於編譯器所看到的依賴,實在是分錯了主次,誤入了歧途。

單向依賴與單一職責原則(SRP)

單向依賴是最簡單的依賴。

單向依賴

上述都是單向依賴的例子。其中,(1)是最理想的狀況。當邏輯變複雜後,單個模塊每每承擔了過多的責任。即便模塊之間能夠保持簡單的單向關係,模塊內部各行爲之間卻造成高強度的耦合總體。根據單一職責原則(SRP),這樣的模塊也是難以維護的,咱們須要對模塊作拆分。

在有多個模塊的狀況下,(2)的依賴關係顯然要好於(3),由於在(2)中模塊的依賴關係要比(3)少。這樣的解釋過於抽象,咱們用遊戲中比較典型的一個應用場景來講明一下。

場景對象管理器GameObjectManager,管理着場景對象GameObjectInstance,而場景對象的構造須要資源AssetStore的支持。他們的調用關係,用(2)和(3)的模式分別實現一遍:

//(2) GameObjectManager從AssetStore取資源數據,而後調用GameObjectInstnce的初始化流程 class GameObjectManager{ public: AssetForGameObject* GetAsset(DWORD dwID){m_Asset.GetAsset(dwID);} GameObjectInstance* Create(DWORD dwAssetID){ AssetForGameObject* pAsset = GetAsset(dwAssetID); return m_GameObjects[dwNewID] = new GameObjectInstance(pAsset); } void TickGameObject(){foreach(auto go = m_GameObjects) go.Tick();} private: AssetStore m_Asset; map<DWORD, GameObjectInstance*> m_GameObjects; }; 

 

//(3) GameObjectInstance本身調用AssetStore的方法取資源數據,作初始化 class GameObjectManager{ public: GameObjectInstance* Create(AssetStore* pAssets, DWORD dwAssetID){ GameObjectInstance* pGo = new GameObjectInstance(); pGo->Init(pAssets, dwAssetID); return m_GameObjects[dwNewID] = pGo; } private: AssetStore m_Asset; map<DWORD, GameObjectInstance*> m_GameObjects; }; class GameObjectInstance{ public: void Init(AssetStore* pAssets, DWORD dwAssetID){ m_Data = pAssets->GetAsset(dwAssetID); } }; 

GameObjectInstance只須要依賴於AssetForGameObject,可是在依賴關係(3)中,卻要依賴於一個範圍更大的概念AssetStore。

將雙向依賴轉換爲單向依賴

雙向依賴關係在網絡遊戲中也是比比皆是。咱們來看一個雙向依賴的典型例子:網絡數據包的收發。若是把「上層業務邏輯」和「底層網絡鏈接」看做兩個模塊。在發數據包的過程當中,業務邏輯調用底層發送接口發送數據。業務邏輯依賴於底層網絡鏈接。而在收數據包的時候,數據首先在網絡鏈接模塊接收,再分派到不一樣的業務邏輯。上層業務邏輯和底層網絡鏈接造成了一種自然的雙向依賴關係。

class Logic{ public: void SendMessage(byte* pbyBuffer, size_t uLen){ m_pConnection->Send(pbyBuffer, uLen); } void HandleMessage(byte* pbyBuffer, size_t uLen){/*...*/} private: Connection* m_pConnection; }; class Connection{ public: void SetLogic(Logic* pLogic){m_pLogic = pLogic;} void SendMessage(byte* pbyBuffer, size_t uLen){/*...*/} void RecvMessage(byte* pbyBuffer, size_t uLen){ m_pLogic->HandleMessage(pbyBuffer, uLen); } private: Logic* m_pLogic; }; 

用最天然的方式,咱們寫出了上面的代碼。這實際上是用「依賴注入」實現的回調。容易發現,當Logic增減成員變量或成員函數,Connection就須要從新編譯,甚至從新調整代碼。這樣的耦合度是沒法接受的。

咱們能夠嘗試用"Don't call us, we will call you"把雙向依賴轉換爲單向依賴。簡單來講,當網絡鏈接收到數據包後,能夠先放到一個存儲區。等調度到業務邏輯的時候,業務邏輯主動去取數據並處理。在存儲區存儲一個數據,就至關於存儲一個對業務邏輯的調用請求。這樣就演變爲了單向依賴關係(3),模塊C就至關於存儲區。須要說明的是,存儲區並不必定必需要獨立出來一個模塊,徹底能夠維護在模塊B中。此種情形,A能夠直接向B要數據。

並非全部的雙向依賴關係均可以很容易的轉換爲單向依賴。上述例子中,若是業務邏輯來不及處理數據包,網絡鏈接層就要維護一個數據列表。這增長了存儲開銷。並且有時候把數據延遲處理是不合適的。代碼也所以變得晦澀難懂,難以維護。若是致使這種結果,那就與咱們轉換依賴關係的初衷背道而馳了。

弱化雙向依賴:回調與中間層

通常狀況下,爲了弱化雙向依賴的影響,咱們能夠增長一箇中間層。雖然調用鏈路是從「網絡鏈接」又回到了「業務邏輯」,可是因爲中間層的存在,變化被隔離,原先很強的依賴關係變弱了。如下介紹四種典型的中間層。

經過添加穩定的中間層隔離變化

須要說明的是,上述所說的中間層,偏向於概念,在代碼實現中並不必定要獨立成一個單獨的模塊。但爲了方便,仍是借用模塊(如上圖中的模塊C)來表述。

1)接口與繼承

咱們很天然想到,依賴注入可使用接口。當Connection依賴的是Logic的接口(假定爲ILogic),雖然Logic變動,只要ILogic不變,就不會影響Connection。可是在實踐中根本不是這麼回事。

咱們常常據說,只要把接口設計得「正交」「緊湊」,就能保證接口的穩定。可是,在實踐中,混亂的繼承關係隨處可見。大多數程序員都停留在利用繼承思惟構造業務邏輯關係,並儘快實現功能。極少有能力有時間檢視繼承關係是否恰當。正確使用繼承對程序員的要求過高了。

當從新審視繼承的時候咱們發現,繼承的父類和子類之間實際造成了一種雙向依賴。繼承和多態不只規定了函數的名稱、參數、返回類型,還規定了類的繼承關係,是一種強耦合(, p45)。接口約定了外部調用的規範,繼承類必須按照這些規範去實現。只要規範不變,繼承類的實現能夠調整而不將影響傳遞出去。糟糕的是,無論是規範仍是實現,都基本上不可能一開始就肯定好。當變化發生的時候,接口類和繼承類都須要作大量的修改,而這些修改也很容易影響到全部使用接口的那些模塊。

穩定的繼承關係能夠提供良好的擴展性,也能夠避免把相同的邏輯寫獲得處都是(DRY原則)。可是濫用繼承也會是災難性的。在"Is-A"和"Has-A"的取捨中,要謹慎行事。

2)Delegation

一個對調用者和被調用者約束較小的方式是代理(Delegation)。所謂代理,就是將依賴轉移到較穩定的代理類上。經過一個仿函數,調用不一樣類中有相同簽名的方法。一個典型的代理類的例子以下所示(The Impossibly Fast C++ Delegates)。其最第一版本須要對每種參數作不一樣處理。後來發展出來一種更通常的代理方式(C++ Delegates On Steroids),能夠接受任意類型和任意數量的參數。

class delegate { public: delegate() : object_ptr(0), stub_ptr(0){} template <class T, void (T::*TMethod)(int)> static delegate from_method(T* object_ptr){ delegate d; d.object_ptr = object_ptr; d.stub_ptr = &method_stub<T, TMethod>; // #1 return d; } void operator()(int a1) const{ return (*stub_ptr)(object_ptr, a1); } private: typedef void (*stub_type)(void* object_ptr, int); void* object_ptr; stub_type stub_ptr; template <class T, void (T::*TMethod)(int)> static void method_stub(void* object_ptr, int a1){ T* p = static_cast<T*>(object_ptr); return (p->*TMethod)(a1); // #2 } }; 

3) Bind/Function

Bind/Function機制不要求被綁定的類有任何繼承規範。其更像是C中的函數指針,比代理類要更簡單。除了和代理類同樣須要函數簽名一致,不須要程序員額外維護一個類。

如今C++11提供了很好用的bind/function(Bind illustratedC++11: std::function and std::bind)。咱們能夠將上述的數據包處理回調重寫以下:

class Logic{ public: void Init(){ m_pConnection->SetCallbackFunc(std::bind(HandleMessage), this); } void SendMessage(byte* pbyBuffer, size_t uLen){ m_pConnection->Send(pbyBuffer, uLen); } void HandleMessage(byte* pbyBuffer, size_t uLen){/*...*/} private: Connection* m_pConnection; }; class Connection{ public: void SetCallbackFunc(Logic* pLogic){m_pLogic = pLogic;} void SendMessage(byte* pbyBuffer, size_t uLen){/*...*/} void RecvMessage(byte* pbyBuffer, size_t uLen){ m_callbackfunc(pbyBuffer, uLen); } private: func* m_callbackfunc; }; 

4) Lambda與閉包

嚴格來講,bind/function的實現也屬於閉包。這裏把Lambda/Closure單獨列出來是想強調Lambda表達式能夠經過匿名函數把相同的事作的更簡潔。比起bind一個成員函數,直接bind一個在局部空間定義的lambda表達式給程序員帶來的思惟負擔更小。

畢竟,修改lambda表達式時,能夠清楚知道影響的範圍。而修改被bind的成員函數時,還要考慮該成員函數是否是在其餘地方被用到。

相關文章
相關標籤/搜索