SockJS 回退
在公共網際網路上,你無法控制的限制性代理可能會阻止 WebSocket 互動,這可能是因為它們未配置為傳遞 Upgrade
頭,或者因為它們關閉看似空閒的長時間連線。
此問題的解決方案是 WebSocket 模擬——即,先嚐試使用 WebSocket,然後在必要時回退到基於 HTTP 的技術,這些技術模擬 WebSocket 互動並提供相同的應用層 API。
在 Servlet 棧上,Spring Framework 為 SockJS 協議提供了伺服器端(以及客戶端)支援。
概述
SockJS 的目標是讓應用可以使用 WebSocket API,但在執行時必要時回退到非 WebSocket 的替代方案,而無需修改應用程式碼。
SockJS 由以下組成
-
SockJS JavaScript 客戶端——一個用於瀏覽器的客戶端庫。
-
SockJS 伺服器端實現,包括 Spring Framework 的
spring-websocket
模組中的一個。 -
spring-websocket
模組中的 SockJS Java 客戶端(從 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}
用於關聯屬於某個 SockJS 會話的 HTTP 請求。 -
{transport}
表示傳輸型別(例如,websocket
,xhr-streaming
等)。
WebSocket 傳輸只需要一個 HTTP 請求來完成 WebSocket 握手。之後的所有訊息都在該套接字上交換。
HTTP 傳輸需要更多請求。例如,Ajax/XHR 流依賴一個長時間執行的請求用於伺服器到客戶端訊息,以及額外的 HTTP POST 請求用於客戶端到伺服器訊息。長輪詢與之類似,但它在每次伺服器到客戶端傳送後結束當前請求。
SockJS 添加了最小的訊息幀。例如,伺服器最初發送字母 o
(“開啟”幀),訊息以 a[\"message1\",\"message2\"]
(JSON 編碼陣列)的形式傳送,如果 25 秒內(預設)沒有訊息流過,則傳送字母 h
(“心跳”幀),傳送字母 c
(“關閉”幀)關閉會話。
要了解更多資訊,請在瀏覽器中執行一個示例並觀察 HTTP 請求。SockJS 客戶端允許固定傳輸列表,因此可以逐個檢視每種傳輸方式。SockJS 客戶端還提供一個除錯標誌,可以在瀏覽器控制檯中啟用有用的訊息。在伺服器端,你可以為 org.springframework.web.socket
啟用 TRACE
級別的日誌記錄。如需更多詳細資訊,請參閱 SockJS 協議敘述性測試。
啟用 SockJS
你可以透過配置啟用 SockJS,如下例所示
-
Java
-
Kotlin
-
Xml
@Configuration
@EnableWebSocket
public class WebSocketConfiguration implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler").withSockJS();
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
@Configuration
@EnableWebSocket
class WebSocketConfiguration : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(myHandler(), "/myHandler").withSockJS()
}
@Bean
fun myHandler(): WebSocketHandler {
return MyHandler()
}
}
<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.docs.web.websocket.websocketserverhandler.MyHandler"/>
</beans>
前面的示例用於 Spring MVC 應用,應包含在 DispatcherServlet
的配置中。然而,Spring 的 WebSocket 和 SockJS 支援不依賴於 Spring MVC。藉助 SockJsHttpRequestHandler
,可以相對簡單地將其整合到其他 HTTP 服務環境中。
在瀏覽器端,應用可以使用 sockjs-client
(版本 1.0.x)。它模擬 W3C WebSocket API 並與伺服器通訊,根據執行的瀏覽器選擇最佳傳輸選項。請參閱sockjs-client頁面和瀏覽器支援的傳輸型別列表。客戶端還提供了幾個配置選項,例如,指定要包含哪些傳輸方式。
IE 8 和 9
Internet Explorer 8 和 9 仍在使用。它們是 SockJS 存在的一個關鍵原因。本節介紹在這些瀏覽器中執行時的重要注意事項。
SockJS 客戶端透過使用 Microsoft 的 XDomainRequest
支援在 IE 8 和 9 中進行 Ajax/XHR 流。這可以在跨域工作,但不支援傳送 cookie。對於 Java 應用來說,cookie 通常至關重要。然而,由於 SockJS 客戶端可以與多種型別的伺服器(不僅僅是 Java 伺服器)一起使用,它需要知道 cookie 是否重要。如果是,SockJS 客戶端優先使用 Ajax/XHR 進行流傳輸。否則,它依賴於基於 iframe 的技術。
SockJS 客戶端傳送的第一個 /info
請求是用於獲取可能影響客戶端傳輸選擇的資訊的請求。其中一個詳細資訊是伺服器應用是否依賴於 cookie(例如,用於認證目的或帶有粘性會話的叢集)。Spring 的 SockJS 支援包含一個名為 sessionCookieNeeded
的屬性。由於大多數 Java 應用依賴於 JSESSIONID
cookie,預設情況下它是啟用的。如果你的應用不需要它,可以關閉此選項,然後 SockJS 客戶端在 IE 8 和 9 中應選擇 xdr-streaming
。
如果你確實使用基於 iframe 的傳輸,請記住可以透過將 HTTP 響應頭 X-Frame-Options
設定為 DENY
、SAMEORIGIN
或 ALLOW-FROM <origin>
來指示瀏覽器阻止在給定頁面上使用 IFrame。這用於防止點選劫持。
Spring Security 3.2+ 支援在每個響應上設定 |
如果你的應用添加了 X-Frame-Options
響應頭(應該這樣做!)並且依賴於基於 iframe 的傳輸,你需要將該頭值設定為 SAMEORIGIN
或 ALLOW-FROM <origin>
。Spring SockJS 支援還需要知道 SockJS 客戶端的位置,因為它從 iframe 中載入。預設情況下,iframe 設定為從 CDN 位置下載 SockJS 客戶端。最好將此選項配置為使用與應用同源的 URL。
以下示例展示瞭如何在 Java 配置中進行設定
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").withSockJS()
.setClientLibraryUrl("https://:8080/myapp/js/sockjs-client.js");
}
// ...
}
XML 名稱空間透過 <websocket:sockjs>
元素提供了類似的選項。
在初始開發期間,請啟用 SockJS 客戶端的 devel 模式,該模式可防止瀏覽器快取 SockJS 請求(例如 iframe),否則這些請求會被快取。有關如何啟用它的詳細資訊,請參閱SockJS 客戶端頁面。 |
心跳
SockJS 協議要求伺服器傳送心跳訊息,以防止代理斷定連線已掛起。Spring SockJS 配置有一個名為 heartbeatTime
的屬性,你可以用它來自定義頻率。預設情況下,如果該連線上沒有傳送其他訊息,則在 25 秒後傳送心跳。這個 25 秒的值符合以下針對公共網際網路應用的 IETF 建議。
當透過 WebSocket 和 SockJS 使用 STOMP 時,如果 STOMP 客戶端和伺服器協商交換心跳,則 SockJS 心跳將被停用。 |
Spring SockJS 支援還允許你配置 TaskScheduler
來排程心跳任務。任務排程器由執行緒池支援,預設設定基於可用處理器數量。你應該根據你的具體需求考慮定製這些設定。
客戶端斷開連線
HTTP 流和 HTTP 長輪詢 SockJS 傳輸需要連線保持開啟狀態比通常更長。有關這些技術的概述,請參閱這篇部落格文章。
在 Servlet 容器中,這是透過 Servlet 3 非同步支援完成的,該支援允許退出 Servlet 容器執行緒,處理請求,並從另一個執行緒繼續寫入響應。
一個具體的問題是 Servlet API 不提供客戶端離開的通知。請參閱 eclipse-ee4j/servlet-api#44。然而,Servlet 容器在後續嘗試寫入響應時會引發異常。由於 Spring 的 SockJS 服務支援伺服器傳送的心跳(預設為每 25 秒),這意味著通常在此時間段內檢測到客戶端斷開連線(或者如果訊息傳送更頻繁,則更早)。
因此,可能會由於客戶端斷開連線而發生網路 I/O 故障,這會在日誌中填充不必要的堆疊跟蹤。Spring 盡最大努力識別代表客戶端斷開連線的網路故障(特定於每個伺服器),並使用專用的日誌類別 DISCONNECTED_CLIENT_LOG_CATEGORY (在 AbstractSockJsSession 中定義)記錄最少量的訊息。如果你需要檢視堆疊跟蹤,可以將該日誌類別設定為 TRACE。 |
SockJS 和 CORS
如果你允許跨源請求(請參閱允許的源),SockJS 協議會在 XHR 流和輪詢傳輸中使用 CORS 進行跨域支援。因此,除非檢測到響應中已存在 CORS 頭,否則會自動新增 CORS 頭。所以,如果應用已經配置了 CORS 支援(例如,透過 Servlet Filter),Spring 的 SockJsService
會跳過這一部分。
也可以透過在 Spring 的 SockJsService 中設定 suppressCors
屬性來停用這些 CORS 頭的新增。
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 年)。
有關具體實現,請參閱 AbstractSockJsService
中的 addCorsHeaders
和原始碼中的 TransportType
列舉。
另外,如果 CORS 配置允許,請考慮排除帶有 SockJS 端點字首的 URL,從而讓 Spring 的 SockJsService
來處理它。
SockJsClient
Spring 提供了一個 SockJS Java 客戶端,用於連線遠端 SockJS 端點而無需使用瀏覽器。這在公共網路上需要在兩個伺服器之間進行雙向通訊時特別有用(即,網路代理可能會阻止使用 WebSocket 協議的情況)。SockJS Java 客戶端對於測試目的也非常有用(例如,模擬大量併發使用者)。
SockJS Java 客戶端支援 websocket
, xhr-streaming
, 和 xhr-polling
傳輸。其餘的傳輸方式只在瀏覽器中使用才有意義。
你可以使用以下方式配置 WebSocketTransport
-
JSR-356 執行環境中的
StandardWebSocketClient
。 -
使用 Jetty 9+ 原生 WebSocket API 的
JettyWebSocketClient
。 -
Spring 的
WebSocketClient
的任何實現。
從定義上看,XhrTransport
支援 xhr-streaming
和 xhr-polling
,因為從客戶端角度看,除了連線伺服器使用的 URL 不同外,沒有其他區別。目前有兩種實現方式
-
RestTemplateXhrTransport
使用 Spring 的RestTemplate
進行 HTTP 請求。 -
JettyXhrTransport
使用 Jetty 的HttpClient
進行 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 且需要其位於類路徑中。或者,您可以配置自定義的 SockJsMessageCodec 實現,並將其配置在 SockJsClient 上。 |
要使用 SockJsClient
模擬大量併發使用者,您需要配置底層的 HTTP 客戶端(用於 XHR 傳輸),以允許足夠數量的連線和執行緒。以下示例展示瞭如何使用 Jetty 進行配置。
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) (1)
.setHttpMessageCacheSize(1000) (2)
.setDisconnectDelay(30 * 1000); (3)
}
// ...
}
1 | 將 streamBytesLimit 屬性設定為 512KB(預設為 128KB — 128 * 1024 )。 |
2 | 將 httpMessageCacheSize 屬性設定為 1,000(預設為 100 )。 |
3 | 將 disconnectDelay 屬性設定為 30 秒(預設為五秒 — 5 * 1000 )。 |