SockJS 回退

在公共互联网上,你无法控制的限制性代理可能会阻止 WebSocket 交互,这可能是因为它们未配置为传递 Upgrade 头,或者因为它们关闭看似空闲的长时间连接。

此问题的解决方案是 WebSocket 仿真——即,先尝试使用 WebSocket,然后在必要时回退到基于 HTTP 的技术,这些技术模拟 WebSocket 交互并提供相同的应用层 API。

在 Servlet 栈上,Spring Framework 为 SockJS 协议提供了服务器端(以及客户端)支持。

概述

SockJS 的目标是让应用可以使用 WebSocket API,但在运行时必要时回退到非 WebSocket 的替代方案,而无需修改应用代码。

SockJS 由以下组成

  • 以可执行的叙述性测试形式定义的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 设置为 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("http://localhost: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-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)。