feat: add ServiceDiscovery framework with Kubernetes, Consul and Static providers

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
sam 2026-02-15 10:32:51 +08:00
commit a6f6245807
16 changed files with 1006 additions and 0 deletions

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Fengling.ServiceDiscovery.Consul</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Consul" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.ServiceDiscovery.Core\Fengling.ServiceDiscovery.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,111 @@
using System.Runtime.CompilerServices;
using Consul;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Fengling.ServiceDiscovery.Consul.Extensions;
namespace Fengling.ServiceDiscovery.Consul;
/// <summary>
/// Consul 服务发现提供商
/// </summary>
public class ConsulDiscoveryProvider : IServiceDiscoveryProvider
{
private readonly IConsulClient _consul;
private readonly ConsulDiscoveryOptions _options;
private readonly ILogger<ConsulDiscoveryProvider> _logger;
public string ProviderName => "Consul";
public ConsulDiscoveryProvider(
IConsulClient consul,
IOptions<ConsulDiscoveryOptions> options,
ILogger<ConsulDiscoveryProvider> logger)
{
_consul = consul;
_options = options.Value;
_logger = logger;
}
public async Task<IReadOnlyList<DiscoveredService>> GetServicesAsync(CancellationToken ct = default)
{
try
{
var services = await _consul.Catalog.Services(ct: ct);
return services.Response.Select(kvp => new DiscoveredService
{
Name = kvp.Key,
Namespace = _options.Datacenter ?? "dc1",
Labels = kvp.Value?.ToDictionary(x => x, x => x) ?? new Dictionary<string, string>(),
Ports = new List<int>(), // Consul 不直接提供端口,需要查每个服务
DiscoveredAt = DateTime.UtcNow
}).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get services from Consul");
return Array.Empty<DiscoveredService>();
}
}
public async Task<IReadOnlyList<DiscoveredInstance>> GetInstancesAsync(
string serviceName,
string? namespace_ = null,
CancellationToken ct = default)
{
try
{
var service = await _consul.Catalog.Service(serviceName, ct: ct);
return service.Response?.Select(s => new DiscoveredInstance
{
InstanceId = s.ServiceID,
Address = $"http://{s.ServiceAddress}:{s.ServicePort}",
Port = s.ServicePort,
IsHealthy = true,
HealthStatus = "Passing"
}).ToList() ?? new List<DiscoveredInstance>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get instances for service {Service} from Consul", serviceName);
return Array.Empty<DiscoveredInstance>();
}
}
public async IAsyncEnumerable<ServiceDiscoveryChange> WatchServicesAsync(
[EnumeratorCancellation] CancellationToken ct)
{
var serviceKeys = await _consul.Catalog.Services(ct: ct);
// Consul 没有原生的 Watch 事件,需要轮询或使用 Agent.Check
// 这里简化实现,返回空
await Task.CompletedTask;
yield break;
}
public async Task<HealthCheckResult> CheckHealthAsync(
string address,
string? healthPath = null,
CancellationToken ct = default)
{
var startTime = DateTime.UtcNow;
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var response = await client.GetAsync($"{address.TrimEnd('/')}/health", ct);
var elapsed = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy(elapsed)
: HealthCheckResult.Unhealthy($"HTTP {response.StatusCode}", elapsed);
}
catch (Exception ex)
{
var elapsed = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
return HealthCheckResult.Unhealthy(ex.Message, elapsed);
}
}
}

View File

@ -0,0 +1,63 @@
using Consul;
using Microsoft.Extensions.DependencyInjection;
namespace Fengling.ServiceDiscovery.Consul.Extensions;
/// <summary>
/// Consul 服务发现扩展方法
/// </summary>
public static class ConsulServiceDiscoveryExtensions
{
/// <summary>
/// 添加 Consul 服务发现
/// </summary>
public static IServiceCollection AddConsulServiceDiscovery(
this IServiceCollection services,
Action<ConsulDiscoveryOptions>? configure = null)
{
var options = new ConsulDiscoveryOptions();
configure?.Invoke(options);
services.AddSingleton(options);
// 注册 Consul 客户端
services.AddSingleton<IConsulClient>(sp =>
{
var opts = sp.GetRequiredService<ConsulDiscoveryOptions>();
return new ConsulClient(config =>
{
config.Address = opts.Address;
config.Datacenter = opts.Datacenter;
if (!string.IsNullOrEmpty(opts.Token))
{
config.Token = opts.Token;
}
});
});
services.AddSingleton<Fengling.ServiceDiscovery.IServiceDiscoveryProvider, ConsulDiscoveryProvider>();
return services;
}
}
/// <summary>
/// Consul 发现配置选项
/// </summary>
public class ConsulDiscoveryOptions
{
/// <summary>
/// Consul 地址
/// </summary>
public Uri Address { get; set; } = new("http://localhost:8500");
/// <summary>
/// 数据中心
/// </summary>
public string? Datacenter { get; set; }
/// <summary>
/// ACL Token
/// </summary>
public string? Token { get; set; }
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Fengling.ServiceDiscovery</RootNamespace>
<AssemblyName>Fengling.ServiceDiscovery.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,38 @@
namespace Fengling.ServiceDiscovery;
/// <summary>
/// 服务发现提供商
/// </summary>
public interface IServiceDiscoveryProvider
{
/// <summary>
/// 提供商名称 (如: "Kubernetes", "Consul", "Static")
/// </summary>
string ProviderName { get; }
/// <summary>
/// 获取所有服务
/// </summary>
Task<IReadOnlyList<DiscoveredService>> GetServicesAsync(CancellationToken ct = default);
/// <summary>
/// 获取服务实例
/// </summary>
Task<IReadOnlyList<DiscoveredInstance>> GetInstancesAsync(
string serviceName,
string? namespace_ = null,
CancellationToken ct = default);
/// <summary>
/// 监听服务变化
/// </summary>
IAsyncEnumerable<ServiceDiscoveryChange> WatchServicesAsync(CancellationToken ct = default);
/// <summary>
/// 健康检查
/// </summary>
Task<HealthCheckResult> CheckHealthAsync(
string address,
string? healthPath = null,
CancellationToken ct = default);
}

View File

@ -0,0 +1,104 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fengling.ServiceDiscovery.Extensions;
/// <summary>
/// 服务发现扩展方法
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加服务发现
/// </summary>
public static IServiceCollection AddServiceDiscovery(
this IServiceCollection services,
Action<ServiceDiscoveryOptions>? configure = null)
{
var options = new ServiceDiscoveryOptions();
configure?.Invoke(options);
services.AddSingleton(options);
// 如果启用了后台同步,注册后台服务
if (options.EnableBackgroundSync)
{
services.AddHostedService<ServiceDiscoveryBackgroundSync>();
}
return services;
}
}
/// <summary>
/// 服务发现配置选项
/// </summary>
public class ServiceDiscoveryOptions
{
/// <summary>
/// 启用后台同步
/// </summary>
public bool EnableBackgroundSync { get; set; } = true;
/// <summary>
/// 同步间隔
/// </summary>
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// 后台同步服务 - 通过接口调度
/// </summary>
public class ServiceDiscoveryBackgroundSync : BackgroundService
{
private readonly ILogger<ServiceDiscoveryBackgroundSync> _logger;
private readonly ServiceDiscoveryOptions _options;
private readonly IEnumerable<IServiceDiscoveryProvider> _providers;
public ServiceDiscoveryBackgroundSync(
IEnumerable<IServiceDiscoveryProvider> providers,
ILogger<ServiceDiscoveryBackgroundSync> logger,
IOptions<ServiceDiscoveryOptions> options)
{
_providers = providers;
_logger = logger;
_options = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting service discovery background sync");
while (!stoppingToken.IsCancellationRequested)
{
try
{
foreach (var provider in _providers)
{
var services = await provider.GetServicesAsync(stoppingToken);
_logger.LogDebug("[{Provider}] Discovered {Count} services",
provider.ProviderName, services.Count);
foreach (var service in services)
{
var instances = await provider.GetInstancesAsync(
service.Name,
service.Namespace,
stoppingToken);
_logger.LogDebug("[{Provider}] Service {Name} has {Count} instances",
provider.ProviderName, service.Name, instances.Count);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during service discovery sync");
}
await Task.Delay(_options.SyncInterval, stoppingToken);
}
}
}

View File

@ -0,0 +1,47 @@
namespace Fengling.ServiceDiscovery;
/// <summary>
/// 发现的服务实例信息
/// </summary>
public record DiscoveredInstance
{
/// <summary>
/// 实例 ID
/// </summary>
public required string InstanceId { get; init; }
/// <summary>
/// Pod 名称
/// </summary>
public string? PodName { get; init; }
/// <summary>
/// Pod IP
/// </summary>
public string? PodIP { get; init; }
/// <summary>
/// 服务地址 (http://ip:port)
/// </summary>
public required string Address { get; init; }
/// <summary>
/// 权重
/// </summary>
public int Weight { get; init; } = 1;
/// <summary>
/// 是否健康
/// </summary>
public bool IsHealthy { get; init; }
/// <summary>
/// 健康状态描述
/// </summary>
public string? HealthStatus { get; init; }
/// <summary>
/// 实例端口
/// </summary>
public int Port { get; init; }
}

View File

@ -0,0 +1,52 @@
namespace Fengling.ServiceDiscovery;
/// <summary>
/// 发现的服务信息
/// </summary>
public record DiscoveredService
{
/// <summary>
/// 服务名称
/// </summary>
public required string Name { get; init; }
/// <summary>
/// 命名空间
/// </summary>
public required string Namespace { get; init; }
/// <summary>
/// 集群内部 IP
/// </summary>
public string? ClusterIP { get; init; }
/// <summary>
/// 外部 IP
/// </summary>
public string? ExternalIP { get; init; }
/// <summary>
/// 端口列表
/// </summary>
public IReadOnlyList<int> Ports { get; init; } = Array.Empty<int>();
/// <summary>
/// 标签
/// </summary>
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
/// <summary>
/// 注解
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
/// <summary>
/// 发现时间
/// </summary>
public DateTime DiscoveredAt { get; init; } = DateTime.UtcNow;
/// <summary>
/// 是否已分配路由
/// </summary>
public bool IsAssigned { get; set; }
}

View File

@ -0,0 +1,49 @@
namespace Fengling.ServiceDiscovery;
/// <summary>
/// 健康检查结果
/// </summary>
public record HealthCheckResult
{
/// <summary>
/// 是否健康
/// </summary>
public required bool IsHealthy { get; init; }
/// <summary>
/// 响应时间 (毫秒)
/// </summary>
public long ResponseTimeMs { get; init; }
/// <summary>
/// 状态描述
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 错误信息
/// </summary>
public string? Error { get; init; }
/// <summary>
/// 检查时间
/// </summary>
public DateTime CheckedAt { get; init; } = DateTime.UtcNow;
public static HealthCheckResult Healthy(long responseTimeMs = 0)
=> new()
{
IsHealthy = true,
ResponseTimeMs = responseTimeMs,
Status = "Healthy"
};
public static HealthCheckResult Unhealthy(string error, long responseTimeMs = 0)
=> new()
{
IsHealthy = false,
ResponseTimeMs = responseTimeMs,
Status = "Unhealthy",
Error = error
};
}

View File

@ -0,0 +1,43 @@
namespace Fengling.ServiceDiscovery;
/// <summary>
/// 服务发现变更事件
/// </summary>
public record ServiceDiscoveryChange
{
/// <summary>
/// 变更类型
/// </summary>
public required ServiceDiscoveryChangeType Type { get; init; }
/// <summary>
/// 变更的服务
/// </summary>
public required DiscoveredService Service { get; init; }
/// <summary>
/// 关联的实例
/// </summary>
public IReadOnlyList<DiscoveredInstance>? Instances { get; init; }
}
/// <summary>
/// 服务发现变更类型
/// </summary>
public enum ServiceDiscoveryChangeType
{
/// <summary>
/// 新增
/// </summary>
Added,
/// <summary>
/// 修改
/// </summary>
Modified,
/// <summary>
/// 删除
/// </summary>
Deleted
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Fengling.ServiceDiscovery.Kubernetes</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KubernetesClient" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.ServiceDiscovery.Core\Fengling.ServiceDiscovery.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,81 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using k8s;
using K8s = k8s.Kubernetes;
namespace Fengling.ServiceDiscovery.Kubernetes.Extensions;
/// <summary>
/// Kubernetes 服务发现扩展方法
/// </summary>
public static class KubernetesServiceDiscoveryExtensions
{
/// <summary>
/// 添加 Kubernetes 服务发现
/// </summary>
public static IServiceCollection AddKubernetesServiceDiscovery(
this IServiceCollection services,
Action<KubernetesDiscoveryOptions>? configure = null)
{
var options = new KubernetesDiscoveryOptions();
configure?.Invoke(options);
services.AddSingleton(options);
// 根据配置决定如何创建 Kubernetes 客户端
if (options.UseInClusterConfig)
{
services.AddSingleton<IKubernetes>(sp =>
{
var config = KubernetesClientConfiguration.InClusterConfig();
return new K8s(config);
});
}
else if (!string.IsNullOrEmpty(options.KubeConfigPath))
{
services.AddSingleton<IKubernetes>(sp =>
{
var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(options.KubeConfigPath);
return new K8s(config);
});
}
else
{
services.AddSingleton<IKubernetes>(sp =>
{
var config = KubernetesClientConfiguration.BuildConfigFromConfigFile();
return new K8s(config);
});
}
services.AddSingleton<IServiceDiscoveryProvider, KubernetesDiscoveryProvider>();
return services;
}
}
/// <summary>
/// Kubernetes 发现配置选项
/// </summary>
public class KubernetesDiscoveryOptions
{
/// <summary>
/// Label 选择器,用于筛选需要发现的服务
/// </summary>
public string LabelSelector { get; set; } = "app.kubernetes.io/managed-by=yarp";
/// <summary>
/// 命名空间,为空表示所有命名空间
/// </summary>
public string? Namespace { get; set; }
/// <summary>
/// 是否使用 In-Cluster 配置 (在 K8s Pod 内运行)
/// </summary>
public bool UseInClusterConfig { get; set; }
/// <summary>
/// KubeConfig 文件路径
/// </summary>
public string? KubeConfigPath { get; set; }
}

View File

@ -0,0 +1,176 @@
using System.Runtime.CompilerServices;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Fengling.ServiceDiscovery.Kubernetes.Extensions;
namespace Fengling.ServiceDiscovery.Kubernetes;
/// <summary>
/// Kubernetes 服务发现提供商
/// </summary>
public class KubernetesDiscoveryProvider : IServiceDiscoveryProvider
{
private readonly IKubernetes _k8s;
private readonly KubernetesDiscoveryOptions _options;
private readonly ILogger<KubernetesDiscoveryProvider> _logger;
public string ProviderName => "Kubernetes";
public KubernetesDiscoveryProvider(
IKubernetes k8s,
IOptions<KubernetesDiscoveryOptions> options,
ILogger<KubernetesDiscoveryProvider> logger)
{
_k8s = k8s;
_options = options.Value;
_logger = logger;
}
public async Task<IReadOnlyList<DiscoveredService>> GetServicesAsync(CancellationToken ct = default)
{
try
{
var services = await _k8s.CoreV1.ListServiceForAllNamespacesAsync(
labelSelector: _options.LabelSelector,
fieldSelector: string.IsNullOrEmpty(_options.Namespace) ? null : $"metadata.namespace={_options.Namespace}",
cancellationToken: ct);
return services.Items.Select(MapToService).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get services from Kubernetes");
return Array.Empty<DiscoveredService>();
}
}
public async Task<IReadOnlyList<DiscoveredInstance>> GetInstancesAsync(
string serviceName,
string? namespace_ = null,
CancellationToken ct = default)
{
try
{
var ns = namespace_ ?? _options.Namespace ?? "default";
var endpoints = await _k8s.CoreV1.ReadNamespacedEndpointsAsync(serviceName, ns, cancellationToken: ct);
var instances = new List<DiscoveredInstance>();
if (endpoints.Subsets != null)
{
foreach (var subset in endpoints.Subsets)
{
var port = subset.Ports?.FirstOrDefault()?.Port ?? 80;
// Ready endpoints
if (subset.Addresses != null)
{
foreach (var addr in subset.Addresses)
{
instances.Add(new DiscoveredInstance
{
InstanceId = $"{serviceName}-{addr.TargetRef?.Name}",
PodName = addr.TargetRef?.Name,
PodIP = addr.Ip,
Address = $"http://{addr.Ip}:{port}",
Port = port,
IsHealthy = true,
HealthStatus = "Running"
});
}
}
// NotReady endpoints
if (subset.NotReadyAddresses != null)
{
foreach (var addr in subset.NotReadyAddresses)
{
instances.Add(new DiscoveredInstance
{
InstanceId = $"{serviceName}-{addr.TargetRef?.Name}",
PodName = addr.TargetRef?.Name,
PodIP = addr.Ip,
Address = $"http://{addr.Ip}:{port}",
Port = port,
IsHealthy = false,
HealthStatus = "NotReady"
});
}
}
}
}
return instances;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get instances for service {Service}", serviceName);
return Array.Empty<DiscoveredInstance>();
}
}
public async IAsyncEnumerable<ServiceDiscoveryChange> WatchServicesAsync(
[EnumeratorCancellation] CancellationToken ct)
{
// Simplified implementation - watch services using the List operation with continuation
var services = await _k8s.CoreV1.ListServiceForAllNamespacesAsync(
labelSelector: _options.LabelSelector,
cancellationToken: ct);
foreach (var service in services.Items)
{
var instances = await GetInstancesAsync(service.Metadata.Name, service.Metadata.Namespace(), ct);
yield return new ServiceDiscoveryChange
{
Type = ServiceDiscoveryChangeType.Added,
Service = MapToService(service),
Instances = instances
};
}
}
public async Task<HealthCheckResult> CheckHealthAsync(
string address,
string? healthPath = null,
CancellationToken ct = default)
{
var startTime = DateTime.UtcNow;
var url = $"{address.TrimEnd('/')}/{healthPath?.TrimStart('/') ?? "health"}";
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var response = await client.GetAsync(url, ct);
var elapsed = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
if (response.IsSuccessStatusCode)
{
return HealthCheckResult.Healthy(elapsed);
}
return HealthCheckResult.Unhealthy($"HTTP {response.StatusCode}", elapsed);
}
catch (Exception ex)
{
var elapsed = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
return HealthCheckResult.Unhealthy(ex.Message, elapsed);
}
}
private DiscoveredService MapToService(V1Service service)
{
return new DiscoveredService
{
Name = service.Metadata.Name,
Namespace = service.Metadata.Namespace() ?? "default",
ClusterIP = service.Spec.ClusterIP,
ExternalIP = service.Spec.ExternalIPs?.FirstOrDefault(),
Ports = service.Spec.Ports?.Select(p => p.Port).ToList() ?? new List<int>(),
Labels = service.Metadata.Labels != null ? new Dictionary<string, string>(service.Metadata.Labels) : new Dictionary<string, string>(),
Annotations = service.Metadata.Annotations != null ? new Dictionary<string, string>(service.Metadata.Annotations) : new Dictionary<string, string>(),
DiscoveredAt = DateTime.UtcNow
};
}
}

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Fengling.ServiceDiscovery.Static</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.ServiceDiscovery.Core\Fengling.ServiceDiscovery.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,88 @@
using Microsoft.Extensions.DependencyInjection;
namespace Fengling.ServiceDiscovery.Static.Extensions;
/// <summary>
/// 静态服务发现扩展方法
/// </summary>
public static class StaticServiceDiscoveryExtensions
{
/// <summary>
/// 添加静态服务发现 (用于本地调试)
/// </summary>
public static IServiceCollection AddStaticServiceDiscovery(
this IServiceCollection services,
Action<StaticDiscoveryOptions> configure)
{
var options = new StaticDiscoveryOptions();
configure(options);
services.AddSingleton(options);
services.AddSingleton<Fengling.ServiceDiscovery.IServiceDiscoveryProvider, StaticDiscoveryProvider>();
return services;
}
}
/// <summary>
/// 静态服务发现配置选项
/// </summary>
public class StaticDiscoveryOptions
{
/// <summary>
/// 静态服务列表
/// </summary>
public List<StaticService> Services { get; set; } = new();
}
/// <summary>
/// 静态服务定义
/// </summary>
public class StaticService
{
/// <summary>
/// 服务名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 命名空间
/// </summary>
public string? Namespace { get; set; }
/// <summary>
/// 服务地址 (不含端口)
/// </summary>
public string? Address { get; set; }
/// <summary>
/// 端口列表
/// </summary>
public List<int>? Ports { get; set; }
/// <summary>
/// 实例列表
/// </summary>
public List<StaticInstance>? Instances { get; set; }
}
/// <summary>
/// 静态实例定义
/// </summary>
public class StaticInstance
{
/// <summary>
/// 实例地址
/// </summary>
public required string Address { get; set; }
/// <summary>
/// 端口
/// </summary>
public int? Port { get; set; }
/// <summary>
/// 权重
/// </summary>
public int? Weight { get; set; }
}

View File

@ -0,0 +1,84 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Fengling.ServiceDiscovery.Static.Extensions;
namespace Fengling.ServiceDiscovery.Static;
/// <summary>
/// 静态服务发现提供商 (用于本地调试)
/// </summary>
public class StaticDiscoveryProvider : IServiceDiscoveryProvider
{
private readonly StaticDiscoveryOptions _options;
private readonly ILogger<StaticDiscoveryProvider> _logger;
public string ProviderName => "Static";
public StaticDiscoveryProvider(
IOptions<StaticDiscoveryOptions> options,
ILogger<StaticDiscoveryProvider> logger)
{
_options = options.Value;
_logger = logger;
}
public Task<IReadOnlyList<DiscoveredService>> GetServicesAsync(CancellationToken ct = default)
{
var services = _options.Services.Select(s => new DiscoveredService
{
Name = s.Name,
Namespace = s.Namespace ?? "default",
ClusterIP = s.Address?.Split(':').FirstOrDefault(),
Ports = s.Ports ?? new List<int> { 80 },
Labels = new Dictionary<string, string>(),
Annotations = new Dictionary<string, string>(),
DiscoveredAt = DateTime.UtcNow
}).ToList();
return Task.FromResult<IReadOnlyList<DiscoveredService>>(services);
}
public Task<IReadOnlyList<DiscoveredInstance>> GetInstancesAsync(
string serviceName,
string? namespace_ = null,
CancellationToken ct = default)
{
var service = _options.Services.FirstOrDefault(s => s.Name == serviceName);
if (service == null || service.Instances == null)
{
return Task.FromResult<IReadOnlyList<DiscoveredInstance>>(
new List<DiscoveredInstance>());
}
var instances = service.Instances.Select((inst, index) => new DiscoveredInstance
{
InstanceId = $"{serviceName}-{index}",
Address = inst.Address,
Port = inst.Port ?? 80,
Weight = inst.Weight ?? 1,
IsHealthy = true,
HealthStatus = "AlwaysHealthy"
}).ToList();
return Task.FromResult<IReadOnlyList<DiscoveredInstance>>(instances);
}
public async IAsyncEnumerable<ServiceDiscoveryChange> WatchServicesAsync(
[EnumeratorCancellation] CancellationToken ct)
{
// 静态配置不支持 Watch返回空
await Task.CompletedTask;
yield break;
}
public Task<HealthCheckResult> CheckHealthAsync(
string address,
string? healthPath = null,
CancellationToken ct = default)
{
// 静态配置默认返回健康
return Task.FromResult(HealthCheckResult.Healthy(0));
}
}