Bootstrap

GRPC使用之ProtoBuf

1. 入门指导

1. 基本定义

Protocol Buffers提供一种跨语言的结构化数据的序列化能力,类似于JSON,不过更小、更快,除此以外它还能用用接口定义(IDL interface define language),通protoc编译Protocol Buffer定义文件,生成结构化类,以及服务调用的客户端和服务端。

1. person.proto

我们来看一个极简的例子,好让自己有一个直观的感受,假设我们有一个person.proto文件,内容如下:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.keyniu.grpc.proto";

message Person {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;
}
2. Person.java

通过protoc生成的Person.java类大概是这样的

// Generated by the protocol buffer compiler.  DO NOT EDIT!
// source: person.proto

// Protobuf Java Version: 3.25.1
package org.keyniu.grpc.proto;

/**
 * Protobuf type {@code Person}
 */
public final class Person extends
    com.google.protobuf.GeneratedMessageV3 implements
    // @@protoc_insertion_point(message_implements:Person)
    PersonOrBuilder {
private static final long serialVersionUID = 0L;
  // Use Person.newBuilder() to construct.
  private Person(com.google.protobuf.GeneratedMessageV3.Builder<?> builder) {
    super(builder);
  }
  private Person() {
    name_ = "";
    email_ = "";
  }
... // 后续的省略
3. 核心用例

我们看一下Person类的核心用法

// 创建对象
Person p = Person.newBuilder().setName("randy").setId(1).setEmail("[email protected]").build();
// 序列化
byte[] serialized = p.toByteArray();
// 反序列化
Person p2 = Person.parseFrom(serialized);
System.out.println(p2);
2. 适用场景

Protocol Buffers提供了结构化数据的序列化/反序列化能力,对领域对象的修改能够兼容历史版本,官方推荐适用于小规模数据(MB级),包括网络传输、数据存储。

不适用的场景包括

  1. 不支持流式解析,待解析的数据要一次性加载进byte数组,然后解析,不能读取部分内容就交由Protocol Buffers解析
  2. 不支持二进制比较,不同语言/平台的序列化后的二进制可能是不同的,要反序列化后才能比较两个对象是否相同
  3. 不支持非面向对象的语言

2. 数据类型

我们先来回顾一下person.proto的定义,这个定义的核心是Person前面的message

message Person {
  optional string name = 1; // label(optional)、字段类型(string)、字段名(name)、字段Id(1)
  optional int32 id = 2;
  optional string email = 3;
}
1. label
label说明举例
optional字段是否可选,允许不设置值,proto3中字段默认optional,对应proto2中的requiredoptional string name = 1;
repeated可以有0或多个值,保留写入顺序repeated string name = 1;
map对应Java里的Mapmap<int32, string> idToName = 2;
字段是否存在,被称为implicit field presence,如果字段未设置值,序列化
oneof一组关联字段,只保留一个值,设置两个字段时,会把第一个清空

来看一个oneof的实例,一个Product对象,它可以参加一种促销(抵用券或打折),但不能同时参加,可以这样定义product.proto

syntax = "proto3";
option java_multiple_files = true;
option java_package = "org.keyniu.grpc.proto";

message Product {
  optional string name = 1;
  oneof promotion {
    string coupon = 2;
    string discount = 3;
  }
}

我们来看看生成的Product类,Product类自动生成了一个Product.PromotionCase类,我们可以通过它判断当前Product参加那类促销

Product prod = Product.newBuilder().setName("Mate60Pro").setCoupon("满10减3").build();
System.out.print(prod.toString());
switch (prod.getPromotionCase()) {
    case DISCOUNT:
        System.out.println(prod.getDiscount());
        break;
    case COUPON:
        System.out.println(prod.getCoupon());
        break;
}

输出如下
在这里插入图片描述

如果我们给Product同时设置Coupon和Discount,代码如下:

prod = Product.newBuilder().setName("Mate60Pro").setCoupon("满100减1").setDiscount("7折").build();
System.out.println(prod.toString());

输出如下
在这里插入图片描述

2. 字段类型

类型分为内置基本类型和自己通过message(enum)定义的类型,我们先来看看基本类型。 proto3的内置基本类型,包括整数、浮点数、布尔型、字符串以及字节数组

1. 基本类型
Proto类型对应Java类型说明默认值
doubledouble0
floatfloat0
int32int变长编码,对负数的编码效率较低,如果有负数建议使用sint320
int64long变长编码,对负数的编码效率较低,如果有负数建议使用sint640
uint32int变长编码,相当于unsigned int320
uint64long变长编码,相当于unsigned int640
sint32int变长编码,对负数的编码效率较高0
sint64long变长编码,对负数的编码效率较高0
fixed32int定长编码,总是使用4 Byte,占空间多,但编码效率高,相当于unsigned int320
sfixed32int定长编码,总是使用4 Byte,相当于signed int320
fixed64long定长编码,总是使用8 Byte,占空间多,但编码效率高,相当于unsigned int640
sfixed64long定长编码,总是使用8 Byte,相当于signed int640
boolboolean布尔值false
stringString字符串空字符串
bytesByteString字节序列,适用于存储任何数据,比如图片的byte数字空字节数组
2. 自定义类型

除此以外,proto允许用户自己通过message、enum定义自己的类型,比如之前提到的Person,我们再看一下示例

message Person {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;
}
3. 自定义枚举

enum关键字和Java的枚举基本一致,假设我们要定义一个性别(Gender)的枚举,可以用下面的语句定义

enum Gender {
  MALE = 0;
  FEMALE = 1;
}

要特别注意的是枚举字段的定义后面的字段id要从0开始。此外enum还有一个Java没有有的特性,枚举类型可以指定别名,比如将MAN作为MALE的别名可以这么写

enum Gender {
  option allow_alias = true;
  MALE = 0;
  MAN = 0;
  FEMALE = 1;
  WOMEN = 1;
}
4. 跨文件引用

如果通过message、enum定义的类型都在同一个文件中,可以直接相互引用,如果是在两个proto文件中,需要手动import,比如这样

import "myproject/gender.proto";
3. 字段ID

官方叫做Assigned Field Number,在person.proto中name字段的id就是1,id在同一个类型内部必须唯一,取值范围[1,5亿],一般从1开始递增,当然数字越大,消耗的储空间越大(类似UTF-8编码)。19000~19999是预留给内部使用的。

optional string name = 1;

所以这个字段标识不能修改,也不能重复,修改导致之前序列化的数据无法解析,重复导致字段混乱。

4. 预留字段

告诉Protocol Buffer预留字段Id ,2、9、10、11、15,预留字段名称foo、bar

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}
5. 对象引用

某些场景下我们可能不确定持有的数据类型,比如Object,proto也提供了这样的支持

import "google/protobuf/any.proto";

message Handler {
  string message = 1;
  repeated google.protobuf.Any target = 2;
}

通过生成对象的pack、unpack方法来访问target持有的引用

class Any {
  // Packs the given message into an Any using the default type URL
  // prefix “type.googleapis.com”.
  public static Any pack(Message message);
  // Packs the given message into an Any using the given type URL
  public static Any pack(Message message, String typeUrlPrefix);
  // Checks whether this Any message’s payload is the given type.
  public <T extends Message> boolean is(class<T> clazz);
  // Unpacks Any into the given message type. Throws exception if
  // the type doesn’t match or parsing the payload has failed.
  public <T extends Message> T unpack(class<T> clazz) throws InvalidProtocolBufferException;
}

3. 服务定义

proto3支持4中类型的服务定义,通过service关键字类定义服务的接口,比如下面示例中的Greeter服务,定义了4个方法,分别对应4种类型的调用

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.keyniu.grpc.generate";

service Greeter {
  rpc sayHello (HelloRequest) returns (HelloReply) {}
  rpc sayHelloClientStream (stream HelloRequest) returns (HelloReply) {}
  rpc sayHelloServerStream (HelloRequest) returns (stream HelloReply) {}
  rpc sayHelloBiStream (stream HelloRequest) returns (stream HelloReply) {}
}
1. 基本调用

基本调用,处理入参HelloRequest,生成响应HelloReply,在Java中怎么实现可以参考[[Helloworld#2. 实现Server]]。

rpc sayHello (HelloRequest) returns (HelloReply) {}
2. Client端Streaming

Server端Streaming,指客户端可能提交多个参数,最后响应一个结果

rpc sayHelloClientStream (stream HelloRequest) returns (HelloReply) {}
3. Server端Streaming

Server端Streaming,指客户端提供一个参数,服务端可能会有多个响应

rpc sayHelloServerStream (HelloRequest) returns (stream HelloReply) {}
4. 双向Streaming

双向Streaming,指客户端可以提交多个参数,服务端也可以有多个响应

rpc sayHelloBiStream (stream HelloRequest) returns (stream HelloReply) {}

4. JSON互操作

我们可能需要让ProtoBuff和JSON交互,ProtoBuff也为我们考虑到了这个问题,在Java中,可以使用protobuf-java-util实现这个能力

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java-util</artifactId>
    <version>3.x.x</version> <!-- 使用你的protobuf版本 -->
</dependency>
1. 转JSON
import com.google.protobuf.util.JsonFormat;
import your.package.YourProtoMessage; // 替换为你的protobuf消息类型

public class Main {
    public static void main(String[] args) throws Exception {
        YourProtoMessage message = YourProtoMessage.newBuilder() // 构建你的protobuf消息
            .setField1("value1") // 设置字段
            .setField2(123)       // 设置字段
            .build();
        JsonFormat.Printer printer = JsonFormat.printer();
        String jsonString = printer.print(message);
        System.out.println(jsonString);
    }
}
2. 解析JSON
import com.google.protobuf.util.JsonFormat;
import your.package.YourProtoMessage; // 替换为你的protobuf消息类型

public class Main {
    public static void main(String[] args) throws Exception {
        String jsonString = "{\"field1\":\"value1\",\"field2\":123}";
        JsonFormat.Parser parser = JsonFormat.parser();
        YourProtoMessage message = parser.merge(jsonString, YourProtoMessage.newBuilder()).build();
        System.out.println(message.getField1()); // 输出: value1
        System.out.println(message.getField2()); // 输出: 123
    }
}

5. 配置选项

选项示例说明
option java_packageoption java_package = “com.example.foo”生成Java类的包名
java_outer_classnameoption java_outer_classname = “Person”;生成Java类外部包装类名称
java_multiple_filesoption java_multiple_files = true;一个proto文件message、service生成多.java文件
optimize_foroption optimize_for = CODE_SIZE;可选值SPEED、CODE_SIZE、LITE_RUNTIME
SPEED: 追求执行速度,生成序列化/反序列化等代码
CODE_SIZE: 追求代码体积,通过反射实现序列化
LITE_RUNTIME: 类似SPEED,但省略descriptor和reflection代码,依赖libprotobuf-lite

A. 参考资料

  1. https://protobuf.dev/programming-guides/proto3/
;