thePHP.cc Logo English Kontakt
Besseres Design dank CQRS

Besseres Design dank CQRS

Getter lesen, Setter schreiben, das weiß doch jedes Kind. Aber was passiert eigentlich, wenn man diese Idee auf eine ganze Anwendung beziehungsweise deren Architektur anwendet? Wir 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 -- die Trennung unterschiedlicher Belange -- 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. Das Single Responsibility-Prinzip, 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.

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 als einfaches Beispiel die Schnittstelle (API) eines Bestell-Service an. Einige Methoden sind hier skizziert:

interface   OrderService  
{  
     public   function   findOrderById ( OrderId   $orderId ) :   Order ;  
 
     public   function   findOrdersByUserId ( UserId   $userId ) :   Orders ;  
 
     public   function   placeOrder (
         UserId   $userId ,
         array   $items
     ) :   void ;  
 
     public   function   cancelOrder ( OrderId   $orderId ) :   void ;  
 
     // ...
}

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, so erhalten wir zwei Schnittstellen:

interface   OrderWriteService  
{  
     public   function   placeOrder (
         UserId   $userId ,  
         array   $items
     ) :   void ;
 
     public   function   cancelOrder ( OrderId   $orderId ) :   void ;  
 
     // ... 
}
interface   OrderReadService  
{  
     public   function   findOrderById ( OrderId   $orderId ) :   Order ;  
 
     public   function   findOrdersByUserId ( UserId   $userId ) :   Orders ;  
 
     // ... 
}

Wir wollen hier nicht darauf eingehen, ob und wie man diese Schnittstellen noch besser machen könnte, 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 das folgende Domänenobjekt repräsentiert wird:

class   Order  
{  
     // ...
 
     public   function   __construct ( $userId )  
     {  
         // ... 
     }  
 
     public   function   addItem ( OrderItem   $orderItem ) :   void
     {  
         // ... 
     }  
 
     public   function   getTotal ( ) :   Money
     {  
         // ... 
     }  
 
     public   function   finalize ( ) :   void
     {  
         // ... 
     }  
 
     public   function   cancel ( ) :   void
     {  
         // ... 
     }  
 
     // ...
}

Die Persistenz für eine Bestellung verbirgt sich hinter einer Repository-Fassade:

class   OrderRepository  
{  
     // ...
 
     public   function   addOrder ( Order   $order ) :   void  
     {  
         // ... prepare to persist this order ... 
     }  
 
     public   function   findOrderById ( $orderId ) :   Order  
     {  
         $order   =   new   Order ( $orderId ) ;  
 
         // ... retrieve data and hydrate order ... 
 
         return   $order ;  
     }  
 
     public   function   findOrdersByUserId ( $userId ) :   Orders  
     {  
         $collection   =   new   Orders ( ) ;  
 
         // ... 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 Persistenz-Infrastruktur dann dafür sorgt, dass die Order dauerhaft gespeichert wird. Eine Implementierung der OrderWriteService-Schnittstelle könnte wie folgt aussehen:

class   OrderWriteServiceImplementation   implements   OrderWriteService
{  
     public   function   placeOrder (
         UserId   $userId ,  
         array   $items
     ) :   void  
     {  
         $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 Symfony und unter Einsatz einer ORM-Lösung wie Doctrine. Für die schreibenden Zugriffe mag dies in der vorliegenden Form auch durchaus sinnvoll sein. 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 zugehörige 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 Aggregate 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 Präsentations-Code 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 Aggregat zu tun hat. Es zeigt sich also schon hier, dass es nicht unbedingt 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 sogenannten 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 Graph-Datenbank 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?

Wann Projektionen aktualisieren?

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 mindestens 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. Dieses Problem mit der Konsistenz ist 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 sogenannten 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:

interface   LoginCommand  
{  
     public   function   getUsername ( ) :   string ;  
 
     public   function   getPasswordHash ( ) :   PasswordHash ;  
}  
 
class   HttpLoginCommand   implements   LoginCommand  
{  
     private   HttpRequest   $httpRequest ;  
 
     public   function   __construct ( HttpRequest   $httpRequest )  
     {  
         $this -> httpRequest   =   $httpRequest ;  
     }  
 
     public   function   getUsername ( )  
     {  
         return   $this -> httpRequest -> getParameter ( 'username' ) ;  
     }  
 
     public   function   getPasswordHash ( )  
     {  
         return   new   PasswordHash (
             $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 Kommandos zusammengesetzt wird:

class   CompositeCommand
{  
     // ...
 
     public   function   __construct (
         LoginCommand   $loginCommand ,
         ConfirmEmailCommand   $confirmEmailCommand
     )  
     {
         // ...
     }  
 
     public   function   getUsername ( ) :   string  
     {  
         return   $this -> loginCommand -> getUsername ( ) ;  
     }  
 
     public   function   getEmail ( ) :   Email
     {
         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 wie folgt aussehen:

class   CreateOrderCommandHandler
{  
     public   function   __construct (
         OrderWriteService   $orderWriteService
     )
     {
         // ...
     }  
 
     public   function   run (
         CreateOrderCommand   $createOrderCommand
     ) :   void
     {  
         $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 aber 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äsentations-Aspekte. 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 eine eigene URL sein und nicht die URL der gerade angezeigten Seite. Beim Abruf einer Seite speichert die Applikation der 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 mit einem Redirect zu eben dieser URL umleitet. Falls der Client abhängig vom Ergebnis der POST-Verarbeitung zu einer 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.