需求
首先要意识到网络通信面对的是一个怎么样的情景:
- 服务器会连任意个客户端;
- 连接是由客户端发起的,绝大多数情况下由客户端断开(除非服务器要维护),任意时刻可能有客户端连入连出,服务端需要知道客户端连出;
- 服务端和客户端可能任意时刻给对方发消息,所以双方都要一直准备好接收。但是两端还有别的事要做,通信不能阻塞主线程;
- 发的只能是字节数组,发时要把数据类序列化,接收时反序列化;
- 发的只能是字节数组,但是发的数据类有多种,需要一个数据类型头标记这是哪个数据类。(如果是二进制序列化用于保存文件,就可以通过文件路径知道对应的数据类,无需这个标记ID,但网络通信是一条信道传递多种数据类);
- 分包黏包。接收端可能接收到多条消息或不完整消息;
- 定义通信的需求,什么情况下需要发数据?发什么数据类?对方回复什么?相当于自定义一套协议。不过没设计好也可以先写纯收发字节数组的模块;
- 多线程情景下,非主线程不能调用Unity类,但需要解决这个问题;
网络通信要处理的问题
- 定义消息类型,通信会使用很多种消息类型,包含的字段数量、类型、名称不同,接收端要能分辨出消息类型(这里面还有两次要分辨类型的情景,一次是反序列化时根据类型提供对应的模板,一次是业务层根据类型交给相应模块处理)。分辨消息类型的方法主要是2种:消息包含的类型枚举;或用is判断。但是反序列化时消息还是字节流,只能用消息头的数字(或者说枚举)判断。业务层这两种方案都可以用。还要考虑消息类型会不会多到爆炸,哪些消息能复用消息类(比如消息类型枚举可以分得很细,但消息类根据消息需要的数据结构分,比如所有只包含一个字符串的消息共用一个消息类,但是它们的消息类型枚举不同)。
- 序列化方案,两端必须使用完全相同的库,即使同用json,不同的库序列化后的具体细节也可能不同。
- 通信系统多层之间控制耦合度。系统分为通信层(负责字节流发送接收,以及接收端的分包黏包)、协议层(字节流和消息类之间的翻译)、业务层(翻译出的消息类给不同模块使用)。在发送端顺序是业务层>协议层>通信层,接收端是通信层>协议层>业务层,前一层的输出是后一层的输入。如果用调用,前一层要引用后一层,然后服务端、客户端都要处理发送、接收,这3层就互相全都引用,紧紧耦合成一个整体。
分层
可以把通信部分分成两个模块:协议模块、通信模块。前者负责:
- 在数据类和字节数组之间转换;
- 发送时在数据类的字节数组前加上标记数据类类型的ID;
- 接收时根据头的ID判断数据类类型,然后反序列化成数据类;
通信模块负责:
- 接收字节数组,发送给另一端,和接收另一端发来的字节数组。
- 维护缓存区,处理分包黏包;
因为处理分包黏包需要读取消息的期望长度,属于协议模块的工作,导致协议模块和通信模块不能完全解耦。
调试用具
首先写一个能打印字节数组的函数,用于直接查看消息内容。
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两个类?
通信模块
- 服务端会连很多客户端,为了存连接的所有客户端可以用一个字典;
- 服务端要知道客户端断开了连接,可以通过判断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;
}
}
}
序列化反序列化模块
用于网络通信的二进制序列化和一般二进制序列化多了消息头,消息头包括:
- 标记是哪种数据类的ID;
- 用于处理分包黏包而加的消息体长度;
如果暂时没有实战项目,纯为了学习,该怎么写数据类序列化反序列化的部分?
是否要写一个能序列化任意数据类的程序?更一般地说,序列化反序列化是写在各数据类里还是由一个类统一完成?
序列化之后就是一个字节数组,已经无法知道是什么数据类了,所以加数据类ID必须在序列化的函数里。那么这个《能序列化任意数据类的程序》也就不再能序列化任意数据类了。然后意识到“能序列化任意数据类”对网络传输意义不大,任何数据类都要通过头ID才知道怎么反序列化。
不过《能序列化任意数据类的程序》还是能解决对大型数据类一个个字段序列化太麻烦的问题。
使用GetFields()的“万能”序列化程序还有一个问题,就是不能序列化基本数据类型,只能序列化class。
综上,这个《能序列化任意数据类的程序》对输入的object需要先用is判断具体类型,加ID头,然后用GetFields()、循环序列化,如果要传输的数据类种类很多、数据类字段很多,才有优势。
消息体长度需要把数据类序列化后才能知道,却要放在消息体前面。这么看序列化函数里用byte[]处理太不灵活了,不如用List<byte>。
TCP
分包粘包处理
一句话概括分包粘包:接收端接收到的消息不一定是一条,可能小于1,可能是多条,可能有“小数”。
发送端在消息头再加上消息体的字节数。接收端有一个byte[]缓存区,缓存头cacheHead、缓存尾int cacheEnd。收到新消息放入缓存区尾部,更新cacheEnd。消息解析变成一个循环,每次循环:
- cacheEnd-cacheHead看是否不小于消息头,小于则循环结束,继续等;
- 不小于则读取消息头记录的消息体长度Le,和缓存区的消息体长度Lr比较;
- 若Le>Lr,则消息没收完,把残留消息搬运到缓存区头,循环结束,继续等;
- 若Le==Lr,则刚好是一条消息,解析,cacheHead、cacheEnd设0,循环结束,继续等;
- 若Le<Lr,则有多条消息,解析,更新cacheHead,回到1;
前面的消息解析完后,最后不完整的消息是搬运到缓冲区头还是原地不动?搬到缓存区头造成额外劳动。
如果原地不动,那么
- 把完整消息解析后如果尾部有残留消息需要把cacheHead移到残留消息头,如果没有残留消息则cacheHead、cacheEnd都设0;
- 存在一种可能,收到的消息总是有残留,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判断,然后接收。
使用异步方法容易遇到的问题
- 客户端连接后就开始发心跳消息,但是连接异步方法可能还没执行完;
- 服务端心跳消息超时后关闭连接,但是会把异步接收用到的某些对象设空;
-
socketAsyncEventArgs.BytesTransferred或EndReceive返回0时,其实是断开了连接;

UDP通信
有这几个特点:
- 无连接,就是说收到消息才知道是谁发的。根据之前如果维护一个记录客户的字典,TCP是接受连接时加入字典,UDP是收到消息,客户不在字典时加入字典,之前是客户类有一个接收缓存区,现在收到消息才确定加不加入字典,无法用客户的缓存区收消息了。
HTTP
InvalidOperationException: Insecure connection not allowed


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

关于protobuf
protobuf通过命令行生成cs脚本,先输入软件protoc.exe,但是要求:
- 不能用双引号括起来;
- 路径里不能有空格,否则就被认为是分隔参数的;

要么
- cd跳转到protoc.exe的路径,直接输入软件名,然后发现这样也不行,还需要前面加.\
; - 使用没有空格的路径(扯淡呢,这不可能);
- 把protoc.exe加入环境变量PATH(多了这一步手动操作);
脚本,需要注意:
- WorkingDirectory写入protoc.exe的文件夹;
- exe名字前面加.\\
- 路径两边加\"
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"错误通常与权限配置问题相关,以下是常见原因和解决方案:
- 目录权限问题
- 确保目标目录具有可写权限(chmod 755或777)3
- 检查目录所有者是否为FTP用户(chown ftpuser)34
- FTP服务配置
- 在vsftpd.conf中启用写入权限:
write_enable=YES4 - 检查匿名用户权限配置(如需匿名上传)8
- 确认用户未被列入/etc/ftpusers黑名单4
- SELinux/防火墙限制
- 临时禁用SELinux测试:
setenforce 04 - 检查防火墙是否开放FTP端口(21及被动模式端口)45
- 被动模式问题
- 尝试切换主动/被动模式5
- 确保防火墙允许被动模式端口范围通信5
- 其他可能原因
- 磁盘空间不足6
- 网络连接不稳定7
- 路径错误(检查文件路径是否存在)2
建议按以下步骤排查:
- 先检查目录权限和所有者34
- 验证FTP服务配置48
- 检查SELinux/防火墙设置45
- 尝试切换传输模式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);

5707

被折叠的 条评论
为什么被折叠?



