[Из песочницы] IOptions и его друзья
Во время разработки часто возникает потребность для вынесения параметров в конфигурационные файлы. Да и вообще — хранить разные конфигурационный константы в коде является признаком дурного тона. Один из вариантов хранения настроек — использования конфигурационных файлов. .Net Core из коробки умеет работать с такими форматами как: json, ini, xml и другие. Так же есть возможность писать свои провайдеры конфигураций. (Кстати говоря за работу с конфигурациями отвечает сервис IConfiguration и IConfigurationProvider — для доступа к конфигурациям определенного формата и для написания своих провайдеров)
Но главная сложность, на мой взгляд, состоит в том, что у нас имеются аж 3 разные интерфейса для работы с конфигурациями. И не всегда понятно для чего они нужны и какой когда нужно использовать.
На MSDN есть статья, которая должна раскрывать все вопросы. Но, как всегда, не все так просто.
IOptions
Does not support:
Reading of configuration data after the app has started.
Named optionsIs registered as a Singleton and can be injected into any service lifetime.
Вцелом, из описания все сразу становится ясно: загружает данные из конфигурационного файла при старте приложения, не подтягивает никаких изменений.
Но у меня большие претензии к Microsoft в плане нейминга. На мой взгляд, если человек недостаточно знаком с технологией, или вообще видит ее в первый раз, то данный интерфейс — это то, что первое придет на ум для использования. И потом, вероятно далеко не сразу, человек выяснит, что то все работает совсем не так, как он задумывал.
Я привык, что довольно большая часть конфигураций в моих проектах может довольно часто меняться. И лично мне хотелось бы, что бы интерфейс, который буквально кричит о том, что его надо использовать для конфигурации вел себя более очевидно. (Хотя конечно я могу быть не прав, и это только мои придирки. А большая часть людей использует файлы конфигураций по-другому)
IOptionsSnapshot
Is useful in scenarios where options should be recomputed on every requestIs registered as Scoped and therefore cannot be injected into a Singleton service.
Supports named options
Должен подходить для большинства ситуаций, о которых я писал выше. Обновляет информацию о конфигурации при каждом запросе. И что немаловажно, не изменяет ее во время запроса.
Подходит для множества случаев, например feature toggle.
MSDN нам говорит, что не может быть заинжекчен в Singletone — на самом деле может (это прям тема для отдельного поста), но тогда и сам он начинает себя вести как Singletone.
IOptionsMonitor
Is used to retrieve options and manage options notifications for TOptions instances.Is registered as a Singleton and can be injected into any service lifetime.
Supports:
Change notifications
Named options
Reloadable configuration
Selective options invalidation (IOptionsMonitorCache)
По сути — это доступ к вашим конфигурациям в режиме реального времени. Тут стоит быть осторожным. И если вы в процессе какого-то запроса читаете конфигурацию несколько раз — стоит быть готовым, что она может измениться.
IOptionsMonitorCache — интерфейс для построения обычного кэша на базе IOptionsMonitor.
Практика
Все тесты проводились на следующем окружении
sw_vers
ProductName: Mac OS X
ProductVersion: 10.15.5
BuildVersion: 19F101
dotnet --version
3.1.301
Мы посмотрели, что говорит нам документация. Теперь давайте посмотри как это работает.
Сразу оставлю ссылку на код, если кто-то захочет ознакомиться подробнее.
В качестве примера будет простое Web API
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseKestrel();
webBuilder.UseStartup();
webBuilder.UseUrls("http://*:5010/");
})
.UseDefaultServiceProvider(options => options.ValidateScopes = false);
}
Клиент, который будет к нему обращаться
static async Task Main(string[] args)
{
using var client = new HttpClient();
var prevResponse = String.Empty;
while (true)
{
var response = await client.GetStringAsync("http://localhost:5010/settings");
if (response != prevResponse) // пишем в консоль только, если настройки изменились
{
Console.WriteLine(response);
prevResponse = response;
}
}
}
В Web API создаем 3 сервиса, который принимает все 3 варианта конфигураций в конструктор и возвращают текущее значение.
private readonly IOptions _testOptions;
private readonly IOptionsSnapshot _testOptionsSnapshot;
private readonly IOptionsMonitor _testOptionsMonitor;
public ScopedService(IOptions testOptions, IOptionsSnapshot testOptionsSnapshot,
IOptionsMonitor testOptionsMonitor)
{
_testOptions = testOptions;
_testOptionsSnapshot = testOptionsSnapshot;
_testOptionsMonitor = testOptionsMonitor;
}
Сервисы будут 3х скоупов: Singletone, Scoped и Transient.
public void ConfigureServices(IServiceCollection services)
{
services.Configure(Configuration.GetSection("TestGroup"));
services.AddSingleton();
services.AddScoped();
services.AddTransient();
services.AddControllers();
}
В процессе работы нашего Web Api изменяем значение TestGroup.Test файла appsettings.json
Имеем следующую картину:
Сразу после запуска
SingletonService IOptions value: 0
SingletonService IOptionsSnapshot value: 0
SingletonService IOptionsMonitor value: 0
ScopedService IOptions value: 0
ScopedService IOptionsSnapshot value: 0
ScopedService IOptionsMonitor value: 0
TransientService IOptions value: 0
TransientService IOptionsSnapshot value: 0
TransientService IOptionsMonitor value: 0
Изменяем нашу настройку и получаем интересную картину
Сразу после изменения
SingletonService IOptions value: 0
SingletonService IOptionsSnapshot value: 0 // не изменилась
SingletonService IOptionsMonitor value: 0 // не изменилась
ScopedService IOptions value: 0
ScopedService IOptionsSnapshot value: // стала пустой
ScopedService IOptionsMonitor value: 0 // не изменилась
TransientService IOptions value: 0
TransientService IOptionsSnapshot value: // стала пустой
TransientService IOptionsMonitor value: 0 // не изменилась
Следующий вывод в консоль (конфиг больше не менялся)
SingletonService IOptions value: 0
SingletonService IOptionsSnapshot value: 0 // не изменилась
SingletonService IOptionsMonitor value: 0 // не изменилась
ScopedService IOptions value: 0
ScopedService IOptionsSnapshot value: changed setting // изменилась
ScopedService IOptionsMonitor value: 0 // не изменилась
TransientService IOptions value: 0
TransientService IOptionsSnapshot value: changed setting // изменилась
TransientService IOptionsMonitor value: 0 // не изменилась
Последний вывод (конфиг также не менялся)
SingletonService IOptions value: 0
SingletonService IOptionsSnapshot value: 0 // не изменилась
SingletonService IOptionsMonitor value: changed setting // изменилась
ScopedService IOptions value: 0
ScopedService IOptionsSnapshot value: changed setting // изменилась
ScopedService IOptionsMonitor value: changed setting // изменилась
TransientService IOptions value: 0
TransientService IOptionsSnapshot value: changed setting // изменилась
TransientService IOptionsMonitor value: changed setting // изменилась
Что имеем в итоге? А имеем то, что IOptionsMonitor — не такой шустрый, как нам говорит документация. Как можно заметить IOptionsSnapshot может вернуть пустое значение. Но, он работает быстрее, чем IOptionsMonitor.
Пока не особо понятно откуда берется это пустое значение. И самое интересное, что подобное поведение проявляется не всегда. Как-то через раз в моем примере IOptionsMonitor и IOptionsSnapshot отрабатывают одновременно.
Выводы
Если вам нужно передавать конфигурацию, которые никогда в процессе жизни вашего приложения не будут меняться используете IOptions.
Если ваши конфигурации будут меняться, то тут все как всегда — зависит. Если Вам важно, что бы в скоупе вашего запроса настройки были релевантны на момент запроса, IOptionsSnapshot — ваш выбор (но не для Singletone, в нем значение никогда не изменится). Но стоит учитывать его странности, хотя и столкнуться с ними вряд ли вам придется.
Если же вам нужны наиболее актуальные значения (или почти) используйте IOptionsMonitor.
Буду рад, если вы запустите пример у себя, и расскажете, повторяется подобное поведение или нет. Возможно мы имеем баг на MacOS, а может это by design.
Продолжу разбираться с этой темой, а пока завел issue, может там прояснят такое поведение.