86 eduardo-sl

go-grpc Skill

超越基础的 Go gRPC 服务:proto 设计、状态码与错误详情、拦截器、超时截止时间、流式传输、健康检查和优雅关闭。适用场景:"gRPC 服务""proto 设计""gRPC 错误处理""interceptor""gRPC 流式""gRPC deadline""gRPC 健康检查""gRPC 状态码"。不适用于:REST handler(go-api-design)、与 protobuf 无关的分层(go-architecture-review)、TLS 加固(go-security-audit)。

安装方式:把技能目录放入 ~/.claude/skills/(Claude Code)或在 claude.ai 设置中启用;也可复制右侧安装命令一键添加。

查看源码

技能指令原文(SKILL.md)

Go gRPC Services

gRPC's contract-first model only pays off if the contract is treated as
an API: versioned packages, deliberate error codes, deadlines
everywhere, and interceptors for everything cross-cutting.

Detailed reference material, loaded on demand:

  • references/clients.md — NewClient, service-config retries and load

balancing, keepalive on both sides, reading status codes.

  • references/testing.md — in-memory bufconn server and client,

asserting status codes.

Read a reference file only when the summary below is not enough.

1. Proto Design Rules

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;
}
  • Version in the package (payment.v1); breaking change = payment.v2.
  • Never reuse or renumber field tags; reserved 3, 7; deleted ones.
  • Dedicated Request/Response messages per RPC — adding a field later is

free; changing a shared message breaks every RPC using it.

  • Generate with buf or a pinned protoc in make generate; commit

generated code so builds don't depend on toolchain drift.

  • In CI, buf lint and buf breaking --against '.git#branch=main' stop

renumbered fields and removed RPCs before they reach a client.
Field rules (lengths, ranges, formats) belong in protovalidate
annotations, enforced by an interceptor.

2. Errors: Status Codes, Not Strings

Return status.Error, mapping domain errors in ONE place:

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
    }
}

Code semantics that matter: InvalidArgument (bad request regardless
of state), FailedPrecondition (bad state), NotFound, AlreadyExists,
Unauthenticated vs PermissionDenied, Unavailable (retryable),
Internal (bug). Clients read codes with status.FromError(err) —
never parse messages.

3. Deadlines Are Mandatory

// 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()
}

The server inherits the client's deadline through the context. Pass
ctx into every downstream call (DB, other RPCs) so cancellation
propagates end to end.

4. Interceptors for Cross-Cutting Concerns

Handlers stay business-only; recovery, auth, logging, metrics live in
interceptors:

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
}

Order matters: recovery first (outermost), then observability, then
auth. Streaming RPCs need the parallel StreamInterceptor versions.

5. Streaming

  • Server streaming for large result sets: stream.Send in a loop,

return non-nil error to abort with a status.

  • Client/bidi streaming only when the protocol truly needs it —

each open stream holds a goroutine and flow-control state.

  • Always terminate on 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. Clients

  • grpc.NewClient (it replaces the deprecated Dial) once per target at

startup; share the *grpc.ClientConn — it multiplexes and reconnects.

  • Retries, timeouts and load balancing in the service config: retry

UNAVAILABLE on idempotent methods only, and use round_robin (the
default pick_first sends everything to one backend).

  • Client keepalive pings must be allowed by the server's

EnforcementPolicy (default MinTime 5 minutes), or the server closes
the connection with too_many_pings.

  • Branch on status.Code(err), never on the message.

Examples: references/clients.md.

7. Testing

Test through the generated client over bufconn: a real server with the
production interceptors, no network. Dial passthrough:///bufnet with
WithContextDialer — NewClient's default dns resolver would try to
resolve the name. Assert with status.Code(err). Example:
references/testing.md.

8. Production Server Setup

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)

For mTLS, the server's tls.Config sets ClientCAs and
ClientAuth: tls.RequireAndVerifyClientCert; handlers read the caller's
verified identity from peer.FromContext(ctx) rather than trusting
metadata.

Verification Checklist

  1. Proto packages versioned (*.v1); no tag reuse; reserved for removals
  2. Dedicated Request/Response message per RPC
  3. Generated code produced by a pinned tool (buf/protoc) and committed; buf breaking runs in CI
  4. All handler errors are status.Error with semantically correct codes
  5. codes.Internal responses never leak internal error text
  6. Every client call has a deadline; ctx propagated through all layers
  7. Recovery, logging, auth implemented as chained interceptors (unary + stream)
  8. Streams select on stream.Context().Done()
  9. Health service registered; graceful stop with forced fallback; TLS (mTLS internally) and an explicit receive limit
  10. grpcurl smoke test (or generated client test) passes against the running server
  11. One shared ClientConn per target, built with grpc.NewClient; retries limited to UNAVAILABLE