WebSocket基本知识(附带@ServerEndpoint注解) WebSocket是一种在单个 TCP 连接上进行全双工通讯的协议。什么意思呢我们之前学过html协议它是一个单工/半双工协议他只能由客户端发起请求服务端没有办法主动给客户端发送消息。如果出现服务器需要主动发送消息的情况比如实时语音转写需要服务端不断的往客户端传送语音转换之后的文字那么处理起来就会很麻烦。用客户端的Ajax轮询技术不仅浪费资源而且实时性也不强。html还有一个缺点就是每次响应结束之后连接都会断开下次还要再次建立TCP协议html连接很多时候又带有很长的头部可能有效的信息并不多就会很浪费资源。WebSocket就解决了这个问题服务端可以主动给客户端传送消息底层TCP连接可以复用连接时不受浏览器同源策略的限制。服务端和客户端只要完成一次握手就可以实现平等双向长连接。一、基本概念1生命周期WebSocket的生命周期主要分为三部分建立连接也就是所谓的“握手”数据传输服务端和客户端都可以进行双向的通信结束连接任意一方断开都可以。2基本用法我们这里主要介绍Java Spring后端的基本用法。首先我们需要一个配置文件Configuration EnableWebSocket public class RawWebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatHandler; public RawWebSocketConfig(ChatWebSocketHandler chatHandler) { this.chatHandler chatHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOriginPatterns(https://example.com, https://*.example.com); } }注意不加任何 origin 配置时默认只允许同源。一个 handler 可以注册到多个路径。编写handlerComponent public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Logger log LoggerFactory.getLogger(ChatWebSocketHandler.class); /** sessionId - session注意线程安全 */ private final MapString, WebSocketSession sessions new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.put(session.getId(), session); log.info(连接建立 id{}, remote{}, session.getId(), session.getRemoteAddress()); session.sendMessage(new TextMessage({\type\:\welcome\,\id\:\ session.getId() \})); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); log.info(收到 {} - {}, session.getId(), payload); // 简单回显 广播 for (WebSocketSession s : sessions.values()) { if (s.isOpen()) { s.sendMessage(new TextMessage(payload)); } } } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error(传输异常 id{}, session.getId(), exception); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session.getId()); log.info(连接关闭 id{}, status{}, session.getId(), status); } /** 需要处理分片消息时打开 */ Override public boolean supportsPartialMessages() { return false; } }这里需要注意并发发送必须包装因为WebSocketSession不是线程安全的。多个线程同时sendMessage会抛IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]。正确做法用ConcurrentWebSocketSessionDecorator包装。Override public void afterConnectionEstablished(WebSocketSession session) { // 发送超时 5s缓冲区上限 512KB超出则关闭连接 WebSocketSession safeSession new ConcurrentWebSocketSessionDecorator(session, 5000, 512 * 1024); sessions.put(session.getId(), safeSession); }3心跳机制STOMP 心跳是应用层的\n字节与 WebSocket 协议层 PING/PONG 不同。协商过程客户端 CONNECT: heart-beat:10000,10000 我愿每 10s 发也要求你每 10s 发 服务端 CONNECTED: heart-beat:10000,10000客户端未按时发心跳服务端在heartbeat * 2后关闭连接。Nginx 默认proxy_read_timeout 60s如果心跳间隔 30s 就会被代理掐断—— 心跳设 10s 是安全的。二、ServerEndpoint注解ServerEndpoint是实例模型。它并不像 Spring 的 Controller 那样是一个单例一个连接就会创建一个端点实例ServerEndpoint是JSR-356Java API for WebSocket​ 的标准注解包路径jakarta.websocket.server.ServerEndpointSpring Boot 3或javax.websocket.server.ServerEndpointBoot 2。它不属于 Spring实现由 Servlet 容器提供Tomcat 的WsServerContainer、Jetty、Undertow。一个类加了这个注解容器在启动时就会把它注册成一个 WebSocket 端点替代了 Spring 里的WebSocketConfigurerWebSocketHandler那套。四个生命周期注解ServerEndpoint(/chat/{roomId}) public class ChatEndpoint { OnOpen public void onOpen(Session session, PathParam(roomId) String roomId, EndpointConfig config) { } OnMessage public String onMessage(String text, Session session) { return echo: text; } OnMessage public void onBinary(ByteBuffer data, Session session, boolean last) { } OnError public void onError(Session session, Throwable error) { } OnClose public void onClose(Session session, CloseReason reason) { } }OnMessage可以写多个一个收String、一个收byte[]、一个收PongMessage容器按消息类型分派。这是 JSR-356 比 Spring 原生 API 方便的地方。接下来是 Spring Boot 里最容易出问题的地方ServerEndpoint类默认不由 Spring 管理所以Autowired的字段全是null。关键问题问题一端点不会被注册内嵌容器jar 方式运行时Tomcat 的WsSci扫描器不会自动执行必须显式声明Configuration public class WsConfig { Bean ConditionalOnProperty(name ws.jsr356.enabled, havingValue true) public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }ServerEndpointExporter会扫描 Spring 上下文中所有带ServerEndpoint的类并注册到ServerContainer。war 部署到外部 Tomcat 时不要声明这个 Bean否则容器自己扫一遍、Exporter 再注册一遍启动直接报Duplicate endpoint。问题二Autowired全为 null端点实例由容器反射new出来完全绕过 Spring所以依赖注入失效。有三种解决办法1SpringConfiguratorServerEndpoint(value /ws/{userId}, configurator SpringConfigurator.class) Component // 必须注册成 Bean否则 getBean 失败 public class ChatEndpoint { Autowired private ChatService chatService; // 可以注入了 }SpringConfiguratororg.springframework.web.socket.server.standard.SpringConfigurator重写了getEndpointInstance改成从WebApplicationContext里getBean(clazz)。副作用getBean返回的是单例于是所有连接共享同一个端点实例实例字段不再隔离。这违背了 JSR-356 的「一连接一实例」语义并发写实例字段会出错。只在端点确实无状态时使用。2自定义 Configuratorpublic class SpringBeanConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware { private static ApplicationContext ctx; Override public void setApplicationContext(ApplicationContext context) { SpringBeanConfigurator.ctx context; } Override public T T getEndpointInstance(ClassT clazz) { // 每次连接新建实例但走 Spring 的创建流程Autowired 依然生效 return ctx.getAutowireCapableBeanFactory().createBean(clazz); } }ServerEndpoint(value /ws/{userId}, configurator SpringBeanConfigurator.class) public class ChatEndpoint { // 注意不要加 Component Autowired private ChatService chatService; }createBean而非getBean兼顾了「依赖注入」和「每连接独立实例」两个需求。这是生产环境的正确做法。3静态注入Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext ctx; Override public void setApplicationContext(ApplicationContext c) { ctx c; } public static T T getBean(ClassT t) { return ctx.getBean(t); } } // 端点里 private final ChatService chatService SpringContextHolder.getBean(ChatService.class);简单直接适合小项目但把容器依赖硬编码进业务类可测性差。问题三并发发送异常Session.getBasicRemote()不是线程安全的多线程同时sendText会抛IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]JSR-356 没有ConcurrentWebSocketSessionDecorator需要手动包装public final class SafeSession { private final Session session; private final Object lock new Object(); public SafeSession(Session session) { this.session session; } public void sendText(String text) throws IOException { synchronized (lock) { if (session.isOpen()) { session.getBasicRemote().sendText(text); } } } }或者改用异步发送 队列避免阻塞业务线程session.getAsyncRemote().sendText(text, result - { if (!result.isOK()) { log.warn(发送失败, result.getException()); } });异步 API 同样需要串行化且要自己处理发送结果。