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[];
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, );
serverSocket.Bind(ipEndPoint);
serverSocket.Listen();
Socket clientSocket = serverSocket.Accept(); string msg = "Hello client";
clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(msg)); // 这里开始进行异步接收消息
clientSocket.BeginReceive(buffer, , , 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, , count));
clientSocket.BeginReceive(buffer, , , 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, , , 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, , count);
Console.WriteLine(msg); clientSocket.BeginReceive(buffer, , , 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 == ) {
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跳出循环

Siki_Unity_4-4_丛林战争_Socket/TCP网络游戏开发的更多相关文章

  1. 使用Photon引擎进行unity网络游戏开发(一)——Photon引擎简介

    使用Photon引擎进行unity网络游戏开发(一)--Photon引擎简介 Photon PUN Unity 网络游戏开发 Photon引擎简介: 1. 服务器引擎: 服 务 器 引 擎 介 绍 服 ...

  2. Java典型应用彻查1000例:图形与网络游戏开发 PDF 扫描版[68M]

    <Java典型应用彻查1000例·图形与网络游戏开发>实例丰富,编排合理,可以让有初级Java基础的读者,从陌生到完全熟练地设计网络游戏,进而掌握3D立体绘图方法,适合作为Java网络游戏 ...

  3. 使用Photon引擎进行unity网络游戏开发(四)——Photon引擎实现网络游戏逻辑

    使用Photon引擎进行unity网络游戏开发(四)--Photon引擎实现网络游戏逻辑 Photon PUN Unity 网络游戏开发 网络游戏逻辑处理与MasterClient 网络游戏逻辑处理: ...

  4. 使用Photon引擎进行unity网络游戏开发(三)——网络游戏大厅及房间

    使用Photon引擎进行unity网络游戏开发(三)--网络游戏大厅及房间 Photon PUN Unity 网络游戏开发 连接到Photon ConnectUsingSettings 设置你的客户端 ...

  5. 使用Photon引擎进行unity网络游戏开发(二)——Photon常用类介绍

    使用Photon引擎进行unity网络游戏开发(二)——Photon常用类介绍 Photon PUN Unity 网络游戏开发 Photon常用类介绍: IPunCallback PUNGIPunCa ...

  6. cross socket tcp客户端开发

    cross socket tcp客户端开发 uses Net.SocketAPI, Net.CrossSocket.Base, Net.CrossSocket FCrossTcp: ICrossSoc ...

  7. 网络游戏开发-服务器(01)Asp.Net Core中的websocket,并封装一个简单的中间件

    先拉开MSDN的文档,大致读一遍 (https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/websockets) WebSocket 是一 ...

  8. Unity多玩家网络游戏开发教程1章Unity带有网络功能

    Unity网络多玩家游戏开发教程第1章Unity自带网络功能 Unity拥有大量的第三方插件.专门提供了对网络功能的支持. 可是.大部分开发人员第一次接触到的还是Unity自带的网络功能.也就是大家常 ...

  9. Modbus库开发笔记之四:Modbus TCP Client开发

    这一次我们封装Modbus TCP Client应用.同样的我们也不是做具体的应用,而是实现TCP客户端的基本功能.我们将TCP客户端的功能封装为函数,以便在开发具体应用时调用. 对于TCP客户端我们 ...

随机推荐

  1. vs环境变量学习

    1. 查看vs环境变量: 在项目设置中的任何路径.目录编辑项目下,右下角有个“宏”,点开即可见所有vs环境变量的当前设置...听说还有其它地方,没看到. 2.上边的“宏”,即是英文的vs环境变量 3. ...

  2. iis和apache共用80端口,IIS代理转发apache

    为什么共用80端口应该不用多说了,服务器上程序运行环境有很多套,都想抢用80端口,所以就有了共用80端口的解决方案. 网上很多的教程一般都是设置APACHE使用默认80端口,代理转发IIS的网站,II ...

  3. 静默安装oracle后,启动oem报错,解决方法!

    一.手工重建EM Oracle 的gridcontrol 由两部分组成:dbcontrol 和repository. 我们可以对某一部分进行操作,也可以同时进行操作. 这里先看一个因为修改hostna ...

  4. 【转】 java中Class对象详解和类名.class, class.forName(), getClass()区别

    Class对象的生成方式如下: 1.类名.class           说明: JVM将使用类装载器, 将类装入内存(前提是:类还没有装入内存),不做类的初始化工作.返回Class的对象 2.Cla ...

  5. windows 下搭建git服务器,及问题处理。

    最近要做一个源码管理服务器,权衡了一下还是git最适合,搭建服务器前看了网上一些windows下搭建git服务器的帖子,感觉还比较简单,没有太多需要配置的地方,于是开始动手. 我选择的是 gitfor ...

  6. pandas数据结构:Series/DataFrame;python函数:range/arange

    1. Series Series 是一个类数组的数据结构,同时带有标签(lable)或者说索引(index). 1.1 下边生成一个最简单的Series对象,因为没有给Series指定索引,所以此时会 ...

  7. set集合HashSet

    一: 和List接口同一级的还有Set接口,Set类型的集合,元素不能重复,存储顺序和迭代顺序没有必然联系.他的元素的唯一性是由hasCode和equals决定的. 他的子类,常用的HashSet和L ...

  8. 《You dont know JS》强制类型转换

    强制类型转换 将值从一种类型转换为另一种类型通常称为类型转换,这是显式的情况.隐式的情况被称为强制类型转换 在书中,作者还提出一种区分方式: 类型转换发生在静态类型语言的编译阶段,强制类型转换发生在动 ...

  9. 在handlebars.js {{#if}}条件下的逻辑运算符解决方案

    解决方案.这增加了比较运算符. Handlebars.registerHelper('ifCond', function (v1, operator, v2, options) { switch (o ...

  10. (未解决)记录一次登录&jmeter,留下的一地鸡毛

    一般的登录校验过程是这样的:客户端发起请求,拿到服务器给的“令牌”,再次发起请求,服务器验证“令牌”是否正确,从而返回给客户端是登录成功还是登录失败.然后我按照这个流程,用jmeter去模拟了登录过程 ...