Skip to content

SockJS Fallback:WebSocket 的优雅降级方案 🔄

引言:为什么需要 SockJS?

想象一下,你精心开发了一个基于 WebSocket 的实时聊天应用,在本地测试一切完美。但当部署到生产环境后,却发现部分用户无法正常使用——他们的网络代理服务器阻止了 WebSocket 连接,或者使用的是不支持 WebSocket 的老旧浏览器。这就是 SockJS 要解决的核心问题。

IMPORTANT

SockJS 的设计哲学:让应用使用 WebSocket API,但在必要时自动降级到非 WebSocket 替代方案,而无需修改应用代码

什么是 SockJS?

SockJS(Socket.JS)是一个 JavaScript 库,它为 WebSocket 提供了一个类似的对象,但会在 WebSocket 不可用时自动降级到其他传输方式。

SockJS 的核心组件

传输方式的降级策略

SockJS 支持三大类传输方式,按优先级排序:

  1. WebSocket - 最优选择
  2. HTTP Streaming - 次优选择
  3. HTTP Long Polling - 保底选择

TIP

SockJS 客户端会首先发送 GET /info 请求获取服务器信息,然后根据浏览器支持情况选择最佳传输方式。

在 Spring Boot 中启用 SockJS

基础配置

kotlin
@Configuration
@EnableWebSocket
class WebSocketConfiguration : WebSocketConfigurer {
    
    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(myHandler(), "/chat") 
                .withSockJS() 
    }
    
    @Bean
    fun myHandler(): WebSocketHandler {
        return ChatWebSocketHandler()
    }
}
kotlin
class ChatWebSocketHandler : TextWebSocketHandler() {
    
    private val sessions = mutableSetOf<WebSocketSession>()
    
    override fun afterConnectionEstablished(session: WebSocketSession) {
        sessions.add(session)
        logger.info("客户端连接建立: ${session.id}")
    }
    
    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        // 广播消息给所有连接的客户端
        sessions.forEach { webSocketSession ->
            if (webSocketSession.isOpen) {
                webSocketSession.sendMessage(message) 
            }
        }
    }
    
    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        sessions.remove(session)
        logger.info("客户端连接关闭: ${session.id}")
    }
    
    companion object {
        private val logger = LoggerFactory.getLogger(ChatWebSocketHandler::class.java)
    }
}

SockJS 的 URL 结构

SockJS 的所有传输请求都遵循以下 URL 结构:

https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}

其中:

  • {server-id} - 用于集群路由(通常不使用)
  • {session-id} - 关联属于同一 SockJS 会话的 HTTP 请求
  • {transport} - 传输类型(如 websocketxhr-streaming 等)

SockJS 的工作原理

连接建立流程

消息帧格式

SockJS 添加了最小的消息帧:

  • o - "open" 帧(连接建立)
  • a["message1","message2"] - 消息帧(JSON 编码数组)
  • h - "heartbeat" 帧(心跳,默认 25 秒)
  • c - "close" 帧(关闭连接)

处理 IE 8/9 兼容性问题

核心挑战

WARNING

Internet Explorer 8 和 9 不支持标准的 WebSocket,这是使用 SockJS 的重要原因之一。

解决方案配置

kotlin
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/portfolio")
                .withSockJS()
                .setClientLibraryUrl("http://localhost:8080/myapp/js/sockjs-client.js") 
    }
    
    // 其他配置...
}

WARNING

如果使用 iframe 传输方式,需要注意 X-Frame-Options 头部设置。Spring Security 默认设置为 DENY,可能需要调整为 SAMEORIGINALLOW-FROM <origin>

心跳机制

为什么需要心跳?

心跳配置

kotlin
@Configuration
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/websocket")
                .withSockJS()
                .setHeartbeatTime(25000) // 25秒心跳间隔
    }
}

NOTE

默认心跳间隔为 25 秒,这符合 IETF 对公共互联网应用的建议。

客户端断开检测

挑战

Servlet API 不提供客户端断开的通知,但 SockJS 通过心跳机制可以在 25 秒内(或更短时间)检测到客户端断开。

日志处理

kotlin
// 在 application.yml 中配置
logging:
  level:
    org.springframework.web.socket.sockjs.transport.session.AbstractSockJsSession: TRACE

TIP

Spring 会尽力识别客户端断开导致的网络故障,并使用专用日志类别 DISCONNECTED_CLIENT_LOG_CATEGORY 记录最小消息。

CORS 支持

自动 CORS 处理

SockJS 协议在 XHR streaming 和 polling 传输中使用 CORS 进行跨域支持:

kotlin
@Configuration
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/websocket")
                .setAllowedOrigins("https://example.com") 
                .withSockJS()
    }
}

CORS 头部

SockJS 期望以下 CORS 头部:

  • Access-Control-Allow-Origin - 从 Origin 请求头初始化
  • Access-Control-Allow-Credentials - 始终设置为 true
  • Access-Control-Allow-Methods - 传输支持的 HTTP 方法
  • Access-Control-Max-Age - 设置为 31536000(1年)

SockJS Java 客户端

使用场景

SockJS Java 客户端特别适用于:

  • 服务器间双向通信
  • 测试目的(模拟大量并发用户)
  • 网络代理阻止 WebSocket 的环境

客户端配置示例

kotlin
// 配置传输方式
val transports = mutableListOf<Transport>().apply {
    add(WebSocketTransport(StandardWebSocketClient()))
    add(RestTemplateXhrTransport())
}

// 创建 SockJS 客户端
val sockJsClient = SockJsClient(transports)

// 连接到 SockJS 端点
sockJsClient.doHandshake(
    MyWebSocketHandler(), 
    "ws://example.com:8080/sockjs"
)

高并发配置

kotlin
// 配置 Jetty HTTP 客户端以支持大量并发连接
val jettyHttpClient = HttpClient().apply {
    maxConnectionsPerDestination = 1000
    executor = QueuedThreadPool(1000) 
}

// 服务端配置优化
@Configuration
class WebSocketConfig : WebSocketMessageBrokerConfigurationSupport() {
    
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/sockjs")
                .withSockJS()
                .setStreamBytesLimit(512 * 1024) // 512KB
                .setHttpMessageCacheSize(1000) 
                .setDisconnectDelay(30 * 1000) // 30秒
    }
}

实际应用示例

聊天应用完整示例

点击查看完整的聊天应用示例
kotlin
// 前端 JavaScript 代码
const socket = new SockJS('/chat');

socket.onopen = function() {
    console.log('连接已建立');
    document.getElementById('status').textContent = '已连接';
};

socket.onmessage = function(event) {
    const message = event.data;
    const messagesDiv = document.getElementById('messages');
    messagesDiv.innerHTML += '<div>' + message + '</div>';
};

socket.onclose = function() {
    console.log('连接已关闭');
    document.getElementById('status').textContent = '已断开';
};

function sendMessage() {
    const input = document.getElementById('messageInput');
    socket.send(input.value);
    input.value = '';
}
kotlin
// 后端 WebSocket 处理器
@Component
class ChatWebSocketHandler : TextWebSocketHandler() {
    
    private val sessions = Collections.synchronizedSet(mutableSetOf<WebSocketSession>())
    
    override fun afterConnectionEstablished(session: WebSocketSession) {
        sessions.add(session)
        broadcastMessage("用户 ${session.id} 加入聊天室")
        logger.info("新用户连接: ${session.id}")
    }
    
    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        val username = session.attributes["username"] ?: "匿名用户"
        val chatMessage = "$username: ${message.payload}"
        broadcastMessage(chatMessage)
    }
    
    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        sessions.remove(session)
        broadcastMessage("用户 ${session.id} 离开聊天室")
        logger.info("用户断开连接: ${session.id}")
    }
    
    private fun broadcastMessage(message: String) {
        sessions.removeIf { session ->
            try {
                if (session.isOpen) {
                    session.sendMessage(TextMessage(message))
                    false
                } else {
                    true
                }
            } catch (e: Exception) {
                logger.error("发送消息失败", e)
                true
            }
        }
    }
    
    companion object {
        private val logger = LoggerFactory.getLogger(ChatWebSocketHandler::class.java)
    }
}

最佳实践与建议

1. 开发阶段

TIP

在开发阶段启用 SockJS 客户端的 devel 模式,防止浏览器缓存 SockJS 请求。

2. 生产环境

生产环境注意事项

  • 合理配置心跳间隔
  • 监控客户端断开情况
  • 优化服务器资源配置
  • 考虑负载均衡和集群部署

3. 错误处理

kotlin
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
    logger.error("传输错误: ${session.id}", exception)
    // 实现重连逻辑或错误恢复机制
}

总结

SockJS 为 WebSocket 应用提供了强大的降级机制,确保在各种网络环境和浏览器中都能正常工作。通过合理配置和使用 SockJS,我们可以构建出既现代又兼容的实时通信应用。

IMPORTANT

记住 SockJS 的核心价值:透明的降级机制让开发者专注于业务逻辑,而不用担心底层传输的兼容性问题

🎉 现在你已经掌握了 SockJS 的核心概念和实际应用,可以开始构建自己的实时通信应用了!