软件系统解耦:理解依赖关系
轉自:https://zhuanlan.zhihu.com/p/31391535
在實際工作中,復雜度上來后,各模塊之間錯綜復雜,調用關系網千頭萬緒。即使有各種設計模式做指導,做出合理的設計也并不容易。程序員天天疲于應對層出不窮的變化,在不斷緊逼的deadline壓力下,面對巨大的重構工作量往往感到心有余而力不足。
系統復雜度的根源除了業務本身的復雜度,就是設計了不恰當的耦合關系。本文試圖探討依賴關系的主要類型,并總結應對依賴的編程范式。
耦合:依賴和變化
耦合是一個有歧義的詞語(為什么“耦合”概念該要摒棄)。當我們說“A和B耦合”的時候,我們是想表達A和B之間有緊密的聯系。具體是什么,不容易講清楚。
在我看來,耦合至少包含了兩個方面的含義:依賴和變化。
業務邏輯固有的復雜度決定了,模塊之間必然存在著依賴。規范模塊間的依賴關系,就是梳理業務復雜度的過程。最終的成果反映在代碼中,代表了對業務復雜度的一種認識。這種認識隨著業務需求的變化而演化,隨著設計者的能力提升而深化。依賴不能被消除,但是可以被優化。探討一些應對的范式有助于規避已知的陷阱。
變化則來源于兩個方面:發展中的用戶需求,完善中的系統模型。用戶的需求是我們努力的方向。系統模型則代表了我們對需求的理解,是經驗和智慧的結晶。一個完善的系統模型,表達能力要足夠強,對業務的適應能力要足夠強。變化,意味著工作量,意味著成本,應該盡量降低。如果我們把“系統變更”和“業務需求變更”寫成函數:
我們希望自變量不變的情況下,“系統變更”這個函數值越小越好。特別是“業務需求變更”在當前系統設計假設條件下產生調整的時候,“系統變更”應該局限在很小的范圍內。
依賴的種類
在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。但是在實踐中根本不是這么回事。
我們經常聽說,只要把接口設計得“正交”“緊湊”,就能保證接口的穩定。但是,在實踐中,混亂的繼承關系隨處可見。大多數程序員都停留在利用繼承思維構造業務邏輯關系,并盡快實現功能。極少有能力有時間檢視繼承關系是否恰當。正確使用繼承對程序員的要求太高了。
當重新審視繼承的時候我們發現,繼承的父類和子類之間實際形成了一種雙向依賴。繼承和多態不僅規定了函數的名稱、參數、返回類型,還規定了類的繼承關系,是一種強耦合(https://cloud.github.com/downloads/chenshuo/documents/CppPractice.pdf, 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 illustrated,C++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的成員函數時,還要考慮該成員函數是不是在其他地方被用到。
總結
以上是生活随笔為你收集整理的软件系统解耦:理解依赖关系的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: tplink路由器用app怎么设置上网t
- 下一篇: Win7系统怎么旋转桌面电脑屏幕如何翻转