Java WebSocket服务端开发实战:从@ServerEndpoint到消息推送
1. 从一个实战场景说起实时推送到底难在哪做Java后端的朋友多半会遇到这样一种需求页面上的数据要实时更新比如用户下单后后台立刻弹出提示、大屏看板的数据每隔几秒自动变动、或者做一个群聊页面消息发出去对方马上就能收到。早期做法是前端轮询。前端隔两三秒向后台发一个普通HTTP请求问一句“有没有新数据”。接口还没返回几条数据连接先被问了几百次服务器压力大不说数据更新还有一两秒的延迟体验很鸡肋。想做得更好就得让服务器主动把消息推给浏览器。这个需求落到技术方案上基本就是WebSocket。WebSocket服务端并不是一个很高深的东西它走的是一条独立的TCP长连接通道一次握手成功后客户端和服务端都能随时往通道里写数据双向、低成本、实时。这篇内容就是给Java技术栈的朋友讲清楚WebSocket服务端的基本使用方法。我会抛开官方文档里那些繁琐的术语从拿到一个需求开始到把服务端代码跑起来再到上线后常遇到的问题一步步过一遍。适合刚接触WebSocket的新手也适合需要快速在Java项目里落地WebSocket功能、但不想走太多弯路的同学。HTTP协议本身是先请求后应答服务器不能主动找客户端。WebSocket出现之前所有“主动推送”都是靠前端反复发请求来伪装的。现在Java服务端实现WebSocket的方式已经很成熟最常用的是JSR 356标准注解也就是ServerEndpoint。理解了这个标准再去看Spring封装的那套也会轻松很多。2. 动手前的准备选型、依赖与三种实现姿势2.1 Java WebSocket标准的来龙去脉JSR 356与容器支持Java里做WebSocket服务端有一个规范叫JSR 356它定义了WebSocket协议在Java体系中的API标准发布在Java EE规范里。也就是说只要你的运行环境遵循Java EE或者Jakarta EE标准就不需要额外引入重量级框架直接用标准API就能开发WebSocket端点。规范里提了两类开发模式一类是注解式开发通过ServerEndpoint把一个普通Java类标记为WebSocket服务端点用OnOpen、OnMessage这些注解声明生命周期方法另一类是继承式开发写一个类继承javax.websocket.Endpoint再通过ServerContainer注册。实际项目里90%以上都用注解式简洁直观。有人会有疑问Tomcat、Jetty、Undertow这些容器兼容JSR 356吗兼容。它们都实现了JSR 356所以你能在Spring Boot的内置Tomcat里直接用ServerEndpoint也能把WAR包扔进外部Tomcat里跑。需要注意的只是不同容器对规范细节的微调这一点后文排障部分会提到。2.2 Maven依赖怎么加、用哪个版本如果你是普通的Java Web项目用的是外部Tomcat 9或10那么javax.websocket-api或者jakarta.websocket-api通常已经由容器提供Maven依赖里只需要配一个provided范围的坐标就行。举个例子Tomcat 9对应的是javax命名空间dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency这里用provided是因为容器里已经带了API实现如果打个WAR包进去还带一份API反而容易引起类加载冲突。Spring Boot项目如果在内嵌Tomcat里跑同样不需要把API打进去写代码时只需要有这个依赖即可编译。如果你用的是Jakarta命名空间也就是Tomcat 10.1或者Spring Boot 3.x把包名换成jakarta.websocket就行dependency groupIdjakarta.websocket/groupId artifactIdjakarta.websocket-api/artifactId version2.1.0/version scopeprovided/scope /dependency注意一个坑Spring Boot 2.x加WebSocket功能很多人习惯引入spring-boot-starter-websocket这个包里其实带的是Spring MVC对WebSocket封装所需的依赖还会拉来Tomcat等容器。如果你只是想在Spring Boot里用标准ServerEndpoint那么引入这个starter不会自动注册你的端点后面要单独配置一个ServerEndpointExporter不然ServerEndpoint的类会出现“定义了却找不到路径”的情况。2.3 三种实现方式怎么选注解式、编程式、Spring封装先说结论新项目、普通需求优先用注解式。注解式代码量最少一个类就是一个端点生命周期方法一目了然团队协作时上手成本低。编程式实现也就是继承Endpoint并注册到ServerContainer这种方式更灵活适合在运行时动态决定端点路径、或者需要拿到更多容器控制能力的场景。但写起来啰嗦需要自己管理Session和消息处理线程日常业务开发用不到。Spring封装的那套比如TextWebSocketHandler和WebSocketHandlerRegistry面向的是WebSocket协议之上的Spring抽象适合需要和SpringMVC的拦截器、权限体系深度集成的项目。它的好处是可以和Spring Security无缝整合坏处是学习成本高了一点你要理解Handler、HandshakeInterceptor、WebSocketSession这一整套体系。个人经验建议如果是纯后端服务没有复杂的登录鉴权整合需求标准JSR 356注解式完全够用如果在Spring生态里做人脸识别推送、在线课堂这类复杂应用且要复用Spring Security做登录态校验那就用Spring的WebSocketHandler。不要两套混用否则排查问题时每一次握手都会面临“又是谁拦截了”的讨论。3. 核心实操写一个能用的WebSocket服务端3.1 注解式开发ServerEndpoint与第一个可运行的端点先来一个最基础的服务端例子作用是接收客户端发来的文本消息并且原样返回。这相当于WebSocket版的“你好世界”。import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; ServerEndpoint(/ws/echo) public class EchoEndpoint { OnOpen public void onOpen(Session session) { System.out.println(连接打开 session.getId()); } OnMessage public void onMessage(String message, Session session) throws IOException { System.out.println(收到消息 message); session.getBasicRemote().sendText(服务端已收到 message); } OnClose public void onClose(Session session) { System.out.println(连接关闭 session.getId()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }这段代码放到项目里启动Web容器后客户端就可以通过ws://ip:port/项目上下文/ws/echo连接了。/ws/echo前的项目上下文取决于你部署的WAR包名或者Spring Boot的context-path如果上下文为空那就是ws://ip:port/ws/echo。很多新手不知道ServerEndpoint里的路径能不能写变量。是可以的比如ServerEndpoint(/ws/chat/{roomId})这个roomId就能作为参数传给后续方法后面会单独说。3.2 生命周期回调OnOpen、OnMessage、OnClose、OnError这四个注解对应WebSocket连接从生到死的四个阶段理解它们比背代码重要。OnOpen是握手成功后的回调在这里可以初始化连接相关状态。比如把当前Session存到一个全局Map里或者给Session放一个用户ID属性。有一点容易被忽略OnOpen方法可以接收Session参数也可以接收EndpointConfig和路径参数。它虽然叫打开但这时TCP连接已经建立只是WebSocket Protocol层刚刚完成握手。OnMessage是接收消息的核心方法。它能接收String文本、byte[]二进制、PongMessage也能接收Reader或InputStream甚至可以通过自定义实现Decoder来接收一个Java对象。容器收到一个完整消息时会调用这个方法。这里要记住默认情况下一个连接上同一时刻只能处理一条消息如果你在OnMessage里做了阻塞操作会拖住该连接后续消息的接收。后面聊并发时再展开。OnClose是关闭连接时的回调。无论是客户端主动断开还是服务端调用session.close()都会触发它。在这个方法里一定要清理全局Session列表不然在线人数统计会失真之后尝试给已断开的Session发消息还会抛异常。OnError处理异常。实际开发中这里至少要把异常日志打出来别留一个空方法。断网、协议错误、消息解析失败都会走到这里空实现会让线上问题排查变得极其困难。3.3 会话管理Session并不是线程安全的你要如何维护在线列表javax.websocket.Session对象代表一条WebSocket连接。客户端连接进来后服务端手里的根对象就是这个Session。发送消息、关闭连接、获取连接参数都靠它。但有三个残酷现实第一Session不是线程安全的多线程同时调用它发消息可能出现消息交错或者连接状态异常第二Session的对象生命周期受容器管理你可能在某次重连后发现拿到的还是旧对象第三Session如果已经关闭调用sendText会抛IllegalStateException。所以在线列表怎么维护最常用的做法是用一个线程安全的Map键为Session的ID值为Session对象或者键为自定义用户ID值为Session。我的建议是同时维护两层映射一层MapString, Session按会话ID存便于拿到原始会话一层MapString, String保存业务用户ID与会话ID的对应关系便于按用户推送。private static final MapString, Session SESSION_MAP new ConcurrentHashMap(); OnOpen public void onOpen(Session session) { SESSION_MAP.put(session.getId(), session); } OnClose public void onClose(Session session) { SESSION_MAP.remove(session.getId()); }注意在线列表字段推荐用static final ConcurrentHashMap。原因有两点一是ServerEndpoint端点类默认不是单例每个连接都会创建一个实例字段不带static就会出现每个连接各自一份Map互相看不到二是普通的HashMap在多线程操作时会出并发问题必须用线程安全版本。关于发送消息前判活我习惯写一个小的工具方法private void safeSend(Session session, String text) { if (session ! null session.isOpen()) { session.getBasicRemote().sendText(text); } }session.isOpen()只能作为发消息前的即时检查它并没有办法保证你调用sendText的瞬间连接是开着的所以异常捕获依然要写。3.4 消息推送点对点、广播与异步发送别把消息发丢了WebSocket服务端不只是被动接收消息更多时候是要主动往客户端推数据。比如用户A下单成功后服务端要把订单状态推给用户B。这就是服务端主动推送。推送分三种常见形式点对点、广播、分组推送。点对点就是拿目标用户的Session调用getBasicRemote().sendText()广播就是遍历所有在线Session逐个发送分组推送就是在在线列表基础上维护一个组ID和Session列表的映射。发送消息时有两个Remote可选getBasicRemote()是同步阻塞发送getAsyncRemote()是异步非阻塞发送。同步发送的好处是简单但如果某客户端网络慢可能会导致发送线程阻塞影响其他用户的消息异步发送可以避免阻塞但需要关注异步回调里的失败处理。关于消息丢失问题WebSocket协议本身不保证应用层消息一定被完整送达尤其是在中间有代理服务器、网络抖动的情况下。你调用sendText只是把消息交给操作系统Socket缓冲区不代表对方已经收到。业务上很重要的消息我通常会在消息体里加一个自增序号客户端收到后回一个ACK服务端超时未收到ACK再触发重推。这已经属于可靠性方案的范畴了功能可以不做得太重但消息体的设计最好从一开始就预留一个messageId字段。4. 进阶让服务端更健壮、更实用4.1 连接参数与路径参数PathParam、查询参数、配置参数真实场景里客户端连接时通常要带一些身份信息。常见的做法有三种第一种路径参数把参数放进URL路径在注解方法上用PathParam接收ServerEndpoint(/ws/chat/{roomId}/{userId}) OnOpen public void onOpen(Session session, PathParam(roomId) String roomId, PathParam(userId) String userId) { // 加入房间映射 }第二种查询参数也就是ws://host/ws?tokenxxx这种形式。查询参数可以在OnOpen里通过session.getRequestParameterMap()拿到这是一个MapString, ListString。为什么值是List因为HTTP查询参数允许重复键WebSocket握手协议沿用这个规则所以别用get(token)直接返回一个String要先取get(token).get(0)。第三种请求头参数通过session.getRequestParameterMap()并不能取请求头。可以用握手阶段的配置在编程式开发时通过HandshakeRequest获取在注解式开发时如果要读取自定义Header需要自己实现Configurator重写modifyHandshake方法然后把请求头数据塞进EndpointConfig.getUserProperties()再在OnOpen方法里通过EndpointConfig取出。这个操作相对少见但做登录鉴权时很实用。还有一个容易踩坑的点Session的getUserProperties()可以用来存放每个连接的自有业务数据。这个方法返回的是MapString, Object且该Map是连接级别的比你在类的静态字段里放数据要安全得多。我常用的模式是登录成功后把userId塞进session.getUserProperties().put(userId, userId)后续发送消息时直接从Session里取避免“用连接ID再反查用户ID”的二次查询。4.2 JSON消息处理自定义Decoder/EncoderOnMessage直接接收String没问题但业务消息通常不是一段裸文本而是JSON结构。如果你在每个消息处理方法里手写JSONObject.parseObject代码会越来越臃肿也难维护。JSR 356提供了Decoder解决了这个问题。解码器负责把客户端发来的文本消息转换成Java对象编码器负责把Java对象转成文本消息发送。拿处理JSON举个例子。先定义消息类public class ChatMessage { private String type; private String content; private String sender; // getter / setter 省略 }再写一个解码器import javax.websocket.DecodeException; import javax.websocket.Decoder; import javax.websocket.EndpointConfig; public class ChatMessageDecoder implements Decoder.TextChatMessage { Override public ChatMessage decode(String s) throws DecodeException { // 使用自己项目里的JSON工具这里示意 return JsonUtil.fromJson(s, ChatMessage.class); } Override public boolean willDecode(String s) { return s ! null !s.isEmpty(); } Override public void init(EndpointConfig config) {} Override public void destroy() {} }在ServerEndpoint注解里声明解码器ServerEndpoint(value /ws/chat, decoders {ChatMessageDecoder.class})之后OnMessage方法直接接收ChatMessage对象OnMessage public void onMessage(ChatMessage msg, Session session) { // 直接处理对象解析逻辑已经封装 }编码器类似实现Encoder.TextT重写encode(T object)方法。配置到encoders属性后服务端调用session.getBasicRemote().sendObject(object)即可自动转JSON发送。我个人的建议是小项目里消息类型不超过三五种直接String手动解析就行一旦消息类型多了一定要上Decoder/Encoder。它不复杂但能让代码结构清爽很多也方便后续加版本字段、兼容旧客户端。4.3 心跳、超时与断线重连服务端怎么配合客户端WebSocket连接虽然比HTTP长命但中间有路由器、防火墙、Nginx等代理设备它们通常对空闲的连接有一个超时回收机制。如果连接长时间没有数据流动可能被中间设备悄悄断开而两端还浑然不知。这个问题要从两端同时入手。客户端一般设置定时器每30秒或50秒发送一个Ping帧或一个自定义心跳消息。WebSocket协议本身有Ping和Pong控制帧浏览器端JavaScript的WebSocket API不开放主动发送Ping帧的能力所以前端项目常常发自定义消息如{type:ping}。服务端收到后要么原样回一个{type:pong}要么直接忽略但要把该Session的最后活跃时间更新一下。服务端这边也可以主动控制给Session设置最大空闲时间。如果你用的是Tomcat作为WebSocket容器连接空闲时间默认受容器配置影响。JSR 356里没有直接暴露一个API方法去设置超时秒数但你可以通过ServerContainer的setDefaultMaxSessionIdleTimeout来设置全局默认值或者通过OnOpen里的session.setMaxIdleTimeout(long)单独设置该连接的闲置超时时间。OnOpen public void onOpen(Session session) { session.setMaxIdleTimeout(60000); // 60秒没有收发消息就关闭 }这个设置是把双刃剑。设得太短用户刷个牙回来连接就被断了体验差设得太长中间设备又会比你先动手。经验值一般是60到120秒之间。如果你设置了Nginx层的proxy_read_timeout那就把服务端超时设得比Nginx小一点保证是服务端主动关连接而不是Nginx静默断开。关连接时客户端收到的是1001或1006状态码下一步触发重连逻辑才清晰。服务端做心跳时还需要清理僵尸连接。客户端异常断电时断开检测往往不及时在线列表里会残留很多不可用的Session。我的做法是起一个后台定时任务每隔一分钟遍历一次在线Session通过session.isOpen()过滤掉明显关闭的再结合最后活跃时间超过阈值但一直没有心跳的就主动关闭。5. 常见问题与排错实录这些年踩过的坑5.1 握手总是404/405/500问题出在哪WebSocket连接失败最常见的表现是前端报WebSocket connection to ws://xxx failed然后浏览器控制台显示HTTP状态码404。404的第一反应是路径不对。很多人写了ServerEndpoint(/ws/chat)但前端连的是/ws/chat/多了一个斜杠路径就不匹配了。第二反应是上下文路径没写对。你部署的是ROOT.war访问路径是ws://ip:port/ws/chat如果WAR包名是wsdemo.war访问路径就变成ws://ip:port/wsdemo/ws/chat。我见过连续三次排错都是这种低级原因所以改动路径前先确认上下文。405通常出现在容器不支持WebSocket协议的情况。比如你用了一个非常老的Servlet容器或者没有开启对应的WebSocket支持。Tomcat 7.0.47以上才支持JSR 356Tomcat 8直接支持Tomcat 9也支持如果版本过旧换容器版本基本能解决。Tomcat 10需要注意命名空间是jakarta。500错误多半是服务端点类初始化异常。ServerEndpoint标注的类必须是public并且有public无参构造器。如果写成内部类或者构造器不是public容器实例化时就会报错。另外端点的生命周期方法是public的返回值要求是void参数列表也不能随便写不符合规范会在部署阶段报错。如果请求能走到服务端但就是握不上手看一下日志里的OnError是不是抛出HandshakeException。有一种常见情况是跨域问题但WebSocket的跨域限制和HTTP不完全一样。浏览器会带上Origin头服务端如果想限制来源需要写ServerEndpoint里配置Configurator检查Origin如果不匹配就返回403前端就会看到握手失败。默认情况下JSR 356是不做跨域限制的所有来源都能连。生产环境如果你没写任何校验那就要小心别人直接拿着你的地址来连接。5.2 多实例和负载均衡下的Session共享问题WebSocket是长连接和HTTP短连接的一个关键区别是连接一旦建立就会固定在某一台服务器实例上。客户端如果通过Nginx负载均衡轮询到A机器建立了WebSocket后续消息一直由A机器处理但如果连接断开重连可能被路由到B机器。此时如果A机器的Session列表里还保存着这个用户B机器上也存了一份就出现了“用户状态分片”的问题。解决思路分几层简单做法是Nginx上开启ip_hash或者使用hash $http_upgrade来保证同一个客户端的升级请求路由到同一台后端实例更高层做法是把WebSocket的在线会话信息放到Redis里服务端发送消息时先通过Redis找到用户所在节点再由那个节点推送。后者会引入一套跨节点下发机制工程复杂度高不少。如果你的项目是多实例部署但当前只想先把功能跑起来我的建议是先把在线列表和消息广播设计成可插拔接口不要硬编码到本地Map里。等真的需要扩展节点时替换Redis实现会平滑很多。这个改动看着小后期价值很大因为许多Java项目是从单机开始最后被多实例问题绊倒。5.3 并发写消息结构体被冲掉怎么办之前提到过Session不是线程安全的。什么叫“结构体被冲掉”举个例子两个线程同时调用同一个Session的getBasicRemote().sendText()一个发一段长文本另一个也发一段长文本在底层Socket缓冲区里可能交错客户端最终收到的内容会乱掉。要保证同一个Session上的消息有序发送有几个办法。最简单的方法是给每个Session配一个独立的发送队列和单线程发送器。每个Session对应一个队列发送消息时把任务丢进队列由单个线程串行消费确保顺序。另一个常见办法是用ConcurrentHashMap配合锁机制发送前对Session加锁整个发送过程串行化。private final Lock lock new ReentrantLock(); private void sendTextWithLock(Session session, String text) throws IOException { lock.lock(); try { if (session.isOpen()) { session.getBasicRemote().sendText(text); } } finally { lock.unlock(); } }我进一步建议在高频推送场景下尽量用getAsyncRemote().sendObject或者sendText的异步版本再在回调里处理失败。同步阻塞发送会让业务线程长期卡住整个推送服务吞吐量下降。如果用异步发送同一个Session上的异步消息本身也未必保证有序所以还是要加队列或锁。5.4 开发调试工具推荐与验证方法写完服务端最重要的是能验证。我常用的调试方式有几种浏览器控制台直接写JS是最快的打开开发者工具输入const ws new WebSocket(ws://localhost:8080/ws/echo); ws.onopen function() { ws.send(hello); }; ws.onmessage function(event) { console.log(收到 event.data); };这样基本能验证连接、收发消息是否正常。需要压力测试的时候可以用命令行工具websocat或者编程方式模拟大量客户端。但正式线上环境建议先小流量别一上来就压测因为WebSocket连接数比HTTP连接更昂贵。一台2核4G的机器开几千个长连接通常问题不大如果每个连接还要维护内存级的会话状态那内存消耗就要重新估量。排查网络层问题时抓包最有效。Chrome的开发者工具也能看到WS帧的收发但要想看TCP层的断开原因还是tcpdump抓包加Wireshark分析更直观。我遇到过前端不断重连服务端也不断打印OnOpen日志最后抓包才发现是客户端把连接关掉了只是服务端没有立刻收到关闭帧白白维持了很多半开连接。这种场景下服务端心跳清理就非常必要。最后再提一个高频坑ServerEndpoint的类里如果注入了Spring的Service直接用Autowired大概率会失败。因为端点实例是WebSocket容器创建的不是Spring容器创建的Spring的依赖注入不会自动帮你去处理这个对象。常见的解决办法是把端点类标记为Component然后通过一个静态工具类从Spring容器里获取Service或者在OnOpen里通过WebApplicationContextUtils手动获取Spring的Context。虽然听起来绕但这是很多用Spring Boot开发WebSocket的人都会撞上的墙。我在实际项目中一般流程是先把纯文本echo跑通再加上JSON格式和业务映射最后才考虑心跳、鉴权和分布式。WebSocket服务端的“基本使用方法”其实就这么多剩余的复杂度基本都来自业务本身。如果你照着上面的代码动手走一遍很快就能把服务端立起来。之后再遇到那些“服务端主动推送”“聊天室”“实时在线状态”需求你也不至于一上来就发愁。