接口调试这件事,真正让人头疼的往往不是请求发不出去,而是脚本写着写着就散了。团队里 A 同学前段时间在 Bruno 里调一个带签名的下单接口,pre-request 里生成了 nonce 和时间戳,post-response 里想拿这两个值去比对服务端回显,结果bru.getVar('nonce')死活返回 undefined;旁边另一位同事更直接,把同一段 HMAC 签名函数复制到了十几个请求里,改一次密钥要全局搜索替换十几处。这两个场景指向的是同一个问题:Bruno 脚本执行的层级和执行顺序没有理清,导致跨阶段代码共享变成了复制粘贴和玄学调试。Bruno 的脚本不是散落在每个请求里的孤立片段,它有一套明确的集合、文件夹、请求三层结构,每一层又分请求前和响应后两个阶段,再加上运行时变量、集合变量、环境变量这几套存储机制。只要把“代码放哪、变量存哪、什么时候能读到”这三件事理顺,跨阶段共享就是三步的事。这篇内容面向已经在用 Bruno 做接口调试、想把零散脚本整理成可复用资产的开发者,也适合刚接触 Bruno 脚本、被 undefined 折腾过的新手,下面按我实际踩坑的顺序展开。
1. 先把“跨阶段”这件事拆明白:Bruno 脚本到底分几层
1.1 三层脚本加两个阶段,执行顺序决定一切
Bruno 的脚本挂载点不是随机的,它跟着集合的目录结构走。最外层是集合,集合设置里可以写 Pre Request Script 和 Post Response Script;中间层是文件夹,文件夹设置里同样有这两个脚本入口;最内层是单个请求,请求编辑页的 Script 标签下分 Pre Request 和 Post Response,另外还有一个 Tests 标签专门放断言。这三层乘两个阶段,构成了一个固定的执行管道。理解这个管道的顺序,比记住任何一个 API 都重要,因为跨阶段共享的本质就是“前一个环节写进去的东西,后一个环节能不能读到”。
我把官方文档里的顺序和实际运行表现对照过,整理成下面这张表。注意这是请求发出前后的完整链路,不是按 UI 里标签页从左到右的顺序:
| 执行序号 | 脚本位置 | 执行时机 | 典型职责 |
|---|---|---|---|
| 1 | Collection Pre Request | 请求发出前,最外层先跑 | 初始化公共变量、生成整轮运行的标识 |
| 2 | Folder Pre Request | 请求发出前,文件夹层 | 模块级参数准备、模块内鉴权头 |
| 3 | Request Pre Request | 请求发出前,请求层 | 生成签名、nonce、改写请求体 |
| 4 | 请求实际发送 | 网络层 | 携带最终 header 和 body |
| 5 | Request Post Response | 响应返回后,请求层先跑 | 解析响应、提取 token、写回变量 |
| 6 | Folder Post Response | 响应返回后,文件夹层 | 模块级清理、模块内统计 |
| 7 | Collection Post Response | 响应返回后,最外层最后跑 | 全局日志、整轮结果汇总 |
这张表里最容易被忽略的是第 5 步和第 6、7 步的关系:请求层的 Post Response 先执行,然后才轮到文件夹层和集合层。这意味着你在集合级 Post Response 里读取请求级脚本刚写下的变量,是能读到的;反过来,在集合级 Pre Request 里想读取请求级 Pre Request 才生成的值,就一定读不到,因为顺序反了。很多“变量莫名其妙是 undefined”的问题,根子就在这个顺序上,跟 API 用错没关系。
Tests 标签和 Post Response 处在同一个响应后阶段,通常 Post Response 先跑、Tests 后跑,但我不建议你在两者之间做严格的值传递依赖。该用变量就用变量,别赌执行顺序,这是我在不同版本之间来回切换后得到的教训。Tests 的定位就是断言,把“准备数据”和“校验数据”分开,后面排查问题时你会感谢自己做了这个区分。
1.2 为什么 pre-request 里的变量在 post-response 里总是 undefined
先说结论:绝大多数情况不是变量没写进去,而是写错了作用域,或者写入的时机和你以为的不一样。Bruno 里跟脚本相关的变量至少有五套:运行时变量(bru.setVar/bru.getVar)、集合变量(bru.setCollectionVar/bru.getCollectionVar)、文件夹变量(bru.setFolderVar/bru.getFolderVar)、环境变量(bru.setEnvVar/bru.getEnvVar)、请求变量(bru.setRequestVar/bru.getRequestVar)。这五套的作用域和生命周期完全不同,名字又长得像,新手很容易随手抓一个就用。
一个典型的错误链路是这样的:在请求的 Pre Request 里用bru.setRequestVar('nonce', ...)写入,然后在同一个请求的 Post Response 里用bru.getVar('nonce')去读。写入用的是请求变量,读取用的是运行时变量,两个命名空间根本不互通,返回 undefined 是必然的。还有一种情况是写入和读取都用了bru.setVar和bru.getVar,但请求是手动逐个点击发送的,运行时变量在两次独立发送之间没有保留,同样读不到。
我的建议是把“同一请求内 pre 到 post 的传递”和“跨请求的传递”当成两类问题处理。前者用运行时变量最轻量,一个请求的生命周期内它一定在;后者优先用集合变量,并且明确要不要持久化。环境变量留给 baseUrl、账号、密钥这类跟环境绑定的配置,不要拿它当临时缓存用,否则环境文件会被写得乱七八糟,提交到仓库还容易泄露敏感信息。
还有一个隐蔽的坑:Bruno 在发送请求时会对{{var}}做插值,但插值发生在脚本执行之后还是之前,取决于你插值的位置。在 Pre Request 脚本里用req.setHeader('X-Nonce', '{{nonce}}')这种写法,不一定会拿到你刚setVar的值,因为脚本里的字符串不会自动走一遍插值流程。稳妥的写法是直接拼字符串,或者在脚本里显式调用bru.interpolate('{{nonce}}')拿到插值后的结果。这个细节文档里不一定显眼,但实测非常关键。
2. 第 1 步:搭好集合级公共脚本底座
2.1 打开 bruno.json 的文件系统访问与模块白名单
跨阶段共享代码,第一步要解决的是“代码放在哪”。如果每个请求都内联一段签名函数,那共享就无从谈起。Bruno 允许脚本通过require加载本地文件和部分内置模块,但这个能力默认是收紧的,需要在集合根目录的bruno.json里显式打开。这个文件是集合的元信息描述,跟着集合一起走,提交到版本库之后团队成员拉下来就能用同一套配置。
下面是我在一个调试集合里用的配置,字段不多,但每个都有用:
{ "version": "1", "name": "订单服务调试集合", "type": "collection", "scripts": { "moduleWhitelist": ["crypto"], "filesystemAccess": { "allow": true } } }moduleWhitelist是模块白名单,只有列在里面的模块才允许被脚本require。这里只放了crypto,因为我只需要用它做 HMAC 签名和随机串生成。白名单的原则是最小化,用不到的一律不加,这既是安全考虑,也是给自己省事——白名单越长,换机器、换版本时排查“为什么这个模块加载不了”的成本越高。
filesystemAccess.allow打开之后,脚本才能通过相对路径读取集合目录下的 JS 文件。这个开关的粒度是集合级的,也就是说一旦打开,集合内所有层级的脚本都能读文件。所以公共脚本目录的命名要清晰,别把业务请求的 json 样例和工具函数混在一起。我习惯在集合根目录下建一个scripts文件夹,只放工具代码和断言封装,请求文件按业务模块分文件夹管理,两边互不干扰。
配置改完之后需要重启一下 Bruno 或者重新打开集合,让配置生效。这一步经常被跳过,然后对着Cannot find module的报错怀疑人生。我的做法是改完配置先随便写一行require('./scripts/utils.js')跑一下,确认能加载再往下写业务逻辑,把环境问题和代码问题分开定位。
2.2 写一个独立 utils.js:签名、随机串、时间格式化
公共脚本文件用 CommonJS 的写法导出,Bruno 的脚本沙箱认这个格式。下面这份是我常用的最小工具集,覆盖了签名、随机串、时间戳三个最高频的需求,你可以直接抄过去改成自己项目的算法:
const crypto = require('crypto'); function sign(payload, secret) { return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); } function randomNonce(size = 16) { return crypto.randomBytes(size).toString('hex'); } function timestamp() { return Date.now().toString(); } function stableStringify(obj) { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj); } const keys = Object.keys(obj).sort(); const parts = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`); return `{${parts.join(',')}}`; } module.exports = { sign, randomNonce, timestamp, stableStringify };重点说几个设计上的取舍。第一,sign的 payload 我强制用字符串传进来,不在函数内部做类型判断,因为签名算法对拼接顺序和编码极其敏感,把“怎么拼”交给调用方显式控制,出问题时更容易对照服务端文档逐字节排查。第二,stableStringify是为了解决 JSON 对象 key 顺序不稳定导致签名对不上的问题,服务端如果按字典序拼接待签串,客户端就必须用同样的规则序列化,这个函数就是干这个的。第三,随机串用randomBytes而不是Math.random,前者是密码学安全的随机源,后者在并发场景下有碰撞风险,nonce 一旦重复,服务端的风控可能直接拒掉请求。
这里有个实操细节值得单独提醒:crypto是 Node 的内置模块,但在 Bruno 的脚本沙箱里它不会自动可用,必须同时满足“在moduleWhitelist里”和“脚本运行在支持 require 的环境”这两个条件。如果你发现require('crypto')报错,先查白名单,再查 Bruno 版本,最后才怀疑代码。另外,不同版本对白名单字段的解析可能有差异,改完配置记得用一行简单的console.log验证,控制台在开发者工具里看,快捷键跟浏览器一致。
2.3 在集合级脚本里做统一初始化与兜底
公共函数有了,接下来是在集合级脚本里做一次性的初始化。集合级 Pre Request 是最外层的执行入口,适合放“整轮运行只做一次”的逻辑,比如生成一个运行标识、检查关键环境变量是否存在、打印当前环境名方便排查。这些逻辑如果散落到每个请求里,既冗余又容易漏。
我在集合的collection.bru里通常写这么一段:
const { randomNonce } = require('./scripts/utils.js'); if (!bru.getVar('runId')) { bru.setVar('runId', randomNonce(8)); } const baseUrl = bru.getEnvVar('baseUrl'); if (!baseUrl) { throw new Error('环境变量 baseUrl 未配置,请先选择正确的环境'); } console.log('[collection] runId =', bru.getVar('runId'), 'baseUrl =', baseUrl);这段代码做了三件事。第一,用runId标记这一轮运行,后面所有请求的日志里都带上它,串联调用链时非常有用,尤其是并发跑多个请求的时候,日志混在一起也能靠这个标识区分。第二,对baseUrl做存在性校验,缺失时直接抛错中断,避免请求发到一个拼接错误的地址上,得到一堆看不懂的 404。第三,打一行启动日志,确认集合级脚本确实执行了,这一步在排查“脚本到底跑没跑”的问题时能省很多时间。
这里要强调throw new Error的行为:在 Pre Request 脚本里抛错,请求会被中断,不会发出去。这比发出去再断言失败要高效得多,因为无效请求打到服务端可能产生脏数据。对于必须满足的前置条件,我倾向于在集合层就拦住,而不是等到请求层再处理。反过来,如果你只是想在日志里提示一下、不影响请求发送,那就用console.log或console.warn,别用 throw。
集合级 Post Response 则适合做收尾,比如统计这一轮跑了多少个请求、有没有失败、把关键结果汇总成一行日志。注意它是在请求级 Post Response 之后执行的,所以能读到请求级脚本写下的运行时变量。我一般会在这里打印一个简单的汇总,配合前面的runId,在开发者工具里一眼就能看出整轮运行的轮廓。
3. 第 2 步:用变量把 Pre-Request 与 Post-Response 串成一条链
3.1 nonce 加签名的完整传递示例
这一步是整篇的核心场景:请求前生成签名材料,请求后拿服务端回显做校验。这是跨阶段共享最典型也最容易出错的用法,把它跑通,其他场景都是它的变体。下面是一个完整的请求脚本,假设服务端要求X-Timestamp、X-Nonce、X-Signature三个头,签名规则是时间戳加 nonce 加请求体的 HMAC-SHA256。
Pre Request 脚本:
const { sign, randomNonce, timestamp, stableStringify } = require('./scripts/utils.js'); const secret = bru.getEnvVar('appSecret'); if (!secret) { throw new Error('环境变量 appSecret 未配置'); } const nonce = randomNonce(); const ts = timestamp(); const body = req.getBody(); const bodyStr = typeof body === 'string' ? body : stableStringify(body); const payload = `${ts}${nonce}${bodyStr}`; const signature = sign(payload, secret); req.setHeader('X-Timestamp', ts); req.setHeader('X-Nonce', nonce); req.setHeader('X-Signature', signature); bru.setVar('req_nonce', nonce); bru.setVar('req_ts', ts); bru.setVar('req_signature', signature);Post Response 脚本:
const nonce = bru.getVar('req_nonce'); const ts = bru.getVar('req_ts'); test('响应中回显的 nonce 与请求一致', function () { expect(res.getHeader('X-Nonce')).to.equal(nonce); }); test('响应中回显的时间戳与请求一致', function () { expect(res.getHeader('X-Timestamp')).to.equal(ts); });这段代码里有几个关键点值得展开。req.getBody()拿到的可能是字符串也可能是对象,取决于请求体的定义方式,所以我用typeof做了一次判断,再用stableStringify统一序列化,保证签名用的字符串和服务端算的是同一份。req.setHeader是直接改请求头,改完之后请求才会带上,这一步必须在脚本里完成,不能放到 Tests 里,因为 Tests 执行时请求已经发出去了。
bru.setVar写入了三个值,Post Response 里通过bru.getVar读取。这三个变量属于运行时变量,作用域是当前这一次运行。同一个请求的 pre 和 post 之间,它一定可读,这是最稳的一档。如果你把读取逻辑放到集合级 Post Response 里,同样能读到,因为集合级 post 在请求级 post 之后执行。
还有一个容易忽略的细节:res.getBody()在响应是 JSON 时会返回解析后的对象,在响应是文本时返回字符串。如果服务端把 nonce 放在 body 里而不是 header 里,你就得先确认res.getBody()的类型再取值,否则会对一个字符串做属性访问,得到 undefined,然后误以为变量没传过来。我的习惯是在 Post Response 开头先打一行console.log(typeof res.getBody(), res.getStatus()),确认类型再写断言,调试完再把这行删掉或者注释掉。
3.2 变量选择对照表:到底该用 setVar 还是 setCollectionVar
变量选错是跨阶段共享失败的第一大原因。我把五套变量的差异整理成表,写脚本时对照着看,能避开大部分坑:
| 变量类型 | 写入 API | 读取 API | 作用域与生命周期 | 是否可持久化 | 典型用途 |
|---|---|---|---|---|---|
| 运行时变量 | bru.setVar | bru.getVar | 一次运行内,跨请求可用 | 否 | nonce、签名、临时上下文 |
| 集合变量 | bru.setCollectionVar | bru.getCollectionVar | 集合级,跨请求、跨运行 | 可选 | 登录 token、公共 appId |
| 文件夹变量 | bru.setFolderVar | bru.getFolderVar | 文件夹级 | 可选 | 模块级参数 |
| 环境变量 | bru.setEnvVar | bru.getEnvVar | 环境级,跟随环境切换 | 可选 | baseUrl、密钥、账号 |
| 请求变量 | bru.setRequestVar | bru.getRequestVar | 单请求内 | 否 | 请求内一次性临时值 |
选择逻辑其实很简单:只在同一个请求的 pre 和 post 之间传,用运行时变量;要给后续请求用,用集合变量;跟环境绑定的配置,用环境变量;真的只在一个请求内部用一次,才用请求变量。请求变量是最容易被误用的,很多人以为它是“请求级”的所以更安全,结果在 post 里换个 API 读就失效了。我的经验是,除非你非常确定生命周期,否则优先运行时变量和集合变量,这两个的行为最直观。
持久化要单独说一下。setCollectionVar和setEnvVar都支持传{ persist: true }把值写回文件,方便下次打开还在。这个能力很诱人,但用在 token 上要极其小心,因为写回文件意味着 token 会落到磁盘上,如果这个集合被提交到版本库,token 就跟着进去了。我的做法是:敏感数据一律不持久化,只放运行时或集合内存变量;确实需要跨会话保留的,用环境变量并且在.gitignore里排除环境文件,同时跟团队约定清楚哪些字段不能提交。
3.3 手动点击和 Runner 运行的差异,最容易踩的一脚
这个坑我踩过不止一次。Bruno 里逐个点击请求发送,和使用 Collection Runner 批量运行,脚本的执行上下文并不完全一样。Runner 模式下,整批请求属于同一次运行,运行时变量从头到尾共享;手动逐个点击时,每次发送是一个独立的动作,运行时变量能不能跨请求保留,不同版本的表现可能不同,我不建议把它当成稳定的契约。
具体表现是这样的:你在登录请求的 Post Response 里用bru.setVar('token', ...)存了 token,然后手动点开下一个请求,Pre Request 里bru.getVar('token')读到空值。这时候你会以为是脚本写错了,其实是两次独立发送之间上下文没接上。解决办法有两个,一是把 token 写到集合变量里,二是用 Runner 把登录和后续请求串成一批跑。前者适合调试阶段随手点,后者适合做完整的链路验证,我通常两个都用,调试用集合变量保证能读到,正式回归用 Runner 保证顺序。
还有一个相关的细节是bru.setNextRequest。这个 API 用来指定下一个要执行的请求,但它只在 Runner 模式下生效,手动点击时不起作用。所以如果你想做“token 失效就跳回登录”这种逻辑,必须用 Runner 验证,手动点是看不出效果的。写这种跳转逻辑时,记得在跳转前把当前请求的失败原因打出来,否则 Runner 跑完只看到一个跳转结果,不知道是哪个环节触发的。
4. 第 3 步:把共享逻辑沉淀成可复用脚本资产
4.1 登录态 token 的跨请求链式传递
单请求内的 pre 到 post 打通之后,下一步是把能力扩展到请求之间。最典型的场景就是登录拿 token,后续请求带 token。这个链路如果每次手工复制,维护成本会随着接口数量线性上升。我的做法是在登录请求的 Post Response 里统一写 token,在集合级 Pre Request 里统一读 token 并设置请求头,这样后续请求什么都不用写,自动带上了鉴权信息。
登录请求的 Post Response:
const body = res.getBody(); if (res.getStatus() === 200 && body && body.token) { bru.setCollectionVar('accessToken', body.token); bru.setVar('tokenExpireAt', Date.now() + (body.expiresIn || 3600) * 1000); console.log('[auth] token 已更新,长度', String(body.token).length); } else { console.warn('[auth] 登录响应未包含 token,状态码', res.getStatus()); }集合级 Pre Request 里补上读取和兜底:
const token = bru.getCollectionVar('accessToken'); const expireAt = bru.getVar('tokenExpireAt'); if (token) { if (expireAt && Date.now() > Number(expireAt)) { console.warn('[auth] token 可能已过期,建议重新执行登录请求'); } req.setHeader('Authorization', `Bearer ${token}`); }这里有个顺序问题要注意:集合级 Pre Request 在所有请求之前执行,包括登录请求本身。登录请求还没拿到 token 的时候,这段代码读到的是空值,不会设置 Authorization 头,这是符合预期的,因为登录接口本来就不需要带 token。等登录跑完,token 写进集合变量,后续请求的集合级 Pre Request 就能读到了。整个链路靠的是集合变量的跨请求可见性,而不是脚本之间的直接调用,这样解耦最彻底。
关于过期判断,我只做提示不做强制拦截。原因是本地调试时 token 过期很常见,直接抛错会打断调试节奏,打个警告让人知道可能要重新登录就够了。如果是 CI 环境跑回归,可以在集合级脚本里加一个开关变量,CI 环境下把警告升级成错误,本地环境保持宽松。这种“行为随环境切换”的设计,比在每个脚本里写 if-else 要干净得多。
4.2 用文件夹级脚本做模块化隔离
当集合里的接口多起来之后,不同业务模块的前置逻辑会打架。比如订单模块需要额外的商户号请求头,用户模块不需要;如果全写在集合级 Pre Request 里,就得用一堆条件判断区分。这时候文件夹级脚本就派上用场了,它只在所属文件夹内的请求执行前触发,天然做了作用域隔离。
目录结构大概是这样:
order-api/ ├── bruno.json ├── collection.bru ├── scripts/ │ ├── utils.js │ └── assertions.js ├── auth/ │ ├── folder.bru │ └── Login.bru └── order/ ├── folder.bru └── CreateOrder.bruorder/folder.bru里可以写订单模块专属的逻辑:
const merchantId = bru.getEnvVar('merchantId'); if (!merchantId) { throw new Error('订单模块缺少 merchantId 环境变量'); } req.setHeader('X-Merchant-Id', merchantId);这段逻辑只会对order文件夹下的请求生效,auth文件夹不受影响。执行顺序上,集合级 Pre Request 先跑,然后是这个文件夹级脚本,最后是请求自己的 Pre Request。所以请求级脚本可以覆盖文件夹级设置的 header,反过来不行。利用这个特性,我通常把“模块内绝大多数请求都要用的”放文件夹级,“个别请求特殊处理的”放请求级,层次清晰,改起来也快。
文件夹级 Post Response 适合做模块内的收尾,比如统计订单模块这一批请求的成功率。需要注意的是,文件夹级脚本抛错同样会中断该文件夹内请求的发送,所以校验逻辑要拿捏好粒度,别把“建议检查”写成“必须阻断”。我见过有同事在文件夹级脚本里对所有响应做严格断言,结果一个本来可以容忍的警告状态码把整批请求都卡住了,排查了半天才发现是脚本太严格。
4.3 断言函数复用:把 expect 封装成业务断言
跨阶段共享不只有请求前和响应后之间的数据传递,还包括断言逻辑的复用。每个请求都写一遍expect(res.getStatus()).to.equal(200)和字段校验,代码重复度高,而且业务规则一变就要全改。把常用断言封装成函数,放到公共脚本里,是性价比很高的一步。
function assertStatus(res, expected) { expect(res.getStatus()).to.equal(expected); } function assertJsonField(res, path, expected) { const body = res.getBody(); const actual = path.split('.').reduce((acc, key) => { if (acc === null || acc === undefined) return undefined; return acc[key]; }, body); expect(actual).to.equal(expected); } module.exports = { assertStatus, assertJsonField };在请求的 Tests 里这样用:
const { assertStatus, assertJsonField } = require('./scripts/assertions.js'); test('下单接口返回成功', function () { assertStatus(res, 200); assertJsonField(res, 'code', 0); assertJsonField(res, 'data.orderStatus', 'CREATED'); });这里有个需要提前验证的点:expect是 Bruno 注入到脚本全局的,被require加载的模块能不能访问到这个全局,取决于沙箱的实现方式。有的版本里可以,有的版本里不行。稳妥的做法是先跑一次,如果报expect is not defined,就把断言函数改成返回布尔值和实际值,在 test 回调里再做 expect:
function checkJsonField(res, path, expected) { const body = res.getBody(); const actual = path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), body); return { pass: actual === expected, actual }; }然后在 Tests 里:
test('订单状态正确', function () { const result = checkJsonField(res, 'data.orderStatus', 'CREATED'); expect(result.actual).to.equal('CREATED'); });这种写法多一行,但完全不依赖全局注入,跨版本更稳。我的建议是,公共脚本里的函数尽量只做纯数据处理,把断言留给 Tests 层,这样职责清晰,也避免了沙箱全局变量带来的不确定性。
5. 常见问题与排查实录
5.1 脚本不执行、变量取不到:速查表
脚本相关的问题,现象往往很相似,但原因分散在配置、作用域、执行顺序几个层面。我把实际遇到过的高频问题整理成表,按现象查原因,比从头读文档快得多:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 脚本完全没执行 | 脚本写在了错误的标签页,或集合配置未生效 | 在脚本首行加console.log,打开开发者工具确认 |
| 变量读取为 undefined | 写入和读取用了不同的变量 API | 对照变量表,确认 set 和 get 是同一套 |
| 跨请求读不到变量 | 用了运行时变量但两次发送是独立动作 | 改用集合变量,或用 Runner 批量运行 |
require报模块找不到 | 模块不在moduleWhitelist里 | 检查bruno.json,加白名单后重开集合 |
require报文件找不到 | 相对路径基准不是当前文件所在目录 | 统一以集合根目录为基准写路径 |
| 签名和服务端对不上 | 待签串拼接顺序或序列化方式不一致 | 在脚本里打印待签串,和服务端日志逐字节比对 |
| 断言全部失败但请求成功 | res.getBody()类型不是预期的对象 | 打印typeof res.getBody()和实际内容 |
这张表里最值得展开的是“签名对不上”这一条。签名问题的排查成本很高,因为服务端通常只返回一个“签名错误”,不告诉你哪一位不对。我的做法是在客户端把待签串完整打印出来,同时让服务端在测试环境把收到的待签串也打出来,两边做字符串比对。差异往往出现在三个地方:JSON 序列化的 key 顺序、空值的处理方式、以及中文或特殊字符的编码。stableStringify解决第一个,显式的空值约定解决第二个,统一用 UTF-8 解决第三个。
5.2 require 报错与路径解析的坑
require相关的报错分两类,一类是模块被拦截,一类是路径找不到。模块被拦截的报错信息通常比较明确,会提示模块不在允许列表里,这时候去bruno.json的moduleWhitelist里加上就行。要注意的是,加完之后需要重新加载集合,热更新不一定生效。路径找不到的报错就没那么友好了,尤其是嵌套文件夹里的脚本,相对路径的基准很容易搞混。
我的做法是所有require都从集合根目录开始写,比如require('./scripts/utils.js'),不管这个脚本写在多深的文件夹里,路径都保持一致。这样虽然看起来不够“相对”,但胜在稳定,复制脚本到别的请求里也不用改路径。如果你们的集合结构比较深,可以在集合根目录放一个index.js做统一出口,所有工具函数从这里导出,脚本里只需要require('./index.js'),路径问题一次性解决。
还有一个容易被忽略的点是缓存。Bruno 在同一个运行上下文里对require的模块做缓存,这意味着你在脚本运行期间修改了utils.js的内容,正在跑的那一批请求可能还是用旧版本。遇到“改了代码没生效”的情况,重新加载集合或者重启应用,别急着怀疑人生。这个行为跟 Node 的模块缓存是一致的,理解之后就不算坑了。
5.3 版本兼容与团队协作注意事项
Bruno 的脚本能力在持续演进,不同版本之间在模块加载、变量 API、执行顺序的细节上可能有差异。团队协作时,最稳妥的做法是把集合的bruno.json和脚本文件都提交到版本库,环境文件单独管理并且排除在版本库之外。这样每个人拉下来的脚本行为是一致的,差异只在于自己本地的环境配置。
我建议在集合根目录放一个简短的说明文件,写清楚三件事:需要配置哪些环境变量、脚本依赖哪些模块白名单、运行时变量和集合变量分别承担什么职责。这三件事写清楚,新人接手时就不用逐个脚本去猜。尤其是变量职责,如果没人说明,很容易出现两个人用同一个变量名存不同含义的情况,排查起来非常痛苦。
另外,敏感信息的管理要提前定规矩。appSecret这类密钥放环境变量,环境文件不进版本库;token 这类临时凭证只放内存变量,不持久化;如果确实需要在 CI 里跑,用进程环境变量注入,在脚本里通过bru.getProcessEnv读取,避免把密钥写进任何会被提交的文件。这条规矩定得越早,后面越省心。
5.4 一个容易忽略的调试习惯
最后说一个纯经验性的点:脚本出问题时,先在关键位置打日志,再改代码。很多人习惯直接改逻辑,改完还是不对,然后陷入反复试错的循环。正确的顺序是先用console.log确认每个变量的实际值和类型,确认执行到了哪一步,再针对性地改。开发者工具里的控制台是唯一能实时看到脚本输出的地方,把它用起来,排查效率会高一个量级。
日志也有讲究,别只打值,把变量名和上下文一起打出来,比如console.log('[pre] nonce =', nonce, 'ts =', ts)。批量运行时日志会很多,加上请求名前缀和runId,事后翻日志能快速定位。调试完成后把日志删掉或者收敛成一行汇总,别让日志噪音淹没真正的错误信息。这个习惯看起来小,但在我处理过的脚本问题里,至少一半是因为日志打得到位,五分钟就定位了。
我在实际项目里把这套结构跑了大半年,最大的体会是:Bruno 脚本的复杂度不在 API 有多少,而在变量作用域和执行顺序的组合。把公共函数收到scripts目录、把跨请求数据收到集合变量、把断言和数据处理分开,这三件事做完之后,新增一个接口基本就是写几行请求配置的事,脚本部分几乎不用动。如果你现在还在每个请求里复制签名函数,建议先从第 1 步的bruno.json配置开始改,改完先拿一个请求验证,跑通之后再逐步迁移其他接口,别一次性全改,那样出了问题不好定位。