Golang

Протокол WebSocket предоставляет двунаправленный (сервер и клиент могут обмениваться сообщениями) и полнодуплексный (сервер или клиент могут отправлять сообщения одновременно) канал связи, подходящий для сценариев реального времени, таких как приложения-чаты и т. д. Подключенные пользователи чата (клиенты) могут отправлять сообщения в приложение (на сервер WebSocket) и обмениваться ими друг с другом — аналогично тому, что происходит в одноранговой сети.

В этой статье мы научимся создавать простое приложение-чат с помощью WebSocket и Go. При создании приложения также будет использован Redis (подробнее об этом далее).

Вы освоите:

  • структуры данных Redis (это приложение использует SET и PUBSUB);
  • взаимодействие с Redis с помощью клиента go-redis;
  • библиотеку gorilla WebSocket с полной и протестированной реализацией протокола WebSocket;
  • Azure Cache for Redis (управляемое в облаке предложение Redis).

Почему Redis?

Посмотрим, как создаётся приложение-чат. При подключении пользователя первым делом внутри приложения (на сервере WebSocket) создаётся соответствующее соединение WebSocket, которое связано с отдельным экземпляром приложения. Благодаря этому соединению WebSocket пользователи чата имеют возможность отправлять друг другу сообщения. Мы можем осуществить (горизонтальное) масштабирование нашего приложения (например, для охвата большой базы пользователей), запустив несколько экземпляров. Теперь каждого нового пользователя можно подключить к новому экземпляру. Таким образом, у нас есть сценарий, в котором разные пользователи (вместе с соответствующими подключениями WebSocket) связаны с разными экземплярами, но не имеют возможности обмениваться сообщениями друг с другом. А это неприемлемо даже для нашего простенького приложения-чата. ?

Redis — это универсальное хранилище данных в формате «ключ — значение», которое поддерживает самые разные структуры данных с широкой функциональностью (List, Set, Sorted Set, Hash и другие). Одной из функциональных возможностей является также PubSub, с помощью которой издатели могут отправлять сообщения на канал(ы) Redis, а подписчики могут прослушивать сообщения на этом(их) канале(ах) абсолютно независимо, будучи не связанными друг с другом. Этим можно воспользоваться для решения нашей проблемы. Вместо того чтобы зависеть только от подключений WebSocket, мы можем использовать Redis channel, на который можно подписать любое приложение-чат. Так сообщения, отправляемые на соединение WebSocket, теперь могут передаваться по каналу Redis, что обеспечивает их получение всеми экземплярами приложения (и связанными с ними пользователями чата).

Подробнее поговорим об этом далее, когда перейдём к коду. Он доступен на Github.

Обратите внимание, что вместо обычногоWebSocketможно также использовать технологии типаAzure SignalR, которые позволяют приложениям отправлять обновления контента подключенным клиентам, например одностраничному веб-сайту или мобильному приложению.В результате клиенты обновляются без необходимости опрашивать сервер или отправлять новые HTTP-запросы на обновления.

Дальше для развертывания этого решения на Azure вам потребуется учётная запись в Microsoft Azure. Если у вас её ещё нет, сейчас можно получить её бесплатно!

Принцип работы приложения-чата

А теперь быстренько разберём код. Вот структура приложения:

.
├── Dockerfile
├── chat
│   ├── chat-session.go
│   └── redis.go
├── go.mod
├── go.sum
├── main.go

В main.go регистрируем наш обработчик WebSocket и запускаем веб-сервер. Здесь надо использовать обычный пакет net/http:

http.Handle("/chat/", http.HandlerFunc(websocketHandler))
	server := http.Server{Addr: ":" + port, Handler: nil}
	go func() {
		err := server.ListenAndServe()
		if err != nil && err != http.ErrServerClosed {
			log.Fatal("failed to start server", err)
		}
	}()

WebSocket обрабатывает пользователей чата (которые являются не кем иным, как клиентами WebSocket) и запускает новый чат.

func websocketHandler(rw http.ResponseWriter, req *http.Request) {
	user := strings.TrimPrefix(req.URL.Path, "/chat/")	peer, err := upgrader.Upgrade(rw, req, nil)
	if err != nil {
		log.Fatal("websocket conn failed", err)
	}
	chatSession := chat.NewChatSession(user, peer)
	chatSession.Start()
}

ChatSession (часть chat/chat-session.go) представляет пользователя и соответствующее ему соединение WebSocket (на стороне сервера).

type ChatSession struct {
	user string
	peer *websocket.Conn
}

Когда чат начинает работу, он запускает goroutine для приёма сообщений от пользователя, только что присоединившегося к чату. Это делается с помощью вызова ReadMessage() (из websocket.Conn) в цикле for. Выполнение горутины завершается (exit), если пользователь отсоединяется (закрывается соединение WebSocket), или выключается приложение (например, нажатием ctrl+c). Для каждого пользователя создаётся отдельная горутина под его сообщения в чате.

func (s *ChatSession) Start() {
...
	go func() {
		for {
			_, msg, err := s.peer.ReadMessage()
			if err != nil {
				_, ok := err.(*websocket.CloseError)
				if ok {
					s.disconnect()
				}
				return
			}
			SendToChannel(fmt.Sprintf(chat, s.user, string(msg)))
		}
	}()

Когда сообщение от пользователя получено (через соединение WebSocket), оно пересылается другим пользователям с помощью функции SendToChannel, которая является частью chat/redis.go. Она передаёт сообщение на канал Redis pubsub.

func SendToChannel(msg string) {
	err := client.Publish(channel, msg).Err()
	if err != nil {
		log.Println("could not publish to channel", err)
	}
}

Важная роль в этой задаче отводится sub (подписчику). В отличие от случая, когда для каждого подключённого пользователя чата выделялась отдельная горутина, мы используем единую горутину (в рамках приложения), с тем чтобы и подписываться на канал Redis, и получать сообщения, и пересылать их всем пользователям через соответствующее их соединение WebSocket.

func startSubscriber() {
	go func() {
		sub = client.Subscribe(channel)
		messages := sub.Channel()
		for message := range messages {
			from := strings.Split(message.Payload, ":")[0]
			for user, peer := range Peers {
				if from != user {
					peer.WriteMessage(websocket.TextMessage, []byte(message.Payload))
				}
			}
		}

Подписка завершается, когда экземпляр приложения выключается. А это, в свою очередь, останавливает цикл канала for-range, и выполнение горутины завершается.

Функция startSubscriber вызывается из функции init() в redis.go. Функция init()запускается при подключении к Redis, а в случае сбоя подключения приложение завершает работу.

Теперь настроим экземпляр Redis, к которому можно подключить внутреннюю часть нашего приложения-чата. Давайте создадим сервер Redis в облаке!

Настройка Azure Redis Cache

Azure Redis Cache предоставляет доступ к защищённому выделенному кэшу Redis, который размещён в Azure и доступен любому приложению как в платформе Azure, так и за её пределами.

Для достижения наших целей мы будем настраивать Azure Redis Cache с уровнем Basic, который предусматривает кэш одного узла и идеально подходит для разработки/тестирования и некритичных рабочих нагрузок. А кроме базового, можно выбрать уровень Standard или Premium с дополнительным набором различных функциональных возможностей, в том числе постоянным хранением данных, кластеризацией, георепликацией и другими.

Для установки будем использовать Azure CLI. Если вам привычнее работать в браузере, можно также воспользоваться облачной оболочкой Azure Cloud Shell.

А быстро настроить экземпляр Azure Redis Cache можно командой az redis create. Например, вот так:

az redis create --location westus2 --name chat-redis --resource-group 
chat-app-group --sku Basic --vm-size c0

Загляните в пошаговое руководство по созданию Azure Cache для Redis.

По завершении вам понадобится информация для подключения к экземпляру Azure Redis Cache, т.е. узел, порт и клавиши быстрого доступа. Получаем эту информацию также через CLI. Например, вот так:

//имя узла и порт (SSL)
az redis show --name chat-redis --resource-group chat-app-group 
--query [hostName,sslPort] --output tsv

//первичный ключ доступа
az redis list-keys --name chat-redis --resource-group chat-app-group --query [primaryKey] --output tsv

Загляните в пошаговое руководство по получению имени узла, портов и ключей для Azure Cache для Redis”.

Вот и всё…

…поболтаем?

Для простоты приложение будет доступно в виде докерного образа.

Первым делом зададим несколько переменных окружения:

//используем порт 6380 для SSL
export REDIS_HOST=[redis cache host name as obtained from CLI]:6380
export REDIS_PASSWORD=[redis cache primary access key as obtained from CLI]
export EXT_PORT=9090
export NAME=chat1

Приложение использует статический порт 8080 внутренне (для веб-сервера). Мы используем внешний порт, указываемый в строке с EXT_PORT, и сопоставляем его с портом 8080 внутри нашего контейнера (используя -p $EXT_PORT:8080).

Запускаем докерный контейнер:

docker run --name $NAME -e REDIS_HOST=$REDIS_HOST -e REDIS_PASSWORD=$REDIS_PASSWORD -p $EXT_PORT:8080 abhirockzz/redis-chat-go

Теперь можно присоединиться к чату! Вы можете использовать любой клиент WebSocket. Я предпочитаю использовать wscat в терминале и/или расширение WebSocket для Chrome в браузере.

Открываем два отдельных терминала, чтобы с помощью wscat сымитировать действия двух разных пользователей:

//терминал 1 (пользователь "foo")
wscat -c ws://localhost:9090/chat/foo

//терминал 2 (пользователь "bar")
wscat -c ws://localhost:9090/chat/bar

Вот как может выглядеть чат с участием пользователей foo и bar:

foo подключается первым и получает приветственное сообщение: «Добро пожаловать, foo!». После foo к чату присоединяется и bar, получающий аналогичное приветственное сообщение: «Добро пожаловать, bar!». Причём foo получил уведомление о том, что bar подключился к чату. foo и bar обменялись несколькими сообщениями, прежде чем bar покинул чат (foo получил уведомление и об этом тоже).

Чтобы потренироваться, вы можете запустить свой экземпляр приложения-чата. Разверните ещё один докерный контейнер с другим значением для внешнего порта EXT_PORT и названием чата. Например, такой:

//используем порт 6380 для SSL
export REDIS_HOST=[redis cache host name as obtained from CLI]:6380
export REDIS_PASSWORD=[redis cache primary access key as obtained from CLI]
export EXT_PORT=9091
export NAME=chat2

docker run --name $NAME -e REDIS_HOST=$REDIS_HOST -e REDIS_PASSWORD=$REDIS_PASSWORD -p $EXT_PORT:8080 abhirockzz/redis-chat-go

Теперь подключаемся через порт 9091 (или выбранный вами порт), чтобы сымитировать действия другого пользователя:

//пользователь "pi"
wscat -c ws://localhost:9091/chat/pi

foo всё ещё активен в чате, поэтому он получит уведомление о прибытии нового участника pi, с которым они теперь могут обменяться любезностями.

Подтверждение от Redis

Давайте получим подтверждение, заглянув в структуры данных Redis. Для этого можно воспользоваться redis-cli. При работе с Azure Redis Cache я бы рекомендовал очень полезную веб-консоль для Redis.

У нас есть SET (с названием chat-users), в котором хранятся активные пользователи:

SMEMBERS chat-users

Теперь вы должны увидеть такой результат:

1) "foo"
2) "bar"

Это означает, что пользователи foo и bar сейчас подключены к приложению-чату и имеют активное подключение WebSocket.

А что с каналом PubSub?

PUBUSB CHANNELS

Так как для всех пользователей у нас один канал, вы должны получить такой результат от сервера Redis:

1) "chat"

Вот и всё.

Читайте также:


Перевод статьи Abhishek Gupta: Let’s learn how to to build a chat application with Redis, WebSocket and Go