13
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Node.jsAdvent Calendar 2017

Day 19

libuvを用いた非同期処理を含むNode.js Native Addon入門

Last updated at Posted at 2017-12-19

概要

Motivation

Node.jsのNative Extensionの解説記事はたくさんあるのですが、情報が古かったり、公式のドキュメントもやや使いづらかったりするので、今回の記事を執筆する運びとなりました。

この記事が目標としていること

動くサンプルを通じて、Native Addonを作るための最低限の要素に触れ、C++の知識があれば大抵のものは作れるようになります(なるはずです)

最低限の要素とは以下の4つです。

  • JavaScript - C++の橋渡し
  • V8のAPIの基本的な操作(配列操作、オブジェクトの操作を含む)
  • 内部にC++のオブジェクトを持ったJavaScriptのオブジェクトの作成
  • 非同期処理

ついでに実現できること

V8がどう動いているのかがほんの少しわかります。

バージョン情報

  • Mac OS X 10.12.6
  • Node.js v9.3.0

リポジトリ

1. はじめの一歩

まずは、C++の関数を呼び出してNodeの文字列を作成し、JSの世界に返すだけのサンプルから入ります。ほぼ公式のドキュメントのままです。

とりあえずビルドツールを準備する

npmとnodeはインストール済みであるとします。

$ npm install -g node-gyp

はじめに何を作るか

これと等価なNodeモジュールを作成します。

module.exports.hello = () => "world";

実装

その壱
function Hello() {
  return "world";
}

まずは、上記JavaScriptの関数に対応するコードをC++で書いてみましょう。

void Hello(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world"));
}

全体が型がv8::FunctionCallbackInfo<v8::Value>argsを引数にとる関数になっています。引数argsからは、JS側で指定した関数の引数を受け取ることができ、またargs.GetReturnValue().Set を呼び出してやると、関数の返り値を設定することができます。

その弐
module.exports.hello = Hello

次に上記コードに対応するC++のコードを書いてみます。

void init(Local<Object> exports) {
  NODE_SET_METHOD(exports, "hello", Hello);
}

NODE_SET_METHODというマクロのふりをした関数がよしなにやってくれます(昔はマクロだったらしい)。v8::Local<v8::Object>はV8オブジェクトへのポインタを意味し、適当なタイミングでGCの対象となります。

マクロの中身をちょっと覗いてやるとこんな感じになっています。v8::HandleScope handle_scope(isolate)を書いてやることによって、関数を抜ける時、HandleScopeのデストラクタが呼ばれたタイミングで、Local型で宣言された変数がGCの対象となるようになります。

inline void NODE_SET_METHOD(v8::Local<v8::Object> recv,
                            const char* name,
                            v8::FunctionCallback callback) {
  v8::Isolate* isolate = v8::Isolate::GetCurrent();
  v8::HandleScope handle_scope(isolate);
  v8::Local<v8::FunctionTemplate> t = v8::FunctionTemplate::New(isolate,
                                                                callback);
  v8::Local<v8::Function> fn = t->GetFunction();
  v8::Local<v8::String> fn_name = v8::String::NewFromUtf8(isolate, name);
  fn->SetName(fn_name);
  recv->Set(fn_name, fn);
}

上では大雑把な解説を書きましたが、実際には上のHello関数の型(void Hello(const FunctionCallbackInfo<Value>& args))をもつ関数は、v8::FunctionTemplate::Newの引数となることができて、v8::Local<v8::FunctionTemplate>GetFunctionを呼んでやると、V8上の関数になります。

その参

壱および弐がNodeモジュールとして機能するために最後の仕上げが必要です。

NODE_MODULE(NODE_GYP_MODULE_NAME, init)

マクロです

その肆

C++の部分を全部をまとめるとこのようなコードになります。

#include <node.h>

namespace demo {

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;

void Hello(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world"));
}

void init(Local<Object> exports) {
  NODE_SET_METHOD(exports, "hello", Hello);
}

// このマクロの後にはセミコロンは不要
NODE_MODULE(NODE_GYP_MODULE_NAME, init)

}  // namespace demo
その伍

Makefileに相当するものをまだ書いてないので、node-gypがきちんと動くように、binding.gypを書いてやります。

{
  "targets": [
    {
      "target_name": "addon",
      "sources": [ "index.cc" ]
    }
  ]
}

以上で実装は完了です。node-gyp configureした後にnode-gyp buildを行うことで、以下のJavaScriptのコードが期待したとおりに動くようになります。

const addon = require('./build/Release/addon');
console.log(addon.hello());  // world

解説

Isolateについて

Isolateを用いることで、実行中のJavaScriptのスコープを得ることができます。後にも出てきますが、HandleScopeがよしなにメモリ管理を行ってくれます。

Isolateについてもう少し詳しく

Isolateを経由して現在のスコープを取得することができますが、Isolate自体は独立したV8のランタイムを示すものです。スコープの制御の他に、ヒープ管理機構や、ガベージコレクタも含みます。同時に一つのスレッドしかIsoateにアクセスすることはできませんが、複数のスレッドから操作することができます。

ところが、IsolateだけではV8のランタイムとしては成立せず、Contextというものも準備する必要があります。Contextはルートオブジェクトを用意してくれます。たとえば、iframeの内部などはContextを分けて制御しています。

また、iframeの例からもわかるように、Isolateの内部には複数のContextが存在することができ、かつ、Isolateが排他ロック制御を行ってくれるおかげで、別々のContextに含まれるObjectは簡単にかつ安全に共有することができます。

さらにIsolateについて

https://github.com/v8/v8/wiki/Embedder's-Guide に書いてあります。

V8をゼロからまるっと動かすサンプルもあります。
https://github.com/v8/v8/blob/master/samples/hello-world.cc

完全なサンプル

https://github.com/kentrino/native-node-addon-samples./hello_world 以下にあります

2. つぎ

JavaScriptの世界から値を受け取って、C++側で利用してみましょう。

何を作るか

function printEverything() {
  if (typeof arguments.length < 2) {
    throw new TypeError("Wrong number of arguments.");
  }
  if (typeof arguments[0] !== "number") {
    throw new TypeError("Argument #1 must be a number.");
  }

  if (typeof arguments[1] !== "string") {
    throw new TypeError("Argument #2 must be a string.");
  }
  console.log(`${arguments[0]}, ${arguments[1]}`);
}

module.exports.printEverything = printEverything;

とだいたい等価なモジュールを作ってみましょう(実際は配列とオブジェクトも引数にとり、それらを表示する関数になっています)

  • 引数の数、型のチェックがあること
  • 例外を投げているところ

が新しい要素です。先程扱っていないところだけを紹介します。

引数の数、型チェックを行う
  if (args.Length() < 2) {
    //
  }

  if (!args[0]->IsNumber()) {
    //
  }

  if (!args[1]->IsString()) {
    //
  }

このようにして、引数の数をみたり、型チェックを行ったりすることができます。

V8の例外を投げる
    isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Argument #2 must be string.")));
V8の値からC++で扱いやすい形に変換する

数値の場合

  double number = args[0]->NumberValue();

文字列の場合

  String::Utf8Value string(args[1]);
  std::string cpp_string = std::string(*string);

配列の場合

  Local<Array> array = Local<Array>::Cast(args[2]);
  std::vector<double> vec;
  unsigned int length = array->Length();
  for (unsigned int i = 0; i < length; i++) {
    if (!array->Get(i)->IsNumber()) {
      isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Argument #3 must consist only of number.")));
    }
    vec.push_back((double)Local<Number>::Cast(array->Get(i))->NumberValue());
  }

オブジェクトの場合

  std::map<std::string, std::string> mp;

  Local<Context> context = isolate->GetCurrentContext();
  Local<Array> props = object->GetOwnPropertyNames(context).ToLocalChecked();
  unsigned int length = props->Length();
  for(unsigned int i = 0; i < length; ++i) {
    Local<Value> local_key = props->Get(i);
    Local<Value> local_val = object->Get(context, local_key).ToLocalChecked();
    if (!local_key->IsString() || !local_val->IsString()) {
      isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Argument #4 must have type of { [key: string]: string; }.")));      
    }
    std::string key = *String::Utf8Value(local_key);
    std::string val = *String::Utf8Value(local_val);
    mp[key] = val;
  }

完全なサンプル

https://github.com/kentrino/native-node-addon-samples./print_everything 以下にあります。

3. さらにつぎ

今度は、C++のオブジェクトを内部に含んだJavaScriptのオブジェクトを作ってみましょう。実用的なNative Addonを作るために必ず必要となってくるテクニックです。

何を作るか

helloプロパティを呼ぶとworldが返ってくるフレンドリーなスタッククラスを作ってみます。

const h = new HelloStack();
h.hello;   // "world"
h.push(3);
h.pop();   // 3

実装

自前のV8オブジェクトを作るクラスの概要

実際にはV8オブジェクトの内部に収まるクラスを作成します。
クラス定義では、node::ObjectWrapを継承します。

#include <node_object_wrap.h>

class HelloStack : public node::ObjectWrap {
 public:
  // コンストラクタをヒープにロードして、呼び出せるようにする
  // プロトタイプの関数のセット、インスタンスが内部にC++のフィールドを持っていることはここで宣言する
  static void LoadConstructor(v8::Isolate* isolate);

  // インスタンスを作成する。下記のConstructorを直接JavaScript側から叩くことができないので、この関数で仲介する
  static void CreateNewInstance(const v8::FunctionCallbackInfo<v8::Value>& args);

  // プロトタイプ関数の実装
  static void Push(const v8::FunctionCallbackInfo<v8::Value>& args);
  static void Pop(const v8::FunctionCallbackInfo<v8::Value>& args);

  // スタックの内部実装で使うvector
  std::vector<double> stack_;

 private:
  explicit HelloStack();
  ~HelloStack();

  // Constructorの実装。プロパティhelloの作成はここで行う
  static void Constructor(const v8::FunctionCallbackInfo<v8::Value>& args);

  // V8ヒープにのっているコンストラクタ
  static v8::Persistent<v8::Function> constructor;
};
モジュール作成部分

Init時にHelloStack::LoadConstructorを呼んでいます。

void CreateNewInstance(const FunctionCallbackInfo<Value>& args) {
  HelloStack::CreateNewInstance(args);
}

void Init(Local<Object> exports, Local<Object> module) {
  HelloStack::LoadConstructor(exports->GetIsolate());

  NODE_SET_METHOD(module, "exports", CreateNewInstance);
}

NODE_MODULE(NODE_GYP_MODULE_NAME, Init);

}  // namespace demo
個々の実装

InstanceTemplate はインスタンス化されたときの形状を指定します。


void HelloStack::LoadConstructor(Isolate* isolate) {
  HandleScope scope(isolate);
  Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, Constructor);
  tpl->SetClassName(String::NewFromUtf8(isolate, "HelloStack"));
  // V8のオブジェクトには、JavaScriptから見えない内部フィールドを持たせることができ、今回の場合、
  // C++のオブジェクトをラップしたV8オブジェクトを作るので1以上である必要がある *1
  tpl->InstanceTemplate()->SetInternalFieldCount(1);

  // プロトタイプの関数を定義する
  NODE_SET_PROTOTYPE_METHOD(tpl, "push", Push);
  NODE_SET_PROTOTYPE_METHOD(tpl, "pop", Pop);

  HelloStack::constructor.Reset(isolate, tpl->GetFunction());
}

*1 Assertion failed: (handle->InternalFieldCount() > 0), function Wrap, file .../include/node/node_object_wrap.h, line 77. が起こる

void HelloStack::Constructor(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  HandleScope scope(isolate);

  // new演算子 or Local<Function>#NewInstanceによる呼び出しの場合
  if (args.IsConstructCall()) {
    HelloStack* obj = new HelloStack();
    Local<Object> that = args.This();

    that->Set(String::NewFromUtf8(isolate, "hello"), String::NewFromUtf8(isolate, "world"));

    obj->Wrap(that);
    args.GetReturnValue().Set(that);
    return;
  }
}

解説

##### マクロとかの中身

NODE_SET_PROTOTYPE_METHOD の実装です。先程のNODE_SET_METHODとほぼ同じことをしていますね(レシーバーが適切かどうかを判定するためのSignatureを設定していますが、どのように機能するのかは調べきれていません)。PrototypeTemplateSomeObject.prototype と同義です。

inline void NODE_SET_PROTOTYPE_METHOD(v8::Local<v8::FunctionTemplate> recv, const char* name, v8::FunctionCallback callback) {
  v8::Isolate* isolate = v8::Isolate::GetCurrent();
  v8::HandleScope handle_scope(isolate);
  v8::Local<v8::Signature> s = v8::Signature::New(isolate, recv);
  v8::Local<v8::FunctionTemplate> t = v8::FunctionTemplate::New(isolate, callback, v8::Local<v8::Value>(), s);
  v8::Local<v8::String> fn_name = v8::String::NewFromUtf8(isolate, name);
  t->SetClassName(fn_name);
  recv->PrototypeTemplate()->Set(fn_name, t);
}

obj->Wrap の中身です。きちんとMakeWeak()されているので、V8オブジェクトの破棄時にV8がHelloStackをGCしてくれます。

  inline void Wrap(v8::Local<v8::Object> handle) {
    assert(persistent().IsEmpty());
    assert(handle->InternalFieldCount() > 0);
    handle->SetAlignedPointerInInternalField(0, this);
    persistent().Reset(v8::Isolate::GetCurrent(), handle);
    MakeWeak();
  }
Persistentなオブジェクトについて

LocalのオブジェクトはHandleScopeのデストラクタが呼ばれるタイミングで(= C++の関数を抜けるタイミングで)GCの対象となりますが、Persistentのオブジェクトは明示的に.Reset() を呼ばない限りGCされることはありません

完全なサンプル

https://github.com/kentrino/native-node-addon-samples./hello_stack 以下にあります

4. ラスト

いよいよlibuvを用いた非同期処理を含んだ、Native Addonの作成にうつります。今回はフィボナッチ数列を計算してくれる、こんな感じに動作するモジュールを作ります。

computeFibonacci(10, console.log); // 55

実装

わかりやすいところから始めます。

フィボナッチ数列を計算するところ
std::string ComputeFibonacci(int number) {
  int i;
  long long a, b, c;
  a = 1;
  b = 1;
  if (number == 1) return std::to_string(a);
  if (number == 2) return std::to_string(b);
  for (i = 0; i < number - 2; ++i) {
    c = a + b;
    a = b;
    b = c;
  }
  return std::to_string(c);
}
まずはデータから
class AsyncWork {
  public:
    // フィボナッチ数列の計算が終わったあと、JavaScriptの世界へ渡す処理
    // PersistentにしておかないとGCされてしまう
    v8::Persistent<v8::Function> callback;

    // フィボナッチ数列の何項目か
    int number;
    
    // フィボナッチ数列の計算結果を詰める
    std::string result;

    // あとでメモリを解放するために突っ込んである
    uv_work_t uv_request;
};

データ型をみると、ある程度処理の様子が想像できると思います。

非同期処理のキューに詰めるところ

uv_work_t型の&work->uv_requestをこのように指定してやります。uv_work_tは内部のフィールドにdataを保持しており、ここに処理するべき内容を突っ込んでやります。
DoWorkがキューのイベントの非同期処理を行う関数で、AfterWorkでは非同期処理終了後の処理を渡しています。

uv_queue_work(uv_default_loop(), &work->uv_request, (uv_work_cb)DoWork, (uv_after_work_cb)AfterWork);

他の部分と合わせるとこんな感じになります。

void ComputeFibonacciAsync(const FunctionCallbackInfo<Value> &args) {
  Isolate *isolate = Isolate::GetCurrent();
  HandleScope scope(isolate);

  int number = (args[0]->NumberValue());
  // JavaScriptのコールバック関数
  Local<Function> cb_local = Local<Function>::Cast(args[1]);

  AsyncWork *work = new AsyncWork;
  work->uv_request.data = work;
  work->callback.Reset(isolate, cb_local);
  work->number = number;

  uv_queue_work(uv_default_loop(), &work->uv_request, (uv_work_cb)DoWork, (uv_after_work_cb)AfterWork);
}
DoWork

キューのイベントを処理するところです。簡単ですね。

static void DoWork(uv_work_t *req) {
  AsyncWork *work = static_cast<AsyncWork *>(req->data);
  std::string result = ComputeFibonacci(work->number);
  work->result = result;
}
AfterWork

非同期処理終了後の処理になります。work から、result を取り出し、その結果をJavaScriptのコールバックに渡してやります。

static void AfterWork(uv_work_t *req, int status) {
  Isolate *isolate = Isolate::GetCurrent();
  HandleScope scope(isolate);

  AsyncWork *work = static_cast<AsyncWork *>(req->data);
  Local<String> result = String::NewFromUtf8(isolate, work->result.c_str());

  const unsigned argc = 1;
  Local<Value> argv[argc] = {result};
  Local<Function> cb = Local<Function>::New(isolate, work->callback);
  cb->Call(isolate->GetCurrentContext()->Global(), argc, argv);
  work->callback.Reset();

  delete work;
}

完全なサンプル

https://github.com/kentrino/native-node-addon-samples./async_fibonacci 以下にあります

GMPを使ったバージョン

上のサンプルだとlong longの範囲でしか計算できないので、GMPを使った任意精度版も用意しました。

https://github.com/kentrino/native-node-addon-samples./gmp_async_fibonacci 以下にあります

GMPのインストール
$ brew install gmp

その他

NAN(https://github.com/nodejs/nan) を使ったものだが優れたサンプルが豊富にある。

参考

質問・マサカリ

まで気軽にお声掛けください。

13
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?