Jak korzystać z dobrodziejstw .NetCore w aplikacjach natywnych (Wpf, Console, Xamarin, WinForms)

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 rzecz IHostLifetime
  • wywołuje asynchronicznie StartAsync na wszystkich zarejestrowanych obiektach IHostedService
  • 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ł 🙂

Ś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, zamiast ASPNETCORE_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 🙂

Podziel się artykułem na: