Konfiguration und Programmlogik in WordPress-Plugins trennen

Konfiguration und Programmlogik in WordPress Plugins trennen

Inpsyder David Naber beschreibt, warum es eine gute Idee ist, Konfiguration im Code zu abstrahieren und zeigt, was er dafür entwickelt hat.

Vor einiger Zeit hat Inpsyder David Naber — so beschreibt er es — gefühlt jede Pluginentwicklung damit begonnen, dass er eine Klasse Vendor\Plugin\Config geschrieben hat, wenn er wusste, dass die Implementierung eine Handvoll konfigurierbarer Optionen beinhalten würde. Nach einigen dieser Plugins entwickelte sich daraus ein immer gleicher Ansatz und er erkannte, dass sich dieser Code gut in eine separate Bibliothek auslagern ließe. Diese wird er im folgenden Artikel vorstellen. Zuvor aber geht es darum, warum es generell eine gute Idee ist, Konfiguration im Code zu abstrahieren.

Was ist Konfiguration?

Zu Anfang möchte ich den Begriff Konfiguration genauer eingrenzen. Konfiguration beschreibt die dynamische Einflussnahme auf das Verhalten eines Programms. In diesem Artikel soll es dabei vornehmlich um Einstellungen gehen, die der Nutzer üblicherweise nicht zu Gesicht bekommt, sondern eher um solche Einstellungen, die das Programm auf seine Laufumgebung anpassen oder die Interaktion mit anderen Komponenten regelt. Demgegenüber stehen statische Konventionen, die direkt vom Programm selbst vorgegeben werden und nicht zur Laufzeit definiert werden können (wofür man üblicherweise PHP-Konstanten verwendet). Ein relativ einfaches Beispiel für eine Konfiguration wäre die Frage, an welches Ziel Log-Messages geschrieben werden: in eine Datei oder an ein Web-Service wie Slack. Wesentlich komplexer wäre hingegen die Konfiguration eines Dependency-Injection Containers.

Um das zu verdeutlichen, schauen wir uns ein Beispiel an, welches in ähnlicher Form in einem Plugin vorkam. Die Aufgabe war, eine bestimmte Information aus dem WordPress Prozess zu einem externen Service zu übermitteln, wobei die Integrität der Daten von großer Wichtigkeit war. Im Wesentlichen handelt es sich also um einen HTTP Request, der zuvor mit einem RSA Schlüssel signiert wurde.

<?php
class Handler {

    // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    public function handle(Event $event) { 

        $url = 'http://remote.service/endpoint';
        $rsaKey = [
            'public' => 'RSA Pub Key',
            'private' => 'RSA Priv Key',
            'password' => 't0p-s3cr3t',
        ];

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request, $rsaKey);
        $this->client->send($request);
    }
}

Das Beispiel zeigt einen beliebigen Event-Handler, der hier als Wrapper dienen soll und zwei Abhängigkeiten besitzt. Zum einen auf einen HTTP-Client zum anderen auf einen Service, der den Request signiert. Die konfigurierbaren Werte sollen in diesem Fall die URL des Ziel-Services $url und der RSA-Schlüssel $rsaKey mit zugehörigem Passwort sein. Für das Plugin entschied ich mich, dass das RSA-Schlüsselpaar in der Datenbank zu speichern sei, genauer in der Tabelle wp_siteoption. Wie bereits erwähnt sind das Aspekte, um die sich kein User kümmern müsste – daher ist auch kein GUI Vorgesehen. Stattdessen lassen sich diese Werte prima mit WP-CLI setzen:

# Den Inhalt einer Datei in die wp_siteoptions schreiben
$ cat id_rsa | wp site option add my_plugin_rsa_priv_key
$ cat id_rsa.pub | wp site option add my_plugin_rsa_pub_key

Das Passwort und die URL werden als Umgebungsvariablen bereitgestellt. Diese können üblicherweise über eine .env-Datei oder direkt über das Deployment-Tool definiert werden. Das hat den zusätzlichen Vorteil, dass der RSA-Schlüssel unbrauchbar ist, sollte mal ein Datenbank-Export abhandenkommen.

Das Beispiel sieht nun wie folgt aus, wenn wir die konfigurierbaren Werte direkt implementieren:

<?php 
class Handler {

   // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    public function handle(Event $event) {
        $url = filter_var(
            getenv('MY_PLUGIN_REMOTE_URL'), 
            FILTER_VALIDATE_URL
        ); 
        $rsaKey = [
            'public' => get_site_option('my_plugin_rsa_pub_key'),
            'private' => get_site_option('my_plugin_rsa_pub_key'),
            'password' => getenv('MY_PLUGIN_RSA_PASS') ?: '',
        ];

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request, $rsaKey);
        $this->client->send($request);
    }
}

Jetzt steckt schon jede Menge mehr Logik in diesem Handler. Er ist nun dafür zuständig, die Konfigurationswerte zu lesen und zu filtern. Das bedeutet Typ-Prüfung und gegebenenfalls auf nicht-existierende Werte zu reagieren. Das verletzt das Single-Responsibility-Prinzip und durch die direkte Abhängigkeit auf Datenbankfunktionen sind die Daten nicht gut gekapselt. Es sollte also refactored werden.

<?php 
class Handler {

    // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    // Config Container
    private $config;

    public function handle(Event $event) {
        $url = $this->config->get('remote_url');

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request);
        $this->client->send($request);
    }
}

Statt direkt auf Konfigurationsquellen zuzugreifen, hat der Handler jetzt eine zusätzliche Abhängigkeit auf einen Configuration-Container. Über dessen Interface können relevante Informationen gelesen werden. Auch der RSA-Key wurde entfernt, da der Signing-Service selbst eine Abhängigkeit auf den Config-Container besitzt. Das hat mehrere Vorteile – allen voran eine bessere Testbarkeit des Codes. Darüber hinaus kann man sich auf Datentypen verlassen und ist vollständig unabhängig von der Quelle und Filterung der Daten.

Das Interface des Containers ist nicht sehr komplex:

<?php
declare(strict_types=1);

namespace Inpsyde\Config;

use Inpsyde\Config\Exception\Exception;

interface Config
{

    /**
     * @throws Exception
     * @return mixed
     */    public function get(string $key);

    public function has(string $key): bool;
}

Es gibt zwei Methoden, mit denen der Client Konfigurationswerte lesen und abfragen kann, ob Werte überhaupt gesetzt sind. Dadurch ist es möglich, optional eingestellte Werte zu lesen. Die get()-Methode muss eine Ausnahme auslösen, wenn ein unbekannter Schlüssel angefordert wird oder ein Wert nicht gefiltert werden kann, da dies eindeutig auf einen Fehler im Code hinweisen würde. Das Verhalten mag an PSR-11 erinnern, aber wir erwarten andere Rückgabetypen als bei einem Dependency-Injection-Container. Wir haben uns entschieden, diese Schnittstelle zu einem separaten Package zu machen: inpsyde/config-interface, sodass wir sie unabhängig von der Implementierung verwenden können, die für sehr kleine Plugins ein Overkill sein könnte.

Das Konfigurations-Package

Eine Schnittstelle zu haben, auf die man sich für gemeinsame Aufgaben verlassen kann, ist eine gute Sache, aber eine korrekte Implementierung ist die Sache, die einem langfristig etwas Arbeit erspart. Und gerade in der Startphase größerer Projekte werden Plugins sozusagen am Fließband geschrieben. Die Hauptziele zu Beginn der Implementierung waren:

  • Lesen der Konfiguration aus gängigen Quellen in WordPress-Umgebung. Dies sind hauptsächlich Umgebungsvariablen, PHP-Konstanten und die Tabelle wp_options und site options für die Multisite Variante.
  • Allowing to filter raw values to ensure correct types and data consistency
  • Das Filtern von Rohwerten, um korrekte Typen und Datenkonsistenz zu gewährleisten, ermöglichen.
  • Konfigurierbar über ein simples und einfach zu bedienendes Schema
  • Eine gut getestete Bibliothek zu haben.

Du kannst einen Schritt weitergehen und selbst entscheiden, ob diese Ziele erreicht wurden: inpsyde/config  Wie auch immer, lasst mich die Verwendung dieser Bibliothek am Beispiel von oben erklären. Zur Erinnerung: Es gab vier Konfigurationswerte: die entfernte URL, einen privaten und öffentlichen RSA-Schlüssel und das Passwort des Schlüssels. Ein weiterer sehr häufiger Konfigurationswert des Plugins ist das Stammverzeichnis des Plugins, also fügen wir es dem Beispiel hinzu. Um den Konfigurationscontainer einzurichten, erstellen wir eine Datei innerhalb des Plugins config.php und schreiben in das folgende Schema-Array:

<?php
declare(strict_types=1);

namespace MyPlugin\Config;

use Inpsyde\Config\Source\Source;

return [
    'remote_url' => [
        // The configuration is read from an environment variable
        'source' => Source::SOURCE_ENV,
        // This is the name of the env variable
        'source_name' => 'PLUGIN_REMOTE_URL',
        // Optional: you can provide a default value as fallback if the variable is not set
        'default_value' => 'http://api.tld/endpoint',
        // Optional: If the variable is set, pass it to filter_var() using the following filter argument
        'filter' => FILTER_VALIDATE_URL,
    ],
    'rsa_pubkey' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_WP_SITEOPTION,
        // With this option key
        'source_name' => '_plugin_rsa_pubkey',
    ],
    'rsa_privkey' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_WP_SITEOPTION,
        // With this option key
        'source_name' => '_plugin_rsa_privkey',
    ],
    'rsa_key_password' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_ENV,
        // With this option key
        'source_name' => 'PLUGIN_KEY_PASSWORD',
    ],
    'plugin_dir' => [
        // The value is provided on instantiation time
        'source' => Source::SOURCE_VARIABLE,
    ],
];

Dieses Schema sagt dem Konfigurationscontainer, woher er Werte lesen soll und ob er einen Standardwert bereitstellen oder die Rohwerte filtern soll. Jeder Konfigurationswert wird durch einen eindeutigen Schlüssel identifiziert. Für jeden Schlüssel gibt es eine Beschreibung der vier Eigenschaften: source, source_name, default_value und filter , wobei die letzten beiden optional sind. Die Bibliothek wird mit einem Builder ausgeliefert, der das Containerobjekt erstellt. Der Builder kann direkt aus einer Konfigurationsdatei lesen, wie im obigen Beispiel:

<?php
declare(strict_types=1);

namespace MyPlugin;

use Inpsyde\Config\Loader;

/* @var \Inpsyde\Config\Config $config */$config = Loader::loadFromFile(
    __DIR__.'/config.php',
    //Provide the variable value
    ['plugin_dir' => __DIR__ ],
);

// Remote URL
$remoteUrl = $config->get('remote_url');
// RSA Key parts
$rsaKey = [
    'pub' => $config->get('rsa_pubkey'),
    'priv' => $config->get('rsa_privkey'),
    'pass' => $config->get('rsa_key_password'),
];

Du kannst eine gesamte und aktuelle Dokumentation der Features in der README.md des Plugins finden.

Zusammenfassung

Eine solide Schnittstelle zur Trennung des Codes von Details des Lesens und Filtern der Konfiguration ist immer eine gute Sache, unabhängig davon, wie komplex dein Plugin sein wird. Deshalb haben wir uns entschieden, das Interface und die Implementierung in zwei separate Packages aufzuteilen. Die inpsyde/config-Bibliothek kann jedoch eine gute Option für dich sein, wenn dein Plugin ein paar Konfigurationswerte hat, die gefiltert werden müssen oder die aus verschiedenen Quellen wie Datenbanken, PHP-Konstanten oder Umgebungsvariablen gelesen werden.

* Vielen Dank an Fatos Bytyqi für das Foto, das wir in diesem Beitrags-Header verwenden.