März 2016

Band 31, Nummer 3

Innovation – Der Abfragestapel einer CQRS-Architektur

Von Dino Esposito | Juli 2016

Dino EspositoDerzeit ist CQRS (Command and Query Responsibility Segregation, Zuständigkeitstrennung für Befehle und Abfragen) eines der meisten diskutierten Themen im Bereich der Softwarearchitektur. Im Wesentlich ist CQRS nichts weiter als Ausdruck gesunden Menschenverstands mit der alleinigen Empfehlung, dass Sie die Abfragen und Befehle, die Ihr System benötigt, mithilfe von getrennten und Ad-hoc-Stapeln programmieren. Sie können nach Wunsch über eine Domänenebene im Befehlsstapel verfügen, Geschäftsaufgaben in Domänendiensten organisieren und Geschäftsregeln in Domänenobjekten packen. Die Persistenzschicht kann auch völlig frei programmiert werden, wobei Sie lediglich die Technologie und Methode wählen, die Ihre Anforderungen am besten erfüllt – einfache relationale Tabellen, NoSQL oder Ereignisspeicher.

Was gilt dagegen für den Lesestapel? Die gute Nachricht ist, dass sobald Sie sich eine CQRS-Architektur ansehen, Sie häufig nicht die Notwendigkeit eines gesonderten Abschnitts der Domänenebene für das bloße Lesen von Daten verspüren. Einfache Abfragen vordefinierter Tabellen sind alles, was Sie brauchen. Der Abfragestapel wendet schreibgeschützte Vorgänge auf das Back-End an, die den Zustand des Systems nicht ändern. Aus diesem Grund benötigen Sie ggf. keine Art von Geschäftsregeln und logischer Vermittlung zwischen Darstellung und Speicher – oder zumindest nichts, das über die Grundfähigkeiten erweiterter SQL-Abfrageoperatoren wie GROUP BY, JOIN und ORDER hinausgeht. Bei dieser Implementierung eines modernen Abfragestapels ist die von Microsoft .NET Framework gestützte Sprache LINQ überaus hilfreich. Im weiteren Verlauf dieser Kolumne durchlaufe ich eine mögliche Implementierung eines Lesestapels, bei der der Speicher entsprechend der Organisation der Daten konzipiert ist, die von der Darstellung benötigt wird.

Verfügen über vordefinierte Daten

Bei einem CQRS-Projekt planen Sie in der Regel getrennte Projekte für den Abfrage- und Befehlsstapel und können sogar über unterschiedliche Teams verfügen, die sich um den jeweiligen Stapel kümmern. Ein Lesestapel besteht im Wesentlichen aus zwei Komponenten: einer Auflistung von Datenübertragungsobjekten (Data Transfer Objects, DTOs) zum Übermitteln von Daten bis zur Darstellungsschicht und einem Datenbankkontext zum Ausführen physischer Lesevorgänge und Auffüllen von DTOs.

Damit der Abfragestapel so schlank und leistungsfähig wie möglich bleibt, sollte Sie eine enge Übereinstimmung zwischen den zu speichernden Daten und darzustellenden Daten anstreben. Insbesondere derzeit ist das ggf. nicht der Fall: Gängiger ist nämlich das Szenario, bei dem Daten idealerweise in einem gegebenen Format gespeichert werden, aber in einem wesentlich anderen Format angeordnet werden müssen, um effektiv genutzt zu werden. Stellen Sie sich als Beispiel ein Softwaresystem zum Buchen von Besprechungsräumen in einer Geschäftsumgebung vor. Abbildung 1 zeigt eine grafische Darstellung dieses Szenarios.

Buchungsschema in einer CQRS-Architektur
Abbildung 1: Buchungsschema in einer CQRS-Architektur

Dem Benutzer werden einige Formulare zum Erstellen einer Buchungsanfrage oder Aktualisieren einer vorhandenen Buchung angeboten. Jede Aktion wird im Ist-Zustand im Befehlsdatenspeicher protokolliert. Beim Durchlaufen der Liste protokollierter Buchungsereignisse kann der Benutzer einfach den Zeitpunkt der Eingabe der einzelnen Buchungen in das System, ihrer Änderungen, die Anzahl der Änderungen und den Löschzeitpunkt nachverfolgen. Dies ist definitiv die effektivste Möglichkeit zum Speichern von Informationen zu einem dynamischen System, bei dem der Zustand gespeicherter Elemente sich mit der Zeit ändern kann.

Das Speichern des Zustands des Systems in Form von Ereignissen hat viele Vorzüge (siehe meine Kolumne „Innovation“ vom August 2015 [msdn.com/magazine/mt185569]), liefert aber keine unmittelbare Ansicht des aktuellen Zustands des Systems. Anders ausgedrückt, können Sie vielleicht den vollständigen Verlauf einer einzelnen Buchung ausgraben, aber nicht sofort die Liste ausstehender Buchungen zurückgeben. Benutzer des Systems sind stattdessen auch zumeist am Abrufen der Liste der Buchungen interessiert. Dadurch werden zwei gesonderte Speicher erforderlich, die synchron gehalten werden müssen. Bei jeder Protokollierung eines neues Ereignisses muss der Zustand der beteiligten Entitäten (synchron oder asynchron) aktualisiert werden, um Abfragen zu vereinfachen.

Während des Synchronisierungsschritts stellen Sie sicher, dass nützliche Daten aus dem Ereignisprotokoll extrahiert werden und in ein Format umgewandelt werden, das der Abfragestapel auf der Benutzeroberfläche einfach nutzen kann. An dieser Stelle muss der Abfragestapel nur noch Daten für das bestimmte Datenmodell der aktuellen Ansicht anpassen.

Mehr zur Rolle von Ereignissen

Ein häufiger Einwand lautet: „Warum sollte ich Daten in Form von Ereignissen speichern? Warum Daten nicht einfach in der Form speichern, in der sie verwendet werden?“

Die Antwort ist das klassische „Hängt davon ab“, und der interessante Aspekt ist, dass es meist nicht von Ihnen, dem Architekten, abhängt. Es hängt von den Geschäftsanforderungen ab. Wenn es für den Kunden wichtig ist, den Verlauf eines Geschäftselements (d. h. der Buchung) nachzuverfolgen oder zu prüfen, wie die Liste der Buchungen an einem bestimmten Tag aussah und ob sich die Verfügbarkeit von Räumen mit der Zeit geändert hat, ist die einfachste Lösung das Protokollieren von Ereignissen und Erstellen erforderlicher überlagernder Datenprojektionen.

Dies ist nebenbei auch der interne Modus Operandi von Business Intelligence-Diensten und -Anwendungen (BI). Mithilfe von Ereignissen können Sie sogar die Grundlagen für unternehmensinterne BI schaffen.

Schreibgeschützter Entity Framework-Kontext

Entity Framework ist eine gängige Möglichkeit des Zugriffs auf gespeicherte Daten, zumindest in .NET- und ASP.NET-Anwendungen. Entity Framework leitet Datenbankaufrufe trichterförmig durch eine Instanz der „DbContext“-Klasse. Die „DbContext“-Klasse bietet Lese-/Schreibzugriff auf die zugrunde liegende Datenbank. Wenn Sie die „DbContext“-Instanz auf den obersten Schichten sichtbar machen, setzen Sie Ihre Architektur dem Risiko aus, Zustandsaktualisierungen auch aus dem Abfragestapel zu empfangen. Dies ist kein Programmfehler an sich, aber ein ernster Verstoß gegen die Architekturregeln, den Sie vermeiden möchten. Zum Vermeiden des Schreibzugriffs auf die Datenbank können Sie die „DbContext“-Instanz in einem Container und einer verwerfbaren Klasse umschließen (siehe Abbildung 2).

Abbildung 2: Umschließen der „DbContext“-Instanz in einem Container und einer verwerfbaren Klasse

public class Database : IDisposable
{
  private readonly SomeDbContext _context = new SomeDbContext();
  public IQueryable<YourEntity> YourEntities
  {
    get
    {
      return _context.YourEntities;
    }
  }
  public void Dispose()
  {
    _context.Dispose();
  }
}

Sie können „Database“ überall dort verwenden, wo Sie einen „DbContext“ nur zum Abfragen von Daten verwenden möchten. Die „Database“-Klasse unterscheidet sich in zwei Aspekten von einem einfachen „DbContext“. Zuallererst kapselt die „Database“-Klasse eine „DbContext“-Instanz als privaten Member. Zweitens macht die „Database“-Klasse alle oder einige der „DbContext“-Auflistungen als „IQueryable<T>“-Auflistungen anstatt als „DbSet<T>“-Auflistungen verfügbar. Dies ist der Kniff, um in den Genuss der Abfrageleistung von LINQ zu kommen, während keine Möglichkeit zum Hinzufügen, Löschen oder bloßen Rückspeichern von Änderungen besteht.

Anpassen der Daten für die Ansicht

In einer CQRS-Architektur gibt es keine vorschriftsmäßige Rangfolge, was die Organisation der Anwendungsschicht angeht. Sie kann für den Befehls- und Abfragestapel unterschiedlich oder eine eindeutige Schicht sein. Meistens hängt die Entscheidung von der Vorstellung des Softwarearchitekten ab. Wenn Sie es geschafft haben, eine duale Speicherung beizubehalten, und daher über Daten verfügen, die für die Darstellung optimiert sind, benötigen Sie nahezu keine komplexe Datenabruflogik. Demzufolge kann die Implementierung des Abfragestapels minimal sein. Im Abfrageteil des Anwendungsschichtcodes können Sie über Code für einen direkten Datenzugriff verfügen und den „DbContext“-Einstiegspunkt von Entity Framework direkt aufrufen. Um sich jedoch tatsächlich auf Abfragevorgänge zu beschränken, verwenden Sie nur die „Database“-Klasse anstelle des nativen „DbContext“. Alles, was Sie von der „Database“-Klasse zurückerhalten, kann dann allerdings nur über LINQ abgefragt werden. Abbildung 3 zeigt ein Beispiel.

Abbildung 3: Abfrage der „Database“-Klasse über LINQ

using (var db = new Database())
{
  var queryable = from i in db.Invoices
                              .Include("Customers")
    where i.InvoiceId == invoiceId
    select new InvoiceFoundViewModel
    {
      Id = i.InvoiceId,
      State = i.State.ToString(),
      Total = i.Total,
      Date = i.IssueDate,
      ExpiryDate = i.GetExpiryDate()
    };
    // More code here
}

Wie Sie sehen, wird die tatsächliche Projektion der Daten, die Sie von der Abfrage erhalten, erst in letzter Minute direkt auf der Anwendungsschicht angegeben. Dies bedeutet, dass Sie das „IQueryable“-Objekt irgendwo auf der Infrastrukturschicht erstellen und es auf den Schichten umher bewegen. Jede Schicht hat die Möglichkeit, das Objekt weiter zu ändern und dabei die Abfrage zu verfeinern, ohne sie tatsächlich auszuführen. Auf diese Weise müssen Sie nicht überaus viele Übertragungsobjekte erstellen, um Daten schichtenübergreifend zu verschieben.

Nun wollen wir uns einmal eine recht komplexe Abfrage anschauen. Angenommen, Sie müssen für einen der Anwendungsfälle in der Anwendung alle Rechnungen eines Geschäftsbereichs abrufen, die 30 Tage nach Fälligkeit nicht bezahlt wurden. Wenn die Abfrage eine solide und kompakte Geschäftsanforderung ausdrückt, die im weiteren Verlauf keiner weiteren Anpassung unterliegt, könnte sie ohne große Umschweife in einfacher T-SQL geschrieben werden. Die Abfrage besteht aus drei Hauptteilen: Abrufen aller Rechnungen, Auswählen derjenigen, die für einen bestimmten Geschäftsbereich spezifisch sind, und Auswählen derjenigen, die nach 30 Tagen nicht bezahlt wurden. Aus Sicht der Implementierung ist es alles andere als sinnvoll, die ursprüngliche Abfrage in drei Unterabfragen aufzuteilen und die meiste Verarbeitung im Arbeitsspeicher auszuführen. Doch aus konzeptueller Sicht erleichtert das Aufteilen der Abfrage in drei Teile ungemein das Verständnis, insbesondere für Einsteiger in das Domänenprinzip.

LINQ ist das magische Tool, mit dem Sie die Abfrage konzeptuell ausdrücken und in einem Schritt ausführen können, wobei der zugrunde liegende LINQ-Anbieter sich um ihre Übersetzung in die ordnungsgemäße Abfragesprache kümmert. Hier ist eine Möglichkeit, die LINQ-Abfrage auszudrücken:

var queryable = from i in db.Invoices
                            .Include("Customers")
  where i.BusinessUnitId == buId &&
     DateTime.Now – i.PaymentDueBy > 30
                select i;

Der Ausdruck gibt jedoch keine Daten, sondern nur eine Abfrage zurück, die noch nicht ausgeführt wurde. Zum Ausführen der Abfrage und Abrufen dazugehöriger Daten müssen Sie einen LINQ-Executor wie „ToList“ oder „FirstOrDefault“ aufrufen. Erst an dieser Stelle wird die Abfrage gebildet, wobei alle Komponenten zusammengesetzt und Daten zurückgegeben werden.

LINQ und universelle Sprache

Ein „IQueryable“-Objekt kann währenddessen geändert werden. Rufen Sie dazu die Methoden „Where“ und „Select“ programmgesteuert auf. Auf diese Weise entsteht, während das „IQueryable“-Objekt die Schichten Ihres Systems durchläuft, die endgültige Abfrage durch die Zusammensetzung von Filtern. Und was noch wichtiger ist: jeder Filter wird nur angewendet, wenn die Logik, auf der er basiert, verfügbar ist.

Weitere wichtige Punkte bei LINQ und „IQueryable“-Objekten im Abfragestapel einer CQRS-Architektur sind Lesbarkeit und universelle Sprache. Beim domänengesteuerten Design (Domain-Driven Design, DDD) ist die universelle Sprache das Muster, das Ihnen vorschlägt, ein Vokabular mit unzweideutigen Geschäftsbegriffen (Nomen und Verben) zu entwickeln und zu pflegen sowie, was noch wichtiger ist, diese Bedingungen im tatsächlichen Code widerzuspiegeln. Ein Softwaresystem, das die universelle Sprache implementiert, löscht beispielsweise nicht den Auftrag, sondern storniert diesen, und übermittelt einen Auftrag nicht, sondern checkt ihn aus.

Die größte Herausforderung moderner Software sind nicht technische Lösungen, sondern das umfassende Verständnis von Geschäftsanforderungen und die Ermittlung einer perfekten Übereinstimmung zwischen Geschäftsanforderungen und Code. Um die Lesbarkeit von Abfragen zu verbessern, können Sie „IQueryable“-Objekte und C#-Erweiterungsmethoden kombinieren. Hier sehen Sie, wie die vorherige Abfrage umgeschrieben wird, um sie deutlich lesbarer zu machen:

var queryable = from i in db.Invoices
                            .Include("Customers")
                            .ForBusinessUnit(buId)
                            .Unpaid(30)
                select i;

„ForBusinessUnit“ und „Unpaid“ sind zwei benutzerdefinierte Erweiterungsmethoden zum Erweitern des „IQueryable<Invoice>“-Typs. Sie dienen zum Hinzufügen einer WHERE-Klausel zur Definition der laufenden Abfrage:

public static IQueryable<Invoice> ForBusinessUnit(
  this IQueryable<Invoice> query, int businessUnitId)
{
  var invoices =
    from i in query
    where i.BusinessUnit.OrganizationID == buId
    select i;
  return invoices;}

Analog dazu besteht die „Unpaid“-Methode aus nichts weiter als einer weiteren WHERE-Klausel zum nochmaligen Einschränken der zurückgegebenen Daten. Die letzte Abfrage ist identisch, doch der Ausdruck ist wesentlich übersichtlicher und kaum misszuverstehen. In anderen Worten erhalten Sie durch die Verwendung von Erweiterungsmethoden nahezu eine domänenspezifische Sprache, was ganz nebenbei eines der Ziele der universellen DDD-Sprache ist.

Zusammenfassung

Wenn Sie CQRS mit einfachem DDD vergleichen, erkennen Sie in den meisten Fällen, dass Sie die Komplexität der Domänenanalyse und des Designs reduzieren können, indem Sie sich auf Anwendungsfälle und dazugehörige Modelle konzentrieren, die Aktionen zum Ändern des Zustands des Systems unterstützen. Alles andere, insbesondere Aktionen, die bloß den aktuellen Zustand lesen, können mithilfe einer wesentlich einfacheren Code-Infrastruktur ausgedrückt werden, ohne komplexer als einfache Datenbankabfragen zu sein.

Wichtig ist der Hinweis, dass im Abfragestapel einer CQRS-Architektur mitunter selbst eine umfassende objektrelationale Zuordnung (Object-Relational Mapper; O/RM) zu viel des Guten sein kann. Letztendlich müssen Sie nur Daten abfragen, zumeist aus vordefinierten relationalen Tabellen. Es besteht keine Notwendigkeit für komplexe Dinge wie Lazy Loading, Gruppierung und Joins, denn alles, was Sie brauchen, ist bereits da. Ein nicht trivialer O/RM wie Entity Framework 6 ist ggf. schon zu viel für Ihre Anforderungen, und Mikro-O/RMs wie PetaPoco oder NPoco eignen sich u. U. nicht für die Aufgabe. Interessanterweise spiegelt sich dieser Trend auch teilweise im Design des neuen Entity Framework 7 und des neuen ASP.NET 5-Stapels wider.


Dino Esposito* ist Autor von „Microsoft .NET: Architecting Applications for the Enterprise“ (Microsoft Press, 2014) und „Modern Web Applications with ASP.NET“ (Microsoft Press, 2016). Esposito ist Technical Evangelist für die .NET- und Android-Plattformen bei JetBrains und spricht häufig auf Branchenveranstaltungen weltweit. Auf software2cents.wordpress.com und auf Twitter unter twitter.com/despos lässt er uns wissen, welche Softwarevision er verfolgt.*