本文主要介绍 UDP 及 KCP 通讯的一个简单使用,目的是为『帧同步』战斗的实现作一个前置准备。
# 前言
之前不是提到了帧同步么,在那之后,或者说那时候就有想自己实现一个完整的帧同步战斗。不过后边又忙了忙项目任务,加上其它一些事情,也就搁置下来了。这两天项目 2.1.0 版本任务差不多,并且也开始出包了。今天周六,加班主要是给测试出出包,于是就想利用空余时间整理下,顺便写写东西。
帧同步战斗对网络性能要求一般会比较高,因为很『怕』延迟,所以一般都会采用 UDP 协议进行数据传输,后面有了 KCP 之后多了个选择 (听说)。当然,不排除也有的游戏直接用 TCP 作为通讯协议,因为 TCP 虽然没前两者快,延迟可能更高,不过因为 TCP 必然会作为游戏通信基础协议,战斗也直接用 TCP 确实更简单一点。
由于 KCP 其实也是从 UDP 之上搭建的一个协议框架,因此这里先从 UDP 开始介绍吧。
注:虽然本文描述内容个人都进行过编码试验,不过限于个人水平,若有错漏,还请指正。
# UDP
# 介绍
众所周知,UDP 作为一个无连接协议,是只管发,不管接没接收到的。
不像 TCP,服务端必须监听客户端连接,然后建立连接才能相互通信。
作为 UDP,只需要监听指定端口,就能直接获取所有从该端口传入的数据。
因此性能上 UDP 会更好,但若是不管不顾的话,问题就是会出现数据包丢失。除非是视频流之类对丢包不敏感的东西,不然使用 UDP 的话,丢包问题都是需要自己解决的。
其使用方式主要有两种:
- 直接使用 C# 提供的 UdpClient
- 采用 Socket 连接方式
使用 UdpClient 的方式相对比较简单,因为其本身就是对 Socket 的一个封装,使得 UDP 更好用一点。所单纯使用 UDP,采用该方式也是可以的。
# UdpClient
UdpClient 使用方式很简单,创建一个 UdpClient,然后调用 Connect 连接至需要发送的对象即可:
发送端:
//Send Client | |
using (UdpClient udp = new UdpClient()) | |
{ | |
udp.Connect(System.Net.IPAddress.Parse("127.0.0.1"), 4444); | |
byte[] sendBytes = Encoding.UTF8.GetBytes("Hello UdpClient!"); | |
udp.Send(sendBytes, sendBytes.Length); | |
Console.WriteLine("已发送"); | |
} |
其中,UdpClient 的构造函数可以接收 IPEndPoint 或 端口号,代表的本地 IP 或端口号。
(关于构造函数参数,更多可参考官方文档)
经个人尝试,有以下规则:
- 若构造函数指定了端口号 (或 IP),UdpClient 本地地址将初始化为指定端口 (IP)
- 若构造函数未指定端口号,则会在调用 Connect 时,自动将本地端口号初始化为一个随机值,IP 初始化为本地 IP
- 仅作为发送端,可以不用指定初始化值
- 作为接收端,需要先指定过本地端口,或调用过会自动初始化本地地址的方法 (如 Connect)
一般 IPEndPoint 倒不用特别注意,若仅仅只是发送数据,倒可以视情况而定,不过若作为数据接收者『接收端』则一般来说是需要指定的,或后续或调用过 Connect 方法,否则直接调用 Receive 方法将会抛出异常:
当然相互之间的通讯,除非能够有告诉另外的发送者,否则可能就没法准确接收数据了,这里特指 UdpClient 的方式,
除非另外一方使用 Socket 方式接收 UDP 数据,不过动态端口号中间也有操作余地的。
上面发送端并未指明本地端口,这里接收端则直接指定 4444 为接收端。
接收端:
//Receve Server | |
using (UdpClient udp = new UdpClient()) | |
{ | |
//udp.Connect(System.Net.IPAddress.Parse("127.0.0.1"), 4444); | |
// 远程 IP 缓存,收到谁的信息,这个 IP 就会是谁的地址 | |
System.Net.IPEndPoint ipRemote = new System.Net.IPEndPoint(System.Net.IPAddress.Any, 0); | |
byte[] receiveBytes = udp.Receive(ref ipRemote); | |
Console.WriteLine($"收到来自 {ipRemote} 的信息:{Encoding.UTF8.GetString(receiveBytes)}"); | |
} | |
Console.ReadKey(); |
测试效果:
可以看见,发送端的端口被打印出来,也是属于一个随机端口。
另外可以注意到,接收端在打印接收信息之前,也打印了一个 “已发送” 的信息
这是因为方便测试,上述接收和发送代码放在了一块用:
//Send Client | |
using (UdpClient udp = new UdpClient()) | |
{ | |
udp.Connect(System.Net.IPAddress.Parse("127.0.0.1"), 4444); | |
byte[] sendBytes = Encoding.UTF8.GetBytes("Hello UdpClient!"); | |
udp.Send(sendBytes, sendBytes.Length); | |
Console.WriteLine("已发送"); | |
} | |
//Receve Server | |
using (UdpClient udp = new UdpClient(4444)) | |
{ | |
// 远程 IP 缓存,收到谁的信息,这个 IP 就会是谁的地址 | |
System.Net.IPEndPoint ipRemote = new System.Net.IPEndPoint(System.Net.IPAddress.Any, 0); | |
byte[] receiveBytes = udp.Receive(ref ipRemote); | |
Console.WriteLine($"收到来自 {ipRemote} 的信息:{Encoding.UTF8.GetString(receiveBytes)}"); | |
} | |
Console.ReadKey(); |
因为接收端收到信息就会释放掉当前端口占用,因此通信完成后新的发送端又会成为接收端。(虽然并没有什么用,不过方便测试,测试时,双击两次执行文件就可以得出运行结果了)
# Socket 方式
Socket 方式是比 UdpClient 更底层的使用方式,相比 UdpClient 来说,相对来说会自由一点。
向指定地址及指定端口 (本机 4444 端口) 发送数据:
···cs
using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
{
socket.Connect("127.0.0.1", 4444);
socket.Send(Encoding.UTF8.GetBytes(Console.ReadLine()));
}
···
监听本机指定端口 (本机 4444 端口) 数据:
using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) | |
{ | |
socket.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 4444)); | |
byte[] buff = new byte[1024]; | |
int num = socket.Receive(buff); | |
if (num > 0) | |
{ | |
Console.WriteLine(Encoding.UTF8.GetString(buff, 0, num)); | |
Console.WriteLine("接收完毕!\n"); | |
} | |
} |
此处发送跟 UdpClient 没有太大差异,都很简单,需要注意的是接收端,Socket 方式需要像 TCP 一样指定接收的缓存字节数组,而且位于接收缓存中的内容也需要自己判断读取,若缓存不够,一次不一定能读取完。关于这点后续再谈,这里直接简单地设置一个 1024byte 大小的缓冲数组用于接受数据。
另外为了方便起见,个人直接写了个简单的测试代码:
Start: | |
Console.WriteLine("输入1初始化为服务端,2初始化为发送端"); | |
var key = Console.ReadKey(); | |
// 服务器 | |
if (key.Key == ConsoleKey.D1) | |
{ | |
Console.WriteLine("已初始化为服务端"); | |
using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) | |
{ | |
socket.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 4444)); | |
Server: | |
byte[] buff = new byte[1024]; | |
int num = socket.Receive(buff); | |
if (num > 0) | |
{ | |
Console.WriteLine(Encoding.UTF8.GetString(buff, 0, num)); | |
Console.WriteLine("接收完毕!\n"); | |
} | |
goto Server; | |
} | |
} | |
// 发送端 | |
else if (key.Key == ConsoleKey.D2) | |
{ | |
Console.WriteLine("已初始化为发送端"); | |
using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) | |
{ | |
socket.Connect("127.0.0.1", 4444); | |
Sender: | |
socket.Send(Encoding.UTF8.GetBytes(Console.ReadLine())); | |
goto Sender; | |
} | |
} | |
else | |
Console.WriteLine("输入错误!"); | |
goto Start; |
(方便测试,这里就不要在意使用 Goto 了)
效果如下:
最基础的发送和接收就是如此,不过如果单纯使用 UDP ,过程中还需要自己另外处理丢包,重发等处理。
后续介绍的 KCP ,则相当于在此基础上,实现了这一类功能。
另外 KCP 的 Socket 使用方式,还有直接调用 『SendTo』 或 『ReceiveFrom』 方法,不过一般不建议那种使用方式。
# KCP
# 介绍
根据说明:
KCP 是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如 UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback 的方式提供给 KCP。 连时钟都需要外部传递进来,内部不会有任何一次系统调用。
更多介绍可参考 KCP 的 Git 地址
(注:这是 C 语言版本)
根据描述来看,感觉 KCP 有点类似于空间换时间,流量换效率的模式。
C# 如果使用 C 语言版本用就有点麻烦了,这里就直接使用其 C# 移植版。在 readme 中也有好几个实现的项目链接,这里个人选择了 KCPSharp
# KCPSharp UDPSession 的问题
注:这个 KCPSharp 虽然提供的 UDPSession 但是稍微有点坑,直接是没法开箱即用的。
因此还有个问题就是项目提供的示例 Demo 也没法正常使用 (虽然不知道作者知不知道)。
UDPSession 中问题主要有以下几点:
- Connect 使用 Dns.GetHostEntry (host) 获取本地地址,实测传入 127.0.0.1 获取到的将会是 IPV6 地址
- 会话标识 (conv) 是直接传入一个随机数,,导致两个 UDPSession 之间都没法正常通信
- 仅提供发送者 Connect 初始化方法,建立的 Socket 本地端口随机,没有初始作为监听者方法
# 默认提供的连接方法
看连接结构 (KCPSharp 提供的 UDPSession):
public void Connect(string host, int port) | |
{ | |
IPHostEntry hostEntry = Dns.GetHostEntry(host); | |
if (hostEntry.AddressList.Length == 0) | |
{ | |
throw new Exception("Unable to resolve host: " + host); | |
} | |
var endpoint = hostEntry.AddressList[0]; | |
mSocket = new Socket(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp); | |
mSocket.Connect(endpoint, port); | |
RemoteAddress = (IPEndPoint)mSocket.RemoteEndPoint; | |
LocalAddress = (IPEndPoint)mSocket.LocalEndPoint; | |
mKCP = new KCP((uint)(new Random().Next(1, Int32.MaxValue)), rawSend); | |
// normal: 0, 40, 2, 1 | |
// fast: 0, 30, 2, 1 | |
// fast2: 1, 20, 2, 1 | |
// fast3: 1, 10, 2, 1 | |
mKCP.NoDelay(0, 30, 2, 1); | |
mKCP.SetStreamMode(true); | |
mRecvBuffer.Clear(); | |
} |
如果自己添加一个监听者直接使用,在 KCP 接收层,Input 方法中,ikcp_decode32u 解析的时候判断会话标识不一致,都会直接返回 - 1 (不信 Random().Next(1, Int32.MaxValue)
这种方法还能让标识一致的?)
# 修改
这里先修改一下这个连接方法,使其可以接受一个 conv 的外部参数,修正即便是两个同一个类创建的 UDPSession 都无法正确通信问题:
(顺便将字符串 IP 和端口换成了 IPEndPoint)
public void Connect(IPEndPoint remoteIP, uint conv = 1) |
由于 KCP 在正确收到消息后,默认会有一个回复数据包消息发回发送端。
因此如果像上文 UDP 那般,仅绑定本地接收端口来接收数据,数据确实能够接收到,但是由于没有 remoteEP, 后续发送回复数据包时就会报错了 (后续修改的时候把代码改)。
而且,还有一个问题:
此时仅有可以发送的连接端,由于调用该接口仅绑定远程主机,本机地址端口号将会随机生成,根本没法正确确定接收信息端口。
为此,可以仿照上文 UdpClient 使用方法,给 UDPSession 增加一个构造函数,使其可以初始化本地地址或端口号:
public UDPSession(IPEndPoint localIP) | |
{ | |
mSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); | |
mSocket.Bind(localIP); | |
} | |
public UDPSession(int port) | |
{ | |
mSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); | |
mSocket.Bind(new IPEndPoint(IPAddress.Any, port)); | |
} | |
public void Connect(IPEndPoint remoteIP, uint conv = 1) | |
{ | |
mSocket.Connect(remoteIP); | |
InitKcp(conv); | |
} |
如此,使用如下测试代码即可进行本机同一客户端自我通信:
UDPSession session = new UDPSession(4444); | |
session.Connect(new System.Net.IPEndPoint(System.Net.IPAddress.Parse("192.168.9.76"), 4444)); | |
byte[] bytes = new byte[1024]; | |
Server: | |
session.Update(); | |
int num = session.Recv(bytes, 0, bytes.Length); | |
if (num > 0) | |
Console.WriteLine("收到信息:" + Encoding.UTF8.GetString(bytes, 0, num)); | |
else if (num == -1) Console.WriteLine("监听错误!"); | |
session.Send(Encoding.UTF8.GetBytes(Console.ReadLine())); | |
goto Server; |
其中需要注意的是 Update 调用,KCP 需要这个更新内部时钟,缓存列表数据的发送处理等等。
虽然在 KCPSharp 提供的 UDPSession 发送接口中,默认处理了等待发送缓存数据及 NoDelay 的判断,通过即会立即发送数据,不过 Update 在其它处理还是必要的。
例如将 session.WriteDelay = true
设置为 true,那么不调用 update 就无法正常发送数据。
效果:
这个程序拿到同一局域网其它电脑上,也是可以连上并通信的,不过需要注意的是:此处虽然接收数据做了处理,一般不会导致线程阻塞,不过在调用 Console.ReadLine 会由于需求用户输入而导致阻塞。另外还有同一台主机开不了两个实例 (端口被占用)。
不同主机之间,就得按下回车,才能正确读取到接收信息了。可以优化一下,放在另外线程即可。
# 关于同时 Bind 与 Connect 的问题
注:如果同时调用 Bind () 与 Connect (),则代表仅接收 Connect 对象的数据,其它任何数据,都将直接丢弃!
参考:StackOverflow: Can you bind() and connect() both ends of a UDP connection
由于 KCP 需求回复数据包,相当于说就是点对点类型的意思了。
因此默认除非真的写死点对点,否则都是需要另外传入参数,例如采用 TCP 传入端口号进行初始化连接。
可以看到,直接的使用方式,KCP 与 UDP 差异不是很大,主要原因在于 UDPSession 可以说是把更新方法、发送判断又封装了一层,KCP 核心其实就 KCP.cs、ByteBuffer.cs 这两个类。虽然封装的 UDPSession 用起来也有点问题,不过改了下就跟上文的 UDPClient 使用方式差不多了。
不过,由于 回复包存在的缘故,KCP 必须在发送消息前相互都『知道』对方的 IP 和端口,这就导致其无法完全独立运行
例如:同一台主机只能存在一个实例,因为在一个实例 Bind 占用了端口号之后另一个实例就无法绑定同一端口号了。此时应当需要额外作处理区分,例如像 UdpClient 一样,使其可以接收不同连接而来的数据,而非一个连接。猜测 UdpClient 可能就用了 ReceiveFrom 一类的方法,当然,此种情况就后续再说了。
后来想起来,C# 是可以反编的,于是用 dnSpy 查看了一下,还真是如此:
// Token: 0x060021EF RID: 8687 RVA: 0x000A24B0 File Offset: 0x000A06B0 | |
public byte[] Receive(ref IPEndPoint remoteEP) | |
{ | |
if (this.m_CleanedUp) | |
{ | |
throw new ObjectDisposedException(base.GetType().FullName); | |
} | |
EndPoint endPoint; | |
if (this.m_Family == AddressFamily.InterNetwork) | |
{ | |
endPoint = IPEndPoint.Any; | |
} | |
else | |
{ | |
endPoint = IPEndPoint.IPv6Any; | |
} | |
int num = this.Client.ReceiveFrom(this.m_Buffer, 65536, SocketFlags.None, ref endPoint); | |
remoteEP = (IPEndPoint)endPoint; | |
byte[] array = new byte[num]; | |
Buffer.BlockCopy(this.m_Buffer, 0, array, 0, num); | |
return array; | |
} |
# 结语
本文只是对 UDP 和 KCP 使用的一个最基础的介绍,后续的修改和使用或许也会写一写。
今天已经 29 号周二了,还是挺费时间,主要是一边写,还得一边尝试,不确定的地方也不敢直接下结论,查资料和实验起来比较麻烦。
写这篇文章的主要原因,其实是因为为我在将 KCPSharp 嵌入自己之前提到的自己搞的一个网络通信结构 (就之前考虑做项目远程打包模式时候弄的那个) 的时候出了点问题,出现了发送 / 接收失败的问题 (其实就是上文提到的一些坑的问题:conv 为随机值,接收数据返回 - 1 等)。
由于结构已经有点复杂,所以不好找问题,因此就就想新开一个项目,然后完全重新尝试。于是就有了本文以及本文的操作。
下一步就是将这修改后的代码,放进我自己的那个框架,使其能够正常使用。然后搞一个简单的帧同步战斗。
再然后,是利用 ET 框架,做一个比较完整的游戏流程。毕竟自己的这个小东西,就单纯只是一个玩具而已,特别是最近从基础开始在复习了一下编程理念,感觉以后还是要重构一下。不过这个还是等那些过完了再说。
参考文章:
- C# 官方文档
- StackOverflow: Can you bind() and connect() both ends of a UDP connection
- 快速可靠协议 - KCP
- KCP 源码阅读
- KCP 协议详解
- 咨询关于 conv 值和 stream 模式的问题
- https://github.com/skywind3000/kcp/issues/100
- KCP Readme