1. Введение
Я разрабатываю сервис реального времени для чата, в котором несколько человек могут собираться в одном чате, обмениваться сообщениями и делиться фотографиями. Это такой сервис, который, как и в популярных мессенджерах, мгновенно отображает сообщения, отправленные другим пользователем, без необходимости обновления.
Самым первым вопросом, с которым я сталкиваюсь при создании реального чата, является «как мгновенно уведомить остальных людей в комнате, когда кто-то отправляет сообщение?». Если в комнате десять человек, и один из них отправляет сообщение, то это сообщение должно появиться на экране остальных девяти почти одновременно.
Самый простой способ мысли — это «отправить текст сообщения девяти людям в реальном времени». Однако мы не выбрали этот способ. Вместо этого мы отправляем только легкое уведомление о том, что «что-то произошло», и планируем, чтобы клиент снова запрашивал фактическое содержимое.
В этой статье я хочу поделиться, почему мы сделали такой выбор, как мы это применили и с какими проблемами мы столкнулись и как их решили в этом процессе.
2. Перед этим, что такое веб-сокеты?
Перед тем как начать основную тему, давайте кратко обсудим веб-сокеты (WebSocket), которые лежат в основе реального времени.
Обычный способ коммуникации, который мы используем, когда пользуемся веб-сайтами, похож на обмен письмами. Каждый раз, когда у меня есть вопрос, я отправляю серверу письмо, то есть запрос, и сервер отвечает на него, то есть отправляет ответ. И как только этот обмен закончен, разговор заканчивается. Когда у меня снова возникает вопрос, я должен написать новое письмо и отправить его.
Ограничение этого метода состоит в том, что сервер не может заговорить со мной первым. Если я ничего не спрошу, сервер не сможет уведомить меня о новых новостях.
Поэтому, чтобы использовать этот способ в реальном времени, клиенту следует постоянно спрашивать: «Есть новые сообщения?» Это похоже на то, как вы ожидаете посылку, открывая входную дверь каждые минуту. Даже если новых сообщений нет, все равно нужно продолжать спрашивать, поэтому затраты велики, и возникает задержка в зависимости от интервала опроса.
С другой стороны, веб-сокеты ближе к телефонным звонкам. Если вы один раз позвоните и соедините, это соединение будет поддерживаться до тех пор, пока оно не будет разорвано. В этом состоянии я могу говорить первым, или другой человек может говорить первым. Таким образом, когда у сервера появляются новые новости, он может уведомить клиента, даже если тот не спрашивает.
Таким образом, метод, позволяющий обеим сторонам свободно обмениваться данными, поддерживая открытое соединение, — это веб-сокеты, который хорошо подходит для служб, где «сервер должен сначала уведомить пользователя», подобно реальному чату.
Мы использовали над веб-сокетами стандартизированный протокол обмена сообщениями STOMP, который определяет «по какому адресу отправить и какие адреса подписаться». Если у вас есть телефонная линия, то STOMP можно рассматривать как что-то вроде этикета телефонного разговора, «как передавать кому и что».
3. Выбор технологии: отправляем только «уведомления» вместо содержимого
3.1. Проверяем идентификацию при подключении
Содержимое чата может содержать конфиденциальную информацию, поэтому нельзя разрешать подключение произвольным пользователям. Поэтому в момент установления веб-сокетного подключения мы проверяем токен пользователя. Если токена нет, он имеет неверный формат или не проходит верификацию через сервер аутентификации, это подключение сразу блокируется.
Здесь было одно проектное решение. Обычно запросы блокируются в защитном фильтре перед сервером. Однако веб-сокет, как только он установлен, остается открытым каналом, поэтому гораздо естественнее и эффективнее проводить проверку один раз в момент соединения, а не на каждом этапе.
Если сравнить с телефонным звонком, это похоже на то, чтобы удостовериться, кто на другом конце, в начале разговора.
3.2. Ключевое решение: отправляем «уведомление», а не содержание
Теперь самое важное проектное решение. Когда кто-то отправляет сообщение, мы не рассылаем содержание сообщения всем, кто находится в одной комнате. Вместо этого мы отправляем только сигнал: «В этой комнате зарегистрировано новое сообщение». Клиенты, получившие этот сигнал, потом, как обычно, запрашивают новое сообщение и отображают его на экране.
Это похоже на обычные пуш-уведомления мессенджера. Само пуш-уведомление содержит только короткий сигнал «○○ отправил сообщение», и когда мы открываем приложение, тогда уже подгружается фактическое содержание разговора.
Сначала можно было подумать: «Если мы уже отправляем в реальном времени, не можем ли отправить и текст сообщения сразу?» Я тоже интуитивно считал, что так будет проще. Но у способа отправки только уведомлений были явные преимущества, и после рассмотрения мы выбрали этот метод.
Во-первых, данные, проходящие через сокет, легкие. Независимо от того, является ли это большим сообщением с прикрепленной фотографией или длинным текстом, по сокету передается только небольшой сигнал «Новое сообщение». Благодаря этому нагрузка на реальный канал уменьшается, даже когда многие люди общаются одновременно.
Во-вторых, точность данных и обработка прав доступа могут быть собраны в одном канале. Фактические данные всегда передаются только через проверенный запрос. Если бы тело сообщения также отправлялось по сокету, то из двух разных каналов — сокета и обычного запроса — могло бы поступать различное содержание данных, что усложняет управление, и такие логики, как проверка прав доступа, нужно было бы дублировать в обоих местах.
В-третьих, клиент может получить только то, что ему нужно. Поскольку в уведомлении содержится только информация о том, «что изменилось», клиенту нужно только запрашивать те данные, которые необходимы в соответствии с текущим состоянием экрана. Например, если он не просматривает комнату, он может не загружать содержание сообщения и просто обновить «метку о непрочитанном».
4. Процесс применения: определить типы событий
4.1. Уведомления содержат только информацию о том, «что произошло»
Данные, передаваемые через уведомления, не включают полное содержание сообщения. Вместо этого они содержат лишь информацию о том, в какой комнате, в отношении какого объекта и что произошло. Конкретно это идентификатор отправителя, идентификатор комнаты, идентификатор затронутого объекта и примерно тип события.
Ключевым моментом здесь является «тип события». Мы заранее четко классифицировали события, которые могут происходить в чате. Вот некоторые примеры.
- Когда регистрируется новое сообщение
- Если сообщение прочитано
- Если сообщение изменено или удалено
- Если сообщение закреплено, на него дан ответ или на него ответили эмодзи
- Если пригласить в комнату, создать или удалить комнату, выйти из комнаты
Таким образом, типы событий были разделены на десятки и представлены только с определенными значениями. Преимущество такого подхода заключается в том, что сервер и клиент могут четко согласовать обещание о том, как обрабатывать «это событие».
Поскольку вместо произвольной строки в уведомлении используются только заранее определенные типы событий, можно уменьшить путаницу, вызванную опечатками или пропусками.
Вот типичный поток. Когда один пользователь отправляет сообщение, сервер отправляет уведомление с типом события «Новое сообщение зарегистрировано» и идентификатором этой комнаты и идентификатором нового сообщения тем, кто находится в той же комнате. Клиент, получивший уведомление, думает: «О, в этой комнате появилось новое сообщение», и обновляет экран, проверив сообщения в этой комнате.
4.2. Обработка «Прочитано» также проходит через ту же структуру уведомления
Преимущество этой структуры уведомления в том, что она применяется не только к новым сообщениям, но и к другим изменениям состояния. Например, если пользователь прочитает сообщение, сервер обновляет последнее место чтения для этого пользователя и отправляет уведомление в ту же комнату с типом события «Сообщение прочитано». Тогда индикатор «Прочитано» на экранах остальных людей обновляется в реальном времени с помощью того же механизма.
Один из моментов, на которые стоит обратить внимание, заключается в том, что обновления и отправка уведомлений происходят только тогда, когда пользователь прочитал что-то дальше, чем его последнее прочитанное место. Это сделано для того, чтобы местоположение чтения не возвращалось обратно, даже если пользователь вновь посмотрит предыдущее сообщение.
В конечном итоге, будь то новое сообщение, изменение сообщения или отметка «Прочитано», все изменения были унифицированы в один согласованный шаблон: «отправить уведомление → клиент снова проверяет». Даже если возникает новый тип события, его просто можно вставить в ту же структуру, что упрощает расширение функционала.
5. Уведомления отправляются только онлайн-пользователям
Уведомления по сокету имеют значение только для тех, кто в данный момент подключен и держит соединение открытым. Если провести аналогию с телефонным разговором, это похоже на то, что если перерывать разговор с тем, кто уже положил трубку, то их не будет слышно, даже если вы бесконечно говорите. Поэтому, непосредственно перед отправкой уведомления, мы сначала проверяем, в сети ли каждый из целевых пользователей.
Сервер имеет информацию о том, кто в данный момент подключен, и при отправке уведомления проверяет этот список, чтобы передать сигнал только подключенным пользователям. Если пользователь не подключен, сигнал не будет получен, поэтому мы избегаем ненужной передачи сигнала.
Процесс предварительного отбора «целей, имеющих ценность для отправки» оказывается особенно эффективным в комнатах с большим количеством пользователей. Даже если в комнате много людей, фактически подключенными могут быть только несколько. Выбирая только подключенных пользователей, мы можем сократить ненужную передачу.
6. Проблемы и решения: кому сообщить при выходе из комнаты
Я расскажу о проблеме, с которой я столкнулся, применяя структуру уведомлений.
В большинстве случаев уведомления следует отправлять «всем текущим участникам этой комнаты». Поэтому в начале я создал систему отправки уведомлений, при которой каждый раз я проверял список текущих участников по идентификатору комнаты. Это работало отлично для новых сообщений, исправлений и отметок о прочтении.
Однако возникли проблемы, когда комната удалялась или участник покидал комнату. Когда комната была удалена или один из пользователей покинул комнату, на момент завершения этого процесса информация о участниках уже была удалена. В этом состоянии, если проверить «текущий список участников», возникла ситуация, когда мы не могли найти тех, кому нужно было сообщить «вы покинули комнату» или «эта комната была удалена».
Когда пришло время отправлять уведомление, получатель уже отсутствовал в списке.
В результате я выяснил, что проблема заключалась в том, что время проверки целевой аудитории для уведомлений и момент, когда информация о членах становится недоступной, не совпадали. Обычно было достаточно проверять «кто сейчас в этой комнате», но события, когда участник уходит или комната удаляется, изменяют сами результаты проверки.
Для решения проблемы я разделил стратегию определения получателей уведомлений на два типа в зависимости от типа события. В случаях, таких как удаление комнаты или выход из нее, уведомления отправляются по заранее зафиксированному списку участников, который был сохранен до их удаления. В то же время, обычные события, такие как новые сообщения или исправления, обрабатываются так же, как и прежде, с проверкой текущих участников.
Таким образом, решение о том, «проверять ли текущее состояние для отправки или отправлять уведомления тем, кого удалось удержать до удаления», разделено в зависимости от характера события.
Из этого опыта я научился тому, что «решение о том, кому отправить уведомление», недостаточно основывается только на текущем состоянии. Необходимо учитывать информацию о том, в какой момент данные были доступны до и после их удаления в зависимости от характера события.
Благодаря раннему четкому разделению типов событий, мне было проще аккуратно разделить различные обработки событий.
7. Результаты и рефлексия
В результате применения этой схемы, по сокету проходят только легкие сигналы о том, что «что-то произошло», а точность фактических данных контролируется проверенной путем. Даже если количество типов уведомлений увеличивается, достаточно просто добавить новые события в заранее определённую категорию и переиспользовать те же методы передачи, что значительно упрощает расширение.
Самым важным уроком было то, что уравнение «реальное время = передача всех данных в реальном времени» не всегда верно. Скорее, подход «быстро сообщая об изменениях, привлекать данные через надежные каналы» оказался более простым и прочным. Это как если бы реальные временные каналы и общие каналы проверки сосредоточились на своих сильных сторонах.
Конечно, есть еще области для улучшения. Например, способ определения текущей информации о подключенных пользователях предполагает наличие одного сервера, и если мы увеличим количество серверов, потребуется дополнительно создать систему для обмена информацией о подключениях между серверами.
Работая над такой, на первый взгляд простой, функцией, как реальный чат, я мог ощутить, что решения о том, «что, когда и кому отправить», глубоко переплетены. Надеюсь, что эта статья станет маленьким ориентиром для тех, кто сталкивается с аналогичными размышлениями.
месси