facebook

Blog

Resta aggiornato

Vediamo come aggiungere ricerche full-text alle nostre applicazioni ASP.NET Core con ElasticSearch
Integrare ElasticSearch in ASP.NET Core
mercoledì 22 Gennaio 2020

Vi sarà sicuramente capitata la richiesta di aggiungere alla vostra applicazione delle funzionalità di ricerca avanzata e, in particolare, una ricerca full-text stile Google.
Durante lo sviluppo di un e-commerce, ci è stato richiesto di permettere agli utenti del sito di poter fare ricerche avanzate sui prodotti, in modo che potessero trovare, in modo efficiente e completo, ciò che cercavano.

Abbiamo tentato l’implementazione di ricerche custom, basate sulla ricerca dell’occorrenza puntuale di una determinata stringa su tutti i campi di un oggetto e, per ottimizzare i tempi, abbiamo provato ad aggiungere uno strato di cache nella chiamata in modo da evitare di stressare troppo il DB, ma il tutto non ci sembrava molto efficiente. Abbiamo quindi cercato sul mercato prodotti che potessero essere affini alle nostre esigenze, e, dopo un’approfondita analisi, abbiamo scelto di usare ElasticSearch: un motore di ricerca, distribuito, facilmente adattabile e che gestisce ricerche e analisi anche su protocollo REST, facilitando l’estrapolazione e la trasformazione dei dati.
Nello specifico, stiamo parlando di un motore di ricerca full text open source basato su Apache Lucene, con cui gestisce l’indicizzazione dei documenti e la ricerca. Cerchiamo di capire quali sono i concetti di base.

ElasticSearch conserva i dati in uno o più indici. L’indice di ES è un concetto abbastanza simile a quello dei database SQL, ovvero viene utilizzato per salvare e leggere documenti.
Il Documento è l’entità principale del mondo ElasticSearch. Consiste in un insieme di campi con nomi e uno o più valori. Ogni documento può avere un insieme di campi e non c’è uno schema o una struttura definita per esso. Da un punto di vista formale, è un oggetto JSON.
Tutti i documenti, prima di essere salvati, vengono analizzati. Questo processo di analisi – chiamato mapping – avviene tramite operazioni di pulizia del contenuto dei documenti (ad esempio, eliminazione di tag HTML) e tokenizzazione di essi, ovvero il contenuto viene splittato in token.
Ogni documento in ElasticSearch ha un tipo definito. Ciò consente di salvare vari tipi di documento in uno stesso indice ed avere mapping diversi per tipi diversi.

Un singola istanza di un server ElasticSearch è chiamata Nodo. Un singolo nodo può essere sufficiente per la maggior parte dei casi d’uso, ma quando bisogna gestire guasti oppure la mole di dati cresce è possibile utilizzare un Cluster multi-nodo. È possibile configurare un cluster in modo che, anche se alcuni nodi non sono disponibili, le funzionalità di ricerca e gestione sono comunque utilizzabili.

Per consentire il corretto funzionamento dei cluster, i dati vengono diffusi su diversi indici fissi di Apache Lucene. Questi indici sono chiamati Shard, e il processo di diffusione è chiamato sharding. ElasticSearch gestisce automaticamente lo sharding e l’utente finale vede tutti gli indici creati come un unico grande indice

Per la sola fase di lettura, è possibile utilizzare una Replica, che consente di sgravare il carico su un singolo nodo che non ha la capacità di gestire tutte le richieste e, inoltre, consente una maggiore sicurezza sui dato poiché, se si perdono dati dallo shard originale, è possibile recuperarli dalla replica. ElasticSearch colleziona varie informazioni circa lo stato dei cluster, impostazioni degli indici etc., e li scrive nei Gateway.

Dal punto di vista architetturale, ElasticSearch è basato su alcuni semplici concetti chiave:

  • Impostazioni e valori di configurazione di default tali che l’utente, una volta installato ElasticSearch, possa iniziare ad utilizzarlo subito senza aver necessità di ulteriori configurazioni;
  • Lavora subito in maniera distribuita. I nodi diventano automaticamente parte di un cluster e, durante la fase di setup, cerca automaticamente di unirsi a quest’ultimo;
  • Architettura P2P senza SPOF (single point of failure). I nodi si connettono automaticamente ad altre macchine del cluster per scambio di dati e monitoraggio incrociato;
  • Facilmente scalabile, sia in termini di capacità che di mole di dati, tramite l’aggiunta di nuovi nodi al cluster;
  • Nessuna restrizione sull’organizzazione dei dati nell’index. Ciò consente agli utenti di modificare il modello dei dati e avere praticamente zero impatti nelle ricerche;
  • Ricerca e versionamento NRT (Near Real Time). Per la natura distribuita di ElasticSearch, è impossibile evitare ritardi e differenze tra dati locati su nodi differenti, per cui sono forniti meccanismi come il versionamento.

Quando un nodo di ElasticSearch si avvia, usa la modalità multicast (o unicast, se configurata) per cercare altri nodi sullo stesso cluster e si connette ad essi.

In un cluster, uno dei nodi è scelto come nodo master, che detiene la responsabilità di gestire lo stato del cluster e il processo di assegnazione degli shard ai nodi. Il nodo master legge lo stato del cluster e, se necessario, inizia una fase di recupero in cui individua quali shard sono disponibili e ne assegna uno come primario. In questo modo il cluster risulta funzionante pur non avendo tutte le risorse a disposizione. Successivamente il nodo master ricerca shard duplicati e li gestisce come repliche.

Durante il funzionamento standard, il nodo master monitora i nodi disponibili e controlla se sono tutti funzionanti. Se uno di essi non è disponibile per un intervallo di tempo configurato, il nodo viene segnalato come rotto e si avvia il processo di gestione del guasto.
L’attività principale del processo di gestione del guasto è il bilanciamento del cluster e degli shard del nodo rotto, e l’assegnazione di un nuovo nodo come responsabile di questi shard.
Per ogni shard primario perso, sarà definito un nuovo shard primario scelto tra le repliche disponibili.

Come già detto, ElasticSearch mette a disposizione delle API REST che sono facilmente integrabili con qualsiasi sistema che può inviare richieste HTTP. Le richieste sono inviate tramite URL definite, il cui body è in formato JSON, così come le risposte.

L’indicizzazione dei dati può essere fatta in quattro modi:

  1. Index API: consente di inviare un documento a un indice definito;
  2. Bulk API: consente l’invio multiplo tramite protocollo HTTP;
  3. UDP bulk API: consente l’invio multiplo su qualsiasi protocollo (più veloce della Bulk API, ma meno affidabile);
  4. Plugin: vengono eseguiti sul nodo, recuperano dati da sistemi esterni.

È fondamentale ricordare che l’indicizzazione avviene solo sullo shard primario e non sulla sua replica, per cui, se la richiesta di indicizzazione viene inviata a un nodo che non ha uno shard corretto o ne contiene una replica, viene inoltrata allo shard principale.

La ricerca avviene tramite la Query API. Utilizzando un linguaggio basato su JSON per costruire query complesse, chiamato Query DSL, è possibile:

  • usare varie tipi di query, incluse query semplici, frasi, intervalli, booleani, coordinate spaziali e altre query;
  • costruire query complesse combinando query semplici;
  • filtrare documenti, eliminando quelli che non corrispondono ai criteri selezionati senza influenzare lo scoring;
  • trovare documenti simili ad un dato documento;
  • trovare suggerimenti e correzioni per una data frase;
  • trovare query che corrispondono a un dato documento.

La ricerca non è comunque un processo semplice ad una fase ma, in generale è possibile suddividerla in due fasi: la fase di scatter, in cui si interrogano tutti gli shard rilevanti sull’indice, e la fase di gather, in cui si elaborano e ordinano i risultati della fase di scatter.

Sporchiamoci le mani

ES offre diverse modalità di utilizzo, sia locali che in cloud. Per installare ES su una macchina Windows, è necessario che sia presente la versione corretta della Java Virtual Machine (https://www.elastic.co/support/matrix#matrix_jvm), poi si può scaricare il file zip dalla pagina dei download (https://www.elastic.co/downloads/elasticsearch) ed estrarlo in una cartella sul disco, nel nostro caso C:\Elasticsearch

Per eseguirlo basta lanciare il file C:\Elasticsearch\bin\elasticsearch.bat

Nel caso si desideri utilizzare ElasticSearch come servizio, in modo da poterlo avviare o stoppare tramite i servizi di Windows, è necessario aggiungere una riga al file C:\Elasticsearch\config\jvm.options, che, nel caso dei sistemi a 32 bit, è -Xss320k, mentre per i 64 è -Xss1m

Dopo aver apportato questa modifica, da prompt dei comandi o da powershell si può eseguire il file C:\Elasticsearch\bin\elasticsearch-service.bat

I comandi disponibili sono installremovestartstop e manager. Per installare il servizio, digitiamo naturalmente: C:\Elasticsearch\bin\elasticsearch-service.bat install

Per gestire il servizio, digitiamo C:\Elasticsearch\bin\elasticsearch-service.bat manager che ci apre Elastic Service Manager, una GUI che consente di personalizzare impostazioni relative al servizio e gestire lo stato dello stesso.

Dopo l’avvio dell’istanza di ElasticSearch, il nome di default del cluster (cluster.name nelle impostazioni) è elasticsearch, e il nome del nodo (node.name) è uguale all’hostname del proprio PC. Nel caso si pianifichi di proseguire nell’utilizzo di questo cluster o aggiungere più nodi, è una buona idea quella di cambiare questi valori di default con dei propri modificandoli nel file elasticsearch.yml.

Possiamo verificare la corretta esecuzione di ElasticSearch da browser, tramite la pagina http://localhost:9200/, in cui otteniamo un risultato simile:

Per implementare la nostra soluzione, basata su .NET Core, abbiamo utilizzato il pacchetto NEST, che possiamo installare tramite il comando:

dotnet add package NEST

NEST ci consente di utilizzare nativamente tutte le funzionalità di ElasticSearch, sia nell’indicizzazione e la ricerca dei documenti, sia nell’amministrazione di nodi e shard, e ci astrae totalmente dal livello di API della piattaforma.

Per gestire il plugin NEST, abbiamo creato la classe ElasticsearchExtensions:

public static class ElasticsearchExtensions
{
    public static void AddElasticsearch(this IServiceCollection services, IConfiguration configuration)
    {
        var url = configuration["elasticsearch:url"];
        var defaultIndex = configuration["elasticsearch:index"];
 
        var settings = new ConnectionSettings(new Uri(url))
            .DefaultIndex(defaultIndex);
 
        AddDefaultMappings(settings);
 
        var client = new ElasticClient(settings);
 
        services.AddSingleton(client);
 
        CreateIndex(client, defaultIndex);
    }
 
    private static void AddDefaultMappings(ConnectionSettings settings)
    {
        settings
            DefaultMappingFor<Product>(m => m
                .Ignore(p => p.Price)
                .Ignore(p => p.Quantity)
                .Ignore(p => p.Rating)
            );
    }
 
    private static void CreateIndex(IElasticClient client, string indexName)
    {
        var createIndexResponse = client.Indices.Create(indexName,
            index => index.Map<Product>(x => x.AutoMap())
        );
    }
}

in cui sono contenute le configurazioni e i mapping degli oggetti, nel nostro caso la classe Product, su cui abbiamo deciso di ignorare, in fase di indicizzazione, il prezzo, la quantità e il rating.

Questa classe viene richiamata in Startup.cs tramite l’istruzione:

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddElasticsearch(Configuration);
}

che ci consente di caricare tutte le impostazioni all’avvio, modificandole nella sezione elasticsearch del file appsettings.json, in cui provvediamo ad inserire le seguenti righe:

"elasticsearch": {
        "index": "products",
        "url": "http://localhost:9200/"
}

Il parametro index rappresenta l’indice di default scelto per salvare i nostri documenti e url è l’indirizzo della nostra istanza di ElasticSearch.

Il nostro oggetto Product, è così definito:

public class Product
{
public int Id { get; set; }
public string Ean { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Brand { get; set; }
public string Category { get; set; }
public string Price { get; set; }
public int Quantity { get; set; }
public float Rating { get; set; }
public DateTime ReleaseDate { get; set; }
}

I prodotti possono essere indicizzati, come detto prima, sia singolarmente che in liste.
Nel nostro service dei prodotti abbiamo implementato entrambe le modalità:

public async Task SaveSingleAsync(Product product)
{
    if (_cache.Any(p => p.Id == product.Id))
    {
        await _elasticClient.UpdateAsync<Product>(product, u => u.Doc(product));
    }
    else
    {
        _cache.Add(product);
        await _elasticClient.IndexDocumentAsync(product);
    }
}
 
public async Task SaveManyAsync(Product[] products)
{
    _cache.AddRange(products);
    var result = await _elasticClient.IndexManyAsync(products);
    if (result.Errors)
    {
        // the response can be inspected for errors
        foreach (var itemWithError in result.ItemsWithErrors)
        {
            _logger.LogError("Failed to index document {0}: {1}",
                itemWithError.Id, itemWithError.Error);
        }
    }
}
 
public async Task SaveBulkAsync(Product[] products)
{
    _cache.AddRange(products);
    var result = await _elasticClient.BulkAsync(b => b.Index("products").IndexMany(products));
    if (result.Errors)
    {
        // the response can be inspected for errors
        foreach (var itemWithError in result.ItemsWithErrors)
        {
            _logger.LogError("Failed to index document {0}: {1}",
                itemWithError.Id, itemWithError.Error);
        }
    }
}

dove abbiamo utilizzato un array _cache per avere un’ulteriore cache dei prodotti.
Per la modalità multipla, abbiamo implementato anche la versione bulk, che ci consente di indicizzare una grossa mole di documenti in tempi molto più brevi, ed abbiamo gestito gli eventuali errori in inserimento con dei log.
Da notare che il metodo SaveSingleAsync gestisce sia l’inserimento che la modifica del documento tramite un controllo sul nostro array di cache.

Per la cancellazione del documento, abbiamo implementato un metodo DeleteAsync:

public async Task DeleteAsync(Product product)
{
    await _elasticClient.DeleteAsync<Product>
(product);
 
    if (_cache.Contains(product))
    {
        _cache.Remove(product);
    }
}

Il documento, una volta cancellato da ElasticSearch viene rimosso anche dalla nostra cache.

Per la ricerca dei documenti, abbiamo implementato SearchController, col suo metodo principale Find, che prende in input una stringa, numero e dimensione di pagina, e restituisce una view con i documenti trovati.

[Route("/search")]
public async Task<IActionResult>Find(string query, int page = 1, int pageSize = 5)
{
    var response = await _elasticClient.SearchAsync<Product>
(
        s =>s.Query(q => q.QueryString(d => d.Query(query)))
            .From((page - 1) * pageSize)
            .Size(pageSize));
 
    if (!response.IsValid)
    {
        // We could handle errors here by checking response.OriginalException 
        //or response.ServerError properties
        _logger.LogError("Failed to search documents");
        return View("Results", new Product[] { });
    }
 
    if (page > 1)
    {
        ViewData["prev"] = GetSearchUrl(query, page - 1, pageSize);
    }
 
    if (response.IsValid && response.Total > page * pageSize)
    {
        ViewData["next"] = GetSearchUrl(query, page + 1, pageSize);
    }
 
    return View("Results", response.Documents);
}
 
private static string GetSearchUrl(string query, int page, int pageSize)
{
    return $"/search?query={Uri.EscapeDataString(query ?? "")}&page={page}&pagesize={pageSize}/";
}

Il metodo GetSearchUrl ci consente di ottenere la URL per gestire la paginazione.

Ai fini di sviluppo, abbiamo implementato il metodo ReInlex, che ci consente di cancellare tutti i documenti sull’indice e importarli di nuovo ad uno ad uno. Può esserci utile per importare liste di documenti già esistenti e non caricati.

//Only for development purpose
[HttpGet("/search/reindex")]
public async Task<IActionResult>ReIndex()
{
    await _elasticClient.DeleteByQueryAsync<Product>(q => q.MatchAll());
 
    var allProducts = (await _productService.GetProducts(int.MaxValue)).ToArray();
 
    foreach (var product in allProducts)
    {
        await _elasticClient.IndexDocumentAsync(product);
    }
 
    return Ok($"{allProducts.Length} product(s) reindexed");
}

Ai fini di esempio, abbiamo realizzato un’interfaccia tramite la quale possiamo aggiungere N prodotti generati dinamicamente, tramite il plugin Bogus, e gestire il CRUD dei prodotti.

Lanciando il progetto, otteniamo la seguente schermata:

Se proviamo ad aggiungere, ad esempio, 10 prodotti al nostro indice, inserendo 10 nella casella di testo e cliccando su Import Documents, possiamo visualizzare il risultato della nostra importazione sia utilizzando la casella di ricerca, ma anche direttamente da browser, all’indirizzo http://localhost:9200/products/_search, dove otterremo un risultato simile a questo:

Il codice utilizzato in questo articolo è disponibile qui.

Alla prossima!