目录
软件架构
程序组织
主要的类
数据设计
业务规则
用户界面设计
资源管理
安全性
性能
可伸缩性
互用性
国际化/本地化
输入输出
错误处理
容错性
架构的可行性
过度工程
关于“买”还是“造”的决策
变更策略
核对表:架构
针对各架构主题
架构的总体质量
项目构建需要花费的时间
总结
软件架构〔software architecturé)是软件设计的高层部分,是用于支撑更细节的设计的框架( Buschman et al. 1996;Fowler 2002;Bass Clements,Kazman 2003;Clements et al.2003)。架构也称为“系统架构/system architecture”、“高层设计/high-level design”或“顶层设计/op-level design”。通常会用一份独立的文档描述架构,这份文档称为“架构规格书/architecture specification”或者“顶层设计”。有些人对“架构”和“高层设计”加以区分——架构指的是适用于整个系统范围的设计约束,而高层设计指的是适用于子系统层次或多个类的层次上的设计约束(但不是整个系统范围的设计)。
软件架构的典型组成部分
系统架构首先要以概括的形式对有关系统做一个综述。如果没有这种综述,要想将成千的局部图片(或十多个单独的类)拼成一幅完整的图画是相当伤脑筋的。如果系统是小小的只有12块的智力拼图玩具,你那一岁的小孩也能在眨眼功夫解决它。不过把12个子系统拼到一起要困难一些,而且如果你不能将它们拼起来,那么就无法理解你正在开发的那个类对系统有何贡献。
架构应该定义程序的主要构造块( building blocks)。根据程序规模不同,各个构造块可能是单个类,也可能是由许多类组成的一个子系统。每个构造块无论是一个类还是一组协同工作的类和子程序,它们共同实现一种高层功能,诸如与用户交互、显示Web页面、解释命令、封装业务规则、访问数据,等等。每条列在需求中的功能特性(feature)都至少应该有一个构造块覆盖它。如果两个或多个构造块声称实现同一项功能,那么它们就应该相互配合而不会冲突。
应该明确定义各个构造块的责任。每个构造块应该负责某一个区域的事情,并且对其他构造块负责的区域知道得越少越好。通过使各个构造块对其他构造块的了解达到最小,你能将设计的信息局限于各个构造块之内。
架构应该详细定义所用的主要的类。它应该指出每个主要的类的责任,以及该类如何与其他类交互。它应该包含对类的继承体系、状态转换、对象持久化等的描述。如果系统足够大,它应该描述如何将这些类组织成一个个子系统。
架构应该记述曾经考虑过的其他类设计方案,并给出选用当前的组织结构的理由。架构无须详细说明系统中的每一个类。瞄准80/20法则:对那些构成系统80%的行为的20%的类进行详细说明。
架构应该描述所用到的主要文件和数据表的设计。它应该描述曾经考虑过的其他方案,并说明做出选择的理由。如果应用程序要维护一个客户ID的列表,而架构师决定使用顺序访问的列表(sequential-access list)来表示该ID表,那么文档就应该解释为什么顺序访问的列表比随机访问的列表(random-access list)、堆栈、散列表要好。在构建期间,这些信息让你能洞察架构师的思想。在维护阶段,这种洞察力是无价之宝。离开它,你就像看一部没有字幕的外语片。
数据通常只应该由一个子系统或一个类直接访问;例外的情况就是透过访问器类( access class)或访问器子程序( access routine)——以受控且抽象的方式——来访问数据。
架构应该详细定义所用数据库的高层组织结构和内容。架构应该解释为什么单个数据库比多个数据库要好(反之亦然),解释为什么不用平坦的文件而要用数据库,指出与其他访问同一数据的程序的可能交互方式,说明会创建哪些数据视图(view),等等。
如果架构依赖于特定的业务规则,那么它就应该详细描述这些规则,并描述这些规则对系统设计的影响。例如,假定要求系统遵循这样条业务规则:客户信息过时的时间不能超过30秒。在此情况下,架构就应该描述这条规则对架构采用的“保持客户信息及时更新且同步”的方法的影响。
用户界面常常在需求阶段进行详细说明。如果没有,就应该在软件架构中进行详细说明。架构应该详细定义 Web页面格式、GUI、命令行接口( command lineinterface)等的主要元素。精心设计的用户界面架构决定了最终做出来的是“人见人爱的程序”还是“没人爱用的程序”。
架构应该模块化,以便在替换为新用户界面时不影响业务规则和程序的输出部分。例如,架构应该使我们很容易地做到:砍掉交互式界面的类,插入一组命令行的类。这种替换能力常常很有用,尤其因为命令行界面便于单元级别和子系统级别的软件测试。
架构应该描述一份管理稀缺资源的计划。稀缺资源包括数据库连接、线程、句柄(handle)等。在内存受限的应用领域,如驱动程序开发和嵌入式系统中,内存管理是架构应该认真对待的另一个重要领域。架构应该估算在正常情况和极端情况下的资源使用量。在简单的情况下,估算数据应该说明:预期的实现环境(运行环境〉有能力提供所需的资源。在更复杂的情况中,也许会要求应用程序更主动地管理其拥有的资源。如果是这样,那么“资源管理器/resource manager”应该和系统的其他部分一样进行认真的架构设计。
架构应该描述实现设计层面和代码层面的安全性的方法。如果先前尚未建立威胁模型(threat model),那么就应该在架构阶段建立威胁模型。在制定编码规范的时候应该把安全性牢记在心,包括处理缓冲区的方法、处理非受信(untrudted)数据(用户输入的数据、cookies、配置数据(文件)和其他外部接口输入的数据)的规则、加密、错误消息的细致程度、保护内存中的秘密数据,以及其他事项。
如果需要关注性能,就应该在需求中详细定义性能目标。性能目标可以包括资源的使用,这时,性能目标也应该详细定义资源(速度、内存、成本)之间的优先顺序。
架构应该提供估计的数据,并解释为什么架构师相信能达到性能目标。如果某些部分存在达不到性能目标的风险,那么架构也应该指出来。如果为了满足性能目标,需要在某些部分使用特定的算法或数据类型,架构也应该说清楚。架构中也可以包括各个类或各个对象的空间和时间预算。
可伸缩性是指系统增长以满足未来需求的能力。架构应该描述系统如何应对用户数量、服务器数量、网络节点数量、数据库记录数、数据库记录的长度、交易量等的增长。如果预计系统不会增长,而且可伸缩性不是问题,那么架构应该明确地列出这一假设。
如果预计这个系统会与其他软件或硬件共享数据或资源,架构应该描述如何完成这一任务。
“国际化”是一项“准备让程序支持多个locales(地域/文化)”的技术活动。国际化常常称为“I18n",因为国际化的英文单词“Internationalization”首尾两个字符“I”和“N”之间一共有18个字母。“本地化Localization”(称为“L10n",理由同上)活动是翻译一个程序,以支持当地特定的语言的工作。
对交互系统,国际化问题值得在架构中关注。大多数交互式系统包含几十.上.百条提示、状态显示、帮助信息、错误信息,等等。应该估算这些字符串所用的资源。如果这是--个在商业中使用的程序,架构应该表现出已经考虑过典型的字符串问题和字符集问题,包括所用的字符集(ASCII、DBCS、EBCDIC、MBCS、Unicode、ISO8859等),所用的字符串类型(C字符串、Visual Basic字符串等),如何无须更改代码就能维护这些字符串,如何将这些字符串翻译为另一种语言而又尽量不影响代码和用户界面。架构可以决定,在需要的时候,是在代码中直接嵌入字符串;还是将这些字符串封入某个类,并透过类的接口来使用它;或者将这些字符串存入资源文件。架构应该说明选用的是哪种方案,并解释其原因。
输入输出(IO)是架构中值得注意的另一个领域。架构应该详细定义读取策略(reading scheme)是先做(look-ahead>、后做(look-behind)还是即时做(just-in-time )。而且应该描述在哪一层检测IO错误:在字段、记录、流,或者文件的层次。
错误处理已被证实为现代计算机科学中最棘手的问题之-一,你不能武断地处理它。有人估计程序中高达90%的代码是用来处理异常情况、进行错误处理、或做簿记(housekeeping)工.作,意味着只有10%的代码是用来处理常规的情况(Shaw inBentley 1982)。既然这么多代码致力于处理错误,那么在架构中就应该清楚地说明一种“--致地处理错误”的策略。
错误处理常被视为是“代码约定层次lcoding-convention-level”的事情一一如果真有人注意它的话。但是因为错误处理牵连到整个系统,因此最好在架构层次上对待它。下面是一些需要考虑的问题。
错误处理是进行纠正还是仅仅进行检测?如果是纠正,程序可以尝试从错误中恢复过来。如果仅仅是检测,那么程序可以像“没有发生任何事”一样继续运行,也可以退出。无论哪一种情况,都应该通知用户说检测到一个错误。错误检测是主动的还是被动的?系统可以主动地预测错误—-例如,通过检查用户输入的有效性—一也可以在不能避免错误的时候,被动地响应错误——例如,当用户输入的组合产生了一个数值滋出错误时。前者可以扫清障碍,后者可以清除混乱。同样,无论采用哪种方案,都与用户界面有影响。程序如何传播错误﹖程序一旦检测到错误,它可以立刻丢弃引发该错误的数据:也可以把这个错误当成一个错误,并进入错误处理状态;或者可以等到所有处理完成,再通知用户说在某个地方发现了错误。
错误消息的处理有什么约定?如果絮构没有详细定义一个一致的处理策略,那用户界面看起来就像“令人困惑的乱七八糟的抽象拼贴画”,由程序的不同部分的各种界面拼接而成。要避免这种外观体验。架构应该建立一套有关错误消息的约定。
如何处理异常(exceptions)?架构应该规定代码何时能够抛出异常,在什么地方捕获异常,如何记录(log)这些异常,以及如何在文档中描述异常,等等。
在程序中,在什么层次上处理错误?你可以在发现错误的地方处理,可以将错误传递到专门处理错误的类进行处理,或者沿着函数调用链往上传递错误。
每个类在验证其输入数据的有效性方面需要负何种责任?是每个类负责验证自己的数据的有效性,还是有一组类负责验证整个系统的数据的有效性?某个层次上的类是否能假设它接收的数据是干净的(clean,即,没有错误)?你是希望用运行环境中内建的错误处理机制,还是想建立自己的--套机制?事实上,运行环境所拥有的某种特定的错误处理方法,并不一定是符合你的需求的最佳方法。
架构还应该详细定义所期望的容错种类。容错是增强系统可靠性的一组技术,包括检测错误;如果可能的话从错误中恢复;如果不能从错误中恢复,则包容其不利影响。
举个例子:为了计算某数的平方根,系统的容错策略有以下几种。
系统在检测到错误的时候退回去,再试一次。如果第一次的结果是错误的,那么系统可以退回到之前一切正常的时刻,然后从该点继续运行。
系统拥有一套辅助代码,以备在主代码出错的时候使用。在本例中,如果发现第一次的答案似乎有错,系统就切换到另一个计算平方根的子程序,以取而代之。
系统使用一种表决算法。它可以有三个计算平方根的类,每一个都使用不同的计算方法。每个类分别计算平方根,然后系统对结果进行比较。根据系统内建的容错机制的种类,系统可以以三个结果的均值、中值、或众数作为最终结果。
系统使用某个不会对系统其余部分产生危害的虚假值(phony value)代替这个错误的值。
设计师多半会关注系统的各种能力,例如是否达到性能目标,能够在有限的资源下运转,实现环境(运行环境〉是否有足够的支持。架构应该论证系统的技术可行性。如果在任何一个方面不可行都会导致项目无法实施,那么架构应该说明“这些问题是如何经过研究的”——通过验证概念的原型(proof-of-conceptprototype)、研究、或其他手段。必须在全面开展构建之前解决掉这些风险。
健壮性(robustness)是指“系统在检测到错误后继续运行”的能力。通常架构详细描述的系统会比需求详细描述的系统更健壮。理由之一是,如果组成系统的各个部分都只能在最低限度上满足健壮性要求,那么系统整体上是达不到所要求的健壮程度的。在软件中,链条的强度不是取决于最薄弱的一环,而是等于所有薄弱环节的乘积。架构应该清楚地指出程序员应该“为了谨慎起见宁可进行过度工程(overengineering)”,还是应该做出最简单的能工作的东西。
详细定义一种过度工程(裕度工程)的方法尤其重要,因为许多程序员会出于专业自豪感,对自己编写的类做过度工程。通过在架构中明确地设立期望目标,就能避免出现“某些类异常健壮,而其他类勉强够健壮”的现象。
最激进的构建软件的解决方案是根本不去构建它一—购买软件,或者免费下载开源的软件。你能买到GUI控件、数据库管理器、图像处理程序、图形与图标组件、Internet通信组件、安全与加密组件、电子表格工具、文本处理工具……这个列表几乎无穷无尽。在现代的GUI 环境中编程的最大好处之一是,大量功能都能自动实现:图形类(graphics class)、对话框管理器、键盘与鼠标的事件处理函数、能自动与任何打印机或显示器打交道的代码等等。
如果架构不采用现货供应的组件,那么就应该说明“自己定制的组件应该在哪些方面胜过现成的程序库和组件”。
因为对于程序员和用户来说,构建软件产品都是一个学习过程,所以在开发过程中产品很可能会发生变化。这些变更来自不稳定的数据类型和文件格式、功能需求的变更、新的功能特性,等等。这些变更可能是计划增加的新功能,也可能是没有添加到系统的第一个版本中的功能。因此,软件架构师面临的一个主要挑战是,让架构足够灵活,能够适应可能出现的变化。
架构应当清楚地描述处理变更的策略。架构应该列出已经考虑过的有可能会有所增强的功能,并说明“最有可能增强的功能同样也是最容易实现的”。如果变更很可能出现在输入输出格式、用户交互的风格、需求的处理等方面,那么架构就应该说明:这些变更已经被预料到了,并且任何单一的变更都只会影响少数几个类。架构应对变更的计划可以很简单,比如在数据文件中放入版本号、保留一些供将来使用的字段、或者将文件设计成能够添加新的表格。如果使用了代码生成器,那么架构应该说明,可预见的变更都不会超出该代码生成器的能力范围。
架构应该指出“延迟提交ldelay commitment”所用的策略1。比如说,架构也许规定使用表驱动(table-driven)技术(而不使用硬编码的if语句)。它也许还规定“表”中的数据是保存在外部文件中,而非直接写在程序代码中,这样就能做到在不重新编译的情况下修改程序。
以下是一份问题列表,优秀的架构应该关注这些问题。这张核对表的意图并非用做一份有关如何做架构的完全指南,而是作为一种实用的评估手段,用来评估软件食物链到了程序员这一头还有多少营养成分。这张核对表可用做你自己的核对表的出发点。就像“需求”的核对表一样,如果你从事的是非止式项目,那么你会发现其中某些条款甚至都不用去想。如果你从事的是更大型的项目,那么大多数条款都会是很有用的。
程序的整体组织结构是否清晰?是否包含一个良好的架构全局观(及其理由)?
是否明确定义了主要的构造块(包括每个构造块的职责范围及与其他构造块的接口)?
是否明显涵盖了“需求”中列出的所有功能(每个功能对应的构造块不太多也不太少)?
是否描述并论证了那些最关键的类?是否描述并论证了数据设计?
是否详细定义了数据库的组织结构和内容?
是否指出了所用关键的业务规则,并描述其对系统的影响?是否描述了用户界面设计的策略?
是否将用户界面模块化,使界面的变更不会影响程序其余部分?是否描述并论证了处理IO的策略?
是否估算了稀缺资源(如线程、数据库连接、句柄、网络带宽等)的使用量,是否描述并论证了资源管理的策略?
是否描述了架构的安全需求?
架构是否为每个类、每个子系统、或每个功能域(functionality area)提出空间与时间预算?
架构是否描述了如何达到可伸缩性?架构是否关注互操作性?
是否描述了国际化/本地化的策略?是否提供了一套内聚的错误处理策略?是否规定了容错的办法(如果需要)?
是否证实了系统各个部分的技术可行性?
是否详细描述了过度工程(overengineering)的方法?是否包含了必要的“买vs.造”的决策?
架构是否描述了如何加工被复用的代码,使之符合其他架构目标?是否将架构设计得能够适应很可能出现的变更?
架构是否解决了全部需求?
有没有哪个部分是“过度架构/overarchitected"或“欠架构/underarchitected”?
是否明确宣布了在这方面的预期指标?
整个架构是否在概念上协调--致?
顶层设计是否独立于用作实现它的机器和语言?是否说明了所有主要的决策的动机?
你,作为一名实现该系统的程序员,是否对这个架构感觉良好?
花费在问题定义、需求分析、软件架构上的时间,依据项目的需要而变化。一般说来,一个运作良好的项目会在需求、架构以及其他前期计划方面投入10%~20%的工作量和20%~30%的时间(McConnell 1998, Kruchten 2000)。这些数字不包括详细设计的时间——那是构建活动的一部分。
如果需求不稳定,同时你从事的是一个大型的正式项目,那你就很可能需要与需求分析师合作,以解决构建活动早期指出的需求问题。你要为“与需求分析师协商”预留一些时间,还应预留时间给需求分析师修订需求,这样你才能得到一份可行的需求。
如果需求不稳定,同时你从事的是一个小型的非正式的项目,那你很可能需要自己解决需求方面的问题。要预留足够的时间,将需求定义足够清晰,让需求的不稳定性对构建活动的负面影响降至最低。
如果需求在任何项目上都不稳定—--无论正式项目或非正式项目--—那就将需求分析工作视为独立的项目来做。在完成需求之后,估计项目余下的部分要花多少时间。这是明智的办法,因为在弄清楚要做的是什么之前,没人相信你能估算出合理的进度表。这就好比你是一名承包商,有人请你建一栋房子。客户问你:“完成这项工作要花多少钱?”你会合理地询问:“你想要我做什么?”客户说:“我不能告诉你,不过我想知道需要花费多少钱?”你该明智地感谢他浪费了你的时间,然后转身回家。
对于建筑物而言,如果客户在告诉你要造什么样的建筑之前要求你给出报价,这很明显是毫无道理的。而你的客户也不会希望在建筑师完成蓝图之前,你就摆出木料、榔头和钉子开始忙活,并开始花费他们的金钱。然而,人们对于软件开发的理解,往往不如对于建筑用的木条或石膏板的理解;因此你的客户可能无法立刻理解,为什么你打算将需求分析立为单独的项目。你可能需要向他们解释你的理由。
在为软件架构分配时间的时候,要使用与需求分析类似的方法。如果软件是你以前没有做过的类型,应当为“在新的领域中做设计”的不确定性预留更多时间。你要确保创建良好架构所需要的时间,不会被“为做好其他方面工作所需要的时间”所挤占。如果有必要,将架构工作也作为独立的项目来对待。
构建活动的准备工作的根本目标在于降低风险。要确认你的准备活动是在降低风险,而非增加风险。
如果你想开发高质量的软件,软件开发过程必须由始至终关注质量。在项目初期关注质量,对产品质量的正面影响比在项目末期关注质量的影响要大。程序员的一部分工作是教育老板和合作者,告诉他们软件开发过程,包括在开始编程之前进行充分准备的重要性。
你所从事的软件项目的类型对构建活动的前期准备有重大影响—--许多项目应该是高度迭代式的,某些应该是序列式的。
如果没有明确的问题定义,那么你可能会在构建期间解决错误的问题。
如果没有做完良好的需求分析工作,你可能没能察觉待解决的问题的重要细节。如果需求变更发生在构建之后的阶段,其代价是“在项目早期更改需求”的20至100倍。因此在开始编程之前,你要确认“需求”已经到位了。
如果没有做完良好的架构设计,你可能会在构建期间用错误的方法解决正确的问题。架构变更的代价随着“为错误的架构编写的代码数量”增加而增加,因此,也要确认“架构”己经到位了。
理解项目的前期准备所采用的方法,并相应地选择构建方法。
该文章知识作为个人笔记,大部分知识来源于书本或网络整理总结;