随便做做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);
    }

}

由于这个协议没有加入任何错误检测和恢复机制,如果网络中出现了错误,例如消息丢失或损坏,这个协议可能无法正确地处理。

服务器解析

目录结构

image-20230530115954709

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>();
  1. 程序首先连接数据库。

    static void Main(string[] args)
    {
        DatabaseConnect.ConnectDB("GameDB", "127.0.0.1",3306, "GameDB","gamedb");
        StartServer();
    }
  2. 启动服务器,绑定端口并开始监听连接请求。

    public static void StartServer()
    {
        server.Bind(new IPEndPoint(IPAddress.Any, 8888));
        server.Listen(20);
        Console.WriteLine("[服务器启动]");
  1. 创建一个定时器,用于定期检查客户端心跳。

                // 定时检测客户端心跳
    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);
            }
        }
    }
  1. 然后程序进入一个无限循环,在每个循环中,它首先清空并重新填充checkRead列表,将服务器Socket和所有客户端Socket添加进去。

    while (true)
    {
    
        //填充checkRead列表
        checkRead.Clear();
        checkRead.Add(server);
    
        foreach (ClientState cs in clients.Values)
        {
            checkRead.Add(cs.socket);
        }
    
        
    }
  1. 接下来,使用Socket.Select方法检查checkRead列表中哪些Socket是可读的。这意味着,对于服务器Socket,有新的连接请求;对于客户端Socket,表示有新的数据到达或者连接被关闭。

        //Select,多路复用,同时检测多个Socket的状态
        Socket.Select(checkRead, null, null, 1000);
  1. 最后,程序遍历checkRead列表,对于每个可读的Socket,如果是服务器Socket,就调用ReadListenfd处理新的连接请求;如果是客户端Socket,就调用ReadClientfd读取新到达的数据。

    //检查可读对象
        foreach (Socket s in checkRead)
        {
            if (s == server)
            {
                ReadListenfd(s);
            }
            else
            {
                ReadClientfd(s);
            }
        }

消息处理

​ 这个ReadClientfd函数是用于处理从客户端Socket接收到的数据的。它首先试图接收来自客户端的数据,并存放到对应的ClientState的接收缓冲区。如果在接收过程中出现任何SocketExceptionObjectDisposedException异常,它将关闭客户端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);
  1. 心跳消息:如果消息类型是心跳,它会更新客户端的最后心跳时间,并在控制台打印心跳消息和客户端的远程端点。

                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;
                }
  1. 聊天消息:如果消息类型是聊天消息,它会打印聊天内容,并将消息转发给所有的客户端。

    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);
    }
  1. 注册消息:如果消息类型是注册消息,它会反序列化接收到的用户数据,打印用户的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);
        }
    
  1. 登录消息:如果消息类型是登录消息,它会以类似的方式处理。反序列化用户数据,检查账户是否存在和密码是否匹配,然后向客户端发送相应的响应。

    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注入攻击的防护措施。下面是每个方法的详细解释:

  1. 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;
                }
    
            }
  1. IsSafeString方法:这个方法检查一个字符串是否包含可能引发SQL注入攻击的字符。它会检查字符串中是否有任何一种可能被用于修改SQL查询语句的字符,如果有则返回false,否则返回true

     public static bool IsSafeString(string str)
     {
         if (string.IsNullOrEmpty(str))
         {
             return true;
         }
         return !Regex.IsMatch(str, @"[-|;|,|.|\/|\(|\)|\{|\}|%|@|\*|!|\'|]");
     }
  2. 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;
        }
    
    }
  1. 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;
        }
    }
  1. 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;
         }
     }

客户端解析

目录结构

image-20230530120015385

场景设计

Login注册与登录场景

image-20230530111840809

Chat对话场景

image-20230530111900368

主要通信类,Client分析

  1. 枚举(Enum)MessageType:定义了一些消息的类型,包括心跳包,聊天消息,注册和登录信息。

    enum MessageType
    {
        Heartbeat = 1,
        ChatMsg = 2,
        RegisterMsg = 3,
        LoginMsg = 4
    }
  2. Awake函数:在Unity中,这个函数会在游戏开始前被调用。它确保了只有一个Client实例存在。

    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
  3. 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);
        }
    }
  4. 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, "");
    }
  5. 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();
    
     }
  6. 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函数。在此仅举一例

image-20230530114014565

每一帧检测,是否收到服务器数据

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");
}

运行演示

打开数据库

image-20230530114322052

开启服务器

image-20230530114251383

image-20230530114346631

客户端运行

image-20230530114427082

注册账户

image-20230530114521946

使用navicat查看数据库

image-20230530114551379

可以看到,刚才插入的3214账户确实插入了数据库

单人发送消息

image-20230530114645399

结束客户端,心跳包检测,自动断开连接

image-20230530114809118

接下来测试多人,打开俩个客户端

image-20230530114950213

image-20230530115244583

image-20230530115300731

image-20230530115348594

可以看到,客户端之间接收到了各自的消息,多人联机部分也正常运行,服务器显示了心跳包和发送的消息序列化字符串

实验总结(遇到的问题及解决办法、体会):

问题一

不知道Pascal命名法。

Pascal命名法是一种命名约定,用于给类型、方法、属性等命名。它的规则是:

  1. 没有空格或标点符号
  2. 采用UpperCamelCase风格,即驼峰命名法
  3. 每个单词的首字母大写

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对象接收,再手动进行反序列化。

发表评论