DCI in C++

DCI in C++

本文講解的C++的DCI編程框架,目前做爲ccinfra的一個組件提供,可訪問https://github.com/MagicBowen/ccinfra獲取具體源碼。ccinfra中的DCI框架原創者是袁英傑先生(Thoughtworks),咱們在兩個大型電信系統的重構過程當中大面積地使用了該技術,取得了很是好的效果,在此我將其整理出來。因爲文筆有限,拙於表達,但願不足之處英傑見諒!html


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架構使得軟件具備以下好處:git

  • 清晰的進行了分層使得軟件更容易被理解。
    1. Context是儘量薄的一層。Context每每被實現得無狀態,只是找到合適的role,讓role交互起來完成業務邏輯便可。可是簡單並不表明不重要,顯示化context層正是爲人去理解軟件業務流程提供切入點和主線。
    2. Data層描述系統有哪些領域概念及其之間的關係,該層專一於領域對象和之間關係的確立,讓程序員站在對象的角度思考系統,從而讓系統是什麼更容易被理解。
    3. 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++中比採用傳統的依賴注入的方式更加簡單高效。github

對於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(本質上是一個類),WorkerUSE_ROLE聲明它的實現須要依賴於另外一個role:EnergyWorker在它的實現中調用ROLE(Energy)訪問它提供的接口方法。Energy是一個抽象類,有兩個子類HumanEnergyChargeEnergy分別對應於人和機器人的能量特徵。上面是以類的形式定義的各類role,下面咱們須要將role和領域對象關聯並將role之間的依賴關係在領域對象內完成正確的交織。

struct Human : Worker
             , private HumanEnergy
{
private:
    IMPL_ROLE(Energy);
};

struct Robot : Worker
             , ChargeEnergy
{
private:
    IMPL_ROLE(Energy);
};

上面的代碼使用多重繼承完成了領域對象對role的組合。在上例中Human組合了WorkerHumanEnergy,而Robot組合了WorkerChargeEnergy。最後在領域對象的類內還須要完成role之間的關係交織。因爲Worker中聲明瞭USE_ROLE(Energy),因此當HumanRobot繼承了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_ROLEIMPL_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_castactor指針轉型成領域對象身上其它有效的public role,dynamic_cast會自動識別這種轉換是否能夠完成,若是在當前Actor的指針對應的對象的繼承樹上找不到目標類,dynamic_cast會返回空指針。上例中爲了簡單把全部代碼寫到了一塊兒。真實場景下,使用ActorWorker的context的實現文件中僅須要包含ActorWorker的頭文件便可,不會被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:e.bowen.wang@icloud.com,轉載請註明做者信息,謝謝!

相關文章
相關標籤/搜索