[翻譯] 如何在 ASP.Net Core 中使用 Consul 來儲存配置
原文: USING CONSUL FOR STORING THE CONFIGURATION IN ASP.NET CORE
作者: Nathanael
[譯者註:因急於分享給大家,所以本文翻譯的很倉促,有些不準確的地方還望諒解]
來自 Hashicorp 公司的 Consul 是一個用於分布式架構的工具,可以用來做服務發現、運行健全狀態檢查和 kv 儲存。本文詳細介紹了如何使用 Consul 通過實現 ConfigurationProvider 在 ASP.Net Core 中儲存配置。
為什麼使用工具來儲存配置?
通常,.Net 應用程式中的配置儲存在設定檔中,例如 App.config、Web.config 或 appsettings.json。從 ASP.Net Core 開始,出現了一個新的可擴充組態架構,它允許將配置儲存在設定檔之外,並從命令列、環境變數等等中檢索它們。
設定檔的問題是它們很難管理。實際上,我們通常最終做法是使用設定檔和對應的轉換檔,來覆蓋每個環境。它們需要與 dll 一起部署,因此,更改配置意味著重新部署設定檔和 dll 。不太方便。
使用單獨的工具集中化可以讓我們做兩件事:
- 在所有機器上具有相同的配置
- 能夠在不重新部署任何內容的情況下更改值(對於功能啟用關閉很有用)
Consul 介紹
本文的目的不是討論 Consul,而是專註於如何將其與 ASP.Net Core 整合。
但是,簡單介紹一下還是有必要的。Consul 有一個 Key/Value 儲存功能,它是按層次組織的,可以建立檔案夾來映射不同的應用程式、環境等等。這是一個將在本文中使用的階層的樣本。每個節點都可以包含 JSON 值。
/|-- App1| |-- Dev| | |-- ConnectionStrings| | \-- Settings| |-- Staging| | |-- ConnectionStrings| | \-- Settings| \-- Prod| |-- ConnectionStrings| \-- Settings\-- App2 |-- Dev | |-- ConnectionStrings | \-- Settings |-- Staging | |-- ConnectionStrings | \-- Settings \-- Prod |-- ConnectionStrings \-- Settings
它提供了 REST API 以方便查詢,key 包含在查詢路徑中。例如,擷取 App1 在 Dev 環境中的配置的查詢如下所示:GET http://:8500/v1/kv/App1/Dev/Settings
響應如下:
HTTP/1.1 200 OKContent-Type: application/jsonX-Consul-Index: 1071X-Consul-Knownleader: trueX-Consul-Lastcontact: 0[ { "LockIndex": 0, "Key": "App1/Dev/Settings", "Flags": 0, "Value": "ewogIkludCI6NDIsCiAiT2JqZWN0IjogewogICJTdHJpbmciOiAidG90byIsCiAgIkJsYSI6IG51bGwsCiAgIk9iamVjdCI6IHsKICAgIkRhdGUiOiAiMjAxOC0wMi0yM1QxNjoyMTowMFoiCiAgfQogfQp9Cgo=", "CreateIndex": 501, "ModifyIndex": 1071 }]
也可以以遞迴方式查詢任何節點,GET http://:8500/v1/kv/App1/Dev?recurse 返回 :
HTTP/1.1 200 OKContent-Type: application/jsonX-Consul-Index: 1071X-Consul-Knownleader: trueX-Consul-Lastcontact: 0[ { "LockIndex": 0, "Key": "App1/Dev/", "Flags": 0, "Value": null, "CreateIndex": 75, "ModifyIndex": 75 }, { "LockIndex": 0, "Key": "App1/Dev/ConnectionStrings", "Flags": 0, "Value": "ewoiRGF0YWJhc2UiOiAiU2VydmVyPXRjcDpkYmRldi5kYXRhYmFzZS53aW5kb3dzLm5ldDtEYXRhYmFzZT1teURhdGFCYXNlO1VzZXIgSUQ9W0xvZ2luRm9yRGJdQFtzZXJ2ZXJOYW1lXTtQYXNzd29yZD1teVBhc3N3b3JkO1RydXN0ZWRfQ29ubmVjdGlvbj1GYWxzZTtFbmNyeXB0PVRydWU7IiwKIlN0b3JhZ2UiOiJEZWZhdWx0RW5kcG9pbnRzUHJvdG9jb2w9aHR0cHM7QWNjb3VudE5hbWU9ZGV2YWNjb3VudDtBY2NvdW50S2V5PW15S2V5OyIKfQ==", "CreateIndex": 155, "ModifyIndex": 155 }, { "LockIndex": 0, "Key": "App1/Dev/Settings", "Flags": 0, "Value": "ewogIkludCI6NDIsCiAiT2JqZWN0IjogewogICJTdHJpbmciOiAidG90byIsCiAgIkJsYSI6IG51bGwsCiAgIk9iamVjdCI6IHsKICAgIkRhdGUiOiAiMjAxOC0wMi0yM1QxNjoyMTowMFoiCiAgfQogfQp9Cgo=", "CreateIndex": 501, "ModifyIndex": 1071 }]
我們可以看到許多內容通過這個響應,首先我們可以看到每個 key 的 value 值都使用了 Base 64 編碼,以避免 value 值和 JSON 本身混淆,然後我們注意到屬性“Index”在 JSON 和 HTTP 頭中都有。 這些屬性是一種時間戳記,它們可以我們知道是否或何時建立或更新的 value。它們可以協助我們知道是否需要重新載入這些配置了。
ASP.Net Core 配置系統
這個配置的基礎結構依賴於 Microsoft.Extensions.Configuration.Abstractions NuGet包中的一些內容。首先,IConfigurationProvider 是用於提供配置值的介面,然後IConfigurationSource 用於提供已實現上述介面的 provider 的執行個體。
您可以在 ASP.Net GitHub 上查看一些實現。
與直接實現 IConfigurationProvider 相比,可以在 Microsoft.Extensions.Configuration 包中繼承一個名為 ConfigurationProvider 的類,該類提供了一些樣版代碼(例如重載令牌的實現)。
這個類包含兩個重要的東西:
/* Excerpt from the implementation */public abstract class ConfigurationProvider : IConfigurationProvider{ protected IDictionary<string, string> Data { get; set; } public virtual void Load() { }}
Data 是包含所有鍵和值的字典,Load 是應用程式開始時使用的方法,正如其名稱所示,它從某處(設定檔或我們的 consul 執行個體)載入配置並填充字典。
在 ASP.Net Core 中載入 consul 配置
我們第一個想到的方法就是利用 HttpClient 去擷取 consul 中的配置。然後,由於配置在層級式的,像一棵樹,我們需要把它展開,以便放入字典中,是不是很簡單?
首先,實現 Load 方法,但是我們需要一個非同步方法,原始方法會阻塞,所以加入一個非同步 LoadAsync 方法
public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
然後,我們將以遞迴的方式查詢 consul 以擷取配置值。它使用類中定義的一些對象,例如_consulUrls,這是一個數組用來儲存 consul 執行個體們的 url(用於容錯移轉),_path 是鍵的首碼(例如App1/Dev)。一旦我們得到 json ,我們迭代每個索引值對,解碼 Base64 字串,然後展平所有鍵和JSON對象。
private async Task<IDictionary<string, string>> ExecuteQueryAsync(){ int consulUrlIndex = 0; while (true) { try { using (var httpClient = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }, true)) using (var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_consulUrls[consulUrlIndex], "?recurse=true"))) using (var response = await httpClient.SendAsync(request)) { response.EnsureSuccessStatusCode(); var tokens = JToken.Parse(await response.Content.ReadAsStringAsync()); return tokens .Select(k => KeyValuePair.Create ( k.Value<string>("Key").Substring(_path.Length + 1), k.Value<string>("Value") != null ? JToken.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(k.Value<string>("Value")))) : null )) .Where(v => !string.IsNullOrWhiteSpace(v.Key)) .SelectMany(Flatten) .ToDictionary(v => ConfigurationPath.Combine(v.Key.Split('/')), v => v.Value, StringComparer.OrdinalIgnoreCase); } } catch { consulUrlIndex++; if (consulUrlIndex >= _consulUrls.Count) throw; } }}
使索引值變平的方法是對樹進行簡單的深度優先搜尋。
private static IEnumerable<KeyValuePair<string, string>> Flatten(KeyValuePair<string, JToken> tuple){ if (!(tuple.Value is JObject value)) yield break; foreach (var property in value) { var propertyKey = $"{tuple.Key}/{property.Key}"; switch (property.Value.Type) { case JTokenType.Object: foreach (var item in Flatten(KeyValuePair.Create(propertyKey, property.Value))) yield return item; break; case JTokenType.Array: break; default: yield return KeyValuePair.Create(propertyKey, property.Value.Value<string>()); break; } }}
包含構造方法和私人欄位的完整的類代碼如下:
public class SimpleConsulConfigurationProvider : ConfigurationProvider{ private readonly string _path; private readonly IReadOnlyList<Uri> _consulUrls; public SimpleConsulConfigurationProvider(IEnumerable<Uri> consulUrls, string path) { _path = path; _consulUrls = consulUrls.Select(u => new Uri(u, $"v1/kv/{path}")).ToList(); if (_consulUrls.Count <= 0) { throw new ArgumentOutOfRangeException(nameof(consulUrls)); } } public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); private async Task LoadAsync() { Data = await ExecuteQueryAsync(); } private async Task<IDictionary<string, string>> ExecuteQueryAsync() { int consulUrlIndex = 0; while (true) { try { var requestUri = "?recurse=true"; using (var httpClient = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }, true)) using (var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_consulUrls[consulUrlIndex], requestUri))) using (var response = await httpClient.SendAsync(request)) { response.EnsureSuccessStatusCode(); var tokens = JToken.Parse(await response.Content.ReadAsStringAsync()); return tokens .Select(k => KeyValuePair.Create ( k.Value<string>("Key").Substring(_path.Length + 1), k.Value<string>("Value") != null ? JToken.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(k.Value<string>("Value")))) : null )) .Where(v => !string.IsNullOrWhiteSpace(v.Key)) .SelectMany(Flatten) .ToDictionary(v => ConfigurationPath.Combine(v.Key.Split('/')), v => v.Value, StringComparer.OrdinalIgnoreCase); } } catch { consulUrlIndex = consulUrlIndex + 1; if (consulUrlIndex >= _consulUrls.Count) throw; } } } private static IEnumerable<KeyValuePair<string, string>> Flatten(KeyValuePair<string, JToken> tuple) { if (!(tuple.Value is JObject value)) yield break; foreach (var property in value) { var propertyKey = $"{tuple.Key}/{property.Key}"; switch (property.Value.Type) { case JTokenType.Object: foreach (var item in Flatten(KeyValuePair.Create(propertyKey, property.Value))) yield return item; break; case JTokenType.Array: break; default: yield return KeyValuePair.Create(propertyKey, property.Value.Value<string>()); break; } } }}
動態重新載入配置
我們可以進一步使用 consul 的變更通知。它只是通過添加一個參數(最後一個索引配置的值)來工作的,HTTP 要求會一直阻塞,直到下一次配置變更(或 HttpClient 逾時)。
與前面的類相比,我們只需添加一個方法 ListenToConfigurationChanges,以便在後台監聽 consul 的阻塞 HTTP 。
public class ConsulConfigurationProvider : ConfigurationProvider{ private const string ConsulIndexHeader = "X-Consul-Index"; private readonly string _path; private readonly HttpClient _httpClient; private readonly IReadOnlyList<Uri> _consulUrls; private readonly Task _configurationListeningTask; private int _consulUrlIndex; private int _failureCount; private int _consulConfigurationIndex; public ConsulConfigurationProvider(IEnumerable<Uri> consulUrls, string path) { _path = path; _consulUrls = consulUrls.Select(u => new Uri(u, $"v1/kv/{path}")).ToList(); if (_consulUrls.Count <= 0) { throw new ArgumentOutOfRangeException(nameof(consulUrls)); } _httpClient = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }, true); _configurationListeningTask = new Task(ListenToConfigurationChanges); } public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); private async Task LoadAsync() { Data = await ExecuteQueryAsync(); if (_configurationListeningTask.Status == TaskStatus.Created) _configurationListeningTask.Start(); } private async void ListenToConfigurationChanges() { while (true) { try { if (_failureCount > _consulUrls.Count) { _failureCount = 0; await Task.Delay(TimeSpan.FromMinutes(1)); } Data = await ExecuteQueryAsync(true); OnReload(); _failureCount = 0; } catch (TaskCanceledException) { _failureCount = 0; } catch { _consulUrlIndex = (_consulUrlIndex + 1) % _consulUrls.Count; _failureCount++; } } } private async Task<IDictionary<string, string>> ExecuteQueryAsync(bool isBlocking = false) { var requestUri = isBlocking ? $"?recurse=true&index={_consulConfigurationIndex}" : "?recurse=true"; using (var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_consulUrls[_consulUrlIndex], requestUri))) using (var response = await _httpClient.SendAsync(request)) { response.EnsureSuccessStatusCode(); if (response.Headers.Contains(ConsulIndexHeader)) { var indexValue = response.Headers.GetValues(ConsulIndexHeader).FirstOrDefault(); int.TryParse(indexValue, out _consulConfigurationIndex); } var tokens = JToken.Parse(await response.Content.ReadAsStringAsync()); return tokens .Select(k => KeyValuePair.Create ( k.Value<string>("Key").Substring(_path.Length + 1), k.Value<string>("Value") != null ? JToken.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(k.Value<string>("Value")))) : null )) .Where(v => !string.IsNullOrWhiteSpace(v.Key)) .SelectMany(Flatten) .ToDictionary(v => ConfigurationPath.Combine(v.Key.Split('/')), v => v.Value, StringComparer.OrdinalIgnoreCase); } } private static IEnumerable<KeyValuePair<string, string>> Flatten(KeyValuePair<string, JToken> tuple) { if (!(tuple.Value is JObject value)) yield break; foreach (var property in value) { var propertyKey = $"{tuple.Key}/{property.Key}"; switch (property.Value.Type) { case JTokenType.Object: foreach (var item in Flatten(KeyValuePair.Create(propertyKey, property.Value))) yield return item; break; case JTokenType.Array: break; default: yield return KeyValuePair.Create(propertyKey, property.Value.Value<string>()); break; } } }}
組合在一起
我們現在有了一個 ConfigurationProvider, 讓我們再寫一個 ConfigurationSource 來建立 我們的 provider.
public class ConsulConfigurationSource : IConfigurationSource{ public IEnumerable<Uri> ConsulUrls { get; } public string Path { get; } public ConsulConfigurationSource(IEnumerable<Uri> consulUrls, string path) { ConsulUrls = consulUrls; Path = path; } public IConfigurationProvider Build(IConfigurationBuilder builder) { return new ConsulConfigurationProvider(ConsulUrls, Path); }}
以及一些擴充方法 :
public static class ConsulConfigurationExtensions{ public static IConfigurationBuilder AddConsul(this IConfigurationBuilder configurationBuilder, IEnumerable<Uri> consulUrls, string consulPath) { return configurationBuilder.Add(new ConsulConfigurationSource(consulUrls, consulPath)); } public static IConfigurationBuilder AddConsul(this IConfigurationBuilder configurationBuilder, IEnumerable<string> consulUrls, string consulPath) { return configurationBuilder.AddConsul(consulUrls.Select(u => new Uri(u)), consulPath); }}
現在可以在 Program.cs 中添加 Consul,使用其他的來源(例如環境變數或命令列參數)來向 consul 提供 url
public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .ConfigureAppConfiguration(cb => { var configuration = cb.Build(); cb.AddConsul(new[] { configuration.GetValue<Uri>("CONSUL_URL") }, configuration.GetValue<string>("CONSUL_PATH")); }) .UseStartup<Startup>() .Build();
現在,可以使用 ASP.Net Core 的標準配置模式了,例如 Options。
public void ConfigureServices(IServiceCollection services){ services.AddMvc(); services.AddOptions(); services.Configure<AppSettingsOptions>(Configuration.GetSection("Settings")); services.Configure<AccountingFeaturesOptions>(Configuration.GetSection("FeatureFlags")); services.Configure<CartFeaturesOptions>(Configuration.GetSection("FeatureFlags")); services.Configure<CatalogFeaturesOptions>(Configuration.GetSection("FeatureFlags"));}
要在我們的代碼中使用它們,請注意如何使用 options ,對於可以動態重新載入的 options,使用 IOptions 將獲得初始值。反之,ASP.Net Core 需要使用 IOptionsSnapshot。
這種情況對於功能切換非常棒,因為您可以通過更改 Consul 中的值來啟用或禁用新功能,並且在不重新發布的情況下,使用者就可以使用這些新功能。同樣的,如果某個功能出現 bug,你可以禁用它,而無需復原或熱修複。
public class CartController : Controller{ [HttpPost] public IActionResult AddProduct([FromServices]IOptionsSnapshot<CartFeaturesOptions> options, [FromBody] Product product) { var cart = _cartService.GetCart(this.User); cart.Add(product); if (options.Value.UseCartAdvisorFeature) { ViewBag.CartAdvice = _cartAdvisor.GiveAdvice(cart); } return View(cart); }}
尾聲
這幾行代碼允許我們在 ASP.Net Core 應用程式中添加對 consul 配置的支援。事實上,任何應用程式(甚至使用 Microsoft.Extensions.Configuration 包的經典 .Net 應用程式)都可以從中受益。在 DevOps 環境中這將非常酷,你可以將所有配置集中在一個位置,並使用熱重新載入功能進行即時切換。