独立网络线程
独立前

独立后

槽函数连接方式

直接连接
connect(发送者, 信号, [](信号参数){});
这种槽函数在发送者所在线程触发。
增加接收者
connect(发送者,信号,接收者,槽函数)如果发送者和接收者在同一个线程,则槽函数调用的线程就是发送者所在的线程。
发送者和接收者不在一个线程,connect默认采用队列连接方式
connect(发送者,信号,接收者,槽函数)
槽函数在接收者所在的线程触发。好处就是解耦合。
元对象系统
- 信号和槽
- 反射
- 动态增加函数和属性
当我们信号和槽连接方式采用队列连接,那么信号的参数会被封装为元对象,投递到队列中。
要想支持元对象有两种方式
- 继承于QObject,并且类内填写Q_OBJECT宏
- 声明并且注册元对象类
为了支持高并发情况下断点续传,考虑将目前项目中TcpMgr中网络模块独立到独立线程
封装TcpThread类
利用RAII思想封装线程启动和回收
1 | class TcpThread:public std::enable_shared_from_this<TcpThread> { |
具体实现
1 | TcpThread::TcpThread() |
主函数启动时记得提前启动线程,将TcpMgr转移到独立线程中
1 | //启动tcp线程 |
测试发现,登录卡住,检测是信号sig_connect_tcp发送了,槽函数slot_tcp_connect没触发。
1 | //连接tcp连接请求的信号和槽函数 |
为了测试
先在TcpMgr中添加测试槽函数
1 | void slot_test() { |
在LoginDialog中连接信号
1 | connect(this, &LoginDialog::sig_test, TcpMgr::GetInstance().get(), &TcpMgr::slot_test); |
在发送sig_connect_tcp处发送sig_test
1 | void LoginDialog::initHttpHandlers() |
测试,是可以看到能触发slot_test函数得,而且线程id显示是子线程中触发得槽函数。
那么sig_connect_tcp信号发出,没有触发槽函数,就是因为信号得参数类型不支持元对象系统。
为了支持元对象系统,我们需要在信号的参数ServerInfo类实现默认构造,同时声明为元对象类型
1 | struct ServerInfo{ |
在TcpMgr中注册这个元对象类型
1 | qRegisterMetaType<ServerInfo>("ServerInfo"); |
再次测试就通过登录了,但是在发送后续得消息时,又遇到了自定义类型作为参数得情况,我们需要和上面一样,依次声明元对象类型并且注册。
如下列举一个,还有很多,不再详细列举
1 | class SearchInfo { |
TcpMgr封装注册元对象函数
1 | void TcpMgr::registerMetaType() { |
在构造函数中调用
1 | TcpMgr::TcpMgr():_host(""),_port(0),_b_recv_pending(false),_message_id(0),_message_len(0) |
再次测试就通过了
这里给大家讲讲为什么单线程情况下,信号可以携带自定义类型作为参数,不用设定元对象就可以传输,而跨线程不可以。
在 Qt 的信号/槽机制中,信号参数的传递方式取决于连接(connect)的类型,而连接类型又由发信号对象和接收槽对象所在的线程决定:
同线程(Direct Connection)
- 如果信号和槽都在同一个线程里,默认使用 Direct Connection。
- Direct Connection 本质上就是一个普通的 C++ 函数调用,参数直接按值或按引用传递,编译时就已经知道了类型,不需要任何额外的元类型信息。
- 因此,即使你没有把
SearchInfo注册为QMetaType,编译器也能直接生成函数调用代码,信号里就可以直接传递SearchInfo。
跨线程(Queued Connection)
如果信号发送者和接收者不在同一个线程,Qt 会自动把连接转成 Queued Connection。
Queued Connection 的实现是:当信号发出时,Qt 会把信号参数打包成一个事件(
QEvent),然后把事件放到目标线程的事件队列里;目标线程的事件循环(QCoreApplication::processEvents())再把这个事件取出来,调用槽函数。这里的“打包”与“解包”就需要运行时才能确定参数类型,以及如何拷贝或序列化这个类型——这正是 Qt 元对象系统(
QMetaType)要干的事情。如果没有把
SearchInfo声明成一个元类型,Qt 就不知道如何在内部把它从一个线程“打包”到事件里,又如何在另一线程里还原。因此,跨线程传递自定义类型,必须在类型定义后加上:
1
Q_DECLARE_METATYPE(SearchInfo)
并在运行时注册(通常在
main()里调用一次):1
qRegisterMetaType<SearchInfo>("SearchInfo");
小结
- 同线程:Direct Connection,编译时直接调用,不需要
Q_DECLARE_METATYPE。 - 跨线程:Queued Connection,需要运行时打包/解包参数,必须用
Q_DECLARE_METATYPE(以及qRegisterMetaType)来注册你的自定义类型。
添加发送队列
UserMgr线程安全
为了保证多线程情况下访问数据的安全性,对UserMgr类的操作加锁
1 | std::mutex _mtx; |
在获取数据和设置数据的地方都进行加锁, 比如
1 | std::shared_ptr<UserInfo> UserMgr::GetUserInfo() |
还有很多不再赘述
设置发送队列
默认情况下qt的socket都是非阻塞的。
所以调用socket.write(数据)可能会直接返回-1
返回-1表示网络出错,一般都是EWOULD_BLOCK/EAGAIN造成的。表示发送缓冲区已经满了,无法继续发送。
而我们之前的逻辑,无论在哪个线程,想要发送数据,统一发送信号
1 | void sig_send_data(ReqId reqId, QByteArray data); |
会触发TcpMgr的槽函数
1 | void TcpMgr::slot_send_data(ReqId reqId, QByteArray dataBytes) |
上述函数在网络情况良好的时候不会产生问题,但是如果网络发送情况频繁的时候,就容易出现written为-1的情况。
也就是发送缓冲区满了,导致发送失败。
对于这种情况,我们可以模仿我们的服务器写法,添加一个发送队列,然后将要发送的数据投递到发送队列
1 | //发送队列 |
修改发送逻辑
1 | void TcpMgr::slot_send_data(ReqId reqId, QByteArray dataBytes) |
我们需要监听发送返回的数据,QT也提供了类似于asio的异步回调功能,只是在发送完成后返回一个信号void bytesWritten(qint64 bytes);
我们连接这个信号
1 | QObject::connect(&_socket, &QTcpSocket::bytesWritten, this, [this](qint64 bytes) { |
_pending控制发送还是放入队列。
断点续传思路
思路图

修改上传逻辑
原来的传输逻辑,采用的是循环上传,就是将一个文件拆分成多个报文段,循环上传,而不等待服务器每次回复
1 | void MainWindow::on_uploadBtn_clicked() |
现在需要改为分段上传,每次上传后,等待服务器返回响应后再上传下一个
1 | void MainWindow::on_uploadBtn_clicked() |
收到响应后续传
当客户端收到服务器的回包后,解析后传递给LogicMgr, LogicMgr中需要将后续的报文段发送给服务器。我们封装如下逻辑
1 | void LogicWorker::InitHandlers() |
其中sig_trans_size为信号,通知主界面显示进度
暂停和续传
客户端需增加暂停和续传按钮,支持传说过程中暂停,点击后再继续上传等功能
1 | void MainWindow::slot_pause_continue() |
这里继续上传需要请求一下服务器,同步之前的上传进度。
我们添加了新的协议ID_SYNC_FILE_REQ, 服务器收到后将状态和进度返回,客户端响应
1 | _handlers[ID_SYNC_FILE_RSP] = [this](QJsonObject obj){ |
客户端根据返回的进度,按照偏移量读取指定文件,并且继续上报。
如果健壮一点,可以判断服务器返回的错误信息,根据错误,提示主界面做出交互显示等。这里不再赘述。
到此客户端设计完成。
单线程服务器改造
单线程服务器改造不大,只需要增加同步文件进度信息的处理逻辑,以及优化之前的上传处理逻辑即可
1 | _fun_callbacks[ID_UPLOAD_FILE_REQ] = [this](shared_ptr<CSession> session, const short& msg_id, |
多线程服务器
多线程服务器改造和单线程类似
只不过将处理逻辑放入LogicWorker中
1 | void LogicWorker::RegisterCallBacks() |
将进度信息存储在LogicSystem中,后续可参考填写入redis,方便后续分布式扩展,注意如果填写了多个资源服务器,还有写入服务器信息,这个不再赘述和进阶,我们只用一个资源服务器做演示,后续读者可自己进阶分布式设计。
1 | void LogicSystem::AddMD5File(std::string md5, std::shared_ptr<FileInfo> fileinfo) { |
集成资源服务器
新架构形式
集成资源服务器后的架构为

将上述多线程服务器,整合到项目目录,同时设置资源属性表,复用之前的就可以了。
注意资源服务器配置要稍作修改
1 | [GateServer] |
客户端新增资源网络类
构造函数解析
因为客户端需要长连接资源服务器,采用TCP方式上传文件,所以需要封装一个单例的FileTcpMgr类,用于上传资源。
1 | class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>, |
构造函数具体实现
1 | FileTcpMgr::FileTcpMgr(QObject *parent) : QObject(parent), |
简单描述下上述构造函数做的事情:
- 成功连接服务器后,会触发
QTcpSocket::connected信号,从而回调lambda表达式,发送sig_con_success信号 - 接收服务器传输的数据,会触发
QTcpSocket::readyRead信号,从而回调lambda表达式,在这里处理头部信息和包体信息。进行TLV协议解析后回调handleMsg。 - 捕获
QTcpSocket::SocketError信号,当出错后回调lambda表达式发送信号通知主界面错误。 - 捕获连接断开信号
QTcpSocket::disconnected,回调lambda表达式,通知主界面连接断开。 - 连接发送信号
sig_send_data,因为socket在独立线程,不能直接调用发送,所以改为异步发送,触发槽函数slot_send_data - 因为异步发送,可能存在未发送完全的情况,所以我们用
QTcpSocket::bytesWritten来检测发送了多少字节,通过lambda表达式回调处理,继续发送数据。 - 因为
socket被独立为单独线程,所以关闭也不能直接调用close(socket), 需要统一在槽函数中处理。
注册元对象系统的逻辑不再赘述。
连接槽函数
我们实现槽函数slot_tcp_connect用来创建客户端到资源服务器的连接
1 | void FileTcpMgr::slot_tcp_connect(std::shared_ptr<ServerInfo> si) |
注册处理流程
注册上传头像回调逻辑
1 | void FileTcpMgr::initHandlers() |
独立文件线程
对于上传我们独立到文件上报线程中
1 | class FileTcpThread: public std::enable_shared_from_this<FileTcpThread>{ |
具体实现
1 | FileTcpThread::FileTcpThread() |
主函数调用
1 |
|
原来的登录流程稍作修改,连接好ChatServer后,连接ResourceServer, 最后再让用户登录。
服务器逻辑
服务器新增文件上报逻辑处理, 在LogicWorker::RegisterCallBacks中添加
1 | _fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id, |
源码
https://gitee.com/secondtonone1/llfcchat
效果展示:
上传前

上传后

服务器存储成功

续传信息持久化
增加redis接口
1 | bool RedisMgr::SetFileInfo(const std::string& md5, std::shared_ptr<FileInfo> file_info) |
新增超时设置
1 | bool RedisMgr::SetExp(const std::string& key, const std::string& value, int expire_seconds) { |
每次收到上传信息后,更新上传进度到redis中
1 | _fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id, |
资源url更新
因为上头像传资源后,要将资源的路径存储到mysql数据库中,所以我们新增MysqlMgr,这个直接从ChatServer拷贝一份即可。
但是要注意,添加如下函数
1 | bool MysqlMgr::UpdateUserIcon(int uid, const std::string& icon) { |
Dao层面实现更新头像逻辑
1 | bool MysqlDao::UpdateHeadInfo(int uid, const std::string& icon) |
封装异步回调
之前我们处理文件上传是异步方式,将要保存的文件投递给消息队列,交给独立线程后台保存。我们没有等待处理完成就直接将消息回传给客户端,这么做不是很好,所以改为异步方式,简单的方式就是通过回调函数处理,或者包装一个future等待。这里考虑保留异步结构,所以还是用回调处理
1 | struct FileTask { |
改进后的处理
1 | _fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id, |
callback是我们封装的回调函数,投递给FileTask, 将来在后台线程处理FileTask时回调。
1 | void FileWorker::task_callback(std::shared_ptr<FileTask> task) |
客户端上传逻辑修改
1 | //上传头像 |
客户端加载头像
服务器将上传的头像信息保存为url更新到mysql中,接下来客户端登录需要加载新的头像
在ChatDialog的构造函数中将头像加载逻辑修改为
1 | //模拟加载自己头像 |
聊天页面也需要修改头像加载逻辑
1 | void ChatPage::AppendChatMsg(std::shared_ptr<ChatDataBase> msg) |
聊天列表中加载头像逻辑修改一下
1 | void ChatUserWid::SetChatData(std::shared_ptr<ChatThreadData> chat_data) { |
用户信息加载头像
1 | UserInfoPage::UserInfoPage(QWidget *parent) : |
测试效果

客户端断点下载资源
客户端请求下载
在客户端加载本地资源发现不存在的时候,需要请求服务器,获取资源。
如果资源比较大,需要分批下载,也就是支持断点下载。
我们先拿UserInfoPage举例
1 | UserInfoPage::UserInfoPage(QWidget *parent) : |
如果本地资源不存在,则需要向服务器请求。
封装请求资源接口
判断资源是否正在下载
1 |
|
如果资源加载成功,很可能处于正在下载,所以也要判断一下。
如果资源未加载成功,则需要向服务器下载资源。先讲要下载的资源和要加载资源的空间缓存起来。
1 | void UserMgr::AddLabelToReset(QString path, QLabel* label) |
结构如下图

更新正在下载资源
1 | void UserMgr::AddDownloadFile(QString name, |
发送下载请求
1 | void FileTcpMgr::SendDownloadInfo(std::shared_ptr<DownloadInfo> download) { |
接收服务器回传
1 | _handlers.insert(ID_DOWN_LOAD_FILE_RSP, [this](ReqId id, int len, QByteArray data) { |
- 判断seq是否为1,如果为1则说明新的文件,需要创建并保存
- 如果seq不为1,则说明是续传文件,更新追加就可以了
- 如果
is_last字段为true,说明是最后一个包,那么移除缓存的下载信息,同时将信息发送到主界面更新图标
更新页面逻辑
在ChatDialog界面中响应这个信号
1 | //重置label icon |
槽函数处理
1 | void ChatDialog::slot_reset_icon(QString path) { |
UserMgr中封装重置icon逻辑
1 | void UserMgr::ResetLabelIcon(QString path) |
测试效果
服务器断点传输逻辑
增加下载worker
1 | class DownloadWorker { |
DownloadWorker处理逻辑和之前的FileWorker类似
1 | DownloadWorker::DownloadWorker() :_b_stop(false) |
在FileSystem中创建worker
1 | FileSystem::FileSystem() |
测试效果
将头像资源从本地删除后,重新登录时或者切换页面会引发资源重新加载,向服务器请求资源后再设置到界面显示。
