Let's learn how to to build a chat application with Redis, WebSocket and Go [part 1]
Abhishek Gupta
Posted on April 13, 2020
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
andPUBSUB
- 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
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)
}
}()
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()
}
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
}
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)))
}
}()
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)
}
}
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))
}
}
}
}()
}
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
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
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
The application uses a static port
8080
internally (for the web server). We use an external port specified byEXT_PORT
and map it to the port8080
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
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
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
Now connect on port 9091
(or your chosen port) to simulate another user
//user "pi"
wscat -c ws://localhost:9091/chat/pi
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
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"
What about the PubSub channel?
PUBUSB CHANNELS
As a single channel is used for all the users, you should get this result from the Redis server:
1) "chat"
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 šš»
Posted on April 13, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 13, 2020