基础控制

首先制作一个简单的角色控制器,极简版就行,这部分内容与学习内容无关

1

简单封装一下这个类就叫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}");
            }
        }
    }
}

2

在服务端这边也有日志

image-20220827124735972

TCP数据流

Socket有着自己的发送缓冲区以及数据缓冲区,如图

image-20220827211028767

此时接收缓冲区有4个字节

如果调用Receive(buffer,0,2)那么会把第一个和第二个字节加入buffer中,更底层的操作是不可控的,完全由操作系统决定

粘包

对于之前简单的例子而言,我们是一次Send对应一次Reveive,但如果发送数据过快没用及时Receive会发生什么呢?

image-20220828105744968

此时连续发送了两次数据,但没有及时接受,那么所有数据都会堆在缓冲区里等待接受,有些时候这与我们的需求是相悖的

正常来说,一次发多少数据,一次就应该接受多少数据

长度信息法

顾名思义,就是在发送的数据前,加上这个数据的字节长度

image-20220828110441315

①处的信息长度是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为例

大端模式为

image-20220828112116537

小端模式为

image-20220828112110395

其实很明显,只不过是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);
 	}
}

上述模型如图所示

image-20220905164329472

这个办法解决了一半的问题,但是,因为调用BeginSend之后需要一段时间才能调用回调函数,如果这时候我们再一次发送了数据,那么会重写readIdx和length,造成数据丢失

为了解决这个问题,可以用一个队列封装这个数据结构

image-20220905164654641

玩家连续发送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
}

image-20220908173022020

此时如果我们Resize(6),那么结果如下:

image-20220908173014344

只会是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底层

四层网络

应用层

对传输的数据进行加工,使其更符合网络传输

image-20220828192012936

传输层

对加工过的数据进行进一步处理,提供数据流传送、可靠性验证、流量控制等功能

image-20220828192315881

网络层

如同寄信,我们不能直接从A市a区送到B市b区,其顺序一般是:a→A→B→b

网络层会给我们的数据添加上一系列地址

image-20220828192306173

网络接口

物理层面,诸如光纤,光缆

常用参数

  • 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资源,真正完成关闭连接的流程。

    image-20220828194651987

    对客户端来说,它在收到服务端的FIN信号后,会进入一个称为 TIME_WAIT的状态,等待一段时间后(Windows下默认为4分钟),才会 释放socket资源,真

    正完成关闭连接的流程。TIME_WAIT状态的意义在于,如果网络状况不好,服务端迟迟没有收到客户端回应的信号,那它会重发FIN信号,客户端socket需要

    维持一段时间,以回应重发的信号,确保对方有很大概率能够收到回应信号。

    这种机制可以让服务端在关闭连接前处理尚未完成的事情,假设收到客户端FIN信号时,发送缓冲区还有尚未发送的数据,那么直接调用Close关闭连 接,缓冲

    区中的数据将被丢弃。这种关闭方式很暴力,因为对端可能 还需要这些数据。在服务端收到关闭信号后,有没有办法先把发送缓冲区中的数据发完,再关闭

    连接呢?LingerState就是为了解决这个问题而诞生的。

    socket.LingerState = new LingerOption (true, 10);
    

    第一个参数是是否启用,第二个参数是维持时间(秒)

Close的时机