JMeter在Java中使用非GUI模式进行压测,并收集压测相关结果。

Spring Wu 767 2021-09-15

1. 需求

在之前的Jmeter在Java中压测并使用Java对压测结果进行收集文章中,写了通过文件替换的方式在进行内容配置替换,然后使用JMeter Engine进行压测,但是有个问题就是做文件替换操作效率太低,非常影响压测服务器的性能。

所以,我只有另寻他路,最后找到一种方式,通过JMeter中的HashTree对压测内容进行组装,最后通过Summariser来收集结果。

最后需要完成的目标如图

  • 线程组配置如下

image.png

  • 请求信息如下

image.png

  • Header信息如下

image.png

  • 吞吐量定时器信息如下

image.png

  • 查看请求是否成功

image.png

2. 准备工作

2.1. Maven引用

<!-- jMeter-core -->
<dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_core</artifactId>
    <version>${jmeter.version}</version>
</dependency>
<!--jMeter component-->
<dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_components</artifactId>
    <version>${jmeter.version}</version>
</dependency>
<!--jMeter Http包-->
<dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_http</artifactId>
    <version>${jmeter.version}</version>
</dependency>
<!-- 工具类包 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.11</version>
</dependency>

2.2. jmeter.properties和saveservice.properties

  • jmeter.properties jmeter的核心配置类,可以通过该文件配置jmeter的核心参数
  • saveservice.properties jmeter的常量映射文件

需要先安装JMeter到本地,之后找到JMeter安装目录中的jmeter.properties和saveservice.properties文件。比如我的安装目录是/Users/wushuaiping/apache-jmeter-5.4.1/,这两个文件就在安装目录的/bin目录中

image.png

找到文件后,将文件拷贝到项目中的resource目录

image.png

如果对JMeter定制化需求不是很高,saveservice.properties可以不用拷贝到项目中。

3. HashTree组装

3.1. 初始化JMeter

StandardJMeterEngine jMeterEngine = new StandardJMeterEngine();
// 加载jmeter.properties文件
JMeterUtils.loadJMeterProperties(JMeterUtil.getJMeterPropertiesFile());
// 设置非GUI模式
System.setProperty(JMeter.JMETER_NON_GUI, "true");
// 初始化本地配置
JMeterUtils.initLocale();

新建JMeterUtil,用于统一jmeter参数初始化,文件加载等操作,避免代码重复。

/**
 * JMeter自定义工具类
 *
 * @author wushuaiping
 * @date 2021/8/23 9:48 上午
 */
public class JMeterUtil {

    private static final String separator = File.separator;

    /**
     * 获取jmeter配置文件
     *
     * @return
     * @throws FileNotFoundException
     */
    public static File getJMeterPropertiesFile() throws IOException {
        return getResourceFile("jmeter", "properties");
    }

    /**
     * 获取jmeter常量类配置文件
     *
     * @return
     * @throws FileNotFoundException
     */
    public static File getSaveservicePropertiesFile() throws IOException {
        return getResourceFile("saveservice", "properties");
    }

    /**
     * 获取resource目录下文件
     *
     * @param prefix
     * @param suffix
     * @return
     * @throws IOException
     */
    public static File getResourceFile(String prefix, String suffix) throws IOException {
        ClassPathResource classPathResource = new ClassPathResource(prefix + "." + suffix);
	// 由于线上是jar包方式来运行项目的,因此通过文件读取获取流的方式行不通,因为无法直接读取压缩包中的文件,读取只能通过流的方式读取。
	// 所以需要将读取到的resource信息copy到临时文件中,这样可以避免线上报错FileNotFound的问题
        byte[] keywordsData = FileCopyUtils.copyToByteArray(classPathResource.getInputStream());
        File tempFile = File.createTempFile(prefix, suffix);
        FileUtils.writeByteArrayToFile(tempFile, keywordsData);
        return tempFile;
    }
}


3.2. 初始化JMeter HTTP请求

3.2.1. 压测请求相关类

  • PressureTestRequest
/**
 * 压测请求
 *
 * @author wushuaiping
 * @date 2021/9/1 10:40 上午
 */
@Data
public class PressureTestRequest {

    private String apiName;

    private String url;

    private String body;

    private String method;

    private List<PressureTestRequestHeader> headers;

}
  • PressureTestRequestHeader
/**
 * @author wushuaiping
 * @date 2021/9/1 10:41 上午
 */
@Data
public class PressureTestRequestHeader {

    private String headerKey;

    private String headerValue;
}

  • 转换压测请求为HashTree
// 在压测时,我们可能需要多个请求压测的情况。所以我们使用一个List来保存请求信息
List<HashTree> httpRequestTrees = new LinkedList<>();
// 初始化JMeter HTTP请求,将HTTP请求转为HashTree
for (PressureTestRequest request : requests) {
    try {
        HashTree hashTree = JMeterUtil.httpToHashTree(request);
        httpRequestTrees.add(hashTree);
    } catch (Exception e) {
        log.error("发生错误",e);
        return false;
    }
}
  • 在JMeterUtil中新增httpToHashTree方法
    /**
     * 将压测请求转为JMeter HashTree
     *
     * @param pressureTestRequest
     * @return
     * @throws MalformedURLException
     * @throws UnsupportedEncodingException
     */
    public static HashTree httpToHashTree(PressureTestRequest pressureTestRequest) throws IOException {
        // 创建一个标准取样器对象sampler
        HTTPSamplerProxy sampler = new HTTPSamplerProxy();
        // 设置sampler的属性(sampler属性部分都会转成xml标签的属性值,和文本值)
        sampler.setEnabled(true);
        // 设置请求参数,jmeter貌似不支持body和param一起传的情况。这里就如果是body默认使用PostBody模式
        // query param也需要放到arguments中
        URL url = new URL(pressureTestRequest.getUrl());
        String urlPath = url.getPath();
        if (pressureTestRequest.getBody() != null) {
            sampler.setPostBodyRaw(true);
            sampler.setArguments(addHttpBodyArguments(pressureTestRequest.getBody()));
            if (StrUtil.isNotBlank(urlPath)) {
                String queryParamsPath = UrlUtils.truncateUrlPage(pressureTestRequest.getUrl());
                if (StrUtil.isNotBlank(queryParamsPath)) {
                    // body传参时使用path来拼接queryParam参数
                    urlPath = urlPath + "?" + queryParamsPath;
                }
            }

        } else {
            // 当没有body参数时,使用arguments的方式来添加queryParam参数
            // 获取url path中的query param参数
            Map<String, String> queryParams = UrlUtils.urlSplit(pressureTestRequest.getUrl());
            if (!queryParams.isEmpty()) {
                sampler.setArguments(addHttpQueryParamsArguments(queryParams));
            }
        }

        sampler.setName(pressureTestRequest.getApiName());
        sampler.setProperty(TestElement.TEST_CLASS, HTTPSamplerProxy.class.getName());
        sampler.setProperty(TestElement.GUI_CLASS, HttpTestSampleGui.class.getName());
        sampler.setProperty(TestPlan.COMMENTS, "");
        sampler.setContentEncoding("UTF-8");
        sampler.setFollowRedirects(true);
        sampler.setAutoRedirects(false);
        sampler.setUseKeepAlive(true);
        // 这些选项都可以做成自定义的
        sampler.setConnectTimeout("5000");
        sampler.setResponseTimeout("5000");
        sampler.setEmbeddedUrlRE("");
        sampler.setMethod(pressureTestRequest.getMethod());
        // 设置请求信息
        sampler.setDomain(URLDecoder.decode(url.getHost(), "UTF-8"));
        sampler.setPath(URLDecoder.decode(urlPath, "UTF-8"));
        sampler.setProtocol(URLDecoder.decode(url.getProtocol(), "UTF-8"));
        if (url.getPort() == -1 && StrUtil.equals("http", url.getProtocol())) {
            sampler.setPort(80);
        } else if (url.getPort() == -1 && StrUtil.equals("https", url.getProtocol())) {
            sampler.setPort(443);
        } else {
            sampler.setPort(url.getPort());
        }
        sampler.setPort(url.getPort());
        HashTree httpTree = new ListedHashTree();
        httpTree.put(sampler, new ListedHashTree());
        //在sampler的树结构添加同一级别的请求头(等同于HTTP信息头管理器)
        addHeaderManagerToHashTree(httpTree, pressureTestRequest.getHeaders());
        return httpTree;
    }
  • 在JMeterUtil中新增addHttpQueryParamsArguments方法
    /**
     * 将queryParam转为JMeter Arguments
     *
     * @param queryParams
     * @return
     */
    public static Arguments addHttpQueryParamsArguments(Map<String, String> queryParams) {
        Arguments arguments = new Arguments();
        queryParams.forEach((key, value) -> {
            HTTPArgument httpArgument = new HTTPArgument(key, value);
            httpArgument.setAlwaysEncoded(false);
            arguments.addArgument(httpArgument);
        });
        return arguments;
    }
  • 在JMeterUtil中新增addHeaderManagerToHashTree方法
    /**
     * 给HashTree添加请求头
     *
     * @param hashTree
     * @param pressureTestAPIHeaders
     */
    public static void addHeaderManagerToHashTree(HashTree hashTree, List<PressureTestRequestHeader> pressureTestAPIHeaders) {
        if (CollectionUtils.isEmpty(pressureTestAPIHeaders)) {
            return;
        }
        HeaderManager headerManager = new HeaderManager();
        headerManager.setEnabled(true);
        headerManager.setName("HTTP信息头管理器");
        headerManager.setProperty(TestElement.GUI_CLASS, HeaderPanel.class.getName());
        headerManager.setProperty(TestElement.TEST_CLASS, HeaderManager.class.getName());
        headerManager.setProperty(TestPlan.COMMENTS, "");
        pressureTestAPIHeaders.forEach(header -> headerManager.add(new Header(header.getHeaderKey(), header.getHeaderValue())));
        HashTree headerTree = new ListedHashTree();
        headerTree.put(headerManager, new ListedHashTree());
        hashTree.add(headerTree);
    }

在Jmeter GUI中的界面为:
image.png

3.2.2. 将转换好的请求HashTree添加到线程组中

// 将请求的HashTree添加进线程组
HashTree threadGroupTree = new ListedHashTree();
ThreadGroup threadGroup = JMeterUtil.initThreadGroup(60*10, -1, 1);
httpRequestTrees.forEach(httpRequestTree -> threadGroupTree.put(threadGroup, httpRequestTree));
  • 在JMeterUtil中新增initThreadGroup方法
    /**
     * 初始化JMeter线程组
     * 请求次数=loops * threadNumber
     *
     * @param duration     持续执行时间,单位秒
     * @param loops        循环次数 -1为无限循环
     * @param threadNumber 线程数
     * @return
     */
    public static ThreadGroup initThreadGroup(long duration, int loops, int threadNumber) {
        LoopController loopController = new LoopController();
        loopController.setName("循环控制器");
        loopController.setEnabled(true);
        loopController.setLoops(loops);
        loopController.setContinueForever(false);
        loopController.setProperty(TestElement.TEST_CLASS, LoopController.class.getName());
        loopController.setProperty(TestElement.GUI_CLASS, LoopControlPanel.class.getName());
        ThreadGroup group = new ThreadGroup();
        group.setEnabled(true);
        group.setName("线程组");
        group.setProperty(TestElement.TEST_CLASS, ThreadGroup.class.getName());
        group.setProperty(TestElement.GUI_CLASS, ThreadGroupGui.class.getName());
        group.setProperty(ThreadGroup.IS_SAME_USER_ON_NEXT_ITERATION, true);
        group.setProperty(TestElement.COMMENTS, "");
        group.setNumThreads(threadNumber);
        group.setDuration(duration);
        group.setRampUp(0);
        group.setDelay(0);
        group.setProperty(ThreadGroup.ON_SAMPLE_ERROR, ThreadGroup.ON_SAMPLE_ERROR_CONTINUE);
        group.setScheduler(true);
        group.setSamplerController(loopController);
        return group;
    }

对应的JMeter GUI界面为:
image.png

3.3. 定义吞吐量定时器

// 定义吞吐量定时器,并添加到线程组中
HashTree throughputTimerHashTree = new ListedHashTree();
// limitQps * 60L为限制每分钟最大请求量
BigDecimal limitQpsDecimal = new BigDecimal(limitQps * 60L).setScale(0, RoundingMode.UP);
ConstantThroughputTimer throughputTimer = JMeterUtil.initConstantThroughputTimer(limitQpsDecimal.longValue());
throughputTimerHashTree.put(throughputTimer, new ListedHashTree());
threadGroupTree.put(threadGroup, throughputTimerHashTree);
  • 在JMeterUtil中新增initConstantThroughputTimer方法
    /**
     * 初始化常数吞吐量定时器
     *
     * @param tps 每分钟吞吐量
     * @return
     */
    public static ConstantThroughputTimer initConstantThroughputTimer(Long tps) {
        ConstantThroughputTimer throughputTimer = new ConstantThroughputTimer();
	// 基于计算吞吐量:所有此线程
        throughputTimer.setProperty("calcMode", 1);
	// 限制每分钟最大吞吐量
        throughputTimer.setProperty("throughput", tps);
        throughputTimer.setProperty(TestElement.TEST_CLASS, ConstantThroughputTimer.class.getName());
        throughputTimer.setProperty(TestElement.GUI_CLASS, TestBeanGUI.class.getName());
        throughputTimer.setEnabled(true);
        throughputTimer.setName("常数吞吐量定时器");
        return throughputTimer;
    }

对应JMeter GUI界面:
image.png

3.4. 添加测试计划

HashTree testPlanHashTree = new ListedHashTree();
TestPlan testPlan = JMeterUtil.initTestPlan("测试计划-" + 业务id);
testPlanHashTree.put(testPlan, threadGroupTree);
  • 在JMeterUtil中新增initTestPlan方法
    /**
     * 初始化测试计划
     *
     * @param testPlanName
     * @return
     * @throws IOException
     */
    public static TestPlan initTestPlan(String testPlanName) {
        TestPlan plan = new TestPlan();
        //设置测试计划属性及内容,最后都会转为xml标签的属性及内容
        plan.setProperty(TestElement.NAME, testPlanName);
        plan.setProperty(TestElement.TEST_CLASS, TestPlan.class.getName());
        plan.setProperty(TestElement.GUI_CLASS, TestPlanGui.class.getName());
        plan.setEnabled(true);
        plan.setComment("");
        plan.setFunctionalMode(false);
        plan.setTearDownOnShutdown(true);
        plan.setSerialized(false);
        plan.setProperty("TestPlan.user_define_classpath", "");
        plan.setProperty("TestPlan.user_defined_variables", "");
        plan.setUserDefinedVariables(new Arguments());
        return plan;
    }

对应JMeter GUI界面:
image.png

3.5. 准备收集结果集

Summariser summer = null;
String summariserName = JMeterUtils.getPropDefault("summariser.name", "summary");
if (summariserName.length() > 0) {
    summer = new Summariser(summariserName);
}
ResultCollector viewResultsFullVisualizer = new ResultCollector(summer);
viewResultsFullVisualizer.setProperty(TestElement.TEST_CLASS, ResultCollector.class.getName());
viewResultsFullVisualizer.setProperty(TestElement.GUI_CLASS, ViewResultsFullVisualizer.class.getName());
viewResultsFullVisualizer.setName("查看结果树");
viewResultsFullVisualizer.setEnabled(true);
testPlanHashTree.add(testPlanHashTree.getArray(), viewResultsFullVisualizer);

3.6. 结果集收集

// 将组装好的HashTree配置到JMeterEngine中
jMeterEngine.configure(testPlanHashTree);
long startPressureTestMillis = System.currentTimeMillis();
jMeterEngine.run();
long endPressureTestMillis = System.currentTimeMillis();

3.6.1. 定制Summariser类

在jmeter的api中是没有提供直接获取压测结果的方法的,所以我们需要找到其他方法来完成该需求。
通过查看jmeter源码,发现收集结果都在Summariser进行,所以我们需要对Summariser进行定制。
找到org.apache.jmeter.reporters.Summariser类

    1. private transient Totals myTotals = null;改为public transient Totals myTotals = null;
    1. 将org.apache.jmeter.reporters.Summariser.Totals#total也改为public
    1. org.apache.jmeter.reporters.SummariserRunningSample类改为public修饰
    1. org.apache.jmeter.reporters.Summariser末尾新增方法
public org.apache.jmeter.reporters.SummariserRunningSample getTotal() {
    return myTotals == null ? null : myTotals.total;
}

如图,直接将源码拷贝到项目中进行修改,只是包名一定要和源码包名保持一致。

image.png

3.6.2. 收集结果

SummariserRunningSample summariserRunningSample = summer.getTotal();
// 本次压测总请求量
summariserRunningSample.getCounter();
// 本次压测总失败请求量
summariserRunningSample.getErrorCount();
// 最小响应时间
summariserRunningSample.getMin();
// 最大响应时间
summariserRunningSample.getMax();
// tps 吞吐量
summariserRunningSample.getRate();
// 平均响应时间
summariserRunningSample.getAverage();

欢迎评论,转发

转发需注明原作者,谢谢~


# Jmeter