如何hack node模块
为何要去hack?
在业务开发过程中,往往会依赖一些node工具,hack这些node模块的主要目的是在不修改工具源码的情况下,篡改一些特定的功能。可能会是出于以下几种情况的考虑:
- 总是存在一些特殊的本地需求,不一定能作为工具的通用需求来暴露正常的API给更多的用户。
- 临时且紧急的需求,提PR已经来不及了。
- 为什么不直接去改源码?考虑到工具会不定期升级,想使用工具的最新特性,改源码可维护性太差。
期望
举个栗子:
// a.js
module.exports = function(){
dosomething();
}
// b.js
module.exports = require(a);
// c.js
console.log(require(b));
b是你项目c依赖的一个工具模块,b依赖a。希望在某个特定的项目中,b调用a时,a的函数里能多加个方法injectSomething()
- hack之前c的输出
js function(){ dosomething(); }
- 期望hack能实现的c的输出
js function(){ injectSomething(); dosomething(); }
主要方法
利用模块cache篡改模块对象属性
这是我最早使用的方法,在模块a的类型是object的时候,可以在自己的项目c中提早require 模块a,按照你的需求修改一些属性,这样当模块b再去require 模块a时,从缓存中取出的模块a已经是被修改过的了。
模块a,b,c栗子如下:
// a.js
module.exports = {
p
}
// b.js
const a = require(a);
a.p();
// c.js
require(b);
我想修改a的方法p,在c中进行如下修改即可,而无需直接去修改工具a、b的源码:
// c.js
const a = require(a);
let oldp = a.p;
a.p = function(...args){
injectSomething();
oldp.apply(this, args);
}
require(b)
- 缺陷:
在某些模块属性是动态加载的情况,不是那么灵敏,而且只能篡改引用对象。但是大部分情况下还是能够满足需求的。
修改require.cache
在遇到模块暴露的是非对象的情况,就需要直接去修改require的cache对象了。关于修改require.cache的有效性,原理部分会在后面说。先来简单的说下操作:
//a.js
module.exports = function(){
doSomething();
}
//c.js
const aOld = require(a);
let aId = require.resolve(aPath);
require.cache[aId] = function(...args){
injectSomething();
aOld.apply(this, args);
}
require(b)
- 缺陷: 可能会有人手动去修改require.cache
代理require
这种方法是直接去代理require
,是最稳妥的方法,但是侵入性相对来说比较强。文件中的require其实是在Module的原型方法上,即Module.prototype.require
。(后面会细说)
const Module = require('module');
const _require = Module.prototype.require;
Module.prototype.require = function(...args){
let res = _require.apply(this, args);
if(args[0] === 'xxx'){ // 只修改xxx模块内容
injectSomething();
}
return res;
}
- 缺陷: 对整个node进程的require操作都具有侵入性。
相关原理
node在启动的过程中发生了什么?
我们先来看看在node a.js
时发生些什么?node源码
上图是node运行a.js
的一个核心流程,node的启动程序bootstrap_node.js
是在 node::LoadEnvironment
中被v8立即执行的,bootstrap_node.js
中的startup()
是包裹在一个匿名函数里面的,所以在一次执行node
的行为中startup()
只会被调用了一次,来保证bootstrap_node.js
的所执行的所有依赖只会被加载一次。LoadEnvironment`立即执行的源码如下:
//LoadEnvironment(Environment* env)
Local<String> script_name = FIXED_ONE_BYTE_STRING(env->isolate()," bootstrap_node.js");
Local<Value> f_value = ExecuteString(env, MainSource(env), script_name);
在bootstrap_node.js
中,会去执行Module的静态方法runMain
,而runMain
中则去执行了Module._load
// bootstrap_node.js
const Module = NativeModule.require('module');
……
run(Module.runMain);
// Module.js
Module.runMain = function() {
Module._load(process.argv[1], null, true);
process._tickCallback();
};
为什么一个进程里只存在一个cache对象?
先来看看module._load
干了什么?
Module._load = function(request, parent, isMain) {
var filename = Module._resolveFilename(request, parent, isMain);
var cachedModule = Module._cache[filename];
if (cachedModule) {
return cachedModule.exports;
}
……
var module = new Module(filename, parent);
……
Module._cache[filename] = module;
tryModuleLoad(module, filename);
return module.exports;
};
可以看到,load的一个模块时,会先读缓存Module._cache
,如果没有就会去new一个Module的实例,
然后再把实例放到缓存里,而Module
在一个node进程调用链路中只会存在一个。
那么Module._cache
和require.cache
有什么关系呢?
可以看下Module.prototype._compile
这个方法,这里面会对大家写的node文件进行一个包装,注入一些上下文,包括require
:
var require = internalModule.makeRequireFunction.call(this);
var args = [this.exports, require, this, filename, dirname];
var depth = internalModule.requireDepth;
var result = compiledWrapper.apply(this.exports, args);
而在internalModule.makeRequireFunction
我们会发现
// 在makeRequireFunction中
require.cache = Module._cache;
所以,Module._cache
和require.cache
是一样的,那么我们直接修改require.cache
的缓存内容,在一个node进程里是有效的。
require
不同场景的挂载
最开始我以为require是挂载在global上的,于是理所当然用node repl来测试:
$ node
> global.require
{ [Function: require]
resolve: [Function: resolve],
main: undefined,
extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
cache: {} }
如果以为可以直接代理global.require那就踩坑了,因为如果在文件中使用会发现:
console.log(global.require);
// undefined
从上文可知,文件中的require
其实是来自于Module.prototype._compile
中注入的Module.prototype.require
, 而最终的指向其实是Module._load
,并没有挂载到module上下文环境中的global对象上。
于是我尝试在REPL中打印:
$ node
> global.require === module.require
false
结果有点奇怪,于是去探究了下。在bootstrap_node.js
中找到repl的调用文件repl.js
const require = internalModule.makeRequireFunction.call(module);
context.module = module;
context.require = require;
得到结论:在repl中,module.require
和global.require
最终的调用方法是一样的,只是函数指向不同而已。
注意点
- path路径
require.cache
是一个key、value的map,key看上去是模块所在的绝对路径,然后是不能用绝对路径直接去用的,需要require.resolve
一下来进行转移
- 多进程的情况
模块间调用的链路比较长,需要考虑def的入口文件和你需要代理的文件是否在一个进程中,简单的方法就是在入口文件和你需要代理的文件打印pid:
console.log(process.pid)
如果一致,那么直接在入口调用前代理即可,否则情况会更复杂点,需要找到相应的进程调用处进行代理。
案例
篡改输入(prompt)
场景:某些需要输入的def命令,eg:def add、def p
原因:想一键完成批量创建or批量发布,不想手动输入。
解决过程:以创建模块为例
首先找到def的入口文件,即一个bin目录下的路径,可以通过这个入口文件不断追溯下去,发现创建模块的generator用的是yeoman-generator的方法.对prompt的方法进行代理。可以将该基础库提前require,更改掉起prompt的方法即可。
附上示例案例(示例只篡改模块的创建类型,其他的篡改方法类似):
#!/usr/bin/env node
'use strict';
require('shelljs/global');
const path = require('path');
const HOME = process.env.HOME;
const yeomanRouter = require(path.join(HOME, '.def/def_modules/.generators/@ali/generator-abs-router/node_modules/@ali/generator-abs-router/node_modules/yeoman-generator'));
yeomanRouter.generators.Base.prototype.prompt = function(list, callback) {
let item = list[0];
let prop = {};
prop[item.name] = 'rx'; // 让模块类型输入自动为rx
callback(prop);
};
//require real def path
const defPath = which('def').stdout;
require(defPath);
篡改dev构建(webpackconfig)
场景:一个kg的组件,需要在本地调试时def dev
更改一个文件内容。
原因:一般来说,这种情况可以选择注释代码大法,本地调试时打开注释,发布前干掉。但这样造成代码很不美观,也容易引起误操作。不妨在本地调试的reflect的过程中动态更换掉就好了。
解决过程:
追溯
def dev
调用链路,找到最终reflect的文件, 在这个builder@ali/builder-cake-kpm
项目里。所使用的webpack的配置项在@ali/cake-webpack-config
下。现在就是往webpack配置项里动态注入一个webpack loader的过程了,我需要的loader是一个preLoader,代码非常简单,我把它放在业务项目的文件里:
module.exports = function(content) {
return content.replace('require\(\'\.\/plugin\'\)', "require('./localPlugin')");
};
@ali/cake-webpack-config
暴露的是个函数而非对象,所以必须从require下手了,最后附上完整的代理过程:
#!/usr/bin/env node
'use strict';
require('shelljs/global');
const path = require('path');
const HOME = process.env.HOME;
const CWD = process.cwd();
const cakeWcPath = path.join(HOME, '.def/def_modules/.builders/@ali/builder-cake-kpm/node_modules/@ali/builder-cake-kpm/node_modules/@ali/cake-webpack-config');
const preLoaderPath = path.join(CWD, 'debug/plugin_compile.js') // 注入的loader路径
const cakeWebpackConfig = require(cakeWcPath)
const requireId = require.resolve(cakeWcPath);
require.cache[requireId].exports = (options) => {
if (options.callback) {
let oldCb = options.callback;
options.callback = function(err, obj) {
obj.module.preLoaders = [{
'test': /index\.js$/,
'loader': preLoaderPath
}];
oldCb(err, obj);
}
}
cakeWebpackConfig(options);
}
//require real def path
const defPath = which('def').stdout;
require(defPath);