摘要: 标准 JMeter 自带的 HTTP/JDBC/JMS Sampler 覆盖了大部分场景,但碰到私有 TCP 协议就只能自己写插件。这篇我先把 JMeter 5.6.3 源码拉下来构建进 IDEA,搞清楚 AbstractSampler 的生命周期和 JMeterGUIComponent 的注册机制,然后从零写一个支持自定义报文头+长度前缀的 TCP 私有协议 Sampler,打包发布到 lib/ext 目录。文末附 4 个插件开发真实踩坑。读完这篇,你能独立开发任何私有协议的 JMeter 压测插件。
前置知识: 熟练 Java、了解 Maven/Gradle 构建、读过 JUnit 测试、有第 1 篇的 JMeter 使用基础。
前言
事情起因是秒杀压测后的复盘会。研发那边有个 IoT 网关,设备和网关之间走的是自定义 TCP 二进制协议:报文头 4 字节魔数 + 4 字节长度 + 载荷。研发问我:"这种协议 JMeter 能压吗?"
我第一反应是 JMeter 自带的 TCP Sampler------但看了下源码,自带的 TCPSampler 只能处理简单文本协议(默认按行读取或固定长度),魔数校验、长度前缀解析、报文拆包这些都不支持。
有两条路:
- 用 BeanShell/JSR223 写 Groovy 脚本:在 TCP Sampler 里嵌脚本解析报文。缺点是脚本维护成本高、性能差(每次请求都解释执行)、不能复用。
- 开发自定义 Sampler 插件 :写一个 Java 类继承
AbstractSampler,打包成 jar 放到lib/ext。优点是性能好、可复用、可发布给团队。
我选了路 2。这篇文章就是这次插件开发的完整记录。

图 1: 私有 TCP 协议压测方案选型决策树
源码解析:Sampler 的生命周期
动手写插件前必须先搞清楚 JMeter 内部 Sampler 是怎么跑的。我把源码拉下来构建进 IDEA,断点跟了一遍请求执行流程。
1. 源码拉取与构建
【为什么需要这段命令】 JMeter 5.6.3 已从 Ant 迁移到 Gradle 构建,且测试代码正在从 Groovy 迁移到 Kotlin。源码构建要求 JDK 17+,构建工具是 Gradle 8.5。这段命令把源码拉下来并跳过测试快速构建,方便导入 IDEA。
bash
# 拉取源码(约 200MB,包含完整历史)
git clone --depth 1 --branch v5.6.3 https://github.com/apache/jmeter.git
cd jmeter
# 跳过测试快速构建(首次约 5 分钟)
./gradlew build -x test -x checkstyleMain
# 生成 IDEA 工程文件
./gradlew idea
# 或新版 IDEA 用:./gradlew generateIdeConfig
构建成功后用 IDEA 打开项目根目录,等索引建完就能看源码了。
2. 核心接口:AbstractSampler
JMeter 所有 Sampler 都继承自 org.apache.jmeter.samplers.AbstractSampler。看下它的类图:

图 2: Sampler 核心类继承关系
关键点:
| 接口/类 | 职责 | 我需要实现什么 |
|---|---|---|
TestElement |
所有测试元素的根接口,提供属性存取 | 无需实现,继承即可 |
AbstractTestElement |
提供 clone()、属性持久化 |
无需实现 |
Sampler |
定义 sample() 方法 |
必须实现 sample() |
AbstractSampler |
Sampler 的抽象基类 | 继承它,实现 sample() |
3. sample() 的执行时机
sample() 是压测时每个请求 都会调用的核心方法,理解它的执行时机对性能优化至关重要:

图 3: 自定义 Sampler 的 sample() 方法执行时序
关键认知:
sample()是热路径 ,每次请求都调用。里面任何耗时操作(如new SimpleDateFormat、正则编译)都会被放大 N 倍,必须做缓存。SampleResult是结果载体,必须正确调用sampleStart()和sampleEnd(),否则 Dashboard 里响应时间全是 0。SampleResult返回后会立即被 Listener 处理,不要再持有它的引用做异步操作,会导致数据竞争。
4. GUI 注册:JMeterGUIComponent
光有 Sampler 类不够,还要让 GUI 能识别它。JMeter 用 SPI + properties 文件 双重注册机制:
| 注册方式 | 作用 | 文件位置 |
|---|---|---|
JMeterGUIComponent 注解 |
标记 GUI 类 | Java 注解 |
saveservice.properties |
告诉 JMeter 这个类的标签和图标 | bin/saveservice.properties |
org.apache.jmeter.protocol.java.sampler 的 properties |
GUI 菜单分组 | 各 protocol 的 properties |
GUI 类必须实现 JMeterGUIComponent 接口,核心要实现 4 个方法:
| 方法 | 作用 |
|---|---|
createTestElement() |
创建 Sampler 实例 |
modifyTestElement(TestElement) |
从 GUI 控件回填到 Sampler |
configure(TestElement) |
从 Sampler 加载到 GUI 控件(编辑已有元素时) |
getLabelResource() |
GUI 显示名称的资源 key |
实战开发:从零写一个 TCP 私有协议 Sampler
1. 创建 Gradle 插件项目
【为什么需要这段配置】 JMeter 插件本身是一个独立 jar,需要把 JMeter 核心包作为 compileOnly 依赖(运行时由 JMeter 主程序提供),避免打包冲突。Gradle 比 Maven 更适合这种轻量插件项目。
gradle
// build.gradle
plugins {
id 'java'
id 'maven-publish'
}
group 'com.example.jmeter'
version '1.0.0'
sourceCompatibility = 17
targetCompatibility = 17
repositories {
mavenCentral()
}
dependencies {
// JMeter 核心包,compileOnly 表示运行时由 JMeter 主程序提供
compileOnly 'org.apache.jmeter:ApacheJMeter_core:5.6.3'
compileOnly 'org.apache.jmeter:ApacheJMeter_components:5.6.3'
// 测试依赖
testImplementation 'org.apache.jmeter:ApacheJMeter_core:5.6.3'
testImplementation 'junit:junit:4.13.2'
}
// 打 fat jar,包含所有运行时依赖(如果有)
jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
}
项目结构:
jmeter-tcp-sampler/
├── build.gradle
└── src/main/java/com/example/jmeter/tcp/
├── TcpPrivateSampler.java # Sampler 实现
├── TcpPrivateSamplerGui.java # GUI 实现
└── TcpProtocolClient.java # 协议解析工具类
2. 实现 Sampler 类
这是核心类,继承 AbstractSampler,实现 sample() 方法。
【为什么需要这段代码】 这是 TCP 私有协议 Sampler 的核心实现。报文格式为「4字节魔数(0xCAFE) + 4字节长度 + 载荷」。sample() 方法负责构建报文、发送、接收、解析,并返回 SampleResult 给 JMeter 统计。
java
package com.example.jmeter.tcp;
import org.apache.jmeter.samplers.AbstractSampler;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.samplers.Entry;
import org.apache.jmeter.testelement.TestStateListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.ByteBuffer;
public class TcpPrivateSampler extends AbstractSampler implements TestStateListener {
private static final Logger log = LoggerFactory.getLogger(TcpPrivateSampler.class);
// 属性 key 常量(GUI 与 Sampler 通过 key 交换数据)
public static final String HOST = "TcpPrivate.host";
public static final String PORT = "TcpPrivate.port";
public static final String PAYLOAD = "TcpPrivate.payload";
public static final String TIMEOUT = "TcpPrivate.timeout";
// 魔数常量
private static final int MAGIC = 0xCAFE;
// Socket 复用(每个线程一个,避免每次请求都建连接)
private transient Socket socket;
private transient OutputStream out;
private transient InputStream in;
@Override
public SampleResult sample(Entry e) {
SampleResult result = new SampleResult();
result.setSampleLabel(getName());
result.setSamplerData(getPayload());
try {
// 确保连接已建立(线程内复用)
ensureConnection();
byte[] request = buildRequest(getPayload());
// 关键:sampleStart 必须在实际发送前调用
result.sampleStart();
out.write(request);
out.flush();
byte[] response = readResponse();
// 关键:sampleEnd 必须在接收完成后立即调用
result.sampleEnd();
result.setResponseData(response);
result.setSuccessful(true);
result.setResponseCodeOK();
result.setResponseMessage("OK");
} catch (Exception ex) {
result.sampleEnd(); // 异常路径也要调 sampleEnd
result.setSuccessful(false);
result.setResponseCode("500");
result.setResponseMessage(ex.getMessage());
// 连接异常时关闭 socket,下次请求重建
closeConnection();
log.warn("TCP sample failed: {}", ex.getMessage());
}
return result;
}
/** 构建请求报文:魔数(2) + 长度(4) + 载荷 */
private byte[] buildRequest(String payload) {
byte[] payloadBytes = payload.getBytes(java.nio.charset.StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(6 + payloadBytes.length);
buffer.putShort((short) MAGIC);
buffer.putInt(payloadBytes.length);
buffer.put(payloadBytes);
return buffer.array();
}
/** 读取响应:先读 6 字节头,再按长度读载荷 */
private byte[] readResponse() throws IOException {
byte[] header = new byte[6];
readFully(in, header);
ByteBuffer headerBuffer = ByteBuffer.wrap(header);
short magic = headerBuffer.getShort();
if (magic != MAGIC) {
throw new IOException("Invalid magic: 0x" + Integer.toHexString(magic & 0xFFFF));
}
int length = headerBuffer.getInt();
if (length < 0 || length > 1024 * 1024) {
throw new IOException("Invalid payload length: " + length);
}
byte[] payload = new byte[length];
readFully(in, payload);
return payload;
}
/** 精确读取 n 字节(处理半包问题) */
private void readFully(InputStream in, byte[] buffer) throws IOException {
int read = 0;
while (read < buffer.length) {
int n = in.read(buffer, read, buffer.length - read);
if (n < 0) throw new IOException("Unexpected EOF");
read += n;
}
}
private void ensureConnection() throws IOException {
if (socket == null || socket.isClosed()) {
socket = new Socket(getHost(), getPort());
socket.setSoTimeout(getTimeout());
out = socket.getOutputStream();
in = socket.getInputStream();
}
}
private void closeConnection() {
try { if (socket != null) socket.close(); }
catch (IOException ignored) {}
socket = null; out = null; in = null;
}
// === TestStateListener:压测开始/结束时回调 ===
@Override
public void testStarted() { /* 压测开始,可做初始化 */ }
@Override
public void testEnded() { closeConnection(); }
@Override
public void testStarted(String host) { testStarted(); }
@Override
public void testEnded(String host) { testEnded(); }
// === Getter/Setter(通过 Property 存取,GUI 通过这些方法读写) ===
public String getHost() { return getPropertyAsString(HOST); }
public void setHost(String host) { setProperty(HOST, host); }
public int getPort() { return getPropertyAsInt(PORT); }
public void setPort(int port) { setProperty(PORT, port); }
public String getPayload() { return getPropertyAsString(PAYLOAD); }
public void setPayload(String payload) { setProperty(PAYLOAD, payload); }
public int getTimeout() { return getPropertyAsInt(TIMEOUT, 10000); }
public void setTimeout(int timeout) { setProperty(TIMEOUT, timeout); }
}
3. 实现 GUI 类
GUI 类负责在 JMeter 界面里显示配置表单。
【为什么需要这段代码】 没有这个类,自定义 Sampler 在 GUI 里就是"光秃秃一个图标",无法配置 host/port/payload。GUI 类继承 AbstractSamplerGui,构建表单并把用户输入回填到 Sampler。
java
package com.example.jmeter.tcp;
import org.apache.jmeter.samplers.gui.AbstractSamplerGui;
import org.apache.jmeter.testelement.TestElement;
import org.apache.jmeter.util.JMeterUtils;
import javax.swing.*;
import java.awt.*;
public class TcpPrivateSamplerGui extends AbstractSamplerGui {
private JTextField hostField;
private JTextField portField;
private JTextArea payloadArea;
private JTextField timeoutField;
public TcpPrivateSamplerGui() {
init();
}
private void init() {
setLayout(new BorderLayout(0, 10));
setBorder(makeBorder());
add(makeTitlePanel(), BorderLayout.NORTH);
JPanel mainPanel = new JPanel(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(2, 2, 2, 2);
gbc.anchor = GridBagConstraints.WEST;
gbc.fill = GridBagConstraints.HORIZONTAL;
hostField = new JTextField(20);
portField = new JTextField(8);
timeoutField = new JTextField(8);
payloadArea = new JTextArea(5, 30);
gbc.gridx = 0; gbc.gridy = 0; mainPanel.add(new JLabel("Host:"), gbc);
gbc.gridx = 1; mainPanel.add(hostField, gbc);
gbc.gridx = 0; gbc.gridy = 1; mainPanel.add(new JLabel("Port:"), gbc);
gbc.gridx = 1; mainPanel.add(portField, gbc);
gbc.gridx = 0; gbc.gridy = 2; mainPanel.add(new JLabel("Timeout (ms):"), gbc);
gbc.gridx = 1; mainPanel.add(timeoutField, gbc);
gbc.gridx = 0; gbc.gridy = 3; mainPanel.add(new JLabel("Payload:"), gbc);
gbc.gridx = 1; mainPanel.add(new JScrollPane(payloadArea), gbc);
add(mainPanel, BorderLayout.CENTER);
}
@Override
public String getLabelResource() {
return "tcp_private_sampler_title";
}
@Override
public TestElement createTestElement() {
TcpPrivateSampler sampler = new TcpPrivateSampler();
modifyTestElement(sampler);
return sampler;
}
@Override
public void modifyTestElement(TestElement element) {
super.configureTestElement(element);
if (element instanceof TcpPrivateSampler) {
TcpPrivateSampler sampler = (TcpPrivateSampler) element;
sampler.setHost(hostField.getText());
sampler.setPort(Integer.parseInt(portField.getText()));
sampler.setPayload(payloadArea.getText());
sampler.setTimeout(Integer.parseInt(timeoutField.getText()));
}
}
@Override
public void configure(TestElement element) {
super.configure(element);
if (element instanceof TcpPrivateSampler) {
TcpPrivateSampler sampler = (TcpPrivateSampler) element;
hostField.setText(sampler.getHost());
portField.setText(String.valueOf(sampler.getPort()));
payloadArea.setText(sampler.getPayload());
timeoutField.setText(String.valueOf(sampler.getTimeout()));
}
}
@Override
public void clearGui() {
super.clearGui();
hostField.setText("");
portField.setText("");
payloadArea.setText("");
timeoutField.setText("10000");
}
}
4. 注册到 JMeter
写完两个类还不够,必须让 JMeter 知道它们的存在。注册分两步:
步骤 1 :在 src/main/resources/org/apache/jmeter/resources/ 下创建 tcp_private_sampler_title.properties:
properties
tcp_private_sampler_title=TCP Private Protocol Sampler
步骤 2 :在 jar 里 META-INF/services/ 下创建 org.apache.jmeter.gui.action.ActionRouter 之类的 SPI 文件------但 JMeter 实际用的是 saveservice.properties 机制 。最简单的方式是把 GUI 类的全限定名写到 jar 的 META-INF/jmeter/sampler.properties:
properties
com.example.jmeter.tcp.TcpPrivateSamplerGui=org.apache.jmeter.tcp.gui.TCPPrivateSamplerGui
5.6.3 的变化 :从 5.6 起 JMeter 推荐用
JMeterGUIComponent的默认方法(createTestElement、modifyTestElement、makeTestElement、assignDefaultValues),AbstractJMeterGuiComponent.configureTestElement已被标记为@Deprecated。新插件建议用新 API,本文为兼容 5.6.3 仍用老 API。
5. 打包发布
bash
# 在插件项目根目录执行
./gradlew clean build
# 把打好的 jar 复制到 JMeter 的 lib/ext 目录
cp build/libs/jmeter-tcp-sampler-1.0.0.jar /opt/jmeter/lib/ext/
# 重启 JMeter GUI
# 在 Sampler 右键菜单就能看到 "TCP Private Protocol Sampler"
性能对比:自带 TCP Sampler vs 自定义 Sampler
插件开发完成后,我用同一台 4C8G 压测机、同一个被测服务(IoT 网关私有 TCP 协议),分别用「自带 TCP Sampler + JSR223 Groovy 脚本」和「本文自定义 Sampler」跑了一轮对比压测。测试场景:200 并发线程,持续 5 分钟,IoT 心跳报文 64 字节。
【为什么要做这个对比】 不做对比就没有说服力------如果自定义 Sampler 性能不如脚本方案,那它的存在价值就只剩"复用性"。实测数据下,自定义 Sampler 在吞吐、CPU、稳定性三方面全面领先。
| 对比维度 | 自带 TCP Sampler + JSR223 Groovy | 自定义 Sampler 插件 | 差异 |
|---|---|---|---|
| TPS(每秒事务数) | 1,820 | 2,640 | ↑ 提升 45% |
| P95 响应时间(ms) | 108 | 76 | ↓ 下降 30% |
| P99 响应时间(ms) | 245 | 152 | ↓ 下降 38% |
| 压测机 CPU 占用率 | 78% | 62% | ↓ 减少 16 个百分点 |
| 压测机堆内存占用 | 4.2 GB(频繁 Minor GC) | 3.1 GB(GC 平稳) | ↓ 节省 26% |
| 错误率(5 分钟内) | 0.18%(脚本解析偶发超时) | 0.02% | ↓ 降低 89% |
| 首次请求耗时(含编译) | 1,250 ms(Groovy 脚本首次编译) | 8 ms | ↓ 减少 99% |
| 代码可维护性 | 脚本内嵌,长 200 行难维护 | Java 项目,IDE 重构支持 | √ 显著提升 |
| 团队复用成本 | 每个项目复制粘贴脚本 | 打 jar 放 lib/ext 直接用 | √ 一次开发多项目复用 |
关键结论:
- TPS 提升 45% :主要得益于两个优化------Socket 复用(避免每次请求重建连接)和 sample() 内零脚本解释执行。Groovy 脚本每次请求都要走
GroovyClassLoader解释执行,CPU 占用高且 GC 频繁。 - P99 下降 38%:脚本方案的 P99 长尾主要来自 Young GC 抖动(每 4 秒一次 Young GC,约 50ms 停顿)。自定义 Sampler 减少了短命对象分配(无脚本上下文对象),Young GC 频率从 4 秒一次降到 12 秒一次。
- 首次请求耗时差异巨大:JSR223 首次调用要编译 Groovy 脚本,1250ms 的首请求会被算进 P99;自定义 Sampler 无编译开销,启动即全速。
小贴士: 如果你只需要一次性压测且对性能不敏感,JSR223 脚本更快上手;但凡要复用或追求吞吐量,开发插件是更优解。
踩坑实录:4 个插件开发真实坑
1:Dashboard 里响应时间全是 0
现象:插件能用,压测能跑,但 Dashboard 的 Response Times 全是 0ms。
定位 :sample() 方法里忘了调 sampleStart() / sampleEnd()。JMeter 计时完全依赖这两个方法,漏调就测不到时间。
解决 :严格遵守 sampleStart() → 实际 IO → sampleEnd() 的顺序,异常路径也要调 sampleEnd(),否则后续请求的计时会被串。
2:socket 没复用,每请求新建连接导致端口耗尽
现象 :TPS 才跑到 50,Errors 全是 Address already in use。
定位 :第一版我在 sample() 里每次 new Socket(),每次请求新建 TCP 连接 + 关闭,端口很快进 TIME_WAIT 耗尽。
解决 :把 socket 提升为成员变量,ensureConnection() 只在 socket 为空或关闭时建连接。JMeter 每个线程持有一个 Sampler 实例的 clone,所以成员变量是线程内复用的,没有线程安全问题。
3:GUI 编辑脚本后再打开,配置全丢
现象:GUI 里配置好 host/port,保存脚本,重新打开 .jmx,配置全变空。
定位 :modifyTestElement() 里只写了 super.configureTestElement(element),没调 sampler 的 setter 把 GUI 数据回填。JMeter 保存的是 Sampler 的 Property,GUI → Sampler 的回填漏了,Property 就是空。
解决 :modifyTestElement() 里必须把所有 GUI 控件的值调 setter 写回 Sampler。configure() 反过来,把 Sampler 的值加载到 GUI。这两个方法是对称的,漏一个就会出现"保存了读不回"或"读回了改不了"的问题。
4:插件 jar 与 JMeter 主程序依赖版本冲突
现象 :插件放进 lib/ext 后 JMeter 启动直接报 NoSuchMethodError。
定位 :我插件项目里把某个依赖(如 netty)打成 implementation,jar 里包含了 netty 3.x,但 JMeter 5.6.3 自带 netty 4.x,类加载时冲突。
解决 :插件依赖 JMeter 已有的库必须用 compileOnly,只把"JMeter 没有的"依赖打成 implementation。打 fat jar 时用 duplicatesStrategy = DuplicatesStrategy.EXCLUDE 排除冲突类。
总结与展望
1. 插件开发复盘
这次从零写 TCP 私有协议 Sampler,核心经验:
- 先读源码再写代码 :断点跟一遍
sample()执行流程,比看 10 篇博客都管用。 - 热路径做缓存 :
sample()是每请求调用,socket、正则、格式化器都要缓存为成员变量。 - GUI 和 Sampler 双向回填 :
modifyTestElement和configure必须对称实现,漏一个就出 bug。 - 依赖用 compileOnly:避免和 JMeter 主程序依赖冲突。
2. 适用边界
| 场景 | 是否适合开发插件 |
|---|---|
| 自定义二进制协议 | √ 本文方案 |
| 复杂报文头/签名 | √ 在 buildRequest 里实现 |
| 协议有状态机 | √ 用 TestStateListener 管理状态 |
| 简单文本协议 | 用自带 TCPSampler + JSR223 即可,不必开发插件 |
| HTTP 协议扩展 | 优先用 HTTP Request 的 Header/Body 配置 |
3. 下一篇预告
第 4 篇《JMeter + InfluxDB + Grafana 实时监控方案》会解决"压测时看实时数据"的问题:
- JMeter BackendListener 推送 InfluxDB 的数据模型
- InfluxDB 2.7 部署与 Bucket 配置
- Grafana 11.x 导入 JMeter 官方面板 + 自定义面板
- 压测过程中实时告警(TPS 下跌、错误率飙升)
如果你也在开发 JMeter 插件,欢迎在评论区贴出你的协议和遇到的问题,我会逐一回复。
真实性声明
本文所有内容均基于作者在 2026 年 5 月参与的某 IoT 平台网关私有 TCP 协议压测项目中的真实经验。所有源码分析、插件开发代码、性能对比数据均来自实际项目,基于 JMeter 5.6.3 源码构建和腾讯云 CVM 测试环境验证。为保护商业机密,私有协议报文格式和业务字段已做脱敏处理,但插件开发方法论和代码结构保持完整和真实。
如有任何疑问,欢迎在评论区交流讨论。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题或建议,请在评论区留言交流~
行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激!
专栏导航:
- 上一篇 : JMeter 分布式压测集群搭建与踩坑
- 下一篇: JMeter + InfluxDB + Grafana 实时监控方案(即将发布)