需求
首先要意识到网络通信面对的是一个怎么样的情景:
- 服务器会连任意个客户端,任意时刻可能有客户端连入连出,服务端需要知道客户端连出;
- 服务端和客户端可能任意时刻给对方发消息,所以双方都要一直准备好接收。但是两端还有别的事要做,通信不能阻塞主线程;
- 发的只能是字节数组,发时要把数据类序列化,接收时反序列化;
- 发的只能是字节数组,但是发的数据类有多种,需要一个数据类型头标记这是哪个数据类。(如果是二进制序列化用于保存文件,就可以通过文件路径知道对应的数据类,无需这个标记ID,但网络通信是一条信道传递多种数据类);
- 分包黏包。接收端可能接收到多条消息或不完整消息;
- 定义通信的需求,什么情况下需要发数据?发什么数据类?对方回复什么?相当于自定义一套协议。不过没设计好也可以先写纯收发字节数组的模块;
可以把通信部分分成两个模块:序列化反序列化模块、通信模块。前者负责:
- 在数据类和字节数组之间转换;
- 发送时在数据类的字节数组前加上标记数据类类型的ID;
- 接收时根据头的ID判断数据类类型,然后反序列化成数据类;
通信模块只管接收字节数组,发送给另一端,和接收另一端发来的字节数组。
调试用具
首先写一个能打印字节数组的函数,用于直接查看消息内容。
cs
void PrintBytes(byte[] bytes) {
string byteString = string.Join(", ", bytes);
Debug.Log(byteString);
}
通信模块
- 服务端会连很多客户端,为了存连接的所有客户端可以用一个字典;
- 服务端要知道客户端断开了连接,可以通过判断socket.Receive(buffer)返回的数是否为0.没收到消息时这个函数会阻塞线程,不返回,若返回0则说明客户端断开了连接。需要紧接着执行socket.Shutdown()和socket.Close();
cs
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Mercenaria;
using UnityEngine;
public class MyNetManager : MonoSingletonDontDestroy<MyNetManager>
{
Socket socket;
Queue<byte[]> queueSend = new Queue<byte[]>();
Queue<byte[]> queueReceive = new Queue<byte[]>();
Thread threadSend, threadReceive;
byte[] buffer = new byte[1024 * 20];
int lenReveive;
bool on;
void Start()
{
Connect("127.0.0.1", 8080);
}
void Update()
{
if (queueReceive.Count > 0)
{
Debug.Log(queueReceive.Dequeue());
}
}
public void Connect(string ip, int port)
{
if (on)
{
return;
}
if (socket == null)
{
socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
}
IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse(ip), port);
try
{
socket.Connect(iPEndPoint);
on = true;
ThreadPool.QueueUserWorkItem(SendMessage);
ThreadPool.QueueUserWorkItem(ReceiveMessage);
}
catch (SocketException ex)
{
if (ex.ErrorCode == 10061)
{
#if UNITY_EDITOR
Debug.Log("服务器拒绝连接");
#endif
}
else
{
#if UNITY_EDITOR
Debug.Log("连接失败" + ex.Message);
#endif
}
}
}
public void Send(byte[] message)
{
byte[] bufferSend = new byte[message.Length];
Array.Copy(message,bufferSend,message.Length);//深拷贝
queueSend.Enqueue(bufferSend);
}
void SendMessage(object obj)
{
while (on)
{
if (queueSend.Count > 0)
{
socket.Send(queueSend.Dequeue());
}
}
}
void ReceiveMessage(object obj)
{
while (on)
{
if (socket.Available > 0)
{
lenReveive = socket.Receive(buffer);
byte[] data=new byte[buffer.Length];
Array.Copy(buffer,data,buffer.Length);
queueReceive.Enqueue(data);
}
}
}
public void Close()
{
if (socket != null)
{
socket.Shutdown(SocketShutdown.Both);
socket.Close();
on = false;
threadSend = null;
threadReceive = null;
}
}
void OnDestroy()
{
Close();
}
}
序列化反序列化模块
用于网络通信的二进制序列化和一般二进制序列化多了消息头,消息头包括:
- 标记是哪种数据类的ID;
- 用于处理分包黏包而加的消息体长度;
如果暂时没有实战项目,纯为了学习,该怎么写数据类序列化反序列化的部分?
是否要写一个能序列化任意数据类的程序?
序列化之后就是一个字节数组,已经无法知道是什么数据类了,所以加数据类ID必须在序列化的函数里。那么这个《能序列化任意数据类的程序》也就不再能序列化任意数据类了。然后意识到"能序列化任意数据类"对网络传输意义不大,任何数据类都要通过头ID才知道怎么反序列化。
不过《能序列化任意数据类的程序》还是能解决对大型数据类一个个字段序列化太麻烦的问题。
使用GetFields()的"万能"序列化程序还有一个问题,就是不能序列化基本数据类型,只能序列化class。
综上,这个《能序列化任意数据类的程序》对输入的object需要先用is判断具体类型,加ID头,然后用GetFields()、循环序列化,如果要传输的数据类种类很多、数据类字段很多,才有优势。
消息体长度需要把数据类序列化后才能知道,却要放在消息体前面。这么看序列化函数里用byte[]处理太不灵活了,不如用List<byte>。
分包粘包处理
一句话概括分包粘包:接收端接收到的消息不一定是一条,可能小于1,可能是多条,可能有"小数"。
发送端在消息头再加上消息体的字节数。接收端有一个byte[]缓存区有一个缓存标志位int cacheMark。**前面的消息解析完后,最后不完整的消息是搬运到缓冲区头还是原地不动?**搬到缓存区头造成额外劳动。
如果原地不动,那么
- 把完整消息解析后如果尾部有残留消息需要把cacheMark移到残留消息头,但是如果没有残留消息则不移动(感觉这样很别扭);
- 把残留消息拼好解析后把cacheMark设0。