[Translate] How to use Consul to store configurations in ASP.
Original: USING CONSUL for storing the CONFIGURATION in ASP.
Author: Nathanael
[Translator Note: Because of anxious to share to everyone, so this article translation is very hasty, some inaccurate place also hope understanding]
The Consul from Hashicorp is a tool for distributed architectures that can be used for service discovery, running health checks, and KV storage. This article details how to use Consul to store configurations in ASP. NET Core by implementing Configurationprovider.
Why use tools to store configurations?
Typically, the configuration in a. Net application is stored in a configuration file, such as app. config, Web. config, or Appsettings.json. Starting with ASP. NET Core, a new extensible configuration framework is available that allows the configuration to be stored outside the configuration file and retrieved from the command line, environment variables, and so on.
The problem with configuration files is that they are difficult to manage. In fact, we usually end up with a configuration file and a corresponding transform file to cover each environment. They need to be deployed with DLLs, so changing the configuration means redeploying the configuration files and DLLs. Not very convenient.
Using separate tools for centralization allows us to do two things:
- Have the same configuration on all machines
- Ability to change values without redeploying any content (useful for feature-enabled shutdown)
Consul Introduction
The purpose of this article is not to discuss Consul, but to focus on how to integrate it with ASP.
However, a brief introduction is still necessary. Consul has a Key/value storage feature that is organized hierarchically and can create folders to map different applications, environments, and so on. This is an example of the hierarchy that will be used in this article. Each node can contain JSON values.
/
|-- App1
| |-- Dev
| | |-- ConnectionStrings
| | \-- Settings
| |-- Staging
| | |-- ConnectionStrings
| | \-- Settings
| \-- Prod
| |-- ConnectionStrings
| \-- Settings
\-- App2
|-- Dev
| |-- ConnectionStrings
| \-- Settings
|-- Staging
| |-- ConnectionStrings
| \-- Settings
\-- Prod
|-- ConnectionStrings
\-- Settings
It provides a REST API for easy querying, and key is included in the query path. For example, the query that gets the configuration of APP1 in the Dev environment is as follows: Get HTTP://8500/v1/kv/app1/dev/settings
The response is as follows:
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
}
]
The
can also recursively query any node, GET http://, 8500/v1/kv/app1/dev?recurse return:
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
}
]
We can see a lot of content through this response, first we can see the value of each key is using the BASE64 encoding to avoid the value values and JSON itself confusion, and then we notice that the property "Index" in both JSON and HTTP headers. These properties are a timestamp, and they can let us know whether or when the value was created or updated. They can help us know if we need to reload these configurations.
ASP. NET Core configuration system
The infrastructure of this configuration relies on some of the content in the Microsoft.Extensions.Configuration.Abstractions nuget package. First, Iconfigurationprovider is an interface for providing configuration values, and then Iconfigurationsource is used to provide instances of provider that have implemented the above interfaces.
You can view some implementations on ASP.
A class named Configurationprovider can be inherited in the Microsoft.Extensions.Configuration package compared to the direct implementation of Iconfigurationprovider. This class provides some sample code (for example, an implementation of an overloaded token).
This class contains two important things:
/* Excerpt from the implementation */
public abstract class ConfigurationProvider : IConfigurationProvider
{
protected IDictionary<string, string> Data { get; set; }
public virtual void Load()
{
}
}
Data is a dictionary that contains all the keys and values, and load is the method used at the beginning of the application, as its name implies, and it loads the configuration from somewhere (the configuration file or our consul instance) and populates the dictionary.
Load the consul configuration in ASP.
The first way we think of it is to use HttpClient to get the configuration in consul. Then, because the configuration is hierarchical, like a tree, we need to expand it so that it can be put into the dictionary, isn't it simple?
First, implement the Load method, but we need an asynchronous method, the original method will block, so join an asynchronous LoadAsync method
public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
We will then query consul recursively to get the configuration value. It uses some of the objects defined in the class, such as _consulurls, which is an array to hold the URLs of consul instances (for failover), _path is the key prefix (for example, App1/dev). Once we get the JSON, we iterate over each key-value pair, decode the Base64 string, and flatten all the keys and JSON objects.
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;
}
}
}
The way to flatten the key values is to make a simple deep-first search of the tree.
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;
}
}
}
The complete class code that contains the construction method and the private field is as follows:
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;
}
}
}
}
Dynamic Reload Configuration
We can further use the consul notice of change. It only works by adding a parameter (the value of the last index configuration), and the HTTP request is blocked until the next configuration change (or HttpClient timeout).
Compared to the previous class, we simply add a method listentoconfigurationchanges to listen for consul blocking HTTP in the background.
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;
}
}
}
}
Grouped together
Now that we have a configurationprovider, let's write a configurationsource to create our 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);
}
}
And some extension methods:
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);
}
}
You can now add Consul to Program.cs and use other sources, such as environment variables or command-line arguments, to provide a URL to Consul
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();
Now, you can use the standard configuration mode for ASP. NET Core, such as 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"));
}
To use them in our code, note how to use the options, and use Ioptions to get the initial value for options that can be dynamically reloaded. Instead, ASP. NET Core needs to use Ioptionssnapshot.
This is great for feature switching because you can enable or disable new features by changing the values in Consul, and users can use these new features without republishing. Similarly, if a feature appears to be a bug, you can disable it without rolling back or hot fix.
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);
}
}
End
These lines of code allow us to add support for the consul configuration in an ASP. NET Core application. In fact, any application (even a classic. Net application that uses the Microsoft.Extensions.Configuration package) can benefit from it. This will be cool in a DevOps environment where you can centralize all configurations in one place and use the hot reload feature for real-time switching.