覺得撰寫 Node.js 時有什麼效能瓶頸嗎?如果想讓它更快,可以試試看使用 C++ Addons 來撰寫模組。這種模組是用 C/C++ 來撰寫,最後可以當成一般的 npm 模組,在 JavaScript 中直接 require 使用。

最近在看一點定翼機的東西,想用 Electron 寫程式分析 Pixhawk 的飛行記錄,再做點視覺化。但是 log 檔案一次飛回來大概都有 50MB 左右,原本是用 Node.js 原生的 fsreadline 模組來讀取(中間有做一些其他計算),雖然做起來沒有什麼問題,但就是每次讀檔案都要跑個三四十秒,在做新功能的過程實在很頭痛。

在這邊第一個想到的是 WebAssembly,但一方面是資料傳遞很麻煩,只能用 ArrayBuffer 來處理各種資料,一開始是想說使用一個超大的 double 陣列來存這些資料,後來翻了很多資料說,其實 WASM 現在的速度其實並沒有比 JavaScript 快很多。那既然用 Node.js,就來試試看 C++ Addons 好了。

其實我也不是什麼 C++ 大師,只是曾經也寫過一點演算法競賽的題目,也稍微熟悉一點 C/C++ 寫法,想說如果只是簡單的文字處理應該可以寫寫看。換句話說:如果只是簡單的東西用 JS 跑很慢的話,你也可以嘗試用 C++ 改寫看看。

Node.js C++ Addon 是什麼?

Node.js C++ Addon 是用 C++ 撰寫的動態連接庫,換句話說有點類似 Windows 的 dll。Node.js 程式可以透過 require 方法載入,並直接共用變數。

此外,C++ Addon 感覺上就像在執行另外一個 C++ 程式,所以你可以直接讀取電腦上的 I/O、甚至使用底層 API。在這個例子中,因為我要讀取的 Pixhawk 記錄存在電腦上,我可以不需要透過其他方法傳遞給 Addon,而可以直接讓 Addon 用 C++ 的 ifstream infile 來讀取檔案。

撰寫的過程會建議先當作純 C++ 來寫,用 std::cout 來測試東西寫得對不對,最後再來考慮怎麼從 JS 讀參數、做完以後怎麼把結果丟回 JS。

C++ 程式範例

這邊先講一段 C++ 程式語言的範例,大概就是讀檔案進來,做點奇怪的處理、算算看檔案有幾行,最後輸出這個數值。

#include <iostream>
#include <string>
#include <fstream>

int main(int argc, char* argv[]){
    FILE* fp;
	char* filename = argv[1];
	if ((fp = fopen(filename, "r")) == NULL) {
        // 檔案不存在
		std::cout << "file opened failed";
		return 1;
	}
    
    std::ifstream infile(filename); // 開檔
    std::string line;
    int GPS_COUNT = 0;
    
    while (std::getline(infile, line)) {
		if (line.find("GPS") == 0) {
			// 找到 GPS 資訊,開始做點正事
            // do_something();
            GPS_COUNT++;
		}
	}
    
    std::cout << GPS_COUNT << std::endl;
    
    return 0;
}

為了等等好處理,我會建議先把重點的部分抽離 main 函式,另外開一個函式來處理。像這樣:

#include <iostream>
#include <string>
#include <fstream>

int process(char* filename) {
    std::ifstream infile(filename); // 開檔
    std::string line;
    int GPS_COUNT = 0;
    
    while (std::getline(infile, line)) {
		if (line.find("GPS") == 0) {
			// 找到 GPS 資訊,開始做點正事
            // do_something();
            GPS_COUNT++;
		}
	}

    return GPS_COUNT;
}

int main(int argc, char* argv[]) {
    FILE* fp;
	char* filename = argv[1];
	if ((fp = fopen(filename, "r")) == NULL) {
        // 檔案不存在
		std::cout << "file opened failed";
		return 1;
	}
    int GPS_COUNT = process(filename);
    
    std::cout << GPS_COUNT << std::endl;
    
    return 0;
}

把你這段邏輯弄完後,可以先用 g++ 之類的編譯器編譯並跑跑看,確定這段邏輯沒有問題。不然等等接上 Node.js 上大爆炸,會搞不清楚到底是環境弄錯還是程式寫錯。

在 Windows 上,你可能會使用 Visual Studio 來撰寫 C++,就使用內建的編譯器吧。在 Linux/Mac 上,你可能可以使用這個指令來編譯並執行:

g++ hello.cc -o hello
chmod +x hello
./hello

安裝 node-gyp 環境

等到主要程式邏輯沒有問題後,接著要用 node-gyp 這個東西編譯成 Node.js 可以用的 .node 模組。首先請安裝 node-gyp

npm install -g node-gyp

如果你是 Windows 使用者,你可能還得安裝 windows-build-tools

npm install -g windows-build-tools

接著在你的專案根目錄(通常就是放 package.json 的那個資料夾)新增一個檔案叫 bindings.gyp,裡面內容放這樣:

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

其中 "hello" 要改成你這個 addon 套件的名字,待會從 JS 裡面 require 的時候會用到;"addons/hello.cc" 要改成你的 C++ 檔案的名字。

安裝 NAN

我們剛剛在 bindings.gyp 裡面加入了 require('nan') 這樣的字眼,因此我們會需要 NAN 這個套件。這個 NAN 並不是 JS 中的 NaN、Not a Number,而是 Native Abstractions for Node.js,它有點像是 C++ 和 V8(JS 引擎)的中間層。要不是有 NAN 的存在,我們可能每次 V8 改版都要重寫 C++ 檔案。

另外為了方便等等快速載入我們的 Addon,我們會順便安裝 bindings 這個套件:

npm install --save nan bindings

把 C++ 程式接上 Node.js

接著就是要如何把這兩個東西接起來。先看一下簡單的 NAN 範例:

#include <nan.h>

void Add(const Nan::FunctionCallbackInfo<v8::Value>& info) {

  if (info.Length() < 2) {
    Nan::ThrowTypeError("Wrong number of arguments");
    return;
  }

  if (!info[0]->IsNumber() || !info[1]->IsNumber()) {
    Nan::ThrowTypeError("Wrong arguments");
    return;
  }

  double arg0 = info[0]->NumberValue();
  double arg1 = info[1]->NumberValue();
  v8::Local<v8::Number> num = Nan::New(arg0 + arg1);

  info.GetReturnValue().Set(num);
}

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

NODE_MODULE(addon, Init)

這個範例取自 node-addon-examples

這邊可以看到它用了一個 void Init 取代我們的 int main,而 Init 中的 exports->Set(...) 有點像是我們 Node.js 會寫的 module.exportsAdd 的部分則是跟一般的函式沒什麼兩樣,只是花了很多時間檢查丟進來的參數數量、參數型態,還有最後取得參數值、計算完再回傳。

回去看我們的原始程式。我們寫好了一個 int process 函式,呼叫時要傳入 char* filename,最後回傳一個 int。所以我們的 Code 應該要長這樣:

#include <nan.h>

void Method(const Nan::FunctionCallbackInfo<v8::Value>& info) {
    v8::String::Utf8Value str(info[0]->ToString());
	std::string _filename = *str;
	char* filename = new char[_filename.length() + 1];
	strcpy(filename, _filename.c_str());
    
    int line = process(filename);
    v8::Local<v8::Number> num = Nan::New(line);
    
    info.GetReturnValue().Set(num);
}

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

NODE_MODULE(addon, Init)

再一起看一次這段程式碼。void Init 的部分算是在宣告 JS 中的 module.exports.process = Method。會多這個函式而不是直接呼叫 process,主要是因為我們不想去動原本的 C++ 程式碼,多寫一個函式來處理 v8 型態和 C++ 型態的轉換。

所以我們多寫一個 Method 函式,一開始從 info 中的第一個參數取得字串。這時的型態是 v8::String::Utf8Value,我先把它轉成 std::string 再轉成 char*,最後才把這個 filename 傳入 process 函式裡。

算完我們需要的值後,我們先存成 int,再轉成 v8::Number 的型態。最後用 GetReturnValue().set() 來把 v8::Number 回傳給 JS。

編譯 C++ Addon

東西都寫的差不多後,就可以使用 node-gyp build 的語法來編譯這個模組,預設應該會放在 build/Release/hello.node。由於我們剛剛有安裝 bindings 這個套件,因此可以直接這樣使用:

const helloAddon = require('bindings')('hello');

接著就可以開始使用這個套件,例如:

const helloAddon = require('bindings')('hello');

const num = hello.process('/path/to/somefile.txt');
console.log(num);

最後,這段程式碼應該會長這樣,供大家參考參考:

#include <nan.h>
#include <iostream>
#include <string>
#include <fstream>

int process(char* filename) {
    std::ifstream infile(filename); // 開檔
    std::string line;
    int GPS_COUNT = 0;
    
    while (std::getline(infile, line)) {
		if (line.find("GPS") == 0) {
			// 找到 GPS 資訊,開始做點正事
            // do_something();
            GPS_COUNT++;
		}
	}

    return GPS_COUNT;
}

void Method(const Nan::FunctionCallbackInfo<v8::Value>& info) {
    v8::String::Utf8Value str(info[0]->ToString());
	std::string _filename = *str;
	char* filename = new char[_filename.length() + 1];
	strcpy(filename, _filename.c_str());
    
    int line = process(filename);
    v8::Local<v8::Number> num = Nan::New(line);
    
    info.GetReturnValue().Set(num);
}

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

NODE_MODULE(addon, Init)