connect-go拦截器中修改响应体的实现方案咨询
我正在使用Buf的connect-go库实现gRPC服务。
诸多gRPC调用具备时间敏感性,因此请求中包含一个供客户端发送自身当前时间戳的字段,服务端会将客户端时间戳与本地时间戳做对比,返回二者的差值。以下为.proto定义示例:
service EventService { // Start performing a task rpc Start (StartRequest) returns (StartResponse); } message StartRequest { int64 location_id = 1; int64 task_id = 2; Location user_latlng = 3; google.protobuf.Timestamp now_on_device = 4; } message StartResponse { TaskPerformanceInfo info = 1; google.protobuf.Duration device_offset = 2; }
由于多个RPC方法都需要实现该逻辑,我希望通过interceptor统一处理,无需在每个独立RPC方法实现中重复编写该逻辑。
由于protoc-gen-go编译器会为字段生成getter方法,因此可以通过定义接口、使用类型断言的方式,轻松判断请求消息是否包含now_on_device字段:
type hasNowOnDevice interface { GetNowOnDevice() *timestamppb.Timestamp }
if reqWithNow, ok := req.Any().(hasNowOnDevice); ok { // ... }
基于该特性,拦截器的大部分逻辑可以很容易实现:
func MakeDeviceTimeInterceptor() func(connect.UnaryFunc) connect.UnaryFunc { return connect.UnaryInterceptorFunc( func(next connect.UnaryFunc) connect.UnaryFunc { return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { now := time.Now().UTC() ctxa := context.WithValue(ctx, CurrentTimestampKey{}, now) var deviceTimeOffset time.Duration // If the protobuf message has a `NowOnDevice` field, use it // to get the difference betweent the device time and server time. if reqWithNow, ok := req.Any().(hasNowOnDevice); ok { deviceTime := reqWithNow.GetNowOnDevice().AsTime() deviceTimeOffset = now.Sub(deviceTime) ctxa = context.WithValue(ctxa, DeviceTimeDiffKey{}, deviceTimeOffset) } res, err := next(ctxa, req) // TODO: How do I modify the response here? return res, err }) }, ) }
当前遇到的难点是如何修改返回的响应。protoc-gen-go不会为字段生成setter方法,无法像处理请求那样为响应定义通用接口。之前尝试在TODO位置使用类型switch做处理,代码如下:
switch resMsg := res.Any().(type) { case *livev1.StartResponse: resMsg.DeviceOffset = durationpb.New(deviceTimeOffset) return &connect.Response[livev1.StartResponse]{ Msg: resMsg, }, err case *livev1.StatusResponse: resMsg.DeviceOffset = durationpb.New(deviceTimeOffset) return &connect.Response[livev1.StatusResponse]{ Msg: resMsg, }, err }
该实现方式存在三个问题:
- 无法找到将原响应的headers/trailers复制到新构造响应的方法,不确定执行到该逻辑时响应头/尾是否已经完成设置
- 使用类型断言需要为每一种响应类型重复编写几乎完全相同的代码块
- 该实现的复杂度已经和在每个RPC方法中单独实现逻辑相当,没有起到简化作用
求更简便的、通过拦截器修改响应字段的方式,或是其他更合理的实现方案。
你之前的实现第一个核心错误是没必要重新构造connect.Response对象:next方法返回的res里,res.Any()拿到的是实际响应消息的指针,直接修改这个指针指向的结构体字段就会生效,原res上绑定的headers、trailers都会完整保留,完全不需要新建响应对象做值拷贝。拦截器中next返回时,业务消息已经填充完成,但响应头还没最终发送给客户端,这时候修改消息内容、操作头信息都是安全的。
在此基础上,有两种低复杂度的实现方案,都可以避免重复写类型switch分支:
方案1:自定义响应setter接口(推荐)
和请求的getter逻辑对应,你可以自己定义一个统一的响应接口,不需要依赖protoc生成setter:
type hasDeviceOffset interface { SetDeviceOffset(*durationpb.Duration) }
之后给所有需要返回device_offset字段的响应类型实现这个接口,每个类型只需要加1行代码:
func (r *StartResponse) SetDeviceOffset(d *durationpb.Duration) { r.DeviceOffset = d } func (r *StatusResponse) SetDeviceOffset(d *durationpb.Duration) { r.DeviceOffset = d }
最后把拦截器里的TODO部分替换成下面的逻辑即可:
if err != nil { return res, err } if resWithOffset, ok := res.Any().(hasDeviceOffset); ok && deviceTimeOffset != 0 { resWithOffset.SetDeviceOffset(durationpb.New(deviceTimeOffset)) } return res, err
这个方案类型安全、性能无损耗,新增响应类型时只需要给对应结构体加一个Set方法,不需要修改拦截器逻辑,维护成本极低。
方案2:Protobuf反射通用处理
如果不想给每个响应类型加Set方法,可以用Protobuf原生的反射能力,自动识别所有带device_offset字段的响应,直接赋值,不需要写任何类型断言:
// 初始化时获取目标字段的描述,匹配全限定名避免同名字段冲突 var deviceOffsetFieldDescriptor = func() protoreflect.FieldDescriptor { dummy := &livev1.StartResponse{} return dummy.ProtoReflect().Descriptor().Fields().ByName("device_offset") }() // TODO部分替换为以下逻辑 if err != nil { return res, err } if deviceTimeOffset == 0 { return res, err } protoMsg, ok := res.Any().(proto.Message) if !ok { return res, err } msgRef := protoMsg.ProtoReflect() field := msgRef.Descriptor().Fields().ByName("device_offset") // 校验字段存在、类型匹配再赋值 if field != nil && field.FullName() == deviceOffsetFieldDescriptor.FullName() { offsetVal := durationpb.New(deviceTimeOffset) msgRef.Set(field, protoreflect.ValueOfMessage(offsetVal.ProtoReflect())) } return res, err
这个方案不需要修改任何响应结构体的代码,拦截器写完之后新增带device_offset字段的响应也能自动处理,缺点是反射有极少量性能开销,且字段名写错时不会有编译期报错。
内容的提问来源于stack exchange,提问作者Moshe Katz

