Unity 4-4 丛林战争(Socket/TCP网络游戏开发)
任务1:素材、演示、Prerequisite
使用c#的有关TCP的底层API进行服务器端的开发(直接通过socket进行通信)
功能:
Third-Person Shooting Game
创建房间、加入房间的联机功能
Prerequisite:
TCP基础知识
MySQL基础知识
UI框架
任务2:IP和port端口号
IP: 在网络环境中,将数据包发给最终的目标地址
路由器可以理解为数据的中转站
连接同一个路由器的可能是多台设备,这部分构成了一个局域网
路由器会给每台设备分配一个不重复的局域网IP
(cmd: ipconfig -- WLAN的IPv4地址,一般是192.168.x.x)
而这个局域网内的设备是共享一个公网IP的
通过百度搜索IP即可查到当前设备的公网IP
IP地址是由网络供应商分配的
游戏的服务器有一个公网IP,用于与客户端之间的通信
服务器购买:阿里云 -> 云服务器ECS
Port: 端口号
数据通信的实质是:在软件之间的传输
端口号表明了是在跟该电脑上的哪个软件进行通信
端口号是不会重复的,由操作系统进行分配
一般公认端口 (Well-known Ports)在0~1023之间
比如HTTP协议代理端口号常用80等
注册端口 (Registered Ports)在1024~49151之间,多被一些服务绑定
动态/私有端口 (Dynamic/ Private Ports)则一般在1024~65535之间
只要运行的程序向系统提出访问网络的申请,那么系统就可以从这些端口号中分配一个共该程序使用
任务3:TCP协议和三次握手
当一个通信建立连接时,需要进行TCP的三次握手
当一个通信连接断开时,需要进行TCP的四次挥手
TCP和UDP的优缺点:
TCP传输稳定,传输信息时会保证信息的完整性
-- 发出消息后会等待接收端的响应,如果等待时间到后没有响应,会再次发送
UDP不稳定,可能丢失数据,但是速度快
-- 发出消息后不会验证消息的接收状态
详见 https://blog.csdn.net/omnispace/article/details/52701752
TCP的三次握手 Three-Way Handshake:-- 连接的建立
1. 客户端发送SYN (syn=j -- 随机产生)包给服务器,并进入SYN_SENT状态,请求建立连接,等待服务器确认
2. 服务器收到SYN包后,针对SYN进行应答ACK (ack = j+1),同时自己也发送一个SYN包 (syn=k -- 随机产生),
即发送了SYN+ACK包给客户端,服务器进入SYN_RECV状态
3. 客户端收到SYN+ACK后,向服务器发送确认包ACK (ack=k+1)
此时客户端和服务器进入ESTABLISHED状态,完成三次握手
自此连接建立成功,可以开始发送数据
TCP的四次挥手 -- 连接终止协议
1. 客户端发送FIN包给服务器,用来表示需要关闭客户端到服务器的数据传输
客户端进入FIN_WAIT_1状态
2. 服务器收到FIN后,针对FIN进行确认应答ACK (确认序号为收到序号+1),并将ACK发送给客户端
服务器进入CLOSE_WAIT状态
3. 服务器发送FIN包给客户端,请求切断连接
服务器进入LAST_ACK状态
4. 客户端收到FIN后,进入TIME_WAIT状态,并针对FIN包进行确认应答ACK,并向服务器发送
服务器进入CLOSED状态
任务4&5&6:创建TCP服务器端控制台应用 (c#)
VS -> 文件 -> 新建 -> 项目 -> 控制台应用(.NET Framework) -> 命名Server
创建Socket并绑定IP和Port:
using System.Net.Sockets;
1. 创建socket -- Socket(AddressFamily, SocketType, ProtocolType);
Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
AddressFamily
.InterNetwork表示IPv4类型的地址
.InterNetworkV6表示IPv6
SocketType
.Dgram表示使用数据报文的形式,以投递的方式进行数据传输,可能丢失 -- UDP可以使用该形式
.Stream表示使用数据流的形式,在两者之间建立管道,数据在管道中进行传输 -- 数据传输稳定
2. 绑定IP和port:
IP:
因为设备可能有多个网卡,每个网卡可能连接不同的网络,因此一个设备可能出现对应多个IP地址
但是,作为服务器端的部署,一般只会有一个外网IP
这里,绑定局域网IP即可
// 通过ipconfig得到局域网ip,或直接使用127.0.0.1 (本地localhost)
using System.Net;
IPAddress -- 代表ip -- xxx.xxx.xx.xx
IPEndPoint -- 代表ip: port -- xxx.xxx.xx.xx : xx
-- 因为过一段时间,路由器会给设备重新分配ip,基于路由器的ip管理策略
所以不应直接设置ip地址
创建ip地址
IPAddress ipAddress = new IPAddress(new byte[] {192,168, x, x});
不推荐这么写,改为 -->
IPAddress ipAddress = IPAddress.Parse("192.168.x.x");
Port:
创建port地址
IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, "65535");
绑定ip和端口号
serverSocket.Bind(ipEndPoint); // 包括了向操作系统申请端口号
发送和接收数据:
3. 开始监听端口
serverSocket.Listen(50);
// 表示处理等待连接的队列最大为50,设置为0表示不设置最大值,无限制
// 服务器只有一个,而客户端有多个,等待队列满后将不再接收客户端连接
4. 等待接收一个客户端来的连接
Socket clientSocket = serverSocket.Accept();
直到接收到连接后,才会继续执行下面的代码
发送数据
string msg = "Hello client! 你好 ....";
byte[] data = System.Text.Encoding.UTF8.GetBytes(msg); // 将string转换成byte[]
clientSocket.Send(data); // 需要传输的类型是byte[]
接收数据
byte[] dataBuffer = new byte[1024]; // 保证数组大小够用即可
int count = clientSocket.Receive(dataBuffer); // 返回值int表示接收到byte[]数据的长度
string megReceived = System.Text.Encoding.UTF8.GetString(dataBuffer,0 , count);
// 表示把有内容的那部分bytes进行转换, 从0开始,一直到第count字节
Console.WriteLine(msgReceive);
5. 关闭连接:
clientSocket.Close(); // 断开客户端的连接
serverSocket.Close();
任务7:创建TCP客户端控制台应用 (c#)
新建 -> 项目 -> 控制台应用(.Net Framework) -> 命名Client
创建socket:
Socket clientSocket = new Socket(AddressFamily.InnerNetwork, SocketType.Stream, ProtocolType.Tcp);
与服务器端建立连接:
clientSocket.Connect(new IPEndPoint(IPAddress.Parse("192.168.x.x"), 65535));
与远程主机建立连接,服务器端的Accept()得到了来自客户端的连接,因此继续执行它以下的代码,向客户端Send()消息
进行有关消息的操作:
从服务器端接收消息:
byte[] data = new byte[1024];
int count = clientSocket.Receive(data);
string msg = System.Text.Encoding.UTF8.GetString(data, 0, count);
Console.Write(msg);
// 调用完Receive()后,程序会暂停并等待,直到接收到信息后才会继续执行下面的代码
发送消息给服务器端:
string input = Console.ReadLine();
clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(input));
-- 此时server中的接收消息部分会接收这段发送过去的信息
关闭连接:
clientSocket.Close();
运行上面的服务器端和客户端
如何同时运行呢?
在VS中不能同时运行两个应用程序
1. 在VS中启动服务器端
2. 在文件资源管理器中,右键对应的项目 -> 生成 -- 就会生成.exe文件
直接双击.exe程序,启动客户端
左侧为server,右侧为client
server打开,暂停在serverSocket.Accept()处等待客户端连接
client打开,并进行clientSocket.Connect(),建立连接
连接建立成功,server代码继续执行,执行Send()后,在Receive()处暂停
而client建立连接后在Receive()处暂停,等待接收server消息,因为server执行了Send(),client接收到了消息
消息接收完,client代码继续执行,等待用户输入 Console.ReadLine();
输入后,进行Send()操作并执行关闭连接
server在接收到client发送的消息,继续执行代码
(因为server接收到信息并打印之后,程序就结束自动关闭了(client也一样)
为了方便看清server接收到的信息,在server最后加上了一行Console.ReadKey()阻止自动关闭)
任务8:实现服务器端异步的消息接收
之前的程序在会在Receive()处一直等待;若要想持续不断地发送或接收消息,有两种方法:
1. 另起一个线程,比如聊天室功能单独占有的线程
2. 异步方法
clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket);
开始监听数据的传递
BeginReceive(buffer, int offset, int size, SocketFlags, AsyncCallback, object state);
offset: 从哪开始;size: 最大数据长度;AsyncCallback: 接收到消息后的回调函数;
state: 给回调函数传递的参数,在回调函数中的ar.AsyncState强制转换成需要的类型即可
static byte[] s_Buffer = new byte[1024]; static void StartServerAsync() { Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAddress = IPAddress.Parse("192.168.1.5"); IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, 65535); serverSocket.Bind(ipEndPoint); serverSocket.Listen(0); Socket clientSocket = serverSocket.Accept(); string msg = "Hello client"; clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(msg)); // 这里开始进行异步接收消息 clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); } static void ReceiveCallBack(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; int count = clientSocket.EndReceive(ar); Console.WriteLine(Encoding.UTF8.GetString(s_Buffer, 0, count)); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); // 循环调用,继续等待接收数据 }
任务9:服务器端开启异步处理客户端连接请求
任务4~6中使用的socket.Accept()也会导致程序等待,当有客户端连接过来时才会继续下面的代码
如何异步地进行接受连接呢 -- 异步方式
BeginAccept(AsyncCallback callback, object state);
serverSocket.BeginAccept(AcceptCallback, serverSocket); // 开始异步等待连接 static void AcceptCallback(IAsyncResult ar) { // 异步接收的回调函数 Socket serverSocket = ar.AsyncState as Socket; Socket clientSocket = serverSocket.EndAccept(ar); byte[] data = Encoding.UTF8.GetBytes("....."); clientSocket.Send(data); clientSocket.BeginReceive(dataBuffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); // 循环调用,不断接收 serverSocket.BeginAccept(AcceptCallback, serverSocket); }
此时,能启动多个客户端并与服务器端连接。
任务10:服务器端处理客户端连接的正常/非正常关闭
客户端连接的非正常关闭:
任务9中提到:
当客户端关闭时,会发现服务器端报错了: SocketException: 远程主机强迫关闭了一个现有的连接。
原因是客户端窗口关闭时可以被视为非正常关闭,而服务器端执行clientSocket.BeginReceive()后调用EndReceive()接收消息时,客户端连接已不存在。
需要进行异常捕获处理
private static void ReceiveCallback(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; try { int count = clientSocket.EndReceive(ar); string msg = Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine(msg); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); } catch(Exception e) { Console.WriteLine(e); if(clientSocket != null) { clientSocket.Close(); } } finally { } }
抛出异常,则关闭连接
客户端连接的正常关闭:
假设在客户端中输入"c",则将socket关闭
string msg = Console.ReadLine(); if(msg == "c") { clientSocket.Close(); return; }
运行,会发现当客户端输入c执行socket.Close()后,服务器端不断接收到空数据,且没有报错
原因:在服务器端的ReceiveCallback()中的EndReceive()会不断接收许多条空数据并继续BeginReceive()
即使客户端的连接已经断掉了
(对上面的原因很有疑惑)
解决方法:在服务器端判断EndReceive()返回值count的大小,如果count==0则关闭连接
if(count == 0) { clientSocket.Close(); return; }
任务11&12:粘包和分包 及其实例
粘包和分包是利用Socket在TCP协议下内部的优化机制
粘包和分包是由于内部的优化机制所导致的
包:每次调用Send()所传输的数据就可以算是一个包
粘包:发送数据很频繁,且每一个数据包都很小时
频繁的发送是很耗费性能的,因此Tcp协议会在内部将多个数据包进行合并,产生一个粘包,在接收数据的终端用一条Receive()接收
一个Receive()接收到的数据很可能包含多个消息
分包:当发送的一个数据包的数据量特别大时,会拆分开来通过多个数据包进行发送。
因为如果这个数据量很大的包发送失败时,需要重新发送,浪费了性能;而且传输时占用的带宽也较大
一个Receive()接收到的数据很可能不是一个完整的消息
粘包和分包发送的数据
实例演示:
粘包:在客户端 利用for循环将i发送出去
在服务器端接收的次数远少于客户端发送的次数
粘包的大小不同的原因应该是客户端for循环运行的快慢导致的
在游戏开发中,粘包需要重点处理,因为游戏同步的数据(比如位置信息等)很符合被粘包数据的特征
分包:在客户端发送很大的数据包。
在服务器端的dataBuffer的长度会将该数据包进行分割。一个dataBuffer存放不下就会留给下一个buffer存放
任务13~17:粘包和分包问题的解决方案
解决方案思路:
给发送的数据添加一个前缀数据,用来表示该数据的长度。
在接收数据后解析数据时,通过读取表示数据长度的数据,得到实际数据。
如果实际得到的数据的长度大于数据长度,则解析出完整数据,并用相同方法解析下一个数据长度数据和实际数据
如果实际得到的数据的长度小于数据长度,则接收下一个数据包,直到接收够完整数据,再进行一次性解析
注意:表示数据长度的前缀数据,它本身的长度必须是固定的
插入题外话:如何将字符串或值类型(比如int)转换为byte[]字节数据
字符串是引用类型
1. 之前使用的方法是用UTF8编码格式将字符串转换为byte[]
byte[] data = System.Text.Encoding.UTF8.GetBytes("1a 中");
尝试输出该字节数组:49 97 32 228 184 173
其中49对应1,97为a的ascii码,32对应空格,之后三个字节对应的是一个汉字
那么,通过这种方法的转换为什么不适用在表示数据长度的前缀数据上呢?
因为数字位数的不同,会导致转换后的字节数不同。
比如长度数据=4,转换后为一个字节;而长度数据=1000,则转换后为四个字节
2. 另一种方法可以将值类型的数据转换为字节数据
int count = 1;
byte[] data = BitConverter.GetBytes(count);
输出data后,为四个字节 0 0 0 1, 因为int为Int32类型,占4个字节
即使count = 100000(只要不溢出Int32),都是4个字节
相对应的,BitConverter.ToInt32(data)可以将字节数据转换成int值
BitConverter中有很多方法,都是用来转换值类型的数据
解决方案实现:
客户端算出数据的长度,并将数据长度信息加到数据包前
public static byte[] GetDataBytesWithLengthInfo(string data) { // 得到data的字节数据 byte[] dataBytes = Encoding.UTF8.GetBytes(data); // 字节数据的长度 int dataLength = dataBytes.Length; // 长度信息的字节数据 byte[] lengthBytes = BitConverter.GetBytes(dataLength); // 合并数据 return lengthBytes.Concat(dataBytes).ToArray(); }
服务器端收到消息后,进行数据包的解析 -- 几条消息
用Message类实现相关功能
需要注意的地方:
1. 需要一个数组用来存储接收到的byte[]
Message.data
2. 需要一个flag来跟踪当前已经读取到的位置
Message.startIndex
3. 将存储的byte[]解析成消息
在Server中定义static Message msg = new Message();
接收数据的时候clientSocket.BeginReceive(msg.data, msg.startIndex, msg.RemainSize, SocketFlag.None, ReceiveCallback, clientSocket);
// data表示存储的byte[]; startIndex为接下来开始存储的位置,也代表已经存储了的字节数;
// RemainSize = data.Length-startIndex, 表示可存储的最大字节数,避免读取太多数据导致msg.data空间不足溢出
每读取一次完(EndReceive()),需要更新msg.startIndex += count;
读取完数据,开始解析:
1. 判断是否有足够数据以解析
if(startIndex <= 4) return; // 如果已经存储在data中的字节数据长度小于4,则没有存储数据(长度数据已经占了4个字节)
2. 数据长度 --
int length = BitConverter.ToInt32(data, 0); // 从0开始读取4个字节的数据,解析成长度数据
3. 判断是否有足够数据,没有的话等待下一次数据的读取,并需要再次调用本方法
if(startIndex - 4 >= length) {
4. 解析数据
Encoding.UTF8.ToString(data, 4, length); // 从4开始,读取出完整的一条数据,多余的不读取
5. 循环读取多条,直到读取完
startIndex -= (4 + length); // 更新startIndex
Array.Copy(data, 4 + count, 0, startIndex); // 删除已经解析完的数据
用while(true)进行循环,直到数据不足startIndex<=4或startIndex-4<length跳出循环