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, "")
}
配了地址但没实现接口 = 不监听 + 告警 一个没有任何服务的端口只会招来误路由的流量和一个虚假的健康表象。所以 verticle 的 规则是:没有 registrar 就没有 listener,只记一条 warn。这个判断是运行时的接口 断言 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)
客户端错误只有消息能过 RPC 边界 errs.Kind 与业务 code 不会跨进程传递,服务端返回的 errs.Invalid 在客户端只是一个普通错误。所以调用方要把错误再映射回自己的 code: errs.Unavailable(50302, "rpcx call failed: %v", err)。
Setup 阶段不能拨自己 那时自己的 server 还没 serve,target 填自己的地址会让 dial 超时、启动失败 (gRPC 的 fail-fast 会直接暴露这一点)。

链路追踪自动生效

两个通道都自动传播 W3C traceparent,业务代码零改动;未开启 tracing 时 全部是 no-op:

已知限制: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 里发生,所以不可达的对端在启动时就暴露; 健康状态与能否接流量始终同一时刻。

相关示例