In recent posts I've been discussing some of the lesser known features of the Options system in ASP.NET Core 2.x. In the first of these posts, I described how to use named options when you want to have multiple instances of a strongly-typed setting, each with a different name. If you're new to them, I recommend reading that post for an introduction to named options, when they make sense, and how to use them.
In this post I'm going to address a limitation with the named options approach seen previously. Namely, the documented way to access named options is with the IOptionsSnaphshot<T>
interface. Accessing named options in this way means they always have a Scoped lifecycle, and are re-bound to the underlying configuration with every request. In this post I introduce the IOptionsMonitor<T>
interface, and show how you can use it to create Singleton named options.
Named options are always scoped with IOptionsSnapshot<>
So far, we've mainly looked at two different interfaces for accessing your strongly-typed settings: IOptions<T>
and IOptionsSnapshot<T>
.
For IOptions<T>
:
Value
property contains the default, strongly-typed settings objectT
.- Is a Singleton - cache's the
T
instance for the lifetime of the app. - Creates and populates the
T
instance the first time anIOptions<T>
instance is requested and theValue
property is accessed.
Whereas for IOptionsSnapshot<T>
:
Value
property contains the default, strongly-typed settings objectT
Get(name)
method is used to fetch named options forT
.- Is Scoped - caches
T
instances for the lifetime of the request. - Creates and populates the default and named
T
instances the first time they're accessed each request.
To me, the IOptionsSnapshot<T>
feels a little bit messy, as it differs from the basic IOptions<T>
interface in two orthogonal ways:
- It allows you to use named options.
- It is Scoped, and responds to changes in the underlying
IConfiguration
object.
If you wish to have your strongly-typed settings objects automatically change when someone updates the appsettings.json file (for example) then IOptionsSnapshot<T>
is definitely the interface for you.
However, if you just want to use named options and don't care about the "reloading" behaviour, then the fact that IOptionsSnapshot<T>
is Scoped is actually detrimental. You can't inject Scoped dependencies into Singleton services, which means you can't easily use named options in Singleton services. Also, if you know that the underlying configuration files aren't going to change (or you don't want to respect those changes), then re-binding the configuration to a new T
settings object every request is very wasteful. That's a lot of pointless reflection and garbage to be collected for no benefit.
So what if you want to use named options, but you want them to be Singletons, not Scoped? There's a few options available to you, some cleaner than others. I'll discuss three of those possibilities in this post.
1. Casting IOptions<T>
to IOptionsSnapshot<T>
The IOptionsSnapshot<T>
interface extends the IOptions<T>
interface, adding support for named options with the Get(name)
method:
public interface IOptionsSnapshot<out T> : IOptions<T> where T : class, new()
{
T Get(string name);
}
With that in mind, the suggestion "cast IOptions<T>
to IOptionsSnapshot<T>
" doesn't seem to makes sense; we could safely cast an IOptionsSnapshot<T>
instance to IOptions<T>
, but not the other way around, surely?
Strictly speaking, that's correct. However, in ASP.NET Core 2.x, both IOptions<T>
and IOptionsSnapshot<T>
are implemented by the OptionsManager<T>
class. This type is registered for both interfaces, in the AddOptions()
extension method.
public static class OptionsServiceCollectionExtensions
{
public static IServiceCollection AddOptions(this IServiceCollection services)
{
// Both IOptions<T> and IOptionsSnapshot<T> are implemented by OptionsManager<T>
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
// I'll get to this one later
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));
// Other depedent services elided
return services;
}
}
This extension method is called by the framework in ASP.NET Core, so you rarely have to call it directly yourself.
Assuming you don't override this registration somehow (you really shouldn't!) then you can be pretty confident that any IOptions<T>
instance is actually an OptionsManager<T>
instance, and hence implements IOptionsSnapshot<T>
. That means the following code is generally going to be safe:
public class MySingletonService
{
// Inject the singleton IOptions<T> instance
public MySingletonService(IOptions<SlackApiSettings> options)
{
// Cast to IOptionsSnapshot<T>.
// Safe as options.GetType() = typeof(OptionsManager<SlackApiSettings>)
var optionsSnapshot = options as IOptionsSnapshot<SlackApiSettings>;
// Access Singleton named options
var namedOptions = optionsSnapshot.Get("MyName");
}
}
With this approach, we get Singleton named option with very little drama. We know the OptionsManager<T>
instance injected into the service is a Singleton, so it's still a singleton after casting to IOptionsSnapshot<T>
. The "MyName"
named options are bound only once, the first time they're requested, and they're cached for the lifetime of the app. Another benefit is that we didn't have to mess with the DI registrations at all.
It doesn't feel very nice though, does it? It requires explicit knowledge of the underlying DI configuration, and is definitely not obvious. Instead of casting interfaces around, we could just use the OptionsManager<T>
directly.
2. Using OptionsManager<T>
directly
As you've already seen, OptionsManager<T>
is the class that implements both IOptions<T>
and IOptionsSnapshot<T>
. We could just directly inject this class into our services, and access the implementation methods directly:
public class MySingletonService
{
public MySingletonService(OptionsManager<SlackApiSettings> options)
{
// No need to cast, as implements IOptionsSnapshot<T>
var namedOptions = options.Get("MyName");
var defaultOptions = options.Value;
}
}
Unfortunately, we also need to register OptionsManager<T>
as a Singleton service in ConfigureServices()
:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(typeof(OptionsManager<>));
}
Even though this approach avoids the dirty cast, it's not ideal. Your services now depend on a specific implementation instead of an interface, and we had to add an extra registration with DI.
It's also important to realise that even though all IOptions<T>
and IOptionsSnapshot<T>
instances will be OptionsManager<T>
instances, they will all be different objects within a given context. This is due to the lack of "forwarding types" in the default ASP.NET Core DI container:
public class MyScopedService
{
public MyScopedService(
IOptions<SlackApiSettings> options, // Implemented by OptionsManager<SlackApiSettings>
IOptionsSnapshot<SlackApiSettings> optionsSnapshot, // Implemented by OptionsManager<SlackApiSettings>
OptionsManager<SlackApiSettings> optionsManager) // Is OptionsManager<SlackApiSettings>
{
Assert.AreSame(options, optionsManager); //FALSE
Assert.AreSame(optionsSnapshot, optionsManager); //FALSE
Assert.AreSame(optionsSnapshot, options); //FALSE
Assert.AreSame(options.Value, optionsManager.Value); //FALSE
Assert.AreSame(optionsSnapshot.Value, optionsManager.Value); //FALSE
Assert.AreSame(optionsSnapshot.Value, options.Value); //FALSE
}
}
It's pretty unlikely that this will actually cause any issues in practice. Strongly-typed settings are typically (and arguably should be) dumb POCO objects that are treated as immutable once created. So even though they may not actually be singletons (the IOptions<T>
instance has one copy, and the OptionsManager<T>
instance has another), they will have the same values. Just don't go storing state in them!
So if we're not quite happy with OptionsManager<T>
, what's left?
3. Using IOptionsSnapshot<T>
's cousin: IOptionsMonitor<T>
When I showed the AddOptions()
extension method previously, I mentioned a registration we'd come back to: IOptionsMonitor<T>
.
public interface IOptionsMonitor<out T>
{
T CurrentValue { get; }
T Get(string name);
IDisposable OnChange(Action<T, string> listener);
}
IOptionsMonitor<T>
is a bit like IOptions<T>
in some ways and IOptionsSnapshot<T>
in others:
- It's registered as a Singleton (like
IOptions<T>
) - It contains a
CurrentValue
property that gets the default strongly-typed settings object as a Singleton (likeIOptions<T>.Value
) - It has a
Get(name)
method for returning named options (likeIOptionsSnapshot<T>
). UnlikeIOptionsSnapshot<T>
, these named options are Singletons. - Responds to changes in the underlying
IConfiguration
object by re-binding options. Note this only happens when the configuration changes (not every request likeIOptionsSnapshot<T>
does).
IOptionsMonitor<T>
is itself a Singleton, and it caches both the default and named options for the lifetime of the app. However, if the underlying IConfiguration
that the options are bound to changes, IOptionsMonitor<T>
will throw away the old values, and rebuild the strongly-typed settings. You can register to be informed about those changes with the OnChange(listener)
method, but I won't go into that in this post.
Using named options in singleton services is now easy with IOptionsMonitor<T>
:
public class MySingletonService
{
public MySingletonService(IOptionsMonitor<SlackApiSettings> options)
{
var namedOptions = options.Get("MyName");
var defaultOptions = options.CurrentValue; // Note CurrentValue, not Value
}
}
IOptionsMonitor<T>
has a lot of advantages in this case:
- Registered as a singleton by default
- Your services depend on an interface instead of a concrete implementation
- No safe/unsafe casts required
The only thing to remember is that CurentValue
(and the values from Get()
) are just that, the current values. While they're the only instance at any one time, they will change if the underlying IConfiguration
changes. If that's not a concern for you, then IOptionsMonitor<T>
is probably the way to go.
This doesn't solve the issue of taking a dependency on the
IOptions*
interfaces in general. There are ways to avoid it when usingIOptions<T>
, but you're stuck with it if you're using named options.
Summary
Named options solve a specific use case - where you want to have multiple instance of a strongly-typed configuration object. You can access named options from the IOptionsSnapshot<T>
interface. However, the strongly-typed settings object you get T
will be recreated with every request, and can only be used in a Scoped context. Sometimes, for performance or convenience reasons, you might want to access named options from a Singleton service.
There are several ways to achieve this. You can:
- Inject an
IOptions<T>
instance and cast it toIOptionsSnapshot<T>
. - Register
OptionsManager<T>
as a Singleton in the DI container, and inject it directly. - Use
IOptionsMonitor<T>
. Be aware that if the underlying configuration changes, the singleton objects will change too.
Of these three options, I think IOptionsMonitor<T>
provides the best solution, though it's important to be aware of the behaviour when the underlying IConfiguration
object changes.