大家好,我是 方圆 。gRPC(general-purpose RPC framework) ,它是开源高性能、通用的 RPC 框架,基于 HTTP/2 协议,使用 Protocol Buffers 作为接口描述语言和消息序列化格式。本篇文章以用 Java 编写 gRPC 接口为例,介绍 gRPC 相关的基本概念和使用方法:既可以简单地了解到 gRPC 的使用,也可以深入细节中熟悉更多相关的内容,代码示例参考 grpc-java-example。
gRPC 最初由 Google 开发,大多数资料里会说 "g" 表示 "Google",实际上它有 "g" 含义变化的小故事:"g" 在最初确实代表 "Google",后来随着 gRPC 开源并成为 CNCF(云原生计算基金会)的孵化项目,Google 有意淡化了品牌色彩,现在更多被理解为 "general-purpose RPC",不再强调 Google 的部分,在官网的描述 "universal" 也更多体现它通用性的定位。
接下来我们通过一个简单的小例子来介绍下 gRPC 的用法:
gRPC 怎么用?
通常情况下大家在定义 RPC 接口时,通常会创建 Java Interface 来定义接口签名(京东内部采用 JSF 框架便是如此),之后调用方通过这个 Interface 找到对应的服务便能调用 RPC 接口。而在 gRPC 中,它需要定义 .proto
文件来声明接口,我们来看一个简单的小例子:
protobuf
syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.grpc.helloworld";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";
package com.grpc.helloworld;
// 定义一个接口
service Greeter {
// 接口中的方法
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// 请求入参
message HelloRequest {
string name = 1;
}
// 请求出参
message HelloReply {
string message = 1;
}
在这个例子中,我们定义了一个名为 Greeter
的接口,它有一个名为 SayHello
的方法,该方法接受一个 HelloRequest
类型的参数,并返回一个 HelloReply
类型的响应。但是定义在 .proto
文件中的接口,并不能直接被调用,需要通过 gRPC 的代码生成工具来生成需要的语言的代码,这样才能供我们使用。那么该如何生成呢?
其实很简单,它提供了一个 Maven 编译的插件 protobuf-maven-plugin
能将 protobuf 编译成 Java 代码,执行 mvn compile
即可:
xml
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.75.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.75.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.75.0</version>
</dependency>
</dependencies>
<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.25.5:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.75.0:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
经过编译后在 Output 输出路径下能看见它将接口定义编译成了类 GreeterGrpc
,入参类型为 HelloRequest
,出参类型为 HelloReply
:

生成的这些类中都包含什么内容呢?首先让我们看看生成的入参类型 HelloRequest
:
java
public final class HelloRequest extends GeneratedMessageV3 implements HelloRequestOrBuilder {
// ...
private volatile Object name_ = "";
private HelloRequest() {
this.name_ = "";
}
// 转换类型并获取字段值
public String getName() {
Object ref = this.name_;
if (ref instanceof String) {
return (String)ref;
} else {
ByteString bs = (ByteString)ref;
String s = bs.toStringUtf8();
this.name_ = s;
return s;
}
}
}
在这个类中它生成了 name_
字段,与 .proto
文件中定义的 name
字段相对应,但类型为 Object,并不是 String,实际类型的转换操作会在 getName
方法中完成的。
如果我们想创建 HelloRequest
对象并为 name
字段赋值该如何做呢?HelloRequest
并没有直接开放出来 setName
方法,构造方法也是私有的,那该如何完成对象的创建呢?
实际上 gRPC 编译生成的类采用了 建造者模式 ,它会公开 newBuilder
方法来创建建造者,从它实现的接口 HelloRequestOrBuilder
命名中也能发现建造者模式的影子,它的这种接口名定义增加了代码的可读性,如下代码所示:
java
public final class HelloRequest extends GeneratedMessageV3 implements HelloRequestOrBuilder {
// 饿汉式 单例模式
private static final HelloRequest DEFAULT_INSTANCE = new HelloRequest();
private HelloRequest() {
this.name_ = "";
}
// 1.默认实现
public static Builder newBuilder() {
return DEFAULT_INSTANCE.toBuilder();
}
// 2.
public Builder toBuilder() {
return this == DEFAULT_INSTANCE ? new Builder() : (new Builder()).mergeFrom(this);
}
private HelloRequest(GeneratedMessageV3.Builder<?> builder) {
super(builder);
}
public static final class Builder extends GeneratedMessageV3.Builder<Builder> implements HelloRequestOrBuilder {
private int bitField0_;
private Object name_ = "";
// 3.
private Builder() {
}
// 4. 为 name 字段赋值
public Builder setName(String value) {
if (value == null) {
throw new NullPointerException();
} else {
this.name_ = value;
// 位运算占位标记被修改,1 的二进制表示最低位为 1,所以位或运算会更将最低位置为 1
this.bitField0_ |= 1;
this.onChanged();
return this;
}
}
// 5. 生成对象实例
public HelloRequest build() {
HelloRequest result = this.buildPartial();
if (!result.isInitialized()) {
throw newUninitializedMessageException(result);
} else {
return result;
}
}
}
}
其中 DEFAULT_INSTANCE
采用了 饿汉式的单例模式 ,提供了 HelloRequest
类型对象的默认空实现,这样便能减少空对象重复创建的开销,实现空对象的复用。
在 Builder
类中可以发现 setName
方法能够为 name
字段赋值,在创建 HelloRequest
对象时需要这样:
java
HelloRequest request = HelloRequest.newBuilder().setName("World").build();
现在我们回到 setName
方法中看一下其中的小细节:
setName
更新字段值的方法不能为 null,否则会抛出异常,这一点比较简单,能在源码中一眼发现- 标记
Builder#bitField0_
字段的最低位 1 为 1
第二点值得我们深入了解一下,在 setName
方法中,通过位运算将 Builder#bitField0_
二进制的第 0 位(最低位)修改成了 1,修改成 1 后有什么作用呢?我们看看以下源码:
java
public static final class Builder extends GeneratedMessageV3.Builder<Builder> implements HelloRequestOrBuilder {
public HelloRequest build() {
HelloRequest result = this.buildPartial();
if (!result.isInitialized()) {
throw newUninitializedMessageException(result);
} else {
return result;
}
}
public HelloRequest buildPartial() {
HelloRequest result = new HelloRequest(this);
// bitField0_ 不为 0 表示有字段被修改过
if (this.bitField0_ != 0) {
this.buildPartial0(result);
}
this.onBuilt();
return result;
}
private void buildPartial0(HelloRequest result) {
int from_bitField0_ = this.bitField0_;
// 通过位运算的占位信息判断哪些字段被修改,被修改的值才会被赋值
if ((from_bitField0_ & 1) != 0) {
result.name_ = this.name_;
}
}
}
在 buildPartial
和 buildPartial0
方法中都能发现使用 bitField0
_ 进行判断的逻辑,并且在构建对象时,只有被修改的字段才会被赋值 ,那么这样的好处是什么呢?一是 能减少对象创建的开销,二是在对象序列化时,未被赋值的字段不会被序列化,这样能减少序列化后的数据量。
某个字段被赋值后,在清除它的值时,该如何修改 Builder#bitField0_
的值呢?Builder
中提供了 clearName
方法:
java
public static final class Builder extends GeneratedMessageV3.Builder<Builder> implements HelloRequestOrBuilder {
public Builder clearName() {
// 获取空对象的默认字段值
this.name_ = HelloRequest.getDefaultInstance().getName();
// 位与运算将最低位置为 0
this.bitField0_ &= -2;
this.onChanged();
return this;
}
}
可以发现它通过位与 -2,来将最低位置为 0。那么为什么是 -2 呢?因为 -2 的二进制表示为 11111111111111111111111111111110
,这样位与计算后能保证其他位不变,只变更最低位,那么 -2 是怎么计算得来的呢?可以通过以下这个简单的公式进行计算:
~(1 << n)
以清除第 0 位为例,想要获取 -2 的二进制表示,可以先获取 1 的二进制,然后取反(~
运算符),如下所示:
text
~(1 << 0)
= ~1
= ~(00000000000000000000000000000001)
= 11111111111111111111111111111110
= -2
这样便得到了 -2 的二进制表示,然后通过位与运算,将最低位置为 0。所以,清除其他位的计算方式以此类推:
text
清除第1位:~(1 << 1) = ~2 = -3
清除第2位:~(1 << 2) = ~4 = -5
在对对象进行序列化时,会调用 writeTo
方法,但是在这个方法中并没有依赖 Builder#bitField0_
字段判断某字段是否需要被序列化,而且通过字段值非空判断的,序列化逻辑调用了 gRPC 库中的 GeneratedMessageV3#isStringEmpty
方法,就不再深入了,如下所示:
java
public final class HelloRequest extends GeneratedMessageV3 implements HelloRequestOrBuilder {
// ...
public void writeTo(CodedOutputStream output) throws IOException {
if (!GeneratedMessageV3.isStringEmpty(this.name_)) {
GeneratedMessageV3.writeString(output, 1, this.name_);
}
this.getUnknownFields().writeTo(output);
}
}
以上就是 HelloRequest
中的重要内容,HelloReply
类型与 HelloRequest
类型中内容基本一致,所以不再赘述。接下来我们再看一下 GreeterGrpc
接口中的内容,它提供了多种获取 Stub
的方法:
java
public final class GreeterGrpc {
// 创建同步阻塞存根
public static GreeterBlockingStub newBlockingStub(Channel channel) {
AbstractStub.StubFactory<GreeterBlockingStub> factory = new AbstractStub.StubFactory<GreeterBlockingStub>() {
public GreeterBlockingStub newStub(Channel channel, CallOptions callOptions) {
return new GreeterBlockingStub(channel, callOptions);
}
};
return (GreeterBlockingStub)GreeterGrpc.GreeterBlockingStub.newStub(factory, channel);
}
// 创建异步存根
public static GreeterStub newStub(Channel channel) {
AbstractStub.StubFactory<GreeterStub> factory = new AbstractStub.StubFactory<GreeterStub>() {
public GreeterStub newStub(Channel channel, CallOptions callOptions) {
return new GreeterStub(channel, callOptions);
}
};
return (GreeterStub)GreeterGrpc.GreeterStub.newStub(factory, channel);
}
// 创建异步 Future 存根
public static GreeterFutureStub newFutureStub(Channel channel) {
AbstractStub.StubFactory<GreeterFutureStub> factory = new AbstractStub.StubFactory<GreeterFutureStub>() {
public GreeterFutureStub newStub(Channel channel, CallOptions callOptions) {
return new GreeterFutureStub(channel, callOptions);
}
};
return (GreeterFutureStub)GreeterGrpc.GreeterFutureStub.newStub(factory, channel);
}
//...
}
那么 Stub
又是什么呢?我们拿其中的 GreeterBlockingStub
为例,看看它的实现:
java
public final class GreeterGrpc {
// 阻塞调用存根
public static final class GreeterBlockingStub extends AbstractBlockingStub<GreeterBlockingStub> {
private GreeterBlockingStub(Channel channel, CallOptions callOptions) {
super(channel, callOptions);
}
protected GreeterBlockingStub build(Channel channel, CallOptions callOptions) {
return new GreeterBlockingStub(channel, callOptions);
}
public HelloReply sayHello(HelloRequest request) {
return (HelloReply)ClientCalls.blockingUnaryCall(this.getChannel(), GreeterGrpc.getSayHelloMethod(), this.getCallOptions(), request);
}
}
}
在 GreeterBlockingStub
中会编译生成 sayHello
方法,与 .proto
文件中定义的方法签名一致,之后客户端便可以通过调用 GreeterBlockingStub#sayHello
方法来实现 RPC 调用,非常简单,具体通讯逻辑是借助 grpc-stub
包下 ClientCalls#blockingUnaryCall
方法完成的,所以实际上 Stub
是 gRPC 客户端的代理对象,它将服务端通信的所有细节封装起来,让客户端实现开箱即用。
在创建 Stub
的方法中,入参都是 Channel
对象,那么该如何理解 Channel
呢?在 gRPC 中,Channel
是客户端与服务端之间的 通信通道 ,它代表到特定服务器地址的连接,是 gRPC 客户端的 网络通信抽象层 ,它将复杂的网络连接管理、协议处理和传输细节封装起来,为 Stub
提供简洁的通信接口,是实现高效、可靠 RPC 通信的基础设施。在创建 Channel
对象时,gRPC 同样地也采用了 建造者模式 ,提供的建造者实现为 ManagedChannelBuilder
,创建一个 Channel
对象的示例如下:
java
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build();
ManagedChannelBuilder#forAddress
方法会绑定具体的 IP 和端口:
java
public abstract class ManagedChannelBuilder<T extends ManagedChannelBuilder<T>> {
public static ManagedChannelBuilder<?> forAddress(String name, int port) {
// provider 方法获取 Channel 实现类
return ManagedChannelProvider.provider().builderForAddress(name, port);
}
}
执行 ManagedChannelProvider#provider
方法会获取实际的 Channel 实现类,我们看一下它的实现:
java
public abstract class ManagedChannelProvider {
public static ManagedChannelProvider provider() {
ManagedChannelProvider provider = ManagedChannelRegistry.getDefaultRegistry().provider();
if (provider == null) {
throw new ProviderNotFoundException("No functional channel service provider found. "
+ "Try adding a dependency on the grpc-okhttp, grpc-netty, or grpc-netty-shaded "
+ "artifact");
}
return provider;
}
}
在获取 ManagedChannelProvider
的实现类时有一段异常提示信息(没有找到 Channel
的提供者,请添加 grpc-okhttp
或 grpc-netty
或 grpc-netty-shaded
的依赖):
No functional channel service provider found. Try adding a dependency on the grpc-okhttp, grpc-netty, or grpc-netty-shaded artifact
根据这段信息能猜得到:它的 Channel
实现类并没有在 grpc-core 的核心依赖包中,而是需要通过额外的上述三种依赖的其一来引入具体的实现,这种设计采用了 面向接口编程 和 松耦合的设计 ,在运行时能动态加载需要的实现类(这也与前文中依赖 grpc-netty-shaded
呼应)。
接下来我们顺着它的源码,看看它到底是如何发现具体的实现类的?因为源码执行步骤较多,我们只看最终逻辑。最终会发现它执行了 ServiceLoader#load
方法,它会去找 io.grpc.ManagedChannelProvider
类型的实现类:
java
final class ServiceProviders {
// 入参 klass: io.grpc.ManagedChannelProvider
public static <T> Iterable<T> getCandidatesViaServiceLoader(Class<T> klass, ClassLoader cl) {
// SPI 服务发现
Iterable<T> i = ServiceLoader.load(klass, cl);
if (!i.iterator().hasNext()) {
i = ServiceLoader.load(klass);
}
return i;
}
}
而 ServiceLoader
是 Java 提供的 服务发现和加载机制 (java.util 包下),可以在运行时动态发现和加载接口实现类,也叫 Java SPI (Service Provider Interface) 。比如依赖 grpc-netty-shaded
依赖包,可以发现在 META-INF/services/io.grpc.ManagedChannelProvider
目录下它定义了两个不同的实现类:

gRPC 在加载到这两个实现类之后,选择了第一个(get(0)),源码如下:
java
public abstract class ManagedChannelProvider {
ManagedChannelProvider provider() {
List<ManagedChannelProvider> providers = providers();
// 获取 provider 的第一个实现类
return providers.isEmpty() ? null : providers.get(0);
}
}
因为 SPI 并不是本次分享主要介绍的内容,但是方便大家理解,在这里也把它比较重要的部分介绍一下。在上边的逻辑中,可以发现执行了 ServiceLoader#load
方法来加载具体的实现类,但是实际上它并不会立即去执行加载,而是采用了 懒加载机制 。它实现了 Iterable
接口,当发生遍历操作时触发加载,ServiceLoader
中有 LazyClassPathLookupIterator
的实现,当执行到 hasNext
方法时,会触发实际的加载操作:
java
public final class ServiceLoader<S> implements Iterable<S> {
// ...
private final class LazyClassPathLookupIterator<T> implements Iterator<Provider<T>> {
static final String PREFIX = "META-INF/services/";
// 执行入口
@Override
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
// 判断是否有下一个要被发现的服务
private boolean hasNextService() {
while (nextProvider == null && nextError == null) {
try {
// 触发加载的动作
Class<?> clazz = nextProviderClass();
if (clazz == null)
return false;
if (clazz.getModule().isNamed()) {
// ignore class if in named module
continue;
}
if (service.isAssignableFrom(clazz)) {
Class<? extends S> type = (Class<? extends S>) clazz;
Constructor<? extends S> ctor
= (Constructor<? extends S>)getConstructor(clazz);
ProviderImpl<S> p = new ProviderImpl<S>(service, type, ctor, acc);
nextProvider = (ProviderImpl<T>) p;
} else {
fail(service, clazz.getName() + " not a subtype");
}
} catch (ServiceConfigurationError e) {
nextError = e;
}
}
return true;
}
private Class<?> nextProviderClass() {
if (configs == null) {
try {
// 拼接路径,约定大于配置,定义所有的服务发现都需要在 META-INF/services/ 目录下
String fullName = PREFIX + service.getName();
// 触发加载操作
if (loader == null) {
configs = ClassLoader.getSystemResources(fullName);
} else if (loader == ClassLoaders.platformClassLoader()) {
// The platform classloader doesn't have a class path,
// but the boot loader might.
if (BootLoader.hasClassPath()) {
configs = BootLoader.findResources(fullName);
} else {
configs = Collections.emptyEnumeration();
}
} else {
configs = loader.getResources(fullName);
}
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
// 处理结果并返回
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return null;
}
pending = parse(configs.nextElement());
}
String cn = pending.next();
try {
return Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service, "Provider " + cn + " not found");
return null;
}
}
}
}
在 IDEA 中添加断点查看会更直观:

现在有了 Channel
对象,接下来就可以通过它来创建 Stub
对象了,那么创建 Stub
对象的逻辑有什么值得关注的吗?以创建阻塞的 GreeterBlockingStub
为例:
java
public final class GreeterGrpc {
public static GreeterBlockingStub newBlockingStub(Channel channel) {
// 工厂方法模式
AbstractStub.StubFactory<GreeterBlockingStub> factory = new AbstractStub.StubFactory<GreeterBlockingStub>() {
// 最终会执行到 newStub 方法
public GreeterBlockingStub newStub(Channel channel, CallOptions callOptions) {
return new GreeterBlockingStub(channel, callOptions);
}
};
return (GreeterBlockingStub)GreeterGrpc.GreeterBlockingStub.newStub(factory, channel);
}
}
// 封装 Channel 对象
public abstract class AbstractStub<S extends AbstractStub<S>> {
private final Channel channel;
private final CallOptions callOptions;
protected AbstractStub(Channel channel, CallOptions callOptions) {
this.channel = checkNotNull(channel, "channel");
this.callOptions = checkNotNull(callOptions, "callOptions");
}
}
在创建 GreeterBlockingStub
对象时,采用了 工厂方法模式 ,最终会执行 AbstractStub
的构造方法,将重要的 Channel
对象封装起来。以上关于 gRPC 客户端需要了解的内容就差不多了,现在我们来使用 Java 代码来实现一个简单的 Hello World 客户端:
java
/**
* gRPC Hello World 客户端
*/
public class HelloWorldClient {
public static void main(String[] args) {
// 创建 gRPC 通道
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051)
.usePlaintext()
.build();
// 创建客户端存根
GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
// 创建异步客户端存根
// GreeterGrpc.GreeterStub stub = GreeterGrpc.newStub(channel);
// 创建支持 Future 的异步客户端存根
// GreeterGrpc.GreeterStub stub = GreeterGrpc.newFutureStub(channel);
// 创建请求
HelloRequest request = HelloRequest.newBuilder()
.setName("World")
.build();
// 调用服务
HelloReply response = stub.sayHello(request);
// 输出响应
System.out.println("收到响应: " + response.getMessage());
// 关闭通道
channel.shutdown();
}
}
客户端调用接口,服务端收到请求后处理并返回响应,那么服务端该如何提供接口服务呢?这就需要我们再看一下 GreeterGrpc
,在 GreeterGrpc
中定义了 GreeterImplBase
类,该类实现了 AsyncService
接口,在 AsyncService
接口中提供了 sayHello
方法的默认实现(default),如下所示:
java
public final class GreeterGrpc {
public interface AsyncService {
// .proto 文件中定义的接口
default void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
ServerCalls.asyncUnimplementedUnaryCall(GreeterGrpc.getSayHelloMethod(), responseObserver);
}
}
public abstract static class GreeterImplBase implements BindableService, AsyncService {
public final ServerServiceDefinition bindService() {
return GreeterGrpc.bindService(this);
}
}
}
这样,我们只需要继承 GreeterImplBase
类,并重写 sayHello
方法即可,如下所示:
java
private static class GreeterImpl extends GreeterGrpc.GreeterImplBase {
@Override
public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
try {
String name = request.getName();
logger.info("收到问候请求,姓名: " + name);
// 创建响应
HelloReply reply = HelloReply.newBuilder()
.setMessage("Hello, " + name + "!")
.build();
// 发送一条响应
responseObserver.onNext(reply);
// 完成响应
responseObserver.onCompleted();
logger.info("已发送问候响应: " + reply.getMessage());
} catch (Exception e) {
// 发送异常响应
responseObserver.onError(Status.INTERNAL
.withDescription("服务器内部错误")
.withCause(e)
.asRuntimeException());
}
}
}
单纯的实现接口逻辑还不够,还需要将启动 gRPC 服务并将该接口注册上去,这样客户端才能调用接口,那么该如何做呢?gRPC 同样给我们提供了采用 建造者模式 的方法来创建并启动 gRPC 服务:
java
public class HelloWorldServer {
private void start() throws IOException {
/* 监听端口为 50051 */
int port = 50051;
Server server = ServerBuilder.forPort(port)
// 注册接口
.addService(new GreeterImpl())
.build()
.start();
logger.info("服务器已启动,监听端口: " + port);
}
}
其中 ServerBuilder#addService
方法的入参为 BindableService
接口:
java
public abstract class ServerBuilder<T extends ServerBuilder<T>> {
public abstract T addService(BindableService bindableService);
}
这也就是生成的 GreeterImplBase
也实现 BindableService
接口的原因。服务端成功启动后,客户端再调用接口便能完成通讯了:
text
服务端:
信息: 服务器已启动,监听端口: 50051
信息: 收到问候请求,姓名: World
信息: 已发送问候响应: Hello, World!
信息: 收到问候请求,姓名: World
信息: 已发送问候响应: Hello, World!
客户端:
收到响应: Hello, World!
收到响应: Hello, World!
使用 gRPC 的优势是什么?
在上文中,我们大概知道了什么是 gRPC 以及 gRPC 的基本用法,那么使用它有什么好处呢?相对直观的好处是 gRPC 将通讯的内部细节全部封装了起来,开发只需要关注业务细节,实现了 开箱即用 。并且 gRPC 相关的类由 .proto
文件编译生成,所以它也就具备了 ProtoBuf 协议的特点:
- 跨语言 :
.proto
文件不仅仅能编译成 Java 语言,还能被编译成 go, c++, node 和 php 等多种语言,可以轻易的实现跨语言通讯 - Protobuf 的序列化机制具有 体积小、解析快 的特点,所以性能也更好,相比于 JSON 序列化后的结果通常要小 20%-50%,而且 ProtoBuf 协议下定义的每个字段在类中都有固定的类型和存储位置,编译器已经知道字段的类型、顺序信息,运行时不需要再通过反射或动态类型判断类型,解析更快
此外,gRPC 基于 HTTP/2.0 协议,那么它也就具备了这个协议的特点,包括多路复用、头部压缩等,这让它 性能更高 ,适用于分布式服务间的内部通讯,并且还支持 双向流通讯 ,这样在 实时通讯 的场景也可以使用 gRPC。
除了定义上述简单的 .proto
文件,我们还可以定义复杂的 proto
文件,在接下来的"protobuf 定义"小节中会介绍一些定义 protobuf 的语法和注意事项,大家选择性地阅读。
protobuf 定义
protobuf 是一种语言无关、平台无关、可扩展的结构化数据序列化机制,它类似于 JSON 或 XML,但更高效、更紧凑。protobuf 通过定义服务和消息类型,gRPC 可以自动生成客户端和服务器端的代码,它除了支持上文中简单的一元调用接口定义外,还支持定义更复杂的类型和服务,包括流式 RPC、嵌套消息、枚举等,如下所示:
- 枚举类型 (Enum)
protobuf
enum UserStatus {
// 枚举的第一个值必须是0
UNKNOWN = 0;
ACTIVE = 1;
INACTIVE = 2;
}
枚举用于定义一组预定义的常量值,第一个枚举值必须是 0(作为默认值),可以嵌套在消息内部定义。
- 重复字段 (Repeated Fields)
protobuf
// 相当于 List<PhoneNumber>
repeated PhoneNumber phones = 5;
// 相当于 List<String>
repeated string tags = 6;
repeated
关键字表示该字段可以重复出现多次,在 Java 中生成为 List<T>
类型,相当于数组或列表。
- 映射类型 (Map)
protobuf
// 键值对映射
map<string, string> metadata = 9;
map<string, int32> scores = 10;
map<K, V>
语法定义键值对映射,键的类型可以是除了浮点数和 bytes 之外的任何标量类型,值可以是任何类型(标量、消息、枚举)。
- 可选字段 (Optional Fields)
protobuf
message User {
// 隐式存在语义
string name = 1;
// 显式存在语义
optional string email = 2;
}
在 proto3 中,使用 optional
关键字明确标记可选字段,提供显式的字段存在检查,区别于默认的隐式存在语义。
在 Java 中的表现:
java
// 隐式存在语义的字段
User user = User.newBuilder().build();
// 输出: ""(空字符串,默认值)
System.out.println(user.getName());
// 无法区分是用户主动设置为空字符串,还是没有设置
// 显式存在语义的字段,输出: false(明确知道没有设置)
System.out.println(user.hasEmail());
User userWithEmail = User.newBuilder().setEmail("").build();
// 输出: true(明确知道设置了,即使是空值)
System.out.println(userWithEmail.hasEmail());
- Oneof 字段
protobuf
oneof sort_criteria {
string sort_by_field = 4;
Priority sort_by_priority = 5;
}
oneof
表示同时只能设置其中一个字段,类似于 union 类型,节省内存和网络带宽。
- 嵌套消息
protobuf
message User {
Profile profile = 14;
// 嵌套定义
message Profile {
string bio = 1;
int32 age = 2;
}
}
消息可以嵌套定义在其他消息内部,提供更好的代码组织和命名空间隔离。
- Well-Known Types
protobuf
google.protobuf.Timestamp created_at = 12;
google.protobuf.Any payload = 2;
Well-Known Types 是 Protocol Buffers 提供的一组标准类型,包含常用的时间戳、持续时间、任意类型消息等。这些类型可以直接使用,无需重新定义,包含:
Timestamp
: 时间戳Duration
: 时间间隔Any
: 可以包含任意类型的消息Empty
: 空消息
- 流式 RPC 类型
protobuf
service UserService {
// 服务器流式 - 一个请求,多个响应
rpc ListUsers(ListUsersRequest) returns (stream User);
// 客户端流式 - 多个请求,一个响应
rpc BatchCreateUsers(stream CreateUserRequest) returns (BatchOperationResponse);
// 双向流式 - 多个请求,多个响应
rpc ChatWithUsers(stream ChatMessage) returns (stream ChatMessage);
}
流式 RPC 允许客户端和服务器之间进行多次消息交换,支持以上注释中标记的三种类型。
定义 protobuf 注意:
- 字段名使用
snake_case
;消息名使用PascalCase
;服务名使用PascalCase
;枚举值使用UPPER_SNAKE_CASE
- 字段编号 1-15:使用 1 个字节编码(推荐用于最常用字段)
- 字段编号 16-2047:使用 2 个字节编码
- 字段编号不需要连续 :可以跳号,如
1, 3, 5, 100
都是合法的 - 保留编号范围:19000-19999 为 Protocol Buffers 内部保留,不能使用
- 字段编号不能重复使用:一旦使用过的编号,即使删除字段也不能再次使用
那么为什么删除字段后要保留编号呢? 因为要保留向后兼容性,确保旧版本客户端仍能与新版本服务器正常通信:
protobuf
// 版本 1
message User {
string name = 1;
// 后来要删除的字段
string email = 2;
int32 age = 3;
}
// 版本 2 - 错误的做法
message User {
string name = 1;
// 删除了 email 字段
int32 age = 3;
// ❌ 重用了编号2,这会导致问题!
string phone = 2;
}
旧版本客户端发送包含 email
(编号2,string类型)的消息,而新版本服务器按 phone
(编号2,string类型)解析,虽然类型相同,但语义完全不同,导致数据混乱。正确的做法是 保留已删除字段的编号,禁止重用:
protobuf
// 版本 2 - 正确的做法
message User {
string name = 1;
// 保留编号2,禁止重用
reserved 2;
// 也可以按字段名保留
// 或者 reserved "email";
int32 age = 3;
// ✅ 使用新的编号
string phone = 4;
}
reserved
关键字用于保留编号或字段名,禁止重用,确保向后兼容性。
更多内容可参考 Protocol Buffers 文档。