Problembeschreibung
Bisweilen sind Objekte von einander abhängig: Teilt man dem Benutzerobjekt mit, dass der Benutzer jetzt Administrator ist, muss man dies auch dem Rechteobjekt mitteilen. Schließlich will die neue Benutzerrolle auch mit den passenden Privilegien versehen werden. Diese simple Abhängigkeit ist noch einfach zu merken und es fällt dem Programmierer sicherlich nicht schwer, sie bei jeder Änderung der Benutzerrolle zu berücksichtigen. Was aber, wenn die Abhängigkeiten sehr viel umfangreicher und komplexer werden? Nach dem Erteilen des Administratorstatus soll das Sicherungsobjekt vielleicht die neue Benutzerrolle in der Datenbank speichern, was wiederum bedeuten würde, dass das Wiederherstellungsobjekt seine Liste der verfügbaren Sicherungsstände aktualisieren müsste. Ganz zu schweigen von der Notwendigkeit, dass das für die Bildschirmdarstellung zuständige Objekt die neuen Sachverhältnisse auch anzeigen muss.
Dieses Beispiel verdeutlicht vielleicht, dass eine vermeintlich kleine Änderung des Zustands eines Objektes einen Rattenschwanz an bedingten Änderungen an anderen Objekten nach sich ziehen kann. Wohl dem Programmierer, der sich vor der Implementierung der Applikation einen Bauplan in Form von Strukturdiagrammen angelegt hat. Diese können ihm helfen, wenigstens halbwegs den Überblick zu behalten, wo welche Änderung weitere Änderungen bedürfen. Allerdings schützen auch Strukturdiagramme nicht davor, diese falsch zu interpretieren oder die eine oder andere Änderung zu vergessen. Die Konsequenz: Auf einen Schlag ist die Konsistenz der gesamten Applikation zunichte gemacht. Ein vermeintlich kleiner Fehler, eine Diskrepanz zwischen Benutzerrolle im Userobjekt und den Rechten im Rechteobjekt etwa, durch die schlichte Tatsache zustande gekommen, dass zwar das Userobjekt, nicht aber das Rechteobjekt aktualisiert wurden, kann fatale Folgen haben. Ein solcher Fehler ist schwer zu finden und stellt einen erheblichen Zeitaufwand beim Debugging dar. Spätestens hier wächst der Wunsch nach einer Lösung, die bedingte Änderungen selbständig durchführt und somit hilft, Inkonsistenzen zwischen den Objekten zu vermeiden.
Problemlösung
Der Ansatz, den das Beobachter-Entwurfsmuster (eng.: observer pattern) beschreibt, definiert Abhängigkeiten zwischen Objekten in einem publisher/subscriber genannten Verfahren: Objekte, welche von bestimmten Objekten abhängig sind, „abonnieren“ diese und werden von ihnen fortan über Änderungen des abonnierten Objektes unterrichtet.
Damit zählt das Beobachter-Muster zu der Klasse der objektorientierten Verhaltensmustern indem es Abhängigkeiten zwischen Objekten abbilden kann.
Dies geschieht wie folgt: Zu einem bestimmten Zeitpunkt erbittet der subscriber ein Abonnement beim publisher. Dies geschieht am sinnvollsten bereits im Konstruktor des subscribers. Hierzu muss der subscriber dem publisher eine callback Schnittstelle anbieten, über die der publisher ihn erreichen kann. Der publisher wiederum führt alle seine Abonnenten in einer Abonnentenliste. Ändert sich nun der Zustand des publishers so, dass sich dies auf seine äußere Repräsentation niederschlägt, so informiert er alle in der Liste enthaltenen Abonnenten über deren einheitliche Schnittstelle.
Das Schlagwort der äußeren Repräsentation ist schnell erklärt: Zustandsänderungen können entweder private Attribute betreffen, die nach außen hin nicht sichtbar sind und auch keine Rückgabewerte von öffentlichen Methoden verändern, oder sie können öffentliche Attribute und Rückgabewerte von öffentlichen Methoden ändern. Zustandsänderungen der zweiten Art verändern somit die äußere Repräsentation der Objekte; In der Regel sind nur sie interessant für Abonnenten.
Es obliegt nun dem subscriber auf die vom publisher mitgeteilte Änderung zu bearbeiten. Hierzu muss der subscriber ermitteln, ob die Änderungen des publishers für ihn relevant sind und entsprechend reagieren: Entweder die Änderung ist relevant und der subscriber fragt benötigte Attribute des publishers ab, oder er ignoriert die Mitteilung. In einer anderen Variante dieses Enturfsmusters sendet der publisher Informationen über die erfolgten Änderungen gleich mit (siehe hierzu den Abschnitt „Push or pull?“).
Ein so bestehendes publisher/subscriber Verhältnis lässt sich natürlich jederzeit (auch ohne Beachtung von Fristen und Vertragslaufzeiten) kündigen; Der publisher streicht den subscriber auf dessen Anfrage hin einfach aus seiner Abonnentenliste. Der subscriber erhält darauf hin keine Mitteilungen mehr vom publisher.
Das ganze Verfahren ist so bildlich, dass es quasi selbsterklärend ist. Aus diesem Grunde wird dieses Mal auf ein Beispiel aus dem wirklichen Leben verzichtet.
Implementierung
Nachdem im Juli der final release von PHP5 veröffentlicht wurde, macht dieses Beispiel Gebrauch von dessen neuen objektorientierten Features und Sprachkonstrukten. Gerade die Einführung von Interfaces erweist sich als hilfreich: Da das Beobachter-Muster unabhängig von den Klassentypen der involvierten Objekte funktionieren sollte, unterstützen Interfaces den Gedanken der losen Kopplung von Objekten: Die Objekte sind konsequent austauschbar solange deren (teilweise) durch Interfaces definierte API gleich bleibt. Aber auch an Leser, die aus unterschiedlichsten Gründen auf PHP4 festgelegt sind, wurde gedacht: Die Box „Das Beobachter-Muster in PHP4“ gibt einige Anregungen zur Portierung des vorliegenden Quellcodes nach PHP4.
Die Vorüberlegung über die Gestaltung der API schlägt sich in der Implementierung von Interfaces nieder: Diese bestimmen alle Methoden und deren Signaturen, über welche die Kommunikation zwischen den am subscriber/publisher Verfahren beteiligten Objekten später abläuft (Listing).
Das publisher Interface definiert die API zum Abonnieren bzw. zum Kündigen von Änderungsmitteilungen. Class-type hints erlauben hierbei gleich die Voraussetzung zu definieren, dass der im Funktionskopf übergebene subscriber das subscriber Interface implementiert haben muss. Auch wenn der Name „class-type hint“ etwas anderes suggeriert: Es können nicht nur Klassentypen sondern auch implementierte Interfaces verlangt werden. Das subscriber Interface wiederum definiert die callback Methode, an die der publisher seine Mitteilungen senden kann. Da hier das Pull-Verfahren Anwendung findet, existiert kein Parameter, der erweiterte Angaben über die am publisher gemachten Änderungen machen könnte.
Das dritte Interface, provideRole, ist vom Prinzip her nicht unbedingt erforderlich für dieses Beispiel, demonstriert aber erneut den Grundgedanken der losen Kopplung zwischen subscriber und publisher: es verhindert, dass der subscriber den Klassentyp des publishers kennen muss. Solange die Bezeichnung und Signatur der zuständigen Methode gleich bleibt, ist der Klassentyp austauschbar.
Als publisher zeigt das Listing mit der Klasse user eine stark vereinfachte Klasse zur Verwaltung eines Users. Diese implementiert die Interfaces publisher und getRole. Mit Hilfe der Methoden user::setRole() und user::getRole() bietet sie lediglich die Möglichkeit, eine Benutzerrolle in Form einer Integer-Zahl zu setzen und auch wieder abzufragen. Alle darüber hinausgehenden Methoden dienen dem Beobachter-Muster.
Das Listing zeigt auch die Klasse rights. Sie fungiert als subscriber und implementiert das gleichnamige Interface. Ihre Aufgabe ist die Verknüpfung von Benutzerrollen mit Benutzerrechten. Auch sie bietet nach außen hin neben den Methoden, die dem Beobachter-Muster vorbehalten sind, lediglich die Möglichkeit mit rights::get() eine Auflistung der aktuellen Benutzerrechte abzufragen.
Den Beweis der Funktionalität und Praktikabilität tritt schlussendlich das Ende des Listings an. Dort werden Exemplare (Objekte) der Userklasse und Rechteklasse erzeugt. Dabei wird dem Rechteobjekt eine Referenz auf die Userklasse übergeben. Das Rechteobjekt fragt bei dessen Erzeugung im Konstruktor die Benutzerrolle beim per Referenz übergebenen Userobjekt ab und abonniert dessen Änderungsmitteilungen. Das Userobjekt legt daraufhin einen Verweis auf das Rechteobjekt in seiner Abonnentenliste user::subscribers ab.
Nun werden die Rechte der momentanen Benutzerrolle (Rolle 0) beim Rechteobjekt abgefragt (rights::get()) und das zurückgegebene Array zur Kontrolle ausgegeben: Die enthaltenen Benutzerrechte beschränken sich auf Leserechte (read). Anschließend wird dem Userobjekt eine neue Benutzerrolle (Rolle 1) zugewiesen und das Rechteobjekt erneut abgefragt, das zurückgelieferte Array enthält diesmal Lese- und Änderungsrechte (read, change). Ein letztes Mal wird die Benutzerrolle geändert (Rolle 2) und die Benutzerrechte beim Rechteobjekt abgefragt, welches dieses Mal Lese-, Änderungs- und Erstellungsrechte (read, change, create) zurückliefert.
Die Funktionstüchtigkeit des Entwurfs ist damit zweifelsfrei nachgewiesen: Da Rechteobjekt bei einer Anfrage nach den Rechten keine Abfrage an das Benutzerobjekt stellt, aber dennoch alle Rechte der jeweils aktuellen Benutzerrolle zurückliefert, muss das Benutzerobjekt Änderungen der Benutzerrolle zwischenzeitlich dem Rechteobjekt mitgeteilt haben.
Betrachten wir die Abläufe beim Ändern der Benutzerrolle genauer: Nach Aufruf der Methode user::setRole() benachrichtigt das Benutzerobjekt per user::notifySubscribers() alle im Array user::subscribers als Referenz abgelegten Abonnenten und ruft jeweils deren notfiy() Methode auf. So benachrichtigt, fragt das Rechteobjekt die aktuelle Benutzerrolle des Userobjektes ab (rights::loadUserRights()) und speichert die assoziierten Benutzerrechte im Array rights::rights. Eine Abfrage der Benutzerrechte am Benutzerobjekt mit rights::get() liefert dieses Array zurück ohne eine erneute Anfrage an das Benutzerobjekt zu stellen.
Der in den Listings abgedruckte Quellcode ist aus layouttechnischen Gründen gekürzt, aber lauffähig. Die vollständige Fassung findet sich mit ausführlichen phpDoc Kommentaren versehen auf der CD, die diesem Heft beiliegt.
Push or Pull?
Das Beobachter-Entwurfsmuster kann als Push- oder als Pull-Verfahren implementiert werden. Im in diesem Artikel vorgestellten Pull-Verfahren werden die Abonnenten lediglich darüber in Kenntnis gesetzt, dass eine Änderung am Zustand des publishers stattgefunden hat, nicht aber welche Änderung das war. Dies müssen die Abonnenten selbst in Erfahrung bringen. Das Push-Verfahren hingegen schickt zusätzlich Informationen über die gemachten Änderungen an alle Abonnenten, welche die Relevanz der Informationen evaluieren können.
Beide Verfahren haben Ihre Vor- und Nachteile: Zwar muss beim Pull-Verfahren der Abonnent bei jeder Benachrichtigung prüfen, ob die Änderungen am Zustand publishers für ihn relevant sind, doch bleibt dafür die Menge der vom Verteiler gesendeten Daten klein. Beim Push-Verfahren hingegen kann der Abonnent anhand der ihm gelieferten Daten selbst entscheiden, ob eine Aktion notwendig ist und muss nicht erst zusätzliche Anfragen an den publisher stellen, dafür erreichen alle vom publisher gesendeten Daten alle Abonnenten, auch wenn die Daten für einige Abonnenten irrelevant sind. Auch die hierfür benötigte API und Auswertungslogik jedes Abonnenten ist weit komplexer als es beim Pull-Verfahren der Fall ist.
Das Beobachter-Muster in PHP4
Das im Rahmen dieses Artikels vorgestellte Beispiel macht intensiven Gebrauch der neuen PHP5 Sprachkonstrukte. Dennoch ist es ohne größeren Aufwand möglich, den Code nach PHP4 zu portieren. Der Verzicht auf Interfaces, sowie auf die public/private/protected Deklarationen, fordert vom Programmierer allerdings etwas Disziplin beim Einhalten von Objekt-Integritäten um die lose Kopplung von Objekten (und damit deren Austauschbarkeit) nicht zu gefährden. Auch ist bei allen Übergaben von Objekten darauf zu achten, dass PHP4 (anders als PHP5) kein standardmäßiges pass-by-reference macht, sondern Kopien der übergebenen Objekte anlegt. Abhilfe schafft hier die Verwendung des &$objekt Konstrukts oder die des &= Operators für explizite Referenzen.
Bewertung
Das Beobachter-Muster ist in seiner Anwendung nicht an Spezialfälle gebunden und kann überall zum Einsatz kommen, wo Objektabhängigkeiten bestehen. Dies macht es zu einem der bekanntesten und am meisten verwendeten Entwurfsmuster. Eine Transferleistung ist fast nicht nötig, die hier vorgestellte API dürfte nahezu unverändert auf 90% der Fälle, wo ein Pull-Verfahren zur Anwendung kommen soll, zutreffen. Aber auch das Beispiel in ein Push-Verfahren zu ändern geht ohne größeren Anwand.
Eine Besonderheit hat das Beobachter-Muster zu bieten: Im Gegensatz zu den im Verlauf dieser Artikelserie bereits vorgestellten Entwurfsmustern führt dieses Muster nicht zu signifikanten Performanceeinbussen. Das Prinzip vieler anderer Entwurfsmuster ist die Einführung einer Interdirektionsebene (siehe Ausgabe 01/2004, Artikel „Strickwerk“, Box „Flexibilität vs. Performance“). Auf einen solchen Overhead an Codezeilen, die bei jeder Aktion anfallen, kann das Beobachter-Muster verzichten: Alle automatisch und zusätzlich zur Skriptanweisung ausgeführte Befehle müssten bei Nichtanwendung des Musters auch manuell ausgeführt werden (bis auch Abonnieren/Kündigen).
In der Gesamtheit betrachtet dient das Beobachter-Muster vornehmlich der Bequemlichkeit des Programmierers, der sich das Berücksichtigen von Abhängigkeiten sparen kann, sobald er diese einmal per Abonnement definiert hat. Einmal mehr findet sich die Weisheit bestätigt, dass „Programmierer von Natur aus faul sind“. Aber auch eine passende Ausrede steht schon parat: Als Nebeneffekt der Anwendung des Musters wird die Konsistenz der Anwendung sichergestellt. Dies geschieht, da, nachdem eine Abhängigkeit per Abonnement definiert wurde, diese fortan bei keiner Aktion mehr vergessen wird.
Listing
interface publisher { public function subscribe(subscriber $subscriber); public function unsubscribe(subscriber $subscriber); } interface subscriber { public function notify(); } interface provideRole { public function getRole(); } class user implements publisher, provideRole { private $subscribers = array(); private $role; public function __construct() { $this->role = 0; } public function subscribe(subscriber $subscriber) { $this->subscribers[] = $subscriber; } public function unsubscribe(subscriber $subscriber) { unset($this->subscribers[$subscriber]); } private function notifySubscribers() { foreach($this->subscribers as $subscriber) { $subscriber->notify(); } } public function setRole($role) { $this->role = intval($role); $this->notifySubscribers(); } public function getRole() { return $this->role; } } class rights implements subscriber { private $user; private $rights; private $roles = array( 0 => array('read'), 1 => array('read', 'change'), 2 => array('read', 'change', 'create') ); public function __construct($user) { $this->user = $user; $this->user->subscribe($this); $this->loadUserRights(); } private function __destruct() { $this->user->unsubscribe($this); } public function notify() { $this->loadUserRights(); } private function loadUserRights() { $this->rights = $this->roles[$this->user->getRole()]; } public function get() { return $this->rights; } } $user = new user(); $rights = new rights($user); print_r($rights->get()); $user->setRole(1); print_r($rights->get()); $user->setRole(2); print_r($rights->get());