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:e.bowen.wang@icloud.com,转载请注明作者信息,谢谢!