560 lines
17 KiB
Markdown
560 lines
17 KiB
Markdown
# Service Discovery - Reference Examples
|
|
|
|
## Complete Implementation Examples
|
|
|
|
### 1. Complete Consul Integration
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Complete Consul service discovery setup.
|
|
/// VI: Cài đặt Consul service discovery hoàn chỉnh.
|
|
/// </summary>
|
|
|
|
// Extensions/ConsulExtensions.cs
|
|
public static class ConsulExtensions
|
|
{
|
|
public static IServiceCollection AddConsulDiscovery(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
var consulConfig = configuration.GetSection("Consul").Get<ConsulConfig>()!;
|
|
|
|
// EN: Register Consul client
|
|
services.AddSingleton<IConsulClient>(new ConsulClient(cfg =>
|
|
{
|
|
cfg.Address = new Uri(consulConfig.Address);
|
|
if (!string.IsNullOrEmpty(consulConfig.Token))
|
|
{
|
|
cfg.Token = consulConfig.Token;
|
|
}
|
|
}));
|
|
|
|
// EN: Register service discovery
|
|
services.AddSingleton<IServiceDiscovery, ConsulServiceDiscovery>();
|
|
|
|
// EN: Register background service for registration
|
|
services.AddHostedService<ConsulRegistrationHostedService>();
|
|
|
|
return services;
|
|
}
|
|
|
|
public static IHttpClientBuilder AddServiceDiscovery(
|
|
this IHttpClientBuilder builder,
|
|
string serviceName)
|
|
{
|
|
builder.AddHttpMessageHandler(sp =>
|
|
{
|
|
var discovery = sp.GetRequiredService<IServiceDiscovery>();
|
|
return new ServiceDiscoveryDelegatingHandler(discovery, serviceName);
|
|
});
|
|
|
|
return builder;
|
|
}
|
|
}
|
|
|
|
public class ConsulConfig
|
|
{
|
|
public string Address { get; set; } = "http://localhost:8500";
|
|
public string? Token { get; set; }
|
|
public string ServiceName { get; set; } = default!;
|
|
public string ServiceHost { get; set; } = default!;
|
|
public int ServicePort { get; set; }
|
|
public string[] Tags { get; set; } = Array.Empty<string>();
|
|
}
|
|
```
|
|
|
|
### 2. Service Discovery Interface and Implementation
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Service discovery abstraction.
|
|
/// VI: Abstraction cho service discovery.
|
|
/// </summary>
|
|
public interface IServiceDiscovery
|
|
{
|
|
Task<ServiceInstance?> GetServiceAsync(string serviceName, CancellationToken ct = default);
|
|
Task<IReadOnlyList<ServiceInstance>> GetServicesAsync(string serviceName, CancellationToken ct = default);
|
|
Task RegisterAsync(ServiceRegistration registration, CancellationToken ct = default);
|
|
Task DeregisterAsync(string serviceId, CancellationToken ct = default);
|
|
}
|
|
|
|
public record ServiceInstance(
|
|
string Id,
|
|
string Name,
|
|
string Host,
|
|
int Port,
|
|
IReadOnlyDictionary<string, string> Metadata)
|
|
{
|
|
public Uri Uri => new($"http://{Host}:{Port}");
|
|
}
|
|
|
|
public record ServiceRegistration(
|
|
string Name,
|
|
string Host,
|
|
int Port,
|
|
string[] Tags,
|
|
TimeSpan HealthCheckInterval);
|
|
|
|
/// <summary>
|
|
/// EN: Consul implementation of service discovery.
|
|
/// VI: Triển khai Consul cho service discovery.
|
|
/// </summary>
|
|
public class ConsulServiceDiscovery : IServiceDiscovery
|
|
{
|
|
private readonly IConsulClient _consulClient;
|
|
private readonly ILogger<ConsulServiceDiscovery> _logger;
|
|
private readonly ConcurrentDictionary<string, DateTime> _lastCacheTime = new();
|
|
private readonly ConcurrentDictionary<string, IReadOnlyList<ServiceInstance>> _cache = new();
|
|
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(10);
|
|
|
|
public ConsulServiceDiscovery(
|
|
IConsulClient consulClient,
|
|
ILogger<ConsulServiceDiscovery> logger)
|
|
{
|
|
_consulClient = consulClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<ServiceInstance?> GetServiceAsync(
|
|
string serviceName,
|
|
CancellationToken ct = default)
|
|
{
|
|
var services = await GetServicesAsync(serviceName, ct);
|
|
|
|
if (services.Count == 0)
|
|
return null;
|
|
|
|
// EN: Random selection for load balancing
|
|
// VI: Chọn ngẫu nhiên để load balancing
|
|
return services[Random.Shared.Next(services.Count)];
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ServiceInstance>> GetServicesAsync(
|
|
string serviceName,
|
|
CancellationToken ct = default)
|
|
{
|
|
// EN: Check cache
|
|
if (_lastCacheTime.TryGetValue(serviceName, out var lastTime) &&
|
|
DateTime.UtcNow - lastTime < _cacheDuration &&
|
|
_cache.TryGetValue(serviceName, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
// EN: Query Consul for healthy services
|
|
var result = await _consulClient.Health.Service(
|
|
serviceName,
|
|
tag: null,
|
|
passingOnly: true,
|
|
ct);
|
|
|
|
var instances = result.Response
|
|
.Select(s => new ServiceInstance(
|
|
s.Service.ID,
|
|
s.Service.Service,
|
|
s.Service.Address,
|
|
s.Service.Port,
|
|
s.Service.Meta ?? new Dictionary<string, string>()))
|
|
.ToList();
|
|
|
|
// EN: Update cache
|
|
_cache[serviceName] = instances;
|
|
_lastCacheTime[serviceName] = DateTime.UtcNow;
|
|
|
|
_logger.LogDebug(
|
|
"Discovered {Count} instances for {ServiceName}",
|
|
instances.Count, serviceName);
|
|
|
|
return instances;
|
|
}
|
|
|
|
public async Task RegisterAsync(
|
|
ServiceRegistration registration,
|
|
CancellationToken ct = default)
|
|
{
|
|
var serviceId = $"{registration.Name}-{Guid.NewGuid():N}";
|
|
|
|
var consulRegistration = new AgentServiceRegistration
|
|
{
|
|
ID = serviceId,
|
|
Name = registration.Name,
|
|
Address = registration.Host,
|
|
Port = registration.Port,
|
|
Tags = registration.Tags,
|
|
Check = new AgentServiceCheck
|
|
{
|
|
HTTP = $"http://{registration.Host}:{registration.Port}/health",
|
|
Interval = registration.HealthCheckInterval,
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
DeregisterCriticalServiceAfter = TimeSpan.FromMinutes(1)
|
|
}
|
|
};
|
|
|
|
await _consulClient.Agent.ServiceRegister(consulRegistration, ct);
|
|
|
|
_logger.LogInformation(
|
|
"Registered service {ServiceName} as {ServiceId}",
|
|
registration.Name, serviceId);
|
|
}
|
|
|
|
public async Task DeregisterAsync(string serviceId, CancellationToken ct = default)
|
|
{
|
|
await _consulClient.Agent.ServiceDeregister(serviceId, ct);
|
|
_logger.LogInformation("Deregistered service {ServiceId}", serviceId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Service Discovery HTTP Handler
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: HTTP message handler that resolves service URIs via discovery.
|
|
/// VI: HTTP message handler giải quyết URIs qua discovery.
|
|
/// </summary>
|
|
public class ServiceDiscoveryDelegatingHandler : DelegatingHandler
|
|
{
|
|
private readonly IServiceDiscovery _discovery;
|
|
private readonly string _serviceName;
|
|
private readonly ILogger<ServiceDiscoveryDelegatingHandler> _logger;
|
|
|
|
public ServiceDiscoveryDelegatingHandler(
|
|
IServiceDiscovery discovery,
|
|
string serviceName)
|
|
{
|
|
_discovery = discovery;
|
|
_serviceName = serviceName;
|
|
_logger = LoggerFactory.Create(b => b.AddConsole())
|
|
.CreateLogger<ServiceDiscoveryDelegatingHandler>();
|
|
}
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken ct)
|
|
{
|
|
var services = await _discovery.GetServicesAsync(_serviceName, ct);
|
|
|
|
if (services.Count == 0)
|
|
{
|
|
throw new ServiceNotFoundException(_serviceName);
|
|
}
|
|
|
|
// EN: Try each healthy instance
|
|
// VI: Thử từng instance healthy
|
|
var shuffled = services.OrderBy(_ => Random.Shared.Next()).ToList();
|
|
Exception? lastException = null;
|
|
|
|
foreach (var service in shuffled)
|
|
{
|
|
try
|
|
{
|
|
// EN: Clone request for retry
|
|
using var clonedRequest = await CloneRequestAsync(request);
|
|
|
|
// EN: Update request URI
|
|
var builder = new UriBuilder(clonedRequest.RequestUri!)
|
|
{
|
|
Scheme = service.Uri.Scheme,
|
|
Host = service.Uri.Host,
|
|
Port = service.Uri.Port
|
|
};
|
|
clonedRequest.RequestUri = builder.Uri;
|
|
|
|
_logger.LogDebug(
|
|
"Sending request to {Uri}",
|
|
clonedRequest.RequestUri);
|
|
|
|
return await base.SendAsync(clonedRequest, ct);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Request to {Service} failed, trying next instance",
|
|
service.Uri);
|
|
lastException = ex;
|
|
}
|
|
}
|
|
|
|
throw new ServiceUnavailableException(
|
|
_serviceName,
|
|
"All service instances failed",
|
|
lastException);
|
|
}
|
|
|
|
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
|
HttpRequestMessage request)
|
|
{
|
|
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
|
|
|
foreach (var header in request.Headers)
|
|
{
|
|
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
|
|
if (request.Content != null)
|
|
{
|
|
var content = await request.Content.ReadAsByteArrayAsync();
|
|
clone.Content = new ByteArrayContent(content);
|
|
|
|
foreach (var header in request.Content.Headers)
|
|
{
|
|
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
}
|
|
|
|
public class ServiceNotFoundException : Exception
|
|
{
|
|
public string ServiceName { get; }
|
|
|
|
public ServiceNotFoundException(string serviceName)
|
|
: base($"No healthy instances found for service: {serviceName}")
|
|
{
|
|
ServiceName = serviceName;
|
|
}
|
|
}
|
|
|
|
public class ServiceUnavailableException : Exception
|
|
{
|
|
public string ServiceName { get; }
|
|
|
|
public ServiceUnavailableException(
|
|
string serviceName,
|
|
string message,
|
|
Exception? innerException = null)
|
|
: base($"Service {serviceName} unavailable: {message}", innerException)
|
|
{
|
|
ServiceName = serviceName;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Kubernetes-Native Service Discovery
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Service discovery for Kubernetes using DNS.
|
|
/// VI: Service discovery cho Kubernetes dùng DNS.
|
|
/// </summary>
|
|
public class KubernetesServiceDiscovery : IServiceDiscovery
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<KubernetesServiceDiscovery> _logger;
|
|
|
|
public KubernetesServiceDiscovery(
|
|
IConfiguration configuration,
|
|
ILogger<KubernetesServiceDiscovery> logger)
|
|
{
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
public Task<ServiceInstance?> GetServiceAsync(
|
|
string serviceName,
|
|
CancellationToken ct = default)
|
|
{
|
|
// EN: In Kubernetes, use DNS for service discovery
|
|
// VI: Trong Kubernetes, dùng DNS cho service discovery
|
|
var namespace_ = _configuration["Kubernetes:Namespace"] ?? "default";
|
|
var host = $"{serviceName}.{namespace_}.svc.cluster.local";
|
|
var port = GetServicePort(serviceName);
|
|
|
|
var instance = new ServiceInstance(
|
|
$"{serviceName}-k8s",
|
|
serviceName,
|
|
host,
|
|
port,
|
|
new Dictionary<string, string>());
|
|
|
|
return Task.FromResult<ServiceInstance?>(instance);
|
|
}
|
|
|
|
public Task<IReadOnlyList<ServiceInstance>> GetServicesAsync(
|
|
string serviceName,
|
|
CancellationToken ct = default)
|
|
{
|
|
// EN: For K8s, we typically use the service DNS which load balances automatically
|
|
// VI: Với K8s, ta thường dùng service DNS vốn tự động load balance
|
|
var instance = GetServiceAsync(serviceName, ct).Result;
|
|
return Task.FromResult<IReadOnlyList<ServiceInstance>>(
|
|
instance != null ? new[] { instance } : Array.Empty<ServiceInstance>());
|
|
}
|
|
|
|
public Task RegisterAsync(ServiceRegistration registration, CancellationToken ct = default)
|
|
{
|
|
// EN: In Kubernetes, registration happens via Pod labels/Service selectors
|
|
// VI: Trong Kubernetes, đăng ký qua Pod labels/Service selectors
|
|
_logger.LogInformation(
|
|
"Kubernetes service registration is automatic via Pod labels");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task DeregisterAsync(string serviceId, CancellationToken ct = default)
|
|
{
|
|
// EN: In Kubernetes, deregistration happens when Pod terminates
|
|
// VI: Trong Kubernetes, hủy đăng ký khi Pod kết thúc
|
|
_logger.LogInformation(
|
|
"Kubernetes service deregistration is automatic when Pod terminates");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private int GetServicePort(string serviceName)
|
|
{
|
|
// EN: Get port from configuration or use default
|
|
// VI: Lấy port từ config hoặc dùng mặc định
|
|
var configKey = $"Services:{serviceName}:Port";
|
|
return _configuration.GetValue<int?>(configKey) ?? 80;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Extension to configure service discovery based on environment.
|
|
/// VI: Extension cấu hình service discovery theo môi trường.
|
|
/// </summary>
|
|
public static class ServiceDiscoveryExtensions
|
|
{
|
|
public static IServiceCollection AddServiceDiscovery(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
var discoveryType = configuration.GetValue<string>("ServiceDiscovery:Type");
|
|
|
|
switch (discoveryType?.ToLower())
|
|
{
|
|
case "consul":
|
|
services.AddConsulDiscovery(configuration);
|
|
break;
|
|
|
|
case "kubernetes":
|
|
default:
|
|
services.AddSingleton<IServiceDiscovery, KubernetesServiceDiscovery>();
|
|
break;
|
|
}
|
|
|
|
return services;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Complete Usage Example
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Complete example using service discovery.
|
|
/// VI: Ví dụ hoàn chỉnh sử dụng service discovery.
|
|
/// </summary>
|
|
|
|
// Program.cs
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// EN: Add service discovery
|
|
builder.Services.AddServiceDiscovery(builder.Configuration);
|
|
|
|
// EN: Add HTTP clients with discovery
|
|
builder.Services.AddHttpClient<IUserServiceClient, UserServiceClient>()
|
|
.AddServiceDiscovery("user-service")
|
|
.AddStandardResilienceHandler();
|
|
|
|
builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>()
|
|
.AddServiceDiscovery("order-service")
|
|
.AddStandardResilienceHandler();
|
|
|
|
// EN: Add health checks
|
|
builder.Services.AddHealthChecks()
|
|
.AddCheck<ServiceDiscoveryHealthCheck>("service-discovery");
|
|
|
|
var app = builder.Build();
|
|
|
|
app.MapHealthChecks("/health");
|
|
app.MapControllers();
|
|
app.Run();
|
|
|
|
// HealthChecks/ServiceDiscoveryHealthCheck.cs
|
|
public class ServiceDiscoveryHealthCheck : IHealthCheck
|
|
{
|
|
private readonly IServiceDiscovery _discovery;
|
|
private readonly string[] _requiredServices = { "user-service", "order-service" };
|
|
|
|
public ServiceDiscoveryHealthCheck(IServiceDiscovery discovery)
|
|
{
|
|
_discovery = discovery;
|
|
}
|
|
|
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
HealthCheckContext context,
|
|
CancellationToken ct = default)
|
|
{
|
|
var missingServices = new List<string>();
|
|
|
|
foreach (var service in _requiredServices)
|
|
{
|
|
var instances = await _discovery.GetServicesAsync(service, ct);
|
|
if (instances.Count == 0)
|
|
{
|
|
missingServices.Add(service);
|
|
}
|
|
}
|
|
|
|
if (missingServices.Any())
|
|
{
|
|
return HealthCheckResult.Unhealthy(
|
|
$"Missing services: {string.Join(", ", missingServices)}");
|
|
}
|
|
|
|
return HealthCheckResult.Healthy("All required services available");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Docker Compose for Local Development
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
consul:
|
|
image: consul:1.15
|
|
ports:
|
|
- "8500:8500"
|
|
command: agent -dev -ui -client=0.0.0.0
|
|
|
|
user-service:
|
|
build:
|
|
context: ./src/UserService
|
|
environment:
|
|
- ASPNETCORE_ENVIRONMENT=Development
|
|
- Consul__Address=http://consul:8500
|
|
- Service__Name=user-service
|
|
- Service__Host=user-service
|
|
- Service__Port=80
|
|
depends_on:
|
|
- consul
|
|
|
|
order-service:
|
|
build:
|
|
context: ./src/OrderService
|
|
environment:
|
|
- ASPNETCORE_ENVIRONMENT=Development
|
|
- Consul__Address=http://consul:8500
|
|
- Service__Name=order-service
|
|
- Service__Host=order-service
|
|
- Service__Port=80
|
|
depends_on:
|
|
- consul
|
|
|
|
api-gateway:
|
|
build:
|
|
context: ./src/ApiGateway
|
|
ports:
|
|
- "5000:80"
|
|
environment:
|
|
- Consul__Address=http://consul:8500
|
|
depends_on:
|
|
- consul
|
|
- user-service
|
|
- order-service
|
|
```
|