Trennungsfreuden

Wartbares Design dank CQRS
Kommentare

Getter lesen, Setter schreiben, das weiß doch jedes Kind. Aber was passiert eigentlich, wenn man diese Idee auf eine ganze Anwendung beziehungsweise Architektur anwendet? Wer Buzzwords mag, spricht dann von CQRS. Wir lassen die Buzzwords weg und sehen uns an, was es durch eine klare Trennung von Lese- und Schreibzugriffen zu gewinnen gibt.

Mit den Daten in unseren Applikationen ist es wie mit Quellcode: Es wird häufiger gelesen als geschrieben. Während bei klassischen Enterprise-Anwendungen oft tausende Lesezugriffe auf einen Schreibzugriff kommen (denken Sie an den Kantinenplan, das Herz und die Seele vieler Corporate Intranets), liegt bei aktuellen Webanwendungen das Verhältnis von Lese- zu Schreibzugriffen typischerweise zwischen 7 zu 1 und 10 zu 1.

Der Begriff „Separation of Concerns“, also die Trennung unterschiedlicher Belange, geht auf den Informatikpionier Edsger Dijkstra zurück. Dieser schrieb 1974 in einem Paper: „But nothing is gained – on the contrary! – by tackling these various aspects simultaneously. It is what I sometimes have called ‘the separation of conerns’, which, even if not perfectly possible, is yet the only available technique for effective ordering of one’s thoughts, that I know of.“.

Über die Jahre hat sich „Separation of Concerns“ zu einer zentralen „goldenen Regel“ beim Programmieren entwickelt. Man kann viele Fehler im Entwurf oder bei der Programmierung damit erklären, dass unterschiedliche Belange nicht sauber voneinander getrennt wurden. „Single Responsibility“, von Robert C. Martin als das „S“ im Akronym SOLID bekannt gemacht, ist übrigens eine Ausprägung von „Separation of Concerns“.

Bertrand Meyer, der Vater der Programmiersprache Eiffel, schrieb in seinem 1988 erschienenen Buch „Object-oriented Software Construction“ ausführlich darüber, dass Methoden entweder Auskunft über den Zustand eines Objekts geben sollen (Getter), oder den Zustand eines Objekts ändern (Setter oder auch Mutatoren genannt). Er schrieb: „Asking a question should not change the answer“ und meinte damit, dass ein Lesezugriff nicht den Zustand eines Objekts ändern darf.

Quell der Inspiration

Ein kleiner Hinweis in eigener Sache: Dieser Artikel diente unserem Zend-Framework-Autor Ralf Eggert als Quell der Inspiration – und so lesen Sie im PHP Magazin 6.2014, wie man CQRS mit dem Zend Framework umsetzen kann.

Einer kann lesen, einer kann schreiben

Das mag heute wenig überraschend klingen, denn bewusst oder unbewusst halten sich die meisten Programmierer heute an diese Regel. Es gibt einige Ausnahmen wie etwa Operationen, die unteilbar sein sollen (get and increment) oder die Abfrage der letzten aufgetretenen Fehlermeldung, die typischerweise den „Fehlerspeicher“ löscht und damit den Zustand des Objekts ändert. Wenn aber die Idee der Trennung unterschiedlicher Belange, also der Trennung von Lese- und Schreibzugriffen auf der Ebene einzelner Objekte eine so gute (und zentrale) Idee ist, warum wenden wir sie dann nicht auch auf ganze Applikationen an?

Sehen wir uns einmal ein einfachen Beispiel an: das API eines einfachen Bestellservice. Einige Methoden sind im Listing 1 skizziert.

interface OrderService 
{ 
  public function findOrderById($orderId); 
  public function findOrdersByUserId($userId); 
  public function placeOrder($userId, array $items); 
  public function cancelOrder($orderId); 
  // ...
} 

Diese Schnittstelle enthält sowohl lesende als auch schreibende Methoden. Folgen wir dem Prinzip „Command Query Separation“ (CQS) und trennen die lesenden und die schreibenden Methoden auf, dann erhalten wir zwei Schnittstellen (Listing 2).

interface OrderWriteService 
{ 
  public function placeOrder($userId, array $items); 
  public function cancelOrder($orderId); 
  // ... 
}

interface OrderReadService 
{ 
  public function findOrderById($orderId); 
  public function findOrdersByUserId($userId); 
  // ... 
}

Wir wollen hier nicht darauf eingehen, wie man dieses API noch besser machen könnte, etwa durch die Verwendung von Wertobjekten für die einzelnen Identifikatoren. Die Methoden dienen schließlich nur als Beispiel.

Man wird sich nun fragen, was die Trennung von lesenden und schreibenden Methoden hier nun wirklich bringt. Im ersten Schritt könnte man sich schon einmal über einen Sicherheitsgewinn freuen, denn für den lesenden Zugriff braucht die Applikation nun keine Schreibrechte mehr. Das bedeutet weniger Angriffsfläche etwa für SQL Injections, über die zwar an dieser Stelle noch immer unberechtigt Daten gelesen, diese aber nicht mehr verändert werden können.

Da Sicherheit aber (leider) nur selten ein Verkaufsargument ist, blicken wir noch ein wenig weiter hinter die Kulissen unserer fiktiven Applikation. Wir stellen uns vor, dass die Bestellung durch ein Domänenobjekt repräsentiert wird (Listing 3).

class Order 
{ 
  // ...

  public function __construct($userId) 
  { 
    // ... 
  } 

  public function addItem(OrderItem $orderItem) 
  { 
    // ... 
  } 

  public function getTotal() 
  { 
    // ... 
  } 

  public function finalize() 
  { 
    // ... 
  } 

  public function cancel() 
  { 
    // ... 
  } 

  // ...
} 

Die Persistenz für eine Bestellung verbirgt sich hinter einer Repository-Fassade (Listing 4).

class OrderRepository 
{ 
  // ...

  public function addOrder(Order $order) 
  { 
    // ... prepare to persist this order ... 
  } 

  public function findOrderById($orderId) 
  { 
    $order = new Order($orderId); 
    // ... retrieve data and hydrate order ... 
    return $order; 
  } 
  
  public function findOrdersByUserId($userId) 
  { 
    $collection = new OrderCollection(); 
    // ... retrieve orders and add to collection ... 
    return $collection; 
  }

  // ... 
}

Wir können also aus dem Repository eine einzelne Bestellung oder eine Sammlung (Collection) von Bestellungen laden. Diese Collection ist, wenn man so will, ein „aufgebohrtes“ Array von Order-Objekten. Um eine neue Bestellung dauerhaft zu speichern, wird sie einfach durch Aufruf von addOrder() an das Repository übergeben. Wir stellen uns vor, dass die Persistenzinfrastruktur dann dafür sorgt, dass die Order dauerhaft gespeichert wird. Eine Implementierung der OrderWriteService-Schnittstelle könnte aussehen wie in Listing 5.

class OrderWriteServiceImplementation implements OrderWriteService
{ 
  public function placeOrder($userId, array $items) 
  { 
    $order = new Order($userId); 
    foreach ($items as $item) { 
      $orderItem = new OrderItem; 
      // ... populate order item ... 
      $order->addItem($orderItem); 
    } 
    $this->orderRepository->addOrder($order); 
  } 

  public function cancelOrder($orderId) 
  { 
    $order = $this->orderRepository->findOrderById($orderId); 
    $order->cancel(); 
  } 
}

Beim Erzeugen einer Order in der Methode placeOrder() wird zunächst eine Bestellung erzeugt, diese mit den übergebenen Einzelposten befüllt und dann an das Repository übergeben, damit die Bestellung (später) persistiert wird. Wie und wann genau dies geschieht, ist an dieser Stelle unerheblich, schließlich ist es die Aufgabe einer Fassade, ein komplexes Subsystem zu verbergen.

Wenn eine Bestellung storniert werden soll, dann muss zunächst das Geschäftsobjekt, das diese Bestellung repräsentiert, aus dem Repository geladen werden. Dann wird einfach die cancel()-Methode auf diesem Objekt aufgerufen. Wir ignorieren hier die Tatsache, dass der gezeigte Code keinerlei Fehlerprüfungen enthält ebenso wie etwa das Fehlen von zusätzlichen Parametern, anhand derer protokolliert werden kann, wann welcher Benutzer eine Bestellung abgegeben beziehungsweise storniert hat.

Code wie in diesen Beispielen findet man in ähnlicher Form in vielen moderneren PHP-Anwendungen, oftmals basierend auf einem Framework wie Symfony2 und unter Einsatz einer ORM-Lösung wie Doctrine2. Für die schreibenden Zugriffe ist dies in der vorliegenden Form auch durchaus sinnvoll. Aber ist es auch sinnvoll, das gleiche Modell für die lesenden Zugriffe zu verwenden? Nur weil die lesenden und die schreibenden Zugriffe die gleichen Daten benutzen, bedeutet das nicht, dass sie auch das gleiche Modell benutzen müssen.

Überlegen wir uns einmal, was bei einem Lesezugriff alles passieren muss, wenn wir die obigen Strukturen verwenden wollen: Zunächst muss das „richtige“ Geschäftsobjekt aus einem passenden Repository geladen werden. Dazu wird auf eine Datenquelle zugegriffen, entweder eine relationale Datenbank oder auch eine Dokumentendatenbank. Das ORM beziehungsweise ODM lädt die Rohdaten und erzeugt daraus PHP-Objekte (wir gehen von der nicht unrealistischen Annahme aus, dass die Bestellung ein Aggregatobjekt ist). Wenn wir Glück haben, dann befinden sich alle Informationen, die wir für die Darstellung brauchen, innerhalb des Order-Aggregats. Ist dies nicht der Fall, müssen wir weitere Geschäftsobjekte laden und wiederholen das ganze Procedere.

Nun müssen wir aus dem beziehungsweise den Geschäftsobjekten den Zustand abfragen, um diese durch unseren Viewcode als HTML rendern zu lassen, oder für AJAX Requests als JSON oder für einen anderen API-Request vielleicht als XML. Dabei ist zu beachten, dass die View in unserem Geschäftsobjekt keine schreibenden Methoden aufruft, beziehungsweise dass das CQS-Prinzip auch wirklich sauber umgesetzt ist und keine der lesenden Methoden den Zustand eines Geschäftsobjekts ändert. Das kann man durch den Einsatz von Read-only Proxies erzielen, dies ist allerdings nicht so ganz einfach umzusetzen, wenn man es mit einem Aggregatobjekt zu tun hat. Es zeigt sich also schon hier, dass es nicht die beste Idee ist, Domänenobjekte an eine View zu übergeben.

Zu viel Arbeit beim Lesen

Falls die Daten ursprünglich in einer Dokumentendatenbank wie MongoDB lagen, dann haben wir das dort gespeicherte JSON-ähnliche Format nun etwa für einen AJAX Request zuerst in einen PHP-Objektgraphen konvertiert, um diesen dann wieder nach JSON zu serialisieren. Für eine Abfrage aus einer relationalen Datenbank wurde dynamisch ein SQL-Statement zusammengebaut, das von der Datenbank geparsed und ausgeführt wurde. Das Ergebnis dieser Abfrage wurde wiederum in einen PHP-Objektgraphen umgewandelt, der beispielsweise nach XML serialisiert wird, um dann mittels XSLT als HTML ausgegeben zu werden. Das klingt alles nach viel Aufwand. Braucht es das wirklich?

Folgen wir dem Architekturprinzip „Command Query Responsibility Segregation“, abgekürzt CQRS, dann ist die Antwort ein klares Nein. Neben der Trennung von Lese- und Schreibzugriffen werden die Daten unabhängig nicht nur in den Datenspeicher, der von der schreibenden Seite verwendet wird, sondern – in einer jeweils in für die Lesezugriffe optimierten Darstellung – redundant auch in einem oder mehreren anderen Datenspeichern vorgehalten. Das ist kein Caching im eigentlichen Sinne, denn ein Cache garantiert nicht, dass Daten tatsächlich darin vorhanden sind.

CQRS fordert, dass alle Lesezugriffe auf eine eigene, dafür optimierte Repräsentation der Daten erfolgen. Hierzu wird kein Domänenobjekt, also auch kein ORM benötigt. Genau genommen werden dazu noch nicht einmal PHP-Objekte benötigt (Daten ohne Verhalten rechtfertigen ja im Normalfall auch kein Objekt). Stattdessen könnten etwa JSON- oder XML-Daten direkt aus der Datenquelle an den Client durchgereicht werden – gegebenenfalls nach einer Prüfung, ob der jeweilige Benutzer diese Daten auch tatsächlich sehen darf.

Die für den lesenden Zugriff aufbereiteten Daten, die so genannten Projektionen können je nach Anforderungen in einem Key-Value Store wie Redis, einer Dokumentendatenbank wie MongoDB oder CouchDB, einer Suchmaschine wie Solr oder Elasticsearch, oder auch einer Graphdatenbank wie Neo4j vorgehalten werden. Es ist nicht realistisch, komplett unterschiedliche Anforderungen wie Suche, Reporting und die Verarbeitung von Transaktionen in ein einziges Modell abzubilden. Warum sollte man also nicht davon profitieren, dass es verschiedene Arten von Datenspeichern gibt, die auf bestimmte Anwendungsfälle optimiert sind?

Aufbereiten im eigenen Request

Eine klassische Architektur würde ihre Leseprojektionen im lesenden Request erzeugen. Das ist eine schlechte Idee, denn wie oben ausgeführt wurde, gibt es davon in etwa eine Größenordnung mehr als schreibende Requests. Und warum sollte man die gleichen Daten erneut und mühsam zu einer Ansicht zusammensetzen, wenn sich gar nichts verändert hat?

Dies würde den Schluss nahelegen, die Leseprojektionen im schreibenden Request zu erzeugen. In einfachen Fällen kann man dies tun, für eine bessere Skalierbarkeit und höhere Performance der schreibenden Requests sollte man dort allerdings lediglich ein Event erzeugen. Dieses wird asynchron in einem separaten Hintergrundprozess verarbeitet und dabei werden die Leseprojektionen erzeugt. Die unterschiedlichen Modelle für den Lese- und Schreibzugriff können somit nicht nur in separaten Prozessen gerechnet werden, sondern diese Berechnung kann sogar auf eigene Hardware ausgelagert werden. Wir haben also eine extrem gut skalierbare Architektur geschaffen, die aus Benutzersicht auch eine sehr gute Performance aufweisen wird: Auch die Schreib-Requests sind schnell verarbeitet, da man auf das Erzeugen der Ansichten nicht warten muss.

Die Sache hat allerdings (scheinbar) einen Haken: bedeutet das asynchrone Erzeugen der Projektionen nicht, dass Änderungen auf der schreibenden Seite nicht „sofort“ auf der lesenden Seite sichtbar werden? Ja, das ist so. Alle verteilten Systeme, also Systeme aus mehr als drei Rechnern beziehungsweise Komponenten, tun sich schwer, über das Gesamtsystem eine transaktionale Konsistenz zu garantieren. Das Konsistenzproblem ist also nicht wirklich neu, sondern inhärent in jeder Webapplikation: Wenn ein Benutzer Daten abruft und ein anderer Benutzer diese danach ändert, ist der Datenbestand aus Benutzersicht streng genommen inkonsistent, da der erste Benutzer bereits veraltete Daten sieht. Auch eine CQRS-Architektur ist in diesem Sinne eventually consistent.

Kommandos senden

Die schreibenden Zugriffe auf die Applikation sind die so genannten Kommandos (Commands). Ein Command ist im Prinzip lediglich ein Parameterobjekt, das vom Client erzeugt und zum Server gesendet wird. Es empfiehlt sich, die Commands vom HTTP-Protokoll zu abstrahieren, was relativ einfach ist (Listing 6).

interface LoginCommand 
{ 
  public function getUsername(); 
  public function getPasswordHash(); 
} 

class HttpLoginCommand implements LoginCommand 
{ 
  private $httpRequest; 

  public function __construct(HttpRequest $httpRequest) 
  { 
    $this->httpRequest = $httpRequest; 
  } 

  public function getUsername() 
  { 
    return $this->httpRequest->getParameter('username'); 
  } 

  public function getPasswordHash() 
  { 
    return $this->httpRequest->getParameter('passwordHash'); 
  } 
}

Anstelle eines Mappers, der aus einem HTTP-Request ein bestimmtes Command erzeugt, definieren wir ein Interface für jedes Command und lassen dieses „on demand“ die Daten direkt selbst aus dem HTTP-Request auslesen. Fehlen benötigte Daten, wirft die Methode getParameter() in httpRequest eine Exception.

Komplexe Kommandos lassen sich einfach durch ein CompositeCommand realisieren, das aus verschiedenen Commands „zusammengesetzt“ wird (Listing 7).

class CompositeCommand
{ 
  // ...
 
  public function __construct(
    LoginCommand $loginCommand,
    ConfirmEmailCommand $confirmEmailCommand) 
  {
    // ...
  } 

  public function getUsername() 
  { 
    return $this->loginCommand->getUsername(); 
  } 

  public function getEmail()
  {
    return $this->confirmEmailCommand->getEmail();
  }

  // ...
}

Die Entkopplung von HTTP macht es nicht nur unerheblich, welcher Client ein Command erzeugt, sondern löst auch das Problem, dass HTTP-Request-Objekte ein implizites API haben. Commands dagegen haben eine explizite Schnittstelle, was gut nachvollziehbar macht, welche Parameter bei der Verarbeitung auch tatsächlich benötigt werden. Ein Command Handler könnte aussehen wie in Listing 8.

class CreateOrderCommandHandler
{ 
  public function __construct(OrderWriteService $orderWriteService)
  {
    // ...
  } 

  public function execute(CreateOrderCommand $createOrderCommand)
  { 
    $userId = $createOrderCommand->getUserID();
    // ... retrieve items from command ...
    $this->orderWriteService->placeOrder($userId, $items);
  }
}

Ein solcher Command Handler würde in einer MVC-Architektur vermutlich dem Controller entsprechen. Seine Aufgabe im Rahmen der Command-Verarbeitung ist es, einen korrekt parametrisierten Aufruf der eigentlichen Geschäftslogik durchzuführen. Man könnte auch auf die Idee kommen, den Code aus der Methode placeOrder() der OrderWriteService-Implementation direkt in den Handler zu schreiben. Ich persönlich ziehe es vor, die gesamte Schnittstelle der Applikation auch tatsächlich als PHP-Code vorliegen zu haben. Dadurch, dass die OrderReadService– beziehungsweise OrderWriteService-Schnittstelle implementiert werden muss, ist garantiert, dass unsere Applikation die dort geforderten Methoden mit den korrekten Parametersignaturen unterstützt. In Verbindung mit den expliziten Schnittstellen der Kommandos und sinnvoll definierten Wertobjekten, die wichtige Domänenkonzepte repräsentieren, bietet die Applikation trotz des dynamischen Typkonzepts von PHP schon fast alle Vorteile einer stark typisierten Sprache.

GET zum Lesen, POST zum Schreiben

Interessanterweise passt die CQRS-Idee sozusagen „by design“ sehr gut zum Web. Die originale HTTP-Spezifikation sieht vor, dass GET Requests nur Informationen abfragen und nicht den Zustand des Servers ändern. POST Requests dagegen ändern den Zustand des Servers. Das passt wunderbar zu CQRS.

Das Gegenstück zu den Commands sind die Queries. Per Definition verändert eine Query nicht den Zustand der Applikation. Da nur Daten abgefragt werden, müssen auch keine Geschäftsregeln durchgesetzt werden. Warum also ein Geschäftsobjekt laden, das Daten und Verhalten kapselt, wenn man das Verhalten an dieser Stelle gar nicht braucht?

Auf der schreibenden Seite dagegen ist man genau am Verhalten der Geschäftsobjekte interessiert, ignoriert aber geflissentlich jegliche Präsentationsfragen. Die Geschäftsobjekte brauchen daher weniger Getter, da niemand ihren Zustand abfragen muss, um Ansichten beziehungsweise Projektionen zu erzeugen.

Die Idee, Ansichten in eigene Datenspeicher zu legen, macht relationale Datenbanken nicht überflüssig. Diese können als „kanonische“ Datenbanken („single source of truth“) durchaus gute Dienste leisten, indem sie die Datenintegrität sicherstellen. Normale Web-Requests allerdings brauchen keine relationale Datenbank, da diese nur auf die Leseprojektionen der Daten zugreifen.

Nochmals: Es handelt sich hier nicht um Caching. Während beim Caching im Pull-Prinzip benötigte Daten aus den Backends geholt werden, werden hier die Daten im Push-Prinzip von den Backends nach vorne geschoben.

Im Einklang mit den REST-Prinzipien sollte eine Webanwendung daher Änderungen des Applikationszustands nur mittels POST Requests veranlassen. Das bedeutet: Commands werden durch POST Requests erzeugt beziehungsweise an die Applikation übertragen, und Queries durch GET Requests.

Man kann in Webapplikationen eine strikte Trennung von lesenden und schreibenden Zugriffen realisieren, indem die Antwort auf einen POST Request immer ein Redirect ist. Ruft ein Client eine HTML-Seite mit GET ab, die ein Formular enthält, sollte dessen POST-Ziel ein eigener URL sein und nicht der URL der gerade angezeigten Seite. Beim Abruf einer Seite speichert die Applikation den aktuellen URL in der Session. Nach Verarbeitung des POST Requests wird der URL aus der Session gelesen und eine HTML-Seite erzeugt, die den Client zu eben diesem URL redirected. Falls der Client abhängig vom Ergebnis der POST-Verarbeitung zu einem anderen URL umgeleitet werden soll, wird der in der Session gespeicherte URL einfach ignoriert.

Eine klare Trennung zwischen Aktion (Command) und Anzeige (Query) lässt sich auf diese Weise sehr gut umsetzen, wenn man denn unbedingt möchte, sogar innerhalb eines MVC-Frameworks.

Fazit

Die fehlende Trennung unterschiedlicher Belange führt in klassischen Architekturen oft dazu, dass sich Software nicht vorhersagbar verhält, weil durch Lesezugriffe Seiteneffekte ausgelöst werden beziehungsweise der Zustand der Applikation verändert wird. Durch CQRS-Ideen, die sich übrigens mit vertretbarem Aufwand auch schrittweise in existierende Software und Architekturen einbringen lassen, wird das Verhalten der Applikation deutlich besser vorhersagbar.

CQRS ist aber keine Universallösung für jedes Problem. Ob einfache CRUD-Anwendung und Anwendungen ohne signifikante Geschäftslogik von CQRS profitieren können, muss im Einzelfall beurteilt werden.

Je stärker sich Objektmodell und Bedienoberfläche unterscheiden, desto mehr profitiert man von CQRS. Ich sehe in der Beratungspraxis oft relativ problematischen Code, der offensichtlich anhand von Screendesigns erstellt wurde. Es ist keine gute Idee, Domänenobjekte anhand von Screendesigns zu erstellen. Auf der anderen Seite sollte man beim Design der Bedienoberfläche auch nicht gezwungen sein, sich an den Geschäftsobjekten zu orientieren. Betrachtet man diese beiden Aspekte voneinander unabhängig, dann resultiert daraus ein deutlich besser wartbares Design. CQRS ist gewissermaßen das Werkzeug, das dies möglich macht. Separation of Concerns eben.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -