Compare commits

...

22 Commits
v0.0.2 ... main

Author SHA1 Message Date
Mark Nellemann 49ca2c186c Update README.md 2024-05-17 07:53:49 +00:00
Mark Nellemann 3deb336a46 Merge branch 'time'
continuous-integration/drone/push Build is passing Details
2023-07-18 15:24:45 +02:00
Mark Nellemann b757541053 Merge branch 'main' of git.data.coop:nellemann/jnetperf 2023-07-18 15:22:19 +02:00
Mark Nellemann 5b7fec6033 Add some tests of conversion and client-server comm.
continuous-integration/drone/push Build is passing Details
2023-07-18 15:21:52 +02:00
Mark Nellemann df69b2e65c Merge pull request 'New option to specify how long test should run.' (#2) from time into main
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #2
2023-07-14 09:19:21 +00:00
Mark Nellemann 8d6c7f8140 Remove print statement.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2023-07-14 11:18:44 +02:00
Mark Nellemann df951d6808 Merge branch 'main' into time
# Conflicts:
#	gradle.properties
#	src/main/java/biz/nellemann/jnetperf/Application.java
#	src/main/java/biz/nellemann/jnetperf/TcpServer.java
2023-07-14 11:18:22 +02:00
Mark Nellemann 0ca08be582 New option to specify how long test should run.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is failing Details
2023-07-14 11:15:39 +02:00
Mark Nellemann 77b7984517 Wait indefinitely on server for connections.
continuous-integration/drone/push Build is passing Details
2023-07-14 08:15:15 +02:00
Mark Nellemann 9f67f98fec Merge branch 'main' of git.data.coop:nellemann/jnetperf 2023-07-14 07:42:40 +02:00
Mark Nellemann 660996a133 Merge pull request 'Suppoet for TCP' (#1) from tcp into main
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #1
2023-07-14 05:42:11 +00:00
Mark Nellemann 7f4a5d28ac Support k,m,g suffix and some cleanup.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2023-07-14 07:40:34 +02:00
Mark Nellemann 87c8c1f56e Initial support for TCP packets.
continuous-integration/drone/push Build is passing Details
2023-07-13 21:59:38 +02:00
Mark Nellemann 0062763439 Merge branch 'main' of git.data.coop:nellemann/jnetperf 2023-07-13 11:29:24 +02:00
Mark Nellemann 5f521322c7 Fix download link in README
continuous-integration/drone/push Build is passing Details
2023-07-05 16:06:40 +02:00
Mark Nellemann c59fd3c216 Update README with examples.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-07-05 15:52:45 +02:00
Mark Nellemann b95c5ca115 Rename package and app. 2023-06-29 20:47:58 +02:00
Mark Nellemann 7431ce836e Logging and output cleanup.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-06-26 22:16:53 +02:00
Mark Nellemann 759c336de2 Merge branch 'main' of git.data.coop:nellemann/jperf 2023-06-26 15:29:30 +02:00
Mark Nellemann c4869be014 Cleanup
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-06-26 12:56:46 +02:00
Mark Nellemann 0f5ee37933 Make client/server options mutually exclusive.
continuous-integration/drone/push Build is passing Details
2023-06-26 09:37:07 +02:00
Mark Nellemann dd011c9f36 Fix main class after rename.
continuous-integration/drone/push Build is passing Details
2023-06-24 16:33:34 +02:00
27 changed files with 1274 additions and 567 deletions

View File

@ -1,31 +1,3 @@
# jPerf
# Repository moved
Small utility to measure network performance.
## Requirements
You need Java (JRE) version 11 or later to run jperf.
## Usage Instructions
- Install the jperf package (*.deb*, *.rpm* or *.jar*) from [downloads](https://bitbucket.org/mnellemann/jperf/downloads/) or compile from source.
- Run **/opt/jperf/bin/jperf**, if installed from package
- Or as **java -jar /path/to/jperf.jar**
To change the temporary directory where disk-load files are written to, use the *-Djava.io.tmpdir=/mytempdir* option.
```shell
Usage: ...
```
## Development Information
You need Java (JDK) version 11 or later to build jperf.
### Build & Test
Use the gradle build tool, which will download all required dependencies:
```shell
./gradlew clean build run
```
Please visit [github.com/mnellemann/jnetperf](https://github.com/mnellemann/jnetperf)

View File

@ -22,7 +22,6 @@ dependencies {
implementation 'ch.qos.logback:logback-classic:1.3.8'
}
// Apply a specific Java toolchain to ease working on different environments.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(8)
@ -33,12 +32,10 @@ java {
application {
// Define the main class for the application.
mainClass = 'biz.nellemann.jperf.App'
mainClass = 'biz.nellemann.jnetperf.Application'
}
tasks.named('test') {
// Use JUnit Platform for unit tests.
useJUnitPlatform()
}
@ -62,12 +59,12 @@ jar {
apply plugin: 'com.netflix.nebula.ospackage'
ospackage {
packageName = 'jperf'
packageName = 'jnetperf'
release = '1'
user = 'root'
packager = "Mark Nellemann <mark.nellemann@gmail.com>"
into '/opt/jperf'
into '/opt/jnetperf'
from(shadowJar.outputs.files) {
into 'lib'

View File

@ -1,3 +1,3 @@
projectId = jperf
projectGroup = biz.nellemann.jperf
projectVersion = 0.0.2
projectId = jnetperf
projectGroup = biz.nellemann.jnetperf
projectVersion = 0.0.8

View File

@ -1,15 +1,5 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
*
* Detailed information about configuring a multi-project build in Gradle can be found
* in the user manual at https://docs.gradle.org/8.1.1/userguide/multi_project_builds.html
*/
plugins {
// Apply the foojay-resolver plugin to allow automatic download of JDKs
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.4.0'
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.5.0'
}
rootProject.name = 'jperf'
rootProject.name = 'jnetperf'

View File

@ -0,0 +1,119 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import java.io.IOException;
import java.util.Locale;
import java.util.concurrent.Callable;
@Command(name = "jnetperf", mixinStandardHelpOptions = true,
versionProvider = VersionProvider.class,
description = "For more information visit https://git.data.coop/nellemann/jnetperf")
public class Application implements Callable<Integer> {
@CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
RunMode runMode;
static class RunMode {
@CommandLine.Option(names = { "-c", "--connect" }, required = true, description = "Connect to remote server (client).", paramLabel = "SRV")
String remoteServer;
@CommandLine.Option(names = { "-s", "--server" }, required = true, description = "Run server and wait for client (server).")
boolean runServer = false;
}
@CommandLine.Option(names = { "-l", "--pkt-len" }, paramLabel = "NUM", description = "Packet size in bytes (client) [default: ${DEFAULT-VALUE}].", converter = UnitSuffixConverter.class)
int packetSize = Payload.DEFAULT_LENGTH;
@CommandLine.Option(names = { "-n", "--pkt-num" }, paramLabel = "NUM", description = "Number of packets to send (client) [default: ${DEFAULT-VALUE}].", converter = UnitSuffixConverter.class)
int packetCount = 150_000;
@CommandLine.Option(names = { "-t", "--runtime" }, paramLabel = "SEC", description = "Time to run, precedes pkt-num (client) [default: ${DEFAULT-VALUE}].", converter = TimeSuffixConverter.class)
int timeInSeconds = 0;
@CommandLine.Option(names = { "-p", "--port" }, paramLabel = "NUM", description = "Network port [default: ${DEFAULT-VALUE}].")
int port = 4445;
@CommandLine.Option(names = { "-u", "--udp" }, description = "Use UDP network protocol [default: ${DEFAULT-VALUE}].")
boolean useUdp = false;
@Override
public Integer call() {
// Set locale to en_US to ensure correct/identical number formatting
Locale.setDefault(new Locale("en", "US"));
try {
if (runMode.runServer) {
runServer();
} else if (runMode.remoteServer != null) {
runClient(runMode.remoteServer);
}
} catch (IOException | InterruptedException e) {
System.err.println(e.getMessage());
}
return 0;
}
public static void main(String... args) {
int exitCode = new CommandLine(new Application()).execute(args);
System.exit(exitCode);
}
private void runClient(String remoteHost) throws InterruptedException, IOException {
if(packetSize < Payload.MIN_LENGTH) {
packetSize = Payload.MIN_LENGTH;
}
if(useUdp) {
if(packetSize > Payload.MAX_UDP_LENGTH) {
packetSize = Payload.MAX_UDP_LENGTH;
}
UdpClient udpClient = new UdpClient(remoteHost, port, packetSize, packetCount, timeInSeconds);
udpClient.start();
} else {
TcpClient tcpClient = new TcpClient(remoteHost, port, packetSize, packetCount, timeInSeconds);
tcpClient.start();
}
}
private void runServer() throws IOException, InterruptedException {
if(useUdp) {
UdpServer udpServer = new UdpServer(port);
udpServer.start();
udpServer.join();
} else {
TcpServer tcpServer = new TcpServer(port);
tcpServer.start();
tcpServer.join();
}
}
}

View File

@ -0,0 +1,138 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
*
* Datagram consists of the following
* <p>
* <------------------------- HEADER 32 bytes --------------> <---------- DATA bytes min 32, max 65475 -------->
* _long _int _int _long _long
* 8_bytes 4_bytes 4_bytes 8_bytes 8_bytes
* MAGIC-ID TYPE LENGTH CUR_PKT MAX_PKT
*
*/
public class Payload {
public final static int MIN_LENGTH = 64;
public final static int MAX_UDP_LENGTH = 64000;
public final static int DEFAULT_LENGTH = 1432;
public final static int HEADER_LENGTH = 32;
private final byte[] MAGIC_ID = "jPerfTok".getBytes(StandardCharsets.UTF_8); // Must be 8-bytes
private final int type;
private final int length;
private final long curPkt;
private final long maxPkt;
private final byte[] data;
/**
* Create new empty datagram
* @param type
* @param length
* @param currentPkt
*/
public Payload(int type, int length, long currentPkt, long maxPkt) {
this.type = type;
this.curPkt = currentPkt;
this.maxPkt = maxPkt;
this.length = length;
if (type == PayloadType.HANDSHAKE.getValue()) {
data = new byte[DEFAULT_LENGTH - HEADER_LENGTH];
} else {
data = new byte[length - HEADER_LENGTH];
}
}
/**
* Assemble datagram from byte[] payload
* @param payload
*/
public Payload(byte[] payload) {
this(ByteBuffer.wrap(payload));
}
/**
* Assemble datagram from ByteBuffer payload
* @param payload
*/
public Payload(ByteBuffer payload) {
byte[] id = new byte[8];
payload.get(id);
if(!Arrays.equals(id, MAGIC_ID)) {
System.out.println(Arrays.toString(id));
System.out.println(Arrays.toString(MAGIC_ID));
throw new RuntimeException("Datagram magic ID does not match: " + MAGIC_ID);
}
// Order is importent when assembling header fields like this
type = payload.getInt();
length = payload.getInt();
curPkt = payload.getLong();
maxPkt = payload.getLong();
data = new byte[payload.limit() - payload.position()];
payload.get(data);
}
public int getLength() {
return length;
}
public byte[] getPayload() {
ByteBuffer buffer = ByteBuffer.allocate(data.length + HEADER_LENGTH);
// Order is important
buffer.put(MAGIC_ID);
buffer.putInt(type);
buffer.putInt(length);
buffer.putLong(curPkt);
buffer.putLong(maxPkt);
buffer.put(data);
return buffer.array();
}
public int getType() {
return type;
}
public long getCurPkt() {
return curPkt;
}
public long getMaxPkt() {
return maxPkt;
}
}

View File

@ -0,0 +1,32 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
public enum PayloadType {
HANDSHAKE(1), DATA(2), ACK(4), END(9);
private final int value;
private PayloadType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}

View File

@ -0,0 +1,163 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class Statistics {
private final int MAX_TICKS_AVG = 300;
private final int LOG_AVG_MODULO = 30;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
private long packetsTransferred;
private long packetsTransferredTotal = 0;
private long bytesTransferred, bytesTransferredTotal = 0;
private long bytesPerSec;
private long packetsPerSec;
private long packetsUnacked = 0;
private int tickIterations = 0;
private int tickTotal = 0;
private final long[] bytesPerSecAvgTmp = new long[MAX_TICKS_AVG];
private final long[] packetsPerSecAvgTmp = new long[MAX_TICKS_AVG];
private Instant timestamp1 = Instant.now();
public void reset() {
timestamp1 = Instant.now();
}
public void tick() {
Instant timestamp2 = Instant.now();
if(Duration.between(timestamp1, timestamp2).toMillis() >= 1000) {
// Because we do this every second ...
bytesPerSec = bytesTransferred;
packetsPerSec = packetsTransferred;
bytesPerSecAvgTmp[tickIterations] = bytesTransferred;
packetsPerSecAvgTmp[tickIterations] = packetsTransferred;
timestamp1 = timestamp2;
printStatus();
bytesTransferred = 0;
packetsTransferred = 0;
if(++tickIterations >= MAX_TICKS_AVG) {
tickIterations = 0;
}
if(tickIterations % LOG_AVG_MODULO == 0) {
printAverage();
}
tickTotal++;
}
}
public void printStatus() {
System.out.printf("%-19s - Status: %10d pkt/s %14d B/s %12d KB/s %8d MB/s\n", formatter.format(Instant.now()), packetsPerSec, bytesPerSec, bytesPerSec/1_000, bytesPerSec/1_000_000);
}
public void printSummary() {
System.out.println();
System.out.printf("%-19s - Summary: %10d pkts %14d B %12d KB %8d MB %8.2f GB\n", formatter.format(Instant.now()), packetsTransferredTotal, bytesTransferredTotal, bytesTransferredTotal /1_000, bytesTransferredTotal /1_000_000, (double) bytesTransferredTotal/1_000_000_000);
}
public void printAverage() {
long bytesPerSecAvg = getAverage(bytesPerSecAvgTmp, bytesTransferred);
long packetsPerSecAvg = getAverage(packetsPerSecAvgTmp, packetsTransferred);
System.out.printf("%-19s - Average: %10d pkt/s %14d B/s %12d KB/s %8d MB/s %8.2f GB/s\n", formatter.format(Instant.now()), packetsPerSecAvg, bytesPerSecAvg, bytesPerSecAvg /1_000, bytesPerSecAvg /1_000_000, (double) bytesPerSecAvg /1_000_000_000);
}
public void ack() {
packetsUnacked--;
}
public void transferPacket() {
packetsUnacked++;
packetsTransferred++;
packetsTransferredTotal++;
}
public void transferBytes(long bytes) {
bytesTransferred += bytes;
bytesTransferredTotal += bytes;
}
public long getPacketsUnacked() {
return packetsUnacked;
}
public long getPacketsTransferredTotal() {
return packetsTransferredTotal;
}
public long getBytesTransferredTotal() {
return bytesTransferredTotal;
}
public int getRuntime() {
return tickTotal;
}
private long getAverage(long[] array, long fallback) {
long avg = getAverage(array);
if(avg < 1) {
return fallback;
}
return avg;
}
private long getAverage(long[] array) {
if(array.length <= 1) {
return 0;
}
int len = 0;
long sum = 0;
for (long l : array) {
if(l > 0) {
sum += l;
len++;
}
}
long avg = 0;
if(len > 0) {
avg = sum / len;
}
return avg;
}
}

View File

@ -0,0 +1,130 @@
package biz.nellemann.jnetperf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.*;
import java.util.concurrent.atomic.AtomicBoolean;
public class TcpClient {
final Logger log = LoggerFactory.getLogger(TcpClient.class);
private final Statistics statistics;
private DataOutputStream out;
private DataInputStream in;
private final int port;
private final InetAddress address;
private Socket socket;
private final byte[] inBuffer = new byte[Payload.DEFAULT_LENGTH];
private final int packets;
private final int length;
private final int runtime;
public TcpClient(String hostname, int port, int length, int packets, int runtime) throws IOException {
log.info("TcpClient() - target: {}, port: {}", hostname, port);
this.port = port;
this.length = length;
this.packets = packets;
this.runtime = runtime;
address = InetAddress.getByName(hostname);
statistics = new Statistics();
}
private void send(Payload payload) throws IOException {
out.write(payload.getPayload());
statistics.transferPacket();
statistics.transferBytes(payload.getLength());
}
private Payload receive() throws IOException {
in.readFully(inBuffer);
return new Payload(inBuffer);
}
private void close() throws IOException {
in.close();
out.close();
socket.close();
}
public void start() throws IOException, InterruptedException {
AtomicBoolean keepRunning = new AtomicBoolean(true);
Thread shutdownHook = new Thread(() -> {
keepRunning.set(false);
System.out.println("Stopping jnetperf, please wait ...");
});
Runtime.getRuntime().addShutdownHook(shutdownHook);
long sequence = 0;
socket = new Socket(address, port);
in = new DataInputStream(socket.getInputStream());
out = new DataOutputStream(socket.getOutputStream());
// Send handshake
Payload payload = new Payload(PayloadType.HANDSHAKE.getValue(), length, sequence++, packets);
send(payload);
payload = receive();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
// Send data
do {
payload = new Payload(PayloadType.DATA.getValue(), length, sequence++, packets);
send(payload);
payload = receive();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
}
statistics.tick();
if (sequence > packets) {
System.out.println("Max packets reached");
keepRunning.set(false);;
}
if(runtime > 0 && statistics.getRuntime() > runtime) {
System.out.println("Max runtime reached");
keepRunning.set(false);
}
} while (keepRunning.get());
// Send end
payload = new Payload(PayloadType.END.getValue(), length, sequence++, packets);
send(payload);
payload = receive();
statistics.ack();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
Thread.sleep(100);
close();
statistics.printAverage();
statistics.printSummary();
}
public Statistics getStatistics() {
return statistics;
}
}

View File

@ -0,0 +1,108 @@
package biz.nellemann.jnetperf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class TcpServer extends Thread {
final Logger log = LoggerFactory.getLogger(TcpServer.class);
private final int port;
private ServerSocket socket;
private DataInputStream in;
private DataOutputStream out;
private byte[] inBuffer;
private boolean runThread = true;
private boolean runSession = true;
public TcpServer(int port) throws IOException {
log.info("TcpServer()");
this.port = port;
}
public void run() {
try {
while (runThread) {
socket = new ServerSocket(port);
socket.setSoTimeout(0); // Wait indefinitely
inBuffer = new byte[Payload.DEFAULT_LENGTH];
session();
socket.close();
}
} catch(IOException e) {
log.error(e.getMessage());
}
}
public void session() throws IOException {
Statistics statistics = new Statistics();
boolean ackEnd = false;
runSession = true;
Socket server = socket.accept();
InetAddress address = socket.getInetAddress();
in = new DataInputStream(server.getInputStream());
out = new DataOutputStream(server.getOutputStream());
while (runSession) {
Payload payload = receive();
statistics.transferPacket();
statistics.transferBytes(payload.getLength());
if(payload.getType() == PayloadType.HANDSHAKE.getValue()) {
log.info("Handshake from ... {}", address);
// Setup to receive larger datagrams
inBuffer = new byte[payload.getLength()];
statistics.reset();
}
if(payload.getType() == PayloadType.END.getValue()) {
ackEnd = true;
}
// Send ACK
Payload responsePayload = new Payload(PayloadType.ACK.getValue(), Payload.DEFAULT_LENGTH, payload.getCurPkt(), 1);
out.write(responsePayload.getPayload());
statistics.ack();
statistics.tick();
if(ackEnd) {
runSession = false;
statistics.printAverage();
statistics.printSummary();
}
}
in.close();
out.close();
server.close();
}
private Payload receive() throws IOException {
in.readFully(inBuffer);
return new Payload(inBuffer);
}
public void finish() {
runThread = false;
runSession = false;
}
}

View File

@ -0,0 +1,41 @@
package biz.nellemann.jnetperf;
import picocli.CommandLine;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class TimeSuffixConverter implements CommandLine.ITypeConverter<Integer> {
final private Pattern pattern = Pattern.compile("(\\d+)([smh])?", Pattern.CASE_INSENSITIVE);
public Integer convert(String value) {
int seconds = 0;
Matcher matcher = pattern.matcher(value);
if (matcher.find()) {
int number = Integer.parseInt(matcher.group(1));
if(matcher.group(2) != null) { // We got the second, minute or hour suffix
String suffix = matcher.group(2);
switch (suffix.toLowerCase(Locale.ROOT)) {
case "s":
seconds = number;
break;
case "m":
seconds = number * 60;
break;
case "h":
seconds = number * 60 * 60;
break;
default:
throw new IllegalArgumentException("Unknown suffix: " + suffix);
}
} else {
seconds = number;
}
}
return seconds;
}
}

View File

@ -0,0 +1,141 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UdpClient {
final Logger log = LoggerFactory.getLogger(UdpClient.class);
private final Statistics statistics;
private final int port;
private final InetAddress address;
private final DatagramSocket socket;
private final byte[] inBuffer = new byte[Payload.DEFAULT_LENGTH];
private final int length;
private final int packets;
private final int runtime;
public UdpClient(String hostname, int port, int length, int packets, int runtime) throws UnknownHostException, SocketException {
log.info("UdpClient() - target: {}, port: {}", hostname, port);
this.port = port;
this.length = length;
this.packets = packets;
this.runtime = runtime;
socket = new DatagramSocket();
address = InetAddress.getByName(hostname);
statistics = new Statistics();
}
private void send(Payload payload) throws IOException {
DatagramPacket packet = new DatagramPacket(payload.getPayload(), payload.getLength(), address, port);
socket.send(packet);
statistics.transferPacket();
statistics.transferBytes(payload.getLength());
}
private Payload receive() throws IOException {
DatagramPacket packet = new DatagramPacket(inBuffer, Payload.DEFAULT_LENGTH);
socket.receive(packet);
return new Payload(inBuffer);
}
private void close() {
socket.close();
}
public void start() throws IOException, InterruptedException {
AtomicBoolean keepRunning = new AtomicBoolean(true);
Thread shutdownHook = new Thread(() -> {
keepRunning.set(false);
System.out.println("Stopping jnetperf, please wait ...");
});
Runtime.getRuntime().addShutdownHook(shutdownHook);
long sequence = 0;
// Send handshake
Payload payload = new Payload(PayloadType.HANDSHAKE.getValue(), Payload.DEFAULT_LENGTH, sequence++, packets);
send(payload);
payload = receive();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
// Send data
do {
payload = new Payload(PayloadType.DATA.getValue(), length, sequence++, packets);
send(payload);
payload = receive();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
}
statistics.tick();
if (sequence > packets) {
System.out.println("Max packets reached");
keepRunning.set(false);
}
if(runtime > 0 && statistics.getRuntime() > runtime) {
System.out.println("Max runtime reached");
keepRunning.set(false);
}
} while (keepRunning.get());
// Send end
payload = new Payload(PayloadType.END.getValue(), length, sequence++, packets);
send(payload);
payload = receive();
statistics.ack();
if(payload.getType() != PayloadType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
Thread.sleep(100);
close();
statistics.printAverage();
statistics.printSummary();
}
public Statistics getStatistics() {
return statistics;
}
}

View File

@ -0,0 +1,114 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UdpServer extends Thread {
final Logger log = LoggerFactory.getLogger(UdpServer.class);
private final int port;
private DatagramSocket socket;
private byte[] inBuffer;
private boolean runThread = true;
private boolean runSession = true;
public UdpServer(int port) {
log.info("UdpServer()");
this.port = port;
}
public void run() {
try {
while (runThread) {
inBuffer = new byte[Payload.DEFAULT_LENGTH];
socket = new DatagramSocket(port);
session();
socket.close();
}
} catch(IOException e) {
log.error(e.getMessage());
}
}
public void session() throws IOException {
Statistics statistics = new Statistics();
boolean ackEnd = false;
runSession = true;
while (runSession) {
DatagramPacket packet = new DatagramPacket(inBuffer, inBuffer.length);
socket.receive(packet);
InetAddress address = packet.getAddress();
int port = packet.getPort();
Payload payload = new Payload(packet.getData());
statistics.transferPacket();
statistics.transferBytes(payload.getLength());
if(payload.getType() == PayloadType.HANDSHAKE.getValue()) {
log.info("Handshake from ... {}", address);
// Setup to receive larger datagrams
inBuffer = new byte[payload.getLength()];
statistics.reset();
}
if(payload.getType() == PayloadType.END.getValue()) {
ackEnd = true;
}
// Send ACK
Payload responsePayload = new Payload(PayloadType.ACK.getValue(), Payload.DEFAULT_LENGTH, payload.getCurPkt(), 1);
packet = new DatagramPacket(responsePayload.getPayload(), responsePayload.getLength(), address, port);
socket.send(packet);
statistics.ack();
statistics.tick();
if(ackEnd) {
runSession = false;
statistics.printAverage();
statistics.printSummary();
}
}
}
public void finish() {
runThread = false;
runSession = false;
}
}

View File

@ -0,0 +1,42 @@
package biz.nellemann.jnetperf;
import picocli.CommandLine;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UnitSuffixConverter implements CommandLine.ITypeConverter<Long> {
final private Pattern pattern = Pattern.compile("(\\d+)([kmg])?b?", Pattern.CASE_INSENSITIVE);
public Long convert(String value) {
long bytes = 0L;
Matcher matcher = pattern.matcher(value);
if (matcher.find()) {
long number = Long.parseLong(matcher.group(1));
if(matcher.group(2) != null) { // We got the kilo, mega og giga suffix
String suffix = matcher.group(2);
switch (suffix.toLowerCase(Locale.ROOT)) {
case "k":
bytes = number * 1024;
break;
case "m":
bytes = number * 1024 * 1024;
break;
case "g":
bytes = number * 1024 * 1024 * 1024;
break;
default:
throw new IllegalArgumentException("Unknown suffix: " + suffix);
}
} else {
bytes = number;
}
}
return bytes;
}
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2023 mark.nellemann@gmail.com
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
http://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.
*/
package biz.nellemann.jnetperf;
import picocli.CommandLine;
import java.io.IOException;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
class VersionProvider implements CommandLine.IVersionProvider {
public String[] getVersion() throws IOException {
Manifest manifest = new Manifest(getClass().getResourceAsStream("/META-INF/MANIFEST.MF"));
Attributes attrs = manifest.getMainAttributes();
return new String[] {
"${COMMAND-FULL-NAME} " + attrs.getValue("Build-Version") + " (on ${os.name} ${os.version} ${os.arch})",
"JVM: ${java.version} (${java.vendor} ${java.vm.name} ${java.vm.version})" };
}
}

View File

@ -1,75 +0,0 @@
/*
* This Java source file was generated by the Gradle 'init' task.
*/
package biz.nellemann.jperf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import java.io.IOException;
import java.net.SocketException;
import java.util.concurrent.Callable;
@Command(name = "jperf", mixinStandardHelpOptions = true, version = "0.1",
description = "Network performance measurement tool.")
public class Application implements Callable<Integer> {
final Logger log = LoggerFactory.getLogger(Application.class);
@CommandLine.Option(names = { "-c", "--connect" }, paramLabel = "SERVER", description = "run client and connect to remote server")
String remoteServer;
@CommandLine.Option(names = { "-s", "--server" }, description = "run server and wait for client")
boolean runServer = false;
@CommandLine.Option(names = { "-l", "--pkt-size" }, paramLabel = "SIZE", description = "datagram size in bytes, max 65507 [default: ${DEFAULT-VALUE}]")
//int packetSize = 16384; // Min: 256 Max: 65507
int packetSize = 65507; // Min: 256 Max: 65507
@CommandLine.Option(names = { "-n", "--pkt-num" }, paramLabel = "NUM", description = "number of packets to send [default: ${DEFAULT-VALUE}]")
int packetCount = 5000;
@CommandLine.Option(names = { "-p", "--port" }, paramLabel = "PORT", description = "network port [default: ${DEFAULT-VALUE}]")
int port = 4445;
@Override
public Integer call() throws Exception { // your business logic goes here...
if(runServer) {
runServer();
}
if(remoteServer != null) {
runClient(remoteServer);
}
return 0;
}
public static void main(String... args) {
int exitCode = new CommandLine(new Application()).execute(args);
System.exit(exitCode);
}
private void runClient(String remoteHost) throws InterruptedException, IOException {
UdpClient udpClient = new UdpClient(remoteHost, port, packetCount, packetSize);
udpClient.start();
}
private void runServer() throws SocketException, InterruptedException {
UdpServer udpServer = new UdpServer(port);
udpServer.start();
udpServer.join();
}
}

View File

@ -1,17 +0,0 @@
package biz.nellemann.jperf;
public enum DataType {
HANDSHAKE(1), DATA(2), ACK(4), END(9);
private final int value;
private DataType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}

View File

@ -1,141 +0,0 @@
package biz.nellemann.jperf;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Random;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* Datagram consists of the following
* <p>
* <------------------------- HEADER 32 bytes --------------> <---------- DATA min 32 bytes -------->
* _long _int _int _long _long
* 8_bytes 4_bytes 4_bytes 8_bytes 8_bytes
* MAGIC-ID TYPE LENGTH CUR_PKT MAX_PKT
*
*/
public class Datagram {
final Logger log = LoggerFactory.getLogger(Datagram.class);
private final Random random = new Random();
private final int HEADER_LENGTH = 32;
private final byte[] MAGIC_ID = "jPerfTok".getBytes(StandardCharsets.UTF_8); // Must be 8-bytes
private final int type;
private final int length;
private final int realLength;
private final long curPkt;
private final long maxPkt;
private final byte[] data;
/**
* Create new empty datagram
* @param type
* @param length
* @param currentPkt
*/
public Datagram(int type, int length, long currentPkt, long maxPkt) {
log.debug("Datagram() - of type: {}, length: {}, sequence: {}", type, length, currentPkt, maxPkt);
this.type = type;
this.length = length;
this.curPkt = currentPkt;
this.maxPkt = maxPkt;
if(type == DataType.DATA.getValue()) {
realLength = length;
data = new byte[length - HEADER_LENGTH];
} else {
realLength = HEADER_LENGTH * 2;
data = new byte[HEADER_LENGTH * 2];
}
//random.nextBytes(data);
}
/**
* Assemble datagram from payload
* @param payload
*/
public Datagram(byte[] payload) throws IOException {
log.debug("Datagram() magic ID is: {} bytes long and contains: {}", MAGIC_ID.length, MAGIC_ID.toString());
ByteBuffer buffer = ByteBuffer.wrap(payload);
byte[] id = new byte[8];
buffer.get(id);
if(!Arrays.equals(id, MAGIC_ID)) {
log.warn("Datagram() - magic ID does not match!");
throw new IOException();
}
// Order is importent when assembling header fields like this
type = buffer.getInt();
length = buffer.getInt();
curPkt = buffer.getLong();
maxPkt = buffer.getLong();
realLength = length;
if(type == DataType.DATA.getValue()) {
data = new byte[length - HEADER_LENGTH];
buffer.get(data, 0, data.length);
} else {
data = new byte[HEADER_LENGTH * 2];
}
}
public int getLength() {
return length;
}
public int getRealLength() {
return realLength;
}
public byte[] getPayload() throws IOException {
log.debug("getPayload() - with type: {}, length: {}, sequence: {}", type, length, curPkt);
ByteBuffer buffer = ByteBuffer.allocate(data.length + HEADER_LENGTH);
// Order is important
buffer.put(MAGIC_ID);
buffer.putInt(type);
buffer.putInt(length);
buffer.putLong(curPkt);
buffer.putLong(maxPkt);
buffer.put(data);
return buffer.array();
}
public int getType() {
return type;
}
public long getCurPkt() {
return curPkt;
}
public long getMaxPkt() {
return maxPkt;
}
}

View File

@ -1,70 +0,0 @@
package biz.nellemann.jperf;
import java.time.Duration;
import java.time.Instant;
public class Statistics {
private long packetsTransferred, packetsTransferredTotal = 0;
private long bytesTransferred, bytesTransferredTotal = 0;
private long bytesPerSec, packesPerSec = 0;
private long packetsUnacked = 0;
private Instant timestamp1 = Instant.now();
private Instant timestamp2 = Instant.now();
public void reset() {
timestamp1 = Instant.now();
}
public void tick() {
timestamp2 = Instant.now();
if(Duration.between(timestamp1, timestamp2).toMillis() >= 1000) {
// Because we do this every second ...
bytesPerSec = bytesTransferred;
packesPerSec = packetsTransferred;
timestamp1 = timestamp2;
print();
bytesTransferred = 0;
packetsTransferred = 0;
}
}
public void print() {
System.out.printf("%-30s Status: %8d pkt/s %12d B/s %10d KB/s %8d MB/s\n", Instant.now().toString(), packesPerSec, bytesPerSec, bytesPerSec/1_000, bytesPerSec/1_000_000);
}
public void summary() {
System.out.printf("%-29s Summary: %8d pkts %13d B %12d KB %10d MB %6d GB\n", Instant.now().toString(), packetsTransferredTotal, bytesTransferredTotal, bytesTransferredTotal /1_000, bytesTransferredTotal /1_000_000, bytesTransferredTotal/1_000_000_000);
}
public void ack() {
packetsUnacked--;
}
public void transferPacket() {
packetsUnacked++;
packetsTransferred++;
packetsTransferredTotal++;
}
public void transferBytes(long bytes) {
bytesTransferred += bytes;
bytesTransferredTotal += bytes;
}
public long getPacketsUnacked() {
return packetsUnacked;
}
public long getPacketsTransferredTotal() {
return packetsTransferredTotal;
}
}

View File

@ -1,105 +0,0 @@
package biz.nellemann.jperf;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UdpClient {
final Logger log = LoggerFactory.getLogger(UdpClient.class);
private Statistics statistics;
private final int port;
private final InetAddress address;
private final DatagramSocket socket;
private byte[] buf = new byte[256];
private long packetsSent = 0;
private long bytesSent = 0;
private int packetCount;
private int packetSize;
public UdpClient(String hostname, int port, int packets, int size) throws UnknownHostException, SocketException {
log.info("UdpClient() - target: {}, port: {}", hostname, port);
this.port = port;
socket = new DatagramSocket();
address = InetAddress.getByName(hostname);
this.packetCount = packets;
this.packetSize = size;
statistics = new Statistics();
}
private void send(Datagram datagram) throws IOException {
DatagramPacket packet = new DatagramPacket(datagram.getPayload(), datagram.getRealLength(), address, port);
socket.send(packet);
statistics.transferPacket();
statistics.transferBytes(datagram.getRealLength());
}
private Datagram receive() throws IOException {
DatagramPacket packet = new DatagramPacket(buf, buf.length);
socket.receive(packet);
return new Datagram(buf);
}
private void close() {
socket.close();
}
public void start() throws IOException, InterruptedException {
long sequence = 0;
// Start datagram
Datagram datagram = new Datagram(DataType.HANDSHAKE.getValue(), packetSize, sequence++, packetCount);
send(datagram);
// TODO: Wait for ACK
datagram = receive();
if(datagram.getType() != DataType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
// Data datagrams ...
for(int i = 0; i < packetCount; i++) {
datagram = new Datagram(DataType.DATA.getValue(), packetSize, sequence++, packetCount);
send(datagram);
datagram = receive();
if(datagram.getType() != DataType.ACK.getValue()) {
log.warn("No ACK!");
}
statistics.tick();
}
// End datagram
//Thread.sleep(100);
datagram = new Datagram(DataType.END.getValue(), packetSize, sequence++, packetCount);
send(datagram);
// TODO: Wait for ACK
datagram = receive();
statistics.ack();
if(datagram.getType() != DataType.ACK.getValue()) {
log.warn("No ACK!");
return;
}
Thread.sleep(100);
close();
statistics.summary();
}
}

View File

@ -1,96 +0,0 @@
package biz.nellemann.jperf;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UdpServer extends Thread {
final Logger log = LoggerFactory.getLogger(UdpServer.class);
private final DatagramSocket socket;
private byte[] buf = new byte[256];
public UdpServer(int port) throws SocketException {
log.info("UdpServer()");
socket = new DatagramSocket(port);
}
public void run() {
boolean running = true;
try {
while (running) {
session();
}
socket.close();
} catch(IOException e) {
log.error(e.getMessage());
}
}
public void session() throws IOException {
Statistics statistics = new Statistics();
boolean running = true;
boolean ackEnd = false;
while (running) {
DatagramPacket packet = new DatagramPacket(buf, buf.length);
socket.receive(packet);
InetAddress address = packet.getAddress();
int port = packet.getPort();
Datagram datagram = new Datagram(buf);
statistics.transferPacket();
statistics.transferBytes(datagram.getRealLength());
if(datagram.getType() == DataType.HANDSHAKE.getValue()) {
log.info("Handshake from ... {}, length: {}", address, datagram.getLength());
// Setup to receive larger datagrams
buf = new byte[datagram.getLength()];
statistics.reset();
}
/*
if(datagram.getType() == DataType.DATA.getValue()) {
bytesReceived += datagram.getRealLength();
bytesReceivedTotal += datagram.getRealLength();
}*/
if(datagram.getType() == DataType.END.getValue()) {
ackEnd = true;
}
// Send ACK
Datagram responseDatagram = new Datagram(DataType.ACK.getValue(), 32, datagram.getCurPkt(), 1);
packet = new DatagramPacket(responseDatagram.getPayload(), responseDatagram.getLength(), address, port);
socket.send(packet);
statistics.ack();
statistics.tick();
if(ackEnd && statistics.getPacketsTransferredTotal() > datagram.getMaxPkt()) {
running = false;
statistics.summary();
}
}
}
}

View File

@ -3,7 +3,7 @@
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<withJansi>false</withJansi>
<encoder>
<pattern>%cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{32}) - %msg%n</pattern>
<pattern>%cyan(%d{yyyy-MM-dd HH:mm:ss.SSS}) %gray([%-10thread]) %highlight(%-5level) %magenta(%logger{12}) - %msg%n</pattern>
</encoder>
</appender>

View File

@ -0,0 +1,45 @@
package biz.nellemann.jnetperf
import spock.lang.Shared
import spock.lang.Specification
class TcpClientServerTest extends Specification {
static final int port = 9876;
@Shared
TcpServer tcpServer = new TcpServer(port)
// run before every feature method
def setup() {
tcpServer.start();
}
// run after every feature method
def cleanup() {
tcpServer.finish()
}
// run before the first feature method
def setupSpec() {
}
// run after the last feature method
def cleanupSpec() {
}
def "test client to server communication"() {
setup:
TcpClient client = new TcpClient("localhost", port, 512, 100, 60)
when:
client.start()
then:
client.getStatistics().getPacketsTransferredTotal() == 102 // packets + handshake + end
client.getStatistics().getBytesTransferredTotal() == 52224
}
}

View File

@ -0,0 +1,38 @@
package biz.nellemann.jnetperf
import spock.lang.Shared
import spock.lang.Specification
class TimeSuffixConverterTest extends Specification {
@Shared
TimeSuffixConverter timeSuffixConverter = new TimeSuffixConverter();
def "test second to seconds"() {
when:
int seconds = timeSuffixConverter.convert("12s")
then:
seconds == 12;
}
def "test minute to seconds"() {
when:
int seconds = timeSuffixConverter.convert("120m")
then:
seconds == 7200;
}
def "test hour to seconds"() {
when:
int seconds = timeSuffixConverter.convert("48h")
then:
seconds == 172800;
}
}

View File

@ -0,0 +1,45 @@
package biz.nellemann.jnetperf
import spock.lang.Shared
import spock.lang.Specification
class UdpClientServerTest extends Specification {
static final int port = 9876;
@Shared
UdpServer udpServer = new UdpServer(port)
// run before every feature method
def setup() {
udpServer.start();
}
// run after every feature method
def cleanup() {
udpServer.finish()
}
// run before the first feature method
def setupSpec() {
}
// run after the last feature method
def cleanupSpec() {
}
def "test client to server communication"() {
setup:
UdpClient client = new UdpClient("localhost", port, 512, 100, 60)
when:
client.start()
then:
client.getStatistics().getPacketsTransferredTotal() == 102 // packets + handshake + end
client.getStatistics().getBytesTransferredTotal() == 53144 // TODO: Why is this larger than the TCP test ?
}
}

View File

@ -0,0 +1,71 @@
package biz.nellemann.jnetperf
import spock.lang.Shared
import spock.lang.Specification
class UnitSuffixConverterTest extends Specification {
@Shared
UnitSuffixConverter unitSuffixConverter = new UnitSuffixConverter();
def "test byte (b) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("16b")
then:
bytes == 16;
}
def "test kilo (k) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("2048k")
then:
bytes == 2097152;
}
def "test kilo (kb) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("2048kb")
then:
bytes == 2097152;
}
def "test mega (m) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("2m")
then:
bytes == 2097152;
}
def "test mega (mb) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("2mb")
then:
bytes == 2097152;
}
def "test giga (g) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("1g")
then:
bytes == 1073741824;
}
def "test giga (gb) to bytes"() {
when:
long bytes = unitSuffixConverter.convert("1gb")
then:
bytes == 1073741824;
}
}

View File

@ -1,11 +0,0 @@
/*
* This Spock specification was generated by the Gradle 'init' task.
*/
package biz.nellemann.jperf
import spock.lang.Specification
class ApplicationTest extends Specification {
}