简介:这份源码面向需要研究TCP/IP通讯转发与数据包监视的VB.NET开发者,尤其适合新手及有一定经验的程序员借鉴。作者针对现有抓包工具不够直观、只能转发却看不到数据包内容的痛点,自行实现了一套可视化转发程序,采用VS2019编写,接收端使用Listener监听,转发目的端为TcpClient客户端,支持客户端与服务端之间的多级转发链路。资源包共33个文件,约456KB,包含6个vb源码文件、2个resx资源文件、2个config配置、2个exe可执行程序、1个vbproj工程文件与1个sln解决方案,另附xml、pdb、dll等编译与调试辅助文件,结构完整可直接打开运行。目前已有1058人学习下载,读者可从中获取通讯转发的核心实现思路、监听与客户端连接配置方法,以及数据包内容可视化展示的参考方案,便于二次开发与排错调试。
1. 从一台老工控机说起:VB.NET 做 TCP/IP 通讯转发到底在转什么
车间里有台跑了八年的工控机,上面挂着一套 VB.NET 写的上位机,只认串口和本地 TCP,可新上的 MES 系统只吃 HTTP 和 MQTT。两边数据要通,又没人敢动那套老代码,最省事的办法就是在中间架一个转发程序:一头连老系统的 TCP 端口,一头把数据吐给新系统。这就是 VB.NET 实现 TCP/IP 通讯转发功能程序源码要解决的事——用 .NET 里现成的System.Net.Sockets,把一路 TCP 连接上的字节流接过来,按规则处理后转发到另一个或多个目的地。它适合手里有 VB.NET 存量项目、又需要做协议桥接或数据分发的工程师,尤其是工控、医疗设备、自助终端这类现场。源码本身不复杂,难的是连接管理、粘包处理和异常重连这几处,后面逐层拆开讲。
2. 转发程序的骨架:TcpListener、TcpClient 与双工数据流
2.1 为什么用原生 Socket 而不是第三方库
VB.NET 做 TCP 转发,第一层选型就是通信底座。常见做法有三种:原生System.Net.Sockets、TcpListener/TcpClient封装、以及引入 SuperSocket 之类的第三方框架。第三方框架功能全,但对一个只做转发的小程序来说,依赖越少越好部署,尤其现场机器往往不能随便装运行时。原生 Socket 和 TcpListener 都在 .NET Framework 里自带,VB.NET 项目直接引用即可,不需要额外包。
我一般选TcpListener做服务端监听、TcpClient做下游连接。原因是这两个类比裸Socket少写不少样板代码,AcceptTcpClient直接返回一个带NetworkStream的对象,读写用流的方式更直观。代价是它们对底层选项的控制弱一些,比如想精细设置LingerState或NoDelay,还是得回到Socket对象上操作。转发场景里延迟敏感,NoDelay = True基本是必设的,否则小包会被 Nagle 算法攒着发,现场表现为「数据慢半拍」。
选型上还有一点:转发程序通常是长连接常驻,不能每来一包就新建连接。所以结构上要维护一张连接表,每个上游客户端对应一个下游连接,双向各起一个读循环。这就是后面代码要落地的骨架。
2.2 最小可运行的转发服务端代码
下面这段是转发程序的核心骨架,监听本地端口,收到连接后连到目标地址,然后双向搬运数据。可以直接放进一个 VB.NET 控制台项目里跑。
Imports System.Net Imports System.Net.Sockets Imports System.Text Imports System.Threading Module Forwarder ' 本地监听端口与转发目标 Private Const ListenPort As Integer = 9000 Private Const TargetHost As String = "127.0.0.1" Private Const TargetPort As Integer = 9100 Sub Main() Dim listener As New TcpListener(IPAddress.Any, ListenPort) listener.Start() Console.WriteLine("转发服务已启动,监听 " & ListenPort) While True ' 阻塞等待上游连接 Dim upstream As TcpClient = listener.AcceptTcpClient() upstream.NoDelay = True ' 每个连接丢到线程池,避免阻塞 Accept ThreadPool.QueueUserWorkItem(Sub(state) HandleClient(upstream)) End While End Sub Private Sub HandleClient(upstream As TcpClient) Dim downstream As New TcpClient() Try downstream.Connect(TargetHost, TargetPort) downstream.NoDelay = True ' 双向转发:两个方向各一个线程 Dim t1 As New Thread(Sub() Pump(upstream, downstream)) Dim t2 As New Thread(Sub() Pump(downstream, upstream)) t1.IsBackground = True t2.IsBackground = True t1.Start() t2.Start() t1.Join() t2.Join() Catch ex As Exception Console.WriteLine("连接异常: " & ex.Message) Finally upstream.Close() downstream.Close() End Try End Sub ' 从 from 读,写到 [to] Private Sub Pump(from As TcpClient, [to] As TcpClient) Dim buffer(4095) As Byte Try While True Dim n As Integer = from.GetStream().Read(buffer, 0, buffer.Length) If n = 0 Then Exit While ' 对端关闭 [to].GetStream().Write(buffer, 0, n) [to].GetStream().Flush() End While Catch ' 任一端断开,退出循环,由外层统一关闭 End Try End Sub End Module逻辑说明:Main里TcpListener常驻监听,AcceptTcpClient拿到上游连接后不直接处理,而是丢进线程池,保证还能继续接受新连接。HandleClient负责建立下游连接,然后开两个后台线程做双向Pump。Pump是纯字节搬运,读到 0 字节代表对端正常关闭,直接退出。
参数说明:ListenPort和TargetPort按现场改;buffer大小 4096 是经验值,太小会增加系统调用次数,太大在低延迟场景反而增加单次拷贝耗时,一般 2K 到 8K 之间都行。NoDelay = True关掉 Nagle,转发场景建议保留。IsBackground = True保证主程序退出时线程不挂住进程。
这段代码能跑通「连上就转发」的最小闭环,但它有两个明显短板:一是没处理粘包,二是没做断线重连。这两点正是后面章节要补的。
2.3 连接表与生命周期管理
上面的写法每个连接独立成对,简单但不好管。真实项目里往往需要知道「当前有几路连接」「哪一路断了」「能不能主动踢掉某一路」。这就需要一张连接表。
常见做法是定义一个ForwardSession类,持有上游、下游两个TcpClient,加一个唯一 Id 和状态标志。用一个ConcurrentDictionary(Of String, ForwardSession)存起来,连接建立时加入,Finally里移除。这样监控线程可以遍历这张表打印状态,也可以在需要时调用Close主动断开。
生命周期上有三个关键点。第一,关闭顺序:先Close上游再Close下游,或者反过来都行,但两个Pump线程要能感知到关闭,否则会卡在Read上。第二,Read阻塞是没法直接打断的,通常靠关闭 socket 让Read抛异常来退出,所以Pump里的Catch不能吞掉后继续循环。第三,Join两个线程时如果其中一个先退出,另一个可能还在阻塞,所以Join前最好先关闭对端 socket,或者给Join设超时。
这块是转发程序最容易出玄学问题的地方——表面看连接还在,实际数据早就不通了。血泪经验是:任何一处Close都要配日志,记录是谁触发的关闭,否则线上排查只能靠猜。
3. 粘包、半包与心跳:转发层必须处理的三个数据边界
3.1 粘包和半包为什么在转发场景更致命
TCP 是字节流,没有消息边界。上游一次Send的 100 字节,下游可能分两次Read收到,也可能和下一包粘在一起一次收到。如果转发程序只是无脑搬运字节,那粘包问题其实被「原样传递」掩盖了——因为下游收到的字节序列和上游发出的完全一致,边界由下游自己解析。这是纯字节转发的一个好处。
但一旦转发程序需要在中间做处理,比如解析出完整报文再转发、或者按协议改字段,粘包就必须自己解决。常见做法是定长头 + 长度字段:先读固定长度的头,从头里解析出包体长度,再读够这么多字节才算一包。VB.NET 里可以用BitConverter处理长度字段,注意大小端要和协议一致。
半包则出现在连接刚建立或大包传输时,Read返回的字节数小于预期。处理方式是循环读直到凑够,或者用一个缓冲区累积。下面是一个按「4 字节长度前缀」拆包的累积读法。
' 按 4 字节大端长度前缀拆包,返回完整报文列表 Private Function ExtractPackets(buffer As List(Of Byte)) As List(Of Byte()) Dim packets As New List(Of Byte()) While buffer.Count >= 4 ' 前 4 字节是包体长度 Dim lenBytes As Byte() = buffer.GetRange(0, 4).ToArray() If BitConverter.IsLittleEndian Then Array.Reverse(lenBytes) Dim bodyLen As Integer = BitConverter.ToInt32(lenBytes, 0) If buffer.Count < 4 + bodyLen Then Exit While ' 半包,等下次 Dim body As Byte() = buffer.GetRange(4, bodyLen).ToArray() packets.Add(body) buffer.RemoveRange(0, 4 + bodyLen) End While Return packets End Function逻辑说明:buffer是跨多次Read累积的字节列表。每次进来先看够不够 4 字节头,够就解析长度,再看包体够不够,不够就退出等下次数据。够就切出一包,从缓冲区移除,继续循环处理下一包。
参数说明:长度字段的字节序要和协议对齐,BitConverter.IsLittleEndian判断当前机器字节序,网络协议通常是大端,所以要Reverse。bodyLen要做上限校验,防止恶意或错误长度导致内存暴涨,一般设个 1MB 之类的上限,超了直接断连接。
3.2 心跳与空闲检测
长连接转发最怕的是「假连接」:TCP 层面没断,但对端进程已经死了,数据发出去石沉大海。TCP 自带的 KeepAlive 默认两小时才探测,现场根本等不起。所以转发程序一般自己做心跳。
两种思路。一种是应用层心跳:转发程序定期往上下游各发一个约定好的心跳包,对端要回。收不到回应超过 N 次就判定断开,主动关闭重连。另一种是空闲超时:记录每个连接最后一次收到数据的时间,超过阈值没数据就关闭。前者更可靠但需要协议支持,后者实现简单但可能误杀正常空闲连接。
我一般两个都用:应用层心跳负责探活,空闲超时兜底。心跳间隔设 30 秒,连续 3 次无响应断开,空闲超时设 5 分钟。这些值要按现场网络质量调,网络抖动大的地方间隔要放宽,否则会频繁重连。
3.3 断线重连的正确姿势
重连不是简单地在Catch里再Connect一次。要区分几种情况:上游断了下游还活着,下游断了上游还活着,两边都断了。上游断了下游要跟着关,否则下游会一直等一个不会来的数据;下游断了要尝试重连,重连期间上游数据要么缓存要么丢弃,取决于业务能不能容忍丢数据。
重连要有退避,不能死循环猛连。常见做法是第一次等 1 秒,之后翻倍,封顶 30 秒。VB.NET 里用Thread.Sleep或Task.Delay都行,注意别阻塞主线程。重连成功后再把状态恢复,如果之前有缓存数据,按顺序补发。
这里有个坑:重连时如果目标地址是域名,Connect会做 DNS 解析,解析失败也会抛异常,要和连接被拒区分开。日志里把异常类型打出来,排查时能省很多事。
4. 避坑与排查:转发程序上线后最常翻车的五个地方
4.1 现象:连接数涨到几百后程序卡死
原因:AcceptTcpClient在主线程里同步处理,或者线程池被占满。每个连接开两个线程,几百个连接就是上千线程,上下文切换开销巨大,还可能触发线程池饥饿。
解决:改用异步模型,AcceptTcpClientAsync配合Async/Await,读写也用ReadAsync/WriteAsync。异步模型下少量线程就能撑住大量连接。如果暂时不想大改,至少把Thread换成Task,并限制最大连接数,超了直接拒绝。
4.2 现象:数据偶尔丢一段,日志里看不到异常
原因:Pump里的Catch把异常吞了,或者Write之后没Flush。NetworkStream的Write一般不需要手动Flush,但如果中间套了BufferedStream就必须Flush,否则数据留在缓冲区里。
解决:Catch里至少记一条日志,包含异常类型和消息。检查是否有缓冲层没刷新。另外确认Read的返回值有没有被忽略——返回 0 是对端关闭,不是「没数据」,处理错了会死循环。
4.3 现象:转发延迟忽高忽低,现场说「时快时慢」
原因:Nagle 算法攒包,或者缓冲区大小不合适。小包频繁发送时 Nagle 会等 ACK 再发,延迟就上来了。
解决:两端都设NoDelay = True。缓冲区大小按实际报文大小调,如果报文普遍在 1KB 以下,缓冲区设 4096 足够;如果经常传大文件,设 64KB 减少系统调用。还可以考虑用Socket.SendBufferSize和ReceiveBufferSize调整内核缓冲。
4.4 现象:程序跑几天后内存持续上涨
原因:连接表里的ForwardSession没被移除,或者缓冲区List(Of Byte)只增不减。异常路径下Finally没执行到,连接对象泄漏。
解决:所有资源释放放在Finally里,连接表移除也放Finally。缓冲区在拆包后及时RemoveRange,不要一直累积。用Using包裹能自动释放的对象。上线前用压力测试跑一天,观察内存曲线。
4.5 现象:下游重连后收到重复数据
原因:重连时把之前已发送但未确认的数据又发了一遍,或者缓存没清空。
解决:重连后清空发送缓存,除非业务明确要求补发。如果要求补发,要有去重机制,比如带序列号,下游按序列号丢弃重复包。这个要和下游系统约定好,单方面补发容易出问题。
5. 进阶:把转发程序做成可配置、可观测的服务
5.1 配置外置与多目标分发
硬编码端口和目标地址只能应付 demo。真实项目里,监听端口、目标地址、缓冲区大小、心跳间隔这些都应该外置到配置文件。VB.NET 里可以用App.config的appSettings,或者读一个 JSON 文件。我倾向 JSON,改起来直观,也方便程序运行时热加载。
多目标分发是另一个常见需求:一路上游数据要同时发给三个下游。做法是把Pump里的单个[to]换成一个下游列表,收到数据后遍历列表逐个Write。注意某个下游写失败不能影响其他下游,每个下游独立Try/Catch,失败的标记为断开并触发重连。
' 多目标分发:一份数据写给多个下游 Private Sub Broadcast(data As Byte(), n As Integer, targets As List(Of TcpClient)) For Each t In targets Try If t.Connected Then t.GetStream().Write(data, 0, n) End If Catch ex As Exception ' 单个下游失败不影响其他 Console.WriteLine("下游写入失败: " & ex.Message) End Try Next End Sub逻辑说明:遍历下游列表,每个独立Try/Catch,一个失败继续下一个。Connected属性只能反映上次操作后的状态,不完全可靠,所以Write本身也要包在Try里。
参数说明:data和n是本次读到的字节和长度,直接透传。如果下游协议和上游不同,这里就是做转换的地方,比如改字段、加头、转编码。
5.2 用日志和计数器做可观测
转发程序出问题时,最怕的是「不知道哪一步断了」。至少要记录这几类日志:连接建立和关闭(带 Id 和时间)、异常(带类型和堆栈)、重连尝试和结果、心跳超时。日志级别分 Info 和 Error,正常连接关闭用 Info,异常用 Error。
计数器更直观:当前连接数、累计接收字节、累计发送字节、重连次数、丢弃包数。这些可以用Interlocked做线程安全的自增,定期打印或暴露一个本地 HTTP 接口给监控拉取。现场排查时,看一眼计数器就知道是没数据还是没转发。
5.3 一个验证转发是否正常的土办法
上线前我会做两件事。第一,用两个telnet或者netcat分别连上游和下游,手动发一串字节,看另一边能不能原样收到。第二,写一个小的压力脚本,模拟 100 个连接持续发数据跑一小时,观察内存、CPU 和连接数是否稳定。这两个测试能覆盖大部分低级错误。
如果现场没有 netcat,用 VB.NET 再写一个几十行的测试客户端也行,发固定序列,收端校验序列是否连续。序列号用递增整数,收到不连续就说明丢包或乱序,能直接定位到是转发层的问题还是网络的问题。
这套东西做完,一个 VB.NET 的 TCP/IP 转发程序基本就能扛住生产环境了。我自己的习惯是:任何转发程序上线前,必须先在测试环境跑满 24 小时,盯一遍内存曲线和连接数曲线,曲线平了才敢往现场推。希望帮到你。
本文还有配套的精品资源,点击获取