简介:这是一份使用 VB 编写 WebSocket 功能的程序源码,客户端与服务端代码都包含在同一压缩包内,定位清晰,非常适合刚接触网络编程的新手,也适合有经验的 VB 开发人员直接参考复用。压缩包为 zip 格式,整体大小约 476KB,体量轻,下载与部署都很快捷。源码中包含可直接打开的 VB 工程,围绕 WebSocket 的关键机制给出可运行示例,包括连接握手、数据帧构造与解析、消息双向收发、连接状态维护以及关闭流程;读者既能逐段对照学习协议实现,也可以抽取其中客户端或服务端代码,嵌入到即时通讯、远程控制、消息推送等实际项目里。这些源码对初学者来说是一条直观的学习路径,能大幅降低理解协议的门槛;对开发者而言则是一套可复用的通信基础模块。截至目前,该资源已有 754 人学习下载,作者为“程序老媛”并经过亲测校正,质量反馈相对可靠,能够帮助学习者少走弯路,更快掌握 VB 环境下 WebSocket 客户端与服务端的实现思路。
1. 为什么在VB里用WebSocket而不是HTTP轮询:实时语义和握手是一次性的
工位机上的数据看板如果用Timer每200ms拉一次HTTP接口,服务端的内存清单会越堆越高,因为每个请求都带着完整的HTTP头,长连接建立后又断开还会让服务器端口进入TIME_WAIT等待。真正要解决的是“让服务端主动把变化推给客户端”,而不是让客户端反复试探。WebSocket先用一次HTTP Upgrade握手完成协议切换,之后两个方向都能随时发送数据帧。这套VB源码的价值在于不依赖IIS和第三方控件,直接用Socket或Winsock完成RFC6455中最关键的握手与帧解析,适合在现有VB上位机工程里长出一个实时通道,也适合拿来对照验证自己没写完的半套实现。
2. 客户端源码拆解:Sec-WebSocket-Key握手与帧掩码规则
2.1 握手报文:随机Key不是装饰,是协议防代理缓存的锚点
WebSocket握手复用HTTP的GET请求,服务端在响应101之后就不再把它当普通HTTP处理。RFC6455要求客户端生成16字节随机数,Base64编码后放进Sec-WebSocket-Key,服务端把这个Key拼上固定GUID,做SHA1再Base64,回写Sec-WebSocket-Accept。这套流程防止老式代理把Upgrade请求缓存下来,也保证客户端不会连到一个根本不支持WebSocket的中间层。VB工程中常见的做法是直接用Socket发送文本报文,而不是依赖WebBrowser控件模拟请求。
Private Sub SendHandshake(ByVal client As Socket, ByVal host As String, ByVal path As String) ' 生成16字节随机数的Base64,作为Sec-WebSocket-Key Dim seed(15) As Byte Dim rng As New RNGCryptoServiceProvider() rng.GetBytes(seed) Dim key As String = Convert.ToBase64String(seed) Dim request As String = "GET " & path & " HTTP/1.1" & vbCrLf & "Host: " & host & vbCrLf & "Upgrade: websocket" & vbCrLf & "Connection: Upgrade" & vbCrLf & "Sec-WebSocket-Key: " & key & vbCrLf & "Sec-WebSocket-Version: 13" & vbCrLf & vbCrLf Dim sendBytes As Byte() = Encoding.ASCII.GetBytes(request) client.Send(sendBytes) End Sub这段代码里有两处容易被改坏。一是seed数组长度固定为16字节,必须用密码学随机数生成器而不是New Random(),否则后续Accept校验撞Key时会在多个连接之间串消息。二是Host头在端口非80时必须带端口号,例如127.0.0.1:8080,否则部分老网络库会把Host补成默认端口,服务端校验失败直接断开。Sec-WebSocket-Version: 13对应RFC6455最终版,低于该版本的服务端会在响应里返回426并关闭TCP。
2.2 帧格式:只有客户端必须掩码,服务端发来的帧不掩码
连接建立之后,双方收发数据都叫“帧”。服务端推给客户端的帧不需要掩码,客户端发给服务端的帧必须掩码,这是RFC应对早期缓存投毒攻击后的强制要求。帧头第一字节高位是FIN,表示是否为完整消息,低位4位是操作码:0x1文本帧、0x2二进制帧、0x8关闭帧、0x9Ping、0xAPong。第二字节最高位是MASK,低7位是payload长度,当长度超过125时需要在后面追加2字节或8字节的扩展长度字段。客户端源码里一旦把MASK位去掉,浏览器或其他标准客户端会直接报1002协议错误。
| 字段 | 长度 | 说明 |
|---|---|---|
| FIN + RSV + Opcode | 1字节 | 0x81表示完整文本帧 |
| MASK + Payload长度 | 1字节 | MASK=1时后面跟4字节Mask Key |
| Mask Key | 4字节 | 仅客户端发出的帧需要 |
| 扩展长度 | 0/2/8字节 | 长度为126时用2字节,127时用8字节 |
| Payload | 变长 | 用Mask Key逐字节异或后传输 |
2.3 发送文本帧的VB实现:异或、掩码与数组拼装
构建客户端帧时,需要按payload长度决定扩展字段。常见做法是小于126字节直接放进第一字节低7位,126到65535字节之间用2字节扩展长度,再大就用8字节并让第二字节低7位等于127。下面代码处理的是最常见的短文本帧场景,便于直接阅读掩码逻辑:
Private Function BuildClientFrame(ByVal text As String) As Byte() Dim payload As Byte() = Encoding.UTF8.GetBytes(text) Dim maskKey(3) As Byte ' 掩码Key每次发送都要重新随机,不能复用上一次 Dim rng As New RNGCryptoServiceProvider() rng.GetBytes(maskKey) ' 简化版:只处理payload长度小于126的场景 Dim frameLen As Integer = 2 + 4 + payload.Length Dim frame(frameLen - 1) As Byte frame(0) = &H81 frame(1) = &H80 Or CByte(payload.Length) Buffer.BlockCopy(maskKey, 0, frame, 2, 4) For i As Integer = 0 To payload.Length - 1 frame(6 + i) = payload(i) Xor maskKey(i Mod 4) Next Return frame End Functionframe(0)=&H81是FIN加文本操作码的合并结果,&H80 Or CByte(payload.Length)把掩码位固定为1,同时把低7位长度写进去。掩码循环里i Mod 4对应Mask Key的四个字节,VB6转过来的习惯是写i And 3,效果一样但可读性差,建议统一风格。如果payload长度超过125还继续用这个简化版,对端会按错误长度解包,表现就是半个汉字或帧边界错乱。
2.4 接收循环:半包、粘包与UTF-8边界
接收方向最常翻车的是把一次Receive当成完整消息。TCP是字节流,服务端一次可能发来多条帧,也可能一条消息分段到达。源码里如果看到While client.Connected类型的循环,一定要配合待处理缓冲区,把每次收到的字节追加进去,再按帧头解析长度,攒够了才取走一帧。解析UTF-8文本时不要按字节数截断,必须把整帧payload交给Encoding.UTF8.GetString,否则一个汉字被截成两段就会变成两个乱码字符。我一般会把接收缓冲区初始化为4096字节,长帧用List(Of Byte)承接后再ToArray(),而不是依赖Socket自身的ReceiveBufferSize。
3. 服务端源码拆解:TcpListener升级校验、心跳与广播推送
3.1 TcpListener监听:Accept循环与线程分配
服务端通常用一个TcpListener绑定本地IP和端口,然后进入Accept循环。每接受一个TcpClient,把它的Socket交给独立子程序处理,避免单个客户端的网络阻塞拖住其他连接。源码里如果看到纯同步模式,那么每个连接会对应一个Thread,客户端超过几百个后线程切换开销会明显变大;更稳妥的做法是交给ThreadPool或Task.Run,但VB工程中为了省事普遍还是“同步Accept + 每连接一线程”。
Private _clients As New Dictionary(Of String, Socket)() Private _listener As TcpListener Public Sub Start(ByVal port As Integer) _listener = New TcpListener(IPAddress.Any, port) _listener.Start(128) ' 128为挂起连接队列长度 While True Dim tcpClient As TcpClient = _listener.AcceptTcpClient() Dim clientSocket As Socket = tcpClient.Client ' 每个新连接独立线程处理,避免阻塞Accept主循环 Dim t As New Thread(Sub() HandleClient(clientSocket)) t.Start() End While End SubStart(128)里的128不是最大连接数,而是操作系统为Accept队列保留的连接上限,超出后内核会直接丢弃客户端SYN,表现为客户端Connect超时或者长时间无法建立连接。真正限制连接数的还有线程数和Socket句柄数。AcceptTcpClient()是阻塞方法,整个Accept循环要放到后台线程,不能在UI线程里直接跑,否则窗体拖动都会卡住。
3.2 服务端握手校验:SHA1+GUID拼出Sec-WebSocket-Accept
服务端收到客户端握手报文后,取出Sec-WebSocket-Key,拼上协议固定GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做SHA1后Base64编码。这里有两个经典错误:一是对整个HTTP请求做SHA1而不是对Key拼GUID的字符串做;二是用了UTF-8编码,Key本身来自ASCII请求头,虽然结果一样,但请求里混入异常字节时就会埋雷。正确做法是先取头,保持原字符串不动,再拼GUID计算。
Private Function ComputeAccept(ByVal secKey As String) As String Const WS_GUID As String = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" Dim raw As String = secKey & WS_GUID Dim sha As New SHA1CryptoServiceProvider() Dim hash As Byte() = sha.ComputeHash(Encoding.ASCII.GetBytes(raw)) Return Convert.ToBase64String(hash) End Function如果源码里没有校验Key为空和长度,建议补上。Key为空或长度明显不对的请求,要么不是真实客户端,要么是端口扫描器在探测服务。此时返回400比返回101安全。服务端的成功响应固定是HTTP/1.1 101 Switching Protocols,每行以\r\n结尾,最后再加一个空行。缺少\r\n或在报文末尾少写了空行,浏览器会一直停在Connecting状态不触发onopen。
3.3 连接字典:唯一标识、移除时机与僵尸连接
服务端要做广播,就不能让每个连接自生自灭。源码里常见结构是Dictionary(Of String, Socket),Key用Guid.NewGuid().ToString()生成,握手成功后把Socket注册进去,断开时移除。移除时机比添加更讲究:Receive返回0表示对端正常关闭,但网络异常时会直接抛异常,所以接收循环里必须把“返回0”和“抛异常”都当成移除条件,否则字典里会积累一堆半天不清理的僵尸连接。心跳超时也应当归到心跳任务里处理,不要在接收线程里做复杂的锁操作。
| 判定条件 | 处理动作 | 对应场景 |
|---|---|---|
| Receive返回0 | 从字典移除并关闭Socket | 对端正常关闭连接 |
| SocketException异常 | 捕获后移除并释放句柄 | 网线断开、对端进程崩溃 |
| 连续N次Ping无Pong | 主动Close并移除 | 对端假死、网络分区 |
3.4 心跳与广播:Ping/Pong是保活和清理的双保险
服务端源码中通常会有一个定时任务向所有客户端发送Ping帧,opcode为0x9,payload里可以带时间戳。标准客户端收到Ping后会自动回Pong,opcode=0xA。如果连续几次Ping都没有Pong,服务端就可以主动切断连接,把假死和半开连接清出去。广播推送的逻辑是在字典上遍历,向每个Socket发送一帧服务端数据。服务端帧第一字节仍然是0x81,第二字节不设掩码位,直接跟payload长度,比客户端帧少了4字节Mask Key和异或过程。
Private Sub BroadcastText(ByVal message As String) Dim payload As Byte() = Encoding.UTF8.GetBytes(message) Dim frame(payload.Length + 2 - 1) As Byte frame(0) = &H81 If payload.Length < 126 Then frame(1) = CByte(payload.Length) Array.Copy(payload, 0, frame, 2, payload.Length) For Each kvp As KeyValuePair(Of String, Socket) In _clients SyncLock kvp.Value kvp.Value.Send(frame) End SyncLock Next End If End Sub广播里的SyncLock kvp.Value是防止两个线程同时写同一个Socket。TCP的Send在并发写时并不是严格原子的,轻则帧头交错,重则抛ObjectDisposedException。锁的粒度要按单个连接加,不要对整个Dictionary加全局锁,否则一个慢客户端会拖慢所有推送。上面示例只处理了短文本,源码包里完整版会在payload超过125时追加扩展长度字段,这是最容易遗漏的一步。
4. 联调与排错:Wireshark抓包、断开码与VB内存回收陷阱
4.1 联调环境:没有自带客户端时的快速验证脚本
调试VB服务端时,不一定每次都要启动那套源码里的VB客户端界面。可以在命令行里用Python快速模拟一个标准客户端,只需要websockets库和一段简短的脚本即可。这个脚本的作用是验证服务端握手和回包是否符合RFC6455,排除VB客户端代码自身对消息格式的干扰。
import asyncio import websockets async def check(): uri = "ws://127.0.0.1:8080/" async with websockets.connect(uri) as ws: await ws.send("{\"type\":\"ping\"}") msg = await asyncio.wait_for(ws.recv(), timeout=3) print("收到:", msg) asyncio.run(check())wait_for里的3秒是响应超时限制,如果服务端握手有问题或回包延迟超过3秒,脚本会抛出asyncio.TimeoutError。这个脚本只适合验证单连接场景,不能当压测工具用。如果连接一直卡住,不要急着怀疑客户端,先用网络工具抓包确认服务端101响应有没有真正回出来。
4.2 断开码:1006不是正常关闭,是连接凭空消失
WebSocket关闭帧里有2字节状态码,联调时看到的状态码可以直接对应到源码里的位置。下表列出最常遇到的几个:
| 断开码 | 含义 | 排错方向 |
|---|---|---|
| 1000 | 正常关闭 | 无需处理 |
| 1001 | 服务端或客户端要下线 | 检查业务主动Close的触发位置 |
| 1006 | 连接异常中断,且没有关闭帧 | 网线断开、进程崩溃、防火墙超时 |
| 1008 | 策略违规 | 客户端发来的数据不符合源码约定 |
| 1009 | 消息超过限制 | VB接收Buffer或最大消息长度偏小 |
| 1011 | 服务端内部错误 | 服务端异常未捕获,Socket被GC回收 |
最容易误判的是1006。1006表示TCP连接在没有发送Close帧的情况下直接消失。VB源码里最常见两个原因:一是服务端线程异常退出,Socket句柄被系统回收;二是防火墙或路由器空闲超时把长连接变成半开状态,而VB端Receive一直阻塞在等数据,既没有心跳也没有超时机制去发现。处理思路是把接收循环的异常捕获做完整,同时用心跳在超时未收到Pong时主动Close。
4.3 VB6迁移到VB.NET时的三个典型问题
这套源码如果保留了VB6时期的写法,迁移到VB.NET后有几个坑值得单独注意。第一个是字符串编码。VB6的StrConv处理的是ANSI系统代码页,WebSocket协议规定的是UTF-8,如果还沿用老思维,中文payload解包后就会变成两个松散字符,推送出去的JSON经常乱码。第二个是Socket的Dispose时机。VB6没有资源回收概念,Winsock.Close()就完事,但VB.NET的Socket需要Close()和Dispose()配合,或者在Using块里使用。连接数涨到几千后句柄数不降,多半是Close()之后底层句柄没有完全释放。第三个是Winsock控件的MaxPendingConnections默认值只有4,如果源码是从VB6控件拖拽风格改过来的,队列太短时并发一上来,客户端Connect就会间歇性超时。
提示:如果使用的是VB6原生的Winsock控件,建议把
MaxPendingConnections设为64以上,并明确在Listen前调用Bind,否则多网卡环境可能绑定到错误的IP上。
4.4 日志落盘:帧头和状态码一起记录
排错到最后,日志里只写“连接断开”没有意义。建议在关键位置把16进制帧头、payload长度、当前连接数、最后收到数据的时间戳写进日志,一行一条,保存成CSV。这样即使服务端被压垮重启,依然能看到是哪个连接在哪个时间点触发了异常。日志内容至少要包含客户端IP和端口,否则多连接场景下很难区分问题来源。
5. 压测验证:用Python并发脚本压出VB服务端的三个参数边界
5.1 并发压测脚本
验证VB服务端能不能扛住并发,不需要专门买压测工具。下面的Python脚本创建大量客户端同时连接同一个地址,每个连接建立后发送一条文本消息并等待回应,最后统计成功率。
import asyncio import websockets async def one_client(i, uri): try: async with websockets.connect(uri) as ws: await ws.send(f"hello-{i}") msg = await asyncio.wait_for(ws.recv(), timeout=5) return (i, True, msg) except Exception as e: return (i, False, str(e)) async def main(): uri = "ws://127.0.0.1:8080/" tasks = [one_client(i, uri) for i in range(500)] results = await asyncio.gather(*tasks) ok = sum(1 for r in results if r[1]) print(f"成功 {ok}/500") for r in results: if not r[1]: print("失败:", r[0], r[2]) asyncio.run(main())呼应前面客户端验证脚本,这里的关键是wait_for的5秒超时,它代表服务端应答能力的一条边界线。跑完之后看三样东西:连接建立是否全部成功、消息是否有串号、是否有客户端在自动重连后仍然失败。串号说明连接字典的Key分配重复或字典遍历时有并发问题,这比丢包更难查。
5.2 三个参数对应到VB源码位置
第一个是TcpListener.Start(backlog),压测时如果大量连接在SYN阶段就超时,说明backlog不够,线程数也跟不上。第二个是用同步Receive的VB服务端在千级连接下必然出现线程池饥饿,压测前先执行ThreadPool.SetMinThreads(200, 200)把最小线程数抬起来。第三个是Socket的ReceiveBufferSize和SendBufferSize,默认8KB会让高频Ping帧占满带宽,调到32KB或64KB能明显降低小包场景的CPU占用。
把这三个参数固定后再跑脚本,记录成功率和平均时延。如果服务端用的是同步收发且没有心跳线程,压测脚本会暴露一个特征:连接全部建立成功,但只有前几十个连接能正常收发。这种情况不是Buffer问题,也不是backlog问题,而是线程栈已经耗尽,需要在源码层面改成异步接收或带超时的循环,才能继续往上压。
本文还有配套的精品资源,点击获取