DNS

DNS作为用来映射域名和IP地址的分布式数据库,使用TCP/UDP端口号53。其中每一级域名限制63个字符,总长度不超过253个字符。

DNS系统中,常见的资源记录类型有:

  • A(Address)记录:最重要的记录,用于将特定主机名映射到对应主机的IPv4地址上
  • MX记录:用于将特定邮箱地址映射到对应邮箱服务器上
  • CNAME(Canonical Name Record)别名记录:用于将某个别名指向某个A记录上
  • AAAA记录:和A记录对应,用于将特定主机名映射到对应主机的IPv6地址上

实现上,全球范围近1000台根域名服务器分为13组,编号A到M,剩下的Internet DNS命名空间被委托给其他DNS服务器。DNS系统也有各种各样的DNS软件所支持,其中最普遍的是BIND(Berkeley Internet Name Domain)。在查询时,有两种实现方式:递归迭代,客户端使用递归,DNS服务器间使用迭代。如查询shenlvmeng.github.io(忽略本地host和DNS缓存和路由器DNS缓存):

  • 客户端发送查询报文query shenlvmeng.github.io到边缘DNS服务器(一般是ISP的DNS服务器),DNS先检查缓存,若存在记录则直接返回结果
  • 若不存在或记录已过期,则:
    • DNS服务器向根域名服务器发送同样的查询报文,根服务器返回顶级域.io的权威域名服务器地址
    • DNS服务器向.io域的权威域名服务器发送查询报文,得到二级域.github.io的权威域名服务器地址
    • DNS服务器向.github.io域的权威域名服务器发送查询报文,得到主机shenlvmeng的A记录,存入自身缓存,设置TTL,返回给客户端

最初的DNS域名使用字符仅限于ASCII字符的子集,2008年后,ICANN通过决议,可以使用其他语言作为顶级域名的字符,如“xxx.中国”。使用punnycode码的IDNA系统,可以将unicode字符映射为有效的DNS字符集,有效避免IDN欺骗(即使用长得很像的不同字符作为钓鱼网站)。

域名的所有者和IP也可以通过查找WHOIS域名数据库查询。对于大多数根域名,由ICANN维护,WHOIS的细节由控制那个域的域注册机构维护。

域名污染

指一些刻意制造或无意制造的域名服务器数据包,指向错误的IP地址。这种错误有可能是域名服务器错误工作带来,也有可能是刻意为之。

对于GFW来说,它会对所有经过它的在UDP端口53上的域名查询进行IDS入侵检测,一旦发现与黑名单关键词相匹配的域名查询请求,会伪装成目标域名的解析服务器返回虚假的查询结果。

  • 系统默认会从使用ISP提供的域名查询服务器去查询国外的权威服务器时,便被GFW污染,缓存虚假的IP地址。
  • 由于TCP连接的机制可靠,理论上无法对TCP协议的域名查询进行污染,理论上可以通过TCP协议查询真实的IP地址。但其实,对于真实的IP地址,会有其他方式封锁,或对查询行为使用连接重置进行拦截
  • 通常情况下,设置的NDS服务主要使用海外的DNS服务,所以都需要穿过GFW,不过一些小型的DNS有技术手段回避GFW污染,从而能够访问国外被封锁网站

ISP域名劫持还包含一些互联网提供商劫持部分域名,转到自己制定的网站,已提供自己的广告。

DNS记录

在DNS的分布式数据库中,不同记录类型有不同的用途。下面介绍了一些常见的记录类型。

  • A记录,传回一个32位的IPv4地址,映射主机名到IP
  • AAAA记录,传回一个128位的IPv6地址,映射主机名到IP
  • CNAME:一个主机名的别名,只能指向一个域名,不能指向IP地址,可以保证原域名地址映射IP地址修改时,别名也能同步修改。CNAME意为真实名称,所以应当读作alias.com的“CNAME“是real.com。为保证效率,应当避免CNAME指向其他CNAME。
  • DNAME,和CNAME类似,不过不是映射域名,而是把域名下的整个解析子树映射到另一域名。如把alias.comDNAME到real.com后,不影响alias.com的原有的解析设置。而xxx.alias.com都会被映射到xxx.real.com
  • MX(邮件交换)记录,将邮箱后缀(@后的部分)映射到类型为A或者AAAA的地址记录,原则上禁止映射到CNAME上
  • NS记录,委托DNS区域使用已提供的权威域名服务器
  • SRV记录,进行服务定位

所有的记录都有一个有效期(TTL,time-to-live),时间耗尽后,所包含的信息必须从权威服务器上得到更新。

useStateuseEffect实现思路

这两个React hooks中引入的特性背后是基于闭包 + 数组索引实现的,下面是一个实现的demo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// useState
import React from "react";
import { render } from "react-dom";

const values: any[] = [];
let cursor = 0;

function useState<T>(initialState: T): [T, (newState: T) => void] {
const currentCursor = cursor;
values[currentCursor] = values[currentCursor] || initialState;
const setFunc = (newState: T) => {
values[currentCursor] = newState;
// 触发重新渲染
renderApp();
}
cursor++;
return [values[currentCursor], setFunc];

}

const App: React.FC<{}> = () => {
const [num, setNum] = useState<number>(0);
return (
<div onClick={() => setNum(num + 1)}>{num}</div>
);
}

function renderApp() {
render(<App />, document.getElementById("root"));
// 重置计数器
cursor = 0;
}

renderApp();

// useEffect
import React from "react";
import { render } from "react-dom";

const deps: any[][] = [];
let cursor = 0;

function useEffect(cb: () => void, dep: any[]) {
const currCursor = cursor;
if (!deps[currCursor]) {
deps[currCursor] = dep;
cursor++;
cb();
return;
}

dep.some((d, index) => {
if (d !== deps[currCursor][index]) {
deps[currCursor][index] = d;
cursor++;
cb();
return true;
}
return false;
});
}

const App: React.FC<{}> = () => {
useEffect(() => {
setTimeout(() => console.log('ok'), 1000);
}, []);

return (
<div>ok</div>
);
}

function renderApp() {
render(<App />, document.getElementById("root"));
cursor = 0;
}

renderApp();

实际使用中,是把useStateuseEffect这样的hooks放在memorizedState数组中,共用一个cursor。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let memoizedState = [];
let cursor = 0; // 当前下标

function useState(initialValue) {
memoizedState[cursor] = memoizedState[cursor] || initialValue;
const currentCursor = cursor;
function setState(newState) {
memoizedState[currentCursor] = newState;
render();
}
return [memoizedState[cursor++], setState];
}

function useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = memoizedState[cursor];
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
memoizedState[cursor] = depArray;
}
cursor++;
}

其中memorized数组也是hooks一定要在top level调用的原因。

在React实际实现上,hooks是以链表的形式存储,通过next属性链接到下一个hook。同时每一个memorized数组都会绑定到一个fiber上,从而在再次渲染时更新对应节点。让hooks之间互不干扰。

proxy简单了解与应用

Vue 3.0在重构后使用了ES6的proxy新特性来跟踪数据字段的更新。它可以封装一个目标对象,为之添加一个代理,返回一个Proxy对象。

1
new Proxy(target, handler)

其中handler即代理配置对象,也是Proxy对象的“魔力”所在。对于一个空的handler,返回的Proxy近似于target本身。在handler上定义任何handler函数的集合,都会让返回Proxy对象有不同的表现。

handler有下面一些可选的属性:

  • apply 监听函数调用的钩子
    • apply: function(target, thisArg, argumentsList) {}
  • construct 监听使用new调用的钩子
    • construct: function(target, argumentsList, newTarget) {}
  • defineProperty 类似Object.defineProperty
    • defineProperty: function(target, property, descriptor) {}
  • get 监听属性访问
    • get: function(target, property, receiver) {}
  • deleteProperty 监听delete操作
    • deleteProperty: function(target, property) {}
  • getOwnPropertyDescriptor 监听Object.getOwnPropertyDescriptor
  • getPrototypeOf() 类似Object.getPrototypeOf
  • has 监听in操作
    • has: function(target, prop) {}
  • isExtensible 监听Object.isExtensible
  • ownKeys 监听Object.getOwnPropertyNamesObject.getOwnPropertySymbols
  • preventExtensions 监听Object.preventExtensions
  • set 监听属性设置
    • set: function(target, property, value, receiver) {}
  • setPrototypeOf 类似Object.setPrototypeOf

利用上面的handler已经可以实现很丰富的功能,immer的produce就有借助proxy来实现。

背景

旧的覆盖率方案实现了覆盖率采集的可行性探索。但是旧方案中也存在着侵入较多,适用场景有局限性,istanbul版本老旧,sourcemap支持不足等问题需要优化。有关覆盖率采集、维护、呈现、上报需要一个更通用和更具可拓展性的方案。

因此,在对整个覆盖率要解决的问题和目标进行抽象后,可以得到以下的实现方案。

整体结构

整体结构

新的覆盖率方案可以分为3层:

  • 采集层,这一层主要解决对代码进行插桩(instrument)的过程,只有插桩过的代码才能在执行的过程中不断积累覆盖率数据,从而通过端到端测试得到代码的覆盖率情况
  • 数据层,采集到的覆盖率数据有上报的需求,浏览器端数据只有上报到服务端才能查看和操作,如果接入第三方平台,就更需要有专门数据处理和上报,这一层正是完成这部分工作
  • 视图层,方案要自带视图化能力,给开发者更直观的反馈,同时要有可交互性,满足开发者的日常需求。这一层要能比较方便地绑定到流行的服务端环境中,减少侵入成本

整体实现上,基于新版本istanbuljs。下面分层介绍各层实现中需要进行的工作。

采集层

代码插桩实现中,拆分为下面几点:

babel preset

对于使用babel转码的工程来说,babel-plugin-istanbul直接提供了一个可以插桩代码的插件。这里要做的只是区分线上和其他环境,以及预定义preset,减少用户babel配置成本。

babel preset的书写可以参考babel preset一节,我们只需将原先.babelrcbabel.config.js中的配置项用require包裹即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = function () {
return {
env: {
development: {
plugins: [
[require('babel-plugin-istanbul')]
]
},
boe: {
plugins: [
[require('babel-plugin-istanbul')]
]
}
}
};
};

相对应的,接入方需要额外在scm_build.sh脚本中额外传入boe环境变量来区分boe和线上环境:

1
2
3
4
5
if [ $BUILD_TYPE = "offline" ]; then
BABEL_ENV=boe yarn build
else
yarn build
fi

同时,默认情况下,istanbul只会对后缀为['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']的文件进行处理,为了处理.vue文件,需要显式给babel-plugin-istanbul文件传入extension字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = function () {
return {
env: {
development: {
plugins: [
[require('babel-plugin-istanbul'), { extension: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue'] }]
]
},
boe: {
plugins: [
[require('babel-plugin-istanbul'), { extension: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue'] }]
]
}
}
};
};

hookRequire动态插桩

有些场景下并不使用babel进行转码,这里以最典型的TypeScript为例,.ts文件可以用tsc编译。tsc编译后的js代码只有插桩后,才可能采集到的覆盖率数据,因此接入方需要在所有业务代码的import之前调用hook,保证所有的服务端js代码在被执行前都完成了插桩。

实现上,新版本istanbuljs整个是个monorepo,原来istanbul各模块的功能拆分到了packages的各子项目中。其中和hook相关的位于istanbul-lib-hook。这个库虽然并没有API和文档。但还好,和老版本的hook.js区别不大。使用暴露出来的hookRequire方法即可,使用方式也与之前无异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// old
import * as istanbul from 'istanbul';
const { hook } = istanbul;

instrumenter = new Instrumenter(options);
const transformer = instrumenter.instrumentSync.bind(instrumenter);
hook.hookRequire(matcher, transformer, {
postLoadHook: (file: any) => {
// ...
}
});

// new
import { hookRequire } from 'istanbul-lib-hook';
import { createInstrumenter } from 'istanbul-lib-instrument';

instrumenter = createInstrumenter(options);
const transformer = instrumenter.instrumentSync.bind(instrumenter);
hookRequire(matcher, transformer, {
postLoadHook: (file: any) => {
// ...
}
});

hookRequire方法接受3个参数:

  • matcher,来自用户输入,用来判断当前文件是否要被hook
  • transformer,最关键的代码转换函数,读入代码,返回转换后的代码,插桩的转换由istanbul-lib-instrument提供(原先的instrumenter.js),理论上也能支持其他的转换函数。这里使用instrumentSync这个同步转换。
  • options,支持verbose和postLoadHook两个选项
    • verbose,boolean,是否打印详细日志
    • postLoadHook,function,成功回调,函数会传入完整的文件名

sourcemap处理

由于istanbuljs插桩的是编译后的js代码,需要借助sourcemap才能找到源文件的覆盖情况。编译后文件的覆盖情况对开发者没有借鉴意义。使用hookRequire动态插桩时,还是需要自己处理sourcemap。

老版本istanbul对sourcemap并不支持,需要借助remap-istanbul才能实现。新版本的istanbuljs有一个独立的包istanbul-lib-source-maps支持这方面的功能。遗憾的是,再次没有文档介绍如何使用。

通过查看测试样例和阅读源码可以发现,istanbul-lib-instrument中的instrumentSync支持第三个sourcemap参数,如果传入,则会在采集文件的覆盖率数据中附加该文件的sourcemap信息到inputSourceMap字段。而istanbul-lib-source-maps这个包可以对覆盖率数据中带有inputSourceMap字段的数据进行反映射,得到源文件覆盖情况。最终达到我们的目的。

源文件的sourcemap信息需要自己采集,这里借鉴的nyc中使用的方案:convert-source-map。这个npm包可以读取文件末尾的sourcemap字符串并转换为sourcemap对象。所以整体上,实现sourcemap处理的流程如下:

  1. 在hookRequire的transformer中拿到代码字符串
  2. 通过convert-source-map的fromSource方法拿到sourcemap对象
  3. 在调用istanbul-lib-instrument的instrumentSync方法时,传入该对象
    1. instrumenter内部的visitor.js调用source-coverage.js的inputSourceMap方法将文件的sourcemap传入
    2. source-coverage.js将sourcemap存入覆盖率数据中
  4. 使用istanbul-lib-source-maps中的transformCoverage方法转换覆盖率数据即可得到原始的覆盖率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createInstrumenter } from 'istanbul-lib-instrument';
import convert from 'convert-source-map';

instrumenter = createInstrumenter({
// ...
});
// Step 1
const transformer: Transformer = (code, options) => {
// Step 2
const sourceMap = convert.fromSource(code);
// Step 3
const transformed = instrumenter.instrumentSync(
code,
options.filename,
sourceMap ? sourceMap.toObject() : undefined
);
return transformed;
};

hook(matcher, transformer, {
//...
});

数据层

在覆盖率数据维护上,主要有下面几点工作:

sourcemap处理

在代码插桩附带上sourcemap信息后,可以参考istanbul-lib-source-maps的测试用例,用transformCoverage方法转换得到原始的覆盖率,即上一个同名小节中的第4步。

1
2
3
4
5
6
7
8
9
10
11
12
async function getOriginalCoverage() {
// 采集到的覆盖率
const currCoverage = this.getCoverage();

// ...
const coverageMap = createCoverageMap(currCoverage);
const mapStore = createSourceMapStore({});
// Step 4
const transformed = await mapStore.transformCoverage(coverageMap);
// 对应到源文件的覆盖率
return transformed.data;
}

实现时,还需要注意对一些sourcemap结构体中的sourceRoot进行特殊处理,避免反映射后得到嵌套的错误路径。

覆盖率数据维护

在一个测试覆盖率的周期下,需要merge每次产生的新的覆盖率。同时,要能通过原始的覆盖率数据生成摘要,方便UI展示。

istanbul-lib-coverage的CoverageMap类型自带的merge方法,可以merge新的覆盖率数据到源CoverageMap中。

1
2
3
4
5
6
7
8
export function mergeClientCoverage(clientCoverage: CoverageMap) {
if (!clientCoverage) {
return;
}
const currCoverage = getCoverageObject();
const coverageMap = createCoverageMap(currCoverage)
coverageMap.merge(clientCoverage)
}

基准覆盖数据维护

有些js代码在未访问到业务代码时就会被执行到,比如importexport语句。这些覆盖率如果被清空,就再也无法通过端到端测试找回来,所以需要对于这部分覆盖数据专门维护,这里称作基准覆盖数据

实现上,在hook完成后,取得hook文件的覆盖率数据,即可得到。这里需要异步执行,保证能够取到。

1
2
3
4
5
6
7
hook(matcher, transformer, {
// ...
postLoadHook: (filename: string) => {
// ...
matcher(filename) && setTimeout(() => Coverage.saveBaselineCoverage(filename));
}
});

每次reset操作,实现上并非简单的清空数据,而是将覆盖率数据置为基准覆盖数据。

1
2
3
4
5
// express
app.post('/reset', (req, res) => {
Coverage.restoreBaselineCoverage();
res.json({ code: 0 });
});

数据上报

数据上报只需实现定期上报,在上报时通过使用方预先定义好的中间件,便于使用方做预处理的工作。同时,提供一个关闭方法,可以在适当时机停止上报,形如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class CoverageCollector {
// ...
use(middleware: Middleware) {
this.middlewares.push(middleware);
}

// ...
init() {
if (!this.intervalFlag) {
this.intervalFlag = setInterval(() => this.send(), this.interval);
}
}

// ...
close() {
if (this.intervalFlag) {
clearInterval(this.intervalFlag);
this.intervalFlag = null;
}
}

private async send() {
// ...
const coverageData = this.middlewares.reduce((data, middle) => middle(data), coverage);
// ...
try {
await axios.post(this.url, coverageData);
} catch (err) {
this.log('Send coverage data failed: ', err);
}
}
}

视图层

视图化和可操作性上,有下面几点工作:

路由提供

中间件对于开发者的受益,体现在能够通过浏览器访问得到视图化和可操作性的覆盖率数据。在功能上,中间件提供5个路由:

  • /,GET,提供整个项目的覆盖率摘要页面
  • /show,GET,访问文件或文件夹时,提供文件覆盖率详情或文件夹覆盖率摘要
  • /reset,POST,重置当前项目的覆盖率,开始一次新的采集周期
  • /object,GET,返回覆盖率的原始数据的JSON格式
  • /client,POST,用来接收来自浏览器端的覆盖率数据,merge到整体的覆盖率数据中

实现上,直接提供预定义好的app或express应用,npm包暴露一个attachHandler API,使用方直接挂载即可拥有上述功能。类似下面所示。

1
app.use(mount('/coverage', attachHandler.koa()));

由于绑定一定发生在服务端环境,可以的话,需要顺便区分线上和其他环境。

报告html页面生成

开发者应该能在每个测试周期内(即请求/reset前),通过访问特定路由得到渲染好的html页面。istanbuljs通过istanbul-lib-report和istanbul-reports两个包相互配合实现导出report的功能。其中:

  • istanbul-lib-report:重点负责构建文件层级结构和生成摘要数据,定义遍历每个节点时行为的几个抽象类
  • istanbul-reports:重点实现各种各样导出格式下内容的生成(只考虑了生成静态文件),通过实现istanbul-lib-report中ReportBase基类,完成遍历中内容的生成

下面是README.md给出的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');

// coverageMap, for instance, obtained from istanbul-lib-coverage
const coverageMap;

const configWatermarks = {
statements: [50, 80],
functions: [50, 80],
branches: [50, 80],
lines: [50, 80]
};

// create a context for report generation
const context = libReport.createContext({
dir: 'report/output/dir',
// The summarizer to default to (may be overridden by some reports)
// values can be nested/flat/pkg. Defaults to 'pkg'
defaultSummarizer: 'nested',
watermarks: configWatermarks,
coverageMap,
})

// create an instance of the relevant report class, passing the
// report name e.g. json/html/html-spa/text
const report = reports.create('json', {
skipEmpty: configSkipEmpty,
skipFull: configSkipFull
})

// call execute to synchronously create and write the report to disk
report.execute(context)

在istanbul-reports/lib/html中主要定义了html相关内容的生成,但是其中对于fs模块的使用,限制了该npm包在server侧的应用。经过询问作者得到,istanbuljs并没有想法支持动态生成html响应。另外,istanbul-lib-report和istanbul-reports关联较多,新开发一个report类型用于生成html响应成本较大。

综上,使用旧的方案,即旧版本istanbul生成html响应比较合适。

新方案

上述工作完成后,最终可以实现一个新的覆盖率方案,基本解决了上面提到的侵入较多,适用场景有局限性,istanbul版本老旧,sourcemap支持不足等问题。

已知问题

上面的改进方案解决了方案接入上的困难,和实现上的一些纰漏,满足了开发者自测时的需求。但同时也有以下不足:

  • 对于更复杂的数据呈现和与业务流程的整合,即旧方案已知问题的第4点:“不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看覆盖率报告,也没有diff内容覆盖率功能”,还需要更多努力。
  • 对于一些特殊场景,如没有使用babel转码的纯前端,不使用express或koa框架的服务端等无法支持

参考

istanbul介绍

intanbuljs是JS语言中最流行的覆盖率采集工具。其前身是个人发起的istanbul项目。日常使用中,经常将相关的CLI工具nyc配合着流行的前端测试框架一起使用,达到在单元/集成/e2e测试的同时,输出测试覆盖率数据的效果。

当然,你也可以通过babel-plugin-istanbul配合测试框架使用

和istanbul的对比

对于此前对前端测试覆盖率稍有了解了的人来说,可能更熟悉的是旧版的istanbul。istanbuljs在功能上和istanbul没有太大的区别,但在项目组织和实现细节上有着些许不同。

  • istanbuljs使用monorepo组织整个项目,将相对独立的插桩、hook、覆盖率、sourcemap、报告等拆分成多个包独立实现和发版
  • 使用babel实现源代码的AST解析和插桩(instrument)代码的生成
  • 内置支持sourcemap
  • 报告生成上取消了对express的依赖,只在本地产出报告

介绍

对于希望借助istanbuljs实现代码覆盖率相关功能的(nyc也基于istanbuljs实现),了解其各个包工作原理将会有所帮助。下面就按功能范畴介绍各个包的实现原理。

代码插桩和hook

覆盖率产出建立在代码插桩的基础上。根据插桩时机,分为编译时插桩运行时插桩

  • 编译时插桩,即在代码转译过程中插入覆盖率采集代码,产出代码本身即拥有采集能力,譬如babel-plugin-istanbul
  • 运行时插桩,即产出代码本身不具有采集能力,在运行时通过hook的方式在使用的代码中插入覆盖率采集代码,譬如hookRequire

不同于使用babel的编译时插桩,运行时插桩需要额外的hook一步。插桩和hook分别由istanbul-lib-instrumentistanbul-lib-hook实现。

istanbul-lib-instrument

1
2
3
4
5
6
7
8
9
10
11
import { createInstrumenter } from 'istanbul-lib-instrument';
const instrumenter = createInstrumenter({
coverageVariable,
debug: options && options.debug
});
// ...
const transformed = instrumenter.instrumentSync(
code,
options.filename,
sourceMap ? sourceMap.toObject() : undefined
);

istanbul-lib-instrument利用babel实现了代码插桩。仓库暴露了3个API:

createInstrumenter

最主要的API。以入参作为配置项创建一个Instrumenter实例,配置项如下:

  • coverageVariable 覆盖率全局变量名,可选,默认__coverage__
  • preserveComments 是否保留注释,可选,默认false
  • esModules 是否插桩ES6代码,可选,默认false
  • produceSourceMap 是否为插桩前后代码生成sourcemap,可选,默认false
  • debug 是否打印详细信息,可选,默认false
  • … 更多配置参见文档介绍

通常使用时,配置项均使用默认值即可。Instrumenter实例有下面几个重要的方法

instrumentSync

同步插桩代码,支持ES6和ES5,插桩ES6代码时,需要配置项中的esModulestrue。解析代码出错时会抛出错误。方法返回插桩后的代码字符串,入参如下:

  • code 代码字符串
  • filename 文件名(包含路径)
  • inputSourceMap 用来将当前代码的覆盖率反映射到源文件中的覆盖率,需要是sourcemap格式。

在指定了inputSourceMap后,当前文件采集覆盖率时,会附带上传入的这个sourcemap,从而可以结合istanbul-lib-sourcemaps使用。

instrument

callback风格的插桩,此时抛出错误将通过回调的入参传入,而非直接抛出。插桩的代码也会在回调中传入而非直接返回。需要注意的是,回调的执行和语句的执行在同一个process tick中,而不是异步的。

函数入参即instrumentSync的第三个位置插入函数类型的callback入参。

剩余的两个方法:

  • lastFileCoverage,返回最近一次的文件覆盖率对象
  • lastSourceMap,返回最近一次的文件的sourcemap
programVisitor

programVisitor是一个将babel用于插桩的适配器函数,该函数会返回一个具有enter以及exit方法的对象,这两个方法必须应用在Program的enter和exit属性上,实现插桩效果。istanbuljs内部也是通过programVisitor实现的功能。programVisitor内部并不依赖babel的状态,因此也可以用在babel以外的环境。

该函数支持以下入参:

  • types,babel-types实例,语法节点类型
  • sourceFilePath,文件路径,可选,默认为unknown.js
  • opts,插桩配置项,可以参见文档介绍

在函数内部集中了插桩的细节实现,即对于特定类型语法树节点插入对应逻辑。

readInitialCoverage

配合InstrumenterlastFileCoverage方法使用,读取文件的初始覆盖率。

上面这些API中,最常用和最核心的即instrumentSync方法。在实现上,方法分为下面几步:

  1. 根据Instrumenter的配置项确定babel的配置项,同时,引入programVisitor作为babel的plugin,指定遍历节点的操作
  2. 使用@babel/coretransformSyncAPI,得到生成的代码
  3. 更新fileCoveragesourcemap
  4. 返回代码

istanbul-lib-hook

1
2
3
4
5
6
7
import { hookRequire } from 'istanbul-lib-hook';
hookRequire(matcher, transformer, {
verbose: true,
postLoadHook: (filename: string) => {
// ...
}
});

istanbul-lib-hook提供了下面一些API,用来hook JS中对代码的引用,其中后三个API都有对应的unhook API:

  • hookRequire,hook了require引入的代码
  • hookCreateScript,hook了vm.createScript引入的代码
  • hookRunInThisContext,hook了vm.runInThisContext引入的代码
  • hookRunInContext,hook了vm.runInContext引入的代码、

在其中最常用的是hookRequire,入参如下:

  • matcher,函数类型,接收文件的完整路径,返回bool类型,用来判断是否对文件插桩
  • transformer,函数类型,接收代码和文件路径,返回插桩后的代码,可以通过对上面提到的InstrumenterinstrumentSync封装得到
  • options,配置项
    • verbose,是否打印详细信息
    • postLoadHook,文件hook之后的回调

hookRequire借助Nodejs的Module加载机制实现,hook了后缀为.js文件的编译过程,在每次require的时候触发。详细的原理可以参见这里。唯一的不同是,在istanbuljs中,这一实现被封装在append-transform包中实现,来兼容异常情况。实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const appendTransform = require('append-transform');

function hookRequire(matcher, transformer, options) {
// ...
const fn = transformFn(matcher, transformer, options.verbose);
const extensions = options.extensions || ['.js'];

extensions.forEach(ext => {
appendTransform((code, filename) => {
// ...
const ret = fn(code, filename);
if (postLoadHook) {
postLoadHook(filename);
}
return ret.code;
}, ext);
});
// ...
}

数据维护

在代码插桩的基础上,产出的覆盖率数据会积累在全局变量上。在数据可视化和统计结果展示前,还需要将数据封装成更贴近实际场景的形态,便于进行合并、输出、摘要等操作。

这部分工作在istanbul-lib-coverage中完成。

覆盖率数据结构

下面是采集到的原始数据的结构定义,这也是istanbul-lib-coverage的基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
interface CoverageMapData {
[key: string]: FileCoverageData;
}
interface FileCoverageData {
path: string;
statementMap: { [key: string]: Range };
fnMap: { [key: string]: FunctionMapping };
branchMap: { [key: string]: BranchMapping };
s: { [key: string]: number };
f: { [key: string]: number };
b: { [key: string]: number[] };
}
interface Location {
line: number;
column: number;
}

interface Range {
start: Location;
end: Location;
}

interface BranchMapping {
loc: Range;
type: string;
locations: Range[];
line: number;
}

interface FunctionMapping {
name: string;
decl: Range;
loc: Range;
line: number;
}

istanbul-lib-coverage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import libCoverage from 'istanbul-lib-coverage';
const map = libCoverage.createCoverageMap(globalCoverageVar);
const summary = libCoverage.createCoverageSummary();

map.merge(otherCoverageMap);

// 获取摘要
map.files().forEach((f) => {
const fc = map.fileCoverageFor(f);
const s = fc.toSummary();
summary.merge(s);
});

console.log('Global summary', summary);

包暴露了3个API, 用来创建封装好的覆盖率数据。

createCoverageMap

传入原始数据(CoverageMapData类型)或CoverageMap类型,返回一个CoverageMap类型对象,在覆盖率数据基础上,提供了方法,包含:

  • merge,合并另一个CoverageMap对象或数据
  • filter,传入filter函数,删除部分覆盖率数据
  • toJSON,返回原始数据
  • files,返回文件列表
  • fileCoverageFor,返回特定文件的覆盖率
  • addFileCoverage,增加特定文件的覆盖率
  • getCoverageSummary,生成覆盖率摘要数据

摘要数据数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
interface CoverageSummaryData {
lines: Totals;
statements: Totals;
branches: Totals;
functions: Totals;
}
interface Totals {
total: number;
covered: number;
skipped: number;
pct: number;
}
createFileCoverage

类似createCoverageMap,通过传入原始数据或对象的方式,创建FileCoverage对象。对象有如下方法:

  • getLineCoverage,返回每一行的执行次数
  • getUncoveredLines,返回未覆盖的行序号列表
  • getBranchCoverageByLine,获取每个分支按行计算的覆盖率比例
  • toJSON,返回原始数据
  • merge,合并另一个FileCoverage或原始数据
  • computeSimpleTotals,根据输入的属性,计算覆盖率摘要
  • computeBranchTotals,根据分支计算覆盖率摘要
  • resetHits,重置所有已执行的数据
  • toSummary,生成摘要数据
createCoverageSummary

根据输入数据,创建摘要。方法较简单:

  • merge,合并一个摘要数据
  • toJSON,返回原始摘要数据
  • isEmpty,是否为空

整体来看istanbul-lib-coverage所做的工作,即按照约定好的格式,将数据组织起来,为可视化输出做了基础。

istanbul-lib-source-maps

1
2
3
4
5
6
7
8
9
10
import { createSourceMapStore } from 'istanbul-lib-source-maps';
import libCoverage from 'istanbul-lib-coverage';

const mapStore = createSourceMapStore({});

const coverageMap = libCoverage.createCoverageMap(coverageData);
const transformed = await mapStore.transformCoverage(coverageMap);
const transformedCoverage = transformed.data;

console.log(transformedCoverage);

绝大多数情况下,istanbuljs采集到的都是转译后目标文件的代码覆盖率,这个数据对于开发者来讲意义不大,需要将这里采集到的行列数据反映射到源文件的位置上。内置istanbul-lib-source-maps包用来处理这种情况的。

istanbul-lib-source-maps只负责转换代码位置和目录,并不能负责收集代码或文件的sourcemap信息,这一步交给使用者自己实现。就像我们在instrumentSync一节提到的,通过第三个入参inputSourceMap可以产出满足istanbul-lib-source-maps需要的覆盖率数据,从而借助istanbul-lib-source-maps获取到源文件的覆盖率。

这个包本身只暴露createSourceMapStore这一个API,通过传入配置的方式,初始化一个sourcemap的store。配置项如下:

  • verbose,是否打印详细信息
  • baseDir,sourcemap文件的基础目录
  • SourceStore,一个SourceStore对象
  • sourceStoreOpts,初始化SourceStore对象的参数列表

初始化好的MapStore对象有如下方法:

  • registerURL,通过dataURL注册一个sourcemap到store中
  • registerMap,通过sourcemap对象注册一个sourcemap到store中
  • getSourceMapSync,从当前store中获取指定文件的sourcemap
  • addInputSourceMapsSync,为当前覆盖率数据同步添加store中的sourcemap数据
  • sourceFinder,寻找指定文件的源文件路径
  • transformCoverage,利用当前store中的sourcemap信息,将覆盖率对应到源文件上。覆盖率信息中有inputSourceMap字段时,优先使用inputSourceMap字段里的sourcemap信息
  • dispose,清除store数据

包中的其余文件也都基于MapStore实现诸如路径转换,数据转换等工具方法。更多细节,可以参见源文件

使用时,有两种方式:

  • 先导入没有sourcemap的覆盖率信息,再逐个注册sourcemap到store中,最后执行transformCoverage
  • 直接导入有inputSourceMap信息的覆盖率数据,执行transformCoverage

可以根据实际场景选择使用方式。例如,nyc中就使用的第2种方式,利用convert-source-map采集到文件内的sourcemap信息,在代码插桩时传入。

可视化与报告

覆盖率工具的最终目的是向使用者呈现可读的数据样式,通常是以UI或文件的形式。因此,要将上一章中维护好的数据按使用者需求输出。可视化和报告由istanbul-lib-report以及istanbul-reports实现。

其中,前者定义了产出报告的抽象行为,后者实现了各种具体的报告形态。两个包结合在一起使用的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import libReport from 'istanbul-lib-report';
import reports from 'istanbul-reports';
const coverageMap;

const configWatermarks = {
statements: [50, 80],
functions: [50, 80],
branches: [50, 80],
lines: [50, 80]
};

const context = libReport.createContext({
dir: 'report/output/dir',
// 摘要的生成方式
// 可以是nested/flat/pkg 默认为'pkg'
defaultSummarizer: 'nested',
watermarks: configWatermarks,
coverageMap,
});
const report = reports.create('json', {
skipEmpty: configSkipEmpty,
skipFull: configSkipFull
});
// 同步操作
report.execute(context);

istanbul-lib-report

维护好的覆盖率数据会和具体的文件节点绑定,形成树状数据结构。遍历每个树节点就是产出报告的基本步骤,只不过不同报告格式,遍历节点的操作也不同。istanbul-lib-report中定义了基本的树状结构和遍历规则。

包暴露了3个API,

  • createContext,创建一个包含产出报告配置信息context对象
  • getDefaultWatermarks,返回默认的评级指标,默认是0到50到80,分别对应差中好
  • ReportBase,所有报告必须继承的基类

其中context和ReportBase是配合实现生成报告的。先来看简单的ReportBase,ReportBase类中简单地描述了管理摘要树的方法和定义了通用的execute方法来生成报告。具体会在istanbul-reports中提到。

再来看Context类。Context入参配置项包含:

  • dir,产出报告的目标目录
  • watermarks,评级指标,默认使用0-50-80-100
  • sourceFinder,通过文件路径返回代码的函数,默认为文件读取操作
  • coverageMap,覆盖率数据
  • defaultSummarizer,摘要树生成的风格,有flat,pkg,nested3种选择
    • flat,所有文件全部打平到1层里,挂载到最近的公共祖先上
    • pkg,所有文件向上追溯1层文件夹,挂载到最近的公共祖先上
    • nested,所有文件向上追溯,直到追溯到最近的公共祖先上

构建好的Context有一个writer属性挂载在thisthis.data上,它决定了报告的产出形式,目前这个包提供了两种产出形式:文件字符串,后者只用在产出XML数据时。其余报告基本都通过文件的形式产出,这也是istanbuljs和istanbul的一大不同。在istanbul中,html可以借助express依赖以HTML响应的形式返回。

文件的输出类定义在file-writer.js中,其中的FileWriter定义了基本的copyFilewriterForDirwriteFile操作用来递归的复制和写入文件。另外在writeFile中,根据目标是否是file,选择使用ConsoleWriter或者FileContentWriter。这两者继承自基本的ContentWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ContentWriter {
colorize(str /*, clazz*/) {
return str;
}
println(str) {
this.write(`${str}\n`);
}
close() {}
}
class FileContentWriter extends ContentWriter {
constructor(fd) {
super();
this.fd = fd;
}

write(str) {
fs.writeSync(this.fd, str);
}

close() {
fs.closeSync(this.fd);
}
}
class ConsoleWriter extends ContentWriter {
write(str) {
if (capture) {
output += str;
} else {
process.stdout.write(str);
}
}

colorize(str, clazz) {
const colors = {
low: '31;1',
medium: '33;1',
high: '32;1'
};

if (supportsColor.stdout && colors[clazz]) {
return `\u001b[${colors[clazz]}m${str}\u001b[0m`;
}
return str;
}
}

字符串的输出类只有XMLWriter,位于xml-writer.js中,在其中定义了xml的开闭标签和缩进操作。

这个Context还有一些方法提供给reports使用:

  • getWriter/getXMLWriter,返回输出数据的writer
  • getSource,通过sourceFinder寻找源码
  • classForPercent,将评价转化为HTML类名
  • getVisitor,获取一个遍历的vistor对象
  • getTree,根据摘要风格返回一个用于遍历的树,

下面具体介绍vistor和tree的概念

VisitorBaseTree

Visitor类和BaseNode类定义了遍历和节点的基本操作,其中Visitor使用代理人模式,在构造时传入一个回调函数对象,当visitor对象触发特定事件时,会将当前节点和状态交给回调函数,实现遍历效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Visitor {
constructor(delegate) {
this.delegate = delegate;
}
}

['Start', 'End', 'Summary', 'SummaryEnd', 'Detail']
.map(k => `on${k}`)
.forEach(fn => {
Object.defineProperty(Visitor.prototype, fn, {
writable: true,
value(node, state) {
if (typeof this.delegate[fn] === 'function') {
this.delegate[fn](node, state);
}
}
});
});

相对应的BaseNode类定义了用于遍历的visit方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BaseNode {
// ...
// 递归遍历
visit(visitor, state) {
// 文件夹没有详情,只有摘要
if (this.isSummary()) {
visitor.onSummary(this, state);
} else {
visitor.onDetail(this, state);
}

this.getChildren().forEach(child => {
child.visit(visitor, state);
});

if (this.isSummary()) {
visitor.onSummaryEnd(this, state);
}
}
}

最后在BaseTree中从Root开始遍历即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BaseTree {
constructor(root) {
this.root = root;
}
getRoot() {
return this.root;
}
visit(visitor, state) {
if (!(visitor instanceof Visitor)) {
visitor = new Visitor(visitor);
}
visitor.onStart(this.getRoot(), state);
this.getRoot().visit(visitor, state);
visitor.onEnd(this.getRoot(), state);
}
}

不同的摘要风格形成的树状结构不同,它们都是ReportTree类型,而ReportTree正是继承自BaseTreeReportTree由继承自BaseNodeReportNode构建。在ReportNode上定义了和覆盖率或文件操作相关的一些方法,如getFileCoveragegetCoverageSummary等。

一个context内的visitor和tree可以通过getVisitorgetTree得到

istanbul-reports

istanbul-reports包中定义了种类繁多的导出格式,在入口文件通过指定的配置项选择使用,如上面样例中的const report = reports.create('json', {/* ... */})即使用json/lib/index.js下导出的JsonReport类。istanbul-reports所有格式都基于istanbul-lib-report中基类的定义。这里以简单的json格式为例。

其中定义了对于onStartonDetailonEnd的定义和上面BaseNode中介绍的回调函数相对应,在遍历ReportTree的各个阶段被触发,通过context的writer去输出。而writer从ReportBase中可以发现是通过context确定的,在context中默认是filewriter。而遍历是如何执行的呢?

回头看下ReportBase的实现。

1
2
3
4
5
6
7
8
9
10
11
const _summarizer = Symbol('ReportBase.#summarizer');

class ReportBase {
constructor(opts = {}) {
this[_summarizer] = opts.summarizer;
}

execute(context) {
context.getTree(this[_summarizer]).visit(this, context);
}
}

execute中传入context,即可把带有覆盖率信息的树形数据结构交给report代表的visitor遍历,逐个节点地打印出覆盖率报告。

其余类型的报告也是类似的原理。

更多

背景

随着业务增长,代码逐渐复杂,前端有些时候不能通过自测保证代码质量,而测试同学从用户角度进行端到端的case测试,也有可能存在测试case不够全面或重复覆盖的情况。而测试覆盖质量只能通过测试case评审来保证,没有量化的、直观的客观数据。

然而前端场景和后端不同,UI测试因为业务形态原因,变更会更频繁,编写和维护单测成本比较高。因此目前且短期内前端工程还是以传统的用户角度的端到端测试为主。另外,在SSR项目中,前端开发也会书写API层,这部分代码也需要测试覆盖到。目前前端生态还没有一个能同时覆盖UI层和API层的覆盖率统计框架,需要借助已有的工具实现这点。

解决问题

提升开发自测质量和提测质量,避免case覆盖不全的问题,同时有直观的统计指标衡量。

预期效果

分3个阶段:

第一阶段:基本可用版本,能够采集UI层和API层的代码覆盖率,覆盖率采集过程对前端开发透明,无需开发业务以外的开发成本。能够打开浏览器页面直观查看各个代码覆盖率。

第二阶段:丰富统计数据,在第一阶段基础上,能够记录不同分支甚至不同版本历史的覆盖率,能够借助gitlab只查看增量文件的覆盖率。

第三阶段:打通QA平台,在第二阶段基础上,接入QA已有平台或自建平台,在第三方页面查看数据;形成一套覆盖率采集方案。

使用场景

本地、特性分支上使用

技术选型

覆盖率采集建立在代码插桩基础上。前端生态里比较成熟的工具是istanbuljs,该库的0.x.x版本位于istanbul库。关于该库的科普介绍,可以阅读ruanyifeng的科普文

istanbul提供两种插桩方式:编译时运行时

  • 编译时
  • 运行时,需要借助istanbul-middleware中间件的帮助
    • im.hookLoader,适用于服务端文件的动态插桩,方法利用istanbul-lib-hook中的hookRequire方法,hook被require引入的js文件,返回插桩后的js文件。因此需要在业务代码require前引入
    • im.createClientHandler,用于客户端js文件的动态插桩,它会把指定根路径下的js文件请求拦截,返回插桩后的代码。

在后台项目中,服务端代码使用.ts书写,本地使用ts-node启动,boe和线上使用tsc编译后的js文件启动。前端代码使用.ts书写,本地使用webpack + babel预编译成js。结合这个场景看,在node侧使用hookLoader,在UI测使用babel-plugin-istanbul插件更合适。

总体来讲,工作分两步:代码插桩可视化

代码插桩

规划里一切功能的基础和本质都来自代码插桩。

client端

client端可以直接使用目前仍良好维护的babel-plugin-istanbul插件。在.babelrc中引入相关配置:

1
2
3
4
5
6
7
{
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}

仅在test环境下开启该插件。可以用cross-env这样的工具设置NODE_ENV=test

1
2
3
4
5
{
"scripts": {
"test": "cross-env NODE_ENV=test npm run start"
}
}

插件还有includeexcludeuseInlineSourceMaps这样的配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"env": {
"test": {
"plugins": [
["istanbul", {
"useInlineSourceMaps": false,
"exclude": [
"**/*.spec.js"
]
}]
]
}
}
}

插桩后的业务代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
var cov_ac7rkuoyv = function () {
var path = "/Users/test/shenlvmeng/nyc-demo/src/App.js";
var hash = "7dec600464f484deef063d183319f809a7c25687";
var global = new Function("return this")();
var gcv = "__coverage__";
var coverageData = {
path: "/Users/shenlvmeng/nyc-demo/src/App.js",
statementMap: {
"0": {
start: {
line: 8,
column: 2
},
end: {
line: 14,
column: 9
}
}
// ...
},
fnMap: {
"0": {
name: "App",
decl: {
start: {
line: 7,
column: 9
},
end: {
line: 7,
column: 12
}
},
loc: {
start: {
line: 7,
column: 15
},
end: {
line: 33,
column: 1
}
},
line: 7
},
// ...
},
branchMap: {},
s: {
"0": 0,
// ...
},
f: {
"0": 0,
// ...
},
b: {},
_coverageSchema: "43e27e138ebf9cfc5966b082cf9a028302ed4184",
hash: "7dec600464f484deef063d183319f809a7c25687"
};
var coverage = global[gcv] || (global[gcv] = {});

if (coverage[path] && coverage[path].hash === hash) {
return coverage[path];
}

return coverage[path] = coverageData;
}();

var _jsxFileName = "/Users/test/shenlvmeng/nyc-demo/src/App.js";

function App() {
cov_ac7rkuoyv.f[0]++;
cov_ac7rkuoyv.s[0]++;
Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
cov_ac7rkuoyv.f[1]++;
cov_ac7rkuoyv.s[1]++;

(async () => {
cov_ac7rkuoyv.f[2]++;
cov_ac7rkuoyv.s[2]++;
console.log(window.__coverage__);
cov_ac7rkuoyv.s[3]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.defaults.headers.post['Access-Control-Allow-Origin'] = '*';
cov_ac7rkuoyv.s[4]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.post('http://localhost:4000/coverage/client', window.__coverage__);
})();
}, []);
cov_ac7rkuoyv.s[5]++;
return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
className: "App",
__source: {
fileName: _jsxFileName,
lineNumber: 16
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("header", {
className: "App-header",
__source: {
fileName: _jsxFileName,
lineNumber: 17
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
src: _logo_svg__WEBPACK_IMPORTED_MODULE_2___default.a,
className: "App-logo",
alt: "logo",
__source: {
fileName: _jsxFileName,
lineNumber: 18
},
__self: this
}), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", {
__source: {
fileName: _jsxFileName,
lineNumber: 19
},
__self: this
}, "Edit ", react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("code", {
__source: {
fileName: _jsxFileName,
lineNumber: 20
},
__self: this
}, "src/App.js"), " and save to reload."), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("a", {
className: "App-link",
href: "https://reactjs.org",
target: "_blank",
rel: "noopener noreferrer",
__source: {
fileName: _jsxFileName,
lineNumber: 22
},
__self: this
}, "Learn React")));
}

可以看到针对每个文件,插桩后的代码新建了一个函数,用于更新这个文件的覆盖率信息。在业务代码执行时,会不断更新对应的计数器。前端代码run起来后,可以通过window.__coverage直接得到当前项目的覆盖率情况。

覆盖率计算原理

拿到代码字符串后,使用AST解析库解析出语法树,在对应树节点插入覆盖率语句,之后将语法树转成插桩后的代码。这个过程在0.x.x版本的实现可以参加这里

0.x.x版本的instanbul使用esprimaescodegen。在最新的istanbuljs的istanbul-lib-instrument中使用babel相关的包完成解析、生成等功能,但插桩思路不变。

node端

node端使用istanbul-middleware中间件的hookLoader完成动态插桩。该中间件为instanbul作者所写,所依赖的istanbul版本是目前已经不再维护的0.x.x版本,同时中间件也是基于express所写,年代较老。中间件的设计和我们的场景有些距离,因此需要在其基础上进行改造。

在预期场景下,我们主要使用im.hookLoaderim.createHandler两个方法。

im.hookLoader

im.hookLoader只是在instanbul hook的基础上做的简单封装。看文档可以发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookRequire(matcher, transformer, options) {
options = options || {};
var fn = transformFn(matcher, transformer, options.verbose),
postLoadHook = options.postLoadHook &&
typeof options.postLoadHook === 'function' ? options.postLoadHook : null;

Module._extensions['.js'] = function (module, filename) {
var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
if (ret.changed) {
module._compile(ret.code, filename);
} else {
originalLoader(module, filename);
}
if (postLoadHook) {
postLoadHook(filename);
}
};
}

hook require是利用CommonJS的模块加载规范实现的。这里介绍下模块加载流程相关的背景。

模块加载流程

require和module来自nodejs的Modules模块。无需显式引入(原因下面会提)即可使用里面的module, require, exports的关键字。在require一个包时,分为了解析、加载、封装、求值、缓存几步。

  1. 解析一步交给module.require(modulename)方法实现,该方法调用静态方法Module._load加载模块。
  2. 加载时首先调用Module._resolveFilename解析路径,解析过程大致分为粗筛和精确定位
  3. 粗筛,原生模块则直接返回模块名,否则从当前目录逐步向上寻找node_modules目录下的模块文件夹
  4. 精确定位,首先在上面的目录下寻找有无没有拓展名的同名文件,再寻找以jsjsonnode拓展名结尾的文件;再寻找同名目录下package.jsonmain字段指定的路径,最后寻找同名目录下的index.js
  5. 得到路径后,先试图从Module._cache中寻找有无模块缓存,若没有,则新建模块对象并缓存,之后调用module.load()方法加载该模块
  6. node默认只能load以js, json, node结尾的文件,除此之外均视为.js文件
1
2
3
4
5
6
7
8
9
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
var extension = path.extname(filename) || '.js';

if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
};
  1. Module._extensions键值对默认只包含对上述三种文件的处理。其中json和node文件较简单
  • json,读取文件 => JSON.parse => 注入到module.exports
  • node,调用process.dlopen加载
  • js,调用module._compile()处理文件内容
  1. module._compile执行js文件编译,编译前调用Module.wrap方法将模块封装在函数内,这也是modulerequireexports__filename__dirname可以直接在模块内使用,且模块间的modulerequireexports不相互干扰的原因。
1
2
3
4
5
6
7
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
  1. wrap后,调用vm.runInThisContext将字符串转为可执行的js函数。最后一句执行封装的函数,注入当前module的相关信息到模块中
1
2
3
4
5
6
7
8
9
10
11
12
13
Module.prototype._compile = function(content, filename) {
// ...
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
// ...
// 执行
result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
return result;
}

实际上,可以在REPL中输入require.extensions看到nodejs支持的3种文件拓展。而hookRequire所做的就是hook了加载.js的步骤,在交由module._compile处理前,前进行了插桩处理。

注:ts-node能够import.ts文件也是因为在module._extendsion中添加了.ts的处理方法

综上,在本地测试时,由于通过ts-node启动,im.hookLoader只会hook.js文件(0.x.x版本),会导致没有服务端代码覆盖率,上线前tsc后,就可以顺利注入,从global.__coverage__中拿到覆盖率数据。

数据采集

服务端覆盖率数据采集借助上述的im.hookLoader就已完成。前端页面的代码覆盖率需要自行周期性上报。im.createHandler提供的API可以接收前端覆盖率,该方法创建的路由回调会调用utils.mergeFileCoverage最终将增量覆盖率数据累加在global.__coverage__中。

可视化

可视化需要借助istanbul(0.x版本)本身提供的相关API,又或者直接更方便地使用im.createHandler。其方便封装了一些istanbul的API,实现的简单的可视化功能,更多介绍可以直接参考istanbul-middleware使用文档。

im.createHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createHandler(opts) {
// ...
var app = express();
// ...

//send static file for /asset/asset-name
app.use('/asset', express.static(ASSETS_DIR));
app.use('/asset', express.static(path.join(ASSETS_DIR, 'vendor')));

app.use(bodyParser.urlencoded(urlOptions));
app.use(bodyParser.json(jsonOptions));

app.get('/', function (req, res) {
// ...
core.render(null, res, origUrl);
});
// ...
}

从上面的使用上看,im.createHandler的使用是和express绑定的,并不适配koa的风格,要想使用在非express服务端场景里,或者增加额外功能,就需要重构这个方法。不过,由于一些API的使用从0.x.x版本迁移到新的monorepo的版本并不平滑,且新版本API暂无文档。所以尽管instanbul已经不再更新,对istanbul-middleware中间件的重构还只能保持对旧版本istanbul的依赖。

重构设计

为了兼容koa环境,从istanbul-middleware库fork新版本istanbul-koa-middleware,使用ts重写。去掉无用的代码,重点需要改造的就是createKoaHandler方法。

为减少istanbul-koa-middleware使用方的依赖,考虑使用类似istanbul-middleware的形式,在createKoaHandler内部定义koa应用,使用方只需引入koa-mount和istanbul-koa-middleware即可在任意路由上挂载覆盖率可视化相关子路由。

除此之外:

  • 使用koa-static替代express.static
  • 使用koa-mount替代app.use('/some/path', someMiddleware)
  • 使用koa-router替代app.getapp.post
  • 定义WebFramework接口,掩盖对回调中res使用方式的不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface WebFramework {
setHeader: (key: string, value: string) => void;
status: (code: number) => void;
write: (content: any) => void;
end: (content?: string) => void;
}

function genWebFrameworkCtxFromKoaCtx(ctx: Koa.ParameterizedContext): WebFramework {
return {
setHeader(key, value) {
ctx.set(key, value);
},
status(code) {
ctx.status = code;
},
write(content) {
ctx.res.write(content);
},
end(content) {
ctx.res.end(content);
}
};
}

这样,重构后的istanbul-koa-middleware就可以帮我们实现最简单的覆盖数据可视化了。后续的新增功能,也都建立在对createHandler函数的改造上。

已知问题

在上面工作完成后,第一阶段目标基本已经实现。

效果图1
效果图2

但是,还有很多可以优化的地方,这也是后续阶段需要解决的问题:

  • 服务端代码如果没有正确设置source-map,覆盖率展示的文件是tsc之后的js文件,可读性差
  • 需要区分环境,在特定环境下才对代码文件插桩
  • 本地环境下可以访问到原始的ts文件,上传测试环境后,由于不会打包上传源文件,覆盖率报告将看不了原始ts文件的覆盖详情
  • 覆盖率报告功能太简单,不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看
    ,也没有diff内容覆盖率功能
  • 出现了一些意义不大的文件,需要剔除

更多

基本概念

Extension是由HTML、CSS、JavaScript和图片等其他资源文件组成的压缩包。它可以增强浏览器体验,实现个性化。

Files

Extension没有做目录的约定,但是它们需要已配置文件的形式写在manifest中。manifest.json中描述了Extension的基本信息、使用能力和重要文件。

manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
{
// Required
"manifest_version": 2,
"name": "My Extension",
"version": "versionString",

// Recommended
"default_locale": "en",
"description": "A plain text description",
"icons": {...},

// Pick one (or none)
"browser_action": {...},
"page_action": {...},

// Optional
"action": ...,
"author": ...,
"automation": ...,
"background": {
// Recommended
"persistent": false,
// Optional
"service_worker":
},
"chrome_settings_overrides": {...},
"chrome_ui_overrides": {
"bookmarks_ui": {
"remove_bookmark_shortcut": true,
"remove_button": true
}
},
"chrome_url_overrides": {...},
"commands": {...},
"content_capabilities": ...,
"content_scripts": [{...}],
"content_security_policy": "policyString",
"converted_from_user_script": ...,
"current_locale": ...,
"declarative_net_request": ...,
"devtools_page": "devtools.html",
"event_rules": [{...}],
"externally_connectable": {
"matches": ["*://*.example.com/*"]
},
"file_browser_handlers": [...],
"file_system_provider_capabilities": {
"configurable": true,
"multiple_mounts": true,
"source": "network"
},
"homepage_url": "http://path/to/homepage",
"import": [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],
"incognito": "spanning, split, or not_allowed",
"input_components": ...,
"key": "publicKey",
"minimum_chrome_version": "versionString",
"nacl_modules": [...],
"oauth2": ...,
"offline_enabled": true,
"omnibox": {
"keyword": "aString"
},
"optional_permissions": ["tabs"],
"options_page": "options.html",
"options_ui": {
"chrome_style": true,
"page": "options.html"
},
"permissions": ["tabs"],
"platforms": ...,
"replacement_web_app": ...,
"requirements": {...},
"sandbox": [...],
"short_name": "Short Name",
"signature": ...,
"spellcheck": ...,
"storage": {
"managed_schema": "schema.json"
},
"system_indicator": ...,
"tts_engine": {...},
"update_url": "http://path/to/updateInfo.xml",
"version_name": "aString",
"web_accessible_resources": [...]
}
  • manifest_version表明使用manifest的格式版本号,目前是整数2
  • icons推荐使用PNG格式
  • browser_actionpage_action类型二选一,前者用于所有页面,后者用于特定一些页面。
  • background可以指定在后台运行的脚本
  • chrome打头的三个配置可以定制浏览器本身的UI或行为
  • commands快捷键配置
  • content_scripts描述需要声明式插入的规则
  • devtools_page描述自定义的devtools选项卡
  • externally_connectable描述其他能够连接到该Extension的url规则
  • omnibox配置关键词当用户在地址栏输入特定字符时,变成与Extension交互
  • permissions显式声明Extension需要使用的权限

Extension中的文件路径类似HTML中,通常使用相对路径访问。在使用绝对路径时,需要使用chrome-extension://<extensionID>/<pathToFile>风格的路径,可以使用chrome.runtime.getURL()得到某资源的绝对路径。

browser_action

下面是一个browser_action的manifest样例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// browser action
{
"name": "My extension",
...
"browser_action": {
"default_icon": { // optional
"16": "images/icon16.png", // optional
"24": "images/icon24.png", // optional
"32": "images/icon32.png" // optional
},
"default_title": "Google Mail", // optional; shown in tooltip
"default_popup": "popup.html" // optional
},
...
}

browser_action的UI可以包括icon、tooltip、badge、popup。

  • icon,可以是图片文件或HTML5 canvas元素。后者可以动态创建,以提供更流畅的效果。
  • tooltip,即配置文件中的title
  • badge,用于描述Extension工作状态的徽章,最长4个字符,可以调用browserAction API动态修改内容或背景色
  • popup,点击Extension按钮弹窗的窗口,可以包含任意HTML内容,可以在default_popup中定义,或调用API动态修改

browser_action有下面一些最佳实践:

  • 在Extension作用于大多数页面时使用,在作用于少数页面时使用page_action
  • 使用更多彩和重的图标,体现出和轻量级page_action的区别
  • 不要模仿chrome内置图标,会造成误解
  • 注意图标在不同主题背景色下的表现形态
  • 不要使用动图,会引起用户焦虑

page_action

更轻量级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// page action
{
"name": "My extension",
...
"page_action": {
"default_icon": { // optional
"16": "images/icon16.png", // optional
"24": "images/icon24.png", // optional
"32": "images/icon32.png" // optional
},
"default_title": "Google Mail", // optional; shown in tooltip
"default_popup": "popup.html" // optional
},
...
}

UI上没有badge,其余和browser_action一致。另外,在非指定页面下,会有灰度展示。最佳实践同上。

架构

除了manifest描述项目结构外,需要另外一些组件组成完整的Extension功能。

  • background script,Extension的事件handler,用于监听对于Extension来说重要的浏览器事件
  • UI元素,有多种体现形式,如右键菜单,omnibox,点击按钮的弹出层等
  • content scripts,用于和页面交互的js脚本。它可以传递消息给Extension的其他部分
  • options page,用于配置Extension的页面

下面是一些详细介绍

background scripts

有效率的后台脚本应该由浏览器事件触发,执行命令之后卸载。这部分脚本在manifest的background中声明。

1
2
3
4
5
6
7
8
9
{
"name": "Awesome Test Extension",
...
"background": {
"scripts": ["background.js"],
"persistent": false
},
...
}

scripts可以指定需要执行的多个后台脚本。persistent需要指定为false。只有使用了chrome.webRequestAPI的后台脚本才指定persistent为true。

使用上,

  1. 在Extension加载时,初始化一次性配置。
  2. 添加监听函数,一些监听函数还提供更多参数便于筛选事件。
  3. 在监听函数内,实现业务逻辑
  4. 在卸载前,执行持久化数据、释放请求等操作

content scripts

Content scripts运行在页面的执行环境下,通过DOM访问页面元素并和所在的Extension交互。它还可以调用Chrome APIs完成一些原生操作。Content scripts执行在和页面JS脚本相隔离的环境里,两者共享1个DOM。

Content scripts有两种执行方式,命令式插入或声明式插入。前者通过chrome.tabs.executeScript实现,后者通过在manifest中声明实现在访问特定url时自动加载js文件:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "My extension",
...
"content_scripts": [
{
"matches": ["http://*.nytimes.com/*"],
"css": ["myStyles.css"],
"js": ["contentScript.js"]
}
],
...
}

Content scripts和页面脚本虽然是隔离开的,但共享一个DOM,可以通过window.postMessage沟通和传递消息。

Chrome API

Extension使用特定的Chrome API在浏览器的环境下执行原生操作。API绝大多数都是异步的,这意味着如果想知道操作的结果,需要在回调函数中进行操作。Chrome的所有API都整合在Chrome这个namespace下,根据类型拆分成多个子模块,如chrome.runtime。

页面通信

由于content scripts运行在网页环境下,它通常需要和Extension本身进行通信。

  • 一次性通信,content scripts端使用chrome.runtime.sendMessage,Extension端使用chrome.tabs.sendMessage。接收侧一律使用chrome.runtime.onMessage.addListener
  • 持久性连接,类似上面使用chrome.runtime.connecttabs.connect,详见文档
  • 跨Extension通信,使用chrome.onMessageExternalruntime.onConnectExternal接收事件,发送事件可以使用上面的一次性通信或持久性连接的方式
0%