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 可以注册到多个路径。
编写handler
@Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Logger log = LoggerFactory.getLogger(ChatWebSocketHandler.class); /** sessionId -> session,注意线程安全 */ private final Map<String, 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-356(Java API for WebSocket) 的标准注解,包路径jakarta.websocket.server.ServerEndpoint(Spring Boot 3)或javax.websocket.server.ServerEndpoint(Boot 2)。它不属于 Spring,实现由 Servlet 容器提供(Tomcat 的WsServerContainer、Jetty、Undertow)。
一个类加了这个注解,容器在启动时就会把它注册成一个 WebSocket 端点,替代了 Spring 里的WebSocketConfigurer+WebSocketHandler那套。
四个生命周期注解
@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,所以依赖注入失效。有三种解决办法:
(1)SpringConfigurator
@ServerEndpoint(value = "/ws/{userId}", configurator = SpringConfigurator.class) @Component // 必须注册成 Bean,否则 getBean 失败 public class ChatEndpoint { @Autowired private ChatService chatService; // 可以注入了 }SpringConfigurator(org.springframework.web.socket.server.standard.SpringConfigurator)重写了getEndpointInstance,改成从WebApplicationContext里getBean(clazz)。
副作用:getBean返回的是单例,于是所有连接共享同一个端点实例,实例字段不再隔离。这违背了 JSR-356 的「一连接一实例」语义,并发写实例字段会出错。只在端点确实无状态时使用。
(2)自定义 Configurator
public 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(Class<T> 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(Class<T> 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 同样需要串行化,且要自己处理发送结果。