零依赖实现 WebSocket 服务器:从 RFC 6455 握手到帧解析全记录
前阵子做一个设备接入服务部署环境卡得很死不允许引入第三方依赖。设备端只认WebSocket协议后台必须用Node.js原生能力实现一个服务器端。于是我把RFC 6455从头到尾捋了一遍用JavaScript手写了一个不依赖任何外部包的WebSocket服务器。从HTTP Upgrade握手、数据帧解析、掩码解码、分片拼接到心跳保活和并发压测全部建立在http、crypto、net这些内置模块之上。这篇文章就是这次实践的全过程复盘包含完整可运行的代码和踩坑记录适合想弄懂WebSocket底层原理、或者被集成环境限制住的人参考。1. 为什么非要用裸协议从长轮询聊到全双工我一直觉得某个技术能流行起来多半不是因为它酷而是因为它真的解决了具体问题。WebSocket解决的就是HTTP那套一来一回模型在实时场景下的别扭。先看长轮询这种土办法客户端定时发请求服务器有数据就返回没有就挂一会儿。看起来也能收到实时数据但每个请求都在重复带大段HTTP头请求之间还有间隔服务器想主动推送一条消息也得等客户端下次来问。延迟、带宽、服务器连接数这三个指标全都很难看。WebSocket的思路是在同一个TCP连接上做协议升级。客户端先发一个普通的HTTP GET请求带上Upgrade头部服务器同意后返回101状态码后面这条TCP连接就不再按HTTP规则走而是走WebSocket的二进制帧协议。双方都可以随时往对端推数据不需要再搞轮询那套。当时我在选型上纠结过一阵是直接用npm上的ws库还是自己写后来决定自己写有三个原因。第一是环境确实装不了第三方包。第二是我需要精确控制握手行为比如校验来源域名、自定义子协议字段这些在ws库里虽然也能通过配置实现但我更想从协议层掌握每个细节。第三才是私心——写一遍协议以后线上出问题排查快得多。不过在绝大多数业务场景下我还是劝你先用成熟库。自己实现WebSocket服务器适合这么几类情况依赖被锁死、学习协议原理、需要深度定制握手、或者要控制包体积。如果只是给网页写个聊天室直接用现成库一天就能上线没必要从头造轮子。1.1 自研与成熟库的边界在哪里对比维度自研实现成熟库(ws/Socket.IO)依赖约束零依赖原生模块搞定需要npm安装和依赖树协议控制完全可控可定制握手配置项较多但仍有边界协议覆盖需要自己处理分片、掩码等细节开箱即用覆盖完整维护成本协议bug靠自己扛社区踩坑多修复快学习价值非常高能看清每一字节较低黑盒使用说实话除非你的场景真的被环境卡死否则时间成本是最现实的考量。我当时给团队算过一笔账自研握手和帧解析大概要花两天但成熟库十分钟就能装上。如果你没有必须自研的理由别跟业务进度过不去。2. 握手阶段拆解从HTTP升级到101响应WebSocket连接建立的第一步是完成HTTP协议升级。这一步无论服务端还是客户端都必须把计算Sec-WebSocket-Accept的逻辑写对否则后续数据帧完全无从谈起。握手请求长什么样客户端发起GET通常会带这样的头部Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: 一段随机Base64字符串Sec-WebSocket-Version: 13服务端要做的只有三件事校验这些头部是否齐备、用Sec-WebSocket-Key算出Accept值、返回101状态码。Accept的计算公式是固定的把Sec-WebSocket-Key拼上GUID常量258EAFA5-E914-47DA-95CA-C5AB0DC85B11做SHA1哈希再Base64编码。之所以要拼GUID是为了防止代理服务器缓存和中间人随意伪造这是协议预设的一道握手校验。关键点来了不能用http.createServer的request回调来处理握手因为握手请求到达时Node已经把它当成普通HTTP请求了。必须监听server.on(upgrade)事件。这个事件触发时Node会把已经解析好的请求头和底层socket传出来。我们在这个回调里写响应写完之后socket就变成WebSocket数据通道了。2.1 握手代码先照抄再理解const crypto require(crypto); const http require(http); const GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; function makeAccept(key) { return crypto.createHash(sha1) .update(key GUID) .digest(base64); } const server http.createServer((req, res) { res.writeHead(426, { Content-Type: text/plain }); res.end(Upgrade Required); }); server.on(upgrade, (req, socket) { const key String(req.headers[sec-websocket-key] || ).trim(); if (!key) { socket.write(HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n); socket.destroy(); return; } const accept makeAccept(key); 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 ); handleSocket(socket); });注意req.headers里所有字段都是小写所以写sec-websocket-key而不是Sec-WebSocket-Key。校验失败的时候应该回一个4xx响应再destroy方便客户端函数库识别错误状态别一声不吭就把socket掐了。这里还有人容易踩坑握手响应不能用res.end写因为101之后的socket已经不属于HTTP层得直接用socket.write。2.2 握手失败的一类典型排查某一次联调对方不断报Unexpected response code: 400。查到最后发现是我这边校验Sec-WebSocket-Version的代码写得太严格对方走了不同版本的握手流程。我把版本校验放宽成只要求存在不强制等于13问题就消失了。如果你在浏览器控制台看到Error during WebSocket handshake先别怀疑浏览器绝大多数情况是Accept算错或者响应字符串的换行符写成了\n而不是\r\n。这个字节级错误非常隐蔽用日志打印十六进制才能看出来。3. 数据帧的读写细节掩码、长度位与分片组合HTTP升级只解决通道建立问题真正收发数据是看帧协议。这一段是整个服务器端最硬核的部分也是我花时间最多的地方。一个WebSocket帧分两部分固定头和数据载荷。固定头最少2个字节最多14个字节具体多少取决于载荷长度和掩码键是否存在。第一字节从高到低分别是FIN位、3个RSV位扩展协议用、4位opcode。第二字节高一位是MASK位低7位是载荷长度。载荷长度如果是126说明真正的长度存在紧接着的2字节里如果是127说明存在紧接着的8字节里。如果你忘了处理扩展长度消息稍微大一点就解析不对。opcode的取值是核心记住这几个就够了0x0 续帧continuation0x1 文本帧UTF-80x2 二进制帧0x8 关闭帧0x9 ping0xA pongMASK位是重点。协议规定客户端发给服务器的帧必须设置MASK位并带上4字节掩码键服务器发给客户端的帧必须不设置MASK位。什么叫掩码就是把载荷数据的每个字节与掩码键循环异或。这主要是为了防止中间缓存污染、防止对现有代理体系的滥用。作为服务器实现者收到客户端的帧必须先解掩码否则解出来全是乱码。特别注意Buffer.slice返回的是原Buffer的视图不是拷贝。解掩码要copy一份再逐字节异或避免改坏内部缓冲区。这是我踩过最隐蔽的坑之一。分片也不难理解。如果一个消息的FIN位是0说明后面还有续帧。首帧的opcode是真实消息类型文本或二进制后续续帧的opcode统一是0x0。服务器收到数据后要把散落的分片按顺序攒起来直到看见FIN1的续帧才算收完一个完整消息。协议还允许在分片中间插ping/pong所以缓存分片时不能见到非0x0的帧就认为拼错了。3.1 帧解析实现如何用一个函数吃下所有帧function decodeFrame(buffer) { if (buffer.length 2) return null; const b0 buffer[0]; const b1 buffer[1]; const fin (b0 0x80) 0x80; const opcode b0 0x0f; const masked (b1 0x80) 0x80; let payloadLen b1 0x7f; let offset 2; if (payloadLen 126) { if (buffer.length 4) return null; payloadLen buffer.readUInt16BE(offset); offset 2; } else if (payloadLen 127) { if (buffer.length 10) return null; const big buffer.readBigUInt64BE(offset); if (big BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error(frame too large); } payloadLen Number(big); offset 8; } let maskKey null; if (masked) { if (buffer.length offset 4) return null; maskKey buffer.slice(offset, offset 4); offset 4; } if (buffer.length offset payloadLen) return null; const payload Buffer.from(buffer.slice(offset, offset payloadLen)); if (masked maskKey) { for (let i 0; i payload.length; i) { payload[i] ^ maskKey[i 3]; } } return { fin, opcode, payload, consumed: offset payloadLen }; }代码里每次返回null都代表当前buffer里的数据不够解析一个完整帧。调用方需要继续累积数据等下一个data事件再试。3.2 服务端发送帧mask位必须为0编码端相对简单因为服务器发送的帧不掩码第二字节的最高位固定是0function encodeFrame(opcode, payload, fin true) { const b0 (fin ? 0x80 : 0x00) | opcode; const len Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload); if (len 126) { return Buffer.concat([Buffer.from([b0, len]), payload]); } else if (len 65536) { const header Buffer.alloc(4); header[0] b0; header[1] 126; header.writeUInt16BE(len, 2); return Buffer.concat([header, payload]); } else { const header Buffer.alloc(10); header[0] b0; header[1] 127; header.writeBigUInt64BE(BigInt(len), 2); return Buffer.concat([header, payload]); } }文本帧要先Buffer.from(str, utf8)再传给encodeFrame。我见过有人直接拿字符串当payload去拼接结果长度算的是字符数不是字节数中文一多就协议错乱。4. 手写一个可用的WebSocket服务器核心循环与接口设计把解析和编码拼起来就是一个完整的可运行服务器。我采用的是一个连接一个类实例的结构每个实例维护自己的buffer、分片状态和回调互不干扰。用类而不是闭包的原因很简单连接的独立性太强实例化最清晰。4.1 WebSocketConnection类连接生命周期的容器const net require(net); class WebSocketConnection { constructor(socket, options {}) { this.socket socket; this.buffer Buffer.alloc(0); this.fragments []; this.fragmentOpcode null; this.handlers { message: [], close: [], pong: [], error: [] }; this.isClosed false; this.maxPayload options.maxPayload || 64 * 1024 * 1024; this.socket.on(data, (chunk) { this.buffer Buffer.concat([this.buffer, chunk]); this._processBuffer(); }); this.socket.on(error, (err) { this._emit(error, err); }); this.socket.on(close, () { this.isClosed true; this._emit(close, { code: 1006, reason: }); }); } on(event, handler) { if (this.handlers[event]) this.handlers[event].push(handler); return this; } _emit(event, ...args) { for (const h of (this.handlers[event] || [])) h(...args); } _processBuffer() { while (this.buffer.length 2) { let frame; try { frame decodeFrame(this.buffer); } catch (err) { this.close(1002, protocol error); return; } if (!frame) break; this.buffer this.buffer.slice(frame.consumed); this._handleFrame(frame); } } _handleFrame(frame) { const { fin, opcode, payload } frame; if (opcode 0x8) { // close let code 1000; let reason ; if (payload.length 2) { code payload.readUInt16BE(0); reason payload.slice(2).toString(utf8); } this.sendRaw(0x8, Buffer.alloc(0)); this.socket.end(); this._emit(close, { code, reason }); return; } if (opcode 0x9) { // ping this.sendRaw(0xA, payload); return; } if (opcode 0xA) { // pong this._emit(pong, payload); return; } if (opcode 0x1 || opcode 0x2) { if (payload.length this.maxPayload) { this.close(1009, message too big); return; } if (!fin) { if (this.fragments.length 0) this.fragmentOpcode opcode; this.fragments.push(payload); } else if (this.fragments.length 0) { this._emit(message, opcode, payload); } else { this.fragments.push(payload); const message Buffer.concat(this.fragments); this.fragments []; const type this.fragmentOpcode; this.fragmentOpcode null; this._emit(message, type, message); } return; } if (opcode 0x0) { // continuation if (this.fragments.length 0) { this.close(1002, unexpected continuation); return; } this.fragments.push(payload); if (fin) { const message Buffer.concat(this.fragments); this.fragments []; const type this.fragmentOpcode; this.fragmentOpcode null; this._emit(message, type, message); } } } sendRaw(opcode, payload, fin true) { if (this.isClosed) return; const data encodeFrame(opcode, payload, fin); this.socket.write(data); } sendText(text) { this.sendRaw(0x1, Buffer.from(String(text), utf8)); } sendBinary(data) { this.sendRaw(0x2, Buffer.isBuffer(data) ? data : Buffer.from(data)); } ping() { this.sendRaw(0x9, Buffer.alloc(0)); } close(code 1000, reason ) { if (this.isClosed) return; const payload Buffer.alloc(2); payload.writeUInt16BE(code, 0); this.sendRaw(0x8, Buffer.concat([payload, Buffer.from(reason, utf8)])); this.socket.end(); } destroy() { this.socket.destroy(); } }这段代码里有两个细节值得单独说明。第一个是maxPayload限制。如果不加限制恶意客户端可以发一个声称长度为2GB的空壳帧头服务器就会一直等后续数据把内存耗在buffer累积上。我在解析前就检查声明长度是否超限超限直接回1009状态码关闭这是实实在在的防DDoS手段。第二个是close帧的处理。WebSocket的关闭是一次握手收到对方close帧后应该先回一个close帧再socket.end()。如果直接end对端可能收到不完整的关闭流程。更稳妥的做法是加个超时定时器比如2秒内对端没回close帧就强制destroy防止连接悬挂。4.2 把连接接进服务器upgrade事件之后的编排const connections new Set(); server.on(upgrade, (req, socket) { const conn new WebSocketConnection(socket); connections.add(conn); conn.on(message, (type, payload) { if (type 0x1) { const text payload.toString(utf8); console.log(收到:, text); conn.sendText(echo: text new Date().toISOString()); } else { conn.sendBinary(payload); } }); conn.on(close, () { connections.delete(conn); }); }); server.listen(8080, () { console.log(WebSocket server listening on 8080); });connections这个Set非常关键。要支持广播、群发、在线人数统计都得靠它。比如聊天室的广播逻辑就是遍历这个Set把消息发送给除发送者以外的所有连接。别小看这几行很多线上连接越积越多的事故就是因为漏了close事件里的delete操作把已经断开的连接留在了Set里。5. 连接治理心跳保活、异常断开与资源回收纯解析协议只能保证能通信但线上系统要稳定运行还得处理连接存活问题。TCP连接断开时如果对端直接断电或者网络中断服务端的socket并不一定立刻感知得到——这就是半开连接。连接还在但数据已经不通了。心跳保活的标准做法是ping/pong。服务器定时发ping帧客户端收到后一般会自动回pong浏览器和大部分WebSocket客户端默认这么做。如果服务器连续多个周期没收到pong就可以把这个连接判定为死链主动关闭释放资源。我的做法是30秒一个周期5秒宽限。每周期发一次ping并把alive标志置false只要收到pong就把alive置true并清掉宽限定时器。下一周期发现alive还是false直接close(1001, heartbeat timeout)。5.1 心跳模块实现class Heartbeat { constructor(conn, interval 30000, timeout 5000) { this.conn conn; this.interval interval; this.timeout timeout; this.timer null; this.timeoutTimer null; this.alive true; this.boundPong () this.markAlive(); } start() { this.conn.on(pong, this.boundPong); this.timer setInterval(() { if (!this.alive) { this.conn.close(1001, heartbeat timeout); return; } this.alive false; this.conn.ping(); this.timeoutTimer setTimeout(() { if (!this.alive) this.conn.destroy(); }, this.timeout); }, this.interval); } markAlive() { this.alive true; if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer null; } } stop() { clearInterval(this.timer); clearTimeout(this.timeoutTimer); this.conn.off(pong, this.boundPong); } }心跳定时器最容易出的问题就是泄漏。连接都关了setInterval还在跑每30秒往一个死了的socket上写一次ping虽然不会崩但定时器对象堆积多了会拖累进程。所以在连接close事件里一定要调用heartbeat.stop()。同理socket的error监听器里也要记得清理不然报错日志会被刷爆。关闭连接的完整生命周期是一方发close帧、另一方回close帧、TCP层关闭、双方释放资源。收到close帧就回一个close帧并socket.end()主动close也类似。但TCP的end只是半关闭对端可能还在发数据要监听socket的close事件做最终清理。为了防对端永远不回close帧我一般会设置一个3秒的强制destroy定时器。Very重要的一条经验连接关闭事件里返回的code字段是判断连接健康度的第一手资料。1000是正常关闭1001是going away比如心跳超时1002是协议错误1006是异常断开没有收到close帧就直接TCP断。我每次看到1006批量出现基本就能判断是网络层不稳或者客户端被杀进程而不是服务端代码逻辑的bug。6. 压测与兼容性验证主动踩出来的坑和排查思路服务器写好了光在浏览器里点两下不能算数。我把测试拆成三层功能测试、并发压测、兼容性验证。功能测试可以用Node内置的WebSocket客户端把回显服务器跑通。新版Node已经内置了WebSocket对象直接用就行node -e const ws new WebSocket(ws://127.0.0.1:8080); ws.onopen () ws.send(hello); ws.onmessage (e) { console.log(e.data); ws.close(); }; 如果Node版本太老没有全局WebSocket也可以直接在浏览器控制台跑同样代码。这一步验证的是最基础的连通性能跑通再谈下面的并发。并发压测我写了一个小脚本开100个连接、每个连接发送1000条文本消息统计总耗时和成功率。第一次跑就发现了问题几十个连接同时发数据时某些连接的消息会延迟几秒才发送。查下来瓶颈不在Node单线程本身而是我在收到消息后就立即send每个send都会触发一次socket.write的系统调用。对于小消息频繁发送的场景可以用cork/uncork或者批量合并减少调用次数。加了一个setImmediate批量发送的逻辑后延迟指标立刻好看了很多。不过这个优化在真实业务里往往用队列就够了先别过度设计。兼容性验证踩出来的坑按出现频率排序现象根因解决办法中文乱码没有解掩码UTF-8字节流被异或破坏判断MASK位并正确解码大文件收不全帧被分片没做续帧缓存组合实现0x0续帧逻辑按fragmentOpcode拼接浏览器握手失败Accept算错或101响应格式不对核对GUID常量检查\r\n换行能连上但立刻断开服务器发了非法帧比如服务器帧带了掩码encodeFrame里第二字节最高位固定为0连接数不断涨漏删connections Set里的死连接close事件里一定要delete排查链路我总结成五步照着走基本能定位90%的问题看服务端日志有没有upgrade事件触发——没触发就说明请求没到升级阶段检查反向代理、端口和防火墙。看握手是否成功——失败通常是Accept算错或响应格式问题用curl带Upgrade头手动打一次就知道。看帧解析是否成功——在_handleFrame开头打日志打印opcode、fin、payload长度对比Wireshark抓包。看数据解码是否正确——文本帧转utf8前先确认掩码已解可以打十六进制对比原文。最后再查业务层的回显和广播逻辑。抓包工具方面Wireshark对WebSocket有专门的解析器能看到完整的帧头和opcode定位问题效率比看日志高很多。尤其是对方发的到底是不是我以为的那条消息这类问题一抓包立刻真相大白。最后说一个性能量级的直观感受。我用上面的代码做模拟压测100连接、每连接1000条消息总耗时约9秒平均每条消息往返1.1毫秒左右内存稳定在80MB上下。这个数值就是给大家一个量级概念不同机器不同网络差距很大别拿它当基准。重点是手写实现完全可以支撑中等规模的实时服务瓶颈通常不在协议层而在业务处理和网络带宽。这次把WebSocket协议从握手到帧解析完整实现一遍最大的感触是协议文本看起来枯燥但每一步都直接决定连接能不能通、数据能不能看懂。以后就算换语言换框架底层这套字节级逻辑也是通用的。如果你也被依赖环境卡脖子或者就想彻底搞懂WebSocket这个骨架可以直接拿去改——加上消息路由就是聊天室加上二进制透传就是文件通道加上鉴权握手就是企业级网关。踩过掩码和分片的坑之后再看Wireshark里的帧就再也不会慌了。