В общедоступной части Интернета ограничительные прокси-серверы, находящиеся вне вашего контроля, могут препятствовать взаимодействию WebSocket либо потому, что они не настроены на передачу заголовка Upgrade, либо потому, что они закрывают долговременные соединения, которые, как представляется, неактивны.

Решением этой проблемы является эмуляция WebSocket – то есть попытка сначала использовать WebSocket, а затем вернуться к технологиям на основе HTTP, которые эмулируют взаимодействие WebSocket и предоставляют тот же API на уровне приложения.

В стеке сервлетов Spring Framework обеспечивает серверную (а также клиентскую) поддержку протокола SockJS.

Краткое описание

Цель SockJS – позволить приложениям использовать WebSocket API, но при необходимости во время выполнения возвращаться к альтернативам, не связанным с WebSocket, без необходимости изменения кода приложения.

SockJS состоит из:

SockJS предназначен для использования в браузерах. Он использует различные методы для поддержки широкого спектра версий браузеров. Полный список типов механизмов передачи SockJS и браузеров смотрите на странице клиента SockJS. Механизмы передачи делятся на три общие категории: WebSocket, потоковая передача по протоколу HTTP и длинный HTTP-поллинг. Обзор этих категорий см. в этой статье блога.

Клиент SockJS начинает с отправки GET /info для получения основной информации от сервера. После этого он должен решить, какой механизм передачи использовать. Если возможно, то используется протокол WebSocket. Если нет, то в большинстве браузеров есть, по крайней мере, одна опция потоковой передачи по протоколу HTTP. Если и такой нет, то используется HTTP-поллинг (длинный).

Все запросы на передачу имеют следующую структуру URL-адреса:

https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}

где:

  • {server-id} используется для маршрутизации запросов в кластере, но в других случаях не используется.

  • {session-id} соотносит HTTP-запросы, принадлежащие сессии SockJS.

  • {transport} указывает тип механизма передачи (например, websocket, xhr-streaming и другие).

Механизму передачи по протоколу WebSocket требуется только один HTTP-запрос для подтверждения установления связи по протоколу WebSocket. Все последующие сообщения передаются через этот сокет.

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

SockJS добавляет простой фрейминг сообщений. Например, сервер изначально передает букву o ("open" фрейм), сообщения передаются в виде a["message1", "message2"] (JSON-кодированный массив), букву h ("heartbeat" фрейм), если в течение 25 секунд (по умолчанию) не поступает никаких сообщений, и букву c ("close" фрейм) для закрытия сессии.

Чтобы узнать больше, запустите пример в браузере и просмотрите HTTP-запросы. Клиент SockJS позволяет фиксировать список механизмов передачи, поэтому можно просматривать каждый механизм по очереди. Клиент SockJS также предусматривает флаг отладки, который позволяет выводить полезные сообщения в консоль браузера. На стороне сервера можно активировать журналирование TRACE для org.springframework.web.socket. Еще более подробную информацию можно найти в тесте протокола SockJS с комментариями.

Активация SockJS

Вы можете активировать SockJS через Java-конфигурацию, как показано в следующем примере:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/myHandler").withSockJS();
    }
    @Bean
    public WebSocketHandler myHandler() {
        return new MyHandler();
    }
}

В следующем примере показан XML-эквивалент конфигурации из предыдущего примера:

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:websocket="http://www.springframework.org/schema/websocket"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/websocket
        https://www.springframework.org/schema/websocket/spring-websocket.xsd">
    <websocket:handlers>
        <websocket:mapping path="/myHandler" handler="myHandler"/>
        <websocket:sockjs/>
    </websocket:handlers>
    <bean id="myHandler" class="org.springframework.samples.MyHandler"/>
</beans>

Предыдущий пример предназначен для использования в приложениях Spring MVC и должен быть включен в конфигурацию DispatcherServlet. Однако поддержка WebSocket и SockJS в Spring не зависит от Spring MVC. С помощью SockJsHttpRequestHandler относительно просто интегрироваться в другие среды, работающие с протоколом HTTP.

На стороне браузера приложения могут использовать sockjs-client (версия 1.0.x). Он эмулирует API для WebSocket, стандартизированный консорциумом W3C, и взаимодействует с сервером для выбора наилучшего варианта передачи в зависимости от браузера, в котором он запущен. См. страницу sockjs-client и список механизмов передачи, поддерживаемых браузером. Клиент также предусматривает несколько опций конфигурации – например, для указания того, какие механизмы передачи включить в состав.

IE 8 и 9

Internet Explorer 8 и 9 по-прежнему используются. Они являются ключевой причиной наличия SockJS. В этом разделе рассматриваются важные аспекты работы в этих браузерах.

Клиент SockJS поддерживает потоковую передачу Ajax/XHR в IE 8 и 9 с помощью XDomainRequest от Microsoft. Этот способ работает в разных доменах, но не поддерживает отправку cookie. Файлы cookie зачастую необходимы для работы Java-приложений. Однако, поскольку клиент SockJS может использоваться со многими типами серверов (не только на Java), ему необходимо понимают, имеют ли файлы cookie какую-либо значимость. Если да, то клиент SockJS отдаст предпочтение Ajax/XHR для потоковой передачи. В противном случае используется техника на основе iframe.

Первый запрос /info от клиента SockJS – это запрос информации, которая может повлиять на выбор клиентом механизма передачи. Частью такой информации является то, использует ли серверное приложение файлы cookie (например, для аутентификации или кластеризации при липких сессиях (sticky sessions)). Средства поддержки SockJS в Spring включают свойство под названием sessionCookieNeeded. Это средство активировано по умолчанию, поскольку большинство Java-приложений используют cookie JSESSIONID. Если вашему приложению оно не требуется, то можно отключить эту опцию, и тогда клиент SockJS должен будет выбрать xdr-streaming в IE 8 и 9.

Если вы используете механизм передачи на основе iframe, имейте в виду, что браузерам может быть дана команда блокировать использование IFrame на данной странице, установив в заголовке HTTP-ответа X-Frame-Options значение DENY, SAMEORIGIN или ALLOW-FROM <origin>. Это используется для предотвращения кликджекинга.

Spring Security 3.2+ обеспечивает поддержку установки заголовка X-Frame-Options для каждого ответа. По умолчанию Java-конфигурация Spring Security устанавливает значение DENY. В версии 3.2 пространство имен XML для Spring Security не устанавливает этот заголовок по умолчанию, но его можно сконфигурировать на такую установку. В будущем оно сможет устанавливать этот заголовок по умолчанию.

См. раздел "Заголовки безопасности по умолчанию" документации Spring Security для получения подробных сведений о том, как настроить параметры заголовка X-Frame-Options. Вы также можете ознакомиться с gh-2718 для получения дополнительной информации.

Если ваше приложение добавляет заголовок ответа X-Frame-Options (как и должно быть!) и использует механизм передачи на основе iframe, то нужно установить значение заголовка в SAMEORIGIN или ALLOW-FROM <origin>. Средствам поддержки Spring для SockJS также должно быть известно местоположение клиента SockJS, поскольку он загружается из iframe. По умолчанию iframe настроен на загрузку клиента SockJS из CDN. Хорошей идеей является настройка этого параметра на использование URL-адреса из того же источника, что и приложение.

В следующем примере показано, как это сделать с помощью Java-конфигурации:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/portfolio").withSockJS()
                .setClientLibraryUrl("http://localhost:8080/myapp/js/sockjs-client.js");
    }
    // ...
}

Пространство имен XML предоставляет аналогичную возможность через элемент <websocket:sockjs>.

Во время предварительной разработки активируйте режим devel клиента SockJS, который предотвратит кэширование браузером запросов SockJS (например, iframe), которые в противном случае были бы кэшированы. Подробнее о том, как его активировать, смотрите на странице клиента SockJS.

Heartbeat-сообщения

Протокол SockJS требует отправки heartbeat-сообщений со стороны сервера, чтобы прокси-серверы не смогли прийти к выводу, что соединение повисло. В конфигурации SockJS для Spring есть свойство heartbeatTime, которое можно использовать для настройки частоты. По умолчанию heartbeat отправляется через 25 секунд, при условии, что для данного соединения не было отправлено никаких других сообщений. Это 25-секундное значение соответствует следующей рекомендации IETF для интернет-приложений общего пользования.

При использовании STOMP поверх WebSocket и SockJS, если клиент и сервер STOMP согласовывают обмен heartbeat-сообщениями, heartbeat-сообщения SockJS отключаются.

Средства поддержки SockJS в Spring также позволяет вам сконфигурировать TaskScheduler для планирования задач передачи heartbeat-сообщений. Планировщик задач использует пул потоков, настройки которого по умолчанию зависят от количества доступных процессоров. Вам следует учесть возможность настройки параметров в соответствии с конкретными потребностями.

Разрыв соединения с клиентом

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

В контейнерах сервлетов это осуществляется благодаря асинхронной поддержке Servlet 3, которая позволяет выйти из потока контейнера сервлетов, обработать запрос и продолжить запись в ответ из другого потока.

Специфическая проблема заключается в том, что Servlet API не предоставляет уведомлений для клиента, который пропал. См. eclipse-ee4j/servlet-api#44. Однако контейнеры сервлетов генерируют исключение при последующих попытках записи в ответ. Поскольку служба Spring для SockJS поддерживает посылаемые сервером heartbeat-сообщения (по умолчанию каждые 25 секунд), это означает, что разрыв соединение с клиентом обычно обнаруживается в течение этого периода времени (или раньше, если сообщения посылаются чаще).

В результате из-за разрыва соединения с клиентом могут возникать сбои сетевого ввода-вывода, что может привести к заполнению журнала ненужными данными трассировки стека. Spring прилагает максимум усилий для идентификации таких сетевых сбоев, которые представляют собой разрыв соединения с клиентом (специфичные для каждого сервера) и записывает сжатое сообщение в журнал, используя специальную категорию журнала, DISCONNECTED_CLIENT_LOG_CATEGORY (определенную в AbstractSockJsSession). Если необходимо видеть трассировку стека, то можно установить категорию журнала в TRACE.

SockJS и CORS

Если вы допускаете запросы между разными источниками, протокол SockJS будет использовать механизм CORS для междоменной поддержки в потоковом транспорте XHR и транспорте опроса. Поэтому CORS-заголовки добавляются автоматически, если наличие CORS-заголовков в ответе не обнаружено. Таким образом, если приложение уже сконфигурировано на поддержку CORS (например, через фильтр сервлетов), SockJsService из Spring пропускает эту часть.

Также можно деактивировать добавление этих CORS-заголовков, установив свойство suppressCors в SockJsService из Spring.

SockJS принимает следующие заголовки и значения:

  • Access-Control-Allow-Origin: Инициализируется из значения заголовка запроса Origin.

  • Access-Control-Allow-Credentials: Всегда устанавливается в true.

  • Access-Control-Request-Headers: Инициализируется из значений эквивалентного заголовка запроса.

  • Access-Control-Allow-Methods: HTTP-методы, которые поддерживает механизм передачи (см. перечисляемый тип TransportType).

  • Access-Control-Max-Age: Устанавливает значение 31536000 (1 год).

Для точной реализации см. addCorsHeaders в AbstractSockJsService и перечисляемый тип TransportType в исходном коде.

Кроме того, если CORS-конфигурация позволяет это, то учтите возможность исключения URL-адресов с префиксом конечной точки SockJS, что позволит тем самым SockJsService из Spring обрабатывать их.

SockJsClient

Spring предусматривает Java-клиент SockJS для подключения к удаленным конечным точкам SockJS без использования браузера. Это может быть особенно полезно, если требуется обеспечить двунаправленную связь между двумя серверами через общедоступную сеть (то есть когда сетевые прокси-серверы могут препятствовать использованию протокола WebSocket). Java-клиент SockJS также очень полезен для тестирования (например, для имитации большого количества одновременных пользователей).

Java-клиент SockJS поддерживает механизмы передачи websocket, xhr-streaming и xhr-polling. Остальные имеют смысл только для использования в браузере.

Вы можете конфигурировать WebSocketTransport с помощью:

  • StandardWebSocketClient в среде выполнения JSR-356.

  • JettyWebSocketClient, используя WebSocket API встроенный в Jetty 9+.

  • Любой реализации WebSocketClient из Spring.

XhrTransport, по определению, поддерживает как xhr-streaming, так и xhr-polling, поскольку, с точки зрения клиента, нет никакой разницы, кроме URL-адреса, используемого для подключения к серверу. В настоящее время существует две реализации:

  • RestTemplateXhrTransport использует RestTemplate из Spring для HTTP-запросов.

  • JettyXhrTransport использует HttpClient из Jetty для HTTP-запросов.

В следующем примере показано, как создать SockJS-клиент и подключиться к конечной точке SockJS:

List<Transport> transports = new ArrayList<>(2);
transports.add(new WebSocketTransport(new StandardWebSocketClient()));
transports.add(new RestTemplateXhrTransport());
SockJsClient sockJsClient = new SockJsClient(transports);
sockJsClient.doHandshake(new MyWebSocketHandler(), "ws://example.com:8080/sockjs");
SockJS для формирования сообщений использует массивы в формате JSON. По умолчанию используется библиотека Jackson 2, которая должна находиться в classpath. Кроме того, можно конфигурировать кастомную реализацию SockJsMessageCodec и сконфигурировать ее на SockJsClient.

Чтобы использовать SockJsClient для имитации большого количества одновременных пользователей, необходимо сконфигурировать базовый HTTP-клиент (для XHR-механизма передачи) на достаточное количество соединений и потоков. В следующем примере показано, как это сделать с помощью Maven:

HttpClient jettyHttpClient = new HttpClient();
jettyHttpClient.setMaxConnectionsPerDestination(1000);
jettyHttpClient.setExecutor(new QueuedThreadPool(1000));

В следующем примере показаны свойства, связанные с SockJS на стороне сервера (подробнее см. javadoc), которые также следует настроить:

@Configuration
public class WebSocketConfig extends WebSocketMessageBrokerConfigurationSupport {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/sockjs").withSockJS()
            .setStreamBytesLimit(512 * 1024) 
            .setHttpMessageCacheSize(1000) 
            .setDisconnectDelay(30 * 1000); 
    }
    // ...
}
  1. Устанавливаем свойство streamBytesLimit равным 512 КБ (по умолчанию 128 КБ - 128 * 1024).
  2. Устанавливаем свойство httpMessageCacheSize равным 1,000 (по умолчанию 100).
  3. Устанавливаем свойство disconnectDelay равным 30 секундам (по умолчанию это пять секунд - 5 * 1000).