前言:
此项目基于unity,使用c#语言构建一个通用的简单的客户端框架,比较简陋但是易懂。
在第一版的基础上,我更详细的描述了模块的编写过程逻辑。
学习目标:学习构建一个游戏客户端基本的网络模块
网络模块的设计实现:
1.网络管理者NetManager类
该类将有关网络通讯部分的功能封装,实现网络模块的主要功能。
主要利用观察者模式,让框架拥有松耦合,灵活性,可拓展性的特性。
整合了客户端连接服务器Connect,关闭与服务器的连接Close,想服务器发送消息Send等基本网络通讯功能。
网络通讯可以抽象为如下阶段
连接Connect -> 发送Send -> 接收Receive -> 客户端处理消息 -> 发送/接收/处理循环->关闭Close
下面是具体实现
关键属性
Socket:连接套接字
readBuff:接受消息的缓冲区
writeQueue:发送消息的写入队列
isClosing :记录是否连接
private static Socket socket;
//接收缓存区
private static ByteArray readBuff;
//写入队列
private static Queue<ByteArray> writeQueue;
private static bool isClosing = false;
这些属性是NetManager的基本字段,十分重要。是实现网络通讯的基础。
网络事件处理
为了更好的可扩展性,
我们使用观察者模式,也就是采用事件驱动模式。
在这种模式中,存在一个发布者(Subject)和多个订阅者(Observers),订阅者会注册(监听)某些事件,当事件发生时,所有订阅该事件的订阅者都会被通知并触发相应的处理逻辑。
这样可以在实现具体方法的时候,方便的触发各种方法。
下面是实现观察者模式的逻辑
网络事件枚举类型
首先利用一个枚举类型NetEvent,存储常用的网络事件名称,该类型作为监听器的key值,用来触发对应事件,方便后续调用。
(其实当然也可以用其他类型,来定义监听器的key值,比如直接用string注册,但是不方便调用,可能会出错)
public enum NetEvent
{
ConnectSucc = 1,
ConnectFail = 2,
Close = 3,
}
实现观察者模式/事件驱动核心部分
1.定义委托
类型名EventListener(参数为string)。
作为监听者的模板,在事件触发的时候,监听该事件的监听者被会触发。
2.定义一个事件列表,
为字典类型<NetEvent,EventListener>
用于记录每种网络事件对应的回调方法。存储了不同网络事件及其对应事件的监听者。
3.实现 添加事件监听者 与 删除事件监听者 的方法。
其中的if/else逻辑是:
如果这个事件不存在就创建一个事件,并为其添加监听者。如果已经存在,就在原事件上再添加监听者。
4.实现 事件触发方法。
方法需要一个网络事件类型,和一个字符串(用于作为被触发的监听器的参数)
具体源代码:
#region 事件处理
//事件委托类型
public delegate void EventListener(string err);
//事件监听列表
private static Dictionary<NetEvent, EventListener>
eventListeners = new Dictionary<NetEvent,EventListener>();
//添加事件监听
public static void AddEventListener(NetEvent netEvent, EventListener listener)
{
if (eventListeners.ContainsKey(netEvent))
{
eventListeners[netEvent] += listener;
}
else
{
eventListeners[netEvent] = listener;
}
}
//删除事件监听
public static void RemoveEventListenner(NetEvent netEvent, EventListener listener)
{
if (eventListeners.ContainsKey(netEvent))
{
eventListeners[netEvent] -= listener;
}
else
{
eventListeners.Remove(netEvent);
}
}
#endregion
//分发(触发)事件
public static void FireEvent(NetEvent netEvent, string err)
{
if (eventListeners.ContainsKey(netEvent))
{
eventListeners[netEvent](err);
}
else
{
throw new Exception("不存在此事件");
}
}
具体基本网络通讯功能的实现(向服务器发送请求)
使用套接字进行端对端连接
其中观察者在其中用于触发连接成功与失败的提示
1.Connet
功能要求:根据ip和端口,向服务器发起连接。
实现逻辑,初始化状态->发起连接->连接回调
具体源代码:
#region Connect
private static bool isConnecting = false;
public static void Connect(string ip, int port)
{
//状态判断
if (socket != null && socket.Connected)
{
Debug.Log("Connect fail,is already connected");
}
if (isConnecting)
{
Debug.Log("Connect fail is connecting");
}
InitState();
socket.NoDelay = true;
socket.BeginConnect(ip, port,ConnectCallback, socket);
}
private static void ConnectCallback(IAsyncResult ar)
{
try
{
Socket socket = ar.AsyncState as Socket;
socket.EndConnect(ar);
Debug.Log("Socket Connect Succ ");
FireEvent(NetEvent.ConnectSucc,"");
isConnecting = false;
}
catch (SocketException ex)
{
Debug.Log("Socket connect fail" + ex.ToString());
FireEvent(NetEvent.ConnectFail,ex.ToString());
isConnecting = false;
}
}
//初始化状态
private static void InitState()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
readBuff = new ByteArray();
writeQueue = new Queue<ByteArray>();
isConnecting = false;
isClosing = false;
}
#endregion
2.Close
功能要求:关闭客户端与服务器的连接
#region Close
public static void Close()
{
//状态判断
if (socket == null || !socket.Connected)
{
return;
}
if (isConnecting)
{
return;
}
//还有数据在发送
if (writeQueue.Count > 0)
{
isClosing = true;
}
else
{
socket.Close();
FireEvent(NetEvent.Close,"");
}
}
#endregion
3.Send(该事件需要先实现协议类)
功能实现:将消息从客户端发送给服务器
逻辑:先进行条件检测有没有连接上服务器,
再对发送的数据编码,组装长条,组装名字,组装消息体(其中编码部分由消息类自己的方法实现)组织成功后,将消息写入发送队列。
然后,再调用发送消息指令,并且在发送回调当中。
其中在发送回调中,为实现数据的完整发送,还有更复杂的发送逻辑
#region Send
public static void Send(MsgBase msg)
{
if (socket == null || !socket.Connected)
{
return;
}
if (isConnecting)
{
return;
}
if (isClosing)
{
return;
}
//数据编码
byte[] nameBytes = MsgBase.EncodeName(msg);
byte[] bodyBytes = MsgBase.Encode(msg);
int len = nameBytes.Length + bodyBytes.Length;
byte[] sendBytes = new byte[2 + len];
//组装长度
sendBytes[0] = (byte)(len % 256);
sendBytes[1] = (byte)(len / 256);
//组装名字
Array.Copy(nameBytes,0,sendBytes,2,nameBytes.Length);
//组装消息体
Array.Copy(bodyBytes,0,sendBytes,2+nameBytes.Length,bodyBytes.Length);
//写入队列
ByteArray ba = new ByteArray(sendBytes);
int count = 0;
lock (writeQueue)
{
writeQueue.Enqueue(ba);
count = writeQueue.Count;
}
//send
if (count == 1)
{
socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);
}
}
public static void SendCallback(IAsyncResult ar)
{
//获取state EndSend处理
Socket socket = ar.AsyncState as Socket;
//状态判断
if (socket == null || !socket.Connected)
{
return;
}
//EndSend
int count = socket.EndSend(ar);
//获得写入队列第一条数据
ByteArray ba;
lock (writeQueue)
{
ba = writeQueue.First();
}
//完整发送
ba.readIdx += count;
if (ba.length == 0)
{
lock (writeQueue)
{
writeQueue.Dequeue();
ba = writeQueue.First();
}
}
//继续发送
if (ba != null)
{
socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
}
//正在关闭
else if(isClosing)
{
socket.Close(9);
}
}
#endregion
接收来自服务器的数据 Receive
情景:因为网络的不稳定因素,对于来着服务器消息的接收,需要更复杂的实现逻辑,来应对各种多样的情况。
所以,为了让客户端可以处理更复杂的消息接受情况,使用一个列表,然后不断从上往下更新处理列表中的数据。
特点如下:
1.每次update处理多条数据、
2.处理粘包半包、大小端判断、
3.使用json协议
Receive部分
#region 接收数据
//消息列表
private static List<MsgBase> msgList = new List<MsgBase>();
//消息列表长度
private static int msgCount = 0;
//每一次update处理的消息量
private readonly static int MAX_MESSAGE_FIRE = 10;
//初始化状态
private static void InitMsgListState()
{
msgList = new List<MsgBase>();
msgCount = 0;
}
//Receive回调 在Connet中调用
public static void ReceiveCallBack(IAsyncResult ar)
{
try
{
Socket socket = ar.AsyncState as Socket;
//获取接收数据长度
int count = socket.EndReceive(ar);
if (count == 0)
{
Close();
return;
}
readBuff.writeIdx += count;
//处理二进制消息
OnReceiveData();
//继续接收数据
if (readBuff.remain < 8)
{
readBuff.MoveBytes();
readBuff.ReSize(readBuff.length*2);
}
socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallBack, socket);
}
catch (SocketException ex)
{
Debug.Log(ex.ToString());
}
}
//数据处理
public static void OnReceiveData()
{
//消息长度
if (readBuff.length <= 2)
{
return;
}
//获取消息体长度
int readIdx = readBuff.readIdx;
byte[] bytes = readBuff.bytes;
Int16 bodyLength = (Int16)((bytes[readIdx + 1] << 8) | bytes[readIdx]);
if (readBuff.length < bodyLength + 2)
{
return;
}
readBuff.readIdx += 2;
//解析协议名
int nameCount = 0;
string protoName = MsgBase.DecodeName(readBuff.bytes, readBuff.readIdx, out nameCount);
if (protoName == "")
{
Debug.Log("Fail Decode protoName");
return;
}
readBuff.readIdx += nameCount;
//解析协议体
int bodyCount = bodyLength - nameCount;
MsgBase msgBase = MsgBase.Decode(protoName, readBuff.bytes, readBuff.readIdx, bodyCount);
readBuff.readIdx += bodyCount;
readBuff.CheckAndMoveBytes();
//添加到消息队列
lock (msgList)
{
msgList.Add(msgBase);
}
msgCount++;
//继续读取消息
if (readBuff.length > 2)
{
OnReceiveData();
}
}
#endregion
更新处理消息列表
得到的消息存储在msgList后,需要不断更新判断,是否需要处理并处理消息。
需要在另一个带有monobehavior的类中,使用Update去调用NetManager.Update启动消息处理。
1.每次update处理多条数据、
#region Update
public static void Update()
{
MsgUpdate();
}
public static void MsgUpdate()
{
if (msgCount==0)
{
return;
}
//重复处理消息
for (int i = 0; i < MAX_MESSAGE_FIRE; i++)
{
MsgBase msgBase = null;
lock (msgBase)
{
if (msgList.Count>0)
{
msgBase = msgList[0];
msgList.RemoveAt(0);
msgCount--;
}
}
//分发消息
if (msgBase != null)
{
FireMsg(msgBase.protoName,msgBase);
}
//没有消息了
else
{
break;
}
}
}
#endregion
处理接收到的来自服务器的消息
首先,同样利用观察者模式,对发来的不同类型的消息,触发不同事件,从而调用不同的事件监听者,对消息进行不同逻辑的处理。
简单来说,就是根据消息名,来触发不同事件。
消息事件处理 (观察者模式)
与上文的网络事件类似,此观察者用于处理接收到的消息。
1.定义委托类型
2.定义一个事件列表,为字典类型,用于记录每种网络事件,与其对应的监听者。
3.实现添加事件与删除事件还有触发
#region 消息事件
//消息委托类型
public delegate void MsgListener(MsgBase msgBase);
//消息监听列表
public static Dictionary<string, MsgListener> MsgListeners = new Dictionary<string, MsgListener>();
public static void AddMsgListener(string msgName, MsgListener msgListener)
{
//添加
if (MsgListeners.ContainsKey(msgName))
{
MsgListeners[msgName] += msgListener;
}
//新增
else
{
MsgListeners[msgName] = msgListener;
}
}
public static void RemoveMsgListener(string msgName, MsgListener msgListener)
{
if (MsgListeners.ContainsKey(msgName))
{
MsgListeners[msgName] -= msgListener;
//删除
if (MsgListeners[msgName] == null)
{
MsgListeners.Remove(msgName);
}
}
}
public static void FireMsg(string msgName, MsgBase msgBase)
{
if (MsgListeners.ContainsKey(msgName))
{
MsgListeners[msgName](msgBase);
}
}
#endregion
小结语
完成上面这些部分,网络管理者类就基本完成了,主要由网络事件处理、消息事件处理、接收消息更新队列为基础,来实现客户端与服务器的连接,发送消息,接收消息,处理消息,关闭连接的功能。
2.协议类protocol
协议类是并不是一个类,而是多种类的统称,具体的可以有比如说json协议类,protobuf协议类。
继承关系:具体消息类 继承 某一协议基类。
功能:
协议基类封装了具体协议编码与解码的功能,
而继承的具体消息子类,以对象的方式,存储了具体消息的数据。
消息的发送处理流程
消息的发送需要经历以下步骤
对象 <--协议解析--> 字符串 <--编码/解码--> 比特数组
Json协议为例
利用unity提供的JsonUtility来实现json协议的编码解码。并以此构建json协议的消息基类。
协议格式
使用长度信息法,在消息前面加上2字节的长度,并接上协议名与协议体。防止粘包与半包
具体消息
以MsgBase为基类,派生出各种不同消息类。
public class MsgMove : MsgBase
{
public MsgMove()
{
protoName = "MsgMove";
}
public int x = 0;
public int y = 0;
public int z = 0;
}
协议基类(以json协议为例)
处理数据的编码与解码
下述是完整的消息基类的代码实现
public class MsgBase
{
//协议名
public string protoName = " ";
//编码 对象->字符->bytes
public static byte[] Encode(MsgBase msgBase)
{
string s = JsonUtility.ToJson(msgBase);
return Encoding.UTF8.GetBytes(s); //以UTF8的格式(transform to 8 bit)转化为比特格式
}
//解码
public static MsgBase Decode(string protoName, byte[] bytes, int offset, int count)
{
string s = Encoding.UTF8.GetString(bytes,offset,count);
MsgBase msgBase = (MsgBase)JsonUtility.FromJson(s, Type.GetType(protoName));
return msgBase;
}
//bytes->字符->对象
public static byte[] EncodeName(MsgBase msgBase)
{
//名字与bytes长度
byte[] nameBytes = Encoding.UTF8.GetBytes(msgBase.protoName);
Int16 len = (Int16)nameBytes.Length;
//申请bytes数值
byte[] bytes = new byte[2 + len];
//组装2字节长度信息
bytes[0] = (byte)(len % 256);
bytes[1] = (byte)(len / 256);
//组装名字bytes
Array.Copy(nameBytes,0,bytes,2,len);
return bytes;
}
public static string DecodeName(byte[] bytes, int offset, out int count)
{
count = 0;
//必须大于2字节
if (offset + 2 > bytes.Length)
{
return "";
}
//读取长度
Int16 len = (Int16)((bytes[offset + 1] << 8) | bytes[offset]);
if (len <= 0) return "";
//长度必须足够
if (offset + 2 + len > bytes.Length) return " ";
//解析
count = 2 + len;
string name = Encoding.UTF8.GetString(bytes, offset + 2, len);
return name;
}
}
3.ByteArray(消息的基本数据结构,提高效率)
懒得写描述了哈哈,直接看实现吧
public class ByteArray : MonoBehaviour
{
//默认大小
private const int DEFAULT_SIZE = 1024;
//初始大小
private int iniSize = 0;
//缓冲区
public byte[] bytes;
//读写位置
public int readIdx = 0;
public int writeIdx = 0;
//容量
private int capacity = 0;
//剩余空间
public int remain
{
get { return capacity - writeIdx; }
}
public int length
{
get { return writeIdx - readIdx; }
}
//构造函数
public ByteArray(int size = DEFAULT_SIZE)
{
bytes = new byte[size];
capacity = size;
iniSize = size;
readIdx = 0;
writeIdx = 0;
}
//构造函数
public ByteArray(byte[] defaultBytes)
{
bytes = defaultBytes;
readIdx = 0;
writeIdx = defaultBytes.Length;
}
public void ReSize(int size)
{
if(size<length) return;
if(size<iniSize) return;
int n = 1;
while (n<size)
{
n *= 2;
}
capacity = n;
byte[] newBytes = new byte[capacity];
Array.Copy(bytes,readIdx,newBytes,0,writeIdx-readIdx);
bytes = newBytes;
writeIdx = length;
readIdx = 0;
}
public void CheckAndMoveBytes()
{
if (length<8)
{
}
}
public void MoveBytes()
{
if (length>0)
{
Array.Copy(bytes,readIdx,bytes,0,length);
}
writeIdx = length;
readIdx = 0;
}
public int Write(byte[] bs, int offset, int count)
{
if (remain < count)
{
ReSize(length+count);
}
Array.Copy(bs,offset,bytes,writeIdx,count);
writeIdx += count;
return count;
}
public int Read(byte[] bs, int offset, int count)
{
count = Math.Min(count, length);
Array.Copy(bytes,readIdx,bs,offset,count);
readIdx += count;
CheckAndMoveBytes();
return count;
}
}
使用流程
如果说我想要和服务器通讯,要经过以下步骤。
大前提是在客户端启动的时候,客户端先根据ip和端口,建立与服务器的连接。
然后,首先,服务器约定好双发传递消息的协议,具体来说,要写一个消息类(里面约定好数据类型)。
在想要发送同步的消息的时候,先构建好准备发送的消息类,然后在需要的游戏逻辑中使用NetManager.Send(MsgBase)发送消息类。
实现接受通过服务器传来的其他客户端的同步消息,要在消息事件处理中,写好对应消息类的处理。
尾声
上述是我对《Unity3D网络游戏实战》第三版第六章的个人笔记,用来梳理以下我对客户端网络模块结构的认识,更详细的可以自己买一本来学习。
后面还有一些模块没写如心跳机制,Protobuf协议。
项目的源码我会在后面几天发到我的github上,可以收藏后过几天来看看。
后续还会补上服务端部分,感兴趣的可以关注。