Jak korzystać z dobrodziejstw .NetCore w aplikacjach natywnych (Wpf, Console, Xamarin, WinForms)
Wstęp
Jak już raz wejdziesz w internetowy świat .NET i później przyjdzie ci coś zrobić na konsoli albo innej aplikacji natywnej (Wpf, Winforms, Xamarin), to nagle się okazuje, że brakuje rzeczy. Nie ma dependency injection (trzeba pobierać np. starego, dobrego Autofaca), konfiguracji z appsettings i wielu innych mechanizmów, do których nas internetowy .NetCore przyzwyczaił.
Ale to nie znaczy, że nie można ich tam w prosty sposób umieścić.
Większość przykładów zrobimy na konsoli, bo jest najprościej. Jednak na koniec pokażę też przykłady w innych typach aplikacji.
W artykule o wstrzykiwaniu zależności pokazałem jak dodać DI do aplikacji konsolowej. Dzisiaj zrobimy pełen pakiet.
Co to Host?
Na początku stwórz nową aplikację konsolową. Po staremu to klasa Program
ze statyczną metodą Main
. W .NET6 to po prostu jedna linijka w pliku Program.cs
:
Console.WriteLine("Hello, World!");
I tutaj dzieje się wszystko. Możesz wypisywać komunikaty na konsoli, możesz pobierać dane od użytkownika, tworzyć obiekty, zwalniać je itd. Jednym zdaniem – to jest serce całej aplikacji (po staremu – metoda Main
z klasy Program
). Można powiedzieć, że metoda Main
jest w pewnym sensie hostem dla Twojej aplikacji.
A gdyby teraz przenieść zarządzanie tym wszystkim do innej klasy? I tak powstała klasa Host
, która implementuje interfejs IHost
. Host zajmuje się całym cyklem życia aplikacji. Zajmuje się konfiguracją, wstrzykiwaniem zależności, zwalnianiem zasobów, tworzeniem ich itd.
Żeby jej użyć, przede wszystkim musisz pobrać pakiet z NuGet: Microsoft.Extensions.Hosting.
Jednak, żeby utworzyć hosta, musisz posłużyć się HostBuilderem
Co to HostBuilder?
To budowniczy (w sensie wzorca projektowego) dla klasy Host
. Najprostszy sposób na utworzenie Hosta to:
using Microsoft.Extensions.Hosting;
var hostBuilder = new HostBuilder();
var host = hostBuilder.Build();
host.Start();
Mamy tutaj utworzenie hosta i uruchomienie aplikacji (Start
).
Jednak czym by był budowniczy, gdyby budował tylko takiego prymitywnego hosta? Zwykłym pijakiem spod bramy…
Budowanie hosta
Możesz zbudować domyślnego hosta na skróty w taki sposób:
using Microsoft.Extensions.Hosting;
var hostBuilder = Host.CreateDefaultBuilder();
var host = hostBuilder.Build();
await host.Run();
Cała magia zadzieje się w metodzie CreateDefaultBuilder
. Ta metoda utworzy i zwróci ci domyślnego buildera, który ma już oprogramowane czytanie konfiguracji z appsettings, zmiennych środowiskowych i linii poleceń, utworzenie dependency injection i rejestrację podstawowych klas. W tym – domyślnej klasy Host
.
Można powiedzieć, że to jest cała podstawa tego, co byś chciał mieć. Nic więcej, nic mniej.
Teraz możesz zadać pytanie – co stanie się w momencie wywołania host.Run()
?
Obiekt Host poszuka zarejestrowanych obiektów implementujących IHostedService
. I po kolei na każdym z nich wywoła metodę StartAsync
. W tym przypadku oczywiście nie znajdzie takich obiektów, ponieważ nie utworzyłeś ich. Zatem praca od razu się zakończy. Wniosek z tego taki, że musisz stworzyć przynajmniej jedną klasę implementującą IHostedService
.
IHostedService
Od tej pory pomyśl o swojej aplikacji konsolowej jako o „hoście”. Twoja główna aplikacja (po staremu metoda Main
) ma za zadanie utworzyć i uruchomić obiekty IHostedService
. I to w tych obiektach będzie dział się cały „prawdziwy” program. Stwórzmy taką klasę:
internal class MainApplication : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
Console.Write("Jak masz na imię? ");
string name = Console.ReadLine();
Console.WriteLine($"Cześć {name}!");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
//ta metoda zostanie wykonana na zakończenie Twojego programu
}
}
Teraz musimy zarejestrować gdzieś, ten IHostedService
:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IHostedService, MainApplication>();
});
var host = hostBuilder.Build();
host.Run();
Jeśli chciałbyś ten kod porównać do starej, dobrej konsoli, to wyglądałoby to mniej więcej tak:
public static void Main(string[] args)
{
var app = new MainApplication();
app.StartAsync(CancellationToken.None).Wait();
app.StopAsync(CancellationToken.None).Wait();
}
Po takiej prostej konfiguracji masz już:
- ustawienie
ContentRootPath
na katalog aplikacji - wczytanie konfiguracji hosta ze zmiennych środowiskowych z prefixem
DOTNET_
- wczytanie konfiguracji z linii poleceń
- wczytanie konfiguracji z plików appsettings i sekretów
- wczytanie konfiguracji ze zmiennych środowiskowych i linii poleceń
- dodanie domyślnego mechanizmu logowania
- no i rzecz jasna Dependency Injection (w tym z automatu zarejestrowane
IHostEnvironment
)
Run, RunAsync, Start, StartAsync? WTF?
Jak się zapewne zdążyłeś zorientować, Host
(a właściwie interfejs IHost
) zawiera 4 metody do startowania aplikacji: Run
, RunAsync
, Start
, StartAsync
. Nie rozróżniając na synchroniczne i asynchroniczne mamy dwie – Run
i Start
. Czym się różnią? Poniżej „aplikacja” zrozum jako „wszystkie zarejestrowane klasy IHostedServices
„.
Run
To jest extension method. W kodzie frameworka wygląda ona tak:
host.RunAsync().GetAwaiter().GetResult();
Uruchamia aplikację i blokuje aktualny wątek do momentu zakończenia aplikacji (wszystkie IHostedServices
się skończą).
RunAsync
To również jest extension method. Ona wywołuje StartAsync
i czeka na zakończenie aplikacji:
await host.StartAsync(token).ConfigureAwait(false);
await host.WaitForShutdownAsync(token).ConfigureAwait(false);
Na koniec wywołuje Dispose
(lub DisposeAsync
) na rzecz hosta.
Start
Wywołuje StartAsync
analogicznie jak Run
wywołuje RunAsync
:
host.StartAsync().GetAwaiter().GetResult();
StartAsync
Startuje hosta asynchronicznie. Działa w taki sposób:
- loguje na konsoli start aplikacji
- wywołuje metodę
WaitForAsync
na rzeczIHostLifetime
- wywołuje asynchronicznie
StartAsync
na wszystkich zarejestrowanych obiektachIHostedService
- powiadamia
IHostLifetime
, że aplikacja się rozpoczęła
Czyli widać z tego, że wszystko sprowadza się i tak do StartAsync
. Metody Start
i Run
są tylko pomocnicze. RunAsync
dodatkowo czeka na zakończenie aplikacji przez IHostLifetime
. Dokładną różnicę między StartAsync
, a RunAsync
, zobaczysz po zakończeniu aplikacji konsolowej.
Jeśli użyjesz RunAsync
, to domyślny IHostLifetime
zakończy aplikację tylko przy zamknięciu okna konsoli lub wciśnięciu Ctrl+C.
Jeśli użyjesz StartAsync
, aplikacja zostanie zakończona normalnie, ale w oknie konsoli będziesz mógł przeczytać dodatkowe informacje zanim je zamkniesz.
IHostLifetime
jest domyślnie ustawiany w CreateDefaultBuilder
. Domyślnie jest to ConsoleLifetime
. Oczywiście nic nie stoi na przeszkodzie, żebyś stworzył własną implementację IHostLifetime
i np. odłożył faktyczne uruchomienie programu do jakiegoś momentu. Jednak to nie jest o tym artykuł 🙂
Dodatki w .NET8
W .NET8 doszedł interfejs, który daje Ci nieco większą kontrolę nad cyklem życia Twojego hosta. IHostedLifecycleService wygląda w taki sposób:
public interface IHostedLifecycleService : IHostedService
{
Task StartingAsync(CancellationToken cancellationToken);
Task StartedAsync(CancellationToken cancellationToken);
Task StoppingAsync(CancellationToken cancellationToken);
Task StoppedAsync(CancellationToken cancellationToken);
}
Jak widzisz, IHostedLifecycleService implementuje już IHostedService. Więc jeśli chcesz mieć tę dodatkową kontrolę, Twój host powinien implementować IHostedLifecycleService.
To teraz przypatrzmy się jaki jest konkretny cykl życia takiego hosta. Te metody są wykonywane po kolei:
- IHostLifetime.WaitForStartAsync
- IHostedLifecycleService.StartingAsync
- IHostedService.Start
- IHostedLifecycleService.StartedAsync
- IHostApplicationLifetime.ApplicationStarted
- IHostedLifecycleService.StoppingAsync
- IHostApplicationLifetime.ApplicationStopping
- IHostedService.Stop
- IHostedLifecycleService.StoppedAsync
- IHostApplicationLifetime.ApplicationStopped
- IHostLifetime.StopAsync
Środowisko
Pamiętaj, że jeśli w taki sposób tworzysz hosta, środowiskiem będzie Production. .NET uzna to za środowisko produkcyjne, ponieważ nigdzie nie znalazł zmiennej ASPNETCORE_ENVIRONMENT
. Oczywiście, jeśli taką zmienną środowiskową masz wpisaną na swojej maszynie lub przekażesz ją w parametrach, to zadziała i środowisko będzie takie jakie sobie wpiszesz. Możesz też na sztywno posłużyć się metodą UseEnvironment
:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IHostedService, MainApplication>();
})
.UseEnvironment("Development");
Możesz też zrobić sobie plik launchSettings.json – analogicznie jak w internetowej wersji. Np:
{
"ENVIRONMENT": "Development"
}
Ten plik jednak nie jest automatycznie wczytywany (w przeciwieństwie do appsettings), więc musisz go dodać do konfiguracji ręcznie:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IHostedService, MainApplication>();
})
.ConfigureHostConfiguration(config =>
{
config.AddJsonFile("launchSettings.json");
});
Zwróć uwagę na dwie rzeczy:
- w takim przypadku w launchSettings.json zmienne środowiskowe nie powinny być prefixowane. Dlatego jest
ENVIRONMENT
, zamiastASPNETCORE_ENVIRONMENT
- plik launchSettings.json powinien się znaleźć w katalogu wynikowym aplikacji. A więc upewnij się, że w jego właściwościach zaznaczysz opcję Copy to Output Directory na Copy always lub Copy if newer.
Teraz możesz sterować swoim środowiskiem z tego pliku. Możesz oczywiście wpisywać tam wszelkie zmienne środowiskowe, jakie Ci się zamarzą.
Konfiguracja
W powyższym kodzie cała konfiguracja aplikacji powinna odbyć się w ConfigureServices
. Tak jak w przykładzie rejestruję MainApplication
:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IHostedService, MainApplication>();
})
.ConfigureHostConfiguration(config =>
{
config.AddJsonFile("launchSettings.json");
});
Jeśli jednak tak jak ja – w kodzie musisz mieć porządek, przenieś to do innej klasy lub metody. Na początek stwórz prostą klasę Startup
– powinieneś znać ją z internetowej wersji .NETCore. Różnica jest taka, że w tej klasie nie definiujemy middleware pipeline, bo go po prostu nie ma:
internal class Startup
{
IConfiguration config;
ILogger<Startup> logger;
public Startup(IConfiguration config, ILogger<Startup> logger)
{
this.config = config;
this.logger = logger;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IHostedService, MainApplication>();
}
}
W konstruktorze wstrzykuję konfigurację i loggera. Zwróć uwagę, że te obiekty zostały zarejestrowane w pierwszym kroku budowania hosta – ConfigureHostConfiguration
w metodzie CreateDefaultBuilder
.
A teraz swojego hosta stwórz w taki sposób:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<Startup>();
var provider = services.BuildServiceProvider();
var startup = provider.GetRequiredService<Startup>();
startup.ConfigureServices(services);
})
.ConfigureHostConfiguration(config =>
{
config.AddJsonFile("launchSettings.json");
});
Spójrz, co się stało w ConfigureServices
.
Najpierw zarejestrowałem naszą klasę Startup
. Równie dobrze mógłbym ją utworzyć przez new, ale tak jest bardziej elegancko i możesz wstrzyknąć do konstruktora klasy Startup
wszystko, co zostało zarejestrowane przed BuildServiceProvider
(5 linijka)
Następnie w linijce 5 budujemy service providera. Powinieneś już wiedzieć (przynajmniej z tego artykułu), że to jest obiekt, który zwróci zarejestrowane wcześniej serwisy.
Teraz małe wyjaśnienie. ServiceProvider
może być budowany kilka razy na podstawie jednego IServiceCollection
. Dlatego w tym momencie otrzymamy dostęp do serwisów zarejestrowanych wcześniej i możemy pobrać obiekt klasy Startup
. Co więcej, możemy dalej posługiwać się IServiceCollection
i przekazać go jako parametr do metody ConfigureServices
(linijka 8).
Na sam koniec ServiceProvider
zostanie znowu utworzony (to się dzieje podczas tworzenia hosta) i wszystkie serwisy rejestrowanie w klasie Startup
również będą dostępne.
Jak dodać appsettings?
Domyślna konfiguracja odczytuje pliki appsettings
z katalogu aplikacji. Czyli teoretycznie niczego nie musisz robić. Zwłaszcza jeśli masz tylko jeden plik appsettings.json
– do tego powinieneś dążyć na środowisku produkcyjnym. Upewnij się tylko, że plik znajduje się w katalogu wynikowym aplikacji (właściwość pliku Copy to Output Directory ustaw na Copy always lub Copy if newer).
Jest też druga możliwość. Dodanie appsettings
do zasobów aplikacji i wczytywanie ich stamtąd. Ale to nie jest artykuł o tym, niedługo coś na ten temat skrobnę.
UWAGA!
Pamiętaj, że nie powinieneś w pliku appsettings przechowywać ŻADNYCH sekretów aplikacji. Wszelkie hasła, dostępy do kont i inne wrażliwe dane muszą być przechowywane w sposób bezpieczny. Pamiętaj, że nawet jeśli umieścisz plik appsettings.json
w zasobach aplikacji, to nie ma problemu dla użytkownika, żeby sobie go wyekstrahować i przeczytać.
Przykład w WPF
Teraz, jak obiecałem na początku, pokażę Ci kilka przykładów innych niż aplikacja konsolowa. Na początek aplikacja WPF.
O ile w aplikacji konsolowej musiałeś stworzyć implementację IHostedService
(bo gdzieś ten program musi być), to w WPF nie musisz już tego robić. Wystarczy, że odpalisz główne okno aplikacji.
Oczywiście przede wszystkim musisz zacząć od małej konfiguracji aplikacji – żeby nie uruchamiała okna głównego, tylko zdarzenie OnStartup
w klasie Application
. W tym celu w pliku App.xaml
ustaw:
<Application x:Class="Wpf.App"
Startup="Application_Startup"
ShutdownMode="OnMainWindowClose">
Metoda Application_Startup zostanie uruchomiona podczas uruchamiania aplikacji. Jeśli tego nie zrobisz, automatycznie uruchomione zostanie okno główne.
Jeśli chodzi o ShutdownMode
to gdy nie ustawisz tej wartości, aplikacja po zamknięciu okna głównego nigdy się nie zakończy. Dlatego też ustaw to. Następnie skonfiguruj .NET.
W konstruktorze klasy App
utwórz hosta:
IHost host;
public App()
{
host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs()) //przekaż linię poleceń
.ConfigureServices((ctx, services) =>
{
s.AddSingleton<Startup>();
var tempServices = services.BuildServiceProvider();
var startup = tempServices.GetRequiredService<Startup>();
startup.ConfigureServices(services);
}).Build();
}
W następnym kroku możesz uruchomić hosta w Application_Startup
:
private void Application_Startup(object sender, StartupEventArgs e)
{
host.Start();
MainWindow = host.Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
Najpierw uruchamiamy hosta. Potem pobieramy zarejestrowany serwis MainWindow
-> w taki sposób, mając hosta, możesz pobrać każdy zarejestrowany serwis (łącznie z konfiguracją).
Gdy masz już MainWindow
– po prostu pokazujesz je.
A jak rejestrujesz MainWindow
? W tym przykładzie użyłem analogicznej klasy Startup
jak w sekcji wyżej, po prostu:
internal class Startup
{
IConfiguration config;
ILogger<Startup> logger;
public Startup(IConfiguration config, ILogger<Startup> logger)
{
this.config = config;
this.logger = logger;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<MainWindow>();
}
}
W aplikacji WinForms zrobisz to analogicznie.
Przykład w Xamarin
Oczywiście z Xamarin jest trochę więcej roboty. Chociaż w MAUI ma to już być w standardzie. Yeah!
Ale póki co, spójrzmy na Xamarin.
W związku z tym, że Xamarin może mieć tak naprawdę kilka aplikacji (np. Android i iPhone) jest trochę więcej kombinacji.
Na początek spójrz do projektu Xamarin (tego współdzielonego) do pliku App.xaml.cs
. To jest klasa aplikacji. Dodaj tam konstruktor:
public readonly IHost host;
public App(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
{
host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs())
.ConfigureServices((ctx, b) =>
{
b.AddSingleton<Startup>();
b.AddSingleton<App>(this);
configureNativeServicesAction?.Invoke(ctx, b);
})
.ConfigureServices((ctx, b) =>
{
var tempServices = b.BuildServiceProvider();
var startup = tempServices.GetRequiredService<Startup>();
startup.ConfigureServices(this, b);
})
.Build();
InitializeComponent();
MainPage = new NavigationPage(new MainPage());
}
W konstruktorze przyjmujesz akcję do rejestracji pewnych natywnych serwisów, których być może używasz. Pisząc „natywne” mam na myśli takie, że na aplikacji Androidowej i na aplikacji iPhone się różnią. Ich implementacje są związane z urządzeniem.
W linii 10 wywołujesz tę akcję. Następnie wszystko jest analogiczne jak przy WPF z wyjątkiem tworzenia głównego okna.
Następnie w tym samym projekcie (Xamarin) stwórz sobie klasę do inicjalizacji aplikacji:
public static class AppInitializer
{
static App theApp;
static bool isInitialized = false;
public static void Init(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
{
if (isInitialized)
return;
theApp = new App(startupAssembly, configureNativeServicesAction);
}
public static App GetApplication()
{
return theApp;
}
}
Klasa App
to oczywiście klasa z pliku App.xaml.cs
z projektu Xamarina z odpowiednim konstruktorem.
Na koniec użyj tego w aplikacji natywnej, np. Android. W metodzie OnCreate
klasy MainActivity
uruchom to wszystko:
protected override void OnCreate(Bundle savedInstanceState)
{
AppInitializer.Init(ConfigureNativeServices);
LoadApplication(AppInitializer.GetApplication());
}
void ConfigureNativeServices(HostBuilderContext ctx, IServiceCollection services)
{
//tutaj zarejestruj serwisy natywne
}
No i to wszystko.
Zakończenie
Zachęcam Cię do poeksperymentowania z HostBuilderem jak i samym Hostem. Możesz naprawdę sporo tym wyciągnąć. I wcale nie musisz posługiwać się metodą CreateDefaultBuilder
. Możesz skonfigurować wszystko sam krok po kroku, używając dostępnych metod.
Dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i jak zwykle zachęcam do zostawienia swojego adresu poniżej – dzięki temu nie ominie Cię żadna dawka wiedzy 🙂