У загальнодоступній частині інтернету обмежувальні проксі-сервери, що знаходяться поза твоїм контролем, можуть перешкоджати взаємодії WebSocket або тому, що вони не налаштовані на передачу заголовка Upgrade, або тому, що вони закривають довготривалі з'єднання, які, здається, неактивні.

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

У стеку сервлетів Spring Framework забезпечує серверну (а також клієнтську) підтримку протоколу SockJS.

Короткий опис

Мета SockJS — дозволити програмам використовувати WebSocket API, але в разі необхідності під час виконання повертатися до альтернатив, не пов'язаних з WebSocket, без необхідності зміни коду програми.

SockJS складається з:

  • Протоколу SockJS, визначеного у вигляді виконуваних тестів з коментарями.

  • Клієнта SockJS з JavaScript — клієнтська бібліотека для використання в браузерах.

  • Реалізації сервера SockJS, включно з одною в модулі spring-websocket зі Spring Framework.

  • Java-клієнта SockJS у модулі spring-websocket (починаючи з версії 4.1 ).

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 (визначену в Ab ). Якщо потрібно бачити трасування стека, можна встановити категорію журналу в 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).