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 到 之间的任意数字。这个数字用于标识字段的顺序,不要改变这个数字,因为这个数字会用于序列化和反序列化。1 到 15 的参数编码时使用 1 个字节,后续的参数编码时会使用更多的字节数。因此,在设计时,如果某字段不常用,把它放在后面,为以后可能的高频参数留下位置。
内置的类型列表如下,
.proto Type | Notes | C++ Type | Java Type | Python Type | Go Type | Ruby Type | C# Type | PHP Type |
---|---|---|---|---|---|---|---|---|
double | double | double | float | float64 | Float | double | float | |
float | float | float | float | float32 | Float | float | float | |
int32 | 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代 | int32 | int | int | int32 | Fixnum 或者 Bignum(根据需要) | int | integer |
uint32 | 使用变长编码 | uint32 | int | int/long | uint32 | Fixnum 或者 Bignum(根据需要) | uint | integer |
uint64 | 使用变长编码 | uint64 | long | int/long | uint64 | Bignum | ulong | integer/string |
sint32 | 使用变长编码,这些编码在负值时比int32高效的多 | int32 | int | int | int32 | Fixnum 或者 Bignum(根据需要) | int | integer |
sint64 | 使用变长编码,有符号的整型值。编码时比通常的int64高效。 | int64 | long | int/long | int64 | Bignum | long | integer/string |
fixed32 | 总是4个字节,如果数值总是比228大的话,这个类型会比uint32高效。 | uint32 | int | int | uint32 | Fixnum 或者 Bignum(根据需要) | uint | integer |
fixed64 | 总是8个字节,如果数值总是比256大的话,这个类型会比uint64高效。 | uint64 | long | int/long | uint64 | Bignum | ulong | integer/string |
sfixed32 | 总是4个字节 | int32 | int | int | int32 | Fixnum 或者 Bignum(根据需要) | int | integer |
sfixed64 | 总是8个字节 | int64 | long | int/long | int64 | Bignum | long | integer/string |
bool | bool | boolean | bool | bool | TrueClass/FalseClass | bool | boolean | |
string | 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。 | string | String | str/unicode | string | String (UTF-8) | string | string |
bytes | 可能包含任意顺序的字节数据。 | string | ByteString | str | []byte | String (ASCII-8BIT) | ByteString | string |
此外,还可以使用枚举类型,
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 还有一些高级特性,例如oneof
,map
等,这里就不详细介绍了。
服务定义
服务定义用于定义服务的接口和方法。服务定义包含一个或多个方法,每个方法包含一个请求消息和一个响应消息。
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);
}
}
可以看到,这样子,远程的服务就可以像调用本地服务一样调用了。