|

NodeJs中实现require拦截的几种方法

最近的几个开发中,需要对 require 进行拦截,阻止不符合规范的 require ,比如禁止引用一些指定的模块,进制引用某个目录下的模块等,实现 require 的拦截,大体上有三种方法,下面一一介绍。

拦截模块引用

在一些特殊需求的场景下,需要对 require 的模块进行记录或者拦截,场景之一就是进制引用一些指定的模块,或者进制引用某个目录下的模块,这时就需要对 require 进行监听和拦截,以便能及时发现这些非法引用。

拦截 require 首先能想到的就是重写 require 方法,但重写 require 方法只能在被重新的文件内有效,无法在项目内生效,即便如此,对于拦截 require ,三种方案如下:

  • 重写 require 方法;
  • 使用 vm 加载模块的同时注入重写的 require 方法;
  • 使用 Module ,重写 _load 方法;

其中直接重写 require 有局限性,使用 vm 可以解决重写 require 的局限性,但在文件模块较多时会出现性能问题,重写 model._load 方案最为合理,下面一一介绍并实现。

重写 require 方法

这是最容易想到的一种防范,假如有一个 math.js 模块如下:

exports.sum = (nums = []) => {
  return nums.reduce((prev, curt) => {
    return prev + curt;
  }, 0);
}

另一个文件中 index.js 中引用该模块,并计算给定值:

const { sum } = require('./math');

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(sum(numbers));

显而易见的,上面的代码会输出 55 ,这是一个求个函数。

那么如果要在 index.js 中拦截 require ,我们通过重新 require 便可以实现:

// 保存原 require 方法,并重新定义 require 方法
const modelRequire = require;
require = (...args) => {
  // 拦截参数
  console.log('params is: ', args);
  return modelRequire(...args);
}

const { sum } = require('./math');

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(sum(numbers));

执行上面的代码会输出:

params is:  [ './math' ]
55

上面的代码通过重写 require 实现了引入拦截,但是局限性却很大,假如 math.js 中又 require 了其他模块,那么重写在 index.js 中的 require 将无能为力。

使用 vm 加载模块

使用 vm 模块可以解决上面的局限性,其思路是将入口文件以外的其他所有文件,都放到虚拟环境中执行,在虚拟环境中注入重写的 require 方法,这样便可在后续的模块引用中实现拦截。

function requireVmModule(fileContext) {
  const injectModule = { module };
  const sandbox = vm.createContext(injectModule);
  return vm.runInContext(fileContext, sandbox);
}

/**
 * 以VM的方式加载模块
 * @param {String} modelName 模块名
 */
function loadModule(modelName) {
  // 依赖,直接 require 
  if (!/\.js/.test(modelName) && !/^\.\//.test(modelName)) {
    return requireVmModule(modelName);
  }
  const relativeModuleName = path.resolve(__dirname, modelName);
  const moduleJsFile = relativeModuleName + '.js';
  if (fs.existsSync(moduleJsFile)) {
    return requireVmModule(fs.readFileSync(moduleJsFile));
  }
  if (fs.existsSync(relativeModuleName)) {
    return requireVmModule(fs.readFileSync(relativeModuleName));
  }
  const moduleIndexFile = path.join(relativeModuleName, 'index.js');
  if (fs.existsSync(moduleIndexFile)) {
    return requireVmModule(fs.readFileSync(moduleIndexFile));
  }
  throw new Error(`Module{modelName} not found`);
}

在项目中尝试使用 loadModule 代替 require :

// 重写当前的 require
require = loadModule;

const { sum, getTypeOf } = require('./math.js');
console.log(sum([1, 2, 3]));
// 输出 6

由此可见使用 vm 的方式也可以实现文件的加载拦截,其实现方式也是重写 require ,只不过使用的是 vm 模块来载入程序。

处理递归引入

上面的代码并未处理递归引入的问题,即当子模块内部继续引入其他模块时,我们也需要对子模块的引入进行拦截。也很简单,由于引入模块始终会使用 require 来导入,所以我们再去监听子模块的 require 即可,在子模块执行 require 的时候继续创建虚拟环境,注入重写的 require 方法即可。

修改 requireVmModule 方法如下:

function requireVmModule(fileContext) {
  const injectModule = {
    module,
    // 重写 require
    require(moduleName) {
      return loadModule(moduleName);
    }
  };
  const sandbox = vm.createContext(injectModule);
  return vm.runInContext(fileContext, sandbox);
}

上面的代码中,当被引入的模块中继续引入了其他模块时,其内部就会调用我们注入的 require 方法,这个方法内部又继续调用了 loadModule 方法,所以便实现了递归引入拦截。

重写 model._load 方法

如果使用 Module 的 _load 方法来实现,那么则更简单且更可靠。

const { Module } = require('module');

const loadBackend = Module._load;
Module._load = (request, parent, isMain) => {
  // 处理 require 拦截
  console.log(request, parent, isMain);
  return loadBackend(request, parent, isMain);
}

const { sum } = require('./math');

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(sum(numbers));

如上代码所示,重写 _load 方法必须在 require 之前,通过重写,之后所有 require 的模块都可以被拦截到。

与使用 vm 的方式相比,这种方案效率更高,模块的加载速度更快。

虽然上面的代码实现了 require 拦截,但还不够优美,上面定义是一次性的,当重写 _load 以后,后续的所有 require 都将被拦截,如果有些部分的代码我们并不需要处理拦截规则,按照现在的方式会显得不那么方便处理。

对 _load 的改进

我们可以定义一个方法,通过闭包特性来启用 require 拦截,同时也支持关闭拦截,这样一样就可以在需要的时候开启它,在不需要的时候关闭它。

const { Module } = require('module');

/**
 * 启用 require 拦截
 * @returns 
 */
function startRequirIntercept() {
  const loadBackend = Module._load;
  Module._load = (request, parent, isMain) => {
    // 处理 require 拦截
    console.log(request);
    return loadBackend(request, parent, isMain);
  }

  return { resume() { Module._load = loadBackend } };
}

const intercept = startRequirIntercept();
const { sum } = require('./math');

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(sum(numbers));

// 恢复到初始状态
intercept.resume();

上面的代码实现了一个闭包函数,通过执行这个函数来启用拦截,闭包返回一个对象,通过对象的 resume 方法可以将 require 恢复到初始状态,这样一来就可以在需要 require 拦截的地方执行闭包函数,在拦截结束后恢复到初始状态。

小结

本文所介绍的是特殊需求的特殊处理,实际开发中很少会遇到需要拦截 require 的需求,不过借助对方案的探索和理解,可以加深对 nodejs 模块的认知,比如综上来看,使用 vm 虽然拥有更好的灵活性,但是在文件数目较多,引入较深的情况下会存在性能瓶颈,因为它需要频繁操作文件;而此时使用对 _load 重写的方式则更高效,实际开发中可酌情选择方案。

类似文章