Jmeter 的 JSON 提取器(JSON Extractor)这个后置处理器,我在几乎每一个需要接口串联的压测脚本和自动化校验脚本里都会用它。它的活儿说穿了很朴素:从上一次请求返回的 JSON 报文里,把某个字段抠出来,存成 JMeter 变量,下一次请求拿着这个变量当参数用。听起来平平无奇,但真正落到“提取一个参数的所有值”和“一次提取多个参数”这两个场景上,翻车的概率相当高——Match No. 填错一位,脚本提取为空;JSON Path 多写了一行忘了补变量名,直接报参数不足;变量名少写一行,一堆请求全带着空值跑过去,还看不出来哪里错了。
这篇东西我打算按我自己的实操顺序来讲:先把提取器在脚本里的位置摆正,再把每个输入框的作用说透,然后分别攻克“单个值”“同一字段的所有值”“多个不同字段”这三类需求,最后把我这几年在真实项目里踩过的坑整理成一张排查表。刚接触 Jmeter 接口测试的人可以照着步骤直接抄,已经用过一段时间的人,建议重点看第 4、5、6 节,那几个地方是最容易出隐蔽问题的地方。
1. 先想清楚 JSON 提取器在脚本里承担什么角色
1.1 一次真实的接口串联场景
我先描述一个几乎所有做过接口压测的人都遇到过的场景。业务是这样的:用户登录拿 token,然后用 token 去查订单列表,拿到订单号之后再逐个去查订单详情,最后对订单做一次状态变更。整条链路里,token、订单号列表、订单详情里的商品 ID,全部都要从上一个响应里抠出来,否则后面的请求根本没法发。
放在 Jmeter 里,这几类数据传递全靠后置处理器。JSON 提取器就是其中最常用的一种后置处理器,它挂在某个 HTTP 请求下面,在一个采样器执行完成、拿到响应体之后立即运行,把匹配到的内容写进 JMeter 的变量空间。写进去的变量,同一个线程组内的后续采样器就可以用${变量名}的方式引用。
它和其他后置处理器的区别在哪里?正则表达式提取器(Regular Expression Extractor)什么响应都能处理,但写正则本身有门槛,遇到嵌套层级深、字段名重复的 JSON,正则写起来又长又容易错;XPath 提取器只对 XML/HTML 有效,现在主流接口基本都是 JSON,用不上。JSON 提取器基于 JSON Path 语法,表达力强、可读性好,只要你的接口返回是标准 JSON,它就是第一选择。
注意:JSON 提取器只管从 JSON 文本里取值,它不做 JSON 合法性校验。响应体本身不是合法 JSON(比如返回了 HTML 错误页、或者前面有多余的 BOM 字符),提取器不会报错,只会把变量设成默认值,然后你在后面的请求里看到一堆空参数。这种“静默失败”最坑人。
1.2 提取器怎么选:JSON、正则、XPath 三种方案对比
很多人上来就问“哪个提取器最好”,这个问题本身没有标准答案,只有适配场景。我把三种常见提取器的特性整理成一张表,你可以按这张表快速判断。
| 对比维度 | JSON 提取器 | 正则表达式提取器 | XPath 提取器 |
|---|---|---|---|
| 适用响应格式 | 标准 JSON | 任意文本 | XML / HTML |
| 语法门槛 | 中,需懂 JSON Path | 中高,需懂正则 | 中,需懂 XPath |
| 处理嵌套结构 | 强 | 弱 | 强 |
| 提取数组全部值 | 原生支持,Match No. 填 -1 | 需配合模板和 Match No. | 需配合循环 |
| 对格式变化敏感度 | 高,字段名一变就失效 | 低,但容易误匹配 | 中 |
| 调试难度 | 低,可用在线 JSON Path 工具验证 | 高,正则调试费时 | 中 |
我自己的判断逻辑是这样:接口返回是 JSON,就默认用 JSON 提取器;返回是 XML 或者老系统的 SOAP 报文,用 XPath;返回是纯文本、或者需要从一段非结构化字符串里抠内容(比如从重定向 URL 里取参数),才动用正则。这个优先级能覆盖九成以上的场景。
有一点要提前说明:Jmeter 的 JSON 提取器在不同版本上的实现细节有差异。较老的版本可能需要额外装插件,新的版本(3.0 以后)是内置的,直接用就行。JSON Path 的解析引擎用的是 Jayway 那一套,所以过滤表达式、数组下标这些写法基本和主流 JSON Path 工具一致,你可以在浏览器里找在线的 JSON Path 测试页面先把表达式调通,再贴进 Jmeter,能省下大量反复跑脚本的时间。
2. JSON 提取器的每个输入框到底怎么填
2.1 Name、Apply to 与作用域
打开 JSON 提取器的配置面板,第一项是 Name,这个纯粹是给你自己看的备注名,写“提取token”“提取订单号列表”这种一眼能懂的名字就行。它不影响任何逻辑,但脚本跑起来之后在结果树里排查问题时,一个清晰的命名能救你不少时间。
第二项 Apply to(应用范围)有四个选项,这里是最容易被忽略的地方:
- Main sample and sub-samples:主采样器和所有子采样器都处理。
- Main sample only:只处理主采样器,这是默认值。
- Sub-samples only:只处理子采样器。
- JMeter Variable Name to use:不处理响应体,而是从一个已有的 JMeter 变量里取值再提取。
绝大多数情况下默认的 Main sample only 就够了。什么时候需要改?当你的请求触发了重定向,或者页面里带 iframe、嵌入式资源请求时,响应内容可能落在子采样器上。还有一种常见情况是用“JMeter Variable Name to use”做二次提取——比如前任一个正则把整个 JSON 字符串抠到了一个变量里,你想用 JSON Path 在这个变量内部再精确取值,就填那个变量的名字。
作用域的问题更值得单独强调。后置处理器的影响范围是它所在的控制器层级,挂在某个 HTTP 请求下面,它就只对这个请求生效;如果你不小心把它拖到了线程组这一层,线程组里所有采样器执行完都会跑一遍这个提取器,轻则浪费性能,重则把变量覆盖成别的接口的值。我见过最典型的一次事故,就是把提取 token 的处理器放错层级,结果第二个请求的响应把 token 变量覆盖了,后面所有请求全带错凭证。
2.2 JSON Path expressions 与 Default Values 的对应关系
JSON Path expressions 这个框支持多行输入,一行一个表达式,每行对应一个变量。默认值(Default Values)同样是多行输入,按顺序和表达式一一对应。这个对应关系是理解“一次提取多个参数”的关键,后面第 5 节会详细展开。
表达式的基本写法,$代表根节点,.往下走一层,..递归查找,[]用于数组下标或过滤条件。几个例子:
$.data.token 取值 data 下的 token $.data.list[0].orderNo 取列表第一个元素的订单号 $.data.list[*].orderNo 取列表所有元素的订单号 $..orderNo 递归查找任意层级的 orderNo $.data.list[?(@.status=='PAID')].orderNo 过滤出已支付订单的订单号 $.data.total 取总数 $.data.list.length() 取列表长度默认值这一项,我建议永远不要留空。留空的后果是提取失败时变量是空字符串,后续请求发出去的是?orderNo=,服务端报个参数校验失败,你还得回头一层层查。填一个有辨识度的默认值,比如NOT_FOUND,在结果树里一眼就能看出这次提取到底成没成。
提示:默认值行数少于表达式行数时,多出来的表达式会用最后一个默认值兜底;反过来,默认值给多了不会报错,多出来的行被忽略。为了可读性,最好还是严格对齐数量。
2.3 Match No. 与 Compute concatenation var 的联动
Match No.(匹配编号)是第二个最容易出错的输入框,它决定了在一个表达式命中多个结果时取哪一个:
| 取值 | 行为 |
|---|---|
| 0 | 随机取一个匹配项 |
| 1 | 取第一个匹配项 |
| 2、3……n | 取第 n 个匹配项 |
| -1 | 取所有匹配项,逐个存成带下标的变量 |
这里有个细节必须说清楚:不管 Match No. 填多少,Jmeter 都会额外生成一个<变量名>_matchNr变量,记录本次一共匹配到多少条。这个数字在调试时非常好用,你可以用 Debug Sampler 直接看到它。另外,如果 JSON Path 压根没匹配到任何内容,_matchNr会是 0。
Compute concatenation var 这个勾选项配合 Match No. = -1 使用,勾上以后,所有匹配项会用英文逗号拼成一个字符串,存进<变量名>_ALL里。注意是英文逗号,如果你的待提取值本身包含逗号,拼出来的结果就没法再拆分了,这个坑我在第 6 节会再提一次。
还有一个容易被忽略的点:Match No. 填 0(随机)时,每次执行的结果都不一样,脚本的稳定性会下降。压测场景下我一般不用随机匹配,只在做数据多样性测试、故意想让每次请求打在不同数据上时才用,并且会在文档里注明。
3. 提取单个参数:从登录到下单的完整实操
3.1 抓包确认响应结构
动手写脚本之前,先用抓包工具或者浏览器开发者面板,把目标接口的响应体完整看一遍。假设登录接口返回的报文长这样:
{ "code": 0, "message": "success", "data": { "token": "eyJhbGciOiJIUzI1NiJ9.abc123", "userId": 10086, "expireAt": 1735689600 } }“看一眼”这件事看着废话,但我见过太多人直接凭接口文档写 JSON Path,结果文档里写的是data.accessToken,实际返回的是data.token,脚本跑起来一直提取为空,白白折腾半小时。以实际响应为准,这是铁律。
3.2 配置提取器并验证
在登录请求上右键,添加 - 后置处理器 - JSON 提取器,按下面填:
- Name:提取登录token
- Apply to:Main sample only
- JSON Path expressions:
$.data.token - Match No.:1
- Default Values:NOT_FOUND
然后在同一个线程组里加一个 Debug Sampler(调试取样器),再挂一个察看结果树。跑一次,在结果树里点 Debug Sampler 的响应数据,你能看到当前线程内所有 JMeter 变量和属性,确认变量名和值都对。这一步是我强烈建议固定下来的习惯动作,比在后面请求里瞎猜变量有没有取到高效得多。
3.3 把变量传给下一个请求
变量拿到之后,在下个请求的 HTTP 请求头里加一行Authorization: Bearer ${token},或者在请求体里写{"userId": ${userId}}。这里要注意两点:
第一,变量引用必须写全${变量名},不要写成$变量名或#{变量名}。Jmeter 只有${}一种写法。
第二,如果你是在请求体里用 JSON 格式传参,数字类型的值直接用${userId}没问题,但如果值本身是字符串类型,必须自己带上引号,写成"token": "${token}"。漏了引号,拼出来的报文就不是合法 JSON,服务端会直接拒掉。
还有一个实操经验:压测脚本里,登录这类接口通常只跑一次,后面的业务接口跑很多次。这时候要控制登录请求的执行次数,别让它跟着循环一起跑。常见的做法是在线程组下加一个仅一次控制器(Once Only Controller),把登录请求包进去,token 提取器也跟着放进去。这样每个线程只登录一次,后续循环复用这个 token。
4. 提取一个参数的所有值
4.1 Match No. 填 -1 之后发生了什么
这是标题里点名的第一个高频需求。场景很典型:订单列表接口返回了 20 条订单,每条都有一个 orderNo,你要把这 20 个订单号全拿出来,后面逐个去查详情。响应体结构如下:
{ "code": 0, "data": { "list": [ { "orderNo": "NO20240001", "amount": 99.5 }, { "orderNo": "NO20240002", "amount": 128.0 }, { "orderNo": "NO20240003", "amount": 45.0 } ], "total": 3 } }配置 JSON 提取器:
- JSON Path expressions:
$.data.list[*].orderNo - Match No.:-1
- Default Values:NOT_FOUND
跑完之后,变量空间里会出现这些东西:
| 变量名 | 值 |
|---|---|
| orderNo_1 | NO20240001 |
| orderNo_2 | NO20240002 |
| orderNo_3 | NO20240003 |
| orderNo_matchNr | 3 |
| orderNo_ALL | NO20240001,NO20240002,NO20240003 |
注意orderNo_matchNr这个名字,是“变量名 + 下划线 + matchNr”,不是matchNr。我见过有人直接写${matchNr}结果取不到值,就是这个原因。
4.2 _matchNr 与 _ALL 的正确用法
_matchNr最实用的地方是配合循环控制器做动态次数的迭代。假设你想让后面的详情查询接口跑的次数刚好等于列表长度,可以把循环控制器的循环次数写成${__V(orderNo_matchNr)},或者更简单,用 ForEach 控制器自动遍历,不用自己数。
_ALL适合的场景是:后端某个接口要求把多个 ID 用逗号拼成一个参数传过去,比如批量查询接口GET /order/batch?nos=NO20240001,NO20240002,NO20240003。这时候你什么都不用做,直接把${orderNo_ALL}塞进请求参数就行。但前提是被提取的值里本身不含逗号,否则拼接结果会被服务端拆错。
注意:
_ALL只有在勾选 Compute concatenation var 时才会生成。图片里那个勾选框很多人习惯性跳过,等到要用${xxx_ALL}的时候发现变量不存在,回头再翻配置。
4.3 用 ForEach 控制器把列表跑一遍
这是把“提取所有值”真正用起来的核心步骤。在线程组下添加一个 ForEach 控制器(逻辑控制器 - ForEach Controller),配置如下:
- 输入变量前缀:orderNo
- 开始索引:1
- 结束索引:留空
- 输出变量名称:currentOrderNo
- Add "_" before number:勾选
ForEach 控制器会从 orderNo_1 开始往后找,每找到一个就把值赋给${currentOrderNo},循环体内的采样器执行一轮,然后继续找下一个。循环体里放订单详情请求,路径写/order/detail/${currentOrderNo},就能把列表里的订单逐个跑一遍。
这里有个隐藏的坑必须提醒:ForEach 遇到第一个不存在的编号就会停止。假如你的变量是 orderNo_1、orderNo_2、orderNo_4(中间断了号),循环只会跑两次。正常情况下 Jmeter 生成的下标是连续的,不会断,但如果你在脚本里手动改过变量名、或者同时有多个提取器往同一个前缀写值,就可能出现断号。遇到“循环次数比预期少”,先查这个。
另外,orderNo_matchNr和orderNo_ALL这两个变量名虽然是orderNo_开头,但后缀不是纯数字,ForEach 不会把它们当成有效项去遍历,这点可以放心。不过如果你手上有其他工具或插件对这个前缀做字符串匹配,就要留意一下了。
5. 一次提取多个参数
5.1 多行表达式的对齐规则
同一个响应里往往不止一个字段有用。比如订单列表的每一条里,你既要订单号,又要商品 ID,还要下单时间。这时候不用配三个提取器,一个提取器里写多行表达式就行:
$.data.list[*].orderNo $.data.list[*].productId $.data.list[*].createTime变量名区域(在 Jmeter 的 JSON 提取器面板里叫 Names of created variables,用分号分隔)写成:
orderNo;productId;createTime这里的规则是:变量名按分号切分,按顺序和表达式一一对应。第一行表达式的结果写进 orderNo,第二行写进 productId,第三行写进 createTime。用分号而不是逗号或换行,这一点必须记住,写错了整个提取器会失效,而且报错信息不直观。
三个表达式都设 Match No. = -1 的话,你会同时得到 orderNo_1..N、productId_1..N、createTime_1..N 三组带下标的变量,加上各自的 matchNr 和 ALL 变量。这三组变量的下标是对齐的,orderNo_2 对应的就是 productId_2 和 createTime_2,可以在 ForEach 循环里同时引用三个变量拼成一条完整记录。
5.2 数量不匹配会怎样
变量名数量少于表达式数量时,多出来的表达式照样会执行,但结果无处可放,通常会落到最后一个变量名上或者直接被丢弃,具体表现依版本略有差异。变量名多于表达式数量时,多出来的名字对应的变量会被设成默认值。不管哪种情况,都不会抛出显眼的错误,脚本照跑,只是数据不对。
这就是我为什么一直强调:配完多个参数之后,一定要用 Debug Sampler 过一遍,逐个核对变量名和值。三个提取目标,结果只出来两个,靠肉眼在结果树的海量日志里翻,效率极低。
5.3 复杂嵌套与过滤表达式的实战写法
真实接口的 JSON 结构往往比示例复杂得多。举几个我实际处理过的形态:
嵌套数组取值。响应里是data.orders[0].items[0].skuId这种结构,如果只要第一层第一条的第一件商品,直接写$.data.orders[0].items[0].skuId。如果要所有订单下所有商品的 SKU,写$.data.orders[*].items[*].skuId,递归展开。
带条件的过滤。只想要金额大于 100 的订单号:$.data.list[?(@.amount > 100)].orderNo。Jayway 这套语法里,字符串比较要用单引号包起来,写成[?(@.status=='PAID')],数字不用引号。这一点和某些在线工具默认的写法不一样,贴进 Jmeter 之前最好本地验证一次。
取数组长度做断言。$.data.list.length()可以直接拿到列表长度,提取出来存在变量里,配合响应断言校验“本次返回条数是否为 10”。这种用法在压测时检查分页逻辑有没有出错特别顺手。
递归查找兜底。$..token会扫描整个文档里所有叫 token 的字段。当你不确定 token 藏在哪一层,或者接口结构经常调整时,用递归查找能提高容错率。代价是如果报文里有多个同名 token,你会一次拿到一串值,得自己判断取第几个。
再补充一个多个提取器协同的写法。有些字段的提取依赖上一步的结果,比如先提取出所有订单号,再从某个嵌套结构里按订单号过滤。JSON Path 本身能做条件过滤,但依赖关系复杂时,我倾向于拆成两个提取器串联,第一个提取列表,第二个用JMeter Variable Name to use模式做二次加工。可读性比堆一个超长表达式好得多。
6. 常见问题与排查
6.1 提取结果为空的排查顺序
提取为空是出现频率最高的问题。我整理了一套固定的排查顺序,按这个顺序走,基本五分钟内能定位:
- 确认响应体里真的有这个字段。打开结果树,看响应数据原文,别只看接口文档。
- 确认响应体是合法 JSON。如果接口在出错时返回 HTML 页面,或者返回体前后带了说明文字,JSON Path 直接解析失败,此时应改用正则提取器。
- 确认 JSON Path 表达式正确。复制响应体到在线 JSON Path 工具里验证,能出结果再贴回 Jmeter。
- 确认 Apply to 选对了。重定向、子请求场景下,值可能不在主采样器的响应里。
- 确认提取器的作用域。是不是放错层级,或者被别的采样器的后置处理器覆盖了。
- 确认变量引用写法。是
${orderNo_1}而不是$orderNo_1,也不要把下标写错。
6.2 编码、转义与特殊字符
有几个特别隐蔽的情况,值得单独列出来。
BOM 头导致解析失败。某些服务端返回的 JSON 文件前面带了不可见的 BOM 字符,人眼看着完全正常,JSON Path 就是匹配不到。这种情况一般出现在直接返回静态文件的场景,处理方法是检查服务端输出编码配置,或者改用正则提取器绕过。
中文乱码。如果待提取的值包含中文,先确认 Jmeter 的默认编码设置和 HTTP 请求里的内容编码配置是否一致,全链路编码统一到 UTF-8 是最省心的做法。乱码问题不解决,后面拿这个变量去发请求,服务端校验必然失败。
值里带逗号。前面提到过,_ALL变量用逗号拼接过个值,如果原始值里自带逗号,拼接结果就无法反向拆分。批量查询这类场景,建议改用 ForEach 逐个发请求,或者用 JSR223 后置处理器自己拼接成分号、竖线等安全分隔符。
值里带引号或反斜杠。变量放进请求体时,如果值本身含双引号,拼出来的 JSON 会断掉。需要在脚本层面做转义处理,或者把参数放到请求头、路径里,避开 JSON 拼接。
6.3 排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 变量值一直是默认值 | JSON Path 写错、字段不存在 | 用在线工具验证表达式,核对响应原文 |
| 变量名找不到 | 变量名写错、提取器未生效 | 用 Debug Sampler 查看当前所有变量 |
| 只取到第一个值 | Match No. 填了 1 | 改为 -1 |
_ALL变量不存在 | 未勾选 Compute concatenation var | 勾选该项并重跑 |
| ForEach 循环次数偏少 | 变量下标断号 | 检查是否有多个提取器写同一前缀 |
| 多参数提取时部分为空 | 变量名和表达式数量不一致 | 按分号逐行对齐核对 |
| 跨线程组取不到变量 | 提取的变量是线程局部的 | 用属性传递或在同一线程组内完成链路 |
| 偶发提取失败 | Match No. 填了 0 导致随机匹配 | 改为固定编号,保证脚本可复现 |
跨线程组传参这个问题值得展开讲两句。JSON 提取器写出来的是线程变量,作用范围只在本线程内。如果你有多个线程组,想在 A 组提取、B 组使用,标准做法是在 A 组里用 JSR223 后置处理器或函数把值写进 Jmeter 属性,B 组再用函数读出来。不过这种做法在并发场景下要小心——多个线程同时写同一个属性名会互相覆盖,最终读到的是谁的值说不准。真有这种需求,我一般改成 setUp 线程组做数据准备,或者干脆把整条链路放进同一个线程组里,用逻辑控制器组织,省掉跨组传递这一层麻烦。
7. 踩坑之后的几点经验
7.1 先想清楚变量的生命周期
变量在 Jmeter 里是线程隔离的。同一个线程组开 10 个线程,每个线程都会执行一遍登录、都会提取一次 token,各自存各自的副本,互不干扰——这是设计上的正确行为,不是 bug。但很多人第一次看到“结果树里有一堆不同 token”时会慌,以为脚本写错了。
理解这一点之后,很多设计就有了依据:想让每个虚拟用户都用独立身份,就让登录在循环里跑;想让大家共用一个凭证,就用 setUp 线程组提前跑一次,把结果写进属性供所有线程读取。选择哪种,取决于你要模拟的业务形态,而不是哪个写起来简单。
7.2 表达式别写得太“聪明”
我早期写 JSON Path 有个坏习惯,喜欢用$..递归查找来“兼容所有结构”,觉得这样接口改版了脚本也不用改。实际用下来发现这是个陷阱:一旦报文里出现多处同名字段,递归查找会一次返回一堆值,Match No. 填 1 就只取第一个,运气不好就取错了根本不是你想要的字段,而且排查起来毫无头绪。
后来我改成写明确的绝对路径,$.data.orderList[0].orderNo这种,一眼能看出取的是哪一层。接口改版了脚本会立刻报错,早报错比晚报错好,至少你知道是这里需要改。
7.3 提取器和断言配合,效果翻倍
单纯把值提取出来只是第一步,真正让脚本变得可靠的是加断言。我通常在提取之后挂一个响应断言,校验code字段是不是 0;再挂一个 JSR223 断言,检查提取出来的_matchNr是不是大于 0。这样一个接口返回了错误结构、或者列表为空的时候,脚本会立刻标红,而不是安静地带着空参数继续往下跑,最后在某个不相干的接口上暴露出一个莫名其妙的报错。
JSR223 断言里可以直接读变量,比如检查vars.get("orderNo_matchNr")转换成整数后是否大于 0,不满足就AssertionResult.setFailure(true)。这段逻辑写一次可以复用到所有提取场景,比每个接口单独写断言省事得多。
7.4 把常用表达式记成模板
最后分享一个我自己的习惯:把高频使用的 JSON Path 表达式和维护要点整理成一个文本文件,新项目直接复制修改。里面大概长这样:
单值: $.data.token 列表全值: $.data.list[*].orderNo Match No.=-1 条件过滤: $.data.list[?(@.status=='PAID')].orderNo 数组长度: $.data.list.length() 递归查找: $..orderNo 谨慎使用 嵌套展开: $.data.orders[*].items[*].skuId配合一份“配置检查清单”——变量名数量与表达式数量是否对齐、Match No. 是否为预期值、默认值是否填写、是否用 Debug Sampler 验证过——几分钟就能配好一个新的提取器,比每次从零回忆这些细节快得多,也少犯很多重复性的错误。