Погружение в websockets

План

1) Введение

2) Выбор socket.io и теория

3) Определение пользователя online

4) Отправка сообщения с участием сокета

5) Выводы


Введение

Всем привет, меня зовут Александр, я являюсь фронтенд разработчиком более 4-х лет. В этой статье хочу поделится тем, как я вспоминал работу с websockets. В своей работе я не сталкивался с тем, чтобы работать с сокетами в коммерческой разработке, но интерес к ним у меня был всегда. Ранее была попытка работы с ними, но она была поверхностна и я удалил этот код, когда чистил свой гитхаб от старого кода. В этот раз я решил погрузится более глубже в тему. Давайте посмотрим, что из этого получится в дальнейшем.


Выбор socket.io и теория

Начнем с того, что вспомним, что такое websockets. Копировать обозначение из документации я не буду, опишу своими словами. Websokets — это протокол связи, который обеспечивает беспрерывный поток связи между клиентом и сервером.


Когда я начал выбирать, каким образом я буду взаимодействовать с websockets на стороне клиента и сервера, то у меня было три варианта развития событий: нативно, библиотеки socket.io или ws. Работать нативно, чтобы изобретать собственный велосипед, я не хотел и у меня не было так много свободного времени на это, поэтому было принято решение посмотреть в сторону двух вышеупомянутых библиотек. Их выбор был обусловлен тем, что на беке использовался фреймворк nestjs. В его документации и в иных статьях на эту тематику из под коробки используется socket.io, поэтому было принято решение использовать эту библиотеку.


Библиотека ws также упоминается в документации к nestjs, но к ней не было готовых примеров, поэтому от нее было решено отказаться, не смотря на ее большую популярность.


Перед тем, как окончательно принимать решение использовать ли socket.io в своей работе, я решил ее более глубже изучить. Во время ознакомления мне было не понятно три опции в конфигурации: path, namespaces, room; и как они работают на практике. По итогу поисков выяснил, как они работают и что обозначают:


  • path — путь, который указывается в опциях конфигурации при подключении;
  • namespaces — пространство имен;
  • room — подканал namespaces, позволяет разграничивать пользователей при отправке данных.


Namespaces и room играют одну роль — позволяют в одном соединении прописывать разную логику, только namespaces могут еще работать с room. В дополнение также необходимо добавить, что комнаты работают исключительно на стороне сервера. Вы не можете подключится к комнате с помощью сокета на стороне клиента. Это должно произойти на стороне сервера. Чтобы решить эту проблему, вам нужно создать сокет с комнатой, к которой вы хотите присоединится в качестве данных, а на сервере вы прослушиваете этот сокет и вызываете метод socket.join с именем или идентификационным номером комнаты. В дальнейшем этот процесс будет подробно описан на живом примере.


Определение пользователя online

Начнем с настройки бекенда. В nest для работы с сокетами используется декоратор WebSocketGateway. Для более удобной работы также используется интерфейс, чтобы прописать функции жизненного цикла, в данном случае это OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect. Сам Gateway в nestjs является провайдером и с ним можно работать как с обычным сервисом. На этом с общей частью заканчиваем и переходим к самому вкусному: как у меня организовано подключение клиента к серверу и его отключение, авторизация пользователя и хранение пользователей в онлайне.


Пример реализации websockets


На картинке «Пример реализации websockets» показана реализация декоратора WebSocketGateway и интерфейсов OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect для работы с функциями жизненного цикла.


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


Развернутая функция handleConnection


На изображении «Развернутая функция handleConnection» отображен весь функционал функции handleConnection. Давайте подробно разберем, что в ней происходит. Через handsnake, в дальнейшем рукопожатие, websocket передает токен пользователя с клиента на сторону сервера. Далее проверяется актуален ли токен и после этого по нему ищется username самого пользователя, а уже через username можно будет получить экземпляр нужного пользователя из базы данных. После того, как пользователь найден, он заносится в массив map, в логах выводится информация, что такой-то пользователь зашел и на сторону клиента отправляется событие, которое сообщается актуальные данные обо всех пользователях онлайн.


Соответственно у опытных разработчиков может возникнуть вопрос: почему нельзя использовать guards и другие декораторы для того, чтобы вынести функционал авторизации из сервиса по работе с сокетами? Давайте разбираться, в документации и одной из статей мною было вычитано, что guards и декораторы не работают для метода handleConnection в nestjs, потому что они подключаются только к контроллеру, а сами сокеты подключаются в сервисе. По этой причине вся логика авторизации и заключена в методе handleConnection в настоящее время. Для того, чтобы передавать подобие заголовков в запросе в библиотеке socket.io используется рукопожатие. С его помощью мною спокойно был передан токен авторизации пользователя и через него удалось получить все нужные данные по пользователю, само получение пользователя описано выше. Примечание, рукопожате (handshake) является разработкой библиотеки socket.io, если бы я писал на нативных вебсокетах, то использовал дополнительный вызов для добавления пользователя в онлайн со стороны клиента.


Допустим, даже если получится создать свой guard для проверки авторизации в вебсокетах, то тогда придется отказаться от ключевого декоратора WebSocketGateway. В моем случае — это недопустимо, потому что в нем задается порт подключения и пространство имен (namespaces). В дополнение, мне не понятно в каком месте жизненного цикла будет вызываться вышеупомянутый guard. Поэтому даже с этой точки зрения использование guards и иных декораторов в данном сценарии невозможно.


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


Развернутая функция handleEmitOnline


Как видно по развернутой функции handleEmitOnline на изображении «Развернутая функция handleEmitOnline», то в ней достаются все пользователи и отдаются на сторону клиента. Это сделано по причине, потому что используется что-то вроде связи один ко многим и по этой нельзя отдавать отфильтрованные данные. Давайте постараюсь объяснить, что это значит. Представим, что есть два и более клиента, которые будут подключатся к серверу с сокетами, в каком порядке, в данном примере не важно. Зашел первый клиент, его обработали по ранее описанному алгоритму и добавили в массив с клиентами, которые находятся в онлайне, и перед тем как вернуть данный массив его необходимо отфильтровать, чтобы в этом массиве не было зашедшего пользователя. Хорошо, первый пользователь получает отфильтрованный пустой массив, так как, кроме него там больше никого нет. Теперь заходит второй пользователь, его также заносим в массив с клиентами, которые находятся в онлайне, и вот здесь начинается самое интересное. Массив фильтруется и двум пользователям будут отправлены массивы, но первому придет некорректный массив, а второму корректный. Это произошло потому что сервер не знает, на каком клиенте какой пользователь активен. В теории можно вычислить какие пользователи находятся в онлайн на стороне бека, но это займет много времени и такой функционал будет тяжело поддерживать. Поэтому на сторону клиента отправляется массив со всеми пользователями онлайн и уже на его стороне эта информация используется для дальнейшей работы, основываясь, что она является всегда истинной.

Массив для хранения пользователей онлайн


Дальше разберем, как организовано хранение пользователей, код данного функционала показан на картинке «Массив для хранения пользователей онлайн». Здесь ничего сложного нет, берется коллекция map и в нее заносятся пользователи, которые находятся в онлайне. Методы getUser, setUser, deleteUser соответственно помогают в работе с данным массивом.


Закрытие соединения сокетов для клиента


Давайте теперь разберем как отрабатывает закрытие соединения для каждого клиента. Код функции handleDisconnect отображен на изображении «Закрытие соединения сокетов для клиента». В ней получаем из коллекции пользователя по id сокета, в логах выводим, что пользователь покинул чат, удаляем его из коллекции и обновленную коллекцию отправляем остальным клиентам, которые находятся в онлайн.


Теперь давайте перейдем на сторону клиента (юай). Изначально мне было не понятно когда и где лучше инициализировать сокеты на стороне клиента. По рекомендациям библиотеки socket.io их необходимо инициализировать в месте работы с компонентами, которые для работы нужна информация из сокетов. Это обозначает, что применяются рядом с местом компонента использования. В моем случае это компонент, в котором монтируются списки собеседников и сообщений. Это делается, потому что в обоих компонентах используется сущность юзера. Код корневого компонента с применением обертки сокетов отображен на изображении «Применение обертки сокетов в корневом компоненте».


Применение обертки сокетов в корневом компоненте


Для реализации сокетов я создал обертку, которая включает в себя инициализацию сокета и сайд эффект, который добавляет обработку подключения и отключения от сервера. Из важного хочу обратить внимание, что в обязательном порядке на стороне клиента, когда компонент размантируется, необходимо вызвать метод disconnect, чтобы отключится от соединения. Если этого не сделать, то после потери и восстановления соединения с сервером могут появится дубли в информации. Все вышеописанное в этом абзаце показано на изображениях «Инициализация сокета в отдельном хуке» и «Обертка, в которой прописывается поведение сокетов».


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


Инициализация сокета в отдельном хуке


Обертка, в которой прописывается поведение сокетов


Продолжим описанием проблем, которые у меня возникли, когда начал тестировать функционал сокетов на стороне клиента и сервера по проверке пользователя онлайн. Наибольшая проблема у меня возникла на стороне юая, как бы странно это не звучало, с беком было все намного проще и на нем проблем не было или они быстро решались. Суть проблемы была в лишнем рендере страницы. Изначально я думал, что могло быть связано с тем, что я где-то забыл прокинуть колбек по работе со стором и не обернуть его в useCallback, но этого не подтвердилось, при нем лишнего рендера не было. Оказывается проблема была в том, что настройку пагинации offset я выставил в useState. Соответственно, когда повышал offset страница перерисовывалась, соответственно обертка тоже, потому что находилась в дочерних комопнентах, относительно корневого Home, для понимания структуры компонента посмотрите на изображение «Применение обертки сокетов в корневом компоненте». Поначалу хотел вынести настройку offset также в обертку, по примеру с сокетами, но меня смущало, что из-за такой небольшой мелочи нужно создавать обертку. Соответственно из вышеописанного мне нужно было придумать, как повышать значение offset, но при этом не вызывать перерисовку страницы на это событие. Ответ был найден также быстро благодаря опыту использования реферальных ссылок — при их изменении не вызывается рендер, это идеально мне подходило. В сухом остатке — переписал offset на реферальную сслыку, а в местах, где менялось его значение, сеттер поменял на операцию прибавления + 1 к старому значению. Таким образом у меня получилось избежать написания обертки для offset.


Отправка сообщения с участием сокета

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


Давайте теперь подробнее окунемся в нюансы. Для того, чтобы начать работу с websockets в обмене сообщений мне необходимо было добавить функционал добавления пользователя в комнату, чтобы сообщения шли только участникам группы. Это было сделано для оптимизации процесса, потому что когда подключено 100 и более пользователей — это очень сильно сказывается на железе и если есть возможность сразу настроить оптимизацию — ее нужно сразу делать. Как настроить сами комнаты очень хорошо написано в документации, поэтому лучше обратится в данном случае к первоисточнику. На что я бы обратил здесь внимание — это место в котором нужно вставить эту логику.


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


Соответственно ко мне будет задан, почему нигде не упоминается, что необходимо оформить выход из комнаты. Давайте порассуждаем. Когда пользователь заходит в чат, то он больше никуда не идет, потому что здесь только одна страница, если еще учитывать, что после того, как пользователь выйдет из этой страницы — он сразу выходит из чата и ему заново нужно авторизоваться, чтобы продолжить работу — из websockets он также выходит. Соответственно, webscokets сам подчищает за собой комнаты ото всех неавторизованных пользователей. Следуя вышеописанной логике — мне не нужно следить за комнатами, за меня это они сами делают и поддерживают актуальное состояние.


Обновление списка собеседников и истории чата через сокеты


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


Обновление websockets на стороне бекенда


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


Из интересного еще хочется добавить, что когда строишь сокеты, то необходимо учитывать, чтобы процессы, которые выполняются в рамках одной логической цепочки, не пересекались между rest и webscokets. На моем примере — это сначала должен выполнится запрос на сохранение нового сообщения и только после этого можно будет эмулировать события в websockets. Если события эмулировать раньше, то на юай придет не актуальная информация.


Выводы

В этой статье мы подробно разобрали, что такое сокеты, как работает библиотека socket.io и почему выбор именно на нее. Также были подробно разобраны определения пользователя онлайн и отправки сообщений, проблемы, которые возникли в ходе разработки данных примеров и пути их решения. Спасибо за внимание и до встречи в следующих статьях.

© Все права защищены 2024