io.cloudracer.mocktcpserver.MockTCPServer Maven / Gradle / Ivy
package io.cloudracer.mocktcpserver;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionGroup;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import io.cloudracer.mocktcpserver.bootstrap.Bootstrap;
import io.cloudracer.mocktcpserver.datastream.DataStream;
import io.cloudracer.mocktcpserver.datastream.DataStreamRegexMatcher;
import io.cloudracer.mocktcpserver.responses.ResponseDAO;
import io.cloudracer.mocktcpserver.tcpclient.TCPClient;
import io.cloudracer.properties.ConfigurationSettings;
/**
* A TCP Server that is designed to simulate success and failure conditions in System/Integration test environments.
*
* @author John McDonnell
*/
public class MockTCPServer extends Thread implements Closeable {
private final Logger logger = LogManager.getLogger(this.getRootLoggerName());
private enum Status {
OPEN, CLOSING, CLOSED
}
private static final byte[] DEFAULT_TERMINATOR = { 13, 10, 10 };
private byte[] terminator = null;
private AssertionError assertionError;
private ServerSocket socket;
private DataStreamRegexMatcher expectedMessage;
private Integer port;
private boolean setIsAlwaysNAKResponse = false;
private boolean setIsAlwaysNoResponse = false;
private boolean isSendResponses = true;
private Status status = Status.OPEN;
private final ConfigurationSettings configurationSettings = new ConfigurationSettings();
private Map> tcpClients = new HashMap<>();;
private abstract static class Print {
private static final Logger logger = LogManager.getLogger(Print.class.getName());
private Print() {
// Do nothing. This class cannot be constructed.
}
private static void printVersion() {
final String ideWorkingFolder = "classes";
final File executableLocation = new File(MockTCPServer.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.getPath());
String version = executableLocation.getName();
// If this is the IDE, use the full path.
if (version.equals(ideWorkingFolder)) {
version = executableLocation.getAbsoluteFile().toString();
}
final Pattern pattern = Pattern.compile("\\d+\\.\\d+\\.\\d+");
final Matcher matcher = pattern.matcher(version);
if (matcher.find()) {
logger.info(matcher.group(0));
} else {
logger.info("Version number cannot be identified.");
}
}
private static void printHelp() {
printHelp(null);
}
private static void printHelp(final ParseException e1) {
if (e1 != null) {
logger.info(String.format("Invalid command line: %s", e1.getMessage()));
}
// automatically generate the help statement
final HelpFormatter formatter = new HelpFormatter();
formatter.printHelp("MockTCPServer", getCommandLineOptions());
}
}
/**
* Start the server on the specified port.
*
* @param port the TCP Server will listen on this port. If null, the default port will be used.
*
* @throws ConfigurationException error reading the configuration file
* @throws InterruptedException the MockTCPServer was unexpectedly interrupted
*/
public MockTCPServer(final Integer port) throws ConfigurationException, InterruptedException {
this(port, true);
}
/**
* Start the server on the specified port.
*
* @param port the TCP Server will listen on this port. If null, the default port will be used.
* @param startServer if true, start the server
*
* @throws ConfigurationException error reading the configuration file
* @throws InterruptedException the MockTCPServer was unexpectedly interrupted
*/
public MockTCPServer(final Integer port, boolean startServer) throws ConfigurationException, InterruptedException {
// If the port is specified as -1, creating a connection pool containing a separate server to listen on each port specified in the configuration file.
if (port == -1) {
this.logger.info("Starting a connection pool...");
new Bootstrap().startup();
} else {
this.logger.info(String.format("Starting to listen on port %d only...", port));
if (port != null) {
this.setPort(port);
}
super.setName(String.format("%s-%d", this.getThreadName(), this.getPort()));
if (startServer) {
this.start();
}
}
/*
* If this pause is not done here, a test that *immediately* tries to connect, may get a "connection refused" error.
*/
try {
final long sleepDuration = 20;
TimeUnit.MILLISECONDS.sleep(sleepDuration);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* This allows an operator to start a stand-alone MockTCPServer. The startup.sh(cmd)
file can be used to start the server on a command-line.
*
* Note: currently, there is no packaged bundle to include all the dependencies and scripts.
*
* @param args an alternative port number can be passed as the first (and only) parameter.
*
* @throws ConfigurationException error reading the configuration file
* @throws InterruptedException the MockTCPServer was unexpectedly interrupted
*/
public static void main(String[] args) throws ConfigurationException, InterruptedException {
final Logger logger = LogManager.getLogger();
try {
CommandLine commandLine = new DefaultParser().parse(getCommandLineOptions(), args);
// If no options have been provided, output the help text
if (commandLine.getOptions().length <= 0) {
commandLine = new DefaultParser().parse(getCommandLineOptions(), new String[] { "--help" });
}
// Version information only.
if (commandLine.hasOption("version")) {
Print.printVersion();
} else if (commandLine.hasOption("help")) {
Print.printHelp();
} else {
final MockTCPServer mockTCPServer;
final int port = Integer.parseInt(commandLine.getOptionValue("port"));
mockTCPServer = new MockTCPServer(port);
// When the Operating System interrupts the thread (kill or CTRL-C), stop the server.
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
logger.info("Operating System interrupt detected.");
mockTCPServer.close();
}
});
try {
waitForThread(logger, mockTCPServer, 0);
} finally {
IOUtils.closeQuietly(mockTCPServer);
}
}
} catch (final ParseException e1) {
Print.printHelp(e1);
}
}
private static void waitForThread(final Logger logger, final Thread thread, final long maximumDurationToWait) {
try {
thread.join(maximumDurationToWait);
} catch (final InterruptedException e) {
logger.warn(e);
Thread.currentThread().interrupt();
}
}
@Override
public void run() {
try {
try {
while (this.getStatus() == Status.OPEN && this.getSocket() != null) {
handleConnection();
}
} catch (final IOException | ConfigurationException e) {
this.logger.error(e.getMessage(), e);
}
} finally {
this.setStatus(Status.CLOSING);
this.close();
}
}
private void handleConnection() throws IOException, ConfigurationException {
try {
acceptNewConnection().start();
} catch (final SocketException e) {
this.logger.warn(e);
}
}
/**
* A server callback when a message has been processed, and a response has been sent to the client.
*
* @param clientMachine the name of the client machine that made the connection
* @param clientPort the port the client is transmitting on
* @param serverMachine the name of the server machine
* @param serverPort the port the machine is listening on
*/
public synchronized void afterConnection(final String clientMachine, final int clientPort, final String serverMachine, final int serverPort) {
this.logger.info(String.format("Accepted a connection on machine %s:%d, from the client %s:%d.", serverMachine, serverPort, clientMachine, clientPort));
}
/**
* The server will read the stream until these characters are encountered.
*
* @return the terminator.
*/
public byte[] getTerminator() {
if (this.terminator == null) {
this.terminator = MockTCPServer.DEFAULT_TERMINATOR;
}
return this.terminator;
}
/**
* The server will read the stream until these characters are encountered.
*
* @param terminator the terminator.
*/
public void setTerminator(final byte[] terminator) {
this.terminator = terminator;
}
/**
* A server callback when a message has been processed, and a response has been sent to the client.
*
* @param response the response that has been sent.
*/
public synchronized void afterResponse(final byte[] response) {
this.logger.debug(String.format("Sent the response: %s.", new String(response)));
}
/**
* A server callback when a message is received.
*
* @param message the message received.
*/
public synchronized void onMessage(final DataStream message) {
this.logger.info(String.format("Received: %s.", message.toString()));
}
/**
* An error is recorded if a message other than that which is expected is received.
*
* @return a recorded error.
*/
public AssertionError getAssertionError() {
return this.assertionError;
}
/**
* An error will be recorded if a message other than that which is {@link ClientConnection#getAssertionError() expected} is received. This property must be set before a client connection is established.
*
* @param assertionError a recorded error.
*/
private void setAssertionError(final AssertionError assertionError) {
this.assertionError = assertionError;
}
/**
* Forces the Server to return a NAK in response to the next message received (regardless of any other conditions). The next message will first be processed as normal; irrespective of this property.
*
* This is intended to be used to test a clients response to receiving a NAK.
*
* Default is false.
*
* @return If true, the Servers next response will always be a NAK.
*/
public boolean getIsAlwaysNAKResponse() {
return this.setIsAlwaysNAKResponse;
}
/**
* Forces the Server to return a NAK in response to the next message received (regardless of any other conditions). The next message will first be processed as normal; irrespective of this property.
*
* This is intended to be used to test a clients response to receiving a NAK. This property must be set before a client connection is established.
*
* Default is false.
*
* @param isAlwaysNAKResponse if true, the Servers next response will always be a NAK.
*/
public void setIsAlwaysNAKResponse(final boolean isAlwaysNAKResponse) {
this.setIsAlwaysNAKResponse = isAlwaysNAKResponse;
}
/**
* The server never return a response, when true.
*
* @return true when the server will never return a response. Default is false.
*/
public boolean getIsAlwaysNoResponse() {
return this.setIsAlwaysNoResponse;
}
/**
* The server never return a response, when true. This property must be set before a client connection is established.
*
* @param isAlwaysNoResponse true when the server will never return a response. Default is false.
*/
public void setIsAlwaysNoResponse(final boolean isAlwaysNoResponse) {
this.setIsAlwaysNoResponse = isAlwaysNoResponse;
}
/**
* The server will send the responses described by {@link #getResponses()}.
*
* Default is true.
*
* @return true, if the {@link #getResponses() responses} are to be sent.
*/
public boolean getIsSendResponses() {
return isSendResponses;
}
/**
* The server will send the responses described by {@link #getResponses()}. This property must be set before a client connection is established.
*
* Default is true.
*
* @param isSendResponses true, if the {@link #getResponses() responses} are to be sent
*
*/
public void setIsSendResponses(boolean isSendResponses) {
this.isSendResponses = isSendResponses;
}
/**
* If any message, other that this one, is the next message to be received, record it as an {@link MockTCPServer#getAssertionError() assertion error}.
*
* @return ignore if null.
*/
public DataStreamRegexMatcher getExpectedMessage() {
return this.expectedMessage;
}
/**
* If any message, other that this one, is the next message to be received, record it as an {@link MockTCPServer#getAssertionError() assertion error} and respond with a NAK. This property must be set before a client connection is established.
*
* @param expectedMessage a Regular Expression that describes what the next received message will be.
*/
public void setExpectedMessage(final String expectedMessage) {
this.expectedMessage = new DataStreamRegexMatcher(expectedMessage);
}
/**
* Get the messages, initialised from the configuration file, that will be sent when specified messages are received.
*
* @return the server responses.
* @throws ConfigurationException error reading the configuration file
*/
public Map> getResponses() throws ConfigurationException {
if (getIsSendResponses()) {
if (tcpClients.isEmpty()) {
final Map> responsesDAOs = this.configurationSettings.getResponses(getPort()).getResponses();
for (Map.Entry> incommingMessage : responsesDAOs.entrySet()) {
for (ResponseDAO responseDAO : incommingMessage.getValue()) {
final TCPClient tcpClient = new TCPClient(responseDAO.getMachineName(), responseDAO.getPort());
tcpClient.addResponse(responseDAO.getResponse());
final Set client = new HashSet<>(Arrays.asList(tcpClient));
if (tcpClients.containsKey(incommingMessage.getKey())) {
Set currentClients = tcpClients.get(incommingMessage.getKey());
updateTCPClientList(currentClients, tcpClient);
} else {
tcpClients.put(incommingMessage.getKey(), client);
}
}
}
}
} else {
tcpClients = new HashMap<>();
}
return Collections.unmodifiableMap(tcpClients);
}
private void updateTCPClientList(Set currentClients, final TCPClient tcpClient) {
if (currentClients.contains(tcpClient)) {
for (Iterator it = currentClients.iterator(); it.hasNext();) {
TCPClient currentClient = it.next();
if (currentClient.equals(tcpClient)) {
currentClient.addResponse(tcpClient.getResponses().get(0));
}
}
} else {
currentClients.add(tcpClient);
}
}
/**
* Close the socket (if it is open) and any open data streams.
*/
@Override
public synchronized void close() {
this.logger.info("Closing...");
if (this.getStatus() != Status.CLOSING) {
this.setStatus(Status.CLOSED);
}
this.closeStreams();
while ((socket != null && !socket.isClosed() && socket.isBound()) || (super.isAlive() && this.getStatus() != Status.CLOSING)) {
final long maximumTimeToWait = 1000;
try {
super.join(maximumTimeToWait);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
// Intermittently, the server fails to close. Retry it indefinitely until it does close; an improvement of blocking forever with no feedback.
if (super.isAlive()) {
this.logger.warn(String.format("Failed to close the Server(%s) in %d milliseconds. Trying again to shutdown the Server...", super.getName(), maximumTimeToWait));
if (!super.isInterrupted()) {
this.logger.trace(String.format("Interrupting the Server Thread(%s)...", super.getName()));
this.closeStreams();
}
}
}
this.logger.info("Closed.");
}
private void closeStreams() {
// Do not set the ServerSocket to null; just close the Stream.
this.logger.debug("Closing the socket...");
IOUtils.closeQuietly(this.socket);
this.logger.debug("Closed the socket.");
}
private Status getStatus() {
return this.status;
}
private void setStatus(final Status status) {
this.status = status;
}
/**
* The port that this server is listening on.
*
* @return the port that this server is listening on.
*/
public int getPort() {
return this.port;
}
private void setPort(int port) {
this.port = port;
}
/**
* Open the Server Socket and wait for a connection.
*
* The socket is opened on the configured {@link #getPort() port} on localhost.
*
* @return a new ServerSocket.
* @throws IOException
*/
private ServerSocket getSocket() throws IOException {
if (this.socket == null || this.socket.isClosed()) {
this.logger.debug(String.format("Opening a socket on port %d...", this.getPort()));
this.setSocket(new ServerSocket(this.getPort()));
}
return this.socket;
}
private ClientConnection acceptNewConnection() throws IOException, ConfigurationException {
this.logger.info(String.format("Waiting for a connection on port %d...", this.getPort()));
final Socket client = this.socket.accept();
@SuppressWarnings("static-access")
final InetAddress inetAddress = client.getInetAddress().getLocalHost(); // NOSONAR
this.afterConnection(inetAddress.getHostName(), client.getPort(), InetAddress.getLocalHost().getHostName(), this.getPort());
final BufferedReader inputStream = new BufferedReader(new InputStreamReader(client.getInputStream()));
final DataOutputStream outputStream = new DataOutputStream(client.getOutputStream());
final ClientConnection clientConnection = new ClientConnection(inputStream, outputStream, getIsAlwaysNAKResponse(), getIsAlwaysNoResponse(), getExpectedMessage(), getTerminator(), getResponses()) {
@Override
public void onMessage(DataStream message) {
super.onMessage(message);
MockTCPServer.this.onMessage(message);
}
@Override
public void setAssertionError(AssertionError assertionError) {
super.setAssertionError(assertionError);
MockTCPServer.this.setAssertionError(assertionError);
}
@Override
public synchronized void afterResponse(byte[] response) throws IOException {
super.afterResponse(response);
MockTCPServer.this.afterResponse(response);
}
};
this.logger.debug("Ready to receive input.");
return clientConnection;
}
private void setSocket(final ServerSocket socket) {
this.socket = socket;
}
private static Options getCommandLineOptions() {
// create the Options
final Options options = new Options();
final OptionGroup startup = new OptionGroup();
final Option port = Option.builder("p")
.longOpt("port")
.desc("the port that the server will listen on.")
.type(Integer.class)
.numberOfArgs(1)
.build();
startup.addOption(port);
options.addOptionGroup(startup);
options.addOption("h", "help", false, "print these usage instructions and exit.");
options.addOption("?", "help", false, "print these usage instructions and exit.");
options.addOption("v", "version", false, "print product version and exit.");
return options;
}
/**
* The log4j root logger name that will contain the class name, even if instantiated as an anonymous class.
*
* @return a root logger name.
*/
private String getRootLoggerName() {
return this.getThreadName().replaceAll("-", ".");
}
/**
* Derives a {@link Thread#getName() Thread name} that includes the class name, even if this object instantiated as an anonymous class.
*
* @return a value used as the log4j root logger and the Thread name.
*/
private String getThreadName() {
final String delimeter = ".";
final String regEx = "\\.";
String name;
if (StringUtils.isNotBlank(this.getClass().getSimpleName())) {
name = this.getClass().getSimpleName();
} else {
if (this.getClass().getName().contains(delimeter)) {
final String[] nameSegments = this.getClass().getName().split(regEx);
name = String.format("%s-%s", this.getClass().getSuperclass().getSimpleName(), nameSegments[nameSegments.length - 1]);
} else {
name = this.getClass().getName();
}
}
return name;
}
}