Node.jsJavaScriptC++

Node.js im Detail

Node.js bietet JavaScript eine Laufzeitumgebung abseits des Browsers. Dabei nutzt es Googles V8 um JavaScript in einem ersten Schritt in Byte Code und anschließend in Maschinen Code zu übersetzen.

Die Compiler Pipeline besteht also aus einem Interpreter (Ignition) und einem in Makro - Assembly geschriebenen Compiler (TurboFan).

JavaScript für sich genommen bietet keine Möglichkeit auf Funktionalitäten außerhalb des Kontexts der ausführenden JavaScript Engine zuzugreifen. Darunter fällt jeglicher I/O, Threading und Ähnliches. Im Browser ausgeführt ist dieser für die Implementation verantwortlich. Durch die Sandbox besteht kein direkter Zugriff auf Betriebssystemebene. Die vom Browser zu implementierenden Funktionen sind unter den Begriff Web API’s zusammengefasst und standardisiert.

Node.js bietet plattformübergreifend eine in den meisten Fällen asynchrone Schnittstelle zum Betriebssystem. Man hat demnach einen Main Thread, in dem unteranderem dein JavaScript ausgeführt wird und einen Pool von n Worker – Threads. In den Workern werden dann rechenintensive, blockierende Operationen ausgeführt.

Dabei wird in einer Semi-Endlosschleife immer wieder überprüft ob ein Worker seinen Job beendet und somit Callbacks in der Callback – Queue vorhanden sind. Die Callback Funktionen werden dann aus der Queue in V8‘s Call Stack (Aufrufstapel) gepushed. Dort landen zur Ausführung alle Funktionen in der Reihenfolge in der Sie aufgerufen werden. Und im Anschluss werden sie in umgekehrter Reihenfolge ausgeführt (First in, Last out: FiLo). Ist das Ende der Funktion erreicht, wird sie vom Call Stack gelöscht und die darunter liegende wird verarbeitet. So lange wie Funktionen im Call Stack sind, bewegt sich der Event-Loop (die Semi-Endlosschleife) kein Stück und die Anwendung kann nicht auf Ereignisse reagieren.

Das Ziel sollte es also sein alle rechenintensiven Aufgaben weiter zu delegieren. Das kann ein Worker im gleichen Anwendungskontext, ein von deiner Anwendung abhängiger oder unabhängiger Prozess oder gar ein Dienst auf einen komplett anderen Rechner im Netzwerk deiner Wahl sein.

Asynchron. Was ist damit gemeint?

Unter einer asynchronen Ausführung versteht man in der Softwareentwicklung eine Methode bzw. Funktion im "Hintergrund" auszuführen. Das aufrunde Programm kann auf weitere Ereignisse oder Eingaben weiterhin reagieren, da der Programmfluss nicht blockiert wird.

Sinnvoll ist das nicht nur bei Anwendungen mit grafischer Benutzeroberfläche sondern auch bei Anwendungen, die viel mit I/O Operationen beschäftigt sind (wie zum Beispiel ein HTTP Server). Fachlich wird von einer Event-Driven-Architecture gesprochen. Wenn wir jetzt beim beispielhaften HTTP Server bleiben, wäre ein alternativer Ansatz für ein asynchrones Verhalten für jeden Request ein neuen Thread zu erzeugen. In der Praxis hat sich jedoch die Event-Driven-Architecture bewährt.


Callback Funktionen

Als noch keine Promises und die Schlüsselwörter async/await standadisiert und in Node.js implementiert waren, wurden asynchron ausgeführten Funktionen eine Referenz zu einer Funktion als Argument übergeben, die dann im Laufe des Anwendungskontext nach dem die Operation das Ende selbiger signalisiert hat von der Laufzeitumgebung ausgeführt wird.

Beispiel: Wir rufen die Funktion setTimeout(function callback, [number]) auf. Diese wird ganz normal dem Call Stack hinzugefügt. Wenn ihre Ausführung an der Reihe ist, wird sie direkt wieder entfernt. Nach der übergebenen Zeit taucht die Callback Funktion im Call Stack wieder auf. In der Zwischenzeit hat die Laufzeitumgebung folgendes gemacht. Sie hat die Referenz zur Callback und die Laufdauer zwischengepeichert. Am Anfang ihrer internen Ereignisschleife überprüft sie ob die Laufdauer schon abgelaufen ist und wenn dem so ist, wird die Referenz zur Callback Queue hinzugefügt. Von dort aus werden dann im weiteren Verlauf der Iteration alle Callbacks dem Call Stack hinzugefügt und abgearbeitet.

Das Timer Beispiel ist dabei noch simpel und kann ohne Threading auskommen. Rechenintensive Aufgaben werden in einem Worker Thread ausgeführt.

const { randomBytes } = require('crypto');

function square(a, callback) { 
  /*
    Timer werden bei jeder Iteration der Ereignisschleife 
    am Anfang überprüft. Wenn einer abgelaufen ist, wird 
    die übergebene Callback Funktion in die Callback Queue
    gepushed.    
  */
  setTimeout(() => {
    /*
      Das erste Argument des Callbacks sollte immer ein Error
      Objekt sein ...
    */
    if (typeof a !== 'number')
      callback(new Error('a should be numeric...'));
    /*
      ... bzw. undefined oder null wenn kein Fehler vorliegt.
      Alle weiteren Argumente sind beliebig.
    */
    const squared = a * a;
    callback(null, squared);
  }, 2000)
}

randomBytes(1, function(error, rndNumber) {
  if (error) throw error;
  
  const num = rndNumber.readUInt8();
  square(num, function(error, result) {
    if (error) throw error;
    
    console.log(`${num} * ${num} = ${result}`);
  });
});

Zufallszahlen sind ein wichtiger Bestandteil in der Krypthographie. Wir nutzen hier die Funktion randomBytes(number, [function]) aber lediglich um ein randomisiertes Byte für die vorab implementierte Funktion square(number, function) zu erzeugen. Der erste Parameter von crypto.randomBytes bestimmt die Anzahl der künstliche Abfolge an Bytes und der zweite ist die Callback Funktion.

crypto.randomBytes ist eine Wrapper-Funktion der Funktion RAND_bytes() von Open SSL.

RAND_bytes will fetch cryptographically strong random bytes. Cryptographically strong bytes are suitable for high integrity needs, such as long term key generation. If your generator is using a software algorithm, then the bytes will be pseudo-random (but still cryptographically strong). RAND_bytes returns 1 for success, and 0 otherwise. If you changed the RAND_METHOD and it is not supported, then the function will return -1. In case of error, you can call ERR_get_error.

wiki.openssl.org


Promises

Promises und die zugehörige Job Queue wurden mit ES6/ES2015 eingeführt. Der Unterschied zum Callback - System von Node liegt bei dem Zeitpunkt der Ausführung. Die meisten Callback Funktionen werden in der nächsten Iteration vom Loop nach den Timern ausgeführt während Promises sobald wie möglich ausgeführt werden.

Wenn der Promise noch vor dem Beenden der aktuellen Funktion aufgelöst wurde, wird er direkt nach ihr ausgeführt.

Die Funktion promisify erstellt aus sich an "best practices" haltende asynchrone Funktionen Promises. Eine Callback in Node sollte immer als ersten Parameter ein Error Objekt übergeben. Gibt es keinen Fehler, dann ist es entweder null oder undefined. Danach folgen die anderen Parameter.

const { randomBytes } = require('crypto');
const { promisify } = require('util');

function square(a, callback) { 
  /*
    Timer werden bei jeder Iteration der Ereignisschleife 
    am Anfang überprüft. Wenn einer abgelaufen ist, wird 
    die übergebene Callback Funktion in die Callback Queue
    gepushed.    
  */
  setTimeout(() => {
    /*
      Das erste Argument des Callbacks sollte immer ein Error
      Objekt sein ...
    */
    if (typeof a !== 'number')
      callback(new Error('a should be numeric...'));
    /*
      ... bzw. undefined oder null wenn kein Fehler vorliegt.
      Alle weiteren Argumente sind beliebig.
    */
    const squared = a * a;
    callback(null, squared);
  }, 2000)
}

const randomBytesAsync = promisify(randomBytes);
const squareAsync = promisify(square);

(async () => {
  const rndNumber = await randomBytesAsync(1);
  const num = rndNumber.readUInt8();
  const result = await squareAsync(num);

  console.log(`${num} * ${num} = ${result}`);
})();

Der globale Anwendungskontext ist nicht asynchron. Das könnte sich in Zukunft ändern aber zur Zeit ist es notwendig asynchrone Funktionen, die ein Promise zurückgeben, in asynchronen Funktionen auch aufzurufen. Hier habe ich einfach alles in einer Pfeilfunktion gewrapped, welche ich direkt aufgerufen habe.

Der Source Code gewinnt durch Promises und async/await an Lesbarkeit.


Der Event Loop

image
Die Node.js Bindings zwischen dem OS und V8 sind in C++ geschrieben. Zur Orchestrierung der Worker und dem Betriebssystem Signal Handling wurde eigens libuv in C entwickelt. Diese Library stellt eine Abstraktionsschicht zwischen Node.js und Betriebssystem zur Verfügung. Dadurch wird ein Plattform unabhängiges Interface zum Threading und I/O geboten.

Funktion uv_run(uv_loop_t*, uv_run_mode) aus der Datei core.c von uvlib

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
    ran_pending = uv__run_pending(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout);

    /* Run one final update on the provider_idle_time in case uv__io_poll
     * returned because the timeout expired, but no events were received. This
     * call will be ignored if the provider_entry_time was either never set (if
     * the timeout == 0) or was already updated b/c an event was received.
     */
    uv__metrics_update_idle_time(loop);

    uv__run_check(loop);
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      /* UV_RUN_ONCE implies forward progress: at least one callback must have
       * been invoked when it returns. uv__io_poll() can return without doing
       * I/O (meaning: no callbacks) when its timeout expires - which means we
       * have pending timers that satisfy the forward progress constraint.
       *
       * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
       * the check.
       */
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}

Node-API

Diese Schnittstelle kann genutzt werden um neue Funktionalität im JavaScript Kontext von V8 bereitzustellen. Man kann sie sich als Vermittlungsschicht zwischen V8, libuv und dem Betriebssystem vorstellen. Dazu könnt ihr mehr in den beiden Beiträgen Node.js C++ Addon und Node.js C++ AsyncWorker erfahren.


Events

Aus dem Browser ist dir vielleicht das Button Element bekannt.

<button>Klick mich</button>

Du kannst es mit Javascript referenzieren und auf seine Eigenschaften zugreifen.

const button = document.querySelector('button');
	
button.onclick = event => {
  event.target.innerHTML = 'Ich wurde geklickt';
}

Die onclick Eigenschaft ist ein click Event, welches an den Button gebunden ist.

button.addEventListener('click', event => {
  event.target.innerHTML = 'Ich wurde geklickt';
});

Mit der Methode addEventListener kannst du eine Callback zum Aufruf für ein Event registrieren. Und das auch mehrfach.

const button = document.querySelector('button');

button.onclick = event => {
  // button.innerHTML wäre natürlich ebenfalls korrekt.
  event.target.innerHTML = 'Ich wurde geklickt';
}

button.addEventListener('mouseover', (event) => {
  event.target.innerHTML = 'Wirst du mich klicken?';
  event.target.addEventListener('mouseout', function(event) {
    event.target.innerHTML = 'Klick mich';                    
    event.target.removeEventListener('mouseout', this);
  });
})

In Node gibt es natürlich auch Events.

const EventEmitter = require('events');

const emitter  = new EventEmitter();
	
emitter.on('dein-event', event => {
  console.log(event);
});
emitter.emit('dein-event', 'ist komplett dir überlassen');

Du kannst ein Event erstellen in dem du das events Modul lädst, die EventEmitter Klasse instanzierst und dein Event wie oben registrierst. Auslösen kannst du es mit der Methode EventEmitter.emit()


Streams

Streams ermöglichen das Bewegen von großen Datenmengen mit wenig System Resourcen. Es ist immer nur der Lese- / Schreibbuffer im Arbeitsspeicher.

Die Klassen Readable, Writable und Duplex des Stream Moduls erben Eigenschaften von der Events Klasse.

const path = require('path');
const { createReadStream } = require('fs');

const readStream = createReadStream(path.join(__dirname, 'test.txt'));
const chunks = [];

readStream.on('data', data => chunks.push(data));
readStream.on('error', error => { throw error });
readStream.on('end', () => console.log(chunks.join("").toString()));

Es wird ein Readable zum File Descriptor der Datei test.txt erstellt. Beim Auslösen des data Events wird dem chunks[] Array der interne Lesebuffer von readStream angehangen. Im Fehlerfall wird das Fehler Objekt geworfen. Das Auslösen des end Events führt in einer Callback Funktion console.log mit dem zu einem String konvertierten, zusammengesetzten Inhalt des chunks[] Arrays.

const path = require('path');
const { createReadStream, createWriteStream } = require('fs');

const readStream = createReadStream(path.join(__dirname, 'test.txt'));
const writeStream = createWriteStream(path.join(__dirname, 'neu.txt'));

readStream.on('data', data => writeStream.write(data));
readStream.on('error', error => { throw error });
readStream.on('end', () => writeStream.end());

Neben readStream wird nun zusätzlich ein Writable Stream zum File Descriptor der Datei neu.txt erstellt. Beim data Event wird dann die Methode writeStream.write ausgeführt um den aktuellen Lesebuffer in den Stream zu schreiben. Das end Event löst das Aufrufen der writeStream.end Methode aus. Sie emittiert das end Event von writeStream.

const path = require('path');
const { createReadStream, createWriteStream } = require('fs');

const readStream = createReadStream(path.join(__dirname, 'test.txt'));
const writeStream = createWriteStream(path.join(__dirname, 'neu.txt'));

readStream.pipe(writeStream);
readStream.on('error', error => { throw error });

Anstatt nun auf die Ereignisse von readStream zu reagieren, wird der Stream in writeStream gepiped. Das empfiehlt sich wenn man einen Stream nur Umleiten und nicht abändern möchte.