WebSockets в Angular. Часть 2

Продуктовые решения

Alex Dukhnovskiy
Angular Soviet
6 min readAug 1, 2018

--

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

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

Вот список основных требований для вебсокет-клиента, которые будут рассматриваться в этой статье:

  • Автоматический «умный» реконнект;
  • Режим дебага;
  • Система подписок на события на основе RxJs;
  • Прием и парсинг бинарных данных;
  • Проецирование (маппинг) получаемой информации на модели;
  • Контроль над изменениями моделей по мере прихода новых событий;
  • Игнорирование произвольных событий и отмена игнорирования.

Рассмотрим каждый пункт подробнее.

Реконнект/Дебаг

Про реконнект я писал в предыдущей статье, поэтому просто процитирую часть текста:

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

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

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

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

Система подписок на события на основе RxJs

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

«Издатель-подписчик (англ. publisher-subscriber или англ. pub/sub) — поведенческий шаблон проектирования передачи сообщений, в котором отправители сообщений, именуемые издателями (англ. publishers), напрямую не привязаны программным кодом отправки сообщений к подписчикам (англ. subscribers). Вместо этого сообщения делятся на классы и не содержат сведений о своих подписчиках, если таковые есть. Аналогичным образом подписчики имеют дело с одним или несколькими классами сообщений, абстрагируясь от конкретных издателей.»

Подписчик обращается к издателю не напрямую, а через промежуточную шину — сервис вебсокетов. Также должна быть возможность подписаться на несколько событий с одинаковым типом возвращаемых данных. Для каждой подписки создается собственный Subject, который добавляется к объекту listeners, что позволяет адресовать события вебсокета нужным подпискам. При работе с RxJs Subject, возникают некоторые сложности с отписками, поэтому создадим несложный сборщик мусора, который будет удалять сабжекты из объекта listeners в случае, когда у них отсутствуют observers.

Прием и парсинг бинарных данных

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

0x80, <длина — один или несколько байт>, <тело сообщения>

Чтобы не создавать ограничений на длину передаваемого сообщения и в то же время не расходовать байты нерационально, разработчики протокола использовали следующий алгоритм. Каждый байт в указании длины рассматривается по отдельности: старший указывает на то, последний ли это байт (0) или же за ним идут другие (1), а младшие 7 битов содержат передаваемые данные. Следовательно, когда появляется признак бинарного дата-фрейма 0x80, то берется следующий байт и откладывается в отдельную «копилку». Потом следующий байт, если у него установлен старший бит, тоже переносится в «копилку» и так далее до тех пор, пока не встретится байт с нулевым старшим битом. Этот байт — последний в указателе длины и также складывается в «копилку». Теперь из байтов в «копилке» убираются старшие биты, и остаток объединяется. Вот это и будет длина тела сообщения — 7-битные числа без старшего бита.

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

Проецирование (маппинг) получаемой информации на модели

Независимо от типа передачи принимаемое требуется безопасно читать и изменять. Нет единого мнения как это лучше делать, я придерживаюсь теории модели данных, так как считаю её логичной и надежной для программирования в ООП-стиле.

«Модель данных — это абстрактное, самодостаточное, логическое определение объектов, операторов и прочих элементов, в совокупности составляющих абстрактную машину доступа к данным, с которой взаимодействует пользователь. Эти объекты позволяют моделировать структуру данных, а операторы — поведение данных.»

Всевозможные популярные шины, которые не дают представление объекта, как класса, в котором определяется поведение, структура и т.д., создают путаницу, хуже контролируются и иногда обрастают тем, что им не свойственно. Например, класс собаки при любых условиях должен описывать собаку. Если собаку воспринимать в виде набора полей: хвост, цвет, морда и т.д., то у собаки может вырасти лишняя лапа, а вместо головы появиться другая собака.

Контроль над изменениями моделей по мере прихода новых событий

В этом пункте опишу задачу, с которой столкнулся при работе над веб-интерфейсом мобильного приложения спортивных ставок. API приложения работало через вебсокеты, через которые получали: обновление коэффициентов, добавление и удаление новых типов ставок, уведомления о начале или окончании матча и т.д. — итого около трёхсот событий вебсокета. Во время матча ставки и информация непрерывно обновляются, иногда 2–3 раза в секунду, таким образом проблема заключалась в том, что вслед за ними без промежуточного контроля обновлялся и интерфейс.

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

Для решения потребовалась иммутабельность для объектов, которые отображались на экране, но при этом коэффициенты ставок должны были меняться, неактуальные ставки приобретать неактивный вид, а новые не добавляться до тех пор, пока пользователь не проскроллит экран. На бэкенде устаревшие варианты не хранились, поэтому такие линии требовалось запоминать и помечать флагом «deleted», для чего было создано промежуточное хранилище данных между вебсокетом и подпиской, что обеспечило контроль за изменениями.

В новом сервисе также создадим слой-заместитель и в этот раз воспользуемся Dexie.js — обертку над IndexedDB API, но подойдет любая другая виртуальная или браузерная БД. Допустимо использование Redux.

Игнорирование произвольных событий и отмена игнорирования

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

Часто все они используют единую кодовую базу, поэтому иногда требуется отключать ненужные события в рантайме или при DI, не удаляя подписки и снова включать, т.е. игнорировать часть из них, чтобы не обрабатывать ненужные события. Это простая, но полезная функция, которая добавляет гибкости шине Pub/Sub.

Начнем с описания интерфесов:

Отнаследуем Subject, чтобы создать сборщик мусора:

В отличие от прошлой реализации websocket.events.ts сделаем частью модуля вебсокетов

Для конфигурирования при подключении модуля создадим websocket.config:

Создадим модель для Proxy:

Простой парсер моделей, в реальных условиях его лучше разделить на несколько файлов:

WebsocketModule:

Начнем создавать сервис:

Метод connect:

Дублируем сборщик мусора, будет проверять подписки по таймауту:

Смотрим в какую подписку слать событие:

Шлем событие в Subject:

Создаем топик Pub/Sub:

Подписка на одно или несколько событий:

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

Добавляем события в игнорлист в рантайме:

Удаляем события из игнорлиста:

Подключаем модуль вебсокетов:

Используем в компонентах:

Сервис готов к использованию.

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

Полную версию сервиса можно найти на GitHub.

По всем вопросам можете обращаться в комментарии, ко мне в Телеграм или на канал Angular там же.

--

--