Über unsMediaKontaktImpressum
Mike Lohmann & Moritz Mann 15. Dezember 2015

Prototyping einer Elasticsearch Application

Die Zeiten, in denen Teams über einen langen Zeitraum neue Ideen entwickeln und anschließend bis ins kleinste Detail durchplanen, um diesen Plan dann von einem Entwicklerteam umsetzen zu lassen, sind vorbei. Time-to-market [1] ist heute der entscheidende Faktor bei der Erstellung von Applikationen. Denn das Risiko, dass Applikationen am Ende von den erdachten Zielgruppen gar nicht so genutzt werden, wie angenommen, ist riesig – und vermeidbar! Das Risiko wird minimiert, indem eine Idee schnell am Markt getestet wird: Rapid Prototyping. Alle Features werden in Bezug zum Nutzwert gesetzt und dabei die Idee aufs absolute Minimum Viable Product (MVP) reduziert. Das Feedback der ersten Nutzer zeigt, ob der eingeschlagene Weg das Potential hat, am Markt zu bestehen.

Natürlich spielen Faktoren wie Softwarequalität, Datenintegrität und -sicherheit weiterhin eine große Rolle. Damit diese Faktoren dem Rapid Prototyping nicht entgegenstehen, braucht es ein Team von Spezialisten mit Erfahrung. Wir bauen Prototypen, die trotz der schnellen Markteinführung (Markteintritt innerhalb von Tagen statt Monaten) hohen Qualitätsansprüchen genügen. Das Geheimnis: ein religiöser Pragmatismus in der Auswahl von MVP-Funktionen, standardisierte Abläufe sowie ein passendes Technologie-Toolset.

Nach diesem Prinzip haben wir Stadtsalat.de [2] mit einer Time-to-market von 4 Tagen entwickelt. Anhand des ersten Kundenfeedbacks konnten wir schnell feststellen, dass es sich (a) lohnen würde, mehr Zeit, Geld und Ressourcen für die Weiterentwicklung zu verwenden und (b) erste Erfahrungen hinsichtlich der Conversion auf verschiedenen Marketingkanälen sammeln. Gerade letzteres lässt sich im Business-Plan nicht vorhersehen.

Betreiben von Prototypen

Die Entwicklung von Prototypen setzt voraus, dass die passende Infrastruktur schnell
erstellt und einsatzbereit ist. Je nach Projekt und Technologiestack greifen wir auf unterschiedliche Tools zurück, um die lokale Entwicklungsumgebung, Stage, Prod und ggf. andere Umgebungen aufzubauen.

Die Konfiguration der Umgebungen erfolgt mit Terraform [3]. Terraform ermöglicht es, über Provider, z. B. Heroku [4] oder AWS [5], Services zu konfigurieren und anschließend zu erstellen. Dabei kann eine Konfiguration über Variablen zum Erstellen von neuen Umgebungen genutzt werden. Für Heroku oder AWS lassen sich Services wie Redis, MySQL, MongoDB oder Elasticsearch zu einem Node einer Umgebung hinzufügen. Das bedeutet, dass wir uns im Zeitdruck des Prototypings nicht um den Betrieb kümmern müssen, sondern einfach spezialisierte Dienste nutzen. Das bringt zwar Nachteile mit sich in Bezug auf Kosten und Freiheit bezüglich Versionen und Plugins – in den meisten Fällen überwiegen jedoch die Vorteile.

In einigen Projekten wird neben Terraform auch Puppet [6] verwendet, um die erzeugten
einzelnen Maschinen für das Projekt zu konfigurieren. Lokale Entwicklungsumgebungen bauen wir mit Docker [7] oder mit Vagrant [8] und Puppet auf. Gerade Docker in Verbindung mit Heroku ermöglicht den schnellen Aufbau lokaler Umgebungen, die sehr ähnlich zu denen im Betrieb sind. Natürlich müssen dafür einige Konventionen bei der Entwicklung eingehalten werden. Wir folgen dazu den 12factors [9].

Elasticsearch im Betrieb

Elasticsearch haben wir anfangs selbst betrieben und bemerkt, dass der Wartungsaufwand relativ hoch war. Im Zuge der Prototypenentwicklung wurden verschiedene Anbieter ausprobiert und seit einiger Zeit nutzen wir Bonsai [10] für die Bereitstellung von Elasticsearch als Dienst. Bei anfänglich relativ geringen Nutzerzahlen lohnt sich im Sinne eines schnellen Setups die Nutzung von spezialisierten Anbietern. Bei größeren Nutzerzahlen kann es sinnvoll sein, Elasticsearch selbst zu betreiben. In jedem Fall ist es ratsam, genau zu prüfen, welche Vor- und Nachteile die Nutzung eines Services hat. Wir sind beispielsweise dazu übergegangen, immer eigene Backuplösungen zu nutzen, um im Falle eines Problems Daten schnell recovern zu können.

Elasticsearch im Projekt

In einem Projekt wurde eine Applikation zur Aggregation von Newsfeeds zum Thema Automobile als Protoyp umgesetzt. Nutzer geben Themen an, denen sie folgen möchten: die VW-Abgasaffäre, die Formel 1 oder Elektromobilität. Diesen Themen werden automatisch einzelne News aus einer Liste von mehr als 200 Feeds zugeordnet und dem Nutzer in seinem persönlichen Newsstream angezeigt.

In diesem Projekt haben wir Elasticsearch eingesetzt, um schnell Ergebnisse zu erzielen. Elasticsearch ist ein Aufsatz auf die Suchmaschine Lucene, der es ermöglicht, über eine sehr umfangreiche API einerseits die Indizierung der Inhalte zu konfigurieren und zum anderen Suchabfragen einfach über JSON zu strukturieren und zu konfigurieren. Es hat sogenannte Indizes, die man mit Datenbanken aus dem SQL-Umfeld vergleichen kann. Jeder Index kann über eine einfach JSON-Syntax konfiguriert werden. Die Konfiguration unterteilt sich in Settings und Mapping.

Mit den Settings werden grundsätzliche Eigenschaften und Funktionen des Indexes konfiguriert. Das Mapping konfiguriert, wie die Felder eines zu indizierenden Dokumentes für eine Suche aufbereitet werden sollen. In unserem Beispiel beinhalten die Settings die Konfiguration, welche Analyzer zum Zeitpunkt des Indizierens eines Dokuments oder für die Suche nach Dokumenten neben den Standards zur Verfügung stehen. Die im Setting konfigurierten Analyzer können dann im Mapping verwendet werden.

Beispiel 1: Settings


{
      "index" : {
        "analysis" : {
          "char_filter" : {
            "special_chars_filter":{
              "type":"pattern_replace",
              "pattern":"[^\\w]",
              "replacement":" "
            }
          },
          "filter" : {
            "shingle" : {
              "max_shingle_size" : "4",
              "min_shingle_size" : "2",
              "output_unigrams_if_no_shingles" : "true",
              "type" : "shingle",
              "output_unigrams" : "true"
            },
            "german_stop" : {
              "type" : "stop",
              "stopwords" : "_german_"
            },
            "german_stemmer" : {
              "type" : "stemmer",
              "language" : "light_german"
            },
            "entity_synonym" : {
              "ignore_case" : "false",
              "type" : "synonym",
              "synonyms" : [ "VW => Cheater", "BMW => Electric", "Mercedes, Mercedes Benz => Mercedes-Benz" ]
            },
            "german_keywords" : {
              "keywords" : [ "Cheater", "Electric", "Porsche", "Mercedes-Benz", "Volvo" ],
              "type" : "keyword_marker",
              "ingnore_case" : "false"
            },
            "entity_keepwords" : {
              "type" : "keep",
              "keep_words_case" : "false",
              "keep_words" : [ "Cheater", "Electric", "Porsche", "Mercedes-Benz", "Volvo" ]
            }
          },
          "analyzer" : {
            "entity_analyzer" : {
              "char_filter": ["special_chars_filter"],
              "filter" : [ "german_stop", "shingle", "entity_synonym", "shingle", "entity_keepwords" ],
              "tokenizer" : "whitespace"
            },
            "german_analyzer" : {
              "filter" : [ "lowercase", "german_stop", "german_keywords", "german_normalization", "german_stemmer" ],
              "tokenizer" : "whitespace"
            }
          }
        },
        "type" : "article"
      }
}

Beispiel 2: Mapping

{
      "article" : {
        "_all" : {
          "auto_boost" : true,
          "analyzer" : "german_analyzer"
        },
        "properties" : {
          "active" : {
            "type" : "boolean"
          },
          "articleId" : {
            "properties" : {
              "DataType" : {
                "type" : "string"
              },
              "StringValue" : {
                "type" : "string"
              }
            }
          },
          "articleVersion" : {
            "type" : "integer",
            "include_in_all" : false
          },
          "description" : {
            "type" : "string",
            "copy_to" : [ "entities.name" ],
            "include_in_all" : true
          },
          "entities" : {
            "include_in_all" : false,
            "properties" : {
              "_id" : {
                "type" : "string",
                "include_in_all" : false
              },
              "name" : {
                "type" : "string",
                "boost" : 4.0,
                "analyzer" : "entity_analyzer",
                "include_in_all" : false
              },
              "type" : {
                "type" : "string",
                "index" : "not_analyzed",
                "include_in_all" : false
              }
            }
          },
          "headline" : {
            "type" : "string",
            "boost" : 3.0,
            "copy_to" : [ "entities.name" ],
            "include_in_all" : true
          },
          "publishingTime" : {
            "type" : "date",
            "format" : "dateTime",
            "include_in_all" : false
          },
          "stories" : {
            "include_in_all" : false,
            "properties" : {
              "_id" : {
                "type" : "string",
                "index" : "not_analyzed",
                "include_in_all" : false
              }
            }
          },
          "teaser" : {
            "type" : "string",
            "boost" : 2.0,
            "copy_to" : [ "entities.name" ],
            "include_in_all" : true
          }
        }
      }
}

 

Vorab sollte sicher sein, welche Datenstrukturen wie aufgebaut sein werden, damit der
Suchindex richtig konfiguriert ist. Falls sich später herausstellt, dass einige Felder einem
Dokument hinzugefügt werden müssen, kann der Index sehr einfach um diese Felder
erweitert werden und die Konfiguration des Mappings angepasst werden. Schwieriger ist die Änderung vorhandener Felder. Das funktioniert zumeist nur mit einer Reindizierung aller vorhandenen Inhalte. Da eine Reindizierung in Produktion relativ zeitaufwändig ist, ist dies soweit wie möglich zu vermeiden.

Mit dem Wissen um die Problematik einer späteren Reindizierung sollte zunächst stets ein Datenschema erstellt werden. Dazu nutzten wir in diesem Projekt swagger [11]. Auf Basis dieses Schemas konnten wir dann das Mapping für den Elasticsearch Index erstellen.

Indizierung von Inhalten

  1. Neue Artikel werden jede Minute aus den mehr als Quellen abgeholt.
  2. Jeder Artikel wird in das definierte Schema umgeschrieben und danach validiert.
  3. War die Validierung erfolgreich, wird der Artikel in eine Queue geschrieben.
  4. An diese Queue ist der Analyzer Service angeschlossen und liest bis zu 20 Artikel parallel.
  5. Jeder dieser Artikel wird in Elasticsearch indiziert.

Ab hier wird das Mapping relevant. Jeder Artikel hat die Felder headline, teaser und description. Bei der Indizierung jedes Feldes wird der Inhalt mit copy_to in das Feld entities kopiert. Im Mapping (Bsp.: 2) ist für das Feld entities der in den settings (Bsp.: 1) definierte entity_analyzer als analyzer definiert. So läuft der gesamte String aus den definierten Feldern (headline, teaser, description) "durch" den entity_analyzer.

Im entity_analyzer werden dann die in den settings definierten Filter auf den jeweiligen String angewendet. Am Ende bleiben dann die entities übrig, die im String existieren und werden dem Dokument zugeordnet.

In Bsp. 3 kann das Verhalten nachvollzogen werden. Im Ergebnis ist zu sehen, dass im Feld entities des in Bsp. 3 verwendeten Inhalts bei der Indizierung die Token Mercedes-Benz, Porsche, Cheater, Electric und Volvo stehen würden. Das heißt, dieser fiktive Artikel ist nun diesen Themen zugeordnet.

Beispiel 3: Test des Analyzers

// Den Text kann man nun an den entity_analyzer weitergeben und sehen, was bei der Inditzierung passiert
curl -XGET "localhost:9200/article/_analyze?analyzer=entity_analyzer&pretty=true" -d "Oh Lord, won`t you buy me a Mercedes Benz ?
My friends all drive Porsche I must make amends.
Worked hard all my lifetime, no help from my friends,
So Lord, won`t you buy me a Mercedes Benz ?
I wait for delivery each day until three,
So oh Lord, won`t you buy me a colored VW ?
Oh Lord, won`t you buy me a BMW in the town ?
I`m counting on you, Volvo, please don`t let me down."
{
  "tokens" : [ {
    "token" : "Mercedes-Benz",
    "start_offset" : 28,
    "end_offset" : 36,
    "type" : "SYNONYM",
    "position" : 33
  }, {
    "token" : "Porsche",
    "start_offset" : 65,
    "end_offset" : 72,
    "type" : "word",
    "position" : 57
  }, {
    "token" : "Mercedes-Benz",
    "start_offset" : 175,
    "end_offset" : 183,
    "type" : "SYNONYM",
    "position" : 149
  }, {
    "token" : "Cheater",
    "start_offset" : 351,
    "end_offset" : 353,
    "type" : "SYNONYM",
    "position" : 301
  }, {
    "token" : "Electric",
    "start_offset" : 384,
    "end_offset" : 387,
    "type" : "SYNONYM",
    "position" : 337
  }, {
    "token" : "Volvo",
    "start_offset" : 423,
    "end_offset" : 428,
    "type" : "word",
    "position" : 373
  } ]
}

 

Matchen von Inhalten zu Stories

Wenn ein Artikel erfolgreich indiziert wurde, läuft der Verarbeitungsprozess weiter zum StoryMatcher. Dieser Teil der Applikation ist dafür zuständig, einlaufende neue Artikel mit vorhandenen Artikeln zu einer Story zusammenzufassen. In diesem Schritt nutzen wir wieder das Featureset von Elasticsearch, um passende Artikel zu finden.

Eine Stärke von Lucene [12] ist es, Inhalte auf Basis von Fragen nach Wichtigkeit sortiert zu finden. Für unsere Applikation ist Ähnlichkeit bei den Feldern entities, headline und teaser ein wichtiges Kriterium, um semantische Ähnlichkeiten in Artikeln zu finden. Die Applikation erstellt dynamisch aus den Feldern der einlaufenden Artikel Queries, die dann ähnliche, schon indizierte Artikel finden.

Bei der Erstellung der Queries werden einige Felder geboostet [13]. Das bedeutet, dass sie eine höhere Gewichtung (scoring [14]) bekommen, wenn in ihnen ein Treffer gefunden wird. Diese Gewichtung ermöglicht die Sortierung nach Relevanz. Das heißt: Je höher der Score eines gefundenen Artikels ist, desto besser passt dieser zu der Query. Weitere Regeln definieren dann, wievielen schon vorhandenen Artikeln der neue Artikel zugeordnet wird. Sobald zwei Artikel matchen, bilden sie eine Story.

Nach dem Durchlauf des gesamten Prozesses werden die neuen Artikel gespeichert und aus der Queue genommen. Den Einstellungen eines Users folgend sind die neuen Stories
und Artikel im Newsstream des Users verfügbar.

Fazit

Das Featureset und die einfache API von Elasticsearch haben wesentlich zum Erfolg des Projektes beigetragen. Elasticsearch lässt sich als Service sehr schnell in ein Projekt einfügen und kann daher auch bestehende Projekte bereichern. Die sehr umfangreichen Konfigurationsoptionen per JSON erlauben den Einsatz in maßgeschneiderten Softwarelösungen.

Wer Backup und Betrieb auslagern möchte, kann Elasticsearch als Service von einem Anbieter wie Bonsai extern einkaufen. Allerdings ist es ratsam in jedem Fall eine eigene Backupstrategie zu haben, die in kritischen Projekten ein schnelles Recovery ermöglichen. Das Verstehen und Kennenlernen der API und des Featuresets von Elasticsearch benötigt ein wenig Zeit. Wir entscheiden uns immer wieder für diese Technologie. In einigen Projekten auch als Hauptdatenbank.

Autoren

Moritz Mann

Moritz Mann, 28 Jahre, studierte International Business an der Maastricht University. Er ist Gründer von Feelgood (mittlerweile stillgelegtes Projekt), Protofy und Stadtsalat.
>> Weiterlesen

Mike Lohmann

Mike Lohmann, 38 Jahre, ist Informatiker und arbeitet als Entwickler bei Protofy. Zuvor war er als Entwickler bei Lycos Europe und als Softwarearchitect bei Gruner + Jahr und ICANS tätig.
>> Weiterlesen
botMessage_toctoc_comments_9210