3
\$\begingroup\$

So I've just written a framework that is supposed to make it easier to create a network-based application (client-server) using the native java.net package. I was wondering if the structure of my code is well understandable and if there are ways to improve my code (in both, readability and performance). I provided a UML-diagram to make it easier to gain a fast overview of the code I wrote:

enter image description here

And here is the source of the classes:

AbstractClient.java

package com.joshuafeld.net;

import java.io.IOException;
import java.net.Socket;

/**
 * A client can connect to a running server using native Java sockets. It can perform requests
 * and perform actions based on potential requests it can get from the server.
 */
public abstract class AbstractClient extends AbstractSocketWrapper {

    /**
     * Creates a new client with a socket that is connected to the specified port number on the
     * named host.
     *
     * <p>If the specified host is {@code null} it is the equivalent of specifying the address as
     * {@link java.net.InetAddress#getByName(String) InetAddress.getByName}{@code (null)}. In
     * other words, it is equivalent to specifying an address of the loopback interface.
     *
     * @param  address     The host name, or {@code null} for the loopback address.
     * @param  port        The port number.
     *
     * @throws IOException If an I/O error occurs when creating the underlying socket.
     */
    public AbstractClient(final String address, final int port) throws IOException {
        super(new Socket(address, port));
        new Thread(this).start();
    }

    /**
     * This is just used to guarantee consistency in the client-server system. It calls the
     * method {@link #handleMessage(String) handleMessage} which then can be extended by a subclass.
     * This is created in the hope of making the system more understandable for a new user.
     *
     * @param message The message to handle.
     */
    @Override
    /* default */ void handle(final String message) {
        handleMessage(message);
    }

    /**
     * Handles an incoming message from the server.
     *
     * @param message The incoming message.
     */
    public abstract void handleMessage(String message);
}

AbstractServer.java

package com.joshuafeld.net;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.LinkedList;
import java.util.List;

/**
 * A server that is based on the native Java sever socket implementation. A server waits for
 * requests to come in over the network. It performs some operation based on that request, and then
 * possibly returns a result to the requester.
 */
public abstract class AbstractServer {

    /**
     * Logs messages to the console.
     */
    private static final Logger logger = LoggerFactory.getLogger(AbstractServer.class);

    /**
     * Waits for requests to come in over the network.
     */
    private ServerSocket serverSocket;

    /**
     * The currently open socket connections.
     */
    private final List<ConnectionSocket> sockets;

    /**
     * Creates a server with a server socket, bound to the specified port. A port number of
     * {@code 0} means that the port number is automatically allocated, typically from an ephemeral
     * port range. This port number can then be retrieved by calling {@link #getPort() getPort}.
     *
     * @param  port                     The port number, or {@code 0} to use a port number that is
     *                                  automatically allocated.
     *
     * @throws IllegalArgumentException If the port parameter is outside the specified range of
     *                                  valid port values which is between 0 and 65535, inclusive.
     */
    public AbstractServer(final int port) {
        if (port < 0 || port > 0xFFFF) {
            throw new IllegalArgumentException("Port value out of range: " + port);
        }
        sockets = new LinkedList<>();
        try {
            serverSocket = new ServerSocket(port);
        } catch (final IOException exception) {
            logger.error("An I/O error occurred while opening the server socket", exception);
        }
        new Thread(new ConnectionAcceptor()).start();
    }

    /**
     * Closes the server socket as well as all connected sockets.
     */
    public void close() {
        try {
            serverSocket.close();
        } catch (final IOException exception) {
            logger.error("An I/O error occurred while closing the server socket", exception);
        }
        sockets.forEach(this::close);
    }

    /**
     * Closes the specified socket and removes it from the list of currently connected sockets.
     *
     * @param socket The socket to close.
     */
    public void close(final ConnectionSocket socket) {
        handleDisconnect(socket);
        sockets.remove(socket);
        socket.close();
    }

    /**
     * Broadcasts a message to all connected sockets/clients.
     *
     * @param message The message to broadcast.
     */
    public void broadcast(final String message) {
        sockets.forEach(socket -> socket.send(message));
    }

    /**
     * Handles an incoming message from the specified socket.
     *
     * @param socket  The socket at which the message arrived.
     * @param message The message.
     */
    public abstract void handleMessage(ConnectionSocket socket, String message);

    /**
     * Handles a newly connected socket.
     *
     * @param socket The newly connected socket.
     */
    public abstract void handleConnect(ConnectionSocket socket);

    /**
     * Handles a disconnected/closed socket.
     *
     * @param socket The closed socket.
     */
    public abstract void handleDisconnect(ConnectionSocket socket);

    /**
     * Returns the IP address to which the server socket is bound.
     *
     * <p>If the server socket was bound prior to being {@link #close() closed}, then this method
     * will continue to return the IP address after the server socket is closed.
     *
     * @return The IP address to which the server socket is bound.
     */
    public String getAddress() {
        return serverSocket.getInetAddress().getHostAddress();
    }

    /**
     * Returns the port number on which the server socket is listening.
     *
     * <p>If the server socket was bound prior to being {@link #close() closed}, then this method
     * will continue to return the port number after the server socket is closed.
     *
     * @return The port number on which the server socket is listening or -1 if the socket is not
     *         bound yet.
     */
    public int getPort() {
        return serverSocket.getLocalPort();
    }

    /**
     * Listens for a connection to be made to the server socket and accepts it.
     */
    private class ConnectionAcceptor implements Runnable {

        /**
         * Creates a new connection acceptor.
         */
        private ConnectionAcceptor() {
            // empty on purpose
        }

        /**
         * Listens for a connection to be made to the server socket and accepts it. The method
         * blocks until a connection is made or the server is {@link #close() closed}.
         */
        @Override
        public void run() {
            while (!serverSocket.isClosed()) {
                try {
                    final ConnectionSocket socket = new ConnectionSocket(serverSocket.accept());
                    handleConnect(socket);
                    new Thread(socket).start();
                    sockets.add(socket);
                } catch (final IOException exception) {
                    logger.error("An I/O error occurred while waiting for a connection", exception);
                }
            }
        }
    }

    /**
     * The socket that represents the connection to a specific client.
     */
    public class ConnectionSocket extends AbstractSocketWrapper {

        /**
         * Creates a new wrapped socket. The socket has to be connected already.
         *
         * @param socket The socket to wrap.
         */
        public ConnectionSocket(final Socket socket) {
            super(socket);
        }

        /**
         * Passes the handling of the message on to the method
         * {@link #handleMessage(ConnectionSocket, String) handleMessage}.
         *
         * @param message The message to handle.
         */
        @Override
        void handle(final String message) {
            handleMessage(this, message);
        }
    }
}

AbstractSocketWrapper.java

package com.joshuafeld.net;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.net.Socket;

/**
 * A simple wrapper for the native Java {@link Socket Socket} that automatically handles messages
 * as soon as they are received by the underlying socket.
 */
public abstract class AbstractSocketWrapper implements Runnable {

    /**
     * Logs messages to the console.
     */
    private static final Logger logger = LoggerFactory.getLogger(AbstractSocketWrapper.class);

    /**
     * The underlying socket/communication endpoint.
     */
    private final Socket socket;

    /**
     * Reads incoming messages from the input stream.
     */
    private BufferedReader input;
    /**
     * Writes messages to the output stream.
     */
    private PrintWriter output;

    /**
     * Creates a new wrapped socket. The socket has to be connected already.
     *
     * @param socket The socket to wrap.
     */
    public AbstractSocketWrapper(final Socket socket) {
        this.socket = socket;
        try {
            input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            output = new PrintWriter(socket.getOutputStream(), true);
        } catch (final IOException exception) {
            logger.error("An I/O error occurred while creating the input/output stream or the "
                    + "socket is not connected", exception);
        }
    }

    /**
     * Listens for new messages on the input stream and passes them on to the
     * {@link #handle(String) handle} method for further handling.
     */
    @Override
    public void run() {
        while (!socket.isClosed()) {
            try {
                input.lines().forEach(this::handle);
            } catch (final UncheckedIOException exception) {
                // This will happen if the input stream is closed. Because this is natural behavior
                // and not really an error, we will just ignore this and not log it into the
                // console.
                break;
            }
        }
    }

    /**
     * Sends a message to the connected socket. The message is terminated by a newline.
     *
     * @param message The message to send.
     */
    public void send(final String message) {
        if (!socket.isClosed()) {
            output.println(message);
        }
    }

    /**
     * Closes the socket as well as the input reader and output writer.
     *
     * <p>Once a socket has been closed, it is not available for further networking use (i.e. can't
     * be reconnected or rebound). A new socket needs to be created.
     */
    public void close() {
        try {
            socket.close();
            input.close();
        } catch (final IOException exception) {
            logger.error("An I/O error occurred while closing the socket/input", exception);
        }
        output.close();
    }

    /**
     * Handles a received message.
     *
     * @param message The message to handle.
     */
    /* default */ abstract void handle(final String message);
}

Example of a simple daytime server/client using this code:

DaytimeServer.java

package com.joshuafeld.net.daytime;

import com.joshuafeld.net.AbstractServer;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DaytimeServer extends AbstractServer {

    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat(
            "yyyy/MM/dd HH:mm:ss");

    public DaytimeServer() {
        super(13);
    }

    @Override
    public void handleConnect(final ConnectionSocket socket) {
        socket.send(DATE_FORMAT.format(new Date()));
        socket.close();
    }

    @Override
    public void handleMessage(final ConnectionSocket socket, final String message) {
        // nothing to do here
    }

    @Override
    public void handleDisconnect(final ConnectionSocket socket) {
        // nothing to do here
    }

    public static void main(String[] args) {
        new DaytimeServer();
    }
}

DaytimeClient.java

package com.joshuafeld.net.daytime;

import com.joshuafeld.net.AbstractClient;

import java.io.IOException;

public class DaytimeClient extends AbstractClient {

    public DaytimeClient(String address) throws IOException {
        super(address, 13);
    }

    @Override
    public void handleMessage(final String message) {
        System.out.println(message);
        close();
    }

    public static void main(String[] args) throws IOException {
        new DaytimeClient("localhost");
    }
}
\$\endgroup\$
4
  • \$\begingroup\$ Do you happen to have a working client/server example of this abstract API? \$\endgroup\$
    – dfhwze
    Aug 19, 2019 at 14:09
  • 1
    \$\begingroup\$ @dfhwze Yes I have added it to the question. \$\endgroup\$
    – imjxsh
    Aug 19, 2019 at 14:14
  • \$\begingroup\$ Accepting the first answer you get shortly after you get it tends to discourage further answers. \$\endgroup\$
    – Eric Stein
    Aug 19, 2019 at 17:16
  • \$\begingroup\$ Thanks for the heads-up, I'm new to this site so I wasn't quite sure what to do. \$\endgroup\$
    – imjxsh
    Aug 20, 2019 at 1:19

1 Answer 1

2
\$\begingroup\$

I read through your profile and saw you are a 17 year old developer hobbiest. I didn't know what OO was at that age. Well done. I made this review under the impression you were a somewhat experienced professional.

AbstractSocketWrapper

  • The constructor silently catches IOException. This leaves the socket in a corrupted state. Throw any exception up the stack to let higher level classes decide how to handle the corrupted socket connection.
  • method run could abort at any time when the the socket gets closed gracefully !socket.isClosed() or otherwise: UncheckedIOException. But the container class does not get notified about this. Perhaps you should raise an event. If not, container classes should periodically check for lingering socket connections and close them.
  • method send prints a friendly message (with a new line). This suggests to me the API can only be used for human readable messages, which limits its usability. The guard in this method is also not that useful. A disconnected socket should get disposed correctly, and the guard does not guarantee that the call will succeed.
  • method close seems robust, however when socket.close(); fails, input.close(); does not get called, possibly leading to unwanted behavior (memory leak?).

ConnectionAcceptor

  • method run is ill-implemented. You perform handleConnect(socket); before sockets.add(socket);. And as you can see in your example server, a server can close the socket in handleConnect. This would mean a socket gets added even though it's already been closed.

AbstractServer

  • method close is not robust. If any socket fails to close sockets.forEach(this::close); others will not get closed, causing lingering socket connections.
  • method broadcast has the same issue that failure in unicasting to a single client prevents other clients from being sent to.

General

  • Your API is not threadsafe. Consider using a locking mechanism when manipulating the list of connections in the server, and also when taking a snapshot of connections to send to.
  • Your API should be made more robust against exceptions, and should check for lingering connections.
\$\endgroup\$
3
  • \$\begingroup\$ First of all, thank you very much for the detailed review. There is one thing I don't really understand: Why is it that if "any socket fails to close sockets.forEach(this::close); others will not get closed, causing lingering socket connections"? Shouldn't the for-each loop just continue and close the next one since no exception is thrown if closing a socket fails? \$\endgroup\$
    – imjxsh
    Aug 19, 2019 at 15:20
  • \$\begingroup\$ output.close() might still throw an exception. And I suggested in my review that close on AbstractSocketWrapper should throw exceptions, so the server can handle them appropriately. It's my opinion that container classes should handle exceptions of classes they use (in most cases). \$\endgroup\$
    – dfhwze
    Aug 19, 2019 at 15:32
  • 1
    \$\begingroup\$ Ah alright I understand, thanks again! \$\endgroup\$
    – imjxsh
    Aug 19, 2019 at 15:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.