Nana

proto 的相关用法 (待补充完善)

源码

介绍

Protocol Buffers(简称 ProtoBuf)是一种序列化数据结构的协议。对于透过管道(pipeline)或存储资料进行通信的程序开发上是很有用的。这个方法包含一个接口描述语言,描述一些数据结构,并提供程序工具根据这些描述产生代码,用于将这些数据结构产生或解析资料流

proto2 提供一个程序产生器,支持 C++、Java 和 Python
proto3 提供一个程序产生器,支持 C++、Java(包含 JavaNano)、Python、Go、Ruby、Objective-C、C# 和 JavaScript
第三方实现支持 Perl、PHP、Dart、Scala 和 Julia

常用于微服务架构下不同服务间的通信数据结构。例如 gRPC 可以使用 ProtoBuf 同时作为其接口定义语言(IDL)和底层消息交换格式

简单使用

使用 protoBuf 的第一步是在 .proto 文本文件中定义你想要序列化的数据的结构。ProtoBuf 中的数据均被定义为 message 类型,每个 message 数据定义的是一段信息的逻辑记录,包含一系列 name-value 的属性对,如:

proto2

message Person {
    required string name = 1;
    required in32 id = 2;
    optional bool has_ponycopter = 3 [default = true];
}

proto3

message Person {
  string name = 1;
  int32 id = 2;
  bool has_ponycopter = 3;
}

定义完成后,使用 protocol buffer 的编译器 protoc 来根据你定义的 proto 文件生成指定语言的相应访问对象。这一步将会给每个属性都声称相应的存取方法,例如 name()、set_name(),也会生成相应的类对象及原始字节类型之间转换的序列化和解析方法。例如,当将上述 Person 的 proto 定义文件编译成 Java 类对象后,即生成 Person 类,随后可在程序中存入、序列化、获取 Person 的 ProtoBuf message 信息

同时,还可在同一 proto 文件中定义包含 RPC 方法的 gRPC services,这些方法的传入参数和返回类型均应以 protoBuf message 类型定义

// The greeter service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

版本

当前主流使用的是 proto2 和 proto3( gRPC 默认使用 proto3 )

  1. 支持语言:
    – proto2: C++、Java 和 Python
    – proto3: C++、Java(包含 JavaNano)、Python、Go、Ruby、Objective-C、C# 和 JavaScript

  2. proto3 取消了对可选字段功能的支持,强制数据的所有字段均为可选值,因此也不再支持 default 修饰符指定默认值。在 proto2 中,默认配置下,一个 optional 字段如果没有设置,或者显式设置成了默认值,在序列化成二进制格式时,这个字段会被去掉,导致反序列化后,无法区分是当初没有设置还是设置成了默认值但序列化是被去掉了。即使 proto2 对于原始数据类型字段都有 hasXxx() 方法,在反序列化后,对于这个“缺失”字段,hasXxx() 总是 false,因此也失去了其判定意义

更新强制所有字段均为 optional 的目的是为了防止在对 proto 文件内容进行更新迭代的过程中,进行了字段的增加或减少,此时如果变更的字段被设置为 required,就会出现兼容性问题

数据类型

.proto 类型 Java 类型 备注
int32 int 使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用 sint64
int64 long 使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用 sint64
double double
float float
bool boolean ⚠️ 注意这里是非包装类型 boolean,而非 Boolean,即其有默认值为 false
string String
bytes ByteString 可能包含任意顺序的字节数据
unit32 int[1] 总是 4 个字节。如果数值总是比总是比 228 大的话,这个类型会比 uint32 高效
unit64 long[1] 总是 8 个字节。如果数值总是比总是比 256 大的话,这个类型会比 uint64 高效
sint32 int 使用可变长编码方式。有符号的整型值。编码时比通常的 int32 高效
sint64 long 使用可变长编码方式。有符号的整型值。编码时比通常的 int64 高效
fixed32 int[1]
fixed64 long[1] 总是 8 个字节。如果数值总是比总是比 256 大的话,这个类型会比 uint64 高效
sfixed32 int 总是 4 个字节
sfixed64 long 总是 8 个字节

特殊字段

字段 含义 备注
enum 枚举(数字从零开始) 作用是为字段指定某”预定义值序列” enum Type {MAN = 0;WOMAN = 1; OTHER= 3;}
message 消息体 message Person{}
repeated 数组/集合 repeated Person people = 1;
import 导入定义 import “protos/other_protos.proto”
// 注释 // 用于注释
extend 扩展 extend User{}
package 包名 相当于命名空间,用来放置不同消息类型的命名冲突

常见问题

区分缺失值和默认值

由上述版本变更内容可知,在 proto3 中,所有字段都是 optional 的,而且对于原始数据类型字段,完全不提供 hasXXX() 方法。因此,在 proto3 中,有时会遇到难以判断序列化后缺失某字段时是初始就未给定其值还是指定为了默认值。
实际上,这种情况在大多数业务场景下都不会破坏原有的业务逻辑,即“未设置”和“默认 0/false”等价,例如“未收取费用”和“收取费用 0 元”表达的是同一个意思。但在少数情况下,比如“收益率未计算”和“收益率为 0”则必须找到方法加以正确区分

  • 方案一:用特殊值区分
    – 设置默认值为 -1,而非0
    – 将相关字段打包成仅含有一个原始数据类型字段的消息类型,此时就可以使用 hasXxx() 来判断了。但是注意此处不可以直接对该字段的包装类型使用 get 和 set 方法,因为其默认值为 null

  • 方案二:显式地额外定义一个 has_Xxx 字段(不建议)

    message Accout {
      string name = 1;
      double profit_rate = 2;
      bool has_profit_rate = 3;
    }
    

    该方法很直白,但是浪费内存和网络带宽,而且每次设置原字段后都需要注意更新判断字段,很麻烦且容易出错

  • 方案三:使用额外的 wrapper 包装类型

    import "google/protobuf/wrappers.proto"
    

message Account {
string name = 1;
google.protobuf.DoubleValue profit_rate = 2;
google.protobuf.BoolValue enable = 3;
}

    这个方案利用了 proto3 只对原始数据类型不生成 hasXxx() 的特点
    -- 在 Java 中,使用 setProfitRate(DoubleValue.of(1.0)) / getProfitRate().getValue() 和 setEnable(BoolValue.of(true)) / getEnable().getValue() 来进行值的读写,而不能简单的直接赋值和获取值
    -- 另外,Java 版本的 ProtoBuf 中,com.google.protobuf.JsonFormat.printer().print(msg) 方法,对于 wrapper 类型有特殊处理(实际是 wrapper 类型自身实现的缘故),虽然 wrapper 类型的 protobuf 定义实际值存储在内层 value 字段下,但是当 JsonFormat 序列化成 JSON 格式时,会直接输出 "xxx":1.0 ,而非 "xxx":{"value": 1.0},因此使得最终的处理方式与原始类型处理一致,更加方便

## protobuf 和 json 互转时默认值的问题
与上一问题类似,在 protobuf 与 json 之间进行数据类型转换时,碰到原始数据类型的字段的值与相应类型的默认值相同时,默认情况下 JSONPrinter 不会在结果的 json 中打印出该字段,比如当某 message 消息体内只有一个 bool enable = false, 则默认情况下会打印出 {}

因此需要在获取 Json 的打印器时设置包括默认值字段

SomeProto.Builder builder = SomeProto.newBuilder();
SomeProto message = builder.build();
String json = com.google.protobuf.util.JsonFormat.printer().includingDefaultValueFields().print(message);


相同的,在 json 转成 protobuf 时,如果存在 message 消息体内未设置的字段,则会报 InvalidProtocolBufferException 异常,此时则需要使用 ignoringUnknownFields

SomeProto.Builder builder = SomeProto.newBuilder();
Stirng json = some json data…
JsonFormat.parser().ignoringUnknownFields().merge(json, builder);
SomeProto proto = builder.build();


文章参考文献

Google Proto Buffer 官方文档

知乎专栏 - 区分 Protobuf 中缺失值和默认值

protobuf 和 json 互转时应该注意的问题

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。