WebSocket不是新协议?借HTTP握手讲透原理、跨域与排查
2026/9/24 14:09:23 网站建设 项目流程

我第一次带团队做实时聊天模块的时候,团队里一个后端同学很认真地把 WebSocket 的 RFC 6455 从头啃了一遍,跑过来跟我讨论帧格式里的掩码规则。我说你先别背那个,你只要记住一句话:WebSocket 从来不是一门需要“从零学”的新协议,它先是借 HTTP 的壳完成一次握手,握手成功之后才切换到自己的协议。很多人搞不懂 WebSocket,不是帧格式难,而是没想清楚它和 HTTP 到底是“借壳”还是“替代”的关系。

这篇我就围绕这个核心,把三件事串起来讲透:HTTP 握手到底握了什么、双端 Demo 怎么写才不算白写、以及为什么 WebSocket 也会遇到跨域。适合刚接触 WebSocket 的前端、后端、全栈同学,也适合被线上 WebSocket 连接问题折磨过、想彻底搞懂原理的开发者。

1. 先想明白:WebSocket 不是替代 HTTP,而是“借 HTTP 升级”

1.1 第一次连接,它就是一个普普通通的 HTTP GET

很多人看 WebSocket 文档,一上来就是 frame、opcode、mask、ping/pong,直接被劝退。但如果你抓一次真实的 WebSocket 握手包,会发现它的起点特别朴素——就是一个标准 HTTP GET 请求。我用 curl 就能模拟出来。

GET /chat HTTP/1.1 Host: localhost:3001 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Version: 13 Origin: http://localhost:8080

你看,它走的是 80 或者 443 端口,用的是 HTTP/1.1 的报文格式,请求头里除了普通 HTTP 头之外,多带了几个以Sec-开头的字段。所以说 WebSocket 不是凭空冒出来的新协议,它先“伪装”成一个 HTTP 请求发出去,请求服务器:我要升级协议,你同不同意。

这里有一个非常关键的认知:HTTP 协议本身就预留了 Upgrade 机制,用来在同一个 TCP 连接上切换到其他协议。WebSocket 只是这个机制的著名应用之一。所以“借 HTTP 握手”不是设计上的妥协,而是一种刻意的选择——它让 WebSocket 可以复用 HTTP 世界的端口、反向代理、TLS 加密链路和运维体系,代价是你必须先把 HTTP 这一套搞明白。

1.2 101 Switching Protocols:这不是报错,是协议切换的暗号

服务器如果同意升级,会返回这样一个响应:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=

第一次看到 101 的同学容易慌,以为 1xx 状态码是不正常的。实际上 101 的意思是:你说得对,我也同意,从这条响应之后,咱们不再按 HTTP 规则聊天了,改用 WebSocket 帧格式通信。这就好比两个人打电话,一开始说的是“请问是某某公司吗”,对方确认“是”,然后双方立刻切换到加密的内部暗语,后面的通话内容外面的人就听不懂了。

101 响应里的Sec-WebSocket-Accept不是随便生成的,客户端会在握手后校验这个值。计算方式是:

Sec-WebSocket-Accept = base64( SHA1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )

后面的那串 GUID 是 RFC 6455 写死的固定字符串。这个校验的目的很简单:让客户端确认对面真的是一个支持 WebSocket 的服务器,而不是某个恰好返回 101 的中间设备。如果你想亲手验证这个计算过程,在命令行里跑一下:

echo -n "x3JJHMbDL1EzLkh9GBhXDw==258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | openssl sha1 -binary | base64

输出就是上面响应里的HSmrc0sMlYUkAGmm5OPpG2HaGWk=。这一步我强烈建议你自己跑一遍,跑完你对“握手”的理解会立刻从“背字段”变成“看得懂字段”。

1.3 理解握手阶段,是理解一切 WebSocket 问题的钥匙

很多人调试 WebSocket 失败的时候,去看什么帧、掩码、分片,方向完全错了。绝大多数问题都出在握手阶段,而不是数据传输阶段。为什么?因为握手阶段还是 HTTP,要经过完整的 HTTP 解析、路由匹配、鉴权中间件、甚至是反向代理的转发规则;一旦切换到 WebSocket 协议,这些中间层很多就不再生效了,连接变成一条裸的双向通道。

所以当你遇到 WebSocket 连接失败,第一反应应该是去抓握手请求和响应,看看请求有没有到服务器、服务器有没有返回 101。如果返回了 400 或者 403,那就是握手层出了问题,跟 WebSocket 本身的帧格式一点关系都没有。后面我会专门列一份排查表格,这里先记住这个结论。

2. 写双端 Demo 之前,先定好三个“约定”

2.1 服务端选型:别一上来就上重型框架

做 Demo 最容易犯的错,就是一上来就打开 Spring Boot 或者 NestJS 全家桶。不是说框架不好,而是框架帮你封装了太多细节,你很难看清握手到底是怎么发生的。我建议第一版 Demo 用 Node.js + ws 库,原因有三个:

第一,ws 库几乎就是 Node 社区 WebSocket 的事实标准,API 贴近原生事件模型,没有太多魔法;第二,它可以在同一个 HTTP server 上挂载 WebSocket 服务,方便你观察握手和普通 HTTP 请求怎么共存;第三,依赖极少,一个npm install ws就能跑起来。

如果你的生产环境是 Java 技术栈,等把 Node 版 Demo 跑通了,再迁移到 Spring Boot 也不迟。原理完全一样,后面我在跨域章节会额外给一份 Spring Boot 的配置参考。

2.2 通信格式:建议统一用 JSON,别裸发字符串

WebSocket 本身传输的是文本帧或二进制帧,它不关心帧里装的是什么。但如果你不提前约定消息格式,双端联调的时候一定会踩坑。我的习惯是最少约定三件事:消息类型(type)、消息内容(payload)、以及一个自增的消息 id(可选,用于回执)。

{ "type": "chat", "payload": "hello", "id": 1 }

这么约定之后,服务端收到消息只需要做一次 JSON.parse,按 type 分发逻辑;客户端收到消息也先 parse,再按 type 处理。千万别偷懒发裸字符串,前期省的事后期全会在联调里找回来。

2.3 生命周期:Open、Message、Close、Error 四个事件必须闭环

WebSocket 连接不是发完不管的,它有完整的生命周期。一个合格的 Demo 至少要把四个事件都监听上:连接建立(open)、收到消息(message)、连接关闭(close)、发生错误(error)。尤其是 error 和 close,很多人只监听 message,出问题了连个日志都没有,排查起来全靠猜。

我自己习惯在每个事件里都打印一条带时间戳的日志,线上排障的时候这四行日志能帮你快速定位是网络问题、服务端主动断开、还是客户端异常退出。别觉得 Demo 不用做这么全,好的调试习惯就是从最小 Demo 开始养的。

3. 双端 Demo 完整落地:从握手日志到消息互通

3.1 服务端:Node.js + ws 搭一个带握手日志的 WebSocket 服务

先建一个目录,初始化项目并安装依赖:

mkdir ws-demo cd ws-demo npm init -y npm install ws

然后创建一个server.js,代码如下:

const http = require('http'); const { WebSocketServer } = require('ws'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('WebSocket server is running'); }); const wss = new WebSocketServer({ server, path: '/chat' }); wss.on('connection', (ws, req) => { console.log('[connection] 客户端已连接'); console.log('[request] 握手请求 URL:', req.url); console.log('[request] Origin:', req.headers.origin); ws.send(JSON.stringify({ type: 'welcome', payload: '连接成功' })); ws.on('message', (data) => { console.log('[message] 收到原始数据:', data.toString()); try { const msg = JSON.parse(data.toString()); ws.send(JSON.stringify({ type: 'echo', payload: msg.payload })); } catch (err) { ws.send(JSON.stringify({ type: 'error', payload: 'JSON 解析失败' })); } }); ws.on('close', (code, reason) => { console.log('[close] 连接关闭,code:', code, ',reason:', reason.toString()); }); ws.on('error', (err) => { console.error('[error]', err.message); }); }); server.listen(3001, () => { console.log('服务已启动: http://localhost:3001'); });

启动服务:node server.js,你会看到监听日志。这个服务做了什么?它创建了一个普通的 HTTP server,同时把 WebSocketServer 挂在了同一个端口上,路径是/chat。这就是典型的“借 HTTP 握手”的落地形态:先有 HTTP server,再有 WebSocket 升级。

3.2 客户端:先用浏览器原生 WebSocket,不引任何库

新建一个client.html,内容如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>WebSocket Demo</title> </head> <body> <h1>WebSocket Demo</h1> <input id="input" type="text" placeholder="输入消息" /> <button id="send">发送</button> <pre id="log"></pre> <script> const log = document.getElementById('log'); const input = document.getElementById('input'); const sendBtn = document.getElementById('send'); function appendLine(text) { log.textContent += new Date().toLocaleTimeString() + ' ' + text + '\n'; } const ws = new WebSocket('ws://localhost:3001/chat'); ws.onopen = () => { appendLine('[open] 连接已建立'); ws.send(JSON.stringify({ type: 'hello', payload: '我来了' })); }; ws.onmessage = (event) => { appendLine('[message] ' + event.data); }; ws.onclose = (event) => { appendLine('[close] code=' + event.code + ', reason=' + event.reason); }; ws.onerror = (err) => { appendLine('[error] ' + JSON.stringify(err)); }; sendBtn.onclick = () => { const value = input.value.trim(); if (value) { ws.send(JSON.stringify({ type: 'chat', payload: value })); input.value = ''; } }; </script> </body> </html>

用浏览器直接打开这个 HTML 文件,不用起多余的静态服务器。打开开发者工具,Network 面板里找到类型为 WS 的请求,点进去你能看到完整的握手请求头、响应头和后续的帧消息。这是你第一次“亲眼”看到 HTTP 握手到 WebSocket 帧的切换过程,建议花几分钟逐个字段看一眼。

3.3 用 wscat 和 Postman 先替浏览器“探路”

有时候浏览器环境太复杂,我习惯先用命令行客户端直接连服务端。这样能把“前端代码问题”和“后端服务问题”迅速隔离开。ws 库官方提供了一个命令行工具 wscat,安装一行命令:

npx wscat -c ws://localhost:3001/chat

连接成功后会进入交互模式,你输入的每一行都会作为消息发出去,服务端返回的内容会直接打印。完全不需要写页面,非常适合快速验证服务端逻辑。

如果你喜欢图形化工具,Postman 也支持 WebSocket 连接。新建一个 WebSocket Request,地址填ws://localhost:3001/chat,点击 Connect。它的界面会分上下两块,上面是手写的原始消息,下面是服务端推送的消息流,调试起来也很直观。工具不在多,关键是你要敢先用工具把服务端验证到“稳定”状态,再开始写前端。

3.4 用 curl 亲手模拟一次完整握手

这才是今天最硬核的一步。你不需要真的浏览器,用 curl 发一个带 Upgrade 头的 HTTP 请求,就能看到服务器怎么回复 101:

curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ http://localhost:3001/chat

注意,加了-N是为了关闭 curl 的缓冲,因为 101 之后服务端会立刻给你推一条 welcome 消息,但那已经是 WebSocket 帧了,curl 打印出来会是乱码。你只要看到响应头里出现HTTP/1.1 101 Switching ProtocolsSec-WebSocket-Accept字段,就可以确定服务端是正常工作的。

这一步的意义在于:它把“WebSocket 连接建立”这个看似神奇的过程,变成了一个你可以完全掌控的、看得见摸得着的 HTTP 交互。以后再有人跟你扯 WebSocket 多玄乎,你就让他跑一次 curl。

4. 跨域:WebSocket 也会被浏览器拦,但方式和 CORS 不一样

4.1 “Socket 有跨域吗”这个问题,要分两层回答

网上经常看到有人问“Socket 有跨域吗”,答案其实是分层的。TCP Socket 本身没有跨域概念,它是四层的东西,不关心域名。但浏览器的 WebSocket API 是跑在浏览器沙箱里的,浏览器出于同源策略的考虑,会在 WebSocket 握手阶段做 Origin 校验。所以结论是:如果服务端不校验 Origin,任何客户端都能连;但只要你的客户端是浏览器,Origin 校验就会起作用。

这和 Fetch/XHR 的 CORS 机制不一样。CORS 靠的是浏览器发起预检请求(OPTIONS),然后读取响应里的Access-Control-Allow-Origin头来决定是否放行。而 WebSocket 握手本身就是一次 GET 请求,浏览器会在请求头里自动带上Origin字段,服务器通过校验这个字段来决定接受还是拒绝连接,不接受也不会给你返回一个 CORS 错误头,而是直接拒绝握手。浏览器发现握手没成功,就报连接失败。

所以你会发现 WebSocket 跨域“没有标准化的服务端响应头”,它更多依赖服务端在握手阶段对 Origin 的检查。这也是为什么很多人照着 CORS 的思路配了一堆跨域头,WebSocket 照样连不上。

4.2 服务端 Origin 白名单:该收就得收

在开发环境,你可以允许任意 Origin 连上来,方便调试。但生产环境一定要做 Origin 白名单校验。用 Node 写一个最简版本:

const allowedOrigins = [ 'http://localhost:8080', 'https://your-domain.com' ]; const wss = new WebSocketServer({ server, path: '/chat' }); wss.on('connection', (ws, req) => { const origin = req.headers.origin; if (!origin || !allowedOrigins.includes(origin)) { console.log('[reject] 非法 Origin:', origin); ws.close(1008, 'origin not allowed'); return; } // 后续业务逻辑... });

注意,ws.close(1008, ...)是 WebSocket 规范里专门用来表示“策略违规”的关闭码。客户端收到之后,close事件里的code会变成 1008,这样前端也能明确知道是服务端拒绝了,而不是网络断了。如果你在浏览器直连服务端,报错信息不够直观,建议先看 Network 面板里握手请求有没有发出、状态码是什么。

4.3 Spring Boot 配置参考:跨域白名单怎么写

如果你用的是 Spring Boot,原理完全一样,只是配置入口不同。在注册 WebSocket Handler 的时候,用setAllowedOrigins指定允许的来源:

@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatHandler(), "/chat") .setAllowedOrigins("http://localhost:8080"); } }

新版 Spring 里setAllowedOrigins不支持*和带路径的 Origin,如果你有更灵活的需求,可以改用setAllowedOriginPatterns,它支持通配符模式。但不管怎么配,原则都是:只放行你信任的前端站点,不要把跨域开放成默认状态。

4.4 跨域之外,还要记得防“伪装请求”

服务端校验 Origin 不只是为了解决跨域问题,它更重要的作用是防 CSRF 类攻击。你想一下,如果用户的浏览器里已经登录了你的站点,Cookie 自动携带,这时候用户打开了一个恶意页面,恶意页面向你的 WebSocket 服务发起连接。如果没有 Origin 校验,服务端看到带 Cookie 的握手请求就以为用户本人在操作,连接建立之后恶意页面就能替用户做任何事。

所以生产环境我一般会再加一层鉴权:要么在握手 URL 里带一个短期 token,要么在握手后的第一条消息里校验 token,要么在子协议里传 token。别只依赖 Origin,Origin 只是第一道门,token 才是真正验证身份的钥匙。

顺带说一句,如果有 Nginx 或其他反向代理,你要确保它把 Upgrade 相关的头透传给了后端。Nginx 最简配置参考:

location /chat { proxy_pass http://backend:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header Origin $http_origin; }

不加UpgradeConnection这两行,Nginx 默认会按普通 HTTP 请求处理,WebSocket 握手就会卡在代理层,后端永远收不到真正的 Upgrade 请求。

5. 常见问题与排查技巧实录

做 WebSocket 这几年,我踩过的坑和帮别人排查过的问题,大部分都集中在握手阶段和部署阶段。我把最常见的情况整理成一张速查表,方便你遇到问题时直接对照。

现象可能原因处理方式
握手响应 404服务端没监听该路径,或路径不匹配检查 WebSocket 路径,如/chat是否前后端一致
握手响应 400Sec-WebSocket-Key 或 Version 缺失检查客户端是否走标准 WebSocket 库,不要手搓握手
握手响应 403服务端 Origin 白名单拒绝了来源查看服务端日志,确认当前页面 Origin 是否在白名单
浏览器报错 1006连接被非正常关闭,无 close 帧1006 是浏览器侧表现,必须结合服务端日志定位
能连上但收不到消息服务端路由/广播逻辑问题在服务端 message 事件里打日志,确认消息有没有进来
连接过一会儿自动断开没做心跳,被网关或代理超时断开前后端约定心跳包,定时发送 ping/pong
HTTPS 页面连 ws 报错混合内容被浏览器拦截页面是 HTTPS 时,WebSocket 必须使用 wss://

这里我特别想展开说一下 1006。很多前端同学看到WebSocket connection failed: 1006就以为网络断了,其实 1006 表示“连接异常关闭且没有收到 close 帧”。它本身不是一个关闭原因,而是一个结果。可能是服务端进程崩了,可能是 Nginx 超时,可能是服务端调用了ws.terminate()强杀连接,也可能是 Origin 被拒但服务端直接销毁了 socket 而不是发 close 帧。排查 1006 的唯一正确姿势就是去服务端日志里看连接断开前发生了什么,不要只对着浏览器发呆。

心跳机制也是老生常谈但总有人忘。WebSocket 规范里有 ping/pong 帧,但很多场景下大家用的库不会自动发,需要你手动定时发。我自己常用的方案是:客户端每隔 30 秒发一条应用层心跳消息,服务端收到后原样回复;如果客户端连续 3 个周期没收到回复,就主动重连。这样做的好处是不依赖协议层的 ping/pong,实现简单,而且能顺带验证消息通路是否真的通了。

6. 我最后补一句个人体会

把 WebSocket 当“新协议”学,是最容易走偏的路。它真正复杂的地方不在协议本身,而在它和 HTTP 基础设施之间的边界上:握手要走 HTTP 的路由和鉴权,握手之后连接又脱离了 HTTP 的管控,反向代理要特殊配置,浏览器安全策略还会介入。你只要抓住“借 HTTP 握手”这条主线,再配合一个能抓到 101 响应的调试环境,大部分问题都能顺藤摸瓜解决。

我现在带人的时候,要求团队新人必须能独立用 curl 复现一次握手,用 wscat 验证一次服务端,再用浏览器连一次。这三步走完,WebSocket 在他们眼里就不再是玄学,而是一个可以拆解、可以验证、可以排查的普通网络功能。希望这篇也能帮你把这一层窗户纸捅破。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询