В предыдущем посте (оригинал, перевод) я описал как можно использовать атрибуты DataAnnotation и новый метод ValidateOnStart(), чтобы проверить валидность вашей конфигурации на старте приложения

В этом посте я покажу как сделать то же самое используя популярную библиотеку с открытым исходным кодом FluentValidation. Для этого понадобится добавить несколько вспомогательных классов.

Предыдущий пост включает описание строго типизированной конфигурации в целом и все возможные варианты получить ошибку, так что если вы сталкиваетесь с этой темой впервые, предлагаю сначала ознакомиться с предыдущей статьёй. Здесь, перед тем, как перейти к FluentValidation я быстренько повторюсь как валидация IOptions работает с атрибутами DataAnnotation.

Проверка IOptions на старте приложения

В .NET проверка конфигурации появилась ещё в .NET Core 2.2 с методами Validate<> и ValidateDataAnnotations(), но они вызывались не на старте приложения, а после попытки доступа к объекту конфигурации.

В .NET 6 появился новый метод, ValidateOnStart(), который запускает валидацию сразу же после запуска приложения.

Чтобы использовать такую валидацию, нужно сделать четыре вещи:

  • Связать объект конфигурации из файла с IOptions<T>
  • Добавить атрибуты валидации к объекту конфигурации
  • Вызвать ValidateDateAnnotations() OptionsBuilder’а, возвращённого из AddOptions<T>()
  • Вызвать ValidateOnStart() OptionsBuilder’а.

В примере ниже, я настроил валидацию конфига для объекта SlackApiSettings:

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<SlackApiSettings>()
.BindConfiguration("SlackApi") // 👈 Связать секцию SlackApi из конфигурации
.ValidateDataAnnotations() // 👈 Включить валидацию
.ValidateOnStart(); // 👈 Валидировать на старте приложения
var app = builder.Build();
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);
app.Run();
public class SlackApiSettings
{
[Required, Url]
public string WebhookUrl { get; set; }
[Required]
public string DisplayName { get; set; }
public bool ShouldNotify { get; set; }
}

Теперь создадим файл конфигурации с ошибкой, например, удалив обязательное значение DisplayName:

{
"SlackApi": {
"WebhookUrl": "http://example.com/test/url",
"DisplayName": null,
"ShouldNotify": true
}
}

Теперь, если вы запустите приложение, то получите исключение сразу, а не в момент обращения к конфигурации:

Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:
DataAnnotation validation failed for 'SlackApiSettings' members:
'DisplayName' with the error: 'The DisplayName field is required.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.OptionsMonitor`1.<>c__DisplayClass10_0.<Get>b__0()

Валидация IOptions с использованием FluentValidation

Атрибуты DataAnnotation это довольно простой способ проверки данных, который перестаёт работать в более сложных случаях. Поэтому заменим его на популярную альтернативу FluentValidation

Эта статья не учебник по FluentValidation, а лишь простой пример минимально необходимой настройки для валидации на старте

1. Создание проекта

Создадим минимальное API для тестирования и добавим зависимость FluentValidation:

Terminal window
dotnet new web
dotnet add package FluentValidation

Затем заменим содержимое Program.cs простым API, которое использует строго типизированный объект конфигурации (SlackApiSettings) и выводит его значение при запросе:

using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<SlackApiSettings>()
.BindConfiguration("SlackApi") // Связывание с секцией SlackApi в конфиге
var app = builder.Build();
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value); // Вывод объекта SlackApiSettings
app.Run();
public class SlackApiSettings
{
public string? WebhookUrl { get; set; }
public string? DisplayName { get; set; }
public bool ShouldNotify { get; set; }
}

В этом простом приложении мы связываем объект SlackApiSettings с секцией SlackApi в конфигурации. Простое API возвращает содержимое конфига в формате JSON:

{
"webhookUrl": null,
"displayName": null,
"shouldNotify": false
}

2. Добавление валидатора FluentValidator

В документации можно подробнее прочитать про то, как создавать валидаторы и правила. Ниже приведён пример того, как можно переписать валидацию на DataAnnotations из начала статьи с помощью AbstractValidator<T>

public class SlackApiSettingsValidator : AbstractValidator<SlackApiSettings>
{
public SlackApiSettingsValidator()
{
RuleFor(x => x.DisplayName)
.NotEmpty(); // не пустой
RuleFor(x => x.WebhookUrl)
.NotEmpty()
// .MustAsync((_, _) => Task.FromResult(true)) 👈 нельзя использовать асинхронные валидаторы
.Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
.When(x => !string.IsNullOrEmpty(x.WebhookUrl));
}
}

Важный момент: Хотя это (скорее всего) не станет проблемой, но стоит держать в голове, что здесь нельзя использовать асинхронные правила, так как интерфейс IValidateOptions<T>, который мы будем использовать дальше только синхронный.

3. Создание метода расширения ValidateFluentValidation

Следующий этап самый важный. Нужно добавить альтернативу метода ValidateDataAnnotations для FluentValidation, который я назвал ValidateFluentValidation. Это расширение довольно простое и похоже на версию для DataAnnotation:

public static class OptionsBuilderFluentValidationExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
{
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
provider => new FluentValidationOptions<TOptions>(
optionsBuilder.Name, provider));
return optionsBuilder;
}
}

Этот метод расширения OptionsBuilder<T> добавляет новый сервис FluentValidationOptions<T> в DI-контейнер и регистрирует его как IValidateOptions<T>. В FluentValidationOptions<T> и происходит вся магия. Здесь кода сильно больше, поэтому всё прокомментировано:

public class FluentValidationOptions<TOptions>
: IValidateOptions<TOptions> where TOptions : class
{
private readonly IServiceProvider _serviceProvider;
private readonly string? _name;
public FluentValidationOptions(string? name, IServiceProvider serviceProvider)
{
// service provider понадобится ниже, чтобы создать scope
_serviceProvider = serviceProvider;
_name = name; // Обработка именованных параметров
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
// Null в имени используется для настройки всех именованных параметров
if (_name != null && _name != name)
{
// Игнорируется, если этот экземпляр не валидируется
return ValidateOptionsResult.Skip;
}
// Убедиться, что опции были переданы
ArgumentNullException.ThrowIfNull(options);
// Валидаторы обычно регистрируются как scoped
// так что нужно создать scope, чтобы быть уверенными, так как этот метод
// будет вызываться из главного scope
using IServiceScope scope = _serviceProvider.CreateScope();
// получаем объект валидатора
var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
// Запускаем валидацию
ValidationResult results = validator.Validate(options);
if (results.IsValid)
{
// Всё успешно
return ValidateOptionsResult.Success;
}
// Валидация провалилась, собираем сообщение об ошибке
string typeName = options.GetType().Name;
var errors = new List<string>();
foreach (var result in results.Errors)
{
errors.Add($"Fluent validation failed for '{typeName}.{result.PropertyName}' with the error: '{result.ErrorMessage}'.");
}
return ValidateOptionsResult.Fail(errors);
}
}

Код выше немного сложнее оригинального IValidateOptions, так как добавляет две особенности:

  • IOptions<T> поддерживает именованные параметры. Они используются редко; чаще всего в аутентификации, например. Больше о них можно прочитать в статье автора.
  • IValidateOptions вызывается из IOptionsMonitor, который регистрируется как синглтон. Поэтому наш объект FluentValidationOptions тоже должен регистрироваться как синглтон. Однако обычно валидаторы FluentValidation регистрируются как scoped. Из-за этого несоответствия мы не можем внедрить IValidator<T> в конструктор FluentValidationOptions и должны сначала создать IServiceScope

За исключением предыдущих двух пунктов, код довольно простой. Он запускает validator.Validate() и возвращает соответствующий результат.

NB: Этот класс требует того, чтобы IValidator<T> был зарегистрирован для конкретного типа в DI

Теперь мы готовы собрать все кусочки пазла воедино, чтобы валидировать конфигурацию на старте приложения.

Собираем всё вместе

Если мы соединим весь код из предыдущих шагов, зарегистрируем валидатор, полное приложение будет выглядеть как-то так:

using FluentValidation;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// 👇 Регистрируем валидатор
builder.Services.AddScoped<IValidator<SlackApiSettings>, SlackApiSettingsValidator>();
builder.Services.AddOptions<SlackApiSettings>()
.BindConfiguration("SlackApi") // 👈 Связываем объект SlackApi из конфигурации с SlackApiSettings
.ValidateFluentValidation() // 👈 Включаем валидацию
.ValidateOnStart(); // 👈 Валидируем на старте приложения
var app = builder.Build();
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);
app.Run();
public class SlackApiSettings
{
public string? WebhookUrl { get; set; }
public string? DisplayName { get; set; }
public bool ShouldNotify { get; set; }
}
public class SlackApiSettingsValidator : AbstractValidator<SlackApiSettings>
{
public SlackApiSettingsValidator()
{
RuleFor(x => x.DisplayName).NotEmpty();
RuleFor(x => x.WebhookUrl)
.NotEmpty()
.Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
.When(x => !string.IsNullOrEmpty(x.WebhookUrl));
}
}
public static class OptionsBuilderFluentValidationExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
{
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
provider => new FluentValidationOptions<TOptions>(optionsBuilder.Name, provider));
return optionsBuilder;
}
}
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
private readonly IServiceProvider _serviceProvider;
private readonly string? _name;
public FluentValidationOptions(string? name, IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_name = name;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (_name != null && _name != name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
using var scope = _serviceProvider.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
var results = validator.Validate(options);
if (results.IsValid)
{
return ValidateOptionsResult.Success;
}
string typeName = options.GetType().Name;
var errors = new List<string>();
foreach (var result in results.Errors)
{
errors.Add($"Fluent validation failed for '{typeName}.{result.PropertyName}' with the error: '{result.ErrorMessage}'.");
}
return ValidateOptionsResult.Fail(errors);
}
}

Теперь, если мы запустим приложение с ошибочной конфигурацией, получим исключение на старте приложения, как мы и хотели:

Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Fluent validation failed for 'SlackApiSettings.DisplayName' with the error: ''Display Name' must not be empty.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)

Пишем метод расширения

Сейчас нужно помнить добавлять валидатор для настроек, включать валидацию для объекта конфига и включать валидацию на старте. Если вы хотите, вместо этого можно написать метод расширения, который будет делать это за вас:

public static class FluentValidationOptionsExtensions
{
public static OptionsBuilder<TOptions> AddWithValidation<TOptions, TValidator>(
this IServiceCollection services,
string configurationSection)
where TOptions : class
where TValidator : class, IValidator<TOptions>
{
// Добавляем валидатор
services.AddScoped<IValidator<TOptions>, TValidator>();
return services.AddOptions<TOptions>()
.BindConfiguration(configurationSection)
.ValidateFluentValidation()
.ValidateOnStart();
}
}

Тогда ваше приложение станет настолько простым:

using FluentValidation;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// 👇 Регистрируем валидатор и параметры
builder.Services.AddWithValidation<SlackApiSettings, SlackApiSettingsValidator>("SlackApi")
var app = builder.Build();
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);
app.Run();

Вывод

В этом посте я показал вам, как можно использовать FluentValidation для ваших строго типизированных объектов конфигурации IOptions<> в ASP.NET Core. Я создал версию ValidateDataAnnotations() как метод расширения ValidateFluentValidation(). В сочетании с ValidateOnStart() (и зарегистрированным IValidator<T>), получаем валидацию конфигов на старте приложения. Это позволяет быть уверенным в том, что ошибки конфигурации всплывут как можно раньше, вместо появления ошибок в рантайме.