Jeroen de Kok
Posted on September 27, 2020
In this tutorial we will build a fully working chat application, the server part will be built with WebSockets in Go, on the front-end we will leverage Vue.js to create a simple interface.
We start out simple and have some fun building a fully working chat application. I'm planning to add some follow up posts soon where I'll explain how to add more features.
Preconditions
Make sure your Go environment is set-up. If not follow the official documentation here. Some basic knowledge of the Go Syntax and Javascript/Vue.js is assumed.
Step 1: Setting up the WebSocket server
In the first step, we will step up the WebSocket server. For the WebSocket implementation, we will be using the gorilla/WebSocket package. To install this package paste the following in your console while in you are in your project folder:
go get github.com/gorilla/websocket
Client
Alright, now let’s add client.go, this file will represent the WebSocket client on the server-side.
We start with the bare minimum, define a Client type to hold the connection. Then expose a ServeWs() function to allow the creation of Websocket connections and use newClient() to create Client structs.
//client.go
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}
// Client represents the websocket client at the server
type Client struct {
// The actual websocket connection.
conn *websocket.Conn
}
func newClient(conn *websocket.Conn) *Client {
return &Client{
conn: conn,
}
}
// ServeWs handles websocket requests from clients requests.
func ServeWs(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := newClient(conn)
fmt.Println("New Client joined the hub!")
fmt.Println(client)
}
The upgrader is used to upgrade the HTTP server connection to the WebSocket protocol. the return value of this function is a WebSocket connection.
Main
Alright, so now we need a simple HTTP server to handle requests from clients and pass them to the ServeWs function.
At first create an HTTP server that listens on the port specified as a flag or on :8080 as default. Requests to the endpoint “/ws” will be handled by our ServeWs function.
//main.go
package main
import (
"flag"
"log"
"net/http"
)
var addr = flag.String("addr", ":8080", "http server address")
func main() {
flag.Parse()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
ServeWs(w, r)
})
log.Fatal(http.ListenAndServe(*addr, nil))
}
That’s it for the server part, for now at least… next, we will try to connect to the WebSocket server with a client.
Step 2: Creating the front-end
The front-end will be kept simple, first, we will add an index.html with some external dependencies.
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Chat</title>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.css" />
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CIntersectionObserver" crossorigin="anonymous"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="https://unpkg.com/vue"></script>
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.js"></script>
<!-- Load the following for BootstrapVueIcons support -->
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue-icons.min.js"></script>
</head>
<body>
<div id="app">
</div>
</body>
<script src="assets/app.js"></script>
</html>
Then create an app.js in the /public/assets folder. In this file we will do three things for now:
- Create a Vue.js app with new Vue()
- Create a WebSocket connection to our server
- Listen to the WebSocket open event which indicates that we have a connection.
// public/assets/app.js
var app = new Vue({
el: '#app',
data: {
ws: null,
serverUrl: "ws://localhost:8080/ws"
},
mounted: function() {
this.connectToWebsocket()
},
methods: {
connectToWebsocket() {
this.ws = new WebSocket( this.serverUrl );
this.ws.addEventListener('open', (event) => { this.onWebsocketOpen(event) });
},
onWebsocketOpen() {
console.log("connected to WS!");
}
}
})
Alright now let’s make sure Go serves our static files, open the main.go file and add the following lines before the listenAndServe call:
...
fs := http.FileServer(http.Dir("./public"))
http.Handle("/", fs)
log.Fatal(http.ListenAndServe(*addr, nil))
Testing the connection
Now you should be able to establish a WebSocket connection from your browser with your Go server. Startup your server from your terminal with:
go run ./
Open up your browser and go to http://localhost:8080. If everything’s working you should see a console.log message that tells you you are connected to the WebSocket! You can also check the network tab of your console and see the pending WebSocket connection:
Meanwhile, in the terminal where you started the Go program, you should see a log with the message “New Client joined the hub!”.
Step 3: Sending and receiving messages
OK, connection established… let’s make sure we can send and receive messages with our connected client. In order to keep track of the connected clients at the server, we add a new file called chatServer.go.
This file contains a WsServer type that has one map for the Clients registered in the server. It also has two channels, one for register requests and one for unregister requests.
package main
type WsServer struct {
clients map[*Client]bool
register chan *Client
unregister chan *Client
}
// NewWebsocketServer creates a new WsServer type
func NewWebsocketServer() *WsServer {
return &WsServer{
clients: make(map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
// Run our websocket server, accepting various requests
func (server *WsServer) Run() {
for {
select {
case client := <-server.register:
server.registerClient(client)
case client := <-server.unregister:
server.unregisterClient(client)
}
}
}
func (server *WsServer) registerClient(client *Client) {
server.clients[client] = true
}
func (server *WsServer) unregisterClient(client *Client) {
if _, ok := server.clients[client]; ok {
delete(server.clients, client)
}
}
The Run() function will run infinitely and listens to the channels. One’s new requests present themself, they will be handled through dedicated functions. For now, this is simply adding clients to the map or removing them.
Main
After this we have to update the main.go file and:
- Create a new WsServer
- Run in in a Go routine
- Pass the server to the ServeWs function
wsServer := NewWebsocketServer()
go wsServer.Run()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
ServeWs(wsServer, w, r)
})
Client.go
The next step involves the client file. First, we modify the type struct, we want to keep a reference to the WsServer for each Client. We may as well register the client within the server by pushing the newly created client in the register channel. Just before registering in the server we are starting two goroutines that we will define below.
// Client represents the websocket client at the server
type Client struct {
// The actual websocket connection.
conn *websocket.Conn
wsServer *WsServer
}
func newClient(conn *websocket.Conn, wsServer *WsServer) *Client {
return &Client{
conn: conn,
wsServer: wsServer,
}
}
// ServeWs handles websocket requests from clients requests.
func ServeWs(wsServer *WsServer, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := newClient(conn, wsServer)
go client.writePump()
go client.readPump()
wsServer.register <- client
}
revised type declaration, newClient() and ServeWs() functions.
readPump
In the readPump Goroutine, the client will read new messages send over the WebSocket connection. It will do so in an endless loop until the client is disconnected. When the connection is closed, the client will call its own disconnect method to clean up.
//import statements
const (
// Max wait time when writing message to peer
writeWait = 10 * time.Second
// Max time till next pong from peer
pongWait = 60 * time.Second
// Send ping interval, must be less then pong wait time
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 10000
)
....
func (client *Client) readPump() {
defer func() {
client.disconnect()
}()
client.conn.SetReadLimit(maxMessageSize)
client.conn.SetReadDeadline(time.Now().Add(pongWait))
client.conn.SetPongHandler(func(string) error { client.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
// Start endless read loop, waiting for messages from client
for {
_, jsonMessage, err := client.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("unexpected close error: %v", err)
}
break
}
client.wsServer.broadcast <- jsonMessage
}
}
Upon receiving new messages the client will push them in the WsServer broadcast channel. We will create this channel below, first, we finish our client by adding the WritePump method
WritePump
The writePump goroutine handles sending the messages to the connected client. It runs in an endless loop waiting for new messages in the client.send channel. When receiving new messages it writes them to the client, if there are multiple messages available they will be combined in one write.
...
var (
newline = []byte{'\n'}
space = []byte{' '}
)
...
func (client *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
client.conn.Close()
}()
for {
select {
case message, ok := <-client.send:
client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The WsServer closed the channel.
client.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := client.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Attach queued chat messages to the current websocket message.
n := len(client.send)
for i := 0; i < n; i++ {
w.Write(newline)
w.Write(<-client.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := client.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
The writePump is also responsible for keeping the connection alive by sending ping messages to the client with the interval given in pingPeriod. If the client does not respond with a pong, the connection is closed.
Wiring up the WsServer
The last thing we need to do in our Go application is the creation of the broadcast channel in the WsServer.
type WsServer struct {
...
broadcast chan []byte
}
func NewWebsocketServer() *WsServer {
return &WsServer{
...
broadcast: make(chan []byte),
}
}
func (server *WsServer) Run() {
for {
select {
...
case message := <-server.broadcast:
server.broadcastToClients(message)
}
}
}
func (server *WsServer) broadcastToClients(message []byte) {
for client := range server.clients {
client.send <- message
}
}
The broadcast channel listens for messages, sent by the client readPump. It in turn pushes this messages in the send channel of all the clients registered.
Step 4: Creating the chat window
In this last step we will create the chat window to send & display the messages!
First update your index.html, add the following between <div id=”app”></div>
. This displays each message and provides a textarea to submit new messages.
<div class="container-fluid h-100">
<div class="row justify-content-center h-100">
<div class="col-md-8 col-xl-6 chat">
<div class="card">
<div class="card-header msg_head">
<div class="d-flex bd-highlight justify-content-center">
Chat
</div>
</div>
<div class="card-body msg_card_body">
<div
v-for="(message, key) in messages"
:key="key"
class="d-flex justify-content-start mb-4"
>
<div class="msg_cotainer">
{{message.message}}
<span class="msg_time"></span>
</div>
</div>
</div>
<div class="card-footer">
<div class="input-group">
<textarea
v-model="newMessage"
name=""
class="form-control type_msg"
placeholder="Type your message..."
@keyup.enter.exact="sendMessage"
></textarea>
<div class="input-group-append">
<span class="input-group-text send_btn" @click="sendMessage"
>></span
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
To get some basic styling you can add a style.css in public/assets/. For this example, the following stylesheet is used: https://github.com/jeroendk/....
App.js
Now we make sure the chat window works by finishing the VueJs component. First, add two new data properties (messages & newMessage).
Then add an event listener to the message event on the WebSocket connection. At last add two functions, one for handling the new messages received through the connect (remember there can be multiple messages at once) and the other for sending a message from the textarea.
data: {
...
messages: [],
newMessage: ""
},
connectToWebsocket() {
...
this.ws.addEventListener('message', (event) => { this.handleNewMessage(event) });
},
handleNewMessage(event) {
let data = event.data;
data = data.split(/\r?\n/);
for (let i = 0; i < data.length; i++) {
let msg = JSON.parse(data[i]);
this.messages.push(msg);
}
}
sendMessage() {
if(this.newMessage !== "") {
this.ws.send(JSON.stringify({message: this.newMessage}));
this.newMessage = "";
}
}
That’s it! You should now be able to send and receive chat messages when you visit your browser a http://localhost:8080.
Whats next?
You could ask the user his name before posting then you can display his or her name in the message. You can add any info you like to the message object, passed to the WebSocket.
Also, stay tuned for the follow-up posts planned:
- Multi-room & 1 one 1 chats.
- Using Redis Pub/Sub for scalability.
- Adding authentication and allow users to log-in.
The final source code for this part can be found here:
https://github.com/jeroendk/go-vuejs-chat/tree/v1.0
Posted on September 27, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.