[譯]用Golang編寫一個簡易聊天室

野生程式元發表於2019-10-23

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

[譯]用Golang編寫一個簡易聊天室

我使用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的知識。

接下來要做什麼?或許可以考慮增加更多的功能,例如多聊天室,資料持久化,也或許是更好的錯誤處理,當然不能忘了,還有單元測試。?

相關文章