随便做做Unity网络聊天室!(二)
其实是俺的结课报告 嘿嘿 水一篇博客
实验过程、步骤:
前言
实验思路
实验目标做一个unity游戏运行时网络聊天室。目标实现注册、登录、心跳检测、在线聊天等功能。服务端设计使用 I/O 多路复用模型来处理并发连接。
协议定义与规划
由于该应用相比于大型项目而言没有那么多需要传输的信息。因此我们简化了协议规划部分。
public class Data
{
public int type;
public int length;
public byte[] msg;
public Data(int type, int length, byte[] msg)
{
this.type = type;
this.length = length;
this.msg = msg;
}
}
协议装载与解包
主要目的是将消息的类型和长度以及消息体封装到一个byte数组中,然后在接收方将它们解包出来。这是一种常见的网络通信中的应用层协议实现。
class NetProtocol
{
public static byte[] Pack(int type, byte[] msg)
{
byte[] container = new byte[1024];
byte[] msgByte = BitConverter.GetBytes(type);
Buffer.BlockCopy(msgByte, 0, container, 0, 4);
byte[] msgLength = BitConverter.GetBytes(msg.Length);
Buffer.BlockCopy(msgLength, 0, container, 4, 4);
Buffer.BlockCopy(msg, 0, container, 8, msg.Length);
container = container.Take(4 + 4 + msg.Length).ToArray();
return container;
}
public static Data UnPack(byte[] msg, Socket Remote)
{
int type = BitConverter.ToInt32(msg, 0);
int msgLength = BitConverter.ToInt32(msg, 4);
byte[] msgBody = msg.Skip(8).ToArray();
return new Data(type, msgLength, msgBody);
}
}
由于这个协议没有加入任何错误检测和恢复机制,如果网络中出现了错误,例如消息丢失或损坏,这个协议可能无法正确地处理。
服务器解析
目录结构
Tools工具类说明
为了便于快速修改与模块化,我们将序列化反序列化封装在工具类中,这样如果序列化产生的问题在JsonSerializer包中,我们就可以通过修改工具类函数,对全局使用JsonSerializer序列化的地方进行同一修改。
class Tools
{
public static string Serialize<TValue>(TValue value)
{
return JsonSerializer.Serialize<TValue>(value);
}
public static TValue Deserialize<TValue>(string s, JsonSerializerOptions options = null) where TValue : new()
{
return JsonSerializer.Deserialize<TValue>(s, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
});
}
}
主流程解析
定义socket,储存clients的字典,可读的Socket列表
static Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
static List<Socket> checkRead = new List<Socket>();
程序首先连接数据库。
static void Main(string[] args) { DatabaseConnect.ConnectDB("GameDB", "127.0.0.1",3306, "GameDB","gamedb"); StartServer(); }
启动服务器,绑定端口并开始监听连接请求。
public static void StartServer() { server.Bind(new IPEndPoint(IPAddress.Any, 8888)); server.Listen(20); Console.WriteLine("[服务器启动]");
创建一个定时器,用于定期检查客户端心跳。
// 定时检测客户端心跳 Timer heartbeatTimer = new Timer( _ => CheckHeartbeat(), null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
其中CheckHeartbeat函数如下。计算客户端心跳时间与当前时间的差值,若客户端超过一定时间没有发送心跳包,则断开连接。
public static void CheckHeartbeat() { long currentTime = DateTime.Now.Ticks; foreach (var client in clients.Values.ToList()) { // 计算客户端心跳时间与当前时间的差值 TimeSpan heartbeatSpan = new TimeSpan(currentTime - client.lastHeartbeatTime); // 若客户端超过一定时间没有发送心跳包,则断开连接 if (heartbeatSpan.TotalSeconds > 30) { Console.WriteLine("Client timeout: " + client.socket.RemoteEndPoint); client.socket.Close(); clients.Remove(client.socket); } } }
然后程序进入一个无限循环,在每个循环中,它首先清空并重新填充
checkRead
列表,将服务器Socket和所有客户端Socket添加进去。while (true) { //填充checkRead列表 checkRead.Clear(); checkRead.Add(server); foreach (ClientState cs in clients.Values) { checkRead.Add(cs.socket); } }
接下来,使用
Socket.Select
方法检查checkRead
列表中哪些Socket是可读的。这意味着,对于服务器Socket,有新的连接请求;对于客户端Socket,表示有新的数据到达或者连接被关闭。//Select,多路复用,同时检测多个Socket的状态 Socket.Select(checkRead, null, null, 1000);
最后,程序遍历
checkRead
列表,对于每个可读的Socket,如果是服务器Socket,就调用ReadListenfd
处理新的连接请求;如果是客户端Socket,就调用ReadClientfd
读取新到达的数据。//检查可读对象 foreach (Socket s in checkRead) { if (s == server) { ReadListenfd(s); } else { ReadClientfd(s); } }
消息处理
这个ReadClientfd
函数是用于处理从客户端Socket接收到的数据的。它首先试图接收来自客户端的数据,并存放到对应的ClientState
的接收缓冲区。如果在接收过程中出现任何SocketException
或ObjectDisposedException
异常,它将关闭客户端Socket并返回。
public static bool ReadClientfd(Socket clientfd)
{
//Console.WriteLine("Read");
ClientState clientState = clients[clientfd];
//接收
int count = 0;
try
{
count = clientfd.Receive(clientState.recvBuffer);
}
catch (SocketException ex)
{
clientfd.Close();
Console.WriteLine("Receive SocketException " + ex.ToString());
return false;
}
catch (ObjectDisposedException ex)
{
clientfd.Close();
Console.WriteLine("Receive SocketException " + ex.ToString());
return false;
}
//客户端关闭
if (count == 0)
{
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Colse");
return false;
}
然后,它会拷贝接收到的数据到一个新的byte数组中,并根据数据内容进行不同的处理。它使用NetProtocol.UnPack
方法解析接收到的数据,并根据解析后的消息类型进行相应的处理:
//Console.WriteLine("Copy");
byte[] receivedData = new byte[count];
Buffer.BlockCopy(clientState.recvBuffer, 0, receivedData, 0, count);
// 解析协议
Data data=NetProtocol.UnPack(receivedData, clientfd);
心跳消息:如果消息类型是心跳,它会更新客户端的最后心跳时间,并在控制台打印心跳消息和客户端的远程端点。
if (data.type == (int)MessageType.Heartbeat) { //Console.WriteLine(data.length); Console.WriteLine(Encoding.UTF8.GetString(data.msg, 0, data.length)); Console.WriteLine("Received heartbeat from client: " + clientfd.RemoteEndPoint); clients[clientfd].lastHeartbeatTime = DateTime.Now.Ticks; }
聊天消息:如果消息类型是聊天消息,它会打印聊天内容,并将消息转发给所有的客户端。
else if (data.type == (int) MessageType.ChatMsg) { Console.WriteLine(Encoding.UTF8.GetString(data.msg, 0, data.length)); byte[] msg = NetProtocol.Pack((int)MessageType.ChatMsg, data.msg); foreach(ClientState cs in clients.Values) { cs.socket.Send(msg); } //clientfd.Send(msg); }
注册消息:如果消息类型是注册消息,它会反序列化接收到的用户数据,打印用户的ID,用户名和密码,然后尝试向数据库插入新的账户。如果插入成功,它会向客户端发送一个表示成功的响应;否则,它会向客户端发送一个表示失败的响应。
else if (data.type == (int)MessageType.RegisterMsg) { string json = Encoding.UTF8.GetString(data.msg, 0, data.length); User u = Tools.Deserialize<User>(json); Console.WriteLine("id:"+u.Id+ " username:" + u.Username+ " password:" + u.Password); //byte[] msg = NetProtocol.Pack(2, data.msg); if(DatabaseConnect.InsertAccount(u.Username, u.Password)) { Console.Write("InsertAccount success"); byte[] msg = NetProtocol.Pack((int)MessageType.LoginMsg, Encoding.UTF8.GetBytes( Tools.Serialize<ResponseData>(new ResponseData(true, "InsertAccount succuss")))); clientfd.Send(msg); } else { Console.Write("InsertAccount failed"); byte[] msg = NetProtocol.Pack((int)MessageType.LoginMsg, Encoding.UTF8.GetBytes( Tools.Serialize<ResponseData>(new ResponseData(false, "InsertAccount failed")))); clientfd.Send(msg); }
登录消息:如果消息类型是登录消息,它会以类似的方式处理。反序列化用户数据,检查账户是否存在和密码是否匹配,然后向客户端发送相应的响应。
else if (data.type == (int)MessageType.LoginMsg) { string json = Encoding.UTF8.GetString(data.msg, 0, data.length); Console.WriteLine(json); User u = Tools.Deserialize<User>(json); Console.WriteLine("id:" + u.Id + " username:" + u.Username + " password:" + u.Password); if(DatabaseConnect.CheckAccount(u.Username, u.Password)) { Console.Write("CheckAccount success"); byte[] msg = NetProtocol.Pack((int)MessageType.LoginMsg, Encoding.UTF8.GetBytes( Tools.Serialize<ResponseData>(new ResponseData(true, "CheckAccount succuss")))); clientfd.Send(msg); } else { Console.Write("CheckAccount failed"); byte[] msg = NetProtocol.Pack((int)MessageType.LoginMsg, Encoding.UTF8.GetBytes( Tools.Serialize<ResponseData>(new ResponseData(false, "CheckAccount failed")))); clientfd.Send(msg); } }
数据库连接
这个DatabaseConnect
类封装了对MySQL数据库的常见操作,包括连接数据库,插入新的账户,检查账户是否存在,以及验证账户用户名和密码是否正确。它也包含了一些对SQL注入攻击的防护措施。下面是每个方法的详细解释:
ConnectDB
方法:这个方法尝试连接到指定的MySQL数据库。它需要数据库的名字,数据库服务器的IP地址,端口,用户名和密码。如果连接成功,它会打印一条消息并返回true
,否则打印错误信息并返回false
。public static bool ConnectDB(string db, string ip, int port, string user, string pw) { mySql = new MySqlConnection(); //连接参数 string connectStr = string.Format("Database={0};Data Source={1};Port={2};User Id={3};Password={4};", db, ip, port, user, pw); mySql.ConnectionString = connectStr; try { mySql.Open(); Console.WriteLine("【数据库】connect success!"); return true; } catch (Exception ex) { Console.WriteLine("【数据库】Connect Fail:" + ex.Message); return false; } }
IsSafeString
方法:这个方法检查一个字符串是否包含可能引发SQL注入攻击的字符。它会检查字符串中是否有任何一种可能被用于修改SQL查询语句的字符,如果有则返回false
,否则返回true
。public static bool IsSafeString(string str) { if (string.IsNullOrEmpty(str)) { return true; } return !Regex.IsMatch(str, @"[-|;|,|.|\/|\(|\)|\{|\}|%|@|\*|!|\'|]"); }
IsAccountExist
方法:这个方法检查指定的账户是否存在。它接受一个字段名(例如"username")和一个值,然后查询数据库中是否有匹配的记录。如果有则返回true
,否则返回false
。注意这个方法也检查输入的安全性,防止SQL注入。public static bool IsAccountExist(string col, string val) { //防止SQL注入 if (!DatabaseConnect.IsSafeString(val)) { Console.WriteLine("【数据库】IsAccountExist: Id is not safe"); return false; } if (!DatabaseConnect.IsSafeString(col)) { Console.WriteLine("【数据库】IsAccountExist: col is not safe"); return false; } //sql语句 string sqlStr = string.Format("select * from user where {0} = '{1}'", col.Trim(), val.Trim()); try { MySqlCommand command = new MySqlCommand(sqlStr, mySql); MySqlDataReader dataReader = command.ExecuteReader(); bool hasRow = dataReader.HasRows; dataReader.Close(); return hasRow; } catch (Exception ex) { Console.WriteLine("【数据库】IsAccountExist ERR:" + ex.Message); return false; } }
CheckAccount
方法:这个方法检查一个账户的用户名和密码是否正确。它首先检查输入的安全性,然后查询数据库看是否有匹配的用户名和密码,如果有则返回true
,否则返回false
。public static bool CheckAccount(string username, string password) { if (!IsSafeString(username)) { Console.WriteLine("【数据库】CheckAccount fail: username is not safe"); return false; } if (!IsSafeString(password)) { Console.WriteLine("【数据库】CheckAccount fail: password is not safe"); return false; } try { string s = string.Format("select * from user where username='{0}' and password='{1}'", username, password); MySqlCommand command = new MySqlCommand(s, mySql); MySqlDataReader dataReader = command.ExecuteReader(); bool hasRow = dataReader.HasRows; dataReader.Close(); return hasRow; } catch (Exception ex) { Console.WriteLine("【数据库】CheckAccount ERR:" + ex.Message); return false; } }
InsertAccount
方法:这个方法尝试在数据库中插入一个新的账户。它首先检查输入的安全性,然后检查账户是否已经存在,最后如果一切正常则插入新的记录。public static bool InsertAccount(string username,string password) { if (!IsSafeString(username)) { Console.WriteLine("【数据库】InsertAccount fail: username is not safe"); return false; } if (!IsSafeString(password)) { Console.WriteLine("【数据库】InsertAccount fail: username is not safe"); return false; } if (IsAccountExist("username", username)) { Console.WriteLine("【数据库】InsertAccount fail: 已存在账号信息 " + username); return false; } try { string s = string.Format("insert into user set username='{0}',password={1}", username, password); using (MySqlCommand cmd = new MySqlCommand(s, mySql)) { cmd.ExecuteNonQuery(); } return true; } catch (Exception ex) { Console.WriteLine("【数据库】InsertAccount ERR:" + ex.Message); return false; } }
客户端解析
目录结构
场景设计
Login注册与登录场景
Chat对话场景
主要通信类,Client分析
枚举(Enum)MessageType:定义了一些消息的类型,包括心跳包,聊天消息,注册和登录信息。
enum MessageType { Heartbeat = 1, ChatMsg = 2, RegisterMsg = 3, LoginMsg = 4 }
Awake
函数:在Unity中,这个函数会在游戏开始前被调用。它确保了只有一个Client
实例存在。void Awake() { if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } }
Connection
函数:这个函数尝试连接到服务器。如果没有指定客户端名字,它会随机生成一个。然后,它尝试连接到服务器,如果失败则打印错误信息。public void Connection() { try { if(string.IsNullOrEmpty(ClientName)) { ClientName = "client" + new System.Random().Next().ToString(); } socket.Connect("127.0.0.1", 8888); StartCoroutine(Heartbeat()); } catch (Exception ex) { Console.WriteLine("Connection failed: " + ex.Message); } }
ReceiveMessageFromServer
函数:这个函数开始异步接收来自服务器的数据。public void ReceiveMessageFromServer() { //Debug.Log("开始接收数据"); Array.Clear(Client.buffer, 0, Client.buffer.Length); Client.socket.BeginReceive(Client.buffer, 0, Client.buffer.Length, SocketFlags.None, ReceiveMessageCallback, ""); }
ReceiveMessageCallback
函数:这个函数是上述函数的回调函数,它在数据接收完成后被调用。它结束数据接收,然后解包数据并把它加到队列中,最后开始接收下一条消息。public void ReceiveMessageCallback(IAsyncResult ar) { //Debug.Log("接收结束!!"); //结束接收 int length = Client.socket.EndReceive(ar); //Debug.Log("接收的长度是:" + length); byte[] msg = Client.buffer; Data data = NetProtocol.UnPack(msg, Client.socket); datas.Enqueue(data); //Debug.Log("服务器发过来的消息是:" + msg); //开启下一次消息的接收 ReceiveMessageFromServer(); }
Heartbeat
函数:这是一个协程函数,它在一个无限循环中每15秒发送一次心跳包。IEnumerator Heartbeat() { while (true) { // 发送心跳包 Client.instance.Send((int)MessageType.Heartbeat,""); // 等待15秒 yield return new WaitForSeconds(15f); } }
主流程解析
login页面,开始自动执行:连接服务器,接收服务器数据
void Start()
{
try
{
Client.instance.Connection();
Client.instance.ReceiveMessageFromServer();
}
catch (Exception ex)
{
Debug.Log("Connect : Fail\n" + ex.Message);
}
}
组件,函数绑定,摁下login调用login函数。在此仅举一例
每一帧检测,是否收到服务器数据
void Update()
{
if (Client.datas.Count != 0)
{
Data data = Client.datas.Dequeue();
HandleMessage(data);
}
}
解析数据,进行操作。如果服务器返回的LoginMsg与RegisterMsg为success则进入下一个界面。
public void HandleMessage(Data data)
{
if (data.type == (int)MessageType.ChatMsg)
{
//addListItem(Encoding.UTF8.GetString(data.msg));
}
else if (data.type == (int)MessageType.LoginMsg)
{
ResponseData response = JsonUtility.FromJson<ResponseData>(Tools.ByteToString(data.msg));
if(response.success)
{
SceneManager.LoadScene("chat");
}
}
else if (data.type == (int)MessageType.RegisterMsg)
{
ResponseData response = JsonUtility.FromJson<ResponseData>(Tools.ByteToString(data.msg));
if (response.success)
{
SceneManager.LoadScene("chat");
}
}
}
chat界面,得益于单例模式,同样的检测方式,以下快速带过。
void Update()
{
if(Client.datas.Count!=0)
{
Data data = Client.datas.Dequeue();
HandleMessage(data);
}
}
public void HandleMessage(Data data)
{
if(data.type == (int)MessageType.ChatMsg)
{
addListItem(Encoding.UTF8.GetString(data.msg));
}
else if(data.type == (int)MessageType.LoginMsg)
{
}
else if (data.type == (int)MessageType.RegisterMsg)
{
}
}
消息接收后增加聊天框组件,对消息进行呈现
public void addListItem(string s)
{
Debug.Log("addListItem");
GameObject newItem = Instantiate(ListItemPrefeb, new Vector3(0, 0, 0), Quaternion.identity);
newItem.transform.SetParent(Content.transform);
newItem.transform.GetComponent<TMP_Text>().text = s;
Debug.Log("addListItem over");
}
运行演示
打开数据库
开启服务器
客户端运行
注册账户
使用navicat查看数据库
可以看到,刚才插入的3214账户确实插入了数据库
单人发送消息
结束客户端,心跳包检测,自动断开连接
接下来测试多人,打开俩个客户端
可以看到,客户端之间接收到了各自的消息,多人联机部分也正常运行,服务器显示了心跳包和发送的消息序列化字符串
实验总结(遇到的问题及解决办法、体会):
问题一
不知道Pascal命名法。
Pascal命名法是一种命名约定,用于给类型、方法、属性等命名。它的规则是:
- 没有空格或标点符号
- 采用UpperCamelCase风格,即驼峰命名法
- 每个单词的首字母大写
C# server端 json解析需要给目标对象标注[System.Serializable],对于需要反序列化的参数需要这样的[JsonPropertyName("Id")]标准。否则反序列化失败。或者遵循Pascal命名法
[System.Serializable]
public class User
{
[JsonPropertyName("Id")]
public string Id { get; set; }
[JsonPropertyName("Username")]
public string Username { get; set; }
[JsonPropertyName("Password")]
public string Password { get; set; }
}
问题二
unity切换场景时tcp连接会断掉,因为切换场景时会将所有对象销毁,包括socket连接类,解决如下。单例模式加DontDestroyOnLoad对象存留声明。
void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
问题三
【数据库】Connect Fail:Could not load file or assembly 'System.Security.Permissions, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. 系统找不到指定的文件。
这个错误表示无法加载System.Security.Permissions这个程序集版本4.0.3.0。
安装Mysql.Data.dll解决
问题四
【数据库】Connect Fail:The type initializer for 'MySql.Data.MySqlClient.Replication.ReplicationManager' threw an exception.
数据库版本不对,要和本地起的数据库版本对上,重新下载8.0.12版本。
问题五
JsonSerializer与JsonUtility不兼容问题。JsonUtility是轻量库,不能解析类中类。
解决:定义一个string对象接收,再手动进行反序列化。