
做接口联调的人大多经历过这种场面一个下单链路要连着跑七八个请求登录态两小时过期一次签名参数每次都得照着文档手算改个字段还得从头再点一遍。Bruno 这个开源的 API 调试工具之所以这两年讨论度越来越高很大程度上就是因为它把脚本执行机制做进了纯文本的请求文件里——请求定义、前置脚本、后置断言全部以可读的.bru文本保存能进 Git、能 diff、能 review。这篇内容就围绕 Bruno 的脚本执行机制展开从运行时机、沙箱环境、变量体系一路讲到动态签名、链式调用、命令行走 CI 的完整落地方式。不管你是刚把 Bruno 装上的新手还是已经写了几十个请求、正被变量污染和异步顺序折磨的老手下面这些拆解和实操细节应该都能对上你的场景。1. Bruno 脚本执行机制到底在解决什么问题1.1 手工点按钮的三个死结先说清楚痛点不然很容易把脚本机制当成锦上添花的功能而忽略掉。手工调试接口的死结集中在三处第一是状态不可复现你今天点出来的成功请求明天换台机器、换个环境变量就复现不了因为 token、时间戳、随机串这些动态内容根本没被记录第二是逻辑无法沉淀签名算法、加密规则、分页遍历这些脏活写在文档里每次联调都要重新翻译成手写参数第三是回归成本高接口改了一个字段你得把整条链路重新点一遍点漏一步就得从头再来。Bruno 的脚本执行机制针对的正是这三点。它允许你在请求发送之前插入一段 JavaScript用来改写 URL、拼接 Header、计算 Body在响应回来之后插入另一段 JavaScript用来断言状态码、提取字段、写回变量甚至决定下一个要跑哪个请求。这些脚本和请求定义存在同一个文件里跟着仓库一起走谁拉下来都能跑出一样的结果。这就是可复现的关键——动态内容由脚本现场算而不是靠人脑记。1.2 脚本执行的四个时机与生命周期Bruno 的脚本不是一个笼统的钩子它按作用范围分成几层执行顺序是从外到内、从通用到具体。理解这个层级关系比记 API 名字重要得多。执行层级作用范围典型用途执行顺序集合级脚本整个集合内所有请求统一注入鉴权头、统一日志格式最先文件夹级脚本该文件夹下的请求模块级公共前置如租户 ID其次请求前脚本单个请求发送前签名、时间戳、依赖请求再次请求后脚本单个请求响应后断言、字段提取、变量回写最后这里有个容易被忽略的细节请求前脚本执行时请求对象已经完成了变量插值。也就是说{{baseUrl}}这种占位符在脚本运行前就已经被替换成实际值了你在脚本里通过req.getUrl()拿到的是替换后的完整地址。很多人第一次写脚本时会尝试在脚本里手动处理{{}}结果发现重复替换或者替换失败根源就在这里。想改地址就老老实实调req.setUrl()别去动插值语法。另一个关键点是脚本是异步执行的。Bruno 的脚本上下文支持await这意味着你可以在请求前脚本里发起额外的请求、等待定时器、读取异步资源。但反过来说如果你忘了写await后面的代码会在异步操作完成之前就执行完表现出来就是变量明明赋了值下一个请求却拿不到。这个坑后面会单独展开。1.3 为什么把逻辑塞进请求文件而不是外部脚本有人会问我完全可以用 Python 或 Node 写一个外部脚本读配置、发请求、做断言为什么要用 Bruno 内嵌的这套答案在于协作界面的位置。外部脚本的问题是把人看的和机器跑的彻底分家了——你在脚本里改了签名逻辑同事在界面上看不到他还是按老参数点点了半天报签名错误然后来问你。Bruno 的.bru文件把请求、脚本、断言放在一起打开就是全貌。签名怎么算的、token 从哪个字段取的、断言期望值是多少一眼扫过去清清楚楚。而且纯文本格式对 Git 极其友好改了一行签名逻辑diff 里就是一行review 的时候不会淹没在几千行代码里。这种逻辑跟着请求走的组织方式在接口联调这种高频小步迭代的场景里效率提升比想象中大。2. 脚本运行环境与变量体系拆解2.1 沙箱里究竟能拿到哪些对象Bruno 的脚本跑在一个受限的执行环境里不是完整的 Node 进程。刚上手时最该记住的是那几个全局对象它们是所有操作的入口。req请求对象提供getUrl、setUrl、getMethod、setMethod、getHeader、setHeader、getBody、setBody等方法只在前置脚本中可用。res响应对象提供getStatus、getBody、getHeader、getResponseTime等只在后置脚本中可用。bru工具对象负责变量读写、请求跳转、主动发起子请求前后置脚本都能用。expect断言库语法沿用链式风格expect(actual).to.equal(expected)这类写法。console日志输出写在界面底部的控制台面板里。关于内置模块不同版本差异比较大。较新的桌面版本开放了部分 Node 内置模块的引入能力比如做哈希、编码转换时会用到但命令行运行环境和桌面环境的支持面不一定完全一致。我的建议是涉及加密、编码这类能力先在桌面端和命令行各跑一遍验证确认两边行为一致再固化成脚本。如果版本不支持你需要的模块退路是把关键值提前算好放进环境变量或者用纯 JS 实现一份简化逻辑。注意不要假设脚本环境里有window、document这类浏览器对象也不要想当然地认为fetch一定可用。跨环境不一致是脚本本地能跑、CI 挂掉的头号原因。2.2 变量作用域与覆盖规则Bruno 的变量体系是分层的大致可以按越靠近请求越优先来理解。这个规则不难难的是记住每一层都在哪里编辑。变量层级定义位置生存周期优先级环境变量环境配置文件跟随所选环境最低集合变量集合根目录配置跟随集合较低文件夹变量文件夹级配置跟随文件夹中等请求变量单个请求文件跟随请求较高运行时变量脚本内bru.setVar写入仅本次运行最高运行时变量是脚本机制里最有用也最容易滥用的一层。它只在当前这次运行过程中有效进程结束就没了非常适合存放 token、随机串、上一步返回的订单号这类跑一次算一次的数据。但它的高优先级也意味着会覆盖同名的环境变量。我踩过一次坑环境里配了userId脚本里又用setVar写了个userId存临时值结果后续所有请求都拿到了临时值排查了半天才发现是覆盖问题。从那以后我给自己定了条规矩运行时变量的名字统一加前缀比如rt_token、rt_orderId从命名上就跟配置变量区分开。写变量的 API 有两组别混用。bru.setEnvVar()改的是环境变量会持久化到环境配置文件里适合这次调完顺手把新 token 存下来的场景bru.setVar()改的是运行时变量跑完即弃。前者要谨慎因为它会污染团队共享的配置文件后者随便用但要注意别在循环里无限膨胀。2.3 数据在脚本之间的流转路径一条完整的链路数据流转大致是这样的集合级脚本准备公共上下文请求 A 的前置脚本算好签名发出去请求 A 的后置脚本把响应里的data.id提取出来写进运行时变量请求 B 的前置脚本读到这个变量、拼进 URL如此往复。这条路径上有两个断点最容易出问题。一是提取时的类型res.getBody()拿到的是字符串你得自己JSON.parse如果响应体是空的或者是非 JSON 格式直接 parse 会抛异常整个后置脚本中断后面的断言一条都不会执行。稳妥的写法是先判空、再判内容类型、再 try-catch。二是写入时的时机后置脚本里写运行时变量写在断言之前还是之后影响很大——如果断言失败抛错写在后面的赋值就不会执行下一个请求自然拿不到值。我的习惯是把赋值放在断言前面先保住数据流转再谈校验。3. 请求前脚本的核心实操3.1 动态签名与时间戳的构造过程签名是最典型的请求前脚本场景。假设服务端要求把时间戳、随机串、请求体按字典序拼接再做一次哈希最后放进 Header。手工算这个基本不可能脚本里就是十几行的事。// 请求前脚本构造签名 const timestamp Date.now().toString(); const nonce Math.random().toString(36).slice(2, 10); // 取出请求体注意这里是字符串 const rawBody req.getBody() || ; // 按约定的顺序拼接 const signSource timestamp${timestamp}nonce${nonce}body${rawBody}; // 哈希函数按你环境支持的方式实现这里用占位示意 const sign computeHash(signSource); req.setHeader(X-Timestamp, timestamp); req.setHeader(X-Nonce, nonce); req.setHeader(X-Sign, sign); // 顺手把时间戳存起来后置脚本可能要校验服务端时间 bru.setVar(rt_ts, timestamp);这段代码里有个参数选择的细节值得说随机串我只取 8 位。原因不是随便定的——服务端对 nonce 一般有长度上限太长会被截断导致验签失败太短又有碰撞风险。8 位 36 进制大概 2.8 万亿种组合在单次测试的时间窗口里够用了。同理时间戳用毫秒而不是秒是因为不少服务端的防重放窗口精确到毫秒级用秒级时间戳可能落在窗口边界上偶发失败。还有一点签名用的请求体必须是最终发送的那一份。如果你在签名之后再改 body签出来的值立刻失效。所以脚本里的顺序应该是先定 body再算签名最后设 Header别倒过来。3.2 前置依赖请求先把 token 拿到手实际项目里大部分接口都要求先登录。笨办法是单独建一个登录请求手动点一下把 token 复制到环境变量里。这个动作重复三次你就会烦。脚本里可以直接发起子请求。// 请求前脚本按需获取 token带缓存 let token bru.getVar(rt_token); if (!token) { const loginRes await bru.runRequest(auth/login); const body JSON.parse(loginRes.body); token body.data.accessToken; bru.setVar(rt_token, token); console.log(token 已刷新长度:, token.length); } req.setHeader(Authorization, Bearer ${token});这里的关键是await。bru.runRequest返回的是异步结果不写await的话loginRes就是个未完成的 Promise取.body直接报 undefined。我见过不止一个同事在这里翻车报错信息还是那种cannot read property of undefined看起来像变量没定义实际是异步没等。缓存判断也值得展开。为什么先读rt_token而不是每次都登录因为登录接口通常有频率限制一条链路里十几个请求每个都登一次很容易触发风控。用运行时变量做缓存一次运行只登一次既快又稳。但要注意如果你的链路执行时间超过 token 有效期缓存就会失效这时候需要加一层过期判断——把登录返回的expiresIn一起存下来每次用之前比对当前时间。这个逻辑不复杂但能省掉大量跑到一半突然 401的排查时间。3.3 参数计算与边界处理除了签名和鉴权请求前脚本还常用来做参数加工分页参数、时间窗口、数组展开、URL 编码。这类需求看着简单边界情况却不少。拿时间窗口举例。服务端要求传最近 7 天的起止时间格式是yyyy-MM-dd HH:mm:ss。直接new Date()减 7 天会有个隐患跨月、跨年、闰年这些情况用毫秒减法本身没问题但时区处理容易出错。如果服务端按 UTC 解析而你按本地时间格式化就会差 8 小时。我的做法是统一用 UTC 格式化脚本里明确写清楚别依赖运行机器的时区设置。再拿数组参数举例。有些接口要求ids1ids2ids3这种重复键格式有些要求ids1,2,3逗号分隔。Bruno 的变量插值对数组支持有限脚本里手动拼更可控先Array.isArray判断再按目标格式 join最后用encodeURIComponent处理每个元素。别偷懒直接拼字符串元素里带或的时候整个请求就废了。实操心得请求前脚本里所有可能抛异常的操作都包一层 try-catch然后在 catch 里console.error打出原始输入。联调阶段最怕的就是脚本静默失败请求发出去了但参数不对你对着服务端返回的错误码猜半天其实问题在脚本里。4. 响应后脚本与断言体系4.1 断言怎么写才有诊断价值断言的目的不只是告诉我过没过更重要的是告诉我哪儿不对。Bruno 的断言基于链式风格写起来很直观但写好需要一点设计。// 响应后脚本基础断言 expect(res.getStatus()).to.equal(200); const body JSON.parse(res.getBody()); expect(body.code).to.equal(0); expect(body.data).to.be.an(object); expect(body.data.id).to.be.a(string).and.not.empty;这段代码里最后一行是重点。很多人只断言code 0结果接口返回了空对象也照样通过等到后续请求拿不到 id 才炸。加上类型和非空断言问题会在源头暴露。同理对数组字段断言length 0比断言是数组更有意义。失败信息也值得花心思。默认的断言失败提示只告诉你期望值和实际值如果断言多你很难定位是哪一条挂的。可以在断言前加一句console.log把关键字段打出来或者在断言消息里带上上下文。命令行跑 CI 的时候这些日志就是唯一的排查线索。4.2 响应数据提取与变量回写提取字段是个看似机械但暗藏细节的活。最朴素的做法是逐层取值body.data.list[0].orderId但真实接口的返回结构经常变——有时候data是对象有时候是数组有时候干脆是 null。硬取就会抛Cannot read property of null。稳一点的写法是写个小工具函数放在集合级脚本里所有请求共用// 集合级脚本定义安全取值工具 function pick(obj, path, fallback undefined) { const keys path.split(.); let cur obj; for (const k of keys) { if (cur null || cur undefined) return fallback; cur cur[k]; } return cur undefined ? fallback : cur; }然后在后置脚本里bru.setVar(rt_orderId, pick(body, data.orderId))。这么写的好处是结构变化时不会中断脚本拿到 undefined 后续再判断比直接抛异常友好得多。缺点是会掩盖一些本该报错的情况所以关键字段我还会补一条expect(...).to.not.be.undefined两种手段配合用。回写变量时还有个顺序问题前面提过赋值放断言前。除此之外变量名要能自解释。我见过id1、id2、tmp这种命名过两天自己都忘了是干嘛的。统一前缀加语义比如rt_loginToken、rt_createdOrderId看名字就知道来源和用途。4.3 链式调用与条件跳转Bruno 支持在一个请求的后置脚本里决定下一个跑哪个请求这把手动点击变成了自动流水线。// 响应后脚本根据结果决定下一步 const body JSON.parse(res.getBody()); if (body.data.status PENDING) { // 状态未终态等一会儿再查 await bru.sleep(2000); bru.setNextRequest(order/query); } else if (body.data.status SUCCESS) { bru.setNextRequest(order/detail); } else { bru.setNextRequest(null); // 结束 }bru.sleep配合setNextRequest可以做轮询但要注意别写成死循环。轮询一定要有次数上限用一个运行时变量计数超过阈值就跳出并标记失败。我见过有人写轮询没设上限服务端一直返回 PENDING脚本跑了二十分钟还在转最后只能强杀进程。条件跳转的另一个用途是失败短路。比如创建订单失败时没必要继续跑支付、发货这些下游请求直接在脚本里setNextRequest(null)终止控制台里看到的就是一条干净的失败链路而不是后面一串莫名其妙的连锁报错。5. 调试技巧与问题排查实录5.1 执行顺序与异步陷阱的验证方法脚本出问题时第一件事是确认执行顺序。Bruno 的控制台会按时间顺序打印每条console.log在每段脚本开头打一条带标识的日志跑一遍就能看出实际顺序和你以为的是否一致。我习惯在集合级脚本打[collection]、请求前打[pre]、请求后打[post]一眼就能定位是哪一层没执行。异步陷阱的排查有个笨但有效的办法在await前后各打一条日志中间打一条看打印顺序。如果中间那条跑到了await后面说明异步没生效或者你漏了await。另一种常见情况是bru.runRequest里嵌套了bru.runRequest两层的 await 都要写少一层就会出现子请求还没回来外层已经开始用结果的情况。还有一个隐蔽的坑脚本重复执行。Bruno 有个行为是同一个请求在切换环境或者重新发送时脚本会完整再跑一遍。如果你的前置脚本里有累加逻辑比如往数组里 push第二次跑就会拿到重复数据。解决办法是用运行时变量做幂等判断或者在脚本开头清理自己写的变量。5.2 常见报错速查表现象最可能的原因排查动作Cannot read property xxx of undefined异步未 await或响应体解析失败检查 await打印原始 body变量在下一个请求里取不到写的是环境变量但读的是运行时变量或名字拼错打印bru.getVar结果核对命名前缀签名一直验不过签名后改了 body或时间戳精度不对调整脚本顺序确认时间戳单位断言全部未执行解析 JSON 时抛异常脚本中断加 try-catch判空后再 parse命令行跑失败、桌面端正常环境差异或内置模块不可用分别打印运行环境信息降级实现请求参数里出现[object Object]直接把对象塞进了字符串插值先 JSON.stringify 或手动拼字段这张表里的每一条我都至少踩过一次其中签名验不过那次最折腾查了两小时才发现是脚本里先设了 Header 再改 body顺序反了。5.3 几条用血换来的避坑经验第一条不要在脚本里写死环境相关的值。域名、端口、租户 ID 这些全部走变量脚本只管逻辑。写死一次换环境就要改一次改漏一处就是半天。第二条日志要能关掉。联调阶段console.log打得很爽等脚本多了以后控制台刷屏真正有用的信息反而找不到。我现在的做法是加一个rt_debug开关集合级脚本里读它只有为 true 时才输出详细日志。第三条敏感信息不进脚本。密码、密钥这类东西放环境变量文件并且把那个文件加进忽略列表。脚本里只引用变量名不出现明文。团队协作时这条尤其重要.bru文件是要提交的明文密钥提交上去就是事故。第四条改脚本前先备份。Bruno 的脚本编辑器没有撤销历史改错了只能靠 Git 恢复。养成改完就跑一遍的习惯跑通了再提交比事后回滚省事得多。6. 工程化落地让脚本真正进流水线6.1 目录结构怎么组织才不乱脚本一多组织方式就决定维护成本。我的经验是按业务模块分文件夹公共逻辑上提到集合级模块专属逻辑放文件夹级请求级脚本只保留这个请求特有的部分。collection/ ├── collection.bru # 集合级公共工具函数、通用 Header ├── environments/ │ ├── dev.bru │ └── staging.bru ├── auth/ │ ├── folder.bru # 文件夹级登录态处理 │ └── login.bru ├── order/ │ ├── folder.bru # 文件夹级订单公共参数 │ ├── create.bru │ └── query.bru └── payment/ └── pay.bru这么分层的好处是改动影响面清晰。要调整登录逻辑只动auth/folder.bru要加全局请求头只动collection.bru。反过来说如果你发现某个脚本被三个以上请求复制粘贴那它就该上提一层了。判断标准很简单出现第三次重复就提取。6.2 命令行运行与 CI 集成Bruno 提供了命令行运行能力这是脚本机制真正发挥价值的地方——本地跑通只是及格线能进 CI 才算闭环。# 运行整个集合指定环境 bru run ./collection --env dev # 只跑某个文件夹输出结构化报告 bru run ./collection/order --env staging --reporter-junit results.xml接入流水线时有两个细节要注意。一是退出码断言失败时命令会返回非零码流水线据此判定失败所以不要在脚本里 try-catch 掉所有异常该失败就让它失败。二是环境变量注入CI 里跑通常不把环境文件提交而是通过环境变量覆盖这里要确认变量名的对应关系别出现本地读baseUrl、CI 里注入了BASE_URL这种大小写不匹配的低级问题。还有一点经验CI 里的运行结果最好留一份报告文件出问题的时候能直接看是哪个请求、哪条断言挂的不用翻日志。JUnit 格式的报告大部分流水线平台都能直接解析配一次省很多事。我个人的习惯是本地开发时用桌面端边调边写脚本功能稳定后立刻用命令行跑一遍验证。两边都通过这套脚本才算真正可用。只在一端验证过的脚本迟早会在另一端给你惊喜。