【Java项目-轻聊】12-实现消息管理模块-WebSocket协议详解

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🎯 你正在阅读「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 服务器第二步:把四个生命周期方法写完整)
      • [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 是唯一 / 最佳选择吗?还有哪些替代方案?)
      • [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?

把上面串起来:

  1. 聊天要 实时 ,服务器得能 主动 把消息推给在线用户。
  2. HTTP 天生是「请求-响应」,不适合这种推送模型。
  3. 用 HTTP 轮询 可以凑合,但 费资源 + 不够及时,用户多了更扛不住。
  4. 所以需要一种 专门为「长连接、双向、实时」设计的协议 ------ 这就是 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,容易误会,就像 JavaJavaScript------都带 Java,其实两码事。

名词 到底是什么
Socket(套接字) 操作系统提供的一套 编程接口(API) 。你写 TCP 程序、UDP 程序,都要用 Socket 这套 API 去 connectsendrecv。它是 工具,不是协议。
WebSocket 一种 写好的协议规范 。浏览器和服务器说好了:握手时 Header 长这样,传数据时帧格式长这样。它是 规则,不是 API 名字。

所以如果问「WebSocket 是不是基于 Socket 实现的?」------

实现上当然底层会用 TCP Socket 去连,但 协议层面 WebSocket ≠ Socket,别把它理解成「Web 版的 Socket API」。


2.3 WebSocket 到底能干啥?一句话说清

连接建立成功之后:

  • 客户端可以随时发消息给服务器;
  • 服务器也可以 随时 发消息给客户端;
  • 不用每次说话都重新发 HTTP 请求、带一大坨 Header;
  • 连接会 保持打开(长连接),直到某一方关闭。

这就叫 全双工通信 + 长连接

张三发消息给服务器,服务器转给李四,李四那边 onmessage 回调立刻触发------这才是聊天要的「实时」。


2.4 一个容易说错的知识点:WebSocket 和 HTTP 谁包谁?

网上常有人说:「WebSocket 是基于 HTTP 实现的。」

这句话不太准确

准确的说法分两段:

  1. 建立连接(握手)那一段 :确实是通过 HTTP 请求 完成的,请求里带 Upgrade: websocket 等特殊 Header,服务器回 101 状态码,表示「好,协议切换了」。

    ------所以抓包时,第一下 你看到的是 HTTP。

  2. 握手成功之后 :同一条 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 报文格式小结

整帧很复杂,但 核心就三块,你可以这样记:

  1. Opcode:这一帧是文本、二进制、关闭、Ping/Pong 里的哪一种?
  2. Payload length(+ 可能的扩展长度):后面数据有多少字节?
  3. 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,带 HostUser-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: UpgradeUpgrade: 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 常见 两条路

  1. Tomcat 等容器提供的原生 WebSocket APIjavax.websocket / Jakarta EE 的 jakarta.websocket
  2. 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 事件。

核心作用

  1. 保存当前客户端Session会话,绑定登录用户ID,维护在线用户映射;
  2. 初始化连接资源、打印上线日志;
  3. 完成上线通知、推送离线消息等初始化业务。
2). handleTextMessage

触发时机 :前端通过 websocket.send() 发送文本消息到后端时触发,对应前端 onmessage

核心作用

  1. 接收客户端发来的聊天文本、指令数据,解析消息体;
  2. 执行业务逻辑:存储消息到数据库、转发消息给目标会话用户;
  3. 通过 session.sendMessage() 主动向客户端反向推送消息。
3). handleTransportError

触发时机 :连接发生网络异常、IO错误、传输中断时触发,对应前端 onerror

核心作用

  1. 捕获网络异常、读写失败等故障,记录错误日志;
  2. 标记当前会话为异常状态,释放失效连接资源;
  3. 主动关闭损坏的WebSocket会话,避免内存泄漏。
4). afterConnectionClosed

触发时机 :连接正常关闭/异常关闭后最终执行,对应前端 onclose

核心作用

  1. 清理资源:从在线用户集合移除当前Session,清除用户-会话绑定关系;
  2. 记录用户下线日志,更新用户在线状态;
  3. 释放连接占用的线程、缓存资源,完成收尾清理。

完整生命周期流程

建立连接 → 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,看请求报文

  1. 启动 Spring Boot;
  2. 用浏览器打开 test.html(或 IDE 里 Live Server 等方式);
  3. 打开开发者工具 → Network,筛选 WS,能看到握手请求。

握手请求是这行代码触发的:

js 复制代码
let websocket = new WebSocket("ws://127.0.0.1:8080/chat");

前端创建实例的时候指定路径

正常的话:

  • 请求头里能看到 Connection: UpgradeUpgrade: 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 跑通之后你应看到什么现象?

把现象描述清楚,说明你真跑过:

  1. 打开页面 → 连接建立 → 浏览器和服务端都打印「连接成功」;
  2. 输入框输入文字,点发送 → 浏览器打印「发送消息: xxx」;
  3. 服务器打印「收到消息: xxx」;
  4. 因为做了回显,浏览器 onmessage 又打印「收到的消息: xxx」;
  5. 关闭页面 → 双方打印连接关闭。

这说明:握手 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)。

接下来我们要做的:

  1. 用户先走 HTTP 登录,不要在 WebSocket 里传账号密码(不安全、也不好和现有 Session 体系配合);
  2. 进入聊天主页面 client.html 后,确认已登录(例如调 GET /userInfo);
  3. 确认登录成功之后 ,再 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 里:

  1. 把 JSON 反序列化成参数对象;
  2. WebSocketSession 取出当前登录用户(发送者);
  3. 校验 type,交给 聊天业务 Service 处理(不要全堆在 Controller 里)。

7.5 第四步:消息落库 + 在线 push(核心逻辑)

这是 后面一篇「消息收发核心逻辑」 的主战场。流程可以先背下来:

张三发一条消息,服务器应该:

  1. 根据 sessionId ,去数据库查 这个会话里还有谁 (成员表,轻聊里叫 message_session_user,单聊就两个人);
  2. 找出 对方 userId(以及是否也要 push 给发送者自己------多标签页同步时会用到);
  3. 对每个目标 userId:
    • 查在线表 → 在线 → 组好响应 JSON,WebSocketSession.sendMessage 实时 push
    • 离线 → 跳过 push(不是丢消息,下一步会存库);
  4. 无论对方在不在线,都把消息 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 连上就行,真项目还要考虑 连接活着吗、断了怎么办

  1. 心跳(Ping/Pong)

    中间 NAT、公司防火墙可能 静默掐长时间无数据的连接 。后面可以加定时 Ping,或业务层发 { "type": "ping" },防止「看着在线其实早断了」。

  2. 断线重连

    协议 不会 帮你自动重连。onclose 里要自己写:指数退避 (1s、2s、4s...)、重试上限、重连成功后 补拉历史

    重连时仍然连 正式路径,Cookie 登录态还要有效。

  3. 好友通知等扩展

    轻聊里发好友申请、同意好友,也可以 共用同一条 WebSocket ,靠 type 区分(如 FRIEND_REQUEST)。这和聊天是同一套「在线 push」思路,后面好友模块会接上。


7.8 本篇任务 vs 后面几篇分工(心里要有数)

读完本篇,2吗应该已经搞透:

  • 为啥聊天要用 WebSocket,而不是纯 HTTP;
  • 协议是啥、帧格式、握手 101 怎么回事;
  • Spring 怎么注册 Handler、四个回调干啥;
  • 浏览器 WebSocket API 怎么用;
  • 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 多用户通信如何管理?

轻聊做的是 一对一单聊(不是群聊),但系统里同时有很多对用户在线,服务器仍要回答两件事:

  1. 这条消息该发给谁? ------ 靠数据库里的 会话表、成员表
  2. 对方此刻在不在线、能不能立刻推过去? ------ 靠内存里的 在线用户表
数据库里存什么?
存啥 干啥用
消息会话表message_session 会话编号、最近访问时间等 一条单聊对应一条记录
会话成员表message_session_user 会话编号 + 用户编号 这个会话里有哪两个人(单聊各一行)
消息表message 发送者、会话编号、内容、时间等 所有聊天记录落库,供查历史

你可以这么记:会话编号 = 聊天房间号成员表 = 这个房间里就张三和李四消息表 = 聊过的每一句话

张三发一条消息,服务器怎么走?
  1. 张三在聊天页点发送,浏览器通过 长连接 发出一段 结构化数据 (要有:消息类型、会话编号、正文);
  2. 服务器从长连接上认出 当前登录的是张三(握手时带登录凭证sessionid);
  3. 根据 会话编号成员表 ,找到 李四(单聊就一个对方);
  4. 在线用户表 :李四有没有开着聊天页?
    • 在线 → 通过李四那条长连接 主动推送,李四窗口里马上出气泡;
    • 离线不推送(不是丢消息);
  5. 不管李四在不在线 ,都把这条消息 写入消息表
  6. 李四后来上线、点进这个会话 → 走 普通 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 次) 避免死循环 超限后要人工刷新
单次超时 每次连接设几秒超时 不会傻等 需配合退避
界面提示 显示「重连中...」「已恢复」 体验好 要写前端状态
轻聊重连时别忘的事
  1. 带上登录态 ------ 重连的是 正式聊天路径 (不是 Hello World 的 /chat);浏览器同域会自动带登录 Cookie,若登录已过期,连上了也推不了消息;
  2. 重连成功后补拉消息 ------ 离线期间别人发的,对当前会话调 历史消息 HTTP 接口
  3. 在线用户表要更新 ------ 新长连接建立后重新登记;若同一账号只允许一条连接,要想好 踢旧的还是拒新的
  4. 别和 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协议的认识】🎉!🚀。

下期开始实现【消息的收发】🖥️!

干货持续更新,记得点赞👍关注🌟收藏⭐,追更不迷路~