在微服务架构里,gRPC已经成为内部服务通信的主流选择之一。Ruby官方的grpc gem虽然不像Go或Java那样开箱提供丰富的中间件生态,但它提供了GRPC::ServerInterceptor和GRPC::ClientInterceptor两个基类,只要掌握正确的写法,就能把认证、日志、链路追踪这些与业务无关的逻辑统一收敛到拦截器中,让业务代码保持干净。本文将从拦截器的基本原理讲起,逐步实现三个可复用的拦截器,并讨论多拦截器的执行顺序与异常处理。

一、Ruby中gRPC拦截器的工作原理
grpc gem中的拦截器设计借鉴了Rack中间件的思想。服务端拦截器继承GRPC::ServerInterceptor,重写request_response、client_streamer、server_streamer和bidi_streamer四个方法,分别对应一元调用、客户端流、服务端流和双向流四种RPC模式。每个方法内部通过yield把控制权交给真正的RPC实现,因此你可以在yield之前做前置处理,在yield之后做后置处理。
客户端拦截器的思路类似,继承GRPC::ClientInterceptor后重写request_response等方法,通过yield触发实际的远程调用。需要注意的是,元数据(metadata)是在调用时传入的字典,拦截器可以在这里注入认证头或trace上下文。服务端注册拦截器的写法是在GRPC::RpcServer初始化时传入interceptors数组,客户端则在构造stub时通过interceptors:参数传入。
一个容易踩的坑是:如果只重写了request_response,流式RPC将完全绕过你的拦截逻辑。所以在编写通用拦截器时,建议把四个方法都委托到同一个处理函数,确保任何调用模式都不会遗漏。
二、实现认证拦截器
认证是最常见的横切逻辑。客户端在每次调用时把token放进metadata,服务端拦截器提取并校验,校验失败直接抛出GRPC::BadStatus的子类,比如GRPC::PermissionDenied,调用方会收到标准的状态码,无需感知服务端的实现细节。
先看客户端拦截器,它负责在每次调用前注入认证头:
require 'grpc'
class AuthClientInterceptor < GRPC::ClientInterceptor
def initialize(token:, **kwargs)
@token = token
super(**kwargs)
end
def request_response(call:, method:, request:, metadata: {})
metadata['authorization'] = "Bearer #{@token}"
yield
end
def client_streamer(call:, method:, request:, metadata: {})
metadata['authorization'] = "Bearer #{@token}"
yield
end
def server_streamer(call:, method:, request:, metadata: {})
metadata['authorization'] = "Bearer #{@token}"
yield
end
def bidi_streamer(call:, method:, requests:, metadata: {})
metadata['authorization'] = "Bearer #{@token}"
yield
end
end服务端拦截器则提取token并做校验,这里以一个简单的HMAC签名校验为例:
require 'grpc'
class AuthServerInterceptor < GRPC::ServerInterceptor
def initialize(secret:)
@secret = secret
super()
end
def request_response(call:)
authenticate(call)
yield
end
# 其余三种模式同样调用 authenticate 后 yield
private
def authenticate(call)
token = call.metadata['authorization'].to_s.sub(/\ABearer\s+/, '')
raise GRPC::Unauthenticated.new('invalid token') unless valid_token?(token)
end
def valid_token?(token)
expected = OpenSSL::HMAC.hexdigest('SHA256', @secret, 'grpc-demo')
ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
end校验失败抛出GRPC::Unauthenticated后,grpc会自动终止本次调用并返回标准的INVALID_ARGUMENT语义错误码,客户端拿到的就是一个普通的GRPC::BadStatus异常,处理方式与业务异常保持一致。如果服务里存在公开方法(比如健康检查),可以在拦截器内维护一个白名单,根据call.method路径跳过校验。
三、实现日志拦截器
日志拦截器的目标是在不侵入业务代码的前提下记录方法名、对端信息、耗时和结果状态。关键点是利用Ruby的Process.clock_gettime计算单调时钟耗时,避免使用Time.now受系统时间跳变影响。
require 'grpc'
require 'json'
require 'time'
class LoggingInterceptor < GRPC::ServerInterceptor
def initialize(logger:)
@logger = logger
super()
end
%i[request_response client_streamer server_streamer bidi_streamer].each do |mode|
define_method(mode) do |call:, **kwargs, &block|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
result = block.call
status = 'ok'
result
rescue GRPC::BadStatus => e
status = e.code.to_s
raise
ensure
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
@logger.info(JSON.generate(
rpc: call.method,
peer: call.peer,
status: status,
duration_ms: elapsed_ms
))
end
end
end
end这里用define_method配合元编程把四种RPC模式统一处理,代码更紧凑。日志输出采用JSON格式,方便接入ELK或Loki等日志平台做结构化检索。要注意ensure块中必须返回result或者保证异常继续向上抛出,否则可能吞掉异常或者返回nil,导致客户端收到莫名其妙的空响应。
四、实现链路追踪拦截器
链路追踪的核心是上下文传播:客户端拦截器把当前trace上下文序列化后写入metadata的traceparent字段,服务端拦截器解析该字段并继续当前span,从而把跨服务的调用串联成一条完整链路。借助OpenTelemetry的Ruby SDK,实现并不复杂。
require 'grpc'
require 'opentelemetry-api'
require 'opentelemetry/sdk'
# 客户端:注入 trace 上下文
class TraceClientInterceptor < GRPC::ClientInterceptor
def request_response(call:, method:, request:, metadata:)
OpenTelemetry.trace.with_span(
OpenTelemetry.tracer_provider.tracer('grpc-client').start_span(method)
) do |span|
OpenTelemetry.propagation.inject(metadata)
yield
end
end
end
# 服务端:提取 trace 上下文并延续链路
class TraceServerInterceptor < GRPC::ServerInterceptor
def request_response(call:)
ctx = OpenTelemetry.propagation.extract(call.metadata)
OpenTelemetry.context.with_context(ctx) do
span = OpenTelemetry.tracer_provider.tracer('grpc-server')
.start_span(call.method, kind: :server)
begin
yield
ensure
span.finish
end
end
end
end配置好exporter之后,在Jaeger或Zipkin的界面上就能看到客户端span与服务端span通过同一个trace_id串联。如果不想引入完整的OpenTelemetry依赖,也可以手动生成一个UUID塞进x-request-id,日志拦截器读取同一个字段并在日志中输出,这是一种轻量级的准链路追踪方案,适合起步阶段。
五、注册多个拦截器与执行顺序
实际项目中往往需要同时挂载多个拦截器。服务端的注册方式如下:
server = GRPC::RpcServer.new(
interceptors: [
TraceServerInterceptor.new,
AuthServerInterceptor.new(secret: ENV['HMAC_SECRET']),
LoggingInterceptor.new(logger: Rails.logger)
]
)拦截器按数组顺序依次包裹,效果类似洋葱模型:排在前面的拦截器外层逻辑先执行,后置逻辑后执行。所以上面的顺序意味着trace最先开始记录(能覆盖认证失败的场景),认证在业务日志之前完成(日志能区分鉴权失败的请求)。建议把追踪放在最外层、认证放中间、日志放最内层靠近业务代码,这样观测数据的完整性最好。
另一个实践建议是给拦截器写单元测试。grpc提供了GRPC::Testing相关工具,也可以直接在进程内起一个本地server配合stub做集成测试,验证认证失败、正常调用、流式调用等场景下拦截器的行为。只要保持拦截器职责单一、通过metadata传递上下文,这套模式就能平滑地扩展到限流、幂等控制等其他治理逻辑上。