Java进阶-分布式框架通信核心基础-序列化、远程过程调用RMI
序列化
- 为什么需要序列化?
- 在JVM创建的对象是在内存当中, 当我们JVM停止运行, 释放内存以后, 对于JVM内存中的对象也会被销毁
- 但是在有些应用常见, 我们需要把对象的数据持久化保存起来, 我们就需要使用对应的序列化和反序列化技术
- 常用序列化的场景:数据的持久化、数据的网络传输
- 序列化与反序列化
- 序列化: 把内存中的对象信息转化为字节数组的过程
- 反序列化: 序列化的逆向操作, 把字节数组转换为对象的过程
- 序列化框架选型的常用指标:序列化的字节数据大小、序列化的速度和系统资源开销
JDK的序列化
- 对于JDK的序列化对象一定要实现Serializable接口:该接口并不需要实现任何方法,但是如果不实现该接口,运行时会报错
- 代码举例
-
User类如下
@Data public class User implements Serializable { //为什么必须写这个属性,因为一旦动态修改user的属性,在进行序列化会报错 private static final long serialVersionUID = 1657079049965088176L; private Integer age; private String name; }
-
封装一个接口与实现专门用来实现序列化、反序列化
//接口 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); } } }
- 序列化:将对象序列化为字节数组;反序列化: 将字节数组转换为对象
-
测试
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); } } }
-
序列化注意事项
- serialVersionUID
- 被序列化的对象一定要自动生成serialVersionUID
- 生成方法
- idea偏好设置->Editor->inspections->右边搜索 Serializable class without ‘serialVersionUID’ 然后勾选 OK即可
-
在User类代码里面,鼠标点击User类名称,然后按住option/alt + 回车键,选择 add ‘serialVersionUID’ fiels ,会自动添加,id自动生成
private static final long serialVersionUID = 1657079049965088176L;
- 为什么要添加呢?(认真理解)
- 如果在A场景已经将User的对象进行了序列化,但是后来在B场景修改了类,给这个类添加、删除了一些属性
- 等到再次进行反序列化获取时,会报错,因为如果没有写serialVersionUID属性,jdk会自动生成该属性,一旦修改了原来类的属性,那么serialVersionUID就会自动修改,因此再次反序列化会找不到原来序列化存储的对象
- 专业讲解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 也不会变化的
- 静态变量不会序列化:序列化保存的是对象的状态,静态变量属于类的状态,因此 序列化并不保存静态变量。
- 父类的序列化: 一个子类实现了 Serializable 接口,它的父类都没有实现 Serializable,那么对于父类中的变量不会实现序列化操作;如果一个父类实现了Serializabla接口, 子类可以不用实现Serializable, 也会对对象的属性进行序列化操作
-
transient:声明为transient的字段一般不会进行序列化, 对于一个敏感信息, 可以声明为transient,比如在User中添加一个pwd属性
//声明为transient的字段一般不会进行序列化, 对于一个敏感信息, 可以声明transient private transient String pwd;
对象的深拷贝、浅拷贝
- 浅克隆:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。实现步骤:
- 实现Cloneable接口
- 实现clone方法, 修改一下方法权限为public
- 深度克隆:克隆出来的对象都是不一样的, 引用的对象类型也是新的引用,实现步骤
- 所有对象都实现序列化的接口
- 自定义一个深度克隆方法deepClone, 通过字节数组流和对象流的方式实现对象的深度拷贝
- 代码举例
-
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);//内存 } }
-
测试
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序列化
- Protobuf是Google的一种数据交换格式,它独立于语言、独立于平台。Google 提供了多种语言来实现,比如 Java、C、Go、Python,每一种实现都包含了相应语言的编译器和库文件。
- Protobuf 使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的 RPC 调用。
- 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中,但是要使用 Protobuf 会相对来说麻烦些,因为他有自己的语法,有自己的编译器
1.简单使用
- 安装IDEA的插件Protobuf support
- 在偏好设置中找到plugins-》搜索Protobuf support,然后安装,重启idea即可
- pom文件配置
-
在properties标签中定义属性
<!--windows写这个--> <os.detected.classifier>windows-x86_64</os.detected.classifier> <!--macos写这个--> <os.detected.classifier> osx-x86_64</os.detected.classifier>
-
添加依赖
<dependency> <groupId>com.google.protobuf</groupId> <artifactId>protobuf-java</artifactId> <version>3.12.0</version> </dependency>
-
添加插件
<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>
-
- 编写proto文件
-
在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 ; }
-
- 通过插件生成格式化的类
- 点击右侧maven工具-》plugins-》protobuf-》protobuf:compile,双击编译
- 会在target下的generated-sources/protobuf/java下的com.zh.demo.serdemo.domain包名下生成一个PersonModel.java类文件
- 将PersonModel.java类文件拖入到项目的src/main/java/下的com.zh.demo.serdemo.domain包中
-
使用编译后的类进行快速序列化,测试代码如下
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); } } }
- 可以看到用protobuf序列化的数据大小才8个字节,之前是194个字节
2.特点
- 处理速度快:编码/解码方式简单(只需要简单的数学运算 = 位移等等)
- 数据体积小:采用了独特的编码方式,如Varint、Zigzag编码方式等等
- 兼容性高:采用T - L - V 的数据存储方式
序列化技术选型
- 查看资料:https://github.com/eishay/jvm-serializers/wiki
- 建议
- 对性能要求不高的场景,可以采用基于 XML 的 SOAP 协议 webservice
- 对性能和间接性有比较高要求的场景,那么Hessian、Protobuf、Thrift、Avro 都可以。
- 基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错
- 参考方面:序列化的数据大小、序列化的处理速度、是否支持跨平台,跨语言、可扩展性和兼容性、技术的流行度、学习的难度
远程过程调用RMI
简介
- RPC概述
- RPC(Remote Procedure Call Protocol)远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,简单的理解是一个服务请求另一个服务提供的服务
- RPC的原理:RPC协议的底层原理,就是对象的序列化、反序列化以及序列化后数据的传输。
- RPC协议的核心组成部分:序列化和反序列化,可以使用Java原生的序列化和反序列化,也可以使用高性能序列化/反序列化工具,如Hessian,FST等,还可以使用表单序列化。
- 本地过程调用: 就是在同一个JVM中, 直接调用本地的方法
- 常见的RPC框架:RMI(JRMP)纯Java的RPC框架、SOAP(webservice)、grpc、Thrift、Motan、Springcloud(常用)
- 为什么需要RPC远程调用?
- 案例:一个电商系统,分为用户、商品、订购三个功能块
- 集中开发:当我们在传统的集中开发的时候,所有的业务功能都在一起运行,打包成一个jar/War包,到时候运行的时候,是在同一个JVM虚拟机中,对于各个功能之间的相互调用,是本地的方法(函数)调用,直接调用对应的方法即可;
- 分模块开发:但是当我们的业务功能增长,我们把一个系统拆分为不同的业务模块的时候,比如:用户模块、商品模块、订单模块,这三个模块单独进行部署,这个时候,如果在商品模块需要调用用户模块的信息,那么在商品模块的JVM中并没有用户的相关实现,只能通过远程调用的方式进行调用。
- 如下图所示:
Java RMI 应用实战
1.业务流程梳理如下:
- 有三个模块rmi-api模块、rmi-server模块、rmi-client模块
- rmi-api模块用于定义接口
- rmi-server模块用于针对rmi-api的接口实现
- rmi-client模块直接使用rmi-api的接口来远程使用rmi-server模块的实现
2.代码实战
- 项目初始化
- 创建父模块07-rmi-demo:新建一个项目(new Module)-》选择Maven->next->name: 07-rmi-demo Atrifact coordinates展开:groupid: com.zh Atrifactid:rmi-demo -> finished
-
由于该项目仅仅是用来管理子项目的,因此在pom.xml文件中添加
<packaging>pom</packaging>
- 删除src文件夹,只留pom文件
- 创建rmi-api模块:点击07-rmi-demo,右击->new->Module->Maven->next->name:rmi-api->finish
- 同理创建rmi-server、rmi-client2个模块
-
rmi-api模块:新建一个接口类IHelloService
package com.zh.demo.rmi; public interface IHelloService { public String sayHello(String name) ; }
- rmi-server模块
-
在pom文件添加rmi-api模块依赖
<dependencies> <dependency> <groupId>com.zh.demo</groupId> <artifactId>rmi-api</artifactId> <version>1.0-SNAPSHOT</version> </dependency> </dependencies>
-
新建一个类HelloServiceImpl
package com.zh.demo.rmi; public class HelloServiceImpl implements IHelloService{ public String sayHello(String name) { System.out.println("name = " + name); return "hello:"+name; } }
-
- rmi-client模块
- 同理pom添加rmi-api模块依赖
-
新建一个类APPClient
public class APPClient { public static void main(String[] args) { IHelloService helloService = null; String result = helloService.sayHello("zh"); System.out.println("result = " + result); } }
3.问题分析:
- rmi-client模块中的APPClient类中helloService为什么为null?因为该模块仅仅依赖于rmi-api模块,并没有IHelloService接口的实现,那么如何办呢?
- 解决办法
- rmi-api模块
- 对于接口必须继承Remote接口
- 对于接口中的方法必须抛出RemoteException的异常
- 改造后如下
public interface IHelloService extends Remote { /** * 定义一个可以远程调用的方法 * @param name 参数 * @return * @throws RemoteException 必须标记有远程调用异常 */ public String sayHello(String name) throws RemoteException; }
- rmi-server模块
- 实现类必须继承UnicastRemoteObject对象
-
对于构造器使用空参的构造器即可
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; } }
-
新增当前模块的服务启动类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("服务启动成功"); } }
-
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); } }
- rmi-api模块
- 运行项目
- 运行rmi-server模块的main函数类启动服务
- 运行rmi-client模块的APPClient
- 结果:rmi-client模块正常打印:
result = hello:zh
,说明获取到了rmi-server模块的的helloService实现
4.RMI调用流程总结
- 启动服务端程序, 暴露对应的端口和名称服务:注册服务端口、创建一个服务提供者的实例对象
- 启动客户端程序, 通过对应的名称服务找到对应的代理对象。
- 本质是:通过代理对象调用远程方法,对于调用过程中的方法参数、执行结果需要序列化在网络上传输,并不是将服务端的对象拷贝到client,然后再client执行。
- 整个流程时序图如下:
- Sub、Skeleten 都是代理对象