Umbraco und Elasticsearch
Freitag, 10.01.2020
Möchte man in einer auf Umbraco basierenden Webanwendung eine Suche implementieren, stellt sich zunächst die Frage ob der in Umbraco bereits implementierte Suchprovider „Examine“ oder irgendein anderer Suchprovider verwendet werden soll.
Examine hat den Vorteil, dass alle Inhalte von Knoten aus dem Content-Bereich automatisch indiziert werden (zumindest bei Verwendung der Standard-Datentypen) und man sehr schnell eine schmale und funktionsfähige Suchfunktion im Frontend der Webanwendung implementieren kann. Allerdings hat Examine auch einige Nachteile.
Umbraco 8 verwendet die Lucene.net Version 3.0.1, welche vor 2012 veröffentlicht wurde. Lucene-Indizies können derzeit nicht in einen Blob Storage ausgelagert werden und auch bei Verwendung eines Lastenausgleichs für die Webanwendung gibt es Einschränkungen. Beim erneuten Indizieren eines vorhandenen Index durch Examine gibt es immer einen kurzen Zeitraum, in welchem bestimmte Dokumente nicht im Index zur Verfügung stehen.
Welche Möglichkeiten haben wir nun, eine komplexe Suche für eine Webseite in Umbraco 8 zu entwickeln? Anforderung ist es, mittels eines einzigen Suchfeldes eine Suche in verschiedenen Indizes durchführen zu können, inklusive Autovervollständigung (auto suggest) und Unschärfe (fuzzy search).
Die einzelnen Indizes haben unterschiedliche Daten. Ein Index muss aus Produktdaten und ein Index aus Blogbeiträgen befüllt werden. Beide Arten von Daten werden jedoch in einer externen Webanwendung gepflegt und von dieser jeweils über eine Web-Api zur Verfügung gestellt. Ein dritter Index enthält klassischen Umbraco-Content und ein vierter PDF-Inhalte, die in der eigenen Umbraco-Instanz vorliegen.
Natürlich hätte man dies alles auch mit Examine umsetzen können, allerdings haben wir uns unter anderem aufgrund der zuvor genannten Nachteile und der besseren Erweiterbarkeit für eine Implementierung auf Basis von Elasticsearch entschieden. Zwei der vielen Vorteile von Elasticsearch gegenüber Examine sind, dass Elasticsearch auf einer aktuellen Lucene-Version basiert und wir bei einer Neu-Indizierung eines vorhandenen Indizes aufgrund des Alias-Konzeptes von Elasticsearch keine Downtime mehr haben.
Ein Lastenausgleich der gesamten Anwendung ist später möglich und durch die Nutzung des sogenannten .NEST-Clients für .NET wird die Entwicklung einfacher und wartbarer.
Zunächst haben wir geprüft, ob es bereits ein Umbraco-Package für Elasticsearch gibt. Tatsächlich gibt es ein solches, das Novicell.Examine.Elasticsearch-Package.
Dieses ist in wenigen Minuten installiert, konfiguriert und funktioniert auch mit der aktuellen Umbraco-Version 8.4.0. Im Prinzip ersetzt das Package den Examine-Provider durch einen neuen Elasticsearch-Provider und stellt eine gute Grundlage für eine gewöhnliche Volltextsuche dar. Auch das Grid-Layout wird gut in Elasticsearch indiziert. Jedoch verwendet dieses Package beim Indizieren in Elasticsearch auch einige Funktionalitäten von Examine.
Da wir aber von Examine vollkommen unabhängig sein wollten und nicht zuletzt, weil das zuvor genannte Package derzeit lediglich in einer Alpha-Version vorliegt, haben wir uns für eine vollständig individuelle Implementierung von Elasticsearch entschieden. So konnten wir auch selbst entscheiden, welche Daten von Content- oder Media-Nodes indiziert werden um die Indizes so schlank wie möglich zu halten.
Wir mussten nun zwar für die Implementierung der Suchfunktion eigene REST-Services entwickeln, aber das wäre uns ohnehin nicht erspart geblieben, weil das Frontend, das teilweise auf Vue.js basiert, keinen serverseitigen Razor-Code zur Verfügung hat. Der Aufwand war also nicht sehr viel höher.
Betrachten wir nun einmal im Folgenden die grundsätzlichen Implementierungsschritte.
Elasticsearch installieren
Zunächst einmal benötigt man einen Elasticsearch-Cluster, welcher aus mindestens einem Node bestehen muss. Dieser kann auf dem gleichen Webserver installiert werden, auf welchem sich die Webanwendung befindet oder auf einem eigenen Webserver, was eine bessere Lastverteilung mit sich bringt. Auch eine Nutzung mit Docker und Kubernetes ist möglich. Die englischsprachige Installationsanleitung findet man hier.
Lobenswert ist übrigens auch, dass die gesamte Dokumentation sehr ausführlich, übersichtlich und immer aktuell ist. Sobald eine neue Version von Elasticsearch erscheint, ist auch die Dokumentation bereits aktualisiert. Dies hat maßgeblich zum schnellen Implementierungserfolg beigetragen. Als Umbraco-Entwickler ist man das so leider nicht gewohnt, Elasticsearch geht hier also mit sehr gutem Beispiel voran.
Indizierung
Das folgende Beispiel demonstriert die Indizierung von Content-Nodes aus Umbraco in Elasticsearch. Wir benötigen zunächst ein eigenes SearchModel. Die Struktur dieses Models stellt auch die Struktur des Datensatzes im Index dar:
public class ContentSearchModel { public Guid Id { get; set; } public CompletionField Fulltext_Suggest { get ; set; } public string Suchkategorie { get; set; } public List<string> Breadcrumbs { get; set; } public string Topline { get; set; } public string Titel { get; set; } public string Vorschautext { get; set; } public string Url { get; set; } public string Bild { get; set; } public OtcKategorieModel OtcKategorie { get; set; } public string Content { get; set; } }
In unserem Beispiel haben alle unsere Content-Nodes eine Property für die Suchkategorie, eine Topline, einen Titel, einen Vorschautext, ein Bild, und einen Richtext Editor für den allgemeinen Text (Content). Diese Properties können wir nachher einfach unseren Properties im ContentSearchModel zuweisen. Alle anderen Properties (Id, FullText_Suggest, Breadcrumbs, Url) werden bei der Indizierung entweder von umbracospezifischen Systemproperties bezogen oder per Code deklariert.
Wichtig für unsere Autovervollständigung ist die Property „FullText_Suggest“. Diese muss vom Typ CompletionField sein, welcher aus dem Namespace „Nest“ kommt.
Für die nächsten Schritte benötigen wir das NuGet-Package „NEST“, welches über die Paket-Manager-Konsole in Visual Studio bezogen werden kann und auch Elasticsearch.Net gleich als Abhängigkeit mit installiert:
Als nächstes erstellen wir einen neuen ApiController und erzeugen uns einen ElasticClient im Konstruktor unseres Controllers.
public class SearchController : UmbracoApiController { private string _elasticUrl = ConfigurationManager.AppSettings.Get("elasticUrl"); private string _contentIndexName = ConfigurationManager.AppSettings.Get("elasticContentIndexName"); public SearchController() { var pool = new SingleNodeConnectionPool (new Uri(_elasticUrl)); var settings = new ConnectionSettings(pool, JsonNetSerializer.Default) .Defaultindex(_contentIndexName) .PrettyJson() .DefaultMappingFor<ContentSearchModel>(m => m.IdProperty( p => p.Id)); ElasticClient _client = new ElasticClient(settings); } }
Die Url des Elasticsearch-Servers, sowie der Name des Indizes wurde in der web.config hinterlegt und werden aus dieser ausgelesen. In den Einstellungen für den ElasticClient wird insbesondere das DefaultMapping konfiguriert. Wir verwenden hier das zuvor erstellte ContentSearchModel und legen die IdProperty fest. Da die Property „Id“ in unserem Model die jeweilige Guid des Content-Knotens ist, wird die Id des jeweiligen Elasticsearch Dokumentes nun ebenfalls diese Guid sein. Ohne diese Zuweisung würde die Indizierung in Elasticsearch automatisch mit der Id = 1 beginnen und diese hochzählen.
Durch Verwendung der Guid haben wir den Vorteil, dass wir uns anhand der Guid jederzeit das richtige zu einem Umbraco-Knoten zugehörige Elasticsearch-Dokument abrufen und den Datensatz ansehen können.
Nun erstellen wir uns in unserem ApiController eine neue Methode, welche den Index erstellen soll:
[HttpPost ] public async Task <object> CreateGeneralContentindex() { var _contentIndexName = "content"; try { if (_client.Indices.Exists(new IndexExistsRequest(_contentIndexName)).Exists) { _client.Indices.Delete(new DeleteIndexRequest(_contentIndexName)); } var createContentIndexResponse = await _client.Indices.CreateAsync(_contentindexName, i => i .Map<ContentSearchModel>(m => m .AutoMap() .Properties(ps => ps .Completion(c => c .Name(p => p.FullText_Suggest) ) ) ) ); if (createContentIndexResponse.IsValid) { return IndexingContent(); } else { return createContentIndexResponse.OriginalException; } } catch(Exception ex) { return ex.Message; } }
Es wird zunächst geprüft, ob es einen Index mit diesem Namen bereits gibt und, falls ja, wird dieser gelöscht um den Index komplett neu zu erstellen. Beim Erstellen des Index durch den Client ist hier besonders wichtig, dass beim Properties-Objekt ein neues Completion-Objekt für unsere Autovervollständigung hinzugefügt wird und dass die Property vom Typ CompletionField unseres ContentSearchModels zugewiesen wird.
Als Antwort erhalten wir vom NEST-Client einen sogenannten CreateIndexResponse. Wenn dieser fehlerhaft ist, geben wir die entsprechende Exception zurück, andernfalls rufen wir die Methode „IndexingContent“ auf, welche wir im nächsten Schritt erstellen um den neu erstellten Index initial mit Daten zu befüllen.
Zuvor ist es aber wichtig, folgende Anforderungen für das weitere Verständnis zu kennen:
Erstens, wir möchten nur Umbraco-Knoten vom Dokumententyp „TextPage“ indizieren und zweitens, es soll für jeden Knoten von diesem Dokumententyp eine Kategorie ausgewählt werden können, wahlweise soll die Indizierung des Knotens gänzlich unterbunden werden können:
In der neuen IndexingContent-Methode legen wir zunächst unser Suchkriterium fest und durchlaufen dann alle Root-Knoten unserer Instanz. Da wir mehrere Root-Knoten haben, jedoch nicht alle indiziert werden dürfen, rufen wir erst bei jedem indizierbaren Root-Knoten eine neue Methode IndexingContentNodes auf und fügen den Response zu einer Liste vom Typ ContentSearchModel hinzu. Diese Liste wird am Ende dann komplett durch unseren ElasticClient indiziert.
public object IndexingContent() { Func<IPublishedContent, bool> contentNodeSearchCondition = (x => (x.ContentType.Alias == "TextPage" && x.Value<IPublishedContent>("suchkategorie")?.Name != "KEINE Indizierung") ); List<ContentSearchModel> datalist = new List<ContentSearchModel>(); foreach (var content in Umbraco.ContentAtRoot().Where(contentNodeSearchCondition)) { datalist.AddRange(_indexingHelper.IndexingContentNodes(content, contentNodeSearchCondition)); } var indexResponse = _contentClient.IndexManyAsync(datalist); return indexResponse.Result.IsValid; }
/// <summary> /// Kann einzelnen Content-Node (durch Eventhandler) oder alle Content-Nodes, welche Condition erfUllen indizieren /// </summary> /// <param name="node">Einzelner Node</param> /// <param name="condition">Bedingung zum Indizieren</param> /// <param name="datalista">Liste aller Nodes zum indizieren (wird fur rekursiven Aufruf benoetigt)</param> /// <param name="isSingleNode">Ist true, wenn Request von Contentservice.Event kommt</param> /// <returns></returns> public List<ContentSearchModel> IndexingContentNodes(IPublishedContent node, Func<IPublishedContent, bool> condition, bool isSingleNode = false) { List<ContentSearchModel> dataList = new List<ContentSearchModel>(); dataList.Add(GetContentSearchData(node, condition)); if (!isSingleNode) { var descendantNodes = node.Descendants().Where(condition); if (descendantNodes.Any()) { foreach (var item in descendantNodes) { dataList.Add(GetContentSearchData(item, condition)); } } } return dataList; }
Der optionale Parameter isSingleNode wird nur durch den entsprechenden Eventhandler auf true gesetzt. Somit wird nur ein einzelner Knoten zu unserer Liste hinzugefügt. Im anderen Fall werden alle Unterknoten durchlaufen, welche unsere Bedingung erfüllen, und diese zur Liste hinzugefügt. Da der letzte Anwendungsfall nur beim initialen Erstellen des Index oder bei Bedarf im Fehlerfalle extrem selten durchgeführt wird, kann man an dieser Stelle ausnahmsweise die Performanceaspekte vernachlässigen, die sich in Umbraco ergeben, wenn man rekursiv den Dokumentbaum durchläuft.
Der jeweilige Knoten wird jedoch noch, bevor wir diesen an unsere Liste für die Indizierung übergeben, in der Methode GetContentSearchData optimiert und konvertiert. Wir verwenden hierzu den AutoMapper.
private ContentSearchModel GetContentSearchData(IPublishedContent node, Func<IPublishedContent, bool> condition ) { ContentSearchModel searchData = _contentSearchMapper.Map<ContentSearchModel>(node); return searchData; }
Und aufgrund der vielen Umwandlungen verwenden wir einen eigenen Konverter:
public class ContentSearchModelConverter : ITypeConverter<IPublishedContent, ContentSearchModel> { public ContentSearchModel Convert(IPublishedContent source, ContentSearchModel destination, ResolutionContext context) { if (source!=null) { var generalContent = GridHelper.GetContent(source, Current.UmbracoHelper); var indexingHelper = new SearchIndexingHelper(); var otcKategorie = !string.IsNullOrEmpty(source.Value<string>("otcKategorie")) ? JsonConvert.DeserializeObject<otcKategorielModel>(source.Value<string>("otcKategorie")) : new otcKategorieModel(); var suchKategorie = source.Value<IPublishedContent>("suchkategorie")?.Name; var titel = !string.IsNullOrEmpty(source.Value<string>("teaserTitel")) ? source.Value<string>("teaserTitel") : source.Name; var topline = source.Value<string>("teaserKategorie"); var vorschautext = source.Value<string>("teaserText"); return new ContentSearchModel { Id = source.Key, Bild = source.Value<IPublishedContent>("teaserBild")?.Url, otcKategorie = otcKategorie, otcKategorieFarbe = otcKategorie?.Farbe, otcKategorield = otcKategorie == null ? "otcKategorieDefault" : otcKategorie?.Id?.Replace(".","-"), Suchkategorie = suchKategorie, Titel = titel, Topline = topline, Url = source.Url, Vorschautext = vorschautext, Breadcrumbs = BreadcrumbHelper.GetBreadcrumbs(source.Path, Current.UmbracoHelper), Content = generalContent, fullText_Suggest = indexingHelper.GetContentFullTextSuggest(source) }; } return destination; } }
In diesem Konverter werden weitere Methoden aufgerufen, wie z.B. ein eigens entwickelter GridHelper um den Inhalt des GridLayouts für unsere Suchindizierung individuell aufzubereiten, eine Methode, um die Breadcrumbs zu erstellen, die direkt im Index abgespeichert werden, und um das Feld „FullText_Suggest“ für unsere Autovervollständigung zu befüllen. In letzterer Methode müssen alle Wörter dem Feld „FullText_Suggest“ zugewiesen werden, welche über die Autovervollständigung für diesen Knoten gefunden werden sollen.
Wenn man den Index nun initial erstellen lässt und sich den Datensatz eines Knotens im Index anschauen möchte, kann man dies ganz einfach per URL-Aufruf machen.
Die URL setzt sich dabei wie folgt zusammen:
https://[ElasticServerUrl:Port]/[indexName]/_doc/[id]
Hier ein konkretes Beispiel:
Wie man sieht, kann man also sehr einfach und schnell an jeden Datensatz im Index gelangen. Hier verbirgt sich möglicherweise ein Sicherheitsproblem. Man sollte sich sehr gut überlegen, ob nicht sensible Daten in Elasticsearch indiziert werden und ob man eine Authentifizierung, Verschlüsselungstechniken oder ähnliches benötigt, um den Zugriff auf diese Daten zu begrenzen.
In unserem Beispiel indizieren wir nur solche Daten, die ohnehin öffentlich auf einer Webseite zur Verfügung stehen, sodass wir uns hier um Sicherheitsaspekte keine großen Gedanken machen müssen.
Schauen wir uns nun noch zwei Beispiele an, wie wir an die Ergebnisse einer Autovervollständigungssuche und an die Ergebnisse einer Volltextsuche kommen.
Autovervollständigung
In unserem ApiController fügen wir eine neue Methode hinzu, welche durch das Frontend bei jeder Veränderung (Zeicheneingabe oder -löschung) im Sucheingabefeld aufgerufen wird.
public async Task<SuggestResponse> ContentSuggestFullText(string keyword) { if (keyword.Length >= 3) { ISearchResponse<ContentSearchModel> searchResponse = await _contentClient.SearchAsync<ContentSearchModel>(s => s .Index(_content IndexName) .Suggest( su => su .Completion(_contentSuggestName , c => c .Field( f => f.FullText_Suggest) .Prefix(keyword) .Fuzzy( f => f .Fuzziness(Fuzziness.Auto) ) .Size(5) ) ) ); return new SuggestResponse { Suggests = _indexingHelper.GetSuggests(searchResponse , _fullTextSuggestName) }; } else { return _indexingHelper.GetNullSuggest(); } }
Wir führen hier mit dem Suchbegriff (keyword) eine Suche auf unseren ContentIndex durch. Als Field müssen wir hierzu die Property FullText_Suggest unseres ContentSearchModels verwenden. Wir müssen eine Prefix-Suche durchführen, so dass bei einer Suche nach „her“ z.B. „Herz“ vorgeschlagen wird, aber „Herz“ nicht bei einer Suche nach „erz“. Außerdem aktivieren wir bei dieser Suche den Unschärfe-Modus (Fuzziness), da wir auch bei einer Suche nach z.B. „Ambroz“ trotz eines falschen Buchstabens den Begriff „Ambroxol“ vorgeschlagen bekommen möchten.
In der nun noch fehlenden GetSuggests-Methode wandeln wir einfach den SearchResponse in eine Liste vom Typ „Suggest“ um, welche dann in der zuvor genannten Methode „ContentSuggestFullText“ der Suggests-Property eines neues SuggestResponse-Objektes zugwiesen und an das Frontend zurück gegeben wird.
public IEnumerable<Suggest> GetSuggests(ISearchResponse<ContentSearchModel> searchResponse, string suggestType) { return from suggest in searchResponse.Suggest[suggestType] from option in suggest.Options select new Suggest { Guid = option.Source.Id, Name = option.Source.Titel, SuggestedName = option.Text, Score = option.Score, Url = option.Source.Url, SuggestType = "Content" }; }
Volltextsuche
In der folgenden, stark reduzierten Methode wird das Vorgehen ganz gut ersichtlich.
public async Task<ISearchResponse<ContentSearchModel>> ContentFullTextSearch(string keyword, int currentResultsCount) { int size = currentResultsCount == -1 ? 1000 : (currentResultsCount < contentResultsInitCount ? contentResultsInitCount : _contentResultsLazyLoadCount); int from = (currentResultsCount - _contentResultsInitCount) < 0 ? 0 : currentResultsCount; ISearchResponse<ContentSearchModel> searchResponse = await _client.SearchAsync<ContentSearchModel>(s => s .Index(_contentIndexName) .From(from) .Size(size) .Query(q => q .Bool(b => b .Should(o => o .Match(t => t .Field(f => f.Titel).Query(keyword) .Fuzziness(Fuzziness.Auto) .Boost(l)) , o => o .Wildcard(m => m .Field(f => f.Titel) .Value("*" + keyword + "*") .Boost(0.9)) , o => o .Match(t => t .Field(f => f.Content).Query(keyword) .Boost(0.3)) o => 0 .Wildcard(m => m .Field(f => f.Content) .Value("*" + keyword + "*") .Boost(0.2) ) ).MinimumShouldMatch(l) ) )); return searchResponse; }
Wir führen hier eine Suche auf dem ContentIndex durch und übergeben die aktuelle Seitenzahl und die gewünschte Anzahl an Suchergebnissen. Das Wesentliche geschieht hier bei der Deklaration unseres Query-SearchDescriptors. Mit dem BoolQueryDescriptor „Should“ wird zunächst definiert, dass alle nachfolgenden QueryDescriptorContainer optional sind und nicht alle Bedingungen erfüllt werden müssen. Am Ende wird jedoch mit MinimumShouldMatch(1) festgelegt, dass mindestens eine dieser Bedingungen erfüllt sein muss, um einen Treffer bei der Suche zu bekommen. Exemplarisch führen wir hier nur eine Suche auf die beiden Felder „Titel“ und „Content“ durch. Da wir eine Volltextsuche mit Unschärfe (Fuzziness) benötigen, müssen wir hier zunächst für jedes Feld eine Match-Suche mit Fuzziness durchführen und dann noch eine entsprechende Wildcard-Suche. Derzeit ist es leider mit Elasticsearch nicht möglich, eine Wildcard-Suche mit Fuzziness zu kombinieren, weshalb wir uns für dieses Vorgehen entschieden haben.
Nun sollte der Code natürlich noch um alle Felder erweitert werden, welche bei der Volltextsuche berücksichtig werden sollen. Mit der QueryDescriptorBase „Boost“ kann hier auch individuell die Wertigkeit verändert werden, um die Reihenfolge in der Suchergebnisliste zu beinflussen.
Fazit
Dieser Artikel soll lediglich ein grober Überblick sein, der zeigt, wie man Elasticsearch mit Umbraco verwenden kann und welche Schritte für eine Implementierung notwendig sind. Man muss selbstverständlich auch noch, wie bereits zuvor erwähnt, daran denken per Umbraco-Eventhandler die Knoten zu indizieren, aktualisieren und beim Löschen auch wieder aus dem Index zu entfernen.
Die Dokumentation von Elasticsearch befindet sich hier.