ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebSocket Delphi服务端源码解析:握手、帧处理与连接管理实战

WebSocket Delphi服务端源码解析:握手、帧处理与连接管理实战 简介面向Delphi开发者的WebSocket服务端控件源码包由作者老吴编制主要解决桌面应用中快速加入实时通信能力的需求适合希望掌握服务端开发与协议细节的中高级Delphi程序员。控件目前已支持收发文本消息、收发二进制流消息、Ping心跳探活、广播消息、全部断开、在线客户端列表与数量统计、记录各客户端收发消息量等常用功能尚未支持wss加密传输适合内网或对安全要求不高的场景先行使用也可自行扩展。压缩包共收录34个文件整体大小951KB包含控件源程序、Demo演示工程、可执行程序、HTML测试页、窗体界面、资源与皮肤配置、图标和Readme说明目录结构清楚打开工程即可运行观察效果。目前已有2098人学习下载源代码与演示程序相互对照便于理解WebSocket服务端的消息处理流程并在此基础上改造出更符合业务需要的通信模块。1. 拿到 WebSocket Delphi 服务端源代码包先搞清这三点再动手一份 WebSocket delphi server 服务端 源代码.rar 摆到面前通常逃不开三种处境接手别人的老项目、从社区下了一份含源码的 Demo 想改巴改巴上线、或者自己正评估 Delphi 到底能不能扛 WebSocket 服务端。这个标题说的就是在 Delphi 里实现 WebSocket 服务端这件事——它意味着已经有人用 Object Pascal 把 HTTP 升级握手、帧编解码和连接管理都写好了你要做的是跑起来、改参数、把业务逻辑接进去。它适合两类人一类要给存量 Delphi 系统加实时推送能力另一类是只有这个 rar 却不知道从哪下手验证可用性。先说结论这玩意儿跑通不难真正耗时间的是心跳、半包缓存和并发连接管理这三件事。2. Delphi 写 WebSocket 服务端为什么还有人选这条老路在开始跑代码之前先回答一个绕不开的问题为什么还有人在 Delphi 里写 WebSocket 服务端答案不在语言排行榜而在存量系统。你去企业内部转一圈就会发现大量工控上位机、实验室数据采集端、老 MIS 系统的客户端就是 Delphi 写的这些程序的数据库连接、权限校验、业务逻辑全在同一个工程里。现在业务要加实时监控、要推送告警第一反应不是另起炉灶而是在现有进程里多开一个端口。也有人问为什么不用 Go 或 Python 单独写个 WebSocket 微服务再做跨进程通信。技术上完全可行落地时却要多吞两粒后悔药跨进程通信要定协议、要处理双方生命周期、部署时还得在两套运行环境里找依赖。很多团队的答案不是最优而是改动最小——在 Delphi 进程里塞一个基于 Indy 的 WebSocket 端口代码改动集中发布包还是那一个 exe。但这不意味着 Delphi 是无脑优选。它的适用边界非常清楚连接数在几百量级、消息频率每秒几百条以内Delphi 完全能稳如果目标是一万台设备并发或要按流量弹性伸缩那就别跟语言较劲。认清这个边界能给你省下后面大量的为什么连客户端一多就卡死的排查时间。2.1 WebSocket 协议里服务端真正要做的事升级握手与帧解析WebSocket 服务端的实质不是开个长连接这么简单它分两个阶段。第一阶段是 HTTP Upgrade 握手客户端发一个 GET 请求头里带 Upgrade: websocket 和 Sec-WebSocket-Key服务端要把这个 Key 拼上固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 做 SHA1 再 Base64算出的结果放到 Sec-WebSocket-Accept 头里返回 101。第二阶段是帧传输双方按 RFC 6455 的帧格式收发数据服务端必须正确解析 FIN、opcode、mask、payload length 这些字段。我一般动手前会把帧格式先摊开看。帧的前两个字节承载了大部分信息第一个字节高 4 位是 FIN是否最后一帧低 4 位是 opcode1 表示文本、2 表示二进制、8 表示关闭、9 表示 ping第二个字节最高位是 MASK低 7 位是 payload length 的初始值。这里最容易被绕晕的是长度字段初始值小于 126则它就是真实长度等于 126后面还有 2 字节的扩展长度等于 127后面则有 8 字节长度。客户端发给服务端的帧必须带掩码服务端回给客户端的帧禁止带掩码——这个不对称写反一次调一晚上都不一定能发现。Delphi 社区里最常见的落地路线是基于 Indy 的 TIdHTTPServer先在 HTTP 事件里识别升级请求并返回 101然后连接交给 OnExecute 做阻塞式帧循环。Indy 默认一个连接一个上下文线程写起来直白天然适合一对一收发。麻烦也出在这个模型上OnExecute 是阻塞循环如果业务逻辑里有同步数据库查询或 sleep单个连接的慢会把线程池拖住如果循环里没有心跳探测客户端静默退出后连接就成了僵尸占着线程不释放。2.2 手写帧解析还是用组件源码包里通常是什么路线拿到 rar 后第一个要判断的是这个源码包走的是哪条路线。打开 .dpr 工程文件看 uses 列表如果引用的单元全是 System、SysUtils、IdTCPClient、IdHTTPServer 这类 Indy 自带的说明它是自包含实现依赖少拷到哪台机器都能编译如果出现某个第三方 WebSocket 组件库的单元名就得先确认库的版本和你的 Delphi 版本兼容。我遇到过某开发者把一个基于旧版组件写的服务端源码塞进新版编译器结果几十处接口变更全部改完等于重写了一版。自包含实现通常由三块组成一块负责 HTTP 升级握手一块负责帧编解码一块负责连接管理。升级环节可以继续用 TIdHTTPServer 的 CommandGet 事件帧编解码是纯字节操作处理 TIdBytes 或 TMemoryStream 都行。这类代码的好处是你能完整看到每一次位移运算调起错来不黑匣子坏处是边缘情况全看作者功力——分片重组、控制帧响应、超长帧内存保护任何一块写得偷懒生产环境都会加倍还你。如果你手头的 rar 里只有 .pas 和 .dfm没有额外 DLL 或 BPL 包那基本可以断定是自包含实现这也是我最推荐新手先跑通的一类源码。因为出问题时可排查的范围被压缩到了几个文件内不至于在组件依赖的迷宫里转圈。2.3 阻塞循环、事件回调和连接表代码组织的三种范式Delphi WebSocket 服务端的代码组织方式基本就三种阻塞循环式、事件回调式、连接表管理式。阻塞循环式最朴素一个 OnExecute 里 while 循环读数据读到完整帧就处理读不到就等。事件回调式把收发拆到 OnMessage、OnDisconnect 这类事件里写界面代码清爽但帧缓冲和半包状态得自己维护在对象成员里。连接表管理式是在前两者之上加一张 TList 或 TDictionary 来登记所有活跃连接统一做心跳巡检和主动推送。从 rar 源码质量判断作者水平就看它的消息处理函数里同时做了哪几件事有没有对半包做缓冲重组、有没有判断 socket 是否可读而不是盲等、业务逻辑是在 socket 线程里直接跑还是丢给工作线程。如果业务直接在 socket 循环里处理还夹着数据库操作这个服务端在连接数一多时会出现一个慢查询拖垮全体连接的连锁反应。先把这个判断做完你再决定是把它跑起来直接用还是拿它当地基自己往上改。3. 从 rar 到手跑把服务端跑通的最小步骤与核心代码3.1 解包之后的目录结构dpr、pas、dfm 各自管什么打开 rar 后不要急着双击编译先把目录结构过一遍。一个典型的 Delphi WebSocket 服务端工程包含三个主要文件类型.dpr 是工程入口类似 C 语言的 main里面声明了工程引用的单元列表和 Application 初始化代码.pas 是业务与协议实现单元握手计算、帧解析、业务处理按职责拆在几个 pas 里.dfm 是窗体资源定义了主界面上的按钮、端口输入框、日志 Memo 这些控件布局。服务端程序多半有个主窗体因为要手动点启动按钮并观察日志。如果 rar 里还带着 .dproj那是较新版本 Delphi 的工程配置只有 .dpr 和 .pas 的老工程也能开Delphi 会自动兼容。拿到工程后第一步是用对应版本的 Delphi 打开 .dpr打开后立刻做一次 Build。如果编译直接过了恭喜依赖问题不存在如果报找不到单元去 uses 里逐个看缺谁常见缺 IdHTTP、IdHashSHA1 这类 Indy 单元在 IDE 的工具包里勾上 Indy 组件即可。编译通过后先别点运行。把主窗体上的端口参数填好默认值通常是 8080 或 9000然后点启动按钮。日志区如果打印出 listening on port 8080 之类的字样说明服务端进程已经起来了可以进下一步验证。3.2 核心代码握手响应与帧解析跑通之后你会想弄明白里面到底发生了什么。下面这段握手函数是所有 Delphi WebSocket 服务端的必答区块——它负责把客户端 Key 算成 Sec-WebSocket-Accept// 计算 Sec-WebSocket-Accept握手响应必须返回这个值 function ComputeWebSocketAccept(const AKey: string): string; var SHA1: TIdHashSHA1; Hash: TIdBytes; ToHash: string; begin ToHash : AKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; SHA1 : TIdHashSHA1.Create; try // 编码必须用 UTF-8用错字符集会算出错误的 Accept Hash : SHA1.HashString(ToHash, TEncoding.UTF8); Result : TIdEncoder.Base64.Encode(Hash); finally SHA1.Free; end; end;这段代码的逻辑输入客户端的 Sec-WebSocket-Key拼上协议规定的固定 GUID先做 SHA1 散列再做 Base64 编码。三个环节缺一不可顺序也不能换。值得盯住的参数是 TEncoding.UTF8如果工程里全局用了 ANSI 编码或者某位前辈把 HashString 的编码参数漏了计算出的 Accept 会和客户端期望的对不上握手必然失败浏览器控制台里看到的永远是 400 或 Unexpected response code。再看帧解析。服务端收数据时要做的第一件事是判断帧头下面的函数演示了如何从原始字节里拆出 opcode 和 payload// 解析客户端发来的一个 WebSocket 帧返回 opcode 与去掩码后的 payload procedure ParseWebSocketFrame(const AData: TIdBytes; var OpCode: Byte; var Payload: TIdBytes); var B1, B2: Byte; PayloadLen: Int64; MaskKey: array[0..3] of Byte; Offset, i: Integer; begin B1 : AData[0]; B2 : AData[1]; OpCode : B1 and $0F; // opcode 在第一个字节低 4 位 PayloadLen : B2 and $7F; // 长度初值在第二个字节低 7 位 Offset : 2; if PayloadLen 126 then // 16 位扩展长度 begin PayloadLen : (AData[2] shl 8) or AData[3]; Offset : 4; end else if PayloadLen 127 then // 64 位扩展长度 begin PayloadLen : 0; for i : 0 to 7 do PayloadLen : (PayloadLen shl 8) or AData[2 i]; Offset : 10; end; if (B2 and $80) 0 then // 客户端帧必须带掩码 begin for i : 0 to 3 do MaskKey[i] : AData[Offset i]; Inc(Offset, 4); SetLength(Payload, PayloadLen); for i : 0 to PayloadLen - 1 do Payload[i] : AData[Offset i] xor MaskKey[i mod 4]; end; end;逻辑不复杂四个注意点第一opcode 取 B1 的低 4 位不是整个字节第二长度是变长的先读初值再决定取 2 字节还是 8 字节第三掩码键是 4 个字节对 payload 逐字节做异或索引是 i mod 4第四这个版本没处理分片如果收到 FIN0 的帧你得把多次 Payload 拼接后再交给业务层。生产代码里我还会加一道防线PayloadLen 超过设定上限时直接发关闭帧防止对方用畸形长度头撑爆内存。3.3 把服务端跑起来最小启动命令与第一个连接验证编译通过、握手函数没改错就可以做端到端验证。步骤如下启动服务端在浏览器开发者工具控制台里执行var ws new WebSocket(ws://127.0.0.1:8080);注册 ws.onmessage 后发一条消息看服务端日志有没有收到服务端回一条测试消息看浏览器 console 是否打印。我一般会建议先用浏览器验证而不是直接接业务客户端因为浏览器是现成的 WebSocket 调试器报错信息完整协议实现也规范。日志里如果出现 handshake ok 再进下一步说明 101 响应、Accept 头都没问题。如果连握手都没过先把 3.2 的握手函数返回值和标准值核对一遍——你可以手动算一个 key 的预期 Accept 值做比对这是定位问题最快的办法比盯着日志猜高效得多。如果手边有带 GUI 的 WebSocket 测试工具也可以直接填 ws 地址点连接。这类工具能显示每次收发的帧详情对排查掩码和分片问题尤其有用。等浏览器和工具都通了再让你的 Delphi 客户端或前端业务代码接进来把收发两条链路都对上这个服务端就可以往里填业务逻辑了。4. 参数调整与客户端联调端口、心跳、帧大小该设多少4.1 服务端必调参数清单把服务端跑通后第一件事不是写业务而是确认几个运行参数。下面是 Delphi WebSocket 服务端最常见的四个参数及其建议值参数建议值说明监听端口8080 / 9000避开 80 与 443防止和现有服务冲突心跳间隔20~30 秒要小于网络链路上反向代理/防火墙的超时时间最大帧长64 KB文本消息 4~8KB 足够大文件走二进制分片读超时60 秒超过则主动断开回收僵尸连接最大连接数300~500受 Indy 线程模型限制不宜盲目调大端口的选择有个现实约束内网系统喜欢 8080但如果同一台机器已经跑了其他 Web 服务就换 9000 或自定义端口避免启动即冲突。心跳间隔是这里面最值得花时间测的参数——它必须小于你网络链路中所有中间设备的空闲超时。你在办公网连本地服务设 60 秒都无所谓一旦客户端走反向代理、负载均衡或云网关代理默认的空闲超时往往在 30~60 秒之间心跳间隔必须比它短否则链路被代理先断客户端还没察觉。最大帧长不是越大越好。它的作用有两个防止对方发畸形长度头把你内存打爆防止超长消息把处理线程长期占住。合理的做法是把它设成你业务最大消息的两倍左右超出直接拒绝并关闭连接。读超时和心跳是一对组合拳心跳负责主动探测对端是否活着读超时负责兜底回收那些连心跳都不回的死连接。4.2 心跳机制为什么服务端比客户端更需要主动探测心跳这件事新手最容易踩的反直觉点在于客户端断线时服务端并不会立刻收到通知。TCP 连接断开只在你下次真正写数据时才报错如果客户端是拔网线、断电、或者进程崩溃服务端侧的 socket 看起来永远还活着。所以服务端必须自己主动发 ping 或者定时检查收包时间才能发现那些静默消失的连接。常见做法是服务端每 20~30 秒发一帧 pingopcode 9客户端如果实现规范会回 pongopcode 10。如果连续两次 ping 都没等到 pong服务端就可以判定连接失效并主动关闭。实现上这个逻辑要塞进 OnExecute 的循环里循环每次醒来先检查距上次收到数据已超过多久超过阈值就发 ping再超就关。注意 ping 本身也是一种发送要和控制帧互斥锁一起考虑避免两个线程同时写一个 socket 导致数据交错。我在跑通 demo 后做的第一件事就是把心跳从注释里放出来。很多源码包默认把心跳逻辑用 if False 或者常量控制关掉了因为 demo 阶段不需要。等你接上真实客户端、跑一夜之后第二天早上连接表里躺着一堆假连接那时再后悔就晚了。4.3 第一轮联调浏览器、测试工具与日志三方对照联调的意义在于把服务端能跑变成业务可用。我的验证顺序是第一步浏览器 WebSocket 连上发文本消息确认服务端日志收到第二步服务端主动推送一条消息浏览器 onmessage 打印出来第三步关掉浏览器标签页5 分钟内看服务端是否把连接从连接表里清掉第四步模拟拔线场景——断点停在客户端强制结束进程看服务端心跳机制多久嗅探出来。第三步和第四步是最能暴露问题的。如果连接表里有连接一直不消失说明 OnExecute 循环里少了读超时或心跳检测。如果关掉标签页后连接倒是清掉了但资源没释放说明 TIdContext 没从连接表移除后续每次重连都会泄漏一点内存跑上几天服务端越来越迟钝。联调时把日志级别调高一点。每次握手成功、连接关闭、心跳超时都打一行带时间戳的日志这些记录不只是给你看也是后面线上出问题时最直接的排查依据。我见过有的服务端上线后出问题日志区却只有启动那行字什么都查不了那已经不是技术问题是工作习惯问题。5. 避坑Delphi WebSocket 服务端的 5 个翻车现场5.1 帧解码乱码忘了处理掩码位现象浏览器发过来的中文消息到服务端变成乱码英文却正常或者偶尔正常偶尔乱码。原因客户端浏览器发出的所有 WebSocket 帧都必须带掩码服务端拿到的是被掩码异或过的数据。如果解析代码只取了 payload 没做异或还原字节流里一部分碰巧是 ASCII 时看起来正常一到多字节的中文就露馅。解决在解析帧时检查 B2 的最高位置位时必须读 4 字节 MaskKey并对 payload 逐字节xor MaskKey[i mod 4]之后再按 UTF-8 解码。5.2 握手一直 400Sec-WebSocket-Accept 计算用错字符集现象浏览器连服务端握手阶段直接报 Unexpected response code: 400服务端日志显示收到了 Upgrade 请求但返回了非 101。原因Accept 值的计算要点是 SHA1 的输入字节必须与客户端一致。常见翻车是把 Sec-WebSocket-Key 拼上固定 GUID 后用 ANSI 或系统默认编码去算哈希而客户端规范里用的是 UTF-8 编码。Delphi 里 AnsiString 和 UTF8String 混用编译不出错但结果全错。解决HashString 的编码参数显式传TEncoding.UTF8不要依赖默认值。验证方法取一个固定 key 手工算标准 Accept 值例如把知名示例 key 的预期结果直接断言比对。5.3 客户端掉了服务端不知道半开连接与心跳失效现象客户端闪退或拔网线后服务端连接表里对应连接长期存在状态显示正常但业务数据已经发不出去。原因TCP 没有主动通知机制服务端不写数据就永远感知不到对端消亡。如果代码里的读循环在等数据而对方既不再发数据也不主动 FIN这条连接就成了半开连接——本地以为活着远端其实早没了。解决OnExecute 循环里做读超时检查超时发 ping连续两次无 pong 就断开。另外连接表巡检线程要定时扫描最后收包时间双保险。5.4 内存缓慢上涨TIdContext 没释放或队列积压现象服务端刚启动时内存 30MB跑 24 小时后变成 200MB且不再下降。原因两个常见病灶。一是连接关闭时只调了AContext.Connection.Disconnect没有把 TIdContext 从连接表移除也没释放绑定的业务对象二是服务端推送消息时对慢客户端不做背压消息在发送队列里无限堆积内存被队列吃光。解决连接关闭事件里做完整的清理动作从连接表移除、释放绑定的对象、置 nil。推送前检查 socket 的可写状态和队列长度超阈值就断开并记录日志。5.5 中文消息在传输中被截断按字节切帧而非按字符切帧现象客户端收到服务端推送的中文消息最后几个字变成了问号或乱码英文消息无事。原因WebSocket 帧的 payload length 是按字节计算的不是按字符。如果发送端把 UTF-8 字符串转字节时长度算错或者服务端从某处缓存读数据时用了字符数当长度帧会被切在半个多字节字符中间。解决发送路径上先TEncoding.UTF8.GetBytes拿到字节数组以数组长度为 payload length再构造帧头。接收路径上把完整 payload 按 UTF-8 解码不要手动截断。6. 进阶把 Demo 式服务端改成能上线的连接管理模式用一个连接表管理所有活跃连接是 Demo 和可上线服务端最明显的分界线。最简做法是TDictionaryTIdContext, TFakeConnInfo 存放上下文及其最后收包时间独立 TTimer 每 15 秒扫描一次把超时连接断开并清理推送业务通过连接表找到目标连接而不是在 OnExecute 局部变量里直接发。这个改造要特别注意线程安全。Indy 的 OnExecute 跑在工作线程里TTimer 跑在界面线程里两者同时读写 TDictionary 和同一个 socket 会发生交错。最简单的做法是给连接表加一个 TCriticalSection或者把巡检也放到独立线程里去。锁的粒度控制在整张表操作外围别锁到每次推送的 socket 写入上否则并发一上去锁竞争就成了新的瓶颈。改造完成后用批量客户端做五分钟压测开 500 个连接每个连接每秒发一条消息观察服务端 CPU 和内存。注意这个过程里同时观察两件事——消息是否有丢失以及关闭一批连接后内存是否会回落到基线。前者验证你的帧解析和并发写锁后者验证你的清理逻辑有没有泄漏。这两关过了这份 rar 里的源码才算真正被接住了。老实说我第一次把这类 Demo 接进真实业务时就栽在连接清理上。当时 30 个客户端跑了一周服务端内存稳定在 500MB 下不来查了两天才发现是移除连接表时用错了 key。所以如果你改完也遇到类似现象先怀疑清理路径再看业务逻辑。希望这篇笔记能帮你少走这一步弯路也希望你拿到这份包时能比当初的我更快地让它真正可用。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进