首页 / 资讯中心 / 文章详情

Delphi手写WebSocket协议栈:从握手到帧解析的完整实战

Delphi手写WebSocket协议栈:从握手到帧解析的完整实战 ★ FEATURED ARTICLE
简介资源是基于Delphi的WebSocket服务端实现项目面向需要在Delphi环境中构建实时双向通信应用的开发者适合聊天、股票交易、在线协作等场景。压缩包共32个文件以.pas源码为主22个并包含.dpr工程文件、.dfm窗体文件、.md说明文档及示例客户端压缩包整体仅134KB结构紧凑。已有419人学习下载。内容覆盖IdWebsocketServer、IdServerIOHandlerWebsocket等核心单元展现服务端初始化、HTTP升级握手、帧解析、数据收发与连接管理的关键流程同时包含RemObjectsSDK_WS.js等辅助文件与单元测试工程便于验证和二次开发。通过阅读这套源码可快速掌握Delphi下借助Indy或WebSocket4Delphi实现WebSocket服务端的完整方法并理解事件驱动网络编程与帧协议细节。1. 一个连不上服务器的深夜Delphi 与 WebSocket 的硬碰硬如果你还在用 Delphi 写桌面客户端大概率会遇到这样一个需求让程序里的某个窗口实时显示来自服务器的数据比如推送告警、行情、聊天消息。传统的轮询方案代码写起来简单可一遇到高频率更新就痛苦连接数量一多服务器 CPU 整天在空转。我当时手头某个项目就是这样数据库服务端已经支持 WebSocket 接口但 Delphi 端找不到一个顺手的组件试了几个第三方控件不是停止维护就是跟新版本系统存在冲突。最后我决定自己写一套轻量级的 WebSocket 协议栈这就是 DelphiWebsockets 的由来。这个项目不是那种大而全的框架它聚焦在 WebSocket 协议的底层通信上握手、数据帧解析、掩码处理、连接管理全部用 Delphi 原生的 Socket 能力实现。适合两类人一是需要在 Delphi 桌面应用里接入现有 WebSocket 服务的开发者二是对协议本身感兴趣、想看清 WebSocket 每一步在干什么的读者。整套代码 2000 行左右没有黑匣子任何一个字节的流向都清楚摆在眼前。2. 技术选型与项目骨架为什么不用第三方控件而要手写协议栈先交代一下背景。Delphi 的生态里不是没有 WebSocket 组件常见的方案有商业控件、老牌开源库还有基于 Indy 的半成品封装。但我用下来发现几个问题一是大部分组件的协议实现都停留在较老的草案版本对 RFC 6455 的支持不完整尤其是分片帧和关闭握手这类边界场景二是组件普遍和可视化窗体、组件生命周期绑得太死想在多线程的后台服务里单独使用很难三是一旦线上出现问题组件内部的协议细节对使用者是黑盒排查问题全靠试错。2.1 方案对比原生 socket、Indy 与第三方控件的取舍我当时做了三个方案的对比测试结论是这样的。方案协议完整度可控性线程友好上手成本结论TClientSocket 原生协议需要完全手写最高高自己掌控线程高最终选择Indy TIdTCPClient需要自己实现 RFC 6455中封装过一层中阻塞模式要注意超时中可作底层传输替代第三方 WebSocket 控件大多不完整分片帧处理简陋低各有差异绑定 VCL 较深低排除原生TClientSocket的好处是它没有多余的封装TCP 层的连接、读写都直接暴露适合做协议栈的底座。坏处是 WebSocket 协议里还有不少精细的位操作、帧格式、掩码算法这些东西没人替你写。Indy 的TIdTCPClient也可以但它引入了自己的读写缓冲机制在流式解析帧时反而要额外处理边界所以我最终选了System.Sockets这个更底层的单元。2.2 目录结构与核心单元职责项目拿到手之后先看目录结构。整个工程的单元划分非常简单每个单元只做一件事这对理解 WebSocket 协议很有帮助。DelphiWebsockets/ ├── src/ │ ├── WS.Types.pas // 常量、枚举、操作码、帧头记录类型 │ ├── WS.Socket.pas // 底层 TCP 连接封装连接、接收、发送、断开 │ ├── WS.Handshake.pas // 客户端握手请求与响应校验 │ ├── WS.Frame.pas // 数据帧的编码与解码 │ ├── WS.Client.pas // 对外主类 TWSClient封装完整会话 │ └── WS.Exceptions.pas // 自定义异常类型 ├── demo/ │ ├── SimpleClient.dpr // 最小控制台演示程序 │ └── FormChat.dpr // 带窗口的简单聊天演示 └── tests/ └── FrameTests.dpr // 帧编解码的自测程序WS.Types.pas定义整个协议栈的基础类型包括TWSFrameHeader记录类型、TWSCloseCode枚举、TWSState状态机枚举还有若干个常量。这个单元的代码量不大但它是其他所有单元的地基。WS.Socket.pas负责最底层的连接屏蔽掉不同平台下 Socket API 的差异。WS.Handshake.pas只有两个函数一个构造 HTTP Upgrade 请求头一个校验服务端返回的Sec-WebSocket-Accept值。WS.Frame.pas是核心编码和解码都在这一个单元里完成。WS.Client.pas把底层能力组装成一个面向使用者的类对外只暴露Connect、Send、OnMessage这些简单接口。3. 关键模块实现握手、帧解析与数据发送的三板斧WebSocket 协议本身不复杂真正需要动手的就是三个环节。从代码量上看WS.Frame.pas最多因为要处理位运算、可变长度、掩码、分片这些细节。这一章把这三板斧逐一拆开代码都是我从项目中摘出的关键片段完整版本在工程里。3.1 握手请求把 SHA1 和 Base64 玩明白WebSocket 的握手复用 HTTP Upgrade 机制。客户端先发一个普通的 HTTP GET 请求带上Connection: Upgrade和Sec-WebSocket-Key头服务端校验通过后返回101 Switching Protocols。这里最关键的一步是验证服务端返回的Sec-WebSocket-Accept值。function BuildHandshakeRequest(const AHost: string; const APort: Integer): string; var LKey: string; LBytes: TBytes; begin // 生成 16 字节随机数用于后续的 Base64 编码 SetLength(LBytes, 16); TThread.CreateAnonymousThread( procedure var i: Integer; begin for i : 0 to 15 do LBytes[i] : Random(256); end ).Free; LKey : TBase64.EncodeBytesToString(LBytes); Result : GET / HTTP/1.1 #13#10 Host: AHost : IntToStr(APort) #13#10 Upgrade: websocket #13#10 Connection: Upgrade #13#10 Sec-WebSocket-Key: LKey #13#10 Sec-WebSocket-Version: 13 #13#10 #13#10; end;说明一下这段的逻辑。Sec-WebSocket-Key是客户端生成的一个 16 字节随机数的 Base64 编码每次连接都应该重新生成不能写成写死的字符串否则服务端可能会认为你是不合法的客户端。Host 字段要带上端口号如果服务端做了域名虚拟主机缺了这个字段大概率会握手失败。服务端返回的Sec-WebSocket-Accept不是直接回显这个 Key而是把 Key 拼接上一个固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11再做 SHA1 散列和 Base64 编码。校验函数长这样function ValidateAccept(const AKey, AAccept: string): Boolean; var LGuidKey: string; LSha1: THashSHA1; begin LGuidKey : AKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; LSha1 : THashSHA1.Create; try Result : TBase64.EncodeBytesToString(LSha1.GetHashBytes(TEncoding.UTF8.GetBytes(LGuidKey))) AAccept; finally LSha1.Free; end; end;这里有个容易踩坑的地方拼接 GUID 时是字符串拼接不是把二进制数据拼上去输入串要用 UTF-8 编码而不是 ANSI。如果这个校验不过基本可以判断服务端不支持 WebSocket 或中间有代理网关干扰了握手。3.2 数据帧解析位运算里的 FIN、OPCODE 与掩码握手完成后连接就从 HTTP 协议无缝切换到 WebSocket 数据帧协议。由于 TCP 是字节流没有消息边界所以解析帧是协议栈里最容易出错的地方。一个帧头最少 2 字节最多 14 字节载荷长度分 7 位、16 位和 64 位三种情况。function DecodeFrameHeader(const AData: TBytes; var AOffset: Integer): TWSFrameHeader; var B1, B2: Byte; begin B1 : AData[AOffset]; B2 : AData[AOffset 1]; Result.Fin : (B1 and $80) 0; Result.Opcode : B1 and $0F; Result.Masked : (B2 and $80) 0; Result.PayloadLen : B2 and $7F; Inc(AOffset, 2); // 三个特殊长度标记126 表示后面 2 字节是长度127 表示后面 8 字节是长度 case Result.PayloadLen of 126: begin Result.PayloadLen : (AData[AOffset] shl 8) or AData[AOffset 1]; Inc(AOffset, 2); end; 127: begin Move(AData[AOffset], Result.PayloadLen, 8); Inc(AOffset, 8); end; end; // 客户端发送的帧必须带掩码服务端发送的帧必须不带掩码 if Result.Masked then begin Move(AData[AOffset], Result.MaskKey, 4); Inc(AOffset, 4); end; end;B1的高 1 位是 FIN低 4 位是操作码。操作码 0x0 表示这是一个分片帧的中间部分0x1 是文本帧0x2 是二进制帧0x8 是关闭连接0x9 是 Ping0xA 是 Pong。PayloadLen的 7 位表示 0-125 之间的数126 和 127 是扩展长度的标记。注意 64 位长度我用Move直接拷贝但这里有个字节序问题下面单独说。这里有一个新手很容易写错的逻辑把B1和B2当作有符号数来运算。所以在代码开头就把它们声明为Byte类型防止 Delphi 的编译器把高位当成符号位处理。3.3 数据帧构造客户端发送时必须处理的 Mask 位RFC 6455 规定客户端发送的帧必须设置掩码位掩码密钥是 4 字节随机数。载荷数据的每个字节都要与掩码字节交替异或规则是data[i] xor mask[i mod 4]。这个设计是为了防止早期代理服务器对缓存投毒现在虽然实际风险不高但协议强制要求不遵守就会被服务端断开连接。procedure MaskPayload(var AData: TBytes; const AMaskKey: array of Byte); var i: Integer; begin for i : 0 to Length(AData) - 1 do AData[i] : AData[i] xor AMaskKey[i mod 4]; end; function EncodeFrame(const AOpcode: Byte; const AData: TBytes): TBytes; var LHeader: array[0..13] of Byte; LMaskKey: array[0..3] of Byte; LDataLen, LHeaderLen, i: Integer; begin LDataLen : Length(AData); // 根据载荷长度决定帧头长度 if LDataLen 126 then LHeaderLen : 2 else if LDataLen 65535 then LHeaderLen : 4 else LHeaderLen : 10; // 客户端帧必须掩码所以帧头再加 4 字节 Inc(LHeaderLen, 4); SetLength(Result, LHeaderLen LDataLen); // 组装帧头 LHeader[0] : $80 or AOpcode; // FIN1, Opcode LHeader[1] : $80; // Mask1 if LDataLen 126 then LHeader[1] : LHeader[1] or LDataLen else if LDataLen 65535 then begin LHeader[1] : LHeader[1] or 126; LHeader[2] : LDataLen shr 8; LHeader[3] : LDataLen and $FF; end else begin LHeader[1] : LHeader[1] or 127; // 64 位长度这里简化为只处理低位因为实际应用很少超 4GB LHeader[2] : 0; LHeader[3] : 0; LHeader[4] : 0; LHeader[5] : 0; LHeader[6] : LDataLen shr 24; LHeader[7] : LDataLen shr 16; LHeader[8] : LDataLen shr 8; LHeader[9] : LDataLen and $FF; end; // 生成掩码密钥并拷贝到帧头 for i : 0 to 3 do LMaskKey[i] : Random(256); Move(LMaskKey, Result[$00], LHeaderLen - 4); Move(AData[0], Result[LHeaderLen], LDataLen); // 拷贝完载荷后对整个载荷部分做掩码运算 MaskPayload(Result, LMaskKey, LHeaderLen); end;编码的流程正好是解码的逆过程先算长度再组装帧头最后做掩码复制和异或。注意MaskPayload在这个重载版本里多了一个AOffset参数表示掩码起始位置因为前面已经有帧头占位了。关于 64 位长度我做了简化的处理只填充低 4 字节。对桌面端应用来说单帧超过 4GB 的发送场景几乎不存在但如果你要写一个通用的协议库这里还是应该完整实现整个 64 位长度的填充逻辑。3.4 完整数据帧接收处理粘包和分片上面只讲了帧头解码实际使用中还必须处理 TCP 粘包。也就是说一次Recv可能只读到半个帧也可能一次读到两个甚至更多帧。业界常见的做法是用一个循环队列或者累积缓冲区把收到的数据先存起来然后循环尝试解析。procedure TWSClient.ProcessIncomingData(const AData: TBytes); var LBuffer: TBytes; LOffset: Integer; LHeader: TWSFrameHeader; begin LBuffer : FReceiveBuffer AData; // 累积到接收缓冲区 LOffset : 0; while True do begin // 帧头都读不完整等待更多数据再解析 if Length(LBuffer) - LOffset 2 then Break; // 尝试解析帧头offset 相应前移 LHeader : DecodeFrameHeader(LBuffer, LOffset); // 载荷数据还没收完整等待 if Length(LBuffer) - LOffset LHeader.PayloadLen then Break; // 取 payload 部分并触发事件 DoMessage(LHeader.Opcode, Copy(LBuffer, LOffset, LHeader.PayloadLen)); Inc(LOffset, LHeader.PayloadLen); end; // 剩余未消费数据挪到缓冲区头部 FReceiveBuffer : Copy(LBuffer, LOffset, Length(LBuffer) - LOffset); end;我认为这是整个项目里最容易出 bug 的代码。如果漏处理 Accumulate 这一步收两个连续的帧就会解析错位如果LOffset没有正确前移会出现死循环。我在测试阶段写过一个脚本连续发送 10000 个帧结果期待 0 错误实际发现一多半的 frame 被错误切分最后排查下来就是这个问题。分片帧的处理也不容忽视。一个消息可能拆成三段发送第一段FIN0, Opcode0x1中间段FIN0, Opcode0x0最后一段FIN1, Opcode0x0。所有分片包共享同一块消息缓冲区最后一段触发消息重传。我在DoFrameComplete里写的逻辑是遇到Opcode0x0时先把数据追加到FragmentedMessage只有FIN1才提交整条消息事件控制帧Ping/Pong/Close则不受分片控制可以插在数据帧中间。4. 让它跑起来编译、连接与会话调试的完整流程很多读者拿到代码的第一反应是项目能不能直接跑这章从零开始走一遍编译、连接、验证的完整过程。4.1 编译环境与运行 Demo项目不依赖任何第三方库唯一要求是 Delphi 版本支持System.Net.Socket单元建议备好 10.3 以上的版本。打开demo/SimpleClient.dpr后直接按 F9 编译即可。控制台程序启动后会用交互方式让你输入 WebSocket 服务地址然后建立连接并发送一条测试文本。var LClient: TWSClient; begin LClient : TWSClient.Create; try LClient.OnMessage : procedure(const AOpcode: Byte; const AData: TBytes) begin WriteLn(收到消息opcode IntToStr(AOpcode) , 长度 IntToStr(Length(AData))); end; LClient.Connect(127.0.0.1, 8080, /ws); LClient.SendText(Hello, WebSocket!); ReadLn; LClient.Disconnect; finally LClient.Free; end; end;Connect的三个参数分别是主机地址、端口和路径。如果服务端跑在/ws路径上而这里写了/握手会直接失败。握手成功后可以通过LClient.IsConnected判断状态这个属性底层维护了一个状态机只有收到101响应后才置为True。4.2 用本地服务验证协议正确性如果手头没有现成的服务端可以用 Node.js 起一个简单的 WebSocket 服务做验证。这不是项目内的代码但配合调试很好用。const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080, path: /ws }); wss.on(connection, (ws) { console.log(client connected); ws.on(message, (data) { console.log(received: data); ws.send(echo: data); }); });你可以在SimpleClient.dpr里连接这个本地服务然后在 Node 端观察消息是否到达。两边互通了说明协议栈的握手和帧编解码的核心路径没问题。这里的 Node.js 只当作一个可用的测试工具实际交付的项目里它不参与运行。4.3 避坑与常见问题这一章是血泪经验沉淀下来的我遇到的坑基本都集中在协议细节和 Socket 的使用习惯上希望后面的人少走弯路。第一个坑握手无效参数而非升级失败。现象是服务端返回400 Bad Request客户端报握手异常。原因是Sec-WebSocket-Key生成逻辑里用了一个随机长度不足的数组或者每次连接复用了同一个 Key。解决确保每次连接生成 16 字节加密安全的随机数并重新执行 Base64。还有一个隐蔽原因HTTP 头部的换行必须是#13#10如果不小心用了#10服务端解析会失败。第二个坑一次 Recv 收不到完整帧。现象是客户端偶发性收到PayloadLen与实际内容不符的数据包解析后出现乱码。原因是 TCP 的分段和粘包特性导致。解决不要在一个Recv回调里假设数据是完整的必须维护接收缓冲区按照「暂存 - 尝试完整帧 - 处理 - 剩余继续」的方式循环直到缓冲区不足才等待下一批数据。第三个坑掩码计算错误导致服务端断开。现象是连接建立后第一次 Send 成功第二次 Send 后服务端直接断开日志里出现A server must not mask any frames的提示。原因是我在处理客户端发送的发送缓冲区时意外地让服务端发来的解掩码逻辑也执行了一遍导致数据被二次异或。解决把掩码逻辑明确分成两个独立函数客户端发送帧只走MaskPayload服务端数据只走UnmaskPayload每个帧头里的 Mask 位就是区分依据。第四个坑连接断开时异常无法正确识别。现象是服务端主动关闭时客户端收到的是Connection Reset但代码不知道这算正常关闭还是错误。解决处理好关闭握手的流程。正常关闭时服务端会发送一个Opcode0x8的控制帧带上关闭状态码和原因。在ProcessIncomingData里遇到关闭帧时应该主动回一个关闭帧并完全断开 TCP 连接而不是让底层 Socket 抛异常。异常只留给网络中断那些真正异常的场景。第五个坑Delphi 的字符串编码问题。现象是发送中文消息后服务端收到乱码。原因是 Delphi 的String默认是 UTF-16直接转成字节时如果没有指定编码会以 ANSI 编码发送。解决所有文本帧在发送前显式执行TEncoding.UTF8.GetBytes收到数据后再用TEncoding.UTF8.GetString还原保持一致。5. 二次开发指南把它改成你的生产级客户端项目现在已经是能跑的状态但距离实际业务使用往往还差几步。这一章给出几个我自己会用到的改造方向每个方向都配有明确的切入代码位置。如果你想让它支持 WSSWebSocket over TLS可以在WS.Socket.pas里替换底层 Socket 为用TIdSSLIOHandlerSocketOpenSSL封装的 Indy Socket或者在 Delphi 的System.Net.Socket基础上引入 OpenSSL DLL 转接层。我一般会在TWSClient里增加一个参数接收一个已配置好的 SSL Handler需要加密时传入不需要时传nil对上层业务完全屏蔽协议差异。需要注意自签名证书时你还需要配置一个校验回调。改造量不大但能覆盖ws://和wss://两种业务形态。如果要处理心跳保活WebSocket 协议本身提供了 Ping/Pong 机制。在WS.Frame.pas里我已经预留了 Ping 构造函数的入口只需要在TWSClient里加一个定时器每隔 30 秒发送Opcode0x9的控制帧并记录上次收到 Pong 的时间。连续两个心跳周期没有收到 Pong就触发OnDisconnected事件。这种方案比应用层的自定义心跳更符合协议规范不会污染业务消息的语义。代码位置大约在TWSClient.StartHeartbeat这个方法附近添加一个TTimer字段即可。还有两个值得做的点一个是把TWSClient从单线程改为线程池模型因为桌面端聊天场景可以单线程但如果要同时管理几十个 WebSocket 连接每个连接都需要独立线程处理接收另一个是对控制帧做完整支持特别是关闭握手的超时重试机制防止恶意服务端不回复 Close 导致资源泄漏。我聊一下自己的改造习惯。协议栈这类代码最容易翻车的地方就是小功能不断堆积后单元测试没有跟上。现在我每次改完必做的验证流程是先跑tests/FrameTests.dpr里的帧编解码自测再启动本地 Node 服务做一次长消息收发、一次短线重连、一次客户端断网。这套流程不是可有可无的仪式之前有好几次都是因为自以为小幅改动了掩码逻辑结果把整个帧解析弄崩了。从那以后我强制自己每次改动后先把这四件事过一遍再继续下一个功能。希望这份笔记能帮你在 Delphi 里填上 WebSocket 这块拼图少踩我踩过的那些坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站