Microsoft Extensions Hosting & Wrap-up

8. Juni 2020

Der Bereich Hosting in den Microsoft Extensions fällt etwas aus dem Rahmen: Es gibt kein Pendant dazu im klassischen .NET Framework, vielmehr vereinheitlicht Hosting unterschiedliche Laufzeitumgebungen. Und anders als die anderen Bereiche stellt Hosting nicht “nur” Funktionalität zur Verfügung, Hosting bringt auch die anderen Themen in Zusammenhang und etabliert Konventionen zum Umgang damit.

Hosting Quickstart

Gehen wir das Ganze einmal grob durch, bevor wir in die Details einsteigen:

  • Es gibt Host-Klassen für unterschiedliche Belange, etwa Microsoft.Extensions.Hosting.Host für Konsolenanwendungen oder Windows Services und Microsoft.AspNetCore.WebHost für ASP.NET Core-Anwendungen. Diese Klassen tun aber nichts weiter, als einen HostBuilder für den jeweiligen Bedarf zu erzeugen, indem sie dort bestimmte Dinge vorkonfigurieren.
  • Ein HostBuilder sammelt alle Informationen zum Aufbau eines Hosts. Das schließt vor allem Callbacks zum Aufsetzen der Konfiguration oder Dependency Injection mit ein. Hier muss mindestens ein “Hosted Service” registriert werden, damit die Anwendung etwas Sinnvolles tut.
  • Im Build() erzeugt der HostBuilder die Konfigurationen und den DI-Container. Dabei werden einige Klassen zusätzlich registriert, u.a. die eigentliche Host-Klasse (die nichts mit der oben genannten zu tun hat!). Eine Referenz auf diese wird als Ergebnis zurückgeliefert.
  • Über Erweiterungsmethoden Run() oder RunAsync() wird die Anwendung gestartet. Der Host startet dann irgendwann die registrierten Hosted Services.

Ein Beispiel, um zu illustrieren, wie sich das aus Sicht des Entwicklers darstellt:

   1: static void Main(string[] args)

   2: {

   3:     Host.CreateDefaultBuilder(args)

   4:         .ConfigureServices((hostContext, services) => services.AddHostedService<MyHostedService>())

   5:         .Build()

   6:         .Run();

   7: }

Der Hosted Service muss die Schnittstelle IHostedService implementieren:

   1: public class MyHostedService : IHostedService

   2: {

   3:     private readonly ILogger<MyHostedService> _logger;

   4:

   5:     public MyHostedService(ILogger<MyHostedService> logger)

   6:     {

   7:         _logger = logger;

   8:     }

   9:

  10:     public Task StartAsync(CancellationToken cancellationToken)

  11:     {

  12:         _logger.LogInformation("Start...");

  13:         return Task.CompletedTask;

  14:     }

  15:

  16:     public Task StopAsync(CancellationToken cancellationToken)

  17:     {

  18:         _logger.LogInformation("Stop...");

  19:         return Task.CompletedTask;

  20:     }

  21: }

Die Anwendung läuft damit, tut aber noch nichts Besonderes:

image

Damit wird auch klar: Hosting adressiert Anwendungen, die starten und dann eine oder mehrerer Hintergrundaktivitäten laufen lassen. Dies kann ein Web-Server sein, ein eigener Hosted Service, der etwa einen WCF-Service startet, oder auch – wie wir unten sehen werden – eine WPF- oder WinForms-Anwendung. Für typische Kommandozeilenanwendungen ist Hosting hingegen weniger geeignet.

Gehen wir jetzt etwas mehr in die Details.

Der Aufbau des Hosts

Im Build() geht der HostBuilder in einer klar definierten Reihenfolge vor und arbeitet dabei auch die jeweils registrierten Callback ab:

  • Die Host-Konfiguration wird erzeugt.
  • Der HostBuilderContext wird mit Anwendungsname und Umgebung, sowie der Host-Konfiguration erstellt und an nachfolgende Aufrufe weitergereicht.
  • Die App-Konfiguration wird erstellt.
  • Der ServiceProvider für Dependency Injection wird erstellt (inklusive Logging).
  • Über den ServiceProvider wird IHost aufgelöst und zurückgeliefert.

Wie man erkennt, unterscheidet der HostBuilder zwei Konfigurationen: Die Host-Konfiguration und die App-Konfiguration. Die Host-Konfiguration wird zuerst aufgebaut und enthält u.a. die Kommandozeilenargumente und einen Teil der Umgebungsvariablen (alle, die mit “DOTNET_” beginnen).

Im Anschluss steht die Host-Konfiguration dann für das Erstellen der App-Konfiguration zur Verfügung. Der Host nutzt das für folgende Registrierung:

   1: var env = hostingContext.HostingEnvironment; // entspricht _hostConfiguration[HostDefaults.EnvironmentKey]

   2: config

   3:     .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)

   4:     .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

Neben der Datei appsettings.json wird noch eine umgebungsabhängige Datei geladen – wobei die Umgebung aus der Host-Konfiguration kommt. Durch dieses zweistufige Verfahren umgeht Hosting das Problem, dass ein Configuration Provider nicht auf die Konfiguration zugreifen kann, die er gerade mit aufbaut.
Beim Erstellen des Service Providers werden drei für die Laufzeit wichtige Komponenten registriert: IHost, IHostLifetime und IHostApplicationLifetime. Für eine vierte muss man selbst sorgen: IHostedService.

Laufzeit

IHost stellt den Einstiegspunkt für die Anwendung zur Verfügung, der im Beispiel eingangs von Run() aufgerufen wird (eigentlich eine Extension-Methode). Umgesetzt wird diese Schnittstelle von Microsoft.Extensions.Hosting.Internal.Host (nicht zu verwechseln mit der Klasse Host, die den HostBuilder erzeugt – warum zwei Klassen mit unterschiedlichen Aufgaben den gleichen Namen tragen müssen, bleibt wohl Microsoft’s Geheimnis). Die für Anwendungsentwickler wichtigste Funktion dieser Klasse: Sie sucht alle registrierten IHostedService und ruft darauf StartAsync() bzw. StopAsync() auf (die beiden einzigen Methoden in dieser Schnittstelle).

Host tut dies in Abstimmung mit IHostLifetime. Diese Klasse übernimmt die Steuerung basierend auf der eigentlichen Laufzeitumgebung, also z.B. für Kommandozeilenanwendungen oder für Windows Services. Als Entwickler kommt man mit IHostLifetime nur indirekt in Kontakt, zum Beispiel wenn man auf dem HostBuilder die Methode UseWindowsService() aufruft. UseConsoleLifetime() existiert zwar, ist aber der Standard und muss deshalb nicht explizit aufgerufen werden.

UseWindowsService() nutzt einen weiteren Trick: Es wird nur aktiv, wenn die Anwendung tatsächlich als Windows Service gestartet wurde. In diesem Fall wird die WindowsServiceLifetime genutzt, die von System.ServiceProcess.ServiceBase ableitet – der Basisklasse für Windows Services in .NET. Wird die gleiche Anwendung jedoch interaktiv gestartet, dann bleibt die ConsoleLifetime aktiv. Die Anwendungslogik, die als IHostedService umgesetzt ist, bekommt davon nichts mit. Es ist also sehr leicht, einen Service interaktiv zu entwickeln, zu debuggen, etc., und dann als Windows Service zu deployen. Etwas mehr Arbeit hat man allerdings bei der Registrierung des Windows Service; die alten Installer sind hier eindeutig komfortabler, als PowerShell-Aufrufe.

Die einzige noch nicht erklärte Schnittstelle ist IHostApplicationLifetime: Darüber kann man die Anwendung per Aufruf im Code beenden.

Hosted Services

Wir wissen bereits, dass eine Anwendung eigenen Code in den Lebenszyklus der Anwendung einklinken kann, indem sie einen oder mehrere Hosted Services registriert. Ein Hosted Service muss folgende Schnittstelle implementieren:

   1: public interface IHostedService

   2: {

   3:     Task StartAsync(CancellationToken cancellationToken);

   4:     Task StopAsync(CancellationToken cancellationToken);

   5: }

Es ist sicher keine große Herausforderung, einen WCF-Service im StartAsync() zu starten und im StopAsync() zu beenden. Gleiches gilt für jede andere Art von Hintergrundverarbeitung, die man gedanklich mit Server-Anwendungen assoziiert.

Aber wie sieht das mit WPF- oder WinForms-Anwendungen aus?

Ein Hosted Service für WPF

Ein passender Hosted Service für WPF ist gar nicht so kompliziert, auch wenn man ein paar Dinge beachten muss.

Schritt 1: Ein WPF-Projekt anlegen.

Im Beispiel nutze ich die Vorlage für .NET 4.6.1. Dazu muss man eine Nuget-Referenz auf Microsoft.Extensions.Hosting setzen. Dieses Paket bringt über seine Abhängigkeiten alles mit, was man im ersten Schritt vermutlich braucht.

Außerdem muss (für dieses Beispiel) in der App.xaml die Angabe der StartupUri gelöscht werden.
Schritt 2: Einen Hosted Service anlegen, der den GUI-Thread startet.

Wir werden einige Services benötigen, also lassen wir uns die über Dependency Injection hereinreichen:

   1: public WpfHostedService(ILogger<WpfHostedService> logger, IServiceProvider serviceProvider, IHostApplicationLifetime hostApplicationLifetime)

   2: {

   3:     _logger = logger;

   4:     _serviceProvider = serviceProvider;

   5:     _hostApplicationLifetime = hostApplicationLifetime;

   6: }

Der Service muss im StartAsync() einen GUI-Thread starten. Beachten muss man dabei nur, dass dieser als STA-Thread markiert ist. Bei WinForms-Anwendungen übernimmt das das Attribut [STAThread] auf der Main()-Methode, bei WPF-Anwendungen gilt das genauso, wird aber nicht sichtbar, weil der Code im Hintergrund zur App.xaml generiert wird.

   1: Task IHostedService.StartAsync(CancellationToken cancellationToken)

   2: {

   3:     // GUI Thread starten

   4:     _thread = new Thread(WpfThread) { Name = "~ WPF thread ~" };

   5:     _thread.SetApartmentState(ApartmentState.STA); // Ersatz für [STAThread] auf main()

   6:     _thread.Start();

   7:

   8:     return Task.CompletedTask;

   9: }

Der Thread ermittelt eine Instanz der App-Klasse und des zu startenden Fensters über den ServiceProvider und kümmert sich zunächst um deren Initialisierung.

Unter bestimmten Umständen enthält der generierte Code (den man sich in der Datei ./obj/Debug/App.g.i.cs anschauen kann) die Methode InitializeComponent(), die man nur per Reflection aufrufen kann:

   1: private void WpfThread()

   2: {

   3:     _logger.LogInformation("WPF App starting...");

   4:     _app = _serviceProvider.GetRequiredService<Application>();

   5:     // Falls vorhanden, wird in der App-Klasse InitializeComponent() in der generierten Main()-Methode aufgerufen; 

   6:     // => das müssen wir hier von Hand machen.

   7:     // Achtung! Nur vorhanden, wenn z.B. StartupUri in app.xaml gesetzt wird

   8:     _app.GetType().GetMethod("InitializeComponent")?.Invoke(_app, new object[0]);

   9:     if (_app.StartupUri == null)

  10:     {

  11:         _app.MainWindow = _serviceProvider.GetService<Window>();

  12:         _app.MainWindow.Show();

  13:     }

  14:     _app.Run();

  15:     _app = null;

  16:     _logger.LogInformation("WPF App stopped...");

  17:     if (!_consoleShutdown)

  18:         _hostApplicationLifetime.StopApplication();

  19: }

Durch das _app.Run() wird die WPF-App gestartet und mit ihr das Hauptfenster; die Methode kehrt erst dann zurück, wenn das Hauptfenster geschlossen wird.

Da unser Hosted Service damit seine Arbeit getan hat, sollte die Anwendung insgesamt beendet werden. Hier kommt die oben angesprochene Schnittstelle IHostApplicationLifetime ins Spiel, über die man den Host beenden kann. (Die Prüfung von _consoleShutdown wird unten erklärt.) Dies wird eine Aufruf von StopAsync() nach sich ziehen, was wir aber für den Moment ignorieren.
Schritt 3: Den Hosted Service registrieren.

Wie üblich stellen wir eine Erweiterungsmethode bereit, um die WPF-App und den Hosted Service zu registrieren:

   1: public static class WpfHostingServiceCollectionExtensions

   2: {

   3:     public static IServiceCollection AddWpfApp<TApp, TWindow>(this IServiceCollection services)

   4:         where TApp : Application

   5:         where TWindow : Window

   6:     {

   7:         services.AddTransient<Application, TApp>();

   8:         services.AddTransient<Window, TWindow>();

   9:         services.AddHostedService<WpfHostedService>();

  10:         return services;

  11:     }

  12: }

Dank der Registrierung konnte die App-Instanz und das Fenster weiter oben über den ServiceProvider aufgelöst werden.
Schritt 4: Eine Program-Klasse anlegen, die die Main()-Methode enthält.

Eine WPF-Anwendung nutzt eine generierte Main()-Methode, die für unsere Belange nicht nutzbar ist. Daher benötigen wir eine eigene Umsetzung, die aber einfach genug ist:

   1: public static class Program

   2: {

   3:     public static void Main(string[] args)

   4:     {

   5:         Host

   6:             .CreateDefaultBuilder(args)

   7:             .ConfigureServices(services => services.AddWpfApp<App, MainWindow>())

   8:             .Build()

   9:             .Run();

  10:     }

  11: }

Anders als die generierte Main()-Methode (bzw. das Pendant für WinForms) hat diese Methode kein [STAThread]-Attribut.

Schritt 5: Die Projekteinstellungen anpassen.

Nachdem das Projekt einmal gebaut (und ggf. neu geladen wurde) muss man in den Projekteinstellungen das “Startup object” auf die Program-Klasse umstellen.

Lässt man jetzt die Anwendung laufen, erscheint – Trommelwirbel! – das Fenster der WPF-Anwendung. Und nach dem Schließen des Fensters wird die Anwendung sauber beendet.
Schritt 6: Als nächstes wollen wir noch den Umstand nutzen, dass das Fenster jetzt über Dependency Injection erzeugt wird. Es ist einfach, der Klasse einen weiteren Konstruktor mit den entsprechenden Abhängigkeiten zu geben:

   1: public MainWindow(IHostEnvironment hostEnvironment)

   2: {

   3:     InitializeComponent();

   4:

   5:     this.Title = hostEnvironment.ApplicationName + " (" + hostEnvironment.EnvironmentName + ")";

   6: }

Et voilà:

image

Schritt 7: Den Hosted Service über die Konsole beenden.

Nochmal zu den Projekteinstellungen: Man kann spaßeshalber in den Projekteinstellungen den “Output type” auf “Console Application” umstellen. Startet man die Anwendung jetzt, so erscheint zunächst eine Konsole, auf der die Logger-Ausgaben ausgegeben werden:

image

Das WPF-Fenster erscheint zusätzlich. Sehr nett während der Entwicklung, wenn man diese Ausgaben sehen kann, ohne erst den Debugger starten zu müssen.

Allerdings tut sich auch eine Lücke auf: Auf der Konsole kann man Ctrl-C drücken, um die Anwendung zu beenden. Diese versucht das auch, bleibt dabei aber hängen.

Grund dafür ist, dass wir in StopAsync() landen, obwohl die WPF-App noch läuft. Wir benötigen hier also eine Fallunterscheidung, ob die App bereits geschlossen wurde (der Fall oben), oder ob das noch passieren muss. In diesem Fall darf dann der Host nicht nochmal beendet werden, weshalb ich oben bereits die Prüfung auf  _consoleShutdown in den Code geschummelt hatte.

Die StopAsync()-Methode sieht damit folgendermaßen aus:

   1: Task IHostedService.StopAsync(CancellationToken cancellationToken)

   2: {

   3:     if (_app != null)

   4:     {

   5:         // Auf der Console wurde Ctrl-C gedrückt...

   6:         // => App beenden, aber ohne nochmal StopApplication aufzurufen!

   7:         _consoleShutdown = true;

   8:         _app.Dispatcher.Invoke(() => _app.Shutdown());

   9:     }

  10:

  11:     // warten auf den GUI Thread...

  12:     _thread.Join();

  13:     _thread = null;

  14:

  15:     return Task.CompletedTask;

  16: }

Wenn die App regulär beendet wurde, ist die Referenz null. Falls nicht, landen wir hier wegen Ctrl-C auf der Konsole. Da die Methode aus einem beliebigen Thread aufgerufen wird, muss man den Shutdown()-Aufruf über den Dispatcher an den GUI-Thread weiterreichen.

Ausblick

Auch wenn diese Implementierung bereits sehr weit geht, kann man sie sicher noch ausbauen und verallgemeinern. Ebenso ist eine Integration mit Caliburn.Micro oder einem anderen Framework denkbar. Und eine Lösung für WinForms sieht nicht wesentlich anders aus. Oder man nutzt eine fertige Implementierung aus dem Web, etwa diese.

In jedem Fall hat dieses Beispiel gezeigt, dass Hosting nicht auf Server-Anwendungen beschränkt ist.

Kommandozeilenanwendungen

Das Beispiel für WPF hat gezeigt, dass Hosting nicht nur auf typische Server-Anwendungen beschränkt ist. Seine Grenzen hat es allerdings bei echten Kommandozeilenanwendungen, und zwar aus mehreren Gründen:

  • Das Modell der Hosted Services passt einfach nicht in eine simple Verarbeitung nach EVA-Prinzip.
  • Hosting bildet die Kommandozeilenargumente auf die Konfiguration ab. Kommandozeilenanwendungen haben aber oft komplexere Parameterlisten, die damit nicht abzudecken sind.
  • Und schließlich vertragen sich der ConsoleLogger und programmatische Ausgaben auf die Konsole nicht sonderlich gut. Nicht zuletzt, weil der ConsoleLogger asynchron arbeitet und seine Ausgaben daher ziemlich wild zwischen die eigenen Ausgaben schreibt.

Aber niemand zwingt zum Einsatz von Hosting. Es ist durchaus kein Beinbruch, wenn man Konfiguration, Dependency Injection und Logger von Hand aufsetzt. Und wenn man unbedingt Hosting für Kommandozeilenanwendungen nutzen will, kann man mit etwas Aufwand auch seinen eigenen ConsoleHost samt Implementierungen von IHostLiftime und IHost schreiben.

Fazit

Bei Microsoft Extensions Hosting kommen alle bisher behandelten Themen zusammen. Konfiguration findet in zwei Phasen statt und wird mit Dependency Injection in definierte Abläufe gebracht, Logging wird vorinitialisiert. Der Ansatz der Hosted Services macht die eigene Implementierung unabhängig von der tatsächlichen Laufzeitumgebung.

Das Konzept deckt also mit Server- und GUI-Anwendungen nahezu die ganze Bandbreite von Anwendungsarten ab, eine Ausnahme bilden lediglich echte Kommandozeilenanwendungen.
Hier noch die relevanten Links zu den Quelltexten und zur Dokumentation:

Fazit zu den Microsoft Extensions

Damit kommt auch diese Serie zum Ende. Gedacht war sie als Motivation, sich mit den Microsoft Extensions auseinanderzusetzen, selbst wenn man weiterhin auf .NET Framework bleibt. Sie bieten eine solide Grundlage für typische Anforderungen und solide Konzepte, die man im .NET Framework an der einen oder anderen Stelle durchaus vermisst hat.

Hier nochmal die Übersicht:

Vielleicht lohnt es sich ja, das im nächsten Projekt einfach mal auszuprobieren.

autor Alexander Jung

Alexander Jung

Chief eXpert Alexander.Jung@sdx-ag.de