當我們安裝node的C/C++原生模塊時,涉及到使用node-gyp對C/C++原生模塊的編譯工作(configure、build)。這個過程,需要nodejs的頭文件以及靜態庫參與(后續稱庫文件)對C/C++項目編譯和鏈接。庫文件從哪里下載,會有一定邏輯進行處理,本文將從源碼入手進行分析。
編寫簡單的原生模塊
為了方便進行分析,我們首先創建一個原生模塊(關於如何編寫原生模塊的細節不再本文討論)。
hello_world.cc
#include <node.h>
void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
v8::Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(v8::String::NewFromUtf8(
isolate, "world").ToLocalChecked());
}
void Initialize(v8::Local<v8::Object> exports) {
NODE_SET_METHOD(exports, "hello", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
binding.gyp
{
"targets": [
{
"target_name": "hello_world",
"sources": [ "hello_world.cc" ]
}
]
}
index.js
const binding = require('./build/Release/hello_world');
console.log(binding.hello());
package.json
...
"scripts": {
"build": "node-gyp configure && node-gyp build",
"run:demo": "node index.js"
},
...
整體結構
按照如下命令依次運行:
$ npm run build
// 使用node-gyp配置並構建
$ npm run run:demo
// 運行Demo
輸出如下:
D:\Projects\node-addon-demo>npm run run:demo
> node-addon-demo@1.0.0 run:demo
> node index.js
world
從源碼分析node-gyp下載庫文件的路徑
首先要直接給出一個結論,庫文件並不是每次都要從網絡上下載,庫文件下載后會緩存在本地一個目錄,在Windows上為C:\Users\用戶\AppData\Local\node-gyp\Cache
中,並按照nodejs的版本進行存儲:
本人電腦安裝的node版本為14.15.0,且曾經已經緩存了對應的庫文件。
為了便於分析,我們首先刪除該緩存文件,並且在原有的npm命令加上--verbose
,輸出更加詳細的日志:
$ npm run build --verbose
於是,我們可以從眾多的輸出中,看到一個關鍵信息:
從日志中可以看出,node-gyp在構建過程中,會創建緩存目錄,然后從指定URL下載指定版本的headers文件。
我們利用GrepWin(一款Windows下超好用的文本內容搜索工具,官網),在node-gyp目錄中搜索created nodedir
這個關鍵詞,因為可以看到gyp http GET
上面出現了這個關鍵詞。那么現在有一個新的問題,node-gyp目錄在哪兒?其實,從上面的日志往上查看,能夠找到:
這里是調用的我們全局安裝的npm依賴的node-gyp,於是我們定位到node-gyp所在目錄進行搜索:
進入該文件,我們找到:
找到關鍵詞搜索后,繼續往后續代碼查閱,能夠看到一個download
函數的調用,入參最后一位是url,此時已經是成型的url,所以接下來我們需要確定,release.tarballUrl
這個值,究竟是什么時候確定的。
tarballUrl如何得到
繼續向上翻閱代碼,能夠在入口處看到這個release是如何生成的:
進入代碼后,能夠找到一段核心的構建:
通過上述代碼流程,我們總結出來,tarballUrl的baseUrl取決於是否存在overrideDistUrl,若存在,則直接使用;否則使用默認URL:https://nodejs.org/dist
。
再查看overrideDistUrl
的傳入點:
也就是說,gyp對象的opts屬性存在dist-url
或disturl
時,就會使用該值作為庫文件下載的baseUrl。
如何構建gyp.opts
首先檢查該函數的調用點:
發現configure和install.js都使用了該函數,且都是入口處進行的調用的:
configure.js
function configure (gyp, argv, callback) {
var python
var buildDir = path.resolve('build')
var configNames = ['config.gypi', 'common.gypi']
var configs = []
var nodeDir
var release = processRelease(argv, gyp, process.version, process.release)
......
}
module.exports = configure
install.js
function install (fs, gyp, argv, callback) {
var release = processRelease(argv, gyp, process.version, process.release)
......
}
module.exports = function (gyp, argv, callback) {
return install(fs, gyp, argv, callback)
}
可以看到confiigure.js和install.js都作為函數形式導出,也就是說,gyp這個對象是在這兩個模塊在被導入並以函數形式調用時被傳入的。那么接下來我們需要看這兩個模塊在何處使用的。
在上文我們查看當前執行的node-gyp目錄的時候,我們就看到過:
gyp verb cli [
gyp verb cli 'D:\\Programs\\nodejs\\node.exe',
gyp verb cli 'D:\\Programs\\nodejs\\global_modules\\node_modules\\npm\\node_modules\\node-gyp\\bin\\node-gyp.js',
gyp verb cli 'configure'
gyp verb cli ]
入口函數是:node-gyp根目錄/bin/node-gyp.js
。所以,我們將node-gyp以項目的形式添加到IDEA中,嘗試以相同的形式調用這些命令,通過開啟DEBUG模式,來一探究竟。
在bin/node-gyp.js
中的最下方進行了一個名為run
的函數調用:
// bin/node-gyp.js
// ......
// 還有很多省略的代碼......
// start running the given commands!
run()
根據注釋可以,run()
執行所提供的命令。翻閱該函數:
總體分為兩步:
- 從對象prog的todo這個數組中取出首個command命令對象,不存在判定為所有命令執行完成。
- 從對象prog的命令數組(commands)中找到對應命令名稱(command.name),通過代碼可知,該命令實際上對應一個函數。傳入參數(command.args)完成該函數的調用。
那么這個prog是什么呢?通過向上閱讀代碼,可以知道來自於上層目錄提供的模塊:
而上層所指代的模塊是通過package.json的main
字段可知是lib/node-gyp.js
:
// 根目錄下的package.json
"main": "./lib/node-gyp.js",
進入該文件的gyp函數,返回的是類Gyp的實例,而Gyp實例的構造過程如下:
- 使用self變量指代Gyp實例,並創建devDir和commands字段。
- 遍歷上方的commands字符串數組,給self(也就是Gyp實例)的commands屬性中,逐步添加對應命令名稱的函數,函數的實現是:require和command同名的js模塊,這些模塊又本身是以函數形式導出的,最終是調用對應模塊函數。舉例說明:當遍歷到command為
configure
的時候,就是如下的形式:
self.commands['configure'] = function (argv, callback) {
log.verbose('command', 'configure', argv)
return require('./configure')(self, argv, callback)
}
那么在進行node-gyp configure
時的調用棧就如下:
執行node-gyp configure:
=> run()
...
...
=> gyp.commands['configure'](argv, cb);
=> require('./configure')(self, argv, cb); // self就是Gyp實例
前文我們已經知道了configure.js這個模塊導出的就是一個函數:
// configure.js
function configure (gyp, argv, callback) {
var python
var buildDir = path.resolve('build')
var configNames = ['config.gypi', 'common.gypi']
var configs = []
var nodeDir
// 這個gyp,就是入參gyp,也就是上面的gyp實例
var release = processRelease(argv, gyp, process.version, process.release)
... ...
}
... ...
module.exports = configure
所以,我們終於知道processRelease
的入參的gyp,就是上面的gyp實例。那么gyp實例中的opts屬性,是哪兒來的呢?使用IDEA的Debug進行斷點調式,調試bin/node-gyp.js
:
可以看到,在執行parseArgv
這個函數前,gyp實例里面還不存在opts屬性,而執行后,又在使用opts屬性的devdir。也就是說,parseArgv
這個函數一定構建了opts,接下來我們重點分析這個函數。
入口的argv就是我們的運行時入參:
"dev": "node ./bin/node-gyp.js configure"
首先會經過nopt
函數,看樣子,是對命令行參數以及短命令的處理:
然后是該函數其他的部分:
主要分為兩個部分:
- 對argv的解析
- 對環境變量的解析
對argv的解析不涉及設置opts屬性,我們重點看對環境變量的解析:
// support for inheriting config env variables from npm
var npmConfigPrefix = 'npm_config_'
Object.keys(process.env).forEach(function (name) {
if (name.indexOf(npmConfigPrefix) !== 0) {
return
}
var val = process.env[name]
if (name === npmConfigPrefix + 'loglevel') {
log.level = val
} else {
// add the user-defined options to the config
name = name.substring(npmConfigPrefix.length)
// gyp@741b7f1 enters an infinite loop when it encounters
// zero-length options so ensure those don't get through.
if (name) {
this.opts[name] = val
}
}
}, this)
處理流程為:
- 判斷環境變量的名稱(name),如果不是以
npm_config_
開頭,則跳過該次處理,否則進入下一步。 - 如果變量名是
npm_config_loglevel
(npm的日志等級變量),則使用該日志等級作為node-gyp在使用npm時候的日志變量(這是對日志等級的特殊處理)。 - 否則(一般處理),截斷該變量的名,例如
name = 'npm_config_my_key'
,則得到my_key
,設置到opts中:opts['my_key'] = 變量值
。
至此,我們已經知道了,opts屬性的值來源於上述的解析。
那么,回到我們一開始的目的,我們知道了要實現從指定的地方下載node的庫文件,只要opts里面存在dist-url
或是disturl
即可。有些讀者可能會說,那這樣就行了呀:
實際上,並不行:
解析結束后,會發現,gyp.opts中是不存在dist-url
字段的,只有dist_url
。這一切的緣由,都是因為,npm在處理環境變量的時候,會將-
替換為下划線_
(config | npm Docs (npmjs.com))。
好在,node-gyp還能夠處理opts中的disturl
字段。所以我們只需要在使用npm來使用node-gyp的時候,加入參數--disturl
。現在,讓我們回到我們一開始的node-addon-demo,添加設置變量的參數:
"scripts": {
"build": "node-gyp configure && node-gyp build",
"build:custom": "npm run build --verbose --disturl=this_is_my_custom_url",
"run:demo": "node index.js"
},
上述build:custom
就是我們新加的配置,通過運行,果然,加載的是我們制定的url:
gyp verb created nodedir C:\Users\w4ngzhen\AppData\Local\node-gyp\Cache\14.15.0
// 這里報錯忽略,因為使用的是一個無效的url: 'this_is_my_custom_url'
// 主要是為了驗證確實是改變了
gyp http GET this_is_my_custom_url/v14.15.0/node-v14.15.0-headers.tar.gz
gyp WARN install got an error, rolling back install
gyp verb command remove [ '14.15.0' ]
node-gyp的直接使用和npm使用的區別
那么,有的細心的讀者可能會說,明明這里通過npm使用的時候會轉為下划線,那在node-gyp的官方github,說是可以使用dist-url
這個參數呢?。
nodejs/node-gyp: Node.js native addon build tool (github.com)
實際上,官方文檔給出的參數,需要你直接使用node-gyp方式進行設置,也就是說,--dist-url這個參數必須緊跟node-gyp的命令:
node-gyp configure --dist-url=xxx
像是上面的npm run ${使用node-gyp的腳本名} --dist-url=xxx
,這個dist-url是作為npm的參數來被識別,而非node-gyp。所以,對於demo,我們還可以如下:
"scripts": {
"build": "node-gyp configure --dist-url=this_is_my_custom_url && node-gyp build --dist-url=this_is_my_custom_url",
"build:custom": "npm run build --verbose",
"run:demo": "node index.js"
},
注意,這一次,我把--dist-url
是放在和node-gyp命令的參數的。但是,我們知道有些npm包,內部就直接使用node-gyp進行配置編譯的操作,這個過程沒法通過--dist-url
緊跟node-gyp
命令方式,所以只能在例如.npmrc
文件中配置兼容的不會被下划線處理的disturl
。
總結
要想讓node-gyp下載node庫文件的時候,能夠走指定的鏡像,可以通過配置--dist-url
或是--disturl
的方式,但配置dist-url
形式參數只能是參數緊跟node-gyp
的形式:
node-gyp configure --dist-url=xxx
而不能是如下的形式:
// 你的package.json scripts字段
"build": "node-gyp configure"
// 然后在命令行調用
npm run build --dist-url=xxx //
因為此時--dist-url
參數是npm的參數,且會被處理為npm_config_dist_url
下划線形式,進而在gyp.opts只有dist_url屬性。
所以,最安全的方式是使用disturl參數:
情況1:
node-gyp configure --disturl=xxx
情況2:
// 你的package.json scripts字段
"build": "node-gyp configure"
// 然后在命令行調用
npm run build --disturl=xxx
情況1下,disturl是作為node-gyp的參數進行解析,能夠被設置到opts中。
情況2,disturl是作為npm的參數被加入到npm環境變量:npm_config_disturl
,此時,node-gyp解析process.env的時候,也能解析到disturl
進而設置到opts。