Les modules natifs Node.js avec N-API

Les modules natifs Node.js avec N-API

Vous appréciez notre travail......nous recrutons !

Ne ratez pas nos articles sur l'open source, le big data et les systèmes distribués, fréquence faible d’un email tous les deux mois.

Que sont les modules natifs pour Node.js et comment les créer ? Les addons C/C++ sont une fonctionnalité utile et puissante du runtime Node.js. Explorons-les depuis leurs fonctionnement, jusqu’à leur développement et publication.

Exécuter du JavaScript

La première chose à savoir est comment le code JavaScript est exécuté. Pour cela, nous avons besoin d’un moteur JavaScript. Par exemple, chaque navigateur intègre un moteur JS : Spidermonkey pour Firefox, JavaScriptCore pour Safari et V8 pour Chrome et Chromium. Node.js est également capable d’exécuter du code JavaScript, car il inclut le moteur V8. Il dispose ensuite d’un ensemble de fonctionnalités en dehors de la portée de JavaScript. C’est pourquoi Node.js n’est pas un framework JavaScript, mais un runtime JavaScript. Il interprète JavaScript grâce à V8, puis appelle ses propres fonctionnalités. V8 est écrit en C++. Node.js, également écrit en C++, inclut la bibliothèque V8 dans son code.

Les modules Node.js

Les bibliothèques Node.js prennent la forme de modules que vous intégrez à votre application Node.js grâce à require :

const foo = require('foo');

Ces modules sont généralement écrits en JavaScript et exportent un ensemble de fonctions et de classes. Il existe un autre moyen d’écrire des modules, qui est le sujet de cet article : les addons natifs, en C++.

Bindings

Lors de l’écriture d’un addon natif pour Node.js, le code que vous souhaitez exporter vers JavaScript doit être compris par V8. Nous appelons cela un binding, c’est-à-dire lier votre code natif à des types JavaScript.

std::string text = "Hello World !";
v8::Local<v8::String> jsText = v8::String::NewFromUtf8(v8::Isolate::GetCurrent(), text.c_str());

Comme vous pouvez le constater dans cet exemple de code, nous utilisons des types V8 pour spécifier une chaîne de caractère JavaScript. La syntaxe est un peu lourde et nous ne ferons pas les choses de cette façon. Mais gardez à l’esprit que nous utilisons V8 pour indiquer quels sont les types JavaScript équivalents de nos types C++.

Pourquoi utiliser des addons natifs ?

  • Calculs lourds

    Comme le code natif a de meilleures performances que le JavaScript, il est logique de déplacer des charges de calcul importantes vers un addon natif. Faites cependant attention au léger overhead lorsque vous passez du contexte JavaScript à C++ : il est généralement négligeable, mais il réduira les performances s’il est trop fréquent.

  • Code asynchrone

    Node.js est exécuté sur un seul thread, l’event loop. Il délègue des tâches bloquantes à d’autres threads, des workers du pool de workers (“Worker Pool”). Lors de l’écriture d’un addon natif, vous pouvez et devez également créer des workers pour vos tâches lourdes ou des tâches I/O bloquantes.

  • Binding de bibliothèques C/C++

    Si vous souhaitez utiliser une bibliothèque C ou C++ dans votre code Node.js, vous pouvez binder les fonctionnalités de la bibliothèque afin de pouvoir l’utiliser dans Node.js. C’est typiquement ce qui est fait dans le module krb5. Nous bindons les fonctions nécessaires de la bibliothèque MIT Kerberos afin de construire les fonctions kinit et kdestroy.

Guide de développement

Bibliothèques

La façon de développer des modules natifs a évolué. Nous verrons une brève description de chaque méthode, car vous pourriez trouver dans vos recherches des ressources utiles qui utilisaient les méthodes précédentes, en particulier NAN.

  • Node

    L’utilisation de la bibliothèque node est le moyen le plus basique de développer un addon natif. La bibliothèque node offre un moyen facile d’exporter votre module, ainsi qu’une classe utile pour simplifier le développement, node::ObjectWrap, pour binder des objets C++. En dehors de cela, vous devrez tout faire en utilisant directement la bibliothèque V8. De plus, vous devrez mettre la main sur libuv pour l’asynchronisme, ce qui est assez complexe.

  • NAN

    Pour faciliter les choses, la bibliothèque NAN a été créée : Native Abstraction for Node.js. Elle enveloppe les bibliothèques node et libuv . Vous n’avez pas besoin de vous battre directement avec libuv, vous pouvez simplement utiliser la classe AsyncWorker pour les tâches asynchrones. Cependant, vous devez toujours utiliser la bibliothèque V8.

  • N-API

    Dernier point mais non le moindre, N-API, l’avenir du développement d’addons natifs, officiellement supporté par les mainteneurs de Node.js. N-API abstrait tout. Cette bibliothèque wrap node et libuv (comme NAN), mais elle wrap également V8. Pourquoi l’abstraction V8 est-elle une fonctionnalité intéressante ? Premièrement, cela rend le code plus simple et lisible, mais il y a d’autres raisons. Des efforts sont actuellement faits pour implémenter Node.js à l’aide d’autres moteurs JavaScript (par exemple, node-jsc, basé sur JavaScriptCore). Avec l’abstraction offerte par N-API, votre addon natif devrait pouvoir (à l’avenir) fonctionner pour les deux moteurs. Cette abstraction garantit également que vous n’aurez pas à changer votre code lorsque l’API V8 change (ce qui se produit régulièrement).

    Une caractéristique importante de N-API est sa portabilité entre les versions de Node.js. Si vous avez compilé votre addon avec Node.js 8, vous pouvez effectuer une mise à niveau vers Node.js 10 en toute sécurité sans avoir à le recompiler.

    N-API est une librairie C. Cependant, un wrapper C++ est disponible : node-addon-ap. Je le recommande car cela facilite l’écriture du code et ressemble beaucoup à NAN.

Build

Build un addon natif est une tâche triviale. Nous utilisons l’outil node-gyp. Il suffit de remplir le fichier binding.gyp contenant le chemin d’accès aux fichiers source, les répertoires include, les dépendances et les options du compilateur. Vous pouvez également définir des paramètres spécifiques pour des systèmes d’exploitation ou des architectures spécifiques.

Lorsque vous utilisez N-API avec node-addon-api, voici à quoi devrait ressembler votre fichier binding.gyp :

{
  "targets": [{
    "target_name": "krb5",
    "sources":[
      "./src/module.cc",
      "./src/foo.cc"
    ],
    "cflags!": ["-fno-exceptions"],
    "cflags_cc!": ["-fno-exceptions"],
    "include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
    "dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
  }]
}

Vous devrez également ajouter la dépendance node-addon-api à votre fichier package.json :

"dependencies": {
  "node-addon-api":"latest"
}

Partager votre addon

  • Lancer le build GYP

    C’est une approche simple : ajoutez simplement votre code à votre repository et exécutez la commande node-gyp rebuild lors de l’installation du module. Ajoutez la commande à votre package.json :

    "scripts": {
      "install": "node-gyp rebuild"
    }

    Inconvénient : vous devez disposer de tous les utilitaires de build sur la machine cible, à savoir gcc (c++), make, Python 2 et Bison.

  • Utiliser Prebuild

    L’idée ici est de build votre module et de partager les fichiers compilés. C’est une approche assez nouvelle, en particulier pour le support de N-API, mais je dirais que c’est le moyen le plus propre de partager votre module. Vous n’avez pas besoin de dépendances de build sur la machine cible et vous n’aurez pas à exécuter la compilation du module.

    Tout d’abord, vous devez compiler votre addon natif avec l’outil Prebuild. Maintenant, il y a deux possibilités. La première consiste à push l’addon compilé, situé dans le dossier prebuilds, utilisé dans votre module Node.js. Ils seront téléchargés directement avec NPM et il vous suffira de les installer à l’aide du module prebuild-install.

    Voici un ensemble typique de scripts pour vous aider à démarrer :

    "scripts": {
      "install": "prebuild-install || node-gyp rebuild",
      "prebuild": "prebuild --verbose --strip"
    }

    prebuid est automatiquement appelée lors du npm install. Si le prebuild-install n’a pas fonctionné, la compilation node-gyp sera lancée. npm run prebuild exécute le prebuild et stocke les fichiers binaires dans le dossier prebuilds.

    La deuxième façon, l’approche DevOps, consiste à avoir une chaîne de CI pour tester et release votre module. Une chaîne simple consisterait à tester votre module dans Travis et à publier le nouvel addon compilé sur les releases GitHub une fois les tests passés. Reportez-vous à cette page qui explique comment uploader vos releases sur GitHub. Une fois uploadé, utilisez prebuild-install --download [URL] pour télécharger et installer l’addon compilé.

Remarque : vous devrez peut-être inclure des bibliothèques externes pour votre addon natif. C’est généralement le cas lors du binding d’une bibliothèque native pour Node.js. Vous avez différentes façons de le faire. Si la bibliothèque est bien prise en charge par les gestionnaires de paquets des systèmes, vous pouvez les utiliser. Il faut lancer le gestionnaire de paquets sur la machine cible. Si vous voulez éviter cela, vous pouvez envoyer la bibliothèque avec votre module. Ceci n’est qu’une bonne solution pour les petites bibliothèques, car vous pourriez être limité par la taille du paquet NPM. Si vous envoyez le code source de la bibliothèque, vous pouvez le compiler à l’aide d’un fichier GYP secondaire que vous référencez dans le fichier GYP de votre module. A titre d’exemple, vous pouvez jeter un coup d’œil au module leveldown.

Quelques conseils utiles

Code asynchrone

Les données de l’event loop ne sont pas accessibles dans un thread worker. Vous devez faire une copie. Exemple, utilisez les attributs de classe pour partager des données :

class Worker_double_it: public Napi::AsyncWorker {
  public:
    Worker_double_it(int number, Napi::Function& callback)
      : Napi::AsyncWorker(callback), number_copy(number) {
    }
 
  private:
    void Execute() {
      number_copy *= 2;
    }
 
    void OnOK() {
      Napi::HandleScope scope(Env());       
 
      Callback().Call({
        Napi::Number::New(Env(), number_copy),
      });
    }
 
    int number_copy;
};
 
Napi::Value double_it(const Napi::CallbackInfo& info) {
  if (info.Length() < 2) {
     throw Napi::TypeError::New(info.Env(), "2 arguments expected");
  }
 
  int number = info[0].As<Napi::Number>().Int32Value();
  Napi::Function callback = info[1].As<Napi::Function>();
 
  Worker_double_it* worker = new Worker_double_it(number, callback);
  worker->Queue();
  return info.Env().Undefined();
}

Cet exemple montre l’implémentation d’une fonction qui prend un entier en entrée, lance un thread worker pour le doubler (bien sûr excessif, simplement pour des raisons de démonstration) et appelle la callback avec l’entrée doublée en tant que paramètre. Le projet complet de cet exemple est disponible ici. Comme nous l’avons dit, vous ne devriez pas essayer d’accéder à la variable number dans la méthode Execute(). Tout d’abord, copiez-la, comme nous l’avons fait avec number_copy.

Le code JavaScript ressemblerait à ceci :

const promaths = require("./build/Release/promaths")
 
promaths.double_it(3, function (res) {
  console.log(res); //outputs 6
});

Remarque : Concevez correctement vos mécanismes d’asynchronisme. Vous devriez vraiment lire l’article suivant si vous ne l’avez pas déjà fait : Don’t Block the Event Loop (or the Worker Pool). Entre autres, vous ne devriez pas inonder le pool de workers, ni le bloquer avec des threads de longue durée.

Créez votre API JavaScript

L’application utilisant un addon natif devrait ressembler à ceci :

  • L’application require un module disponible sur NPM

    // Application - index.js
    const myModule = require("myModule");
     
    myModule.myFunction();
  • Le module myModule require l’addon natif :

    // myModule - lib/index.js
    const native_myModule = require("../build/Release/native_myModule")
     
    module.exports = { 
      myFunction: function() {
        // do something with your native addon here
        native_myModule.myNativeFunction();
      }
    }

Votre module NPM devrait exposer les fonctionnalités apportées par votre addon natif. Cela vous permet d’écrire votre API quelle que soit la manière dont elle est implémentée dans l’addon natif. Par exemple, vous ne pouvez pas retourner de promesses depuis votre addon natif, seulement appeler des callbacks. Si vous voulez que votre API fonctionne avec des promesses, vous devez wrap l’addon d’une manière ou d’une autre. Vous pouvez même souhaiter que votre API fonctionne à la fois pour les callbacks et les promesses, selon qu’une callback a été passé ou non (exemple ici).

Buffers et externals

Je veux mentionner ces deux types, les buffers et les externals, car ils ne sont pas très communs, ou du moins, pas intuitifs. Pourtant, ils sont puissants et vont résoudre beaucoup de bindings difficiles.

Les buffers sont des données brutes, des octets stockés dans V8. Vous pouvez prendre n’importe quelle variable C++ de n’importe quel type, la copier dans un buffer accessible en JavaScript (même s’il ne s’agit que de données brutes), puis la reconvertir en un type C++. Il s’agit globalement d’un cast en unsigned char * pour créer le buffer et d’un reinterpret_cast pour le renvoyer au type d’origine T. Vous pouvez utiliser ce type lorsque vous souhaitez stocker des données en dehors de la portée de votre code C++ et être capable de le déplacer depuis JavaScript. Vous pouvez, par exemple, l’utiliser pour une structure complexe comportant de nombreuses couches d’autres structures, etc. lorsque vous ne vous souciez pas vraiment de pouvoir lire ces données en JavaScript car elles ne sont utilisées que par votre code C++. Il vous suffit de les stocker en octets, de le déplacer en JavaScript et de le renvoyer au code C++ à chaque fois que vous souhaitez les réutiliser.

Les externals servent un but similaire, sauf que vous ne stockez pas les données dans la mémoire de V8. Vous laissez le code C++ gérer la variable. La variable external de V8 ne stocke que le pointeur, c’est-à-dire l’adresse en mémoire, de la variable gérée de manière externe. Ceci est extrêmement utile lorsque vous utilisez des variables lourdes que vous utilisez tout le temps, dans plusieurs de vos fonctions (généralement une structure de contexte de bibliothèque). Vous pourriez utiliser des buffers à la place, mais les buffers sont beaucoup plus lourds car vous copiez toutes les données à chaque fois.

Conclusion

Avec les informations fournies dans cet article, vous devriez être en mesure d’avoir une idée claire de la façon de commencer à développer un addon natif et des outils à utiliser au cours de ce processus. Nous avons vu que la méthode privilégiée à l’heure actuelle est d’utiliser N-API. Même si elle wrap tout, vous devez comprendre ce qui se passe en coulisses. Les astuces et les exemples devraient constituer un ajout intéressant à la documentation, car j’ai eu le sentiment qu’elle manquait un peu d’orientation. Bon développement !

Partagez cet article

Canada - Maroc - France

Nous sommes une équipe passionnée par l'Open Source, le Big Data et les technologies associées telles que le Cloud, le Data Engineering, la Data Science le DevOps…

Nous fournissons à nos clients un savoir faire reconnu sur la manière d'utiliser les technologies pour convertir leurs cas d'usage en projets exploités en production, sur la façon de réduire les coûts et d'accélérer les livraisons de nouvelles fonctionnalités.

Si vous appréciez la qualité de nos publications, nous vous invitons à nous contacter en vue de coopérer ensemble.

Support Ukrain