从配置推送理解 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());

这个方法完成几件具体的事:

  1. 校验配置是否在允许访问的范围内。
  2. 比较新旧内容,相同值不重复发布。
  3. 检查消息大小,生成递增版本的快照。
  4. 更新服务端当前状态。
  5. 把快照发送给各个推送客户端。

状态更新与广播使用同一把锁,首次订阅也参与这套同步。这样可以避免客户端注册订阅时,恰好漏掉一次配置变化。

配置对象是不可变的。创建一个新的 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

这个示例已经把配置发布、流式传输和客户端回调连通。它的状态保存在内存中,客户端为了演示只运行二十秒,也没有断线恢复逻辑。要用于持续运行的配置服务,接下来需要处理重新订阅、初始快照与增量更新的衔接、配置持久化,以及客户端应用失败后的恢复策略。

posted @ 2026-10-18 22:54  软件心理学工程师  Views(10)  Comments(0)    收藏  举报