Server-Side Discovery Pattern

In server-side discovery, clients make requests to a router or load balancer, which:

  1. Queries the service registry
  2. Selects a service instance
  3. Routes the request to the selected instance

This pattern simplifies client code but requires an additional infrastructure component.

Here’s an implementation of a server-side discovery router in Go:

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
    "sync"
    "time"
)

// ServiceRegistry from previous example...

// DiscoveryRouter routes requests to services using service discovery
type DiscoveryRouter struct {
    registry *ServiceRegistry
    proxies  map[string][]*httputil.ReverseProxy
    mutex    sync.RWMutex
}

// NewDiscoveryRouter creates a new discovery router
func NewDiscoveryRouter(registry *ServiceRegistry) *DiscoveryRouter {
    return &DiscoveryRouter{
        registry: registry,
        proxies:  make(map[string][]*httputil.ReverseProxy),
    }
}

// updateProxies updates the reverse proxies for a service
func (dr *DiscoveryRouter) updateProxies(serviceName string) error {
    instances, exists := dr.registry.GetService(serviceName)
    if !exists || len(instances) == 0 {
        return fmt.Errorf("no instances available for service: %s", serviceName)
    }
    
    var proxies []*httputil.ReverseProxy
    
    for _, instance := range instances {
        if instance.Status != "UP" {
            continue
        }
        
        target, err := url.Parse(fmt.Sprintf("http://%s:%d", instance.Address, instance.Port))
        if err != nil {
            log.Printf("Error parsing URL for instance %s: %v", instance.ID, err)
            continue
        }
        
        proxy := httputil.NewSingleHostReverseProxy(target)
        
        // Add custom error handler
        proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
            log.Printf("Proxy error: %v", err)
            w.WriteHeader(http.StatusBadGateway)
            w.Write([]byte("Service unavailable"))
        }
        
        proxies = append(proxies, proxy)
    }
    
    if len(proxies) == 0 {
        return fmt.Errorf("no healthy instances available for service: %s", serviceName)
    }
    
    dr.mutex.Lock()
    dr.proxies[serviceName] = proxies
    dr.mutex.Unlock()
    
    return nil
}

// ServeHTTP implements the http.Handler interface
func (dr *DiscoveryRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Extract service name from the request path
    // In a real implementation, you'd use a more sophisticated routing mechanism
    serviceName := extractServiceName(r.URL.Path)
    
    dr.mutex.RLock()
    proxies, exists := dr.proxies[serviceName]
    dr.mutex.RUnlock()
    
    // If no proxies exist or they need to be refreshed
    if !exists || len(proxies) == 0 {
        err := dr.updateProxies(serviceName)
        if err != nil {
            log.Printf("Error updating proxies: %v", err)
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte("Service unavailable"))
            return
        }
        
        dr.mutex.RLock()
        proxies = dr.proxies[serviceName]
        dr.mutex.RUnlock()
    }
    
    // Simple round-robin selection
    proxy := proxies[time.Now().UnixNano()%int64(len(proxies))]
    
    // Forward the request
    proxy.ServeHTTP(w, r)
}

// extractServiceName extracts the service name from the request path
// In a real implementation, you'd use a more sophisticated routing mechanism
func extractServiceName(path string) string {
    // This is a simplified example
    // In practice, you might use a routing table or path-based convention
    if len(path) > 1 && path[0] == '/' {
        parts := strings.Split(path[1:], "/")
        if len(parts) > 0 {
            return parts[0]
        }
    }
    return "default"
}

func main() {
    // Create a service registry
    registry := NewServiceRegistry()
    
    // Register some service instances
    registry.Register(ServiceInstance{
        ID:      "payment-service-1",
        Name:    "payment",
        Address: "10.0.0.1",
        Port:    8080,
        Status:  "UP",
    })
    
    registry.Register(ServiceInstance{
        ID:      "payment-service-2",
        Name:    "payment",
        Address: "10.0.0.2",
        Port:    8080,
        Status:  "UP",
    })
    
    registry.Register(ServiceInstance{
        ID:      "order-service-1",
        Name:    "order",
        Address: "10.0.0.3",
        Port:    8080,
        Status:  "UP",
    })
    
    // Create a discovery router
    router := NewDiscoveryRouter(registry)
    
    // Start the router
    log.Println("Starting discovery router on :8000")
    log.Fatal(http.ListenAndServe(":8000", router))
}

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

  1. A router that intercepts client requests
  2. Integration with the service registry to discover service instances
  3. Dynamic proxy creation to route requests to the appropriate service
  4. Load balancing across multiple instances of the same service

Comparing the Approaches

Both client-side and server-side discovery have advantages and disadvantages:

Client-Side Discovery:

  • Advantages:
    • Fewer network hops (direct client-to-service communication)
    • More control over instance selection and load balancing
    • No single point of failure in the request path
  • Disadvantages:
    • More complex client code
    • Registry client library needed for each language/framework
    • Clients need to implement service selection logic

Server-Side Discovery:

  • Advantages:
    • Simpler client code
    • Clients don’t need to be aware of the discovery mechanism
    • Centralized load balancing and routing policies
  • Disadvantages:
    • Additional network hop
    • Router can become a bottleneck or single point of failure
    • More complex infrastructure

The choice between these patterns depends on your specific requirements, but many production systems use a combination of both approaches.