diff --git a/.github/workflows/featureBranch.yml b/.github/workflows/featureBranch.yml index cf00506..2f27566 100644 --- a/.github/workflows/featureBranch.yml +++ b/.github/workflows/featureBranch.yml @@ -4,10 +4,6 @@ on: push: branches-ignore: - 'master' - pull_request: - branches-ignore: - - 'master' - types: [opened, synchronize, reopened] jobs: build: @@ -37,6 +33,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew build sonarqube --info + run: ./gradlew build jacocoTestReport sonarqube --info diff --git a/.gitignore b/.gitignore index 9a6338e..160955a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .gradle/* .idea/* -gradle/* build/* +logs/* \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0edc751..68aad0b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,23 +1,51 @@ -group 'com.shortandprecise.loadgenerator' -version '1.0-SNAPSHOT' +plugins { + id 'java' + id 'jacoco' + id 'org.sonarqube' version '3.0' +} -apply plugin: 'java' -apply plugin: 'application' +group 'com.shortandprecise' +version '1.0-SNAPSHOT' -sourceCompatibility = 1.8 +sourceCompatibility = '11' +targetCompatibility = '11' repositories { mavenCentral() } dependencies { - compile 'org.asynchttpclient:async-http-client:2.0.32' - testCompile group: 'junit', name: 'junit', version: '4.12' + implementation 'org.asynchttpclient:async-http-client:2.12.1' + implementation 'ch.qos.logback:logback-classic:1.2.3' + implementation 'net.logstash.logback:logstash-logback-encoder:6.4' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.3' + implementation 'org.apache.commons:commons-lang3:3.11' + + testImplementation 'org.mockito:mockito-core:3.5.15' + testCompile 'org.junit.jupiter:junit-jupiter:5.7.0' + testCompile 'org.eclipse.jetty:jetty-server:9.4.33.v20201020' +} + +test { + useJUnitPlatform() } -run { - if (project.hasProperty("appArgs")) { - args Eval.me(appArgs) +jacocoTestReport { + reports { + xml.enabled true + html.enabled false + csv.enabled false + xml.destination file("$buildDir/jacoco/test/jacocoTestReport.xml") + } +} + +sonarqube { + properties { + property "sonar.projectKey", "rejuan_LoadGenerator" + property "sonar.organization", "rejuan" + property "sonar.host.url", "https://sonarcloud.io" + property "sonar.jacoco.reportPath", "$buildDir/jacoco/test.exec" + property "sonar.coverage.jacoco.xmlReportPaths", "$buildDir/jacoco/test/jacocoTestReport.xml" } } @@ -27,6 +55,4 @@ jar { 'Main-Class': 'com.shortandprecise.App' ) } -} - -mainClassName = 'com.shortandprecise.App' \ No newline at end of file +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..62d4c05 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..622ab64 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4453cce..2fe81a7 100755 --- a/gradlew +++ b/gradlew @@ -1,5 +1,21 @@ #!/usr/bin/env sh +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + ############################################################################## ## ## Gradle start up script for UN*X @@ -28,16 +44,16 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -109,8 +125,8 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` @@ -138,35 +154,30 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args -save ( ) { +save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } -APP_ARGS=$(save "$@") +APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d..24467a1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome diff --git a/schema/schema.json b/schema/schema.json new file mode 100644 index 0000000..07588f9 --- /dev/null +++ b/schema/schema.json @@ -0,0 +1,22 @@ +{ + "baseUrl": "http://localhost:8081/", + "requests": [ + { + "url": "mockGet", + "method": "GET", + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + } + }, + { + "url": "mockPost", + "method": "POST", + "body": "{\"key\":\"value\"}", + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + } + } + ] +} \ No newline at end of file diff --git a/src/main/java/com/shortandprecise/App.java b/src/main/java/com/shortandprecise/App.java deleted file mode 100644 index 76d5abe..0000000 --- a/src/main/java/com/shortandprecise/App.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.shortandprecise; - - -/** - * Created by rejuan on 6/12/17. - */ -public class App { - - public static void main(String[] args) { - if (args.length == 2) { - String url = args[0]; - int numberOfClient = 100; - try { - numberOfClient = Integer.parseInt(args[1]); - } catch (Exception ex) { - System.out.println("Invalid number of client."); - System.out.println("Starting with default 100"); - } - - new Thread(new ClientGenerator(url, numberOfClient)).start(); - - } else { - System.out.println("Set URL and number of client as argument respectively."); - } - } -} diff --git a/src/main/java/com/shortandprecise/ClientGenerator.java b/src/main/java/com/shortandprecise/ClientGenerator.java deleted file mode 100644 index 380c602..0000000 --- a/src/main/java/com/shortandprecise/ClientGenerator.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.shortandprecise; - -import org.asynchttpclient.AsyncCompletionHandler; -import org.asynchttpclient.AsyncHttpClient; -import org.asynchttpclient.DefaultAsyncHttpClient; -import org.asynchttpclient.Response; - -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Created by rejuan on 6/12/17. - */ -public class ClientGenerator implements Runnable { - - private Thread clientGenerator; - private AsyncHttpClient asyncHttpClient; - private AtomicInteger tracker; - private int numberOfClient; - private String url; - - public ClientGenerator(String url, int numberOfClient) { - this.asyncHttpClient = new DefaultAsyncHttpClient(); - this.tracker = new AtomicInteger(0); - this.numberOfClient = numberOfClient; - this.url = url; - } - - @Override - public void run() { - - clientGenerator = Thread.currentThread(); - System.out.println("URL: " + url); - System.out.println("Number of client: " + numberOfClient); - - while (true) { - while (tracker.get() < numberOfClient) { - request(); - tracker.incrementAndGet(); - } - - try { - Thread.sleep(100); - } catch (Exception ex) { - } - } - } - - private void request() { - - asyncHttpClient.prepareGet(url).execute(new AsyncCompletionHandler() { - - @Override - public Response onCompleted(Response response) throws Exception { - tracker.decrementAndGet(); - clientGenerator.interrupt(); - return response; - } - - @Override - public void onThrowable(Throwable t) { - tracker.decrementAndGet(); - clientGenerator.interrupt(); - } - }); - } -} diff --git a/src/main/java/com/shortandprecise/loadgenerator/App.java b/src/main/java/com/shortandprecise/loadgenerator/App.java new file mode 100644 index 0000000..0e07d36 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/App.java @@ -0,0 +1,51 @@ +package com.shortandprecise.loadgenerator; + +import com.shortandprecise.loadgenerator.config.PropertyConfig; +import com.shortandprecise.loadgenerator.config.SchemaConfig; +import com.shortandprecise.loadgenerator.process.LoadRunner; +import com.shortandprecise.loadgenerator.process.Statistics; +import com.shortandprecise.loadgenerator.process.StatisticsTask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Starting point of the application + * + * @author rejuan + */ +public class App { + + private static final Logger LOGGER = LoggerFactory.getLogger(App.class); + + public static void main(String[] args) { + + PropertyConfig propertyConfig = null; + try { + propertyConfig = new PropertyConfig(); + } catch (Exception ex) { + LOGGER.error("Property loading problem", ex); + System.exit(0); + } + + SchemaConfig schemaConfig = null; + try { + schemaConfig = new SchemaConfig(propertyConfig); + } catch (Exception ex) { + LOGGER.error("Schema loading problem", ex); + System.exit(0); + } + + Statistics statistics = new Statistics(); + StatisticsTask statisticsTask = new StatisticsTask(statistics, propertyConfig); + + new Thread(new LoadRunner(propertyConfig, schemaConfig, statistics)).start(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + executor.scheduleAtFixedRate(statisticsTask, propertyConfig.getQpsCountPeriod(), + propertyConfig.getQpsCountPeriod(), TimeUnit.SECONDS); + + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/config/PropertyConfig.java b/src/main/java/com/shortandprecise/loadgenerator/config/PropertyConfig.java new file mode 100644 index 0000000..f3b3122 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/config/PropertyConfig.java @@ -0,0 +1,48 @@ +package com.shortandprecise.loadgenerator.config; + +import com.shortandprecise.loadgenerator.constant.Constant; + +/** + * Load property from system + */ +public class PropertyConfig { + + private static final String MAX_QPS_SHIELD_DEFAULT_VALUE = "100"; + private static final String NUMBER_OF_CLIENT_DEFAULT = "10"; + private static final String QPS_COUNT_PERIOD_IN_SECOND_DEFAULT = "10"; + + private int maxQpsShield; + private int numberOfClient; + private int qpsCountPeriod; + private String schemaPath; + + public PropertyConfig() { + loadProperty(); + } + + private void loadProperty() { + maxQpsShield = Integer.parseInt( + System.getProperty(Constant.MAX_QPS_SHIELD_PROPERTY_STRING, MAX_QPS_SHIELD_DEFAULT_VALUE)); + numberOfClient = + Integer.parseInt(System.getProperty(Constant.NUMBER_OF_CLIENT_PROPERTY_STRING, NUMBER_OF_CLIENT_DEFAULT)); + qpsCountPeriod = + Integer.parseInt(System.getProperty(Constant.QPS_COUNT_PERIOD_IN_SECOND_PROPERTY_STRING, QPS_COUNT_PERIOD_IN_SECOND_DEFAULT)); + schemaPath = System.getProperty(Constant.SCHEMA_PATH_STRING); + } + + public int getMaxQpsShield() { + return maxQpsShield; + } + + public int getNumberOfClient() { + return numberOfClient; + } + + public int getQpsCountPeriod() { + return qpsCountPeriod; + } + + public String getSchemaPath() { + return schemaPath; + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/config/SchemaConfig.java b/src/main/java/com/shortandprecise/loadgenerator/config/SchemaConfig.java new file mode 100644 index 0000000..982fceb --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/config/SchemaConfig.java @@ -0,0 +1,86 @@ +package com.shortandprecise.loadgenerator.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shortandprecise.loadgenerator.exception.SchemaValidationException; +import com.shortandprecise.loadgenerator.model.HttpMethod; +import com.shortandprecise.loadgenerator.model.Request; +import com.shortandprecise.loadgenerator.model.Schema; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Load schema from file + */ +public class SchemaConfig { + + private Schema schema; + private final PropertyConfig propertyConfig; + private static final String BASE_URL = "baseUrl"; + private static final String REQUESTS = "requests"; + private static final String URL = "url"; + private static final String METHOD = "method"; + private static final String HEADERS = "headers"; + private static final String BODY = "body"; + + public SchemaConfig(PropertyConfig propertyConfig) throws IOException { + this.propertyConfig = propertyConfig; + loadSchema(); + } + + private void loadSchema() throws IOException { + Map schemaMap; + File file = new File(propertyConfig.getSchemaPath()); + schemaMap = new ObjectMapper().readValue(file, Map.class); + schema = prepareSchema(schemaMap); + } + + /** + * Convert schema map to {@link Schema} object + * + * @param schemaMap Schema map + * @return Schema + */ + private Schema prepareSchema(Map schemaMap) { + List requestList = new ArrayList<>(); + String baseUrl = (String) schemaMap.get(BASE_URL); + List> requests = (List>) schemaMap.get(REQUESTS); + requests.forEach(requestMap -> { + String url = baseUrl + requestMap.get(URL); + String method = (String) requestMap.get(METHOD); + String body = (String) requestMap.get(BODY); + HttpHeaders httpHeaders = getHttpHeaders(requestMap); + + requestList.add(new Request(url, getHttpMethod(method), httpHeaders, body)); + }); + + return new Schema(requestList); + } + + private HttpHeaders getHttpHeaders(Map requestMap) { + Map headers = (Map) requestMap.get(HEADERS); + + HttpHeaders httpHeaders = new DefaultHttpHeaders(); + headers.forEach(httpHeaders::add); + return httpHeaders; + } + + private HttpMethod getHttpMethod(String method) { + HttpMethod httpMethod = HttpMethod.get(method); + if(Objects.isNull(httpMethod)) { + throw new SchemaValidationException("Invalid http method in schema"); + } else { + return httpMethod; + } + } + + public Schema getSchema() { + return schema; + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/constant/Constant.java b/src/main/java/com/shortandprecise/loadgenerator/constant/Constant.java new file mode 100644 index 0000000..4c665aa --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/constant/Constant.java @@ -0,0 +1,15 @@ +package com.shortandprecise.loadgenerator.constant; + +public class Constant { + + public static final String MAX_QPS_SHIELD_PROPERTY_STRING = "max.qps.shield"; + + public static final String NUMBER_OF_CLIENT_PROPERTY_STRING = "number.of.client"; + + public static final String QPS_COUNT_PERIOD_IN_SECOND_PROPERTY_STRING = "qps.count.period.inSecond"; + + public static final String SCHEMA_PATH_STRING = "schema.path"; + + private Constant() { + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/exception/SchemaValidationException.java b/src/main/java/com/shortandprecise/loadgenerator/exception/SchemaValidationException.java new file mode 100644 index 0000000..ecec309 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/exception/SchemaValidationException.java @@ -0,0 +1,11 @@ +package com.shortandprecise.loadgenerator.exception; + +/** + * Schema validation exception + */ +public class SchemaValidationException extends RuntimeException { + + public SchemaValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/model/HttpMethod.java b/src/main/java/com/shortandprecise/loadgenerator/model/HttpMethod.java new file mode 100644 index 0000000..2289f7a --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/model/HttpMethod.java @@ -0,0 +1,40 @@ +package com.shortandprecise.loadgenerator.model; + +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Http method enum + */ +public enum HttpMethod { + + GET("get"), + POST("post"); + + private final String method; + + HttpMethod(String method) { + this.method = method; + } + + public String getMethod() { + return method; + } + + private static final Map httpMethodMap = new HashMap<>(); + + static { + for (HttpMethod httpMethod : HttpMethod.values()) { + httpMethodMap.put(httpMethod.getMethod(), httpMethod); + } + } + + public static HttpMethod get(String method) { + if(StringUtils.isBlank(method)) { + return null; + } + return httpMethodMap.get(method.toLowerCase()); + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/model/Request.java b/src/main/java/com/shortandprecise/loadgenerator/model/Request.java new file mode 100644 index 0000000..8b2d1ac --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/model/Request.java @@ -0,0 +1,53 @@ +package com.shortandprecise.loadgenerator.model; + +import io.netty.handler.codec.http.HttpHeaders; + +/** + * Request model + */ +public class Request { + + private String url; + private HttpMethod httpMethod; + private HttpHeaders headers; + private String body; + + public Request(String url, HttpMethod httpMethod, HttpHeaders headers, String body) { + this.url = url; + this.httpMethod = httpMethod; + this.headers = headers; + this.body = body; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public HttpMethod getHttpMethod() { + return httpMethod; + } + + public void setHttpMethod(HttpMethod httpMethod) { + this.httpMethod = httpMethod; + } + + public HttpHeaders getHeaders() { + return headers; + } + + public void setHeaders(HttpHeaders headers) { + this.headers = headers; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/model/Schema.java b/src/main/java/com/shortandprecise/loadgenerator/model/Schema.java new file mode 100644 index 0000000..44b620b --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/model/Schema.java @@ -0,0 +1,22 @@ +package com.shortandprecise.loadgenerator.model; + +import java.util.List; + +/** + * LoadGenerator Schema + */ +public class Schema { + private List requests; + + public Schema(List requests) { + this.requests = requests; + } + + public List getRequests() { + return requests; + } + + public void setRequests(List requests) { + this.requests = requests; + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/process/Client.java b/src/main/java/com/shortandprecise/loadgenerator/process/Client.java new file mode 100644 index 0000000..feff1cc --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/process/Client.java @@ -0,0 +1,61 @@ +package com.shortandprecise.loadgenerator.process; + +import com.shortandprecise.loadgenerator.model.HttpMethod; +import io.netty.handler.codec.http.HttpHeaders; +import org.apache.commons.lang3.StringUtils; +import org.asynchttpclient.*; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This class makes HTTP request and send stat to Statistics class + * + * @author rejuan + */ +public class Client { + + private final AsyncHttpClient asyncHttpClient; + private final Statistics statistics; + + public Client(AsyncHttpClient asyncHttpClient, Statistics statistics) { + this.asyncHttpClient = asyncHttpClient; + this.statistics = statistics; + } + + /** + * Make a GET request to provided URL. Send given header as part of request + * + * @param url Target URL + * @param httpMethod {@link HttpMethod} + * @param httpHeaders {@link HttpHeaders} + * @param body Request body + * @return ListenableFuture + */ + public ListenableFuture request(String url, HttpMethod httpMethod, HttpHeaders httpHeaders, + String body, Thread loadRunner, AtomicInteger requestTracker) { + long startTime = System.currentTimeMillis(); + BoundRequestBuilder requestBuilder = asyncHttpClient.prepare(httpMethod.getMethod(), url) + .setHeaders(httpHeaders); + if (StringUtils.isNotBlank(body)) { + requestBuilder.setBody(body); + } + + return requestBuilder.execute(new AsyncCompletionHandler<>() { + + @Override + public Response onCompleted(Response response) { + requestTracker.decrementAndGet(); + statistics.storeSuccessStat((System.currentTimeMillis() - startTime)); + loadRunner.interrupt(); + return response; + } + + @Override + public void onThrowable(Throwable t) { + requestTracker.decrementAndGet(); + statistics.storeFailureStat((System.currentTimeMillis() - startTime)); + loadRunner.interrupt(); + } + }); + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/process/LoadRunner.java b/src/main/java/com/shortandprecise/loadgenerator/process/LoadRunner.java new file mode 100644 index 0000000..31bca20 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/process/LoadRunner.java @@ -0,0 +1,66 @@ +package com.shortandprecise.loadgenerator.process; + +import com.shortandprecise.loadgenerator.config.PropertyConfig; +import com.shortandprecise.loadgenerator.config.SchemaConfig; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This class is the brain of this project. It generates load. + * + * @author rejuan + */ +public class LoadRunner implements Runnable { + + private final PropertyConfig propertyConfig; + private final SchemaConfig schemaConfig; + private final AtomicInteger requestTracker; + private final Statistics statistics; + private volatile boolean isRunning; + + public LoadRunner(PropertyConfig propertyConfig, SchemaConfig schemaConfig, Statistics statistics) { + this.requestTracker = new AtomicInteger(0); + this.propertyConfig = propertyConfig; + this.schemaConfig = schemaConfig; + this.statistics = statistics; + this.isRunning = true; + } + + @Override + public void run() { + Thread loadRunner = Thread.currentThread(); + RequestFacade requestFacade = new RequestFacade(schemaConfig, statistics); + + while (isRunning) { + while (requestTracker.get() < propertyConfig.getNumberOfClient()) { + requestTracker.incrementAndGet(); + requestFacade.request(loadRunner, requestTracker); + qpsCircuit(); + } + + //TODO need to add proper comment + sleep(50); + } + } + + private void qpsCircuit() { + long totalRequest = statistics.getSuccessfulRequest() + statistics.getFailureRequest(); + if((totalRequest / propertyConfig.getQpsCountPeriod()) > propertyConfig.getMaxQpsShield()) { + //TODO need to add proper comment + sleep(100); + } + } + + private void sleep(long timeInMillis) { + try { + Thread.sleep(timeInMillis); + } catch (Exception ignored) { + // No need to log this exception because when a request completed + // then this sleeping will be interrupted to make another call immediately + } + } + + public void shutdown() { + isRunning = false; + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/process/RequestFacade.java b/src/main/java/com/shortandprecise/loadgenerator/process/RequestFacade.java new file mode 100644 index 0000000..8f98fea --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/process/RequestFacade.java @@ -0,0 +1,41 @@ +package com.shortandprecise.loadgenerator.process; + +import com.shortandprecise.loadgenerator.config.SchemaConfig; +import com.shortandprecise.loadgenerator.model.Request; +import com.shortandprecise.loadgenerator.model.Schema; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.DefaultAsyncHttpClient; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This class select url and send request through {@link Client} class + * + * @author rejuan + */ +public class RequestFacade { + + private final Schema schema; + private final Client client; + private int index = 0; + private final int numberOfUrl; + private final AsyncHttpClient asyncHttpClient; + + public RequestFacade(SchemaConfig schemaConfig, Statistics statistics) { + this.schema = schemaConfig.getSchema(); + this.asyncHttpClient = new DefaultAsyncHttpClient(); + this.client = new Client(this.asyncHttpClient, statistics); + this.numberOfUrl = schema.getRequests().size(); + } + + public void request(Thread loadRunner, AtomicInteger requestTracker) { + Request request = getRequestObject(); + client.request(request.getUrl(), request.getHttpMethod(), request.getHeaders(), request.getBody(), + loadRunner, requestTracker); + } + + private Request getRequestObject() { + index = ((index + 1) % numberOfUrl); + return schema.getRequests().get(index); + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/process/Statistics.java b/src/main/java/com/shortandprecise/loadgenerator/process/Statistics.java new file mode 100644 index 0000000..5498ae5 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/process/Statistics.java @@ -0,0 +1,105 @@ +package com.shortandprecise.loadgenerator.process; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * The class handle request-response related statistics + * + * @author rejuan + */ +public class Statistics { + + private final AtomicLong totalResponseTime; + private final AtomicLong successfulRequest; + private final AtomicLong failureRequest; + private final AtomicLong maxResponseTime; + + public Statistics() { + this.totalResponseTime = new AtomicLong(0); + this.successfulRequest = new AtomicLong(0); + this.failureRequest = new AtomicLong(0); + this.maxResponseTime = new AtomicLong(0); + } + + /** + * Store successful request stat + * + * @param responseTime Response time in long + */ + public void storeSuccessStat(long responseTime) { + successfulRequest.incrementAndGet(); + totalResponseTime.addAndGet(responseTime); + updateMaxResponseTime(responseTime); + } + + /** + * Store failure request stat + * + * @param responseTime Response time in long + */ + public void storeFailureStat(long responseTime) { + failureRequest.incrementAndGet(); + totalResponseTime.addAndGet(responseTime); + updateMaxResponseTime(responseTime); + } + + private void updateMaxResponseTime(long responseTime) { + if (responseTime > maxResponseTime.get()) { + maxResponseTime.set(responseTime); + } + } + + /** + * Provide total response time + * + * @return long + */ + public long resetAndGetTotalResponseTime() { + return totalResponseTime.getAndSet(0); + } + + /** + * Provide total successful request number + * + * @return long + */ + public long resetAndGetSuccessfulRequest() { + return successfulRequest.getAndSet(0); + } + + /** + * Provide total failure request number + * + * @return long + */ + public long resetAndGetFailureRequest() { + return failureRequest.getAndSet(0); + } + + /** + * Provide total successful request number + * + * @return long + */ + public long getSuccessfulRequest() { + return successfulRequest.get(); + } + + /** + * Provide total failure request number + * + * @return long + */ + public long getFailureRequest() { + return failureRequest.get(); + } + + /** + * Provide max response time + * + * @return long + */ + public long resetAndGetMaxResponseTime() { + return maxResponseTime.getAndSet(0); + } +} diff --git a/src/main/java/com/shortandprecise/loadgenerator/process/StatisticsTask.java b/src/main/java/com/shortandprecise/loadgenerator/process/StatisticsTask.java new file mode 100644 index 0000000..1771f76 --- /dev/null +++ b/src/main/java/com/shortandprecise/loadgenerator/process/StatisticsTask.java @@ -0,0 +1,35 @@ +package com.shortandprecise.loadgenerator.process; + +import com.shortandprecise.loadgenerator.config.PropertyConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Write statistics to log + */ +public class StatisticsTask implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(StatisticsTask.class); + private final Statistics statistics; + private final PropertyConfig propertyConfig; + + public StatisticsTask(Statistics statistics, PropertyConfig propertyConfig) { + this.statistics = statistics; + this.propertyConfig = propertyConfig; + } + + @Override + public void run() { + long successfulRequest = statistics.resetAndGetSuccessfulRequest(); + long failureRequest = statistics.resetAndGetFailureRequest(); + long totalResponseTime = statistics.resetAndGetTotalResponseTime(); + long maxResponseTime = statistics.resetAndGetMaxResponseTime(); + + long averageResponseTime = (totalResponseTime / (successfulRequest + failureRequest)); + long successQps = (successfulRequest / propertyConfig.getQpsCountPeriod()); + long failureQps = (failureRequest / propertyConfig.getQpsCountPeriod()); + + LOGGER.info("Max response time: {} ## Avg response time: {} ## Success QPS: {} ## Failure QPS: {}", + maxResponseTime, averageResponseTime, successQps, failureQps); + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..a0868eb --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,31 @@ + + + + + + + ${HOME_LOG} + + + logs/archived/app.%d{yyyy-MM-dd}.%i.log + + 10MB + + 1GB + + 10 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/shortandprecise/loadgenerator/config/SchemaConfigTest.java b/src/test/java/com/shortandprecise/loadgenerator/config/SchemaConfigTest.java new file mode 100644 index 0000000..17cac31 --- /dev/null +++ b/src/test/java/com/shortandprecise/loadgenerator/config/SchemaConfigTest.java @@ -0,0 +1,63 @@ +package com.shortandprecise.loadgenerator.config; + +import com.shortandprecise.loadgenerator.exception.SchemaValidationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class SchemaConfigTest { + + private String resourcePath = "src/test/resources"; + + @Mock + PropertyConfig propertyConfig; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() { + propertyConfig = null; + } + + @Test + void testGetSchema() throws IOException { + //When + when(propertyConfig.getSchemaPath()).thenReturn(resourcePath + "/schema/schema.json"); + + //Then + SchemaConfig schemaConfig = new SchemaConfig(propertyConfig); + + //Verify + verify(propertyConfig, times(1)).getSchemaPath(); + assertEquals(2, schemaConfig.getSchema().getRequests().size()); + } + + @Test + void testWrongMethod() { + //When + when(propertyConfig.getSchemaPath()).thenReturn(resourcePath + "/schema/wrongMethodSchema.json"); + + //Then+Verify + assertThrows(SchemaValidationException.class, () -> new SchemaConfig(propertyConfig)); + } + + @Test + void testWrongFilePath() { + //When + when(propertyConfig.getSchemaPath()).thenReturn(resourcePath + "/schema/wrongFile.json"); + + //Then+Verify + assertThrows(IOException.class, () -> new SchemaConfig(propertyConfig)); + } +} \ No newline at end of file diff --git a/src/test/java/com/shortandprecise/loadgenerator/process/ClientTest.java b/src/test/java/com/shortandprecise/loadgenerator/process/ClientTest.java new file mode 100644 index 0000000..b9a13a9 --- /dev/null +++ b/src/test/java/com/shortandprecise/loadgenerator/process/ClientTest.java @@ -0,0 +1,68 @@ +package com.shortandprecise.loadgenerator.process; + +import com.shortandprecise.loadgenerator.model.HttpMethod; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.DefaultAsyncHttpClient; +import org.asynchttpclient.ListenableFuture; +import org.asynchttpclient.Response; +import org.eclipse.jetty.server.Server; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class ClientTest { + + private final AsyncHttpClient asyncHttpClient = new DefaultAsyncHttpClient(); + private AtomicInteger requestTracker; + private Thread clientGenerator; + private final int port = ThreadLocalRandom.current().nextInt(10000, 60000); + private Client client; + private Server server; + + @BeforeEach + void setUp() throws Exception { + this.clientGenerator = new Thread(); + this.requestTracker = new AtomicInteger(1); + this.server = new Server(port); + this.server.start(); + this.client = new Client(asyncHttpClient, new Statistics()); + } + + @AfterEach + void tearDown() throws Exception { + this.server.stop(); + this.server = null; + this.client = null; + } + + @Test + void testRequestSuccess() throws ExecutionException, InterruptedException { + String url = "http://localhost:" + port; + String body = "[1,2]"; + ListenableFuture request = client.request(url, HttpMethod.POST, null, body, clientGenerator, requestTracker); + request.get(); + + assertEquals(0, requestTracker.get()); + } + + @Test + void testRequestFailure() { + try { + String url = "http://localhost:80000/"; + String body = "[1,2]"; + ListenableFuture request = client.request(url, HttpMethod.POST, null, body, clientGenerator, requestTracker); + request.get(); + } catch (Exception ex) { + // Expected exception to verify failure case + } + + assertEquals(0, requestTracker.get()); + } + +} \ No newline at end of file diff --git a/src/test/resources/schema/schema.json b/src/test/resources/schema/schema.json new file mode 100644 index 0000000..07588f9 --- /dev/null +++ b/src/test/resources/schema/schema.json @@ -0,0 +1,22 @@ +{ + "baseUrl": "http://localhost:8081/", + "requests": [ + { + "url": "mockGet", + "method": "GET", + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + } + }, + { + "url": "mockPost", + "method": "POST", + "body": "{\"key\":\"value\"}", + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/schema/wrongMethodSchema.json b/src/test/resources/schema/wrongMethodSchema.json new file mode 100644 index 0000000..b4dd51f --- /dev/null +++ b/src/test/resources/schema/wrongMethodSchema.json @@ -0,0 +1,13 @@ +{ + "baseUrl": "http://localhost:8081/", + "requests": [ + { + "url": "mockGet", + "method": "WRONG", + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + } + } + ] +} \ No newline at end of file