gRPC 拦截器实战——认证鉴权、日志追踪、错误处理中间件完整方案
gRPC 拦截器实战——认证鉴权、日志追踪、错误处理中间件完整方案
适读人群:已在用 gRPC 的 Go 工程师、想系统化 gRPC 中间件的开发者 | 阅读时长:约19分钟 | 核心价值:掌握 gRPC 拦截器的设计与链式组合,构建生产级别的认证、日志、错误处理体系
从一次线上排障说起
那是2023年夏天,我们的 gRPC 服务上线三个月,线上突然出现了一个玄学问题:某个接口偶发性地返回 permission denied 错误,但日志里找不到任何鉴权失败的记录。
我翻了两个小时日志,毫无头绪。最后靠运气,在一个同事的代码 review 里发现了问题:鉴权逻辑写在了业务代码里,但某个新接口漏写了鉴权,权限校验规则也没有统一。
更惨的是,因为日志也没有统一,每个接口的日志格式都不一样,根本没法快速定位是哪个 RPC 出了问题。
这次排障让我意识到:gRPC 服务必须要有拦截器(Interceptor),把认证、日志、错误处理这些横切关注点从业务代码里抽出来,统一管理。
这篇文章就是我重构那套系统的总结。
gRPC 拦截器是什么
gRPC 拦截器相当于 Java Spring 里的 Filter 或 Interceptor,是一种 AOP 思想的实现。
在 gRPC 里,拦截器分两类:
- UnaryInterceptor:拦截普通 RPC(请求-响应)
- StreamInterceptor:拦截流式 RPC
函数签名如下:
// UnaryServerInterceptor:服务端 Unary 拦截器
type UnaryServerInterceptor func(
ctx context.Context,
req interface{},
info *UnaryServerInfo, // 包含方法名等信息
handler UnaryHandler, // 真正的业务处理函数
) (interface{}, error)
// UnaryClientInterceptor:客户端 Unary 拦截器
type UnaryClientInterceptor func(
ctx context.Context,
method string,
req, reply interface{},
cc *ClientConn,
invoker UnaryInvoker, // 真正发起 RPC 的函数
opts ...CallOption,
) error核心思路是:在调用真正的 handler 之前和之后,可以插入自定义逻辑。
实战:三个最重要的拦截器
拦截器1:JWT 认证鉴权
package interceptor
import (
"context"
"strings"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// 不需要鉴权的方法白名单
var noAuthMethods = map[string]bool{
"/user.UserService/Login": true,
"/user.UserService/Register": true,
}
// Claims 自定义 JWT payload
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// contextKey 用于在 context 里传递用户信息,避免 key 冲突
type contextKey string
const userClaimsKey contextKey = "user_claims"
var jwtSecret = []byte("your-secret-key")
// AuthInterceptor 认证鉴权拦截器(服务端)
func AuthInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// 白名单跳过
if noAuthMethods[info.FullMethod] {
return handler(ctx, req)
}
// 从 metadata 里取 token
// Java 类比:gRPC metadata ≈ HTTP Header
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "缺少请求元数据")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "缺少认证 token")
}
tokenStr := strings.TrimPrefix(tokens[0], "Bearer ")
// 解析 JWT
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, status.Errorf(codes.Unauthenticated, "无效的签名算法: %v", t.Header["alg"])
}
return jwtSecret, nil
})
if err != nil || !token.Valid {
return nil, status.Errorf(codes.Unauthenticated, "token 无效: %v", err)
}
// 把用户信息注入 context,业务代码通过 context 取用
ctx = context.WithValue(ctx, userClaimsKey, claims)
return handler(ctx, req)
}
// GetUserFromContext 从 context 中取出用户信息(业务代码调用)
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(userClaimsKey).(*Claims)
return claims, ok
}如何生成 JWT Token(测试用):
func GenerateToken(userID int64, username, role string) (string, error) {
claims := &Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}拦截器2:结构化日志追踪
package interceptor
import (
"context"
"time"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
// LogInterceptor 结构化日志拦截器
func LogInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
// 取调用方 IP
clientIP := "unknown"
if p, ok := peer.FromContext(ctx); ok {
clientIP = p.Addr.String()
}
// 取用户信息(如果已经鉴权过)
userID := int64(0)
if claims, ok := GetUserFromContext(ctx); ok {
userID = claims.UserID
}
// 调用真正的 handler
resp, err := handler(ctx, req)
// 记录日志
duration := time.Since(start)
st, _ := status.FromError(err)
fields := []zap.Field{
zap.String("method", info.FullMethod),
zap.String("client_ip", clientIP),
zap.Int64("user_id", userID),
zap.Duration("duration", duration),
zap.String("code", st.Code().String()),
}
if err != nil {
fields = append(fields, zap.String("error", st.Message()))
if st.Code() == codes.Internal {
logger.Error("RPC 内部错误", fields...)
} else {
logger.Warn("RPC 业务错误", fields...)
}
} else {
logger.Info("RPC 调用", fields...)
}
return resp, err
}
}拦截器3:统一错误处理与 panic 恢复
package interceptor
import (
"context"
"fmt"
"runtime/debug"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// RecoveryInterceptor panic 恢复 + 错误统一包装
func RecoveryInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (resp interface{}, err error) {
// 捕获 panic
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
logger.Error("gRPC handler panic",
zap.Any("panic", r),
zap.String("method", info.FullMethod),
zap.ByteString("stack", stack),
)
// 将 panic 转换为 gRPC Internal 错误,不把内部信息暴露给客户端
err = status.Errorf(codes.Internal, "服务内部错误")
}
}()
resp, err = handler(ctx, req)
// 统一错误包装:把非 gRPC 错误转换为 gRPC 状态错误
if err != nil {
if _, ok := status.FromError(err); !ok {
// 原始错误不是 gRPC status 错误,包装一下
err = status.Errorf(codes.Internal, "内部错误: %v", err)
}
}
return resp, err
}
}拦截器链:多个拦截器组合使用
gRPC 原生只支持注册一个拦截器,要链式组合多个,需要用 grpc.ChainUnaryInterceptor(Go 1.16+ 支持):
package main
import (
"net"
"go.uber.org/zap"
"google.golang.org/grpc"
"your-project/interceptor"
pb "your-project/pb/user"
)
func main() {
logger, _ := zap.NewProduction()
// 拦截器链——注意顺序很重要!
// 执行顺序:Recovery → Log → Auth → Handler → Auth → Log → Recovery
// 建议把 Recovery 放最外层,Log 放第二层,Auth 放最内层
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(
interceptor.RecoveryInterceptor(logger), // 最外层,捕获所有 panic
interceptor.LogInterceptor(logger), // 记录所有请求日志
interceptor.AuthInterceptor, // 鉴权,最后执行
),
)
pb.RegisterUserServiceServer(grpcServer, &UserServer{})
lis, _ := net.Listen("tcp", ":50051")
grpcServer.Serve(lis)
}Java 对比: 这和 Spring Security 的 Filter Chain 很像,拦截器按顺序执行,每个拦截器可以选择继续往下传或直接返回。
客户端拦截器:自动注入 Token
// ClientAuthInterceptor 客户端自动注入 JWT Token
func ClientAuthInterceptor(tokenFunc func() string) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
req, reply interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
token := tokenFunc()
if token != "" {
// 把 token 加到 metadata 里(相当于设置 HTTP Authorization header)
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
// 使用
conn, _ := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithChainUnaryInterceptor(
ClientAuthInterceptor(func() string {
return getTokenFromCache() // 从缓存里取 token,自动刷新
}),
),
)踩坑实录
坑1:拦截器顺序搞反,panic 没被捕获
现象: Recovery 拦截器存在,但 handler 里的 panic 仍然导致服务崩溃。
原因: 把 Recovery 拦截器放到了最内层,Auth 拦截器在更外层先执行,而 Auth 里的 panic 没被捕获。
解法: Recovery 必须是最外层的拦截器(第一个注册的),因为拦截器链类似洋葱模型,第一个注册的最先执行、最后返回,它的 defer recover() 能捕获所有内层的 panic。
坑2:用 string 类型作为 context key,和其他中间件冲突
现象: 在 context 里存了 "user_id",但另一个库也往 context 里存了 "user_id",导致数据被覆盖,取出来的值类型断言失败。
原因: Go 的 context 用 key 存值时,key 是 interface{} 类型。如果两个包都用字符串 "user_id" 做 key,就会冲突。
解法: 定义专属的 context key 类型,用私有类型做 key:
type contextKey string
const userKey contextKey = "user" // 类型不同,不会和其他包的 string key 冲突坑3:流式 RPC 忘了加 Stream 拦截器
现象: Unary RPC 有日志和鉴权,但流式 RPC 没有任何中间件,出问题完全看不到日志。
原因: grpc.ChainUnaryInterceptor 只对 Unary RPC 生效,流式 RPC 需要用 grpc.ChainStreamInterceptor。
解法: 同时注册两种拦截器:
grpc.NewServer(
grpc.ChainUnaryInterceptor(unaryInterceptors...),
grpc.ChainStreamInterceptor(streamInterceptors...),
)流式拦截器的接口签名不同,需要单独实现:
func StreamAuthInterceptor(
srv interface{},
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
// 鉴权逻辑
if err := authenticate(ss.Context()); err != nil {
return err
}
return handler(srv, ss)
}总结
gRPC 拦截器是构建生产级 gRPC 服务的必备工具。我给的实践建议:
- Recovery 永远放最外层,防止 panic 扩散。
- Log 放第二层,确保所有请求都有日志,包括鉴权失败的。
- Auth 放最内层,鉴权通过后才进入业务代码。
- 流式 RPC 不要忘记加 Stream 拦截器,很多人只加了 Unary 的。
- context key 用私有类型,避免跨包冲突。
这五条规则,能帮你避开90%的拦截器坑。
