[Перевод] Напишите простой чат на Golang

Go

Первоисточник:medium.com/@ забери сумку/напиши…

Я также некоторое время использовал Go для написания некоторых инструментов. Далее я решил потратить больше времени и подумал изучить его углубленно, основное направление это системное программирование и распределенное программирование.

Этот чат был вспышкой на сковороде. Для одного из моих проектов-песочниц это достаточно лаконично, но не слишком просто. Я постараюсь написать этот проект с 0.

Эта статья является скорее кратким изложением моей практики написания программ на Go, если вы более склонны смотреть на исходный код, вы можете проверить мой githubпроект.

необходимость

Основные функции чата:

  • простой чат
  • Пользователь может подключиться к этому чату
  • Пользователи могут установить свое имя пользователя при подключении
  • Пользователи могут отправлять в нем сообщения, и сообщение будет транслироваться всем остальным пользователям

В настоящее время в чате нет сохранения данных, и пользователь может видеть только те сообщения, которые он получил после входа в систему.

Протокол

Клиент и сервер взаимодействуют через TCP через строки. Изначально я планировал использовать протокол RPC для передачи данных, но одна из основных причин использования TCP в итоге заключается в том, что я не часто соприкасаюсь с базовыми операциями потока данных TCP, а RPC смещен в сторону более высокого уровня. коммуникационные операции уровня, поэтому я также хочу воспользоваться этой возможностью. Попробуйте и научитесь.

С учетом вышеуказанных требований можно получить следующие три инструкции:

  • Отправить инструкцию (SEND): клиент может отправлять сообщения в чат
  • Команда Name (Имя): клиент задает имя пользователя
  • Инструкция сообщения (СООБЩЕНИЕ): сервер транслирует сообщения чата другим пользователям

Каждая директива представляет собой строку, начинающуюся с имени директивы, с аргументами/содержимым между ними, заканчивающуюся\nЗаканчивать.

Например, чтобы отправить сообщение «Привет», клиент поместит строкуSEND Hello\nОтправить в сокет TCP, сервер будет транслировать после его принятия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чтобы преобразовать эти потоки байтов обратно в строки. Перейти будетio.Readerа такжеio.WriterПоскольку общий интерфейс является очень хорошей практикой, так что интеграции не нужно заботиться о реализации части потока байтов TCP.

Писателю легче писать

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

запись на стороне сервера

Сначала определите серверinterface, я не определял напрямуюstructПотому чтоinterfaceДелает поведение этого сервера более понятным.

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

Теперь, чтобы начать писать фактический серверный метод, я предпочитаю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)
        }
    }
}

Когда сервер принимает соединение, он создает соответствующий клиент для отслеживания пользователя. В то же время мне нужно использовать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Основная логика метода заключается в отправке команд от клиента и их обработке в соответствии с командами. Поскольку у нас есть протокол связи между читателями и писателями, серверу нужно иметь дело только с высокоуровневой информацией, а не с низкоуровневым двоичным потоком. Если сервер получает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
}

Код для запуска этого сервера относительно прост

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

полный код серверакликните сюда

Написание клиента

Так же мы используем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, а модуль чтения/записи — это инкапсуляция метода отправки сообщений.

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Устанавливает соединение и создает модуль чтения и записи для протокола связи.

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

Другие методы относительно просты, и я не буду вдаваться в подробности в этой статье. Наиболее важным методом является клиентStartметод, который используется для прослушивания сообщений, транслируемых сервером, и отправки их обратно в канал.

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

Я потратил некоторое время на написание пользовательского интерфейса на стороне клиента, что делает весь проект более наглядным, и здорово отображать пользовательский интерфейс непосредственно на терминале. В Go есть множество сторонних пакетов для поддержки пользовательского интерфейса терминала, ноtui-goединственный, который я нашел до сих пор, который поддерживает текстовые поля, и у него уже естьОчень хороший пример чата. Здесь значительная часть кода из-за ограниченного места, поэтому я не буду вдаваться в подробности.кликните сюдаОзнакомьтесь с полным кодом.

в заключении

Это, несомненно, очень интересное упражнение.Весь процесс освежил мое понимание сетевого программирования TCP и многому научил меня в пользовательском интерфейсе терминала.

Что дальше? Возможно, стоит рассмотреть возможность добавления дополнительных функций, таких как несколько чатов, постоянство данных, возможно, улучшенная обработка ошибок и, конечно же, модульное тестирование. 😉