从懵逼到恍然大悟之Java中RMI的使用

此处讲的是Java中的RMI,而不是通用意义上的RMI,关于通用的RMI能够参考分布式之RPC的协议以及错误处理 这篇文章。html

1、Java RMI简介

Java RMI用于不一样虚拟机之间的通讯,这些虚拟机能够在不一样的主机上、也能够在同一个主机上;一个虚拟机中的对象调用另外一个虚拟上中的对象的方法,只不过是容许被远程调用的对象要经过一些标志加以标识。这样作的特色以下:java

  • 优势:避免重复造轮子;
  • 缺点:调用过程很慢,并且该过程是不可靠的,容易发生不可预料的错误,好比网络错误等;

RMI中的核心是远程对象(remote object),除了对象自己所在的虚拟机,其余虚拟机也能够调用此对象的方法,并且这些虚拟机能够不在同一个主机上。每一个远程对象都要实现一个或者多个远程接口来标识本身,声明了能够被外部系统或者应用调用的方法(固然也有一些方法是不想让人访问的)。web

1.1 RMI的通讯模型

从方法调用角度来看,RMI要解决的问题,是让客户端对远程方法的调用能够至关于对本地方法的调用而屏蔽其中关于远程通讯的内容,即便在远程上,也和在本地上是同样的。shell

从客户端-服务器模型来看,客户端程序直接调用服务端,二者之间是经过JRMPJava Remote Method Protocol)协议通讯,这个协议相似于HTTP协议,规定了客户端和服务端通讯要知足的规范。编程

可是实际上,客户端只与表明远程主机中对象的Stub对象进行通讯,丝绝不知道Server的存在。客户端只是调用Stub对象中的本地方法,Stub对象是一个本地对象,它实现了远程对象向外暴露的接口,也就是说它的方法和远程对象暴露的方法的签名是相同的。客户端认为它是调用远程对象的方法,其实是调用Stub对象中的方法。能够理解为Stub对象是远程对象在本地的一个代理,当客户端调用方法的时候,Stub对象会将调用经过网络传递给远程对象。服务器

java 1.2以前,与Stub对象直接对话的是Skeleton对象,在Stub对象将调用传递给Skeleton的过程当中,其实这个过程是经过JRMP协议实现转化的,经过这个协议将调用从一个虚拟机转到另外一个虚拟机。在Java 1.2以后,与Stub对象直接对话的是Server程序,再也不是Skeleton对象了。网络

因此从逻辑上来看,数据是在ClientServer之间横向流动的,可是其实是从ClientStub,而后从SkeletonServer这样纵向流动的。框架

这里写图片描述

1.2 重要的问题

1.2.1 数据的传递问题

咱们都知道在Java程序中引用类型(不包括基本类型)的参数传递是按引用传递的,对于在同一个虚拟机中的传递时是没有问题的,由于的参数的引用对应的是同一个内存空间,可是对于分布式系统中,因为对象再也不存在于同一个内存空间,虚拟机A的对象引用对于虚拟机B没有任何意义,那么怎么解决这个问题呢?maven

  • 第一种:将引用传递更改成值传递,也就是将对象序列化为字节,而后使用该字节的副本在客户端和服务器之间传递,并且一个虚拟机中对该值的修改不会影响到其余主机中的数据;可是对象的序列化也有一个问题,就是对象的嵌套引用就会形成序列化的嵌套,这必然会致使数据量的激增,所以咱们须要有选择进行序列化,在Java中一个对象若是可以被序列化,须要知足下面两个条件之一:
    • Java的基本类型;
    • 实现java.io.Serializable接口(String类即实现了该接口);
    • 对于容器类,若是其中的对象是能够序列化的,那么该容器也是能够序列化的;
    • 可序列化的子类也是能够序列化的;
  • 第二种:仍然使用引用传递,每当远程主机调用本地主机方法时,该调用还要经过本地主机查询该引用对应的对象,在任何一台机器上的改变都会影响原始主机上的数据,由于这个对象是共享的;

RMI中的参数传递和结果返回可使用的三种机制(取决于数据类型):分布式

  • 简单类型:按值传递,直接传递数据拷贝;
  • 远程对象引用(实现了Remote接口):以远程对象的引用传递;
  • 远程对象引用(未实现Remote接口):按值传递,经过序列化对象传递副本,自己不容许序列化的对象不容许传递给远程方法;

1.2.2 远程对象的发现问题

在调用远程对象的方法以前须要一个远程对象的引用,如何得到这个远程对象的引用在RMI中是一个关键的问题,若是将远程对象的发现类比于IP地址的发现可能比较好理解一些。

在咱们平常使用网络时,基本上都是经过域名来定位一个网站,可是实际上网络是经过IP地址来定位网站的,所以其中就须要一个映射的过程,域名系统(DNS)就是为了这个目的出现的,在域名系统中经过域名来查找对应的IP地址来访问对应的服务器。那么对应的,IP地址在这里就至关于远程对象的引用,而DNS则至关于一个注册表(Registry)。而域名在RMI中就至关于远程对象的标识符,客户端经过提供远程对象的标识符访问注册表,来获得远程对象的引用。这个标识符是相似URL地址格式的,它要知足的规范以下:

  • 该名称是URL形式的,相似于httpURL,schema是rmi;
  • 格式相似于rmi://host:port/namehost指明注册表运行的注解,port代表接收调用的端口,name是一个标识该对象的简单名称。
  • 主机和端口都是可选的,若是省略主机,则默认运行在本地;若是端口也省略,则默认端口是1099

2、编程实现

2.1 基本内容

实现RMI所需的API几乎都在:

  • java.rmi:提供客户端须要的类、接口和异常;
  • java.rmi.server:提供服务端须要的类、接口和异常;
  • java.rmi.registry:提供注册表的建立以及查找和命名远程对象的类、接口和异常;

其实在RMI中的客户端和服务端并无绝对的界限,与Web应用中的客户端和服务器仍是有区别的。这二者实际上是平等的,客户端能够为服务端提供远程调用的方法,这时候,原来的客户端就是服务器端。

2.2 基本实现之一(注册表单独运行)

2.2.1 构建服务器端

什么是远程对象?首先从名称上来看,远程对象是存在于服务端以供客户端调用。那么什么对象能够被客户端进行远程调用?这个问题从编程的角度来看,实现了java.rmi.Remote接口的类或者继承了java.rmi.Remote接口的全部接口都是远程对象。这些继承或者实现了该接口的类或者接口中定义了客户端能够访问的方法。这个远程对象中可能有不少个方法,可是只有在远程接口中声明的方法才能从远程调用,其余的公共方法只能在本地虚拟机中使用。

实现过程当中的注意事项:

  • 子接口的每一个方法都必须声明抛出java.rmi.RemoteException异常,该异常是使用RMI时可能抛出的大多数异常的父类。
  • 子接口的实现类应该直接或者间接继承java.rmi.server.UnicastRemoteObject类,该类提供了不少支持RMI的方法,具体来讲,这些方法能够经过JRMP协议导出一个远程对象的引用,并经过动态代理构建一个能够和远程对象交互的Stub对象。具体的实现看以下的例子。

首先远程接口以下:

public interface UserHandler extends Remote {
    String getUserName(int id) throws RemoteException;
    int getUserCount() throws RemoteException;
    User getUserByName(String name) throws RemoteException;
}

远程接口的实现类以下:

public class UserHandlerImpl extends UnicastRemoteObject implements UserHandler {
    // 该构造期必须存在,由于集继承了UnicastRemoteObject类,其构造器要抛出RemoteException
    public UserHandlerImpl() throws RemoteException {
        super();
    }

    @Override
    public String getUserName(int id) throws RemoteException {
        return "lmy86263";
    }
    @Override
    public int getUserCount() throws RemoteException{
        return 1;
    }
    @Override
    public User getUserByName(String name) throws RemoteException{
        return new User("lmy86263", 1);
    }
}

为了测试在使用RMI的序列化的问题,这里特别设置了一个引用类型User

public class User implements Serializable {
    // 该字段必须存在
    private static final long serialVersionUID = 42L;
    // setter和getter能够没有
    String name;
    int id;

    public User(String name, int id) {
        this.name = name;
        this.id = id;
    }
}

Java 1.4及 之前的版本中须要手动创建Stub对象,经过运行rmic命令来生成远程对象实现类的Stub对象,可是在Java 1.5以后能够经过动态代理来完成,再也不须要这个过程了。

运行该远程对象的服务器代码以下:

UserHandler userHandler = null;
try {
    userHandler = new UserHandlerImpl();
    Naming.rebind("user", userHandler);
    System.out.println(" rmi server is ready ...");
} catch (Exception e) {
    e.printStackTrace();
}

这里面的核心代码为Naming.rebind("user", userHandler) ,经过一个名称映射到该远程对象的引用,客户端经过该名称获取该远程对象的引用。

在远程对象中有三个方法:getUserName(int id)getUserCount()的参数和返回结果都是基本类型,所以是默认序列化的,可是对于getUserByName(String name)方法,返回的结果是一个引用类型,所以会涉及到序列化与反序列的问题,对于User类,必须知足如下条件:

  • 必须实现java.io.Serializable接口;

  • 其中必须有serialVersionUID字段,格式以下:

    private static final long serialVersionUID = 42L;

    若是没有该字段,则默认该类会随机生成一个整数,且在客户端和服务器生成的整数不相同,则会抛出异常以下:

    这里写图片描述

    并且在服务器和客户端这个字段必须保持一致才能进行反序列化,若是两端都有该字段,可是数据不一致,则会抛出异常以下:

这里写图片描述

  • 这个类在服务器和客户端都必须可用;

  • 在序列化的时候,若是在字段前加入了transient关键字,则该数据不会被序列化;

2.2.2 构建注册表

注册表其实不用写任何代码,在你的JAVA_HOMEbin目录下有一个rmiregistry.exe程序,须要在你的程序的classpath下运行该程序。

在启动服务器的时候,实际上须要运行两个服务器:

  • 一个是远程对象自己;
  • 一个是容许客户端下载远程对象引用的注册表;

因为远程对象须要与注册表对话,因此必须首先启动注册表程序。当注册表程序没有启动的时候,若是强行启动远程对象服务器时,会抛出以下错误:

这里写图片描述

确保远程对象类能够被注册表程序发现,当远程对象类没有被注册表程序发现时,则会发现以下错误:

这里写图片描述

若是是使用maven管理工程,则在target/classes目录中启动该程序。

这说明注册表程序时运行在一个单独的进程中的,它做为一个第三方的组件,来协调客户端和服务器之间的通讯,可是与它们两个之间是彻底解决解耦的。

rmiregistry.exe默认状况下是监听1099端口,若是已经该端口已经被使用了,能够经过命令

rmiregistry 1020

指定其余的端口来运行。

能够经过start rmiregistry命令在后台运行

运行完注册表程序后,就能够运行远程对象所在的服务器,以便接受客户端的链接。

2.2.3 构建客户端

客户端的代码以下:

try {
    UserHandler handler = (UserHandler) Naming.lookup("user");
    int  count = handler.getUserCount();
    String name = handler.getUserName(1);
    System.out.println("name: " + name);
    System.out.println("count: " + count);
    System.out.println("user: " + handler.getUserByName("lmy86263"));
} catch (NotBoundException e) {
    e.printStackTrace();
} catch (MalformedURLException e) {
    e.printStackTrace();
} catch (RemoteException e) {
    e.printStackTrace();
}

在上边的代码中经过Naming.lookup(...)获取该远程对象的引用。这个方法经过一个指定的名称来获取,该名称必须与远程对象服务器绑定的名称一致。能够经过Naming.list(...)方法列出全部可用的远程对象。

在使用客户端链接服务器调用远程方法的时候,须要注意的问题以下:

  • UserHandler类在客户端本地必须可用,否则没法指定要调用的方法,并且其全限定名必须与服务器上的对象彻底相同,否则抛出以下异常:

    这里写图片描述

  • 从注册表中获取的对象引用已经失去类型信息,须要强制转化为远程对象类型。这样运行客户端的时候才能得到相应的响应;

  • 若是在方法中使用到了引用类型,好比这里的User,那么该类型的全限定名也必须与服务器的相同,若是不相同则会抛出以下异常:

    这里写图片描述

  • 客户端的引用类型的serialVersionUID字段要与服务器端的对象保持一致;

在客户端的User对象以下:

public class User implements Serializable {
    // 与客户端的serialVersionUID字段数据一致
    private static final long serialVersionUID = 42L;
    // setter和getter能够没有
    String name;
    int id;

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", id=" + id + '}';
    }
}

2.3 基本实现之二(服务端运行注册表程序)

对于实现二,和实现一的主要区别在注册表程序的运行,再也不是经过rmiregistry.exe单独运行,而是经过编程来实现,而远程接口以及其实现类与实现一彻底相同。

这里注册表的实现是经过java.rmi.registry包中的Registry接口和以及其实现类LocateRegistry来完成的。若是你详细查看JDK的源码的话,就会发现其实咱们以前使用的java.rmi.Naming类中的方法实际上都是间接经过RegistryLocateRegistry实现的。

其中获取根据主机和端口获取注册表引用的源码以下:

/** * Returns a registry reference obtained from information in the URL. */
private static Registry getRegistry(ParsedNamingURL parsed) throws RemoteException {
    return LocateRegistry.getRegistry(parsed.host, parsed.port);
}

并且Naming中的方法和Registry中是一一对应的。

而若是要建立一个注册表,这里要使用的是LocateRegistry,该类中只要两类方法:

  • 建立本地注册表而且获取该注册表的引用;

    • createRegistry(int port)

    • createRegistry(int port, RMIClientSocketFactory csf, RMIServerSocketFactory ssf)

  • 直接获取注册表引用,该注册表能够是本地运行的,也能够是远程运行的,这类方法是不可以建立注册表的,只能等注册表程序运行起来以后,和它进行通讯来获取引用,不然抛出异常以下:

    这里写图片描述

    其中的方法以下:

    • getRegistry()
    • getRegistry(int port)
    • getRegistry(String host)
    • getRegistry(String host, int port)
    • getRegistry(String host, int port, RMIClientSocketFactory csf)

    因为是可能从远程主机获取注册表引用,所以可能须要指定Socket套接字来和远程主机进行沟通,在这个过程当中也有可能由于各类缘由形成调用过程失败;

运行远程对象的服务器代码以下:

UserHandler userHandler = null;
Registry registry = null;
try {
    registry = LocateRegistry.createRegistry(1099);
    userHandler = new UserHandlerImpl();
    registry.rebind("user", userHandler);
    System.out.println(" rmi server is ready ...");
} catch (RemoteException e) {
    e.printStackTrace();
}

除此以外,其余服务器端和客户端的代码与实现一彻底相同。

关于RMI的实际使用,其中一种方式能够参考相关文章第6个,经过和ZooKeeper结合使用RMI


相关文章:

  1. What is a serialVersionUID and why should I use it?

  2. Java 序列化的高级认识

  3. java.rmi.Naming和java.rmi.registry.LocateRegistry的区别

  4. RMI教程:入门与编译方法

  5. Java深度历险(十)——Java对象序列化与RMI

  6. 使用 RMI + ZooKeeper 实现远程调用框架