How to Build a Concurrent Chat App with Go and Websockets

mbogan

Michael Bogan

Posted on November 30, 2020

How to Build a Concurrent Chat App with Go and Websockets

Go 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 the innovators of C, 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 easily create concurrent apps to build a chat app. 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 the 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 at the same time, while 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:

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can run this example online at the Go Playground to see the results. Channels are created by first specifying the data type which they will communicated with—in this case, string. Two goroutines, oneand two, accept each of these channels as an argument. Both then loop five times, passing a message to the channel, which is indicated by the &lt;- glyph. Meanwhile, in the mainfunction, an infinite for loop, waits for messages to come in from the channels. The selectstatement 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 to also indicate 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 frontend, so that 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
Enter fullscreen mode Exit fullscreen mode

Next, create a file called main.go, and paste these lines into it:

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)
}
Enter fullscreen mode Exit fullscreen mode

Finally, enter go run main.go in your terminal. You should be able to visit localhost:4444 in the browser, and see the greeting. With just these few lines, we can get a better sense of how to create routes using Gorilla.

But 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:

<!DOCTYPE html>
<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>
<script type="text/javascript" src="app.js"></script>
</html>
Enter fullscreen mode Exit fullscreen mode

There's some JavaScript necessary for this page to communicate with the server; let's create a placeholder app.js file now:

window.addEventListener('DOMContentLoaded', (_) => {
  form.addEventListener("submit", function (event) {
     event.preventDefault();
     let username = document.getElementById("input-username");
     let text = document.getElementById("input-text");
     text.value = "";
  });
});
Enter fullscreen mode Exit fullscreen mode

Then, let's change our server code to look like this:

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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!

golang1

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 we deploy the app to production.

Create a file called .env and paste this line into it:

PORT=4444
Enter fullscreen mode Exit fullscreen mode

Then, fetch the godotenv modules:

go get github.com/joho/godotenv
Enter fullscreen mode Exit fullscreen mode

And lastly, let's change the server code one more time to accept this environment variable:

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)
}
Enter fullscreen mode Exit fullscreen mode

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 environment variables to be set by the system, 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. For this project, we don't need the heft of a relational database, 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
Enter fullscreen mode Exit fullscreen mode

Grab the Redis module as a dependency from GitHub:

go get -u github.com/gomodule/redigo/redis
Enter fullscreen mode Exit fullscreen mode

We'll set up our Redis client as a global variable to make life easier:

var (
  rdb *redis.Client
)
Enter fullscreen mode Exit fullscreen mode

Then, in our main() function, we will create an instance of this client via the environment variable:

redisURL := os.Getenv("REDIS_URL")
opt, err := redis.ParseURL(redisURL)
if err != nil {
  panic(err)
}
rdb = redis.NewClient(opt)
Enter fullscreen mode Exit fullscreen mode

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 their message. Let's set up a data structure in Go that captures this:

type ChatMessage struct {
  Username string`json:"username"`
  Text     string`json:"text"`
}
Enter fullscreen mode Exit fullscreen mode

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:

http.HandleFunc("/websocket", handleConnections)
go handleMessages()
Enter fullscreen mode Exit fullscreen mode

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:

var clients = make(map[*websocket.Conn]bool)
var broadcaster = make(chan ChatMessage)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
  returntrue
},
}
Enter fullscreen mode Exit fullscreen mode

In these lines:

  • clients is a list of all the currently active clients (or open websockets)
  • broadcasteris a single channel which is responsible for sending and receiving our ChatMessagedata structure
  • upgraderis 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 handleConnectionsfirst. When a new user joins the chat, three things should happen:

  1. They should be set up to receive messages from other clients.
  2. They should be able to send their own messages.
  3. 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 clientslist in just a few lines:

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
Enter fullscreen mode Exit fullscreen mode

Let's look at sending messages next instead:

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
}
Enter fullscreen mode Exit fullscreen mode

After a client websocket is opened and added to the clientspool, an infinite for 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 msgis populated. If msgis ever not nil, it'll send the message over to the broadcasterchannel. That's pretty much it as far as sending messages goes. If this websocket has an issue afterwards, it'll remove itself from the clients pool--delete(clients, ws), and then break out of this loop, severing its connection.

What happens when a msgis sent to the broadcasterchannel? That's where handleMessagescomes in.

Receiving Messages

It's the responsibility of handleMessagesto send any new messages to every connected client. Just like the sending of messages, it all starts with an infinite for loop:

func handleMessages() {
  for {
    // grab any next message from channel
    msg := <-broadcaster
  }
}
Enter fullscreen mode Exit fullscreen mode

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 work around. When a msgis received, we can send it to all the open clients:

for client := range clients {
   err := client.WriteJSON(msg)
   if err != nil && unsafeError(err) {
     log.Printf("error: %v", err)
     client.Close()
     delete(clients, client)
   }
}
Enter fullscreen mode Exit fullscreen mode

We iterate over every client using the rangeoperator; 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:

  1. Any new message should be added to a list of running messages.
  2. 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)
Enter fullscreen mode Exit fullscreen mode

When a new user joins, we can send the entire list at once using LRANGE:

chatMessages, err := rdb.LRange("chat_messages", 0, -1).Result()
if err != nil {
  panic(err)
}
Enter fullscreen mode Exit fullscreen mode

This application is a bit tricky, because we need to send all the messages to just 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:

// 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)
   }
}
Enter fullscreen mode Exit fullscreen mode

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:

$(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
  });
Enter fullscreen mode Exit fullscreen mode

$("#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 = "";
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's break this down into chunks. The first lines (let websocket and let room) just 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 handleMessageswrites 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. Any time 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
Enter fullscreen mode Exit fullscreen mode

Next, let's create a new app using create:

heroku create
Enter fullscreen mode Exit fullscreen mode

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, as well as setting up any workers.

Ours will be a single line:

web: bin/heroku_chat_sample
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's build the app, and commit everything we have:

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"
Enter fullscreen mode Exit fullscreen mode

And let's send it all to Heroku:

heroku git:remote -a evening-wave-98825
git push heroku main
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
mbogan
Michael Bogan

Posted on November 30, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related