I keep information about my services (host, name) in appsettings. Sometimes I want to retrieve info about host not from appsettings but from Consul. So I want to configure IOptions in runtime, check if Host is presented and if not I want to query Consul to get it.
My options in appsettings.json look like this:
"MyServices": {
"MyServiceA": {
"ServiceName": "ServiceAName",
"ConsulDataCenter": "",
"Host": "http://localhost:12345"
},
"MyServiceB": {
"ServiceName": "ServiceBName",
"ConsulDataCenter": ""
}
}
So ServiceA host should be taken from appsettings and ServiceB host should be taken from Consul. First, I created a class representing my options:
public class MyServicesOptions : Dictionary<string, ConsulServiceData> { }
public sealed class ConsulServiceData
{
public required string ServiceName { get; init; }
public required string ConsulDataCenter { get; init; }
public string Host { get => _uri?.Host; set => _uri = new Uri(value); }
public Uri Uri { get => _uri; }
private Uri _uri;
}
Next, I need to configure IOptions. I've created an interface:
public interface IConfigureOptionsAsync<in TOptions> where TOptions : class
{
Task ConfigureAsync(TOptions options, CancellationToken cancellationToken = default);
}
and a class which implements the above described logic of configuration:
public class ConfigureServicesAsync : IConfigureOptionsAsync<MyServicesOptions>
{
protected readonly ConsulService _consulService;
private readonly ILogger<ConfigureMyServicesAsync> _logger;
public ConfigureMyServicesAsync(ConsulService consulService, ILogger<ConfigureMyServicesAsync> logger)
{
_consulService = consulService;
_logger = logger;
}
public virtual async Task ConfigureAsync(MyServicesOptions options, CancellationToken cancellationToken = default)
{
try
{
foreach (var (clientName, client) in options)
{
if (string.IsNullOrEmpty(client.Host))
{
var result = await _consulService.GetServiceUriAsync(client.ServiceName, null, new QueryOptions { Datacenter = client.ConsulDataCenter }, cancellationToken).ConfigureAwait(false);
if (result is null)
{
_logger.LogWarning("Could not retrieve address from consul for service {serviceName}. Reason: {error}", client.ServiceName, result.Error);
continue;
}
client.Host = result;
}
_logger.LogInformation("Address for {serviceName} - {uri}", client.ServiceName, client.Uri);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while configuring rest consul services: {exception}", ex.Message);
throw;
}
}
}
Then create hosted service for asynchronous configuration:
public class ConfigureOptionsHostedService<TOptions> : IHostedService where TOptions : class
{
private readonly IServiceProvider _services;
public ConfigureOptionsHostedService(IServiceProvider services)
{
_services = services;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var configurator = scope.ServiceProvider.GetRequiredService<IConfigureOptionsAsync<TOptions>>();
var options = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<TOptions>>();
await configurator.ConfigureAsync(options.CurrentValue, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Create methods to hosted service for options configuration
public static IServiceCollection ConfigureOptionsAsync<TOptions, TConfigureOptionsAsync>(this IServiceCollection services)
where TConfigureOptionsAsync : class, IConfigureOptionsAsync<TOptions>
where TOptions : class
{
services.AddHostedService<ConfigureOptionsHostedService<TOptions>>();
services.AddScoped<IConfigureOptionsAsync<TOptions>, TConfigureOptionsAsync>();
return services;
}
and register all the stuff
public static void AddMyServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<MyServicesOptions>(configuration.GetSection("MyServices"));
services.ConfigureOptionsAsync<MyServicesOptions, ConfigureMyServicesAsync>();
}
Finally, I want to obtain address for my HttpClient from IOptions.
services.AddHttpClient<IMyServiceA, MyServiceA>((sp, client) =>
{
var uri = sp.GetRequiredService<IOptions<MyServicesOptions>>().Value.GetValueOrDefault("ServiceA")?.Uri;
client.BaseAddress = uri;
});
services.AddHttpClient<IMyServiceB, MyServiceB>((sp, client) =>
{
var uri = sp.GetRequiredService<IOptions<MyServicesOptions>>().Value.GetValueOrDefault("ServiceB")?.Uri;
client.BaseAddress = uri;
});
And here is the question. HttpClient for ServiceA, which address was specified in appsettings.json, is obtained correctly. But ServiceB address is null. I thought that query to Consul returns null (line from ConfigureServicesAsync)
var result = await _consulService.GetServiceUriAsync(client.ServiceName, null, new QueryOptions { Datacenter = client.ConsulDataCenter }, cancellationToken).ConfigureAwait(false);
but its not the case, the address is correctly obtained from Consul and assign it to Host in options.
So what am I doing wrong? Why my IOptions configuration does not work even if I receeve correct address from Consul and assign it correctly?
IOptions is singleton service that reads the configuration value from the configuration source when the app starts, and remains unchanged throughout the app's lifecycle. If you change the data in the configuration source at runtime, IOptions does not automatically update these values.IOptionsMonitor is also a singleton service, but it is able to respond to configuration changes. 'IOptionsMonitor' dynamically updates the configuration value when the configuration changes and can provide the most up-to-date configuration data for each request.
In your case MyServicesOptions is configured to fetch values dynamically from appsettings.json and Consul. When you use 'IOptions', ServiceA's address is correctly obtained because it is already defined in 'appsettings.json' when the app starts. However, ServiceB's address is obtained from Consul at runtime, which is why you need to use 'IOptionsMonitor'.
When you use IOptionsMonitor, the service dynamically queries Consul at runtime, and when the configuration returned from Consul is updated, it is able to catch those changes and update the configuration values internally. This the reason why replacing 'IOptions' with 'IOptionsMonitor' works for you.
Here is the official document you can refer: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-8.0#options-interfaces