В каждом современном сервисе должны быть сториз и чат, и для начала мы решили запилить в hh.ru переписки. Меня зовут Влад Коротун, я фронтенд-разработчик. В этой статье расскажу, как неординарный подход к использованию Web Workers помог нам решить эту задачу.

Для желающих посмотреть на предмет сабжа в динамике у нас есть видеоверсия этой статьи. 

Подготовка

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

Здесь нас подстерегала первая проблема. Пользователи нашего сайта зачастую открывают огромное количество вкладок, и иметь активное сокет-соединение на каждой из них – крайне затратный подход. Кроме того, простое обновление счетчика через http по таймеру на каждой вкладке создавало большую нагрузку на сервер, даже когда сам чатик был раскатан всего на 20% аудитории.

Мы стали искать готовые решения и смотреть, как делают другие. Некоторые открывают чатик целиком в новой вкладке и устанавливают сокет-соединение только там. Но нам хотелось иметь доступ к сообщениям сервера на всех наших вкладках, поэтому этот подход нам не подошел. 

Еще мы нашли интересную статью о том, как с помощью SharedWorker можно пошарить сокет-соединение между доступными воркеру вкладками. Но, к сожалению, на текущий момент Safari дропнул поддержку SharedWorker. А это большой процент нашей аудитории.

В поисках подходящего решения мы обратили внимание на ServiceWorker. Эти воркеры доступны на всех вкладках и фреймах своего Origin и поддерживают работу с WebSocket. Нашей целью было разработать систему, позволяющую использовать одно сокет-соединение во всех наших web-приложениях, включая те, origin которых отличается от того, на котором находится сам сервер.

Схема получилась следующая: сам сокет-сервер находится на поддомене websocket.hh.ru. Там же мы расположили Proxy-страницу, которая устанавливает и активирует воркер. Страница может встраиваться в любые наши сайты с помощью IFrame и посредством PostMessage проксировать сокет-события в родительское окно.

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

Первые трудности

Во время разработки всё работало, как часы. Но когда мы передали задачу в тестирование, мы обнаружили, что спустя 10-15 мин после запуска страницы счетчик переставал реагировать на обновление.

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

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

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

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

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

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

Чтобы воркер сразу захватывал текущую вкладку, мы прибегли к встроенному методу self.clients.claim(), о котором также прочитали в документации. Этот метод позволяет сделать его клиентами все вкладки, попадающие в область видимости воркера. 

В случае отсутствия поддержки воркеров в браузере, наша proxy-страница переходит в fallback-режим и устанавливает прямое сокет-соединение без участия воркера. На данный момент, в fallback-режиме находится 3% наших активных подключений. 

Оптимизируем запросы счетчика

Одно сокет-соединение – это хорошо, но запросы нового счетчика всё равно ходят по http. Так как в нашей реализации сокетов обмен информации происходит в одностороннем формате, сам счетчик нужно запрашивать по http. И пусть уже не по таймеру, но все равно в какой-то момент каждая вкладка полезет его обновлять. Здесь на помощь снова приходит ServiceWorker. 

Мы разработали отдельный воркер, на этот раз привязанный к поддомену чатика, и proxy-страницу для доступа к нему. Proxy-страница счетчика встраивает в себя proxy-страницу соединения и слушает ее обновления. При необходимости обновить счетчик запрос поступает в воркер, где происходит debounce, и запрос нового счетчика произойдет только один раз для всех вкладок в определенный момент времени. Полученное значение будет проксировано всем клиентам — proxy-страницам этого Worker, а они передадут новое значение в родительское приложение, где мы сможем нарисовать новую цифру. 

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

В заключение

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

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

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


Важный опрос про деньги!

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

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

Опрос можно пройти здесь.

Комментарии (3)


  1. Alexandroppolus
    17.02.2022 14:16
    -2

    1. shai_hulud
      17.02.2022 18:00
      +3

      Вроде когда одна компания делает техническое решение, другим тоже разрешено.

      Ну и тем более интересно почтитать с чем столкнулась команда довольно загруженного проекта.


  1. DownloadMASTER
    19.02.2022 02:36

    Мы решили попытаться обойти это ограничение и попробовали отправлять в воркер пустые сообщения по таймеру.

    Вы имеете ввиду "message" событие на стороне sw?
    С каким интервалом отправляете пустые сообщения из main?

    По моим наблюдениям, если sw все-таки уснул, то иногда отправка сообщения из main на "message" обработчик не приводит к пробуждению sw. А вот fetch всегда.