Własny szablon dla dotnet new

Własny szablon dla dotnet new

Wstęp

W poprzednim artykule pokazałem jak tworzyć klasyczny szablon projektów dla Visual Studio. Natomiast z tego artykułu dowiesz się jak stworzyć szablon dla dotnet new. Taki szablon możesz zaimportować na każdym komputerze i systemie, w którym zainstalowany jest dotnet.

Klasyczne szablony (dla Visual Studio) są obsługiwane tylko przez VisualStudio dla Windows. Natomiast te nowe dla „dotnet new” już nie mają tego ograniczenia. Ponadto pozwalają duuużo prościej zrobić bardziej skomplikowane rzeczy. Minus? Nie da się tego wyklikać…jeszcze…

Pamiętaj, że ten artykuł to nie jest kompletny podręcznik szablonów. O nie! To jest dość duży temat. Artykuł pokazuje jak zacząć i jak zrobić coś trudniejszego niż „hello world”. Na koniec jednak daję Ci kilka linków, które uzupełniają artykuł. Jeśli czegoś tutaj nie znajdziesz, być może znajdziesz w oficjalnej dokumentacji (podlinkowanej na końcu).

Z czego składa się szablon

Są dwie rzeczy:

  • struktura projektu – wrzucasz tutaj dowolną zawartość (pliki, foldery, inne projekty), która ma się znaleźć na dzień dobry w projekcie wynikowym – od tego zaczynasz, po prostu stwórz nowy projekt, który będzie wyjściowym projektem dla Twojego szablonu
  • plik konfiguracyjny template.json – opisuje parametry szablonu
  • opcjonalne pliki np. do obsługi Wizarda, czy też ikonka

W przeciwieństwie do klasycznych szablonów pod VisualStudio, nie musisz stosować specjalnych tokenów w formie parametrów w swoich projektach. Nie ma też problemu z kompilowaniem ich – właśnie ze względu na brak tych tokenów.

Musisz jednak zapewnić odpowiednią strukturę katalogów, np. taką:

Jak widzisz, w katalogu z projektem musisz utworzyć folder .template.config, w którym umieścisz całą konfigurację swojego szablonu. Ja utworzyłem projekt WebApplication (RazorPages), jako projekt wyjściowy, któremu nadałem nazwę WebAppWithApiTemplate.

Tylko uwaga – będzie Cię korciło, żeby dodać folder .template.config do projektu w Visual Studio. Nie rób tego. Wszystko, co zrobisz z projektem będzie miało skutek w Twoim szablonie. W prawdzie można potem zastosować pewien mechanizm, żeby pozbyć się tego folderu w wynikowym projekcie, ale moim zdaniem można to zrobić lepiej…

Dodawanie plików konfiguracyjnych do solucji

To nie znaczy jednak, że nie możesz pracować na plikach konfiguracyjnych w Visualu. Byłoby to mocno uciążliwe. Utwórz zatem ręcznie katalog .template.config, a w nim plik o nazwie template.json. A następnie w Visual Studio kliknij prawym klawiszem myszy na solucję i wybierz Add -> New Solution Folder:

To utworzy Ci folder (tzw. „filtr”) na poziomie solucji. Jednak żaden folder na dysku nie powstanie. Ja swój „filtr” nazwałem template-files – to jak nazwiesz swój nie ma żadnego znaczenia.

Następnie kliknij go prawym klawiszem myszy i wybierz Add -> Existing Item:

W pickerze wybierz pliki konfiguracyjne szablonu, które utworzyłeś wcześniej. To doda je do solucji, ale nie zrobi żadnych zmian na poziomie projektów. Dzięki temu będziesz mógł pracować na plikach konfiguracyjnych w Visual Studio:

Plik template.json

To właściwie serce Twojego szablonu. Niestety na dzień dzisiejszy nie da się go wyklikać, ale dość łatwo się go tworzy. Zwłaszcza, jeśli wykorzystasz możliwości VisualStudio. Spójrz na okno z zawartością pliku:

Plik oczywiście jest pusty, ale w edytorze kodu widzisz combobox oznaczony jako Schema. Możesz w nim wybrać schemat pliku json, który chcesz tworzyć – dzięki temu cały Intellisense zadziała i będziesz mieć eleganckie podpowiedzi w kodzie. Odnajdź na tej liście https://json.schemastore.org/template.json – to jest opisany schemat szablonu.

UWAGA! Widoczny na powyższym screenie schemat to jakiś przykładowy pierwszy lepszy z listy. Pamiętaj, żeby odnaleźć tam konkretny: https://json.schemastore.org/template.json

VS dzięki temu opisowi może dawać Ci podpowiedzi. A teraz zacznijmy wypełniać plik template.json. Na początek trzeba mu wskazać wybrany schemat. Robimy to za pomocą właściwości $schema:

{
  "$schema": "https://json.schemastore.org/template.json"
}

OK, teraz zupełnie podstawowa zawartość tego pliku może wyglądać tak:

{
  "$schema": "https://json.schemastore.org/template.json",
  "author": "Adam Jachocki",
  "classifications": [ "WebApp", "ApiClient" ],
  "identity": "Jachocki.WebAppWithApiTemplate",
  "name": "Web application with API Client",
  "shortName": "waapi",
  "tags": {
    "type": "project",
    "language": "c#"
  }
}

To są minimalne wymagane właściwości. Rozpoznasz je po tym, że w Intellisense są pogrubione. A teraz przelecimy je po kolei:

  • author – to oczywiście autor szablonu
  • classifications – to klasyfikuje szablon. Te wartości będą potem używane w filtrach np. Visual Studio. Podajesz tutaj tablicę wartości, które powinny kategoryzować Twój szablon. W VisualStudio to te właściwości podczas dodawania nowego projektu. W narzędziu dotnet new --list będą się pojawiały w rubryce tags.
  • identity – to jest coś w rodzaju ID Twojego szablonu. To musi być unikalne, dlatego daj tutaj jakąś unikalną wartość, ale niech to będzie coś opisowego a nie np. GUID – później się przyda
  • name – pełna nazwa Twojego szablonu (przykłady istniejących: „Console App”, „ASP.NET Core Web App”)
  • shortName – nazwa skrócona szablonu, której będziesz mógł używać, tworząc projekt na jego podstawie
  • tags – podstawowy opis szablonu – wskazujesz, że szablon odnosi się do projektu (type: project) i jaki jest główny język projektu. Szablon może być jeszcze dla całej solucji (kilka projektów) lub nowym item’em – czyli poszczególnym typem pliku.

Dość istotną właściwością jest sourceName. SourceName to ciąg znaków zarówno w zawartości plików jak i w ich nazwach, który podczas tworzenia projektu zostanie zastąpiony nazwą projektu, którą poda użytkownik. Przykładowo, jeśli mój kod wygląda tak:

I jeśli we właściwości sourceName podam: „WebAppWithApiTemplate” wtedy ten ciąg zostanie zamieniony na to, co wpisze użytkownik podając nazwę swojego projektu.

Czyli zarówno namespace’y, jak i nazwa projektu głównego zostaną zamienione na ten żądany przez użytkownika. A więc dodajmy to:

{
  "$schema": "https://json.schemastore.org/template.json",
  "author": "Adam Jachocki",
  "classifications": [ "WebApp", "ApiClient" ],
  "identity": "Jachocki.WebAppWithApiTemplate",
  "name": "Web application with API Client",
  "shortName": "waapi",
  "tags": {
    "type": "project",
    "language": "c#"
  },
  "sourceName": "WebAppWithApiTemplate"
}

Pewnie są sytuacje, w których nie chciałbyś takiej podmiany. Wtedy po prostu tego nie stosujesz, gdyż nie jest to właściwość wymagana.

To jest już pełnoprawny projekt szablonu. Teraz musisz go tylko zapakować w nuget i zainstalować. O tym później. Najpierw zrobimy inne fajne rzeczy.

Parametry

Szablon często będzie zawierał jakieś parametry, które mogą być ustawione przez użytkownika. W pliku template.json lądują one we właściwości symbols. Parametry są jednym z typów symboli.

Parametr składa się z nazwy (ID), a także typu danych. Może zawierać wartość domyślną, a także ciąg, który będzie zamieniony na wartość parametru.

Jak to działa? Silnik szablonów analizuje plik template.json. Jeśli teraz znajdzie jakieś parametry (symbole) do podmiany, to każdy taki ciąg znaków w kodzie zamieni na konkretną wartość podaną przez użytkownika. Analogicznie do właściwości sourceName.

Dodajmy do naszego szablonu folder ApiClient, a nim prostą klasę:

I teraz chcę, żeby taka klasa znalazła się w katalogu ApiClient, ale to użytkownik będzie decydował o tym, jak ten klient ma się nazywać. Dlatego też posłużę się symbolem:

"symbols": {
  "ApiClientName": { //nazwa parametru (jego ID)
    "type": "parameter",
    "datatype": "text",
    "description": "Podaj nazwę klasy dla swojego klienta API",
    "displayName": "Nazwa klasy dla Api Client",
    "defaultValue": "MyApiClient",
    "isRequired": true,
    "replaces": "varApiClientName",
    "fileRename": "varApiClientName"
  }
}

Uznaj właściwość symbols za klasę, która posiada inne właściwości – konkretne symbole. I teraz tak. Każdy symbol ma swoją nazwę (ID). Tutaj to APIClientName. Nazwą symbolu możesz posługiwać się w kodzie swojego szablonu, o tym za chwilę.

Każdy symbol to też swego rodzaju klasa, która posiada konkretne właściwości:

  • type – typ symbolu. To może być parametr, generator itd. Na razie skupmy się na parametrach
  • datatype – typ danych, jakie ten parametr będzie przechowywał. Inne typy to np:
    • bool – true/false – na GUI równoznaczne z checkboxem (np. Use Https)
    • choice – lista wartości do wyboru – na GUI równoznaczne z combo (np. wybór Identity)
    • float, hex, int, text – no te wartości same się opisują. Przy czym text jest wartością domyślną
  • description – opis parametru. Pokaże się w konsoli w poleceniu dotnet new, ale też w VisualStudio jako dodatkowa informacja (chociaż dla IDE jest dodatkowy/osobny sposób pokazywania parametrów opisany niżej)
  • defaultValue – wartość domyślna
  • isRequired – czy parametr jest wymagany. Jeśli podczas tworzenia projektu przez polecenie dotnet new użytkownik nie poda tej wartości, to dotnet new zwróci błąd
  • replaces – to jest ten magiczny string w plikach, który zostanie zamieniony na wartość wprowadzoną przez użytkownika. U mnie to varApiClientName. Ten prefix „var” jest tylko pewnym udogodnieniem dla mnie. Nie ma tutaj żadnych wytycznych ani obostrzeń. Ale pamiętaj, że zostaną zamienione WSZYSTKIE STRINGI w kodzie. Łącznie z tymi w plikach projektów, komentarze, a nawet wszystkie wartości tekstowe. Jeśli więc miałbym w kodzie coś takiego:
public string Id { get; set; } = "varApiClientName";

to też będzie zamienione. Dlatego ja posługuję się tym przedrostkiem var. To trochę chroni przed zrobieniem głupoty.

  • fileRename – analogicznie jak replace z tą różnicą, że taki ciąg zostanie podmieniony w nazwach plików.

Stwórzmy teraz bardzo prostego i prymitywnego klienta dla API. To jest tylko bardzo prymitywny przykład. Jak fajnie zrobić takiego klienta pisałem w tym artykule. Nie chcę zaciemniać obrazu, dlatego zrobię najprościej jak się da. Ten klient powstał tylko dla przykładu. I naprawdę nie jest istotne, że nie ma większego sensu 😉

Sparametryzowany klient

Co chcemy uzyskać?

  1. Użytkownik ma mieć opcję do podania nazwy klienta – to już nam załatwił parametr varApiClientName.
  2. Użytkownik ma mieć możliwość wyłączenia lub włączenia rejestracji serwisów – czyli domyślnej konfiguracji klienta.
  3. W przypadku włączenia rejestracji serwisów, użytkownik ma mieć możliwość podania BaseAddress dla API. Zarówno dla środowiska produkcyjnego jak i deweloperskiego.

Tworzenie pełnego kodu

Tak, czy inaczej musimy stworzyć cały kod. W pliku template.json nie możemy dodawać plików ani linijek kodu. Dlatego też musimy wyjść od pełnego obrazu.

Przede wszystkim jest klasa, która umożliwia wysyłanie żądań – prosty klient w pliku varApiClientName.cs:

namespace WebAppWithApiTemplate.ApiClient
{
	public class varApiClientName
	{
		private readonly HttpClient _client;

		public varApiClientName(HttpClient client)
		{
			_client = client;
		}

		public async Task<HttpResponseMessage> GetData(string endpoint)
		{
			return await _client.GetAsync(endpoint);			
		}

		public async Task<HttpResponseMessage> PostData<TOut>(TOut data, string endpoint)
		{
			return await _client.PostAsJsonAsync(endpoint, data);
		}
	}
}

Do tego trzeba zrobić rejestrację HttpClienta. Dlaczego w taki sposób? Opisywałem to w tym artykule.

W folderze ApiClient dodałem plik ServiceCollectionsExtensions.cs:

public class varApiClientNameOptions
{
	public const string CONFIG_SECTION = "varApiClientName";
	public string BaseAddress { get; set; }
}
public static class ServiceCollectionExtensions
{
	public static IServiceCollection AddvarApiClientNameIntegration(this IServiceCollection services, 
		IConfiguration config)
	{
		varApiClientNameOptions options = new varApiClientNameOptions();
		config.Bind(varApiClientNameOptions.CONFIG_SECTION, options);

		services.AddHttpClient<varApiClientName>(client =>
		{
			client.BaseAddress = new Uri(options.BaseAddress);
		});

		return services;
	}
}

W tym kodzie po prostu rejestrujemy HttpClient dla naszego klienta. Jeśli nie wiesz co robi AddHttpClient, koniecznie przeczytaj ten artykuł.

Najpierw pobieramy ustawienia, w których będzie wpisany adres bazowy api. To nam zapewnia, że adres bazowy będzie pobrany w zależności od środowiska. Nasępnie konfigurujemy klienta.

Spójrz w jaki sposób przemycam wszędzie varApiClientName – te wszystkie miejsca zostaną zamienione na faktyczną nazwę klienta.

Na koniec wywołamy jeszcze tę metodę podczas rejestracji serwisów w pliku Program.cs:

using WebAppWithApiTemplate.ApiClient;
//...

builder.Services.AddRazorPages();
builder.Services.AddvarApiClientNameIntegration(builder.Configuration);

Dodawanie parametrów

W związku z tym, że chcemy aby użytkownik mógł podać adres bazowy dla API, musimy dodać takie parametry do template.json. Oczywiście dodajemy to dalej w sekcji symbols:

"ApiProdBaseAddress": {
  "type": "parameter",
  "datatype": "text",
  "description": "Bazowy adres dla API dla środowiska produkcyjnego",
  "replaces": "varApiProdBaseAddress",
  "defaultValue": "https://api.example.com/"
},
"ApiDevBaseAddress": {
  "type": "parameter",
  "datatype": "text",
  "description": "Bazowy adres dla API dla środowiska deweloperskiego",
  "replaces": "varApiDevBaseAddress",
  "defaultValue": "https://api.dev.example.com"
}

Dodałem dwa parametry – jeden to adres bazowy dla środowiska produkcyjnego, drugi dla deweloperskiego. Jeśli nie wiesz, czym jest appsettings.json, czym się różni od appsettings.Development.json i masz małe pojęcie o konfiguracji .NET, koniecznie przeczytaj ten artykuł.

Dodawanie ustawień w aplikacji

OK, możemy teraz te dane dodać do plików appsettings.

Teraz mój plik appsettings.json wygląda tak:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "varApiClientName": {
    "BaseAddress": "varApiProdBaseAddress"
  } 
}

Spójrz w jaki sposób przemycam tutaj znów nazwy tego klienta i parametr z bazowym adresem dla API. Analogicznie zrobimy w pliku appsettings.Development.json:

"varApiClientName": {
  "BaseAddress": "varApiDevBaseAddress"
}

Wycinanie fragmentów kodu

OK, teraz mamy ogarnięte punkty 1 i 3 z naszych założeń. Czyli użytkownik może podać nazwę klienta, a także adresy bazowe do API. To teraz musimy się zatroszczyć o punkt 2 – użytkownik ma mieć możliwość wyłączenia jakiejkolwiek konfiguracji klienta.

Najpierw dodajmy taki parametr w pliku template.json:

"ConfigureApiClient": {
  "type": "parameter",
  "datatype": "bool",
  "description": "Czy klient API ma być domyślnie skonfigurowany",
  "defaultValue": "true"
}

Zauważ, że tutaj nie posługuję się właściwością replaces, ponieważ niczego nie będziemy podmieniać. Albo wykonamy fragment kodu, albo nie.

Zatem wytnijmy warunkowo fragment kodu, który wywołuje rejestrację klienta, czyli to:

using WebAppWithApiTemplate.ApiClient;
//...

builder.Services.AddRazorPages();
builder.Services.AddvarApiClientNameIntegration(builder.Configuration);

Robimy to w bardzo prosty sposób – dyrektywami kompilatora:

#if ConfigureApiClient
using WebAppWithApiTemplate.ApiClient;
#endif

//...

			builder.Services.AddRazorPages();
#if ConfigureApiClient
			builder.Services.AddvarApiClientNameIntegration(builder.Configuration);
#endif

W taki sposób możesz sterować fragmentami kodu w plikach. Po prostu sprawdzasz, czy parametr o podanym ID ma wartość true.

Usuwanie plików

Jeśli chodzi jednak o plik ServiceCollectionExtensions.cs nie jest on w ogóle potrzebny, gdy użytkownik nie chce automatycznej konfiguracji. Nie ma sensu wycinać kodu w tym pliku, bo zostałby nam zupełnie pusty. Dlatego warunkowo możemy go usunąć. Z pomocą przychodzi nowa sekcja w pliku template.jsonsources – w niej możemy stosować modyfikatory plików źródłowych.

Sekcja sources zawiera modyfikatory. Każdy modyfikator może mieć warunek lub wykonać się bezwarunkowo (zawsze). Napiszmy więc taki modyfikator, który usunie plik ServiceCollectionsExtensions.cs, gdy parametr ConfigureApiClient nie będzie ustawiony (jego wartość będzie na false):

"sources": [
  {
    "modifiers": [
      {
        "condition": "(!ConfigureApiClient)",
        "exclude": "ApiClient/ServiceCollectionExtensions.cs"
      }
    ]
  }
],

Jak widzisz, żeby sprawdzić wartość jakiegoś parametru, po prostu wpisujemy jego nazwę w nawias. Możemy też go zanegować wykrzyknikiem. Czyli w tym przypadku, gdybyśmy chcieli ten warunek przepisać na kod, wyglądałoby to akoś tak:

if(!ConfigureApiClient)
{
    exclude("ApiClient/ServiceCollectionExtensions.cs");
}

Następnie musimy wskazać, co ma się zadziać, jeśli warunek będzie spełniony. A więc pozbywamy się pliku, wykluczamy go (exclude) z całości.

Teraz może pojawić się pytanie – skąd silnik template’ów wie, gdzie jest konkretny plik. Ścieżką wyjściową (bazową) dla silnika jest folder, w którym znajduje się folder .template.config.

I tutaj odnosimy się do folderu ApiClient i pliku, który się w nim znajduje. Możesz stosować tutaj symbole wieloznaczne, np: „**/*Extensions.cs” – usunęłoby wszystkie pliki z wszystkich podkatalogów, których nazwy kończą się na Extensions.cs.

Jeśli jednak chciałbyś wykluczyć większość plików, a zostawić tylko jeden, łatwiej będzie posłużyć się modyfikatorem include. Domyślnie include włącza wszystkie pliki, które znajdują się w projekcie (oczywiście poza katalogiem .template.config).

Możesz też chcieć warunkowo zmienić nazwy plików. Do tego możesz zastosować modyfikator rename.

Warunkowe zawartości plików

Pliki *.cs

Jak już pisałem wcześniej, w plikach cs możemy posługować się dyrektywami kompilatora, żeby warunkowo mieć jakąś zawartość, np:

#if ConfigureApiClient
			builder.Services.AddvarApiClientNameIntegration(builder.Configuration);
#endif

Możesz stosować również dyrektywę #elif. A co jeśli chcesz jednak, żeby jakaś dyrektywa była widoczna na koniec w pliku? Np:

#if DEBUG
			logger.LogWarning("UWAGA! Tryb deweloperski!");
#endif

Jest i na to sposób. Nazywa się to processing flag. To już jednak wygląda jak czary:

//-:cnd:noEmit
#if DEBUG
			logger.LogWarning("UWAGA! Tryb deweloperski!");
#endif
//+:cnd:noEmit

No cóż… Grunt, że jest taka opcja 😉 Pamiętaj – „processing flag”.

Pliki JSON

Tutaj nie możemy się posłużyć bezczelnie dyrektywą, ale możemy posłużyć się specjalnym komentarzem. W plikach json komentarz jest rozpoczynany znakami // i trwa do końca linii. To wytnijmy teraz ustawienia związane z konfiguracją api clienta z plików appsettings:

  //#if ConfigureApiClient
  "varApiClientName": {
    "BaseAddress": "varApiProdBaseAddress"
  }
  //#endif

To spowoduje dokładnie to, czego się spodziewasz – sekcja varApiClientName pojawi się w pliku appsettings tylko wtedy, gdy parametr ConfigureApiClient będzie miał wartość TRUE. Zrób analogiczną operację w pliku appsettings.Development.json.

Zmiany w plikach projektów i innych XMLach

Tutaj analogicznie posłużymy się komentarzami XMLowymi. Możesz bez problemu je stosować w plikach projektów. Dodajmy do naszych wymagań jeszcze jedno – niech użytkownik wybierze, czy chce korzystać z biblioteki System.Text.Json, czy ze starego dobrego Newtonsoft.Json. Na początek dodajmy taki parametr do pliku template.json. Niech to będzie combobox.

"JsonLibrary": {
  "type": "parameter",
  "datatype": "choice",
  "choices": [
    {
      "choice": "Default",
      "description": "Używaj domyślnej biblioteki do obsługi JSON",
      "displayName": "Domyślny System.Text.Json"
    },
    {
      "choice": "Newtonsoft",
      "description": "Używaj starego, dobrego Newtonsoft",
      "displayName": "Newtonsoft.Json"
    }
  ],
  "defaultValue": "Default"
}

Jak widzisz, choices składa się z tablicy obiektów (choice, description, displayName). Ich właściwości same się opisują. I co ciekawe, przy choice też możesz stosować właściwość replaces, co później pokażę. Pamiętaj też, żeby wartością datatype było ustawione na choice.

To teraz zmieńmy plik projektu. Jak już wspomniałem robimy to specjalnym komentarzem:

<!--#if (JsonLibrary == 'Newtonsoft')-->
<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
<!--#endif -->

Musisz tutaj zwrócić uwagę na dwie rzeczy:

  1. Pomiędzy znakiem komentarza <!-- a hashem nie może być spacji. Inaczej silnik szablonów nie uzna tego za dyrektywę, tylko za zwykły komentarz.
  2. Wartość, którą porównujesz musi być w apostrofach. Inaczej warunek nie zostanie uznany jako spełniony.

Można to zrobić jeszcze inaczej – bez porównywania konkretnych wartości. Przydać się to może w sytuacji, gdzie miałbyś do sprawdzenia kilka tych samych warunków w kilku miejscach. Ale o tym za chwilę.

Trochę magii – czyli czego nie powie Ci dokumentacja

Dokumentacje szablonów są całkiem nieźle opisane, ale mają miejscami sporo braków. Jak np. magiczny parametr Framework. Na jakimś video z Microsoftu słyszałem, że jest on zalecany, ale nigdzie nie jest opisany. Hurrra!

Generalnie parametr Framerowk daje wybór frameworka, na którym ma być stworzony projekt oparty na szablonie. Tworzy się go analogicznie jak inne parametry:

"Framework": {
  "type": "parameter",
  "description": "The target framework for the project.",
  "datatype": "choice",
  "choices": [
    {
      "choice": "net6.0",
      "description": "Target net6.0"
    },
    {
      "choice": "netcoreapp3.1",
      "description": ".NetCore 3.1"
    },
    {
      "choice": "net7.0",
      "description": ".NET 7.0"
    }
  ],
  "replaces": "net6.0",
  "defaultValue": "net6.0"
}

Zwróć uwagę tutaj na dwie rzeczy:

  • wartości w choice muszą być dokładnymi „monikerami” wersji .NET, np.:
    • net48 – .NetFramework 4.8
    • netstandard2.1
    • netcoreapp3.1
    • net5.0
    • net6.0
    • net7.0

Jeśli wrzucisz inne wartości, to magiczny mechanizm nie zadziała.

  • we właściwości replaces wstawiasz, jak to z innymi parametrami, string do podmiany. Pamiętaj, że to podmieni wszystkie znalezione stringi „net6.0” na wybraną przez użytkownika wartość – w szczególności w pliku projektu.
Zobacz, jak sprytnie Visual Studio rozkminił wersje

Symbole wyliczane

Innym typem symbolu są symbole wyliczane. To coś w rodzaju parametru. Tylko nie podaje go użytkownik, a wyliczamy go na jakiejś podstawie. Możesz to stosować gdy w kilku miejscach stosujesz jakieś warunki. Np:

"IsLTS": {
  "type": "computed",
  "value": "(Framework == net6.0 || Framework == netcoreapp3.1)"
}

Taki kodzik stworzy Ci coś w rodzaju zmiennej o nazwie IsLTS, która przyjmie odpowiednią wartość na podstawie innych parametrów. W tym konkretnym przypadku możesz sprawdzić, czy wybrana wersja .net jest długowieczną (long time support), czy też nie. Później na tej podstawie możesz zadecydować o czymś innym, stosując w innych warunkach zmienną IsLTS – dokładnie w taki sam sposób jak inne parametry. Np. możesz gdzieś w kodzie wpisać warning:

#if (!IsLTS)
#warning "Caution! Your framework is not LTS version!"
#endif

UWAGA! Symbole typu computed mogą przyjmować jedynie wartości typu bool.

Instalowanie szablonu

Właściwie wszystkie wytyczne mamy już ogarnięte. Teraz możemy zainstalować taki szablon. Oczywiście nie można zrobić z VisualStudio tego automatem… jeszcze… za to można to zrobić na kilka sposobów. Najpierw podam Ci sposób lokalny – to jest wystarczające jeśli tworzysz jakiś szablon dla siebie i raczej nie będziesz w nim już grzebał.

Uruchom terminal i przejdź do katalogu głównego Twojej aplikacji. Tam, gdzie masz katalog z projektem. U mnie plik projektu znajduje się w projekty\MasterBranch\SingleNewTemplate\src\WebAppWithApiTemplate\WebAppWithApiTemplate.csproj dlatego muszę ustawić się w katalogu src: projekty\MasterBranch\SingleNewTemplate\src\

Teraz wystarczy zainstalować szablon, wykonując polecenie:

dotnet new --install .\WebAppWithApiTemplate

czyli podajemy nazwę katalogu, w którym jest projekt z szablonem.

Następnie możemy przejrzeć sobie listę szablonów:

dotnet new --list

U mnie wygląda to tak:

Jak widzisz, mój szablon został zainstalowany.

Jeśli będziesz chciał go odinstalować, to dotnet new podpowie Ci dokładnie co zrobić, ale zasadniczo powinieneś podać pełną ścieżkę dostępu do katalogu z projektem szablonu, np.:

dotnet new --uninstall d:\projekty\MasterBranch\SingleNewTemplate\src\WebAppWithApiTemplate

Jeśli chciałbyś utworzyć sobie projekt na podstawie tego szablonu, to możesz to zrobić podając króką nazwę szablonu. A wywołując instrukcję help, dostaniesz pełną pomoc dla swojego szablonu:

dotnet new waapi --help

Jak widzisz, dotnet świetnie sobie poradził z Twoimi parametrami i pokazuje Ci dokładnie jak ich użyć. Przykładowe utworzenie projektu:

dotnet new waapi -J Default -A ExampleApiClient -o .\NewProject

Tutaj jednak pamiętaj, że parametry polecenia dotnet new mieszają się z Twoimi parametrami. Stąd np. -o (--output) – parametr dotnet new, który mówi gdzie stworzyć projekt.

Możesz też taki projekt stworzyć bezpośrednio z Visual Studio (zrestartuj Visual Studio po zainstalowaniu szablonu).

Instalowanie szablonu NuGetem

Ten sposób umożliwia Ci podzielenie się szablonem z innymi, a także łatwe jego wersjonowanie. Wymaga to utworzenia nugetowej paczki. Ale zadanie jest dość proste.

Utwórz gdzieś plik template.nuspec. Możesz to zrobić w katalogu .template.config, ale nie musisz. Utwórz go gdzieś, gdzie uznasz za słuszne. Jego przykładowa zawartość powinna wyglądać tak:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
  <metadata>
    <id>Jachocki.ApiClient</id>
    <version>1.0.0</version>
    <description>Przykładowy projekt szablonu</description>
    <authors>Adam Jachocki</authors>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <license type="expression">MIT</license>
    <tags>dotnet templates apiclient webapi</tags>
	<packageTypes>
      <packageType name="Template" />
    </packageTypes>
  </metadata>
  <files>
    <file src="..\**\*.*" exclude="..\**\bin\**\*.*;..\**\obj\**\*.*;" />
  </files>
</package>

Ten plik sam się opisuje. Musisz pamiętać jedynie o tym, żeby sekcja files odnosiła się do konkretnych plików Twojego szablonu. Ja akurat plik nuspec umieściłem w .template.config – dlatego też w sekcji files idę po pliki „piętro wyżej”.

Następnie musisz wywołać na nim:

nuget pack template.nuspec

Jeśli wszystko jest ok, to otrzymałeś plik z rozszerzeniem nupkg. I to jest Twój pakiet, którym możesz się już dzielić. Żeby teraz zainstalować taki szablon, wystarczy:

dotnet new install .\twoj-plik.nupkg

Odinstalowanie jest analogiczne. Tyle, że podajesz tylko nazwę pakietu – bez ścieżki do niego.

Przygotowanie szablonu dla środowisk IDE

dotnetcli.host.json

Oprócz pliku template.json, który jest podstawą, możesz mieć jeszcze dodatkowe pliki, w których swój szablon możesz podkręcić. Najprostszym jest dotnetcli.host.json. Jego rolą jest ustawienie aliasów do parametrów w narzędziu dotnet new. Domyślnie są one tworzone jakoś automagicznie. Jeśli wyświetlisz pomoc dla swojego szablonu, zobaczysz taki mniej więcej opis:

Parametr na długą nazwę, np. --ApiClientName, ale może mieć też krótki alias, np: -a. Zauważ, że pełna nazwa parametru rozpoczyna się podwójnym myślnikiem [–], natomiast aliast pojedynczym [-]. Jest to naturalne zachowanie. Jak widzisz powyżej, dla każdego parametru istnieją jakieś domyślne aliasy. Ale możesz je sam utworzyć, stosując plik dotnetcli.host.json, np. tak:

{
  "$schema": "https://json.schemastore.org/dotnetcli.host.json",
  "symbolInfo": {
    "ApiClientName": {
      "longName": "ApiClientName",
      "shortName": "cn"
    }
  }
}

Najpierw podajesz id symbolu takie jak w pliku template.json. Następnie masz parametry:

  • longName – długa nazwa – czyli ta prawilna cała nazwa parametru prefiksowana podwójnym myślnikiem
  • shortName – alias – krótka nazwa parametru. Jeśli podasz pusty string „”, wtedy parametr nie będzie miał aliasu.
  • isHidden – jeśli ustawisz na true, to ta opcja nie będzie widoczna z poziomu dotnet new --help. Jednak nadal będziesz mógł z niej korzystać (po prostu nie zobaczysz jej w podpowiedziach).

ide.host.json

Ten plik z kolei pomaga ogarnąć dodatkowe rzeczy w Visual Studio (i pewnie w innych IDE). We wcześniejszych wersjach (poniżej 2022) był wymagany. Teraz jest opcjonalny, jednak umożliwia dodatkowe czary mary.

Zacznijmy od zupełnej podstawy:

{
  "$schema": "https://json.schemastore.org/ide.host.json",
  "icon": "icon.png"
}

Tutaj ustawiamy ikonkę dla szablonu. To zakłada, że w katalogu .template.config masz ikonkę o nazwie icon.png. Żeby ikonka była dobrze widoczna, powinna być w rozmiarze 32×32 piksele o głębokości 32 bitów.

UWAGA! Jeśli tego nie zrobisz, ale w katalogu .template.config będziesz miał ikonkę o nazwie icon.png, to VisualStudio od wersji 2022 też to ogarnie.

Parametry

Informacje o parametrach przechowywane są we właściwości symbolInfo. To po prostu tablica parametrów, mówiących VisualStudio jak ma je obsługiwać. Podobnie jak w template.json

Podstawowa budowa obiektu jest taka:

"symbolInfo": [
  {
    "id": "ApiClientName",
    "name": {
      "text": "Nazwa klienta API"
    },
    "description": {
      "text": "Podaj nazwę klienta dla swojego API. Tak będzie nazywać się wygenerowana klasa."
    },
    "isVisible": true
  }
]

Id to nazwa parametru z pliku template.json. Właściwości name i description pokażą się przy tym parametrze jako jego nazwa i opis, który będzie w hincie:

Jeśli nie wypełnisz właściwości name i description, zostanę one odczytane z pliku template.json.

Parametr isVisible określa, czy właściwość ma być widoczna.

Jeśli używasz pliku ide.host.json, to pamiętaj że domyślnie wszystkie parametry są UKRYTE. Stąd właściwość isVisible. Ustawiasz nią, które parametry mają być widoczne. Jeśli chcesz żeby wszystkie były widoczne, to jest od tego właściwość defaultSymbolVisibility, np:

{
  "$schema": "https://json.schemastore.org/ide.host.json",
  "icon": "icon.png",
  "supportsDocker": true,
  "defaultSymbolVisibility": true
}

Ten kod sprawi, że wszystkie parametry z template.json będą widoczne podczas tworzenia nowego projektu w Visual Studio. Czyli zasadniczo w VisualStudio 2022 równie dobrze ten plik (ide.host.json) mógłby nie istnieć.

Plik ide.host.json ma jeszcze kilka parametrów:

  • description – opis szablonu – jeśli go nie wypełnisz, zostanie wzięty z pliku template.json
  • name – nazwa szablonu – analogicznie jak wyżej
  • order – kolejność w jakiej pojawi się szablon na liście tworzenia nowego projektu
  • supportsDocker – jeśli ustawione na true, to podczas tworzenia projektu zobaczysz opcję (checkbox) pozwalającą zdokeryzować taki projekt. I tutaj uwaga! Jeśli chcesz żeby ta opcja była widoczna w VisualStudio, musisz do pliku template.json dodać parametr Framework (ten magiczny). Inaczej nie zadziała.
  • unsupportedHosts – możesz tutaj podać listę wersji, dla których ten szablon ma być niewidoczny na dialogu tworzenia nowego projektu, np. taka konfiguracja ukryje szablon w VisualStudio:
"unsupportedHosts": [
  {
    "id": "vs"
  }
]

Zaawansowane

Specjalne operacje

Jeśli silnik szablonów nie potrafi czegoś zrobić standardowo, to być może da Ci taką możliwość za pomocą specjalnych operacji. Te operacje pozwalają na zdefiniowanie dodatkowych akcji podczas tworzenia projektu na podstawie szablonu. Akcje mogą być globalne (dla wszystkich plików) lub ograniczone tylko do niektórych plików.

{
  "customOperations": { //odnosi się do wszystich plików - globalnie
   },

  "specialCustomOperations": { //tylko do niektórych plików
  }
}

Ja w jednym swoim szablonie potrzebowałem mieć różne grupy usingów. Szablon został utworzony pod konkretną solucję, w której te specjalne projekty (namespacey) już są. Jednak z nimi projekt szablonu się nie kompilował. Owszem, mogłem utworzyć jakiś plik z konkretnymi namespaceami i potem go usuwać, ale wpadłem na inny pomysł. Wyobraź sobie taki plik *.cs:

/*add-usings
using Nerdolando.BS.Common.Abstractions;
using Nerdolando.BS.Integrations;
add-usings*/
namespace WebAppWithApiTemplate.ApiClient
{
	public class SpecialOperations
	{
	}
}

Zauważ, że usingi mam tutaj wykomentowane. Ale chcę, żeby pojawiły się w wynikowym projekcie. Dlatego posłużyłem się customową operacją z pliku template.json:

"SpecialCustomOperations": {
  "**/*.cs": {
    "flagPrefix": "/*add-usings",
    "operations": [
      {
        "type": "replacement",
        "configuration": {
          "original": "/*add-usings",
          "replacement": ""
        }
      },
      {
        "type": "replacement",
        "configuration": {
          "original": "add-usings*/",
          "replacement": ""
        }
      }
    ]
  }
}

Najpierw podaję do jakich plików odnoszą się akcje. Tutaj – wszystkie pliki z rozszerzeniem *.cs. Następnie definuję operacje. Jest kilka predefiniowanych operacji, m.in. „replacement„. Każda z takich operacji ma swoją konfigurację. Niestety na listopad 2022 w schemacie template.json nie ma tego zdefiniowanego, więc nie będziesz miał podpowiedzi w Intellisense.

W związku z tym, że ten artykuł nie jest kompletnym przewodnikiem, masz tutaj stronę z oficjalnej dokumentacji z opisem tych operacji: https://github.com/dotnet/templating/wiki/Reference-for-template.json#global-custom-operations-and-special-custom-operations

Porady

W tym momencie kończę już ten nieco przydługi artykuł. Dam Ci jeszcze kilka porad na koniec.

  1. Przede wszystkim zainstaluj sobie narzędzie do analizy szablonów:
dotnet tool install --global sayedha.template.command

Następnie możesz przeanalizować swój szablon pod kątem problemów:

templates analyze -f <path-to-folder>

Gdzie <path-to-folder> to ścieżka do katalogu z szablonem (katalogu, w którym jest .template.config)

  1. Dodawaj parametr Framework do template.json.
  2. Jeśli masz jakiś problem ze swoim szablonem – Visual Studio widzi starą wersję albo nie widzi go wcale:
    • odinstaluj szablon
    • usuń zawartość katalogu C:\Users\<nazwa użytkownika>\.templateengine\
    • zainstaluj szablon na nowo
    • jeśli to nie pomaga, czasem pomaga restart Visuala, a czasem restart komputera
  3. Jeśli chcesz wejść mocniej w temat, koniecznie odwiedź:

Dzięki za przeczytanie tego artykułu. Jeśli znalazłeś jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu. Daj też znać w komentarzu, jeśli uważasz, że taki artykuł jest za długi i powinien zostać podzielony na dwa 🙂

Podziel się artykułem na:
Konfiguracja i opcje programu w .NET

Konfiguracja i opcje programu w .NET

Wstęp

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ę:

{
  "ConnectionStrings": {
      "MainConnectionString": "cs"
   }
}

To główny connection string możesz pobrać tak:

string mainCs = Configuration["ConnectionStrings:MainConnectionString"];

Jak odczytać ustawienia do obiektu POCO?

Załóżmy, że masz taki plik appsettings.json:

{
"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}
}

I analogiczną klasę – klasa musi mieć publicznie dostępne właściwości do zapisu i odczytu:

public class EmailOptions
{
    public string SmtpAddress { get; set; }
    public string From { get; set; }
    public string FromEmail { get; set; }
}

Teraz musisz skonfigurować te opcje:

services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings"));

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):

  1. 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ń
  2. 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ą:

public IConfiguration Configuration { get; }

public Startup(IConfiguration configuration)
{
	Configuration = configuration;
}

//..
void Foo()
{
  string password = Configuration["tajne-haslo"];
}

Jeśli w konfiguracji masz bardziej zagnieżdżone dane, np:

{
  "EmailSettings": {
    "ServiceMailing": {
      "SmtpAddress": "https://smtp.example.com",
      "From": "Admin",
      "FromEmail": "admin@example.com"
    }
  }
}

posługujesz się dwukropkiem, żeby oddzielić kolejne poziomy, np:

string smtp = Configuration["EmailSettings:ServiceMailing:SmtpAddress"];

Jeśli chcesz tworzyć wielopoziomowe obiekty za pomocą zmiennych środowiskowych, to każdy poziom oddzielasz dwoma podkreślnikami: „__”, np:

setx EmailSettings__ServiceMailing__SmtpAddress "https://smtp.example.com" /M

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)
    {

    }
}

Albo jeszcze gorzej – tak:

public class EmailService
{
#if DEBUG
    const string OPTION_SMTP_ADDRESS = "https://smtp.local.example.com";
#else
    const string OPTION_SMTP_ADDRESS = "https://smtp.example.com";
#endif

    const string OPTION_FROM = "Admin";
    const string OPTION_FROM_EMAIL = "admin@example.com";

    public void SendMail(string msg, string subject)
    {

    }
}

I wtedy metoda SendMail będzie posługiwała się tymi nieszczęsnymi stałymi. Dużo lepszym rozwiązaniem byłoby trzymanie opcji w zupełnie innej klasie:

public class EmailOptions
{
    public string SmtpAddress { get; set; }
    public string From { get; set; }
    public string FromEmail { get; set; }
}

i wstrzyknięcie w jakiś sposób tych opcji do obiektu EmailService. Jest to możliwe. I zaraz pokażę Ci jak.

Konfiguracja opcji

Załóżmy, że Twój plik appsettings.json zawiera taki fragment:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}

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:

services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings"));

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:

IOptionsMonitor<EmailOptions> optionsMonitor;

public EmailService(IOptionsMonitor<EmailOptions> optionsMonitor)
{
    this.optionsMonitor = optionsMonitor;
}

public void SendMail(string msg, string subject)
{
    EmailOptions options = optionsMonitor.CurrentValue;
}

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.

Czym są NamedOptions?

Spójrz na taki plik appsettings.json:

{
  "EmailSettings": {
    "ServiceMailing": {
      "SmtpAddress": "https://smtp.example.com",
      "From": "Admin",
      "FromEmail": "admin@example.com"
    },
    "NewsletterMailing": {
      "SmtpAddress": "https://smtp.news.example.com",
      "From": "John Rambo",
      "FromEmail": "john@news.example.com"
    }
  }
}

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:

services.Configure<EmailOptions>("ServiceMailing", Configuration.GetSection("EmailSettings:ServiceMailing"));
services.Configure<EmailOptions>("NewsletterMailing", Configuration.GetSection("EmailSettings:NewsletterMailing"));

Zwróć uwagę tutaj na dwie rzeczy:

  • 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:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}

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:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "John Rambo",
  "FromEmail": "admin@example.com"
}

Jako twórca biblioteki, stworzyłeś rozszerzenie dla IServiceCollection, które pozwala na konfigurację Twojej biblioteki:

public static IServiceCollection AddEmail(this IServiceCollection services, Action<EmailOptions> configureAction)
{
    EmailOptions emailOptions = new EmailOptions();
    configureAction(emailOptions);

    if (emailOptions.FromEmail.Contains("admin", StringComparison.InvariantCultureIgnoreCase))
        emailOptions.From = "Admin";

    services.Configure<EmailOptions>(options =>
    {
        options.SmtpAddress = emailOptions.SmtpAddress;
        options.FromEmail = emailOptions.FromEmail;
        options.From = emailOptions.From
    });

    services.AddSingleton<EmailService>();

    return services;
}

I niby wszystko jest ok. Na początku umożliwiasz użytkownikowi konfigurację, potem ją sprawdzasz i rejestrujesz odpowiednie opcje.

Użytkownik teraz może w ConfigureServices tak zarejestrować Twój serwis do maili:

services.AddEmail(o =>
{
	o.SmtpAddress = "smtp.example.com";
	o.FromEmail = "admin@example.com";
	o.From = "John Rambo";
});

I wszystko będzie OK. Twoje zabezpieczenie zadziała. Ale co jeśli użytkownik będzie chciał być sprytny i nadpisze ustawienia jeszcze raz?

services.AddEmail(o =>
{
	o.SmtpAddress = "smtp.example.com";
	o.FromEmail = "admin@example.com";
	o.From = "John Rambo";
});

services.Configure<EmailOptions>(o =>
{
	o.From = "John Rambo";
});

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:

public static IServiceCollection AddEmail(this IServiceCollection services, Action<EmailOptions> configureAction)
{
    EmailOptions emailOptions = new EmailOptions();
    configureAction(emailOptions);

    if (emailOptions.FromEmail.Contains("admin", StringComparison.InvariantCultureIgnoreCase))
        emailOptions.From = "Admin";

    services.PostConfigure<EmailOptions>(options =>
    {
        options.SmtpAddress = emailOptions.SmtpAddress;
        options.FromEmail = emailOptions.FromEmail;
        options.From = emailOptions.From;
    });

    services.AddSingleton<EmailService>();

    return services;
}

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.

Nie posługuj się w kodzie dyrektywami w stylu:

#if DEBUG
  connectionString = Configuration["ConnectionStrings:DevConnectionString"];
#else
  connectionString = Configuration["ConnectionStrings:MainConnectionString"];
#endif

Zamiast tego używaj różnych środowisk… SERIO.

Nie trzymaj danych wrażliwych w appsetings

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 jednak temat na osobny artykuł, który możesz przeczytać tutaj.


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 🙂

Obrazek wyróżniający: Tło plik wektorowy utworzone przez mamewmy – pl.freepik.com

Podziel się artykułem na:
Entity Framework w osobnym projekcie

Entity Framework w osobnym projekcie

Jeśli szukasz szybkiego rozwiązania, kliknij tu. Jeśli chcesz się nieco więcej dowiedzieć, przeczytaj cały post.

Wstęp

Gdy tworzymy nową aplikację z identyfikacją użytkowników (Identity) w VisualStudio, domyślny kreator tworzy jeden projekt, do którego pcha wszystkie klasy. Do malutkich rzeczy, czy nauki to w zupełności wystarczy. Jednak w świecie rzeczywistym chcielibyśmy mieć osobny projekt do modeli i osobny projekt dla warstwy danych (Data Access Layer).

Niby nie jest to trudne, wystarczy przenieść nasz DbContext do innego projektu i już. A co z migracjami? Migracje nadal będą się tworzyć w projekcie głównym. Nie o to chodzi. Chcemy migracje też w projekcie z danymi.

Dlaczego to nie jest oczywiste?

Musisz zdać sobie sprawę z tego, jak działają migracje w Entity Framework (czy też EfCore), a także jak działa aktualizacja bazy danych.

Gdy uruchamiasz polecenie Add-Migration lub dotnet ef migrations add, narzędzie uruchamia Twoją główną aplikację. Uruchomienie aplikacji następuje w sposób normalny. Czyli przy aplikacji konsolowej, odpalona zostanie metoda Main. Przy aplikacji webowej, pójdzie cała konfiguracja.

Jednym z kroków jest inicjalizacja Entity Framework, np:

services.AddDbContext<ApplicationDbContext>(options =>
	options.UseSqlServer(
		Configuration.GetConnectionString("DefaultConnection")));

W tym momencie tworzymy połączenie z bazą danych i migracje mogą zostać utworzone. Pamiętaj, że do utworzenia migracji konieczne jest połączenie z bazą danych. Narzędzie musi sprawdzić, jak wygląda baza i jak wygląda model – musi mieć możliwość porównania tego.

Teraz jeśli uruchomisz migrację z parametrem -p, wskazując na konkretny projekt, np:

Add-Migration InitialDbCreate -p DataAccessLayer

Entity Framework będzie próbowało uruchomić projekt DataAccessLayer. Jeśli jest to zwykła biblioteka klas (class library), no to co się uruchomi? Nic. Dlatego też migracja nie będzie mogła się odbyć.

Ale można to nieco obejść. Narzędzie poszuka jeszcze klasy, która implementuje pewien interfejs. Jeśli znajdzie taką, utworzy jej obiekt i za jej pomocą skonfiguruje połączenie z bazą danych.

Rozwiązanie

  1. W swoim projekcie z danymi (tam, gdzie masz DbContext i chcesz mieć migracje) musisz utworzyć klasę implementującą specjalny interfejs IDesignTimeDbContextFactory. Ef właśnie tego poszuka (jeśli używasz Sql Servera, dodaj pakiet nuget: Microsoft.EntityFrameworkCore.SqlServer):
public class DbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
	public XMoneyDbContext CreateDbContext(string[] args)
	{
		DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();

        optionsBuilder.UseSqlServer("tutaj Twój connection string")

        return new ApplicationDbContext(optionsBuilder.Options);
	}
}

Przeanalizujmy go:

  • deklarujesz fabrykę kontekstu bazy danych (Ef poszuka właśnie klasy implementującej ten interfejs), parametrem generycznym jest oczywiście Twój kontekst bazy danych.
  • najpierw tworzysz buildera do opcji kontekstu
  • ustawiasz opcje (np. UseSqlServer) i connection string
  • tworzysz swój kontekst i zwracasz go

I to właściwie tyle. Możesz już teraz uruchomić migrację z przełącznikiem -p:

Add-Migration NazwaMigracji -p NazwaTwojegoProjektu

lub

dotnet ef migrations add NazwaMigracji -p NazwaTwojegoProjektu

Podziel się artykułem na: