gRPC 与 rpcx
两个 RPC 通道都由 verticle 统一管生命周期:配了配置段才建 server,实现了 Registrar
接口才监听端口。客户端都在 Setup 里拨号,由框架负责关闭。
为什么两个都有
它们解决的场景不重叠。gRPC 靠 protobuf 提供强契约与多语言互通;rpcx 不要 IDL, 一个普通 struct 就是服务,代价是契约弱、以 Go 为主。
| gRPC | rpcx | |
|---|---|---|
| IDL / 代码生成 | 需要(.proto + protoc) |
不需要(struct 即契约) |
| 契约强度 | 强:字段号与类型都在 IDL 里 | 弱:方法签名与导出字段的约定 |
| 多语言互通 | 好 | Go 优先(codec 可换 msgpack / protobuf) |
| 传输 | HTTP/2 | tcp / quic / kcp / ws / http 可选 |
| 客户端连接 | fail-fast:Setup 阶段确认 READY |
懒连接:第一次 Call 才拨号 |
| 内置健康检查 | grpc.health.v1(默认开) |
走 HTTP 的 /healthz /readyz |
跨语言、要强契约用 gRPC;纯 Go 内部服务、想少一层生成代码用 rpcx。两者可以在同一进程里并存。
注册:只有实现了 Registrar 才监听
// gRPC:verticle.Run 创建 server 后调用它在 serve 之前。
func (s *DemoService) RegisterGRPC(g *grpc.Server) {
pb.RegisterGreeterServer(g, s.greeter)
}
// rpcx:注册名要和客户端的 servicePath 一致。
func (s *rpcxService) RegisterRPCX(srv *rpcxserver.Server) error {
return srv.RegisterName("Greeter", s.greeter, "")
}
svc.(GRPCRegistrar),不是配置开关。
grpc:
addr: ":9090" # 留空/删除即禁用 server
dial_timeout: 3s # 客户端拨号超时
health: true # 标准 grpc.health.v1(默认开)
reflection: false # 默认关
rpcx:
network: tcp # tcp | quic | kcp | ws | http
addr: ":8972" # 留空/删除即禁用
内置健康服务
grpcx.New 在 health 为 true(或未设,默认即 true)时注册标准
grpc.health.v1 服务。业务不要再自己
healthpb.RegisterHealthServer——重复注册会 panic。
状态流转刻意贴合探针的视角:
| 时刻 | 状态 | 原因 |
|---|---|---|
New 且尚未 Serve |
NOT_SERVING |
地址已绑定但还没开始服务 |
Serve 开始 |
SERVING |
探针看到的"健康"与实际能接流量同一时刻 |
Shutdown |
先 NOT_SERVING |
负载均衡先摘流量,在途调用继续排空 |
grpc:
health: false # 关掉内置健康服务
// 直接 grpcx.New 的场合:依赖异常时按 service 名翻转状态,
// 让负载均衡在请求开始失败之前停止路由过来("" 是整个 server)。
srv.SetServing("greeter.Greeter", healthpb.HealthCheckResponse_NOT_SERVING)
Reflection:默认关
reflection: true 让 grpcurl / grpcui 不用 .proto 就能列出服务
(grpcurl -plaintext localhost:9090 list)。默认关闭是安全取向:
它把服务的整个面公开给任何能连到端口的人。生产上按需开启,或只在不对外暴露的
网络里开。
客户端
// gRPC:fail-fast。dial_timeout(默认 3s)内确认 READY,连不上就返回错误。
conn, err := env.GRPCClient(ctx, "user-svc:9090")
if err != nil {
return err // 依赖不可达 = 启动失败,而不是第一个请求失败
}
s.users = pb.NewUserClient(conn)
// rpcx:对单点地址用 RPCXClient。
c, err := env.RPCXClient("Greeter", "user-svc:8972") // 或 "quic@user-svc:8972"
if err != nil {
return err
}
s.client = c
// 需要 etcd / consul 发现或多个服务端时换 RPCXClientWith。
xc := env.RPCXClientWith("Greeter", discovery)
-
连接由框架关:
env.GRPCClient/env.RPCXClient/env.RPCXClientWith都注册了关闭函数,Setup里不用defer conn.Close()。 -
传输默认明文(集群内内网)。要对端 TLS 就给
env.GRPCClient传grpc.WithTransportCredentials(...);rpcx 传输由network决定。 -
rpcx 是懒连接:
Setup阶段不探测连通性,所以要校验依赖得自己Call一个 ping 方法。 -
rpcx 客户端策略固定为
Failtry+RandomSelect(单点重试 + 随机选节点)。
errs.Kind 与业务 code 不会跨进程传递,服务端返回的 errs.Invalid
在客户端只是一个普通错误。所以调用方要把错误再映射回自己的 code:
errs.Unavailable(50302, "rpcx call failed: %v", err)。
链路追踪自动生效
两个通道都自动传播 W3C traceparent,业务代码零改动;未开启 tracing 时
全部是 no-op:
- gRPC:unary 与 stream 的服务端 / 客户端拦截器都在
New和Dial里无条件接好。要加自己的 unary 拦截器请用grpc.ChainUnaryInterceptor组合,别替换掉追踪拦截器。 - rpcx:服务端一个 plugin(
PreHandleRequest开 span、PostCall关 span 并记录错误)+ 客户端对XClient的包装。Failtry的重试复用同一个 span,因为那是一次逻辑调用。
已知限制:rpcx handler 里自己起 obs.StartSpan 的子 span 不会自动挂在
RPC server span 下(OTel 的 span context 键不能从外部写入),但跨进程的 client → server
父子链是完整的。
一次带健康检查的 BFF 调用
sequenceDiagram
autonumber
participant C as 客户端
participant B as BFF(本进程)
participant P as 对端服务
participant PS as grpcx.Server
Note over B: Setup
B->>P: env.GRPCClient(ctx, target)
B->>P: 连接直到 READY(dial_timeout 内)
P-->>B: READY / 超时即返回 error(启动失败)
Note over C,P: 运行期
C->>B: GET /api/v1/health
B->>PS: Health/Check(客户端拦截器注入 traceparent)
PS->>PS: 服务端拦截器抽出 traceparent,开 server span
PS-->>B: SERVING(或 NOT_SERVING)
B-->>C: 200 {"status":"SERVING"}
Note over PS: Shutdown
PS->>PS: 先翻 NOT_SERVING,再 GracefulStop 排空
Setup 里发生,所以不可达的对端在启动时就暴露;
健康状态与能否接流量始终同一时刻。
相关示例
-
examples/grpc:内置健康服务 + reflection,
HTTP 侧透传对端状态;config.yaml 打开了 reflection 方便
grpcurl list。 -
examples/rpcx:无 IDL 的
Greeter,本地调用与真实 rpcx 往返各一条路由。 - 服务与生命周期:Registrar 在启动序列里的位置。
- 可观测性:traceparent 是怎么被注入和抽出的。
- 离线测试:进程内起 server + 真 client 的写法。