JMeter 通用 HTTP 自定义采样器

背景

        曾经兼职维护过公司的压测代码一断时间,当时接手过来的那压测代码,简直不要太LOW:就是简单暴力的java -cp去运行一个main方法,里面用多线程去加压,测试结果也是不严谨的自己算出来的(如果进行反推计算可以发现很多不能自圆其说的地方)。由于测试结果的不准确,后来我基于JMeter自定义采样器的开发规范集成到JMeter中去了。然后就一有压测的事情就找我,干着干着好像也能勉强算半个压测的行家了,基础的Http接口压测、基于Tcp的私有长连接协议的压测、音视频UDP媒体中继层面的压测都搞过。然后最近公司一个RestHttpApi接口数量200+的系统要压测,又找到我。我没有功夫通过自定义采样器的方式去一个一个接口的去硬实现,也不想折腾JMeter里各种元件,于是萌生了结合低代码思路去开发一个简单易用的自定义采样器的想法。

1. Release Note

  • 【2022-10-12】原型发布,基本功能可用。
  • 【2022-11-23】sampleIndex不再对外暴露使用优化。之前把sample定义为内置变量,很多函数需要使用,例如:getCsvData(#{csvFileName}, #{varName}, #{sampleIndex}),这样有2个弊端:1:让人困惑:自己根本就没有定义过这样的变量,它是哪里来的?2:函数使用语法不简洁。优化后为:getCsvData(#{csvFileName}, #{varName}),这样就很明确,从哪个csv文件(csvFileName参数表达)去按照顺序读(函数名表达)哪个字段(varName参数表达)。
  • 【2023-1-31】采样变量定义及援引有序性支持(之前变量定义为无序方式,造成使用上存在相关约束和限制),该能力支持后,压测测试计划编排将更加符合自然思维,例如支持以下写法(原约束说明详见7.1):
    JMeter 通用 HTTP 自定义采样器_第1张图片

2. 编译及源码

1、编译下载
0 积分免费下载,内容包括jmeter-5.4.3及通用 HTTP 自定义采样器编译,如下图:添加采样器即可直接使用。由于使用jmeter5.4.3因此依赖java11。
JMeter 通用 HTTP 自定义采样器_第2张图片

https://download.csdn.net/download/camelials/87769473

2、源码地址

https://github.com/bossfriday/bossfriday-jmeter

1. 如何开发JMeter自定义采样器

首先介绍一下为什么选择JMeter以及如何开发JMeter自定义采样器。

压测工具很多,之所以选择JMeter的原因是LoadRunner太重了(装一下好几个G),然后基本都是在windows下使用(现在应该是出了linux版)。

JMeter使用Java语言开发,扩展自定义采样器其实十分简单,简单来说就2步:继承AbstractSampler去实现你自己的采样器、继承AbstractSamplerGui去实现采样器的UI。至于JMeter的集成方式就更加简单:直接将编译好的Jar包放到:{JMeterHome}/lib/ext目录下重启JMeter即可,代码示例及效果如下:
1)AppSampler.java

public class AppSampler extends AbstractSampler implements TestStateListener {

    private static final Logger log = LogManager.getLogger(AppSampler.class);
    private static BaseSampler sampler;
    private static SamplerSetting setting;

    public AppSampler() {
        this.setName(GUI_SAMPLER_NAME);
    }

    @Override
    public void testStarted() {
        try {
            setSamplerSetting(this);
            setSampler(setting);

            sampler.testStarted();
            log.info("sampler testStarted() done.");
        } catch (Exception ex) {
            sampler.setStartError(true);
            log.error("testStarted() error!", ex);
        }
    }

    @Override
    public SampleResult sample(Entry entry) {
        try {
            if (sampler.isStartError()) {
                return AppSamplerUtils.buildSampleResult(setting.getSampleLabel(),
                        String.valueOf(CODE_TEST_STARTED_ERROR),
                        RESPONSE_MESSAGE_STARTED_ERROR,
                        false);
            }

            SampleResult sampleResult = sampler.sample();
            sampleResult.setSampleLabel(setting.getSampleLabel());

            return sampleResult;
        } catch (Exception ex) {
            log.error("sample() error!", ex);
            return AppSamplerUtils.buildSampleResult(setting.getSampleLabel(),
                    String.valueOf(CODE_SAMPLE_ERROR),
                    ex.getMessage(),
                    false);
        }
    }

    @Override
    public void testStarted(String s) {
        // ignore the unsupported invoke
    }

    @Override
    public void testEnded() {
        try {
            sampler.testEnded();
            log.info("sampler testEnded() done.");
        } catch (Exception ex) {
            log.error("testEnded() error!", ex);
        }
    }

    @Override
    public void testEnded(String s) {
        // ignore the unsupported invoke
    }

    /**
     * setSamplerSetting
     *
     * @param sampler
     */
    private static synchronized void setSamplerSetting(AbstractSampler sampler) throws PocException {
        String samplerType = sampler.getPropertyAsString(GUI_SAMPLER_TYPE);
        String sampleLabel = sampler.getPropertyAsString(GUI_SAMPLE_LABEL);
        String assertType = sampler.getPropertyAsString(GUI_ASSERT_TYPE);
        boolean isLogRequest = sampler.getPropertyAsBoolean(GUI_SAMPLE_IS_LOG_REQUEST);
        boolean isLogResponse = sampler.getPropertyAsBoolean(GUI_SAMPLE_IS_LOG_RESPONSE);
        String sampleVar = sampler.getPropertyAsString(GUI_SAMPLE_VAR);
        boolean isHttps = sampler.getPropertyAsBoolean(GUI_IS_HTTPS);
        String method = sampler.getPropertyAsString(GUI_METHOD);
        String url = sampler.getPropertyAsString(GUI_URL);
        String headerData = sampler.getPropertyAsString(GUI_HEADER_DATA);
        String bodyData = sampler.getPropertyAsString(GUI_BODY_DATA);
        String variables = sampler.getPropertyAsString(GUI_VARIABLES);
        String jsonPath = sampler.getPropertyAsString(GUI_JSON_PATHS);
        String defaultValues = sampler.getPropertyAsString(GUI_DEFAULT_VALUES);
        String manualDoc = sampler.getPropertyAsString(GUI_MANUAL_DOC);

        if (StringUtils.isEmpty(sampleLabel)) {
            throw new PocException("sampleLabel is empty!");
        }

        if (StringUtils.isEmpty(samplerType)) {
            throw new PocException("samplerType is empty!");
        }

        setting = SamplerSetting.builder()
                .samplerType(samplerType)
                .sampleLabel(sampleLabel)
                .assertType(assertType)
                .isLogRequest(isLogRequest)
                .isLogResponse(isLogResponse)
                .sampleVar(sampleVar)
                .isHttps(isHttps)
                .method(method)
                .url(url)
                .headerData(headerData)
                .bodyData(bodyData)
                .variables(variables)
                .jsonPath(jsonPath)
                .defaultValues(defaultValues)
                .manualDoc(manualDoc)
                .build();
        log.info("Current SamplerSetting is: {}", setting);
    }

    /**
     * setSampler
     *
     * @param setting
     * @throws PocException
     * @throws InvocationTargetException
     * @throws NoSuchMethodException
     * @throws InstantiationException
     * @throws IllegalAccessException
     */
    private static synchronized void setSampler(SamplerSetting setting) throws PocException,
            InvocationTargetException,
            NoSuchMethodException,
            InstantiationException,
            IllegalAccessException {
        sampler = AppSamplerBuilder.getSampler(setting);
    }
}

2)AppSamplerGui.java

public class AppSamplerGui extends AbstractSamplerGui {

    private JTextField sampleLabel;
    private JLabeledChoice samplerType;
    private JLabeledChoice asserterType;

    private JCheckBox isLogRequest;
    private JCheckBox isLogResponse;
    private JCheckBox isHttps;

    private JLabeledChoice method;
    private JTextField url;

    private JSyntaxTextArea sampleVar = JSyntaxTextArea.getInstance(5, 80);
    JTextScrollPane sampleVarScrollPane = JTextScrollPane.getInstance(this.sampleVar);

    private JSyntaxTextArea headerData = JSyntaxTextArea.getInstance(5, 80);
    JTextScrollPane headerDataScrollPane = JTextScrollPane.getInstance(this.headerData);

    private JSyntaxTextArea bodyData = JSyntaxTextArea.getInstance(10, 80);
    JTextScrollPane bodyDataScrollPane = JTextScrollPane.getInstance(this.bodyData);

    private JSyntaxTextArea manualDoc = JSyntaxTextArea.getInstance(38, 80);
    JTextScrollPane manualDocScrollPane = JTextScrollPane.getInstance(this.manualDoc);

    private JTextField variables;
    private JTextField jsonPath;
    private JTextField defaultValues;

    public AppSamplerGui() {
        super();

        JPanel settingPanel = new VerticalPanel(10, 10);
        settingPanel.add(this.createBaseSetting());
        settingPanel.add(this.createRequestSetting());
        settingPanel.add(this.createResponseJsonExtractorSetting());
        settingPanel.add(this.createManualDoc());

        JPanel dataPanel = new JPanel(new BorderLayout(10, 10));
        dataPanel.add(settingPanel, BorderLayout.NORTH);
        this.setLayout(new BorderLayout(10, 10));
        this.setBorder(this.makeBorder());
        this.add(this.makeTitlePanel(), BorderLayout.NORTH);
        this.add(dataPanel, BorderLayout.CENTER);
    }

    @Override
    public String getLabelResource() {
        throw new IllegalStateException("This shouldn't be called");
    }

    @Override
    public TestElement createTestElement() {
        AppSampler sampler = new AppSampler();
        this.modifyTestElement(sampler);

        return sampler;
    }

    @Override
    public void modifyTestElement(TestElement testElement) {
        testElement.clear();
        this.configureTestElement(testElement);

        testElement.setProperty(GUI_SAMPLE_LABEL, this.sampleLabel.getText());
        testElement.setProperty(new BooleanProperty(GUI_IS_HTTPS, this.isHttps.isSelected()));
        testElement.setProperty(GUI_SAMPLER_TYPE, this.samplerType.getText());
        testElement.setProperty(GUI_ASSERT_TYPE, this.asserterType.getText());
        testElement.setProperty(new BooleanProperty(GUI_SAMPLE_IS_LOG_REQUEST, this.isLogRequest.isSelected()));
        testElement.setProperty(new BooleanProperty(GUI_SAMPLE_IS_LOG_RESPONSE, this.isLogResponse.isSelected()));
        testElement.setProperty(GUI_SAMPLE_VAR, this.sampleVar.getText());
        testElement.setProperty(GUI_METHOD, this.method.getText());
        testElement.setProperty(GUI_URL, this.url.getText());
        testElement.setProperty(GUI_HEADER_DATA, this.headerData.getText());
        testElement.setProperty(GUI_BODY_DATA, this.bodyData.getText());
        testElement.setProperty(GUI_VARIABLES, this.variables.getText());
        testElement.setProperty(GUI_JSON_PATHS, this.jsonPath.getText());
        testElement.setProperty(GUI_DEFAULT_VALUES, this.defaultValues.getText());
    }

    @Override
    public String getStaticLabel() {
        return GUI_SAMPLER_NAME;
    }

    @Override
    public void clearGui() {
        super.clearGui();

        this.manualDoc.setText(AppSamplerManual.getDocument());
    }

    @Override
    public void configure(TestElement element) {
        super.configure(element);

        this.sampleLabel.setText(element.getPropertyAsString(GUI_SAMPLE_LABEL));
        this.isHttps.setSelected(element.getPropertyAsBoolean(GUI_IS_HTTPS));
        this.samplerType.setText(element.getPropertyAsString(GUI_SAMPLER_TYPE));
        this.asserterType.setText(element.getPropertyAsString(GUI_ASSERT_TYPE));
        this.isLogRequest.setSelected(element.getPropertyAsBoolean(GUI_SAMPLE_IS_LOG_REQUEST));
        this.isLogResponse.setSelected(element.getPropertyAsBoolean(GUI_SAMPLE_IS_LOG_RESPONSE));
        this.sampleVar.setText(element.getPropertyAsString(GUI_SAMPLE_VAR));
        this.method.setText(element.getPropertyAsString(GUI_METHOD));
        this.url.setText(element.getPropertyAsString(GUI_URL));
        this.headerData.setText(element.getPropertyAsString(GUI_HEADER_DATA));
        this.bodyData.setText(element.getPropertyAsString(GUI_BODY_DATA));
        this.variables.setText(element.getPropertyAsString(GUI_VARIABLES));
        this.jsonPath.setText(element.getPropertyAsString(GUI_JSON_PATHS));
        this.defaultValues.setText(element.getPropertyAsString(GUI_DEFAULT_VALUES));
    }

    protected Component createBaseSetting() {
        this.sampleLabel = new JTextField(20);
        JLabel label1 = new JLabel("SampleLabel: ");
        label1.setLabelFor(this.sampleLabel);

        this.samplerType = new JLabeledChoice("", AppSamplerBuilder.getSamplerTypes(), true, false);
        this.samplerType.setPreferredSize(new Dimension(200, 30));

        this.asserterType = new JLabeledChoice("", AppSamplerAsserter.getAsserters(), false, false);
        this.asserterType.setPreferredSize(new Dimension(200, 30));

        JLabel label2 = new JLabel("    SamplerType: ");
        label2.setLabelFor(this.samplerType);

        JLabel label3 = new JLabel("    AsserterType: ");
        label3.setLabelFor(this.asserterType);

        this.isLogRequest = new JCheckBox("IsLogRequest    ");
        this.isLogRequest.setFont(null);
        this.isLogRequest.setSelected(false);

        this.isLogResponse = new JCheckBox("IsLogResponse");
        this.isLogResponse.setFont(null);
        this.isLogResponse.setSelected(false);

        JPanel panel1 = new HorizontalPanel();
        panel1.setLayout(new FlowLayout(0, 0, 0));
        panel1.add(label1, BorderLayout.WEST);
        panel1.add(this.sampleLabel, BorderLayout.WEST);
        panel1.setLayout(new FlowLayout(0, 0, 0));
        panel1.add(label2, BorderLayout.WEST);
        panel1.add(this.samplerType, BorderLayout.WEST);
        panel1.add(label3, BorderLayout.WEST);
        panel1.add(this.asserterType, BorderLayout.WEST);
        panel1.add(this.isLogRequest, BorderLayout.WEST);
        panel1.add(this.isLogResponse, BorderLayout.WEST);

        JPanel panel3 = new HorizontalPanel();
        JPanel vertPanel3 = new VerticalPanel();
        JPanel httpHeaderDataPanel3 = new JPanel(new BorderLayout());
        httpHeaderDataPanel3.add(this.sampleVarScrollPane, BorderLayout.CENTER);
        vertPanel3.add(httpHeaderDataPanel3);
        vertPanel3.setBorder(BorderFactory.createTitledBorder(BorderFactory.createLineBorder(Color.gray), "Sample Variable ( Fun Supported )"));
        panel3.add(vertPanel3);

        return this.getTitledPanel("Global Setting", panel1, panel3);
    }

    protected Component createRequestSetting() {
        this.method = new JLabeledChoice("", AppSamplerUtils.getHttpMethodNames(), false, false);

        this.url = new JTextField(83);
        JLabel pathLabel = new JLabel("URL: ");
        pathLabel.setLabelFor(this.url);

        this.isHttps = new JCheckBox("IsHttps");
        this.isHttps.setFont(null);
        this.isHttps.setSelected(false);

        JPanel panel1 = new HorizontalPanel();
        panel1.setLayout(new FlowLayout(0, 0, 0));
        panel1.add(new JLabel("Method:"));
        panel1.add(this.method, BorderLayout.WEST);
        panel1.add(pathLabel, BorderLayout.WEST);
        panel1.add(this.url, BorderLayout.WEST);
        panel1.add(this.isHttps, BorderLayout.WEST);

        JPanel panel2 = new HorizontalPanel();
        JPanel headerDataContentPanel = new VerticalPanel();
        JPanel httpHeaderDataPanel = new JPanel(new BorderLayout());
        httpHeaderDataPanel.add(this.headerDataScrollPane, BorderLayout.CENTER);
        headerDataContentPanel.add(httpHeaderDataPanel);
        headerDataContentPanel.setBorder(BorderFactory.createTitledBorder(BorderFactory.createLineBorder(Color.gray), "Header Data"));
        panel2.add(headerDataContentPanel);

        JPanel panel3 = new HorizontalPanel();
        JPanel bodyDataContentPanel = new VerticalPanel();
        JPanel bodyDataPanel = new JPanel(new BorderLayout());
        bodyDataPanel.add(this.bodyDataScrollPane, BorderLayout.CENTER);
        bodyDataContentPanel.add(bodyDataPanel);
        bodyDataContentPanel.setBorder(BorderFactory.createTitledBorder(BorderFactory.createLineBorder(Color.gray), "Body Data"));
        panel3.add(bodyDataContentPanel);

        return this.getTitledPanel("Request Setting (Fun & Var Supported)", panel1, panel2, panel3);
    }

    protected Component createResponseJsonExtractorSetting() {
        this.variables = new JTextField(90);
        JLabel label1 = new JLabel("Name Of Variables: ");
        label1.setLabelFor(this.sampleLabel);
        label1.setPreferredSize(new Dimension(110, 30));

        this.jsonPath = new JTextField(90);
        JLabel label2 = new JLabel("JSON Path: ");
        label2.setLabelFor(this.jsonPath);
        label2.setPreferredSize(new Dimension(110, 30));

        this.defaultValues = new JTextField(90);
        JLabel label3 = new JLabel("Default Values: ");
        label3.setLabelFor(this.defaultValues);
        label3.setPreferredSize(new Dimension(110, 30));

        JPanel panel1 = new HorizontalPanel();
        panel1.setLayout(new FlowLayout(0, 0, 0));
        panel1.add(label1, BorderLayout.WEST);
        panel1.add(this.variables, BorderLayout.WEST);

        JPanel panel2 = new HorizontalPanel();
        panel2.setLayout(new FlowLayout(0, 0, 0));
        panel2.add(label2, BorderLayout.WEST);
        panel2.add(this.jsonPath, BorderLayout.WEST);
        panel2.add(new JLabel("(Var Spt)"), BorderLayout.WEST);

        JPanel panel3 = new HorizontalPanel();
        panel3.setLayout(new FlowLayout(0, 0, 0));
        panel3.add(label3, BorderLayout.WEST);
        panel3.add(this.defaultValues, BorderLayout.WEST);

        JPanel titledPanel = new VerticalPanel();
        JPanel borderedPanel = new VerticalPanel();
        borderedPanel.add(panel1);
        borderedPanel.add(panel2);
        borderedPanel.add(panel3);
        borderedPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
        titledPanel.add(borderedPanel);
        titledPanel.setBorder(BorderFactory.createTitledBorder(BorderFactory.createLineBorder(Color.gray), "Response JSON Extractor Setting"));

        return titledPanel;
    }

    protected Component createManualDoc() {
        this.manualDoc.setEditable(false);
        JPanel panel = new HorizontalPanel();
        JPanel bodyDataContentPanel = new VerticalPanel();
        JPanel bodyDataPanel = new JPanel(new BorderLayout());
        bodyDataPanel.add(this.manualDocScrollPane, BorderLayout.CENTER);
        bodyDataContentPanel.add(bodyDataPanel);
        panel.add(bodyDataContentPanel);

        return this.getTitledPanel("Manual Document", panel);
    }

    /**
     * getTitledPanel
     *
     * @param title
     * @param panels
     * @return
     */
    private Component getTitledPanel(String title, JPanel... panels) {
        JPanel titledPanel = new VerticalPanel();
        JPanel borderedPanel = new VerticalPanel();
        for (JPanel panel : panels) {
            borderedPanel.add(panel);
        }

        borderedPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
        titledPanel.add(borderedPanel);
        titledPanel.setBorder(BorderFactory.createTitledBorder(BorderFactory.createLineBorder(Color.gray), title));

        return titledPanel;
    }
}

3)UI展示效果
JMeter 通用 HTTP 自定义采样器_第3张图片

2. 为什么重复造轮子

从上面的UI展示,对于Jmeter相对熟悉的同学肯定可以发现:使用Jmeter自带HTTP采样器也能进行HTTP接口的压力测试,那么为什么要重复造轮子呢?重复造轮子的事情如果没有充足的理由,那么坚决是不应该干的。Jmeter自带HTTP采样器存在以下不足:

  • 非简单用例无法在一个界面下完成配置,常常一个测试计划很难一眼看明白(自己是作者,过些天要调整测试计划,往往需要先回当时思路;自己不是作者,往往需要前前后后的把整个测试计划反复推敲),通常使用Jmeter内置元件进行非简单的HTTP接口压力测试需要涉及以下元件:
    CSV Data Set Config
    BeanShell Processor(BeanShell PreProcessor、BeanShell PostProcessor)
    HttpHeader Manager
    JSON Extractor
    Random Variable
    JSON Assertsion(Jmeter默认断言为看应答statCode是否为200
  • JMeter JSON Extractor默认内存持久存在以下弊端
    1、使用CSV文件持久需要自己写BeanShell脚本
    2、使用内存持久局限性:前置用例活着,后置用例无法单独直接跑、前置用例与后置用例为不同线程组则配置更加复杂。
    3、引入其他持久方式Redis、DB则打压端部署/执行重。
  • 使用受限、扩展门槛高
    1、Random CSV Data Set 为外部插件(自己写插件门槛高,到处去找插件麻烦不说,还未必能找到合适的插件)
    2、BeanShell有一定学习成本且需要注意其执行效率问题(我自己没怎么用过,听同事说这玩意执行效率存在问题,特别是一些特定场景下,例如:涉及多采样器场景,需要其作用域提高时,BeanShell执行效率低肉眼可见)。

下面是一个用于压测验证码登录场景的使用Jmeter自身元件配置出来的测试计划:
JMeter 通用 HTTP 自定义采样器_第4张图片
JMeter 通用 HTTP 自定义采样器_第5张图片
JMeter 通用 HTTP 自定义采样器_第6张图片
JMeter 通用 HTTP 自定义采样器_第7张图片
JMeter 通用 HTTP 自定义采样器_第8张图片
JMeter 通用 HTTP 自定义采样器_第9张图片
结合上面的实例,是不是有上面的说的这些弊端呢?因此我这里闭门造车的初衷为:
1、使用简单,降低Jmeter学习成本(像使用PostMan一样的进行HTTP接口压力测试,再也不需要去学习Jmeter各种插件/元件的使用方式,以及不用去到处找各种非官方插件)。
2、一眼就能看明白整个测试计划(整个测试计划在一个界面中配置完成,再也不用去推敲各种无序Jmeter元件的在某个测试计划中的用途)。
3、自定义函数及采样断言的扩展简单快捷(后续介绍方法)。

上面说的这些太空泛,那么看实际例子吧(前两天压了一下我们系统会议服务主要场景的测试计划):
1、创建会议:
JMeter 通用 HTTP 自定义采样器_第10张图片
2、加入会议
创建会议时候总采样数是5万,这里使用自定义函数modCsvData保障5万次加入采样只会往5000个会议里加,这样就可以控制每个会议的成员为10人,很多时候这种类似的要求经常碰到:例如:创建N个百人群,或者其他一对多的关系的时候,那么这个时候可以使用这种方法进行精准的控制
JMeter 通用 HTTP 自定义采样器_第11张图片
3、离开会议
JMeter 通用 HTTP 自定义采样器_第12张图片
4、结束会议
JMeter 通用 HTTP 自定义采样器_第13张图片
相信这些测试计划其中意思大家容易看懂,是不是:使用简单、一眼就能看到全貌?

另外,也可以把它当成接口批量调用的工具去使用,例如:
JMeter 通用 HTTP 自定义采样器_第14张图片
上面的这测试计划的用途是把指定订单的结束时间往后延期30天,指定的订单数据可以直接从库里用sql查出来之后导出为csv。

3. 设计及实现思路概要

开发这玩意大部分的时间是处于边做边想的状态(毕竟设计开发共用了9天),以下是当时动手编码之前做的一些简单考虑的记录,大家简单了解下在哪些简单想法下诞生了这个玩意即可。

3.1 UI原型设计

JMeter 通用 HTTP 自定义采样器_第15张图片
对比成品去看,成品与最开始的想法还是有不小的改动。

3.2 采样器扩展考虑

要求:简单快速扩展新的采样器
实现要求:抽象束行之后结合自定义注解,启动时反射扫描以实现自动装载。新增采样器实现约束即可使用。(兼容一些特殊场景的压测无法使用通用采样器进行配置的情况,例如:真实媒体文件,然后以一定码率向某个会议发送媒体流)。

3.3 请求设置表达式解析

  • 直接正则解析一步到位 --脚本引擎健壮性差、开发复杂度相对低
    两种选择:(实现阶段酌情选择)
  • 正规的词法和语法分析(先分词得到token,然后再对token进行语法分析) --脚本引擎健壮性好、开发复杂度高。

3.4 自定义函数扩展

两种选择:(实现阶段酌情选择,目前倾向开发难度小的函数Map方式)

  • 函数MAP
  • 四元组数组执行器(编译和非编译执行均可)

3.5 应答 JSON 提取

基本上首选JSON PATH。

3.6 执行优化

  • 表达式解析或者编译需要保障在采样前置逻辑中完成。
  • 采样变量中的常量不参与表达式计算。
  • 懒加载数据在采样开始之前进行1次warm up,以保障第一次采样结果不受其影响。
  • 多次被使用的同一个采样变量运行时需保障一定不会被多次重复计算。
  • 采样结果提取器落盘在采样后置阶段进行。
  • 正常采样Log信息每N条进行一次写入,错误采样日志每条均进行Log记录。

4. 工具性能

在经过一定的本工具压测实战之后,基本可以确定本工具可用且基本定型,那么工具自身执行效率还是需要验证一下的。因为表达式执行如果自身效率不高则会对打压结果造成干扰。设计之初我就有所考虑(3.6章节中执行优化)以下是非严谨验证方法:
测试计划:2个变量、3个函数;
JMeter 通用 HTTP 自定义采样器_第16张图片
CommonHttpSampler临时改造:将应答由请求接口获取改为直接hardcode为一个正确应答:

            sampleResult.sampleStart();
            int currentSampleIndex = this.sampleIndex.getAndIncrement();
            Map<String, Object> args = this.executor.getVariableMap(currentSampleIndex);
            String url = this.executor.applyExpression(this.executor.getUrlParser(), args);
            String header = this.executor.applyExpression(this.executor.getHeaderParser(), args);
            String body = this.executor.applyExpression(this.executor.getBodyParser(), args);
            httpRequest = this.executor.getHttpRequest(url, header, body);
            ResponseHandler<String> responseHandler = new BasicResponseHandler();

            // sampleResult.sampleStart()
            // String responseBody = httpClient.execute(httpRequest, responseHandler);
            String responseBody = "{\n" +
                    "  \"code\": 0,\n" +
                    "  \"msg\": \"success\",\n" +
                    "  \"data\": {\n" +
                    "    \"msgExpireTime\": 300\n" +
                    "  }\n" +
                    "}";
            sampleResult.sampleEnd();

测试结果(测试机器:自用笔记本,DELL 灵越 17年老款):平均延时<1ms,秒吞吐量:10W+
JMeter 通用 HTTP 自定义采样器_第17张图片
从上面的测试结果基本上可以认为:使用本压测工具进行压力测试,工具自身性能损耗对压测结果干扰基本可以忽略。

5. 扩展方法

5.1 自定义函数

1、继承BaseFunction
2、增加Fun注解设置函数名称。
3、实现apply及getArgsMap方法。
4、将编译jar拷贝至{jmeterHome}/lib/ext/目录下(可通过使用说明是否自动生成判断新增函数是否自动加载)。
自定义函数示例:

@FunctionExecutor.Fun(name = Const.FUNCTION_APPEND_STRING)
public class AppendString extends BaseFunction {

    public AppendString(String funName) {
        super(funName);
    }

    @Override
    public Object apply(Object... args) throws PocException {
        if (ArrayUtils.isEmpty(args)) {
            return "";
        }

        StringBuilder sb = new StringBuilder();
        for (Object arg : args) {
            sb.append(arg.toString());
        }

        return sb.toString();
    }

    @Override
    public Map<String, Integer> getArgsMap() {
        return Collections.emptyMap();
    }

    @Override
    public String getDocument() {
        StringBuilder sb = new StringBuilder();
        sb.append(this.funName);
        sb.append("(#{str}...)");

        return sb.toString();
    }
}

目前提供多种函数,一般情况下的压力测试基本够用了,这里列出部分:
appendString(#{str}…):字符串拼接
circleCsvData(#{csvFileName}, #{varName}, #{sampleIndex}):顺序转圈获取CSV数据;
getCsvData(#{csvFileName}, #{varName}, #{sampleIndex}):顺序获取CSV数据(如果数据不够抛出异常);
getDeptName(#{sampleIndex}):获取部门中文名称(造相对真实好看的数据用);
getGroupName(#{sampleIndex}):获取群中文名称(造相对真实好看的数据用);
getMobile(#{mobileSegment}, #{sampleIndex}):自增获取手机号;
getDate(#{date}, #{pattern}, #{field}, #{amount}):获取服务指定格式时间字符串(支持按amount进行时间加减);
getTime(#{amount}):获取服务器时间戳(支持按amount进行时间加减);
mathAdd(#{x}, #{y}):加法运算;
mathDivide(#{x}, #{y}):除法运算;
mathMultiply(#{x}, #{y}):乘法运算;
mathSubtract(#{x}, #{y}):减法运算;
modCsvData(#{csvFileName}, #{varName}, #{sampleIndex}, #{mod}):sampleIndex取模获取CSV数据(例如:构建100人群时使用)
randomChineseName(#{firstNameLength}):获取随机中文姓名(造相对真实好看的数据用);
randomCsvData(#{csvFileName}, #{varName}):随机获取CSV数据;
randomMobile(#{mobileSegment}, #{start}, #{end}):随机获取手机号;
leftString(#{content}, #{length}):类似Excel中的Left函数
rightString(#{content}, #{length}):类似Excel中的Right函数

大家可以阅读:cn.bossfriday.jmeter.fuction.impl下源码了解详细实现,这些函数的用途相信从函数名称就跟知道个大概。

5.2 采样断言器

方法和自定函数类似,示例:

@AppSamplerAsserter.Asserter(name = Const.ASSERTER_CODE_MSG_ASSERT)
public class CodeMsgAsserter implements ISamplerAsserter {

    @Override
    public Combo3<Boolean, String, String> isSuccess(String response) {
        if (StringUtils.isEmpty(response)) {
            return new Combo3<>(false, "501", "response is empty");
        }

        ApiResponse<Void> apiResponse = JSON.parseObject(response, new TypeReference<ApiResponse<Void>>() {
        });

        return new Combo3<>(apiResponse.isSuccess(), String.valueOf(apiResponse.getCode()), apiResponse.getMsg());
    }
}

备注:
ApiResponse定义:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {

    private int code;

    private String msg;

    @JSONField(name = "data")
    private T data;

    /**
     * isSuccess
     *
     * @return
     */
    public boolean isSuccess() {
        return CODE_SUCCESS == this.code;
    }
}

6. 使用说明

为了使用方便,在采样器UI的最下面会将自动生成的帮助信息给列出来(毕竟没有功夫折腾UI下的脚本提示,对于前端好多年没有搞过了,现在含有提示功能的编辑器控件应该很多了),这样可以方便的去复制粘贴了:
JMeter 通用 HTTP 自定义采样器_第18张图片

当前发布版本的内容如下:

1. Sample Variable Example:
lang: en
mobile: getMobile(138)

2. URL Example:
localhost:9033/baseCheck
localhost:9033/baseCheck?id=#{id}
localhost:9033/baseCheck?mobile=getMobile(138)

3. Header Example:
content-type: application/json
uid: #{uid}
mobile: getMobile(138)

4. Body Example:
mobile=13810494632&region=86&vCode=966966
mobile=#{mobile}®ion=86&deviceCode=1
mobile=getMobile(138)&region=86&deviceCode=1

5. Name Of Variable / Default Values Example:
uid;appToken;imToken

6. JSON Path Example:
$.data.uid;$.data.appToken;$.data.imToken
#{mobile};$.data.uid;$.data.appToken;$.data.imToken
getMobile(138);$.data.uid;$.data.appToken;$.data.imToken

7. Implemented Functions:
appendString(#{str}...)
circleCsvData(#{csvFileName}, #{varName})
getCsvData(#{csvFileName}, #{varName})
getDate(#{date}, #{pattern}, #{field}, #{amount})    // field:Calendar.XXField; 1:Year; 2:Mon; 5:Day; 10:Hour; 12:Min; 13:Sec;
getDeptName()
getGroupName()
getMobile(#{mobileSegment})
getTime(#{amount})
leftString(#{content}, #{length})
mathAdd(#{x}, #{y})
mathDivide(#{x}, #{y})
mathMultiply(#{x}, #{y})
mathSubtract(#{x}, #{y})
modCsvData(#{csvFileName}, #{varName}, #{mod})
offsetLimitCsvData(#{csvFileName}, #{varName}, #{offset}, #{limit})
randomChineseName(#{firstNameLength})
randomCsvData(#{csvFileName}, #{varName})
randomMobile(#{mobileSegment}, #{start}, #{end})
rightString(#{content}, #{length})

6.1 全局设置(Global Setting)

6.1.1 采样标签(SampleLabel)

对应JMeter采样标签,大家可以理解为给你的用例取一个可读的名字:
JMeter 通用 HTTP 自定义采样器_第19张图片

6.1.2 采样器类型(SamplerType)

目前只提供了一个通用HTTP采样器,如果碰到非HTTP接口压测,或者其他需要,大家可以自己去扩展,方法很简单:继承BaseSampler去实现即可,大家可以参考CommonHttpSampler的实现。采样器自动装载方式如下:

public class AppSamplerBuilder {

    @Documented
    @Target({ElementType.TYPE})
    @Retention(RUNTIME)
    public @interface SamplerType {
        /**
         * 采样器类型名称
         *
         * @return
         */
        String name() default "";

        /**
         * ignore
         *
         * @return
         */
        boolean ignore() default false;
    }

    private static final Logger log = LogManager.getLogger(AppSamplerBuilder.class);
    private static ConcurrentHashMap<String, Class<?>> clazzMap = new ConcurrentHashMap<>();
    private static String[] samplerTypes;

    static {
        // init clazzMap
        Set<Class<? extends BaseSampler>> classes = new Reflections().getSubTypesOf(BaseSampler.class);
        for (Class<?> clazz : classes) {
            SamplerType samplerType = clazz.getAnnotation(SamplerType.class);
            if (samplerType.ignore()) {
                continue;
            }

            if (!clazzMap.containsKey(samplerType.name())) {
                clazzMap.put(samplerType.name(), clazz);
                log.info("load sampler done: {}", samplerType.name());
            } else {
                log.warn("duplicated sampler: {}", samplerType.name());
            }
        }

        // init samplerTypeNames
        samplerTypes = new String[clazzMap.size()];
        int x = 0;
        for (String key : clazzMap.keySet()) {
            samplerTypes[x] = key;
            x++;
        }

        // sort
        int size = samplerTypes.length;
        for (int i = 0; i < size - 1; i++) {
            for (int j = i + 1; j < samplerTypes.length; j++) {
                if (samplerTypes[i].compareTo(samplerTypes[j]) > 0) {
                    String temp = samplerTypes[i];
                    samplerTypes[i] = samplerTypes[j];
                    samplerTypes[j] = temp;
                }
            }
        }
    }

    /**
     * getSampler
     */
    public static BaseSampler getSampler(SamplerSetting config)
            throws PocException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        String samplerTypeName = config.getSamplerType();
        if (!clazzMap.containsKey(config.getSamplerType())) {
            throw new PocException("invalid samplerTypeName!(" + samplerTypeName + ")");
        }

        return (BaseSampler) clazzMap.get(samplerTypeName).getConstructor(SamplerSetting.class).newInstance(config);
    }

    /**
     * getSamplerTypes
     *
     * @return
     */
    public static String[] getSamplerTypes() {
        return samplerTypes;
    }
}

6.1.3 断言类型(AsserterType)

目前就内置了一个断言器,通过阅读:5.2 采样器断言扩展 大家应该就能明白其前后。不过现在很多系统的应答都是基于以下形式,不同的是可能各家的成功码有所差异,那么大家实际使用的时候改下断言逻辑或者扩展自己的断言器都是可以的。

{
  "code": 0,
  "msg": "success",
  "data": {
       xxxxxxx
  }
}

6.1.4 是否Log请求(IsLogRequest)

jmeter.log是否记录请求,目的是辅助问题排查。

6.1.6 是否Log应答(IsLogResponse)

jmeter.log是否记录应答,目的是辅助问题排查。日志处理代码如下(通过阅读下面代码,你会发现如果采样失败,不管你是否配置了IsLogRequest,IsLogResponse,都会日志输出请求及应答的):

 protected String getSampleLog(int sampleIndex, SampleResult result, String url, String header, String body, String response) {
        StringBuilder sb = new StringBuilder();
        if (result.isSuccessful()) {
            sb.append("sample success, sampleIndex=" + sampleIndex + ", time=" + result.getTime());
        } else {
            sb.append("sample failed, sampleIndex=" + sampleIndex + ", time=" + result.getTime());
        }

        if (this.setting.isLogRequest() || !result.isSuccessful()) {
            if (!StringUtils.isEmpty(url)) {
                sb.append(", url=" + url);
            }

            if (!StringUtils.isEmpty(header)) {
                sb.append(", header=" + header.replace('\n', ' '));
            }

            if (!StringUtils.isEmpty(body)) {
                sb.append(", body=" + body.replace('\n', ' '));
            }
        }

        boolean isLogResponse = (this.setting.isLogResponse() || !result.isSuccessful()) && !StringUtils.isEmpty(response);
        if (isLogResponse) {
            sb.append(", response=" + response.replace('\n', ' '));
        }

        return sb.toString();
    }

6.1.7 采样变量设置(Sample Variable)

从帮助示例可以看到,采样变量支持常量和函数:

lang: en
mobile: getMobile(138, #{sampleIndex})

6.2 请求设置(Request Setting)

6.2.1 URL

从帮助示例可以看到,URL支持常量、变量、函数:

localhost:9033/baseCheck
localhost:9033/baseCheck?id=#{id}
localhost:9033/baseCheck?mobile=getMobile(138, #{sampleIndex})

6.2.2 Header

从帮助示例可以看到,Header支持常量、变量、函数:

content-type: application/json
uid: #{sampleIndex}
mobile: getMobile(138, #{sampleIndex})

6.2.3 Body

从帮助示例可以看到,Body支持常量、变量、函数:

mobile=13810494632&region=86&vCode=966966
mobile=#{mobile}®ion=86&deviceCode=1
mobile=getMobile(138, #{sampleIndex})®ion=86&deviceCode=1

6.2.4 预置内部变量

目前就一个#{sampleIndex},直接使用即可,无需显式声明;

6.3 应答提取设置(Response JSON Extracto Setting)

6.3.1 应答提取器变量名和默认值

从帮助示例可以看到,应答提取器变量名和默认值都是用分号切分:

uid;appToken;imToken

6.3.2 应答提取器JsonPath

从帮助示例可以看到,应答提取器JsonPath为标准JsonPath写法和采样变量及函数支持:

$.data.uid;$.data.appToken;$.data.imToken
#{mobile};$.data.uid;$.data.appToken;$.data.imToken
getMobile(138, #{sampleIndex});$.data.uid;$.data.appToken;$.data.imToken

7. 使用局限性

7.1 采样变量申明无序性处理带来的问题 目前已经支持采样变量定义及援引的有序性

由于对于采样变量的处理是用的无序Map处理(后续有空改为有序Map解决该问题),因此下面的用法不支持(会出错)

--采样变量
now: getTs()
end: mathAdd(#{now}, 3700000)

--Header
192.168.100.62:9030/calendar/v1/event/#{now}/#{end}

解决方案:不声明变量

--采样变量
now: getTs()

--Header
192.168.100.62:9030/calendar/v1/event/#{now}/mathAdd(#{now}, 3700000)

7.2 脚本处理极度简化带来的问题

由于工具开发时间较短(设计,开发时间一共不到10天),脚本处理部分极度简化(300行代码左右)。后续有时间再考虑替换为比较健壮的脚本引擎方式(考虑:分词,语法,四元组,执行器方式的脚本处理引擎),可以参考:遵循编译原理主要过程实现“打印1+1结果”:https://blog.csdn.net/camelials/article/details/123415475
下面的用法目前不支持(函数及参数嵌套):

--采样变量
end: mathAdd(getTs(), 3700000)

--Header
192.168.100.62:9030/calendar/v1/event/getTs()/#{end}

8. 源码地址及结束语

8.1 源码地址及结束语

1、源码地址:https://github.com/bossfriday/bossfriday-jmeter
2、源码经过了sonar和阿里规约的扫描,大家阅读起来应该还好。想吐槽的是标准的doubleCheck单例写法sonar扫描不过,因此我也就不改成其他的实现方式了,算是对它的抗争:
JMeter 通用 HTTP 自定义采样器_第20张图片
3、由于初版的开发时间较短,这玩意前后搞了9天(包括设计、开发、自测试),因此脚本表达式部分只能先极度的简化处理(300行代码吧),之前搞过一个低代码平台,里面的表达式处理那是十分强大,不过要适配至Jmeter里还是需要静下心来花费一定的时间,这方面大家有兴趣可以看下我低代码专栏里的相关文章。后续可能考虑替换表达引擎,不过现在看来,这非常精简的表达式处理也好像够用了,基本上也能达到预期。

短时间闭门造车出来的东西,各方面难免不周,欢迎指正/交流。
原创不易,写文档不易,国庆原创写文章不易,动动小手,点赞+关注,大家的支持和关注是我原创和分享的最大动力。

附录

附录 - JMeter压测报告术语

1 Apdex

全称 Application Performance Index,是由 Apdex 联盟开放的用于评估应用性能的工业标准。Apdex 联盟起源于 2004 年,由 PeterSevcik发起。Apdex 标准从用户的角度出发,将对应用响应时间的表现,转为用户对于应用性能的可量化为范围为 0-1 的满意度评价。
T:满意阈值,小于或等于该值,表示满意。
F:失败阈值,大于或等于该值,表示不满意。
JMeter 通用 HTTP 自定义采样器_第21张图片

2 Throughput

吞吐量是指系统在单位时间内处理请求的数量。对于无并发的应用系统而言,吞吐量与响应时间成严格的反比关系,实际上此时吞吐量就是响应时间的倒数。前面已经说过,对于单用户的系统,响应时间(或者系统响应时间和应用延迟时间)可以很好地度量系统的性能,但对于并发系统,通常需要用吞吐量作为性能指标。
并发用户数是指系统可以同时承载的正常使用系统功能的用户的数量。与吞吐量相比,并发用户数是一个更直观但也更笼统的性能指标。现实中很难模拟出在同一时刻(同一毫秒)发出大量的请求(线程越多CPU线程切换损失也越大)。
最佳打压线程数,任何系统的任何接口一定存在一个吞吐量上限,也可以称之为冒烟点。一般情况下任何系统的任何接口打压线程与吞吐量关系为:

  • 开始阶段:
    随着打压线程的增加,吞吐量随之增加,平均延时基本保持稳定。
  • 冒烟阶段:
    随着打压线程的增加,吞吐量基本保持稳定,平均延时随之增加。理想情况下最佳的测试结果为:吞吐量达到最大,平均延时为保障吞吐量冒烟的前提下最小。
  • 高压阶段:
    随着打压线程的增加,吞吐量逐步下降,平均延时基本保持稳定或者逐步增大。随着压力或者压测持续时间的增加,测试结果还可能表现出采样失败率逐步升高。

3 Requests Summary

所有Request的成功比例,OK表示成功,KO表示不成功。
JMeter 通用 HTTP 自定义采样器_第22张图片

4 Statistics

数据分析。Samples:采样数量。KO:失败数量。Error:失败率。Average:平均耗时。Min:最小耗时。Max:最长耗时。90th/95th/99thpct:90%、95%、99%的线程耗时。Throughput:每秒钟发送的请求数量。
在这里插入图片描述

5 Response Time Over Time

随时间变化,每个时间节点上的线程平均响应时间。
JMeter 通用 HTTP 自定义采样器_第23张图片

6 ResponseTime Percentiles Over Time

随时间变化,每个时间节点上的最长/最短/90%/95%/99%的线程响应时间
JMeter 通用 HTTP 自定义采样器_第24张图片

7 Active Threads Over Time

随时间变化,每个时间节点上的活动线程数。
JMeter 通用 HTTP 自定义采样器_第25张图片

8 LatenciesOver Time

随时间变化,每个时间节点上的平均响应延时。
JMeter 通用 HTTP 自定义采样器_第26张图片

9 HitsPer Second (excluding embedded resources)

每秒钟向服务器发送的请求数量。
JMeter 通用 HTTP 自定义采样器_第27张图片

10 CodesPer Second (excluding embedded resources)

每秒钟服务器返回的ResponseCode数量。
JMeter 通用 HTTP 自定义采样器_第28张图片

11 TransactionsPer Second

服务器每秒钟处理的事务数量。
JMeter 通用 HTTP 自定义采样器_第29张图片

12 ResponseTime Vs Request

每秒发送多少个请求时,所对应的平均响应时间。
JMeter 通用 HTTP 自定义采样器_第30张图片

13 LatencyVs Request

每秒发送多少个请求时,所对应的平均延时。
JMeter 通用 HTTP 自定义采样器_第31张图片

14 ResponseTime Percentiles

响应时间与百分比的对应关系,即有百分之多少的线程花费了某一响应时间。
JMeter 通用 HTTP 自定义采样器_第32张图片

15 ResponseTime Overview

小于T,大于T小于F,大于F的线程数各有多少。JMeter 通用 HTTP 自定义采样器_第33张图片

16 Time Vs Threads

N个活动线程情况下的平均响应时间。
JMeter 通用 HTTP 自定义采样器_第34张图片

17 Response TimeDistribution

在某一响应时间段内的线程响应数量。
JMeter 通用 HTTP 自定义采样器_第35张图片

你可能感兴趣的:(低代码,其他,jmeter,http,网络协议)