SockJS 倒退机制(SockJS Fallback)
在公共互联网上,你无法控制的限制性代理可能会阻碍WebSocket交互的进行,要么是因为这些代理没有被配置为传递Upgrade头信息,要么是因为它们会关闭那些看似处于空闲状态但实际持续运行的连接。
这个问题的解决方案是WebSocket仿真——也就是说,首先尝试使用WebSocket,然后在无法使用时转而采用基于HTTP的技术来模拟WebSocket交互,并暴露相同的应用程序级API。
在Servlet堆栈上,Spring框架为SockJS协议提供了服务器(以及客户端)支持。
概述
SockJS的目标是让应用程序能够使用WebSocket API,但在运行时必要时可以回退到非WebSocket的替代方案,而无需更改应用程序代码。
SockJS由以下部分组成:
-
SockJS JavaScript客户端——一种用于浏览器的客户端库。
-
SockJS服务器实现,包括Spring框架中的
spring-websocket模块所提供的实现。 -
自4.1版本起,在
spring-websocket模块中也提供了SockJS Java客户端。
SockJS 是专为浏览器设计开发的。它采用多种技术来支持广泛的浏览器版本。有关 SockJS 的传输类型及所支持的浏览器的完整列表,请参阅 SockJS 客户端 页面。这些传输方式大致可分为三类:WebSocket、HTTP 流式传输(HTTP Streaming)以及 HTTP 长轮询(HTTP Long Polling)。有关这些类别的概述,请阅读 这篇博客文章。
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请求来传递客户端到服务器的数据。长轮询(Long Polling)类似,不同之处在于它在每次服务器向客户端发送数据后会结束当前的请求。
SockJS添加了最简化的消息帧结构。例如,服务器最初会发送字母“o”(表示“打开”状态),消息以a["message1","message2"](JSON编码的数组)的形式发送;如果25秒内没有消息传递(默认设置),则会发送字母“h”(表示“心跳”信号);而要关闭会话时,则会发送字母“c”(表示“关闭”状态)。
要了解更多信息,可以在浏览器中运行示例并观察HTTP请求。SockJS客户端允许自定义传输方式列表,因此可以一次查看每种传输方式。SockJS客户端还提供了一个调试标志(debug flag),该标志可在浏览器控制台中显示有用的信息。在服务器端,可以为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客户端通过使用微软的XDomainRequest在IE 8和9中支持Ajax/XHR流式传输。这种技术可以跨域使用,但不支持发送cookies。对于Java应用程序来说,cookies通常是必不可少的。然而,由于SockJS客户端可以与多种类型的服务器(不仅仅是Java服务器)配合使用,因此它需要知道cookies是否重要。如果cookies很重要,那么SockJS客户端会优先选择Ajax/XHR进行流式传输;否则,它会采用基于iframe的技术。
SockJS客户端发送的第一个 /info 请求是用于获取可能会影响客户端传输方式选择的信息的请求。其中一项详细信息是服务器应用程序是否依赖于cookie(例如,用于身份验证目的或通过粘性会话(sticky sessions)进行集群)。Spring 对 SockJS 的支持包含一个名为 sessionCookieNeeded 的属性。该属性默认是启用的,因为大多数 Java 应用程序都依赖于 JSESSIONID cookie。如果您的应用程序不需要它,您可以关闭此选项,那么在 IE 8 和 IE 9 中,SockJS 客户端应该会选择 xdr-streaming 作为传输方式。
如果你确实使用了基于iframe的传输方式,请记住,可以通过将HTTP响应头X-Frame-Options设置为DENY、SAMEORIGIN或ALLOW-FROM <origin>来指示浏览器阻止在特定页面上使用iframe。这样做是为了防止点击劫持。
如果你的应用程序添加了X-Frame-Options响应头(这是应该做的!),并且依赖于基于iframe的传输方式,那么你需要将这个头部的值设置为SAMEORIGIN或ALLOW-FROM <origin>。Spring SockJS也需要知道SockJS客户端的位置,因为它是从iframe中加载的。默认情况下,iframe会从CDN位置下载SockJS客户端。最好将此选项配置为使用与应用程序相同来源的URL。
以下示例展示了如何配置它:
- Java
- Kotlin
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").withSockJS()
.setClientLibraryUrl("http://localhost:8080/myapp/js/sockjs-client.js");
}
// ...
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Configure message broker...
}
}
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfiguration : WebSocketMessageBrokerConfigurer {
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/portfolio").withSockJS()
.setClientLibraryUrl("http://localhost:8080/myapp/js/sockjs-client.js")
}
// ...
override fun configureMessageBroker(registry: MessageBrokerRegistry) {
// Configure message broker...
}
}
在初始开发阶段,建议启用 SockJS 客户端的 devel 模式,该模式可以防止浏览器缓存 SockJS 请求(比如通过 iframe 发送的请求),否则这些请求可能会被浏览器缓存。有关如何启用该模式的详细信息,请参阅 SockJS 客户端 页面。
心跳
SockJS协议要求服务器发送心跳消息,以防止代理服务器认为连接已挂起。Spring SockJS配置中有一个名为heartbeatTime的属性,你可以使用它来自定义心跳发送的频率。默认情况下,如果在该连接上没有发送其他消息,那么25秒后就会发送一次心跳。这个25秒的间隔时间符合以下IETF建议中对于公共互联网应用的要求。
当通过WebSocket和SockJS使用STOMP时,如果STOMP客户端和服务器协商交换心跳信号,那么SockJS的心跳功能将被禁用。
Spring SockJS支持还允许你配置TaskScheduler来调度心跳任务。任务调度器由一个线程池支持,其默认设置是根据可用的处理器数量来决定的。你应该根据你的具体需求考虑自定义这些设置。
客户端断开连接
HTTP流式传输和HTTP长轮询(SocketJS)需要保持连接时间比平常更长。有关这些技术的概述,请参阅这篇博客文章。
在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 需要以下头部信息(headers)和对应的值(values):
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),您也应该考虑对这些属性进行自定义:
- Java
- Kotlin
@Configuration
public class WebSocketConfiguration extends WebSocketMessageBrokerConfigurationSupport {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/sockjs").withSockJS()
// Set the streamBytesLimit property to 512KB (the default is 128KB -- 128 * 1024)
.setStreamBytesLimit(512 * 1024)
// Set the httpMessageCacheSize property to 1,000 (the default is 100)
.setHttpMessageCacheSize(1000)
// Set the disconnectDelay property to 30 property seconds (the default is five seconds -- 5 * 1000)
.setDisconnectDelay(30 * 1000);
}
// ...
}
@Configuration
class WebSocketConfiguration : WebSocketMessageBrokerConfigurationSupport() {
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/sockjs").withSockJS()
// Set the streamBytesLimit property to 512KB (the default is 128KB -- 128 * 1024)
.setStreamBytesLimit(512 * 1024)
// Set the httpMessageCacheSize property to 1,000 (the default is 100)
.setHttpMessageCacheSize(1000)
// Set the disconnectDelay property to 30 property seconds (the default is five seconds -- 5 * 1000)
.setDisconnectDelay(30 * 1000)
}
// ...
}