程序员修炼之道 8 正交软件架构方法
程序员修炼之道 8 正交软件架构方法
如果你想要制作易于设计、构建、测试及扩展的系统,正交性是一个十分关键的概念,但是,正交性的概念很少被直接讲授,而常常是你学习的各种其他方法和技术的隐含特性。这是一个错误。一旦你学会了直接应用正交性原则,你将发现,你制作的系统的质量立刻就得到了提高。
什么是正交性
“正交性”是从几何学中借来的术语。如果两条直线相交成直角,它们就是正交的,比如图中的坐标轴。用向量术语说,这两条直线互不依赖。沿着某一条直线移动,你投影到另一条直线上的位置不变。
在计算技术中,该术语用于表示某种不相依赖性或是解耦性。如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。在设计良好的系统中,数据库代码与用户界面是正交的:你可以改动界面,而不影响数据库;更换数据库,而不用改动界面。
在我们考察正交系统的好处之前,让我们先看一看非正交系统。
非正交系统
你正乘坐直升机游览科罗拉多大峡谷,驾驶员——他显然犯了一个错误,在吃鱼,他的午餐——突然呻吟起来,晕了过去。幸运的是,他把你留在了离地面100英尺的地方。你推断,升降杆控制总升力,所以轻轻将其压低可以让直升机平缓降向地面。然而,当你这样做时,却发现生活并非那么简单。直升机的鼻子向下,开始向左盘旋下降。突然间你发现,你驾驶的这个系统,所有的控制输入都有次级效应。压低左手的操作杆,你需要补偿性地向后移动右手柄,并踩右踏板。但这些改变中的每一项都会再次影响所有其他的控制。突然间,你在用一个让人难以置信的复杂系统玩杂耍,其中每一项改变都会影响所有其他的输入。你的工作负担异常巨大:你的手脚在不停地移动,试图平衡所有交互影响的力量。
直升机的各个控制器断然不是正交的。
正交的好处
如直升机的例子所阐明的,非正交系统的改变与控制更复杂是其固有的性质。当任何系统的各组件互相高度依赖时,就不再有局部修正(local fix)这样的事情。
提示13 | |
---|---|
Eliminate Effects Between Unrelated Things 消除无关事物之间的影响 |
我们想要设计自足(self-contained)的组件:独立,具有单一、良好定义的目的(Yourdon和Constantine称之为内聚(cohesion)[YC86])。如果组件是相互隔离的,你就知道你能够改变其中之一,而不用担心其余组件。只要你不改变组件的外部接口,你就可以放心:你不会造成波及整个系统的问题。
如果你编写正交的系统,你得到两个主要好处:提高生产率与降低风险。
提高生产率
- 改动得以局部化,所以开发时间和测试时间得以降低。与编写单个的大块代码相比,编写多个相对较小的、自足的组件更为容易。你可以设计、编写简单的组件,对其进行单元测试,然后把它们忘掉——当你增加新代码时,无须不断改动已有的代码。
- 正交的途径还能够促进复用。如果组件具有明确而具体的、良好定义的责任,就可以用其最初的实现者未曾想象过的方式,把它们与新组件组合在一起。
- 如果你对正交的组件进行组合,生产率会有相当微妙的提高。假定某个组件做M件事情,而另一个组件做N件事情。如果它们是正交的,而你把它们组合在一起,结果就能做M x N件事情。但是,如果这两个组件是非正交的,它们就会重叠,结果能做的事情就更少。通过组合正交的组件,你的每一份努力都能得到更多的功能。
降低风险
正交的途径能降低任何开发中固有的风险。
- 有问题的代码区域被隔离开来。如果某个模块有毛病,它不大可能把病症扩散到系统的其余部分。要把它切掉,换成健康的新模块也更容易。
- 所得系统更健壮。对特定区域做出小的改动与修正,你所导致的任何问题都将局限在该区域中。
- 正交系统很可能能得到更好的测试,因为设计测试、并针对其组件运行测试更容易。
- 你不会与特定的供应商、产品、或是平台紧绑在一起,因为与这些第三方组件的接口将被隔离在全部开发的较小部分中。
让我们看一看在工作中应用正交原则的几种方式。
项目团队
你是否注意到,有些项目团队很有效率,每个人都知道要做什么,并全力做出贡献,而另一些团队的成员却老是在争吵,而且好像无法避免互相妨碍?
这常常是一个正交性问题。如果团队的组织有许多重叠,各个成员就会对责任感到困惑。每一次改动都需要整个团队开一次会,因为他们中的任何一个人都可能受到影响。
怎样把团队划分为责任得到了良好定义的小组,并使重叠降至最低呢?没有简单的答案。这部分地取决于项目本身,以及你对可能变动的区域的分析。这还取决于你可以得到的人员。我们的偏好是从使基础设施与应用分离开始。每个主要的基础设施组件(数据库、通信接口、中间件层,等等)有自己的子团队。如果应用功能的划分显而易见,那就照此划分。然后我们考察我们现有的(或计划有的)人员,并对分组进行相应的调整。
你可以对项目团队的正交性进行非正式的衡量。只要看一看,在讨论每个所需改动时需要涉及多少人。人数越多,团队的正交性就越差。显然,正交的团队效率也更高(尽管如此,我们也鼓励子团队不断地相互交流)。
设计
大多数开发者都熟知需要设计正交的系统,尽管他们可能会使用像模块化、基于组件、或是分层这样的术语描述该过程。系统应该由一组相互协作的模块组成,每个模块都实现不依赖于其他模块的功能。有时,这些组件被组织为多个层次,每层提供一级抽象。这种分层的途径是设计正交系统的强大方式。因为每层都只使用在其下面的层次提供的抽象,在改动底层实现、而又不影响其他代码方面,你拥有极大的灵活性。分层也降低了模块间依赖关系失控的风险。你将常常看到像下一页的图2.1这样的图表示的层次关系。
对于正交设计,有一种简单的测试方法。一旦设计好组件,问问你自己:如果我显著地改变某个特定功能背后的需求,有多少模块会受影响?在正交系统中,答案应该是“一个”。移动GUI面板上的按钮,不应该要求改动数据库schema。增加语境敏感的帮助,也不应该改动记账子系统。
图2.1 典型的层次图
让我们考虑一个用于监视和控制供暖设备的复杂系统。原来的需求要求提供图形用户界面,但后来需求被改为要增加语音应答系统,用按键电话控制设备。在正交地设计的系统中,你只需要改变那些与用户界面有关联的模块,让它们对此加以处理:控制设备的底层逻辑保持不变。事实上,如果你仔细设计你的系统结构,你应该能够用同一个底层代码库支持这两种界面。157页的“它只是视图”将讨论怎样使用模型-视图-控制器(MVC)范型编写解耦的代码,该范型在这里的情况下也能很好地工作。
还要问问你自己,你的设计在多大程度上解除了与现实世界中的的变化的耦合?你在把电话号码当作顾客标识符吗?如果电话公司重新分配了区号,会怎么样?不要依赖你无法控制的事物属性。
工具箱与库
在你引入第三方工具箱和库时,要注意保持系统的正交性。要明智地选择技术。
我们曾经参加过一个项目,在其中需要一段Java代码,既运行在本地的服务器机器上,又运行在远地的客户机器上。要把类按这样的方式分布,可以选用RMI或CORBA。如果用RMI实现类的远地访问,对类中的远地方法的每一次调用都可能会抛出异常;这意味着,一个幼稚的实现可能会要求我们,无论何时使用远地类,都要对异常进行处理。在这里,使用RMI显然不是正交的:调用远地类的代码应该不用知道这些类的位置。另一种方法——使用CORBA——就没有施加这样的限制:我们可以编写不知道我们类的位置的代码。
在引入某个工具箱时(甚或是来自你们团队其他成员的库),问问你自己,它是否会迫使你对代码进行不必要的改动。如果对象持久模型(object persistence scheme)是透明的,那么它就是正交的。如果它要求你以一种特殊的方式创建或访问对象,那么它就不是正交的。让这样的细节与代码隔离具有额外的好处:它使得你在以后更容易更换供应商。
Enterprise Java Beans(EJB)系统是正交性的一个有趣例子。在大多数面向事务的系统中,应用代码必须描述每个事务的开始与结束。在EJB中,该信息是作为元数据,在任何代码之外,以声明的方式表示的。同一应用代码不用修改,就可以运行在不同的EJB事务环境中。这很可能是将来许多环境的模型。
正交性的另一个有趣的变体是面向方面编程(Aspect-Oriented Programming,AOP),这是Xerox Parc的一个研究项目([KLM+97]与[URL 49])。AOP让你在一个地方表达本来会分散在源码各处的某种行为。例如,日志消息通常是在源码各处、通过显式地调用某个日志函数生成的。通过AOP,你把日志功能正交地实现到要进行日志记录的代码中。使用AOP的Java版本,你可以通过编写aspect、在进入类Fred的任何方法时写日志消息:
aspect Trace {
advise * Fred.*(..) {
static before {
Log.write(“-> Entering “ + thisJoinPoint.methodName);
}
}
}
如果你把这个方面编织(weave)进你的代码,就会生成追踪消息。否则,你就不会看到任何消息。不管怎样,你原来的源码都没有变化。
编码
每次你编写代码,都有降低应用正交性的风险。除非你不仅时刻监视你正在做的事情,也时刻监视应用的更大语境,否则,你就有可能无意中重复其他模块的功能,或是两次表示已有的知识。
你可以将若干技术用于维持正交性:
- 让你的代码保持解耦。编写“羞怯”的代码——也就是不会没有必要地向其他模块暴露任何事情、也不依赖其他模块的实现的模块。试一试我们将在183页的“解耦与得墨忒耳法则”中讨论的得墨忒耳法则(Law of Demeter)[LH89]。如果你需要改变对象的状态,让这个对象替你去做。这样,你的代码就会保持与其他代码的实现的隔离,并增加你保持正交的机会。
- 避免使用全局数据。每当你的代码引用全局数据时,它都把自己与共享该数据的其他组件绑在了一起。即使你只想对全局数据进行读取,也可能会带来麻烦(例如,如果你突然需要把代码改为多线程的)。一般而言,如果你把所需的任何语境(context)显式地传入模块,你的代码就会更易于理解和维护。在面向对象应用中,语境常常作为参数传给对象的构造器。换句话说,你可以创建含有语境的结构,并传递指向这些结构的引用。
《设计模式》[GHJV95]一书中的Singleton(单体)模式是确保特定类的对象只有一个实例的一种途径。许多人把这些singleton对象用作某种全局变量(特别是在除此而外不支持全局概念的语言中,比如Java)。使用singleton要小心——它们可能造成不必要的关联。
- 避免编写相似的函数。你常常会遇到看起来全都很像的一组函数——它们也许在开始和结束处共享公共的代码,中间的算法却各有不同。重复的代码是结构问题的一种症状。要了解更好的实现,参见《设计模式》一书中的Strategy(策略)模式。
养成不断地批判对待自己的代码的习惯。寻找任何重新进行组织、以改善其结构和正交性的机会。这个过程叫做重构(refactoring),它非常重要,所以我们专门写了一节加以讨论(见“重构”,184页)
测试
正交地设计和实现的系统也更易于测试,因为系统的各组件间的交互是形式化的和有限的,更多的系统测试可以在单个的模块级进行。这是好消息,因为与集成测试(integration testing)相比,模块级(或单元)测试要更容易规定和进行得多。事实上,我们建议让每个模块都拥有自己的、内建在代码中的单元测试,并让这些测试作为常规构建过程的一部分自动运行(参见“易于测试的代码”,189页)。
构建单元测试本身是对正交性的一项有趣测试。要构建和链接某个单元测试,都需要什么?只是为了编译或链接某个测试,你是否就必须把系统其余的很大一部分拽进来?如果是这样,你已经发现了一个没有很好地解除与系统其余部分耦合的模块。
修正bug也是评估整个系统的正交性的好时候。当你遇到问题时,评估修正的局部化程度。
你是否只改动了一个模块,或者改动分散在整个系统的各个地方?当你做出改动时,它修正了所有问题,还是又神秘地出现了其他问题?这是开始运用自动化的好机会。如果你使用了源码控制系统(在阅读了86页的“源码控制”之后,你会使用的),当你在测试之后、把代码签回(check the code back)时,标记所做的bug修正。随后你可以运行月报,分析每个bug修正所影响的源文件数目的变化趋势。
文档
也许会让人惊讶,正交性也适用于文档。其坐标轴是内容和表现形式。对于真正正交的文档,你应该能显著地改变外观,而不用改变内容。现代的字处理器提供了样式表和宏,能够对你有帮助(参见“全都是写”,248页)。
认同正交性
正交性与27页介绍的DRY原则紧密相关。运用DRY原则,你是在寻求使系统中的重复降至最小;运用正交性原则,你可降低系统的各组件间的相互依赖。这样说也许有点笨拙,但如果你紧密结合DRY原则、运用正交性原则,你将会发现你开发的系统会变得更为灵活、更易于理解、并且更易于调试、测试和维护。
如果你参加了一个项目,大家都在不顾一切地做出改动,而每一处改动似乎都会造成别的东西出错,回想一下直升机的噩梦。项目很可能没有进行正交的设计和编码。是重构的时候了。
另外,如果你是直升机驾驶员,不要吃鱼……
工具箱与库
在你引入第三方工具箱和库时,要注意保持系统的正交性。要明智地选择技术。
我们曾经参加过一个项目,在其中需要一段Java代码,既运行在本地的服务器机器上,又运行在远地的客户机器上。要把类按这样的方式分布,可以选用RMI或CORBA。如果用RMI实现类的远地访问,对类中的远地方法的每一次调用都可能会抛出异常;这意味着,一个幼稚的实现可能会要求我们,无论何时使用远地类,都要对异常进行处理。在这里,使用RMI显然不是正交的:调用远地类的代码应该不用知道这些类的位置。另一种方法——使用CORBA——就没有施加这样的限制:我们可以编写不知道我们类的位置的代码。
在引入某个工具箱时(甚或是来自你们团队其他成员的库),问问你自己,它是否会迫使你对代码进行不必要的改动。如果对象持久模型(object persistence scheme)是透明的,那么它就是正交的。如果它要求你以一种特殊的方式创建或访问对象,那么它就不是正交的。让这样的细节与代码隔离具有额外的好处:它使得你在以后更容易更换供应商。
Enterprise Java Beans(EJB)系统是正交性的一个有趣例子。在大多数面向事务的系统中,应用代码必须描述每个事务的开始与结束。在EJB中,该信息是作为元数据,在任何代码之外,以声明的方式表示的。同一应用代码不用修改,就可以运行在不同的EJB事务环境中。这很可能是将来许多环境的模型。
正交性的另一个有趣的变体是面向方面编程(Aspect-Oriented Programming,AOP),这是Xerox Parc的一个研究项目([KLM+97]与[URL 49])。AOP让你在一个地方表达本来会分散在源码各处的某种行为。例如,日志消息通常是在源码各处、通过显式地调用某个日志函数生成的。通过AOP,你把日志功能正交地实现到要进行日志记录的代码中。使用AOP的Java版本,你可以通过编写aspect、在进入类Fred的任何方法时写日志消息:
aspect Trace {
advise * Fred.*(..) {
static before {
Log.write(“-> Entering “ + thisJoinPoint.methodName);
}
}
}
如果你把这个方面编织(weave)进你的代码,就会生成追踪消息。否则,你就不会看到任何消息。不管怎样,你原来的源码都没有变化。
编码
每次你编写代码,都有降低应用正交性的风险。除非你不仅时刻监视你正在做的事情,也时刻监视应用的更大语境,否则,你就有可能无意中重复其他模块的功能,或是两次表示已有的知识。
你可以将若干技术用于维持正交性:
- 让你的代码保持解耦。编写“羞怯”的代码——也就是不会没有必要地向其他模块暴露任何事情、也不依赖其他模块的实现的模块。试一试我们将在183页的“解耦与得墨忒耳法则”中讨论的得墨忒耳法则(Law of Demeter)[LH89]。如果你需要改变对象的状态,让这个对象替你去做。这样,你的代码就会保持与其他代码的实现的隔离,并增加你保持正交的机会。
- 避免使用全局数据。每当你的代码引用全局数据时,它都把自己与共享该数据的其他组件绑在了一起。即使你只想对全局数据进行读取,也可能会带来麻烦(例如,如果你突然需要把代码改为多线程的)。一般而言,如果你把所需的任何语境(context)显式地传入模块,你的代码就会更易于理解和维护。在面向对象应用中,语境常常作为参数传给对象的构造器。换句话说,你可以创建含有语境的结构,并传递指向这些结构的引用。
《设计模式》[GHJV95]一书中的Singleton(单体)模式是确保特定类的对象只有一个实例的一种途径。许多人把这些singleton对象用作某种全局变量(特别是在除此而外不支持全局概念的语言中,比如Java)。使用singleton要小心——它们可能造成不必要的关联。
- 避免编写相似的函数。你常常会遇到看起来全都很像的一组函数——它们也许在开始和结束处共享公共的代码,中间的算法却各有不同。重复的代码是结构问题的一种症状。要了解更好的实现,参见《设计模式》一书中的Strategy(策略)模式。
养成不断地批判对待自己的代码的习惯。寻找任何重新进行组织、以改善其结构和正交性的机会。这个过程叫做重构(refactoring),它非常重要,所以我们专门写了一节加以讨论(见“重构”,184页)
测试
正交地设计和实现的系统也更易于测试,因为系统的各组件间的交互是形式化的和有限的,更多的系统测试可以在单个的模块级进行。这是好消息,因为与集成测试(integration testing)相比,模块级(或单元)测试要更容易规定和进行得多。事实上,我们建议让每个模块都拥有自己的、内建在代码中的单元测试,并让这些测试作为常规构建过程的一部分自动运行(参见“易于测试的代码”,189页)。
构建单元测试本身是对正交性的一项有趣测试。要构建和链接某个单元测试,都需要什么?只是为了编译或链接某个测试,你是否就必须把系统其余的很大一部分拽进来?如果是这样,你已经发现了一个没有很好地解除与系统其余部分耦合的模块。
修正bug也是评估整个系统的正交性的好时候。当你遇到问题时,评估修正的局部化程度。
你是否只改动了一个模块,或者改动分散在整个系统的各个地方?当你做出改动时,它修正了所有问题,还是又神秘地出现了其他问题?这是开始运用自动化的好机会。如果你使用了源码控制系统(在阅读了86页的“源码控制”之后,你会使用的),当你在测试之后、把代码签回(check the code back)时,标记所做的bug修正。随后你可以运行月报,分析每个bug修正所影响的源文件数目的变化趋势。
文档
也许会让人惊讶,正交性也适用于文档。其坐标轴是内容和表现形式。对于真正正交的文档,你应该能显著地改变外观,而不用改变内容。现代的字处理器提供了样式表和宏,能够对你有帮助(参见“全都是写”,248页)。
认同正交性
正交性与27页介绍的DRY原则紧密相关。运用DRY原则,你是在寻求使系统中的重复降至最小;运用正交性原则,你可降低系统的各组件间的相互依赖。这样说也许有点笨拙,但如果你紧密结合DRY原则、运用正交性原则,你将会发现你开发的系统会变得更为灵活、更易于理解、并且更易于调试、测试和维护。
如果你参加了一个项目,大家都在不顾一切地做出改动,而每一处改动似乎都会造成别的东西出错,回想一下直升机的噩梦。项目很可能没有进行正交的设计和编码。是重构的时候了。
另外,如果你是直升机驾驶员,不要吃鱼……
相关内容:
- 重复的危害,26页
- 源码控制,86页
- 按合约设计,109页
- 解耦与得墨忒耳法则,138页
- 元程序设计,144页
- 它只是视图,157页
- 重构,184页
- 易于测试的代码,189页
- 邪恶的向导,198页
- 注重实效的团队,224页
- 全都是写,248页
挑战
- 考虑常在Windows系统上见到的面向GUI的大型工具和在shell提示下使用的短小、但却可以组合的命令行实用工具。哪一种更为正交,为什么?如果正好按其设计用途加以应用,哪一种更易于使用?哪一种更易于与其他工具组合、以满足新的要求?
- C++支持多重继承,而Java允许类实现多重接口。使用这些设施对正交性有何影响?使用多重继承与使用多重接口的影响是否有不同?使用委托(delegation)与使用继承之间是否有不同?
练习
\1. 你在编写一个叫做Split的类,其用途是把输入行拆分为字段。下面的两个Java类的型构(signature)中,哪一个是更为正交的设计?(解答在279页)
class Split1 { public Split1(InputStreamReader rdr) { … public void readNextLine() throws IOException { … public int numFields() { … public String getField(int fieldNo) { … } class Split2 { public Split2(String line) { … public int numFields() { … public String getField(int fieldNo) { … }
2.非模态对话框或模态对话框,哪一个能带来更为正交的设计? (解答在279页)
3.过程语言与对象技术的情况又如何?哪一种能产生更为正交的系统? (解答在280页)
- 上一篇 Welcome To Jekyll
- 下一篇 Ikm