简介:这是一份基于Delphi的WebSocket服务端实现示例工程,面向需要在桌面应用中集成实时双向通信的Delphi开发者,尤其适合聊天、股票行情、在线协作等场景。工程核心代码完整展示了服务器初始化、HTTP Upgrade握手、数据帧解析、二进制与文本数据收发以及异常断开后的连接管理,覆盖WebSocket协议落地时最关键的技术环节。资源共32个文件,以22个Pascal源文件为主,另有工程文件、窗体定义、README文档、HTML和JS脚本等,压缩包仅134KB,便于快速阅读和二次修改;其中还包含专门用于验证功能的单元测试工程与演示程序。目前已有418人学习下载,适合需要理解Delphi下WebSocket服务端实现原理、并希望参考成熟代码上手实践的初中级开发者。
1. 用 DelphiWebsockets 写 WebSocket:老项目的实时化不用换掉整套栈
大多数开发者听到 WebSocket,第一反应是 Node.js 或者 Java 网关,很少有人想得起 DelphiWebsockets 这组东西。但真到了老车间、老控制台那一摊上位机代码面前,换语言比写长连接更冒险。我的情况是:Delphi 写的设备状态采集程序要往浏览器页面推实时数据,重新搭一套中间件意味着要把鉴权、设备绑定、协议格式全部平移过去,成本远超预期。最后选择用 DelphiWebsockets 在原有进程里直接开一个 WebSocket 端口,服务端、客户端两个角色都留在 Delphi 侧,前端只负责收消息。
这套资源解决的是“不换技术栈的前提下,把 Delphi 应用变成实时通信节点”这件事。它适合 Delphi 服务端开发者、做桌面端与 Web 端联调的人,以及想搞懂 WebSocket 帧格式但不想被文档绕晕的人。下面直接从技术选型讲到实战避坑,中间会给出能直接抄的握手和帧解析代码。
2. 选型与结构:跑通 WebSocket 需要准备协议解析与连接管理
2.1 为什么不直接靠 Delphi 自带的 HTTP 服务控件做升级
如果只是做 HTTP 请求转发,Delphi 自带的 HTTP 服务控件确实够用,但 WebSocket 是另一套协议。它虽然以 HTTP Upgrade 请求开头,一旦返回 101 Switching Protocols,后面的数据流就不再是 HTTP 报文,而是一串连续的二进制的“帧(Frame)”。自带 HTTP 服务控件能帮你拿到最初的 Upgrade 请求,却不会继续解析帧,所有收发都得自己接管 IOHandler。这意味着你要自己完成三块工作:握手校验、帧解析、连接生命周期管理。
DelphiWebsockets 的核心思路就是把这三块封装好。项目中提供的服务端组件处理监听端口和底层 socket,客户端组件处理连接建立和断线重连,业务代码只需要挂回调。我在一开始选型时对比过三条路线:
| 路线 | 协议处理位置 | 并发模型 | 适用场景 |
|---|---|---|---|
| 自带 HTTP 服务控件 + 手写协议 | 业务层自己接管 | 阻塞式连接,每连接一个线程 | 连接数小于 50,快速验证 |
| 轻量 socket 封装(本资源) | 组件内完成握手与帧解析 | 每连接一线程 + 消息队列 | 内网监控、设备管理后台 |
| 系统级 HTTP 服务托管 | 内核驱动接管 | 异步 I/O 模型 | Windows 服务,高并发公网网关 |
选型结论并不复杂:从 Windows 服务角度处理高并发,走内核托管是最稳的,但调试麻烦;从普通业务系统来看,轻量 socket 封装已经是性价比最高的中间态。它不依赖外部服务,不用额外装库,部署时一个 exe 就带走。
2.2 资源包里的核心组件与参数
DelphiWebsockets 给我的第一印象是组件划分干净。服务端是一个 TWebSocketServer,客户端是一个 TWebSocketClient,两者共用了同一套帧编解码单元。服务端常用参数大致有这些:
FServer := TWebSocketServer.Create(nil); try FServer.Port := 8080; FServer.HeartbeatIntervalMs := 30000; FServer.MaxPayloadSize := 1024 * 1024; FServer.OnConnect := HandleOnConnect; FServer.OnMessage := HandleOnMessage; FServer.OnDisconnect := HandleOnDisconnect; FServer.Start; finally FServer.Free; end;逻辑说明:Port 决定监听端口,客户端连接时要用同一个端口;HeartbeatIntervalMs 是服务端主动发 Ping 帧的间隔,设成 30000 表示每 30 秒探测一次,比前端空闲断开的时间短,能防止中间设备把连接回收掉;MaxPayloadSize 是收到帧后允许的最大负载长度,单位是字节,这里限制 1MB,避免恶意客户端发超大长度字段拖垮内存。OnConnect 里通常做鉴权,比如校验客户端的 token;OnMessage 里处理业务消息;OnDisconnect 里清理资源。
参数说明:端口不要和业务 http 端口混用,排查问题时区分度会差很多。心跳间隔一般建议取 15 到 30 秒,太短会导致无意义流量占带宽,太长会被 Nginx 之类的中间层判定为闲置连接。
2.3 客户端组件与断线重连
客户端组件的参数没有服务端多,但有个 AutoReconnect 值得先说:
FClient := TWebSocketClient.Create(nil); try FClient.RemoteURL := 'ws://127.0.0.1:8080/ws'; FClient.ConnectTimeoutMs := 5000; FClient.AutoReconnect := True; FClient.OnMessage := HandleClientMessage; FClient.Connect; finally FClient.Free; end;逻辑说明:RemoteURL 用 ws:// 开头,路径 /ws 是服务端允许接入的路径,如果服务端做了路径校验,这里必须一致;ConnectTimeoutMs 表示 TCP 连接握手阶段最大等待时间,超过 5 秒直接算失败;AutoReconnect 为 True 时,底层 socket 断开会自动重连,不需要业务代码关心重连时机。
这里要提醒一点:AutoReconnect 只负责重新建立 TCP 连接和完成 HTTP 升级,不会自动重放业务消息。业务消息的重发需要自己在 OnMessage 里维护一个待确认队列,这是我在后文避坑章节会展开的内容。
3. 服务端实现:握手和帧解析是绕不开的两道关口
3.1 握手响应:Sec-WebSocket-Accept 的计算
WebSocket 握手表面上是 HTTP 协议,浏览器向服务端发一个 Upgrade 请求,带 Sec-WebSocket-Key 头,服务端必须用这个 Key 拼接固定 GUID,做 SHA1 哈希,再做 Base64 编码,最后把结果放进 Sec-WebSocket-Accept 响应头返回。这里最容易犯的错误是拼接顺序写反,或者把 Key 的 Base64 解码后再算一遍。
下面是我在 DelphiWebsockets 里保留的握手计算函数:
uses System.Hash, System.NetEncoding; const WS_MAGIC_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; function BuildAcceptKey(const AWebSocketKey: string): string; var LHashBytes: TBytes; begin LHashBytes := THashSHA1.GetHashBytes( TEncoding.UTF8.GetBytes(AWebSocketKey + WS_MAGIC_GUID) ); Result := TNetEncoding.Base64.EncodeBytesToString(LHashBytes); end;逻辑说明:AWebSocketKey 是请求头里原样取出的字符串,直接拼接 GUID,不能做 URL 解码,不能转小写,也不能额外加空格。THashSHA1.GetHashBytes 得到 20 字节的哈希结果,再交给 Base64 编码就是浏览器期望的 Accept 值。
参数说明:返回的字符串长度固定为 28 个字符。如果调试时发现浏览器报 “Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header”,优先检查这一行是否符合上述逻辑。很多例子会忽略一个细节:GUID 必须写成精确字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,少一个连字符都会失败。
3.2 帧解析:从字节流里拆出消息体的状态机
握手完成后,浏览器发来的数据就是 WebSocket 帧。帧开头两个字节是关键:第一字节的高位是 FIN 标志,低 4 位是操作码;第二字节的高位是掩码标志,低 7 位是负载长度。长度超过 125 时,真正的长度存放在后续的 2 字节或 8 字节扩展字段里。下面是完整的解析函数:
type TWSFrame = record Fin: Boolean; Opcode: Byte; HasMask: Boolean; Payload: TBytes; end; const OP_TEXT = $1; OP_BINARY = $2; OP_CLOSE = $8; OP_PING = $9; OP_PONG = $A; function TryParseFrame(const ABuffer: TBytes; out AFrame: TWSFrame; out AConsumed: Integer): Boolean; var B1, B2: Byte; PayloadLen: Int64; Offset: Integer; Mask: array[0..3] of Byte; i: Integer; begin Result := False; if Length(ABuffer) < 2 then Exit; B1 := ABuffer[0]; B2 := ABuffer[1]; AFrame.Fin := (B1 and $80) <> 0; AFrame.Opcode := B1 and $0F; AFrame.HasMask := (B2 and $80) <> 0; PayloadLen := B2 and $7F; Offset := 2; if PayloadLen = 126 then begin if Length(ABuffer) < 4 then Exit; PayloadLen := (ABuffer[2] shl 8) or ABuffer[3]; Offset := 4; end else if PayloadLen = 127 then begin if Length(ABuffer) < 10 then Exit; PayloadLen := 0; for i := 0 to 7 do PayloadLen := (PayloadLen shl 8) or ABuffer[Offset + i]; Inc(Offset, 8); end; if AFrame.HasMask then begin if Length(ABuffer) < Offset + 4 then Exit; Move(ABuffer[Offset], Mask[0], 4); Inc(Offset, 4); end; if Length(ABuffer) < Offset + PayloadLen then Exit; SetLength(AFrame.Payload, PayloadLen); if AFrame.HasMask then begin for i := 0 to PayloadLen - 1 do AFrame.Payload[i] := ABuffer[Offset + i] xor Mask[i mod 4]; end else begin Move(ABuffer[Offset], AFrame.Payload[0], PayloadLen); end; Inc(Offset, PayloadLen); AConsumed := Offset; Result := True; end;逻辑说明:函数先判断缓冲区长度是否足够读取对应字段,避免越界。长度等于 126 时,按网络字节序读后续 2 字节;等于 127 时,读后续 8 字节。这里有个现实中容易踩的坑:很多实现只读 4 字节的高 32 位,对超大消息会算错,所以我统一按 8 字节循环累加。掩码处理只对客户端发往服务端的帧启用,服务端发给客户端的帧不加密。整个过程用 AConsumed 返回本次消费的字节数,业务层把剩下的字节继续拼到下一轮缓冲区里。
参数说明:Opcode 决定消息类型,$1 是文本帧,$2 是二进制帧,$8 是关闭帧,$9 是 Ping,$A 是 Pong。解析时不做消息语义判断,只负责拆帧。
3.3 消息分发:把 Opcode 映射到业务回调
帧解析完成以后,不能直接把 Payload 丢给业务层。客户端可能发文本,可能发二进制,还可能发 Ping 帧做探活。正确做法是先按 Opcode 分类:
procedure HandleFrame(const AFrame: TWSFrame; const AConnection: TWSConnection); var LMessage: string; begin case AFrame.Opcode of OP_TEXT: begin LMessage := TEncoding.UTF8.GetString(AFrame.Payload); DoMessage(AConnection, LMessage); end; OP_BINARY: DoBinaryMessage(AConnection, AFrame.Payload); OP_PING: SendFrame(AConnection, BuildPongFrame(AFrame.Payload)); OP_CLOSE: begin SendCloseReply(AConnection); CloseConnection(AConnection); end; end; end;逻辑说明:文本帧用 UTF-8 解码成字符串再交给上层;二进制帧保留原始字节;Ping 帧必须回 Pong,而且负载要原样带回,浏览器会校验 Pong 的内容是否一致;Close 帧出现后,服务端回一个 Close 再主动关闭连接,不需要等浏览器先断开。
参数说明:Close 帧的 Payload 里前两个字节是关闭状态码,比如 1000 表示正常关闭。如果客户端发来 1001 表示页面正要跳转离开,服务端也回 1001 即可。我用 CloseConnection 统一收尾,确保连接资源被释放。
4. 客户端实现:把 Delphi 也变成长连接节点
4.1 客户端握手:Sec-WebSocket-Key 的生成与连接状态机
服务端要算 Accept,客户端就要生成一个合规的 Key。RFC 6455 要求使用随机 16 字节,再经过 Base64 编码,不能使用固定字符串。固定 Key 在多个客户端共用时会暴露连接来源,而且有些服务端会直接拒绝重复 Key。
function GenerateWebSocketKey: string; var LRaw: TBytes; i: Integer; begin SetLength(LRaw, 16); for i := 0 to 15 do LRaw[i] := Random(256); Result := TNetEncoding.Base64.EncodeBytesToString(LRaw); end;逻辑说明:每建立一个连接都生成一次新 Key。服务端只能根据 Key 计算出 Accept,不能反推出原始随机数,所以安全性和唯一性都由这 16 字节保证。实际代码里建议用加密安全的随机数源,避免用纯数学 Random,不过内部联调工具用 Random 也无伤大雅。
客户端连接过程是一个状态机:CONNECTING 表示 TCP 已连上、正在等待握手结果;OPEN 表示握手成功可以收发消息;CLOSING 表示已发出 Close 帧、等待对端确认;CLOSED 表示连接已释放。AutoReconnect 逻辑只有在 CLOSED 状态下才触发,否则会出现重连风暴。
4.2 客户端发送帧:掩码是必须项
服务端给客户端发的帧不需要掩码,客户端给服务端发的帧必须带掩码,而且掩码键是随机 4 字节。这个不对称规则是初学者最容易忽略的。服务端如果收到一个没有掩码的客户端帧,按规范应该直接断连。
function BuildClientFrame(const AMessage: string): TBytes; var LPayload: TBytes; LLength, LOffset: Integer; LMask: array[0..3] of Byte; i: Integer; begin LPayload := TEncoding.UTF8.GetBytes(AMessage); LLength := Length(LPayload); if LLength <= 125 then begin SetLength(Result, LLength + 6); Result[0] := $81; Result[1] := $80 or LLength; LOffset := 2; end else begin SetLength(Result, LLength + 8); Result[0] := $81; Result[1] := $80 or 126; Result[2] := Byte(LLength shr 8); Result[3] := Byte(LLength and $FF); LOffset := 4; end; LMask[0] := Random(256); LMask[1] := Random(256); LMask[2] := Random(256); LMask[3] := Random(256); Move(LMask[0], Result[LOffset], 4); Inc(LOffset, 4); for i := 0 to LLength - 1 do Result[LOffset + i] := LPayload[i] xor LMask[i mod 4]; end;逻辑说明:Result[0] 固定为 $81,8 表示 FIN 位为 1,1 表示文本帧。这里的重点在 Result[1]:$80 or LLength 表示把掩码位置 1,同时把长度写进低 7 位。超过 125 负载时,需要用 126 作为长度占位符,后续 2 字节写真实长度,此时帧长度会比小消息模式多 2 字节,所以我 SetLength 分配了 LLength + 8。
参数说明:这段代码只覆盖了 65535 字节以内的消息,超过这个范围需要走 127 分支和 8 字节长度字段。大多数业务场景的消息体都是 JSON 文本,控制在几 KB 到几十 KB,125 和 65535 两个分支够用。如果传输大文件,建议改走二进制分片,不要把一个超长 Payload 塞进单帧。
4.3 断线重连与心跳的配合
客户端断开不一定是网络故障,也可能是服务端主动踢人、中间网关超时。客户端组件里的重连逻辑我一般这样接:
procedure TWSClient.HandleDisconnect; begin FState := TWSState.CLOSED; if FAutoReconnect and FUserNeedReconnect then begin Sleep(GetReconnectIntervalMs()); Connect(FRemoteURL); end; end;逻辑说明:断开事件里先将状态置为 CLOSED,避免正在重连时又收到旧连接回调。Sleep 间隔要加上随机抖动,比如在 1 秒到 3 秒之间随机浮动,否则大量客户端同时掉线重连会形成风暴。FUserNeedReconnect 这个开关用来区分“用户主动关闭”和“异常断开”,主动关闭时不能重连。
参数说明:心跳在这里起到了判活的作用。客户端定时发 Ping,服务端在 5 秒内没回 Pong,就认为连接已死并主动重连。这个方法在 TCP 层面无法感知半开连接时特别有用,它是保证连接池干净的关键。
4.4 JSON 消息封装:让收发双方有统一协议
Delphi 端直接把字符串发过去不是不可以,但消息一旦带上设备号、时间戳、类型,前端解析就麻烦了。我习惯用 TJSONObject 做一层封装再发:
var LJson: TJSONObject; begin LJson := TJSONObject.Create; try LJson.AddPair('type', 'status'); LJson.AddPair('deviceId', FDeviceId); LJson.AddPair('ts', TJSONNumber.Create(DateTimeToUnix(Now))); FWebSocketClient.Send(LJson.ToJSON); finally LJson.Free; end; end;逻辑说明:type 字段是消息类型,前端靠它区分状态推送和告警推送;deviceId 是业务标识;ts 是 Unix 时间戳,用于前端判断消息是否过期。Send 内部会调用 BuildClientFrame 完成掩码和帧编码。这里要注意 TJSONObject 必须手动 Free,不能依赖字符串引用自动释放。
参数说明:DateTimeToUnix 需要引入 System.DateUtils。时间戳的类型是 Int64,前端拿到后乘以 1000 转成毫秒才能正确渲染。如果前后端用不同语言解析 JSON,建议统一约定数字字段,不要混用字符串和数字类型。
5. 实战避坑记录:握手 400、半包、假死与内存增长
5.1 握手一直返回 400,浏览器控制台报 Sec-WebSocket-Accept 不正确
现象:浏览器连接 ws://127.0.0.1:8080/ws 后,控制台出现 “Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header”,服务端日志显示收到 Upgrade 请求后返回了 400。
原因:响应头里的 Sec-WebSocket-Accept 值不是用客户端的 Sec-WebSocket-Key 拼 GUID 后哈希得到的。常见错误有三种:拼接时把 GUID 写成了 UUID 格式,四组十六进制字符串顺序不对;对客户端 Key 先做了解码再做哈希;响应头里多了空格或用了 LF 而不是 CRLF。
解决:把 BuildAcceptKey 函数单独加日志,输出服务端算出来的值和客户端 Key,再用在线工具比对。响应头必须按下面格式写:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: <BuildAcceptKey 的返回值>行尾统一用 CRLF,每个头部一行,最后必须多留一个空行。很多嵌入式网络库会用Format拼字符串,如果不小心把#13#10写成了#10,浏览器就会判定响应格式不合法。
5.2 小消息正常,大消息偶发残缺,消息内容被切断或粘在一起
现象:发 1KB 以内的消息一切正常,发 64KB 以上时,前端收到的消息有时候缺尾巴,有时候两条消息拼在一起,服务端这边解析 Payload 长度越界。
原因:TCP 是字节流,没有消息边界。服务端一次 Read 不可能保证刚好读到一个完整 WebSocket 帧,可能只读到帧头,也可能一次读到好几个帧。我在 3.2 里提供的 TryParseFrame 只是“解析器”,不是“分包器”,调用前必须把数据攒进缓冲区。
解决:接收处理逻辑改成“缓冲区追加 + 循环解析”。伪代码结构如下:
procedure OnDataReceived(const AData: TBytes); var LFrame: TWSFrame; LConsumed: Integer; begin AppendToBuffer(AData); while Length(FBuffer) > 0 do begin if not TryParseFrame(FBuffer, LFrame, LConsumed) then Break; HandleFrame(LFrame); Delete(FBuffer, 0, LConsumed); if LConsumed = 0 then Break; end; end;逻辑说明:每次收到网络数据先追加到 FBuffer,然后尝试解析。解析失败说明缓冲区里还不够一个完整帧,直接退出等下一次数据。解析成功则立即处理并从缓冲区头部删除已消费字节。这里最大的坑是忘记删除已消费字节,或者解析失败后没有 Break,导致同一个数据段被反复解析。
参数说明:FBuffer 是成员变量,属于单个连接实例,不能全局共用。缓冲区建议初始分配 4KB,自动增长到 MaxPayloadSize。超过 MaxPayloadSize 仍然凑不齐一个完整帧时,直接断开连接,避免内存开销失控。
5.3 客户端断开后服务端不触发 OnDisconnect,连接数只增不减
现象:浏览器页面关闭后,服务端 OnDisconnect 没有立刻执行,连接列表里的存活连接数一直涨,直到系统文件句柄耗尽。
原因:浏览器关闭页面时未必会发 Close 帧。操作系统的 TCP 断开通知可能被延迟,尤其当中间经过代理或 NAT 时,服务端侧 socket 感知不到对端消失,阻塞在 Read 上,一直在等一个永远不会来的数据。心跳探测就是解决这个问题的手段。
解决:我保留的规则是服务端每 30 秒发一次 Ping 帧,连续 2 次没有收到对应 Pong 就强制断开。代码里不要只依赖 OnDisconnect 回调来清理对象,把“心跳超时清理”做成独立定时任务:
procedure TWebSocketServer.CheckHeartbeats; var LConn: TWSConnection; begin for LConn in FConnections do begin if LConn.LastPongTime < Now - HeartbeatTimeoutSecs / 86400 then begin LConn.Close; FConnections.Remove(LConn); end; end; end;逻辑说明:每个连接记录最后一次收到 Pong 的时间,判断是否超过阈值,超过就主动关闭。浏览器的发 Close 帧是被动路径,心跳主动断开是兜底路径,两条路径都要走。只做被动回收会在代理节点保留大量残留连接。
参数说明:HeartbeatTimeoutSecs 建议设为心跳间隔的 2 到 3 倍。间隔 30 秒,超时就设 75 到 90 秒,避免瞬时抖动导致误杀。
5.4 长时间运行内存增长,疑似内存泄漏
现象:服务端连续跑两天,内存从 200MB 涨到 2GB,析构函数里检查并没有明显的对象泄漏。
原因:多半不是传统意义的泄漏,而是字节容器堆积。接收缓冲区 FBuffer 在每次消息处理完后没有截断,或者回调里的 Payload 数组被上层引用后又忘了释放;还有一个隐蔽原因是消息处理太慢,传入队列的速度大于消费速度,积压消息全堆在内存里。
解决:在每轮处理完帧后立刻释放引用,并给队列设上限:
try HandleFrame(LFrame); finally SetLength(LFrame.Payload, 0); if Length(FBuffer) > 0 then SetLength(FBuffer, 0); end;逻辑说明:SetLength 把数组清零,释放底层引用。队列上限可以在 OnMessage 入口判断,比如积压超过 1000 条时丢弃最旧消息并打警告日志,而不是无限缓存。这套组合拳基本能根治长时间运行的内存增长问题。
参数说明:如果业务确实需要高吞吐,把队列上限调大,但必须结合消息超时时间,超过 30 秒还没被处理的消息直接丢弃,避免前端拿到过期数据还在那里混淆视听。
6. 验证技巧:手工握手、抓包确认帧边界与全链路压测
资源跑通以后,不要急着接业务。我先用浏览器控制台做一次最原始的手工验证,把协议链路拆成三块分别确认。
第一块是握手验证。在服务端日志里打印收到的 Sec-WebSocket-Key,再用独立函数算出 Accept,和浏览器请求预期的结果对比。确认计算逻辑无误后,才去接业务回调。第二块是帧边界验证。用浏览器连续发送三条消息,其中一条故意超过 125 字节,观察服务端是否按三条消息分别触发 OnMessage,而不是粘成一条。第三块是心跳验证。停掉客户端心跳,等超过超时阈值后看服务端是否主动断开连接。
浏览器控制台里可以直接起一个临时客户端:
const ws = new WebSocket('ws://127.0.0.1:8080/ws'); ws.onopen = () => { ws.send('hello'); ws.send('this is a longer message crafted for frame boundary check'); }; ws.onmessage = (ev) => { console.log(ev.data); ws.close(); };逻辑说明:如果服务端控制台输出两条独立消息,说明帧解析和消息分发正常。如果第一条和第二条连在一起,说明粘包处理有问题,回到 5.2 的缓冲循环里找原因。如果只输出一条,说明小消息正常但大消息被截断,检查 126 长度分支的字节序。
抓包工具里的验证更直接。Wireshark 过滤tcp.port == 8080,先看第一条 TCP 会话里的 HTTP 段,确认 101 Switching Protocols 返回正常;再看后续 TCP 段,协议列应显示为 WebSocket,负载里可以看到操作码 1 的文本帧。心跳是否正常体现在周期性出现的 Ping/Pong 帧上,间隔应该与 HeartbeatIntervalMs 基本吻合。
压测我一般用一段小脚本创建 100 个连接,每 3 秒发一条消息,跑 30 分钟,观察内存和连接数曲线是否平缓。这里重点不是吞吐量,而是长时间运行下的稳定性。消息丢失率统计要在应用层做,帧层抓包只能确认协议格式对。
从那次全链路压测翻车以后,我每次写新的 WebSocket 模块,都强制先把握手、帧解析、心跳、断线重连这四个脚本测试走一遍,再开始接业务代码。这个习惯至少帮我省掉了三次上线后的半夜排障。希望你也能把这些步骤变成自己的固定流程,少踩几个坑,就是多省几个晚上。希望帮到你。
本文还有配套的精品资源,点击获取