protobuf

182 阅读18分钟

1 protobuf 简介

Protocol Buffers是1种轻便高效的结构化数据存储格式,可用于结构化数据串行化(序列化)。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

目前:Protobuf官方工程主页上显示的已支持的开发语言多达10种,分别有:C++、Java、Python、Objective-C、C#、Ruby、Go、PHP、Dart、Javascript,基本上主流的语言都已支持(具体详见Protobuf工程主页:github.com/protocolbuf…)。

protoc下载安装配置

blog.csdn.net/liu64491133…

工作流程

在开发 gRPC 应用程序时,先要定义服务接口,其中应包含如下信息:消费者消费服务的方式、消费者能远程调用的方法以及调用这些方法所使用的参数和消息格式等。在服务定义中所使用的语言叫作接口定义语言(interface definition language,IDL)。

借助服务定义,可生成服务器端代码,即服务器端骨架 (这里的“骨架”和“存根”都是代理。服务器端代理叫作“骨架”(skeleton),客户端代理叫作“存根”(stub)。),它通过提供低层级的通信抽象简化了服务器端的逻辑。同时,还可生成客户端代码,即客户端存根,它使用抽象简化了客户端的通信,为不同的编程语言隐藏了低层级的通信。就像调用本地函数,客户端能远程调用在服务接口定义中所指定的方法。底层的 gRPC 框架处理所有的复杂工作,通常包括确保严格的服务契约、数据序列化、网络通信、认证、访问控制、可观察性等。

为理解 gRPC 的基本概念,来看1个使用 gRPC 实现微服务的实际场景。假设正在构建1个在线零售应用程序,该应用程序由多个微服务组成。

如图 1-1 所示,假设要构建1个微服务来展现在线零售应用程序中可售商品的详情。例如,将 ProductInfo 服务建模为 gRPC 服务,通过网络对外暴露。

服务定义是在 ProductInfo.proto 文件中声明的,服务器端和客户端都会使用该文件来生成代码。这里假设 ProductInfo 服务使用 Go 语言来实现,消费者使用 Java 语言来实现,2者间的通信则通过 HTTP/2 来进行。

典型的序列化和反序列化过程需如下组件:

IDL(Interface description language)文件:参与通讯的各方需对通讯的内容需做相关的约定。为建立1个与语言和平台无关的约定,这个约定需采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。IDL Compiler:IDL文件中约定的内容为在各语言和平台可见,需有1个编译器,将IDL文件转换成各语言对应的动态库。Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是1段部署在分布式系统客户端的代码,1方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另1方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。

可看到,对于序列化协议来说,使用方只需关注业务对象本身,即 idl定义,序列化和反序列化的代码只需通过工具生成即可。

序列化原理解析

请记住Protocol Buffer的 三个关于数据存储 的重要结论:

结论1: Protocol Buffer将 消息里的每个字段编码后,再利用T-L-V方式存储数据,最终得到1个 2进制字节流

T - L - V 的数据存储方式

即 Tag - Length - Value,标识 - 长度 - 字段值 存储方式

以 标识 - 长度 - 字段值 表示单个数据,最终将所有数据拼接成1个 字节流,从而 实现 数据存储 的功能

其中 Length可选存储,如 储存Varint编码数据就不需存储Length

示意图

从上图可知,T - L - V存储方式的优点是

不需分隔符就能 分隔开字段,减少了分隔符的使用各字段 存储得非常紧凑,存储空间利用率非常高若 字段没被设置字段值,那该字段在序列化时的数据中完全不存在,即不需编码结论2:Protocol Buffer对于不同数据类型 采用不同的 序列化方式(编码方式 & 数据存储方式),如下图:

从上表可看出:

对于存储Varint编码数据,就不需存储字节长度 Length,所以实际上Protocol Buffer的存储方式是 T - V;若Protocol Buffer采用其他编码方式(如LENGTH_DELIMITED)则采用T - L - V

结论3:因为 Protocol Buffer对于数据字段值的 独特编码方式 & T - L - V数据存储方式,使得 Protocol Buffer序列化后数据量体积如此小

2 定义proto文件

**PS:**在 Proto3 中不支持 required字段。

2.1 message对象

在 ProtocolBuffers 中:

1个消息对象(Message) = 1个 结构化数据消息对象用 修饰符 message 修饰消息对象 含有 字段:消息对象(Message)里的 字段 = 结构化数据 里的成员变量

有几个地方需要注意:

1)1个 Protobuf 文件里面可添加多个消息类,也可嵌套;

2)上面的 1,2,3,4 并不是给字段赋值,而是给每个字段定义1个唯1的编号(这些编号用于2进制格式中标识的字段,并在使用的消息类型后不应更改);

3)1-15 的字段编号只占1个字节进行编码,16-2047 的字段编号占2个字节,包括字段编号和字段类型,因此建议更多的使用 1-15 的字段编号;

4)可指定最小字段编号为 1,最大字段编号为 2^29 - 1 或 536870911(另外不能使用 19000-19999 的标识号,因为 protobuf 协议实现对这些进行了预留,同样也不能使用任何以前保留(reserved) 的字段编号)。

2.2 message 消息定义

创建tutorial.person.proto文件,文件内容如下:

syntax = "proto3"; // 协议版本(proto3中,在第1行非空白非注释行,必须写:syntax = "proto3";)
package protocobuff_Demo;
// 关注1:包名,防止不同 .proto 项目间命名 发生冲突


option java_package = "com.chenj.protobuf";//// 作用:指定生成的类应该放在什么Java包名下
option java_outer_classname = "Demo";//作用:生成对应.java 文件的类名(不能跟下面message的类名相同)
// 关注2:option选项,作用:影响 特定环境下 的处理方式


// 关注3:消息模型 作用:真正用于描述 数据结构
// 下面详细说明
// 生成 Person 消息对象(包含多个字段,下面详细说明)
message Person {
     string name = 1;//(proto3消息定义时,移除 “required”、 “optional” :)
     int32 id = 2;//(proto3消息定义时,移除 “required”、 “optional” :)
     string email = 3;//(proto3消息定义时,移除 “required”、 “optional” :)


    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }


    message PhoneNumber {
        string number = 1;
        PhoneType type = 2 ;//(proto3消息定义时,移除 default 选项:)
    }


    repeated PhoneNumber phone = 4;
}


message AddressBook {
    repeated Person person = 1;
}

2.3 字段

消息对象的字段 组成主要是:字段 = 字段修饰符 + 字段类型 +字段名 +标识号

2.3.1 包声明

proto 文件以package声明开头,这有助于防止不同项目间命名冲突。在C++中,以package声明的文件内容生成的类将放在与包名匹配的namespace中,上面的.proto文件中所有的声明都属于tutorial。

2.3.2 字段修饰符
字段类型

基本数据

可变长度编码和固定长度编码区别:

//例如说我在 Java 里面进行如下定义:
int a = 1;
//因为 int 类型占 4 个字节,1个字节占 8 位,把 1 的字节占位给列出来:
00000000 00000000 00000000 00000001
//可看到 1 的前面 3 个字节占位都是 0,在 Protobuf 里面是可去掉的,于是就变成了:
00000001
//因此 1 在 Protobuf 里面就只占用了1个字节,节省了空间

上面这种就是可变长度编码。而固定长度编码就是即使前面的字节占位是 0,也不能去掉,我就是要占这么多字节。

基本数据类型默认值:

类型枚举

作用:为字段指定1个 可能取值的字段集合,该字段只能从 该指定的字段集合里 取值

下面例子,电话号码 可能是手机号、家庭电话号或工作电话号的其中1个,那就将PhoneType定义为枚举类型,并将加入电话的集合( MOBILE、 HOME、WORK)

// 枚举类型需要先定义才能进行使用


// 枚举类型 定义
 enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
// 电话类型字段 只能从 这个集合里 取值
  }


// 特别注意:
// 1. 枚举类型的定义可在1个消息对象的内部或外部
// 2. 都可在 同1.proto文件 中的任何消息对象里使用
// 3. 当枚举类型是在1消息内部定义,希望在 另1个消息中 使用时,需要采用MessageType.EnumType的语法格式


  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
    // 使用枚举类型的字段(设置了默认值)
  }


// 特别注意:
// 1.  枚举常量必须在32位整型值的范围内
// 2. 不推荐在enum中使用负数:因为enum值是使用可变编码方式的,对负数不够高

注意:

1)定义枚举类型使用 enum 关键字;

2)枚举类型第1个字段的值为必须 0,否则编译会报错;

3)枚举常量值必须在 32 位整型值的范围内(因为 enum 值是使用可变编码方式的,对负数不够高效,因此不推荐在 enum 中使用负数);

4)枚举里面的 = 操作是对常量进行赋值操作,而枚举外面的 = 则是对当前字段进行编号。

Protobuf 集合

repeated string list = 1; //类似 Java 的 List<String>
map<string,string> = 2; //类似 Java 的 Map<String,String>

注意:

1)Protobuf 中定义集合,就是在定义好的属性前面加 repeated 关键字;

2)Protobuf 中定义 map 和 Java 类似,只不过 map 是小写的。

reserved 保留字段

当使用 reserved 关键字指定1个字段编号或字段名为保留字段后,就不能去使用它,否则编译器会报错(如下图所示)。

类型消息对象

2.3.3 标识号

标识号:在消息体的定义中,每个字段都必须要有1个唯1的标识号,标识号是[0,2^29-1]的1个整数。以Person为例,name=1,id=2, email=3, phones=4 中的1-4就是标识号。

每个字段在进行编码时都会占用内存,而 占用内存大小 取决于 标识号:

范围 [1,15] 标识号的字段 在编码时占用1个字节;范围 [16,2047] 标识号的字段 在编码时占用2个字节

2.3.4 数据定义

许多标准的简单数据类型都可用作message字段类型,包括bool,int32,float,double和string。还可使用其他message类型作为字段类型在消息体中添加更多结构。在上面的示例中,Person包含PhoneNumber message, 而AddressBook包含Person message。甚至可定义嵌套在其他message中的message类型。例如,上面的PhoneNumber定义在Person。

2.3.5 函数方法

用message关键字声明的的消息体,允许检查、操作、读、或写整个消息,包括解析2进制字符串,以及序列化2进制字符串。除此之外,也定义了下列方法:

Person:缺省的构造函数。


~Person():缺省的析构函数。


Person(const Person& other):拷贝构造函数。


Person& operator=(const Person& other):
赋值 (Assignment )操作符。


const UnknownFieldSet& unknown_fields() const:
返回当解析信息时遇到的未知字段的集合。


UnknownFieldSet* mutable_unknown_fields():
返回当前解析信息时遇到的未知字段的集合的1个mutale指针。

3 编译proto文件

首先安装 ProtoBuf 编译器 protoc,这里有详细的安装教程。可执行以下protoc命令对.proto文件进行编译,生成对应的c文件。Linux系统通过 help protoc 查看protoc命令的使用详解。

protoc --java_out=./src/main/java ./src/main/idl/customer.proto

从项目的根路径执行该命令,并添加了2个参数:java_out,定义./src/main/java/为Java代码的输出目录;而./src/main/idl/customer.proto是.proto文件所在目录。

(编译器为每个.proto文件里的每个消息类型生成1个.java文件&1个Builder类 (Builder类用于创建消息类接口))

IDEA开发环境安装protobuf插件,生成java代码:

blog.csdn.net/jason_jiaho…

blog.csdn.net/Xin_101/art…

4 使用message

消息对象类 类通过 2进制数组 写 和 读 消息类型使用方法包括:

<-- 方式1:直接序列化和反序列化 消息 -->
protocolBuffer.toByteArray();
// 序列化消息 并 返回1个包含它的原始字节的字节数组
protocolBuffer.parseFrom(byte[] data);
// 从1个字节数组 反序列化(解析) 消息


<-- 方式2:通过输入/ 输出流(如网络输出流) 序列化和反序列化消息 -->
protocolBuffer.writeTo(OutputStream output);
output.toByteArray();
// 将消息写入 输出流 ,然后再 序列化消息 


protocolBuffer.parseFrom(InputStream input);
// 从1个 输入流 读取并 反序列化(解析)消息




// 只含包含字段的getters方法
// required string name = 1;
public boolean hasName();// 如果字段被设置,则返回true
public java.lang.String getName();


// required int32 id = 2;
public boolean hasId();
public int getId();


// optional string email = 3;
public boolean hasEmail();
public String getEmail();


// repeated .tutorial.Person.PhoneNumber phone = 4;
// 重复(repeated)字段有1些额外方法
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
// 列表大小的速记
// 作用:通过索引获取和设置列表的特定元素的getters和setters

Builder类

作用:创建 消息构造器 & 设置/ 获取消息对象的字段值 & 创建 消息类 实例

a. 创建 消息构造器

Demo.Person.Builder person = Person.newBuilder();

b. 设置/ 获取 消息对象的字段值 具体方法如下:

// 标准的JavaBeans风格:含getters和setters
// required string name = 1;
public boolean hasName();// 如果字段被设置,则返回true
public java.lang.String getName();
public Builder setName(String value);
public Builder clearName(); // 将字段设置回它的空状态


// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();


// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();


// repeated .tutorial.Person.PhoneNumber phone = 4;
// 重复(repeated)字段有1些额外方法
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
// 列表大小的速记
// 作用:通过索引获取和设置列表的特定元素的getters和setters


public PhoneNumber getPhone(int index);
public Builder setPhone(int index, PhoneNumber value);


public Builder addPhone(PhoneNumber value);
// 将新元素添加到列表的末尾


public Builder addAllPhone(Iterable<PhoneNumber> value);
// 将1个装满元素的整个容器添加到列表中
public Builder clearPhone();


public Builder isInitialized() 
// 检查所有 required 字段 是否都已经被设置


public Builder toString() :
// 返回1个人类可读的消息表示(用于调试)


public Builder mergeFrom(Message other)
// 将 其他内容 合并到这个消息中,覆写单数的字段,附接重复的。


public Builder clear()
// 清空所有的元素为空状态。
 

5 优点

性能方面

在序列化结构化数据的机制中,ProtoBuf是灵活、高效、自动化的,相对常见的XML、JSON,描述同样的信息,ProtoBuf序列化后数据量更小 (在网络中传输消耗的网络流量更少)、序列化/反序列化速度更快、更简单。

1旦定义了要处理的数据的数据结构之后,就可利用ProtoBuf的代码生成工具生成相关的代码。只需使用 Protobuf 对数据结构进行1次描述,即可利用各种不同语言(proto3支持C++, Java, Python, Go, Ruby, Objective-C, C#)或从各种不同流中对的结构化数据轻松读写。

使用方面

使用简单:proto编译器自动进行序列化和反序列化
维护成本低:多平台只需维护1套对象协议文件,即.proto文件
可扩展性好:不必破坏旧的数据格式,就能对数据结构进行更新
加密性好:http传输内容抓包只能抓到字节数据

使用范围

跨平台、跨语言、可扩展性强

Protocol Buffer的序列化 & 反序列化简单 & 速度快的原因是:

a. 编码 / 解码 方式简单(只需简单的数学运算 = 位移等等)

b. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成

Protocol Buffer的数据压缩效果好(即序列化后的数据量体积小)的原因是:

a. 采用了独特的编码方式,如Varint、Zigzag编码方式等等

b. 采用T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑

具体使用

使用步骤如下:

**步骤1:**通过 消息类的内部类Builder类 构造 消息构造器

**步骤2:**通过 消息构造器 设置 消息字段的值

**步骤3:**通过 消息构造器 创建 消息类 对象

**步骤4:**序列化 / 反序列化 消息

具体使用如下:(注释非常清晰)

package com.chenj.protobuf;




import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;


public class TestProto {
    public static void main(String[] args) {
        // 步骤1:通过 消息类的内部类Builder类 构造 消息类的消息构造器
        Demo.Person.Builder personBuilder =  Demo.Person.newBuilder();


        // 步骤2:设置想要设置的字段为选择的值
        personBuilder.setName("Lisi");// 在定义.proto文件时,该字段的字段修饰符是required,所以必须赋值
        personBuilder.setId(123);// 在定义.proto文件时,该字段的字段修饰符是required,所以必须赋值
        personBuilder.setEmail("lisi.ho@foxmail.com"); // 在定义.proto文件时,该字段的字段修饰符是optional,所以可赋值 / 不赋值(不赋值时将使用默认值)


        Demo.Person.PhoneNumber.Builder phoneNumber =  Demo.Person.PhoneNumber.newBuilder();
        phoneNumber.setType( Demo.Person.PhoneType.HOME);// 直接采用枚举类型里的值进行赋值
        phoneNumber.setNumber("0157-23443276");
        // PhoneNumber消息是嵌套在Person消息里,可理解为内部类
        // 所以创建对象时要通过外部类来创建


        // 步骤3:通过 消息构造器 创建 消息类 对象
        Demo.Person person = personBuilder.build();


        // 步骤4:序列化和反序列化消息(2种方式)


        /*方式1:直接 序列化 和 反序列化 消息 */
        // a.序列化
        byte[] byteArray1 = person.toByteArray();
        // 把 person消息类对象 序列化为 byte[]字节数组
        System.out.println(Arrays.toString(byteArray1));
        // 查看序列化后的字节流


        // b.反序列化
        try {


            Demo.Person person_Request = Demo.Person.parseFrom(byteArray1);
            // 当接收到字节数组byte[] 反序列化为 person消息类对象


            System.out.println(person_Request.getName());
            System.out.println(person_Request.getId());
            System.out.println(person_Request.getEmail());
            // 输出反序列化后的消息
        } catch (IOException e) {
            e.printStackTrace();
        }




        /*方式2:通过输入/ 输出流(如网络输出流) 序列化和反序列化消息 */
        // a.序列化
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        try {


            person.writeTo(output);
            // 将消息序列化 并写入 输出流(此处用 ByteArrayOutputStream 代替)


        } catch (IOException e) {
            e.printStackTrace();
        }


        byte[] byteArray = output.toByteArray();
        // 通过 输出流 转化成2进制字节流


        // b. 反序列化
        ByteArrayInputStream input = new ByteArrayInputStream(byteArray);
        // 通过 输入流 接收消息流(此处用 ByteArrayInputStream 代替)


        try {


            Demo.Person person_Request = Demo.Person.parseFrom(input);
            // 通过输入流 反序列化 消息


            System.out.println(person_Request.getName());
            System.out.println(person_Request.getId());
            System.out.println(person_Request.getEmail());
            // 输出消息
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Protobuf 转 Java 文件

首先要明白1点:Protobuf 是1种与平台,语言无关的数据存储格式,因此要在其它语言如:Java,Kotlin,Dart 等语言中使用它,则必须将 Protobuf 文件转换为对应平台的语言文件去使用。

这里以转 Java 文件为例,介绍2种转换的方式:

1)集成 Protobuf 插件转换;

2)使用 protoc 命令行转换。

在使用这2种方式转换前,需要先集成 protobuf-java 这个第三方库,因为转换的 Java 文件中会使用这个库里面的功能。

在Android Studio的工作配置中集成 protobuf-java:

implementation 'com.google.protobuf:protobuf-java:3.19.2'

最新版本可查看此链接:github.com/protocolbuf…

集成 Protobuf 插件转换

使用 protoc 命令行转换

windows

protoc --java_out=${"要生成的 Java 文件目录"} ${"Protobuf 文件位置"}


protoc --java_out=./app/src/main/java ./app/src/main/proto/student.proto

linux

export LD_LIBRARY_PATH=/work/oppo_80300461/old_data/protobuf/protobuf-21.12/src/.libs

./protoc-c -I=./ --c_out=./ ./netlink_msg.proto

protoc-c --c_out=. netlink_msg.proto

./protoc-c用的还是你当前目录下的protoc-c,这个不是最新的版本。最新的protoc-c我已经给你安装到/usr/local/bin/protoc-c,你在任何目录都可以直接用protoc-c -I=./ --c_out=xxxx

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib

生成的 Java 文件介绍

Protobuf 的使用

基本调用

fun main(){
    //构建 Protobuf 对象
    val student = StudentOuterClass.Student.newBuilder()
        .setName("erdai")
        .setAge(18)
        .setEmail("erdai666@qq.com")
        .addAllCourse(mutableListOf("Math", "English", "Computer"))
        .build()
    println(student)
    println()
    println(StudentOuterClass.Weather.Season.SPRING.number)
    println(StudentOuterClass.Weather.Season.SUMMER.number)
    println(StudentOuterClass.Weather.Season.AUTUMN.number)
    println(StudentOuterClass.Weather.Season.WINTER.number)
}

序列化和反序列化

fun main(){
    //1、构建 Protobuf 对象
    val student = StudentOuterClass.Student.newBuilder()
        .setName("erdai")
        .setAge(18)
        .setEmail("erdai666@qq.com")
        .addAllCourse(mutableListOf("Math", "English", "Computer"))
        .build()
    //2、序列化并返回1个包含其原始字节的字节数组
    val byteArray: ByteArray = student.toByteArray()
    //3、反序列化从字节数组中解析消息
    val parseStudent: StudentOuterClass.Student = StudentOuterClass.Student.parseFrom(byteArray)
}

传输二维数组

message subArg {
    repeated string arg1 = 1;
    repeated string arg2 = 2;
}
message Arg {
    subArg arg = 1;
}

参考

www.jianshu.com/p/1538bf85d…

www.jianshu.com/p/e06ba6249…

blog.csdn.net/carson_ho/a…

blog.csdn.net/MarkusZhang…

zhuanlan.zhihu.com/p/434289660

官方文档:protobuf.dev/

Java 中使用 protobuf :复杂深入篇,看这篇就够了!

blog.csdn.net/wxw1997a/ar…