23.05.20197 min

Marcin BaranieckiFront-end LionSoftwareMill

Rozszerzanie Node.js natywnymi modułami C++

Sprawdź, jak rozszerzyć Node.js o moduły natywne skompilowanego kodu C/C++.

Rozszerzanie Node.js natywnymi modułami C++

Node.js może nie tylko ładować biblioteki JavaScript, ale także zostać rozszerzony o moduły natywne (skompilowany kod C/C++). Chociaż nie oznacza to, że należy wyrzucić istniejące moduły JavaScript na rzecz starego dobrego C++. Wiedza ta może okazać się przydatną w kilku konkretnych przypadkach.


Jestem programistą JavaScript, dlaczego miałbym używać C++?

Po pierwsze

Masz bezpośredni dostęp do istniejących (starszych) bibliotek C/C++. Zamiast wywoływać je jak aplikacje zewnętrzne w stylu „wykonaj komendę”, sięgnij bezpośrednio po istniejący kod źródłowy i przekaż wyniki z powrotem do Node.js w formie zrozumiałej dla środowiska wykonawczego JavaScript. W ten sposób możesz również uzyskać dostęp do niskopoziomowego API systemu operacyjnego.

Po drugie

Wydajność. W wielu sytuacjach dobrze napisany kod natywny może okazać się szybszy i bardziej wydajny niż odpowiednik w JavaScripcie.

Disclaimer: Niektórzy z Was mogą być zaskoczeni, że stawiam wydajność jako drugą, ale zrobiłem to celowo - ogólnie lepsza wydajność kodu natywnego nie powinna być powodem ponownego przepisywania każdego fragmentu kodu JavaScript do odpowiednika w C/C ++. Istnieje wiele przypadków, w których zwykły kod JavaScript będzie nadal bardziej skuteczny, niż ponowne odkrycie koła w formie kodu C/C ++, napisanego przez niedoświadczonego programistę.

Uważaj - przechodzenie na niższy poziom może skończyć się tak, jak odpicowanie starej bryki w warsztacie. Zamontowanie nitro w Twoim starym silniku może mieć katastrofalne skutki:

Ten koleś był zbyt entuzjastycznie nastawiony do modułów C ++ w aplikacjach Node.js.


Witaj, (natywny) świecie!

Wystarczy już tych ostrzeżeń, jesteś już gotowy na swoje pierwsze wiersze kodu C++, które zostaną skompilowane do natywnego modułu Node’a.

Na potrzeby tego artykułu będę używał natywnych abstrakcji dla Node.js (Nan), który jest zasadniczo plikiem nagłówkowym C++, zapewniającym namespace’y i zestaw przydatnych makr dla łatwiejszej interakcji z API w V8 (silnik JavaScript używany w Node.js). Sprawia to, że Twój kod jest odporny na przyszłe zmiany (w przypadku nowych wersji Node.js), ponieważ API w V8 mają tendencję do dramatycznych zmian między wersjami. Nan jest zalecanym rozwiązaniem w docsach API node.js.

Utwórz plik .cpp (nazwałem go main.cpp) i wypełnij go następującym fragmentem kodu:

#include <nan.h>

// NAN_METHOD is a Nan macro enabling convenient way of creating native node functions.
// It takes a method's name as a param. By C++ convention, I used the Capital cased name.
NAN_METHOD(Hello) {
    // Create an instance of V8's String type
    auto message = Nan::New("Hello from C++!").ToLocalChecked();
    // 'info' is a macro's "implicit" parameter - it's a bridge object between C++ and JavaScript runtimes
    // You would use info to both extract the parameters passed to a function as well as set the return value.
    info.GetReturnValue().Set(message);
}

// Module initialization logic
NAN_MODULE_INIT(Initialize) {
    // Export the `Hello` function (equivalent to `export function Hello (...)` in JS)
    NAN_EXPORT(target, Hello);
}

// Create the module called "addon" and initialize it with `Initialize` function (created with NAN_MODULE_INIT macro)
NODE_MODULE(addon, Initialize);

Hello, (native) world!


Zauważ, że metody utworzone w C++ nie zwracają żadnej wartości jawnie. Zamiast tego zwracana wartość jest ustawiana na "pośredniczącym" obiekcie info (referencji do typu Nan::FunctionCallbackInfo, “pośrednio” przekazanej w marko NAN_METHOD).

Aby uruchomić kod, nadal musisz go skompilować. Nie bój się, nie musisz ręcznie wywoływać kompilatora C++. Node.js Cię wyręczy.

Na początek zainicjuj projekt Node.js w tym samym katalogu, w którym znajduje się plik main.cpp:

npm init -y


Dalej, zainstaluj Nan i node-gyp (znasz już Nan; node-gyp to zestaw narzędzi dla kompilacji kodu natywnego):

npm install nan node-gyp --save


Zaktualizuj plik package.json, aby zawierał następujące wiersze (wersje Twoich zależności mogą się nieznacznie różnić, ale to całkowicie w porządku!):

{
  "name": "node-native-addons-example",
  "version": "1.0.0",
  "dependencies": {
    "nan": "^2.6.1",
    "node-gyp": "^3.6.0"
  },
  "scripts": {
    "compile": "node-gyp rebuild",
    "start": "node main.js"
  },
  "gypfile": true
}


Ważną częścią jest tutaj sekcja “scripts”:

  • :compile": “node-gyp rebuild” - dla kompilacji kodu C++
  • "start": "node main.js" - dla naszego głównego skryptu wykonawczego

Teraz utwórz plik bindings.gyp - to rodzaj pliku konfiguracyjnego dla node-gyp. Zwróć uwagę, jak używa nazwy pliku „main.cpp” (źródła „hello world”) w tablicy „sources”:

{
  "targets": [
    {
      "include_dirs": [
        "<!(node -e \"require('nan')\")"
      ],
      "target_name": "addon",
      "sources": [ "main.cpp" ]
    }
  ]
}


Te trzy narzędzia będą musiały być zainstalowane na Twoim komputerze (na MacOS nie powinieneś mieć problemów - są one zainstalowane fabrycznie; są również łatwe do zainstalowania na Linuksie, np. na Ubuntu z apt-get):

  • make
  • g++
  • python 2.7


Jeśli czegoś brakuje, node-gyp to zgłosi, więc nie trać czasu, zastanawiając się, czy masz je w swoim systemie operacyjnym, czy nie.

Teraz nadszedł czas na pierwszą kompilację - npm run compile (pamiętaj, uruchomi ona node-gyp i skompiluje źródła C++ tak jak skonfigurowano w pliku bindings.gyp). Jeśli wszystko przebiegnie gładko, powinieneś zobaczyć podobny output:

 node-native-addons-example@1.0.0 compile /Users/marcin/projects/node-native-addons-example
> node-gyp rebuild
CXX(target) Release/obj.target/addon/main.o
 SOLINK_MODULE(target) Release/addon.node


Super, masz tu swój pierwszy skompilowany i gotowy do użycia moduł natywny. Stwórzmy plik JavaScript, który będzie uruchamiany z Node.js.

// note that the compiled addon is placed under following path
const {Hello} = require('./build/Release/addon');

// `Hello` function returns a string, so we have to console.log it!
console.log(Hello());


Woohoo! Jeśli wszystko poszło dobrze, powinieneś zobaczyć każdą wiadomość, którą zwróciłeś ze swojego dodatku C++ w konsoli.


Urocze, ale co powiesz na coś bardziej użytecznego?

Oczywiście. Chciałbyś zobaczyć prawdziwą moc natywnych modułów.

W tym celu zaimplementujemy prostą funkcję, która sprawdzi, czy podana liczba jest liczbą pierwszą i zwraca wartość logiczną (prawda lub fałsz). Będziemy używać tego samego algorytmu zarówno dla wersji C++, jak i funkcji JavaScript, i ostatecznie porównamy czasy wykonania z tą samą, stosunkowo dużą liczbą.

Algorytm:

  • najpierw sprawdzi, czy jedynym argumentem jest liczba (jeśli nie - wyrzuci TypeError);
  • po drugie sprawdzi, czy liczba jest mniejsza niż 2 (jeśli tak, zwróć true);
  • po trzecie, będzie iterować w zakresie od 2 do jakiejś liczby (wyłącznie) i sprawdzi, czy operacja modulo daje zero (jeśli tak, przerwie pętlę i zwróci false, ponieważ taka liczba nie jest liczbą pierwszą);
  • zwróci true na samym końcu - liczbie udało się przedostać przez pętle, więc musi być pierwsza.


(dla uproszczenia celowo pominąłem sprawdzenie liczb całkowitych lub ujemnych; Potraktuj to jako ćwiczenie!)

Utwórz plik: isPrime.js :

module.exports = (number) => {
  if (typeof number !== 'number') {
    throw new TypeError('argument must be a number!');
  }

  if (number < 2) {
    return false;
  }

  for (let i = 2; i < number; i++) {
    if (number % i === 0) {
      return false;
    }
  }

  return true;
};


Teraz dla pliku main.cpp zastąp jego zawartość następującym kodem (zauważ, że usunąłem funkcję Hello):

#include <nan.h>

NAN_METHOD(IsPrime) {
    if (!info[0]->IsNumber()) {
        Nan::ThrowTypeError("argument must be a number!");
        return;
    }
    
    int number = (int) info[0]->NumberValue();
    
    if (number < 2) {
        info.GetReturnValue().Set(Nan::False());
        return;
    }
    
    for (int i = 2; i < number; i++) {
        if (number % i == 0) {
            info.GetReturnValue().Set(Nan::False());
            return;
        }
    }
    
    info.GetReturnValue().Set(Nan::True());
}

NAN_MODULE_INIT(Initialize) {
    NAN_EXPORT(target, IsPrime);
}

NODE_MODULE(addon, Initialize);


Otwórz swój plik main.js i zamień istniejące treści następującym kodem:

const {IsPrime} = require('./build/Release/addon'); // native c++
const isPrime = require('./isPrime'); // js

const number = 654188429; // thirty-fifth million first prime number (see https://primes.utm.edu/lists/small/millions/)
const NATIVE = 'native';
const JS = 'js';

console.time(NATIVE);
console.log(`${NATIVE}: checking whether ${number} is prime... ${IsPrime(number)}`);
console.timeEnd(NATIVE);
console.log('');
console.time(JS);
console.log(`${JS}: checking whether ${number} is prime... ${isPrime(number)}`);
console.timeEnd(JS);


Odpal npm run compile, by skompilować moduł C++ oraz npm start, by wykonać plik main.js. Wykonujemy zarówno funkcje C++, jak i JavaScript, aby określić, czy 654188429 (dość duża liczba) jest liczbą pierwszą, czy nie.

Zajmuje to średnio 1800 ms dla funkcji C++ i 3100 ms dla funkcji JavaScript w moim Macbooku Pro 2015 (macOS Sierra 10.12, Intel i5, 8 GB RAM), co oznacza, że wersja algorytmu C++ jest 1,7 razy szybsza niż jego odpowiednik JavaScript.

Repo z kompletnym przykładem jest dostępne na moim Githubie.


Wniosek

W tym artykule chciałem pokazać, że platforma Node.js jest otwarta na natywne rozszerzenia niskiego poziomu. Jednak proces pisania wymaga pewnego przygotowania i dodatkowego oprzyrządowania. Chociaż kod natywny może być rzeczywiście bardziej wydajny, nadal powinieneś preferować rozwiązania JavaScript, chyba że istnieje pilna potrzeba dokonania dużych optymalizacji lub uzyskania dostępu do niskopoziomowych API.

Linki

<p>Loading...</p>