ProtoBuf原理与使用

758 阅读8分钟

1. ProtoBuf 简介

ProtoBuf(Protocol buffers)是Google的无关语言、无关平台、可扩展的序列化数据格式——相比XML,更小、更快、更简单。一旦定义一个ProtoBuf数据结构之后,可利用生成的各种不同语言的代码从各种不同数据流中对结构化数据轻松读写。

扩展阅读:

官网:developers.google.com/protocol-bu…

git: github.com/protocolbuf…

2. ProtoBuf 产生

大家可能会觉得 Google 发明 ProtoBuf 是为了解决序列化速度的,其实真实的原因并不是这样的。

ProtoBuf最先开始是 Google用来解决索引服务器 request/response 协议的。没有ProtoBuf之前,Google 已经存在了一种 request/response 格式,用于手动处理 request/response 的编解码。它也能支持多版本协议,不过代码不够优雅:

if (protocolVersion=1) {
    doSomething();
} else if (protocolVersion=2) {
    doOtherThing();
} ...

如果是非常明确的格式化协议,会使新协议变得非常复杂。因为开发人员必须确保请求发起者与处理请求的实际服务器之间的所有服务器都能理解新协议,然后才能切换开关以开始使用新协议。

这也就是每个服务器开发人员都遇到过的低版本兼容、新旧协议兼容相关的问题。

为了解决这些问题,于是ProtoBuf就诞生了。

ProtoBuf 最初被寄予以下 2 个特点:

  • 更容易引入新的字段,并且不需要检查数据的中间服务器可以简单地解析并传递数据,而无需了解所有字段。
  • 数据格式更加具有自我描述性,可以用各种语言来处理(C++, Java 等各种语言)。

这个版本的 ProtoBuf 仍需要自己手写解析的代码。

不过随着系统慢慢发展,演进,ProtoBuf具有了更多的特性:

  • 自动生成的序列化和反序列化代码避免了手动解析的需要。(官方提供自动生成代码工具,各个语言平台的基本都有)。
  • 除了用于数据交换之外,ProtoBuf被用作持久化数据的便捷自描述格式。

ProtoBuf 现在是 Google 用于数据交换和存储的通用语言。谷歌代码树中定义了 48162 种不同的消息类型,包括 12183 个 .proto 文件。它们既用于 RPC 系统,也用于在各种存储系统中持久存储数据。

ProtoBuf 诞生之初是为了解决服务器端新旧协议(高低版本)兼容性问题,名字也很体贴,“协议缓冲区”。只不过后期慢慢发展成用于传输数据。

3. ProtoBuf 工作原理

3.1 创建 .proto 文件,定义数据结构

通过在 .proto 文件中定义 protocol buffer message 类型,来指定你想如何对序列化信息进行结构化。每一个 protocol buffer message 是一个信息的小逻辑记录,包含了一系列的 name-value 对。这里有一个非常基础的 .proto 文件样例,它定义了一个包含 "person" 相关信息的 message:

syntax = "proto3";
package com.bnc.protobuf.bean;
message Personal {
	optional string name = 1;
	optional int32 id = 2;
	optional string email = 3;
	enum PhoneType {
		MOBILE = 0;
		HOME = 1;
		WORK = 2;
	}
	
	message PhoneNumber {
		optional string number = 1;
		optional PhoneType type = 2;
	}
	repeated PhoneNumber phone = 4;
}

message 格式很简单 - 每种 message 类型都有一个或多个具有唯一编号的字段,每个字段都有一个名称和一个值类型,其中值类型可以是数字(整数或浮点数),布尔值,字符串,原始字节,甚至(如上例所示)其它 protocol buffer message 类型,这意味着允许你分层次地构建数据。

  • syntax: 指定proto的版本,protobuf目前有proto2和proto3两个常用版本,如果没有声明,则默认是proto2.

  • package: 指定包名。

  • import: 导入包,类似于java的import.

  • java_package: 指定生成类所在的包名

  • java_outer_classname: 定义当前文件的类名,如果没有定义,则默认为文件的首字母大写名称

  • message: 定义类,类似于java class;可以嵌套

  • repeated: 字段可以有多个内容(包括0),类似于array

ProtoBuf类型映射表:类型参照表

3.2 下载与安装编译器

一旦定义了 messages,就可以在 .proto 文件上运行 protocol buffer 编译器来生成指定语言的数据访问类。这些类为每个字段提供了简单的访问器(如 name()和 set_name()),以及将整个结构序列化为原始字节和解析原始字节的方法 - 例如,如果你选择的语言是 C++,则运行编译器上面的例子将生成一个名为 Person 的类。然后,你可以在应用程序中使用此类来填充,序列化和检索 Person 的 messages。

下载地址

protobuf的编译器叫protoc,在上面的网址中找到最新版本的安装包,下载安装,设置环境变量。

打开cmd,命令窗口执行protoc命令,没有报错的话,就已经安装成功。 image.png

3.3 将.proto文件,编译成指定语言类库

protoc编译器支持将proto文件编译成多种语言版本的代码,我们这里以kotlin为例。

切换到proto文件所在的目录, 执行下面命令

protoc --java_out=./output/java --kotlin_out=./output/kotlin Personal.proto

注意--java_out--kotlin_out都是需要的,当前版本的 protoc 对 Kotlin 的支持是在原来 Java 生成的基础上多了 Kotlin 的增强

更多命令行

官方支持语言

第三方支持语言

3.4 在代码中使用ProtoBuf对数据进行序列化和反序列化

Gradle引入:

implementation("com.google.protobuf:protobuf-kotlin:3.21.9")

序列化与反序列化demo

val personal = personal {
    id = 1
    name = "PengZhiming"
    email = "pengzm@akulaku.com"
    phone += phoneNumber {
        type = PersonProto.Personal.PhoneType.MOBILE
        number = "15013589848"
    }
}
val bytes = personal.toByteArray()
println("bytes:${bytes.toByteString()}")
val personal2 = PersonProto.Personal.newBuilder().mergeFrom(bytes).build()
println("personal2:${personal2}")

输出

bytes:<ByteString@5c5a1b69 size=52 contents="\n\vPengZhiming\020\001\032\022pengzm@akulaku.com\"\017\n\v15013589...">
personal2:name: "PengZhiming"
id: 1
email: "pengzm@akulaku.com"
phone {
  number: "15013589848"
  type: MOBILE
}

ProtoBuf 协议的工作流程

image.png

4. ProtoBuf 编码原理

ProtoBuf的数据组成方式为TLV,TLV编码,即 Tag - Length - Value。Tag 作为该字段的唯一标识,Length 代表 Value 数据域的长度,最后的 Value 便是数据本身。数据结构图如下:

6009978-fa1925e9b2c985e3.png

特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式。依据Tag确定,例如int类型的数据就只有Tag-Value,string类型的数据就必须是Tag-Length-Value

如果没有了 Length 我们该如何确定 Value 的边界?答案就是 Varint 编码

Tag 由 field_numberwire_type 两个部分组成:

6009978-9ac80dc61783d5eb.png

  • field_number: message 定义字段时指定的字段编号
  • wire_type: ProtoBuf 编码类型,根据这个类型选择不同的 Value 编码方案。
  • (field_number << 3) | wire_type:其中Tag块的后3位表示数据类型,其他位表示数据编号

3 bit 的 wire_type 最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下图所示:

6009978-1f5a226beed30152.png 第一列即是对应的类型编号,第二列为面向最终编码的编码类型,第三列是面向开发者的 message 字段的类型。

另外要特别注意一点,虽然 wire_type 代表编码类型,但是 Varint 这个编码类型里针对 sint32、sint64 又会有一些特别编码(ZigTag 编码)处理,相当于 Varint 这个编码类型里又存在两种不同编码。

现在我们可以理解一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:

  • Tag - Length - Value:编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构,
  • Tag - Value:编码类型表中 Varint、64-bit、32-bit 使用这种结构。

其中 Tag 由字段编号 field_number 和 编码类型 wire_type 组成, Tag 整体采用 Varints 编码。

详细编码,请阅读www.jianshu.com/p/73c9ed3a4…

上面介绍了Protocol Buffer的原理,可以看出Protocol Buffer更快,更小,总结如下:

  1. 序列化的时候,不序列化key的name,只序列化key的编号
  2. 序列化的时候,没有赋值的key,不参与序列化,反序列化的时候直接使用默认值填充
  3. 可变长度编码,减小字节占用
  4. TLV编码,去除没有的符号,使数据更加紧凑。

5. ProtoBuf 插件使用

IDEA/Android Studio下protobuf-gradle-plugin 插件配置方法 , 参考 github.com/google/prot… 项目主页的 MarkDown 文档;

build.gradle中配置如下,插件增加配置:

plugins {
    id "com.google.protobuf" version "0.9.1"
}

配置proto协议文件路径:

sourceSets {
    main {
        proto {
            // proto源文件路径
            srcDir 'src/main/protobuf'
        }
    }
}

protobuf节点配置:

protobuf {
    // 配置protoc编译版本
    protoc {
        // Download from repositories
        artifact = 'com.google.protobuf:protoc:3.21.9'
    }
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                // 增加对kotlin的支持
                kotlin {
                    option "lite"
                }
            }
        }
    }
}

编译后生成java和kotlin语言的源码

image.png

6. ProtocolBuf Any的使用

对于公共网络response的返回结构体如下:

{
	"data": {},
	"success": true,
	"code": "",
	"errMsg": ""
}

data为具体的业务请求所返回的响应结果。如果按照之前的介绍,可能每一个接口都需要对应一个带有code的proto文件,对应的每个Response都带有code,能不能实现泛型呢?答案是肯定的。

定义全局公用proto文件如下:

syntax = "proto3";
//包名
package com.bnc.http.bean;
//重命名,如果不写,默认为文件名的首字母大写转化生成,如本文件如果不写则是Person
option java_outer_classname = "ResponseProto";
//需要导入any.proto文件才可实现泛型
import "google/protobuf/any.proto";
// 公用响应体
message BaseResponse{
  optional google.protobuf.Any data = 1;
  optional bool success = 2;
  optional string code = 3;
  optional string errMsg = 4;
}

假设data为Personal对象,如下进行组装data序列化与反序列化。

// 组装与序列化response
val personal = personal {
    id = 1
    name = "PengZhiming"
    email = "pengzm@akulaku.com"
    phone += phoneNumber {
        type = PersonProto.Personal.PhoneType.MOBILE
        number = "15013589848"
    }
}
val response = baseResponse {
    success = true
    // 核心是打包data数据
    data = com.google.protobuf.Any.pack(personal)
}
val bytes = response.toByteArray()
// 反序列化
val response2 = BaseResponse.newBuilder().mergeFrom(bytes).build()
println("response2:\n$response2")
// 类型转换
val personal2 = response2.data.unpack(Personal::class.java)
println("personal2:\n$personal2")

序列化时,使用com.google.protobuf.Any.pack(personal)打包data; 反序列化时,使用com.google.protobuf.Any.unpcka(Personal::class.java)进行反序列化成具体对象。

参考阅读:juejin.cn/post/684490…