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:
| Package | Contents |
|---|---|
| connectrpc.com/connect/v2 | 核心类型,生成代码 import |
| connectrpc.com/connect/v2/connecthttp | net/http server 和 client 绑定 |
| connectrpc.com/connect/v2/connectproto | Protobuf codecs |
| connectrpc.com/connect/v2/connectgzip | Gzip 压缩 |
| 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 代码,所以迁移分两遍:
- 运行
connect-go-v2-migrate -w,更新buf.gen.yaml,并应用不依赖生成代码的改动。 - 用 v2 插件重新生成代码(
buf generate)。 - 再运行一次
connect-go-v2-migrate -w,针对 v2 生成代码更新 client 调用和 handler 签名。 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 选项。
模块路径变化:
| v1 | v2 |
|---|---|
| connectrpc.com/connect | connectrpc.com/connect/v2 |
| connectrpc.com/grpchealth | connectrpc.com/grpchealth/v2 |
| connectrpc.com/grpcreflect | connectrpc.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 并不影响协议兼容和后续维护。