Walidacja opcji
Wstęp
Kiedy tworzysz opcje do swojego programu, warto dodatkowo je walidować. Pewnie, że nie wszystkie. Jednak daje to większe poczucie spokoju, zwłaszcza kiedy aplikacja chodzi na różnych środowiskach (chociażby produkcyjne i deweloperskie). Jeśli ktoś przez pomyłkę źle skonfiguruje system, to nie będzie on działał poprawnie. Co więcej, przez długi czas możesz się o tym nie dowiedzieć. Niepoprawne opcje mogą przez dłuższy czas nie dawać o sobie znaku. Walidacja ich od razu może rozwalić program. Od razu będzie wiadomo, że coś jest nie tak.
W tym artykule pokażę Ci jak możesz walidować swoje opcje zarówno za pomocą adnotacji, jak i fluent validation.
Jeśli nie wiesz jak konfigurować opcje w .NET, koniecznie przeczytaj ten artykuł: Konfiguracja i opcje programu w .NET.
Przykładowy projekt
Do tego artykułu przygotowałem przykładowy projekt, który możesz pobrać z GitHuba: https://github.com/AdamJachocki/OptionsValidation/tree/master
Podstawy walidacji
.NET umożliwia Ci walidację opcji na kilka różnych sposobów. Możesz sprawdzać typowymi adnotacjami (DataAnnotations) w klasie modelu opcji. Pisałem już o tym w artykule o walidacji.
Załóżmy więc, że mamy taki prosty model opcji:
public class SimpleOptions
{
[EmailAddress]
public string SenderEmail { get; set; }
[Required]
public string SmtpAddress { get; set; }
}
Jak widać, walidujemy tutaj za pomocą adnotacji. Pole SenderEmail
musi być adresem e-mail, natomiast pole SmtpAddress
jest wymagane.
Teraz, żeby uruchomić walidację, trzeba nieco inaczej skonfigurować te opcje niż w sposób domyślny opisany w tym artykule. Teraz zamiast metody Configure
, użyjemy AddOptions
, które zwraca obiekt klasy OptionBuilder
, który z kolei umożliwia walidacje:
services.AddOptions<SimpleOptions>()
.Bind(Configuration.GetSection("SimpleOptions"))
.ValidateDataAnnotations();
Zauważ, że używając OptionBuildera
, trzeba użyć metody Bind
do powiązania tych opcji i na koniec ValidateDataAnnotations
, co uruchomi walidację tych opcji, używając adnotacji. Tylko adnotacji. Pamiętaj o tym.
Teraz, jeśli jakieś opcje nie będą spełniały założeń, podczas ich wstrzykiwania pójdzie wyjątek. Np. spójrz na taki appsettings.json:
{
"SimpleOptions": {
"SenderEmail": "admin@example.com"
}
}
Jak widzisz, nie ma tutaj w ogóle pola SmtpAddress
, które jest wymagane w naszym modelu. Teraz, jeśli chcielibyśmy takie opcje odczytać np.:
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
private readonly SimpleOptions _simpleOptions;
public TestController(IOptions<SimpleOptions> simpleOptions)
{
_simpleOptions = simpleOptions.Value;
}
}
to w linijce 8 dostaniemy wyjątek. Takich opcji nie można pobrać, bo nie spełniają warunków.
Problemem jest to, że program musi dojść do tego miejsca, żeby opcje zostały zwalidowane. Na szczęście w .NET6 można sprawdzić konkretne opcje już podczas uruchamiania aplikacji, co jest naprawdę mega użyteczne. Piszę o tym później.
Oczywiście sam możesz pisać własne atrybuty walidacyjne, o czym pisałem tutaj. Wystarczy napisać klasę dziedziczącą po ValidationAttribute
.
To prosta walidacja. Nie można za jej pomocą zrobić bardziej wyrafinowanych sprawdzeń. A jeśli można to jest to uciążliwe. Dlatego dla takich scenariuszy przychodzi kolejna możliwość…
Własny walidator
Wystarczy stworzyć własny walidator – klasę, która implementuje interfejs IValidateOptions
. Nic nie stoi na przeszkodzie, żeby Twój model ten interfejs implementował, jednak z punktu widzenia czystości kodu, to nie jest dobre rozwiązanie. Pamiętaj o tym.
Stworzę zatem osobną klasę, która będzie walidować taki model:
public class ApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
}
Te przykładowe opcje pozwalają się łączyć z hipotetycznym API. Założenie jest takie, że albo podajemy ClientId
i ClientSecret
(który musi być odpowiedniej długości), albo podajemy ClientUri
. Napiszmy teraz walidator do tego. Zacznijmy od pustego:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
}
}
Jak widzisz, interfejs IValidateOptions
posiada tylko jedną metodę do implementacji. W parametrze name
dostaniesz nazwę tych opcji, jeśli używasz named options. Natomiast w parametrze options
otrzymasz cały model odczytany z konfiguracji. I teraz możesz go sprawdzić np. w taki najprostszy sposób:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
if (isIdAndSecret && isUri)
return ValidateOptionsResult.Fail("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
return ValidateOptionsResult.Fail("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
return ValidateOptionsResult.Fail("Client secret jest za krótki");
return ValidateOptionsResult.Success;
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Po prostu wykonujemy kilka prostych sprawdzeń i albo zwracamy ValidateOptionsResult.Success
, albo Fail
. W przypadku, jeśli walidacja się nie powiedzie, zachowanie będzie identyczne jak przy walidacji adnotacjami. Program się wywali na próbie pobrania opcji.
Teraz tylko trzeba to zarejestrować w nieco inny sposób. Możemy posłużyć się zarówno OptionsBuilderem
jak i konfiguracją, jednak trzeba dodatkowo zarejestrować takiego walidatora:
builder.Services.Configure<ApiOptions>(builder.Configuration.GetSection("ApiOptions"));
builder.Services.AddSingleton<IValidateOptions<ApiOptions>, ApiOptionsValidator>();
Nowość w .NET8
W .NET8 wprowadzono budowniczego ValidateOptionsResultBuilder, którym możesz sobie zbudować cały rezultat jeśli chcesz. Dzięki temu możesz zwrócić kilka błędów. Powyższy kod mógłby wyglądać tak:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
ValidateOptionsResultBuilder builder = new();
if (isIdAndSecret && isUri)
builder.AddError("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
builder.AddError("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
builder.AddResult(ValidateOptionsResult.Fail("Client secret jest za krótki"));
return builder.Build();
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Zaznaczyłem znaczące linie. Jak widzisz, możesz do buildera dodawać zarówno errory jak i całe obiekty ValidateOptionsResult.
Dzięki temu możesz pokazać wszystkie problemy związane z opcjami, a nie tylko jeden.
Walidacja w OptionsBuilderze
Ten sposób walidacji zostawiam raczej jako ciekawostkę. W małych systemach pewnie się sprawdzi, natomiast w większych lepiej go unikać.
Można napisać kod walidacyjny podczas rejestrowania opcji:
builder.Services.AddOptions<ApiOptions>()
.Bind(builder.Configuration.GetSection("ApiOptions"))
.Validate(options =>
{
if (string.IsNullOrWhiteSpace(options.ClientUri)
&& string.IsNullOrWhiteSpace(options.ClientId)
&& string.IsNullOrWhiteSpace(options.ClientSecret))
return false;
return true;
});
To tylko fragment wcześniejszej walidacji. Musiałbym napisać resztę przypadków, ale to nie ma sensu (bo to tylko przykład). Tutaj metoda Validate
przyjmuje delegat – funkcję, która zwraca bool
, a w parametrze ma model opcji.
Dlaczego to nie ma sensu? To chyba widać. W przypadku większej ilości opcji lub bardziej wyrafinowanych walidacji w kodzie po prostu zrobi się burdel i całość stanie się mało czytelna.
Tak jak mówiłem wcześniej – w małych, szybkich projektach to się może sprawdzić. Natomiast w większych raczej nie.
Walidacja przy starcie systemu
Domyślny mechanizm będzie walidował opcje dopiero w momencie próby ich pobrania: var myOptions = options.Value
. Natomiast możesz sobie życzyć, żeby opcje były sprawdzane podczas uruchamiania programu. Plusem tego jest to, że od razu dowiesz się, że coś jest nie tak, bo apka wywali się podczas uruchamiania. Minus? Aplikacja będzie potrzebować nieco więcej czasu, żeby się uruchomić, ponieważ będzie sprawdzać opcje, które wskażesz. Myślę jednak, że warto to zrobić, bo od razu dostajesz wiedzę, że coś jest źle skonfigurowane.
Wystarczy, że wywołasz metodę ValidateOnStart
z OptionsBuilder
:
builder.Services.AddOptions<SimpleOptions>()
.Bind(builder.Configuration.GetSection("SimpleOptions"))
.ValidateDataAnnotations()
.ValidateOnStart();
Pamiętaj, że metoda ValidateOnStart
doszła dopiero w .NET6. Dlatego jeśli masz projekt we wcześniejszej wersji przemyśl migrację do .NET6.
Walidacja typu FLUENT
Na koniec pokażę jak zaprząc do walidacji opcji znaną i lubianą bibliotekę open source – FluentValidation. Od razu zaznaczam, że ten artykuł nie jest kursem ani nawet nie muska działania tej biblioteki. Jeśli wiesz, co ona robi, ten akapit może Ci się przydać. W innym przypadku spróbuj się z nią najpierw zapoznać.
FluentValidation umożliwia walidacje w sposób „fluent” modeli. Jednak standardowo nie obsługuje opcji. Można to w dość prosty sposób zmienić.
Spójrzmy na przykładowy model opcji:
public class FluentApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
public string ApiUrl { get; set; }
}
Założenia będą takie jak w poprzednim przykładzie, tzn. podajemy albo clientId
i secret
, albo clientUri
. Jedno z dwóch. Dodatkowo zawsze musi być ApiUrl
.
Walidator Fluenta
Napiszmy do tego standardowy walidator fluentowy:
public class FluentApiOptionsValidator: AbstractValidator<FluentApiOptions>
{
public FluentApiOptionsValidator()
{
RuleFor(x => x.ApiUrl)
.NotEmpty();
RuleFor(x => x.ClientUri)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientId) && string.IsNullOrWhiteSpace(x.ClientSecret))
.WithMessage("Jeśli nie podajesz clientId i sekretu, musisz podać ClientUri")
.MinimumLength(5)
.WithMessage("ClientUri jest za krótkie");
RuleFor(x => x.ClientId)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientUri))
.WithMessage("Musisz podać ClientId i sekret, jeśli nie podajesz ClientUri");
RuleFor(x => x.ClientSecret)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ClientId))
.WithMessage("Brak client secret");
}
}
I dopiero teraz zacznie się zabawa.
Mając już walidator do konkretnego modelu, musimy teraz stworzyć swój własny walidator opcji – ten, implementujący interfejs IValidateOptions
. Dlaczego?
Integracja z opcjami
Jak już mówiłem, FluentValidation nie jest domyślnie zintegrowany z mechanizmem opcji w .NET. A szkoda, bo mógłby być. Zatem sami musimy sobie taką integrację zapewnić. I tutaj przychodzi z pomocą IValidateOptions
. Utworzymy generyczny walidator, żeby można go było używać z każdym typem opcji. To w najprostszej postaci może wyglądać tak:
public class GenericFluentValidator<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IServiceProvider _serviceProvider;
private readonly string _name;
public GenericFluentValidator(string name, IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_name = name;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (_name != null && _name != name)
return ValidateOptionsResult.Skip;
using var scope = _serviceProvider.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
ValidationResult res = validator.Validate(options);
if (res.IsValid)
return ValidateOptionsResult.Success;
var errorArray = res.Errors.Select(e => e.ErrorMessage).ToArray();
var msg = string.Join(Environment.NewLine, errorArray);
return ValidateOptionsResult.Fail(msg);
}
}
To na pierwszy rzut oka może okazać się zawiłe, ale jest naprawdę bardzo proste.
Pomińmy na razie konstruktor – w jakiś sposób dostaniemy IServiceProvider
i nazwę (jeśli używamy named options). Przejdźmy od razu do metody Validate
.
Najpierw sprawdzamy, czy używamy named options i czy to odpowiedni do tego walidator. Jeśli nie, to olewamy.
Następnie tworzymy sobie scope’a, żeby pobrać z niego serwis implementujący IValidator<TOptions>
. A teraz pytanie – co to takiego? Interfejs IValidator<T>
pochodzi z FluentValidation. Wszystkie walidatory ten interfejs implementują. A więc po prostu szukamy walidatora dla konkretnego typu.
Gdy już mamy go, to w linijce 21 uruchamiamy walidację. Jeśli się udała, zwracamy sukces. Jeśli nie, zwracamy listę błędów z tego walidatora w postaci jednego stringa.
Rejestracja
Teraz, jak już wiesz, trzeba zarejestrować ten GenericFluentValidator
:
builder.Services.AddSingleton<IValidateOptions<FluentApiOptions>>(sp =>
{
return new GenericFluentValidator<FluentApiOptions>("", sp);
});
Po prostu dodajemy go tak jak w poprzednich przykładach, tyle że z wykorzystaniem fabryki – dzięki czemu możemy przekazać IServiceProvidera
. Parametrem name
na razie się nie przejmuj, zajmiemy się nim później.
Na koniec wystarczy zarejestrować konkretny walidator modelu:
builder.Services.AddSingleton<IValidator<FluentApiOptions>, FluentApiOptionsValidator>();
No i wszystko śmiga. Ale przyznasz, że żeby ogarnąć jeden model w taki sposób, trzeba się sporo napisać. Właściwie upierdliwe są te rejestracje. Ale jest na to metoda…
Upraszczamy rejestracje
Posłużymy się extensioniem, żeby ułatwić sobie pracę. Całą rejestrację przeniesiemy do extensiona. Przy okazji załatwi nam to problem named options:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions, TValidator>(this OptionsBuilder<TOptions> builder)
where TOptions : class
where TValidator: class, IValidator<TOptions>
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
builder.Services.AddSingleton<IValidator<TOptions>, TValidator>();
return builder;
}
}
Dzięki takiemu rozwiązaniu, model opcji możemy zarejestrować w taki sposób:
builder.Services.AddOptions<FluentApiOptions>()
.BindConfiguration("FluentApiOptions")
.ValidateFluentValidation<FluentApiOptions, FluentApiOptionsValidator>()
.ValidateOnStart();
Jeśli uważasz, że to wciąż kupa roboty, można to upraszczać dalej.
FluentValidation ma oficjalne rozszerzenie do DependencyInjection. Jeśli używasz FV, to pewnie tego rozszerzenia też:
dotnet add package FluentValidation.DependencyInjectionExtensions
W tym pakiecie znajdują się metody, które automatycznie rejestrują wszystkie walidatory ze wskazanego assembly. Więc teraz wystarczy uprościć nasze rozszerzenie i wywalić z niego rejestrację walidatora:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> builder)
where TOptions : class
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
return builder;
}
}
A na koniec zarejestrować opcje:
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
builder.Services.AddOptions<FluentApiOptions>()
.BindConfiguration("FluentApiOptions")
.ValidateFluentValidation()
.ValidateOnStart();
Teraz jest łatwo, miło i prosto.
I tutaj rodzi się pytanie, czy walidowanie opcji za pomocą FluentValidation ma sens i czy nie jest to przerost formy nad treścią. Jak zwykle – to zależy. Jeśli szybciej/lepiej pracuje Ci się z FluentValidation i widzisz zysk w takim sprawdzaniu, zamiast pisać własny kod walidujący, to na pewno ma to sens, a czas włożony w konfigurację tego ustrojstwa szybko się zwróci. Zwłaszcza, że jest już sporo gotowych walidatorów na „dzień dobry”. A jak widzisz, konfiguracja nie jest aż taka straszna.
Nowości w .NET8
Walidacja opcji bez użycia refleksji – zgodność z AOT
.NET8 przynosi pewną, małą nowość. Kod, który używa refleksji (na przykład ten standardowy sposób walidacji powyżej), nie jest zgodny z AOT. Dlatego też nie moglibyśmy używać walidacji opcji w kompilacji AOT.
Możemy napisać częściowego walidatora, którego kod zostanie wygenerowany automagicznie i ten kod nie będzie już używał refleksji.
Brzmi jak kupa roboty? Może i tak, ale spójrz na to:
public class MyAppConfig
{
[EmailAddress]
[Required]
public string SmtpAdress { get; set; }
[Range(1, 10)]
public int TraceLevel { get; set; }
}
To jest model, który będziemy walidować. A walidator będzie wyglądał tak:
[OptionsValidator]
public partial class MyAppConfigValidator: IValidateOptions<MyAppConfig>
{
}
I to jest dokładnie tyle. Dobrze widzisz. Tutaj są istotne dwie rzeczy:
- klasa musi być oznaczona jako partial
- klasa musi posiadać atrybut [OptionsValidator]
W innym wypadku po prostu się nawet nie skompiluje.
W ostatnim kroku musimy ją jeszcze zarejestrować:
builder.Services.AddSingleton<IValidateOptions<MyAppConfig>, MyAppConfigValidator>();
W efekcie zostanie wygenerowany kod dla klasy MyAppConfigValidator, który będzie miał zaszytą całą logikę walidacji w sobie. I to wszystko zadzieje się bez wykorzystania refleksji. Dzięki temu możesz tego używać w kompilacjach AOT.
Bindowanie opcji bez użycia refleksji – zgodność z AOT
Jeśli chodzi o rejestracje opcji, tj. Configure(TOptions), Bind i Get, to standardowo była do tego wykorzystywana refleksja. W .NET8 w aplikacjach internetowych domyślnie konfiguracja jest realizowana przez generator kodu. Czyli jest to zgodne z AOT i nie wymaga żadnych zmian.
Jeśli jednak chcesz być zgodny z AOT i nie tworzysz aplikacji webowej, musisz na takie działanie jawnie wyrazić zgodę. Wystarczy dodać ustawienie w projekcie:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
A czy ty walidujesz swoje opcje? Daj znać w komentarzu 🙂
Dziękuję Ci za przeczytanie tego artykułu. Wierzę, że walidacja opcji stała się dla Ciebie jasna i będziesz jej używać. Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
No i podziel się tym artykułem z kimś, komu uważasz że się przyda 🙂