二进制分发 nodejs 插件包

npm 包简介

js package

开发中必不可少会引用一些外部依赖包,这些包提供一些与业务逻辑无关,又能提高开发进度的功能。业务开发过程中,我们也会剥离一些比较独立的模块出来,发布一个或多个 npm package。npm 是最大的包管理工具了,该平台目前有一百二十多万个包,周下载量达十八亿次。

通常来说,一个 npm 包的最小结构只需要一个 package.json 和一个入口 js 文件。比如 cache 这个包,总共就三个文件,实际代码只有 40 行不到,实现了一个带过期时间的内存缓存,每周有 1.4k 的下载。对于一些小而美的功能,js 也足够用了,但是稍复杂或庞大的功能,依赖无类型的 js 开发,将变得比较困难。

ts package

对于较大型或功能较为复杂的包来说,使用 ts 是一个非常不错的选择,当然 ts 依然可以做小而美的包。借助 ts 类型机制,代码在提示上,静态校验层面将会比 js 更加方便一点。tsc 可将 ts 代码编译成 js 代码,并不影响在 js 环境执行,借助编译期的检查更能尽早发现错误,提高代码质量。

addon package

众所周知 nodejs 不擅长 cpu 密集型运算,因为 cpu 密集型运算会导致当前 js 线程阻塞,无法继续对外服务。nodejs 的 js 代码是单线程执行的,但是 node 进程并不是。而 ndoejs 是基于 c++ 开发的,并且 nodejs 提供了编写 c++ 插件的能力。这就使得 nodejs 可以借助 c++ 完成一些 cpu 密集型任务,比如图片处理的 sharp 包,比如中文分词的 nodejieba。这些真正执行任务的还是 c++ 的代码,但是基于 nodejs 包裹后,使得我们可以完全不关心其 c++ 部分,像使用普通 js 包一样去使用它。

创建一个 addon package

注册登录相关账户

首先注册 npm 账户是少不了的,先到 https://www.npmjs.com/ 注册 npm 账户,然后本地命令登录该账户 npm login, 按提示输入账户名和密码。

创建包项目

新建一个项目文件夹,然后使用 npm init, 交互式输入相关选项即可初始化一个 package.json 文件,有了这个文件,可以认为有了一个项目。但是对外提供服务,还需要提供一个入口文件,这个文件需要在 package.json 中的 main 字段指明。

对于 package.json 这里有最详细的解释,我们着重看一下几个字段

  • files: 这个数组字段用来标记哪些文件可以被打包到 package 中,npm 安装依赖时会下载下来,对于 ts 包来说,通常不需要发布 ts 文件,只需要指定编译后的 js 文件目录即可,有助于减小包的体积。
  • main: 这个用来指定包的入口文件,是必选字段。
  • repository: 这里指明包的存放仓库,当然也可以不填,但是用户就很难反馈相关信息
  • scripts: 这里可以指定相关命令,使用 npm run 来进行执行。之所以重点提出是因为 npm 提供了部分 hook 在这里,比如:prepublishOnly, 我们可以设置这个指令为 npm run test && npm run build, 避免 ts 包发布时忘记 build,比如:install: 这个我们下文中会用到

编写 addon

有了包项目,如果只是发布一个 js 功能,相关配置完成后,npm publish 即可。但我们今天的主题是 二进制分发 nodejs 的插件包,那么我们还得继续,先制作一个插件包,才能继续后续操作。

插件包并不比 js 包多多少配置,主要在于一个 binding.gyp 文件, 这里可以找到相关的详细配置,简而言之 binding.gyp 类似 package.json, 它描述了 c++ 的代码该如何进行编译连接。既然有配置文件,那么谁来负责读取执行呢?答案就是 node-gyp。node-gyp 并不是一个纯 js 库,它通过 child_process 模块,转调 Python 脚本来实现相关功能,这也是有些包在安装时需要依赖 Python 环境的原因。

nodejs 插件的编写需要遵循相关的规范,我们可以从 C++ Addons 这里找到插件的编写示例,早期 ndoe 的插件 api 并不稳定,为了兼容不同版本的 nodejs,我们通常使用 NAN 这个库。简单粗暴,提供抽象的宏定义,根据判断不同 nodejs 版本的 api 进行适配。后来 nodejs 推出了 napi ,各个版本维持接口不变,相关的头文件可在 node-addon-api 这个包获取,以便编译 c++ 代码。其实插件包到这里就可以发布了,npm 在安装我们的包时,会根据包的配置,自动编译相关代码,生成插件供下载方使用,那为什么还要二进制发布呢?

二进制发布包

前文有说到,nodejs 插件依赖 node-gyp 编译,node-gyp 依赖 Python 环境,其实还少了一个环境,真正编译 c++ 代码时,还需要 g++ 编译器。这些通常是操作系统必备的,但是某些运行环境恰恰缺少这些,比如我们使用 docker 部署应用时,会尽可能减少包的大小,而这些又仅在安装时使用一次,后续代码运行并不需要。当然我们可以选择分阶段构建镜像,使用环境齐全的镜像作为编译镜像,最后拷贝到打包镜像中去,但是这并非最佳解决方案。

c++ 代码在编译时,也是需要时间的,对于一些复杂的 c++ 代码,其编译时间甚至可以达几十分钟。这对于 npm 来说是不能忍受的。nodejs 的插件可以像 nodejs 一样,根据不同平台编译出对应的可执行文件,便于分发,免去长久的编译过程,减少对运行环境的依赖。

为了达到这个目的,我们需要一个库,node-pre-gyp, 它实现的功能很简单,就是在安装时,根据配置规则尝试直接下载二进制文件,如果下载不到在用 node-gyp 执行编译过程。逻辑很简单,我们也可以自己编写相关脚本来实现,比如 sharp 就是自己编写脚本的。

示例包目录简介

相关示例代码可以在参考资料中获取

.
├── LICENSE
├── README.md
├── binding.gyp
├── build
│   ├── Release
│   │   └── hello.node
│   └── stage
│       └── hello-v1.0.1-node-v64-darwin-x64.tar.gz
├── index.js
├── package.json
├── source
│   └── hello.cc
├── test
│   └── test.test.js
└── yarn.lock

简单说明下各个文件的含义

  • binding.gyp:这是 c++ 插件的编译配置,node-gyp 根据此配置编译 c++ 代码到 node 插件
  • build:这是一个编译目录,插件编译的临时文件和插件本身都在此目录下
    • Release:release 插件的编译结果,如果是 debug 则存在 Debug 目录
      • hello.node:插件,插件的名称取决于 binding.gyp 中的 target 配置
    • stage:这是 node-pre-gyp 打包的存放结果
      • hello-v1.0.1-node-v64-darwin-x64.tar.gz:这是 node-pre-gyp 打包的文件,需要上传至远端,当再次安装时,将下载此文件,文件名根据 package.json 中的 binary 的配置,受 nodejs 版本,系统运行架构等等影响
  • index.js:此测试包的入口 js,主要对外导出插件相关方法
  • source:这是放置 c++ 源文件的地方,其实文件夹名称无所谓,只要在 binding.gyp 中对应配置即可

Travis CI

上文所述,我们可以动态的下载插件的二进制包,那么二进制包如何来呢?插件开发者使用不同的机器手动打包上传?这对开发者的负担也太大了。好在我们可以使用 Travis CI 来避免这些重复性劳动。

Travis-CI 是众多持续构建系统之一,与 GitHub 相配合可以做到很多强大的能力,尤其对于开源项目的免费计划,所以十分实用。使用 GitHub 注册 Travis CI ,在 Travis CI 中开启对应仓库的权限即可,Travis CI 将会根据仓库的更新事件,执行仓库中 .travis.yml 指定的脚本。

Travis 的使用

os:
  - linux
language: node_js
env:
  - CXX=g++-4.8
node_js:
  - "10.0.0"
  - "12.0.0"
  - "13"
notifications:
  email:
    recipients:
      - 1127132348@qq.com
    on_success: change
    on_failure: always
addons:
  apt:
    sources:
      - ubuntu-toolchain-r-test
    packages:
      - g++-4.8
before_script: node-pre-gyp configure && node-pre-gyp build && node-pre-gyp package
script: yarn test
deploy:
  provider: releases
  token: ${GH_TOKEN}
  file_glob: true
  skip_cleanup: true
  file: build/stage/*
  on:
    branch: master
    tags: true

简单介绍下各阶段的含义:

  • node_js: 这里指定 nodejs 的版本,由于测试包使用的 napi 对 node 版本有最低限制,所以没有列出更低的 node 版本。如果使用 nan,可已将支持的版本都添加进去,这样每个版本的 node 都可以获取到对应的二进制包,不需要本地编译
  • before_script:执行 script 之前,提前编译插件并打包好
  • deploy:这是 Travis-CI 的一个阶段,deploy 可以做到很多功能,比如上传文件,比如上传 git 等等。
    • provider:指明 deploy 的类型是 GitHub release

    • token:这里使用的是 GitHub 授权的 token,token 需要有仓库上传的的权限,Travis 根据 api 调用 GitHub 相关 api 完成上传操作,这里为了方便直接在 Travis 后台添加的环境变量,如不放心可使用 Travis 提供的 cli 加密自己 token 使用 secure 来配置

      token
        secure: YOUR_API_KEY_ENCRYPTED
      
    • file_glob:这里指定使用 glob 语法匹配要上传的文件,由于 node-pre-gyp 最终打包的文件我们不能一一确定文件名,使用 glob 匹配最为简单

    • skip_cleanup:跳过清理缓存,避免将上传的文件清空掉

    • file:指明要上传的文件,file_glob 指定了可以使用 glob 语法,这里使用通配符上传打包的文件

    • on:指定 deploy 执行的条件,这里指定在 master 有 tag 提交的时候,才进行,这样我们在发布包完成后,只需要在 GitHub 上新建一个同版本的 release,deploy 将自动执行, 之后打包文件将上传至 GitHub release,后续 npm 包安装时,就可以从这里拿到对应版本编译好的文件

Travis 注意事项

${GH_TOKEN}:这是需要到 Travis 后台设定的环境变量,环境变量 key 为 GH_TOKEN, value 为 GitHub 上的授权 token, 在 token 页面的 Personal access tokens 栏目中创建,需要给与仓库的权限,因为 deploy 时,需要将打包好的二进制文件上传至 GitHub。

file_glob:为 true 时,file 不支持列表,只支持示例中的写法进行匹配,曾经我在这里试了很多次。。。。

参考资料