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:
- Querying the service registry
- Selecting a service instance (often with load balancing logic)
- 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:
- A service registry that maintains information about available services
- A client that queries the registry to discover service instances
- Logic to select an appropriate instance (simple round-robin in this example)
- Direct communication between the client and the selected service instance