SockJS 回退机制
在公共互联网中,您无法控制的限制性代理可能会阻止 WebSocket 交互,这可能是因为它们未配置为传递 Upgrade
头,或者因为它们关闭了看似空闲的长连接。
解决此问题的方法是 WebSocket 模拟——即首先尝试使用 WebSocket,然后退而求其次使用基于 HTTP 的技术来模拟 WebSocket 交互,并暴露相同的应用程序级 API。
在 Servlet 栈上,Spring 框架为 SockJS 协议提供了服务器(以及客户端)支持。
概述
SockJS 的目标是让应用程序使用 WebSocket API,但在运行时必要时可以回退到非 WebSocket 的替代方案,而无需更改应用程序代码。
SockJS 包含:
-
SockJS JavaScript 客户端 — 一个用于浏览器的客户端库。
-
SockJS 服务器实现,包括 Spring Framework
spring-websocket
模块中的一个实现。 -
spring-websocket
模块中的 SockJS Java 客户端(自版本 4.1 起)。
SockJS 设计用于浏览器中。它使用多种技术来支持各种版本的浏览器。有关 SockJS 传输类型和浏览器的完整列表,请参见 SockJS client 页面。传输分为三大类: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 客户端通过使用微软的 XDomainRequest 支持在 IE 8 和 9 中的 Ajax/XHR 流式传输。这种方法可以跨域工作,但不支持发送 cookies。Cookies 对于 Java 应用程序通常是必不可少的。然而,由于 SockJS 客户端可以与多种服务器类型(不仅仅是 Java 服务器)一起使用,因此需要知道 cookies 是否重要。如果重要,SockJS 客户端会优先选择使用 Ajax/XHR 进行流式传输。否则,它会依赖于基于 iframe 的技术。
SockJS 客户端的第一个 /info
请求是一个请求信息的操作,这些信息可以影响客户端对传输方式的选择。其中一个细节是服务器应用程序是否依赖于 cookies(例如,用于身份验证目的或使用粘性会话进行集群)。Spring 的 SockJS 支持包括一个名为 sessionCookieNeeded
的属性。默认情况下它是启用的,因为大多数 Java 应用程序依赖于 JSESSIONID
cookie。如果你的应用程序不需要它,你可以关闭这个选项,然后 SockJS 客户端在 IE 8 和 9 中应该选择 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 配置中进行操作:
@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 过滤器),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
}
// ...
}
将
streamBytesLimit
属性设置为 512KB(默认值为 128KB —128 * 1024
)。将
httpMessageCacheSize
属性设置为 1,000(默认值为100
)。将
disconnectDelay
属性设置为 30 秒(默认值为五秒 —5 * 1000
)。