如何实现“跨虚拟机”的调用,它就是 RMI(Remote Method Invocation,远程方法调用)。例如,服务A 在 JVM1 中运行,服务B 在 JVM2 中运行,服务A 与 服务B 可相互进行远程调用,就像调用本地方法一样,这就是 RMI。在分布式系统中,我们使用 RMI 技术可轻松将 服务提供者(Service Provider)与 服务消费者(Service Consumer)进行分离,充分体现组件之间的弱耦合,系统架构更易于扩展。
ZooKeeper Java原生RMI实现分布式协调案例_1024程序员节
我们先从通过一个最简单的 RMI 服务与调用示例,快速掌握 RMI 的使用方法,然后指出 RMI 的局限性,最后笔者对此问题提供了一种简单的解决方案,即使用 ZooKeeper 轻松解决 RMI 调用过程中所涉及的问题。

下面我们就从一个最简单的 RMI 示例开始吧!
ZooKeeper Java原生RMI实现分布式协调案例_1024程序员节_02

1 发布 RMI 服务

发布一个 RMI 服务,我们只需做三件事情:

  1. 定义一个 RMI 接口:HelloService
  2. 编写 RMI 接口的实现类:HelloServiceImpl
  3. 通过 JNDI 发布 RMI 服务

2 定义一个 RMI 接口

RMI 接口实际上还是一个普通的 Java 接口,只是 RMI 接口必须继承 java.rmi.Remote,此外,每个 RMI 接口的方法必须声明抛出一个 java.rmi.RemoteException 异常,就像下面这样:

package com.yqq.zookeeper.rmi.common;

import java.rmi.Remote;
import java.rmi.RemoteException;

/**
* @Author yqq
* @Date 2021/10/24 16:20
* @Version 1.0
*/
public interface HelloService extends Remote {
String sayHello(String name) throws RemoteException;
}

继承了 Remote 接口,实际上是让 JVM 得知该接口是需要用于远程调用的,抛出了 RemoteException 是为了让调用 RMI 服务的程序捕获这个异常。毕竟远程调用过程中,什么奇怪的事情都会发生(比如:断网)。需要说明的是,RemoteException 是一个“受检异常”,在调用的时候必须使用 try…catch… 自行处理。
编写 RMI 接口的实现类

3 编写 RMI 接口的实现类

实现以上的 HelloService 是一件非常简单的事情,但需要注意的是,我们必须让实现类继承 java.rmi.server.UnicastRemoteObject 类,此外,必须提供一个构造器,并且构造器必须抛出 java.rmi.RemoteException 异常。我们既然使用 JVM 提供的这套 RMI 框架,那么就必须按照这个要求来实现,否则是无法成功发布 RMI 服务的,一句话:我们得按规矩出牌!

package com.yqq.zookeeper.rmi.service;

import com.yqq.zookeeper.rmi.common.HelloService;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

/**
* @Author yqq
* @Date 2021/10/24 16:25
* @Version 1.0
*/
public class HelloServiceImpl extends UnicastRemoteObject implements HelloService {
protected HelloServiceImpl() throws RemoteException {
}

@Override
public String sayHello(String name) throws RemoteException {
return String.format("Hello %s !",name);
}
}

为了满足 RMI 框架的要求,我们确实做了很多额外的工作(继承了 UnicastRemoteObject 类,抛出了 RemoteException 异常),但这些工作阻止不了我们发布 RMI 服务的决心!我们可以通过 JVM 提供的 JNDI(Java Naming and Directory Interface,Java 命名与目录接口)这个 API 轻松发布 RMI 服务。

4 通过 JNDI 发布 RMI 服务

发布 RMI 服务,我们需要告诉 JNDI 三个基本信息:

  1. 域名或 IP 地址(host)
  2. 端口号(port)
  3. 服务名(service)

它们构成了 RMI 协议的 URL(或称为“RMI 地址”):

rmi://:/
如果我们是在本地发布 RMI 服务,那么 host 就是“localhost”。此外,RMI 默认的 port 是“1099”,我们也可以自行设置 port 的值(只要不与其它端口冲突即可)。service 实际上是一个基于同一 host 与 port 下唯一的服务名,我们不妨使用 Java 完全类名来表示吧,这样也比较容易保证 RMI 地址的唯一性。
对于我们的示例而言,RMI 地址为:

rmi://localhost:1099/com.yqq.zookeeper.rmi.server.HelloServiceImpl

我们只需简单提供一个 main() 方法就能发布 RMI 服务,就像下面这样:

package com.yqq.zookeeper.rmi.service;

import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

/**
* @Author yqq
* @Date 2021/10/24 17:31
* @Version 1.0
*/
public class RmiServer {
public static void main(String[] args) throws Exception {
System.out.println("rmi server running");
//定义发布RMI服务使用的端口
int port = 1099;
//定义url
String url = "rmi://localhost:1099/com.yqq.zookeeper.rmi.service.HelloServiceImpl";
//注册服务:在JNDI中创建了一个注册表
LocateRegistry.createRegistry(port);
//绑定服务:将RMI服务的实现类对象与url绑定
Naming.rebind(url,new HelloServiceImpl());
}
}

需要注意的是,我们通过 LocateRegistry.createRegistry() 方法在 JNDI 中创建一个注册表,只需提供一个 RMI 端口号即可。此外,通过 Naming.rebind() 方法绑定 RMI 地址与 RMI 服务实现类,这里使用了 rebind() 方法,它相当于先后调用 Naming 的 unbind() 与 bind() 方法,只是使用 rebind() 方法来得更加痛快而已,所以我们选择了它。

运行这个 main() 方法,RMI 服务就会自动发布,剩下要做的就是写一个 RMI 客户端来调用已发布的 RMI 服务。

5 调用 RMI 服务

同样我们也使用一个 main() 方法来调用 RMI 服务,相比发布而言,调用会更加简单,我们只需要知道两个东西:1. RMI 请求路径、2. RMI 接口(一定不需要 RMI 实现类,否则就是本地调用了)。数行代码就能调用刚才发布的 RMI 服务,就像下面这样:

package com.yqq.zookeeper.rmi.client;

import com.yqq.zookeeper.rmi.common.HelloService;

import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;

/**
* @Author yqq
* @Date 2021/10/24 18:09
* @Version 1.0
*/
public class RmiClient {
public static void main(String[] args) throws RemoteException, NotBoundException, MalformedURLException {
System.out.println("rmi client running");
//定义url
String url = "rmi://localhost:1099/com.yqq.zookeeper.rmi.service.HelloServiceImpl";
//寻找服务,返回对象
HelloService helloService = (HelloService)Naming.lookup(url);
//调用方法
String result = helloService.sayHello("詹姆斯");
System.out.println("client result:"+result);
}
}

ZooKeeper Java原生RMI实现分布式协调案例_zookeeper_03

6 RMI 服务的局限性

可见,借助 JNDI 这个所谓的命名与目录服务,我们成功地发布并调用了 RMI 服务。实际上,JNDI 就是一个注册表,服务端将服务对象放入到注册表中,客户端从注册表中获取服务对象。在服务端我们发布了 RMI 服务,并在 JNDI 中进行了注册,此时就在服务端创建了一个 Skeleton(骨架),当客户端第一次成功连接 JNDI 并获取远程服务对象后,立马就在本地创建了一个 Stub(存根),远程通信实际上是通过 Skeleton 与 Stub 来完成的,数据是基于 TCP/IP 协议,在“传输层”上发送的。毋庸置疑,理论上 RMI 一定比 WebService 要快,毕竟 WebService 是基于 HTTP 的,而 HTTP 所携带的数据是通过“应用层”来传输的,传输层较应用层更为底层,越底层越快。
ZooKeeper Java原生RMI实现分布式协调案例_java_04
既然 RMI 比 WebService 快,使用起来也方便,那么为什么我们有时候还要用 WebService 呢?

其实原因很简单,WebService 可以实现跨语言系统之间的调用,而 RMI 只能实现 Java 系统之间的调用。也就是说,RMI 的跨平台性不如 WebService 好,假如我们的系统都是用 Java 开发的,那么当然首选就是 RMI 服务了。

貌似 RMI 确实挺优秀的,除了不能跨平台以外,还有那些问题呢?
主要有以下两点局限性:

  1. RMI 使用了 Java 默认的序列化方式,对于性能要求比较高的系统,可能需要使用其它序列化方案来解决(例如:Protobuf)。
  2. RMI 服务在运行时难免会存在出故障,例如,如果 RMI 服务无法连接了,就会导致客户端无法响应的现象。

在一般的情况下,Java 默认的序列化方式确实已经足以满足我们的要求了,如果性能方面如果不是问题的话,我们需要解决的实际上是第二点,也就是说,让使系统具备 HA(High Availability,高可用性)。