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-memorybufconnserver 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 lintandbuf 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.Sendin 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 deprecatedDial) 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
- Proto packages versioned (
*.v1); no tag reuse;reservedfor removals - Dedicated Request/Response message per RPC
- Generated code produced by a pinned tool (buf/protoc) and committed;
buf breakingruns in CI - All handler errors are
status.Errorwith semantically correct codes codes.Internalresponses never leak internal error text- Every client call has a deadline; ctx propagated through all layers
- Recovery, logging, auth implemented as chained interceptors (unary + stream)
- Streams select on
stream.Context().Done() - Health service registered; graceful stop with forced fallback; TLS (mTLS internally) and an explicit receive limit
grpcurlsmoke test (or generated client test) passes against the running server- One shared
ClientConnper target, built withgrpc.NewClient; retries limited toUNAVAILABLE