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

前端使用 node-gyp 构建 Native Addon的更多相关文章

  1. 实践案例丨教你一键构建部署发布前端和Node.js服务

    如何使用华为云服务一键构建部署发布前端和Node.js服务 构建部署,一直是一个很繁琐的过程 作为开发,最害怕遇到版本发布,特别是前.后端一起上线发布,项目又特别多的时候. 例如你有10个项目,前后端 ...

  2. React Native是一套使用 React 构建 Native app 的编程框架

    React Native是一套使用 React 构建 Native app 的编程框架 React Native at first sight what is React Native? 跟据官方的描 ...

  3. 前端用node+mysql实现简单服务端

    node express + mysql实现简单服务端前端新人想写服务端不想学PHP等后端语言怎么办,那就用js写后台吧!这也是我这个前端新人的学习成果分享,如有那些地方不对,请给我指出. 1.准备工 ...

  4. 前端学习 node 快速入门 系列 —— 初步认识 node

    其他章节请看: 前端学习 node 快速入门 系列 初步认识 node node 是什么 node(或者称node.js)是 javaScript(以下简称js) 运行时的一个环境.不是一门语言. 以 ...

  5. 前端使用node.js的http-server开启一个本地服务器

    前端使用node.js的http-server开启一个本地服务器 在写前端页面中,经常会在浏览器运行HTML页面,从本地文件夹中直接打开的一般都是file协议,当代码中存在http或https的链接时 ...

  6. 论Node在构建超媒体API中的作用

    论Node在构建超媒体API中的作用 作者:chszs,转载需注明. 博客主页:http://blog.csdn.net/chszs 超媒体即Hypermedia,是一种採用非线性网状结构对块状多媒体 ...

  7. 前端学习 node 快速入门 系列 —— npm

    其他章节请看: 前端学习 node 快速入门 系列 npm npm 是什么 npm 是 node 的包管理器,绝大多数 javascript 相关的包都放在 npm 上. 所谓包,就是别人提供出来供他 ...

  8. 前端学习 node 快速入门 系列 —— 模块(module)

    其他章节请看: 前端学习 node 快速入门 系列 模块(module) 模块的导入 核心模块 在 初步认识 node 这篇文章中,我们在读文件的例子中用到了 require('fs'),在写最简单的 ...

  9. 前端学习 node 快速入门 系列 —— 简易版 Apache

    其他章节请看: 前端学习 node 快速入门 系列 简易版 Apache 我们用 node 来实现一个简易版的 Apache:提供静态资源访问的能力. 实现 直接上代码. - demo - stati ...

随机推荐

  1. flask上下管理文相关 - 总结

    flask上下管理文相关 - 总结 flask上下文管理机制 当用户请求到来之后,flask内部会创建两个对象: ctx = ReqeustContext(),内部封装request/sesion a ...

  2. window环境下zookeeper的安装(自用---仅供参考)

    转自: https://www.cnblogs.com/ysw-go/p/11396343.html 第一部分:单机模式 1)下载地址:http://www.pirbot.com/mirrors/ap ...

  3. Fabric1.4 背书策略 .yam文件

    { identities: [ // 以下几项自动编号为[0,1,2] { role: { name: "member", mspId: "peerOrg1" ...

  4. Vue Cli3.0 使用jquery

    参考链接:https://blog.csdn.net/ai520587/article/details/84098601

  5. CentOS7 卸载mysql(YUM源方式)

    防止重装 yum方式 查看yum是否安装过mysql yum list installed mysql* 如或显示了列表,说明系统中有MySQL  yum卸载 根据列表上的名字 yum remove ...

  6. C#中异步编程异常的处理方式

    异步编程异常处理 在同步编程中,一旦出现错误就会抛出异常,我们可以使用try-catch来捕捉异常,未被捕获的异常则会不断向上传递,形成一个简单而统一的错误处理机制.但是对于异步编程来说,异常处理一直 ...

  7. IO-file-03 文件的长度

    package com.bwie.io; import java.io.File; public class FileDemo4 { /**文件字节数 * length():字节数 文件夹 0 * * ...

  8. js复制文本

    第一种: 自己测试时 只适合于input 和textarea 但是针对于其他标签的复制就不能用了.代码如下: <!DOCTYPE html> <html> <head&g ...

  9. [转帖]「知乎知识库」— 5G

    「知乎知识库」— 5G 甜草莓 https://zhuanlan.zhihu.com/p/55998832 ​ 通信 话题的优秀回答者 已关注 881 人赞同了该文章 谢 知识库 邀请~本文章是几个答 ...

  10. System x 服务器制作ServerGuide U盘安装Windows Server 2008 操作系统 --不格式化盘

    1.全格式化 用ServerGuide10.5 刻录成U盘 下载附件中的Rufus 3.6工具,并制作引导U盘 以管理员权限打开Rufus 3.6, 选择镜像文件 2.不格式化,仅安装C盘下载老毛桃U ...