Skip to main content

Building a CLI chat app with go and WebSockets

·1841 words·9 mins
go WebSockets

With the current global situation and the need to stay home, I finished writing this post I started during the last Christmas holidays.

I wanted to learn more about the WebSockets protocol during Christmas.

A simple chat app would be a good exercise.

I searched the internet for tutorials on building a chat app using WebSockets and Go, but I could not find any tutorial that showed a more realistic example. The majority of the samples were echo servers.

I wanted some examples that involved some user interaction.

I wanted to build something that resembles a real chat like Slack, WhatsApp, Telegram, etc. There are many examples out there.

I’m not a professional Go developer, but slowly learning for personal projects and work.

Ok, let’s start!!

First, we need to decide which WebSocket library to use, Go provides a WebSocket library, but the Go team, advises to use other solutions built by the community. You can even see a message when you want to read the doc for the library in GoDoc.

I decided to use websocket because it was the most recent and supported context and rate-limiting.

Once we move away from deciding on the WebSocket library, we need a way to route requests to our server; Go provides a fantastic http package for doing that. Still, often I find myself reaching for another solution from the community to build the routes. Not for any technical reason, but it was the first library I used to create a web app some time ago, and I’m sticking with what I know and have worked for me.

Before continuing, I want to clarify that this post will not discuss goroutines synchronization; there is excellent documentation on the sync package and tons of resources. So far, for simplicity, I will remove those bits from the code examples.

Fantastic, we are ready to start building the CLI chat app.

So I want to divide the code between server and client.

The server handles new WebSocket connections, listens to messages on those socket connections and broadcast messages to the right users. Also, it adds and removes users to different chat rooms.

The client connects to the server, listens to messages and prints them. It also allows the users to submit new messages to the server.

Those are going to be our two Go packages.

Let’s start with the server: #

The server must provide an endpoint that clients can connect using the WebSocket protocol; for that, we will use the Http.Server abstraction that the Http package provides.

The handler is going to have a single route, /chat/:chat_room/:user_name.

We have four abstractions: Hub, Chat, Message and User.

The Hub will hold a list of chats. You can think of the hub as a workspace in Slack; each has different chat rooms.

When a user opens a WebSocket connection to /chat/general/Gustavo, we will check if the chat generalexists. If not, we will create it, store it in theHub`, and add gustavo to the list of users of that chat. If the chat room exists, we will check if the user is already there and, if not, create a new user or return an error message if the user already exists.

func (h *hub) chatRoom(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  chatRoom := ps.ByName("chat_room")
  userName := ps.ByName("user_name")
  c, ok := h.rooms[chatRoom]
  if !ok {
    c := h.addChat(chatRoom)
    user, err := newUser(userName, w, r)
    if err != nil {
      log.WithError(err).Fatal("Error creating user to new chat")
    }
    c.addUser(user)
    c.run()
  } else {
    if c.hasUser(userName) {
      log.WithFields(log.Fields{
        "chat":     chatRoom,
        "username": userName,
      }).Info("User already exists in chat room")
    } else {
      user, err := newUser(userName, w, r)
      if err != nil {
        log.WithError(err).Fatal("Error creating user for chat")
      } else {
        c.addUser(user)
      }
    }
  }
}

The chat holds a list of users connected to that chat. The most important thing that the chat does is act as a coordinator between the multiple goroutines that will work to make listening, broadcasting and managing users possible.

Before we continue, we need to know what a user is. The user holds the name and the WebSocket connection for that particular user.

type user struct {
  name      string
  conn      *websocket.Conn
  listening bool
}

The user connection is an important detail because the chat uses those connections for listening to and broadcasting messages to the right client.

Now that we know how the user and chat work, let’s dive into how we can listen, broadcast and keep the users updated for each chat.

Once we create a chat room, we will call the run function, which will start three different goroutines.

func (c *chat) run() {
  go c.listen()
  go c.broadcast()
  go c.keepUserListUpdated()
}

The listen goroutine loops over all the users on the chat and creates another goroutine to listen to incoming messages. After receiving the message, we send it to the internal messages channel.

func (c *chat) listen() {
  for {
    if len(c.users) > 0 {
      for _, user := range c.users {
        if !user.listening {
          user.listening = true
          go c.listenToUser(user)
        }
      }
    }
  }
}

func (c *chat) listenToUser(user *user) {
  for {
    _, msg, err := user.conn.Read(c.ctx)
    if err == nil {
      c.messages <- message{
        bytes:  msg,
        author: user,
      }
    } else {
      c.dropUsers <- user
      break
    }
  }
}

It is essential to mention that if we get an error while listening to a message from a user, we will send a message to the dropUsers channel. We are going to use this channel on the keepUserListUpdated function.

Why do we need a separate goroutine per user? Great question. The act of listening for a message in the WebSocket connection is a blocking action. So when iterating over the users, it will stop and wait for the first user to send a message, once the user has sent a message it will iterate to the following user and repeat the process.

If we do not create a different goroutine, we will listen to the messages in the order the users have connected to the chat. But we want a real-time experience when sending and receiving messages on our CLI chat app.

The broadcast goroutine listens on the messages channel, and every time we get a new message, we will send it to all the users except the author of the message.

func (c *chat) broadcast() {
loop:
  for {
    select {
    case message := <-c.messages:
      usersToSend := c.userToSend(message.author)
      bytes, err := message.print()
      if err == nil {
        for _, user := range usersToSend {
          user.conn.Write(c.ctx, websocket.MessageText, bytes)
        }
        c.messagesRead = append(c.messagesRead, message)
      } else {
        log.WithError(err).Warn("Error building the message")
      }
    case <-c.ctx.Done():
      break loop
    }
  }
}

Finally, our last goroutine is the keepUserListUpdated, which adds and removes users on each chat. We remove users from the chat whenever an error reading from the WebSocket connection occurs. You can check listenToUser above to see the logic that handles those cases.

Remember that previously we mentioned that when there is an error listening to users, we send a message to the dropUsers channel?

The keepUserListUpdated function listens to that channel, and every time a new message comes, it deletes the user from the list of active users in the chat.

func (c *chat) keepUserListUpdated() {
loop:
  for {
    select {
    case user := <-c.addedUsers:
      users := c.users
      users = append(users, user)
      c.users = users
      c.broadcastMessage([]byte(fmt.Sprintf("%s joined %s\n", user.name, c.name)))
    case user := <-c.dropUsers:
      users := c.deleteUser(user)
      c.users = users
    case <-c.ctx.Done():
      break loop
    }
  }
}

This function also takes care of adding users to the chat similarly, with the addition of broadcasting to all users that a new user joined the chat.

And that is all there is to the server package.

Let’s dive into the client side of the CLI app. #

The client functionality is to connect to the server, listen for new messages, print them and allow the users to send new messages to the server.

In our example, the client stores the name of the user, a WebSocket connection to the server and a channel we will use to send messages.

type client struct {
  userName string
  conn     *websocket.Conn
  ctx      context.Context
  message  chan string
}

When using the CLI as a client, we must pass the chat room and the user connecting to it.

go run client/main.go --user_name=gustavo --chat_room=general.

Executing that line will open a new connection to the server using the WebSocket protocol. ws://localhost:8080/chat/general/Gustavo, notice that we haven't used httporhttpson the URL but ratherws`.

The WebSocket library we use takes care of all this logic.

Once the client has initialized, we are going to call the run method, which will start two new goroutines: listen and getInput.

The listen goroutine is very similar to the one on the server, but we do not have to loop over the different users since here we only have one user listening for messages.

func (c *client) listen() {
  for {
    _, reader, err := c.conn.Reader(c.ctx)
    if err != nil {
      log.WithError(err).Warn("Error receiving message")
      break
    } else {
      io.Copy(os.Stdout, reader)
    }
  }
}

What is that io.Copy(os.Stdout, reader) doing? It gets the message from the WebSocket and copies it to the terminal stdout. Go provides the Reader and Writer abstractions that help us manipulate data between entities that implement the reader and writer interface.

The getInput function is a simple for loop that will listen for the user input coming from the terminal stdin. Once we have a message from the user, we will send that message to the message channel of the client.

func (c *Client) getInput() {
  for {
    in := bufio.NewReader(os.Stdin)
    result, err := in.ReadString('\n')
    if err != nil {
      log.WithError(err).Fatal(err)
    }

    if result != "" {
      c.message <- result
    }
  }
}

It ensures that it is not empty since the user could use the return key but hasn’t typed anything, and we do not want to send blank messages to the server that will get broadcast to all the chat users.

Finally, we have another for loop and select listening for messages on the message channel and every time we get a new message, we will write to the server.

func (c *Client) run() {
  go c.listen()
  go c.getInput()
loop:
  for {
    select {
    case text := <-c.message:
      err := c.conn.Write(c.ctx, websocket.MessageText, []byte(text))
      if err != nil {
        break loop
      }
    case <-c.ctx.Done():
      break loop
    }
  }
  c.close()
}

And this is all that you need to create a CLI chat app with Go and WebSockets. I hope you enjoyed reading as much as I did writing the app and the article. If you have any questions, please use the comments below, and I will answer them.

You can find all the code here

One last thing, I know the functionality is far from complete, but I need more time to work more on it. If you are curious and want to improve the app, here is a list of things you could work on:

  • Broadcast all previous messages when a new user connects to the chat.
  • Handle connection retry both on the server and the client.