在 VB.NET 中构建 UDP 客户端/服务器应用程序






4.14/5 (4投票s)
2006 年 4 月 27 日
9分钟阅读

101684

8173
本文介绍了一种管理 UDP 连接的可能方法,并提供了一些协议建议和一个可用的 UDP 客户端/服务器应用程序示例。
快速入门
下载代码,将测试文件放入服务器目录,然后运行两者,在客户端命令行中输入 FILE filename.ext
阅读下文以了解一些蹩脚的英语和解释。
引言
如果您是 UDP 新手,我建议您阅读 Kumudu Gunasekara 的 《在 VB.NET 中使用线程发送和接收 UDP》。我以他的作品为起点,并感谢他。Code Project 上还有一个 《TinyUDP 组件》 文章,看起来也很有希望。我在这里要做的就是更深入地探讨,展示一种可能的 UDP 通信方式。
您可能已经听说过 UDP 被称为“不可靠”协议。不可靠意味着您无法保证发送的数据包能够到达目的地。UDP 也不能保证传输的单个数据包会按照发送的顺序到达。还存在重复消息的问题(您发送了 1,但收到了 3,哇!)。如果需要传输信息的任何可靠性,都必须在上层实现,即在您的应用程序中。
因此,作为 UDP 编码人员,您的第一个任务是设计一个低级协议,该协议将为您处理数据报传输。它必须做到两点:确保递送和确保按正确顺序递送。在此协议之上,您可以稍后添加一个用于*实际*通信的协议(例如,聊天消息命令、昵称更改等)。
我不建议您在实际的客户端/服务器应用程序中使用我的代码。只需看看它是如何工作的(有时效果不佳!)并构建一个更好的。
协议
如前所述,此设计使用了 2 种协议:用于数据报传输的低级或递送协议,以及用于应用程序通信的高级或实际协议。两种协议都受益于 BinNumerization 过程,我必须在继续之前对此进行解释。
BinNum、UnBinNum 和数据包分隔
想象一个任务:通过网络传输 2 个变量 A 和 B(它们包含数字 34 和 257)。有哪些可能的方法?
ASCII。
像这样编码(最常见的方式):34|257
数字由一个特殊的 ASCII 字符分隔,称为...分隔符。有时使用 CrLf 作为这种目的。这种方法的主要缺点是,如果有人进入您的聊天室,名字是“TheE||vis”。而更严重的问题存在于文件传输领域。二进制文件往往会使用各种字节,您知道。
分隔是最明显的方式,因此,我强烈建议避免使用它。
像这样编码(有时在 ASCII 协议中使用):0003400257
每个数字使用 5 位数字表示自己,并在末尾添加零。这样,将字符串分成 2 部分将得到 00034 和 00257。**缺点:**您会因为愚蠢的零而丢失一些字节。
像这样编码:2.343.257
这里实际发生的是,表示数字的字符串长度与实际数字之间用 ASCII 字符分隔。这样,解析器会在点之前读取所有您需要的内容(将得到“2”),然后正好读取 2 个字符(“34”),这将剩下“3.257”字符串,他所要做的就是……重复。
顺便说一句,如果您正在开发协议,请考虑上述方案!当然,这在 TCP 上更为重要,在 TCP 上,数据包是流式的,切割/组合块是首要任务。
二进制。
这很简单。每个数字都编码为一个字节。或一个字。这是传输数据的近乎完美的方式。唯一的缺点是:我可以将 34 编码为一个字节,但无法将 257 放入一个字节,因此,我必须使用一个字来表示它。如果变量 B(257)有一天变为 255,它将为它的字编码节省 3 个额外字节。
BinNumerization。
此方法需要在发送端和接收端进行一些工作,但*绝不*会浪费任何不必要的数据。如果数字小于 248,则将其编码为字节,如果大于 248,则将其编码为带有长度的字符串。255-248 = 最大 7 位数字。您可以根据需要调整这些值。但请务必在两端都这样做!
Public Function UnBinNum(ByRef S$, Optional ByVal EatOut As Boolean = True) As Integer
'MsgBox("CALL FOR UNBIN NUM:" & S$)
Dim l As Int16
Dim nval As Long
Dim h As String
'On Error GoTo Error
If Asc(Left(S$, 1)) < 249 Then
nval = Asc(Left(S$, 1))
If EatOut = True Then S$ = Right$(S$, Len(S$) - 1)
Else
l = Asc(Left$(S$, 1)) - 248
h = Mid$(S$, 2, l)
If Len(S$) < l + 1 Then nval = -1 : GoTo ErrorS
'Debug.Print "len: " & l
'Debug.Print "hex:" & h
'Debug.Print "unhex:" & HexToDecimal(h)
nval = HexToDecimal(h)
If EatOut = True Then S$ = Right$(S$, Len(S$) - l - 1)
End If
ErrorS:
'MsgBox("nval:" & nval)
Return CInt(nval)
End Function
Function BinNum(ByVal NUM) As String
Dim h As String, l As Byte
If NUM > 248 Then
h = Hex(NUM)
l = Len(h) + 248
If l > 255 Then MsgBox("L:" & l & "...." & h & "..." & Len(h))
Return (Chr(l) & h)
Else
Return (Chr(NUM))
End If
End Function
是的,我在 VB6 中也使用了这两种方法。它们还依赖于源中找到的 `HexToDecimal` 函数(不是我写的)。另请注意,`UnBinNum` 以 `ByRef` 形式获取其参数!无论如何,此实现并不完美(缺点之一是您无法使用负数),但应该能让您有一个大致的想法,并证明该概念*是*完美的。好吧,至少是接近完美的:在编码 249-255 范围内的数字时,您仍然会丢失一些字节(它们被编码为字符串,而不是字节)。
低级递送协议
每个数据包以 2 个字节和 1 个 BinNum 开头,表示 ClientID、PacketType 和 Sequence。其余是高级数据,不应在低级使用。
这三者都至关重要。由于 UDP 中没有办法确定单个连接(您只能处理:IP、端口、收到的数据),因此 ClientID 就派上用场了。
在我的服务器示例中,IP+ClientID 构成一个唯一的客户端,而 IP+Port 仅在根本没有 ClientID 时使用。但这只发生在握手期间。
握手
客户端开始传输 NIL 数据包(由*三个*空字节组成,或 2 个空字节和 1 个空 BinNum,这在此时是相同的)。基本上,客户端在说:
我是新客户端,没有 ClientID,没有传输历史,没有之前的经验,请给我一个可以使用的 ClientID。
服务器为 IP+端口对分配一个新的 ClientID,并发送一个欢迎消息(来自高级协议)。
PacketTypes & 递送
最有用的 PacketType 是 INF(字节 2)。它表示里面有实际数据。每收到一个 INF,接收方都应发送一个类型为 ACK(字节 0)或 BUF(字节 1)的数据包,它们基本意思相同:数据包已收到,请勿重发。
发送方会持续从队列中发送 INF,直到收到 ACK(或 BUF)。您问 ACK 和 BUF 之间有什么区别?BUF 在数据包乱序(太旧或来自未来)时发送,并且可能未被您的客户端实现(如果您没有缓冲机制),但接收方*必须*确认每个传入的数据包。
'Sending side
Function Compose(ByVal RAWDATA as String)
'increment counter
outSeq += 1
'add header
RAWdata = Chr(clientID) & Chr(typ) & BinNum(outSeq) & RAWdata
Dim dlg As New UDPMaster.DGram
dlg.IP = IP
dlg.Port = port
dlg.data = UDPMaster.StringToBytes(RAWdata)
'add to sending buffer
sendBuffer.Add(dlg)
End Function
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
' the sending side then loops through sendbuffer and sends it
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
'Receiving side
Function CollectData()
' read Header
' byte(0) - clientID, byte(1) - type, UnBinNum (seqNum)
If seqNum = mCl.inSeq Then ' ONE WE WAITED FOR
udp.Send(mCl.IP, mCl.port, mCl.ComposeACK)
'ACK immidiatly
'increment the counter
mCl.inSeq += 1
End if
End Function
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
' the sending side now receives an ACK and removes packet
' from sendBuffer
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
还有一个 255 或 TERMINATION PacketType,在断开连接时发送。它不是强制性的,因为 UDP 是无连接的,应用程序通常会找到方法来告知断开连接的客户端(例如,长时间没有收到 ACK,或简单的 ping 超时)。
您添加到队列中发送的每个数据包都会被分配一个 Sequence 号码,该号码会随着每次此类操作而增加。您的 ClientID 也被打包在头部中作为第一个字节。PacketType 是 INF、ACK 或 BUF。只有 INF 数据包包含实际数据,其余用于信号和管理。
重新连接。
服务器可以向客户端发送一个 NIL 数据包,这意味着它们需要再次执行握手。此选项应在长时间断开连接后使用。
高级实际协议
这里没什么花哨的。它主要是 ASCII,带有简单的全大写单词作为命令。可用的命令是“WHO”列出所有连接的客户端,“SAY text”将说的文本传输给所有人,以及“FILE filename”请求文件传输。
代码
我已经将最通用的部分提取到一个 `UDPMaster` 类中,它可以同时用作客户端和服务器。`UDPMaster` 还包含 `DGram` 类,这是一个保存传入数据报的结构。
这是您如何使用它来创建一个客户端(或!一个服务器)。
Dim udp as new UDPMaster(2002) ' local port you are listening too
Do
If udp.hasnews then
'Receive data
Dim dgram as UPDMaster.Dgram
udp.poll(dgram)
'Show data
Console.WriteLine ("DATAGRAM received ") ;
Console.WriteLine ("Sender: " & dgram.IP & ":" & dgram.port)
'the data is holded in dgram.data() byte array
'Work with data...
' ...........
end if
'Send data...
udp.send(drgam.IP, dgram.port, "REPLY")
Loop
客户端和服务器都构建在该代码之上,但仅此而已。`UDPMaster` 不包含上面描述的任何协议特定功能。它只报告新的数据报(或发送您的)。数据包的处理、决定哪个去哪里、缓冲和确认——所有这些都在更高级别上完成。两个程序共享的另一个类是 `client` 类。这并没有特别的原因,只是使用起来很方便:这个类存储给定客户端的 IP、数据、ping 时间等,所以而不是在客户端声明所有这些变量,我只是从服务器偷了这个类并声明了一个实例。这使得两个解决方案中都找到的 UDPMaster.vb 和 Helper.vb 文件相同。
我留下了很多注释,但它们更多地指向某个地方,而不是解释任何内容(我的注释编写技巧非常差)。为了更好地理解正在发生的事情,您可以取消注释所有那些 `BetCon` 行,它们会将报告输出到控制台。
压力控制
仅在服务器上实现,并且*仅*应视为 TEST-DEMO-DIRECTION-ETC。压力参数(每个客户端都有一个)是服务器在重传数据之前等待 ACK 的滴答数(1/1000 秒)。降低此值将增加压力和发送的数据包数量。
慢启动
我尝试实现慢启动方法(如 TCP 中所见),但运气不佳。默认压力为 1000,然后根据数据包丢失减少到合适的值。
数据包丢失
每发送 10 个数据包就会计算一次数据包丢失。服务器将发送的数据包数量除以收到的 ACK 数量。如果数据包丢失率很高,它会降低压力,反之亦然。
往返时间
数据包到达目的地并返回所需的时间。通过 2 个时间戳(发送、接收)计算。此值应取近似值(这是专家建议的),而这正是这些注释掉的代码行所做的(在 `Client` 类的 `Done` 方法中找到)。
我希望您能够构建一个不错的压力控制系统,因为我在该任务上有些失败。如果您真的想承担这项任务,请尝试阅读 TCP/IP RFC,它们提供了许多有趣的技术。
最后的寄语
1. 通过多线程可以实现更好的速度。我对多线程不太擅长,所以这取决于您。在当前设计中,客户端首先读取所有数据,然后发送 ACK - 这可能导致一些严重的瓶颈。
2. 文件传输速度在我每次启动这些应用程序时都会有所不同,但过了一段时间后它会开始下降。我相信,这是由于我无法定位的错误,或者也许我的逻辑中有一些完全错误的地方:)
3. 如果您有任何建议或更正,请*一定*告诉我。我对这个话题非常感兴趣。
4. 我也不知道如何保护服务器免受滥用客户端的侵害。在 TCP 中,您只需断开滥用客户端的连接……在 UDP 中,它根本就没有连接!您可以轻松忽略他的消息,但它们仍然不断到来。