Modeling a chat room and clients on the server
All users (clients) of our chat application will automatically be placed in one big public room where everyone can chat with everyone else. The room
type will be responsible for managing client connections and routing messages in and out, while the client
type represents the connection to a single client.
Tip
Go refers to classes as types and instances of those classes as objects.
To manage our web sockets, we are going to use one of the most powerful aspects of the Go community open source third-party packages. Every day, new packages solving real-world problems are released, ready for you to use in your own projects, and they even allow you to add features, report and fix bugs, and get support.
Tip
It is often unwise to reinvent the wheel unless you have a very good reason. So before embarking on building a new package, it is worth searching for any existing projects that might have already solved your very problem. If you find one similar project that doesn't quite satisfy your needs, consider contributing to the project and adding features. Go has a particularly active open source community (remember that Go itself is open source) that is always ready to welcome new faces or avatars.
We are going to use Gorilla Project's websocket
package to handle our server-side sockets rather than write our own. If you're curious about how it works, head over to the project home page on GitHub, https://github.com/gorilla/websocket, and browse the open source code.
Modeling the client
Create a new file called client.go
alongside main.go
in the chat
folder and add the following code:
package main import ( "github.com/gorilla/websocket" ) // client represents a single chatting user. type client struct { // socket is the web socket for this client. socket *websocket.Conn // send is a channel on which messages are sent. send chan []byte // room is the room this client is chatting in. room *room }
In the preceding code, socket
will hold a reference to the web socket that will allow us to communicate with the client, and the send
field is a buffered channel through which received messages are queued ready to be forwarded to the user's browser (via the socket). The room
field will keep a reference to the room that the client is chatting in this is required so that we can forward messages to everyone else in the room.
If you try to build this code, you will notice a few errors. You must ensure that you have called go get
to retrieve the websocket
package, which is as easy as opening a terminal and typing the following:
go get github.com/gorilla/websocket
Building the code again will yield another error:
./client.go:17 undefined: room
The problem is that we have referred to a room
type without defining it anywhere. To make the compiler happy, create a file called room.go
and insert the following placeholder code:
package main type room struct { // forward is a channel that holds incoming messages // that should be forwarded to the other clients. forward chan []byte }
We will improve this definition later once we know a little more about what our room needs to do, but for now, this will allow us to proceed. Later, the forward
channel is what we will use to send the incoming messages to all other clients.
Note
You can think of channels as an in-memory thread-safe message queue where senders pass data and receivers read data in a non-blocking, thread-safe way.
In order for a client to do any work, we must define some methods that will do the actual reading and writing to and from the web socket. Adding the following code to client.go
outside (underneath) the client
struct will add two methods called read
and write
to the client
type:
func (c *client) read() { defer c.socket.Close() for { _, msg, err := c.socket.ReadMessage() if err != nil { return } c.room.forward <- msg } } func (c *client) write() { defer c.socket.Close() for msg := range c.send { err := c.socket.WriteMessage(websocket.TextMessage, msg) if err != nil { return } } }
The read
method allows our client to read from the socket via the ReadMessage
method, continually sending any received messages to the forward
channel on the room
type. If it encounters an error (such as 'the socket has died'
), the loop will break and the socket will be closed. Similarly, the write
method continually accepts messages from the send
channel writing everything out of the socket via the WriteMessage
method. If writing to the socket fails, the for
loop is broken and the socket is closed. Build the package again to ensure everything compiles.
Note
In the preceding code, we introduced the defer
keyword, which is worth exploring a little. We are asking Go to run c.socket.Close()
when the function exits. It's extremely useful for when you need to do some tidying up in a function (such as closing a file or, as in our case, a socket) but aren't sure where the function will exit. As our code grows, if this function has multiple return
statements, we won't need to add any more calls to close the socket, because this single defer
statement will catch them all.
Some people complain about the performance of using the defer
keyword, since it doesn't perform as well as typing the close
statement before every exit point in the function. You must weigh up the runtime performance cost against the code maintenance cost and potential bugs that may get introduced if you decide not to use defer. As a general rule of thumb, writing clean and clear code wins; after all, we can always come back and optimize any bits of code we feel is slowing our product down if we are lucky enough to have such success.
Modeling a room
We need a way for clients to join and leave rooms in order to ensure that the c.room.forward <- msg
code in the preceding section actually forwards the message to all the clients. To ensure that we are not trying to access the same data at the same time, a sensible approach is to use two channels: one that will add a client to the room and another that will remove it. Let's update our room.go
code to look like this:
package main type room struct { // forward is a channel that holds incoming messages // that should be forwarded to the other clients. forward chan []byte // join is a channel for clients wishing to join the room. join chan *client // leave is a channel for clients wishing to leave the room. leave chan *client // clients holds all current clients in this room. clients map[*client]bool }
We have added three fields: two channels and a map. The join
and leave
channels exist simply to allow us to safely add and remove clients from the clients
map. If we were to access the map directly, it is possible that two goroutines running concurrently might try to modify the map at the same time, resulting in corrupt memory or unpredictable state.
Concurrency programming using idiomatic Go
Now we get to use an extremely powerful feature of Go's concurrency offerings the select
statement. We can use select
statements whenever we need to synchronize or modify shared memory, or take different actions depending on the various activities within our channels.
Beneath the room
structure, add the following run
method that contains three select
cases:
func (r *room) run() { for { select { case client := <-r.join: // joining r.clients[client] = true case client := <-r.leave: // leaving delete(r.clients, client) close(client.send) case msg := <-r.forward: // forward message to all clients for client := range r.clients { client.send <- msg } } } }
Although this might seem like a lot of code to digest, once we break it down a little, we will see that it is fairly simple, although extremely powerful. The top for
loop indicates that this method will run forever, until the program is terminated. This might seem like a mistake, but remember, if we run this code as a goroutine, it will run in the background, which won't block the rest of our application. The preceding code will keep watching the three channels inside our room: join
, leave
, and forward
. If a message is received on any of those channels, the select
statement will run the code for that particular case.
Note
It is important to remember that it will only run one block of case code at a time. This is how we are able to synchronize to ensure that our r.clients
map is only ever modified by one thing at a time.
If we receive a message on the join
channel, we simply update the r.clients
map to keep a reference of the client that has joined the room. Notice that we are setting the value to true
. We are using the map more like a slice, but do not have to worry about shrinking the slice as clients come and go through time setting the value to true
is just a handy, low-memory way of storing the reference.
If we receive a message on the leave
channel, we simply delete the client
type from the map, and close its send
channel. If we receive a message on the forward
channel, we iterate over all the clients and add the message to each client's send
channel. Then, the write
method of our client type will pick it up and send it down the socket to the browser.
Turning a room into an HTTP handler
Now we are going to turn our room
type into an http.Handler
type like we did with the template handler earlier. As you will recall, to do this, we must simply add a method called ServeHTTP
with the appropriate signature.
Add the following code to the bottom of the room.go
file:
const ( socketBufferSize = 1024 messageBufferSize = 256 ) var upgrader = &websocket.Upgrader{ReadBufferSize: socketBufferSize, WriteBufferSize: socketBufferSize} func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) { socket, err := upgrader.Upgrade(w, req, nil) if err != nil { log.Fatal("ServeHTTP:", err) return } client := &client{ socket: socket, send: make(chan []byte, messageBufferSize), room: r, } r.join <- client defer func() { r.leave <- client }() go client.write() client.read() }
The ServeHTTP
method means a room can now act as a handler. We will implement it shortly, but first let's have a look at what is going on in this snippet of code.
Tip
If you accessed the chat endpoint in a web browser, you would likely crash the program and see an error like ServeHTTPwebsocket: version != 13. This is because it is intended to be accessed via a web socket rather than a web browser.
In order to use web sockets, we must upgrade the HTTP connection using the websocket.Upgrader
type, which is reusable so we need only create one. Then, when a request comes in via the ServeHTTP
method, we get the socket by calling the upgrader.Upgrade
method. All being well, we then create our client and pass it into the join
channel for the current room. We also defer the leaving operation for when the client is finished, which will ensure everything is tidied up after a user goes away.
The write
method for the client is then called as a goroutine, as indicated by the three characters at the beginning of the line go
(the word go
followed by a space character). This tells Go to run the method in a different thread or goroutine.
Note
Compare the amount of code needed to achieve multithreading or concurrency in other languages with the three key presses that achieve it in Go, and you will see why it has become a favorite among system developers.
Finally, we call the read
method in the main thread, which will block operations (keeping the connection alive) until it's time to close it. Adding constants at the top of the snippet is a good practice for declaring values that would otherwise be hardcoded throughout the project. As these grow in number, you might consider putting them in a file of their own, or at least at the top of their respective files so they remain easy to read and modify.
Using helper functions to remove complexity
Our room is almost ready to go, although in order for it to be of any use, the channels and map need to be created. As it is, this could be achieved by asking the developer to use the following code to be sure to do this:
r := &room{ forward: make(chan []byte), join: make(chan *client), leave: make(chan *client), clients: make(map[*client]bool), }
Another, slightly more elegant, solution is to instead provide a newRoom
function that does this for us. This removes the need for others to know about exactly what needs to be done in order for our room to be useful. Underneath the type room struct
definition, add this function:
// newRoom makes a new room. func newRoom() *room { return &room{ forward: make(chan []byte), join: make(chan *client), leave: make(chan *client), clients: make(map[*client]bool), } }
Now the users of our code need only call the newRoom
function instead of the more verbose six lines of code.
Creating and using rooms
Let's update our main
function in main.go
to first create and then run a room for everybody to connect to:
func main() { r := newRoom() http.Handle("/", &templateHandler{filename: "chat.html"}) http.Handle("/room", r) // get the room going go r.run() // start the web server if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal("ListenAndServe:", err) } }
We are running the room in a separate goroutine (notice the go
keyword again) so that the chatting operations occur in the background, allowing our main goroutine to run the web server. Our server is now finished and successfully built, but remains useless without clients to interact with.