本文主要介绍 UDP 及 KCP 通讯的一个简单使用,目的是为『帧同步』战斗的实现作一个前置准备。

# 前言

之前不是提到了帧同步么,在那之后,或者说那时候就有想自己实现一个完整的帧同步战斗。不过后边又忙了忙项目任务,加上其它一些事情,也就搁置下来了。这两天项目 2.1.0 版本任务差不多,并且也开始出包了。今天周六,加班主要是给测试出出包,于是就想利用空余时间整理下,顺便写写东西。

帧同步战斗对网络性能要求一般会比较高,因为很『怕』延迟,所以一般都会采用 UDP 协议进行数据传输,后面有了 KCP 之后多了个选择 (听说)。当然,不排除也有的游戏直接用 TCP 作为通讯协议,因为 TCP 虽然没前两者快,延迟可能更高,不过因为 TCP 必然会作为游戏通信基础协议,战斗也直接用 TCP 确实更简单一点。

由于 KCP 其实也是从 UDP 之上搭建的一个协议框架,因此这里先从 UDP 开始介绍吧。

注:虽然本文描述内容个人都进行过编码试验,不过限于个人水平,若有错漏,还请指正。

# UDP

# 介绍

众所周知,UDP 作为一个无连接协议,是只管发,不管接没接收到的。

不像 TCP,服务端必须监听客户端连接,然后建立连接才能相互通信。
作为 UDP,只需要监听指定端口,就能直接获取所有从该端口传入的数据。

因此性能上 UDP 会更好,但若是不管不顾的话,问题就是会出现数据包丢失。除非是视频流之类对丢包不敏感的东西,不然使用 UDP 的话,丢包问题都是需要自己解决的。

其使用方式主要有两种:

  1. 直接使用 C# 提供的 UdpClient
  2. 采用 Socket 连接方式

使用 UdpClient 的方式相对比较简单,因为其本身就是对 Socket 的一个封装,使得 UDP 更好用一点。所单纯使用 UDP,采用该方式也是可以的。

# UdpClient

UdpClient 使用方式很简单,创建一个 UdpClient,然后调用 Connect 连接至需要发送的对象即可:

发送端:

s
//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 为接收端。

接收端:

s
//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();

测试效果:

可以看见,发送端的端口被打印出来,也是属于一个随机端口。

另外可以注意到,接收端在打印接收信息之前,也打印了一个 “已发送” 的信息

这是因为方便测试,上述接收和发送代码放在了一块用:

s
//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 增加一个构造函数,使其可以初始化本地地址或端口号:

s
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