Chipmunk & Panda

-- 鼠熊部落格

All work and no play makes Jack a dull boy.

基于 Whistle 插件的网络重写方案

基于 Whistle 的接口响应重写方案,参照官方文档并使用 whistle.script

1 问题背景

Whistle 的介绍及其基本的安装与使用可直接参照官方文档。其中,在进行代理配置时,倘若使用 SwitchyOmega 及类似工具,则需注意默认或空 BypassList 配置均会无法将本地回环地址(如 127.0.0.1)代理至 Whistle,原因参照 Proxy support in Chrome。需使用 <-loopback> 替代原本的 BypassList,才可正常代理本地回环地址。

现有任务涉及对大量接口的字段类型变更,原本返回时间字符串的接口将改为返回 13 位 Unix 时间戳(如 "2012-12-21 15:14:35" 改为 "1356074075000"),由前端根据展示需要自行处理。在后端同学完成接口改造之前,为了方便自测,前端同学需要对请求响应结果进行 Mock,与“从 0 到 1”的新接口开发场景不同,本场景的特殊性在于:

  1. 已有可用接口,只是接口响应结果存在部分差异;
  2. 接口数量庞大;
  3. 接口改动比较同质化(没有数据变化,只有格式变化);
  4. 变更涉及的接口名、字段名并不统一。

简单起见,假设所有接口中时间格式均为 "YYYY-MM-DD HH:mm:ss"。在此场景下,依次针对每个接口去 Mock 响应数据显然有些低效,而可以考虑对响应进行拦截,对结果进行格式转化,如:

1
2
3
4
5
6
function formatRes(res: string) {
return res.replace(
/"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"/g,
(match, time) => `${new Date(time).valueOf()}`
);
}

可以在项目中引入响应拦截器来在将结果送到业务逻辑层前使用 formatRes 进行处理,然而,通常应当避免这种对代码具有侵入性的方式,而选择在网络层对响应结果进行重写(实际上是 MitM)以达到相同目的。遗憾的是,各种 Mock 常用工具大多仅支持返回完整的 Mock 结果,或对结果进行较为死板的搜索替换,而更高自由度的重写操作往往需要借助更加专业的网络调试工具实现,如下表所示。

工具名称 工具类型 价格 支持的操作 备注
Postman API 管理工具 免费(高级版付费) Mock Server(返回预先构造的响应结果) 支持捕获网络流量(浏览器插件),但不支持修改
Apifox API 管理工具 个人版免费 Mock Server
YApi API 管理工具 开源免费 Mock Server
Mock.js 工具库 开源免费 请求拦截 拦截网络请求,直接返回 Mock 响应,实际效果与 Mock Server 类似
*Fiddler 网络调试工具 6~35 USD/月 MitM 预置规则较为简单,可以使用 Fiddler Script 实现高级重写(JS)
Charles 网络调试工具 50 USD/license MitM 仅支持匹配替换和全量替换
*Reqable API 管理工具 + 网络调试工具 社区版免费,仅能保存 3 个脚本 Mock Server、MitM 号称 “Reqable = Fiddler + Charles + Postman”,可以使用脚本实现高级重写(Python 3)
*Quantumult X 网络调试工具(及 VPN) 9.99 USD(美区 App Store) MitM iOS + macOS 双平台,支持使用脚本实现高级重写(JS)
*Surge 网络调试工具 iOS:49.99 USD/3 台设备
Mac:49.99 USD/设备
MitM iOS + macOS 双平台(License 不通用),支持使用脚本实现高级重写(JS)
中文支持较好,提供了官方中文指引介绍实现原理,具有一定通用性和学习价值
界面美观,功能齐全
*Whistle 网络调试工具 开源免费 MitM Node 跨平台,支持使用插件使用高级重写

可以看出,Whistle 基本是同时具备“开源免费”、“支持重写”、“使用 JS”几个特性的唯一选择,美中不足在于界面略显朴素,且插件开发相关文档有待完善,这也是本文的意义所在。

接下来,本文将简要介绍如何使用 Whistle 实现前述提及的响应重写功能,即拦截指定网络请求,将响应结果中 "YYYY-MM-DD HH:mm:ss" 格式的时间字符串格式化为 13 位 Unix 时间戳,再返回给前端用于页面渲染测试

2 Whistle 插件开发

2.1 注意事项

Whistle 的插件提供了多个钩子,其中仅 server(实际上还有 reqRead/reqWriteresRead/resWrite)钩子能够对网络流量进行内容上的修改,需要在符合要求的配置格式下,Whistle 才会将请求转发至 server,否则将无法实现重写,参见 plugin

然而,开启转发 server 的配置会被视为 Whistle 的自定义规则,从而与 rule 中的其他预置规则存在冲突,仅优先级最高的配置可以生效,参见匹配原则。这意味着倘若想要在借助插件实现重写的同时使用其他规则(例如请求替换),便不得不在插件中自行实现相同功能(见本文 2.3.1 节)。

【推荐】这一问题可以借助 Whistle 提供的 pipe 协议来解决。pipe 原本被设计用于对请求、响应流进行加解密(参见插件实例),但实际上也可以被用来进行请求、响应流的重写,且由于 pipe 不属于 rule,因而不会与请求替换等规则发生冲突,相较于所有功能均通过自定义 server 来实现有着更加清晰的责任划分(见本文 2.3.2 节)。

2.2 whistle.script

Whistle 提供了插件开发的脚手架插件示例,但相对缺少“一步到胃”的开发指引,这可能也是 Whistle 尚未能形成一定规模插件生态市场的原因之一。

实际上,本文并不打算仿照上述插件示例并使用脚手架来进行 Whistle 插件的开发,而是借助官方插件库提供的 whistle.script 来进行网络请求响应重写的实现。换言之,本文主要参考的内容基本只有官方文档所提供的 插件 API 与 whistle.script 的 README

通过查看源码,whistle.script 所做的主要工作实际上就是在 server 中调用用户编写的 handleRequest,并将参数透传,如:

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
handleRequest = (ctx, request) => {
// user code
};

server.on("request", async (req, res) => {
// ...

// ref: https://github.com/whistle-plugins/whistle.script/blob/master/lib/util.js#L113
const ctx = {
req,
res,
fullUrl,
url: req.originalReq.url,
headers: req.headers,
method: req.method,
};

// ref: https://github.com/whistle-plugins/whistle.script/blob/master/lib/server.js#L24
try {
await handleRequest(ctx, req.request);
} catch (err) {
clearup();
req.emit("error", err);
console.error(err);
}
});

因此,可以直接在 whistle.script 所提供的编辑器中进行自定义插件的编写,而绕过原始的插件项目搭建等流程。

2.3 插件实现

2.3.1 基于 server 的重写

whistle.script 的安装与配置可以直接参考 README,本文不再赘述,此处直接给出实现重写的示例代码(参考 Whistle 插件 API):

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
// 规则配置:script://test www.whistlescript.com

function formatRes(res) {
return res.replace(
/"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"/g,
(match, time) => `${new Date(time).valueOf()}`
);
}

exports.handleRequest = (ctx, request) => {
const apiList = ["getBirthday"];
const { fullUrl } = ctx;

const { req, res } = ctx;
req.passThrough(
(rawReqBody, next) => {
next({
// 请求替换
url: fullUrl.replace("www.whistlescript.com", "127.0.0.1:8080"),
headers: req.headers,
body: rawReqBody,
});
},
(rawResBody, next, _res) => {
if (apiList.some((api) => fullUrl.endsWith(api))) {
// 请求筛选
_res.getText((err, text) => {
if (err) {
return next();
}
next({
// 响应重写
body: formatRes(text),
});
});
} else {
// 直接返回原始响应
next({
body: rawResBody,
});
}
}
);
};

该插件的主要工作及注意事项如下:

  1. 【注意事项】规则配置中,需要使用 script://test 而非 whistle.script://test 否则不会将请求转发至 server
  2. 【示例】手动实现了请求 url 的替换,即浏览器发出的请求 http://{www.whistlescript.com}/api/getBirthday 会被替换为 http://{127.0.0.1:8080}/api/getBirthday
  3. 【示例】手动实现(简单且不完善的)规则匹配,仅对 apiList 中的接口进行响应重写,否则直接返回原始响应;
  4. 【示例】使用 formatRes 对响应内容进行重写;
  5. 【注意事项】next 函数的入参就是会传递下去的全部内容,换言之,即使插件中并没有对请求的 headers 和 body 进行修改,也需要将原始的请求内容传递下去而不能缺省,否则会导致响应内容的丢失。

2.3.2 基于 pipe 的重写

whistle.script 也支持自定义 pipe 方法,对 xxxReadxxxWrite 接口均进行了暴露,因此可以通过请求重写规则与 pipe 插件的结合实现上一节中 handleRequest 功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 请求替换规则配置:http://www.whistlescript.com/api/getBirthday http://127.0.0.1:8080/api/getBirthday
// pipe 协议配置:pipe://script(test-pipe) www.whistlescript.com

exports.handleResRead = (req, res, options) => {
const apiList = ["getBirthday"];
const { fullUrl } = req.originalReq;

if (apiList.some((api) => fullUrl.endsWith(api))) {
let body;
req.on("data", (data) => {
body = body ? Buffer.concat([body, data]) : data;
});
req.on("end", () => {
if (body) {
res.end(formatRes(body.toString()));
} else {
res.end();
}
});
} else {
req.pipe(res);
}
};

与自定义 server 相比,该插件的主要工作及注意事项如下:

  1. 【示例】直接通过配置请求替换规则实现 url 的替换;
  2. 【示例】使用 handleResRead 而非 handleRequest 钩子;
  3. 【注意事项】在本示例情景中,handleResWrite 钩子同样可以实现内容重写,二者在调用上存在先后顺序,参见实现原理;
  4. 【注意事项】针对 tunnel 请求和 ws 请求,需要使用对应的 tunnelResReadwsResRead 钩子(参见 whistle.script 的 README)。

3. 总结

在完成上述插件的开发与配置后,假设在浏览器中发起请求 http://{www.whistlescript.com}/api/getBirthday若使用自定义 server,则请求将大致经历以下步骤:

  1. (假设使用 SwitchyOmega)SwitchyOmega 将请求代理至 Whistle;
  2. Whistle 将请求转发至 whistle.script;
  3. whistle.script 根据规则配置,读取 test 中编写的 handleRequest 函数并执行;
    • 将请求的 url 替换为 http://{127.0.0.1:8080}/api/getBirthday,然后送出;
    • 收到响应,检查发现 url 匹配 apiList 中的接口,则对响应内容进行重写;
    • 将重写后的响应返回;
  4. 浏览器收到重写后的响应,用于前端展示。

若使用自定义 pipe,则大致经历以下步骤:

  1. (假设使用 SwitchyOmega)SwitchyOmega 将请求代理至 Whistle;
  2. Whistle 根据请求替换规则,将请求的 url 替换为 http://{127.0.0.1:8080}/api/getBirthday
  3. Whistle 将请求转发至 whistle.script;
  4. whistle.script 根据规则配置,读取 test 中编写的 handleResRead 函数并执行;
    • 收到响应,检查发现 url 匹配 apiList 中的接口,则对响应内容进行重写;
    • 将重写后的响应返回;
  5. 浏览器收到重写后的响应,用于前端展示。

参考资料