Spring Boot整合Jetty内置WebSocket实现前后端实时通信 前阵子在做一个模拟项目X需要把后端产生的实时数据推到网页上。第一反应是轮询但数据更新的频率不高不低轮询要么浪费流量要么延迟严重想来想去还是用WebSocket干净。项目技术栈本身就是Spring Boot容器换成了Jetty所以标题里写的“SpringBoot使用Jetty内置的WebSocket实现简单前后端通信”就是我的实际解决方案。这篇算是把完整的做法、代码、踩坑过程都梳理了一遍适合想用最快速度把WebSocket跑起来又对Jetty这种嵌入式容器不太熟的人。我尽量把依赖和配置写得细一点因为这种项目难点通常不在业务上而在“为什么我这个Tomcat下能跑的代码换到Jetty就跑不通了”“为什么握手总失败”这类环境问题上。读完之后你至少能拿到一个能直接改改就用的最小实现并且知道后续遇到连接断开、跨域、心跳这类问题该往哪个方向查。1. 项目整体设计与方案选型1.1 需求核心在哪里项目需求说白了就一句话后端某几个指标有波动前端页面要马上看到不能靠用户手动刷新。这个场景用HTTP非常别扭因为HTTP是请求-响应模型服务端没法主动把数据塞给浏览器。要让前端实时收到数据常见办法只有几条路前端定时轮询实现最简单但实时性差数据变化越频繁轮询间隔越短服务器压力越大。长轮询本质上还是HTTP只是把响应时间拉长达到“假装实时”的效果但会占用一批连接线程。SSE服务端单向推送浏览器支持很好但只能服务器往客户端推双向交互还是要另开接口。WebSocket真正意义上的全双工长连接一端握手成功后双方可以随时互发消息。对于我这个场景前端不仅要收数据偶尔还要向后端发控制指令所以WebSocket是唯一能把两条路并成一条路的方案。而且项目规模不大引入WebSocket不需要复杂的消息队列直接用容器自带的实现就够了。1.2 为什么是 Jetty不是默认的 TomcatSpring Boot 默认内嵌容器是Tomcat但它本身支持灵活切换只要在依赖里排除掉 spring-boot-starter-tomcat引入 spring-boot-starter-jetty 就能换成Jetty。做这个选择不是强制要求主要考虑点有三个第一Jetty定位轻量启动速度快内存占用小。对这种并发量不高的中小型实时通信DemoJetty跑起来比Tomcat更省资源尤其在开发机上频繁重启时体感非常明显。第二Jetty自身就是成熟Web服务容器它的WebSocket实现做得比较早完全支持JSR-356规范也支持底层原生API。如果是“想用容器自己那一套WebSocket”Jetty是个很顺手的选择不需要额外装独立WebSocket服务器。第三同一套代码以后想改成全异步Servlet模型Jetty也支持得很顺。虽然这个项目用不到但保留这个扩展空间不亏。1.3 WebSocket 在 Jetty 里是怎么干活儿的要理解后面的配置先得大概知道一次WebSocket连接在嵌入式Jetty里怎么建立。浏览器发起的是一个“带Upgrade请求头的HTTP请求”Jetty收到之后发现这个请求不是普通静态资源而是希望升级成WebSocket于是把连接交给对应的WebSocket处理器底层Socket就直接接管双方后续可以互发文本帧或二进制帧。在这个过程中Spring Boot起的作用是搭好Servlet容器Spring WebSocket做的是抽象层封装。我们代码里写的WebSocketHandler最终会被Spring适配到Jetty的WebSocket实现上。这样即使用了Jetty业务代码还是面向Spring的接口在写而不是直接跟Jetty的原生类打交道可维护性会好很多。在选型上我最终采用了Spring的WebSocketHandler方案而不是Jetty原生注解原因是后面还要结合Spring MVC的拦截器做用户登录态传递原生API当然也能做但用Spring的抽象省得自己处理一堆底层状态。2. 环境准备与基础配置2.1 依赖到底怎么配整个工程只需要一个普通Spring Boot Maven项目不需要额外安装任何WebSocket服务。依赖部分最关键的是三件事引入Spring WebSocket的starter、去掉默认Tomcat、换上Jetty。我用的依赖配置大致如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jetty/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency /dependencies注意一点如果项目里还引了spring-boot-starter-webfluxWebSocket依赖的包可能冲突因为WebFlux和Spring MVC不能同时占用一个Web容器。我调研时踩到过这个坑后来整个工程删掉WebFlux相关依赖就安静了。正常做REST接口引入spring-boot-starter-web就够了别再画蛇添足加别的。2.2 Jetty 相关配置项在application.yml里其实不用写太多东西。Jetty的默认端口仍然是8080如果想改端口就写server.port。不过我对连接配置做了一点调整避免测试时连接被空闲超时干掉server: port: 8080 jetty: threads: acceptors: 2 selectors: 4 max-connections: 200这个配置只是把Jetty的线程模型稍微调一下。acceptors负责接收TCP连接selectors负责处理网络事件。对于WebSocket这种长连接服务selectors数量可以适当比acceptors多一些。如果你不确定要配多少直接用默认值也没问题小项目根本碰不到瓶颈。2.3 确认容器已经切换成功配置完成之后启动项目时留意日志。如果启动日志里出现了Jetty相关的标志字样并且在监听8080端口那说明切换成功。如果日志还是Tomcat那一套说明排除Tomcat依赖没生效去看依赖树把可能从传递依赖里冒出来的Tomcat也一并排除掉。有些人会在这里打包出错原因是当前环境里可能设置了系统的CATALINA_HOME之类但这跟Spring Boot内嵌容器没关系别看岔了方向。排查时直接跑mvn dependency:tree确认spring-boot-starter-tomcat不在列表里基本就稳了。3. 服务端核心通信逻辑3.1 先决定用什么方式写服务端服务端写WebSocket接收端最少有两种姿势用JSR-356的ServerEndpoint注解这是Java标准里的WebSocket端点写法Jetty天生支持。类上标一个ServerEndpoint(/ws/demo)再写OnOpen、OnMessage、OnClose方法就完事了。这套API很轻不需要额外注册Jetty会在启动时自动扫描加载。用Spring的WebSocketHandler接口需要继承TextWebSocketHandler然后通过WebSocketConfigurer手动注册端点。优点是能跟Spring容器无缝整合Handler里可以直接注入Service也能走Spring拦截器。两种方式我都在项目里跑过。如果你只想做最快的DemoServerEndpoint在Jetty下确实最省事甚至连starter-websocket都不太需要。但是一旦想拿Spring的注入、拦截、消息监听那一套就会发现在JSR-356的类里注入Spring Bean很绕需要自己写一个全局的ApplicationContext工具。我最后选的是Spring方案原因很实际这类小项目的下一步十有八九是接数据库、接鉴权、接业务Service与其后面从标准API切到Spring不如一开始就用Spring封装。下面所有内容也以这个方案为主。3.2 WebSocketHandler 具体实现Spring里最常用的处理器是TextWebSocketHandler它默认只处理文本消息跟我们的JSON通信场景刚好吻合。如果以后要传图片、文件分片可以换BinaryWebSocketHandler或者重写handleMessage。一个简单的处理器核心代码是这样的package com.example.demo.websocket; import java.util.concurrent.CopyOnWriteArraySet; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import org.springframework.stereotype.Component; Component public class DeviceDataHandler extends TextWebSocketHandler { // 用一个线程安全的集合保存所有在线会话 private final CopyOnWriteArraySetWebSocketSession sessions new CopyOnWriteArraySet(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { super.afterConnectionEstablished(session); sessions.add(session); System.out.println(连接建立 session.getId() 当前在线数 sessions.size()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); System.out.println(收到消息 payload); // 这里简单回显实际会解析payload后执行对应业务 if (session.isOpen()) { session.sendMessage(new TextMessage(服务端已收到 payload)); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session); System.out.println(连接关闭 session.getId() 状态 status); } }这里面有个关键点WebSocketSession在建立连接后底层Socket可能随时被关闭所以发送消息前一定要判断session.isOpen()。如果要给所有客户端广播要遍历sessions集合逐个发一个客户端发送异常不能影响其他客户端最好把每个发送动作都放到try-catch里。3.3 端点注册与跨域配置光有Handler还不够得让Spring把“/ws/device”这个路径映射到Handler上。这一步通过实现WebSocketConfigurer接口完成package com.example.demo.websocket; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final DeviceDataHandler deviceDataHandler; public WebSocketConfig(DeviceDataHandler deviceDataHandler) { this.deviceDataHandler deviceDataHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(deviceDataHandler, /ws/device) .setAllowedOrigins(*); } }setAllowedOrigins(*) 表示允许任何来源连接。生产环境不建议这么写要限制成自己前端的域名否则别人可以随便连你的WebSocket端口。但开发阶段这个配置能省掉一大半跨域问题。如果前端和后端不同端口不设置AllowedOrigins浏览器控制台会报跨域错误而服务端日志还看不出明显异常非常误导人。到这里后端接收端点其实已经能跑了。启动项目后前端就可以尝试连接ws://localhost:8080/ws/device。3.4 广播与点对点发送怎么实现在很多实时场景里不只是“谁发消息回给谁”还需要把一条消息推送给所有人。比如设备状态变化要通知所有监控页面就是典型的广播。我上面的代码里DevDataHandler里有一个CopyOnWriteArraySet专门保存所有在线session广播方法可以这样写public void broadcast(String jsonMessage) { for (WebSocketSession session : sessions) { if (session.isOpen()) { try { synchronized (session) { session.sendMessage(new TextMessage(jsonMessage)); } } catch (Exception e) { // 记录日志但不要中断循环 e.printStackTrace(); } } } }这里我用了synchronized块原因是同一个session可能被多个线程触发发送如果不做同步Jetty内部可能会报“Cannot call sendMessage() while another sendMessage() is in progress”之类的错误。这个错误挺常见尤其是自己起了一个定时器线程往页面推数据页面又时不时代发表单消息给服务端两个线程抢同一个session不锁就会翻车。3.5 业务线程怎么主动推送消息现在后端Handler只是“被动”接收消息那“主动”推送设备状态变化该怎么做我的做法是用Spring的Scheduled定时任务配合广播方法。比如模拟一个“每隔3秒产生一次设备状态”的服务package com.example.demo.service; import java.time.LocalTime; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import com.example.demo.websocket.DeviceDataHandler; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; Service EnableScheduling public class DeviceStatusService { private final DeviceDataHandler handler; private final ObjectMapper objectMapper new ObjectMapper(); private final AtomicInteger counter new AtomicInteger(0); public DeviceStatusService(DeviceDataHandler handler) { this.handler handler; } Scheduled(fixedRate 3000) public void pushStatus() { try { MapString, Object data new HashMap(); data.put(type, STATUS); data.put(time, LocalTime.now().toString()); data.put(counter, counter.incrementAndGet()); data.put(status, RUNNING); handler.broadcast(objectMapper.writeValueAsString(data)); } catch (Exception e) { e.printStackTrace(); } } }这里用到了Jackson把Map转成JSON字符串前端收到的就是标准JSON。代码里每次推送都通过同一个Handler实例广播所有在线的页面都能收到这份数据。注意一点Scheduled默认是单线程调度器如果只有一个定时任务直接把Method改成无参public即可。如果项目里有多个定时任务建议在配置类上指定线程池大小否则两个任务都到时间时只能排队跑。这个不属于WebSocket本身的问题但很多人写到这里会困惑为什么定时推送不按时执行。3.6 如果要用 JSR-356 原生的写法前面说了如果只是想验证Jetty对WebSocket的原生支持还可以用ServerEndpoint快速写一个。这里给一个极简示例作为备选它不需要继承Spring的Handler直接在类上标注即可package com.example.demo.websocket; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; ServerEndpoint(/ws/native) public class NativeEchoEndpoint { OnOpen public void onOpen(Session session) { System.out.println(原生端点连接 session.getId()); } OnMessage public String onMessage(String message, Session session) { return echo: message; } OnClose public void onClose(Session session) { System.out.println(原生端点关闭 session.getId()); } }Jetty会自动扫描这种类前端连接ws://localhost:8080/ws/native就能用。不过这里我要提醒一下如果你同时在项目里配了Spring的WebSocketConfigurer又在类上写ServerEndpoint注意两者路径别冲突。之前我把端点都设计成了同一个路径启动时没报错但前端连接时只会走到其中一个处理器上排查了半天才找到原因。所以我的建议是一个项目里选定一种方案要么全部走Spring抽象要么全部走JSR-356原生API混着用很容易精神分裂。4. 前端通信与联调测试4.1 写一个最简单的前端页面前端我直接用原生JavaScript的WebSocket对象没有引任何框架。这样能把逻辑说得最清楚。工具页面大致长这样!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJetty WebSocket Demo/title /head body h2WebSocket 测试页/h2 div input typetext idmessageInput placeholder输入要发送的内容 button onclicksendMessage()发送/button button onclickconnect()重新连接/button /div ul idmessageList/ul script let ws null; function connect() { if (ws) { ws.close(); } ws new WebSocket(ws:// window.location.host /ws/device); ws.onopen function () { appendMsg(连接已建立); }; ws.onmessage function (event) { appendMsg(收到 event.data); }; ws.onerror function (event) { appendMsg(发生错误详见控制台); }; ws.onclose function (event) { appendMsg(连接关闭code event.code); }; } function sendMessage() { const input document.getElementById(messageInput); if (ws ws.readyState WebSocket.OPEN) { ws.send(input.value); appendMsg(发送 input.value); } else { appendMsg(连接尚未建立); } } function appendMsg(text) { const li document.createElement(li); li.textContent text; document.getElementById(messageList).appendChild(li); } connect(); /script /body /html这个页面可以直接放在src/main/resources/static目录下。因为示例工程里还有Spring MVC直接访问http://localhost:8080/static.html就能打开页面后端也是同一个端口所以我在new WebSocket时直接拼接了不太完整的地址——按需修改即可比如把窗口位置的host换成后端实际的IP和端口。前端有几个状态值得记住WebSocket.CONNECTING0OPEN1CLOSING2CLOSED3。发送数据前必须检查readyState是不是OPEN否则浏览器会直接抛异常。connect()函数里第一次关掉旧的ws时不要立刻又开新的最好在onclose回调里再执行重连逻辑否则会有竞态问题。4.2 浏览器控制台怎么验证写完页面后最直接的验证方式是打开浏览器开发者工具切到Network面板刷新页面看WebSocket连接请求。点击那个连接名称能看到Status Code是101 Switching Protocols这表示握手成功。如果看到404或者426那问题基本都在路径或升级请求处理上。如果不想写前端也可以用浏览器控制台直接执行let socket new WebSocket(ws://localhost:8080/ws/device); socket.onmessage function (e) { console.log(e.data); }; socket.send(hello);这个办法适合快速测试不需要刷新整个页面。我在联调时经常开两个浏览器Tab一个做发送端一个做接收端同时开着开发者工具的Network面板看帧数据调试效率很高。4.3 怎么判断数据是JSON还是普通文本后端推送的数据里我带头了type字段和time字段这样前端可以根据type走不同分支。比如“STATUS”渲染到状态区“LOG”追加到日志列表。前端处理时不要直接JSON.parse所有消息因为有时候服务端会先发一条连接成功提示内容可能不是合法JSON。稳妥做法是先根据业务前缀或头字段判断。我在这个Demo里把“发送”和“广播”两种消息混在同一个连接上不算规范。正式项目应该设计成事件类型字段例如{type:STATUS,payload:{...}}客户端解析时统一取type再取payload代码结构会整洁很多。WebSocket本身不关心消息是JSON还是纯字符串所以这层协议设计要靠自己约束。5. 联调排错与实战问题5.1 常见问题速查表把一个多小时的调试过程压缩一下这些问题是最高频的现象可能原因解决办法浏览器显示WebSocket握手失败状态码404端点路径不对Handler没有注册成功核对注册路径确认前端连接的路径完全一致握手失败状态码426 Upgrade RequiredJetty没有启用WebSocket支持或者Spring Boot版本匹配问题检查stater依赖确认没有把spring-boot-starter-websocket漏掉浏览器报跨域错误没配置setAllowedOrigins开发阶段设为*生产环境改为具体域名连接建立后马上断开服务端有异常未被捕获查看服务端日志特别关注onOpen和handleTextMessage里的错误能收到消息但发不出去服务端发送前没有判断session.isOpen或session被并发占用发送前判断isOpen必要时用synchronized包裹sendMessage同一浏览器访问两个页面只有一个页面收得到不同Tab连接被复用或者页面脚本事件覆盖每个页面单独new WebSocket检查代码里是否共用变量这表不能覆盖所有情况但排查时先对照一遍能省下很多无用功。5.2 连接为什么总是被断开开发时经常遇到前端刚打开几秒钟连接就自动关闭。常见原因之一就是空闲超时。Jetty对WebSocket默认设置了空闲时间如果一个连接在一段时间内没有收发任何帧容器就主动断开。如果你不想让连接被闲置回收可以在Handler注册时或者通过容器工厂配置。Spring Boot里可以通过注册ServletServerContainerFactoryBean来调整Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(8192); container.setMaxBinaryMessageBufferSize(8192); container.setMaxSessionIdleTimeout(60000L); return container; }这里把空闲超时设置成了60秒。注意如果前端也设置了超时比如Nginx或网关层的proxy_read_timeout很短服务端再怎么放长也没用前端和网关也要同步调大。5.3 心跳机制怎么设计对于生产环境单纯把空闲超时调大并不是最好的方案。更主动的做法是加心跳。服务端定期发一个ping消息前端收到后回pong这样连接就一直保持活跃状态。如果前端用的是浏览器原生WebSocket它收到ping帧会由底层自动回pong写JavaScript代码时甚至看不到这个过程。但很多WebSocket库或测试工具不会自动回pong所以应用层要设计一套自己的心跳更稳。我在这里的做法是前端每30秒发送一个{ type: HEARTBEAT }消息服务端在handleTextMessage里识别到这个type后直接返回一个{ type: HEARTBEAT_ACK }其余业务消息照常处理。这样即使底层没有自动pong回包也能保证链路是通的。心跳间隔要小于Jetty的空闲超时时间否则心跳还没发出去连接就被断了。一般设置为空闲超时的一半比较合适。5.4 CopyOnWriteArraySet 和 Session 泄漏问题在线Session集合我用了CopyOnWriteArraySet因为并发场景下读多写少这个集合遍历和发送时很安全。但它也有代价每次写入都会复制整个底层数组。对于动辄几万人的在线列表来说不划算但对这个项目几千上限完全没问题。Session泄漏是很坑的一件事。如果客户端直接拔网线不经过正常的TCP FIN包服务端onClose可能不会立刻触发已经断开的session会一直留在集合里。解决办法有两个定期遍历集合检查session.isOpen()把关闭的会话移除。在广播发送时如果发送异常且类型是Session Closed之类的错误立即从集合里移除。我自己的做法是两套都上定时任务每5分钟扫一次清理兜底广播发送遇到异常时顺手移除有问题会话。这样能最大程度避免“僵尸Session”越积越多。5.5 代理服务器和网关层要注意的点如果项目不是内网直连而是经过Nginx一类的反代服务转发这就特别容易出幺蛾子。WebSocket握手时有个关键请求头Upgrade: websocket Connection: Upgrade如果网关把这两个头吞掉或者改写后端收到的就不是一个正常的升级请求握手必然失败。所以在Nginx配置里需要显式设置长短连接协议相关的头转发支持并且把超时时间适当调大否则就算握手成功过一会儿也会被网关正常关闭。我在本地调试时没遇到这个问题一到联调环境就频繁断开最后查出来不是后端代码的问题而是网关层超时太短。这类问题你从后端日志看只有一句“connection closed”非常难定位所以先确认链路里有没有代理层。5.6 日志里到底该打哪些点我建议在Handler里把这几处信息打上日志连接建立、收到业务消息、连接关闭、发送异常。尤其连接关闭时要打印closeCode和reason这比一堆堆栈更容易定位问题。如果用Spring框架别直接用System.out.println统一用logger。但在这个Demo里为了减少配置我就用了系统输出如果是正式项目建议换成logback或者log4j2。WebSocket连接的日志量会比较大每次收发都打全量内容可能把磁盘写爆生产环境只打会话状态和异常业务消息可以拉到debug级别。6. 一些扩展建议与个人心得如果你只是想跑通一个Demo看到网页上能实时收到后端定时推送的内容到这里已经完成了。但如果你打算把这段代码继续往正式项目里推我劝你先想清楚这几件事。第一这个方案没有鉴权机制。WebSocket握手本质上还是一个HTTP请求所以可以复用Spring MVC里的Interceptor去做登录校验。在registerWebSocketHandlers方法里加上.addInterceptors或者直接配置HandshakeInterceptor在校验失败时返回false连接就会被拒掉。我实际做的时候发现很多人会忽略这一步导致别人能直接连上ws端口拿数据这等于开了一个后门。第二消息格式要尽早规范。我见过项目上线后把聊天内容和业务指令混在一起发前端解析逻辑越写越乱。最好一上来就约定一个通用消息结构比如包含type、requestId、payload、timestamp后面加功能就只需要新增type不用改框架。前期花半天定义协议后期能省几天。第三Jetty本身很稳但你要知道它默认对WebSocket的限制。文本消息缓冲区大小、二进制消息缓冲区大小、最大连接数、空闲时间这些默认值不一定适合你的业务。当业务方反馈“图片传不上来”或“长文本被截断”时先去查ServletServerContainerFactoryBean里的缓冲区配置别一开始就怀疑代码逻辑。第四不要指望WebSocket是万能的。它是长连接这就意味着服务器端要一直维护在线状态连接数上来之后内存和CPU开销都会增加。小并发下完全没有问题但如果你预期会有数万连接就需要考虑分布式方案比如把session信息放到Redis里或者借助消息中间件做广播。到那一步单机Jetty已经不够了重点是API设计要提前考虑到横向扩展。最后分享一个我自己写代码时的习惯无论多简单的WebSocket项目我都会在Handler里维护一个“最后活跃时间”字段并定时清理超时连接。这样做的好处不只是防僵尸连接还能帮你发现前端有没有悄悄断线重连。调试的时候你打印一下“在线数xxx当前时间yyy最后活跃zzz”很多摸不着头脑的缺数据问题都能一眼看到规律。这次用Jetty跑WebSocket的经历整体比我预想的顺畅。只要把依赖切换、Handler注册、跨域配置这三板斧做对后面的业务逻辑都是水到渠成的事。如果你正卡在某个握手或断开问题上建议先按我第5节给的排查顺序走一遍多半能解开。