|
对于最新稳定版本,请使用 Spring Framework 7.0.6! |
SockJS 降级方案
在公共互联网上,您无法控制的限制性代理可能会阻止 WebSocket 通信,原因可能是它们未配置为传递 Upgrade 头部,或者因为它们会关闭看似空闲的长连接。
解决此问题的方法是 WebSocket 仿真——即首先尝试使用 WebSocket,如果不可用,则回退到基于 HTTP 的技术,这些技术可以模拟 WebSocket 交互,并暴露相同的应用层 API。
在 Servlet 栈上,Spring Framework 为 SockJS 协议提供了服务器端(以及客户端)支持。
概述
SockJS 的目标是让应用程序能够使用 WebSocket API,但在运行时必要时可回退到非 WebSocket 的替代方案,而无需更改应用程序代码。
SockJS 包含以下部分:
-
SockJS JavaScript 客户端 —— 一个用于浏览器的客户端库。
-
SockJS 服务器端实现,包括 Spring Framework
spring-websocket模块中的一种实现。 -
spring-websocket模块中提供的 SockJS Java 客户端(自 4.1 版本起)。
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 请求来实现客户端到服务器的消息传递。长轮询与此类似,不同之处在于它在每次服务器向客户端发送消息后都会结束当前请求。
SockJS 添加了最小的消息帧格式。例如,服务器最初会发送字母 o(“打开”帧),消息以 a["message1","message2"](JSON 编码的数组)形式发送,如果 25 秒内(默认值)没有消息传输,则发送字母 h(“心跳”帧),并在关闭会话时发送字母 c(“关闭”帧)。
要了解更多内容,请在浏览器中运行一个示例并观察 HTTP 请求。
SockJS 客户端允许固定传输方式列表,因此可以一次只查看一种传输方式。
SockJS 客户端还提供了一个调试标志(debug flag),可在浏览器控制台中启用有用的提示信息。
在服务器端,您可以为 TRACE 启用 org.springframework.web.socket 级别日志记录。
如需更详细的说明,请参阅 SockJS 协议的
带注释的测试。
启用 SockJS
您可以通过 Java 配置启用 SockJS,如下例所示:
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler").withSockJS();
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
以下示例展示了与前述示例等效的 XML 配置:
<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.samples.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 客户端在 IE 8 和 IE 9 中通过利用微软的
XDomainRequest支持 Ajax/XHR 流式传输。
该方案支持跨域,但不支持发送 Cookie。
Cookie 对于 Java 应用程序而言通常是必不可少的。
然而,由于 SockJS 客户端可与多种服务器类型(不仅限于 Java)配合使用,因此需要判断 Cookie 是否重要。
如果需要 Cookie,SockJS 客户端将优先选择 Ajax/XHR 进行流式传输;否则,它将依赖基于 iframe 的技术。
SockJS 客户端发起的第一个 /info 请求用于获取信息,这些信息会影响客户端对传输方式的选择。
其中一个细节是服务器应用程序是否依赖 Cookie
(例如,用于身份验证或使用粘性会话进行集群)。
Spring 的 SockJS 支持包含一个名为 sessionCookieNeeded 的属性。
该属性默认启用,因为大多数 Java 应用程序都依赖 JSESSIONID
Cookie。如果你的应用程序不需要它,可以关闭此选项,
这样 SockJS 客户端在 IE 8 和 IE 9 中就会选择 xdr-streaming 传输方式。
如果你确实使用了基于 iframe 的传输方式,请注意,浏览器可以通过设置 HTTP 响应头 X-Frame-Options 为 DENY、SAMEORIGIN 或 ALLOW-FROM <origin> 来禁止在特定页面上使用 iframe。这是用于防止点击劫持(clickjacking)的措施。
|
Spring Security 3.2 及以上版本支持在每个响应中设置 |
如果你的应用程序添加了 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 年)。
有关具体实现,请参见源代码中的 addCorsHeaders 类里的 AbstractSockJsService 方法以及 TransportType 枚举。
或者,如果 CORS 配置允许,可以考虑排除带有 SockJS 端点前缀的 URL,从而让 Spring 的 SockJsService 来处理它。
SockJsClient
Spring 提供了一个 SockJS Java 客户端,用于在不使用浏览器的情况下连接到远程的 SockJS 端点。这在需要通过公共网络(即网络代理可能阻止使用 WebSocket 协议的场景)实现两个服务器之间的双向通信时尤其有用。SockJS Java 客户端在测试场景中也非常有用(例如,用于模拟大量并发用户)。
SockJS Java 客户端支持 websocket、xhr-streaming 和 xhr-polling 传输方式。其余的传输方式仅适用于浏览器环境。
你可以通过以下方式配置 WebSocketTransport:
-
StandardWebSocketClient在 JSR-356 运行时中。 -
JettyWebSocketClient,使用 Jetty 9+ 原生 WebSocket API。 -
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,并且需要将其放在类路径(classpath)中。或者,你也可以配置一个自定义的 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)。 |