Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Configuring named options using IConfigureNamedOptions and ConfigureAll

$
0
0

This is a follow on to my previous post on using multiple instances of strongly-typed settings in ASP.NET Core 2.x. At the end of that post I introduced the concept of named options which were added in ASP.NET Core 2.0. In this post I'm going to look closer at how you can configure named options. In particular I'm going to look at:

A quick recap on named options

In my previous post I looked in depth at the scenario named options are designed to solve. Named options provide a solution where you want to have multiple instances of a strongly-typed settings class, each of which can be resolved from the DI container.

In my previous post, I used the scenario where you want to have an arbitrary number of settings for sending messages to Slack using WebHooks. For example, imagine you have the following strongly-typed settings object:

public class SlackApiSettings  
{
    public string WebhookUrl { get; set; }
    public string DisplayName { get; set; }
}

and the following configuration, stored in appsettings.json, which will be loaded into an IConfiguration object on app Startup:

{
  "SlackApi": {
    "DevChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T1/B1/111111",
      "DisplayName": "c0mp4ny 5l4ck b07"
    },
    "GeneralChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T2/B2/222222",
      "DisplayName": "Company Slack Bot"
    },
    "PublicChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T3/B3/333333",
      "DisplayName": "Professional Looking name"
    }
  }

You could bind each separate channel to a new SlackApiSettings instance in Startup.ConfigureServices() using the following:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("General", Configuration.GetSection("SlackApi:GeneralChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));
} 

Each instance is given a unique name (the first parameter to the Configure() method), and a configuration section to bind. You access these settings using the IOptionsSnapshot<> interface method and its Get(name) method:

public class SlackNotificationService
{
    public SlackNotificationService(IOptionsSnapshot<SlackApiSettings> options)
    {
        // fetch the settings for each channel
        SlackApiSettings devSettings = options.Get("Dev");
        SlackApiSettings generalSettings = options.Get("General");
        SlackApiSettings publicSettings = options.Get("Public");
    }
}

It's worth remembering that IOptionsSnapshot<T> re-binds options when they're requested (once every request). This differs from IOptions which binds options once for the lifetime of the app. As named options are typically exposed using IOptionsSnapshot<T>, they are similarly bound once-per request.

Named options vs the default options instance

You can use named options and the default options in the same application, and they won't interfere. Calling Configure() without specifying a name targets the default options, for example:

public void ConfigureServices(IServiceCollection services)
{
    // Configure named options
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));

    // Configure the default "unnamed" options
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 
} 

You can retrieve the default options using the Value property on IOptions<T> or IOptionsSnapshot<T>:

public class SlackNotificationService
{
    public SlackNotificationService(IOptionsSnapshot<SlackApiSettings> options)
    {
        // fetch the settings for each channel
        SlackApiSettings devSettings = options.Get("Dev");
        SlackApiSettings publicSettings = options.Get("Public");
        
        // fetch the default unnamed options
        SlackApiSettings defaultSettings = options.Value;
    }
}

Even if you don't explicitly use named options in your applications, the Options framework itself uses named options under the hood. When you call the Configure<T>(section) extension method (without providing a name), the framework calls the named version of the extension method behind the scenes, using Options.DefaultName as the default name:

public static IServiceCollection Configure<TOptions>(
    this IServiceCollection services, IConfiguration config) 
    where TOptions : class
{
    return services.Configure<TOptions>(Options.Options.DefaultName, config);
}

Options.DefaultName is set to string.Empty, so the following two lines have the same effect - they configure the default options object:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 
    // Using string.Empty as the named options type 
    services.Configure<SlackApiSettings>(string.Empty, Configuration.GetSection("SlackApi:GeneralChannel")); 
}

This is an important thing to bear in mind for this post - the default options are just named options with a specific name: string.Empty.

For the rest of this post I show some of the ways to configure named options in particular, compared to their default "unnamed" counterpart.

Injecting services into named options with IConfigureNamedOptions<T>

It's relatively common to require an external service when configuring your options. I've written previously about how to use IConfigureOptions<T> to access services when configuring options. In my last post, I discussed some of the issues to watch out for when those services are registered as Scoped services.

In all of those posts, I described how to configure the default options using IConfigureOptions<T>. There is also an equivalent interface you can implement to configure named options, called IConfigureNamedOptions<T>:

public interface IConfigureNamedOptions<in TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
    void Configure(string name, TOptions options);
}

This interface has two methods:

  • Configure(name, options) - implemented by the interface directly
  • Configure(options) - implemented by IConfigureOptions<T> (which it inherits)

When implementing the interface, it's important to understand that Configure(name, options) will be called for every instance of the options objects T that are instantiated in your application. That includes all named options, including the default options. It's up to you to check which instance is currently being configured at runtime.

Implementing IConfigureNamedOptions<T> for a specific named options instance

I think the the easiest way to understand IConfigureNamedOptions<T> is with an example. Lets consider a situation based on the Slack WebHooks scenario I described earlier. You have multiple WebHook URLs your app must call, which are configured in appsettings.json and are bound to separate named instances of SlackApiSettings. In addition, you have a default options instance. These are all configured as I described earlier:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 
} 

Now imagine that the WebHook URL for the named instance "Public" is not known in advance, and so can't be added to appsettings.json. Instead you have a separate service PublicSlackDetailsService that can be called to find the URL:

public interface PublicSlackDetailsService
{
    public string GetPublicWebhookUrl() => return "/some/url";
}

Note that the GetPublicWebhookUrl() method is synchronous, not async. Options configuration occurs inside a DI container when constructing an object, so it's not a good place to be doing asynchronous things like calling remote end points. If you find you need this capability, consider using other patterns such as a factory object instead of Options.

The PublicSlackDetailsService service is registered as a Singleton in ConfigureServices():

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<PublicSlackDetailsService>();
} 

Important: if you need to use Scoped services to configure your named options, see my previous post.

By implementing IConfigureNamedOptions<T>, you can configure a specific named options instance ("Public") using the PublicSlackDetailsService service:

public class ConfigurePublicSlackApiSettings: IConfigureNamedOptions<SlackApiSettings>
{
    // inject the PublicSlackDetailsService directly
    private readonly PublicSlackDetailsService _service;
    public ConfigurePublicSlackApiSettings(PublicSlackDetailsService service)
    {
        _service = service;
    }

    // Configure the named instance
    public void Configure(string name, SlackApiSettings options)
    {
        // Only configure the options if this is the correct instance
        if (name == "Public")
        {
            options.WebhookUrl = _service.GetPublicWebhookUrl();
        }
    }

    // This won't be called, but is required for the interface
    public void Configure(SlackApiSettings options) => Configure(Options.DefaultName, options);
}

It's easy to restrict ConfigurePublicSlackApiSettings to only configure the "Public" named instance. A simple check of the name parameter passed to Configure(name, options) avoids configuring both other named instances (e.g. "Dev") or the default instance (name will be string.Empty).

The other thing to note is that the Configure(options) method (required by the IConfigureOptions interface) delegates to the Configure(name, options) method, using the name Options.DefaultName. Technically speaking, this isn't really necessary: the options infrastructure used to create options (OptionsFactory) always preferentially calls Configure(name, options) when its available. However, the example shown should be considered a best practice.

The last thing to do is register the ConfigurePublicSlackApiSettings class with the DI container in ConfigureServices():

public void ConfigureServices(IServiceCollection services)
{
    // Configure the options objects using appsettings.json
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 

    // Add required service
    services.AddSingleton<PublicSlackDetailsService>();

    // Add named options configuration AFTER other configuration
    services.AddSingleton<IConfigureOptions<SlackApiSettings>, ConfigurePublicSlackApiSettings>);
}

Important: note that you must register as an IConfigureOptions<T> instance, not an IConfigureNamedOptions<T> instance! Also, as with all options configuration, order is important.

Whenever you request an instance of SlackApiSettings using IOptionsSnapshot<T>.Get(), the ConfigurePublicSlackApiSettings.Configure(name, options) method will be executed. The "Public" instance will have its WebhookUrl property updated, an all other named options will be ignored.

Now, I said earlier that the default options instance is just a named options instance with the special name string.Empty. I also said that IConfigureNamedOptions<T> is called for all named settings. This includes when the default options instance is requested using IOptions<T>.Value. ConfigurePublicSlackApiSettings handles this as the name passed to Configure(name, options) will be string.Empty so our code gracefully ignores it, the same as any other named options.

Configuring all options objects with ConfigureAll<T>

So far, and in recent posts, I've shown how to:

One thing I haven't shown is how to configure all options at once: both named options and the default options. If you're binding to configuration sections or using an Action<>, the easiest approach is to use the ConfigureAll() extension method

public void ConfigureServices(IServiceCollection services)
{
    // Configure ALL options instances, both named and default
    services.ConfigureAll<SlackApiSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 
    services.ConfigureAll<SlackApiSettings>(options => options.DisplayName = "Unknown"); 

    // Override values for named options
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));

    // Override values for default options 
    services.Configure<SlackApiSettings>(() => options.DisplayName = "default");
}

In this example, we bind every options object that we request to the "SlackApi:GeneralChannel" configuration section, and also set the DisplayName to "Unknown". Then depending on the name of the options instance requested, another configuration step may take place:

  • If the default instance is requested (using IOptions<T>.Value or IOptionsSnapshot<T>.Value, the DisplayName is set to "default")
  • If the "Dev" named instance is requested, the instance is bound to the "SlackApi:DevChannel" configuration section
  • If the "Public" named instance is requested, the instance is bound to the "SlackApi:PublicChannel" configuration section
  • If any other named instance is requested, no further configuration occurs.

This raises another important point:

You can request a named options instance that has not been explicitly registered.

Configuring all options in this way is convenient when you can use a simple Action<> or bind to a configuration section, but what if you need to use a service like PublicSlackDetailsService? In that case, you're back to implementing IConfigureNamedOptions<T>.

Using injected services when configuring all options instances

For simplicity's sake, we'll extend the scenario we described earlier. Instead of using PublicSlackDetailsService to set the WebhookUrl for only the "Public" named options instance, we'll imagine we need to set the value for every named options instance, including the default options. Luckily, all we need to do is remove the if() statement from our previous implementation, and we're pretty much there:

public class ConfigureAllSlackApiSettings: IConfigureNamedOptions<SlackApiSettings>
{
    // inject the PublicSlackDetailsService directly
    private readonly PublicSlackDetailsService _service;
    public ConfigurePublicSlackApiSettings(PublicSlackDetailsService service)
    {
        _service = service;
    }

    // Configure all instances
    public void Configure(string name, SlackApiSettings options)
    {
        // we don't care which instance it is, just set the URL!
        options.WebhookUrl = _service.GetPublicWebhookUrl();
    }

    // This won't be called, but is required for the interface
    public void Configure(SlackApiSettings options) => Configure(Options.DefaultName, options);
}

All that remains is to register the ConfigureAllSlackApiSettings in the default container. Remember that order matters for option configuration: if you want ConfigureAllSlackApiSettings to run before other configuration it should appear before other Configure() methods in ConfigureServices; otherwise it should appear after them:

public void ConfigureServices(IServiceCollection services)
{
    // Configure ALL options instances, both named and default
    services.ConfigureAll<SlackApiSettings>(options => options.DisplayName = "Unknown"); 

    // Override values for named options
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel"));

    // Add ALL options configuration AFTER other configuration (in this case)
    services.AddSingleton<IConfigureOptions<SlackApiSettings>, ConfigureAllSlackApiSettings>);
}

With Configure<T>(), ConfigureAll<T>(), IConfigureOptions<T>, and IConfigureNamedOptions<T> you have a wide range of tools for configuring both the default options and the named options in your application. IConfigureNamedOptions<T> is especially flexible - it's easy to apply configuration to all of your options instance, to a subset, or to a specific named instance.

However, as always, it's best to choose the simplest approach that gets the job done. Don't need named options? Don't use them. Need to bind to a configuration section? Just use Configure<T>(). KISS rules, but it's good to know the flexibility is there if you need it.

Summary

In this post I described how the default options object is a special case of named options, with a name of string.Empty. I showed how you could configure options that required other injected services by implementing IConfigureNamedOptions<T>, and how you could limit which options it applies too.

I also showed how you can apply configuration to all options, including both named and default instances, using the ConfigureAll<T>() extension method. Finally, I showed how you could achieve the same thing using IConfigureNamedOptions<T> when you need access to other services for configuration.

If you're implementing IConfigureNamedOptions<T> it's important to consider the lifecycle of the services you're using. In particular, you'll need to take extra steps to consume Scoped services, as I described in my previous post.


Viewing all articles
Browse latest Browse all 743

Trending Articles