node-pre-gyp以及node-gyp的源码简单解析(以安装sqlite3为例)

前言

简单来说,node是跨平台的,那么对于任何的node模块理论也是应该是跨平台的。然而,有些node模块直接或间接使用原生C/C++代码,这些东西要跨平台,就需要使用源码根据实际的操作平台环境进行原生模块编译。SQLite3就是一个经典的原生模块,让我们以安装该模块为例,探索一下安装原生模块的流程。

项目建立

建立一个简单的node项目,我们开始安装SQLite3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ mkdir sqlite3-install-demo 
$ cd sqlite3-install-demo
$ npm init
# 初始化项目
Press ^C at any time to quit.
package name: (projects) sqlite3-install-demo
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC) MIT
About to write to D:\Projects\package.json:

{
"name": "sqlite3-install-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "MIT"
}

安装SQLite3

1
$ npm install -S sqlite3

完成命令执行后,你会看到命令行界面出现了如下的几行重要的输出:

1
2
3
4
5
6
...
> sqlite3@5.0.0 install D:\Projects\sqlite3-install-demo\node_modules\sqlite3
> node-pre-gyp install --fallback-to-build

node-pre-gyp WARN Using request for node-pre-gyp https download
...

啪一下,很快啊!我们就迎来了第一个东西node-pre-gyp,但是提到了node-pre-gyp,我们不得不提及node-gyp,然后又不得不提及gyp

gyp与node-gyp与node-pre-gyp

什么是gyp?

gyp全称Generate Your Projects(构建你的项目)。wiki的解释如下,自行翻译:

GYP (generate your projects) is a build automation tool. GYP was created by Google to generate native IDE project files (such as Visual Studio Code and Xcode) for building the Chromium web browser and is licensed as open source software using the BSD software license.

重点在于,它是一套用于生成原生IDE项目文件的自动化构建工具,处理C/C++项目,同类型的有CMake、ninja等自动构建工具。

什么是node-gyp?

直接给出stackoverflow高票回答:

node-gyp is a tool which compiles Node.js Addons. Node.js Addons are native Node.js Modules, written in C or C++, which therefore need to be compiled on your machine. After they are compiled with tools like node-gyp, their functionality can be accessed via require(), just as any other Node.js Module.

简单来说,node是跨平台的,那么对于任何的node模块理论也是应该是跨平台的。然而,有些node模块直接或间接使用原生C/C++代码,这些东西要跨平台,就需要使用源码根据实际的操作平台环境进行原生模块编译。那么我们需要下载源码文件,通过node-gyp生成一定结构的代码项目让我们能够require引入(譬如,Windows下会生成vcxproj,再调用MSBuild进行编译,以生成Windows下的动态链接库,最后打包为一个原生node模块)。这个知乎回答的每一条可以看看:传送门

什么是node-pre-gyp?

上面node-gyp固然相当方便了,但是每一次安装node原生模块的时候,都需要根据平台(Windows、Linux、macOS以及对应的x86、x64、arm64等等)进行源码编译,这样做费时费力。为什么不一开始就针对这些平台编译好了做成二进制制品发布呢?反正一般来说主流的平台架构就那么一些(Windows、Linux、macOS)。所以node-pre--gyp就帮我们做了这件事。原生模块开发者将代码编译生成各个平台架构的二进制包直接发布到node-pre-gyp上,当我们的node项目安装原生模块时候。处理流程就是首先去node-pre-gyp上找有没有当前平台的组件包,有的话直接拉取使用,如果没有则进行原生编译。

node-pre-gyp一些重要参数(不全):

  • -C/--directory: run the command in this directory
  • --build-from-source: build from source instead of using pre-built binary
  • --fallback-to-build: fallback to building from source if pre-built binary is not available
  • --target=0.4.0: Pass the target node or node-webkit version to compile against
  • --target_arch=ia32: Pass the target arch and override the host arch. Valid values are ‘ia32’,‘x64’, or arm.
  • --target_platform=win32: Pass the target platform and override the host platform. Valid values are linux, darwin, win32, sunos, freebsd, openbsd, and aix.

对于--fallback-to-build这个参数:如果二进制不可获取则直接从源码编译,即从node-pre-gyp又回到node-gyp。所以你才会在上文看到安装sqlite3的时候,会有--fallback-to-build

于是乎,当我们进行node原生模块安装的时候,一般会有如下的流程:

  1. 针对当前平台架构优先考虑node-pre-gyp方式进行安装,但是为了防止无法获取针对对应平台编译好的二进制包(网络原因、暂时没有对应平台的二进制包),进入第2步;
  2. 下载原生模块源码,然后使用node-gyp进行项目构建,得到与平台相关的源码项目文件(Windows则生成vcxproj项目,Linux下是Makefile);在这个过程,node-gyp会使用Python进行自动化构建操作,这也是为什么有些朋友安装node原生模块的时候,会报错找不到Python
  3. 调用平台对应的编译工具进行编译。在Windows的环境下,node-gyp会查找本地的MSBuild/CL等编译工具,而这些编译工具又一般在Visual Studio安装的时候,也一并安装在了机器上。这就是为什么有些朋友没有安装Visual Studio的时候,会报错。

探索SQLite3的安装流程

npm install

为什么我们安装sqlite3的时候,会调用node-pre-gyp命令呢?进入项目目录/node_modules/sqlite3/文件夹,让我们查看一下package.json中的scripts部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
...
"repository": {
"type": "git",
"url": "git://github.com/mapbox/node-sqlite3.git"
},
"scripts": {
"install": "node-pre-gyp install --fallback-to-build", // install
"pack": "node-pre-gyp package",
"pretest": "node test/support/createdb.js",
"test": "mocha -R spec --timeout 480000"
},
"version": "5.0.0"
}

答案显而易见了,install脚本中执行了node-pre-gyp install --fallback-to-build命令。

这就不得不提到npm的安装流程是。当我们进行npm install xxx的时候,npm首先下载xxx的包。下载完成后,若package.json中的scripts中存在install属性,则会立刻调用。至于scripts中的其他固定脚本:testpreinstallpostinstall等等作用以及scripts的高级用法,请直接查阅scripts | npm Docs (npmjs.com)

所以本此sqlite3前期安装的过程为:

  1. npm下载在仓库中的sqlite3npm包;
  2. 执行${your_projects}/node_modules/sqlite3/package.json中的install脚本,即node-pre-gyp install --fallback-to-build

于是乎,安装进入到了一个新的环节:node-pre-gyp install。当然,若你没有全局安装node-pre-gyp,它会由npm帮你安装到${your_projects}/node_modules/中,并且通过node-pre-gyp/package.json中的bin元素,建立软连接到${your_projects}/node_modules/.bin中。这样,node\npm环境中就有了node-pre-gyp命令可以使用。至于package.json#bin的作用,详细参考官方文档package.json | npm Docs (npmjs.com)

node-pre-gyp install

node-pre-gyp在上述的安装流程中,已经能够被我们在CLI中所使用。查看node_modules/node-pre-gyp/bin/node-pre-gyp文件(下文都将省略${your_projects}/),用文本的形式打开。就是node-pre-gypCLI的执行过程,脚本中的主要内容为最后一行:

1
2
// start running the given commands!
run();

检查该函数的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function run () {
var command = prog.todo.shift();
if (!command) {
// done!
completed = true;
log.info('ok');
return;
}

prog.commands[command.name](command.args, function (err) {
if (err) {
log.error(command.name + ' error');
log.error('stack', err.stack);
errorMessage();
log.error('not ok');
console.log(err.message);
return process.exit(1);
}
var args_array = [].slice.call(arguments, 1);
if (args_array.length) {
console.log.apply(console, args_array);
}
// now run the next command in the queue
process.nextTick(run);
});
}

prog是什么?该文件往上查看定义,原来是:

1
2
3
4
5
6
7
8
var node_pre_gyp = require('../'); // 上一个目录作为模块引入
var log = require('npmlog');

/**
* Process and execute the selected commands.
*/

var prog = new node_pre_gyp.Run(); // 来自于node_pre_gyp中的Run,而node_pre_gyp在上方

继续检查上一个目录,发现并没又indes.js文件,熟悉npm的朋友应该知道要去看package.json中的main元素了:

1
2
3
4
5
...
"license": "BSD-3-Clause",
"main": "./lib/node-pre-gyp.js", // 模块是这个文件
"name": "node-pre-gyp",
...

查阅lib/node-pre-gyp.js代码中的Run:

1
2
3
4
5
6
7
8
9
10
11
12
function Run() {
var self = this;

this.commands = {};

commands.forEach(function (command) {
self.commands[command] = function (argv, callback) {
log.verbose('command', command, argv);
return require('./' + command)(self, argv, callback); // 这里是核心
};
});
}

核心功能就是引入当前所在目录下的模块进行执行。例如,本次调用的是node-pre-gyp install,则会require(./install),检查一下node-pre-gyp.js目录下,果然存在该js文件。继续阅读install.js源码。里面有几个函数的定义。咱们先不看内容,把函数名列举出来,猜测一下作用:

1
2
3
4
5
6
7
8
9
10
11
// 去下载平台编译好的二进制?
function download(uri,opts,callback) {...}
// 把下载好的二进制放到对应目录?
function place_binary(from,to,opts,callback) {...}
// 进行构建。难道是没有下载,就调用node-gyp源码编译?
// 还有,node-pre-gyp又--fallback-to-build参数,也会调用这个?
function do_build(gyp,argv,callback) {...}
// 打印回退出现的异常
function print_fallback_error(err,opts,package_json) {...}
// 安装,核心没跑了
function install(gyp, argv, callback) {...}

首先看download的调用点是在place_binary中:

1
2
3
4
5
6
7
8
function place_binary(from,to,opts,callback) { // place_binary函数
download(from,opts,function(err,req) { // 调用了download
if (err) return callback(err);
if (!req) return callback(new Error("empty req"));
...
}
...
}

再看place_binary调用点是在install中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function install(gyp, argv, callback) {
// 省略部分...
var should_do_source_build = source_build === package_json.name || (source_build === true || source_build === 'true');
if (should_do_source_build) { // 源码编译
log.info('build','requesting source compile');
return do_build(gyp,argv,callback);
} else {
// 省略部分...
mkdirp(to,function(err) {
if (err) {
after_place(err);
} else {
place_binary(from,to,opts,after_place); // 调用点
}
});
// 省略部分...
}
// 省略部分...
}

通过上述分析,整个大的处理流程如下:

  1. 进入install函数
  2. 检查是否需要build-from-source。是则进,入do_build分支,进行源码编译;否则进入步骤3。
  3. 检查是否启用--fallback-to-build参数,设定是否启用标志位。
  4. 解析编译好的二进制文件的选项配置,譬如二进制文件存放地址,也就是通过请求下载对应二进制包的地址,以及各种各样参数。所以说,为什么下载很慢,我们后文会重点关注下载地址。

下载二进制包

根据流程,接下来我们进一步检查versioning.js文件,找到其中的evaluate函数,分析最后的hosted_tarball路径:

hosted_tarball路径主要分为两个部分:1、hosted_path;2、package_name

hosted_path

经过源码分析来源路径为:

我们自底向上分析。

host变量取决于从环境变量中检查名称为'npm_config_' + opts.module_name + '_binary_host_mirror'的环境变量。如果不存在,则使用package_json.binary.host。正常使用的时候,我们并不会设定环境变量,所以这里就进入package_json.binary进行获取。这个package_jsonevaluate函数被调用时候传入的,在node-pre-gyp/install.js中能够看到:

一开始分析的时候,看到这里,本人以为package_json就是node-pre-gyp/package.json,于是本人去检查该json发现很奇怪,并没有binary属性,更别提host了。一番思考才明白,node-pre-gyp install的运行时调用者是谁呀?不是应该是sqlite3吗?所以这个地方的require('./package.json')实际上是指代的是sqlite3/package.json。查看sqlite3/package.json,果然发现了对应的元素:

binary属性中,我们还能看到remote_path也在其中。

至此,hosted_path我们完成了简单的分析,我们可以得出一个结论:

node-pre-gyp下载二进制文件的路径,优先来源于对应模块的镜像地址,该镜像地址通过配置'npm_config_' + 模块名 + '_binary_host_mirror'来实现自定义;在没有定义镜像地址的情况下,读取模块package.json中的binary属性信息。

当然,读者可以根据具体情况再进一步分析源码。

package_name

其实,对于hosted_path的分析,我们也容易分析package_name了。

自底向上分析,来自于sqlite3/package.jsonbinary属性中的package_name,内容见上图分析host

失败处理

--fallback-to-build参数表明了是否进行失败后下载源码进行编译,源码不再分析。

从源码构建

build.js

当我们提供了参数--build-from-source或是在下载编译好的二进制到本地出错的时提供了参数--fallback-to-build。node-pre-gyp将进入do_build模块,进行源码编译。

1
2
3
4
5
function do_build(gyp,argv,callback) {
var args = ['rebuild'].concat(argv);
gyp.todo.push( { name: 'build', args: args } );
process.nextTick(callback);
}

代码中,gyp由调用install的时候,传入:

那么我们又将回到调用install的地方。实际上,gyp就是node-pre-gyp.js导出的模块:

也就是说在do_build中进行操作就是,放置了一个build任务在队列中。所以我们按照先前的分析,直接去看build.js

看源码调用了当前模块中的do_build,且其中最核心的就是compile模块:

util/compile.js

进入compile模块,直接找到对应的run_gyp函数,代码很短,不难看出进行构建调用了node-gyp

上述代码,会先考略node-webkit构建。但是我们核心的还是使用node-gyp,所以else中,会进行node-gyp的工具的检查工作。最后调用命令行执行node-gyp。于是,node原生模块的安装工作,进入了新的阶段:node-gyp

node-gyp build

上文提到我们已经进入了node-gyp的范畴,会调用node-gyp build操作。当然,这个命令同样是在安装node-gyp依赖的时候已经完成了安装,并且进行node_modules/.bin/软连接操作。

1
2
3
4
5
...
"bin": {
"node-gyp": "bin/node-gyp.js"
},
...

我们进入该js进行分析

实际上,node-gyp这段的命令行代码,和node-pre-gyp非常相似!所以我们也不去深入分析调用命令行了。直接在lib文件夹下面的build.js。在该js中,核心的方法为:

1
2
3
function build (gyp, argv, callback) {
...
}

在该方法中,还编写了几个内部函数,作为了功能的划分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// function build (gyp, argv, callback) 内部函数
/**
* Load the "config.gypi" file that was generated during "configure".
*/
function loadConfigGypi () {...}
/**
* On Windows, find the first build/*.sln file.
*/
function findSolutionFile () {...}
/**
* Uses node-which to locate the msbuild / make executable.
*/
function doWhich () {...}
/**
* Search for the location of "msbuild.exe" file on Windows.
*/
function findMsbuild () {...}
/**
* Actually spawn the process and compile the module.
*/
function doBuild () {...}
/**
* Invoked after the make/msbuild command exits.
*/
function onExit (code, signal) {...}

不得不说,build写的真心不错,看起来很舒服。这里为了方便读者快速阅读,我整理这些函数的调用图:

整个调用流程图个人认为足够进行安装的时候的一场分析了。至于每个内部函数的功能,有空继续更新本文吧。