
2.8 远程方法调用(RMI)
基于对象的技术已经展示了它在开发非分布式应用程序方面的价值。对象最重要的特征之一是,通过定义良好的接口向外界隐藏其内部结构。这种机制保证了只要保持对象接口不变,就可以方便地替换或者修改对象。
当RPC机制逐渐成为分布式系统通信处理的事实标准时,人们开始认识到RPC的原理同样可以应用于对象。在本节中,把RPC的思想拓展到对远程对象的调用上,并且说明与RPC相比,这种方法是如何增强分布透明性的。本节集中讨论远程对象调用。在第4章中,将详细讨论包括CORBA、DCOM和J2EE在内的多种基于对象的分布式系统。
2.8.1 分布式对象
对象的关键特性是对数据的封装,这些被封装的数据称为状态(State),对这些数据的操作称为方法(Method)。只有通过接口(Interface)才能使用方法。理解以下内容很重要:一个进程除了可以通过对象的接口来引用它可用的方法外,不存在其他合法途径来访问或者操纵对象的状态。一个对象可以实现多个接口。同样,在给定一个接口定义的情况下,也可以由若干个对象提供该接口的实现。
将接口与实现这些接口的对象分离开来,对于分布式系统是至关紧要的。由于进行了严格的分离,因此可以将接口放在一台机器上,而让对象本身驻留在另一台机器上。这种组织结构如图2-46所示,通常称为分布式对象(Distributed Object)。

图2-46 远程对象调用的一般组织结构
当一个客户绑定(Bind)到一个分布式对象的时候,该对象接口的一种实现——称为代理(Proxy)被加载到客户机的地址空间中。代理与RPC系统中的客户机存根相类似。代理惟一的工作是将对方法的调用编组成消息,并且对应答消息进行解编,将调用的方法得到的结果返回给客户机。实际对象驻留在服务器所在机器上,它向服务器提供的接口与向客户机提供的接口相同。输入的调用请求首先传递给服务器存根—这种存根也称为骨架(skeleton),它将调用请求解编成对服务器上对象接口的恰当的方法调用。服务器存根同时还负责对应答消息进行编组,并且将应答消息发送回客户端代理。
多数分布式对象所特有的但同时又是直观难以想象的特性是:它们的状态并不是分布的;它驻留在单个机器上,只有由该对象实现的接口可以在其他机器上使用。这种对象也称为远程对象(Remote Objects)。对此将会在后续章节中介绍。在普通的分布式对象中,状态本身可能是物理上分布在多台机器上的,但是这种分布对于客户端来说是隐藏在对象接口后面的。
1.编译时对象与运行时对象
分布式系统中的对象可能以多种形式出现。最常见的一种对象形式直接与语言级对象(如Java、C++以及其他面向对象语言直接支持的对象)相关联,这种对象称为编译时对象。在这种情况下,对象定义为类的实例。类(Class)是对一种抽象类型的描述,是含有数据元素以及对这些数据元素的操作的模块。
在分布式系统中,使用编译时对象常常使得构建分布式应用程序更加方便。比如,在Java中,可以通过定义类及该类实现的接口来完整地定义一个对象。对该类定义进行编译,得到可以实例化Java对象的代码。对接口进行编译可以得到客户机存根以及服务器存根,用来从远程机器上引用Java对象。Java开发人员在大多数情况下都看不到对象的分布,所看到的只有Java程序的代码。
编译时对象的一个显而易见的缺陷是对特定编程语言的依赖性。因此,另一种构建分布式对象的方法是,在运行时显式生成对象。由于这种方案独立于编写分布式应用程序时所使用的编程语言,许多基于对象的分布式系统都使用它。应特别指出的是,可以使用由多种不同语言编写的对象来构建应用程序。
在使用运行对象的时候,实现实际对象的方法是多种多样的。比如,开发人员可以选择用C语言编写一个库,库中包含许多函数,这些函数可以处理常规的数据文件。根本问题是,如何让这样的实现表现为一个对象,以便从远程机器调用其方法。一种常用的方案是使用一种对象适配器(Object Adapter),它可以作为实现的一种包装,惟一的作用就是让该实现呈现出对象的外部特征。适配器(Adapter)这个术语来自于一种设计模式,这种设计模式用来将接口转换成客户机期望的某种东西。例如,动态绑定到上面所说的那个C库,随后打开代表对象当前状态的对应数据文件的适配器就是对象适配器。
对象适配器在基于对象的分布式系统中扮演了一个重要角色。为了使包装尽可能简单,对象根据它们实现的接口来惟一定义。接口的实现可以在适配器中注册,随后适配器可以使该接口可供(远程)调用。适配器会注意到有调用请求发出,随后向客户机提供远程对象的映像。
2.持久对象和暂时对象
除了可以将对象分为语言级对象和运行时对象外,还可以将对象分为持久对象(Persistent Object)和暂时对象(Transient Object)。持久对象无论当前是否位于服务器进程的地址空间内,都始终保持存在。换句话说,持久对象并不依赖于当前的服务器。这意味着当前管理持久对象的服务器在退出运行之前,可以先把持久对象的状态存储到辅助存储器上;之后,新启动的服务器可以由存储器将对象的状态读到自己的地址空间中,对调用请求进行处理。与持久对象相反,暂时对象只在管理该对象的服务器存在的期间存在,服务器退出运行后,该对象也就不再存在。在是否应该使用持久对象的问题上存在争论:有些人认为使用暂时对象就足够了。
2.8.2 将客户机绑定到对象
传统RPC系统与支持分布式对象的系统有一个很有趣的不同点,后者一般都提供系统范围内的对象引用。这种对象引用(比如作为方法调用的参数)可以在位于不同机器上的进程之间自由传递。通过将对象引用的实际实现隐藏起来,使其变得不透明,甚至变成引用对象的惟一途径,与传统RPC系统相比,分布透明性增强了。
如果进程获得了某个对象的引用,那么在调用该对象的任何方法之前必须首先绑定到该对象。绑定将产生一个位于该进程地址空间内的代理,它实现了包含有此进程所能调用的方法的接口。在多数情况下,绑定是自动进行的。如果给底层系统一个对象引用,它就需要通过某种途径来定位管理该实际对象的服务器,随后将代理放置在客户机地址空间中。
通过隐式绑定(Implicit Binding),可向客户机提供一种简单机制,该机制允许客户机在只使用对象引用的情况下直接进行方法调用。比如,C++允许重载一元成员选择操作符(—>),这样就可以引入对象的引用,就好像它们是通常的指针一样,如图2-47(a)所示。通过隐式绑定,可以在引用被连接到实际对象上时将客户机透明地绑定到对象上。与之相反,如果使用显式绑定(Explicit Binding),客户机必须首先调用某个特殊函数来绑定到对象上,随后才能调用该对象的方法。显式绑定一般返回指向代理的指针,该代理可以在本地使用,如图2-47(b)所示。

图2-47 隐式绑定和显式绑定的例子
很明显,对象引用必须包含足够的信息,以便使客户机可以绑定到服务器上。一个简单的对象引用包含实际对象驻留的机器的网络地址,以及端点(用来标志管理该对象的服务器),还有对象的标号。对象的标号一般由服务器提供,比如可以用16 位数来表示。这里的端点对应于由服务器的本地操作系统动态分配的某个本地端口。然而,这种对象引用存在诸多缺陷。
首先,如果服务器所在机器崩溃,而在机器恢复后又给服务器分配一个与原先不同的端点,所有原来的对象引用就都变得无效了。这个问题可以通过如下办法解决:在每台机器上驻留一个本地守护程序,监听某个公开的端点,并且通过端点表来追踪服务器与端点的对应关系。在将客户机绑定到对象上之前,首先要请求守护程序给出服务器当前所用的端点。如果使用这种方案,就需要将服务器ID编码到对象引用中,该对象引用可作为端点表中的索引,还要求服务器通过本地守护程序进行注册。
然而,将服务器所在机器的网络地址编码到对象引用中并不是一个好主意。这种方法存在的问题是,在不破坏服务器所管理的所有对象引用的前提下,无法将服务器移到另一台机器上。一个容易想到的解决方法是,对维护端点表的守护程序的思路进行拓展,使用定位服务器(Location Server)来追踪管理对象的服务器当前在哪一台机器上运行。在对象引用中不仅包含定位服务器的网络地址,而且还含有服务器的系统级标志符。这种解决方法同样也存在很多严重缺陷,特别是在侧重考虑可扩展性的情况下更是如此。
前面已做了这样一个默认的假定:客户机和服务器都已经配置为使用相同的协议栈。这不仅意味着它们使用相同的传输协议(如TCP),而且意味着它们必须使用相同的协议进行参数编组和解除编组,而且还必须使用相同的协议来建立初始连接,以相同方式进行错误处理和流控制,等等。
如果能够从对象引用中得到更多的信息,就能够放心地丢掉这个假定。所需要的信息包括用来绑定到一个对象的协议标志,以及那些由负责对象管理的服务器支持标志。比如,某个服务器可能支持通过TCP连接和UDP数据报同时传入数据,此时客户机就必须负责为对象引用中标出的至少一种协议提供代理实现。
对以上方案做进一步的改进,在对象引用中加入实现句柄(Implementation Handle),它指向代理的完整实现,客户机可以在绑定到对象时动态加载这些代理。比如,实现句柄可以采用URL的形式,指向档案文件,例如ftp://ftp.clientware.org/proxies/java/proxy-vl.la.zip。绑定协议随后,只需要规定这种文件必须动态下载、解包、安装且实例化。这种方案的优点是,客户端不需要关心它是否拥有针对于某种特定协议的实现。另外,它还赋予对象开发者自主设计针对特定对象的代理的自由。然而,必须采取专门的安全措施来确保客户机能够信任下载的代码。
2.8.3 静态远程方法调用与动态远程方法调用
在将客户机绑定到对象之后,就可以通过代理来调用对象的方法。这种远程方法调用(Remote Method Invocation)简称为RMI,它在参数编组及传递方面十分接近于RPC。RMI与RPC的本质上不同是,RMI一般支持系统级对象引用,就像前面所介绍的那样。另外,RMI不需要使用通用的客户机和服务器存根,而可以更方便地使用针对特定对象的存根。
提供RMl支持的常规方法是,利用接口定义语言来指定对象的接口,与RPC中采用的方法相似。另外,也可以使用诸如Java之类的基于对象的语言来自动生成存根。使用预先确定的接口定义的方法一般称为静态调用(Static Invocation)。静态调用要求在开发客户应用程序时就已知对象接口,这也意味着如果接口发生变化,就必须重新编译客户应用程序,然后才能使用新的接口。
另一种方法是,采用更为动态的方式进行方法调用。在实践中,有时在运行时建立方法调用会更为方便,这种做法称为动态调用(Dynamic Invocation),它与静态调用的本质区别是,使用动态调用的应用程序直到运行时才对需要在远程对象上调用的方法做出选择。动态调用的常用形式如下所示:
invoke(object,method,input_parameters,output_parameters);
其中,参数object代表分布式对象,参数method指定要调用的方法,input_parameters是存储方法需要的输入参数值的数据结构,而output_parameters是存储输出值的数据结构。
举个例子,考虑在文件对象fobject的末尾添加整型数int,该对象实现了方法append。在这种情况下,静态调用的形式是:
fobject.append(int)
相应的动态调用的形式可能会是:
invoke(fobject,id(append),int)
其中,操作id(append)返回方法append的标志符。
为了进一步说明动态调用的用处,下面研究对象浏览器,它可以用来查看一组对象。假定该浏览器支持远程对象调用。这种浏览器能够绑定到分布式对象上,而且可将分布式对象的接口提供给用户,随后要求用户选择一个方法,并且提供该方法的输入参数值,然后浏览器就可以执行实际调用了。典型地,开发这种对象浏览器时应该考虑到为任何可能的接口提供支持,这就要求在运行时检查接口并动态建立方法调用。
另一个应用动态调用的例子是批处理服务。这种服务可以处理指定了调用执行时间的调用请求。该服务可以通过调用请求队列的形式实现,在队列中根据请求中指定的执行调用时间的先后来对调用请求进行排序。该服务中的主循环的结构是:简单地等待,直到下一次调用的时刻来临,在调用前将该调用请求从队列中移出,然后调用上面代码中的invoke。
2.8.4 参数传递
由于多数RMI系统都支持系统级对象引用,因此对方法调用中的参数传递的要求不像RPC中那么严格。然而,也有一些细微的东西会使得RMI变得比预想的复杂,将在下面对这些问题进行简要的讨论。
首先考虑以下这种状况:只有分布式对象存在,换句话说也就是系统中的所有对象都可以通过远程机器访问。在这种情况下,可以一成不变地使用对象引用作为方法调用的参数。引用通过值来进行传递,从一台机器复制到另一台机器上。当把作为方法调用的结果的对象引用赋予进程时,该进程就可以在必要的时候简单地绑定到引用的对象上。
不幸的是,只使用分布式对象可能造成效率降低,特别是在对象规模比较小的情况下,比如当对象是整型数或者布尔值的时候。如果客户机引用的是位于另一服务器上的对象,那么它执行的每一次调用所生成的请求都要跨越不同地址空间,甚至要跨越不同的机器。因此,指向远程对象的引用和指向本地对象的引用要区别对待。
在使用对象引用作为参数来进行方法调用时,只有在该引用指向远程对象的情况下,才将对象引用作为值参数来复制和传递。在这种情况下,对象是完全通过引用来传递的。然而,如果引用指向的是本地对象,也就是位于与客户机相同的地址空间中的对象,那么被引用对象将完整地进行复制,并且与调用一起传递。也就是说,此时对象是通过值传递的。
注意:正在处理的引用指向的是本地对象还是远程对象,可以是高度透明的,比如在Java中就是这样。在Java中,二者间可见的惟一不同点只是:本地对象的数据类型与远程对象在本质上是不同的,而在其他方面,这两种引用几乎一样。但是,如果使用的是C语言这样的常规编程语言,指向本地对象的引用可以是简单的指针,而这种简单指针是不可能用来指向远程对象的。
将对象引用作为参数来进行方法调用的做法也会带来副作用,即可能要复制整个对象。因为无法对这种情况进行隐藏,所以被迫显式地区分本地对象和分布式对象。很明显,这种区分不但损害了分布透明性,而且导致编写分布式应用程序变得更加困难。