模式罗汉拳:Composite模式与自动化测试框架的实现
透明
梗概
Composite模式将相似的对象以树型结构的方式组合在一起,使开发者可以创建复杂的对象。另外,Composite模式要求树中的对象都有共同的超类(或者说:接口),因此可以用同样的方式来处理树中的对象。
场景
讲到Composite模式,总会涉及“文档格式化”这个例子,我也继续用这个例子。假设你正在设计一个文档格式化程序,这个程序的作用就是把字(character)格式化为行(line of text),很多行就组织成栏(column),栏再组织成页(page)。一篇文档(document)还可能包括其他的元素,例如图片(image)之类的。栏和页中还可以有框(frame),框中可以再容纳栏。栏、框和行都可以包含图片。从上面的描述,你可以得到这样的一个设计:
图1:文档结构
这些不同的关联使得系统的复杂度变得巨大。你可以想象,维护这样的一个系统会有多困难!如果使用Composite模式,系统的复杂度会大大降低(如图2所示)。
图2:使用Composite模式,系统变得清晰
约束
- 你手上有一个复杂的对象,希望将它分解成一个“由部分组成整体”的类体系。
- 你希望尽量减少这个类体系的复杂度。为了达到这个目的,应该让继承树中的每个子对象都尽量少去了解其他子对象。
解决方案
使用Composite模式。所有类都派生自共同的基类(在上面的例子中就是DocumentElement);然后,可以包容其他元素的类派生自CompositeDocumentElement类(这个类也派生自DocumentElement类)。Composite模式的一般结构如图3所示,具体的实现细节我们将在下面讲到。
图3:Composite模式的结构
效果
- 树型结构的组合对象可以把其中包含的所有对象都当作AbstractComponent类的实例来处理,不管这些对象实际上是简单对象还是组合对象。
- 客户可以把组合对象也当作AbstractComponent类的实例来处理,而不必再去了解子类的实现细节。
- 如果客户在AbstractComposite派生类的对象上调用了AbstractComponent类的方法,该对象就会把调用转发给其中包含的AbstractComponent对象。
- 如果客户调用方法的对象是AbstractComponent的派生对象、而不是AbstractComposite的派生对象,并且这个方法还需要与场景相关的信息,那么AbstractComponent对象就会把请求转发给自己的父对象(也就是包容自己的对象)。
- Composite模式允许任何AbstractComponent派生对象成为任何AbstractComposite派生对象的子对象(也就是被容纳的对象)。如果你需要更多的限制,就必须为AbstractComposite及其子类加上类型判别代码,这就会使Composite模式的价值打折扣。
- 被容纳的对象可能会有特有的方法。你可以在AbstractComponent类中声明这个方法,并给它一个空的实现,这样就可以在组合对象中使用这个方法,而且不需要引入类型判别代码。
实现
- 如果被包容的对象需要向包容对象转发请求,那么你可以让被包容对象保存一个指向包容对象的指针,这样转发会更简单。
- 如果要让被包容对象保存包容对象的指针,那么就必须有某种机制来保证两者关联的一致性。最好是在Add()方法(或其他功能相似的方法)中添加相关的设置。
- 出于效率的考虑,对象可以把父对象转发过来的方法调用的结果暂存(cache)起来。
- 如果子对象暂存了方法调用的结果,当结果不再正确的时候,父对象就必须提醒子对象。
实现一个自动化测试框架
第一个问题:什么是“自动化测试框架”?顾名思义,自动化测试框架(automated testing framework)就是可以自动对代码进行单元测试的框架。在传统的软件开发流程中,计划、设计、编码和测试都有各自独立的阶段,阶段之间不回溯,所以测试是不是自动化并不重要——反正有的是时间来慢慢测试。但是,在新的软件开发流程中,迭代周期变短,要求对代码进行频繁地重构。而这就要求单元测试必须能够自动、简便、高速地运行,否则重构就是不现实的[1]。
OK,我假设你已经明白了测试框架的作用,现在我们来看看它的需求。别忘了,这可是“自动化”的测试框架,它应该简单到开发者按一个按钮就能完成所有测试的程度。所以,我们必须以某种方式将测试用例(test case)组织成一个测试套件(suite),然后才能很方便地自动运行它;此外,还必须能很简单地向套件中添加新的测试用例,添加多少都可以,而且还不影响套件的正常运行;而且,测试套件还应该可以随意组合,也就是说:一个套件应该可以包含其他的套件。
看看这些需求,想到了什么?很明显,这就是一个Composite模式。简单的结构如图4:
图4:CUnit的核心框架
在《Refactoring》中,Martin Fowler介绍了Java的自动化测试框架JUnit。参考JUnit的结构,我用C++写了一个测试框架CUnit,图4就是CUnit的结构。这是一个典型的Composite模式:TestSuite可以容纳任何派生自Test的对象;当调用TestSuite对象的run()方法时,它会遍历自己容纳的对象,逐个调用它们的run()方法;客户无须关心自己拿到的究竟是TestCase还是TestSuite,他(它)只管调用对象的run()方法,然后分析run()方法返回的结果就行了[2]。
代码示例
下面,我们来看看CUnit的一些关键代码。首先是Test类,它定义了一个公用的接口:
class Test
{
public:
virtual void run() = 0;
};
然后,TestCase类继承了Test类,加入了两个新方法setUp()和tearDown()(关于这两个方法的用途,请参见《Refactoring》一书的相关章节)。TestCase不实现run()方法,所以它也是一个抽象类。用户需要从TestCase类派生出自己的测试用例类,并根据自己的需要来实现run()方法。另外,用户可能想自己实现setUp()和tearDown()这两个方法,也有可能不做任何实现,所以这两个方法应该是虚方法,但不能是纯虚方法。 class TestCase : public Test
{
protected:
virtual void setUp(){ };
virtual void tearDown(){ };
};
TestSuite类也继承了Test类。由于TestSuite是一个Composite类,所以它能够容纳其他Test类型的对象(用addTest()方法添加);而TestSuite::run()则遍历这些被包容的对象,逐个调用它们的run()方法。 class TestSuite : public Test
{
public:
void addTest(Test * test){
m_Compositee.push_back(test);
};
virtual void run(){
for(int i=0; irun();
};
private:
vector m_Compositee;
};
以上就是CUnit的主要代码。当然,要实现自动化的单元测试,仅靠这个类体系是远远不够的,还需要其他很多的技巧。我把CUnit的全部代码上传到了http://gigix.topcool.net/download/03.zip,欢迎有兴趣的读者与我一起讨论。
相关模式
- Chain of Responsibility模式
添加相应的父对象连接,就可以把Chain of Responsibility模式和Composite模式组合起来。这样,子对象就可以从某个祖先那里得到信息,而不必知道究竟从哪个祖先得到信息。
- Visitor模式
Visitor模式可以把Composite模式中散布在多个类中的操作封装在一个类中。
注释
[1] 关于重构和自动化测试框架的概念,参见Martin Fowler的《Refactoring》。此外,《程序员》杂志2001年第12期技术专题“代码重构”也有关于重构的知识。
[2] 实际上我的CUnit与JUnit还有一定的差异,因为我的主要目的是为了阐述Composite模式的应用,而非设计真正实用的测试框架。在Source Forge上有一个叫CppUnit的项目,其结构与JUnit几乎毫无二致,而且也更加完善。