编程 connect-go v2:砍掉 Request[T] 包装,handler 回归 (*Req) (*Res, error),迁移工具还有四点改不掉

2026-10-27 20:03:15

connect-go v2 迁移手记:去泛型、拆 transport,以及迁移工具改不掉的四个点

2026-10-07,Team Buf 发布 connect-go v2。这次破坏的是 Go API,不是 Connect 协议:v2 客户端和 v1 服务端仍能正常通信。v1 也不会停更,会继续接收修复和安全补丁。由于 v2 使用模块路径 connectrpc.com/connect/v2,两个版本可以在同一程序里共存,迁移可以按服务逐个做,也可以不做。

  • 信源:
  • 迁移指南:、
  • 仓库:
  • 模块:(v2.0.2,Go 1.26.0)

为什么砍泛型

v1 把 unary 请求和响应包在 connect.Request[T] / connect.Response[T] 里,T 是消息类型。这样不用深入 context.Context 就能拿到 header、trailer 和调用的其他 metadata。当时认为这个收益值得引入的复杂度。

生产环境用了四年后,结论相反:大部分调用并不需要这层包装。调用 RPC 前要先 connect.NewRequest(...),访问真实消息还要 .Msg,这跟 Go RPC 世界的常见签名 func(context.Context, *Request) (*Response, error) 不一致,从 gRPC-Go 迁移到 ConnectRPC 时多了一层无谓改动。

v1.19.0 曾用生成代码里的 simple flag 做过一轮扩展 beta,反馈正面。v2 里 unary RPC 的签名因此变成:

-Say(context.Context, *connect.Request[SayRequest]) (*connect.Response[SayResponse], error)
+Say(context.Context, *SayRequest) (*SayResponse, error)

去掉泛型也缩小了二进制。编译器不再为每个 RPC 生成一套 client 和 method set。Buf CLI 迁到 v2 后,stripped binary 缩小约 10%。核心包导出的标识符从 v1 的约 180 个降到约 25 个,stream 类型变成接口,测试里可以 fake。提案中的统计是:Buf CLI 里 ConnectRPC 相关条目占所有函数和类型名字节的近 1/3;connectrpc.com/connect 及其实例化占 stripped binary 的 12–13%。

需要 metadata 时仍可通过 context 里的 *connect.CallInfo 拿到。

五个破坏性变化

1. 签名去掉包装

服务端方法直接收发消息:

// v1
func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) {
return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil
}
// v2
func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) {
return &pingv1.PingResponse{Text: req.Text}, nil
}

Streaming 生成代码也不再使用泛型,而是为每个 RPC 定义命名 stream 类型,例如 PingServiceCumSumClientStream。Send/Receive/SendHeaders 保持 v1 的形状,不接收 context;调用的 context 在 stream 打开时绑定。Streaming handler 的参数跟着变:

// v1
func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error
// v2
func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error

2. transport 可插拔

v1 围绕 HTTP 语义构建:用户传一个普通 http.Client,服务实现 http.Handler。这对 HTTP 没问题,但想跑在 WebSocket 或 stdin/stdout 上,就得来回翻译 HTTP 语义。

v2 把 HTTP 支持放进 connecthttp,其他 transport 可以复用同一套生成的 client 和 handler,不再依赖 net/http。同时新增 connectinprocess,一个内存 transport,不用起 HTTP server,也不用折腾缓冲,就能测试 Connect endpoint。

运行时被拆成同一模块下的子包,核心包不再依赖 net/http:

PackageContents
connectrpc.com/connect/v2核心类型,生成代码 import
connectrpc.com/connect/v2/connecthttpnet/http server 和 client 绑定
connectrpc.com/connect/v2/connectprotoProtobuf codecs
connectrpc.com/connect/v2/connectgzipGzip 压缩
connectrpc.com/connect/v2/connectinprocess进程内 transport

客户端构造也跟着 transport 拆分:

// v1
client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080")
// v2
client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080")))

3. 统一 interceptor

v1 的 Interceptor 接口为 unary 调用、streaming client、streaming handler 分别定义方法。v2 换成两个函数类型,connect.ClientInterceptor 和 connect.ServerInterceptor,统一包装所有 RPC:

func NewServerInterceptor(logger *slog.Logger) connect.ServerInterceptor {
return func(next connect.ServerFunc) connect.ServerFunc {
return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error {
err := next(ctx, spec, stream)
logger.InfoContext(ctx, "rpc completed",
slog.String("procedure", spec.Procedure),
slog.Any("error", err),
)
return err
}
}
}

一个 interceptor 包住整个调用,unary 或 streaming 都一样,并在任何 payload 工作之前运行。鉴权可以只凭 header 拒绝调用,metrics 能观察到完整调用,细到线上的字节数。

Server 构造方式随之变化:

// v1
mux := http.NewServeMux()
mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{}, connect.WithInterceptors(validate.NewInterceptor())))
// v2
mux := http.NewServeMux()
server := connect.NewServer(validate.NewServerInterceptor())
pingv1connect.RegisterPingServiceHandler(server, &pingServer{})
connecthttp.Mount(mux, server)

Interceptor 从 connect.WithInterceptors(...) 移到 connect.NewServer 的参数里,其他 handler 选项移到 connecthttp.Mount。

v2 里 interceptor 作用于 server 上的所有服务,没有 per-handler 选项。连续调用 mux.Handle(...) 且选项相同的 handler 会共享一个 server。需要不同 interceptor 的 handler,可以注册到不同 server 上,再挂到同一个 mux,保持 v1 的行为。

4. 错误语义反转

v1 里 handler 返回的错误会直接序列化给客户端,可能泄露信息。v2 中只有显式构造的 *connect.Error 才会把 message 带上线。其他错误到客户端时是 CodeUnknown,没有 message。

connect.NewError 接收公开 message,.WithCause() 在本地附带私有底层错误。从客户端收到的错误会被标记为 remote。

5. metadata 进 context

metadata 移到 context 上的 *connect.CallInfo,暴露 RequestHeader()、ResponseHeader()、ResponseTrailer():

// v2 server
info, _ := connect.CallInfoForServerContext(ctx)
token := info.RequestHeader().Get("Authorization")
res := &pingv1.PingResponse{}
info.ResponseHeader().Set("X-Server-Name", "ping-server")

客户端先用 connect.NewClientContext(ctx) 把 info 写入 context。v1 的 connect.HandlerContext(ctx) 改名为 connect.CallInfoForServerContext(ctx),两者都返回 (CallInfo, bool)。

附带改名

HTTP 相关选项迁到 connecthttp:

  • connecthttp.WithReadMaxBytes(1024)
  • connecthttp.WithSendMaxBytes(2048)
  • connecthttp.WithCompressMinBytes(512)
  • connecthttp.WithRequireConnectProtocolHeader()
  • connecthttp.WithSendGzip()
  • connecthttp.WithHTTPGet()
  • connecthttp.WithHTTPGetMaxURLSize(8192, true)
  • connecthttp.WithProtoJSON()
  • connecthttp.WithGRPC()
  • connecthttp.WithGRPCWeb()
  • connecthttp.WithSendCompression("gzip")

ErrorWriter、IsNotModifiedError 同样搬到 connecthttp。

grpchealth 的注册方式也变了:

// v1
mux.Handle(grpchealth.NewHandler(checker))
// v2
grpchealth.Register(server, checker)

grpcreflect 类似。

迁移工具的两遍流程

迁移是可选的,v1 和 v2 可以同模块共存。

go install connectrpc.com/connect/v2/cmd/connect-go-v2-migrate@latest
connect-go-v2-migrate        # dry run:打印 diff 与警告
connect-go-v2-migrate -w     # 写入磁盘;-json 输出机器可读报告

大部分代码改动依赖重新生成的 v2 代码,所以迁移分两遍:

  1. 运行 connect-go-v2-migrate -w,更新 buf.gen.yaml,并应用不依赖生成代码的改动。
  2. 用 v2 插件重新生成代码(buf generate)。
  3. 再运行一次 connect-go-v2-migrate -w,针对 v2 生成代码更新 client 调用和 handler 签名。
  4. go mod tidy,然后 build/test,处理报告里的手工项。

第 1 步和第 3 步之间项目无法编译。服务可以逐个迁移。

buf.gen.yaml 的远程插件版本:

version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- - remote: buf.build/connectrpc/go:v1.18.1
+ - remote: buf.build/connectrpc/go:v2.0.0
out: gen
opt: paths=source_relative

工具也会替换 buf.build/connectrpc/gosimple,因为 v2 默认就是 simple 风格。BSR 生成的 SDK 移到 /v2 模块,import 路径要加 /v2。本地插件条目不变(v1/v2 插件共用二进制名),但需要重新安装:

go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest

v2 生成器拒绝 v1 的 simple 选项。

模块路径变化:

v1v2
connectrpc.com/connectconnectrpc.com/connect/v2
connectrpc.com/grpchealthconnectrpc.com/grpchealth/v2
connectrpc.com/grpcreflectconnectrpc.com/grpcreflect/v2
connectrpc.com/validate不变
connectrpc.com/otelconnect不变

validate v0.8.0 与 otelconnect v0.11.0 是首批为 connect v2 构建的版本。

迁移工具改不掉的点

报告里会带 ⚠️ 警告,需要手工处理:

  • 自定义 interceptor 必须按新的函数类型重写。
  • otelconnect.NewInterceptor() 要拆成 otelconnect.NewServerInterceptor() 或 otelconnect.NewClientInterceptor()。这两个构造函数返回 error,需要在构造 server/client 之前先赋值。
  • validate.NewInterceptor() 工具能自动映射到 Server/Client 版本。
  • 选项不同的 handler 会被分到不同 server 上,各自保持 v1 行为。工具会警告,要你确认是否应该共用一个 server。先把 path 和 handler 赋给变量的写法也会警告,需要手工改。
  • 多个服务共享同名 RPC 和消息类型时,stream 参数类型的匹配存在歧义,工具会警告,需要手选生成的类型。

何时不值得迁

迁移不是强制的。v1 会持续接收修复和安全补丁,两个模块路径也能在同一程序里共存,所以一个服务一个服务地迁、或者完全不迁,都是可行状态。

需要权衡的是迁移过程中的成本:第 1 步到第 3 步之间项目不能编译;自定义 interceptor、otelconnect 拆分、多 server 分组、歧义 stream 类型这几类问题工具处理不了,要手工改。如果现有服务不依赖 v2 的可插拔 transport、统一 interceptor 这些新结构,继续留在 v1 并不影响协议兼容和后续维护。

复制全文 生成海报 Go connect-go RPC Buf 迁移

推荐文章

程序员茄子在线接单