Multi-room Chat Application With WebSockets In Go And Vue.js (Part 2)

In this tutorial series, 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.

This is the second part of this series, in part 1 we completed the basic chat application with one chat room. In this part, we will introduce multi-room support and allow users to chat privately in a one on one room.

Posts overview

Preconditions

To follow along you should have completed part 1 or grab the source from here.

Step 1: Introducing rooms

The first thing we will do is to create a new struct for our new Room type. The room struct looks a lot like how the ChatServer is currently structured. Because each room should be able to register clients, unregister clients, and broadcast to clients, we will add the same maps and channels as in the ChatServer.

// room.go
package main

import "fmt"

type Room struct {
	name       string
	clients    map[*Client]bool
	register   chan *Client
	unregister chan *Client
	broadcast  chan *Message
}

// NewRoom creates a new Room
func NewRoom(name string) *Room {
	return &Room{
		name:       name,
		clients:    make(map[*Client]bool),
		register:   make(chan *Client),
		unregister: make(chan *Client),
		broadcast:  make(chan *Message),
	}
}

// RunRoom runs our room, accepting various requests
func (room *Room) RunRoom() {
	for {
		select {

		case client := <-room.register:
			room.registerClientInRoom(client)

		case client := <-room.unregister:
			room.unregisterClientInRoom(client)

		case message := <-room.broadcast:
			room.broadcastToClientsInRoom(message)
		}
	}
}

func (room *Room) registerClientInRoom(client *Client) {
	room.notifyClientJoined(client)
	room.clients[client] = true
}

func (room *Room) unregisterClientInRoom(client *Client) {
	if _, ok := room.clients[client]; ok {
		delete(room.clients, client)
	}
}

func (room *Room) broadcastToClientsInRoom(message []byte) {
	for client := range room.clients {
		client.send <- message
	}
}

The only new thing here is the Name property, this is to identify each room later on.

Keeping track of the rooms

Because our ChatServer acts like a hub for connecting the parts of our chat application, we will use it to keep track of all the rooms that will be created.

First add a new map to hold the rooms:

// chatServer.go
type WsServer struct {
	...
	rooms      map[*Room]bool
}

// NewWebsocketServer creates a new WsServer type
func NewWebsocketServer() *WsServer {
	return &WsServer{
		...
		rooms:      make(map[*Room]bool),
	}
}

We will keep the existing maps and channels for now, me might need them when we want to know who is online.

Next add a method to find a existing room and to create a new one:

// chatServer.go
func (server *WsServer) findRoomByName(name string) *Room {
	var foundRoom *Room
	for room := range server.rooms {
		if room.GetName() == name {
			foundRoom = room
			break
		}
	}

	return foundRoom
}

func (server *WsServer) createRoom(name string) *Room {
	room := NewRoom(name)
	go room.RunRoom()
	server.rooms[room] = true

	return room
}

Step 2: Changing the Client to accept various requests

In this next step, we will update our client, so it is able to handle a wider variety of requests than just passing along a text message to the ChatServer.

Message types

To handle different kinds of messages, like joining a room or sending a chat message, we will introduce a Message type. This type will consist of:

  • Action: What action is the message requesting (send-message, join-room or leave-room)?
  • Message: The actual message, could be a room name when joining a room for example.
  • Target: A target for the message, a room for example.
  • Sender: Who is sending the message?
// message.go
package main

import (
	"encoding/json"
	"log"
)

const SendMessageAction = "send-message"
const JoinRoomAction = "join-room"
const LeaveRoomAction = "leave-room"

type Message struct {
	Action  string  `json:"action"`
	Message string  `json:"message"`
	Target  string  `json:"target"`
	Sender  *Client `json:"sender"`
}

func (message *Message) encode() []byte {
	json, err := json.Marshal(message)
	if err != nil {
		log.Println(err)
	}

	return json
}

The JSON tags are needed for encoding and decoding, see the documentation for more info.

We will include an encode method that can be called to create a json []byte object that is ready to be send back to clients.

Interacting with the rooms

Now it’s time to modify our Client so it can join, leave, and broadcast to rooms. First, we will add a map to our client this is for keeping track of the rooms this client joins, in case he or she wants to leave one.

We will also modify the disconnect method so a client will be unregistered from each room next to unregistering from the ChatServer.

// client.go
type Client struct {
	...
	rooms    map[*Room]bool
}

func newClient(conn *websocket.Conn, wsServer *WsServer) *Client {
	return &Client{
        ...
		rooms:    make(map[*Room]bool),
	}
}

func (client *Client) disconnect() {
	client.wsServer.unregister <- client
	for room := range client.rooms {
		room.unregister <- client
	}
    ...
}

Handling messages

Now we are able to actually join a room. As mentioned earlier we created a Message type with different actions. Lets now use those actions to handle the different kinds of messages.

First, modify the client and add a new method that decodes the JSON message and then handles it directly or passes it to a dedicated handler:

// client.go
import (
	"encoding/json"
    ...
)

func (client *Client) handleNewMessage(jsonMessage []byte) {

	var message Message
	if err := json.Unmarshal(jsonMessage, &message); err != nil {
		log.Printf("Error on unmarshal JSON message %s", err)
	}
	
    // Attach the client object as the sender of the messsage.
	message.Sender = client

	switch message.Action {
	case SendMessageAction:
        // The send-message action, this will send messages to a specific room now.
        // Which room wil depend on the message Target
		roomName := message.Target
        // Use the ChatServer method to find the room, and if found, broadcast!
		if room := client.wsServer.findRoomByName(roomName); room != nil {
			room.broadcast <- &message
		}
    // We delegate the join and leave actions. 
	case JoinRoomAction:
		client.handleJoinRoomMessage(message)

	case LeaveRoomAction:
		client.handleLeaveRoomMessage(message)
	}
}

So with the new method above, we will send messages directly to a room now! Since we are sending Message objects now instead of []byte objects, we will need to make a minor adjustment to our room.go.

// room.go
func (room *Room) RunRoom() {
	for {
		select {
	    ...
		case message := <-room.broadcast:
			room.broadcastToClientsInRoom(message.encode())
		}

	}
}

The methods for joining and leaving a room are fairly simple. When the room does not exist yet we will create one:

// client.go
func (client *Client) handleJoinRoomMessage(message Message) {
	roomName := message.Message

	room := client.wsServer.findRoomByName(roomName)
	if room == nil {
		room = client.wsServer.createRoom(roomName)
	}

	client.rooms[room] = true

	room.register <- client
}

func (client *Client) handleLeaveRoomMessage(message Message) {
	room := client.wsServer.findRoomByName(message.Message)
	if _, ok := client.rooms[room]; ok {
		delete(client.rooms, room)
	}

	room.unregister <- client
}

upon leaving a room we both remove it from the client and unregister the client from the room.

The last step here is to use our new methods when a new message is received so switch out the first rule for the second in client.go:

- client.wsServer.broadcast <- jsonMessage
+ client.handleNewMessage(jsonMessage)

Naming our client

It would be nice to see whom you are chatting with, so let’s give our so-far anonymous client a name.

// client.go
type Client struct {
	...	
	Name     string `json:"name"`	
}

func newClient(conn *websocket.Conn, wsServer *WsServer, name string) *Client {
	return &Client{
		Name:     name,
		...
	}
}

func (client *Client) GetName() string {
	return client.Name
}

Notice the json tags again, the client is used as sender in the message type. Now it wil be encoded with the name of the sender.

To get the name form the user, we will allow the use of a parameter on the connection string. For this we have to modify the existing ServeWs method

// client.go
func ServeWs(wsServer *WsServer, w http.ResponseWriter, r *http.Request) {

	name, ok := r.URL.Query()["name"]

	if !ok || len(name[0]) < 1 {
		log.Println("Url Param 'name' is missing")
		return
	}

	...

	client := newClient(conn, wsServer, name[0])

	...
}

Welcome message

The last thing we will do is send out a message when a user joins a room, so other users see him or her joining!

For this we will add one new method in room.go

// room.go
const welcomeMessage = "%s joined the room"

func (room *Room) notifyClientJoined(client *Client) {
	message := &Message{
		Action:  SendMessageAction,
        Target:  room.name,
		Message: fmt.Sprintf(welcomeMessage, client.GetName()),
	}

	room.broadcastToClientsInRoom(message.encode())
}

Then call this method in when a user registers:

// room.go
func (room *Room) registerClientInRoom(client *Client) {
    // by sending the message first the new user won't see his own message.
	room.notifyClientJoined(client)
	room.clients[client] = true
}

That’s all the server code for our public chat rooms. Now let’s update our front-end code!

Step 3: Room interface

Let’s start out with the JavaScript. To keep track of our new components (rooms & the name of a user) we will drop a few data properties and add some other. The final result should look like:

// public/assets/app.js
var app = new Vue({
  el: '#app',
  data: {
    ws: null,
    serverUrl: "ws://localhost:8080/ws",
    roomInput: null,
    rooms: [],
    user: {
      name: ""
    }
  },
  ... 
})
  • roomInput is used for joining new rooms.
  • rooms wil keep track of the joined rooms.
  • user is for user data, like the name.

The modified methods look as followed:

// public/assets/app.js
methods: {
    connect() {
        this.connectToWebsocket();
    },
    connectToWebsocket() {
	    // Pass the name paramter when connecting.
        this.ws = new WebSocket(this.serverUrl + "?name=" + this.user.name);
        this.ws.addEventListener('open', (event) => { this.onWebsocketOpen(event) });
        this.ws.addEventListener('message', (event) => { this.handleNewMessage(event) });
    },
    onWebsocketOpen() {
        console.log("connected to WS!");
    },

    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]);
        // display the message in the correct room.
        const room = this.findRoom(msg.target);
        if (typeof room !== "undefined") {
            room.messages.push(msg);
        }
        }
    },
    sendMessage(room) {
        // send message to correct room.
        if (room.newMessage !== "") {
        this.ws.send(JSON.stringify({
            action: 'send-message',
            message: room.newMessage,
            target: room.name
        }));
        room.newMessage = "";
        }
    },
    findRoom(roomName) {
        for (let i = 0; i < this.rooms.length; i++) {
          if (this.rooms[i].name === roomName) {
              return this.rooms[i];
          }
        }
    },
    joinRoom() {
        this.ws.send(JSON.stringify({ action: 'join-room', message: this.roomInput }));
        this.messages = [];
        this.rooms.push({ "name": this.roomInput, "messages": [] });
        this.roomInput = "";
    },
    leaveRoom(room) {
        this.ws.send(JSON.stringify({ action: 'leave-room', message: room.name }));

        for (let i = 0; i < this.rooms.length; i++) {
          if (this.rooms[i].name === room.name) {
              this.rooms.splice(i, 1);
              break;
          }
        }
    }
}

we’ve introduced three new methods for finding a room, joining a room and leaving a room.

The existing receive and send methods are modified to work with the rooms.

The HTML is modified to include an input for choosing a name and for joining a room. After filling out a name it will connect to the WebSocket server.

<!-- public/index.html-->
...
  <body>
    <div id="app">
      <div class="container h-100">
        <div class="row justify-content-center h-100">
          <div class="col-12 form" v-if="!ws">
              <div class="input-group">
                <input
                  v-model="user.name"
                  class="form-control name"
                  placeholder="Please fill in your (nick)name"
                  @keyup.enter.exact="connect"
                />
                <div class="input-group-append">
                  <span class="input-group-text send_btn" @click="connect">
                  >
                  </span>
                </div>
            </div>
          </div>

          <div class="col-12 room" v-if="ws != null">
            <div class="input-group">
              <input
                v-model="roomInput"
                class="form-control name"
                placeholder="Type the room you want to join"
                @keyup.enter.exact="joinRoom"
              />
              <div class="input-group-append">
                <span class="input-group-text send_btn" @click="joinRoom">
                >
                </span>
              </div>
            </div>
          </div>

          <div class="chat" v-for="(room, key) in rooms" :key="key">
            <div class="card">
              <div class="card-header msg_head">
                <div class="d-flex bd-highlight justify-content-center">
                  {{room.name}}
                  <span class="card-close" @click="leaveRoom(room)">leave</span>
                </div>
              </div>
              <div class="card-body msg_card_body">
                <div
                  v-for="(message, key) in room.messages"
                  :key="key"
                  class="d-flex justify-content-start mb-4"
                >
                  <div class="msg_cotainer">
                    {{message.message}}
                    <span class="msg_name" v-if="message.sender">{{message.sender.name}}</span>
                  </div>
                </div>
              </div>
              <div class="card-footer">
                <div class="input-group">
                  <textarea
                    v-model="room.newMessage"
                    name=""
                    class="form-control type_msg"
                    placeholder="Type your message..."
                    @keyup.enter.exact="sendMessage(room)"
                  ></textarea>
                  <div class="input-group-append">
                    <span class="input-group-text send_btn" @click="sendMessage(room)"
                      >></span
                    >
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </body>

The modified style can be found here: https://github.com/jeroendk/go-vuejs-chat/blob/master/public/assets/style.css

That is it for public rooms! We should now be able to run our code… Start your server with:

go run ./

And visit: http://localhost:8080
You should be able to join multiple rooms and start chatting.

Demo chatting

Step 4: Listing online users

Not that we can join chat rooms on the fly it is time to add one on one chats. We will make use of the existing room architecture to start private chats.

To be able to start a private chat, the user should be able to see who is online. So the first thing we will do is notify users when a user joins or leaves.

Let’s open our Message file and add two more actions:

// message.go
const UserJoinedAction = "user-join"
const UserLeftAction = "user-left"

Identify users

To keep users apart let’s add a unique ID to our user struct. For this we will use the UUID bundle, install it as follows:

go get github.com/google/uuid

After installing modify the Client struct to include a field for the ID and generate a new ID on creation:

// client.go
import (
	...
	"github.com/google/uuid"
)
type Client struct {
	...
	ID       uuid.UUID `json:"id"`
	...
}

func newClient(conn *websocket.Conn, wsServer *WsServer, name string) *Client {
	return &Client{
		ID:       uuid.New(),
		...
	}
}

Sending notifications

Next, we will implement the methods for notifying the users, this will be done in the chatServer because that is were users are joining and leaving.

We will add three methods:

  • notifyClientJoined, to notify existing users when a new one joins.
  • notifyClientLeft, to notify existing users when one leaves.
  • listOnlineClients, to list all the existing users to the joining user.
// chatServer.go
func (server *WsServer) notifyClientJoined(client *Client) {
	message := &Message{
		Action: UserJoinedAction,
		Sender: client,
	}

	server.broadcastToClients(message.encode())
}

func (server *WsServer) notifyClientLeft(client *Client) {
	message := &Message{
		Action: UserLeftAction,
		Sender: client,
	}

	server.broadcastToClients(message.encode())
}

func (server *WsServer) listOnlineClients(client *Client) {
	for existingClient := range server.clients {
		message := &Message{
			Action: UserJoinedAction,
			Sender: existingClient,
		}
		client.send <- message.encode()
	}
}

At last we have to call these methods at the appropriate times.
The existing register and unregister methods will now look like this:

// chatServer.go
func (server *WsServer) registerClient(client *Client) {
	server.notifyClientJoined(client)
	server.listOnlineClients(client)
	server.clients[client] = true
}

func (server *WsServer) unregisterClient(client *Client) {
	if _, ok := server.clients[client]; ok {
		delete(server.clients, client)
		server.notifyClientLeft(client)
	}
}

Notify all the clients of the new client and list all the clients to the new one before we add the new client to the map. This way the client won’t see his own profile.

Handling the notifications

Now we should be able to listen to the notifications in the JavaScript. First, we will add a data property and the methods for handling these notifications:

// public/assets/app.js
data: {
    ...
    users: []
},

handleUserJoined(msg) {
    this.users.push(msg.sender);
},
handleUserLeft(msg) {
    for (let i = 0; i < this.users.length; i++) {
        if (this.users[i].id == msg.sender.id) {
            this.users.splice(i, 1);
        }
    }
},

Next refactor the handleNewMessage method so it can act upon multiple types of messages.

// public/assets/app.js
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]);
        switch (msg.action) {
        case "send-message":
            this.handleChatMessage(msg);
            break;
        case "user-join":
            this.handleUserJoined(msg);
            break;
        case "user-left":
            this.handleUserLeft(msg);
            break;
        default:
            break;
        }
    }
},

handleChatMessage(msg) {
  const room = this.findRoom(msg.target);
  if (typeof room !== "undefined") {
    room.messages.push(msg);
  }
},

Listing the users

Alright, it is time to display our online users, open the HTML file, and add the following just above:

<!-- public/index.html-->
<div class="col-12 ">
    <div class="row">
        <div class="col-2 card profile" v-for="user in users" :key="user.id">
        <div class="card-header">{{user.name}}</div>
        <div class="card-body">
            <button class="btn btn-primary">Send Message</button>
        </div>
        </div>
    </div>
</div>

You can add the above code just before the room input field:

<div class="col-12 room" v-if="ws != null">

The modified style can be found here: https://github.com/jeroendk/go-vuejs-chat/blob/master/public/assets/style.css

Now join the server with multiple tabs and the result should look like this:

Step 5: Starting a private chat

We are now able to see who is online, so the next step is to create a private chat room when you want to message someone.

First open up the message file again, we will add two more actions:

  • JoinRoomPrivateAction, to join or create a private room.
  • RoomJoinedAction, to send back to the user after joining a room. Soon will identify rooms by ID instead of the name so we have to let our users know what this ID is.
// message.go
const JoinRoomPrivateAction = "join-room-private"
const RoomJoinedAction = "room-joined"


type Message struct {
-	Target  string  `json:"target"`	
+	Target  *Room   `json:"target"`
}

We changed the target in a message to the Room type instead of a string, this way we can include the ID and the name.

Identify Rooms

Now let’s give our rooms an ID like with the users we will use the UUID library. We will expose the Name field in JSON as well by making it public (capitalize) and adding the JSON tags. At last, we add a boolean field that indicates if the room is private or public.

// room.go
import (
	...
	"github.com/google/uuid"
)

type Room struct {
	ID         uuid.UUID `json:"id"`
	Name       string    `json:"name"`	
	Private    bool `json:"private"`
    ...
}

func NewRoom(name string, private bool) *Room {
	return &Room{
		ID:         uuid.New(),
		Name:       name,		
		Private:    private,
        ...
	}
}

func (room *Room) GetId() string {
	return room.ID.String()
}

func (room *Room) GetName() string {
	return room.Name
}

To comply with the changed message struct we make one more adjustment to the room file. In the notifyClientJoined method change the Target to room instead of room.name.

// room.go
message := &Message{
  Action:  SendMessageAction,
  Target:  room,
  Message: fmt.Sprintf(welcomeMessage, client.GetName()),
}

If you want to suppress the welcome message when another user joins the private room, change the registerClientInRoom method like this:

// room.go
func (room *Room) registerClientInRoom(client *Client) {
	if !room.Private {
		room.notifyClientJoined(client)
	}
	room.clients[client] = true
}

Joining a private room

To join a private room we can reuse a lot of existing code in the client file, therefor we refactor it to use it both for private and public rooms. The end result looks like below. See the inline comments for some more explanation.

// client.go

// Refactored method
// Use the ID of the target room instead of the name to find it.
// Added case for joining private room
func (client *Client) handleNewMessage(jsonMessage []byte) {

	var message Message
	if err := json.Unmarshal(jsonMessage, &message); err != nil {
		log.Printf("Error on unmarshal JSON message %s", err)
		return
	}

	message.Sender = client

	switch message.Action {
	case SendMessageAction:
		roomID := message.Target.GetId()
		if room := client.wsServer.findRoomByID(roomID); room != nil {
			room.broadcast <- &message
		}

	case JoinRoomAction:
		client.handleJoinRoomMessage(message)

	case LeaveRoomAction:
		client.handleLeaveRoomMessage(message)

	case JoinRoomPrivateAction:
		client.handleJoinRoomPrivateMessage(message)
	}

}

// Refactored method
// Use new joinRoom method
func (client *Client) handleJoinRoomMessage(message Message) {
	roomName := message.Message

	client.joinRoom(roomName, nil)
}

// Refactored method
// Added nil check
func (client *Client) handleLeaveRoomMessage(message Message) {
	room := client.wsServer.findRoomByID(message.Message)
	if room == nil {
		return
	}
	if _, ok := client.rooms[room]; ok {
		delete(client.rooms, room)
	}

	room.unregister <- client
}

// New method
// When joining a private room we will combine the IDs of the users
// Then we will bothe join the client and the target.
func (client *Client) handleJoinRoomPrivateMessage(message Message) {

	target := client.wsServer.findClientByID(message.Message)
	if target == nil {
		return
	}

	// create unique room name combined to the two IDs
	roomName := message.Message + client.ID.String()

	client.joinRoom(roomName, target)
	target.joinRoom(roomName, client)

}

// New method
// Joining a room both for public and private roooms
// When joiing a private room a sender is passed as the opposing party
func (client *Client) joinRoom(roomName string, sender *Client) {

	room := client.wsServer.findRoomByName(roomName)
	if room == nil {
		room = client.wsServer.createRoom(roomName, sender != nil)
	}

	// Don't allow to join private rooms through public room message
	if sender == nil && room.Private {
		return
	}

	if !client.isInRoom(room) {
		client.rooms[room] = true
		room.register <- client
		client.notifyRoomJoined(room, sender)
	}

}

// New method
// Check if the client is not yet in the room
func (client *Client) isInRoom(room *Room) bool {
	if _, ok := client.rooms[room]; ok {
		return true
	}
	return false
}

// New method
// Notify the client of the new room he/she joined
func (client *Client) notifyRoomJoined(room *Room, sender *Client) {
	message := Message{
		Action: RoomJoinedAction,
		Target: room,
		Sender: sender,
	}

	client.send <- message.encode()
}

After applying the above, we need to update our chatServer and add two new methods used by the client:

// chatServer.go
func (server *WsServer) findRoomByID(ID string) *Room {
	var foundRoom *Room
	for room := range server.rooms {
		if room.GetId() == ID {
			foundRoom = room
			break
		}
	}

	return foundRoom
}

func (server *WsServer) findClientByID(ID string) *Client {
	var foundClient *Client
	for client := range server.clients {
		if client.ID.String() == ID {
			foundClient = client
			break
		}
	}

	return foundClient
}

And at last change the joinRoom method to pass the private boolean:

// chatServer.go
func (server *WsServer) createRoom(name string, private bool) *Room {
	room := NewRoom(name, private)
	...
}

Putting it al together

Alright, it is time to let it all fall in place by finishing our client-side code.
Since we now send over a room object instead of just a name we have to adjust a few existing methods:

// public/assets/app.js
handleChatMessage(msg) {
  const room = this.findRoom(msg.target.id);
  if (typeof room !== "undefined") {
    room.messages.push(msg);
  }
},

sendMessage(room) {
    if (room.newMessage !== "") {
        this.ws.send(JSON.stringify({
        action: 'send-message',
        message: room.newMessage,
        target: {
            id: room.id,
            name: room.name
        }
        }));
        room.newMessage = "";
    }
},

findRoom(roomId) {
    for (let i = 0; i < this.rooms.length; i++) {
        if (this.rooms[i].id === roomId) {
        return this.rooms[i];
        }
    }
},

And at last, add the methods for joining a private room and responding to room-joined action from the server:

// public/assets/app.js

handleNewMessage(event) {
  ....
  case "room-joined":
    this.handleRoomJoined(msg);
    break;
}

handleRoomJoined(msg) {
  room = msg.target;
  room.name = room.private ? msg.sender.name : room.name;
  room["messages"] = [];
  this.rooms.push(room);
},

// we removed a line in the function below
// since we only save new rooms when the server tells so
joinRoom() {
  this.ws.send(JSON.stringify({ action: 'join-room', message: this.roomInput }));
  this.roomInput = "";
},

joinPrivateRoom(room) {
  this.ws.send(JSON.stringify({ action: 'join-room-private', message: room.id }));
}

Ok that’s it! Let’s see our chat server application in action!

What’s next?

In the next part will leverage Redis Pub/Sub for scalability (or for fun?), so stay tuned!

Feel free to leave a comment when you have suggestions or questions!

The final source code of this part van be found here:
https://github.com/jeroendk/go-vuejs-chat/tree/v2.0