Our server won’t have any persistence layer, meaning:
no chat history
no backlogged messages going to offline users when they log on
you can only send messages to other online users
For simplicity’s sake, we’ll assign users integer ids (from a sequence) starting at 1.
Our API will be very simple:
users connect to ws://localhost:8080
you send a message in this format, using JSON
If you receive a message, it will be in this format
Now, this is JSON encoded as text. I realize websockets only support text and binary.
Router Approach
Of the two approaches that come to mind for this problem, the first one I’m going to call the “router” approach.
We’ll have one goroutine for receiving messages, one for sending them, and one for routing in between recipient and sender goroutines for different users.
Some notes about this approach:
There are 2n + 1 goroutines running, where n is the number of connections.
Only the go router.Run() goroutine has access to all the clients.
Even registering and de-registering a user occurs via channels.
The Router is potentially a bottleneck - 2 unrelated high flux conversations could cause latency for each other.
Let’s see how that stacks up against our second approach.
The User Directory Approach
In this approach, we get rid of the potential Router bottleneck.
Each ReceiveMessages goroutine can send data directly to the SendMessages goroutine of the recipient user. It uses a shared data structure, which I’m calling a “user directory”, to get the right channel.
However, can just we write directly to the connection of the correct user in the ReceiveMessages goroutine? Why an extra SendMessages goroutine?
That question actually applies to the previous example as well - “why not send data to the connection directly in the Router?”
Basically, because we’re breaking the contract of the library we’re using. Here’s the direct quote from the gorilla websocket docs:
Connections support one concurrent reader and one concurrent writer.
Applications are responsible for ensuring that no more than one goroutine calls the write methods (NextWriter, SetWriteDeadline, WriteMessage, WriteJSON, EnableWriteCompression, SetCompressionLevel) concurrently and that no more than one goroutine calls the read methods (NextReader, SetReadDeadline, ReadMessage, ReadJSON, SetPongHandler, SetPingHandler) concurrently.
So, we can’t let multipe goroutines write to a single connection. Now, we could have a single goroutine for sending and receiving called something like ManageConnection, with a select statement in a for loop, but I believe separating them is cleaner.
Some notes about this approach:
It uses 2n go routines, where n is the number of connections. As I’ve mentioned this could be n, if we collapsed sending and receiving messages into 1 go routine. This comment applies to the router approach as well.
We are using a RWMutex so the only point of contention should be if users are frequently entering and joining the chat server.
However, typical sending of messages does not suffer from a single bottleneck.
Comparison
The main difference between these two approaches is the bottleneck of the router. I’m not sure one is strictly superior to the other, because in order to get around the bottleneck of the router I introduced shared state, albeit shared state optimized for reading, which is the main use case.