ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Bruno脚本执行模型与跨阶段代码共享实践

Bruno脚本执行模型与跨阶段代码共享实践

1. 先摸清 Bruno 的脚本执行模型,别再靠猜

很多人第一次在 Bruno 里写脚本,习惯性地把 Pre Request 和 Post Response 当成两个互不相干的小编辑器:上面算个签名,下面写个断言,中间靠 copy-paste 撑着。等到接口一多,签名逻辑复制了七份,时间戳格式改了就要挨个改,这时候才开始怀念那种"一份代码到处用"的日子。这篇内容就是解决这个问题的——Bruno 脚本执行怎么分层、跨阶段的代码共享怎么落地、共享层怎么维护才不至于越写越乱。

先给没接触过的朋友交代一句:Bruno 是一款离线优先的 API 调试工具,它的集合直接以文件夹和.bru文本文件的形式存在本地,所以天然适合进版本库。它在请求前后各挂了一个脚本入口,用 JavaScript 写,能读请求、改请求、读响应、做断言,还能在多个请求之间传递变量。这套机制用好了,一个集合可以当成一个轻量级的自动化测试工程来使;用不好,就是一堆复制粘贴的碎片。适合谁看:已经在用 Bruno 调接口、写断言,但脚本开始互相抄的;想把接口调试链做成可回归的;以及单纯想知道"pre 里算的东西为什么 post 里拿不到"的人。

我先把结论亮出来:Bruno 的跨阶段代码共享,本质是两件事——数据共享和逻辑共享。数据靠运行时变量池传递,逻辑靠集合级脚本和本地模块复用。绝大多数人卡住的,是把这两件事混成一件,试图直接把一个函数对象从 pre 递到 post,然后发现对面是undefined。

1.1 脚本到底有几个落点,谁先谁后

Bruno 的脚本不是只有一个地方能写。按作用范围从小到大排,大致有这么几层:

  • 集合级脚本:挂在 Collection 上,整个集合里每个请求执行时都会先跑它的 Pre Request 部分,请求结束后跑 Post Response 部分。这是放公共逻辑最合适的位置。
  • 文件夹级脚本:挂在 Collection 内部的某个文件夹上,只对文件夹里的请求生效。适合按业务域切分,比如"订单域"用一套鉴权初始化。
  • 请求级脚本:单个请求自己的 Pre Request 和 Post Response,最常用的落点。
  • Tests:它和 Post Response 属于同一阶段,只是 Bruno 在界面上给出了一块更聚焦断言的编辑区,执行时机上没有本质差异。

执行顺序按嵌套由外向内:集合级 Pre → 文件夹级 Pre → 请求级 Pre → 真正把请求发出去 → 请求级 Post(含 Tests)→ 文件夹级 Post → 集合级 Post。这个顺序的意义在于,越靠外的层越适合做"环境准备"和"全局收尾",越靠内的层越适合做"这个请求特有的活儿"。

注意:文件夹级脚本在较新版本中支持得比较完整,老版本可能只有集合级和请求级两层。你手上是什么版本,先在集合设置里点一遍确认,别照着别人的目录结构硬套,然后怀疑自己配置错了。

理解这个顺序有个很实际的用处:你可以在集合级 Pre 里统一注入追踪头、统一生成请求 ID,在请求级 Pre 里只做业务参数签名,职责清清楚楚。而不是每个请求开头都复制三行req.setHeader('X-Trace', ...)。

1.2 三个变量作用域,用错就是"值明明写了却读不到"

这是跨阶段共享的第一道坎,我见过太多人在这里绕圈。Bruno 里常打交道的作用域有这么几个:

作用域写法示例生命周期是否落盘典型用途
环境变量bru.getEnvVar/bru.setEnvVar跟随所选环境会写回环境配置baseURL、密钥占位、开关
集合变量bru.getCollectionVar/bru.setCollectionVar跟随集合会写回集合文件跨请求复用的 token、缓存 ID
运行时变量bru.setVar/bru.getVar本次会话或本次 runner 运行不落盘单条链路的中间结果、签名原文

关键点在最后一列。运行时变量才是跨阶段共享最顺手的那把钥匙:它在 Pre Request 里写、在 Post Response 里读,同一个请求的两个阶段共享同一个池子,不污染环境配置,也不会跑完一次之后把敏感值留在文件里。

反过来,用环境变量传中间值就是自找麻烦。第一,它会落盘,签名原文、临时 token 全进了配置文件,一不小心提交到仓库;第二,它在 runner 批量执行时会跨 iteration 残留,上一轮的脏数据影响下一轮;第三,改环境变量的心智负担比想象中大,尤其是多人协作时,别人拉下来发现环境里多了一堆看不懂的键。

所以我的建议很明确:配置类的东西进环境变量,跨请求的业务状态进集合变量,单次请求内部的中间结果进运行时变量。三条线不要互相串。

还有一个容易忽略的点:这些变量池里存的都是可序列化的数据。字符串、数字、布尔、普通对象、数组,都没问题。但你要是想把一个函数塞进去,指望对面读出来直接调用,那是不行的——跨脚本边界传递函数对象,取决于运行时的隔离策略,不同版本表现不一样。稳妥的做法是:函数定义在它该在的地方,变量池里只放函数算出来的结果。

2. 三步搭起跨阶段共享的骨架

把模型理清楚之后,落地就三步:收敛公共逻辑、打通阶段握手、让结果回流。这三步不是并列的三个技巧,而是有先后依赖的——先把公共逻辑收敛了,握手才有东西可握;握手通了,回流才有意义。

2.1 第一步:把公共函数收敛到集合级,而不是散落在每个请求里

最常见的乱象是这样的:一个集合里八个请求要签名,每个请求的 Pre Request 里都躺着一段一模一样的 HMAC 计算,二十行。某天密钥的拼接规则从secret + payload改成payload + secret,你得改八处,改漏一处就等着线上联调时对不上。

收敛的做法有两条路,看你的团队习惯选。

路线 A:集合级脚本里集中定义,请求级按需调用。把公共函数写在 Collection 的 Pre Request 脚本里,请求级脚本里直接调。这条路的好处是零配置、纯 Bruno 内部解决;局限是脚本隔离策略不同版本有差异,函数能不能被内层直接看见,需要你自己验证一遍——写个console.log(typeof myFn)打出来看,比查文档快。

路线 B:把公共逻辑抽成本地模块,脚本里require。Bruno 的集合本身就是一个文件夹,你完全可以在集合根目录放一个scripts/目录,里面写纯 JavaScript 模块,脚本里用相对路径引进来。较新版本对require内置模块和本地文件的支持比较友好,配合集合级的依赖声明还能用第三方包。

// scripts/sign.js —— 与 .bru 文件放在同一集合目录下 const crypto = require('crypto'); function hmacSha256(secret, payload) { return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); } function stamp() { return String(Date.now()); } function nonce(len = 8) { return Math.random().toString(36).slice(2, 2 + len); } module.exports = { hmacSha256, stamp, nonce };
// 请求级 Pre Request Script const { hmacSha256, stamp } = require('./scripts/sign.js'); const ts = stamp(); const payload = JSON.stringify({ sku: 'A-001', qty: 2, ts }); req.setHeader('X-Ts', ts); req.setHeader('X-Sign', hmacSha256(bru.getEnvVar('APP_SECRET'), payload)); req.setBody(payload); // 关键:把实际发出去的那份字符串存起来,Post 阶段要回放 bru.setVar('sent_payload', payload);

我倾向路线 B,理由很实在:模块是纯 JS 文件,可以被单元测试覆盖,可以在编辑器里跳转、可以被 lint,改一处全集合生效。而挂在集合脚本里的函数,你只能在 Bruno 的界面里读,出了编辑器就没有工具链支持。

实操心得:scripts/目录一定要跟.bru文件一起进版本库,但密钥不要进。签名函数里从环境变量取值,环境文件本身加进.gitignore,只提交一份带占位符的示例环境。这条规矩能省掉很多"我这跑得好好的你那报 401"的对话。

2.2 第二步:用运行时变量做阶段间的"握手"

握手这件事,说穿了就是一句话:Pre 阶段把 Post 阶段需要的信息写进变量池,Post 阶段按约定读出来。

但这里有个高频误解必须先破除:很多人在 Pre 里算完签名,只做了bru.setVar('sign', sign),然后就以为请求带上了签名,结果接口返回 401,回头查了半天签名算法,其实问题在于他只存了变量,没有改请求。变量池是给脚本之间传递用的,请求发出去用的是什么,取决于你对req对象做了什么。

所以 Pre 阶段的完整动作永远是两步:改请求 + 留凭证。

// 请求级 Pre Request Script —— 完整形态 const { hmacSha256, stamp } = require('./scripts/sign.js'); const secret = bru.getEnvVar('APP_SECRET'); // 1. 只序列化一次,后面所有地方都用这同一个字符串 const payload = JSON.stringify({ sku: bru.getVar('sku') || 'A-001', qty: Number(bru.getVar('qty') || 2), ts: stamp() }); // 2. 改请求:头、体、URL 参数,看接口要求 req.setHeader('Content-Type', 'application/json'); req.setHeader('X-Sign', hmacSha256(secret, payload)); req.setBody(payload); // 3. 留凭证:Post 阶段和后续请求要用的,都放运行时变量池 bru.setVar('sent_payload', payload); bru.setVar('sent_at', Date.now());

这里有个细节值得单独拎出来讲:payload 只序列化一次。如果你在 Pre 里JSON.stringify一遍算签名,又让 Bruno 在发送时把对象再序列化一遍,两次生成的字符串很可能不一样——对象键的顺序、数字格式化、空格处理,任何一点差异都会让服务端重算出来的签名和你的对不上。这种 bug 特别难受,因为你盯着代码看,算法完全正确,就是过不了。解决办法就一句:算出字符串,存字符串,发字符串。

2.3 第三步:让响应结果回流到断言和后续请求

Post 阶段的价值不只是断言通过与否,它还是整条链路的"数据回流口":把服务端返回的 token、订单号、状态机流转结果拿出来,喂给下一个请求。

// 请求级 Post Response Script // 1. 先断言,失败早暴露 expect(res.getStatus()).to.equal(200); // 2. 安全地解析 body:不同版本拿到的可能是字符串也可能是对象 const raw = res.getBody(); const data = typeof raw === 'string' ? JSON.parse(raw) : raw; // 3. 回放 Pre 阶段留下的凭证,做"请求-响应"一致性校验 const sent = bru.getVar('sent_payload'); expect(sent).to.be.a('string'); test('响应回显的载荷与发出的载荷一致', function () { expect(data.echo).to.equal(sent); }); // 4. 写回业务状态,供后续请求使用 if (data.token) { bru.setVar('token', data.token); // 本次链路用 bru.setCollectionVar('last_token', data.token); // 跨会话留痕 } // 5. 记录耗时,做粗粒度性能观察 const cost = Date.now() - Number(bru.getVar('sent_at') || 0); console.log('round-trip(ms):', cost);

注意第 4 步里我同时写了两处。这不是冗余,是有意为之:运行时变量给"这一轮执行"用,集合变量给"下次打开 Bruno 还能看到上次结果"用。用途不同,别混。

再补一个容易忽略的细节:res.getBody()的返回类型。有些版本直接给你解析好的对象,有些给你原始字符串,接口返回非 JSON 时还可能是一段 HTML。所以那段typeof raw === 'string'的判断不是防御性编程的洁癖,是真的会踩。我遇到过一次网关报错返回 HTML 错误页,JSON.parse直接抛异常,把整个 Post 脚本带崩,连带后面的断言一条都没跑,排查时还以为是断言写错了。

3. 完整实操:一条带签名的下单链路怎么串起来

光讲机制有点飘,我拿一个虚构的电商下单链路走一遍。三个请求:登录拿 token、查库存、提交订单(需要签名)。目标是:整套流程能在 Runner 里一键跑完,变量自动流转,断言覆盖关键节点。

3.1 目录结构与脚本落点

先看集合长什么样:

order-flow/ ├── scripts/ │ └── sign.js # 公共签名与工具函数 ├── auth/ │ └── login.bru # 登录,无签名 ├── stock/ │ └── query.bru # 查库存,要 token ├── order/ │ └── create.bru # 下单,要 token + 签名 └── collection.bru # 集合配置与环境引用

分层原则很直白:scripts/放纯函数,不碰bru对象;.bru里的脚本负责跟 Bruno 交互;集合级脚本只做全局准备,比如生成一次性的 trace ID,不塞业务逻辑。这么切的好处是纯函数可以脱离 Bruno 单独测——你写个 node 脚本跑一遍hmacSha256('k','v')对比预期值,五分钟就能确认签名实现有没有问题,不用每次都真发请求。

3.2 登录:最基础的变量回流

login.bru的请求级 Post Response:

expect(res.getStatus()).to.equal(200); const body = typeof res.getBody() === 'string' ? JSON.parse(res.getBody()) : res.getBody(); test('返回体包含 token', function () { expect(body.token).to.be.a('string'); expect(body.token.length).to.be.greaterThan(8); }); bru.setVar('token', body.token); bru.setVar('uid', body.uid);

这段没什么花活,但它是整条链路的起点。token进了运行时变量池,后面两个请求的 Pre 阶段直接bru.getVar('token')就能用。

3.3 查库存:把 token 落到请求头上

// query.pre-request const token = bru.getVar('token'); if (!token) { throw new Error('token 缺失,请先执行 login 请求'); } req.setHeader('Authorization', 'Bearer ' + token); req.setHeader('X-Trace', bru.getVar('trace_id') || 'na');

这里我故意写了个throw。原因是:缺 token 的时候,如果不抛错,请求照样发出去,服务端返回 401,然后你在 Post 里看到断言失败,一路往回推,最后才发现是执行顺序不对或者上一个请求失败了。在 Pre 阶段提前抛错,报错信息直接告诉你缺什么,定位成本从十分钟降到十秒。

注意:Pre Request 脚本抛异常会导致该请求根本不发送。这是特性不是 bug,正好用来做前置条件校验。但也意味着你不能在 Pre 里写那种"出错了就吞掉继续"的 try-catch,否则错误会被掩盖到更远的地方。

3.4 下单:签名、发送、回放三件套

create.bru的 Pre:

const { hmacSha256 } = require('./scripts/sign.js'); const secret = bru.getEnvVar('APP_SECRET'); const token = bru.getVar('token'); if (!secret) throw new Error('APP_SECRET 未配置'); if (!token) throw new Error('token 缺失'); // 参数集中在一处,方便对照接口文档 const params = { sku: bru.getVar('sku') || 'A-001', qty: Number(bru.getVar('qty') || 1), uid: bru.getVar('uid'), ts: String(Date.now()) }; const payload = JSON.stringify(params); const sign = hmacSha256(secret, payload); req.setHeader('Authorization', 'Bearer ' + token); req.setHeader('Content-Type', 'application/json'); req.setHeader('X-Sign', sign); req.setBody(payload); // 三个凭证,Post 阶段逐一回放 bru.setVar('sent_payload', payload); bru.setVar('sent_sign', sign); bru.setVar('sent_ts', params.ts);

对应 Post:

expect(res.getStatus()).to.equal(201); const raw = res.getBody(); const data = typeof raw === 'string' ? JSON.parse(raw) : raw; test('订单创建成功并返回单号', function () { expect(data.orderId).to.be.a('string'); expect(data.status).to.equal('CREATED'); }); test('服务端回显的签名与本地一致', function () { expect(data.signEcho).to.equal(bru.getVar('sent_sign')); }); test('时间戳落在合理窗口内', function () { const drift = Math.abs(Date.now() - Number(bru.getVar('sent_ts'))); expect(drift).to.be.lessThan(5000); }); bru.setVar('order_id', data.orderId); bru.setCollectionVar('last_order_id', data.orderId);

这里第三条断言看似随口一提,其实是签名类接口最值得加的一条。签名对不上,原因可能是密钥错、可能是拼接顺序错、也可能单纯是本地时钟偏了几分钟,服务端时间窗校验直接拒掉。把 drift 打出来,一眼就能区分是算法问题还是时钟问题。我吃过这个亏,当时排查了快两个小时,最后发现是虚拟机休眠后系统时间没同步。

3.5 用 Runner 串起来跑,顺便管好变量残留

单条跑通只是及格线,能批量跑才是这条链路的价值。Runner 里选中整个集合,设定迭代次数,可以挂一份数据文件驱动参数。

几个要点:

  • 执行顺序靠文件夹排序控制,登录必须在最前面。别指望 Runner 自己推断依赖。
  • 数据文件按行驱动,每行一组sku、qty,脚本里用bru.getVar('sku')读,取不到就退回默认值,这样单跑和批量跑都不会崩。
  • 跨 iteration 的变量残留要主动清理。运行时变量在整次 runner 运行期间是活的,第一轮算出来的order_id如果不清,第二轮万一接口失败没写新值,你读到的就是上一轮的旧数据,断言还全绿——这种假绿是最危险的。在集合级 Post 脚本里收尾清一次,或者每轮开头覆写一遍。
// 集合级 Post Response —— 每轮结束收尾 bru.setVar('sent_payload', ''); bru.setVar('sent_sign', ''); bru.setVar('sent_ts', '');

清空比删除省事,也避免了"删了之后读取报 undefined"的连锁问题。

4. 踩坑实录:这些错我基本都犯过

脚本类工具的通病是错误信息不友好,一个undefined背后可能藏着七八种原因。下面这几类是我在实际项目里反复遇到的,配了定位路径。

4.1 典型症状与定位路径

症状一:Post 阶段读 Pre 阶段的变量,拿到 undefined。

先别怀疑变量池,按这个顺序查:变量名有没有拼错(包括大小写)?Pre 阶段是不是在setVar之前就抛错中断了?读的到底是bru.getVar还是bru.getEnvVar,两个池子不互通?最后一种情况最常见——Pre 里用setVar写,Post 里用getEnvVar读,写读不在同一层,永远读不到。

症状二:签名接口稳定返回 401,本地算法看起来没错。

按"三同一"排查:同一份字符串、同一个密钥、同一个时间窗。字符串:确认服务端签的是不是你真正发出去的那一份,最直接的办法是把sent_payload打到日志里,跟抓包看到的 body 逐字符比。密钥:确认环境变量取到的是真值不是占位符,console.log(secret.length)打长度最安全,别直接把密钥打出来。时间窗:看 drift 断言,超了就是时钟问题。

症状三:脚本报Cannot read properties of undefined。

九成是res.getBody()在请求失败时返回空,JSON.parse(undefined)直接炸。养成习惯:解析前先判类型,解析用 try-catch 包一层,catch 里把原始 body 打出来。网关返回 HTML 错误页的场景太常见了。

症状四:请求压根没发出去,界面没反应。

大概率是 Pre 脚本抛异常了。Bruno 的 Pre 阶段异常会阻断发送,控制台里翻一下错误堆栈。这类问题有个特点是"看起来像网络问题",其实跟网络一点关系没有。

症状五:Runner 批量跑,第一轮通过后面全挂。

变量残留。要么是 token 过期后没重新登录,要么是上一轮的脏数据覆盖了本轮的初始值。在每轮开头显式重置关键变量,比事后猜哪一轮开始不对要省事得多。

4.2 常见问题速查表

现象高概率原因一分钟验证方式
变量读不到写读用了不同作用域两边都换成setVar/getVar再试
签名 401发送体与签名字符串不一致打印sent_payload与抓包 body 比对
签名 401(时序)本地时钟漂移加 drift 断言,看是否超时间窗
请求未发送Pre 脚本抛异常看控制台堆栈,逐行注释排查
JSON 解析报错响应非 JSON 或为空先判typeof,打印原始 body
断言假绿变量上一轮残留每轮开头覆写关键变量
函数未定义路径写错/模块未导出console.log(typeof fn)打一下
依赖装不上集合级依赖未声明检查集合目录下的依赖配置

还有两个不太起眼但会咬人的点,顺手记一下。

第一个是请求体的编码。req.setBody传字符串和传对象,Bruno 的处理路径不完全一样。传对象时它会按 Content-Type 自动序列化,传字符串时原样发。签名场景必须走字符串,否则你签的和发的对不上。这个差异不会报错,只会让你在 401 里反复怀疑人生。

第二个是环境切换后的变量漂移。从测试环境切到预发环境,环境变量跟着换了,但集合变量和运行时变量不会自动重置。如果里面有环境相关的 ID,比如某个环境特有的租户标识,切换后请求就会串环境。我的做法是把所有环境相关的值全放环境变量,集合变量只存"跟环境无关的业务状态",从源头上消灭这类问题。

5. 让共享层能活过三个月的几个习惯

脚本写出来不难,难的是三个月后别人接手还能看懂,或者你自己回头改还能五分钟定位。这几条是我踩完之后固化下来的习惯,不一定每条都适合你,但方向值得参考。

给变量加前缀,一眼看出归属。sent_开头的是 Pre 写给 Post 的凭证,auth_开头的是鉴权相关,biz_开头的是业务状态。变量池是个扁平的大筐,没有命名约定就是一团乱麻。命名成本几乎为零,收益是排查时的确定性。

公共模块只做纯计算,不碰bru对象。scripts/sign.js里只有入参和出参,没有任何全局依赖。这样它可以被 node 直接跑、被单测覆盖、被别的集合复制走。一旦模块里出现bru.getEnvVar,它就锁死在 Bruno 里了,复用性归零。

Pre 阶段的前置校验要抛错,不要静默降级。缺 token 就抛,缺密钥就抛,参数类型不对就抛。降级处理会让错误漂移到离现场很远的地方,排查成本成倍上升。

用集合自身的版本管理能力。Bruno 的集合是纯文本目录,git diff能看清每一次脚本改动。这比那些把集合存在云端数据库里的工具强太多——出问题能 bisect,能回滚,能在 PR 里 review 脚本逻辑。我现在的习惯是,脚本改动和接口改动分开提交,出问题时一眼能分清是逻辑变了还是参数变了。

升级前跑一遍回归。脚本 API 在不同版本之间有过调整,尤其是变量作用域、require支持范围、响应体返回类型这几块。升级前把整个集合 Runner 跑一遍,比升级后在生产联调时发现问题要好得多。

最后分享一个我个人用得很顺的小技巧:给集合加一个"自检请求",它的唯一作用就是把当前环境的配置、关键变量是否存在、签名函数算出来的固定样例值全部打出来。联调前先跑它,三十秒内就能确认环境对不对、密钥有没有配、函数有没有加载成功。这比一条条试错高效得多,尤其是刚接手别人集合的时候,跑一遍自检就知道这套东西能不能用。

返回列表