Einleitung

Die Welt der Webentwicklung könnte kaum vielfältiger sein. Es existiert eine Vielzahl an Möglichkeiten Informationen anzuzeigen. Möglichkeiten mit Datenbanken, Techniken ohne Datenbanken oder dezente Mischformen von allen. Selbstverständlich gibt es so etwas wie "best practices", was mich aber nicht daran hindert selbst etwas zu experimentieren.

"Versuchsaufbau"

Während ich arbeitssuchend war, bin ich öfters auf den Bereich Elasticsearch gestoßen. Hierbei handelt es sich um eine Suchapplikation, die auf den Lucene-Index von Apache basiert. Die zu indexierenden Informationen werden hierbei intern in JSON vorgehalten und sind jederzeit lösch-/durchsuch- und ergänzbar. Neben Kibana, was als Analyse-App für Elasticsearch fungieren kann (darunter fallen u.a. auch die Anlage und Modifikation von Indizes, Querys, Dashboards, etc), bietet Elasticsearch eine REST-API für den Datenaustausch an. Und genau über diese Schnittstelle möchte ich primär mit Elasticsearch kommunizieren.

Es deutet sich vielleicht schon an welchen Zweck ich mir für den Einsatz dieser Software vorgestellt habe. Elasticsearch soll für mich als eine Art API, sozusagen als Middleware, zwischen einer Datenbank und dem Frontend fungieren.

Um Elasticsearch aber benutzen zu können, muss im Vorhinein ein Index konfiguriert werden (wenn dieses nicht per REST gemacht werden soll). Ich habe mich dazu entschieden erst einmal zu testen. Hierfür habe ich Kibana genutzt und die folgenden Felder, die indexiert werden sollen, wie folgt definiert:

{
  "index" : {
    "aliases" : { },
    "mappings" : {
      "properties" : {
        "abstract" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "id_1" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "id_c" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "id_3" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "date" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "id_2" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "persName" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "signature" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
      }
    },
    "settings" : {
      "index" : {
        "creation_date" : "1557485887918",
        "number_of_shards" : "1",
        "number_of_replicas" : "1",
        "uuid" : "MKnoTQP5Ro-vtzqKoO5VMg",
        "version" : {
          "created" : "7000199"
        },
        "provided_name" : "index"
      }
    }
  }
}
Indexdefnition Elasticsearch

Für diesen Test wollte ich vorab nur ein paar IDs aus den Daten und dazugehörige Texte extrahieren. Die Felder wurden von mir alle als Texte angelegt und mit dem Schlagwort keyword markiert. Dieses erlaubt mir u.a. nach dem jeweiligen Feld sortieren zu können. Weitere Informationen, wie Indizes und Mappings angelegt werden können, sind in der Dokumentation zu finden.

Es stellt sich nun die Frage, wie bekomme ich die Daten aus der Datenbank nach Elasticsearch. Als ersten Versuch habe ich einen Testdatenbestand der Monasterium.net-Plattform aus einer eXist-Datenbank (No-SQL) übertragen. Es handelt sich hierbei um Daten, die in einem TEI-ähnlichem Format in XML vorliegen. Um die Daten schlussendlich nach Elasticsearch zu bringen, wurde von mir ein Skript geschrieben, welches über die Daten iteriert und die aufbereitete Daten an die API per REST überträgt. Das dazugehörige Skript in XQuery sieht so aus:

xquery version "3.1";

import module namespace httpclient="http://exist-db.org/xquery/httpclient"; 
import module namespace util="http://exist-db.org/xquery/util"; 
import module "http://expath.org/ns/crypto";
declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization";

            
declare variable $atom:HEADERS := 
                <headers> 
                    <header name="Content-Type" value="application/json"/> 
                </headers>; 

declare function local:post($payload, $id) { 
                let $uri := xs:anyURI( concat('http://[ES-Server]/index/_doc/', $id) ) 
                return 
                    httpclient:post($uri, $payload, false(), $atom:HEADERS) 
            }; 
            
let $collection := collection("/db/[Collection-mit-Daten]/")

let $return :=
    <charters>
        {
        for $record in $collection//entry
 
            let $id_1 := ""
            let $id_2 := ""
            let $id_c := "" 
            let $id_3 := ""
        
            let $tokens := tokenize($record//id/text(), '/')
            let $id_3 := $tokens[last()]
            
            let $id_c := if (count($tokens) = 4) then $tokens[last()-1] else ""
            
            let $id_2 := if (count($tokens) = 5) then $tokens[last()-1] else ""
            let $id_1 := if (count($tokens) = 5) then $tokens[last()-2] else ""
            
            let $abstract := $record//abstract
            let $persname := $record//persName
            let $date := $record//date
            let $signature := $record//idno
            let $element := element record {
                element id_1 {$id_1},
                element id_2 {$id_2},
                element id_c {$id_c},
                element abstract {$abstract[1]/text()},
                element date {data($date/@value)},
                element persName {$persname/text()},
                element signature {$signature/text()},
                element idno {data($record//id/@idno)}
            }
            
            let $json := serialize($element, 
        <output:serialization-parameters>
            <output:method>json</output:method>
        </output:serialization-parameters>)
            let $iv := crypto:hash("initialization vector", "MD5", "base64")
            let $id := crypto:encrypt(string($record//id/text()), "symmetric", "Encryptionkey", "AES/CBC/PKCS5Padding", $iv, "SunJCE")		
            let $post :=  local:post($json, $id) 
            return $json

            
        }
    </charters>
            
return $return
XQuery-Exportskript

In XQuery wurde von mir ein Element mit den zu indexierenden Daten erstellt, dieses nach JSON serialisiert und schließlich per POST an die Elasticsearch-REST-Schnittstelle übertragen.

Nur kurz noch zum Aufbau der Datenübertragung.
Die URL für die Übertragung besteht aus der Adresse des Servers, den Indexnamen, dem Dokumententyp sowie einer eindeutigen ID. Leider konnte ich nicht direkt die ID aus den Dokumenten nutzen, da diese Sonderzeichen beinhalten können. Demnach stand ich vor der Wahl diese entweder zu escapen oder eine Alternative zu finden. Ich wählte den Weg die ID in einem MD5 Hash umzuwandeln und diesen dann als ID zu nutzen. Der Weg funktionierte im Folgenden ohne Schwierigkeiten.

Für das Frontend habe ich zum Test eine React.JS Applikation erstellt. Da es für React bereits Komponenten gibt, die für die Arbeit mit Elasticsearch ausgelegt sind, schien mir diese Auswahl gerechtfertigt. Die Anzeige der Daten soll lediglich einen Filter und eine einfache Anzeige der Daten beinhalten.

import React from 'react';
import { ReactiveBase, ResultList, ReactiveList, DataSearch, SelectedFilters } from "@appbaseio/reactivesearch";
import css from "../App.css";

const constID_1 = "Testwert";

const Testcomponent = () => (
    <div className="container">
        <ReactiveBase
            app="index"
            url="https://[URL-des-Elasticsearch-Servers]"
        >
            <DataSearch
                dataField="id_1"
                componentId="id_1Sensor"
                defaultValue="Testwert"
                showIcon={false}
                showFilter={true}
            />
            <div className="col">
                <SelectedFilters />
                <ReactiveList
                    className="right-col"
                    componentId="SearchResult"
                    dataField="id_1"
                    size={12}
                    pagination
                    react={{
                        and: 'id_1Sensor',
                    }}
                    render={({ data }) => (
                        <ReactiveList.ResultListWrapper>
                            {data.map(item => (
                                <ResultList key={item._id}>
                                    <ResultList.Content>
                                            <ResultList.Title>
                                                <div
                                                    className="book-title"
                                                    dangerouslySetInnerHTML={{
                                                        __html: item.archive
                                                    }}
                                                />
                                            </ResultList.Title>
                                            <ResultList.Description>
                                                <div className="flex column justify-space-between">
                                                    <div className="info"><b>id_2:</b> {item.id_2}</div>
                                                    <div className="info"><b>Date:</b> {item.date}</div>
                                                    <p className="info"><b>id_3: {item.id_3}</b><br/></p>
                                                    <p className="info">{item.abstract}</p>
                                                </div>
                                            </ResultList.Description>
                                    </ResultList.Content>
                            </ResultList>
                        )
                    )}

                    </ReactiveList.ResultListWrapper>
                    )}


                />
            </div>
        </ReactiveBase>
    </div>
);

export default Testcomponent;

Praxistest

Das Resultat hat mich schon etwas überrascht. Die Geschwindigkeit, mit der die Filterung bzw. der Abruf der Daten erfolgte, ist überragend. Direkt nach Tastendruck werden Werte vorgeschlagen und die Daten angezeigt. Da es sich bei dem Abruf der Daten um einen asynchronen Call handelt, ist der Browser nicht blockiert. Ein Scrollen durch die Daten ist weiterhin möglich. Da die Komponente reactivesearch speziell für den Umgang mit Elasticsearch ausgelegt ist, werden spezielle Funktionen, wie das paging oder facetted search direkt, soll heißen: out-of-the-box, mit angeboten. Damit entfällt weiterer Programmieraufwand, da ins besondere das Blättern durch die Ergebnisse(= paging) für einen größeren Datenkorpus massiv relevant ist.

Ich habe einen ähnlichen Versuchsaufbau mit Daten aus einem Processwire CMS, Elasticsearch und Processwire-Frontend nachgebaut und konnte so den Abruf der Daten von 2,7 Sekunden auf 300 Millisekunden reduzieren. Es zeigt sich also das nicht nur subjektive, sondern auch messbare Verbesserungen der Performance nachweisbar sind. Dieses konnte bereits mit einem einfachen Versuchsaufbau realisiert werden.

Fazit

Der Funktionstest hast gezeigt, dass in speziellen Anwendungsfällen eine separate Unterstützung der Suchfunktion einen massiven Geschwindigkeitsvorteil bringen kann. Hält man die Zeit für die Implementierung dagegen, ist es noch immer ein gut nachweisbarer Fortschritt. Bedenkt man die Tatsache, dass bei der Implementierung wahrscheinlich noch ein massives Optimierungspotential vorherrscht, ist solch ein Aufbau vor allem bei einem großen Datenbestand sinnvoll. Hinzu kommt eine große Community, die bei Fragen zum Thema Elasticsearch vielfältig Auskunft geben kann.

Für die Umsetzung in einer Produktionsumgebung muss noch geklärt werden, wie geänderte Daten aus der Datenbank zurück in den Suchbestand überführt werden können. Alternativen sind regelmäßig vollständige Exporte aus der Datenbank oder aber die Übertragung einzelner Datensätze per Datenbanktrigger. Nur so kann gewährleistet werden das die Suche stets aktuelle Resultate liefert.