Node.jsJavaScript

Gatsby-Ghost Suche: Redis und RediSearch - Teil 1

Wir erweitern unser Such - Plugin und verschieben den Prozess des durchsuchens vom Client zum Server.

Die Suche vollständig auf den Client auszulagern ist eigentlich nur in eher seltenen Fällen eine kluge Entscheidung. Auch wenn der Suchvorgang in einen Service Worker durchlaufen wird und so die Performance der Webseite bisher nicht darunter leidet, wird die Ladezeit mit wachsenden Inhalt unaufhörlich zunehmen. Nicht nur die des downloadens der Daten sondern auch die Zeit, die zum durchsuchen der Datensätze gebraucht wird. Zudem kommt das ein lineares Durchsuchen bei einer geringeren bis mittleren Anzahl an Einträgen noch ziemlich gut funktioniert, aber ab einen gewissen Punkt sollte man dann doch einen anderen Algorithmus in Erwägung ziehen.

Die Datenbank

Es gäbe durchaus viele Möglichkeiten das Speichern und Laden der Datensätze zu realisieren. Unter anderem könnte man die Ghost Content API nutzen um die Inhalte in einer seperaten SQL oder NoSQL Datenbank zu speichern, eine direkte Verbindung zur Ghost Datenbank wäre ebenfalls denkbar oder auch ganz easy die Daten als JSON im Dateisystem speichern.

Ich habe mich hier jedoch für Redis entschieden. Redis ist eine hochperformante In Memory Datenbank, die mittels Key Value Paaren arbeitet. Schließlich brauch ich nicht unbedingt noch ein Backup. Die für das Hinzufügen in Redis aufbereiteten Daten werden zusätzlich auf der Festplatte gespeichert. So muss nach einem Neustart der Prozess nicht noch einmal durchlaufen werden und die Datensätze können ohne Umwege direkt in Redis geladen werden.

Der HTTP Service

Als HTTP Backend ist Node.js in Kombination mit Express in diesen Fall meiner Meinung nach keine schlechte Wahl und NGINX fungiert bei meinem Setting als Reverse Proxy.


Setup

Bevor wir mit dem programmieren loslegen können müssen wir Redis sammt RediSearch, die Node.js Module redis und redredisearch installieren.

Auf die genaue Konfiguration von Docker, Redis und NGINX werde ich hier nicht eingehen. Vor allem weil es gar nicht so abwegig ist, einen Dienst wie AWS in diesen Zusammenhang zu nutzen.

Docker installieren

Docker installieren und das Docker Image von RediSearch starten
$ sudo apt update
$ sudo apt install docker.io
$ docker run -p 6379:6379 redislabs/redisearch:latest

Gatsby vorbereiten

Benötigte Node Module installieren
$ cd suchen-beispiel
$ npm install redis redredisearch

Anpassungen des gatsby-plugin-search

Unser Gatsby Plugin müssen wir um eine Funktionatität erweitern die den Content für Redis in ein verständliches Format bringt und ihn danach von Redis indizieren lassen. Dabei möchte ich bewusst unsere Plugin Basis erweitern damit die ursprüngliche Funktionsweise erhalten bleibt.

Vorab sei gesagt das die RediSearch Implementierung für Node.js eher rundimentär wirkt und so ein großer möglicher Funktionsumfang von Redis und RediSearch ungenutzt bleibt. Das kann aber auch daran liegen, dass ich noch nicht die richtigen Informationen gefunden habe.

Änderungen an der onPostBootstrap Funktion

Neben dem Objekt / Array für den Content benötigen wir nun auch ein Objekt, welches Url und Title in Bezug zu einander bringt. Ich hab es mangels bessere Idee einfach sitemap genannt. Somit müssen wir auch das Options Objekt anpassen damit man auch die entsprechende Dateinnamen frei wählen kann.

plugins/gatsby-plugin-search/gatsby-node.js
exports.onPostBootstrap = async (_ref, pluginOptions) => {
  const page = _ref.page;
  const actions = _ref.actions;
  const createPage = actions.createPage;
  const {getNodesByType, reporter} = _ref;
  const start = Date.now();
    	
  /*

    <==========================================================>
          Den Ursprünglichen Code werde ich hier einfach 
          auskommentieren aber prinzipiell kann der 
          natürlich entfernt werden.               	
    <==========================================================>
        
    let indexed = [];
    let options = {
      file: '/public/indexed.json'
    };	

  */
  
  let options = {
    files: {
      sitemap: 'sitemap.json',
      index: 'index.json'
    }
  };
  
  options = {...options, ...pluginOptions};
  
  let sitemap = new Object();
  let index = options.redis ? new Object() : [];     
    
  /*
    
    let indexFile = path.join(__dirname,'./../../public/', options.index);
    
  */

  let indexFile = path.join(__dirname, './../../public/', options.files.index);
  let sitemapFile = path.join(__dirname, './../../public/', options.files.sitemap);
    
  /* ... */

Wenn in den Options Objekt die redis Eigenschaft mit übergeben wird, dann wird der Redis Client initialisiert und die Ausgabepfade von der Datei sitemaps.json und index.json werden geändert da diese nicht regulär über HTTP erreichbar sein müssen. Zudem erweitern wir das Erstellen des Objekts für den Index um eine weitere Abfrage ob options.redis definiert ist. Worauf wir dann entweder die Datensätze für Redis aufbereiten und weitergeben oder wie gehabt verfahren.

plugins/gatsby-plugin-search/gatsby-node.js
  /* ... */
    
   if(options.nodes == undefined && options.index == undefined) {
     reporter.warn('No data to index');
     return;
  }

  if(options.redis) {
    client = redis.createClient(
      {host: options.redis.host,port: options.redis.port}
    );

    redsearch.setClient(client);

    indexFile = path.join(
      __dirname
      ,'./../../../.index/'
      , options.files.index
    );
    sitemapFile = path.join(
      __dirname
      ,'./../../../.index/'
      , options.files.sitemap);
  }
    
  options.nodes.forEach((node) => {
    getNodesByType(node.node_type).forEach((n) => {
      let obj = new Object();
      let slug = n[node.slug_field];
      obj.id = n[node.id];
      obj.title = n[node.title_field];
      obj.search = new Object();      

      node.fields.forEach((val) => {
        obj.search[val] = remLBaTabs(trimChar(n[val], ' '));
      });
      
      /*
        indexed.push(obj);
      */

      if(options.redis) {
        redsearch.createSearch('redis_index', {}, (err, search) => {
          if(err) {
            reporter.error(`Error: ${err.message}`);
            return;
          }
          search.index(Object.values(obj.search).join(' '), slug);
          reporter.success(`added "/${slug}" to redis index`);
        });
        index[slug] = Object.values(obj.search).join(' ');
      }
      else {        
        obj.slug = slug;
        index.push(obj);
      }
      
      sitemap[slug] = obj.title;
    });
  });

Dann müssen wir uns nur noch dem Speichern der JSON Dateien widmen. Da wir nun eine Datei mehr haben müssen wir die auch in einen seperaten Vorgang speichern.

plugins/gatsby-plugin-search/gatsby-node.js
  /* ... */

  fs.writeFile(indexFile, JSON.stringify(index), (err) => {
    fs.stat(indexFile, (err, fstat) => {
      if(err) {
        reporter.error(`Error: ${err.message}`);
        return;
      }

      const duration = ((Date.now() - start) / 1000).toFixed(2);
      reporter
        .success(
          `search index saved to \"${options.files.index}\" (${(fstat.size / 1024).toFixed(2)} KB) - ${duration}s`
        );
    });

    if(err) reporter.error(`Error: ${err.message}`);
  });

  fs.writeFile(sitemapFile, JSON.stringify(sitemap), (err) => {
    fs.stat(sitemapFile, (err, fstat) => {
      if(err) {
        reporter.error(`Error: ${err.message}`);
        return;
      }

      const duration = ((Date.now() - start) / 1000).toFixed(2);
      reporter
        .success(
          `sitemap saved to \"${options.files.sitemap}\" (${(fstat.size / 1024).toFixed(2)} KB) - ${duration}s`
        );
      });

      if(err) reporter.error(`Error: ${err.message}`);
  });
 }

Gatsby Config anpassen

Fast geschafft! Zum Testen fügen wir unsere gatsby-config.js noch ein redis Objekt mit den Eigenschaften host und port zu.

gatsby-config.js
  
  /* ... */
  
  {
    resolve: require.resolve('./plugins/gatsby-plugin-search'),
    options: {
      redis: {
        host: 'localhost',
        port: 6379
      },      
     /* ... */
    }
  },
  /* ... */

Wenn wir nun gatsby build oder gatsby develop ausführen sollte nun der Content in Redis landen. Das soll es dann auch erst einmal gewesen sein. Im nächsten Teil kümmern wir uns darum die Daten per HTTP Request von RediSearch durchsuchen zu lassen und mit dem Ergebnis zu antworten.


Weitere Blog Posts aus dieser Serie

  1. Gatsby-Ghost Suche: React Komponente
  2. Gatsby-Ghost Suche: Webworker
  3. Gatsby-Ghost Suche: Content indizieren
  4. Gatsby-Ghost Suche: Redis und RediSearch (Teil 1)
  5. Gatsby-Ghost Suche: Redis und RediSearch (Teil 2)
  6. Gatsby-Ghost Suche: Redis und RediSearch (Teil 3)