组件是部署单元。它们是系统中可被部署的最小实体。在 Java 中,组件是 jar 文件;在 Ruby 中,是 gem 文件;在 .NET 中,是 DLL 文件。在编译型语言中,组件是二进制文件的聚合体;在解释型语言中,组件是源文件的聚合体。在所有语言中,组件都是部署的基本粒度单位。

组件既可以被链接整合为单个可执行文件,也可以被聚合打包成单个归档文件(比如.war 文件),还能作为独立的动态加载插件来部署(例如.jar、.dll 或.exe 文件)。无论最终采用何种部署方式,设计良好的组件始终保有 “可独立部署” 的特性 —— 也正因如此,它们必然具备 “可独立开发” 的能力

A brief history of components

在软件开发早期,程序员需要亲自控制程序的内存地址与布局。程序的第一行代码往往是“起始语句(origin statement)”——用来声明程序将要被加载到内存中的具体地址。

以这个简单的PDP-8程序为例:它包含一个名为GETSTR的子程序(功能是从键盘读取字符串并保存到缓冲区),还附带了一段用于测试GETSTR的简单单元测试代码。

*200
TLS
START,
CLA
TAD BUFR
JMS GETSTR
CLA
TAD BUFR
JMS PUTSTR
JMP START
BUFR,
3000
GETSTR,
0
DCA PTR
NXTCH,
KSF
JMP -1
KRB
DCA I PTR
TAD I PTR
AND K177
ISZ PTR
TAD MCR
SZA
JMP NXTCH
K177,
177
MCR,
-15

注意程序开头的*200指令:它告诉编译器生成的代码要加载到八进制地址200(注:原文8为八进制标识,下同)。

这种编程方式对如今的大多数程序员来说完全是陌生的——他们几乎不用考虑程序被加载到计算机内存的哪个位置。但在早期,这是程序员要做的第一个决策之一:那时候的程序是不可重定位的(即加载地址固定,不能随便挪)。

那时候程序员怎么调用库函数?上面的代码就体现了当时的做法:程序员会把库函数的源代码直接嵌入应用代码中,然后将所有代码编译成一个完整的程序。库文件只以源码形式存在,而非二进制文件。

这种方式的问题在于:那个年代的设备速度慢、内存昂贵且容量有限。编译器需要对源代码进行多轮处理,但内存根本不足以容纳全部源码,因此编译器不得不通过低速设备反复读取源代码——这会耗费大量时间。而且函数库越大,编译耗时越长,编译一个大型程序甚至要花数小时。

为了缩短编译时间,程序员开始把函数库源码和应用代码分开:先单独编译函数库,将生成的二进制文件加载到一个固定的已知地址(比如八进制2000);再为函数库生成符号表,将符号表与应用代码一起编译。运行应用程序时,先加载二进制函数库,再加载应用程序——此时内存布局如图12.1所示。

只要应用程序能装进八进制地址0000到1777之间的空间,这种方式就没问题。但很快,应用程序体积超出了预留空间,程序员不得不把应用拆成两个地址段,绕着函数库的地址空间“跳转执行”。

显然,这种方式难以为继:随着程序员给函数库新增更多功能,库文件会超出预留地址范围,不得不为其分配更多空间(比如本例中分配到八进制7000附近)。而随着计算机内存容量的增长,程序和库文件的地址碎片化问题只会愈演愈烈。

链接器

链接加载器让程序员能够将程序拆分为可独立编译、独立加载的程序段。在程序规模较小、链接的函数库也相对精简的年代,这种方式运行良好。但到了20世纪60年代末、70年代初,程序员的开发野心不断膨胀,编写的程序体量也大幅增长。

渐渐地,链接加载器的运行速度慢到让人无法忍受。函数库存储在磁带这类低速存储设备上,即便在当时,磁盘的读写速度也十分缓慢。依靠这些低速设备,链接加载器需要读取数十个甚至上百个二进制库文件,才能完成外部符号引用的解析。随着程序越来越庞大,库文件中积累的函数也越来越多,仅加载一个程序,链接加载器有时就要耗费一个多小时。

最终,加载与链接被拆分为两个独立阶段。程序员把耗时的链接工作剥离出来,做成了一个独立程序——链接器。链接器的输出结果是一个已链接完成的可重定位文件,重定位加载器可以极快地将其加载到内存。这样一来,程序员可以先用速度较慢的链接器生成可执行文件,之后随时都能快速完成加载。

时间来到20世纪80年代,程序员开始使用C语言等高级语言开发。随着开发野心进一步扩大,程序体量也水涨船高,数十万行代码的程序已是常态。

C语言源码文件(.c)会被编译为目标文件(.o),再输入给链接器生成可快速加载的可执行文件。单个模块的编译速度尚可,但全部模块整体编译就需要耗费不少时间,链接过程耗时则更久。
在很多场景下,开发周转时间又重新拉长到一小时甚至更久。

程序员仿佛注定要陷入这种无休止的恶性循环。从60年代到80年代,所有为提升开发效率做出的改进,最终都被程序员不断膨胀的开发野心和程序体量抵消。长达一小时的周转时间似乎始终无法摆脱。程序加载速度虽然很快,但编译-链接的过程成了性能瓶颈。

我们无疑正在应验程序规模墨菲定律

程序总会膨胀到占满所有可用的编译与链接时间。

但这场博弈中不只有墨菲定律。20世纪80年代末,摩尔定律登场,与前者展开较量,最终摩尔定律取得了胜利。磁盘体积不断缩小,读写速度大幅提升;计算机内存价格变得极其低廉,磁盘上的大量数据可以缓存至内存中;计算机主频也从1MHz提升到了100MHz。

到90年代中期,链接耗时的缩短速度,开始超过程序随开发需求膨胀的速度。多数情况下,链接时间被压缩至几秒内。对于小型程序,链接加载器的设计思路再次变得可行。

这一时期迎来了Active-X、共享库,以及.jar文件的雏形。计算机与存储设备速度大幅提升,我们得以重新在加载阶段完成链接工作。只需几秒,就能将多个.jar文件或多个共享库链接起来并运行程序,组件插件化架构就此诞生。

如今,我们早已习惯将.jar文件、DLL文件或共享库作为插件,集成到现有应用中。比如想给《我的世界》制作模组,只需将自定义.jar文件放入指定文件夹;想在Visual Studio中安装ReSharper插件,只需引入对应的DLL文件即可。

结语

这些可在运行时动态链接、按需组合的文件,就是现代架构中的软件组件。历经半个世纪的发展,组件插件化架构如今已成为常规的默认方案,不再像过去那样需要付出巨大的开发成本才能实现。