在上篇文章 Node.js 模块机制及源码分析 中,通过分析 Node.js 中模块的加载源码,基本理解了 Node.js 的模块加载原理,其中 Module.prototype._compile 函数主要用于对于第三方 js 文件进行编译加载,所以我们可以巧妙的在 Module.prototype._compile 运行前后执行一些自己的代码,就能实现出意向不到的效果。
最近在看赵坤大神的 《Node.js调试指南》,其中它在讲调试工具和日志定位问题时,引用自己写的两个库: proxy-hot-reload 和 koa-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 模块机制使用。