☰
WebSocket基本知识(附带@ServerEndpoint注解)
2026/11/6 7:50:57 网站建设 项目流程

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 同样需要串行化,且要自己处理发送结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询