Java进阶-分布式框架通信核心基础-序列化、远程过程调用RMI

序列化

  1. 为什么需要序列化?
    1. 在JVM创建的对象是在内存当中, 当我们JVM停止运行, 释放内存以后, 对于JVM内存中的对象也会被销毁
    2. 但是在有些应用常见, 我们需要把对象的数据持久化保存起来, 我们就需要使用对应的序列化和反序列化技术
  2. 常用序列化的场景:数据的持久化、数据的网络传输
  3. 序列化与反序列化
    1. 序列化: 把内存中的对象信息转化为字节数组的过程
    2. 反序列化: 序列化的逆向操作, 把字节数组转换为对象的过程
  4. 序列化框架选型的常用指标:序列化的字节数据大小、序列化的速度和系统资源开销

JDK的序列化

  1. 对于JDK的序列化对象一定要实现Serializable接口:该接口并不需要实现任何方法,但是如果不实现该接口,运行时会报错
  2. 代码举例
    1. User类如下

       @Data
       public class User implements Serializable {
           //为什么必须写这个属性,因为一旦动态修改user的属性,在进行序列化会报错
           private static final long serialVersionUID = 1657079049965088176L;
           private Integer age;
           private String name;
       }
      
    2. 封装一个接口与实现专门用来实现序列化、反序列化

       //接口
       package com.zh.demo.serdemo.ser;
       /**
        * 定义序列化接口
        */
       public interface ISerialize {
              
           /**
            * 序列化方法, 把一个对象转换为字节数组
            */
           <T> byte[] serialize(T obj);
              
           /**
            * 反序列方法, 把一个字节数组转换为一个对象
            */
           <T>  T deSerialize(byte[] buffer,Class<T> clazz);
       }
              
       //实现JdkSerialize
       package com.zh.demo.serdemo.ser.impl;
       import com.zh.demo.serdemo.ser.ISerialize;
       import java.io.*;
       public class JdkSerialize implements ISerialize {
              
           @Override
           public <T> byte[] serialize(T obj)  {
               //2 定义一个用于接收对象信息的字节数组输入流
               ByteArrayOutputStream bos = new ByteArrayOutputStream();
               //1 使用对象的输入流读取对象信息
               try {
                   //对象输出流, 传入对象输出的目的地
                   ObjectOutputStream oos = new ObjectOutputStream(bos);
                   //写入一个对象
                   oos.writeObject(obj);
                   //把字节数组流转换为字节数据
                   return bos.toByteArray();
               } catch (IOException e) {
                   e.printStackTrace();
                   throw  new  RuntimeException("序列化失败",e);
               }
           }
              
           @Override
           public <T> T deSerialize(byte[] buffer, Class<T> clazz) {
               //1 把字节数组转换为输入流
               ByteArrayInputStream bis = new ByteArrayInputStream(buffer);
               try {
                   ObjectInputStream ois = new ObjectInputStream(bis);
                   return (T) ois.readObject();
               } catch (Exception e) {
                   e.printStackTrace();
                   throw  new  RuntimeException("反序列化失败",e);
               }
           }
       }
      
      1. 序列化:将对象序列化为字节数组;反序列化: 将字节数组转换为对象
    3. 测试

       public class JdkSerializeTest {
           private ISerialize serialize=new JdkSerialize();
           //对象序列化
           @Test
           public void serialize() throws Exception {
               //创建一个对象
               User user = new User();
               user.setAge(21);
               user.setName("zh");
               byte[] datas = this.serialize.serialize(user);
               //数据的长度:194
               System.out.println("数据的长度:"+ datas.length);
               //保存到文件
               try(FileOutputStream fos = new FileOutputStream("user.dat");){
                   fos.write(datas);
                   fos.flush();
               }
           }
          
           //对象反序列化
           @Test
           public void deserialize() throws Exception {
               //反序列化
               byte[] buffer=new byte[1024];
               int len=-1;
               FileInputStream fis = new FileInputStream("user.dat");
               if((len=fis.read(buffer))>0){
                   buffer = Arrays.copyOf(buffer, len);
                   User u = serialize.deSerialize(buffer,User.class);
                   System.out.println("u = " + u);
               }
           }
       }
      

序列化注意事项

  1. serialVersionUID
    1. 被序列化的对象一定要自动生成serialVersionUID
    2. 生成方法
      1. idea偏好设置->Editor->inspections->右边搜索 Serializable class without ‘serialVersionUID’ 然后勾选 OK即可
      2. 在User类代码里面,鼠标点击User类名称,然后按住option/alt + 回车键,选择 add ‘serialVersionUID’ fiels ,会自动添加,id自动生成

         private static final long serialVersionUID = 1657079049965088176L;
        
    3. 为什么要添加呢?(认真理解)
      1. 如果在A场景已经将User的对象进行了序列化,但是后来在B场景修改了,给这个类添加、删除了一些属性
      2. 等到再次进行反序列化获取时,会报错,因为如果没有写serialVersionUID属性,jdk会自动生成该属性,一旦修改了原来类的属性,那么serialVersionUID就会自动修改,因此再次反序列化会找不到原来序列化存储的对象
      3. 专业讲解Java的序列化机制是通过判断类的 serialVersionUID 来验证版本一致性的。在进行反序列化时,JVM 会把传来的字节流中的 serialVersionUID与本地相应实体的 serialVersionUID 进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是 InvalidCastException如果没有为指定的 class 配置 erialVersionUID,那么 java 编译器会自动给这个 class 进行一个摘要算法,类似于指纹算法,只要这个文件有任何改动,得到的 UID 就会截然不同的,可以保证在这么多类中,这个编号是唯一的serialVersionUID 有两种显示的生成方式:一是默认的 1L,比如:private static final long serialVersionUID = 1L;二是根据类名、接口名、成员方法及属性等来生成一个 64 位的哈希字段当 实 现 java.io.Serializable 接 口 的 类 没 有 显 式 地 定 义 一 个serialVersionUID 变量时候,Java 序列化机制会根据编译的 Class 自动生成一个 serialVersionUID 作序列化版本比较用,这种情况下,如果Class 文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID 也不会变化的
  2. 静态变量不会序列化:序列化保存的是对象的状态,静态变量属于类的状态,因此 序列化并不保存静态变量。
  3. 父类的序列化: 一个子类实现了 Serializable 接口,它的父类都没有实现 Serializable,那么对于父类中的变量不会实现序列化操作;如果一个父类实现了Serializabla接口, 子类可以不用实现Serializable, 也会对对象的属性进行序列化操作
  4. transient:声明为transient的字段一般不会进行序列化, 对于一个敏感信息, 可以声明为transient,比如在User中添加一个pwd属性

     //声明为transient的字段一般不会进行序列化, 对于一个敏感信息, 可以声明transient
     private transient String pwd;
    

对象的深拷贝、浅拷贝

  1. 浅克隆:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。实现步骤:
    1. 实现Cloneable接口
    2. 实现clone方法, 修改一下方法权限为public
  2. 深度克隆:克隆出来的对象都是不一样的, 引用的对象类型也是新的引用,实现步骤
    1. 所有对象都实现序列化的接口
    2. 自定义一个深度克隆方法deepClone, 通过字节数组流和对象流的方式实现对象的深度拷贝
  3. 代码举例
    1. GirlfFriend、User类

       //GirlfFriend类
       @Data
       public class GirlfFriend implements Serializable {
           private String name;
       }
       //User类
       @Data
       public class User  extends Person implements Cloneable, Serializable {
           private static final long serialVersionUID = 1657079049965088176L;
           private Integer age;
           private String name;
           //声明为transient的字段一般不会进行序列化, 对于一个敏感信息, 可以声明为transient
           private transient String pwd;
              
           private GirlfFriend gf;// oxa9bf 在浅拷贝的时候, 并没有创建一个新的对象, 拷贝的只是一个内存地址值
                  
           //浅拷贝
           @Override
           public User clone() throws CloneNotSupportedException {
               return (User) super.clone(); //浅克隆, 只会拷贝内存地址值, 对于引用类型, 还是同一个对象
           }
                  
           //自己实现深拷贝
           public User deepClone(){
               //把对象序列化为字节数组
               ISerialize serialize = new JdkSerialize();
               //先序列化为一个字节输出
               byte[] bytes = serialize.serialize(this);//内存中
               //反序列化为一个对象,此时就是重新分配内存的新对象
               return serialize.deSerialize(bytes,User.class);//内存
           }
       }
      
    2. 测试

       public class CloneTest {
      
           @Test
           public void testClone() throws Exception {
               //1 创建一个User对象
               User jack = new User();
               jack.setName("jack");
               jack.setAge(18);
              
               GirlfFriend lucy = new GirlfFriend();
               lucy.setName("lucy");
               jack.setGf(lucy);
              
               //浅拷贝
               User jonh = jack.clone();
               //深度克隆
       //        User jonh = jack.deepClone();
               jonh.setAge(21);
               jonh.setName("jonh");
               jonh.getGf().setName("mary");
               //如果是浅拷贝,二者一样,jack、jonh的Gf都是mary,同一个人;反之,深拷贝,二者不一样
               System.out.println(jonh.getGf()==jack.getGf());
               System.out.println("jonh = " + jonh);
               System.out.println("jack = " + jack);
           }
       }
      

Protobuf序列化

  1. Protobuf是Google的一种数据交换格式,它独立于语言、独立于平台。Google 提供了多种语言来实现,比如 Java、C、Go、Python,每一种实现都包含了相应语言的编译器和库文件。
  2. Protobuf 使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的 RPC 调用。
  3. 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中,但是要使用 Protobuf 会相对来说麻烦些,因为他有自己的语法,有自己的编译器

1.简单使用

  1. 安装IDEA的插件Protobuf support
    1. 在偏好设置中找到plugins-》搜索Protobuf support,然后安装,重启idea即可
  2. pom文件配置
    1. 在properties标签中定义属性

       <!--windows写这个-->
       <os.detected.classifier>windows-x86_64</os.detected.classifier>
       <!--macos写这个-->
       <os.detected.classifier> osx-x86_64</os.detected.classifier>
      
    2. 添加依赖

       <dependency>
           <groupId>com.google.protobuf</groupId>
           <artifactId>protobuf-java</artifactId>
           <version>3.12.0</version>
       </dependency>
      
    3. 添加插件

       <plugin>
           <groupId>org.xolstice.maven.plugins</groupId>
           <artifactId>protobuf-maven-plugin</artifactId>
           <version>0.6.1</version>
           <configuration>
               <protocArtifact>
                   com.google.protobuf:protoc:3.12.0:exe:${os.detected.classifier}
               </protocArtifact>
               <pluginId>grpc-java</pluginId>
           </configuration>
       </plugin>
      
  3. 编写proto文件
    1. 在main目录下面创建包proto, 在proto包下面创建对应的资源文件,比如Person.proto文件,内容如下

       // 使用的协议版本 proto3  proto2
       syntax = "proto3";
       // 将要生成的Java类的包,就是生产序列化的文件放到哪个目录下
       option java_package = "com.zh.demo.serdemo.domain";
       // 创建一个文件名 PersonModel,就是序列化生成的文件名称
       option java_outer_classname = "PersonModel";
              
       // 具体需要序列化的类
       message Person {
           // 类的每个属性设置一个唯一的一个编号
           string name = 1  ;
           int32 age= 2 ;
       }
      
  4. 通过插件生成格式化的类
    1. 点击右侧maven工具-》plugins-》protobuf-》protobuf:compile,双击编译
    2. 会在target下的generated-sources/protobuf/java下的com.zh.demo.serdemo.domain包名下生成一个PersonModel.java类文件
    3. 将PersonModel.java类文件拖入到项目的src/main/java/下的com.zh.demo.serdemo.domain包中
  5. 使用编译后的类进行快速序列化,测试代码如下

     public class ProtoBufTest {
    
         @Test
         public void testSerialize() throws Exception {
             PersonModel.Person person = PersonModel.Person.newBuilder().setName("zh").setAge(21).build();
             //对对象进行序列化
             byte[] bytes = person.toByteArray();
             //序列化长度 8  之前是194
             System.out.println("序列化长度 " + bytes.length);
             //保存到文件
             try(FileOutputStream fos = new FileOutputStream("user2.dat");){
                 fos.write(bytes);
                 fos.flush();
             }
         }
         @Test
         public void testDeSerialize() throws Exception {
             //反序列化
             byte[] buffer=new byte[1024];
             int len=-1;
             FileInputStream fis = new FileInputStream("user2.dat");
             if((len=fis.read(buffer))>0){
                 buffer = Arrays.copyOf(buffer, len);
                 PersonModel.Person person = PersonModel.Person.parseFrom(buffer);
                 System.out.println("person = " + person);
             }
         }
     }
    
  6. 可以看到用protobuf序列化的数据大小才8个字节,之前是194个字节

2.特点

  1. 处理速度快:编码/解码方式简单(只需要简单的数学运算 = 位移等等)
  2. 数据体积小:采用了独特的编码方式,如Varint、Zigzag编码方式等等
  3. 兼容性高:采用T - L - V 的数据存储方式

序列化技术选型

  1. 查看资料:https://github.com/eishay/jvm-serializers/wiki
  2. 建议
    1. 对性能要求不高的场景,可以采用基于 XML 的 SOAP 协议 webservice
    2. 对性能和间接性有比较高要求的场景,那么Hessian、Protobuf、Thrift、Avro 都可以。
    3. 基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错
  3. 参考方面:序列化的数据大小、序列化的处理速度、是否支持跨平台,跨语言、可扩展性和兼容性、技术的流行度、学习的难度

远程过程调用RMI

简介

  1. RPC概述
    1. RPC(Remote Procedure Call Protocol)远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,简单的理解是一个服务请求另一个服务提供的服务
    2. RPC的原理:RPC协议的底层原理,就是对象的序列化、反序列化以及序列化后数据的传输。
    3. RPC协议的核心组成部分:序列化和反序列化,可以使用Java原生的序列化和反序列化,也可以使用高性能序列化/反序列化工具,如Hessian,FST等,还可以使用表单序列化。
    4. 本地过程调用: 就是在同一个JVM中, 直接调用本地的方法
    5. 常见的RPC框架:RMI(JRMP)纯Java的RPC框架、SOAP(webservice)、grpc、Thrift、Motan、Springcloud(常用)
  2. 为什么需要RPC远程调用?
    1. 案例:一个电商系统,分为用户、商品、订购三个功能块
    2. 集中开发:当我们在传统的集中开发的时候,所有的业务功能都在一起运行,打包成一个jar/War包,到时候运行的时候,是在同一个JVM虚拟机中,对于各个功能之间的相互调用,是本地的方法(函数)调用,直接调用对应的方法即可;
    3. 分模块开发:但是当我们的业务功能增长,我们把一个系统拆分为不同的业务模块的时候,比如:用户模块、商品模块、订单模块,这三个模块单独进行部署,这个时候,如果在商品模块需要调用用户模块的信息,那么在商品模块的JVM中并没有用户的相关实现,只能通过远程调用的方式进行调用。
    4. 如下图所示:

    图1

Java RMI 应用实战

1.业务流程梳理如下:

图1

  1. 有三个模块rmi-api模块、rmi-server模块、rmi-client模块
  2. rmi-api模块用于定义接口
  3. rmi-server模块用于针对rmi-api的接口实现
  4. rmi-client模块直接使用rmi-api的接口来远程使用rmi-server模块的实现

2.代码实战

  1. 项目初始化
    1. 创建父模块07-rmi-demo:新建一个项目(new Module)-》选择Maven->next->name: 07-rmi-demo Atrifact coordinates展开:groupid: com.zh Atrifactid:rmi-demo -> finished
    2. 由于该项目仅仅是用来管理子项目的,因此在pom.xml文件中添加

       <packaging>pom</packaging>
      
    3. 删除src文件夹,只留pom文件
    4. 创建rmi-api模块:点击07-rmi-demo,右击->new->Module->Maven->next->name:rmi-api->finish
    5. 同理创建rmi-server、rmi-client2个模块
  2. rmi-api模块:新建一个接口类IHelloService

     package com.zh.demo.rmi;
     public interface IHelloService {
         public String sayHello(String name) ;
     }
    
  3. rmi-server模块
    1. 在pom文件添加rmi-api模块依赖

       <dependencies>
           <dependency>
               <groupId>com.zh.demo</groupId>
               <artifactId>rmi-api</artifactId>
               <version>1.0-SNAPSHOT</version>
           </dependency>
       </dependencies>
      
    2. 新建一个类HelloServiceImpl

       package com.zh.demo.rmi;
       public class HelloServiceImpl  implements IHelloService{
           public String sayHello(String name) {
               System.out.println("name = " + name);
               return "hello:"+name;
           }
       }
      
  4. rmi-client模块
    1. 同理pom添加rmi-api模块依赖
    2. 新建一个类APPClient

       public class APPClient {
           public static void main(String[] args) {
               IHelloService helloService = null;
               String result =  helloService.sayHello("zh");
               System.out.println("result = " + result);
           }
       }
      

3.问题分析:

  1. rmi-client模块中的APPClient类中helloService为什么为null?因为该模块仅仅依赖于rmi-api模块,并没有IHelloService接口的实现,那么如何办呢?
  2. 解决办法
    1. rmi-api模块
      1. 对于接口必须继承Remote接口
      2. 对于接口中的方法必须抛出RemoteException的异常
      3. 改造后如下
       public interface IHelloService extends Remote {
           /**
            * 定义一个可以远程调用的方法
            * @param name  参数
            * @return
            * @throws RemoteException 必须标记有远程调用异常
            */
           public String sayHello(String name) throws RemoteException;
       }
      
    2. rmi-server模块
      1. 实现类必须继承UnicastRemoteObject对象
      2. 对于构造器使用空参的构造器即可

         public class HelloServiceImpl extends UnicastRemoteObject implements IHelloService{
             public  HelloServiceImpl() throws RemoteException {
                 super();
             }
             public String sayHello(String name) throws RemoteException {
                 System.out.println("name = " + name);
                 return "hello:"+name;
             }
         }
        
      3. 新增当前模块的服务启动类APPServer

         //启动服务
         public class APPServer {
             public static void main(String[] args) throws Exception {
                 //1 注册服务端口
                 LocateRegistry.createRegistry(1099);
                 //2 提供具体的服务, 先创建服务
                 IHelloService helloService = new HelloServiceImpl(); // tcp  Serversocket等待连接
                 //3 发布远程服务  这里用rmi协议 类似注册中心的功能, key-->value
                 Naming.bind("rmi://127.0.0.1:1099/hello",helloService);
                 System.out.println("服务启动成功");
             }
         }
        
    3. rmi-client模块:通过lookup方法查找到对应的服务提供者,服务名称

       public class APPClient {
           public static void main(String[] args) throws  Exception{
               //通过lookup方法查找到对应的服务提供者,  服务名称
               IHelloService helloService= (IHelloService) Naming.lookup("rmi://127.0.0.1:1099/hello");
               String result =  helloService.sayHello("zh");
               System.out.println("result = " + result);
           }
       }
      
  3. 运行项目
    1. 运行rmi-server模块的main函数类启动服务
    2. 运行rmi-client模块的APPClient
    3. 结果:rmi-client模块正常打印:result = hello:zh,说明获取到了rmi-server模块的的helloService实现

4.RMI调用流程总结

  1. 启动服务端程序, 暴露对应的端口和名称服务:注册服务端口、创建一个服务提供者的实例对象
  2. 启动客户端程序, 通过对应的名称服务找到对应的代理对象。
  3. 本质是:通过代理对象调用远程方法,对于调用过程中的方法参数、执行结果需要序列化在网络上传输,并不是将服务端的对象拷贝到client,然后再client执行。
  4. 整个流程时序图如下: 图1
    1. Sub、Skeleten 都是代理对象
Table of Contents