说实话,JMeter 里的 JDBC Request 用起来最让人难受的地方,不是配置复杂,而是返回结果的展示方式。默认情况下,你在“响应数据”里看到的就是一段用分隔符拼起来的文本表格,第一行是列名,后面是数据行,列和列之间用制表符,行和行之间用换行。数据少的时候还能凑合着看,一旦字段多、数据多,或者查询结果里带着中文和特殊字符,整个脚本调试体验就很糟糕。更别说想对响应数据做断言、提取、或者拿给开发同学对齐数据时,这种文本格式和接口返回的 JSON 结构完全是两回事。
基于这个痛点,我对 JMeter 做了一次源码二次开发:把 JDBC Request 的返回结果直接改成 JSON 格式输出。整个过程涉及源码定位、结果集处理逻辑修改、Gradle 构建打包、jar 替换等环节,中间也踩了压缩包、类型识别、编码这几个坑。这篇就把完整过程记录下来,给同样想动 JMeter 源码的同学一个参考。
1. 为什么要把JDBC Request的返回结果改成JSON
1.1 默认结果的展示形态和现实痛点
先说说 JDBC Request 默认长什么样。假设在 MySQL 里执行一句select id, name, age from t_user,默认返回的响应数据大体是这样:
id name age 1001 张三 28 1002 李四 32但在响应原始数据里,它实际是:
id\tname\tage\n1001\t张三\t28\n1002\t李四\t32这种结构有几个很实际的问题:
第一,肉眼查看效率低。字段少的时候问题不大,字段一旦超过五个,再加上数据列数较多,对齐感很差,人眼扫过去很难快速定位某一列的值。
第二,做断言和提取很痛苦。JMeter 的响应断言、正则提取器都是基于文本工作的。针对这种制表符分隔格式写正则,基本等于在猜列顺序。今天 SQL 调整了字段顺序,正则就要跟着改,测试脚本维护成本很高。
第三,和业务数据形态割裂。现在绝大多数接口返回的都是 JSON,前后端联调、数据核对都习惯直接看 JSON 结构。JDBC Request 查出来的数据如果也能以 JSON 数组的形式呈现,直接就能和接口返回值做结构对比,效率完全是两码事。
也许有人会说,JMeter 的 JDBC Request 在查看结果树里不是有表格视图吗?确实有,但那个表格视图只是 GUI 层面根据响应文本二次解析出来的展示形式,底层SampleResult里保存的仍然是那段文本格式字符串。如果希望通过脚本处理、或者把响应数据以 JSON 形式抛给下游,仍然要回到文本这块来。
1.2 为什么选择源码二次开发而不是脚本处理
解决这个问题的办法不止一条。常见的做法有三类:
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| JSR223 后置处理器 | 在 JDBC Request 后添加 Groovy 脚本,把响应文本解析后重新设置成 JSON | 不改源码,灵活,能按需处理 | 每个线程组都要加脚本,解析逻辑依赖分隔符,大规模脚本里维护成本高 |
| 自定义 Sampler | 继承或仿照 JDBCSampler 重写一个自己的 JDBC 请求元件 | 完全可控,能按自己的需求设计 | 开发工作量大,还需要保证变量注入、事务回滚、连接管理等行为一致 |
| 源码二次开发 | 直接修改 JDBCSampler 的结果集拼接逻辑,重新编译 jar | 一次改动全局生效,所有测试计划都能享受 | 需要准备编译环境,替换 jar 后要在本机维护 |
我最终选择源码二次开发,核心原因有两个:一是 JDBC Request 的结果拼接逻辑本身就不复杂,改造成本其实很低;二是性能测试团队往往有多套脚本在跑,如果每个脚本都挂一个后置处理器,光解析和转义就够烦了,源码里改掉一劳永逸。
当然,源码二次开发有门槛,需要能理解 JMeter 的模块结构、Java 基本的编译构建流程。但只要做过一次就会发现,JMeter 的源码结构比想象中要规整得多,定位到关键类之后,改动量其实很小。
2. 源码结构定位:JDBC Request的结果是怎么拼出来的
2.1 关键类与模块定位
JMeter 是典型的模块化结构,协议相关的代码在源码的src/protocol目录下。JDBC 协议对应的模块是src/protocol/jdbc,我们主要关心的类是:
org.apache.jmeter.protocol.jdbc.sampler.JDBCSampler这个类承担了 JDBC Request 的大部分逻辑,包括连接获取、SQL 执行、ResultSet 处理、响应数据回写、变量注入等。打开这个类之后,建议直接搜索fetchRS和iterateRS两个方法,这是本次改造的关键入口。
JDBCSampler 处理一次查询的流程大致如下:
sample()方法根据查询类型创建 Statement 或 PreparedStatement,并执行 SQL。- 拿到
ResultSet之后,调用fetchRS()把结果集读出来,转成一个两层List结构。 - 调用
iterateRS()把这个List拼装成一个字符串。 - 通过
sampleResult.setResponseData()将字符串写入采样结果,完成一次请求。
所以,要让 JDBC Request 返回 JSON,思路很直接:改iterateRS()的拼接方式,让它生成 JSON 字符串。
2.2 默认文本格式的生成逻辑
fetchRS()做的事情,是把ResultSet转成一个List<Object>。这个 List 的第一个元素是列名的集合,后面每个元素对应一行数据。每一行也是一个 List,里面的值统一转成了字符串,包括null也变成了字符串"null"。
伪还原一下这段逻辑:
private List<Object> fetchRS(ResultSet rs, int stringMaxSize) throws SQLException { List<Object> result = new ArrayList<>(); ResultSetMetaData meta = rs.getMetaData(); int columnCount = meta.getColumnCount(); // 第一行放列名 List<String> columnNames = new ArrayList<>(columnCount); for (int i = 1; i <= columnCount; i++) { columnNames.add(meta.getColumnName(i)); } result.add(columnNames); // 从第二行开始放数据 while (rs.next()) { List<String> rowValues = new ArrayList<>(columnCount); for (int i = 1; i <= columnCount; i++) { Object value = rs.getObject(i); if (value == null) { rowValues.add("null"); } else { String s = value.toString(); // 超过配置的最大长度时截断 if (stringMaxSize > 0 && s.length() > stringMaxSize) { s = s.substring(0, stringMaxSize); } rowValues.add(s); } } result.add(rowValues); } return result; }iterateRS()则负责把这个双层 List 拼接成文本。大致逻辑是循环每一行,先输出列名,再输出数据,列之间用制表符、行之间用换行:
private StringBuilder iterateRS(List<Object> resultSet) { StringBuilder sb = new StringBuilder(); List<String> columnNames = (List<String>) resultSet.get(0); // 输出列名 for (int i = 0; i < columnNames.size(); i++) { if (i > 0) { sb.append("\t"); } sb.append(columnNames.get(i)); } sb.append("\n"); // 输出数据行 for (int i = 1; i < resultSet.size(); i++) { List<String> row = (List<String>) resultSet.get(i); for (int j = 0; j < row.size(); j++) { if (j > 0) { sb.append("\t"); } sb.append(row.get(j)); } sb.append("\n"); } return sb; }看出来了吗?整个 JDBC Request 的默认文本格式,其实就是这么简单粗暴地拼出来的。没有复杂的模板引擎,也没有格式化结构。因此改造成 JSON 完全可行,只要保证生成的字符串是一个合法 JSON 即可。
2.3 与变量注入的关系
在动手改代码之前,必须先搞清一个边界:JDBCSampler 不仅返回响应文本,还承担着变量注入的职责。如果在 JDBC Request 里配置了“Variable Names”(变量名列表),并且查询返回的列正好对应这些变量名,JMeter 会把结果集的每一列注入到变量中,供后续请求使用。
这个变量注入逻辑发生在fetchRS()阶段,它把结果集数据按列读取出来,再进行变量赋值。也就是说,我们后续修改iterateRS()只影响响应文本的展示,不影响变量注入行为。这一点非常重要,如果贸然把整个结果处理流程重写,很可能会把变量注入功能搞坏,这也是自定义 Sampler 方案风险较高的原因之一。
3. 核心改造:把响应结果拼成JSON
3.1 改造前先想清楚:字符串拼接还是JSON库
改造时首先遇到一个选择题:到底是引入一个 JSON 库,还是自己用字符串拼。
引入 JSON 库的好处是处理转义、类型识别更省心,JMeter 的lib目录下也确实有 json-path、json-smart 这类和 JSON 相关的依赖。坏处是,JDBC 协议模块本身并不直接依赖这些库,硬要用就得改模块的构建文件,并且在打包时把这些依赖一起带上。如果只是自己本地用,还要额外把依赖 jar 放到 JMeter 的lib目录,维护成本有点高。
我的选择是:自己写字符串拼接,同时写一个 JSON 转义方法。JDBC Request 的结果集本质上是一个二维表,列名是已知的,每一行的值也已经拿到了字符串形式。要把它拼成 JSON 数组对象,核心就是处理好字符串的转义,其他都不复杂。自己拼接还有个好处,就是构建产物轻量、替换 jar 时不需要额外引入依赖,后期迁移到别的 JMeter 环境也方便。
3.2 给JDBCSampler加一个toJson方法
具体的改造方式,是在iterateRS()方法里不再直接拼制表符和换行,而是按 JSON 的语法拼装。
先看一个最精简的版本,把结果集转成一个 JSON 数组,数组中每个元素是一个 JSON 对象:
private StringBuilder iterateRS(List<Object> resultSet) { List<String> columnNames = (List<String>) resultSet.get(0); StringBuilder sb = new StringBuilder(); sb.append("["); for (int i = 1; i < resultSet.size(); i++) { List<String> row = (List<String>) resultSet.get(i); if (i > 1) { sb.append(","); } sb.append("{"); for (int j = 0; j < columnNames.size(); j++) { if (j > 0) { sb.append(","); } String columnName = columnNames.get(j); String value = row.get(j); sb.append("\"").append(escapeJson(columnName)).append("\":"); if ("null".equals(value)) { sb.append("null"); } else { sb.append("\"").append(escapeJson(value)).append("\""); } } sb.append("}"); } sb.append("]"); return sb; }这个版本有几个细节值得注意:
第一,列名的转义。列名虽然一般不会出现特殊字符,但保险起见还是走escapeJson()处理,避免数据库里出现反引号、引号之类的怪异列名时把 JSON 搞坏。
第二,null的处理。因为fetchRS()已经把所有值转成了字符串,父这里直接判断字符串是不是"null",是的话就输出裸的null,不加引号。这样下游解析 JSON 时,用isNull()就能判断空值,而不是拿到一个字符串"null"。
第三,JSON 对象里的 key 顺序。我这里是按查询结果的列顺序输出的,和 SQL 语句的字段顺序保持一致,方便对照查看。如果想要字母序或者其他顺序,需要额外排序。
3.3 类型处理细节:数字、时间、NULL该怎么输出
上面这个版本把所有值都当成字符串输出,包括数字和日期。这在很多场景下够用,因为 JMeter 的响应数据本质上是给人看和给断言用的,字符串形式的数字完全不影响断言。但如果你希望数字字段在 JSON 里保持数字类型,就需要在fetchRS()阶段保留原始类型。
一个比较简单的办法是修改fetchRS(),不要提前toString(),而是保留ResultSet.getObject()返回的原始对象,只在需要拼接文本时再转换。然后在生成 JSON 时判断类型:
private static boolean isNumeric(Object value) { return value instanceof Number; } private static boolean isBoolean(Object value) { return value instanceof Boolean; }拼接时,如果是数字或布尔类型,直接裸输出:
if (value instanceof Number || value instanceof Boolean) { sb.append(value.toString()); } else if (value == null) { sb.append("null"); } else { sb.append("\"").append(escapeJson(value.toString())).append("\""); }日期时间类型就需要注意了。ResultSet.getObject()在某些驱动下会返回java.sql.Timestamp,它的toString()格式是2025-06-10 14:30:00.0,直接输出成字符串完全没问题,但如果你希望格式化成 ISO 8601,就需要额外处理。个人建议是,除非有明确的下游消费需求,否则日期时间一律按字符串输出,兼容性最好。
这里还要提醒一句:fetchRS()里的stringMaxSize截断逻辑,本质上发生在把原始值转换成字符串之后。如果保留原始类型而字符串截断,可能出现数字被截断成不完整数字的怪象。所以不要把截断逻辑简单地带到 JSON 输出里,最好保留一个原则:只有字符串类型的值才做截断,数字和布尔值不截断。
3.4 调整响应头与编码
拼接完成之后,还有一个容易被忽略的细节:sampleResult.setResponseData()时最好显式设置编码格式,并把响应类型标记为 JSON。
在 JDBCSampler 中,原本的回写大致是:
sampleResult.setResponseData(sb.toString(), StandardCharsets.UTF_8.name());这个本身已经是 UTF-8。但如果想让查看结果树里更直观地展示 JSON 格式,可以额外设置:
sampleResult.setContentType("application/json");设置 ContentType 后,在查看结果树里选择“JSON Path Tester”之类的功能时,识别会更友好。当然,JMeter 的文本查看器本身不依赖 ContentType,但下游对接或者第三方断言工具读取时,有这个标记总是更规范。
4. 编译打包与替换:让修改真正生效
源码改完之后,下一步就是编译、打包、替换。这一步是很多第一次动 JMeter 源码的人最容易卡住的地方,这里把完整流程拆开讲。
4.1 编译环境准备与源码获取
编译 JMeter 源码,推荐使用 JDK 11,太老的 JDK 8 在部分源码版本上会有编译告警甚至失败。我自己的经验是直接装一个 JDK 11 的干净环境,避免本机多版本 JDK 干扰 Gradle。
源码获取方式很直接,到 JMeter 官方仓库拉一个 tag 对应的源码即可。比如想要 5.6 系列的版本,就拉对应分支或 tag。尽量不要用最新主分支,因为主分支可能会引入新的构建要求,稳定版本更适合二次开发。
拉下来之后,目录结构应该类似:
apache-jmeter-src/ ├── build.gradle ├── gradlew ├── src/ │ ├── core/ │ ├── protocol/ │ │ └── jdbc/ │ └── ...JDBC 模块对应的源码路径是src/protocol/jdbc/src/main/java/org/apache/jmeter/protocol/jdbc/sampler/JDBCSampler.java,修改完成后保存即可。
4.2 两种构建方式:全量发布包与单独编译
构建方式有两类,取决于你的使用习惯。
第一种是构建完整的发布包。JMeter 源码使用 Gradle 作为构建工具,在源码根目录执行发布包构建任务:
./gradlew :src:dist:createDist这条命令会生成一个完整的 JMeter 发行目录,一般在build/dist下。你可以直接把整个目录拷贝出去当一个新的 JMeter 环境用,里面已经包含了编译好的所有 jar。这种方式适合需要整体迁移的场景。
第二种是单独编译 JDBC 模块,快速验证修改效果:
./gradlew :src:protocol:jdbc:jar单独编译后,产物在对应模块的build/libs目录下,文件名通常是ApacheJMeter_jdbc.jar。这种方式比较快,适合本地反复调试,确认没问题之后再整套构建。
如果你是第一次编译,建议先跑一次全量构建,把 Gradle 依赖下载好,再切回单独编译,速度会快很多。
4.3 替换jar后先做一个小验证
拿到新编译出来的 jar 之后,替换到 JMeter 安装目录的lib/ext下,覆盖原来的ApacheJMeter_jdbc.jar。这一步有一个老生常谈但非常重要的提醒:先备份原 jar。万一改出来的代码有问题,还能快速回滚。
替换完成后启动 JMeter,用最简单的 JDBC Request 查一张表,然后到“查看结果树”里看响应数据。如果改动生效,响应数据应该已经是一个 JSON 数组,而不是原来的制表符分隔文本。
如果没生效,不要着急,先看下一节的问题排查。
5. 踩坑记录:修改后常见问题与排查
5.1 替换jar后响应还是原来的文本
这个问题我一开始也遇到过,排查下来无非几个原因。
第一,替换错了 jar。JMeter 有很多协议相关的 jar,JDBC 模块对应的 jar 名字是ApacheJMeter_jdbc.jar,不是ApacheJMeter_core.jar。如果你改的是 JDBCSampler,但替换的是 core jar,那必然不生效。
第二,类加载顺序问题。如果lib或者lib/ext目录下同时存在两个不同版本的ApacheJMeter_jdbc.jar,JMeter 的类加载器可能会加载到旧的那个。检查一下目录里所有同名 jar,只保留一个。
第三,JMeter 没有完全重启。如果你是用 GUI 模式,修改 jar 后必须完全退出进程再重新启动,不要只关闭测试计划再打开。
第四,编译产物没有刷新。单独编译时,Gradle 可能因为增量编译没有包含最新的修改。可以在编译前执行一次 clean 任务:
./gradlew :src:protocol:jdbc:clean :src:protocol:jdbc:jar执行 clean 能确保产物是从最新源码编译出来的。
5.2 中文乱码与JSON解析失败
中文乱码的问题在本地数据库和 JMeter 之间时有发生。大多数情况下和源码改造无关,而是数据库连接 URL 里没有指定字符集。比如 MySQL 的连接串最好带上:
jdbc:mysql://localhost:3306/db?useUnicode=true&characterEncoding=utf8如果确认连接 URL 没问题,再检查 JMeter 的jmeter.properties里的编码配置。默认采样结果编码是 UTF-8,一般不会乱码。
JSON 解析失败则要重点检查escapeJson()的实现。数据库返回的文本里如果有换行、制表符、双引号,不转义直接放进 JSON,解析器一定会报错。最简单可靠的转义规则是:
"转义为\"\转义为\\- 换行符转义为
\n - 制表符转义为
\t - 回车符转义为
\r - 其他控制字符按
\uXXXX处理
一个够用的实现是这样:
private static String escapeJson(String s) { if (s == null) { return ""; } StringBuilder sb = new StringBuilder(s.length()); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); switch (c) { case '"': sb.append("\\\""); break; case '\\': sb.append("\\\\"); break; case '\n': sb.append("\\n"); break; case '\r': sb.append("\\r"); break; case '\t': sb.append("\\t"); break; default: if (c < 0x20) { sb.append(String.format("\\u%04x", (int) c)); } else { sb.append(c); } } } return sb.toString(); }这套转义基本覆盖了日常会碰到的特殊字符。
5.3 NULL值和类型丢失问题
我前面提到,默认fetchRS()把 null 转成了字符串"null",直接按字符串处理,JSON 里会多一对引号,变成"null"这样错误的语义。如果 JSON 输出中希望表示空值,一定要在拼接时判断并且输出裸的null。
类型丢失的问题,也就是数字被当成字符串,这是自拼接方案的一个固有取舍。如果你希望完整保留 JSON 的数据类型,建议把fetchRS()里提前toString()的逻辑去掉,保留原始 Object,再在生成 JSON 时做类型判断。这种做法更严谨,但返回值进到 JMeter 的变量系统时仍然是字符串形式,所以对变量使用没有影响。
5.4 改动影响到了旧的测试脚本怎么办
把默认格式从文本改成 JSON 之后,以前基于文本正则提取、文本断言编写的脚本可能会失效。这是二次开发最常见的兼容性问题。
为了避免这个问题,我建议在改造时留一个后门,用 JMeter 属性控制输出格式。具体做法是,在iterateRS()里读取 JMeter 属性:
String format = JMeterUtils.getPropDefault("jdbbc.sampler.result.format", "json");然后根据属性值选择走原来的文本拼接逻辑,还是走 JSON 拼接逻辑。这样如果某个旧脚本依赖文本格式,只要在它的jmeter.properties里设置jdbbc.sampler.result.format=csv即可,不需要动源码换 jar。这个开关成本很低,强烈推荐在动手改造时一并加上。
6. 更灵活的替代方案:不改源码也能得到JSON
6.1 JSR223 Groovy后置处理器方案
如果你不想动源码、不想维护自定义 jar,还有一种折中方案:在 JDBC Request 后挂一个 JSR223 Groovy 后置处理器,把响应数据重新解析为 JSON。
因为默认的文本格式是制表符分隔,而 JSR223 脚本能拿到prev对象(即当前采样结果),就可以直接操作响应文本:
import groovy.json.JsonOutput def raw = prev.getResponseDataAsString() def lines = raw.split("\n") def headers = lines[0].split("\t") def rows = [] for (int i = 1; i < lines.length; i++) { if (lines[i].length() == 0) { continue } def values = lines[i].split("\t") def row = [:] headers.eachWithIndex { header, idx -> row[header] = values[idx] } rows << row } def json = JsonOutput.toJson(rows) prev.setResponseData(json.getBytes("UTF-8"), "UTF-8")这个方案的好处是改动范围小,只对加了后置处理器的取样器生效。缺点是解析逻辑依赖分隔符,如果字段值里本身包含制表符就会出问题,而且每个 JDBC Request 都要手动挂一个处理器,脚本一多维护起来也累。
另外再提醒一个细节,JSR223 脚本里如果要解析 JSON,建议直接用 Groovy 自带的groovy.json.JsonOutput,不要额外引入依赖。
6.2 后续还能怎么扩展
源码改造其实只是起点。基于同样的思路,你还可以做很多扩展:
一是在 JSON 中加入元信息。比如把影响行数、SQL 执行耗时、结果集大小一并放入输出对象,方便一眼看出查询的整体情况。
二是支持只输出指定列。通过 JMeter 属性或者现有“Variable Names”配置来控制 JSON 里的字段集合,这样即使查询返回很多列,最终输出也能精简。
三是把 CSV 和 JSON 双格式输出做成可配置。前面提到用属性开关来控制,属于这个方向的基础实现。更进一步还能做成 GUI 界面上的一个下拉选项,不过这就要动 JMeter 的 GUI 源码了,成本相对高不少。
四是考虑把 JSON 输出与断言结合。JDBC Request 改成 JSON 后,可以在后续的 JSR223 断言里用groovy.json.JsonSlurper解析响应并做结构化断言,比正则文本断言可靠得多。
这几个扩展本质上都不复杂,核心还是先把源码改造这条路走通。
最后聊一点个人体会。这次改造最值的收获,其实是彻底搞清楚了 JMeter 的结果集在采样器内部是怎么流转的。ResultSet到List到文本字符串,再到SampleResult,整个链路非常清晰,改起来远比想象中简单。如果你也经常被 JMeter 的默认响应格式困扰,并且愿意维护一个自己的 jar,这个改动性价比很高。如果只是临时用一下,JSR223 后置处理器也完全够用。两种方案我都实际用过,最终选了源码改造加属性开关的组合,因为测试团队里多套脚本同时跑,一次改动全局生效,省心太多。