前端輪子千千萬(wàn), 但還是有些瓶頸, 公司需要在前端調(diào)用自有 tcp 協(xié)議, 該協(xié)議只有 c++ 的封裝版本. 領(lǐng)導(dǎo)希望可以直接調(diào)該模塊, 不要重復(fù)造輪子.
實(shí)話說(shuō)我對(duì) C 還有點(diǎn)印象, 畢竟也是有二級(jí) C 語(yǔ)言證的人..但是已經(jīng)很久沒(méi)用了, 看著一大堆的C 語(yǔ)言類型的定義, 讓我這個(gè)常年使用隱式類型的 jser 情何以堪.這是我從業(yè)以來(lái)最難實(shí)現(xiàn)的 hello world
項(xiàng)目.
一個(gè) Native Addon 在 Nodejs 的環(huán)境里就是一個(gè)二進(jìn)制文件, 這個(gè)文件是由低級(jí)語(yǔ)言, 比如 C 或 C++實(shí)現(xiàn), 我們可以像調(diào)用其他模塊一樣 require() 導(dǎo)入 Native Addon
Native Addon 與其他.js 的結(jié)尾的一樣, 會(huì)暴露出 module.exports
或者 exports
對(duì)象, 這些被封裝到 node 模塊中的文件也被成為 Native Module(原生模塊).
那么如何讓 Native Addon 可以加載并運(yùn)行在 js 的應(yīng)用中? 讓 Native Addon 可以兼容 js 的環(huán)境并且暴露的 API 可以像正常 node 模塊一樣被使用呢?
這里不得不說(shuō)下 DLL(Dynamic Linked Library)動(dòng)態(tài)庫(kù), 他是由 C 或 C++使用標(biāo)準(zhǔn)編譯器編譯而成, 在 linux 或 macOS 中也被稱作 Shared Library. 一個(gè) DLL 可以被一個(gè)程序在運(yùn)行時(shí)動(dòng)態(tài)加載, DLL 包含源 C 或 C++代碼以及可通信的 API. 有動(dòng)態(tài)是否還有靜態(tài)的呢? 還真有~ 可以參考這里來(lái)看這兩者的區(qū)別, 簡(jiǎn)單來(lái)說(shuō)靜態(tài)比動(dòng)態(tài)更快, 因?yàn)殪o態(tài)不需要再去查找依賴文件并加載, 但是動(dòng)態(tài)可以顆粒度更小的修改打包的文件.
在 Nodejs 中, 當(dāng)編譯出 DLL 的時(shí)候, 會(huì)被導(dǎo)出為.node 的后綴文件. 然后可以 require 該文件, 像 js 文件一樣.不過(guò)代碼提示是不可能有的了.
Nodejs 其實(shí)是很多開(kāi)源庫(kù)的集合,可以看看他的倉(cāng)庫(kù), 在 package.json 中找 deps. 使用的是谷歌開(kāi)源的 V8 引擎來(lái)執(zhí)行 js 代碼, 而 V8剛好是使用 C++寫的, 不信你看 v8 的倉(cāng)庫(kù). 而對(duì)于像異步 IO, 事件循環(huán)和其他低級(jí)的特性則是依賴 Libuv 庫(kù).
當(dāng)安裝完 nodejs 之后, 實(shí)際上是安裝了一個(gè)包含整個(gè) Nodejs 以及其依賴的源代碼的編譯版本, 這樣就不用一個(gè)一個(gè)手動(dòng)安裝這些依賴而. 不過(guò)Nodejs也可以由這些庫(kù)的源代碼編譯而來(lái). 那么跟 Native Addon 有什么關(guān)系呢? 因?yàn)?Nodejs 是由低層級(jí)的 C 和 C++編譯而成的, 所以本身就具有與 C 和 C++相互調(diào)用的能力.
Nodejs 可以動(dòng)態(tài)加載 C 和 C++的 DLL 文件, 并且使用其 API 在 js 程序中進(jìn)行操作. 以上就是基本的 Native Addon 在 Nodejs 中的工作原理.
ABI 是特指應(yīng)用去訪問(wèn)編譯好|compiled的程序, 跟 API(Application Programming Interface)非常相似, 只不過(guò)是與二進(jìn)制文件進(jìn)行交互, 而且是訪問(wèn)內(nèi)存地址去查找 Symbols, 比如 numbers, objects, classes和 functions
那么這個(gè) ABI 跟 Native Addon 有什么關(guān)系呢? 他是 Native Addon 與 Nodejs 進(jìn)行通信的橋梁. DDL 文件實(shí)際上是通過(guò) Nodejs 提供的ABI 來(lái)注冊(cè)或者訪問(wèn)到值, 并且通過(guò)Nodejs暴露的 API和庫(kù)來(lái)執(zhí)行命令.
舉個(gè)例子, 有個(gè) Native Addon 想添加一個(gè)sayHello
的方法到exports
對(duì)象上, 他可以通過(guò)訪問(wèn) Libuv 的 API 來(lái)創(chuàng)建一個(gè)新的線程,異步的執(zhí)行任務(wù), 執(zhí)行完畢之后再調(diào)用回調(diào)函數(shù). 這樣 Nodejs 提供的 ABI 的工作就完成了.
通常來(lái)說(shuō), 都會(huì)將 C 或 C++編譯為 DLL, 會(huì)使用到一些被稱作header 頭文件的元數(shù)據(jù). 都是以.h
結(jié)尾.當(dāng)然這些頭文件中, 可以是 Nodejs及node的庫(kù)暴露出去的可以讓 Native Addon引用的.頭文件的資料可參考
一個(gè)典型的引用是使用#include
比如#inlude<v8.h>
, 然后使用聲明來(lái)寫 Nodejs 可執(zhí)行的代碼.有以下四種方式來(lái)使用頭文件.
比如v8.h
-> v8引擎, uv.h
-> Libuv庫(kù)這兩個(gè)文件都在 node 的安裝目錄中. 但是這樣的問(wèn)題就是 Native Addon 和 Nodejs 之間的依賴程度太高了.因?yàn)?Nodejs 的這些庫(kù)有可能隨著 Node 版本的更新而更改, 那么每次更改之后是否還要去適配更改 Native Addon? 這樣的維護(hù)成本較高.你可以看看 node 官方文檔中對(duì)這種方法的描述, 下面有更好的方法
NAN 項(xiàng)目最開(kāi)始就是為了抽象 nodejs 和 v8 引擎的內(nèi)部實(shí)現(xiàn). 基本概念就是提供了一個(gè) npm 的安裝包, 可以通過(guò)前端的包管理工具yarn
或npm
進(jìn)行安裝, 他包含了nan.h
的頭文件, 里面對(duì) nodejs 模塊和 v8 進(jìn)行了抽象. 但是 NAN 有以下缺點(diǎn):
所以更推薦以下兩種方式
N-API類似于 NAN 項(xiàng)目, 但是是由 nodejs 官方維護(hù), 從此就不需要安裝外部的依賴來(lái)導(dǎo)入到頭文件. 并且提供了可靠的抽象層
他暴露了node_api.h
頭文件, 抽象了 nodejs 和包的內(nèi)部實(shí)現(xiàn), 每次 Nodejs 更新, N-API 就會(huì)同步進(jìn)行優(yōu)化保證 ABI 的可靠性
這里是 N-API 的所有接口文檔, 這里是官方對(duì) N-API 的 ABI 穩(wěn)定性的描述
N-API 同時(shí)適合于 C 和 C++, 但是 C++的 API 使用起來(lái)更加的簡(jiǎn)單, 于是, node-addon-api 就應(yīng)運(yùn)而生.
跟上述兩個(gè)一樣, 他有自己的頭文件napi.h
, 包含了 N-API 的所有對(duì) C++的封裝, 并且跟 N-API 一樣是由官方維護(hù), 點(diǎn)這里查看倉(cāng)庫(kù).因?yàn)樗氖褂孟噍^于其他更加的簡(jiǎn)單, 所以在進(jìn)行 C++API 封裝的時(shí)候優(yōu)先選擇該方法.
需要全局安裝yarn global add node-gyp
, 因?yàn)檫€依賴于 Python, (GYP 全稱是 Generate Your Project, 是一個(gè)用 Python 寫成的工具). 具體制定 python 的環(huán)境及路徑參考文檔.
安裝完成后就有了一個(gè)生成編譯 C 或 C++到 Native Addon 或 DLL的模板代碼的CLI, 一頓操作猛如虎后,會(huì)生成一個(gè).node
文件. 但是這個(gè)模板是怎么生成的呢?就是下面這個(gè) binding.gyp
文件
binding.gyp
binding.gyp
包含了模塊的名字, 哪些文件應(yīng)該被編譯等. 模板會(huì)根據(jù)不同的平臺(tái)或架構(gòu)(32還是 64)包含必要的構(gòu)建指令文件, 也提供了必要的 header 或 source 文件去編譯 C 或 C++, 類似于 JSON 的格式, 詳情可點(diǎn)擊查看.
安裝依賴后, 真正開(kāi)始我們的 hello world 項(xiàng)目, 整體的項(xiàng)目文件結(jié)構(gòu)為:
├── binding.gyp
├── index.js
├── package.json
├── src
│ ├── greeting.cpp
│ ├── greeting.h
│ └── index.cpp
└── yarn.lock
復(fù)制代碼
Native Module 跟正常的 node 模塊或其他 NPM 包一樣. 先yarn init -y
初始化項(xiàng)目, 再安裝node-addon-apiyarn add node-addon-api
.
創(chuàng)建 greeting.h 文件
#include <string>
std::string helloUser(std::string name);
復(fù)制代碼
創(chuàng)建 greeting.cpp 文件
#include <iostream>
#include <string>
#include "greeting.h"
std::string helloUser(std::string name) {
return "Hello " + name + "!";
}
復(fù)制代碼
創(chuàng)建 index.cpp 文件, 該文件會(huì)包含 napi.h
#include <napi.h>
#include <string>
#include "greeting.h"
// 定義一個(gè)返回類型為 Napi String 的 greetHello 函數(shù), 注意此處的 info
Napi::String greetHello(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string result = helloUser('Lorry');
return Napi::String::New(env, result);
}
// 設(shè)置類似于 exports = {key:value}的模塊導(dǎo)出
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)
復(fù)制代碼
注意這里你看到很多的 Napi:: 這樣的書(shū)寫, 其實(shí)這就是在 js 與 C++之間的數(shù)據(jù)格式橋梁, 定義雙方都看得懂的數(shù)據(jù)類型. 這里經(jīng)歷了以下流程:
napi.h
頭文件, 他會(huì)解析到下面會(huì)說(shuō)的 binding.gyp 指定的路徑中greeting.h
自定義頭文件. 注意使用 ""和<>的區(qū)別, ""會(huì)查找當(dāng)前路徑, 詳情請(qǐng)查看node-addon-api
的頭文件. Napi 是一個(gè)命名空間. 因?yàn)楹瓴恢С置臻g, 所以 NODE_API_MODULE
前沒(méi)有NODE_API_MODULE
是一個(gè)node-api
(N-API)中封裝的NAPI_MODULE
宏中提供的函數(shù)(宏). 它將會(huì)在js 使用require
導(dǎo)入 Native Addon的時(shí)候被調(diào)用.binding.gyp
中的 target_name 保持一致, 只不過(guò)這里是使用一個(gè)標(biāo)簽 label 而不是字符串的格式env
和 exports
參數(shù)env
值是Napi::env
類型, 包含了注冊(cè)模塊時(shí)的環(huán)境(environment), 這個(gè)在 N-API 操作時(shí)被使用. Napi::String::New
表示創(chuàng)建一個(gè)新的Napi::String
類型的值.這樣就將 helloUser的std:string
轉(zhuǎn)換成了Napi::String
exports
是一個(gè)module.exports
的低級(jí) API, 他是Napi::Object
類型, 可以使用Set
方法添加屬性, 參考文檔, 該函數(shù)一定要返回一個(gè)exports
創(chuàng)建binding.gyp
文件
{
"targets": [
{
"target_name": "greet", // 定義文件名
"cflags!": [ "-fno-exceptions" ], // 不要報(bào)錯(cuò)
"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' // 去掉所有報(bào)錯(cuò)
],
}
]
}
復(fù)制代碼
生成模板文件
在 binding.gyp
同級(jí)目錄下使用
node-gyp configure
復(fù)制代碼
將會(huì)生成一個(gè) build 文件夾, 會(huì)包含以下文件:
./build
├── Makefile // 包含如何構(gòu)建 native 源代碼到 DLL 的指令, 并且兼容 Nodejs 的運(yùn)行時(shí)
├── binding.Makefile // 生成文件的配置
├── config.gypi // 包含編譯時(shí)的配置列表
├── greet.target.mk // 這個(gè) greet 就是之前配置的 target_name 和 NODE_API_MODULE 的第一個(gè)參數(shù)
└── gyp-mac-tool // mac 下打包的python 工具
復(fù)制代碼
構(gòu)建并編譯
node-gyp build
復(fù)制代碼
將會(huì)構(gòu)建出一個(gè).node
文件
./build
├── Makefile
├── Release
│ ├── greet.node // 這個(gè)就是編譯出來(lái)的node文件, 可直接被 js require 引用
│ └── obj.target
│ └── greet
│ └── src
│ ├── greeting.o
│ └── index.o
├── binding.Makefile
├── config.gypi
├── greet.target.mk
└── gyp-mac-tool
復(fù)制代碼
走到這一步你會(huì)發(fā)現(xiàn).node
文件是無(wú)法被打開(kāi)的, 因?yàn)樗筒皇墙o人讀的, 是一個(gè)二進(jìn)制文件.這個(gè)時(shí)候就可以嘗試一波
// index.js
const addon = require('./build/Release/greet.node')
console.log(addon.greetHello())
復(fù)制代碼
直接使用node index.js
運(yùn)行代碼你會(huì)發(fā)現(xiàn)打印出 Hello Lorry !
, 正是 helloUser 里面的內(nèi)容. 真是不容易啊.
僅僅到此嗎? 還不夠
傳參
上述代碼都是寫死的 Lorry, 我要是 Mike, Jane, 張三王五呢?而且不能傳參的函數(shù)不是好函數(shù)
于是之前說(shuō)到的 info 就起作用了, 詳情可參考, 因?yàn)閕nfo的[]運(yùn)算符重載, 可以實(shí)現(xiàn)對(duì)類C++數(shù)組的訪問(wèn). 以下是對(duì) index.cpp
文件的 greetHello
函數(shù)的修改:
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);
}
復(fù)制代碼
然后使用
node-gyp rebuild
復(fù)制代碼
在修改下引用的 index.js 文件
const addon = require('./build/Release/greet.node')
console.log(addon.greetHello('張三')) // Hello 張三!
復(fù)制代碼
至此, 終于算是比較完整的實(shí)現(xiàn)了我們的 hello world.別急, 還有貨
如果要像其他包一樣可以進(jìn)行發(fā)布的話, 操作就跟正常的npm打包流程差不多了. 在package.json
中的 main 字段中指定 index.js
,然后修改index.js
內(nèi)容為:
const addon = require('./build/Release/greet.node')
module.exports = addon.greetHello
復(fù)制代碼
再使用 yarn pack
即可打包出一個(gè).tgz
, 在其他項(xiàng)目中引入即可.還有沒(méi)有?還有一點(diǎn)點(diǎn)
通常在發(fā)布模塊的時(shí)候, 不會(huì)把build
文件夾算在內(nèi), 但是.node
文件是放在里面的. 而且.node
文件之前說(shuō)了, 依賴于系統(tǒng)和架構(gòu), 如果是使用 macOS 打包的.node
肯定是不能在 windows 上使用的. 那么怎么實(shí)現(xiàn)兼容性呢? 沒(méi)錯(cuò), 每次在用戶安裝的時(shí)候都重新按照對(duì)應(yīng)硬件配置build 一遍, 也就是使用node-gyp rebuild
, npm或者 yarn 在安裝依賴過(guò)程中發(fā)現(xiàn)了binding.gyp
的話會(huì)自動(dòng)在本地安裝node-gyp
, 所以 rebuild
才能成功.
不過(guò),還記得嗎? 處理 node-gyp 之外還有別的前提條件, 這就是為什么在安裝一些庫(kù)的時(shí)候經(jīng)常會(huì)出現(xiàn) node-gyp 的報(bào)錯(cuò).比如 python 的版本? node 的版本? 都有可能導(dǎo)致安裝這個(gè)模塊的用戶抓狂.于是還有一個(gè)辦法:為每個(gè)平臺(tái)架構(gòu)打包一份.node 文件, 這可以通過(guò) pacakge.json 的 install 腳本實(shí)現(xiàn)區(qū)分安裝, 有一個(gè)第三方包 node-pre-gyp 可以自動(dòng)實(shí)現(xiàn). 如果不想使用 node-pre-gyp 中那么復(fù)雜的配置, 還可以嘗試 prebuild-install這個(gè)輪子
但是還有一個(gè)問(wèn)題, 我們?nèi)绾螌?shí)現(xiàn)打包出不同平臺(tái)和架構(gòu)的文件? 難道我買各種硬件來(lái)打包?不現(xiàn)實(shí). 沒(méi)事, 還有輪子 prebuild, 可以設(shè)置不同平臺(tái), 架構(gòu)甚至 node 版本都能指定.
PS: 這里還有一個(gè) vscode 的坑, 在使用 C++ 的 extension 進(jìn)行代碼提示的時(shí)候老是提醒我#include <napi.h>
找不到文件,但是打包是完全沒(méi)有問(wèn)題的, 猜測(cè)是編輯器不支持識(shí)別 binding.gyp 里的頭文件查找路徑, 找了很多地方?jīng)]有相應(yīng)的解決辦法.最后翻這個(gè)插件的文檔發(fā)現(xiàn)可以配置clang.cxxflags
, 于是乎我在里面添加了一條頭文件的指定路徑-I${workspaceRoot}/node_modules/node-addon-api
就沒(méi)問(wèn)題了, 可以享受代碼提示了, 不然真的很容易寫錯(cuò)啊!!
聯(lián)系客服