07-12¶
7 错误处理¶
错误处理只不过是编程时必须要做的事之一。输入可能出现异常,设备可能失效。简言之可能会出错,当错误发生时,程序员就有责任确保代码照常工作。
错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
7.1 使用异常而非返回码¶
- 在很久以前,许多语言都不支持异常。这些语言处理和汇报错误的手段都有限。你要么设置一个错误标识,要么返回给调用者检查的错误码。
- 遇到错误时,最好抛出一个异常。调用代码很整洁,其逻辑不会被错误处理搞乱。
7.2 先写 Try-Catch-Finally语句¶
- 异常的妙处之一是,它们在程序中定义了一个范围。执行try-catch-finally语句中try部分的代码时,你是在表明可随时取消执行,并在catch语句中接续。
- 在编写可能抛出异常的代码时,最好先写出try-catch-finally语句。这能帮你定义代码的用户应该期待什么,无论try代码块中执行的代码出什么错都一样。
- 用try-catch结构定义了一个范围,可以继续用测试驱动(TDD)方法构建剩余的代码逻辑。
7.3 使用不可控异常¶
- 可控异常的代价就是违反开放/闭合原则。如果在方法中抛出可控异常,而catch语句在三个层级之上,就得在catch语句和抛出异常处之间的每个方法签名中声明该异常。这意味着对软件中较低层级的修改,都将波及较高层级的签名。修改好的模块必须重新构建和发布,即便它们自身所关注的任何东西都没改动过。
- 最终得到的就是一个从软件最底端贯穿到最高端的修改链!封装被打破了,因为在抛出路径中的每个函数都要去了解下一层级的异常细节。既然异常旨在让你能在较远处处理错误,可控异常以这种方式破坏封装简直就是一种耻辱。
- 如果你在编写一套关键代码库,则可控异常有时也会有用:你必须捕获异常。但对于一般的应用开发,其依赖成本要高于收益。
7.4 给出异常发生的环境说明¶
- 你抛出的每个异常,都应当提供足够的环境说明,以便判断错误的来源和处所。
- 应创建信息充分的错误消息,并和异常一起传递出去。在消息中,包括失败的操作和失败类型。
7.5 依调用者需要定义异常类¶
- 对错误分类有很多方式。不过当我们在应用程序中定义异常类时,最重要的考虑应该是它们如何被捕获。
- 就可以通过打包调用API、确保它返回通用异常类型,从而简化代码。
- 实际上将第三方API打包是个良好的实践手段。当你打包一个第三方API,就降低了对它的依赖:未来你可以不太痛苦地改用其他代码库。在你测试自己的代码时,打包也有助于模拟第三方调用。
- 对于代码的某个特定区域,单一异常类通常可行。伴随异常发送出来的信息能够区分不同错误。如果你想要捕获某个异常并且放过其他异常,就使用不同的异常类。
7.6 定义常规流程¶
- 这样做却把错误检测推到了程序的边缘地带。你打包了外部API以抛出自己的异常,你在代码的顶端定义了一个处理器来应付任何失败了的运算。
- 这种手法叫做特例模式(SPECIAL CASE PATTERN)。 创建一个类或配置一个对象,用来处理特例。你来处理特例,客户代码就不用应付异常行为了。异常行为被封装到特例对象中。
7.7 别返回null 值¶
- 返回null值,基本上是在给自己增加工作量,也是在给调用者添乱。只要有一处没检查null值,应用程序就会失控。
- 如果你打算在方法中返回null值,不如抛出异常,或是返回特例对象。如果你在调用某个第三方API中可能返回null值的方法,可以考虑用新方法打包这个方法,在新方法中抛出异常或返回特例对象。
7.8 别传递null值¶
- 在方法中返回null值是糟糕的做法,但将null值传递给其他方法就更糟糕了。除非API要求你向它传递null值,否则就要尽可能避免传递null值。
- 还有替代方案。可以使用一组断言。
- 在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已如此,恰当的做法就是禁止传入null值。这样你在编码的时候,就会时时记住参数列表中的null值意味着出问题了,从而大量避免这种无心之失。
7.9 小结¶
整洁代码是可读的,但也要强固。可读与强固并不冲突。如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而整洁的代码。做到这一步,我们就能单独处理它,也极大地提升了代码的可维护性。
8 边界¶
不管是哪种情况,我们都得将外来代码干净利落地整合进自己的代码中。
8.1 使用第三方代码¶
- 在接口提供者和使用者之间,存在与生俱来的矛盾。第三方程序包和框架提供者追求普适性,这样就能在多个环境中工作,吸引广泛的用户。而使用者则想要集中满足特定需求的接口。这种矛盾会导致系统边界上出现问题。
- 边界上的接口是隐藏的。它能随来自应用程序其他部分的极小的影响而变动。
- 该接口也经过仔细修整和归置以适应应用程序的需要。结果就是得到易于理解、难以被误用的代码。
- 如果你使用类似Map这样的边界接口,就把它保留在类或近亲类中。避免从公共API中返回边界接口,或将边界接口作为参数传递给公共API。
8.2 浏览和学习边界¶
- 第三方代码帮助我们在更少时间内发布更丰富的功能。在利用第三方程序包时,该从何处入手呢?我们没有测试第三方代码的职责,但为要使用的第三方代码编写测试,可能最符合我们的利益。
- 学习第三方代码很难。整合第三方代码也很难。同时做这两件事难上加难。如果我们采用不同的做法呢?不要在生产代码中试验新东西,而是编写测试来遍览和理解第三方代码。Jim Newkirk 把这叫做学习性测试(learning tests)。
- 在学习性测试中,我们如在应用中那样调用第三方代码。我们基本上是在通过核对试验来检测自己对那个API的理解程度。测试聚焦于我们想从API得到的东西。
8.3 学习性测试的好处不只是免费¶
- 学习性测试毫无成本。无论如何我们都得学习要使用的API,而编写测试则是获得这些知识的容易而不会影响其他工作的途径。学习性测试是一种精确试验,帮助我们增进对API的理解。
- 学习性测试不光免费,还在投资上有正面的回报。当第三方程序包发布了新版本,我们可以运行学习性测试,看看程序包的行为有没有改变。
- 学习性测试确保第三方程序包按照我们想要的方式工作。一旦整合进来,就不能保证第三方代码总与我们的需要兼容。原作者不得不修改代码来满足他们自己的新需要。他们会修正缺陷、添加新功能。风险伴随新版本而来。如果第三方程序包的修改与测试不兼容,我们也能马上发现。
- 无论你是否需要通过学习性测试来学习,总要有一系列与生产代码中调用方式一致的输出测试来支持整洁的边界。不使用这些边界测试来减轻迁移的劳力,我们可能会超出应有时限,长久地绑在旧版本上面。
8.4 使用尚不存在的代码¶
- 另一种边界是那种将已知和未知分隔开的边界。在代码中总有许多地方是我们的知识未及之处。有时边界那边就是未知的(至少目前未知)。有时,我们并不往边界那边看过去。
- 编写我们想得到的接口,好处之一是它在我们控制之下。这有助于保持客户代码更可读,且集中于它该完成的工作。
- ADAPTER 封装了与API的互动,也提供了一个当API发生变动时唯一需要改动的地方。
8.5 整洁的边界¶
- 边界上会发生有趣的事。改动是其中之一。有良好的软件设计,无需巨大投入和重写即可进行修改。在使用我们控制不了的代码时,必须加倍小心保护投资,确保未来的修改不至于代价太大。
- 边界上的代码需要清晰的分割和定义了期望的测试。应该避免我们的代码过多地了解第三方代码中的特定信息。依靠你能控制的东西,好过依靠你控制不了的东西,免得日后受它控制。
- 我们通过代码中少数几处引用第三方边界接口的位置来管理第三方边界。
9 单元测试¶
敏捷和TDD运动鼓舞了许多程序员编写自动化单元测试,每天还有更多人加入这个行列。但是在争先恐后将测试加入规程中时,许多程序员遗漏了一些关于编写好测试的更细微但却重要的要点。
9.1 TDD三定律¶
- 定律一:在编写不能通过的单元测试前,不可编写生产代码。
- 定律二:只可编写刚好无法通过的单元测试,不能编译也算不通过。
- 定律三:只可编写刚好足以通过当前失败测试的生产代码。
9.2 保持测试整洁¶
- 那个团队认定,测试代码的维护不应遵循生产代码的质量标准。
- 他们彼此默许在单元测试中破坏规矩。这个团队没有意识到的是,脏测试等同于——如果不是坏于的话——没测试。问题在于测试必须随生产代码的演进而修改。测试越脏,就越难修改。测试代码越缠结,你就越有可能花更多时间塞进新测试,而不是编写新生产代码。修改生产代码后,旧测试就会开始失败,而测试代码中乱七八糟的东西将阻碍代码再次通过。于是测试变得就像是不断翻番的债务。
- 随着版本递进团队维护测试代码组的代价也在上升。最终,它变成了开发者最大的抱怨对象。当经理们问及为何超支如此巨大,开发者们就归咎于测试。最后他们只能扔掉了整个测试代码组。
- 但是没有了测试代码组,他们就失去了确保对代码的改动能如愿工作的能力。没有了测试代码组,他们就无法确保对系统某个部分的修改不会影响到系统的其他部分。故障率开始增加。随着并非出自有意的故障越来越多,他们开始害怕做改动。他们不再清理生产代码,因为他们害怕修改带来的损害多于收益。生产代码开始腐坏。最后他们只剩下没有测试、纷乱而缺陷缠身的生产代码,沮丧的客户,还有对测试的失望。
- 故事的寓意很简单:测试代码和生产代码一样重要。它可不是二等公民。它需要被思考、被设计和被照料。它该像生产代码一般保持整洁。
- 如果测试不能保持整洁,你就会失去它们。没有了测试则会失去保证生产代码可扩展的一切要素。你没看错。正是单元测试让你的代码可扩展、可维护、可复用。原因很简单。有了测试则不担心对代码的修改!没有测试则每次修改都可能带来缺陷。
- 覆盖了生产代码的自动化单元测试程序组能尽可能地保持设计和架构的整洁。测试带来了一切好处,因为测试使改动变得可能。如果测试不干净,你改动自己代码的能力就有所牵制,而你也会开始失去改进代码结构的能力。测试越脏,代码就会变得越脏。最终你丢失了测试,代码开始腐坏。
9.3 整洁的测试¶
- 整洁的测试有什么要素?有三个要素:可读性,可读性和可读性。在单元测试中,可读性甚至比在生产代码中还重要。测试如何才能做到可读?和其他代码中一样:明确,简洁,还有足够的表达力。在测试中你要以尽可能少的文字表达大量内容。
- 这些测试显然呈现了构造-操作-检验(BUILD-OPERATE-CHECK)模式。每个测试都清晰地拆分为三个环节。第一个环节构造测试数据,第二个环节操作测试数据,第三个部分检验操作是否得到期望的结果。
1. 面向特定领域的测试语言
- 我们没有直接使用程序员用来对系统进行操作的API,而是打造了一套包装这些API的函数和工具代码,这样就能更方便地编写测试,写出来的测试也更便于阅读。那正是一种测试语言,可以帮助程序员编写自己的测试,也可以帮助后来者阅读测试。
- 这种测试API并非起初就设计出来,而是在对那些充满令人迷惑细节的测试代码进行后续重构时逐渐演进。
2. 双重标准
- 在某种意义上,本章开始处提到的那个团队的做法是正确的。测试API中的代码与生产代码相比,的确有一套不同的工程标准。测试代码应当简单、精悍、足具表达力,但它该和生产代码一般有效。毕竟它是在测试环境而非生产环境中运行,这两种环境有着截然不同的需求。
- 这就是双重标准。有些事你大概永远不会在生产环境中做,而在测试环境中做却完全没问题。通常这关乎内存或CPU效率的问题,不过却永远不会与整洁有关。
9.4 每个测试一个断言¶
- 有个流派认为:JUnit 中每个测试函数都应该有且只有一个断言语句。这条规则看似过于苛求,但其好处却可以在代码清单中看到。这些测试都归结为一个可快速方便地理解的结论。
- 注意修改了那些函数的名称,以符合 given-when-then 约定。这让测试更易阅读。若如此分解测试则导致了许多重复代码 的出现。可利用模板方法(TEMPLATE METHOD)模式,将 given/when 部分放到基类中,将 then 部分放到派生类中,消除代码重复问题。
- 我认为,单个断言是个好准则。我通常都会创建支持这条准则的特定领域测试语言,如代码清单所示。不过我也不害怕在单个测试中放入一个以上断言。我认为最好的说法是单个测试中的断言数量应该最小化。
- 最佳规则也许是应该尽可能减少每个概念的断言数量,每个测试函数只测试一个概念。
9.5 F.I.R.S.T.¶
- 快速(Fast):测试应该够快。测试应该能快速运行。测试运行缓慢,你就不会想要频繁地运行它。
- 独立(Independent):测试应该相互独立。某个测试不应为下一个测试设定条件。
- 可重复(Repeatable):测试应当可在任何环境中重复通过。你应该能够在生产环境、质检环境中运行测试。
- 自足验证(Self-Validating):测试应该有布尔值输出。无论是通过或失败,你不应该查看日志文件来确认测试是否通过。
- 及时(Timely):测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。
9.6 小结¶
- 对于项目的健康度,测试盒生产代码同等重要。或许测试更为重要,因为它保证和增强了生产代码的可扩展性、可维护性和可复用性。所以保持测试整洁吧。让测试具有表达力并短小精悍。发明作为面向特定领域语言的测试API,帮助自己编写测试。如果你坐视测试腐坏,那么代码也会跟着腐坏。保持测试整洁吧。
10 类¶
除非我们将注意力放到代码组织的更高层面,就始终不能得到整洁的代码。
10.1类的组织¶
- 遵循标准的 Java 约定,类应该从一组变量列表开始。如果有公共静态常量,应该先出现。然后是私有静态变量,以及私有实体变量。很少会有公共变量。公共函数应跟在变量列表之后。我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数后面。这符合了自顶向下原则,让程序读起来就像一篇报纸文章。
- 保持变量和工具函数的私有性,但并不执着于此。有时我们也需要用到受护(protected)变量或工具函数,好让测试可以访问到。
- 首先会想办法使之保有隐私。放松封装总是下策。
10.2 类应该短小¶
- 关于类的第一条规则是类应该短小。第二条规则是还要更短小。
- 类的名称应当描述其权责。实际上命名正是帮助判断类的长度的第一个手段。如果无法为某个类命以精确的名称,这个类大概就太长了。类名越含糊,该类越有可能拥有过多权责。
1 单一权责原则
- 单一权责原则 SRP 认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。
- 鉴别权责(修改的理由)常常帮助我们在代码中认识到并创建出更好的抽象。
- SRP 是 OO 101 设计中最为重要的概念之一,也是较为容易理解和遵循的概念之一。奇怪的是SRP往往也是最容易被破坏的类设计原则。经常会遇到做太多事的类。
- 分而治之,其在编程行为中的重要程度等同于在程序中的重要程度。
- 每个达到一定规模的系统都会包括大量逻辑和复杂性。管理这种复杂性的首要目标就是加以组织,以便开发者知道到哪儿能找到东西,并且在某个特定时间只需要理解直接有关的复杂性。反之,拥有巨大多目的类的系统,总是让我们在目前并不需要了解的一大堆东西中艰难跋涉。
- 强调一下:系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。
2 内聚
- 类应该只有少量实体变量。类中的每个方法都应该操作一个或多个这种变量。通常而言方法操作的变量越多,就越黏聚到类上。如果一个类中的每个变量都被每个方法所使用则该类具有最大的内聚性。
- 一般来说创建这种极大化内聚类是既不可取也不可能的;另一 方面我们希望内聚性保持在较高位置。内聚性高,意味着类中的方法和变量互相依赖、互相结合成一个逻辑整体。
- 保持函数和参数列表短小的策略,有时会导致为一组子集方法所用的实体变量数量增加。出现这种情况时,往往意味着至少有一个类要从大类中挣扎出来。你应当尝试将这些变量和方法分拆到两个或多个类中,让新的类更为内聚。
3 保持内聚性就会得到许多短小的类
- 仅仅是将较大的函数切割为小函数,就将导致更多的类出现。
- 如果有些函数想要共享某些变量,为什么不让它们拥有自己的类呢?当类丧失了内聚性,就拆分它!
- 将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。程序会更加有组织,也会拥有更为透明的结构。
10.3 为了修改而组织¶
- 对于多数系统,修改将一直持续。每处修改都让我们冒着系统其他部分不能如期望般工作的风险。在整洁的系统中我们对类加以组织,以降低修改的风险。
- 出现了只与类的一小部分有关的私有方法行为,意味着存在改进空间。然而展开行动的基本动因却应该是系统的变动。
- 每个类中的代码都变得极为简单。理解每个类花费的时间缩减到近乎为零。函数对其他函数造成毁坏的风险也变得几近于无。从测试的角度看,验证方案中每一处逻辑都成了极为简单的任务,因为类与类之间相互隔离了。
- 我们希望精心组织系统,从而添加或修改特性时尽可能减少惹麻烦。在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。
- 依赖于具体细节的客户类,当细节改变时就会有风险。我们可以借助接口和抽象类来隔离这些细节带来的影响。
- 如果系统解耦到足以这样测试的程度,也就更加灵活,更加可复用。部件之间的解耦代表着系统中的元素互相隔离得很好。隔离也让对系统每个元素的理解变得更加容易。
- 通过降低连接度,我们的类就遵循了另一条类设计原则,依赖倒置原则(Dependency Inversion Principle,DIP)。本质而言,DIP 认为类应当依赖于抽象而不是依赖于具体细节。
11 系统¶
11.1如何建造一个城市¶
- 城市能运转,还因为它演化出恰当的抽象等级和模块,好让个人和他们所管理的“组件”即便在不了解全局时也能有效地运转。尽管软件团队往往也是这样组织起来,但他们所致力的工作却常常没有同样的关注面切分及抽象层级。整洁的代码帮助我们在较低层的抽象层级上达成这一目标。本章将讨论如何在较高的抽象层级——系统层级——上保持整洁。
11.2 将系统的构造与使用分开¶
- 软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在互相缠结的依赖关系。
- 每个应用程序都该留意启始过程。将关注的方面分离开,是软件技艺中最古老也最重要的设计技巧。不幸的是,多数应用程序都没有做分离处理。启始过程代码很特殊,被混杂到运行时逻辑中。
- 仅出现一次的延迟初始化不算是严重问题。不过在应用程序中往往有许多种类似的情况出现。于是,全局设置策略(如果有的话)在应用程序中四散分布,缺乏模块组织性,通常也会有许多重复代码。
- 如果我们勤于打造有着良好格式并且强固的系统,就不该让这类就手小技巧破坏模块组织性。对象构造的启始和设置过程也不例外。应当将这个过程从正常的运行时逻辑中分离出来,确保拥有解决主要依赖问题的全局性一贯策略。
1. 分解main
- 将构造与使用分开的方法之一是将全部构造过程搬迁到 main 或被称之为 main 的模块中,设计系统的其余部分时,假设所有对象都已正确构造和设置。
- 控制流程很容易理解。main 函数创建系统所需的对象,再传递给应用程序,应用程序只管使用。
2. 工厂
- 有时应用程序也要负责确定何时创建对象。
3. 依赖注入
- 有一种强大的机制可以实现分离构造与使用,那就是依赖注入(Dependency Injection,DI),控制反转(Inversion of Control,IoC)在依赖管理中的一种应用手段。控制反转将第二权责从对象中拿出来,转移到另一个专注于此的对象中,从而遵循了单一权责原则。在依赖管理情景中,对象不应负责实体化对自身的依赖。反之,它应当将这份权责移交给其他“有权力”的机制,从而实现控制的反转。因为初始设置是一种全局问题,这种授权机制通常要么是 main 例程,要么是有特定目的的容器。
- 调用对象并不控制真正返回对象的类别(当然前提是它实现了恰当的接口),但调用对象仍然主动分解了依赖。真正的依赖注入还要更进一步。类并不直接分解其依赖,而是完全被动的。它提供可用于注入依赖的赋值器方法或构造器参数(或二者皆有).在构造过程中,DI 容器实体化需要的对象(通常按需创建),并使用构造器参数或赋值器方法将依赖连接到一起。
- 但延后初始化的好处是什么呢?这种手段在 DI 中也有其作用。首先,多数 DI 容器在需要对象之前并不构造对象。其次,许多这类容器提供调用工厂或构造代理的机制,而这种机制可为延迟赋值或类似的优化处理所用。
11.3 扩容¶
- “一开始就做对系统”纯属神话。反之,我们应该只去实现今天的用户故事,然后重构,明天再扩展系统、实现新的用户故事。这就是迭代和增量敏捷的精髓所在。测试驱动开发、重构以及它们打造出的整洁代码,在代码层面保证了这个过程的实现。
- 与物理系统相比,软件系统比较独特。软件系统的架构可以递增式地增长,只要我们持续将关注面恰当地切分。如我们将见到的那样,软件系统短生命周期本质使这一切变得可行。
- 业务逻辑与 EJB2 应用“容器”紧密耦合。你必须子类化容器类型,必须提供许多个该容器所需要的生命周期方法。由于存在这种与重量级容器的紧耦合,隔离单元测试就很困难。在 EJB2 bean 中,定义一种本质上是无行为 struct 的“数据传输对象”(DTO)很常见。这往往会导致拥有同样数据的冗余类型出现,而且也需要在对象之间复制数据的八股式代码。
- 在某些领域,EBJ2 架构已经很接近于真正的关注面切分。例如,在与源代码分离的部署描述中声明了期待的事务、安全及部分持久化行为。
- 原则上,你可以从模块、封装的角度推理持久化策略。但在实践上,你却不得不将实现了持久化策略的代码铺展到许多对象中。我们用术语“横贯式关注面”来形容这类情况。同样,持久化框架和领域逻辑,孤立地看也可以是模块化的。问题在于横贯这些领域的情形。
- 实际上,EJB 架构处理持久化、安全和事务的方法是“预期”面向方面编程(aspect-oriented programming,AOP),而 AOP 是一种恢复横贯式关注面模块化的普适手段。在 AOP 中,被称为方面(aspect)的模块构造指明了系统中哪些点的行为会以某种一致的方式被修改,从而支持某种特定的场景。这种说明是用某种简洁的声明或编程机制来实现的。以持久化为例,可以声明哪些对象和属性(或其模式)应当被持久化,然后将持久化任务委托给持久化框架。行为的修改由 AOP 框架以无损方式在目标代码中进行。
11.4 Java代理¶
- Java 代理适用于简单的情况,例如在单独的对象或类中包装方法调用。然而,JDK 提供的动态代理仅能与接口协同工作。对于代理类,你得使用字节码操作库。
- 代码量和复杂度是代理的两大弱点,创建整洁代码变得很难!另外,代理也没有提供在系统范围内指定执行点的机制,而那正是真正的 AOP 解决方案所必须的。
11.5 纯Java AOP框架¶
- 编程工具能自动处理大多数代理模板代码。在数个 Java 框架中,代理都是内嵌的,如 Spring AOP 和 JBoss AOP 等,从而能够以纯 Java 代码实现面向方面编程。
- 你实际上只是指定 Spring 或 Jboss 类库,框架以对用户透明的方式处理使用 Java 代理或字节代码库的机制。这些声明驱动了依赖注入(DI)容器,DI 容器再实体化主要对象,并按需将对象连接起来。
- Spring 之类的框架最终导致了 EJB 标准在第 3 版的彻底变化。使用 XML 配置文件和/或 Java 5 annotation,EJB3 很大程度上遵循了 Spring 通过描述性手段支持横贯式关注面的模型。
11.6 AspectJ的方面¶
- 通过方面来实现关注面切分的功能最全的工具是 AspectJ 语言,一种提供“一流的”将方面作为模块构造处理支持的 Java 扩展。在 80%~90%用到方面特性的情况下,Spring AOP 和 JBoss AOP 提供的纯 Java 实现手段足够使用。然而,AspectJ 却提供了一套用以切分关注面的丰富而强有力的工具。AspectJ 的弱势在于,需要采用几种新工具,学习新语言构造和使用方式。
11.7 测试驱动系统架构¶
- 通过方面式的手段切分关注面的威力不可低估。假使你能用 POJO 编写应用程序的领域逻辑,在代码层面与架构关注面分离开,就有可能真正地用测试来驱动架构。采用一些新技术,就能将架构按需从简单演化到精细。没必要先做大设计(Big Design Up Front,BDUF)。实际上,BDUF 甚至是有害的,它阻碍改进,因为心理上会抵制丢弃既成之事,也因为架构上的方案选择影响到后续的设计思路。
- 尽管软件也有物理的一面,只要软件的构架有效切分了各个关注面,还是有可能做根本性改动的。
- 这意味着我们可以从“简单自然”但切分良好的架构开始做软件项目,快速交付可工作的用户故事,随着规模的增长添加更多基础架构。有些世界上最大的网站采用了精密的数据缓存、安全、虚拟化等技术,获得了极高的可用性和性能,在每个抽象层和范围之内,那些最小化耦合的设计都简单到位,效率和灵活性也随之而来。
- EJB 早期架构就是一种著名的过度工程化而没能有效切分关注面的 API。在没能真正得到使用时,设计得再好的 API 也等于是杀鸡用牛刀。优秀的 API 在大多数时间都该在视线之外,这样团队才能将创造力集中在要实现的用户故事上。否则,架构上的约束就会妨碍向客户交付优化价值的软件。
- 最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯 Java(或其他语言)对象实现。不同的领域之间用最不具有侵害性的方面或类方面工具整合起来。这种架构能测试驱动,就像代码一样。
11.8 优化决策¶
- 模块化和关注面切分成就了分散化管理和决策。在巨大的系统中,不管是一座城市或一个软件项目,无人能做所有决策。
- 延迟决策至最后一刻也是好手段。这不是懒惰或不负责;它让我们能够基于最有可能的信息做出选择。提前决策是一种预备知识不足的决策。如果决策太早,就会缺少太多客户反馈、关于项目的思考和实施经验。
- 拥有模块化关注面的 POJO 系统提供的敏捷能力,允许我们基于最新的知识做出优化的、时机刚好的决策。决策的复杂性也降低了。
11.9 明智使用添加了可论证价值的标准¶
- 有了标准,就更易复用想法和组件、雇用拥有相关经验的人才、封装好点子,以及将组件连接起来。不过,创立标准的过程有时却漫长到行业等不及的程度,有些标准没能与它要服务的采用者的真实需求相结合。
11.10 系统需要领域特定语言¶
- 在软件领域,领域特定语言(Domain-Specific Language,DSL)最近重受关注。DSL 是一种单独的小型脚本语言或以标准语言写就的 API,领域专家可以用它编写读起来像是组织严谨的散文一般的代码。
- 优秀的 DSL 填平了领域概念和实现领域概念的代码之间的“壕沟”,就像敏捷实践优化了开发团队和甲方之间的沟通一样。如果你用与领域专家使用的同一种语言来实现领域逻辑,就会降低不正确地将领域翻译为实现的风险。
- DSL 在有效使用时能提升代码惯用法和设计模式之上的抽象层次。它允许开发者在恰当的抽象层级上直指代码的初衷。领域特定语言允许所有抽象层级和应用程序中的所有领域,从高级策略到底层细节,使用 POJO 来表达。
11.11 小结¶
- 系统也应该是整洁的。侵害性架构会湮灭领域逻辑,冲击敏捷能力。当领域逻辑受到困扰,质量也就堪忧,因为缺陷更易隐藏,用户故事更难实现。当敏捷能力受到损害时,生产力也会降低,TDD 的好处遗失殆尽。在所有的抽象层级上,意图都应该清晰可辨。只有在编写 POJO 并使用类方面的机制来无损地组合其他关注面时,这种事情才会发生。无论是设计系统或单独的模块,别忘了使用大概可工作的最简单方案。
12 迭进¶
12.1 通过迭进设计达到整洁目的¶
据 Kent 所述,只要遵循以下规则,设计就能变得“简单”:
- 运行所有测试;
- 不可重复;
- 表达了程序员的意图;
- 尽可能减少类和方法的数量;
以上规则按其重要程度排列。
12.2 简单设计规则 1:运行所有测试¶
- 只要系统可测试,就会导向保持类短小且目的单一的设计方案。遵循 SRP 的类,测试起来较为简单。测试编写得越多,就越能持续走向编写较易测试的代码。所以,确保系统完全可测试能帮助我们创建更好的设计。
- 紧耦合的代码难以编写测试。同样,编写测试越多,就越会遵循 DIP 之类规则,使用依赖注入、接口和抽象等工具尽可能减少耦合。如此一来,设计就有长足进步。
- 遵循有关编写测试并持续运行测试的简单、明确的规则,系统就会更贴近面向对象低耦合度、高内聚度的目标。编写测试引致更好的设计。
12.3 简单设计规则 2~4:重构¶
- 有了测试,就能保持代码和类的整洁,方法就是递增式地重构代码。添加了几行代码后,就要暂停,琢磨一下变化了的设计。设计退步了吗?如果是,就要清理它,并且运行测试,保证没有破坏任何东西。测试消除了对清理代码就会破坏代码的恐惧。
- 在重构过程中,可以应用有关优秀软件设计的一切知识。提升内聚性,降低耦合度,切分关注面,模块化系统性关注面,缩小函数和类的尺寸,选用更好的名称,如此等等。这也是应用简单设计后三条规则的地方:消除重复,保证表达力,尽可能减少类和方法的数量。
12.4 不可重复¶
- 重复是拥有良好设计系统的大敌。它代表着额外的工作、额外的风险和额外且不必要的复杂度。
- 要想创建整洁的系统,需要有消除重复的意愿,即便对于短短几行也是如此。
- 可以把一个新方法分解到另外的类中,从而提升其可见性。团队中的其他成员也许会发现进一步抽象新方法的机会,并且在其他场景中复用之。“小规模复用”可大量降低系统复杂性。要想实现大规模复用必须理解如何实现小规模复用。
12.5 表达力¶
- 我们中的大多数人都经历过费解代码的纠缠。我们中的许多人自己就编写过费解的代码。写出自己能理解的代码很容易,因为在写这些代码时,我们正深入于要解决的问题中。代码的其他维护者不会那么深入,也就不易理解代码。
- 代码应当清晰地表达其作者的意图。作者把代码写得越清晰,其他人花在理解代码上的时间也就越少,从而减少缺陷,缩减维护成本。
- 可以通过选用好名称来表达。我们想要听到好类名和好函数名,而且在查看其权责时不会大吃一惊。也可以通过保持函数和类尺寸短小来表达。短小的类和函数通常易于命名,易于编写,易于理解。还可以通过采用标准命名法来表达。编写良好的单元测试也具有表达性。测试的主要目的之一就是通过实例起到文档的作用。读到测试的人应该能很快理解某个类是做什么的。
- 做到有表达力的最重要方式却是尝试。有太多时候,我们写出能工作的代码,就转移到下一个问题上,没有下足功夫调整代码,让后来者易于阅读。记住,下一位读代码的人最有可能是你自己。
- 所以,多少尊重一下你的手艺吧。花一点点时间在每个函数和类上。选用较好的名称,将大函数切分为小函数,时时照拂自己创建的东西。用心是最珍贵的资源。
12.6 尽可能少的类和方法¶
- 即便是消除重复、代码表达力和 SRP 等最基础的概念也会被过度使用。为了保持类和函数短小,我们可能会造出太多的细小类和方法。所以这条规则也主张函数和类的数量要少。
- 我们的目标是在保持函数和类短小的同时,保持整个系统短小精悍。不过要记住,这在关于简单设计的四条规则里面是优先级最低的一条。所以,尽管使类和函数的数量尽量少是很重要的,但更重要的却是测试、消除重复和表达力。
12.7 小结¶
有没有能替代经验的一套简单实践手段呢?当然不会有。另一方面,本章中写到的实践来自于本书作者数十年经验的精练总结。遵循简单设计的实践手段,开发者不必经年学习就能掌握好的原则和模式。