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

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

我使用Go來編寫一些工具也有一段時間了。接下來我決定花更多的時間和心思去深刻學習它,主要的方向是系統編程以及分佈式編程。github

這個聊天室是靈光一現所得。對於一個個人沙盒項目而言,它足夠的簡潔但也不至於太過簡單。我會盡可能嘗試從0開始去編寫這個項目。golang

本文更像是一份我在練習如何去用Go編寫程序時的總結,若是你更趨向於看源代碼,你能夠查看我github的項目編程

需求

聊天室的基礎的功能:網絡

  • 一個簡單的聊天室
  • 用戶能夠鏈接到這個聊天室
  • 用戶能夠設置他們鏈接時的用戶名
  • 用戶能夠在裏面發消息,而且消息會被廣播給全部其餘用戶

目前聊天室是沒有作數據持久化的,用戶只能看到他/她登錄之後所接收到的消息。併發

通信協議

客戶端和服務端經過字符串進行TCP通信。我本來打算使用RPC協議進行數據傳輸,可是最後仍是採用TCP的一個主要緣由是我並非很常常去接觸到TCP底層的數據流操做,而RPC偏向於上層的通信操做,因此也想借此機會嘗試和學習一下。app

有了以上需求能引伸出如下3個指令:socket

  • 發送指令(SEND):客戶端能夠發送聊天消息
  • 命名指令(Name):客戶端設置用戶名
  • 消息指令(MESSAGE):服務端廣播聊天消息給其餘用戶

每一個指令都是字符串,以指令名稱開始,中間帶有參數/內容,以\n結束。tcp

例如,要發送一個「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的知識。

接下來要作什麼?或許能夠考慮增長更多的功能,例如多聊天室,數據持久化,也或許是更好的錯誤處理,固然不能忘了,還有單元測試。😉

相關文章
相關標籤/搜索