☰
零依赖实现 WebSocket 服务器:从 RFC 6455 握手到帧解析全记录
2026/10/13 21:40:34 网站建设 项目流程

前阵子做一个设备接入服务,部署环境卡得很死,不允许引入第三方依赖。设备端只认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: websocket
  • Connection: Upgrade
  • Sec-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 续帧(continuation)
  • 0x1 文本帧(UTF-8)
  • 0x2 二进制帧
  • 0x8 关闭帧
  • 0x9 ping
  • 0xA pong

MASK位是重点。协议规定:客户端发给服务器的帧,必须设置MASK位并带上4字节掩码键;服务器发给客户端的帧,必须不设置MASK位。什么叫掩码?就是把载荷数据的每个字节与掩码键循环异或。这主要是为了防止中间缓存污染、防止对现有代理体系的滥用。作为服务器实现者,收到客户端的帧必须先解掩码,否则解出来全是乱码。

特别注意:Buffer.slice返回的是原Buffer的视图,不是拷贝。解掩码要copy一份再逐字节异或,避免改坏内部缓冲区。这是我踩过最隐蔽的坑之一。

分片也不难理解。如果一个消息的FIN位是0,说明后面还有续帧。首帧的opcode是真实消息类型(文本或二进制),后续续帧的opcode统一是0x0。服务器收到数据后,要把散落的分片按顺序攒起来,直到看见FIN=1的续帧,才算收完一个完整消息。协议还允许在分片中间插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

编码端相对简单,因为服务器发送的帧不掩码,第二字节的最高位固定是0:

function 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%的问题:

  1. 看服务端日志有没有upgrade事件触发——没触发就说明请求没到升级阶段,检查反向代理、端口和防火墙。
  2. 看握手是否成功——失败通常是Accept算错或响应格式问题,用curl带Upgrade头手动打一次就知道。
  3. 看帧解析是否成功——在_handleFrame开头打日志,打印opcode、fin、payload长度,对比Wireshark抓包。
  4. 看数据解码是否正确——文本帧转utf8前先确认掩码已解,可以打十六进制对比原文。
  5. 最后再查业务层的回显和广播逻辑。

抓包工具方面,Wireshark对WebSocket有专门的解析器,能看到完整的帧头和opcode,定位问题效率比看日志高很多。尤其是"对方发的到底是不是我以为的那条消息"这类问题,一抓包立刻真相大白。

最后说一个性能量级的直观感受。我用上面的代码做模拟压测,100连接、每连接1000条消息,总耗时约9秒,平均每条消息往返1.1毫秒左右,内存稳定在80MB上下。这个数值就是给大家一个量级概念,不同机器不同网络差距很大,别拿它当基准。重点是:手写实现完全可以支撑中等规模的实时服务,瓶颈通常不在协议层,而在业务处理和网络带宽。

这次把WebSocket协议从握手到帧解析完整实现一遍,最大的感触是:协议文本看起来枯燥,但每一步都直接决定连接能不能通、数据能不能看懂。以后就算换语言换框架,底层这套字节级逻辑也是通用的。如果你也被依赖环境卡脖子,或者就想彻底搞懂WebSocket,这个骨架可以直接拿去改——加上消息路由就是聊天室,加上二进制透传就是文件通道,加上鉴权握手就是企业级网关。踩过掩码和分片的坑之后,再看Wireshark里的帧就再也不会慌了。

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

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

立即咨询