◆ C++ で書く Native addon
◆ これまでの v8 依存の方法と それをバージョン依存しないようにした nan がある
◆ N-API は v8 とは独立して node.js で管理していて v8 のバージョンに依存しないバイナリインターフェース
  ◆ コンパイル時と異なるバージョンでも nan のような再コンパイルが不要になる
◆ 普通に書くと nan よりもコードが多くて複雑
  ◆ ラッパーライブラリの node-addon-api を使うとシンプルに書ける
◆ 速度はもちろん JavaScript よりは早い
  ◆ ものによっては組み込み機能より早いけど繰り返してると N-API のほうが遅くなった

Node.js の Native Addon を使ってみました
C++ を書いてビルドしてから使うやつです

Native Addon

Native Addon を調べてみると node.h と v8.h を include して C++ で直接使ってる方法やバージョンに依存させずシンプルに書ける nan というのを使ってるのがよく出てきます
Native Addon というだけあって JavaScript と違い Node.js というか内部の V8 のバージョンに依存しているので書き方がよく変わったりするようです
JavaScript だと互換性をあまり壊さないようにしたりされてますが Native な部分になるとそうでもないようです

バージョンが変わるたびに Native Addon のコードを修正するのは大変なので コードを変えなくて良いように書けるようにしたものが nan です
「Native Abstractions for Node.js」 の略で 「Not a Number」ではありません

最近 N-API というのを聞いたけどこれとは違うのかな?と調べてみるとまた別物でした
nan は nan のマクロで書いたコードが各ランタイム用に展開されるもので 実行する環境用の C++ コードを直接書いたのと同じになります

N-API は V8 とは独立していて Node.js 側で管理されている Application Binary Interface です
コンパイルしたものを再コンパイルなしで別のバージョンで動かすことを目的としています

こっちのほうが新しいものみたいですし マクロで無理やり頑張る nan よりは共通のインターフェースであるこっちのほうがよさそうですね
ただ バージョンが違っても動くような共通インターフェースなものより 目的のものに特化してるほうがパフォーマンスは優れそうですが nan と比べて遅かったりするのでしょうか?

その他細かいことはドキュメント参照です
https://nodejs.org/dist/latest-v10.x/docs/api/addons.html

比較

例はこのリポジトリにまとまっています
abi-stable-node-addon-examples

C++ ファイルと .gyp ファイルが必要になります
.gyp がコンパイルに関連する設定を書くファイルです

gyp というのはビルドツールで v8 や Chrome のビルドに使われていました
今では Chrome は gn という新しいのに移行したので Node.js も gn になったのかなと思ったのですが 今でも node-gyp を使い続けるようです
https://github.com/nodejs/node/issues/6089


リポジトリの例の 1 つ目の Hello world を見てみます

C++ で直接書く


#include <node.h>
#include <v8.h>

using namespace v8;

void Method(const v8::FunctionCallbackInfo<Value>& args) {
Isolate* isolate = Isolate::GetCurrent();
HandleScope scope(isolate);
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world"));
}

void Init(Handle<Object> exports) {
Isolate* isolate = Isolate::GetCurrent();
exports->Set(String::NewFromUtf8(isolate, "hello"),
FunctionTemplate::New(isolate, Method)->GetFunction());
}

NODE_MODULE(hello, Init)
{
"targets": [
{
"target_name": "hello",
"sources": [ "hello.cc" ]
}
]
}

使う側は

var addon = require('bindings')('hello');

console.log(addon.hello()); // 'world'

です

C++ 部分が複雑でよくわからないですね
これは 0.12 のときのものです
最近のバージョンは見当たらなかったです
最近は nan など使うのが当たり前で直接書くようなことはないからでしょうか

nan


#include <nan.h>

void Method(const Nan::FunctionCallbackInfo<v8::Value>& info) {
info.GetReturnValue().Set(Nan::New("world").ToLocalChecked());
}

void Init(v8::Local<v8::Object> exports) {
exports->Set(Nan::New("hello").ToLocalChecked(),
Nan::New<v8::FunctionTemplate>(Method)->GetFunction());
}

NODE_MODULE(hello, Init)
{
"targets": [
{
"target_name": "hello",
"sources": [ "hello.cc" ],
"include_dirs": [
"<!(node -e \"require('nan')\")"
]
}
]
}

C++ コードは少し短くなりました
代わりに gyp ファイルが長くなっています

使う側は一緒です

N-API


#include <node_api.h>
#include <assert.h>

napi_value Method(napi_env env, napi_callback_info info) {
napi_status status;
napi_value world;
status = napi_create_string_utf8(env, "world", 5, &world);
assert(status == napi_ok);
return world;
}

#define DECLARE_NAPI_METHOD(name, func) \
{ name, 0, func, 0, 0, 0, napi_default, 0 }

napi_value Init(napi_env env, napi_value exports) {
napi_status status;
napi_property_descriptor desc = DECLARE_NAPI_METHOD("hello", Method);
status = napi_define_properties(env, exports, 1, &desc);
assert(status == napi_ok);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
{
"targets": [
{
"target_name": "hello",
"sources": [ "hello.cc" ]
}
]
}

C++ コードが長いです
これなら nan のほうがいいんじゃないの?って思うくらいです

node-addon-api

ドキュメントを見てると node-addon-api という N-API のラッパーライブラリが紹介されていました
あくまでラッパーライブラリの例の一つで これらのライブラリは N-API の一部でも Node.js の一部でもないという扱いみたいです

例を見てみると

#include <napi.h>

Napi::String Method(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
return Napi::String::New(env, "world");
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "hello"),
Napi::Function::New(env, Method));
return exports;
}

NODE_API_MODULE(hello, Init)
{
"targets": [
{
"target_name": "hello",
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"sources": [ "hello.cc" ],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
}
]
}

かなりシンプルになってます
代わりに gyp ファイルは複雑になりました

MD5 計算してみる

node-addon-api くらいのシンプルさなら使ってみようかなという気になったので 使ってみました
どうせなら速度比較するために Node.js 組み込みや npm のライブラリがあるものでそれなりに多くの計算をするアルゴリズムが良いなと思ってとりあえず MD5 にしてみました
有名ドコロなので C++ のアルゴリズム部分のコードは適当に拾ってこれそうですし

node-addon-api で実装

まず MD5 の部分ですが fast md5 あたりで検索してでてきた このページのコードを使います

md5.c の方にはコアの計算部分のみで md5 の計算結果を取得するための処理は md5-test.c の方にありました
必要なところだけ合わせて 1 つのファイルにしました

[md5.c]
/* 
* MD5 hash in C
*
* Copyright (c) 2017 Project Nayuki. (MIT License)
* https://www.nayuki.io/page/fast-md5-hash-implementation-in-x86-assembly
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/

#include <stdint.h>
#include <stddef.h>
#include <string.h>

void md5_compress(uint32_t state[], const uint8_t block[]) {
#define LOADSCHEDULE(i) \
schedule[i] = (uint32_t)block[i * 4 + 0] << 0 \
| (uint32_t)block[i * 4 + 1] << 8 \
| (uint32_t)block[i * 4 + 2] << 16 \
| (uint32_t)block[i * 4 + 3] << 24;

uint32_t schedule[16];
LOADSCHEDULE( 0)
LOADSCHEDULE( 1)
LOADSCHEDULE( 2)
LOADSCHEDULE( 3)
LOADSCHEDULE( 4)
LOADSCHEDULE( 5)
LOADSCHEDULE( 6)
LOADSCHEDULE( 7)
LOADSCHEDULE( 8)
LOADSCHEDULE( 9)
LOADSCHEDULE(10)
LOADSCHEDULE(11)
LOADSCHEDULE(12)
LOADSCHEDULE(13)
LOADSCHEDULE(14)
LOADSCHEDULE(15)

#define ROTL32(x, n) (((0U + (x)) << (n)) | ((x) >> (32 - (n)))) // Assumes that x is uint32_t and 0 < n < 32
#define ROUND0(a, b, c, d, k, s, t) ROUND_TAIL(a, b, d ^ (b & (c ^ d)), k, s, t)
#define ROUND1(a, b, c, d, k, s, t) ROUND_TAIL(a, b, c ^ (d & (b ^ c)), k, s, t)
#define ROUND2(a, b, c, d, k, s, t) ROUND_TAIL(a, b, b ^ c ^ d , k, s, t)
#define ROUND3(a, b, c, d, k, s, t) ROUND_TAIL(a, b, c ^ (b | ~d) , k, s, t)
#define ROUND_TAIL(a, b, expr, k, s, t) \
a = 0U + a + (expr) + UINT32_C(t) + schedule[k]; \
a = 0U + b + ROTL32(a, s);

uint32_t a = state[0];
uint32_t b = state[1];
uint32_t c = state[2];
uint32_t d = state[3];

ROUND0(a, b, c, d, 0, 7, 0xD76AA478)
ROUND0(d, a, b, c, 1, 12, 0xE8C7B756)
ROUND0(c, d, a, b, 2, 17, 0x242070DB)
ROUND0(b, c, d, a, 3, 22, 0xC1BDCEEE)
ROUND0(a, b, c, d, 4, 7, 0xF57C0FAF)
ROUND0(d, a, b, c, 5, 12, 0x4787C62A)
ROUND0(c, d, a, b, 6, 17, 0xA8304613)
ROUND0(b, c, d, a, 7, 22, 0xFD469501)
ROUND0(a, b, c, d, 8, 7, 0x698098D8)
ROUND0(d, a, b, c, 9, 12, 0x8B44F7AF)
ROUND0(c, d, a, b, 10, 17, 0xFFFF5BB1)
ROUND0(b, c, d, a, 11, 22, 0x895CD7BE)
ROUND0(a, b, c, d, 12, 7, 0x6B901122)
ROUND0(d, a, b, c, 13, 12, 0xFD987193)
ROUND0(c, d, a, b, 14, 17, 0xA679438E)
ROUND0(b, c, d, a, 15, 22, 0x49B40821)
ROUND1(a, b, c, d, 1, 5, 0xF61E2562)
ROUND1(d, a, b, c, 6, 9, 0xC040B340)
ROUND1(c, d, a, b, 11, 14, 0x265E5A51)
ROUND1(b, c, d, a, 0, 20, 0xE9B6C7AA)
ROUND1(a, b, c, d, 5, 5, 0xD62F105D)
ROUND1(d, a, b, c, 10, 9, 0x02441453)
ROUND1(c, d, a, b, 15, 14, 0xD8A1E681)
ROUND1(b, c, d, a, 4, 20, 0xE7D3FBC8)
ROUND1(a, b, c, d, 9, 5, 0x21E1CDE6)
ROUND1(d, a, b, c, 14, 9, 0xC33707D6)
ROUND1(c, d, a, b, 3, 14, 0xF4D50D87)
ROUND1(b, c, d, a, 8, 20, 0x455A14ED)
ROUND1(a, b, c, d, 13, 5, 0xA9E3E905)
ROUND1(d, a, b, c, 2, 9, 0xFCEFA3F8)
ROUND1(c, d, a, b, 7, 14, 0x676F02D9)
ROUND1(b, c, d, a, 12, 20, 0x8D2A4C8A)
ROUND2(a, b, c, d, 5, 4, 0xFFFA3942)
ROUND2(d, a, b, c, 8, 11, 0x8771F681)
ROUND2(c, d, a, b, 11, 16, 0x6D9D6122)
ROUND2(b, c, d, a, 14, 23, 0xFDE5380C)
ROUND2(a, b, c, d, 1, 4, 0xA4BEEA44)
ROUND2(d, a, b, c, 4, 11, 0x4BDECFA9)
ROUND2(c, d, a, b, 7, 16, 0xF6BB4B60)
ROUND2(b, c, d, a, 10, 23, 0xBEBFBC70)
ROUND2(a, b, c, d, 13, 4, 0x289B7EC6)
ROUND2(d, a, b, c, 0, 11, 0xEAA127FA)
ROUND2(c, d, a, b, 3, 16, 0xD4EF3085)
ROUND2(b, c, d, a, 6, 23, 0x04881D05)
ROUND2(a, b, c, d, 9, 4, 0xD9D4D039)
ROUND2(d, a, b, c, 12, 11, 0xE6DB99E5)
ROUND2(c, d, a, b, 15, 16, 0x1FA27CF8)
ROUND2(b, c, d, a, 2, 23, 0xC4AC5665)
ROUND3(a, b, c, d, 0, 6, 0xF4292244)
ROUND3(d, a, b, c, 7, 10, 0x432AFF97)
ROUND3(c, d, a, b, 14, 15, 0xAB9423A7)
ROUND3(b, c, d, a, 5, 21, 0xFC93A039)
ROUND3(a, b, c, d, 12, 6, 0x655B59C3)
ROUND3(d, a, b, c, 3, 10, 0x8F0CCC92)
ROUND3(c, d, a, b, 10, 15, 0xFFEFF47D)
ROUND3(b, c, d, a, 1, 21, 0x85845DD1)
ROUND3(a, b, c, d, 8, 6, 0x6FA87E4F)
ROUND3(d, a, b, c, 15, 10, 0xFE2CE6E0)
ROUND3(c, d, a, b, 6, 15, 0xA3014314)
ROUND3(b, c, d, a, 13, 21, 0x4E0811A1)
ROUND3(a, b, c, d, 4, 6, 0xF7537E82)
ROUND3(d, a, b, c, 11, 10, 0xBD3AF235)
ROUND3(c, d, a, b, 2, 15, 0x2AD7D2BB)
ROUND3(b, c, d, a, 9, 21, 0xEB86D391)

state[0] = 0U + state[0] + a;
state[1] = 0U + state[1] + b;
state[2] = 0U + state[2] + c;
state[3] = 0U + state[3] + d;
}

#define BLOCK_LEN 64 // In bytes
#define STATE_LEN 4 // In words

void md5_hash(const uint8_t message[], size_t len, uint32_t hash[]) {
hash[0] = UINT32_C(0x67452301);
hash[1] = UINT32_C(0xEFCDAB89);
hash[2] = UINT32_C(0x98BADCFE);
hash[3] = UINT32_C(0x10325476);

#define LENGTH_SIZE 8 // In bytes

size_t off;
for (off = 0; len - off >= BLOCK_LEN; off += BLOCK_LEN)
md5_compress(hash, &message[off]);

uint8_t block[BLOCK_LEN] = {0};
size_t rem = len - off;
memcpy(block, &message[off], rem);

block[rem] = 0x80;
rem++;
if (BLOCK_LEN - rem < LENGTH_SIZE) {
md5_compress(hash, block);
memset(block, 0, sizeof(block));
}

block[BLOCK_LEN - LENGTH_SIZE] = (uint8_t)((len & 0x1FU) << 3);
len >>= 5;
for (int i = 1; i < LENGTH_SIZE; i++, len >>= 8)
block[BLOCK_LEN - LENGTH_SIZE + i] = (uint8_t)(len & 0xFFU);
md5_compress(hash, block);
}

元コードにあった 引数の配列部分の static 表記はコンパイル時にエラーになったので削除してます
gyp の cflags とかをいじってみてもダメでした

次は md5.c のヘッダファイルです

[md5.h]
#include <stdint.h>
#include <stddef.h>
#include <string.h>

extern "C" {
extern void md5_hash(const uint8_t message[], size_t len, uint32_t hash[]);
}

JavaScript から値を受け取って md5 関数を通して結果を返す部分を作ります
引数チェックや md5 関数用の型変換です

[addon.cc]
#include "md5.h"
#include <napi.h>
#include <iomanip>

Napi::Value Md5(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();

if (info.Length() < 1) {
Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException();
return env.Null();
}

if (!info[0].IsString()) {
Napi::TypeError::New(env, "Wrong arguments").ThrowAsJavaScriptException();
return env.Null();
}

std::string str = info[0].As<Napi::String>().ToString();
std::vector<uint8_t> vec(str.begin(), str.end());
uint8_t *msg = &vec[0];

uint32_t hash[4];
md5_hash(msg, vec.size(), hash);

// to hex string
std::stringstream stream;

for(int i=0; i<4; i++){
stream << std::setfill('0') << std::setw(2) << std::hex << ((hash[i] & 0x000000FF) >> 0);
stream << std::setfill('0') << std::setw(2) << std::hex << ((hash[i] & 0x0000FF00) >> 8);
stream << std::setfill('0') << std::setw(2) << std::hex << ((hash[i] & 0x00FF0000) >> 16);
stream << std::setfill('0') << std::setw(2) << std::hex << ((hash[i] & 0xFF000000) >> 24);
}

return Napi::String::New(env, stream.str());
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "md5"),
Napi::Function::New(env, Md5));
return exports;
}

NODE_API_MODULE(addon, Init)

たったこれだけなのにすごく時間がかかりました
やっぱり IDE なしで C++ なんて書きたくないです
目的の型に変換するだけで一苦労です

型変換するみたいな基本的な処理のやり方が整理されてまとまってるところがあればいいのですけどね
場所によって書いてることが違ったり動いたり動かなかったりで嫌になってきます


gyp ファイルはソースに md5.c を追加するくらいでした

[binding.gyp]
{
"targets": [
{
"target_name": "addon",
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"sources": [ "md5.c", "addon.cc" ],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
}
]
}

package.json は比較用に md5 パッケージを追加しただけで N-API 系には手を加えていません

[package.json]
{
"name": "hello_world",
"version": "0.0.0",
"description": "Node.js Addons Example #1",
"main": "hello.js",
"private": true,
"dependencies": {
"bindings": "~1.2.1",
"md5": "^2.2.1",
"node-addon-api": "^1.0.0"
},
"scripts": {
"test": "node hello.js"
},
"gypfile": true
}

実行する JavaScript ファイルも特に変わりなしです

[addon.js]
const addon = require("bindings")("addon")
console.log(addon.md5("abc"))


動かす

これらのファイルを同じフォルダに置いたら 「npm install」 を実行します
node-addon-api などがインストールされ その後自動で N-API の部分がビルドされます

sqlite などのパッケージを入れる時に自動でビルド処理がされますが それと同じような感じです
package.json に 「gypfile: true」 が設定されていれば binding.gyp に従って勝手にやってくれるみたいです
C++ のファイルを更新したときも npm install を実行すればリビルドしてくれました

速度比較

組み込み機能と N-API と JavaScript での処理 (npm ライブラリ) を比較してみます

「native」 は組み込みの crypto モジュールを使う方法です
「napi」 は今回作った native addon です
「npm」 は npm にあった md5 のライブラリです

const addon = require("bindings")("addon")
const crypto = require("crypto")
const md5 = require("md5")

console.log(native("abc"))
console.log(napi("abc"))
console.log(npm("abc"))

const num = 10000000
console.log("num: ", num)
measure("native", native, num)
measure("napi", napi, num)
measure("npm", npm, num)

function measure(name, fn, num){
const msg = "foobarbaz12345"
console.time(name)
for(let i=0;i<num;i++) fn(msg)
console.timeEnd(name)
}

function native(msg){
return crypto.createHash("md5").update(msg).digest("hex")
}

function napi(msg){
return addon.md5(msg)
}

function npm(msg){
return md5(msg)
}


num を変えて 3 回ずつ試しました
最初の console.log は結果が同じになることを確かめてるだけなので 1 回目以降は省略してます

900150983cd24fb0d6963f7d28e17f72
900150983cd24fb0d6963f7d28e17f72
900150983cd24fb0d6963f7d28e17f72

num: 100
native: 0.715ms
napi: 0.275ms
npm: 4.207ms

num: 100
native: 0.725ms
napi: 0.316ms
npm: 4.157ms

num: 100
native: 1.176ms
napi: 0.310ms
npm: 4.158ms


num: 10000
native: 44.221ms
napi: 27.736ms
npm: 106.474ms

num: 10000
native: 42.949ms
napi: 26.894ms
npm: 106.556ms

num: 10000
native: 43.691ms
napi: 28.504ms
npm: 105.237ms


num: 100000
native: 212.871ms
napi: 251.072ms
npm: 504.674ms

num: 100000
native: 193.499ms
napi: 246.103ms
npm: 512.337ms

num: 100000
native: 200.059ms
napi: 239.714ms
npm: 505.803ms


num: 10000000
native: 16918.929ms
napi: 24186.466ms
npm: 44565.530ms

num: 10000000
native: 16957.654ms
napi: 25357.619ms
npm: 44796.333ms

num: 10000000
native: 18007.055ms
napi: 25007.414ms
npm: 45060.242ms

npm は JavaScript なのでやはり大きく劣っています

100 回や 10000 回だと N-API が一番早いです
さすが fast と書いてた MD5 を計算するコードを使っただけあります

ですが 100000 回 (10万) や 10000000 回 (1000万) になると native のほうが早くなります
回数が少ない頃は native のほうが遅いので 「1 回あたりの計算は native のほうが早いけど最初の 1 回に大きめのコストがあって そのコストを超えるまでは napi のほうが早い」 ということかと思いました
しかし 計測とは別で先に一度 abc のハッシュ値を計算してるので そういうのがあっても計測時には影響しなそうです

なので napi 側に回数が増えると遅くなる原因があるのかもしれません