關於 junit4 90% 的人都不知道的特性,詳解 junitperf 的實現原理

  • 2021 年 7 月 24 日
  • 筆記

前言

上一節介紹了 //github.com/houbb/junitperf 的入門使用。

這一節我們從源碼的角度,剖析一下其實現方式。

性能測試該怎麼做?

Junit Rules

junit4 小夥伴們肯定不陌生,那麼 junit rules 你聽過說過嗎?

junit4-struct

要想基於 junit4 實現一個性能測試框架,最核心的一點在於理解 Junit Rules。

官方文檔://github.com/junit-team/junit4/wiki/Rules

Rules 作用

規則允許非常靈活地添加或重新定義測試類中每個測試方法的行為。

測試人員可以重用或擴展下面提供的規則之一,或者編寫自己的規則。

自定義規則

ps: 下面的內容來自官方的例子。

大多數自定義規則可以作為 ExternalResource 規則的擴展來實現。

但是,如果您需要有關所討論的測試類或方法的更多資訊,則需要實現 TestRule 介面。

import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

public class IdentityRule implements TestRule {
  @Override
  public Statement apply(final Statement base, final Description description) {
    return base;
  }
}

當然,實現 TestRule 的強大功能來自使用自定義構造函數的組合、向類添加方法以用於測試,以及將提供的 Statement 包裝在新的 Statement 中。

例如,考慮以下為每個測試提供命名記錄器的測試規則:

package org.example.junit;

import java.util.logging.Logger;

import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

public class TestLogger implements TestRule {
  private Logger logger;

  public Logger getLogger() {
    return this.logger;
  }

  @Override
  public Statement apply(final Statement base, final Description description) {
    return new Statement() {
      @Override
      public void evaluate() throws Throwable {
        logger = Logger.getLogger(description.getTestClass().getName() + '.' + description.getDisplayName());
        base.evaluate();
      }
    };
  }
}

然後這個規則就可以按照下面的方式使用:

import java.util.logging.Logger;

import org.example.junit.TestLogger;
import org.junit.Rule;
import org.junit.Test;

public class MyLoggerTest {

  @Rule
  public final TestLogger logger = new TestLogger();

  @Test
  public void checkOutMyLogger() {
    final Logger log = logger.getLogger();
    log.warn("Your test is showing!");
  }

}

定義和使用

看了上面的例子,我們發現 junit4 中的自定義規則還是比較簡單的。

定義方式:實現 TestRule 介面

使用方式;使用 @Rule 放在創建的內部屬性上。

是不是很簡單呢?

好了你已經學會 1+1=2 了,下面讓我們來學習一下泰勒展開吧。

算數入門

性能測試演算法流程

如何統計一個方法的執行耗時呢?

相信你一定不會陌生,只需要在方法執行開始前和結束後各統計一個時間,然後差值就是耗時。

如何模擬多個執行緒調用呢?

使用 java 多執行緒執行進行模擬即可。

如何生成報告文件呢?

把上述統計的各個維度數據,結合生成對應的 html 等文件即可。

我們將要做的事情,就是把上面的點綜合起來,然後結合 Junit4 Rules 實現即可。

聽起來也不難不是嗎?

下面,讓我們來一起看一看實現源碼吧。

Rule 的入門

入門例子

我們首先看一個 junit4 的入門例子:

public class HelloWorldTest {

    @Rule
    public JunitPerfRule junitPerfRule = new JunitPerfRule();

    /**
     * 單一執行緒,執行 1000ms,默認以 html 輸出測試結果
     * @throws InterruptedException if any
     */
    @Test
    @JunitPerfConfig(duration = 1000)
    public void helloWorldTest() throws InterruptedException {
        System.out.println("hello world");
        Thread.sleep(20);
    }

}

JunitPerfRule 就是我們前面提及的自定義規則。

JunitPerfRule

實現如下:

public class JunitPerfRule implements TestRule {

    //region private fields
    // 省略內部變數
    //endregion

    @Override
    public Statement apply(Statement statement, Description description) {
        Statement activeStatement = statement;
        JunitPerfConfig junitPerfConfig = description.getAnnotation(JunitPerfConfig.class);
        JunitPerfRequire junitPerfRequire = description.getAnnotation(JunitPerfRequire.class);

        if (ObjectUtil.isNotNull(junitPerfConfig)) {
            // Group test contexts by test class
            ACTIVE_CONTEXTS.putIfAbsent(description.getTestClass(), new HashSet<EvaluationContext>());

            EvaluationContext evaluationContext = new EvaluationContext(description.getMethodName(), DateUtil.getSimpleDateStr());
            evaluationContext.loadConfig(junitPerfConfig);
            evaluationContext.loadRequire(junitPerfRequire);
            ACTIVE_CONTEXTS.get(description.getTestClass()).add(evaluationContext);

            activeStatement = new PerformanceEvaluationStatement(evaluationContext,
                    statement,
                    statisticsCalculator,
                    reporterSet,
                    ACTIVE_CONTEXTS.get(description.getTestClass()),
                    description.getTestClass()
            );
        }

        return activeStatement;
    }

}

主要流程就是執行方法的時候,首先獲取方法上的 @JunitPerfConfig@JunitPerfRequire 註解資訊,然後進行對應的執行統計。

Statement

Statement 是 junit4 中執行最核心的一個對象。

可以發現,這裡根據註解資訊,對這個實現重寫為 PerformanceEvaluationStatement。

PerformanceEvaluationStatement 的核心實現如下:

/**
 * 性能測試 statement
 * @author 老馬嘯西風
 * @see com.github.houbb.junitperf.core.rule.JunitPerfRule 用於此規則
 */
public class PerformanceEvaluationStatement extends Statement {

    // 省略內部變數

    @Override
    public void evaluate() throws Throwable {
        List<PerformanceEvaluationTask> taskList = new LinkedList<>();

        try {
            EvaluationConfig evaluationConfig = evaluationContext.getEvaluationConfig();
            
            // 根據註解配置,創建對應的執行執行緒數
            for(int i = 0; i < evaluationConfig.getConfigThreads(); i++) {
                // 初始化執行任務
                PerformanceEvaluationTask task = new PerformanceEvaluationTask(evaluationConfig.getConfigWarmUp(),
                        statement, statisticsCalculator);
                Thread t = FACTORY.newThread(task);
                taskList.add(task);
                // 子執行緒執行任務
                t.start();
            }

            //主執行緒沉睡等待
            Thread.sleep(evaluationConfig.getConfigDuration());
        } finally {
            //具體詳情,當執行打斷時,被打斷的任務可能已經開始執行(尚未執行完),會出現主執行緒往下走,被打斷的執行緒也在繼續走的情況
            for(PerformanceEvaluationTask task : taskList) {
                task.setContinue(false);    //終止執行的任務
            }
        }

        // 更新統計資訊
        evaluationContext.setStatisticsCalculator(statisticsCalculator);
        evaluationContext.runValidation();

        generateReportor();
    }

    /**
     * 報告生成
     */
    private synchronized void generateReportor() {
        for(Reporter reporter : reporterSet) {
            reporter.report(testClass, evaluationContextSet);
        }
    }

}

這裡是最核心的實現部分,主流程如下:

(1)根據配置,創建對應的任務子執行緒

(2)根據配置,初始化子任務,並且執行

(3)主執行緒進行沉睡等待

(4)主執行緒沉睡結束,打斷子執行緒自行,更新統計資訊

(5)根據統計資訊,生成對應的測試報告文件

PerformanceEvaluationTask

子任務的實現也值得注意,核心實現如下:

public class PerformanceEvaluationTask implements Runnable {

    /**
     * 熱身時間
     */
    private long warmUpNs;

    /**
     * junit statement
     */
    private final Statement statement;

    /**
     * 統計計算者
     */
    private StatisticsCalculator statisticsCalculator;

    /**
     * 是否繼續標誌位
     */
    private volatile boolean isContinue;

    public PerformanceEvaluationTask(long warmUpNs, Statement statement, StatisticsCalculator statisticsCalculator) {
        this.warmUpNs = warmUpNs;
        this.statement = statement;
        this.statisticsCalculator = statisticsCalculator;
        this.isContinue = true; //默認創建時繼續執行
    }

    @Override
    public void run() {
        long startTimeNs = System.nanoTime();
        long startMeasurements = startTimeNs + warmUpNs;
        while (isContinue) {
            evaluateStatement(startMeasurements);
        }
    }

    /**
     * 執行校驗
     * @param startMeasurements 開始時間
     */
    private void evaluateStatement(long startMeasurements) {
        //0. 如果繼續執行為 false,退出執行。
        if(!isContinue) {
            return;
        }

        //1. 準備階段
        if (nanoTime() < startMeasurements) {
            try {
                statement.evaluate();
            } catch (Throwable throwable) {
                // IGNORE
            }
        } else {
            long startTimeNs = nanoTime();
            try {
                statement.evaluate();
                statisticsCalculator.addLatencyMeasurement(getCostTimeNs(startTimeNs));
                statisticsCalculator.incrementEvaluationCount();
            } catch (InterruptedException e) { // NOSONAR
                // IGNORE - no metrics
            } catch (Throwable throwable) {
                statisticsCalculator.incrementEvaluationCount();
                statisticsCalculator.incrementErrorCount();
                statisticsCalculator.addLatencyMeasurement(getCostTimeNs(startTimeNs));
            }
        }
    }

    /**
     * 獲取消耗的時間(單位:毫秒)
     * @param startTimeNs 開始時間
     * @return 消耗的時間
     */
    private long getCostTimeNs(long startTimeNs) {
        long currentTimeNs = System.nanoTime();
        return currentTimeNs - startTimeNs;
    }

    //region getter & setter
    public boolean isContinue() {
        return isContinue;
    }

    public void setContinue(boolean aContinue) {
        isContinue = aContinue;
    }
    //endregion
}

這個任務,主要負責統計任務的耗時。

統計對應的成功數量、異常數量等。

通過 volatile 定義的 isContinue 變數,便於在主執行緒沉睡結束後,終止循環。

ps: 這裡還是可以發現一個問題,如果 statement.evaluate(); 已經開始執行了,那麼無法被中斷。這是一個可以改進的地方。

小結

本篇從 junit rules 講起,分析了整個性能測試工具的實現原理。

總的來說,實現思路並不是很難,所有複雜的應用,都是有簡單的部分組成

文中為了便於大家理解,對源碼部分做了大量簡化。

如果想獲取完整的源碼,請前往開源地址://github.com/houbb/junitperf

我是老馬,期待與你的下次重逢。

當然,也許你可以發現這種方式還是不夠優雅,junit5 為我們提供了更加強大的功能,我們下一節將講解 junit5 的實現方式。

參考資料

//github.com/houbb/junitperf

//github.com/junit-team/junit4/wiki/Rules