Go gRPC服务端日志指标采集优化实践疑问咨询
针对gRPC拦截器优化问题的解答
背景
这是此前问题《Reuse log client in interceptor for Golang grpc server method》的延伸。我基于Go编写了一个gRPC服务端,对外暴露SubmitJob、CancelJob、GetJobStatus三个API,使用Datadog记录指标。最初在每个API中编写日志代码,但存在LogRequestCount、LogRequestDuration代码重复,以及多处调用LogErrorCount的冗余问题。尝试用UnaryInterceptor拦截器优化后解决了部分问题,但产生两个疑问:
- 拦截器以
myServer为接收者是否为最佳实践?目前这么做是为了复用myServer内创建的Datadog客户端(dd_client),另有单例客户端、单独拦截器结构体两种方案可选; - 拦截器仅能处理请求数、耗时等通用指标,各API的特有指标仍需在接口实现中编写日志代码,此时日志代码分散在两处,是否仍应继续使用拦截器?
1. 拦截器接收者的最佳实践选择
以myServer作为拦截器接收者不是最优方案——这种写法会把拦截器逻辑和服务端结构体强耦合,既不利于拦截器复用,也会增加单元测试的复杂度。更推荐的是单独的拦截器结构体方案,理由如下:
- 解耦清晰:拦截器的监控逻辑封装在独立结构体中,和服务端业务逻辑完全分离,后续如果要替换监控客户端(比如从Datadog换成Prometheus),只需修改拦截器结构体,不用动服务端核心代码。
- 复用性强:这个拦截器结构体可以直接用到其他gRPC服务上,只要传入对应配置的监控客户端即可,不用重复写通用指标统计逻辑。
- 测试友好:单独的结构体更容易编写单元测试,只需模拟监控客户端的行为即可,不会牵扯服务端的其他业务逻辑。
至于单例客户端方案,虽然实现简单,但缺点很突出:全局单例状态会导致单元测试互相干扰,而且如果后续需要多个不同配置的Datadog客户端,单例模式根本无法满足需求。
优先级排序:单独拦截器结构体 > 服务端接收者 > 单例客户端
示例代码参考:
type MetricsInterceptor struct { ddClient *datadog.Client } func NewMetricsInterceptor(ddClient *datadog.Client) *MetricsInterceptor { return &MetricsInterceptor{ddClient: ddClient} } func (mi *MetricsInterceptor) UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { // 通用指标统计:请求数、耗时、错误数 startTime := time.Now() mi.ddClient.Incr("grpc.request.count", []string{"method:" + info.FullMethod}) resp, err := handler(ctx, req) duration := time.Since(startTime) mi.ddClient.Timing("grpc.request.duration", duration, []string{"method:" + info.FullMethod}, 1.0) if err != nil { mi.ddClient.Incr("grpc.error.count", []string{"method:" + info.FullMethod, "error:" + err.Error()}) } return resp, err }
创建gRPC服务时注入拦截器:
ddClient := datadog.NewClient(...) metricsInterceptor := NewMetricsInterceptor(ddClient) grpcServer := grpc.NewServer(grpc.UnaryInterceptor(metricsInterceptor.UnaryInterceptor))
2. 日志代码分散的情况下是否继续用拦截器
必须继续用拦截器,核心原因是它解决了通用指标的重复问题,同时实现了关注点分离:
- 避免重复代码:请求数、耗时、错误数这些是所有API都需要的基础指标,用拦截器统一处理后,后续新增API时会自动统计这些指标,不用手动在每个API里重复写。
- 业务与监控分离:拦截器负责通用监控逻辑,业务代码只需要处理特有指标和核心业务逻辑,不会导致业务代码臃肿,可读性和维护性更高。
- 扩展性强:后续要新增通用指标(比如请求大小、响应大小),只需修改拦截器一次,所有API都会生效,不用逐个修改业务代码。
针对特有指标分散的问题,可以做以下优化让代码更整洁:
- 封装特有指标统计函数:比如针对
SubmitJob的job.queued.count,封装成RecordJobQueued(ddClient *datadog.Client, jobType string),业务代码里直接调用这个函数,不用重复写Datadog的调用逻辑。 - 通过上下文传递客户端:如果不想让业务代码依赖服务端结构体里的客户端,可以在拦截器里把Datadog客户端放入gRPC上下文,业务代码从上下文取出后调用,进一步降低耦合度。
示例代码参考:
拦截器中注入客户端到上下文:
func (mi *MetricsInterceptor) UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { ctx = context.WithValue(ctx, "dd_client", mi.ddClient) // 其他通用逻辑... return handler(ctx, req) }
业务代码中取出客户端并统计特有指标:
func (s *myServer) SubmitJob(ctx context.Context, req *SubmitJobRequest) (*SubmitJobResponse, error) { // 业务逻辑处理... ddClient := ctx.Value("dd_client").(*datadog.Client) ddClient.Incr("job.queued.count", []string{"job_type:" + req.JobType}) // ... return &SubmitJobResponse{}, nil }
内容的提问来源于stack exchange,提问作者tastynoodle
相关产品推荐
相关产品推荐

