zoukankan      html  css  js  c++  java
  • 前端使用 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就没问题了, 可以享受代码提示了, 不然真的很容易写错啊!!

  • 相关阅读:
    vue 自定义全局按键修饰符
    Vue 过滤器
    v-if、v-show 指令
    其他内置函数
    python中序列化和反序列化
    jmeter图形化html报告核心指标介绍
    jmeter在linux系统下如何进行压力测试
    文件操作的其他方法
    文件处理操作
    内置函数reduce()
  • 原文地址:https://www.cnblogs.com/BigJ/p/Cxx2JS.html
Copyright © 2011-2022 走看看