2023-09-27  阅读(6)
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/series/article/9343756475

前面两篇文章介绍了 Java 原生序列化算法Hessian ,我们知道 Java 原生序列化算法有很多缺陷和不足支出,而 Hessian 也足够的优秀,但是它依然不是最牛逼的,这篇文章大明哥就带你们来了解一个更牛逼的序列化算法:Google 出品的 ProtoBuf。

ProtoBuf 是什么

ProtoBuf(Protocol Buffers) 是 Google 推出的一个结构化数据交换协议,用于传递自定义的消息格式,可用于分布式应用之间的数据通信或者异构环境下的数据交换。

ProtoBuf 是一种语言无关、平台无关、高效、扩展性良好的语言,提供了一种将结构化数据进行序列化和反序列化的方法。它提供了多种语言的实现:Java、C#、C++、Go 和 Python 等等,基本上所有的主流语言都已经支持了,每一种实现都包含了相应语言的编译器以及库文件。

ProtoBuf 协议说明

要使用 ProtoBuf 我们就必须先了解 ProtoBuf 的协议。在 ProtoBuf 中,协议是由一系列的 Message(消息)组成的,如下:

systax = "proto3"; 
package School; 

message Student {
  required string name = 1 [default="张三"];
  optional string sex = 2;
  optional int32 heigth = 3 [default=175];
}

message Teacher {
  required strint name = 1;
  optional string class = 2;
  optional string object = 3; 
}
  • systax = "proto3":协议版本。表明我们使用的是 proto3,目前 ProtoBuf 有两个版本 proto2 和 proto3,默认使用 proto2。这个指定语法行必须是文件的非空非注释的第一行。
  • package School:包名
  • message:消息体

一个消息体 message 有四个部分组成:

  • 限定修饰符
  • 数据类型
  • 字段名称
  • 字段编码值
  • 默认值

限定修饰符

用于描述字段规则,它有三个值:

  • required:必须字段。对于发送方法,在发送消息之前,必须要设定该字段的值,对于接收方,它必须要能够识别该字段。用 required 修饰的字段如果发送方没有设定值或者接收方无法识别该字段都会导致解析失败,导致消息被丢弃。
  • optional:可选字段。表明该字段是可选择性的,发送方设定不设定该值都可以,接收方能不能识别也行。
  • repreated:可重复字段。说明该字段可以包含 0 ~ N 个元素,相当于 Java 中的数组或者集合。

在后续升级版本的时候,我们要使用 optional ,而不是 required,如果定义为 require,则需要所有子系统配合你一起升级,这明显是不现实的,所以推荐使用 optional 来进行平滑升级,带所有系统升级完毕后再调整为 required。

要注意的是 proto3 移除了 required 和 optional 两个限定修饰符,因为 proto3 认为 required和 optional 字段是有害的并且违反了 protobuf 的兼容性语义。所以如果我们要使用 proto3 协议的话,就只能使用 repreated 了。

数据类型

ProtoBuf 定义了一整套完整的基本数据类型,几乎都可以映射到 Java/C++ 等语言的基本数据类型:

protobuf 数据结构 描述 打包 Java 语言映射
bool 布尔类型 1字节 boolean
double 64浮点数 N double
float 32浮点数 N float
int32 32位整数 N int
uint32 无符号32位整数 N int
int64 64位整数 N long
uint64 64位无整数 N long
sint32 32位整数,处理负数效率更高 N int
sint64 64位整数,处理负数效率更高 N int
fixed32 32位无符号整数 4 int
fixed64 64位无符号整数 8 long
sfixed32 32位整数,能以更高的效率处理负数 4 int
sfixed64 64位整数 8 long
string 只能处理ASCII字符 N String
bytes 用于处理多字节的语言字符,如中文 N byte
enum 可以包含一个用户自定义的枚举类型uint32 N(uint32) Enum
message 可以包含一个用户自定义的消息类型 N Object

字段名称

相当于 Java 中的属性名,不过 ProtoBuf 推荐采用下划线分割,而不是驼峰式。比如:class_name 而不是 className。

字段编码值

通信双方互相识别的关键,有了该值,发送方和接收方才能互相识别对方的字段。对于该字段 ProtoBuf 有如下规定:

  1. 相同的字段编码值,其限定修饰符和数据类型必须相同。
  2. 同一个消息体不能有相同的字段编码值。
  3. 只要合法,无须连续

该值的范围为 1~2^32,其中 1 ~ 15 的编码时间和空间效率都是最高的,编码值越大,效率越低,所以我们一般都将该值设定为 1 ~ 15,超过了咋办?继续上增吧。

默认值

对于 required 类型的字段,我们可以使用默认值来进行设定,如required string name = 1 [default="张三"];,如果发送端没有设定该值,则默认使用张三来填充。

ProtoBuf 的优缺点

优点

  • 性能好,效率高

ProtoBuf 序列化速度块,比 XML 和 JSON 快 20 ~ 100 倍,性能极高。

由于序列化生成的是一个紧致的二进制字节流,所以序列化后,数据包大小很小,因为体积小,所以传输起来带宽和速度都得到了较大的提升。

下面是 ProtoBuf 与其他序列化算法的对比,来看看他有多变态。

从图中可以看出,无论是序列化速度还是序列化后的字节流大小,ProtoBuf 都是碾压式的。

  • 跨平台、跨语言

ProtoBuf 是无关平台、无关语言的序列化算法的,所以它可以用于分布式应用系统或者异构系统之间的数据交互。且官方提供了几乎涵盖所有主流编程语言的实现,可扩展性非常好。

  • 使用简单,兼容性好

接收端与发送端不需要根据版本同步进行,发送端增加一个字段,并不会影响接收端的使用。同时 ProtoBuf 的语法很简单,没有复杂的对象模型,且文档足够清晰。

缺点

  • 可读性差

因为是二进制,直接导致了可读性比较差,在开发测试时我们无法看到里面的实际内容,可能会影响开发效率,当然我们可以尽可能相信 ProtoBuf,它并不会出现太大问题。

  • 缺乏自描述

一般来说,XML 和 JSON 是字描述的,而 ProtoBuf 则不是,你只能给 .proto 文件才能读懂数据结构。

如何使用 ProtoBuf

知道 ProtoBuf 是干啥的了,下一步就是使用它。

常规使用流程

1. 安装 ProtoBuf

要使用 ProtoBuf 我们就必须要按照 ProtoBuf 的编译器。大明哥是 MacBook,所以就只介绍 MAC 下如何安装了。

https://github.com/protocolbuffers/protobuf/releases 下载最新版的 ProtoBuf(protoc-21.5-osx-aarch_64.zip),然后解压。解压完成后执行以下命令就可以安装了:

cd protoc-21.5-osx-aarch_64
cp -r include/ /usr/local/include/
cp -r bin/ /usr/local/bin/

完成后,执行 protoc --version,看能否打印对应的版本,如果能够正常显示,说明已安装完成。

protoc --version
libprotoc 3.21.5

2.定义 .proto 文件

安装完成后,我们就可以使用 ProtoBuf 了。

我们首先需要编写一个 .proto 文件,定义我们需要处理的结构化数据。具体的语法大明哥在上面已经介绍了。内容如下:

syntax = "proto3";
package com.sike.javacore.serializer.protobuf;   //java 的 package
option java_outer_classname = "StudentEntity";   //生成的 Java 类的类名

message Student
{
  int32  id = 1;
  string name = 2;
  string sex = 3;
  repeated string hobbybes = 4;
} 

3. 编译 .proto 文件

编写完 .proto 文件后,我们需要对其进行编译。命令如下:

protoc.exe -I=proto的输入目录 --java_out=java类输出目录 proto的输入目录包括包括proto文件

protoc -I=/Users/chenssy/Downloads --java_out=/Users/chenssy/Downloads /Users/chenssy/Downloads/student.proto

最后会在对应位置生成一个 StudentEntity.java 的 Java 文件 ,大明哥看了这个类,那是相当的复杂,然后在这个类顶部还有一句话// Generated by the protocol buffer compiler. DO NOT EDIT! ,看到那个 DO NOT EDIT!了没。

3.序列化和反序列化

将上面生成的 Java 类导入 到 Idea 中,然后添加 ProtoBuf 的依赖。

<dependency>
  <groupId>com.google.protobuf</groupId>
  <artifactId>protobuf-java</artifactId>
  <version>3.21.5</version>
</dependency>

然后就是使用了。

public class ProtoBufTest01 {
    public static void main(String[] args) throws InvalidProtocolBufferException {
        // 首先需要获取构造器
        StudentEntity.Student.Builder builder = StudentEntity.Student.newBuilder();
        // 设置属性值
        builder.setId(1);
        builder.setName("张三");
        builder.setSex("1");
        builder.addHobbybes("足球");
        builder.addHobbybes("篮球");

        // 创建对象
        StudentEntity.Student  student = builder.build();
        // 序列化
        byte[] data = student.toByteArray();
        System.out.println("序列化内容");
        for(byte b : data){
            System.out.print(b);
        }

        System.out.println();
        System.out.println("==============================");
        System.out.println("反序列化内容");

        // 反序列化
        StudentEntity.Student student1 = StudentEntity.Student.parseFrom(data);
        System.out.println("id:" + student1.getId());
        System.out.println("sex:" + student1.getSex());
        System.out.println("name:" + student1.getName());
        System.out.println("hobbybes:" + student1.getHobbybesList().toString());
    }
}

执行结果:

序列化内容
81186-27-68-96-28-72-11926149346-24-74-77-25-112-125346-25-81-82-25-112-125
==============================
反序列化内容
id:1
sex:1
name:张三
hobbybes:[足球, 篮球]

从结果上来看,已成功完成序列化和反序列化过程。

ProtoBuf 整体上使用还是非常简单的,定义好 .proto 文件,然后编译成 Java 类,最后导入到项目中就可以直接使用了。但是大明哥认为这种方式不适合直接在项目中使用,在真实项目中你会定义一个这样的 .proto 文件 ?而且我们是多人合作 ,你加一个字段,另外一个同事加另外一个字段?然后都复制过来,最后发现你的被他覆盖了,这还怎么去维护,不单单说维护的,就说工作量,写一个 Entity ,你需要懂 ProtoBuf 的语法(当然不是很难),需要编写 .proto 文件,还需要编译,看这个过程就比较繁琐。

所以我们需要有另外的方式来实现,就像我们使用 JSON 那么方面!

Java 中使用

io.protostuff 就很好地解决 了上面那个问题。

  • 1、引入依赖
        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-core</artifactId>
            <version>1.8.0</version>
        </dependency>

        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-runtime</artifactId>
            <version>1.8.0</version>
        </dependency>
  • 2、定义实体类

我们还是定义上面例子的 StudentDTO。

@Data
public class StudentDTO {

    @Tag(1)
    private Integer id;

    @Tag(2)
    private String name;

    @Tag(3)
    private String sex;

    @Tag(4)
    private List<String> hobbybes;
}

我们使用 @Tag()来标注,注意里面的数字,它和数字编码值是一个意思,不能重复,我们最好也不要改变原有的值,如果有新增的字段我们保持递增即可。

  • 3、定义 ProtoBuf 序列化反序列化工具类
@Slf4j
public class ProtoBufUtil {
    /**
     * 避免每次序列化都重新申请Buffer空间
     */
    private static LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
    /**
     *  缓存Schema
     */
    private static Map<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap();

    /**
     * 系列化
     * @param obj
     * @param <T>
     * @return
     */
    public static <T> byte[] serialize(T obj) {
        Class<T> clazz = (Class<T>) obj.getClass();
        Schema<T> schema = getSchema(clazz);
        byte[] data;
        try {
            data = ProtostuffIOUtil.toByteArray(obj, schema, buffer);
        } finally {
            buffer.clear();
        }
        return data;
    }

    /**
     * 反序列化
     * @param data
     * @param clazz
     * @param <T>
     * @return
     */
    public static <T> T deserialize(byte[] data, Class<T> clazz) {
        Schema<T> schema = getSchema(clazz);
        T obj = schema.newMessage();
        ProtostuffIOUtil.mergeFrom(data, obj, schema);
        return obj;
    }

    /**
     * 获取 Schema
     * @param clazz
     * @param <T>
     * @return
     */
    private static <T> Schema<T> getSchema(Class<T> clazz) {
        Schema<T> schema = (Schema<T>) schemaCache.get(clazz);
        if (schema == null) {
            schema = RuntimeSchema.getSchema(clazz);
            if (schema == null) {
                schemaCache.put(clazz, schema);
            }
        }
        return schema;
    }
}

schemaCache:这是一个 Schema 的缓存,它所表示的是序列化对象的结构。我们这里将其缓存起来,避免序列化同一个类的时候需要重新解析。

序列化方法(serialize())和反序列化方法(deserialize())也是很简单的,直接调用 ProtostuffIOUtil 即可。

  • 4、验证
public class ProtoBufTest02 {
    public static void main(String[] args) {
        StudentDTO studentDTO = new StudentDTO();
        studentDTO.setId(1);
        studentDTO.setName("张三");
        studentDTO.setSex("1");
        studentDTO.setHobbybes(new ArrayList<String>(){{add("足球");add("篮球");}});

        byte[] datas = ProtoBufUtil.serialize(studentDTO);
        System.out.println("序列化内容");
        for(byte b : datas){
            System.out.print(b);
        }

        System.out.println();
        System.out.println("==============================");
        System.out.println("反序列化内容");

        StudentDTO studentDTO1 = ProtoBufUtil.deserialize(datas,StudentDTO.class);
        System.out.println("id:" + studentDTO1.getId());
        System.out.println("sex:" + studentDTO1.getSex());
        System.out.println("name:" + studentDTO1.getName());
        System.out.println("hobbybes:" + studentDTO1.getHobbybes().toString());
    }
}

运行结果:

序列化内容
81186-27-68-96-28-72-11926149346-24-74-77-25-112-125346-25-81-82-25-112-125
==============================
反序列化内容
id:1
sex:1
name:张三
hobbybes:[足球, 篮球]

从上面的执行结果可以看出,序列化和反序列化结果正确。

两个 Student 对象的属性值一模一样,我们对比下两次的序列化内容是否一致:

// 常规使用流程 
81186-27-68-96-28-72-11926149346-24-74-77-25-112-125346-25-81-82-25-112-125

// Java 中使用的
81186-27-68-96-28-72-11926149346-24-74-77-25-112-125346-25-81-82-25-112-125

两个序列化内容一模一样。

大明哥这里只讲基本的应用,至于里面的原理,大明哥就不深入了,有兴趣的小伙伴可以继续深入研究下!


Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。

它的内容包括:

  • 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
  • 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
  • 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
  • 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
  • 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
  • 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
  • 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
  • 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw

目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:

想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询

同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。

阅读全文