Elasticsearch

Als Java developer kom je regelmatig projecten tegen waar “iets” met zoeken moet worden gedaan. Een veelgebruikte oplossing voor dit zoeken, is het open source project Elasticsearch. In dit artikel lees je hoe je Elasticsearch gebruikt in jouw Java-project.

In 2010 werd de eerste versie van de zoekmachine Elasticsearch beschikbaar gesteld door Shay Banon. Twee jaar later was daar het bedrijf Elastic. Elasticsearch werd van de grond af opgebouwd als een schaalbare, gedistribueerde zoekoplossing. Doel van de oplossing was om het mogelijk te maken om vanuit allerlei talen gemakkelijk met Elasticsearch te kunnen praten. Om Elasticsearch voor meerdere programmeertalen beschikbaar te maken, werken ze voornamelijk met clients, die gebruik maken van de REST-interface op basis van HTTP en JSON. Zo zijn er onder andere clients voor JavaScript, Python, PHP en .Net. Vanuit Java is er ook een client, maar deze maakt geen gebruik van REST (hierover later meer).

Ter ondersteuning van het artikel heb ik een voorbeeld gemaakt, waarin we alle artikelen van het Java Magazine indexeren in Elasticsearch en deze doorzoekbaar maken. https://github.com/luminis-ams/java-magazine-Elasticsearch.

 

Installeren van Elasticsearch

De installatie van Elasticsearch is niet ingewikkeld. Voor een productieomgeving moet je nadenken over het ontwerp van je cluster. Hoeveel nodes je nodig hebt, geheugen, diskruimte, etc. Voor dit artikel ga ik uit van een lokaal draaiend cluster van één node. Het mooie van Elasticsearch is dat het voor het programmeren niets uitmaakt of je tegen een lokaal cluster van één node praat of tegen een productiecluster van 60 nodes.

De installatie is zo eenvoudig als het downloaden van een zip [1], dit uitpakken en starten door in de uitgepakte directory het command bin/elasticsearch uit te voeren. Lees eerst nog even de volgende paragraaf, voordat je Elasticsearch opstart. Ik heb ook een Docker compose file in de Github repository geplaatst in de Docker map. Ik maak gebruik van de Docker images, die door Elastic beschikbaar worden gesteld [2]. Opstarten met docker-compose up in de folder met de config file docker-compose.yml.

Wanneer je de zip hebt uitgepakt, dan zijn er twee files waar je normaal gesproken aanpassingen doet. De eerste file is config/elasticsearch.yml. Hierin staan properties als “cluster.name” en “node.name”. Om straks met Java te kunnen verbinden, moeten we de naam van het clusteren veranderen in playground. Er zijn nog meer properties, die interessant kunnen zijn. Zo heb je “repo.path” als je met snapshot/restore functionaliteit aan de slag wilt gaan. De tweede file waar je naar kunt kijken is jvm.options. Belangrijkste settings hier zijn -Xms2g en -Xmx2g. Als je meer dan wel minder geheugen nodig hebt, kun je dat hier aanpassen. Voor Docker hebben we deze parameters al goed gezet. Nu kun je Elasticsearch opstarten. Uiteraard kun je controleren of je cluster goed is opgestart. Dit kan in een browser, met curl of elke andere HTTP client. Ga naar deze url en je zou een JSON response moeten krijgen met basisinformatie, zoals de naam van de node, het cluster en de versie van Elasticsearch. Verifieer of je cluster_name inderdaad playground is.

Nu we een lokaal cluster hebben draaien, gaan we een verbinding opzetten vanuit een Java applicatie.

 

Verbinding met Elasticsearch in een Java applicatie

Vanuit een Java applicatie heb je twee keuzes om met Elasticsearch te verbinden. Je kunt ten eerste met het REST endpoint verbinden. Deze draait standaard op port 9200. De tweede mogelijkheid is om direct met het binary protocol te praten vanuit Java. Deze praat tegen port 9300. Het voordeel van het gebruik van REST is dat je minder afhankelijk bent van de exacte versie van Elasticsearch waar je tegen praat. Wanneer je gebruik maakt van het binary protocol (Transport Client), dan moet deze exact hetzelfde zijn als die van het cluster waarmee je praat om problemen te voorkomen.

Voor Java is er momenteel een low level REST client beschikbaar [3]. Met behulp van deze client kun je verbinding maken met de REST endpoint. Er is echter nog geen ondersteuning voor de query DSL of response object parsing. Er wordt wel gewerkt aan een high level client, die dit wel gaat ondersteunen.

Kun je niet wachten op de door Elasticsearch ondersteunde REST client? Dan is er een 3rd party project, dat gebruik maakt van de REST endpoint. [4] Dit project wordt echter niet ondersteund door Elasticsearch zelf, maar het wordt in de markt wel veel gebruikt.

In dit artikel zullen we gebruik maken van de Transport Client, omdat deze nog steeds het meest volledig is qua functionaliteit. Het opzetten van een client gebeurt in de class ElasticClientFactory. De code voor het aanmaken van een client staat in Listing 1. Tijdens het aanmaken moet je een clusternaam meegeven en een string met daarin de hosts waarmee je wilt verbinden. In ons geval is het een eenvoudige string “localhost:9300”.

 


private Client createClient() {
    Settings settings = Settings.builder()
            .put("cluster.name", clusterName)
            .build();
 
    TransportClient client = new PreBuiltTransportClient(settings);
 
    try {
        client.addTransportAddresses(
getTransportAddresses(unicastHosts));
    } catch (UnknownHostException e) {
        logger.error("Problem while creating a client for Elasticsearch",
e);
        throw new ElasticConfigException("Could not create Elasticsearch
client");
    }
 
    return client;
}

 

Nu kunnen we een client opvragen met de volgende code:

 


ElasticClientFactory clientFactory = new ElasticClientFactory("playground", "localhost:9300");
Client client = clientFactory.obtainClient();

 

In de volgende paragrafen gaan we deze client gebruiken om met Elasticsearch te kunnen praten.

 

Praten met Elasticsearch: de API’s

Elasticsearch heeft verschillende API’s voor taken, die je uit kunt voeren. Zo zijn er onder andere de Search API voor het zoeken in de geïndexeerde documenten, Document API om de documenten te beheren en de Index API om de indexen te beheren.

Index API

Met deze API kun je indexen beheren. Om indexen te maken, moet je wel een aantal keuzes maken. Zo moet je beslissen uit hoeveel shards je index gaat bestaan. Dit kun je namelijk achteraf niet wijzigen. Als een index meer shards heeft, kun je de index over meerdere nodes verdelen. Dit betekent dat als je meer data wilt opslaan, je meer shards nodig hebt. Daarnaast kun je nog de replica shards configureren. Een replica is een copy. Hiermee kun je herstellen van een falende node, maar kun je ook meer queries tegelijkertijd aan. Er zijn nog veel meer settings, die je aan kunt passen. Het belangrijkste om nog te vermelden, is het specificeren van de mapping voor een index. In de mapping leg je vast hoe een document geïndexeerd wordt. Indexeren is feitelijk het vertalen van text in termen waarop gezocht kan worden. Ofwel het aanleggen van de inverted index. Zo’n mapping voor een veld bestaat uit een analyser per veld. Een analyser bestaat uit een tokeniser om teksten op te splitsen in tokens en filters om de tokens aan te passen. Voorbeeld van een tokeniser is de whitespace tokeniser, die tokens maakt op basis van spaties en tabs. Voorbeelden van filters zijn: lowercase, stop words en Stemmer filters.

In Listing 3 maken we een index en een alias aan. Omdat een index mapping, maar ook eigenschappen als aantal shards, niet aangepast kan worden, is het slim om alias te gebruiken. Idee is dat je een index aanmaakt met een timestamp en een alias. De alias gebruik je dan weer in je applicatiecode.

 


public void createIndex() {
    String indexName = INDEX_BASE + "-" +
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
 
    Settings settings = Settings.builder()
            .put("number_of_shards", 1)
            .put("number_of_replicas", 0)
            .build();
    CreateIndexRequest request = new CreateIndexRequest(indexName, settings);
 
    String mapping = "{\n" +
            "    \"article\": {\n" +
            "      \"properties\": {\n" +
            "        \"title\": {\n" +
            "          \"type\": \"text\"\n" +
            "        },\n" +
            "        \"author\": {\n" +
            "          \"type\": \"keyword\"\n" +
            "        },\n" +
            "        \"issue\": {\n" +
            "          \"type\": \"keyword\"\n" +
            "        },\n" +
            "        \"link\": {\n" +
            "          \"type\": \"keyword\"\n" +
            "        },\n" +
            "        \"description\": {\n" +
            "          \"type\": \"text\"\n" +
            "        },\n" +
            "        \"postDate\": {\n" +
            "          \"type\": \"date\",\n" +
            "          \"format\": \"yyyy-MM-dd\"\n" +
            "        }\n" +
            "      }\n" +
            "    }\n" +
            "  }";
 
    request.mapping("article", mapping, XContentType.JSON);
    request.alias(new Alias(INDEX_BASE));
 
    try {
        CreateIndexResponse createIndexResponse = this.client.admin().indices().create(request).get();
        if (!createIndexResponse.isAcknowledged()) {
            throw new ElasticExecutionException("Create java_magazine index was not acknowledged");
        }
    } catch (InterruptedException | ExecutionException e) {
        logger.error("Error while creating an index", e);
        throw new ElasticExecutionException("Error when trying to create an index");
    }
}

 

Bij het schrijven van de mapping kun je zien dat we twee soorten velden gebruiken: keyword en text. Keyword wordt niet geanalyseerd (de hele tekst wordt als 1 token gezien) en text wordt geanalyseerd met de standaard analyser. Nu we een index hebben, kunnen we documenten gaan indexeren.

Document API

Met behulp van de document API kun je documenten beheren in een index. In de meeste projecten gebruik ik Jackson om entity objecten om te zetten naar een JSON representatie, die door Elasticsearch geïndexeerd kan worden. Door een POST request te doen, geef je Elasticsearch de opdracht om zelf een ID te genereren. Als je extern de ID bepaalt, dan kun je een PUT request gebruiken. Hieronder een voorbeeld van het indexeren van een document.

 


public void indexArticle(Article article) {
    try {
        String articleAsString = objectMapper.writeValueAsString(article);
        client.prepareIndex(INDEX_BASE, "article").setSource(articleAsString, XContentType.JSON).get();
    } catch (IOException e) {
        throw new ElasticExecutionException("Error indexing document");
    }
}

 

Een ander belangrijke endpoint voor de document API is het bulk endpoint. Wanneer je meerdere acties wilt uitvoeren op een index, meerdere documenten wilt toevoegen, verwijderen of updaten, dan is de bulk endpoint een stuk efficiënter [5].

Search API

Het is mooi dat we nu weten hoe je indexen kunt aanmaken er hoe je er documenten in kunt stoppen. Uiteindelijk is het ons echter toch te doen om het daadwerkelijk zoeken in deze documenten. Het doel is toch om dat document te vinden waar we naar op zoek zijn. Dat artikel waarin we geïnteresseerd waren om te lezen.

Het maken van een query in Elasticsearch is een vak apart. Je hebt enorm veel keuzes. Zo kun je kiezen uit meerdere beschikbare queries, zoals Match query, Query string query, Term query en Bool query. Er zijn meerdere manieren om een query naar Elasticsearch toe te sturen. Zo kun je een URL maken, waarin je een volledige query doet, maar je kunt ook een request body meesturen. In Listing 5 staan beide voorbeelden voor een eenvoudige match query. Uiteraard kunnen we ook in Java zo’n zelfde query schrijven. Ook die code staat in Listing 5.

 


GET articles/_search?q=title:java
 
GET articles/_search
{
  "query": {
    "match": {
      "title": "java"
    }
  }
}
 
public List<Article> searchArticlesBy(String searchString) {
  QueryBuilder queryBuilder = matchQuery(“title”,searchString);
  SearchResponse searchResponse = client.prepareSearch(INDEX_BASE).setQuery(queryBuilder).get();
    SearchHit[] hits = searchResponse.getHits().hits();
 
    return Arrays.stream(hits)
            .map(this::parseHitIntoArticle)
            .collect(Collectors.toCollection(ArrayList::new));
}

 

Voordat je nu meteen in de complexere queries duikt, moet je eerst het verschil begrijpen tussen de query en de filter context. Het antwoord op een query context is in de vorm: “hoe goed matcht dit document op mijn gestelde vraag?” In tegenstelling tot de filter context waar het antwoord in de volgende vorm is: “matcht dit document mijn vraag?” Een voorbeeld: we zijn op zoek naar een artikel van Bert Jan Schrijver. Een artikel is van Bert Jan of niet. Dit is dus een Filter context. Nu heeft Bert Jan aardig wat artikelen geschreven voor Java Magazine. Ik meen me te herinneren, dat hij iets heeft geschreven over Jenkins en pipelines. Dus ik ga zoeken op die twee termen. Dan kan ik artikelen vinden, waarin slechts één van de termen voorkomt en artikelen waar ze allebei in voorkomen. Ook kunnen de termen in het ene document een keer voorkomen en in het andere document in de titel staan. Het ene document zal dus beter matchen dan het andere. Dit betekent dat we in de query context zitten.

Ik noemde eerder een Bool query. Dit is een samengestelde query. Hier kun je combinaties maken van bijvoorbeeld een Match query en een Term query. Ook kun je weer Bool queries in een Bool query stoppen. Zo kun je complexe queries samenstellen. Stel, je publiceert artikelen voor meerdere magazines. Dan wil je op de website van een magazine, dat gebruikers alleen in die artikelen zoeken. De query in Listing 6 zoekt alleen in artikelen van Java magazine, geschreven door Bert Jan Schrijver over Jenkins.

 


public List<Article> searchAndFilterAuthorArticlesBy(String author, String searchString) {
   QueryBuilder queryBuilder = boolQuery()
            .must(multiMatchQuery(searchString, "description", "title"))
            .filter(termsQuery("author", author));
 
    SearchResponse searchResponse = client.prepareSearch(INDEX_BASE).setQuery(queryBuilder).get();
    SearchHit[] hits = searchResponse.getHits().hits();
 
    return Arrays.stream(hits)
            .map(this::parseHitIntoArticle)
            .collect(Collectors.toCollection(ArrayList::new));
}
 
private Article parseHitIntoArticle(SearchHit hit) {
    try {
        return objectMapper.readValue(hit.getSourceAsString(), Article.class);
    } catch (IOException e) {
        logger.error("Error parsing article", e);
    }
    return null;
}

 

Ik hoop dat je nu een goed beeld hebt gekregen van Elasticsearch en hoe je met Java gebruik kunt maken van Elasticsearch. Met deze kennis moet je zelf aan de slag kunnen gaan. Vergeet niet om de codevoorbeelden op Github te bekijken. Hierin staan voldoende aanknopingspunten om zelf aan de slag te gaan.

 


Referenties
[1] - https://www.elastic.co/downloads/elasticsearch
[2] - https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
[3] - https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/index.html
[4] - https://github.com/searchbox-io/Jest/tree/master/jest
[5] - https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/java-docs-bulk.html