Comment requêter l'API Scroll d'Elasticsearch depuis une application Angular

Elasticsearch est un serveur permettant d'indexer et de rechercher des données de manière très performante. Il fournit un moteur de recherche distribué et multi-entité via une API REST.

Présentation API Scroll

La recherche de documents dans Elasticsearch peut retourner un grand nombre de résultats. De manière assez classique, nous sommes souvent intéressés par la possibilité de paginer ces résultats. Il est possible de gérer la pagination de façon manuelle dans les requêtes Elasticsearch grâce aux paramètres from et size de l’API Search.

Il existe également une autre façon, plus automatique de gérer la pagination : l'API Scroll. Le principe consiste à ouvrir un contexte de recherche pendant un certain intervalle de temps. La première requête retournera la première page de résultat et un identifiant scroll_id correspondant au contexte de recherche. La seconde requête à l'API Scroll, avec le paramètre scroll_id, retournera la deuxième page de résultats, et ainsi de suite.

A l'inverse d'une recherche avec pagination classique, les résultats retournés par l'API Scroll sont relatifs au contexte et aux données présentes dans Elasticsearch au moment où la première recherche est lancée. On peut comparer ça à un snapshot : les mises à jour de documents ultérieures à la requête ne seront pas reflétées dans les résultats. La gestion de nombreux contextes étant coûteuse en mémoire, Elasticsearch limite à 500 le nombre de contextes de recherche ouverts simultanément par l’API Scroll.

A l’inverse de l’API Get, l’API Scroll n’est pas real-time : si des données sont sur le point d’être indexées, il faut utiliser l’API Refresh sur les index concernés afin de s’assurer que les nouveaux documents seront bien dans le contexte de scroll.

Dans cet article, nous verrons comment requêter l'API Scroll d'Elasticsearch depuis une application Angular avec RxJS. Le but n'est pas d'expliquer comment installer Elasticsearch ni son fonctionnement, et nous supposons donc que vous disposez d'un serveur Elasticsearch prêt à l'emploi. En particulier, les CORS doivent être correctement configurés pour autoriser la connexion depuis votre application front.

Les requêtes seront faites de manière non sécurisées à l’API Elasticsearch. Il est conseillé d’installer un proxy comme Keycloak Gatekeeper avec authentification devant l’API pour en sécuriser son accès.

Si vous souhaitez vous lancer sur Elasticsearch en partant de zéro, n’hésitez pas à suivre le guide officiel. A l’heure où j’écris cet article, j’utilise la version 7.3.

Installation des dépendances

Nous allons installer la librairie qui va nous permettre de requêter l'API Elasticsearch ainsi que disposer des types associés. Je précise ici les numéros des versions utilisées lors de la rédaction de l’article.

npm install --save elasticsearch-browser@16.3.0

npm install --save-dev @types/request-promise@4.1.44

npm install --save-dev @types/elasticsearch@5.0.34

L'installation de la librairie client (première commande) permet de requêter les différentes méthodes de l'API en utilisant directement des fonctions portant le nom de l'API à requêter, évitant ainsi d'écrire manuellement les requêtes HTTP.

L'installation des types (deux dernières commandes) est un réel avantage car ils permettent, sans devoir parcourir toute la documentation d'Elasticsearch, d'avoir à disposition les paramètres d'entrée de l'API et la structure des objets de retour. Cependant, les types en version 5.0.34 ne sont pas parfaitement à jour avec l’API d’Elasticsearch 7.3 : j’ai rencontré un seul cas d’erreur pour l’instant (sur le nombre total de hits) que j’ai contourné en castant vers le type attendu.

A l’heure où j’écris cet article, la librairie elasticsearch-browser est en passe d’être dépréciée par Elasticsearch. C’est indiqué sur ce dépôt Github (branche 16.x, au début du readme). Comme l’explique leur article, Elasticsearch conseille de migrer vers leur nouvelle librairie pour Javascript (qui supporte également Typescript). Malheureusement, comme indiqué dans leur documentation, cette librairie n’est pas pensée pour être utilisée dans un navigateur, elle utilise des fonctions exclusives à Node.js. Ils justifient cela par des raisons de sécurité, mais annoncent dans le guide de migration qu’une version de la librairie pour les navigateurs est prévue. Dans l’attente de la sortie de cette nouvelle version, nous resterons donc sur la seule version fonctionnelle à l’heure actuelle.

Création d’un service Angular

Nous allons placer tout le code permettant de requêter Elasticsearch dans un service Angular dont voici le code. Je reviendrai sur le rôle des variables d’instance et des imports dans les sections suivantes de l’article.

import { Injectable } from '@angular/core';

import { EMPTY, from, Observable, of } from 'rxjs';
import { expand, last, map, switchMap, tap } from 'rxjs/operators';

import { Client, CountResponse, SearchResponse } from 'elasticsearch';
import { Client as ClientJs } from 'elasticsearch-browser';

@Injectable({
    providedIn: 'root'
})
export class ElasticsearchService {
    private static readonly NB_RESULTS_PAGE: number = 1000;
    private static readonly SCROLL_API_SEARCH_CONTEXT_DURATION: string = '1m';

    private client: Client;
}

Configuration du client

Avant de lancer n'importe quelle requête vers Elasticsearch, nous devons disposer d'une classe Client initialisée. Le paramètre minimal à renseigner est l'adresse sur laquelle est exposée votre instance d'Elasticsearch.

private getElasticsearchClient(): Client {
    if (!this.client) {
        // The name of the types in not the same as the package name, 
        // so we instantiate it with its JS import and use its TS type elsewhere.
        this.client = new ClientJs({
            host: 'http://localhost:8888/elasticsearch'
        });
    }
    return this.client;
}

C'est sur cet objet que nous appellerons les méthodes requêtant les différentes API exposées par Elasticsearch.

Comme tracé sur cette question dans Stackoverflow, le nom de la librairie de types étant différent de celui de la librairie, nous devons user d’un contournement au moment de l’import afin de pouvoir bénéficier des types dans notre IDE.

Récupération de la première page de documents

Nous allons écrire une première fonction getDocumentsWithScrollFirstPage qui va initialiser un contexte de scroll et retourner la première page de résultat.

/**
    * Fetch documents from Elasticsearch with pagination, for the first time.
    * @param queryBody an object describing the search query for Elasticsearch using the query DSL
    * @param documentIndex the index where documents are stored (if undefined, searches everywhere)
    * @return an observable containing the first results page from the Elasticsearch scroll API
    */
public getDocumentsWithScrollFirstPage<T>(queryBody: any, documentIndex?: string | Array<string>): Observable<SearchResponse<T>> {
    return from(this.getElasticsearchClient().search({
        index: documentIndex,
        scroll: ElasticsearchService.SCROLL_API_SEARCH_CONTEXT_DURATION,
        body: {
            size: ElasticsearchService.NB_RESULTS_PAGE,
            query: queryBody
        }
    }));
}

Cette fonction prend en paramètre zéro, un ou plusieurs index : il s'agit de l'emplacement où va être lancé la recherche dans Elasticsearch : dans tous les index ou dans certains index en particulier. Elle prend également en paramètre un objet contenant tous les critères de recherche au langage Query DSL, comme expliqué dans l'API Elasticsearch. C'est dans cet objet que vous préciserez par exemple de trier vos résultats selon un critère, de les filtrer selon une chaîne de caractères, etc.

Nous avons utilisé un type générique T pour cette fonction. Cela permet de typer vos objets retournés dans l'attribut source avec une interface par exemple et d'utiliser la même fonction quel que soit l'index utilisé et la requête envoyée.

La durée de vie du contexte de recherche (variable SCROLL_API_SEARCH_CONTEXT_DURATION) dépend de vos besoins. Si vous souhaitez agréger toutes les pages de résultats dans votre application front, par exemple pour afficher des points sur un graphique, une durée de vie de quelques secondes sera suffisante. A l'inverse, si vous souhaitez afficher les résultats page par page dans un tableau sur lequel l'utilisateur choisit lui-même de passer à la page suivante, vous créerez un contexte durant au moins plusieurs minutes. La syntaxe et les unités de temps sont décrites sur la documentation.

Le retour de cette fonction nous renvoie donc, dans l'objet SearchResponse, le scroll_id qui devra être utilisé pour récupérer la page suivante de résultats, ainsi que les premiers résultats dans l'objet hits.hits. Voici une petite méthode utilitaire qui permet de récupérer une liste d’objets correctement typés depuis la réponse d’Elasticsearch :

private getDocumentsContent<T>(searchResponse: SearchResponse<T>): Array<T> {
    return searchResponse.hits.hits ? searchResponse.hits.hits.map(v => v._source) : [];
}

Le retour de la requête HTTP est encapsulé dans une promesse qui est résolue lorsque l'appel a terminé. En cas d'erreur, un objet contenant entre autres le code d'erreur et un message explicatif est encapsulé dans le rejet de la promesse. Il possible de convertir aisément l'objet Promise en Observable via la méthode from de RxJS afin de garder une certaine cohérence dans le code de son application Angular en ne manipulant que des Observable.

Récupération des pages suivantes de résultats

La fonction getDocumentsWithScrollNextPage sera appelée pour récupérer les pages suivantes de résultats.

/**
* Fetch documents from Elasticsearch with pagination, once getDocumentsWithScrollFirstPage() 
* has already been called.
* @param scroll_id the search id returned by getDocumentsWithScrollFirstPage()
* @return an observable containing a results page from the Elasticsearch scroll API
*/
public getDocumentsWithScrollNextPage<T>(scroll_id: string): Observable<SearchResponse<T>> {
    return from(this.getElasticsearchClient().scroll({
        scrollId: scroll_id,
        scroll: ElasticsearchService.SCROLL_API_SEARCH_CONTEXT_DURATION
    }));
}

Son unique paramètre scroll_id est égal à l'attribut scroll_id retourné par la précédente requête : comme il peut changer, il est conseillé d'utiliser celui de la dernière requête et de ne pas garder celui de la première requête. Le fait d'appeler cette méthode remet à zéro le timer associé à la durée de vie du contexte de recherche.

L'objet de retour est construit sur le même modèle que pour la fonction getDocumentsWithScrollFirstPage et on peut exploiter des résultats typés.

Si la liste hits.hits est vide ou de taille inférieure à la taille des pages définie initialement, c'est que la dernière page de résultats correspond au contexte de recherche créé par la fonction getDocumentsWithScrollFirstPage a été atteinte.

Conclusion

Au travers de cet article, nous avons vu comment requêter, depuis une application front-end en Angular, l'API Scroll de Elasticsearch pour récupérer de façon paginée des documents dans un contexte de recherche figée au moment de la première requête.

De nombreuses autres possibilités sont offertes par l'API d'Elasticsearch mais les détailler ici nuirait à la clarté de l'article. Par exemple, il est aussi possible de nettoyer manuellement les contextes de recherche avant qu'ils n'expirent.

Bien que les exemples s'appuient sur Angular et RxJS en langage Typescript, vous pouvez largement appliquer les principes décrits dans cet article pour requêter l'API Scroll avec d'autres technologies. La documentation de l'API Elasticsearch est à mon avis très claire et détaillée. C'est plutôt leur partie client navigateur qui pêche en cette période de transition de leur côté.

Sources

Les liens suivants sont utiles tout au long de la programmation :

Les problématiques moins génériques de l’article sont sourcées directement au moment où elles sont abordées.