Using WebSockets on Heroku with Go
Last updated July 12, 2024
Table of Contents
This tutorial will get you going with a Go application that uses a WebSocket, deployed to Heroku.
Sample code for the demo application is available on GitHub. Edits and enhancements are welcome. Just fork the repository, make your changes and send us a pull request.
Prerequisites
- Go, Git, Godep and the Heroku client (as described in Getting Started with Go).
- A Heroku user account. Signup is free and instant.
Create WebSocket app
The sample application provides a simple example of using WebSockets with Go. Get the sample app and follow along with the code as you read.
Get the sample app
$ go get github.com/heroku-examples/go-websocket-chat-demo/...
$ cd $GOPATH/src/github.com/heroku-examples/go-websocket-chat-demo
Functionality
The sample application is a simple chat application that will open a WebSocket to the back-end. Any time a chat message is sent from the browser, it’s sent to the server and then published to a Redis channel. A separate goroutine is subscribed to the same Redis channel and broadcasts the received message to all open WebSocket connections.
There are a few key pieces to this example:
- The Gorilla WebSocket library which provides a complete WebSocket implementation.
- The Logrus library which provides structured, pluggable logging.
- The redigo library which provides an interface to Redis.
- JavaScript on the browser that opens a WebSocket connection to the server and responds to a WebSocket message received from the server.
Let’s take a look at both the back-end and front-end pieces in more detail.
Back-end
With Gorilla’s WebSocket library, we can create WebSocket handlers, much like standard http.Handler
s. In this case, we’ll have one endpoint that handles sending and receiving messages named handleWebsocket
. A websocket.Upgrader
needs to be used to upgrade incoming requests to WebSocket connections. The endpoint is used for both submitting new messages to and receiving messages from the chat service. Incoming messages are received via ws.ReadMessage()
as Go byte slices. We take those messages, validate them and insert them into our Redis subscription channel, so all connected servers can receive updates.
// handleWebsocket connection.
func handleWebsocket(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
m := "Unable to upgrade to websockets"
log.WithField("err", err).Println(m)
http.Error(w, m, http.StatusBadRequest)
return
}
id := rr.register(ws)
for {
mt, data, err := ws.ReadMessage()
l := log.WithFields(logrus.Fields{"mt": mt, "data": data, "err": err})
if err != nil {
if err == io.EOF {
l.Info("Websocket closed!")
} else {
l.Error("Error reading websocket message")
}
break
}
switch mt {
case websocket.TextMessage:
msg, err := validateMessage(data)
if err != nil {
l.WithFields(logrus.Fields{"msg": msg, "err": err}).Error("Invalid Message")
break
}
rw.publish(data)
default:
l.Warning("Unknown Message!")
}
}
rr.deRegister(id)
ws.WriteMessage(websocket.CloseMessage, []byte{})
}
Messages received from Redis are broadcast to all WebSocket connections by the redisReceiver
s run()
func.
func (rr *redisReceiver) run() error {
l := log.WithField("channel", Channel)
conn := rr.pool.Get()
defer conn.Close()
psc := redis.PubSubConn{Conn: conn}
psc.Subscribe(Channel)
for {
switch v := psc.Receive().(type) {
case redis.Message:
l.WithField("message", string(v.Data)).Info("Redis Message Received")
if _, err := validateMessage(v.Data); err != nil {
l.WithField("err", err).Error("Error unmarshalling message from Redis")
continue
}
rr.broadcast(v.Data)
case redis.Subscription:
l.WithFields(logrus.Fields{
"kind": v.Kind,
"count": v.Count,
}).Println("Redis Subscription Received")
case error:
return errors.Wrap(v, "Error while subscribed to Redis channel")
default:
l.WithField("v", v).Info("Unknown Redis receive during subscription")
}
}
}
Because of this architectural model, you can run this application on as many dynos as you want and all of your users will be able to send and receive updates.
In the main()
func we create a redisReceiver
and redisWritter
and run()
them to handle our Redis interactions. We register the handleWebsocket
handler at /ws
. The public
directory will be served by http.FileServer
out of public/
. We take special care to handle availability of the redis server, which may not be available on boot and may be restarted as part of a maintenance.
func main() {
port := os.Getenv("PORT")
if port == "" {
log.WithField("PORT", port).Fatal("$PORT must be set")
}
redisURL := os.Getenv("REDIS_URL")
redisPool, err := redis.NewRedisPoolFromURL(redisURL)
if err != nil {
log.WithField("url", redisURL).Fatal("Unable to create Redis pool")
}
rr = newRedisReceiver(redisPool)
rw = newRedisWriter(redisPool)
go func() {
for {
waited, err := redis.WaitForAvailability(redisURL, waitTimeout, rr.wait)
if !waited || err != nil {
log.WithFields(logrus.Fields{"waitTimeout": waitTimeout, "err": err}).Fatal("Redis not available by timeout!")
}
rr.broadcast(availableMessage)
err = rr.run()
if err == nil {
break
}
log.Error(err)
}
}()
go func() {
for {
waited, err := redis.WaitForAvailability(redisURL, waitTimeout, nil)
if !waited || err != nil {
log.WithFields(logrus.Fields{"waitTimeout": waitTimeout, "err": err}).Fatal("Redis not available by timeout!")
}
err = rw.run()
if err == nil {
break
}
log.Error(err)
}
}()
http.Handle("/", http.FileServer(http.Dir("./public")))
http.HandleFunc("/ws", handleWebsocket)
log.Println(http.ListenAndServe(":"+port, nil))
}
Front-end
The second part of this is setting up the client side to open the WebSockets connection with the server.
The index page uses Bootstrap for CSS and
jQuery. We store all of our static assets in the public
folder.
The main WebSocket interaction happens in public/js/application.js
, which is
loaded by the main page. It opens our WebSocket connection to the server.
We use reconnecting-websocket,
which automatically reconnects any broken connection in the browser.
With an open WebSocket, the browser will receive messages and we define a function to handle this.
box.onmessage = function(message) {
var data = JSON.parse(message.data);
$("#chat-text").append("<div class='panel panel-default'><div class='panel-heading'>" + $('<span/>').text(data.handle).html() + "</div><div class='panel-body'>" + $('<span/>').text(data.text).html() + "</div></div>");
$("#chat-text").stop().animate({
scrollTop: $('#chat-text')[0].scrollHeight
}, 800);
};
The messages we’re using will be a JSON response with two keys: handle
(user’s handle) and text
(user’s message). When the message is received, it is parsed by JSON and inserted as a new entry in the page.
We override how the submit button works in our input form by using event.preventDefault()
to stop the form from actually sending a POST. Instead, we grab the the values from the form and send them as a JSON message over the WebSocket to the server.
$("#input-form").on("submit", function(event) {
event.preventDefault();
var handle = $("#input-handle")[0].value;
var text = $("#input-text")[0].value;
box.send(JSON.stringify({ handle: handle, text: text }));
$("#input-text")[0].value = "";
});
Run locally
The sample application already contains a Procfile, which declares the web
process:
web: go-websocket-chat-demo
We need to compile and install the executable into $GOPATH/bin
:
$ go install -v
The Heroku CLI comes with the Heroku Local command to assist us in running the app locally, but it needs to know which $PORT
and $REDIS_URL
to connect to locally:
$ cp .env.local .env
Modify the .env
file as necessary.
Let’s launch the web app locally.
$ heroku local
[OKAY] Loaded ENV .env File as KEY=VALUE Format
## Deploy
It’s time to deploy your app to Heroku. Create a Heroku app to deploy to:
```term
$ heroku create
Creating pure-river-3626... done, stack is heroku-18
Buildpack set. Next release on pure-river-3626 will use heroku/go.
https://pure-river-3626.herokuapp.com/ | https://git.heroku.com/pure-river-3626.git
Add a Redis add-on:
$ heroku addons:create heroku-redis
Creating flowing-subtly-2327... done, (free)
Adding flowing-subtly-2327 to pure-river-3626... done
Setting HEROKU_REDIS_CHARCOAL_URL and restarting pure-river-3626... done, v15
Database has been created and will be available shortly
Use `heroku addons:docs heroku-redis` to view documentation.
Deploy your code with git push
.
$ git push heroku master
Counting objects: 624, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (342/342), done.
Writing objects: 100% (624/624), 808.36 KiB | 0 bytes/s, done.
Total 624 (delta 231), reused 611 (delta 225)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Go app detected
remote: -----> Checking vendor/vendor.json file.
remote: -----> Using go1.7
remote: -----> Installing govendor v1.0.3... done
remote: -----> Fetching any unsaved dependencies (govendor sync)
remote: -----> Running: go install -v -tags heroku .
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/Sirupsen/logrus
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/garyburd/redigo/internal
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/garyburd/redigo/redis
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/gorilla/websocket
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/heroku/x/redis
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/pkg/errors
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/satori/go.uuid
remote: github.com/heroku-examples/go-websocket-chat-demo
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 2.4M
remote: -----> Launching...
remote: Released v5
remote: https://go-websocket-chat-demo.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/go-websocket-chat-demo.git
* [new branch] master -> master
Congratulations! Your web app should now be up and running on Heroku.
Next steps
Now that you have deployed your app and understand the basics we’ll want to expand the app to be a bit more robust. This won’t encompass everything you’d want to do before going public, but it will get you along the a path where you’ll be thinking about the right things.
Security
Remember, this is only a demo application and is likely vulnerable to various attacks. Please refer to WebSockets Security for more general guidelines on securing your WebSocket application.