这一系列文章主要是对protocol buffer这种编码格式的使用方式、特点、使用技巧和编码原理进行说明
1.什么是protobuf
protocol buffer是由google推出一种数据编码格式,不依赖平台和语言,类似于xml和json。然而与xml和json最大的不同之处在于,protobuf并非是一种可以完全自解释的编码格式,这点在之后会有说明。
2.为什么要使用protobuf
和json或者xml相比,protocol buffer的解析速度更快,编码后的字节数更少。
另外,比json和xml更便利的是,开发者只需要编写一份.proto的描述文件,就可以通过google提供的编译器生成不同平台的模型代码,包括java、C#等等,而不需要手动进行模型编写。
本文后续的示例都是采用java进行展示。
3.如何使用
首先我们需要下载一个google提供的编译器,下载地址:
选择自己的系统下载相应的zip包
解压后就能看到看到一个protoc的执行文件,即是我们所需要的编译器。
接着我们需要定义一份BasicUsage.proto的描述文件,其结构和我们定义普通的类十分类似。
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "BasicUsage";
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
第一行表示所使用的的语法版本,这里选择的是最新的proto3版本。
syntax = "proto3";
第三、四行表示最终生成的java的package名和外部class的类名(这里外部class的意思之后会有代码解释)。
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "BasicUsage";
之后紧接着的就是我们所定义的模型,其中大部分都是我们所熟悉的内容。
这里需要特别注意,特别注意,特别注意的是,在字段的后面都跟着一个"= X",这里并不是指这个字段的值,而是表示这个字段的“序号”,和正确地编码与解码息息相关,在我看来是protocol buffer的灵魂,之后会有详细的说明
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
有了编译器和.poto描述文件,我们就可以生成java模型文件了
编译指令
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/BasicUsage.proto
-I :表示工作目录,如果不指定,则就是当前目录
--java_out:表示输出.java文件的目录
这里我比较习惯将.proto文件放到java项目中,并且将.java文件直接生成到相应的package文件夹中,即前文的java_package参数,这样在使用的时候就可以不用再手动复制文件了
protoc -I=/protocol_buffer/protobuf/proto --java_out=/protocol_buffer/protobuf/src/main/java/ /protocol_buffer/protobuf/proto/BasicUsage.proto
项目的目录结构如下图,其中BasicUsage的class文件就是生成出来的
以上都是准备工作,接着我们就要进入代码相关部分
引入maven依赖
<!--这部分是protobuf的基本库-->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.9.1</version>
</dependency>
<!--这部分是protobuf和json相关的库,这里一并导入,后面会用到-->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>3.9.1</version>
</dependency>
接着我们创建一个Test方法
/**
* protobuf的基础使用
*/
@Test
void basicUse() {
//创建一个Person对象
BasicUsage.Person person = BasicUsage.Person.newBuilder()
.setId(5)
.setName("tera")
.setEmail("tera@google.com")
.build();
System.out.println("Person's name is " + person.getName());
//编码
//此时我们就可以通过我们想要的方式传递该byte数组了
byte[] bytes = person.toByteArray();
//将编码重新转换回Person对象
BasicUsage.Person clone = null;
try {
//解码
clone = BasicUsage.Person.parseFrom(bytes);
System.out.println("The clone's name is " + clone.getName());
} catch (InvalidProtocolBufferException e) {
}
//引用是不同的
System.out.println("==:" + (person == clone));
//equals方法经过了重写,所以equals是相同的
System.out.println("equals:" + person.equals(clone));
//修改clone中的值
clone = clone.toBuilder().setName("clone").build();
System.out.println("The clone's new name is " + clone.getName());
}
在Test方法中,我们可以看到,访问Person类是需要通过BasicUsage.Person进行访问,这就是我们前面在定义.proto文件时指定的java_outer_classname参数
因为在一个.proto文件中,我们可以定义多个类,而多个.proto文件也可以定义相同的类名,因此用这个java_outer_classname进行区分,可以认为是.proto的package名
这里需要注意几个点:
protobuf的对象的实例化和赋值必须通过newBuilder()返回的Builder对象进行,实例化最终对象需要通过build()方法。
BasicUsage.Person person = BasicUsage.Person.newBuilder()
.setId(5)
.setName("tera")
.setEmail("tera@google.com")
.build();
对象实例化完成之后就只能调用get方法而无法set,如果需要set值,则必须将其转换回Builder对象才行。
clone = clone.toBuilder().setName("clone").build();
而对象的编码和解码,则分别通过toByteArray()方法和parseFrom()方法 。
byte[] bytes = person.toByteArray();
...
BasicUsage.Person.parseFrom(bytes);
以上就是protocol buffer的基本使用方式,其实除了赋值比较麻烦意外,其他操作都很方便(如果我们需要在普通的模型中实现.setXX().setYY()这种连续操作,还得另外加个注解呢),特别是对于需要深度clone的对象,protocol buffer也是一个很好的选择,可以避免很多clone引用的问题。
4.protocol buffer模型解析
当然,了解了基础使用,源码的研究自然也是不能少的,不过遵照着循序渐进的原则,我们先看下生成的模型文件中有些什么
查看Person的类,此时的你是不是吓了一跳,这么简单的一个类的代码竟然有这么多!为了不凑字数,我这里就不贴全了,有兴趣的同学自己去生成一个看看全貌,总计836行代码
下面主要看下几个主要部分
1).BasicUsage
主类名是BasicUsage,其余所有的类都作为了该主类的内部类,所以访问Person时,需要通过BasicUsage.Person访问
public final class BasicUsage {
...
}
2).PersonOrBuilder接口
PersonOrBuilder接口,定义了Person对象所有字段的get方法以及其对应的字节的get方法
public interface PersonOrBuilder extends
// @@protoc_insertion_point(interface_extends:Person)
com.google.protobuf.MessageOrBuilder {
java.lang.String getName();
com.google.protobuf.ByteString getNameBytes();
int getId();
java.lang.String getEmail();
com.google.protobuf.ByteString getEmailBytes();
}
3).Person类
Person对象是实现了PersonOrBuilder接口的,因此Person只能get而不能set了
public static final class Person extends
com.google.protobuf.GeneratedMessageV3 implements
PersonOrBuilder {
...
}
Person类没有public的构造函数,只有3个private的构造函数,因此在外部代码中是不能直接创建Person对象的
3个构造函数分为接受Builder对象、构造空对象、接受CodeInputStream对象
其中Builder对象正是之前提到过的,用于通过Builder创建Person
而CodeInputStream则是指字节数组,则是用于从byte[]中解码出对象
这2个构造函数在后文中都可以看到使用场景
private Person(com.google.protobuf.GeneratedMessageV3.Builder<?> builder) {
super(builder);
}
private Person() {
name_ = "";
email_ = "";
}
private Person(
com.google.protobuf.CodedInputStream input,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws com.google.protobuf.InvalidProtocolBufferException {
...
}
查看Person的getName方法,可以看到在这里,name_是一个Object而不是String,在取值的时候需要做一个类型判断
这么实现的原因在于,因为对象是可以通过byte[]数组解码的,而byte[]数组的内容是不可控的、灵活可变的,为了尽量兼容这些情况,所以才会如此处理,这个问题后文会给出一些示例
@java.lang.Override
public java.lang.String getName() {
java.lang.Object ref = name_;
if (ref instanceof java.lang.String) {
return (java.lang.String) ref;
} else {
com.google.protobuf.ByteString bs =
(com.google.protobuf.ByteString) ref;
java.lang.String s = bs.toStringUtf8();
name_ = s;
return s;
}
}
查看equals和hashcode方法,可以看到根据对象字段的内容进行了相应的重写,因此在之前的基本使用示例中,equals方法会返回true
@java.lang.Override
public boolean equals(final java.lang.Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof cn.tera.protobuf.model.BasicUsage.Person)) {
return super.equals(obj);
}
cn.tera.protobuf.model.BasicUsage.Person other = (cn.tera.protobuf.model.BasicUsage.Person) obj;
if (!getName()
.equals(other.getName())) return false;
if (getId()
!= other.getId()) return false;
if (!getEmail()
.equals(other.getEmail())) return false;
if (!unknownFields.equals(other.unknownFields)) return false;
return true;
}
@java.lang.Override
public int hashCode() {
if (memoizedHashCode != 0) {
return memoizedHashCode;
}
int hash = 41;
hash = (19 * hash) + getDescriptor().hashCode();
hash = (37 * hash) + NAME_FIELD_NUMBER;
hash = (53 * hash) + getName().hashCode();
hash = (37 * hash) + ID_FIELD_NUMBER;
hash = (53 * hash) + getId();
hash = (37 * hash) + EMAIL_FIELD_NUMBER;
hash = (53 * hash) + getEmail().hashCode();
hash = (29 * hash) + unknownFields.hashCode();
memoizedHashCode = hash;
return hash;
}
查看Person的toByteArray()方法,可以看到这个方法是在AbstractMessageLite的类中,这是所有Protobuf生成对象的父类中的方法
public byte[] toByteArray() {
try {
byte[] result = new byte[this.getSerializedSize()];
CodedOutputStream output = CodedOutputStream.newInstance(result);
this.writeTo(output);
output.checkNoSpaceLeft();
return result;
} catch (IOException var3) {
throw new RuntimeException(this.getSerializingExceptionMessage("byte array"), var3);
}
}
此时查看Person类中的this.writeTo方法,可以看到正是在这个方法中写入了3个字段的数据,这些方法的细节我们需要放到之后详细分析,因为涉及到了protobuf的编码原理等内容
@java.lang.Override
public void writeTo(com.google.protobuf.CodedOutputStream output)
throws java.io.IOException {
if (!getNameBytes().isEmpty()) {
com.google.protobuf.GeneratedMessageV3.writeString(output, 1, name_);
}
if (id_ != 0) {
output.writeInt32(2, id_);
}
if (!getEmailBytes().isEmpty()) {
com.google.protobuf.GeneratedMessageV3.writeString(output, 3, email_);
}
unknownFields.writeTo(output);
}
对于Person类,我们最后再看一下parseFrom方法,这个方法有很多的重载,然而本质都是一样的,通过*PARSER*去处理数据,这里我就不全贴出来了
public static cn.tera.protobuf.model.BasicUsage.Person parseFrom(byte[] data)
throws com.google.protobuf.InvalidProtocolBufferException {
return PARSER.parseFrom(data);
}
查看PARSER对象,这里正是会调用Person的接受Stream参数的构造函数,和前文对应
private static final com.google.protobuf.Parser<Person>
PARSER = new com.google.protobuf.AbstractParser<Person>() {
@java.lang.Override
public Person parsePartialFrom(
com.google.protobuf.CodedInputStream input,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws com.google.protobuf.InvalidProtocolBufferException {
return new Person(input, extensionRegistry);
}
};
4).Builder类
Builder类为Person的内部类,一样实现了PersonOrBuilder接口,不过额外定义了set的方法
public static final class Builder extends
com.google.protobuf.GeneratedMessageV3.Builder<Builder> implements
// @@protoc_insertion_point(builder_implements:Person)
cn.tera.protobuf.model.BasicUsage.PersonOrBuilder {
...
}
这里的get方法的逻辑和Person类一样,不过特别注意的是,这里的name_和Person的getName方法中的name_不是同一个对象,而是分别属于Builder类和Person类的private字段
public java.lang.String getName() {
java.lang.Object ref = name_;
if (!(ref instanceof java.lang.String)) {
com.google.protobuf.ByteString bs =
(com.google.protobuf.ByteString) ref;
java.lang.String s = bs.toStringUtf8();
name_ = s;
return s;
} else {
return (java.lang.String) ref;
}
}
查看set方法,比较简单,就是一个直接的赋值操作
public Builder setName(
java.lang.String value) {
if (value == null) {
throw new NullPointerException();
}
name_ = value;
onChanged();
return this;
}
最后,我们来看下Builder的build方法,这里调用了buildPartial方法
@java.lang.Override
public cn.tera.protobuf.model.BasicUsage.Person build() {
cn.tera.protobuf.model.BasicUsage.Person result = buildPartial();
if (!result.isInitialized()) {
throw newUninitializedMessageException(result);
}
return result;
}
查看buildPartial方法,可以看到这里调用了Person获取builder参数的构造函数,和前文对应
构造完成后,将Builder中的各种字段赋值给Person中的相应字段,即完成了构造
@java.lang.Override
public cn.tera.protobuf.model.BasicUsage.Person buildPartial() {
cn.tera.protobuf.model.BasicUsage.Person result = new cn.tera.protobuf.model.BasicUsage.Person(this);
result.name_ = name_;
result.id_ = id_;
result.email_ = email_;
onBuilt();
return result;
}
总结一下:
1.protocol buffer需要定义.proto描述文件,然后通过google提供的编译器生成特定的模型文件,之后就可以作为正常的java对象使用了
2.不可以直接创建对象,需要通过Builder进行
3.只有Builder才可以进行set
4.可以通过对象的toByteArray()和parseFrom()方法进行编码和解码
5.模型文件很大(至少在java这里是如此),其中所有的代码都是定制的,这其实是它很大的缺点之一
接着我们将继续深入探究protobuf的编码原理。
主要分为两个部分
第一部分是之前留下的几个伏笔展示protobuf的使用特性
第二部分是分析protobuf的编码原理,解释特性背后的原因
第一部分,Protobuf使用特性
1.不同类型对象的转换
我们先定义如下一个.proto文件
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "DifferentModels";
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
message Article {
string title = 1;
int32 wordsCount = 2;
string author = 3;
}
其中我们定义了2个模型,一个Person,一个Article,虽然他们的字段名字不相同,但是类型和编号都是一致的
接着我们生成.java文件,最终文件结构如下图
此时我们尝试做如下的一个转换
/**
* 测试不同模型间的转换
* @throws Exception
*/
@Test
public void parseDifferentModelsTest() throws Exception {
//创建一个Person对象
DifferentModels.Person person = DifferentModels.Person.newBuilder()
.setName("person name")
.setId(1)
.setEmail("tera@google.com")
.build();
//对person编码
byte[] personBytes = person.toByteArray();
//将编码后的数据直接merge成Article对象
DifferentModels.Article article = DifferentModels.Article.parseFrom(personBytes);
System.out.println("article's title:" + article.getTitle());
System.out.println("article's wordsCount:" + article.getWordsCount());
System.out.println("article's author:" + article.getAuthor());
}
输出结果如下
article's title:person name
article's wordsCount:1
article's author:tera@google.com
可以看到,虽然jsonBytes是由person对象编码得到的,但是可以用于article对象的解码,不但不会报错,所有的数据内容都是完整保留的
这种兼容性的前提是模型中所定义的字段类型和序号都是一一对应相同的
在平时的编码中,我们经常会遇到从数据库中读取数据模型,然后将其转换成业务模型,而很多时候,这2种模型的内容其实是完全一致的,此时我们也许就可以使用protobuf的这种特性,就可以省去很多低效的赋值代码
2.protobuf序号的重要性
我们看到在定义.proto文件时,字段后面会跟着一个"= X",这里并不是指这个字段的值,而是表示这个字段的“序号”,和正确地编码与解码息息相关,在我看来是protocol buffer的灵魂
我们定义如下的.proto文件,这里注意,Model1和Model2的name和id的序号有不同
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "TagImportance";
message Model1 {
string name = 1;
int32 id = 2;
string email = 3;
}
message Model2 {
string name = 2;
int32 id = 1;
string email = 3;
}
定义如下的测试方法
/**
* 序号的重要性测试
*
* @throws Exception
*/
@Test
public void tagImportanceTest() throws Exception {
TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
.setEmail("model1@google.com")
.setId(1)
.setName("model1")
.build();
TagImportance.Model2 model2 = TagImportance.Model2.parseFrom(model1.toByteArray());
System.out.println("model2 email:" + model2.getEmail());
System.out.println("model2 id:" + model2.getId());
System.out.println("model2 name:" + model2.getName());
System.out.println("-------model2 数据---------");
System.out.println(model2);
}
输出结果如下
model2 email:model1@google.com
model2 id:0
model2 name:
-------model2 数据---------
email: "model1@google.com"
1: "model1"
2: 1
可以看到,虽然Model1和Model2定义的字段类型和名字都是相同的,然而name和id的序号颠倒了一下,导致最终model2在解析byte数组时,无法正确将数据解析到对应的字段上,所以输出的id为0,而name字段为null
不过即使字段无法一一对应,但在输出model2.toString()时,我们依然可以看到数据是被解析到了,只不过无法对应到具体字段,只能用1,2来表示其字段名
3.protobuf序号对编码结果大小的影响
protobuf的序号不仅影响编码、解码的正确性,一定程度上还会影响编码结果的字节数
我们在上面的.proto文件中增加一个Model3,其中Model3中定义的字段没有变化,但是序号更改为16,17,18
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "TagImportance";
message Model1 {
string name = 1;
int32 id = 2;
string email = 3;
}
message Model2 {
string name = 2;
int32 id = 1;
string email = 3;
}
message Model3 {
string name = 16;
int32 id = 17;
string email = 18;
}
测试方法
/**
* 序号对编码大小的影响
*
* @throws Exception
*/
@Test
public void tagSizeInfluenceTest() throws Exception {
TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
.setEmail("model1@google.com")
.setId(1)
.setName("model1")
.build();
System.out.println("model1 编码大小:" + model1.toByteArray().length);
TagImportance.Model3 model3 = TagImportance.Model3.newBuilder()
.setEmail("model1@google.com")
.setId(1)
.setName("model1")
.build();
System.out.println("model3 编码大小:" + model3.toByteArray().length);
}
输出结果如下
model1 编码大小:29
model3 编码大小:32
可以看到,在数据量完全相同的情况下,编号偏大的对象编码的结果也会偏大
4.模型字段数据类型兼容性
之前我在getName()方法中提到了灵活性,接下去就展示一下该特性
我们定义如下的.proto文件
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "ModelTypeCompatible";
message OldPerson {
string name = 1;
int32 id = 2;
string email = 3;
}
message NewPerson {
Name name = 1;
int32 id = 2;
string email = 3;
}
message Name {
string first = 1;
string last = 2;
int32 usedYears = 3;
}
其中定义了2个Person对象
在OldPerson中,name是一个纯String
在NewPerson中,name字段则被定义为了一个对象
此时我们做如下的操作
/**
* 模型字段不同类型的兼容性
*
* @throws Exception
*/
@Test
public void typeCompatibleTest() throws Exception {
ModelTypeCompatible.NewPerson newPerson = ModelTypeCompatible.NewPerson.newBuilder()
.setName(ModelTypeCompatible.Name.newBuilder()
.setFirst("tera")
.setLast("cn")
.setUsedYears(10)
).setId(5)
.setEmail("tera@google.com")
.build();
ModelTypeCompatible.OldPerson oldPerson = ModelTypeCompatible.OldPerson.parseFrom(newPerson.toByteArray());
System.out.println(oldPerson.getName());
}
输出结果如下
tera cn
可以看到,虽然NewPerson的name字段是一个对象,但是却可以被成功地转换成OldPerson的String类型的name字段,虽然其中的usedYears字段被舍弃了
这种兼容性的前提是从对象类型向String类型转换,而反向是不可以的
5.protobuf与json之间的转换和对比
json是现在应用最为广泛的数据结构之一,因此当我们决定使用protobuf时,不可避免的问题就是它和json的兼容性
因此接下去我们看下protobuf和json之间是如何转换的
我们先构造一个简单的java类
public class PersonJson {
public String name;
public int id;
public String email;
}
重复利用之前生成的protobuf模型BasicUsage.Person,以及前文就引入的json相关的maven,我们测试如下方法
/**
* json和protobuf的互相转换
*/
@Test
void jsonToProtobuf() throws Exception {
//构造简单的模型
PersonJson model = new PersonJson();
model.email = "personJson@google.com";
model.id = 1;
model.name = "personJson";
String json = JSON.toJSONString(model);
System.out.println("原始json");
System.out.println("------------------------");
System.out.println(json);
System.out.println();
//parser
JsonFormat.Parser parser = JsonFormat.parser();
//需要build才能转换
BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
//将json字符串转换成protobuf模型,并打印
parser.merge(json, personBuilder);
BasicUsage.Person person = personBuilder.build();
//需要注意的是,protobuf的toString方法并不会自动转换成json,而是以更简单的方式呈现,所以一般没法直接用
System.out.println("protobuf内容");
System.out.println("------------------------");
System.out.println(person.toString());
//修改protobuf模型中的字段,并再转换会json字符串
person = person.toBuilder().setName("protobuf").setId(2).build();
String buftoJson = JsonFormat.printer().print(person);
System.out.println("protobuf修改过数据后的json");
System.out.println("------------------------");
System.out.println(buftoJson);
}
输出结果如下
原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}
protobuf内容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"
protobuf修改过数据后的json
------------------------
{
"name": "protobuf",
"id": 2,
"email": "personJson@google.com"
}
可以看到json和protobuf是可以做到完全兼容的互相转换
此时我们就可以比较一下,相容的数据内容经过json和protobuf分别编码后的数据字节大小,我们就使用上面的数据内容,做如下的测试
/**
* json和protobuf的编码数据大小
*/
@Test
void codeSizeJsonVsProtobuf() throws Exception {
//构造简单的模型
PersonJson model = new PersonJson();
model.email = "personJson@google.com";
model.id = 1;
model.name = "personJson";
String json = JSON.toJSONString(model);
System.out.println("原始json");
System.out.println("------------------------");
System.out.println(json);
System.out.println("json编码后的字节数:" + json.getBytes("utf-8").length + "\n");
//parser
JsonFormat.Parser parser = JsonFormat.parser();
//需要build才能转换
BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
//将json字符串转换成protobuf模型,并打印
parser.merge(json, personBuilder);
BasicUsage.Person person = personBuilder.build();
//需要注意的是,protobuf的toString方法并不会自动转换成json,而是以更简单的方式呈现,所以一般没法直接用
System.out.println("protobuf内容");
System.out.println("------------------------");
System.out.println(person.toString());
System.out.println("protobuf编码后的字节数:" + person.toByteArray().length);
}
输出内容如下
原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}
json编码后的字节数:60
protobuf内容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"
protobuf编码后的字节数:37
可以看到,相同的数据内容,protobuf编码的结果是json编码结果的60%左右(当然这个数值是会随着数据内容的不同浮动)
这里先总结一下之前的特性
1.protobuf的解码不需要类型相同,也不需要字段名相同
2.protobuf的解码依赖于序号的正确性
3.protobuf中的序号大小会影响最终编码大小
4.protobuf的对象类型可以向String类型兼容
5.protobuf可以和json完全兼容,且编码大小要比json小
第二部分,Protobuf编码原理
首先,我们需要了解一种最基本的编码方式varints(原文档的单词,没有找到特别准确的翻译,所以就就保留英文),这是一种用1个或多个字节对Integer进行编码的方法
当一个Integer采用这种方式编码后,除了最后一个字节,每一个字节的最高位都是1,而最后一个字节的最高位则是0,从而在解码的时候可以通过判断最高位的值来确定是否已经解码到了最后一个字节。
每一个字节除了最高位的其他7个bit则用来存放数字本身的编码
例如300,编码后得到2个字节,红色表示最高位bit,蓝色表示数字本身编码
1010 1100 0000 0010
其中第一个字节最高位bit为1,表示后面还有字节需要一并进行解码。第二个字节最高位bit为0,则表示已经到达最后一个字节了
解码时
1.去掉2个字节的最高位
010 1100 000 0010
2.反转2个字节的顺序
000 0010 010 1100
3.连接2个字节,构成了300的二进制形式
100101100
接着我们来看一个实际的例子,编码一个Person对象,只给里面的id字段赋值
/**
* varint数字编码
*/
@Test
void varintTest() {
BasicUsage.Person person = BasicUsage.Person.newBuilder()
.setId(91809)
.build();
Utility.printByte(person.toByteArray());
}
输出的编码结果如下
16 -95 -51 5
00010000 10100001 11001101 00000101
其中黄色部分即是91809的varints编码,我们来验证一下
红色表示最高位,蓝色表示数字本身编码,在读取该部分字节的时候是一个一个读取的
读取到第一个字节时,发现最高位是1,因此会继续读取第二个字节,第二个字节最高位也是1,因此继续读取第三个字节,而第三个字节最高位为0,从而结束读取,就处理这3个字节
10100001 11001101 00000101
1.去掉3个字节的最高位
0100001 1001101 0000101
2.反转3个字节的顺序
0000101 1001101 0100001
3.连接3个字节,构成了91809的二进制形式
10110011010100001
接着我们看person编码结果的第一个字节
16 -95 -51 5
00010000 10100001 11001101 00000101
这个字节表示的是数据的序号和类型,编码方式也是varient,因此我将其分为3个部分
00010000
红色0为最高位bit,表示是否解析到了本次varient的最后一个字节
中间蓝色的4个bit 0010表示序号,十进制2,即id的序号
最后3个黄色底的0为该字段的类型,000表示int32类型
此时一个最简单的protobuf的编码就解析完成了
到这里我们先总结一下protobuf编码的性质,将特别抽象的的内容转换成一个我们可以直觉理解的东西
先看原始数据,如果用json表示出来就是如下形式
{
"id": 91890
}
而protobuf编码后的数据格式如下
00010000 10100001 11001101 00000101
其中第一个字节表示序号和字段类型,即序号为2,类型为int的字段
后三个字节表示数据的值,值为91890
这时候就会有这样一个问题,那id这个字段名去哪儿了?
答案就是,id的字段名被protobuf舍弃了!
所以,protobuf最终的编码结果是抛弃了所有的字段名,仅仅保留了字段的序号、类型和数据的值。
因此在第一篇文章的开头,就提到protobuf并非是一种可以完全自解释的编码格式,意思就是如此。
也正因为如此,所以我也认为这个序号正是protobuf编码的灵魂所在
有了这个概念之后,我们就可以解释之前5个示例了
***示例1:***protobuf的解码不需要类型相同,也不需要字段名相同
因为protobuf编码后的结果根本就不包含类的信息,也不包含字段名的信息,因此解码的时候自然也就不依赖于类和字段名
***示例2:***protobuf的解码依赖于序号的正确性
因为编码后的结果的序号和类型是在同一个字节中,是一一对应的关系,如果编码的对应关系和解码的对应关系不同,则自然编码和解码的过程会出问题
***示例3:***protobuf中的序号大小会影响最终编码大小
我们前面看到序号和字段类型的字节结构如下,表示序号的部分是中间的4个bit,0010
00010000
而4个bit所能表示的最大数是1111,也就是15,因此当序号大于15的时候,一个字节就不够表达了,就需要额外一个字节,例如序号为17,类型为int的字段,它的序号字节就会如下
10001000 00000001
其中黄色底的000表示类型Int,去除后,剩下的bit通过标准的varient解码后,得到的结果就是17
因此,如果序号超过15,那么就会多需要一个字节来表示序号。回过头看示例3,model3编码结果正好比model1编码结果多3个字节,正是3个字段的序号导致的
***示例4:***protobuf的对象类型可以向String类型兼容
上面提到了int的类型在字节中的bit表示是000,那么接下去我么可以看下其他类型对应的bit表示
| Type | Meaning | Used For |
|---|---|---|
| 0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | 64-bit | fixed64, sfixed64, double |
| 2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
| 3 | Start group | groups (deprecated) |
| 4 | End group | groups (deprecated) |
| 5 | 32-bit | fixed32, sfixed32, float |
这里可以看到,0就是表示int32,表达方式是varient
而2则可以表示string、embedded messages等,而这里的embedded messages对应的就是子对象
既然类型的表示是相同的,那么在解码的时候自然就是可以从embeedded messages向string兼容
然而由于messages的结构是要比string复杂的,因此反向是无法兼容的
其实这个更广域和普世来说,总是复杂信息可以向简单信息转换,而反向一般是不可行的
**示例5:**protobuf可以和json完全兼容,且编码大小要比json小
兼容性是由java类库实现的,这个不在编码原理的范畴内,这里主要看下编码大小比json小的原因
例如示例中的json
{"email":"personJson@google.com","id":1,"name":"personJson"}
json的编码后,为了保证格式的正确和自解释的功能,其中还包含了很多格式字符,包括{ " , }等,还包括了email、id、name字段名本身
而protobuf编码后,则仅仅保留了序号、类型,以及字段的值,没有任何其他额外的符号,因此就比json节省了很多字节数
最后总结下这一大部分的内容,通过5个示例展示了protobuf在使用上的一些特性,并通过基本的编码原理解释了特性的本质原因
特性有以下5点
1.protobuf的解码不需要类型相同,也不需要字段名相同
2.protobuf的解码依赖于序号的正确性
3.protobuf中的序号大小会影响最终编码大小
4.protobuf的对象类型可以向String类型兼容
5.protobuf可以和json完全兼容,且编码大小要比json小
我们通过一些示例了解了protobuf的使用特性,以及和这些特性相关的基础编码原理。
编码原理只开了个头,所以接着将继续展示protobuf剩余的编码原理
在之前,我们只是定义了一些非常简单的模型,其中只包含了string、int和一个Name对象,所以我们首先先定义一个更复杂的模型
.proto文件如下
syntax = "proto3";
option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "ProtobufStudent";
message Student{
int32 age = 1;
int64 hairCount = 2;
bool isMale = 3;
string name = 4;
double height = 5;
float weight = 6;
Parent father = 7;
Parent mother = 8;
repeated string friends = 9;
repeated Hobby hobbies = 10;
Color hairColor = 11;
bytes scores = 12;
uint32 uage = 13;
sint32 sage = 14;
}
message Parent {
string name = 1;
int32 age = 2;
}
message Hobby {
string name = 1;
int32 cost = 2;
}
enum Color {
BLACK = 0;
RED = 1;
YELLOW = 2;
}
相比之前定义的模型,这里新增了int64,bool,double,float,repeated,enum,uint,sint类型
repeated类型对应的是java中的list
protobuf将这些具体的类型分为了几个大类,如下面这个表格所示
| Type | Meaning | Used For |
|---|---|---|
| 0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | 64-bit | fixed64, sfixed64, double |
| 2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
| 5 | 32-bit | fixed32, sfixed32, float |
接着我们就通过实例来看下每种数据结构的编码方式
1.Varint
这种类型的数据,在序号字节中的类型部分表示为000,即表格中的Type字段0
首先我们看最简单的4种类型,protobuf类型为int32、int64、bool、enum,模型中对应这种类型的字段是age、hairCount、isMale、hairColor,因此我们分别给这4个字段赋值
age测试代码
/**
* protobuf基础编码,varint类型
*/
@Test
void protobufBaseEncodeTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setAge(15)
Utility.printByte(student.toByteArray());
}
输出结果
8 15
00001000 00001111
这里复习一下之前中关于protobuf的编码基础
第一个字节表示字段的序号和类型
黄色底000,表示该数据类型是varint
蓝色0001,表示序号为1
红色0,表示序号解析到了最后一个字节
第二个字节表示数字的值15
通过varint解码后,即是15
hairCount测试代码
@Test
void protobufBaseEncodeTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setHairCount(239281373231123L)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
16 -109 -16 -126 -54 -128 -76 54
00010000 10010011 11110000 10000010 11001010 10000000 10110100 00110110
第一个字节表示字段的序号和类型
黄色底000,表示该数据类型是varint
蓝色0010,表示序号为2
红色0,表示序号解析到了最后一个字节
后面7个字节,通过varint解码后,即是239281373231123L
isMale测试代码
@Test
void protobufBaseEncodeTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setIsMale(true)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
24 1
00011000 00000001
序号字节结构和之前一样
这里因为赋值的是true,所以值是1,如果赋值是false的话,那么该字段就不会被编码了(因为bool类型默认就是false)
hairColor测试代码
@Test
void protobufBaseEncodeTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setHairColor(ProtobufStudent.Color.RED)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
88 1
01011000 00000001
序号字节结构和之前一样,这里因为赋值的是Color.RED,我们查看枚举值表即为1,如果赋值的是Color.BLACK,则该字段将不会被编码(因为int类型默认值就是0)
上面4个例子是可以通过正数就可以表达的类型,接着我们看对于有符号的正数,protobuf是如何表达的
protobuf类型为int32、uint32、sint32,对应模型中的age、uage、sage**(这里注意,虽然在.proto文件中我们分了3个类型进行定义,但最终映射到java的类型都是int)**
负数age测试代码
/**
* protobuf基础编码,有符号的整数
*/
@Test
void negativeIntTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setAge(-7)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
8 -1 -1 -1 -1 -1 -1 -1 -1 -1 1
00001000 11111001 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111 00000001
可以看到数据体占用了10个字节,通过varint解码后就可以得到-7
因为一般负数的二进制结果都是采用正数补码的形式存储,所以protobuf使用了一个长度固定为10个字节的空间对负数进行编码,即使是-7也需要10个字节进行存储,其实是十分不合理的,因此我们看下uint和sint的表现
uage测试代码
/**
* protobuf基础编码,有符号的整数
*/
@Test
void negativeIntTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setUage(-7)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
104 -1 -1 -1 -1 15
01101000 11111001 11111111 11111111 11111111 00001111
如果定义为uint32的话,那么固定的数据存储空间则会缩减为5个字节
sage测试代码
/**
* protobuf基础编码,有符号的整数
*/
@Test
void negativeIntTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setSage(-7)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
112 13
01110000 00001101
粗看一下还不错,至少是用一个字节就表示了,但是仔细观察就会发现,我们传入的数字明明是-7,但是编码结果却是13
原因在于如果我们定义的是sint32,那么protobuf会采用一种叫做ZigZag的编码方式,即一种数据的映射,表格如下
| Signed Original | Encoded As |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| 2 | 4 |
| ... | ... |
即设需要编码的整数为n:如果n>=0,则映射为2n;如果n<0,则映射为-2n-1
用代码来表示的话:
对于int32类型,映射规则为
(n << 1) ^ (n >> 31)
对于int64类型,映射规则为
(n << 1) ^ (n >> 63)
映射之后,再将映射的值通过varint的方式编码成字节
回过头看-7,对应的映射正是13,因此编码结果中也就是13
当然采取这种ZigZag进行映射后,对于负数编码所需的空间会减少,但对于正数的编码结果则会多出1个bit(看一下映射规则,如果n>=0,则映射为2n)
因此综上测试结果,如果我们能够预知在使用的过程中会遇到负数,那么从编码结果字节数的角度来说,采用sint定义.proto的字段将会是一个更优的选择
这里总结一下Varint的编码方式,首先由一个序号字节标识字段的序号和类型,数据体无论是int、long、bool、enum,因为其最终总能用一个数字表示,因此他们都能统一地通过varint进行编码,所以这种数据类型的分类就叫Varint
对于正数来说,直接通过varint编码即可
而对于负数来说,int会采用固定的10个字节对补码进行varint编码,uint会采用固定的5个字节对补码进行varint编码,而sint则是采用ZigZag的映射将负数映射成一个正数后再进行varint编码
顺带一提,在进行varint解码的时候,我们会发现它是需要将字节顺序反转之后才能解析出我们需要的数字(这里详解见protobuf的使用特性及编码原理),而这种反转存放的形式正是小端存储,即little-endian,这部分有兴趣的同学可以自己再去了解一下
2.64-bit和32-bit
这里我将64bit和32bit放到一起,因为他们之间的编码区别仅在于最终字节数量的不同,而编码原理是一样的
这两种类型的数据,在序号字节中的类型部分表示为001和101,即表格中的Type字段0和5
模型中对应这种类型的字段是height、weight因此我们分别给这2个字段赋值
height测试代码
/**
* protobuf基础编码,double和float类型
*/
@Test
void protobufBaseEncodeTestDoubleAndFloat() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setHeight(99.6)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
41 102 102 102 102 102 -26 88 64
00101001 01100110 01100110 01100110 01100110 01100110 11100110 01011000 01000000
第一个字节表示字段的序号和类型
黄色底001,即十进制的1,表示该数据类型64-bit
蓝色0101,表示序号为5
红色0,表示序号解析到了最后一个字节
后续的8个字节正是99.6的二进制表达形式,protobuf采用的标准是IEEE754。不过因为该标准的内容比较复杂,可以单独成文,所以就不放在这里展开了,本文还是专注于protobuf本身。
这里提供两个网址
维基百科:zh.wikipedia.org/wiki/IEEE_7…
线上转换:www.binaryconvert.com/result_doub…
我们进入线上转换站点,输入99.6,得到结果
和varint类型数据的存储方式一样,这里采用的也是小端存储(little-endian),因此将图中的字节反转之后,即可得到我们前面代码输出的内容
weight测试代码
/**
* protobuf基础编码,double和float类型
*/
@Test
void protobufBaseEncodeTestDoubleAndFloat() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setWeight(99.6F)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
53 51 51 -57 66
00110101 00110011 00110011 11000111 01000010
序号字节结构和double一样,只不过表示类型的3个bit是101,即十进制的5,表示bit-32类型
后面4个字节,也是float类型的IEEE754标准编码,并且采用小端存储的方式,我们可以再去转换验证一下,如下图
这里总结一下bit64和bit32的编码方式:和varint一样,通过一个序号字节来标识序号和类型,而数据体是long和double类型,采用的都是IEEE754标准的编码方式,并且字节是小端存储
3.Length-delimited
这种类型的数据,在序号字节中的类型部分表示为010,即表格中的Type字段2
从名字上来看,就是“长度限定”的意思,也就是说这种类型的数据都是需要指定长度的
包括string, 字节数组, 子对象,list,对应我们模型中的name、scores、father、friends
name测试代码
/**
* protobuf基础编码,LengthDelimited类型
*/
@Test
void protobufBaseEncodeTestLengthDelimited() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setName("tera")
.build();
Utility.printByte(student.toByteArray());
}
输出结果
34 4 116 101 114 97
00100010 00000100 01110100 01100101 01110010 01100001
第一个字节表示字段的序号和类型
黄色底010,即十进制的2,表示该数据类型Length-delimited
蓝色0100,表示序号为4
红色0,表示序号解析到了最后一个字节
之前提到这种类型的名称叫“长度限定”,因此和varint以及64bit类型的数据相比,这里会额外多出一个字节,代表后续数据的字节长度,用粉色底表示
这里我们看到长度为00000100,即十进制的4,也就是说后面4个字节是实际的数据
而"tera"4个英文字母对应的utf-8字节正是01110100 01100101 01110010 01100001
scores测试代码
/**
* protobuf基础编码,LengthDelimited类型
*/
@Test
void protobufBaseEncodeTestLengthDelimited() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setScores(ByteString.copyFrom(new byte[200]))
.build();
Utility.printByte(student.toByteArray());
}
输出结果
98 -56 1 0 0 ... 0
01100010 11001000 00000001 00000000 00000000 ... 00000000
因为数组的长度我设定为200,所以就用...表示中间省略的输出
序号字节结构和之前一样
第二和第三个字节为长度字节,编码方式为varint,解码后得到11001000,即十进制的200,表示后面200个字节都是数据
没有给byte数组赋值,所以数据的值默认都是0
friends测试代码
/**
* protobuf基础编码,LengthDelimited类型
*/
@Test
void protobufBaseEncodeTestLengthDelimited() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.addFriends("a")
.addFriends("b")
.build();
Utility.printByte(student.toByteArray());
}
输出结果
74 1 97 74 1 98
01001010 00000001 01100001 01001010 00000001 01100010
因为这是一个list,因此其中包含了多个数据段
第一和第四个字节分别表示2个数据的序号和类型,特别注意的是,因为这2个数据是属于同一个list的,所以序号都是1001,即十进制的9
第二和第五个字节表示数据的长度,因为我这里只是添加了a和b,所以数据长度都是1
第三和第六个字节表示数据的值,也就是a和b对应的utf-8编码
father测试代码
/**
* protobuf基础编码,lengthDelimited类型
*/
@Test
void protobufBaseEncodeTestLengthDelimited() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setFather(ProtobufStudent.Parent.newBuilder()
.setName("MrTera"))
.build();
Utility.printByte(student.toByteArray());
}
输出结果
58 8 10 6 77 114 84 101 114 97
00111010 00001000 00001010 00000110 01001101 01110010 01010100 01100101 01110010 01100001
第一个字节为序号和类型,表示序号7,Length-Delimited类型的数据
第二个字节为十进制的8,即表示后面8个字节就是数据体
这里特别注意,因为father字段本身就是一个Parent对象,所以这个数据体本身就是一个完整的protobuf的数据结构
此时我们就可以将后8个字节看成一个独立的protobuf结构
第三个字节为father中的字段序号和类型,表示序号1,Length-Delimited类型的数据,注意,这里的序号1代表的是father字段的Parent类中序号为1
第四个字节为father中的数据长度,表示后面6个字节为数据体
最后6个字节即为字符串"MrTera"的utf-8编码
这里总结一下Length-Delimited类型的数据,同样通过一个序号字节来标识序号和类型,额外有字节标识数据体的长度。
对于string和byte数组的数据体,以utf-8的形式进行编码
而对于子对象来说,数据体就是一个独立完整的protobuf结构
对于list来说,则根据其内容的不同,分别采用直接utf-8或者完整protobuf结构编码
这里额外展示一个示例,用list存储对象,我将bit相应的含义都用颜色标识好,具体的含义留给读者自行分析,从而可以更好地理解protobuf的编码原理
/**
* protobuf基础编码,lengthDelimited类型
*/
@Test
void protobufBaseEncodeTestLengthDelimited() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.addHobbies(ProtobufStudent.Hobby.newBuilder().setName("a"))
.addHobbies(ProtobufStudent.Hobby.newBuilder().setName("b"))
.build();
Utility.printByte(student.toByteArray());
}
输出结果
82 3 10 1 97 82 3 10 1 98
01010010 00000011 00001010 00000001 01100001 01010010 00000011 00001010 00000001 01100010
最后让我们把前面所讲到的编码原理结合到一起,看一下一个相对完整的编码结果
测试代码
/**
* 一个相对完整的模型
*/
@Test
void entireModelTest() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setAge(12)
.setName("tera")
.setIsMale(true)
.setFather(ProtobufStudent.Parent.newBuilder()
.setName("MrTera"))
.addFriends("peter")
.build();
Utility.printByte(student.toByteArray());
}
输出结果
8 12 24 1 34 4 116 101 114 97 58 8 10 6 77 114 84 101 114 97 74 5 112 101 116 101 114
00001000 00001100 00011000 00000001 00100010 00000100 01110100 01100101 01110010 01100001 00111010 00001000 00001010
00000110 01001101 01110010 01010100 01100101 01110010 01100001 01001010 00000101 01110000 01100101 01110100 01100101 01110010
整个结果就是之前每一组单独示例的结果拼合到一起,这里我大致标识下每个字节的意义,序号字节就不细分了,而是全部采用黄色背景,其他颜色和之前的都一致。有兴趣的同学可以再仔细分析一下
此时我们考虑这样一个问题,对于所属大类相同,但是实际类型不同的数据,在解码的时候该如何区分?
例如,我们编码如下一组数据
/**
* 数据类型的分辨
*/
@Test
void differDatatype() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setAge(14)
.setSage(-7)
.build();
Utility.printByte(student.toByteArray());
}
输出结果
8 14 112 14
00001000 00001110 01110000 00001110
可以看到age和sage的在编码后的值都是00001110,而且数据类型都是000(varint),唯一有区别的仅仅是序号
那么当我们仅仅拿到这4个字节的时候,是完全无法将编码后的数据还原成原始数据的!!
任何大类型相同,而实际类型不同的数据都有可能发生这种情况,例如字符串和字节数组
/**
* 数据类型的分辨
*/
@Test
void differDatatype() {
ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
.setName("aaa")
.setScores(ByteString.copyFrom(new byte[]{97, 97, 97}))
.build();
Utility.printByte(student.toByteArray());
}
输出结果
34 3 97 97 97 98 3 97 97 97
00100010 00000011 01100001 01100001 01100001 01100010 00000011 01100001 01100001 01100001
可以看到数据类型、长度、值都是相同的
这里再次强调,protobuf是一个不可以自解释的编码方式,除了字段的名字被抛弃之外,数据解析的歧义性也是其不可自解释的原因之一
所以protobuf的正确解析必须依赖于我们在编译.proto文件时,自动生成的那个巨大的java文件
也正是因为这个protobuf的不可自解释,在我们传递protobuf编码的时候,数据发送方和数据接收方都必须有相同的.proto文件,其中的字段序号必须一一对应,一旦有偏差,那么最终的解析一定是有问题的,再次说明序号是protobuf编码的灵魂所在
最后总结一下protobuf的编码原理
1.最重要的序号字节,每一个数据段都必须有该字节;标识字段的序号和类型;类型共有3种大类型;
2.varint,可以转化为int类型的数据,包括各种int32、int64等整型相关类型以及bool、enum,编码方式为varint,字节顺序为小端存储
3.double和float,浮点类型数据,编码方式为IEEE754标准,字节顺序为小端存储
4.Length-Delimited类型,需要指定数据体长度的类型,包括string、list、byte数组、子对象等,除了序号字节和数据内容字节,还需要额外的字节标识数据的长度
5.对于大类型相同,而小类型不同的数据,在解码的时候是通过编译时生成的java代码对其进行区分,仅依靠字节数据本身是无法区分的
更多Android进阶学习方法可以在我的Github中查看!