JS手写WebSocket服务端:从握手到心跳的协议拆解
前两年我给自己做过一个实时消息推送的模拟项目用的就是JS实现WebSocket服务器端。从零手写握手、解析数据帧、维护心跳虽然代码量不大但把整个协议跑通之后再去看各种封装库的源码会顺畅很多。很多前端同学天天用new WebSocket(url)却不清楚服务端为什么返回101、数据帧长什么样、连接又是怎么保持的。这篇文章就把WebSocket服务器端的核心细节拆开讲一遍既适合想做聊天室、通知中心、协同编辑的人也适合对实时通信原理感兴趣的开发者。目标不是让你放弃成熟库而是让你在能直接调库的前提下知道底层发生了什么。1. 为什么选中JS来写WebSocket服务端1.1 一条长连接到底难在哪WebSocket和普通HTTP最大的区别是连接建立后不再走“请求-响应”模式。HTTP下服务器不能主动给客户端发消息想做实时推送就得靠轮询客户端频繁发请求问“有数据吗”效率低延迟也高。WebSocket一旦握手成功双方可以随时向对方扔数据真正解决了服务端主动推送的问题。但“长连接”听起来简单做起来并不轻松。第一个难点是状态管理。一个HTTP请求处理完就结束了资源释放很干净WebSocket连接会一直挂在服务器上每一路连接都要记住自己的握手状态、鉴权信息、最后活跃时间、是否已经发送过关闭帧。连接多了以后这些状态就是一笔不小的内存开销。第二个难点是并发。用传统多线程模型写长连接服务每来一个连接就起一个线程两万路连接就是两万个线程线程切换会把CPU拖垮。第三个难点是TCP流本身的特性数据在网络上是一截一截的流应用层需要自己处理“半包”和“粘包”也就是这次收到的Buffer里可能只有半帧消息也可能塞了两帧完整消息拆错了就全乱套。这也是为什么很多刚从HTTP转过来的人会觉得WebSocket服务端难写不是协议难而是对数据流的处理方式变了。1.2 JS事件循环天然适合处理长连接Node.js采用的是单线程事件循环模型配合底层异步I/O可以让一个进程同时维护大量连接。它的优势在于连接没有到来时不会占着线程干等而是由系统统一监听事件连接有数据到了事件循环再去处理。这个模型特别适合WebSocket这种“大部分时间都不说话偶尔突然来一下”的场景跟现实里客服坐在工位上等电话很像电话没响的时候他可以干别的响了一声才去接。所以用JS写WebSocket服务端最大的收益是开发成本低。前端本来就要处理WebSocket对象、JSON消息格式现在后端也用同一门语言连数据序列化都不用跨语言反复对字段直接一套代码搞定。另外Node生态里已经有非常成熟的WebSocket实现轮子很全不需要自己折腾底层的加密扩展、分片重组、优雅关闭这些边角问题。当然也要记住单线程的短板如果某个连接的某个回调里做了耗CPU的重计算比如解压大文件、处理复杂图片事件循环就会被卡住其他人的心跳、消息全都得排队。真碰到这种场景得把重活丢给worker_threads或子进程别阻塞主线程。1.3 选型原生Node模块还是依赖第三方库做WebSocket服务端摆在面前的路线大致有三条自己用http模块写一套、直接用ws库、用更上层的Socket.IO这类框架。三者的差别很直观方案协议标准学习价值生产可用度适合场景原生http模块手写自己控制容易踩坑极高能彻底搞懂WebSocket低边角情况多教学、协议研究ws库标准RFC6455成熟稳定中用起来方便高很多项目都在用生产环境实时通信Socket.IO自带协议兼容WebSocket低封装太重高但协议不标准快速搭建、需要自动重连的业务我自己当初是为了搞懂原理先用原生方式写了一遍后来做实际业务就换成了ws。如果你刚开始接触我也建议两条路都走一遍先手写握手和帧解析再用ws库实现同一个功能。你会发现用库的时候连“掩码”“分片”“心跳”这些概念都不用管那是因为库已经把细节消化掉了反而容易让人忽略它们的存在。2. WebSocket握手与协议细节拆解2.1 HTTP升级请求从普通请求到101 Switching ProtocolsWebSocket连接的起点不是一个特殊协议而是一次普通HTTP请求。浏览器发送的握手请求大概长这样GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端收到这个请求后如果同意切换协议就要返回状态码101 Switching Protocols同时带上Upgrade、Connection和Sec-WebSocket-Accept头。这里的关键是101不是200。101表示HTTP协议要切换到WebSocket协议后续双方不再按HTTP规则通信而是按WebSocket帧格式直接对话。如果用200响应客户端会认为这是一个普通HTTP页面不会建立长连接。在Node的http模块里当客户端发出升级请求时服务端并不会走普通的request事件而是触发upgrade事件。此时回调会拿到原始的req和底层socket写响应头、开始帧通信都得在这层做。2.2 Sec-WebSocket-Accept的计算原理握手过程中最容易让人困惑的是Sec-WebSocket-Accept是怎么来的。它不是一个随机字符串而是用固定算法生成的取出客户端发来的Sec-WebSocket-Key拼上一个固定的GUID字符串做SHA1哈希再用Base64编码。固定GUID是协议里写死的258EAFA5-E914-47DA-95CA-C5AB0DC85B11为什么需要这个固定字符串说白了是为了防止普通HTTP客户端或者中间层误把WebSocket请求当成普通GET请求去处理。加上这个固定值之后只有真正实现了WebSocket协议的服务端才能算出正确的Accept值客户端收到后可以自己验证确认对面不是一个恰好返回101的普通HTTP服务。Node里算起来很简单const crypto require(crypto); const key req.headers[sec-websocket-key]; const GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; const accept crypto.createHash(sha1) .update(key GUID) .digest(base64);不要把Sec-WebSocket-Key当成密钥或者Token它的作用更像是一次随机挑战值。真正做鉴权的时候通常需要在URL参数、Cookie或者子协议里带认证信息不能靠这个Key保证安全。2.3 帧结构mask、opcode、payload lengthWebSocket协议中的数据不是流式字节而是被组织成一帧一帧的。每一帧头部有固定的结构我建议你在脑子里记住这一段位段长度含义FIN1bit是否为消息的最后一帧RSV1-33bit扩展保留位一般置0opcode4bit帧类型文本、二进制、关闭、Ping等MASK1bit客户端发来的帧是否使用掩码Payload length7bit / 16bit / 64bit负载长度分三种情况表示Masking key4字节如果有掩码用于解码负载Payload data可变实际数据opcode是最常用的几个值0x1表示文本帧0x2表示二进制帧0x8表示关闭连接0x9是Ping0xA是Pong。协议规定客户端发给服务端的帧必须设置MASK服务端发给客户端的帧不能设置MASK。为什么要这么设计主要为了防止早期缓存投毒类攻击强制客户端加掩码服务端收到后再把掩码解开相当于多一层校验。明白这一点写解析代码时就不会忘了处理掩码。Payload length最容易被忽略。如果长度小于126直接用7位就能表示如果长度在126到65535之间头两字节后面要再读2个字节作为16位长度如果长度更大要读8个字节作为64位长度。很多初学者只处理了第一种情况一传大文本就解析失败就是这个原因。3. 核心实现从零写一个可用的WebSocket服务器3.1 用Node原生模块完成第一个握手先跑通最基础的一步收到升级请求计算Accept返回101响应。我用http.createServer创建普通服务但不在request事件里处理业务而是监听upgrade事件。代码如下const http require(http); const crypto require(crypto); const GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; const server http.createServer((req, res) { res.writeHead(400); res.end(); }); server.on(upgrade, (req, socket) { const key req.headers[sec-websocket-key]; if (!key) { socket.write(HTTP/1.1 400 Bad Request\r\n\r\n); socket.destroy(); return; } const accept crypto.createHash(sha1) .update(key GUID) .digest(base64); socket.write( HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: ${accept}\r\n \r\n ); }); server.listen(8080, () { console.log(WebSocket server listening on 8080); });写响应头时一定要用\r\n换行最后还要留一个空行也就是再补一个\r\n。这两个字符少了或者顺序不对客户端会一直等待响应连接卡在握手阶段。writeHead、res.end这些HTTP层面的API在这里不适用因为升级之后就要直接在socket上写原始字节。3.2 解析与组装WebSocket数据帧握手完成之后客户端随时可能发来数据帧。这一步最核心的是处理TCP粘包和半包。Socket每次data事件带来的Buffer不一定正好是一帧。我建议用一块累积缓冲区不断往里追加然后循环尝试解析直到数据不足一帧为止。下面是我整理的一个解析函数function decodeFrames(buffer) { const frames []; let offset 0; while (true) { if (buffer.length - offset 2) break; const b0 buffer[offset]; const b1 buffer[offset 1]; const fin (b0 0x80) ! 0; const opcode b0 0x0f; const masked (b1 0x80) ! 0; let payloadLength b1 0x7f; let headerLength 2; if (payloadLength 126) { if (buffer.length - offset - 2 2) break; payloadLength buffer.readUInt16BE(offset 2); headerLength 2; } else if (payloadLength 127) { if (buffer.length - offset - 2 8) break; const bigLength buffer.readBigUInt64BE(offset 2); if (bigLength BigInt(1024 * 1024)) { throw new Error(payload too large); } payloadLength Number(bigLength); headerLength 8; } let maskKey null; if (masked) { if (buffer.length - offset - headerLength 4) break; maskKey buffer.slice(offset headerLength, offset headerLength 4); headerLength 4; } if (buffer.length - offset - headerLength payloadLength) break; let payload Buffer.from( buffer.slice(offset headerLength, offset headerLength payloadLength) ); if (masked maskKey) { for (let i 0; i payload.length; i) { payload[i] ^ maskKey[i % 4]; } } frames.push({ fin, opcode, payload }); offset headerLength payloadLength; } return { frames, consumed: offset }; }这段代码的核心是“读不到完整一帧就退出循环”。比如缓冲区里只有一个半帧第二次判断条件不足就返回已解析的帧和实际消耗的字节数。外部再把剩余Buffer留到下一次。我用Buffer.from包了一层避免slice共享内存带来的隐式修改风险否则解掩码的时候会影响原始缓冲区。3.3 实现文本消息收发与广播帧解析出来之后要能原样回复给客户端就需要一个编码函数。服务端发给客户端的帧不需要掩码所以比解析简单function encodeFrame(opcode, payloadBuffer) { const length payloadBuffer.length; let header; if (length 126) { header Buffer.alloc(2); header[1] length; } else if (length 65536) { header Buffer.alloc(4); header[1] 126; header.writeUInt16BE(length, 2); } else { header Buffer.alloc(10); header[1] 127; header.writeBigUInt64BE(BigInt(length), 2); } header[0] 0x80 | (opcode 0x0f); return Buffer.concat([header, payloadBuffer]); }如果发文本消息opcode传1最终第一字节就是0x81。0x80代表FIN置1表示这是单帧完整消息0x81中的1表示文本opcode。我在项目里用一个Set维护所有在线连接收到新消息后遍历所有连接做广播const clients new Set(); function handleTextMessage(socket, payload) { const text payload.toString(utf8); const out encodeFrame(1, Buffer.from(text)); [...clients].forEach(client { if (client.readyState open) { client.write(out); } }); }广播的时候用[...clients]先拍个快照是为了避免在发送过程中有连接关闭导致一边遍历一边增删集合而报错。实际项目里如果消息量大这种简单全量广播会有性能问题需要设计房间、订阅和定向推送但原理是一样的。3.4 心跳检测与掉线清理WebSocket连接看起来是长连接但实际网络链路中很多设备会对空闲连接做清理。没有人说话的空连接可能过几分钟就被某个网关或中间设备断掉。所以服务端要主动发Ping帧客户端收到后一般会自动回Pong帧服务端就知道这个连接还活着。我这里用一个lastActive时间戳标记每个连接的最后活跃时间定时器每30秒检查一次。超过90秒没有Pong就把连接销毁同时清掉定时器避免内存泄漏。socket.lastActive Date.now(); socket.isAlive true; const heartbeatTimer setInterval(() { if (Date.now() - socket.lastActive 90000) { socket.destroy(); clearInterval(heartbeatTimer); return; } socket.write(encodeFrame(0x9, Buffer.alloc(0))); }, 30000);如果是用ws库心跳会更简单因为库内置了ping()和pong事件只需要在pong回调里标记isAlive true再定时遍历清理。但原理不变主动探测超时关闭。4. 实操过程中踩过的坑与问题排查4.1 粘包问题与Buffer拼接我第一次用原生方式实现时直接在data事件里把当前chunk当完整帧解析结果消息一多就乱码。后来才意识到TCP是字节流不是消息流。它不会按照WebSocket帧的边界给你送数据底层可能把两帧放在同一个chunk里也可能把一个很长的帧拆成多个chunk。如果把TCP比作水管WebSocket帧就是水管里的一个个气泡气泡之间没有物理分隔需要应用层自己去切分。解决办法就是累积Buffer加循环解析也就是上面那段decodeFrames的思路。还有一个容易被忽视的点解析完成后要正确更新剩余缓冲区。很多人解析完一帧忘了用buffer buffer.slice(consumed)把已读部分丢掉结果缓冲区越攒越大最终内存飙升。排查的时候我会在data事件里打印一下chunk长度和十六进制前几个字节一般立刻能看出是不是粘包问题。4.2 客户端鉴权与Origin校验WebSocket的握手请求本质是HTTP所以起源校验是可行的。服务端可以读取req.headers.origin判断请求是不是来自允许的页面。但我要提醒一句千万别只靠Origin做安全边界。因为WebSocket客户端不只是浏览器命令行脚本、桌面程序、移动端都能发请求这些场景下Origin可以随意伪造。我实际做过的一个项目里是在握手URL上带一个短时效的签名参数服务端再解析校验。先创建一个不带业务逻辑的HTTP Server在upgrade事件里先做鉴权校验通过再继续握手不通过就直接销毁socket。这种方案比等连接建立后再过滤消息可靠得多因为一个WebSocket连接建立后能持续很久如果攻击者已经连上了再想踢掉往往要费更多功夫。4.3 反向代理层引发的空闲断开问题本地调试一切正常部署到服务器之后发现连接每隔一分钟左右就断一次这是我遇到过最典型的问题。根源不在Node进程而在WebSocket连接经过的反向代理层。不少反向代理默认会对空闲连接设置超时时间常见的是60秒左右只要超过这个时间没有数据流就会主动掐断连接。如果心跳间隔设置得比这条超时时间更长就必然会被切断。比如每90秒发一次Ping代理层在60秒先断了Ping根本发不过去。后来我把心跳间隔改成30秒同时确认代理层能支持WebSocket升级和长连接问题就消失了。这个经验告诉我在部署WebSocket服务时一定要把整个链路都梳理一遍客户端到负载均衡、负载均衡到Node进程每一层的空闲超时必须大于心跳间隔否则任何一层偷偷断连都会让你误以为是代码问题。4.4 性能观察与容量预估用JS实现的WebSocket服务器能撑到多少连接跟内存、文件描述符和处理速度都有关。每个连接除了Socket对象本身还有收发缓冲区、业务状态对象。简单估算时一路连接分配1KB缓冲区一万路就是10MB看起来很轻松但加上TCP内核缓冲区、定时器、集合结构实际占用会比理论值高不少。我自己的一个模拟项目里万级连接在单进程下是能跑的主要瓶颈反而不是CPU而是文件描述符上限和内存。如果连接数要继续往上走单进程方案就要考虑横向扩展。比较常见的做法是用多个Node进程每个进程维护一部分连接进程间用消息总线同步事件。一个进程收到广播把消息发到消息总线其他进程订阅后再转发给各自的本地客户端。这样连接分散到多进程单进程的压力就降下来了。5. 生产级建议与经验总结5.1 是否需要自己造轮子自己手写一遍WebSocket服务端最大的价值是搞懂协议而不是在生产环境替代成熟库。像分片重装、扩展协商、压缩、关闭握手这些细节协议里都写得清清楚楚但自己实现很容易漏。哪怕是错误码处理标准库也会比手写代码严谨得多。我在模拟项目里手写过一次之后再也不想在真实业务里维护那一套帧解析逻辑了到了生产环境老老实实换成了ws。不过这个手写过程仍然值得做。它帮你建立了一种直觉以后遇到WebSocket超时、乱码、握手失败你能知道问题大概出在哪个环节。是Accept算错了是掩码没解是长度没读够还是代理层切断了连接没有底层经验排查这些问题就像在黑箱里猜。5.2 使用ws库时的高频配置说明ws库的用法非常简洁但有几个配置值得注意。一个是通过noServer模式把WebSocket服务和已有的HTTP服务共用端口还能在upgrade事件里先做鉴权。代码形态大致是这样的const http require(http); const WebSocket require(ws); const server http.createServer((req, res) { res.writeHead(200); res.end(); }); const wss new WebSocket.Server({ noServer: true, maxPayload: 1 * 1024 * 1024 }); server.on(upgrade, (req, socket, head) { // 这里可以做自定义鉴权 wss.handleUpgrade(req, socket, head, (ws) { wss.emit(connection, ws, req); }); }); wss.on(connection, (ws, req) { ws.on(message, (data) { ws.send(data.toString()); }); }); server.listen(8080);maxPayload一定要按业务实际设置。如果默认允许上百兆的数据帧攻击者只要发一个超大Payload就能把你的内存吃光。noServer:true还有一个好处是你不用让WebSocket服务占用单独端口HTTP和WebSocket可以共存。perMessageDeflate压缩如果业务场景里文本很大可以启用如果都是小消息压缩带来的CPU开销可能不划算可以关掉。5.3 多进程与部署注意点部署WebSocket服务时文件描述符限制经常被忽略。Node在Linux上默认能打开的Socket数量可能只有1024也就是连接数稍微多一点就直接报EMFILE。我一般建议在服务管理配置里把文件描述符上限调高或者至少在启动前用ulimit -n确认当前限制不然代码写得再好也只能连到一千路就卡住。多进程方案的另一个关键点是连接保持。如果负载均衡层做随机轮询同一个WebSocket连接握手和后续消息可能会被分到不同进程连接就断掉了。通常需要开启会话保持或者按客户端IP哈希转发保证同一连接始终落在同一进程上。但这也会带来热点不均的问题一旦某个IP的连接特别多某个进程的压力就会很大。小型项目单进程加ws库一般够用真到了多进程阶段再引入外部消息总线做跨进程广播也不迟。我自己动手写过一遍握手和收帧之后最大收获不是用不上原生实现而是再看ws库源码时不觉得陌生了。以前遇到WebSocket连接总是掉线我只会怀疑服务器不稳定现在我会先查心跳间隔、再看代理层超时、然后翻业务代码有没有阻塞事件循环排查路径清晰很多。最后分享一个实用小技巧调试WebSocket协议时打开浏览器开发者工具里的Network面板找到WebSocket连接后切到Frames标签能看到每一帧的opcode和payload配合这里的帧结构知识很多时候一眼就能发现问题出在客户端还是服务端。