ProtoBuf
ProtoBuf介绍
ProtoBuf,全称Protocol Buffers,是Google开源的一款跨语言、跨平台、扩展性好的序列化工具。它可以定义数据的结构,然后使用特殊生成的源代码轻松地在各种数据流中使用各种语言进行编写和读取结构数据。这种序列化方法的特点是语言无关、平台无关,并且具有高效、灵活和可扩展的特性。protobuf支持多种语言,如Java、C++、Python等,并且可以在多个平台上使用。
它比XML更小、更快、更简单,因此是一种高效的数据交换格式。此外,protobuf还具有强大的扩展性和兼容性,只需要使用protobuf对结构数据进行一次描述,即可从各种数据流中读取结构数据,更新数据结构时也不会破坏原有的程序。
基本语法规则
syntax = "proto3";
import "xxx/other.proto";
message Person{
string name = 1;
int32 age = 2;
optional string address = 3;
repeated string hobbies = 4;
}
Proto消息类型文件一般以 .proto 结尾,一个 .proto 文件中可以定义多个消息类型及服务接口。其中syntax关键字标识使用的Proto语法版本,message关键字用于定义通信中使用的消息类型,同时可以使用import关键字来导入其他.proto文件中定义的消息类型。
消息类型中的每个字段都需要定义唯一的编号(编号时可以不连续,但必须保证唯一),该编号会用来识别二进制数据中字段。编号从1开始,最大可到2^29-1。其中19000至19999范围内的编号被ProtoBuf预先保留使用,故不能使用该范围内的编号进行编码。消息类型中的每一个字段都需要规定数据类型,其中Proto与其他语言的数据类型映射关系如下(来源:https://protobuf.dev/programming-guides/proto3/):
Proto3的主要字段修饰符为optional与repeated,不加修饰则默认为optional字段。其中optional表示该字段为可选字段,即通信双方处理消息时,可以不设置该字段的具体值,默认其为0值或空值。repeated修饰则表示该字段可以包含多个值,相当于一个该类型数据的列表。
ProtoBuf语法支持消息的嵌套,如下,消息类型A中定义了消息类型B,同时包含了一个消息B类型的字段b。其它消息类型也可以通过使用A.B来引用消息类型B。
message A{
message B{
string data = 1;
int32 num = 2;
}
int32 id = 1;
B b = 2;
}
编码原理
ProtoBuf数据量小,解析速度快的特点,很大程度要得益于其高效的编码方式。它采用了TLV(tag-length-value) 编码格式,每个字段都有唯一的 tag 值,它是字段的唯一标识。length 表示 value 数据的长度,为可选字段。value 则表示数据内容。
如上图所示,一个Filed结构对应着消息类型中的一个字段,Protocol Buffer 根据tag可以确定消息字段与value之间的对应关系。其中tag的定义为(field_number << 3) | wire_type,field_number即初始定义中赋予给每个字段的编号,wire_type占3bit,对应着不同的编码方式及不同的字段数据类型,具体如下图所示:
对于64-bit与32-bit编码方式,均使用固定字节来表示数据,无特点可说。start group与end group已废弃,故不再提。下面仅介绍一下Varint、Zigzag以及对字符串的编码方式。
Varint 是一种对于int型数据变长的编码方式。它规定每个字节的最高位如果为1,则表示下一个字节也是该数字的一部分,如果为0,则表示该字节为该数字的最后一个字节。这样的话,对于较小的数字可以用较少的字节来表示,即便是很大的数字也只需要5个字节来表示。看似每个字节浪费了1bit,但在大多数情况下,消息中一般不会有很大的数字,所以相比于固定使用4字节来表示int数据,采用 Varint 编码还是可以很大程度缩减数据量的。
Varint 编码过程:对于int32数字259,其二进制补码为00000000 00000000 00000001 00000011, 首先取最低7位bit为000 0011,由于剩下数据中还存在1,故最高位补1得到1000 0011。然后取再较高的7位bit为000 0010,由于剩下的数据中均为0,故最高位补0(表示后面没有有效数据),得到0000 0010。最后将得到的所有字节汇合(按照小端方式),得到最终的编码数据为10000011 00000010。解析过程即为上述的逆过程。
如果直接使用Varint 方式对负数编码,需要固定使用5字节,因为需要保证负数的最高有效位1。所以 ProtoBuf 定义了 sint32 和 sint64 类型来表示负数,先采用 Zigzag 编码,将有符号的数转成无符号的数,然后采用 Varint 编码,从而减少编码后使用的字节数。 Zigzag 编码过程:对于sint32类型的负数A,对其补码左移1位,右边补0,得到A1,然后对其补码右移31位,左边补1,得到A2。再令A1与A2按位异或得到正整数N,最后对N进行Varint 编码,得到最终的编码数据。sint64类型的负数编码过程类似。
由于Varint 编码可以自动解析内容的长度(通过最高位为0则可判断为数据的最后一字节),故对于整型数据的编码便不需要length字段来表示其长度。
对于字符串类型,由于其数据长度不固定,所以需要使用length字段来表示其长度。其tag与length字段为整型数据,所以可以采用Varint 方式进行编码,从而减少数据量。其value值则使用UTF-8编码来存储字符。
对于嵌套消息的编码,则其value为若干消息字段的拼接,即若干TLV结构的拼接。编码结构如下:
gRPC
gRPC介绍
gRPC是由 Google 开发的一个高性能、通用的开源RPC(远程过程调用)框架。gRPC以HTTP/2.0为通信协议,采用 Protocol Buffers定义与序列化消息与服务,同时支持多种编程语言,包括 Java、Python、Go、C#、Ruby、Node.js 等。
在gRPC中,我们称调用方为Client,被调用方为Server。通过ProtoBuf定义好消息与服务后,Server端需要实现定义的方法,Client只需要直接传递参数调用定义好的方法即可以拿到结果,其中网络通信及数据序列化过程等底层细节均被gPRC封装好了,并且gRPC提供了面向各种语言的代码生成器与服务发现工具,大大提高了使用者的编码效率。
服务定义从.proto开始,gRPC 提供生成客户端和服务器端代码的插件。gRPC 用户通常在客户端调用这些 API,并在服务器端实现相应的 API。在服务器方面,服务器实现服务声明的方法,并运行 gRPC 服务器来处理客户端请求。gRPC 基础框架解码传入的请求、执行服务方法并编码服务响应。在客户端方面,客户端有一个称为服务代理的stub对象,该对象提供给客户端相同的方法。然后,客户端只需在本地对象上调用这些方法,在适当的protbuf消息类型中包装请求的参数,gRPC 再将请求发送到服务器并返回服务器的协议缓冲响应后进行处理。
gRPC四种通信模式
gRPC有四种通信⽅式,分别是:简单 RPC(Unary RPC)、服务端流式 RPC (Server streaming RPC)、客户端流式 RPC (Clientstreaming RPC)、双向流式 RPC(Bi-directional streaming RPC)。
通信模式 | 描述 |
---|---|
简单 RPC | 一般的rpc调用,传入一个请求对象,返回一个返回对象 |
服务端流式 RPC | 传入一个请求对象,服务端可以返回多个结果对象 |
客户端流式 RPC | 客户端传入多个请求对象,服务端返回一个结果对象 |
双向流式 RPC | 结合客户端流式RPC和服务端流式RPC,可以传入多个请求对象,返回多个结果对象 |
简单 RPC:客户端发起一次请求,服务端响应一个数据。定义格式如下:
rpc simple(Request) returns (Response) {}
服务端流式rpc:客户端发送一个请求对象,服务端可以返回多个结果对象。服务端流 RPC 下,客户端发出一个请求,但不会立即得到一个响应,而是在服务端与客户端之间建立一个单向的流,服务端可以随时向流中写入多个响应消息,最后主动关闭流,而客户端需要监听这个流,不断获取响应直到流关闭。定义格式如下:
rpc serverStream(Request) returns (stream Response) {}
客户端流式rpc :客户端传入多个请求对象,服务端返回一个响应结果。定义格式如下:
rpc clientStream(stream Request) returns (Response) {}
双向流式rpc :客户端与服务端可以持续发送多个请求与响应对象。定义格式如下:
rpc bothStream(stream Request) returns (stream Response) {}
JAVA中的gRPC应用
使用简单gRPC通信模式编写一个程序用来简单模拟用户的注册与登录。服务端利用Map模拟数据库存储账号密码,提供注册与登录两个方法,并且通过拦截器来记录客户端请求方法以及消息。客户端通过gRPC生成的服务代理来调用方法,从而实现注册或登录。
Protoc安装
首先根据主机系统选择对应的Protocol Buffers版本文件,(可以访问https://github.com/protocolbuffers/protobuf/releases进行下载),然后解压缩至特定的目录下,最后把bin文件的目录添加到系统变量Path中。
gRPC相关依赖导入
在IDEA中新建一个Maven项目,在pom.xml文件中导入如下相关依赖(依赖来自https://github.com/grpc/grpc-java/),主要包括java使用netty框架进行gRPC网络通信的依赖,gRPC与ProtoBuf的整合依赖,gRPC生成服务代理的依赖。
<dependencies>
<!--java使用netty框架进行gRPC网络通信的依赖-->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.59.1</version>
<scope>runtime</scope>
</dependency>
<!--gRPC与ProtoBuf的整合依赖-->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.59.1</version>
</dependency>
<!--gRPC生成服务代理的依赖-->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.59.1</version>
</dependency>
<dependency> <!-- necessary for Java 9+ -->
<groupId>org.apache.tomcat</groupId>
<artifactId>annotations-api</artifactId>
<version>6.0.53</version>
<scope>provided</scope>
</dependency>
</dependencies>
消息类型及服务定义
新建一个Login.proto文件,编写客户端请求消息LoginRequest与服务端相应消息LoginResponse,编写服务LoginService,其中包含两个方法分别处理登录和注册请求。
syntax = "proto3";
option java_multiple_files = false;
option java_package = "com.wang";
option java_outer_classname = "LoginProto";
message LoginRequest{
int32 requestType = 1;
string userName = 2;
string password = 3;
}
message LoginResponse{
string response = 1;
}
service LoginService{
rpc Login(LoginRequest) returns(LoginResponse) {}
rpc Register(LoginRequest) returns(LoginResponse) {}
}
生成gRPC相关类
在pom.xml文件中添加以下配置,便可以在Maven中生成compile与compile-custom插件。其中compile插件可以用于自动生成消息类型及其相关操作的类,compile-custom则用于生成供客户端及服务端使用的服务接口。
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<!--生成消息类型及其相关操作的类-->
<protocArtifact>com.google.protobuf:protoc:3.24.0:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<!--生成服务接口-->
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.59.1:exe:${os.detected.classifier}</pluginArtifact>
<!--定义生成文件的地址-->
<outputDirectory>${basedir}/src/main/java</outputDirectory>
<!--设置为非覆盖式追加-->
<clearOutputDirectory>false</clearOutputDirectory>
</configuration>
<executions>
<execution>
<goals>
<!--生成消息的命令-->
<goal>compile</goal>
<!--生成服务的命令-->
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
定义消息与服务的.proto文件需要放在xxx/src/main/proto文件下,因为不加配置的情况下,compile与compile-custom插件均会在该目录下扫描.proto文件。找不到文件时,会出现以下情况:
生成成功后,则会在预先配置的com.wang目录下多出两个java类。LoginProto中包含对请求和回应消息体的构造器及其他相关操作方法,LoginServiceGrpc中主要包含需要服务端实现的抽象类,以及生成各种服务代理的方法。
服务端构建
服务端主要包括两个模块,一个是具体的业务处理模块,需要实现gRPC生成的服务抽象类来完成。另一个模块主要用于绑定监听端口,并发布服务。
//实现gRPC预先定义的服务方法
public class LoginServiceImpl extends LoginServiceGrpc.LoginServiceImplBase{
Map<String, String> AccountMap = new HashMap<>();//用于模拟存储账号密码的数据库
@Override
public void login(LoginProto.LoginRequest request, StreamObserver<LoginProto.LoginResponse> responseObserver) {//登录处理方法
//解析request数据
String userName = request.getUserName();
String password = request.getPassword();
//创建一个响应消息的构造器
LoginProto.LoginResponse.Builder builder = LoginProto.LoginResponse.newBuilder();
if(userName.isEmpty() || password.isEmpty()){
builder.setResponse("用户名、密码不能为空!");
}else if(!AccountMap.containsKey(userName)){
builder.setResponse("用户名错误!");
}else{
if(AccountMap.get(userName).equals(password)){
builder.setResponse("登录成功!");
}else{
builder.setResponse("密码错误!");
}
}
LoginProto.LoginResponse loginResponse = builder.build();
//发送响应
responseObserver.onNext(loginResponse);
//声明自己已完成响应
responseObserver.onCompleted();
}
@Override
public void register(LoginProto.LoginRequest request, StreamObserver<LoginProto.LoginResponse> responseObserver) {//注册处理方法
String userName = request.getUserName();
String password = request.getPassword();
LoginProto.LoginResponse.Builder builder = LoginProto.LoginResponse.newBuilder();
if(userName.isEmpty() || password.isEmpty()){
builder.setResponse("用户名、密码不能为空!");
}else if(AccountMap.containsKey(userName)){//注册账号已存在
builder.setResponse("用户名已存在!");
}else{
AccountMap.put(userName, password);
builder.setResponse("注册成功!");
}
LoginProto.LoginResponse loginResponse = builder.build();
responseObserver.onNext(loginResponse);
responseObserver.onCompleted();
}
}
//服务端拦截器,用于模拟日志,记录客户端请求
public class ServerLogInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata, ServerCallHandler<ReqT, RespT> serverCallHandler) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd/HH:mm:ss");
System.out.println(sdf.format(new Date(System.currentTimeMillis())) +
" Client Request: " + serverCall.getMethodDescriptor().getFullMethodName());
ServerCall.Listener<ReqT> listener = serverCallHandler.startCall(serverCall, metadata);
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(listener) {
@Override
public void onMessage(ReqT message) {
System.out.println(sdf.format(new Date(System.currentTimeMillis())) + " Received message: " + message.toString());
super.onMessage(message);
}
@Override
public void onHalfClose() {
System.out.println(sdf.format(new Date(System.currentTimeMillis())) + " Client closed the call");
super.onHalfClose();
}
@Override
public void onCancel() {
System.out.println(sdf.format(new Date(System.currentTimeMillis())) + " Call cancelled");
super.onCancel();
}
@Override
public void onComplete() {
System.out.println(sdf.format(new Date(System.currentTimeMillis())) + " Call completed");
super.onComplete();
}
};
}
}
//监听端口,发布服务
public class LoginServer {
public static void main(String[] args) throws IOException, InterruptedException {
//绑定端口
ServerBuilder serverBuilder = ServerBuilder.forPort(8888);
//添加服务
serverBuilder.addService(new LoginServiceImpl());
//添加拦截器
serverBuilder.intercept(new ServerLogInterceptor());
//创建服务对象
Server server = serverBuilder.build();
server.start();
server.awaitTermination();
}
}
客户端构建
客户端利用gRPC请求服务时,需要先创建一个服务代理,这个服务代理中提供了服务端发布的各种服务API,客户端通过代理便可以直接调用各种功能服务。
public class LoginClient {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", 8888).usePlaintext().build();
LoginServiceGrpc.LoginServiceBlockingStub loginService = LoginServiceGrpc.newBlockingStub(managedChannel);
LoginProto.LoginRequest.Builder builder;
while(true){
System.out.println("请选择服务:1:注册,2:登录,3:退出。");
String input = scanner.nextLine();
if("3".equals(input)){
exit(0);
}else if("1".equals(input) || "2".equals(input)){
builder = LoginProto.LoginRequest.newBuilder();
System.out.println("请输入用户名:");
String userName = scanner.nextLine();
System.out.println("请输入密码:");
String password = scanner.nextLine();
LoginProto.LoginRequest request = builder.setUserName(userName).setPassword(password).build();
if("1".equals(input)){
System.out.println(loginService.register(request).getResponse());
}else{
System.out.println(loginService.login(request).getResponse());
}
}else{
System.out.println("请输入正确的服务编号!");
}
}
}
}
运行结果
可以看到在客户端中调用注册与登录请求均正常执行,并且服务端的拦截器可以正确输出客户端请求的服务方法以及发送的消息。
参考链接
https://www.cnblogs.com/niuben/p/14212711.html