敏捷思维-架构设计中的方法学
目录
1.从方法论看架构设计... 2
2.架构设计的敏捷视图... 7
3.源自需求... 13
4.团队设计... 18
5.简单设计... 24
6.迭代设计... 29
7.组合使用模式... 36
8.架构愿景... 41
9.分层 (上) 45
10.分层 (下) 53
11.精化和合并... 60
12.Refactoring. 66
13.稳定化... 71
14.代码验证... 75
15.进一步阅读... 80
1.从方法论看架构设计
方法论对软件开发而言意味着什么?我们如何看待软件开发中的方法论?方法论能够成为软件开发的救命稻草吗?在读过此文后,这些疑惑就会得到解答。
在第一篇文章中,我们来了解标题中的一些词的含义。
- 方法学是什么?
- 敏捷是什么?
- 为什么讨论架构?
方法论
方法论的英文为Methodology,词典中的解释为"A series of related methods or techniques"我们可以把它定义为软件开发(针对软件开发)的一整套方法、过程、规则、实践、技术。关于方法论的出现的问题,我很赞同Alistair Cockburn的一句话,"方法论源于恐惧。"出于对项目的超期、成本失控等等因素的恐惧,项目经理们从以前的经验出发,制定出了一些控制、监测项目的方法、技巧。这就是方法论产生的原因。
在Agile Software Development一书中,作者提到了方法论的十三个要素,基本能够函盖方法论的各个方面:
- 角色(Roles)
- 个性(Personality)
- 技能(Skills)
- 团队(Teams)
- 技术(Techniques)
- 活动(Activities)
- 过程(Process)
- 工件(Work products)
- 里程碑(Milestones)
- 标准(Standards)
- 质量(Quality)
- 工具(Tools)
- 团队价值(Team Values)
它们之间的关系可以用一幅图来表示:
图 1. 方法论的十三个要素
很多的方法论,都涉及了上面列举的十三要素中的部分要素,因此,我们可以把方法论看作是一个抽象的、无穷的超集,而现实中的方法论都是指超集的一个有限的子集而已。它们之间的关系就好像有理数和1到100之间的整数的关系一样。不论是XP,还是UI设计经验之类,都属于方法论的一个子集,只是这两个子集之间有大小的差别而已。我们还应该看到,讨论一个完备的方法论是没有意义的,因此这种方法论铁定不存在,就好像你视图穷举出所有的有理数一样荒唐。因此,我们关于一个通用的方法论的说法也是无意义的。好的方法论,比如说XP、水晶系列,它们都有一个适合的范围,因为它们了解一点,自己并不是一个无所不能的方法论。
在现实中,我们其实不断的在接触方法论。比如说,为了控制项目的进度,项目经理要求所有的开发人员每周递交一份详细的进度报告,这就是一种方法、一种技巧。如果把开发过程中的这些技巧系统的组织起来,就能够成为一种方法论。你可能会说,那一种方法论的产生也太容易了吧。不,这样产生的方法论并没有太大的实用价值,没有实用价值的方法论根本就没有存在的必要。因此,一个成功的方法论是要能够为多个的项目所接受,并且能够成功实现软件的交付的方法论。
我和我的同事在实践中做了一些试验,希望能够把一些好的方法论应用于开发团队。试验的结果很无奈,方法论实施的效果并不理想,一开始我们认为是方法本身的原因,到后来,我们发现事情并不是这么简单。在试验的过程中,开发人员一致认同方法论的优势所在,但是在实施过程中,鲜有坚持的下来的。在Agile Software Development中,我发现作者遇到了和我们一样的问题。
Alistair Cockburn在和大量的项目团队的访谈之后,写成了Agile Software Development一书。在访谈之前,他笃定自己将会发现高度精确的过程控制是成功的关键所在,结果他发现事实并非如此,他把他的发现归结为7条定律。而我在实际中的发现也包含在这七条定律中,总结起来就只有两点:沟通和反馈。
只要能够保证良好的沟通和即时的反馈,那么开发团队即使并没有采用先进的方法论,一样可以成功。相反,那些"高质量"的团队却往往由于缺乏这两个因素而导致失败(我们这里指的失败是用户拒绝使用最终的软件)。最有效,而成本也最低的沟通方法就是面对面(face to face)的沟通,而随着项目团队的变大,或是另外一些影响因素的加入(比如地理位置的隔绝),面对面的沟通越来越难实现,这导致沟通的的成本逐渐加大,质量也慢慢下降。但这并不是说非面对面的沟通不可,重要的是我们需要知道不同的沟通方式的成本和质量并不相同。XP方法尤为强调面对面的沟通,通过现场客户、站立会议、结对编程等方式来保证沟通的有效。在我的经验中,一个开发团队其实是需要多种沟通方式的结合的。完全的面对面的沟通对某些团队来说是很难实现的,那么问题的关键就在于你如何应用沟通的方式来达到你希望的效果。在前不久结束的欧莱雅创业计划大赛上,有一支团队特别引人注目,他们彼此间素未谋面,仅仅凭借Internet和电话完成了高效的合作。他们虽然没有使用面对面的沟通方式,但是仍然达成了既定的目标。软件开发也是一样的,面对面的沟通是非常有必要的,但其它的沟通方式也是需要的。
再看反馈,不论是控制进度,还是保证客户的满意度,这些活动都需要管理成本。软件开发中的管理成本的一个通性就是伴随有中间产出物(intermediate delivery)。比如说我们的需求规约、分析文档、设计文档、测试计划,这些都属于中间产出物。中间产出物的增加将会带来效率下降的问题,因为开发人员的时间都花在了完成中间产出物的工作上,花在给软件新功能上的时间就减少了。而中间产出物的主要目的是两个,一个是为了保证软件如客户所愿,例如需求规约;另一个是为了作为团队中的其他成员工作的输入,例如开发计划、测试计划等。因此,我们也可以针对这两点来商讨对策,一种是采用迭代的思想,提高软件发布的频率,以保证客户的需求被确实的满足,另一种就是缩小团队的沟通范围,保证成员能够从其他人那里得到新的思路,而不是撰写规范的内部文档(内部文档指那些仅为内部开发人员之间的沟通所需要的文档)。
因此,一个软件项目的成功和你采用的开发方法论并没有直接的关系。
重量
我们根据把拥有大量artifact(RUP官方翻译为工件,意思是软件开发过程中的中间产物,如需求规约、设计模型等)和复杂控制的软件开发方法称为重型(Heavy Weight)方法,相对的,我们称artifact较少的方法为轻型(Light Weight)方法。在传统的观念中,我们认为重型方法要比轻型安全许多。因为我们之所以想出重型方法,就是由于在中大型的项目中,项目经理往往远离代码,他无法有效的了解目前的工程的进度、质量、成本等因素。为了克服未知的恐惧感,项目经理制定了大量的中间管理方法,希望能够控制整个项目,最典型的莫过于要求开发人员频繁的递交各种表示项目目前状态的报告。
在Planning XP一书中有一段讨论轻重型方法论的精辟论述,它把重型方法论归结为一种防御性的姿态(defensive posture),而把轻型方法论归结为一种渴望成功(Plan to win)的心态。如果你是采用了防御性姿态,那么你的工作就集中在防止和跟踪错误上,大量的工作流程的制定,是为了保证项目不犯错误,而不是项目成功。而这种方法也不可谓不好,但前提是如果整个团队能够满足前面所提到的两个条件的话,项目也肯定会成功,但是重型方法论的一个弊端就在于,大家都在防止错误,都在惧怕错误,因此人和人之间的关系是很微妙的,要达到充分的沟通也是很难的。最终,连对人的评价也变成是以避免错误的多寡作为考评的依据,而不是成就。我们在做试验的时候,一位项目经理开玩笑说,"方法论源自项目经理的恐惧,这没错。但最糟糕的是整个团队只有项目经理一个人恐惧,如果能够做到人人的恐惧,那大家也就没有什么好恐惧的了。"这句话提醒了我们,如果一个团队的精神就是力求成功,那么这支团队的心态就和其它的团队不同了,尤其是对待错误的心态上。根本就没有必要花费大量的精力来预防错误,错误犯了就犯了,即时改正就可以了。这其实就是渴望成功的心态。
方法论的艺术
管理,被称为科学和艺术的融合体,而管理的艺术性部分很大程度的体现为人的管理上。我说,方法学,一样是科学和艺术的融合体。这是有依据的,其实方法论和管理学是近亲关系,管理学中有一门分支是项目管理,而在软件组织中,项目管理是非常重要的,方法学就是一种针对软件开发的一种特定的项目管理(或是项目管理的一个子集)。
重型方法最大的一个问题就在于他不清楚或忽略了艺术这个层次,忽视了人的因素,把人做为一个计量单位,一种资源,一种线性元素。而人的要素在软件开发中是非常重要的,软件开发实际上是一种知识、智力的转移过程,最终形成的产品是一种知识产品,它的成本取决于开发者的知识价值,因此,人是最重要的因素。而人这个要素是很难衡量的,每个人都有不同的个性、想法、经验、经历,这么多复杂的因素加在一起,就导致了人的不可预见性。因此,我们强调管人的艺术。
最简单的例子是,在重型方法中,我们的基本假设是对人的不信任。项目经理要控制项目。但不信任就会产生很多的问题,比如士气不高,计划赶不上变化,创新能力低下,跳槽率升高等等。人都是希望被尊重的,技术人员更看重这一点,而很多公司也口口声声说自己多么多么以人为本,可是采用的却是以不信任人为前提的开发方法,言行不一。我们说敏捷方法的出发点是相互信任,做到这一点是很难的,但是一旦做到了,那这个团队就是非常具有竞争力的。因此,这就产生了一个问题,在没有做到完全的相互信任之前,我们到底相不相信他人呢,这就是我提到的艺术性的问题,什么时候你要相信人?什么时候你不相信人,这些都是需要权衡的问题,也都是表现你艺术性的问题。
敏捷
敏捷代表着有效和灵活。我们称那些轻型的、有效的方法为敏捷方法。在重型方法中,我们在一些不必要、重复的中间环节上浪费了太多的精力,而敏捷则避免了这种浪费。我们的文章将会重点的讨论敏捷(Agile)方法论的思想,敏捷这个名字的前身就是轻型。目前已经有了一个敏捷联盟,他们制定了敏捷宣言:
- Individuals and interactions over processes and tools.
- Working software over comprehensive documentation.
- Customer collaboration over contract negotiation.
- Responding to change over following a plan.
而我对敏捷的理解包括了几个方面:
- 较低的管理成本和高质量的产出。软件开发存在两个极端:一个是没有任何的管理成本,所有的工作都是为了软件的产出,但是这种方式却往往导致软件开发过程的混沌,产品的低质量,团队士气的低落。另一个是大量管理活动的加入,评审、变更管理,缺陷跟踪,虽然管理活动的加入能够在一定程度上提高开发过程的有序性,但是成本却因此提高,更糟糕的是,很容易导致团队的低效率,降低创新能力。因此,敏捷方法视图寻找一个平衡点,用低成本的管理活动带来最大的产出,即软件的高质量。
- 尊重人性。敏捷方法尊重人性,强调效率。软件开发可以说是一种脑力的投入,如果不能保证开发人员的自愿投入,产品就肯定要打折扣。事实多次的证明,一个愿意投入的开发人员和一个不愿意投入的开发人员效率相差在三倍以上,对组织的贡献更是在十倍以上。
- 沟通和反馈是一切的基础。我们已经讨论过沟通的重要程度,而即时的反馈是拥抱变化的前提条件。
- 客户是上帝。没有客户就没有一切,客户的重要性可以用一句话来形容,就是以合理的成本建造合适的软件(build the right system at the right cost)。
敏捷其实也有轻重之分,关键在于是否能够做到有效和灵活。因此,敏捷方法论提倡的一个思想是"刚好够(barely sufficient)"。不过这个"刚好够"可不是那么容易判断的。一支8个人的团队采用XP方法,随着方法的熟练使用,团队的能力在不断的增强,能够处理的问题越越来越复杂,也许他们能够处理采用重型方法的20个人团队能够处理的问题。可是如果团队的人数突然增加到12人,这支团队肯定就会出问题,他的表现可能还不如那支20个人的团队了。人数增加了的时候,原先的方法肯定还做适当的调整,比如说,在原先的敏捷方法上增加一些重型方法的技巧。我们不能够要求一支6个人的团队和一支20个人的团队用同样的方法,前者可能采用轻一些的敏捷方法,后者可能采用重一些的敏捷方法,关键的问题在于,两支团队都把重点放在沟通、反馈、频繁交付软件这些关键的因素上,也就是做到有效和灵活。
架构设计
架构(Architecture)(也有被称为体系结构的)是软件设计中非常重要的一个环节。软件开发的过程中只要需求和架构确定之后,这个软件就基本上可以定型了。这就好比骨骼确定了,这个人的体形就不会有很大的变化。因此我选择了架构设计来讨论敏捷软件开发(需求我已经写过了)。我们在前面讨论过超集和子集的概念,因此我们接下去要讨论的架构设计也是一个很小的子集。方法论如果没有经历过多个项目的检验是不能称为成功的方法论的,我也并不认为我的架构设计就是一个好的方法论,但引玉还需抛砖,他的主要目的是为了传播一种思想。因此,我采用了模式语言(PLOP)做为写作架构设计的形式,主要的原因就是模式是一种很好的组织思想的方法。
因此,在我们接下去的历程中,我们集中讨论的东西就围绕着架构、方法学、敏捷这三个要素展开。这篇文章并不是讨论如何编码实现软件架构的,也不要单纯的把它看作架构设计的指南,其实文中的很多思想来自于方法论,因此提到的很多架构设计的思想也适用于其它工作,如果能够了解这一点,看这篇文章的收获可能会更多一些。
2.架构设计的敏捷视图
通过上一章的介绍,我们对敏捷和方法有了一个大致的了解,从这一章起,我们开始对软件开发过程中架构设计的研究。记住一点,我们并不是为了架构设计而研究架构设计,我们的目的在于敏捷方法学的应用。
架构设计是一种权衡(trade-off)。一个问题总是有多种的解决方案。而我们要确定唯一的架构设计的解决方案,就意味着我们要在不同的矛盾体之间做出一个权衡。我们在设计的过程总是可以看到很多的矛盾体:开放和整合,一致性和特殊化,稳定性和延展性等等。任何一对矛盾体都源于我们对软件的不同期望。可是,要满足我们希望软件稳定运行的要求,就必然会影响我们对软件易于扩展的期望。我们希望软件简单明了,却增加了我们设计的复杂度。没有一个软件能够满足所有的要求,因为这些要求之间带有天生的互斥性。而我们评价架构设计的好坏的依据,就只能是根据不同要求的轻重缓急,在其间做出权衡的合理性。
目标
我们希望一个好的架构能够:
- 重用:为了避免重复劳动,为了降低成本,我们希望能够重用之前的代码、之前的设计。重用是我们不断追求的目标之一,但事实上,做到这一点可没有那么容易。在现实中,人们已经在架构重用上做了很多的工作,工作的成果称为框架(Framework),比如说Windows的窗口机制、J2EE平台等。但是在企业商业建模方面,有效的框架还非常的少。
- 透明:有些时候,我们为了提高效率,把实现的细节隐藏起来,仅把客户需求的接口呈现给客户。这样,具体的实现对客户来说就是透明的。一个具体的例子是我们使用JSP的tag技术来代替JSP的嵌入代码,因为我们的HTML界面人员更熟悉tag的方式。
- 延展:我们对延展的渴求源于需求的易变。因此我们需要架构具有一定的延展性,以适应未来可能的变化。可是,如上所说,延展性和稳定性,延展性和简单性都是矛盾的。因此我们需要权衡我们的投入/产出比。以设计出具有适当和延展性的架构。
- 简明:一个复杂的架构不论是测试还是维护都是困难的。我们希望架构能够在满足目的的情况下尽可能的简单明了。但是简单明了的含义究竟是什么好像并没有一个明确的定义。使用模式能够使设计变得简单,但这是建立在我熟悉设计模式的基础上。对于一个并不懂设计模式的人,他会认为这个架构很复杂。对于这种情况,我只能对他说,去看看设计模式。
- 高效:不论是什么系统,我们都希望架构是高效的。这一点对于一些特定的系统来说尤其重要。例如实时系统、高访问量的网站。这些值的是技术上的高效,有时候我们指的高效是效益上的高效。例如,一个只有几十到一百访问量的信息系统,是不是有必要使用EJB技术,这就需要我们综合的评估效益了。
- 安全:安全并不是我们文章讨论的重点,却是架构的一个很重要的方面。
规则
为了达到上述的目的,我们通常需要对架构设计制定一些简单的规则:
功能分解
顾名思义,就是把功能分解开来。为什么呢?我们之所以很难达到重用目标就是因为我们编写的程序经常处于一种好像是重复的功能,但又有轻微差别的状态中。我们很多时候就会经不住诱惑,用拷贝粘贴再做少量修改的方式完成一个功能。这种行为在XP中是坚决不被允许的。XP提倡"Once and only once",目的就是为了杜绝这种拷贝修改的现象。为了做到这一点,我们通常要把功能分解到细粒度。很多的设计思想都提倡小类,为的就是这个目的。
所以,我们的程序中的类和方法的数目就会大大增长,而每个类和方法的平均代码却会大大的下降。可是,我们怎么知道这个度应该要如何把握呢,关于这个疑问,并没有明确的答案,要看个人的功力和具体的要求,但是一般来说,我们可以用一个简单的动词短语来命名类或方法的,那就会是比较好的分类方法。
我们使用功能分解的规则,有助于提高重用性,因为我们每个类和方法的精度都提高了。这是符合大自然的原则的,我们研究自然的主要的一个方向就是将物质分解。我们的思路同样可以应用在软件开发上。除了重用性,功能分解还能实现透明的目标,因为我们使用了功能分解的规则之后,每个类都有自己的单独功能,这样,我们对一个类的研究就可以集中在这个类本身,而不用牵涉到过多的类。
根据实际情况决定不同类间的耦合度
虽然我们总是希望类间的耦合度比较低,但是我们必须客观的评价耦合度。系统之间不可能总是松耦合的,那样肯定什么也做不了。而我们决定耦合的程度的依据何在呢?简单的说,就是根据需求的稳定性,来决定耦合的程度。对于稳定性高的需求,不容易发生变化的需求,我们完全可以把各类设计成紧耦合的(我们虽然讨论类之间的耦合度,但其实功能块、模块、包之间的耦合度也是一样的),因为这样可以提高效率,而且我们还可以使用一些更好的技术来提高效率或简化代码,例如Java中的内部类技术。可是,如果需求极有可能变化,我们就需要充分的考虑类之间的耦合问题,我们可以想出各种各样的办法来降低耦合程度,但是归纳起来,不外乎增加抽象的层次来隔离不同的类,这个抽象层次可以是具体的类,也可以是接口,或是一组的类(例如Beans)。我们可以借用Java中的一句话来概括降低耦合度的思想:"针对接口编程,而不是针对实现编程。"
设计不同的耦合度有利于实现透明和延展。对于类的客户(调用者)来说,他不需要知道过多的细节(实现),他只关心他感兴趣的(接口)。这样,目标类对客户来说就是一个黑盒子。如果接口是稳定的,那么,实现再怎么扩展,对客户来说也不会有很大的影响。以前那种牵一发而动全身的问题完全可以缓解甚至避免。
其实,我们仔细的观察GOF的23种设计模式,没有一种模式的思路不是从增加抽象层次入手来解决问题的。同样,我们去观察Java源码的时候,我们也可以发现,Java源码中存在着大量的抽象层次,初看之下,它们什么都不干,但是它们对系统的设计起着重大的作用。
够用就好
我们在上一章中就谈过敏捷方法很看重刚好够用的问题,现在我们结合架构设计来看:在同样都能够满足需要的情况下,一项复杂的设计和一项简单的设计,哪一个更好。从敏捷的观点来看,一定是后者。因为目前的需求只有10项,而你的设计能够满足100项的需求,只能说这是种浪费。你在设计时完全没有考虑成本问题,不考虑成本问题,你就是对开发组织的不负责,对客户的不负责。
应用模式
这篇文章的写作思路很多来源于对模式的研究。因此,文章中到处都可以看到模式思想的影子。模式是一种整理、传播思想的非常优秀的途径,我们可以通过模式的方式学习他人的经验。一个好的模式代表了某个问题研究的成果,因此我们把模式应用在架构设计上,能够大大增强架构的稳定性。
抽象
架构的本质在于其抽象性。它包括两个方面的抽象:业务抽象和技术抽象。架构是现实世界的一个模型,所以我们首先需要对现实世界有一个很深的了解,然后我们还要能够熟练的应用技术来实现现实世界到模型的映射。因此,我们在对业务或技术理解不够深入的情况下,就很难设计出好的架构。当然,这时候我们发现一个问题:怎样才能算是理解足够深入呢。我认为这没有一个绝对的准则。
一次,一位朋友问我:他现在做的系统有很大的变化,原先设计的工作流架构不能满足现在的要求。他很希望能够设计出足够好的工作流架构,以适应不同的变化。但是他发现这样做无异于重新开发一个lotus notes。我听了他的疑问之后觉得有两点问题:
首先,他的开发团队中并没有工作流领域的专家。他的客户虽然了解自己的工作流程,但是缺乏足够的理论知识把工作流提到抽象的地步。显然,他本身虽然有技术方面的才能,但就工作流业务本身,他也没有足够的经验。所以,设计出象notes那样的系统的前提条件并不存在。
其次,开发一个工作流系统的目的是什么。原先的工作流系统运作的不好,其原因是有变化发生。因此才有改进工作流系统的动机出现。可是,毕竟notes是为了满足世界上所有的工作流系统而开发的,他目前的应用肯定达不到这个层次。
因此,虽然做不到最优的业务抽象,但是我们完全可以在特定目的下,特定范围内做到最优的业务抽象。比如说,我们工作流可能的变化是工组流路径的变化。我们就完全可以把工作流的路径做一个抽象,设计一个可以动态改变路径的工作流架构。
有些时候,我们虽然在技术上和业务上都有所欠缺,没有办法设计出好的架构。但是我们完全可以借鉴他人的经验,看看类似的问题别人是如何解决的。这就是我们前面提到的模式。我们不要把模式看成是一个硬性的解决方法,它只是一种解决问题的思路。Martin Fowler曾说:"模式和业务组件的区别就在于模式会引发你的思考。"
在《分析模式》一书中,Martin Fowler提到了分析和设计的区别。分析并不仅仅只是用用例列出所有的需求,分析还应该深入到表面需求的的背后,以得到关于问题本质的Mental Model。然后,他引出了概念模型的概念。概念模型就类似于我们在讨论的抽象。Martin Fowler提到了一个有趣的例子,如果要开发一套软件来模拟桌球游戏,那么,用用例来描述各种的需求,可能会导致大量的运动轨迹的出现。如果你没有了解表面现象之后隐藏的运动定律的本质,你可能永远无法开发出这样一个系统。
关于架构和抽象的问题,在后面的文章中有一个测量模式的案例可以很形象的说明这个问题。
架构的一些误解
我们花了一些篇幅来介绍架构的一些知识。现在回到我们的另一个主题上来。对于一个敏捷开发过程,架构意味着什么,我们该如何面对架构。这里我们首先要澄清一些误解:
- 误解1:架构设计需要很强的技术能力。从某种程度来说,这句话并没有很大的错误。毕竟,你的能力越强,设计出优秀架构的几率也会上升。但是能力和架构设计之间并没有一个很强的联系。即使是普通的编程人员,他一样有能力设计出能实现目标的架构。
- 误解2:架构由专门的设计师来设计,设计出的蓝图交由程序员来实现。我们之所以会认为架构是设计师的工作,是因为我们喜欢把软件开发和建筑工程做类比。但是,这两者实际上是有着很大的区别的。关键之处在于,建筑设计已经有很长的历史,已经发展出完善的理论,可以通过某些理论(如力学原理)来验证设计蓝图。可是,对软件开发而言,验证架构设计的正确性,只能够通过写代码来验证。因此,很多看似完美的架构,往往在实现时会出现问题。
- 误解3:在一开始就要设计出完善的架构。这种方式是最传统的前期设计方式。这也是为XP所摒弃的一种设计方式。主要的原因是,在一开始设计出完美的架构根本就是在自欺欺人。因为这样做的基本假设就是需求的不变性。但需求是没有不变的(关于需求的细节讨论,请参看拙作『需求的实践』)。这样做的坏处是,我们一开始就限制了整个的软件的形状。而到实现时,我们虽然发现原来的设计有失误之处,但却不愿意面对现实。这使得软件畸形的生长。原本一些简单的问题,却因为别扭的架构,变得非常的复杂。这种例子我们经常可以看到,例如为兼容前个版本而导致的软件复杂性。而2000年问题,TCP/IP网络的安全性问题也从一个侧面反映了这个问题的严重性。
- 误解4:架构蓝图交给程序员之后,架构设计师的任务就完成了。和误解2一样,我们借鉴了建筑工程的经验。我们看到建筑设计师把设计好的蓝图交给施工人员,施工人员就会按照图纸建造出和图纸一模一样的大厦。于是,我们也企图在软件开发中使用这种模式。这是非常要命的。软件开发中缺乏一种通用的语言,能够充分的消除设计师和程序员的沟通隔阂。有人说,UML不可以吗?UML的设计理念是好的,可以减轻沟通障碍问题。可是要想完全解决这个问题,UML还做不到。首先,程序员都具有个性化的思维,他会以自己的思维方式去理解设计,因为从设计到实现并不是一项机械的劳动,还是属于一项知识性的劳动(这和施工人员的工作是不同的)。此外,对于程序员来说,他还极有可能按照自己的想法对设计图进行一定的修改,这是非常正常的一项举动。更糟的是,程序员往往都比较自负,他们会潜意识的排斥那些未经过自己认同的设计。
架构设计的过程模式
通常我们认为模式都是用在软件开发、架构设计上的。其实,这只是模式的一个方面。模式的定义告诉我们,模式描述了一个特定环境的解决方法,这个特定环境往往重复出现,制定出一个较好的解决方法有利于我们在未来能有效的解决类似的问题。其实,在管理学上,也存在这种类似的这种思维。称为结构性问题的程序化解决方法。所以呢,我们完全可以把模式的思想用在其它的方面,而目前最佳的运用就是过程模式和组织模式。在我们的文章中,我们仅限于讨论过程模式。
我们讨论的过程仅限于面向对象的软件开发过程。我们称之为OOSP(object-oriented software process )。因为我们的过程需要面向对象特性的支持。当然,我们的很多做法一样可以用在非OO的开发过程中,但是为了达到最佳的效果,我建议您使用OO技术。
那么,我们应该如何避开这些误区呢,或者,换句话说,敏捷软件开发是如何做架构设计的。这里有几种过程模式:
图 2. 敏捷架构过程模式概览(High-Level)
在接下去的篇幅中,我们会逐一对各种过程模式进行介绍。然后再站在全局的角度分析各个模式之间的关系,并将之归纳为架构设计的模式。
敏捷型架构设计
我们说我们这里列出的过程模式是敏捷型的,关于这一点我们会在接下去的各个章节中验证这一点。我们列出的各个过程模式并不是完全照搬敏捷型方法,因为在各种敏捷型方法中,某些技巧适合架构设计,某些方法则不适合架构设计。因此,我们在采用一种方法和技术前,我们会问自己几个简单的问题:
- 该方法/技巧有什么价值?
- 该方法/技巧需要多大的投入?从创建、维护、培训等多方面估计。
- 比较该方法/技巧的投入和价值,它还值得我们采用吗?
- 是否还有其它价值/投入比更高的方法/技巧呢?
在我们的文章中,每一种方法/技巧的讨论都回答了前三个问题,至于第四个问题,希望有同行能够告诉我。
3.源自需求
我们说,和重型方法偏重于计划、过程和中间产物不同,敏捷方法更加看重人和沟通。人和沟通永远是第一位的,而计划、过程和中间产物,那只是保证沟通、实现目标的手段。这并不是说计划、过程、中间产物不重要,只是不能够本末倒置
注:我们把中间产物定义为为了实现跨边界的沟通而制定的文档、模型、代码。例如设计文档、数据模型等。参考RUP的Artifact。
评判软件成功的标准有很多,对于敏捷方法论来说,成功的标准首先在于交付可用的软件。为了保证软件的可用性,最重要的就是做好需求。做好需求的方法有很多(参见拙作需求的实践),但这并不是我们讨论的主题。对于我们要开始的架构设计的工作来说,从需求出发来设计架构,这就是保证软件可用性的一个基本的保证。
Context
我们如何开始我们的架构设计工作?
Problem
我们在进行架构设计的时候,往往主要考虑的都是平台、语言、开发环境、数据库等一些基本问题,可是对于和客户的具体情况密切相关的一些问题却很少系统的考虑。甚至还存在一种误区,认为架构设计无非就是写一些空话,套话。这样子做出来架构设计,如何用于指导软件的实现呢?
IT界的技术层出不穷,面对着如此之多的技术、平台、框架、函数库,我们如何选择一组适合软件的技术?
每一个客户的软件都有自身的特点,如何才能够设计出符合客户利益的架构?
软件中往往都充斥着众多的问题,在一开始就把所有的问题都想清楚往往很难做到,但是如果不解决问题,风险又居高不下。
Solution
针对需求设计架构。 架构设计就是铺设软件的主管道(例1)。我们根据什么来制定主管道的粗细、路径等因素呢?很明显,是根据城市的人口、地理位置、水源等因素来决定的。对应到软件设计也是一样的。城市的各因素就是软件中的各种需求:功能需求、非功能需求、变化案例等等。 一般来说,功能需求决定业务架构、非功能需求决定技术架构,变化案例决定架构的范围。需求方面的知识告诉我们,功能需求定义了软件能够做些什么。我们需要根据业务上的需求来设计业务架构,以使得未来的软件能够满足客户的需要。非功能需求定义了一些性能、效率上的一些约束、规则。而我们的技术架构要能够满足这些约束和规则。变化案例是对未来可能发生的变化的一个估计,结合功能需求和非功能需求,我们就可以确定一个需求的范围,进而确定一个架构的范围。 从例2中,我们看到自已字处理软件的几种需求的范例。真正的字处理软件要复杂的多。而我们最主要的就是必须认识到,架构是来自于需求的。有什么样的需求就有什么样的架构。试想一下,如果我们没有对速度的要求,我们还需要考虑这方面的设计吗?我们上面提到了几种类型的需求对架构的影响,其实还有一个很重要的需求,就是环境的需求。这并不是一个很重要的需求,但是对于部署(deployment)架构设计来说就特别重要。毕竟,我们开发出的软件是要上"战场"的,充分的考虑部署问题是非常有必要的。
|
|
从需求到架构。
在需求阶段,我们可以得到一些代表需求调研成果的中间产物。比如说,CRC卡片、基本用例模型、用户素材、界面原型、界面原型流程图、非功能需求、变化案例等。我们在架构设计阶段的主要工作就是要把这些需求阶段的中间产物转换为架构设计阶段的中间产物。
图 3. 需求阶段的中间产物
其实,架构设计就是要完成两项工作,一是分析,二是设计。分析是分析需求,设计则是设计软件的大致结构。很多的方法论把分析和设计两种活动分开来,但其实这两者是很难区分的,做分析的时候会想到如何设计,而思考如何设计反过来又会影响分析的效果。可以说,他们两者之间是相互联系和不断迭代的。这种形态我们将会在后面的迭代设计模式中详细的讨论。
在敏捷方法论中,需求最好是迭代进行的,也就是说一点一点的作需求。这种做法在那些需求变化快的项目中尤其适用。由于我们采用的流程是一种迭代式的流程,这里我们将会面临着如何对待上一次迭代的中间产物的问题。如果我们每一次迭代都需要修改已存在的中间产物,那么这种维护的成本未免过大。因此,敏捷方法论的基本做法是,扔掉那些已经没有用处的中间产物。还记得在第一章的时候,我们强调说软件要比文档重要。我们生成中间产物的目的都是为了生成最终的程序,对于这些已经完成作用的模型,没有必要付出额外的维护成本。
不要断章取义的采用抛弃模型的做法。因为,抛弃模型的做法需要一个适合环境的支持。后面会针对这个话题开展大范围的讨论。这里我们简单的做一个了解:
- 简单化:简单的模型和简单的程序。模型和程序越复杂,就需要更多的精力来处理它们。因此,我们尽可能的简化它们,为的是更容易的处理它们。
- 高效的沟通渠道:通过增强沟通的效果来减少对中间产物的需要。试想一下,如果我随时能够从客户那里得到需求的细节资料,那前期的需求调研就没有必要做的太细致。
- 角色的交叉轮换:开发人员之间建立起交换角色的机制,这样,能够尽量的避免各子系统诸侯割据的局面。
- 清晰的流程:或者我们可以称之为明确的过程。过程在方法论中向来都是一个重点,敏捷方法论也不例外。开发人员能够清楚的知道,今天做什么,明天做什么。过程不是给别人看的,而是给自己用的。
- 工具:好用的工具能够节省大量的时间,这里的工具并不仅仅指CASE工具,还包括了版本控制工具、自动化测试工具、画图工具、文档制作和管理工具。使用工具要注意成本和效益的问题。
- 标准和风格:语言不通是沟通的一个很大的障碍。语言从某个角度来看属于一种标准、一种风格。因此,一个团队如果采用同样的编码标准、文档标准、注释风格、制图风格,那么这个团队的沟通效率一定非常的高。
如果上述的环境你都不具备,或是欠缺好几项,那你的文档的模型还是留着的好。
仅针对需求设计架构
仅针对需求设计架构的含义就是说不要做未来才有用的事情。有时候,我们会把架构考虑的非常复杂,主要的原因就是我们把很多未来的因素放入到现在来考虑。或者,我们在开发第一个产品的时候就视图把它做成一个完美的框架。以上的这两种思路有没有错呢?没有错,这只是如何看待投入的问题,有人希望开始的时候多投入一些,这样后续的投入就会节省下来。但在现实中,由于需求的不确定性,希望通过增加开始阶段的投入来将降低未来的投入往往是难以做到的,框架的设计也绝对不是能够一蹴而就的,此这种做法并不是一个好的做法。所以我们在后头会着重论述架构设计的简单性和迭代过程,也就是因为这个理由。
模式 模式将可以帮助我们抓住重点。设计模式在书的一开始(第二章)就讨论了一个设计一个文档编辑器的问题。为了解决设计文档编辑器引出的七个问题,一共使用了8种不同的模式。这8种模式的组合其实就是架构,因为它们解决的,都是系统中最高层的问题。 在实践中,人们发现架构也是存在模式的。比如,对于系统结构设计,我们使用层模式;对于分布式系统,我们使用代理模式;对于交互系统,我们使用MVC(模型-视图-控制器)模式。模式本来就是针对特定问题的解,因此,针对需求的特点,我们也可以采用相应的模式来设计架构。 在sun网站上提供的宠物商店的范例中,就把MVC模式的思想扩展成为架构的思想,用于提供不同的界面视图: MVC架构图,这里提供原图的概览,查看其出处请点击这里。 我们可以了解到在图的背后隐藏着的需求:系统需要支持多种用户界面,包括为普通用户提供的HTML界面,为无线用户提供的WML界面,为管理员提供的Swing界面,以及为B2B业务设计的WebService界面。这是系统最重要的需求,因此,系统的设计者就需要确定一个稳定的架构,以解决多界面的问题。相对于多界面的问题,后端的业务处理逻辑都是一致的。比如HTML界面和WML界面的功能并没有太大的差别。把处理逻辑和界面分离开来还有额外的好处,可以在添加功能的同时,不涉及界面的改动,反之亦然。这就是我们在第二篇中提到的耦合度的问题。 MVC模式正可以适用于解决该问题。系统使用控制器来为业务逻辑选择不同的界面,这就完成了MVC架构的设计思路。在架构设计的工作中,我们手头上有模式这样一张好牌,有什么理由不去使用它呢? 抓住重点 在架构设计一开始,我们就说架构是一种抽象,那就是说,架构设计摒弃了具体的细节,仅仅抓住软件最高层的概念,也就是最上层、优先级最高、风险最大的那部分需求。 我们考虑、分析、解决一个问题,一定有一个渐进的过程。架构设计就是解决问题其中比较早期的一个阶段,我们不会在架构设计这个阶段投入过多的时间(具体的原因在下文会有讨论),因此关键点在于我们要能够在架构设计中把握住需求的重点。比如,我们在模式一节中提到了分布式系统和交互系统,分布和交互就是这两个系统的重点。那么,如果说我们面对的是一个分布式的交互系统,那么,我们就需要把这两种特性做为重点来考虑,并以此为基础,设计架构。而我们提到的宠物商店的范例也是类似的,除了MVC的架构,还有很多的设计问题需要解决,例如用于数据库访问的数据对象,用于视图管理的前端控制器,等等(具体使用到的架构模式可以访问sun的网站)。但是这些相对于MVC模式来说,属于局部的,优先级较低的部分,可以在架构确定后再来设计。 架构设计和领域专家 一个架构要设计的好,和对需求的理解是分不开的。因此在现实中,我们发现业务领域专家凭借着他对业务领域的了解,能够帮助开发人员设计出优秀的架构来。架构是需要抽象的,它是现实社会活动的一个基本模型,而业务领域的模型仅仅凭开发人员是很难设计出来的。在ERP的发展史上,我们看到MRP发展为MRPII,在发展到闭环MRP,直到发展成为现在的ERP,主要的因素是管理思想的演化,也就是说,对业务领域的理解进步了,架构才有可能进步。 因此,敏捷型架构设计的过程中,我们也非常强调领域专家的作用。 |
|
4.团队设计
团队设计是敏捷方法论中很重要的一项实践。我们这里说的团队,指的并不是复数的人。一群人就是一群人,并没有办法构成团队。要想成为团队,有很多的工作要做。
我们之所以考虑以团队为单位来考虑架构设计,是因为软件开发本身就不是一件个人的事情,架构设计更是如此。单个人的思维不免有考虑欠妥之处,单个人的学识也不可能覆盖所有的学科。而组织有效的团队却能够弥补这些缺憾。
Context
谁来负责架构的设计?
Problem
在我们的印象中,总认为架构设计是那些所谓架构设计师的专属工作,他们往往拥有丰富的设计经验和相关的技能,他们不用编写代码,就能够设计出理论上尽善尽美的架构,配有精美的图例。
问题1:理论上设计近乎完美的架构缺乏程序的证明,在实际应用中往往会出这样那样的问题。
问题2:设计师设计架构带有很大的主观性,往往会忽视客户的需求,导致架构无法满足需求。
问题3:实现的程序员对这种架构有抵触的情绪,或是因为不理解架构而导致架构实现的失败。
问题4:架构师设计架构主要是依据自己的大量经验,设计出的架构不能真实的反映目前的软件需要。
Solution
团队设计的理论依据是群体决策。和个人决策相比,群体决策的最大好处就是其结论要更加的完整。而群体决策虽然有其优点,但其缺点也是很明显的:需要额外付出沟通成本、决策效率低、责任不明确、等等。但是群体决策如果能够组织得当的话,是能够在架构设计中发挥很大的优势的。
避免象牙塔式的架构设计 对软件来说,架构设计是一项至关重要的工作。这样的工作交给某个人是非常危险的。即便这个人再怎么聪明,他也可能会遗漏部分的细节。组织有效的团队的力量是大大超过个人的力量的,因此团队的成果较之个人的成果,在稳定性和思考的周密程度上,都要更胜一筹。 Scott W. Ambler在其著作中给出了象牙塔式架构(ivory tower architecture)的概念: An ivory tower architecture is one that is often developed by an architect or architectural team in relative isolation to the day-to-day development activities of your project team(s). 中国现在的软件开发行业中也逐渐出现了象牙塔式的架构设计师。这些架构师并不参与实际的程序编写,他的工作就是为项目制作出精美的架构模型,这种架构模型在理论上是相当完美的。 |
|
优秀的架构师能够充分的利用现有框架,减少软件的投入,增强软件的稳定性。这些都没有错,但是问题在于“过犹不及”。象牙塔式架构师往往会出现文章开始指出的那些问题。架构设计其实并不是非常复杂的工作,但它要求开发人员具备相关的技能、经验以及对问题域有一定的了解。开发人员往往都具有相关的技术技能(编程、数据库设计、建模),而对问题域的理解可以从用户和行业专家那里获得帮助。因此,在理论上,我们要实现架构设计的团队化是完全可能的。
在上面的象牙塔式架构定义中,我们看到架构师和日常的开发工作是隔绝的。这样的设计出的架构有很大的局限性。在现实中,我们还会发现另外一种角色,他来自于开发团队外部,为开发人员提供相关的技术或业务的培训。这种角色称为教练,在软件开发中是非常重要的角色,不能够和象牙塔式架构设计师之间画等号。
选择你的设计团队。
软件的架构在软件的生命周期的全过程中都很重要,也就是说,软件开发团队中的所有人员都需要和架构打交道。因此,最好的团队组织方式是所有开发人员都参与架构的设计,我们称这种方式为全员参与。全员参与的方式保证了所有开发人员都能够对架构设计提出自己的见解,综合多方面的意见,在全体开发人员中达成一致。这种方式尤其适合于一些小的团队。
还是会有很多的团队由于种种的原因不适合采用全员参与的方式。那么,组织优秀的开发人员组成设计组也是比较好的方式。一般,我们选择那些在项目中比较重要的,有较多开发经验,或是理论扎实的那些人来组成设计组。当然,如果你考虑到为组织培养后续力量,你也可以让一些新手加入设计组,或是你觉得自己的开发力量不足,邀请外部的咨询力量介入,这完全取决于具体的情况。
设计组不同于我们之前提到的象牙塔式架构设计师。设计组设计出来的架构只能称为原始架构,它是需要不断的反馈和改进的。因此,在架构实现中,设计组的成员将会分布到开发团队的各个领域,把架构的思想带给所有开发人员,编写代码来检验架构,并获得具体的反馈,然后所有的成员再集中到设计组中讨论架构的演进。
团队设计中存在的问题 在团队设计的过程,我们会遇到各种各样的问题,首当其冲的就是沟通成本的问题。架构设计时,需求尚未被充分理解,软件的设计思路还处于萌发的状态。这样的情况下,团队的每位成员对软件都有独特的见解,这些可能有些是相同的,有些是互斥的。就好比盲人摸象一样,他们的观点都代表了软件的一部分或是一方面,但是没有办法代表软件的全部。 在敏捷方法论中,我们的每一个流程都是迅速进行、不断改进的。架构设计也是一样,我们不可能在一次架构设计上花费更多的时间。而团队决策总是倾向于较长的讨论和权衡。 例2中的问题在架构设计中时有发生,纯技术的讨论很容易上升称为争吵。这种情况几乎没有办法完全避免。团队型的决策必然会发生观念的冲突。控制一定程度内的观念的冲突对团队的决策是有益,但是如果超出了这个程度就意味着失控了,需要团队领导者的调节。而更重要的,我们需要注意沟通的技巧: 团队沟通 团队进行架构设计的时候沟通是一个非常需要注意的问题,上述的情境在软件组织中是经常发生的,因为技术人员很自然认为自己的技术比别人的好,如果自己的技术受到质疑,那怕对方是抱着讨论的态度,也无异于自身的权威受到了挑战,面子是无论如何都需要捍卫的。而沟通如果带上了这样一层主观色彩,那么沟通信息的受众就会潜意识的拒绝接受信息。相反,他会找出对方话语中的漏洞,准备进行反击。因此,我们要注意培养一种良好的沟通氛围。 在实际的观察中,我发现团队沟通中存在两种角色,一种是建议者,他们经常能够提出建议。一种是质疑者,他们对建议提出否定性的看法。这两种角色是可能互换的,现在的建议者可能就是刚才的质疑者。质疑者的发言是很能打击建议者的积极性的,而在一个脑力激荡的会议中,最好是大家都能够扮演建议者的角色,这就要求沟通会议的主持者能够掌握好这一点,对建议给予肯定的评价,并鼓励大家提出新的建议。 |
|
良好的沟通有助于架构设计工作的开展。一个成员的能力平平的团队,可以藉由良好的沟通,设计出优秀的架构,而一个拥有一个优秀成员的团队,如果缺乏沟通,最后可能连设计都出不来。这种例子现实中可以找到很多。
标准和风格
我们总是在不知不觉之中使用各种各样的标准和风格。在团队设计中,我们为了提高决策的效率,可以考虑使用统一的标准和风格。统一的标准和风格并不是一朝一夕形成的。因为每个人都有自己不同的习惯和经历,强制性的要求开发人员使用统一的标准(风格)容易引起开发人员的不满。因此在操作上需要注意技巧。对架构设计而言,比较重要的标准(风格)包括以下的这些类别:
- 界面设计
- 流程设计
- 建模规范
- 编码规范
- 持久层设计
- 测试数据
在我的经验中,有一些组织平时并不注意标准(风格)的积累,认为这种积累属于雕虫小技,但正是这些小技,能够非常有效的提高沟通的效率和降低开发人员的学习曲线。试想一下,如果一个团队中所有人写出的代码都是不同标准和风格的,那么理解起来肯定会困难许多。当然,我们没有必要自己开发一套标准(风格)出来,现实中有很多可以直接借用的资料。最好的标准是UML语言,我们可以从UML的官方网站下载到最新的规范,常用的编码标准更是随处可见。不过虽然有了统一的标准,如果风格不统一,同样会造成沟通的障碍。例如下图显示的类图,虽然它们表示的是同一个类,但是由于版型、可视性、详细程度的差别,看起来又很大的差别。而在其它的标准中,这种差别也是普遍存在的。因此,我们在使用了统一的标准之后,还应该使用同样的风格。Scott W. Ambler专门成立了一个网站讨论UML的建模风格的相关问题,有兴趣的读者可以做额外的阅读。
图 4. 两种风格的类图
在统一的风格的基础上更进一步的是使用术语。使用沟通双方都了解专门的术语,可以代表大量的信息。最好的术语的范例就是设计模式的模式名。如果沟通的双方都了解设计模式,那么一方只需要说这部分的设计可以使用工厂模式,另一方就能够理解,而不用再详细的解释设计的思路。这种的沟通方式是最高效的,但它所需要的学习曲线也会比较陡。
团队设计的四明确
为了最大程度的提高团队设计的高效性,可以从4个方面来考虑:
1、明确目标
泛泛的召开架构讨论会议是没有什么意义的,一个没有鲜明主题的会议也不会有什么结果。在源自需求的模式中,我们谈到说可以有非功能需求的架构,可以有功能需求的架构。因此,在进行团队设计之前,我们首先也需要确定,此次要解决什么问题,是讨论业务逻辑的架构,还是技术架构;是全局性的架构,还是各模块的架构。
2、明确分工
我们之所以重视团队,很重要的额一个原因就是不同的成员有不同的擅长的区域。有些成员可能擅长于业务逻辑的建模,有的擅长于原型设计,有的擅长于数据库设计,有的则擅长于Web编程。你能够想象一个软件没有界面吗?(有些软件可能是这种情况)你能够想象一个软件只有数据库,而没有处理逻辑吗?因此,架构设计就需要综合的考虑各个方面,充分利用成员的优势。这就要求团队的各个成员都能够明确自己的分工。
3、明确责权
除了明确自己的分工,每位成员都需要清楚自己的责任。没有责任,分工就不会有任何的效力。每位成员都需要明确自己要做些什么。当然,和责任相对的,没有成员还需要知道自己的权力是什么。这些清楚了,进行高效的沟通的前提就具备了。每次架构的讨论下来,每个人都清楚,自己要做些什么,自己需要要求其他人做些什么,自己该对谁负责。如果这些问题回答不了,那这次的讨论就白费了。
4、明确沟通方式
这里使用沟通方式可能有一点点不恰当,为了明确的表达意思,大家可以考虑信息流这个词。一个完整架构包括几个方面,分别都由那些人负责,如何产生,产生的整个过程应该是什么样的?这样的一个信息流程,囊括了上面提到的三个明确。如果团队的每一个人都能够为架构的产生而努力,并顺利的设计出架构,那么这样的流程是完美的。如果你发现其中的一些人不知道做些什么,那么,这就是流程出问题的现象了。完美的流程还会有一个额外的副产品,架构产生之后,团队对于软件的设计已经是非常的清晰了。因为我们提倡的是尽可能多的开发人员参与架构的设计。
不仅仅是架构
讨论到这里,其实有很多的内容已经脱离了架构设计了。也就是说,很多的原则和技巧都是可以用于软件开发的其它活动的。至于哪一些活动能够利用这些方法呢?大家可以结合自己的实际情况,来思考这个问题。提示一点,关键的入手处在于目前效率较低之处。
5.简单设计
XP非常强调简单的设计原则:能够用数组实现的功能决不用链表。在其它Agile方法中,简单的原则也被反复的强调。在这一章,我们就对简单性做一个全面的了解。
Context
架构应该设计到什么程度?
Problem
软件的架构都是非常的复杂的,带有大量的文档和图表。开发人员花在理解架构本身上的时间甚至超出了实现架构的时间。在前面的文章中,我们提到了一些反对象牙塔式架构的一个原因,而其中的一个原因就是象牙塔式架构的设计者往往在设计时参杂进过多的自身经验,而不是严格的按照需求来进行设计。
在软件开发领域,最为常见的设计就是"Code and Fix"方式的设计,设计随着软件开发过程而增长。或者,我们可以认为这种方式根本就不能算是设计,它抱着一种船到桥头自然直的态度,可是在设计不断改动之后,代码变得臃肿且难以理解,到处充满着重复的代码。这样的情形下,架构的设计也就无从谈起,软件就像是在风雨中的破屋,濒临倒塌。
针对于这种情形,新的设计方式又出现了,Martin Fowler称这种方式为"Planned Design"。和建筑的设计类似,它强调在编码之前进行严格的设计。这也就是我们在团队设计中谈到的架构设计师的典型做法。设计师们通常不会去编程,理由是在土木工程中,你不可能看到一位设计师还要砌砖头。
"Planned Design"较之"Code and Fix"进步了许多,但是还是会存在很多问题。除了在团队设计中我们谈的问题之外,需求变更将会导致更大的麻烦。因此,我们理所当然的想到进行"弹性设计":弹性的设计能够满足需求的变更。而弹性的设计所付出的代价就是复杂的设计。
题外话:
这里我们谈论"Planned Design"引出的一些问题,并没有任何排斥这种方式的意思。"Planned Design"还是有很多可取之处的,但也有很多需要改进的地方。事实上,本文中我们讨论的架构设计方式,本质上也是属于"Planned Design"方式。和"Planned Design"相对应的方式是XP所主张的"Evolutionary Design"方式,但是这种方式还有待于实践的检验,并不能简单的说他就一定要比"Planned Design"先进或落后。但可以肯定的一点是:"Evolutionary Design"方式中有很多的思想和技巧是值得"Planned Design"借鉴的。
Solution
XP中有两个非常响亮的口号:"Do The Simplest Thing that Could Possibly Work"和"You Aren't Going to Need It"(通常称之为YAGNI)。他们的核心思想就是不要为了考虑将来,把目前并不需要的功能加到软件中来。
粗看之下,会有很多开发人员认为这是不切实际的口号。我能理解这种想法,其实,在我热衷于模式、可重用组件技术的时候,我对XP提倡的简单的口号嗤之以鼻。但在实际中,我的一些软件因为复杂设计导致开发成本上升的时候,我重新思考这个问题,发现简单的设计是有道理的。
降低开发的成本
不论是模式,可重用组件,或是框架技术,目的都是为了降低开发的成本。但是他们的方式是先进行大量的投入,然后再节省后续的开发成本。因此,架构设计方面的很多思路都是围绕着这种想法展开的,这可能也是导致开发人员普遍认为架构设计高不可攀的原因。
XP的方式恰恰相反,在处理第一个问题的时候,不必要也不可能就设计出具有弹性、近乎完美的架构来。这项工作应该是随着开发的演进,慢慢成熟起来的。我不敢说这种方式肯定正确,但是如果我们把生物的结构视同为架构,这种方式不是很类似于自然界中生物的进化方式吗?
在一开始就制作出完美的架构的设想并没有错,关键是很难做到这一点。总是会有很多的问题是你在做设计时没有考虑到的。这样,当一开始花费大量精力设计出的"完美无缺"的架构必然会遇到意想不到的问题,这时候,复杂的架构反而会影响到设计的改进,导致开发成本的上升。这就好比如果方向错了,交通工具再快,反而导致错误的快速扩大。Martin Fowler在他的论文中说,"Working on the wrong solution early is even more wasteful than working on the right solution early"(提前做一件错事要比提前做一件对的事更浪费时间),相信也是这个道理。
更有意思的是,通常我们更有可能做错。在我们进行架构设计的时候,我们不可能完全取得详细的需求。事实上,就算你已经取得了完整的需求,也有可能发生变化。这种情况下做出的架构设计是不可能不出错的。这样,浪费大量的时间在初始阶段设计不可能达到的"完美架构",倒不如把时间花在后续的改进上。
提升沟通的效率
我们在团队设计中已经谈过了团队设计的目标之一就是为了降低沟通的成本,以期让所有人都能够理解架构。但是如果架构如果过于复杂,将会重新导致沟通成本的上升,而且,这个成本并不会随着项目进行而降低,反而会因为上面我们提到的遇到新的问题导致沟通成本的持续上升。
简单的架构设计可以加快开发团队理解架构的速度。我们可以通过两种方式来理解简单的含义。首先,简单意味着问题的解不会非常的复杂,架构是解决需求的关键,无论需求再怎么复杂多变,总是可以找出简单稳定的部分,我们可以把这个简单稳定的部分做为基础,再根据需要进行改进扩展,以解决复杂的问题。在示例中,我们提到了measurement pattern,它就是按照这种想法来进行设计的。
其次,简单性还体现在表示的简单上。一份5页的文档就能够表达清楚的架构设计为什么要花费50页呢?同样的道理,能够用一副简单的图形就能够表示的架构设计也没有必要使用文档。毕竟,面对面的沟通才是最有效率的沟通,文档不论如何的复杂,都不能被完全理解,而且,复杂的文档,维护起来也需要花费大量的时间。只有在两种情况下,我们提倡使用复杂的文档:一是开发团队没有办法做到面对面沟通;二是开发成果要作为团队的知识积累起来,为下一次开发所用。
考虑未来
我们之所以考虑未来,主要的原因就是需求的不稳定。因此,我们如果考虑未来可能发生的需求变化,就会不知觉的在架构设计中增加复杂的成分。这违背的简单的精神。但是,如果你不考虑可能出现的情况,那些和目前设计格格不入的改变,将会导致大量的返工。
还记得YAGNI吗?原则上,我们仍然坚持不要在现有的系统中为将来可能的情况进行设计。但是,我们必须思考,必须要为将来可能出现的情况做一些准备。其实,软件中了不起的接口的思想,不就是源于此吗?因此,思考未来,但等到需要时再实现。
变更案例有助于我们思考未来,变更案例就是你在将来可能要(或可能不要)满足的,但现在不需要满足的需求。当我们在做架构设计的时候,变更案例也将会成为设计的考虑因素之一,但它不可能成为进行决策的唯一考虑因素。很多的时候,我们沉迷于设计通用系统给我们带来的挑战之中,其实,我们所做的工作对用户而言是毫无意义的。
架构的稳定
架构简单化和架构的稳定性有什么关系吗?我们说,架构越简单,其稳定性就越好。理由很简单,1个拥有4个方法和3个属性的类,和1个拥有20个方法和30属性的类相比,哪一个更稳定?当然是前者。而架构最终都是要映射到代码级别上的,因此架构的简单将会带来架构的稳定。尽可能的让你的类小一些,尽可能的让你的方法短一些,尽可能的让类之间的关系少一些。这并不是我的忠告,很多的设计类的文章都是这么说的。在这个话题上,我们可以进一步的阅读同类的文章(关于 refactoring 的思考)。
辨正的简单
因此,对我们来说,简单的意义就是不要把未来的、或不需要实现的功能加入到目前的软件中,相应的架构设计也不需要考虑这些额外的需求,只要刚好能够满足当前的需求就好了。这就是简单的定义。可是在现实之中,总是有这样或者那样的原因,使得设计趋向复杂。一般来说,如果一个设计对团队而言是有价值的,那么,付出一定的成本来研究、验证、发展、文档化这个设计是有意义的。反之,如果一个设计没有很大的价值或是发展它的成本超过了其能够提供的价值,那就不需要去考虑这个设计。
价值对不同的团队来说具有不同的含义。有时候可能是时间,有时候可能是用户价值,有时候可能是为了团队的设计积累和代码重用,有时候是为了获得经验,有时候是为了研究出可重用的框架(FrameWork)。这些也可以称为目的,因此,你在设计架构时,请注意先确定好你的目的,对实现目的有帮助的事情才考虑。
Scott W.Ambler在他的文章中提到一个他亲身经历的故事,在软件开发的架构设计过程中,花了很多的时间来设计数据库到业务逻辑的映射架构,虽然这是一件任何开发人员都乐意专研的事情(因为它很酷)。但他不得不承认,对用户来说,这种设计先进的架构是没有太大的意义的,因为用户并不关心具体的技术。当看到这个故事的时候,我的触动很大。一个开发人员总是热衷于新奇的技术,但是如果这个新奇技术的成本由用户来承担,是不是合理呢?虽然新技术的采用能够为用户带来效益,但是没有人计算过效益背后的成本。就我开发过的项目而言,这个成本往往是大于效益的。这个问题可能并没有确定的答案,只能是见仁见智了。
简单并不等于实现简单 说到这里,如果大家有一个误解,认为一个简单的架构也一定是容易设计的,那就错了。简单的架构并不等于实现起来也简单。简单的架构需要设计者花费大量的心血,也要求设计者对技术有很深的造诣。在我们正在进行的一个项目中,一开始设计的基础架构在实现中被修改了几次,但每修改一次,代码量都减少一分,代码的可读性也就增强一分。从心理的角度上来说,对自己的架构进行不断的修改,确实是需要一定的勇气的。因为不论是设计还是代码,都是开发人员的心血。但跨出这一步是值得的。 右侧的例子讨论了Java的IO设计,Java类库的设计应该来说是非常优秀的,但是仍然避免不了重新的修改。实际上,在软件开发领域,由于原先的设计失误而导致后来设计过于复杂的情况比比皆是(例如微软的OLE)。同样的,我们在设计软件的时候,也需要对设计进行不断的修改。能够实现复杂功能,同时自身又简单的设计并不是一件容易的事情。 简单设计需要什么样的设计师 简单的架构需要全面的设计师。什么才是全面的设计师,我的定义是既能够设计,又能够编码。我们在团队设计模式中就已经谈过象牙塔式架构和象牙塔式架构设计师。他们最容易犯的一个毛病就是设计和代码的脱离。从我们自己的经验来看,即使在设计阶段考虑的非常完美的架构,在编码阶段也会出现这样或那样的问题。从而导致架构实现变得复杂。最明显的特征就是在编码时出现了有大量方法的类,或是方法很长的类。这表明架构和代码脱钩了。在我们的开发过程中,不只一次出现这种现象,或者说,出现了坏味道(Bad Smell)。Refactoring的技巧也同样有助于识别坏味道。 一次的架构设计完成后,开发人员可以按照设计,快速的编程。可在一段时间之后,新的特色不断的加入,我们发现代码开始混乱,代码量增大,可读性下降,调试变得困难,代码不可控制的征兆开始出现。我们就知道,架构的设计需要调整了。这属于我们在后面所提到的Refactoring模式。而我们在这里要说的是,如果架构的设计师不参与编码,它是无法感受到坏味道的,因此也就不会主动的对设计进行改进。要解决这个问题,最好的办法是让设计师参与代码的编写,尤其是重要架构的现实部分需要设计师的参与。如果设计师没有办法参与编码,那就需要一种机制,能够把代码反馈给设计师,让他在适当的时候,重新考虑改进架构。一个可能的办法是Code Review。让设计师审核代码,以确保编码者真正了解了架构设计的意图。 |
|
例2. measurement pattern
在分析模式一书中有一个measurement pattern(测量模式),原来它是为了要解决现实中各种各样纷繁复杂的可测量的属性。例如,一个医疗系统中,可能会有身高多高,体重多种,血压多少等上千种可测量的属性。如果分别表示它们,必然导致系统复杂性的上升。因此measurement pattern就从这些属性的可测量的共性出发,研究新的解决方法,提出了measurement pattern的想法:
如图所示,把可测量的属性(Measurement)做为Phenomenon Type的实例,此外,每一个的Person可以拥有多个的Measurement,同时,Measurement还对应处理的属性,例如图中的Quantity,就表示了Measurement的数量和单位。比如,一个人的体重是65公斤,那么,Phenomenon Type就是体重,Quantity的amount是65,units是公斤。
图 5.牋 measurement pattern 的类图
这其实是一个很简单的设计,但它清楚的表示了属性之间的关系,简化了数千种的属性带来的复杂性。此外,我们进一步思考,就会发现,这种架构只是针对目前出现属性众多的问题的基本解决方法,它还可以根据具体的需要进行扩展,例如,实现动态添加单位,或实现不同单位的转化等问题。
因此,我们这里展示的其实是一种思考的方法,假想一下,当你在面对一个复杂的医疗系统时,大量的属性和不同的处理方式,你是不是可以从这样复杂的需求中找出简单的部分来呢?在我们架构设计的第一篇中,我们谈到架构设计的本质在于抽象,这里例子就是最典型的一个例子,在我们传统的想法中,我们都会把身高、体重等概念做为属性或是类,但是为了满足这里的需求,我们对这些具体的概念做一个抽象,提出可测量类别的概念,并把它设计为类(Phenomenon Type),而把具体的概念做为实例。这种抽象的思想在软件设计中无处不在,例如元类的概念。
更深入的理解
下一章中我们将会讨论迭代设计,其中还会涉及到简单设计的相关知识。建议可以将两章的内容结合起来看。
6.迭代设计
迭代是一种软件开发的生命周期模型,在设计中应用迭代设计,我们可以得到很多的好处。
Context
在软件生命周期中,我们如何对待架构设计的发展?
Problem
架构设计往往发生在细节需求尚未完成的时候进行的。因此,随着项目的进行,需求还可能细化,可能变更。原先的架构肯定会有不足或错误的地方。那么,我们应该如何对待原先的设计呢?
我们在简单设计模式中简单提到了"Planned Design"和"Evolutionary Design"的区别。XP社团的人们推崇使用"Evolutionary Design"的方式,在外人看来,似乎拥护者们从来不需要架构的设计,他们采用的方式是一开始就进入代码的编写,然后用Refactoring来改进代码的质量,解决未经设计导致的代码质量低下的功能。
从一定程度上来说,这个观点并没有错,它强调了代码对软件的重要性,并通过一些技巧(如Refactoring)来解决缺乏设计的问题。但我并不认同"Evolutionary Design"的方式,在我看来,一定程度上的"Planned Design"是必须的,至少在中国的软件行业中,"Planned Design"还没有成为主要的设计方向。借用一句明言,"凡事预则立,不预则废",在软件设计初期,投入精力进行架构的设计是很有必要的,这个架构是你在后续的设计、编码过程中依赖的基础。但是,一开始我们提到的设计改进的问题依然存在,我们如何解决它呢?
在简单设计模式中,我们提到了设计改进的必要性,但是,如果没有一种方法去控制设计的改进的话,那么设计改进本身就是一场噩梦。因此,何时改进,怎么改进,如何控制,这都是我们需要面对的问题。
Solution
为了实现不断的改进,我们将在开发流程中引入迭代的概念。迭代的概念在我的另一篇文章--《需求的实践》中已经提到,这里我们假设读者已经有了基本的迭代的概念。
软件编码之前的工作大致可以分为这样一个工作流程:
上图中的流程隐含着一个信息的损失的过程。来自于用户的需求经过整理之后,开发人员就会从中去掉一些信息,同样的事情发生在后面的过程中,信息丢失或变形的情况不断的发生。这里发生了什么问题?应该说,需求信息的失真是非常普遍的,我们缺少的是一种有效的办法来抑止失真,换句话说,就是缺少反馈。
如果把眼睛蒙上,那我们肯定没有办法走出一条很长的直线。我们走路的时候都是针对目标不断的调整自己的方向的。同样的,漫长的软件开发过程如果没有一种反馈机制来调整方向,那最后的软件真是难以想象。
所以我们引入了迭代周期。
初始设计和迭代设计
在团队设计中,我们一直在强调,设计组最开始得到的设计一定只是一个原始架构,然后把这个原始架构传播到每一位开发者的手中,从而在开发团队中形成共同的愿景。(愿景(Vision):源自于管理学,表示未来的愿望和景象。这里借用来表示软件在开发人员心中的样子。在后面的文章中我们会有一个章节专门的讨论架构愿景。)
迭代(Iterate)设计,或者我们称之为增量(Incremental)设计的思想和XP提倡的Evolutionary Design有异曲同工之妙。我们可以从XP、Crystal、RUP、ClearRoom等方法学中对比、体会迭代设计的精妙之处:每一次的迭代都是在上一次迭代的基础上进行的,迭代将致力于重用、修改、增强目前的架构,以使架构越来越强壮。在软件生命周期的最后,我们除了得到软件,还得到了一个非常稳定的架构。对于一个软件组织来说,这个架构很有可能就是下一个软件的投入或参考。
我们可以把早期的原始架构当作第一次迭代前的早期投入,也可以把它做为第一次迭代的重点,这些都是无所谓的。关键在于,原始架构对于后续的架构设计而言是非常重要的,我们讨论过架构是来源于需求的,但是原始架构应该来源于那些比较稳定的需求。
|
单次的迭代
我们说,每一次的迭代其实是一个完整的小过程。也就是说,它同样要经历文章中讨论的这些过程模式。只不过,这些模式的工作量都不大,你甚至可以在很短的时间内做完所有的事情。因此,我们好像又回到了文章的开头,重新讨论架构设计的过程。
单次迭代最令我们兴奋的就是我们总是可以得到一个在当前迭代中相当稳定的结果,而不像普通的架构设计那样,我们深怕架构会出现问题,但又不得不依赖这个架构。从我们的心理上来分析,我们是在持续的建设架构中,我们不需要回避需求的变更,因为我们相信,在需求相对应的迭代中,我们会继续对架构进行改进。大家不要认为这种心理的改变是无关紧要的,我起初并没有意识到这个问题,但是我很快发现新的架构设计过程仍然笼罩在原先的惧怕改变的阴影之下的时候,迭代设计很容易就退化为"Code and Fix"的情形。开发人员难以接受新方法的主要原因还是在心理上。因此,我不得不花了很多的时间来和开发人员进行沟通,这就是我现实的经验。
迭代的交错
基于我们对运筹学的一点经验,迭代设计之间肯定不是线性的关系。这样说的一个原因架构设计和后续的工作间还是时间差的。因此,我们不会傻到把时间浪费在等待其它工作上。一般而言,当下一次迭代的需求开始之后,详细需求开始之前,我们就已经可以开始下一次迭代的架构设计了。
各次迭代之间的时间距离要视项目的具体情况而定。比如,人员比较紧张的项目中,主要的架构设计人员可能也要担任编码人员的角色,下一次迭代的架构设计就可能要等到编码工作的高峰期过了之后。可是,多次的交错迭代就可能产生版本的问题。比如,本次的迭代的编码中发现了架构的一个问题,反馈给架构设计组,但是架构设计组已经根据伪修改的本次迭代的架构开始了下一次迭代的架构设计,这时候就会出现不同的设计之间的冲突问题。这种情况当然可以通过加强对设计模型的管理和引入版本控制机制来解决,但肯定会随之带来管理成本上升的问题,而这是不符合敏捷的思想的。这时候,团队设计就体现了他的威力了,这也是我们在团队设计中没有提到的一个原因。团队设计通过完全的沟通,可以解决架构设计中存在冲突的问题。
迭代频率
XP提倡迭代周期越短越好(XP建议为一到两周),这是个不错的提议。在这么短的一个迭代周期内,我们花在架构设计上的时间可能就只有一两个小时到半天的时间。这时候,会有一个很有意思的现象,你很难去区分架构设计和设计的概念了。因为在这么短的一个周期之内,完成的需求数量是很少的,可能就只有一两个用例或用户素材。因此,这几项需求的设计是不是属于架构设计呢?如果是的话,由于开发过程是由多次的迭代组成的,那么开发过程中的设计不都属于架构设计了吗?我们说,架构是一个相对的概念,是针对范围而言的,在传统的瀑布模型中,我们可以很容易的区分出架构设计和普通设计,如果我们把一次迭代看作是一个单独的生命周期,那么,普通的设计在这样一个范围之内也就是架构设计,他们并没有什么两样。但是,迭代周期中的架构设计是要遵循一定的原则的,这我们在下面还会提到。
我们希望迭代频率越快越好,但是这还要根据现实的情况而定。比如数据仓库项目,在项目的初期阶段,我们不得不花费大量的时间来进行数据建模的工作,这其实也是一项专门针对数据的架构设计,建立元数据,制定维,整理数据,这样子的过程很难分为多次的迭代周期来实现。
如何确定软件的迭代周期
可以说,如果一支开发团队没有相关迭代的概念,那么这支团队要立刻实现时隔两周迭代周期是非常困难的,,同时也是毫无意义的。就像我们在上面讨论的,影响迭代周期的因素很多,以至于我们那无法对迭代周期进行量化的定义。因此我们只能从定性的角度分析迭代周期的发展。
另一个了解迭代的方法是阅读XP的相关资料,我认为XP中关于迭代周期的使用是很不错的一种方法,只是他强调的如此短的迭代周期对于很多的软件团队而言都是难以实现的。
迭代周期的引入一定是一个从粗糙到精确的过程。迭代的本质其实是短周期的计划,因此这也是迭代周期越短对我们越有好处的一大原因,因为时间缩短了,计划的可预测性就增强了。我们知道,计划的制定是依赖于已往的经验,如果原先我们没有制定计划或细节计划的经验,那么我们的计划就一定是非常粗糙,最后的误差也一定很大。但是这没有关系,每一次的计划都会对下一次的计划产生正面的影响,等到经验足够的时候,计划将会非常的精确,最后的误差也会很小。
迭代周期的确定需要依赖于单位工作量。单位工作量指的是一定时间内你可以量化的最小的绩效。最简单的单位工作量是每位程序员一天的编码行数。可惜显示往往比较残酷,团队中不但有程序员的角色,还有设计师、测试人员、文档制作人员等角色的存在,单纯的编码行数是不能够作为唯一的统计依据的。同样,只强调编码行数,也会导致其它的问题,例如代码质量。为了保证统计的合理性,比较好的做法是一个团队实现某个功能所花费的天数作为单位工作量。这里讨论的内容实际是软件测量技术,如果有机会的话,再和大家探讨这个问题。
迭代周期和软件架构的改进
我们应用迭代方法的最大的目的就是为了稳步的改进软件架构。因此,我们需要了解架构是如何在软件开发的过程中不断演进的。在后面的文章中,我们会谈到用Refactoring的方法来改进软件架构,但是Refactoring的定义中强调,Refactoring必须在不修改代码的外部功能的情况下进行。对于架构来说,我们可以近乎等价的认为就是在外部接口不变的情况下对架构进行改进。而在实际的开发中,除非非常有经验,否则在软件开发全过程中保持所有的软件接口不变是一件非常困难的事情。因此,我们这里谈的架构的改进虽然和Refactoring有类似之处,但还是有区别的。
软件架构的改进在软件开发过程会经历一个振荡期,这个振荡期可能横跨了数个迭代周期,其间架构的设计将会经历剧烈的变化,但最后一定会取向于平稳。(如果项目后期没有出现设计平稳化的情况,那么很不幸,你的项目注定要失败了,要么是时间的问题,要么就是需求的问题)。关键的问题在于,我们有没有勇气,在架构需要改变的时候就毅然做出变化,而不是眼睁睁的看着问题变得越来越严重。最后的例子中,我们讨论三个迭代周期,假设我们在第二个周期的时候拒绝对架构进行改变,那么第三个周期一定是有如噩梦一般。变化,才有可能成功。
我们知道变化的重要性,但没有办法知道变化的确切时间。不过我们可以从开发过程中嗅到架构需要变化的气味:当程序中重复的代码逐渐变多的时候,当某些类变得格外的臃肿的时候,当编码人员的编码速度开始下降的时候,当需求出现大量的变动的时候。
例子:
从这一周开始,我和我的小组将要负责对软件项目中的表示层的设计。在这个迭代周期中,我们的任务是要为客户端提供6到10个的视图。由于视图并不很多,表示层的架构设计非常的简单:
准确的说,这里谈不上设计,只是简单让客户端访问不同的视图而已。当然,在设计的示意图中,我们并没有必要画出所有的视图来,只要能够表达客户端和视图的关联性就可以了。
(架构设计需要和具体的实现绑定,但是在这个例子中,为了着重体现设计的演进,因此把不必要的信息都删掉。在实际的设计中,视图可能是JSP页面,也可能是一个窗口。)
第一个迭代周的任务很快的完成了,小组负责的表示层模块也很顺利的和其它小组完成了对接,一个简陋但能够运转的小系统顺利的发布。客户观看了这个系统的演示,对系统提出了修改和补充。
第二个迭代周中,模块要处理的视图增加到了30个,视图之间存在相同的部分,并且,负责数据层的小组对我们说,由于客户需求的改进,同一个视图中将会出现不同的数据源。由于我们的视图中直接使用了数据层小组提供给我们的数据源的函数,这意味着我们的设计需要进行较大的调整。
考虑到系统的视图的量大大的增加,我们有必要对视图进行集中的管理。前端控制器(Front Control)模式将会是一个不错的技巧。对于视图之间的普遍的重复部分,可以将视图划分为不同的子视图,再把子视图组合为各种各样的视图。这样我们就可以使用组合(Composite)模式:
客户的请求集中提交给控制器,控制器接受到客户的请求之后,根据一定的规则,来提供不同的视图来反馈给客户。控制器是一个具有扩展能力的设计,目前的视图数量并不多,因此仍然可以使用控制器来直接分配视图。如果视图的处理规则比较复杂,我们还可以使用创建工厂(Create Factory)模式来专门处理生成视图的问题。对于视图来说,使用组合模式,把多个不同数据源的视图组合为复杂的视图。例如,一个JSP的页面中,可能需要分为头页面和尾页面。
项目进入第三个迭代周期之后,表示层的需求进一步复杂化。我们需要处理权限信息、需要处理数据的合法性判断、还需要面对更多的视图带来的复杂程度上升的问题。
表示层的权限处理比较简单,我们可以从前端控制器中增加权限控制的模块。同时,为了解决合法性判断问题,我们又增加了一个数据过滤链模块,来完成数据的合法性判断和转换的工作。为了不使得控制器部分的功能过于复杂,我们把原先属于控制器的视图分发功能转移到新的分发器模块,而控制器专门负责用户请求、视图的控制。
我们来回顾这个例子,从迭代周期1中的需求最为简单,其实,现实中的项目刚开始的需求虽然未必会像例子中的那么简单,但一定不会过于复杂,因此迭代周期1的设计也非常的简单。到了迭代周期2的时候,需求开始变得复杂,按照原先的架构继续设计的话,必然会导致很多的问题,因此对架构进行改进是必要的。我们看到,新的设计能够满足新的需求。同样的,迭代周期3的需求更加的复杂,因此设计也随之演进。这就是我们在文章的开始提到的"Evolutionary Design"的演进的思想。
7.组合使用模式
我们已经讨论了敏捷架构设计的4种过程模式,在这一章中,我们对这四种过程模式做一个小结,并讨论4者间的关系以及体现在模式中的敏捷方法论特色。通过这一章的描述,大家能够对前面的内容有更进一步的了解。
四种模式的着重点
我把源自需求、团队设计、简单设计、迭代设计这4种过程模式归类为架构设计的第一层次,这4种模式能够确定架构设计过程的框架。这里需要对框架的含义进行澄清:架构设计的框架并不是说你要严格的按照文中介绍的内容来进行架构设计,在文章的一开始我们就指出,模式能够激发思考。因此,这一框架是需要结合实际,进行改造的。实际,我们在这一个部分中介绍的,比较偏向于原则,我们花了很大的时间来讨论原则的来龙去脉,而原则的度,则要大家自己去把握。为什么我们不讨论原则的度呢?这里有两个原因,一个是软件开发团队各有特色,很难定义出一个通用的度。第二个原因是我的水平不够,实践经验也不够丰富。
前面提到的四种模式其实是从四个侧面讨论了架构设计中的方法问题。源自需求提供了架构设计的基础。在软件过程中,架构设计是承接于需求分析的,如果没有良好的需求分析活动的支持,再好的架构设计也没有用。因此我们把这一模式放在首位,做为架构设计的目标。
有了确定的目标,还需有组织的保证,这也就是第二种模式――团队设计的由来。敏捷方法提倡优秀的沟通,因此团队设计是必要且有效的。而团队设计的另一个意图,是保证架构设计的下游活动得以顺利的进行,例如详细设计、编码、测试等。由于开发团队中的人大都加入了架构设计,因此最大程度的减小了不同的活动间的信息损耗和沟通效率低下的问题。如果说源自需求模式是起承上的作用,那么团队设计模式则是扮演了启下的角色。
在软件设计的过程中,沟通往往扮演着非常重要的角色。从团队设计开始的几种模式所要解决的都是沟通的问题。团队设计对沟通的贡献在于它能够把设计意图以最小的代价传播到开发团队的每个角落。这样,设计和下游的活动间由于沟通不畅产生的问题就能够得到缓解。一般而言,设计到编码会经历一个信息损失的过程,编码人员无法正确理解设计人员的意图,设计人员却往往无法考虑到一些编码的细节。虽然我们可以通过共通的设计符号来提高沟通的质量,例如UML。但是实践证明,只要能够保证畅通的沟通,即便没有优秀的开发方法,项目成功的概率依然很高。因此对于单个的项目来说,最关键的问题还是在于沟通。只要组织得当,团队设计是一个值得应用的模式。当然,配合以UML为代表的建模语言,更能够提高沟通的效果。
在设计中,我们发现,当设计信息转换为编码信息需要一定的时间,这个时间包括设计的组织时间,设计被理解的时间。如果设计比较复杂,或者说设计的文档比较复杂,编码人员花在理解上的时间就会大大增加。因此,权衡后的结果是,相对于详细的设计说明书而言,简单的设计说明书再配合一定程度的面对面沟通能够起到更好的效果。"简单要比复杂有效",这就是简单设计模式的基本思路。
同样,简单的思路还会用在软件开发的各个方面,例如文档、设计、流程。坚持简单的原则,并不断的加以改进,是降低软件开发成本的一种很有效的做法。
在有了以上的思路之后,我们还需要面对两个现实的问题。需求的变化将会导致设计的不稳定,而需求的复杂性又会导致简单架构设计的困难。为了解决这个问题,我们引入了迭代的方法,将问题分割为多个子问题(把一个复杂的问题分解为多个较简单的子问题是计算机领域最常见的处理方法)。这样,问题的范围和难度都大大降低了。而更关键的是,由于对用户需求理解不充分或用户表达需求有错导致的设计风险被降到最低点。迭代和前面几个模式都有关系。
需求和迭代
源自需求模式是架构设计中的起手式,没有这一模式的支持,架构设计只能是空中楼阁。其实,源自需求模式严格意义上并不能算是敏捷方法论的特色,而应该算是软件开发的天然特性。不幸的是,就是这么一个基本的原则,却没能够引起开发者足够的重视。
敏捷方法论中把需求摆在一个非常重要的位置,我们把源自需求模式作为架构设计的第一个模式,主要的目的是承接架构设计的上游工作――需求。需求决定架构,因此,我们在经典的瀑布模型中可以看到需求到设计的严格的分界线,但是在实际的开发中,按照瀑布模型的理论往往会遇到很多的问题,所以,我们尝试着把需求和(架构)设计之间的界限打破,形成一个重叠的地带,从而提高软件开发的速度。因此,我们在源自需求模型中指出,架构设计是紧随着需求开始的。
需求对软件开发最具影响就是需求的不稳定性。我们都非常的清楚软件开发的曲线,越到软件开发的后期,修改软件的成本越高。因此,在软件开发上游的需求的变动将会对软件开发的下游产生天翻地覆的影响。为了协调这一矛盾,软工理论提出了螺旋开发模型,这就是我们在迭代开发模式中的讨论的理论基础。把软件开发过程分为多个的迭代周期,每一次的迭代周期最后都将生成一个可交付的软件,用户在每一次的迭代结束后,可以试用软件,提出下一步的需求或是改变原先的需求。通过这样的方式,把客户、开发商的风险降到一个可以接受的水平上。
请注意迭代的前提:需求的易变性。因此,对于那些需求容易发生变化的项目,我们就可以使用迭代式的开发过程,虽然我们会付出一些额外的成本(刚开始这个成本会比较大,但可以用较长的迭代周期来降低这种成本),但是风险减小了。而对于需求比较固定的项目,是不是有必要使用迭代的方法,就要看具体的环境了。因此,我们是根据实际的情况选用开发方法,而不是因为先进或是流行的原因。
实际上,由于现代社会的特性,大部分的项目都是可以采用迭代方法。因此,我们的选择就变成了了迭代周期应该要多长。迭代周期在理论上应该是越短越好,但是并没有一个绝对的数值,时间的跨度一般从几周到几个月。一般来说,迭代周期会受到几个因素的影响:
- 各模块的关联程度。在软件开发中,我们有时候很难把一些模块分离开来,要开发模块A,就需要模块B,而模块B又需要模块C。各模块的关联程度越高,迭代周期越长。当然,也相应的解决方法,我们可以在各模块的功能中选取出一些关键点,作为里程碑,以里程碑作为迭代完成点。
- 人员技能、经验的平均程度。团队中成员的开发能力、开发经验良莠不齐,这也是造成迭代周期延长的一个原因。能力低、经验少的开发人员会拖后每一次迭代的时间。针对这种情况,做好统筹规划就显得非常的重要,可以通过一两次的迭代,找出队伍中的瓶颈人员,安排相应的对策。
- 工具的缺乏。迭代周期越短,就意味着build、发布的次数越多,客户也就有更多的机会来修改需求。这要求有相关的工具来帮助开发人员控制软件。最重要的工具是回归测试工具。每一次迭代都需要增加新的功能,或是对原先的功能进行改动,这就有可能引入新的bug,如果没有回归测试,开发人员就需要花费时间重新测试原先的功能。
- 计划、控制的能力。迭代周期越短,所需要的计划、控制的能力就越强。因为短时间内的计划制定和实施需要高度的细分,这就要求开发团队的管理者对开发能力、工作量、任务分配有很强的认识,才能做好这项工作。不过,迭代周期越短,同样开发时间的迭代次数就越多,而团队调整、改进计划控制的机会就越多。因此,后期的迭代一般都能够做到比较精确的控制。而这样的做法,要比问题堆积到软件交付日才爆发出来要好的多。没有突然落后的软件,只有每天都在落后的软件。
简单和迭代
简单和迭代关系是双向的。
在现实设计我们很难界定出简单设计的程度。怎样的架构设计才算是简单?按照我们在简单设计模式中的讨论,刚好满足目前的需求的架构设计就算是简单的设计。但是,从另外一个方面考虑,需求的易变性限制我们做出简单的设计,因为我们不能够肯定目前的需求将来会发生什么样的变化。因此,为了克服对未知的恐惧,我们花了很大的力气设计一些灵活的、能够适应变化的架构。这是源自需求模式对简单设计模式的影响。
源自需求和迭代设计的关系的讨论建议我们把需求分为多个迭代周期来实现。那么,相应的架构设计也被分在多个迭代周期中。这样的方法可以降低架构设计的复杂程度。因为设计人员不需要考虑软件的全部需求,而只需要考虑当前迭代周期的需求。复杂性的降低将会有助于架构设计的简单化,从而达到简单设计的一系列的好处(参见简单设计)。
我们从迭代设计中的最后一个例子可以清楚的看到迭代设计是如何把复杂的需求给简单化的。把握迭代设计有助于我们避免过分设计的毛病。这是个技术人员经常犯的毛病。我所在的团队很多时候也无法避免。例如,在很多的项目中,我们都会花费大量的时间来设计数据库到业务实体的映射。诸如此类的技术问题对开发人员的吸引程度是不言而喻的,但是必须看到,这种的设计会导致开发成本的大幅度上升。更为糟糕的是,除非有丰富的经验,这种类型的设计给开发工作带来的价值往往无法超过其成本。
因此,我们需要学会权衡利弊,是否有必要投入大量的资源来开发其实并没有那么有用的功能。因此,迭代设计和简单设计的结合有助于我们摆脱过度设计的困扰,把精力集中在真正重要的功能之上。
此外,简单的设计并不等同于较少的付出。简单的设计往往需要对现实世界的抽象,回忆我们在简单设计中讨论的测量模式的例子,它看似简单,但实现起来却需要大量的业务知识、很强的设计能力。因此,做到简单是程序员不断追寻的目标之一。
在很多的方法论中,一般并不过分注意代码重复的问题,要么是不关注,要么认为适当的代码重复是允许的。而XP却把代码重复视为良好代码的大敌。"只要存在重复代码,就说明代码仍有Refactoring的可能。"这种观点看起来非常的绝对,这可能也正是其名字中Extreme的来历(英文中的Extreme属于语气非常重的一个单词)。从实践的角度上来看,追求不重复的代码虽然很难做到,但是其过程却可以有效的提高开发团队代码的写作质量,因为它逼迫着你在每次迭代重对代码进行改进,不能有丝毫的怠惰。而这种迭代的特性,促进了简单的实现。
团队和简单
我们在简单设计中提过简单设计需要全面的设计师。除此之外,它还需要团队的配合。简单意味着不同活动间交付工件的简单化。也就是说,类似于需求说明书、设计文档之类的东西都将会比较简单。正因为如此,我们很难想象一个地理上分布在不同地点的开发团队或一个超过50人的大团队能够利用这种简单的文档完成开发任务。
因此,简单的设计是需要团队的组织结构来保证的。简单的设计要求团队的相互沟通能够快速的进行。架构设计完成后,架构的设计思路传达给所有的编码人员的速度要块,同样,编码中发现问题,回馈给设计者,设计者经过改进之后再传达给收到影响的编码人员的速度也要快。象这样高效率的传播我们可以称之为"Hot Channel"。
为了保证"Hot Channel"的高沟通效率,最好的组织单位是开发人员在3到6人之间,并处于同间工作室中。这样的结构可以保证讯息的交互速度达到最高,不需要付出额外的沟通成本,也不需要过于复杂的版本控制工具或权限分配。根据我的经验,一个共享式的小型版本控制工具、网络共享、再加上一个简单的网络数据库就能够解决大部分的问题了。
在理论上,我们说只要分配得当,大型的团队同样可以组织为金字塔式的子团队,以提高大型团队的工作效率。但是实际中,随着团队的人数的增加,任务的正确分配的难度也随之加大,沟通信息上传下达的效率开始下降,子团队间的隔阂开始出现,各种因素的累加导致敏捷方法并不一定适合于大型的团队,因此我们讨论的敏捷方法都将受到团队的特性的限制。
模式的源头
如果你对XP有一定的了解的话,那么你可能会感觉到我们讨论的模式中应用了XP的实践。确实如此,XP中有很多优秀的实践,如果组织得当的话,这些微小的实践将会组合成为一套了不起的开发方法。不过,目前的软件开发混乱的现状阻止了先进的软件方法的应用,对一个身体虚弱的病人施以补药只会适得其反。因此,在前面讨论的模式中,我们应用了一些容易应用、效果明显的实践方法。在实践中适当的应用这些方法,并不需要额外的投入,却能够有很好的效果,同时还会为你的团队打下一个良好的基础。
8.架构愿景
从这一篇开始,我们将会进入另一个不同的主题,和前面所讨论的模式专注于组织、过程、方法不同,以后介绍的模式更偏重于设计。但是过程、方法的影子依然在我们的讨论中隐约可见。
架构愿景是一个很简单的模式,在软件开发中所占的时间也很短。但是这并不意味着架构愿景不重要。相反,它会是设计过程不可或缺的一环。
Context
在单次的迭代开始阶段,我们已经收集好了单次迭代的需求。
Problem
架构和分析设计是密不可分的,有时候很难说得清楚架构的定义,但架构应该能够描述软件的整体。架构包括了软件的各个方面,但是每一个设计细节总是需要单独考虑,这时候就会出现设计细节之间、以及设计细节和架构之间的不一致。
架构设计的各个部分之间的设计冲突是很容易发生的。发生的概率及频率和团队的规模成正比、和沟通的频度及效果成反比。在很多次的项目开发过程中,我们发现了多处的相同功能的代码,原因是代码的作者并不知道别人已经实现了这个功能了。这可能只是浪费了一点精力,可如果不同模块间的设计冲突导致了软件无法正常运行的时候,我们就需要坐下来好好的审视,究竟发生了什么。
Solution
我们需要建立一个架构愿景。架构愿景应该能够提供软件全局视图,包括所有的重要部分,定义了各个部分的责任和之间的关系,而且还定义了软件设计需要满足的原则。而这个架构愿景的设计,应该是满足源自需求模式的,也就是说,部分的划分和部分的设计,都是根据需求而进行的。同时,架构愿景应该要能够满足架构的其它各种特点,例如简单、可扩展性、抽象性。简单来说,我们把架构愿景当作是一个mini的架构设计。
由于我们是在单次的迭代中讨论架构愿景,因此从整体上考虑,架构愿景是也是在不断的变化的。这是很自然的,因为架构愿景代表了架构的设计,架构愿景的演进代表了架构设计的演进。
架构的愿景是相对于一个范围来说的,在一个特定的软件功能范围之内,谈架构愿景才有实际的意义,例如针对软件的全局或某个子模块。在这个特定的范围中,订立了架构愿景之后,这个范围内的所有设计原则将不能违背架构愿景。这是非常重要的,是架构愿景的最大的用处。有了这样的保证,我们就可以保证设计的一致性和有效性。任何一项设计的加入,都能够融入到原先的架构中,使得软件更加的完善,而不是更加的危险。
当然,要做到这一点并不是一件容易的事情。特别需要指出的是,架构愿景模式仅仅是实现该目的的一条道路,并不是一个充分条件。如果在设计中愿景不能够贯彻其意志,或是愿景制定本身就存在问题,那么要想达到上述的效果,几乎是不可能的事情。此外,该模式仅仅只是达到该目的的第一步,我们在接下去的模式中会发现还需要很多方面的配合。
架构愿景的层次
我们根据架构适用范围的不同,把架构愿景分为几个类别讨论:
软件全局
软件全局的架构是我们所最关心的,因此也会花费最多的笔墨。
软件全局中的架构愿景一般很难具体化到代码级别,其实你会发现,就算是具体化到了代码级别,也会因为实际中存在的问题,导致代码没有太多的价值。因此,为软件全局设置的架构愿景可以以原则、或是模式名的方式体现,并用自然语言或伪代码描述。例如,可以为一个系统规定三层架构作为其愿景,并指出三层的分类原则。注意,我们需要指出分类原则,否则规定三层架构并没有太大的意义,因为三层架构随着实现平台的不同、开发人员的不同而有很大的差异,如果不能够规定一个可操作的规范,那么愿景是没有意义的。在Java环境下,我们可以这样说:
"客户端采用前端浏览器界面,业务逻辑采用servlet,配合JSP编写,浏览器到服务器的数据采用集中处理,具体的方法是在业务逻辑和前端浏览器之间采用Front Control模式,接受前端浏览器传送过来的数据,并指派给相应的业务逻辑处理。数据的合法性检验分为两个部分:和业务逻辑无关的基本合法性验证在前端使用Java Script处理,和业务逻辑相关的合法性验证在业务逻辑层处理,可以使用一个集中的servlet专门处理错误情况。"
而在Windows环境下,我们知道三层结构的分界和Java中的并不相同(关于这一点,下一篇的文章中将会有详细的描述),我们可以在业务逻辑层或显示层直接操纵数据,非常的方便,因此,在Windows中的架构愿景的描述又不一样。另外,在非面向对象的环境中,在分布式的环境中,在Lotus Notes的环境中,其架构的描述都不一样。
注意到,架构愿景的制定是根据不同的应用环境而变化的,这一点我们在文章的一开始就强调过,这里又出现了相同的问题。我们可以通过一个简单的例子来加深对这个问题的理解。在Java的环境下,特别是J2EE的环境下面,经典的设计思路是把数据库的一张表视作一个类,表中的每一行都是这个类的一个具体的实例,表的每一个字段对应了类的一个变量,类一般支持Find方法,来获取数据不同的实例。这是一种很自然的设计思路,而且可以通过CMP等技术来简化设计的繁琐步骤。可是,在Windows环境下,相信没有多少人会这么做。常用的处理方法是使用记录集(RecordSet)的方式,也就是说针对一组的记录来进行操作,而不是单个的记录。这些记录可以是跨表的(使用SQL的连接),也可以是单表的。由于Windows环境下大多数的编程语言都能够支持记录集方式,因此编写基于记录集方式的程序是相当简单的。当然,我们也可以采用J2EE平台的编程方式,但是会导致编程量的激增,同时也难以达到预计的效果。这种平台的差别导致了架构愿景的差别。
此外,我们注意到我们对架构的描述其实还是不够仔细,这是非常正常的,因为随着开发的深入,我们会不断的完善架构的描述。例如,我们可以写出Front Controll的类框架,写出主要的几个Servlet,以及他们之间的关系。这个时候,使用UML类图会是一个不错的选择,实际上,我非常推崇在架构设计中大量的使用类图。不过在实际的运作中,每个软件团队大多数都有自己熟悉的架构设计思路,使用何种工具并不是主要的问题。
我们从源自需求模式中,学习到架构的设计是来自于需求的,而应用于软件全局的架构则来自于最重要的需求。还记得我们在那个模式中提到的网上宠物店的例子吗?系统采用了MVC模式,sun的官方文档一开始说明了为什么采用MVC模式,MVC模式解决了什么问题,然后开始分析MVC模式的几个组成部分:Model、View、和Controll。其实,MVC中的每一个部分,在真正的代码中,大都代表了一个子系统,但是在目前,我们就非常的清楚系统大致上会是一个什么样子,虽然这时候它还十分的朦胧。
不要视图在全局的架构愿景中就制定出非常细致的规划,更不要视图生成大量的实际代码。因为,你的架构愿景还没有稳定(我们在其后的稳定化的模式中将会讨论稳定的问题),还没有获得大家的同意,也没有经过证明。因此,从整个的开发周期来看,全局架构愿景是随着迭代周期的进行不断发展、修改、完善的。
我们如何确定全局架构愿景工作的完成?一般来说,你的架构设计团队取得了一致的意见就可以结束了,如果问题域是团队所熟悉的,一两个小时就能够解决问题。接下来设计团队把架构愿景传播到整个的开发团队,大家形成一致的认识,不同的意见将会被反馈会来,并在本次的迭代周期(如果时间比较紧迫)或下一次的迭代周期中(如果时间比较宽松)考虑。
子模块级、或是子问题级的架构愿景
这时候的架构愿景已经是比较明确的了,因为已经存在明确的问题域。例如界面的设计、领域模型的设计、持久层的设计等。这里的愿景制定本质上和全局的愿景制定差不多,具体的例子我们也不再举了。但是要注意一点,你不能够和全局愿景所违背。在操作上,全局愿景是设计团队共同制定出来的,而子模块级的架构愿景就可以分给设计子团队来负责,而其审核则还是要设计团队的共同参与。这有两个好处,一是确保各个子模块(子问题)间不至于相互冲突或出现空白地带,二是每个子设计团队可以从别人那里吸取设计经验。
在设计时,同样我们可以参考其它的资料,例如相关的模式、或规范(界面设计指南)。在一个有开发经验的团队,一般都会有开发技术的积累,这些也是可供参考的重要资料。
我们在这个层次的愿景中主要谈一谈子模块(子问题)间的耦合问题。一般来说,各个子模块间的耦合程度相对较小,例如一个MIS系统中,采购和销售模块的耦合度就比较小,而子问题间的耦合程度就比较大,例如权限设计、财务,这些功能将会被每个模块使用。那么,我们就需要为子模块(子问题)制定出合同接口(Contact Interface)。合同的意思就是说这个接口是正式的,不能够随意的修改,因为这个结构将会被其它的设计团队使用,如果修改,将会对其它的团队产生无法预计的影响。合同接口的制定、修改都需要设计团队的通过。此外,系统中的一些全局性的子问题最好是提到全局愿景中考虑,例如在源自需求模式中提到的信贷帐务的例子中,我们就把一个利息计算方式的子问题提到了全局愿景中。
代码级的愿景
严格的说这一层次的愿景已经不是真正的愿景,而是具体设计了。但是我们为了保证对架构设计理解的完整性,还是简单的讨论一下。这一个层次的愿景一般可以使用类图、接口来表示。但在类图中,你不需要标记出具体的属性、操作,你只需要规定出类的职责以及类之间的相互关系就可以了。该层次愿景的审核需要设计子团队的通过。
而设计细分到这个粒度上,执行愿景设计的开发人员可能就只有一两个左右。但是比较重要的工作在于问题如何分解和如何归并。分解主要是从两个维度来考虑,一个是问题大小维,一个是时间长短维。也就是说,你(设计子团队负责人)需要把问题按大小和解决时间的长短分解为更细的子问题,交给不同的开发人员。然后再把开发人员提出的解决方法组合起来。
架构愿景的形成过程
架构愿景的形成的源头是需求,需要特别指出的是,这里的需求主要是那些针对系统基本面的需求。比如说,系统的特点是一个交互式系统,还是一个分布式系统。这些需求将会影响到架构愿景的设计。在收集影响架构愿景的各项需求之后,按照需求的重要性来设计架构愿景。
架构愿景的设计并不需要很复杂的过程,也不需要花费很多的时间。我们已经提过,架构远景的主要目的就是为了能够在开发团队中传播设计思路,因此,架构愿景包括基本的设计思路和基本的设计原则。
值得注意的是,架构远景可能会有多种的视角,下文讨论了一种设计模式的视角。但是实际设计中还可能会基于数据库来设计架构愿景。但在企业信息系统的设计中,我推荐使用领域类的设计,也就是下文中讨论的例子。
架构愿景设计好之后,问题的焦点就转到如何传播架构愿景上来,为了达到在开发团队中取得统一设计意图的效果,可以考虑援引团队设计模式。除此之外,针对性的项目前期培训也会是一种有效的做法。
使用架构模式
架构模式也是一种很好的架构愿景设计思路的来源。随着对设计模式的研究的深入,人们发现其中的一些设计模式可以扩展、或变化为软件设计的基础。在这个基础上再实现更多的设计,这些模式就形成了架构模式。当然,不同的软件,它们的架构模式也是不一样的。在《Applying Pattern》一文中,有一个很典型的架构愿景的例子:
假设我们需要设计分布式的交互式系统。分布式系统和交互式系统都有特定的架构模式,前者为Broker模式,后者为MVC模式。首先我们先要根据系统的特点的重要程度来排列模式的顺序。这里假设需求中分布式特性更重要一些。那么我们首先选择Broker模式作为架构的基本模式:
再考虑交互式的特点,根据MVC模式的特点,我们需要从目前的基本架构中识别出Model、Controller、以及View。Model和View都很简单,分别分布在上图中的Server和Client中。而Controller则有两种的选择,假设这里的Controller部署在客户端,上图则演化为下图:
这样,基础的架构愿景就已经出现了。如果我们还有更多的需求,还可以继续改进。但是,记住一点,架构愿景不要过于复杂。正如我们在上一节中所讨论的,这里我们虽然是基于设计模式来讨论架构愿景,但是实际中还有很多从其它的视角来看待架构愿景的。至于要如何选择架构愿景的视角,关键的还是在于需求的理解。
9.分层 (上)
在定义了架构愿景之后,团队中的所有人员应该对待开发的软件有一定的了解了。但是,面对一个庞大的软件系统,接下来要做些什么呢?分而治之的思想是计算机领域非常重要的思想,因此我们也从这里开始入手。
要进行应用软件的设计,分层是非常重要的思想,掌握好分层的思想,设计出的软件是可以令人赏心悦目的。由于这一章的重要性和特殊性,本章的内容分为上下两节,并不采取模式描述语言的方式。
分层只是将系统进行有效组织的方式。
本章特别针对于企业应用进行讨论,但其中大部分的内容都可以应用在其它的系统中,或为其它的系统所参考。
在企业应用中,有两个非常重要的概念:业务逻辑和持久性。可以说,企业应用是围绕着业务逻辑进行开展的。例如报销、下订单、货品入库等都是业务逻辑。从业务逻辑的底层实现来看,业务逻辑其实是对业务实体进行组织的过程。这一点对于面向对象的系统才成立,因为在面向对象的系统中,识别业务实体,并制定业务实体的行为是非常基础的工作,而不同的业务实体的组合就形成了业务逻辑。
还有另一个重要的概念是持久性。企业应用中大部分的数据都是需要可持久化的。因此,基础组织支持持久性就显得非常的重要。目前最为通行的支持持久性的机制是数据库,尤其是关系性数据库-RDBMS。
除此之外,在企业应用中还有其它的重要概念,例如人机交互。
为了能够更有效的对企业中的各种逻辑进行组织,我们使用层技术来实现企业应用。层技术在计算机领域中有着悠久的历史,计算机的实现中就引用了分层的概念。TCP/IP的七层协议栈也是典型的分层的概念。分层的优势在于:
上层的逻辑不需要了解所有的底层逻辑,它只需要了解和它邻接的那一层的细节。我们知道TCP/IP协议栈就是通过不同的层对数据进行层层封包的,不同层间的耦合度明显降低。通过严格的区分层次,大大降低了层间的耦合度。
某一层次的下级层可以有不同的实现。例如同样的编程语言可以在不同的操作系统和不同的机器中运行。
和第三条类似的,同一个层次可以支持不同的上级层。TCP协议可以支持FTP、HTTP等应用层协议。
综合上面的考虑,我们把企业应用分为多个层次。企业应用到底应该分为几种层次,目前还没有统一的意见。
在前些年的软件开发中,两层结构占有很重要的位置。例如在银行中应用很广的大型主机/终端方式,以及Client/Server方式。两层的体系结构一直到现在还广泛存在,但是两层结构却有着很多的缺点,例如客户端的维护成本高、难以实现分布式处理。随着在两层结构的终端用户和后端服务间加入更多的层次,多层的结构出现了。
经典的三层理论将应用划分为三个层次:
表示层(Presentation Layer),用于处理人机交互。目前最主流的两种表示层是Windows格式和WebBrowser格式。它主要的责任是处理用户请求,例如鼠标点击、输入、HTTP请求等。
领域逻辑层(Domain Logic Layer),模拟了企业中的实际活动,也可以认为是企业活动的模型。
数据层(Data source Layer),处理数据库、消息系统、事务系统。
在实际的应用中,三层结构有一些变化。例如,在Windows的.NET系统中,把应用分为三个层次:表示层(Presentation Layer)、业务层(Business Layer)、数据访问层(Data Access Layer),分别对应于经典的三层理论中的三个层次。值得一提的是,.NET系统中表示层可以直接访问数据访问层,即记录集技术。在ADO.NET中,这项技术已经非常成熟,并通过表示层中的某些数据感知组件,实现非常友好的功能。这种越层访问的技术通常被认为是不被允许的,因为它可能会破坏层之间的依赖关系。而在Windows平台中,严格遵守准则就意味着需要大量额外的工作量。因此,我们看到准则也不是一成不变的。
在J2EE的环境中,三层结构演变为五层的结构。在表示层这里,J2EE将其分为运行在客户机上的用户层(Client Layer),以及运行在服务端上的Web层(Presentation Layer)。这样做的主要理由是Web Server已经成为J2EE中非常核心的技术,例如JSP和Java Servlet都和它有关系。Web层为用户层提供表示逻辑,并对用户的请求产生回应。
业务层(Business Layer)并没有发生变化,仍然是处理应用核心逻辑之处。而数据层则被划分为两个层次:集成层(Integration Layer)和资源层(Resource Layer)。其中,资源层并非J2EE所关心的内容,它可能是数据库或是其它的老系统,集成层是重要的层次,包括事务处理,数据库映射系统。
实例
这一章的的组织方式和之前的模式有一些差别。我们先从一个例子来体会架构设计中分层的重要性。
上图是一个业务处理系统的软件架构图。在上图中,我们把软件分为四个层次。这种层次结构类似于我们前面所谈的J2EE的分层。但是和J2EE不同的是,缺少了一个Web Server层,这是因为目前我们还没有任何对Web Server的需要。
在资源层上,我们有三种资源:数据库、平台服务、UI。数据库是企业应用的基础,而这里的平台服务指的是操作系统系统或第三方软件提供的事务管理器的功能。值得注意的是,这里的事务管理器指的并不是数据库内部支持的事务,而是指不同的业务实体间事务处理,这对于企业应用有着很重要的意义。因为对于一个企业应用来说,常常需要处理跨模块、跨软件、甚至跨平台的会话(Session),这时候,单纯的数据库支持的事务往往就难以胜任了。这方面的例子包括微软的MTS和Oracle的DBLink。当然,如果说,在你处理的系统中,可以使用数据库事务来处理大部分的会话的话,那就可以避免考虑这方面的设计了。除了典型的事务管理器,平台还能够提供其它的服务。对于大部分的企业应用来说,都是集成了多个的平台服务,平台服务对架构的设计至关重要。但是从分层的角度上考虑,上层的设计应该尽可能的和平台无关。和使用平台服务类似的,一般来说,企业应用都不会从头设计界面,大部分情况下都会使用现有的的UI资源。比如Window平台的MFC中的界面部分。因此,我们把被使用的UI资源也归到资源层这个层次上。
资源层的上一层是集成层。集成层主要完成两项工作,第一项是使用资源层的平台服务,完成企业应用中的事务管理。有些事务处理机制已经提供了比较好封装机制,直接使用资源层的平台服务就可以了。但是对于大多数的应用来说,平台提供的服务往往是比较简单的,这时候集成层就派上大用场了。第二项是对上一层的对象提供持久性机制。可以说,这是一组起到过渡作用的对象。它实际上使用的是资源层的数据库功能,并为上一层的对象提供服务。这样,上一层的业务对象就不需要直接同数据库打交道。对于那些底层使用关系型数据库,编程中使用面向对象技术的系统来说,目前比较常见的处理持久性的做法是对象/关系映射(OR Mapping)。
在这个层次上,我们可以做一些扩展,来分析层的作用。假设我们的系统需要处理多个数据库,而不同数据库之间的处理方式有一定的差异。这时候,层的作用就显示出来了。我们在集成层中支持对多个数据库的处理,但对集成层以上的层次提供统一的接口。对于业务层来说,它不知道,也不需要知道数据库的差别。目前我们自己开发了集成层中的持久类,但是随着功能的扩展,原有的类无法再支持新增加的功能了,新的解决方案是购买商用程序。为了尽可能的保持对业务层次的影响,我们仍然使用原有的接口,但是具体的实现已经不同了,新的代码是针对新的商业程序来实现的。而对业务层来说,最理想的状况是不需要任何的改变。当然现实中不太可能出现如此美好的情况,但可以肯定的一点是,引入层次比不引入层次要好的多。
以上列举的两个例子都是很好的解决了耦合度的问题。关于分层中的耦合度的问题,我们在下面还会讨论。
业务层的设计比较简单,暂时只是把它实现为一组的业务类。类似的,表示层的设计也没有做更多的处理。表示层的类是继承自资源层的。这是一种处理的方法,当然,也可以是使用关系,这和具体的实现环境和设计人员的偏好都有关系,并不是唯一的做法。在对软件的大致架构有了一个初步了解之后,我们需要进一步挖掘需求,来细化我们的设计。在前面的设计中,我们对业务层的设计过于粗糙了。在我们的应用中,还存在一个旧系统,这个系统中实现了应用规则,从应用的角度来看,这些规则目前仍然在使用,但新的系统中会加入新的规则。在新系统启用后,旧的系统中的规则处理仍然需要发挥它的作用,因此不能够简单的把所有的规则转移到新系统中。(有时候我们是为了节省成本而不在新系统中实现旧系统的逻辑)。我们第二步的架构设计的细化过程中将会加入对新的要求的支持。
在细化业务层的过程中,我们仍然使用层技术。这时候,我们把原先的业务层划分为两个子层。对于大多数的企业应用来说,业务层往往是最复杂的。企业对象之间存在着错综复杂的联系,企业的流程则需要用到这些看似独立的企业对象。我们希望在业务层中引入新的机制,来达到组织业务类的目的。业务层的组织需要依赖于具体的应用环境,金融行业的应用和制造行业的应用就有着巨大的差距。这里,我们从一般性的设计思考的角度出发来看待我们的设计:
首先,我们看到,业务层被重新组织为两个层次。增加层次的主要考虑是设计的重用性。从我们前面对层的认识,我们知道。较高的层次可以重用较低的层次。因此,我们把业务层中的一部分类归入较低的层次,以供较高的层次使用。降低类层次的主要思路是分解行为、识别共同行为、抽取共性。
在Martin Fowler的分析模式中提供了一种将操作层和知识层分离的处理方法:
Action、Accountability、Party属于较高层次的操作层(Operational Layer),这一层次的特点是针对于特定的应用。但是观察Accountability和Party,有很多相似的职责。也就是说,我们对于知识的处理并不合适。因此,Martin Fowler提出了新的层次――属于较低层次的知识层(Knowledge Layer)。操作层中可重用的知识被整理到知识层。从而实现对操作层的共性抽取。注意到虽然图中的层次(Level)的概念和层(Layer)有所差别,但是思路是基本一致的。
另一种分层方法是利用继承:
该图中也是来自于分析模式一书。不同的部门有着差异点和共性,将共性提取到父类中是继承的基本概念。这时候我们可以把父类看作是较低的层次,而把子类看作是较高的层次。对于一组层次结构很深的类来说,也可以从某一个水平线上分离层次。
最后一种方法需要分解并抽象对象的行为。C++的STL中为不同的数据类型和不同的容器实现了Iterator的功能。它的实现是典型的降低层次的行为。我们不考虑它对不同数据类型的支持,因为它使用了比较独特的模板(Template)技术,只考虑它对不同的容器的实现。首先是对共性的分析,不论是数组(Array)还是向量(Vector),要执行遍历操作,都需要知道三个条件:容器的起始点、容器的长度、匹配值。这就是一种抽象。通过这种方式,就可以统一不同容器的接口。
以上我们讨论了细分层次的好处和实现策略。下面我们回到前例中,继续讨论层中的各个部件。
业务实体指的是企业中的一些单独的对象。例如订单、客户、供应商等。由于业务实体可以被很多的业务流程(业务会话)所使用,因此我们把业务实体放在业务实体层中。企业中的业务实体大多是需要持久性的。因此在我们的设计中,业务实体将持久性的职责委托给下一个层次中的持久性包,而不是直接调用数据库方法。另一种常用的方法是使用继承――业务实体继承自持久类。EJB中的Entity Bean就是典型的业务实体,它可以支持自动的持久性机制。
在我们的系统中,规则是一个尴尬的存在。部分的规则处于旧系统中,并使用旧系统的持久性机制。而新系统中有需要支持新的规则。对于上层的用户来说,并不需要知道新旧系统的规则。如果我们在使用规则时做一个新旧规则的判断并调用不同的系统函数,那就显得太傻了。我们的设计对上层而言应该是透明的。
所以我们总共设计了三个包来实现规则的处理。包装器包装了旧系统中的规则,这样做处于几点考虑:首先,旧系统是面向过程的,因此我们需要用包装器将函数(或过程)封装到类中。其次,对旧系统的使用需要额外的程序(或平台服务)的支持,因此需要单独的类来处理。最后,我们可以使用Adapter模式[GOF 94]将新旧系统不同的接口给统一起来,以使他们能够一起工作。新的规则类实现了新的规则,较好的做法是定义一个虚协议,具体的表现形式可以是虚基类或接口。然后由其子类实现虚协议。再结合前面介绍的把旧规则的接口转换为新的接口的方法,就能够统一规则接口。从图中我们看到,业务实体需要使用规则,通过统一的接口,业务实体就能够透明的使用新旧两种规则。
在定义了规则和标准的规则接口之后,上一层的规则控制器就可以通过调用规则,来实现不同规则组合。因此这个规则控制器就类似于应用规则。在业务交易处理中需要调用规则控制器来实现部分的功能。
请注意,这里讨论的思路虽然非常的简单、清晰,但是现实中的处理却没有这么容易。因为为新旧规则制定统一的接口实在是太难了。要能够使接口在未来也相对的稳定更是难上加难。而在应用已经部署之后,对已发布的接口的任何一个小改动都意味着很高的成本。在这个问题上,并没有什么好的策略,经验是最重要的。在实际中的一个比较实用的方法是为接口增加一些额外的参数。即便可能这个参数当前并没有使用到,但是如果为了有可能使用的话,那还是加上吧。举个例子,对于商业应用软件而言,数据库,或者说是关系数据库一定是不可缺少的部分。大量的操作都需要和数据库结合起来,因此,可以考虑在方法中加入一个数据库连接字符串的参数。虽然这对于很多方法而言是没什么必要的,但是为将来的实践提供了一个可扩展的空间。国内的某个知名的ERP软件的设计就采用了这种思路。
本章的主要精力都放在实例的研究上,在有了一个基本的概念之后,我们再回来谈谈分层的一些原则和需要注意的问题。
10.分层 (下)
上篇我们用了大量的篇幅来观察了一个实际的例子,相信大家已经对分层有了一个比较具体的概念了。在这一篇中我们就对分层在实践中可能会遇到的问题做一个讨论。分层在架构设计中是一种非常常见的,但是又很不容易用好的技术。因此我们这里花了很大的气力来讨论它。
由于这是一篇介绍软件设计技术的文章,为了尽可能让更多的人理解,本应该尽可能不涉及到过于具体的技术或平台。但是这个目标可能很难实现,因为软件设计是没办法脱离具体的实现技术的。因此本文能够做到的是尽可能的不涉及具体的编码细节。
何时使用分层技术?
分层技术实际上是把技术复杂化了。和以往简单的CS结构的系统不同,分层往往需要使用特定的技术平台来实现。当然,不使用这些技术平台也是可能的,但是效果可能就没有那么好了。支持分层技术的平台有很多,包括目前主流的J2EE和.NET。甚至在不同厂商的开发平台上,要求也不一样。使用分层技术实现的多层架构,成本要比普通的CS架构高得多。
这就产生了一个非常现实的问题-并不是所有的软件都适合采用分层技术的。一般来说,小型的软件使用分层并没有太大的意义,因为分层导致的成本超过它所能带来的好处。在一般的CS结构中,可以把界面控制、逻辑处理和数据库访问都放在一块儿。这种设计方式在纯粹的多层主义者看来简直就是十恶不赦。但是对于小型的软件而言,这并不是什么大不了的事情。因为从表示层到数据层的整套功能都被囊括在一个功能块中,同样能够实现较好的封装。而且,如果结构设计的足够好,也能够避免表示层、业务层和数据层之间出现过高的耦合度。因此,除非确实需要,不然没有必要使用分层技术。
尤其在处理一些特殊的项目时,严格的区分三层结构并不理想。比如在快速开发windows界面的应用时,往往会用到一些对数据库敏感的控件,这种处理方法跨越了三个层次,但是却很实用,成本也比较低。又比如一些框架,给出了从界面层到数据库的综合的解决方案,和windows的应用类似,严格的三层技术也不适用于这种情况。
如何使用分层技术?
从某种意义上来看,层其实是一个粗粒度的组件。就像我们使用组件技术是为了对系统进行一种划分一样,层的一个很大的作用也是如此。其目的是为了系统更容易被理解,不同的部分能够被较容易的替换。
使用分层技术的依据是软件开发人员的实际需要。如果你是在使用某些优秀的面向对象的软件开发平台的话,那它们一般都会建议(或是强制)你使用某一种分层机制。这是你采用分层技术的一大参考。
对于大多数有一定经验的软件团队而言,一般都会积累一些软件开发经验。其中包含了很多在某些特定的领域中使用的基础的类或组件。这些元素构成了一个系统的通用层次。这个层次也是分层时需要考虑的。例如一些应用软件中使用的一些通用的Currency对象或是Organization对象。分析模式一书对此类的对象进行了充分细致的阐述。这个层次一般被称为跨领域层(cross-domain layer),或称为工具层(utility layer)。
目前的很多软件都采用了数据库映射技术。数据库映射层对于企业应用系统非常的重要,因此也需要纳入考虑之列。数据库映射技术用起来简单,但是要实现可不容易。如果不是非常有必要,尽可能使用现成的框架,或是采用其中部分的设计思路。试图构建一个大而全的映射层次的代价是非常高昂的,认识不到这一点会带来很大的麻烦。数据库映射技术的知识,我们在下文中还有专门的篇幅来讨论。
如何存放数据(状态)?
在学习EJB的过程中,最先要理解的一定是有状态和无状态的概念。可以说,整个概念是多层体系的核心。为什么这么说呢?这里的状态指的是类的状态,例如类的属性、变量等。由于状态的不同,类也表现出差异来。而对于多层结构的软件,创建和销毁一个类的开销是很大的,如果该软件支持分布式的话尤为如此。所以如果系统的不同层次间进行频繁的调用-创建一个类,再销毁一个类。这种做法是非常消耗资源的。在应用系统的设计中,一般不单独使用COM,就是这个原因。所以我们很自然的想到了一种经典的设计-缓冲池。把对象存放在缓冲池中,当需要的时候从池中取出一个,当不需要的时候再把对象放入池中。这种设计思路能够大幅度的提高效率。但是这对能够放在池中的对象也提出了苛刻的要求-所有的对象必须是无差异的,也就是无状态的。只有这样才能够实现缓冲池。
一般来说,对象缓冲池的技术是用在中间的业务层上的。既然中间业务层上不能够保留有状态,那就出现了一个状态转移的问题。这里有两种的选择,一种是前移,把状态移到用户端,最典型的是使用cookie。这种选择一般是由于状态和用户端有关,不需要长时间保存。另一种选择是后移,把状态移到数据层,由数据库来实现持久性状态,当需要时才把状态提交给业务层。这种方式是企业应用软件中采用最多的,但是也增大了数据库的负担。
处理好接口
由于使用了分层技术,因此原先那种在CS结构中类之间存在复杂关系就有必要重新评估了。一般层间的耦合度不宜过大。因此需要慎重的设计层之间的类调用方式。一些分布式软件体系(例如J2EE)对层之间的调用方式以接口的形式给出了要求。同时,不同层之间仅仅知道目标层的接口,而不知道目标层的具体实现。EJB的home接口和remote接口就是这样。在COM+体系中,也需要在设计类的同时,把接口公布出来,以供客户方使用。
在设计层间的接口时,除了考虑开发平台的约束之外,还有一点是开发人员必须考虑的。那就是业务需要。业务层中往往有非常多的对象和方法,它们之间的关系也非常的负责,但对于其它的层次来说,它并不关心这些细节。因此业务层公布的接口必须要简单,而且和实现无关。因此,可以使用设计模式的Facade模式来简化层间的接口。这种做法非常有效,EJB中的SessionBean和EntityBean区分就含有这种设计思路。
同样的,不同层之间的数据传递也存在问题。如果不同层的物理节点在一起还好办,如果不在一起,那就需要使用到分布式技术了。因为不同机器的内存地址编码是不同的,如果接口之间采用对象引用的方式,那一定会出现问题。因此会将对象打包成字符串,发送到目标机器后再还原为对象。所有的分布式平台都提供了对这种技术的支持,这里就不多说了。但是这种实现技术会对我们的设计思路产生影响,少量的数据直接使用字符串来传递,数据量大的话,就需要使用封装了数据的对象。这类对象的设计需要非常的小心。在设计过程中可以参照开发平台提供的一些标准做法。同样的,数据的请求的频率也是难点之一。过于频繁的操作来自后端的数据会加大系统的开销。因此,在设计调用方法时同样需要结合实际应用来考虑。
兼顾效率
一般来说,纯粹的面向对象设计者设计出的软件都会比较完美。但是需要付出一定的代价。在一些大的软件平台上编程的时候,往往需要利用到平台的一些机制。最典型的就是平台的事务机制(最典型的包括J2EE平台的JTS,以及COM+平台的MTS),但是事务机制的实现往往需要平台大量对象的支撑。这种情况下,创建一个支持事务的对象的开销是很大的。处理这种问题有一种变通的办法,就是仅仅对需要事务支撑的对象提供事务支持。这就意味着,一个单独的业务实体类,可能需要根据是否支持事务分为两种类:对该业务实体的select方法不需要事务的支持,只有update和delete方法才需要有事务的支持。这是不符合纯面向对象设计者的观点的。但是这种做法却可以获得比较优秀的效率。
图1 将单个的业务实体分为不同的实现
应该承认,这种提高效率的做法加大了复杂度。因为对于客户端来说,它们并不关心具体的实现技术。要求客户端在某一种情况下调用这个类,在其它情况下又调用另一个类,这种做法既不符合面向对象的设计思路,也增大了层间耦合度及复杂性。因此,我们可以考虑使用接口或是外观类(参见设计模式一书中的facade模式),把具体的实现封装起来,而只把用户关心的部分提供给用户。这方面的技巧我们在下面的章节中还会提到。
以迭代的方式进行分层
软件设计中的迭代做法同样可以适用于分层。根据自己的经验,在一开始就定义好所有的层次是很难的。除非有着非常丰富的经验,都则实现和原先的设计总有或大或小的差距。因此调整势在必行。每一次的迭代都能够对分层技术进行改进,并为后一个项目积累了经验。
这里的分层迭代不可以过于频繁,每一次的迭代都是对架构的重大修改,都是需要投入人力的,而且会影响到软件开发的进度。但是成功的迭代的效果是非常明显的,能够在接下来的开发周期中起到稳定架构,减少代码量,提升软件质量的功效。注意,不要让新潮技术成为分层迭代的推动力。这是开发人员都常犯的毛病,这并不是什么缺点,只能称为一种职业病吧。分层迭代的推动力应该源自于需求的演进以及现有架构的不稳定已经妨碍了软件进一步的开发。因此这需要团队中的技术主管对技术有着非常好的把握。
重构能够对迭代有所帮助。嗅出代码中隐藏的坏味道并加以改进。应该说,迭代是一种比较激烈的做法,更好的做法是在开发中不断的对架构、层次进行调整。但这对团队、技术、方法、过程都有着很高的要求。因此迭代仍然是一种主要的改进手段。
层内的细分
分层的思路还可以适用于层的内部。层内的细分并没有固定的方式,其驱动因素往往是出于封装性和重用的考虑。例如,在EJB体系中的业务层中,实体Bean负责实现业务对象,因此一个应用往往拥有大量的实体Bean。而用户端并不需要了解每一个的实体Bean,对它们来说,只要能够完全一些业务逻辑就可以了,但完成这些业务逻辑则需要和多个实体Bean打交道。因此EJB提供了会话Bean,来负责把实体Bean封装起来,用户只知道会话Bean,不知道实体Bean的存在。这样既保证了实体Bean的重用性,又很好的实现了封装。
面向接口编程
在前面的章节中,我们提到一个接口设计的例子。为什么我们提倡接口的设计呢?Martin Fowler在他的分析模式一书中指出,分析问题应该站在概念的层次上,而不是站在实现的层次上。什么叫做概念的层次呢?简单的说就是分析对象该做什么,而不是分析对象怎么做。前者属于分析的阶段,后者属于设计甚至是实现的阶段。在需求工程中有一种称为CRC卡片的玩艺儿,是用来分析类的职责和关系的,其实那种方法就是从概念层次上进行面向对象设计。因此,如果要从概念层次上进行分析,这就要求你从领域专家的角度来看待程序是如何表示现实世界中的概念的。下面的这句话有些拗口,从实现的角度上来说,概念层次对应于合同,合同的实现形式包括接口和基类。简单的说吧,在概念层次上进行分析就是设计出接口(或是基类),而不用关心具体的接口实现(实现推迟到子类再实现)。结合上面的论述,我们也可以这样推断,接口应该是要符合现实世界的观念的。
在Martin Fowler的另一篇著作中提到了这样一个例子,非常好的解释了接口编程的思路:
interface Person { public String name(); public void name(String newName); public Money salary (); public void salary (Money newSalary); public Money payAmount (); public void makeManager (); } interface Engineer extends Person{ public void numberOfPatents (int value); public int numberOfPatents (); } interface Salesman extends Person{ public void numberOfSales (int numberOfSales); public int numberOfSales (); } interface Manager extends Person{ public void budget (Money value); public Money budget (); } |
可以看到,为了表示现实世界中人(这里其实指的是员工的概念)、工程师、销售员、经理的概念,代码根据人的自然观点设计了继承层次结构,并很好的实现了重用。而且,我们可以认定该接口是相对稳定的。我们再来看看实现部分:
public class PersonImpFlag implements Person, Salesman, Engineer,Manager{ // Implementing Salesman public static Salesman newSalesman (String name){ PersonImpFlag result; result = new PersonImpFlag (name); result.makeSalesman(); return result; }; public void makeSalesman () { _jobTitle = 1; }; public boolean isSalesman () { return _jobTitle == 1; }; public void numberOfSales (int value){ requireIsSalesman () ; _numberOfSales = value; }; public int numberOfSales () { requireIsSalesman (); return _numberOfSales; }; private void requireIsSalesman () { if (! isSalesman()) throw new PreconditionViolation ("Not a Salesman") ; }; private int _numberOfSales; private int _jobTitle; } |
这是其中一种被称为内部标示(Internal Flag)的实现方法。这里我们只是举出一个例子,实际上我们还有非常多的解决方法,但我们并不关心。因为只要接口足够稳定,内部实现发生再大的变化都是允许的。如果对实现的方式感兴趣,可以参考Matrin Fowler的角色建模的文章或是我在阅读这篇文章的一篇笔记。
通过上面的例子,我们可以了解到,接口和实现分离的最大好处就是能够在客户端未知的情况下修改实现代码。这个特性对于分层技术是非常适用的。一种是用在层和层之间的调用。层和层之间是最忌讳耦合度过高或是改变过于频繁的。设计优秀的接口能够解决这个问题。另一种是用在那些不稳定的部分上。如果某些需求的变化性很大,那么定义接口也是一种解决之道。举个不恰当的例子,设计良好的接口就像是我们日常使用的万用插座一样,不论插头如何变化,都可以使用。
最后强调一点,良好的接口定义一定是来自于需求的,它绝对不是程序员绞尽脑汁想出来的。
数据映射层
在各个层的设计中,可能比较令人困惑的就是数据映射层了。由于篇幅的关系,我们不可能在这个问题上讨论太多,只能是抛砖引玉。如果有机会,我们还可以来谈谈这方面的话题。
面向对象技术已经成为软件开发的一种趋势,越来越多的人开始了解、学习和使用面向对象技术。而大多数的面向对象技术都只是解决了内存中的面向对象的问题。但是鲜有提到持久性的面向对象问题。
面向对象设计的机制与关系模型有很大的不同,这造成了面向对象设计与关系数据库设计之间的不匹配。面向对象设计的基本理论包括耦合、聚合、封装、继承、多态,而关系数据模型的理论则完全不同,它的基本原理是数据库的三大范式。最明显的一个例子是,Order对象包括一组的OrderItem对象,因此我们需要在Order类中设计一个容器(各个编程语言都提供了一组的容器对象及相关操作以供使用)来存储OrderItem,也就是说Order类中的指针指向OrderItem。假设Order类和OrderItem分别对应于数据库的两张表(最简单的映射情况),那么,我们要实现二者之间的关系,是通过在OrderItem表(假设名称一样)增加指向Order表的外键。这是两种完全不同的设置。数据映射层的作用就是向用户端隐藏关系数据库的存在。
自己开发一个对象/关系映射工具是非常诱人的。但是应该考虑到,开发这样一个工具并不是一件容易的事,需要付出很大的成本。尤其是手工处理数据一致性和事务处理的问题上。它比你想象的要难的多。因此,获取一个对象/关系映射工具的最好途径是购买,而不是开发。
总结
分层对现代的软件开发而言是非常重要的概念。也是我们必须学习的知识。分层的总体思路并没有什么特别的地方,但是要和自己的开发环境、应用环境结合起来,你还需要付出很多的努力才行。
在完成了分层之后,软件架构其实已经清晰化了。下面的讨论将围绕着如何改进架构,如何使架构稳定方面的问题进行。
11.精化和合并
对于一个已经初步建立好的模型(分析模型或是设计模型)来说,对其进行精化和合并是必要的步骤。
Context
建立架构愿景,为架构的设计定义了主要的设计策略和实现思路。应用分层的原则则对整个的软件进行了结构上的划分,并定义了结构的不同部分的职责。而现在,我们需要对初步完成的模型进行必要的改进。
Problem
我们如何对初始架构模型进行改进?
Solution
对模型进行改进的活动可以分为精化和合并两种。我们先从精化开始。
首先,我们手头上的初始架构模型已经包括了总原则(参见架构愿景模式)和层结构(参见分层模式)两部分的内容。现在我们要做的工作是根据需求和架构原则来划分不同的粗粒度组件。粗粒度组件来源于分析活动中的业务实体。把具有很强相关性业务实体组合起来,形成一个集合。集合内部存在错综复杂的关系,同时集合向外部提供服务接口。这样的集合就称为粗粒度组件。粗粒度组件对外的接口和内部的实现是相区分的。粗粒度组件的形式有很多,Java平台上的Jar文件、Windows平台上的dll文件,甚至古老的.o或.a文件都可以是粗粒度组件的表现形式。设计优秀的粗粒度组件应该只是完成一项功能,这一点是它与子系统的主要区分。一个系统中可能包括会计子系统、库存管理子系统。但是提供会计粗粒度组件或是库存管理粗粒度组件是没有什么意义的。因为这样的粗粒度组件的范围过于广泛,难以发挥重用的价值。
粗粒度组件是可以(可能也是必须)跨越层次的。粗粒度组件拥有持久化的行为,拥有业务逻辑,需要表示层的支持。这样看起来,它所属的轴向和层次的轴向是相互垂直的。
粗粒度组件来源于需求。需求阶段产生的需求说明书或是用例模型将是粗粒度组件开发的基础。在拥有了需求工件之后,我们需要对需求进行功能性的划分,将需求分为几个功能组,这样我们基本上就可以得到相应的粗粒度组件了。如果系统比较庞大,可以对功能组再做细分。这取决于粗粒度组件的范围。过小的范围,将会造成粗粒度组件不容易使用,用户需要理解不同的粗粒度组件之间的复杂关系,最后的结果也将包含大量的组件和复杂的逻辑。过大的范围,则会造成粗粒度组件难以重用,导致粗粒度组件称为一个子系统。
假设我们需要开发一个人力资源管理系统。经过整理,它的需求大致分为这样几个部分:
- 组织结构的设计和管理:包括员工职务管理和员工所属部门的管理。
- 员工资料的管理:包括员工的基本资料和简单的考评资料。
- 日常事务的管理:包括了对员工的考勤管理和工资发放管理。
对于前两项的功能组,我们认为建立粗粒度组件是比较合适的。但是对于第三项功能组,我们认为范围过大,因为将之分为考勤管理和工资管理。现在我们得到了四个粗粒度组件。分别是组织结构组件、员工资料组件、员工考勤组件、员工工资组件。
在得到了粗粒度组件之后,我们的工作需要分为两个部分:第一个部分是定义不同的粗粒度组件之间的关系。第二个部分是在粗粒度组件的基础上定义业务实体或是定义细粒度组件。
不同的粗粒度组件之间的关系其实就是前文提到的粗粒度组件的外部接口。如果可能,在粗粒度组件之间定义单向的关联(如上图所示)可以有效的减少组件之间的耦合。如果必须要定义双向的关联,请确保关联双方组件之间的一致性。在上图中,我们可以清晰的看出,组织结构处于最底层,员工资料依赖于组织结构,包括从组织结构中获得员工的所属部门,以及员工职务等信息。而对于考勤、工资组件来说,需要从员工资料中获取必要的信息,也包括了部门和职务两方面的信息。这里有两种关联定义的方法,一种是让考勤组件从组织结构组件中获得部门和职务信息,从员工资料中获得另外的信息,另一种是如上图一样,考勤组件只从员工资料组件中获得信息,而员工资料组件再使用委托,从组织结构中获得部门和职务的信息。第二种做法的好处是向考勤、工资组件屏蔽了组织结构组件的存在,并保持了信息获取的一致性。这里演示的只是组件之间的简单关系,现实中的关系不可能如此的简单,但是处理的基本思路是一样的,就是尽可能简化组件之间的关系,从而减少它们之间的耦合度。
考虑另一种的需求情况,在原先的系统的基础上,我们又增加了会计子系统部分,其中的一个粗粒度组件是对部门、员工进行成本分析。在原先的模型基础上,我们增加了对分层的考虑。从下图中,我们可以看到,组织结构组件已经发挥了它的重用性,被成本分析组件使用了。从分层上考虑(参见分层模式),我们将组织结构组件划分到工具层,而将其它的组件划分到领域层,并在领域层中进行子系统级的划分。从某个角度上来说,这种做法类似于一个分析模型的建模过程。总之,这个过程中,最重要的就是定义好不同的组件的关系。尽管这中分析是初始的、模糊的。
在得到了粗粒度组件模型之后,我们需要对其进行进一步的分析,以得到细粒度的组件。细粒度的组件具有更好的重用性,并使得架构设计的精化工作更进一步。按Jacobson推荐的面向对象软件工程(OOSE)的做法,我们需要从软件的目标领域中识别出关键性的实体,或者说是领域中的名词。例如上例中的员工、部门、工资等。然后决定它们应该归属于哪些粗粒度组件。先识别细粒度组件还是先识别粗粒度组件并没有固定的顺序。
最初得到的组件模型可能并不完善,需要对其进行修改。可能某个组件中的类太多了,过于复杂,我们就需要对其进一步精化、分为更细的组件,也许某个组件中的类太少了,需要和其它的组件进行合并。也许你会发现某两个组件之间存在重复的要素,可以从中抽取出共性的部分,形成新的组件。组件分析的过程并没有一种标准的做法,你只能够根据具体的案例来进行分析。到最后,你可能会为其中的几个类的归属而苦恼不已,不要在它们身上浪费太多的时间,尽善尽美的模型并不存在。
最后的模型将会明确的包含几个经过精化之后的粗粒度组件。粗粒度组件之间的关系也会进行一次重新定义。如果这时候,粗粒度组件之间仍然存在着复杂的关系,也许意味着你的业务逻辑比较复杂,因此这个部分需要你投入比较多的精力来处理。当然,你可以通过一些技巧来减少不同组件之间的耦合程度。这里有几种可参考的办法:
第一种方法是使用外观(Facade)模式(在分层模式中,我们就提到过外观模式)。如下图所示,新引入的BusinessFacade类充当了外观的角色,将调用者和复杂的业务类隔离了起来,调用者无须知道业务类之间的复杂的关系,就能够进行业务处理,从而大大降低了调用者和业务类之间的耦合度。这种方法在实践中经常被采用,适合用在内部关系较为复杂的组件中,也适合用在业务层向表示层发布接口的情况中。对于外观模式来说,我们可以在BusinessFacade类的业务方法中提供参数,来实现数据的传递。这对于一些数据较少的情景特别的适用。如果当数据种类较多时,也可以使用参数类或值类来达到数据传送的目的。
第二种方法是使用命令(Command)模式,该模式也来自于设计模式一书。在处理参数时,命令模式使用了一系列的set方法来逐一设置参数,最后调用execute()来执行业务逻辑。命令模式的好处是为调用者提供了统一的接口,调用者并不需要关心具体的业务逻辑,他需要做的就是设置数据,并调用execute()方法。如果遇到业务逻辑需要用到较多的参数,逐个的调用set方法过于麻烦了,也可以提供一个setValues()方法来处理多个参数。当然,该模式也有其弱点,如果业务方法太多,那么相应的Command类也会随之增多。这是我们不希望看到的。
除了上面介绍的两种方法以外,还可以使用诸如工厂(Factory)模式、业务代表(Business Delegate)模式等方法来减少不同组件之间的耦合度。应该认识到,不同的设计模式有其不同的上下文环境,在架构设计中使用设计模式(以及分析模式)有助于优化设计,但是请注意模式的上下文环境,误用或滥用模式反而会导致设计的失误。
以上介绍的方法除了能够降低不同的组件之间的耦合度之外,还可以起到向调用者隐藏实现的功能。这一点对于重构活动(参见Refactoring模式)非常的关键,因为它可以有效的缓解在对组件进行重构时将变化扩散到其它的组件中。
精化是对模型进行改进的第一步。完成的模型基本上代表了最终的软件。但如果我们对其进行认真的检查,我们会发现模型仍然存在问题。这时候的问题主要体现在设计模型过于肥大了。如果说精化使得模型变得复杂,那么合并就是使得模型变得简单。千万不要以为这两项工作是互斥的,通过这两项活动,可以使得模型得到极大的改进。
还记得我们在简单设计模式中提到的简单原则吗?在进入下面的讨论之前,请确保你能够理解简单原则,这是敏捷的核心原则之一。
在上文中我们提到了一些设计模式的使用,而在整个的敏捷架构设计的文章中,我们也大量地讨论了模式。而在很多时候,我们其实是在不恰当的使用模式来解决一些简单的问题。所以,在使用模式之前,我们应该回顾需求说明书(或是用例模型)上的相关部分,确定是否需要使用模式。
在一次的设计软件的持久性机制的时候,我选用了DTO(Data Transfer Object)模式作为持久层的实现机制。原因只是我在前一天晚上看了这个模式,并觉得它很酷。看起来我是犯了模式综合症了(当学习了一个模式之后,就想方设法的使用它)。在花费了很多的时间学习并实现该模式之后,我发现该模式并没能够发挥它应有的作用。原因是,模式的上下文环境并不适合用在目前的软件中,原本只需要用JDBC就可以实现的功能,在使用了模式之后,反而变得复杂了。糟糕的是,我不得不向开发人员解释这个模式,吹捧这个设计模式的精妙之处。但是结果令人不安。开发人员显然不能够理解这个模式在这里发挥了什么样的作用。最后,我去掉了这个模式,设计得到了简化。
同样的,在开发过程还存在各种各样的过度设计的例子,尤其是数据库访问、线程安全、一致性等方面的设计。这些问题往往需要花费大量的时间来处理,但是他们的价值却并不高,尤其是小型的系统。当然,在设计一些大型的系统时,这些问题是必须要考虑的。
而当使用设计模式在对不同的组件进行整合的时候,我们也需要对组件的行为进行合并。将不同组件之间的相同的行为合并到一个组件中,尤其是那些关系非常复杂的组件。这样可以把复杂的关系隐藏到组件内部,而简化其对外提供的接口。
很难评判设计是否已经完成了。这里有两个不同的极端。花费了过多的时间在初始设计上,以及过度的迭代。在初始模型上花费太多的时间并不一定能够得到尽善尽美的模型,相反的,可能还会因为设计师钻牛角尖的行为导致设计模型的失误。而在过于频繁的迭代对于改进模型同样没有好处,因为实际上,你不是在改进模型,而是在改变模型。请区分这两种完全不同的行为,虽然它们似乎很相似。在Refactoring模式中,我们还会进一步对迭代和模型改进进行讨论。
一种判断方法是请编码人员来评判设计是否完成。设计模型最后是要交给编码人员,指导编码人员的开发工作的。因此,如果编码人员无法理解模型,这个模型设计的再好看,也没有太大的作用。另一方面,如果编码人员认为模型已经足够指导开发工作了,那么还有什么必要再画蛇添足下去了呢?不同水平、不同经验的编码人员对模型的要求也不一样。在我们的工作中,对资深开发人员和开发新手的发布的模型是不一样的。对于资深的开发人员而言,可能只需要对他说,在组件A和组件B之间使用外观模式,他就能够理解这句话的意思,并立即着手开发,可是对于没有经验的开发人员,就需要从模式理论开始进行讲述。对于他们来说,设计模型必须足够充分,足够细致。设置需要把类、方法、参数、功能描述全部设计出来才可以。
复审是避免设计模型出现错误的重要手段。强烈建议在架构设计过程中引入复审的活动。复审对于避免设计错误有着重大的帮助。复审应该着重于粗粒度组件的分类和粗粒度组件之间的关系。正如后续的Refactoring模式和稳定性模式所描绘的那样,保持粗粒度组件的稳定性有助于重构行为,有助于架构模型的改进。
12.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 |
add(Object o) |
boolean |
addAll(Collection c) |
void |
clear() |
boolean |
contains(Object o) |
boolean |
containsAll(Collection c) |
boolean |
equals(Object o) |
int |
hashCode() |
boolean |
isEmpty() |
Iterator |
iterator() |
boolean |
remove(Object o) |
boolean |
removeAll(Collection c) |
boolean |
retainAll(Collection c) |
int |
size() |
Object[] |
toArray() |
Object[] |
toArray(Object[] a) |
重构。代码重构是令架构趋于稳定的另一项方法。准确而言,重构应该是程序员的一种个人素质。但是在实际中,我们发现,重构这种行为更加适合作为开发团队的共同行为。为什么这么说呢?最早接触重构概念的时候,我对面向对象的认识并不深入。因此对重构的概念并不感冒。但随着经验的积累,面向对象思维的深入。我渐渐发现,重构是一种非常优秀的代码改进方式,它通过把原子性的操作,逐步的改进代码质量,从而达到改进软件架构的效果。当程序员逐渐熟练运用重构的时候,他已经不再拘泥于这些原子操作,而是自然而然的写出优秀的软件。这是重构方法对各人行为的改进。另一方面,对于一个团队来说,每个人的编程水平和经验都不一而足,因此软件的各个模块质量也都是参差不齐的。这种情况下,软件质量的改进就已经不是个人的问题了,而该问题的难度要比前一个问题大的多。此时重构方法更能够发挥其威力。在团队中提倡使用、甚至半强制性使用重构,有助于分享优秀的软件设计思路,提高软件的整体架构。而此时的重构也不仅仅局限在代码的改进上(指的是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网站上可以下载到。