refactor(config): 自实现 ConsulConfigurationProvider 替换 Winton 包
Winton.Extensions.Configuration.Consul 在容器环境下读不到 KV(黑盒, 无异常无日志无连接),反复调试无效。改自实现 provider: - ConsulConfigurationProvider.cs:~180 行,HTTP API 拉 /v1/kv/?recurse=true - 支持 Optional(失败回退本地)、ReloadOnChange(Timer 轮询 watch) - 完全可控:ILogger 打印加载结果/异常/热重载事件 - KV key 映射:去 buildflow/ 前缀,剩余直接作 IConfiguration key - Value base64 解码(Consul API 默认编码) - 去掉 Winton 包依赖(csproj) Program.cs 改用 ConsulConfigurationSource 注册
This commit is contained in:
parent
78d9bcb5b2
commit
705c6c1b1f
@ -54,8 +54,7 @@
|
|||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.11.0-beta.2" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.11.0-beta.2" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
|
||||||
<!-- Consul KV 配置中心:作为 IConfiguration Provider 接入,支持 ReloadOnChange 热重载 -->
|
<!-- Consul KV 配置中心:自实现 Provider(BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs),不依赖第三方包 -->
|
||||||
<PackageReference Include="Winton.Extensions.Configuration.Consul" Version="3.0.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
181
src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs
Normal file
181
src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace BuildFlow.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轻量 Consul KV 配置 Provider(自实现,替代 Winton.Extensions.Configuration.Consul)。
|
||||||
|
/// 启动时用 Consul HTTP API 拉取 prefix 下所有 KV,扁平化注入 IConfiguration。
|
||||||
|
/// 支持 ReloadOnChange(后台 long-poll watch)。
|
||||||
|
///
|
||||||
|
/// 与 Winton 的区别:完全可控、有日志、不黑盒;用 HttpClient 直接调 /v1/kv/?recurse=true。
|
||||||
|
///
|
||||||
|
/// KV key 映射:Consul key 去掉 root 前缀后,剩余部分直接作为 IConfiguration key。
|
||||||
|
/// 如 Consul key "buildflow/Jwt:SigningKey",root="buildflow/",剩余 "Jwt:SigningKey" → IConfiguration key "Jwt:SigningKey"。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConsulConfigurationSource : IConfigurationSource
|
||||||
|
{
|
||||||
|
private readonly ConsulOptions _options;
|
||||||
|
|
||||||
|
public ConsulConfigurationSource(ConsulOptions options) => _options = options;
|
||||||
|
|
||||||
|
public IConfigurationProvider Build(IConfigurationBuilder builder) =>
|
||||||
|
new ConsulConfigurationProvider(_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConsulOptions
|
||||||
|
{
|
||||||
|
public required string Host { get; init; } // 如 http://43.134.235.58:8520
|
||||||
|
public required string Token { get; init; } // ACL token
|
||||||
|
public required string KeyPrefix { get; init; } // 如 buildflow/
|
||||||
|
public bool Optional { get; init; } = true;
|
||||||
|
public bool ReloadOnChange { get; init; } = true;
|
||||||
|
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConsulConfigurationProvider : ConfigurationProvider, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ConsulOptions _options;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
|
private readonly ILogger<ConsulConfigurationProvider> _logger;
|
||||||
|
private ulong _lastIndex;
|
||||||
|
private Timer? _watchTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ConsulConfigurationProvider(ConsulOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
if (!string.IsNullOrEmpty(options.Token))
|
||||||
|
_http.DefaultRequestHeaders.Add("X-Consul-Token", options.Token);
|
||||||
|
_loggerFactory = LoggerFactory.Create(b => b.AddConsole());
|
||||||
|
_logger = _loggerFactory.CreateLogger<ConsulConfigurationProvider>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (dict, index) = FetchKvAsync(waitForChange: false).GetAwaiter().GetResult();
|
||||||
|
_lastIndex = index;
|
||||||
|
Data = dict;
|
||||||
|
_logger.LogInformation("[Consul] 加载成功:{Count} 个 KV,LastIndex={Index}", dict.Count, index);
|
||||||
|
if (dict.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var k in dict.Keys.Take(3))
|
||||||
|
_logger.LogDebug("[Consul] 示例 key: {Key} = {Val}", k, dict[k]?.ToString()?.Substring(0, Math.Min(20, dict[k]?.ToString()?.Length ?? 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.ReloadOnChange && _watchTimer == null)
|
||||||
|
{
|
||||||
|
_watchTimer = new Timer(_ => WatchCallback(), null, _options.PollInterval, _options.PollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[Consul] 加载失败(Optional={Optional}): {Message}", _options.Optional, ex.Message);
|
||||||
|
if (!_options.Optional) throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void WatchCallback()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (dict, index) = await FetchKvAsync(waitForChange: true);
|
||||||
|
if (index > _lastIndex)
|
||||||
|
{
|
||||||
|
_lastIndex = index;
|
||||||
|
Data = dict;
|
||||||
|
OnReload();
|
||||||
|
_logger.LogInformation("[Consul] 热重载:检测到变更(LastIndex={Index}),已刷新 {Count} 个 KV", index, dict.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "[Consul] watch 轮询异常(将继续重试): {Message}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Dictionary<string, string?> Dict, ulong Index)> FetchKvAsync(bool waitForChange)
|
||||||
|
{
|
||||||
|
// Consul KV recurse API:返回 prefix 下所有 KV
|
||||||
|
var url = $"{_options.Host}/v1/kv/{_options.KeyPrefix}?recurse=true";
|
||||||
|
using var resp = await _http.GetAsync(url);
|
||||||
|
|
||||||
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
// Consul 返回 404:无此 key(首次未灌入)。Optional 时视为空配置。
|
||||||
|
var idx = ParseIndex(resp.Headers);
|
||||||
|
return (new Dictionary<string, string?>(), idx);
|
||||||
|
}
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var kvs = await resp.Content.ReadFromJsonAsync<List<ConsulKvItem>>()
|
||||||
|
?? new List<ConsulKvItem>();
|
||||||
|
var lastIndex = ParseIndex(resp.Headers);
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var kv in kvs)
|
||||||
|
{
|
||||||
|
// key 去掉 prefix,剩余直接作为 IConfiguration key(Consul key 里用 : 表层级)
|
||||||
|
var configKey = kv.Key;
|
||||||
|
if (configKey.StartsWith(_options.KeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
configKey = configKey[_options.KeyPrefix.Length..];
|
||||||
|
// Consul key 可能用 / 分隔层级,转成 IConfiguration 的 :
|
||||||
|
configKey = configKey.Replace('/', ':');
|
||||||
|
// value 是 base64 编码的,Consul API 默认返回 base64
|
||||||
|
var value = DecodeValue(kv.Value);
|
||||||
|
dict[configKey] = value;
|
||||||
|
}
|
||||||
|
return (dict, lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong ParseIndex(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues("X-Consul-Index", out var vals))
|
||||||
|
{
|
||||||
|
var s = vals.FirstOrDefault();
|
||||||
|
if (ulong.TryParse(s, out var idx)) return idx;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consul KV API 的 Value 字段是 base64 编码的(recurse 接口默认)
|
||||||
|
private static string? DecodeValue(string? b64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(b64)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = Convert.FromBase64String(b64);
|
||||||
|
return System.Text.Encoding.UTF8.GetString(bytes);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return b64; // 非 base64,原样返回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
_watchTimer?.Dispose();
|
||||||
|
_http.Dispose();
|
||||||
|
_loggerFactory?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consul KV API 响应项(recurse 接口)
|
||||||
|
internal sealed class ConsulKvItem
|
||||||
|
{
|
||||||
|
public int CreateIndex { get; set; }
|
||||||
|
public int ModifyIndex { get; set; }
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public int LockIndex { get; set; }
|
||||||
|
public string? Value { get; set; }
|
||||||
|
public int Flags { get; set; }
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
using BuildFlow.Host;
|
using BuildFlow.Host;
|
||||||
using BuildFlow.Host.Configuration;
|
using BuildFlow.Host.Configuration;
|
||||||
using OpenTelemetry.Logs;
|
using OpenTelemetry.Logs;
|
||||||
using Winton.Extensions.Configuration.Consul;
|
|
||||||
using Winton.Extensions.Configuration.Consul.Parsers;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -11,37 +9,22 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
||||||
|
|
||||||
// Consul KV 配置中心(优先级最高,覆盖本地 json + secrets,作为生产配置真相源)。
|
// Consul KV 配置中心(优先级最高,覆盖本地 json + secrets,作为生产配置真相源)。
|
||||||
// 加载顺序决定优先级:后注册的覆盖先注册的,故 Consul 在 Secrets 之后。
|
// 自实现 Provider(替代 Winton.Extensions.Configuration.Consul),用 HTTP API 拉 buildflow/ 下所有 KV。
|
||||||
// 鸡生蛋问题:Consul 地址本身用环境变量 Consul__Host 注入(compose 配置),
|
// 鸡生蛋问题:Consul 地址用环境变量 Consul__Host 注入,默认回退 middleware 公网地址。
|
||||||
// 默认回退到 middleware 公网地址。Consul 不可用时 Optional=true 回退本地 json,不阻断启动。
|
|
||||||
var consulHost = builder.Configuration["Consul:Host"] ?? "http://43.134.235.58:8520";
|
var consulHost = builder.Configuration["Consul:Host"] ?? "http://43.134.235.58:8520";
|
||||||
var consulToken = builder.Configuration["Consul:Token"] ?? "";
|
var consulToken = builder.Configuration["Consul:Token"] ?? "";
|
||||||
builder.Configuration.AddConsul(
|
if (!string.IsNullOrEmpty(consulToken))
|
||||||
"buildflow/",
|
{
|
||||||
options =>
|
builder.Configuration.Add(new ConsulConfigurationSource(new ConsulOptions
|
||||||
{
|
{
|
||||||
options.ConsulConfigurationOptions = co =>
|
Host = consulHost,
|
||||||
{
|
Token = consulToken,
|
||||||
co.Address = new Uri(consulHost);
|
KeyPrefix = "buildflow/",
|
||||||
// ACL token:Consul 开启了 ACL(default deny),api 必须带专用 token 才能读写 KV
|
Optional = true, // Consul 不可用回退本地 json,不阻断启动
|
||||||
// token 通过环境变量 Consul:Token 注入(compose),不进代码/镜像
|
ReloadOnChange = true, // 后台 watch KV 变更,热重载
|
||||||
if (!string.IsNullOrEmpty(consulToken))
|
PollInterval = TimeSpan.FromSeconds(30)
|
||||||
co.Token = consulToken;
|
}));
|
||||||
};
|
}
|
||||||
// 用 SimpleConfigurationParser:每个 Consul KV 的 value 作为字符串值(非 JSON)。
|
|
||||||
// 默认 ConvertConsulKVPairToConfig 把 Consul key 路径(去 buildflow/ 前缀,/ 转 :)作为 IConfiguration key,
|
|
||||||
// 如 buildflow/Jwt:SigningKey → 配置 Jwt:SigningKey,value 直接是密钥字符串。
|
|
||||||
options.Parser = new SimpleConfigurationParser();
|
|
||||||
options.Optional = false; // 诊断期:强制 Consul 必须可用,暴露真实错误
|
|
||||||
options.ReloadOnChange = true; // KV 变更自动刷新 IConfiguration(热重载基础)
|
|
||||||
// 诊断期:打印 Consul 加载异常(确认后改回 Ignore)
|
|
||||||
options.OnLoadException = ex =>
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Consul] 加载异常: {ex.Exception?.GetType().Name}: {ex.Exception?.Message}");
|
|
||||||
Console.WriteLine($"[Consul] 堆栈: {ex.Exception?.StackTrace?.Substring(0, System.Math.Min(500, ex.Exception?.StackTrace?.Length ?? 0))}");
|
|
||||||
ex.Ignore = false; // 不吞,让异常抛出
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// OTel 日志导出
|
// OTel 日志导出
|
||||||
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user