Unity3D网络游戏实战|03 TCP
基础控制
首先制作一个简单的角色控制器,极简版就行,这部分内容与学习内容无关
简单封装一下这个类就叫Player
Player是由当前玩家主动控制的角色
public class Player : MonoBehaviour
{
public NavMeshAgent agent;
void Update()
{
if (Input.GetMouseButtonDown(0))
{
var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
Debug.DrawRay(ray.origin, ray.direction, Color.red);
if (Physics.Raycast(ray, out var hit, int.MaxValue, 1 << LayerMask.NameToLayer("ground")))
{
agent.SetDestination(hit.point);
}
}
}
}
使用网络模块
通讯协议
本质上,通讯协议就是约定好的一种数据类型,比如约定1代表开始游戏,那么每当客户端发送1时,服务器变转发开始游戏的消息,任何协议本质上都是约定好的一种字符形式
对于目前的游戏而言,我们约定三种字符协议如下:
进入游戏:Enter;玩家身份
移动:Move;玩家身份;移动位置
离开游戏:Leave;玩家身份
举例:Move;127.0.0.1:8989;0,1,0。代表玩家127.0.0.1:8989移动到0,1,0的位置。服务端根据约定好的协议做字符串解析就好了。
NetManager
一个最简单的NetManager模块
用一个字典注册监听,把接受到的消息放到List里,在主循环中派发
public class NetManager
{
public Socket socket;
private byte[] buffer = new byte[1024];
private Dictionary<string, Action<string>> msgMap = new Dictionary<string, Action<string>>();
private List<string> msgList = new List<string>();
public void Connect()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect("127.0.0.1", 1234);
socket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, socket);
}
private void ReceiveCallBack(IAsyncResult obj)
{
var socket = obj as Socket;
if (socket == null)
return;
var count = socket.EndReceive(obj);
var msg = Encoding.UTF8.GetString(buffer, 0, count);
msgList.Add(msg);
socket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, socket);
}
public void Send(string sendStr)
{
if (socket == null) return;
if (!socket.Connected) return;
byte[] sendBytes =
System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
}
public void Update()
{
if (msgList.Count <= 0)
return;
String msgStr = msgList[0];
msgList.RemoveAt(0);
string[] split = msgStr.Split(';');
string msgName = split[0];
string msgArgs = split[1];
if (msgMap.ContainsKey(msgName))
msgMap[msgName].Invoke(msgArgs);
}
public void AddListener(string type,Action<string> callback)
{
if (msgMap.ContainsKey(type))
{
msgMap[type] += callback;
}
else
{
msgMap.Add(type, callback);
}
}
}
客户端这边就很简单了,在运行时候连接服务器,然后监听一下数据就好了
移动时候发送数据
public class Player : MonoBehaviour
{
public NavMeshAgent agent;
private NetManager netManager = new NetManager();
private void Start()
{
netManager.Connect();
netManager.AddListener("Move", OnMove);
}
private void OnMove(string arg)
{
Debug.Log(arg);
}
void Update()
{
netManager.Update();
if (Input.GetMouseButtonDown(0))
{
var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
Debug.DrawRay(ray.origin, ray.direction, Color.red);
if (Physics.Raycast(ray, out var hit, int.MaxValue, 1 << LayerMask.NameToLayer("ground")))
{
agent.SetDestination(hit.point);
netManager.Send($"Move;{netManager.socket.LocalEndPoint};{hit.point.x};{hit.point.y};{hit.point.z}");
}
}
}
}
在服务端这边也有日志
TCP数据流
Socket有着自己的发送缓冲区以及数据缓冲区,如图
此时接收缓冲区有4个字节
如果调用Receive(buffer,0,2)那么会把第一个和第二个字节加入buffer中,更底层的操作是不可控的,完全由操作系统决定
粘包
对于之前简单的例子而言,我们是一次Send对应一次Reveive,但如果发送数据过快没用及时Receive会发生什么呢?
此时连续发送了两次数据,但没有及时接受,那么所有数据都会堆在缓冲区里等待接受,有些时候这与我们的需求是相悖的
正常来说,一次发多少数据,一次就应该接受多少数据
长度信息法
顾名思义,就是在发送的数据前,加上这个数据的字节长度
①处的信息长度是10,但缓冲区只有3个数据,此时不做Receive处理,等待继续接受数据
②处的缓冲区长度已经超过10了,但我们也只会接受前10个数据。之后的数据也同上处理。
固定长度法
每次都发送固定长度的数据,太傻比了,不做解释
结束符号法
每个消息结尾都以一个固定的字符作为分隔符,也挺蠢的
大小端问题
用长度信息法时,需要在消息头加一个消息的长度,一般是16位(2个字节)
我们会调用BitConverter.ToInt16(buffer,offset)方法来计算消息头的长度
简化代码如下
public static short ToInt16(byte[] value, int startIndex)
{
if( startIndex % 2 == 0)
{
return *((short *) pbyte);
}
else
{
if( IsLittleEndian)
{
return (short)((*pbyte) | (*(pbyte + 1) << 8)) ;
}
else
{
return (short)((*pbyte << 8) | (*(pbyte + 1)));
}
}
}
其中IsLittleEndian判断的是当前计算是大端编码还是小端编码,那么很明显了,在不同编码的计算机上,我们得到的消息长度肯定是不一样的,这需要我们自己处理
以258为例
大端模式为
小端模式为
其实很明显,只不过是2个字节调换一下顺序而已
只有为什么有大小端之分,具体百度,历史遗留问题
兼容大小端编码
处理方法也很简单,我们规定只能以某一种编码发送和接受数据,以小端为例,我们要求发送和接受数据都以小端为准,如果当前计算机是大端编码,那么先转换成小端
以发送为例
public void Send()
{
string sendStr = InputFeld.text;
//组装协议
byte[] bodyBytes =System.Text.Encoding.Default.GetBytes(sendStr);
Int16 len = (Int16)bodyBytes.Length;
byte[] lenBytes = BitConverter.GetBytes(len);
//大小端编码
if(!BitConverter.IsLittleEndian)
{
Debug.Log("[Send] Reverse lenBytes");
lenBytes.Reverse(); // 转为小端,其实只是把字节数组倒序,因为大小端的区别就只是顺序颠倒而已
}
//拼接字节
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
socket.Send(sendBytes);
}
手动还原长度
public void OnReceiveData()
{
//消息长度
if(buffCount <= 2)
return;
//消息长度
Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
Debug.Log("[Recv] bodyLength=" + bodyLength);
//消息体、更新缓冲区
//消息处理、继续读取消息
……
}
其中**(readBuff[1] « 8) | readBuff[0]**代表将第二个字节x256再加上第一个字节,这是计算小端代码的方法 |
数据完整性
考虑一种情况,如果你要放松1024字节的数据,但socket的发送缓冲区只剩下200字节了,那剩下的800多字节怎么办呢?
很明显,我们需要存下来,在恰当的时候再发送过去
//定义发送缓冲区
byte[] sendBytes = new byte[1024];
//缓冲区偏移值
int readIdx = 0;
//缓冲区剩余长度
int length = 0;
//点击发送按钮
public void Send()
{
sendBytes = 要发送的数据;
length = sendBytes.Length; //数据长度
readIdx = 0;
socket.BeginSend(sendBytes, 0, length, 0, SendCallback,socket);
}
//Send回调
public void SendCallback(IAsyncResult ar)
{
//获取state
Socket socket = (Socket) ar.AsyncState;
//EndSend的处理
int count = socket.EndSend(ar);
readIdx + =count;
length -= count;
//继续发送
if(length > 0)
{
socket.BeginSend(sendBytes, readIdx, length, 0, SendCallback, socket);
}
}
上述模型如图所示
这个办法解决了一半的问题,但是,因为调用BeginSend之后需要一段时间才能调用回调函数,如果这时候我们再一次发送了数据,那么会重写readIdx和length,造成数据丢失
为了解决这个问题,可以用一个队列封装这个数据结构
玩家连续发送3次消息,则会想队列里添加3次数据
而回调函数只会从队列头取消息进行发送
ByteArray
将上述数据结构进行一定的封装
public class ByteArray
{
// 缓冲区
private byte[] bytes;
// 读写位置
public int ReadIndex { get; private set; }
public int WriteIndex { get; private set; }
//数据长度
public int Length => WriteIndex - ReadIndex;
//构造函数
public ByteArray(byte[] defaultBytes)
{
bytes = defaultBytes;
ReadIndex = 0;
WriteIndex = defaultBytes.Length;
}
public override string ToString()
{
return BitConverter.ToString(bytes, ReadIndex, WriteIndex);
}
}
实际使用如下
//定义
Queue<ByteArray> writeQueue = new Queue<ByteArray>();
//点击发送按钮
public void Send()
{
//拼接字节,省略组装sendBytes的代码
byte[] sendBytes = 要发送的数据;
ByteArray ba = new ByteArray(sendBytes);
writeQueue.Enqueue(ba);
//send
if (writeQueue.Count == 1)
{
socket.BeginSend(ba.bytes, ba.readIdx, ba.length,0, SendCallback, socket);
}
}
//Send回调
public void SendCallback(IAsyncResult ar)
{
//获取state、EndSend的处理
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndSend(ar);
//判断是否发送完整
ByteArray ba = writeQueue.First();
ba.readIdx += count;
if (ba.length == 0)
{
//发送完整
writeQueue.Dequeue();
ba = writeQueue.First();
}
if (ba != null)
{
//发送不完整,或发送完整且存在第二条数据
socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
0, SendCallback, socket);
}
}
高效的数据接收
为了避免接收数据时的反复Copy,对ByteArray进行一定的修改
构造部分和上述基本相同,加入一个新的构造函数,默认生命一个1024字节的数组
public class ByteArray
{
#region 字段
//默认大小
private const int DEFAULT_SIZE = 1024;
// 初始大小
private int initSize = 0;
#endregion
#region 属性
// 缓冲区
public byte[] Bytes { get; private set; }
// 读写位置
public int ReadIndex { get; private set; }
public int WriteIndex { get; private set; }
// 容量
public int Capacity { get; private set; }
// 剩余空间
public int Remain => Capacity - WriteIndex;
// 数据长度
public int Length => WriteIndex - ReadIndex;
#endregion
#region 构造
// 构造函数1
public ByteArray(int size = DEFAULT_SIZE)
{
Bytes = new byte[size];
Capacity = size;
initSize = size;
ReadIndex = 0;
WriteIndex = 0;
}
// 构造函数2
public ByteArray(byte[] defaultBytes)
{
Bytes = defaultBytes;
Capacity = defaultBytes.Length;
initSize = defaultBytes.Length;
ReadIndex = 0;
WriteIndex = defaultBytes.Length;
}
#endregion
}
当长度不够时,我们需要进行Reszie,机制和List等类似
public class ByteArray
{
#region 构造
// 重设尺寸
private void ReSize(int size)
{
if (size < Length)
return;
if (size < initSize)
return;
int n = 1;
while (n < size) n *= 2;
Capacity = n;
byte[] newBytes = new byte[Capacity];
Array.Copy(Bytes, ReadIndex, newBytes, 0, WriteIndex - ReadIndex);
Bytes = newBytes;
WriteIndex = Length;
ReadIndex = 0;
}
// 检查并移动数据
private void CheckAndMoveBytes()
{
if (Length < 8)
{
MoveBytes();
}
}
// 移动数据
private void MoveBytes()
{
Array.Copy(Bytes, ReadIndex, Bytes, 0, Length);
WriteIndex = Length;
ReadIndex = 0;
}
#endregion
}
此时如果我们Resize(6),那么结果如下:
只会是2^n大小
数据移动的规则时,如果当前的数据量很小(甚至Read=Write),那么即使拷贝移动也不会造成很大问题,反而可能可以避免一次数组重生申明,那就移动一下
最后封装一下读取相关的模块
public class ByteArray
{
#region 读写
// 写入数据
public int Write(byte[] bs, int offset, int count)
{
if (Remain < count)
{
ReSize(Length + count);
}
Array.Copy(bs, offset, Bytes, WriteIndex, count);
WriteIndex += count;
return count;
}
// 读取数据
public int Read(byte[] bs, int offset, int count)
{
count = Math.Min(count, Length);
Array.Copy(Bytes, 0, bs, offset, count);
ReadIndex += count;
CheckAndMoveBytes();
return count;
}
// 读取Int16,16位=2字节
public Int16 ReadInt16()
{
if (Length < 2)
return 0;
Int16 ret = (Int16) ((Bytes[1] << 8) | Bytes[0]);
ReadIndex += 2;
CheckAndMoveBytes();
return ret;
}
// 读取Int32
public Int32 ReadInt32()
{
if (Length < 4) return 0;
Int32 ret = (Int32) ((Bytes[3] << 24) |(Bytes[2] << 16) |(Bytes[1] << 8) |Bytes[0]);
ReadIndex += 4;
CheckAndMoveBytes();
return ret;
}
#endregion
}
TCP底层
四层网络
应用层
对传输的数据进行加工,使其更符合网络传输
传输层
对加工过的数据进行进一步处理,提供数据流传送、可靠性验证、流量控制等功能
网络层
如同寄信,我们不能直接从A市a区送到B市b区,其顺序一般是:a→A→B→b
网络层会给我们的数据添加上一系列地址
网络接口
物理层面,诸如光纤,光缆
常用参数
-
ReceiveBufferSize
读取缓冲区的大小,默认是8192字节
-
SendBufferSize
默认写缓冲区的大小,默认是8192字节
-
NoDelay
是否使用Nagle算法发送数据,这是一种节约网络流量的机制,如果实时性要求很高,建议设置为True,默认为True
原理也很简单,对于每一条消息,TCP都需要进行一定的包装(加入数据信息,IP信息等),但是如果你每次都发送很小的数据,但频率很高,那么TCP包装
的信息都比你发送的信息大了,此时是很浪费的,Nagle算法会积累到一定数据时再发送
-
TTL
是IP头部数据中的一个值,表示该数据能够进行多少次路由转发,网络中传输的数据都是有路由器进行转发,最终到达指定目标的
默认值与操作系统有关,XP默认是128,Win7默认是64,Win10默认是65,Linux默认255
-
ReuseAddress
端口复用,让一个端口可以被多个Socket使用,没有特殊需求一般不使用
-
LingerState
设置Socket保持连接的时间
在我们关闭TCP连接时,客户端调用Close关闭Socket连接,这时,客户端会给服务端发送FIN信 号,然后进入等待。当服务端收到FIN信号时,会返回一个长
度为0的数据,然后向客户端回应信息。这也是为什么关闭连接时,对端Receive会收到0个数据。如果服务端不做处理,客户端将会持续等待。
当服务端收到一个长度为0的数据时,会有如下代码
public static void ReceiveCallback(IAsyncResult ar) { …… int count = clientfd.EndReceive(ar); //客户端关闭 if(count == 0) { clientfd.Close(); …… return; } }
服务端会再次主动调用客户端的Close方法,作为回应。服务端在调用Close后,向客户端发送FIN信号,然后等待客户端回应。当服务端收到客户端的回应消
息时,它会释放socket资源,真正完成关闭连接的流程。
对客户端来说,它在收到服务端的FIN信号后,会进入一个称为 TIME_WAIT的状态,等待一段时间后(Windows下默认为4分钟),才会 释放socket资源,真
正完成关闭连接的流程。TIME_WAIT状态的意义在于,如果网络状况不好,服务端迟迟没有收到客户端回应的信号,那它会重发FIN信号,客户端socket需要
维持一段时间,以回应重发的信号,确保对方有很大概率能够收到回应信号。
这种机制可以让服务端在关闭连接前处理尚未完成的事情,假设收到客户端FIN信号时,发送缓冲区还有尚未发送的数据,那么直接调用Close关闭连 接,缓冲
区中的数据将被丢弃。这种关闭方式很暴力,因为对端可能 还需要这些数据。在服务端收到关闭信号后,有没有办法先把发送缓冲区中的数据发完,再关闭
连接呢?LingerState就是为了解决这个问题而诞生的。
socket.LingerState = new LingerOption (true, 10);
第一个参数是是否启用,第二个参数是维持时间(秒)