Tutorial: HookUp

Hooks ist ein Thema über das sich wahrscheinlich die wenigsten Gedanken machen, jedoch essentiell beim Schreiben von Plugins ist. Im Grunde genommen ist es ein relativ einfaches Thema, dass jedoch schnell recht komplex werden kann. Ich werde hier ein wenig auf die Do’s und Don’ts eingehen und dies anhand von einigen Beispielen demonstrieren.

Das Tutorial richtet sich an Fortgeschrittene Anwender. Um es ein wenig kompakter zu halten, werde ich hier nicht auf die Funktionsweise von Hooks oder die Unterschiede zwischen Actions und Filter eingehen. Kernpunkt des Tutorials ist das richtige setzen von Hooks damit diese auch wieder entfernt werden können.
Wer etwas experementieren möchte, für den habe ich ein Git-Repository angelegt. In diesem befinden sich einige Scripte die als fertige Plugins abgespeichert sind. Einfach das Repo in das Pluginverzeichnis klonen und schon kann man testen und probieren.

Etwas Geschichte

WordPress wurde zu einer Zeit geschrieben, als PHP5 noch nicht veröffentlicht war und Objektorientierte Programmierung (OOP) noch nicht sehr verbreitet war. WordPress ist prozedural entwickelt worden und trägt diese Altlast bis heute mit sich herum. Zwar nutzt auch WordPress Klassen und Objekte, jedoch baut das Grundprinzip immer noch auf prozeduraler Programmierung auf. Das Handling von Objekten ist auf diese prozeduraler Basis aufgepflanzt, was jedoch keine saubere Lösung darstellen kann.
So lange wir prozedural programmieren, werden wir kaum oder gar keine Probleme mit Hooks haben. Sobald wir jedoch auf OOP setzen, wird es sehr schnell sehr schwierig die richtige Lösung für die auftretende Probleme zu finden.

Hooks entsprechen dem Observer Pattern. Hierbei können sich Beobachter (Observer) für ein bestimmtes Ereignis (Hook) registrieren und werden benachrichtigt sobald das Ereignis eintritt. In einer prozeduralen Umgebung reicht es den Funktionsnamen als Observer zu speichern und diesen beim Eintritt des Ereignisses als Callback aufzurufen.
In einer OOP-Umgebung ist dies schon nicht mehr ausreichend. Eine Klasse ist lediglich das Entwurfsmuster für ein Objekt und es können verschiedene Objekte von der gleichen Klasse erzeugt werden. Spätestens mit PHP5.3 wird die Lage noch komplizierter, da nun auch anonyme Funktionen als Callback registriert werden können.

Prozedurale Programmierung

In der Prozeduralen Programmierung ist es wie gesagt einfach:

function hooker( $var ) {
  // do something
}

add_action( 'hook', 'hooker', 0, 1 );
do_action( 'hook' );

add_filter( 'hook', 'hooker', 0, 1 );
$var = 'old value';
$var = apply_filters( 'hook', $var );

// removes the action AND filter because both
// use the same callback
remove_action( 'hook', 'hooker' );

Einfach aber nicht ganz unproblematisch. Als WordPress noch klein war und es nur wenige Plugins und Themes gab, war das alles relativ unproblematisch. Als WordPress jedoch wuchs und es immer mehr Themes und Plugins gab, stieg die Wahrscheinlichkeit das jemand anderes eine andere Funktion mit dem gleichen Namen erstellte.
Eine Zwischenlösung war es, die Funktionen mit Prefixen zu versehen. Aber auch die Prefixe werden irgendwann knapp, denn man empfahl häufig die Initialen des Autors als vergleichsweise kurzen Prefix zu nehmen. Das dies nicht lange gut geht, kann man sich denken. Also wurde der Name des Plugins bzw. Themes hinzugefügt. Sowohl Theme- als auch Plugin-Namen sind einzigartig, jedoch nicht immer kurz.
Hier wird eine konzeptionelle Schwäche von WordPress deutlich die vor 10 Jahren, als die Basis von WordPress entwickelt wurde, noch nicht abzusehen war. So lange man alleine oder in einen kleinen Team an einem Projekt arbeitet und die volle Kontrolle hat, funktioniert das Konzept von WordPress. Öffnet man jedoch eine Schnittstelle nach Außen die jeder nutzen kann, scheitert das Konzept.

Namespaces könnten eine Lösung für das Problem sein. Mit Namespaces lassen sich weite Teile des Codes “prefixen” was die Arbeit sehr erleichtert. Jedoch hat sich das Team um WordPress dafür entschieden das PHP5.2 als Mindestanforderung gilt, Namespaces gibt es leider erst ab PHP5.3. Will oder kann man seine Nutzer nicht dazu zwingen PHP5.3 zu verwenden, fällt diese Lösung also raus.

OOP

Objektorientierte Programmierung kommt immer mehr in Mode. Es ist wirklich eine Mode und das “objektorientiert” muss man in Anführungszeichen setzen. Zumindest was WordPress angeht.
Man kann nun lange darüber diskutieren wie oder was objektorientiert bedeutet oder wie stark man das auslegen muss damit es als OOP gilt. Im Fall WordPress hat sich sehr schnell herausgestellt das viele Plugin-Programmierer Klassen als Namespaces missbrauchten. Bestehender Code wurde teilweise lediglich mit einem Klassen-Statement umschlossen und damit war die “objektorientierung” für den Programmierer gegessen. Daraus entstanden gravierende Probleme was das entfernen einmal gesetzter Hooks angeht. Prinzipiell bestehen diese Probleme auch dann, wenn man “streng objektorientiert” arbeitet. Der Grund ist nun einmal weniger die objektorientierte Programmierung, als viel mehr der veraltete Unterbau von WordPress.

Hooks im Detail

Um zu verstehen wo das Problem liegt, müssen wir uns kurz anschauen wie WordPress die Callbacks zu den einzelnen Hooks registriert. Damit beim Aufruf von z.B. do_action( 'sample_hook' ); alle Callback-Funktionen aufgerufen werden die zu diesen Hook registriert wurden, muss man eine Liste der Callback-Funktionen führen. WordPress nutzt dazu das globale Array wp_filter. Schauen wir uns dazu das Plugin “01_HookUp Tutorial – Part One” aus dem Git-Repo an. Nach der Aktivierung zeigt es im Dashboard eine Metabox mit ein paar Ausgaben. Unter anderem wird dort mittels var_dump() das Array $GLOBALS['wp_filter']['test_hook'][0] ausgegeben. Warum nun ausgerechnet dieses Array?
Die Antwort ist relativ simpel. In wp_filter stehen, wie erwähnt, die registrierten Hooks. Mittels add_action() bzw. add_filter() fügt man einen Hook hinzu sofern dieser noch nicht besteht. Die 0 ist die Priorität mit der die Callbacks ausgeführt werden. Nun wird es interessant, denn ab hier wird der Name des Callbacks als Index verwendet. Wir haben die Funktion hook_callback für den Hook test_hook registriert. In der Ausgabe sehen wir dann auch das unter diesen Indexeintrag die Werte function und accepted_args gespeichert sind.
Die Registrierung folgt also nach folgenden Muster ab: [wp_filter][hook name][priorität][array mit callbacks]. Das Array mit den Callbacks ist ein assoziatives Array (Schlüssel (Index) – Werte Paarung) mit dem Muster [funktionsname] => [function], [accepted_args].

Funktionen und Methoden

So lange wir nur Funktionen als Callback registrieren, können diese im Klartext als Index verwendet werden. Schwierig wird es bei der Verwendung von Klassen. Fügen wir in unserem Script einfach mal eine Klasse ein und schauen uns an was passiert wenn wir eine Methode dieser Klasse als Callback registrieren. Zuerst einmal die Klasse, die kann einfach ans Ende des Scripts eingefügt werden:

class Demo_Hook
{
  public function print_var( $var ) {
    printf( '<p>The var <em>%s</em> was send by <code>do_action()</code> to <code>%s</code></p>', $var, __METHOD__ );
  }
}

In der Funktion hooktest_output() fügen wir nach der Zeile mit add_action() noch folgende zwei Zeilen ein:

  $demo = new Demo_Hook();
  add_action( 'test_hook', array( $demo, 'print_var' ), 0, 1 );

Schauen wir nun wieder ins Dashboard, so sehen wir das sich die Ausgabe des var_dump() dahingehend verändert hat, dass dort nun ein zweiter Index erscheint. Dieser neue Index besteht aus einem Hash1 (die lange kryptische Zeichenkette) und den Namen der Methode (hier print_var). Nun drücken wir einfach ein paar mal F5 und beobachten den neuen Index. Wie gut zu erkennen ist, verändert sich der Hash mit jeden Seitenaufruf obwohl wir am Script nichts geändert haben. Und genau das ist unser Problem.

Hooks und Callbacks entfernen

Es wird wohl eher selten vorkommen das jemand einen kompletten Hook entfernen möchte, es wäre jedoch möglich. Dazu reicht es aus den Hook aus dem globalen Array wp_filter mit unset() zu entfernen. Dazu einfach mal testweise nach den Registrieren der Hooks (also vor // [...]) ein unset( $GLOBALS['wp_filter']['test_hook'][0] ); einfügen.
Nun spuckt der var_dump() eine Fehlermeldung aus weil der Index test_hook ja nicht mehr existiert. Aber auch die komplette Ausgabe da drunter ist verschwunden. Komplette Hooks entfernen ist also möglich, jedoch oft eher sinnlos.
Viel häufiger kommt es vor das man einzelne Callbacks entfernen möchte. Wir erweitern den unset() Aufruf einfach mal um ['hook_callback'] (unset( $GLOBALS['wp_filter']['test_hook'][0]['hook_callback'] );) und schauen was passiert. Rufen wir das Dashboard erneut auf (ggf. aktualisieren), sehen wir das sowohl der erste Index (hook_callback) in der Ausgabe des var_dump() verschwunden ist, jedoch auch die Ausgabe da drunter ist weniger geworden. Nun steht nur noch die Ausgabe aus der Klasse Demo_Hook dort.

Durch Manipulation des globalen Arrays wp_filter können wir also Callbacks und sogar ganze Hooks entfernen. Natürlich hat WordPress dafür bereits eine Funktion zur Verfügung gestellt die uns einiges an Arbeit abnimmt. remove_action() und remove_filter() machen im Prinzip nichts anderes als das was wir gerade eben von Hand durchgeführt haben.
Kommen wir wieder zurück zu unseren Problem. Wie wir anhand des unset() gesehen haben, müssen wir einen Index entfernen der aus dem Funktions- bzw. aus dem Hash plus Methoden-Namen besteht. Dementsprechend erwarten remove_action() und remove_filter() neben dem Hook aus dem der Callback entfernt werden soll einen Funktionsnamen. Bei hook_callback würde das dann so aussehen remove_action( 'test_hook', 'hook_callback', 0 );.
Aber was ist wenn wir nicht die Callback-Funktion hook_callback sondern die Callback-Methode aus dem Objekt entfernen wollen? Um an eine Antwort zu gelangen ändern wir noch mal kurz das Script. Zuerst einmal löschen wir den unset() Aufruf wieder. Die Zeile mit $demo = new Demo_Hook(); brauchen wir auch nicht mehr und kann gelöscht oder auskommentiert werden. Den add_action() Aufruf ändern wir zu add_action( 'test_hook', array( 'Demo_Hook', 'print_var' ), 0, 1 ); und die Methode print_var() deklarieren wir als static (public static function print_var( $var ){...}.
Nun das Dashboard neu laden und schon sehen wir das der Hash verschwunden ist und dafür ein Index Demo_Hook::print_var erscheint. Das ist gut, denn das können wir, anders als den Hash, vorhersagen. Klassenname Doppelter-Doppelpunkt Methoden-Name. Das können wir jetzt so in remove_action() einsetzen: remove_action( 'test_hook', 'Demo_Hook::print_var', 0 );. Alternativ können wir auch das gleiche Array verwenden das wir bei add_action() verwendet haben: remove_action( 'test_hook', array( 'Demo_Hook', 'print_var' ), 0 );.
Woher WordPress weiß das der String 'Demo_Hook::print_var' die gleiche Methode meint wie das Array array( 'Demo_Hook', 'print_var' ), dazu kommen wir später. Dann klären wir auch wie man ein Object bzw. nicht statischen Methoden-Aufruf entfernen kann.

1 Die Hash-Werte werden erst ab PHP5.2 erzeugt und verwendet. Unter PHP

Zwischenfazit

  • Hooks in WordPress basieren auf eine Technik aus Zeiten vor OOP. Dadurch kommt es in Verbindung mit Objekten zu Problemen die sich nicht so einfach lösen lassen.
  • Die Callbacks werden in einem assoziativen Array mit Index-Werte-Paarung gespeichert. Zum Entfernen eines Callbacks benötigt man den Funktionsnamen, so wie er im Index erscheint.
  • Verwenden wir eine einfache Funktion als Callback für den Hook, so wird der Funktionsname als Index verwendet.
  • Bei verwendung einer statischen Methode aus einer Klasse, so wird der Klassenname gefolgt von einem Doppelten-Doppelpunkt und den Methoden-Name als Index verwendet. Zum Entfernen eines mittels statischen Methoden-Aufrufes hinzugefügten Callbacks, kann entweder ein String aus Klassenname::Methoden-Name oder ein Array mit den Werten Klassenname und Methodenname verwendet werden.
  • Callbacks aus Methoden in Objekten bzw. nicht statischen Methoden-Aufrufen können nicht ohne weiteres entfernt werden da der Index zum Teil aus einem Hash besteht der bei jeden Seitenaufruf verschieden ist.