# [翻譯] 如何在 ASP.Net Core 中使用 Consul 來存儲配置 原文: [USING CONSUL FOR STORING THE CONFIGURATION IN ASP.NET CORE](https://www.natmarchand.fr/consul-configura... ...
[翻譯] 如何在 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://
響應如下:
HTTP/1.1 200 OK
Content-Type: application/json
X-Consul-Index: 1071
X-Consul-Knownleader: true
X-Consul-Lastcontact: 0
[
{
"LockIndex": 0,
"Key": "App1/Dev/Settings",
"Flags": 0,
"Value": "ewogIkludCI6NDIsCiAiT2JqZWN0IjogewogICJTdHJpbmciOiAidG90byIsCiAgIkJsYSI6IG51bGwsCiAgIk9iamVjdCI6IHsKICAgIkRhdGUiOiAiMjAxOC0wMi0yM1QxNjoyMTowMFoiCiAgfQogfQp9Cgo=",
"CreateIndex": 501,
"ModifyIndex": 1071
}
]
也可以以遞歸方式查詢任何節點,GET http://
HTTP/1.1 200 OK
Content-Type: application/json
X-Consul-Index: 1071
X-Consul-Knownleader: true
X-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 值都使用了 Base64 編碼,以避免 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
這種情況對於功能切換非常棒,因為您可以通過更改 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 環境中這將非常酷,你可以將所有配置集中在一個位置,並使用熱重新載入功能進行實時切換。