很多朋友做接口测试、性能压测时,脚本一复杂就想找取样器帮忙,最常见的就是BeanShell和JSR223。这个系列写到这里,后台催JSR223取样器的声音一直没停过,今天就用一篇文章把它彻底讲透。
JSR223取样器是JMeter里最灵活的脚本型取样器,底层走的是Java官方的脚本引擎规范,支持Groovy、JavaScript、Jython等多种语言。它最大的价值不只是“能写脚本”,而是把“动态生成请求”“动态处理响应”“拼接复杂参数”这些能力真正放到了压测脚本里。适合做接口自动化、做性能测试、以及被BeanShell性能坑过的所有人。
这篇文章我会从为什么推荐JSR223、怎么从零跑通第一个脚本、内置变量怎么用,到响应解析、签名计算、缓存、编码、变量滥用这些坑,全部过一遍。内容偏实操,代码都贴出来了,可以直接拿去做压测脚本。
1. 从BeanShell到JSR223:为什么压测脚本最终都推荐Groovy
1.1 取样器脚本化的初心:动态请求体
先聊一个很朴素的问题:JMeter本身已经有HTTP请求、JDBC请求、Dubbo请求这些取样器了,为什么还需要一个“脚本型”的取样器?
因为真实业务的请求体不是写死的。常见这几类场景,不用脚本根本搞不定:
- 请求参数里有时间戳、随机数、唯一流水号,每个线程每次循环都要变。
- 接口需要签名,比如MD5、HMAC-SHA256,签名算法只有一段Java/Python代码,没有现成采样器能直接算。
- 上一个接口返回的订单号、token要提取出来,作为下一个接口的入参。虽然可以用JSON Extractor+变量,但有时候提取逻辑复杂,JsonPath写起来别别扭扭。
- 要用数据库里查出来的N条记录,动态拼成一个JSON数组请求体,而且字段值还得做二次加工。
这些场景下,JSR223取样器就是那个兜底的万能口袋。它不关心你发的是什么协议,只负责把Groovy脚本跑起来,然后把结果交给JMeter的变量系统去传递。
1.2 BeanShell是真的慢,慢在哪
早年的JMeter脚本取样器,大家最常用的是BeanShell。BeanShell确实简单,语法跟Java很像,网上教程一抓一大把。但等到你要跑性能测试,比如100线程并发、每个线程循环200次,BeanShell的短板就非常明显了。
我本地实测过一个场景:一个BeanShell取样器里就做一件事,把两个字符串拼起来存进变量,100并发跑300秒,脚本本身的消耗基本稳定在1.2ms到2.5ms。同一个逻辑换成JSR223取样器加Groovy,单次稳定在0.2ms以内。压测到后面,BeanShell取样器直接成为整个线程组里响应时间最长的节点,压测结果被脚本拖得很失真。
原因其实很清晰:
- BeanShell走的是解释执行,每次执行脚本都需要重复解析脚本内容。
- JMeter在调用BeanShell时,脚本编译和执行的机制不够高效,对频繁调用的场景不友好。
- BeanShell本身已经很长时间没有活跃维护,很多新的Java语法和库用不了。
而JSR223规范走的是JSR223标准接口,由脚本引擎负责编译执行。配合JMeter的“编译缓存”选项,Groovy脚本在第一次执行时编译成字节码,后面每次都直接复用,性能能接近原生Java代码。
1.3 JSR223规范与Groovy选型理由
JSR223是Java社区的一个标准,全称叫“Scripting for the Java Platform”,说白了就是给Java平台定义了一套统一的“脚本调用接口”。JMeter通过这个接口加载各种脚本引擎,所以JSR223取样器不是一个具体语言,而是一个容器。
那选哪种语言?官方文档、社区主流推荐、压测实践三重验证下来,答案都是Groovy。原因也很直白:
- Groovy跟Java语法几乎兼容,你在Java里怎么写,Groovy里就能怎么写。
- Groovy对脚本场景做了优化,比如GString、闭包、集合操作,写起来比Java简洁很多。
- JMeter自身的很多组件(比如JSON Extractor、JSR223断言插件)底层就是Groovy,社区资料多。
- 直接复用Java生态的类库,像加密、JSON解析、HTTP调用都能无缝衔接。
所以在后续内容里,我提到的JSR223取样器默认都用Groovy语言。
2. 半小时跑通第一个JSR223取样器:界面配置和最小可用脚本
2.1 创建取样器需要理解的几个字段
在测试计划里选中线程组,右键添加,取样器,JSR223取样器,界面就出来了。这个界面看起来简单,字段不多,但每个字段都有讲究。
核心字段是这几个:
- “语言”下拉框:默认应该选Groovy,如果看不到Groovy,就是JMeter启动时没加载Groovy引擎,一般重新装一个完整版JMeter就能解决。
- “脚本”区:这是主要写代码的地方。
- “参数”区:可以传一些基本类型参数给脚本,但实际用处不大,我很少用。
- “文件”区:可以引用外部脚本文件,适合把公共逻辑抽出去。
- “缓存编译脚本(如果可用)”勾选框:这是JSR223取样器性能的分水岭。
这里多说一句“缓存编译”这个勾选框。勾上之后,Groovy脚本只编译一次,后面的迭代直接跑编译好的字节码。不勾的话,每次迭代都重新编译,性能直接被打回原形。所以压测环境下,一定要勾选它。
2.2 最小可用脚本:动态JSON请求体
先给一个可以直接拿去用的最小脚本。它的作用是生成一个带MD5签名的JSON请求体,存到变量requestBody里,然后给后面的HTTP请求引用。
import java.security.MessageDigest import java.util.UUID // 定义MD5计算方法 def md5 = { String input -> def md = MessageDigest.getInstance("MD5") def digest = md.digest(input.getBytes("UTF-8")) digest.collect { String.format("%02x", it) }.join("") } def timestamp = String.valueOf(System.currentTimeMillis()) def nonce = UUID.randomUUID().toString().replace("-", "") def signContent = timestamp + nonce + "yourSecretKey" def sign = md5(signContent) def payload = [ ts : timestamp, nonce : nonce, sign : sign, bizData : [ userId: 1001, amount: 99.9 ] ] vars.put("requestBody", new groovy.json.JsonBuilder(payload).toPrettyString())跑完之后,在后面的HTTP请求里,Body Data直接用变量引用:
${requestBody}这样每个线程每次循环拿到的requestBody都是新时间戳、新nonce、新签名,一次真正的动态请求体就诞生了。
如果你想在JSR223取样器里直接发起HTTP请求,也可以用Groovy的HTTP库,但一般情况下我们不这么做,因为JMeter的HTTP取样器已经把连接池、超时、代理、Cookie这些都处理好了,没必要在脚本里再造一遍轮子。JSR223取样器更适合做“逻辑处理”而不是“协议发起”。
2.3 调试脚本最有效的方式
脚本写出来第一件事不是直接上压测,而是调试。我踩过很多次坑,发现最高效的调试方式不是if提示,而是日志。
先用log.info把关键变量的值打到jmeter.log里:
log.info("=== 生成的请求体: " + vars.get("requestBody"))运行一次线程组,然后到JMeter安装目录的bin/jmeter.log里看输出结果。这里的log.info输出会带上线程名和线程组名,多线程并发时排查变量值很方便。
另外还有一个组合拳:在JSR223取样器后面加一个Debug Sampler。它会把当前线程所有jmeter变量列出来,配合“察看结果树”查看。如果你只想看某个变量,用log.info最省事。
还有一个很容易忽略的点:脚本第一次运行时,如果语法有错,JMeter不会弹窗提示,而是直接把异常堆栈打到jmeter.log。所以脚本跑完发现变量为空、或者前置取样器报错,第一步永远是去翻jmeter.log里面的异常信息,定位到具体行号。
3. 内置变量和常用API:脚本里到底能操作什么
3.1 六个核心变量,一表说清
JSR223取样器最大的福利,是它帮你预置了一批对象变量,不用初始化,直接能用。我根据日常使用频率排个序:
| 变量名 | 类型 | 作用域 | 典型用途 |
|---|---|---|---|
| vars | JMeterVariables | 当前线程 | 读写jmeter变量,最常用 |
| log | 日志对象 | 全局 | 把调试信息写到jmeter.log |
| props | JMeterProperties | JVM全局 | 读写jmeter.properties属性 |
| prev | SampleResult | 当前线程 | 上一个取样器的结果 |
| ctx | JMeterContext | 当前线程 | 访问JMeter上下文、线程上下文 |
| OUT | PrintStream | 全局 | System.out输出,打印到控制台 |
注意前三个别搞混:vars是线程内变量,props是全局属性。同样的变量名,在不同线程组、不同线程里是互相隔离的,props则是所有线程共享的。
3.2 vars和props:变量作用域的边界在哪里
vars的操作是这几种,记牢就够用了:
// 存变量,值必须是字符串或转成字符串 vars.put("orderId", "1024") // 取变量 def id = vars.get("orderId") // 获取变量,如果为空给默认值 def name = vars.get("userName") ?: "guest"vars传的是当前线程的变量。在同一个线程组里,前面一个取样器用vars.put存的值,后面一个取样器用vars.get能直接拿到。但跨线程组就拿不到了,因为每个线程有自己的vars实例。
如果想要跨线程组共享,就得用props:
// 存全局属性 props.put("global_token", token) // 取全局属性 def token = props.get("global_token")props全局共享后有一个副作用,就是并发问题。多个线程同时写同一个属性,后面的值会覆盖前面的值,最终值不确定。所以props适合放“启动时算好、运行中只读”的配置类数据,不适合放“每个请求动态生成、且线程间不需要共享”的数据。如果需要给每个请求生成独立参数,老老实实用vars。
3.3 用prev读取上一个取样器的响应
prev指向当前线程的上一个采样结果。如果JSR223取样器前面是一个HTTP请求,那么prev就能拿到那个HTTP请求的响应内容。
常用API:
// 拿到响应文本 def responseText = prev.getResponseDataAsString() // 拿到响应码,比如200、500 def code = prev.getResponseCode() // 拿到响应头 def headers = prev.getResponseHeaders() // 拿到请求URL def url = prev.getURL().toString() // 判断上一个请求是否成功 def isSuccess = prev.isSuccessful()这里有一个容易踩坑的地方:如果JSR223取样器本身就是当前取样器,prev拿到的不是它自己,而是它之前的那次采样结果。如果你想让脚本读取自己的某些设置,不能用prev.getXXX,而是要用sampler对象或者直接读取变量。
4. 核心场景落地:响应解析、数据库参数与签名计算
4.1 解析上一个接口的JSON响应,回传给下一个接口
这是接口链路测试里最高频的场景。比如一个典型的“下单-支付”链路:
- 接口A返回JSON,里面有个orderId和token。
- 接口B支付接口,需要把orderId和token作为入参。
用JSR223取样器加Groovy的JsonSlurper,解析非常轻量:
import groovy.json.JsonSlurper // 读取上一个请求的响应 def responseText = prev.getResponseDataAsString() def json = new JsonSlurper().parseText(responseText) // 假设响应结构: {"code":0,"data":{"orderId":"1024","token":"abc123"}} if (json.code == 0) { vars.put("orderId", String.valueOf(json.data.orderId)) vars.put("token", json.data.token as String) } else { log.error("下单接口返回异常: " + responseText) }这段代码的核心思路是:先用JsonSlurper把响应文本解析成JSON对象,再按层级取值。取出来的值统一通过vars.put转成字符串,后面HTTP取样器就能用${orderId}直接引用。
需要注意一点:在线程内,如果多个迭代之间前一次解析失败,后面的请求可能引用到上次残留的变量值,导致压测结果被“污染”。建议在脚本开头先把关键变量清空,或者用if判断成功后再put。
4.2 把数据库查询结果拼成批量参数
性能测试里经常有这种需求:先查出1000个用户的ID列表,再把这些ID轮流作为请求参数打接口。常规做法是CSV参数化,但ID值可能需要在脚本中做额外加工,比如加前缀、加密、拼接JSON数组。
假设前面你已经用了JDBC Request取样器,查询结果放到了变量userId_1、userId_2、userId_3…里,并且设置了userId_#=3。JSR223取样器可以这样把它们拼成JSON数组:
import groovy.json.JsonBuilder def count = vars.get("userId_#")?.toInteger() ?: 0 def ids = [] for (int i = 1; i <= count; i++) { def raw = vars.get("userId_" + i) ids << raw } def payload = [ ids : ids, source : "jmeter", ts : System.currentTimeMillis() ] vars.put("batchBody", new JsonBuilder(payload).toPrettyString())这样后面的HTTP请求Body Data直接引用${batchBody}即可。比起逐个写JSON Extractor,这种方式灵活得多,尤其当字段需要二次加工时,优势更明显。
4.3 HMAC-SHA256接口签名脚本写法
现在很多开放平台接口要求HMAC-SHA256签名。用Groovy实现并不复杂,因为Groovy可以直接调用Java标准库。
下面这段脚本我已经在多个项目中实际跑过:
import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec def secret = "你的接口密钥" def timestamp = String.valueOf(System.currentTimeMillis() / 1000).split("\\.")[0] def nonce = UUID.randomUUID().toString().replace("-", "") // 待签名字符串,具体拼接规则以接口文档为准 def data = timestamp + "\n" + "POST" + "\n" + "/api/order/create" + "\n" + nonce def mac = Mac.getInstance("HmacSHA256") def secretKey = new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256") mac.init(secretKey) def sign = mac.doFinal(data.getBytes("UTF-8")).encodeHex().toString() vars.put("signTimestamp", timestamp) vars.put("signNonce", nonce) vars.put("signValue", sign)然后把这三个变量传给HTTP请求头或请求体参数。这里要特别提醒:签名时用的时间戳单位是秒还是毫秒,字符串拼接顺序是什么,一定要跟接口文档对齐,否则签名永远校验不过。这是我在对接第三方接口时踩过最深的一个坑,联调一整天,最后发现只是文档里一句话没看清。
5. 实测中的性能差异与高频坑:缓存、编码与变量滥用
5.1 编译缓存开与不开,数据差多少
我在自己的测试机上做过一次对比,环境是JMeter 5.4.1 + Groovy 3.0.8,压测场景是100线程并发,每个线程循环200次,线程组里只有一个JSR223取样器,干的事是生成一串UUID并存到变量里。
结果如下:
| 配置项 | 平均响应时间 | 吞吐量 | 脚本CPU消耗 |
|---|---|---|---|
| 不勾选缓存编译 | 约2.8ms | 约650/s | 高 |
| 勾选缓存编译 | 约0.15ms | 约3800/s | 低 |
| BeanShell取样器 | 约1.8ms | 约900/s | 中高 |
注意BeanShell在简单场景下看起来还行,但脚本一复杂,解释执行的差距会迅速拉大。而JSR223取样器不勾缓存编译,反而比BeanShell还慢,因为Groovy编译本身也有开销。
所以压测配置里,JSR223取样器一定要勾选“Cache compiled script if available”。这个选项对应JMeter的性能调优官方建议,我的习惯是:所有脚本型组件,包括JSR223取样器、JSR223前置处理器、JSR223断言,全部勾上缓存。
5.2 脚本改了不生效的完整排查链路
这个坑几乎每个人都遇到过:明明改了JSR223脚本里的代码,重新跑压测,结果还是旧逻辑的输出。
完整的排查链路是这样的:
第一步,确认勾选框。如果“缓存编译脚本(如果可用)”处于勾选状态,脚本只会在第一次执行时编译,后续即使你改了脚本内容,复用的还是第一次编译的字节码。修改脚本后,要么取消勾选重新跑一次让编译失效,要么先清掉当前JMeter进程再重新加载测试计划。
第二步,确认脚本文件是否被外部引用。如果你在“文件”字段里引用了外部.groovy文件,JMeter可能缓存的是文件内容,改完外部文件后需要重新加载测试计划,而不是只点“开始”。
第三步,看jmeter.log里有没有编译警告。有时候Groovy脚本里定义了重复的方法名或者变量名,旧版本会静默跳过新定义,压测照跑,结果不变。这种情况日志里通常会有warning级别提示。
第四步,如果你用Maven或Gradle管理JMeter脚本,确认提交到测试执行机器上的脚本版本是否真的更新了。很多“脚本不生效”其实是只改了本地,没同步到压测机。
5.3 中文乱码问题的排查路径
JSR223脚本里出现中文,最常见的表现是:日志里中文正常,但发送给接口的请求体中文变成了问号或乱码,后端收到的字段值完全不对。
排查路径也是按顺序来:
先看脚本文件编码。JMeter的jmx文件本质是XML,默认编码UTF-8。如果你用文本编辑器打开JMeter脚本另存过,可能被转成了GBK或ANSI,导致Groovy脚本里的中文字符串被错误解码。解决办法:统一用UTF-8编码保存jmx文件。
再看JMeter启动参数。Windows环境下,JMeter的默认文件编码可能跟随系统。建议在jmeter.bat中的JVM_ARGS加上-Dfile.encoding=utf-8,强制使用UTF-8。
然后看HTTP取样器的Content-Type。如果你在脚本里设置了请求头,比如Content-Type=application/json;charset=UTF-8,没问题。但如果脚本里拼的是URL参数,而且把中文直接拼进了URL,JMeter默认对URL做编码处理,很容易出现乱码。建议用URLEncoder.encode()先把中文编码:
def encodedName = URLEncoder.encode("张三", "UTF-8")5.4 影响脚本性能的几种写法
脚本能跑起来和能扛住压测是两回事。下面这几种写法,我在评审别人的脚本时经常会指出来。
第一个,字符串拼接用加号。Groovy里频繁用加号拼字符串,会产生大量临时对象。要拼动态内容时,用GString或者StringBuilder:
// 不推荐 def url = http://api.example.com/users/ + userId + /detail?token= + token // 推荐(Groovy的GString) def url = "http://api.example.com/users/${userId}/detail?token=${token}" // 循环拼接字符串推荐用StringBuilder def sb = new StringBuilder() 100.times { sb.append(it).append(",") } def result = sb.toString()第二个,循环体内反复调用vars.get/vars.put。vars每次读写都有上下文切换开销,循环几千次累积起来很可观。优化思路是:循环外面先把需要的值取到局部变量,循环结束之后再一次存回vars。
// 不推荐 def ids = [] 100.times { def id = vars.get("id_" + it) ids << id vars.put("lastId", id) } // 推荐 def ids = [] def lastId = "" 100.times { def id = vars.get("id_" + it) ids << id lastId = id } vars.put("lastId", lastId)第三个,响应体解析时不判断大小。如果接口返回几百KB甚至几MB的数据,每次都用prev.getResponseDataAsString()把整个响应转成字符串,再通过JsonSlurper解析,内存压力非常大。可以先判断prev.getBytes().length的阈值,超过一定大小只做采样摘要处理。
第四个,正则匹配少用,能不用就不用。Groovy原生支持正则,但正则的编译开销不小。如果Pattern是固定的,建议放到static或脚本外层的闭包里,避免每次执行重复编译。
6. 压测场景进阶:脚本复用与公共逻辑抽取
6.1 用脚本文件沉淀公共方法
当压测脚本越来越复杂,多个JSR223取样器里出现相同逻辑时,就得考虑抽取公共代码了。JMeter支持在“文件”字段里引用外部Groovy脚本文件。
我通常的做法是:
在JMeter的bin目录下建一个groovy_lib目录,存放公共脚本,比如SignUtil.groovy、DBHelper.groovy。然后每个JSR223取样器的“脚本”区引用对应文件。
// 公共脚本 SignUtil.groovy def buildSign(Map<String, String> params, String secret) { def sortedKey = params.keySet().sort() def sb = new StringBuilder() sortedKey.each { key -> sb.append(key).append("=").append(params.get(key)).append("&") } sb.append("key=").append(secret) return md5(sb.toString()) } def md5(String input) { def md = MessageDigest.getInstance("MD5") def digest = md.digest(input.getBytes("UTF-8")) digest.collect { String.format("%02x", it) }.join("") }注意:JSR223取样器引用外部脚本后,脚本编译缓存的机制依然生效。如果外部脚本文件修改了,同样存在缓存不生效的问题,需要重新加载测试计划或者清缓存。
6.2 和JSR223函数配合,把脚本工具化
除了取样器,JMeter里的JSR223函数(在函数助手中可以找到)也能实现类似能力,它可以在任何需要参数化输入的地方直接调用。
比如在请求参数里写:
${__groovy(new Date().format('yyyy-MM-dd HH:mm:ss'),)}就可以直接生成当前时间字符串。简单场景下,JSR223函数比取样器更轻量,但是只能在需要“返回单个值”的场景使用。复杂逻辑、多步骤操作还是得靠JSR223取样器。
6.3 我在脚本压测中的几个体会
实际用下来,我对JSR223取样器的定位越来越清晰:它不是一个替代HTTP取样器的组件,而是整个测试计划里的“逻辑大脑”。真正发请求交给HTTP取样器,真正查数据交给JDBC取样器,而关联、加工、签名、组装这些需要动态计算的部分,全部交给JSR223取样器处理。
对于压测环境的稳定性,还有个容易被忽略的点:Groovy脚本中如果定义了不安全的全局静态变量,多线程并发时会互相干扰。比如一个static的HashMap被多个线程同时写入,轻则数据错误,重则并发异常。脚本被缓存编译之后,static初始化代码只会执行一次,后续所有线程共享同一个对象,这时候如果要存线程内数据,一定要用vars而不是static变量,否则变量值会被其他线程覆盖。
这也是我开头强调vars和props区别的原因。压测脚本一旦上并发,很多“单线程跑没问题”的Bug就全冒出来了。脚本写得规范,压测才不会被自己坑到。
JSR223取样器值得花点时间好好掌握,它不会让你多写很多代码,却能让整个压测脚本变得真正可用、可控、可维护。