DEV Community

Indal Kumar
Indal Kumar

Posted on

Building Modern Microservices with gRPC in Go: A Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

Project structure:

.
├── proto/
│   └── book/
│       └── v1/
│           └── book.proto
├── internal/
│   ├── server/
│   │   └── book.go
│   └── client/
│       └── book.go
├── cmd/
│   ├── server/
│   │   └── main.go
│   └── client/
│       └── main.go
└── go.mod
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Use Deadlines: Always set timeouts for RPC calls
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Enter fullscreen mode Exit fullscreen mode
  1. Handle Cancellation: Check context cancellation
select {
case <-ctx.Done():
    return nil, ctx.Err()
case <-time.After(processTime):
    return result, nil
}
Enter fullscreen mode Exit fullscreen mode
  1. Use Status Codes: Use appropriate gRPC status codes
if err != nil {
    return nil, status.Error(codes.Internal, "internal error")
}
Enter fullscreen mode Exit fullscreen mode
  1. Implement Health Checks: Add health checking service
"google.golang.org/grpc/health/grpc_health_v1"
Enter fullscreen mode Exit fullscreen mode
  1. Use Middleware: Implement common concerns as interceptors
grpc.UnaryInterceptor(LoggingInterceptor)
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

  1. Connection Pooling
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)
Enter fullscreen mode Exit fullscreen mode

Performance Optimization (continued)

  1. 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()),
)
Enter fullscreen mode Exit fullscreen mode
  1. 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]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

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"]
                }
            }]
        }`),
    )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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",
    })
)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)