简介基于Delphi编写的WebSocket服务端控件源程序包由作者老吴整理面向需要在Delphi项目里快速搭建WebSocket服务的开发人员。控件已实现接收和发送客户端文本消息、二进制流消息支持Ping心跳、广播、全部断开、在线客户端列表与数量统计、各客户端收发消息计数等功能当前尚未支持wss加密通信更适合普通网络条件下的消息服务开发。压缩包共包含34个文件以pas源程序、dpk控件包、dproj工程文件为核心另外还有exe演示程序、dfm窗体文件、res资源与png图标等整体容量仅951KB结构清晰方便直接查看控件源码和运行效果。目前已有2098人学习下载对想掌握Delphi实现WebSocket协议或直接复用控件代码的开发者来说代码包内含完整可编译控件、配套Demo和可执行程序能够显著减少从零搭建服务端的时间和精力。1. 为什么 Delphi 写 WebSocket 服务端仍然值得看一眼一个 Delphi 老项目要加实时推送这是最现实的开局业务逻辑、数据库访问、历史报表全在 Windows 服务端上重写语言是不可能的轮询却又把内存和带宽吃得很厉害。WebSocket 刚好是协议层最后一段拼图而“WebSocket Delphi server 服务端源代码.rar”这套东西解决的就是“Delphi 能不能自己扛一个 WebSocket 服务端”的疑问。把源码包编译成 exe 跑在服务器上不引入重量级框架不依赖外部网关客户端用浏览器直连这是它最有吸引力的地方。适合读这篇文章的人也很明确你手上有一个 Delphi 7 到 Delphi 10 之间的遗留系统需要给前端或 App 加实时通知、聊天、看板刷新或者你拿到这份源码包但还没跑起来想先弄明白里面哪些单元是关键、哪些参数是摆设。这里不会把协议背一遍给你听只讲服务端代码里绕不开的握手、帧解析、线程模型以及源码包里最常出问题的那几个位置。2. 先跨过两道门槛握手校验和 WebSocket 帧解析2.1 握手不是“答一句 101”就行Accept Key 少算一位就连不上WebSocket 握手本质是一次 HTTP Upgrade。客户端发来的请求长这样GET / 带有 Upgrade: websocket、Sec-WebSocket-Key、Sec-WebSocket-Version: 13。服务端回应 101 之外必须返回正确的 Sec-WebSocket-Accept 头。很多第一次做的人以为随便回一句“101 Switching Protocols”就行结果浏览器直接报 “Error during WebSocket handshake”。Accept 的计算只有三步把客户端传来的 Sec-WebSocket-Key 拼接固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11对这个字符串做 SHA1再做 Base64。常量是 RFC 6455 写死的不是随便选的。坑往往出在拼接时带着原请求里的回车换行、或者只在末尾补了空格再哈希算出来的 Accept 完全不一样。uses System.NetEncoding, System.Hash; function ComputeAcceptKey(const AKey: string): string; var LCombined: string; LDigest: TBytes; begin // RFC 6455 固定的 GUID不能改也不能把它当版本号去掉 LCombined : AKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; LDigest : THashSHA1.GetHashBytes(LCombined, TEncoding.UTF8); Result : TBase64Encoding.Base64.Encode(LDigest); end;这段代码的输入 AKey 必须原样保留客户端请求里的 Sec-WebSocket-Key不要 Trim、不要大小写转换。SHA1 是对 UTF-8 字节做的不是对 UnicodeString 内存直接哈希。很多新版 Delphi 的隐性 bug 就出在 GetHashBytes 默认编码上所以这里显式传了 TEncoding.UTF8。Base64 之后按规范不带换行符响应的 HTTP 头里也用 CRLF 结尾。响应头拼装时顺序不重要但别少了 Upgrade 和 Connection 两个头。常见的错误是把 Connection 写成 keep-alive或者把 Sec-WebSocket-Accept 的大小写写错浏览器对头内容是大小写不敏感的对值敏感。2.2 客户端字节流不是消息帧头、掩码和 64 位长度握手完成后WebSocket 的数据都是“帧”的形式。没有经验的开发者最容易犯的错是把一次 socket 接收到的字节当成一个完整消息来解。TCP 是流协议一帧数据可能分三次到达三帧消息也可能一次到齐。先记住帧头布局。第一字节高四位是 FIN表示这是不是最后一个分片低四位是 opcode1 表示文本帧2 表示二进制帧8 表示关闭9 是 ping10 是 pong。第二字节最高位是 MASK客户端发来的帧必须置 1低七位是 payload 长度如果值小于 126这个值就是实际长度等于 126则后面两字节才是长度等于 127则后面八字节是长度。服务端解析时有个细节经常被忽略客户端帧必须带掩码掩码 key 是四个字节紧接着扩展长度字段。payload 的每个字节要依次和掩码 key 的四个字节做异或key 用完一轮继续循环。很多源码包在写 demo 时只处理短消息长度一超过 125 就错位因为没有跟进第二段和第三段的长度字段。procedure ParseWebSocketFrame(const AData: TBytes; var AOpcode: Byte; var APayload: TBytes); var B1: Byte; LPayloadLen: UInt64; LIndex, i: Integer; LMaskKey: array[0..3] of Byte; begin if Length(AData) 2 then Exit; AOpcode : AData[0] and $0F; B1 : AData[1]; LPayloadLen : B1 and $7F; LIndex : 2; if LPayloadLen 126 then begin LPayloadLen : (AData[LIndex] shl 8) or AData[LIndex 1]; Inc(LIndex, 2); end else if LPayloadLen 127 then begin LPayloadLen : 0; // 网络字节序高字节在前逐字节拼进来 for i : 0 to 7 do LPayloadLen : (LPayloadLen shl 8) or AData[LIndex i]; Inc(LIndex, 8); end; // 客户端帧必须带掩码这是 RFC 6455 的强制要求 if (B1 and $80) 0 then begin Move(AData[LIndex], LMaskKey[0], 4); Inc(LIndex, 4); SetLength(APayload, LPayloadLen); Move(AData[LIndex], APayload[0], LPayloadLen); for i : 0 to LPayloadLen - 1 do APayload[i] : APayload[i] xor LMaskKey[i mod 4]; end else begin SetLength(APayload, LPayloadLen); Move(AData[LIndex], APayload[0], LPayloadLen); end; end;这段代码的关键在 127 那个分支。八字节长度按网络字节序排列高字节在低地址直接套 Int64 的话需要先做字节反转。源码包里如果看到有人直接用了 LPayloadLen : PInt64(AData[LIndex])^那就是隐患。另外实时现场往往不能这样静态解析你要先确认缓冲区里凑够了二字节帧头、扩展长度字节和四字节 mask key再动 payload否则 Length 判断会失效。2.3 最小服务端线程从 socket 按“完整帧”读不是等“完整消息”帧解析只是工具真正跑起来要靠 socket 循环。Delphi 服务端源码包最常见的线程实现是“一个监听线程 每连接一个工作线程”。工作线程的任务很单一循环读取字节喂给一个连接级缓冲区每次尝试从缓冲区里取出一帧取出后回调业务层。关键习惯是写一个“读满 N 字节”的工具函数。socket 不是每调用一次 Recv 就恰好返回你要的长度尤其在高并发下返回值经常小于请求值。function ReadExact(ASocket: TSocket; ABuf: PByte; ACount: Integer): Integer; var LRead, LLeft: Integer; begin Result : 0; LLeft : ACount; while LLeft 0 do begin LRead : Recv(ASocket, ABuf, LLeft, 0); if LRead 0 then Break; // 连接关闭或出错 Inc(ABuf, LRead); Inc(Result, LRead); Dec(LLeft, LRead); end; end;调用方在使用时先读两字节帧头然后再根据帧头里的长度字段决定要不要继续读扩展长度和 payload。这样既能避免半帧问题也能天然处理多条消息合并到达的情况。源码包里凡是出现“直接把 Recv 结果当整包消息解析”的代码都应该怀疑它的稳定性。我在实际维护中会额外做一层缓冲把每次 Recv 的数据追加到 TBytesList然后尝试循环取出所有完整帧剩余部分留在缓冲里等下一个 Recv 周期。3. 把服务端源码包跑起来编译、依赖、第一个 WebSocket 客户端3.1 先看清压缩包里的常用文件不是每个单元都要改你手里的压缩包可能命名不完全一样但结构上有很大的共性。我一般会先打开包看五个位置确认每个文件职责以后再编译避免在命令行瞎试。文件职责拿到后先确认什么WebSocketServer.dpr程序入口创建监听 socket端口、是否控制台程序、是否支持 Windows 服务WSConst.pas全局参数常量端口、绑定 IP、最大连接数、心跳间隔WSServer.pas监听线程、客户端会话管理线程模型、回调是否线程安全WSFrame.pas帧解析、帧拼装是否处理了 126/127 长度、是否处理掩码WSCrypt.pasSHA1 和 Base64 工具Accept Key 的 GUID 拼接是否正确如果压缩包里还带一个 client 目录或者 HTML 页面那是调试用的不是服务端必须的一部分。先不要把客户端代码也编译进服务端保持入口干净后面排错容易定位。有的源码包会带 Indy 依赖那你还要确认 Indy 版本和当前 Delphi 版本匹配Indy 10 和 Indy 9 在字符串处理上有明显差异。3.2 编译工程先解决 Unicode、路径和运行库这三处卡点老代码拿到新编译环境最常见的报错是“E2010 Incompatible types: ‘AnsiChar’ and ‘Char’”这类。根源大多是源码用 Delphi 7 或更早版本写的默认字符串是 AnsiString而新版 Delphi 默认是 UnicodeString。握手响应头里到处是字节拷贝处处都可能版本不兼容。program WebSocketServer; {$APPTYPE CONSOLE} {$H} // 确保长字符串模式否则 string 会退化成短字符串 uses System.SysUtils, System.Classes, WSConst in WSConst.pas, WSServer in WSServer.pas; var LPort: Integer; begin LPort : WSConst.ServerPort; try WSServer.Start(LPort); WriteLn(WebSocket server listening on port IntToStr(LPort)); except on E: Exception do begin WriteLn(Startup failed: E.Message); ExitCode : 1; end; end; end.编译前先看一下项目文件里 uses 部分是否包含业务无关的单元比如登录框、数据库组件这些在服务端源码包里多半是从某个 GUI demo 抄来的应该删掉。{$H} 是关键不加的话在旧版 Delphi 兼容模式下 string 只有 255 字节长度握手响应头稍微长一点就被截断。依赖方面如果源码包用了 Indy需要把 Indy 的 IdGlobal、IdHashSHA1、IdBase64Component 等单元路径加进 IDE 搜索路径。不要用“编译一次报错再搜一次”的土办法先把包的根目录加到 Library Path编译错误会少很多。编译完成后先别急着双击 exe用命令行跑一次观察标准输出的监听日志确认端口是否真的被 bind 住。3.3 用浏览器页面当第一个客户端连接、发消息、收消息服务端源码包跑没跑通最直接的验证工具是浏览器而不是另一个 Delphi 程序。浏览器对握手协议的实现非常严格Accept Key 有半个字节不对都会立刻断开比拿自定义客户端测会更早暴露问题。!DOCTYPE html html langzh-CN headmeta charsetutf-8titlews debug page/title/head body input idmsg valuehello server stylewidth: 240px; button onclicksendMsg()发送/button div idlog/div script var ws new WebSocket(ws://127.0.0.1:9000); ws.onopen function () { log(连接已建立); }; ws.onmessage function (e) { log(收到: e.data); }; ws.onclose function () { log(连接已关闭); }; ws.onerror function () { log(发生错误); }; function sendMsg() { var val document.getElementById(msg).value; ws.send(val); } function log(s) { var d document.createElement(p); d.textContent s; document.getElementById(log).appendChild(d); } /script /body /html这个页面的地址要用 ws:// 而不是 wss://服务端没有配置 TLS 证书时不能用加密连接否则会报安全错误。调试时把服务端地址写成 127.0.0.1 能避开一部分 Windows 防火墙弹窗但真正部署到局域网时是 0.0.0.0 端口需要在防火墙里放行。打开页面后如果日志停在“连接已建立”但收不到回应说明握手通了但服务端的消息回写逻辑没工作如果直接停在“发生错误”然后关闭多半是握手响应没通过。不要急着改业务代码先把握手响应头和标椎 RFC 6455 样例对一遍。3.4 源码包里自带的客户端工程调试用还是演示用不少源码包会把“Delphi 客户端”和“服务端”一起打包。客户端演示程序的意义在于同一个项目里可以直接按 F9 就能跑交互不依赖浏览器。但请注意区分客户端的握手和服务端是两套逻辑客户端要对自己发出的帧做掩码服务端则严禁对响应帧做掩码。如果源码包里两份代码共用了同一个编解码单元往往要仔细看条件和分支。我在调试时会把客户端工程里的连接地址改成本机回环地址先确认客户端自身没有依赖服务端启动顺序再换局域网 IP。客户端不是必须的真正上线时绝大多数前端只会用浏览器或移动端 SDK源码包里的客户端工程跑通了也只是说明协议兼容初步没问题不能替代服务端的并发验证。4. Delphi WebSocket 服务端的 5 个踩坑记录连接、粘包、中文、线程、死连接4.1 连接刚建立就被客户端断开Accept Key 计算错或响应头格式不完整现象浏览器控制台报握手失败服务端日志显示客户端连接后 0 到 3 秒内就关闭抓包能看到服务端回了 101但紧接着客户端发 RST。 原因Sec-WebSocket-Accept 值不对或者响应头里把 Upgrade 写成了小写之外的变体又或者 HTTP 头之间用了 LF 而不是 CRLF。 解决独立写一个函数专门计算 Accept Key对着标准样例做单元测试。常用的样例是 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ期望输出 s3pPLMBiTxaQ9kYGzzhZRbKxOo。如果这个样例没过就别往下查业务代码。响应头最后统一用 #13#10 拼行最后的空行也是 CRLF不要只用 #10。这个样例是 RFC 6455 官方文档里现成的任何语言的 WebSocket 库都拿它做回归测试。我每次升级 Delphi 版本后都会先跑一遍这个样例免得编译器对字符串字面量的编码处理变动把结果带偏。4.2 超过 125 字节的消息解析错位长度字段处理顺序没跟上现象短消息收发正常一旦消息超过 125 字节收到的内容变乱有时后半段被丢了有时直接触发异常。 原因帧头第二字节的低 7 位在 126 和 127 时要走不同的扩展长度分支很多源码包只写了“小于 126”和“等于 126”两种情况把 127 的八字节长度漏了有的虽然写了但没做字节序处理按整型读出来是 0X0102030405060708 这样反着的值。 解决把长度解析单独抽成函数逐字节左移拼接不要用 Cast 方式直接转 Int64。写完后用 126、127 和 125 三种边界场景做自测分别发送 124、125、126、65535 字节的消息。65535 这个长度正好触发七位127 的分支最容易看出问题。4.3 服务端偶发“一帧变两帧”“两帧合体”TCP 粘包与半包现象客户端发一句话服务端收到两条空消息或者内容截半反过来客户端连发两条消息服务端只收到一条payload 变成两段内容拼接。 原因WebSocket 是消息协议TCP 是字节流协议。Recv 一次返回几个字节由底层网络决定和发送方的 Write 次数没有固定对应关系。 解决服务端必须实现“读两字节帧头再按帧头长度读完整 body”的机制。可以用循环读满指定长度也可以维护每连接的接收缓冲区缓冲区够一帧才解析。源码包里如果解析函数做完立刻清空缓冲区那基本都会踩这个坑。正确习惯是先 TryParse剩余数据保留在缓冲里等下一次 Recv 继续追加。4.4 一开多人界面就假死回调跑在连接线程里直接操作 UI现象单连接调试正常开到三五个浏览器标签后服务端界面卡住窗口拖不动最小化恢复很慢用“开始”按钮发的指令半天才有反应。 原因Delphi 的 VCL 组件只能由主线程操作。很多 demo 为了方便把收到的消息直接往 Memo.Lines.Add 里送Socket 工作线程一旦并发往 VCL 塞数据界面就锁死严重时整个进程崩溃。 解决工作线程里只做协议解析和业务处理界面更新统一用 TThread.Queue 或 TThread.Synchronize 排到主线程。服务端如果不需要界面最稳妥的做法是写成控制台程序或 Windows 服务彻底去掉 VCL 依赖。源码包如果自带窗体建议把窗体压缩成托盘通知或者干脆退役。4.5 客户端拔网线服务端连接越攒越多没有心跳机制现象连接数长时间只增不减任务管理器里句柄数持续上涨内存占用缓慢爬升重启进程后数字一下子降下来。 原因TCP 协议本身的 keepalive 在应用层看不见默认参数在 Windows 上通常在两小时左右才感知断线。客户端断电、退出 App 时可能连 FIN 都没发服务端的 socket 看起来还活着。 解决源码包里必须有心跳。服务端每隔 30 到 60 秒发一次 ping 帧客户端在协议层面应当回 pong。连续两三个周期没收到 pong就把连接主动 Close。注意心跳要放在独立定时器线程里不要塞在某个连接的 receive 循环里否则这个请求频次低时心跳也会被拖慢。5. 服务端参数怎么定心跳间隔、缓冲区、线程模型和超时值5.1 心跳和超时源码包里 ping/pong 是怎么定的心跳参数直接决定死连接能存活多久。我自己压测过一组组合心跳周期 30 秒、允许连续丢两个 pong 就断开是比较可靠的默认值。太短则频繁发 ping 占用带宽太长则死连接清理不及时。参数推荐值依据说明心跳间隔30 秒多数业务消息间隔不会超过 5 秒30 秒能及时发现网络异常心跳超时次数2 次连续两次没有收到 pong判定死连接握手超时10 秒超过 10 秒没完成握手的连接直接关闭防慢速攻击读写超时60 秒配合心跳使用避免 read 永久阻塞在线程里这里要区分“心跳间隔”和“读写超时”。读超时设的比心跳周期长是合理的不然网络抖动一次就断开正常连接。客户端如果通过 Nginx 反向代理Nginx 自带 proxy_read_timeout 默认 60 秒所以服务端心跳周期设成 30 秒不会和代理冲突。源码包如果只写了 ping 没写 pong 处理也要补上因为协议规范要求在收到 ping 时回 pong。5.2 缓冲区和线程分配值定得越狠性能掉得越快连接数不多时缓冲区怎么设都看不出问题到千级连接时参数就是生死线。每连接一个固定缓冲区的做法最直白但 1000 个连接每个 64KB 就是 64MB 内存这还不算业务处理过程中的临时副本。我一般把接收缓冲设为 4KB 起步、按需扩容到 64KB长时间空闲的连接保持 4KB 即可。参数默认值大并发时调整方向单连接初始接收缓冲4KB消息普遍较大时提到 16KB 或 32KB单连接最大缓冲64KB超过 64KB 的消息应走分片或业务层分包最大连接数1000超 1000 先看句柄和线程数不要盲目调高线程模式每连接一线程超 2000 连接改用 I/O 完成端口或单线程复用线程模式是大头。每连接一线程写起来简单但 2000 个连接就是 2000 个线程Windows 上线程栈默认 1MB光栈空间就占 2GB 虚拟内存。Delphi 源码包如果没有引入完成端口基本都是每连接一线程这种方案跑到 500 连接附近先看 CPU 再看内存压力多数来自线程切换而不是协议解析本身。要上大规模就在架构层面分割多实例不追求单进程硬扛。5.3 把参数从常量改成配置的 3 处修改源码包里参数通常会写死在 WSConst.pas 里方便但不利于部署。我一般会改成读取同目录下的 ini 文件保留默认值兜底。type TWSConfig record Port: Integer; BindIP: string; MaxConnections: Integer; HeartbeatIntervalSec: Integer; RecvBufferSize: Integer; end; function LoadConfig(const AFileName: string): TWSConfig; var LIni: TIniFile; begin Result.Port : 9000; Result.BindIP : 0.0.0.0; Result.MaxConnections : 1000; Result.HeartbeatIntervalSec : 30; Result.RecvBufferSize : 64 * 1024; if not FileExists(AFileName) then Exit; LIni : TIniFile.Create(AFileName); try Result.Port : LIni.ReadInteger(server, port, Result.Port); Result.BindIP : LIni.ReadString(server, bind_ip, Result.BindIP); Result.MaxConnections : LIni.ReadInteger(server, max_connections, Result.MaxConnections); Result.HeartbeatIntervalSec : LIni.ReadInteger(server, heartbeat_sec, Result.HeartbeatIntervalSec); Result.RecvBufferSize : LIni.ReadInteger(server, recv_buffer_size, Result.RecvBufferSize); finally LIni.Free; end; end;注意 RecvBufferSize 是初始分配的单个连接缓冲区大小不是所有连接的共享池。调大它只在该连接收到密集型消息时有效对峰值并发用处不大。真正要调的是 MaxConnections 和 HeartbeatIntervalSec。上线前把配置文件和服务端 exe 放在同目录改配置不用重新编译省掉很多来回。6. 收尾用这几个验证动作确定服务端不会在你走后出生产问题服务端源码包能编译、能跑出 hello world只是第一步。真正可以放心上线必须按下面这套动作做一遍验证缺一项都可能留隐患。先做协议合规验证。浏览器页面连上后分别发送 1 字节、125 字节、126 字节、64KB 的消息再发一段中文和一段 Emoji确认服务端回显完整。这一步能同时覆盖掩码、长度分支和 UTF-8 解码。Emoji 是四字节 UTF-8很多源码包只用三字节 UTF-8 会在这里翻车。再做并发与注意状态验证。用 Python 写一个短脚本同时开 200 个 WebSocket 连接每个连接循环发 100 条消息观察服务端内存和句柄数是否平稳断开一半连接后句柄是否落回来。import asyncio import websockets async def client(i: int): uri ws://127.0.0.1:9000 async with websockets.connect(uri) as ws: for j in range(20): await ws.send(fclient-{i}-{j}) resp await ws.recv() assert isinstance(resp, str) async def main(): tasks [client(i) for i in range(200)] await asyncio.gather(*tasks, return_exceptionsTrue) # 脚本退出后去服务端看句柄是否回落到基线 asyncio.run(main())这个脚本用的是 websockets 库不是标准库里的东西但它是 Python 生态里验证 WebSocket 服务端最常用的工具。跑的时候注意在 Windows 命令行里把事件循环策略设成 WindowsSelectorEventLoopPolicy否则 Windows 上可能报事件循环错误。最后做断电模拟。在保持一批连接打开的状态下直接关闭客户端进程等一个心跳周期加超时时间再数服务端的活动连接。如果服务端能在一个心跳周期内清掉死连接说明心跳代码是真的在工作。如果连接数不动回去查第 4.5 小节多半是心跳只发了 ping 没有检测 pong。我自己的习惯是上线前把这些检查记成一张两页纸的核对单部署时照着勾选。Delphi 写 WebSocket 服务端这事儿真正困难的不是语法和组件而是把 RFC 6455 的字节细节和 Windows 网络栈的脾气都摸透。等这套流程走顺了服务端新加一条消息类型、一个鉴权逻辑成本都很低。希望这篇文章能让你手里的源码包少走几夜弯路也希望你写的服务端能扎扎实实跑住在线业务。本文还有配套的精品资源点击获取