Wie bekomme ich nativen Code in NodeJS zum Laufen?

Vielleicht bist du als NodeJS Entwickler schon vor dem Problem gestanden, bestehende Libraries oder Executables in deine nodejs Applikation einbinden zu müssen. Hier gibt es (abhängig davon, in welcher Form die Binaries vorhanden sind) vielfältige Möglichkeiten. Dieser Artikel gibt dazu einen Überblick.

Warum will ich eigentlich Binaries verwenden?

Hier gibt es grundsätzlich zwei Gründe. Einerseits kommt es vor, dass man bestehende Software, die nicht in JavaScript verfasst ist, verwenden will, andererseits weil man sich eine Verbesserung der Performance erhofft. Bei letzterem Grund ist durchaus Vorsicht geboten. Nativer Code bedeutet nicht automatisch einen Performancegewinn. Hier muss immer von Fall zu Fall entschieden werden.

In der Folge sind einige Möglichkeiten mit Vor- und Nachteilen, und teilweise Codebeispielen angeführt. Alle diese Varianten können auf Windows, Linux und MacOS verwendet werden.

NodeJSNode „Child Process“ API

…für ausführbare Dateien (z.B. .exe)

NodeJS beinhaltet die sogenannte Child Process API. Damit können beliebige ausführbare Dateien (Executables) in Subprozessen gestartet werden. Hier ein kurzes Beispiel aus der Dokumentation, das auf Linux und MacOS funktionieren sollte (hier ist nämlich das Programm ls vorinstalliert):


const { spawn } = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.log(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});

Sofern die ausführbare Datei auch ohne NodeJS am Hostrechner läuft, kannst du sie auf diesem Weg auch mit NodeJS ausführen. Wenn du beim Deployment die ausführbare Datei mitlieferst, heißt das aber auch, dass alle Abhängigkeiten (z.B. .dlls) entweder auch mitgeliefert werden oder am System installiert sein müssen. Falls die Applikation auf verschiedenen Plattformen laufen soll, wird ein Binary für jede Plattform benötigt. Dann muss man zur Laufzeit das Betriebssystem abfragen (z.B. per os.type()), und die richtige Datei zum Ausführen wählen.

Ein weiterer Nachteil dieser Methode ist die limitierte Kommunikation der ausführbaren Datei mit der Node Applikation. Die Kommunikation kann erfolgen:

  • – beim Start des Subprozesses mit Command Line Argumenten. Hier gibt es ein Längenlimit, das vom Betriebssystem und der Shell abhängig ist. Unter Windows liegt dies bei „nur“ 8192 Zeichen.
  • – mit stdin, stdout und stderr des auszuführenden Programmes
  • – per lokaler Netzwerkschnittstelle, also Sockets oder http

In den meisten Fällen muss für die bidirektionale Kommunikation jedenfalls das auszuführende Programm angepasst werden. In jedem Fall ist diese Kommunikation suboptimal.

C++ Addons

…für statische und dynamische Libraries (.dll, .so, .lib, .a)

Mit Node C++ Addons kann man C/C++ Code als Modul vom JavaScript Code aus verwenden. Für den aufrufenden JS Code erfolgt das vollkommen transparent. Ein Addon Modul kann somit per require eingebunden werden und Objekte, Funktionen, usw. exportieren. Auf C++ Seite muss hier Verbindungscode (engl. „glue code“) geschrieben werden, um die JavaScript Sprachelemente wie Datentypen, Objekt-Lifetime, usw. in C++ abzubilden.

Das benötigte Grundsetup ist auf nodeaddons.com recht gut erklärt. Ein Addon wird mit dem gyp Build Tool kompiliert, welches mit den auf der Webseite angegebenen Abhängigkeiten auskommt. Beim build Prozess kann man hier nun eine vorhandene dynamische oder statische Programmbibliothek hinzulinken. Dazu werden in der binding.gyp Datei die jeweiligen Änderungen eingetragen:


{
  "targets": [
    {
      "target_name": "some_target",
      "sources": [...],
      "include_dirs" : ["<!(node -e \"require('nan')\")",
                        "/path/to/ext/lib/header.h"],
      "conditions": [
        ["OS=='linux'", {
          "sources": [...],
          "link_settings" : {
            "libraries" : ["-L/some/lib/path", "-lmyextlib"]
          }
        },
        ["OS=='win'", {
          "sources": [...],
          "link_settings" : {
            "libraries" : ["-l/some/lib/path/myextlib.lib"]
          }
        }
      ]
    }
  ]
}

Soll die NodeJS Applikation auf mehreren Plattformen laufen, muss man also das jeweilige Build System, und somit die plattformspezifischen Linkereinstellungen beachten. Man braucht dann auch noch C++ Code, in dem man die JavaScript-Welt der Applikation mit der Binärwelt der Library verbindet.

Und beim Schreiben dieses Glue Codes ist größte Sorgfalt geboten. Man sollte sich mit der Google v8 JavaScript Engine, und mit libuv auseinandersetzen, und muss sich um viele Details wie die Lifetime etwaiger JavaScript Variablen, und auch um das Threading kümmern. Wird hier blockierender nativer Code der Programmbibliothek direkt ausgeführt, wird auch die Event Queue der NodeJS Applikation blockiert. Man kann jedoch die libuv API verwenden, um den Aufruf in einem separaten Thread zu starten.

Das Paket NaN bietet Utilities mit denen die Ausführung von nativem Code im Hintergrund erleichtert wird (hier ein Beispiel). Außerdem werden API Änderungen der Google v8 Engine in den verschiedenen NodeJS Versionen abstrahiert. Aus diesem Grund sollte man unbedingt auf das Paket zurückgreifen.

N-Api

…für statische und dynamische Libraries (.dll, .so, .lib, .a)

Einen ähnlichen Ansatz bietet die N-Api, die seit NodeJS Version 9 größtenteils als „Stable“ deklariert ist. Mit dieser API kann man ebenfalls NodeJS Module in C oder C++ schreiben, bzw mit Libraries verbinden. Wie beim obengenannten NaN Paket muss man die Google v8 Version nicht mehr beachten. Weiters ist die N-Api in purem C verfasst, was ABI-Kompatibilität ermöglicht. Auch wird dadurch das Schreiben von Bindings zu anderen Programmiersprachen erheblich vereinfacht.

Teile der N-Api, insbesondere das Ausführen von Funktionalität im Hintergrund, sind allerdings noch als „Experimental“ gekennzeichnet. Das bedeutet, dass sich diese Teile der API in zukünftigen NodeJS Versionen noch verändern können.

Die N-Api Schnittstelle ist, wie auch die C++ Addons sehr performant.

Node FFI

…für dynamische Libraries (.dll, .so)

Wer dynamische Bibliotheken einbinden will, ohne C oder C++ Code zu schreiben, kann auf Module wie ffi zurückgreifen. Als Bibliotheken eignen sich zum Beispiel .so Dateien unter Unixoiden oder .dll’s unter Windows, nicht aber .NET/CLR .dll’s. Programmbibliotheken können auch weitere Programmbibliotheken referenzieren. Funktionen solcher referenzierter Libraries funktionieren ohne Weiteres, auch ohne dass man diese explizit angeben muss.

Im Tutorial ist angeführt, wie man das Modul verwendet. Hier als Beispiel eine Funktion, die qpdf verwendet, um Infos über eine .pdf Datei auszugeben. Im Header des C-Interfaces qpdf-c.h finden wir die benötigten Signaturen der C Funktionen:


// init structure
qpdf_data qpdf_init(); // qpdf_data is an opaque pointer
// release opaque pointer
void qpdf_cleanup(qpdf_data* qpdf);
// read a pdf file
QPDF_ERROR_CODE qpdf_read(qpdf_data qpdf, char const* filename,
                          char const* password);
// needed for getting metadata (the author in our case)
char const* qpdf_get_info_key(qpdf_data qpdf, char const* key);
// returns the pdf version
char const* qpdf_get_pdf_version(qpdf_data qpdf);

Nun können wir mit ffi.Library diese Funktionen im JavaScript Code aufrufbar machen. Hier ein erster Lösungsansatz:


const ffi = require("ffi");
function getPdfInfo(filename) {
  try {
    // use ffi to convert native functions
    const qpdf = ffi.Library("libqpdf.so",{
      qpdf_init: ['pointer', []],
      qpdf_cleanup: ['void', ['pointer']],
      qpdf_read: ['int', ['pointer', 'string', 'string']],
      qpdf_get_info_key: ['string', ['pointer', 'string']],
      qpdf_get_pdf_version: ['string', ['pointer']]
    });

    // get the opaque pointer
    // (would be of type qpdf_data in C, but we don't need to care
    // in JS in this example)
    const pdfHandle = qpdf.qpdf_init();
    if(pdfHandle == null) {
      throw new Error("qpdf initialization failed");
    }

    // WRONG: You'll block the main event queue of nodejs!
    if(qpdf.qpdf_read(pdfHandle, "/tmp/test_qdf.pdf", null) != 0) {
      throw new Error("could not read pdf file");
    }

    // get author...will be null if the author is not stored in the pdf
    const author = qpdf.qpdf_get_info_key(pdfHandle, "/Author");

    // get pdf version (e.g. "1.4")
    const pdfVersion = qpdf.qpdf_get_pdf_version(pdfHandle);

    // cleanup. In C this function does not take the handle of type qpdf_data as
    // parameter, but the reference of if (you'll see it in the C header)
    qpdf.qpdf_cleanup(pdfHandle.ref());
    
    console.log(`the author of this pdf (version ${pdfVersion}) is ${ author || "unknown"}`);
  } catch (e) {
    console.error("error", e.message);
  }
}

Wenn diese Funktion mit einem (autorlosem) PDF aufgerufen wird, gibt sie folgendes aus:

> the author of this pdf (version 1.6) is unknown

Der Glue Code wird hier also im Vergleich zu Addons in den JavaScript Code verlagert. Wenn die Parameteranzahl oder die Typen im ffi.Library Aufruf nicht stimmen, führt das meist zu einer Runtime Exception. ffi Unterstützt weiters auch Callback Parameter (hier kann eine JavaScript Funktion angegeben werden), Structs und (ganz wichtig) asynchrone Aufrufe.

Im obigem Beispiel rufen wir nämlich, wie schon im Kommentar erwähnt, lange dauernde native Funktionen in der NodeJS main loop auf. Das blockiert die gesamte Applikation. Das asynchrone interface von ffi funktioniert mit Callbacks. Weil es aber viel hipper ist, Promises und die async/await Syntax zu verwenden, konvertieren wir die Aufrufe mit Bluebird:


const ffi = require("ffi");
const Promise = require("bluebird");

// our function is now "async", i.e. returns a Promise
async function getPdfInfo(filename) {
  try {
    //same as before:
    const qpdf = ffi.Library("libqpdf.so",{
      qpdf_init: ['pointer', []],
      qpdf_cleanup: ['void', ['pointer']],
      qpdf_read: ['int', ['pointer', 'string', 'string']],
      qpdf_get_info_key: ['string', ['pointer', 'string']],
      qpdf_get_pdf_version: ['string', ['pointer']]
    });

    // promisify async function (e.g. qpdf_init.async)
    // all function are replaced "by hand" by their promised version
    Object.keys(qpdf).forEach(funName => {
      qpdf[funName] = Promise.promisify(qpdf[funName].async);
    });

    // now everything is called in the same way as before...just some await statements...

    const pdfHandle = await qpdf.qpdf_init();
    if(pdfHandle == null) {
      throw new Error("qpdf initialization failed");
    }

    // No more blocking of the main event queue of nodejs!
    if((await qpdf.qpdf_read(pdfHandle, filename, null)) != 0) {
      throw new Error("could not read pdf file");
    }

    // not calling those 2 in parallel because qpdf might not like it
    const author = await qpdf.qpdf_get_info_key(pdfHandle, "/Author");
    const pdfVersion = await qpdf.qpdf_get_pdf_version(pdfHandle);

    // clean
    await qpdf.qpdf_cleanup(pdfHandle.ref());
    
    console.log(`the author of this pdf (version ${pdfVersion}) is ${ author || "unknown"}`);
  } catch (e) {
    console.error("error", e.message);
  }
}

Und schon blockiert kein Aufruf mehr die NodeJS main loop.

Neben der recht seltenen Aktualisierung des Source Codes hat ffi aber auch weitere Nachteile:

  • – Performance: der aufgerufene Code selbst läuft zwar schnell, der Aufruf selbst dauert jedoch relativ lange. Das wirkt sich vor allem dann aus, wenn der native Code sehr häufig aufgerufen wird. Es gibt hier die Alternative fastcall, bei der laut eigener Benchmarks die Aufrufe mit wesentlich weniger Overhead belastet sind. Dies trifft jedoch bei der asynchronen API in viel geringerem Maße zu. Fastcall benötigt als zusätzliche Abhängigkeit am System CMake.
  • – Mit C++ kompilierte Bibliotheken kann man nicht direkt einbinden. In diesem Fall muss man vorher einen C Wrapper schreiben. Für die zuvor verwendete Library QPdf, die eigentlich in C++ geschrieben ist, gibt es eben zusätzlich eine solche (minimalistische) C API.

Edge.JSedge.js

…für .NET/CLR Libraries (managed .dll)

Mit ffi oder fastcall können, wie erwähnt, keine .NET .dlls (auch „managed dlls“ genannt) verwendet werden. Das großartige edge.js Modul kann diese nicht nur auf Windows, sondern auch auf Linux und MacOS ausführen. Das System muss nur CoreCLR oder .NET 4.5 (oder Mono 4 auf Linux und Mac) installiert haben (siehe Tabelle auf der edge.js Webseite).

Man kann mit diesem Modul nicht nur .dll Dateien laden, sondern auch direkt C# Code (wie auch Code von andere .NET Programmiersprachen) ausführen lassen. Um die unterschiedlichen Threading Modelle von .NET und NodeJS auf einen Nenner zu bringen, können auf direktem Wege nur Delegates mit der Signatur Func<object, Task<object>> aufgerufen werden, siehe Darstellung in diesem Blog.

Um Funktionen einer .dll, die nicht dieser Signatur entsprechen auszuführen, schreibt man einfach in-line wrapper Code (siehe hier). Um .NET Funktionen mit mehreren Parametern aufzurufen, packt man diese Parameter am besten in ein JSON, wie hier beschrieben. Mit edge.js kann man auch JavaScript Funktionen an den C# Code übergeben, und umgekehrt.

emscriptenEmscripten

…für LLVM Binaries oder Source Code, der in LLVM kompiliert werden kann (z.B. C/C++)

Nun können wir mit edge.js .NET/CLR Binaries einbinden. Aber was ist mit dem „Konkurrenzprodukt“ LLVM? Hier gibt es was noch viel Besseres: Emscripten kompiliert LLVM Code nach JavaScript! Performantes JavaScript! Das funktioniert dann nicht nur unter NodeJS, sondern auch in modernen Browsern. Sogar 3D Engines wie Unity oder Unreal Engine 4 verwenden Emscripten, um die Spiele im Browser laufen zu lassen. Ein weiteres Beispiel wäre OpenCV.

Nun aber zurück zu NodeJS. Nachdem du, wie unter anderem hier beschrieben, deinen C/C++ oder LLVM Code nach JavaScript konvertiert hast, kannst du ihn direkt aufrufen (vorausgesetzt, die compiler parameter, wie EXPORTED_FUNCTIONS, siehe hier passen). Der generierte Code funktioniert dann auch auf jeder Plattform.

Vorsicht ist auch hier mit blockierenden Aufrufen geboten. Alles JavaScript läuft ja bei NodeJS in ein- und demselben Thread. Bei rechenintensiven Aufrufen muss man hier auf Node Subprozesse (via child_process.fork) mit Interprozesskommunikation, oder aber auf intelligenteres Threading Management mit napajs zurückgreifen.

Fazit

Wer in NodeJS sprachfremden Code oder Binärcode verwenden will, hat mitleiweile viele Möglichkeiten. Es gibt auch noch weitere Module wie node-java für Java Bytecode. Der Einsatz eines solchen Modules ist immer mit etwas Einarbeitungszeit verbunden. Allerdings lohnt sich der Einsatz häufig, wenn man Bestehendes in seine NodeJS Applikation einbauen will.