1.01 系统概述¶
1.01.1 系统的定义¶
对当今的软件开发人员而言,如果要开发一个应用程序(称为Application或APP),他们不需要从头开始编写底层代码,比如编写一套字符串的排序算法,或者构建一套CPU调度策略,等等。这些底层代码通常已经被前人编写完成,甚至已经经过了多年的锤炼和优化,它们以系统软件、程序库、编程框架等形式存在于世。在开发应用程序时,软件开发人员更多的工作是调用和指挥这些系统软件、程序库或编程框架,让它们按照应用程序的逻辑协同工作,以实现应用程序的功能。我们可以将这些系统软件、程序库、编程框架看作应用软件的通用模块,它们可以像积木一样被搭建成各种形态,以满足不同需求。
系统软件就是这些通用模块中的一类。常见的系统软件包括操作系统、网络交换系统、数据管理系统、浏览器等。本书将系统软件简称为系统,并给了它一个极其简单的定义,即能够独立运行的程序模块。它是应用程序的组成部分,提供各种应用程序共同需要的一些基本功能。例如,操作系统提供了所有应用都需要的CPU调度功能和内存管理功能,网络交换系统提供了所有应用都需要的远程通信功能。它最原始的目的是简化软件开发工作,即将繁琐的软件工程过程替换为简单的系统使用过程,从而提升软件开发的效率。
以上的定义可能和部分专业人士对系统软件的定义有所区别。例如,站在操作系统开发者的角度,系统软件更像一个平台,用于管理和调度各种硬件设备,为上层的应用软件提供资源和服务。这些定义都没有问题。解读任何事物都可以通过不同的角度。本书从“程序模块”的角度来定义数据管理系统,更利于读者把握它的设计思想。
既然系统承载了软件的底层功能,那么它的设计就必须考究。系统的稳定性、性能、使用的方便程度等都要达到极高的标准。否则就不能胜任现代软件的需求。但这些指标常常相互矛盾,难以完全兼顾。如何衡量一个系统的优劣?这是一个很重要的问题,也是我们希望读者在完成本书的学习之后能够解答的问题。它的答案是复杂的。本书会在不同的章节从不同的角度提供这个问题的答案。我们首先从软件模块化的角度谈一谈,作为软件的一个程序模块,什么样的系统设计是好的。
1.01.2 一个好的系统一定是一个好的模块¶
所谓软件模块化(modularization),就是将软件的总体功能分解成多个子功能,分而治之。模块化是软件开发过程中的一个自然想法。当我们还是程序设计的初学者时,就开始不知不觉使用模块化了。凡是复杂一点的程序,我们都会将其功能分解,然后分别编写各个模块的程序,这是人与生俱来的思维习惯。在大型软件的开发中,模块化更是一个关键环节,它不仅用于团队的分工合作,也有利于代码的维护。然而,即便模块化是一种自然想法,我们并不是天生就能将它做好。对一个程序进行模块分解的方式很多,其效果可能迥异。那么,区分模块化优劣的标准何在?所幸前人已经帮我们做了很好的总结。
按照前面对系统的定义,一个系统实际上就是应用软件的一个模块,只不过它比普通的软件模块更为通用。那么,要衡量一套系统设计得如何,我们首先要考究它是否是一个设计合理的模块,也就是说,它是否经得起模块化标准的考验。
究竟什么是好的模块化?
衡量模块化的尺子很多。其中被公认的核心标准是由David L. Parnas教授在上世纪七十年代提出的“信息隐藏”(Information Hiding)标准。这里用一个简单的例子来说明这一项标准。这个例子出现在由David L. Parnas 1972年发表的一篇经典论文中[1]。
KWIC索引问题:
输入为一张有序的列表,表中的每一行是一个单词序列,而每一个单词是一个字母序列。我们可以对任意一行单词实施滚动操作。一次滚动是将该行的首个单词取出,再追加到该行的末尾。滚动操作可以不断重复,直到这一行单词恢复初始状态。对一行单词而言,每一次滚动的结果称为该行的一个“位移”。KWIC索引的功能就是对列表中所有行的所有“位移”进行穷举,并将它们按照字母顺序输出。
用一段程序实现KWIC索引并不难。关键是如何将这段程序合理地分解成模块。论文比较了两种方案。
方案一: 将程序分解成五个模块
模块1(输入):将列表的所有行读入,存放在内存里的数据结构中,以便所有模块的程序进行处理。
模块2(滚动):对内存中的每一行单词实施滚动,生成所有的位移。为了节约对内存的消耗,本模块实际上会构建一数组,只将每一行的每一个单词的地址记录在数组中。这样,数组中每一个项就代表了由这个单词起始的那一个“位移”。
模块3(排序):将模块2产生的数组作为输入,并生成同样大小和格式的另一个数组。只不过后一个数组是按照“位移”的字母顺序排列的。
模块4(输出):依照模块3生成的数组,去模块1的数据结构中读取相应的“位移”,并将其依次输出到终端。
模块5(控制台):负责启动和控制由前四个模块构成的工作流。
方案二: 将程序分解成六个模块
模块1(存储器):用于管理表格中的数据。提供如下的访问接口:函数CHAR(r,w,c)用于读取表格中第r行的第w个单词的第c个字母;函数SETCHAR(r,w,c,d)用于将第r行的第w个单词的第c个字母设置成变量d的取值;函数WORDS®将返回第r行的单词个数;同理,可以有其他函数用于读取每个单词的字母数或者整个表格的行数,等等。
模块2(输入):读取列表,调用模块1的相应函数,将列表存放到存储器中。
模块3(滚动):负责实施滚动操作,主要提供两个访问接口:函数CSCHAR(r,l,w,c)用于读取第r行的第l个“位移”的第w个单词的第c个字母;在首次调用CSCHAR之前,需要调用一次CSSETUP函数,以完成滚动所需的预备工作。实际功能都通过调用模块1的接口实现。
模块4(排序):也提供两个访问接口:函数ALPH用于实现按字母排序,需在其他函数之前调用;函数ITH(i)将返回排序后的第i个“位移”。实际功能都通过调用模块1和模块3的接口实现。
模块5(输出):调用模块4,按字母顺序依次读取“位移”,并输出到终端。
模块6(控制台):负责启动和控制前五个模块的工作。
以上两个方案孰优孰劣可能并非一目了然。第一种方案按照工作步骤划分模块,很自然,也是大部分人首先想到的方式。第二种方案显得比较复杂,但接口定义得很简洁、清晰。稍作分析,结论实际上是显而易见的。模块化的目的一方面是为了分工合作,另一方面是让软件易于维护。两者其实都基于同一个想法,就是让模块之间的关联(专业术语称为耦合度)越小越好。关联越小,就更有利于各个模块的独立开发,分工合作就更容易。同样,关联越小,一个模块的改动对其他模块的影响就越小,软件就更容易维护。在David L. Parnas看来,模块化归根到底是要实现“信息隐藏”,也就是说,用最简单的接口将模块的功能提供给使用者,而将模块的实现细节尽可能藏在模块内部,避免暴露给其他模块。这样做的目的就是降低模块间的耦合度。
方案二的设计显然遵循了“信息隐藏”的原则,虽然它看上去显得并不十分自然。反观方案一,模块之间的耦合就复杂多了。首先,用于存放表格的数据结构成为了联系各个模块的纽带 - 几乎所有的模块都需要访问这个数据结构以实现自己的功能。其次,模块2和模块3之间以及模块3和模块4之间都需要传递大型数组,这与方案二的简单函数接口形成了鲜明对比。
假设现实应用遇到的表格太大,不适合全部存放在内存中。对于方案二而言,只需要对模块1进行调整,使用硬盘空间存储表格,其他模块可以不变。而对于方案一,用于存放表格的数据结构一旦需要改造,所有模块都需要做相应调整,因为它们都需要直接访问这个数据结构。
又假设需求发生了变化,用户只需要输出排在最前面的那部分“位移”。这个变化带来了性能优化的空间。例如,程序可以采取渐进的模式,先需考虑那些以'a'开头的“位移”,如果这些“位移”足以满足需求,则可以终止,否则再追加考虑其余的“位移”。为了实现这项优化,方案一的滚动和排序模块需要推倒重来,而方案二则只需要对模块4进行改动。
以上的例子表明了基于“信息隐藏”原则实现的模块拆分优势明显。系统软件的设计也必须遵循“信息隐藏”原则。同理,作为一个模块的系统软件也应该用简单清楚的接口将自己的功能暴露给应用程序,而将功能的实现方式隐藏在内部。应用程序只需要通过接口将自己的需求或者想要达到的目的表达出来,然后将工作全权交给系统。系统负责准确无误并且不出意外地完成任务。至于怎么完成任务,这完全是系统自己的事,应用程序无需关心。系统内部的工作方式可以不断改进,但只要功能接口不变,应用程序就不受影响。
“信息隐藏”原则看似简单,实现它却不容易。要获得简洁的接口,首先要界定系统的功能范围。如果界定得不好,要么接口难以简化,要么功能缺失。在数据管理系统的发展过程中,人们就在这方面遇到了相当大的挑战,并且至今争论不休。在后面的内容中,我们会介绍不同数据管理系统的设计选择,及其相应的优势和弊端。总之,有一点是毋庸置疑的,一个好的系统一定是一个好的模块。
1.01.3 折衷是系统设计难以摆脱的宿命¶
系统在设计中面临的一个棘手问题就是取舍。
大数据的热潮带动了分布式系统的发展。对分布式系统有所了解的人大都听说过所谓的CAP理论。在CAP理论中,C代表数据一致性(Consistency),A代表可用性(Availability),P代表分区容忍能力(Partition Tolerance)。三者的具体含义这里暂且不表。CAP理论断言:一个分布式系统不可能同时实现C、A、P三种性质,只能选择其中的两种。于是,一个分布式系统的设计者就不得不做权衡和取舍 - 到底放弃哪一种性质呢。类似CAP理论这样的困境其实不是什么新鲜事。对于系统设计师而言,取舍就是家常便饭,是工程实践中随时都会碰到。经验告诉他们,完美的系统只存在于想象中,系统设计的目标只能限于找到一个完美的折衷。
作为人之常情,我们希望一个系统既有强大的功能,又有极致的性能,还非常易于使用。在功能、性能和易用性这三种性质之间,我们就碰到了难以调和的矛盾。如果一个系统功能强大,它的构造就会相对复杂,而复杂的构造势必带来性能损失。同样,一个功能强大的系统需要提供足够丰富的接口,以满足用户足够多的需求。接口的增加又会降低系统的易用性。类似这样的矛盾很多。在后面具体系统的介绍中,我们还会碰到不少。这些矛盾表明完美的系统是难以实现的,设计师必须取舍,找到合理的折衷点。同时,这些矛盾使得系统设计成为了一件有趣的事。它们考验着人的智慧。好的系统宛如一件艺术品,映射出设计师深邃的洞察力和巧妙的构思。
因为折衷,现实世界才诞生了各式各样的数据管理系统。这些系统因为选择了不同的折衷点而表现出不同的优势和弊端,也因而获得了各自的生存空间。然而,系统的多样性对软件开发并非好事。一方面,一个软件工程师没有精力去学习和掌握太多种类的系统。另一方面,一个软件生态也难以容纳过多的系统,否则其进化过程会遇到太多阻力。站在软件开发者的角度,某一类工作最好只交给一个通用系统,比如,数据管理工作最好只交给MySQL数据库,这样会大大简化软件工程的技术门槛和工程开销。这种“one size fits all”的需求很明确,也成为不少系统开发者的奋斗目标。但是,折衷问题是难以克服的,它意味着任何一个系统都有自己的弱点,因而难以做到真正的“one size fits all”。在数据管理系统的发展历程中,我们看到“one size fits all”和“one size fits a bunch”始终在抗衡,此消彼长。
如今,曾经被认为是“one size fits all”的传统SQL数据库(例如Oracle和MySQL)正面临各种NoSQL和NewSQl数据库的挑战。数据管理系统的多样化成为了当前的趋势。这预示着未来的软件开发人员需具备使用不同系统的能力。正因如此,本书选择将多种数据管理系统作为讲解对象,避免造成大家的思维固化,误将某一类系统的折衷选择作为放之四海而皆准的准则。
练习题¶
1. 关于为什么要降低软件系统中模块与模块之间的耦合度,哪种说法不对?
- 便于各个模块的独立开发
- 当一个模块的实现需要改变时,不至于对其他模块造成较大影响
- 减少模块之间的交互,提升性能
- 有利于程序的可读性
2. 思考题:请为以下程序做模块设计。 * 初始输入:若干文件,每个文件是一篇文章,由若干英文单词组成。例如:file1.txt, file2.txt, file5.txt,… * 调用输入:初始输入中的一个文件的文件名, 例如:file2.txt; * 调用输出:从初始输入的若干文件中,找到和调用输入文件最相似的文件(除了它自己),并输出这个文件的文件名,例如:file5.txt。 * 注意:程序未来可能需要调整,比如,相似度的计算方法可能会调整,又如,调用输入可能允许输入一个新文件,再如,调用输出可能允许输出多个相似的文件,等等。
参考资料¶
[1] D. L. Parnas, On the criteria to be used in decomposing systems into modules, Communications of the ACM, v.15 n.12, p.1053-1058, Dec. 1972.