Dezember 2016

Band 31, Nummer 13

Cutting Edge: Schreiben eines CRUD-Systems mit Ereignissen und CQRS

Von Dino Esposito

Dino EspositoIn der Welt herrscht kein Mangel an klassischen CRUD-Systemen (Create, Read, Update, Delete), die eine relationale Datenbank und Geschäftslogik kombinieren, manchmal in gespeicherten Prozeduren versinken oder sogar durch Blackboxkomponenten eingeschränkt sind. Im Kern solcher Blackboxes finden die vier CRUD-Vorgänge statt: Erstellen neuer Entitäten, Lesen, Aktualisieren und Löschen. Auf einer ausreichend hohen Abstraktionsebene gibt es mehr nicht zu sagen: Jedes System ist in gewisser Hinsicht ein CRUD-System. Die Entitäten können unter Umständen recht komplex sein und eher die Form eines Aggregats annehmen.

In einem domänengesteuerten Entwurf (Domain-Driven Design, DDD) ist ein Aggregat ein geschäftsbezogener Cluster von Entitäten mit einem Stammobjekt. Aus diesem Grund kann das Erstellen, Aktualisieren oder sogar Löschen einer Entität mehreren komplizierten Geschäftsregeln unterliegen. Sogar das Lesen des Zustands eines Aggregats ist normalerweise problematisch, und zwar meistens aus UX-Gründen. Das Modell, mit dem der Zustand des Systems geändert wird, ist nicht notwendigerweise das gleiche Modell, mit dem Benutzern Daten in allen Anwendungsfällen präsentiert werden.

Wenn die höchste Abstraktionsebene von CRUD erreicht wird, führt dies unmittelbar zur Trennung der Vorgänge, die den Zustand eines Systems ändern, von den Vorgängen, die einfach mindestens eine Ansicht des Systems zurückgeben. Dies ist das wesentliche Merkmal von CQRS (Command and Query Responsibility Segregation): nämlich die saubere Trennung von Befehls- und Abfrageverantwortlichkeiten.

Softwarearchitekten und Entwickler müssen jedoch zahlreiche weitere Aspekte berücksichtigen. Der Zustand des Systems wird im Befehlsstapel geändert. Konkret gesagt, werden dort Aggregate zuerst erstellt und später auch aktualisiert und gelöscht. Und genau das ist der Aspekt, der neu überdacht werden muss.

Das Speichern des Verlaufs ist für beinahe jedes Softwaresystem wesentlich. Wenn Software für die Unterstützung der laufenden Geschäftsaktivitäten geschrieben wird, ist das Lernen aus der Vergangenheit aus zwei Gründen unabdingbar: Es muss vermieden werden, dass auch nur ein einziges aufgetretenes Ereignis verloren geht, und die Dienste für Kunden und Mitarbeiter müssen verbessert werden.

In den Ausgaben dieser Kolumne aus Mai 2016 (msdn.com/magazine/mt703431) und Juni 2016 (msdn.com/magazine/mt707524) habe ich zwei Verfahren zum Erweitern des klassischen CRUD-Systems in ein historisches CRUD-System vorgestellt. In meinen Artikeln aus August 2016 (msdn.com/magazine/mt767692) und Oktober 2016 (msdn.com/magazine/mt742866) habe ich im Gegensatz dazu ein ECS-Muster (Event-Command-Saga) und ein Memento FX-Framework (bit.ly/2dt6PVD) als Bausteine für ein neues Verfahren zum Ausdrücken der Geschäftslogik beschrieben, die die täglichen Anforderungen erfüllt.

In dieser sowie in der nächsten Kolumne beschäftige ich mich mit den beiden zuvor erwähnten Vorteilen, die sich durch das Beibehalten des Verlaufs in einem System ergeben, indem ich eine Buchungsdemoanwendung (die gleiche Demo, die ich in den Artikeln aus Mai und Juni verwendet habe) mit CQRS und Ereignisquellen überarbeite.

Das große Ganze

Meine Beispielanwendung ist ein internes Buchungssystem für Besprechungsräume. Der Hauptanwendungsfall ist ein protokollierter Benutzer, der durch einen Kalender scrollt und mindestens einen freien Termin für einen bestimmten Raum bucht. Das System verwaltet Entitäten (z. B. „Room“, „RoomConfiguration“ und „Booking“). Wie Sie sich denken können, dreht sich die gesamte Anwendung konzeptmäßig um das Hinzufügen und Bearbeiten von Räumen und Konfigurationen (also wann der Raum gebucht werden kann und wie lang die einzelnen Besprechungstermine sind) sowie um das Hinzufügen, Aktualisieren und Stornieren von Reservierungen. Abbildung 1 gibt einen Einblick in die Aktionen, die Benutzer des Systems ausführen können, und zeigt, wie diese in die Architektur eines CQRS-Systems gemäß dem ECS-Muster eingebettet werden können.

Benutzeraktionen und Entwurf des Systems auf hoher Ebene
Abbildung 1: Benutzeraktionen und Entwurf des Systems auf hoher Ebene

Ein Benutzer kann eine neue Reservierung eingeben, diese verschieben oder stornieren und sogar im Raum einchecken, damit das System weiß, dass der reservierte Raum auch tatsächlich genutzt wird. Der Workflow hinter jeder Aktion wird in einer Saga verarbeitet, und diese Saga ist eine im Befehlsstapel definierte Klasse. Eine Sagaklasse besteht aus Handlermethoden, die jeweils einen Befehl oder ein Ereignis verarbeiten. Das Vornehmen einer Reservierung (oder das Verschieben einer vorhandenen Reservierung) besteht im Übermitteln eines Befehls an den Befehlsstapel mithilfe von Push. Allgemein lässt sich sagen, dass das Übermitteln eines Befehls mithilfe von Push einfach (direkter Aufruf der entsprechenden Sagamethode) sein oder auch die Dienste eines Bus durchlaufen kann.

Wenn der Verlauf beibehalten werden soll, müssen mindestens alle Geschäftsauswirkungen aller verarbeiteten Befehle nachverfolgt werden. Unter bestimmten Umständen möchten Sie ggf. auch die ursprünglichen Befehle nachverfolgen. Ein Befehl ist ein Datenübertragungsobjekt, das bestimmte Eingabedaten enthält. Eine Geschäftsauswirkung der Ausführung eines Befehls durch eine Saga ist ein Ereignis. Ein Ereignis ist ein Datenübertragungsobjekt, das die Daten enthält, die das Ereignis vollständig beschreiben. Ereignisse werden in einem bestimmten Datenspeicher gespeichert. Für die Speichertechnologie, die für Ereignisse verwendet wird, gelten keine strengen Einschränkungen. Es kann sich dabei um ein einfaches RDBMS (Relational Database Management System, Managementsystem für relationale Datenbanken) oder um einen NoSQL-Datenspeicher handeln. (Informationen zum Einrichten von MementoFX und RavenDB sowie des Bus finden Sie in meinem Artikel aus Oktober.)

Koordinieren von Befehlen und Abfragen

Angenommen, ein Benutzer gibt einen Befehl zum Buchen eines Besprechungstermins für einen bestimmten Raum aus. In einem ASP.NET MVC-Szenario ruft der Controller die geposteten Daten ab und gibt dann einen Befehl an den Bus aus. Der Bus ist so konfiguriert, dass einige Sagas erkannt werden, und jede Saga deklariert die Befehle (und/oder Ereignisse), die von ihr verarbeitet werden sollen. Der Bus versendet die Nachricht daher an die Saga. Die Eingabe der Saga sind die Rohdaten, die Benutzer in die Formulare in der Benutzeroberfläche eingegeben haben. Der Sagahandler ist für die Rückgabe der empfangenen Daten in einer Instanz eines Aggregats verantwortlich, das mit der Geschäftslogik konsistent ist.

Nehmen wir nun an, dass der Benutzer mit der Maus klickt, um die Buchung auszuführen. Abbildung 2 zeigt dies. Die von der Schaltfläche ausgelöste Controllermethode empfängt die ID des Raums, den Tag und die Uhrzeit sowie den Benutzernamen. Der Sagahandler muss diese Angaben in ein Buchungsaggregat umwandeln, das speziell für die Verarbeitung der erwarteten Geschäftslogik konzipiert ist. Sinnvollerweise übernimmt die Geschäftslogik Aspekte aus den Bereichen Berechtigungen, Prioritäten, Kosten und sogar Parallelität. Die Sagamethode muss jedoch mindestens ein Buchungsaggregat erstellen und dieses speichern.

Buchen eines Besprechungsraums im Beispielsystem
Abbildung 2: Buchen eines Besprechungsraums im Beispielsystem

Auf den ersten Blick unterscheidet sich der Codeausschnitt in Abbildung 3 nicht von einem einfachen CRUD. Er verwendet jedoch eine Factory und die besondere Repository-Eigenschaft. Die kombinierten Auswirkungen von Factory und Repository schreiben alle Ereignisse in die konfigurierten Ereignisspeicher, die in der Implementierung der Booking-Klasse ausgelöst wurden.

Abbildung 3: Struktur einer Saga-Klasse

public class ReservationSaga : Saga,
  IAmStartedBy<MakeReservationCommand>,
  IHandleMessages<ChangeReservationCommand>,
  IHandleMessages<CancelReservationCommand>
{
   ...
  public void Handle(MakeReservationCommand msg)
  {
    var slots = CalculateActualNumberOfSlots(msg);
    var booking = Booking.Factory.New(
      msg.FullName, msg.When, msg.Hour, msg.Mins, slots);
    Repository.Save(booking);
  }
}

Am Ende speichert das Repository keinen Datensatz mit dem aktuellen Zustand einer Booking-Klasse, in dem Eigenschaften irgendwie Spalten zugeordnet sind. Das Repository speichert nur Geschäftsereignisse im Speicher, und am Ende dieser Phase wissen Sie genau, was mit ihrer Buchung geschehen ist (wann und wie sie erstellt wurde), es stehen jedoch nicht die klassischen Informationen zur Verfügung, die dem Benutzer angezeigt werden. Sie wissen, was geschehen ist, verfügen jedoch über keine Daten für die Anzeige. Der Quellcode der Factory wird in Abbildung 4 gezeigt.

Abbildung 4: Quellcode der Factory

public static class Factory
{
  public static Booking New(string name, DateTime when,
    int hour, int mins, int length)
  {
    var created = new NewBookingCreatedEvent(
      Guid.NewGuid(), name.Capitalize(), when,
      hour, mins, length);
    // Tell the aggregate to log the "received" event
    var booking = new Booking();
    booking.RaiseEvent(created);
    return booking;
  }
}

Eigenschaften der neu erstellten Instanz der Booking-Klasse werden in der Factory nicht verarbeitet. Es wird aber eine Ereignisklasse erstellt und mit den tatsächlichen Daten aufgefüllt, die in der Instanz gespeichert werden sollen (einschließlich des groß geschriebenen Namens des Kunden und der eindeutigen ID, die die Reservierung im gesamten System dauerhaft nachverfolgt). Das Ereignis wird an die RaiseEvent-Methode (Teil des MementoFX-Frameworks) übergeben, weil dies die Basisklasse aller Aggregate ist. „RaiseEvent“ fügt das Ereignis einer internen Liste hinzu, die das Repository beim „Speichern“ der Instanz des Aggregats durchläuft. Ich verwende den Begriff „Speichern“, weil genau dies geschieht. Die Anführungszeichen weisen jedoch darauf hin, dass es sich um einen anderen Typ von Aktion als in einem klassischen CRUD-System handelt. Das Repository speichert das Ereignis der Reservierungserstellung mit den angegebenen Daten. Genauer gesagt, speichert das Repository alle in einer Instanz des Aggregats während der Ausführung eines Geschäftsworkflows protokollierten Ereignisse, nämlich eine Sagahandlermethode. Abbildung 5 zeigt dies.

Speichern von Ereignissen im Vergleich zum Speichern des Zustands
Abbildung 5: Speichern von Ereignissen im Vergleich zum Speichern des Zustands

Das Nachverfolgen des Geschäftsereignisses, das sich aus einem Befehl ergibt, ist jedoch nicht ausreichend.

Denormalisieren von Ereignissen für den Abfragestapel

Wenn Sie CRUD unter dem Aspekt der Beibehaltung des Datenverlaufs betrachten, erkennen Sie, dass sich das Erstellen und Lesen von Entitäten nicht auf den Verlauf auswirkt. Dies gilt jedoch nicht für das Aktualisieren und Löschen. Ein Ereignisspeicher kann nur Anfügevorgänge ausführen, und Aktualisierungen und Löschvorgänge sind nur neue Ereignisse, die sich auf die gleichen Aggregate beziehen. Wenn Sie über eine Liste der Ereignisse für ein bestimmtes Aggregat verfügen, wissen Sie alles über den Verlauf. Sie kennen nur nicht den aktuellen Zustand. Und der aktuelle Zustand ist genau das, was Sie Benutzern anzeigen müssen.

An dieser Stelle kommen Denormalisierer ins Spiel. Ein Denormalisierer ist eine Klasse, die als Sammlung von Ereignishandlern (genau wie die Handler, die im Ereignisspeicher gespeichert werden) erstellt wird. Sie registrieren einen Denormalisierer beim Bus, und der Bus verteilt dann bei jedem Empfang eines Ereignisses Ereignisse an ihn. Das Ergebnis davon ist, dass ein Denormalisierer, der für das Lauschen auf das erstellte Buchungsereignis geschrieben wurde, reagieren kann, wenn ein Ereignis ausgelöst wird.

Ein Denormalisierer ruft die Daten im Ereignis ab und führt dann alle vorgegebenen erforderlichen Aktionen aus. Er synchronisiert z. B. eine einfach abzufragende relationale Datenbank mit aufgezeichneten Ereignissen. Die relationale Datenbank (oder ein NoSQL-Speicher bzw. ein Cache, wenn deren Verwendung einfacher oder vorteilhafter ist) gehört zum Abfragestapel, und ihre API erhält keinen Zugriff auf die gespeicherte Liste der Ereignisse. Außerdem können mehrere Denormalisierer verwendet werden, die Ad-hoc-Ansichten der gleichen Rohereignisse erstellen. (In meinem nächsten Artikel werde ich diesen Aspekt näher untersuchen.) In Abbildung 1 wird der Kalender, aus dem ein Benutzer einen Besprechungstermin auswählt, aus einer einfachen relationalen Datenbank mit Daten aufgefüllt, die mit Ereignissen synchronisiert durch die Aktion eines Denormalisierers verwaltet wird. Abbildung 6 zeigt den Code der Denormalisiererklasse.

Abbildung 6: Struktur einer Denormalisiererklasse

public class BookingDenormalizer :
  IHandleMessages<NewBookingCreatedEvent>,
  IHandleMessages<BookingMovedEvent>,
  IHandleMessages<BookingCanceledEvent>
{
  public void Handle(NewBookingCreatedEvent message)
  {
    var item = new BookingSummary()
    {
      DisplayName = message.FullName,
      BookingId = message.BookingId,
      Day = message.When,
      StartHour = message.Hour,
      StartMins = message.Mins,
      NumberOfSlots = message.Length
    };
    using (var context = new MfxbiDatabase())
    {
      context.BookingSummaries.Add(item);
      context.SaveChanges();
    }  }
  ...
}

Wie in Abbildung 5 gezeigt, stellen Denormalisierer ein relationales CRUD-System nur für Lesezwecke zur Verfügung. Die Ausgabe des Denormalisierers wird häufig als „Lesemodell” bezeichnet. Entitäten im Lesemodell stimmen normalerweise nicht mit den Aggregaten überein, die zum Erstellen von Ereignissen verwendet werden, weil sie in den meisten Fällen von den Anforderungen der Benutzeroberfläche gesteuert werden.

Aktualisierungen und Löschvorgänge

Nehmen Sie jetzt an, dass der Benutzer einen zuvor gebuchten Besprechungstermin verschieben möchte. Ein Befehl wird mit allen Details des neuen Besprechungstermins ausgegeben, und eine Sagamethode übernimmt das Schreiben eines Moved-Ereignisses für die angegebene Buchung. Die Saga muss das Aggregat abrufen, weil es im aktualisierten Zustand benötigt wird. Wenn Denormalisierer soeben eine relationale Kopie vom Zustands des Aggregats erstellt haben (aus diesem Grund deckt sich das Lesemodell fast mir dem Domänenmodell), können Sie den aktualisierten Zustand aus dieser Kopie abrufen. Andernfalls erstellen Sie eine neue Kopie des Aggregats und führen alle protokollierten Ereignisse für diese aus. Am Ende der Wiedergabe befindet sich das Aggregat im bestmöglichen Aktualisierungszustand. Die Wiedergabe von Ereignissen ist kein Task, den Sie direkt ausführen müssen. In MementoFX rufen Sie ein aktualisiertes Aggregat mit einer Codezeile in einem Sagahandler ab:

var booking = Repository.GetById<Booking>(message.BookingId);

Im nächsten Schritt wenden Sie beliebige benötigte Geschäftslogik auf die Instanz an. Die Geschäftslogik generiert Ereignisse, und diese Ereignisse werden durch das Repository persistent gespeichert:

booking.Move(id, day, hour, mins);
Repository.Save(booking);

Wenn Sie das Muster „Domänenmodell“ verwenden und dabei DDD-Prinzipien beachten, enthält die Move-Methode die gesamte Domänenlogik und alle Ereignisse. Andernfalls führen Sie eine Funktion mit der benötigten Geschäftslogik aus und lösen Ereignisse direkt für den Bus aus. Durch Binden eines weiteren Ereignishandlers an den Denormalisierer besteht die Möglichkeit, das Lesemodell zu aktualisieren.

Das Stornieren einer Buchung folgt den gleichen Prinzipien. Das Ereignis einer Buchungsstornierung ist ein Geschäftsereignis und muss nachverfolgt werden. Dies bedeutet, dass Sie ggf. eine boolesche Eigenschaft im Aggregat zum Ausführen logischer Löschvorgänge verwenden. Im Lesemodell kann das Löschen abhängig davon, ob Ihre Anwendung das Lesemodell auf stornierte Buchungen abfragt, ein physischer Vorgang sein. Eine interessante Nebenerscheinung: Sie können das Lesemodell jederzeit erneut erstellen, indem Sie Ereignisse von Anfang an oder ab einem Wiederherstellungspunkt wiedergeben. Sie müssen zu diesem Zweck nur ein Ad-hoc-Tool erstellen, das die Ereignisspeicher-API zum Lesen von Ereignissen und direkten Aufrufen von Denormalisierern verwendet.

Verwenden der Ereignisspeicher-API

Sehen Sie sich die Auswahl in der Dropdownliste in Abbildung 2 an. Der Benutzer möchte die Buchung ab dem Startzeitpunkt so lange wie möglich ausdehnen. Die Geschäftslogik im Aggregat muss in der Lage sein, dies zu erkennen, und sie muss auf die Liste der Buchungen des gleichen Tages zugreifen können, die später als die Startzeit liegen, um dem Wunsch des Benutzers entsprechen zu können. In einem klassischen CRUD-System ist dies nicht mit großem Aufwand verbunden. Mit MementoFX können Sie jedoch ebenfalls auf Ereignisse abfragen:

var createdEvents = EventStore.Find<NewBookingCreatedEvent>(e =>
  e.ToDateTime() >= date).ToList();

Der Codeausschnitt gibt eine Liste der NewBookingCreated-Ereignisse ab der angegebenen Uhrzeit zurück. Es gibt jedoch keine Garantie, dass die erstellte Buchung noch aktiv ist und nicht auf einen anderen Besprechungstermin verschoben wurde. Sie müssen unbedingt den aktualisierten Zustand dieser Aggregate abrufen. Über den zu diesem Zweck verwendeten Algorithmus entscheiden Sie. Sie können z. B. aus der Liste der Created-Ereignisse die nicht mehr aktiven Buchungen ausfiltern und dann die ID der verbleibenden Buchungen abrufen. Schließlich überprüfen Sie den tatsächlichen Besprechungstermin im Vergleich zu dem Termin, der ausgedehnt werden soll, ohne dass Überschneidungen auftreten. Im Quellcode dieses Artikels habe ich diese gesamte Programmlogik in einem separaten Dienst (Domänendienst) im Befehlsstapel codiert.

Zusammenfassung

Die Verwendung von CQRS und Ereignisquellen ist nicht auf bestimmte Systeme mit High-End-Anforderungen an Parallelität, Skalierbarkeit und Leistung eingeschränkt. Wenn Infrastruktur verfügbar ist, die das Arbeiten mit Aggregaten und Workflows ermöglicht, kann jedes der zeitgemäßen CRUD-Systeme so überarbeitet werden, dass zahlreiche Vorteile entstehen. Zu diesen Vorteilen gehört Folgendes:

  • Erhalten des Verlaufs von Daten.
  • Ein effektiveres und robusteres Verfahren zum Implementieren von Geschäftstasks und zum Ändern von Tasks aufgrund von Geschäftsänderungen mit überschaubarem Aufwand und eingeschränktem Regressionsrisiko.
  • Da es sich bei Ereignissen um unveränderliche Fakten handelt, können sie problemlos kopiert und dupliziert werden, und selbst Lesemodelle können programmgesteuert nach Wunsch erneut generiert werden.

Dies bedeutet, dass das ECS-Muster (manchmal auch als „CQRS/ES“ bezeichnet) ein enormes Skalierungspotenzial besitzt. Zudem ist das MementoFX-Framework in diesem Fall hilfreich, weil es allgemeine Tasks vereinfacht und die Aggregatabstraktion für einfachere Programmierung bietet.

MementoFX verfolgt einen DDD-orientierten Ansatz. Sie können das ECS-Muster jedoch mit anderen Frameworks und Paradigmen (z. B. dem funktionalen Paradigma) verwenden. Es gibt noch einen weiteren Vorteil, der möglicherweise der relevanteste ist. Diesen behandele ich in meiner nächsten Kolumne.


Dino Espositoist 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 @despos lässt er uns wissen, welche Softwarevision er verfolgt.

Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Andrea Saltarello