Writing a Chat Server in Go

The requirements

I will start with very basic features:

  • 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.

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.

  • Name command: client sets its name
  • Message command: server broadcasts the chat message from others
// 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
}
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
}
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 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()
}
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)
}
}
}
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()
}
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
}
}
}
func (s *TcpChatServer) Broadcast(command interface{}) error {
for _, client := range s.clients {
// TODO: handle error here?
client.writer.Write(command)
}
return nil
}
var s server.ChatServer
s = server.NewServer()
s.Listen(":3333")
// start the server
s.Start()

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
}
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),
}
}
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
}
func (c *TcpChatClient) Send(command interface{}) error {
return c.cmdWriter.Write(command)
}
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 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.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store