Skip to main content

gRPC

这一章中,我们使用 Java 语言,但不会使用 Spring 框架。

GraphQL 适合用于资源型接口。但是,还有些时候,我们需要去调用远程的某个服务,而非查询资源。如果用 REST,那么就会有很多的接口,而且每个接口都是一个资源。如果用 GraphQL,最后会产生很多没有意义的 mutation。这时候,我们就需要用到 RPC(Remote Procedure Call),即远程过程调用。

RPC 是与 HTTP 同级别的协议,且早于后者的出现。具体而言,RPC 希望能使得调用远程的函数与调用本地的函数一样简单。

RPC 有许多实现,其中最常用的是 gRPC,由 Google 开发。gRPC 是一个高性能、开源和通用的 RPC 框架,基于 HTTP/2 协议,支持多种语言。gRPC 使用 Protocol Buffers 作为接口描述语言,这样可以定义服务和消息。

此外,JSON RPC,dubbo 等也是常用的 RPC 框架。

gRPC 基本原理

gRPC 的原理很简单。当本地调用远程服务时,实际上是调用了一个代理对象。这个代理对象会将调用的方法、参数等信息序列化成二进制数据,然后通过网络传输到远程服务。远程服务接收到数据后,再将数据反序列化,调用相应的方法,然后将结果序列化后返回给客户端。

gRPC 中,这个序列化方法是 Protocol Buffers。Protocol Buffers 是一种轻便高效的结构化数据存储格式,类似于 XML。Protocol Buffers 可以用于结构化数据序列化,很适合用于通信协议和数据存储。

gRPC 中,使用.proto文件来约定接口。.proto文件定义了服务和消息。然后,使用 protoc 编译器生成客户端和服务端的代码。

项目配置

引入依赖,

implementation 'io.grpc:grpc-stub:1.66.0'
implementation 'io.grpc:grpc-protobuf:1.66.0'
implementation 'io.grpc:grpc-netty:1.66.0'
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'

然后,还有一个插件,

id "com.google.protobuf" version "0.9.4"

gRPC 是基于.proto文件的,.proto文件需要一个 protoc 编译器,可以使用本地的 protoc。此外,还需要一个代码生成器。这里代码生成器使用 java lite 版本,protoc 也适用 java lite 版本。

implementation 'com.google.protobuf:protobuf-javalite:4.28.0-RC3'

然后配置编译 proto 的插件和任务。

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.3"
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.66.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}

注意,这个版本必须对应上,最新版本参考官方GitHub

protobuf 语法

proto 文件是一个文本文件,用于定义消息类型和服务。

文件头

在 proto 文件头,需要指定 proto 文件的语法版本,目前最新的是 3 版本。

syntax = "proto3";

此外,还可以指定一些选项,可以用option关键字。

option java_multiple_files = true;

消息类型

消息类型是一个结构化的数据类型,用于定义消息的结构。消息类型可以包含标量类型、枚举类型、消息类型、数组类型等。

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

注意,上面的=不代表赋值,而是字段的标识符,或者说是标号。这个标识符是唯一的,用于标识字段。这个标识符是一个数字,可以是 1 到 22912^{29} - 1 之间的任意数字。这个数字用于标识字段的顺序,不要改变这个数字,因为这个数字会用于序列化和反序列化。1 到 15 的参数编码时使用 1 个字节,后续的参数编码时会使用更多的字节数。因此,在设计时,如果某字段不常用,把它放在后面,为以后可能的高频参数留下位置。

内置的类型列表如下,

.proto TypeNotesC++ TypeJava TypePython TypeGo TypeRuby TypeC# TypePHP Type
doubledoubledoublefloatfloat64Floatdoublefloat
floatfloatfloatfloatfloat32Floatfloatfloat
int32使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
uint32使用变长编码uint32intint/longuint32Fixnum 或者 Bignum(根据需要)uintinteger
uint64使用变长编码uint64longint/longuint64Bignumulonginteger/string
sint32使用变长编码,这些编码在负值时比int32高效的多int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
sint64使用变长编码,有符号的整型值。编码时比通常的int64高效。int64longint/longint64Bignumlonginteger/string
fixed32总是4个字节,如果数值总是比228大的话,这个类型会比uint32高效。uint32intintuint32Fixnum 或者 Bignum(根据需要)uintinteger
fixed64总是8个字节,如果数值总是比256大的话,这个类型会比uint64高效。uint64longint/longuint64Bignumulonginteger/string
sfixed32总是4个字节int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
sfixed64总是8个字节int64longint/longint64Bignumlonginteger/string
boolboolbooleanboolboolTrueClass/FalseClassboolboolean
string一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。stringStringstr/unicodestringString (UTF-8)stringstring
bytes可能包含任意顺序的字节数据。stringByteStringstr[]byteString (ASCII-8BIT)ByteStringstring

此外,还可以使用枚举类型,

enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}

message PhoneNumber {
string number = 1;
PhoneType type = 2;
enum Owner {
PERSONAL = 0;
COMPANY = 1;
}
Owner owner = 3;
}

消息类型可以嵌套,例如,

message PhoneNumber {
string number = 1;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
PhoneType type = 2;
}

message Record {
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
Person person = 1;
PhoneNumber phone = 2;
}

如果要使用数组,使用repeated关键字,

message AddressBook {
repeated Person person = 1;
}

protobuf 还有一些高级特性,例如oneofmap等,这里就不详细介绍了。

服务定义

服务定义用于定义服务的接口和方法。服务定义包含一个或多个方法,每个方法包含一个请求消息和一个响应消息。

service AddressBookService {
rpc AddPerson(Person) returns (Person);
rpc GetPerson(Person) returns (Person);
}

注意,小括号里必须是消息类型,不能是基本类型。

服务端实现

现在我们定义一个 proto 文件,proto 文件应当放在src/main/proto目录下。例如,我们定义一个AddressBook.proto文件,

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.github.fingerbone.demo";

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

message PersonId {
int32 id = 1;
}

service EmailService {
rpc GetPersonById(PersonId) returns (Person);
rpc AddPerson(Person) returns (PersonId);
}

现在如果编译项目,会产生对应的类,对应的 java 文件在build/generated/source/proto/main/java/io/github/fingerbone/demo目录下。当然,这些文件很复杂,我们不需要关心这些文件。

要实现服务,需要继承生成的 Service,

package io.github.fingerbone.demo;

import io.grpc.stub.StreamObserver;
import java.util.HashMap;
import java.util.Map;

public class EmailServiceImpl extends EmailServiceGrpc.EmailServiceImplBase {

private final Map<Integer, Person> personMap = new HashMap<>();

@Override
public void getPersonById(PersonId request, StreamObserver<Person> responseObserver) {
Person person = personMap.get(request.getId());
if (person != null) {
responseObserver.onNext(person);
} else {
responseObserver.onError(new Exception("Person not found"));
}
responseObserver.onCompleted();
}

@Override
public void addPerson(Person request, StreamObserver<PersonId> responseObserver) {
personMap.put(request.getId(), request);
PersonId personId = PersonId.newBuilder().setId(request.getId()).build();
responseObserver.onNext(personId);
responseObserver.onCompleted();
}
}

但是,现在只是实现了服务,还没有启动服务。要启动服务,需要使用Server类,

package io.github.fingerbone.demo;

import java.io.IOException;

import io.grpc.Server;
import io.grpc.ServerBuilder;

public class Application {

public static void main(String[] args) throws IOException, InterruptedException {
Server server = ServerBuilder.forPort(8080)
.addService(new EmailServiceImpl())
.build();
server.start();
server.awaitTermination();
}

}

然后,在 gradle 中设置启动类,

application {
// Define the main class for the application.
mainClass = 'io.github.fingerbone.demo.Application'
}

使用gradle run启动服务。

现在可以用 Postman 进行调试,输入地址并导入 proto 文件,然后就可以调用服务了。

不过,更方便的方法是开启反射,这样就可以直接读取 RPC 信息。

在依赖中加入,

implementation 'io.grpc:grpc-services:1.42.1'

然后添加反射服务,

public static void main(String[] args) throws IOException, InterruptedException {
Server server = ServerBuilder.forPort(8080)
.addService(new EmailServiceImpl())
.addService(ProtoReflectionService.newInstance())
.build();
server.start();
server.awaitTermination();
}

现在,在 Postman 中使用 gRPC,然后输入地址,在过程选择里使用反射自动获取,就可以调用服务了。

客户端实现

客户端要引用相同的库,并进行相同的配置和编译。如果是多体项目,最好将 proto 文件单独提取出来,然后在其他项目中引用。

客户端要使用,

implementation 'net.devh:grpc-client-spring-boot-starter'
implementation project(':grpc-lib')

然后在配置文件中加入,

grpc:
client:
address:
default:
host: localhost
port: 8080

然后在配置类中加入,

@Configuration
public class GrpcConfig {

@Bean
public ManagedChannel managedChannel() {
return ManagedChannelBuilder.forAddress("localhost", 8080)
.usePlaintext()
.build();
}

@Bean
public EmailServiceGrpc.EmailServiceBlockingStub emailServiceBlockingStub(ManagedChannel managedChannel) {
return EmailServiceGrpc.newBlockingStub(managedChannel);
}

}

然后在服务中使用,

@Service
public class EmailService {

@Autowired
private EmailServiceGrpc.EmailServiceBlockingStub emailServiceBlockingStub;

public Person getPersonById(int id) {
return emailServiceBlockingStub.getPersonById(PersonId.newBuilder().setId(id).build());
}

public PersonId addPerson(Person person) {
return emailServiceBlockingStub.addPerson(person);
}

}

可以看到,这样子,远程的服务就可以像调用本地服务一样调用了。