Juni 2019

Band 34, Nummer 6

[Cutting Edge]

Erneute Untersuchung der ASP.NET Core-Pipeline

Von Dino Esposito | Juni 2019

Dino EspositoFast jede serverseitige Verarbeitungsumgebung verfügt über eine eigene Pipeline von Pass-Through-Komponenten, um eingehende Anforderungen und die ausgehenden Antworten zu prüfen, umzuleiten oder zu ändern. Klassisches ASP.NET konzentriert sich dabei auf HTTP-Module, während ASP.NET Core die modernere Architektur auf Basis von Middlewarekomponenten verwendet. Letztlich ist der Zweck immer gleich: Konfigurierbare externe Module greifen in den Ablauf der Anforderung (und später der Antwort) in der Serverumgebung ein. Der Hauptzweck von Middlewarekomponenten ist es, den Datenfluss zu verändern und in irgendeiner Weise zu filtern (und in einigen speziellen Fällen nur die Anforderung kurzzuschließen und die weitere Verarbeitung zu beenden).

Die ASP.NET Core-Pipeline ist seit Version 1.0 des Frameworks nahezu unverändert, aber das kommende Release von ASP.NET Core 3.0 lädt zu einigen Bemerkungen zur aktuellen Architektur ein, die weitgehend unbemerkt geblieben sind. In diesem Artikel werde ich also die Gesamtfunktionalität der ASP.NET Core-Laufzeitpipeline untersuchen und mich auf die Rolle und mögliche Implementierung von HTTP-Endpunkten konzentrieren.

ASP.NET Core für das Web-Back-End

Vor allem in den letzten Jahren hat sich die Entwicklung von Webanwendungen mit vollständig entkoppeltem Front-End und Back-End durchgesetzt. Daher sind die meisten ASP.NET Core-Projekte heute einfache Web-API-Projekte ohne Benutzeroberfläche, die nur eine HTTP-Fassade für eine Single-Page- und/oder mobile Anwendung bereitstellen, die größtenteils mit Angular, React, Vue und ihren mobilen Gegenstücken erstellt wurde.

Wenn Sie sich dessen bewusst sind, taucht eine Frage auf: Macht es in einer Anwendung, die keine Razor-Funktionen verwendet, immer noch Sinn, eine Bindung an das MVC-Anwendungsmodell herzustellen? Das MVC-Modell ist mit Kosten verbunden, und in einem gewissen Maß ist es vielleicht nicht einmal die einfachste Option, sobald Sie aufhören, Controller zu verwenden, um Aktionsergebnisse zu liefern. Um die Frage noch weiter zu verschärfen: Ist das Aktionsergebniskonzept selbst unbedingt notwendig, wenn ein signifikanter Teil des ASP.NET Core-Codes nur geschrieben wird, um JSON-Nutzlasten zurückzugeben?

Untersuchen wir vor dem Hintergrund dieser Überlegungen die ASP.NET Core-Pipeline und die interne Struktur von Middlewarekomponenten sowie die Liste der integrierten Laufzeitdienste, mit denen beim Start eine Bindung hergestellt werden kann.

Die Startup-Klasse

In jeder ASP.NET Core-Anwendung wird eine Klasse als Anwendungs-Bootstrapper definiert. Meistens trägt diese Klasse den Namen „Startup“. Die Klasse wird in der Konfiguration des Webhosts als Startklasse deklariert, und der Webhost instanziiert und ruft sie über eine Reflektion auf. Die Klasse kann zwei Methoden aufweisen: ConfigureServices (optional) und Configure. In der ersten Methode erhalten Sie die aktuelle (Standard-)Liste der Laufzeitdienste und werden voraussichtlich weitere Dienste hinzufügen, um den Boden für die eigentliche Anwendungslogik zu bereiten. In der Configure-Methode führen Sie die Konfiguration sowohl der Standarddienste als auch der Dienste aus, die Sie ausdrücklich zur Unterstützung Ihrer Anwendung angefordert haben.

Die Configure-Methode empfängt mindestens eine Instanz der Anwendungsgeneratorklasse. Sie können diese Instanz als eine funktionierende Instanz der ASP.NET-Laufzeitpipeline ansehen, die an Ihren Code übergeben wird, um entsprechend konfiguriert zu werden. Sobald die Configure-Methode zurückkehrt, ist der Pipelineworkflow vollständig konfiguriert und wird verwendet, um alle weiteren Anforderungen, die von verbundenen Clients gesendet werden, weiterzuverfolgen. Abbildung 1 zeigt eine Beispielimplementierung der Configure-Methode einer Startup-Klasse.

Abbildung 1: Grundlegendes Beispiel für die Configure-Methode in der Startup-Klasse

public void Configure(IApplicationBuilder app)
{
  app.Use(async (context, nextMiddleware) =>
  {
    await context.Response.WriteAsync("BEFORE");
    await nextMiddleware();  
    await context.Response.WriteAsync("AFTER");
  });
  app.Run(async (context) =>
  {
    var obj = new SomeWork();
    await context
      .Response
      .WriteAsync("<h1 style='color:red;'>" +
                   obj.SomeMethod() +
                  "</h1>");
  });
}

Die Use-Erweiterungsmethode ist die Hauptmethode, die Sie einsetzen, um dem andernfalls leeren Pipelineworkflow Middlewarecode hinzuzufügen. Beachten Sie Folgendes: Je mehr Middleware Sie hinzufügen, desto mehr Arbeit muss der Server leisten, um eingehende Anforderungen zu erfüllen. Je minimaler die Pipeline ist, desto schneller ist der TTFB-Wert (Time-To-First-Byte) für den Client.

Sie können der Pipeline Middlewarecode mithilfe von Lambdaausdrücken oder Ad-hoc-Middlewareklassen hinzufügen. Sie haben die Wahl: Der Lambdaausdruck ist direkter, aber die Klasse (und vorzugsweise einige Erweiterungsmethoden) vereinfacht die Lesbarkeit und Verwaltung des Ganzen. Der Middlewarecode ruft den HTTP-Kontext der Anforderung und einen Verweis auf die nächste Middleware in der Pipeline ab, falls vorhanden. Abbildung 2 bietet einen allgemeinen Überblick darüber, wie die verschiedenen Middlewarekomponenten miteinander verknüpft sind.

Die ASP.NET Core-Laufzeitpipeline
Abbildung 2: Die ASP.NET Core-Laufzeitpipeline

Jede Middlewarekomponente erhält eine doppelte Chance, in den Verlauf der aktuellen Anforderung einzugreifen. Sie kann die Anforderung vorverarbeiten, die sie von der Kette der Komponenten empfängt, die für die frühere Ausführung registriert wurden, und dann wird erwartet, dass sie die Verarbeitung an die nächste Komponente in der Kette übergibt. Wenn die letzte Komponente in der Kette die Möglichkeit erhält, die Anforderung vorzuverarbeiten, wird die Anforderung an die abschließende Middleware für die eigentliche Verarbeitung übergeben, um eine konkrete Ausgabe zu generieren. Danach wird die Kette der Komponenten in umgekehrter Reihenfolge durchlaufen (wie in Abbildung 2 dargestellt), und jede Middlewarekomponente erhält eine zweite Chance zur Verarbeitung. Dieses Mal handelt es sich jedoch um eine Nachbearbeitungsaktion. Im Code in Abbildung1 gibt die folgende Zeile die Grenze zwischen Vorverarbeitung und Nachbearbeitung an:

await nextMiddleware();

Die abschließende Middleware

Der Schlüssel zu der in Abbildung 2 dargestellten Architektur ist die Rolle der abschließenden Middleware, d.h. der Code am Ende der Configure-Methode, der die Kette beendet und die Anforderung verarbeitet. Alle ASP.NET Core-Demoanwendungen weisen einen abschließenden Lambdaausdruck auf:

app.Run(async (context) => { ... };

Der Lambdaausdruck empfängt ein HttpContext-Objekt und verarbeitet es wie im Kontext der Anwendung vorgesehen.

Eine Middlewarekomponente, die bewusst nicht an eine nächste Komponente übergibt, beendet die Kette tatsächlich, sodass die Antwort an den anfordernden Client gesendet wird. Ein gutes Beispiel dafür ist die UseStaticFiles-Middleware, die eine statische Ressource unter dem angegebenen Webstammordner verarbeitet und die Anforderung beendet. Ein weiteres Beispiel ist UseRewriter. Dieses Element kann ggf. eine Clientumleitung an eine neue URL anweisen. Ohne eine abschließende Middleware kann eine Anforderung kaum zu einer sichtbaren Ausgabe auf dem Client führen, obwohl eine Antwort immer noch wie von der ausgeführten Middleware geändert gesendet wird, z.B. durch Hinzufügen von HTTP-Headern oder Antwortcookies.

Es gibt zwei dedizierte Middlewaretools, die auch verwendet werden können, um die Anforderung kurzzuschließen: app.Map und app.MapWhen. Das erste Tool prüft, ob der Anforderungspfad mit dem Argument übereinstimmt und führt eine eigene abschließende Middleware aus, wie hier gezeigt:

app.Map("/now", now =>
{
  now.Run(async context =>
  {
    var time = DateTime.UtcNow.ToString("HH:mm:ss");
    await context
      .Response
      .WriteAsync(time);
  });
});

Das zweite Tool führt stattdessen nur dann eine eigene abschließende Middleware aus, wenn eine angegebene boolesche Bedingung überprüft wird. Die boolesche Bedingung ergibt sich aus der Auswertung einer Funktion, die einen HttpContext annimmt. Der Code in Abbildung 3 stellt eine sehr schlanke und minimale Web-API dar, die nur einen einzelnen Endpunkt verarbeitet, und zwar ohne eine Controllerklasse.

Abbildung 3: Minimale ASP.NET Core-Web-API

public void Configure(IApplicationBuilder app,
                      ICountryRepository country)
{
  app.Map("/country", countryApp =>
  {
    countryApp.Run(async (context) =>
    {
      var query = context.Request.Query["q"];
      var list = country.AllBy(query).ToList();
      var json = JsonConvert.SerializeObject(list);
      await context.Response.WriteAsync(json);
    });
  });
  // Work as a catch-all
  app.Run(async (context) =>
  {
    await context.Response.WriteAsync("Invalid call");
  }
});

Wenn die URL mit /country übereinstimmt, liest die abschließende Middleware einen Parameter aus der Abfragezeichenfolge und veranlasst einen Aufruf des Repositorys zum Abrufen der Liste der übereinstimmenden Länder. Das Listenobjekt wird dann manuell im JSON-Format direkt in den Ausgabedatenstrom serialisiert. Durch Hinzufügen von weiteren Zuordnungsrouten könnten Sie Ihre Web-API sogar noch erweitern. Einfacher kann es kaum sein.

Wie sieht es mit MVC aus?

In ASP.NET Core wird die gesamte MVC-Funktionalität als Blackbox-Laufzeitdienst angeboten. Sie müssen nur eine Bindung an den Dienst in der ConfigureServices-Methode herstellen und seine Routen in der Configure-Methode konfigurieren, wie im folgenden Code gezeigt:

public void ConfigureServices(IServiceCollection services)
{
  // Bind to the MVC machinery
  services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
  // Use the MVC machinery with default route
  app.UseMvcWithDefaultRoute();
  // (As an alternative) Use the MVC machinery with attribute routing
  app.UseMvc();
}

An diesem Punkt können Sie gerne den bekannten Ordner „Controllers“ und sogar den Ordner „Views“ mit Daten auffüllen, wenn Sie beabsichtigen, HTML zu verarbeiten. Beachten Sie, dass Sie in ASP.NET Core auch POCO-Controller verwenden können, also einfache C#-Klassen, die als Controller erkannt und vom HTTP-Kontext getrennt werden.

Die MVC-Funktionalität ist ein weiteres gutes Beispiel für abschließende Middleware. Sobald die Anforderung von der MVC-Middleware erfasst wird, übernimmt sie die Kontrolle, und die Pipeline wird abrupt beendet.

Es ist interessant zu erwähnen, dass die MVC-Funktionen intern eine eigene benutzerdefinierte Pipeline ausführen. Diese ist nicht middlewarezentriert, aber dennoch eine vollwertige Laufzeitpipeline, die steuert, wie Anforderungen an die Controlleraktionsmethode weitergeleitet werden, wobei das generierte Aktionsergebnis schließlich in den Ausgabedatenstrom gerendert wird. Die MVC-Pipeline besteht aus verschiedenen Arten von Aktionsfiltern (Aktionsnamenselektoren, Autorisierungsfiltern, Ausnahmehandlern, benutzerdefinierten Aktionsergebnis-Managern), die vor und nach jeder Controllermethode ausgeführt werden. In ASP.NET Core erfolgt auch die Aushandlung von Inhalten verborgen in der Laufzeitpipeline.

Bei näherer Untersuchung sieht die gesamte ASP.NET MVC-Funktionalität so aus, als würde sie auf der neuesten und überarbeiteten middlewarezentrierten Pipeline von ASP.NET Core aufsetzen. Als wären die ASP.NET Core-Pipeline und die MVC-Funktionen verschiedene Typen von Entitäten, die nur in irgendeiner Weise miteinander verbunden sind. Das Gesamtbild unterscheidet sich nicht wesentlich von der Art und Weise, wie MVC auf die jetzt nicht mehr zur Verfügung stehende Web Forms-Laufzeit aufgesetzt wurde. In diesem Zusammenhang trat MVC über einen dedizierten HTTP-Handler auf den Plan, wenn die Verarbeitungsanforderung nicht einer physischen Datei (wahrscheinlich einer ASPX-Datei) zugeordnet werden konnte.

Ist das ein Problem? Wahrscheinlich nicht. Oder vielleicht auch noch nicht!

Integrieren von SignalR

Wenn Sie einer ASP.NET Core-Anwendung SignalR hinzufügen, müssen Sie lediglich eine Hubklasse erstellen, um Ihre Endpunkte bereitzustellen. Der interessante Aspekt dabei ist, dass die Hubklasse vollständig unabhängig von Controllern sein kann. Sie benötigen MVC nicht, um SignalR auszuführen, aber die Hubklasse verhält sich wie ein Front-End-Controller für externe Anforderungen. Eine Methode, die von einer Hubklasse bereitgestellt wird, kann alle Aufgaben ausführen – selbst Aufgaben, die nicht mit dem anwendungsübergreifenden Benachrichtigungscharakter des Frameworks zusammenhängen, wie in Abbildung 4 dargestellt.

Abbildung 4: Bereitstellen einer Methode aus einer Hubklasse

public class SomeHub : Hub
{
  public void Method1()
  {
    // Some logic
    ...
    Clients.All.SendAsync("...");
  }
  public void Method2()
  {
    // Some other logic
    ...
    Clients.All.SendAsync("...");
  }
}

Erkennen Sie das Gesamtbild?

Die SignalR-Hubklasse kann als Controllerklasse angesehen werden (ohne die gesamte MVC-Funktionalität), ist also ideal für benutzeroberflächenlose (oder besser gesagt Razor-lose) Antworten.

Integrieren von gRPC

In Version 3.0 bietet ASP.NET Core auch native Unterstützung für das gRPC-Framework. Das Framework wurde in Anlehnung an die RPC-Richtlinien entwickelt und ist eine Codeshell um eine Schnittstellendefinitionssprache, die den Endpunkt vollständig definiert und in der Lage ist, die Kommunikation zwischen den verbundenen Parteien über binäre Protobuf-Serialisierung über HTTP/2 auszulösen. Aus der Sicht von ASP.NET Core 3.0 ist gRPC eine weitere aufrufbare Fassade, die serverseitige Berechnungen durchführen und Werte zurückgeben kann. So aktivieren Sie eine ASP.NET Core-Serveranwendung für die Unterstützung von gRPC:

public void ConfigureServices(IServiceCollection services)
{
  services.AddGrpc();
}
public void Configure(IApplicationBuilder app)
{
  app.UseRouting(routes =>
    {
      routes.MapGrpcService<GreeterService>();
    });
}

Beachten Sie auch die Verwendung von globalem Routing, damit die Anwendung Routen ohne MVC-Funktionen unterstützen kann. Sie können sich UseRouting als eine strukturiertere Methode zur Definition von app.Map-Middlewareblöcken vorstellen.

Die Hauptwirkung des vorherigen Codes besteht darin, RPC-ähnliche Aufrufe von einer Clientanwendung an den zugeordneten Dienst (die GreeterService Klasse) zu ermöglichen. Interessanterweise ist die GreeterService-Klasse konzeptionell gleichwertig mit einem POCO-Controller, mit dem Unterschied, dass sie nicht als Controllerklasse erkannt werden muss, wie hier gezeigt:

public class GreeterService : Greeter.GreeterBase
{
  public GreeterService(ILogger<GreeterService> logger)
  {
  }
}

Die Basisklasse (GreeterBase ist eine abstrakte Klasse, die von einer statischen Klasse umschlossen wird) enthält die notwendige Infrastruktur zur Durchführung des Anforderung/Antwort-Datenverkehrs. Die gRPC-Dienstklasse ist vollständig in die Infrastruktur von ASP.NET Core integriert und kann mit externen Verweisen injiziert werden.

Fazit

Insbesondere mit dem Release von ASP.NET Core 3.0 wird es zwei weitere Szenarien geben, in denen eine MVC-freie Fassade im Controllerstil hilfreich wäre. SignalR verfügt über Hubklassen und gRPC über eine Dienstklasse. Der Punkt ist aber, dass diese Klassen konzeptionell dasselbe sind und für verschiedene Szenarien auf unterschiedliche Weise implementiert werden müssen. Die MVC-Funktionen wurden mehr oder weniger so in ASP.NET Core portiert, wie sie ursprünglich für klassisches ASP.NET entwickelt wurden, und sie verwalten eine eigene interne Pipeline um Controller und Aktionsergebnisse. Gleichzeitig wächst mit der zunehmenden Nutzung von ASP.NET Core als einfacher Anbieter von Back-End-Diensten ohne Unterstützung von Ansichten der Bedarf für eine möglicherweise einheitliche Fassade im RPC-Stil für HTTP-Endpunkte.


Dino Esposito hat in seiner 25-jährigen Karriere über 20 Bücher und mehr als 1.000 Artikel verfasst. Als Autor von „The Sabbatical Break“, einer theatralisch angehauchten Show, schreibt Esposito Software für eine grünere Welt als digitaler Stratege bei BaxEnergy. Folgen Sie ihm auf Twitter: @despos.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Marco Cecconi


Diesen Artikel im MSDN Magazine-Forum diskutieren