[译]用Golang编写一个简易聊天室

6,549 阅读6分钟

原文出处:medium.com/@nqbao/writ…

我使用Go来编写一些工具也有一段时间了。接下来我决定花更多的时间和心思去深入学习它,主要的方向是系统编程以及分布式编程。

这个聊天室是灵光一现所得。对于一个我的沙盒项目而言,它足够的简洁但也不至于太过简单。我会尽量尝试从0开始去编写这个项目。

本文更像是一份我在练习如何去用Go编写程序时的总结,如果你更趋向于看源代码,你可以查看我github的项目

需求

聊天室的基础的功能:

  • 一个简单的聊天室
  • 用户可以连接到这个聊天室
  • 用户可以设置他们连接时的用户名
  • 用户可以在里面发消息,并且消息会被广播给所有其他用户

目前聊天室是没有做数据持久化的,用户只能看到他/她登陆以后所接收到的消息。

通讯协议

客户端和服务端通过字符串进行TCP通讯。我原本打算使用RPC协议进行数据传输,但是最后还是采用TCP的一个主要原因是我并不是很经常去接触到TCP底层的数据流操作,而RPC偏向于上层的通讯操作,所以也想借此机会尝试和学习一下。

有了以上需求能引申出以下3个指令:

  • 发送指令(SEND):客户端可以发送聊天消息
  • 命名指令(Name):客户端设置用户名
  • 消息指令(MESSAGE):服务端广播聊天消息给其他用户

每个指令都是字符串,以指令名称开始,中间带有参数/内容,以\n结束。

例如,要发送一个“Hello”的消息,用户端会将字符串SEND Hello\n 提交给TCP socket,服务端接受后会广播MESSAGE username Hello\n给其他用户。

指令编写

首先定义好struct来表示所有的指令

// 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
}

接下来我会继承一个reader来将这些命令转化成字节流,再通过writer去将这些字节流转化回字符串。Go将 io.Reader以及io.Writer作为通用的接口是一个非常好的做法,可以使得集成的时候不需要去关心TCP字节流部分的实现。

Writer的编写比较容易

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
}

Reader的代码相对长一些,将近一半的代码是错误处理。所以在编写这一部分代码的时候我就会想念其他错误处理非常简易的编程语言。

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
}

完整的代码可以在此处查看reader.go以及writer.go

服务端编写

先定义一个server的interface,我没有直接定义一个struct是因为interface能让这个server的行为更加清晰明了。

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

现在开始编写实际的server的方法,我倾向于在struct中增加一个私有属性clients,为了方便跟踪连接的用户以其他的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() {
    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)
        }
    }
}

当服务端接受一个连接时,它会创建对应的client去跟踪此用户。同时我需要用mutex去锁定此共享资源,避免并发请发下的数据不一致问题。Goroutine是一个强大的功能,但你依然需要自己去留意和注意一些并发情况下的数据处理问题。

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()
}

serve方法主要的逻辑是从客户端发送过来的指令并且根据指令的不同去处理他们。由于我们有reader和writer的通讯协议,所以server只要处理高层的信息而不是底层的二进制流。如果server接收到SEND命令,则会广播信息给其他用户。

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
}

启动这个server的代码相对简单

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

完整的server代码戳这里

客户端编写

同样我们使用interface先定义客户端

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
}

客户端通过Dial()连接到服务端,Start() Close()负责停止和关闭服务,Send()用于发送指令。SetName()SendMessage()负责设置用户名以及发送消息的逻辑封装。最后Incoming()返回一个channel,作为和服务端建立起来作为通讯的连接通道。

下来定义客户端的struct,里面设置一些私有变量用于跟踪连接的conn,同时reader/writer是发送消息放方法的封装。

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),
   }
}

所有的方法都相对简单,Dial建立连接并且创建通讯协议的reader和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使用cmdWriter将制定发送到服务端

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

其他方法相对简单我就不一一在本文赘述。最重要的方法是client的Start方法,这是用来监听服务端广播的消息并且将他们发送回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)
        }
      }
   }
}

客户端的完整代码戳这里

TUI

我花了一些时间在客户端的UI的编写上,这能让整个项目更加可视化,直接在终端上显示UI是一件很酷的事情。Go有很多第三方的包去支持终端UI,但是tui-go是目前为止我发现的唯一一个支持文本框的,并且它已经有一个非常不错的聊天示例。这里是一部分相当多的代码由于篇幅有限就不在赘述,又可以戳这里查看完整的代码。

结论

这无疑是一个非常有趣的练习,整个过程下来刷新了我对TCP网络编程的认识以及学到了很多终端UI的知识。

接下来要做什么?或许可以考虑增加更多的功能,例如多聊天室,数据持久化,也或许是更好的错误处理,当然不能忘了,还有单元测试。😉