Client-Side vs Server-Side Discovery

The two primary architectural patterns for service discovery are client-side and server-side discovery. Let’s examine each approach and implement examples in Go.

Client-Side Discovery Pattern

In client-side discovery, the client is responsible for:

  1. Querying the service registry
  2. Selecting a service instance (often with load balancing logic)
  3. Making the request directly to the selected instance

This pattern gives clients more control but also places more responsibility on them.

Here’s an implementation of a client-side discovery pattern in Go:

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)

// ServiceRegistry maintains a registry of available service instances
type ServiceRegistry struct {
    services map[string][]ServiceInstance
    mutex    sync.RWMutex
}

// ServiceInstance represents a single instance of a service
type ServiceInstance struct {
    ID        string            `json:"id"`
    Name      string            `json:"name"`
    Address   string            `json:"address"`
    Port      int               `json:"port"`
    Metadata  map[string]string `json:"metadata,omitempty"`
    Status    string            `json:"status"`
    LastSeen  time.Time         `json:"lastSeen"`
}

// NewServiceRegistry creates a new service registry
func NewServiceRegistry() *ServiceRegistry {
    return &ServiceRegistry{
        services: make(map[string][]ServiceInstance),
    }
}

// Register adds a service instance to the registry
func (sr *ServiceRegistry) Register(instance ServiceInstance) {
    sr.mutex.Lock()
    defer sr.mutex.Unlock()
    
    instance.LastSeen = time.Now()
    
    // Check if service exists and update if it does
    instances, exists := sr.services[instance.Name]
    if !exists {
        sr.services[instance.Name] = []ServiceInstance{instance}
        return
    }
    
    // Check if instance already exists
    for i, existing := range instances {
        if existing.ID == instance.ID {
            instances[i] = instance
            sr.services[instance.Name] = instances
            return
        }
    }
    
    // Add new instance
    sr.services[instance.Name] = append(instances, instance)
}

// Deregister removes a service instance from the registry
func (sr *ServiceRegistry) Deregister(name, id string) bool {
    sr.mutex.Lock()
    defer sr.mutex.Unlock()
    
    instances, exists := sr.services[name]
    if !exists {
        return false
    }
    
    for i, instance := range instances {
        if instance.ID == id {
            // Remove instance by replacing it with the last element and truncating
            instances[i] = instances[len(instances)-1]
            sr.services[name] = instances[:len(instances)-1]
            
            // If no instances left, remove the service
            if len(sr.services[name]) == 0 {
                delete(sr.services, name)
            }
            return true
        }
    }
    
    return false
}

// GetService returns all instances of a specific service
func (sr *ServiceRegistry) GetService(name string) ([]ServiceInstance, bool) {
    sr.mutex.RLock()
    defer sr.mutex.RUnlock()
    
    instances, exists := sr.services[name]
    return instances, exists
}

// ServiceClient is a client that uses service discovery
type ServiceClient struct {
    registry *ServiceRegistry
    client   *http.Client
}

// NewServiceClient creates a new service client
func NewServiceClient(registry *ServiceRegistry) *ServiceClient {
    return &ServiceClient{
        registry: registry,
        client:   &http.Client{Timeout: 10 * time.Second},
    }
}

// CallService makes a request to a service using service discovery
func (sc *ServiceClient) CallService(serviceName, path string) ([]byte, error) {
    // Get service instances from registry
    instances, exists := sc.registry.GetService(serviceName)
    if !exists || len(instances) == 0 {
        return nil, fmt.Errorf("no instances available for service: %s", serviceName)
    }
    
    // Simple round-robin selection (in production, use more sophisticated load balancing)
    // In a real implementation, you'd track which instance was last used
    instance := instances[time.Now().UnixNano()%int64(len(instances))]
    
    // Build the URL and make the request
    url := fmt.Sprintf("http://%s:%d%s", instance.Address, instance.Port, path)
    resp, err := sc.client.Get(url)
    if err != nil {
        return nil, fmt.Errorf("error calling service %s: %w", serviceName, err)
    }
    defer resp.Body.Close()
    
    // Check response status
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("service %s returned status: %d", serviceName, resp.StatusCode)
    }
    
    // Read and return response body
    var body []byte
    _, err = resp.Body.Read(body)
    if err != nil {
        return nil, fmt.Errorf("error reading response: %w", err)
    }
    
    return body, nil
}

func main() {
    // Create a service registry
    registry := NewServiceRegistry()
    
    // Register some service instances
    registry.Register(ServiceInstance{
        ID:      "payment-service-1",
        Name:    "payment-service",
        Address: "10.0.0.1",
        Port:    8080,
        Status:  "UP",
    })
    
    registry.Register(ServiceInstance{
        ID:      "payment-service-2",
        Name:    "payment-service",
        Address: "10.0.0.2",
        Port:    8080,
        Status:  "UP",
    })
    
    // Create a client that uses service discovery
    client := NewServiceClient(registry)
    
    // Make a request to the payment service
    response, err := client.CallService("payment-service", "/api/process-payment")
    if err != nil {
        log.Fatalf("Error calling payment service: %v", err)
    }
    
    fmt.Printf("Response from payment service: %s\n", response)
}

This implementation demonstrates the core components of client-side discovery:

  1. A service registry that maintains information about available services
  2. A client that queries the registry to discover service instances
  3. Logic to select an appropriate instance (simple round-robin in this example)
  4. Direct communication between the client and the selected service instance