diff --git a/ClientSessionThread.java b/ClientSessionThread.java new file mode 100644 index 0000000..7b3c4a9 --- /dev/null +++ b/ClientSessionThread.java @@ -0,0 +1,138 @@ +package com.collibra.server; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.UUID; + +/** + * Every time a new client connects to the server a new thread is created for the client's session. + * The session stays active until the client times out or the client sends a disconnect message. + * @version 1.0 + * @author Yordan Kirov + * @since 21/11/2018 + */ +class ClientSessionThread extends Thread { + + /** Socket used for communication between the server and the client. */ + private final Socket clientSocket; + + /** The time when the client has connected. */ + private long startTime; + + /** The name of the client that is retrieved after the initial communication. */ + private String clientName = null; + + /** + * Indicator whether the client session is still active or not. + * When it is made inactive the client gets disconnected. + */ + private boolean isSessionActive; + + /** Universally unique identifier of the client session. */ + private final String sessionId; + + /** + * Constructor + * Initializes the start time of the connection. + * Sets the session to active. + * Generates a universally unique identifier of the client session. + * @param clientSocket the socket on which the client connects to the server. + */ + public ClientSessionThread(Socket clientSocket) { + System.out.println("Client connected."); + this.clientSocket = clientSocket; + this.startTime = System.currentTimeMillis(); + this.isSessionActive = true; + this.sessionId = UUID.randomUUID().toString(); + } + + /** + * Initializes the communication between the server and the client. + * Sends a first message to the client. + * Sets the client name if the client sends it properly or waits to receive it. + * Takes all commands of the client and answers to them. + * When an error occurs the connection with the client is closed. + */ + public void run() { + PrintWriter out = null; + BufferedReader in = null; + try { + out = new PrintWriter(clientSocket.getOutputStream(), true); + in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + + SimpleProtocol.sendFirstMessage(out, sessionId); + + String inputLine; + while (isSessionActive && (inputLine = in.readLine()) != null) { + if (this.clientName == null) { + this.clientName = SimpleProtocol.retrieveClientName(out, inputLine); + } else { + acceptMessage(inputLine, out); + } + } + } catch (SocketTimeoutException se) { + System.out.println(this.clientName + " TIMED OUT!!!"); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SimpleProtocol.sendGoodbyeMessage(out, this.clientName, startTime); + + disconnect(); + } + } + + /** + * Accepts client messages and executes the commands according to the SimpleProtocol. + * @param clientMessage message coming from the client + * @param out the PrintWriter with which the server sends messages to the client. + */ + private void acceptMessage(String clientMessage, PrintWriter out) { + if ("BYE MATE!".equals(clientMessage)) { + this.isSessionActive = false; + return; + } + + String serverMessage; + if (clientMessage.startsWith("ADD NODE ")) { + serverMessage = SimpleProtocol.addNode(clientMessage); + } else if (clientMessage.startsWith("ADD EDGE ")) { + serverMessage = SimpleProtocol.addEdge(clientMessage); + } else if (clientMessage.startsWith("REMOVE NODE ")) { + serverMessage = SimpleProtocol.removeNode(clientMessage); + } else if (clientMessage.startsWith("REMOVE EDGE ")) { + serverMessage = SimpleProtocol.removeEdge(clientMessage); + } else if (clientMessage.startsWith("SHORTEST PATH ")) { + serverMessage = SimpleProtocol.getShortestPath(clientMessage); + } else if (clientMessage.startsWith("CLOSER THAN ")) { + serverMessage = SimpleProtocol.getCloserThan(clientMessage); + } else { + serverMessage = SimpleProtocol.unsupportedCommand(); + } + + debugToConsole(clientMessage, serverMessage); + + out.println(serverMessage); + } + + private void debugToConsole(String clientMessage, String serverMessage) { + if (Server.DEBUG) { + System.out.println(">>>" + clientMessage); + System.out.println(serverMessage); + } + } + + private void disconnect() { + System.out.println("Closing the connection: " + this.clientName); + + try { + if (clientSocket != null) { + clientSocket.close(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/Graph.java b/Graph.java new file mode 100644 index 0000000..ea21429 --- /dev/null +++ b/Graph.java @@ -0,0 +1,217 @@ +package com.collibra.server; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Directed weighted graph, to which nodes and edges can be added, and removed asynchronically. + * Some calculations can be made like getting the shortest path and retrieving all the nodes + * that are closer to node X than the given weight. + * For the calculations an implementation of the algorithm of Dijkstra with a priority queue is used. + * + * @param The type of the nodes in the graph. + * @version 1.0 + * @author Yordan Kirov + * @since 21/11/2018 + */ +class Graph { + /** A list of all nodes of the graph */ + private final ArrayList nodes; + + /** A list of all edges of the graph */ + private final ArrayList edges; + + /** Constructor initializing the lists of nodes or edges */ + public Graph() { + this.nodes = new ArrayList<>(); + this.edges = new ArrayList<>(); + } + + /** + * Checks if a given node exist in the GRAPH. + * @param name the name of the node + * @return Indicates whether a node exists in the GRAPH. + */ + public boolean containsNode(Node name) { + return this.nodes.contains(name); + } + + /** + * Adds a node to the graph. + * The method is synchronized. + * @param name the name of the node + * @return Indicates whether a node exists in the GRAPH. + */ + public synchronized void addNode(Node name) { + nodes.add(name); + } + + /** + * Removes a node by the name of it and also removes all edges which are connected to it. + * The method is synchronized. + * @param name the name of the node to be removed. + */ + public synchronized void removeNode(Node name) { + nodes.remove(name); + edges.removeIf(edge -> edge.getTo().equals(name) || edge.getFrom().equals(name)); + } + + /** + * Adds a directed edge to the GRAPH. + * The method is synchronized. + * @param from the node which is the tail of the edge. + * @param to the node which is the head of the edge. + * @param weight the weight of the edge. Should be a positive integer. + */ + public synchronized void addEdge(Node from, Node to, int weight) { + Edge edge = new Edge(from, to, weight); + this.edges.add(edge); + } + + /** + * Removes all directed edges from the GRAPH which are having the same tail node and head node. + * The method is synchronized. + * @param from the node which is the tail of the edge. + * @param to the node which is the head of the edge. + */ + public synchronized void removeEdge(String from, String to) { + this.edges.removeIf(edge -> edge.getFrom().equals(from) && edge.getTo().equals(to)); + } + + /** + * Calculates the sum of the weights of the shortest path between two nodes in the GRAPH. + * The method is synchronized. + * @param from source node. + * @param to target node. + * @return the sum of the weights of the shortest path between two nodes in the GRAPH. + * If there is no path between the nodes Integer.MAX_VALUE is returned. + */ + public synchronized int getShortestPathDistance(Node from, Node to) { + Map distances = getShortestPathDistances(from); + + return distances.get(to); + } + + /** + * Finds all the nodes that are closer to a node from the client message than the given weight. + * + * For example: + * Simple graph: Mark -­ 5 -­> Michael -­ 2 -­> Madeleine -­ 8 -­> Mufasa + * CLOSER THAN 8 Mark + * Would return: Madeleine,Michael + * Because Michael is at weight 5 from Mark and Madeleine is at weight 7 (5+2) from Mark. + * + * @param distance the max distance (sum of weight) from the source node for which the closer nodes will be listed. + * @param source the node from which the search will start. + * @return a comma separated list(no spaces) of found nodes, that are closer to a node from the client message, + * sorted alphabetically by name, not including the source node. + */ + public synchronized String getNodesCloserThan(int distance, Node source) { + Map distances = getShortestPathDistances(source); + List nodes = new ArrayList<>(); + for (Map.Entry nodeToDistance : distances.entrySet()) { + if (nodeToDistance.getValue() < distance && !nodeToDistance.getKey().equals(source)) { + nodes.add(nodeToDistance.getKey().toString()); + } + } + + String nodeNames = nodes.stream().sorted().collect(Collectors.joining(",")); + + return nodeNames; + } + + private int getSmallestWeight(Node from, Node to) { + int weight = Integer.MAX_VALUE; + for (Edge e : edges) { + if (e.getFrom().equals(from) && e.getTo().equals(to)) { + if (weight > e.getWeight()) { + weight = e.getWeight(); + } + } + } + + return weight; + } + + private List getNeighbours(Node from) { + List neighbours = new ArrayList<>(); + for (Edge e : edges) { + if (e.getFrom().equals(from) && !neighbours.contains(e.getTo())) { + neighbours.add(e.getTo()); + } + } + + return neighbours; + } + + private Map getShortestPathDistances(Node source) { + Map distances = new HashMap<>(); + distances.put(source, 0); + + for (Node node : nodes) { + if (!node.equals(source)) { + distances.put(node, Integer.MAX_VALUE); + } + } + PriorityQueue queue = new PriorityQueue<>(); + queue.add(new PrioritisedNode(source, 0)); + + while (!queue.isEmpty()) { + PrioritisedNode currentNode = queue.poll(); + + for (Node neighbour : getNeighbours(currentNode.node)) { + int newDistance = + distances.get(currentNode.node) + getSmallestWeight(currentNode.node, neighbour); + + if (newDistance < distances.get(neighbour)) { + distances.put(neighbour, newDistance); + queue.add(new PrioritisedNode(neighbour, newDistance)); + } + } + } + + return distances; + } + + private class PrioritisedNode implements Comparable { + final Node node; + final int priority; + + PrioritisedNode(Node node, int priority) { + this.node = node; + this.priority = priority; + } + + public int compareTo(PrioritisedNode other) { + return Integer.compare(this.priority, other.priority); + } + } + + private class Edge { + private final Node from; + private final Node to; + private final int weight; + + Edge(Node from, Node to, int weight) { + this.from = from; + this.to = to; + this.weight = weight; + } + + Node getFrom() { + return from; + } + + Node getTo() { + return to; + } + + int getWeight() { + return weight; + } + + public String toString() { + return from + "->" + to + " " + weight; + } + } +} diff --git a/Server.java b/Server.java new file mode 100644 index 0000000..a103d66 --- /dev/null +++ b/Server.java @@ -0,0 +1,46 @@ +package com.collibra.server; + +import java.net.*; +import java.io.*; + + +/** + * A simple TCP socket server that is accepting client connections in parallel, on the specified port: {@value #PORT}. + * If client stays inactive for the time of the timeout: {@value #TIMEOUT}, it gets disconnected. + * Debug {@value #DEBUG} is used to display the client - server communication in the console. + * Creates a directed weighted GRAPH, that is going to be used for calculations by the clients. + * + * @version 1.0 + * @author Yordan Kirov + * @since 21/11/2018 + */ +public class Server { + + /** The PORT on which the server is running. Hardcoded to 50000. */ + private static final int PORT = 50000; + + /** The timeout of every client connection to the server. Hardcoded to 30000 MS. */ + private static final int TIMEOUT = 30000; + + /** Directed weighted graph, that is going to be used for calculations by the clients. */ + static final Graph GRAPH = new Graph<>(); + + /** Debug {@value #DEBUG} is used to display the client - server communication in the console. */ + static final boolean DEBUG=false; + + public static void main(String[] args) { + System.out.println("The server is listening..."); + + try (ServerSocket serverSocket = new ServerSocket(PORT)) { + while (true) { + Socket socket = serverSocket.accept(); + socket.setSoTimeout(TIMEOUT); + + ClientSessionThread st = new ClientSessionThread(socket); + st.start(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/SimpleProtocol.java b/SimpleProtocol.java new file mode 100644 index 0000000..8cfe280 --- /dev/null +++ b/SimpleProtocol.java @@ -0,0 +1,256 @@ +package com.collibra.server; + +import java.io.PrintWriter; + +/** + * A simple protocol for exchanging messages between a server and clients. + * + * @version 1.0 + * @author Yordan Kirov + * @since 21/11/2018 + * + */ +class SimpleProtocol { + /** + * Sends a first message to every client that connects to the server. + * @param out the PrintWriter with which the server sends messages to the client. + * @param sessionId identification of the session with the current client. + */ + public static void sendFirstMessage(PrintWriter out, String sessionId) { + out.println("HI, I'M " + sessionId); + } + + /** + * Extracts and validates the name of the connected client from its first message. + * + * The server expects a message starting with "HI, I'M " and followed by the name of the client. + * If the client doesn't fulfil the server expectations, by sending the wrong command or sending + * a clientName that is non alphanumeric plus the character "-", the server is awaiting for the client to give a + * proper name and doesn't accept other commands before that. + * + * @param out the PrintWriter with which the sever sends messages to the client. + * @param message received from a client. + * + * @return clientName - the name of the client if it is valid, otherwise returns null; + */ + public static String retrieveClientName(PrintWriter out, String message) { + String clientName; + if (message.contains("HI, I'M ")) { + String unvalidatedClientName = message.replace("HI, I'M ", ""); + + if (isValidName(unvalidatedClientName)) { + clientName = unvalidatedClientName; + System.out.println("Connected client: " + clientName); + + out.println("HI " + clientName); + return clientName; + } + } + + out.println(unsupportedCommand()); + return null; + } + + /** + * Sends a message to the client before disconnecting. + * The message includes the name of the client and the duration of the connection. + * + * @param out the PrintWriter with which the sever sends messages to the client. + * @param clientName the name of the connected client. + * @param startTime the time in milliseconds when the client connected. + */ + public static void sendGoodbyeMessage(PrintWriter out, String clientName, long startTime) { + if (out != null) { + System.out.println("GOODBYE MESSAGE!" + clientName); + long stopTime = System.currentTimeMillis(); + long duration = stopTime - startTime; + out.println("BYE " + clientName + ", WE SPOKE FOR " + duration + " MS"); + } + } + + /** + * Adds a node (as a string) to the GRAPH. + * When the name of the node is only including alphanumeric character, + * plus the character "-" and the node doesn't already exist. + * + * @param message received from a client. + * @return returns an error message if the node already exists + * or success message if the node has been successfully added. + */ + public static String addNode(String message) { + String nodeName = message.replace("ADD NODE ", ""); + + if (!isValidName(nodeName)) { + return unsupportedCommand(); + } + + if (!Server.GRAPH.containsNode(nodeName)) { + Server.GRAPH.addNode(nodeName); + return "NODE ADDED"; + } else { + return "ERROR: NODE ALREADY EXISTS"; + } + } + + /** + * Adds a directed edge to the GRAPH. + * A directed edge has three parts, node from which it points, node to which it points + * and weight which is a positive integer. + * When the names of the nodes between the edge is are already existing and + * the format of the command is valid and the weight is a positive integer. + * + * @param message received from a client. + * @return returns an error message if the the edge can't be added + * or success message if the edge has been successfully added. + */ + public static String addEdge(String message) { + String edgeString = message.replace("ADD EDGE ", ""); + String[] edgeParts = edgeString.split(" "); + + if (!isValidEdge(edgeParts)) { + return unsupportedCommand(); + } + + String fromName = edgeParts[0]; + String toName = edgeParts[1]; + + if (Server.GRAPH.containsNode(fromName) && Server.GRAPH.containsNode(toName)) { + int weight = Integer.valueOf(edgeParts[2]); + + Server.GRAPH.addEdge(fromName, toName, weight); + return "EDGE ADDED"; + } + + return "ERROR: NODE NOT FOUND"; + } + + /** + * Removes a node from the GRAPH, only when the node is already existing. + * All edges that are linked to the removed node will also be removed. + * @param message received from a client. + * @return returns an error message if the node doesn't exist + * or success message if the node has been successfully removed. + */ + public static String removeNode(String message) { + String nodeName = message.replace("REMOVE NODE ", ""); + + if (Server.GRAPH.containsNode(nodeName)) { + Server.GRAPH.removeNode(nodeName); + return "NODE REMOVED"; + } else { + return "ERROR: NODE NOT FOUND"; + } + } + + /** + * Removes all directed edges from the GRAPH when the command is valid + * and the both nodes that form the graph are existing. + * @param message received from a client. + * @return returns an error message if the command is invalid or the nodes between the graph aren't existing. + * Returns a success message if the edges have been successfully removed. + */ + public static String removeEdge(String message) { + String edgeString = message.replace("REMOVE EDGE ", ""); + String[] edgeParts = edgeString.split(" "); + + if (edgeParts.length != 2) { + return unsupportedCommand(); + } + + String fromName = edgeParts[0]; + String toName = edgeParts[1]; + + if (Server.GRAPH.containsNode(fromName) && Server.GRAPH.containsNode(toName)) { + Server.GRAPH.removeEdge(fromName, toName); + return "EDGE REMOVED"; + } + + return "ERROR: NODE NOT FOUND"; + } + + /** + * Calculates the sum of the weights of the shortest path between two nodes in the GRAPH, + * when the input of the command is valid and the nodes exist in the GRAPH. + * @param message received from a client. + * @return the sum of the weights of the shortest path between two nodes. + * Returns an error message if the command is invalid or the nodes of the GRAPH aren't existing. + */ + public static String getShortestPath(String message) { + String fromAndTo = message.replace("SHORTEST PATH ", ""); + String[] fromTo = fromAndTo.split(" "); + if (fromTo.length != 2) { + return unsupportedCommand(); + } + + String from = fromTo[0]; + String to = fromTo[1]; + + if (Server.GRAPH.containsNode(from) && Server.GRAPH.containsNode(to)) { + return String.valueOf(Server.GRAPH.getShortestPathDistance(from, to)); + } + + return "ERROR: NODE NOT FOUND"; + } + + /** + * Finds all the nodes that are closer to a node from the client message than the given weight. + * + * For example: + * Simple graph: Mark -­ 5 -­> Michael -­ 2 -­> Madeleine -­ 8 -­> Mufasa + * CLOSER THAN 8 Mark + * Would return: Madeleine,Michael + * Because Michael is at weight 5 from Mark and Madeleine is at weight 7 (5+2) from Mark. + * + * @param message received from a client. + * @return a comma separated list(no spaces) of found nodes, that are closer to a node from the client message, + * sorted alphabetically by name, not including the starting node. + * Returns an error message if the command is invalid or the node of the GRAPH isn't existing. + */ + public static String getCloserThan(String message) { + String weightAndNode = message.replace("CLOSER THAN ", ""); + String[] weightNode = weightAndNode.split(" "); + + if (weightNode.length != 2 || !isValidWeightString(weightNode[0])) { + return unsupportedCommand(); + } + int weight = Integer.valueOf(weightNode[0]); + String nodeName = weightNode[1]; + + if (Server.GRAPH.containsNode(nodeName)) { + String path = Server.GRAPH.getNodesCloserThan(weight, nodeName); + return path; + } + + return "ERROR: NODE NOT FOUND"; + } + + /** + * Returns string for unsupported command. + * @return string for unsupported command. + */ + public static String unsupportedCommand() { + return "SORRY, I DIDN'T UNDERSTAND THAT"; + } + + + private static boolean isValidEdge(String[] input) { + if (input.length != 3) { + return false; + } + + String weightString = input[2]; + + boolean isValidWeightString = isValidWeightString(weightString); + + return isValidWeightString; + } + + private static boolean isValidWeightString(String weightString) { + return weightString.length() > 0 && + weightString.chars().allMatch(Character::isDigit); + } + + private static boolean isValidName(String input) { + return input.matches("[A-Za-z0-9-]+"); + } +}