为何要去hack?

在业务开发过程中,往往会依赖一些node工具,hack这些node模块的主要目的是在不修改工具源码的情况下,篡改一些特定的功能。可能会是出于以下几种情况的考虑:

  1. 总是存在一些特殊的本地需求,不一定能作为工具的通用需求来暴露正常的API给更多的用户。
  2. 临时且紧急的需求,提PR已经来不及了。
  3. 为什么不直接去改源码?考虑到工具会不定期升级,想使用工具的最新特性,改源码可维护性太差。

期望

举个栗子:

// 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_main

上图是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._cacherequire.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._cacherequire.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.requireglobal.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);