[teamai] Push 87 resource(s) from XingfenD
This commit is contained in:
@@ -0,0 +1 @@
|
||||
XingfenD
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: golang-grpc
|
||||
description: "Provides gRPC usage guidelines, protobuf organization, and production-ready patterns for Golang microservices. Use when implementing, reviewing, or debugging gRPC servers/clients, writing proto files, setting up interceptors, handling gRPC errors with status codes, configuring TLS/mTLS, testing with bufconn, or working with streaming RPCs."
|
||||
user-invocable: true
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.1.7"
|
||||
openclaw:
|
||||
emoji: "🌐"
|
||||
homepage: https://github.com/samber/cc-skills-golang
|
||||
requires:
|
||||
bins:
|
||||
- go
|
||||
- protoc
|
||||
install:
|
||||
- kind: brew
|
||||
formula: protobuf
|
||||
bins: [protoc]
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs Bash(protoc:*) AskUserQuestion Bash(godig:*) Bash(gopls:*) LSP mcp__gopls__*
|
||||
---
|
||||
|
||||
**Persona:** You are a Go distributed systems engineer. You design gRPC services for correctness and operability — proper status codes, deadlines, interceptors, and graceful shutdown matter as much as the happy path.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Build mode** — implementing a new gRPC server or client from scratch.
|
||||
- **Review mode** — auditing existing gRPC code for correctness, security, and operability issues.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- protoc: `brew install protobuf`
|
||||
- protoc-gen-go: `go install google.golang.org/protobuf/cmd/protoc-gen-go@latest`
|
||||
- protoc-gen-go-grpc: `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest`
|
||||
|
||||
# Go gRPC Best Practices
|
||||
|
||||
Treat gRPC as a pure transport layer — keep it separate from business logic. The official Go implementation is `google.golang.org/grpc`.
|
||||
|
||||
This skill is not exhaustive. Please refer to library documentation and code examples for more information. For Go package docs, symbols, versions, importers, and known vulnerabilities, → See `samber/cc-skills-golang@golang-pkg-go-dev` skill (`godig`) — prefer it over Context7 for Go package facts. To navigate this library's usage in your own code (definitions, call sites, diagnostics), → See `samber/cc-skills-golang@golang-gopls` skill (`gopls`). Context7 remains a fallback for docs not indexed on pkg.go.dev.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Concern | Package / Tool |
|
||||
| --- | --- |
|
||||
| Service definition | `protoc` or `buf` with `.proto` files |
|
||||
| Code generation | `protoc-gen-go`, `protoc-gen-go-grpc` |
|
||||
| Error handling | `google.golang.org/grpc/status` with `codes` |
|
||||
| Rich error details | `google.golang.org/genproto/googleapis/rpc/errdetails` |
|
||||
| Interceptors | `grpc.ChainUnaryInterceptor`, `grpc.ChainStreamInterceptor` |
|
||||
| Middleware ecosystem | `github.com/grpc-ecosystem/go-grpc-middleware` |
|
||||
| Testing | `google.golang.org/grpc/test/bufconn` |
|
||||
| TLS / mTLS | `google.golang.org/grpc/credentials` |
|
||||
| Health checks | `google.golang.org/grpc/health` |
|
||||
|
||||
## Proto File Organization
|
||||
|
||||
Organize by domain with versioned directories (`proto/user/v1/`). Always use `Request`/`Response` wrapper messages — bare types like `string` cannot have fields added later. Generate with `buf generate` or `protoc`.
|
||||
|
||||
[Proto & code generation reference](references/protoc-reference.md)
|
||||
|
||||
## Server Implementation
|
||||
|
||||
- Implement health check service (`grpc_health_v1`) — Kubernetes probes need it to determine readiness
|
||||
- Use interceptors for cross-cutting concerns (logging, auth, recovery) — keeps business logic clean
|
||||
- Use `GracefulStop()` with a timeout fallback to `Stop()` — drains in-flight RPCs while preventing hangs
|
||||
- Disable reflection in production — it exposes your full API surface
|
||||
|
||||
```go
|
||||
srv := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(loggingInterceptor, recoveryInterceptor),
|
||||
)
|
||||
pb.RegisterUserServiceServer(srv, svc)
|
||||
healthpb.RegisterHealthServer(srv, health.NewServer())
|
||||
|
||||
go srv.Serve(lis)
|
||||
|
||||
// On shutdown signal:
|
||||
stopped := make(chan struct{})
|
||||
go func() { srv.GracefulStop(); close(stopped) }()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(15 * time.Second):
|
||||
srv.Stop()
|
||||
}
|
||||
```
|
||||
|
||||
### Interceptor Pattern
|
||||
|
||||
```go
|
||||
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
start := time.Now()
|
||||
resp, err := handler(ctx, req)
|
||||
log.Printf("method=%s duration=%s code=%s", info.FullMethod, time.Since(start), status.Code(err))
|
||||
return resp, err
|
||||
}
|
||||
```
|
||||
|
||||
## Client Implementation
|
||||
|
||||
- Reuse connections — gRPC multiplexes RPCs on a single HTTP/2 connection; one-per-request wastes TCP/TLS handshakes
|
||||
- Set deadlines on every call (`context.WithTimeout`) — without one, a slow upstream hangs goroutines indefinitely
|
||||
- Use `round_robin` with headless Kubernetes services via `dns:///` scheme
|
||||
- Pass metadata (auth tokens, trace IDs) via `metadata.NewOutgoingContext`
|
||||
|
||||
```go
|
||||
conn, err := grpc.NewClient("dns:///user-service:50051",
|
||||
grpc.WithTransportCredentials(creds),
|
||||
grpc.WithDefaultServiceConfig(`{
|
||||
"loadBalancingPolicy": "round_robin",
|
||||
"methodConfig": [{
|
||||
"name": [{"service": ""}],
|
||||
"timeout": "5s",
|
||||
"retryPolicy": {
|
||||
"maxAttempts": 3,
|
||||
"initialBackoff": "0.1s",
|
||||
"maxBackoff": "1s",
|
||||
"backoffMultiplier": 2,
|
||||
"retryableStatusCodes": ["UNAVAILABLE"]
|
||||
}
|
||||
}]
|
||||
}`),
|
||||
)
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Always return gRPC errors using `status.Error` with a specific code — a raw `error` becomes `codes.Unknown`, telling the client nothing actionable. Clients use codes to decide retry vs fail-fast vs degrade.
|
||||
|
||||
| Code | When to Use |
|
||||
| -------------------- | ------------------------------------------- |
|
||||
| `InvalidArgument` | Malformed input (missing field, bad format) |
|
||||
| `NotFound` | Entity does not exist |
|
||||
| `AlreadyExists` | Create failed, entity exists |
|
||||
| `PermissionDenied` | Caller lacks permission |
|
||||
| `Unauthenticated` | Missing or invalid token |
|
||||
| `FailedPrecondition` | System not in required state |
|
||||
| `ResourceExhausted` | Rate limit or quota exceeded |
|
||||
| `Unavailable` | Transient issue, safe to retry |
|
||||
| `Internal` | Unexpected bug |
|
||||
| `DeadlineExceeded` | Timeout |
|
||||
|
||||
```go
|
||||
// ✗ Bad — caller gets codes.Unknown, can't decide whether to retry
|
||||
return nil, fmt.Errorf("user not found")
|
||||
|
||||
// ✓ Good — specific code lets clients act appropriately
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, status.Errorf(codes.NotFound, "user %q not found", req.UserId)
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "lookup failed: %v", err)
|
||||
```
|
||||
|
||||
For field-level validation errors, attach `errdetails.BadRequest` via `status.WithDetails`.
|
||||
|
||||
## Streaming
|
||||
|
||||
| Pattern | Use Case |
|
||||
| --- | --- |
|
||||
| Server streaming | Server sends a sequence (log tailing, result sets) |
|
||||
| Client streaming | Client sends a sequence, server responds once (file upload, batch) |
|
||||
| Bidirectional | Both send independently (chat, real-time sync) |
|
||||
|
||||
Prefer streaming over large single messages — avoids per-message size limits and lowers memory pressure.
|
||||
|
||||
```go
|
||||
func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
|
||||
for _, u := range users {
|
||||
if err := stream.Send(u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bufconn` for in-memory connections that exercise the full gRPC stack (serialization, interceptors, metadata) without network overhead. Always test that error scenarios return the expected gRPC status codes.
|
||||
|
||||
[Testing patterns and examples](references/testing.md)
|
||||
|
||||
## Security
|
||||
|
||||
- TLS MUST be enabled in production — credentials travel in metadata
|
||||
- For service-to-service auth, use mTLS or delegate to a service mesh (Istio, Linkerd)
|
||||
- For user auth, implement `credentials.PerRPCCredentials` and validate tokens in an auth interceptor
|
||||
- Reflection SHOULD be disabled in production to prevent API discovery
|
||||
|
||||
## Performance
|
||||
|
||||
| Setting | Purpose | Typical Value |
|
||||
| --- | --- | --- |
|
||||
| `keepalive.ServerParameters.Time` | Ping interval for idle connections | 30s |
|
||||
| `keepalive.ServerParameters.Timeout` | Ping ack timeout | 10s |
|
||||
| `grpc.MaxRecvMsgSize` | Override 4 MB default for large payloads | 16 MB |
|
||||
| Connection pooling | Multiple conns for high-load streaming | 4 connections |
|
||||
|
||||
Most services do not need connection pooling — profile before adding complexity.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Returning raw `error` | Becomes `codes.Unknown` — client can't decide whether to retry. Use `status.Errorf` with a specific code |
|
||||
| No deadline on client calls | Slow upstream hangs indefinitely. Always `context.WithTimeout` |
|
||||
| New connection per request | Wastes TCP/TLS handshakes. Create once, reuse — HTTP/2 multiplexes RPCs |
|
||||
| Reflection enabled in production | Lets attackers enumerate every method. Enable only in dev/staging |
|
||||
| `codes.Internal` for all errors | Wrong codes break client retry logic. `Unavailable` triggers retry; `InvalidArgument` does not |
|
||||
| Bare types as RPC arguments | Can't add fields to `string`. Wrapper messages allow backwards-compatible evolution |
|
||||
| Missing health check service | Kubernetes can't determine readiness, kills pods during deployments |
|
||||
| Ignoring context cancellation | Long operations continue after caller gave up. Check `ctx.Err()` |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-context` skill for deadline and cancellation patterns
|
||||
- → See `samber/cc-skills-golang@golang-error-handling` skill for gRPC error to Go error mapping
|
||||
- → See `samber/cc-skills-golang@golang-observability` skill for gRPC interceptors (logging, tracing, metrics)
|
||||
- → See `samber/cc-skills-golang@golang-testing` skill for gRPC testing with bufconn
|
||||
@@ -0,0 +1,156 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "raw-error-vs-status-error",
|
||||
"description": "Tests that gRPC handlers return status.Errorf with specific codes, not raw errors",
|
||||
"prompt": "Write a Go gRPC handler for GetUser that looks up a user from a repository. If the user is not found, return an appropriate error. If the repository returns an unexpected error, return that too.",
|
||||
"trap": "Without the skill, the model may return fmt.Errorf or raw errors, which become codes.Unknown. The skill requires status.Errorf with specific codes.",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Uses status.Errorf (or status.Error) for the not-found case with codes.NotFound"},
|
||||
{"id": "1.2", "text": "Uses status.Errorf with codes.Internal for unexpected errors"},
|
||||
{"id": "1.3", "text": "Does NOT return a raw fmt.Errorf or errors.New as the gRPC error"},
|
||||
{"id": "1.4", "text": "Imports google.golang.org/grpc/status and google.golang.org/grpc/codes"},
|
||||
{"id": "1.5", "text": "Does NOT leak internal error details in the user-facing gRPC message for Internal errors"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "wrapper-messages-not-bare-types",
|
||||
"description": "Tests that proto RPCs use Request/Response wrapper messages, not bare types",
|
||||
"prompt": "Write a proto3 service definition for a product catalog with RPCs: GetProduct (takes product ID, returns product), DeleteProduct (takes product ID, returns nothing), SearchProducts (takes search query string, returns list of products).",
|
||||
"trap": "Without the skill, the model may use bare types like string or google.protobuf.Empty. The skill requires Request/Response wrappers for backward-compatible evolution.",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Uses GetProductRequest/GetProductResponse wrapper messages, not bare string or Product"},
|
||||
{"id": "2.2", "text": "Uses DeleteProductRequest/DeleteProductResponse wrapper messages, not bare string or google.protobuf.Empty"},
|
||||
{"id": "2.3", "text": "Uses SearchProductsRequest/SearchProductsResponse wrapper messages"},
|
||||
{"id": "2.4", "text": "Each wrapper message has properly named fields (not just a single unnamed field)"},
|
||||
{"id": "2.5", "text": "Includes go_package option in the proto file"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "proto-directory-organization",
|
||||
"description": "Tests proper proto file organization by domain with versioned directories",
|
||||
"prompt": "I'm building a microservice with user management and order management. How should I organize my proto files? Show the directory layout and an example buf.gen.yaml.",
|
||||
"trap": "Without the skill, the model may put all protos in a flat directory or skip versioning. The skill requires domain-based organization with v1 directories.",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Organizes proto files by domain (user/, order/ or similar domain grouping)"},
|
||||
{"id": "3.2", "text": "Includes version directories (v1/ under each domain)"},
|
||||
{"id": "3.3", "text": "Separates message definitions from service definitions (e.g. user.proto vs user_service.proto)"},
|
||||
{"id": "3.4", "text": "Includes a shared/ or common/ directory for shared messages"},
|
||||
{"id": "3.5", "text": "Shows buf.gen.yaml with go and go-grpc plugins configured"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "graceful-stop-with-timeout-fallback",
|
||||
"description": "Tests proper gRPC server shutdown: GracefulStop with timeout fallback to Stop",
|
||||
"prompt": "Write Go code for a gRPC server that handles shutdown signals properly. The server should try to drain in-flight requests before stopping.",
|
||||
"trap": "Without the skill, the model may only call srv.Stop() or only srv.GracefulStop() without a timeout fallback. The skill requires GracefulStop with a timeout fallback to Stop.",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Calls srv.GracefulStop() first to drain in-flight RPCs"},
|
||||
{"id": "4.2", "text": "Has a timeout mechanism that falls back to srv.Stop() if GracefulStop takes too long"},
|
||||
{"id": "4.3", "text": "Uses a select statement or timer for the timeout"},
|
||||
{"id": "4.4", "text": "Listens for OS signals (SIGINT, SIGTERM or os.Interrupt)"},
|
||||
{"id": "4.5", "text": "The timeout is reasonable (5-30 seconds)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "health-check-service-registration",
|
||||
"description": "Tests that gRPC servers register the health check service for Kubernetes readiness probes",
|
||||
"prompt": "Write a production-ready gRPC server setup in Go. Register a UserService and make it deployable to Kubernetes.",
|
||||
"trap": "Without the skill, the model may not register the health check service. The skill says health check is required for Kubernetes probes.",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Registers grpc_health_v1.RegisterHealthServer (or equivalent health check service)"},
|
||||
{"id": "5.2", "text": "Uses health.NewServer() to create the health service"},
|
||||
{"id": "5.3", "text": "Registers interceptors using grpc.ChainUnaryInterceptor"},
|
||||
{"id": "5.4", "text": "Does NOT enable reflection (or explicitly disables it for production)"},
|
||||
{"id": "5.5", "text": "Includes graceful shutdown handling"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "client-connection-reuse-and-deadlines",
|
||||
"description": "Tests that gRPC clients reuse connections and set deadlines on every call",
|
||||
"prompt": "Write a Go gRPC client that calls a UserService. Create the client and make a GetUser call.",
|
||||
"trap": "Without the skill, the model may create a new connection per request or not set a deadline. The skill requires connection reuse and context.WithTimeout on every call.",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Creates the connection once and reuses it (not creating a new connection per call)"},
|
||||
{"id": "6.2", "text": "Uses context.WithTimeout (or context.WithDeadline) for the RPC call"},
|
||||
{"id": "6.3", "text": "Uses grpc.NewClient (not the deprecated grpc.Dial)"},
|
||||
{"id": "6.4", "text": "Includes transport credentials (TLS or explicitly insecure for dev)"},
|
||||
{"id": "6.5", "text": "Properly defers cancel() from context.WithTimeout"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "bufconn-testing-pattern",
|
||||
"description": "Tests that gRPC tests use bufconn for in-memory connections, not real network",
|
||||
"prompt": "Write a test for a Go gRPC UserService that verifies GetUser returns the correct user and that requesting a non-existent user returns the proper error code.",
|
||||
"trap": "Without the skill, the model may start a real TCP server for testing. The skill requires bufconn for in-memory connections.",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses bufconn.Listen for an in-memory listener"},
|
||||
{"id": "7.2", "text": "Uses grpc.WithContextDialer with the bufconn dialer"},
|
||||
{"id": "7.3", "text": "Verifies the gRPC status code for the not-found case using status.FromError"},
|
||||
{"id": "7.4", "text": "Checks that the error code is codes.NotFound specifically"},
|
||||
{"id": "7.5", "text": "Uses t.Cleanup or defer for cleaning up the server and connection"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "error-code-selection-judgment",
|
||||
"description": "Tests correct gRPC error code selection across different scenarios",
|
||||
"prompt": "Write a Go gRPC handler for CreateOrder that validates the request has a non-empty user_id and at least one item, checks the user exists, checks inventory availability, and creates the order. Return appropriate gRPC errors for each failure case.",
|
||||
"trap": "Without the skill, the model may use codes.Internal for everything or pick wrong codes. The skill has a specific mapping table.",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Uses codes.InvalidArgument for missing user_id or empty items list"},
|
||||
{"id": "8.2", "text": "Uses codes.NotFound for user not found"},
|
||||
{"id": "8.3", "text": "Uses codes.FailedPrecondition for insufficient inventory (system not in required state)"},
|
||||
{"id": "8.4", "text": "Uses codes.Internal only for truly unexpected errors"},
|
||||
{"id": "8.5", "text": "Does NOT use codes.Unknown for any handled error case"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "unimplemented-server-embedding",
|
||||
"description": "Tests that gRPC server implementations embed UnimplementedXxxServer, not UnsafeXxxServer",
|
||||
"prompt": "Write a Go implementation of a gRPC server for an OrderService with CreateOrder and GetOrder RPCs. Show the struct definition and method signatures.",
|
||||
"trap": "Without the skill, the model may embed UnsafeXxxServer or not embed any base server. The skill requires UnimplementedXxxServer for forward compatibility.",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Embeds UnimplementedOrderServiceServer in the server struct"},
|
||||
{"id": "9.2", "text": "Does NOT embed UnsafeOrderServiceServer"},
|
||||
{"id": "9.3", "text": "Implements the service methods with correct signatures (context, request pointer, response pointer and error return)"},
|
||||
{"id": "9.4", "text": "Uses status.Errorf for error returns in the handler methods"},
|
||||
{"id": "9.5", "text": "Registers the server with pb.RegisterOrderServiceServer"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "client-load-balancing-and-retry",
|
||||
"description": "Tests knowledge of gRPC client-side load balancing and retry configuration",
|
||||
"prompt": "I'm deploying a Go gRPC client in Kubernetes. The server has multiple replicas behind a headless service. How do I configure the client to distribute requests across all replicas and retry on transient failures?",
|
||||
"trap": "Without the skill, the model may suggest a separate load balancer or basic round-robin without the dns:/// scheme. The skill shows the specific configuration.",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Uses the dns:/// scheme for service discovery with headless Kubernetes services"},
|
||||
{"id": "10.2", "text": "Configures round_robin load balancing policy via service config"},
|
||||
{"id": "10.3", "text": "Configures retry policy in the service config with retryableStatusCodes containing UNAVAILABLE"},
|
||||
{"id": "10.4", "text": "Sets maxAttempts, initialBackoff, maxBackoff in the retry policy"},
|
||||
{"id": "10.5", "text": "Does NOT suggest creating a new connection per request for load distribution"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "streaming-over-large-messages",
|
||||
"description": "Tests preference for streaming over large single messages",
|
||||
"prompt": "I need to send a result set of 50,000 records from a gRPC server to a client. Each record is about 1KB. What's the best approach?",
|
||||
"trap": "Without the skill, the model may suggest increasing MaxRecvMsgSize and sending all records in one response. The skill recommends streaming to avoid per-message size limits and memory pressure.",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Recommends server streaming RPC to send records incrementally"},
|
||||
{"id": "11.2", "text": "Explains why a single large message is problematic (size limits, memory pressure)"},
|
||||
{"id": "11.3", "text": "Shows the server streaming pattern with stream.Send in a loop"},
|
||||
{"id": "11.4", "text": "Client reads with stream.Recv in a loop, checking for io.EOF"},
|
||||
{"id": "11.5", "text": "Does NOT just increase MaxRecvMsgSize as the primary solution"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
# Protobuf & Code Generation Reference
|
||||
|
||||
## Directory Layout
|
||||
|
||||
Organize proto files by domain with versioned directories. Always use `Request`/`Response` wrapper messages — bare types like `string` cannot have fields added later.
|
||||
|
||||
```
|
||||
proto/
|
||||
├── user/v1/
|
||||
│ ├── user.proto # Messages
|
||||
│ └── user_service.proto # Service RPCs
|
||||
├── order/v1/
|
||||
│ ├── order.proto
|
||||
│ └── order_service.proto
|
||||
└── shared/v1/
|
||||
└── common.proto # Pagination, timestamps, shared enums
|
||||
```
|
||||
|
||||
## Proto File Conventions
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package mycompany.user.v1;
|
||||
option go_package = "github.com/mycompany/myservice/gen/user/v1;userv1";
|
||||
|
||||
service UserService {
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
|
||||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||||
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
|
||||
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message ListUsersRequest {
|
||||
int32 page_size = 1;
|
||||
string page_token = 2;
|
||||
}
|
||||
|
||||
message ListUsersResponse {
|
||||
repeated User users = 1;
|
||||
string next_page_token = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### `go_package` conventions
|
||||
|
||||
- Format: `"import/path;alias"` — the alias becomes the Go package name
|
||||
- Convention: lowercase version suffix (e.g. `userv1`, `orderv1`)
|
||||
- Generate into a `gen/` directory to keep generated code separate from hand-written code
|
||||
|
||||
## Code Generation with `protoc`
|
||||
|
||||
```bash
|
||||
# Basic generation
|
||||
protoc --go_out=gen --go_opt=paths=source_relative \
|
||||
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
|
||||
proto/user/v1/*.proto
|
||||
|
||||
# With validation (using buf-validate)
|
||||
protoc --go_out=gen --go_opt=paths=source_relative \
|
||||
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
|
||||
--validate_out="lang=go:gen" \
|
||||
proto/user/v1/*.proto
|
||||
|
||||
# Include external imports
|
||||
protoc -I proto -I third_party \
|
||||
--go_out=gen --go_opt=paths=source_relative \
|
||||
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
|
||||
proto/user/v1/*.proto
|
||||
```
|
||||
|
||||
### Common `protoc` flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| -------------------------------- | --------------------------------------- |
|
||||
| `--go_out=DIR` | Output directory for message types |
|
||||
| `--go-grpc_out=DIR` | Output directory for service stubs |
|
||||
| `--go_opt=paths=source_relative` | Place output relative to proto source |
|
||||
| `-I DIR` | Add import path for proto dependencies |
|
||||
| `--descriptor_set_out=FILE` | Emit binary descriptor (for reflection) |
|
||||
|
||||
## Code Generation with `buf`
|
||||
|
||||
`buf` is the recommended modern alternative to raw `protoc`. It manages dependencies, lints protos, and generates code from a single config.
|
||||
|
||||
### `buf.gen.yaml`
|
||||
|
||||
```yaml
|
||||
version: v2
|
||||
plugins:
|
||||
- remote: buf.build/protocolbuffers/go
|
||||
out: gen
|
||||
opt: paths=source_relative
|
||||
- remote: buf.build/grpc/go
|
||||
out: gen
|
||||
opt: paths=source_relative
|
||||
```
|
||||
|
||||
### `buf.yaml`
|
||||
|
||||
```yaml
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
```
|
||||
|
||||
### Common `buf` commands
|
||||
|
||||
```bash
|
||||
buf generate # Generate code from buf.gen.yaml
|
||||
buf lint # Lint proto files
|
||||
buf breaking --against '.git#branch=main' # Check backward compatibility
|
||||
buf dep update # Update dependencies
|
||||
buf build # Validate proto files compile
|
||||
```
|
||||
|
||||
## Generated Code Patterns
|
||||
|
||||
After generation, import and use the generated code:
|
||||
|
||||
```go
|
||||
import (
|
||||
userv1 "github.com/mycompany/myservice/gen/user/v1"
|
||||
)
|
||||
|
||||
// Server: implement the interface
|
||||
type userServer struct {
|
||||
userv1.UnimplementedUserServiceServer
|
||||
}
|
||||
|
||||
// Client: use the generated client
|
||||
client := userv1.NewUserServiceClient(conn)
|
||||
resp, err := client.GetUser(ctx, &userv1.GetUserRequest{UserId: "123"})
|
||||
```
|
||||
|
||||
Always embed `Unimplemented*Server` (not `Unsafe*Server`) — it provides forward compatibility when new RPCs are added to the proto definition.
|
||||
@@ -0,0 +1,270 @@
|
||||
# gRPC Testing Reference
|
||||
|
||||
## Testing with `bufconn`
|
||||
|
||||
`bufconn` creates in-memory connections that exercise the full gRPC stack (serialization, interceptors, metadata) without network overhead. This is the standard approach for gRPC unit and integration tests.
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```go
|
||||
func setupTest(t *testing.T) pb.UserServiceClient {
|
||||
t.Helper()
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
t.Cleanup(func() { lis.Close() })
|
||||
|
||||
srv := grpc.NewServer()
|
||||
pb.RegisterUserServiceServer(srv, newTestService())
|
||||
t.Cleanup(func() { srv.Stop() })
|
||||
go srv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufconn",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return lis.DialContext(ctx)
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return pb.NewUserServiceClient(conn)
|
||||
}
|
||||
```
|
||||
|
||||
### Setup with Interceptors
|
||||
|
||||
Test your interceptors by including them in the test server:
|
||||
|
||||
```go
|
||||
func setupTestWithInterceptors(t *testing.T) pb.UserServiceClient {
|
||||
t.Helper()
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
t.Cleanup(func() { lis.Close() })
|
||||
|
||||
srv := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(
|
||||
loggingInterceptor,
|
||||
authInterceptor,
|
||||
recoveryInterceptor,
|
||||
),
|
||||
)
|
||||
pb.RegisterUserServiceServer(srv, newTestService())
|
||||
t.Cleanup(func() { srv.Stop() })
|
||||
go srv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufconn",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return lis.DialContext(ctx)
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return pb.NewUserServiceClient(conn)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Error Codes
|
||||
|
||||
Always verify that RPCs return the expected gRPC status codes — clients rely on codes for retry and error-handling logic.
|
||||
|
||||
```go
|
||||
func TestGetUser_NotFound(t *testing.T) {
|
||||
client := setupTest(t)
|
||||
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "nonexistent"})
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected gRPC status error, got: %v", err)
|
||||
}
|
||||
if st.Code() != codes.NotFound {
|
||||
t.Errorf("expected NotFound, got %s: %s", st.Code(), st.Message())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Table-Driven Error Code Tests
|
||||
|
||||
```go
|
||||
func TestGetUser_Errors(t *testing.T) {
|
||||
client := setupTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *pb.GetUserRequest
|
||||
wantCode codes.Code
|
||||
}{
|
||||
{
|
||||
name: "empty user ID",
|
||||
req: &pb.GetUserRequest{UserId: ""},
|
||||
wantCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "user not found",
|
||||
req: &pb.GetUserRequest{UserId: "nonexistent"},
|
||||
wantCode: codes.NotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := client.GetUser(context.Background(), tt.req)
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected gRPC status error, got: %v", err)
|
||||
}
|
||||
if st.Code() != tt.wantCode {
|
||||
t.Errorf("code = %s, want %s; message: %s", st.Code(), tt.wantCode, st.Message())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Streaming RPCs
|
||||
|
||||
### Server Streaming
|
||||
|
||||
```go
|
||||
func TestListUsers_Stream(t *testing.T) {
|
||||
client := setupTest(t)
|
||||
stream, err := client.ListUsers(context.Background(), &pb.ListUsersRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
|
||||
var users []*pb.User
|
||||
for {
|
||||
user, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
if len(users) != 3 {
|
||||
t.Errorf("got %d users, want 3", len(users))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Client Streaming
|
||||
|
||||
```go
|
||||
func TestBatchCreate_ClientStream(t *testing.T) {
|
||||
client := setupTest(t)
|
||||
stream, err := client.BatchCreateUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchCreateUsers: %v", err)
|
||||
}
|
||||
|
||||
for _, u := range testUsers {
|
||||
if err := stream.Send(u); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndRecv: %v", err)
|
||||
}
|
||||
if resp.Created != int32(len(testUsers)) {
|
||||
t.Errorf("created = %d, want %d", resp.Created, len(testUsers))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Metadata
|
||||
|
||||
Verify that interceptors correctly read/write metadata:
|
||||
|
||||
```go
|
||||
func TestAuth_Metadata(t *testing.T) {
|
||||
client := setupTestWithInterceptors(t)
|
||||
|
||||
// Without auth token → Unauthenticated
|
||||
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "1"})
|
||||
if st, _ := status.FromError(err); st.Code() != codes.Unauthenticated {
|
||||
t.Errorf("expected Unauthenticated without token, got %s", st.Code())
|
||||
}
|
||||
|
||||
// With valid token → success
|
||||
md := metadata.Pairs("authorization", "Bearer valid-token")
|
||||
ctx := metadata.NewOutgoingContext(context.Background(), md)
|
||||
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with valid token: %v", err)
|
||||
}
|
||||
if resp.User == nil {
|
||||
t.Error("expected user in response")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Deadlines
|
||||
|
||||
```go
|
||||
func TestGetUser_DeadlineExceeded(t *testing.T) {
|
||||
client := setupTest(t) // server handler sleeps for 5s
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "slow"})
|
||||
st, _ := status.FromError(err)
|
||||
if st.Code() != codes.DeadlineExceeded {
|
||||
t.Errorf("expected DeadlineExceeded, got %s", st.Code())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Test Patterns
|
||||
|
||||
For tests that hit real dependencies (database, external services), use build tags to separate them:
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
func TestUserService_Integration(t *testing.T) {
|
||||
// Connect to real gRPC server
|
||||
conn, err := grpc.NewClient("localhost:50051",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
|
||||
// Test full roundtrip
|
||||
created, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
|
||||
Name: "integration-test",
|
||||
Email: "test@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
got, err := client.GetUser(context.Background(), &pb.GetUserRequest{
|
||||
UserId: created.User.Id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.User.Name != "integration-test" {
|
||||
t.Errorf("name = %q, want %q", got.User.Name, "integration-test")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run integration tests separately:
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./...
|
||||
```
|
||||
Reference in New Issue
Block a user