gRPC is a high-performance RPC (Remote Procedure Call) framework that enables efficient communication between distributed services. In this guide, we'll explore how to build, test, and deploy gRPC services in Go.
Project Setup
First, let's set up our project structure and install necessary dependencies:
mkdir grpc-go-demo
cd grpc-go-demo
go mod init grpc-go-demo
# Install required packages
go get -u google.golang.org/grpc
go get -u google.golang.org/protobuf
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Project structure:
.
├── proto/
│ └── book/
│ └── v1/
│ └── book.proto
├── internal/
│ ├── server/
│ │ └── book.go
│ └── client/
│ └── book.go
├── cmd/
│ ├── server/
│ │ └── main.go
│ └── client/
│ └── main.go
└── go.mod
Defining the Protocol
Let's create a book service prototype in proto/book/v1/book.proto
:
syntax = "proto3";
package book.v1;
option go_package = "grpc-go-demo/proto/book/v1;bookv1";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
service BookService {
rpc CreateBook(CreateBookRequest) returns (Book);
rpc GetBook(GetBookRequest) returns (Book);
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
rpc UpdateBook(UpdateBookRequest) returns (Book);
rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty);
rpc WatchBooks(WatchBooksRequest) returns (stream Book);
}
message Book {
int64 id = 1;
string title = 2;
string author = 3;
google.protobuf.Timestamp published_at = 4;
}
message CreateBookRequest {
string title = 1;
string author = 2;
google.protobuf.Timestamp published_at = 3;
}
message GetBookRequest {
int64 id = 1;
}
message ListBooksRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListBooksResponse {
repeated Book books = 1;
string next_page_token = 2;
}
message UpdateBookRequest {
int64 id = 1;
Book book = 2;
}
message DeleteBookRequest {
int64 id = 1;
}
message WatchBooksRequest {
// Empty for now, could add filters later
}
Generate Go code from the proto file:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/book/v1/book.proto
Implementing the Server
Create the server implementation in internal/server/book.go
:
package server
import (
"context"
"sync"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
pb "grpc-go-demo/proto/book/v1"
)
type BookServer struct {
pb.UnimplementedBookServiceServer
mu sync.RWMutex
books map[int64]*pb.Book
}
func NewBookServer() *BookServer {
return &BookServer{
books: make(map[int64]*pb.Book),
}
}
func (s *BookServer) CreateBook(ctx context.Context, req *pb.CreateBookRequest) (*pb.Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Simple ID generation
id := int64(len(s.books) + 1)
book := &pb.Book{
Id: id,
Title: req.Title,
Author: req.Author,
PublishedAt: req.PublishedAt,
}
s.books[id] = book
return book, nil
}
func (s *BookServer) GetBook(ctx context.Context, req *pb.GetBookRequest) (*pb.Book, error) {
s.mu.RLock()
defer s.mu.RUnlock()
book, exists := s.books[req.Id]
if !exists {
return nil, status.Errorf(codes.NotFound, "book with ID %d not found", req.Id)
}
return book, nil
}
func (s *BookServer) ListBooks(ctx context.Context, req *pb.ListBooksRequest) (*pb.ListBooksResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
resp := &pb.ListBooksResponse{
Books: make([]*pb.Book, 0, len(s.books)),
}
for _, book := range s.books {
resp.Books = append(resp.Books, book)
}
return resp, nil
}
func (s *BookServer) UpdateBook(ctx context.Context, req *pb.UpdateBookRequest) (*pb.Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.books[req.Id]; !exists {
return nil, status.Errorf(codes.NotFound, "book with ID %d not found", req.Id)
}
s.books[req.Id] = req.Book
return req.Book, nil
}
func (s *BookServer) DeleteBook(ctx context.Context, req *pb.DeleteBookRequest) (*emptypb.Empty, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.books[req.Id]; !exists {
return nil, status.Errorf(codes.NotFound, "book with ID %d not found", req.Id)
}
delete(s.books, req.Id)
return &emptypb.Empty{}, nil
}
func (s *BookServer) WatchBooks(req *pb.WatchBooksRequest, stream pb.BookService_WatchBooksServer) error {
// Implement streaming updates
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-stream.Context().Done():
return nil
case <-ticker.C:
s.mu.RLock()
for _, book := range s.books {
if err := stream.Send(book); err != nil {
s.mu.RUnlock()
return err
}
}
s.mu.RUnlock()
}
}
}
Create the server main file in cmd/server/main.go
:
package main
import (
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"grpc-go-demo/internal/server"
pb "grpc-go-demo/proto/book/v1"
)
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterBookServiceServer(s, server.NewBookServer())
// Enable reflection for tools like grpcurl
reflection.Register(s)
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Implementing the Client
Create the client implementation in internal/client/book.go
:
package client
import (
"context"
"io"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "grpc-go-demo/proto/book/v1"
)
type BookClient struct {
client pb.BookServiceClient
}
func NewBookClient(address string) (*BookClient, error) {
conn, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &BookClient{
client: pb.NewBookServiceClient(conn),
}, nil
}
func (c *BookClient) CreateBook(ctx context.Context, title, author string) (*pb.Book, error) {
return c.client.CreateBook(ctx, &pb.CreateBookRequest{
Title: title,
Author: author,
})
}
func (c *BookClient) WatchBooks(ctx context.Context) error {
stream, err := c.client.WatchBooks(ctx, &pb.WatchBooksRequest{})
if err != nil {
return err
}
for {
book, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
log.Printf("Received book update: %v", book)
}
return nil
}
Create the client main file in cmd/client/main.go
:
package main
import (
"context"
"log"
"time"
"grpc-go-demo/internal/client"
)
func main() {
ctx := context.Background()
bookClient, err := client.NewBookClient("localhost:50051")
if err != nil {
log.Fatalf("failed to create client: %v", err)
}
// Create a book
book, err := bookClient.CreateBook(ctx, "The Go Programming Language", "Alan A. A. Donovan")
if err != nil {
log.Fatalf("failed to create book: %v", err)
}
log.Printf("Created book: %v", book)
// Watch for updates
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := bookClient.WatchBooks(ctx); err != nil {
log.Fatalf("failed to watch books: %v", err)
}
}
Adding Middleware (Interceptors)
Create middleware for logging and authentication:
package middleware
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// LoggingInterceptor logs RPC calls
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("method: %s, duration: %s, error: %v",
info.FullMethod,
time.Since(start),
err)
return resp, err
}
// AuthInterceptor checks for valid API key
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
apiKeys := md.Get("api-key")
if len(apiKeys) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing API key")
}
// Validate API key (replace with your validation logic)
if apiKeys[0] != "valid-api-key" {
return nil, status.Error(codes.Unauthenticated, "invalid API key")
}
return handler(ctx, req)
}
Testing
Create tests for the service:
package server
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
pb "grpc-go-demo/proto/book/v1"
)
func TestBookServer_CreateBook(t *testing.T) {
server := NewBookServer()
ctx := context.Background()
publishedAt := timestamppb.New(time.Now())
req := &pb.CreateBookRequest{
Title: "Test Book",
Author: "Test Author",
PublishedAt: publishedAt,
}
book, err := server.CreateBook(ctx, req)
assert.NoError(t, err)
assert.NotNil(t, book)
assert.Equal(t, req.Title, book.Title)
assert.Equal(t, req.Author, book.Author)
assert.Equal(t, publishedAt, book.PublishedAt)
}
Error Handling
Proper error handling with gRPC status codes:
package server
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func validateBook(book *pb.Book) error {
if book.Title == "" {
return status.Error(codes.InvalidArgument, "title is required")
}
if book.Author == "" {
return status.Error(codes.InvalidArgument, "author is required")
}
return nil
}
Deployment
Example Dockerfile:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o server cmd/server/main.go
FROM alpine:latest
COPY --from=builder /app/server /server
EXPOSE 50051
CMD ["/server"]
Best Practices
- Use Deadlines: Always set timeouts for RPC calls
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- Handle Cancellation: Check context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(processTime):
return result, nil
}
- Use Status Codes: Use appropriate gRPC status codes
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
- Implement Health Checks: Add health checking service
"google.golang.org/grpc/health/grpc_health_v1"
- Use Middleware: Implement common concerns as interceptors
grpc.UnaryInterceptor(LoggingInterceptor)
Performance Optimization
- Connection Pooling
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)
Performance Optimization (continued)
- Message Compression
// Server-side compression
s := grpc.NewServer(
grpc.RPCCompressor(grpc.NewGZIPCompressor()),
grpc.RPCDecompressor(grpc.NewGZIPDecompressor()),
)
// Client-side compression
conn, err := grpc.Dial(
address,
grpc.WithCompressor(grpc.NewGZIPCompressor()),
grpc.WithDecompressor(grpc.NewGZIPDecompressor()),
)
- Batch Processing
type BatchBookServer struct {
pb.UnimplementedBookServiceServer
batchSize int
batchChan chan *pb.Book
}
func (s *BatchBookServer) CreateBooks(stream pb.BookService_CreateBooksServer) error {
batch := make([]*pb.Book, 0, s.batchSize)
for {
req, err := stream.Recv()
if err == io.EOF {
// Process remaining batch
return s.processBatch(batch)
}
if err != nil {
return err
}
batch = append(batch, req.Book)
if len(batch) >= s.batchSize {
if err := s.processBatch(batch); err != nil {
return err
}
batch = batch[:0]
}
}
}
Advanced Features
1. Bidirectional Streaming
// In proto file
service BookService {
rpc ChatAboutBooks(stream ChatMessage) returns (stream ChatMessage);
}
// Server implementation
func (s *BookServer) ChatAboutBooks(stream pb.BookService_ChatAboutBooksServer) error {
for {
in, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
response := &pb.ChatMessage{
Message: fmt.Sprintf("Received: %s", in.Message),
}
if err := stream.Send(response); err != nil {
return err
}
}
}
// Client implementation
func ChatWithServer(client pb.BookServiceClient) error {
stream, err := client.ChatAboutBooks(context.Background())
if err != nil {
return err
}
waitc := make(chan struct{})
// Send messages
go func() {
messages := []string{"Hello", "How are you?", "Goodbye"}
for _, msg := range messages {
if err := stream.Send(&pb.ChatMessage{Message: msg}); err != nil {
log.Printf("Failed to send message: %v", err)
return
}
time.Sleep(time.Second)
}
stream.CloseSend()
}()
// Receive messages
go func() {
for {
in, err := stream.Recv()
if err == io.EOF {
close(waitc)
return
}
if err != nil {
log.Printf("Failed to receive message: %v", err)
return
}
log.Printf("Received: %s", in.Message)
}
}()
<-waitc
return nil
}
2. Load Balancing
// Client-side load balancing
func NewLoadBalancedClient(endpoints []string) (*grpc.ClientConn, error) {
return grpc.Dial(
"",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithResolvers(NewCustomResolver(endpoints)),
)
}
// Custom resolver implementation
type customResolver struct {
endpoints []string
target resolver.Target
}
func (r *customResolver) ResolveNow(o resolver.ResolveNowOptions) {
addresses := make([]resolver.Address, len(r.endpoints))
for i, endpoint := range r.endpoints {
addresses[i] = resolver.Address{Addr: endpoint}
}
r.cc.UpdateState(resolver.State{Addresses: addresses})
}
3. Retry Logic
// Client-side retry configuration
func NewClientWithRetry() (*grpc.ClientConn, error) {
return grpc.Dial(
address,
grpc.WithDefaultServiceConfig(`{
"methodConfig": [{
"name": [{"service": "book.v1.BookService"}],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2.0,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}`),
)
}
4. Circuit Breaker
type CircuitBreaker struct {
failureThreshold int
resetTimeout time.Duration
failures int
lastFailure time.Time
mu sync.RWMutex
}
func (cb *CircuitBreaker) Execute(ctx context.Context, fn func(context.Context) error) error {
cb.mu.RLock()
if cb.failures >= cb.failureThreshold {
if time.Since(cb.lastFailure) < cb.resetTimeout {
cb.mu.RUnlock()
return status.Error(codes.Unavailable, "circuit breaker open")
}
cb.mu.RUnlock()
cb.mu.Lock()
cb.failures = 0
cb.mu.Unlock()
} else {
cb.mu.RUnlock()
}
err := fn(ctx)
if err != nil {
cb.mu.Lock()
cb.failures++
cb.lastFailure = time.Now()
cb.mu.Unlock()
}
return err
}
5. Metrics and Monitoring
// Prometheus metrics
func MetricsInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start)
// Record metrics
grpcRequestsTotal.Inc()
grpcRequestDuration.Observe(duration.Seconds())
if err != nil {
grpcRequestErrors.Inc()
}
return resp, err
}
}
var (
grpcRequestsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "grpc_requests_total",
Help: "Total number of gRPC requests handled",
})
grpcRequestDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "grpc_request_duration_seconds",
Help: "Duration of gRPC requests in seconds",
})
grpcRequestErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "grpc_request_errors_total",
Help: "Total number of gRPC request errors",
})
)
6. Context Propagation
// Propagating metadata
func MetadataPropagationInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if ok {
// Propagate specific headers
headers := []string{
"x-request-id",
"x-correlation-id",
"user-agent",
}
outCtx := ctx
for _, h := range headers {
if vals := md.Get(h); len(vals) > 0 {
outCtx = metadata.AppendToOutgoingContext(outCtx, h, vals[0])
}
}
return handler(outCtx, req)
}
return handler(ctx, req)
}
}
7. Rate Limiting
type RateLimiter struct {
tokens chan struct{}
interval time.Duration
}
func NewRateLimiter(rate int, interval time.Duration) *RateLimiter {
rl := &RateLimiter{
tokens: make(chan struct{}, rate),
interval: interval,
}
// Fill token bucket
for i := 0; i < rate; i++ {
rl.tokens <- struct{}{}
}
go rl.refill(rate)
return rl
}
func (rl *RateLimiter) refill(rate int) {
ticker := time.NewTicker(rl.interval)
defer ticker.Stop()
for range ticker.C {
for i := 0; i < rate; i++ {
select {
case rl.tokens <- struct{}{}:
default:
// Bucket is full
}
}
}
}
func (rl *RateLimiter) Allow() bool {
select {
case <-rl.tokens:
return true
default:
return false
}
}
// Usage in interceptor
func RateLimitInterceptor(rl *RateLimiter) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !rl.Allow() {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
return handler(ctx, req)
}
}
These advanced features provide a robust foundation for building production-ready gRPC services. The combination of performance optimizations, monitoring, and reliability patterns ensures your services can handle real-world challenges effectively.
Top comments (0)