第 5 章 信息隐藏和信息泄露
第 4 章认为模块应该是深的。本章及随后的几个章节讨论了创建深模块的技术。
本章概览模块里该藏什么,泄露了怎么看出来?
5.1 信息隐藏
实现深模块最重要的技术之一是 信息隐藏。这一思想最早由 David Parnas 在一篇经典论文 [1] 中提出。其基本思想是,每个模块应封装一些代表设计决策的知识 [2]。这些知识包含在模块的实现中,但不出现在接口中,因此其他模块无法看到。
隐藏在模块中的信息通常是某种机制的实现细节,例如:
- 如何在 B 树中存储信息,以及如何高效地访问它。
- 如何识别文件中每个逻辑块相对应的物理磁盘块。
- 如何实现 TCP 网络协议。
- 如何在多核处理器上调度线程。
- 如何解析 JSON 文档。
隐藏的信息包括与该机制相关的数据结构和算法,也可以包括较低层级的细节(例如页面大小),或更抽象的高层级概念,例如“大多数文件都很小”这一假设。
信息隐藏从两个方面降低了复杂性。首先,它简化了模块的接口。接口以更简单、更抽象的方式反映模块的功能,并隐藏细节,减少使用者的认知负荷。例如,使用 B 树类的开发人员不需要考虑树节点的理想扇出(fanout:指每个节点允许的最大子节点数),也不需要考虑如何保持树的平衡。其次,信息隐藏使系统更容易演进。如果一项信息被隐藏在模块中,模块外部就不存在对它的依赖,因此与该信息相关的设计变更只会影响这一个模块。例如,如果 TCP 协议发生变化(例如引入一种新的拥塞控制机制),就必须修改协议的实现,但使用 TCP 发送和接收数据的高层级代码应当无需修改。
设计新模块时,应仔细考虑可以在该模块中隐藏哪些信息。如果可以隐藏更多信息,你就应该能够简化模块的接口,这会使模块更深。
注意:将类中的变量和方法声明为私有,与信息隐藏不是同一回事。私有元素有助于隐藏信息,因为类外部无法直接访问它们。但是,私有属性仍可以通过公有方法(如 getter 和 setter 方法)暴露出来。在这种情况下,私有属性的性质和用法就与公有属性一样,对外都是可见的。
信息隐藏的最佳形式是将信息完全隐藏在模块中,让模块的用户既不必关心,也无法看到这些信息。不过,部分隐藏也有价值。例如,如果只有一个类的少数使用者需要某项特性或信息,而且它通过单独的方法访问,那么在最常见的场景中,这些信息仍然不可见,也就可以说它们在大部分情况下是隐藏的。与将信息暴露给类的所有使用者相比,这种方式产生的依赖更少。
5.2 信息泄露
信息隐藏的反面是信息泄露。当一个设计决策反映在多个模块中时,就会发生 信息泄露。这会在模块之间建立依赖关系:对该设计决策的任何更改,都要求修改所有涉及的模块。如果一项信息出现在模块的接口中,那么按定义,它就已经泄露了;因此,更简单的接口往往能隐藏更多信息。但是,即使信息未出现在接口中,也可能发生泄露。假设两个类都了解某种文件格式(也许一个类读取该格式的文件,另一个类负责写入),即使它们都不在接口中公开相关信息,也都依赖这一格式:如果文件格式改变,两个类就都需要修改。这种后门泄露比通过接口泄露更严重,因为它更隐蔽。
信息泄露是软件设计中最重要的危险信号之一。作为软件设计师,学会敏锐地识别信息泄露,是最有价值的技能之一。如果你在类之间发现信息泄露,请自问:“我该如何重新组织这些类,让这些特定知识只影响一个类?”如果受影响的类本身比较小,而且与泄露的信息紧密相关,那么将它们合并成一个类可能是合理的。另一种方法是从所有受影响的类中提取这些信息,再创建一个新类来封装它们。不过,只有能找到一个简单接口,将所有这些细节隐藏起来,这种方法才有效。如果新类仍通过接口公开大部分知识,这么做的价值就不大(你只不过用接口泄露取代了后门泄露)。
危险信号:信息泄露
在多个地方使用相同的知识时,就会发生信息泄露,例如上文中两个都依赖同一种文件格式的类。
5.3 时间顺序分解
一种我称之为 时间顺序分解 的设计风格,是导致信息泄露的常见原因。在时间顺序分解中,系统的结构对应于操作发生的时间顺序。考虑一个应用程序:它读取特定格式的文件,修改内容,然后将文件重新写入。按时间顺序分解,这个应用程序可能被拆成三个类:一个负责读取文件,一个负责修改,还有一个负责写入新版本的文件。读取和写入步骤都需要了解文件格式,这就导致了信息泄露。解决方案是将读写文件的核心机制合并到一个类中,供应用程序在读取和写入阶段使用。编写代码时,我们通常会顺着操作的执行顺序思考,因此很容易陷入时间顺序分解的陷阱。然而,大多数设计决策都会在应用程序生命周期中的多个不同时间点用到,所以时间顺序分解经常导致信息泄露。
执行顺序固然重要,也会在应用程序中有所体现。但除非按执行顺序划分模块符合信息隐藏的要求(例如,不同执行阶段可能使用完全不同的信息),否则不应让执行顺序决定模块结构。在设计模块时,应专注于执行每个任务所需的知识,而不是任务的执行顺序。
危险信号:时间分解
在时间顺序分解中,执行顺序反映在代码结构中:不同时间发生的操作被放在不同的方法或类中。如果不同执行点使用相同的知识,这些知识就会在多处代码中出现,从而导致信息泄露。
译者图解运行顺序不等于模块边界
5.4 示例:HTTP 服务器
为了说明信息隐藏中的问题,我们可以看看软件设计课程中,学生在实现 HTTP 协议时做出的设计决策。分析他们做得好的方面和遇到困难的地方,很有帮助。
HTTP 是 Web 浏览器用来与 Web 服务器通信的机制。当用户单击 Web 浏览器中的链接或提交表单时,浏览器使用 HTTP 通过网络将请求发送到 Web 服务器。服务器处理完请求后,会将响应发送回浏览器。该响应通常包含要显示的新网页。HTTP 协议指定了请求和响应的格式,两者均以文本形式表示。图 5.1 显示了描述表单提交的 HTTP 请求示例。在该课程中,学生们被要求实现一个或多个类,以使 Web 服务器可以轻松地接收传入的 HTTP 请求并发送响应。
图 5.1:HTTP 协议中的 POST 请求包含通过 TCP 套接字发送的文本。每个请求都包含一个初始行、一个由空行终止的标头(Header)集合以及一个可选的请求体(Body)。初始行包含请求类型(POST 用于提交表单数据),指示操作(/comments/create)和可选参数(photo_id 的值为 246)的 URL,以及发送方使用的 HTTP 协议版本。每个标头行由一个名称(例如 Content-Length)及其后的值组成。对于此请求,请求体包含了其他的参数(备注和优先级)。
5.5 示例:太多的类
学生们最常犯的错误是将代码分成大量浅类,导致类之间的信息泄露。有一个小组使用两个不同的类来接收 HTTP 请求:第一个类从网络连接中将请求读成字符串,第二个类解析这个字符串。这是时间顺序分解的一个示例(“首先读取请求,然后解析它”)。之所以会发生信息泄露,是因为不解析消息就无法读取 HTTP 请求。例如,Content-Length 标头指定了请求体的长度,只有解析标头,才能计算请求的总长度。因此,这两个类都需要了解 HTTP 请求的大部分结构,也都包含重复的解析代码。这种做法还给调用方带来了额外的复杂性:接收请求时,必须按特定顺序调用不同类中的两个方法。
由于这些类共享大量信息,因此最好将它们合并为一个同时处理请求读取和解析的类。这样便将请求格式的所有知识隔离在一个类中,提供了更好的信息隐藏,并且还为调用者提供了一个更简单的接口(只需要调用一个方法)。
这个示例说明了软件设计中的一个普遍规律:通常可以通过使类稍大一些来改善信息隐藏。这样做有两个好处。首先,可以将与特定功能相关的所有代码(例如解析 HTTP 请求)放在一起,让一个类包含与该功能相关的所有内容。其次,可以提高接口的抽象层级。例如,与其为计算的三个步骤分别提供方法,不如用一个方法完成整个计算,这样就能简化接口。上一段的示例同时体现了这两个好处:合并后的类包含了解析 HTTP 请求的所有相关代码,并用一个方法替代了原来两个对外可见的方法。这个类比原来的任何一个类都更深。
当然,类的范围也可能扩大过头(例如把整个应用程序都放在一个类里)。第 9 章将讨论什么情况下适合把代码拆分成多个较小的类。
译者图解还没解析,怎么知道读完了?
5.6 示例:HTTP 参数处理
服务器收到 HTTP 请求后,需要访问其中的某些信息。例如,处理图 5.1 中请求的代码可能需要知道 photo_id 参数的值。参数可以在请求的第一行中指定(图 5.1 中的 photo_id),有时也可以在请求体中指定(图 5.1 中的 comment 和 priority)。每个参数都有名称和值。参数值采用一种特殊编码,称为 URL 编码。例如,在图 5.1 的备注值中,+ 表示空格,%21 表示 !。为了处理请求,服务器需要取得某些参数解码后的值。
在参数处理方面,大多数学生项目都做出了两个不错的选择。首先,他们认识到服务器应用程序不关心参数是在标头行还是请求体中指定的,因此向调用者隐藏了这一区别,将两个位置的参数合并在一起。其次,他们隐藏了 URL 编码的细节:HTTP 解析器先解码参数值,再返回给 Web 服务器。因此,图 5.1 中的 comment 参数返回的值是 What a cute baby!,而不是 What+a+cute+baby%21。在这两种情况下,信息隐藏都使 HTTP 模块的 API 更加简单。
译者注(勘误):此处原文称“标头行”,实际指携带请求目标的“请求行”;请求行与后面的标头字段行是不同部分。正文保留原文用词,参见 HTTP 消息语法。
但是,大多数学生用来返回参数的接口太浅,错过了 信息隐藏 的机会。大多数项目使用 HTTPRequest 类型的对象保存已解析的 HTTP 请求,并在 HTTPRequest 类中只提供了一个类似下面的方法来返回参数:
public Map<String, String> getParams() {
return this.params;
}
该方法没有返回单个参数,而是返回了内部用于存储所有参数的映射(Map)的引用。这个方法很浅,因为它公开了 HTTPRequest 类存储参数的内部表示。更改这一表示方式就会导致接口变更,进而要求修改所有调用者。修改实现时,往往需要改变关键数据结构的表示方式(例如为了提高性能),因此应尽量避免暴露内部数据结构。这种做法还增加了调用者的工作:必须先调用 getParams,再调用另一个方法,从映射中获取特定参数。最后,调用者必须知道,不应该修改 getParams 返回的映射,否则会影响 HTTPRequest 的内部状态。
这是一个用于检索参数值的更好的接口:
public String getParameter(String name) { ... }
public int getIntParameter(String name) { ... }
getParameter 以字符串形式返回参数值,提供了比上面的 getParams 略深的接口。更重要的是,它隐藏了参数的内部表示。getIntParameter 将 HTTP 请求中以字符串表示的参数值转换为整数(例如图 5.1 中的 photo_id 参数)。调用者因此不必另行执行字符串到整数的转换,也无需了解转换机制。需要时,还可以为其他数据类型定义类似的方法,例如 getDoubleParameter。(如果所需参数不存在,或无法转换为请求的类型,这些方法都会抛出异常;上面的代码省略了异常声明。)
译者图解返回参数,还是泄露容器?
5.7 示例:HTTP 响应中的默认值
HTTP 项目还必须支持生成 HTTP 响应。学生在这方面最常犯的错误是没有提供足够的默认值。每个 HTTP 响应都必须指定一个 HTTP 协议版本。有一个小组要求调用者在创建响应对象时显式指定版本。但是,响应版本必须与请求对象中的版本相对应,而且发送响应时,已经需要将请求作为参数传入(它指示响应应发送到哪里)。因此,由 HTTP 类自动提供响应版本更合理。调用者不太可能知道该指定哪个版本,而且如果调用者确实指定了一个值,还很可能导致 HTTP 库与调用者之间的信息泄露。HTTP 响应还包含一个日期标头,用于指定发送响应的时间,HTTP 库也应该为它提供合理的默认值。
默认值体现了设计接口时应使常见情况尽可能简单的原则,也是部分隐藏信息的一个示例:正常情况下,调用者不必知道有这项采用默认值的设置。只有在需要覆盖默认值的少数情况下,调用者才需要了解相应的值,并调用专门的方法进行修改。
只要有可能,类就应该自动“做正确的事”,而不必等调用者显式要求。默认值就是一个例子。第 4.7 节的 Java I/O 示例从反面说明了这一点。文件 I/O 的使用者普遍希望获得缓冲功能,因此调用者不应还得显式请求它,甚至不必知道它的存在。I/O 类应该自动提供缓冲,做正确的事。最好的功能,是那些你甚至不知道它们存在的功能。
危险信号:过度暴露
如果一个常用特性的 API 迫使用户了解其他很少使用的特性,这将增加不需要使用这些特性的用户的认知负荷。
5.8 类内部的信息隐藏
本章的信息隐藏示例着重于类对外可见的 API,但信息隐藏也可以应用于系统的其他层级,比如类的内部。可以尝试让每个私有方法都封装一些信息或功能,向类的其余部分隐藏。此外,应尽量减少使用每个实例变量的位置。有些变量可能需要在整个类中广泛使用,另一些则可能只在少数地方用到。减少使用变量的位置,就能消除类内的依赖关系,降低复杂性。
5.9 做过头了
只有模块外部不需要某项信息时,隐藏它才有意义。如果模块外部需要该信息,就不能隐藏。假设模块的性能受某些配置参数影响,而不同用途需要不同的参数设置。这时,在接口中暴露这些参数就很重要,以便适当调整。作为软件设计师,你的目标应该是尽量减少模块外部需要了解的信息。例如,如果模块能够自动调整配置,就比公开配置参数更好。但必须识别出模块外部确实需要的信息,并确保将其公开。
5.10 结论
信息隐藏和深模块密切相关。模块隐藏的信息越多,往往就能提供越多功能,同时缩小对外接口,使模块更深。反过来,如果模块没有隐藏多少信息,就要么功能不多,要么接口复杂。无论哪种情况,模块都是浅的。
将系统分解为模块时,尽量不要受运行时操作顺序的影响,否则就会走向时间顺序分解,导致信息泄露和浅模块。应当考虑完成应用程序的任务需要哪些知识,再让每个模块封装其中的一项或几项。这样就能得到整洁、简单且模块足够深的设计。
[1] David Parnas,“关于将系统分解为模块的标准”,ACM 通讯,1972 年 12 月。 ↩
[2] 译者注:关于知识(knowledge)可以了解“最少知识原则”的设计原则。 ↩
