Первоисточник: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 и многому научил меня в пользовательском интерфейсе терминала.
Что дальше? Возможно, стоит рассмотреть возможность добавления дополнительных функций, таких как несколько чатов, постоянство данных, возможно, улучшенная обработка ошибок и, конечно же, модульное тестирование. 😉