SockJS 备用方案

通过公共互联网,您无法控制的限制性代理可能会阻止 WebSocket 交互,因为它们要么未配置为传递Upgrade标头,要么因为它们关闭了看起来处于空闲状态的长连接。

此问题的解决方案是 WebSocket 模拟,即首先尝试使用 WebSocket,然后回退到模拟 WebSocket 交互并公开相同应用程序级 API 的基于 HTTP 的技术。

在 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 流式传输和 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}指示传输类型(例如,websocketxhr-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。Cookie 通常对于 Java 应用程序至关重要。但是,由于 SockJS 客户端可以与许多服务器类型(不仅仅是 Java 类型)一起使用,因此它需要知道 Cookie 是否重要。如果是,则 SockJS 客户端更喜欢 Ajax/XHR 进行流式传输。否则,它依赖于基于 iframe 的技术。

来自 SockJS 客户端的第一个/info请求是用于获取可以影响客户端传输选择的信息的请求。其中一个细节是服务器应用程序是否依赖于 Cookie(例如,用于身份验证目的或使用粘性会话进行集群)。Spring 的 SockJS 支持包括一个名为sessionCookieNeeded的属性。它默认启用,因为大多数 Java 应用程序都依赖于JSESSIONIDCookie。如果您的应用程序不需要它,您可以关闭此选项,然后 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 协议要求服务器发送心跳消息,以防止代理认为连接已挂起。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 过滤器),则 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 客户端支持websocketxhr-streamingxhr-polling传输。其余的只有在浏览器中使用才有意义。

您可以使用以下方式配置WebSocketTransport

  • 在 JSR-356 运行时中使用StandardWebSocketClient

  • 通过使用 Jetty 9+ 本地 WebSocket API 使用JettyWebSocketClient

  • Spring 的WebSocketClient的任何实现。

从客户端的角度来看,除了用于连接到服务器的 URL 之外,XhrTransport根据定义支持xhr-streamingxhr-polling,因为没有其他区别。目前有两个实现

  • 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 秒 — 5 * 1000)。