mcpField
G
go-grpc

go-grpc

github.com/eduardo-sl开发8391 次安装2 周前更新
代码仓库

关于 go-grpc

面向 Go 开发者的 gRPC 服务与客户端设计技能参考,适用于采用契约优先方式构建 API。重点包括版本化 protobuf 契约、语义化状态码、截止时间传递、拦截器、流式处理和内存测试。

主要功能

  1. 01版本化 protobuf 契约设计
  2. 02语义化 gRPC 状态码
  3. 03截止时间与上下文传递
  4. 04一元和流式拦截器
  5. 05流式处理与客户端配置
  6. 06bufconn 测试指引

适用场景

  1. 01设计版本化 RPC 接口
  2. 02将领域错误映射为状态码
  3. 03在服务调用间传递截止时间
  4. 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) 读取调用方经过验证的身份,而非信任元数据。

验证清单

  1. Proto 包已进行版本控制(*.v1);不重复使用标签;移除字段时使用 reserved
  2. 每个 RPC 都有专用的请求/响应消息
  3. 使用固定版本的工具(buf/protoc)生成代码并提交;CI 中运行 buf breaking
  4. 所有处理程序错误都使用语义正确的代码构造为 status.Error
  5. codes.Internal 响应绝不泄露内部错误文本
  6. 每次客户端调用都设置截止时间;ctx 在所有层之间传递
  7. 恢复、日志记录和身份验证通过串联拦截器实现(单向 + 流式)
  8. 流会在 stream.Context().Done() 上进行选择
  9. 已注册健康检查服务;优雅停止并提供强制回退;使用 TLS(内部使用 mTLS)并明确设置接收限制
  10. 针对运行中服务的 grpcurl 冒烟测试(或生成的客户端测试)通过
  11. 每个目标共用一个 ClientConn,通过 grpc.NewClient 创建;重试仅限于 UNAVAILABLE

评论

支持 Markdown