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,转载请注明做者信息,谢谢!

相关文章
相关标签/搜索