WebSocket不是新协议?借HTTP握手讲透原理、跨域与排查
我第一次带团队做实时聊天模块的时候团队里一个后端同学很认真地把 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 x3JJHMbDL1EzLkh9GBhXDw258EAFA5-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 langzh-CN head meta charsetUTF-8 / titleWebSocket Demo/title /head body h1WebSocket Demo/h1 input idinput typetext placeholder输入消息 / button idsend发送/button pre idlog/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 请求就能看到服务器怎么回复 101curl -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 Protocols和Sec-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。别只依赖 OriginOrigin 只是第一道门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; }不加Upgrade和Connection这两行Nginx 默认会按普通 HTTP 请求处理WebSocket 握手就会卡在代理层后端永远收不到真正的 Upgrade 请求。5. 常见问题与排查技巧实录做 WebSocket 这几年我踩过的坑和帮别人排查过的问题大部分都集中在握手阶段和部署阶段。我把最常见的情况整理成一张速查表方便你遇到问题时直接对照。现象可能原因处理方式握手响应 404服务端没监听该路径或路径不匹配检查 WebSocket 路径如/chat是否前后端一致握手响应 400Sec-WebSocket-Key 或 Version 缺失检查客户端是否走标准 WebSocket 库不要手搓握手握手响应 403服务端 Origin 白名单拒绝了来源查看服务端日志确认当前页面 Origin 是否在白名单浏览器报错 1006连接被非正常关闭无 close 帧1006 是浏览器侧表现必须结合服务端日志定位能连上但收不到消息服务端路由/广播逻辑问题在服务端 message 事件里打日志确认消息有没有进来连接过一会儿自动断开没做心跳被网关或代理超时断开前后端约定心跳包定时发送 ping/pongHTTPS 页面连 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 在他们眼里就不再是玄学而是一个可以拆解、可以验证、可以排查的普通网络功能。希望这篇也能帮你把这一层窗户纸捅破。