How to Build a Concurrent Chat App With Go and WebSockets
Just as Go makes programming such an application simple, Heroku makes it easy to supplement it with additional infrastructure.
Join the DZone community and get the full member experience.
Join For FreeGo emerged from Google out of a need to build highly performant applications using an easy-to-understand syntax. It's a statically typed, compiled language developed by some of C's innovators, without the programming burden of manual memory management. Primarily, it was designed to take advantage of modern multicore CPUs and networked machines.
In this post, I'll demonstrate the capabilities of Go. We'll take advantage of Go's ability to create concurrent apps to build a chat app easily. On the backend, we'll use Redis as the intermediary to accept messages from the browser and send them to the subscribed clients. On the frontend, we'll use WebSockets via socket.io to facilitate client-side communication. We'll deploy it all on Heroku, a PaaS provider that makes it easy to deploy and host your apps. Just as Go makes programming such an application simple, Heroku makes it easy to supplement it with additional infrastructure.
Channels in Go
What developers find appealing about Go is its ability to communicate concurrently, which it does through a system called channels. It's important to draw upon an oft-cited distinction between concurrency and parallelism. Parallelism is the process by which a CPU executes multiple tasks simultaneously. Simultaneously, concurrency is the CPU's ability to switch between multiple tasks, which start, run, and complete while overlapping one another. In other words, parallel programs handle many operations at once, while concurrent programs can switch between many operations over the same period of time.
A channel in Go is the conduit through which concurrency flows. Channels can be unidirectional—with data either sent to or received by them — or bidirectional, which can do both. Here's an example that demonstrates the basic principles of concurrency and channels:
xxxxxxxxxx
func one(c1 chan string) {
for i := 0; i < 5; i++ {
c1 <- "Channel One"
}
close(c1)
}
func two(c2 chan string) {
for i := 0; i < 5; i++ {
c2 <- "Channel Two"
}
close(c2)
}
func main() {
c1 := make(chan string)
c2 := make(chan string)
go one(c1)
go two(c2)
for {
select {
case msg, ok := <-c1:
fmt.Println(msg)
if !ok {
c1 = nil
}
case msg, ok := <-c2:
fmt.Println(msg)
if !ok {
c2 = nil
}
}
if c1 == nil && c2 == nil {
break
}
}
}
You can run this example online at the Go Playground to see the results. Channels are created by first specifying the data type they will communicate with — in this case, string. Two goroutines, one and two, accept each of these channels as an argument. Both then loop five times, passing a message to the channel, indicated by the <- glyph. Meanwhile, in the main function, an infinite loop waits for messages to come in from the channels. The select statement picks the channel which has a pending message, prints it, and moves on. If the channel was closed (which is important not just for memory management but also indicates that no more data will be sent), the channel is set to nil; when both channels are nil, the loop breaks.
In essence, a receiver is waiting endlessly to receive packets of data. When it receives the data, it acts upon it, then continues to wait for more messages. These receivers operate concurrently, without interrupting the rest of the program's flow. For this chat application, we will wait for a user to send a message to a receiver over a channel. When the message is received, the app will broadcast it to the front end. Everyone sitting in chat can read the text.
Prerequisites
You should have a relatively recent version of Golang installed; anything past 1.12 will do. Create a directory in your GOPATH called heroku_chat_sample. If you'd like to run the code locally, you can also install and run a Redis server—but this is definitely not required, as a Heroku add-on will provide this for us in production.
Building a Simple Server
Let's start with a quick and easy "Hello World" server to verify that we can run Go programs. We'll start by fetching Gorilla, a web toolkit that simplifies the process of writing HTTP servers:
go get -u github.com/gorilla/mux
Next, create a file called main. Go, and paste these lines into it:
xxxxxxxxxx
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
r := mux.NewRouter()
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, world!")
})
log.Print("Server starting at localhost:4444")
http.ListenAndServe(":4444", r)
}
Finally, enter run main. Go to your terminal. You should be able to visit localhost:4444 in the browser and see the greeting. With just these few lines, we can better understand how to create routes using Gorilla.
But the static text is boring, right? Let's have this server show an HTML file. Create a directory called public, and within that, create a file called index.html that looks like this:
xxxxxxxxxx
<html>
<head>
<title>Go Chat!</title>
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<div class="container">
<div class="jumbotron">
<h1>Go Chat!</h1>
</div>
<form id="input-form" class="form-inline">
<div class="form-group">
<input
id="input-username"
type="text"
class="form-control"
placeholder="Enter username"
/>
</div>
<div class="form-group">
<input
id="input-text"
type="text"
class="form-control"
placeholder="Enter chat text here"
/>
</div>
<button class="btn btn-primary" type="submit">Send</button>
</form>
<div id="chat-text"></div>
</div>
</body>
</html>
There's some JavaScript necessary for this page to communicate with the server; let's create a placeholder app.js file now:
xxxxxxxxxx
window.addEventListener('DOMContentLoaded', (_) => {
form.addEventListener("submit", function (event) {
event.preventDefault();
let username = document.getElementById("input-username");
let text = document.getElementById("input-text");
text.value = "";
});
});
Then, let's change our server code to look like this:
xxxxxxxxxx
package main
import (
"log"
"net/http"
)
func main() {
http.Handle("/", http.FileServer(http.Dir("./public")))
log.Print("Server starting at localhost:4444")
if err := http.ListenAndServe(":4444", nil); err != nil {
log.Fatal(err)
}
}
If you restart the server and head back to localhost:4444, you should see a page inviting you to chat. It won't do much yet, but it's a start!
Let's make one more minor change to see this app on the way to becoming a twelve-factor app: store our port number in an environment variable. This won't be hugely important right now in development, but it will make a difference when deploying the app to production.
Create a file called .env and paste this line into it:
PORT=4444
Then, fetch the godotenv modules:
get github.com/joho/godotenv
And lastly, let's change the server code one more time to accept this environment variable:
x
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
port := os.Getenv("PORT")
// Same code as before
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
In short, so long as GO_ENV is empty, we will load our environment variables from whatever is defined locally in .env. Otherwise, the app expects the system's environment variables, which we will do when the time comes.
Establishing Communication Using WebSockets and Redis
Websockets are a useful technique to pass messages from the client/browser to the server. It will be the fundamental technology used to send and receive chat messages from all the users in our chat room. On the backend, we will use Redis to store the chat history so that any new user can instantly get all of the room's previous messages. Redis is an in-memory database, which is often used for caching. We don't need the heft of a relational database for this project, but we do want some kind of storage system to keep track of users and their messages.
Setting Up Redis
To start with, let's prepare to introduce Redis as a dependency. If you have Redis running locally, you'll need to add a new line to specify the host and port of your Redis instance in your .env file:
REDIS_URL=127.0.0.1:6379
Grab the Redis module as a dependency from GitHub:
go get -u github.com/gomodule/redigo/redis
We'll set up our Redis client as a global variable to make life easier:
var (
rdb *redis.Client
)
Then, in our main() function, we will create an instance of this client via the environment variable:
xxxxxxxxxx
redisURL := os.Getenv("REDIS_URL")
opt, err := redis.ParseURL(redisURL)
if err != nil {
panic(err)
}
rdb = redis.NewClient(opt)
We're using environment variables here because the server address is likely to be different than the one we use in development, but we don't want to hardcode those values. If you don't have a Redis server running locally, don't worry — you can still follow along in the tutorial and see the result in your browser live after we publish the app to Heroku.
When the server starts up, it'll connect to Redis first before listening for any incoming connections.
Setting Up WebSockets
Configuring our websockets is a little bit trickier, particularly because we need to jump into some JavaScript code to finish wiring that up. However, before we get there, let's take a step back and remember what we're trying to do. A user will visit a webpage, assign themselves a username, and send messages in a chat room. It's fair to say that the smallest chunk of data would be the user's name and message. Let's set up a data structure in Go that captures this:
xxxxxxxxxx
type ChatMessage struct {
Username string`json:"username"`
Text string`json:"text"`
}
Since we're going to be communicating with the frontend, it's useful to prepare to think about this structure in terms of how it will be represented in JSON.
Next, let's add two more lines of functionality to our web server in main(). The first line will indicate which function we want to run whenever a new WebSocket connection is opened—in other words, whenever a new user joins. The second line will set up a long-running goroutine which decides what to do whenever a user sends a message:
xxxxxxxxxx
http.HandleFunc("/websocket", handleConnections)
go handleMessages()
Last, let's jump back to the top of the file and add some global variables. We'll explain what they're for after the code:
xxxxxxxxxx
var clients = make(map[*websocket.Conn]bool)
var broadcaster = make(chan ChatMessage)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
returntrue
},
}
In these lines:
clients
is a list of all the currently active clients (or open WebSockets)broadcaster
is a single channel which is responsible for sending and receiving our ChatMessage data structureupgrader
is a bit of a clunker; it's necessary to "upgrade" Gorilla's incoming requests into a WebSocket connection
Sending a Message
Let's start building out handleConnections first. When a new user joins the chat, three things should happen:
- They should be set up to receive messages from other clients.
- They should be able to send their own messages.
- They should receive a full history of the previous chat (backed by Redis).
Addressing number one is simple with Gorilla. We'll create a new client and append it to our global client's list in just a few lines:
xxxxxxxxxx
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Fatal(err)
}
// ensure connection close when function returns
defer ws.Close()
clients[ws] = true
Let's look at sending messages next instead:
xxxxxxxxxx
for {
var msg ChatMessage
// Read in a new message as JSON and map it to a Message object
err := ws.ReadJSON(&msg)
if err != nil {
delete(clients, ws)
break
}
// send new message to the channel
broadcaster <- msg
}
After a client WebSocket is opened and added to the client's pool, an infinite loop will run endlessly.
Unlike other languages, infinite loops are practically encouraged in Go. The trick is to remember to break out of them and to clean up after yourself when you do. Here, the WebSocket is just endlessly looking for messages that the client has sent: ws.ReadJSON(&msg) is checking to see if msg is populated. If msg is ever not nil, it'll send the message over to the broadcaster channel. That's pretty much it as far as sending messages goes. If this WebSocket has an issue afterward, it'll remove itself from the client pool--delete(clients, ws), and then break out of this loop, severing its connection.
What happens when a msg is sent to the broadcaster channel? That's where handleMessages comes in.
Receiving Messages
It's the responsibility of handleMessages to send any new messages to every connected client. Just like the sending of messages, it all starts with an infinite for loop:
xxxxxxxxxx
func handleMessages() {
for {
// grab any next message from channel
msg := <-broadcaster
}
}
This line does nothing until something is sent to the channel. This is the core of goroutines, concurrency, and channels. Concurrency depends on channels to communicate with one another. If there's no data being sent, there's nothing to reason about or workaround. When a msg is received, we can send it to all the open clients:
xxxxxxxxxx
for client := range clients {
err := client.WriteJSON(msg)
if err != nil && unsafeError(err) {
log.Printf("error: %v", err)
client.Close()
delete(clients, client)
}
}
We iterate over every client using the range operator; for each client, instead of reading JSON, we're writing it back out. Again, what comes after this is handled on the JavaScript side of things. If there's an issue with this write, we'll print a message, close the client, and remove it from the global list.
Saving and Restoring History
But what about our final feature, which requires that every new client has access to the full chat history? We'll need to use Redis for that, and in particular, two operations:
- Any new message should be added to a list of running messages.
- Any new user should receive that full list.
When sending new messages, we can store them as a list in Redis using RPUSH:
rdb.RPush("chat_messages", json)
When a new user joins, we can send the entire list at once using LRANGE:
xxxxxxxxxx
chatMessages, err := rdb.LRange("chat_messages", 0, -1).Result()
if err != nil {
panic(err)
}
This application is a bit tricky because we need to send all the messages to a single client. However, we can assume that only new connections call handleConnections, and at any point before the infinite for loop, we can communicate to this client and send them our messages. Our code would look something like this:
xxxxxxxxxx
// send previous messages
for _, chatMessage := range chatMessages {
var msg ChatMessage
json.Unmarshal([]byte(chatMessage), &msg)
err := client.WriteJSON(msg)
if err != nil && unsafeError(err) {
log.Printf("error: %v", err)
client.Close()
delete(clients, client)
}
}
Full Backend Code
He's what our complete Go code would look like:
https://gist.github.com/gjtorikian/8894dec140a6e57934572f5b447f6d51
The Frontend
Since this post focuses on Go and Heroku, we won't go into many details about the JavaScript code. However, it's only about 25 lines, so there's not much to go into!
Our previous index.html can stay the same. Let's replace the contents of app.js with the following:
xxxxxxxxxx
$(function () {
let websocket = newWebSocket("wss://" + window.location.host + "/websocket");
let room = $("#chat-text");
websocket.addEventListener("message", function (e) {
let data = JSON.parse(e.data);
let chatContent = `<p><strong>${data.username}</strong>: ${data.text}</p>`;
room.append(chatContent);
room.scrollTop = room.scrollHeight; // Auto scroll to the bottom
});
$("#input-form").on("submit", function (event) {
event.preventDefault();
let username = $("#input-username")[0].value;
let text = $("#input-text")[0].value;
websocket.send(
JSON.stringify({
username: username,
text: text,
})
);
$("#input-text")[0].value = "";
});
});
Let's break this down into chunks. The first lines (let websocket and let room) set up some global variables we can use later on.
websocket.addEventListener is responsible for handling any new messages the client receives. In other words, it's the frontend code corresponding to handleMessages. When handleMessages writes JSON, it sends it as an event called message. From there, the JavaScript can parse the data out, style it a bit, and append the text to the chat room.
Similarly, the form logic sends data using WebSockets to our previous ws.ReadJSON line. Whenever the form is submitted, the JavaScript takes note of who said something and what they said. It then sends the message to the WebSocket so that the Go code can store it in Redis and notify all the clients.
Deploying to Heroku
You're now ready to deploy this app to Heroku! If you don't have one already, be sure to create a free account on Heroku, then install the Heroku CLI, which makes creating apps and attaching add-ons much easier.
First, log into your account:
heroku login
Next, let's create a new app using create:
heroku create
You'll be assigned a random name; I've got evening-wave-98825, so I'll be referring to that here.
Next, create a Procfile. A Procfile specifies which commands to run when your app boots up and set up any workers.
Ours will be a single line:
web: bin/heroku_chat_sample
Since we need Redis, we can attach the free instance for our demo app:
heroku addons:create heroku-redis:hobby-dev -a evening-wave-98825
Let's build the app, and commit everything we have:
xxxxxxxxxx
go mod init
go mod vendor
go build -o bin/heroku_chat_sample -v .
git init
git add .
git commit -m "First commit of chat app"
And let's send it all to Heroku:
heroku git:remote -a evening-wave-98825
git push heroku main
This process is all you need to deploy everything into production. If you visit the URL Heroku generated for you; you should see your chat app. It may look basic, but there's a lot going on behind the scenes!
You can download all of the code used in this article here.
More Information
If you enjoyed how easy it was to deploy a Go app with Redis onto Heroku, this is just the beginning! Here's another tutorial on building something exciting with Go. If you'd like to know more about how Go works with Heroku, here's another article with all the details.
Opinions expressed by DZone contributors are their own.
Comments