8. Qt实现多人聊天室(单聊、群聊、文件传输)

一、前言

本聊天室的整体运行逻辑直接参考的本站另一篇文章:手写一个聊天室

只不过本文是用Qt又将其重写了一遍而已,如果你阅读过上面那篇文章,阅读本文可能会更轻松一点。

不得不说,用Qt写代码可比用WTL、MFC方便太多了!

当然,这所谓的容易是建立在你使用过其它不那么容易的框架基础之上的,如果你一来就使用Qt,那可能你很难感受到它的容易。

本文属于C++进阶类型,过于基础的内容不会反复赘述,所以在阅读本文前,我建议你至少需要阅读以下几篇文章:

注意这里的用词:至少,因为该项目大量使用了上面这些文章中提到的知识点,我也不可能每次都挨着赘述一遍。

这里默认你已经了解了C/C++的基本语法。

有了上面几篇文章的基础可以保证你基本能看懂本项目的代码,但如果只看这几篇文章,想要将其完全理解,一般来说肯定还是不够的。

最好的学习方法是尝试自己写一遍,哪里有问题了再来看本文寻找相应的解决方案,这会让你编程能力进步飞快。

二、项目介绍

前面说了,这个项目仅仅只是用Qt来重写了界面而已,其总体功能、使用逻辑其实和用WTL实现的那个聊天室是一模一样的:

image-20231021103339375

当然,可能有的同学对于学习WTL并没有什么兴趣,所以根本就没有看过那篇文章。

我也能理解,毕竟WTL那玩意用起来确实烦人,所以本文尽量还是会从零开始将其介绍一遍的。

该项目文件你可以在文末下载,本文是进阶项目,也不会再从零带着你跟着敲代码了,而是为你理清楚一个现有程序的运行逻辑、以及用到的相应知识点。

三、Qt网络模块

由于我们现在用的是Qt,所以最好就是直接使用Qt为我们封装好的网络库了。

如果你使用的qt creator,那么你需要在项目文件中添加一句:QT += network,才能使用网络库。

如果你和我一样习惯于使用vs,那么也很简单,来到创建好的项目属性中:

image-20231021104139320

直接勾选网络模块即可。

同时你也看到了,上面这里还有一个qt的版本号,我目前使用的是6.5.3,如果你不是这个版本,也可以在这里更换。

添加了网络库后,我们就可以引入以下两个头文件:

#include<qtcpserver.h>
#include<qtcpsocket.h>

这两个文件中就有两个类:QTcpServerQTcpSocket

其中QTcpServer就是qt已经为我们封装好的tcp服务器类,而QTcpSocket则是封装好的客户端类。

现在我们不再需要一步一步按照标准网络编程的步骤去走了,因为人家已经给我们封装好了!

此时一个最简单的服务器代码如下:

QTcpServer server;
server.listen(QHostAddress::Any, 9999);
QObject::connect(&server, &QTcpServer::newConnection, [&]() {})

就三步:

  1. 创建一个服务器对象。
  2. 监听地址、端口。
  3. 将客户端连接信号与一个槽函数进行绑定,这里为了方便,我直接绑定的一个lambda表达式。

如果你学过底层TCP编程,那么我相信你是真的能感受到它的便捷的!

也正因为Qt封装的太好了,所以很多时候,它并不适合用来学习知识点,而更适合用来开发应用。

同时它其实也没有那么多知识点需要学习,无非就是找找看它某个类有哪些函数可以用,仅此而已。

所以下面我们就直接开始讲解代码了。

四、网络数据包

网络编程中,首先最重要的一点其实就是确定好客户端、服务器的通信逻辑。

因为在网络通信中,无论你是消息、文件、视频、音乐,在这里都是一堆字节,你只有确定好传输的规则,对方才能够按照规则将其解析、还原。

这种规则也常称为协议,比如tcp协议、udp协议,http协议等等。

而我们这里就相当于要自己规定一个协议。

由于网络数据是流式传输的,所以我们一般会把协议内容放在数据的开头,也常称为数据包头,数据包头结合其后真正要传输的数据,就可以统称为数据包

放在代码中,其实就是一个结构体或者一个类而已:

image-20231021105709468

服务器与客户端的网络数据包是一模一样的,所以这里我直接拿项目中服务器的代码来介绍。

服务器中只有一个文件main.cpp,里面存放了所有的代码,一共也就200行,并不算多。

在这个数据包类NetPacket中,我又声明了一个结构体:Header,这其实就可以称为协议,也就是数据包头。

这个数据包头中,有四个属性:

  • head:用一个数字标志数据包的开始,这里使用0xFEFF的原因是,根据概率统计,这个数据在网络数据中出现的可能性比较低,这可以让我们更容易识别到我们的数据包头。
  • id:这其实就可以称为我们的协议内容,比如数据包后面的数据是什么类型,文件数据还是普通消息单发还是群发?就靠这个来识别。
  • sum:网络传输数据最大的问题其实就是存在不稳定因素,数据可能会出现错误的情况,所以我们需要用这个校验和来确保我们传输的数据没有出错。
  • len:这个就是数据包后面的数据长度了,可以方便我们解析。

有了数据包头,我们就可以开始来写收发数据包的函数了,也就是这类中的两个静态函数。

这两个静态函数的作用非常的直接:发送/接收一个数据包。

如果没有成功收发一个数据包,那就直接返回false,即失败。

1.SendMsg

比如首先我们来看看发送一个数据包的函数SendMsg

image-20231021122815970

由于接收/发送数据用到的都是QTcpSocket类,所以这里的第一个参数就是要给哪个套接字发送数据。

然后第二个就是要发送的数据类型,这里使用的枚举

image-20231021123031185

并且我让这些枚举量的值都等于2的多少次方,这样就可以对其进行组合:

0b0001 //2的0次方的二进制数据
0b0010 //2的1次方的二进制数据
    
0b0001 | 0b0010 = 0b0011 //组合
0b0011 & 0b0001 =1 //结果等于1,则说明它拥有该标志位

组合之后就可以再通过&运算来判断它是否拥有哪一个标志,比如这里写的私发文件,就是私发文件两个枚举进行组合起来的。

更多内容可以自行浏览器搜索:位运算

回到代码中,第三个参数就是我们实际要发送的数据,用的是Qt封装好的QByteArray这个数据类型。

因为C/C++中字符串是以0作为结尾的,而网络数据中难保不会出现0这个数据,所以用字符串肯定是不合适的,一般我们用的都是字节数组。

事实上C/C++本身标准库的vector<char>也是字节数组,只不过这里既然使用了Qt,那自然就用Qt封装好的库了。

然后在函数内部,我们首先就是要根据数据构造出一个数据包头:

Header h;
h.id = (char)id;
for (auto i : msg) { //得到校验和,方便对方校验接收到的数据是否有误
    h.sum += i;
}
h.len = msg.size();

由于前面我们的结构体写了默认构造函数,所以标志头在这里不需要再重复写了。

所以这里主要就是赋值id,要传输的数据长度、以及计算校验和。

检验和计算很简单,直接将要传输的数据全部加起来就行了。

检验和sumchar类型,最多只能表示255,所以全部加起来肯定会溢出的,但无所谓,因为我们需要的仅仅只是一个能代表所有数据准确性的一个数,任何数丢失了,最后的结果大概率都是不一样的,也就校验和失败了。

最后,只需要再先发送数据包头,再发送数据即可:

qint64 ret = so->write((char*)&h, sizeof(Header)); //先发送头部
if (ret == -1 || ret != sizeof(Header)) return false; //发送失败
ret = so->write(msg); //再发送数据
if (ret == -1 || ret != msg.size()) return false; //发送失败
return true; //发送成功

调用的函数为write,它的返回值为实际发送的数据长度。

至于它的参数就比较丰富了,因为它有很多重载函数,比如可以直接发送QByteArray,也可以发送纯粹的字节数据。

这都可以参考官方文档,不再赘述了,基本都是看一下它怎么用就会了。

2.RecvMsg

然后来看接收数据包的函数:

image-20231021124906740

它同样也是三个参数,不同的是第二第三参数都是引用,这是为了让我们能够将解析到的数据返回出去。

由于QTcpSocke接收数据的函数read返回的是QByteArray类型,字节数组并不方便进行解析,所以我这里专门定义了一个数据包头的指针h

只需要将接收到的字节数组(constData函数返回)强制转化为这个类型,就可以直接进行使用,如果这个数据头不等于0xFEFF,就说明接收失败。

因为QByteArray会自己管理内部内存,所以我们这里并不需要delete这个h

找到了数据包头后,就可以返回id,读取后面的数据,重新计算校验和与对方发送来的校验和进行比较。

如果相同,则说明成功接收到一个数据包,然后返回即可。

注意最后我还添加了一个readAll函数,这是为了防止后面还有其它数据没有读取完,会影响下一次数据包的接收。

更好的处理方式应该是直接将所有数据都读取出来,然后对数据进行解析,如果有剩下的数据就留到下一次与后面接收到的数据一起来解析。

不过这里我们只是一个简单的聊天程序,就不考虑那些复杂的逻辑了。

五、服务器

有了这个网络数据包类,我们后面开发起来就要简单多了。

首先是服务器,我们现在就只剩三个部分了:

image-20231021130021079

首先是这里的g_user,它使用的是Qt提供的QMap映射类,实现QTcpSocketQString两者的映射。

因为发送作为程序来说我们需要知道QTcpSocket才能收发数据,但对于用户来说是通过昵称来选择收发数据对象的,所以我们需要对其做一个映射。

这也就导致了,进入聊天室的成员昵称不能重复!只不过本程序并没有明确做这样的限制,如果重复了,会出问题的。

然后是NoticeOtherUser这个函数,也比较简单,因为我们这主要还是群聊,所以很多时候都需要将消息转发给除发送者以外的所有用户:

image-20231021130637211

如果是自己,那就跳过,否则,就直接调用前面我们封装好的SendMsg函数进行发送即可。

这里通过的是Keys来返回的key列表,即map中的第一个元素,它还有一个values函数可以返回value列表,即map中的第二个元素,比C++中的STL map更加强大好用!

最后就可以来到我们的重中之重:main函数

image-20231021130933493

先将代码折叠起来,可以看到整个程序的运行逻辑非常简单:

  1. 定义一个程序实例a
  2. 定义一个QTcpServer对象,调用listen函数绑定要监听的地址、端口
  3. 然后将这个对象上的有新用户连接消息newConnection与一个槽函数进行绑定,这个槽函数我们这里写的是一个lambda表达式。
  4. 最后执行exec函数,进入消息循环。

那么之后只要有客户端连接上来了,程序就会自动调用后面的lambda表达式:

这个lambda表达式中,首先通过nextPendingConnection函数来获取连接上来的这个客户端对象:

image-20231021131326770

然后我们就又可以来绑定这个对象上的readyReaddisconnected这两个信号了,分别代表客户端发送来了消息数据,以及客户端断开了连接。

由于此时cur_socket为局部变量,所以我们这里采用的是赋值捕获进入lambda表达式来使用它。

先来看看简单的,也就是客户断开连接:

image-20231021131640035

断开连接的逻辑,就是先通知其它用户该用户下线,然后将其从g_user中进行移除,最后再调用它上面的deleteLater函数来删除这个对象。

然后是处理读取数据的逻辑:

image-20231021131815212

整体的逻辑就是直接调用RecvMsg函数来读取一个数据包,读取失败直接返回,读取成功则根据得到的消息id,分别根据实现定义的数据格式进行处理即可。

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux