面向对象体系中的代码验证
代码验证是保证优秀的架构设计的一种方法,同时也是避免出现象牙塔式架构设计的一种措施。我们在上一篇稳定化中提到说架构设计最终将会体现为代码的形式,因此使用形式化的代码来对架构进行验证是最有效的。
由于是代码验证,因此就离不开编写代码,而代码总是和具体的语言、编译环境息息相关的。在这里我们主要讨论面向对象语言,代码示例采用的Java语言。利用面向对象语言来进行架构设计有很多的好处:
首先,面向对象语言是一种更优秀的结构化语言,比起非面向对象语言,它能够更好的实现封装、降低耦合、并允许设计师在抽象层次上进行思考。这些因素为优秀的架构设计提供了条件。
其次,面向对象语言可以允许设计师只关注在框架代码上,而不用关心具体的实现代码。当然,这并不是说非面向对象的语言就做不到这一点,只是面向对象语言的表现更优秀一些。
最后,面向对象语言可以进行很好的重用。这就意味着,设计师可以利用原有的知识、原有的软件体系,来解决新的问题。
此外,利用Java语言,还可以获得更多的好处。Java语言是一种面向接口的语言。我们知道,Java语言本身不支持多重集成,所有的Java类都是从Object类继承下来的。这样,一个继承体系一旦确定就很难再更改。为了能够达到多重继承的灵活性,Java引入了接口机制,使用接口和使用抽象类并没有什么不同的地方,一个具体类可以实现多个接口,而客户端可以通过申明接口类型来使用,如下面这样:
List employees=new Vctor();
如果需要将Vctor换成LinkedList,那么除了上面的创建代码,其它的代码不需要再做更多的修改。而Vctor这个具体类除了实现List这个接口以外,还实现了Cloneable、Collection、 RandomAccess、Serializable。这说明除了List接口之外,我们还可以通过以上所列的接口来访问Vector类。因此接口继承能够成为类继承的补充手段,发挥十分灵活的作用。同时又避免了多重继承的复杂性。但是接口中只能够定义空方法,这是接口的一个缺陷。因此在实际编程中,接口和抽象类通常是一起使用的。我们在Java的java.util包中看到Collection接口以及实现Collection接口的AbstractCollection抽象类就是这方面的例子。你可以从AbstractCollection抽象类(或其某个子类)中继承,这样你就可以使用到AbstractCollection中的缺省代码实现,由于AbstractCollection实现了Collection接口,你的类也实现Collection接口;如果你不需要利用AbstractCollection中的代码,你完全可以自己写一个类,来实现Collection接口(这个例子中不太可能发生这种情况,因为工具类的重用性已经实现设计的非常好了)。Java中有很多类似的例子。Java语言设计并不是我们讨论的重点,更加深入的讨论可以参看专门的书籍,这里我们就不作太多的介绍了。
以上花了一些篇幅来讨论面向对象设计和面向接口设计的一些简单的预备知识。这些知识将成为代码验证的基础。
接口和架构
这里的接口指的并不是Java中的Interface的概念,它是广义的接口,在Java语言中具体表现为类的公有方法或接口的方法。在COM体系或J2EE体系中还有类似但不完全相同的表现。对于一个系统的架构来说,最主要的其实就是定义这些接口。通过这些接口来将系统的类联系在一起,通过接口来为用户提供服务,通过接口来连接外部系统(例如数据库、遗留系统等)。因此,我们为了对架构进行验证的要求,就转化为对接口的验证要求。
对接口进行验证的基本思路是保证接口的可测试性。要保证接口具有可测试性,首先要做的是对类和类的职责进行分析。这里有几条原则,可以提高接口的可测试性。
1、 封装原则
接口的实现细节应该封装在类的内部,对于类的用户来说,他只需要知道类发布出的公有方法,而不需要知道实现细节。这样,就可以根据类的共有方法编写相应的测试代码,只要满足这些测试代码,类的设计就是成功的。对于架构来说,类的可测试性是基础,但是光保证这一条还不够。
2、 最小职责原则
一个类(接口)要实现多少功能一直是一个不断争论的问题。但是一个类实现的功能应该尽可能的紧凑,一个类中只处理紧密相关的一些功能,一个方法更应该只做一件事情。这样的话,类的测试代码相应也会比较集中,保证了类的可测试性。回忆在分层模式中我们讨论的那个例子,实现类为不同的用户提供了不同的接口,这也是最小原则的一个体现。
3、 最小接口原则
对于发布给用户使用的方法,需要慎之再慎。一般来说,发布的方法应该尽可能的少。由于公布的方法可能被客户频繁的使用,如果设计上存在问题,或是需要对设计进行改进,都会对现有的方法造成影响。因此需要将这些影响减到最小。另一方面,一些比较轻型的共有方法应该组合为单个的方法。这样可以降低用户和系统的耦合程度,具体的做法可以通过外观模式,也可以使用业务委托模式。关于这方面的讨论,可以参考分层模式。较少的接口可以减轻了测试的工作量,让测试工作更加集中。
4、 最小耦合原则
最小耦合原则说的是你设计的类和其它类的交互应该尽可能的少。如果发现一个类和大量的类存在耦合关系,可以引入新的类来削弱这种耦合度。在设计模式中,中介模式和外观模式都是此类的应用。对于测试,尤其是单元测试来说,最理想的情况是测试的类是一个单纯的类,和其它的类没有任何的关系。但是现实中这种类是极少的,因此我们能够做的是尽可能的降低测试类和其它的类的耦合度。这样,测试代码相对比较简单,类在修改的时候,对测试代码的影响也比较小。
5、 分层原则
分层原则是封装原则的提升。一个系统,往往有各种各样的职责,例如有负责和数据库打交道的代码,也有和用户打交道的代码。把这些代码根据功能划分为不同的层次,就可以对软件架构的不同部分实现大的封装。而要将类的可测试性的保证发展为对架构的可测试性的保证。就需要对系统使用分层原则,并在层的级别上编写测试代码。关于分层的详细讨论,请参见分层模式。
如果你设计的架构无法满足上述的原则,那么可以通过重构来对架构加以改进。关于重构方面的话题,可以参考Martin Fowler的重构一书和Joshua Kerievsky的重构到模式一书。
如果我们深入追究的话,到底一个可验证的架构有什么样的意义呢?这就是下一节中提到的测试驱动和自动化测试的概念。
测试驱动
测试驱动的概念可能大家并不陌生。在RUP中的同样概念是测试优先设计(test-first design),而在XP中则表现为测试优先编程(test-first programming)。其实我们在日常的工作中已经不知不觉的在进行测试驱动的部分工作了,但是将测试驱动提高如此的高度则要归功于敏捷方法。测试驱动的基本思想是在对设计(或编码)之前先考虑好(或写好)测试代码,这样,测试工作就不仅仅是测试,而成为设计(或代码)的规范了。Martin Fowler则称之为"specification by example"
在敏捷测试领域。一种做法是将需求完全表述为测试代码的形式。这样,软件设计师的需求工作就不再是如何编写需求来捕获用户的需要,而是如何编写测试来捕获用户的需要了。这样做有一个很明显的好处。软件设计中的最致命的代码是在测试工作中发现代码不能够满足需求,发生这种情况有很多的原因,但是其结果是非常可怕的,它将导致大量的返工。而将需求整理为测试代码的形式,最后的代码只要能够经过测试,就一定能够满足需求。当然,这种肯定是有前提的,就是测试代码要能够完整、精确的描述需求。做到这一点可不容易。我们可以想象一下,在对用户进行需求分析的时候,基本上是没有什么代码的,甚至连设计图都没有。这时候,要写出测试代码,这是很难做到的。这要求设计师在编写测试代码的时候,系统的整体架构已经成竹在胸。因此这项技术虽然拥有美好的前景,但是目前还远远没有成熟。
虽然我们没有办法完全使用以上的技术,但是借用其中的思想是完全有可能的。
首先,测试代码取代需求的思想之所以好,是因为测试代码是没有歧义的,能够非常精确的描述需求(因为代码级别是最细的级别),并紧密结合架构。因此,从需求分析阶段,我们就应该尽可能的保持需求文档的可测试性。其中一个可能的方式是使用CRC技术。CRC技术能够帮助设计人员分析需求中存在的关键类,并找出类的职责和类之间的关系。在RUP中也有类似的技术。业务实体代表了领域中的一些实体类,定义业务实体的职责和关系,也能够有助于提高设计的可测试性。无论是哪一种方法,其思路都是运用分析技术,找出业务领域中的关键因素,并加以细化。
其次,测试驱动认为,测试已经不仅仅是测试了,更重要的是,测试已经成为一种契约。用于指导设计和测试。在这方面,Bertrand Meyer很早就提出了Design by Contract的概念。从软件设计的最小的单元来看,这种契约实际上是定义了类的制造者和类的消费者之间的接口。
最后,软件开发团队中的所有相关人员如果都能够清楚架构测试代码,那么对于架构的设计、实现、改进来说都是有帮助的。这里有一个关于测试人员的职责的问题。一般来说,我们认为测试人员的主要职责是找出错误,问题在于,测试人员大量的时间都花费在了找出一些开发人员不应该犯的错误上面。对于现代化的软件来说,测试无疑是非常重要的一块,但是如果测试人员的日常工作被大量原本可以避免的错误所充斥的话,那么软件的质量和成本两个方面则会有所欠缺。一个优秀的测试人员,应该把精力集中在软件的可用性上,包括是否满足需求,是否符合规范、设计是否有缺陷、性能是不是足够好。除了发现缺陷(注意,我们这里用的是缺陷,而不是错误),测试人员还应该找出缺陷的原因,并给出改正意见。
因此,比较好的做法是要求开发人员对软件进行代码级别的测试。因此,给出架构的测试代码,并要求实现代码通过测试是提高软件质量的有效手段。在了解了测试驱动的思路之后,我们来回答上一节结束时候的问题。可验证架构的最大的好处是通过自动化测试,能够建立一个不断改进的架构。在重构模式中,我们了解了重构对架构的意义,而保证架构的可测试性,并为其建立起测试网(下一节中讨论),则是架构能够得以顺利重构的基本保证。我们知道,重构的基本含义是在不影响代码或架构外部行为的前提条件下对内部结构进行调整。但是,一旦对代码进行了调整,要想保证其外部行为的不变性就很难了。因此,利用测试驱动的思路实现自动化测试,自动化测试是架构外部行为的等价物,不论架构如何演化,只要测试能够通过,说明架构的外部行为就没有发生变化。
针对接口的测试
和前文一样,这里接口的概念仍然是广义上的接口。我们希望架构在重构的时候能够保持外部行为的稳定。但要做到这一点可不容易。发布的接口要保证稳定,设计师需要有丰富的设计经验和领域经验。前文提到的最小接口原则,其中的一个含义就是如此,发布的接口越多,今后带来的麻烦就越多。因此,我们在设计架构,设计类的时候,应该从设计它们的接口入手,而不是一上手就思考具体的实现。这是面向对象思想和面向过程思想的一大差别。
这里,我们需要回顾在稳定化这一模式中提到的从变化中寻找不变因素的方法。稳定化模式中介绍的方法同样适用于本模式。只有接口稳定了,测试脚本才能够稳定,测试自动化才可以顺利进行。将变化的因素封装起来,是保持测试脚本稳定的主要思路。变化的因素和需要封装的程度根据环境的不同而不同。对一个项目来说,数据库一般是固定的,那么数据访问的代码只要能够集中在固定的位置就已经能够满足变化的需要了。但是对于一个产品来说,需要将数据访问封装为数据访问层(或是OR映射层),针对不同的数据库设计能够动态替换的Connection。
测试网
本章的最后一个概念是测试网的概念。如果严格的按照测试优先的思路进行软件开发的话。软件完成的同时还会产生一张由大量的测试脚本组成的测试网。为什么说是测试网呢?测试脚本将软件包裹起来,软件任何一个地方的异动,测试网都会立刻反映出来。这就像是蜘蛛网一样,能够对需求、设计的变更进行快速、有效的管理。
测试网的脚本主要是由单元测试构成的。因此开发人员的工作除了编写程序之外,还需要编织和修补这张网。编织的含义是在编写代码之前先编写测试代码,修补的含义是在由于软件变更而导致接口变更的时候,需要同步对测试脚本进行修改。额外的工作看起来似乎是加大了开发人员的工作量。但在我们的日常实践中,我们发现事实正好相反,一开始开发人员虽然会因为构建测试网而导致开发速度下降,但是到了开发过程的中期,测试网为软件变动节约的成本很快就能够抵消初始的投入。而且,随着对测试优先方法的熟悉和认同,构建测试网的成本将会不断的下降,而起优势将会越来越明显:
能够很容易的检测出出错的代码,为开发人员扫除了后顾之忧,使其能够不断的开发新功能,此外,它还是代码日创建的基础。
为测试人员节省大量的时间,使得测试人员能够将精力集中在更有效益的地方。
此外,构成测试网还有一个额外的成本,如果开发团队不熟悉面向对象语言,那么由于接口不稳定导致的测试网的变动会增大其构建成本。
总结
从以上的讨论可以看出,架构和代码是分不开的,架构脱离了代码就不能够称得上是一个好的架构。这是架构的目标所决定的,架构的最终目标就是成为可执行的代码,而架构则为代码提供了结构性的指导。因此,用代码来验证架构是一种有效的做法。而要实现这个做法并不是一件容易的事情,我们需要考虑代码级别的架构相关知识(我们讨论的知识虽然局限在面向对象语言,但是在其它的语言中同样可以找到类似的思想),并利用它们为架构设计服务。
(待续)
作者简介:
林星,辰讯软件工作室项目管理组资深项目经理,有多年项目实施经验。辰讯软件工作室致力于先进软件思想、软件技术的应用,主要的研究方向在于软件过程思想、Linux集群技术、OO技术和软件工厂模式。您可以通过电子邮件 iamlinx@21cn.com 和他联系。
文章来源于领测软件测试网 https://www.ltesting.net/