资讯动态

DelphiWebsockets实战:老项目无缝接入WebSocket实时通信

发布时间:2026/10/14 16:45:07 来源:尧图企业网站定制
简介这是一份基于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 里通常做鉴权比如校验客户端的 tokenOnMessage 里处理业务消息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] 固定为 $818 表示 FIN 位为 11 表示文本帧。这里的重点在 Result[1]$80 or LLength 表示把掩码位置 1同时把长度写进低 7 位。超过 125 负载时需要用 126 作为长度占位符后续 2 字节写真实长度此时帧长度会比小消息模式多 2 字节所以我 SetLength 分配了 LLength 8。参数说明这段代码只覆盖了 65535 字节以内的消息超过这个范围需要走 127 分支和 8 字节长度字段。大多数业务场景的消息体都是 JSON 文本控制在几 KB 到几十 KB125 和 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 模块都强制先把握手、帧解析、心跳、断线重连这四个脚本测试走一遍再开始接业务代码。这个习惯至少帮我省掉了三次上线后的半夜排障。希望你也能把这些步骤变成自己的固定流程少踩几个坑就是多省几个晚上。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑