node-gyp 实现 nodejs 调用 C++

前端轮子千千万, 但仍是有些瓶颈, 公司须要在前端调用自有 tcp 协议, 该协议只有 c++ 的封装版本. 领导但愿能够直接调该模块, 不要重复造轮子.html

实话说我对 C 还有点印象, 毕竟也是有二级 C 语言证的人..可是已经好久没用了, 看着一大堆的C 语言类型的定义, 让我这个常年使用隐式类型的 jser 情何以堪.这是我从业以来最难实现的 hello world 项目.前端

总体介绍

Native Addon

一个 Native Addon 在 Nodejs 的环境里就是一个二进制文件, 这个文件是由低级语言, 好比 C 或 C++实现, 咱们能够像调用其余模块同样 require() 导入 Native Addonnode

Native Addon 与其余.js 的结尾的同样, 会暴露出 module.exports 或者 exports 对象, 这些被封装到 node 模块中的文件也被成为 Native Module(原生模块).python

那么如何让 Native Addon 能够加载并运行在 js 的应用中? 让 Native Addon 能够兼容 js 的环境而且暴露的 API 能够像正常 node 模块同样被使用呢?linux

这里不得不说下 DLL(Dynamic Linked Library)动态库, 他是由 C 或 C++使用标准编译器编译而成, 在 linux 或 macOS 中也被称做 Shared Library. 一个 DLL 能够被一个程序在运行时动态加载, DLL 包含源 C 或 C++代码以及可通讯的 API. 有动态是否还有静态的呢? 还真有~ 能够参考这里来看这二者的区别, 简单来讲静态比动态更快, 由于静态不须要再去查找依赖文件并加载, 可是动态能够颗粒度更小的修改打包的文件.ios

在 Nodejs 中, 当编译出 DLL 的时候, 会被导出为.node 的后缀文件. 而后能够 require 该文件, 像 js 文件同样.不过代码提示是不可能有的了.c++

Native Addon 是如何工做的呢?

Nodejs 实际上是不少开源库的集合,能够看看他的仓库, 在 package.json 中找 deps. 使用的是谷歌开源的 V8 引擎来执行 js 代码, 而 V8恰好是使用 C++写的, 不信你看 v8 的仓库. 而对于像异步 IO, 事件循环和其余低级的特性则是依赖 Libuv 库.git

当安装完 nodejs 以后, 其实是安装了一个包含整个 Nodejs 以及其依赖的源代码的编译版本, 这样就不用一个一个手动安装这些依赖而. 不过Nodejs也能够由这些库的源代码编译而来. 那么跟 Native Addon 有什么关系呢? 由于 Nodejs 是由低层级的 C 和 C++编译而成的, 因此自己就具备与 C 和 C++相互调用的能力.github

Nodejs 能够动态加载 C 和 C++的 DLL 文件, 而且使用其 API 在 js 程序中进行操做. 以上就是基本的 Native Addon 在 Nodejs 中的工做原理.npm

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就没问题了, 能够享受代码提示了, 否则真的很容易写错啊!!

相关文章
相关标签/搜索