如何 hack Node.js 模块?

为何要去 hack?

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

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

期望

举个栗子:

// a.jsmodule.exports = function(){  dosomething();}// b.js module.exports = require(a);// c.js console.log(require(b));

b 是项目 c 依赖的一个工具模块,b 依赖 a。希望只在项目 c 中,b 调用 a 时,a 的函数里能注入一些方法 injectSomething()

  • hack 之前 c 的输出
function(){  dosomething();}
  • 期望:hack 之后 c 的输出
function(){  injectSomething();  dosomething();}

具体案例比如:在做个人自动化工具时,需要 mock 一些工具的手动输入;在本地构建时,需要修改通用的构建流程(后面案例部分会详细说)

主要方法

利用模块 cache 篡改模块对象属性

这是我最早使用的方法,在模块 a 的类型是 object 的时候,可以在自己的项目 c 中提早 require 模块 a,按照你的需求修改一些属性,这样当模块 b 再去 require 模块 a 时,从缓存中取出的模块 a 已经是被修改过的了。

模块 a、b、c 栗子如下:

// a.jsmodule.exports = {  p}// b.jsconst a = require(a);a.p();// c.jsrequire(b);

我想修改 a 的方法 p,在 c 中进行如下修改即可,而无需直接去修改工具 a、b 的源码:

// c.jsconst 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.jsconst aOld = require(a); let aId = require.resolve(aPath);require.cache[aId] = function(...args){   injectSomething();   aOld.apply(this, args);}require(b);

缺陷:可能后续调用链路会有人手动去修改 require.cache,例如热加载。

修改 require

这种方法是直接去代理 require ,是最稳妥的方法,但是侵入性相对来说比较强。Node.js 文件中的 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] === 'a') { // 只修改a模块内容        injectSomething();    }    return res;}

缺陷:对整个 Node.js 进程的 require 操作都具有侵入性。

相关原理

node的启动过程

我们先来看看在运行 node a.js 时发生些什么?node源码

上图是node运行 a.js 的一个核心流程,Node.js 的启动程序 bootstrap_node.js 是在 node::LoadEnvironment 中被立即执行的,bootstrap_node.js 中的 startup() 是包裹在一个匿名函数里面的,所以在一次执行 node 的行为中 startup() 只会被调用了一次,来保证 bootstrap_node.js 的所执行的所有依赖只会被加载一次。C++ 语言部分中:

//node_main.cc 如果在win环境执行wmain(),unix则执行main(),函数最后都执行了node::Start(argc, argv)  #ifdef _WIN32  int wmain()#else  int main()#endif

//node::Start(argc, argv) 提供载入 Node.js 进程的 V8 环境Environment::AsyncCallbackScope callback_scope(&env);LoadEnvironment(&env);

//node::LoadEnvironment(Environment* env) 加载 Node.js 环境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.jsconst Module = NativeModule.require('module');……run(Module.runMain);// Module.jsModule.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]; // get cache  if (cachedModule) {    return cachedModule.exports;   }  ……  var module = new Module(filename, parent);  ……  Module._cache[filename] = module; // set cache  tryModuleLoad(module, filename);  return module.exports;};

可以看到,在 load 的一个模块时,会先读缓存 Module._cache,如果没有就会去 new 一个 Module 的实例,
然后再把实例放到缓存里。由前面的 Node.js 启动过程可以知道, bootstrap_node.js 中的 startup() 只会执行了一次,其中产生的 Module 对象在整个node进程调用链路中只会存在一个,进而 Module._cache 只有一个。

Module._cache 和 require.cache 的关系

可以看下 Module.prototype._compile 这个方法,这里面会对大家写的 Node.js 文件进行一个包装,注入一些上下文,包括 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.js 进程里都是有效的。

require 不同场景的挂载

最开始我以为 require 是挂载在 global 上的,为了图省事,一般用 Node.js repl 来测试:

$ node> global.require{ [Function: require]  resolve: [Function: resolve],  main: undefined,  extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },  cache: {} }

可以看到,repl 下,global.require 是存在的,如果以为可以直接在 Node.js 文件中代理 global.require 那就踩坑了,因为如果在 Node.js 文件中使用会发现:

console.log(global.require);// undefined

从上文可知,Node.js 文件中的 require 其实是来自于 Module.prototype._compile 中注入的 Module.prototype.require, 而最终的指向其实是 Module._load,并没有挂载到 module 上下文环境中的 global 对象上。

而 repl 中也是有 module 实例,于是我尝试在 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 来解析路径,解析后才是 cache 中正确的 key 格式。

下面对比下区别:

// 模块的绝对路径/Users/kino/.def/def_modules/.builders/@ali/builder-cake-kpm/node_modules/@ali/builder-cake-kpm/node_modules/@ali/cake-webpack-config/index.js

// 用 require.resolve 转义后的结果/Users/kino/.def/def_modules/.builders/@ali/builder-cake-kpm/node_modules/.0.16.23@@ali/cake-webpack-config/index.js

多进程的情况

模块间调用的链路比较长,有可能会新建子进程,需要考虑你项目中的入口文件和你需要代理的文件是否在一个进程中,简单的方法就是在入口文件和你需要代理的文件打印 pid:

console.log(process.pid)

如果一致,那么直接在入口调用前代理即可,否则情况会更复杂点,需要找到相应的进程调用处进行代理。

案例

DEF 是淘宝前端的集成开发环境,支持前端模块创建、构建打包、发布等一系列流程。 在以下案例中,主要 hack 的 Node.js 项目便是 DEF。

篡改输入(prompt)

场景:使用 DEF 创建模块 or 发布模块时

原因:想一键完成批量创建 or 批量发布,不想手动输入。

解决过程:以创建模块为例

  • 首先找到 DEF 的入口文件,即一个 bin 目录下的路径,可以通过这个入口文件不断追溯下去,发现创建模块的 generator 用的是 yeoman-generator 的方法。对 prompt 的方法进行代理,可以将该基础库提前 require,更改掉其 prompt 的方法即可。
  • 附上示例代码(示例只篡改 def add 模块的创建类型,其他输入的篡改方法类似):
#!/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] = 'kissy-pc'; // 让模块类型输入自动为pc  callback(prop);};

//require real def pathconst defPath = which('def').stdout;require(defPath);

篡改构建流程(webpackconfig)

场景:一个淘宝的前端组件,需要在使用def本地调试时提前更改一个文件内容。(淘宝组件的构建会按照组件类型统一构建器,并不是每个组件单独去配置)

原因:一般来说,这种情况可以选择注释代码大法,本地调试时打开注释,发布前干掉。但这样造成代码很不美观,也容易引起误操作。不妨在本地调试的 reflect 过程中动态更换掉就好了。

解决过程:

  • 追溯 def dev 调用链路,找到最终reflect的文件, 在这个构建器 @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 pathconst defPath = which('def').stdout;require(defPath);

结束语

去 hack 一个 Node.js 模块,需要对该 Node.js 模块的调用链路有一定的了解,在很多情况下,不一定是最优的方法,但也不失为一种解决方案。有趣的是,Node.js 源码中其实有一行这样的注释:

// Hello, and welcome to hacking node.js!// some descriptions

So, just hacking for fun!

作者:宣予

转载自:http://taobaofed.org/blog/2016/10/27/how-to-hack-nodejs-modules/

时间: 2024-10-20 10:09:28

如何 hack Node.js 模块?的相关文章

Node.js模块封装及使用

 Node.js中也有一些功能的封装,类似C#的类库,封装成模块这样方便使用,安装之后用require()就能引入调用. 一.Node.js模块封装  1.创建一个名为censorify的文件夹  2.在censorify下创建3个文件censortext.js.package.json.README.md文件     1).在censortext.js下输入一个过滤特定单词并用星号代替的函数. var censoredWorlds=["sad","bad",&qu

NW.js —— 在浏览器端调用 Node.js 模块

NW.js 详细介绍 NW.js 可以让你直接在 DOM 上调用所有 Node.js 模块,相当于使用一种新的方法来编写 Web 应用.NW.js 的前身是 node-webkit . 特性: 使用 HTML5.CSS3.JS 和 WebGL 编写应用 完全支持 Node.js APIs 以及其 第三方模块 性能表现良好,Node 和 WebKit 运行在同一个线程,函数调用更直接,对象在同一个内存堆中,可直接引用 方便打包和分发 支持跨平台 演示程序:https://github.com/zc

学习Node.js模块机制_node.js

一.CommonJS的模块规范 Node与浏览器以及 W3C组织.CommonJS组织.ECMAScript之间的关系 Node借鉴CommonJS的Modules规范实现了一套模块系统,所以先来看看CommonJS的模块规范. CommonJS对模块的定义十分简单,主要分为模块引用.模块定义和模块标识3个部分. 1. 模块引用 模块引用的示例代码如下: var math = require('math'); 在CommonJS规范中,存在require()方法,这个方法接受模块标识,以此引入一

快速掌握Node.js模块封装及使用_node.js

Node.js中也有一些功能的封装,类似C#的类库,封装成模块这样方便使用,安装之后用require()就能引入调用. 一.Node.js模块封装  1.创建一个名为censorify的文件夹  2.在censorify下创建3个文件censortext.js.package.json.README.md文件 1).在censortext.js下输入一个过滤特定单词并用星号代替的函数. var censoredWorlds=["sad","bad","ma

Node.js模块封装及使用方法_node.js

Node.js中也有一些功能的封装,类似C#的类库,封装成模块这样方便使用,安装之后用require()就能引入调用. 一.Node.js模块封装  1.创建一个名为censorify的文件夹  2.在censorify下创建3个文件censortext.js.package.json.README.md文件 1).在censortext.js下输入一个过滤特定单词并用星号代替的函数. var censoredWorlds=["sad","bad","ma

跟我学Nodejs(三)--- Node.js模块_javascript技巧

简介及资料     通过Node.js的官方API可以看到Node.js本身提供了很多核心模块 http://nodejs.org/api/ ,这些核心模块被编译成二进制文件,可以require('模块名')去获取:核心模块具有最高的加载优先级(有模块与核心模块同名时会体现)     (本次主要说自定义模块)     Node.js还有一类模块为文件模块,可以是JavaScript代码文件(.js作为文件后缀).也可以是JSON格式文本文件(.json作为文件后缀).还可以是编辑过的C/C++文

学习Node.js模块机制的笔记

一.CommonJS的模块规范 Node与浏览器以及 W3C组织.CommonJS组织.ECMAScript之间的关系 Node借鉴CommonJS的Modules规范实现了一套模块系统,所以先来看看CommonJS的模块规范. CommonJS对模块的定义十分简单,主要分为模块引用.模块定义和模块标识3个部分. 1. 模块引用 模块引用的示例代码如下: var math = require('math'); 在CommonJS规范中,存在require()方法,这个方法接受模块标识,以此引入一

详解Node.js模块间共享数据库连接的方法_基础知识

这个标题本身就是一个命题,因为使用默认方式的情况下,一个 Node.js 应用里的各个模块都是共享的同一个数据库连接.但是如果姿势不对,可能会很丑陋,甚至可能会出错. 你可以忽略下面这部分,直接切入正题. 背景最近在做专业课程设计,题目是"机票预订管理系统".需求比较简单,就试着拿最近在学的 Node.js 来做了.本来还在调研用何种 Node.js 框架比较合适,看了几个框架之后发现这是杀鸡用牛刀,有看文档查资料的时间还不如直接动手写了.最后写完我会把代码放到 Github 上,欢迎

在Windows上安装Node.js模块的方法_javascript技巧

不过有消息称Microsoft已经联系Node.js官方,相信很快会有改善. 那么在不安装Cygwin的情况下,是否可以在Windows上搭建Node.js环境进行试验开发哪?我以Node.js + express做了个简单测试,基本可行. 步骤如下: 1. 下载Node.js官方非稳定版Windows可执行程序: http://nodejs.org/#download 我在试验中使用了0.5.7版本:http://nodejs.org/dist/v0.5.7/node.exe 2. 创建c:\