关于 go-grpc
面向 Go 开发者的 gRPC 服务与客户端设计技能参考,适用于采用契约优先方式构建 API。重点包括版本化 protobuf 契约、语义化状态码、截止时间传递、拦截器、流式处理和内存测试。
主要功能
- 01版本化 protobuf 契约设计
- 02语义化 gRPC 状态码
- 03截止时间与上下文传递
- 04一元和流式拦截器
- 05流式处理与客户端配置
- 06bufconn 测试指引
适用场景
- 01设计版本化 RPC 接口
- 02将领域错误映射为状态码
- 03在服务调用间传递截止时间
- 04无网络测试 gRPC 客户端和服务端
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-grpc安装后在 Claude Code 中输入/go-grpc确认已加载。
附带文件references/clients.mdreferences/testing.md
详细介绍
Go gRPC 服务
只有将契约视为 API,gRPC 的契约优先模式才能发挥价值:对包进行版本管理,审慎选择错误码,为所有操作设置截止时间,并使用拦截器处理所有横切关注点。
按需加载的详细参考资料:
references/clients.md—NewClient、服务配置中的重试和负载均衡、客户端与服务端的 keepalive、读取状态码。references/testing.md— 内存中的bufconn服务端和客户端、断言状态码。
只有在下方摘要不够时,才阅读参考文件。
1. Proto 设计规则
syntax = "proto3";
package payment.v1; // version IN the package
option go_package = "github.com/acme/payment-service/gen/payment/v1;paymentv1";
service PaymentService {
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}
message CreatePaymentRequest { // one request/response pair
string order_id = 1; // per RPC, always — even if
int64 amount_cents = 2; // empty today
}
message CreatePaymentResponse {
Payment payment = 1;
}
- 在包名中包含版本号(
payment.v1);破坏性变更应升级为payment.v2。 - 切勿重复使用或重新编号字段标签;已删除的标签应使用
reserved 3, 7;保留。 - 每个 RPC 使用专属的 Request/Response 消息——以后添加字段不会有问题;更改共享消息则会影响所有使用它的 RPC。
- 在
make generate中使用 buf 或固定版本的 protoc 生成代码;将生成的代码提交到仓库,避免构建受工具链变化影响。 - 在 CI 中运行
buf lint和buf breaking --against '.git#branch=main',在字段重新编号或 RPC 被移除的代码到达客户端之前将其拦截。字段规则(长度、范围、格式)应写在 protovalidate 注解中,并由拦截器强制执行。
2. 错误:使用状态码,而非字符串
返回 status.Error,并在同一个地方映射领域错误:
func (s *Server) CreatePayment(ctx context.Context, req *pb.CreatePaymentRequest) (*pb.CreatePaymentResponse, error) {
p, err := s.svc.Create(ctx, toDomain(req))
if err != nil {
return nil, toStatus(err)
}
return &pb.CreatePaymentResponse{Payment: fromDomain(p)}, nil
}
func toStatus(err error) error {
switch {
case errors.Is(err, domain.ErrNotFound):
return status.Error(codes.NotFound, "payment not found")
case errors.Is(err, domain.ErrDuplicate):
return status.Error(codes.AlreadyExists, "payment already exists")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "timed out")
default:
return status.Error(codes.Internal, "internal error") // no details leak
}
}
需要注意的状态码语义:InvalidArgument(无论状态如何,请求均无效)、FailedPrecondition(状态无效)、NotFound、AlreadyExists、Unauthenticated 与 PermissionDenied 的区别、Unavailable(可重试)、Internal(程序错误)。客户端使用 status.FromError(err) 读取状态码——切勿解析错误消息。
3. 必须设置截止时间
// Client — every call gets a deadline
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.CreatePayment(ctx, req)
// Server — check before expensive work
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
服务端会通过 context 继承客户端的截止时间。每次调用下游服务(数据库、其他 RPC)时都传入 ctx,确保取消信号端到端传播。
4. 使用拦截器处理横切关注点
处理器只处理业务逻辑;恢复、身份验证、日志记录和指标应放在拦截器中:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor, // outermost: panic → codes.Internal
loggingInterceptor,
authInterceptor,
),
)
func loggingInterceptor(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
slog.InfoContext(ctx, "rpc",
slog.String("method", info.FullMethod),
slog.Duration("duration", time.Since(start)),
slog.String("code", status.Code(err).String()),
)
return resp, err
}
顺序很重要:先进行恢复(最外层),然后是可观测性,最后是身份验证。流式 RPC 需要使用对应的 StreamInterceptor 版本。
5. 流式传输
- 对大型结果集使用服务端流式传输:在循环中调用
stream.Send,并返回非 nil 错误,以状态码中止操作。 - 仅在协议确实需要时使用客户端流式传输/双向流式传输——每个打开的流都会占用一个 goroutine 和流量控制状态。
- 始终在
ctx.Done()时终止:
func (s *Server) WatchPayments(req *pb.WatchRequest, stream pb.PaymentService_WatchPaymentsServer) error {
for {
select {
case <-stream.Context().Done():
return status.FromContextError(stream.Context().Err()).Err()
case ev := <-s.events:
if err := stream.Send(toProto(ev)); err != nil {
return err
}
}
}
}
6. 客户端
- 每个目标在启动时调用一次
grpc.NewClient(它取代了已弃用的Dial);共享*grpc.ClientConn——它会复用连接并自动重连。 - 在服务配置中设置重试、超时和负载均衡:仅对幂等方法重试
UNAVAILABLE,并使用round_robin(默认的pick_first会将所有请求发送到同一个后端)。 - 服务端的
EnforcementPolicy必须允许客户端发送 keepalive ping(默认MinTime为 5 分钟),否则服务端会以too_many_pings关闭连接。 - 根据
status.Code(err)进行分支判断,切勿根据错误消息判断。
示例:references/clients.md。
7. 测试
通过生成的客户端使用 bufconn 进行测试:运行真实服务端并启用生产环境使用的拦截器,不需要网络。使用 WithContextDialer 连接 passthrough:///bufnet——NewClient 默认的 dns 解析器会尝试解析该名称。使用 status.Code(err) 断言。示例:references/testing.md。
8. 生产环境服务端配置
lis, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
srv := grpc.NewServer(
grpc.Creds(creds), // TLS; mTLS for service-to-service
grpc.MaxRecvMsgSize(4<<20), // explicit; size it to the largest valid request
grpc.ChainUnaryInterceptor(...),
)
pb.RegisterPaymentServiceServer(srv, server)
healthSrv := health.NewServer() // grpc.health.v1 — load balancers need it
healthpb.RegisterHealthServer(srv, healthSrv)
reflection.Register(srv) // grpcurl/debugging; gate on non-prod if policy requires
go func() {
<-ctx.Done()
stopped := make(chan struct{})
go func() { srv.GracefulStop(); close(stopped) }()
select {
case <-stopped: // in-flight RPCs finished
case <-time.After(10 * time.Second):
srv.Stop() // force after grace period
}
}()
return srv.Serve(lis)
对于 mTLS,服务端的 tls.Config 设置 ClientCAs 和 ClientAuth: tls.RequireAndVerifyClientCert;处理器通过 peer.FromContext(ctx) 读取调用方经过验证的身份,而非信任元数据。
验证清单
- Proto 包已进行版本控制(
*.v1);不重复使用标签;移除字段时使用reserved - 每个 RPC 都有专用的请求/响应消息
- 使用固定版本的工具(buf/protoc)生成代码并提交;CI 中运行
buf breaking - 所有处理程序错误都使用语义正确的代码构造为
status.Error codes.Internal响应绝不泄露内部错误文本- 每次客户端调用都设置截止时间;
ctx在所有层之间传递 - 恢复、日志记录和身份验证通过串联拦截器实现(单向 + 流式)
- 流会在
stream.Context().Done()上进行选择 - 已注册健康检查服务;优雅停止并提供强制回退;使用 TLS(内部使用 mTLS)并明确设置接收限制
- 针对运行中服务的
grpcurl冒烟测试(或生成的客户端测试)通过 - 每个目标共用一个
ClientConn,通过grpc.NewClient创建;重试仅限于UNAVAILABLE