WebSocket даёт двунаправленный канал сообщений между клиентом и сервером. Он полезен, когда обе стороны часто что-то отправляют: совместный редактор, интерактивная сессия, чат. Но канал не определяет смысл сообщений, подтверждение бизнес-операции и восстановление после обрыва. Всё это нужно описать поверх него.
В совместном редакторе один человек печатает, второй двигает курсор, третий меняет заголовок. Сервер постоянно принимает команды и раздаёт обновления. Здесь постоянный канал выглядит разумно. Но фраза «сделаем на WebSocket» закрывает только доставку. Кто победит при одновременной правке заголовка? Можно ли повторить команду? Что увидит вернувшийся из метро пользователь? До этих вопросов проектирование ещё не началось.
Что именно даёт протокол?
После установления соединения стороны могут отправлять текстовые и бинарные сообщения. В классическом варианте поверх HTTP/1.1 старт выглядит как handshake с Upgrade, после которого идёт обмен WebSocket-фреймами. В проде используем защищённый wss://. Установление соединения и управляющие фреймы описаны в RFC 6455.
Для стандартного браузерного API приложение работает с сообщениями через message, а не собирает их из сетевых пакетов. WebSocket сохраняет порядок сообщений в рамках соединения, но не восстанавливает историю после нового подключения. Прикладные подтверждения, журнал и дедупликация от этого не появляются.
WebSocket и Socket.IO — разные договорённости
Не подменяйте название протокола библиотекой в контракте. Если команда выбирает надстройку, проверьте, какой клиент ей нужен, как устроены повторы и совместимость версий. Обычный WebSocket-клиент не обязан понимать её прикладные сообщения.
Что писать вместо «передаём JSON»?
Возьмём учебную команду смены заголовка документа. Разделим намерение пользователя, подтверждение записи и событие для подписчиков:
Клиент → сервер:
{"type":"document.rename","requestId":"req-9","documentId":"d-7",
"expectedRevision":41,"payload":{"title":"План запуска"}}
Сервер → автор команды после сохранения:
{"type":"command.result","requestId":"req-9","status":"applied","revision":42}
Сервер → подписчики:
{"type":"document.renamed","eventId":"evt-202","documentId":"d-7",
"revision":42,"payload":{"title":"План запуска"}}
При конфликте:
{"type":"command.result","requestId":"req-9","status":"rejected",
"code":"REVISION_CONFLICT","currentRevision":43}
Для каждого типа задаём схему, обязательность полей, лимит размера, право на действие и обработку неизвестной версии. В нашем примере подтверждение applied отправляется после фиксации изменения, а не сразу после чтения JSON. Событие всем подписчикам не заменяет ответ автору: человеку надо знать судьбу именно его команды.
Порядок получения result и event тоже должен быть оговорён: клиент связывает результат по requestId, а состояние применяет по ревизии и не предполагает, что result всегда будет первым. Эволюция такого конверта — та же задача версионирования API, что и для HTTP.
Сеть оборвалась после отправки. Повторяем?
sequenceDiagram participant C as Клиент participant S as Сервер C->>S: rename requestId=req-9 S->>S: сохранить изменение и результат команды S--xC: applied потерялся Note over C,S: Новое соединение C->>S: rename с тем же requestId=req-9 S-->>C: ранее сохранённый результат applied
Сервер применил переименование, но клиент не получил подтверждение. При повторе с тем же идентификатором сервер возвращает сохранённый результат. Чтобы это работало, договоримся об области ключа, например пользователь плюс requestId, сроке хранения и проверке совпадения тела. Дедупликация должна работать между инстансами, а сохранение результата — согласовываться с изменением документа.
Если ключ уже забыт, нельзя обещать безопасный повтор старой команды. Клиент должен сверить состояние или получить явный ответ о невозможности восстановить результат. Это применение идемпотентности, а не свойство WebSocket. Для операций с деньгами требования к доказательству результата будут строже, чем для курсора в редакторе.
Как вернуть клиента в актуальное состояние?
После reconnect клиент заново авторизуется и восстанавливает подписки. Для документа d-7 сообщает последнюю применённую ревизию. Сервер отдаёт хвост после неё или снимок с согласованной позицией, если история уже удалена. Во время восстановления показываем «синхронизируем», а не выдаём локальную копию за актуальную.
Счётчик ревизий документа и позиция общего журнала — разные вещи. Если один канал содержит события десяти документов, ревизия одного из них не является курсором всего канала. Область порядка нужно назвать явно. Та же проблема подробно разобрана для long polling и SSE.
Как понять, что другая сторона ещё жива?
У протокола есть Ping/Pong, но стандартный JavaScript API браузера не предоставляет методы отправки этих управляющих фреймов или события их получения. Их может использовать серверный стек. Если приложению нужен свой контроль активности, проектируем отдельные сообщения heartbeat с таймаутом; это прикладной механизм. Возможности браузерного API описаны в документации MDN.
В нашем редакторе после пропуска согласованного окна активности показываем потерю связи. Попытки соединиться снова идут с растущей задержкой и случайным разбросом. Отказ в доступе не лечится вечными повторами. Возврат связи запускает синхронизацию, а не просто меняет цвет индикатора на зелёный.
Что делать, если клиент читает медленнее сервера?
У обычного браузерного WebSocket API нет автоматического backpressure для входящих сообщений. Неограниченный поток может забить память или загрузить процессор. bufferedAmount помогает наблюдать очередь исходящих байтов, но не подтверждает обработку на другой стороне. Эти ограничения перечислены в MDN.
Я бы разделил наши сообщения по ценности. Старые координаты курсора заменяем последними. Изменения документа не выбрасываем: ограничиваем очередь, при переполнении закрываем подписку с понятной причиной и восстанавливаемся по журналу. В контракте нужны пределы размера сообщения, очереди, количества подписок и частоты команд. «Ничего не терять и хранить в памяти сколько угодно» — не стратегия.
Кто имеет право читать и писать в канал?
Проверяем пользователя при подключении, Origin по allowlist для браузерного сценария, затем права на каждую подписку и команду. Origin не заменяет аутентификацию: небраузерный клиент может его подделать. Не доверяем documentId только потому, что канал уже открыт. Отдельно задаём реакцию на истечение сессии и отзыв доступа посреди работы. Браузерный конструктор WebSocket, как и EventSource, не даёт свободно поставить заголовок Authorization.
Для редактора я бы потребовал прекращать доставку после отзыва прав в измеримый срок и удалять запрещённые подписки. Результат проверки прав нельзя бессрочно кэшировать на момент handshake. Основа — модель авторизации.
Какие проверки включить в приёмку?
- Команда сохранилась, подтверждение потерялось: повтор не создаёт второе действие.
- Два участника меняют одну ревизию: конфликт обрабатывается по контракту.
- Клиент вернулся после долгого отсутствия: получил хвост либо согласованный снимок.
- Медленный клиент не вызывает неограниченный рост памяти сервера.
- Отзыв доступа прекращает чтение и запись, в том числе уже открытой подписки.
- Перезапуск инстанса приводит к контролируемому восстановлению; массовый reconnect проходит нагрузочную проверку.
Если команды редки, а обновления идут только от сервера, сравните с SSE плюс HTTP-команды. Необходимость двунаправленного канала проверяем по требованиям к доставке, а не по моде.
В звонилке WebSocket часто доставляет приглашения и параметры соединения, а голос идёт другим путём. Это разделение управления и медиа подробно разобрано в главе WebRTC: как работают голосовые и видеозвонки.
Частые вопросы
Метод send завершился — сообщение обработано?
Нет. Передача данных API не равна бизнес-подтверждению. Для важной команды ждём результат с согласованной семантикой, например applied после сохранения.
WebSocket сам решает конфликты совместного редактирования?
Нет. Проверка ревизии, OT, CRDT или иной алгоритм согласования работают на уровне приложения. Канал только переносит сообщения.
Чат обязательно делать через WebSocket?
Нет. Отправка по HTTP и получение по SSE тоже возможны. Сравните частоту обмена, требования к задержке, восстановление и стоимость поддержки обоих вариантов.