压测做久了,你会发现真正折磨人的往往不是并发量,而是请求参数本身。JMeter自带的那套函数库,平时用着还行,一旦遇到“每个请求都要动态算签名”“按业务规则生成一批特定手机号”“从响应里取出来的值还得做二次编码”这类场景,就开始绕弯子了。我经历过在BeanShell里敲一大段脚本、跑起来又慢又难排查的时期,后来认真做了一次JMeter二次开发,才把这类问题彻底解决掉。
这里说的二次开发,不是改JMeter源码,而是利用JMeter预留的扩展点,用Java写一个自定义函数。写完之后打成jar包放进JMeter的lib/ext目录,它就能和内置函数一样,出现在函数助手对话框里,用${__xxx(...)}的方式直接引用。这篇文章就从实际需求出发,从环境准备、核心原理、代码编写、打包部署到问题排查,把整个链路完整过一遍。适合已经会用JMeter做接口压测、懂一点Java、想把工具用得更顺手的人。
1. 为什么Jmeter内置函数不够用?二次开发的切入点
1.1 内置函数解决不了的四个典型场景
我一开始也觉得,内置函数配CSV、配JSON提取器,大部分压测场景都能覆盖。可真上了生产级的接口压测项目,这个想法很快被打破。
第一个场景是接口签名。现在稍微正经一点的业务接口,都会做防篡改校验:请求头里带一个timestamp,再带上一个把时间戳、密钥、业务参数拼接后算出来的sign。你算一次当然简单,但压测是持续几分钟甚至几十分钟的高并发,Sign得随每个请求动态变化。用JMeter的__time函数能生成时间戳,可是MD5、SHA256这种加密运算是内置函数里没有的。
第二个场景是特殊格式的数据生成。有些系统要求手机号必须以13x开头、身份证要符合校验位规则,或者订单号要带每天的日期前缀。内置的__Random只能生成纯随机数字,你拿它拼出来的数据,可能直接在业务校验层就被拦掉了,压测根本没打到真正想测的接口上。
第三个场景是请求值需要跨接口联动。比如登录接口返回一个token,后面的下单接口要拿token加业务参数再算一次签名。从响应里提取token用JSON提取器就能办到,但“拿到之后做某种计算再塞进下一个接口”这件事,内置函数帮不上忙。
第四个场景更简单也更实际:团队里不同人写的脚本,同样的逻辑各自实现了一遍。今天张三写一段BeanShell加密,明天李四在另一个脚本里再写一遍,谁也不敢保证结果一致,维护起来也痛苦。
这四个场景的共同点,是“有业务逻辑的数据处理”。内置函数的定位是通用工具,不会为某个公司的特定规则定制功能。既然它没做,就得我们自己动手。
1.2 BeanShell能写,但为什么不是最佳方案
很多人遇到上面的问题,第一反应是用BeanShell或JSR223脚本,在测试计划里写一段Groovy代码直接计算。我也这么干过,确实能解决一部分问题,但越用越觉得别扭。
最大的问题是性能。BeanShell脚本引擎在JMeter里跑起来是出了名的慢,因为它是脚本解释执行,每调用一次就要经过脚本引擎的解析、执行、返回。压测本身追求的是把服务器的性能榨出来,结果客户端自己先扛不住了,出来的数据就没参考价值。Groovy的JSR223脚本性能好不少,但要是不加缓存、每个请求都重新编译一遍,同样会有额外开销。
其次是代码维护问题。脚本写在测试计划里,意味着校验逻辑分散在各处。项目一换、人员一流动,后面接手的人打开jmx文件,看到一段三百行的Groovy,心态直接崩了。也没办法做单元测试,每次改完逻辑只能重新跑一遍压测脚本验证,效率低得让人怀疑人生。
还有复用性。原来TeamA写好的加密脚本,TeamB想在另一个项目里用,只能复制粘贴改改。脚本之间参数传递全凭约定,出了问题连查的地方都没有。
1.3 自定义函数:一次开发,全局复用
自定义函数走的完全是另一条路。它在JMeter里被当成一等公民对待:你写好的函数会出现在函数助手对话框的下拉列表里,有参数描述,有点错参数数量还会给出提示。跟用__time没什么区别。
好处我实际体验下来有几点。第一,复用性彻底解决了。jar包放到lib/ext之后,任何测试计划都能用,同一套签名函数在订单接口、支付接口、查询接口里随便调。第二,代码质量可控。自定义函数是正经的Java类,可以写单元测试、走CodeReview、在CI里做编译检查,这些是脚本方式给不了的。第三,性能接近原生。函数执行时就是一个Java方法调用,没有脚本引擎的解释执行开销,压测客户端本身的损耗可以忽略。
当然,自定义函数也不是银弹,它解决的是“参数需要业务计算”这一类问题。如果你需要自定义协议、改造取样器的执行逻辑,那是另一个层面的二次开发,这种场景下才需要去搞AbstractJavaSamplerClient或者协议层。本文聚焦在函数层,覆盖面已经足够广。
2. 开发环境与运行机制:先把地基打好
2.1 工具链准备:JDK、Maven、JMeter、IDE
做二次开发之前,先把环境理清楚。JMeter 5.x 需要JDK 8以上的环境,我自己用的是JDK 8,兼容性最稳妥,网上大量jmeter安装 jdk 8的教程也印证了这一点。如果你在mac上,也是一样的逻辑,下载mac版本的JMeter压缩包解压就能用,网上有大量的mac jmeter下载、mac jmeter安装的教程,记得把Java配好就行。
Maven用来做依赖管理和打包,是标准选择。不熟Maven的话直接用IDE里的构建功能也行,但我建议还是装一个,后面管理依赖会方便太多。IDE的话,IntelliJ IDEA和Eclipse都行,我长期用IDEA,对Java工程的默认支持体验最好。
然后是JMeter本体的获取。认准Apache JMeter官网和GitHub仓库,别去第三方下载站,理由你懂得,版本混乱之外还容易踩到捆绑插件的坑。本地装好之后,重点注意一件事:把JMeter的lib和lib/ext目录里有哪些jar都扫一遍,尤其是ApacheJMeter_core.jar、ApacheJMeter_functions.jar,后面编译编译自定义函数全靠它们。
2.2 AbstractFunction 是如何被Jmeter识别的
聊实现之前,先得知道JMeter在用什么机制“发现”我们自定义的函数。本质上,JMeter在启动时会扫描lib/ext目录下所有的jar包,通过反射加载里面的类。它会查找实现或继承了特定接口/抽象类的class,然后注册到函数集合里。
对我们来说,这个特定入口就是org.apache.jmeter.functions.AbstractFunction。它是一个抽象类,实现了Function接口。只要你的类继承AbstractFunction,再按约定实现几个方法,JMeter就能识别它。函数名怎么定呢?看getReferenceKey方法的返回值,比如返回__MD5Sign,那么在测试计划里就能写${__MD5Sign(...)}。注意,约定俗成是双下划线前缀,否则JMeter的解析器不会把它当函数处理。
这个设计思路,是在JMeter启动时被加载并缓存起来的。所以每次新增或修改jar包,都必须重启JMeter才生效。我刚开始做的时候老是不重启就测试,然后懵半天为什么函数助手列表里没有新函数,其实原因就这么简单。
2.3 函数从jar包到执行的长链路
了解加载机制后,我还想再说一下一个自定义函数被调用的完整链路。理解这条链路,对排错特别有帮助。
当你把编译好的jar放进lib/ext并重启JMeter后,JMeter会在启动阶段扫描所有jar包,将识别到的函数类实例化并注册到一个叫FunctionProperties的地方。之后,当你打开函数助手对话框,下拉列表里的内容就来自这个注册表。你在测试计划里写${__MD5Sign(arg1,arg2)}时,JMeter的字符串解析器会匹配双下划线开头的函数名,找到对应的类,然后调用setParameters方法把参数传进去。真正执行时,再调用execute方法,它的返回值会替换到原来的占位符字符串里。
所以说,一个自定义函数的核心,就是setParameters和execute这两个方法。前者负责收参数、做前置校验;后者负责干真正的计算活。搞懂这条链路,出了问题你能知道卡在哪个环节,是没加载、没解析、还是执行逻辑错了。这个思路不光对函数有效,理解JMeter其他扩展点也是一通百通。
3. 实战:手写一个MD5签名函数
3.1 需求定义:压测接口的动态sign从哪来
为了不纸上谈兵,这里以一个真实项目回归测试时遇到的场景为例子。
系统要求在下单接口压测时,请求体的JSON里带上timestamp和sign两个字段。sign的算法是:把appId、timestamp、一个固定的appSecret拼成一个字符串,然后做MD5,得到32位小写串。timestamp是当前毫秒时间戳。appSecret是测试环境固定的值,为了不让测试参数随便外露,我希望在JMeter脚本里不直接写明文密钥。
那我需要的是这样一个函数:__genSign(appId, timestamp, appSecret, sourceStr)。sourceStr是业务参数拼接串,比如订单金额和商品Id拼起来的一段原文。函数内部把四个入参拼在一起做MD5,返回sign字符串。这样一个函数,覆盖了签名计算的核心场景。将来如果算法从MD5换成HMAC-SHA256,Java代码里改一行就够,测试计划完全不用动。
需求里还要考虑到异常输入:参数个数不够时,函数助手应该提醒;传入null时,不应该抛NullPointerException,而是返回明确的错误信息,方便压测时一眼看出脚本参数配错了。
3.2 Java代码实现与逐段解析
来看核心代码。我建了一个Maven工程,包名用了com.example.jmeter.functions,类名叫GenSignFunction,继承AbstractFunction。下面是完整实现。
package com.example.jmeter.functions; import org.apache.jmeter.engine.util.CompoundVariable; import org.apache.jmeter.functions.AbstractFunction; import org.apache.jmeter.functions.InvalidVariableException; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.samplers.Sampler; import java.security.MessageDigest; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class GenSignFunction extends AbstractFunction { // 函数名,JMeter里写成 ${__genSign(...)} private static final String KEY = "__genSign"; // 函数助手对话框里展示的参数描述 private static final List<String> DESC = new LinkedList<>(); static { DESC.add("appId"); DESC.add("timestamp"); DESC.add("appSecret"); DESC.add("sourceStr,业务参数原文"); } private Object[] values; @Override public String getReferenceKey() { return KEY; } @Override public List<String> getArgumentDesc() { return DESC; } @Override public void setParameters(Collection<CompoundVariable> parameters) throws InvalidVariableException { // 固定四个参数,多一个少一个直接报错 checkParameterCount(parameters, 4, 4); values = parameters.toArray(); } @Override public String execute(SampleResult previousResult, Sampler currentSampler) throws InvalidVariableException { // 参数的最终值要在execute阶段才取,因为入参里可能嵌了JMeter变量 String appId = ((CompoundVariable) values[0]).execute().trim(); String timestamp = ((CompoundVariable) values[1]).execute().trim(); String appSecret = ((CompoundVariable) values[2]).execute().trim(); String sourceStr = ((CompoundVariable) values[3]).execute().trim(); // 简单校验,缺参数给个明显提示,比返回null好排查 if (appId.isEmpty() || timestamp.isEmpty() || appSecret.isEmpty()) { throw new InvalidVariableException("genSign参数不能为空"); } String raw = appId + timestamp + appSecret + sourceStr; return md5(raw); } private String md5(String input) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException("MD5计算失败", e); } } }分几个关键点解释一下。
setParameters方法里,我用checkParameterCount做了参数数量校验。这是AbstractFunction提供的一个便捷方法,参数不符合预设的min和max时,会直接抛出InvalidVariableException,函数助手界面会显示错误,不会等到执行时才一脸懵。
执行逻辑的重点是:参数不能直接在setParameters里取值,必须等到execute再从CompoundVariable里execute()取一次。因为调用的时候参数本身可能是个JMeter表达式,比如${__genSign(${appIdVar},...) },变量只有在执行阶段才被解析成真实值。这在压测场景里太常见了,所以我特别强调这一点,很多自己写函数的人就是栽在这上面。
MD5算法部分,注意用UTF-8编码,否则中文业务参数会算出不同的摘要。返回32位小写十六进制串,这里用String.format("%02x", b)逐字节格式化,简单可靠。真实项目里如果密钥校验要求大写,改一行format就行。
3.3 Maven打包、部署与验证
代码写完了,还得能编译部署。在pom.xml里引入JMeter的依赖,我这里以provided方式引入,因为目标运行环境里JMeter本来就有这些jar包,不需要打进最终产物。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>jmeter-custom-functions</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_core</artifactId> <version>5.6.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_functions</artifactId> <version>5.6.3</version> <scope>provided</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> </plugin> </plugins> </build> </project>如果你在Maven中央仓库拉不下来这两个依赖,也不影响。最简单的方案是直接去JMeter安装目录的lib和lib/ext里找到对应的jar包,用IDE手动添加到工程classpath。原理一样,只是为了编译期能引用到AbstractFunction类而已。
构建命令很简单。
mvn clean package打包出来的jar会在target目录下,名字类似jmeter-custom-functions-1.0.0.jar。把它拷贝到JMeter安装目录的lib/ext下,重启JMeter。这一步是无数人出错的地方,我再说一次:一定要重启JMeter,不能热加载。
验证是否成功,最快的方式是打开JMeter的log窗口,启动日志里会出现你自定义类的加载记录。或者直接打开函数助手对话框,在下拉列表里如果看到了genSign这个函数,就说明注册成功了。
3.4 在函数助手和测试计划里用起来
函数注册成功之后,剩下的就是在测试计划里灵活使用。
最简单的用法是在函数助手里双击genSign,按参数描述依次填写四个参数值。函数助手会自动帮你转义成${__genSign(appId,timestamp,secret,source)}这个形式的字符串,复制到需要的地方即可。
我实际压测时的用法是组合变量。先加一个用户自定义变量,把appId和appSecret定义好,再在HTTP取样器请求体里这样写:
{ "appId": "${appId}", "timestamp": "${__time(/1000,)}", "sign": "${__genSign(${appId}, ${__time(/1000,)}, ${appSecret}, ${sourceStr})}", "bizData": "${sourceStr}" }注意sign参数里的timestamp,我用的是__time函数生成的秒级时间戳,同时传给__genSign。这么做的好处是时间戳只生成一次,保证请求体里的timestamp和签名用的时间戳完全一致,不会因为间隔了一毫秒导致签名校验失败。这也是我在实际踩坑之后才养成的习惯。
如果还要做数据驱动,可以把sourceStr这类的业务参数放在CSV文件里,配合CSV数据文件配置读取,再直接塞进函数参数。这样一来,压测数据和签名计算就完全解耦了,脚本逻辑干净很多。
4. 常见问题排查与压测场景联动
4.1 函数加载失败:找不到符号、不生效
最常遇到的坑是函数在函数助手里出不来。我的排查顺序是这样的。
先看jar有没有真的拷进lib/ext。很多人把jar放在了lib目录,这不行,JMeter只扫描lib/ext下的插件和扩展。再看jar包内部结构,用jar tf命令看一下里面的class路径,确认是不是编译后的class在。如果Maven打包时没配好,打出来的可能是空jar或者只含资源的jar,这类问题只能从打包配置解决。
再看函数名。JMeter解析函数要求双下划线开头,我之前见过有人写${_genSign(...)},少了一个下划线,结果执行时被当成普通字符串原样发出去了,接口签名校验必然失败,但一开始完全看不出来问题在哪。如果函数出现在函数助手里、却在测试计划里不执行,也可以用这个思路检查字样。
还要看版本兼容性。JMeter 5.6使用AbstractFunction的方法签名,和JMeter 3.x基本一致,但这不代表绝对兼容。比较大的版本升级后,建议重新编译一次再部署,别拿两年前打的jar往新版本里丢。
4.2 参数解析、中文乱码、逗号转义
第二个高频坑出现在参数值不对或者乱码上。
前面强调过,取值要在execute阶段调用CompoundVariable.execute(),否则传进来的${appId}不会被解析成真实值,而是变成字符串“${appId}”原样拼接。如果你发现算出来的签名在服务器端校验老是不对,先从这个地方查。
中文乱码的问题,多数是编码不一致。代码里拼接原文时用了getBytes("UTF-8"),但JMeter脚本里的字符串本身可能是平台默认编码。我的建议是代码里所有字节转换都显式指定UTF-8,同时保证jmeter脚本文件以UTF-8保存、请求默认的Content Encoding也设为UTF-8。这三者一致,乱码问题基本绝迹。
再有一个容易被忽略的是参数里的逗号。JMeter函数用逗号分割参数,如果你的sourceStr里有逗号,解析器会认为参数多了一个,导致checkParameterCount直接报错。解决办法有两个:一是传入前做URL编码或Base64把逗号转掉;二是设计函数时让参数数量可变,把最后一个参数当作可变长来接收。无论如何,在函数文档里写清楚参数约束,能省掉大量联调时间。
4.3 高并发场景下的线程安全与性能
压测工具自己的性能,直接关系到压测结果的可信度。自定义函数运行在JMeter的多线程环境中,一个函数实例可能会被多个线程同时调用,所以线程安全必须注意。
最简单的原则是:不要在execute里创建可变的全局对象,不要在execute里做IO。比如把MessageDigest实例放在execute方法内部创建,这天然线程安全;如果你为了性能想复用MessageDigest实例,就必须加同步,但加同步又会影响并发。实际测下来,每次execute里new一个MessageDigest的开销可以忽略,压测瓶颈完全不在自定义函数上。
还有一个常见武器是ThreadLocal。比如做时间格式化,SimpleDateFormat不是线程安全的,可以在类里用ThreadLocal包装一下,每个线程持有独立的DateFormat实例,既安全又能避免重复创建。但这个例子里我们只做MD5,不涉及这类问题,我只是提醒一下。
更重要的性能点是:避免在函数里做重活。比如从数据库查配置、读本地文件、甚至调用远程服务,这些都不能塞进自定义函数的execute里。函数是纯计算单元,所有外部数据应该通过参数传进来,或者在函数加载时一次性缓存在static字段里。
4.4 上传文件乱码、数据库压测、HTTPS脚本这些联动坑
自定义函数不是孤立存在的,实际压测项目里,它经常和JMeter的其它配置一起发挥作用。
比如jmeter上传文件时遇到中文文件名乱码是常有的事,特别是在Windows环境下。一个实用做法是在测试计划里用自定义函数对文件名做URL编码或Base64编码后再拼到请求参数里,服务端拿到的文件名是可还原的,规避了multipart/form-data的中文编码问题。这比在JMeter的HTTP请求里反复试各种编码设置要省心得多。
再比如JMeter数据库压测或数据库驱动的数据准备。有人压测下单接口时,需要从库里的有效用户数据去压。JDBC请求返回的结果可以作为变量传入签名函数,再生成每一笔订单请求。这样压测流量是真实业务数据的形态,服务端不容易在参数校验层挡掉,压出来的数据更有参考价值。
还要提一句HTTPS脚本录制。很多人录https脚本时报证书告警,其实JMeter自带安全证书,导入到系统信任列表后就能正常录制。这是另一个话题了,和自定义函数本身不冲突。我只是想说,压测链路里每个环节都会有小坑,二次开发也好、证书配置也好,都值得单独花时间吃透。
5. 二次开发的经验沉淀:把单点能力做成团队工具
5.1 规划函数家族与命名规范
自定义函数做了一两个之后,自然会产生一个念头:把常用的东西都做成函数,沉淀成团队的工具库。这个方向完全正确,但一定要提前定好规划。
我建议按业务能力把函数分类。比如签名类,统一叫__genSign、__genHmac、__genRsa;数据生成类,叫__phone、__idCard、__orderNo;格式处理类,叫__urlEncode、__base64、__tableName。每个函数在函数助手里都有清晰的参数说明,命名遵循“双下划线+动词或缩写”的规则。这个规范写进团队的测试开发wiki里,后来的人照着补充就行,不会出现一人一个风格的局面。
类的组织上,按包名区分模块,比如com.company.jmeter.functions.sign、com.company.jmeter.functions.data。每个类保持单职责,一个类只做一类事。函数多了以后,IMPORT长得吓人,但代码审查和测试定位依然方便。
5.2 单元测试保证函数质量
有人觉得搞个压测函数还写单元测试是小题大做。真不是,我在生产环境里被坑过一次之后,从此所有自定义函数都配套JUnit。
写单元测试其实很直接。函数的核心逻辑都在execute方法里,而execute依赖CompoundVariable,这个类本身在JMeter包里,构造和调用都不复杂。你可以在测试里new一个GenSignFunction,把参数包装成CompoundVariable的列表,调用execute并断言结果。
还有一个更简洁的思路:把签名计算的纯算法抽成包内可见的方法,比如md5(String)方法,测试时直接对方法做断言,完全不用依赖JMeter运行时环境。像GenSignFunction里的md5方法就是这样,测试代码异常简单:输入一个已知字符串,断言输出等于预计算的MD5值。这样做的价值是,将来谁不小心改了算法逻辑,CI一跑测试就发现问题,不用等到压测现场才暴露。
5.3 如何随CI/CD分发到压测环境
最后一个实践点,是把自定义函数融入持续压测体系,而不是每次手动拷jar。
比较简单的做法是把函数工程的构建接入Jenkins流水线,每次提交代码后自动执行mvn package,并把产物上传到制品库。压测环境的JMeter可以是容器化的,那么在构建JMeter镜像时,把最新的自定义函数jar作为依赖层放进去,每次启动镜像都是最新的函数版本。
如果团队规模再大一点,可以做一个简单的函数市场,把常用函数jar包统一管理起来,按版本号分发。各项目在创建压测任务时声明需要哪个版本的函数jar,CI在准备工作区时自动下载。这套体系一旦建立起来,二次开发不再是几个人手里的技巧,而是团队真正的可复用资产。从我个人经验来看,这个投入产出比非常高。
最后说几句接地气的体会
做完这一整套事情后回头看,JMeter二次开发的门槛其实不在技术点,而在愿不愿意把临时脚本沉淀成工程化方案。我最初也是写BeanShell应付,后来在一个签名频繁变更的项目上被反复折磨,才下决心做自定义函数。真做下来发现,代码量不大,收益却极其明显。
如果你正准备尝试,我给的建议是先写一个最简单的函数跑通全链路,哪怕只是输出一个固定字符串,也别一上来就搞复杂加密。把编译打包、jar部署、函数助手识别整明白了,再往里填业务逻辑,会顺利很多。另外,遇到问题多翻JMeter自带函数的源码,比如BuiltinFunctions里那些内置函数是怎么写的,那是最权威的学习材料。
自定义函数这个入口,只是JMeter扩展能力的一个侧面。掌握它之后,再去看自定义Sampler、自定义断言、自定义监听器,思路都是通的。从一个小函数开始,慢慢就有能力把JMeter真正变成你自己的压测平台了。