From 705c6c1b1f93d31266e3052d5a84aec60075fbce Mon Sep 17 00:00:00 2001 From: xiangning Date: Sun, 19 Jul 2026 13:20:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor(config):=20=E8=87=AA=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20ConsulConfigurationProvider=20=E6=9B=BF=E6=8D=A2=20Winton=20?= =?UTF-8?q?=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 注册 --- src/BuildFlow.Host/BuildFlow.Host.csproj | 3 +- .../ConsulConfigurationProvider.cs | 181 ++++++++++++++++++ src/BuildFlow.Host/Program.cs | 43 ++--- 3 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs diff --git a/src/BuildFlow.Host/BuildFlow.Host.csproj b/src/BuildFlow.Host/BuildFlow.Host.csproj index 23bcf6d..1358abc 100644 --- a/src/BuildFlow.Host/BuildFlow.Host.csproj +++ b/src/BuildFlow.Host/BuildFlow.Host.csproj @@ -54,8 +54,7 @@ - - + diff --git a/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs b/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs new file mode 100644 index 0000000..f4fc0ce --- /dev/null +++ b/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs @@ -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; + +/// +/// 轻量 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"。 +/// +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 _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(); + } + + 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 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(), idx); + } + resp.EnsureSuccessStatusCode(); + + var kvs = await resp.Content.ReadFromJsonAsync>() + ?? new List(); + var lastIndex = ParseIndex(resp.Headers); + + var dict = new Dictionary(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; } +} diff --git a/src/BuildFlow.Host/Program.cs b/src/BuildFlow.Host/Program.cs index e79b51a..61ca650 100644 --- a/src/BuildFlow.Host/Program.cs +++ b/src/BuildFlow.Host/Program.cs @@ -1,8 +1,6 @@ using BuildFlow.Host; using BuildFlow.Host.Configuration; using OpenTelemetry.Logs; -using Winton.Extensions.Configuration.Consul; -using Winton.Extensions.Configuration.Consul.Parsers; var builder = WebApplication.CreateBuilder(args); @@ -11,37 +9,22 @@ var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true); // Consul KV 配置中心(优先级最高,覆盖本地 json + secrets,作为生产配置真相源)。 -// 加载顺序决定优先级:后注册的覆盖先注册的,故 Consul 在 Secrets 之后。 -// 鸡生蛋问题:Consul 地址本身用环境变量 Consul__Host 注入(compose 配置), -// 默认回退到 middleware 公网地址。Consul 不可用时 Optional=true 回退本地 json,不阻断启动。 +// 自实现 Provider(替代 Winton.Extensions.Configuration.Consul),用 HTTP API 拉 buildflow/ 下所有 KV。 +// 鸡生蛋问题:Consul 地址用环境变量 Consul__Host 注入,默认回退 middleware 公网地址。 var consulHost = builder.Configuration["Consul:Host"] ?? "http://43.134.235.58:8520"; var consulToken = builder.Configuration["Consul:Token"] ?? ""; -builder.Configuration.AddConsul( - "buildflow/", - options => +if (!string.IsNullOrEmpty(consulToken)) +{ + builder.Configuration.Add(new ConsulConfigurationSource(new ConsulOptions { - options.ConsulConfigurationOptions = co => - { - co.Address = new Uri(consulHost); - // ACL token:Consul 开启了 ACL(default deny),api 必须带专用 token 才能读写 KV - // token 通过环境变量 Consul:Token 注入(compose),不进代码/镜像 - if (!string.IsNullOrEmpty(consulToken)) - 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; // 不吞,让异常抛出 - }; - }); + Host = consulHost, + Token = consulToken, + KeyPrefix = "buildflow/", + Optional = true, // Consul 不可用回退本地 json,不阻断启动 + ReloadOnChange = true, // 后台 watch KV 变更,热重载 + PollInterval = TimeSpan.FromSeconds(30) + })); +} // OTel 日志导出 var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";