Writ…

I’ve been using Go to write some tools for a while. Then I decided to spend more time and effort to learn it in depth, mainly in the direction of system programming and distributed programming.

This chat room was an Epiphany. For one of my sandbox projects, it was simple enough but not too simple. I will try to write this project from zero as much as possible.

This article is more like a summary of my practice on how to write programs in Go, but if you prefer to look at source code, you can check out my Project on Github.

demand

Basic features of chat rooms:

  • A simple chat room
  • Users can connect to this chat room
  • Users can set the username they want to connect to
  • Users can send messages inside, and the messages are broadcast to all other users

At present, chat rooms do not do data persistence, the user can only see the message he or she received after logging in.

Communication protocol

The client communicates with the server through a string. I originally planned to use RPC protocol for data transmission, but one of the main reasons why I chose TCP in the end is that I am not often exposed to the data flow operations at the bottom of TCP, while RPC tends to the communication operations at the top, so I want to take this opportunity to try and learn.

With the above requirements, the following three instructions can be extended:

  • SEND: The client can SEND chat messages
  • Naming directive (Name) : Client set user Name
  • MESSAGE command: a server broadcasts chat messages to other users

Each instruction is a string, starting with the instruction name, with arguments/content in the middle, and ending with \n.

For example, to SEND a “Hello” MESSAGE, the client submits the string SEND Hello\n to the TCP socket. After receiving the MESSAGE, the server broadcasts MESSAGE username Hello\n to other users.

Written instructions

First define the struct to represent all the instructions

// 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
}
Copy the code

Next, I inherit a Reader to convert these commands into bytes, and use Writer to convert these bytes back into strings. Go’s use of IO.Reader and IO.Writer as generic interfaces is a good way to integrate without having to worry about the IMPLEMENTATION of the TCP byte stream.

Writer is easy to write

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
}
Copy the code

The Reader code is relatively long, and about half of it is error handling. So while writing this part of the code I missed other programming languages that made error handling very easy.

type CommandReader struct {
   reader *bufio.Reader
}

func NewCommandReader(reader io.Reader) *CommandReader {
   return &CommandReader{
      reader: bufio.NewReader(reader),
   }
}

func (r *CommandReader) Read(a) (interface{}, error) {
   // Read the first part
   commandName, err := r.reader.ReadString(' ')
   iferr ! =nil {
      return nil, err
   }
   switch commandName {
     case "MESSAGE ":
       user, err := r.reader.ReadString(' ')
       iferr ! =nil {
         return nil, err
       }
       message, err := r.reader.ReadString('\n')
       iferr ! =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
}
Copy the code

The complete code can be viewed here for reader.go and writer.go

Server-side writing

I didn’t define a struct directly because interface makes the behavior of the server clearer.

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

Now that I’m writing the actual server method, I prefer to add a private client attribute to the struct to make it easier to track connections to the user using another username

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(a) {
    s.listener.Close()
}

func (s *TcpChatServer) Start(a) {
    for {
        // XXX: need a way to break the loop
        conn, err := s.listener.Accept()
        iferr ! =nil {
            log.Print(err)
        } else {
           // handle connection
           client := s.accept(conn)
           go s.serve(client)
        }
    }
}
Copy the code

When a server accepts a connection, it creates a client to trace the user. At the same time, I need to lock this shared resource with Mutex to avoid inconsistent data sent by concurrent requests. Goroutine is a great feature, but you still need to keep an eye out for some concurrent data handling issues.

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()
}
Copy the code

The main logic of the serve method is to send commands from the client and process them accordingly. Since we have the reader and Writer communication protocols, the server only processes the higher-level information rather than the lower-level binary stream. If the server receives the SEND command, it broadcasts the message to other users.

func (s *TcpChatServer) serve(client *client) {
    cmdReader := protocol.NewCommandReader(client.conn)
    defer s.remove(client)
    for {
        cmd, err := cmdReader.Read()
        iferr ! =nil&& err ! = io.EOF { log.Printf("Read error: %v", err)
        }
        ifcmd ! =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
}
Copy the code

The code to start the server is relatively simple

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

The full server code is here

Client-side writing

Again, we use interface to define the client first

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
}
Copy the code

The client connects to the server through Dial(), Start() Close() stops and shuts down the service, and Send() sends instructions. SetName() and SendMessage() are responsible for setting the username and logical encapsulation of the sent message. Finally, Incoming() returns a channel, which is set up to communicate with the server.

Define the struct of the client, which sets some private variables to track the conn of the connection, and the reader/writer is a wrapper around the method of sending messages.

type TcpChatClient struct {
    conn      net.Conn
    cmdReader *protocol.CommandReader
    cmdWriter *protocol.CommandWriter
    name      string
    incoming  chan protocol.MessageCommand
}

func NewClient(a) *TcpChatClient {
   return &TcpChatClient{
       incoming: make(chan protocol.MessageCommand),
   }
}
Copy the code

All methods are relatively simple. Dial establishes connections and creates readers and writers for communication protocols.

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
}
Copy the code

Send sends the configuration to the server using cmdWriter

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

Other methods are relatively simple and I won’t go over them in this article. The most important method is the client’s Start method, which listens for messages broadcast by the server and sends them back to the channel.

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

The full code for the client is here

TUI

I spent some time writing the UI on the client side, which made the whole project more visual, and it was cool to display the UI directly on the terminal. Go has a number of third-party packages to support terminal UI, but Tui-Go is the only one I’ve found so far that supports text boxes, and it already has a pretty good chat example. Here is a portion of quite a bit of code that I won’t go over because I don’t have enough space, but you can see the full code here.

conclusion

It was a very interesting exercise, refreshing my knowledge of TCP network programming and learning a lot about terminal UI.

What’s next? You might consider adding more features, such as multi-chat rooms, data persistence, perhaps better error handling, and of course, unit testing. 😉