SockJS 後備機制

在公共網際網路上,您無法控制的限制性 Proxy 可能會阻止 WebSocket 互動,原因可能是它們未組態為傳遞 Upgrade 標頭,或是因為它們關閉了看似閒置的長期連線。

這個問題的解決方案是 WebSocket 模擬 — 也就是說,先嘗試使用 WebSocket,然後在必要時回退到基於 HTTP 的技術,這些技術模擬 WebSocket 互動並公開相同的應用程式層級 API。

在 Servlet 堆疊上,Spring 框架為 SockJS 協定提供伺服器(以及用戶端)支援。

概覽

SockJS 的目標是讓應用程式使用 WebSocket API,但在運行時必要時回退到非 WebSocket 替代方案,而無需更改應用程式碼。

SockJS 包含:

  • SockJS 協定,以可執行敘述式測試的形式定義。

  • SockJS JavaScript 用戶端 — 用於瀏覽器的用戶端程式庫。

  • SockJS 伺服器實作,包括 Spring 框架 spring-websocket 模組中的一個。

  • spring-websocket 模組中的 SockJS Java 用戶端(自 4.1 版起)。

SockJS 旨在用於瀏覽器。它使用各種技術來支援各種瀏覽器版本。有關 SockJS 傳輸類型和瀏覽器的完整列表,請參閱 SockJS 用戶端 頁面。傳輸分為三個主要類別:WebSocket、HTTP Streaming 和 HTTP Long Polling。有關這些類別的概述,請參閱這篇部落格文章

SockJS 用戶端首先傳送 GET /info 以從伺服器取得基本資訊。之後,它必須決定要使用哪種傳輸方式。如果可能,則使用 WebSocket。如果不行,在大多數瀏覽器中,至少有一個 HTTP streaming 選項。如果沒有,則使用 HTTP(長)輪詢。

所有傳輸請求都具有以下 URL 結構:

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

其中:

  • {server-id} 用於在叢集中路由請求,但在其他情況下不使用。

  • {session-id} 將屬於 SockJS 會話的 HTTP 請求關聯起來。

  • {transport} 指示傳輸類型(例如,websocketxhr-streaming 和其他)。

WebSocket 傳輸只需要一個 HTTP 請求即可執行 WebSocket 握手。此後的所有訊息都在該 Socket 上交換。

HTTP 傳輸需要更多請求。例如,Ajax/XHR streaming 依賴於一個長期運行的請求來傳輸伺服器到用戶端的訊息,以及額外的 HTTP POST 請求來傳輸用戶端到伺服器的訊息。Long polling 與此類似,不同之處在於它在每次伺服器到用戶端傳送後都會結束目前的請求。

SockJS 添加了最少的訊息框架。例如,伺服器最初傳送字母 o(“open” 框架),訊息以 a["message1","message2"](JSON 編碼的陣列)的形式傳送,如果 25 秒(預設情況下)沒有訊息流動,則傳送字母 h(“heartbeat” 框架),並傳送字母 c(“close” 框架)以關閉會話。

若要了解更多資訊,請在瀏覽器中運行範例並觀看 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 streaming。這可以跨網域工作,但不支援傳送 Cookie。Cookie 通常對於 Java 應用程式至關重要。但是,由於 SockJS 用戶端可以用於多種類型的伺服器(不僅僅是 Java 伺服器),因此它需要知道 Cookie 是否重要。如果是,則 SockJS 用戶端首選 Ajax/XHR 進行 streaming。否則,它將依賴於基於 iframe 的技術。

來自 SockJS 用戶端的第一次 /info 請求是請求可用於影響用戶端傳輸選擇的資訊。其中一個細節是伺服器應用程式是否依賴 Cookie(例如,用於驗證目的或具有黏性會話的叢集)。Spring 的 SockJS 支援包含一個名為 sessionCookieNeeded 的屬性。預設情況下啟用它,因為大多數 Java 應用程式都依賴於 JSESSIONID Cookie。如果您的應用程式不需要它,您可以關閉此選項,然後 SockJS 用戶端應在 IE 8 和 9 中選擇 xdr-streaming

如果您使用基於 iframe 的傳輸,請記住,可以透過將 HTTP 回應標頭 X-Frame-Options 設定為 DENYSAMEORIGINALLOW-FROM <origin> 來指示瀏覽器阻止在給定頁面上使用 IFrame。這用於防止 點擊劫持

Spring Security 3.2+ 提供了在每個回應上設定 X-Frame-Options 的支援。預設情況下,Spring Security Java 組態將其設定為 DENY。在 3.2 中,Spring Security XML 命名空間預設情況下不設定該標頭,但可以組態為執行此操作。將來,它可能會預設設定它。

有關如何組態 X-Frame-Options 標頭設定的詳細資訊,請參閱 Spring Security 文件的 預設安全性標頭。您也可以參閱 gh-2718 以取得更多背景資訊。

如果您的應用程式添加了 X-Frame-Options 回應標頭(應該如此!)並依賴於基於 iframe 的傳輸,則需要將標頭值設定為 SAMEORIGINALLOW-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://127.0.0.1:8080/myapp/js/sockjs-client.js");
	}

	// ...

}

XML 命名空間透過 <websocket:sockjs> 元素提供類似的選項。

在初始開發期間,請啟用 SockJS 用戶端 devel 模式,以防止瀏覽器快取 SockJS 請求(例如 iframe),否則這些請求將被快取。有關如何啟用它的詳細資訊,請參閱 SockJS 用戶端 頁面。

心跳

SockJS 協定要求伺服器傳送心跳訊息,以防止 Proxy 認為連線已掛起。Spring SockJS 組態有一個名為 heartbeatTime 的屬性,您可以使用它來自訂頻率。預設情況下,假設該連線上沒有傳送其他訊息,則在 25 秒後傳送心跳。此 25 秒值符合以下針對公共網際網路應用程式的 IETF 建議

當在 WebSocket 和 SockJS 上使用 STOMP 時,如果 STOMP 用戶端和伺服器協商要交換的心跳,則 SockJS 心跳將被停用。

Spring SockJS 支援還允許您組態 TaskScheduler 以排程心跳任務。任務排程器由執行緒池支援,預設設定基於可用處理器的數量。您應考慮根據您的特定需求自訂設定。

用戶端斷線

HTTP streaming 和 HTTP long polling SockJS 傳輸要求連線保持開啟時間比平常更長。有關這些技術的概述,請參閱 這篇部落格文章

在 Servlet 容器中,這是透過 Servlet 3 非同步支援完成的,該支援允許退出 Servlet 容器執行緒、處理請求以及從另一個執行緒繼續寫入回應。

一個具體的問題是 Servlet API 沒有提供用戶端已離開的通知。請參閱 eclipse-ee4j/servlet-api#44。但是,Servlet 容器在後續嘗試寫入回應時會引發例外。由於 Spring 的 SockJS Service 支援伺服器傳送的心跳(預設情況下每 25 秒),這意味著通常會在該時間段內(或更早,如果訊息傳送頻率更高)偵測到用戶端斷線。

因此,由於用戶端已斷線,可能會發生網路 I/O 失敗,這可能會在日誌中填滿不必要的堆疊追蹤。Spring 盡最大努力識別此類代表用戶端斷線的網路失敗(特定於每個伺服器),並使用專用的日誌類別 DISCONNECTED_CLIENT_LOG_CATEGORY(在 AbstractSockJsSession 中定義)記錄最少的訊息。如果您需要查看堆疊追蹤,可以將該日誌類別設定為 TRACE。

SockJS 和 CORS

如果您允許跨來源請求 (請參閱允許的來源),SockJS 協定會在 XHR 串流和輪詢傳輸中,使用 CORS 來支援跨網域。因此,除非偵測到回應中存在 CORS 標頭,否則會自動新增 CORS 標頭。因此,如果應用程式已設定為提供 CORS 支援 (例如,透過 Servlet 篩選器),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 端點,而無需使用瀏覽器。當需要在公用網路上 (也就是網路 Proxy 可能會排除 WebSocket 協定的使用) 兩個伺服器之間進行雙向通訊時,這可能特別有用。SockJS Java 用戶端對於測試目的 (例如,模擬大量並行使用者) 也非常有用。

SockJS Java 用戶端支援 websocketxhr-streamingxhr-polling 傳輸方式。其餘的傳輸方式僅適用於在瀏覽器中使用。

您可以透過以下方式設定 WebSocketTransport

  • JSR-356 執行階段中的 StandardWebSocketClient

  • 使用 Jetty 9+ 原生 WebSocket API 的 JettyWebSocketClient

  • Spring 的 WebSocketClient 的任何實作。

根據定義,XhrTransport 支援 xhr-streamingxhr-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)。