DEV Community

Abhishek Gupta for Microsoft Azure

Posted on

Let's learn how to to build a chat application with Redis, WebSocket and Go [part 1]

The WebSocket protocol offers a bi-directional (both server and client can exchange messages) and full-duplex (server or client can send messages at the same time) communication channel that makes it suitable for real-time scenarios such as chat applications etc. The connected chat users (clients) can send messages to the application (WebSocket server) and can exchange messages with each other - similar to a peer-to-peer setting.

In this blog, we will explore how to build a simple chat application using WebSocket and Go. The solution will make use of Redis as well (more on this soon).

A follow-up blog post (part 2) will demonstrate how to deploy this application to Azure App Service which will communicate with Azure Redis Cache using Virtual Network integration

You will learn:

  • Redis data structures - this app uses SET and PUBSUB
  • Interacting with Redis using the go-redis client
  • The gorilla WebSocket library which provides a complete and tested implementation of the WebSocket protocol
  • Azure Cache for Redis which is a managed Redis offering in the cloud

Why Redis?

Let's consider a chat application. When a user first connects, a corresponding WebSocket connection is created within the application (WebSocket server) and it is associated with the specific application instance. This WebSocket connection is what enables us to broadcast chat messages between users. We can scale (out) our application (for e.g. to account for a large user base) by running multiple instances. Now, if a new user comes in, they may be connected to a new instance. So we have a scenario where different users (hence their respective WebSocket connections) are associated with different instances. As a result, they will not be able to exchange messages with each other - this is unacceptable, even for our toy chat application 😉

Redis is a versatile key value that supports a variety of rich data structures such as List, Set, Sorted Set, Hash etc. One of the features also includes a PubSub capability using which publishers can send messages to Redis channel(s) and subscribers can listen for messages on these channel(s) - both are completely independent and decoupled from each other. This can be used to solve the problem we have. Now, instead of depending on WebSocket connections only, we can use a Redis channel which each chat application can subscribe to. Thus, the messages sent to the WebSocket connection can be now piped via the Redis channel to ensure that all the application instances (and associated chat users) receive them.

More on this when we dive into the code in the next section. It is available on Github

Please note that instead of plain WebSocket, you can also use technologies such as Azure SignalR that allows apps to push content updates to connected clients, such as a single page web or mobile application. As a result, clients are updated without the need to poll the server or submit new HTTP requests for updates

To follow along and deploy this solution to Azure, you are going to need a Microsoft Azure account. You can grab one for free if you don't have it already!

Chat application overview

Time for a quick code walkthrough. Here is the application structure:

.
├── Dockerfile
├── chat
│   ├── chat-session.go
│   └── redis.go
├── go.mod
├── go.sum
├── main.go
Enter fullscreen mode Exit fullscreen mode

In main.go, we register our WebSocket handler and start the web server - all that's used is plain net/http package

    http.Handle("/chat/", http.HandlerFunc(websocketHandler))
    server := http.Server{Addr: ":" + port, Handler: nil}
    go func() {
        err := server.ListenAndServe()
        if err != nil && err != http.ErrServerClosed {
            log.Fatal("failed to start server", err)
        }
    }()
Enter fullscreen mode Exit fullscreen mode

The WebSocket handler processes chat users (which are nothing but WebSocket clients) and starts a new chat session.

func websocketHandler(rw http.ResponseWriter, req *http.Request) {
    user := strings.TrimPrefix(req.URL.Path, "/chat/")

    peer, err := upgrader.Upgrade(rw, req, nil)
    if err != nil {
        log.Fatal("websocket conn failed", err)
    }
    chatSession := chat.NewChatSession(user, peer)
    chatSession.Start()
}
Enter fullscreen mode Exit fullscreen mode

A ChatSession (part of chat/chat-session.go) represents a user and it's corresponding WebSocket connection (on the server side)

type ChatSession struct {
    user string
    peer *websocket.Conn
}
Enter fullscreen mode Exit fullscreen mode

When a session is started, it starts a goroutine to accept messages from the user who just joined the chat. It does so by calling ReadMessage() (from websocket.Conn) in a for loop. This goroutine exits if the user disconnects (WebSocket connection gets closed) or the application is shut down (e.g. using ctrl+c). To summarize, there is a separate goroutine spawned for each user in order to handle its chat messages.

func (s *ChatSession) Start() {
...
    go func() {
        for {
            _, msg, err := s.peer.ReadMessage()
            if err != nil {
                _, ok := err.(*websocket.CloseError)
                if ok {
                    s.disconnect()
                }
                return
            }
            SendToChannel(fmt.Sprintf(chat, s.user, string(msg)))
        }
    }()
Enter fullscreen mode Exit fullscreen mode

As soon as a message is received from the user (over the WebSocket connection), its broadcasted to other users using the SendToChannel function which is a part of chat/redis.go. All it does it publish the message to a Redis pubsub channel

func SendToChannel(msg string) {
    err := client.Publish(channel, msg).Err()
    if err != nil {
        log.Println("could not publish to channel", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part is the sub (subscriber) part of the equation. As opposed to the case where there was a dedicated goroutine for each connected chat user, we use a single goroutine (at an application scope) to subscribe to the Redis channel, receive messages and broadcast it to all the users using their respective server-side WebSocket connection.

func startSubscriber() {
    go func() {
        sub = client.Subscribe(channel)
        messages := sub.Channel()
        for message := range messages {
            from := strings.Split(message.Payload, ":")[0]
            for user, peer := range Peers {
                if from != user {
                    peer.WriteMessage(websocket.TextMessage, []byte(message.Payload))
                }
            }
        }
    }()
}
Enter fullscreen mode Exit fullscreen mode

The subscription is ended when the application instance is shut down - this is turn terminates the channel for-range loop and the goroutine exits

The startSubscriber function is called from the init() function in redis.go. The init()function starts off by connecting to Redis and the application exits if connectivity fails.

Alright! It's time to set up a Redis instance to which we can hook up our chat backend to. Let's create a Redis server in the cloud!

Azure Redis Cache setup

Azure Cache for Redis provides access to a secure, dedicated Redis cache which is hosted within Azure, and accessible to any application within or outside of Azure.

For the purposes of this blog, we will setup an Azure Redis Cache with a Basic tier which is a single node cache ideal for development/test and non-critical workloads. Please note that you also choose from Standard and Premium tiers which provide different features ranging from persistence, clustering, geo-replication, etc.

I will be using Azure CLI for the installation. You can also use Azure Cloud Shell if you are a browser person!

To quickly setup an Azure Redis Cache instance, we can use the az redis create command, e.g.

az redis create --location westus2 --name chat-redis --resource-group chat-app-group --sku Basic --vm-size c0
Enter fullscreen mode Exit fullscreen mode

Checkout "Create an Azure Cache for Redis" for a step-by-step guide

Once that's done, you need the get the information required to connect to Azure Redis Cache instance i.e. host, port and access keys. This can be done using CLI as well, e.g.

//host and (SSL) port
az redis show --name chat-redis --resource-group chat-app-group --query [hostName,sslPort] --output tsv

//primary access key
az redis list-keys --name chat-redis --resource-group chat-app-group --query [primaryKey] --output tsv
Enter fullscreen mode Exit fullscreen mode

Checkout "Get the hostname, ports, and keys for Azure Cache for Redis" for a step-by-step guide

That's it...

.... lets chat!

To keep things simple, the application is available in the form of a Docker image

First, set a few environment variables:

//use port 6380 for SSL
export REDIS_HOST=[redis cache hostname as obtained from CLI]:6380
export REDIS_PASSWORD=[redis cache primary access key as obtained from CLI]
export EXT_PORT=9090
export NAME=chat1
Enter fullscreen mode Exit fullscreen mode

The application uses a static port 8080 internally (for the web server). We use an external port specified by EXT_PORT and map it to the port 8080 inside our container (using -p $EXT_PORT:8080)

Start the Docker container

docker run --name $NAME -e REDIS_HOST=$REDIS_HOST -e REDIS_PASSWORD=$REDIS_PASSWORD -p $EXT_PORT:8080 abhirockzz/redis-chat-go
Enter fullscreen mode Exit fullscreen mode

Its time to join the chat! You can use any WebSocket client. I prefer using wscat in the terminal and or the Chrome WebSocket extension in the browser

I will demonstrate this using wscat from my terminal. Open two separate terminals to simulate different users:

//terminal 1 (user "foo")
wscat -c ws://localhost:9090/chat/foo

//terminal 2 (user "bar")
wscat -c ws://localhost:9090/chat/bar
Enter fullscreen mode Exit fullscreen mode

Here is an example of a chat between foo and bar

foo joined first and got a Welcome foo! message and so did bar who joined after foo. Notice that foo was notified that bar had joined. foo and bar exchanged a few messages before bar left (foo was notified of that too).

As an exercise, you can start another instance of the chat application. Spin up another Docker container with a different value for EXT_PORT and NAME e.g.

//use port 6380 for SSL
export REDIS_HOST=[redis cache host name as obtained from CLI]:6380
export REDIS_PASSWORD=[redis cache primary access key as obtained from CLI]
export EXT_PORT=9091
export NAME=chat2

docker run --name $NAME -e REDIS_HOST=$REDIS_HOST -e REDIS_PASSWORD=$REDIS_PASSWORD -p $EXT_PORT:8080 abhirockzz/redis-chat-go
Enter fullscreen mode Exit fullscreen mode

Now connect on port 9091 (or your chosen port) to simulate another user

//user "pi"
wscat -c ws://localhost:9091/chat/pi
Enter fullscreen mode Exit fullscreen mode

Since foo is still active, it will get notified: pi and foo can exchange pleasantries

Check Redis

Let's confirm by peeking into the Redis data structures. You can use the redis-cli for this. If you're using the Azure Redis Cache, I would recommend using a really handy web based Redis console for this.

We have a SET (name chat-users) which stores the active users

SMEMBERS chat-users
Enter fullscreen mode Exit fullscreen mode

You should see the result - this means that the users foo and bar are currently connected to the chat application and have an associated active WebSocket connection

1) "foo"
2) "bar"
Enter fullscreen mode Exit fullscreen mode

What about the PubSub channel?

PUBUSB CHANNELS
Enter fullscreen mode Exit fullscreen mode

As a single channel is used for all the users, you should get this result from the Redis server:

1) "chat"
Enter fullscreen mode Exit fullscreen mode

That's it for this blog post. Stay tuned for part 2!

If you found this useful, please don't forget to like and follow 🙌 I would love to get your feedback: just drop a comment here or reach out on Twitter 🙏🏻

Top comments (1)

Collapse
 
stephenandwood profile image
Info Comment hidden by post author - thread only accessible via permalink
stephenandwood

See reddit.com/r/golang/comments/g11vs... for feedback on the application.

Some comments have been hidden by the post's author - find out more