Douglas C. Schmidt
ACE自适配通信环境(Adaptive Communication Environment)是一种面向对象(OO)的工具包,它实现了通信软件的许多基本的设计模式。ACE的目标用户是在UNIX和Win32平台上开发高性能通信服务和应用的开发者。ACE简化了使用进程间通信、事件多路分离、显式动态链接和并发的OO网络应用和服务的开发。通过在运行时将服务与应用动态链接进应用,并在一个或多个进程或线程中执行这些服务,ACE使系统的配置和重配置得以自动化。
本论文描述ACE的结构和功能,并使用来自像电信、企业级医学成像和WWW服务这样的领域的例子阐释核心的ACE特性。ACE可以自由使用,并正在被用于许多商业项目(比如爱立信、Bellcore、西门子、摩托罗拉、柯达,和McDonnell Douglas),以及许多学院和工业研究项目。ACE已被移植到多种OS(操作系统)平台上,包括Win32和大多数的UNIX/POSIX实现。此外,同时有C++和Java版本的ACE可用。
对健壮的和高性能的分布式计算系统的需求一直在稳定地增长。这些类型的系统的例子包括全球个人通信系统、网络管理平台,企业级医学成像系统、在线金融分析系统,以及实时航空控制系统。对于以下方面来说,分布式计算是一种有前途的技术:通过连接性和相互配合促进协作;通过并行处理改善性能;通过复制改善可*性和可用性;通过模块性改善可伸缩性和可移植性;通过动态配置和重配置改善可扩展性;以及通过资源共享和开发系统提高成本效用。
尽管分布式计算提供了许多潜在的好处,开发通信软件仍然是昂贵而易错的。面向对象编程语言、组件和构架(Framework)是被广泛鼓吹的、用以降低软件成本并提高软件质量的技术。去除那些过分的宣传,OO的主要好处源于对模块性和可扩展性的强调,它将易变的实现细节封装在稳定的接口后面,并增强了软件的复用。
多年来,在某些已被广泛探索的领域中的开发者已经成功地应用了OO技术和工具。例如,Microsoft MFC GUI构架和OCX组件是PC平台上用于创建图形商业应用的事实上的工业标准。尽管这些工具有着自身的局限,它们仍然演示了复用通用构架和组件的生产效率优势。
在像电信、医学成像、航空控制和在线事务处理这样的更复杂的领域中,软件开发者历来就缺少标准的、成型的中间件组件。结果,开发者在很大程度上是从头开始构建、验证和维护软件系统。在一个政府经济干预减少的艰难的全球竞争时代,这样的作坊式开发过程正在变得难以容忍的昂贵和费时。在业界,这样的情形导致了一场“分布式软件危机”:计算硬件和网络在变小、变快、变得更为便宜;而分布式软件的开发和维护在变大、变慢、变得更为昂贵。
构建分布式软件的挑战源于与分布式系统相关联的固有的和非固有的复杂性[1]。固有的复杂性源于开发分布式软件的基本的挑战,其中主要的有:检测和恢复网络及主机失败、最小化通信响应延迟的影响,以及确定服务组件和工作负载在网络的处理单元上的最优划分。
非固有的复杂性源于用以开发分布式软件的工具和技术的局限。例如,许多标准的网络机制(比如socket[2]和TLI[3])和可复用组件库(比如X windows和Sun RPC)缺乏类型安全的、可移植的、可重入的和可扩展的应用编程接口(API)。同样地,通用网络编程接口,如socket和TLI,使用弱类型的整型句柄,可能会导致微妙的运行时错误[4]。
复杂性的另一来源起因于算法分解的普遍使用[5],它致使软件系统不可扩展和不可复用[6]。尽管图形用户接口(GUI)普遍采用面向对象技术构建,典型的分布式软件通常仍然使用算法分解进行开发。在一些流行的网络编程教科书[7, 8, 3]中的例子基于面向算法的设计和实现技术,从而更加恶化了前述问题。
可扩展性和最大限度复用的缺乏对于复杂的分布式软件是特别成问题的。可扩展性是确保服务和特性的及时修改和增强的基本要求。复用是有效利用专家开发者的领域知识、以避免重新开发和重新验证“反复出现的需求和软件挑战的通用解决方案”的基本要求。
面向对象的设计模式和构架有助于减少对分布式软件的核心概念和抽象的昂贵的重新发现和发明,它们因此而备受重视。模式提供了一种封装设计知识的方法,这些设计知识为标准的分布式软件开发问题提供解决方案[9]。例如,模式对于描述重复出现的“微型结构”(比如反应堆(Reactor)[10]和主动对象(Active Object)[11])十分有用,这些微型结构是对一些已被证明可用于构建分布式通信软件的通用对象结构的抽象。但是,被文档化为模式的抽象并不直接产生可复用代码。因此,有必要通过构架的创建和使用来增加对模式的研究。
通过集成成组的抽象类,并定义这些类的协作的标准途径,构架为应用提供了可复用的软件组件[12]。构架实例化设计模式族,以帮助开发者避免对通用分布式软件组件的昂贵的重新发明。其成果是“半完成”的应用骨架,它可以通过继承和实例化构架中的可复用“积木”组件来进行定制。因为构架与关键的分布式编程任务(比如服务初始化、错误处理、流控制、事件多路分离、并发控制)紧密地集成在一起,复用的范围可以显著地大于使用传统函数库,甚或是通常的OO类库。
本论文被组织如下:1.2给出ACE工具包的结构和功能的综述;1.3详细描述ACE C++包装组件和较高级的ACE构架组件及模式;1.4检查若干使用ACE构建的网络应用的实现;还有1.5给出结束语
为阐释OO模式和构架是怎样被成功地应用于分布式软件的,本论文考查自适配通信环境(ACE)[6]。ACE是可以自由使用的OO工具包,其中包含有丰富的、可跨越广泛的OS平台执行通用网络编程任务的可复用包装、类属,以及构架。ACE提供的任务包括:
- 事件多路分离和事件处理器分派[13, 14, 10, 15];
- 连接建立和服务初始化[16, 17, 18];
- 进程间通信[19, 4]和共享内存管理;
- 分布式通信服务的动态配置[20, 21];
- 并发/并行和同步[22, 23, 11, 24];
- 更高级的分布式服务组件(比如名字服务、事件服务、日志服务、时间服务和令牌服务)。

图1-1 ACE自适配通信环境中的组件
ACE工具包的设计采用分层的体系结构。图1-1演示了ACE组件间的纵向和横向关系。ACE的较低层是封装现有的OS网络编程机制的OO包装(wrapper)。ACE的高层扩展这些包装,以提供OO构架和组件、覆盖更为广泛的面向应用的网络任务和服务。这一部分的余下部分给出对ACE中类属的结构和功能的综述(如图1-2所示)。1.3提供了对ACE的网络编程特性和组件的深入讨论。
贯穿本论文,ACE组件通过Booch表示法[5]来进行图解。实心矩形表示类属,它将一定数量的相关类合成进一个公共的名字空间。实心云表示对象;如嵌套则表示对象间的合成关系;而无方向的边表示在两个对象间存在某种类型的链接。虚线云表示类;有向边表示类之间的继承关系;而一端有小圆圈的无向边表示两个类之间的合成或是使用关系。在三角形内标记的“A”标识一个类为抽象类[25]。抽象类不能被直接实例化,而必须被子类化。在实例化抽象类子类的任何对象之前,该子类必须提供所有抽象方法的定义。
ACE的源码树含有超过85,000行C++代码。其中大约9,000行代码(也就是,大约为总数的10%)为OS适配层所特有。该层将ACE的较高层和与下列OS机制相关联的平台特有的依赖屏蔽开来:
- 多线程和同步
- 进程间通信
- 事件多路分离
- 显式动态链接
- 内存映射文件和共享内存

图1-2 ACE中的类属
在OS适配层之上是许多OO包装,它们封装并增强在像Win32和UNIX这样的现代操作系统上可用的并发、进程间通信(IPC)、以及虚拟内存机制(在图1-1底部演示)。应用可以通过有选择地继承、聚合(aggregating)、和/或实例化下列ACE包装类属来合并和编写这些组件:
- IPC SAP-它封装本地和/或远地的IPC服务访问点(IPC SAP)机制,比如socket、TLI、UNIX FIFO和STREAM管道,以及Win32命名管道[19, 4];
- 服务初始化-ACE提供一组连接器(Connector)和接受器(Acceptor)组件[18],分别使主动和被动的初始化角色与一旦初始化完成后通信服务执行的任务去耦合(decouple)。
- 并发机制-ACE抽象较低级的OS多线程和多进程机制(比如互斥体和信号量[22]),以创建较高级的OO并发抽象(比如主动对象Active Object[11]);
- 内存管理机制-ACE内存管理组件为管理共享内存和局部内存的动态分配和释放提供了灵活和可扩展的抽象;
- CORBA集成-ACE可与CORBA实现集成在一起(比如单线程和多线程Orbix)。
通过采用类型安全的OO接口封装OS通信、并发和虚拟内存机制,OO包装的使用提高了应用的健壮性。这也减少了应用直接访问用弱类型C接口编写的底层OS库的需求。因此,像C++和Java这样的OO语言的编译器可以在编译时、而非运行时检测类型系统违例。ACE的C++版本大量使用内联(inlining),以消除包装层提供额外的类型安全性和抽象所导致的性能下降。
ACE含有一个更高层的网络编程构架,集成并增强了较低层的OS包装。该构架支持由应用服务组成的并发网络看守的动态配置。ACE的构架部分包括以下类属:
- 反应堆(Reactor)-ACE反应堆[10]提供可扩展的、面向对象的多路分离器,它分派处理器、以响应多种类型的事件(例如,基于I/O的、基于定时器的、基于信号的,以及基于同步的事件);
- 服务配置器(Service configurator)-ACE服务配置器[21]支持这样的服务构造:其服务可在安装时和/或运行时动态配置;
- 流(Stream)-ACE流组件[6]简化了由一或多个层次相关的服务(比如协议栈)组成的并发通信应用的开发。
除了包装和构架,ACE还提供了一个网络服务组件的标准库。这些组件在ACE中扮演两种角色:
- 它们演示怎样利用ACE IPC包装、反应堆、服务配置器、服务初始化、并发、内存管理,以及流组件;
- 它们为常见的分布式系统任务提供了可复用组件,比如日志[13]、名字、锁定和时间同步[21];
当与OO语言特性(比如类、继承、动态绑定和参数化类型)和设计模式(比如抽象工厂(Abstract Factory)、构建器(Builder)和服务配置器)相结合时,可复用的ACE组件促进了通信服务和应用的开发,无需修改、重编译、重链接、甚至不用重启运行中的软件,它们就可以被更新和扩展[20]。
ACE提供了植根于IPC SAP(“进程间通信服务访问点”)基类的一个类属“森林”。标准的基于I/O句柄的OS本地和远地IPC机制提供了面向连接和无连接的协议,IPC SAP对这些机制进行了封装。如图1-3所示,该类属“森林”包括SOCK SAP(封装socket API)、TLI SAP(封装TLI API)、SPIPE SAP(封装UNIX SunOS 5.x STREAM管道API),以及FIFO SAP(封装UNIX命名管道API)。

图1-3 IPC SAP类属关系
每一类属都被组织成一个继承层次。所有子类都提供定义良好的、本地或远地通信机制的子集的接口。在一个层次里的子类共同包含了一种特定通信抽象的全部功能(比如Internet域或UNIX域协议族)。类的使用(相对于单独的函数)帮助简化了网络编程:
- 使应用与易错的细节相屏蔽:例如,图1-3中所示的ACE_Addr类层次通过类型安全的OO接口支持若干不同的网络寻址格式,而非直接使用麻烦而易错的基于C的struct sockaddr数据结构。
- 合并若干操作、以形成单一操作:例如,SOCK Acceptor的构造器执行创建被动模式服务器端点所需的多个socket系统调用(比如socket、bind和listen)。
- 将IPC机制参数化进应用:类构成了通过所需的IPC机制的类型来参数化应用的基础。如1.3.1.2所讨论的,这样有助于改善可移植性。
- 增强代码共享:基于继承的层次划分增加了在多种IPC机制间共享的通用代码的数量(比如像fcntl和ioctl这样的低级OS设备控制系统调用的OO接口)。
下面的部分讨论IPC SAP中的每个类属。

图1-4 SOCK SAP类属
SOCK SAP[4]类属为应用提供Internet域和UNIX域协议族[8]的面向对象的接口。通过继承或实例化图1-4所示的适当的SOCK SAP子类,应用可以访问底层的Internet域或UNIX域的socket类型的功能。ACE_SOCK *子类封装Internet域的功能,而ACE_LSOCK *子类封装UNIX域的功能。如图1-4所示,这些子类可进一步划分为(1)*Dgram组件(提供不可*、无连接、面向消息的功能) vs. *ACE_Stream组件(提供可*、面向连接的字节流的功能)和(2)ACE_*_Acceptor组件(提供通常用于服务器的连接建立功能) vs. *Stream组件(提供同时用于客户和服务器的双向字节流传输功能)。
使用OO包装来封装socket接口有助于(1)在编译时检测许多微妙的应用类型系统违例,(2)推动传输层接口的平台无关性,以改善应用的可移植性,以及(3)极大地减少应用代码的数量和花费在较低级网络编程细节上的开发工作。为演示后面一点,下面的例子程序实现了一个简单的客户应用,使用ACE_SOCK_Dgram_Bcast类来向局域网子网中所有在指定的端口号上侦听的服务器广播消息:
int main (int argc, char *argv[])
{
ACE_SOCK_Dgram_Bcast b_sap (sap_any);
char *msg;
unsigned short b_port;
msg = argc > 1 ? argv[1] : "hello world\n";
b_port = argc > 2 ? atoi (argv[2]) : 12345;
if (b_sap.send (msg, strlen (msg), b_port) == -1)
perror ("can’t send broadcast"), exit (1);
exit (0);
}
把这个简洁的例子与直接使用socket接口实现广播所需的成打的C源码相比较很有启发意义。
TLI SAP类属提供系统V传输层接口(Transport Layer Interface)的OO接口。TLI的TLI SAP继承层次几乎与socket的SCOK SAP包装相同。主要的区别是TLI和TLI SAP并不定义UNIX域协议族的接口。此外,目前TLI没有被移植到Win32平台。
通过结合C++特性(比如缺省参数值和模板)和tirdwr(read/write兼容性STREAM模块),开发可在编译时参数化、以在基于socket或基于TLI传输接口上正确操作的应用变得相对直截了当了。例如,下面的代码演示怎样将C++模板用于参数化应用所需的IPC机制。这些代码是从在1.4.1中描述的分布式日志工具中摘录出来的。在下面的代码中,一个派生自ACE_Event_Handler的子类被一种特定类型的传输接口及其相应的协议地址类参数化:
/* Logging_Handler header file */
template
class Logging_Handler : public ACE_Event_Handler
{
public:
Logging_Handler (void);
virtual ?Logging_Handler (void);
virtual int handle_input (ACE_HANDLE);
virtual ACE_HANDLE get_handle (void) const
{
return this->peer_stream_.get_handle ();
}
protected:
PEER_STREAM peer_stream_;
}
如下所示,取决于底层OS平台的特定属性(比如是基于BSD的SunOS 4.x还是基于系统V的SunOS 5.x),日志应用可以实例化Client Handler类、以使用SOCK SAP或是TLI SAP:
/* Logging application */
class Logging_Handler
#if defined (MT_SAFE_SOCKETS)
: public Logging_Handler
#else
: public Logging_Handler
#endif /* MT_SAFE_SOCKETS */
{
/* ... */
};
在开发必须具有跨平台可移植性的应用时,这种基于模板的方法所带来的更多的灵活性极其有用。特别地,因为SunOS 5.2的socket实现不是线程安全的,而SunOS 4.x的TLI实现含有许多严重缺陷,在SunOS的变种上进行跨平台开发时,必须具有根据传输接口来参数化应用的能力。
TLI SAP还使应用与许多TLI接口的特性相屏蔽。例如,在一个并发服务器中,在qlen > 1[3]的情况下,要正确处理t_listen和t_accept的非直观和易错的行为,需要编写一些微妙的应用级代码;而在TLI Acceptor类的accept方法中这些代码被封装起来。该方法接受来自客户的到来的连接请求。通过使用C++缺省参数值,基于TLI SAP和基于SOCK SAP的应用的调用accept方法的标准方法在语法上是相同的。
SPIPE SAP类属为高性能本地IPC提供OO包装。在Win32平台上,SPIPE SAP类属在命名管道之上实现。Win32命名管道机制主要用于在同一机器的进程间高效地传输数据。对于本地IPC,它通常比使用socket更为高效[27]
在UNIX平台上,SPIPE SAP类属通过已安装(mounted)的STREAM管道和connld[28]来实现。SunOS 5.x提供fattach系统调用,将管道句柄安装到UNIX文件系统中的指定位置。通过将connld流模块推入管道已安装的一端,可以创建服务器应用。当与服务器运行在同一主机上的客户应用随后打开与已安装的管道相关联的文件时,客户和服务器都将获得一个I/O句柄,标识一个唯一的、非多路服用和双向的通信信道。
SPIPE SAP继承层次是SOCK SAP及TLI SAP所用层次的“镜像“。它提供与SOCK SAP ACE_LSOCK *类(它们自己封装了UNIX域的socket)相近似的功能。但是,在SunOS 5.x平台上,SPIPE SAP比ACE_LSOCK*接口更为灵活,因为它使得STREAM模块可以分别被“推入”和“弹出”SPIPE SAP终点。SPIPE SAP还支持在同一主机上执行的进程和/或线程间的字节流和按优先级排序的面向消息的数据的双向传送[29]。
FIFO SAP类属封装UNIX命名管道机制(也称为FIFO)。不像STREAM管道,命名管道只提供从一或多个发送者到单一接收者的单向数据信道。而且,来自不同发送者的消息都被放入同一个通信信道。因而,在每一消息中必须明确地包含某种类型的多路分离标识符(demultiplexing identifier),以使接收者能够确定是哪一个发送者发送的消息。
SunOS 5.x中的基于STREAM的命名管道同时实现提供面向消息和面向字节流的数据传送机制。相反,某些平台(比如SunOS 4.x)只提供面向字节流的命名管道。因此,除非总是使用定长消息,在SunOS 4.x中通过命名管道发送的消息必须通过某种形式的字节计数、或是特别的结束符来加以区分,以使接收者能从通信信道字节流中提取消息。为减少这样的局限,ACE FIFO SAP实现包含有对SunOS 5.x的面向消息的机制进行模拟的逻辑。
除了封装像socket和TLI这样的基于句柄的I/O通信机制,ACE还提供了内存映射文件和系统V UNIX IPC机制的OO封装:
- 内存映射文件:ACE_Mem_Map类提供Win32和UNIX(比如mmap系统调用族)上可用的内存映射文件机制的OO接口。这些调用利用底层的OS虚拟内存机制[30]来将文件映射到进程的地址空间。映射文件的内容可直接通过指针访问。指针接口常常比通过标准read/write I/O系统调用间接地访问数据块要更为方便和高效。此外,内存映射文件的内容可以很方便地在两个或多个进程间共享。
现有的Win32和UNIX的内存映射文件接口的风格有一点巴洛克(baroque,过分雕饰)。例如,开发者必须手工完成许多“簿记”工作(比如显式地打开文件、确定它的长度、执行多种映射,等等)。相反,ACE_Mem_Map OO封装提供的接口采用了缺省值和多种有若干类型特征(type signature)变体的构造器(例如,“从已打开的文件句柄映射”、“从文件名映射”,等等),以简化常见的内存映射文件的使用模式。
例如,下面的程序使用ACE_Mem_Map OO封装来映射一个通过命令行指定的文件,并将它的各行反向打印出来:
static void putline (const char *s)
{
while (putchar (*s++) != ’\n’)
continue;
}
int main (int argc, char *argv[])
{
char *filename = argv[1];
char *file_p;
Mem_Map mmap (filename);
if (mmap (file_p) != -1)
{
size_t size = mmap.size () - 1;
if (file_p[size] == ’\0’)
file_p[size] = ’\n’;
while (--size >= 0)
if (file_p[size] == ’\n’)
putline (file_p + size + 1);
putline (file_p);
return 0;
}
else
return 1;
}
把这个OO包装接口的使用与直接使用像read这样的I/O系统调用所需的远为冗长的C接口相比较,很有启发意义。
- 系统V IPC机制:SunOS UNIX提供了一套共享内存、同步和消息传递机制,即俗话所说的“系统V IPC”[29]。这些机制所提供的大多数功能已包含在较新的SunOS UNIX机制中(比如,对应地,mmap、线程同步[31]、和STREAM管道原语)。但是,特定类型的应用(比如数据库引擎)可能会从系统V IPC机制中获益(比如消息队列的对等天性、高效的信号量多操作原子语义,以及系统V IPC机制在一系列UNIX OS 平台上的广泛可用性)。然而,要正确地理解和使用系统V IPC机制(特别是信号量)有一点挑战性,因为这些接口非常通用,而且直到最近,有关它们的行为特性的文档都非常地少。
ACE系统V IPC包装接口将开发者与无数多余的细节屏蔽开来。例如,对于利用标准wait和signal信号量的应用来说,ACE OO包装版本的系统V IPC信号量使用起来更为直观和简单;如下面的来自典型的生产者/消费者例子的代码片段所示:
typedef ACE_SV_Semaphore_Simple SEMA;
SEMA prod (1, SEMA::CREATE, 1);
SEMA cons (2, SEMA::CREATE, 0);
void producer (void)
{
for (;;)
{
prod.wait ();
// produce resource...
cons.signal ();
}
}
void consumer (void)
{
for (;;)
{
cons.wait ();
// consume resource...
prod.signal ();
}
}
把这个简洁的OO包装接口与直接使用系统V信号量所需的远为冗长的C接口相比较,很有启发意义。
