DEV Community

Cover image for Another simple TCP chat in C#? Why not
Luke Matt
Luke Matt

Posted on

Another simple TCP chat in C#? Why not

Does this low-latency TCP runtime:

  • let me build something in one evening instead of two days?
  • assume writing the code will take the whole day, but we finish the topic in 2 hours?
  • solve website refactoring problems without trying multiple approaches?

None of these things 🙂

Because obviously it's not an AI agent.
I had to start like this because posts without mentioning AI are kind of niche these days 😉


So what will be in the post?

Well - I created a library for client-server communication with, let's say, a bit of a mixed style. Something like actor-style, message-driven with RPC over TCP transport.

It was created mainly out of the need for simplicity and as little code as possible, but with an API as high-level as possible.

When using simple socket wrappers before, you usually have one receiving endpoint at your disposal, which is generally nice, but what next?

Once the message is ready, we have it in the form of bytes and now you can deal with it yourself...

Some libraries don't even frame the message, so assembling the data from the stream is additionally passed on to us.

Having worked with many client-server projects, I had a rough framework of what I expected.

Over time, the concept changed, even very often, but ultimately the library gives me what I expect (of course, for today 😄).


Moving on to the main topic - our simple chat

I set myself a challenge - console chat with as little code as possible.

I didn't fully achieve that 😄 but I'm still satisfied because this minimalist code offers quite good performance on the server side using simple techniques.

Project assumptions

  • global chat
  • each user automatically connects to the server and joins the chat

Without further ado, let's get to the best part: the code.


Server side

[EAttr(ChannelId = ChatSync)]
public static class MainChat
{
    [EAttrChannel(ChannelType = EChannelType.Share, ChannelTasks = 1)]
    public const ushort ChatSync = 1;

    [EAttrPool(MaxPoolObjs = 1000)]
    public const ushort MsgPool = 1;

    static List<byte> _message = [0, 0, 0, 0];
    static readonly EArrayBufferWriter _bufferMsg = new(5000);
    static readonly List<MyUserServer> _users = [];

    static long RegisterUser(MyUserServer user)
    {
        if (!_users.Contains(user))
        {
            _users.Add(user);
            return user.UserId;
        }
        return 0;
    }

    [EAttr(PoolId = MsgPool, MaxParamSize = 4096)]
    static void PushMsg(MyUserServer user, List<byte> msg)
    {
        _message.RemoveRange(4, _message.Count - 4);
        BinaryPrimitives.WriteInt32LittleEndian(CollectionsMarshal.AsSpan(_message), user.UserId);
        _message.AddRange(msg);

        if (user.ESerial.Serialize(_bufferMsg, _message) < 1)
            return;

        var payload = _bufferMsg.WrittenSpan;
        for (int i = 0; i < _users.Count; i++)
        {
            var u = _users[i];
            if (!u.SendSerialized("PushMsg", payload) && u.Status == ESocketServerStatus.Dead)
            {
                _users.RemoveAt(i);
                i--;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you noticed, we work on a static class (we don't need to create an instance if we only have one global room).

We also have two receiving methods.

When does a method become a receiver?

It is when it has the first argument inheriting from the EUserServer class.

This is a kind of contract - a method with: EUserServer and one optional parameter (message) becomes an endpoint.

In our example, the first parameter is the MyUserServer class, which looks like this:

public class MyUserServer : EUserServer
{
    static int _idValue;
    public int UserId { get; private set; }

    public MyUserServer(ESocketResourceServer ers) : base(ers) { }

    protected override void OnConnected()
    {
        UserId = Interlocked.Increment(ref _idValue);
    }
}
Enter fullscreen mode Exit fullscreen mode

Technically, we don't even really need the MyUserServer class here, but it helps assign a simple id to users after they connect.

Back to our main code.

The first thing that catches the eye is that these objects in MainChat:

static List<byte> _message = [0, 0, 0, 0];
static readonly EArrayBufferWriter _bufferMsg = new(5000);
static readonly List<MyUserServer> _users = [];
Enter fullscreen mode Exit fullscreen mode

are not thread-safe ... and in a sense this is true.

However, we use the attribute: ChatSync, which synchronizes all sockets to one thread thanks to the EChannelType.Share option and one task executing ChannelTasks = 1.

To illustrate: if we had EChannelType.Private set, each socket would call our access points on its own thread, breaking thread safety 😅

Fortunately, we can work with objects synchronously, allowing us to implement optimization techniques.

And there are several of them:

  • to reduce the pressure on GC, we use a reusable List<byte> msg object, which becomes reusable by setting PoolId = MsgPool in the attribute
  • we serialize once on one buffer and send the same bytes to everyone
  • the library uses SendSerialized, i.e. mixed sync/async sending, so that slow clients do not block the loop and thus do not create backpressure
  • disconnected clients are removed inline in a loop

In fact, that's all on the server side - the main logic fits in 45 lines.

And here, if anyone is still curious how we initialize the server:

var serv = new ETCPServer<MyUserServer>(new ERSA(PrivatePemKey, PrivatePemKeyToSign));
serv.Start(EAddress.Get());
Enter fullscreen mode Exit fullscreen mode

... yes, encryption is required.
I found no reason not to require it.


Client side

The client part is much simpler.

We don't need to worry too much about performance because, as a rule, each client has its own logical machine.

var client = new EUserClient(new ERSA(PublicPemKey, PublicPemKeyToSign));
if (await client.Connect(EAddress.Get()) == 0)
{
    long id = await client.SendWithResponse("RegisterUser");
    if (id > 0)
    {
        Console.WriteLine($"You have joined the chat room as User({id})");
        while (true)
        {
            var message = Console.ReadLine();

            var bytesMsg = Encoding.UTF8.GetBytes(message ?? "").ToList();
            if (bytesMsg.Count > 4000 || bytesMsg.Count < 1)
            {
                Console.WriteLine("Message length is out of range");
                continue;
            }

            if (!await client.Send("PushMsg", bytesMsg))
            {
                Console.WriteLine("Failed to send message");
                continue;
            }
        }
    }
    else
    {
        Console.WriteLine("Failed to join the room");
        Environment.Exit(0);
    }
}
else
{
    Console.WriteLine("Failed to connect to the server");
    Environment.Exit(0);
}
Enter fullscreen mode Exit fullscreen mode

In the usual way, we connect to the server, join the global room and can immediately write.

To make sure we joined successfully, we use SendWithResponse to guarantee a response.

Below is a simple class that receives messages from the server:

public static class MainChat
{
    [EAttr(PoolId = 1, MaxParamSize = 4096)]
    static void PushMsg(EUserClient user, List<byte> msg)
    {
        if (msg.Count <= 4)
            return;

        var spanMsg = CollectionsMarshal.AsSpan(msg);

        int userId = BinaryPrimitives.ReadInt32LittleEndian(spanMsg[..4]);
        string message = Encoding.UTF8.GetString(spanMsg[4..]);
        Console.WriteLine($"User({userId}): {message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

That's all, really.

We could have tried to reduce another dozen lines, but I don't think it's worth it. We would have to make too many compromises.

Even now, while typing in the console, we can get a message from the server appended to ours 😄

But you can add a solution to this problem yourself.


Repo

Link to my repo with complete example:

Github: EnjoySockets

There is also a small benchmark comparing 3 libraries to show roughly where EnjoySockets ranks in terms of performance.

There are plans for an even more extensive example, showing almost all aspects of the library, but that's for another post.


P.S. This library isn't AI, but it should speed up your work 😉

Thanks for reading and just enjoy it 😄

Top comments (0)