使用C#和Unity实现的多人聊天室(1)

GIF 2020-8-4 16-19-32

前言

网络是游戏开发的重要一环,许多Unity网络相关的项目都是基于Unet实现的,可以在引擎内通过组件的方式较轻松的实现网络功能,但现已被弃用,替代产品有:

Mirror Networking:https://github.com/vis2k/Mirror

Phton:https://www.photonengine.com/

近期腾讯也和Unity合作推出了游戏联机服务的C# SDK: https://cloud.tencent.com/document/product/1038

框架也有:

服务端ComBlock:https://github.com/kbengine/kbengine

服务端Pomelo:https://github.com/NetEase/pomelo

双端ET:https://github.com/egametang/ET

等。要想学习这部分的引擎,首先基础要打好,所以想到先从自己实现一个简单的客户端/服务端来了解多人游戏基本的工作流。项目服务端使用c#编写,数据库选用Mysql,客户端使用Unity引擎。

参考书籍:

《Unity3D网络游戏实战》-罗培羽https://luopeiyu.github.io/unity_net_book/

项目地址

客户端:https://github.com/TLS18/Block-Client

服务端:https://github.com/TLS18/Block-Server

整体架构

image-20200804214735151

实现了一个较为基础的服务端,市面上成熟的服务端架构要比这个复杂许多,例如增加分区分服,增加数据库访问代理,处理数据缓存等大量优化。下面分客户端和服务端两方面来记录学习历程

服务端部分

DataMgr类

该类用于连接数据库,封装了访问数据库的所有方法,通过Mysql提供的connector能在c#中访问修改数据库,在此之前需要先连接上数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class DataMgr
{
MySqlConnection sqlConn;
public bool Connect()
{
string connStr = "database=game;data source=" + DBHost + ";";
connStr += "user=" + user + ";password=" + pw + ";port=" + DBPort + ";";
sqlConn = new MySqlConnection(connStr);
try
{
sqlConn.Open();
Console.WriteLine("[数据库]连接成功");
return true;
}
catch (Exception e)
{
Console.WriteLine("[数据库]连接失败" + e.Message);
return false;
}
}
}

其中一个判断角色是否能注册的方法(查询数据库内是否有某个id对应的记录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private bool CanRegister(string id)
{
if (!IsSafeStr(id)) return false;
string cmdStr = string.Format("select * from user where id = '{0}';", id);
MySqlCommand cmd = new MySqlCommand(cmdStr, sqlConn);
try
{
MySqlDataReader dataReader = cmd.ExecuteReader();
bool hasRows = dataReader.HasRows;
dataReader.Close();
return !hasRows;
}
catch(Exception e)
{
Console.WriteLine("[DataMgr]查询用户失败" + e.Message);
return false;
}
}
sql注入

需要注意的一点是要防止sql注入,简单来说就是服务端需要向数据库进行sql查询,一些场景下需要结合用户输入动态构建sql语句,例如上面这个方法要使用用户id来查询,此时用户就可以在id中加入非法语句来达到查询修改数据库的目的,所以在id上使用了一个简单的IsSafeStr()来判断输入是否符号要求,代码如下

1
2
3
4
public bool IsSafeStr(string str)
{
return !Regex.IsMatch(str, @"[-|;|,|\/|\(|\)|\[|\]|\}|\{|%|@|\*|!|\']");
}

更多关于数据库及网络安全的知识还有待学习,记录一个用于渗透测试的平台留待研究,里面就有关于sql注入的部分

DVWA:http://www.dvwa.co.uk/

ServMgr类

该类负责管理所有客户端的连接,包括消息接收/分发、消息发送、维护连接状态等功能。在初始化时会建立一个Socket对象并开始异步监听是否有客户端连接,客户端连接成功后使用socket.BeginReceive()异步接收该连接的消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public void Start(string host,int post)
{
...
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind部分
IPAddress ipAdr = IPAddress.Parse(host);
IPEndPoint ipEp = new IPEndPoint(ipAdr, post);
listenfd.Bind(ipEp);
//Listen部分
listenfd.Listen(maxConn); //最多可容纳的接收数
//Accept部分
listenfd.BeginAccept(AcceptCb, null);//将回调函数做参
...
}
private void AcceptCb(IAsyncResult ar)
{
try
{
Socket socket = listenfd.EndAccept(ar);
int index = NewIndex();

if(index < 0)
{
socket.Close();
Console.Write("[警告]连接已满");
}
else
{
Conn conn = conns[index];
conn.Init(socket);
string adr = conn.GetAddr();
Console.WriteLine("客户端连接[" + adr + "]conn池ID:" + index);
conn.socket.BeginReceive(conn.readBuff, conn.buffCount,conn.BuffRemain(), SocketFlags.None, ReceiveCb, conn);
listenfd.BeginAccept(AcceptCb, null);
}
}
catch(Exception e)
{
Console.WriteLine("AcceptCb失败:" + e.Message);
}
}

private void ReceiveCb(IAsyncResult ar)
{
处理接收到的消息
}

当服务端接收到来自客户端的消息时,调用定义好的回调函数ReceiveCb,将消息根据种类分发给不同的类去执行,关于如何辨别消息的种类后面会有记录

TCP粘包分包

这里需要注意的是,由于TCP协议的机制,如果遇到较小的数据包,会等待其他数据包到来一同发送,或者缓冲区有数据未取出,下次就会一口气取出多个包,数据接收端就无法判断这个包是否一同处理;相反如果遇到较大的数据包,会将其拆分发送。故以上两种情况会造成粘包分包问题,一种解决方法是在每个包的前面增加一个包长度,当接收端处理数据时,先根据缓冲区里数据量和给定包的长度进行判断,这样就能保证每次都能正确的取出数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void ProcessData(Conn conn)
{
if(conn.buffCount < sizeof(Int32))
{
return;
}
//获取消息长度
Array.Copy(conn.readBuff, conn.lenBytes, sizeof(Int32));
conn.msgLength = BitConverter.ToInt32(conn.lenBytes, 0);
//Console.WriteLine("收到长度[" + conn.msgLength + "]");
if (conn.buffCount < conn.msgLength + sizeof(Int32))
{
return;
}
ProtocolBase protocol = proto.Decode(conn.readBuff, sizeof(Int32), conn.msgLength);
//消息分发给具体负责处理的类
HandleMsg(conn, protocol);
//清除掉已经处理的消息
int count = conn.buffCount - conn.msgLength - sizeof(Int32);
Array.Copy(conn.readBuff, sizeof(Int32) + conn.msgLength, conn.readBuff, 0, count);
conn.buffCount = count;
//如果缓冲区非空则继续处理
if (conn.buffCount > 0)
{
ProcessData(conn);
}
}
心跳机制

当一个客户端出现问题,比如网络断线,客户端闪退等没有正常下线的场景,与客户端的连接就会变为死连接,即无法接收到客户端的消息且没有主动关闭。可以加入心跳机制解决问题,客户端定时给服务端发送心跳协议,一般5s左右发送一次,更新服务端的心跳时间,当心跳时间超过一定阀值时,判断连接失效,主动去断开与客户端的连接

消息分发

对消息进行处理时,先根据其消息类型进行分发,然后由不同的类去执行,例如服务端收到了创建角色的消息,在HandlePlayerMsg中有这样一个方法

1
2
3
4
public void MsgCreatePlayer(Player player,ProtocolBase protoBase)
{
...
}

在ServMgr中可以这样访问,通过C#的反射,可以在程序运行期获取类型信息,这里使用MethodInfo可以获取到方法的名称参数返回值等等,接着构建一个数组保存要传入方法的参数,当类型不确定的时候可以使用Object类,接着就是使用MethodInfo.Invoke调用给定对象的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public HandlePlayerMsg handlePlayerMsg = new HandlePlayerMsg();
//name=CreatePlayer
string methodName = "Msg" + name;
MethodInfo mm = handlePlayerMsg.GetType().GetMethod(methodName);
if(mm == null)
{
string str = "[警告]HandleMsg没有处理玩家方法";
Console.WriteLine(str + methodName);
return;
}
Object[] obj = new object[] { conn.player, protocolBase };
if (name != "UpdateInfo")
{
Console.WriteLine("[处理玩家信息] " + conn.player.id + " : " + name);
}
mm.Invoke(handlePlayerMsg, obj);

Player类

该类作为连接Conn类和管理ServMgr类的中间层,可以看作是服务器里每一个玩家的抽象,其包含成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//玩家id
public string id;
//玩家与服务器的连接
public Conn conn;
//长期数据,比如玩家的等级,金币,段位
public PlayerData data;
//临时数据,比如玩家在某一局里的击杀数
public PlayerTempData tempData;

public Player(string id,Conn conn)
{
this.id = id;
this.conn = conn;
tempData = new PlayerTempData();
}

Scene类

该类用于管理服务器场景,在玩MMORPG的时候,经常会有地图或者副本的概念,一个副本就是一个场景,用户加载资料的时候,只需要专注于场景里的玩家,所以需要一个场景管理类来管理该场景里所有玩家的信息。本项目结构简单只有一个场景,如果需要多个场景可以建立Scene类的列表来实现。当有玩家进入场景时,该类需要做的就是创建ScenePlayer对象来表示其在场景中的状态,同时将同场景下的所有玩家的信息发送给该玩家,队伍或者房间也同理

1
2
3
4
5
6
7
8
9
10
public class ScenePlayer
{
public string id;
public float x = 0;
public float y = 0;
public float z = 0;
public float xScale = 0;
public int animInfo = 0;
public string name = "";
}

服务端与客户端的协议

字节流协议

在运行时服务端与客户端之间会发送不同类型的消息,要想方便的对不同消息类型进行处理,需要创建一个协议来规定两端之间通信的内容,网络通信的参与方必须遵守协议才能互相传输信息

例如可以通过字符串协议,客户端向服务端发送一个字符串表示注册账号

消息长度(粘包分包处理) 消息内容
Int(xxx) Register,userId,password

字符串用逗号隔开,第一个表示方法名,后面的表示参数,这样传输到服务端后就会交由MsgRegister来对参数userId和password进行处理,但这样有一个漏洞,如果客户端发送了一段带有逗号的字符串会引发错误,所以书籍中采用了字节流协议的方法,将方法名和参数全部放进一个字节数组中,客户端和服务端依照约定好的顺序依次从数组中读取值。这种协议可以表示下面几种数据类型

数据类型 占用字节
Int 4
Float 8
String

字符串较特殊,为了保证其完整性,我们需要在其前面加上表示字符串字节长度的int型数据,例如

0 1 2 3 4 5 6 7 8 9 10 11
0 0 0 8 R e g i s t e r

再将其放入字节数组中

Protocol类

基类

除了字节流协议,服务端还要能支持多种协议,定义一个基类,包含协议必须要支持的编码,解码,获取协议名称等功能(名称指的是登录协议,注册协议等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ProtocolBase
{
public virtual ProtocolBase Decode(byte[] readbuff, int start, int length)
{
return new ProtocolBase();
}
public virtual byte[] Encode()
{
return new byte[] { };
}
public virtual string GetName()
{
return "";
}

public virtual string GetDesc()
{
return "";
}
}
字节流类

需要注意的是添加和获取字符串的方法,获取字符串里的参数end加上了ref关键字,表示该参数在方法内的改变会影响到原本的变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void AddString(string str)
{
//将字符串根据UTF8编码转换成字节
byte[] strBytes = System.Text.Encoding.UTF8.GetBytes(str);
//获取字节长度
byte[] lenBytes = BitConverter.GetBytes(strBytes.Length);
//两者进行组合,加入到字节流协议的数组里面
if (bytes == null)
{
bytes = lenBytes.Concat(strBytes).ToArray();
}
else bytes = bytes.Concat(lenBytes).Concat(strBytes).ToArray();
}

public string GetString(int start,ref int end) //引用类型的参数,将方法内的变化带出外面
{
if((bytes == null)|| (bytes.Length < start + sizeof(Int32)))
{
return "";
}
//获取字符串的字节长度
Int32 strLen = BitConverter.ToInt32(bytes, start);
if (bytes.Length < start + sizeof(Int32))
{
return "";
}
//将字节转化为字符串
string str = System.Text.Encoding.UTF8.GetString(bytes, start + sizeof(Int32), strLen);
//更新
end = start + sizeof(Int32) + strLen;
return str;
}

构建字节流协议

发送端

1
2
3
4
5
6
7
ProtocolBytes protocol = new ProtocolBytes();
protocol.AddString("Register");
protocol.AddString(idInput.text);
protocol.AddString(pwInput.text);
Debug.Log("发送" + protocol.GetDesc());
//发送给服务端,回调函数为OnRegBack
NetMgr.srvConn.Send(protocol, OnRegBack);

使用字节流协议

接收端,由于ref关键字,每次读取完后start的位置会往后移动

1
2
3
4
5
6
7
8
9
10
11
public void MsgRegister(Conn conn, ProtocolBase protoBase)
{
int start = 0;
ProtocolBytes protocol = (ProtocolBytes)protoBase;
string protoName = protocol.GetString(start, ref start);
string id = protocol.GetString(start, ref start);
string pw = protocol.GetString(start, ref start);
string strFormat = "[收到注册协议] " + conn.GetAddr();
Console.WriteLine(strFormat + " 用户名: " + id + " 密码: " + pw);
...
}