DCI in C++ 本文講解的C++的DCI編程框架,目前作為 "ccinfra" 的一個組件提供,可訪問 "https://github.com/MagicBowen/ccinfra" 獲取具體源碼。ccinfra中的DCI框架原創者是袁英傑先生(Thoughtworks),我們在兩個大型電信系 ...
DCI in C++
本文講解的C++的DCI編程框架,目前作為ccinfra的一個組件提供,可訪問https://github.com/MagicBowen/ccinfra獲取具體源碼。ccinfra中的DCI框架原創者是袁英傑先生(Thoughtworks),我們在兩個大型電信系統的重構過程中大面積地使用了該技術,取得了非常好的效果,在此我將其整理出來。由於文筆有限,拙於表達,希望不足之處英傑見諒!
DCI是一種面向對象軟體架構模式,它可以讓面向對象更好地對數據和行為之間的關係進行建模從而更容易被人理解。DCI目前廣泛被作為對DDD(領域驅動開發)的一種發展和補充,用於基於面向對象的領域建模。DCI建議將軟體的領域核心代碼分為Context、Interactive和Data層。Context層用於處理由外部UI或者消息觸發業務場景,每個場景都能找對一個對應的context,其作為理解系統如何處理業務流程的起點。Data層用來描述系統是什麼(What the system is?),在該層中採用領域驅動開發中描述的建模技術,識別系統中應該有哪些領域對象以及這些對象的生命周期和關係。而DCI最大的發展則在於Interactive層,DCI認為應該顯示地對領域對象在每個context中所扮演的角色role
進行建模,role代表了領域對象服務於context時應該具有的業務行為。正是因為領域對象的業務行為只有在去服務於某一context時才會具有意義,DCI認為對role的建模應該是面向context的,屬於role的方法不應該強塞給領域對象,否則領域對象就會隨著其支持的業務場景(context)越來越多而變成上帝類。但是role最終還是要操作數據,那麼role和領域對象之間應該存在一種註入(cast)關係。當context被觸發的時候,context串聯起一系列的role進行交互完成一個特定的業務流程。Context應該決定在當前業務場景下每個role的扮演者(領域對象),context中僅完成領域對象到role的註入或者cast,然後讓role互動以完成對應業務邏輯。基於上述DCI的特點,DCI架構使得軟體具有如下好處:
- 清晰的進行了分層使得軟體更容易被理解。
- Context是儘可能薄的一層。Context往往被實現得無狀態,只是找到合適的role,讓role交互起來完成業務邏輯即可。但是簡單並不代表不重要,顯示化context層正是為人去理解軟體業務流程提供切入點和主線。
- Data層描述系統有哪些領域概念及其之間的關係,該層專註於領域對象和之間關係的確立,讓程式員站在對象的角度思考系統,從而讓系統是什麼更容易被理解。
- Interactive層主要體現在對role的建模,role是每個context中複雜的業務邏輯的真正執行者。Role所做的是對行為進行建模,它聯接了context和領域對象!由於系統的行為是複雜且多變的,role使得系統將穩定的領域模型層和多變的系統行為層進行了分離,由role專註於對系統行為進行建模。該層往往關註於系統的可擴展性,更加貼近於軟體工程實踐,在面向對象中更多的是以類的視角進行思考設計。
- 顯示的對role進行建模,解決了面向對象建模中充血和貧血模型之爭。DCI通過顯示的用role對行為進行建模,同時讓role在context中可以和對應的領域對象進行綁定(cast),從而既解決了數據邊界和行為邊界不一致的問題,也解決了領域對象中數據和行為高內聚低耦合的問題。
面向對象建模面臨的一個棘手問題是數據邊界和行為邊界往往不一致。遵循模塊化的思想,我們通過類將行為和其緊密耦合的數據封裝在一起。但是在複雜的業務場景下,行為往往跨越多個領域對象,這樣的行為放在某一個對象中必然導致別的對象需要向該對象暴漏其內部狀態。所以面向對象發展的後來,領域建模出現兩種派別之爭,一種傾向於將跨越多個領域對象的行為建模在所謂的service中(見DDD中所描述的service建模元素)。這種做法使用過度經常導致領域對象變成只提供一堆get方法的啞對象,這種建模導致的結果被稱之為貧血模型。而另一派則堅定的認為方法應該屬於領域對象,所以所有的業務行為仍然被放在領域對象中,這樣導致領域對象隨著支持的業務場景變多而變成上帝類,而且類內部方法的抽象層次很難一致。另外由於行為邊界很難恰當,導致對象之間數據訪問關係也比較複雜。這種建模導致的結果被稱之為充血模型。
在DCI架構中,如何將role和領域對象進行綁定,根據語言特點做法不同。對於動態語言,可以在運行時進行綁定。而對於靜態語言,領域對象和role的關係在編譯階段就得確定。DCI的論文《www.artima.com/articles/dci_vision.html》中介紹了C++採用模板Trait的技巧進行role和領域對象的綁定。但是由於在複雜的業務場景下role之間會存在大量的行為依賴關係,如果採用模板技術會產生複雜的模板交織代碼從而讓工程層面變得難以實施。正如我們前面所講,role主要對複雜多變的業務行為進行建模,所以role需要更加關註於系統的可擴展性,更加貼近軟體工程,對role的建模應該更多地站在類的視角,而面向對象的多態和依賴註入則可以相對更輕鬆地解決此類問題。另外,由於一個領域對象可能會在不同的context下扮演多種角色,這時領域對象要能夠和多種不同類型的role進行綁定。對於所有這些問題,ccinfra提供的DCI框架採用了多重繼承來描述領域對象和其支持的role之間的綁定關係,同時採用了在多重繼承樹內進行關係交織來進行role之間的依賴關係描述。這種方式在C++中比採用傳統的依賴註入的方式更加簡單高效。
對於DCI的理論介紹,以及如何利用DCI框架進行領域建模,本文就介紹這些。後面主要介紹如何利用ccinfra中的DCI框架來實現和拼裝role以完成這種組合式編程。
下麵假設一種場景:模擬人和機器人製造產品。人製造產品會消耗吃飯得到的能量,缺乏能量後需要再吃飯補充;而機器人製造產品會消耗電能,缺乏能量後需要再充電。這裡人和機器人在工作時都是一名worker(扮演的角色),工作的流程是一樣的,但是區別在於依賴的能量消耗和獲取方式不同。
DEFINE_ROLE(Energy)
{
ABSTRACT(void consume());
ABSTRACT(bool isExhausted() const);
};
struct HumanEnergy : Energy
{
HumanEnergy()
: isHungry(false), consumeTimes(0)
{
}
private:
OVERRIDE(void consume())
{
consumeTimes++;
if(consumeTimes >= MAX_CONSUME_TIME)
{
isHungry = true;
}
}
OVERRIDE(bool isExhausted() const)
{
return isHungry;
}
private:
enum
{
MAX_CONSUME_TIME = 10,
};
bool isHungry;
U8 consumeTimes;
};
struct ChargeEnergy : Energy
{
ChargeEnergy() : percent(0)
{
}
void charge()
{
percent = FULL_PERCENT;
}
private:
OVERRIDE(void consume())
{
if(percent > 0)
percent -= CONSUME_PERCENT;
}
OVERRIDE(bool isExhausted() const)
{
return percent == 0;
}
private:
enum
{
FULL_PERCENT = 100,
CONSUME_PERCENT = 1
};
U8 percent;
};
DEFINE_ROLE(Worker)
{
Worker() : produceNum(0)
{
}
void produce()
{
if(ROLE(Energy).isExhausted()) return;
produceNum++;
ROLE(Energy).consume();
}
U32 getProduceNum() const
{
return produceNum;
}
private:
U32 produceNum;
private:
USE_ROLE(Energy);
};
上面代碼中使用了DCI框架中三個主要的語法糖:
DEFINE_ROLE
:用於定義role。DEFINE_ROLE
的本質是創建一個包含了虛析構的抽象類,但是在DCI框架裡面使用這個命名更具有語義。DEFINE_ROLE
定義的類中需要至少包含一個虛方法或者使用了USE_ROLE
聲明依賴另外一個role。USE_ROLE
:在一個類裡面聲明自己的實現依賴另外一個role。ROLE
:當一個類聲明中使用了USE_ROLE
聲明依賴另外一個類XXX後,則在類的實現代碼裡面就可以調用ROLE(XXX)
來引用這個類去調用它的成員方法。
上面的例子中用DEFINE_ROLE
定義了一個名為Worker
的role(本質上是一個類),Worker
用USE_ROLE
聲明它的實現需要依賴於另一個role:Energy
,Worker
在它的實現中調用ROLE(Energy)
訪問它提供的介面方法。Energy
是一個抽象類,有兩個子類HumanEnergy
和ChargeEnergy
分別對應於人和機器人的能量特征。上面是以類的形式定義的各種role,下麵我們需要將role和領域對象關聯並將role之間的依賴關係在領域對象內完成正確的交織。
struct Human : Worker
, private HumanEnergy
{
private:
IMPL_ROLE(Energy);
};
struct Robot : Worker
, ChargeEnergy
{
private:
IMPL_ROLE(Energy);
};
上面的代碼使用多重繼承完成了領域對象對role的組合。在上例中Human
組合了Worker
和HumanEnergy
,而Robot
組合了Worker
和ChargeEnergy
。最後在領域對象的類內還需要完成role之間的關係交織。由於Worker
中聲明瞭USE_ROLE(Energy)
,所以當Human
和Robot
繼承了Worker
之後就需要顯示化Energy
從哪裡來。有如下幾種主要的交織方式:
IMPL_ROLE
: 對上例,如果Energy
的某一個子類也被繼承的話,那麼就直接在交織類中聲明IMPL_ROLE(Energy)
。於是當Worker
工作時所找到的ROLE(Energy)
就是在交織類中所繼承的具體Energy
子類。IMPL_ROLE_WITH_OBJ
: 當持有被依賴role的一個引用或者成員的時候,使用IMPL_ROLE_WITH_OBJ
進行關係交織。假如上例中Human
類中有一個成員:HumanEnergy energy
,那麼就可以用IMPL_ROLE_WITH_OBJ(Energy, energy)
來聲明交織關係。該場景同樣適用於類內持有的是被依賴role的指針、引用的場景。DECL_ROLE
: 自定義交織關係。例如對上例在Human
中定義一個方法DECL_ROLE(Energy){ // function implementation}
,自定義Energy
的來源,完成交織。
當正確完成role的依賴交織工作後,領域對象類就可以被實例化了。如果沒有交織正確,一般會出現編譯錯誤。
TEST(...)
{
Human human;
SELF(human, Worker).produce();
ASSERT_EQ(1, SELF(human, Worker).getProduceNum());
Robot robot;
SELF(robot, ChargeEnergy).charge();
while(!SELF(robot, Energy).isExhausted())
{
SELF(robot, Worker).produce();
}
ASSERT_EQ(100, SELF(robot, Worker).getProduceNum());
}
如上使用SELF
將領域對象cast到對應的role上訪問其介面方法。註意只有被public繼承的role才可以從領域對象上cast過去,private繼承的role往往是作為領域對象的內部依賴(上例中human
不能做SELF(human, Energy)
轉換,會編譯錯誤)。
通過對上面例子中使用DCI的方式進行分析,我們可以看到ccinfra提供的DCI實現方式具有如下特點:
通過多重繼承的方式,同時完成了類的組合以及依賴註入。被繼承在同一顆繼承樹上的類天然被組合在一起,同時通過
USE_ROLE
和IMPL_ROLE
的這種編織虛函數表的方式完成了這些類之間的互相依賴引用,相當於完成了依賴註入,只不過這種依賴註入成本更低,表現在C++上來說就是避免了在類中去定義依賴註入的指針以及通過構造函數進行註入操作,而且同一個領域對象類的所有對象共用類的虛表,所以更加節省記憶體。提供一種組合式編程風格。
USE_ROLE
可以聲明依賴一個具體類或者抽象類。當一個類的一部分有復用價值的時候就可以將其拆分出來,然後讓原有的類USE_ROLE
它,最後通過繼承再組合在一起。當一個類出現新的變化方向時,就可以讓當前類USE_ROLE
一個抽象類,最後通過繼承抽象類的不同子類來完成對變化方向的選擇。最後如果站在類的視圖上看,我們得到的是一系列可被覆用的類代碼素材庫;站在領域對象的角度上來看,所謂領域對象只是選擇合適自己的類素材,最後完成組合拼裝而已(見下麵的類視圖和DCI視圖)。類視圖:
DCI視圖:
每個領域對象的結構類似一顆向上生長的樹(見上DCI視圖)。Role作為這顆樹的葉子,實際上並不區分是行為類還是數據類,都儘量設計得高內聚低耦合,採用
USE_ROLE
的方式聲明互相之間的依賴關係。領域對象作為樹根採用多重繼承完成對role的組合和依賴關係交織,可以被外部使用的role被public繼承,我們叫做“public role”(上圖中空心圓圈表示),而只在樹的內部被調用的role則被private繼承,叫做“private role”(上圖中實心圓圈表示)。當context需要調用某一領域對象時,必須從領域對象cast到對應的public role上去調用,不會出現傳統教科書上所說的多重繼承帶來的二義性問題。採用這種多重繼承的方式組織代碼,我們會得到一種小類大對象的結構。所謂小類,指的是每個role的代碼是為了完成組合和擴展性,是站在類的角度去解決工程性問題(面向對象),一般都相對較小。而當不同的role組合到一起形成大領域對象後,它卻可以讓我們站在領域的角度去思考問題,關註領域對象整體的領域概念、關係和生命周期(基於對象)。大對象的特點同時極大的簡化了領域對象工廠的成本,避免了繁瑣的依賴註入,並使得記憶體規劃和管理變得簡單;程式員只用考慮領域對象整體的記憶體規劃,對領域對象上的所有role整體記憶體申請和釋放,避免了對一堆小的拼裝類對象的記憶體管理,這點對於嵌入式開發非常關鍵。
多重繼承關係讓一個領域對象可以支持哪些角色(role),以及一個角色可由哪些領域對象扮演變得顯示化。這種顯示化關係對於理解代碼和靜態檢查都非常有幫助。
上述在C++中通過多重繼承來實現DCI架構的方式,是一種幾近完美的一種方式(到目前為止的個人經驗)。如果非要說缺點,只有一個,就是多重繼承造成的物理依賴污染問題。由於C++中要求一個類如果繼承了另一個類,當前類的文件里必須包含被繼承類的頭文件。這就導致了領域對象類的聲明文件裡面事實上包含了所有它繼承下來的role的頭文件。在context中使用某一個role需用領域對象做cast,所以需要包含領域對象類的頭文件。那麼當領域對象上的任何一個role的頭文件發生了修改,所有包含該領域對象頭文件的context都得要重新編譯,無關該context是否真的使用了被修改的role。解決該問題的一個方法就是再建立一個抽象層專門來做物理依賴隔離。例如對上例中的Human
,可以修改如下:
DEFINE_ROLE(Human)
{
HAS_ROLE(Worker);
};
struct HumanObject : Human
, private Worker
, private HumanEnergy
{
private:
IMPL_ROLE(Worker);
IMPL_ROLE(Energy);
};
struct HumanFactory
{
static Human* create()
{
return new HumanObject;
}
};
TEST(...)
{
Human* human = HumanFactory::create();
human->ROLE(Worker).produce();
ASSERT_EQ(1, human->ROLE(Worker).getProduceNum());
delete human;
}
為了屏蔽物理依賴,我們把Human
變成了一個純介面類,它裡面聲明瞭該領域對象可被context訪問的所有public role,由於在這裡只用前置聲明,所以無需包含任何role的頭文件。而對真正繼承了所有role的領域對象HumanObject
的構造隱藏在工廠裡面。Context中持有從工廠中創建返回的Human
指針,於是context中只用包含Human
的頭文件和它實際要使用的role的頭文件,這樣和它無關的role的修改不會引起該context的重新編譯。
事實上C++語言的RTTI特性同樣可以解決上述問題。該方法需要領域對象額外繼承一個公共的虛介面類。Context持有這個公共的介面,利用dynamic_cast
從公共介面往自己想要使用的role上去嘗試cast。這時context只用包含該公共介面以及它僅使用的role的頭文件即可。修改後的代碼如下:
DEFINE_ROLE(Actor)
{
};
struct HumanObject : Actor
, Worker
, private HumanEnergy
{
private:
IMPL_ROLE(Energy);
};
struct HumanFactory
{
static Actor* create()
{
return new HumanObject;
}
};
TEST(...)
{
Actor* actor = HumanFactory::create();
Worker* worker = dynamic_cast<Worker*>(actor);
ASSERT_TRUE(__notnull__(worker));
worker->produce();
ASSERT_EQ(1, worker->getProduceNum());
delete actor;
}
上例中我們定義了一個公共類Actor
,它沒有任何代碼,但是至少得有一個虛函數(RTTI要求),使用DEFINE_ROLE
定義的類會自動為其增加一個虛析構函數,所以Actor
滿足要求。最終領域對象繼承Actor
,而context僅需持有領域對象工廠返回的Actor
的指針。Context中通過dynamic_cast
將actor
指針轉型成領域對象身上其它有效的public role,dynamic_cast
會自動識別這種轉換是否可以完成,如果在當前Actor
的指針對應的對象的繼承樹上找不到目標類,dynamic_cast
會返回空指針。上例中為了簡單把所有代碼寫到了一起。真實場景下,使用Actor
和Worker
的context的實現文件中僅需要包含Actor
和Worker
的頭文件即可,不會被HumanObject
繼承的其它role物理依賴污染。
通過上例可以看到使用RTTI
的解決方法是比較簡單的,可是這種簡單是有成本的。首先編譯器需要在虛表中增加很多類型信息,以便可以完成轉換,這會增加目標版本的大小。其次dynamic_cast
會隨著對象繼承關係的複雜變得性能底下。所以C++編譯器對於是否開啟RTTI
有專門的編譯選項開關,由程式員自行進行取捨。
最後我們介紹ccinfra的DCI框架中提供的一種RTTI
的替代工具,它可以模仿完成類似dynamic_cast
的功能,但是無需在編譯選項中開啟RTTI
功能。這樣當我們想要在代碼中小範圍使用該特性的時候,就不用承擔整個版本都因RTTI
帶來的性能損耗。利用這種替代技術,可以讓程式員精確地在開發效率和運行效率上進行控制和平衡。
UNKNOWN_INTERFACE(Worker, 0x1234)
{
// Original implementation codes of Worker!
};
struct HumanObject : dci::Unknown
, Worker
, private HumanEnergy
{
BEGIN_INTERFACE_TABLE()
__HAS_INTERFACE(Worker)
END_INTERFACE_TABLE()
private:
IMPL_ROLE(Energy);
};
struct HumanFactory
{
static dci::Unknown* create()
{
return new HumanObject;
}
};
TEST(...)
{
dci::Unknown* unknown = HumanFactory::create();
Worker* worker = dci::unknown_cast<Worker>(unknown);
ASSERT_TRUE(__notnull__(worker));
worker->produce();
ASSERT_EQ(1, worker->getProduceNum());
delete unknown;
}
通過上面的代碼,可以看到ccinfra的dci框架中提供了一個公共的介面類dci::Unknown
,該介面需要被領域對象public繼承。能夠從dci::Unknown
被轉化到的目標role需要用UNKNOWN_INTERFACE
來定義,參數是類名以及一個32位的隨機數。這個隨機數需要程式員自行提供,保證全局不重覆(可以寫一個腳本自動產生不重覆的隨機數,同樣可以用腳本自動校驗代碼中已有的是否存在重覆,可以把校驗腳本作為版本編譯檢查的一部分)。領域對象類繼承的所有由UNKNOWN_INTERFACE
定義的role都需要在BEGIN_INTERFACE_TABLE()
和END_INTERFACE_TABLE()
中由__HAS_INTERFACE
顯示註冊一下(參考上面代碼中HumanObject
的寫法)。最後,context持有領域對象工廠返回的dci::Unknown
指針,通過dci::unknown_cast
將其轉化目標role使用,至此這種機制和dynamic_cast
的用法基本一致,在無法完成轉化的情況下會返回空指針,所以安全起見需要對返回的指針進行校驗。
上述提供的RTTI替代手段,雖然比直接使用RTTI略顯複雜,但是增加的手工編碼成本並不大,帶來的好處卻是明顯的。例如對嵌入式開發,這種機制相比RTTI來說對程式員是可控的,可以選擇在僅需要該特性的範圍內使用,避免無謂的記憶體和性能消耗。
作者:MagicBowen, Email:[email protected],轉載請註明作者信息,謝謝!