✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
🎯 你正在阅读「Java项目-轻聊」系列文章 🎯
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
🔥 弹简特 个人主页
❄️ 个人专栏直通车:
✨ 靠热爱去书写自己,靠勇敢去书写生活!
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
🌟 博主简介:

文章目录:
- [消息的发送和接收【核心】--- WebSocket](#消息的发送和接收【核心】— WebSocket)
-
- [一、背景知识:为什么聊天一定要「实时」?HTTP 为什么搞不定?](#一、背景知识:为什么聊天一定要「实时」?HTTP 为什么搞不定?)
-
- [1.1 咱们这个项目到底要解决什么问题?](#1.1 咱们这个项目到底要解决什么问题?)
- [1.2 先回忆:张三和李四不能直接连,必须走服务器](#1.2 先回忆:张三和李四不能直接连,必须走服务器)
- [1.3 张三发给服务器:没问题;服务器推给李四:很别扭](#1.3 张三发给服务器:没问题;服务器推给李四:很别扭)
- [1.4 用「给娃喂饭」来理解这两种模式](#1.4 用「给娃喂饭」来理解这两种模式)
- [1.5 能不能用 HTTP「模拟」推送?可以,但坑很多------轮询](#1.5 能不能用 HTTP「模拟」推送?可以,但坑很多——轮询)
- [1.6 小结:我们为什么要引入 WebSocket?](#1.6 小结:我们为什么要引入 WebSocket?)
- [二、WebSocket 是什么?和 Socket 啥关系?和 HTTP 啥关系?](#二、WebSocket 是什么?和 Socket 啥关系?和 HTTP 啥关系?)
-
- [2.1 WebSocket 首先是一个「应用层协议」](#2.1 WebSocket 首先是一个「应用层协议」)
- [2.2 那「Socket」和「WebSocket」有没有关系?](#2.2 那「Socket」和「WebSocket」有没有关系?)
- [2.3 WebSocket 到底能干啥?一句话说清](#2.3 WebSocket 到底能干啥?一句话说清)
- [2.4 一个容易说错的知识点:WebSocket 和 HTTP 谁包谁?](#2.4 一个容易说错的知识点:WebSocket 和 HTTP 谁包谁?)
- [三、WebSocket 协议报文格式(不用背,但要能看懂)](#三、WebSocket 协议报文格式(不用背,但要能看懂))
-
- [3.1 数据是按「帧(Frame)」一块一块传的](#3.1 数据是按「帧(Frame)」一块一块传的)
- [3.2 FIN:是不是这一条消息的「最后一帧」](#3.2 FIN:是不是这一条消息的「最后一帧」)
- [3.3 RSV:保留位,现在不用,先占坑](#3.3 RSV:保留位,现在不用,先占坑)
- [3.4 Opcode:这一帧是干啥的(比较重要)](#3.4 Opcode:这一帧是干啥的(比较重要))
- [3.5 MASK:掩码,客户端发必须掩,服务端发一般不掩](#3.5 MASK:掩码,客户端发必须掩,服务端发一般不掩)
- [3.6 Payload length:载荷有多长?127 字节够吗?](#3.6 Payload length:载荷有多长?127 字节够吗?)
- [3.7 Extended payload length:三种长度模式(文档里写得很细)](#3.7 Extended payload length:三种长度模式(文档里写得很细))
- [3.8 Payload Data:最后才是真正要传输的内容](#3.8 Payload Data:最后才是真正要传输的内容)
- [3.9 报文格式小结](#3.9 报文格式小结)
- [四、WebSocket 握手过程:怎么从 HTTP「升级」成长连接?](#四、WebSocket 握手过程:怎么从 HTTP「升级」成长连接?)
-
- [4.1 客户端(浏览器)会带哪些「不一样」的请求头?](#4.1 客户端(浏览器)会带哪些「不一样」的请求头?)
- [4.2 服务器同意的话,响应长什么样?](#4.2 服务器同意的话,响应长什么样?)
- [4.3 握手完成之后会发生什么?](#4.3 握手完成之后会发生什么?)
- [4.4 再强调一遍:别再说「WebSocket 就是 HTTP」](#4.4 再强调一遍:别再说「WebSocket 就是 HTTP」)
- [五、代码实战:Spring Boot 服务端 + 浏览器客户端 Hello World](#五、代码实战:Spring Boot 服务端 + 浏览器客户端 Hello World)
-
- [5.1 服务器第一步:写一个类,当 WebSocket 的「接线员」](#5.1 服务器第一步:写一个类,当 WebSocket 的「接线员」)
- [5.2 服务器第二步:把四个生命周期方法写完整](#5.2 服务器第二步:把四个生命周期方法写完整)
-
- 1). afterConnectionEstablished. afterConnectionEstablished)
- 2). handleTextMessage. handleTextMessage)
- 3). handleTransportError. handleTransportError)
- 4). afterConnectionClosed. afterConnectionClosed)
- [5.3 两个参数特别重要,每次看代码都要认得](#5.3 两个参数特别重要,每次看代码都要认得)
- [5.4 回显服务器是什么意思?](#5.4 回显服务器是什么意思?)
- [5.5 本项目里三种「Session」,名字像,完全不是一回事(极易混)](#5.5 本项目里三种「Session」,名字像,完全不是一回事(极易混))
- [5.6 服务器第三步:把 Handler「挂」到 URL 上(配置类)](#5.6 服务器第三步:把 Handler「挂」到 URL 上(配置类))
- [5.7 客户端:写一个 test.html,用 JS 连 WebSocket](#5.7 客户端:写一个 test.html,用 JS 连 WebSocket)
- [5.8 启动服务器、打开 test.html,看请求报文](#5.8 启动服务器、打开 test.html,看请求报文)
- [5.9 第一次连可能是 403:权限不够,不是代码写错了](#5.9 第一次连可能是 403:权限不够,不是代码写错了)
- [5.10 跑通之后你应看到什么现象?](#5.10 跑通之后你应看到什么现象?)
- [六、HTTP 轮询 和 WebSocket 摆在一起比](#六、HTTP 轮询 和 WebSocket 摆在一起比)
- 七、和轻聊项目后面怎么衔接(知道往哪走)
-
- [7.1 先建立直觉:Hello World 和真聊天差在哪?](#7.1 先建立直觉:Hello World 和真聊天差在哪?)
- [7.2 第一步:登录之后再建 WebSocket(不能谁都能连)](#7.2 第一步:登录之后再建 WebSocket(不能谁都能连))
- [7.3 第二步:维护「用户 → 连接」映射(在线表)](#7.3 第二步:维护「用户 → 连接」映射(在线表))
- [7.4 第三步:约定消息格式(带上 sessionId)](#7.4 第三步:约定消息格式(带上 sessionId))
- [7.5 第四步:消息落库 + 在线 push(核心逻辑)](#7.5 第四步:消息落库 + 在线 push(核心逻辑))
- [7.6 第五步:历史消息走 HTTP,实时消息走 WebSocket](#7.6 第五步:历史消息走 HTTP,实时消息走 WebSocket)
- [7.7 第六步:心跳、断线重连(可以稍后加,但要知道)](#7.7 第六步:心跳、断线重连(可以稍后加,但要知道))
- [7.8 本篇任务 vs 后面几篇分工(心里要有数)](#7.8 本篇任务 vs 后面几篇分工(心里要有数))
- 八、常见问题
- 附:轻聊专属问题(此处做完本项目再回头看)
-
- [9.1 多用户通信如何管理?](#9.1 多用户通信如何管理?)
- [9.2 WebSocket 是唯一 / 最佳选择吗?还有哪些替代方案?](#9.2 WebSocket 是唯一 / 最佳选择吗?还有哪些替代方案?)
-
- [WebSocket 优缺点](#WebSocket 优缺点)
- 替代方案逐个说(知道干啥用、为啥轻聊不首选)
- [9.3 除了文字,能发图片吗?怎么实现?](#9.3 除了文字,能发图片吗?怎么实现?)
-
- [做法一:消息里加「类型」字段(和文字共用 JSON)](#做法一:消息里加「类型」字段(和文字共用 JSON))
- [为啥图片常走 Base64?](#为啥图片常走 Base64?)
- [9.4 连接断了,怎么发现?](#9.4 连接断了,怎么发现?)
- [9.5 断线后,怎么快速恢复连接?](#9.5 断线后,怎么快速恢复连接?)
消息的发送和接收【核心】--- WebSocket
接下来这一期,我们要学的是 WebSocket 相关的知识。
这个模块是整个轻聊项目里最核心的一块------前面我们已经把主界面、用户管理、会话管理、好友管理、历史消息等都搭好了,到这儿才真正解决「消息怎么实时发、怎么实时收」的问题。
一、背景知识:为什么聊天一定要「实时」?HTTP 为什么搞不定?
1.1 咱们这个项目到底要解决什么问题?
你想象一下轻聊这个程序:张三和李四已经是好友了,张三在输入框里打了一句话,点发送。
理想情况是什么?
李四那边不用刷新页面、不用点任何按钮 ,聊天窗口里马上 弹出张三刚发的那条气泡------这就是咱们说的 实时传输。

「实时」两个字听起来简单,实现起来却有一个大前提:服务器得能在李四没主动请求的情况下,把新消息推给李四 。
这一推,就把 HTTP 那套老路子给难住了。下面咱们一层一层说清楚。
1.2 先回忆:张三和李四不能直接连,必须走服务器
这个点在网络原理、NAT 那部分介绍过,同时上一期我们也有在谈到,这里再捋一遍,因为后面讲 WebSocket 离不开它。
张三和李四各自在家连 WiFi,都在内网 里。内网 IP 比如 192.168.x.x,外网互相看不见。
所以张三不能 直接连李四的电脑发消息,李四也连不上张三------NAT 机制决定的。
那怎么办?中间必须有一个大家都能访问到的服务器 。服务器有公网 IP ,张三能连它,李四也能连它。
消息路径就变成:
张三(客户端) ──发消息──> 聊天服务器(有公网 IP) ──转发──> 李四(客户端)
注意这里的角色:
- 张三、李四:都是客户端(浏览器里跑的页面 + 后台 Spring 服务,对 NAT 来说都是「往外连」的一方)。
- 中间这台:是服务器,负责收消息、存消息、转发消息。

1.3 张三发给服务器:没问题;服务器推给李四:很别扭
张三 → 服务器 这一段,用 HTTP 非常自然。
为什么?因为 HTTP 从设计第一天起,就是 「客户端主动发起请求,服务器返回响应」 这种模式。
张三点发送,浏览器或前端代码发一个 HTTP 请求到服务器:「我把这条消息给你,你帮我存一下、转一下。」
服务器收到请求,处理完,返回一个响应:「好的,收到了。」
------这完全符合 HTTP 的脾气。
麻烦的是服务器 → 李四 这一段。
李四这会儿可能正盯着聊天窗口发呆呢,并没有 向服务器发任何新请求。
可张三的消息已经到服务器了,服务器得主动告诉李四:「喂,你有新消息了。」
这就相当于:客户端还没开口要,服务器先把结果塞过去 。
在 HTTP 世界里,这叫 「服务器推送数据给客户端」(Server Push,和 HTTP/2 的 Push 不是一回事,别混)。
咱们以前写的登录、查用户列表、拉历史聊天记录,全是:
用户点一下 → 浏览器发 HTTP 请求 → 服务器返JSON/HTML
从来没有 遇到过「用户啥也没干,服务器突然往页面里塞数据」这种玩法。
不是不能做,是用纯 HTTP 非常别扭,协议本身也不是为这个场景生的。

1.4 用「给娃喂饭」来理解这两种模式
这里有一个比喻:给娃喂食,下图👇

模式一:娃饿哭了再喂
- 娃(客户端)先发出信号:饿哭了(代表娃饿了)(发 HTTP 请求)
- 家长(服务器)再喂饭(返回 HTTP 响应)
- 娃不哭,家长就不喂------没有请求,就没有响应
这对应咱们写的几乎所有 HTTP 程序:GET 查数据、POST 提交表单,都是这种模式。
模式二:娃还没饿哭,看时间到了,家长主动喂一口
- 娃(客户端)没有发起任何请求
- 家长(服务器)看表:该吃饭了,直接塞过去
- 这就是 服务器主动推数据
聊天里「对方发来新消息」就是模式二:李四没刷新、没点「拉新消息」,服务器也要能把张三的话显示在李四屏幕上。
HTTP 当年主要是干啥的?
90 年代浏览网页、看文章、下图------像看报纸杂志。
页面是静态的,用户点链接,浏览器去拉 一份新页面回来就行。
那时候没人想到:同一个页面要长时间挂着 ,还要随时蹦出新内容 。
所以 HTTP 把应用层模型做成了「一问一答」,把 TCP 本来能双向通信的能力,在 HTTP 这一层给「收窄」了。
补充一句:底层 TCP 本来就是全双工的,A 和 B 连上以后,两边谁都可以先发数据。
但 HTTP 协议 在 TCP 之上又定了一套规则:一次通信通常是一次 Request(请求) + 一次 Response(响应),服务器不能在你没 Request 的时候随便塞 Response。
------所以不是 TCP 不行,是 HTTP 这个应用层协议不擅长干推送这事。
1.5 能不能用 HTTP「模拟」推送?可以,但坑很多------轮询
既然 HTTP 不能真推送,那能不能让李四假装是自己在要数据,其实目的是「尽快知道有没有新消息」?
可以。最常见的土办法叫 轮询(Polling):
李四的浏览器每隔一段时间 (比如 500 毫秒)就发一次 HTTP 请求问服务器:「我有新消息吗?」
有的话服务器这次就把消息返回;没有就返回空。
大概就是这样:
李四 ──GET /message/poll?userId=xxx──> 服务器 (没有新消息,空跑)
李四 ──GET /message/poll?userId=xxx──> 服务器 (没有,又空跑)
李四 ──GET /message/poll?userId=xxx──> 服务器 (有了!返回张三刚发的那条)
李四 ──GET /message/poll?userId=xxx──> 服务器 (没有,继续空跑)
......
表面上 李四好像「实时」收到消息了,本质上 不是服务器推,是李四自己高频地去问 。
就像你每隔半秒钟敲一次邻居家门:「邮件到了吗?」「邮件到了吗?」------邮件真到了那一趟你能拿到,但大部分敲门都是白敲。

轮询有两个很难回避的问题(务必记牢):
问题一:浪费系统资源
- 李四每 500ms 问一次,一天问几十万次。
- 大部分时候张三根本没发消息,服务器只能回答「没有」。
- 这些请求照样占:网络带宽、服务器线程/连接、CPU 解析 HTTP、可能还有数据库查一下。
- 用户一多,成千上万的人一起轮询,服务器压力非常大,大量计算是空转。
问题二:消息不够及时
- 你是间隔去问的,不是 24 小时盯着。
- 假设轮询间隔 500ms,张三的消息刚好在你问完 100ms 之后到达服务器。
- 那李四要等到下一个 500ms 周期去问,才能拿到------平白多等接近 400ms。
- 间隔设得越大,等得越久;间隔设得越小,资源浪费越狠。
所以轮询有个死结:
| 你把轮询调快 | 消息更及时,但服务器更累、空请求更多 |
|---|---|
| 你把轮询调慢 | 服务器轻松点,但用户感觉「卡、不实时」 |
两头不讨好。
(顺便提一句:还有 长轮询 、SSE(Server-Sent Events) 等 HTTP 系的改进方案,但是咱们这个项目直接用 WebSocket,没有使用长轮询等这些东西,所以这里不展开,知道「HTTP 系方案都有各自局限」即可。)
1.6 小结:我们为什么要引入 WebSocket?
把上面串起来:
- 聊天要 实时 ,服务器得能 主动 把消息推给在线用户。
- HTTP 天生是「请求-响应」,不适合这种推送模型。
- 用 HTTP 轮询 可以凑合,但 费资源 + 不够及时,用户多了更扛不住。
- 所以需要一种 专门为「长连接、双向、实时」设计的协议 ------ 这就是
WebSocket。
二、WebSocket 是什么?和 Socket 啥关系?和 HTTP 啥关系?
2.1 WebSocket 首先是一个「应用层协议」
这句话要理解到位:
WebSocket 是一个应用层协议 ,和 HTTP 是 同一层级 的东西。
它们 都跑在传输层的 TCP 之上,都是互联网上广泛使用的、有 RFC 标准文档规定的协议。
- HTTP:RFC 2616 / 7230 等,规定 Request 行、Header、Body 长什么样。
- WebSocket:RFC 6455,规定握手怎么握、数据帧(Frame)长什么样、Opcode 什么意思。
你可以把它们想成:TCP 是公路,HTTP 和 WebSocket 是公路上跑的两种不同规格的货车,规则不一样,但路是同一条 TCP 路。

2.2 那「Socket」和「WebSocket」有没有关系?
没有任何关系。
名字里都有 Socket,容易误会,就像 Java 和 JavaScript------都带 Java,其实两码事。
| 名词 | 到底是什么 |
|---|---|
| Socket(套接字) | 操作系统提供的一套 编程接口(API) 。你写 TCP 程序、UDP 程序,都要用 Socket 这套 API 去 connect、send、recv。它是 工具,不是协议。 |
| WebSocket | 一种 写好的协议规范 。浏览器和服务器说好了:握手时 Header 长这样,传数据时帧格式长这样。它是 规则,不是 API 名字。 |
所以如果问「WebSocket 是不是基于 Socket 实现的?」------
实现上当然底层会用 TCP Socket 去连,但 协议层面 WebSocket ≠ Socket,别把它理解成「Web 版的 Socket API」。
2.3 WebSocket 到底能干啥?一句话说清

连接建立成功之后:
- 客户端可以随时发消息给服务器;
- 服务器也可以 随时 发消息给客户端;
- 不用每次说话都重新发 HTTP 请求、带一大坨 Header;
- 连接会 保持打开(长连接),直到某一方关闭。
这就叫 全双工通信 + 长连接 。
张三发消息给服务器,服务器转给李四,李四那边 onmessage 回调立刻触发------这才是聊天要的「实时」。
2.4 一个容易说错的知识点:WebSocket 和 HTTP 谁包谁?
网上常有人说:「WebSocket 是基于 HTTP 实现的。」
这句话不太准确。
准确的说法分两段:
-
建立连接(握手)那一段 :确实是通过 HTTP 请求 完成的,请求里带
Upgrade: websocket等特殊 Header,服务器回 101 状态码,表示「好,协议切换了」。------所以抓包时,第一下 你看到的是 HTTP。
-
握手成功之后 :同一条 TCP 连接上,后面传的数据就 不再走 HTTP 的请求/响应格式 了,而是走 WebSocket 自己的帧格式 。
------这时候和 HTTP 没关系了。
所以:WebSocket 和 HTTP 是并列关系 ,不是「WebSocket 属于 HTTP 的一种」。
更准确:握手借 HTTP,数据传输用 WebSocket 协议。
三、WebSocket 协议报文格式(不用背,但要能看懂)
WebSocket 的格式在 RFC 6455 里写死了,和 TCP、UDP、IP、HTTP 一样,都是标准文档。
学习时可以直接翻 RFC,文档里会有示意图。
浏览器搜索
webSocket rfc 6455即可有对应文档
现实情况: 咱们写 Spring Boot + 浏览器 JS,几乎不会手写帧,框架和浏览器都封装好了。
3.1 数据是按「帧(Frame)」一块一块传的
WebSocket 不是把一整段 JSON 裸扔在 TCP 里,而是包成 一帧一帧 的结构。
每一帧大致分两块:帧头和载荷
┌──────────────────────────────────────────────┐
│ 帧头:FIN、RSV、Opcode、MASK、长度...... │ ← 描述「这帧是啥、多长」
├──────────────────────────────────────────────┤
│ Payload Data:真正要传输的内容(聊天文字等) │ ← 载荷
└──────────────────────────────────────────────┘

接下来我们会逐个字段讲。
3.2 FIN:是不是这一条消息的「最后一帧」
FIN 是一个标志位。
为 1 通常表示:这是当前这条消息的 最后一帧;为 0 可能表示后面还有延续帧(大消息会分片)。
注意别和 TCP 的 FIN 搞混:
- TCP 的 FIN:传输层「我要关闭连接」的信号,参与 四次挥手。
- WebSocket 的 FIN:应用层「这条 WebSocket 消息发完了」的标志。
WebSocket 说「发完了」,底层 TCP 连接 还可以继续 传下一条消息;
真要断开 WebSocket 连接,还会用 Opcode = 关闭 的帧,最后才走到 TCP 四次挥手。
3.3 RSV:保留位,现在不用,先占坑
RSV 有 3 个保留位 。
目前协议里规定它们必须是 0,留给以后扩展用。
你可以理解成:「现在用不上,但位得占着,万一以后协议升级要用。」
日常开发 不用管。
3.4 Opcode:这一帧是干啥的(比较重要)
Opcode(操作码) 用 4 个比特表示,说明 当前这一帧的类型/作用。
常见取值(不必全背,知道干啥、需要时查 RFC 即可):
| Opcode(十六进制) | 含义 | 该怎么理解 |
|---|---|---|
| 0x0 | 延续帧 | 一条消息太大,被拆成多帧时,后面的片 |
| 0x1 | 文本帧 | 载荷是 UTF-8 文本,聊天文字通常走这个 |
| 0x2 | 二进制帧 | 载荷是二进制,传图片、文件、Protobuf 等 |
| 0x8 | 关闭连接 | 告诉对方「我要关 WebSocket 了」 |
| 0x9 | Ping | 心跳探测:你还活着吗? |
| 0xA | Pong | 对 Ping 的回应:我还活着 |
所以 WebSocket 不仅能传字符串 ,也能传 二进制 。
咱们轻聊项目里先用 文本 (TextWebSocketHandler),以后如果要传文件,可以走二进制帧。
3.5 MASK:掩码,客户端发必须掩,服务端发一般不掩
MASK 位表示:Payload 有没有做 掩码 XOR 处理。
规则(RFC 规定死的):
- 客户端 → 服务器 :必须 MASK = 1,必须做掩码。
原因:防止某些有漏洞的代理把 WebSocket 流量误当成 HTTP 缓存起来,造成 缓存投毒 等安全问题。掩码让 payload 看起来是随机的。 - 服务器 → 客户端:必须 MASK = 0,不做掩码。
咱们写业务 几乎碰不到 手动掩码,浏览器和 Spring 自动处理。
如果问到「WebSocket 为什么客户端要掩码?」------答:安全规范,防代理误缓存,够用了。
3.6 Payload length:载荷有多长?127 字节够吗?
Payload 翻译过来叫 载荷 ,就是帧里 真正要_carry 的数据 (比如 "hello" 这几个字)。
Payload length 最初用 7 个比特 表示,单位是 字节 ,范围 0~127 。
你可能会问:127 字节一条聊天消息都不够,是不是 WebSocket 只能发这么短?
不是。 因为后面还有 扩展长度 机制。
3.7 Extended payload length:三种长度模式(文档里写得很细)
RFC 里规定,根据 Payload length 那 7 个比特的值,分 三种模式:
模式 1:7 比特的值 < 126
- 数据长度 就直接 是这 7 比特表示的数。
- 不需要额外的扩展长度字段。
- 适合 很短 的消息。
模式 2:7 比特的值 正好等于 126
- 说明真正的长度 不在这 7 比特里 ,而在后面跟着的 16 比特(2 字节) 里。
- 能表示更大的 payload。
模式 3:7 比特的值 正好等于 127
- 真正的长度在后面的 64 比特(8 字节) 里。
- 能表示 特别大 的数据。
怎么记?看前 7 比特的数值:
| 7 比特的值 | 模式 | 长度从哪读 |
|---|---|---|
| 0~125 | 模式 1 | 就这 7 比特 |
| 126 | 模式 2 | 后面 16 比特 |
| 127 | 模式 3 | 后面 64 比特 |
日常聊天一条文本远远用不满这些上限,知道 「小帧直接读,大帧有扩展」 就行。
3.8 Payload Data:最后才是真正要传输的内容
帧头都解析完了,剩下的 Payload Data(载荷数据) 就是:
- 文本帧:UTF-8 编码的字符串字节;
- 二进制帧:任意字节序列。
咱们在 JS 里 websocket.send("你好"),浏览器会帮你包成文本帧;
Spring 里 handleTextMessage 收到的 message.getPayload(),就是从这里解出来的。
3.9 报文格式小结
整帧很复杂,但 核心就三块,你可以这样记:
- Opcode:这一帧是文本、二进制、关闭、Ping/Pong 里的哪一种?
- Payload length(+ 可能的扩展长度):后面数据有多少字节?
- Payload Data:真正的业务内容。
Spring 和浏览器把组帧、解帧都包了,写业务时你面对的是字符串和回调,但懂帧结构,读 RFC、抓包、上手会轻松很多。
四、WebSocket 握手过程:怎么从 HTTP「升级」成长连接?
WebSocket 也是 浏览器页面和服务器 交互的一种形式。
你在 JS 里写 new WebSocket("ws://127.0.0.1:8080/chat") 的时候,第一步发出去的不是 WebSocket 帧,而是一个特殊的 HTTP 请求。

4.1 客户端(浏览器)会带哪些「不一样」的请求头?
普通 HTTP 请求可能长这样:GET /index.html,带 Host、User-Agent 等。
WebSocket 握手请求 除了普通 Header,还必须带下面这几个关键的:
http
GET /chat HTTP/1.1
Host: 127.0.0.1:8080
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
......
逐个翻译成人话:
| Header | 人话解释 |
|---|---|
Connection: Upgrade |
Upgrade译为升级,浏览器在说:「这次连接我想 升级 一下用法,不是普通的一次性 HTTP 请求。」 |
Upgrade: websocket |
「我想升级成 WebSocket 协议,以后咱俩按 WebSocket 规则说话。」 |
Sec-WebSocket-Key |
浏览器随机生成的一串 Base64,用来 防止缓存、参与握手校验(不是加密密钥,别误会)。 |
Sec-WebSocket-Version: 13 |
使用的 WebSocket 协议版本,现在基本都是 13。 |
合起来就是:浏览器问服务器------咱俩原来用 HTTP 聊,现在能不能改成 WebSocket 长连接?
4.2 服务器同意的话,响应长什么样?
服务器如果支持 WebSocket,且路径、权限都 OK,会返回 不是 200,而是 101:
http
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
......
重点:
| 点 | 说明 |
|---|---|
| 状态码 101 | 英文叫 Switching Protocols ,意思是 协议切换成功 。从 HTTP 切换到 WebSocket。面试非常高频。 |
同样带 Connection: Upgrade 和 Upgrade: websocket |
表示服务器 乐意升级,和浏览器对上暗号了。 |
Sec-WebSocket-Accept |
服务器根据浏览器发来的 Sec-WebSocket-Key,按 RFC 规定的算法算出来的。浏览器会校验,对不上就握手失败。 |
101 和 200 的区别:
200 是「请求成功,HTTP 正常结束」;
101 是「HTTP 这段对话到此为止,接下来换协议了,同一条 TCP 连接继续用,但不再按 HTTP 格式说话」。
4.3 握手完成之后会发生什么?
握手成功 一瞬间 之后:
- 还是同一条 TCP 连接(没有重新三次握手换端口);
- 但线上跑的数据变成了 WebSocket 帧 ,不再是
GET /xxx HTTP/1.1那种文本; - 浏览器触发 JS 的
websocket.onopen; - Spring 里对应 Handler 的
afterConnectionEstablished被调用。
从此张三和李四的客户端都可以和服务器 随时互发消息,不用每次再发 HTTP 请求。
4.4 再强调一遍:别再说「WebSocket 就是 HTTP」
握手 借 HTTP,只有 第一下 是 HTTP。
传消息阶段 WebSocket 和 HTTP 并列,各走各的协议格式。
五、代码实战:Spring Boot 服务端 + 浏览器客户端 Hello World
理论讲完,必须 动手跑通一次,后面做轻聊项目心里才有底。
Java 里用 WebSocket 常见 两条路:
- Tomcat 等容器提供的原生 WebSocket API (
javax.websocket/ Jakarta EE 的jakarta.websocket) - Spring 封装的 WebSocket API(咱们项目用这条,和 Spring Boot、依赖注入、其它 Bean 配合顺)
要实现一个最小的 Hello World,需要 两块:
- 服务器端:Spring Boot 里写 Handler + 配置类;
- 客户端 :浏览器里写 JS,WebSocket 是浏览器内置能力,不用额外下插件。
5.1 服务器第一步:写一个类,当 WebSocket 的「接线员」
新建一个类,继承 Spring 自带的 TextWebSocketHandler 。

为什么继承它?
TextWebSocketHandler是 Spring 内置的,专门处理 文本帧(Opcode 文本) 的处理器;- 里面已经帮你做好了二进制/文本的分派,咱们聊天文字用它刚好;
- 你要做的是 重写几个回调方法,在连接建立、收到消息、出错、关闭时写自己的逻辑。
同时给类加上 @Component ,让 Spring 管理这个 Bean,后面配置类才能 @Autowired 进来。
java
import org.springframework.stereotype.Component;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* 传输文本数据的 WebSocket 处理器
*/
@Component
public class TestWebSocketController extends TextWebSocketHandler {
}
继承这个父类,主要就是为了 重写下面这些方法 (先知道有这四个,后面填代码)。

**
5.2 服务器第二步:把四个生命周期方法写完整

1). afterConnectionEstablished
触发时机 :客户端与服务端WebSocket握手完成、连接创建成功时执行,对应前端 onopen 事件。
核心作用
- 保存当前客户端Session会话,绑定登录用户ID,维护在线用户映射;
- 初始化连接资源、打印上线日志;
- 完成上线通知、推送离线消息等初始化业务。
2). handleTextMessage
触发时机 :前端通过 websocket.send() 发送文本消息到后端时触发,对应前端 onmessage。
核心作用
- 接收客户端发来的聊天文本、指令数据,解析消息体;
- 执行业务逻辑:存储消息到数据库、转发消息给目标会话用户;
- 通过
session.sendMessage()主动向客户端反向推送消息。
3). handleTransportError
触发时机 :连接发生网络异常、IO错误、传输中断时触发,对应前端 onerror。
核心作用
- 捕获网络异常、读写失败等故障,记录错误日志;
- 标记当前会话为异常状态,释放失效连接资源;
- 主动关闭损坏的WebSocket会话,避免内存泄漏。
4). afterConnectionClosed
触发时机 :连接正常关闭/异常关闭后最终执行,对应前端 onclose。
核心作用
- 清理资源:从在线用户集合移除当前Session,清除用户-会话绑定关系;
- 记录用户下线日志,更新用户在线状态;
- 释放连接占用的线程、缓存资源,完成收尾清理。
完整生命周期流程
建立连接 → afterConnectionEstablished
收发消息 → handleTextMessage
网络出错 → handleTransportError
连接关闭 → afterConnectionClosed
正常断开无报错时,会跳过handleTransportError,直接执行关闭回调。
java
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* WebSocket 文本消息处理器测试类
* 继承Spring提供的TextWebSocketHandler,专门处理文本类型的WebSocket通信
* @Component 将当前类交给Spring容器管理,作为WebSocket处理器Bean
*/
@Component
public class TestWebSocketController extends TextWebSocketHandler {
/**
* 生命周期回调1:连接建立成功回调
* 触发时机:客户端与服务端完成WebSocket握手、TCP长连接正式建立后立刻执行
* 对应前端JS websocket.onopen 事件
* @param session 当前客户端专属会话对象,持有本次连接全部上下文、发送消息、关闭连接能力
* @throws Exception 业务代码抛出异常由框架捕获处理
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 打印日志,标记当前客户端成功建立WebSocket长连接
System.out.println("Test API 连接成功!!!");
// 实际业务可扩展:
// 1. 从session获取请求参数/登录用户,绑定用户与session映射,维护在线用户集合
// 2. 推送用户离线历史消息、广播用户上线通知
// 3. 初始化连接缓存、权限校验
}
/**
* 生命周期回调2:接收客户端文本消息回调
* 触发时机:前端调用 websocket.send("文本内容") 发送文本消息到后端时触发
* 仅处理文本消息;二进制消息不会进入此方法
* 对应前端JS websocket.onmessage 接收服务端推送
* @param session 当前消息所属客户端会话
* @param message 客户端发送的文本消息封装对象
* @throws Exception 消息处理异常
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// message.getPayload() 获取客户端发送的原始字符串消息内容
String clientMsg = message.getPayload();
System.out.println("Test API 收到消息:" + clientMsg);
// session.sendMessage(message):主动向当前客户端反向推送消息
// 此处逻辑:消息回显,客户端发什么,服务器原样返回
session.sendMessage(message);
// 实际业务可扩展:
// 1. 解析消息JSON,区分聊天、心跳、指令等不同业务类型
// 2. 存储聊天消息入库
// 3. 根据会话ID/用户ID转发消息给其他在线客户端(群聊/单聊推送)
// 4. 回复业务响应数据
}
/**
* 生命周期回调3:连接传输异常回调
* 触发时机:连接读写、网络IO、传输过程出现异常、断网、客户端强制闪退时触发
* 对应前端JS websocket.onerror 错误事件
* @param session 发生异常的客户端会话
* @param exception 捕获到的异常对象,可获取异常信息、堆栈定位故障
* @throws Exception 异常处理时抛出的错误
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 打印异常描述信息,用于日志排查网络故障
System.out.println("Test API 连接出现异常:" + exception.getMessage());
// 实际业务可扩展:
// 1. 打印完整异常堆栈日志,记录故障上下文
// 2. 标记该session为失效连接,主动关闭会话释放资源
// 3. 清理在线用户映射、释放缓存,防止内存泄漏
}
/**
* 生命周期回调4:连接关闭最终回调
* 触发时机:连接正常主动关闭 / 异常报错后框架自动关闭连接,最后都会执行此方法
* 对应前端JS websocket.onclose 关闭事件
* @param session 已关闭的客户端会话
* @param status 关闭状态对象,包含关闭码、关闭原因(区分正常关闭/异常断开)
* @throws Exception 关闭收尾处理异常
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 打印日志,标记客户端WebSocket连接已断开
System.out.println("Test API 连接关闭!!!");
// 实际业务可扩展:
// 1. 从在线用户Map移除当前session,更新用户在线状态为离线
// 2. 广播用户下线通知给好友/群会话
// 3. 释放当前连接占用的缓存、临时资源,做收尾清理
// 4. 根据status判断是用户主动退出还是网络异常断开,做差异化日志
}
}
把生命周期串成一条线,脑子里要有画面:
浏览器 new WebSocket(url)
↓
HTTP 握手(101)
↓
afterConnectionEstablished ← 「连接好了,可以说话了」
↓
(用户点发送,多次循环)
handleTextMessage ← 「收到一条,处理一条」
↓
(如果网络脏了、协议错了)
handleTransportError ← 「出事了」
↓
(用户关页面、服务器踢人、网络断)
afterConnectionClosed ← 「拜拜」
5.3 两个参数特别重要,每次看代码都要认得
① WebSocketSession session

- 表示 当前这一条 WebSocket 连接 的会话对象;
- 里面 握着和某个客户端之间的那条连接;
- 你可以用
session.sendMessage(...)主动往这个客户端推消息------这就是后面「服务器推给李四」的编程入口; - 一个浏览器 tab 连上来,通常对应一个
WebSocketSession。
② TextMessage message

- 封装 本次收到的那条文本消息;
message.getPayload()拿到字符串内容;- 如果是二进制处理器,对应的是
BinaryMessage,咱们这期用文本就够了。
5.4 回显服务器是什么意思?
上面 handleTextMessage 里有一行:
java
session.sendMessage(message);
意思是:客户端发啥,服务器 原封不动 再发回客户端。
用来 验证链路通了 :浏览器 send → 服务器 handleTextMessage → 服务器 sendMessage → 浏览器 onmessage。
做聊天之前,先把这个 最小闭环 跑通,非常重要。
5.5 本项目里三种「Session」,名字像,完全不是一回事(极易混)
写到这儿必须 停一下专门讲清楚,后面做消息转发还会反复用到。
| 名字 | 出现在哪 | 到底是啥 |
|---|---|---|
HttpSession |
登录、Servlet/Spring MVC | 传统 HTTP 会话 。用户登录成功后,服务器发 SessionId(常放 Cookie),一段时间内识别「这是哪个登录用户」。和 WebSocket 不是同一个东西 ,但后面 WebSocket 握手时可能会 结合 HttpSession 做鉴权(谁连上来的)。 |
MessageSession |
咱们轻聊 业务代码 里自己建的 | 业务上的聊天会话 :比如「张三和李四的单聊」对应数据库里一条会话记录,有 sessionId、好友关系、最后一条消息时间等。这是产品概念上的会话。 |
WebSocketSession |
Spring WebSocket API | 一条 WebSocket 长连接 在 Java 代码里的句柄。谁连上服务器,就有一个 WebSocketSession。这是技术连接上的会话。 |
举例:
- 李四 登录 了 → 有一个
HttpSession说「当前登录用户是李四」; - 李四和张三的 聊天窗口 → 业务里有一个
MessageSession(sessionId = 5); - 李四浏览器和服务器之间的 那根 WS 线 → 有一个
WebSocketSession对象。
三个都叫 Session, 层次完全不同。
5.6 服务器第三步:把 Handler「挂」到 URL 上(配置类)
Handler 写好了,Spring 还不知道:访问哪个路径时,该找这个 Handler 。
所以要再写一个 配置类 ,实现 WebSocketConfigurer 接口:
java
import com.zhongge.web_chatroom.controller.TestWebSocketController;
import org.springframework.beans.factory.annotation.Autowired;
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 // 告诉 Spring:我是配置类
@EnableWebSocket // 告诉 Spring:我要启用 WebSocket 功能
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private TestWebSocketController testWebSocketController;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 把 testWebSocketController 注册到路径 "/chat"
// 当浏览器 WebSocket 请求的路径是 "/chat" 时,就由 TestWebSocketController 里的方法处理
registry.addHandler(testWebSocketController, "/chat")
.setAllowedOrigins("*"); // 解决跨域,下面 5.9 节细讲
}
}

几个注解/方法解释:
@Configuration:配置类,Spring 启动时会加载。@EnableWebSocket:开关,不开的话 WebSocket 不生效。registerWebSocketHandlers:在这里 登记:哪个 Handler 负责哪个 URL。addHandler(处理器, "/chat"):路径/chat对应TestWebSocketController。
浏览器里 URL 写ws://127.0.0.1:8080/chat(注意ws://协议头,不是http://)。
5.7 客户端:写一个 test.html,用 JS 连 WebSocket
浏览器 内置 WebSocket,不用装插件。新建测试页:

html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- 移动端自适应适配 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试 WebSocket 的使用</title>
</head>
<body>
<!-- 消息输入框:填写要发给后端的文本 -->
<input type="text" id="message" placeholder="请输入发送内容">
<!-- 发送按钮:点击触发发送WebSocket消息 -->
<button id="send-button">发送</button>
<script>
// 1. 创建浏览器原生WebSocket实例,建立长连接
// 协议ws:// 代表明文WebSocket,wss://是加密版(线上HTTPS环境使用)
// IP+端口必须和后端服务一致,末尾路径 /chat 必须和WebSocketConfig注册的路径完全匹配
let websocket = new WebSocket("ws://127.0.0.1:8080/chat");
// 2. 生命周期回调:onopen 连接建立成功事件
// 对应后端处理器 afterConnectionEstablished 方法,握手完成双向触发
websocket.onopen = function() {
// 连接建立之后,就会自动执行到
console.log("webSocket 连接成功");
// 拓展业务:连接成功可自动发送上线消息、拉取离线消息
};
// 3. 生命周期回调:onclose 连接关闭事件
// 客户端主动关闭、后端断开、网络中断都会触发,对应后端 afterConnectionClosed
websocket.onclose = function() {
// 连接断开之后,就会自动执行到
console.log("webSocket 连接断开");
// 拓展业务:可做断线重连逻辑,弹窗提示用户断开连接
};
// 4. 生命周期回调:onerror 传输异常事件
// 网络故障、服务宕机、跨域拦截、连接中断报错触发,对应后端 handleTransportError
websocket.onerror = function() {
// 连接异常之后,就会自动执行到
console.log("webSocket 异常");
};
// 5. 生命周期回调:onmessage 接收后端推送消息事件
// 后端调用 session.sendMessage() 向前端发数据时触发,e.data 是后端返回的原始文本
// 对应后端 handleTextMessage 中 session.sendMessage 回推逻辑
websocket.onmessage = function(e) {
// 收到消息,就会自动执行到
console.log("webSocket 收到的消息:" + e.data);
// 拓展业务:把消息渲染到页面聊天框,区分自己/对方消息
};
// 6. 获取页面DOM元素:输入框、发送按钮
let messageInput = document.querySelector('#message');
let sendButton = document.querySelector('#send-button');
// 绑定按钮点击事件
sendButton.onclick = function() {
// 判断连接状态:readyState === 1 代表OPEN,连接正常才能发消息
if (websocket.readyState !== WebSocket.OPEN) {
alert("连接未就绪,无法发送消息!");
return;
}
// 获取输入框文本
let sendText = messageInput.value.trim();
if (!sendText) {
alert("不能发送空消息");
return;
}
console.log("webSocket 发送消息: " + sendText);
// 核心API:websocket.send() 前端发送文本消息,后端handleTextMessage接收
websocket.send(sendText);
// 发送后清空输入框
messageInput.value = '';
};
// 补充:主动关闭连接方法(可选按钮调用)
// websocket.close();
</script>
</body>
</html>

JS 这边和 Java 那边,回调是一一对应的:
| 浏览器 JS | 大致对应服务器 Spring |
|---|---|
onopen |
afterConnectionEstablished |
onmessage |
handleTextMessage(且服务器也可能主动 sendMessage 触发客户端 onmessage) |
onerror |
handleTransportError |
onclose |
afterConnectionClosed |
send() |
客户端发消息 → 服务器 handleTextMessage 被调用 |
5.8 启动服务器、打开 test.html,看请求报文
- 启动 Spring Boot;
- 用浏览器打开
test.html(或 IDE 里 Live Server 等方式); - 打开开发者工具 → Network,筛选 WS,能看到握手请求。
握手请求是这行代码触发的:
js
let websocket = new WebSocket("ws://127.0.0.1:8080/chat");
前端创建实例的时候指定路径
正常的话:
- 请求头里能看到
Connection: Upgrade、Upgrade: websocket; - 响应状态码 101 Switching Protocols;
- 控制台打印「webSocket 连接成功」;
- 服务器控制台打印「Test API 连接成功!!!」。
如下所示:
请求:

响应:

5.9 第一次连可能是 403:权限不够,不是代码写错了

如上图所示,如果我们没有做跨域访问,那么
很多人第一次跑会遇到:响应 403 Forbidden 。
看如下结果:

响应出现403状态码,而403是拒绝访问,权限不够

403 的意思是 拒绝访问、权限不够 。
常见原因:Spring WebSocket 默认会校验请求的 Origin(来源)。
比如你用 file:// 直接双击打开 html,或者页面端口、域名和服务器不一致,Origin 对不上,Spring 为了安全 拒绝握手。
解决办法(开发环境):
在注册 Handler 时加上:
java
registry.addHandler(testWebSocketController, "/chat")
.setAllowedOrigins("*"); // 允许任意来源,开发调试方便
.setAllowedOrigins("*") 是在解决跨域/Origin 校验问题 ,不是 WebSocket 协议本身的要求。
上线生产环境 不要把 * 乱用,应改成你的前端真实域名,例如 https://chat.example.com。
改完之后重新连,应看到 101 ,然后发消息,控制台 客户端和服务器两边都会打印 ,回显成功。

5.10 跑通之后你应看到什么现象?
把现象描述清楚,说明你真跑过:

- 打开页面 → 连接建立 → 浏览器和服务端都打印「连接成功」;
- 输入框输入文字,点发送 → 浏览器打印「发送消息: xxx」;
- 服务器打印「收到消息: xxx」;
- 因为做了回显,浏览器
onmessage又打印「收到的消息: xxx」; - 关闭页面 → 双方打印连接关闭。
这说明:握手 OK、双向发消息 OK、Spring 回调 OK、JS 回调 OK。
下一期就可以在这个基础上做 真正的聊天:多用户、存库、转发。
六、HTTP 轮询 和 WebSocket 摆在一起比

| 对比项 | HTTP 短轮询 | WebSocket |
|---|---|---|
| 通信模型 | 客户端反复发 HTTP 问「有没有」 | 一次握手,长连接 挂着 |
| 服务器能主动推吗 | 不能真推,只能等客户端来问 | 能 ,随时 sendMessage / 推帧 |
| 实时性 | 取决于轮询间隔,总有延迟 | 通常毫秒级,消息到了就触发回调 |
| 开销 | 每次完整 HTTP 请求,Header 重复,大量空请求 | 帧头很小,没有Repeated HTTP 头 |
| 实现复杂度 | 逻辑简单,但扛不住高并发在线 | 要管连接表、心跳、断线重连 |
| 典型场景 | 低频通知、兼容老环境 | 聊天、协作编辑、行情、游戏 |
WebSocket 不是银弹,缺点也要会说:
- 长连接占资源 :每个在线用户占一条连接,服务器要维护
WebSocketSession,内存、文件描述符都有上限; - 要心跳 :中间 NAT、防火墙、负载均衡可能 静默掐空闲连接,需要 Ping/Pong 保活;
- 断线重连:网络抖了要重连、重新鉴权、可能补拉消息;
- 老代理:极少数环境对 HTTP Upgrade 支持不好。
但对 聊天 这种 高频、双向、实时 场景,WebSocket 的收益 远大于 这些成本,所以轻聊项目选它是对的。
七、和轻聊项目后面怎么衔接(知道往哪走)
前面我们做到的是:一个浏览器连上 /chat,发什么服务器原样 echo 回去 。
这足够证明:WebSocket 协议通了、Spring 注册对了、JS 回调会用了。
但轻聊要的是:张三登录后发一条「你好」,李四不用刷新页面,聊天窗口里马上弹出气泡 。
从 echo 到真聊天,中间还要 补好几块拼图 。后面几篇项目实战会逐步实现;本篇先把「往哪走、每一步解决什么问题」讲清楚,到时候写代码时心里才有底。

7.1 先建立直觉:Hello World 和真聊天差在哪?
本文 Hello World(/chat) |
轻聊正式聊天(后面要做) | |
|---|---|---|
| 谁都能连吗 | 能,不验身份 | 不能,必须知道是哪个 userId |
| 服务器认识「这条连接是谁」吗 | 不认识 | 认识,靠登录态 |
| 收到消息后干啥 | 原样 echo 给自己 | 按会话找对方 → 在线就 push → 一定落库 |
| 离线的人怎么办 | 不管 | 消息存数据库,上线后拉历史 |
| 历史记录 | 没有 | MySQL message 表 + HTTP 查询接口 |
一句话:Hello World 验证「管道通了」;真聊天要在管道上跑「身份 + 路由 + 持久化 + 推送」这套业务。
7.2 第一步:登录之后再建 WebSocket(不能谁都能连)

Hello World 里,任何人打开页面 new WebSocket('/chat') 都能连上------练协议可以,上线不行。
轻聊前面已经(或即将)用 HTTP 做了登录 :POST /login,成功后服务端把用户信息放进 HttpSession ,浏览器自动保存 Cookie(常见叫 JSESSIONID)。
接下来我们要做的:
- 用户先走 HTTP 登录,不要在 WebSocket 里传账号密码(不安全、也不好和现有 Session 体系配合);
- 进入聊天主页面
client.html后,确认已登录(例如调GET /userInfo); - 确认登录成功之后 ,再
new WebSocket(...)建立长连接。
WebSocket 握手本质还是一次 HTTP 请求(Upgrade),同域下浏览器会自动带上 Cookie 。
所以你在 JS 里 不用手动塞 Token------Cookie 把登录态带过去就行。
服务端还要做一件事:在注册正式 WebSocket 路径时,加上 HttpSessionHandshakeInterceptor 。
它的作用:握手时把 HttpSession 里的 "user" 复制到 WebSocketSession.attributes 。
这样连接一建立,处理器里就能 session.getAttributes().get("user"),知道 这条连接对应哪个 userId。
未登录或 Session 过期?user == null,拒绝登记在线、丢弃业务消息------这就是「不能裸连聊天通道」的具体做法。
路径怎么规划(建议):
/chat:保留本文的 Hello World / 联调回显,不配登录拦截器,专门验证协议;/WebSocketMessage(或你自定义的名字):正式聊天 走这里,必须带登录拦截器。
两条路径可以在同一个 WebSocketConfig 里注册,互不干扰 ------学的时候用 /chat,做项目时用正式路径。
7.3 第二步:维护「用户 → 连接」映射(在线表)
echo 服务器只有 一条连接、一个 session,不需要知道「现在谁在线」。
真聊天里,张三要给李四 push 消息,服务器得回答一个问题:李四此刻有没有开着聊天页?如果有,他的 WebSocketSession 是哪一条?
接下来我们要写一个在线管理组件 (轻聊里可以叫 OnlineUserManager,名字随意),核心就一张表:
java
// 线程安全:ConcurrentHashMap<Integer, WebSocketSession>
// key = userId,value = 该用户当前这条 WebSocket 连接
在哪些时机维护这张表?
| 时机 | 做什么 |
|---|---|
| 连接建立 + 已登录 | online(userId, session) 登记 |
| 连接关闭 / 传输异常 | offline(userId, session) 移除 |
| 要 push 消息给某人 | getWebSocketSession(userId) 查连接 |
注意和 HttpSession 的区别:
- HttpSession:HTTP 登录会话,「这个浏览器有没有登录过」;
- WebSocketSession:某一条长连接的句柄,「此刻能不能通过 WS 推消息给他」;
- 推送消息查的是 在线表,不是 HttpSession。
可选策略(轻聊会采用其中一种):同一账号只允许一条 WebSocket------后连上的浏览器拒绝或踢掉旧的,避免一个 userId 对应多条连接 push 乱套。这个可以放到后面实现消息时再细做。
7.4 第三步:约定消息格式(带上 sessionId)
Hello World 发的是纯文本或随便什么字符串;真聊天必须告诉服务器:这条消息发到哪个会话里。
轻聊是 一对一单聊 (不是群聊):张三和李四有一个 sessionId (对应数据库里的「消息会话」)。
前端点发送时,通过 WebSocket 发 JSON,例如:
json
{
"type": "message",
"sessionId": 1001,
"content": "你好呀"
}
type:区分消息种类(聊天、心跳、好友通知等,后面会扩展);sessionId:发到哪个聊天会话(左侧会话列表里每一项都有一个 id);content:正文。
后端新建一个 WebSocketController (继承 TextWebSocketHandler),在 handleTextMessage 里:
- 把 JSON 反序列化成参数对象;
- 从
WebSocketSession取出当前登录用户(发送者); - 校验
type,交给 聊天业务 Service 处理(不要全堆在 Controller 里)。
7.5 第四步:消息落库 + 在线 push(核心逻辑)
这是 后面一篇「消息收发核心逻辑」 的主战场。流程可以先背下来:
张三发一条消息,服务器应该:
- 根据
sessionId,去数据库查 这个会话里还有谁 (成员表,轻聊里叫message_session_user,单聊就两个人); - 找出 对方 userId(以及是否也要 push 给发送者自己------多标签页同步时会用到);
- 对每个目标 userId:
- 查在线表 → 在线 → 组好响应 JSON,
WebSocketSession.sendMessage实时 push; - 离线 → 跳过 push(不是丢消息,下一步会存库);
- 查在线表 → 在线 → 组好响应 JSON,
- 无论对方在不在线,都把消息 INSERT 进
message表------不然历史消息没法查。

和 Hello World 的本质区别就在这里:
- Hello World:收到 → 原样发回给同一条连接;
- 真聊天:收到 → 按 sessionId 路由 → 查在线表决定 push 谁 → 一定写数据库。
数据库这边,前面会话模块应该已经建好了(或即将建):
| 表 | 干什么 |
|---|---|
message_session |
一条单聊会话的元数据(一个 sessionId) |
message_session_user |
这个会话里有哪两个 userId |
message |
每条聊天内容(fromId、sessionId、content、时间...) |
你不需要在本篇就把 SQL 和 Mapper 全写完------只要知道:会话管「跟谁聊」,消息表管「聊过什么」,WebSocket 管「在线的立刻看到」。
7.6 第五步:历史消息走 HTTP,实时消息走 WebSocket
有个点新手容易混:不是所有聊天数据都走 WebSocket。
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 对方在线,刚发的消息 | WebSocket push | 实时、省资源 |
| 刚进聊天页,拉以前记录 | HTTP GET /message?sessionId=xxx |
可能几百条,走 WS 不合适;且离线时本来就没 push |
| 对方离线期间发的 | 先 落库;对方上线后 | 点进会话 HTTP 拉历史 就能看到 |
所以完整体验是 两条腿走路:
- WebSocket:负责「此刻在线的人立刻收到」;
- HTTP:负责「进入会话时加载历史、补离线期间错过的」。
后面写前端 client.js 时:onmessage 里更新气泡和未读;点进某个会话时,再 ajax 调历史接口把记录渲染出来。
7.7 第六步:心跳、断线重连(可以稍后加,但要知道)
Hello World 连上就行,真项目还要考虑 连接活着吗、断了怎么办:
-
心跳(Ping/Pong)
中间 NAT、公司防火墙可能 静默掐长时间无数据的连接 。后面可以加定时 Ping,或业务层发
{ "type": "ping" },防止「看着在线其实早断了」。 -
断线重连
协议 不会 帮你自动重连。
onclose里要自己写:指数退避 (1s、2s、4s...)、重试上限、重连成功后 补拉历史 。重连时仍然连 正式路径,Cookie 登录态还要有效。
-
好友通知等扩展
轻聊里发好友申请、同意好友,也可以 共用同一条 WebSocket ,靠
type区分(如FRIEND_REQUEST)。这和聊天是同一套「在线 push」思路,后面好友模块会接上。
7.8 本篇任务 vs 后面几篇分工(心里要有数)
读完本篇,2吗应该已经搞透:
- 为啥聊天要用 WebSocket,而不是纯 HTTP;
- 协议是啥、帧格式、握手 101 怎么回事;
- Spring 怎么注册 Handler、四个回调干啥;
- 浏览器
WebSocketAPI 怎么用; - 403 多半是 Origin 校验,
.setAllowedOrigins怎么配; - Hello World echo 跑通。
后面几篇项目实战,按顺序补:
| 顺序 | 内容 | 解决什么问题 |
|---|---|---|
| 1 | 正式 WS 路径 + 登录拦截器 | 知道连接是谁 |
| 2 | OnlineUserManager 在线表 |
知道推给谁 |
| 3 | WebSocketController + 消息 JSON |
收发入口 |
| 4 | transferMessage:查成员 → push → 落库 |
真聊天核心 |
| 5 | 前端 initWebSocket + onmessage + 历史 HTTP |
页面完整体验 |
| 6 | 心跳、重连(可选增强) | 上线稳定性 |
本篇的 Hello World 不是白做 ------/chat 那套你可以一直留着联调;做真功能时 复制 Handler 的思路,换路径、加拦截器、加业务 Service 即可。
八、常见问题
Q1:为什么聊天不用 HTTP,要用 WebSocket?
HTTP 是请求-响应模型,客户端不请求,服务器很难主动推消息。用 HTTP 轮询可以凑合,但浪费资源、延迟大。WebSocket 握手后长连接、全双工,服务器可以随时推,适合实时聊天。
Q2:WebSocket 和 Socket 有什么关系?
没关系。Socket 是操作系统 API;WebSocket 是应用层协议。名字像 Java 和 JavaScript。
Q3:WebSocket 是不是基于 HTTP 的?
不完全对。只有 握手阶段 用 HTTP Upgrade,响应 101;数据传输 用 WebSocket 帧,和 HTTP 并列。
Q4:101 状态码什么意思?
Switching Protocols,协议切换成功,从 HTTP 切换到 WebSocket。
Q5:WebSocket 帧里 Opcode 0x1 和 0x2?
0x1 文本帧,0x2 二进制帧。
Q6:Spring 里 TextWebSocketHandler 四个回调?
afterConnectionEstablished 连接建立;handleTextMessage 收文本;handleTransportError 异常;afterConnectionClosed 关闭。
Q7:HttpSession、MessageSession、WebSocketSession 区别?
HttpSession 是 HTTP 登录会话;MessageSession 是业务聊天会话;WebSocketSession 是一条 WS 连接的句柄。
Q8:WebSocket 握手 403 常见原因?
Origin 校验失败。开发可加 setAllowedOrigins("*"),生产指定前端域名。
Q9:轮询为什么费资源?
大量请求没有新消息也在问,HTTP 开销重复,服务器空转。
Q10:客户端发 WebSocket 为什么要掩码?
RFC 要求,防止代理错误缓存 WebSocket 流量,安全考虑。
接下来我们解释轻聊中WebSocket知识相关的问题
附:轻聊专属问题(此处做完本项目再回头看)
这部分知识是本项目完成之后复盘用的,如果项目还未全部做完,可以先不用看。
这部分把轻聊项目里的 专属问题 单独拎出来讲清楚。
9.1 多用户通信如何管理?
轻聊做的是 一对一单聊(不是群聊),但系统里同时有很多对用户在线,服务器仍要回答两件事:
- 这条消息该发给谁? ------ 靠数据库里的 会话表、成员表;
- 对方此刻在不在线、能不能立刻推过去? ------ 靠内存里的 在线用户表。
数据库里存什么?
| 表 | 存啥 | 干啥用 |
|---|---|---|
消息会话表 (message_session) |
会话编号、最近访问时间等 | 一条单聊对应一条记录 |
会话成员表 (message_session_user) |
会话编号 + 用户编号 | 这个会话里有哪两个人(单聊各一行) |
消息表 (message) |
发送者、会话编号、内容、时间等 | 所有聊天记录落库,供查历史 |
你可以这么记:会话编号 = 聊天房间号 ;成员表 = 这个房间里就张三和李四 ;消息表 = 聊过的每一句话。
张三发一条消息,服务器怎么走?

- 张三在聊天页点发送,浏览器通过 长连接 发出一段 结构化数据 (要有:消息类型、会话编号、正文);
- 服务器从长连接上认出 当前登录的是张三(握手时带登录凭证sessionid);
- 根据 会话编号 查 成员表 ,找到 李四(单聊就一个对方);
- 查 在线用户表 :李四有没有开着聊天页?
- 在线 → 通过李四那条长连接 主动推送,李四窗口里马上出气泡;
- 离线 → 不推送(不是丢消息);
- 不管李四在不在线 ,都把这条消息 写入消息表;
- 李四后来上线、点进这个会话 → 走 普通 HTTP 接口 拉历史,把离线期间错过的补上。
群聊呢?
轻聊本期不做。以后若要扩展,成员表里多几行、对在线成员 循环推送 即可。
好友申请通知怎么办?
也可以 共用同一条长连接 :推送时带 消息类型 区分「聊天正文」和「好友申请 / 同意好友」,前端收到后分支处理。
9.2 WebSocket 是唯一 / 最佳选择吗?还有哪些替代方案?
不是唯一,但在浏览器里做文字实时聊天,WebSocket 仍然是最常见、最成熟的选择之一。

WebSocket 优缺点

| 优点 | 缺点 | 常见应对 |
|---|---|---|
| 全双工,服务器能主动推 | 长连接占连接数、占内存 | 集群部署、限制单用户连接数 |
| 实时性好 | 中间网络设备可能 掐掉长时间无数据的连接 | 心跳(定时发探测包保活) |
| 协议成熟,浏览器和 Spring 支持好 | 断线要自己 重连、重新带登录态 | 指数退避重连(越失败等越久再试) |
| 帧头小,比反复发 HTTP 省 | 少数老环境对 协议升级 支持差 | 生产用 加密长连接 (wss,相当于 HTTPS 版的 WebSocket) |
替代方案逐个说(知道干啥用、为啥轻聊不首选)
① 长轮询
- 浏览器发 HTTP 请求,服务器 先挂着不回,有新消息或超时才响应;
- 浏览器收到后 马上再发下一个 请求;
- 和 WebSocket 的区别: 不是一条长连接一直挂着,而是 反复「请求---等---响应---再请求」;
- 能用,老项目常见;但每次都要带完整 HTTP 头,不如长连接干净。
- 轻聊为啥不首选: Spring + 浏览器对 WebSocket 支持已经很好,长轮询是退而求其次。
② 服务器推送事件(SSE,全称 Server-Sent Events,单向推送)
- 只能服务器 → 浏览器单向推,浏览器用 HTTP 长连接收事件流;
- 适合 通知、行情 这类「只要收不要发」的场景;
- 聊天要 双向发消息,SSE 不够用,还得配别的接口。
③ MQTT
- 发布 / 订阅 模型,协议轻,常见于 物联网、传感器;
- 浏览器聊天里很少见。
④ WebRTC
- 强项是 音视频、点对点传输;
- 一般不拿来传普通文字;视频通话才是它的主场。
⑤ WebTransport(较新)
- 基于 QUIC 协议,目标更低延迟、更高吞吐;
- 规范还在演进,普及度不如 WebSocket;
- 面试可提一句「未来可能替代部分场景」,现阶段聊天仍选 WebSocket 更稳。
一句话:
「WebSocket 不是唯一方案,长轮询、SSE、MQTT、WebRTC 各有场景;轻聊这种浏览器文字实时聊天,WebSocket 是主流最优解之一。」
9.3 除了文字,能发图片吗?怎么实现?
轻聊本期聊天正文只发文字 ;头像走 HTTP 上传 + 数据库存路径,不是走长连接传图。
以后若要 在聊天里发图片,常见两种做法:
做法一:消息里加「类型」字段(和文字共用 JSON)
前后端约定好格式,例如:
json
{ "type": "text", "sessionId": 1001, "content": "你好呀" }
{ "type": "image", "sessionId": 1001, "content": "iVBORw0KGgoAAAANSUhEUg..." }
- 类型(type):告诉对方这是文字还是图片;
- 内容(content) :文字就直接是字符串;图片则放 Base64 编码后的文本。
为啥图片常走 Base64?
长连接 能传二进制 ,但轻聊用的是 文本帧 + JSON 字符串 。
JSON 里不好直接塞二进制,常见做法:图片 → Base64 转成可打印字符 → 塞进 content。
浏览器收到后:解码 → 拼成 data:image/png;base64,... → 显示在图片标签里。

Base64 是啥?
一种把二进制变成文本的编码,方便在 JSON 里传。体积会比原图大约 三分之一,小图聊天够用。
更推荐的做法(生产常见,轻聊本期也未做):
先 HTTP 上传图片 拿到地址,消息里只发图片链接------省带宽。头像上传就是这套思路,聊天图片可以照抄。
另一种路子: 不用 Base64,改用 二进制帧 传纯图片字节------性能更好,前后端处理器都要改,复杂度高一档。
项目里「类型」字段已经在用了: 聊天正文、好友申请、同意好友,都靠 类型 区分;只是 聊天里还没有图片类型,扩展时沿用同一约定即可。
9.4 连接断了,怎么发现?
WebSocket 协议本身 支持 心跳探测(Ping 探测包 / Pong 回应包):
- 一方定时发 Ping(探测);
- 对方应 Pong(回应);
- 长时间没回应 → 认为连接已死,触发关闭。
轻聊后面做项目时建议加上心跳 ;若只做 Hello World 联调,本地往往感觉不到,上线后中间网络设备可能 静默掐掉空闲连接。
除了协议层,业务代码也会收到通知:
| 端 | 什么时候触发 | 轻聊里要做什么 |
|---|---|---|
| 浏览器 · 连接关闭时 | 连接正常关、异常关 | 提示「连接已断开」,引导刷新或重连 |
| 浏览器 · 传输出错时 | 传输出错 | 提示用户检查网络 |
| 服务端 · 连接关闭时 | 用户关页、断网 | 从 在线用户表 里删掉,避免往废连接上推 |
| 服务端 · 传输异常时 | 网络中断等 | 同上,清理在线状态 |

后面写服务端时要记得: 连接关掉后必须从 在线用户表 移除;还要做 引用比对 ------关掉的必须是登记表里 同一条 连接才删,防止「旧连接关闭误删新连接」。
正式聊天页也要做: 断线后别让用户以为还能发消息。
9.5 断线后,怎么快速恢复连接?
光发现断了不够,还得 重连 ------协议不会自动帮你连回去,要在应用层自己写。
轻聊后面建议在前端加重连逻辑;现阶段若只刷新页面,会重新走「查登录信息 → 再建长连接」,也能恢复,但体验差。

常见策略
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 立即重连 | 一断马上再建长连接 | 闪断恢复极快 | 服务器故障时会 疯狂打 服务器 |
| 指数退避(推荐) | 等 1 秒、2 秒、4 秒...再试,最长封顶 30 秒 | 减轻服务器压力 | 完全宕机时要等几轮 |
| 重试上限 | 最多试 N 次(如 10 次) | 避免死循环 | 超限后要人工刷新 |
| 单次超时 | 每次连接设几秒超时 | 不会傻等 | 需配合退避 |
| 界面提示 | 显示「重连中...」「已恢复」 | 体验好 | 要写前端状态 |
轻聊重连时别忘的事
- 带上登录态 ------ 重连的是 正式聊天路径 (不是 Hello World 的
/chat);浏览器同域会自动带登录 Cookie,若登录已过期,连上了也推不了消息; - 重连成功后补拉消息 ------ 离线期间别人发的,对当前会话调 历史消息 HTTP 接口;
- 在线用户表要更新 ------ 新长连接建立后重新登记;若同一账号只允许一条连接,要想好 踢旧的还是拒新的;
- 别和 HTTP 登录态搞混 ------ 长连接断了,HTTP 登录可能还在;重连的是 推送通道,不是重新登录(除非登录 Session 也过期了)。
重连伪代码思路(加在浏览器「连接关闭」回调里即可):
javascript
let retry = 0;
function reconnect() {
if (retry >= 10) { alert("请检查网络后刷新页面"); return; }
let delay = Math.min(1000 * Math.pow(2, retry), 30000); // 指数退避
setTimeout(() => {
retry++;
websocket = new WebSocket("ws://127.0.0.1:8080/WebSocketMessage");
websocket.onopen = () => {
retry = 0;
let sid = getCurrentSessionId();
if (sid) getHistoryMessage(sid); // 补拉历史
};
}, delay);
}
websocket.onclose = () => { reconnect(); };
本期搞定【WebSocket协议的认识】🎉!🚀。
下期开始实现【消息的收发】🖥️!
干货持续更新,记得点赞👍关注🌟收藏⭐,追更不迷路~