Microsoft Extensions Hosting & Wrap-up

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.

Microsoft Extensions Logging

Nach Konfiguration und Dependency Injection müssen wir uns noch mit dem dritten großen Querschnittsthema auseinandersetzen: Dem Logging.

Von System.Diagnostics

Logging ist im klassischen .NET Framework nicht sehr konsistent ausgebaut. Es gibt System.Diagnostics.Trace mit etwas Infrastruktur dahinter, mit WebForms kann man auf Page.Trace zurückgreifen. ServiceBase nutzt das EventLog.

Das hatte alles seinen Platz, aber insbesondere das Schreiben von Logdateien oder in Datenbanken hat sich mit diesen Mechanismen nie breit etabliert. Und damit hat die BCL auch keine einheitliche Lösung für Logging geboten. Diese Nische haben Logging-Frameworks wie log4net und NLog ausgefüllt. Das führte dann zu solchen Fragmenten im eigenen Code (hier mit log4net, aber NLog arbeitet ähnlich):

   1: public class Foo

   2: {

   3:     private static readonly ILog _log = LogManager.GetLogger(typeof(Foo));

   4:

   5:     public Foo()

   6:     {

   7:         _log.Debug("c'tor");

   8:     }

Zusammen mit der notwendigen Konfiguration lassen sich damit Logs mit unterschiedlichen Informationen in unterschiedliche Ziele schreiben.

Der Pferdefuß an dieser Lösung ist, dass sich die Abhängigkeit zu einer Bibliothek quer durch den eigenen Code zieht – ich hatte schon mehr als ein Projekt, in dem sich das später zu einem Problem entwickelt hat.

… zu den Microsoft Extensions

Ähnlich wie bei Dependency Injection basiert der Lösungsansatz der Microsoft Extensions auf einem Mittelweg: Für die Nutzung die notwendigen Schnittstellen zur Verfügung stellen. Und die Umsetzung über ein Provider-Konzept abstrahieren, so dass man bei Bedarf das Logging-Framework seiner Wahl einklinken kann.

Logging ist dabei voll in den Dependency Injection Mechanismus integriert, nutzt für die Anmeldung eines Providers aber wieder das Builder-Pattern, wie schon Konfiguration und Dependency Injection selbst:

   1: var services = new ServiceCollection();

   2:

   3: services.AddLogging(loggingBuilder =>

   4: {

   5:     loggingBuilder.AddConsole();

   6:     loggingBuilder.AddDebug();

   7: });

Für die Nutzung gibt es zwei Wege:

Entweder man lässt sich über über Dependency Injection ein ILoggerFactory geben. Der Methode CreateLogger() kann eine Kategorie übergeben werden, ähnlich wie in log4net, und als Ergebnis bekommt man ein ILogger. Die eigentlichen Logging-Methoden sind als Extension Methoden für diese Schnittstelle definiert.

Der übliche Weg ist aber, sich per Dependency Injection einen ILogger<TCategoryName> geben zu lassen und als TCategory den eigenen Typ anzugeben. Die Kategorie ergibt sich dabei aus dem qualifizierten Klassennamen:

   1: class MyClass

   2: {

   3:     private ILogger _logger;

   4:     private ILogger _sqlLogger;

   5:

   6:     public MyClass(ILogger<MyClass> logger, ILoggerFactory loggerFactory)

   7:     {

   8:         _logger = logger;

   9:         _sqlLogger = loggerFactory.CreateLogger("SQL");

  10:     }

  11: }

Natürlich bietet Logging die üblichen Möglichkeiten bzgl. LogLevel, Konfiguration, Filterung von Ausgaben und mehr; wer schon mit log4net oder ähnlichem gearbeitet hat, wird hier aber keine Überraschungen erleben, weshalb ich es bei den Verweisen belasse.

Konventionen

Wie schon bei den anderen Bereichen gibt es auch beim Logging die Konvention, dass Erweiterungsmethoden, die sich auf ILoggingBuilder oder auf ILogger beziehen, im Namespace Microsoft.Extensions.Logging liegen. Eine andere Konvention ist das Injizieren des generischen ILogger<> mit dem Typ als Kategorie, wie oben dargestellt.

Man kann sich zudem seine eigene Konvention schaffen, zum Beispiel wenn man ein spezielles Log für besondere Inhalte führen will. Wenn man etwa Logger mit Kategorie “SQL” für die Protokollierung von SQL-Aufrufen verwenden will, kann man natürlich über die LoggerFactory einen entsprechenden Logger abrufen, wie im Beispiel oben. Man kann sich aber auch einen eigenen ISqlLogger bereitstellen. Die Schnittstelle benötigt nur einen simplen Wrapper:

   1: public interface ISqlLogger : ILogger { }

   2:

   3: public class SqlLogger : ISqlLogger

   4: {

   5:     private readonly ILogger _logger;

   6:

   7:     public SqlLogger(ILoggerFactory loggerFactory)

   8:     {

   9:         _logger = loggerFactory.CreateLogger("SQL");

  10:     }

  11:

  12:     public bool IsEnabled(LogLevel logLevel)

  13:     {

  14:         return _logger.IsEnabled(logLevel);

  15:     }

  16:     [...]

  17: }

Die passende Registrierung (natürlich im Einklang mit den Konventionen)

   1: namespace Microsoft.Extensions.DependencyInjection

   2: {

   3:     public static class SqlLoggerServiceCollectionExtensions

   4:     {

   5:         public static IServiceCollection AddSqlLogger(this IServiceCollection services)

   6:         {

   7:             services.AddSingleton<ISqlLogger, SqlLogger>();

   8:             return services;

   9:         }

  10:     }

  11: }

Und man kann das Beispiel von oben vereinfachen:

   1: class MyClass

   2: {

   3:     private ILogger _logger;

   4:     private ILogger _sqlLogger;

   5:

   6:     public MyClass(ILogger<MyClass> logger, ISqlLogger sqlLogger)

   7:     {

   8:         _logger = logger;

   9:         _sqlLogger = sqlLogger;

  10:     }

  11: }

Diesen Aufwand wird man natürlich nicht sehr häufig treiben, aber für bestimmte Fälle macht das durchaus Sinn. In einem laufenden Projekt nutze ich das zur Unterscheidung von technischen und fachlichen Logs.

Logger Provider

Wie bei Konfiguration auch, kann man über das Provider-Konzept alternative Provider anbieten. Anders als bei Konfiguration muss man das oft auch tatsächlich tun, denn die Auswahl an Providern in den Microsoft Extensions selbst ist leider etwas beschränkt. So gibt es weder einen File-Logger, noch ein Logging in eine Datenbank. Und die bestehenden Logger sind wenig konfigurierbar. Der Console-Logger liefert die von ASP.NET Core vielleicht bekannte Ausgabe:

image

Aber diese Ausgabe ist vom Aufbau her wie sie ist; konfigurieren lässt sich da nichts.

Einerseits ist das etwas unbefriedigend, andererseits aber auch verständlich. Übermäßige Konfigurierbarkeit wird gerne mal zum Selbstzweck und konkurrierende Zugriffe auf Log-Files sind nicht gerade trivial – selbst bei log4net muss man hier aufpassen.

Es ist also durchaus sinnvoll, sich nach alternativen Providern umzuschauen. Microsoft führt Listen hier und hier. Und wer einen eigenen Provider erstellen will – auch das ist kein Hexenwerk – kann den DebugLoggerProvider als Vorlage verwenden. Der Console-Logger ist hingegen erstaunlich komplex, da er Windows und Linux adressiert und zudem asynchron arbeitet. Wer sich mit einem File-Provider auseinandersetzen will, findet vielleicht bei den Providern für AzureAppServices einige Anregungen.

Fazit

Konzeptionell lässt sich Logging mit Konfiguration und Dependency Injection vergleichen: Trennung von Nutzung und eigentlicher Umsetzung über Schnittstellen, Provider-Konzept für Erweiterbarkeit, Builder-Pattern und Konventionen für Konsistenz. Es stellt – anders als das .NET Framework – eine umfassende Lösung bereit. Und anders als 3rd Party Bibliotheken sorgt die Abstraktion dafür, dass sich die Abhängigkeit zwischen Nutzung und eigentlichem Provider in Grenzen hält.

Das ist umso wichtiger, da die verfügbaren Provider doch sehr limitiert sind. Man wird in vielen Projekten nicht darum herumkommen, eigene Provider zu schreiben oder einen Provider für ein gängiges Logging-Framework zu nutzen.

Hier wie immer die Links zu den Quelltexten und zur Dokumentation:

Microsoft Extensions Dependency Injection

Nach Konfiguration im letzten Beitrag soll es diesmal um Dependency Injection gehen.

Dependency Injection ist insofern ein Sonderfall, als es im klassischen .NET Framework nicht enthalten ist. Allerdings hat sich die Nutzung eines DI-Containers in den letzten Jahren weitgehend durchgesetzt, so dass offensichtlich auch Microsoft nicht darauf verzichten wollte. Tatsächlich sind die Microsoft Extensions konsequent auf die Nutzung von Dependency Injection ausgelegt, was ein wesentlicher Grund für die Flexibilität und Erweiterbarkeit ist.

Von [mein Lieblings-DI-Container] …

Es gibt mittlerweile eine ganze Reihe von DI-Containern, insbesondere auch solche, die eine gewisse Verbreitung gefunden haben: Unity, Castle.Windsor, SimpleInjector, Autofac – um nur einige zu nennen, über die ich in den letzten Jahren in Projekten gestolpert bin.

Im Grundsatz bieten diese DI-Container alle einen vergleichbaren Funktionsumfang. Im Detail unterscheiden sie sich etwas, abhängig davon, ob sie auf Performance oder auf funktionale Reichhaltigkeit getrimmt sind, aber einige Funktionalitäten bieten die meisten an:

  • Objekterzeugung mit Constructor- und Property-Injection
  • Irgendeine Form von Scopes
  • Möglichkeiten, um aspektorientierte Ansätze über Adapter bzw. Decorators zu realisieren.
  • Benannte Instanzen
  • Ausführliche Unterstützung beim Registrieren von Komponenten

Man kann sich in seiner Anwendung entscheiden, ob man alle Funktionalitäten eines DI-Containers konsequent ausnutzt, oder ob man sich auf grundlegende Dinge beschränkt. Das Ausnutzen erlaubt oft sehrt effiziente und elegante Lösungswege für Querschnittsthemen und andere Dinge, kommt aber in der Regel zum Preis der Abhängigkeit von diesem speziellen DI-Container.

… zu den Microsoft Extensions

Da der Einsatz von Dependency Injection nicht wirklich in Frage steht, stellte sich für Microsoft nur die Frage, welchen DI-Container sie einsetzen, oder ob sie einen eigenen neu schreiben. Letztendlich haben sie sich für den Mittelweg entschieden: Ein DI-Container, der genau das zur Verfügung stellt, was sie benötigen. Und die Bereitstellung über ein Provider-Konzept, so dass man bei Bedarf den DI-Container seiner Wahl einklinken kann.

Diese Selbstbeschränkung was den Funktionsumfang angeht bleibt nicht ohne Folgen. Auf der einen Seite ist der entstandene DI-Container sehr schnell. Auf der anderen Seite muss man aber mit einigen Einschränkungen leben:

  • Es wird nur Constructor Injection unterstützt, keine Property Injection.
  • Keine benannten Instanzen
  • Kein Interception-Mechanismus, damit keine Unterstützung von Adaptern oder Decorators.

Property Injection halte ich persönlich für ein Antipattern, insofern Applaus. Benannte Instanzen kommen in Sonderfällen oder beim Aufbau von Pipelines zum Einsatz, das lässt sich ggf. aber auch anders lösen. Am schwersten kommt sicher der letzte Punkt zum Tragen. Man braucht Interception nicht oft, aber wenn man es braucht, dann geht es meist um sehr grundlegende Dinge, die anders nur sehr umständlich oder mit mehr oder weniger viel Code zu lösen sind. Das Feature nicht verfügbar zu haben, kann durchaus schmerzlich sein.

Andere Dinge sind im ersten Schritt etwas aufwendiger als bei anderen DI-Containern, lassen sich aber lösen:

  • Registrierung aller Typen nach Assembly oder Interface.
  • Die Registrierung einer Klasse für zwei Interfaces.
  • Weitere Convenience-Features

Diese Einschränkungen sind insbesondere dann relevant, wenn man im Kontext einer wiederverwendbaren Bibliothek arbeitet. Sie definieren im Grunde die Schnittmenge der Funktionalitäten, die ein DI-Container im Umfeld von .NET Standard anbieten muss und worauf man sich verlassen kann. Sie legen aber eben auch fest, was nicht zwingend zur Verfügung steht – und somit als Beschränkung akzeptiert werden muss.

Bei der Entwicklung einer Anwendung kann man hingegen den DI-Container seiner Wahl nutzen und die Beschränkungen so umgehen. Microsoft verlinkt selbst auf einige Alternativen. Hier und hier.

Konventionen

Es gibt noch einige Konventionen, die man kennen und ggf. einhalten sollte.

Um das deutlicher zu machen, einfaches Beispiel, das den DI-Container aufsetzt und nutzt:

   1: class Program

   2: {

   3:     static void Main(string[] args)

   4:     {

   5:         var services = new ServiceCollection();

   6:

   7:         services.AddSingleton<MyApp>();

   8:         services.AddClassLibrary1();

   9:

  10:         var serviceProvider = services.BuildServiceProvider();

  11:         var app = serviceProvider.GetService<MyApp>();

  12:         app.Main();

  13:     }

  14: }

Der Aufbau des DI-Containers nutzt das Builder-Pattern, wie wir es auch schon bei der Konfiguration gesehen haben.

Der Code ist sehr gradlinig und sollte für jeden, der schon mal mit DI zu tun hatte, nachvollziehbar sein. Einzig der Aufruf von AddClassLbrary1() muss noch angesprochen werden.
Registrierung

Wir haben die gleiche Konvention, die uns schon bei Konfiguration begegnet ist: Jede Bibliothek, die Registrierungen für Dependency Injection vornehmen muss, stellt eine passende Erweiterungsmethode für IServiceCollection zur Verfügung. Und sie tut dies nicht im eigenen Namespace, sondern im Namespace Microsoft.Extensions.DependencyInjection, so dass die Methode ohne jedes weitere using verfügbar ist. Der Name der Klasse endet üblicherweise auf ServiceCollectionExtensions, sollte jedoch global eindeutig sein (andernfalls käme es zu Namenskollisionen).

Hier das passende Beispiel:

   1: namespace Microsoft.Extensions.DependencyInjection

   2: {

   3:     public static class ClassLibrary1ServiceCollectionExtensions

   4:     {

   5:         public static IServiceCollection AddClassLibrary1(this IServiceCollection services)

   6:         {

   7:             return services

   8:                 .AddSingleton<IFoo, FooService>()

   9:                 .AddTransient<IBar, BarService>();

  10:         }

  11:     }

  12: }

Konfiguration

Eine weitere Konvention gibt es im Zusammenhang mit der Konfiguration über Options. Hier sind Überladungen üblich, die einen Callback übernehmen, um die Options per Code zu setzen. Am Beispiel Caching/Memory aus den Microsoft Extensions (verkürzt):

   1: public static class MemoryCacheServiceCollectionExtensions

   2: {

   3:      public static IServiceCollection AddMemoryCache(this IServiceCollection services)

   4:      {

   5:          services.AddOptions();

   6:          services.TryAdd(ServiceDescriptor.Singleton<IMemoryCache, MemoryCache>());

   7:          return services;

   8:      }

   9:

  10:      public static IServiceCollection AddMemoryCache(this IServiceCollection services, Action<MemoryCacheOptions> setupAction)

  11:      {

  12:          services.AddMemoryCache();

  13:          services.Configure(setupAction);

  14:          return services;

  15:      }

Es lohnt sich, diese Konvention auch im eigenen Code einzuhalten, denn in UnitTests möchte man durchaus mal unterschiedliche Konfigurationen austesten.

Fazit

Das war’s im Grunde auch schon: die Microsoft Extensions bieten wenig Spezielles zum Thema Dependency Injection. Man muss sich der Einschränkungen bewusst sein, man sollte ein paar Konventionen einhalten – und man ist “good to go”.

Lohnt sich eine Umstellung, wenn man schon einen DI-Container im Einsatz hat? Wer sich von speziellen Features seines DI-Containers ferngehalten hat, kann den DI-Container einfach austauschen. Dinge wie Batch-Registrierungen, Mehrfachregistrierung oder benannte Registrierungen (z. Beispiel für Pipelines) lassen sich mit wenig Aufwand – wenn auch nicht immer ganz elegant – lösen. Einzig wer auf Interception angewiesen ist hat etwas mehr Arbeit vor sich.
Hier noch die relevanten Links zu den Quelltexten und zur Dokumentation:

Microsoft Extensions Configuration

Der letzte Beitrag hat die grundsätzliche Motivation adressiert, die Microsoft Extensions einzusetzen – und zwar insbesondere auch, wenn man das klassische .NET Framework weiter nutzen will.

Die Bereitstellung von Konfiguration ist dafür ein sehr gelungenes Beispiel: Microsoft Extensions Configuration ist – meiner Meinung nach – dem klassischen System.Configuration in jeder Beziehung vorzuziehen.

Von System.Configuration …

Wer schon mit System.Configuration gearbeitet hat, kennt die Fähigkeiten dieses Systems:

  • Konfiguration kann als XML abgelegt werden
  • Man kann Klassen (abgeleitet von entsprechenden Basisklassen in System.Configuration) bereitstellen, um die Konfiguration zu validieren und typisiert auszulesen
  • Geänderte Konfiguration lässt sich speichern

Das kommt jedoch mit einigen Einschränkungen:

Das Schreiben eigener, typisierter ConfigurationSection und ggf. weiterer Klassen ist zwar kein Hexenwerk, kann aber durchaus mit Aufwand verbunden sein. Und die resultierenden Klassen sind an das Konfigurationssystem gebunden.

Man ist auf XML festgelegt, normalerweise in Form einer einzigen XML-Datei (das Auslagern von Sections über ConfigSource mal ignoriert). Zwar kann man in eigenem Code durchaus eine Config-Datei gezielt lesen, aber die üblichen Schnittstellen über den ConfigurationManager bekommen davon nichts mit.

Damit ist das Konfigurationssystem in sich geschlossen und wenig erweiterbar. Wer Konfigurationswerte in der Datenbank oder in einem zentralen Service ablegen will, muss seine eigene Abstraktionsschicht darüberlegen. Aber selbst dann ist das nur eine Teillösung: bestehender Code – insbesondere 3rd-Party-Bibliotheken – weiß davon nichts. Und in aller Regel ist dies auf die appSettings und evtl. die ConnectionStrings beschränkt; andere ConfigSections bleiben außen vor.

Und wer schon mal UnitTests mit unterschiedlichen Konfigurationen schreiben musste, hatte durchaus einige Probleme zu lösen.

… zu den Microsoft Extensions

Die Microsoft Extensions bauen das Konfigurationssystem etwas anders auf:

  • Provider stellen Name/Value-Paare zur Verfügung, mehrere Provider lassen sich kombinieren.
  • Sections entstehen unabhängig von Providern über eine Namenskonvention.
  • Die Abbildung auf typisierte Klassen nutzt Pocos – keine speziellen Basisklassen oder Attribute.
  • Über Microsoft Extensions Options kommen Validierung und die Integration mit Dependency Injection ins Spiel.

Zunächst zu Providern: Diese stellen Konfigurationswerte als Name/Value-Paare zur Verfügung. Allein durch diese Abstrahierung sind bereits unterschiedlichste Quellen für Konfigurationswerte möglich: JSON-Dateien und Kommandozeilenargumente sind die üblichen, aber für UnitTests kann man die Konfiguration auch problemlos im Speicher aufbauen.

Für den Aufbau der Konfiguration kommt – wie an vielen Stellen in den Microsoft Extensions – das Builder-Pattern zum Einsatz. Die Provider werden dem Builder bekannt gemacht, die fertige Konfiguration nutzt eine Liste dieser Provider:

   1: var config = new ConfigurationBuilder()

   2:     .AddEnvironmentVariables()

   3:     .AddJsonFile("appSettings.json")

   4:     .AddCommandLine(args)

   5:     .Build();

Die letztgenannten Provider können Werte der vorherigen Provider übersteuern, so dass sich unterschiedliche Quellen mischen lassen: Standardwerte kommen aus der Konfigurationsdatei, aber für einen bestimmten Testlauf kann man sie mit Kommandozeilenargumenten übersteuern.

Sections ergeben sich aus einer Namenskonvention: Über die Trennung mit Doppelpunkt wird eine Hierarchie aufgebaut. Die JSON-Datei…

   1: {

   2:   "section0": {

   3:     "key0": "value0",

   4:     "key1": "value1"

   5:   },

… wird als

  • “section0:key0” = “value0”
  • “section0:key1” = “value1”

geliefert.

Der Zugriff auf die Werte geht über die Schnittstelle IConfiguration, die wahlweise mit vollständigem Bezeichner oder über Sections auf Werte zugreifen kann:

   1: var value0 = _config["section0:key0"];

   2:

   3: var configSection = _config.GetSection("section0");

   4: var value1 = configSection["key1"];

Man kann eine Section auch problemlos auf ein Objekt abbilden:

   1: var section = new Section();

   2: _config.GetSection("section0").Bind(section);

Das wird man üblicherweise aber nicht tun, sondern stattdessen die Integration mit Dependency Injection nutzen, die Microsoft Extensions Options zur Verfügung stellt.

Nutzung von Dependency Injection über Microsoft Extensions Options

Dependency Injection werde ich in einem späteren Beitrag noch behandeln, aber schon mal als Vorgriff:

Hat man die Konfiguration beim DI-Container bekannt gemacht, muss man nur noch die spezielle Konfigurationsklasse anmelden, und kann sie dann für Constructor Injection nutzen. Hierbei werden Konfigurationsdaten – jetzt in der Regel als “options” bezeichnet – über eine generische Schnittstelle kenntlich gemacht:

   1: // Registrierung für Dependency Injection:

   2: services.Configure<Section>(config.GetSection("section0"));

   3:

   4: // Nutzung

   5: public class MyClass

   6: {

   7:     public MyClass(IOptions<Section> options)

   8:     {

   9:         var value0 = options.Value.Key0;

  10:     }

  11: }

Durch die Nutzung über die generische Schnittstelle IOptions<> wird die Konfiguration erst beim Zugriff auf das Value-Property ausgelesen und auf das Poco abgebildet.

Microsoft Extensions Options bietet darüber hinaus weitere Möglichkeiten:

  • Die Options können per Code initialisiert werden.
  • Die Options können auf unterschiedliche Arten validiert werden.
  • Es gibt weitere Schnittstellen, um sich über Konfigurationsänderungen benachrichtigen zu lassen.

Microsoft Extensions Configuration ist also für das eigentliche Konfigurationssystem zuständig, während Microsoft Extensions Options die Brücke zu Dependency Injection schlägt.

Eigene Provider

Einen eigenen Provider zu bauen ist denkbar einfach: Man benötigt eine Klasse, die IConfigurationSource implementiert, die einzige Methode Build() muss einen IConfigurationProvider zurückliefern. Die Source wird während des Build()-Aufrufs des ConfigurationBuilder benötigt, zur Laufzeit ist der Provider für das Liefern von Konfigurationswerten zuständig. Wer von der Basisklasse ConfigurationProvider ableitet, muss nur die Methode Load() überschreiben und das Property Data befüllen.

Ein einfaches Beispiel bietet der Memory-Provider mit Source und Provider.

Wer so einen eigenen Provider anbietet, sollte sich mit einer Konvention vertraut machen, die sich auch an anderen Stellen durch die Microsoft Extensions zieht: Für die Registrierung sollte man eine Klasse mit Erweiterungsmethoden zur Verfügung stellen; diese sollten nicht im Namespace der Bibliothek liegen, sondern im Namespace des Features. Am Beispiel des Memory-Providers:

   1: namespace Microsoft.Extensions.Configuration

   2: {

   3:     public static class MemoryConfigurationBuilderExtensions

   4:     {

   5:         public static IConfigurationBuilder AddInMemoryCollection(this IConfigurationBuilder configurationBuilder)

   6:         {

   7:             configurationBuilder.Add(new MemoryConfigurationSource());

   8:             return configurationBuilder;

   9:         }

Durch die Wahl des Namespaces Microsoft.Extensions.Configuration ist sichergestellt, dass das Feature registriert werden kann, sobald eine Referenz auf die Bibliothek gesetzt ist. Diese Konvention wird uns auch an anderen Stellen wieder begegnen.

Übrigens: Wer an das Auslesen der Konfigurationswerte aus der Datenbank oder ähnliches denkt, muss sich einem Problem stellen: Er kann in der Source nicht auf Konfiguration zugreifen, da diese ja gerade erst aufgebaut wird. Das Bereitstellen etwa des Connection-Strings wird also schwierig. Hier muss man sich selbst um eine zweistufige Abarbeitung kümmern. Da sich Konfiguration aber derart einfach aufbauen lässt, ist das kein grundsätzliches Problem.

Die Grenzen

Wie eingangs erwähnt halte ich den Ansatz der Microsoft Extensions für deutlich besser gelungen als das klassische System.Configuration. Aber natürlich hat ein solches System auch seine Grenzen. Im direkten Vergleich sind dies:

Speichern: Die Microsoft Extensions unterstützen zwar das Nachladen, wenn sich die Konfiguration geändert hat, was mit seinen Benachrichtigungsmechanismen deutlich über System.Configuration hinausgeht. Aber das Speichern von geänderten Werten ist nicht vorgesehen. Immerhin: Wenn man per Code einen Konfigurationswert setzt, wird er in allen Providern gesetzt; man hat also die Information, was geändert wurde.

Konfiguration benötigt Konfiguration: Ein Provider, der seinerseits Konfiguration benötigt (z.B. einen Connection String für den Zugriff auf die Datenbank) ist im Konzept nicht vorgesehen. Ich komme in einem späteren Beitrag zum Hosting auf dieses Thema zurück.

Standards: Mit System.Configuration bekommt man einige Standards, auf die man sich verlassen kann: Die AppSettings und die ConnectionStrings sind in aller Regel dort, wo man sie erwartet und der ConfigurationManager bietet entsprechende Properties für den Zugriff an. Die Microsoft Extensions haben solche Vorgaben nicht fest verbaut; man muss sich schlicht darauf verlassen, dass dies als Konvention weiter besteht.

Kein Dependency Injection: Das Konzept der Microsoft Extensions geht davon aus, dass zuerst die Konfiguration aufgebaut wird, danach Dependency Injection. Will man also Dependency Injection für den eigenen Provider nutzen, muss man auch hier ein zweistufiges Konzept umsetzen und etwa nachträglich den DI-Container an den eigenen Provider übergeben.

Fazit

Man kann das Konfigurationssystem der Microsoft Extensions eigentlich nur als gelungen bezeichnen: Es hat klare Konzepte, ist sauber strukturiert, flexibel erweiterbar und bietet alles, was man braucht.

Nicht zuletzt räumt es mit den in System.Configuration vorhandenen Problemen auf:

  • Typisierung lässt sich leicht und über Standardwege erreichen, man muss nicht auf proprietäre Basisklassen setzen.
  • Konfiguration ist nicht auf XML-Dateien (bzw. eine XML-Datei) beschränkt.
  • Man kann mehrere Quellen kombinieren.
  • UnitTests lassen sich problemlos umsetzen.

Die Einschränkungen sind dabei meiner Ansicht nach vernachlässigbar. Insbesondere sind sie keine Showstopper, sondern können mit eigenen Lösungsansätzen leicht umgangen werden – sie sind eben nur nicht im eigentlichen Konzept verankert.
Hier noch die relevanten Links zu den Quelltexten und zur Dokumentation:

Microsoft Extensions für Alle!

Im Artikel Quo Vadis, .NET? habe ich auf die Microsoft Extensions hingewiesen und darauf, dass diese auch mit klassischen .NET Framework-Anwendungen nutzbar sind. Allerdings stellt sich die Frage, warum man das tun sollte. In diesem Beitrag möchte ich genau diese Frage beantworten.

Die Rolle der Microsoft Extensions

Das klassische .NET Framework stellt dem Anwendungsentwickler mit der Base Class Library (BCL) grundlegende Plattform-Features zur Verfügung (Datentypen, I/O, Threading, …), sowie grundlegende Features, die zwar nicht plattformabhängig sind, die man aber heutzutage im Rahmen eines Frameworks erwartet (Konfiguration, Logging, …).

Hinweis: Man findet unterschiedliche Definitionen für “BCL”, die teilweise enger gefasst sind, teilweise vergleichbar und teilweise darüber hinaus gehen (Wikipedia, Blog, Blog und Stack Overflow). In der Microsoft-Dokumentation selbst findet man den Begriff hingegen immer weniger.

Mit .NET Standard hat Microsoft die Vorgabe für die plattformabhängigen Anteile geschaffen (zumindest soweit diese auf allen Plattformen verfügbar sein sollen). Die Microsoft Extensions adressieren plattformunabhängige Anteile und schließen so eine Lücke, die sich sonst aus Sicht eines Anwendungsentwicklers auftun würde. Nur der Vollständigkeit halber: Eine dritte Gruppe sind die Windows-abhängigen Features, die im Rahmen des Windows Compatibility Pack zur Verfügung gestellt werden.

Microsoft hat die Microsoft Extensions nun aber nicht aus dem Ansinnen heraus entwickelt, .NET Framework-Features nachzubauen. Vielmehr sind sie im Kontext von ASP.NET Core – aufgrund konkreter Anforderungen und nicht zuletzt im Kontext von realen Anwendungsszenarien – entstanden. Sie bieten daher nicht nur Funktionalitäten, die man aus dem .NET Framework im Grundsatz kennt (etwa Konfiguration), sondern auch Dinge, die in der klassischen BCL nicht vorhanden sind, zum Beispiel Dependency Injection. Und spätestens wenn man in die Details schaut, kann man jede Verwandtschaft mit dem klassischen .NET Framework ausschließen.

Microsoft Extensions für .NET Framework

Wenn dem .NET Framework-Entwickler die Features der Microsoft Extensions also größtenteils ohnehin bereits zur Verfügung stehen – für Themen wie Dependency Injection gibt es genügend Alternativen – welchen Grund sollte es also geben, die Microsoft Extensions dennoch in diesem Kontext einzusetzen?

Bei bestehenden Anwendungen lautet die Antwort in vielen Fällen: Es gibt keinen! Gerade Anwendungen, die hauptsächlich der Wartung unterliegen und bei denen keine große Weiterentwicklung abzusehen ist, bringen die Microsoft Extensions nur zusätzliche Komplexität ohne echten Mehrwert.

Etwas anders sieht das aber bei Anwendungen aus, die einer konstanten Weiterentwicklung unterliegen. Hier kann eine Migration durchaus sinnvoll sein. Und bei Neuentwicklungen, ebenso wie bei der Entwicklung von wiederverwendbaren Bibliotheken, kann man von vorne herein auf die Microsoft Extensions setzen.

Hier einige Gründe, die für den Einsatz der Microsoft Extensions – auch im Rahmen von .NET Framework – sprechen:

Vorbereitung auf eine (spätere) Migration. Wenn eine Migration auf .NET Core ohnehin ansteht, stellt sich die Frage nach dem “ob” gar nicht. Aber auch, wenn dies für einen späteren Zeitpunkt angedacht ist, ist eine Migration auf die Microsoft Extensions sinnvoll. Man kann dies ggf. sogar für einzelne Features getrennt machen. Diese “Migration in kleinen Schritten” hilft dabei, Big-Bang-Szenarien zu vermeiden und reduziert so das Risiko.

Modernere, flexiblere Implementierungen. Die bestehenden Implementierungen im .NET Framework oder gängigen Bibliotheken sind nicht immer ausreichend oder kommen zum Preis einer externen Abhängigkeit. Sei es der Wunsch, Konfigurationswerte aus der Datenbank zu lesen, die dann aber nicht als AppSettings verfügbar sind, oder ein Logging-Framework wie log4net, das sich als Abhängigkeit quer durch den Code zieht.
Die Microsoft Extensions sind konsequent auf Abstraktion und Erweiterbarkeit ausgelegt. Konfigurationswerte aus unterschiedlichen Quellen zu liefern, ohne dass der nutzende Code dafür geändert werden muss, ist Teil des Konzeptes. Gleiches gilt für das Logging oder auch Dependency Injection: Aufrufender Code arbeitet konsequent gegen Schnittstellen, die eigentliche Implementierung dahinter kann flexibel nach den eigenen Bedürfnissen ausgetauscht werden.

Performante Implementierungen. Die Microsoft Extensions sind im Rahmen von ASP.NET Core aus der Praxis heraus entwickelt worden. Die vorhandenen Konzepte und Implementierungen bieten einen pragmatischen Umfang und sie sind im Hinblick auf Performance optimiert.
Eine Konsequenz daraus ist, dass die einzelnen Funktionalitäten beim direkten Vergleich mit alternativen Bibliotheken nicht alle Features anbieten. So bietet Dependency Injection zum Beispiel weder Property Injection noch ein Konzept für Interceptoren an – typische Funktionalitäten, die nahezu jeder DI-Container im Gepäck hat. Aber man hat die Wahl: Nutzt man den DI-Container der Microsoft Extensions, bekommt man eine performante Umsetzung; will man nicht auf erweiterten Features verzichten, kann man problemlos auch alternative DI-Container einklinken.

Architektur. Die Microsoft Extensions sind konsequent auf Abstraktion und Erweiterbarkeit ausgelegt. Die Nutzung eines Features bedeutet eben nicht, dass man sich damit auf Gedeih und Verderb den in den Microsoft Extensions vorhandenen Umsetzungen ausliefert. Man kann ohne Probleme eigene Konfigurationen anbinden, ein alternatives Logging-Framework nutzen, oder seinen bevorzugten DI-Container einbinden (weitere Details hierzu folgen in einem separaten Artikel).

Standardisierung. Konfiguration oder Logging sind typische Basisfunktionalitäten, die auch von anderen Bibliotheken benötigt werden. Man kann durchaus erwarten, dass Bibliotheken, die zum Beispiel heute über Mechanismen aus System.Configuration eine ConfigurationSection bereitstellen, in Zukunft auf die Microsoft Extensions schwenken werden. Das gilt insbesondere, da es derzeit einen Trend zu .NET Standard gibt.

Zukunftssicherheit. Selbst wenn eine Anwendung auf absehbare Zeit weiterhin das .NET Framework nutzt – etwa, weil sie WCF benötigt – bezieht sich diese Abhängigkeit ja nicht auf den gesamten Code. Man kann also durchaus Teilbereiche auf .NET Standard oder sogar .NET Core umstellen. Denn Fakt ist:  Die im .NET Framework vorhandene Funktionalität ist eingefroren, Neuentwicklungen werden im Bereich .NET Core und basierend auf .NET Standard stattfinden. Wer zukunftsfähig bleiben will, muss sich dieser Realität stellen.

Fazit

Wenn die von mir angeführten Gründe überzeugend sind, steht dem Einsatz der Microsoft Extensions eigentlich nichts entgegen, oder?

Abgesehen davon…
dass man sich als klassischer .NET Framework-Entwickler mit den bestehenden Funktionalitäten auskennt, während die Microsoft Extensions neu sind.
… dass man nicht beurteilen kann, ob sie die eigenen Anforderungen erfüllen.
… dass man nicht weiß, wie man sie möglichst effizient nutzt,
… wie man sie erweitert,
… und wo man ggf. auch davon abweicht.

Anders ausgedrückt: Solange man sich nicht damit auseinandersetzt, wird das mit dem Einsatz nichts.

Einen ersten Einstieg bekommt man über die Dokumentation. Diese beschreibt die einzelnen Features aus technischer Sicht und ist insgesamt recht gut gelungen. Allerdings muss man berücksichtigen, dass sie aus historischen Gründen im Kontext von ASP.NET Core angesiedelt ist und sich auch inhaltlich häufig darauf bezieht. Und sie geht wenig auf konzeptionelle Dinge bzgl. des Designs der Microsoft Extensions ein; und schon gar nicht auf die Abgrenzung zum klassischen .NET Framework.

Aus diesem Grund werde ich es nicht bei diesem Beitrag bewenden lassen: Nachfolgende Beiträge werden einzelne Teilbereiche der Microsoft Extensions adressieren. Dabei werde ich mich insbesondere auf diese “Lücken” konzentrieren: Den Vergleich mit klassischen Funktionalitäten, Hinweise auf Konzepte und auf erweiterte Nutzung und Erfahrungswerte.

In diesem Sinne: Stay tuned!

Quo vadis, .NET?

Microsoft krempelt die .NET-Welt gerade kräftig um. Neue Begriffe, die man noch nicht einordnen kann, Gerüchte um Abkündigungen (die nicht zutreffen!), und die Frage, was das alles konkret bedeutet, sind die logische Folge.

Um zu verstehen, was da gerade passiert – und warum – lohnt sich ein Blick in die Vergangenheit.

Blick in die Vergangenheit

.NET wurde als Preview auf der PDC 2000 vorgestellt. In den ca. 15 Jahren danach war die Entwicklung von zwei Aspekten geprägt: Neue Features und neue Umgebungen.

Neue Features

.NET hatte in diesen ersten 15 Jahren mit jeder neuen Version neue Themen im Gepäck. Der folgende Zeitstrahl gibt das wieder:

image

Angefangen mit .NET 4.6 hat das nachgelassen, Neuerungen sind seit dem eher “Modellpflege”.

Neue Umgebungen

Microsoft hat von Anfang an versucht, .NET zu standardisieren (erste Gehversuche bzgl. Offenheit) und auf verschiedenen Umgebungen zu unterstützen. Die eigene Referenzimplementierung unter FreeBSD, sowie Mono als Open Source-Variante kamen sehr früh.

Aber auch auf den eigenen Plattformen gab es Varianten wie das Micro– und Compact Framework, sowie Silverlight für Web und Windows Phone:

image

Gemeinsam ist diesen Varianten, dass sie keine Relevanz mehr haben.

Die Ausnahme ist Mono/Xamarin: Nachdem Mono lange vor sich hingedümpelt ist, bekam es mit der Übernahme durch Xamarin und der Unterstützung der wichtigsten Mobile Plattformen einen deutlichen Schub. Und mit der Übernahme durch Microsoft kamen macOS, Android und iOS auf die Liste der direkt von Microsoft unterstützten Betriebssysteme. Parallel dazu hatte Microsoft mit .NET Core selbst angefangen, Linux-Varianten zu unterstützen.

image

Der Preis des Erfolgs

Als .NET auf der PDC 2000 vorgestellt wurde, gab es Standing Ovations, weil .NET viele der Probleme der bis dato vorherrschenden Entwicklung mit Visual Basic, C++ und COM einfach beiseite wischte. 10 bis 15 Jahre später begann .NET am eigenen Erfolg zu kränkeln.

Ein Problem bestand für Entwickler von Bibliotheken: Assemblies waren – bei nahezu identischem Code auf allen Plattformen – immer auf genau eine Plattform festgelegt. Wer eine allgemeingültige Bibliothek bereitstellen wollte – etwa ein Logging-Framework wie log4net oder ein Dependency Injection Framework –, der musste dies für jede Plattform separat tun. Und spätestens mit dem Aufkommen von Xamarin und plattformübergreifender Entwicklung waren davon zunehmend auch Anwendungsentwickler betroffen.

Der Lösungsansatz für dieses Problem waren Portable Class Libraries (PCL): Assembly-Projekte konnten angeben, für welche Plattformen sie verfügbar sein sollten. Die Compiler-Infrastruktur stellte sicher, dass man nur auf die Funktionen zugreifen konnte, die auf allen diesen Plattformen verfügbar sind. Einerseits konnte man dadurch mit einem Assembly mehrere Plattformen bedienen, andererseits wurde die Menge der verfügbaren Funktionen mit jeder zusätzlichen Plattform kleiner.

Als rein technischer Lösungsansatz haben PCLs ihre Aufgabe durchaus erfüllt. In der Praxis ist aber die Schnittmenge der verfügbaren Funktionen eine deutliche Einschränkung – PCLs müssen eigentlich immer um plattformabhängige Assemblies ergänzt werden. Das führte dazu, dass PCLs eine Nischenlösung blieben.

Andere Problembereiche ergaben sich durch den Umfang und die Verbreitung von .NET selbst. Fortschritte im Bereich der Compiler-Technologie ließen sich nicht nutzen, weil dies in einigen Fällen zu Inkompatibilitäten geführt hätte. Das globale Deployment erwies sich als Hemmschuh, wenn es um kleine Windows Store Anwendungen ging. Die enge Bindung mit Laufzeitumgebungen wie dem IIS für ASP.NET, sowie das Mitschleifen längst abgekündigter Konzepte wie Code Access Security taten ein Übriges, um der Weiterentwicklung Fußfesseln anzulegen.

Blick in die Gegenwart

Das war die Situation vor ca. 5 Jahren: Ein sehr erfolgreiches .NET Framework, das sich für die Weiterentwicklung als Hemmschuh herausstellte; mit PCLs eine technische Lösung, die einiges zu wünschen übrig ließ; und nicht zuletzt geänderte Anforderungen, die mit den “traditionellen” .NET-Umgebungen nicht adäquat zu beantworten waren. Mit .NET Core schlug Microsoft daher neue Wege ein.

.NET Core

.NET Core ist ursprünglich für Windows Store Apps entstanden: Ein radikal entschlacktes .NET, Dinge wie AppDomains oder Remoting entfernt, das Deployment-Modell so geändert, dass jede Anwendung ihr eigenes .NET mitbringt. So entstand eine schlanke kleine Laufzeitumgebung, die vollständig in einer Store App installiert werden konnte, ohne dass sie andere Installationen voraussetzte oder diese beeinflusste.

Dadurch konnten nicht nur neue Compiler-Technologien wie der 64-bit-Compiler RyuJIT oder .NET Native leichter umgesetzt werden, zudem verringerte sich auch die Abhängigkeit von Windows, so dass sich die Entwicklung von .NET Core sehr schnell in den Bereich von ASP.NET Core und Container-Technologien verlagerte.

Dieses Vorgehen löste das Problem der Altlasten; das Problem für Bibliotheksentwickler hätte aber weiter bestanden.

.NET Standard

Microsoft hätte .NET Core als eine weitere Plattform in PCL aufzunehmen können, aber auch hier haben sie sich – zum Glück! – anders entschieden: Anstatt sich der eingeschränkten Schnittmenge verfügbarer Funktionen zu beugen – mit mehr oder weniger großen Lücken und Inkonsistenzen – haben sie definiert, welche Funktionen sie gerne hätten. Eine einzige, vollständige, konsistente API, unabhängig von der Plattform. Der Name dieser Definition: .NET Standard.

Und jede Plattform, die konform zu .NET Standard sein will, muss entsprechend nacharbeiten, wenn Funktionen fehlen. Dies ist mittlerweile für alle noch relevanten Plattformen geschehen, so dass ein .NET Standard-Assembly auch auf allen diesen Plattformen eingesetzt werden kann:

image
Quelle: https://dotnet.microsoft.com/platform/dotnet-standard

In .NET Standard enthalten sind die Basisfeatures, die die Laufzeitumgebungen bereitstellen müssen:

image
vgl. https://devblogs.microsoft.com/dotnet/introducing-net-standard/

Microsoft Extensions

Im Zuge der Entwicklung an .NET Core, insbesondere an ASP.NET Core, kristallisierten sich weitere Themen heraus, die zwar nicht von der Plattform selbst zur Verfügung gestellt werden müssen, aber die aus Sicht eines Anwendungsentwicklers genauso grundlegend sind. Dinge wie Konfiguration, Logging oder Dependency Injection.

Microsoft hat diese Blöcke als .NET Standard-Bibliotheken unter dem Label “Microsoft Extensions” zur Verfügung gestellt. Also nicht als inhärenten Teil von .NET, wie das zum Beispiel bei System.Configuration der Fall ist, sondern als frei wählbare NuGet-Pakete, die zudem im Quelltext verfügbar sind.

Enthalten sind im Wesentlichen folgende Blöcke:

image

Der Umfang von .NET Standard, ergänzt um die Microsoft Extensions, stellt Anwendungsentwicklern eine solide Grundlage für die Entwicklung zur Verfügung, die auf allen Plattformen nutzbar ist.

Blick in die Zukunft

Kommen wir zum bislang letzten Akt, der im Mai 2019 mit einem Blogbeitrag eingeläutet wurde: .NET 5.

Mit .NET Framework, Mono/Xamarin und .NET Core hat Microsoft nach wie vor drei relevante .NET-Plattformen im Angebot. Drei Plattformen, die gepflegt und weiterentwickelt werden wollen? Die sich gerade auseinanderentwickeln? Wäre es nicht besser, nur eine Plattform pflegen zu müssen?

Das klassische .NET Framework bietet sich dafür nicht an; die Probleme damit haben schließlich zur Abspaltung von .NET Core geführt. Andererseits gibt es hier eine enorme Code-Basis, eine Abkündigung wie seinerzeit mit Silverlight kommt also nicht in Frage.

Die Lösung, die Microsoft sich ausgedacht hat, ist zweigleisig:

Erstens: .NET Framework wird in der Version 4.8 eingefroren und als Betriebssystemkomponente deklariert. Niemand muss bestehende Anwendungen migrieren, aber Weiterentwicklungen in .NET kommen eben auch nur den anderen Plattformen zugute.

Zweitens: In .NET Core 3.0 werden die Bestandteile verfügbar gemacht, die Entwickler bislang vom Einsatz von .NET abgehalten haben: Im Wesentlichen Windows Forms und WPF. Für neue Anwendungen gibt es also – so die Hoffnung – keinen Grund noch auf das klassische .NET Framework zu setzen.

Ob diese Hoffnung sich bestätigt ist keineswegs sichergestellt. Größter Stolperstein dabei ist WCF, das unter .NET Core bzw. .NET Standard nicht voll verfügbar ist. Im Web- oder Cloud-Umfeld ist das kein Problem, aber im Unternehmensumfeld ist WCF sehr verbreitet. Und auch Remoting oder die Workflow Foundation haben ihre Nische.

Dieser Schritt ist vollbracht: .NET Core 3.0 ist verfügbar und 3.1 in Preview (Stand November 2019).
Bleiben noch die beiden Umgebungen Mono/Xamarin und .NET Core. Deren Zusammenführung ist der Kern der Ankündigung:

.NET 5 builds on this work, taking .NET Core and the best of Mono to create a single platform that you can use for all your modern .NET code.

Das folgende Diagramm zeigt die Zusammenhänge auf:

image

Ziel dieser Verschmelzung der beiden Laufzeitumgebungen ist eine einzige Umgebung, die dann auf allen Geräten verfügbar ist:


Quelle: https://devblogs.microsoft.com/dotnet/introducing-net-5/

Von der Zusammenführung abgesehen hat Microsoft angekündigt, in welcher Kadenz weitere Versionen erscheinen sollen:


Quelle: https://devblogs.microsoft.com/dotnet/introducing-net-5/

Für .NET Core-Entwickler wird .NET 5 also nur die nächste Version ihrer Laufzeitumgebung sein, eventuell um Features aus Mono ergänzt; für Xamarin-Entwickler gilt analog das gleiche.

Darüberhinausgehende Aussagen, was für die nachfolgenden Versionen geplant ist, gibt es derzeit aber nicht.

Fazit

Für das .NET Framework ist die Bilanz gemischt. Einerseits wurde es zur Betriebssystemkomponente aufgewertet, es gibt also keinen Migrationszwang. Andererseits wird .NET Framework von zukünftigen Weiterentwicklungen ausgeschlossen, der Migrationsdruck wird also über kurz oder lang zunehmen.

Die Entwicklung von .NET Core, .NET Standard und den Microsoft Extensions ist für Anwendungsentwickler eine gute Sache. Eine schlankere, performante Umgebung, Konzepte die den heute doch etwas anders gelagerten Anforderungen gerecht werden. Nicht zuletzt lassen sich .NET Standard und die Microsoft Extensions auch in klassischen .NET Framework-Anwendungen nutzen – sei es wegen der Features selbst, oder in Vorbereitung auf eine angedachte Migration.

Und .NET 5, die große Ankündigung vom Mai? Für Microsoft ist .NET 5 sicher eine wichtige Sache. Aus Sicht von uns Anwendungsentwicklern aber eher nicht. .NET 5 wird einfach die nächste Version von Mono/Xamarin und .NET Core. Nicht mehr als ein technisches Release, denn im Idealfall sollten wir davon nichts merken.

Und was die nachfolgenden Versionen bringen werden, steht noch in den Sternen. Vielleicht überdenkt Microsoft die Entscheidung, WCF nicht zu migrieren, ja doch noch…?

Azure Functions – Wrap-up

Dieser Beitrag schließt meine Serie über Azure Functions und bringt noch einige Ergänzungen und Hinweise.

Ich denke, mit dieser Serie alle allgemein relevanten Themen aufgegriffen zu haben:

  1. Azure Functions – Eine kurze Einführung
  2. Azure Functions – Der App Service Plan
  3. Azure Functions – Die Skalierung begrenzen
  4. Azure Functions – Trigger und Bindings
  5. Azure Functions – Optionale Parameter und eigene Erweiterungen
  6. Azure Functions – Security

Der erste Beitrag gibt eine kurze Einführung sowie eine Liste von Best Practices aus der Praxis. Die nächsten beiden Beiträge widmen sich dem Betrieb und der Skalierung von Azure Functions. Die Beiträge 4 und 5 adressieren Schnittstellen einer Function, ihre Verbindung zur Laufzeitumgebung und zur Außenwelt. Der obligatorische Beitrag zu Security rundet das Thema Azure Functions ab.

Das einzige Thema, das ich bewusst unter den Tisch habe fallen lassen, ist Monitoring mit Application Insights. Azure Functions ist hier natürlich voll integriert; andererseits ist dies aber kein spezifisches Azure Functions-Thema, weshalb ich diese Auslassung für gerechtfertigt halte.

Der Vollständigkeit halber

Die Serie ist über einen längeren Zeitraum entstanden, und der Veröffentlichungsprozess fordert auch seinen Tribut; währenddessen ist die Weiterentwicklung von Azure Functions nicht stehengeblieben. So bleibt es nicht aus, dass einige Aussagen in meinen Beiträgen zwischenzeitlich von der Realität überholt wurden. Daher hier noch ein paar Ergänzungen und Korrekturen.

Premium plan

Der Premium plan ist ein neuer App Service Plan. Bislang hatte man die Wahl zwischen Consumption Plan mit dynamischer Skalierung der Instanzen einer Function App und dem “klassischen” App Service Plan, bei dem man die Anzahl der Instanzen vorgibt. Der neue Premium Plan ergänzt nun einen App Service Plan, bei dem man die minimale und die maximale Anzahl der Instanzen angeben kann. So kann man auch mit einem App Service Plan von den Skalierungsfähigkeiten von Azure Functions profitieren. “Vorgewärmte Instanzen” und VNET Integration sind weitere Neuerungen im Premium Plan.

Hier sind weiterführende Links zur Ankündigung und der Dokumentation.

Dependency Injection

Die zweite “große” Neuerung ist Dependency Injection, worauf ich im 5. Beitrag kurz eingegangen bin. Azure Functions unterstützt dies nun von Hause aus, was nicht nur weniger Aufwand für uns Entwickler bedeutet, sondern auch deutlich eingängiger gelöst ist, als dies bisher möglich war.

Im Kern beruht das natürlich darauf, dass eine Function nicht mehr durch eine statische Methode umgesetzt werden muss, sondern durch eine normale Instanz-Methode. Dependency Injection funktioniert dann wie üblich über Konstruktor-Parameter. Das Ganze basiert auf ASP.NET Core Dependency Injection.

Die Dokumentation zeigt dies recht anschaulich.

Sonstiges

Es gibt noch einige weitere Neuerungen, die auf der diesjährigen Build vorgestellt wurden, von Netzwerkthemen, über Azure Functions in Kubernetes (KEDA), bis DevOps-Integration.

Auf diese Neuerungen, sowie mehr Hintergründe zu den beiden angesprochen Themen, geht ein entsprechender Vortrag auf der Build ein. Ein Thema, das ich in meiner Serie nur kurz angeschnitten hatte, ist Durable Functions. Auch hier gibt es mit Version 2.0 einige Neuerungen. Ein Vortag beleuchtet das Thema insgesamt.

Und wem die Vorträge zu lang oder zu unhandlich sind: Ein Blog-Beitrag fasst das alles zusammen und bietet weiterführende Links.

Fazit zu Azure Functions

Das einfache und dadurch mächtige Programmiermodel, der kosteneffiziente Einsatz und die Skalierbarkeit machen Azure Functions – meiner Meinung nach – zum idealen Ansatz für jede Art von Hintergrundverarbeitung. Ob als Backend für Mobile Apps, um Last aus einer Web-App auszulagern, Datenimports im Hintergrund durchzuführen – Azure Functions machen eine gute Figur.

Natürlich hat das alles seine Grenzen oder kommt nicht schlüsselfertig – in meinen Beiträgen habe ich mich bemüht, gerade auf diese Dinge hinzuweisen. Deshalb lohnt sich eine detailliertere Auseinandersetzung mit dem Thema (wie bei jeder anderen Technologie auch). Ich hoffe, dass ich dies mit dieser Serie bieten konnte.

Nebenbei: Es sollte niemandem entgangen sein, dass ich bekennender Fan von Azure Functions bin. 😉

Azure Functions – Security

Security ist ein Thema, mit dem sich keiner gerne beschäftigt, aber an dem auch keiner vorbeikommt. Das macht auch vor Azure Functions nicht halt.

Security hat viele Facetten; aus Sicht eines App Services wie einer Function App geht es dabei speziell um Authentifizierung. Dies gilt sowohl für den Zugriff auf die Function App, als auch für den Zugriff durch die Function App auf andere Ressourcen.

Genauer lassen sich folgende Szenarien unterscheiden:

  • Zugriff auf fremde Ressourcen (innerhalb und außerhalb von Azure)
  • Zugriff auf fremde Ressourcen mit Managed Identity
  • Zugriff auf die Function App durch andere Azure Ressourcen
  • Zugriff auf die Function App von außen

Natürlich können sich diese Szenarien auch mischen.

Zugriff auf fremde Ressourcen

Auf “fremde Ressourcen” greift man mit Azure Functions in jedem Fall zu, da immer der Zugriff auf Azure Storage benötigt wird. Daneben sind aber auch Zugriffe auf Datenbanken oder andere Systeme (auch außerhalb von Azure) denkbar.

Azure Function Security

Der notwendige Connection String für Azure Storage wird in der Konfiguration hinterlegt – sprich, in der local.settings.json für die lokale Ausführung und im Azure Portal für den Betrieb in Azure.

Analog kann man auch die notwendigen Adressen, Passwörter, Connection Strings etc. für andere Ressourcen dort ablegen. Diese kann man per Code auslesen; der letzte Beitrag hat zudem gezeigt, wie man auf die Konfiguration deklarativ in Bindings zugreifen kann.

Zugriff auf fremde Ressourcen mit Managed Identity

Passwörter im Klartext in der Konfiguration stehen zu haben ist nicht immer gewünscht – die Konfiguration ist schließlich für jeden einsehbar, der Zugriff auf die Resource Group hat. Aber nur, weil ein Entwickler Zugriff auf die Logs benötigt, heißt das nicht, dass er auch die SAP-Passwörter sehen darf.

Für solche Fälle hat Microsoft Managed Identities vorgesehen, das Azure-Pendant zu einem technischen Account, verbunden mit Azure Key Vault, um Informationen sicher abzulegen.

Azure Function Security

Da Managed Identity und Key Vault nicht das Thema dieser Serie sind, hier nur vereinfacht zusammengefasst:

Konfiguration:

  • Man weist der Function App unter Platform features/Identity eine Managed Identity in Azure Active Directory zu.
  • Man berechtigt die Manage Identity in Key Vault zum Lesen der Informationen.

Code:

  • Man holt sich ein Token zum Zugriff auf den Key Vault
  • Man fragt im Key Vault die benötigte Information ab.

Der Mehraufwand ist überschaubar und rechtfertigt sich durch die sichere Ablage der sicherheitskritischen Konfiguration.

Typischer Stolperstein in der Praxis: Es sind typischerweise die Entwickler, die dies aufsetzen, eventuell im Rahmen von Continuous Deployment. Gerade die haben aber nicht immer die notwendigen Berechtigungen im Azure Active Directory des Unternehmens oder für das Einrichten bzw. den Zugriff auf den Key Vault.

Das folgende Video zeigt dies ganz anschaulich (ab 13:00):


Sprung zur passenden Stelle: https://youtu.be/N5I59z3qY0A?t=776

Zugriff auf die Function App durch andere Azure Ressourcen

Bei den meisten Triggern meldet sich die Function App selbst, z.B. über einen Connection String, an. Es gibt aber einen Trigger, der eine Tür nach außen aufmacht, durch die prinzipiell jeder auf die Function App zugreifen kann: Der HTTP Trigger. Das lässt sich mit Bordmitteln auch nicht verhindern – jede Function App bekommt eine gültige URL, darauf hat man keinen Einfluss.

Trotzdem lässt sich der Zugriff auf einen HTTP Trigger beschränken: Mit Authorization Keys, die der Aufrufer mitliefern muss.

Das folgende Bild zeigt eine Web App und eine API App, die solche Keys hinterlegt haben und sich damit gegenüber der Function App ausweisen:

Azure Function Security

In diesem Szenario stellen die Web App bzw. API App sicher, dass der ursprüngliche Aufrufer berechtigt ist. Bei ihrem eigenen Aufruf an die Function App übergibt sie dann den hinterlegten Authorization Key. Dieser wird von der Laufzeitumgebung von Azure Functions anhand der Anforderungen der Function geprüft.

Der HTTP-Trigger definiert, welchen Authorization Key er erwartet:

 image

Mögliche Werte sind dabei Anonymous, Function und Admin.
System und User scheinen Relikte ohne Bedeutung zu sein.

Die eigentlichen Keys können dem Azure Portal entnommen werden: In der Function App eine einzelne Function auswählen und dort den Punkt “Manage” selektieren:

image

Es gibt Function Keys, die für die einzelne Function gelten, sowie Host Keys, die für alle Functions der Function App Gültigkeit haben. Von beiden kann man weitere Keys anlegen, z.B. wenn man unterschiedliche Abnehmer hat.

Die Werte der Enumeration bilden sich dabei wie folgt auf die Keys aus dem Portal ab:

  • Anonymous: Es wird kein Key erwartet und auch keiner geprüft.
  • Function: Der Aufrufer muss einen Function Key oder Host Key übergeben.
  • Admin: Der Aufrufer muss den Key “_master” übergeben.
  • User, System: Nicht verwendet.

Da die Herausgabe des Keys _master nicht empfohlen werden kann (dies ist auch der einzige Key, der nicht gelöscht werden kann), bleiben nur Anonymus und Function als sinnvolle Werte.

Der Aufrufer muss diesen Key als Header-Parameter (alternativ als Query-Parameter) übergeben.

Wenn man diesen Weg geht, sollte man beachten, dass es sich bei den Keys um “Shared Secrets” handelt – sie sollten also das Backend nicht verlassen. Im gerade betrachteten Szenario sind die Aufrufer selbst Azure Ressourcen, das ist also sichergestellt.

Übrigens: Falls die Absicherung durch Keys nicht ausreichend ist, bietet die Dokumentation Hinweise, wie man den Zugriff mit Hilfe anderer Azure Dienste darüber hinaus absichern kann.

Zugriff auf die Function App von außen

Bleibt noch der letzte Fall: Die Azure Function App soll einen HTTP Trigger öffentlich verfügbar machen, aber gleichzeitig gegen unberechtigte Nutzung absichern. Typisches Szenario: Ich möchte aus einer Mobile App heraus eine Azure Function aufrufen:

Azure Function Security HTTP Trigger

Zentrale Herausforderung ist hier, dass die Function App lediglich ein übergebenen Authentication Token überprüfen, aber keinen Authentication Workflow initiieren kann. Dies muss die aufrufende Anwendung sicherstellen. Dabei gibt es zwei Szenarien zu unterscheiden:

  • Man nutzt “Authentication/Authorization” im Azure Portal.
  • Der Client authentifiziert sich selbständig und gibt das passende Token weiter

Im ersten Fall konfiguriert man in der Function App einen Authentication Provider:

image

Der eigentliche Client muss sich ebenfalls gegenüber diesem Provider authentifizieren (zum Beispiel Xamarin) und die entsprechenden Informationen – das Token – beim Aufruf an die Function App mitgeben. Azure gibt der Function dann über HTTP Header Informationen über die Authentifizierung mit. Enthalten ist mindestens eine User ID und ein Klartextname, je nach Provider kommen weitere Informationen dazu, etwa das Token. Über die URL /.auth/me können zudem weitere Details, etwa die Emailadresse, abgerufen werden.

Das Video oben geht auch auf diesen Fall ein, wenn auch im Kontext einer Web App. Vorteil dieser Vorgehensweise ist, dass einem Azure eine Menge Arbeit abnimmt. Nachteil ist, dass man im Normalfall immer nur einen Provider angeben kann.
Alternative zur Konfiguration in Azure ist, dass die Function App ein übergebenes Token selbst prüft. Das kann sinnvoll sein, wenn nur einzelne Functions authentifiziert werden sollen oder wenn Provider benötigt werden, die in Azure nicht unterstützt werden.

Zwar muss die Function App das übergebene Token jetzt selbst verifizieren, das ist aber nur ein einfacher Web-Request an den Anbieter. Zudem benötigt man hierfür keine Registrierung beim Anbieter; das Registrieren und Hinterlegen von Client ID/Client Secret entfällt also.

Nachteil dieser Lösung: Da jeder OAuth-Anbieter eigene URLs und Konventionen hat, muss man leider jeden Anbieter gesondert anbinden, auch wenn das an sich nicht sehr kompliziert ist. Bibliotheken, die einem diese Arbeit abnehmen, sind leider dünn gesät bzw. werden nicht mehr gepflegt. Ihr Quelltext kann jedoch die nötigen Informationen für die Aufrufe liefern, zum Beispiel Nemiro.OAuth.

Fazit

Anforderungen nach Authentifizierung sind mit Azure Functions relativ einfach umzusetzen, jedenfalls solange die Function App selbst den Aufruf macht, oder der Aufrufer sich ebenfalls in Azure (oder einer anderen geschützten Umgebung) befindet.

Eines der interessantesten Szenarien – der Einsatz als Backend für eine Mobile App – erfordert jedoch einiges an Handarbeit. Und das, obwohl das sowohl in ASP.NET MVC, als auch in Xamarin eigentlich schon erledigt ist.

Azure Functions – Optionale Parameter und eigene Erweiterungen

image244_thumb3_thumbAzure Functions erlauben mehr Parameter, als nur Trigger, Input- und Output-Bindings. Tatsächlich lässt sich mit Bindings weit mehr anstellen.
Der letzte Beitrag hat sich mit Triggern und Input- bzw. Output-Bindings beschäftigt, also mit der Frage, wie eine Function Daten mit der Außenwelt austauscht. Die Laufzeitumgebung stellt jedoch noch ein paar mehr Möglichkeiten zur Verfügung – allerdings ohne darauf besonders hinzuweisen.

Optionale Parameter

Die Laufzeitumgebung unterstützt einige optionale Parameter, um die man seine Parameterliste ergänzen kann. Diese werden über den Typ identifiziert:

  • ILogger (früher TraceWriter) für Logging
  • CancellationToken für vorzeitige Abbrüche
  • ExecutionContext für Informationen über die Umgebung und die derzeitige Ausführung (z.B. eine ID)

 

Die ersten beiden dürften selbsterklärend sein, besonders da eine neu erzeugte Function in der Regel über einen Parameter vom Typ ILogger und einen beispielhaften Aufruf verfügt. Der Parameter von Typ ExecutionContext ist weniger offensichtlich, liefert aber zwei interessante Informationen:

Die Ausführungs-ID, die von der Laufzeitumgebung im Log ausgegeben wird:

image

Diese ID lässt sich damit leicht auch in eigene Log-Einträge aufnehmen, so dass sich Log-Einträge leichter korrelieren lassen.

 

Über die zweite wichtige Information stolpert man, wenn man auf die Konfiguration zugreifen will. Die üblichen Antworten nutzen das Property FunctionAppDirectory, um die Konfiguration selbst zu laden:

   1: [FunctionName("Function2")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,

   4:     ExecutionContext executionContext,

   5:     ILogger log)

   6: {

   7:     var config = new ConfigurationBuilder()

   8:         .SetBasePath(executionContext.FunctionAppDirectory)

   9:         .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)

  10:         .AddEnvironmentVariables()

  11:         .Build();

  12:     var value = config["test"];

  13:     return new OkObjectResult(value);

  14: }

Produktiver Code sollte die Konfiguration natürlich nicht jedes mal neu laden.

Eigene Erweiterungen

Wenn es spezielle Sonderbehandlungen für optionale Parameter gibt, stellt sich natürlich die Frage, ob man hier selbst weitere Parameter “ergänzen” kann. Um das letzte Beispiel nochmal aufzugreifen: Die Laufzeitumgebung nutzt selbst die Konfiguration, kann man diese also nicht über einen Parameter vom Typ IConfiguration verfügbar machen?

Um es kurz zu machen: Ja, das geht. Aber man bewegt sich dabei in wenig bis nicht dokumentierten Bereichen. Blogbeiträge findet man zwar, aber die sind teilweise veraltet oder unvollständig. Letztlich kommt man nicht umhin, sich die Quelltexte von Azure Functions anzuschauen, insbesondere azure-functions-host und azure-webjobs-sdk.

Das nachfolgende Beispiel gibt eine kurze Einführung, um die grundsätzliche Arbeitsweise aufzuzeigen.

Konfiguration über Binding Provider

Die Implementierung dieses Beispiels basiert auf der Umsetzung für ILogger, sowie Hinweisen, wie dies mit dem neuen Erweiterungsmodell von Azure Functions zusammenspielt.

Ein eigenes Binding besteht aus dem Binding selbst (IBinding) und einem Provider, der dieses für jeden Parameter erzeugen muss (IBindingProvider). Als drittes muss man noch dafür sorgen, dass das der Laufzeitumgebung der Provider bekannt gemacht wird.

Das Binding sieht dabei etwas wild aus, ist aber im Grunde recht simpel und reicht nur Informationen weiter:

   1: internal class ConfigurationBinding : IBinding

   2: {

   3:     private readonly ParameterInfo _parameter;

   4:     private readonly IConfiguration _configuration;

   5:

   6:     public bool FromAttribute => false;

   7:

   8:     public ConfigurationBinding(ParameterInfo parameter, IConfiguration configuration)

   9:     {

  10:         _parameter = parameter;

  11:         _configuration = configuration;

  12:     }

  13:

  14:     public Task<IValueProvider> BindAsync(object value, ValueBindingContext context)

  15:     {

  16:         return Task.FromResult<IValueProvider>(new ValueBinder(value, _parameter.ParameterType));

  17:     }

  18:

  19:     public Task<IValueProvider> BindAsync(BindingContext context)

  20:     {

  21:         return BindAsync(_configuration, context.ValueContext);

  22:     }

  23:

  24:     public ParameterDescriptor ToParameterDescriptor()

  25:     {

  26:         return new ParameterDescriptor { Name = _parameter.Name };

  27:     }

  28:

  29:     private sealed class ValueBinder : IValueBinder

  30:     {

  31:         private readonly object _value;

  32:

  33:         public Type Type { get; private set; }

  34:

  35:         public ValueBinder(object value, Type type)

  36:         {

  37:             _value = value;

  38:             Type = type;

  39:         }

  40:

  41:         public Task<object> GetValueAsync() => Task.FromResult(_value);

  42:         public string ToInvokeString() => null;

  43:         public Task SetValueAsync(object value, CancellationToken cancellationToken) => Task.CompletedTask;

  44:     }

  45: }

 

Der Provider nimmt das IConfiguration entgegen und erzeugt für passende Parameter ein entsprechendes Binding. Wichtig ist nur, dass er null zurückliefert, wenn der Parameter-Typ nicht passt, denn der Provider wird für alle Parameter aufgerufen.

   1: internal class ConfigurationBindingProvider : IBindingProvider

   2: {

   3:     private readonly IConfiguration _configuration;

   4:

   5:     public ConfigurationBindingProvider(IConfiguration configuration)

   6:     {

   7:         this._configuration = configuration;

   8:     }

   9:

  10:     public Task<IBinding> TryCreateAsync(BindingProviderContext context)

  11:     {

  12:         var parameter = context.Parameter;

  13:         if (parameter.ParameterType != typeof(IConfiguration))

  14:             return Task.FromResult<IBinding>(null);

  15:

  16:         IBinding binding = new ConfigurationBinding(parameter, _configuration);

  17:         return Task.FromResult(binding);

  18:     }

  19: }

Fehlt noch der letzte Schritt: Der Provider muss beim Start der Function App bei der Laufzeitumgebung für Dependency Injection angemeldet werden. Die Laufzeitumgebung erzeugt ihn dann bei Bedarf und stellt auch die Konfiguration zur Verfügung.

Hierzu sind zwei Schritte nötig. Da Function Apps auf das WebJobs SDK aufsetzen, muss eine Startup-Klasse angelegt und angemeldet werden, die Microsoft.Azure.WebJobs.Hosting.IWebJobsStartup implementiert. Diese kann dann den Provider registrieren:

   1: [assembly: WebJobsStartup(typeof(Startup))]

   2:

   3: namespace FunctionApp

   4: {

   5:     internal class Startup : IWebJobsStartup

   6:     {

   7:         public void Configure(IWebJobsBuilder builder)

   8:         {

   9:             builder.Services.AddSingleton<IBindingProvider, ConfigurationBindingProvider>();

  10:         }

  11:     }

  12: }

Das reicht aber noch nicht ganz. Damit die Laufzeitumgebung die Klasse findet, muss sie in der Datei extensions.json im bin-Verzeichnis angemeldet werden. Der dokumentierte Weg dies zu tun ist recht einfach: Man muss nur das Nuget-Paket Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator in das Projekt aufnehmen.

Bleibt als letzter Schritt das neue Binding auch zu nutzen:

   1: [FunctionName("Function3")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,

   4:     IConfiguration configuration)

   5: {

   6:     var value = configuration["test"];

   7:     return new OkObjectResult(value);

   8: }

Voilà.

Natürlich würde man diesen Aufwand nicht für jeden Parameter treiben, aber der hier gezeigte Code ist allgemein und generisch genug, um in einer eigenen kleinen Bibliothek wiederverwendet zu werden.

Dependency Injection

Dependency Injection (DI) ist ein weiteres typisches Beispiel, das auf der Wunschliste der Entwickler steht. Dies ist in Arbeit, aber solange muss man nicht warten – es gibt einige Blog-Beiträge, die das jetzt schon ermöglichen.

Boris Wilhelms bietet eine nahezu vollständige Umsetzung. Allerdings sollte man das nicht ganz kritikfrei übernehmen: er nutzt sein eigenes Repository für den DI-Container, statt sich in den existierenden Kontext einzuklinken; und die Generierung der extensions.json löst er in seinem Beispiel von Hand.

Holger Leichsenring nutzt ganz gezielt Autofac und kann als Beispiel dienen, falls man besondere Anforderungen an den DI-Container hat.

Beide Beispiele zeigen zudem, wie man ein Binding mit einem Attribut für den Parameter kombiniert, was sicher gute Praxis ist.

Fazit

Die optionalen Parameter zu kennen ist wichtig, insbesondere den ExecutionContext. Eigene Bindings wird man sicher nicht jeden Tag schreiben, aber gerade die hier vorgestellten sind so allgemein, dass man sie leicht in eine Bibliothek auslagern und wiederverwenden kann. Die Anforderungen lassen sich zwar auch anders lösen, aber sauber integriert ist das sicher angenehmer und weniger fehleranfällig.

.NET Core is the Future of .NET

Microsoft hat es auf der Build angekündigt: Die Weiterentwicklung von .NET findet in .NET Core statt:

There will be just one .NET going forward, and you will be able to use it to target Windows, Linux, macOS, iOS, Android, tvOS, watchOS and WebAssembly and more. (link)

(Quelle: Microsoft)

Die nächste Version wird .NET 5 heißen und das derzeitige .NET Core (2.x bzw. 3.0) mit Mono zusammenführen:

“Expand the capabilities of .NET by taking the best of .NET Core, .NET Framework, Xamarin and Mono.” (link)

In einem weiteren Blog-Beitrag macht Microsoft auch deutlich, was das für das klassische .NET Framework bedeutet:

.NET Framework 4.8 will be the last major version of .NET Framework. If you have existing .NET Framework applications that you are maintaining, there is no need to move these applications to .NET Core. We will continue to both service and support .NET Framework, which includes bug–, reliability– and security fixes.” (link)

Reaktionen

Die Ankündigung kam für manche überraschend, für andere war sie absehbar und eine logische Konsequenz. Für den einen ist das keine große Sache, weil er seine Web-Anwendung ohnehin bereits mit .NET Core schreibt, der andere hat Bedenken, weil “sein” .NET Framework angeblich abgekündigt wurde. Man findet sogar Trolle, die behaupten, jetzt müsste jede Anwendung neu geschrieben werden.

Fakten

Das wichtigste zuerst: Wie es im Zitat oben heißt: Das klassische .NET Framework ist nicht abgekündigt. Bestehende Anwendungen müssen nicht neu geschrieben werden.
Ich würde sogar noch einen Schritt weitergehen: Es kann durchaus sinnvoll sein, auch jetzt noch neue Anwendungen mit .NET Framework zu schreiben. Es gibt einige Teile im .NET Framework, die es unter .NET Core bzw. .NET 5 (Stand heute) nicht geben wird; prominentestes Beispiel ist sicher WCF.
Fakt ist aber auch, dass in den nächsten Jahren der Migrationsdruck zunehmend wachsen wird, denn neue Features werden nur .NET Core zugute kommen. Je früher man auf diesen Zug aufspringt (wenn machbar), desto besser.

Konsequenzen

Wer im Web- oder Cloud-Umfeld unterwegs ist, dürfte bereits auf .NET Core sein. Er kann sich freuen auf das richtige Pferd gesetzt zu haben, und sicher sein, dass “seine” Plattform in Zukunft noch besser unterstützt wird.

Auch für Mobile- bzw. Xamarin-Entwickler ist das eine gute Nachricht, denn die parallele Entwicklung zweier Plattformen – hier Mono – kann auf Dauer nur zu Problemen führen, ohne dabei einen Vorteil zu bringen. Die Argumente der Vergangenheit – Open Source und Plattformunabhängigkeit – sind mit .NET Core hinfällig.

Bleibt noch die Entwicklung mit klassischem .NET Framework. Hier muss man drei Szenarien unterscheiden:

  • Keine einfache Migration möglich
  • Eine einfache Migration ist möglich
  • Die Migration hängt von Fremdbibliotheken ab

Keine einfache Migration

Schlecht sieht es aus für…

  • ältere Web-Anwendungen auf Basis von ASP.NET WebForms, HTTP-Modulen, etc.
  • Services auf Basis von WCF, .asmx oder Remoting
  • Anwendungen die AppDomains oder Code Access Security verwenden
  • und vermutlich noch ein paar weitere …

Diese Technologien sind bereits veraltet oder müssen mit der Ankündigung Microsofts als veraltet angesehen werden. Im Falle von WCF kann man hier durchaus anderer Ansicht sein, was auch durchaus kontrovers diskutiert wird. Die Aussage von Microsoft ist aber eindeutig:

“If you are a remoting or WCF Server developer and want to build a new application on .NET Core, we would recommend either ASP.NET Core Web APIs or gRPC”. (link)

Hier muss entschieden werden, ob es sich lohnt, die Anwendung auf alternative Technologien zu migrieren. Die häufigere Vorgehensweise dürfte jedoch sein, die Anwendung einfach weiterzupflegen – wohlwissend, dass damit jede relevante Investition in neue Funktionalitäten fehl am Platz ist.

Einfache Migration

Glück haben Entwickler von WPF-, WinForms- oder UWP-Anwendungen, denn diese werden mit .NET Core 3.0 unterstützt. Ebenso Anwendungen, die ADO.NET gegen den SQL Server nutzen.

Daneben gibt es das Windows Compatibility Pack for .NET Core, mit dem auch aus .NET Core-Anwendungen heraus unter Windows der Zugriff zum Beispiel auf das EventLog möglich ist. Abgerundet wird das mit der entsprechenden Dokumentation mit Anleitungen zum Migrationsprozess.

Allerdings darf man keine relevante Fremdbibliothek im Einsatz haben, sonst landet man in der nächsten Gruppe.

Abhängigkeit von Fremdbibliotheken

Die meisten nicht-trivialen Anwendungen – gerade auch, wenn sie schon älter sind – nutzen neben dem eigentlichen .NET Framework noch weitere 3rd Party Bibliotheken. Typische Vertreter sind:

Die meisten der genannten Beispiele beeinflussen die Anwendungsarchitektur massiv und sind nicht leicht austauschbar. Daher müssen sie in einer Variante für .NET Standard verfügbar sein, damit eine Migration der eigenen Anwendung möglich ist. Das ist bei vielen der Bibliotheken der Fall; Caliburn Micro scheint allerdings im Alpha-Stadium festzuhängen. Wer auf die Enterprise Library gesetzt hat, hat allerdings ein Problem.

Fazit

Auf lange Sicht ist die Ankündigung, die Plattform auf Basis von .NET Core zu vereinheitlichen zu begrüßen – ein plattformübergreifendes, von Altlasten befreites und an vielen Stellen verbessertes .NET Framework – wer könnte da nein sagen?
Und die Versuche der Vergangenheit – von unterschiedlichen Laufzeitumgebungen bis zu Portable Libraries – haben ihre Nischen nie verlassen können.

Kurz- und mittelfristig sorgt die Ankündigung aber für einen deutlichen Aufschlag auf den Wartungsbedarf bestehender Anwendungen.

Immerhin eins muss man Microsoft aber zugestehen: Es gibt eine klare Aussage. Keinen Eiertanz wie seinerzeit zu Silverlight.