本文将以 AREX 为例为大家介绍如何在 Web 端实现对各类 API 的调试功能。
在软件开发过程中,对于各类 API 的调试工作至关重要。API调试是验证和测试应用程序接口的有效性和正确性的关键步骤。传统的 API 调试方法通常依赖于独立的工具或桌面应用程序,限制了调试过程的灵活性和效率。
为推动 API 调试向更便捷、高效的方向发展,越来越多的开发人员开始寻求在纯 Web 端完成各类 API 调试的解决方案。纯 Web 端的 API 调试具有许多优势,包括无需安装额外软件、跨平台支持、便于团队协作等。本文将以开源项目 AREX 为例为大家介绍如何在 Web 端实现对各类 API 的调试功能。
关于 AREX
AREX(http://arextest.com/)是一款开源的基于真实请求与数据的自动化回归测试平台,利用 Java Agent 技术与比对技术,通过流量录制回放能力实现快速有效的回归测试。同时提供了接口测试、接口比对测试等丰富的自动化测试功能。
难点一:跨域限制
要想在纯 Web 端实现各类 API 的调试工作,首先要解决的难题是处理浏览器的跨域限制。
什么是跨域
浏览器跨域问题是指在 Web 开发中,当使用 JavaScript 代码从一个域名的网页访问另一个域名的资源时会遇到的限制。浏览器实施了一种安全策略,称为同源策略(Same-Origin Policy), 用于保护用户信息的安全。同源策略要求网页中的 JavaScript 只能访问与其来源(协议、域名和端口号)相同的资源,而对于不同域名的资源访问会受到限制。
由于浏览器存在跨域限制,我们不能在浏览器端随心所欲地发送 HTTP 请求,这是浏览器的安全策略决定的。
解决方案
经调研,突破此限制的方法有两种:分别是 Chrome 插件代理 和服务端代理,以下是两种方法的比较。
Chrome 插件代理 | 服务端代理 | |
---|---|---|
访问本地 | 可以 | 不可以 |
速度 | 无请求时间损耗 | 整个流程速度受代理接口影响 |
实际请求 | 已知 Origin 源会被修改为 Chrome 插件的源 | 完全一样 |
权衡下来 AREX 选择了 Chrome 插件代理的方法,其原理是利用了 Chrome 插件中 background 可以发送跨域请求的能力,我们将浏览器端拦截到的请求通过 window.postmassage
与 Chrome 插件的 background 进行通信(其中通信还需要 Chrome 插件的 content-script
作为数据桥梁)。
具体实现如下:
在页面脚本中
- 生成一个随机的字符串,并将其转换为字符串形式,存储在
tid
变量中。 - 使用
window.postMessage()
方法发送一条消息到其他扩展程序,消息包括一个类型为AREX_EXTENSION_REQUEST
的标识、tid、以及 params 参数。 - 添加一个
message
事件监听器receiveMessage
,用于接收其他扩展程序发送的消息。 - 在
receiveMessage
函数中,检查接收到的消息是否为类型为AREX_EXTENSION_RES
,并且 tid 与之前发送的消息的 tid 相匹配。如果匹配成功,则移除事件监听器。
- 生成一个随机的字符串,并将其转换为字符串形式,存储在
在内容脚本中
- 添加一个
message
事件监听器,用于接收来自页面脚本或其他扩展程序发送的消息。 - 在事件监听器中,检查接收到的消息是否为类型为
AREX_EXTENSION_REQUEST
,如果是,则使用chrome.runtime.sendMessage()
方法将消息发送给后台脚本。 - 在接收到来自后台脚本的响应后,使用
window.postMessage()
方法将响应消息发送回页面脚本或其他扩展程序。
- 添加一个
在后台脚本中
- 使用
chrome.runtime.onMessage.addListener()
方法添加一个监听器,用于接收来自内容脚本或其他扩展程序发送的消息。 - 在监听器中可以处理接收到的消息,并根据需要作出响应。
- 使用
// arex
const tid = String(Math.random());
window.postMessage(
{
type: '__AREX_EXTENSION_REQUEST__',
tid: tid,
payload: params,
},
'*',
);
window.addEventListener('message', receiveMessage);
function receiveMessage(ev: any) {
if (ev.data.type === '__AREX_EXTENSION_RES__' && ev.data.tid == tid) {
window.removeEventListener('message', receiveMessage, false);
}
}
// content-script.js
window.addEventListener("message", (ev) => {
if (ev.data.type === "__AREX_EXTENSION_REQUEST__"){
chrome.runtime.sendMessage(ev.data, res => {
// 与background通信
window.postMessage(
{
type: "__AREX_EXTENSION_RES__",
res,
tid:ev.data.tid
},
"*"
)
})
}
})
// background.js
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
})
难点二:API 调试
上述已经解决了跨域问题,接下来就是如何实现 API 调试的功能。
解决方案
Postman 是业内成熟的 API 调试工具,我们站在了 Postman 这位巨人的肩膀上,在 AREX 中引入了 Postman 的 JavaScript 沙盒,使用它的沙盒运行前置脚本、后置脚本以及断言来调试 API。
以下是 AREX 请求的流程图:
当点击发送请求的时候,会将表单中的数据汇聚到一起,数据结构为:
export interface Request {
id: string;
name: string;
method: string;
endpoint: string;
params: {key:string,value:string}[];
headers: {key:string,value:string}[];
preRequestScript: string;
testScript: string;
body: {contentType:string,body:string};
}
这是 AREX 的数据结构,我们会将其转换成 Postman 的数据结构。之后调用 PostmanRuntime.Runner()
方法,将转换好了的 Postman 数据结构和当前所选的环境变量传入,Runner 会执行 preRequestScript
和 testScript
脚本。preRequestScript
发生在请求之前,可以在其中穿插请求以及对请求参数、环境变量进行操作,testScript
发生在请求之后,可以对 response 返回数据进行断言操作,并且脚本中也可以通过 console.log
输出数据,在控制台进行调试。
var runner = new runtime.Runner(); // runtime = require('postman-runtime');
// 一个标准的postman集合对象
var collection = new sdk.Collection();
runner.run(collection, {}, function (err, run) {
run.start({
assertion:function (){}, //断言
prerequest:function (){}, // 预请求勾子
test:function (){}, //测试勾子
response:function (){} //返回勾子
});
});
在 Postman 沙盒中也存在跨域问题,由于 Postman 沙盒的集成度非常高,为了确保与 PostmanRuntime
的同步以及方便性,我们采用了 Ajax 拦截技术。通过在浏览器端拦截 Ajax 请求,我们可以对请求进行修改、添加自定义逻辑或者进行其他处理操作。这样可以实现对请求和响应的全局控制和定制化。
当 Postman 沙盒发送请求时,会携带一个名为 "postman-token" 的请求头。我们拦截到这个 Ajax 请求后,会将请求参数进行拼装,并通过 window.postMessage 发送给浏览器插件。浏览器插件再次构建 fetch 请求,将数据返回给 Postman 沙盒,使其输出最终结果,包括响应(response)、测试结果(testResult)和控制台日志(console.log)。需要注意的是,responseType 必须指定为 arraybuffer。
具体流程如下:
使用
xspy.onRequest()
方法注册一个请求处理程序。这个处理程序接受两个参数:request 和 sendResponse。request 参数包含请求的相关信息,例如方法、URL、头部、请求体等。sendResponse 是一个回调函数,用于发送响应给请求方。在处理程序中,通过检查请求的头部中是否存在
postman-token
来判断请求是否来自 Postman。如果存在该头部,表示请求是通过 Postman 发送的。则使用 AgentAxios 发起一个新的请求,使用原始请求的方法、URL、头部和请求体。AgentAxios 返回一个 agentData 对象,其中包含了响应的状态码、头部和数据等信息。创建一个名为
dummyResponse
的响应对象,包含了与原始请求相关的信息。dummyResponse 的 status 字段为 agentData 的状态码,headers 字段为将 agentData 的头部数组转换为对象格式的结果,ajaxType 字段为字符串 xhr,responseType 字段为字符串 arraybuffer,response 字段为将 agentData 的数据转换为 JSON 字符串并用 Buffer 包装的结果。最后,使用 sendResponse(dummyResponse) 将响应发送给请求方。如果请求不是来自 Postman,则直接调用 sendResponse(),表示不返回任何响应。
xspy.onRequest(async (request: any, sendResponse: any) => {
// 判断是否是pm发的
if (request.headers['postman-token']) {
const agentData: any = await AgentAxios({
method: request.method,
url: request.url,
headers: request.headers,
data: request.body,
});
const dummyResponse = {
status: agentData.status,
headers: agentData.headers.reduce((p: any, c: { key: any; value: any }) => {
return {
...p,
[c.key]: c.value,
};
}, {}),
ajaxType: 'xhr',
responseType: 'arraybuffer',
response: new Buffer(JSON.stringify(agentData.data)),
};
sendResponse(dummyResponse);
} else {
sendResponse();
}
});
难点三:二进制对象序列化传递
还有一点值得一提,对于 x-www-form-urlencoded
和 Raw
类型的请求,由于它们都是普通的 JSON 对象,处理起来比较容易。但是对于 form-data
和 binary
类型的请求,需要支持传输二进制文件负载。然而,Chrome 插件的 postMessage
通信方式不支持直接传递二进制对象,导致无法直接处理这两种类型的请求。
解决方案
为了解决这个问题,AREX 采用了 base64 编码技术。在用户选择文件时,AREX 会将二进制文件转换为 base64 字符串,然后进行传输。在 Chrome 插件端,AREX 会将 base64 数据进行解码,并用于构建实际的 fetch
请求。这样可以绕过直接传递二进制对象的限制。
这个流程图描述了将 FormData 中的二进制文件转换为 Base64 字符串,并通过 Chrome 插件代理将其转换回文件并进行进一步处理的过程。
- form-data binary(A):表示一个包含二进制文件的 FormData 表单数据。
- FileReader(B):使用 FileReader 对象来读取二进制文件。
- readAsDataURL base64 string:FileReader 使用 readAsDataURL 方法将二进制文件读取为 Base64 字符串。
- Chrome 插件代理(C):Base64 字符串经过读取操作后,传递给 Chrome 插件代理进行进一步处理。
- base64 string:表示经过 FileReader 读取二进制文件后得到的 Base64 字符串。
- Uint8Array(D):在 Chrome 插件代理中,将 Base64 字符串转换为 Uint8Array。
- File(E):使用 Uint8Array 的数据创建一个新的 File 对象。
- fetch(F):将新创建的 File 对象通过 fetch 方法或其他方式进行进一步处理,例如上传到服务器或进行其他操作。
代码分析
以下是代码层面的分析:
toBase64 函数接受一个 File 对象作为参数,并返回一个 Promise 对象,该 Promise 对象将解析为表示文件的 Base64 字符串。
在函数内部,创建了一个 FileReader 对象。 通过调用 reader.readAsDataURL(file) 将文件读取为 Data URL。 当读取操作完成时,通过 reader.onload 事件处理程序将读取结果解析为字符串,并使用 resolve 将其传递给 Promise。 如果发生错误,将使用 reject 将错误传递给 Promise。 base64ToFile 函数接受两个参数:dataurl(Base64 字符串)和 filename(文件名),并返回一个 File 对象。
首先,将 dataurl 使用逗号分割成数组 arr,如果分割结果为空,则将其设为包含一个空字符串的数组。 通过正则表达式匹配 arr[0] 中的内容,提取出 MIME 类型,即数据的类型。 使用 atob 将 Base64 字符串解码为二进制字符串 bstr。 创建一个长度为 n 的 Uint8Array 数组 u8arr。 使用循环遍历 bstr,将每个字符的 Unicode 编码放入 u8arr 中。 最后,使用 File 构造函数创建并返回一个新的 File 对象,其中包含了从 u8arr 中读取的文件数据、文件名和 MIME 类型。 导出 base64ToFile 函数,以便在其他地方使用。
// 文件转Base64
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
});
// base64转文件
function base64ToFile(dataurl: string, filename: string) {
const arr = dataurl.split(',') || [''],
mime = arr[0].match(/:(.*?);/)?.[1],
bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
export default base64ToFile;