N-APIとnode-addon-apiでNode.jsのネイティブ拡張を作る

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

福岡のyoshihitohです。

以前紹介した Emscripten を使うと、C/C++のライブラリをJavaScriptやWebAssemblyにビルドすることができます。これを使ってライブラリを個人開発しているんですが、 Node.js環境固有の問題 が発生したことと、パフォーマンス向上のためネイティブ化して欲しいという要望がありました。ちょうど良い機会だったのでNode.jsのネイティブ拡張について調べてみました。

今回はNode.jsの N-API と、そのラッパーライブラリ node-addon-api を使ってネイティブ拡張を実装してみます。

検証環境

  • macOS: 10.13.6
  • Node.js: v11.6.0
  • Command Line Tools for Xcode: 10.0.0.0.1.1535735448
  • node-gyp: 3.8.0

試すこと

C言語実装のデータ圧縮ライブラリ Zstandard をNode.jsから呼び出せるようにします。以下のコードのように使えるようにしてみます。

// 圧縮対象のデータを読み込む
const fs = require('fs');
const original = toArrayBuffer(fs.readFileSync('original-data.txt'));

// ネイティブ拡張を読み込む
const napi = require('bindings')('zstd_napi');
const zstd = new napi.Zstd();

// 圧縮する
const compresed = zstd.compress(original);

// 伸長する
const decompressed = zstd.decompress(compresed);

// 圧縮/伸長結果を比較
const compare_rc = Buffer.compare(original, decompressed);
console.log(`compare_rc: ${compare_rc}`); // 内容が一致してれば0になる

ネイティブ拡張の作り方

Node.jsの公式ドキュメント を確認したところ、ネイティブ拡張を作る方法は何通りかあるようです。

  • V8エンジンのAPIを利用する方法
  • nan (Native Abstractions for Node.js)を利用する
  • N-API を利用する

歴史的な経緯で何通りかの方法が用意されているようです。以下の記事がとても参考になりました。

今回利用する N-API はABI安定化のために用意されたC言語のAPIで、 node-addon-api は、N-APIをC++から利用するためのラッパークラスのライブラリです。今回はコード量を減らすためC++でネイティブ拡張を実装してみます。

実装時に発生する問題を切り分けしやすくするため、Zstandardをラップするクラスと、N-API/node-addon-apiをラップする2つのクラスを作成します。

前提条件

C/C++のビルド環境はインストール済みの前提です。mac環境の場合はどちらもbrewでインストールできます。 Linux環境もおそらく同様にパッケージマネージャから導入できると思います。

ネイティブ拡張を実装する

プロジェクトの構成

以下の構成でネイティブ拡張のプロジェクトを作成します。

napi-zstd
├── binding.gyp
├── external
│  └── zstd
├── package.json
└── src
   ├── napi-zstd.cc
   ├── zstd-wrapper.cc
   └── zstd-wrapper.h

プロジェクトを初期化

プロジェクトを初期化して依存ライブラリを設定します。

$ npm init

ネイティブ拡張実装のため node-addon-api を使用します。また、JavaScriptからネィティブ拡張を読み込みやすくするため、 node-bindings も使用します。

package.json に依存関係を設定します。

{
  "name": "napi-zstd",
  "version": "0.0.1",
  "description": "",
  "author": "yoshihitoh",
  "dependencies": {
    "bindings": "~1.2.1",
    "node-addon-api": "^1.0.0"
  },
  "license": "MIT"
}

Zstandardを準備する

external フォルダに外部依存ライブラリのZstandardを準備します。

$ mkdir -p external
$ git clone https://github.com/facebook/zstd.git external/zstd
$ cd external/zstd && make lib-release

external/zstd/lib/libzstd.a が静的ライブラリです。これをリンクして使います。

binding.gyp にビルド設定を書く

Node.jsのネイティブ拡張をビルドするときは、 node-gyp を使うことが多いようです。

ビルド設定の binding.gyp を設定します。

{
    'targets': [
        {
            'target_name': 'napi_zstd',
            'sources': ['src/napi-zstd.cc', 'src/zstd-wrapper.cc'],
            'include_dirs': [
                '<!@(node -p \'require("node-addon-api").include\')',
                '<(module_root_dir)/external/zstd/lib',
            ],
            'cflags!': ['-fno-exceptions'],
            'cflags_cc!': ['-fno-exceptions', '-std=c++14'],
            'defines': ['NAPI_DISABLE_CPP_EXCEPTIONS'],
            'libraries': ['<(module_root_dir)/external/zstd/lib/libzstd.a'],
            'conditions': [
                [
                    'OS=="mac"',
                    {
                        'xcode_settings': {
                            'GCC_ENABLE_CPP_EXCEPTIONS': 'NO',
                            'CLANG_CXX_LIBRARY': 'libc++',
                            'CLANG_CXX_LANGUAGE_STANDARD': 'c++14',
                            'MACOSX_DEPLOYMENT_TARGET': '10.13'
                        }
                    },
                ]
            ],
        }
    ]
}

Mac環境でビルドする場合、 MACOSX_DEPLOYMENT_TARGET の設定値は環境にあわせて変更する必要があります。

また、node-addon-apiを利用する場合、通常はC++の例外を有効化します。例外が有効かどうか判断できない場合、例外を無効化したい場合は defines の定義で明示的な指定が必要です。

例外 定義名
有効 NAPI_CPP_EXCEPTIONS
無効 NAPI_DISABLE_CPP_EXCEPTIONS

今回はC++の例外を使用しないので、 definesNAPI_DISABLE_CPP_EXCEPTIONS を指定しています。

Zstandardのラッパークラスを作る

C++から簡単に呼び出せるようにするため、STLでデータをやり取りできるようにします。

以下、圧縮/伸長関数のヘッダ定義の抜粋です。

/* データを圧縮する関数、対象のデータと圧縮結果の出力先を指定する */
ZSTDLIB_API size_t ZSTD_compress( void* dst, size_t dstCapacity,
                            const void* src, size_t srcSize,
                                  int compressionLevel);

/* データを伸長する関数、対象のデータと伸長結果の出力先を指定する */
ZSTDLIB_API size_t ZSTD_decompress( void* dst, size_t dstCapacity,
                              const void* src, size_t compressedSize);

これらの関数をラップしたクラスを定義します。成否判定の結果は bool で、入出力データは std::vector を使用します。

#pragma once

#include <cstdint>
#include <vector>

class Zstd {
public:
    bool compress(std::vector<uint8_t>& dest, const std::vector<uint8_t>& src, int level) const;
    bool decompress(std::vector<uint8_t>& dest, const std::vector<uint8_t>& src) const;
};

このクラスを実装します。

#include <cassert>
#include "zstd-wrapper.h"

bool Zstd::compress(std::vector<uint8_t> &dest, const std::vector<uint8_t> &src, int level) {
    // 圧縮後サイズの最悪値を取得する
    const auto buffer_size = ZSTD_compressBound(src.size());
    if (ZSTD_isError(buffer_size)) {
        return false;
    }

    // 圧縮結果を出力できるようにバッファサイズを変更する
    dest.resize(buffer_size);

    // 圧縮する
    const auto compressed_size = ZSTD_compress(dest.data(), dest.size(), src.data(), src.size(), level);
    if (ZSTD_isError(compressed_size)) {
        return false;
    }

    // 圧縮結果のサイズに変更する
    dest.resize(compressed_size);
    dest.shrink_to_fit();

    // 圧縮成功
    return true;
}

bool Zstd::decompress(std::vector<uint8_t> &dest, const std::vector<uint8_t> &src) {
    // 伸長後のサイズを取得する
    const auto dest_size = ZSTD_getDecompressedSize(src.data(), src.size());
    if (ZSTD_isError(dest_size)) {
        return false;
    }

    // 伸長結果を出力できるようにバッファサイズを変更する
    dest.resize(dest_size);

    // 伸長する
    const auto rc = ZSTD_decompress(dest.data(), dest.size(), src.data(), src.size());
    if (ZSTD_isError(rc)) {
        return false;
    }

    // 伸長に成功した場合はサイズが一致するはず
    assert(rc == dest_size);

    // 伸長成功
    return true;
}

N-APIを使ってNode.jsのバインディングライブラリを作る

次に、Node.jsから利用するためのバインディングライブラリを実装します。

C++とJavaScript間のデータ変換

まず、C++とJavaScriptのデータをやりとりするため、 std::vectorBuffer を相互に変換するヘルパー関数を実装します。 どちらも同じような方法で変換できます。

  • std::vectorBuffer : 先頭アドレスとそのサイズを指定する
  • Bufferstd::vector : 先頭アドレスと終端アドレスを指定する

データの入出力は原則 Buffer の想定で実装しますが、せっかくなので ArrayBufferTypedArray も対応できるようにします。

#include <napi.h>
#include <vector>

// std::vector → Buffer に変換する
Napi::Buffer<uint8_t> to_buffer(napi_env env, std::vector<uint8_t>& data)
{
    // NOTE: Newだと外部メモリのビューになる。メモリ管理をNode.jsに任せるため、Copyで新規メモリを割り当てる
    return Napi::Buffer<uint8_t>::Copy(env, data.data(), data.size());
}

// Value → std::vector に変換する (ArrayBuffer/Buffer/TypedArrayのみ対応)
std::vector<uint8_t> to_vector(Napi::Value value)
{
    auto env = value.Env();

    if (value.IsArrayBuffer()) {
        // ArrayBuffer
        auto buffer = value.As<Napi::ArrayBuffer>();
        const auto data = static_cast<const uint8_t*>(buffer.Data());
        return std::vector<uint8_t>(data, data + buffer.ByteLength());
    }
    else if (value.IsTypedArray()) {    // NOTE: BufferもTypedArrayとしてまとめて処理する
        // TypedArray or Buffer
        auto array = value.As<Napi::TypedArray>();
        auto buffer = array.ArrayBuffer();
        const auto data = static_cast<const uint8_t*>(buffer.Data());
        return std::vector<uint8_t>(data, data + array.ByteLength());
    }

    // その他のデータ型は未対応
    Napi::TypeError::New(env, "Unsupported data type").ThrowAsJavaScriptException();
    return std::vector<uint8_t>();
}

BufferTypedArrayArrayBuffer のビューなので、 ArrayBuffer#byteLength でサイズを取得すると実際のサイズよりも大きな値を取得することがあるので注意が必要です。

std::vector に変換するときは Buffer#byteLengthTypedArray#byteLength でサイズを取得しましょう。 筆者は誤って ArrayBuffer#byteLength を使いハマってしまいました。

Zstdクラスのラッパー

次に、 JavaScriptから Zstd オブジェクトを利用できるようにするため Napi::ObjectWrap を使ってラッパークラスを実装します。 ObjectWrapを利用すると、C++のオブジェクトをJavaScriptのオブジェクトと同様に操作できるようになります。

クラスの宣言です。

#include "zstd-wrapper.h"

// ラッパークラス
class JsZstd : public Napi::ObjectWrap<JsZstd> {
public:
    static Napi::Object Init(Napi::Env env, Napi::Object exports);

    JsZstd(const Napi::CallbackInfo& info);

private:
    static Napi::FunctionReference s_constructor;

    Napi::Value compress(const Napi::CallbackInfo& info);
    Napi::Value decompress(const Napi::CallbackInfo& info);

    Zstd zstd_;
};

クラスの実装です。先に実装したヘルパークラスを活用します。

// 注意: JavaScriptのコンストラクタオブジェクト
// クラス宣言とは別に定義する必要があるので注意 (しないとビルドエラー or 実行時エラーになる)
Napi::FunctionReference JsZstd::s_constructor;

// 初期化処理、JavaScriptからクラスを利用できるようにする
Napi::Object JsZstd::Init(Napi::Env env, Napi::Object exports) {
    // JavaScriptのクラスを定義する
    Napi::Function func = DefineClass(env, "Zstd", {
        InstanceMethod("compress", &JsZstd::compress),
        InstanceMethod("decompress", &JsZstd::decompress),
    });

    // コンストラクタを用意する
    s_constructor = Napi::Persistent(func);
    // コンストラクタは破棄されないようにする
    s_constructor.SuppressDestruct();

    // Zstd という名前でクラスをエクスポートする
    exports.Set("Zstd", func);
    return exports;
}

// ラッパーオブジェクトのコンストラクタ
JsZstd::JsZstd(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<JsZstd>(info), zstd_() {
    // nop
}

// 圧縮関数: 対象のデータを受け取り、圧縮結果を返す
Napi::Value JsZstd::compress(const Napi::CallbackInfo &info) {
    auto env = info.Env();

    // 引数チェック: 対象のデータが指定されていない場合は続行不可
    if (info.Length() < 1) {
        Napi::TypeError::New(env, "Wrong number of arguments, expected 1")
            .ThrowAsJavaScriptException();
        return env.Null();
    }

    // 第1引数: 対象のデータを取得
    auto original = to_vector(info[0]);
    if (env.IsExceptionPending()) {
        return env.Null();
    }

    // 第2引数: 圧縮レベルが指定されている場合は取得する。省略時はデフォルトの圧縮レベルにする
    int level = Zstd::defaultCompressionLevel();
    if (info.Length() >= 2) {
        if (!info[1].IsNumber()) {
            Napi::TypeError::New(env, "Wrong second argument, must be a number")
                .ThrowAsJavaScriptException();
            return env.Null();
        }

        level = info[1].As<Napi::Number>().Int32Value();
    }

    // 圧縮する
    std::vector<uint8_t> compressed;
    if (!zstd_.compress(compressed, original, level)) {
        Napi::TypeError::New(env, "Compression error")
            .ThrowAsJavaScriptException();
        return env.Null();
    }

    // 圧縮した結果をBufferに変換して返す
    return to_buffer(env, compressed).As<Napi::Value>();
}

// 伸長関数: 対象のデータを受け取り、伸長結果を返す
Napi::Value JsZstd::decompress(const Napi::CallbackInfo &info) {
    auto env = info.Env();

    // 引数チェック: 対象のデータが指定されていない場合は続行不可
    if (info.Length() < 1) {
        Napi::TypeError::New(env, "Wrong number of arguments, expected 1")
            .ThrowAsJavaScriptException();
        return env.Null();
    }

    // 第1引数: 対象のデータを取得
    auto compressed = to_vector(info[0]);
    if (env.IsExceptionPending()) {
        return env.Null();
    }

    // 伸長する
    std::vector<uint8_t> original;
    if (!zstd_.decompress(original, compressed)) {
        Napi::TypeError::New(env, "Decompression error")
            .ThrowAsJavaScriptException();
        return env.Null();
    }

    // 伸長した結果をBufferに変換して返す
    return to_buffer(env, original).As<Napi::Value>();
}

最後に、実装したモジュールをエクスポートしてJavaScriptからZstd クラスを利用できるようにします。

// モジュールの初期化処理
Napi::Object init(Napi::Env env, Napi::Object exports)
{
    // ラッパークラスを初期化する
    JsZstd::Init(env, exports);

    // エクスポート結果を返却
    return exports;
}

// モジュールを登録する
NODE_API_MODULE(napi_zstd, init);

以上でネイティブ拡張の実装は完了です。ビルドして動かしてみましょう。

動作確認

npmの install コマンドでネイティブ拡張をビルドします。

$ npm install
> napi-zstd@0.0.1 install /Users/yoshihitoh/workspace/blog/nodejs/napi-zstd
> node-gyp rebuild

  ACTION binding_gyp_napi_zstd_target_prepare_zstd Release/obj.target/napi_zstd/geni/libzstd.a
make[2]: Nothing to be done for `lib-release'.
make[1]: Entering directory `/Users/yoshihitoh/workspace/blog/nodejs/napi-zstd/external/zstd'
make[2]: Entering directory `/Users/yoshihitoh/workspace/blog/nodejs/napi-zstd/external/zstd/lib'
make[2]: Nothing to be done for `lib-release'.
make[2]: Leaving directory `/Users/yoshihitoh/workspace/blog/nodejs/napi-zstd/external/zstd/lib'
make[1]: Leaving directory `/Users/yoshihitoh/workspace/blog/nodejs/napi-zstd/external/zstd'
  CXX(target) Release/obj.target/napi_zstd/src/napi-zstd.o
  CXX(target) Release/obj.target/napi_zstd/src/zstd-wrapper.o
  SOLINK_MODULE(target) Release/napi_zstd.node
npm WARN napi-zstd@0.0.1 No description
npm WARN napi-zstd@0.0.1 No repository field.

audited 2 packages in 4.971s
found 0 vulnerabilities

うまくビルドできました! 続いてNode.jsのREPLで動かしてみます。試しに binding.gyp を圧縮してみます。

# 余計な `undefined` とプロンプト(>) を除外して表示しています。
$ node
> // ファイル読み込み
> const fs = require('fs');
> const data = fs.readFileSync('./binding.gyp');
> data.length
1424

> // ネイティブ拡張読み込み
> const napi = require('bindings')('napi_zstd');
> const zstd = new napi.Zstd();

> // 圧縮
> var compressed = zstd.compress(data);
> compressed.length
570

> // 伸長
> var decompressed = zstd.decompress(compressed);
> decompressed.length
1424

> // 伸長結果が元のファイルの内容と一致するかチェック
> decompressed.toString('utf-8') == data.toString('utf-8')
true

ばっちり動いてますね!

おわりに

N-API と、そのラッパーライブラリ node-addon-api を使って手軽にネイティブ拡張を実装することができました。Node.jsで未対応のライブラリを使う機会があればまた試してみたいと思います!

参考