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.