远程方法调用入门指南
Copyright © 2005 Stephen Suen. All rights reserved.
Java 远程方法调用(Remote Method Invocation, RMI)使得运行在一个 Java 虚拟机(Java Virtual Machine, JVM)的对象可以调用运行另一个 JVM 之上的其他对象的方法,从而提供了程序间进行远程通讯的途径。RMI 是 J2EE 的很多分布式技术的基础,比如 RMI-IIOP 乃至 EJB。本文是 RMI 的一个入门指南,目的在于帮助读者快速建立对 Java RMI 的一个感性认识,以便进行更深层次的学习。事实上,如果你了解 RMI 的目的在于更好的理解和学习 EJB,那么本文就再合适不过了。通过本文所了解的 RMI 的知识和技巧,应该足够服务于这个目的了。
我们知道远程过程调用(Remote Procedure Call, RPC)可以用于一个进程调用另一个进程(很可能在另一个远程主机上)中的过程,从而提供了过程的分布能力。Java 的 RMI 则在 RPC 的基础上向前又迈进了一步,即提供分布式 对象间的通讯,允许我们获得在远程进程中的对象(称为远程对象)的引用(称为远程引用),进而通过引用调用远程对象的方法,就好像该对象是与你的客户端代码同样运行在本地进程中一样。RMI 使用了术语"方法"(Method)强调了这种进步,即在分布式基础上,充分支持面向对象的特性。
RMI 并不是 Java 中支持远程方法调用的唯一选择。在 RMI 基础上发展而来的 RMI-IIOP(Java Remote Method Invocation over the Internet Inter-ORB Protocol),不但继承了 RMI 的大部分优点,并且可以兼容于 CORBA。J2EE 和 EJB 都要求使用 RMI-IIOP 而不是 RMI。尽管如此,理解 RMI 将大大有助于 RMI-IIOP 的理解。所以,即便你的兴趣在 RMI-IIOP 或者 EJB,相信本文也会对你很有帮助。另外,如果你现在就对 API 感兴趣,那么可以告诉你,RMI 使用 java.rmi 包,而 RMI-IIOP 则既使用 java.rmi 也使用扩展的 javax.rmi 包。
本文的随后内容将仅针对 Java RMI。
在学习 RMI 之前,我们需要了解一些基础知识。首先需要了解所谓的分布式对象(Distributed Object)。分布式对象是指一个对象可以被远程系统所调用。对于 Java 而言,即对象不仅可以被同一虚拟机中的其他客户程序(Client)调用,也可以被运行于其他虚拟机中的客户程序调用,甚至可以通过网络被其他远程主机之上的客户程序调用。
下面的图示说明了客户程序是如何调用分布式对象的:
从图上我们可以看到,分布式对象被调用的过程是这样的:
-
客户程序调用一个被称为 Stub (有时译作存根,为了不产生歧义,本文将使用其英文形式)的客户端代理对象。该代理对象负责对客户端隐藏网络通讯的细节。Stub 知道如何通过网络套接字(Socket)发送调用,包括如何将调用参数转换为适当的形式以便传输等。
-
Stub 通过网络将调用传递到服务器端,也就是分布对象一端的一个被称为 Skeleton 的代理对象。同样,该代理对象负责对分布式对象隐藏网络通讯的细节。Skeleton 知道如何从网络套接字(Socket)中接受调用,包括如何将调用参数从网络传输形式转换为 Java 形式等。
-
Skeleton 将调用传递给分布式对象。分布式对象执行相应的调用,之后将返回值传递给 Skeleton,进而传递到 Stub,最终返回给客户程序。
这个场景基于一个基本的法则,即行为的定义和行为的具体实现相分离。如图所示,客户端代理对象 Stub 和分布式对象都实现了相同的接口,该接口称为远程接口(Remote Interface)。正是该接口定义了行为,而分布式对象本身则提供具体的实现。对于 Java RMI 而言,我们用接口(interface)定义行为,用类(class)定义实现。
RMI 的底层架构由三层构成:
-
首先是 Stub/Skeleton 层。该层提供了客户程序和服务程序彼此交互的接口。
-
然后是远程引用(Remote Reference)层。这一层相当于在其之上的 Stub/Skeleton 层和在其之下的传输协议层之前的中间件,负责处理远程对象引用的创建和管理。
-
最后是传输协议(Transport Protocol) 层。该层提供了数据协议,用以通过线路传输客户程序和远程对象间的请求和应答。
这些层之间的交互可以参照下面的示意图:
和其它分布式对象机制一样,Java RMI 的客户程序使用客户端的 Stub 向远程对象请求方法调用;服务器对象则通过服务器端的 Skeleton 接受请求。我们深入进去,来看看其中的一些细节。
注意: 事实上,在 Java 1.2 之后,RMI 不再需要 Skeleton 对象,而是通过 Java 的反射机制(Reflection)来完成对服务器端的远程对象的调用。为了便于说明问题,本文以下内容仍然基于 Skeleton 来讲解。
当客户程序调用 Stub 时,Stub 负责将方法的参数转换为序列化(Serialized)形式,我们使用一个特殊的术语,即编列(Marshal)来指代这个过程。编列的目的是将这些参数转换为可移植的形式,从而可以通过网络传输到远程的服务对象一端。不幸的是,这个过程没有想象中那么简单。这里我们首先要理解一个经典的问题,即方法调用时,参数究竟是传值还是传引用呢?对于 Java RMI 来说,存在四种情况,我们将分别加以说明。
-
对于基本的原始类型(整型,字符型等等),将被自动的序列化,以传值的方式编列。
-
对于 Java 的对象,如果该对象是可序列化的(实现了 java.io.Serializable 接口),则通过 Java 序列化机制自动地加以序列化,以传值的方式编列。对象之中包含的原始类型以及所有被该对象引用,且没有声明为 transient 的对象也将自动的序列化。当然,这些被引用的对象也必须是可序列化的。
-
绝大多数内建的 Java 对象都是可序列化的。 对于不可序列化的 Java 对象(java.io.File 最典型),或者对象中包含对不可序列化,且没有声明为 transient 的其它对象的引用。则编列过程将向客户程序抛出异常,而宣告失败。
-
客户程序可以调用远程对象,没有理由禁止调用参数本身也是远程对象(实现了 java.rmi.Remote 接口的类的实例)。此时,RMI 采用一种模拟的传引用方式(当然不是传统意义的传引用,因为本地对内存的引用到了远程变得毫无意义),而不是将参数直接编列复制到远程。这种情况下,交互的双方发生的戏剧性变化值得我们注意。参数是远程对象,意味着该参数对象可以远程调用。当客户程序指定远程对象作为参数调用服务器端远程对象的方法时,RMI 的运行时机制将向服务器端的远程对象发送作为参数的远程对象的一个 Stub 对象。这样服务器端的远程对象就可以回调(Callback)这个 Stub 对象的方法,进而调用在客户端的远程对象的对应方法。通过这种方法,服务器端的远程对象就可以修改作为参数的客户端远程对象的内部状态,这正是传统意义的传引用所具备的特性。是不是有点晕?这里的关键是要明白,在分布式环境中,所谓服务器和客户端都是相对的。被请求的一方就是服务器,而发出请求的一方就是客户端。
在调用参数的编列过程成功后,客户端的远程引用层从 Stub 那里获得了编列后的参数以及对服务器端远程对象的远程引用(参见 java.rmi.server.RemoteRef API)。该层负责将客户程序的请求依据底层的 RMI 数据传输协议转换为传输层请求。在 RMI 中,有多种的可能的传输机制,比如点对点(Point-to-Point)以及广播(Multicast)等。不过,在当前的 JMI 版本中只支持点对点协议,即远程引用层将生成唯一的传输层请求,发往指定的唯一远程对象(参见 java.rmi.server.UnicastRemoteObject API)。
在服务器端,服务器端的远程引用层接收传输层请求,并将其转换为对远程对象的服务器端代理对象 Skeleton 的调用。Skeleton 对象负责将请求转换为对实际的远程对象的方法调用。这是通过与编列过程相对的反编列(Unmarshal)过程实现的。所有序列化的参数被转换为 Java 形式,其中作为参数的远程对象(实际上发送的是远程引用)被转换为服务器端本地的 Stub 对象。
如果方法调用有返回值或者抛出异常,则 Skeleton 负责编列返回值或者异常,通过服务器端的远程引用层,经传输层传递给客户端;相应地,客户端的远程引用层和 Stub 负责反编列并最终将结果返回给客户程序。
整个过程中,可能最让人迷惑的是远程引用层。这里只要明白,本地的 Stub 对象是如何产生的,就不难理解远程引用的意义所在了。远程引用中包含了其所指向的远程对象的信息,该远程引用将用于构造作为本地代理对象的 Stub 对象。构造后,Stub 对象内部将维护该远程引用。真正在网络上传输的实际上就是这个远程引用,而不是 Stub 对象。
在 RMI 的基本架构之上,RMI 提供服务与分布式应用程序的一些对象服务,包括对象的命名/注册(Naming/Registry)服务,远程对象激活(Activation)服务以及分布式垃圾收集(Distributed Garbage Collection, DGC)。作为入门指南,本文将指介绍其中的命名/注册服务,因为它是实战 RMI 所必备的。其它内容请读者自行参考其它更加深入的资料。
在前一节中,如果你喜欢刨根问底,可能已经注意到,客户端要调用远程对象,是通过其代理对象 Stub 完成的,那么 Stub 最早是从哪里得来的呢?RMI 的命名/注册服务正是解决这一问题的。当服务器端想向客户端提供基于 RMI 的服务时,它需要将一个或多个远程对象注册到本地的 RMI 注册表中(参见java.rmi.registry.Registry API)。每个对象在注册时都被指定一个将来用于客户程序引用该对象的名称。客户程序通过命名服务(参见 java.rmi.Naming API),指定类似 URL 的对象名称就可以获得指向远程对象的远程引用。在 Naming 中的 lookup() 方法找到远程对象所在的主机后,它将检索该主机上的 RMI 注册表,并请求所需的远程对象。如果注册表发现被请求的远程对象,它将生成一个对该远程对象的远程引用,并将其返回给客户端,客户端则基于远程引用生成相应的 Stub 对象,并将引用传递给调用者。之后,双方就可以按照我们前面讲过的方式进行交互了。
注意: RMI 命名服务提供的 Naming 类并不是你的唯一选择。RMI 的注册表可以与其他命名服务绑定,比如 JNDI,这样你就可以通过 JNDI 来访问 RMI 的注册表了。
理论离不开实践,理解 RMI 的最好办法就是通过例子。开发 RMI 的分布式对象的大体过程包括如下几步:
-
定义远程接口。这一步是通过扩展 java.rmi.Remote 接口,并定义所需的业务方法实现的。
-
定义远程接口的实现类。即实现上一步所定义的接口,给出业务方法的具体实现逻辑。
-
编译远程接口和实现类,并通过 RMI 编译器 rmic 基于实现类生成所需的 Stub 和 Skeleton 类。
RMI 中各个组件之间的关系如下面这个示意图所示:
回忆我们上一节所讲的,Stub 和 Skeleton 负责代理客户和服务器之间的通讯。但我们并不需要自己生成它们,相反,RMI 的编译器 rmic 可以帮我们基于远程接口和实现类生成这些类。当客户端对象通过命名服务向服务器端的 RMI 注册表请求远程对象时,RMI 将自动构造对应远程对象的 Skeleton 实例对象,并通过 Skeleton 对象将远程引用返回给客户端。在客户端,该远程引用将用于构造 Stub 类的实例对象。之后,Stub 对象和 Skeleton 对象就可以代理客户对象和远程对象之间的交互了。
我们的例子展现了一个简单的应用场景。服务器端部署了一个计算引擎,负责接受来自客户端的计算任务,在服务器端执行计算任务,并将结果返回给客户端。客户端将发送并调用计算引擎的计算任务实际上是计算指定精度的 π 值。
定义远程接口与非分布式应用中定义接口的方法没有太多的区别。只要遵守下面两个要求:
注意: 在 Java 1.2 之前,上面关于抛出异常的要求更严格,即必须抛出 java.rmi.RemoteExcption,不允许类似 java.io.IOException 这样的超类。现在之所以放宽了这一要求,是希望可以使定义既可以用于远程对象,也可以用于本地对象的接口变得容易一些(想想 EJB 中的本地接口和远程接口)。当然,这并没有使问题好多少,你还是必须声明异常。不过,一种观点认为这不是问题,强制声明异常可以使开发人员保持清醒的头脑,因为远程对象和本地对象在调用时传参的语意是不同的。本地对象是传引用,而远程对象主要是传值,这意味对参数内部状态的修改产生的结果是不同的。
对于第一个要求,java.rmi.Remote 接口实际上没有任何方法,而只是用作标记接口。RMI 的运行环境依赖该接口判断对象是否是远程对象。第二个要求则是因为分布式应用可能发生任何问题,比如网络问题等等。
例 1
列出了我们的远程接口定义。该接口只有一个方法:executeTask() 用以执行指定的计算任务,并返回相应的结果。注意,我们用后缀 Remote 表明接口是远程接口。
例 1. ComputeEngineRemote 远程接口
package rmitutorial;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface ComputeEngineRemote extends Remote {
public Object executeTask(Task task) throws RemoteException;
}
例 2
列出了计算任务接口的定义。该接口也只有一个方法:execute() 用以执行实际的计算逻辑,并返回结果。注意,该接口不是远程接口,所以没有扩展 java.rmi.Remote 接口;其方法也不必抛出 java.rmi.RemoteException 异常。但是,因为它将用作远程方法的参数,所以扩展了 java.io.Serializable 接口。
例 2. Task 接口
package rmitutorial;
import java.io.Serializable;
public interface Task extends Serializable {
Object execute();
}
接下来,我们将实现前面定义的远程接口。例 3给出了实现的源代码。
例 3. ComputeEngine 实现
package rmitutorial;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class ComputeEngine extends UnicastRemoteObject
implements ComputeEngineRemote {
public ComputeEngine() throws RemoteException {
super();
}
public Object executeTask(Task task) throws RemoteException {
return task.execute();
}
}
类 ComputeEngine 实现了之前定义的远程接口,同时继承自 java.rmi.server.UnicastRemoteObject 超类。UnicastRemoteObject 类是一个便捷类,它实现了我们前面所讲的基于 TCP/IP 的点对点通讯机制。远程对象都必须从该类扩展(除非你想自己实现几乎所有 UnicastRemoteObject 的方法)。在我们的实现类的构造函数中,调用了超类的构造函数(当然,即使你不显式的调用这个构建函数,它也一样会被调用。这里这样做,只是为了突出强调这种调用而已)。该构造函数的最重要的意义就是调用 UnicastRemoteObject 类的 exportObject() 方法。导出(Export)对象是指使远程对象准备就绪,可以接受进来的调用的过程。而这个过程的最重要内容就是建立服务器套接字,监听特定的端口,等待客户端的调用请求。
为了让客户程序可以找到我们的远程对象,就需要将我们的远程对象注册到 RMI 的注册表。这个过程有时被称为"引导"过程(Bootstrap)。我们将为此编写一个独立的引导程序负责创建和注册远程对象。例 4 给出了引导程序的源代码。
例 4. 引导程序
package rmitutorial;
import java.rmi.Naming;
import java.rmi.RMISecurityManager;
public class Bootstrap {
public static void main(String[] args) throws Exception {
String name = "ComputeEngine";
ComputeEngine engine = new ComputeEngine();
System.out.println("ComputerEngine exported");
Naming.rebind(name, engine);
System.out.println("ComputeEngine bound");
}
}
可以看到,我们首先创建了一个远程对象(同时导出了该对象),之后将该对象绑定到 RMI 注册表中。Naming 的 rebind() 方法接受一个 URL 形式的名字作绑定之用。其完整格式如下:
protocol://host:port/object
其中,协议(Protocol)默认为 rmi;主机名默认为 localhost;端口默认为 1099。注意,JDK 中提供的默认 Naming 实现只支持 rmi 协议。在我们的引导程序里面只给出了对象绑定的名字,而其它部分均使用缺省值。
例 5
给出了我们的客户端程序。该程序接受两个参数,分别是远程对象所在的主机地址和希望获得的 π 值的精度。
例 5. Client.java
package rmitutorial;
import java.math.BigDecimal;
import java.rmi.Naming;
public class Client {
public static void main(String args[]) throws Exception {
String name = "rmi://" + args[0] + "/ComputeEngine";
ComputeEngineRemote engineRemote =
(ComputeEngineRemote)Naming.lookup(name);
Pi task = new Pi(Integer.parseInt(args[1]));
BigDecimal pi = (BigDecimal)(engineRemote.executeTask(task));
System.out.println(pi);
}
}
例 6. Pi.java
package rmitutorial;
import java.math.*;
public class Pi implements Task {
private static final BigDecimal ZERO =
BigDecimal.valueOf(0);
private static final BigDecimal ONE =
BigDecimal.valueOf(1);
private static final BigDecimal FOUR =
BigDecimal.valueOf(4);
private static final int roundingMode =
BigDecimal.ROUND_HALF_EVEN;
private int digits;
public Pi(int digits) {
this.digits = digits;
}
public Object execute() {
return computePi(digits);
}
public static BigDecimal computePi(int digits) {
int scale = digits + 5;
BigDecimal arctan1_5 = arctan(5, scale);
BigDecimal arctan1_239 = arctan(239, scale);
BigDecimal pi = arctan1_5.multiply(FOUR).subtract(
arctan1_239).multiply(FOUR);
return pi.setScale(digits,
BigDecimal.ROUND_HALF_UP);
}
public static BigDecimal arctan(int inverseX,
int scale) {
BigDecimal result, numer, term;
BigDecimal invX = BigDecimal.valueOf(inverseX);
BigDecimal invX2 =
BigDecimal.valueOf(inverseX * inverseX);
numer = ONE.divide(invX, scale, roundingMode);
result = numer;
int i = 1;
do {
numer =
numer.divide(invX2, scale, roundingMode);
int denom = 2 * i + 1;
term =
numer.divide(BigDecimal.valueOf(denom),
scale, roundingMode);
if ((i % 2) != 0) {
result = result.subtract(term);
} else {
result = result.add(term);
}
i++;
} while (term.compareTo(ZERO) != 0);
return result;
}
}
编译我们的示例程序和编译其它非分布式的应用没什么区别。只是编译之后,需要使用 RMI 编译器,即 rmic 生成所需 Stub 和 Skeleton 实现。使用 rmic 的方式是将我们的远程对象的实现类(不是远程接口)的全类名作为参数来运行 rmic 命令。参考下面的示例:
E:\classes\rmic rmitutorial.ComputeEngine
编译之后将生成 rmitutorial.ComputeEngine_Skel 和 rmitutorial.ComputeEngine_Stub 两个类。
远程对象的引用通常是通过 RMI 的注册表服务以及 java.rmi.Naming 接口获得的。远程对象需要导出(注册)相应的远程引用到注册表服务,之后注册表服务就可以监听并服务于客户端对远程对象引用的请求。标准的 Sun Java SDK 提供了一个简单的 RMI 注册表服务程序,即 rmiregistry 用于监听特定的端口,等待远程对象的注册,以及客户端对这些远程对象引用的检索请求。
在运行我们的示例程序之前,首先要启动 RMI 的注册表服务。这个过程很简单,只要直接运行 rmiregistry 命令即可。缺省的情况下,该服务将监听 1099 端口。如果需要指定其它的监听端口,可以在命令行指定希望监听的端口(如果你指定了其它端口,需要修改示例程序以适应环境)。如果希望该程序在后台运行,在 Unix 上可以以如下方式运行(当然,可以缺省端口参数):
$ rmiregistry 1099 &
在 Windows 操作系统中可以这样运行:
C:\> start rmiregistry 1099
我们的 rmitutorial.Bootstrap 类将用于启动远程对象,并将其绑定在 RMI 注册表中。运行该类后,远程对象也将进入监听状态,等待来自客户端的方法调用请求。
$ java rmitutorial.Bootstrap
ComputeEngine exported
ComputeEngine bound
启动远程对象后,打开另一个命令行窗口,运行客户端。命令行的第一个参数为 RMI 注册表的地址,第二个参数为期望的 π 值精度。参考下面的示例:
$ java rmitutorial.Client localhost 50
3.14159265358979323846264338327950288419716939937511
在演示示例程序时,我们实际上是在同一主机上运行的服务器和客户端,并且无论是服务器和客户端所需的类都在相同的类路径上,可以同时被服务器和客户端所访问。这忽略了 Java RMI 的一个重要细节,即动态类装载。因为 RMI 的特性(包括其它几个特性)并不适用于 J2EE 的 RMI-IIOP 和 EJB 技术,所以,本文将不作详细介绍,请读者自行参考本文给出的参考资料。不过,为了让好奇的读者不至于过分失望,这里简单介绍一下动态类装载的基本思想。
RMI 运行时系统采用动态类装载机制来装载分布式应用所需的类。如果你可以直接访问应用所涉及的所有包括服务器端客户端在内的主机,并且可以把分布式应用所需的所有类都安装在每个主机的 CLASSPATH
中(上面的示例就是极端情况,所有的东西都在本地主机),那么你完全不必关心 RMI 类装载的细节。显然,既然是分布式应用,情况往往正相反。对于 RMI 应用,客户端需要装载客户端自身所需的类,将要调用的远程对象的远程接口类以及对应的 Stub 类;服务器端则要装载远程对象的实现类以及对应的 Skeleton 类(Java 1.2 之后不需要 Skeleton 类)。RMI 在处理远程调用涉及的远程引用,参数以及返回值时,可以将一个指定的 URL 编码到流中。交互的另一端可以通过 该 URL 获得处理这些对象所需的类文件。这一点类似于 Applet 中的 CODEBASE 的概念,交互的两端通过 HTTP 服务器发布各自控制的类,允许交互的另一端动态下载这些类。以我们的示例为例,客户端不必部署 ComputeEngine_Stub 的类文件,而可以通过服务器端的 HTTP 服务器获得类文件。同样,服务器端也不需要客户端实现的定制任务 Pi 的类文件。
注意,这种动态类装载将需要交互的两端加载定制的安全管理器(参见 java.rmi.RMISecurityManager API),以及对应的策略文件。
-
The Java™ Tutorial Trail:RMI
-
David Flanagan, Jim Farley, William Crawford and Kris Magnusson, 1999, ISBN 1-56592-483-5E, O'Reilly, Java™ Enterprise in a Nutshell
-
Ed Roman, Scott Ambler and Tyler Jewell 2002, ISBN 0-471-41711-4, John Wiley &Sons, Inc., Matering Enterprise JavaBeans™ , Second Edition