# node端覆盖率统计
# server端覆盖率统计原理
如果对前端覆盖率统计还没有概念,需要先去看看[前端覆盖率统计总结](此处没有链接,没有上传)
node端覆盖率统计的最基本原理就是在node代码入口,对require做hook,返回istanbul插完桩的代码,即动态插桩;
先来剖析一下istanbul-middleware提供的hookloader是如何做的; 这里省略了一些无关的代码和扩展的功能代码
var istanbul = require('istanbul');
var Instrumenter = istanbul.Instrumenter;
var hook = istanbul.hook;
function hookLoader(matcherOrRoot, opts) {
var matcherFn,
transformer,
if (typeof matcherOrRoot === 'function') {
matcherFn = matcherOrRoot;
} else if (typeof matcherOrRoot === 'string') {
matcherFn = getRootMatcher(matcherOrRoot);
} else {
throw new Error('Argument was not a function or string');
}
instrumenter = new Instrumenter(opts);
transformer = instrumenter.instrumentSync.bind(instrumenter);
hook.hookRequire(matcherFn, transformer, {
verbose: opts.verbose
});
}
hookloader接收两个参数,一个是match,一个是options;
match函数用来匹配require的目标是否是需要插桩的目标;因为我们一旦对入口的require做了hook之后,是不希望所有文件都插桩的,一方面我们有我们的覆盖率统计目标,另一方面如果对大量的第三方代码做插桩容易引起性能问题;
这个match参数传入的如果是个函数,将通过执行函数,获取true和false来决定是否执行代码插桩; 如果传入的是字符串,hookRequire将会把require的目标和该字符串做匹配,只有匹配上的目标才会被插桩,同时istanbul-middleware默认排除了node_modules相关的目标; 这个getRootMatcher函数如下(我们完全可以自定义改写):
function getRootMatcher(root) {
return function (file) {
if (file.indexOf(root) !== 0) { return false; }
file = file.substring(root.length);
if (file.indexOf('node_modules') >= 0) { return false; }
return true;
};
}
有了匹配函数以后剩下的就是插桩了,istanbul-middleware调用的是istanbul暴露的的hookRequire方法,该方法接受了上述的match匹配规则和transformer插桩函数在内部调用,先执行match,一旦符合规则,就调用transformer;
而istanbul-middleware提供的tranformer还是istanbul暴露的instrumentSync方法,即instrument的同步方法,为什么采用同步方法其实原因很清晰,因为commonjs模块加载require是同步操作,hookRequire不会也不允许返回一个异步结果,异步转换在内部将会报错(见下面hookRequire代码片段里的注释)
到这里其实原理很清晰了
# 实践中遇到的问题
- 基于egg框架定制的上层框架kapp的入口require在哪里?
- istanbul的instrumentSync无法对ES6代码进行兼容插桩
# 问题一: 基于egg框架定制的上层框架的入口require在哪里?
我们的目标是对配置文件,中间件,插件,controller, service, extend扩展等目标进行插桩,但是仔细一想,这些文件都是在egg-core的Agent类的实例化过程中分阶段加载的;这些地方我们不太方便执行hookloader;
再整理一下egg启动流程: egg-script => app.framework.startCulter => egg-cluster => egg-core
会发现我们可以在app指定startCluster为egg-cluster时,执行我们的hookloader, 这里也是我们的app最先执行的语句;这个节点会有比较基础但是却有难以解决的问题,比如这个节点无法拿到env, 进而无法根据env启用hookloader
如果觉得这个节点太早了话,egg Agent类其实还提供了几个生命周期钩子可以考虑(找准时序节点很重要),详见egg官网开发文档
# 问题二: istanbul的instrumentSync无法对ES6代码进行兼容插桩
要解决这个问题思路很简单,先将es6代码经过babel.transform转换后,再传递通过hookloader;但是这样会丢失行信息,也就是说,原来的第N行ES6代码,经过babel.transform后,可能变成了第N+M行,再经过hookloader统计出来的数据后,会变成N+M行被覆盖,而非原ES6代码的行数;
要进一步解决这个问题,其实思路来源于浏览器端代码统计的解决方案,即babel-plugin-istanbul; 简单的说,就是在进行babel.transform的同时,将babel-plugin-istanbul作为插件传递给babel, 这个插件会将行的映射关系维护起来;
所以我们要做的hookloader还是不变, 只不过换一个transformer
原transformer: istanbul.instrumentSync(sourceCode, filename, sourceMap)
兼容ES6插桩的transformer:
const babelPluginIstanbul = require('babel-plugin-istanbul');
const getTransformer = () => {
return (sourceCode, filename, sourceMap) => {
return babel.transform(sourceCode, {
filename,
plugins: [
[babelPluginIstanbul.default, {
inputSourceMap: sourceMap
}]
]
}).code
}
}
const transformer = getTransformer();
其实仔细一想,插桩的实现在浏览器端和node端原理是一样的,babel-loader底层掉哟过的也就是这些方法,只不过node端是换成了node端的写法;
# 思考问题
Q: istanbul的hookRequire是如何做到对require做改造的
A: 要做到这一点,首先要对CommonJs的require有一定的了解,抛开require的其他实现机制,单单讲一下require的加载原理就能很好的理解这个问题;
要知道CommonJs的模块加载原理实际上都是通过闭包实现的; 当我们require加载一个模块的时候实际上是执行了这个闭包,然后才能得到这个模块的module.exports;
(function(exports,require,Module,__filename,__dirname){
module.exports = exports = this = {}
//文件中的所有代码
return module.exports
})
除此之外,Module上还维护了一个加载策略Module._extensions;它的作用就是用来处理加载不同类型模块的时候做对应的处理;
比如说:
- 加载.js的javascript脚本,则读入内存然后执行
- 加载.json的JSON文件,则通过fs模块读入内存,转化成JSON对象
- 加载.node文件可以直接使用等等
这个加载策略维护如下:
Module._extensions = {
'.js': function(module){
//每个文件的加载逻辑不一样
},
'.json': function(module){
},
'.node': 'xxx',
}
讲到这里其实很清楚了,istanbul的hookRequire就是改写了Module._extensions的.js后缀的加载策略
function hookRequire(matcher, transformer, options) {
var fn = transformFn(matcher, transformer, options.verbose),
Module._extensions['.js'] = function (module, filename) {
//此处如果transformFn采用异步的话,ret将会是Promise, 在调用module._compile时将会报错
var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
if (ret.changed) {
module._compile(ret.code, filename);
} else {
originalLoaders[ext](module, filename);
}
};
}