node-gyp 实现 nodejs 调用 C++

9,276 阅读12分钟

前端轮子千千万, 但还是有些瓶颈, 公司需要在前端调用自有 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就没问题了, 可以享受代码提示了, 不然真的很容易写错啊!!