«— Что такое WebSocket? — спросил ученик.
— Это когда ты говоришь, а тебе не просто кивают, — ответил Мастер. — Тебе отвечают, и вы говорите одновременно, не путаясь в словах.
— Но разве это возможно?
— Возможно, если забыть о том, что мир состоит из запросов и ответов. Он состоит из потоков.
— Из потоков чего?
— Из потоков присутствия».
Из сборника притч «Айти-пустотность», том 3 (если бы он существовал)
Когда мы говорим о веб-разработке, мы мыслим категориями «спросил — ответили». Шаблон въелся в подкорку так глубоко, что, даже когда мы стряпаем чат-бота, всё равно имитируем живой диалог через тысячи маленьких HTTP-укусов. Клиент дёргает: «Есть что?» Сервер вяло отмахивается: «Не-а». Клиент: «А теперь?» Сервер: «Не-а». Клиент: «А сейчас?» Сервер: «Да! На! Возьми! Срочно!»
Но есть другой вариант, где клиент и сервер болтают одновременно — не ждут паузы, вставляют реплики, перебивают.
Краткая история протокола, о которой никто не просил
HTTP хорош для документов, но не для диалога. Каждый запрос — это отдельный визит: хочешь получить что-то ещё — стучись заново. Long polling эту схему немного улучшил, но, по сути, просто заставил сервер держать дверь приоткрытой и молчать в ожидании, пока у него не появится что сказать.
В 2011-м появился WebSocket и предложил: «Давайте просто откроем дверь один раз и не будем закрывать».
Общение начинается с обычного HTTP-запроса. Клиент передаёт такой заголовок:
text
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Сервер, если готов к разговору, отвечает:
text
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
И после этого соединение переключается с HTTP на WebSocket. Дверь открыта. Больше не надо каждый раз объяснять, кто ты и зачем пришёл.
Как работать с Spring WebSocket
Добавляете в pom.xml строчку:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
И открываете в приложении портал в реальность, где всё происходит здесь и сейчас. Без постоянного «Ты ещё там?».
В Spring есть два способа работать с WebSocket: низкоуровневое присутствие и STOMP.
Первый способ — реализовать WebSocketHandler. Это если вы сами становитесь священником, который лично принимает каждую исповедь.
java
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
private final CopyOnWriteArraySet< WebSocketSession > sessions = new opyOnWriteArraySet <>();
@Override
public void afterConnectionEstablished (WebSocketSession session) {
sessions.add(session);
log.info("WebSocket session established: {}", session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
String payload = message.getPayload();
TextMessage response = new TextMessage("Эхо: " + payload);
sessions.forEach(s -> {
try {
s.sendMessage(response);
} catch (IOException e) {
// клиент отвалился, бывает
}
});
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
log.info("WebSocket session closed: {}", session.getId());
}
}
Конфигурация простая:
java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private ChatWebSocketHandler chatHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler, "/chat")
.setAllowedOrigins("*")
.withSockJS();
}
}
Этот подход даёт полный контроль. Вы видите каждое соединение, каждое сообщение. Сами решаете, кому ответить. Но и вся ответственность на вас: сессии, потоки, закрытия. Путь для тех, кто любит держать всё под колпаком.
Второй способ — STOMP (Simple Text Oriented Messaging Protocol), протокол поверх WebSocket, который вносит в хаос стройность. Есть места для проповедей (топики) и личные кабинеты для исповеди (очереди).
Подключение STOMP начинается с отправки фрейма:
text
CONNECT
accept-version:1.2
heart-beat:10000,10000
Сервер отвечает:
text
CONNECTED
version:1.2
heart-beat:10000,10000
Дальше клиент подписывается на топики и отправляет сообщения. Здесь Spring разворачивается во всей красе.
Аннотации:
java
@Controller
public class StompChatController {
private final SimpMessagingTemplate messagingTemplate;
public StompChatController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/chat.send")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
return message;
}
@MessageMapping("/chat.private")
public void sendPrivateMessage(@Payload PrivateMessage message) {
messagingTemplate.convertAndSendToUser(
message.getRecipient(),
"/queue/messages",
message.getContent()
);
}
@MessageExceptionHandler
@SendToUser("/queue/errors")
public String handleException(Exception e) {
return "Ошибка: " + e.getMessage();
}
}
Никакой ручной работы с сессиями, потому что Spring сам знает, куда и кому отправлять.
Как использовать брокер сообщений в Spring WebSocket
Брокер сообщений тоже можно реализовать в двух вариантах.
Простой брокер (Simple Broker):
java
@Configuration
@EnableWebSocketMessageBroker
public class SimpleBrokerConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("https://myapp.com")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue", "/user");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
}
Простой брокер живёт внутри приложения. Лёгкий, быстрый, но не хранит сообщения между перезапусками. Как монах, который помнит ваши грехи, только пока не выключился.
Внешний брокер (External Broker):
Для продакшна, где важна надёжность:
java
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/topic", "/queue", "/user")
.setRelayHost("localhost")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest")
.setSystemLogin("admin")
.setSystemPasscode("admin")
.setSystemHeartbeatSendInterval(5000)
.setSystemHeartbeatReceiveInterval(4000);
registry.setApplicationDestinationPrefixes("/app");
}
Внешний брокер — это уже целый монастырь с архивом. Сообщения хранятся, маршрутизируются, дублируются между экземплярами. Можно выбрать по своим задачам или просто по желанию: ActiveMQ, RabbitMQ, Artemis.
SockJS как эмулятор для старых браузеров
SockJS — прослойка, которая эмулирует WebSocket там, где его нет. Он пробует разные транспорты по очереди, от WebSocket до jsonp-polling, и выбирает тот, что работает. Не быстро, зато стабильно.
На клиенте это выглядит так:
javascript
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
const socket = new SockJS('http://localhost:8080/ws');
const client = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
client.subscribe('/topic/public', (message) => {
const msg = JSON.parse(message.body);
console.log(msg);
});
client.subscribe('/user/queue/messages', (message) => {
console.log(message.body);
});
},
onStompError: (frame) => {
console.error('STOMP error', frame);
}
});
client.activate();
Как не пропустить момент, когда соединение разорвалось
WebSocket-соединение может умереть тихо. Никто не заметит, пока не попытается что-то отправить. Чтобы этого избежать, придумали heartbeat — регулярные пульсации, которые проверяют, жив ли канал.
В STOMP-клиенте:
javascript
const client = new Client({
heartbeatIncoming: 4000, // как часто ждать пульс от сервера
heartbeatOutgoing: 4000, // как часто посылать пульс
});
На сервере Simple Broker поддерживает heartbeat автоматически. Для внешнего брокера интервалы настраиваются отдельно:
java
registry.enableStompBrokerRelay("/topic", "/queue")
.setSystemHeartbeatSendInterval(5000)
.setSystemHeartbeatReceiveInterval(4000);
Если пульс пропадает, соединение закрывается.
Как обеспечить безопасность
Поскольку соединение WebSocket постоянное, нельзя просто проверить токен в каждом запросе. Но Spring позволяет вытащить токен из HTTP-рукопожатия:
java
@Configuration
@EnableWebSocketMessageBroker
public class SecureWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("https://myapp.com")
.withSockJS()
.setInterceptors(new HandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest =
(ServletServerHttpRequest) request;
String token = servletRequest.getServletRequest()
.getParameter("token");
if (isValidToken(token)) {
attributes.put("user", extractUser(token));
return true;
}
}
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception ex) {}
});
}
}
А в контроллере получаете пользователя через @AuthenticationPrincipal:
java
@MessageMapping("/secure.send")
@SendTo("/topic/secure")
public Message secureSend(@Payload Message message,
@AuthenticationPrincipal User user) {
message.setFrom(user.getUsername());
return message;
}
О чём молчит документация
Когда выкатываешь WebSocket в продакшн, реальность подкидывает сюрпризы:
Nginx и буферизация. Стандартная конфигурация Nginx любит накапливать данные, прежде чем отдать клиенту. Для WebSocket это смерти подобно. Первое, что я проверяю после деплоя, — не забыл ли добавить proxy_buffering off. Если забыл, сообщения приходят пачками, а не по мере отправки. Выглядит так, будто собеседник заикается.
nginx
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
}
Балансировка нагрузки. WebSocket-соединения должны «прилипать» к одному серверу. Иначе балансировщик отправит сообщение не туда. Именно поэтому Sticky sessions обязательны.
Корпоративные прокси. Некоторые из них убивают долгие соединения. SockJS с его транспортами часто спасает, но не всегда.
Количество соединений. Каждый WebSocket держит открытым файловый дескриптор. Рано или поздно может закончиться лимит — ulimit -n. Проверяйте заранее.
Вместо заключения
Spring WebSocket предлагает два способа передавать данные потоком, а не привычными запросами-ответами. Можно быть дзен-монахом с WebSocketHandler — в полной тишине, лицом к лицу с каждым соединением. А можно быть священником в храме STOMP — с иерархией и ритуалами, которые помогают не сойти с ума от количества прихожан.
P. S. Если ваше приложение всё ещё использует polling и long polling, задумайтесь. WebSocket не гарантирует просветления, но он хотя бы избавляет от привычки долбиться в закрытую дверь каждые полсекунды. И это, если подумать, уже немало.