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.
.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:
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:
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:
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:
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:
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:
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:
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;
}
}
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 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:
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 🙂
Z tego artykułu dowiesz się na czym polega konfiguracja w .NET i jak odczytywać ustawienia na różne sposoby w swoich klasach (IOptions, IOptionsSnapshot, IOptionsMonitor), a także czym są opcje nazwane (named options).
Konfiguracja w .NET to nie tylko IConfigure, czy też IOptions. To naprawdę bardzo fajnie przemyślany mechanizm, który zdecydowanie warto poznać.
Na szybko (kliknij, by rozwinąć)
Jak odczytać ustawienia zagnieżdżone?
Posłuż się dwukropkiem, np. jeśli w plik appsettings.json masz konfigurację:
Na koniec możesz odczytać ich wartości w poszczególnych obiektach za pomocą IOptions<T>, IOptionsSnapshot<T> lub IOptionsMonitor<T> – szczegółowo to jest opisane niżej
Dlaczego .NET nie odczytuje zmiennych środowiskowych do konfiguracji?
Spokojnie, odczytuje. Jeśli uruchamiasz aplikację z wiersza poleceń przez dotnet run, to po zmianie zmiennych środowiskowych zrestartuj wiersz poleceń. Jeśli uruchamiasz z Visual Studio – to po zmianie zrestartuj Visual Studio. Szczegóły w treści artykułu.
appsettings, czy nie appsettings… – czyli dostawcy konfiguracji
Być może nie wiesz, ale w .NET nie musisz trzymać konfiguracji w pliku appsettings. Co więcej, NIE jest to zalecane miejsce dla danych wrażliwych – tak jak skrupulatnie przekonują Cię o tym tutoriale na YouTube.
Jest wiele miejsc, w których możesz trzymać swoją konfigurację (zwłaszcza wrażliwe dane) i sam nawet możesz dopisać własne mechanizmy (np. odczyt konfiguracji z rejestru przy aplikacji desktopowej).
Taki mechanizm odczytywania danych nazywa się configuration provider – czyli dostawca konfiguracji. W .NET masz do dyspozycji kilku takich dostawców, którzy są w stanie pobrać Twoją konfigurację z miejsc takich jak:
plik appsettings.json
zmienne środowiskowe
Azure Key Vault (polecam do trzymania danych wrażliwych)
argumenty linii poleceń
Odczytywanie konfiguracji
Tworząc aplikację poprzez WebApplication.CreateBuilder lub Host.CreateDefaultBuilder, dodajemy m.in. kilku domyślnych providerów, którzy odczytują konfigurację z różnych miejsc i wszystko umieszczają w jednym obiekcie IConfiguration (a konkretniej, to providerzy ze swoimi danymi siedzą w IConfiguration). Konfiguracja jest dostarczana w dwóch etapach (w kolejności):
Konfiguracja hosta, w której są odczytywane:
zmienne środowiskowe z prefixem DOTNET_
zmienne środowiskowe z prefixem DOTNET_ z pliku launchSettings.json
zmienne środowiskowe z prefixem ASPNETCORE_
zmienne środowiskowe z prefixem ASPNETCORE_ z pliku launchSettings.json (przy czym specjalna zmienna: ASPNETCORE_ENVIRONMENT wskazuje na aktualne środowisko (produkcja, development, staging -> to jest ładowane do HostingEnvironment. Jeśli tej zmiennej nie ma, to .NET traktuje to jako środowisko produkcyjne)
parametry z linii poleceń
Konfiguracja aplikacji – w tym momencie znamy już HostingEnvironment (czyli wiadomo, czy to produkcja, develop, staging…)
konfiguracja z pliku appsettings.json
konfiguracja z pliku appsettings.environment.json – gdzie „environment” to określenie aktualnego środowiska („Production”, „Development”, „Staging”…)
konfiguracja z secrets.json
wszystkie zmienne środowiskowe
Pamiętaj, żeby nigdy nie odczytywać aktualnego środowiska z konfiguracji: Configuration["ASPNETCORE_ENVIRONMENT"], bo może to być błędne. Środowisko jest trzymane w IHostingEnvironment i to tego powinieneś używać do odczytu.
Dlaczego możesz się na tym przejechać? Załóżmy, że ktoś z jakiegoś powodu wpisze ustawienie ASPNETCORE_ENVIRONMENT do pliku appsettings.json. I już będzie klops. Bo owszem, ustawienie w obiekcie IConfiguration zostanie „nadpisane”, jednak IHostingEnvironment będzie trzymał zupełnie inne dane.
Co z tymi zmiennymi środowiskowymi i co to launchSettings.json?
Dlaczego .NET nie odczytuje zmiennych środowiskowych?
Czasami możesz odnieść takie wrażenie, że to po prostu nie działa. Też tak miałem, dopóki nie zdałem sobie sprawy z tego, jak naprawdę działają zmienne środowiskowe.
Program odczytuje te zmienne w momencie swojego uruchamiania. I to jest najważniejsze zdanie w tym akapicie. Zmienne środowiskowe nie są „aktualizowane” w aplikacji. Jeśli uruchomisz swoją aplikację z wiersza poleceń (dotnet run), to Twój program otrzyma takie zmienne jakie otrzymał wiersz poleceń podczas swojego uruchamiania.
Jeśli uruchamiasz program z VisualStudio, to Twój program otrzyma takie zmienne, jakie dostał VisualStudio podczas swojego uruchamiania.
Dlatego, jeśli zmieniasz wartości zmiennych środowiskowych, pamiętaj żeby zrestartować wiersz poleceń / Visual Studio. Wtedy Twoja aplikacja dostanie aktualne zmienne.
Jeśli zmieniasz zmienne na poziomie IIS, zrestartuj IIS.
Jest to pewna upierdliwość. Dlatego mamy plik launchSettings.json, w którym możesz sobie poustawiać różne zmienne środowiskowe. Te zmienne będą odczytywane podczas każdego uruchamiania Twojego programu – nie musisz niczego restartować.
Oczywiście pamiętaj, że plik launchSettings.json służy tylko do developmentu. Więc jeśli poustawiasz tam jakieś zmienne, których używasz, pamiętaj żeby ustawić je też na środowisku produkcyjnym.
Nie zdradzaj tajemnicy, czyli secrets.json
Domyślne pliki z ustawieniami – appsettings.json i appsettings.Development.json są przesyłane do repozytorium kodu. Jeśli pracujesz w zamkniętym zespole, to nie ma to większego znaczenia – dopóki w programie nie używasz jakiś swoich prywatnych subskrypcji.
Jeśli w plikach appsettings trzymasz dane wrażliwe (connection stringi, hasła, klucze), to miej świadomość, że one będą widoczne w repozytorium kodu i KAŻDY z dostępem będzie mógł z nich skorzystać (w szczególności GitHub).
Dlatego też powstał plik secrets.json. Aby go utworzyć/otworzyć, kliknij w Visual Studio prawym klawiszem myszy na swój projekt i z menu wybierz Manage User Secrets:
Możesz też użyć .NetCli i wykonać polecenie dotnet user-secrets
Wywołanie w VisualStudio otworzy Ci edytor tekstu taki sam jak dla pliku appsettings. Zresztą secrets.json ma dokładnie taką samą budowę.
Różnica między secrets.json a appsettings.json jest taka, że secrets.json nie znajduje się ani w katalogu z kodem (leży gdzieś tam w AppData), ani w repozytorium. Więc możesz sobie w nim bezkarnie umieszczać wszystkie klucze, hasła itd, których używasz w programie.
Oczywiście możesz mieć różne pliki sekretów w różnych projektach.
Gdzie dokładnie leży plik secrets.json?
W takiej lokalizacji: AppData\Roaming\Microsoft\UserSecrets\{Id sekretów}\secrets.json
Id sekretów to GUID, który jest przechowywany w pliku (csproj) konkretnego projektu.
Kolejność konfiguracji
Jak już zapewne wiesz – .NET odczytuje konfigurację w konkretnej kolejności – opisanej wyżej. A co jeśli w różnych miejscach (np. appsettings.json i secrets.json) będą ustawienia, które tak samo się nazywają? Nico. Ustawienia, które odczytują się później będą tymi aktualnymi. Czyli jeśli w pliku appsetting.json umieścisz:
"tajne-haslo" : ""
I to samo umieścisz w pliku secrets.json, który jest odczytywany później:
"tajne-haslo" : "admin123"
To z konfiguracji odczytasz „admin123”.
Dla wścibskich
Tak naprawdę te wartości nie są nadpisywane i przy odrobinie kombinowania możesz odczytać konkretne wartości z konkretnych miejsc (jako że IConfiguration nie trzyma bezpośrednio tych wartości, tylko ma listę ConfigurationProviderów). Domyślnie .NET szuka klucza „od tyłu” – w odwrotnej kolejności niż były dodawane do IConfiguration, ale moim zdaniem może to być szczegół implementacyjny, który w przyszłości może ulec zmianie. Jednak nie czytałem dokumentacji projektowej.
Pobieranie danych z konfiguracji
Prawdopodobnie to wiesz. Do klasy Startup wstrzykiwany jest obiekt implementujący IConfiguration i wtedy z niego możemy pobrać sobie dane, które nas interesują:
To oczywiście podstawowe pobieranie danych z konfiguracji, przejdźmy teraz do fajniejszych rzeczy.
Tworzenie opcji dla programu
Dużo lepszym i fajniejszym rozwiązaniem jest tworzenie opcji dla komponentów Twojego programu. Załóżmy, że masz serwis do wysyłania e-maili. On może wyglądać tak:
public class EmailService
{
const string OPTION_SMTP_ADDRESS = "https://smtp.example.com";
const string OPTION_FROM = "Admin";
const string OPTION_FROM_EMAIL = "admin@example.com";
public void SendMail(string msg, string subject)
{
}
}
Tutaj najważniejsze jest to, jak masz nazwane poszczególne właściwości. Muszą być tak samo nazwane jak właściwości w Twojej klasie EmailOptions.
A w klasie EmailOptions to MUSZĄ być właściwości do publicznego odczytu i zapisu (nie mogą to być pola).
Jeśli już masz skonstruowaną klasę opcji (EmailOptions) i fragment konfiguracji (np. ten powyżej), możesz podczas konfiguracji serwisów dodatkowo skonfigurować te opcje:
Czyli mówisz: „Klasa EmailOptions ma trzymać dane odczytane z sekcji w konfiguracji o nazwie „EmailSettings”.
Od teraz możesz klasę EmailOptions z wypełnionymi wartościami wstrzykiwać do swoich obiektów na trzy sposoby… Każdy z nich ma swoje wady i zalety.
Interfejs IOptions<T>
To pierwszy sposób pobrania opcji i chyba najprostszy. Wystarczy, że wstrzykniesz IOptions<T> do obiektu, w którym chcesz mieć swoją konfigurację:
public class EmailService
{
EmailOptions options;
public EmailService(IOptions<EmailOptions> options)
{
this.options = options.Value; //pamiętaj, że opcje będziesz miał we właściwości Value
}
public void SendMail(string msg, string subject)
{
}
}
Zobacz jak sprytnie pozbyliśmy się tych brzydkich stałych z kodu na rzecz opcji trzymanych w odpowiednim obiekcie.
Plusy:
IOptions jest zarejestrowane jako singleton
Może być wstrzyknięte do każdego obiektu niezależnie od jego cyklu życia (Scoped, Singleton, czy Transient)
Minusy:
Odczytuje konfigurację TYLKO podczas uruchamiania systemu – to moim zdaniem jest najważniejsza kwestia. Przy niektórych opcjach to będzie wystarczające, przy innych nie.
Nie pozwala na „named options” (o tym za chwilę)
Interfejs IOptionsSnapshot<T>
Przykład wstrzyknięcia:
public EmailService(IOptionsSnapshot<EmailOptions> options)
{
this.options = options.Value;
}
Czyli dokładnie tak samo. Różnice natomiast są trzy.
Plusy:
daje Ci aktualne opcje – nawet jeśli zmienią się w pliku – bez konieczności restartu aplikacji
obsługuje „named options”, o czym później
Minusy:
zarejestrowane jako scoped – odczytuje opcje z każdym requestem, jednak nie wstrzykniesz tego do serwisów rejestrowanych jako singleton.
Interfejs IOptionsMonitor<T>
To wygląda trochę jak hybryda dwóch poprzednich interfejsów.
jest rejestrowany jako singleton, więc może być wstrzyknięty do serwisu niezależnie od jego cyklu życia
potrafi zaktualizować opcje, gdy się zmienią – bez restartu aplikacji
obsługuje „named options”
Użycie tego jest nieco bardziej skomplikowane. Oto przykład:
Pierwsza różnica jest taka, że nie trzymasz w swoim serwisie obiektu klasy EmailOptions tak jak to było do tej pory. Zamiast tego trzymasz cały monitor. A gdy potrzebujesz odczytać AKTUALNE opcje, posługujesz się właściwością CurrentValue tego monitora.
Teraz jeśli opcje fizycznie zostaną zmienione (np. w pliku appsettings.json), tutaj będziesz miał aktualne wartości – bez potrzeby restartowania aplikacji.
UWAGA! Zmiany zmiennych środowiskowych nie będą uwzględnione.
Masz tutaj różne ustawienia dla maili serwisowych i newslettera. Nie musisz tworzyć całej takiej struktury klas. Zwróć uwagę na to, że zarówno ServiceMailing jak i NewsletterMailing mają dokładnie takie same pola. Dokładnie też takie, jak klasa EmailOptions.
Możesz się tutaj posłużyć IOptionsSnapshot lub IOptionsMonitor, żeby wydobyć konkretne ustawienia (przypominam – IOptions nie obsługuje named options).
Najpierw trzeba jednak skonfigurować opcje, przekazując ich nazwy:
w pierwszym parametrze podajesz „nazwę zestawu opcji” – po tej nazwie będziesz później pobierał opcje do obiektu
w drugim pobierasz konkretną sekcję, w której są umieszczone te dane (tak jak do tej pory)
Teraz możesz odpowiednie opcje odczytać w taki sposób:
public class EmailService
{
EmailOptions serviceMailOptions;
EmailOptions newsletterMailOptions;
public EmailService(IOptionsSnapshot<EmailOptions> options)
{
serviceMailOptions = options.Get("ServiceMailing");
newsletterMailOptions = options.Get("NewsletterMailing");
}
}
Odłóż wczytywanie opcji na później
Odraczanie czytania opcji może być przydatne dla twórców bibliotek. Więc jeśli tego nie robisz, możesz śmiało opuścić ten akapit. Jeśli Cię to interesuje, to rozwiń go:
Kliknij tu, żeby rozwinąć ten akapit
Przedstawię trochę bezsensowny przykład, ale dzięki temu załapiesz jak wczytywać konfigurację później.
Pomyśl sobie, że tworzysz jakąś bibliotekę do wysyłania maili. Użytkownik może ją skonfigurować jak w powyższych przykładach:
Użytkownik może podać oczywiście dowolne dane. Ale Ty chcesz w swojej bibliotece mieć pewność, że jeśli FromEmail zawiera słowo „admin”, to pole From będzie zawierało „Admin”. Czyli konfiguracja taka jak poniżej będzie niepoprawna:
Wtedy EmailService otrzyma niepoprawne ustawienia (właściwość From znów będzie zawierała „John Rambo”).
Post konfiguracja
.NET przeprowadza konfigurację w dwóch etapach. Możesz posłużyć się metodą services.Configure lub services.PostConfigure.
.NET najpierw zbuduje CAŁĄ konfigurację, która została zarejestrowana metodą Configure (skrótowo powiedzmy, że „zbuduje wszystkie wywołania Configure”). A w drugim kroku zbuduje CAŁĄ konfigurację zarejestrowaną metodą PostConfigure. I teraz jeśli zmienisz kod swojej biblioteki:
w taki sposób, że zamiast Configure użyjesz PostConfigure, to wszystko zadziała. EmailService otrzyma poprawne dane.
Pewnie zapytasz teraz – „No dobrze, a czy użytkownik nie może użyć PostConfigure i znowu nadpisać mi opcje?” – pewnie, że może i nadpisze. Tak jak mówiłem na początku – to niezbyt udany przykład, ale chyba załapałeś o co chodzi z odroczoną konfiguracją 🙂 Walidację opcji tak naprawdę powinno się robić inaczej…
Jeśli spotkałeś się z przykładem z życia, gdzie PostConfigure jest lepsze albo pełni ważną rolę – daj znać w komentarzu.
Dobre praktyki
Jest kilka dobrych praktyk, które powinieneś stosować przy opcjach i naprawdę warto je stosować. Zdecydowanie mogą ułatwić Ci życie.
Twórz różne środowiska
Przede wszystkim, twórz w swoim projekcie różne środowiska. Development i Production to obowiązkowe minimum. Po prostu upewnij się, że masz takie pliki:
appsettings.json – ustawienia dla wersji produkcyjnej
appsettings.Development.json – ustawienia dla wersji developerskiej.
Tych plików możesz tworzyć znacznie więcej, np:
appsettings.Staging.json – ustawienia dla wersji przedprodukcyjnej (ostateczne testy przed wydaniem)
appsettings.Testing.json – jakieś ustawienia np. dla testów integracyjnych
appsettings.Local.json – jakieś typowe ustawienia dla środowiska lokalnego – Twojego komputera, na którym piszesz kod.
Pamiętaj, że o środowisku świadczy zmienna środowiskowa ASPNETCORE_ENVIRONMENT. Ona musi przyjąć jedną z nazw Twoich środowisk (Development, Production, Staging…). Jeśli tej zmiennej nie ma w systemie – uznaje się, że jest to wersja produkcyjna.
Pamiętaj, że pliki appsettings*.json lądują w repozytorium kodu. Chyba, że zignorujesz je w swoim systemie kontroli wersji. Jeśli tworzysz plik appsettings.Local.json – powinieneś automatycznie wyrzucać go z kontroli wersji.
Do trzymania wrażliwych danych używaj pliku secrets.json lub (w przypadku produkcji) – Azure KeyVault – jak to zrobić opiszę w osobnym artykule (zapisz się na newsletter lub polub stronę na fejsie, żeby go nie przegapić :)).
Nie używaj stringów (jako identyfikatorów) bezpośrednio
To chyba dotyczy wszystkiego – nie tylko opcji. Posługuj się w tym celu stałymi lub operatorem nameof. Np. zamiast wklepywać:
services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings")); //nazwa sekcji na sztywno
wykorzystaj stałe:
public class EmailOptions
{
public const string EmailOptionsSectionName = "EmailSettings";
public string SmtpAddress { get; set; }
public string From { get; set; }
public string FromEmail { get; set; }
}
//
services.Configure<EmailOptions>(Configuration.GetSection(EmailOptions.EmailOptionsSectionName));
Sprawdzaj poprawność swoich opcji
Swoje opcje możesz walidować przez DataAnnotation (standard) lub FluentValidation (osobna biblioteka) i faktycznie powinieneś to robić, jeśli opcje mają jakieś ograniczenia lub z jakiegoś powodu mogą być niepoprawne.
To tyle, jeśli chodzi o zarządzanie opcjami w .NET. Jak pisałem wyżej – są jeszcze dwa aspekty, które na pewno będę chciał poruszyć w osobnych artykułach – walidajca opcji i odczytywanie opcji z Azure KeyVault. Być może napiszę też artykuł o tworzeniu własnego ConfigurationProvidera.
Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu 🙂
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie