Juli 2015

Band 30, Nummer 7

Cutting Edge – CQRS und nachrichtenbasierte Anwendungen

Von Dino Esposito | Juli 2015

Dino EspositoUnterm Strich ist CQRS (Command and Query Responsibility Segregation, Trennung von Befehls- und Abfragezuständigkeit) eine Art der Softwareentwicklung, bei der Code, der den Zustand ändert, von dem Code getrennt wird, der den Zustand lediglich liest. Dabei kann es sich um eine logische Trennung handeln, die auf verschiedenen Schichten aufbaut. Sie kann aber auch physisch sein und mit getrennten Ebenen arbeiten. Hinter CQRS steht weder ein Manifest noch eine modische Philosophie. Die einzige Triebkraft ist die Einfachheit des Entwurfs. Ein vereinfachter Entwurf stellt in diesen verrückten Zeiten mit überwältigend komplexen Geschäftsabläufen den einzig sicheren Weg dar, wie Effektivität, Optimierung und Erfolg sichergestellt werden können.

In meiner letzten Kolumne (msdn.microsoft.com/magazine/mt147237) habe ich einen Ausblick auf den CQRS-Ansatz gegeben, der ihn für Anwendungen aller Art geeignet macht. Sobald Sie eine CQRS-Architektur mit getrennten Befehls- und Abfragestapeln in Erwägung ziehen, kreisen Ihre Gedanken um Möglichkeiten, die Stapel einzeln zu optimieren.

Bestimmte Vorgänge werden dann nicht mehr aufgrund von Einschränkungen des Modells riskant, unpraktisch oder schlicht zu teuer. Die Zielvorstellung des Systems wird deutlich stärker aufgabenorientiert. Wichtiger noch, das geschieht ganz natürlich. Sogar einige auf Domänen basierende Entwurfskonzepte, wie etwa Aggregate, sehen auf einmal nicht mehr so ermüdend aus. Sogar sie finden ihren natürlichen Platz im Entwurf. Das ist die Stärke eines vereinfachten Entwurfs.

Wenn Sie jetzt so neugierig auf CQRS sind, dass Sie nach Fallstudien und Anwendungen aus Ihrem Geschäftsbereich suchen, werden Sie möglicherweise feststellen, dass sich die meisten Verweise auf Anwendungsszenarien beziehen, die Ereignisse und Nachrichten verwenden, um Geschäftslogik zu modellieren und implementieren. Während CQRS sich problemlos bei einfacher gestrickten Anwendungen bewährt – die man vielleicht als einfache CRUD-Apps bezeichnen könnte – brilliert es ganz eindeutig in Situationen mit komplexeren Geschäftsvorgängen. Das legt eine größere Vielschichtigkeit der Geschäftsregeln und eine starke Änderungsneigung nahe.

Nachrichtenbasierte Architektur

Beim Blick auf die reale Welt erkennen Sie Aktionen in der Entwicklung und Ereignisse, die sich aus diesen Aktionen ergeben. Aktionen und Ereignisse bringen Daten mit sich und manchmal auch neue Daten hervor, und das ist der entscheidende Punkt. Es sind nur Daten. Sie benötigen nicht unbedingt ein voll ausgestattetes Objektmodell, um das Ausführen dieser Aktionen zu unterstützen. Ein Objektmodell kann dennoch hilfreich sein. Wie Sie in einem Moment verstehen werden, ist das nur eine der möglichen Optionen zum Organisieren von Geschäftslogik.

Eine nachrichtenbasierte Architektur ist vorteilhaft, da sie die Verwaltung komplexer und vielschichtiger Geschäftsworkflows, die häufigen Änderungen unterliegen, stark vereinfacht. Diese Arten von Workflows beinhalten Abhängigkeiten von Legacycode, externen Diensten und sich dynamisch ändernden Regeln. Das Aufbauen einer nachrichtenbasierten Architektur wäre außerhalb des Kontexts von CQRS, das die Befehls- und Abfragestapel säuberlich getrennt hält, aber nahezu unmöglich. Daher können Sie die folgende Architektur für den einzigen Befehlsstapel verwenden.

Eine Nachricht kann entweder ein Befehl oder ein Ereignis sein. Im Code definierten Sie normalerweise eine Basisnachrichtenklasse und definieren im Ausgang von dieser weitere Basisklassen für Befehle und Ereignisse, wie in Abbildung 1 gezeigt.

Abbildung 1 Definieren der Basisnachrichtenklasse

public class Message
{
  public DateTime TimeStamp { get; proteted set; }
  public string SagaId { get; protected set; }
}
public class Command : Message
{
  public string Name { get; protected set; }
}
public class Event : Message
{
  // Any properties that may help retrieving
  // and persisting events.
}

Unter dem Gesichtspunkt der Semantik betrachtet, sind Befehle und Ereignisse leicht unterschiedliche Entitäten, die verschiedenen, aber verwandten Zwecken dienen. Ein Ereignis ist fast das Gleiche wie in Microsoft .NET Framework: eine Klasse, die Daten enthält, und Sie benachrichtigt, wenn etwa vorgefallen ist. Ein Befehl ist eine Aktion, die auf dem Back-End ausgeführt wird, und die von einem Benutzer oder einer anderen Systemkomponente angefordert wurde. Ereignisse und Befehle folgen Standardbenennungskonventionen. Befehle sind imperativ, wie „AuftragSendenBefehl“, während Ereignisse in der Vergangenheit stehen, wie „AuftragErstellt“.

Normalerweise wird durch das Klicken auf beliebige Elemente der Benutzeroberfläche ein Befehl ausgelöst. Sobald das System den Befehl empfängt, generiert es eine Aufgabe. Die Aufgabe kann alles von einem zustandsbehafteten Prozess mit langer Ausführungszeit bis hin zu einer einzelnen Aktion oder einem zustandsfreien Arbeitsablauf sein. Ein gebräuchlicher Name für eine derartige Aufgabe ist Saga.

Eine Aufgabe ist unidirektional, schreitet von der Darstellung den ganzen Weg hinunter durch die Middleware fort und endet mit Wahrscheinlichkeit beim Ändern von System- und Speicherzustand. Befehle geben normalerweise keine Daten an die Darstellung zurück, ausgenommen vielleicht von einem kurzen Feedback, etwa, ob der Vorgang erfolgreich abgeschlossen wurde oder ein Fehler auftrat.

Explizite Benutzeraktionen sind nicht die einzige Möglichkeit, Befehle auszulösen. Sie können einen Befehl auch bei autonomen Diensten platzieren, die asynchron mit dem System interagieren. Stellen Sie sich etwa ein B2B-Szenario vor, etwa den Versand von Produkten, bei dem die Kommunikation zwischen den Partnern über einen HTTP-Dienst erfolgt.

Ereignisse in einer nachrichtenbasierten Architektur

Befehle leiten also Aufgaben ein, und Aufgaben bestehen häufig aus mehreren Schritten, die kombiniert einen Arbeitsablauf ergeben. Bei der Ausführung eines bestimmten Schritts soll häufig eine Ergebnisbenachrichtigung an andere Komponenten übergeben werden, damit sie weitere Arbeiten ausführen können. Die Kette der von einem Befehl ausgelösten Unteraufgaben kann lang und komplex sein. Eine nachrichtenbasierte Architektur ist vorteilhaft, weil sie das Modellieren von Arbeitsabläufen in Form einzelner Aktionen (die durch Befehle ausgelöst werden) und Ereignisse ermöglicht. Durch die Definition von Handlerkomponenten für Befehle und nachfolgende Ereignisse können Sie jeden beliebigen komplexen Geschäftsprozess modellieren.

Wichtiger noch, Sie können einer Arbeitsmetapher folgen, die der eines klassischen Flussdiagramms ähnelt. Dies vereinfacht das Verständnis der Regeln erheblich und optimiert die Kommunikation mit Domänenexperten. Außerdem wird der resultierende Arbeitsablauf in Myriaden kleinerer Handler aufgebrochen, von denen jeder einen kleinen Schritt ausführt. Jeder Schritt setzt ebenfalls asynchrone Befehle ab und benachrichtigt andere Listener über Ereignisse.

Ein wichtiger Vorzug dieses Ansatzes liegt darin, dass die Anwendungslogik leicht zu verändern und zu erweitern ist. Sie erstellen dazu lediglich neue Teile und fügen Sie zum System hinzu, und Sie können das mit der Gewissheit tun, dass sie sich nicht auf den vorhandenen Code und die bestehenden Arbeitsabläufe auswirken werden. Um zu sehen, warum das zutrifft und wie es praktisch funktioniert, gehe ich einige der Implementierungsdetails von nachrichtenbasierter Architektur durch, einschließlich eines neuen Infrastrukturelements – des Busses.

Willkommen beim Bus

Für den Anfang betrachte ich eine selbst gemachte Buskomponente. Die Kernschnittstelle eines Busses ist hier zusammengefasst:

public interface IBus
{
  void Send<T>(T command) where T : Command;
  void RaiseEvent<T>(T theEvent) where T : Event;
  void RegisterSaga<T>() where T : Saga;
  void RegisterHandler<T>();
}

Der Bus ist in der Regel ein Singleton. Er empfängt Anforderungen zum Ausführen von Befehlen und Ereignisbenachrichtigungen. Der Bus verrichtet seinerseits keine konkrete Arbeit. Er wählt lediglich eine registrierte Komponente zum Verarbeiten des Befehls oder Behandeln des Ereignisses aus. Der Bus verfügt über eine Liste bekannter Geschäftsprozesse, die von Befehlen und Ereignissen ausgelöst oder von zusätzlichen Befehlen fortgeführt werden.

Prozesse, die Befehle behandeln, und die zugehörigen Ereignisse werden normalerweise als Sagas bezeichnet. Während der ursprünglichen Konfiguration des Busses registrieren Sie Handler und Sagakomponenten. Ein Handler ist lediglich eine einfachere Form von Saga und stellt einen Einzelschrittvorgang dar. Wenn dieser Vorgang angefordert wird, startet und endet er, ohne mit anderen Ereignissen verkettet zu sein oder andere Befehle auf den Bus zu pushen. Abbildung 2 stellt eine mögliche Implementierung einer Busklasse dar, die Sagas und Handlerverweise im Arbeitsspeicher hält.

Abbildung 2 Beispiel für die Implementierung einer Busklasse

public class InMemoryBus : IBus
{
  private static IDictionary<Type, Type> RegisteredSagas =
    new Dictionary<Type, Type>();
  private static IList<Type> RegisteredHandlers =
    new List<Type>();
  private static IDictionary<string, Saga> RunningSagas =
    new Dictionary<string, Saga>();
  void IBus.RegisterSaga<T>() 
  {
    var sagaType = typeof(T);
    var messageType = sagaType.GetInterfaces()
      .First(i => i.Name.StartsWith(typeof(IStartWith<>).Name))
      .GenericTypeArguments
      .First();
    RegisteredSagas.Add(messageType, sagaType);
  }
  void IBus.Send<T>(T message)
  {
    SendInternal(message);
  }
  void IBus.RegisterHandler<T>()
  {
    RegisteredHandlers.Add(typeof(T));
  }
  void IBus.RaiseEvent<T>(T theEvent) 
  {
    EventStore.Save(theEvent);
    SendInternal(theEvent);
  }
  void SendInternal<T>(T message) where T : Message
  {
    // Step 1: Launch sagas that start with given message
    // Step 2: Deliver message to all already running sagas that
    // match the ID (message contains a saga ID)
    // Step 3: Deliver message to registered handlers
  }
}

Wenn Sie einen Befehl an den Bus senden, durchläuft er einen dreistufigen Prozess. Der Bus überprüft zunächst die Liste der registrierten Sagas, um festzustellen, ob registrierte Sagas für den Start beim Empfang dieser Nachricht konfiguriert sind. In diesem Fall wird eine neue Sagakomponente instanziiert, die Meldung übergeben und zur Liste der ausgeführten Sagas hinzugefügt. Schließlich überprüft der Bus, ob registrierte Handler vorhanden sind, die sich für die Nachricht interessieren.

Ein dem Bus übergebenes Ereignis wird wie ein Befehl behandelt und an registrierte Listener weitergeleitet. Wenn das für das Geschäftsszenario wichtig ist, schreibt es jedoch möglicherweise ein Ereignis in das Protokoll eines Ereignisspeichers. Ein Ereignisspeicher ist ein einfacher Datenspeicher, der nur Anfügen zulässt und alle Ereignisse in einem System nachverfolgt. Die Verwendung protokollierter Ereignisse weist erhebliche Unterschiede auf. Sie können Ereignisse nur zum Zweck der Ablaufverfolgung protokollieren oder dies als einzige Datenquelle verwenden (Event Sourcing). Sie könnten dies sogar verwenden, um den Verlauf einer Datenentität nachzuverfolgen, während Sie zugleich noch klassische Datenbanken zum Speichern des letzten bekannten Entitätszustands verwenden.

Erstellen einer Sagakomponente

Eine Saga ist eine Komponente, die die folgenden Informationen deklariert: einen Befehl oder ein Ereignis, der bzw. das den der Saga zugeordneten Geschäftsprozess startet, die Liste der Befehle, die von der Saga behandelt werden können, und die Liste der Ereignisse, an denen die Saga interessiert ist. Eine Saga-Klasse implementiert Schnittstellen, durch die sie die interessierenden Befehle und Ereignisse deklariert. Schnittstellen wie „IStartWith“ und „ICanHandle“ sind wie folgt definiert:

public interface IStartWith<T> where T : Message
{
  void Handle(T message);
}
public interface ICanHandle<T> where T : Message
{
  void Handle(T message);
}

Hier ist ein Beispiel für die Signatur einer Saga-Beispielklasse:

public class CheckoutSaga : Saga<CheckoutSagaData>,
       IStartWith<StartCheckoutCommand>,
       ICanHandle<PaymentCompletedEvent>,
       ICanHandle<PaymentDeniedEvent>,
       ICanHandle<DeliveryRequestRefusedEvent>,
       ICanHandle<DeliveryRequestApprovedEvent>
{
  ...
}

In diesem Fall stellt die Saga den Auscheckvorgang eines Onlinestores dar. Die Saga startet, wenn der Benutzer auf die Schaltfläche zum Auschecken klickt und die Anwendungsebene den Auscheckbefehl per Push an den Bus übergibt. Der Saga-Konstruktor erzeugt eine eindeutige ID, was erforderlich ist, um parallel ausgeführte Instanzen des gleichen Geschäftsprozesses zu verarbeiten. Sie sollten imstande sein, mehrere parallel ausgeführte Auscheck-Sagas zu verarbeiten. Bei der ID kann es sich um eine GUID, einen eindeutigen Wert, der mit der Befehlsanforderung gesendet wird, oder sogar die Sitzungs-ID handeln.

Für eine Saga besteht die Verarbeitung eines Befehls oder Ereignisses darin, die Handle-Methode der Schnittstelle „ICanHandle“ oder „IStartWith“ aus der Buskomponente heraus aufrufen zu lassen. In der Handle-Methode führt die Saga eine Berechnung oder einen Datenzugriff durch. Anschließend veröffentlicht sie einen weiteren Befehl für andere lauschende Sagas oder löst einfach ein Ereignis als Benachrichtigung aus. Nehmen Sie beispielsweise an, dass der Arbeitsablauf beim Auschecken so ist wie in Abbildung 3 dargestellt.

Arbeitsablauf beim Auschecken
Abbildung 3 Arbeitsablauf beim Auschecken

Die Saga führt alle Schritte bis zum Akzeptieren der Zahlung aus. An diesen Punkt übergibt sie einen Accept­Payment-Befehl als Push an den Bus, mit dem die PaymentSaga fortfahren soll. Die Payment­Saga wird ausgeführt und löst ein PaymentCompleted- oder ein PaymentDenied-Ereignis aus. Diese Ereignisse werden wieder von der CheckoutSaga behandelt. Diese Saga schreitet dann mit einem weiteren Befehl, der für eine weitere Saga platziert wird, die mit dem externen Subsystem im Unternehmen des Versandpartners zusammenwirkt, zum Lieferungsschritt fort.

Die Verkettung von Befehlen und Ereignissen hält die Saga bis zum Erreichen des Abschlusses am Leben. In dieser Hinsicht können Sie eine Saga wie einen klassischen Arbeitsablauf mit Anfangs- und Endpunkt betrachten. Ein weiterer bemerkenswerter Umstand besteht darin, dass Sagas normalerweise dauerhaft (persistent) sind. Persistenz wird in der Regel vom Bus verarbeitet. Die hier vorgestellte Beispielbusklasse unterstützt keine Persistenz. Ein kommerzieller Bus wie NServiceBus oder sogar ein Open Source-Bus wie Rebus würde möglicherweise SQL Server verwenden. Damit Dauerhaftigkeit erreicht werden kann, müssen Sie jeder Saga-Instanz eine eindeutige ID geben.

Zusammenfassung

Damit moderne Anwendungen wirklich effektiv sein können, müssen Sie mit den Geschäftsanforderungen skalieren können. Mit einer nachrichtenbasierten Architektur wird es unglaublich einfach, Geschäftsworkflows zu erweitern und zu ändern und neue Szenarien zu unterstützen. Sie können Erweiterungen vollständig isoliert verwalten – alles, was erforderlich ist, ist eine neue Saga oder einen neuen Handler hinzuzufügen, sie bzw. ihn beim Start der Anwendung beim Bus zu registrieren und ihr bzw. ihm mitzuteilen, wie er bzw. sie nur die für ihn bzw. sie bestimmten Nachrichten verarbeitet. Die neue Komponente wird automatisch nur bei Bedarf aufgerufen und arbeitet Seite an Seite mit dem restlichen System. Es geht leicht, ist einfach und effektiv.


Dino Esposito ist Mitverfasser von „Microsoft .NET: Architecting Applications for the Enterprise“ (Microsoft Press, 2014) und „Programming ASP.NET MVC 5“ (Microsoft Press, 2014). Esposito ist Technical Evangelist für die Microsoft .NET Framework- 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.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Jon Arne Saeteras