软件系统是由多层组成的,高层使用低层提供的能力。
在一个设计良好的系统中,每一层都会提供一种不同于其上下层的抽象;如果你跟踪一个操作在层之间通过方法调用不断向上、向下流动的过程,你会发现抽象会随着每一次方法调用而发生变化。
例如:
在文件系统中,最上层实现的是“文件”这一抽象。文件由一个可变长度的字节数组构成,可以通过读取和写入不同长度的字节区间来更新。文件系统中的下一层实现的是内存中的固定大小磁盘块缓存;调用者可以假设,频繁使用的 block 会保留在内存中,从而能够被快速访问。最底层则由设备驱动组成,它们负责在二级存储设备与内存之间搬运 block。
在 TCP 这样的网络传输协议中,最上层提供的抽象是:一种能够可靠地把字节流从一台机器传送到另一台机器的机制。这一层建立在更低层之上,而低层负责在机器之间以“尽力而为(best-effort)”的方式传输有大小限制的数据包:大多数 packet 都能够成功送达,但有些 packet 可能会丢失,或者乱序到达。
如果系统中相邻层拥有相似的抽象,那么这是一个危险信号,说明类的拆分方式可能存在问题。
本章将讨论:
当相邻层具有相似抽象时,这个问题通常会表现为“pass-through method”的形式。
所谓 pass-through method,是指一个方法除了调用另一个方法之外几乎什么都不做,而被调用方法的签名与当前方法相同或几乎相同。
例如,在一个实现 GUI 文本编辑器的学生项目中,有一个类几乎完全由 pass-through method 组成。
下面是其中一部分代码:
public class TextDocument ... {
private TextArea textArea;
private TextDocumentListener listener;
...
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
public int getCursorOffset() {
return textArea.getCursorOffset();
}
public void insertString(String textToInsert,
int offset) {
textArea.insertString(textToInsert, offset);
}
public void willInsertString(String stringToInsert,
int offset) {
if (listener != null) {
listener.willInsertString(this,
stringToInsert, offset);
}
}
...
}
pass-through method 指的是一个方法除了把参数传递给另一个方法之外,不做任何事情;而且通常它拥有与被调用方法相同的 API。
这通常意味着这些类之间并不存在清晰的职责划分。
15 个 public 方法中,有 13 个都是 pass-through method。pass-through method 会让类变得更浅(shallower):它们增加了类接口的复杂度,从而增加系统复杂度,但却没有增加系统整体功能。在前面展示的四个方法中,只有最后一个真正做了一点事情,而即便如此,它的功能也非常简单:只是检查了一个变量是否有效。
pass-through method 还会在类之间制造依赖关系:如果 TextArea 中 insertString 方法的签名发生变化,那么 TextDocument 中的 insertString 方法也必须随之修改。
pass-through method 表明:类之间的职责划分存在混乱。
在前面的例子中,TextDocument 类提供了 insertString 方法,但真正负责文本插入功能的实现却完全位于 TextArea 中。
这通常是一个糟糕的设计:某项功能的接口,应当与实现该功能的代码位于同一个类中。
当你看到一个类把方法 pass-through 给另一个类时,可以思考下面这个问题:
“这两个类分别到底负责哪些功能与抽象?”
你很可能会发现。这些类之间的职责出现了重叠。解决办法是重构这些类,使每个类都拥有清晰且一致的职责集合。
其中一种是,直接把底层类暴露给高层类的调用者,从而让高层类彻底不再负责该功能。
另一种方式是,重新分配类之间的功能。
最后,如果这些类实在无法解耦,那么最好的方案是,把它们合并。
在前面的例子中,共有三个职责相互缠绕的类:
TextDocumentTextAreaTextDocumentListener学生最终通过:
来消除了这些 pass-through method。而新的两个类,其职责划分也更加清晰。
拥有相同签名的方法,并不一定是坏事。关键在于:每个新增的方法,都必须提供重要的新功能。
pass-through method 之所以糟糕,是因为它们没有提供任何新功能。
有一种情况中:方法调用另一个拥有相同签名的方法,反而是有意义的。这种方法称为 dispatcher(分发器)。
dispatcher 是一种方法:它利用自己的参数,从多个方法中选择一个进行调用;随后,它会把大部分甚至全部参数传递给被选中的方法。dispatcher 的签名通常与它所调用的方法签名相同。即便如此,dispatcher 仍然提供了有价值的功能:
它负责决定:
哪一个方法应该执行当前任务。
例如:当 Web 服务器收到来自浏览器的 HTTP 请求时,它会调用一个 dispatcher。这个 dispatcher 会检查请求中的 URL,并选择某个特定方法来处理请求。有些 URL 可能会通过读取磁盘文件来处理;另一些 URL 则可能通过执行 PHP 或 JavaScript 程序来处理。
dispatch 过程本身可能非常复杂。
通常,它由一组规则驱动,而这些规则会与传入 URL 进行匹配。只要每个方法都提供了有价值且不同的功能,那么多个方法拥有相同签名就是合理的。dispatcher 所调用的方法就具备这一特点。
另一个例子是:
操作系统中具有多种实现的接口,例如磁盘驱动。每个 driver 都为不同类型的磁盘提供支持,但它们拥有相同接口。当多个方法提供同一接口的不同实现时,它能够降低认知负担。一旦你已经学会使用其中一个方法,那么使用其他方法也会更容易,因为你不需要重新学习新的接口。这类方法通常位于同一层,并且它们提供的是相同抽象的不同实现。
Decorator 设计模式(也被称为 “wrapper”)是一种会鼓励跨层 API 重复的模式。Decorator 对象接收一个已有对象,并在其基础上扩展功能;它提供与底层对象相同或相似的 API,而它自身的方法则会调用底层对象的方法。
在第 4 章 Java I/O 的例子中,BufferedInputStream 类就是一个 decorator:给定一个 InputStream 对象后,它提供了带有 buffering 能力的同一套 API。
例如:当调用它的 read 方法读取单个字符时,它实际上会调用底层 InputStream 的 read 方法来读取一个更大的 block,并把额外读取到的字符保存下来,以满足后续的 read 调用。
另一个例子出现在窗口系统中:一个 Window 类实现的是一种不能滚动的简单窗口;而 ScrollableWindow 类则通过增加水平和垂直滚动条来 decorate Window 类。
Decorator 的动机,是把某个类中的专用扩展,从更加通用的核心中分离出来。然而,decorator 类通常都很浅(shallow):它们会引入大量样板代码(boilerplate),但新增功能却很少。Decorator 类中通常包含大量 pass-through method。
Decorator 模式很容易被过度使用:每增加一个小功能,就创建一个新类。这样会导致大量 shallow class 的爆炸式增长,就像 Java I/O 的例子那样。在创建 decorator 类之前,可以考虑下面这些替代方案:
如果这个新功能本身相对通用,或者它在逻辑上与底层类密切相关,又或者大多数使用底层类的场景都会使用这个新功能,那么这样做是合理的。例如:几乎所有创建 Java InputStream 的人,都会再创建一个 BufferedInputStream;而 buffering 本来就是 I/O 的天然组成部分,因此这些类本应被合并。
这样会得到一个更深(deeper)的 decorator 类,而不是多个浅层 decorator。
有时候 wrapper 的确是合理的。一种情况是:系统使用了一个外部类,而该类的 interface 无法修改,但它在当前应用中又必须符合另一套 interface。在这种情况下,可以使用 wrapper class 在两个 interface 之间进行转换。不过,这种情况比较少见;通常都会存在比 wrapper 更好的方案。
“不同层,不同抽象(different layer, different abstraction)”这一原则的另一个应用是:一个类的 interface 通常应当与其 implementation 不同。也就是说:内部实现所使用的表示方式,应当不同于 interface 中出现的抽象。如果两者拥有相似抽象,那么这个类很可能不够深(deep)。
例如:在第 6 章讨论的文本编辑器项目中,大多数团队都把文本模块实现为“按行存储文本”:每一行文本被单独存储。一些团队还围绕“行”来设计文本类 API,例如:
getLineputLine然而,这会让文本类变得浅且难用。在高层用户界面代码中,经常需要:
如果文本类采用“按行”的 API,那么调用者就不得不:
来实现用户界面操作。这些代码不仅复杂,而且会分散并重复出现在用户界面实现中。当文本类提供“按字符”的 interface 时,会容易使用得多。例如:
insert 方法,可以在文本中的任意位置插入任意字符串(其中可能包含换行)delete 方法,可以删除两个任意位置之间的文本在内部,文本仍然是按行表示的。但“按字符”的 interface 把:
这些复杂性封装在了文本类内部。这样会让文本类变得更深,并简化使用该类的高层代码。在这种做法中:文本 API 与内部“按行存储”的机制有着明显区别;而这种区别,正体现了该类所提供的价值。
另一种跨层 API 重复的形式,是 pass-through variable。所谓 pass-through variable,是指:一个变量被沿着很长的方法调用链不断向下传递。
下面是一个来自数据中心服务(datacenter service)的例子。某个命令行参数描述了用于安全通信的证书(certificate)。这些信息实际上只会被底层方法 m3 使用;m3 会调用一个库方法来打开 socket。然而,这个变量却被一路传递经过了从 main 到 m3 之间的所有方法。cert 变量出现在所有中间方法的签名中。
pass-through variable 会增加复杂度,因为:它迫使所有中间方法都必须知道这个变量的存在,即便这些方法根本不会使用它。此外,如果后来系统新增了一个变量(例如系统最初不支持 certificate,但后来决定增加支持),那么你可能不得不修改大量 interface 和方法,以便把该变量沿着所有相关路径继续向下传递。
消除 pass-through variable 往往并不容易。一种做法是:
检查最上层方法与最底层方法之间,是否已经共享某个对象。在 datacenter service 示例中,也许已经存在一个对象,其中保存了其他网络通信信息,并且 main 与 m3 都能够访问它。如果是这样,那么 main 可以把 certificate 信息保存在这个对象中,这样它就不需要再通过所有中间方法传递给 m3。
不过,如果存在这样一个共享对象,那么它本身也可能成为 pass-through variable。(否则 m3 是如何访问到它的呢?)另一种做法是把信息保存在 global variable 中。这样就不需要在方法之间逐层传递信息。但是 global variable 几乎总会带来其他问题。
例如:global variable 会让你无法在同一个进程中创建两个相互独立的系统实例,因为它们对 global variable 的访问会互相冲突。
在生产环境中,你可能觉得自己不太可能需要多个实例;但在测试中,多实例往往非常有用。我最常使用的解决方案,引入一个 context object。context 用于保存应用程序中的所有“全局状态”:也就是那些原本会成为 pass-through variable 或 global variable 的内容。
大多数应用程序都会在全局状态中包含多个变量,例如:
系统的每个实例拥有一个 context object。这样,就能够让同一个进程中同时存在多个系统实例,并且每个实例拥有自己的 context。
遗憾的是,context 很可能会在很多地方被使用,因此它本身也可能变成 pass-through variable。为了减少需要知道 context 存在的方法数量,可以把对 context 的引用保存在系统中大多数主要对象内部。
当创建新对象时,创建方法会从自己的对象中取得 context 引用,并把它传递给新对象的构造函数。
采用这种方式后,context 在系统中几乎随处可用,但它只会作为显式参数出现在 constructor 中。context object 能够统一处理所有系统级全局信息,并消除 pass-through variable 的需求。如果需要新增一个变量,只需把它加入 context object;除了 context 的 constructor 和 destructor 之外,现有代码都不需要修改。
context 还使系统全局状态更容易识别与管理,因为所有内容都集中保存在一个地方。它对于测试也非常方便:测试代码可以通过修改 context 中的字段,来改变应用程序的全局配置。如果系统使用的是 pass-through variable,那么实现这样的修改会困难得多。
不过,context 远不是理想方案。保存在 context 中的变量,会继承 global variable 的大部分缺点。
例如某个变量为什么存在、在哪里被使用,可能并不明显。如果缺乏约束,context 很容易变成一个巨大的“数据垃圾袋(grab-bag)”,从而在系统各处制造隐式依赖关系。context 还可能带来线程安全问题。避免问题的最好方式,是让 context 中的变量保持 immutable(不可变)。遗憾的是到目前为止,我还没有找到比 context 更好的方案。
系统中新增的每一项设计基础设施——例如:
都会增加复杂度,因为开发者必须学习这些元素。因此,一个设计元素若想在复杂度上带来“净收益”,它必须消除一些“如果没有这个设计元素,本来会存在的复杂度”。否则,还不如根本不要引入这个设计元素。
例如一个 class 可以通过封装功能来降低复杂度,使 class 的使用者无需了解这些功能内部实现。“different layer, different abstraction(不同层,不同抽象)”这一原则,本质上就是上述思想的一个应用。如果不同层拥有相同抽象,例如:
那么很可能意味着这些额外基础设施,并没有提供足够收益来抵消它们自身带来的复杂度。
pass-through argument 会迫使多个方法都必须知道它们的存在(从而增加复杂度),但却没有提供额外功能。