一次说清楚:原生 @ServerEndpoint 与 Spring 整合 WebSocketHandler,配置差异、踩坑全记录


前言

WebSocket 是实现服务器主动推送、实时通信的利器,常见于聊天室、消息通知、实时监控大屏等场景。Spring Boot 集成 WebSocket 有两条路,很多人在这里摔跟头,原因只有一个:把两套配置混用了

本文会讲清楚:

  • 两种方式各自的工作原理
  • 各自的完整配置步骤
  • 最容易踩的坑(以及为什么会踩)
  • 选型建议

一、两种方式的本质区别

维度 原生 JSR-356(@ServerEndpoint Spring 整合(WebSocketHandler
规范来源 Java EE 标准,javax.websocket Spring 框架封装,org.springframework.web.socket
底层容器 由 Servlet 容器(Tomcat/Jetty)直接管理 由 Spring DispatcherServlet 统一管理
实例生命周期 每个连接 new 一个新实例 单例 Handler 处理所有连接
与 Spring 集成 需要额外桥接(ServerEndpointExporter 原生支持,Bean 注入无障碍
适用场景 轻量、快速上手 需要 Spring 生态深度整合

二、方式一:原生 JSR-356(@ServerEndpoint

2.1 原理

JSR-356 是 Java EE 标准的 WebSocket API。Spring Boot 内嵌的 Tomcat 本身就支持这套规范,但 Spring 容器默认不会扫描 @ServerEndpoint 注解的类。

ServerEndpointExporter 的作用就是充当"桥梁"——它在 Spring 启动时,把所有被 @ServerEndpoint 标注的类手动注册到底层 Servlet 容器的 WebSocket 运行时中。

Spring容器启动
    └── ServerEndpointExporter.afterPropertiesSet()
            └── 扫描 @ServerEndpoint 类
                    └── 注册到 ServerContainer(Tomcat WebSocket 运行时)

2.2 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2.3 第一步:WebSocket 配置类

@Configuration
public class WebSocketConfig {

    /**
     * 向 Spring 容器注册 ServerEndpointExporter
     * 它会在应用启动后,将所有 @ServerEndpoint 注解的类注册到底层 Servlet 容器
     * 注意:使用外部容器(如独立部署的 Tomcat)时,不需要注册此 Bean,
     *       外部容器会自行完成注册
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

 禁忌:这个配置类不能@EnableWebSocket,也不能实现 WebSocketConfigurer。否则两套机制冲突,启动时会抛出类转换异常(ClassCastException)。

2.4 第二步:WebSocket 服务端点

@Component  // ① 必须交给 Spring 容器,才能在内部注入 Service 等 Bean
@ServerEndpoint("/ws/chat/{roomId}")  // ② 定义 WebSocket 连接路径
public class ChatWebSocketServer {

    // ③ 核心踩坑点:@ServerEndpoint 每个连接都会 new 一个新实例
    //    因此不能用普通的 @Autowired 字段注入,必须用 static 字段 + setter 注入
    private static MessageService messageService;

    @Autowired
    public void setMessageService(MessageService messageService) {
        ChatWebSocketServer.messageService = messageService;
    }

    // ④ 线程安全:用 ConcurrentHashMap 管理所有在线 Session
    private static final ConcurrentHashMap<String, Session> SESSION_MAP
            = new ConcurrentHashMap<>();

    private Session session;
    private String userId;

    /**
     * 连接建立成功时触发
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("roomId") String roomId) {
        this.session = session;
        this.userId = session.getId();
        SESSION_MAP.put(userId, session);
        System.out.printf("用户 [%s] 加入房间 [%s],当前在线人数:%d%n",
                userId, roomId, SESSION_MAP.size());
    }

    /**
     * 收到客户端消息时触发
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.printf("收到用户 [%s] 的消息:%s%n", userId, message);
        // 调用业务 Service 处理消息(static 注入,可正常使用)
        messageService.saveMessage(userId, message);
        // 广播给所有在线用户
        broadcastMessage(userId + ": " + message);
    }

    /**
     * 连接关闭时触发
     */
    @OnClose
    public void onClose() {
        SESSION_MAP.remove(userId);
        System.out.printf("用户 [%s] 断开连接,当前在线人数:%d%n",
                userId, SESSION_MAP.size());
    }

    /**
     * 发生错误时触发
     */
    @OnError
    public void onError(Session session, Throwable error) {
        System.err.printf("用户 [%s] 发生错误:%s%n", userId, error.getMessage());
        error.printStackTrace();
    }

    /**
     * 广播消息给所有在线用户
     */
    private void broadcastMessage(String message) {
        SESSION_MAP.values().forEach(s -> {
            try {
                if (s.isOpen()) {
                    s.getBasicRemote().sendText(message);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }

    /**
     * 向指定用户发送消息(可供外部调用)
     */
    public static void sendMessageToUser(String userId, String message) {
        Session session = SESSION_MAP.get(userId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

三、方式二:Spring 整合 WebSocket(WebSocketHandler

3.1 原理

这套方案是 Spring 自己封装的 WebSocket 抽象,通过 WebSocketConfigurer 将处理器注册进 Spring 的 WebSocket 路由体系,请求由 DispatcherServlet 统一入口分发。

HTTP 请求升级为 WebSocket
    └── DispatcherServlet
            └── WebSocketHandlerMapping(路径路由)
                    └── 你的 WebSocketHandler(处理具体逻辑)

因为全程在 Spring 生态内,Bean 注入、拦截器、权限校验都可以无缝对接。

3.2 第一步:WebSocket 配置类

@Configuration
@EnableWebSocket  // ① 开启 Spring WebSocket 支持
public class WebSocketConfig implements WebSocketConfigurer {  // ② 实现此接口

    @Autowired
    private ChatWebSocketHandler chatWebSocketHandler;

    @Autowired
    private WebSocketAuthInterceptor authInterceptor;

    /**
     * ③ 重写此方法,将处理器注册到指定路径
     */
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry
            .addHandler(chatWebSocketHandler, "/ws/chat")  // 注册处理器和路径
            .addInterceptors(authInterceptor)               // 可添加握手拦截器
            .setAllowedOrigins("*");                        // 跨域配置
    }
}

3.3 第二步:握手拦截器(可选但推荐)

握手拦截器在 WebSocket 连接建立之前执行,常用于身份验证、权限校验、将用户信息存入 Session attributes。

@Component
public class WebSocketAuthInterceptor implements HandshakeInterceptor {

    /**
     * WebSocket 握手前执行:返回 false 则拒绝连接
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
                                   ServerHttpResponse response,
                                   WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) throws Exception {
        // 从请求参数或 Header 中获取 Token,验证用户身份
        String token = ((ServletServerHttpRequest) request)
                .getServletRequest().getParameter("token");

        if (token == null || !isValidToken(token)) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;  // 拒绝握手
        }

        // 将用户信息存入 attributes,后续 Handler 中可以取到
        attributes.put("userId", parseUserId(token));
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
        // 握手后执行,一般留空
    }

    private boolean isValidToken(String token) {
        // 实际项目中调用 JWT 解析或 Redis 校验
        return token.startsWith("valid_");
    }

    private String parseUserId(String token) {
        return token.replace("valid_", "");
    }
}

3.4 第三步:WebSocket 处理器

@Component  // ① 交给 Spring 容器管理,正常 @Autowired 注入无任何问题
public class ChatWebSocketHandler extends TextWebSocketHandler {  // ② 继承此类处理文本消息

    @Autowired
    private MessageService messageService;  // ③ 单例 Handler,直接 @Autowired 完全没问题

    // 维护在线 Session 的线程安全 Map
    private static final ConcurrentHashMap<String, WebSocketSession> SESSION_MAP
            = new ConcurrentHashMap<>();

    /**
     * 连接建立成功时触发
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String userId = (String) session.getAttributes().get("userId");
        SESSION_MAP.put(userId, session);
        System.out.printf("用户 [%s] 已连接,当前在线:%d%n", userId, SESSION_MAP.size());
    }

    /**
     * 收到文本消息时触发
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message)
            throws Exception {
        String userId = (String) session.getAttributes().get("userId");
        String payload = message.getPayload();

        System.out.printf("收到 [%s] 的消息:%s%n", userId, payload);

        // 调用业务 Service
        messageService.saveMessage(userId, payload);

        // 广播消息
        broadcastMessage(userId + ": " + payload);
    }

    /**
     * 连接关闭时触发
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
            throws Exception {
        String userId = (String) session.getAttributes().get("userId");
        SESSION_MAP.remove(userId);
        System.out.printf("用户 [%s] 已断开,当前在线:%d%n", userId, SESSION_MAP.size());
    }

    /**
     * 传输异常时触发
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception)
            throws Exception {
        System.err.println("传输错误:" + exception.getMessage());
        session.close(CloseStatus.SERVER_ERROR);
    }

    private void broadcastMessage(String message) {
        SESSION_MAP.values().forEach(s -> {
            try {
                if (s.isOpen()) {
                    s.sendMessage(new TextMessage(message));
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }
}

四、最容易踩的坑,逐一拆解

坑一:两套配置混用 → ClassCastException

错误场景:配置类同时写了 ServerEndpointExporter Bean 又实现了 WebSocketConfigurer

报错特征

java.lang.ClassCastException: class X cannot be cast to class Y

根因:原生方式绕过 Spring MVC 直接对接 Servlet 容器;Spring 整合方式走 DispatcherServlet 体系。两套路由机制同时工作,处理同一个 WebSocket 请求时类型不匹配,直接炸。

解法:二选一,坚决不混用。


坑二:原生方式 @Autowired 注入为 null

错误场景

@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
    @Autowired
    private UserService userService;  // 运行时是 null!

    @OnMessage
    public void onMessage(String msg) {
        userService.doSomething(msg);  // NullPointerException
    }
}

根因@ServerEndpoint 类由 Servlet 容器管理实例化,每来一个连接就 new 一个新对象。Spring 只管理它在自己容器里的那一个原型实例,Servlet 容器 new 出来的新实例 Spring 不认识,自然也不会注入。

解法:static 字段 + setter 注入(Spring 注入的那一个实例执行 setter,写入 static 字段,所有实例共享):

@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
    private static UserService userService;

    @Autowired  // Spring 对它管理的那个实例执行此方法,写入 static 字段
    public void setUserService(UserService userService) {
        ChatServer.userService = userService;
    }
}

坑三:跨域配置不生效

  • 原生方式跨域:在 @ServerEndpoint 注解本身无跨域配置项,需要在 Nginx 层或 Filter 层处理
  • Spring 整合方式:直接在 addHandler(...).setAllowedOrigins("*") 配置,简洁明了

坑四:外部 Tomcat 部署时不需要 ServerEndpointExporter

用嵌入式 Tomcat(spring-boot:run 或打 jar 包)时需要注册 ServerEndpointExporter;打 war 包部署到外部 Tomcat 时,外部容器会自己扫描 @ServerEndpoint再注册 ServerEndpointExporter 反而会报错

// 嵌入式容器:需要
// 外部容器:删掉这个 Bean
@Bean
public ServerEndpointExporter serverEndpointExporter() {
    return new ServerEndpointExporter();
}

五、包路径对照表

两种方式的核心类来自完全不同的包,混用时 IDE 的自动补全会"帮你犯错",务必留意:

功能 原生 JSR-356 Spring 整合
端点/处理器注解 javax.websocket.@ServerEndpoint 实现 org.springframework.web.socket.WebSocketHandler
连接建立 @OnOpen afterConnectionEstablished()
接收消息 @OnMessage handleTextMessage()
连接关闭 @OnClose afterConnectionClosed()
错误处理 @OnError handleTransportError()
Session 类型 javax.websocket.Session org.springframework.web.socket.WebSocketSession
消息类型 String / ByteBuffer TextMessage / BinaryMessage

六、前端连接示例

无论哪种后端方式,前端连接写法完全一样:

// 原生方式路径示例
const ws1 = new WebSocket('ws://localhost:8080/ws/chat/room123');

// Spring 整合方式路径示例(附带 token 参数用于握手拦截器验证)
const ws2 = new WebSocket('ws://localhost:8080/ws/chat?token=valid_user001');

ws2.onopen = () => {
    console.log('连接已建立');
    ws2.send(JSON.stringify({ type: 'chat', content: 'Hello!' }));
};

ws2.onmessage = (event) => {
    console.log('收到消息:', event.data);
};

ws2.onclose = (event) => {
    console.log('连接已关闭,code:', event.code);
};

ws2.onerror = (error) => {
    console.error('连接错误:', error);
};

七、选型建议

需要权限校验、Session 管理、与 Spring Security 集成?
    └── 选 Spring 整合方式(WebSocketHandler)

快速实现、团队熟悉 Java EE 规范、无复杂 Spring 生态依赖?
    └── 选原生方式(@ServerEndpoint)

需要打 war 包部署到外部 Tomcat?
    └── 两种都行,但原生方式记得去掉 ServerEndpointExporter Bean

追求更强的消息抽象(发布订阅、广播频道)?
    └── 考虑 Spring WebSocket + STOMP 协议(本文未涉及,可作进阶方向)

总结

原生 @ServerEndpoint Spring WebSocketHandler
配置类 @Configuration + ServerEndpointExporter Bean @Configuration + @EnableWebSocket + 实现 WebSocketConfigurer
处理器 @ServerEndpoint + @Component 实现 WebSocketHandler + @Component
Bean 注入 必须用 static 字段 + setter 注入 直接 @Autowired,无限制
实例模型 每连接一个新实例 全局单例
互相混用 严禁,直接报错 严禁,直接报错

记住一句话:选定一套,配全套,绝不混搭。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐