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跳出循环

转载于:https://www.cnblogs.com/FudgeBear/p/8655978.html

Siki_Unity_4-4_丛林战争_Socket/TCP网络游戏开发相关推荐

  1. 网络游戏《丛林战争》开发与学习之(一):网络编程的基础知识

    <丛林战争>是一款完整的网络游戏案例,运用U3D开发客户端,Socket开发服务端,其中涉及到了网络编程.数据库和Unity的功能实现,之前通过U3D开发了一个单机游戏<黑暗之光&g ...

  2. unity网络实战开发(丛林战争)-前期知识准备(004-开发TCP客户端的接收数据和发送数据)

    使用工具:VS2015 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解一下客户端的接收数据和发送数据. 首先在现有 ...

  3. unity网络实战开发(丛林战争)-正式开发阶段(014-游戏客户端与服务器端连接搭建)

    使用工具:VS2017,unity3d 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 上一篇文章中,我已经把服务器端的框架进行了搭建,接下来, ...

  4. unity网络实战开发(丛林战争)-正式开发阶段(013-游戏服务器端框架搭建)

    使用工具:VS2015 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解一下游戏服务器端的开发. 01-项目目录结构 ...

  5. unity网络实战开发(丛林战争)-前期知识准备(012-UI框架开发)

    使用工具:VS2017,Unity2017.3,DoTween插件 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解 ...

  6. unity网络实战开发(丛林战争)-前期知识准备(010-在服务器端解析数据)

    使用工具:VS2015 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解一下在服务器端解析数据. 首先,同前文类同, ...

  7. unity网络实战开发(丛林战争)-前期知识准备(006-修改服务器端开启异步处理客户端连接请求)

    使用工具:VS2015 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解一下修改服务器开启一步处理客户端连接请求. ...

  8. unity网络实战开发(丛林战争)-前期知识准备(008-粘包和分包及解决方案)

    使用工具:VS2015 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 继上一篇文章内容,这节课讲解一下什么是粘包和分包,以及处理粘包和分包问题 ...

  9. unity网络实战开发(丛林战争)-正式开发阶段(016-数据库设计以及登录处理)

    使用工具:VS2017,unity3d 使用语言:c# 作者:Gemini_xujian 参考:siki老师-<丛林战争>视频教程 上一篇文章中,已经完成了游戏场景与开始界面UI的搭建,接 ...

最新文章

  1. java数列求和_java中关于数列求和的计算方法
  2. 大数据处理——Hadoop解析(一)
  3. BUU[SCTF2019]Who is he
  4. 【django】创建django项目工程
  5. selenium2 python自动化测试实战(回归测试)
  6. Java实现线性表(顺序表,链表)
  7. 图解WinCE6.0下的内核驱动和用户驱动
  8. 2019.7.17刷题统计
  9. BZOJ 2653 middle
  10. 使用sync-request和cheerio写爬虫
  11. Java基础--多态
  12. JUnit单元测试的几个规律总结
  13. java 字符终端库_Java 字符终端上获取输入三种的方式分享
  14. 如何判断python是否安装成功_python怎么判断模块安装完成
  15. 设计大师Donald Norman和Bill Buxton签书会在南京举行
  16. SpringBoot整合Selenium
  17. 关于CalendarUtil获取时间的工具类
  18. java hl7v3_hl7 java 解析
  19. 完工后的决算书范本_装修竣工结算书范本谁能给份
  20. [kriging](一)网上下载的kriging克里金的C++程序的初步调试

热门文章

  1. iOS与Android对比
  2. python下标遍历字典_字典的遍历以及公共方法
  3. java并发编程实战wwj----------第二阶段------------工厂--count down--Thread-per-message------------------31-32-33
  4. python 不定积分_python使用sympy不定积分入门及求解
  5. 关于全栈工程师的一点想法
  6. Java设计模式——单例设计模式/权限修饰符的使用
  7. vue 项目打包部署到金蝶
  8. Visual Studio 2022 vcvarsXXX.bat文件所在的目录
  9. 【Agile框架(一)】首页布局
  10. Vue报错:Root file specified for compilation Vetur(1261)