ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VB.NET实现TCP/IP通讯转发:从原理到实战

VB.NET实现TCP/IP通讯转发:从原理到实战 简介本资源是一套基于VB.NET开发的TCP/IP通讯转发功能完整实现源码面向网络通信初学者及有一定.NET开发经验的工程师解决实际项目中对双向通信数据包内容实时监控与中继转发的需求痛点。程序采用VS2019开发核心逻辑包含Listener监听服务端与TcpClient转发客户端双模块支持客户端↔服务端↔客户端多向通讯链路的数据捕获、解析与透传便于协议调试与中间件行为分析。压缩包共33个文件含6个关键VB源文件含主窗体FrmTrack.vb及设计器、资源文件、1个解决方案.sln、1个项目文件.vbproj、2个配置文件App.config、Settings.settings及可执行exe等总大小456KB结构清晰、模块职责分明。已有1057人学习下载源码经作者实测校正附带界面截图与完整编译输出开箱即用可直接调试运行并快速理解TCP连接管理、异步读写、数据缓冲与转发策略等核心实现细节。1. 为什么用VB.NET做TCP/IP通讯转发我的真实考量1.1 通讯转发到底在解决什么问题做上位机开发这些年我遇到最多的需求之一就是“把设备A的数据转发给系统B”。设备采集器、PLC、扫码枪、称重仪表这些硬件各自带着自己的TCP/IP协议但上位机软件或者中控系统往往没法直接对接所有设备于是中间就需要一个“透明管道”一边收一边发数据是什么样就原样送过去不解析、不改动这就是典型的TCP/IP通讯转发。我最近用VB.NET重写了一个这样的转发工具。程序本身不复杂监听一个TCP端口有客户端连上来就建立一条链路把从源端收到的字节流写到目标端反向也做同样的事。但真正把它跑稳从“能通”到“能长期在线运行”中间有不少细节值得掰开揉碎聊一聊。这篇文章会把整套设计思路和核心源码讲清楚适合正要做设备数据采集、局域网中继、协议桥接的朋友参考。为什么选VB.NET而不是C#或者C说实话做这种工具类程序VB.NET的劣势没那么明显优势反而很突出开发快、Socket封装成熟、部署方便不用装额外的运行时。尤其是给车间里跑的维护小工具你不想让甲方去装一大堆依赖。.NET Framework在Windows上基本是自带的VB.NET写出来的东西复制个exe过去就能跑这对现场来说太重要了。1.2 VB.NET在这个领域的优势和劣势先聊点实在的。VB.NET做TCP/IP转发的优势首先是System.Net.Sockets这套API足够成熟TcpListener、TcpClient、NetworkStream三件套用起来非常顺手不需要像C那样自己管理socket句柄和底层状态机。其次是VB.NET对异步编程的支持在.NET Framework 4.5以后有了Await和Async写网络转发这种IO密集型任务代码的清晰度比传统BeginRead回调高很多维护起来也省心。再有就是UI。转发程序总要给人看状态谁连着、转发了多少字节、有没有报错。VB.NET做界面是看家本领拖拖控件就能完成一个带实时状态、按钮启停、日志显示的小工具。这个优势在做工业现场工具时特别实在因为我见过太多用纯命令行工具跑转发的案例出了故障没人敢动最后还是得补个界面。但劣势也要说清楚。如果单通道的数据吞吐量非常大比如持续几十MB/s以上的流媒体转发VB.NET的托管内存和GC机制会带来性能损耗这时候C或者直接上Linux下的转发中间件更合适。另外如果客户端连接数达到几千上万个.NET的异步模型虽然能扛但内存占用和GC压力会明显上升。做这种规模的应用我一般不会用VB.NET硬扛而是考虑用专门的网关程序。普通工控场景、设备采集、协议桥接VB.NET完全够用这也是我选择它的核心原因。1.3 选型之前你必须想清楚的几件事写转发程序前先别急着敲代码有几个问题一定要先问自己否则后面返工很痛苦。第一透明的字节流转发还是协议级转发如果只是把A的数据搬到B不需要关心内容那就做透明转发简单稳定。如果你想在中间做解析、过滤、改写、根据内容路由那就是协议网关复杂度完全不一样。很多人一上来就想“顺手把协议也解析了吧”结果转发没跑通解析逻辑先出了一堆bug。我的经验是第一版只做透明转发协议解析放到二期。第二单工还是双工TCP通讯本身是全双工的A能发B也能发。但转发程序可能需要处理“只从A到B”的单向需求也可能需要双向。双向转发不是“再加一个方向”那么简单它涉及两个独立的读取循环各自管理缓冲区还要防止两边同时写入造成的死锁设计时要单独考虑。第三断线之后怎么办。现场环境里网线松动、设备重启、交换机掉电都是常态。转发程序如果设备重启后不会自动重新建立链路那这个工具就是废的。所以断线重连机制是转发程序的刚需不是加分项这点我在后面会重点讲。第四是否需要记录收发数据。很多时候现场排查问题要看到底是哪一端的数据丢了这时候日志功能就特别关键。但不要每收到一个字节就写一次日志那会把磁盘IO拖垮要在缓冲区和日志策略上做设计。想清楚这四件事工具的整体架构其实就已经定了。接下来就是具体实现层面的拆解。2. 转发程序的核心设计思路拆开看其实很简单2.1 通讯转发的本质接收、缓冲、发送TCP/IP通讯转发本质就是一个数据搬运工。源端的socket收到字节流程序把字节流放进缓冲区再从这个缓冲区写到目标端的socket。整个过程像一个两端开口的管道水从一端进另一端出管道自己不存水。但就是这个“管道”写起来有讲究。最容易犯的错误是读一个字节写一个字节。这样做有两个问题一是性能差一次Read和一次Write都是系统调用高频小包会让CPU忙个不停二是容易把TCP的Nagle算法触发出来小包会被组合发送造成莫名的延迟。正确的做法是用一块合适的缓冲区比如4KB或者8KB循环读取读到的数据立刻写入对端然后继续读。这相当于把多次小读写合并成批量读写性能提升非常明显。缓冲区大小到底选多少要看实际包的特征如果设备一包只有几十字节4KB就够了如果传输文件或图片建议用64KB减少循环次数。另外缓冲区要尽量复用。我见过有人每读一次就New Byte()一次运行一晚上之后内存涨了几百MB。正确做法是在读循环外面创建一次缓冲区循环体内反复使用。2.2 单向转发与双向转发的架构差异单向转发的结构很简单一个Pump循环从源端读往目标端写。但双向转发需要两个Pump循环A到B一个B到A一个。这里有个很容易踩的坑两个线程同时往同一个socket写入NetworkStream.Write本身是线程安全的但如果你在写之前先检查了某个状态、再写那检查到写入之间可能有另一个线程改了状态就会出问题。比如你先判断Connected再写但判断完之后连接刚好断了写入就会抛异常。所以写操作要包一层独立的错误处理不要依赖别的线程维护的状态标志。还有一种情况是“半关闭”。TCP有个特性一方可以关闭发送方向而对端还能继续发送数据这叫半关闭。如果程序中只处理了完整的断开事件半关闭状态就可能被误判为异常。做透明转发时要单独处理读到0字节的情况。Read返回0说明对端关闭了发送方向这时候应该调用NetworkStream.Close()或者至少关闭写方向而不能继续往对端写数据。2.3 线程模型与异步IO为什么不要在主线程里读网络流转发程序如果放在UI线程里写一旦Read阻塞界面就卡死了而且Windows会判定UI线程无响应。所以网络读写必须放到后台线程或者用异步方式。VB.NET里我推荐用Async/Await配合ReadAsync和WriteAsync。这种方式有几个好处代码写起来跟同步逻辑一样直观但实际上不占用线程底层用的是IO完成端口非常适合网络转发这种“大部分时间在等待”的场景。你看一个Await就觉得好像卡住了但其实线程已经回去干别的活了等数据到了再回来继续执行。用异步写转发循环核心大概是这样Private Async Function PumpAsync(source As NetworkStream, target As NetworkStream, buffer() As Byte, cancellationToken As CancellationToken) As Task Try While Not cancellationToken.IsCancellationRequested Dim readCount As Integer Await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken) If readCount 0 Then 对端关闭发送方向正常结束 Exit While End If Await target.WriteAsync(buffer, 0, readCount, cancellationToken) Await target.FlushAsync() End While Catch ex As OperationCanceledException 正常取消不需要处理 Catch ex As Exception 记录日志 End Try End Function这段代码就是一个标准转发的单方向逻辑。两个方向各跑一个这样的循环互不干扰。注意source.ReadAsync的cancellationToken在程序退出或者链路断开时用Cancel来终止循环比直接暴力Abort线程安全得多。2.4 缓冲区与流量控制TCP粘包/半包的处理边界很多人一听到TCP转发就紧张担心粘包半包。这里我要说一个让很多人意外的观点纯透明转发根本不需要处理粘包半包。为什么因为粘包半包是“协议解析层”需要关心的问题。当你需要把字节流还原成一个个“消息”时你需要定义消息边界比如用长度前缀或者特殊分隔符。但透明转发不关心消息边界它只是把字节流从一段搬到另一端100个字节可能分两次到达也可能合并成一次到达转发程序原样搬过去就行接收端自己会处理。所以缓冲区大小不需要纠结“能不能装下一个完整的数据包”你只需要保证“读到了就发出去”这个语义正确即可。真正需要处理粘包的是协议转换场景比如要把Modbus TCP转成Modbus RTU这时候就必须做完整的协议解析。不过流量控制还是要做的。如果源端狂发数据目标端处理不过来缓冲区会持续堆积。TCP本身有滑动窗口机制会反压对端但这要求转发程序不要无限制地往内核缓冲区写数据。用Await WriteAsync天然具备反压能力因为这会让调用方等待对端消费。切记不要用BeginWrite然后又不管回调那会导致数据无限堆积最终内存爆炸。3. 源码实现从监听器到转发通道的完整落地3.1 TcpListener监听接入的设计转发的入口是TcpListener。启动监听之后需要一个循环不停接受客户端连接。但注意不能用同步的AcceptTcpClient否则你只能同时处理一个连接后面的会被阻塞。要用AcceptTcpClientAsync配合无限循环。Private listener As TcpListener Private cancellationTokenSource As CancellationTokenSource Public Async Function StartListeningAsync(port As Integer) As Task cancellationTokenSource New CancellationTokenSource() listener New TcpListener(IPAddress.Any, port) listener.Start() While Not cancellationTokenSource.IsCancellationRequested Dim client As TcpClient Await listener.AcceptTcpClientAsync() 每来一个连接就开一个独立任务去处理 Dim task As Task HandleClientAsync(client, cancellationTokenSource.Token) End While End Function这里有个细节AcceptTcpClientAsync在取消时可能会抛异常要包一层Try...Catch否则循环会退出。我在实际代码里会捕获所有异常判断是否取消如果是取消就正常退出如果是其他异常就记录日志继续循环不要让一个连接的错误打垮整个监听器。每来一个连接就开一个Task这在客户端数量不多时很理想。如果连接数非常大可以用Task.Run配合线程池来限制并发但对转发工具来说普通场景几百个连接用异步任务完全没问题。3.2 TcpClient与NetworkStream的封装TcpClient是连接的对象真正的数据读写靠NetworkStream。这个阶段要把连接的目标地址管理好因为“接入后要转给谁”是个配置问题不能写死在代码里。我习惯封装一个ForwardChannel类负责一条完整的转发链路Public Class ForwardChannel Public Property SourceClient As TcpClient Public Property TargetClient As TcpClient Public Property SourceStream As NetworkStream Public Property TargetStream As NetworkStream Public Property IsRunning As Boolean Public Sub Close() IsRunning False Try SourceStream.Close() Catch End Try Try TargetStream.Close() Catch End Try Try SourceClient.Close() Catch End Try Try TargetClient.Close() Catch End Try End Sub End Class封装的目的很简单当一个方向异常断开时可以干净地关掉整条链路而不是留下半个半开连接占用资源。Close方法的顺序有讲究先关流再关客户端否则可能有未发送的数据直接被丢弃。3.3 核心转发代码接入、连目标、双向泵送客户端接入之后先创建到目标端的连接然后启动两个方向的泵送任务。这里我给出最核心的代码段Private Async Function HandleClientAsync(client As TcpClient, token As CancellationToken) As Task Dim channel As New ForwardChannel() channel.SourceClient client channel.SourceStream client.GetStream() Try 建立到目标端的连接 Dim targetClient As New TcpClient() Await targetClient.ConnectAsync(targetHost, targetPort) channel.TargetClient targetClient channel.TargetStream targetClient.GetStream() 关闭Nagle算法降低小包延迟 client.NoDelay True targetClient.NoDelay True 源端 - 目标端 Dim buffer1(8191) As Byte Dim pump1 As Task PumpAsync(channel.SourceStream, channel.TargetStream, buffer1, token) 目标端 - 源端 Dim buffer2(8191) As Byte Dim pump2 As Task PumpAsync(channel.TargetStream, channel.SourceStream, buffer2, token) 等待任意一个方向结束说明链路断了 Await Task.WhenAny(pump1, pump2) channel.Close() 再等另一个方向退出避免悬挂 Try Await Task.WhenAll(pump1, pump2) Catch End Try Catch ex As Exception LogError(转发通道建立失败, ex) channel.Close() End Try End Function这段代码有几个细节值得说明。NoDelay True这个设置值得单独拎出来讲。TCP默认开启Nagle算法会把小包合并成大包再发送。对转发场景来说如果设备发的是高频小包Nagle算法可能引入几十毫秒的额外延迟对实时性要求高的场景是灾难。我在工控现场实测过关掉Nagle之后小包延迟从几十毫秒降到了几毫秒。代价是网络小包数量增多但局域网的带宽完全不是瓶颈这个权衡非常划算。Task.WhenAny等待任一个方向先结束这是双向转发比较优雅的退出方式。因为链路断开时通常只有一个方向会先读到0字节或者异常另一个方向还挂着如果只等一个pump另一个pump会无限等待导致资源和连接泄漏。WhenAny触发后先清理连接让另一个pump也结束再用WhenAll确保两个任务都退出这样收尾就很干净。3.4 单实例运行与Mutex的使用转发工具在工业现场最容易出现的问题之一就是被重复启动。我记得有一次现场工程师双击了三次exe结果三个进程抢同一个端口两个直接报错剩下的一个也没法正常用。Windows下判断程序是否已经在运行Mutex是标准做法。定义方式Private Shared mutex As Mutex Public Shared Sub Main() Dim createdNew As Boolean mutex New Mutex(True, Global\MyTcpForwarder_SingleInstance, createdNew) If Not createdNew Then MessageBox.Show(程序已经在运行了请不要重复启动。) Return End If Application.EnableVisualStyles() Application.SetCompatibleTextRenderingDefault(False) Application.Run(New MainForm()) End Sub注意Global\前缀这表示互斥量对整个系统生效。如果省略这个前缀互斥量的作用域默认是每个登录会话在同一台机器的不同用户会话下仍然可能重复启动。用Global\前缀可以确保整个机器内只有一个实例在跑这对服务型工具很重要。还有一个细节createdNew变量会告诉你当前实例是不是第一个。如果createdNew为False说明已经有一个实例在运行此时要给出友好提示并退出。不提示也行但我试过什么都不提示就退出用户会以为程序坏了所以提示语一定要有。3.5 配置项与日志小工具能持续跑的隐形关键转发工具的配置信息包括监听端口、目标IP端口、是否双向、缓冲区大小等。这些不要写死在代码里否则换个端口就得重新编译一次。我用My.Settings或者一个简单的config.ini文件来管理。对VB.NET来说My.Settings挺好用能直接绑定到界面控件保存也方便。日志方面我的经验是分两级界面上的实时滚动日志和文件里的持久化日志。界面日志只保留最近几百条防止UI卡顿文件日志按天分文件格式尽量简单时间 级别 消息一行一个。日志一定要加锁多个转发线程同时写日志时不加锁会出现乱行严重时还会抛异常。Private Shared logLock As New Object() Public Shared Sub LogInfo(message As String) SyncLock logLock Dim line As String ${DateTime.Now:HH:mm:ss} [INFO] {message} File.AppendAllText(LogFilePath, line Environment.NewLine) End SyncLock End Sub发送字节和接收字节的计数也建议做个全局计数器界面上定时刷新。很多人在现场排查问题时第一个问题就是“到底收到的数据有没有进入程序”一个显眼的计数器能立刻回答这个问题。4. 实操过程中避不开的坑与排查实录4.1 常见问题速查表做转发工具这几年我遇到过的问题基本都能归纳到下面这张表里。建议你在写代码时对这些点提前做防护总比上线后对着日志猜要好。现象可能原因排查思路客户端连不上监听端口防火墙拦了端口、端口被占用、监听没启动先telnet本机端口再关防火墙测试能连上但数据不通目标地址/端口配错、目标端没监听、Nagle算法延迟确认目标端socket状态用抓包工具看TCP连接转发一段时间后停住线程异常未捕获、半关闭未处理、对端卡死查看日志是否停在某个异常栈检查两个Pump是否正常退出内存持续上涨循环里反复创建缓冲区、GC未及时回收、事件未解绑抓转储文件统计byte[]对象数量CPU占用居高不下同步BlockingRead循环、忙等检测、没有Await看线程池线程数量检查是否有空闲轮询多个实例抢端口没有Mutex保护、启动脚本重复调用加上Mutex检查进程列表收到数据有延迟Nagle算法开启、TCP Delayed ACK设置NoDelayTrue实测延迟下降明显程序退出后端口仍被占用TIME_WAIT状态、close顺序不对等服务超时或调整短连接策略确保主动关闭socket这张表不是纸上谈兵每一行都是我在实际调试中遇到过的。特别提醒在现场排查时不要只盯应用层先确认TCP连接本身是通的再怀疑转发代码。4.2 断线重连与异常恢复的实战处理转发程序面临的断线有两种一是源端断开二是目标端断开。两种的恢复机制不一样。源端断开比较简单就是客户端主动断开连接Pump读到0字节后退出转发通道整体关闭等待下一个客户端接入就好。这种场景对监听器没有影响新客户端随时可以连接。目标端断开比较麻烦。比如设备重启了目标端口暂时不可用此时转发程序不能直接放弃而是要周期性重连目标端。我写了一个简单的重试机制HandleClientAsync中尝试连目标失败后等几秒重试持续重试直到成功或者源端断开。Private Async Function ConnectWithRetryAsync(host As String, port As Integer, token As CancellationToken) As Task(Of TcpClient) Dim retryInterval As Integer 3000 While Not token.IsCancellationRequested Try Dim client As New TcpClient() Await client.ConnectAsync(host, port) Return client Catch ex As Exception LogWarning($连接目标失败{retryInterval / 1000}秒后重试: {host}:{port}) Await Task.Delay(retryInterval, token) End Try End While Return Nothing End Function重试间隔不要固定死我一般用倍增策略3秒、5秒、10秒、30秒封顶避免目标端还没恢复时程序疯狂重试消耗CPU。重试过程中源端可能一直在发数据这些数据会堆积在内核缓冲区里如果你担心内存问题可以在Pump里加一个暂停逻辑目标端未连上时不读取源端数据。这里要强调一个经验断线重连最怕的就是“看起来连上了但又立刻断了”这个循环。如果目标端不稳定连接建立后很快又断开就会陷入“重连、断开、重连、断开”的死循环。解决办法是记录稳定连接持续时间持续太短的话加大重试间隔这算进阶技巧但很实用。4.3 压测与稳定性验证转发1万字节后还能稳吗写完之后一定要做压力测试不然到现场出问题就晚了。我的压测方法很简单用两个工具一个TCP服务端模拟目标系统一个TCP客户端模拟源设备数据内容用随机字节或者递增序列。先在本地回环地址测试127.0.0.1转发到127.0.0.1的另一端口这样可以排除网卡和物理链路的影响纯粹验证程序逻辑。数据量从100包/秒起步慢慢加观察转发程序的CPU、内存、日志有没有异常。我还习惯做“长时间稳定性测试”让程序连续跑24小时每秒钟发50个包每天早上来看日志有没有中断记录。转发程序的稳定标准是跑48小时日志里除了启动信息没有任何一条错误记录计数器单调递增没有归零。有些问题只有长时间跑才暴露。比如线程池饥饿当Pump任务因为某些原因阻塞时线程池会慢慢累积等待任务最终表现为转发延迟越来越大。这种问题短时间压测很难发现必须靠长时间观察。如果发现延迟随时间线性增长优先检查是不是有锁竞争或者任务队列堆积。5. 扩展进阶怎么把这套程序变成可复用的转发工具5.1 多通道转发与端口映射式设计单通道版运行稳定后自然想让它支持多个端口同时监听这就是多通道转发。配置层面可以用一个列表来管理多个映射规则每条规则包含监听端口、目标IP、目标端口、是否启用。结构大致是这样的Public Class ForwardRule Public Property ListenPort As Integer Public Property TargetHost As String Public Property TargetPort As Integer Public Property Enabled As Boolean End Class启动时循环创建多个TcpListener每个Listener独立监听自己的端口。要注意的是多个Listener之间的异常要隔离不能因为一个端口被占用就把其他通道全部搞挂。我会在界面里给每个通道显示独立的状态灯和收发计数方便一眼定位问题通道。这种设计其实就是一个轻量级的TCP端口转发器类似大家熟知的端口映射工具但因为是自己的代码你可以任意加功能比如界面日志、流量统计、黑白名单这在现场很有用。5.2 协议转换的切入点透明转发跑通后进阶需求往往是协议转换。最常见的场景是Modbus设备用Modbus RTU over TCP但上位机要Modbus TCP标准协议两者报文格式不一样需要在中间做转换。这种做法是从“字节流管道”变成“协议感知管道”。你不能再用简单Pump需要解析报文提取功能码和数据区然后按目标协议重新封装。核心是两步定义一套消息边界识别逻辑比如Modbus TCP的报文头6个字节里包含长度字段。定义一个转换函数输入原始报文输出目标报文。Private Function ConvertModbusTcpToRtu(originalPacket() As Byte) As Byte() 解析TCP报文头 提取单元标识和PDU 重新计算CRC16 返回RTU格式报文 End Function协议转换千万不要零散地到处改建议抽象出ITranslator接口一个协议对应一个实现类。这样以后新增协议时你只需要增加一个类不需要改动原有的转发框架。5.3 从转发到代理透明中继的进阶如果已经实现多通道、协议转换再往后就是更完整的网络代理层。区别于简单的字节转发代理还要处理身份认证、访问控制、流量审计、链路健康检查。对我个人来说转发工具进阶到“代理”形态最大的价值是可控性。你能看到每条连接的建立时间、持续时间、传输方向、累计流量甚至能手动踢掉某条异常连接。这种可控性在调试和排障时极其重要。实现上我会在ForwardChannel基础上增加监控对象记录连接生命周期并且开放一个控制命令接口界面和命令行都能操作。这样工具就从“隧道”变成了“管理型网关”真正变成一个可维护、可运营的网络中间件。写在最后的实操心得我在这套程序上踩过不少坑说三个最值得分享的。第一个是异步回调里更新UI控件会抛跨线程异常VB.NET在调试模式下会看到“线程间操作无效”解决办法是用Control.BeginInvoke或者Invoke不要直接赋Text。这个坑大多数人都会遇到写一个统一的UI调度方法能省很多事。第二个是**Close和Dispose的顺序**先关流再关TcpClient否则数据可能丢在缓冲区里这种问题很难复现但偶尔就会冒出来。第三个是别相信“看起来连接还在”这个判断TCP的Connected属性不代表对端还在真正判断连接是否存活要靠读写操作的结果所以在Pump里不要通过Connected来判断退出而要通过Read返回值。这套VB.NET TCP/IP通讯转发程序代码量不大但把监听、转发、断线重连、日志、配置、单实例保护这些机制都做扎实之后它在工业现场真的成了一个很可靠的“数字管道”。如果后续你打算在它基础上加协议转换或者更多管理功能一开始把框架解耦好后面扩展起来会非常顺手。根据我的经验保持“转发核心”和“业务逻辑”分离是这个工具能长期维护下去的关键。本文还有配套的精品资源点击获取
返回列表