Building a simple Chat application with WebSockets in Go and Vue.js

jeroendk

Jeroen de Kok

Posted on September 27, 2020

Building a simple Chat application with WebSockets in Go and Vue.js

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

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

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

Then create an app.js in the /public/assets folder. In this file we will do three things for now:

  1. Create a Vue.js app with new Vue()
  2. Create a WebSocket connection to our server
  3. 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!");
      }
    }
  })
Enter fullscreen mode Exit fullscreen mode

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

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:
Screenshot-from-2020-09-22-20-48-46-1024x299

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

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:

  1. Create a new WsServer
  2. Run in in a Go routine
  3. 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)
})
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

That’s it! You should now be able to send and receive chat messages when you visit your browser a http://localhost:8080.

Screenshot from 2020-09-24 20-38-48
Working chatbox!

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:

The final source code for this part can be found here:
https://github.com/jeroendk/go-vuejs-chat/tree/v1.0

💖 💪 🙅 🚩
jeroendk
Jeroen de Kok

Posted on September 27, 2020

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

Sign up to receive the latest update from our blog.

Related