Refactoring
当架构模型进行迭代的过程中,必然伴随着对模型进行修改和改进。我们如何防止对模型的修改,又如何保证对模型进行正确的改进?
Context
架构模型通过精化、合并等活动之后,将会直接用于指导代码。而这个时候,往往就会暴露出一些问题出来,通常在实际编码中,发现架构存在或大或小的问题和错误,导致编码活动无法继续。这时候我们就需要对架构模型进行修改了。而架构设计的过程本身是一个迭代的过程,这就意味着在每一次的迭代周期中,都需要对架构进行改进。
Problem
我们如何避免对架构模型进行修改?又如何保证架构进行正确的改进?
Solution
我们从XP中借用了一个词来形容架构模型的修改过程――Refactoring,中文可以译作重构。这个词原本是形容对代码进行修改的。它指的是在不改变代码外部行为(可观察行为)的情况下对代码进行修改。我们把这个词用在架构模型上,因为经过精化和合并之后的架构模型往往由很多个粗粒度组件构成。这些组件之间存在一定的耦合度(虽然我们可以令耦合度尽可能的低,但是耦合度一定是存在的),任何一个组件的重构行为都会使变化扩散到系统中的其它组件。这取决于被重构的组件和其它组件之间的相对关系。如果被重构的组件属于层次较低的工具层上,那么这次的修改就可以引起模型很大的变动。
在精化和合并模式中,我们提到了改变和改进的区别,因此,我们的对策主要分为两种:如何防止改变的发生,以及,使用重构来改进软件架构。
防止改变的发生
在任何时候,需求的变更总是对架构及软件有着最大的伤害。而需求变更中最大问题是需求蔓延。很多人都有这样的感觉,项目完成之后,发现初期的计划显得那么陌生。在项目早期对需求进行控制是重要的,但并不是该模式谈论的重点。我们更关注在项目中期的需求蔓延问题和晚期的需求控制问题。关于这方面的详细讨论,请参见稳定化模式。在项目中期,尤其是编码工作已经开始之后,要尽可能避免出现需求蔓延的情况。需求蔓延是经常发生的,可能是因为用户希望加入额外的功能,或是随着用户对软件了解的加深,发现原有的需求存在一定的不足。完全防止需求蔓延是无法做到的,但是需要对其进行必要的控制。例如,有效的估计变更对开发、测试、文档、管理、组织等各个方面带来的影响。
避免发生改变的另一个有效的办法是从软件过程着手。迭代法或渐进交付法都是可用的方法。一个软件的架构设计往往是相对复杂的,其中涉及到整体结构、具体技术等问题。一次性考虑全部的要素,就很容易发生考虑不周详的情况。人的脑容量并没有我们想象的那么大。将架构设计分为多个迭代周期来进展,可以减少单次迭代周期中需要建模的架构数量,因此可以减少错误的发生。另一方面,迭代次数的增多的直接结果是时间的延长,此外还有一个潜在的问题,如果由于设计师的失误,在后期的迭代中出现问题,必然会导致大量的返工。因为之前的模型已经实现了。在得与失之间,我们如何找到适当的平衡点呢?
迭代次数应该根据不同软件组织的特点来制定,对于初期的迭代周期而言,它的主要任务应该是制定总原则(使用架构愿景模式)、定义层结构和各层的职责(使用分层模式)、解决主要的技术问题上。在这个过程中,可以列出设计中可能会遇到的风险,并根据风险发生的可能性和危害性来排定优先级,指定专人按次序解决这些问题。除此之外,在初期参考前一个项目的经验,让团队进行设计(参见团队设计模式),这些组织保证也是很重要。初期的迭代过程是防止改变的最重要的活动。
请注意需求中的非功能需求。如果说功能需求定义了架构设计的目标的话,非功能需求就对如何到达这个目标做出了限制。例如,对于实现一个报表有着多种的操作方法,但是如果用户希望新系统和旧系统进行有效的融合,那么实现的方式就需要好好的规划了。请从初期的迭代过程就开始注意非功能需求,因为如果忽略它们,在后期需要花费很大的精力来调整架构模型。试想一下,如果在项目晚期的压力测试中,发现现有的数据库访问方法无法满足用户基本的速度要求,那对项目进行将会造成多么大的影响。
注意架构的稳定性。在精化和合并模式中,我们提到了一些模式,能够降低不同组件之间的耦合度。并向调用者隐藏具体的实现。接口和实现分离是设计模式最大的特点,请善用这一点。
尽可能的推延正式文档的编写。在设计的初期,修饰模型和编写文档通常都没有太大的意义。因为此时的模型还不稳定,需要不断的修改。如果这时候开始投入精力开发文档,这就意味着后续的迭代周期中将会增加一项维护文档一致性的工作了。而这时候的文档却无法发挥出它真正的作用。但是,延迟文档的编写并不等于什么都不做,无论什么时候进行设计,都需要随手记录设计的思路。这样在需要的时候,我们就能够有充分的资料对设计进行文档化的工作。
对软件架构进行重构
Martin Fowler的Refactoring一书为我们列举了一系列的对代码进行重构方法。架构也是类似的。
重构到模式
Joshua Kerievsky在《Refactoring to Patterns》一书中这样描述重构和模式的关系:
Patterns are a cornerstone of object-oriented design, while test-first programming and merciless refactoring are cornerstones of evolutionary design
(模式是面向对象设计的基石,而测试优先编程和无情的重构则是设计演进的基石)。作者在文中着重强调了保持适度设计的重要性。
在作者看来,模式常常扮演着过度设计的角色。而在解决这个问题的同时又利用模式的优点的解决方法是避免在一开始使用模式,而是在设计演进中重构到模式。这种做法非常的有效,因为在初始设计中使用模式的话,你的注意力将会集中到如何使用模式上,而不是集中在如何满足需求上。这样就会导致不恰当的设计(过度设计或是设计不充分)。因此,在初始设计中,除非非常有把握(之前有类似的经验),否则我们应当把精力放在如何满足需求上。在初始模型完成后(参见精化和合并模式中的例子),我们会对架构进行重构,而随着迭代的演进,需求的演进,架构也需要演进,这时候也需要重构行为。在这些过程中,如果发现某些部分的设计需要额外的灵活性来满足需求,那么这时候就需要引入模式了。
在软件开发过程中,我们更常的是遇见设计不充分的情况,例如组件之间耦合度过高,业务层向客户端暴露了过多的方法等等。很多的时候,产生这种现象是由于不切实际的计划而导致的。开发人员不得不为了最终期限而赶工,所有的时间都花费在新功能上,而完成的软件则被仍在一边。这样产出的软件是无法保证其质量的。对于这种情况,我们也需要对设计进行重构,当然,合理的计划是大前提所在。团队的领导者必须向高层的管理者说明,现在的这种做法只会导致未来的返工,目前的高速开发是以牺牲未来的速度为代价的。因为低劣的设计需要的高成本的维护,这将抵消前期节省的成本。如果软件团队需要可持续的发展,那么请避免这种杀鸡取卵的行为。
因此,使用模式来帮助重构行为,以实现恰当的设计。
测试行为
重构的前提是测试优先,测试优先是XP中很重要的一项实践。对于编码来说,测试优先的过程是先写测试用例,再编写代码来完成通过测试用例(过程细节不只如此,请参看XP的相关书籍)。但是对于架构设计来说,测试行为是发生在设计之后的,即在设计模型完成后,产出相应的测试用例,然后再编码实现。这时候,测试用例就成为联系架构设计和编码活动的纽带。
另一方面,在设计进行重构时,相应的测试用例也由很大的可能性发生改变。此时往往会发生需要改变的测试代码超出普通代码的情况。避免这种情况一种做法是令你的设计模型的接口和实现相分离,并使测试用例针对接口,而不是实现。在精化和合并模式中,我们提到了一些模式,能够有助于稳定设计和测试用例。Martin Fowler在他的Application Facade一文中,提到使用Facade模式来分离不同的设计部分,而测试则应当针对facade来进行,其思路也是如此。
考虑一个用户转帐的用例。银行需要先对用户进行权限的审核,在审核通过之后才允许进行转帐(处于简便起见,图中忽略了对象的创建过程和调用参数):
需要分别针对三个类编写测试用例,设计模型一旦发生变化,测试用例也将需要重新编写。再考虑下面的一种情况:
现在的设计引入了TransferFacade对象,这样我们的测试用例就可以针对TransferFacade来编写了,而转帐的业务逻辑是相对比较稳定的。使用这种测试思路的时候,要注意两点:首先,这并不是说其它的类就不需要测试用例了,这种测试思路仅仅是把测试的重点放在外观类上,因为任何时候充分的测试都是不可能的。但其它类的测试也是必要的,对于外观类来说,任何一个业务方法的错误都会导致最终的测试失败。其次,当外观类的测试无法达到稳定测试用例的效果时,就没有必要使用外观类了。
只针对有需要的设计进行重构。
任何时候,请确保重构行为仅仅对那些有重构需要的设计。重构需要花费时间和精力,而无用的重构除了增大设计者的虚荣心之外,并不能够为软件增加价值。重构的需要来源于两点:一是需求的变更。目前的设计可能无法满足新的需求,因此需要重构。二是对设计进行改进,以得到优秀简洁的设计。除了这两种情况,我们不应该对设计模型进行重构。
使用文档记录重构的模式。
应该承认,模式为设计提供了充分的灵活性。而这些设计部分往往都是模型的关键之处和难点所在,因此需要对模式进行文档化的工作,甚至在必要的时候,对这部分的设计进行培训和指导。确保你的团队能够正确的使用文档来设计、理解、扩展模式。我们在解决方案的前一个部分提到了尽可能延迟文档的创建。而在设计重构为模式的时候,我们就需要进行文档化的工作了。因为模式具有灵活性,能够抵抗一定的变更风险。
重构并保持模式的一致性
正如上一节所说的那样,模式并不是一个很容易理解的东西,虽然它保持了设计的灵活性和稳定性。对于面向对象的新手而言,模式简直就像是飞碟一样,由于缺少面向对象的设计经验,他们无法理解模式的处理思路,在实践中,我们不只一次的碰到这种情况。我们不得不重头开始教授关于模式的课程。因此,最后我们在软件设计采用一定数量的模式,并确保在处理相同问题的时候使用相同的模式。这样,应用的模式就成为解决某一类的问题的标准做法,从而在一定程度上降低了学习的曲线。
保持模式的一致性的另一个方面的含义是将模式作为沟通的桥梁。软件开发是一种团队的行为。因此沟通在软件开发中扮演着很重要的角色。试想一下,开发人员在讨论软件设计的时候,只需要说"使用工厂模式",大家就都能够明白,而不是费劲口舌的说明几个类之间的关系。这将大大提高沟通的效率。此外,模式的使用和设计的重构对于提高团队的编程水平,培养后备的设计人员等方面都是很有意义的。
13.稳定化
敏捷方法的兴起对设计提出了新的要求,其最核心的一点是针对无法在项目一开始就固化的需求进行演进型的设计。在项目一开始就进行细致、准确的架构设计变得越来越难,因此,架构设计在项目的进展中被不断的改进,这相应导致了编码、测试等活动的不稳定。但是,软件最终必须是以稳定的代码形式交付的。因此,架构设计必须要经历从不稳定到稳定的过程。而架构设计能够稳定的前提就是需求的稳定。
需求冻结
敏捷方法和传统方法的区别在于对待变化的态度。传统的做法是在编码活动开始之前进行充分、细致的需求调研和设计工作,并签署合同。确保所有的前期工作都已经完成之后,才开始编码、测试等工作。如果发生需求变化的情况,则需要进行严格的控制。而在现实中,这种方法往往会由于对开发人员和客户双方需求理解的不一致,需求本身的变化性等问题而导致项目前期就完全固化需求变得不现实。结果要么是拒绝需求的改变而令客户的利益受损,要么是屈从于需求的改变而导致项目失控。敏捷方法则不同,它强调拥抱变化。对于易变的需求,它使用了一系列实践,来驯服这只烈马。其核心则是迭代式开发。应该承认,做到掌握需求并不是一件容易的事,而迭代开发也很容易给开发团队带来额外的高昂成本。要做到这一点,需要有其它实践的配合(下文会提到)。因此,我们在迭代开发进入到一定的阶段的时候,需要进行需求冻结。这时候的需求冻结和上面提到的一开始就固化需求是不一样的。首先,用户经历过一次或几次的迭代之后,对软件开发已经有了形象的认识,对需求不再是雾里看花。其次,通过利用原型法等实践,用户甚至可能对软件的最终形式已经有了一定的经验。这样,用户提出的需求基本上可以代表他们的真实需求。即便还有修改,也不会对软件的架构产生恶劣的影响。最后,需求冻结的时点往往处于项目的中期,这时候需求如果仍然不稳定,项目的最后成功就难以得到保证。
在需求冻结之前,不要过分的把精力投入到文档的制作上,正确的做法是保留应有的信息,以便在稍后的过程中完成文档,而不是在需求未确定的时候就要求格式精美的文档。在格式化文档上很容易就会花费大量的时间,如果需求发生改变,所有的投入都浪费了。文档的投入量应该随着项目的进行而增大。但这决不是说文档不重要,因为你必须要保留足够的信息,来保证文档能够顺利的创建。
确保有专人来接受对变更需求的请求,这样可以确保需求的变化能够得以控制。这项工作可以由项目经理(或同类角色)负责,也可以由下文所说的变更委员会负责。小的、零散的需求很容易对开发人员产生影响,而他们有更重要的任务――把项目往前推进。此时项目经理就像是一个缓冲区,由他来决定需求的分类、优先级、工作量、对现有软件的影响程度等因素,从而安排需求变更的计划――是在本次迭代中完成,还是在下一次迭代中完成。
建立需求变更委员会是一种很好的实践,它由项目的不同类型的涉众组成,可能包括管理、开发、客户、文档、质量保证等方面的人员。他们对需求变更做出评估及决定,评估需求对费用、进度、及各方面的影响,并做出是否以及如何接受需求的决定。由于委员会往往涉及到整个项目团队,因此效率可能会成为它的主要缺点。在这种情况下,一方面可以加强委员会的管理,一方面可以保证委员会只处理较大的需求变更。
在项目的不同时候都需要对需求进行不同程度的约束,这听起来和我们提倡的拥抱变化有些矛盾。其实不然。对需求进行约束的主要目的是防止需求的膨胀和蔓延,避免不切实际的功能列表。我们常常能够提到诸如 "这项功能很酷,我们的软件也要包含它"以及"我们的对手已经开发出这项功能了,最终的软件必须要包含这项功能"之类的话语。这就是典型的需求蔓延的征兆。在项目开始时正确的估计功能进度,在项目中期时控制需求,在项目晚期是杜绝新增需求,甚至剪切现有需求。通过三种方法来保证软件能够保时保质的推出。
稳定架构
即便是需求已经成功的冻结了,我们仍然面对一个不够稳定的架构。这是必然的,而不稳定的程度则和团队的能力,以及对目标领域的理解程度成反比。因此,架构也需要改进。前一个模式中,我们讨论了对架构的重构,其实这就是令架构稳定的一种方法。经验数据表明,一系列小的变化要比一次大变化容易实现,也更容易控制。因此在迭代中对架构进行不断重构的做法乍看起来会托慢进度,但是它为软件架构的稳定奠定了基础。重构讲究两顶帽子的思维方式,即这一个时段进行功能上的增加,下一个时段则进行结构的调整,两个时段决不重复,在对增加功能时不考虑结构的改进,在改进结构时也同样不考虑功能的增加。而在架构进行到稳定化这样一个阶段之后,其主要的职责也将变为对结构的改进了。从自身的经验来看,这个阶段是非常重要的,对软件质量的提高,对加深项目成员对目标领域的认识都有莫大的帮助。而这个阶段,也是很容易提炼出通用架构,以便软件组织进行知识积累的。
在这个阶段中,让有经验的架构师或是高级程序员介入开发过程是非常好的做法。这种做法来自于软件评审的实践。无论是对于改进软件质量,还是提高项目成员素质,它都是很有帮助的。
架构稳定的实践中暗含了一个开发方法的稳定。程序员往往喜欢新的技术、新的工具。这一点无可厚非。但是在项目中,采用新技术和新工具总是有风险的。可能厂商推出的工具存在一些问题没有解决,或者该项技术对原有版本的支持并不十分好。这些都会对项目产生不良的影响。因此,如果必须在项目中采用新技术和新工具的话,有必要在项目初期就安排对新事物进行熟悉的时间。而在架构进入稳定之前,工具的用法、技术的方法都必须已经完成试验,已经向所有成员推广完毕。否则必须要在延长时间和放弃使用新事物之间做一个权衡。
保证架构稳定的优秀实践
在文章的开头,我们就谈到说在项目起始阶段就制定出准确、详细的架构设计是不太现实的。因此,敏捷方法中有很多的实践来令最初的架构设计稳定化。实际上,这些实践并非完全是敏捷方法提出的新概念。敏捷方法只是把这些比较优秀的实践组织起来,为稳定的架构设计提供了保证。以下我们就详细讨论这些实践。
在不稳定的环境中寻求稳定因素。什么是稳定的,什么是不稳定的。RUP推荐使用业务实体(Business Entity)法进行架构设计。这种方法的好处之一是通过识别业务实体从而建立起来的架构是相对稳定的。因为业务实体在不稳定的业务逻辑中属于稳定的元素。大家可以想象,公司、雇员、部门这些概念,几十年来并没有太大的变化。对于特定的业务也是一样的。例如对于会计总帐系统来说,科目、余额、分户账、原始凭证,这些概念从来就没有太大的变化,其对应的行为也相差不大。但是某些业务逻辑就完全相反了。不同的系统业务逻辑不同,不同的时点业务逻辑也有可能发生变化。例如,对于不同的制造业来说,其成本核算的逻辑大部分都是不一样的。即便行业相同,该业务逻辑也没有什么共性。因此,稳定的架构设计应该依赖于稳定的基础,对于不稳定的因素,较好的做法是对其进行抽象,抽象出稳定的东西,并且把不稳定的因素封装在单独的位置,避免其对其它模块的影响。而这种思路的直接成果,就是下一段提到的针对接口编程的做法。例如对于上面提到的成本核算来说,虽然它们是易变的、不稳定的,但是它们仍然存在稳定的东西,就是大部分制造业企业都需要成本核算。这一点就非常的重要,因此着意味着接口方法是相对固定的。
保持架构稳定性的另一种方法是坚持面向接口编程的设计方法。我们在分层模式中就提到了面向接口编程的设计方法,鼓励抽象思维、概念思维。从分层模式中提到的示例中(详见分层模式下篇的面向接口编程一节),我们可以看出,接口的一大作用是能够有效的对类功能进行分组,从而避免客户程序员了解和他不相关的知识。设计模式中非常强调接口和实现分离,其主要的表现形式也正是如此,客户程序员不需要知道具体的实现,对他们来说,只需要清楚接口发布出的方法就可以了。
从另一个方面来看,之所以要把接口和实现相分离,是因为接口是需求中比较稳定的部分,而实现则是和具体的环境相关联的。下图为Java中Collection接口公布出的方法。可以看到,在这个层次上,Collection接口只是根据容器的特性定义了一些稳定的方法。例如增加、删除、比较运算等。所以这个接口是相对比较稳定的,但是对于具体的实现类来说,这些方法的实现细节都有所差别。例如,对于一个List和一个Array,它们对于增加、删除的实现都是不一样的。但是对于客户程序员来说,除非有了解底层实现的需要,否则他们不用了解List的add方法和Array的add方法有什么不同。另一方面,将这些方法实现为固定的、通用的接口,也有利于接口的开发者。他们可以将实现和接口相分离,此外,只要满足这些公布的接口,其它软件开发团队同样能够开发出合用的应用来。在当前这样一个讲求合作、讲求效率的大环境中。这种开发方法是非常重要的。
java.util | |
boolean | |
boolean | addAll(Collection c) |
void | clear() |
boolean | |
boolean | |
boolean | |
int | hashCode() |
boolean | isEmpty() |
iterator() | |
boolean | |
boolean | |
boolean | |
int | size() |
Object[] | toArray() |
Object[] |
重构。代码重构是令架构趋于稳定的另一项方法。准确而言,重构应该是程序员的一种个人素质。但是在实际中,我们发现,重构这种行为更加适合作为开发团队的共同行为。为什么这么说呢?最早接触重构概念的时候,我对面向对象的认识并不深入。因此对重构的概念并不感冒。但随着经验的积累,面向对象思维的深入。我渐渐发现,重构是一种非常优秀的代码改进方式,它通过把原子性的操作,逐步的改进代码质量,从而达到改进软件架构的效果。当程序员逐渐熟练运用重构的时候,他已经不再拘泥于这些原子操作,而是自然而然的写出优秀的软件。这是重构方法对各人行为的改进。另一方面,对于一个团队来说,每个人的编程水平和经验都不一而足,因此软件的各个模块质量也都是参差不齐的。这种情况下,软件质量的改进就已经不是个人的问题了,而该问题的难度要比前一个问题大的多。此时重构方法更能够发挥其威力。在团队中提倡使用、甚至半强制性使用重构,有助于分享优秀的软件设计思路,提高软件的整体架构。而此时的重构也不仅仅局限在代码的改进上(指的是Martin Fowler在重构一书中提到的各种方法),还涉及到分析模式、设计模式、优秀实践的应用上。同时,我们还应该看到,重构还需要其它优秀实践的配合。例如代码复审和测试优先。
总结
令架构趋于稳定的因素包括令需求冻结和架构改进两个方面。需求冻结是前提,架构改进是必然的步骤。在面向对象领域,我们可以通过一些实践技巧来保持一个稳定的架构。这些实践技巧体现在从需求分析到编码的过程中。稳定化模式和下一篇的代码验证模式有很多的关联,细节问题我们会在下一篇中讨论。
14.代码验证
要保证架构的稳定和成功,利用代码对架构进行验证是一种实用的手段。代码验证的核心是测试,特别是单元测试。而测试的基本操作思路是测试优先,它是敏捷方法中非常重要的一项实践,是重构和稳定核模式的重要保障。
面向对象体系中的代码验证
代码验证是保证优秀的架构设计的一种方法,同时也是避免出现象牙塔式架构设计的一种措施。我们在上一篇稳定化中提到说架构设计最终将会体现为代码的形式,因此使用形式化的代码来对架构进行验证是最有效的。
由于是代码验证,因此就离不开编写代码,而代码总是和具体的语言、编译环境息息相关的。在这里我们主要讨论面向对象语言,代码示例采用的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。
测试网
本章的最后一个概念是测试网的概念。如果严格的按照测试优先的思路进行软件开发的话。软件完成的同时还会产生一张由大量的测试脚本组成的测试网。为什么说是测试网呢?测试脚本将软件包裹起来,软件任何一个地方的异动,测试网都会立刻反映出来。这就像是蜘蛛网一样,能够对需求、设计的变更进行快速、有效的管理。
测试网的脚本主要是由单元测试构成的。因此开发人员的工作除了编写程序之外,还需要编织和修补这张网。编织的含义是在编写代码之前先编写测试代码,修补的含义是在由于软件变更而导致接口变更的时候,需要同步对测试脚本进行修改。额外的工作看起来似乎是加大了开发人员的工作量。但在我们的日常实践中,我们发现事实正好相反,一开始开发人员虽然会因为构建测试网而导致开发速度下降,但是到了开发过程的中期,测试网为软件变动节约的成本很快就能够抵消初始的投入。而且,随着对测试优先方法的熟悉和认同,构建测试网的成本将会不断的下降,而起优势将会越来越明显:
- 能够很容易的检测出出错的代码,为开发人员扫除了后顾之忧,使其能够不断的开发新功能,此外,它还是代码日创建的基础。
- 为测试人员节省大量的时间,使得测试人员能够将精力集中在更有效益的地方。
此外,构成测试网还有一个额外的成本,如果开发团队不熟悉面向对象语言,那么由于接口不稳定导致的测试网的变动会增大其构建成本。
总结
从以上的讨论可以看出,架构和代码是分不开的,架构脱离了代码就不能够称得上是一个好的架构。这是架构的目标所决定的,架构的最终目标就是成为可执行的代码,而架构则为代码提供了结构性的指导。因此,用代码来验证架构是一种有效的做法。而要实现这个做法并不是一件容易的事情,我们需要考虑代码级别的架构相关知识(我们讨论的知识虽然局限在面向对象语言,但是在其它的语言中同样可以找到类似的思想),并利用它们为架构设计服务。
15.进一步阅读
敏捷架构设计一文到目前已经全部结束,由于架构设计是一个很大的话题,要在一篇文章中完全把架构设计讲清楚是很难的。因此本文的最后一个章节中提供了一组书籍和文章,这些资料都和架构设计有关,本文的写作过程也从中获益良多,故而推荐给有兴趣的读者。
Refactoring To Patterns(Joshua Kerievsky)勿庸置疑,模式是软件设计的一种有效的工具。但是在将模式和现实中的软件设计关联起来时,很多人往往迷惑于模式到底是如何应用的。结果是表现出两种极端:一是用不好模式,二是过度的滥用模式。模式是他人设计经验的总结,但是它在提供优秀的设计思路的同时也会增加一定的复杂性。因此,不了解模式应用的上下文环境而错误的使用模式是非常危险的。不但达不到原先的效果,而且会导致设计难以理解和设计团队沟通的困难。文章一开始,作者就批评了滥用模式的做法。那么,到底要怎样才算是正确的使用模式呢?作者借鉴了Martin Fowler的重构方法。通过实际的例子,讨论如何把一个普通的、不够灵活、不具备扩展性的设计重构为一个优美的设计模式。因此,本文的核心在于,如何识别哪些设计需要重构?如何判断重构的时机?如何评价重构前后的优缺点?以及,如何进行重构?本书目前正在写作中,从http://industriallogic.com可以找到其草稿。在透明的网站和umlchina上,也可以找到部分的译稿。在阅读架构重构模式后,你可以再翻阅此文,这样你就可以了解到该模式在代码级别上的实现。
Effective Java(Joshua Bloch)此书的定位于编程习惯(Idiom)和良好的OO思维上。任何的设计都无法脱离最终的代码。因此,擅长于架构设计的设计师一定也拥有浑厚的编码功力。优秀的软件过程是由大量优秀的实践拚接而成,架构设计也是一样的,它是由大量的代码级的实践累积起来的。此外,本书的很多讨论都是关于如何进行一个优秀的OO设计的。在本文中,我们很多关于具体设计的讨论都是基于OO设计的,在稳定化模式中我们也讨论了OO设计优秀之处。因此,在了解架构设计的基本思路后,阅读此书,你可以进一步的了解和强化OO架构的设计思路。顺便一提,本书的中文版即将面世。
Writing Effective Use Case(Alistair Cockburn)文如其名,本书主要介绍了如何编写用例的知识。在架构设计中,我们不断的强调需求的重要性,可以说,没有需求,就没有架构。那么,需求应该如何组织呢?用例就是一种不错的需求组织方式,注意,用例并不能够完全代替需求,类似于业务流程、非功能需求之类的需求都不是用例所擅长的。本书的精华就在于它完整的介绍了叙述型用例的各个方面,从用例的范围、角色、层次等方面描述了用例的构成,并讨论了用例的很多相关知识。更为宝贵的是,本书中包含了大量的实例。相较一些介绍用例图的书而言,本书的定位更加的实践化。一个优秀的需求的价值在于,它能够很容易的转换为软件开发过程中其它实践所需要的工件。如果我们仔细的体悟本书的话,我们会发现书中的很多思路都是基于此的。本书在市面上可以找到中文版和英文版两种版本。
Thinking in Patterns(Bruce Eckel)Bruce Eckel 的另外两本书《Thinking in C++》和《Thinking in Java》可以说是非常的出名。后者更是有三个版本,前两个版本都有中文译本,候捷老师更是亲自翻译了第二个版本,第三个版本目前正在写作中,从Bruce Eckel的网站(http://www.mindview.net)上可以下载到。《Thinking in Patterns》目前也仍然处于写作中,但已经略具规模了。Bruce Eckel从不同的应用角度来讨论模式之间的不同以及模式的内涵。例如,对工厂模式的讨论是从封装对象创建的角度开始讨论的,对适配器模式的讨论则是从改变接口的角度开始讨论的。模式的关键在于应用,阅读本书,你能够体会到这一点。
Java 与模式(阎宏)如果说上述的一些好文都出自国外专家之手,那么这本书就是绝对的中文原创。本书的重点是讨论隐藏在模式背后的面向对象规律,并一一对各种设计模式进行分析和实例研讨。使用很多有趣的例子和运用哲学思维来解释模式是本书的两大特色。在阅读该书的时候,请注意区分技术细节和框架代码。设计模式的好处就在于能够根据上下文环境,设计出一个具有灵活性、扩展性、低耦合度的框架来。这和架构设计的思路是一样的,不要在软件开发过程的早期考虑太多的细节。
Patterns of Enterprise Application Architecture(Martin Fowler)这是一本绝对的讨论架构设计模式的书了,但这里的架构是特指企业信息系统的架构,这和本文讨论的问题域是一样的。根据三层结构的理论,本书的模式大致可以分为5类:表示层模式、逻辑层模式、数据层模式、分布式模式、以及一些基础的模式。书的早期版本采用了这种分类法,在出版之后,模式的分类进一步细化。例如数据层模式就被进一步的区分为数据源架构模式、对象-关系行为模式、对象-关系结构模式、对象-关系元数据映射模式等。本书的内容覆盖面很广,Martin Fowler在素材的组织上拥有非常优异的才能,当年的《重构》一书就是这方面的例证。阅读本书,你会对架构设计的技术层面有着很深的了解,但是,应该注意,书中的一些模式虽然看起来简单,但是如果要真正实现,却需要花费大量的精力,因此,听从《Refactoring To Patterns》一书和本文重构模式的建议吧,只有在需要的时候才把设计重构为模式。
Dealing with Roles(Martin Fowler)这只是一篇小短文,讨论的重点是关于角色处理的知识,但作者从面向对象的基础知识出发,讨论了如何根据需求的不同,来进行不同的设计,并用实际的例子,演示了设计是如何变化的。这种思想和本文提倡的思想是非常的相似的,架构设计不能够独立于需求而存在。建议不论是对面向对象设计有兴趣还是对软件工程有兴趣的人都可以阅读此文。在Martinfowler的网站上(http://www.martinfowler.com)可以找到本文,次外,网站上还有其它一些优秀作品,《Dealing with Properties》就是其中的一篇。我曾经为《Dealing with Roles》一问撰写了一篇读书笔记,发布在点空间上(http://www.dotspace.twmail.net/patternscolumn/analysis%20patterns/RoseModelingNotes_S.htm),如果有兴趣,也可以指导一二。
《Framework Process Patterns》(James Carey,Brent Carlson)本书的作者是IBM公司的成员,他们有着面向对象操作系统和企业应用框架的设计经验,而后者,这是著名的IBM SanFrancisco框架。他们把框架设计中学习到的知识整理为过程模式的形式,书中并没有太多的理论,但处处都体现出了作者的丰富经验。在阅读本书的时候,要时刻牢记其推介的框架设计的特点,再结合自己工作的具体情况,来理解和应用这些模式。不要盲目的把书中介绍的模式应用于自身,这是我的忠告。本书的中文版由我和一位朋友翻译,将不日面世。
IBM Sanfrancisco 框架,这并不是一本书,而是一个现实中的产品。IBM根据市场经验,设计了一个企业应用框架,定位于为企业应用开发者提供通用的组件。从这个产品中,你可以充分的了解到模式是如何应用在一个成熟的产品中的。要了解这个产品的设计思路,关键是要先了解它的层次划分。SanFrancisco框架总共分为三个层次:Foundation Layer、Common Business Objects Layer、Core Business Process Layer。Foundation Layer定义了基础的类以及类的使用策略,例如工厂类来负责所有对象的创建;Common Business Objects Layer定义了企业中的一些通用对象,例如公司、帐户、客户等,而Core Business Process Layer定义了企业应用所需要的关键业务流程,包括会计框架、应收应付、订单处理、库存管理几个方面。这三个层次可以进行独立的重用,越高的层次的重用价值越大。在理解这样一个产品的时候,我们要有这样的思路,对于一个大型的产品来说,一致性有时候是重于其它的价值的,例如,在对象创建方面,产品统一使用了工厂模式,而在属性处理上,统一使用了动态属性的设计方式。虽然有些地方并不需要用到这两种设计模式,但是为了保持设计的一致性,还是必须使用该模式。这一点对于普通的项目开发或是小产品开发来说可能未必适用,但是对于大型的项目或产品来说就显得非常的重要了。
Applying Patterns(Frank Buschmann)这是一篇用过程模式语言来描述如何在实际中应用设计模式的文章。文章短小精悍,把设计模式的应用总结为几种模式,没有提供具体的实例是个遗憾。对正在学习设计模式的人而言,花一些时间了解别人是如何应用设计模式是很有必要的。在点空间上可以找到原文链接和繁体版译文。本文的架构愿景模式就参考了这篇文章中的内容。
重构(Martin Fowler)其实本书已经不用再介绍了,他的价值就在于他能够把程序员日常的很多优秀做法提升到理论的阶段,并为更多的程序员提供指导。这也是我在上文夸奖Martin Fowler具有优异的组织才能的一大原因。遗憾的是,本书一直没有中文译本,不过这个遗憾即将结束,候捷和透明正在合译此书,相信不久之后就可以一饱眼福。http://www.refactoring.com是Martin Fowler创建的重构的讨论站点,上面也会很多的相关内容。而关于重构的另一方面的消息是,现在已经有越来越多的建模工具将重构作为重要的特性之一,这无疑能够为程序员节省大量的精力。
http://www.agiledata.org(Scott W. Ambler)这是Scott W. Ambler 最新维护的一个网站,也代表了Agile方法发展的一个方向――如何以敏捷的姿态进行数据库项目的开发。在读过站点的内容之后,你会了解到如何做好数据库项目的开发。目前,本站点还在Scott W. Ambler的维护下不断的更新。数据库设计一直不是面向对象阵营强调的一个重点,基本的观点认为,关键是类的设计足够规范,数据库并不是主要问题。但是在实际的项目中,数据库,特别是关系型数据库往往是无法忽略的部分,包括数据库模式的设计、性能优化、数据库连接管理、数据操纵语言。除此之外,遗留数据库、并发问题、安全,关系数据到对象的映射,业务逻辑处理,这些都是需要在架构设计的时候就加以考虑的问题。在本文中并没有专门的章节对数据库相关的知识进行讨论,因为数据库的讨论最好是结合具体的数据库进行。如果大家在架构设计中存在数据库方面的问题,可以参考这个网站。
Designing for Scalability with Microsoft Windows DNA(Sten Sundblad)目前关于讨论微软体系平台设计的优秀书籍不多,而本书正是其中之一。本书介绍了DNA体系下设计一个分层架构所要注意的问题。其核心思想是避免纯理论的面向对象设计。例如,书中在介绍领域对象的时候,建议将只读对象和可写对象分开处理,这样,只读对象就不需要COM+的支持,能够提高效率,但这是不符合面向对象的设计的封装思路的。另外,为了能够使用对象缓冲池技术,本书提议在设计业务对象的时候不要包括状态数据,这和类包括数据和行为的基本思路也是相斥的。从这本书中,我们可以了解到现实系统的设计和经典面向对象思想之间的辨正关系。
设计数据层组件并在层间传递数据(Angela Crocker、Andy Olsen 和 Edward Jezierski)这是另一篇讨论windows体系平台的文章。微软的产品适合于小型的开发,一方面和具体的技术有关,另一方面也和体系结构的设计有关。windows体系结构的特点是快速开发,因此在一些小型的项目中,使用微软产品的开发速度较快,但是随着项目规模的增大,快速开发伴随着的结构性欠佳的问题就逐渐显露出来了。因此,文章的主要内容就是如何优化结构。其主要的思路是对系统进行分层,并实现层间数据传递的策略。这两点不论是在哪一类型的体系中都是关键性的问题,在分层模式中,我们也曾经对这两个问题做了大篇幅的讨论。和Java体系相比,Window体系有其特殊的一面,也能够起到他山之石的效果。
EJB Design Patterns(Floyd Marinescu)本书分为两个部分,第一个部分重点讨论了如何在一个分层体系中应用模式语言,并分别针对架构设计、数据传输(即上一段中讨论的层间传送数据)、事务和持久性、服务端和客户端交互、主键生成策略等主题讨论了可能的设计模式。第二部分讨论了EJB设计过程中的实践活动和过程。虽然本文的所有内容都是针对EJB设计的,但是其思路同样可以借鉴于其它体系。本书的电子书在Middleware网站上可以下载到。