Unity网络通信笔记

需求

首先要意识到网络通信面对的是一个怎么样的情景:

  1. 服务器会连任意个客户端;
  2. 连接是由客户端发起的,绝大多数情况下由客户端断开(除非服务器要维护),任意时刻可能有客户端连入连出,服务端需要知道客户端连出;
  3. 服务端和客户端可能任意时刻给对方发消息,所以双方都要一直准备好接收。但是两端还有别的事要做,通信不能阻塞主线程;
  4. 发的只能是字节数组,发时要把数据类序列化,接收时反序列化;
  5. 发的只能是字节数组,但是发的数据类有多种,需要一个数据类型头标记这是哪个数据类。(如果是二进制序列化用于保存文件,就可以通过文件路径知道对应的数据类,无需这个标记ID,但网络通信是一条信道传递多种数据类);
  6. 分包黏包。接收端可能接收到多条消息或不完整消息;
  7. 定义通信的需求,什么情况下需要发数据?发什么数据类?对方回复什么?相当于自定义一套协议。不过没设计好也可以先写纯收发字节数组的模块;
  8. 多线程情景下,非主线程不能调用Unity类,但需要解决这个问题;

网络通信要处理的问题

  1. 定义消息类型,通信会使用很多种消息类型,包含的字段数量、类型、名称不同,接收端要能分辨出消息类型(这里面还有两次要分辨类型的情景,一次是反序列化时根据类型提供对应的模板,一次是业务层根据类型交给相应模块处理)。分辨消息类型的方法主要是2种:消息包含的类型枚举;或用is判断。但是反序列化时消息还是字节流,只能用消息头的数字(或者说枚举)判断。业务层这两种方案都可以用。还要考虑消息类型会不会多到爆炸,哪些消息能复用消息类(比如消息类型枚举可以分得很细,但消息类根据消息需要的数据结构分,比如所有只包含一个字符串的消息共用一个消息类,但是它们的消息类型枚举不同)。
  2. 序列化方案,两端必须使用完全相同的库,即使同用json,不同的库序列化后的具体细节也可能不同。
  3. 通信系统多层之间控制耦合度。系统分为通信层(负责字节流发送接收,以及接收端的分包黏包)、协议层(字节流和消息类之间的翻译)、业务层(翻译出的消息类给不同模块使用)。在发送端顺序是业务层>协议层>通信层,接收端是通信层>协议层>业务层,前一层的输出是后一层的输入。如果用调用,前一层要引用后一层,然后服务端、客户端都要处理发送、接收,这3层就互相全都引用,紧紧耦合成一个整体。

分层

可以把通信部分分成两个模块:协议模块、通信模块。前者负责:

  1. 在数据类和字节数组之间转换;
  2. 发送时在数据类的字节数组前加上标记数据类类型的ID;
  3. 接收时根据头的ID判断数据类类型,然后反序列化成数据类;

通信模块负责:

  1. 接收字节数组,发送给另一端,和接收另一端发来的字节数组。 
  2. 维护缓存区,处理分包黏包;

因为处理分包黏包需要读取消息的期望长度,属于协议模块的工作,导致协议模块和通信模块不能完全解耦。

调试用具

首先写一个能打印字节数组的函数,用于直接查看消息内容。

void PrintBytes(byte[] bytes) {
        string byteString = string.Join(", ", bytes);
        Debug.Log(byteString);
    }

C#网络通信的类

IPAddress、IPEndPoint、Socket。Socket构造时输入地址版本(IPv4、IPv6)、协议(TCP、UDP)、IPEndPoint构造时输入IPAddress、端口号。二者通过

socket.Bind(iPEndPoint);

联系起来。为什么需要IPEndPoint、Socket两个类?

通信模块

  1. 服务端会连很多客户端,为了存连接的所有客户端可以用一个字典;
  2. 服务端要知道客户端断开了连接,可以通过判断socket.Receive(buffer)返回的数是否为0.没收到消息时这个函数会阻塞线程,不返回,若返回0则说明客户端断开了连接。需要紧接着执行socket.Shutdown()和socket.Close();

客户端: 

void HandleBuffer(int len)
        {
            bufferEnd += len;
            while (bufferEnd - bufferHead >= MyProtocol.headOffset)
            {
                int lenE = buffer[bufferHead + MyProtocol.typeOffset];
                int lenR = bufferEnd - bufferHead - MyProtocol.headOffset;
                if (lenE > lenR)//分包
                {
                    if (bufferHead != 0)//搬运到缓存区头
                    {
                        Array.Copy(buffer, bufferHead, buffer, 0, bufferEnd - bufferHead);
                        bufferEnd = bufferEnd - bufferHead;
                        bufferHead = 0;
                    }
                    break;
                }
                else if (lenE == lenR)
                {
                    object data = MyProtocol.Instance.Decode(buffer, bufferHead);
                    PrintData(data);
                    bufferHead = 0;
                    bufferEnd = 0;
                    break;
                }
                else if (lenE < lenR)//黏包
                {
                    object data = MyProtocol.Instance.Decode(buffer, bufferHead);
                    PrintData(data);
                    bufferHead+=MyProtocol.headOffset+lenE;
                }
            }
        }

序列化反序列化模块

用于网络通信的二进制序列化和一般二进制序列化多了消息头,消息头包括:

  1. 标记是哪种数据类的ID;
  2. 用于处理分包黏包而加的消息体长度;

如果暂时没有实战项目,纯为了学习,该怎么写数据类序列化反序列化的部分?

是否要写一个能序列化任意数据类的程序?更一般地说,序列化反序列化是写在各数据类里还是由一个类统一完成?

    序列化之后就是一个字节数组,已经无法知道是什么数据类了,所以加数据类ID必须在序列化的函数里。那么这个《能序列化任意数据类的程序》也就不再能序列化任意数据类了。然后意识到“能序列化任意数据类”对网络传输意义不大,任何数据类都要通过头ID才知道怎么反序列化。

    不过《能序列化任意数据类的程序》还是能解决对大型数据类一个个字段序列化太麻烦的问题。

    使用GetFields()的“万能”序列化程序还有一个问题,就是不能序列化基本数据类型,只能序列化class。

    综上,这个《能序列化任意数据类的程序》对输入的object需要先用is判断具体类型,加ID头,然后用GetFields()、循环序列化,如果要传输的数据类种类很多、数据类字段很多,才有优势。

    消息体长度需要把数据类序列化后才能知道,却要放在消息体前面。这么看序列化函数里用byte[]处理太不灵活了,不如用List<byte>。

    TCP

    分包粘包处理

    一句话概括分包粘包:接收端接收到的消息不一定是一条,可能小于1,可能是多条,可能有“小数”。

    发送端在消息头再加上消息体的字节数。接收端有一个byte[]缓存区,缓存头cacheHead、缓存尾int cacheEnd。收到新消息放入缓存区尾部,更新cacheEnd。消息解析变成一个循环,每次循环:

    1. cacheEnd-cacheHead看是否不小于消息头,小于则循环结束,继续等;
    2. 不小于则读取消息头记录的消息体长度Le,和缓存区的消息体长度Lr比较;
    3. 若Le>Lr,则消息没收完,把残留消息搬运到缓存区头,循环结束,继续等;
    4. 若Le==Lr,则刚好是一条消息,解析,cacheHead、cacheEnd设0,循环结束,继续等;
    5. 若Le<Lr,则有多条消息,解析,更新cacheHead,回到1;

    前面的消息解析完后,最后不完整的消息是搬运到缓冲区头还是原地不动?搬到缓存区头造成额外劳动。

    如果原地不动,那么

    1. 把完整消息解析后如果尾部有残留消息需要把cacheHead移到残留消息头,如果没有残留消息则cacheHead、cacheEnd都设0;
    2. 存在一种可能,收到的消息总是有残留,cacheHead、cacheEnd总是不设0,有可能溢出缓存区。所以我们发现完整消息解析完后残留消息必须搬运到缓存区头。

     

    处理分包黏包的逻辑略复杂,应该尽量封装,避免反复写。但是这个函数内部调用了解码函数,还修改了缓存区头、缓存区尾两个字段,和其他部分高度耦合。 即使解耦也不得不把解码函数通过委托,bufferHead、bufferEnd通过out传入,解码函数是一个byte[]转object的函数,委托类型还要自定义。

    void HandleBuffer(int len)
            {
                bufferEnd += len;
                while (bufferEnd - bufferHead >= MyProtocol.headOffset)
                {
                    int lenE = buffer[bufferHead + MyProtocol.typeOffset];
                    int lenR = bufferEnd - bufferHead - MyProtocol.headOffset;
                    if (lenE > lenR)//分包
                    {
                        if (bufferHead != 0)//搬运到缓存区头
                        {
                            Array.Copy(buffer, bufferHead, buffer, 0, bufferEnd - bufferHead);
                            bufferEnd = bufferEnd - bufferHead;
                            bufferHead = 0;
                        }
                        break;
                    }
                    else if (lenE == lenR)
                    {
                        object data = MyProtocol.Instance.Decode(buffer, bufferHead);
                        PrintData(data);
                        bufferHead = 0;
                        bufferEnd = 0;
                        break;
                    }
                    else if (lenE < lenR)//黏包
                    {
                        object data = MyProtocol.Instance.Decode(buffer, bufferHead);
                        PrintData(data);
                        bufferHead+=MyProtocol.headOffset+lenE;
                    }
                }
            }

    心跳消息

    心跳消息里有几种周期?首先是客户端发消息周期Tsend,然后服务端心跳超时Tout如果也用这个周期那么传输中有点延迟就超时了,所以心跳超时应该比Tsend大一点。收心跳消息直接用接收函数。还有一个检测的周期Tcheck,能不能让Tcheck=Tout?

    心跳消息和socket.Receive()判断客户端断开的矛盾

    socket.Receive()会阻塞线程,使用它返回的len判断客户端断开就不能利用接收线程接收心跳消息。要让socket.Receive()不阻塞接收线程,需要用socket.Availabe>0判断,然后接收。

    使用异步方法容易遇到的问题

    1. 客户端连接后就开始发心跳消息,但是连接异步方法可能还没执行完;
    2. 服务端心跳消息超时后关闭连接,但是会把异步接收用到的某些对象设空;
    3. socketAsyncEventArgs.BytesTransferred或EndReceive返回0时,其实是断开了连接;

    UDP通信

    有这几个特点:

    1. 无连接,就是说收到消息才知道是谁发的。根据之前如果维护一个记录客户的字典,TCP是接受连接时加入字典,UDP是收到消息,客户不在字典时加入字典,之前是客户类有一个接收缓存区,现在收到消息才确定加不加入字典,无法用客户的缓存区收消息了。 

    HTTP

    InvalidOperationException: Insecure connection not allowed

    要么把url的http改成https,有时候能成功,有时候会打印unityWebRequest.error:

    关于protobuf

    protobuf通过命令行生成cs脚本,先输入软件protoc.exe,但是要求:

    1. 不能用双引号括起来;
    2. 路径里不能有空格,否则就被认为是分隔参数的;

    要么

    1. cd跳转到protoc.exe的路径,直接输入软件名,然后发现这样也不行,还需要前面加.\
    2. 使用没有空格的路径(扯淡呢,这不可能);
    3. 把protoc.exe加入环境变量PATH(多了这一步手动操作);

    脚本,需要注意:

    1. WorkingDirectory写入protoc.exe的文件夹;
    2. exe名字前面加.\\
    3. 路径两边加\" 
    using System.Diagnostics;
    using System.IO;
    using UnityEditor;
    using UnityEngine;
    public class ProtobufAuto{
        static string PATH = $"{Application.dataPath}/protobuf";
        static string EXE_PATH = $".\\protoc.exe";
        [MenuItem("Protobuf/生成cs脚本")]
        static void GenCS()
        {
            DirectoryInfo directory = Directory.CreateDirectory(PATH);
            FileInfo[] files = directory.GetFiles();
            UnityEngine.Debug.Log(PATH);
            foreach (FileInfo file in files)
            {
                if (file.Extension == ".proto")
                {
                    Process process = new Process();
                    process.StartInfo.FileName = EXE_PATH;
                    process.StartInfo.Arguments = $"-I=\"{PATH}\" --csharp_out=\"{PATH}\" {file.Name}";
                    process.StartInfo.WorkingDirectory = $"{Application.dataPath}";
                    process.Start();
                    UnityEngine.Debug.Log(file.Name);
                }
            }
        }
    }

    找不到metadata文件

    重开项目,好了,草你妈。

    UnityWebRequest

    获取文本、二进制

    IEnumerator Load(){
        UnityWebRequest reqest=UnityWebRequest.Get("资源地址带后缀名");
        yield return request.SendWebRequest();
        if(request.result==UnityWebRequest.Result.Success){
            request.downloadHander.text;
            request.downloadHandler.data;
        }    

    获取纹理

    IEnumerator LoadTex(){
        UnityWebRequest reqest=UnityWebRequestTexture.GetTexture("资源地址带后缀名");
        yield return request.SendWebRequest();
        if(request.result==UnityWebRequest.Result.Success){
            (request.downloadHander as DownloadHandlerTexture).texture;//或者下面的方法
            DownloadHandlerTexture.GetContent(request);
        }    

    获取AB包

    IEnumerator LoadAB(){
        UnityWebRequest reqest=UnityWebRequestAssetBundle.GetAssetBundle("资源地址带后缀名");
        yield return request.SendWebRequest();
        if(request.result==UnityWebRequest.Result.Success){
            (request.downloadHander as DownloadHandlerAssetBundle).assetBundle;//或者下面的方法
            DownloadHandlerAssetBundle.GetContent(request);
        }    

    获取进度

    IEnumerator Load(){
        UnityWebRequest reqest=UnityWebRequest.Get("资源地址带后缀名");
        request.SendWebRequest();
        while(!request.isDone){
            Debug.Log(request.downloadProgress);
            Debug.Log(request.downloadBytes);
            yield return 0;
        }
        if(request.result==UnityWebRequest.Result.Success){
            request.downloadHander.text;
            request.downloadHandler.data;
        }    

    FTP和Serv U

    管理用户时出现:当前页面的脚本发生错误

    上传失败后会出现,重启Serv U可暂时解决

    上传失败:550 Permission denied.

    deepseek:

    FTP上传文件时出现"550 Permission denied"错误通常与权限配置问题相关,以下是常见原因和解决方案:

    1. 目录权限问题
    • 确保目标目录具有可写权限(chmod 755或777)3
    • 检查目录所有者是否为FTP用户(chown ftpuser)34
    1. FTP服务配置
    • 在vsftpd.conf中启用写入权限:write_enable=YES4
    • 检查匿名用户权限配置(如需匿名上传)8
    • 确认用户未被列入/etc/ftpusers黑名单4
    1. SELinux/防火墙限制
    • 临时禁用SELinux测试:setenforce 04
    • 检查防火墙是否开放FTP端口(21及被动模式端口)45
    1. 被动模式问题
    • 尝试切换主动/被动模式5
    • 确保防火墙允许被动模式端口范围通信5
    1. 其他可能原因
    • 磁盘空间不足6
    • 网络连接不稳定7
    • 路径错误(检查文件路径是否存在)2

    建议按以下步骤排查:

    1. 先检查目录权限和所有者34
    2. 验证FTP服务配置48
    3. 检查SELinux/防火墙设置45
    4. 尝试切换传输模式5
    5. 查看服务器日志获取具体错误信息
    原因:没有设置request.Credentials,输入用户名和密码!

    上传失败:Cannot send a content-body with this verb-type

    原因:没有写request.Method = WebRequestMethods.Ftp.UploadFile; !!!!

      在SocketAsyncEventArgs.Completed里执行一个回调,里面把一个面板隐藏,显示另一个面板,没有执行。在回调里面Debug.Log(字符串)可以执行,Debug.Log(面板)没有执行。以为是非主线程不能访问Unity类,但是打印Thread.CurrentThread.ManagedThreadId显示是一样的。

      解决方法:

      //记录主线程的变量
      SynchronizationContext _mainThread;
      //获得主线程
      public MyTCPManagerAsync()
      {
          _mainThread = SynchronizationContext.Current;
      }
      //把一个委托推到主线程
      void PostMainThreadAction(Action action)
          {
              _mainThread.Post(new SendOrPostCallback((o) =>
              {
                  Action e = (Action)o.GetType().GetProperty("action").GetValue(o);
                  if (e != null) e();
              }), new { action = action });
          }
      //使用
      PostMainThreadAction(onConnect);

      评论
      添加红包

      请填写红包祝福语或标题

      红包个数最小为10个

      红包金额最低5元

      当前余额3.43前往充值 >
      需支付:10.00
      成就一亿技术人!
      领取后你会自动成为博主和红包主的粉丝 规则
      hope_wisdom
      发出的红包
      实付
      使用余额支付
      点击重新获取
      扫码支付
      钱包余额 0

      抵扣说明:

      1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
      2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

      余额充值