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

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

Подготовка

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Оптимизация встречных запросов

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

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

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

Краткое содержание

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

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

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