前端使用 node-gyp 構建 Native Addon


前端輪子千千萬, 但還是有些瓶頸, 公司需要在前端調用自有 tcp 協議, 該協議只有 c++ 的封裝版本. 領導希望可以直接調該模塊, 不要重復造輪子.

實話說我對 C 還有點印象, 畢竟也是有二級 C 語言證的人..但是已經很久沒用了, 看着一大堆的C 語言類型的定義, 讓我這個常年使用隱式類型的 jser 情何以堪.這是我從業以來最難實現的 hello world 項目.

整體介紹

Native Addon

一個 Native Addon 在 Nodejs 的環境里就是一個二進制文件, 這個文件是由低級語言, 比如 C 或 C++實現, 我們可以像調用其他模塊一樣 require() 導入 Native Addon

Native Addon 與其他.js 的結尾的一樣, 會暴露出 module.exports 或者 exports 對象, 這些被封裝到 node 模塊中的文件也被成為 Native Module(原生模塊).

那么如何讓 Native Addon 可以加載並運行在 js 的應用中? 讓 Native Addon 可以兼容 js 的環境並且暴露的 API 可以像正常 node 模塊一樣被使用呢?

這里不得不說下 DLL(Dynamic Linked Library)動態庫, 他是由 C 或 C++使用標准編譯器編譯而成, 在 linux 或 macOS 中也被稱作 Shared Library. 一個 DLL 可以被一個程序在運行時動態加載, DLL 包含源 C 或 C++代碼以及可通信的 API. 有動態是否還有靜態的呢? 還真有~ 可以參考這里來看這兩者的區別, 簡單來說靜態比動態更快, 因為靜態不需要再去查找依賴文件並加載, 但是動態可以顆粒度更小的修改打包的文件.

在 Nodejs 中, 當編譯出 DLL 的時候, 會被導出為.node 的后綴文件. 然后可以 require 該文件, 像 js 文件一樣.不過代碼提示是不可能有的了.

Native Addon 是如何工作的呢?

Nodejs 其實是很多開源庫的集合,可以看看他的倉庫, 在 package.json 中找 deps. 使用的是谷歌開源的 V8 引擎來執行 js 代碼, 而 V8剛好是使用 C++寫的, 不信你看 v8 的倉庫. 而對於像異步 IO, 事件循環和其他低級的特性則是依賴 Libuv 庫.

當安裝完 nodejs 之后, 實際上是安裝了一個包含整個 Nodejs 以及其依賴的源代碼的編譯版本, 這樣就不用一個一個手動安裝這些依賴而. 不過Nodejs也可以由這些庫的源代碼編譯而來. 那么跟 Native Addon 有什么關系呢? 因為 Nodejs 是由低層級的 C 和 C++編譯而成的, 所以本身就具有與 C 和 C++相互調用的能力.

Nodejs 可以動態加載 C 和 C++的 DLL 文件, 並且使用其 API 在 js 程序中進行操作. 以上就是基本的 Native Addon 在 Nodejs 中的工作原理.

ABI Application Binary Interface 應用二進制接口

ABI 是特指應用去訪問編譯好|compiled的程序, 跟 API(Application Programming Interface)非常相似, 只不過是與二進制文件進行交互, 而且是訪問內存地址去查找 Symbols, 比如 numbers, objects, classes和 functions

那么這個 ABI 跟 Native Addon 有什么關系呢? 他是 Native Addon 與 Nodejs 進行通信的橋梁. DDL 文件實際上是通過 Nodejs 提供的ABI 來注冊或者訪問到值, 並且通過Nodejs暴露的 API和庫來執行命令.

舉個例子, 有個 Native Addon 想添加一個sayHello的方法到exports對象上, 他可以通過訪問 Libuv 的 API 來創建一個新的線程,異步的執行任務, 執行完畢之后再調用回調函數. 這樣 Nodejs 提供的 ABI 的工作就完成了.

通常來說, 都會將 C 或 C++編譯為 DLL, 會使用到一些被稱作header 頭文件的元數據. 都是以.h 結尾.當然這些頭文件中, 可以是 Nodejs及node的庫暴露出去的可以讓 Native Addon引用的.頭文件的資料可參考

一個典型的引用是使用#include比如#inlude<v8.h>, 然后使用聲明來寫 Nodejs 可執行的代碼.有以下四種方式來使用頭文件.

1. 使用核心實現

比如v8.h -> v8引擎, uv.h -> Libuv庫這兩個文件都在 node 的安裝目錄中. 但是這樣的問題就是 Native Addon 和 Nodejs 之間的依賴程度太高了.因為 Nodejs 的這些庫有可能隨着 Node 版本的更新而更改, 那么每次更改之后是否還要去適配更改 Native Addon? 這樣的維護成本較高.你可以看看 node 官方文檔中對這種方法的描述, 下面有更好的方法

2. 使用 Native Abstractions for Node(NAN)

NAN 項目最開始就是為了抽象 nodejs 和 v8 引擎的內部實現. 基本概念就是提供了一個 npm 的安裝包, 可以通過前端的包管理工具yarnnpm進行安裝, 他包含了nan.h的頭文件, 里面對 nodejs 模塊和 v8 進行了抽象. 但是 NAN 有以下缺點:

  • 不完全抽象出了 V8 的 api
  • 並不提供 nodejs 所有庫的支持
  • 不是Nodejs 官方維護的庫.

所以更推薦以下兩種方式

3. 使用 N-API

N-API類似於 NAN 項目, 但是是由 nodejs 官方維護, 從此就不需要安裝外部的依賴來導入到頭文件. 並且提供了可靠的抽象層
他暴露了node_api.h頭文件, 抽象了 nodejs 和包的內部實現, 每次 Nodejs 更新, N-API 就會同步進行優化保證 ABI 的可靠性
這里是 N-API 的所有接口文檔, 這里是官方對 N-API 的 ABI 穩定性的描述

N-API 同時適合於 C 和 C++, 但是 C++的 API 使用起來更加的簡單, 於是, node-addon-api 就應運而生.

4. 使用 node-addon-api 模塊

跟上述兩個一樣, 他有自己的頭文件napi.h, 包含了 N-API 的所有對 C++的封裝, 並且跟 N-API 一樣是由官方維護, 點這里查看倉庫.因為他的使用相較於其他更加的簡單, 所以在進行 C++API 封裝的時候優先選擇該方法.

開始實現 Hello World

環境准備

需要全局安裝yarn global add node-gyp, 因為還依賴於 Python, (GYP 全稱是 Generate Your Project, 是一個用 Python 寫成的工具). 具體制定 python 的環境及路徑參考文檔.

安裝完成后就有了一個生成編譯 C 或 C++到 Native Addon 或 DLL的模板代碼的CLI, 一頓操作猛如虎后,會生成一個.node文件. 但是這個模板是怎么生成的呢?就是下面這個 binding.gyp 文件

binding.gyp

binding.gyp包含了模塊的名字, 哪些文件應該被編譯等. 模板會根據不同的平台或架構(32還是 64)包含必要的構建指令文件, 也提供了必要的 header 或 source 文件去編譯 C 或 C++, 類似於 JSON 的格式, 詳情可點擊查看.

設置項目

安裝依賴后, 真正開始我們的 hello world 項目, 整體的項目文件結構為:

├── binding.gyp
├── index.js
├── package.json
├── src
│   ├── greeting.cpp
│   ├── greeting.h
│   └── index.cpp
└── yarn.lock

安裝依賴

Native Module 跟正常的 node 模塊或其他 NPM 包一樣. 先yarn init -y初始化項目, 再安裝node-addon-apiyarn add node-addon-api.

創建 C++示例

創建 greeting.h 文件

#include <string>
std::string helloUser(std::string name);

創建 greeting.cpp 文件

#include <iostream>
#include <string>
#include "greeting.h"

std::string helloUser(std::string name) {
    return "Hello " + name + "!";
}

創建 index.cpp 文件, 該文件會包含 napi.h

#include <napi.h>
#include <string>
#include "greeting.h"

// 定義一個返回類型為 Napi String 的 greetHello 函數, 注意此處的 info
Napi::String greetHello(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  std::string result = helloUser('Lorry');
  return Napi::String::New(env, result);
}

// 設置類似於 exports = {key:value}的模塊導出
Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(
    Napi::String::New(env, "greetHello"), // key
    Napi::Function::New(env, greetHello)  // value
  );

  return exports;
}

NODE_API_MODULE(greet, Init)

注意這里你看到很多的 Napi:: 這樣的書寫, 其實這就是在 js 與 C++之間的數據格式橋梁, 定義雙方都看得懂的數據類型.
這里經歷了以下流程:

  1. 導入napi.h頭文件, 他會解析到下面會說的 binding.gyp 指定的路徑中
  2. 導入 string 標准頭文件和 greeting.h自定義頭文件. 注意使用 ""和<>的區別, ""會查找當前路徑, 詳情請查看
  3. 使用 Napi:: 開頭的都是使用的 node-addon-api 的頭文件. Napi 是一個命名空間. 因為宏不支持命名空間, 所以 NODE_API_MODULE 前沒有
  4. NODE_API_MODULE是一個node-api(N-API)中封裝的NAPI_MODULE宏中提供的函數(). 它將會在js 使用require導入 Native Addon的時候被調用.
  5. 第一個參數為唯一值用於注冊進 node 里表示導出模塊名. 最好與 binding.gyp 中的 target_name 保持一致, 只不過這里是使用一個標簽 label 而不是字符串的格式
  6. 第二個參數是 C++的函數, 他會在 Nodejs開始注冊這個方法的時候進行調用.分別會傳入 envexports參數
  7. env值是Napi::env類型, 包含了注冊模塊時的環境(environment), 這個在 N-API 操作時被使用. Napi::String::New表示創建一個新的Napi::String類型的值.這樣就將 helloUser的std:string轉換成了Napi::String
  8. exports是一個module.exports的低級 API, 他是Napi::Object類型, 可以使用Set方法添加屬性, 參考文檔, 該函數一定要返回一個exports

創建binding.gyp文件

{
  "targets": [
    {
      "target_name": "greet",               // 定義文件名
      "cflags!": [ "-fno-exceptions" ],     // 不要報錯
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [                          // 包含的待編譯為 DLL 的文件們
        "./src/greeting.cpp",
        "./src/index.cpp"
      ],
      "include_dirs": [                     // 包含的頭文件路徑, 讓 sources 中的文件可以找到頭文件
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      'defines': [ 
        'NAPI_DISABLE_CPP_EXCEPTIONS'       // 去掉所有報錯
      ],
    }
  ]
}

生成模板文件

binding.gyp 同級目錄下使用

node-gyp configure

將會生成一個 build 文件夾, 會包含以下文件:

./build
├── Makefile            // 包含如何構建 native 源代碼到 DLL 的指令, 並且兼容 Nodejs 的運行時
├── binding.Makefile    // 生成文件的配置
├── config.gypi         // 包含編譯時的配置列表
├── greet.target.mk     // 這個 greet 就是之前配置的 target_name 和 NODE_API_MODULE 的第一個參數
└── gyp-mac-tool        // mac 下打包的python 工具

構建並編譯

node-gyp build

將會構建出一個.node文件

./build
├── Makefile
├── Release
│   ├── greet.node              // 這個就是編譯出來的node文件, 可直接被 js require 引用
│   └── obj.target
│       └── greet
│           └── src
│               ├── greeting.o
│               └── index.o
├── binding.Makefile
├── config.gypi
├── greet.target.mk
└── gyp-mac-tool

走到這一步你會發現.node文件是無法被打開的, 因為他就不是給人讀的, 是一個二進制文件.這個時候就可以嘗試一波

// index.js
const addon = require('./build/Release/greet.node')
console.log(addon.greetHello())

直接使用node index.js運行代碼你會發現打印出 Hello Lorry !, 正是 helloUser 里面的內容. 真是不容易啊.

僅僅到此嗎? 還不夠

傳參

上述代碼都是寫死的 Lorry, 我要是 Mike, Jane, 張三王五呢?而且不能傳參的函數不是好函數

於是之前說到的 info 就起作用了, 詳情可參考, 因為info的[] 運算符重載, 可以實現對類C++數組的訪問. 以下是對 index.cpp 文件的 greetHello函數的修改:

Napi::String greetHello(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  std::string user = (std::string) info[0].ToString();
  std::string result = helloUser(user);
  return Napi::String::New(env, result);
}

然后使用

node-gyp rebuild

在修改下引用的 index.js 文件

const addon = require('./build/Release/greet.node')
console.log(addon.greetHello('張三')) // Hello 張三!

至此, 終於算是比較完整的實現了我們的 hello world.別急, 還有貨

如果要像其他包一樣可以進行發布的話, 操作就跟正常的npm打包流程差不多了. 在package.json中的 main 字段中指定 index.js,然后修改index.js內容為:

const addon = require('./build/Release/greet.node')
module.exports = addon.greetHello

再使用 yarn pack即可打包出一個.tgz, 在其他項目中引入即可.還有沒有?還有一點點

關於打包的跨平台

通常在發布模塊的時候, 不會把build文件夾算在內, 但是.node文件是放在里面的. 而且.node文件之前說了, 依賴於系統和架構, 如果是使用 macOS 打包的.node肯定是不能在 windows 上使用的. 那么怎么實現兼容性呢? 沒錯, 每次在用戶安裝的時候都重新按照對應硬件配置build 一遍, 也就是使用node-gyp rebuild, npm或者 yarn 在安裝依賴過程中發現了binding.gyp的話會自動在本地安裝node-gyp, 所以 rebuild才能成功.

不過,還記得嗎? 處理 node-gyp 之外還有別的前提條件, 這就是為什么在安裝一些庫的時候經常會出現 node-gyp 的報錯.比如 python 的版本? node 的版本? 都有可能導致安裝這個模塊的用戶抓狂.於是還有一個辦法:為每個平台架構打包一份.node 文件, 這可以通過 pacakge.json 的 install 腳本實現區分安裝, 有一個第三方包 node-pre-gyp 可以自動實現.
如果不想使用 node-pre-gyp 中那么復雜的配置, 還可以嘗試 prebuild-install這個輪子

但是還有一個問題, 我們如何實現打包出不同平台和架構的文件? 難道我買各種硬件來打包?不現實. 沒事, 還有輪子 prebuild, 可以設置不同平台, 架構甚至 node 版本都能指定.

PS: 這里還有一個 vscode 的坑, 在使用 C++ 的 extension 進行代碼提示的時候老是提醒我#include <napi.h>找不到文件,但是打包是完全沒有問題的, 猜測是編輯器不支持識別 binding.gyp 里的頭文件查找路徑, 找了很多地方沒有相應的解決辦法.最后翻這個插件的文檔發現可以配置clang.cxxflags, 於是乎我在里面添加了一條頭文件的指定路徑-I${workspaceRoot}/node_modules/node-addon-api就沒問題了, 可以享受代碼提示了, 不然真的很容易寫錯啊!!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM