38
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Spring WebSocket: карма, которая не ждёт ответа

Время чтения 45 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
38
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

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

В той истории WebSocket появился как полиция в конце боевика: выстрелил, всех спас, титры. А кто он такой? Откуда взялся? Почему работает именно так? И главное — как его звать осознанно, а не когда «стриминг накрылся и я уже три ночи не сплю»?

Вот об этом и расскажу сейчас. Меня зовут Николай Пискунов, я руководитель направления Big Data и эксперт курса Cloud DevSecOps по безопасной разработке от Академии вАЙТИ.

Spring WebSocket: карма, которая не ждёт ответа

«— Что такое 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 не гарантирует просветления, но он хотя бы избавляет от привычки долбиться в закрытую дверь каждые полсекунды. И это, если подумать, уже немало.

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники