前言
前面实现了聊天信息的存储和加载,以及实现了一些资源的上传和下载。
接下来实现聊天中传输多媒体消息的逻辑,基本包括图片传输,视频传输和播放,文件传输等。
为了更顺利的实现功能,我打算先以图片聊天传输为切入点,然后再实现视频传输,文件传输等。
图片传输思路
在聊天中传输图片
- 一方面是要把聊天消息传输到
ChatServer中 - 另一方面在收到
ChatServer的回复后,将资源断点续传方式传输给ResourceServer - ``ResourceServer`需要采用断点上传方式回复给客户端。
ResourceServer将接收完整资源后,需要通过grpc将消息发送给ChatServer更新消息为已经上传完成的状态。ChatServer收到消息后更新数据,并且做消息推送,推送给消息关联的双方。推送给发送方资源已经上传完成。推送给接收方资源已经上传完成- 发送方将图片设置为已上传状态,接收方则展示预览图,并显示进度百分比。
- 后期还要优化,发送方在上传资源的时候显示圆圈百分比。已经响应点击事件,暂停或者继续。

MsgInfo完善
我修改了MsgInfo的结构,以支持图片视频等多媒体资源在聊天中传输
1 | struct MsgInfo{ |
- 将内容字段改为
_text_or_url,表示文件和图像的url,或者纯文本信息 _preview_pix为文件或者图片的缩略图,如果为视频则需要抽取第一帧作为缩略图,如果文件则设置指定格式即可_unique_name为文件唯一名字,生成唯一名字有一个好处,同一个文件可以多次传输,每个文件按不同副本保存。当然也可以保存为同一份,采用md5做区分,总之这里先按照不同的副本存储在服务器。_total_size用来统计文件的总大小_current_size用来表示当前已经传输的大小_seq表示传输的序号,将来做断点续传使用_md5文件传输用的md5
修改插入消息的逻辑
1 | void MessageTextEdit::insertMsgList(QVector<std::shared_ptr<MsgInfo>> &list, MsgType msgtype, |
将消息插入到消息列表,第一个参数是可选择的,有时我们需要将消息插入到全局消息列表。有时需要将消息插入到资源消息列表。
比如当我们拖动一个多媒体资源到富文本编辑框的时候,就是将这个资源的信息插入到资源消息列表。
当我们汇总所有的消息,用来发送的时候,就需要将消息添加到全局消息列表。
图片气泡框

声明
1 | class PictureBubble : public BubbleFrame |
具体实现
1 |
|
图片聊天消息
实现
1 | class ImgChatData : public ChatDataBase { |
发送消息逻辑

修改发送消息的逻辑,发送图片时,需要将之前的文本消息发送出去,再发送图片
1 | void ChatPage::on_send_btn_clicked() { |
接收服务器返回
先注册消息,用于跨线程调用
1 | void TcpMgr::registerMetaType() { |
注册消息
1 | void TcpMgr::initHandlers() |
服务器接收图片消息
先注册消息
1 | void LogicSystem::RegisterCallBacks() { |
处理聊天中的图片消息
1 | void LogicSystem::DealChatImgMsg(std::shared_ptr<CSession> session, |
聊天资源断点续传

之前我们实现了1和2,接下来在客户端Client收到ChatServer的回复消息2后,需要执行步骤3
这期间要在客户端和服务器之间实现断点续传。
发送资源
1 | _handlers.insert(ID_IMG_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) { |
我们的客户端在收到服务器的回复(步骤2)之后,立刻读取文件,发送第一个包,这里通过FileTcpMgr发送资源给ResourceServer
资源服务器存储
我们实现断点续传,在LogicWorker中注册处理逻辑
1 | void LogicWorker::RegisterCallBacks() |
- 通过callback存储回调函数,一同包裹进
FileInfo, 回调函数通过=捕获所有局部变量,这样构造了一个伪闭包。 - 我们将包裹好的
FileInfo投递到FileSystem中,交给其中的线程池,由多个FileWorker线程处理
我们跟进FileSystem的投递逻辑
1 | void FileSystem::PostMsgToQue(shared_ptr<FileTask> msg, int index) |
FileWorker投递逻辑
1 | void FileWorker::PostTask(std::shared_ptr<FileTask> task) |
我们将任务直接包裹到一个lambda表达式中,利用python万物皆对象的思想,结合C++语法,将这个可调用对象投递给队列。
将来可调用对象从队列取出后就会调用这个lambda表达式, 进而调用task_callback函数
1 | FileWorker::FileWorker() :_b_stop(false) |
调用逻辑
1 | void FileWorker::task_callback(std::shared_ptr<FileTask> task) |
从_handlers中根据消息id检索,取出回调函数,传入task参数调用
_handlers的注册逻辑
1 | void FileWorker::RegisterHandlers() |
比如是聊天图片上传的请求,就调用如下
1 | _handlers[ID_IMG_CHAT_UPLOAD_REQ] = [this](std::shared_ptr<FileTask> task) { |
在这个逻辑里我们打开文件,并采取追加的方式将数据写入服务器所在的磁盘目录保存
测试效果


结论
- 经过测试,可以实现断点续传上传聊天资源的功能
- 但是对于大文件,采用串行方式断点续传效率很慢
- 考虑搞一个拥塞窗口多序列传输,本质上还是通过网络线程串行上传,但不是等待服务器回复后才上传,而是通过一个拥塞窗口控制发送频率。
设计拥塞窗口提高发送效率
思路分析
客户端发送端单线程还是多线程
本质上客户端如果采用切片的方式将一个文件切割为多个小文件,可以不考虑顺序,将来汇总服务器的回包统计是否传输完成即可。
但是对于同一个socket多线程调用send会产生数据错乱,对于asio这种网络库,我们采用的是发送队列控制顺序,保证互斥性,一个包发送完成再发送下一个。
对于QT其底层封装了发送队列,支持多线程并发调用send,但是本质上底层的发送还是很串行化。
所以对于现有的结构,我们通过跨线程的方式,将要发送的数据投递给FileMgr所在的线程的消息队列,统一发送。
这个结构不用改。
客户端发送逻辑修改
客户端不再等待服务器回包后再发送,而是将切割好的包一次性添加到发送队列。
但是如果文件过大,要几百个包,一次性会堆满队列,另外循环发送几百个包会造成网络拥塞,导致服务器一段时间只为这一个客户端服务,这是不可取的。
拥塞窗口设计
为了解决这个问题,我们可以优先将要发送的数据放入拥塞窗口,处于拥塞窗口的数据优先发送
其余的数据投递到队列中。
如果文件数据过多,可以优先将一部分数据放入队列,等到队列队列大小缩小后继续放入数据。
当客户端收到服务器回包后,做错误判断,如果无误则从队列取出数据放到拥塞窗口中继续发送。
队列减小到一定阈值后,将文件剩余未发送的包继续填充到队列中。

这么做还要考虑如果发送失败,就要清除队列中该次未发送的数据包。
如果发送两个文件,队列中的数据将会是交叉的。所以对于错误处理,要考虑剔除发送失败的包。
数据结构设计:
1 | struct SendTask { |
队列管理:
- 使用map<file_id, queue
>区分不同文件的数据包 - 发送失败时,只清除对应file_id的所有待发送包
- 维护已发送但未确认的包列表,便于超时重传
服务器逻辑
服务器是多线程还是单线程
服务器可以采用多线程方式处理收到的文件包,可以采用多线程的方式写如文件,但是对于同一个文件要加锁。
本质上同一个时刻只有一个线程可以对文件进行读写。所以干脆就用一个线程负责一个文件的写,可以根据session_id区分不同的连接,对于同一个连接采用同一个FileWorker执行写就可以了。
这样不用加锁还保证线程安全了。

服务器乱序存储
服务器不再用原来的线性方式将内容追加到磁盘上。
而是优先接收客户端的第一个包,获取文件信息,然后按照seq个数创建文件大小,在最后一个字节写入空,这样整个空文件就构造好了。

然后服务器每次接收到客户端的乱序序列后,将内容写入对应的偏移位置。并且回复客户端,将序列号和文件基本信息回复给客户端。

客户端实现拥塞窗口
窗口大小成员
在FileTcpMgr中添加成员变量
1 | class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>, |
封装发送逻辑
1 | class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>, |
具体实现
1 | void FileTcpMgr::BatchSend(std::shared_ptr<MsgInfo> msg_info) { |
同步发送信息
考虑以后很多场景都会将发送信息同步给服务器,所以单独抽象了一个发送协议
在TcpMgr收到聊天消息回复后,可以考虑先将图片信息同步给资源服务器
1 | _handlers.insert(ID_IMG_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) { |
处理同步信息回包
1 | _handlers.insert(ID_FILE_INFO_SYNC_RSP, [this](ReqId id, int len, QByteArray data) { |
之后的处理逻辑就和聊天图片上传一样,只是这个是一次上传多个。
有个更好的改进点就是不用等到服务器写完,服务器就回复给客户端,但是逻辑控制更复杂,如果后续写失败,还要回滚之类的,更麻烦。这里还是保留原逻辑,服务器写完就回复,只不过客户端不是等待回复后一个一个发送了,开始的时候是一起发送,用拥塞窗口控制。后续还是会收到限制,因为受限于服务器写,这次就先这样了,以后在考虑做优化。
响应资源回复
1 | _handlers.insert(ID_IMG_CHAT_UPLOAD_RSP, [this](ReqId id, int len, QByteArray data) { |
服务器响应同步信息
1 | _fun_callbacks[ID_FILE_INFO_SYNC_REQ] = [this](shared_ptr<CSession> session, const short& msg_id, |
其余逻辑不变。
测试效果
