从配置推送理解 gRPC:代码生成、流式调用与底层通信
配置中心有一个常见需求:服务端修改了配置,客户端尽快拿到新值,更新本地缓存,再通知业务代码。
假设配置由三个字段标识:
namespace = dev group = configuration-center dataId = application.properties
配置内容最初是:
timeout.ms=1100
服务端把它改成 timeout.ms=2000 后,希望所有订阅它的客户端都收到变化。
这个场景可以把 gRPC 的几个主要概念串起来:用 Protobuf 定义消息,用生成的 Stub 发起调用,通过 Stream 持续推送,最后由 HTTP/2 和底层网络框架完成传输。
下面的例子使用 Java 21、gRPC Java 1.76.0 和 Protobuf 3.25.5。
从一条配置的变化开始
先确定客户端和服务端之间需要怎样交互。
客户端启动后发起一次请求。服务端返回当前配置,但不立即结束响应。之后每当配置变化,服务端继续通过这次调用发送新的配置快照:
客户端 服务端
────── 打开配置推送流 ─────────────→
←───── 当前配置,version=1 ────────
配置发生变化
←───── 新配置,version=2 ──────────
配置再次变化
←───── 新配置,version=3 ──────────
这里包含两种不同的生命周期:
- 一次配置变化,会产生一条消息。
- 一次推送调用,可以包含很多条消息。
用 proto 描述通信约定
与直接推送有关的协议定义如下:
syntax = "proto3"; import "google/protobuf/empty.proto"; package demo; option java_package = "demo.proto"; option java_multiple_files = true; message ConfigKey { string namespace = 1; string group = 2; string data_id = 3; // 本示例在服务端内部用它提交配置变化。 string value = 4; } message ConfigSnapshot { ConfigKey key = 1; int64 version = 2; string content = 3; } service ConfigService { rpc GetConfig(ConfigKey) returns (ConfigSnapshot); rpc pushConfig(google.protobuf.Empty) returns (stream ConfigSnapshot); }
message 定义数据结构,service 定义可以远程调用的方法。
GetConfig 接收一条请求,返回一条响应,适合主动查询。
pushConfig 的返回类型前有一个 stream,表示服务端可以连续返回多条 ConfigSnapshot。它属于服务端流式 RPC。
这里使用 Empty,因为示例只允许访问一份固定配置,客户端不需要额外传入订阅条件。这个约束由服务端授权逻辑决定。多租户场景需要根据认证身份限制可访问资源,空请求本身不提供权限控制。
ConfigKey 中的 value 是这个示例为了提交内部更新增加的字段。生成快照时,key 只保留配置标识,实际配置值放在 content 中。
生成 Java 代码
在项目根目录执行:
mvn generate-sources
Maven 会调用两套生成逻辑:
config.proto
├── protoc 的 Java 生成器
│ └── ConfigKey、ConfigSnapshot 等消息类
│
└── protoc-gen-grpc-java
└── ConfigServiceGrpc
服务端继承生成的基类:
class ConfigServiceImpl extends ConfigServiceGrpc.ConfigServiceImplBase { // 实现 getConfig、pushConfig 等方法 }
客户端通过生成的 Stub 发起请求:
ConfigServiceGrpc.newStub(channel)
.pushConfig(Empty.getDefaultInstance(), receiver);
其中,channel 是已经配置好地址、TLS 等参数的通信入口,receiver 是客户端接收快照的回调。
这几行代码背后,接口类型、消息编解码和方法分发的对应关系,都已经由生成器确定。
配置变化时,服务端做什么
客户端打开推送流后,服务端保存它的发送端,并立即发送当前快照。
后续配置变化统一经过 publishConfig:
publishConfig(DemoConfig.KEY.toBuilder() .setValue("timeout.ms=2000\n") .build());
这个方法完成几件具体的事:
- 校验配置是否在允许访问的范围内。
- 比较新旧内容,相同值不重复发布。
- 检查消息大小,生成递增版本的快照。
- 更新服务端当前状态。
- 把快照发送给各个推送客户端。
状态更新与广播使用同一把锁,首次订阅也参与这套同步。这样可以避免客户端注册订阅时,恰好漏掉一次配置变化。
配置对象是不可变的。创建一个新的 ConfigKey 不会自动触发推送,需要通过发布入口提交变化。
客户端收到快照以后
客户端通过 StreamObserver<ConfigSnapshot> 接收消息:
onNext收到一份快照。onError收到调用终止错误。onCompleted收到服务端正常结束响应的通知。
本例在 onNext 中将后续处理交给一个有界执行器,再完成配置范围校验、版本比较、缓存更新和 Listener 调用。
日志只记录事件和版本:
event=config_snapshot_received version=1
event=business_listener version=1
event=config_snapshot_received version=2
event=business_listener version=2
这条链路中,客户端直接拿到了完整内容,不需要收到通知后再查询一次。
生成代码为什么有那么多
一个只有几个字段的消息,往往会生成数百行 Java 代码。原因在于生成类承担的工作远多于保存字段。
以 ConfigKey 为例,它需要提供:
- 字段读取方法;
- Builder;
- 二进制编码与解析;
- 对象比较和合并;
- 未知字段处理;
- 消息结构描述。
因此,生成目录里会出现几类文件:
| 生成内容 | 作用 |
|---|---|
ConfigKey、ConfigSnapshot 等 |
消息对象及其 Builder、编码器、解析器 |
ConfigKeyOrBuilder 等 |
消息对象与 Builder 共用的读取接口 |
Config |
proto 文件的结构描述等元数据 |
ConfigServiceGrpc |
服务端基类、客户端 Stub、方法描述与调用分发 |
业务开发通常只使用这些类暴露的方法。协议有变化时,修改 proto 后重新生成;业务逻辑保留在手写的服务端和客户端类中。
Builder 负责构造,Parser 负责解析
消息一般通过 Builder 构造:
ConfigKey key = ConfigKey.newBuilder() .setNamespace("dev") .setGroup("configuration-center") .setDataId("application.properties") .build();
Builder 可以修改字段,build() 返回构造完成的不可变消息。
单独使用 Protobuf 时,可以直接调用:
byte[] bytes = key.toByteArray(); ConfigKey restored = ConfigKey.parseFrom(bytes);
第一步把对象编码成二进制,第二步从二进制解析出新的对象。实际 RPC 中,这两个过程由 gRPC 自动衔接。
字段后面的数字是解析的关键:
string namespace = 1;
1 是字段编号。假设消息只包含 namespace = "dev",对应的二进制可以表示为:
0A 03 64 65 76
│ │ └────── "dev" 的 UTF-8 字节
│ └───────── 长度为 3
└──────────── 字段编号 1,采用长度分隔编码
生成的 Parser 已经知道字段 1 对应 namespace,并且应当按字符串解析。
二进制中不需要携带 Java 类名,也不依赖两端使用相同编程语言。接收方按照双方约定的消息定义解析字段。这也是已有字段编号需要保持稳定、删除后不应随意复用的原因。编码细节可以查看 Protobuf Encoding。
解析只解决结构问题。即使一个消息能够成功解析,它请求的配置也可能不允许当前客户端访问,这仍需要业务校验。
Stub、Channel 和服务端实现各负责什么
客户端的一次查询可以写成:
ConfigSnapshot snapshot = ConfigServiceGrpc.newBlockingStub(channel) .withDeadlineAfter(3, TimeUnit.SECONDS) .getConfig(DemoConfig.KEY);
三个角色分别是:
| 角色 | 职责 |
|---|---|
ManagedChannel |
管理面向目标服务的通信资源,包括底层连接和连接状态 |
| Stub | 提供与 proto 对应的调用方法,并携带本次调用的选项 |
| 服务端实现 | 执行授权、读取配置、管理订阅等业务逻辑 |
创建 Stub 不等于创建一条新的 TCP 连接。多个 Stub 可以共用一个 Channel,Channel 也不必与底层连接一一对应。
Java 中常用的几种 Stub 主要区别在调用方式:
| 创建方法 | 调用方式 |
|---|---|
newBlockingStub |
调用线程等待结果 |
newStub |
通过 StreamObserver 回调收发消息 |
newFutureStub |
通过 ListenableFuture 获取单次调用的结果 |
本例用异步 Stub 接收持续推送,用阻塞 Stub 展示一次查询。当前生成器还提供 Blocking V2 API,使用时应以对应版本生成的接口为准。
服务端继承 ConfigServiceImplBase 后重写方法。收到网络请求时,gRPC 根据方法描述找到对应实现,再把解析完成的参数交给业务方法。
像“版本如何递增”“配置从哪里读取”“哪些客户端能够订阅”,都需要服务端自己实现。
gRPC 支持哪些调用方式
直接推送使用了服务端流。把请求和响应两边的消息数量组合起来,gRPC 一共支持四种调用形式:
| 调用形式 | 请求数量 | 响应数量 | 例子 |
|---|---|---|---|
| Unary | 一条 | 一条 | 查询当前配置 |
| 服务端流 | 一条 | 多条 | 持续推送配置快照 |
| 客户端流 | 多条 | 一条 | 分批上传数据,最后返回处理结果 |
| 双向流 | 多条 | 多条 | 订阅、通知、ACK 等持续交互 |
流里的多条消息属于同一次 RPC。双向流的两个方向可以独立收发,各方向内保持消息顺序。gRPC Java 基础教程中有这四种形式的完整示例。
配置订阅还可以采用另一种设计:
rpc Watch(stream ClientFrame) returns (stream ServerFrame);
客户端通过请求方向发送订阅和 ACK,服务端通过响应方向发送变化通知。客户端收到通知后,再调用 GetConfig 拉取内容。
这时两条链路分别是:
直接推送:
pushConfig → ConfigSnapshot → 缓存 → Listener
通知后拉取:
Watch → Changed → ACK → GetConfig → 缓存 → Listener
选择哪一种,取决于消息大小、更新频率和业务对确认机制的要求。ACK、版本号、缓存和重新订阅都是应用协议的一部分,需要在 gRPC 提供的通信能力之上设计。
StreamObserver 的发送与接收
StreamObserver 容易让人困惑,因为同一个接口会出现在两个角色中。
接收端由业务代码实现:
框架收到消息
→ 调用你实现的 onNext(message)
发送端由框架提供:
业务调用 outbound.onNext(message)
→ 框架安排消息发送
双向流客户端的调用可以同时看见这两个角色:
StreamObserver<ClientFrame> sender =
ConfigServiceGrpc.newStub(channel).watch(receiver);
传入的 receiver 接收服务端消息,返回的 sender 向服务端发送消息。
调用发送端的 onCompleted() 表示这个方向不再发送数据。对于双向流,客户端结束请求方向后,仍可能继续接收服务端的响应。
一次调用还需要哪些控制
远程调用涉及等待、失败和资源占用。gRPC 提供了相应机制,但参数和业务语义需要应用确定。
Deadline 限制整次调用的时间
查询配置时设置三秒 deadline,表示这次 RPC 必须在这段时间内完成。超过时间,客户端会收到 DEADLINE_EXCEEDED。
同样的 deadline 如果用在配置推送上,限制的也是整条推送 RPC 的生命周期,不是相邻两条消息之间的间隔。因此,长时间订阅不能直接照搬普通查询的三秒设置。Deadline 文档说明了客户端超时和服务端取消的关系。
取消发生后,业务代码还需要停止相关工作、清理资源。本例通过取消回调移除推送会话和待发送队列。
状态码描述 RPC 的结果
示例中几种错误有明确含义:
| 状态 | 使用场景 |
|---|---|
PERMISSION_DENIED |
请求的配置超出授权范围 |
INVALID_ARGUMENT |
提交的配置快照超过消息大小限制 |
RESOURCE_EXHAUSTED |
订阅数达到上限,或慢客户端队列已满 |
DEADLINE_EXCEEDED |
调用超过 deadline |
客户端需要根据具体状态决定处理方式。某次调用失败,并不能直接推出服务端业务一定没有执行,重试还需要考虑幂等性。
流控与业务确认是两件事
客户端处理速度跟不上时,服务端不能无限制地积压快照。
本例为每个推送流保留一个有界队列。发送端暂时不可写时,快照先排队;onReady 通知到来后继续发送。队列达到上限,就关闭这个慢客户端,避免拖累其他订阅者。
这里有一个常被忽略的细节:调用 onNext() 返回,不代表消息已经抵达对端,更不代表对端业务处理成功。框架可能还在缓冲、编码或等待发送条件。gRPC Flow Control对此有明确说明。
如果业务要求知道某个版本已经应用,需要另外设计 ACK,必要时还要区分“收到消息”和“完成应用”。
认证之外还有资源授权
示例使用 mTLS,服务端验证客户端证书,客户端验证服务端证书。
证书验证解决连接对端的身份问题。连接建立之后,服务端仍要判断这个身份可以读取哪些配置。
gRPC 还提供 Metadata、Interceptor、名称解析、负载均衡和可观测性相关接口。应用可以借助它们统一处理认证上下文、链路信息和调用策略。这些扩展机制不会自动替代配置系统自身的权限模型。
一次 RPC 如何跨过网络
到这里,业务代码看到的仍然是对象和方法:
stub.getConfig(key);
从这行代码往下看,调用要经过几次转换:
Java 消息对象
↓ Protobuf 编码
消息二进制
↓ gRPC 消息封装
带长度信息的 gRPC 消息
↓ HTTP/2
HEADERS、DATA、尾部状态等帧
↓ TLS
加密数据
↓ TCP
网络字节流
接收端沿反方向处理,最后才得到 Java 对象。
框架如何找到目标方法
Stub 保存了 RPC 方法描述。比如当前推送接口的 HTTP/2 路径是:
/demo.ConfigService/pushConfig
它由 proto 的 package demo、服务名 ConfigService 和方法名 pushConfig 组成。
java_package = "demo.proto" 决定生成 Java 类的包名;它与网络上的服务路径是两个不同的命名空间。
服务端收到请求头后,根据方法路径找到对应的 RPC 定义。这个定义中已经包含请求和响应的 Marshaller,用于衔接 Protobuf 对象与字节。
所以业务方法被调用时,参数已经是 ConfigKey,不需要再手动读取 socket 或解析二进制。
TCP 字节流如何还原成一条条消息
TCP 没有应用消息边界。一次读取可能只拿到半条消息,也可能同时包含多条消息。
gRPC 在每条消息前面增加一个五字节前缀:
1 字节:是否压缩
4 字节:消息体长度
后续字节:消息体
接收端根据长度累计完整消息,必要时解压,再交给对应的 Protobuf Parser。
这个前缀属于 gRPC 消息封装,与 Protobuf 内部字段编码不同。一条 gRPC 消息还可能跨越多个 HTTP/2 DATA 帧,不能把“一次网络读取”“一个 DATA 帧”和“一条业务消息”等同起来。协议细节见 gRPC over HTTP/2。
Stream 为什么能持续通信
HTTP/2 允许在同一条连接中同时存在多个 stream。每个 stream 有独立编号,用来区分不同的请求和响应。
在复用同一条连接的情况下,可以出现这样的结构:
HTTP/2 连接
├── stream 1:pushConfig,持续返回快照
├── stream 3:第一次 GetConfig,返回后结束
└── stream 5:第二次 GetConfig,返回后结束
普通查询结束时,结束的是它自己的 stream,底层连接仍可以继续服务其他调用。
对于服务端流式 pushConfig,客户端只发送一条请求,然后结束请求方向。服务端的响应方向仍然开放,可以继续发送快照。这种半关闭状态是 HTTP/2 正常的流状态。
服务端调用 onCompleted() 后,框架发送结束响应的状态信息,stream 才完成。stream 的关闭也不必导致整个 TCP 连接关闭。HTTP/2 流状态规范定义了这些状态转换。
因此,Java 中的 pushConfig() 方法返回时,返回的只是当前方法调用。gRPC 仍保留着这次 RPC 的状态、发送端和回调。
连接空闲时不需要不断发送业务消息。两端保留 socket 状态,等待下次数据即可。网络故障、代理超时或服务端连接策略仍可能终止连接;keepalive 可以帮助探测连接状态,但无法替业务恢复订阅。
Netty 在这条链路里做什么
本例使用 grpc-netty-shaded 作为传输实现。Netty 管理 socket,并承载 TLS、HTTP/2 等处理逻辑。
接收路径可以简化为:
操作系统 TCP 接收缓冲区
↓
Netty 读取字节到 ByteBuf
↓
TLS 解密
↓
HTTP/2 帧解析
↓
gRPC 还原消息边界
↓
Protobuf 解析
↓
gRPC 调度业务回调
Netty 的 EventLoop 通过 I/O 多路复用管理多个 Channel。没有消息的连接保留状态,有数据或其他事件时再进行处理,因此连接数不需要与线程数一一对应。EventLoop 文档描述了这种关系。
业务代码也要遵守这个线程模型。阻塞查询和耗时计算应交给合适的执行器。Netty 的 I/O 线程、gRPC 的回调执行器,以及本例处理 Listener 的执行器,分别承担不同职责。
如果从发布配置的一行代码向下追踪,路径就是:
publishConfig(key)
→ 生成新的 ConfigSnapshot
→ 放入各客户端的发送队列
→ outbound.onNext(snapshot)
→ Protobuf 编码与 gRPC 消息封装
→ HTTP/2 DATA 帧
→ TLS 加密
→ Netty 写入 socket
→ 客户端沿反方向解析并执行 Listener
这个示例已经把配置发布、流式传输和客户端回调连通。它的状态保存在内存中,客户端为了演示只运行二十秒,也没有断线恢复逻辑。要用于持续运行的配置服务,接下来需要处理重新订阅、初始快照与增量更新的衔接、配置持久化,以及客户端应用失败后的恢复策略。

浙公网安备 33010602011771号