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進行綁定。對于所有這些問題,CUB提供的DCI框架采用了多重繼承來描述領域對象和其支持的role之間的綁定關系,同時采用了在多重繼承樹內進行關系交織來進行role之間的依賴關系描述。這種方式在C++中比采用傳統的依賴注入的方式更加簡單高效。
對于DCI的理論介紹,以及如何利用DCI框架進行領域建模,本文就介紹這些。后面主要介紹如何利用CUB中的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的方式進行分析,我們可以看到CUB提供的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
有專門的編譯選項開關,由程序員自行進行取舍。
最后我們介紹CUB的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;
}
通過上面的代碼,可以看到CUB的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來說對程序員是可控的,可以選擇在僅需要該特性的范圍內使用,避免無謂的內存和性能消耗。
本文講解的C++的DCI編程框架,目前作為CUB的一個組件提供。CUB是一個面向嵌入式系統的C++基礎編程庫,我們在幾個大型電信系統的重構過程中大面積地使用了CUB庫和DCI架構,取得了非常好的效果!
作者:MagicBowen, Email:e.bowen.wang@icloud.com,轉載請注明作者信息,謝謝!