Написание простого чата на tcp на GO

01.04.2020 tcp server чат


Для начала необходимо запустить сервер:

l, err := net.Listen("tcp", "localhost:9090")
if err != nil {
	return
}

defer l.Close()

Сервер слушает 9090 порт на локалхосте. Это стандартная практика поднимать сервисы на локалхосте.

Cерверу необходимо принимать подключения:

conn, err := l.Accept()
if err != nil {
	return
}

Здесь пока нет никакой обработки ошибок, об этом скажу позже.

Далее, чтобы обработать присланные подключенным клиентом данные, необходимо в цикле читать данные из подключения.

Т.к. подключений может быть более одного, а чтение будет блокирующей операцией, мы не сможем в одном потоке читать данные из нескольких подключений. Решаем эту проблему созданием горутины на обработку каждого подключения:

l, err := net.Listen("tcp", "localhost:9090")
if err != nil {
	return
}

defer l.Close()

for {
	conn, err := l.Accept()
	if err != nil {
		return
	}

	go handleUserConnection(conn)
}
func handleUserConnection(c net.Conn) {
	defer c.Close()

	for {
		userInput, err := bufio.NewReader(c).ReadString('\n')
		if err != nil {
			return
		}
	}
}

Закрываем коннект в случае возникновения ошибки при чтении данных из подключения. В этом нам помогает конструкция defer — не важно каким образом функция закончит свою работу, закрытие подключения будет выполнено.

Никак не обрабатываем ошибку закрытия подключения, т.к. ошибка в том случае никак не изменит логику программы.

Мы получили работающий сервер, который только считывает данные от клиентов, но ничего более не делает. Теперь добавим логику чата. Самый простой чат будет рассылать каждое присланное на сервер сообщение всем участникам.

Для реализации "рассылки всем" после считывания данных от одного клиента мы далее должны пройтись по всем подключениям и "написать" в них новое сообщение.

Для данной цели мы должны доступным для итерирования образом хранить подключения. Для хранения выбран тип sync.Map, т.к. он позволит решить проблему конкурентного доступа из коробки.

Для простейшего перебора пользователей подойдет и slice, но поскольку у нас конкурентная программа с несколькими горутинами, нам пришлось бы заботиться о конкурентных вставке и удалении из среза, чего нельзя было бы делать без sync.Lock, а поскольку мы пишем максимально простой чат, то мы воспользуемся встроенным типом, решающим эту проблему.

// Using sync.Map to not deal with concurrency slice/map issues
var connMap = &sync.Map{}

Важный момент — работаем именно с указателем на sync.Map, не со значением. Структура sync.Map содержит в себе sync.Lock. Существует известная проблема — копирование sync.Lock.

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

При отключении пользователей необходимо так же чистить от них мапу, чтобы не итерировать тех, кто никакие сообщения не получит. Для чистки мапы идентификаторы пользователей передадим в функцию обработки коннекта.

id := uuid.New().String()
connMap.Store(id, conn)

Теперь обеспечим наконец рассылку сообщений:

for {
	userInput, err := bufio.NewReader(c).ReadString('\n')
	if err != nil {
		return
	}

	connMap.Range(func(key, value interface{}) bool {
		if conn, ok := value.(net.Conn); ok {
			conn.Write([]byte(userInput))
		}

		return true
	})
}

Булево значение, возвращаемое в анонимной функции итерирования мапы позволяет прекратить итерирование, если вернуть false. У нас нет необходимости останавливать итерирование.

В конце я добавил обработку ошибок в виде логгирования с помощью логгера zap. В итоге получается следующий объемный фрагмент кода. Полные исходники также доступны на гитхабе.

package main

import (
	"bufio"
	"net"
	"sync"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

func main() {
	var loggerConfig = zap.NewProductionConfig()
	loggerConfig.Level.SetLevel(zap.DebugLevel)

	logger, err := loggerConfig.Build()
	if err != nil {
		panic(err)
	}

	l, err := net.Listen("tcp", "localhost:9090")
	if err != nil {
		return
	}

	defer l.Close()

	// Using sync.Map to not deal with concurrency slice/map issues
	var connMap = &sync.Map{}

	for {
		conn, err := l.Accept()
		if err != nil {
			logger.Error("error accepting connection", zap.Error(err))
			return
		}

		id := uuid.New().String()
		connMap.Store(id, conn)

		go handleUserConnection(conn, connMap, logger)
	}
}

func handleUserConnection(id string, c net.Conn, connMap *sync.Map, logger *zap.Logger) {
	defer func(){
		c.Close()
		connMap.Delete(id)
	}()

	for {
		userInput, err := bufio.NewReader(c).ReadString('\n')
		if err != nil {
			logger.Error("error reading from client", zap.Error(err))
			return
		}

		connMap.Range(func(key, value interface{}) bool {
			if conn, ok := value.(net.Conn); ok {
				if _, err := conn.Write([]byte(userInput)); err != nil {
					logger.Error("error on writing to connection", zap.Error(err))
				}
			}

			return true
		})
	}
}

Другие статьи