14
\$\begingroup\$

A while ago, I wanted to make a multiplayer game, so I wrote some TCPClient and TCPListener code, and it worked, but it was messy. I looked around for a way to use the TCPClient with events, but I couldn't find any good APIs for it. So I made my own.

This library successfully turns TCP networking (sending messages to a server, server responding, etc.) into an event-based system. You can attach the MessageReceived event from either Client or Server, making it much easier to make programs using this, along with a variety of other options and events on each class.

Within the GitHub page, there's the solution for it, and within that, 3 projects. The main DLL project, and a test client and test server showing example usage.

This also has a ResponseEvent system, where you can add a ResponseEvent to the server, so that when a message has certain text, you can trigger an action or method.

Client

public class Client
{
    TcpClient _client;
    NetworkStream _stream => _client.GetStream();
    Thread _listenThread;

    /// <summary>
    /// Whether the Client has been disconnected and disposed or not.
    /// </summary>
    public bool IsDisposed { get; private set; }

    /// <summary>
    /// When a message is received from the server this client is connected to.
    /// </summary>
    public event EventHandler<MessageReceivedEventArgs> MessageReceived;

    /// <summary>
    /// When the server stops, disposing the client automatically.
    /// </summary>
    public event EventHandler ServerStopped;

    /// <summary>
    /// When the client is disposed.
    /// </summary>
    public event EventHandler Disposed;

    /// <summary>
    /// When the client has begun listening for messages from the server.
    /// </summary>
    public event EventHandler StartedListening;

    /// <summary>
    /// If this is the client end creating the connected, this is false.
    /// If this is the client the sever creates on the server end after a client has connected, this is true.
    /// </summary>
    public bool IsServerClient { get; }

    /// <summary>
    /// If the instantiation, and inheritly the connection, failed.
    /// </summary>
    public bool FailedConnect { get; }

    /// <summary>
    /// The tag attached to this object.
    /// </summary>
    public object Tag { get; set; }

    /// <summary>
    /// The endpoint of the client. If <see cref="IsServerClient"/>, this returns the originating client's IP endpoint. 
    /// If not true, returns the address of the server.
    /// </summary>
    public string ConnectAddress => IPAddress.Parse(((IPEndPoint)_client.Client.RemoteEndPoint).Address.ToString()).ToString();

    /// <summary>
    /// The port this client is connected to the server on.
    /// </summary>
    public int Port { get; set; }

    /// <summary>
    /// If it's server-side.
    /// </summary>
    /// <param name="client"></param>
    public Client(TcpClient client)
    {
        _client = client;
        IsServerClient = true;
        Port = ((IPEndPoint)client.Client.RemoteEndPoint).Port;

        StartListening();
    }

    /// <summary>
    /// If it's client side.
    /// </summary>
    /// <param name="address"></param>
    /// <param name="port"></param>
    public Client(string address, int port)
    {
        try
        {
            Port = port;
            _client = new TcpClient(address, port);

            StartListening();
        }
        catch
        {
            FailedConnect = true;
        }
    }

    /// <summary>
    /// Starts the client listening for messages.
    /// </summary>
    private void StartListening()
    {
        _listenThread = new Thread(ListenForMessages);
        _listenThread.Start();
        StartedListening?.Invoke(this, null);
    }

    /// <summary>
    /// Sends a message to the endpoint of this client.
    /// </summary>
    /// <param name="content"></param>
    public void SendMessage(object content)
    {
        var outContent = content.ToString()
            .Replace(TcpOptions.EndConnectionCode.ToString(), "")
            .Replace(TcpOptions.EndMessageCode.ToString(), "");
        outContent += TcpOptions.EndMessageCode.ToString();
        var data = outContent.GetBytes();
        _stream.Write(data, 0, data.Length);
    }

    /// <summary>
    /// Sends a message to the endpoint of this client, not replacing a <see cref="TcpOptions.EndConnectionCode"/>.
    /// </summary>
    /// <param name="content"></param>
    /// <param name="a"></param>
    private void SendMessage(object content, bool a)
    {
        var outContent = content.ToString()
            .Replace(TcpOptions.EndMessageCode.ToString(), "");
        outContent += TcpOptions.EndMessageCode.ToString();
        var data = outContent.GetBytes();
        _stream.Write(data, 0, data.Length);
    }

    /// <summary>
    /// The thread method where the client listens for new messages and handles them accordingly.
    /// </summary>
    private void ListenForMessages()
    {
        var bytes = new List<byte>();

        while (!IsDisposed)
        {
            var i = -1;

            try
            {
                i = _stream.ReadByte();
            }
            catch (SocketException e)
            {
                if (e.SocketErrorCode == SocketError.Interrupted)
                {
                    break;
                }
            }
            catch
            {
                break;
            }

            if (i == -1)
            {
                break;
            }
            else if (i == TcpOptions.EndMessageCode)
            {
                if (bytes.Count > 0)
                {
                    var message = bytes.ToArray().GetString();
                    var eventargs = new MessageReceivedEventArgs
                    {
                        Message = message,
                        Time = DateTime.Now,
                        Client = this
                    };
                    MessageReceived?.Invoke(this, eventargs);
                    bytes.Clear();
                }
            }
            else if (i == TcpOptions.EndConnectionCode && !IsServerClient)
            {
                ServerStopped?.Invoke(this, null);
                Dispose(true);
                break;
            }
            else
            {
                bytes.Add(Convert.ToByte(i));
            }
        }
    }

    /// <summary>
    /// Stops the client from listening, sending an end connection code to the server, and disposing.
    /// </summary>
    /// <param name="fromServer"></param>
    public void Dispose(bool fromServer)
    {
        if (!IsDisposed)
        {
            IsDisposed = true;
            if (!fromServer)
            {
                SendMessage(TcpOptions.EndConnectionCode.ToString(), true);
            }
            _client.Close();
            _client.Dispose();
            _listenThread.Abort();
            Disposed?.Invoke(this, null);
        }
    }
}

Server

public class Server
{
    TcpListener _listener;
    Thread _clientListenerThread;

    /// <summary>
    /// A list of all the clients connected to this server.
    /// </summary>
    public List<Client> ConnectedClients { get; set; }

    /// <summary>
    /// A list of all the responses set up on this server.
    /// </summary>
    public List<ResponseEvent> Responses { get; set; }

    /// <summary>
    /// The address the server is listening on.
    /// </summary>
    public string Address { get; }

    /// <summary>
    /// The port the server is listening on.
    /// </summary>
    public int Port { get; }

    /// <summary>
    /// Whether the server has been stopped and disposed.
    /// </summary>
    public bool IsDisposed { get; private set; }

    /// <summary>
    /// Occurs when a client connects to the server.
    /// </summary>
    public event EventHandler<ClientToggleEventArgs> ClientConnected;

    /// <summary>
    /// Occurs when a client disconnected from the server.
    /// </summary>
    public event EventHandler<ClientToggleEventArgs> ClientDisconnected;

    /// <summary>
    /// Occurs when any client sends a message to the server.
    /// </summary>
    public event EventHandler<MessageReceivedEventArgs> MessageReceived;

    /// <summary>
    /// Occurs when the server is disconnected and disposed.
    /// </summary>
    public event EventHandler Disposed;

    /// <summary>
    /// Whether the client is listening or not.
    /// </summary>
    public bool HasStartedListening { get; private set; }

    /// <summary>
    /// If it's listening and has not been disposed.
    /// </summary>
    public bool IsReady => HasStartedListening && !IsDisposed;

    /// <summary>
    /// The tag attached to this object.
    /// </summary>
    public object Tag { get; set; }

    /// <summary>
    /// Constructor to instantiate and start the server for listening.
    /// </summary>
    /// <param name="address">The IP address the listen on.</param>
    /// <param name="port">The port to listen on.</param>
    public Server(string address, int port)
    {
        _listener = new TcpListener(IPAddress.Parse(address), port);
        _listener.Start();

        Address = address;
        Port = port;

        StartClientListening();
    }

    /// <summary>
    /// Starts the listening thread for the server. After this, the server has begun listening for connected from clients. 
    /// Private so that users do not call more than once.
    /// </summary>
    private void StartClientListening()
    {
        ConnectedClients = new List<Client>();
        Responses = new List<ResponseEvent>();
        _clientListenerThread = new Thread(ListenForClients);
        _clientListenerThread.Start();
        HasStartedListening = true;
    }

    /// <summary>
    /// The threaded method where the server listens for client connections. Only called from <see cref="StartClientListening"/>
    /// </summary>
    private void ListenForClients()
    {
        while (!IsDisposed)
        {
            try
            {
                var connectedTCPClient = _listener.AcceptTcpClient();
                var connectedClient = new Client(connectedTCPClient);

                connectedClient.MessageReceived += ConnectedClient_MessageReceived;

                ConnectedClients.Add(connectedClient);
                var eventargs = new ClientToggleEventArgs
                {
                    ConnectedClient = connectedClient,
                    Time = DateTime.Now
                };
                ClientConnected?.Invoke(this, eventargs);
            }
            catch (SocketException e)
            {
                if (e.SocketErrorCode == SocketError.Interrupted)
                {
                    break;
                }
                else
                {
                    throw e;
                }
            }
        }
    }

    /// <summary>
    /// This is the event handler attached to every client that is connected's MessageReceive event.
    /// This is where it checks if a client has sent the disconnetion code, and if so, disposes of them.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void ConnectedClient_MessageReceived(object sender, MessageReceivedEventArgs e)
    {
        if (e.Message == TcpOptions.EndConnectionCode.ToString())
        {
            ConnectedClients.Remove(sender as Client);
            var eventargs = new ClientToggleEventArgs
            {
                ConnectedClient = sender as Client,
                Time = DateTime.Now
            };
            ClientDisconnected?.Invoke(this, eventargs);
        }
        else
        {
            foreach (var response in Responses)
            {
                var willTrigger = false;

                switch (response.Mode)
                {
                    case ContentMode.Contains:
                        if (e.Message.Contains(response.Content))
                        {
                            willTrigger = true;
                        }
                        break;
                    case ContentMode.EndsWish:
                        if (e.Message.EndsWith(response.Content))
                        {
                            willTrigger = true;
                        }
                        break;
                    case ContentMode.StartsWith:
                        if (e.Message.StartsWith(response.Content))
                        {
                            willTrigger = true;
                        }
                        break;
                    case ContentMode.Equals:
                        if (e.Message == response.Content)
                        {
                            willTrigger = true;
                        }
                        break;
                }

                if (willTrigger)
                {
                    response.Event?.Invoke(e);
                }
                else
                {
                    MessageReceived?.Invoke(sender, e);
                }
            }
        }
    }

    /// <summary>
    /// This disposes the server, also stopping the listening thread, and sending an
    /// <see cref="TcpOptions.EndConnectionCode"/> to every client connected.
    /// </summary>
    public void Dispose()
    {
        if (!IsDisposed)
        {
            IsDisposed = true;
            foreach (var client in ConnectedClients)
            {
                client.SendMessage(TcpOptions.EndConnectionCode);
                client.Dispose(false);
            }
            ConnectedClients = null;
            _listener.Stop();
            Disposed?.Invoke(this, null);
        }
    }

    /// <summary>
    /// Returns this machine's intranetwork IPv4 address. 
    /// Throws an exception if there are no connected network adapters on the system.
    /// </summary>
    /// <returns>The IPv4 address of this machine.</returns>
    public static string GetLocalIPAddress()
    {
        var host = Dns.GetHostEntry(Dns.GetHostName());
        foreach (var ip in host.AddressList)
        {
            if (ip.AddressFamily == AddressFamily.InterNetwork)
            {
                return ip.ToString();
            }
        }
        throw new Exception("No network adapters with an IPv4 address in the system!");
    }
}

Other

public class ResponseEvent
{
    public string Content { get; set; }
    public ContentMode Mode { get; set; }
    public Action<MessageReceivedEventArgs> Event { get; set; }
}

public enum ContentMode
{
    Contains,
    StartsWith,
    EndsWish,
    Equals,
}

Example Usage Server-Side

public class Program
{
    static Server server;

    static void Main(string[] args)
    {
        Console.WriteLine("Initializing Server...");

        server = new Server(Server.GetLocalIPAddress(), 13001);
        server.ClientConnected += Server_ClientConnected;
        server.MessageReceived += Server_MessageReceived;
        server.ClientDisconnected += Server_ClientDisconnected;
        var rickroll = new ResponseEvent()
        {
            Content = "never gunna give you up",
            Mode = ContentMode.Contains,
            Event = Rickroll,
        };
        server.Responses.Add(rickroll);

        Console.WriteLine("Server started.");
        Console.WriteLine("Listing on IP address: " + server.Address);
        Console.WriteLine("On port: " + server.Port);

        while (!server.IsDisposed)
        {
            Console.Write("> ");
            var input = Console.ReadLine();

            switch (input)
            {
                case "listclients":
                    Console.WriteLine(server.ConnectedClients.Count + " Client(s) Connected\n-----------------------");
                    foreach (var client in server.ConnectedClients)
                    {
                        Console.WriteLine(client.ConnectAddress);
                    }
                    Console.WriteLine("-----------------------");
                    break;
                case "stop":
                    Console.WriteLine("Disposing Server...");
                    server.Dispose();
                    Console.WriteLine("Server closed. Press any key to exit.");
                    Console.Read();
                    break;
                default:
                    Console.WriteLine("Invalid Command: " + input);
                    break;
            }

            Console.WriteLine();
        }
    }

    public static void Rickroll(MessageReceivedEventArgs e)
    {
        e.Client.SendMessage("never gunna let you down");
    }

    private static void Server_ClientDisconnected(object sender, ClientToggleEventArgs e)
    {
        Console.WriteLine("Client Disconnected: " + e.ConnectedClient.ConnectAddress);
    }

    private static void Server_MessageReceived(object sender, MessageReceivedEventArgs e)
    {
        Console.WriteLine("Received Message: " + e.Client.ConnectAddress + " : " + e.Message);

        var toRespond = Reverse(e.Message);

        Console.WriteLine("Returning Message: " + toRespond);
        e.Client.SendMessage(toRespond);
    }

    public static string Reverse(string s)
    {
        var charArray = s.ToCharArray();
        Array.Reverse(charArray);
        return new string(charArray);
    }

    private static void Server_ClientConnected(object sender, ClientToggleEventArgs e)
    {
        Console.WriteLine("Client Connected: " + e.ConnectedClient.ConnectAddress);
    }
}

Example Usage Client-Side

public class Program
{
    static Client client;

    static void Main(string[] args)
    {
        failedToConnect:;

        Console.Write("Enter the IP address to connect to:\n> ");
        var ip = Console.ReadLine();

        invalidPort:;

        Console.Write("Enter the port to connect to:\n> ");
        var portString = Console.ReadLine();
        Console.WriteLine();

        if (int.TryParse(portString, out var port))
        {
            try
            {
                client = new Client(ip, port);
                if (client.FailedConnect)
                {
                    Console.WriteLine("Failed to connect!");
                    goto failedToConnect;
                }
                client.MessageReceived += Client_MessageReceived;
                Console.WriteLine("Client connected.");

                while (!client.IsDisposed)
                {
                    Console.Write("> ");
                    var input = Console.ReadLine();

                    if (client.IsDisposed)
                    {
                        Console.WriteLine("The server closed. This client has disposed. Press any key to close...");
                        Console.ReadLine();
                    }
                    else
                    {

                        if (input.ToLower().StartsWith("send "))
                        {
                            var toSend = input.Substring(5, input.Length - 5);

                            Console.WriteLine("Sending message: \n     " + toSend + "\n");
                            client.SendMessage(toSend);
                            Console.WriteLine("Sent message.");
                        }
                        else if (input.ToLower() == "stop")
                        {
                            Console.WriteLine("Disconnecting...");
                            client.Dispose(false);
                            Console.WriteLine("Disconnected. Press any key to continue.");
                            Console.ReadLine();
                        }
                    }

                    Console.WriteLine();
                }
            }
            catch (Exception e)
            {
                Console.WriteLine("Error: " + e.Message);
                Console.ReadLine();
            }
        }
        else
        {
            Console.WriteLine("Invalid Port. ");
            goto invalidPort;
        }
    }

    private static void Client_MessageReceived(object sender, MessageReceivedEventArgs e)
    {
        Console.WriteLine("Received message from server: " + e.Message);
    }
}

My questions:

  1. Are event-based TCP protocols usable or feasible in real-world applications?
  2. Is this the best way I could have done this?
  3. Is there any code that should be added? Removed? Changed?
\$\endgroup\$
  • \$\begingroup\$ Comments are not for extended discussion; this conversation has been moved to chat. \$\endgroup\$ – Malachi Jun 18 '18 at 15:49
12
\$\begingroup\$

This won't be comprehensive review/ramble, because I'm hungry; however, it is nice to see fairly comprehensive inline documentation on the public API!

General Networking Commentary

Because you asked if there are better ways of doing this, here is some high-level discussion that doesn't relate so much to your precise code. I've written code like this dozens of times, but I'm no expert by any stretch.

Threading

You've done what we all do when we start doing networking: give each client a thread, and tell it to get on with it! This is often a lot of work (maintenance wise) and (fortunately?) not the most efficient way of writing a server. You're much better off using asynchronous callbacks (which are efficiently pinged off by the networking stack, rather than having each thread constantly polling).

If you want an event based system (which as far as I'm concerned is fine, but it may present difficulties with threading (e.g. if an event fires while you are in the middle of updating the game-state), which is partly why a lot of software doesn't do this) then the old NetworkStream.BeginRead(byte[], int, int) method could be the cleanest way of writing this, but you could also just refit your code which currents runs in its own thread with async calls to NetworkStream.ReadAsync(byte[], int, int). Both will be efficient and save you running dozens of threads, all competing for CPU time during which they will usually do nothing useful. Both, however, require that you switch from trying to read the stream one byte at a time, and instead process it in blocks...

Messages

I would strongly suggest prefixing messages with a length, rather than using start/end codes. Code like this in SendMessage(object) is never good enough (it should at least escape the forbidden content!):

var outContent = content.ToString()
    .Replace(TcpOptions.EndConnectionCode.ToString(), "")
    .Replace(TcpOptions.EndMessageCode.ToString(), "");

Since you are sending strings, you need only first send an integer length, and then the string; very simple. There are numerous benefits to this: you don't have code like the above (which is slow and dodgy), it significantly simplifies the listening code (just read a number, then read lots of bytes, none of that checking every byte in-case it is special business), and sets you up for adding more metadata (e.g. send the type of message, then the length, then the payload). The big downside is that you can't interrogate your stream with telnet anymore.

In terms of efficiency, a length-prefixed scheme enables you to dependably use the Read(byte[], int, int) method on your NetworkStream to read (up to) as many bytes as you need. Most of my networking code starts with an async ReadBytes(int count) method which does nothing but wait until count many bytes have been read so that I can process them in one go (e.g. read a message by reading 4 bytes for the length, then the right number of bytes for the payload). It's so much cleaner than scanning a byte stream, and saves you worrying about calling ReadByte() thousands of times, which is a performance concern which can only be solved by making block reads with Read(byte[], int, int) (and you can do a lot worse than reading each message as a block) or the other various asynchronous equivalents.

Personally I think it's also a bit untidy that you are taking an object and calling ToString() on it. If you are sending and deserialising strings, then I would make that clear in your API (i.e. take a string, not an object): the caller can call ToString() if it makes sense.

Edit: I forgot to mention Unicode: your current code strips out characters from a string, but you are delimitating messages based on bytes. Assuming you want to use more than ASCII (I'm assuming GetString() and GetBytes() use UTF-8 or UTF-16 (UTF-8 is the networking standard of sorts)), this is potentially problematic, depending on your choice of symbols and encoding (e.g. a multi-byte character might contain the single byte you are using to delimitate messages). If you do length-prefix strings, be sure to send the length in bytes (not chars) so that it is encoding independent, and that would solve the problem.

Responses

It's a neat idea, but looking for strings within arbitrary strings is always a bad idea: your 'special' strings can always appear in unexpected places. It is much better to have a formal protocol for recognised responses. Instead of sending a message as "just a string, it might contain fancy things", define it as a MessageType/Payload combination, and handle them separately.

If you have length-prefixed packets, and you can agree magic numbers between the server and client, you can give each important event a specific id (or set of tags), and transmit these before the payload. Though your current system is quite flexible, it is inherently dodgy and expensive: you have to query the message against every response type. If instead you gave the responses unique IDs, you could look up the appropriate event(s) in a Dictionary.

Server

It's a bit odd that you start listening for clients as soon as you construct the Server. This means it might receive messages before you've configured it with any responses. It also feels like most of what is in StartClientListening() should be the constructor (except the starting listening bit).

Relatedly, foreach (var response in Responses) is liable to crash if you try to modify the set of responses on a different thread. You can get around this by using a dedicated concurrent collection such as ConcurrentBag<T> which has a thread-safe enumerator.

Client

Again, you try to connect in the constructor of the client: this is just a bit unexpected, and the inline documentation for the constructors is not very helpful.

SendMessage(object content, bool a) has a second parameter, but is never used. You'd be better off giving this method a different name (or purging it, if you switch to length-prefixed messages).

The line MessageReceived?.Invoke(sender, e); looks like it should be outside the foreach loop. Currently, you are checking the message against each type of response, and firing MessageReceived for each one that does not trigger.

Other

ResponseEvent could be immutable: since everything else is so nicely encapsulated, I'm assuming though that you have a good reason for this design.

\$\endgroup\$
  • \$\begingroup\$ I quite like your usage of prefixing messages with a length. For some reason, that had never occurred to me. I instead ended each message with that EndMessageCode :P. I'll for sure use many of the ideas in your reply. You are also right about ResponseEvent being immutable; I didn't think about that either. I'll adjust a bunch of things to make it more thread safe for sure, including that Response foreach. \$\endgroup\$ – Noah Heber Jun 17 '18 at 20:01

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.