我先把话说在前面:只要你的压测脚本里出现过“明明登录成功了,后面的接口却一个个报401”“从上一个请求里拿到的数据,到下一个请求就没了”“换了线程组之后变量全体失踪”这类问题,那这篇文章就是冲着你来的。
JMeter里的参数传递,本质上是两件事:从某个请求的响应里提取出目标数据,再把提取到的数据引用到后续请求的任意位置。绝大多数人踩坑,都是因为把“提取”和“引用”混成了一件事。你以为自己在做关联,其实只是把一桶水从A桶倒进B桶——问题是,倒之前你得先确认“舀水的瓢”在哪个抽屉里。
这篇文章不讲虚的,直接以一条“登录→查询→下单→支付”的典型接口链路为主线,把提取器怎么选、变量作用域怎么理解、跨线程组怎么传、${}到底什么时候求值、传参失败了怎么排查,一次性说透。适合刚接触JMeter接口测试的人,也适合写了很久脚本但总在参数传递上翻车的老手——有些细节,真的是不踩一次坑就不会注意的。
1. 为什么说参数传递是接口链路测试的“水电工程”
1.1 一桶水与一条渠:别把“提取”和“引用”混为一谈
我先打个比方。接口A返回的数据是一桶水,接口B需要用到这桶水。你从A桶里把水舀出来,这个过程叫“提取”;然后把水顺着管道灌到B桶里,这个过程叫“引用”。两个动作是独立的,管线里任何一个环节断了,B桶里都没水。
很多人跟我说“我做了参数关联但就是不好使”,等我一看脚本,要么只加了提取器、没在目标位置写${},要么写了${}但提取器压根没配置对。这就是典型的“管道已经铺好了,但水龙头没打开”。
在JMeter里,提取动作靠的是“后置处理器”一族的组件,它们挂在采样器下面,等采样器执行完、拿到响应数据之后才运行。常见的有正则表达式提取器、JSON提取器、边界提取器、XPath提取器。而引用动作非常简单,就是在目标位置写变量占位符,例如${token}。注意,这两个动作之间是严格的先后关系:提取器必须先跑完,${token} 才能拿到非空的值。
1.2 最常见的三个坑,你迟早会踩
搞清楚“提取”和“引用”的边界之后,再看下面这三个坑就很好理解了。
第一个坑:提取器挂对了,但目标请求先于提取器执行了。很多人把登录请求和后续请求放在同一个线程组里,按树形结构从上到下执行,逻辑上没问题。可一旦中间加了循环控制器、条件控制器、甚至把请求顺序调乱,后置处理器的执行顺序就跟着乱了。只要引用点跑到提取点前面,${token} 在那一刻就没有值,JMeter会把这个“空值”直接拼到请求里。
第二个坑:变量名写错,但JMeter不报错。这一点最坑人。你提取的时候把引用名称写成token_abc,引用的时候写成了${token_abc1},JMeter不会告诉你“没这个变量”,它只会安静地把${token_abc1}当成一个字面量塞进请求参数。你要是不打开查看结果树,根本发现不了。
第三个坑:跨线程组传参用了变量,而不是属性。变量是线程级的概念,每个线程自己存一份;属性才是JVM全局的。默认情况下,线程组A里提取出的变量,线程组B根本看不见。想看?你得把变量升级成属性。这个点非常关键,后面专门用一章来讲。
这三个坑,是我见过的高频问题里最典型的三种。把它们的成因拆开看,基本可以把JMeter参数传递的90%知识串起来。
2. 提取阶段:正则在左,JSON在右,边界在中间
2.1 正则表达式提取器:老而弥坚,但别让它背锅
正则表达式提取器是JMeter最老牌的提取方案,什么格式都能处理,前提是你会写正则。它的核心配置项不多:引用名称、正则表达式、模板、匹配数字、缺省值。
引用名称就是变量名,比如token。正则表达式是提取规则,模板是“从你捕获的多个分组里怎么拼结果”。举个简单例子,响应体里有一串"token":"abc123def",你想提取abc123def,可以用:
"token":"(.*?)"模板填$1$,匹配数字填1,缺省值可以填NOTFOUND或者留空。
这里面有几个细节我建议新手注意。第一,(.*?)是非贪婪匹配,它会从前向后找到第一个满足条件的片段就停下来;如果你写成(.*)贪婪匹配,很容易把后面所有的内容都吞进去。第二,正则表达式里的点号默认不匹配换行符,如果你的目标字段跨行了,需要给整个正则加上(?s)前缀,比如(?s)"token":"(.*?)"。第三,匹配数字这个字段很靠语气:填1表示取第一个匹配项,填0表示随机取一个,填-1表示取所有匹配项并生成 token_1、token_2 这样的序列变量。很多人把0理解成“第一个”,结果取到随机值,然后整个链路都飘忽不定。
我不太建议在JSON响应上强行用正则,除非对方接口返回的格式非常不规范、没有规律可循。因为JSON里字段顺序一变、空格一变,正则在响应体上匹配就很脆。正则适合的范围是:响应是纯文本、HTML片段,或者是各种非标准结构的数据。
2.2 JSON提取器:面对99%的JSON返回,这套语法就够了
现在后端接口基本都是JSON返回,JSON提取器是你在接口测试里用得最多的提取器。它把“用正则匹配字符串”这件事,简化成了“用JSONPath定位JSON节点”。
它的配置比正则简单:引用名称、JSONPath表达式、匹配数字、缺省值。比如登录接口返回:
{ "code": 0, "data": { "token": "abc123", "user_id": 12345, "expires_in": 7200 } }我要提取 token,JSONPath 写$.data.token;提取 user_id,写$.data.user_id。如果响应的层级很深,或者你不想关心到底在哪一层,可以写$..token,这表示在所有嵌套层级里找 token 字段。
数组场景也很常见。返回{"list":[{"id":1,"name":"a"},{"id":2,"name":"b"}],"total":2},提取第一项的 id:$.list[0].id。如果想遍历所有 id,匹配数字填 -1,JMeter 会生成 id_1、id_2 两个变量,配合后续的循环逻辑使用。
这里我想提一个非常容易混淆的差异:正则提取器里,匹配数字 0 表示随机、-1 表示取全部;JSON提取器里,匹配数字 -1 表示取全部、0 表示随机、1 表示取第一个。两者对0和-1的定义不一样!我见过不止一个人用JSON提取器想取全部匹配项,结果填了0,取到随机一项,调试半天都不知道问题出在哪。
还有一点:JSON提取器对“字段不存在”的处理比较安静,不会报错,而是按缺省值生成变量。如果你没配缺省值,变量就是空。后面某个请求引用空变量,就变成发了一个参数缺失的请求。所以给关键变量配一个明显的缺省值,比如NOTFOUND,排查时一眼就能发现问题。
2.3 边界提取器:JMeter 4.0后的新选择
JMeter 4.0 引入了边界提取器,它的定位是“比正则简单、比JSON提取器通用”。它不需要写正则,只需要指定左边界和右边界,提取器会找第一个左边界和第一个右边界之间的内容。
用刚才的例子,响应里是"token":"abc123",左边界写"token":",右边界写",就能拿到abc123。注意引号和冒号都要写对,边界可以包含字符,但不能包含正则元字符。
边界提取器在纯文本、日志片段、HTML标签提取里很好用,因为它没有转义负担。但它也有明显的短板:如果响应里有多个相同结构的数据,或者左右边界中间的内容本身就包含右边界字符,你就会拿到不期望的结果。另外它也没有JSONPath那种“按层级取数”的能力。所以我的建议是:能用JSON提取器解决的,优先用JSON提取器;响应是杂文本的,用边界提取器;正则表达式只作为兜底方案。
2.4 三种提取器怎么选:一张表看完
| 提取器 | 适用场景 | 语法成本 | 维护性 | 典型坑点 |
|---|---|---|---|---|
| 正则表达式提取器 | 非JSON结构、HTML、旧系统接口 | 较高,需要正则基础 | 较差,字段变动容易失效 | 贪婪匹配、跨行匹配、0与-1的语义注意 |
| JSON提取器 | JSON返回的接口测试 | 较低,JSONPath直观 | 较好,结构清晰好维护 | Match No的-1与0别搞混;不存在的字段不报错 |
| 边界提取器 | 纯文本、日志、固定前后缀结构 | 最低 | 一般,依赖边界文本不变 | 边界内部包含右边界时截取错误 |
实际做项目时,我一般会在写脚本前先看一眼响应格式。如果响应是规整JSON,直接上JSON提取器;如果是别人返回的一段HTML或日志,边界提取器比正则舒服得多;只有那些格式乱七八糟、连边界都不稳定的响应,我才会动用正则表达式提取器。
3. 作用域才是真正的大Boss:变量卡在“线程”里,属性才是全家桶
3.1 JMeter的两种存储:变量是每线程一份,属性是全局一份
参数传递传着传着突然拿不到值,大概率是因为你没搞清楚JMeter里“变量”和“属性”的区别。
JMeter里,变量(Variable)是线程局部存储。你在一个线程组里启动了10个线程,每个线程都会各自执行一遍脚本,每个线程里都有自己的一份 token。打个比方,10个员工各拿一张工牌,工牌号当然各不相同。提取器把token存进变量后,只有当前线程自己能看到,别的线程看不到,更别提别的线程组。这其实是好事,它能保证并发时每个线程的业务数据不串号。但如果你想让“某个线程提取出来的值”被别的线程组共享,变量这条路就走不通了。
JMeter属性(Property)是JVM全局存储,类似公司前台的一块公共白板,谁都可以往上写、谁都可以看。属性不会因为线程结束而消失,也不会因为线程组切换而重置。跨线程组传参,本质就是把“私人变量”升级成“公共属性”。
3.2 跨线程组传递的标准姿势:__setProperty + __P
假设场景是这样的:线程组A负责登录,登录成功后拿到token;线程组B负责后续业务,B里的请求需要用到A里提取的token。
如果你直接在B里写${token},结果是空。正确做法分两步。
第一步,在A的登录请求下面加一个“JSR223后置处理器”,写一行Groovy代码:
props.put("token", vars.get("token"));这里vars.get("token")是取当前线程里的变量值,props.put是把值写进JMeter属性。
第二步,在B线程组的请求里用函数引用属性:
${__P(token,)}__P函数是从属性池里取值。第二个参数是缺省值,这里留空表示属性不存在时返回空字符串。你也可以写成${__P(token,NOTFOUND)},方便排查。
有一点要特别注意:变量和属性是两套存储空间,我可以把变量“拷贝”到属性里,但不会自动同步。如果token在A线程组里被更新了(比如重新登录),你需要重新执行一次props.put。千万别以为第一次写入后属性会自动跟随变量变化。
另外,如果你在“用户自定义变量”里定义过同名变量,又在后置处理器里用同名提取器,两者的值会互相覆盖。JMeter变量本质上是通过vars.put写入的,后置处理器里的设置动作发生在测试计划启动之后,所以它会覆盖“用户定义变量”里的初始值。脚本维护时要特别留意命名冲突,我习惯给不同来源的变量加前缀,比如login_token、order_id、pay_amount。
3.3 文件接力:并发量高时的变通方案
属性方式在单机压测里非常好用。但如果你用了JMeter分布式压测,Agent之间是不共享属性的——主控机上的属性不会自动同步到各台执行机上。这种情况下的跨线程组传参,就得想别的办法,最常见的是文件接力。
思路很简单:线程组A把关键数据写入一个本地CSV文件,线程组B用CSV数据文件配置读取这个文件。举例,A里加一个JSR223取样器:
def file = new File("token_store.csv") file.append(vars.get("token") + "\n")B线程组里加一个CSV数据文件配置,指向同名文件,变量名设为token,然后在请求里用${token}引用。
文件接力的优点是分布式也能用,缺点也很明显:多线程并发写文件时会相互干扰,可能把多个token拼成一行;而且B线程组必须等A写完才能读,你得用“测试计划级别的setUp/tearDown线程组”或者“线程组运行顺序”来控制时序。我的经验是,单机压测优先用属性方式,文件接力只作为分布式环境下的备选方案,能用消息队列之类的中间件更好,不过对大多数压测场景来说,文件接力已经够用了。
4. 引用时机:${token} 到底在什么时候被“定型”
4.1 变量替换发生在“取样器执行前”
JMeter里,${token} 这类变量引用并不是“实时查询”的,它更像一次“渲染”:取样器每次准备执行的时候,JMeter会扫一遍这个取样器的参数,把参数里所有 ${} 包裹的占位符替换成当时的变量值,然后发出请求。一旦请求发出,你再改变量的值,对已经发出的请求没有任何影响。
这个机制解释了为什么“先引用后提取”会出问题:如果提取器还没跑,变量就是空的,JMeter拿空字符串替换了 ${token},请求发出去自然是缺参数。它也解释了一个常见现象:同一个线程组里,第一次循环时某个 ${token} 是空的,从第二次循环开始才有值。因为循环第一次时,提取token的上游请求刚执行完第一次,还没跑到你引用它的位置,或者提取器确实跑过了,但那次响应里压根没有目标字段。
函数和变量是两回事。变量是一次提取、处处引用;函数则是在每次引用时动态求值。比如${__time(,)}会在每次执行时取当前时间戳,这类动态值的场景不能用变量替代。如果你想在脚本里动态拼接一个“过期token”的测试场景,可以先用提取器拿到expires_in,再用函数计算当前时间加上有效期。但这里要特别小心:变量一旦提取,它的值就固定了,如果你把expires_in变量拼到后面的请求里,得确保提取器的执行顺序先于所有引用它的请求。
4.2 提防“先引用后提取”的顺序陷阱
顺序陷阱是新手最容易进的一个坑。我见过一个脚本,同一个线程组里有三个HTTP请求:登录、查询、下单。它们在树形结构里的顺序是登录→查询→下单,但用户把JSON提取器挂在了登录请求下面,却在“查询”请求的参数里用 ${token}——看起来没问题,但实际执行时,只要线程组里有任何循环控制器包住了这些请求,循环的结构会让后置处理器的执行时机变得很微妙。
正确的检查方法是:把“查看结果树”打开,点击任何一个请求,看“请求体”里的参数到底被替换成了什么。如果出现${token}字面量,说明JMeter压根没认出这个变量;如果替换成空字符串,说明变量存在但在当时没有值。这两种情况处理方向完全不同,前者检查名字拼写,后者检查执行顺序。
另一个容易忽略的点是“逻辑控制器的作用域”。比如你用“如果(If)控制器”包住了登录请求,那么当条件不满足时,登录请求根本不执行,它下面的后置处理器自然也不会执行,token变量就一直是空的。后续请求再引用它,只能拿到空值。这不是参数传递本身有问题,而是断言和逻辑控制器的范围把采样器“短路”了。排查顺序陷阱时,你不能只看采样器的树形位置,还要看包裹它的每个逻辑控制器是否真的会放行。
4.3 用好“用户自定义变量”但别被它带偏
很多人喜欢把环境相关的配置塞到“用户定义的变量”里,比如 baseUrl、端口、默认账号。这些变量在测试计划启动时就会初始化,全局可见,用起来很方便。但要注意,用户自定义变量是“启动时定型”的:测试计划一跑起来,它们就被固定在JVM里,你中途改不了。如果某次调试时你改了“用户定义的变量”里的值,必须重启测试计划才生效。
所以我的习惯是:环境配置放“用户定义的变量”,运行期间的动态数据(token、订单号、验证码)一律用提取器产生的变量,并且故意避开同名。如果你把一个变量既放在“用户定义的变量”里、又让提取器写入同名变里,规则是后执行的覆盖先执行的——但这样一来,脚本的可读性和可预测性都变差了,不值得。
5. 一条完整链路:登录→查询→下单→支付,参数是怎么一路传下去的
5.1 登录拿token:JSON提取器与HTTP头管理器的配合
先说一个最常见的开头:登录接口返回了token,后面的所有业务请求都要在请求头里带Authorization: Bearer ${token}。
登录请求响应是:
{ "code": 0, "data": { "token": "eyJhbGciOiJIUzI1NiJ9.xxx", "user_id": 10001, "expires_in": 7200 } }在登录请求下面加一个JSON提取器:
- 引用名称:
login_token - JSONPath:
$.data.token - 匹配数字:1
- 缺省值:
NOTFOUND
然后在HTTP头管理器里添加一个Header:
Authorization: Bearer ${login_token}这里有个细节:如果头管理器放在登录请求和业务请求的共同父级下面,那么登录请求本身也会带上这个Header,有些服务端对此不敏感,有些会返回“Token无效”。所以我通常把公共头管理器放在业务请求的父控节点下,登录请求单独不放Authorization。这种按作用域拆分请求头的方式,比一股脑塞进“HTTP请求默认值”里干净得多。
5.2 订单号链路:多个提取器挂同一个采样器
接下来,查询订单和下单环节,需要从一个响应里同时提取订单号和支付流水号。比如下单接口返回:
{ "code": 0, "data": { "order_id": "20240101001", "pay_no": "PAY20240101001", "amount": 9999 } }我给下单请求挂两个JSON提取器,一个提取order_id,一个提取pay_no。提取器可以并列挂在同一采样器下,它们会依次执行,互不干扰。
支付请求的参数里引用:
{ "orderId": "${order_id}", "payNo": "${pay_no}", "amount": ${amount} }这种“同一个响应、多字段并行提取”的方式,在JMeter里非常常见。我建议每个提取器都写上缺省值,并且用有意义的前缀命名,比如order_id、pay_no、order_amount。脚本跑到第40个请求时报错,你光看变量名就能判断数据流断在哪里。
5.3 一处容易被忽略的传递:Cookie与动态Header
参数传递不只发生在“响应→请求参数”之间,还有一个常常被遗忘的方向:响应里的Set-Cookie会被JMeter的HTTP Cookie管理器自动处理。只要在线程组里加了HTTP Cookie管理器,同一个线程组内的会话Cookie会自动维护,不需要你手动提取,也不需要在下一个请求里手动引用。
但如果服务端鉴权用的是自定义Header(比如x-auth-token),而登录响应体里又恰好没有这个字段,只有Set-Cookie里才有,那Cookie管理器虽然帮你维护了Cookie,你仍然需要在后续请求中手动提取Header值。这时候就得借助提取器去拿Cookie值,再通过头管理器转发。还有一种情况:登录响应里返回了Set-Cookie: session_id=abc123,你提取出来后,如果想在下一个请求的正文里引用,提取器的正则或JSONPath就得指向响应头而不是响应体。JMete的响应头在查看结果树的“响应数据”页签下面可以看到,但提取器默认是对整个响应体做匹配,需要用到HTTP信息头管理器下的“前置处理器”配合。如果遇到响应头里的参数,我会在正则表达式提取器里把模板直接作用于响应头字段,这需要一些经验,但原理和响应体提取完全一致。
6. 传参失败时的排查套路:从“结果树”到“最终原因”的全过程
6.1 Debug Sampler是排障第一件武器
参数传不过去,第一反应别去瞎改正则,先加一个“调试取样器”(Debug Sampler)看变量状态。调试取样器的位置很关键:你把它放在引用变量的请求之后、下一个请求之前,勾选“JMeter变量”和“JMeter属性”,然后用查看结果树看它输出的内容。它会把你当前线程里所有变量和当前JVM里所有属性的键值都打出来。
我举个真实排查经历。某次我在压测一个支付链路,支付请求一直返回签名无效。脚本参数里明明写了${order_id},可请求发出去却显示空字符串。我在下单请求后面加了调试取样器,发现order_id那一栏是NOTFOUND。再往下翻下单请求的响应,发现字段名根本不是order_id,而是data.orderId——一个字母大小写不对,提取器就空手而归。这就是典型的“路径写错但JMeter不报错”的坑。要不是调试取样器把变量值打出来,我可能还在改签名算法。
6.2 实战排查五步法
按下面的顺序排查,基本能解决90%的参数传递问题。
第一步:打开查看结果树,找到你期望提取数据的那个请求,看响应体里是否真的有目标字段。没有?那是上游问题,别在这里折腾提职器。
第二步:确认提取器是否挂在正确的采样器下面。提取器作为后置处理器,只会等它直属的采样器执行完之后才运行。你挂在了登录请求下,它不会在查询请求执行完后再跑一遍。
第三步:在引用变量之前的位置加调试取样器,看变量值到底是什么。变量名拼写错误、未定义、提取失败,一眼就能看出来。
第四步:单独执行一次目标采样器,观察第一次循环和第二次循环的变量差异。如果第一次为空、第二次有值,十有八九是“先引用后提取”的顺序问题,把请求顺序或循环结构调整一下就好了。
第五步:检查变量是否跨了作用域。跨线程组的一定要用属性,同一线程组内才可以用变量。多线程并发下,如果每个线程都往同一个属性里写数据,后写的线程会覆盖先写的值,目标请求拿到的属性会飘。
6.3 常见失败原因速查表
| 现象 | 可能原因 | 排查入口 | 解决方向 |
|---|---|---|---|
请求体里出现${token}字面量 | 变量名拼写错误或变量不存在 | 调试取样器看变量列表 | 修正引用名称,保持提取器与引用一致 |
| 请求体里该参数为空 | 提取器未执行或提取路径错误 | 查看结果树看响应体 | 核对JSONPath/正则表达式,检查挂载位置 |
| 第一个线程组有值,第二个线程组为空 | 跨线程组用了变量而非属性 | 调试取样器看属性列表 | 用props.put+${__P()} |
| 多线程并发,属性值偶尔错乱 | 多个线程往同一个属性写入 | 脚本逻辑审查 | 尽量用变量,不要用属性保存线程私有数据 |
| 提取器返回多个值,引用只拿到一个 | 匹配数字设置不当 | 调试取样器看变量列表 | 正则用-1取全部,JSON提取器用-1取全部再遍历 |
| 第二次循环才拿到值 | 第一次循环中引用点早于提取点 | 查看结果树逐请求分析 | 调整请求顺序或把提取器放到更早的位置 |
6.4 我踩过几次坑之后的固定习惯
写JMeter脚本写了几年,我现在基本养成了几个固定习惯,也分享给大家。
第一,脚本里长期保留一个“参数总览”节点。我一般会在所有业务请求之后放一个调试取样器,不勾选“JMeter属性”,只勾选“JMeter变量”,然后在查看结果树里把它折叠成一个固定的取样器条目。这样每次跑完脚本,我扫一眼最后那个节点,就能知道这次运行中哪些变量的值是正常的、哪些又变成了 NOTFOUND。等整个测试计划稳定后,我再把这个调试取样器禁用,而不是删掉,方便下次调试。
第二,给每个提取器加一个文本注释,写清楚“这个变量是从哪个接口的哪个字段提取的,给谁用的”。脚本写的时候是清楚的,三个月后回来看,没有注释的提取器就是一堆天书。变量命名也尽量带有业务语义,login_token、order_id这样的名字永远比var1、var2强。
第三,遇到那种怎么都提不出来的字段,我会先手动把响应体复制到文本编辑器里,用工具验证一下正则或JSONPath能不能匹配上。这一步能帮你把“提取器配置问题”和“响应结构问题”区分开。很多时候你以为是自己脚本写错了,其实是接口在报错状态返回了一个不含目标字段的异常响应体。先确认响应里有货,再回头查脚本,效率会高很多。
参数传递这种问题,说穿了就是一个数据流管理的问题。把提取、引用、作用域这三件事的边界想清楚,再配合调试取样器做可视化验证,脚本写起来会顺手很多,排查起来也不再是拿着放大镜到处找。希望这篇文章能帮你在下次遇到401、签名无效、参数为空的时候,少走几步弯路。