Das Inpsyde Elasticsearch Plugin 2/2

Das Inpsyde Elasticsearch Plugin 2/2
Gestern habe ich in meinem ersten Blogbeitrag einige interne Prozesse gezeigt und wie "Konzepte bei Inpsyde erstellt werden". Heute werden wir ein bisschen tiefer in die Materie von Konzepten eintauchen. Außerdem gibt es ein paar Code Beispiele, um zu zeigen, wie wir alles implementiert haben.

In seinem gestrigen Beitrag beschrieb Inpsyder Christian, wie er als Hauptentwickler mit seinem Team das “Inpsyde Elasticsearch Plugin” Projekt begonnen hat. Im zehnten Adventskalender-Beitrag geht er nun auf eine tiefere Ebene. Er zeigt die Konzepte hinter unserem Inpsyde Elasticsearch Plugin sowie einige Code Beispiele, um darzulegen, wie das Team alles umgesetzt hat.


Inhaltsverzeichnis

1. Ein Name
2. Module definieren und entwickeln
2.1. App-Modul
2.2. Client-Modul
2.3. Debug-Modul
2.4. Index-Mapping-Property-Modul
2.5. Document-Modul
2.6. CLI-Modul
2.7. Queue-Modul
3. Status Quo und Zukunft


1. Ein Name

Ich denke, das war der beste Teil der gesamten Konzeption und der Arbeit am Plugin. Es gab eine Masse an lustiger Namen in unserer Brainstorming Session, aber am Ende einigten wir uns auf:

ElasticWP

2. Module definieren und entwickeln

Nachdem wir einen Namen für unser Baby hatten, begannen wir damit, einige konzeptionelle Teile niederzuschreiben, um all die Anforderungen zu definieren und wie wir sie lösen werden. Um die Lesezeit etwas zu reduzieren, konzentriere ich mir hier auf das Endergebnis und die Entscheidungen, die wir während des Prozesses getroffen haben. Außerdem möchte ich gerne aufzeigen, wie wir alles implementiert haben.

Der ganze Prozess intern, enthält mehrere Abschnitte:

  1. Die benötigten Module definieren
  2. Konzeptionelle Arbeit an diesen Modulen
  3. Review und Diskussionen
  4. Konzept finalisieren
  5. Das MVP (minimum viable product = minimal nutzbares Produkt) definieren
  6. Implementieren

Die Implementierung selbst erfolgte durch Rapid Prototyping, indem ein funktionsfähiger Proof of Concept erstellt wurde, der Folgendes beinhaltete:

  1. erlaubt, Module über Provider zu registrieren
  2. einen Client zu konfigurieren
  3. die Erstellung einer API, um einen Index über Konfiguration zu erstellen
  4. transformiert Daten über Dokumente von WP_Post, WP_Comment, WP_User & WP_Term zu Elasticsearch
  5. Unit Tests
  6. Ein lokales Setup über Docker-Compose

Ich habe den Prototyp an einem Wochenende erstellt und er war in einem sehr frühen Zustand. Aber es erlaubte uns, kontinuierlich daran zu arbeiten, indem wir einige Tage durch immer wiederkehrender Rewrites und Reviews alle Kernmodule fertiggestellt hatten.

Lasst uns einen Blick auf die Ergebnisse werfen:

2.1. App-Modul

Das App-Modul ist der Hauptteil des Plugins. Es bietet eine PSR-11-Containerimplementierung mit ein Provider-Interface, welches es ermöglicht, Klassen oder Konfigurationen im Container zu registrieren. Darüber hinaus verfügt es über eine BootableProvider-Interface, die es Modulen ermöglicht auf WordPress-Hooks zu hören.

Der Haupt-Plugin-Container:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-
namespace ElasticWP;

use ElasticWP\App\BootableProvider;
use ElasticWP\App\Provider;
use Psr\Container\ContainerInterface;

final class ElasticWP implements ContainerInterface
{
public function set(string $id, $value): self { /*snip */ }

public function register(Provider $provider) { /*snip */ }

public function boot(): bool { /*snip */ }

public function get($id) { /*snip */ }

public function has($id) { /*snip */ }
}

Der Provider:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

namespace ElasticWP\App;

use ElasticWP\ElasticWP;

interface Provider
{
   public function register(ElasticWP $plugin);
}

Der BootableProvider:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

namespace ElasticWP\App;

use ElasticWP\ElasticWP;

interface BootableProvider extends Provider
{
   public function boot(ElasticWP $plugin);
}

Wie du siehst, erweitert der BootableProvider den Provider. Das bedeutet, es muss etwas registiert, bevor es gebootet werden kann.

Die Registrierung von Providern oder bestimmten Konfigurationen und Klassen zum Container ist über einen Bootstrap-Hook möglich. Er sieht wie folgt aus:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\ElasticWP;

add_action(
   'ElasticWP.boot',
   function (ElasticWP $plugin) {
        // $plugin->set(string $key, mixed $value);
        // $value = $plugin->get(string $key);
        // $plugin->register(Provider $provider);
   }
);

2.2. Client-Modul

Das Client-Modul bietet einen Weg, einen Elasticsearch\Client aus dem “Elasticsearch-PHP”-Package zu konfigurieren und zu erstellen.

Wir haben uns entschieden, einen ElasticWP\Client\ClientConfigurationBuilder anzubieten, der deine Konfiguration automatisch entweder aus einer definierten Konstante oder einer Umgebungsvariablen liest.

Auf diese Weise kannst du deine Client-Verbindung global zu Elasticsearch konfigurieren. Außerdem wollten wir sicherstellen, dass ungültige Konfigurationen frühzeitig mit einer Fehlermeldung fehlschlagen. Darüber hinaus haben wir auch einige Standardwerte festgelegt – wie z.B. den verwendeten Logger, der vom Plugin mitgeliefert wird. Die Mindestanforderung für das Erstellen einer Client-Instanz ist die Bereitstellung von mindestens einem Host.

Hier ist ein Beispiel für deine wp-config.php:

<?php # -*- coding: utf-8 -*-

$config = ['hosts' => ['localhost']];
$config = base64_encode(serialize($config));

// v1 - via constant
define('ELASTICWP_CLIENT_CONFIG', $config);

// v2 - via env var
putenv('ELASTICWP_CLIENT_CONFIG=' . $config);

Des Weiteren ist es auch möglich, einen Client manuell zu erstellen und ihn im App-Modul zu registrieren.

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\ElasticWP;
use Elasticsearch\Client;


add_action(
   'ElasticWP.boot',
   function (ElasticWP $plugin) {


      $plugin->set(
         Client::class,
         function (ElasticWP $plugin): Client {
             // return an instance of the Client
         }
      );

   }
);

Lies mehr über den Client in der offiziellen Dokumentation: https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/_configuration.html

2.3. Debug-Modul

Das Debug-Modul bietet eine Implementierung des PSR-3 LoggerInterface. Das Modul selbst enthält eine einzelne Klasse, die intern eine eigene action verwendet:

do_action( "ElasticWP.{errorLevel}", string $message, array $context );

Mit der Bereitstellung solch einer Implementierung und der Verwendung von WordPress-Internals können wir Logs nach Belieben erstellen.

[!] ProTipp: Wir nutzen Inpsyde\Wonolog, um solche action zu verfolgen und Daten zu einem Logging-Service zu übertragen.

2.4. Index-Mapping-Property-Modul

Nun machen wir mit einem großen Modul weiter. Es sind eigentlich drei Module, die sehr stark voneinander abhängen. Darum – und auch, um zu vermeiden zwischen diesen Abschnitten hin- und herzuspringen – beschreibe ich an dieser Stelle die gesamte Konzepterstellung eines Indexes mit Mapping und Properties.

Aber bevor wir anfangen …

[!] Wichtig: Ein in Elasticsearch 6.x erstellter Index erlaubt nur einen Single-Typ pro Index. Jeder Name kann für diesen Typ verwendet werden, aber es kann nur einen geben. Der bevorzugte Name ist _doc, sodass Index APIs den gleichen Weg haben, den sie auch in 7.0: PUT {index}/_doc/{id} und POST {index}/_doc haben werden.

Wir haben uns dazu entschieden, die gesamte “Typ”-Definition in unserem Index zu entfernen. Stattdessen nutzen wir immer “_doc”, um die Kompatibilität mit zukünftigen Releases zu gewährleisten.

Der richtige Weg, einen vollständigen Index zu konfigurieren, ist, alle benötigten Informationen in einem Array bereitzustellen, der einen Index über ElasticWP\Index\IndexBuilder erstellt und im ElasticWP\Index\IndexRegistry registriert ist.

Hier ist ein kurzes Index-Schema-Beispiel mit Kommentaren:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\Configuration\IndexConfiguration;
use ElasticWP\Mapping\Property\PropertyInterface;

$indexSchema = [
   'index' => 'name of your index',    // string - unique name
   'settings' => [],                   // array - optional
   'mappings' => [
       '_meta' => [
           'dataSource' => IndexConfiguration::DATA_SOURCE_POST,
           'objectTypes' => [],        // array - optional
           'version' => '1.0.0',       // string
       ],
       'properties' => [],             // PropertyInterface[]
   ]
];

Das Format und die Struktur dieses Arrays ist so ähnlich wie der Elasticsearch Index. Aber lasst uns Schritt für Schritt auf jedes Feld schauen.

index
Der Name des Indexes in Elasticsearch.
settings
Hier kannst du deine eigenen Einstellungen für den aktuellen Index definieren.

Weitere Infos: https://www.elastic.co/guide/en/elasticsearch/guide/current/_index_settings.html

mappings
Da nur ein Typ pro Index erlaubt ist, haben wir den “Typ” komplett aus unserem Index-Schema entfernt und es standardmäßig auf “_doc” gesetzt.

mappings._meta
Die _meta in Elasticsearch ist ein optionaler Array. Sie können mit zusätzlichen Informationen gefüllt werden, die nicht von Elasticsearch verwendet werden. Wir haben uns dazu entschieden, diesen Array als Konfiguration für automatisches Hooking in die richtigen action in WordPress zu nutzen, um Daten für den Index bereitzustellen.

Folgende Felder sind erforderlich:

1. dataSource
Die dataSource ist ein Pflichtfeld und durch eine der verfügbaren ElasticWP\Configuration\IndexConfiguration::DATA_SOURCE_*-Konstanten definiert.

  • IndexConfiguration::DATA_SOURCE_POST – PostTypes als Startpunkt
  • IndexConfiguration::DATA_SOURCE_TERM – Taxonomy Terms als Startpunkt
  • IndexConfiguration::DATA_SOURCE_COMMENT – Kommentare als Startpunkt
  • IndexConfiguration::DATA_SOURCE_USER< – User als Startpunkt

2. objectTypes
Diese Konfiguration schränkt die dataSource noch weiter ein – z.B. wenn du von dataSource=IndexConfiguration::DATA_SOURCE_POST nur PostType=”page” willst, dann ist dies der Punkt, an dem du einschränkst.

3. version
Das dritte Feld version wird genutzt, um Änderungen im Index zu erkennen und sein Mapping zu aktualisieren. Es liegt an dir, wie du die Versionen deines Index definierst. Aber denke daran, dass wir version_compare() über “greater than” verwenden, um Änderungen zu erkennen.

mappings.properties
Properties sind der Hauptteil deines Mappings. Da wir Daten basierend auf der definierten dataSource und den objectTypes verarbeiten, müssen wir diese Daten in das richtige Format transformieren, um sie an Elasticsearch zu übergeben.

Aus diesem Grund können wir nicht nur ein multidimensionales Array die definition der Properties verwenden. Stattdessen haben wir ein eigenes Interface ElasticWP\Mapping\Property\PropertyInterface welches genutzt werden muss:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

namespace ElasticWP\Mapping\Property;

interface PropertyInterface
{
  // Used to sort processors before executing them.
  public function priority(): int;

  // Processing data to the given Document.
  public function transform(DocumentInterface $document): DocumentInterface;

  // Contains the array of property definition.
  public function definition(): array;
}

Erfahre mehr über Properties in der offiziellen Dokumentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/properties.html

Hier ist ein kurzes Beispiel, wie ein PostAuthorProperty mit E-Mail, Login, Name und ID aussieht:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\Document\DocumentInterface;
use \ElasticWP\Mapping\Property\PropertyInterface;

class PostAuthorProperty implements PropertyInterface
{

   public function priority(): int
   {
       return 1;
   }

   public function definition(): array
   {
       return [
           'author' => [
               'type' => 'object',
               'properties' => [
                   'email' => [
                       'type' => 'keyword',
                   ],
                   'login' => [
                       'type' => 'keyword',
                   ],
                   'name' => [
                       'type' => 'keyword',
                   ],
                   'id' => [
                       'type' => 'long',
                   ],
               ],
           ],
       ];
   }

   public function transform(DocumentInterface $document): DocumentInterface
   {
       $userId = $document->object()->post_author;
       $user = get_userdata($userId);
      
       $document->set(
           'author',
           [
               'email' => $user->user_email,
               'login' => $user->user_login,
               'name' => $user->display_name,
               'id' => $userId,
           ]
       );

       return $document;
   }
}

Um schließlich den Index zu erstellen und für das Plugin zu registrieren, musst du den Index in deinem Plugin folgendermaßen erstellen:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\ElasticWP;
use ElasticWP\Index\IndexBuilder;
use ElasticWP\Index\IndexRegistry;

add_action(
   'ElasticWP.boot',
   function (ElasticWP $plugin) {
        // @var ElasticWP\Index\IndexBuilder $indexBuilder
        $indexBuilder = $plugin->get(IndexBuilder::class);

        // @var ElasticWP\Index\IndexInterface $index
        $index = $indexBuilder->fromArray($indexSchema);

        // @var ElasticWP\Index\IndexRegistry $indexRegistry
        $indexRegistry = $plugin->get(IndexRegistry::class);

        // Register the Index to the Plugin.
        $indexRegistry->register($index);
   }
);

Das war’s. Unser ElasticWP Plugin hat jetzt einen neuen Index, der automatisch in Elasticsearch erstellt wird und die richtigen Hooks in WordPress verfolgt, um die Daten aus WordPress in dein Schema zu transformieren und sie zu Elasticsearch zu bringen

2.5. Dokument-Modul

Das Dokument-Modul ist wesentlich dafür verantwortlich, dass ein Dokument aus einer gegebenen dataSource (z.B. “WP_Post”) zu einem möglichen eingeschränkten objectType (z.B. PostType=”page”) für einen gegebenen Index generiert wird und dass dieses Dokument auf Basis der aktuellen WordPress action entweder erstellt/upgedatet oder gelöscht wird.

Das ElasticWP\Document\DocumentInterface sieht wie folgt aus:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

namespace ElasticWP\Document;

interface DocumentInterface
{

   // Unique ID which represents the Document in Index
   public function id(): string;

   // Contains the type of dataSource like \WP_Post|Comment|Term|User
   public function dataSource(): string;

   // Contains the type of object like the CPT, CommentType or Taxonomy.
   public function objectType(): string;

   // Returns the complete entity which is present to build the data.
   public function object();

   // Returns the ID of the object from WordPress.
   public function objectId(): int;

   // The current_blog_id where the Document belongs to.
   public function blogId(): int;

   // Array of all data which is set to Document and used to insert/update.
   public function body(): array;

   // Returns true, if the current Document has a valid object, otherwise false.
   public function isValid(): bool;

   public function set(string $key, $value);
   public function get(string $key);}
   public function remove(string $key): bool;
}

Unser ElasticWP\Document\DocumentSyncInterface führt bei der entsprechenden WordPress action im Hintergrund via ElasticWP\Document\DocumentDataGenerator alle definierten ElasticWP\Mapping\Property\PropertyInterface aus, um ein Dokument zu erstellen, das entweder in Elasticsearch erstellt, aktualisiert oder gelöscht wird.

Da wir in unserem Container ein austauschbares Interface auf Basis des ElasticWP\Document\DocumentSyncInterface verwenden, ist es einfach, die Implementierung vollständig zu ersetzen.

2.6. CLI-Modul

Grundsätzlich unterstützen wir die Core API des “Elasticsearch-PHP”-Packages, indem wir die Doc-Blocks des Client, IndicesNamespace und ClusterNamespace parsen:

NAME

  wp elasticwp

SYNOPSIS

  wp elasticwp <command>

SUBCOMMANDS

  client       
  cluster      
  indices      
  reindex      Reindex all Documents to a given Index.

Zusätzlich haben wir ein eigenes WP-CLI-Command “reindex” implementiert, welches es uns ermöglicht, den gesamten Index neu aufzubauen:

NAME

  wp elasticwp reindex

DESCRIPTION

  Reindex all Documents to a given Index.

SYNOPSIS

  wp elasticwp reindex <indexName>

OPTIONS

  <indexName>
    The name of the Index

EXAMPLES

    wp elasticwp reindex <indexName>

2.7. Queue-Modul

Das Hauptproblem bei bestehenden Plugins sind eine Menge Daten und Hunderte von Editoren, die parallel arbeiten. Wir müssen zu 100% sicherstellen, dass beim Klick auf “Beitrag speichern” oder “Beitrag löschen”, dieser tatsächlich mit Elasticsearch synchronisiert wird.

Standardmäßig hört ElasticWP auf die WordPress-Hooks für Update/Löschen und versucht, mit Elasticsearch zu kommunizieren. Aber das Plugin unterstützt auch eine komplette Message-Queue-Implementierung, falls “Elasticsearch nicht erreichbar ist”. Sie verschiebt das aktuelle Dokument in einen WordPress Cron und wiederholt diesen so lange, bis der Push erfolgreich war. Man kann diese Implementierung einfach über Message, Handler, Producer und Consumer ersetzen, indem man z.B. RabbitMQ verwendet, um das gesamte Laden in WordPress auf ein Minimum zu reduzieren.

Hier ist ein kurzes Beispiel, wie du eine eigene Implementierung bereitstellen kannst, um asynchron zu arbeiten und deine Warteschlange z.B. an RabbitMQ zu senden:

<?php declare(strict_types=1); # -*- coding: utf-8 -*-

use ElasticWP\ElasticWP;
use ElasticWP\Queue\ProducerInterface;
use ElasticWP\Document\AsyncDocumentSync;
use ElasticWP\Document\DocumentSyncInterface;

add_action(
   'ElasticWP.boot',
   function (ElasticWP $plugin) {
        // Set the AsyncDocumentSync - default is synchronous
        $plugin->set(
            DocumentSyncInterface::class,
            function(ElasticWP $plugin): DocumentSyncInterface
            {
                return $plugin->get(AsyncDocumentSync::class);
            }
        );
        
        // Queue - Send to RabbitMQ
        $plugin->set(
            ProducerInterface::class,
            function(): ProducerInterface
            {
            
                return new YourAmqpProducer( ... );
            }
        );

   }
);

3. Status Quo und Zukunft

Kurz zusammengefasst: Das Plugin funktioniert.

Wir nutzen dieses Plugin bereits seit ein paar Monaten in Kundenprojekten mit benutzerdefinierten Suchintegrationen. Es funktioniert sehr stabil, zuverlässig und performant.

Auch haben wir ein paar benutzerdefinierte Properties erstellt, welche häufig in verschiedenen Indizes wiederverwendet werden. Auch wurde in den vergangenen Wochen die Performance deutlich noch einmal verbessert.

Ein komplett neuer Index für einen objectType kann in unter einer Minute erstellt werden. Und mit komplettem Multisite-Support und WP-CLI-Integration ist es möglich, alle Dokumente im Handumdrehen neu zu indizieren.

Derzeit ist das Plugin nur intern über ein privates Repository für uns verfügbar. Die Dokumentation ist vollständig und wir haben eine ziemlich gute Testabdeckung. Wir planen derzeit einige ziemlich nette Features für dieses fantastischen Plugin. Wenn du also weitere Informationen haben möchtest, hinterlasse einen Kommentar oder kontaktiere uns. Wir werden uns mit dir in Verbindung setzen. 🙂

Und am Ende … ein paar Statistiken für Geeks:

Investierte Zeit : 160 Arbeitsstunden
Commits: 153
Zeilen geschriebener Code: 21.663
Zeilen gelöschter Code: 9.834

Unit Tests:

Integrationstests: