Building Robust Microservices in Go with gRPC and Protocol Buffers: A Complete Guide
In modern software development, microservices architecture has become the standard approach for building large-scale, distributed systems. This architectural pattern allows teams to develop, deploy, and scale components independently, improving both development velocity and system resilience. When implementing microservices, the choice of communication protocol and programming language significantly impacts your system’s performance, maintainability, and scalability.
Go (or Golang) has emerged as an excellent language for microservice development due to its simplicity, strong standard library, excellent concurrency support, and efficient memory management. When paired with gRPC and Protocol Buffers, Go enables developers to build high-performance, type-safe distributed systems that can scale efficiently.
This guide provides a comprehensive approach to developing robust microservices using Go, gRPC, and Protocol Buffers, covering everything from basic concepts to advanced deployment strategies.
Understanding the Core Technologies
Before diving into implementation details, let’s understand the key technologies that form the foundation of our microservices architecture.
Go (Golang)
Go is a statically typed, compiled language developed by Google. Its key features make it particularly suitable for microservices:
- Simplicity: Go’s syntax is concise and easy to learn, enhancing code readability and maintainability.
- Concurrency: Go’s goroutines and channels provide a simple yet powerful concurrency model.
- Performance: As a compiled language, Go offers execution speeds comparable to C/C++, with the added benefit of garbage collection.
- Standard Library: Go’s robust standard library reduces the need for external dependencies.
- Cross-Compilation: Go supports building binaries for multiple platforms from a single development environment.
gRPC (gRPC Remote Procedure Calls)
gRPC is a high-performance, open-source RPC (Remote Procedure Call) framework that can run in any environment. Key advantages include:
- HTTP/2 Based: gRPC uses HTTP/2, which supports request multiplexing, header compression, and bidirectional streaming.
- Language Agnostic: gRPC supports multiple programming languages, making it ideal for polyglot microservices environments.
- Streaming Support: gRPC supports bidirectional streaming, allowing real-time communication between services.
- Built-in Load Balancing and Authentication: gRPC has built-in support for load balancing, tracing, health checking, and authentication.
Protocol Buffers (Protobuf)
Protocol Buffers is Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data:
- Efficient Serialization: Protobuf messages are smaller and faster to process than JSON or XML.
- Strongly Typed: Messages are defined in a schema, providing type safety across language boundaries.
- Code Generation: The protoc compiler generates client and server code in multiple languages from .proto files.
- Backward Compatibility: Protobuf is designed to handle schema evolution gracefully.
Setting Up Your Development Environment
Let’s start by setting up the necessary tools and dependencies for developing Go microservices with gRPC.
Installing Go
Download and install Go from the official website. Verify the installation:
go version
Installing Protocol Buffers Compiler
Download and install the Protocol Buffers compiler (protoc) from the GitHub releases page.
Installing Go Plugins for Protobuf and gRPC
Install the required Go plugins for Protocol Buffers and gRPC:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Add the installed binaries to your PATH:
export PATH="$PATH:$(go env GOPATH)/bin"
Project Initialization
Create a new directory for your project and initialize a Go module:
mkdir -p go-microservices/product-service
cd go-microservices/product-service
go mod init github.com/yourusername/product-service
Designing a Microservice Architecture
Let’s design a simple e-commerce system with multiple microservices. We’ll focus on the Product Service as our main example, but we’ll discuss how it interacts with other services.
System Overview
Our e-commerce system consists of the following microservices:
- Product Service: Manages product catalog (our main focus)
- Order Service: Handles order processing
- Inventory Service: Tracks product inventory
- User Service: Manages user accounts and authentication
- Payment Service: Processes payments
Service Communication Patterns
Our microservices will use several communication patterns:
- Synchronous Request/Response: Using gRPC for direct service-to-service communication
- Asynchronous Messaging: Using a message broker (like NATS or Kafka) for event-driven communication
- API Gateway: For external client communication
For this guide, we’ll focus on implementing the Product Service with gRPC and then demonstrate how it communicates with other services.
Defining the Service with Protocol Buffers
Let’s define our Product Service using Protocol Buffers. Create a directory for your proto files:
mkdir -p proto/product
Create a file named proto/product/product.proto:
syntax = "proto3";
package product;
option go_package = "github.com/yourusername/product-service/proto/product";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
// Product represents a product in the catalog
message Product {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
repeated string categories = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// CreateProductRequest is the request for creating a new product
message CreateProductRequest {
string name = 1;
string description = 2;
double price = 3;
repeated string categories = 4;
}
// GetProductRequest is the request for retrieving a product
message GetProductRequest {
string id = 1;
}
// UpdateProductRequest is the request for updating a product
message UpdateProductRequest {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
repeated string categories = 5;
}
// DeleteProductRequest is the request for deleting a product
message DeleteProductRequest {
string id = 1;
}
// ListProductsRequest is the request for listing products
message ListProductsRequest {
int32 page_size = 1;
string page_token = 2;
string filter = 3;
}
// ListProductsResponse is the response for listing products
message ListProductsResponse {
repeated Product products = 1;
string next_page_token = 2;
int32 total_size = 3;
}
// ProductService provides operations on products
service ProductService {
// CreateProduct creates a new product
rpc CreateProduct(CreateProductRequest) returns (Product);
// GetProduct retrieves a product by ID
rpc GetProduct(GetProductRequest) returns (Product);
// UpdateProduct updates an existing product
rpc UpdateProduct(UpdateProductRequest) returns (Product);
// DeleteProduct deletes a product
rpc DeleteProduct(DeleteProductRequest) returns (google.protobuf.Empty);
// ListProducts lists products with pagination
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
}
Generating Go Code from Protobuf
Now, let’s generate the Go code from our Protobuf definition:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/product/product.proto
This command generates two files:
proto/product/product.pb.go: Contains the Go structs for our messagesproto/product/product_grpc.pb.go: Contains the gRPC client and server interfaces
Implementing the Product Service
Now that we have the generated code, let’s implement our Product Service.
Creating the Service Implementation
First, let’s create a directory for our server code:
mkdir -p internal/server
Now, create a file internal/server/product_server.go:
package server
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"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 "github.com/yourusername/product-service/proto/product"
)
// ProductServer implements the ProductService interface
type ProductServer struct {
pb.UnimplementedProductServiceServer
mu sync.RWMutex
products map[string]*pb.Product
}
// NewProductServer creates a new ProductServer
func NewProductServer() *ProductServer {
return &ProductServer{
products: make(map[string]*pb.Product),
}
}
// CreateProduct creates a new product
func (s *ProductServer) CreateProduct(ctx context.Context, req *pb.CreateProductRequest) (*pb.Product, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "product name is required")
}
if req.Price < 0 {
return nil, status.Error(codes.InvalidArgument, "product price cannot be negative")
}
s.mu.Lock()
defer s.mu.Unlock()
// Create a new product
now := timestamppb.New(time.Now())
id := uuid.New().String()
product := &pb.Product{
Id: id,
Name: req.Name,
Description: req.Description,
Price: req.Price,
Categories: req.Categories,
CreatedAt: now,
UpdatedAt: now,
}
// Store the product
s.products[id] = product
return product, nil
}
// GetProduct retrieves a product by ID
func (s *ProductServer) GetProduct(ctx context.Context, req *pb.GetProductRequest) (*pb.Product, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "product ID is required")
}
s.mu.RLock()
defer s.mu.RUnlock()
product, ok := s.products[req.Id]
if !ok {
return nil, status.Errorf(codes.NotFound, "product with ID %s not found", req.Id)
}
return product, nil
}
// UpdateProduct updates an existing product
func (s *ProductServer) UpdateProduct(ctx context.Context, req *pb.UpdateProductRequest) (*pb.Product, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "product ID is required")
}
s.mu.Lock()
defer s.mu.Unlock()
product, ok := s.products[req.Id]
if !ok {
return nil, status.Errorf(codes.NotFound, "product with ID %s not found", req.Id)
}
// Update fields if provided
if req.Name != "" {
product.Name = req.Name
}
if req.Description != "" {
product.Description = req.Description
}
if req.Price > 0 {
product.Price = req.Price
}
if len(req.Categories) > 0 {
product.Categories = req.Categories
}
// Update the updated_at timestamp
product.UpdatedAt = timestamppb.New(time.Now())
return product, nil
}
// DeleteProduct deletes a product
func (s *ProductServer) DeleteProduct(ctx context.Context, req *pb.DeleteProductRequest) (*emptypb.Empty, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "product ID is required")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[req.Id]; !ok {
return nil, status.Errorf(codes.NotFound, "product with ID %s not found", req.Id)
}
delete(s.products, req.Id)
return &emptypb.Empty{}, nil
}
// ListProducts lists products with pagination
func (s *ProductServer) ListProducts(ctx context.Context, req *pb.ListProductsRequest) (*pb.ListProductsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// In a real implementation, you would apply filters and pagination
// This is a simplified version
pageSize := int(req.PageSize)
if pageSize <= 0 {
pageSize = 10 // Default page size
}
var products []*pb.Product
for _, product := range s.products {
products = append(products, product)
if len(products) >= pageSize {
break
}
}
return &pb.ListProductsResponse{
Products: products,
NextPageToken: "", // In a real impl, you would calculate this
TotalSize: int32(len(s.products)),
}, nil
}
Setting Up the gRPC Server
Now, let’s create a file cmd/server/main.go to start our gRPC server:
package main
import (
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"github.com/yourusername/product-service/internal/server"
pb "github.com/yourusername/product-service/proto/product"
)
func main() {
// Determine the port to listen on
port := os.Getenv("PORT")
if port == "" {
port = "50051" // Default port
}
// Create a TCP listener
lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create a new gRPC server
grpcServer := grpc.NewServer()
// Register our service
productServer := server.NewProductServer()
pb.RegisterProductServiceServer(grpcServer, productServer)
// Register reflection service for grpcurl and other tools
reflection.Register(grpcServer)
// Start the server in a goroutine
go func() {
log.Printf("starting gRPC server on port %s", port)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()
// Wait for interrupt signal to gracefully shut down the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down gRPC server...")
grpcServer.GracefulStop()
log.Println("server stopped")
}
Implementing a gRPC Client
Now, let’s create a client to interact with our Product Service. Create a file cmd/client/main.go:
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/yourusername/product-service/proto/product"
)
func main() {
// Set up a connection to the server
serverAddr := os.Getenv("SERVER_ADDR")
if serverAddr == "" {
serverAddr = "localhost:50051" // Default address
}
conn, err := grpc.Dial(serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// Create a client
client := pb.NewProductServiceClient(conn)
// Set a timeout for our API call
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
// Create a product
product, err := client.CreateProduct(ctx, &pb.CreateProductRequest{
Name: "Smartphone XYZ",
Description: "Latest smartphone with amazing features",
Price: 999.99,
Categories: []string{"Electronics", "Smartphones"},
})
if err != nil {
log.Fatalf("could not create product: %v", err)
}
fmt.Printf("Created product: %s\n", product.Id)
// Get the product
getResp, err := client.GetProduct(ctx, &pb.GetProductRequest{
Id: product.Id,
})
if err != nil {
log.Fatalf("could not get product: %v", err)
}
fmt.Printf("Retrieved product: %s - %s ($%.2f)\n", getResp.Id, getResp.Name, getResp.Price)
// Update the product
updateResp, err := client.UpdateProduct(ctx, &pb.UpdateProductRequest{
Id: product.Id,
Price: 899.99, // Reduced price
})
if err != nil {
log.Fatalf("could not update product: %v", err)
}
fmt.Printf("Updated product price to: $%.2f\n", updateResp.Price)
// List products
listResp, err := client.ListProducts(ctx, &pb.ListProductsRequest{
PageSize: 10,
})
if err != nil {
log.Fatalf("could not list products: %v", err)
}
fmt.Printf("Listed %d products out of %d total\n", len(listResp.Products), listResp.TotalSize)
// Delete the product
_, err = client.DeleteProduct(ctx, &pb.DeleteProductRequest{
Id: product.Id,
})
if err != nil {
log.Fatalf("could not delete product: %v", err)
}
fmt.Printf("Deleted product: %s\n", product.Id)
}
Building and Running the Service
Let’s build and run our service:
# Create the necessary directories
mkdir -p cmd/server cmd/client
# Build the server
go build -o bin/product-server cmd/server/main.go
# Build the client
go build -o bin/product-client cmd/client/main.go
# Run the server
./bin/product-server
# In another terminal, run the client
./bin/product-client
Advanced Features and Best Practices
Now that we have a basic microservice working, let’s explore more advanced features and best practices.
Middleware and Interceptors
gRPC supports interceptors, which are similar to middleware in HTTP servers. You can use them for logging, authentication, rate limiting, and more.
Create a file internal/middleware/interceptors.go:
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 information about each gRPC call
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
// Extract metadata
md, _ := metadata.FromIncomingContext(ctx)
// Process the request
resp, err := handler(ctx, req)
// Log details about the call
log.Printf(
"Method: %s, Duration: %s, Error: %v, Metadata: %v",
info.FullMethod,
time.Since(start),
err,
md,
)
return resp, err
}
// AuthInterceptor checks for valid authentication
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Extract metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "metadata is not provided")
}
// Check for authentication token
values := md.Get("authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "authorization token is not provided")
}
// In a real implementation, you would validate the token
token := values[0]
if token != "valid-token" {
return nil, status.Error(codes.Unauthenticated, "invalid authorization token")
}
// Call the handler
return handler(ctx, req)
}
// RecoveryInterceptor catches panics and converts them to gRPC errors
func RecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in gRPC method %s: %v", info.FullMethod, r)
status.Error(codes.Internal, "Internal server error")
}
}()
return handler(ctx, req)
}
Update cmd/server/main.go to use these interceptors:
// Create a new gRPC server with interceptors
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(
grpc.ChainUnaryInterceptor(
middleware.RecoveryInterceptor,
middleware.LoggingInterceptor,
middleware.AuthInterceptor,
),
),
)
Service Discovery and Load Balancing
In a microservices architecture, service discovery and load balancing are crucial. Let’s integrate with a common service registry, etcd.
First, add the required dependencies:
go get go.etcd.io/etcd/client/v3
Create a file internal/discovery/etcd.go:
package discovery
import (
"context"
"fmt"
"log"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
// ServiceRegistry handles service registration and discovery
type ServiceRegistry struct {
client *clientv3.Client
leaseID clientv3.LeaseID
cancelFunc context.CancelFunc
serviceKey string
serviceVal string
ttl int64
}
// NewServiceRegistry creates a new service registry
func NewServiceRegistry(endpoints []string, serviceName, serviceAddr string, ttl int64) (*ServiceRegistry, error) {
client, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
return &ServiceRegistry{
client: client,
serviceKey: fmt.Sprintf("/services/%s/%s", serviceName, serviceAddr),
serviceVal: serviceAddr,
ttl: ttl,
}, nil
}
// Register registers the service with etcd
func (sr *ServiceRegistry) Register() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Create a lease
resp, err := sr.client.Grant(ctx, sr.ttl)
if err != nil {
return err
}
sr.leaseID = resp.ID
// Register the service with lease
_, err = sr.client.Put(
ctx,
sr.serviceKey,
sr.serviceVal,
clientv3.WithLease(sr.leaseID),
)
if err != nil {
return err
}
// Keep the lease alive
keepAliveCh, err := sr.client.KeepAlive(context.Background(), sr.leaseID)
if err != nil {
return err
}
// Set up a goroutine to watch the keep-alive channel
ctx, cancelFunc := context.WithCancel(context.Background())
sr.cancelFunc = cancelFunc
go func() {
for {
select {
case <-ctx.Done():
return
case resp, ok := <-keepAliveCh:
if !ok {
log.Println("Keep alive channel closed, re-registering service")
if err := sr.Register(); err != nil {
log.Printf("Failed to re-register service: %v", err)
}
return
}
log.Printf("Received keep-alive response: %v", resp)
}
}
}()
return nil
}
// Unregister removes the service from etcd
func (sr *ServiceRegistry) Unregister() error {
if sr.cancelFunc != nil {
sr.cancelFunc()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Revoke the lease
_, err := sr.client.Revoke(ctx, sr.leaseID)
if err != nil {
return err
}
// Close the client
return sr.client.Close()
}
// GetService returns the address of a service by name
func GetService(endpoints []string, serviceName string) ([]string, error) {
client, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Get all instances of the service
resp, err := client.Get(ctx, fmt.Sprintf("/services/%s/", serviceName), clientv3.WithPrefix())
if err != nil {
return nil, err
}
var addresses []string
for _, kv := range resp.Kvs {
addresses = append(addresses, string(kv.Value))
}
return addresses, nil
}
Update cmd/server/main.go to register the service:
// Register the service with etcd
etcdEndpoints := []string{"localhost:2379"} // Update with your etcd endpoints
registry, err := discovery.NewServiceRegistry(etcdEndpoints, "product-service", fmt.Sprintf("localhost:%s", port), 60)
if err != nil {
log.Fatalf("failed to create service registry: %v", err)
}
if err := registry.Register(); err != nil {
log.Fatalf("failed to register service: %v", err)
}
defer registry.Unregister()
Update cmd/client/main.go to discover services:
// Discover service addresses
etcdEndpoints := []string{"localhost:2379"} // Update with your etcd endpoints
addresses, err := discovery.GetService(etcdEndpoints, "product-service")
if err != nil {
log.Fatalf("failed to discover services: %v", err)
}
// Use a simple round-robin load balancer
serverAddr := addresses[0] // For simplicity, just use the first one
if len(addresses) > 1 {
serverAddr = addresses[time.Now().UnixNano()%int64(len(addresses))]
}
Authentication and Authorization
For authentication, we can use JWT (JSON Web Tokens) with gRPC. First, add the required dependency:
go get github.com/golang-jwt/jwt/v5
Create a file internal/auth/jwt.go:
package auth
import (
"context"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc/metadata"
)
// Claims represents the JWT claims
type Claims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateToken generates a new JWT token
func GenerateToken(userID, role, secret string, expirationTime time.Duration) (string, error) {
// Create the JWT claims
claims := &Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "product-service",
Subject: userID,
ID: "",
Audience: []string{"client"},
},
}
// Create a new token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign the token with the secret
return token.SignedString([]byte(secret))
}
// ValidateToken validates a JWT token
func ValidateToken(tokenString, secret string) (*Claims, error) {
// Parse the token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
// Validate the token
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// ExtractTokenFromContext extracts the JWT token from the gRPC context
func ExtractTokenFromContext(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", fmt.Errorf("metadata is not provided")
}
values := md.Get("authorization")
if len(values) == 0 {
return "", fmt.Errorf("authorization token is not provided")
}
// The token is usually in the format "Bearer <token>"
token := values[0]
if len(token) > 7 && token[:7] == "Bearer " {
token = token[7:]
}
return token, nil
}
Update the Auth interceptor to use JWT:
// AuthInterceptor checks for valid JWT tokens
func AuthInterceptor(jwtSecret string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Skip authentication for certain methods
if info.FullMethod == "/product.ProductService/GetProduct" ||
info.FullMethod == "/product.ProductService/ListProducts" {
return handler(ctx, req)
}
// Extract the token
token, err := auth.ExtractTokenFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}
// Validate the token
claims, err := auth.ValidateToken(token, jwtSecret)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
// Check role-based permissions
if info.FullMethod == "/product.ProductService/DeleteProduct" && claims.Role != "admin" {
return nil, status.Error(codes.PermissionDenied, "admin role required")
}
// Add claims to the context
newCtx := context.WithValue(ctx, "claims", claims)
// Call the handler
return handler(newCtx, req)
}
}
Testing Microservices
Testing is crucial for microservices. Let’s create a file internal/server/product_server_test.go to test our service:
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/yourusername/product-service/proto/product"
)
func TestCreateProduct(t *testing.T) {
server := NewProductServer()
ctx := context.Background()
tests := []struct {
name string
req *pb.CreateProductRequest
wantErr bool
errCode codes.Code
}{
{
name: "valid product",
req: &pb.CreateProductRequest{
Name: "Test Product",
Description: "A test product",
Price: 99.99,
Categories: []string{"Test"},
},
wantErr: false,
},
{
name: "empty name",
req: &pb.CreateProductRequest{
Name: "",
Description: "A test product",
Price: 99.99,
},
wantErr: true,
errCode: codes.InvalidArgument,
},
{
name: "negative price",
req: &pb.CreateProductRequest{
Name: "Test Product",
Description: "A test product",
Price: -10.0,
},
wantErr: true,
errCode: codes.InvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := server.CreateProduct(ctx, tt.req)
if tt.wantErr {
assert.Error(t, err)
st, ok := status.FromError(err)
assert.True(t, ok)
assert.Equal(t, tt.errCode, st.Code())
} else {
assert.NoError(t, err)
assert.NotEmpty(t, resp.Id)
assert.Equal(t, tt.req.Name, resp.Name)
assert.Equal(t, tt.req.Description, resp.Description)
assert.Equal(t, tt.req.Price, resp.Price)
assert.Equal(t, tt.req.Categories, resp.Categories)
assert.NotNil(t, resp.CreatedAt)
assert.NotNil(t, resp.UpdatedAt)
}
})
}
}
func TestGetProduct(t *testing.T) {
server := NewProductServer()
ctx := context.Background()
// Create a product first
product, err := server.CreateProduct(ctx, &pb.CreateProductRequest{
Name: "Test Product",
Description: "A test product",
Price: 99.99,
Categories: []string{"Test"},
})
assert.NoError(t, err)
tests := []struct {
name string
req *pb.GetProductRequest
wantErr bool
errCode codes.Code
}{
{
name: "existing product",
req: &pb.GetProductRequest{
Id: product.Id,
},
wantErr: false,
},
{
name: "non-existing product",
req: &pb.GetProductRequest{
Id: "non-existing-id",
},
wantErr: true,
errCode: codes.NotFound,
},
{
name: "empty ID",
req: &pb.GetProductRequest{
Id: "",
},
wantErr: true,
errCode: codes.InvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := server.GetProduct(ctx, tt.req)
if tt.wantErr {
assert.Error(t, err)
st, ok := status.FromError(err)
assert.True(t, ok)
assert.Equal(t, tt.errCode, st.Code())
} else {
assert.NoError(t, err)
assert.Equal(t, product.Id, resp.Id)
assert.Equal(t, product.Name, resp.Name)
assert.Equal(t, product.Description, resp.Description)
assert.Equal(t, product.Price, resp.Price)
assert.Equal(t, product.Categories, resp.Categories)
}
})
}
}
Deploying to Kubernetes
To deploy our microservice to Kubernetes, let’s create a Dockerfile:
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o product-server ./cmd/server
# Use a small Alpine image for the final container
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from the builder stage
COPY --from=builder /app/product-server .
# Expose the port
EXPOSE 50051
# Command to run
CMD ["./product-server"]
Create Kubernetes manifests in a k8s directory:
mkdir -p k8s
Create k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: product-service
labels:
app: product-service
spec:
replicas: 3
selector:
matchLabels:
app: product-service
template:
metadata:
labels:
app: product-service
spec:
containers:
- name: product-service
image: yourregistry/product-service:latest
ports:
- containerPort: 50051
env:
- name: PORT
value: "50051"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
exec:
command: ["/bin/grpc_health_probe", "-addr=:50051"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["/bin/grpc_health_probe", "-addr=:50051"]
initialDelaySeconds: 10
periodSeconds: 30
Create k8s/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: product-service
spec:
selector:
app: product-service
ports:
- port: 50051
targetPort: 50051
type: ClusterIP
Conclusion: Best Practices for Go Microservices
Let’s conclude with a summary of best practices for building Go microservices with gRPC:
Define Clear Service Boundaries: Each microservice should have a well-defined responsibility and domain.
Use Protocol Buffers for Contracts: Protobuf provides strong typing and versioning for your API contracts.
Implement Proper Error Handling: Use gRPC status codes and provide meaningful error messages.
Add Middleware/Interceptors: Use interceptors for cross-cutting concerns like logging, authentication, and metrics.
Implement Health Checks: Add health check endpoints for Kubernetes and service discovery.
Add Observability: Implement logging, metrics, and tracing for better observability.
Test Thoroughly: Write unit, integration, and end-to-end tests for your services.
Use Service Discovery: Implement service discovery for dynamic service-to-service communication.
Implement Circuit Breaking: Add circuit breakers to prevent cascading failures.
Secure Your Services: Implement authentication, authorization, and encryption.
Document Your API: Use tools like Swagger or gRPC reflection for API documentation.
Containerize and Orchestrate: Use Docker and Kubernetes for deployment and scaling.
By following these best practices, you’ll be well on your way to building robust, scalable microservices in Go with gRPC and Protocol Buffers.