一、前言
本聊天室的整体运行逻辑直接参考的本站另一篇文章:手写一个聊天室
只不过本文是用Qt又将其重写了一遍而已,如果你阅读过上面那篇文章,阅读本文可能会更轻松一点。
不得不说,用Qt写代码可比用WTL、MFC方便太多了!
当然,这所谓的容易是建立在你使用过其它不那么容易的框架基础之上的,如果你一来就使用Qt,那可能你很难感受到它的容易。
本文属于C++进阶类型,过于基础的内容不会反复赘述,所以在阅读本文前,我建议你至少需要阅读以下几篇文章:
注意这里的用词:至少,因为该项目大量使用了上面这些文章中提到的知识点,我也不可能每次都挨着赘述一遍。
这里默认你已经了解了C/C++的基本语法。
有了上面几篇文章的基础可以保证你基本能看懂本项目的代码,但如果只看这几篇文章,想要将其完全理解,一般来说肯定还是不够的。
最好的学习方法是尝试自己写一遍,哪里有问题了再来看本文寻找相应的解决方案,这会让你编程能力进步飞快。
二、项目介绍
前面说了,这个项目仅仅只是用Qt来重写了界面而已,其总体功能、使用逻辑其实和用WTL实现的那个聊天室是一模一样的:
当然,可能有的同学对于学习WTL并没有什么兴趣,所以根本就没有看过那篇文章。
我也能理解,毕竟WTL那玩意用起来确实烦人,所以本文尽量还是会从零开始将其介绍一遍的。
该项目文件你可以在文末下载,本文是进阶项目,也不会再从零带着你跟着敲代码了,而是为你理清楚一个现有程序的运行逻辑、以及用到的相应知识点。
三、Qt网络模块
由于我们现在用的是Qt,所以最好就是直接使用Qt为我们封装好的网络库了。
如果你使用的qt creator
,那么你需要在项目文件中添加一句:QT += network
,才能使用网络库。
如果你和我一样习惯于使用vs,那么也很简单,来到创建好的项目属性中:
直接勾选网络模块即可。
同时你也看到了,上面这里还有一个qt的版本号,我目前使用的是6.5.3
,如果你不是这个版本,也可以在这里更换。
添加了网络库后,我们就可以引入以下两个头文件:
#include<qtcpserver.h>
#include<qtcpsocket.h>
这两个文件中就有两个类:QTcpServer
与QTcpSocket
。
其中QTcpServer
就是qt已经为我们封装好的tcp服务器类,而QTcpSocket
则是封装好的客户端类。
现在我们不再需要一步一步按照标准网络编程的步骤去走了,因为人家已经给我们封装好了!
此时一个最简单的服务器代码如下:
QTcpServer server;
server.listen(QHostAddress::Any, 9999);
QObject::connect(&server, &QTcpServer::newConnection, [&]() {})
就三步:
- 创建一个服务器对象。
- 监听地址、端口。
- 将客户端连接信号与一个槽函数进行绑定,这里为了方便,我直接绑定的一个
lambda
表达式。
如果你学过底层TCP编程,那么我相信你是真的能感受到它的便捷的!
也正因为Qt封装的太好了,所以很多时候,它并不适合用来学习知识点,而更适合用来开发应用。
同时它其实也没有那么多知识点需要学习,无非就是找找看它某个类有哪些函数可以用,仅此而已。
所以下面我们就直接开始讲解代码了。
四、网络数据包
网络编程中,首先最重要的一点其实就是确定好客户端、服务器的通信逻辑。
因为在网络通信中,无论你是消息、文件、视频、音乐,在这里都是一堆字节,你只有确定好传输的规则,对方才能够按照规则将其解析、还原。
这种规则也常称为协议,比如tcp协议、udp协议,http协议等等。
而我们这里就相当于要自己规定一个协议。
由于网络数据是流式传输的,所以我们一般会把协议内容放在数据的开头,也常称为数据包头,数据包头结合其后真正要传输的数据,就可以统称为数据包。
放在代码中,其实就是一个结构体或者一个类而已:
服务器与客户端的网络数据包是一模一样的,所以这里我直接拿项目中服务器的代码来介绍。
服务器中只有一个文件main.cpp
,里面存放了所有的代码,一共也就200
行,并不算多。
在这个数据包类NetPacket
中,我又声明了一个结构体:Header
,这其实就可以称为协议,也就是数据包头。
这个数据包头中,有四个属性:
head
:用一个数字标志数据包的开始,这里使用0xFEFF
的原因是,根据概率统计,这个数据在网络数据中出现的可能性比较低,这可以让我们更容易识别到我们的数据包头。id
:这其实就可以称为我们的协议内容,比如数据包后面的数据是什么类型,文件数据还是普通消息?单发还是群发?就靠这个来识别。sum
:网络传输数据最大的问题其实就是存在不稳定因素,数据可能会出现错误的情况,所以我们需要用这个校验和来确保我们传输的数据没有出错。len
:这个就是数据包后面的数据长度了,可以方便我们解析。
有了数据包头,我们就可以开始来写收发数据包的函数了,也就是这类中的两个静态函数。
这两个静态函数的作用非常的直接:发送/接收一个数据包。
如果没有成功收发一个数据包,那就直接返回false
,即失败。
1.SendMsg
比如首先我们来看看发送一个数据包的函数SendMsg
:
由于接收/发送数据用到的都是QTcpSocket
类,所以这里的第一个参数就是要给哪个套接字发送数据。
然后第二个就是要发送的数据类型,这里使用的枚举:
并且我让这些枚举量的值都等于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
,要传输的数据长度、以及计算校验和。
检验和计算很简单,直接将要传输的数据全部加起来就行了。
检验和
sum
为char
类型,最多只能表示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
然后来看接收数据包的函数:
它同样也是三个参数,不同的是第二第三参数都是引用,这是为了让我们能够将解析到的数据返回出去。
由于QTcpSocke
接收数据的函数read
返回的是QByteArray
类型,字节数组并不方便进行解析,所以我这里专门定义了一个数据包头的指针h
。
只需要将接收到的字节数组(constData
函数返回)强制转化为这个类型,就可以直接进行使用,如果这个数据头不等于0xFEFF
,就说明接收失败。
因为
QByteArray
会自己管理内部内存,所以我们这里并不需要delete
这个h
。
找到了数据包头后,就可以返回id
,读取后面的数据,重新计算校验和与对方发送来的校验和进行比较。
如果相同,则说明成功接收到一个数据包,然后返回即可。
注意最后我还添加了一个readAll
函数,这是为了防止后面还有其它数据没有读取完,会影响下一次数据包的接收。
更好的处理方式应该是直接将所有数据都读取出来,然后对数据进行解析,如果有剩下的数据就留到下一次与后面接收到的数据一起来解析。
不过这里我们只是一个简单的聊天程序,就不考虑那些复杂的逻辑了。
五、服务器
有了这个网络数据包类,我们后面开发起来就要简单多了。
首先是服务器,我们现在就只剩三个部分了:
首先是这里的g_user
,它使用的是Qt提供的QMap
映射类,实现QTcpSocket
与QString
两者的映射。
因为发送作为程序来说我们需要知道QTcpSocket
才能收发数据,但对于用户来说是通过昵称来选择收发数据对象的,所以我们需要对其做一个映射。
这也就导致了,进入聊天室的成员昵称不能重复!只不过本程序并没有明确做这样的限制,如果重复了,会出问题的。
然后是NoticeOtherUser
这个函数,也比较简单,因为我们这主要还是群聊,所以很多时候都需要将消息转发给除发送者以外的所有用户:
如果是自己,那就跳过,否则,就直接调用前面我们封装好的SendMsg
函数进行发送即可。
这里通过的是Keys
来返回的key
列表,即map
中的第一个元素,它还有一个values
函数可以返回value
列表,即map
中的第二个元素,比C++中的STL map
更加强大好用!
最后就可以来到我们的重中之重:main
函数
先将代码折叠起来,可以看到整个程序的运行逻辑非常简单:
- 定义一个程序实例
a
。 - 定义一个
QTcpServer
对象,调用listen
函数绑定要监听的地址、端口 - 然后将这个对象上的有新用户连接消息
newConnection
与一个槽函数进行绑定,这个槽函数我们这里写的是一个lambda
表达式。 - 最后执行
exec
函数,进入消息循环。
那么之后只要有客户端连接上来了,程序就会自动调用后面的lambda
表达式:
这个lambda
表达式中,首先通过nextPendingConnection
函数来获取连接上来的这个客户端对象:
然后我们就又可以来绑定这个对象上的readyRead
、disconnected
这两个信号了,分别代表客户端发送来了消息数据,以及客户端断开了连接。
由于此时cur_socket
为局部变量,所以我们这里采用的是赋值捕获进入lambda
表达式来使用它。
先来看看简单的,也就是客户断开连接:
断开连接的逻辑,就是先通知其它用户该用户下线,然后将其从g_user
中进行移除,最后再调用它上面的deleteLater
函数来删除这个对象。
然后是处理读取数据的逻辑:
整体的逻辑就是直接调用RecvMsg
函数来读取一个数据包,读取失败直接返回,读取成功则根据得到的消息id
,分别根据实现定义的数据格式进行处理即可。