3.2为何设计序列化数据
目录介绍
- 01.什么是序列化
- 1.1 为什么会有序列化
- 1.2 序列化概念和作用
- 1.3 Java序列化方式分类
- 02.序列化设计思路
- 2.1 设计序列化的思路
- 2.2 设计序列化注意点
- 03.Serializable序列化
- 3.1 Serializable介绍
- 3.2 Serializable设计思想
- 3.3 控制字段序列化
- 3.4 如何自定义序列化
- 3.5 序列化版本号作用
- 3.6 Serializable接口单例
- 3.7 Serializable原理
- 04.Serializable序列化缺陷
- 4.1 无法跨语言
- 4.2 易被攻击
- 4.3 序列化后流太大
- 4.4 序列化性能太差
- 05.Protobuf序列化
- 5.1 ProtoBuf是什么
- 5.2 ProtoBuf序列化
- 5.3 ProtoBuf数据为何小
- 5.4 ProtoBuf和json效率
- 5.5 ProtoBuf优缺点
- 5.6 Proto遇到的坑
01.什么是序列化
1.1 为什么会有序列化
- 为什么要设计序列化呢?
- 前端和后端,比如前端页面需要请求后端接口数据,用来填充页面,不同业务之间通信需要通过接口实现调用。
- 两个服务之间要共享一个数据对象,就需要从对象转换成二进制流,通过网络传输,传送到对方服务,再转换回对象,供服务方法调用。
- 这个编码和解码过程我们称之为序列化与反序列化。
1.2 序列化概念和作用
- Java序列化是指将Java对象转换为字节流的过程,以便在网络传输、持久化存储或跨平台传递。
- 数据持久化:通过将对象序列化为字节流,可以将对象的状态保存到磁盘或数据库中,实现数据的持久化存储。
- 网络传输:通过序列化,可以将Java对象转换为字节流,便于在网络中传输。
- 跨平台传递:Java序列化是一种与平台无关的数据交换格式。通过将对象序列化为字节流,可以在不同的操作系统和编程语言之间传递数据。
- 缓存和缓存共享:通过将对象序列化为字节流,可以将对象存储在缓存中,以提高系统的性能和响应速度。
1.3 Java序列化方式分类
- 目前序列化方式分类:
- 第一种:实现Serializable接口。
- 第二种:使用Protobuf开源库API实现序列化。
- 第三种:实现Externalizable接口。
- 实现Serializable接口
- 被序列化的类必须实现Serializable接口。这个接口没有任何方法定义,只是一个标记接口,表示该类可以被序列化。
- 实现Externalizable接口
- 使用Externalizable接口,则需要实现该接口,并且需要覆写writeExternal和readExternal方法。
- 使用Protobuf开源库API实现序列化
- 将定义的.proto文件编译生成Java类,用于序列化和反序列化消息。
02.序列化设计思路
2.1 设计序列化的思路
- 如果让你设计序列化框架,你会考虑哪些因素?设计序列化的一般思想和考虑事项。
- 1.将对象可以转化为二进制流;2.保存对象的状态;3.版本兼容性;4.控制序列化字段;5.如何让用户自定义序列化;6.测试和验证。
- 设计序列化的思想主要包括以下几个方面:
- 1.将对象转换为字节流:序列化的主要目的是将对象转换为字节流,以便在网络传输、持久化存储或跨平台传递。这涉及将对象的状态(字段值)转换为字节表示形式。
- 2.保存对象的状态:序列化的关键是保存对象的状态,即对象的字段值。在序列化过程中,需要将对象的字段值写入字节流中,以便在反序列化时能够重新构建对象。
- 3.版本兼容性:在设计序列化时,如果对象的定义发生变化,如新增或删除字段,可能会导致序列化和反序列化的问题。这时可以设计字段来指定序列化版本号,并在反序列化时进行版本检查。
- 4.控制字段的序列化:有时候,你可能不希望所有字段都被序列化,可以使用关键字修饰字段,使其不参与序列化。
- 5.如何让用户自定义序列化:
- 6.测试和验证:完成序列化和反序列化的实现后,进行测试和验证,确保序列化和反序列化的正确性和性能。
2.2 设计序列化注意点
- 避免敏感信息的序列化:
- 在设计序列化时,需要注意避免将敏感信息(如密码、密钥等)序列化到外部存储或网络传输中。可以通过标记字段为transient或使用自定义序列化方法来排除敏感信息的序列化。
- 考虑性能和安全性:
- 序列化和反序列化是一种资源密集型操作,可能会对性能产生影响。在设计序列化时,需要考虑性能优化的策略,如使用缓存、压缩等。此外,为了确保安全性,需要对序列化的数据进行验证和防篡改措施。
- 可能涉及到版本的兼容性
- 比如添加了新的字段或者修改了字段的类型,之前已经序列化的对象可能无法被正确地反序列化。比如Serializable为了解决这个问题,可以显式地声明serialVersionUID,并保持其值不变。
03.Serializable序列化
3.1 Serializable介绍
- 实现Serializable接口可以实现序列化
- 这是一个标记接口,没有任何方法需要实现。通过实现该接口,告诉Java虚拟机该类可以被序列化。
- 序列化和反序列化的过程是通过ObjectOutputStream和ObjectInputStream来完成的。
- 可以使用这两个类的writeObject和readObject方法来手动控制序列化和反序列化的过程。
public class Person implements Serializable { private String name; private int age; private transient String address; // The field marked as transient will not be serialized public Person(String name, int age, String address) { this.name = name; this.age = age; this.address = address; } private void writeObject(ObjectOutputStream out) throws IOException { // Manually control the serialization process out.defaultWriteObject(); // Default serialization of name and age fields // Custom serialization for the address field out.writeUTF(address); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { // Manually control the deserialization process in.defaultReadObject(); // Default deserialization of name and age fields // Custom deserialization for the address field address = in.readUTF(); } @Override public String toString() { return "Person [name=" + name + ", age=" + age + ", address=" + address + "]"; } } public class SerializationExample { public static void main(String[] args) { Person person = new Person("打工充", 30, "湖北武汉"); try { //写数据 FileOutputStream fileOut = new FileOutputStream("person.txt"); ObjectOutputStream out = new ObjectOutputStream(fileOut); out.writeObject(person); // Manually call writeObject to serialize out.close(); fileOut.close(); //读数据 FileInputStream fileIn = new FileInputStream("person.txt"); ObjectInputStream in = new ObjectInputStream(fileIn); Person restoredPerson = (Person) in.readObject(); // Manually call readObject to deserialize in.close(); fileIn.close(); System.out.println("Original Person: " + person); System.out.println("Restored Person: " + restoredPerson); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } }
3.2 Serializable设计思想
- Serializable序列化机制的设计思想
- 这种机制能够将一个对象序列化为二进制形式(字节数组),用于写入磁盘或输出到网络,同时也能从网络或磁盘中读取字节数组,反序列化成对象,在程序中使用。
image - JDK 提供的两个输入、输出流对象 ObjectInputStream 和 ObjectOutputStream,它们只能对实现了 Serializable 接口的类的对象进行反序列化和序列化。
- Java开发工程师设计Serializable实现步骤
- 1.创建一个类,并实现 Serializable 接口
- 2.添加 serialVersionUID 字段
- 3.实现序列化方法 writeObject
- 4.实现反序列化方法 readObject
- 5.对需要序列化的对象进行序列化
3.3 控制字段序列化
- 有时候,你可能不希望所有字段都被序列化,该怎么处理
- 方法1: 可以使用transient关键字修饰字段,使其不参与序列化。
- 方法2: 可以自定义序列化过程,通过实现writeObject()和readObject()方法来控制字段的序列化和反序列化。
- 看一个案例,由于age字段被标记为transient,所以它不会被序列化。
- 因此,在输出结果中,age字段的值在序列化和反序列化后保持为默认值0。
public class Person2 implements Serializable { private String name; private transient int age; // 使用transient关键字修饰的字段不会被序列化 public Person2(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return "Person2 [name=" + name + ", age=" + age + "]"; } } public class SerializationExample2 { public static void main(String[] args) { Person2 person = new Person2("打工充", 30); try { FileOutputStream fileOut = new FileOutputStream("person.txt"); ObjectOutputStream out = new ObjectOutputStream(fileOut); out.writeObject(person); // 序列化Person2对象 out.close(); fileOut.close(); FileInputStream fileIn = new FileInputStream("person.txt"); ObjectInputStream in = new ObjectInputStream(fileIn); Person2 restoredPerson2 = (Person2) in.readObject(); // 反序列化为Person2对象 in.close(); fileIn.close(); System.out.println("Original Person2: " + person); System.out.println("Restored Person2: " + restoredPerson2); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } } //Original Person2: Person2 [name=打工充, age=30] //Restored Person2: Person2 [name=打工充, age=0]
- 自定义话序列化过程,通过实现writeObject()和readObject()方法来控制字段的序列化和反序列化。
public class Person3 implements Serializable { private String name; private int age; private transient String address; // The field marked as transient will not be serialized private transient long cardId; public Person3(String name, int age, String address , long cardId) { this.name = name; this.age = age; this.address = address; this.cardId = cardId; } private void writeObject(ObjectOutputStream out) throws IOException { // Manually control the serialization process out.defaultWriteObject(); // Default serialization of name and age fields // Custom serialization for the address field out.writeUTF(address); out.writeLong(cardId); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { // Manually control the deserialization process in.defaultReadObject(); // Default deserialization of name and age fields // Custom deserialization for the address field address = in.readUTF(); cardId = in.readLong(); } @Override public String toString() { return "Person [name=" + name + ", age=" + age + ", address=" + address + ", cardId=" + cardId + "]"; } } public class SerializationExample3 { public static void main(String[] args) { Person3 person = new Person3("打工充", 30,"武汉", 2143324); try { FileOutputStream fileOut = new FileOutputStream("person.txt"); ObjectOutputStream out = new ObjectOutputStream(fileOut); out.writeObject(person); out.close(); fileOut.close(); FileInputStream fileIn = new FileInputStream("person.txt"); ObjectInputStream in = new ObjectInputStream(fileIn); Person3 restoredPerson2 = (Person3) in.readObject(); // 反序列化为Person2对象 in.close(); fileIn.close(); System.out.println("Original Person3: " + person); System.out.println("Restored Person3: " + restoredPerson2); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } } //Original Person3: Person [name=打工充, age=30, address=武汉, cardId=2143324] //Restored Person3: Person [name=打工充, age=30, address=武汉, cardId=2143324]
- 这里writeObject和readObject的使用是有顺序的
- 例如第一次writeObject是将address作为Object写入, 所以第一次调用readObject读到的对象就一定是address;
- 所以, 写入的顺序是address,cardId, 读取时候的顺序一定也要是address,cardId
3.4 如何自定义序列化
- 实现自定义序列化方法:
- 如果需要对序列化过程进行更精细的控制,可以通过实现writeObject()和readObject()方法,可以在序列化和反序列化过程中对字段进行自定义处理。
- 需要特殊处理序列化和反序列化过程的类必须实现以下具有确切签名的特殊方法:
private void writeObject(java.io.ObjectOutputStream out) throws IOException private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException; private void readObjectNoData() throws ObjectStreamException;
- writeObject 方法负责为特定类写入对象的状态,以便对应的 readObject 方法可以恢复状态。
- 可以通过调用 out.defaultWriteObject 来调用默认的字段保存机制。该方法不需要关注其超类或子类的状态。
- 通过使用 writeObject 方法将各个字段写入 ObjectOutputStream,或者使用 DataOutput 支持的原始数据类型的方法来保存状态。
- readObject 方法负责从流中读取并恢复类的字段。
- 它可以调用 in.defaultReadObject 来调用默认的恢复对象非静态和非瞬态字段的机制。defaultReadObject 方法根据流中的信息,将流中保存的对象的字段与当前对象中同名的字段进行赋值。
- 这处理了类演变添加新字段的情况。该方法不需要关注其超类或子类的状态。通过使用 readObject 方法将各个字段从 ObjectInputStream 中读取,或者使用 DataOutput 支持的原始数据类型的方法来恢复状态。
- readObjectNoData 方法负责在序列化流未列出给定类作为被反序列化对象的超类时,初始化特定类的状态。
- 这可能发生在接收方使用与发送方不同版本的反序列化实例类,并且接收方的版本扩展了发送方版本未扩展的类。
- 这也可能发生在序列化流被篡改的情况下,因此 readObjectNoData 在源流存在敌意或不完整时很有用,以便正确地初始化反序列化对象。
3.5 序列化版本号作用
- 序列化版本号作用是什么
- 在Class类文件中默认会有一个serialNo作为序列化对象的版本号,无论在序列化方还是在反序列化方的class类文件中都存在一个默认序列号。
- 在序列化时,会将该版本号加载进去,在反序列化时,会校验该版本号。
- 如果是具有相同类名的不同版本号的类,在反序列化中是无法获取对象的。
- 序列化运行时会为每个可序列化类关联一个版本号,称为 serialVersionUID,在反序列化过程中用于验证序列化对象的发送方和接收方是否加载了与序列化兼容的类。
- 如果接收方加载的类与发送方的类具有不同的 serialVersionUID,则反序列化将导致 InvalidClassException。
- 然后通过一个小案例来看一下,只执行反序列化代码
public class Test { public static void main(String[] args) throws Exception { File file = new File("person.txt"); ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file)); Person newPerson = (Person)objectInputStream.readObject(); objectInputStream.close(); System.out.println(newPerson); // Person [age=null, name=abc] } } class Person implements Serializable { private static final long serialVersionUID = 1L; private transient Integer age; private String name; private String address; // 新添加的属性 // 省略构造函数/getter/setter/toString方法 }
- 删除serialVersionUID字段后再次执行,如下:
public class Test { public static void main(String[] args) throws Exception { File file = new File("person.txt"); ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file)); Person newPerson = (Person)objectInputStream.readObject(); objectInputStream.close(); System.out.println(newPerson); // Person [age=null, name=abc] } } class Person implements Serializable { private transient Integer age; private String name; private String address; // 新添加的属性 // 省略构造函数/getter/setter/toString方法 }
- 然后报错如下所示
- 没有定义serialVersionUID值, 反序列化可能会出现local class incompatible异常, 是Java的安全机制。
- 当序列化对象时, 如果该对象所属类没有serialVersionUID, Java编译器会对jvm中该类的Class文件进行摘要算法生成一个serialVersionUID(version1), 并保存在序列化结果中。
- 当反序列化时, JVM会再次对JVM中Class文件摘要生成一个serialVersionUID(version2)
- 当且仅当version1=version2时,才会将反序列化结果加载入jvm中,否则jvm会判断为不安全,拒绝载入并抛出local class incompatible异常.
java.io.InvalidClassException: Person; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 6567164524970287738 at SerializationExample.main(SerializationExample.java:18)
3.6 Serializable接口单例
- 这是一个使用单例模式实现的类,如果我们将该类实现 Java 的 Serializable 接口,它还是单例吗?
- 可以把类路径上几乎所有实现了 Serializable 接口的对象都实例化。
- 导致这个问题的原因是序列化中的readObject会通过反射,调用没有参数的构造方法创建一个新的对象。破坏单例模式。
public class Singleton implements Serializable { private final static Singleton singleInstance = new Singleton(); private Singleton(){} public static Singleton getInstance(){ return singleInstance; } }
- 如果要你来写一个实现了 Java 的 Serializable 接口的单例,你会怎么写呢?
- 解决方法是添加readResolve()方法,自定义返回对象策略。
public static class Singleton implements Serializable { private final static Singleton singleInstance = new Singleton(); private Singleton(){} public static Singleton getInstance(){ return singleInstance; } private Object readResolve(){ return singleInstance; } }
04.Serializable序列化缺陷
4.1 无法跨语言
- 现在的系统设计越来越多元化,很多系统都使用了多种语言来编写应用程序。
- Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。
- 因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。
4.2 易被攻击
- Java 官网安全编码指导方针中说明:“对不信任数据的反序列化,从本质上来说是危险的,应该予以避免”。
- 可见 Java 序列化是不安全的。
- 对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。
- 这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
- 对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。
- 攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。
Set root = new HashSet(); Set s1 = root; Set s2 = new HashSet(); for (int i = 0; i < 100; i++) { Set t1 = new HashSet(); Set t2 = new HashSet(); t1.add("foo"); //使t2不等于t1 s1.add(t1); s1.add(t2); s2.add(t1); s2.add(t2); s1 = t1; s2 = t2; }
4.3 序列化后流太大
- 序列化后的二进制流大小能体现序列化的性能。
- 序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
- Java 序列化中使用了 ObjectOutputStream 来实现对象转二进制编码,那么这种序列化机制实现的二进制编码完成的二进制数组大小,相比于 NIO 中的 ByteBuffer 实现的二进制编码完成的数组大小,有没有区别呢?
- 可以通过一个简单的例子来验证下:
User user = new User(); user.setUserName("test"); user.setPassword("test"); //第一种方式 ByteArrayOutputStream os =new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(os); out.writeObject(user); byte[] testByte = os.toByteArray(); System.out.print("ObjectOutputStream 字节编码长度:" + testByte.length + "\n"); //第二种方式 ByteBuffer byteBuffer = ByteBuffer.allocate( 2048); byte[] userName = user.getUserName().getBytes(); byte[] password = user.getPassword().getBytes(); byteBuffer.putInt(userName.length); byteBuffer.put(userName); byteBuffer.putInt(password.length); byteBuffer.put(password); byteBuffer.flip(); byte[] bytes = new byte[byteBuffer.remaining()]; System.out.print("ByteBuffer 字节编码长度:" + bytes.length+ "\n");
- 运行结果:
- 这里我们可以清楚地看到:Java 序列化实现的二进制编码完成的二进制数组大小,比 ByteBuffer 实现的二进制编码完成的二进制数组大小要大上几倍。
- 因此,Java 序列后的流会变大,最终会影响到系统的吞吐量。
ObjectOutputStream 字节编码长度:99 ByteBuffer 字节编码长度:16
4.4 序列化性能太差
- 序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。
- 我们再来通过上面这个例子,来对比下 Java 序列化与 NIO 中的 ByteBuffer 编码的性能:
User user = new User(); user.setUserName("test"); user.setPassword("test"); long startTime = System.currentTimeMillis(); for (int i = 0; i < 1000; i++) { ByteArrayOutputStream os = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(os); out.writeObject(user); out.flush(); out.close(); byte[] testByte = os.toByteArray(); os.close(); } long endTime = System.currentTimeMillis(); System.out.print("ObjectOutputStream 序列化时间:" + (endTime - startTime) + "\n"); long startTime1 = System.currentTimeMillis(); for (int i = 0; i < 1000; i++) { ByteBuffer byteBuffer = ByteBuffer.allocate(2048); byte[] userName = user.getUserName().getBytes(); byte[] password = user.getPassword().getBytes(); byteBuffer.putInt(userName.length); byteBuffer.put(userName); byteBuffer.putInt(password.length); byteBuffer.put(password); byteBuffer.flip(); byte[] bytes = new byte[byteBuffer.remaining()]; } long endTime1 = System.currentTimeMillis(); System.out.print("ByteBuffer 序列化时间:" + (endTime1 - startTime1) + "\n");
- 运行结果:
- 可以清楚地看到:Java 序列化中的编码耗时要比 ByteBuffer 长很多。
ObjectOutputStream 序列化时间:121 ByteBuffer 序列化时间:14
05.Protobuf序列化
5.1 ProtoBuf是什么
- ProtoBuf简单介绍
- 它是Google公司发布的一套开源编码规则,基于二进制流的序列化传输,可以转换成多种编程语言,几乎涵盖了市面上所有的主流编程语言,是目前公认的非常高效的序列化技术。
- ProtoBuf是一种灵活高效可序列化的数据协议
- 相于XML,具有更快、更简单、更轻量级等特性。支持多种语言,只需定义好数据结构,利用ProtoBuf框架生成源代码,就可很轻松地实现数据结构的序列化和反序列化。
- 一旦需求有变,可以更新数据结构,而不会影响已部署程序。
- ProtoBuf的Github主页:
- 官方开源地址 :https://github.com/protocolbuffers/protobuf
- 语法指南:https://developers.google.com/protocol-buffers/docs/proto
- ProtoBuf 是一个小型的软件框架,也可以称为protocol buffer 语言,带着疑问会发现Proto 有很多需要了解:
- Proto 文件书写格式,关键字package、option、Message、enum 等含义和注意点是什么?
- 消息等嵌套如何使用?实现的原理?
- Proto 文件对于不同语言的编译,和产生的obj 文件的位置?
- Proto 编译后的cc 和java 文件中不同函数的意义?
- 如何实现*.proto 到*.java、.h、.cc 等文件?
- 数据包的组成方式、repeated 的含义和实现?
- Proto 在service和client 的使用,在java 端和native 端如何使用?
- 与xml 、json 等相比时间、空间上的比较如何?
5.2 ProtoBuf序列化
- 第一步:开发者首先需要编写.proto文件
- 按照proto格式编写文件,指定消息结构。
- 第二步:编译.proto文件生成对应的代码
- 需要把.proto文件丢给目标语言的protoBuf编译器。protoBuf编译器将生成相应语言的代码。
- 例如,对Java来说,编译器生成相应的.java文件,以及一个特殊的Builder类(该类用于创建消息类接口)。
- 第三步:使用代码传输数据,调用api
- protoBuf编译器会生成相应的方法,这些方法包括序列化方法和反序列化方法。
- 序列化方法用于创建和操作object,将它们转换为序列化格式,以进行存储或传输。调用
toByteArray()
方法将object转为byte字节数组。 - 反序列化方法用于将输入的protoBuf数据转换为object。调用
parseFrom(bytes)
方法将bytes字节数据转为object对象。 - 编译器完成它的工作后,开发人员所要做的,就是在发送/接收数据的代码中使用这些方法。
AddressBookProto.AddressBook addressBook = AddressBookProto.AddressBook.newBuilder() .addPeople(zs) .addPeople(ls) .addPeople(ys) .build(); //序列化 byte[] bytes = addressBook.toByteArray(); //反序列化 AddressBookProto.AddressBook book = AddressBookProto.AddressBook.parseFrom(bytes);
- 使用特定的工具或库将数据转换为 JSON 格式。
// 创建消息对象并设置字段值 MyMessage.Builder builder = MyMessage.newBuilder(); builder.setId(1); builder.setName("John Doe"); MyMessage message = builder.build(); // 将消息对象转换为 JSON String json = new Gson().toJson(message);
5.3 ProtoBuf数据为何小
- 先看看Proto文件的一个案例
message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; }
- 序列化数据为何说数据小
- 序列化数据时,不序列化key的name,使用key的编号替代,减小数据。
- 如上面的数据在序列化时query ,page_number以及result_per_page的key不会参与,由编号1,2,3替代。
- 这样在反序列的时候可以直接通过编号找到对应的key,这样做确实可以减小传输数据,但是编号一旦确定就不可更改;
- 没有赋值的key,不参与序列化:
- 序列化时只会对赋值的key进行序列化,没有赋值的不参与,在反序列化的时候直接给默认值即可;
- 可变长度编码:
- 主要缩减整数占用字节实现,例如java中int占用4个字节,但是大多数情况下,我们使用的数字都比较小,使用1个字节就够了,这就是可变长度编码完成的事;
- TLV:
- TLV全称为Tag_Length_Value,其中Tag表示后面数据的类型,Length不一定有,根据Tag的值确定,Value就是数据了,TLV表示数据时,减少分隔符的使用,更加紧凑;
- 如何理解TLV结构:TLV全称为Type_Length_Value
- Type块并不是只表示数据类型,其中数据编号也在Tag块中,Tag的生成规则如下:Tag块的后3位表示数据类型,其他位表示数据编号。Tag中1-15编号只占用1个字节,所以确保编号中1-15为常用的,减少数据大小。
- Length不一定有,依据Tag确定,例如数值类型的数据,使用VarInts不需要长度,就只有Tag-Value,string类型的数据就必须是Tag-Length-Value
- Value:消息字段经过编码后的值
image
5.4 ProtoBuf和json效率
- ProtoBuf就好比信息传输的媒介
- 如果用一句话来概括ProtoBuf和JSON的区别的话,那就是:对于较多信息存储的大文件而言,ProtoBuf的写入和解析效率明显高很多,而JSON格式的可读性明显要好。
- 如何对json和ProtoBuf进行效率测试
- 核心思路:创建同样数据内容的对象。然后将对象序列化成字节数组,获取字节数据大小,最后又将字节反序列化成对象。
- 效率对比:主要是比较序列化耗时,序列化之后的数据大小,以及反序列化的耗时。注意:必须保证创建数据内容是一样的。
- 测试案例分析如下
2023-05-09 09:31:49.699 23442-23442/com.yc.appgrpc E/Test效率测试:: Gson 序列化耗时:14 2023-05-09 09:31:49.699 23442-23442/com.yc.appgrpc E/Test效率测试:: Gson 序列化数据大小:188 2023-05-09 09:31:49.701 23442-23442/com.yc.appgrpc E/Test效率测试:: Gson 反序列化耗时:2 2023-05-09 09:31:49.701 23442-23442/com.yc.appgrpc E/Test效率测试:: Gson 数据:{"persons":[{"id":1,"name":"张三","phones":[{"number":"110","type":"HOME"}]},{"id":2,"name":"李四","phones":[{"number":"130","type":"MOBILE"}]},{"id":3,"name":"王五","phones":[{}]}]} 2023-05-09 09:31:49.720 23442-23442/com.yc.appgrpc E/Test效率测试:: protobuf 序列化耗时:4 2023-05-09 09:31:49.720 23442-23442/com.yc.appgrpc E/Test效率测试:: protobuf 序列化数据大小:59 2023-05-09 09:31:49.722 23442-23442/com.yc.appgrpc E/Test效率测试:: protobuf 反序列化耗时:2 2023-05-09 09:31:49.725 23442-23442/com.yc.appgrpc E/Test效率测试:: protobuf 数据:# com.yc.appgrpc.AddressBookProto$AddressBook@83d0213a people { id: 1 name: "\345\274\240\344\270\211" phones { number: "110" type: HOME type_value: 1 } } people { id: 2 name: "\346\235\216\345\233\233" phones { number: "120" } } people { id: 3 name: "\347\216\213\344\272\224" phones { number: "130" } }
- 测试结果说明
- 空间效率:Json:188个字节;ProtoBuf:59个字节
- 时间效率:Json序列化:14ms,反序列化:2ms;ProtoBuf序列化:4ms 反序列化:2ms
- 可以得出结论
- 通过以上的时间效率和空间效率,可以看出protoBuf的空间效率是JSON的2-5倍,时间效率要高,对于数据大小敏感,传输效率高的模块可以采用protoBuf库。
5.5 ProtoBuf优缺点
- ProtoBuf优点
- 性能:1.体积小,序列化后,数据大小可缩小3-10倍;2.序列化速度快,比XML和JSON快20-100倍;3.传输速度快,因为体积小,传输起来带宽和速度会有优化
- 使用优点:1.使用简单,proto编译器自动进行序列化和反序列化;2.维护成本低,多平台仅需维护一套对象协议文件(.proto);3.向后兼容性(扩展性)好,不必破坏旧数据格式就可以直接对数据结构进行更新;4.加密性好,Http传输内容抓包只能看到字节
- 使用范围:跨平台、跨语言(支持Java, Python, Objective-C, C+, Dart, Go, Ruby, and C#等),可扩展性好
- ProtoBuf缺点
- 功能缺点:不适合用于对基于文本的标记文档(如HTML)建模,因为文本不适合描述数据结构
- 通用性较差:json、xml已成为多种行业标准的编写工具,而ProtoBuf只是Google公司内部的工具
- 自解耦性差:以二进制数据流方式存储(不可读),需要通过.proto文件才能了解到数据结构
- 阅读性差:.proto文件去生成相应的模型,而生成出来的模型无论从可读性还是易用性上来说都是较差的。并且生成出来的模型文件是不允许修改的(protoBuf官方建议),如果有新增字段,都必须依赖于.proto文件重新进行生成。
- .protoBuf会导致客户端的体积增加许多
- protoBuf所生成的模型文件十分巨大,略复杂一些的数据可以达到1MB,请注意,1MB只是一个模型文件。
- 导致该问题的原因是,protoBuf为了实现对传输数据的信息补全(可以参看编码原理),将编码、解码的代码都整合到了每一个独立的模型文件中,因此导致代码有非常大的冗余
5.6 Proto遇到的坑
字段顺序问题:proto 使用字段的编号来标识字段,而不是使用字段的名称。
- 因此,如果更改了字段的顺序,可能会导致与旧版本的 proto 不兼容。为了避免这个问题,建议在 proto 文件中为字段指定唯一的编号,并避免在后续版本中更改字段的编号。
字段类型问题:proto 提供了多种字段类型,如 int32、string、bool 等。
- 确保选择正确的字段类型,以适应你的数据需求。如果你更改了字段的类型,可能需要进行相应的代码更改和数据迁移。
网络通信优化之序列化:避免使用Java序列化
- https://time.geekbang.org/column/article/99774
【Java原理系列】 Java可序列化接口Serializable原理全面用法示例源码分析
- https://zhuanlan.zhihu.com/p/676475438