Node.js 中妙用 Module.prototype._compile 函数

本文深入探讨了如何利用 Module.prototype._compile 实现 Node.js 热更新和动态打点。通过分析 proxy-hot-reload 和 koa-await-breakpoint 的源码,展示了如何借助 node-glob 查找文件、esprima 生成语法树、escodegen 重构代码、async_hooks 获取请求上下文以及 shimmer 包装 _compile 函数。同时,文章提到了这些技术在生产环境可能存在的性能开销和内存泄漏问题。
摘要由CSDN通过智能技术生成

在上篇文章 Node.js 模块机制及源码分析 中,通过分析 Node.js 中模块的加载源码,基本理解了 Node.js 的模块加载原理,其中 Module.prototype._compile 函数主要用于对于第三方 js 文件进行编译加载,所以我们可以巧妙的在 Module.prototype._compile 运行前后执行一些自己的代码,就能实现出意向不到的效果。

最近在看赵坤大神的 《Node.js调试指南》,其中它在讲调试工具和日志定位问题时,引用自己写的两个库: proxy-hot-reloadkoa-await-breakpoint,proxy-hot-reload 用于实现 Nodejs 的热更新,koa-await-breakpoint 用于实现在每个 await 执行的语句前后进行打点工作。这两个库都巧妙的使用了 Module.prototype._compile 函数,下面我们通过分析这两个库的源码来学习如何妙用 Module.prototype._compile 函数。

proxy-hot-reload 源码分析

使用 proxy—hot-reload 实现热加载功能很简单,只需要在文件中引入如下代码便可:

require('proxy-hot-reload')({
    includes: '**/*.js'   // includes表示需要热加载的的目录
});

我们知道 Node 模块加载是有缓存的,第一次启动加载成功后便将编译结果放入缓存中,也就是 require.cache 中,后面即使文件内容有更新也不会重新加载,除非你重启整个 Node 进程,如果你想实现对文件的热加载,必须删除 require.cache 中缓存,让其重新加载编译。而 proxy-hot-reload 便是借助 Module.prototype._compile 函数在编译时通过 Proxy 代理 exports 的属性进行拦截实现的:

module.exports = function proxyHotReload (opts) {
  // chokidar 模块用于监听指定目录文件的改动
  chokidar
    .watch(watchFiles, {
      usePolling: true
    })
    .on('change', (path) => {
      if (require.cache[path]) {
          const _exports = require.cache[path].exports
          //如果发现被更改的路径有设置缓存且是一个字面量的Object且不为空
          if (_.isPlainObject(_exports) && !_.isEmpty(_exports)) {
            //删除对应的缓存,并且使用 require 函数重新加载缓存
            //这里需要注意的是,缓存只是用于下次加载时使用,当前已经加载的无法在更改
            //如果想实现立即实现,我们就需要对Module.prototype._compile函数进行封装处理,看下面是怎么做的
            delete require.cache[path]
            require(path)
          } else {
            //该函数实现在文件更改的情况下不进行重新加载缓存的自定义操作
            watchedFileChangedButNotReloadCache(path)
          }
        }
    })
    .on('error', (error) => console.error(error))
  // shimmer.wrap 函数实现对对象的某些属性进行包装处理,更改原生的 Module.prototype._compile 函数
  shimmer.wrap(Module.prototype, '_compile', function (__compile) {
    return function proxyHotReloadCompile (content, filename) {
      //如果当前文件不属于热更新文件列表,则直接执行原生的 Module.prototype._compile 函数
      if (!_.includes(watchFiles, filename)) {
        return __compile.call(this, content, filename)
      } else {
        const result = __compile.call(this, content, filename)
        this._exports = this.exports
        // 我们在获取 exports 对象的属性时,中间加了一层代理,通过代理间接获取原有属性的值
        // 如果 require.cache 中有数据,则直接从 require.cache 中读取缓存,因为我们在更新文件的时候已经拿到了最新的数据,所以 require.cache  中的数据是最新的,
        // 可见 Proxy 可以实现属性访问拦截,也可实现断开强引用的作用
        this.exports = new Proxy(this._exports, {
            get: function (target, key, receiver) {
                if (require.cache[filename]) {
                  return require.cache[filename]._exports[key]
                } else {
                  return Reflect.get(target, key, receiver)
                }
            }
        })
        return result
      }
    }
  })
}
koa-await-breakpoint 源码分析

koa-await-breakpoint 是一个 koa2 的中间件,用于对 await 执行的表达式进行打点,记录当前 await 处于第几步,耗时多长时间,执行结果是多少,并且可以很方便找到当前请求的 ctx,用法如下:

const koaAwaitBreakpoint = require('koa-await-breakpoint')({
  name: 'api',
  files: ['./routes/*.js']
})

const Koa = require('koa')
const routes = require('./routes')
const app = new Koa()
app.use(koaAwaitBreakpoint)
routes(app)
app.listen(3000, () => {
  console.log('listening on 3000')
})

它的实现原理同样是通过对 Module.prototype._compile 函数进行包装改造,Module.prototype._compile,在模块加载的前后修改原始文件的代码,在其中加入打点代码便可实现动态自动打点。

该模块使用到了模块如下:

  • node-glob: 根据模式匹配查找文件
  • esprima:对代码进行语法分析,解析成语法树
  • escodegen:根据语法树重新生成代码
  • async_hooks:于跟踪 NodeJs 中的异步资源的生命周期
  • shimmer:wrap 对象的函数属性

下面的源码分析也是根据这几个模块展开的:

1. node-glob 模块查找需要打点的文件

node-glob 模块可以根据文件模式匹配规则找到你需要的文件:

const glob = require('glob')
let filenames = []
  //找到哪些文件需要打点
  files.forEach(filePattern => {
    if (filePattern) {
      filenames = filenames.concat(glob.sync(filePattern, opt))
    }
  })
  //将需要排序的文件删掉
  exclude_files.forEach(filePattern => {
    if (filePattern) {
      _.pullAll(filenames, glob.sync(filePattern, opt))
    }
  })
2. 使用 esprima 生成代码语法树

使用 esprima 模块对每个需要监听的文件的 content 进行语法分析,content 其实就是 Module.prototype._compile 函数的第一个参数的,对 content 调用 esprima.parse 函数得到语法树:

parsedCodes = esprima.parse(content, { loc: true });

分析 parsedCodes 找到 AwaitExpression 节点,包装每个 AwaitExpression 节点的表达式,加入打点代码:

function findAwaitAndWrapLogger (node) {
    // 如果是 AwaitExpression 节点则对该节点进行wrap
    if (node.hasOwnProperty('type') && node.type === 'AwaitExpression' && !node.__skip) {
        const codeLine = node.loc.start
        const __argument = node.argument
        const __expressionStr = escodegen.generate(__argument)
        //使用global[loggerName]对该节点的进行包装,最终变成调用global[loggerName]函数
        //所以最终的打点工作都放在 global[loggerName] 这个函数中,下面我们会分析global[loggerName]
        //这样包装生成新的 expressionStr 表达式
        const expressionStr = `
            global.${loggerName}(
              (typeof ctx !== 'undefined' ? ctx : this),
              function(){
                return ${__expressionStr}
              },
              ${JSON.stringify(__expressionStr)},
              ${JSON.stringify(filename + ':' + codeLine.line + ':' + codeLine.column)}
            )`
        //重新 parse 包装后的 expressionStr,然后将该 expressionStr 加到原始的 node 节点中
        node.argument = esprima.parse(expressionStr, { loc: true }).body[0].expression
        node.delegate = true
        node.argument.arguments[1].body.body[0].argument.__skip = true
        node.argument.arguments[1].body.body[0].argument.argument = __argument
    }
    //递归遍历每个节点,找到 AwaitExpression 进行重新编译
    for (const key in node) {
        if (node.hasOwnProperty(key)) {
          findAwaitAndWrapLogger(node[key])
        }
    }
}

3. 使用 escodegen 重新生成 code content

findAwaitAndWrapLogger 生成了新的语法树,我们可以使用 escodegen 将新的语法树转变成 code content:

const escodegen = require('escodegen');
content = escodegen.generate(parsedCodes, {
  format: { indent: { style: '  ' } },
  sourceMap: filename,
  sourceMapWithCode: true
});
4. global[loggerName] 打点记录:
//该函数会对每个 await 节点的代码进行 wrap, fn 是原先在 await 要执行的函数, ctx 是 fn 所在上下文,fnStr 是 await 后的表达式字符串
//通过该函数,在执行 fn 前我们记录一下日志,在执行 fn 后再记录一下日志,便实现了打点工作
global[loggerName] = async function (ctx, fn, fnStr, filename) {
    //这里要注意的 originalContext 是 await 节点所在的上下文, 而 ctx 是每个请求中间件里的ctx
    const originalContext = ctx
    let requestId = _getRequestId()
    
    //获取当前请求所在的上下文,关于如何使用 async_hooks 模块获取当前请求的 ctx,请看下文
    const asyncId = async_hooks.executionAsyncId()
    if (!requestId) {
      const _ctx = getCtx(asyncId)
      if (_ctx) {
        ctx = _ctx
        requestId = _getRequestId()
      }
    } else {
      asyncIdMap.set(asyncId, ctx)
    }
    
    // beforeAwait 内容打点
    let prevRecord
    if (requestId) {
      prevRecord = _logger('beforeAwait')
    }
    let result = await fn.call(originalContext)
    //afterAwait 内容打点
    if (requestId) {
      _logger('afterAwait', result, prevRecord && prevRecord.timestamp)
    }
    return result
  }
5. 巧妙使用 async_hooks 获取每个节点对应请求的 ctx

关于 async_hooks 模块如何使用,我在这篇博客 学习使用 NodeJs 中 async-hooks 模块 中有详细的介绍,我们主要看一下如何利用 async_hooks 获取每个函数对应的请求上下文:

async_hooks.createHook({
  // 每个异步调用时,建立 asyncId 和 triggerAsyncId 的映射关系
  // asyncId 是执行当前步骤的所在的 asyncId,而 triggerAsyncId 是上一个调用当前函数的 triggerAsyncId
  // 这样我们只要记录请求一进来 asyncId 和 请求 ctx 的关系,便可以很方便根据映射关系递归找到每个节点对应的请求 ctx
  init (asyncId, type, triggerAsyncId) {
    const ctx = getCtx(triggerAsyncId)
    if (ctx) {
      asyncIdMap.set(asyncId, ctx)
    } else {
      asyncIdMap.set(asyncId, triggerAsyncId)
    }
  },
  destroy (asyncId) {
    asyncIdMap.delete(asyncId)
  }
}).enable();

// 递归获取请求 ctx
function getCtx (asyncId) {
  if (!asyncId) {
    return
  }
  if (typeof asyncId === 'object' && asyncId.app) {
    return asyncId
  }
  return getCtx(asyncIdMap.get(asyncId))
}
6. 使用 shimmer 包装 Module.prototype._compile 完成最终需求
shimmer.wrap(Module.prototype, '_compile', function (__compile) {
    return function koaBreakpointCompile (content, filename) {
      //如果当前不需要打点,直接调用原生的 __compile 函数
      if (!_.includes(filenames, filename)) {
        return __compile.call(this, content, filename)
      }
      //解析语法树
      parsedCodes = esprima.parse(content, { loc: true })
      //在语法树中找到 await 节点,加入打点代码
      findAwaitAndWrapLogger(parsedCodes)
      //重新生成 content 内容
      content = escodegen.generate(parsedCodes, {
          format: { indent: { style: '  ' } },
          sourceMap: filename,
          sourceMapWithCode: true
      })
      //将新生成的content传入原生的 __compile 函数进行编译
      return __compile.call(this, content.code, filename)
})

最后,写这篇文章我只是想借用 proxy-hot-reload 和 koa-await-breakpoint 两个模块来了解一下 Module.prototype._compile 的原理,以及如何去 wrap 这个函数,对于这两个模块的使用,会有性能开销和内存泄漏:

  • 第一个包 delete require.cache 依旧会有内存泄漏的问题,只能在开发环境下使用(话说很多框架都实现了文件变动热重启,也不需要这样的热更新方案)

  • async/await 在目前的机制下有性能损耗的问题(而且是不能忽视的性能损耗),用于大流量的线上服务器一样得关注性能损耗

所以建议不要在生产环境使用可以作为本地调试工具或者学习 Node 模块机制使用

参考文献
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值