Writing a Chat Server in Go

Bao Nguyen
Oct 24, 2018 · 7 min read

I have been using Go for a while, but mainly for tools. So I decided to invest some time to learn more about the language, and also more about system programming, distributed programming.

The chat server was just a random idea. It is simple and also complicated enough for a sandbox project. I would try to do everything from scratch

This post is more like a summary of my experience during the exercise. If you wan to look at the source code under this github repository.

So let’s start!

The requirements

I will start with very basic features:

  • There is a single chat room
  • User can connect to the server
  • User can set their name
  • User can send the message to the room, and the message will be broadcast to all other users.

And for now, there is no persistence. User only sees messages when he/she connects to the server.

The protocols

For this prototype, the client and server communicate via TCP using a simple string protocol. I could have used the rpc package, but I want to use TCP because I rarely deal with raw network directly.

With the requirements, there are three types of commands:

  • Send command: client sends a chat message
  • Name command: client sets its name
  • Message command: server broadcasts the chat message from others

A command is a string starts with the command name, all the parameters and ends with \n.

For example, to send a “Hello” message, the client sends SEND Hello\n over the TCP socket, the server then sends MESSAGE username Hello\n to other clients.

All the message can be defined as Golang structs:

// SendCommand is used for sending new message from client
type SendCommand struct {
Message string
}
// NameCommand is used for setting client display name
type NameCommand struct {
Name string
}
// MessageCommand is used for notifying new messages
type MessageCommand struct {
Name string
Message string
}

And I can implement a reader to parse the command from a stream, and a writer to write the command back to a string. It’s nice that Go uses io.Reader and io.Writer as a generic interfaces, so the implementation is not aware that it would be used for TCP stream.

The writer is quite easy. The switch/type is nice.

type CommandWriter struct {
writer io.Writer
}
func NewCommandWriter(writer io.Writer) *CommandWriter {
return &CommandWriter{
writer: writer,
}
}
func (w *CommandWriter) writeString(msg string) error {
_, err := w.writer.Write([]byte(msg))
return err
}
func (w *CommandWriter) Write(command interface{}) error {
// naive implementation ...
var err error
switch v := command.(type) {
case SendCommand:
err = w.writeString(fmt.Sprintf("SEND %v\n", v.Message))
case MessageCommand:
err = w.writeString(fmt.Sprintf("MESSAGE %v %v\n", v.Name, v.Message))
case NameCommand:
err = w.writeString(fmt.Sprintf("NAME %v\n", v.Name))
default:
err = UnknownCommand
}
return err
}

The reader is a bit verbose, almost half of the code is for error handling. When I implemented this I really missed how easy of error handling in other languages are.

type CommandReader struct {
reader *bufio.Reader
}
func NewCommandReader(reader io.Reader) *CommandReader {
return &CommandReader{
reader: bufio.NewReader(reader),
}
}
func (r *CommandReader) Read() (interface{}, error) {
// Read the first part
commandName, err := r.reader.ReadString(' ')
if err != nil {
return nil, err
}
switch commandName {
case "MESSAGE ":
user, err := r.reader.ReadString(' ')
if err != nil {
return nil, err
}
message, err := r.reader.ReadString('\n') if err != nil {
return nil, err
}
return MessageCommand{
user[:len(user)-1],
message[:len(message)-1],
}, nil
// similar implementation for other commands default:
log.Printf("Unknown command: %v", commandName)
}
return nil, UnknownCommand
}

The full source can be found in reader.go and writer.go.

The server

Let’s start with the chat server. The interface of the server can be defined as below. I originally didn’t start with the interface at the beginning, but the interface actually helps you to define the behavior clearly.

type ChatServer interface {
Listen(address string) error
Broadcast(command interface{}) error
Start()
Close()
}

The server listens for incoming connections using Listen() method, Start() and Close() are for starting, and stopping the server, and BroadCast() is to send command to other clients.

Now, the actual implementation for the server. It is also pretty straightforward to implement. I actually added a private client struct to keep track of clients and its name.

type TcpChatServer struct {
listener net.Listener
clients []*client
mutex *sync.Mutex
}
type client struct {
conn net.Conn
name string
writer *protocol.CommandWriter
}
func (s *TcpChatServer) Listen(address string) error {
l, err := net.Listen("tcp", address)
if err == nil {
s.listener = l
}
log.Printf("Listening on %v", address) return err
}
func (s *TcpChatServer) Close() {
s.listener.Close()
}
func (s *TcpChatServer) Start() {
for {
// XXX: need a way to break the loop
conn, err := s.listener.Accept()
if err != nil {
log.Print(err)
} else {
// handle connection
client := s.accept(conn)
go s.serve(client)
}
}
}

When the server accepts the connection, it will create a client struct to keep track of clients. I need to use a mutex to avoid race condition. Goroutine is not magic bullet, you still need to deal with all kind of race condition yourself.

func (s *TcpChatServer) accept(conn net.Conn) *client {
log.Printf("Accepting connection from %v, total clients: %v", conn.RemoteAddr().String(), len(s.clients)+1)
s.mutex.Lock()
defer s.mutex.Unlock()
client := &client{
conn: conn,
writer: protocol.NewCommandWriter(conn),
}
s.clients = append(s.clients, client) return client
}
func (s *TcpChatServer) remove(client *client) {
s.mutex.Lock()
defer s.mutex.Unlock()
// remove the connections from clients array
for i, check := range s.clients {
if check == client {
s.clients = append(s.clients[:i], s.clients[i+1:]...)
}
}
log.Printf("Closing connection from %v", client.conn.RemoteAddr().String())
client.conn.Close()
}

The main serve method is to read message from client and handle each message accordingly. Since we have the protocol reader and write, the server only deals with high level message, not the raw byte stream. If the server receives SendCommand, it just broadcasts to all other clients.

func (s *TcpChatServer) serve(client *client) {
cmdReader := protocol.NewCommandReader(client.conn)
defer s.remove(client) for {
cmd, err := cmdReader.Read()
if err != nil && err != io.EOF {
log.Printf("Read error: %v", err)
}
if cmd != nil {
switch v := cmd.(type) {
case protocol.SendCommand:
go s.Broadcast(protocol.MessageCommand{
Message: v.Message,
Name: client.name,
})
case protocol.NameCommand:
client.name = v.Name
}
}
if err == io.EOF {
break
}
}
}

And finally the Broadcast() method:

func (s *TcpChatServer) Broadcast(command interface{}) error {
for _, client := range s.clients {
// TODO: handle error here?
client.writer.Write(command)
}
return nil
}

We can start the server as easy as below:

var s server.ChatServer
s = server.NewServer()
s.Listen(":3333")
// start the server
s.Start()

The full source of the server can be found here.

The client

Starting with the interface as following:

type ChatClient interface {
Dial(address string) error
Send(command interface{}) error
SendMessage(message string) error
SetName(name string) error
Start()
Close()
Incoming() chan protocol.MessageCommand
}

The client can connect to the server using Dial(), Start() and Close() are for starting and stopping the client. Send() is used send command to server. And SetName() and SendMessage() are wrapper methods to set display name and send chat message. And finally, Incoming() returns a channel to retrieve the chat messages from server.

The client struct and constructor can be defined as below. It has some private variables to keep track of the conn for the connection, and reader/writer as wrapper to send command over the wire.

type TcpChatClient struct {
conn net.Conn
cmdReader *protocol.CommandReader
cmdWriter *protocol.CommandWriter
name string
incoming chan protocol.MessageCommand
}
func NewClient() *TcpChatClient {
return &TcpChatClient{
incoming: make(chan protocol.MessageCommand),
}
}

Most of the methods are quite simple. Dial establishes the connection to the server, then create protocol reader and writer.

func (c *TcpChatClient) Dial(address string) error {
conn, err := net.Dial("tcp", address)
if err == nil {
c.conn = conn
}
c.cmdReader = protocol.NewCommandReader(conn)
c.cmdWriter = protocol.NewCommandWriter(conn)
return err
}

Send() just then uses the cmdWriter to send command to the server

func (c *TcpChatClient) Send(command interface{}) error {
return c.cmdWriter.Write(command)
}

Other methods are too simple, so I skip it here. The most important method of the client is the Start() method, where it listens for incoming messages and then send it back to the channel.

func (c *TcpChatClient) Start() {
for {
cmd, err := c.cmdReader.Read()
if err == io.EOF {
break
} else if err != nil {
log.Printf("Read error %v", err)
}
if cmd != nil {
switch v := cmd.(type) {
case protocol.MessageCommand:
c.incoming <- v
default:
log.Printf("Unknown command: %v", v)
}
}
}
}

The source of the client can be found here.

The TUI

It is quite hard to see the whole thing in action. So I spent some more time to work on a UI for the client, terminal UI to be technically correct. Go has many packages for terminal UI, but tui-go is the only package at the moment with support for text area, and it already has a nice Chat example. There is quite a bit of code so I will skip quote it in the post, you can find the full source here.

Conclusion

This is an interesting exercise. I refreshed my memory about network programming with TCP, and also learn more about terminal UI.

What is the next step? Maybe add more features such as Multi-chat rooms, or message persistence, or maybe better error handling, or … unit tests 😉.

Bao Nguyen

Written by

I write, so I learn.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade