一年前做的东西都快忘记了,复习下PB3。一年前在契机下在Android用到了Socket+PB3通信,接到需求的时候瑟瑟发抖,没有很系统地用过Socket,也完全不知道什么是PB3…一年之前因为在需求太赶的情况下
为了防止以后再弱弱瑟瑟发抖,再看看PB3是什么。
PB3 官方说明
https://developers.google.com/protocol-buffers/docs/overview
https://developers.google.com/protocol-buffers/docs/javatutorial
https://developers.google.com/protocol-buffers/docs/proto3
参考博客:
https://www.jianshu.com/p/a24c88c0526a
https://www.jianshu.com/p/b33ca81b19b5
PB3简单来说是一个Google封装好的数据格式,但是只是专注于数据的序列化和反序列化。功能类似于JSON、XML,但是效率优于XML。那么和其他的JSON和XML一样,他也有自己的后缀名:.proto
PB3可以作为Java、C++、Python协议通信的数据结构体…但是PB3的传输数据结构体需要提前定义,当初我也不知道他们怎么找到这个PB3作为传输体的…
protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
Protocol Buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。
你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。
当初开发的时候记得PB3的格式是需要前后端预先定义好的,然后通过一个Java的编译器,将这个xxx.proto文件转为一个类,之后就直接Copy这个类到项目就好了,然后之后通过这个类进行通信就好了。
但是PB3也是因为是预定好的数据格式,所以在新增和删减字段的时候,需要注意版本兼容性问题。
尝试下Pb3的语言格式,主要还是用name-values这样的键值对实现数据的定义
// 例1: 在 xxx.proto 文件中定义 Example1 message
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phone = 4;
}
一个消息的主体,往往是通过message的方式去定义,好比JSON里面的JSONObject
message xxx {
}
同样也有参数的标识符
message xxx {
// 字段规则:required -> 字段只能也必须出现 1 次
// 字段规则:optional -> 字段可出现 0 次或1次
// 字段规则:repeated -> 字段可出现任意多次(包括 0)
// 类型:int32、int64、sint32、sint64、string、32-bit ....
// 字段编号:0 ~ 536870911(除去 19000 到 19999 之间的数字)
字段规则 类型 名称 = 字段编号;
}
在上例中,我们定义了:
一个ProtocolBuffer的数据结构体就定义完毕了,因为ProtoBuffer支持默认值处理,所以在后续的更新迭代中,即使新增或者减少字段都不会对旧的数据体有什么影响。(除非默认值没有好好考虑好处理方式)
上面只是说道了PB3的通信体定义,但是应用到不同的语言的时候,我们就可以通过protocol Buffer提供的编译器,将对应的proto文件转换为一个具体的类。刚刚上面演示的是一个简单的PB协议数据体,到了Java层面,还可以提供更多的字段
// DES: 指定PB3版本
syntax = "proto3";
// DES: 当前PB3的包名,有点像java,用于区分pb3的命名空间
package tutorial;
// DES: 当前PB3转Java结构体之后的package(与Java的Package保持一致),假若不使用java_package指定,默认会使用上面pb3的package的命名作为java的package。
option java_package = "com.example.tutorial";
// DES: 当前PB3转Java结构体之后的名字,假若不指定的话默认用pb3的文件名+驼峰式去转换成Java类。
option java_outer_classname = "AddressBookProtos";
同时Pb3定义的字段后面紧跟的" = 1", " = 2",这些也叫tagNumber,标志着当前字段在Pb3传输体的唯一性。
假若该值使用1-15的话在编码的时候会少一个byte值,因为每个repeated标记的字段每次都需要重编码,所以在使用repeated的时候用1-15的话,算是一种小优化了。
那假若是optional的字段,可以使用大于等于16的值。
- 假若required标记的字段没有提供,那么就认为这个proto文件是未初始化完毕的,未初始化的proto文件在构建的时候会抛出RuntimeException。解析"未初始化"完成的proto文件,则会抛出IOException
- 假若optional标记的字段没有提供默认值,那么它的默认值就是used。对应的int值就是0,string值就是空字符串,boolean值就是false,假若是一个内嵌的Message对象,那么它的默认值就是一个空的对应对象,这是这个对象里面没有任何的值。
在Google的DOC中,有一段很关键的提示说明
Required Is Forever: You should be very careful about marking fields as required. If at some point you wish to stop writing or sending a required field, it will be problematic to change the field to an optional field – old readers will consider messages without this field to be incomplete and may reject or drop them unintentionally. You should consider writing application-specific custom validation routines for your buffers instead. Some engineers at Google have come to the conclusion that using required does more harm than good; they prefer to use only optional and repeated. However, this view is not universal.
上述说到:假若当一个字段被定义成了required的,需要考虑它的兼容性问题。例如在版本1.0的时候制定了一个required字段address。但是随着业务升级在2.0的时候,address字段变成非必须的了,需要变为optional可选的。那假若这时候在数据结构体里面直接将这个address从required转为optional的话就会存在一个坑了:
这时候旧版本的机器上面会认为没有解析到这个字段,就抛弃了这段Message了。所以在Google的工程师都觉得required的字段对程序的兼容性不是很友好,我们应该在新版本自己通过业务层处理好required字段兼容,而不是考虑向下兼容…因为向下兼容会失败。
这个过程需要下载一个转换的Java程序,download Link
然后通过这样的命令,SRCDIR指定proto文件夹,DSTDIR指定输出的Java类文件夹。后面就是proto文件了
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
按照官方文档的说明,
输出之后的文件大概长这样:Message中每个定义的字段都有一个getXXX的函数。而且还会为这个Message额外创建一个Builder对象,Builder对象有setter和getter函数,用于构建Message。
// required string name = 1;
public boolean hasName();
public 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 phones = 4;
public List getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
----------------------------------分割线Message和Builder---------------------------------------
// required string name = 1;
public boolean hasName();
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();
// DES: 可以看到repeated标志的字段变成了一个List集合
// repeated .tutorial.Person.PhoneNumber phones = 4;
public List getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
public Builder setPhones(int index, PhoneNumber value);
public Builder addPhones(PhoneNumber value);
public Builder addAllPhones(Iterable value);
public Builder clearPhones();
然后还能看到在Message生成的函数中除了getXXX函数,还会存在一个hasXXX的函数,假若这个值被设置了那么就返回true。
同时还看到Builder里面会存在一个clearXXX的函数,用来清除当前字段的信息,重置回空状态。(是默认状态吗?)
被Repeated标志的字段还存在一个getXXXCount()函数,和List.size()是一样的。因为repeated字段最后转为了一个List,所以后面你会看到有add和AddAll函数…
还有一些小细节:proto文件经过编译器的转换,可以看到这些getter和setter函数等都是按照驼峰式定义的。那么Google建议我们在proto文件定义的时候,尽量用小写字母和下划线的方式定义字段。
一旦Message经过ProtoBuffer的编译器编译成Java类之后,这个Java类就是不可变的(但是还是可以在里面写东西…因为不是源码级别的,但是还是不要乱改…)。
其实Message最后会被转换成一个MessageOrBuilder类型文件
public interface Aes_Key_RequestOrBuilder extends
// @@protoc_insertion_point(interface_extends:Aes_Key_Request)
com.google.protobuf.MessageOrBuilder {}
看到类的关系,是实现了MessageOrBuilder接口和MessageLiteOrBuilder接口,所以这里会有一些额外的函数。
- isInitialized(): 检查Required字段是否都被初始化了
- toString(): 和普通的toString没什么区别
- mergeFrom(Message other): (builder only) >使用传递的other参数替换当前Builder构建对象的字段,有点像复制一个Message的所有字段值替换到当前构建出的对象字>段的值上面。
- clear(): (builder only) 清除所有的字段值,回到空状态(有默认值吗?)
那么我们端与端之间的传递就是通过Builder这个类的build函数去构建对象,然后再转为对应的二进制数据进行传输。
Google提供的解析和序列优的函数有这些
- byte[] toByteArray();: 将一个Message对象转为二进制流
- static Person parseFrom(byte[] data);: 从二进制流里面转成一个对象
- void writeTo(OutputStream output);: 序列化消息,并且存到一个输出流里面
- static Person parseFrom(InputStream input);: 从一个输入流里面读取二进制流,并转为一个对象。
在官网的文档中提及到一个设计上面的东东
Protocol Buffers and O-O Design:Protocol buffer classes are basically dumb data holders (like structs in C); they don’t make good first class citizens in an object model. If you want to add richer behaviour to a generated class, the best way to do this is to wrap the generated protocol buffer class in an application-specific class. Wrapping protocol buffers is also a good idea if you don’t have control over the design of the .proto file (if, say, you’re reusing one from another project). In that case, you can use the wrapper class to craft an interface better suited to the unique environment of your application: hiding some data and methods, exposing convenience functions, etc. You should never add behaviour to the generated classes by inheriting from them. This will break internal mechanisms and is not good object-oriented practice anyway.
Pb转成的数据结构是很死古板的,假若想基于这个Pb数据结构做一些扩展:最好的方式就是通过外层包裹Pb数据体,然后在外层提供一些访问Pb数据体的函数,这样做的话可以通过访问权去控制api做到隐藏Pb的实现细节,也可以抽取一个业务的接口提供共有的行为。
千万不要继承Pb的数据体然后做一个扩展,因为这样就破坏了Pb3结构体的一致性了,不能和之前定义好的格式做一个逻辑校对。
就直接从官网文档Copy例子了,因为调用的API在上面提及过了
class AddPerson {
// DES: PB3转出来的Java类,千万不要尝试修改中间的逻辑,以免破坏一致性
static Person PromptForAddress(BufferedReader stdin,
PrintStream stdout) throws IOException {
Person.Builder person = Person.newBuilder();
stdout.print("Enter person ID: ");
person.setId(Integer.valueOf(stdin.readLine()));
stdout.print("Enter name: ");
person.setName(stdin.readLine());
stdout.print("Enter email address (blank for none): ");
String email = stdin.readLine();
if (email.length() > 0) {
person.setEmail(email);
}
while (true) {
stdout.print("Enter a phone number (or leave blank to finish): ");
String number = stdin.readLine();
if (number.length() == 0) {
break;
}
Person.PhoneNumber.Builder phoneNumber =
Person.PhoneNumber.newBuilder().setNumber(number);
stdout.print("Is this a mobile, home, or work phone? ");
String type = stdin.readLine();
if (type.equals("mobile")) {
phoneNumber.setType(Person.PhoneType.MOBILE);
} else if (type.equals("home")) {
phoneNumber.setType(Person.PhoneType.HOME);
} else if (type.equals("work")) {
phoneNumber.setType(Person.PhoneType.WORK);
} else {
stdout.println("Unknown phone type. Using default.");
}
person.addPhones(phoneNumber);
}
return person.build();
}
// DES: 入口Main函数
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: AddPerson ADDRESS_BOOK_FILE");
System.exit(-1);
}
// DES: 创建Message的Builder对象
AddressBook.Builder addressBook = AddressBook.newBuilder();
// DES:尝试从文件输入流读取一个对象
try {
addressBook.mergeFrom(new FileInputStream(args[0]));
} catch (FileNotFoundException e) {
System.out.println(args[0] + ": File not found. Creating a new file.");
}
// DES: 同上,从输入流添加一个对象
addressBook.addPerson(
PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
System.out));
// 将对象存储到文件中
FileOutputStream output = new FileOutputStream(args[0]);
addressBook.build().writeTo(output);
output.close();
}
}
官网的demo做了一个小的处理,从File里面读取一个AddressBook对象并且merge到自身,同时再通过控制台的输入,创建一个PromptForAddress对象,然后将新的AddressBook对象写到本地的文件中…
在使用Pb3的时候,兼容性其实往往是一个需要重要考虑的点。因为pb3的数据结构体是前后端协商定义的,难免会因为业务变化而需要新增或者删除字段。这时候就需要遵循下面的规则了
- 千万不能随意修改之前proto文件里面的tagNumber
- 千万不能添加任何新的required字段或者删除已有的required字段…(看上面的坑…还是不要用required了)
- 可以删除optional和repeated的字段
- 可以新增新的optional和repeated字段,但是注意tagNumber不能和之前的相等(即使有删除了久的tabNumber,新字段也不能直接用旧的tagNumber)。
这样做的话,旧版本的pb数据就能兼容新版本的,因为他会跳过所有新版本的字段。假若被删掉的optional字段则会置为默认值,repeated字段则会置为空列表。那从旧数据转换到新数据也不存在什么问题了。
但是呢,也因为新的字段不会显示在旧的Message上的特性。那就可能出现一个这样的问题,假若线上版本是1.0有optional的name字段,现在发布2.0版本把name字段干掉了。兼容的方式是最好提供默认值或者通过has函数判断是否有这个字段(用has?我能未雨绸缪知道业务变化么…)