恋恋风辰的个人博客


  • Home

  • Archives

  • Categories

  • Tags

  • Search

多媒体信息传输

Posted on 2025-11-14 | In C++聊天项目

前言

前面实现了聊天信息的存储和加载,以及实现了一些资源的上传和下载。

接下来实现聊天中传输多媒体消息的逻辑,基本包括图片传输,视频传输和播放,文件传输等。

为了更顺利的实现功能,我打算先以图片聊天传输为切入点,然后再实现视频传输,文件传输等。

图片传输思路

在聊天中传输图片

  • 一方面是要把聊天消息传输到ChatServer中
  • 另一方面在收到ChatServer的回复后,将资源断点续传方式传输给ResourceServer
  • ``ResourceServer`需要采用断点上传方式回复给客户端。
  • ResourceServer将接收完整资源后,需要通过grpc将消息发送给ChatServer更新消息为已经上传完成的状态。
  • ChatServer收到消息后更新数据,并且做消息推送,推送给消息关联的双方。推送给发送方资源已经上传完成。推送给接收方资源已经上传完成
  • 发送方将图片设置为已上传状态,接收方则展示预览图,并显示进度百分比。
  • 后期还要优化,发送方在上传资源的时候显示圆圈百分比。已经响应点击事件,暂停或者继续。

image-20251114211632712

MsgInfo完善

我修改了MsgInfo的结构,以支持图片视频等多媒体资源在聊天中传输

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct MsgInfo{

MsgInfo(MsgType msgtype, QString text_or_url, QPixmap pixmap, QString unique_name, qint64 total_size, QString md5)
:_msg_type(msgtype), _text_or_url(text_or_url), _preview_pix(pixmap),_unique_name(unique_name),_total_size(total_size),
_current_size(0),_seq(1),_md5(md5)
{}

MsgType _msg_type;
QString _text_or_url;//表示文件和图像的url,文本信息
QPixmap _preview_pix;//文件和图片的缩略图
QString _unique_name; //文件唯一名字
qint64 _total_size; //文件总大小
qint64 _current_size; //传输大小
qint64 _seq; //传输序号
QString _md5; //文件md5
};
  • 将内容字段改为_text_or_url,表示文件和图像的url,或者纯文本信息
  • _preview_pix为文件或者图片的缩略图,如果为视频则需要抽取第一帧作为缩略图,如果文件则设置指定格式即可
  • _unique_name为文件唯一名字,生成唯一名字有一个好处,同一个文件可以多次传输,每个文件按不同副本保存。当然也可以保存为同一份,采用md5做区分,总之这里先按照不同的副本存储在服务器。
  • _total_size用来统计文件的总大小
  • _current_size用来表示当前已经传输的大小
  • _seq表示传输的序号,将来做断点续传使用
  • _md5文件传输用的md5

修改插入消息的逻辑

1
2
3
4
5
6
7
8
void MessageTextEdit::insertMsgList(QVector<std::shared_ptr<MsgInfo>> &list, MsgType msgtype,
QString text_or_url, QPixmap preview_pix,
QString unique_name, uint64_t total_size, QString md5) {

auto msg_info = std::make_shared<MsgInfo>(msgtype, text_or_url, preview_pix, unique_name, total_size, md5);
list.append(msg_info);

}

将消息插入到消息列表,第一个参数是可选择的,有时我们需要将消息插入到全局消息列表。有时需要将消息插入到资源消息列表。

比如当我们拖动一个多媒体资源到富文本编辑框的时候,就是将这个资源的信息插入到资源消息列表。

当我们汇总所有的消息,用来发送的时候,就需要将消息添加到全局消息列表。

图片气泡框

image-20251115152713630

声明

1
2
3
4
5
6
class PictureBubble : public BubbleFrame
{
Q_OBJECT
public:
PictureBubble(const QPixmap &picture, ChatRole role, QWidget *parent = nullptr);
};

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "PictureBubble.h"
#include <QLabel>


#define PIC_MAX_WIDTH 160
#define PIC_MAX_HEIGHT 90

PictureBubble::PictureBubble(const QPixmap &picture, ChatRole role, QWidget *parent)
:BubbleFrame(role, parent)
{
QLabel *lb = new QLabel();
lb->setScaledContents(true);
QPixmap pix = picture.scaled(QSize(PIC_MAX_WIDTH, PIC_MAX_HEIGHT),
Qt::KeepAspectRatio, Qt::SmoothTransformation);
lb->setPixmap(pix);
this->setWidget(lb);

int left_margin = this->layout()->contentsMargins().left();
int right_margin = this->layout()->contentsMargins().right();
int v_margin = this->layout()->contentsMargins().bottom();
setFixedSize(pix.width()+left_margin + right_margin, pix.height() + v_margin *2);
}

图片聊天消息

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ImgChatData : public ChatDataBase {
public:
ImgChatData(std::shared_ptr<MsgInfo> msg_info, QString unique_id,
int thread_id, ChatFormType form_type, ChatMsgType msg_type,
int send_uid, int status, QString chat_time = ""):
ChatDataBase(unique_id,thread_id, form_type, msg_type, msg_info->_text_or_url,
send_uid, status, chat_time), _msg_info(msg_info){

}

std::shared_ptr<MsgInfo> _msg_info;
};

Q_DECLARE_METATYPE(std::shared_ptr<ImgChatData>)

发送消息逻辑

image-20251115154431242

修改发送消息的逻辑,发送图片时,需要将之前的文本消息发送出去,再发送图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
void ChatPage::on_send_btn_clicked() {
if (_chat_data == nullptr) {
qDebug() << "friend_info is empty";
return;
}

auto user_info = UserMgr::GetInstance()->GetUserInfo();
auto pTextEdit = ui->chatEdit;
ChatRole role = ChatRole::Self;
QString userName = user_info->_name;
QString userIcon = user_info->_icon;

const QVector<std::shared_ptr<MsgInfo>>& msgList = pTextEdit->getMsgList();
QJsonObject textObj;
QJsonArray textArray;
int txt_size = 0;
auto thread_id = _chat_data->GetThreadId();
for (int i = 0; i < msgList.size(); ++i)
{
//消息内容长度不合规就跳过
if (msgList[i]->_text_or_url.length() > 1024) {
continue;
}

MsgType type = msgList[i]->_msg_type;
ChatItemBase* pChatItem = new ChatItemBase(role);
pChatItem->setUserName(userName);
SetSelfIcon(pChatItem, user_info->_icon);
QWidget* pBubble = nullptr;
//生成唯一id
QUuid uuid = QUuid::createUuid();
//转为字符串
QString uuidString = uuid.toString();
if (type == MsgType::TEXT_MSG)
{
pBubble = new TextBubble(role, msgList[i]->_text_or_url);
if (txt_size + msgList[i]->_text_or_url.length() > 1024) {
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
textObj["text_array"] = textArray;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送并清空之前累计的文本列表
txt_size = 0;
textArray = QJsonArray();
textObj = QJsonObject();
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ, jsonData);
}

//将bubble和uid绑定,以后可以等网络返回消息后设置是否送达
//_bubble_map[uuidString] = pBubble;
txt_size += msgList[i]->_text_or_url.length();
QJsonObject obj;
QByteArray utf8Message = msgList[i]->_text_or_url.toUtf8();
auto content = QString::fromUtf8(utf8Message);
obj["content"] = content;
obj["unique_id"] = uuidString;
textArray.append(obj);
//todo... 注意,此处先按私聊处理
auto txt_msg = std::make_shared<TextChatData>(uuidString, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, content, user_info->_uid, 0);
//将未回复的消息加入到未回复列表中,以便后续处理
_chat_data->AppendUnRspMsg(uuidString, txt_msg);
}
else if (type == MsgType::IMG_MSG)
{
//将之前缓存的文本发送过去
if (txt_size) {
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
textObj["text_array"] = textArray;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送并清空之前累计的文本列表
txt_size = 0;
textArray = QJsonArray();
textObj = QJsonObject();
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ, jsonData);
}

pBubble = new PictureBubble(QPixmap(msgList[i]->_text_or_url), role);
//需要组织成文件发送,具体参考头像上传
auto img_msg = std::make_shared<ImgChatData>(msgList[i],uuidString, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, user_info->_uid, 0);
//将未回复的消息加入到未回复列表中,以便后续处理
_chat_data->AppendUnRspMsg(uuidString, img_msg);
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
textObj["md5"] = msgList[i]->_md5;
textObj["name"] = msgList[i]->_unique_name;
textObj["token"] = UserMgr::GetInstance()->GetToken();
textObj["unique_id"] = uuidString;
//文件信息加入管理
UserMgr::GetInstance()->AddTransFile(msgList[i]->_unique_name, msgList[i]);
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_IMG_CHAT_MSG_REQ, jsonData);
}
else if (type == MsgType::FILE_MSG)
{

}
//发送消息
if (pBubble != nullptr)
{
pChatItem->setWidget(pBubble);
pChatItem->setStatus(0);
ui->chat_data_list->appendChatItem(pChatItem);
_unrsp_item_map[uuidString] = pChatItem;
}

}

qDebug() << "textArray is " << textArray;
//发送给服务器
textObj["text_array"] = textArray;
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送并清空之前累计的文本列表
txt_size = 0;
textArray = QJsonArray();
textObj = QJsonObject();
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ, jsonData);
}

接收服务器返回

先注册消息,用于跨线程调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void TcpMgr::registerMetaType() {
// 注册所有自定义类型
qRegisterMetaType<ServerInfo>("ServerInfo");
qRegisterMetaType<SearchInfo>("SearchInfo");
qRegisterMetaType<std::shared_ptr<SearchInfo>>("std::shared_ptr<SearchInfo>");

qRegisterMetaType<AddFriendApply>("AddFriendApply");
qRegisterMetaType<std::shared_ptr<AddFriendApply>>("std::shared_ptr<AddFriendApply>");

qRegisterMetaType<ApplyInfo>("ApplyInfo");

qRegisterMetaType<std::shared_ptr<AuthInfo>>("std::shared_ptr<AuthInfo>");

qRegisterMetaType<AuthRsp>("AuthRsp");
qRegisterMetaType<std::shared_ptr<AuthRsp>>("std::shared_ptr<AuthRsp>");

qRegisterMetaType<UserInfo>("UserInfo");

qRegisterMetaType<std::vector<std::shared_ptr<TextChatData>>>("std::vector<std::shared_ptr<TextChatData>>");

qRegisterMetaType<std::vector<std::shared_ptr<ChatThreadInfo>>>("std::vector<std::shared_ptr<ChatThreadInfo>>");

qRegisterMetaType<std::shared_ptr<ChatThreadData>>("std::shared_ptr<ChatThreadData>");
qRegisterMetaType<ReqId>("ReqId");
qRegisterMetaType<std::shared_ptr<ImgChatData>>("std::shared_ptr<ImgChatData>");
}

注册消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void TcpMgr::initHandlers()
{
_handlers.insert(ID_IMG_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive create private chat rsp Success";

//收到消息后转发给页面
auto thread_id = jsonObj["thread_id"].toInt();
auto unique_id = jsonObj["unique_id"].toString();
auto unique_name = jsonObj["unique_name"].toString();

auto sender = jsonObj["fromuid"].toInt();
auto msg_id = jsonObj["message_id"].toInt();
QString chat_time = jsonObj["chat_time"].toString();
int status = jsonObj["status"].toInt();

auto file_info = UserMgr::GetInstance()->GetTransFileByName(unique_name);

auto chat_data = std::make_shared<ImgChatData>(file_info, unique_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, sender, status, chat_time);

//发送信号通知界面
emit sig_chat_img_rsp(thread_id, chat_data);
});

}

服务器接收图片消息

先注册消息

1
2
3
4
5
6
7
8
void LogicSystem::RegisterCallBacks() {

_fun_callbacks[ID_LOAD_CHAT_MSG_REQ] = std::bind(&LogicSystem::LoadChatMsg, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_IMG_CHAT_MSG_REQ] = std::bind(&LogicSystem::DealChatImgMsg, this,
placeholders::_1, placeholders::_2, placeholders::_3);
}

处理聊天中的图片消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void LogicSystem::DealChatImgMsg(std::shared_ptr<CSession> session, 
const short& msg_id, const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);

auto uid = root["fromuid"].asInt();
auto touid = root["touid"].asInt();

auto md5 = root["md5"].asString();
auto unique_name = root["name"].asString();
auto token = root["token"].asString();
auto unique_id = root["unique_id"].asString();
auto chat_time = root["chat_time"].asString();
auto status = root["status"].asInt();

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;

rtvalue["fromuid"] = uid;
rtvalue["touid"] = touid;
auto thread_id = root["thread_id"].asInt();
rtvalue["thread_id"] = thread_id;
rtvalue["md5"] = md5;
rtvalue["unique_name"] = unique_name;
rtvalue["unique_id"] = unique_id;
rtvalue["chat_time"] = chat_time;
rtvalue["status"] = status;

auto timestamp = getCurrentTimestamp();
auto chat_msg = std::make_shared<ChatMessage>();
chat_msg->chat_time = timestamp;
chat_msg->sender_id = uid;
chat_msg->recv_id = touid;
chat_msg->unique_id = unique_id;
chat_msg->thread_id = thread_id;
chat_msg->content = unique_name;
chat_msg->status = MsgStatus::UN_UPLOAD;


//插入数据库
MysqlMgr::GetInstance()->AddChatMsg(chat_msg);

Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_IMG_CHAT_MSG_RSP);
});

//发送通知 todo... 以后等文件上传完成再通知
}

聊天资源断点续传

image-20251114211632712

之前我们实现了1和2,接下来在客户端Client收到ChatServer的回复消息2后,需要执行步骤3

这期间要在客户端和服务器之间实现断点续传。

发送资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
_handlers.insert(ID_IMG_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive create private chat rsp Success";

//收到消息后转发给页面
auto thread_id = jsonObj["thread_id"].toInt();
auto unique_id = jsonObj["unique_id"].toString();
auto unique_name = jsonObj["unique_name"].toString();

auto sender = jsonObj["fromuid"].toInt();
auto msg_id = jsonObj["message_id"].toInt();
QString chat_time = jsonObj["chat_time"].toString();
int status = jsonObj["status"].toInt();
auto text_or_url = jsonObj["text_or_url"].toString();

auto file_info = UserMgr::GetInstance()->GetTransFileByName(unique_name);

auto chat_data = std::make_shared<ImgChatData>(file_info, unique_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, sender, status, chat_time);

//发送信号通知界面
emit sig_chat_img_rsp(thread_id, chat_data);

//创建QFileInfo 对象 todo 留作以后收到服务器返回消息后再发送
QFile file(file_info->_text_or_url);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

file.seek(file_info->_current_size);
auto buffer = file.read(MAX_FILE_LEN);
qDebug() << "buffer is " << buffer;
//将文件内容转换为base64编码
QString base64Data = buffer.toBase64();
QJsonObject file_obj;
file_obj["name"] = file_info->_unique_name;
file_obj["unique_id"] = unique_id;
file_obj["seq"] = file_info->_seq;
file_info->_current_size = buffer.size() + (file_info->_seq - 1) * MAX_FILE_LEN;
file_obj["trans_size"] = file_info->_current_size;
file_obj["total_size"] = file_info->_total_size;
file_obj["token"] = UserMgr::GetInstance()->GetToken();
file_obj["md5"] = file_info->_md5;
file_obj["uid"] = UserMgr::GetInstance()->GetUid();
file_obj["data"] = base64Data;

if (buffer.size() + (file_info->_seq - 1) * MAX_FILE_LEN >= file_info->_total_size) {
file_obj["last"] = 1;
}
else {
file_obj["last"] = 0;
}

//发送文件 todo 留作以后收到服务器返回消息后再发送
QJsonDocument doc_file(file_obj);
QByteArray fileData = doc_file.toJson(QJsonDocument::Compact);

//发送消息给ResourceServer
FileTcpMgr::GetInstance()->SendData(ReqId::ID_IMG_CHAT_UPLOAD_REQ, fileData);

});

我们的客户端在收到服务器的回复(步骤2)之后,立刻读取文件,发送第一个包,这里通过FileTcpMgr发送资源给ResourceServer

资源服务器存储

我们实现断点续传,在LogicWorker中注册处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
void LogicWorker::RegisterCallBacks()
{
_fun_callbacks[ID_IMG_CHAT_UPLOAD_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto uid = root["uid"].asInt();
//转化为字符串
auto uid_str = std::to_string(uid);
auto file_path_str = (file_path / uid_str / name).string();
Json::Value rtvalue;

auto callback = [=](const Json::Value& result) {

// 在异步任务完成后调用
Json::Value rtvalue = result;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
rtvalue["uid"] = uid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_IMG_CHAT_UPLOAD_RSP);
};

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}
else {
auto file_info = RedisMgr::GetInstance()->GetFileInfo(name);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, ID_IMG_CHAT_UPLOAD_REQ, uid, file_path_str, name, seq, total_size,
trans_size, last, file_data, callback),
index
);
};
}
  1. 通过callback存储回调函数,一同包裹进FileInfo, 回调函数通过=捕获所有局部变量,这样构造了一个伪闭包。
  2. 我们将包裹好的FileInfo投递到FileSystem中,交给其中的线程池,由多个FileWorker线程处理

我们跟进FileSystem的投递逻辑

1
2
3
4
void FileSystem::PostMsgToQue(shared_ptr<FileTask> msg, int index)
{
_file_workers[index]->PostTask(msg);
}

FileWorker投递逻辑

1
2
3
4
5
6
7
8
9
10
11
12
void FileWorker::PostTask(std::shared_ptr<FileTask> task)
{
{
std::lock_guard<std::mutex> lock(_mtx);
//借鉴python万物皆对象思想,构造伪闭包将函数对象扔到队列中
_task_que.push([task, this]() {
task_callback(task);
});
}

_cv.notify_one();
}

我们将任务直接包裹到一个lambda表达式中,利用python万物皆对象的思想,结合C++语法,将这个可调用对象投递给队列。

将来可调用对象从队列取出后就会调用这个lambda表达式, 进而调用task_callback函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
FileWorker::FileWorker() :_b_stop(false)
{
RegisterHandlers();
_work_thread = std::thread([this]() {
while (!_b_stop) {
std::unique_lock<std::mutex> lock(_mtx);
_cv.wait(lock, [this]() {
if (_b_stop) {
return true;
}

if (_task_que.empty()) {
return false;
}

return true;
});

if (_b_stop) {
break;
}

auto task_call = _task_que.front();
_task_que.pop();
task_call();
}

});
}

调用逻辑

1
2
3
4
5
6
7
8
9
void FileWorker::task_callback(std::shared_ptr<FileTask> task)
{
auto iter = _handlers.find(task->_msg_id);
if (iter == _handlers.end()) {
return;
}

iter->second(task);
}

从_handlers中根据消息id检索,取出回调函数,传入task参数调用

_handlers的注册逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
void FileWorker::RegisterHandlers()
{
//处理头像上传
_handlers[ID_UPLOAD_HEAD_ICON_REQ] = [this](std::shared_ptr<FileTask> task) {
// 解码
std::string decoded = base64_decode(task->_file_data);

auto file_path_str = task->_path;
auto last = task->_last;
//std::cout << "file_path_str is " << file_path_str << std::endl;

boost::filesystem::path file_path(file_path_str);
boost::filesystem::path dir_path = file_path.parent_path();
// 获取完整文件名(包含扩展名)
std::string filename = file_path.filename().string();
Json::Value result;
result["error"] = ErrorCodes::Success;

// Check if directory exists, if not, create it
if (!boost::filesystem::exists(dir_path)) {
if (!boost::filesystem::create_directories(dir_path)) {
std::cerr << "Failed to create directory: " << dir_path.string() << std::endl;
result["error"] = ErrorCodes::FileNotExists;
task->_callback(result);
return;
}
}


std::ofstream outfile;
//第一个包
if (task->_seq == 1) {
// 打开文件,如果存在则清空,不存在则创建
outfile.open(file_path_str, std::ios::binary | std::ios::trunc);
}
else {
// 保存为文件
outfile.open(file_path_str, std::ios::binary | std::ios::app);
}


if (!outfile) {
std::cerr << "无法打开文件进行写入。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.write(decoded.data(), decoded.size());
if (!outfile) {
std::cerr << "写入文件失败。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.close();
if (last) {
std::cout << "文件已成功保存为: " << task->_name << std::endl;
//更新头像
MysqlMgr::GetInstance()->UpdateUserIcon(task->_uid, filename);
//获取用户信息
auto user_info = MysqlMgr::GetInstance()->GetUser(task->_uid);
if (user_info == nullptr) {
return;
}

//将数据库内容写入redis缓存
Json::Value redis_root;
redis_root["uid"] = task->_uid;
redis_root["pwd"] = user_info->pwd;
redis_root["name"] = user_info->name;
redis_root["email"] = user_info->email;
redis_root["nick"] = user_info->nick;
redis_root["desc"] = user_info->desc;
redis_root["sex"] = user_info->sex;
redis_root["icon"] = user_info->icon;
std::string base_key = USER_BASE_INFO + std::to_string(task->_uid);
RedisMgr::GetInstance()->Set(base_key, redis_root.toStyledString());
}

if (task->_callback) {
task->_callback(result);
}
};

//处理聊天图片上传
_handlers[ID_IMG_CHAT_UPLOAD_REQ] = [this](std::shared_ptr<FileTask> task) {
// 解码
std::string decoded = base64_decode(task->_file_data);

auto file_path_str = task->_path;
auto last = task->_last;
//std::cout << "file_path_str is " << file_path_str << std::endl;

boost::filesystem::path file_path(file_path_str);
boost::filesystem::path dir_path = file_path.parent_path();
// 获取完整文件名(包含扩展名)
std::string filename = file_path.filename().string();
Json::Value result;
result["error"] = ErrorCodes::Success;

// Check if directory exists, if not, create it
if (!boost::filesystem::exists(dir_path)) {
if (!boost::filesystem::create_directories(dir_path)) {
std::cerr << "Failed to create directory: " << dir_path.string() << std::endl;
result["error"] = ErrorCodes::FileNotExists;
task->_callback(result);
return;
}
}


std::ofstream outfile;
//第一个包
if (task->_seq == 1) {
// 打开文件,如果存在则清空,不存在则创建
outfile.open(file_path_str, std::ios::binary | std::ios::trunc);
}
else {
// 保存为文件
outfile.open(file_path_str, std::ios::binary | std::ios::app);
}


if (!outfile) {
std::cerr << "无法打开文件进行写入。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.write(decoded.data(), decoded.size());
if (!outfile) {
std::cerr << "写入文件失败。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.close();
if (last) {
std::cout << "文件已成功保存为: " << task->_name << std::endl;
//todo...更新数据库聊天图像上传状态
//通过grpc通知ChatServer
}

if (task->_callback) {
task->_callback(result);
}
};
}

比如是聊天图片上传的请求,就调用如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
_handlers[ID_IMG_CHAT_UPLOAD_REQ] = [this](std::shared_ptr<FileTask> task) {
// 解码
std::string decoded = base64_decode(task->_file_data);

auto file_path_str = task->_path;
auto last = task->_last;
//std::cout << "file_path_str is " << file_path_str << std::endl;

boost::filesystem::path file_path(file_path_str);
boost::filesystem::path dir_path = file_path.parent_path();
// 获取完整文件名(包含扩展名)
std::string filename = file_path.filename().string();
Json::Value result;
result["error"] = ErrorCodes::Success;

// Check if directory exists, if not, create it
if (!boost::filesystem::exists(dir_path)) {
if (!boost::filesystem::create_directories(dir_path)) {
std::cerr << "Failed to create directory: " << dir_path.string() << std::endl;
result["error"] = ErrorCodes::FileNotExists;
task->_callback(result);
return;
}
}


std::ofstream outfile;
//第一个包
if (task->_seq == 1) {
// 打开文件,如果存在则清空,不存在则创建
outfile.open(file_path_str, std::ios::binary | std::ios::trunc);
}
else {
// 保存为文件
outfile.open(file_path_str, std::ios::binary | std::ios::app);
}


if (!outfile) {
std::cerr << "无法打开文件进行写入。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.write(decoded.data(), decoded.size());
if (!outfile) {
std::cerr << "写入文件失败。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return;
}

outfile.close();
if (last) {
std::cout << "文件已成功保存为: " << task->_name << std::endl;
//todo...更新数据库聊天图像上传状态
//通过grpc通知ChatServer
}

if (task->_callback) {
task->_callback(result);
}
};

在这个逻辑里我们打开文件,并采取追加的方式将数据写入服务器所在的磁盘目录保存

测试效果

image-20251123112409697

image-20251123112343094

结论

  1. 经过测试,可以实现断点续传上传聊天资源的功能
  2. 但是对于大文件,采用串行方式断点续传效率很慢
  3. 考虑搞一个拥塞窗口多序列传输,本质上还是通过网络线程串行上传,但不是等待服务器回复后才上传,而是通过一个拥塞窗口控制发送频率。

设计拥塞窗口提高发送效率

思路分析

客户端发送端单线程还是多线程

本质上客户端如果采用切片的方式将一个文件切割为多个小文件,可以不考虑顺序,将来汇总服务器的回包统计是否传输完成即可。

但是对于同一个socket多线程调用send会产生数据错乱,对于asio这种网络库,我们采用的是发送队列控制顺序,保证互斥性,一个包发送完成再发送下一个。

对于QT其底层封装了发送队列,支持多线程并发调用send,但是本质上底层的发送还是很串行化。

所以对于现有的结构,我们通过跨线程的方式,将要发送的数据投递给FileMgr所在的线程的消息队列,统一发送。

这个结构不用改。

客户端发送逻辑修改

客户端不再等待服务器回包后再发送,而是将切割好的包一次性添加到发送队列。

但是如果文件过大,要几百个包,一次性会堆满队列,另外循环发送几百个包会造成网络拥塞,导致服务器一段时间只为这一个客户端服务,这是不可取的。

拥塞窗口设计

为了解决这个问题,我们可以优先将要发送的数据放入拥塞窗口,处于拥塞窗口的数据优先发送

其余的数据投递到队列中。

如果文件数据过多,可以优先将一部分数据放入队列,等到队列队列大小缩小后继续放入数据。

当客户端收到服务器回包后,做错误判断,如果无误则从队列取出数据放到拥塞窗口中继续发送。

队列减小到一定阈值后,将文件剩余未发送的包继续填充到队列中。

image-20251123115425162

这么做还要考虑如果发送失败,就要清除队列中该次未发送的数据包。

如果发送两个文件,队列中的数据将会是交叉的。所以对于错误处理,要考虑剔除发送失败的包。

数据结构设计:

1
2
3
4
5
6
7
struct SendTask {
int file_id; // 文件唯一标识
int chunk_id; // 分片序号
int total_chunks; // 总分片数
vector<char> data; // 数据内容
int retry_count; // 重试次数
};

队列管理:

  • 使用map<file_id, queue>区分不同文件的数据包
  • 发送失败时,只清除对应file_id的所有待发送包
  • 维护已发送但未确认的包列表,便于超时重传

服务器逻辑

服务器是多线程还是单线程

服务器可以采用多线程方式处理收到的文件包,可以采用多线程的方式写如文件,但是对于同一个文件要加锁。

本质上同一个时刻只有一个线程可以对文件进行读写。所以干脆就用一个线程负责一个文件的写,可以根据session_id区分不同的连接,对于同一个连接采用同一个FileWorker执行写就可以了。

这样不用加锁还保证线程安全了。

image-20251123123519059

服务器乱序存储

服务器不再用原来的线性方式将内容追加到磁盘上。

而是优先接收客户端的第一个包,获取文件信息,然后按照seq个数创建文件大小,在最后一个字节写入空,这样整个空文件就构造好了。

image-20251123122259919

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

image-20251123122429328

客户端实现拥塞窗口

窗口大小成员

在FileTcpMgr中添加成员变量

1
2
3
4
5
6
class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>,
public std::enable_shared_from_this<FileTcpMgr>{

//发送的拥塞窗口,控制发送数量
int _cwnd_size;
}

封装发送逻辑

1
2
3
4
5
6
7
class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>,
public std::enable_shared_from_this<FileTcpMgr>
{
Q_OBJECT
public:
void BatchSend(std::shared_ptr<MsgInfo> msg_info);
}

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
void FileTcpMgr::BatchSend(std::shared_ptr<MsgInfo> msg_info) {

if ((msg_info->_seq) * MAX_FILE_LEN >= msg_info->_total_size) {
qDebug() << "file has sent finished";
return;
}

if (MAX_CWND_SIZE - _cwnd_size == 0) {
return;
}

//打开
QFile file(msg_info->_text_or_url);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file: " << file.errorString();
return;
}

//文件偏移到已经发送的位置,继续读取发送
file.seek(msg_info->_seq * MAX_FILE_LEN);

bool b_last = false;
//再次组织数据发送
for (; MAX_CWND_SIZE - _cwnd_size > 0; ) {

QByteArray buffer;
msg_info->_seq++;
//放入发送未回包集合
msg_info->_flighting_seqs.insert(msg_info->_seq);
//每次读取MAX_FILE_LEN字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject sendObj;
//将文件内容转换为base64编码
QString base64Data = buffer.toBase64();
sendObj["md5"] = msg_info->_md5;
sendObj["name"] = msg_info->_unique_name;
sendObj["seq"] = msg_info->_seq;
sendObj["trans_size"] = buffer.size() + (msg_info->_seq - 1) * MAX_FILE_LEN;
sendObj["total_size"] = msg_info->_total_size;

b_last = false;
if (buffer.size() + (msg_info->_seq - 1) * MAX_FILE_LEN >= msg_info->_total_size) {
sendObj["last"] = 1;
b_last = true;
}
else {
sendObj["last"] = 0;
}

sendObj["data"] = base64Data;
sendObj["last_seq"] = msg_info->_max_seq;
sendObj["uid"] = UserMgr::GetInstance()->GetUid();
QJsonDocument doc(sendObj);
auto send_data = doc.toJson();
//直接发送,其实是放入tcpmgr发送队列
SendData(ID_IMG_CHAT_UPLOAD_REQ, send_data);
_cwnd_size++;
//如果
if (b_last) {
break;
}
}

file.close();
}

同步发送信息

考虑以后很多场景都会将发送信息同步给服务器,所以单独抽象了一个发送协议

在TcpMgr收到聊天消息回复后,可以考虑先将图片信息同步给资源服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
_handlers.insert(ID_IMG_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive create private chat rsp Success";

//收到消息后转发给页面
auto thread_id = jsonObj["thread_id"].toInt();
auto unique_id = jsonObj["unique_id"].toString();
auto unique_name = jsonObj["unique_name"].toString();

auto sender = jsonObj["fromuid"].toInt();
auto msg_id = jsonObj["message_id"].toInt();
QString chat_time = jsonObj["chat_time"].toString();
int status = jsonObj["status"].toInt();
auto text_or_url = jsonObj["text_or_url"].toString();

auto file_info = UserMgr::GetInstance()->GetTransFileByName(unique_name);

auto chat_data = std::make_shared<ImgChatData>(file_info, unique_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, sender, status, chat_time);

//发送信号通知界面
emit sig_chat_img_rsp(thread_id, chat_data);

//管理消息,添加序列号到正在发送集合
file_info->_flighting_seqs.insert(file_info->_seq);

//发送消息
QFile file(file_info->_text_or_url);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

file.seek(file_info->_current_size);
auto buffer = file.read(MAX_FILE_LEN);
qDebug() << "buffer is " << buffer;
//将文件内容转换为base64编码
QString base64Data = buffer.toBase64();
QJsonObject file_obj;
file_obj["name"] = file_info->_unique_name;
file_obj["unique_id"] = unique_id;
file_obj["seq"] = file_info->_seq;
file_info->_current_size = buffer.size() + (file_info->_seq - 1) * MAX_FILE_LEN;
file_obj["trans_size"] = file_info->_current_size;
file_obj["total_size"] = file_info->_total_size;
file_obj["token"] = UserMgr::GetInstance()->GetToken();
file_obj["md5"] = file_info->_md5;
file_obj["uid"] = UserMgr::GetInstance()->GetUid();
file_obj["data"] = base64Data;

if (buffer.size() + (file_info->_seq - 1) * MAX_FILE_LEN >= file_info->_total_size) {
file_obj["last"] = 1;
}
else {
file_obj["last"] = 0;
}

//发送文件 todo 留作以后收到服务器返回消息后再发送
QJsonDocument doc_file(file_obj);
QByteArray fileData = doc_file.toJson(QJsonDocument::Compact);

//发送消息给ResourceServer
FileTcpMgr::GetInstance()->SendData(ReqId::ID_FILE_INFO_SYNC_REQ, fileData);

});

处理同步信息回包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
_handlers.insert(ID_FILE_INFO_SYNC_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject recvObj = jsonDoc.object();
qDebug() << "data jsonobj is " << recvObj;

if (!recvObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "icon upload_failed, err is Json Parse Err" << err;
//todo ... 提示上传失败,将来可能断点重传等
//emit upload_failed();
return;
}

int err = recvObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Login Failed, err is " << err;
//emit upload_failed();
return;
}

//为了简单起见,先处理网络正常情况
auto seq = recvObj["seq"].toInt();
auto name = recvObj["name"].toString();

auto file_info = UserMgr::GetInstance()->GetTransFileByName(name);
if (!file_info) {
return;
}

//根据seq从未接收集合移动到已接收集合中
file_info->_flighting_seqs.erase(seq);
//将seq放入已收到集合中
file_info->_rsp_seqs.insert(seq);

//计算当前最后确认的序列号
while (file_info->_rsp_seqs.count(file_info->_last_confirmed_seq + 1)) {
++file_info->_last_confirmed_seq;
}

qDebug() << "recv : " << name << "file seq is " << seq;
//判断最大序列和最后确认序列号相等,说明收全了
if (file_info->_last_confirmed_seq == file_info->_max_seq) {
UserMgr::GetInstance()->RmvTransFileByName(name);
//todo 此处添加发送其他待发送的文件
auto free_file = UserMgr::GetInstance()->GetFreeTransFile();

return;
}

BatchSend(file_info);
});

之后的处理逻辑就和聊天图片上传一样,只是这个是一次上传多个。

有个更好的改进点就是不用等到服务器写完,服务器就回复给客户端,但是逻辑控制更复杂,如果后续写失败,还要回滚之类的,更麻烦。这里还是保留原逻辑,服务器写完就回复,只不过客户端不是等待回复后一个一个发送了,开始的时候是一起发送,用拥塞窗口控制。后续还是会收到限制,因为受限于服务器写,这次就先这样了,以后在考虑做优化。

响应资源回复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
_handlers.insert(ID_IMG_CHAT_UPLOAD_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);
_cwnd_size--;
// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject recvObj = jsonDoc.object();
qDebug() << "data jsonobj is " << recvObj;

if (!recvObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "icon upload_failed, err is Json Parse Err" << err;
//todo ... 提示上传失败
//emit upload_failed();
return;
}

int err = recvObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Login Failed, err is " << err;
//emit upload_failed();
return;
}

auto name = recvObj["name"].toString();
auto file_info = UserMgr::GetInstance()->GetTransFileByName(name);
if (!file_info) {
return;
}

auto md5 = file_info->_md5;
auto seq = recvObj["seq"].toInt();
//根据seq从未接收集合移动到已接收集合中
file_info->_flighting_seqs.erase(seq);
//将seq放入已收到集合中
file_info->_rsp_seqs.insert(seq);
//计算当前最后确认的序列号
while (file_info->_rsp_seqs.count(file_info->_last_confirmed_seq + 1)) {
++file_info->_last_confirmed_seq;
}

qDebug() << "recv : " << name << "file seq is " << seq;
//判断最大序列和最后确认序列号相等,说明收全了
if (file_info->_last_confirmed_seq == file_info->_max_seq) {
UserMgr::GetInstance()->RmvTransFileByName(name);
//todo 此处添加发送其他待发送的文件
auto free_file = UserMgr::GetInstance()->GetFreeTransFile();
BatchSend(free_file);
return;
}

BatchSend(file_info); });

服务器响应同步信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
_fun_callbacks[ID_FILE_INFO_SYNC_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto uid = root["uid"].asInt();
//转化为字符串
auto uid_str = std::to_string(uid);
auto file_path_str = (file_path / uid_str / name).string();
Json::Value rtvalue;

auto callback = [=](const Json::Value& result) {

// 在异步任务完成后调用
Json::Value rtvalue = result;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
rtvalue["uid"] = uid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_FILE_INFO_SYNC_RSP);
};

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_FILE_INFO_SYNC_RSP);
return;
}
}
else {
auto file_info = RedisMgr::GetInstance()->GetFileInfo(name);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_FILE_INFO_SYNC_RSP);
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_FILE_INFO_SYNC_RSP);
return;
}
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, ID_FILE_INFO_SYNC_REQ, uid, file_path_str, name, seq, total_size,
trans_size, last, file_data, callback),
index
);
};

其余逻辑不变。

测试效果

image-20251213174219570

断点续传

Posted on 2025-07-29 | In C++聊天项目

独立网络线程

独立前

image-20250803111220495

独立后

image-20250803111620510

槽函数连接方式

image-20250803114247485

  1. 直接连接

    connect(发送者, 信号, [](信号参数){});

​ 这种槽函数在发送者所在线程触发。

  1. 增加接收者

    connect(发送者,信号,接收者,槽函数)

    如果发送者和接收者在同一个线程,则槽函数调用的线程就是发送者所在的线程。

  2. 发送者和接收者不在一个线程,connect默认采用队列连接方式

​ connect(发送者,信号,接收者,槽函数)

​ 槽函数在接收者所在的线程触发。好处就是解耦合。

元对象系统

  1. 信号和槽
  2. 反射
  3. 动态增加函数和属性

当我们信号和槽连接方式采用队列连接,那么信号的参数会被封装为元对象,投递到队列中。

要想支持元对象有两种方式

  1. 继承于QObject,并且类内填写Q_OBJECT宏
  2. 声明并且注册元对象类

为了支持高并发情况下断点续传,考虑将目前项目中TcpMgr中网络模块独立到独立线程

封装TcpThread类

利用RAII思想封装线程启动和回收

1
2
3
4
5
6
7
class TcpThread:public std::enable_shared_from_this<TcpThread> {
public:
TcpThread();
~TcpThread();
private:
QThread* _tcp_thread;
};

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TcpThread::TcpThread()
{
_tcp_thread = new QThread();
TcpMgr::GetInstance()->moveToThread(_tcp_thread);
QObject::connect(_tcp_thread, &QThread::finished, _tcp_thread, &QObject::deleteLater);

_tcp_thread->start();
}

TcpThread::~TcpThread()
{
_tcp_thread->quit();
}

主函数启动时记得提前启动线程,将TcpMgr转移到独立线程中

1
2
3
4
5
//启动tcp线程
TcpThread tcpthread;
MainWindow w;
w.show();
return a.exec();

测试发现,登录卡住,检测是信号sig_connect_tcp发送了,槽函数slot_tcp_connect没触发。

1
2
//连接tcp连接请求的信号和槽函数
connect(this, &LoginDialog::sig_connect_tcp, TcpMgr::GetInstance().get(), &TcpMgr::slot_tcp_connect);

为了测试

先在TcpMgr中添加测试槽函数

1
2
3
4
void slot_test() {
qDebug() << "receve thread is " << QThread::currentThread();
qDebug() << "slot test......";
}

在LoginDialog中连接信号

1
connect(this, &LoginDialog::sig_test, TcpMgr::GetInstance().get(), &TcpMgr::slot_test);

在发送sig_connect_tcp处发送sig_test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void LoginDialog::initHttpHandlers()
{
//注册获取登录回包逻辑
_handlers.insert(ReqId::ID_LOGIN_USER, [this](QJsonObject jsonObj){
int error = jsonObj["error"].toInt();
if(error != ErrorCodes::SUCCESS){
showTip(tr("参数错误"),false);
enableBtn(true);
return;
}
auto email = jsonObj["email"].toString();

//发送信号通知tcpMgr发送长链接
ServerInfo si;
si.Uid = jsonObj["uid"].toInt();
si.Host = jsonObj["host"].toString();
si.Port = jsonObj["port"].toString();
si.Token = jsonObj["token"].toString();

_uid = si.Uid;
_token = si.Token;
qDebug()<< "email is " << email << " uid is " << si.Uid <<" host is "
<< si.Host << " Port is " << si.Port << " Token is " << si.Token;
emit sig_connect_tcp(si);
emit sig_test();
});
}

测试,是可以看到能触发slot_test函数得,而且线程id显示是子线程中触发得槽函数。

那么sig_connect_tcp信号发出,没有触发槽函数,就是因为信号得参数类型不支持元对象系统。

为了支持元对象系统,我们需要在信号的参数ServerInfo类实现默认构造,同时声明为元对象类型

1
2
3
4
5
6
7
8
9
10
11
struct ServerInfo{
public:
ServerInfo() = default;
ServerInfo(const ServerInfo& other):Host(other.Host),Port(other.Port),Token(other.Token),Uid(other.Uid){}
QString Host;
QString Port;
QString Token;
int Uid;
};

Q_DECLARE_METATYPE(ServerInfo)

在TcpMgr中注册这个元对象类型

1
qRegisterMetaType<ServerInfo>("ServerInfo");

再次测试就通过登录了,但是在发送后续得消息时,又遇到了自定义类型作为参数得情况,我们需要和上面一样,依次声明元对象类型并且注册。

如下列举一个,还有很多,不再详细列举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SearchInfo {
public:
SearchInfo(int uid, QString name, QString nick, QString desc, int sex, QString icon);
SearchInfo() = default;
int _uid;
QString _name;
QString _nick;
QString _desc;
int _sex;
QString _icon;
};

Q_DECLARE_METATYPE(SearchInfo)
Q_DECLARE_METATYPE(std::shared_ptr<SearchInfo>)

TcpMgr封装注册元对象函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void TcpMgr::registerMetaType() {
// 注册所有自定义类型
qRegisterMetaType<ServerInfo>("ServerInfo");
qRegisterMetaType<SearchInfo>("SearchInfo");
qRegisterMetaType<std::shared_ptr<SearchInfo>>("std::shared_ptr<SearchInfo>");

qRegisterMetaType<AddFriendApply>("AddFriendApply");
qRegisterMetaType<std::shared_ptr<AddFriendApply>>("std::shared_ptr<AddFriendApply>");

qRegisterMetaType<ApplyInfo>("ApplyInfo");

qRegisterMetaType<std::shared_ptr<AuthInfo>>("std::shared_ptr<AuthInfo>");

qRegisterMetaType<AuthRsp>("AuthRsp");
qRegisterMetaType<std::shared_ptr<AuthRsp>>("std::shared_ptr<AuthRsp>");

qRegisterMetaType<UserInfo>("UserInfo");

qRegisterMetaType<std::vector<std::shared_ptr<TextChatData>>>("std::vector<std::shared_ptr<TextChatData>>");

qRegisterMetaType<std::vector<std::shared_ptr<ChatThreadInfo>>>("std::vector<std::shared_ptr<ChatThreadInfo>>");

qRegisterMetaType<std::shared_ptr<ChatThreadData>>("std::shared_ptr<ChatThreadData>");
qRegisterMetaType<ReqId>("ReqId");
}

在构造函数中调用

1
2
3
4
5
TcpMgr::TcpMgr():_host(""),_port(0),_b_recv_pending(false),_message_id(0),_message_len(0)
{
registerMetaType();
//...
}

再次测试就通过了

这里给大家讲讲为什么单线程情况下,信号可以携带自定义类型作为参数,不用设定元对象就可以传输,而跨线程不可以。

在 Qt 的信号/槽机制中,信号参数的传递方式取决于连接(connect)的类型,而连接类型又由发信号对象和接收槽对象所在的线程决定:

  1. 同线程(Direct Connection)

    • 如果信号和槽都在同一个线程里,默认使用 Direct Connection。
    • Direct Connection 本质上就是一个普通的 C++ 函数调用,参数直接按值或按引用传递,编译时就已经知道了类型,不需要任何额外的元类型信息。
    • 因此,即使你没有把 SearchInfo 注册为 QMetaType,编译器也能直接生成函数调用代码,信号里就可以直接传递 SearchInfo。
  2. 跨线程(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
2
3
4
5
std::shared_ptr<UserInfo> UserMgr::GetUserInfo()
{
std::lock_guard<std::mutex> lock(_mtx);
return _user_info;
}

还有很多不再赘述

设置发送队列

默认情况下qt的socket都是非阻塞的。

所以调用socket.write(数据)可能会直接返回-1

返回-1表示网络出错,一般都是EWOULD_BLOCK/EAGAIN造成的。表示发送缓冲区已经满了,无法继续发送。

而我们之前的逻辑,无论在哪个线程,想要发送数据,统一发送信号

1
void sig_send_data(ReqId reqId, QByteArray data);

会触发TcpMgr的槽函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void TcpMgr::slot_send_data(ReqId reqId, QByteArray dataBytes)
{
uint16_t id = reqId;

// 计算长度(使用网络字节序转换)
quint16 len = static_cast<quint16>(dataBytes.length());

// 创建一个QByteArray用于存储要发送的所有数据
QByteArray block;
QDataStream out(&block, QIODevice::WriteOnly);

// 设置数据流使用网络字节序
out.setByteOrder(QDataStream::BigEndian);

// 写入ID和长度
out << id << len;

// 添加字符串数据
block.append(dataBytes);


qint64 written = _socket.write(block);
qDebug() << "tcp mgr send byte data is" << _current_block
<< ", write() returned" << written;
}

上述函数在网络情况良好的时候不会产生问题,但是如果网络发送情况频繁的时候,就容易出现written为-1的情况。

也就是发送缓冲区满了,导致发送失败。

对于这种情况,我们可以模仿我们的服务器写法,添加一个发送队列,然后将要发送的数据投递到发送队列

1
2
3
4
5
6
7
8
//发送队列
QQueue<QByteArray> _send_queue;
//正在发送的包
QByteArray _current_block;
//当前已发送的字节数
qint64 _bytes_sent;
//是否正在发送
bool _pending;

修改发送逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void TcpMgr::slot_send_data(ReqId reqId, QByteArray dataBytes)
{
uint16_t id = reqId;

// 计算长度(使用网络字节序转换)
quint16 len = static_cast<quint16>(dataBytes.length());

// 创建一个QByteArray用于存储要发送的所有数据
QByteArray block;
QDataStream out(&block, QIODevice::WriteOnly);

// 设置数据流使用网络字节序
out.setByteOrder(QDataStream::BigEndian);

// 写入ID和长度
out << id << len;

// 添加字符串数据
block.append(dataBytes);

//判断是否正在发送
if (_pending) {
//放入队列直接返回,因为目前有数据正在发送
_send_queue.enqueue(block);
return;
}

// 没有正在发送,把这包设为“当前块”,重置计数,并写出去
_current_block = block; // ← 保存当前正在发送的 block
_bytes_sent = 0; // ← 归零
_pending = true; // ← 标记正在发送

qint64 written = _socket.write(_current_block);
qDebug() << "tcp mgr send byte data is" << _current_block
<< ", write() returned" << written;
}

我们需要监听发送返回的数据,QT也提供了类似于asio的异步回调功能,只是在发送完成后返回一个信号void bytesWritten(qint64 bytes);

我们连接这个信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
QObject::connect(&_socket, &QTcpSocket::bytesWritten, this, [this](qint64 bytes) {
//更新发送数据
_bytes_sent += bytes;
//未发送完整
if (_bytes_sent < _current_block.size()) {
//继续发送
auto data_to_send = _current_block.mid(_bytes_sent);
_socket.write(data_to_send);
return;
}

//发送完全,则查看队列是否为空
if (_send_queue.isEmpty()) {
//队列为空,说明已经将所有数据发送完成,将pending设置为false,这样后续要发送数据时可以继续发送
_current_block.clear();
_pending = false;
_bytes_sent = 0;
return;
}

//队列不为空,则取出队首元素
_current_block = _send_queue.dequeue();
_bytes_sent = 0;
_pending = true;
qint64 w2 = _socket.write(_current_block);
qDebug() << "[TcpMgr] Dequeued and write() returned" << w2;
});

_pending控制发送还是放入队列。

断点续传思路

思路图

image-20250810113255940

修改上传逻辑

原来的传输逻辑,采用的是循环上传,就是将一个文件拆分成多个报文段,循环上传,而不等待服务器每次回复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void MainWindow::on_uploadBtn_clicked()
{
ui->uploadBtn->setEnabled(false);
// 打开文件
QFile file(_file_name);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

// 保存当前文件指针位置
qint64 originalPos = file.pos();
QCryptographicHash hash(QCryptographicHash::Md5);
if (!hash.addData(&file)) {
qWarning() << "Failed to read data from file:" << _file_name;
return ;
}

_file_md5 = hash.result().toHex(); // 返回十六进制字符串

// 读取文件内容并发送
QByteArray buffer;
int seq = 0;

QFileInfo fileInfo(_file_name); // 创建 QFileInfo 对象

QString fileName = fileInfo.fileName(); // 获取文件名
qDebug() << "文件名是:" << fileName; // 输出文件名
int total_size = fileInfo.size();
int last_seq = 0;
if(total_size % MAX_FILE_LEN){
last_seq = (total_size/MAX_FILE_LEN)+1;
}else{
last_seq = total_size/MAX_FILE_LEN;
}

// 恢复文件指针到原来的位置
file.seek(originalPos);

while (!file.atEnd()) {
//每次读取2048字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject jsonObj;
// 将文件内容转换为 Base64 编码(可选)
QString base64Data = buffer.toBase64();
//qDebug() << "send data is " << base64Data;
++seq;
jsonObj["md5"] = _file_md5;
jsonObj["name"] = fileName;
jsonObj["seq"] = seq;
jsonObj["trans_size"] = buffer.size() + (seq-1)*MAX_FILE_LEN;
jsonObj["total_size"] = total_size;

if(buffer.size() + (seq-1)*MAX_FILE_LEN == total_size){
jsonObj["last"] = 1;
}else{
jsonObj["last"] = 0;
}

jsonObj["data"]= base64Data;
jsonObj["last_seq"] = last_seq;
QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
TcpClient::Inst().sendMsg(ID_UPLOAD_FILE_REQ, send_data);
//startDelay(500);
}

//关闭文件
file.close();

}

现在需要改为分段上传,每次上传后,等待服务器返回响应后再上传下一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void MainWindow::on_uploadBtn_clicked()
{
ui->uploadBtn->setEnabled(false);
ui->pauseBtn->setEnabled(true);
// 打开文件
QFile file(_file_name);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

// 保存当前文件指针位置
qint64 originalPos = file.pos();
QCryptographicHash hash(QCryptographicHash::Md5);
if (!hash.addData(&file)) {
qWarning() << "Failed to read data from file:" << _file_name;
return ;
}

_file_md5 = hash.result().toHex(); // 返回十六进制字符串

// 读取文件内容并发送
QByteArray buffer;
int seq = 0;

// 创建 QFileInfo 对象
auto fileInfo = std::make_shared<QFileInfo>(_file_name);

QString fileName = fileInfo->fileName(); // 获取文件名
qDebug() << "文件名是:" << fileName; // 输出文件名
int total_size = fileInfo->size();
int last_seq = 0;
if(total_size % MAX_FILE_LEN){
last_seq = (total_size/MAX_FILE_LEN)+1;
}else{
last_seq = total_size/MAX_FILE_LEN;
}

// 恢复文件指针到原来的位置
file.seek(originalPos);

//改为读取第一块并发送
//每次读取2048字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject jsonObj;
// 将文件内容转换为 Base64 编码(可选)
QString base64Data = buffer.toBase64();
//qDebug() << "send data is " << base64Data;
++seq;
jsonObj["md5"] = _file_md5;
jsonObj["name"] = fileName;
jsonObj["seq"] = seq;
jsonObj["trans_size"] = buffer.size() + (seq-1)*MAX_FILE_LEN;
jsonObj["total_size"] = total_size;

if(buffer.size() + (seq-1)*MAX_FILE_LEN == total_size){
jsonObj["last"] = 1;
}else{
jsonObj["last"] = 0;
}

jsonObj["data"]= base64Data;
jsonObj["last_seq"] = last_seq;
QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
TcpClient::Inst().sendMsg(ID_UPLOAD_FILE_REQ, send_data);
LogicMgr::Inst()->AddMD5File(_file_md5, fileInfo);
//关闭文件
file.close();

}

收到响应后续传

当客户端收到服务器的回包后,解析后传递给LogicMgr, LogicMgr中需要将后续的报文段发送给服务器。我们封装如下逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void LogicWorker::InitHandlers()
{
//注册上传消息
_handlers[ID_UPLOAD_FILE_RSP] = [this](QJsonObject obj){
auto err = obj["error"].toInt();
if(err != RSP_SUCCESS){
qDebug() << "upload msg rsp err is " << err;
return;
}

auto name = obj["name"].toString();
auto total_size = obj["total_size"].toInt();
auto trans_size = obj["trans_size"].toInt();
auto md5 = obj["md5"].toString();
auto seq = obj["seq"].toInt();

qDebug() << "recv : " << name << " file trans_size is " << trans_size;
emit sig_trans_size(trans_size);

//判断trans_size是否和total_size相等
if(total_size == trans_size){
return;
}

auto file_info = LogicMgr::Inst()->GetFileInfo(md5);
if(!file_info){
return;
}
//再次组织数据发送
QFile file(file_info->filePath());
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

//文件偏移到已经发送的位置,继续读取发送
file.seek(trans_size);

if(LogicMgr::Inst()->Pause()){
return ;
}
QByteArray buffer;
seq++;
//每次读取2048字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject jsonObj;
// 将文件内容转换为 Base64 编码(可选)
QString base64Data = buffer.toBase64();

jsonObj["md5"] = md5;
jsonObj["name"] = file_info->fileName();
jsonObj["seq"] = seq;
jsonObj["trans_size"] = buffer.size() + (seq-1)*MAX_FILE_LEN;
jsonObj["total_size"] = total_size;

if(buffer.size() + (seq-1)*MAX_FILE_LEN == total_size){
jsonObj["last"] = 1;
}else{
jsonObj["last"] = 0;
}

jsonObj["data"]= base64Data;
jsonObj["last_seq"] = obj["last_seq"].toInt();
QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
TcpClient::Inst().sendMsg(ID_UPLOAD_FILE_REQ, send_data);

file.close();
};

}

其中sig_trans_size为信号,通知主界面显示进度

暂停和续传

客户端需增加暂停和续传按钮,支持传说过程中暂停,点击后再继续上传等功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void MainWindow::slot_pause_continue()
{
//续传状态或者初始状态,按下暂停按钮
if(_cur_state == INIT || _cur_state == CONTINUE){
//设置当前状态为暂停状态
_b_pause = true;
ui->pauseBtn->setText("继续");
_cur_state = PAUSE;
LogicMgr::Inst()->SetPause(true);
return;
}

//判断当前为暂停状态,则点击后开启续传
if(_cur_state == PAUSE){
_b_pause = false;
ui->pauseBtn->setText("暂停");
_cur_state = CONTINUE ;
LogicMgr::Inst()->SetPause(false);
//发送请求获取文件信息,继续上传
auto file_info = LogicMgr::Inst()->GetFileInfo(_file_md5);

QJsonObject jsonObj;

jsonObj["md5"] = _file_md5;

QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
TcpClient::Inst().sendMsg(ID_SYNC_FILE_REQ, send_data);

return;
}
}

这里继续上传需要请求一下服务器,同步之前的上传进度。

我们添加了新的协议ID_SYNC_FILE_REQ, 服务器收到后将状态和进度返回,客户端响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
_handlers[ID_SYNC_FILE_RSP] = [this](QJsonObject obj){
auto err = obj["error"].toInt();
if(err != RSP_SUCCESS){
qDebug() << " msg rsp err is " << err;
return;
}

auto md5 = obj["md5"].toString();
auto seq = obj["seq"].toInt();
auto total_size = obj["total_size"].toInt();

auto file_info = LogicMgr::Inst()->GetFileInfo(md5);
if(!file_info){
qDebug() << "not found file" ;
return;
}

//再次组织数据发送
QFile file(file_info->filePath());
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Could not open file:" << file.errorString();
return;
}

auto trans_size = obj["trans_size"].toInt();

//文件偏移到已经发送的位置,继续读取发送
file.seek(trans_size);

if(LogicMgr::Inst()->Pause()){
return ;
}
QByteArray buffer;
seq++;
//每次读取2048字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject jsonObj;
// 将文件内容转换为 Base64 编码(可选)
QString base64Data = buffer.toBase64();

jsonObj["md5"] = md5;
jsonObj["name"] = file_info->fileName();
jsonObj["seq"] = seq;
jsonObj["trans_size"] = buffer.size() + (seq-1)*MAX_FILE_LEN;
jsonObj["total_size"] = total_size;

if(buffer.size() + (seq-1)*MAX_FILE_LEN == total_size){
jsonObj["last"] = 1;
}else{
jsonObj["last"] = 0;
}

jsonObj["data"]= base64Data;
jsonObj["last_seq"] = obj["last_seq"].toInt();
QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
TcpClient::Inst().sendMsg(ID_UPLOAD_FILE_REQ, send_data);

file.close();
};

客户端根据返回的进度,按照偏移量读取指定文件,并且继续上报。

如果健壮一点,可以判断服务器返回的错误信息,根据错误,提示主界面做出交互显示等。这里不再赘述。

到此客户端设计完成。

单线程服务器改造

单线程服务器改造不大,只需要增加同步文件进度信息的处理逻辑,以及优化之前的上传处理逻辑即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
 _fun_callbacks[ID_UPLOAD_FILE_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto data = root["data"].asString();
//std::cout << "recv file data is " << data << std::endl;

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_FILE_RSP);
});

// 解码
std::string decoded = base64_decode(data);

auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto file_path_str = (file_path / name).string();
std::cout << "file_path_str is " << file_path_str << std::endl;

if (seq != 1) {
auto iter = _map_md5_files.find(md5);
if (iter == _map_md5_files.end()) {
rtvalue["error"] = ErrorCodes::FileNotExists;
return;
}
}


std::ofstream outfile;
//第一个包
if (seq == 1) {
// 打开文件,如果存在则清空,不存在则创建
outfile.open(file_path_str, std::ios::binary | std::ios::trunc);
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
std::lock_guard<std::mutex> lock(_file_mtx);
_map_md5_files[md5] = file_info;
}
else {
// 保存为文件
outfile.open(file_path_str, std::ios::binary | std::ios::app);
std::lock_guard<std::mutex> lock(_file_mtx);
auto file_info = _map_md5_files[md5];
file_info->_seq = seq;
file_info->_trans_size = trans_size;
}

if (!outfile) {
std::cerr << "无法打开文件进行写入。" << std::endl;
return ;
}

outfile.write(decoded.data(), decoded.size());
if (!outfile) {
std::cerr << "写入文件失败。" << std::endl;
return ;
}

outfile.close();
std::cout << "文件已成功保存为: " << name << std::endl;

rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["md5"] = md5;
};


_fun_callbacks[ID_SYNC_FILE_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {

Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_SYNC_FILE_RSP);
});

auto md5 = root["md5"].asString();

auto iter = _map_md5_files.find(md5);
if (iter == _map_md5_files.end()) {
rtvalue["error"] = ErrorCodes::FileNotExists;
return;
}

rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = iter->second->_total_size;
rtvalue["seq"] = iter->second->_seq;
rtvalue["name"] = iter->second->_name;
rtvalue["trans_size"] = iter->second->_trans_size;
rtvalue["md5"] = md5;

};

多线程服务器

多线程服务器改造和单线程类似

只不过将处理逻辑放入LogicWorker中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
void LogicWorker::RegisterCallBacks()
{
_fun_callbacks[ID_TEST_MSG_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto data = root["data"].asString();
std::cout << "recv test data is " << data << std::endl;

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_TEST_MSG_RSP);
});

rtvalue["error"] = ErrorCodes::Success;
rtvalue["data"] = data;
};

_fun_callbacks[ID_UPLOAD_FILE_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto file_path_str = (file_path / name).string();
Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_FILE_RSP);
});

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
LogicSystem::GetInstance()->AddMD5File(md5, file_info);
}
else {
auto file_info = LogicSystem::GetInstance()->GetFileInfo(md5);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, name, seq, total_size,
trans_size, last, file_data),
index
);

rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
};



_fun_callbacks[ID_SYNC_FILE_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {

Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_SYNC_FILE_RSP);
});

auto md5 = root["md5"].asString();

auto file = LogicSystem::GetInstance()->GetFileInfo(md5);
if (file == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
return;
}

rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = file->_total_size;
rtvalue["seq"] = file->_seq;
rtvalue["name"] = file->_name;
rtvalue["trans_size"] = file->_trans_size;
rtvalue["md5"] = md5;

};
}

将进度信息存储在LogicSystem中,后续可参考填写入redis,方便后续分布式扩展,注意如果填写了多个资源服务器,还有写入服务器信息,这个不再赘述和进阶,我们只用一个资源服务器做演示,后续读者可自己进阶分布式设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void LogicSystem::AddMD5File(std::string md5, std::shared_ptr<FileInfo> fileinfo) {
std::lock_guard<std::mutex> lock(_file_mtx);
_map_md5_files[md5] = fileinfo;
}

std::shared_ptr<FileInfo> LogicSystem::GetFileInfo(std::string md5) {
std::lock_guard<std::mutex> lock(_file_mtx);
auto iter = _map_md5_files.find(md5);
if (iter == _map_md5_files.end()) {
return nullptr;
}

return iter->second;
}

集成资源服务器

新架构形式

集成资源服务器后的架构为

image-20250905105415106

将上述多线程服务器,整合到项目目录,同时设置资源属性表,复用之前的就可以了。

注意资源服务器配置要稍作修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[GateServer]
Port = 8080
[VarifyServer]
Host = 127.0.0.1
Port = 50051
[StatusServer]
Host = 127.0.0.1
Port = 50052
[SelfServer]
Name = reserver
Host = 0.0.0.0
Port = 9090
RPCPort = 51055
[Mysql]
Host = 81.68.86.146
Port = 3308
User = root
Passwd = 123456.
Schema = llfc
[Redis]
Host = 81.68.86.146
Port = 6380
Passwd = 123456

[Static]
Path = static
[Output]
Path = bin

客户端新增资源网络类

构造函数解析

因为客户端需要长连接资源服务器,采用TCP方式上传文件,所以需要封装一个单例的FileTcpMgr类,用于上传资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class FileTcpMgr : public QObject, public Singleton<FileTcpMgr>,
public std::enable_shared_from_this<FileTcpMgr>
{
Q_OBJECT
public:
friend class Singleton<FileTcpMgr>;
~FileTcpMgr();
void SendData(ReqId reqId, QByteArray data);
void CloseConnection();
private:
void initHandlers();
explicit FileTcpMgr(QObject *parent = nullptr);

void registerMetaType();
void handleMsg(ReqId id, int len, QByteArray data);

QTcpSocket _socket;
QString _host;
uint16_t _port;
QByteArray _buffer;
bool _b_recv_pending;
quint16 _message_id;
quint32 _message_len;
QMap<ReqId, std::function<void(ReqId id, int len, QByteArray data)>> _handlers;
//发送队列
QQueue<QByteArray> _send_queue;
//正在发送的包
QByteArray _current_block;
//当前已发送的字节数
qint64 _bytes_sent;
//是否正在发送
bool _pending;
signals:
void sig_send_data(ReqId reqId, QByteArray data);
void sig_con_success(bool bsuccess);
void sig_connection_closed();
public slots:
void slot_send_data(ReqId reqId, QByteArray data);
void slot_tcp_connect(std::shared_ptr<ServerInfo> si);
};

构造函数具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
FileTcpMgr::FileTcpMgr(QObject *parent) : QObject(parent),
_host(""), _port(0), _b_recv_pending(false), _message_id(0), _message_len(0), _bytes_sent(0), _pending(false)
{
registerMetaType();
QObject::connect(&_socket, &QTcpSocket::connected, this, [&]() {
qDebug() << "Connected to server!";
emit sig_con_success(true);
});


QObject::connect(&_socket, &QTcpSocket::readyRead, this, [&]() {
// 当有数据可读时,读取所有数据
// 读取所有数据并追加到缓冲区
_buffer.append(_socket.readAll());

QDataStream stream(&_buffer, QIODevice::ReadOnly);
stream.setVersion(QDataStream::Qt_5_0);

forever {
//先解析头部
if(!_b_recv_pending){
// 检查缓冲区中的数据是否足够解析出一个消息头(消息ID + 消息长度)
if (_buffer.size() < FILE_UPLOAD_HEAD_LEN) {
return; // 数据不够,等待更多数据
}

// 预读取消息ID和消息长度,但不从缓冲区中移除
stream >> _message_id >> _message_len;

//将buffer 中的前六个字节移除
_buffer = _buffer.mid(FILE_UPLOAD_HEAD_LEN);

// 输出读取的数据
qDebug() << "Message ID:" << _message_id << ", Length:" << _message_len;

}

//buffer剩余长读是否满足消息体长度,不满足则退出继续等待接受
if(_buffer.size() < _message_len){
_b_recv_pending = true;
return;
}

_b_recv_pending = false;
// 读取消息体
QByteArray messageBody = _buffer.mid(0, _message_len);
qDebug() << "receive body msg is " << messageBody ;

_buffer = _buffer.mid(_message_len);
handleMsg(ReqId(_message_id),_message_len, messageBody);
}

});


//5.15 之后版本
// QObject::connect(&_socket, QOverload<QAbstractSocket::SocketError>::of(&QTcpSocket::errorOccurred), [&](QAbstractSocket::SocketError socketError) {
// Q_UNUSED(socketError)
// qDebug() << "Error:" << _socket.errorString();
// });

// 处理错误(适用于Qt 5.15之前的版本)
QObject::connect(&_socket, static_cast<void (QTcpSocket::*)(QTcpSocket::SocketError)>(&QTcpSocket::error),
this,
[&](QTcpSocket::SocketError socketError) {
qDebug() << "Error:" << _socket.errorString() ;
//todo... 根据错误类型做不同的处理
switch (socketError) {
case QTcpSocket::ConnectionRefusedError:
qDebug() << "Connection Refused!";
emit sig_con_success(false);
break;
case QTcpSocket::RemoteHostClosedError:
qDebug() << "Remote Host Closed Connection!";
break;
case QTcpSocket::HostNotFoundError:
qDebug() << "Host Not Found!";
emit sig_con_success(false);
break;
case QTcpSocket::SocketTimeoutError:
qDebug() << "Connection Timeout!";
emit sig_con_success(false);
break;
case QTcpSocket::NetworkError:
//qDebug() << "Network Error!";
break;
default:
//qDebug() << "Other Error!";
break;
}
});

// 处理连接断开
QObject::connect(&_socket, &QTcpSocket::disconnected, this,[&]() {
qDebug() << "Disconnected from server.";
emit sig_connection_closed();
});


//连接发送信号用来发送数据
QObject::connect(this, &FileTcpMgr::sig_send_data, this, &FileTcpMgr::slot_send_data);

//连接发送信号
QObject::connect(&_socket, &QTcpSocket::bytesWritten, this, [this](qint64 bytes) {
//更新发送数据
_bytes_sent += bytes;
//未发送完整
if (_bytes_sent < _current_block.size()) {
//继续发送
auto data_to_send = _current_block.mid(_bytes_sent);
_socket.write(data_to_send);
return;
}

//发送完全,则查看队列是否为空
if (_send_queue.isEmpty()) {
//队列为空,说明已经将所有数据发送完成,将pending设置为false,这样后续要发送数据时可以继续发送
_current_block.clear();
_pending = false;
_bytes_sent = 0;
return;
}

//队列不为空,则取出队首元素
_current_block = _send_queue.dequeue();
_bytes_sent = 0;
_pending = true;
qint64 w2 = _socket.write(_current_block);
qDebug() << "[TcpMgr] Dequeued and write() returned" << w2;
});

//连接
QObject::connect(this, &FileTcpMgr::sig_close, this, &FileTcpMgr::slot_tcp_close);
//注册消息
initHandlers();

}

简单描述下上述构造函数做的事情:

  1. 成功连接服务器后,会触发QTcpSocket::connected信号,从而回调lambda表达式,发送sig_con_success信号
  2. 接收服务器传输的数据,会触发QTcpSocket::readyRead信号,从而回调lambda表达式,在这里处理头部信息和包体信息。进行TLV协议解析后回调handleMsg。
  3. 捕获QTcpSocket::SocketError信号,当出错后回调lambda表达式发送信号通知主界面错误。
  4. 捕获连接断开信号QTcpSocket::disconnected,回调lambda表达式,通知主界面连接断开。
  5. 连接发送信号sig_send_data,因为socket在独立线程,不能直接调用发送,所以改为异步发送,触发槽函数slot_send_data
  6. 因为异步发送,可能存在未发送完全的情况,所以我们用QTcpSocket::bytesWritten来检测发送了多少字节,通过lambda表达式回调处理,继续发送数据。
  7. 因为socket被独立为单独线程,所以关闭也不能直接调用close(socket), 需要统一在槽函数中处理。

注册元对象系统的逻辑不再赘述。

连接槽函数

我们实现槽函数slot_tcp_connect用来创建客户端到资源服务器的连接

1
2
3
4
5
6
7
8
9
void FileTcpMgr::slot_tcp_connect(std::shared_ptr<ServerInfo> si)
{
qDebug()<< "receive tcp connect signal";
// 尝试连接到服务器
qDebug() << "Connecting to server...";
_host = si->_res_host;
_port = static_cast<uint16_t>(si->_res_port.toUInt());
_socket.connectToHost(_host, _port);
}

注册处理流程

注册上传头像回调逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
void FileTcpMgr::initHandlers()
{
//todo 接收上传用户头像回复
_handlers.insert(ID_UPLOAD_HEAD_ICON_RSP, [this](ReqId id, int len, QByteArray data){
Q_UNUSED(len);
qDebug()<< "handle id is "<< id ;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if(jsonDoc.isNull()){
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject recvObj = jsonDoc.object();
qDebug()<< "data jsonobj is " << recvObj ;

if(!recvObj.contains("error")){
int err = ErrorCodes::ERR_JSON;
qDebug() << "icon upload_failed, err is Json Parse Err" << err ;
//todo ... 提示上传失败
//emit upload_failed();
return;
}

int err = recvObj["error"].toInt();
if(err != ErrorCodes::SUCCESS){
qDebug() << "Login Failed, err is " << err ;
//emit upload_failed();
return;
}

auto md5 = recvObj["md5"].toString();
auto seq = recvObj["seq"].toInt();
auto trans_size = recvObj["trans_size"].toInt();
auto uid = recvObj["uid"].toInt();
auto total_size = recvObj["total_size"].toInt();
auto name = recvObj["name"].toString();

qDebug() << "recv : " << name << "file trans_size is " << trans_size;
//判断trans_size和total_size相等
if(total_size == trans_size){
return;
}

auto file_info = UserMgr::GetInstance()->GetFileInfoByMD5(md5);
if(!file_info){
return;
}

//再次组织数据发送
QFile file(file_info->filePath());
if(!file.open(QIODevice::ReadOnly)){
qWarning() << "Could not open file: " << file.errorString();
return;
}

//文件偏移到已经发送的位置,继续读取发送
file.seek(trans_size);
QByteArray buffer;
seq ++;
//每次读取2048字节发送
buffer = file.read(MAX_FILE_LEN);
QJsonObject sendObj;
//将文件内容转换为base64编码
QString base64Data = buffer.toBase64();
sendObj["md5"] = md5;
sendObj["name"] = file_info->fileName();
sendObj["seq"] = seq;
sendObj["trans_size"] = buffer.size() + (seq-1)*MAX_FILE_LEN;
sendObj["total_size"] = total_size;

if(buffer.size() + (seq-1)*MAX_FILE_LEN >= total_size){
sendObj["last"] = 1;
}else{
sendObj["last"] = 0;
}

sendObj["data"] = base64Data;
sendObj["last_seq"] = recvObj["last_seq"].toInt();
sendObj["uid"] = uid;
QJsonDocument doc(sendObj);
auto send_data = doc.toJson();
SendData(ID_UPLOAD_HEAD_ICON_REQ, send_data);

file.close();
});

}

独立文件线程

对于上传我们独立到文件上报线程中

1
2
3
4
5
6
7
8
class FileTcpThread: public std::enable_shared_from_this<FileTcpThread>{
public:
FileTcpThread();
~FileTcpThread();
private:
QThread * _file_tcp_thread;

};

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
FileTcpThread::FileTcpThread()
{
_file_tcp_thread = new QThread();
FileTcpMgr::GetInstance()->moveToThread(_file_tcp_thread);
QObject::connect(_file_tcp_thread, &QThread::finished, _file_tcp_thread, &QObject::deleteLater);
_file_tcp_thread->start();
}

FileTcpThread::~FileTcpThread()
{
_file_tcp_thread->quit();
}

主函数调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include "mainwindow.h"
#include <QApplication>
#include <QFile>
#include "global.h"
#include "tcpmgr.h"
#include "filetcpmgr.h"
int main(int argc, char *argv[])
{
QApplication a(argc, argv);

QFile qss(":/style/stylesheet.qss");

if( qss.open(QFile::ReadOnly))
{
qDebug("open success");
QString style = QLatin1String(qss.readAll());
a.setStyleSheet(style);
qss.close();
}else{
qDebug("Open failed");
}


// 获取当前应用程序的路径
QString app_path = QCoreApplication::applicationDirPath();
// 拼接文件名
QString fileName = "config.ini";
QString config_path = QDir::toNativeSeparators(app_path +
QDir::separator() + fileName);

QSettings settings(config_path, QSettings::IniFormat);
QString gate_host = settings.value("GateServer/host").toString();
QString gate_port = settings.value("GateServer/port").toString();
gate_url_prefix = "http://"+gate_host+":"+gate_port;

//启动tcp线程
TcpThread tcpthread;
//启动资源网络线程
FileTcpThread file_tcp_thread;
MainWindow w;
w.show();
return a.exec();
}

原来的登录流程稍作修改,连接好ChatServer后,连接ResourceServer, 最后再让用户登录。

服务器逻辑

服务器新增文件上报逻辑处理, 在LogicWorker::RegisterCallBacks中添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
_fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto uid = root["uid"].asInt();
auto token = root["token"].asString();
auto last_seq = root["last_seq"].asInt();
//转化为字符串
auto uid_str = std::to_string(uid);

auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto file_path_str = (file_path / uid_str / name).string();
Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
});

//第一个包校验一下token是否合理
if (seq == 1) {
//从redis获取用户token是否正确
std::string uid_str = std::to_string(uid);
std::string token_key = USERTOKENPREFIX + uid_str;
std::string token_value = "";
bool success = RedisMgr::GetInstance()->Get(token_key, token_value);
if (!success) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return;
}

if (token_value != token) {
rtvalue["error"] = ErrorCodes::TokenInvalid;
return;
}
}

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
LogicSystem::GetInstance()->AddMD5File(md5, file_info);
}
else {
auto file_info = LogicSystem::GetInstance()->GetFileInfo(md5);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, file_path_str, name, seq, total_size,
trans_size, last, file_data),
index
);

rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
rtvalue["uid"] = uid;
rtvalue["last_seq"] = last_seq;
};

源码

https://gitee.com/secondtonone1/llfcchat

效果展示:

上传前

image-20250905145321372

上传后

image-20250905145408921

服务器存储成功

image-20250905145440949

续传信息持久化

增加redis接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool RedisMgr::SetFileInfo(const std::string& md5, std::shared_ptr<FileInfo> file_info)
{
Json::Reader reader;
Json::Value root;
root["file_path_str"] = file_info->_file_path_str;
root["name"] = file_info->_name;
root["seq"] = file_info->_seq;
root["total_size"] = file_info->_total_size;
root["trans_size"] = file_info->_trans_size;
auto file_info_str = root.toStyledString();
auto redis_key = "file_upload_" + md5;
bool success = SetExp(redis_key, file_info_str, 3600);
return success;
}

新增超时设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
bool RedisMgr::SetExp(const std::string& key, const std::string& value, int expire_seconds) {
//执行redis命令行
auto connect = _con_pool->getConnection();
if (connect == nullptr) {
return false;
}
auto reply = (redisReply*)redisCommand(connect, "SETEX %s %d %s", key.c_str(),
expire_seconds,
value.c_str());

if (NULL == reply) {
std::cout << "Execute command [ SETEX " << key << " " << expire_seconds
<< " " << value << " ] failure ! " << std::endl;
_con_pool->returnConnection(connect);
return false;
}

if (!(reply->type == REDIS_REPLY_STATUS &&
(strcmp(reply->str, "OK") == 0 || strcmp(reply->str, "ok") == 0))) {
std::cout << "Execute command [ SETEX " << key << " " << expire_seconds
<< " " << value << " ] failure ! " << std::endl;
freeReplyObject(reply);
_con_pool->returnConnection(connect);
return false;
}

freeReplyObject(reply);
std::cout << "Execute command [ SETEX " << key << " " << expire_seconds
<< " " << value << " ] success ! " << std::endl;
_con_pool->returnConnection(connect);
return true;
}

每次收到上传信息后,更新上传进度到redis中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
_fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto uid = root["uid"].asInt();
auto token = root["token"].asString();
auto last_seq = root["last_seq"].asInt();
//转化为字符串
auto uid_str = std::to_string(uid);

auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto file_path_str = (file_path / uid_str / name).string();
Json::Value rtvalue;
auto callback = [=](const Json::Value& result) {

// 在异步任务完成后调用
Json::Value rtvalue = result;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
rtvalue["uid"] = uid;
rtvalue["last_seq"] = last_seq;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
};

//第一个包校验一下token是否合理
if (seq == 1) {
//从redis获取用户token是否正确
std::string uid_str = std::to_string(uid);
std::string token_key = USERTOKENPREFIX + uid_str;
std::string token_value = "";
bool success = RedisMgr::GetInstance()->Get(token_key, token_value);
if (!success) {
rtvalue["error"] = ErrorCodes::UidInvalid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}

if (token_value != token) {
rtvalue["error"] = ErrorCodes::TokenInvalid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
//LogicSystem::GetInstance()->AddMD5File(md5, file_info);
//改为用redis存储
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}
else {
//auto file_info = LogicSystem::GetInstance()->GetFileInfo(md5);
//改为从redis中加载
auto file_info = RedisMgr::GetInstance()->GetFileInfo(name);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, uid, file_path_str, name, seq, total_size,
trans_size, last, file_data, callback),
index
);

};

资源url更新

因为上头像传资源后,要将资源的路径存储到mysql数据库中,所以我们新增MysqlMgr,这个直接从ChatServer拷贝一份即可。

但是要注意,添加如下函数

1
2
3
4
bool MysqlMgr::UpdateUserIcon(int uid, const std::string& icon) {
return _dao.UpdateHeadInfo(uid, icon);
}

Dao层面实现更新头像逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
bool MysqlDao::UpdateHeadInfo(int uid, const std::string& icon)
{
auto con = pool_->getConnection();
if (!con) {
return false;
}
Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});

auto& conn = con->_con;
try {
std::string update_sql =
"UPDATE user SET icon = ? WHERE uid = ?;";

std::unique_ptr<sql::PreparedStatement> pstmt(conn->prepareStatement(update_sql));
pstmt->setString(1, icon);
pstmt->setInt64(2, uid);

int affected_rows = pstmt->executeUpdate();

// 检查是否有行被更新(可选)
if (affected_rows == 0) {
std::cerr << "No user found with uid: " << uid << std::endl;
return false;
}

return true;
}
catch (sql::SQLException& e) {
std::cerr << "SQLException in UpdateHeadInfo: " << e.what() << std::endl;
return false;
}
return false;
}

封装异步回调

之前我们处理文件上传是异步方式,将要保存的文件投递给消息队列,交给独立线程后台保存。我们没有等待处理完成就直接将消息回传给客户端,这么做不是很好,所以改为异步方式,简单的方式就是通过回调函数处理,或者包装一个future等待。这里考虑保留异步结构,所以还是用回调处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct FileTask {
FileTask(std::shared_ptr<CSession> session, int uid, std::string path, std::string name,
int seq, int total_size, int trans_size, int last,
std::string file_data,
std::function<void(const Json::Value&)> callback) :_session(session), _uid(uid),
_seq(seq), _path(path), _name(name), _total_size(total_size),
_trans_size(trans_size), _last(last), _file_data(file_data), _callback(callback)
{}
~FileTask(){}
std::shared_ptr<CSession> _session;
int _uid;
int _seq ;
std::string _path;
std::string _name ;
int _total_size ;
int _trans_size ;
int _last ;
std::string _file_data;
std::function<void(const Json::Value&)> _callback; //添加回调函数
};

改进后的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
_fun_callbacks[ID_UPLOAD_HEAD_ICON_REQ] = [this](shared_ptr<CSession> session, const short& msg_id,
const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto md5 = root["md5"].asString();
auto seq = root["seq"].asInt();
auto name = root["name"].asString();
auto total_size = root["total_size"].asInt();
auto trans_size = root["trans_size"].asInt();
auto last = root["last"].asInt();
auto file_data = root["data"].asString();
auto uid = root["uid"].asInt();
auto token = root["token"].asString();
auto last_seq = root["last_seq"].asInt();
//转化为字符串
auto uid_str = std::to_string(uid);

auto file_path = ConfigMgr::Inst().GetFileOutPath();
auto file_path_str = (file_path / uid_str / name).string();
Json::Value rtvalue;
auto callback = [=](const Json::Value& result) {

// 在异步任务完成后调用
Json::Value rtvalue = result;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["total_size"] = total_size;
rtvalue["seq"] = seq;
rtvalue["name"] = name;
rtvalue["trans_size"] = trans_size;
rtvalue["last"] = last;
rtvalue["md5"] = md5;
rtvalue["uid"] = uid;
rtvalue["last_seq"] = last_seq;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
};

//第一个包校验一下token是否合理
if (seq == 1) {
//从redis获取用户token是否正确
std::string uid_str = std::to_string(uid);
std::string token_key = USERTOKENPREFIX + uid_str;
std::string token_value = "";
bool success = RedisMgr::GetInstance()->Get(token_key, token_value);
if (!success) {
rtvalue["error"] = ErrorCodes::UidInvalid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}

if (token_value != token) {
rtvalue["error"] = ErrorCodes::TokenInvalid;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}

// 使用 std::hash 对字符串进行哈希
std::hash<std::string> hash_fn;
size_t hash_value = hash_fn(name); // 生成哈希值
int index = hash_value % FILE_WORKER_COUNT;
std::cout << "Hash value: " << hash_value << std::endl;

//第一个包
if (seq == 1) {
//构造数据存储
auto file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = name;
file_info->_seq = seq;
file_info->_total_size = total_size;
file_info->_trans_size = trans_size;
//LogicSystem::GetInstance()->AddMD5File(md5, file_info);
//改为用redis存储
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}
else {
//auto file_info = LogicSystem::GetInstance()->GetFileInfo(md5);
//改为从redis中加载
auto file_info = RedisMgr::GetInstance()->GetFileInfo(name);
if (file_info == nullptr) {
rtvalue["error"] = ErrorCodes::FileNotExists;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
file_info->_seq = seq;
file_info->_trans_size = trans_size;
bool success = RedisMgr::GetInstance()->SetFileInfo(name, file_info);
if (!success) {
rtvalue["error"] = ErrorCodes::FileSaveRedisFailed;
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_UPLOAD_HEAD_ICON_RSP);
return;
}
}


FileSystem::GetInstance()->PostMsgToQue(
std::make_shared<FileTask>(session, uid, file_path_str, name, seq, total_size,
trans_size, last, file_data, callback),
index
);

};

callback是我们封装的回调函数,投递给FileTask, 将来在后台线程处理FileTask时回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
void FileWorker::task_callback(std::shared_ptr<FileTask> task)
{
// 解码
std::string decoded = base64_decode(task->_file_data);

auto file_path_str = task->_path;
auto last = task->_last;
//std::cout << "file_path_str is " << file_path_str << std::endl;

boost::filesystem::path file_path(file_path_str);
boost::filesystem::path dir_path = file_path.parent_path();
// 获取完整文件名(包含扩展名)
std::string filename = file_path.filename().string();
Json::Value result;
// Check if directory exists, if not, create it
if (!boost::filesystem::exists(dir_path)) {
if (!boost::filesystem::create_directories(dir_path)) {
std::cerr << "Failed to create directory: " << dir_path.string() << std::endl;
result["error"] = ErrorCodes::FileNotExists;
task->_callback(result);
return;
}
}


std::ofstream outfile;
//第一个包
if (task->_seq == 1) {
// 打开文件,如果存在则清空,不存在则创建
outfile.open(file_path_str, std::ios::binary | std::ios::trunc);
}
else {
// 保存为文件
outfile.open(file_path_str, std::ios::binary | std::ios::app);
}


if (!outfile) {
std::cerr << "无法打开文件进行写入。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return ;
}

outfile.write(decoded.data(), decoded.size());
if (!outfile) {
std::cerr << "写入文件失败。" << std::endl;
result["error"] = ErrorCodes::FileWritePermissionFailed;
task->_callback(result);
return ;
}

outfile.close();
if (last) {
std::cout << "文件已成功保存为: " << task->_name << std::endl;
//更新头像
MysqlMgr::GetInstance()->UpdateUserIcon(task->_uid, filename);
//获取用户信息
auto user_info = MysqlMgr::GetInstance()->GetUser(task->_uid);
if (user_info == nullptr) {
return ;
}

//将数据库内容写入redis缓存
Json::Value redis_root;
redis_root["uid"] = task->_uid;
redis_root["pwd"] = user_info->pwd;
redis_root["name"] = user_info->name;
redis_root["email"] = user_info->email;
redis_root["nick"] = user_info->nick;
redis_root["desc"] = user_info->desc;
redis_root["sex"] = user_info->sex;
redis_root["icon"] = user_info->icon;
std::string base_key = USER_BASE_INFO + std::to_string(task->_uid);
RedisMgr::GetInstance()->Set(base_key, redis_root.toStyledString());
}

if (task->_callback) {
task->_callback(result);
}

}

客户端上传逻辑修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//上传头像
void UserInfoPage::slot_up_load()
{
// 1. 让对话框也能选 *.webp
QString filename = QFileDialog::getOpenFileName(
this,
tr("选择图片"),
QString(),
tr("图片文件 (*.png *.jpg *.jpeg *.bmp *.webp)")
);
if (filename.isEmpty())
return;

// 2. 直接用 QPixmap::load() 加载,无需手动区分格式
QPixmap inputImage;
if (!inputImage.load(filename)) {
QMessageBox::critical(
this,
tr("错误"),
tr("加载图片失败!请确认已部署 WebP 插件。"),
QMessageBox::Ok
);
return;
}

QPixmap image = ImageCropperDialog::getCroppedImage(filename, 600, 400, CropperShape::CIRCLE);
if (image.isNull())
return;

QPixmap scaledPixmap = image.scaled( ui->head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小

QString storageDir = QStandardPaths::writableLocation(
QStandardPaths::AppDataLocation);
// 2. 在其下再建一个 avatars 子目录
QDir dir(storageDir);
if (!dir.exists("avatars")) {
if (!dir.mkpath("avatars")) {
qWarning() << "无法创建 avatars 目录:" << dir.filePath("avatars");
QMessageBox::warning(
this,
tr("错误"),
tr("无法创建存储目录,请检查权限或磁盘空间。")
);
return;
}
}
// 3. 拼接最终的文件名 head.png
QString file_name = generateUniqueIconName();
QString filePath = dir.filePath("avatars" +
QString(QDir::separator()) + file_name);

// 4. 保存 scaledPixmap 为 PNG(无损、最高质量)
if (!scaledPixmap.save(filePath, "PNG")) {
QMessageBox::warning(
this,
tr("保存失败"),
tr("头像保存失败,请检查权限或磁盘空间。")
);
} else {
qDebug() << "头像已保存到:" << filePath;
// 以后读取直接用同一路径:storageDir/avatars/head.png
}

//实现头像上传
QFile file(filePath);
if(!file.open(QIODevice::ReadOnly)){
qWarning() << "Could not open file:" << file.errorString();
return;
}

//保存当前文件位置指针
qint64 originalPos = file.pos();

QCryptographicHash hash(QCryptographicHash::Md5);
if (!hash.addData(&file)) {
qWarning() << "Failed to read data from file:" << filePath;
return ;
}

// 5. 转化为16进制字符串
QString file_md5 = hash.result().toHex(); // 返回十六进制字符串

//读取文件内容并发送
QByteArray buffer;
int seq = 0;

//创建QFileInfo 对象
auto fileInfo = std::make_shared<QFileInfo>(filePath);
//获取文件名
QString fileName = fileInfo->fileName();
//文件名
qDebug() << "文件名是: " << fileName;

//获取文件大小
int total_size = fileInfo->size();
//最后一个发送序列
int last_seq = 0;
//获取最后一个发送序列
if(total_size % MAX_FILE_LEN){
last_seq = (total_size / MAX_FILE_LEN) +1;
}else{
last_seq = total_size / MAX_FILE_LEN;
}

// 恢复文件指针到原来的位置
file.seek(originalPos);

//每次读取MAX_FILE_LEN字节并发送
buffer = file.read(MAX_FILE_LEN);

QJsonObject jsonObj;
//将文件内容转化为Base64 编码(可选)
QString base64Data = buffer.toBase64();
++seq;
jsonObj["md5"] = file_md5;
jsonObj["name"] = file_name;
jsonObj["seq"] = seq;
jsonObj["trans_size"] = buffer.size() + (seq - 1) * MAX_FILE_LEN;
jsonObj["total_size"] = total_size;
jsonObj["token"] = UserMgr::GetInstance()->GetToken();
jsonObj["uid"] = UserMgr::GetInstance()->GetUid();

if (buffer.size() + (seq - 1) * MAX_FILE_LEN == total_size) {
jsonObj["last"] = 1;
} else {
jsonObj["last"] = 0;
}

jsonObj["data"] = base64Data;
jsonObj["last_seq"] = last_seq;
QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();
//将md5信息和文件信息关联存储
UserMgr::GetInstance()->AddNameFile(file_name, fileInfo);
//发送消息
FileTcpMgr::GetInstance()->SendData(ID_UPLOAD_HEAD_ICON_REQ, send_data);
file.close();
}

客户端加载头像

服务器将上传的头像信息保存为url更新到mysql中,接下来客户端登录需要加载新的头像

在ChatDialog的构造函数中将头像加载逻辑修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//模拟加载自己头像
QString head_icon = UserMgr::GetInstance()->GetIcon();
//使用正则表达式检查是否使用默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(head_icon);
if (match.hasMatch()) {
// 如果是默认头像(:/res/head_X.jpg 格式)
QPixmap pixmap(head_icon); // 加载默认头像图片
QPixmap scaledPixmap = pixmap.scaled(ui->side_head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
ui->side_head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->side_head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");

// 确保目录存在
if (avatarsDir.exists()) {
QString avatarPath = avatarsDir.filePath(QFileInfo(head_icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {
QPixmap scaledPixmap = pixmap.scaled(ui->side_head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
ui->side_head_lb->setPixmap(scaledPixmap);
ui->side_head_lb->setScaledContents(true);
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}

聊天页面也需要修改头像加载逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
void ChatPage::AppendChatMsg(std::shared_ptr<ChatDataBase> msg)
{
auto self_info = UserMgr::GetInstance()->GetUserInfo();
ChatRole role;
if (msg->GetSendUid() == self_info->_uid) {
role = ChatRole::Self;
ChatItemBase* pChatItem = new ChatItemBase(role);

pChatItem->setUserName(self_info->_name);

// 使用正则表达式检查是否是默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(self_info->_icon);
if (match.hasMatch()) {
pChatItem->setUserIcon(QPixmap(self_info->_icon));
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");

// 确保目录存在
if (avatarsDir.exists()) {
QString avatarPath = avatarsDir.filePath(QFileInfo(self_info->_icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {
pChatItem->setUserIcon(pixmap);
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}

QWidget* pBubble = nullptr;
if (msg->GetMsgType() == ChatMsgType::TEXT) {
pBubble = new TextBubble(role, msg->GetMsgContent());
}

pChatItem->setWidget(pBubble);
auto status = msg->GetStatus();
pChatItem->setStatus(status);
ui->chat_data_list->appendChatItem(pChatItem);
if (status == 0) {
_unrsp_item_map[msg->GetUniqueId()] = pChatItem;
}
}
else {
role = ChatRole::Other;
ChatItemBase* pChatItem = new ChatItemBase(role);
auto friend_info = UserMgr::GetInstance()->GetFriendById(msg->GetSendUid());
if (friend_info == nullptr) {
return;
}
pChatItem->setUserName(friend_info->_name);

// 使用正则表达式检查是否是默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(friend_info->_icon);
if (match.hasMatch()) {
pChatItem->setUserIcon(QPixmap(friend_info->_icon));
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");

// 确保目录存在
if (avatarsDir.exists()) {
QString avatarPath = avatarsDir.filePath(QFileInfo(friend_info->_icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {
pChatItem->setUserIcon(pixmap);
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}

QWidget* pBubble = nullptr;
if (msg->GetMsgType() == ChatMsgType::TEXT) {
pBubble = new TextBubble(role, msg->GetMsgContent());
}
pChatItem->setWidget(pBubble);
auto status = msg->GetStatus();
pChatItem->setStatus(status);
ui->chat_data_list->appendChatItem(pChatItem);
if (status == 0) {
_unrsp_item_map[msg->GetUniqueId()] = pChatItem;
}
}


}

聊天列表中加载头像逻辑修改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
void ChatUserWid::SetChatData(std::shared_ptr<ChatThreadData> chat_data) {
_chat_data = chat_data;
auto other_id = _chat_data->GetOtherId();
auto other_info = UserMgr::GetInstance()->GetFriendById(other_id);
// 加载图片

QString head_icon = UserMgr::GetInstance()->GetIcon();

// 使用正则表达式检查是否是默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(other_info->_icon);

if (match.hasMatch()) {
// 如果是默认头像(:/res/head_X.jpg 格式)
QPixmap pixmap(other_info->_icon); // 加载默认头像图片
QPixmap scaledPixmap = pixmap.scaled(ui->icon_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
ui->icon_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->icon_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");

// 确保目录存在
if (avatarsDir.exists()) {
QString avatarPath = avatarsDir.filePath(QFileInfo(other_info->_icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {
QPixmap scaledPixmap = pixmap.scaled(ui->icon_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
ui->icon_lb->setPixmap(scaledPixmap);
ui->icon_lb->setScaledContents(true);
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}

ui->user_name_lb->setText(other_info->_name);

ui->user_chat_lb->setText(chat_data->GetLastMsg());
}

用户信息加载头像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
UserInfoPage::UserInfoPage(QWidget *parent) :
QWidget(parent),
ui(new Ui::UserInfoPage)
{
ui->setupUi(this);
auto icon = UserMgr::GetInstance()->GetIcon();
qDebug() << "icon is " << icon ;

//使用正则表达式检查是否使用默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(icon);
if (match.hasMatch()) {
QPixmap pixmap(icon);
QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");

// 确保目录存在
if (avatarsDir.exists()) {
QString avatarPath = avatarsDir.filePath(QFileInfo(icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {
QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}



//获取nick
auto nick = UserMgr::GetInstance()->GetNick();
//获取name
auto name = UserMgr::GetInstance()->GetName();
//描述
auto desc = UserMgr::GetInstance()->GetDesc();
ui->nick_ed->setText(nick);
ui->name_ed->setText(name);
ui->desc_ed->setText(desc);
//连接上
connect(ui->up_btn, &QPushButton::clicked, this, &UserInfoPage::slot_up_load);
}

测试效果

image-20250923094811744

客户端断点下载资源

客户端请求下载

在客户端加载本地资源发现不存在的时候,需要请求服务器,获取资源。

如果资源比较大,需要分批下载,也就是支持断点下载。

我们先拿UserInfoPage举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
UserInfoPage::UserInfoPage(QWidget *parent) :
QWidget(parent),
ui(new Ui::UserInfoPage)
{
ui->setupUi(this);
auto icon = UserMgr::GetInstance()->GetIcon();
qDebug() << "icon is " << icon ;

//使用正则表达式检查是否使用默认头像
QRegularExpression regex("^:/res/head_(\\d+)\\.jpg$");
QRegularExpressionMatch match = regex.match(icon);
if (match.hasMatch()) {
QPixmap pixmap(icon);
QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
// 如果是用户上传的头像,获取存储目录
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir avatarsDir(storageDir + "/avatars");
// 确保目录存在
if (avatarsDir.exists()) {
auto file_name = QFileInfo(icon).fileName();
QString avatarPath = avatarsDir.filePath(QFileInfo(icon).fileName()); // 获取上传头像的完整路径
QPixmap pixmap(avatarPath); // 加载上传的头像图片
if (!pixmap.isNull()) {

//判断是否正在下载
bool is_loading = UserMgr::GetInstance()->IsDownLoading(file_name);
if (is_loading) {
qWarning() << "正在下载: " << file_name;
//先加载默认的
QPixmap pixmap(":/res/head_1.jpg");
QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
return;
}

QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小
}
else {
qWarning() << "无法加载上传的头像:" << avatarPath;
UserMgr::GetInstance()->AddLabelToReset(avatarPath, ui->head_lb);
//先加载默认的
QPixmap pixmap(":/res/head_1.jpg");
QPixmap scaledPixmap = pixmap.scaled(ui->head_lb->size(),
Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小

//判断是否正在下载
bool is_loading = UserMgr::GetInstance()->IsDownLoading(file_name);
if (is_loading) {
qWarning() << "正在下载: " << file_name;
return;
}
//发送请求获取资源
auto download_info = std::make_shared<DownloadInfo>();
download_info->_name = file_name;
download_info->_current_size = 0;
download_info->_seq = 1;
download_info->_total_size = 0;
download_info->_client_path = avatarPath;
//添加文件到管理者
UserMgr::GetInstance()->AddDownloadFile(file_name, download_info);
//发送消息
FileTcpMgr::GetInstance()->SendDownloadInfo(download_info);
}
}
else {
qWarning() << "头像存储目录不存在:" << avatarsDir.path();
}
}



//获取nick
auto nick = UserMgr::GetInstance()->GetNick();
//获取name
auto name = UserMgr::GetInstance()->GetName();
//描述
auto desc = UserMgr::GetInstance()->GetDesc();
ui->nick_ed->setText(nick);
ui->name_ed->setText(name);
ui->desc_ed->setText(desc);
//连接上
connect(ui->up_btn, &QPushButton::clicked, this, &UserInfoPage::slot_up_load);
}

如果本地资源不存在,则需要向服务器请求。

封装请求资源接口

判断资源是否正在下载

1
2
3
4
5
6
7
8
9
10

bool UserMgr::IsDownLoading(QString name) {
std::lock_guard<std::mutex> lock(_down_load_mtx);
auto iter = _name_to_download_info.find(name);
if (iter == _name_to_download_info.end()) {
return false;
}

return true;
}

如果资源加载成功,很可能处于正在下载,所以也要判断一下。

如果资源未加载成功,则需要向服务器下载资源。先讲要下载的资源和要加载资源的空间缓存起来。

1
2
3
4
5
6
7
8
9
10
11
12
void UserMgr::AddLabelToReset(QString path, QLabel* label)
{
auto iter = _name_to_reset_labels.find(path);
if (iter == _name_to_reset_labels.end()) {
QList<QLabel*> list;
list.append(label);
_name_to_reset_labels.insert(path, list);
return;
}

iter->append(label);
}

结构如下图

image-20251004125019778

更新正在下载资源

1
2
3
4
5
void UserMgr::AddDownloadFile(QString name, 
std::shared_ptr<DownloadInfo> file_info) {
std::lock_guard<std::mutex> lock(_down_load_mtx);
_name_to_download_info[name] = file_info;
}

发送下载请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FileTcpMgr::SendDownloadInfo(std::shared_ptr<DownloadInfo> download) {
QJsonObject jsonObj;
jsonObj["name"] = download->_name;
jsonObj["seq"] = download->_seq;
jsonObj["trans_size"] = 0;
jsonObj["total_size"] = 0;
jsonObj["token"] = UserMgr::GetInstance()->GetToken();
jsonObj["uid"] = UserMgr::GetInstance()->GetUid();
jsonObj["client_path"] = download->_client_path;

QJsonDocument doc(jsonObj);
auto send_data = doc.toJson();

SendData(ID_DOWN_LOAD_FILE_REQ, send_data);
}

接收服务器回传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
_handlers.insert(ID_DOWN_LOAD_FILE_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive download file info rsp success";

QString base64Data = jsonObj["data"].toString();
QString clientPath = jsonObj["client_path"].toString();
int seq = jsonObj["seq"].toInt();
bool is_last = jsonObj["is_last"].toBool();
QString total_size_str = jsonObj["total_size"].toString();
qint64 total_size = total_size_str.toLongLong(nullptr);
QString current_size_str = jsonObj["current_size"].toString();
qint64 current_size = current_size_str.toLongLong(nullptr);
QString name = jsonObj["name"].toString();

auto file_info = UserMgr::GetInstance()->GetDownloadInfo(name);
if (file_info == nullptr) {
qDebug() << "file: " << name << " not found";
return;
}

file_info->_current_size = current_size;
file_info->_total_size = total_size;

//Base64解码
QByteArray decodedData = QByteArray::fromBase64(base64Data.toUtf8());
QFile file(clientPath);

// 根据 seq 决定打开模式
QIODevice::OpenMode mode;
if (seq == 1) {
// 第一个包,覆盖写入
mode = QIODevice::WriteOnly;
}
else {
// 后续包,追加写入
mode = QIODevice::WriteOnly | QIODevice::Append;
}

if (!file.open(mode)) {
qDebug() << "Failed to open file for writing:" << clientPath;
qDebug() << "Error:" << file.errorString();
return;
}


qint64 bytesWritten = file.write(decodedData);
if (bytesWritten != decodedData.size()) {
qDebug() << "Failed to write all data. Written:" << bytesWritten
<< "Expected:" << decodedData.size();
}

file.close();

qDebug() << "Successfully wrote" << bytesWritten << "bytes to file";
qDebug() << "Progress:" << current_size << "/" << total_size
<< "(" << (current_size * 100 / total_size) << "%)";

if (is_last) {
qDebug() << "File download completed:" << clientPath;
UserMgr::GetInstance()->RmvDownloadFile(name);
//发送信号通知主界面重新加载label
emit sig_reset_label_icon(clientPath);
}
else {
//继续请求
file_info->_seq = seq+1;
FileTcpMgr::GetInstance()->SendDownloadInfo(file_info);
}
});
  1. 判断seq是否为1,如果为1则说明新的文件,需要创建并保存
  2. 如果seq不为1,则说明是续传文件,更新追加就可以了
  3. 如果is_last字段为true,说明是最后一个包,那么移除缓存的下载信息,同时将信息发送到主界面更新图标

更新页面逻辑

在ChatDialog界面中响应这个信号

1
2
//重置label icon
connect(FileTcpMgr::GetInstance().get(), &FileTcpMgr::sig_reset_label_icon, this, &ChatDialog::slot_reset_icon);

槽函数处理

1
2
3
void ChatDialog::slot_reset_icon(QString path) {
UserMgr::GetInstance()->ResetLabelIcon(path);
}

UserMgr中封装重置icon逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void UserMgr::ResetLabelIcon(QString path)
{
auto iter = _name_to_reset_labels.find(path);
if (iter == _name_to_reset_labels.end()) {
return;
}

for (auto ele_iter = iter.value().begin(); ele_iter != iter.value().end(); ele_iter++) {
QPixmap pixmap(path); // 加载上传的头像图片
if (!pixmap.isNull()) {
QPixmap scaledPixmap = pixmap.scaled((*ele_iter)->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
(*ele_iter)->setPixmap(scaledPixmap);
(*ele_iter)->setScaledContents(true);
}
else {
qWarning() << "无法加载上传的头像:" << path;
}
}

_name_to_reset_labels.erase(iter);
}

测试效果

服务器断点传输逻辑

增加下载worker

1
2
3
4
5
6
7
8
9
10
11
12
13
class DownloadWorker {
public:
DownloadWorker();
~DownloadWorker();
void PostTask(std::shared_ptr<DownloadTask> task);
private:
void task_callback(std::shared_ptr<DownloadTask>);
std::thread _work_thread;
std::queue<std::shared_ptr<DownloadTask>> _task_que;
std::atomic<bool> _b_stop;
std::mutex _mtx;
std::condition_variable _cv;
};

DownloadWorker处理逻辑和之前的FileWorker类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
DownloadWorker::DownloadWorker() :_b_stop(false)
{
_work_thread = std::thread([this]() {
while (!_b_stop) {
std::unique_lock<std::mutex> lock(_mtx);
_cv.wait(lock, [this]() {
if (_b_stop) {
return true;
}

if (_task_que.empty()) {
return false;
}

return true;
});

if (_b_stop) {
break;
}

auto task = _task_que.front();
_task_que.pop();
task_callback(task);
}
});
}

DownloadWorker::~DownloadWorker()
{
_b_stop = true;
_cv.notify_one();
_work_thread.join();
}

void DownloadWorker::PostTask(std::shared_ptr<DownloadTask> task)
{
{
std::lock_guard<std::mutex> lock(_mtx);
_task_que.push(task);
}

_cv.notify_one();
}

void DownloadWorker::task_callback(std::shared_ptr<DownloadTask> task)
{
// 解码
auto file_path_str = task->_file_path;

//std::cout << "file_path_str is " << file_path_str << std::endl;

boost::filesystem::path file_path(file_path_str);

// 获取完整文件名(包含扩展名)
std::string filename = file_path.filename().string();
Json::Value result;
result["error"] = ErrorCodes::Success;

if (!boost::filesystem::exists(file_path)) {
std::cerr << "文件不存在: " << file_path_str << std::endl;
result["error"] = ErrorCodes::FileNotExists;
task->_callback(result);
return;
}

std::ifstream infile(file_path_str, std::ios::binary);
if (!infile) {
std::cerr << "无法打开文件进行读取。" << std::endl;
result["error"] = ErrorCodes::FileReadPermissionFailed;
task->_callback(result);
return;
}

std::shared_ptr<FileInfo> file_info = nullptr;

if (task->_seq == 1) {
// 获取文件大小
infile.seekg(0, std::ios::end);
std::streamsize file_size = infile.tellg();
infile.seekg(0, std::ios::beg);
//如果为空,则创建FileInfo 构造数据存储
file_info = std::make_shared<FileInfo>();
file_info->_file_path_str = file_path_str;
file_info->_name = filename;
file_info->_seq = 1;

file_info->_total_size = file_size;
file_info->_trans_size = 0;
// 立即保存到 Redis,覆盖旧数据,设置过期时间
RedisMgr::GetInstance()->SetDownLoadInfo(filename, file_info);
std::cout << "[新下载] 文件: " << filename
<< ", 大小: " << file_size << " 字节" << std::endl;
}
else {
//断点续传,从 Redis 获取历史信息
file_info = RedisMgr::GetInstance()->GetDownloadInfo(filename);
if (file_info == nullptr) {
// Redis 中没有信息(可能过期了)
std::cerr << "断点续传失败,Redis 中无下载信息: " << filename << std::endl;
result["error"] = ErrorCodes::RedisReadErr;
task->_callback(result);
infile.close();
return;
}

// 验证序列号是否匹配
if (task->_seq != file_info->_seq) {
std::cerr << "序列号不匹配,期望: " << file_info->_seq
<< ", 实际: " << task->_seq << std::endl;
result["error"] = ErrorCodes::FileSeqInvalid;
task->_callback(result);
infile.close();
return;
}

std::cout << "[续传] 文件: " << filename
<< ", seq: " << task->_seq
<< ", 进度: " << file_info->_trans_size
<< "/" << file_info->_total_size << std::endl;
}

// 计算当前偏移量
std::streamsize offset = ((std::streamsize)task->_seq - 1) * MAX_FILE_LEN;
if (offset >= file_info->_total_size) {
std::cerr << "偏移量超出文件大小。" << std::endl;
result["error"] = ErrorCodes::FileOffsetInvalid;
task->_callback(result);
infile.close();
return;
}

// 定位到指定偏移量
infile.seekg(offset);

// 读取最多2048字节
char buffer[MAX_FILE_LEN];
infile.read(buffer, MAX_FILE_LEN);
//获取read实际读取多少字节
std::streamsize bytes_read = infile.gcount();

if (bytes_read <= 0) {
std::cerr << "读取文件失败。" << std::endl;
result["error"] = ErrorCodes::FileReadFailed;
task->_callback(result);
infile.close();
return;
}

// 将读取的数据进行base64编码
std::string data_to_encode(buffer, bytes_read);
std::string encoded_data = base64_encode(data_to_encode);

// 检查是否是最后一个包
std::streamsize current_pos = offset + bytes_read;
bool is_last = (current_pos >= file_info->_total_size);

// 设置返回结果
result["data"] = encoded_data;
result["seq"] = task->_seq;
result["total_size"] = std::to_string(file_info->_total_size);
result["current_size"] = std::to_string(current_pos);
result["is_last"] = is_last;

infile.close();

if (is_last) {
std::cout << "文件读取完成: " << file_path_str << std::endl;
RedisMgr::GetInstance()->DelDownLoadInfo(filename);
}
else {
//更新信息
file_info->_seq++;
file_info->_trans_size = offset + bytes_read;
//更新redis
RedisMgr::GetInstance()->SetDownLoadInfo(filename, file_info);
}

if (task->_callback) {
task->_callback(result);
}

}

在FileSystem中创建worker

1
2
3
4
5
6
7
8
9
10
FileSystem::FileSystem()
{
for (int i = 0; i < FILE_WORKER_COUNT; i++) {
_file_workers.push_back(std::make_shared<FileWorker>());
}

for (int i = 0; i < DOWN_LOAD_WORKER_COUNT; i++) {
_down_load_worker.push_back(std::make_shared<DownloadWorker>());
}
}

测试效果

将头像资源从本地删除后,重新登录时或者切换页面会引发资源重新加载,向服务器请求资源后再设置到界面显示。

image-20251004153501602

聊天信息存储方案

Posted on 2025-05-28 | In C++聊天项目

需求分析

我们希望客户端在登录后,从服务器拉取聊天信息,并且展示。常规的设计中,客户端本地也会有一个数据库,缓存上一次获取的最后的聊天信息,如果客户端下线了,再次登录,只需要从服务器拉取未接受的数据即可。

所以综合考虑过后将需求列出

  1. 客户端本地数据库缓存已经接受的消息(以后再做)
  2. 客户端登录后,将本地数据的最大的消息id发送给服务器,服务器根据这个id去数据库查找,找到比这个id大的消息,将消息回传给客户端
  3. 客户端登录后,先加载旧的数据,再差异加载未读取的数据即可。

客户端本地数据库存储放在之后实现,所以我们客户端目前只发送消息id为0即可。

数据模型设计

  1. 消息唯一标识
    • 在服务器端的 MySQL 表里,为每条消息分配一个全局唯一的自增主键(message_id),再配合时间戳(created_at)。
    • 客户端本地用同样的 message_id 做主键,这样就能很方便地做增量同步与去重。
  2. 会话/用户维度的索引
    • 如果支持多对多(群聊),再维护一个会话表(thread_id)和用户—会话关联表。
    • 查询和分页时,都按 (thread_id, message_id) 或 (thread_id, created_at) 建复合索引,加速筛选。

同步流程

  1. 客户端登录时

    1. 从本地 SQLite 加载最近 N 条消息(按 message_id 或时间倒序),渲染到界面。

    2. 读取本地记录的「每个会话已同步到的最大 message_id」,发送给服务器:

      1
      2
      3
      4
      5
      {
      "action": "fetch_messages",
      "thread_id": 123,
      "since_id": 3456
      }
  2. 服务器端响应

    • 查询 WHERE thread_id=123 AND message_id>3456 ORDER BY message_id ASC LIMIT 1000
    • 返回消息列表(可以分页返回,大量时前端可循环拉取,或返回 has_more 标记)。
  3. 客户端接收并保存

    • 将服务器返回的消息批量插入本地 SQLite,注意用「主键冲突忽略(INSERT OR IGNORE)」防止重复。
    • 更新本地「已同步最大 message_id」。
  4. 后续聊天时

    • 新消息既推到服务器,也实时写入本地 SQLite。
    • 如果走长连接(Asio + 自定义协议或使用 WebSocket),服务器收到新消息后直接广播给在线客户端,并提示客户端写到本地。
    • 如果客户端离线,新消息积累在服务器,下一次登录再按 above 流程拉取。

聊天消息表

下面给出消息聊天表的字段和解释,包含了message_id, thread_id以及常见的其他字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE `chat_message` (
`message_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`thread_id` BIGINT UNSIGNED NOT NULL,
`sender_id` BIGINT UNSIGNED NOT NULL,
`recv_id` BIGINT UNSIGNED NOT NULL,
`content` TEXT NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未读 1=已读 2=撤回',
PRIMARY KEY (`message_id`),
KEY `idx_thread_created` (`thread_id`, `created_at`),
KEY `idx_thread_message` (`thread_id`, `message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

字段说明

  • message_id:全局自增主键,唯一标识一条消息。
  • thread_id:会话(单聊、群聊)ID,同一会话下的所有消息共用一个 thread_id。
  • sender_id:发送者用户 ID,指向用户表的主键。
  • recv_id : 接收者用户ID,指向用户表主键
  • content:消息正文,TEXT 类型,适合存储普通文字。
  • created_at:消息创建时间,自动记录插入时刻。
  • updated_at:消息更新时间,可用于标记“撤回”(status 变更)、编辑等操作。
  • status:消息状态,用于标记未读/已读/撤回等(也可扩展更多状态)。

索引设计

  1. 主键索引:PRIMARY KEY (message_id) 用于唯一检索消息。
  2. 会话+时间索引:KEY (thread_id, created_at) 支持按会话分页、按时间范围查询。
  3. 会话+消息ID 索引:KEY (thread_id, message_id) 支持按 message_id 做增量拉取(WHERE thread_id=… AND message_id > since_id)。

可选扩展

  • 群聊用户表:如果支持群聊,需要一个 thread_member 表,记录每个 thread_id 下的成员及其角色。
  • 附件支持:若要存储图片/文件,可额外建 message_attachment 表,字段例如 attachment_id、message_id、file_url、file_type。
  • 已读回执:单独设计 message_read 表,记录哪些用户在何时已读了该消息,字段如 (message_id, user_id, read_at)。

会话消息表

全局聊天线程表

建立chat_thread主表,给它一个全局自增id,记录所有私聊/群聊的线程统一入口

1
2
3
4
5
6
CREATE TABLE chat_thread (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`type` ENUM('private','group') NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);

单聊表设计

对于单聊,只有两个人,所以可以直接在private_chat表中定义两个字段存储user1_id和user2_id,这样能直接确定参与者

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `private_chat` (
`thread_id` BIGINT UNSIGNED NOT NULL COMMENT '引用chat_thread.id',
`user1_id` BIGINT UNSIGNED NOT NULL,
`user2_id` BIGINT UNSIGNED NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`thread_id`),
UNIQUE KEY `uniq_private_thread` (`user1_id`, `user2_id`), -- 保证每对用户只能有一个私聊会话
-- 以下两行就是我们要额外加的复合索引
KEY `idx_private_user1_thread` (`user1_id`, `thread_id`),
KEY `idx_private_user2_thread` (`user2_id`, `thread_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 通过 user1_id 和 user2_id 唯一确定一个单聊会话
  • 询某两个用户的单聊时,直接 SELECT 即可。

群聊表设计

群聊相较于单聊要复杂一些,需要记录每个群聊的多名成员及其角色、权限等信息

先建一个独立的会话(线程)表:

1
2
3
4
5
6
CREATE TABLE `group_chat` (
`thread_id` BIGINT UNSIGNED NOT NULL COMMENT '引用chat_thread.id',
`name` VARCHAR(255) DEFAULT NULL COMMENT '群聊名称',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`thread_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 群聊会话表只存储群聊本身的信息(如群名称、创建时间等),thread_id 是唯一标识符

群聊成员表设计

  • 群聊成员表用于存储群聊中各成员的信息(包括角色、加入时间、禁言等)。
1
2
3
4
5
6
7
8
9
10
CREATE TABLE `group_chat_member` (
`thread_id` BIGINT UNSIGNED NOT NULL COMMENT '引用 group_chat_thread.thread_id',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '引用 user.user_id',
`role` TINYINT NOT NULL DEFAULT 0 COMMENT '0=普通成员,1=管理员,2=创建者',
`joined_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`muted_until` TIMESTAMP NULL COMMENT '如果被禁言,可存到什么时候',
PRIMARY KEY (`thread_id`, `user_id`),
KEY `idx_user_threads` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

前端聊天框调整

回顾

我们先回顾一下之前设计的聊天框

https://cdn.llfc.club/1718417551126.jpg

对于我们自己发出的信息,我们可以实现这样一个网格布局管理

https://cdn.llfc.club/1718423760358.jpg

NameLabel用来显示用户的名字,Bubble用来显示聊天信息,Spacer是个弹簧,保证将NameLabel``,IconLabel,Bubble等挤压到右侧。

如果是别人发出的消息,我们设置这样一个网格布局

https://cdn.llfc.club/1718497364660.jpg

增加状态标签

因为自己发送的时候要增加发送状态(发送失败,未读,已读)三种,所以考虑将自己发送的消息改为如下

image-20250601121313179

大体结构如下

1
2
3
4
5
6
7
8
9
10
11
           列0       列1         列2             列3
┌───────┬───────────┬────────────┬──────────┐
行 0 │ │ (空) │ m_pNameLabel │ m_pIconLabel │
│ │ (col=1, │ (右对齐+8px)│ (跨两行、靠上) │
│ │ 未放置)│ │ │
├───────┼───────────┼────────────┴──────────┤
行 1 │ pSpacer│ m_pStatusLabel │ m_pBubble │ m_pIconLabel │
│ │ (row=1, │ (聊天气泡) │ (继续占位) │
│ │ col=1) │ │ │
└───────┴───────────┴──────────────────────┘

代码修改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
ChatItemBase::ChatItemBase(ChatRole role, QWidget *parent)
: QWidget(parent)
, m_role(role)
{
m_pNameLabel = new QLabel();
m_pNameLabel->setObjectName("chat_user_name");
QFont font("Microsoft YaHei");
font.setPointSize(9);
m_pNameLabel->setFont(font);
m_pNameLabel->setFixedHeight(20);
m_pIconLabel = new QLabel();
m_pIconLabel->setScaledContents(true);
m_pIconLabel->setFixedSize(42, 42);
m_pBubble = new QWidget();
QGridLayout *pGLayout = new QGridLayout();
pGLayout->setVerticalSpacing(3);
pGLayout->setHorizontalSpacing(3);
pGLayout->setMargin(3);
QSpacerItem*pSpacer = new QSpacerItem(40, 20, QSizePolicy::Expanding, QSizePolicy::Minimum);

//添加状态图标控件
m_pStatusLabel = new QLabel();
m_pStatusLabel->setFixedSize(16, 16);
m_pStatusLabel->setScaledContents(true);

if(m_role == ChatRole::Self)
{
m_pNameLabel->setContentsMargins(0,0,8,0);
m_pNameLabel->setAlignment(Qt::AlignRight);
//名字标签
pGLayout->addWidget(m_pNameLabel, 0,2, 1,1);
//icon 头像
pGLayout->addWidget(m_pIconLabel, 0, 3, 2,1, Qt::AlignTop);
//第 0 列:依然是 pSpacer,占用第 1 行,第 0 列
pGLayout->addItem(pSpacer, 1, 0, 1, 1);
//气泡控件
pGLayout->addWidget(m_pBubble, 1,2, 1,1);
//状态图标
pGLayout->addWidget(m_pStatusLabel, 1, 1, 1, 1, Qt::AlignCenter);
pGLayout->setColumnStretch(0, 2);
pGLayout->setColumnStretch(1, 0); // status 图标 (固定大小)
pGLayout->setColumnStretch(2, 3); // 名字 + 气泡 (主要拉伸区域)
pGLayout->setColumnStretch(3, 0); // 头像 (固定大小)
}else{
m_pNameLabel->setContentsMargins(8,0,0,0);
m_pNameLabel->setAlignment(Qt::AlignLeft);
pGLayout->addWidget(m_pIconLabel, 0, 0, 2,1, Qt::AlignTop);
pGLayout->addWidget(m_pNameLabel, 0,1, 1,1);
pGLayout->addWidget(m_pBubble, 1,1, 1,1);
pGLayout->addItem(pSpacer, 2, 2, 1, 1);
pGLayout->setColumnStretch(1, 3);
pGLayout->setColumnStretch(2, 2);
}
this->setLayout(pGLayout);
}

增加接口设置状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void ChatItemBase::setStatus(int status) {
if (status == MsgStatus::UN_READ) {
m_pStatusLabel->setPixmap(QPixmap(":/res/unread.png"));
return;
}

if (status == MsgStatus::SEND_FAILE) {
m_pStatusLabel->setPixmap(QPixmap(":/res/send_fail.png"));
return;
}

if (status == MsgStatus::READED) {
m_pStatusLabel->setPixmap(QPixmap(":/res/readed.png"));
return;
}
}

客户端同步流程

客户端本地会有sql记录该用户所有聊天记录最后收到的消息信息,包括message_id,thread_id等,每次客户端登录将本地最大messag_id和thread_id发送给服务器,服务器按照每个thread_id将信息恢复给客户端,可支持分页返回。

举例

比如第一次请求,客户端携带message_id为1001,thread_id为22,那么服务器就会去chat_message中升序查找,比message_id(1001)大且thread_id为22的消息,返回20条

客户端拿到20条消息后,可根据最后一个消息messag_id继续请求消息。

所以我们得出一个结论要拉取消息就要有thread_id以及message_id。

接下来的情形分为两种

情况1

本地有thread_id,但是在该用户A离线的时候B用户给他发消息,因为他们之前没有聊过天,所以此时B会通知服务器在private_chat表中创建新的thread_id,但是A本地数据库没有这个thread_id,所以A需要在登录时拉取.

拉取就传递目前A本地数据库中最大的thead_id以及自己的user_id给服务器,服务器去查找比这个thread_id大的会话列表返回即可,采取分页的方式,每次加载100个,并配合load_more字段通知客户端是否继续拉取

如果load_more字段为true则客户端继续拉取,传递上次服务器给它同步的最大的thread_id,服务器继续返回比thread_id大的会话列表。

直到load_more为false,客户端不再拉取。

情况2

如果客户端换了新机器,本地没有记录信息,那么就需要在用户登录后向服务器发送user_id和thread_id,thread_id 请求从 0 开始,服务器将返回该用户的所有聊天thread_id,必须分页返回,并且携带 load_more 字段,字段和上面类似。

一个服务器返回的数据格式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"error":0,
"uid" : 1001,
"load_more":true,

"threads":[
{
"thread_id": 1001,
"type": "private",
"user1_id": 1019,
"user2_id": 1020
},
{
"thread_id": 1002,
"type": "group",
"user1_id": 0,
"user2_id": 0,
},
{
"thread_id": 1003,
"type": "private",
"user1_id": 1019,
"user2_id": 1021
},
{
"thread_id": 1004,
"type": "group",
"user1_id": 0,
"user2_id": 0
}
]
}

可采用如下sql语句查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 1) CTE 把私聊/群聊合并好
WITH all_threads AS (
SELECT
thread_id,
'private' AS type,
user1_id,
user2_id
FROM private_chat
WHERE (user1_id = :me OR user2_id = :me)
AND thread_id > :last_id
UNION ALL
SELECT
thread_id,
'group' AS type,
NULL AS user1_id,
NULL AS user2_id
FROM group_chat_member
WHERE user_id = :me
AND thread_id > :last_id
)
-- 2) 按 thread_id 升序,取 page_size+1 条
SELECT *
FROM all_threads
ORDER BY thread_id
LIMIT :page_size + 1;

然后在服务端(伪代码)处理结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def fetch_threads(me, last_id, page_size):
rows = db.query(sql, { "me": me, "last_id": last_id, "page_size": page_size })
# rows 最多有 page_size+1 条
if len(rows) > page_size:
load_more = True
rows = rows[:-1] # 丢掉第 page_size+1 条
else:
load_more = False

# 更新下一次游标:取最后一条的 thread_id
if rows:
next_last_id = rows[-1]["thread_id"]
else:
next_last_id = last_id

return {
"data": rows,
"next_last_id": next_last_id,
"load_more": load_more
}

说明

  1. 为什么要多取 1 条?
    • 取 page_size + 1 条后,如果结果确实多出那 1 条,就说明“在本页之后”还有数据;
    • 如果正好只有 page_size 条或更少,就可以断定已经取尽。
  2. 游标(cursor)模式 vs OFFSET
    • 用游标(thread_id > last_id)可以保证性能,避免大 OFFSET 带来的全表扫描。
    • 每次请求只跑新数据所在的索引范围。
  3. 客户端流程
    • 初次加载:传 last_id = 0;
    • 点「加载更多」:传上次接口返回的 next_last_id;
    • **收到 load_more = false**:表示已到末尾,不要再发更多请求。

当然为了提升效率,可以在用户登录后,选择是否同步消息的勾选框

如果勾选则调用上述sql语句查询该用户所有chat_thread返回。

如果没勾选,就不用加载chat_thread。

重构聊天item

需要重构聊天左侧item列表结构,以支持聊天消息记录持久化存储。

默认情况下,会检索本地客户端是否有聊天记录信息,

如果没有则需要请求所有thread_id列表,然后更新左侧item列表。

如果有,也需要差异化加载 thread_id 列表,比如说 A 下线了,B 和 A 通信,A 之前没有收到过 B 的信息,所以也要拉取所有新建立的会话。

所以当务之急是先把这个聊天列表加载好

因为我们没有为客户端设置本地数据库,所以我们默认每次用户登录都请求一下所有thread_id列表,这样方便测试效果

Server返回聊天列表

Server需要根据用户uid返回他的聊天列表

1 注册消息

1
2
_fun_callbacks[ID_LOAD_CHAT_THREAD_REQ] = std::bind(&LogicSystem::GetUserThreadsHandler, this,
placeholders::_1, placeholders::_2, placeholders::_3);

2 实现获取聊天记录逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void LogicSystem::GetUserThreadsHandler(std::shared_ptr<CSession> session, 
const short& msg_id, const string& msg_data)
{
//从数据库加chat_threads记录
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["uid"].asInt();
std::cout << "get uid threads " << uid << std::endl;

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["uid"] = uid;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_LOAD_CHAT_THREAD_RSP);
});

std::vector<std::shared_ptr<ChatThreadInfo>> threads;
bool res = GetUserThreads(uid, threads);
if (!res) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return;
}

//整理threads数据写入json返回
for (auto& thread : threads) {
Json::Value thread_value;
thread_value["thread_id"] = int(thread->_thread_id);
thread_value["type"] = thread->_type;
thread_value["user1_id"] = thread->_user1_id;
thread_value["user2_id"] = thread->_user2_id;
rtvalue["threads"].append(thread_value);
}
}

bool LogicSystem::GetUserThreads(int userId,
std::vector<std::shared_ptr<ChatThreadInfo>>& threads)
{
return MysqlMgr::GetInstance()->GetUserThreads(userId, threads);
}

3 数据库加载聊天

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 新增两个输出参数:loadMore, nextLastId
bool MysqlDao::GetUserThreads(
int64_t userId,
int64_t lastId,
int pageSize,
std::vector<std::shared_ptr<ChatThreadInfo>>& threads,
bool& loadMore,
int64_t& nextLastId)
{
// 初始状态
loadMore = false;
nextLastId = lastId;
threads.clear();

auto con = pool_->getConnection();
if (!con) {
return false;
}
Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});
auto& conn = con->_con;

try {
// 准备分页查询:CTE + UNION ALL + ORDER + LIMIT N+1
std::string sql =
"WITH all_threads AS ( "
" SELECT thread_id, 'private' AS type, user1_id, user2_id "
" FROM private_chat "
" WHERE (user1_id = ? OR user2_id = ?) "
" AND thread_id > ? "
" UNION ALL "
" SELECT thread_id, 'group' AS type, 0 AS user1_id, 0 AS user2_id "
" FROM group_chat_member "
" WHERE user_id = ? "
" AND thread_id > ? "
") "
"SELECT thread_id, type, user1_id, user2_id "
" FROM all_threads "
" ORDER BY thread_id "
" LIMIT ?;";

std::unique_ptr<sql::PreparedStatement> pstmt(
conn->prepareStatement(sql));

// 绑定参数:? 对应 (userId, userId, lastId, userId, lastId, pageSize+1)
int idx = 1;
pstmt->setInt64(idx++, userId); // private.user1_id
pstmt->setInt64(idx++, userId); // private.user2_id
pstmt->setInt64(idx++, lastId); // private.thread_id > lastId
pstmt->setInt64(idx++, userId); // group.user_id
pstmt->setInt64(idx++, lastId); // group.thread_id > lastId
pstmt->setInt(idx++, pageSize + 1); // LIMIT pageSize+1

// 执行
std::unique_ptr<sql::ResultSet> res(pstmt->executeQuery());

// 先把所有行读到临时容器
std::vector<std::shared_ptr<ChatThreadInfo>> tmp;
while (res->next()) {
auto cti = std::make_shared<ChatThreadInfo>();
cti->_thread_id = res->getInt64("thread_id");
cti->_type = res->getString("type");
cti->_user1_id = res->getInt64("user1_id");
cti->_user2_id = res->getInt64("user2_id");
tmp.push_back(cti);
}

// 判断是否多取到一条
if ((int)tmp.size() > pageSize) {
loadMore = true;
tmp.pop_back(); // 丢掉第 pageSize+1 条
}

// 如果还有数据,更新 nextLastId 为最后一条的 thread_id
if (!tmp.empty()) {
nextLastId = tmp.back()->_thread_id;
}

// 移入输出向量
threads = std::move(tmp);
}
catch (sql::SQLException& e) {
std::cerr << "SQLException: " << e.what()
<< " (MySQL error code: " << e.getErrorCode()
<< ", SQLState: " << e.getSQLState() << ")\n";
return false;
}

return true;
}

客户端请求聊天列表

1 完善loading对话框

完善加载对话框,调整下布局,增加一个label和旋转gif的布局

image-20250605180346879

布局界面

image-20250605180401895

接下来调整下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifndef LOADINGDLG_H
#define LOADINGDLG_H

#include <QDialog>

namespace Ui {
class LoadingDlg;
}

class LoadingDlg : public QDialog
{
Q_OBJECT

public:
explicit LoadingDlg(QWidget *parent = nullptr, QString tip = "Loading...");
~LoadingDlg();

private:
Ui::LoadingDlg *ui;
};

#endif // LOADINGDLG_H

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
LoadingDlg::LoadingDlg(QWidget *parent, QString tip):
QDialog(parent),
ui(new Ui::LoadingDlg)
{
ui->setupUi(this);

// 1. 让这个 Widget 透明背景、无边框、拦截底部事件
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowStaysOnTopHint);
setAttribute(Qt::WA_TranslucentBackground);// 设置背景透明
// 2. 让它覆盖父窗口整个面积
if (parent) {
// 获取屏幕尺寸
setFixedSize(parent->size()); // 设置对话框为全屏尺寸
}

if (parent) {
QPoint topLeft = parent->mapToGlobal(QPoint(0, 0));
move(topLeft);
}

// 3. 半透明黑色背景(alpha = 128,大约 50% 透明度)
// setStyleSheet("background-color: rgba(0, 0, 0, 128);");

QMovie *movie = new QMovie(":/res/loading2.gif"); // 加载动画的资源文件
ui->loading_lb->setMovie(movie);
movie->start();
// 3. 告诉 QMovie:将解码后的每一帧缩放到 100×100(固定大小)
movie->setScaledSize(ui->loading_lb->size());
ui->status_lb->setText(tip);
}

LoadingDlg::~LoadingDlg()
{
delete ui;
}

2 加载聊天记录

之前没有从数据库加载聊天记录,只是模拟从本地好友中加载为聊天记录了,现在需要将这部分从ChatDialog构造函数中移除

改为从服务器申请,并且此时展示LoadingDlg对话框,直到获取记录后,将LoadingDlg移除。

因为获取服务器记录是通过网络获取的,所以在客户端的TcpMgr中通过信号发送给ChatDialog界面

所以ChatDialog的构造函数改为如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
ChatDialog::ChatDialog(QWidget* parent) :
QDialog(parent),
ui(new Ui::ChatDialog), _b_loading(false), _mode(ChatUIMode::ChatMode),
_state(ChatUIMode::ChatMode), _last_widget(nullptr), _cur_chat_uid(0), _loading_dlg(nullptr)
{
ui->setupUi(this);

ui->add_btn->SetState("normal", "hover", "press");
ui->add_btn->setProperty("state", "normal");
QAction* searchAction = new QAction(ui->search_edit);
searchAction->setIcon(QIcon(":/res/search.png"));
ui->search_edit->addAction(searchAction, QLineEdit::LeadingPosition);
ui->search_edit->setPlaceholderText(QStringLiteral("搜索"));


// 创建一个清除动作并设置图标
QAction* clearAction = new QAction(ui->search_edit);
clearAction->setIcon(QIcon(":/res/close_transparent.png"));
// 初始时不显示清除图标
// 将清除动作添加到LineEdit的末尾位置
ui->search_edit->addAction(clearAction, QLineEdit::TrailingPosition);

// 当需要显示清除图标时,更改为实际的清除图标
connect(ui->search_edit, &QLineEdit::textChanged, [clearAction](const QString& text) {
if (!text.isEmpty()) {
clearAction->setIcon(QIcon(":/res/close_search.png"));
}
else {
clearAction->setIcon(QIcon(":/res/close_transparent.png")); // 文本为空时,切换回透明图标
}

});

// 连接清除动作的触发信号到槽函数,用于清除文本
connect(clearAction, &QAction::triggered, [this, clearAction]() {
ui->search_edit->clear();
clearAction->setIcon(QIcon(":/res/close_transparent.png")); // 清除文本后,切换回透明图标
ui->search_edit->clearFocus();
//清除按钮被按下则不显示搜索框
ShowSearch(false);
});

ui->search_edit->SetMaxLength(15);

//连接加载信号和槽
connect(ui->chat_user_list, &ChatUserList::sig_loading_chat_user, this, &ChatDialog::slot_loading_chat_user);
//模拟加载自己头像
QString head_icon = UserMgr::GetInstance()->GetIcon();
QPixmap pixmap(head_icon); // 加载图片
QPixmap scaledPixmap = pixmap.scaled(ui->side_head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->side_head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->side_head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小

ui->side_chat_lb->setProperty("state", "normal");

ui->side_chat_lb->SetState("normal", "hover", "pressed", "selected_normal", "selected_hover", "selected_pressed");

ui->side_contact_lb->SetState("normal", "hover", "pressed", "selected_normal", "selected_hover", "selected_pressed");

ui->side_settings_lb->SetState("normal", "hover", "pressed", "selected_normal", "selected_hover", "selected_pressed");

AddLBGroup(ui->side_chat_lb);
AddLBGroup(ui->side_contact_lb);
AddLBGroup(ui->side_settings_lb);

connect(ui->side_chat_lb, &StateWidget::clicked, this, &ChatDialog::slot_side_chat);
connect(ui->side_contact_lb, &StateWidget::clicked, this, &ChatDialog::slot_side_contact);
connect(ui->side_settings_lb, &StateWidget::clicked, this, &ChatDialog::slot_side_setting);

//链接搜索框输入变化
connect(ui->search_edit, &QLineEdit::textChanged, this, &ChatDialog::slot_text_changed);

ShowSearch(false);

//检测鼠标点击位置判断是否要清空搜索框
this->installEventFilter(this); // 安装事件过滤器

//设置聊天label选中状态
ui->side_chat_lb->SetSelected(true);
//设置选中条目
SetSelectChatItem();
//更新聊天界面信息
SetSelectChatPage();

//连接加载联系人的信号和槽函数
connect(ui->con_user_list, &ContactUserList::sig_loading_contact_user,
this, &ChatDialog::slot_loading_contact_user);

//连接联系人页面点击好友申请条目的信号
connect(ui->con_user_list, &ContactUserList::sig_switch_apply_friend_page,
this, &ChatDialog::slot_switch_apply_friend_page);

//连接清除搜索框操作
connect(ui->friend_apply_page, &ApplyFriendPage::sig_show_search, this, &ChatDialog::slot_show_search);

//为searchlist 设置search edit
ui->search_list->SetSearchEdit(ui->search_edit);

//连接申请添加好友信号
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_friend_apply, this, &ChatDialog::slot_apply_friend);

//连接认证添加好友信号
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_add_auth_friend, this, &ChatDialog::slot_add_auth_friend);

//链接自己认证回复信号
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_auth_rsp, this,
&ChatDialog::slot_auth_rsp);

//连接点击联系人item发出的信号和用户信息展示槽函数
connect(ui->con_user_list, &ContactUserList::sig_switch_friend_info_page,
this, &ChatDialog::slot_friend_info_page);

//设置中心部件为chatpage
ui->stackedWidget->setCurrentWidget(ui->chat_page);


//连接searchlist跳转聊天信号
connect(ui->search_list, &SearchList::sig_jump_chat_item, this, &ChatDialog::slot_jump_chat_item);

//连接好友信息界面发送的点击事件
connect(ui->friend_info_page, &FriendInfoPage::sig_jump_chat_item, this,
&ChatDialog::slot_jump_chat_item_from_infopage);

//连接聊天列表点击信号
connect(ui->chat_user_list, &QListWidget::itemClicked, this, &ChatDialog::slot_item_clicked);

//连接对端消息通知
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_text_chat_msg,
this, &ChatDialog::slot_text_chat_msg);

connect(ui->chat_page, &ChatPage::sig_append_send_chat_msg, this, &ChatDialog::slot_append_send_chat_msg);

_timer = new QTimer(this);
connect(_timer, &QTimer::timeout, this, [this]() {
auto user_info = UserMgr::GetInstance()->GetUserInfo();
QJsonObject textObj;
textObj["fromuid"] = user_info->_uid;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_HEART_BEAT_REQ, jsonData);
});

_timer->start(10000);

//连接tcp返回的加载聊天回复
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_load_chat_thread,
this, &ChatDialog::slot_load_chat_thread);
}

当用户登录成功后会切换到聊天页面,此时请求聊天列表

1
2
3
4
5
6
7
8
9
10
11
12
void MainWindow::SlotSwitchChat()
{
_chat_dlg = new ChatDialog();
_chat_dlg->setWindowFlags(Qt::CustomizeWindowHint|Qt::FramelessWindowHint);
setCentralWidget(_chat_dlg);
_chat_dlg->show();
_login_dlg->hide();
this->setMinimumSize(QSize(1050,900));
this->setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX);
_ui_status = CHAT_UI;
_chat_dlg->loadChatList();
}

通过发送请求获取聊天记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ChatDialog::loadChatList()
{
showLoadingDlg(true);

//发送请求逻辑
QJsonObject jsonObj;
auto uid = UserMgr::GetInstance()->GetUid();
jsonObj["uid"] = uid;
int last_chat_thread_id = UserMgr::GetInstance()->GetLastChatThreadId();
jsonObj["thread_id"] = last_chat_thread_id;


QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_THREAD_REQ, jsonData);
}

TCPMgr注册从服务器获取回复的消息处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
_handlers.insert(ID_LOAD_CHAT_THREAD_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "chat thread json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get chat thread rsp failed, error is " << err;
return;
}

qDebug() << "Receive chat thread rsp Success";

auto thread_array = jsonObj["threads"].toArray();
std::vector<std::shared_ptr<ChatThreadInfo>> chat_threads;
for (const QJsonValue& value : thread_array) {
auto cti = std::make_shared<ChatThreadInfo>();
cti->_thread_id = value["thread_id"].toInt();
cti->_type = value["type"].toString();
cti->_user1_id = value["user1_id"].toInt();
cti->_user2_id = value["user2_id"].toInt();
chat_threads.push_back(cti);
}

bool load_more = jsonObj["load_more"].toBool();
int next_last_id = jsonObj["next_last_id"].toInt();
//发送信号通知界面
emit sig_load_chat_thread(load_more, next_last_id, chat_threads);
});

ChatDialog接收TcpMgr发送的sig_load_chat_thread消息,然后触发如下函数,该函数主要加载聊天列表并且消除加载动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
void ChatDialog::slot_load_chat_thread(bool load_more, int last_thread_id,
std::vector<std::shared_ptr<ChatThreadInfo>> chat_threads)
{
for (auto& cti : chat_threads) {
//先处理单聊,群聊跳过,以后添加
if (cti->_type == "group") {
continue;
}

auto uid = UserMgr::GetInstance()->GetUid();
auto other_uid = 0;
if (uid == cti->_user1_id) {
other_uid = cti->_user2_id;
}else {
other_uid = cti->_user1_id;
}

auto friend_info = UserMgr::GetInstance()->GetFriendById(other_uid);
if (!friend_info) {
continue;
}

auto* chat_user_wid = new ChatUserWid();
auto user_info = std::make_shared<UserInfo>(friend_info);
chat_user_wid->SetInfo(user_info);
QListWidgetItem* item = new QListWidgetItem;
//qDebug()<<"chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
item->setSizeHint(chat_user_wid->sizeHint());
ui->chat_user_list->addItem(item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_items_added.insert(user_info->_uid, item);

auto chat_thread_data = std::make_shared<ChatThreadData>();
chat_thread_data->_user1_id = uid;
chat_thread_data->_user2_id = other_uid;
chat_thread_data->_last_msg_id = 0;
chat_thread_data->_thread_id = cti->_thread_id;
UserMgr::GetInstance()->AddChatThreadData(chat_thread_data);
}

UserMgr::GetInstance()->SetLastChatThreadId(last_thread_id);

if (load_more) {
//发送请求逻辑
QJsonObject jsonObj;
auto uid = UserMgr::GetInstance()->GetUid();
jsonObj["uid"] = uid;
jsonObj["thread_id"] = last_thread_id;


QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_THREAD_REQ, jsonData);
return;
}

//更新聊天界面信息
SetSelectChatItem();
SetSelectChatPage();
showLoadingDlg(false);

}

数据库构建

去navicat中执行上面数据模型设计中提到的几个sql语句

1 创建chat_thread

2 创建group_chat

image-20250613154003601

成功后显示

image-20250605181826063

3 创建group_member

image-20250613154030973

成功后显示表

image-20250605182001600

4 创建私聊表

image-20250613154048586

成功后显示

image-20250605182147160

注意: 创建后没有数据,数据是我自己添加的,为了方便测试

开启服务器,客户端登陆后加载数据

效果如下

image-20250605183518757

首次单聊

A和B是好友,首次单聊,A发送给服务器创建聊天的请求。

服务器根据A的创建请求创建私聊,然后返回给客户端A。

注意

因为聊天服务是异步的,而且是分布式的,所以有可能对方B就在此时发送消息给A,服务器已经创建好了,或者服务器正在调用sql创建。

所以对于创建请求,sql需要先查询是否已经被其他人创建了thread_id, 我们可以制定一个规则,任何一方创建thread_id,在写入私聊表private_chat时都需要保证最小的uid为uid1_id, 大的在uid2_id, 这样查询的时候也方便。

这个查询要加行级锁,避免分布式造成数据混乱。

总结

所以创建单聊时,要先去private_chat表根据uid查询,如果查到了则返回这个thread_id, 这个查询要加行级锁。

如果没查到,则在chat_thread表创建thread_id并且插入private_chat表

思路

我们整理下思路

  1. 查询是否已存在私聊会话,如果存在则加锁行并返回 thread_id
1
2
3
4
SELECT thread_id
FROM private_chat
WHERE (user1_id = LEAST(:user1_id, :user2_id) AND user2_id = GREATEST(:user1_id, :user2_id))
FOR UPDATE; -- 使用行级锁,避免并发冲突

查询时使用 LEAST 和 GREATEST 来保证无论是 user1_id 还是 user2_id,都将较小的 ID 存放在 user1_id,较大的存放在 user2_id。这样可以避免不同的用户顺序导致查找不到匹配的记录。

FOR UPDATE 关键字会锁定这些查询行,确保在事务结束之前不会有其他并发的操作修改数据。

  1. 如果未找到数据(查询返回空),则插入新记录:
1
2
3
4
5
--    1. 在 chat_thread 表中创建新记录
INSERT INTO chat_thread (type, created_at)
VALUES ('private', NOW());
-- 2. 获取新插入的 thread_id(假设你可以通过 LAST_INSERT_ID 获取)
SELECT LAST_INSERT_ID();
  1. 将新生成的 thread_id 插入 private_chat 表
1
2
INSERT INTO private_chat (thread_id, user1_id, user2_id, created_at)
VALUES (:new_thread_id, LEAST(:user1_id, :user2_id), GREATEST(:user1_id, :user2_id), NOW());

使用 INSERT INTO chat_thread 创建新的聊天记录,并使用 LAST_INSERT_ID() 获取新生成的 thread_id。

将新 thread_id 插入到 private_chat 表中,同时使用 LEAST 和 GREATEST 确保较小的 ID 存入 user1_id,较大的存入 user2_id。

问题分析

  • 行级锁的生命周期:
    行级锁(通过 FOR UPDATE 获得的锁)只在当前事务中有效。当查询结束后,锁会被释放。也就是说,如果我们查询了是否存在 private_chat 的记录并加了锁,但在查询完成后进行插入 chat_thread 和 private_chat 的操作时,其他并发请求可能会先插入新的私聊记录,从而造成数据冲突。
  • 可能的并发问题:
    例如:
    1. 线程 A 执行查询,锁定了 private_chat 表的行;
    2. 线程 B 也执行了相同的查询,发现没有记录,于是开始插入 chat_thread;
    3. 线程 A 完成插入 chat_thread 和 private_chat,但线程 B 也在此时完成了它的插入,导致 private_chat 表中出现两个重复的记录。

解决方案

为了确保并发操作的安全性,我们可以使用 事务 来保证在查询、插入 chat_thread 和 private_chat 表的过程中,数据的一致性和原子性。具体步骤如下:

方案:使用事务(Atomic Transaction)

我们可以使用 事务 来确保操作的一致性,整个操作从查询到插入都在一个事务中进行。这样即使存在多个并发请求,也能保证同一时间只有一个请求可以成功创建 chat_thread 和 private_chat。

关键改动:

  1. 在查询时加行级锁。
  2. 确保所有的数据库操作(查询和插入)都在一个事务中进行,这样可以防止并发插入的问题。
  3. 使用事务提交(commit)和回滚(rollback)确保数据一致性。

关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
bool MysqlDao::CreatePrivateChat(int user1_id, int user2_id, int& thread_id)
{
auto con = pool_->getConnection();
if (!con) {
return false;
}
Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});
auto& conn = con->_con;
try {
// 开启事务
conn->setAutoCommit(false);
// 1. 查询是否已存在私聊并加行级锁
int uid1 = std::min(user1_id, user2_id);
int uid2 = std::max(user1_id, user2_id);
std::string check_sql =
"SELECT thread_id FROM private_chat "
"WHERE (user1_id = ? AND user2_id = ?) "
"FOR UPDATE;";

std::unique_ptr<sql::PreparedStatement> pstmt(conn->prepareStatement(check_sql));
pstmt->setInt64(1, uid1);
pstmt->setInt64(2, uid2);
std::unique_ptr<sql::ResultSet> res(pstmt->executeQuery());

if (res->next()) {
// 如果已存在,返回该 thread_id
thread_id = res->getInt("thread_id");
conn->commit(); // 提交事务
return true;
}

// 2. 如果未找到,创建新的 chat_thread 和 private_chat 记录
// 在 chat_thread 表插入新记录
std::string insert_chat_thread_sql =
"INSERT INTO chat_thread (type, created_at) VALUES ('private', NOW());";

std::unique_ptr<sql::PreparedStatement> pstmt_insert_thread(conn->prepareStatement(insert_chat_thread_sql));
pstmt_insert_thread->executeUpdate();

// 获取新插入的 thread_id
std::string get_last_insert_id_sql = "SELECT LAST_INSERT_ID();";
std::unique_ptr<sql::PreparedStatement> pstmt_last_insert_id(conn->prepareStatement(get_last_insert_id_sql));
std::unique_ptr<sql::ResultSet> res_last_id(pstmt_last_insert_id->executeQuery());
res_last_id->next();
thread_id = res_last_id->getInt(1);

// 3. 在 private_chat 表插入新记录
std::string insert_private_chat_sql =
"INSERT INTO private_chat (thread_id, user1_id, user2_id, created_at) "
"VALUES (?, ?, ?, NOW());";


std::unique_ptr<sql::PreparedStatement> pstmt_insert_private(conn->prepareStatement(insert_private_chat_sql));
pstmt_insert_private->setInt64(1, thread_id);
pstmt_insert_private->setInt64(2, uid1);
pstmt_insert_private->setInt64(3, uid2);
pstmt_insert_private->executeUpdate();

// 提交事务
conn->commit();
return true;
}
catch (sql::SQLException& e) {
std::cerr << "SQLException: " << e.what() << std::endl;
conn->rollback();
return false;
}
return false;
}

bool MysqlMgr::CreatePrivateChat(int user1_id, int user2_id, int& thread_id)
{
return _dao.CreatePrivateChat(user1_id, user2_id, thread_id);
}

LogicSystem添加创建聊天的回调函数,并且注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void LogicSystem::CreatePrivateChat(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data)
{
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["uid"].asInt();
auto other_id = root["other_id"].asInt();

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["uid"] = uid;
rtvalue["other_id"] = other_id;

Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_LOAD_CHAT_THREAD_RSP);
});

int thread_id = 0;
bool res = MysqlMgr::GetInstance()->CreatePrivateChat(uid, other_id, thread_id);
if (!res) {
rtvalue["error"] = ErrorCodes::CREATE_CHAT_FAILED;
return;
}

rtvalue["thread_id"] = thread_id;
}

_fun_callbacks[ID_CREATE_PRIVATE_CHAT_REQ] = std::bind(&LogicSystem::CreatePrivateChat, this,
placeholders::_1, placeholders::_2, placeholders::_3);

客户端完善

在好友信息界面

1
2
3
4
5
6
void FriendInfoPage::on_msg_chat_clicked()
{
qDebug() << "msg chat btn clicked";
emit sig_jump_chat_item(_user_info);
}

追踪这个信号,我们完善槽函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
void ChatDialog::slot_jump_chat_item_from_infopage(std::shared_ptr<UserInfo> user_info)
{
qDebug() << "slot jump chat item " << endl;
auto thread_id = UserMgr::GetInstance()->GetThreadIdByUid(user_info->_uid);
if (thread_id != -1) {
auto find_iter = _chat_thread_items.find(thread_id);
if (find_iter != _chat_thread_items.end()) {
qDebug() << "jump to chat item , uid is " << user_info->_uid;
ui->chat_user_list->scrollToItem(find_iter.value());
ui->side_chat_lb->SetSelected(true);
SetSelectChatItem(user_info->_uid);
//更新聊天界面信息
SetSelectChatPage(user_info->_uid);
slot_side_chat();
return;
} //说明之前有缓存过聊天列表,只是被删除了,那么重新加进来即可
else {
auto* chat_user_wid = new ChatUserWid();
chat_user_wid->SetInfo(user_info);
QListWidgetItem* item = new QListWidgetItem;
qDebug() << "chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
ui->chat_user_list->insertItem(0, item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_thread_items.insert(thread_id, item);
ui->side_chat_lb->SetSelected(true);
SetSelectChatItem(user_info->_uid);
//更新聊天界面信息
SetSelectChatPage(user_info->_uid);
slot_side_chat();
return;
}
}

//如果没找到,则发送创建请求
auto uid = UserMgr::GetInstance()->GetUid();
QJsonObject jsonObj;
jsonObj["uid"] = uid;
jsonObj["other_id"] = user_info->_uid;

QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_CREATE_PRIVATE_CHAT_REQ, jsonData);
}

客户端注册服务器返回的消息ID_CREATE_PRIVATE_CHAT_RSP,进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
_handlers.insert(ID_CREATE_PRIVATE_CHAT_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive create private chat rsp Success";

int uid = jsonObj["uid"].toInt();
int other_id = jsonObj["other_id"].toInt();
int thread_id = jsonObj["thread_id"].toInt();

//发送信号通知界面
emit sig_create_private_chat(uid, other_id, thread_id);
});

编写槽函数和sig_create_private_chat连接,并且增加聊天条目

1
2
3
//连接tcp返回的创建私聊的回复
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_create_private_chat,
this, &ChatDialog::slot_create_private_chat);

具体处理的槽函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void ChatDialog::slot_create_private_chat(int uid, int other_id, int thread_id)
{
auto* chat_user_wid = new ChatUserWid();
auto user_info = UserMgr::GetInstance()->GetFriendById(other_id);
chat_user_wid->SetInfo(user_info);
QListWidgetItem* item = new QListWidgetItem;
item->setSizeHint(chat_user_wid->sizeHint());
qDebug() << "chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
ui->chat_user_list->insertItem(0, item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_thread_items.insert(thread_id, item);

auto chat_thread_data = std::make_shared<ChatThreadData>();
chat_thread_data->_user1_id = uid;
chat_thread_data->_user2_id = other_id;
chat_thread_data->_last_msg_id = 0;
chat_thread_data->_thread_id = thread_id;
UserMgr::GetInstance()->AddChatThreadData(chat_thread_data, other_id);

ui->side_chat_lb->SetSelected(true);
SetSelectChatItem(user_info->_uid);
//更新聊天界面信息
SetSelectChatPage(user_info->_uid);
slot_side_chat();
return;
}

聊天消息重构

ChaUserWid重构

之前我们的会话列表由一个一个的ChatUserWid构成

image-20250622085350930

原来的ChatUserWid内部存储的是UserInfo结构,目前我们已经增加了ChatThread数据库内容,所以要将会话列表的每个ChatUserWid中存储ChatThreadData结构。

接下来我们定义这几个结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ChatUserWid : public ListItemBase
{
Q_OBJECT

public:
explicit ChatUserWid(QWidget *parent = nullptr);
~ChatUserWid();
QSize sizeHint() const override;
void SetChatData(std::shared_ptr<ChatThreadData> chat_data);
std::shared_ptr<ChatThreadData> GetChatData();
void ShowRedPoint(bool bshow);
void updateLastMsg(std::vector<std::shared_ptr<TextChatData>> msgs);
private:
Ui::ChatUserWid *ui;
std::shared_ptr<ChatThreadData> _chat_data;
};

具体定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void ChatUserWid::SetChatData(std::shared_ptr<ChatThreadData> chat_data) {
_chat_data = chat_data;
auto other_id = _chat_data->GetOtherId();
auto other_info = UserMgr::GetInstance()->GetFriendById(other_id);
// 加载图片
QPixmap pixmap(other_info->_icon);

// 设置图片自动缩放
ui->icon_lb->setPixmap(pixmap.scaled(ui->icon_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
ui->icon_lb->setScaledContents(true);

ui->user_name_lb->setText(other_info->_name);

ui->user_chat_lb->setText(chat_data->GetLastMsg());

}

std::shared_ptr<ChatThreadData> ChatUserWid::GetChatData()
{
return _chat_data;
}

这样我们就将聊天会话的信息写入到了ChatUserWid这样一个个小条目了。

消息类抽象

因为我们将来要存储文本,文件以及图片不同类型的消息,那么就将原来的消息抽象出一个基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ChatDataBase {
public:
ChatDataBase(int msg_id, int thread_id, ChatFormType form_type, ChatMsgType msg_type,
QString content,int _send_uid);
ChatDataBase(QString unique_id, int thread_id, ChatFormType form_type, ChatMsgType msg_type,
QString content, int send_uid);
int GetMsgId() { return _msg_id; }
int GetThreadId() { return _thread_id; }
ChatFormType GetFormType() { return _form_type; }
ChatMsgType GetMsgType() { return _msg_type; }
QString GetContent() { return _content; }
int GetSendUid() { return _send_uid; }
QString GetMsgContent(){return _content;}
void SetUniqueId(int unique_id);
QString GetUniqueId();
private:
//客户端本地唯一标识
QString _unique_id;
//消息id
int _msg_id;
//会话id
int _thread_id;
//群聊还是私聊
ChatFormType _form_type;
//文本信息为0,图片为1,文件为2
ChatMsgType _msg_type;
QString _content;
//发送者id
int _send_uid;
};

然后基于上面的基类,我们可以定义不同类型的消息,如文本消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TextChatData : public ChatDataBase {
public:

TextChatData(int msg_id, int thread_id, ChatFormType form_type, ChatMsgType msg_type, QString content,
int send_uid):
ChatDataBase(msg_id, thread_id, form_type, msg_type, content, send_uid)
{

}

TextChatData(QString unique_id, int thread_id, ChatFormType form_type, ChatMsgType msg_type, QString content,
int send_uid):
ChatDataBase(unique_id, thread_id, form_type, msg_type, content, send_uid)
{

}

};

有了这个文本消息后,我们可以将基类指针ChatDataBase存储起来,将来通过实现虚函数,进行多态调用.

ChatThreadData聊天线程

聊天线程数据,重构和完善

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//客户端本地存储的聊天线程数据结构
class ChatThreadData {
public:
ChatThreadData(int other_id, int thread_id, int last_msg_id):
_other_id(other_id), _thread_id(thread_id), _last_msg_id(last_msg_id){}
void AddMsg(std::shared_ptr<ChatDataBase> msg);
void SetLastMsgId(int msg_id);
void SetOtherId(int other_id);
int GetOtherId();
QString GetGroupName();
QMap<int, std::shared_ptr<ChatDataBase>> GetMsgMap();
int GetThreadId();
QMap<int, std::shared_ptr<ChatDataBase>>& GetMsgMapRef();
void AppendMsg(int msg_id, std::shared_ptr<ChatDataBase> base_msg);
QString GetLastMsg();
private:
//如果是私聊,则为对方的id;如果是群聊,则为0
int _other_id;
int _last_msg_id;
int _thread_id;
QString _last_msg;
//群聊信息,成员列表
std::vector<int> _group_members;
//群聊名称
QString _group_name;
//缓存消息map,抽象为基类,因为会有图片等其他类型消息
QMap<int, std::shared_ptr<ChatDataBase>> _msg_map;
};

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void ChatThreadData::AddMsg(std::shared_ptr<ChatDataBase> msg)
{
_msg_map.insert(msg->GetMsgId(), msg);
}

void ChatThreadData::SetLastMsgId(int msg_id)
{
_last_msg_id = msg_id;
}

void ChatThreadData::SetOtherId(int other_id)
{
_other_id = other_id;
}

int ChatThreadData::GetOtherId() {
return _other_id;
}

QString ChatThreadData::GetGroupName()
{
return _group_name;
}

QMap<int, std::shared_ptr<ChatDataBase>> ChatThreadData::GetMsgMap() {
return _msg_map;
}

int ChatThreadData::GetThreadId()
{
return _thread_id;
}

QMap<int, std::shared_ptr<ChatDataBase>>& ChatThreadData::GetMsgMapRef()
{
return _msg_map;
}


void ChatThreadData::AppendMsg(int msg_id, std::shared_ptr<ChatDataBase> base_msg) {
_msg_map.insert(msg_id, base_msg);
_last_msg = base_msg->GetMsgContent();
_last_msg_id = msg_id;
}

QString ChatThreadData::GetLastMsg()
{
return _last_msg;
}

好友认证

对于好友认证时,如果双方通过,也要默认建立聊天消息,并且产生会话列表.

我们先从这块接入聊天消息列表,完善整体流程

proto协议修改

因为认证添加好友后,会生成两条聊天信息(比如,我们已经是好友了等),同时通知给对方,协议格式增加和修改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
message AddFriendMsg{
int32 sender_id = 1;
string unique_id = 2;
int32 msg_id = 3;
int32 thread_id = 4;
string msgcontent = 5;
}

message AuthFriendReq{
int32 fromuid = 1;
int32 touid = 2;
repeated AddFriendMsg textmsgs = 3;
}

message AuthFriendRsp{
int32 error = 1;
int32 fromuid = 2;
int32 touid = 3;
}

服务器接收好友申请

服务器收到A向B添加好友的请求,会更新数据库申请记录,同时转发给B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
void LogicSystem::AddFriendApply(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data)
{
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["uid"].asInt();
auto desc = root["applyname"].asString();
auto bakname = root["bakname"].asString();
auto touid = root["touid"].asInt();
std::cout << "user login uid is " << uid << " applydesc is "
<< desc << " bakname is " << bakname << " touid is " << touid << endl;

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_ADD_FRIEND_RSP);
});

//先更新数据库
MysqlMgr::GetInstance()->AddFriendApply(uid, touid, desc, bakname);

//查询redis 查找touid对应的server ip
auto to_str = std::to_string(touid);
auto to_ip_key = USERIPPREFIX + to_str;
std::string to_ip_value = "";
bool b_ip = RedisMgr::GetInstance()->Get(to_ip_key, to_ip_value);
if (!b_ip) {
return;
}


auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];


std::string base_key = USER_BASE_INFO + std::to_string(uid);
auto apply_info = std::make_shared<UserInfo>();
bool b_info = GetBaseInfo(base_key, uid, apply_info);

//直接通知对方有申请消息
if (to_ip_value == self_name) {
auto session = UserMgr::GetInstance()->GetSession(touid);
if (session) {
//在内存中则直接发送通知对方
Json::Value notify;
notify["error"] = ErrorCodes::Success;
notify["applyuid"] = uid;
notify["name"] = apply_info->name;
notify["desc"] = desc;
if (b_info) {
notify["icon"] = apply_info->icon;
notify["sex"] = apply_info->sex;
notify["nick"] = apply_info->nick;
}
std::string return_str = notify.toStyledString();
session->Send(return_str, ID_NOTIFY_ADD_FRIEND_REQ);
}

return ;
}


AddFriendReq add_req;
add_req.set_applyuid(uid);
add_req.set_touid(touid);
add_req.set_name(apply_info->name);
add_req.set_desc(desc);
if (b_info) {
add_req.set_icon(apply_info->icon);
add_req.set_sex(apply_info->sex);
add_req.set_nick(apply_info->nick);
}

//发送通知
ChatGrpcClient::GetInstance()->NotifyAddFriend(to_ip_value,add_req);

}

如果不在一个服务器,则通过grpc通知对端所在服务器, 对端服务器收到后,组织消息转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Status ChatServiceImpl::NotifyAddFriend(ServerContext* context, const AddFriendReq* request, AddFriendRsp* reply)
{
//查找用户是否在本服务器
auto touid = request->touid();
auto session = UserMgr::GetInstance()->GetSession(touid);

Defer defer([request, reply]() {
reply->set_error(ErrorCodes::Success);
reply->set_applyuid(request->applyuid());
reply->set_touid(request->touid());
});

//用户不在内存中则直接返回
if (session == nullptr) {
return Status::OK;
}

//在内存中则直接发送通知对方
Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["applyuid"] = request->applyuid();
rtvalue["name"] = request->name();
rtvalue["desc"] = request->desc();
rtvalue["icon"] = request->icon();
rtvalue["sex"] = request->sex();
rtvalue["nick"] = request->nick();

std::string return_str = rtvalue.toStyledString();

session->Send(return_str, ID_NOTIFY_ADD_FRIEND_REQ);
return Status::OK;
}

服务器收到同意申请

当B客户同意添加好友,会将请求发送给服务器

服务器收到后会执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
void LogicSystem::AuthFriendApply(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) {

Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);

auto uid = root["fromuid"].asInt();
auto touid = root["touid"].asInt();
auto back_name = root["back"].asString();
std::cout << "from " << uid << " auth friend to " << touid << std::endl;

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
auto user_info = std::make_shared<UserInfo>();

std::string base_key = USER_BASE_INFO + std::to_string(touid);
bool b_info = GetBaseInfo(base_key, touid, user_info);
if (b_info) {
rtvalue["name"] = user_info->name;
rtvalue["nick"] = user_info->nick;
rtvalue["icon"] = user_info->icon;
rtvalue["sex"] = user_info->sex;
rtvalue["uid"] = touid;
}
else {
rtvalue["error"] = ErrorCodes::UidInvalid;
}


Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_AUTH_FRIEND_RSP);
});

//先更新数据库, 放到事务中,此处不再处理
//MysqlMgr::GetInstance()->AuthFriendApply(uid, touid);

std::vector<std::shared_ptr<AddFriendMsg>> chat_datas;

//更新数据库添加好友
MysqlMgr::GetInstance()->AddFriend(uid, touid,back_name, chat_datas);

//查询redis 查找touid对应的server ip
auto to_str = std::to_string(touid);
auto to_ip_key = USERIPPREFIX + to_str;
std::string to_ip_value = "";
bool b_ip = RedisMgr::GetInstance()->Get(to_ip_key, to_ip_value);
if (!b_ip) {
return;
}

auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
//直接通知对方有认证通过消息
if (to_ip_value == self_name) {
auto session = UserMgr::GetInstance()->GetSession(touid);
if (session) {
//在内存中则直接发送通知对方
Json::Value notify;
notify["error"] = ErrorCodes::Success;
notify["fromuid"] = uid;
notify["touid"] = touid;
std::string base_key = USER_BASE_INFO + std::to_string(uid);
auto user_info = std::make_shared<UserInfo>();
bool b_info = GetBaseInfo(base_key, uid, user_info);
if (b_info) {
notify["name"] = user_info->name;
notify["nick"] = user_info->nick;
notify["icon"] = user_info->icon;
notify["sex"] = user_info->sex;
}
else {
notify["error"] = ErrorCodes::UidInvalid;
}

for(auto & chat_data : chat_datas)
{
Json::Value chat;
chat["sender"] = chat_data->sender_id();
chat["msg_id"] = chat_data->msg_id();
chat["thread_id"] = chat_data->thread_id();
chat["unique_id"] = chat_data->unique_id();
chat["msg_content"] = chat_data->msgcontent();
notify["chat_datas"].append(chat);
rtvalue["chat_datas"].append(chat);
}

std::string return_str = notify.toStyledString();
session->Send(return_str, ID_NOTIFY_AUTH_FRIEND_REQ);
}

return ;
}


AuthFriendReq auth_req;
auth_req.set_fromuid(uid);
auth_req.set_touid(touid);
for(auto& chat_data : chat_datas)
{
auto text_msg = auth_req.add_textmsgs();
text_msg->CopyFrom(*chat_data);
Json::Value chat;
chat["sender"] = chat_data->sender_id();
chat["msg_id"] = chat_data->msg_id();
chat["thread_id"] = chat_data->thread_id();
chat["unique_id"] = chat_data->unique_id();
chat["msg_content"] = chat_data->msgcontent();
rtvalue["chat_datas"].append(chat);
}
//发送通知
ChatGrpcClient::GetInstance()->NotifyAuthFriend(to_ip_value, auth_req);
}

数据库处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
bool MysqlDao::AddFriend(const int& from, const int& to, std::string back_name,
std::vector<std::shared_ptr<AddFriendMsg>>& chat_datas) {
auto con = pool_->getConnection();
if (con == nullptr) {
return false;
}

Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});

try {

//开始事务
con->_con->setAutoCommit(false);
std::string reverse_back;
std::string apply_desc;

{
// 1. 锁定并读取
std::unique_ptr<sql::PreparedStatement> selStmt(con->_con->prepareStatement(
"SELECT back_name, descs "
"FROM friend_apply "
"WHERE from_uid = ? AND to_uid = ? "
"FOR UPDATE"
));
selStmt->setInt(1, to);
selStmt->setInt(2, from);

std::unique_ptr<sql::ResultSet> rsSel(selStmt->executeQuery());

if (rsSel->next()) {
reverse_back = rsSel->getString("back_name");
apply_desc = rsSel->getString("descs");
}
else {
// 没有对应的申请记录,直接 rollback 并返回失败
con->_con->rollback();
return false;
}
}

{
// 2. 执行真正的更新
std::unique_ptr<sql::PreparedStatement> updStmt(con->_con->prepareStatement(
"UPDATE friend_apply "
"SET status = 1 "
"WHERE from_uid = ? AND to_uid = ?"
));

updStmt->setInt(1, to);
updStmt->setInt(2, from);

if (updStmt->executeUpdate() != 1) {
// 更新行数不对,回滚
con->_con->rollback();
return false;
}
}

{
// 3. 准备第一个SQL语句, 插入认证方好友数据
std::unique_ptr<sql::PreparedStatement> pstmt(con->_con->prepareStatement("INSERT IGNORE INTO friend(self_id, friend_id, back) "
"VALUES (?, ?, ?) "
));
//反过来的申请时from,验证时to
pstmt->setInt(1, from); // from id
pstmt->setInt(2, to);
pstmt->setString(3, back_name);
// 执行更新
int rowAffected = pstmt->executeUpdate();
if (rowAffected < 0) {
con->_con->rollback();
return false;
}

//准备第二个SQL语句,插入申请方好友数据
std::unique_ptr<sql::PreparedStatement> pstmt2(con->_con->prepareStatement("INSERT IGNORE INTO friend(self_id, friend_id, back) "
"VALUES (?, ?, ?) "
));
//反过来的申请时from,验证时to
pstmt2->setInt(1, to); // from id
pstmt2->setInt(2, from);
pstmt2->setString(3, reverse_back);
// 执行更新
int rowAffected2 = pstmt2->executeUpdate();
if (rowAffected2 < 0) {
con->_con->rollback();
return false;
}
}



// 4. 创建 chat_thread
long long threadId = 0;
{
std::unique_ptr<sql::PreparedStatement> threadStmt(con->_con->prepareStatement(
"INSERT INTO chat_thread (type, created_at) VALUES ('private', NOW());"
));

threadStmt->executeUpdate();

std::unique_ptr<sql::Statement> stmt(con->_con->createStatement());
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT LAST_INSERT_ID()")
);

if (rs->next()) {
threadId = rs->getInt64(1);
}
else {
return false;
}
}

// 5. 插入 private_chat
{
std::unique_ptr<sql::PreparedStatement> pcStmt(con->_con->prepareStatement(
"INSERT INTO private_chat(thread_id, user1_id, user2_id) VALUES (?, ?, ?)"
));

pcStmt->setInt64(1, threadId);
pcStmt->setInt(2, from);
pcStmt->setInt(3, to);
if (pcStmt->executeUpdate() < 0) return false;
}

// 6. 可选:插入初始消息到 chat_message
if (apply_desc.empty() == false)
{
std::unique_ptr<sql::PreparedStatement> msgStmt(con->_con->prepareStatement(
"INSERT INTO chat_message(thread_id, sender_id, recv_id, content,created_at, updated_at, status) VALUES (?, ?, ?, ?,NOW(),NOW(),?)"
));

msgStmt->setInt64(1, threadId);
msgStmt->setInt(2, to);
msgStmt->setInt(3, from);
msgStmt->setString(4, apply_desc);
msgStmt->setInt(5, 0);
if (msgStmt->executeUpdate() < 0) { return false; }

std::unique_ptr<sql::Statement> stmt(con->_con->createStatement());
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT LAST_INSERT_ID()")
);
if (rs->next()) {
auto messageId = rs->getInt64(1);
auto tx_data = std::make_shared<AddFriendMsg>();
tx_data->set_sender_id(to);
tx_data->set_msg_id(messageId);
tx_data->set_msgcontent(apply_desc);
tx_data->set_thread_id(threadId);
tx_data->set_unique_id("");
std::cout << "addfriend insert message success" << std::endl;
chat_datas.push_back(tx_data);
}
else {
return false;
}
}

{
std::unique_ptr<sql::PreparedStatement> msgStmt(con->_con->prepareStatement(
"INSERT INTO chat_message(thread_id, sender_id, recv_id, content, created_at, updated_at, status) VALUES (?, ?, ?, ?,NOW(),NOW(),?)"
));

msgStmt->setInt64(1, threadId);
msgStmt->setInt(2, from);
msgStmt->setInt(3, to);
msgStmt->setString(4, "We are friends now!");

msgStmt->setInt(5, 0);

if (msgStmt->executeUpdate() < 0) { return false; }

std::unique_ptr<sql::Statement> stmt(con->_con->createStatement());
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT LAST_INSERT_ID()")
);
if (rs->next()) {
auto messageId = rs->getInt64(1);
auto tx_data = std::make_shared<AddFriendMsg>();
tx_data->set_sender_id(from);
tx_data->set_msg_id(messageId);
tx_data->set_msgcontent("We are friends now!");
tx_data->set_thread_id(threadId);
tx_data->set_unique_id("");
chat_datas.push_back(tx_data);
}
else {
return false;
}
}

// 提交事务
con->_con->commit();
std::cout << "addfriend insert friends success" << std::endl;

return true;
}
catch (sql::SQLException& e) {
// 如果发生错误,回滚事务
if (con) {
con->_con->rollback();
}
std::cerr << "SQLException: " << e.what();
std::cerr << " (MySQL error code: " << e.getErrorCode();
std::cerr << ", SQLState: " << e.getSQLState() << " )" << std::endl;
return false;
}


return true;
}

服务器收到同意通知

B同意A的申请,此时B所在的服务器会将同意的通知发送到A所在的服务器

下面是A所在的服务器收到请求后,发送通知给A的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Status ChatServiceImpl::NotifyAuthFriend(ServerContext* context, const AuthFriendReq* request,
AuthFriendRsp* reply) {
//查找用户是否在本服务器
auto touid = request->touid();
auto fromuid = request->fromuid();
auto session = UserMgr::GetInstance()->GetSession(touid);

Defer defer([request, reply]() {
reply->set_error(ErrorCodes::Success);
reply->set_fromuid(request->fromuid());
reply->set_touid(request->touid());
});

//用户不在内存中则直接返回
if (session == nullptr) {
return Status::OK;
}

//在内存中则直接发送通知对方
Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["fromuid"] = request->fromuid();
rtvalue["touid"] = request->touid();

std::string base_key = USER_BASE_INFO + std::to_string(fromuid);
auto user_info = std::make_shared<UserInfo>();
bool b_info = GetBaseInfo(base_key, fromuid, user_info);
if (b_info) {
rtvalue["name"] = user_info->name;
rtvalue["nick"] = user_info->nick;
rtvalue["icon"] = user_info->icon;
rtvalue["sex"] = user_info->sex;
}
else {
rtvalue["error"] = ErrorCodes::UidInvalid;
}

for(auto& msg : request->textmsgs()) {
Json::Value chat;
chat["sender"] = msg.sender_id();
chat["msg_id"] = msg.msg_id();
chat["thread_id"] = msg.thread_id();
chat["unique_id"] = msg.unique_id();
chat["msg_content"] = msg.msgcontent();
rtvalue["chat_datas"].append(chat);
}

std::string return_str = rtvalue.toStyledString();

session->Send(return_str, ID_NOTIFY_AUTH_FRIEND_REQ);
return Status::OK;
}

客户端收到好友同意回复

当A申请B加好友,B同意后,服务器会回复给B消息,这样B的客户端要处理同意的回包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
_handlers.insert(ID_AUTH_FRIEND_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "Auth Friend Failed, err is Json Parse Err" << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Auth Friend Failed, err is " << err;
return;
}

auto name = jsonObj["name"].toString();
auto nick = jsonObj["nick"].toString();
auto icon = jsonObj["icon"].toString();
auto sex = jsonObj["sex"].toInt();
auto uid = jsonObj["uid"].toInt();

std::vector<std::shared_ptr<TextChatData>> chat_datas;
for (const QJsonValue& data : jsonObj["chat_datas"].toArray()) {
auto send_uid = data["sender"].toInt();
auto msg_id = data["msg_id"].toInt();
auto thread_id = data["thread_id"].toInt();
auto unique_id = data["unique_id"].toInt();
auto msg_content = data["msg_content"].toString();
auto chat_data = std::make_shared<TextChatData>(msg_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, msg_content, send_uid);
chat_datas.push_back(chat_data);
}

auto rsp = std::make_shared<AuthRsp>(uid, name, nick, icon, sex);
rsp->SetChatDatas(chat_datas);
emit sig_auth_rsp(rsp);

qDebug() << "Auth Friend Success " ;
});

界面和好友状态更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void ChatDialog::slot_auth_rsp(std::shared_ptr<AuthRsp> auth_rsp)
{
qDebug() << "receive slot_auth_rsp uid is " << auth_rsp->_uid
<< " name is " << auth_rsp->_name << " nick is " << auth_rsp->_nick;

//判断如果已经是好友则跳过
auto bfriend = UserMgr::GetInstance()->CheckFriendById(auth_rsp->_uid);
if (bfriend) {
return;
}

UserMgr::GetInstance()->AddFriend(auth_rsp);
int randomValue = QRandomGenerator::global()->bounded(100); // 生成0到99之间的随机整数
int str_i = randomValue % strs.size();
int head_i = randomValue % heads.size();
int name_i = randomValue % names.size();

auto* chat_user_wid = new ChatUserWid();
auto chat_thread_data = std::make_shared<ChatThreadData>(auth_rsp->_uid, auth_rsp->_thread_id, 0);
UserMgr::GetInstance()->AddChatThreadData(chat_thread_data, auth_rsp->_uid);
for (auto& chat_msg : auth_rsp->_chat_datas) {
chat_thread_data->AppendMsg(chat_msg->GetMsgId(), chat_msg);
}
chat_user_wid->SetChatData(chat_thread_data);
QListWidgetItem* item = new QListWidgetItem;
//qDebug()<<"chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
item->setSizeHint(chat_user_wid->sizeHint());
ui->chat_user_list->insertItem(0, item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_thread_items.insert(auth_rsp->_thread_id, item);
}

客户端收到好友同意通知

A加B为好友,B同意后,服务器通知A,以下为A收到通知后的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
_handlers.insert(ID_NOTIFY_AUTH_FRIEND_REQ, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();
if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "Auth Friend Failed, err is " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Auth Friend Failed, err is " << err;
return;
}

int from_uid = jsonObj["fromuid"].toInt();
QString name = jsonObj["name"].toString();
QString nick = jsonObj["nick"].toString();
QString icon = jsonObj["icon"].toString();
int sex = jsonObj["sex"].toInt();

std::vector<std::shared_ptr<TextChatData>> chat_datas;
for (const QJsonValue& data : jsonObj["chat_datas"].toArray()) {
auto send_uid = data["sender"].toInt();
auto msg_id = data["msg_id"].toInt();
auto thread_id = data["thread_id"].toInt();
auto unique_id = data["unique_id"].toInt();
auto msg_content = data["msg_content"].toString();
auto chat_data = std::make_shared<TextChatData>(msg_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, msg_content, send_uid);
chat_datas.push_back(chat_data);
}

auto auth_info = std::make_shared<AuthInfo>(from_uid,name,
nick, icon, sex);

auth_info->SetChatDatas(chat_datas);

emit sig_add_auth_friend(auth_info);
});

界面添加好友会话状态更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void ChatDialog::slot_add_auth_friend(std::shared_ptr<AuthInfo> auth_info) {
qDebug() << "receive slot_add_auth__friend uid is " << auth_info->_uid
<< " name is " << auth_info->_name << " nick is " << auth_info->_nick;

//判断如果已经是好友则跳过
auto bfriend = UserMgr::GetInstance()->CheckFriendById(auth_info->_uid);
if (bfriend) {
return;
}

UserMgr::GetInstance()->AddFriend(auth_info);

auto* chat_user_wid = new ChatUserWid();
auto chat_thread_data = std::make_shared<ChatThreadData>(auth_info->_uid, auth_info->_thread_id, 0);
UserMgr::GetInstance()->AddChatThreadData(chat_thread_data, auth_info->_uid);
for (auto& chat_msg : auth_info->_chat_datas) {
chat_thread_data->AppendMsg(chat_msg->GetMsgId(), chat_msg);
}

chat_user_wid->SetChatData(chat_thread_data);
QListWidgetItem* item = new QListWidgetItem;
//qDebug()<<"chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
item->setSizeHint(chat_user_wid->sizeHint());
ui->chat_user_list->insertItem(0, item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_thread_items.insert(auth_info->_thread_id, item);
}

效果展示

image-20250622131525274

GRPC同步认证消息认证

分布式认证就是让两个客户端分别登录不同的服务器,注意因为我们修改了连接检测和记录的方式,改为通过心跳定时更新,为了避免两个客户端同时登录到一个服务器的情况,可以在一个客户端登录服务器后,另一个客户端延迟一分钟登录。

同时要注意StatusServer要将getChatServer这个函数打开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ChatServer StatusServiceImpl::getChatServer() {
std::lock_guard<std::mutex> guard(_server_mtx);
auto minServer = _servers.begin()->second;

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, minServer.name);
if (count_str.empty()) {
//不存在则默认设置为最大
minServer.con_count = INT_MAX;
}
else {
minServer.con_count = std::stoi(count_str);
}


// 使用范围基于for循环
for ( auto& server : _servers) {

if (server.second.name == minServer.name) {
continue;
}

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
if (count_str.empty()) {
server.second.con_count = INT_MAX;
}
else {
server.second.con_count = std::stoi(count_str);
}

if (server.second.con_count < minServer.con_count) {
minServer = server.second;
}
}

return minServer;
}

两个客户端登录后,确保后台看到两个用户登录不同的Server

1019用户登录Server2

image-20250625212522598

1002用户登录Server1

image-20250625212740526

这样二者都登陆成功了,然后任意一方向对方发送添加好友请求,另一方同意,看到的效果如下

image-20250625212823862

聊天记录增量加载

客户端逻辑

聊天记录增量加载,可以在加载完聊天会话列表后,继续分页加载聊天信息。

因为qt支持信号和槽函数机制,所以我们可以加载完会话列表后发送, 在UserMgr中设置一个当前加载的_cur_load_chat_index用来记录将要加载的会话消息。

我们对外暴露两个接口,分别是获取当前要加载会话信息,和下次加载的会话信息

1
2
3
4
5
6
7
8
9
10
11
12
13
std::shared_ptr<ChatThreadData> UserMgr::GetCurLoadData()
{
if (_cur_load_chat_index >= _chat_thread_ids.size()) {
return nullptr;
}

auto iter = _chat_map.find(_chat_thread_ids[_cur_load_chat_index]);
if (iter == _chat_map.end()) {
return nullptr;
}

return iter.value();
}

然后封装加载消息的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void ChatDialog::loadChatMsg() {

//发送聊天记录请求
_cur_load_chat = UserMgr::GetInstance()->GetCurLoadData();
if (_cur_load_chat == nullptr) {
return;
}

showLoadingDlg(true);

//发送请求给服务器
//发送请求逻辑
QJsonObject jsonObj;
jsonObj["thread_id"] = _cur_load_chat->GetThreadId();
jsonObj["message_id"] = _cur_load_chat->GetLastMsgId();

QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_MSG_REQ, jsonData);
}

接下来我们在加载完会话列表后调用这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void ChatDialog::slot_load_chat_thread(bool load_more, int last_thread_id,
std::vector<std::shared_ptr<ChatThreadInfo>> chat_threads)
{
for (auto& cti : chat_threads) {
//先处理单聊,群聊跳过,以后添加
if (cti->_type == "group") {
continue;
}

auto uid = UserMgr::GetInstance()->GetUid();
auto other_uid = 0;
if (uid == cti->_user1_id) {
other_uid = cti->_user2_id;
}
else {
other_uid = cti->_user1_id;
}

auto chat_thread_data = std::make_shared<ChatThreadData>(other_uid, cti->_thread_id, 0);
UserMgr::GetInstance()->AddChatThreadData(chat_thread_data, other_uid);

auto* chat_user_wid = new ChatUserWid();
chat_user_wid->SetChatData(chat_thread_data);
QListWidgetItem* item = new QListWidgetItem;
//qDebug()<<"chat_user_wid sizeHint is " << chat_user_wid->sizeHint();
item->setSizeHint(chat_user_wid->sizeHint());
ui->chat_user_list->addItem(item);
ui->chat_user_list->setItemWidget(item, chat_user_wid);
_chat_thread_items.insert(cti->_thread_id, item);
}

UserMgr::GetInstance()->SetLastChatThreadId(last_thread_id);

if (load_more) {
//发送请求逻辑
QJsonObject jsonObj;
auto uid = UserMgr::GetInstance()->GetUid();
jsonObj["uid"] = uid;
jsonObj["thread_id"] = last_thread_id;


QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_THREAD_REQ, jsonData);
return;
}

showLoadingDlg(false);
//继续加载聊天数据
loadChatMsg();
}

在收到服务器回复时处理消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
_handlers.insert(ID_LOAD_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "parse create private chat json parse failed " << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "get create private chat failed, error is " << err;
return;
}

qDebug() << "Receive create private chat rsp Success";

int thread_id = jsonObj["thread_id"].toInt();
int last_msg_id = jsonObj["last_message_id"].toInt();
bool load_more = jsonObj["load_more"].toBool();

std::vector<std::shared_ptr<TextChatData>> chat_datas;
for (const QJsonValue& data : jsonObj["chat_datas"].toArray()) {
auto send_uid = data["sender"].toInt();
auto msg_id = data["msg_id"].toInt();
auto thread_id = data["thread_id"].toInt();
auto unique_id = data["unique_id"].toInt();
auto msg_content = data["msg_content"].toString();
QString chat_time = data["chat_time"].toString();
auto chat_data = std::make_shared<TextChatData>(msg_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, msg_content, send_uid, chat_time);
chat_datas.push_back(chat_data);
}

//发送信号通知界面
emit sig_load_chat_msg(thread_id, last_msg_id, load_more, chat_datas);
});

界面收到sig_load_chat_msg后添加消息,并且判断是否还有剩余消息加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void ChatDialog::slot_load_chat_msg(int thread_id, int msg_id, bool load_more, std::vector<std::shared_ptr<TextChatData>> msglists)
{
_cur_load_chat->SetLastMsgId(msg_id);
//加载聊天信息
for (auto& chat_msg : msglists) {
_cur_load_chat->AppendMsg(chat_msg->GetMsgId(), chat_msg);
}

//还有未加载完的消息,就继续加载
if (load_more) {
//发送请求给服务器
//发送请求逻辑
QJsonObject jsonObj;
jsonObj["thread_id"] = _cur_load_chat->GetThreadId();
jsonObj["message_id"] = _cur_load_chat->GetLastMsgId();

QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_MSG_REQ, jsonData);
return;
}

//获取下一个chat_thread
_cur_load_chat = UserMgr::GetInstance()->GetNextLoadData();
//都加载完了
if(!_cur_load_chat){
//更新聊天界面信息
SetSelectChatItem();
SetSelectChatPage();
showLoadingDlg(false);
return;
}

//继续加载下一个聊天
//发送请求给服务器
//发送请求逻辑
QJsonObject jsonObj;
jsonObj["thread_id"] = _cur_load_chat->GetThreadId();
jsonObj["message_id"] = _cur_load_chat->GetLastMsgId();

QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);

//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_LOAD_CHAT_MSG_REQ, jsonData);
}

服务器逻辑

注册消息

1
2
_fun_callbacks[ID_LOAD_CHAT_MSG_REQ] = std::bind(&LogicSystem::LoadChatMsg, this,
placeholders::_1, placeholders::_2, placeholders::_3);

具体逻辑处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void LogicSystem::LoadChatMsg(std::shared_ptr<CSession> session, 
const short& msg_id, const string& msg_data) {

Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto thread_id = root["thread_id"].asInt();
auto message_id = root["message_id"].asInt();


Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["thread_id"] = thread_id;

Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_LOAD_CHAT_MSG_RSP);
});

int page_size = 10;
std::shared_ptr<PageResult> res = MysqlMgr::GetInstance()->LoadChatMsg(thread_id, message_id, page_size);
if (!res) {
rtvalue["error"] = ErrorCodes::LOAD_CHAT_FAILED;
return;
}

rtvalue["last_message_id"] = res->next_cursor;
rtvalue["load_more"] = res->load_more;
for (auto& chat : res->messages) {
Json::Value chat_data;
chat_data["sender"] = chat.sender_id;
chat_data["msg_id"] = chat.message_id;
chat_data["thread_id"] = chat.thread_id;
chat_data["unique_id"] = 0;
chat_data["msg_content"] = chat.content;
chat_data["chat_time"] = chat.chat_time;
rtvalue["chat_datas"].append(chat_data);
}

}

数据库新增根据thread_id和message_id返回分页数据

1
2
3
4
std::shared_ptr<PageResult> MysqlMgr::LoadChatMsg(int threadId, int lastId, int pageSize)
{
return _dao.LoadChatMsg(threadId, lastId, pageSize);
}

具体在MysqlDao层面实现分页加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
std::shared_ptr<PageResult> MysqlDao::LoadChatMsg(int thread_id, int last_message_id, int page_size)
{
auto con = pool_->getConnection();
if (!con) {
return nullptr;
}
Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});
auto& conn = con->_con;


try {
auto page_res = std::make_shared<PageResult>();
page_res->load_more = false;
page_res->next_cursor = last_message_id;

// SQL:多取一条,用于判断是否还有更多
const std::string sql = R"(
SELECT message_id, thread_id, sender_id, recv_id, content,
created_at, updated_at, status
FROM chat_message
WHERE thread_id = ?
AND message_id > ?
ORDER BY message_id ASC
LIMIT ?
)";

uint32_t fetch_limit = page_size + 1;
auto pstmt = std::unique_ptr<sql::PreparedStatement>(
conn->prepareStatement(sql)
);
pstmt->setInt(1, thread_id);
pstmt->setInt(2, last_message_id);
pstmt->setInt(3, fetch_limit);

auto rs = std::unique_ptr<sql::ResultSet>(pstmt->executeQuery());

// 读取 fetch_limit 条记录
while (rs->next()) {
ChatMessage msg;
msg.message_id = rs->getUInt64("message_id");
msg.thread_id = rs->getUInt64("thread_id");
msg.sender_id = rs->getUInt64("sender_id");
msg.recv_id = rs->getUInt64("recv_id");
msg.content = rs->getString("content");
msg.chat_time = rs->getString("created_at");
msg.status = rs->getInt("status");
page_res->messages.push_back(std::move(msg));
}

return page_res;
}
catch (sql::SQLException& e) {
std::cerr << "SQLException: " << e.what() << std::endl;
conn->rollback();
return nullptr;
}
return nullptr;

}

效果展示

image-20250702125912799

发送和接收消息同步

客户端缓存发送消息

我们需要在客户端缓存一下发送的消息,等到服务器回复后再将收到的消息放入ChatThreadData中。

为了标识消息的唯一性,我们需要在客户端生成唯一unique_id,构造成ChatTextData先放到ChatThreadData中存起来。

1
2
//已发送的消息,还未收到回应的。
QMap<QString, std::shared_ptr<TextChatData>> _msg_unrsp_map;

实现发送逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
void ChatPage::on_send_btn_clicked()
{
if (_chat_data == nullptr) {
qDebug() << "friend_info is empty";
return;
}

auto user_info = UserMgr::GetInstance()->GetUserInfo();
auto pTextEdit = ui->chatEdit;
ChatRole role = ChatRole::Self;
QString userName = user_info->_name;
QString userIcon = user_info->_icon;

const QVector<MsgInfo>& msgList = pTextEdit->getMsgList();
QJsonObject textObj;
QJsonArray textArray;
int txt_size = 0;
auto thread_id = _chat_data->GetThreadId();
for(int i=0; i<msgList.size(); ++i)
{
//消息内容长度不合规就跳过
if(msgList[i].content.length() > 1024){
continue;
}

QString type = msgList[i].msgFlag;
ChatItemBase *pChatItem = new ChatItemBase(role);
pChatItem->setUserName(userName);
pChatItem->setUserIcon(QPixmap(userIcon));
QWidget *pBubble = nullptr;
//生成唯一id
QUuid uuid = QUuid::createUuid();
//转为字符串
QString uuidString = uuid.toString();
if(type == "text")
{
pBubble = new TextBubble(role, msgList[i].content);
if(txt_size + msgList[i].content.length()> 1024){
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
textObj["text_array"] = textArray;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送并清空之前累计的文本列表
txt_size = 0;
textArray = QJsonArray();
textObj = QJsonObject();
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ, jsonData);
}

//将bubble和uid绑定,以后可以等网络返回消息后设置是否送达
//_bubble_map[uuidString] = pBubble;
txt_size += msgList[i].content.length();
QJsonObject obj;
QByteArray utf8Message = msgList[i].content.toUtf8();
auto content = QString::fromUtf8(utf8Message);
obj["content"] = content;
obj["unique_id"] = uuidString;
textArray.append(obj);
//todo... 注意,此处先按私聊处理
auto txt_msg = std::make_shared<TextChatData>(uuidString, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, content, user_info->_uid, 0);
//将未回复的消息加入到未回复列表中,以便后续处理
_chat_data->AppendUnRspMsg(uuidString,txt_msg);
}
else if(type == "image")
{
pBubble = new PictureBubble(QPixmap(msgList[i].content) , role);
}
else if(type == "file")
{

}
//发送消息
if(pBubble != nullptr)
{
pChatItem->setWidget(pBubble);
pChatItem->setStatus(0);
ui->chat_data_list->appendChatItem(pChatItem);
_unrsp_item_map[uuidString] = pChatItem;
}

}

qDebug() << "textArray is " << textArray ;
//发送给服务器
textObj["text_array"] = textArray;
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _chat_data->GetOtherId();
textObj["thread_id"] = thread_id;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
//发送并清空之前累计的文本列表
txt_size = 0;
textArray = QJsonArray();
textObj = QJsonObject();
//发送tcp请求给chat server
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ, jsonData);
}

相比于之前,我们在json中增加了unique_id和thread_id字段,服务器收到后根据thread_id生成消息放入到数据库,并携带unique_id回传给客户端。

客户端缓存消息放入UserMgr中

1
2
//将未回复的消息加入到未回复列表中,以便后续处理
_chat_data->AppendUnRspMsg(uuidString,txt_msg);

此外,客户端需要设置聊天文本状态为未回复

1
pChatItem->setStatus(0);

切换聊天不丢失状态

如果此时切换页面,再切回来,也要保证之前服务器未回复的消息能重新加载

切换的逻辑在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
void ChatDialog::SetSelectChatPage(int thread_id)
{
if (ui->chat_user_list->count() <= 0) {
return;
}

if (thread_id == 0) {
auto item = ui->chat_user_list->item(0);
//转为widget
QWidget* widget = ui->chat_user_list->itemWidget(item);
if (!widget) {
return;
}

auto con_item = qobject_cast<ChatUserWid*>(widget);
if (!con_item) {
return;
}

//设置信息
auto chat_data = con_item->GetChatData();
ui->chat_page->SetChatData(chat_data);
return;
}

auto find_iter = _chat_thread_items.find(thread_id);
if (find_iter == _chat_thread_items.end()) {
return;
}

//转为widget
QWidget* widget = ui->chat_user_list->itemWidget(find_iter.value());
if (!widget) {
return;
}

//判断转化为自定义的widget
// 对自定义widget进行操作, 将item 转化为基类ListItemBase
ListItemBase* customItem = qobject_cast<ListItemBase*>(widget);
if (!customItem) {
qDebug() << "qobject_cast<ListItemBase*>(widget) is nullptr";
return;
}

auto itemType = customItem->GetItemType();
if (itemType == CHAT_USER_ITEM) {
auto con_item = qobject_cast<ChatUserWid*>(customItem);
if (!con_item) {
return;
}

//设置信息
auto chat_data = con_item->GetChatData();
ui->chat_page->SetChatData(chat_data);

return;
}

}

其中SetChatData是设置页面聊天信息列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void ChatPage::SetChatData(std::shared_ptr<ChatThreadData> chat_data) {
_chat_data = chat_data;
auto other_id = _chat_data->GetOtherId();
if(other_id == 0) {
//说明是群聊
ui->title_lb->setText(_chat_data->GetGroupName());
//todo...加载群聊信息和成员信息
return;
}

//私聊
auto friend_info = UserMgr::GetInstance()->GetFriendById(other_id);
if (friend_info == nullptr) {
return;
}
ui->title_lb->setText(friend_info->_name);
ui->chat_data_list->removeAllItem();
_unrsp_item_map.clear();
for(auto & msg : chat_data->GetMsgMapRef()){
AppendChatMsg(msg);
}

for (auto& msg : chat_data->GetMsgUnRspRef()) {
AppendChatMsg(msg);
}
}

这样我们可以加载服务器已经回复的和服务器未回复的。保证完全,具体添加逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void ChatPage::AppendChatMsg(std::shared_ptr<ChatDataBase> msg)
{
auto self_info = UserMgr::GetInstance()->GetUserInfo();
ChatRole role;
if (msg->GetSendUid() == self_info->_uid) {
role = ChatRole::Self;
ChatItemBase* pChatItem = new ChatItemBase(role);

pChatItem->setUserName(self_info->_name);
pChatItem->setUserIcon(QPixmap(self_info->_icon));
QWidget* pBubble = nullptr;
if (msg->GetMsgType() == ChatMsgType::TEXT) {
pBubble = new TextBubble(role, msg->GetMsgContent());
}

pChatItem->setWidget(pBubble);
auto status = msg->GetStatus();
pChatItem->setStatus(status);
ui->chat_data_list->appendChatItem(pChatItem);
if (status == 0) {
_unrsp_item_map[msg->GetUniqueId()] = pChatItem;
}
}
else {
role = ChatRole::Other;
ChatItemBase* pChatItem = new ChatItemBase(role);
auto friend_info = UserMgr::GetInstance()->GetFriendById(msg->GetSendUid());
if (friend_info == nullptr) {
return;
}
pChatItem->setUserName(friend_info->_name);
pChatItem->setUserIcon(QPixmap(friend_info->_icon));
QWidget* pBubble = nullptr;
if (msg->GetMsgType() == ChatMsgType::TEXT) {
pBubble = new TextBubble(role, msg->GetMsgContent());
}
pChatItem->setWidget(pBubble);
auto status = msg->GetStatus();
pChatItem->setStatus(status);
ui->chat_data_list->appendChatItem(pChatItem);
if (status == 0) {
_unrsp_item_map[msg->GetUniqueId()] = pChatItem;
}
}


}

其中_unrsp_item_map是聊天页面上的服务器未回复的聊天记录的,每次切换页面清掉,再重新创建加载。

这么做效率不高,后期给大家介绍Module View Delegate模式去优化聊天数据加载和管理。

这里先把持久化存储功能先实现再说。

客户端收到服务器回复

收到服务器回复后,需要组织数据发送给ChatDialog界面,将未回复的消息更新为已回复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
_handlers.insert(ID_TEXT_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "Chat Msg Rsp Failed, err is Json Parse Err" << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Chat Msg Rsp Failed, err is " << err;
return;
}

qDebug() << "Receive Text Chat Rsp Success " ;
//收到消息后转发给页面
auto thread_id = jsonObj["thread_id"].toInt();
auto sender = jsonObj["fromuid"].toInt();


std::vector<std::shared_ptr<TextChatData>> chat_datas;
for (const QJsonValue& data : jsonObj["chat_datas"].toArray()) {
auto msg_id = data["message_id"].toInt();
auto unique_id = data["unique_id"].toString();
auto msg_content = data["content"].toString();
QString chat_time = data["chat_time"].toString();
int status = data["status"].toInt();
auto chat_data = std::make_shared<TextChatData>(msg_id,unique_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, msg_content, sender, status, chat_time);
chat_datas.push_back(chat_data);
}

//发送信号通知界面
emit sig_chat_msg_rsp(thread_id, chat_datas);

});

将信号和槽函数连接

1
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_chat_msg_rsp, this, &ChatDialog::slot_add_chat_msg);

会触发槽函数, 槽函数内部检测消息,将消息存储到已经回复列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ChatDialog::slot_add_chat_msg(int thread_id, std::vector<std::shared_ptr<TextChatData>> msglists) {
auto chat_data = UserMgr::GetInstance()->GetChatThreadByThreadId(thread_id);
if (chat_data == nullptr) {
return;
}

//将消息放入数据中管理
for (auto& msg : msglists) {
chat_data->MoveMsg(msg);

if (_cur_chat_thread_id != thread_id) {
continue;
}
//更新聊天界面信息
ui->chat_page->UpdateChatStatus(msg->GetUniqueId(),msg->GetStatus());
}

}

转移逻辑, 其实就是去未回复中查找对应消息,如果有就移动到已回复列表,如果没有就直接将回复的消息插入已回复列表中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void ChatThreadData::MoveMsg(std::shared_ptr<ChatDataBase> msg) {
auto iter = _msg_unrsp_map.find(msg->GetUniqueId());
if (iter == _msg_unrsp_map.end()) {
AddMsg(msg);
return;
}

iter.value()->SetStatus(2);
AddMsg(iter.value());
_msg_unrsp_map.erase(iter);
}

void ChatThreadData::AddMsg(std::shared_ptr<ChatDataBase> msg)
{
_msg_map.insert(msg->GetMsgId(), msg);
_last_msg = msg->GetMsgContent();
_last_msg_id = msg->GetMsgId();
}

对端收到消息通知

客户端对端收到通知消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
_handlers.insert(ID_NOTIFY_TEXT_CHAT_MSG_REQ, [this](ReqId id, int len, QByteArray data) {
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "Notify Chat Msg Failed, err is Json Parse Err" << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Notify Chat Msg Failed, err is " << err;
return;
}

qDebug() << "Receive Text Chat Notify Success " ;

//收到消息后转发给页面
auto thread_id = jsonObj["thread_id"].toInt();
auto sender = jsonObj["fromuid"].toInt();


std::vector<std::shared_ptr<TextChatData>> chat_datas;
for (const QJsonValue& data : jsonObj["chat_datas"].toArray()) {
auto msg_id = data["message_id"].toInt();
auto unique_id = data["unique_id"].toString();
auto msg_content = data["content"].toString();
QString chat_time = data["chat_time"].toString();
int status = data["status"].toInt();
auto chat_data = std::make_shared<TextChatData>(msg_id, unique_id, thread_id, ChatFormType::PRIVATE,
ChatMsgType::TEXT, msg_content, sender, status, chat_time);
chat_datas.push_back(chat_data);
}


emit sig_text_chat_msg(chat_datas);
});

这个消息连接槽函数

1
2
3
//连接对端消息通知
connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_text_chat_msg,
this, &ChatDialog::slot_text_chat_msg);

因为被通知,可能此时不在对应的会话中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ChatDialog::slot_text_chat_msg(std::vector<std::shared_ptr<TextChatData>> msglists)
{
for (auto& msg : msglists) {

//更新数据
auto thread_id = msg->GetThreadId();
auto thread_data = UserMgr::GetInstance()->GetChatThreadByThreadId(thread_id);

thread_data->AddMsg(msg);

if (_cur_chat_thread_id != thread_id) {
continue;
}

ui->chat_page->AppendChatMsg(msg);
}

}

服务器逻辑

服务器在收到聊天消息后要将消息入库,并且判断对方是否通服,如果不是一个服务器,则用grpc通知对方所在的服务器,再通过对方服务器的Session通知对方。

如果是同一个服务器,则直接通过Session通知对方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
void LogicSystem::DealChatTextMsg(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);

auto uid = root["fromuid"].asInt();
auto touid = root["touid"].asInt();

const Json::Value arrays = root["text_array"];

Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;

rtvalue["fromuid"] = uid;
rtvalue["touid"] = touid;
auto thread_id = root["thread_id"].asInt();
rtvalue["thread_id"] = thread_id;
std::vector<std::shared_ptr<ChatMessage>> chat_datas;
auto timestamp = getCurrentTimestamp();
for (const auto& txt_obj : arrays) {
auto content = txt_obj["content"].asString();
auto unique_id = txt_obj["unique_id"].asString();
std::cout << "content is " << content << std::endl;
std::cout << "unique_id is " << unique_id << std::endl;
auto chat_msg = std::make_shared<ChatMessage>();
chat_msg->chat_time = timestamp;
chat_msg->sender_id = uid;
chat_msg->recv_id = touid;
chat_msg->unique_id = unique_id;
chat_msg->thread_id = thread_id;
chat_msg->content = content;
chat_msg->status = 2;
chat_datas.push_back(chat_msg);
}


//插入数据库
MysqlMgr::GetInstance()->AddChatMsg(chat_datas);


for (const auto& chat_data : chat_datas) {
Json::Value chat_msg;
chat_msg["message_id"] = chat_data->message_id;
chat_msg["unique_id"] = chat_data->unique_id;
chat_msg["content"] = chat_data->content;
chat_msg["status"] = chat_data->status;
chat_msg["chat_time"] = chat_data->chat_time;
rtvalue["chat_datas"].append(chat_msg);
}

Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_TEXT_CHAT_MSG_RSP);
});


//查询redis 查找touid对应的server ip
auto to_str = std::to_string(touid);
auto to_ip_key = USERIPPREFIX + to_str;
std::string to_ip_value = "";
bool b_ip = RedisMgr::GetInstance()->Get(to_ip_key, to_ip_value);
if (!b_ip) {
return;
}

auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
//直接通知对方有认证通过消息
if (to_ip_value == self_name) {
auto session = UserMgr::GetInstance()->GetSession(touid);
if (session) {
//在内存中则直接发送通知对方
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, ID_NOTIFY_TEXT_CHAT_MSG_REQ);
}

return ;
}


TextChatMsgReq text_msg_req;
text_msg_req.set_fromuid(uid);
text_msg_req.set_touid(touid);
text_msg_req.set_thread_id(thread_id);
for (const auto& chat_data : chat_datas) {
auto *text_msg = text_msg_req.add_textmsgs();
text_msg->set_unique_id(chat_data->unique_id);
text_msg->set_msgcontent(chat_data->content);
text_msg->set_msg_id(chat_data->message_id);
text_msg->set_chat_time(chat_data->chat_time);
}


//发送通知 todo...
ChatGrpcClient::GetInstance()->NotifyTextChatMsg(to_ip_value, text_msg_req, rtvalue);
}

数据库处理

1
2
3
bool MysqlMgr::AddChatMsg(std::vector<std::shared_ptr<ChatMessage>>& chat_datas) {
return _dao.AddChatMsg(chat_datas);
}

Dao层做了详细的数据库操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
bool MysqlDao::AddChatMsg(std::vector<std::shared_ptr<ChatMessage>>& chat_datas) {
auto con = pool_->getConnection();
if (!con) {
return false;
}
Defer defer([this, &con]() {
pool_->returnConnection(std::move(con));
});
auto& conn = con->_con;


try {
//关闭自动提交,以手动管理事务
conn->setAutoCommit(false);
auto pstmt = std::unique_ptr<sql::PreparedStatement>(
conn->prepareStatement(
"INSERT INTO chat_message "
"(thread_id, sender_id, recv_id, content, created_at, updated_at, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?)"
)
);

for (auto& msg : chat_datas) {
// 普通字段
pstmt->setUInt64(1, msg->thread_id);
pstmt->setUInt64(2, msg->sender_id);
pstmt->setUInt64(3, msg->recv_id);
pstmt->setString(4, msg->content);

pstmt->setString(5, msg->chat_time); // created_at
pstmt->setString(6, msg->chat_time); // updated_at

pstmt->setInt(7, msg->status);
pstmt->executeUpdate();

// 2. 取 LAST_INSERT_ID()
std::unique_ptr<sql::Statement> keyStmt(
conn->createStatement()
);
std::unique_ptr<sql::ResultSet> rs(
keyStmt->executeQuery("SELECT LAST_INSERT_ID()")
);
if (rs->next()) {
msg->message_id = rs->getUInt64(1);
}
else {
continue;
}
}

conn->commit();
return true;
}
catch (sql::SQLException& e) {
std::cerr << "SQLException: " << e.what() << std::endl;
conn->rollback();
return false;
}
return true;

}

grpc协议完善

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
message TextChatMsgReq {
int32 fromuid = 1;
int32 touid = 2;
int32 thread_id = 3;
repeated TextChatData textmsgs = 4;
}

message TextChatData{
string unique_id = 1;
int32 msg_id = 2;
string msgcontent = 3;
string chat_time = 4;
}

message TextChatMsgRsp {
int32 error = 1;
int32 fromuid = 2;
int32 touid = 3;
int32 thread_id = 4;
repeated TextChatData textmsgs = 5;
}

对端服务器处理

如果客户不在本服,则通知对端服务处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Status ChatServiceImpl::NotifyTextChatMsg(::grpc::ServerContext* context,
const TextChatMsgReq* request, TextChatMsgRsp* reply) {
//查找用户是否在本服务器
auto touid = request->touid();
auto session = UserMgr::GetInstance()->GetSession(touid);
reply->set_error(ErrorCodes::Success);

//用户不在内存中则直接返回
if (session == nullptr) {
return Status::OK;
}

//在内存中则直接发送通知对方
Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
rtvalue["fromuid"] = request->fromuid();
rtvalue["touid"] = request->touid();
rtvalue["thread_id"] = request->thread_id();
//将聊天数据组织为数组
Json::Value text_array;
for (auto& msg : request->textmsgs()) {
Json::Value element;
element["content"] = msg.msgcontent();
element["unique_id"] = msg.unique_id();
element["message_id"] = msg.msg_id();
element["chat_time"] = msg.chat_time();
text_array.append(element);
}
rtvalue["chat_datas"] = text_array;

std::string return_str = rtvalue.toStyledString();

session->Send(return_str, ID_NOTIFY_TEXT_CHAT_MSG_REQ);
return Status::OK;
}

验证效果

image-20250725233631386

待完善部分

目前切换页面会将之前的记录删掉,这样每次重新加载会影响效率。

考虑以后采用多页缓存机制。

以后用Model View Delegate改造数据存储模式。

使用 Model/View 架构(QListView + QAbstractListModel + Delegate)

  • 思路:不要手动往布局里插 widget,而是把 “一条聊天消息” 抽象成一个数据结构,存到自定义的 QAbstractListModel。
  • 在右侧放一个 QListView,并为它写一个 QStyledItemDelegate,统一负责绘制消息气泡、头像、时间等。
  • 优点:Qt 的视图会自动做 行缓存(view recycling)、懒加载 等优化,数据量大也能保持流畅。
  • 切换用户:只需 model->setMessages(userMessages)(内部发 beginResetModel()/endResetModel()),视图自动刷新。

方案一:在同一个 Model 里 reset 数据

  1. 维护一个消息列表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class ChatModel : public QAbstractListModel {
    QVector<Message> m_msgs;
    public:
    // 必要的 override:rowCount(), data(), roleNames()...

    void setMessages(const QVector<Message>& msgs) {
    beginResetModel();
    m_msgs = msgs;
    endResetModel();
    }
    };
  2. 切换用户时

    1
    2
    3
    4
    5
    6
    7
    8
    // 假设你有一个 ChatModel* model 和 QListView* listView
    // listView->setModel(model); // 已经在初始化时做过一次
    void onUserClicked(const User& u) {
    QVector<Message> msgs = loadMessagesFromDb(u.id);
    model->setMessages(msgs);
    // 可选:滚到最底部
    listView->scrollToBottom();
    }
  3. 优点

    • 结构简单,一处 model,view 自动刷新。
    • 不需要销毁或创建 widget,性能最佳。

方案二:每个用户一个 Model,切换指针

如果你希望把每个用户的数据和 model 分开管理,也可以为每个用户维护独立的 ChatModel:

1
2
3
4
5
6
7
8
9
10
11
12
QMap<UserId, ChatModel*> modelPool;

void onUserClicked(const User& u) {
if (!modelPool.contains(u.id)) {
// 第一次点击,创建并加载
ChatModel* m = new ChatModel(this);
m->setMessages(loadMessagesFromDb(u.id));
modelPool[u.id] = m;
}
listView->setModel(modelPool[u.id]);
listView->scrollToBottom();
}
  • 优点:切换立刻就有缓存好的数据,不用每次都从数据库/网络加载。
  • 缺点:如果用户特别多,内存开销会比较大。

更细粒度的更新

如果你不想一次 beginResetModel()/endResetModel() 重刷全表,还可以在 model 里实现增删改接口:

1
2
3
4
5
6
7
8
9
10
void ChatModel::appendMessage(const Message& m) {
beginInsertRows(QModelIndex(), m_msgs.size(), m_msgs.size());
m_msgs.append(m);
endInsertRows();
}
void ChatModel::clearMessages() {
beginRemoveRows(QModelIndex(), 0, m_msgs.size()-1);
m_msgs.clear();
endRemoveRows();
}
  • 切换用户时先 clearMessages(),然后循环 appendMessage()。
  • 这样 view 能做更细粒度的动画或局部刷新。

总结

  • 最简单:一个 model,内部维护 QVector<Message>,切换时调用 setMessages()。
  • 缓存多用户:给每个用户分配一个 model,切换时调用 listView->setModel(...)。
  • 增量更新:用 beginInsertRows/beginRemoveRows 实现局部刷新。

选哪种方案,取决于你的聊天数据量和内存/加载开销:

  • 少量用户、消息量大 → 方案一(reset)+ 分页加载
  • 用户量多、切换频繁 → 方案二(model 池)
  • 想要炫酷的动画或更精细性能 → 增量更新。

QT实现头像裁剪功能

Posted on 2025-05-11 | In C++聊天项目

前情回顾

前文我们实现了心跳,今天来实现头像框裁剪的功能,为以后头像上传和资源服务器做准备。

大体上头像上传框的效果如下

image-20250511075018888

添加设置页面

我们需要在聊天对话框左侧添加设置按钮

image-20250511075548367

左侧设置按钮是我们封装的类StateWidget

image-20250511075648519

右侧添加UserInfoPage界面

image-20250511075822544

UserInfoPage界面布局

image-20250511082150907

属性表

image-20250511082230811

头像裁剪逻辑

点击上传按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//上传头像
void UserInfoPage::on_up_btn_clicked()
{
// 1. 让对话框也能选 *.webp
QString filename = QFileDialog::getOpenFileName(
this,
tr("选择图片"),
QString(),
tr("图片文件 (*.png *.jpg *.jpeg *.bmp *.webp)")
);
if (filename.isEmpty())
return;

// 2. 直接用 QPixmap::load() 加载,无需手动区分格式
QPixmap inputImage;
if (!inputImage.load(filename)) {
QMessageBox::critical(
this,
tr("错误"),
tr("加载图片失败!请确认已部署 WebP 插件。"),
QMessageBox::Ok
);
return;
}

QPixmap image = ImageCropperDialog::getCroppedImage(filename, 600, 400, CropperShape::CIRCLE);
if (image.isNull())
return;

QPixmap scaledPixmap = image.scaled( ui->head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小

QString storageDir = QStandardPaths::writableLocation(
QStandardPaths::AppDataLocation);
// 2. 在其下再建一个 avatars 子目录
QDir dir(storageDir);
if (!dir.exists("avatars")) {
if (!dir.mkpath("avatars")) {
qWarning() << "无法创建 avatars 目录:" << dir.filePath("avatars");
QMessageBox::warning(
this,
tr("错误"),
tr("无法创建存储目录,请检查权限或磁盘空间。")
);
return;
}
}
// 3. 拼接最终的文件名 head.png
QString filePath = dir.filePath("avatars/head.png");

// 4. 保存 scaledPixmap 为 PNG(无损、最高质量)
if (!scaledPixmap.save(filePath, "PNG")) {
QMessageBox::warning(
this,
tr("保存失败"),
tr("头像保存失败,请检查权限或磁盘空间。")
);
} else {
qDebug() << "头像已保存到:" << filePath;
// 以后读取直接用同一路径:storageDir/avatars/head.png
}
}

内部调用了我们的ImageCropperDialog,弹出对话框后会显示裁剪图片的界面。

接下来我们看看ImageCropperDialog实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#ifndef IMAGECROPPER_H
#define IMAGECROPPER_H

#include <QWidget>
#include <QDialog>
#include <QPainter>
#include <QLabel>
#include <QPixmap>
#include <QString>
#include <QMessageBox>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QPushButton>

#include "imagecropperlabel.h"

/*******************************************************
* Loacl private class, which do image-cropping
* Used in class ImageCropper
*******************************************************/
class ImageCropperDialogPrivate : public QDialog {
Q_OBJECT
public:
ImageCropperDialogPrivate(const QPixmap& imageIn, QPixmap& outputImage,
int windowWidth, int windowHeight,
CropperShape shape, QSize cropperSize = QSize()) :
QDialog(nullptr), outputImage(outputImage)
{
this->setAttribute(Qt::WA_DeleteOnClose, true);
this->setWindowTitle("Image Cropper");
this->setMouseTracking(true);
this->setModal(true);

imageLabel = new ImageCropperLabel(windowWidth, windowHeight, this);
imageLabel->setCropper(shape, cropperSize);
imageLabel->setOutputShape(OutputShape::RECT);
imageLabel->setOriginalImage(imageIn);
imageLabel->enableOpacity(true);

QHBoxLayout* btnLayout = new QHBoxLayout();
btnOk = new QPushButton("OK", this);
btnCancel = new QPushButton("Cancel", this);
btnLayout->addStretch();
btnLayout->addWidget(btnOk);
btnLayout->addWidget(btnCancel);

QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(imageLabel);
mainLayout->addLayout(btnLayout);

connect(btnOk, &QPushButton::clicked, this, [this](){
this->outputImage = this->imageLabel->getCroppedImage();
this->close();
});
connect(btnCancel, &QPushButton::clicked, this, [this](){
this->outputImage = QPixmap();
this->close();
});
}

private:
ImageCropperLabel* imageLabel;
QPushButton* btnOk;
QPushButton* btnCancel;
QPixmap& outputImage;
};


/*******************************************************************
* class ImageCropperDialog
* create a instane of class ImageCropperDialogPrivate
* and get cropped image from the instance(after closing)
********************************************************************/
class ImageCropperDialog : QObject {
public:
static QPixmap getCroppedImage(const QString& filename,int windowWidth, int windowHeight,
CropperShape cropperShape, QSize crooperSize = QSize())
{
QPixmap inputImage;
QPixmap outputImage;

if (!inputImage.load(filename)) {
QMessageBox::critical(nullptr, "Error", "Load image failed!", QMessageBox::Ok);
return outputImage;
}

ImageCropperDialogPrivate* imageCropperDo =
new ImageCropperDialogPrivate(inputImage, outputImage,
windowWidth, windowHeight,
cropperShape, crooperSize);
imageCropperDo->exec();

return outputImage;
}
};



#endif // IMAGECROPPER_H

私有对话框

  1. 继承自 QDialog
    • QDialog(nullptr):以无父窗口方式创建,独立弹出。
    • Qt::WA_DeleteOnClose:关闭时自动 delete 对象,防止内存泄漏。
    • setModal(true):对话框模式,阻塞主窗口输入。
  2. 成员变量
    • ImageCropperLabel* imageLabel:自定义裁剪视图。
    • QPushButton* btnOk, btnCancel:确认/取消按钮。
    • QPixmap& outputImage:引用外部提供的 QPixmap,用来保存裁剪结果。
  3. 布局管理
    • 水平布局 (QHBoxLayout) 放置按钮并居右。
    • 垂直布局 (QVBoxLayout) 先是大图,再是按钮区。
  4. Lambda 连接信号与槽
    • OK 时,将裁剪后的图像复制给外部引用,然后 close()。
    • Cancel 时,将 outputImage 置空,表示用户放弃裁剪。

静态对话框

  • 统一接口:只要一行 ImageCropperDialog::getCroppedImage(…),就能弹出裁剪 UI 并获取结果。
  • 输入合法性检查:先用 QPixmap::load() 加载文件,失败则弹错并返回空图。
  • 阻塞执行:exec() 会进入本地事件循环,直到用户点击 OK/Cancel 关闭对话框。
  • 返回结果:通过外部引用 outputImage 将裁剪结果“带出”函数作用域。

image-20250511112606921

头像裁剪控件

头文件声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/*************************************************************************
* class: ImageCropperLabel
* author: github@Leopard-C
* email: leopard.c@outlook.com
* last change: 2020-03-06
*************************************************************************/
#ifndef IMAGECROPPERLABEL_H
#define IMAGECROPPERLABEL_H

#include <QLabel>
#include <QPixmap>
#include <QPen>

enum class CropperShape {
UNDEFINED = 0,
RECT = 1,
SQUARE = 2,
FIXED_RECT = 3,
ELLIPSE = 4,
CIRCLE = 5,
FIXED_ELLIPSE = 6
};

enum class OutputShape {
RECT = 0,
ELLIPSE = 1
};

enum class SizeType {
fixedSize = 0,
fitToMaxWidth = 1,
fitToMaxHeight = 2,
fitToMaxWidthHeight = 3,
};


class ImageCropperLabel : public QLabel {
Q_OBJECT
public:
ImageCropperLabel(int width, int height, QWidget* parent);

void setOriginalImage(const QPixmap& pixmap);
void setOutputShape(OutputShape shape) { outputShape = shape; }
QPixmap getCroppedImage();
QPixmap getCroppedImage(OutputShape shape);

/*****************************************
* Set cropper's shape
*****************************************/
void setRectCropper();
void setSquareCropper();
void setEllipseCropper();
void setCircleCropper();
void setFixedRectCropper(QSize size);
void setFixedEllipseCropper(QSize size);
void setCropper(CropperShape shape, QSize size); // not recommended

/*****************************************************************************
* Set cropper's fixed size
*****************************************************************************/
void setCropperFixedSize(int fixedWidth, int fixedHeight);
void setCropperFixedWidth(int fixedWidht);
void setCropperFixedHeight(int fixedHeight);

/*****************************************************************************
* Set cropper's minimum size
* default: the twice of minimum of the edge lenght of drag square
*****************************************************************************/
void setCropperMinimumSize(int minWidth, int minHeight)
{ cropperMinimumWidth = minWidth; cropperMinimumHeight = minHeight; }
void setCropperMinimumWidth(int minWidth) { cropperMinimumWidth = minWidth; }
void setCropperMinimumHeight(int minHeight) { cropperMinimumHeight = minHeight; }

/*************************************************
* Set the size, color, visibility of rectangular border
*************************************************/
void setShowRectBorder(bool show) { isShowRectBorder = show; }
QPen getBorderPen() { return borderPen; }
void setBorderPen(const QPen& pen) { borderPen = pen; }

/*************************************************
* Set the size, color of drag square
*************************************************/
void setShowDragSquare(bool show) { isShowDragSquare = show; }
void setDragSquareEdge(int edge) { dragSquareEdge = (edge >= 3 ? edge : 3); }
void setDragSquareColor(const QColor& color) { dragSquareColor = color; }

/*****************************************
* Opacity Effect
*****************************************/
void enableOpacity(bool b = true) { isShowOpacityEffect = b; }
void setOpacity(double newOpacity) { opacity = newOpacity; }

signals:
void croppedImageChanged();

protected:
/*****************************************
* Event
*****************************************/
virtual void paintEvent(QPaintEvent *event) override;
virtual void mousePressEvent(QMouseEvent *e) override;
virtual void mouseMoveEvent(QMouseEvent *e) override;
virtual void mouseReleaseEvent(QMouseEvent *e) override;

private:
/***************************************
* Draw shapes
***************************************/
void drawFillRect(QPoint centralPoint, int edge, QColor color);
void drawRectOpacity();
void drawEllipseOpacity();
void drawOpacity(const QPainterPath& path); // shadow effect
void drawSquareEdge(bool onlyFourCorners);

/***************************************
* Other utility methods
***************************************/
int getPosInCropperRect(const QPoint& pt);
bool isPosNearDragSquare(const QPoint& pt1, const QPoint& pt2);
void resetCropperPos();
void changeCursor();

enum {
RECT_OUTSIZD = 0,
RECT_INSIDE = 1,
RECT_TOP_LEFT, RECT_TOP, RECT_TOP_RIGHT, RECT_RIGHT,
RECT_BOTTOM_RIGHT, RECT_BOTTOM, RECT_BOTTOM_LEFT, RECT_LEFT
};

const bool ONLY_FOUR_CORNERS = true;

private:
QPixmap originalImage;
QPixmap tempImage;

bool isShowRectBorder = true;
QPen borderPen;

CropperShape cropperShape = CropperShape::UNDEFINED;
OutputShape outputShape = OutputShape::RECT;

QRect imageRect; // the whole image area in the label (not real size)
QRect cropperRect; // a rectangle frame to choose image area (not real size)
QRect cropperRect_; // cropper rect (real size)
double scaledRate = 1.0;

bool isLButtonPressed = false;
bool isCursorPosCalculated = false;
int cursorPosInCropperRect = RECT_OUTSIZD;
QPoint lastPos;
QPoint currPos;

bool isShowDragSquare = true;
int dragSquareEdge = 8;
QColor dragSquareColor = Qt::white;

int cropperMinimumWidth = dragSquareEdge * 2;
int cropperMinimumHeight = dragSquareEdge * 2;

bool isShowOpacityEffect = false;
double opacity = 0.6;
};

#endif // IMAGECROPPERLABEL_H

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
#include "imagecropperlabel.h"

#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QDebug>
#include <QBitmap>

ImageCropperLabel::ImageCropperLabel(int width, int height, QWidget* parent) :
QLabel(parent)
{
this->setFixedSize(width, height);
this->setAlignment(Qt::AlignCenter);
this->setMouseTracking(true);

borderPen.setWidth(1);
borderPen.setColor(Qt::white);
borderPen.setDashPattern(QVector<qreal>() << 3 << 3 << 3 << 3);
}

void ImageCropperLabel::setOriginalImage(const QPixmap &pixmap) {
originalImage = pixmap;

int imgWidth = pixmap.width();
int imgHeight = pixmap.height();
int labelWidth = this->width();
int labelHeight = this->height();
int imgWidthInLabel;
int imgHeightInLabel;

if (imgWidth * labelHeight < imgHeight * labelWidth) {
scaledRate = labelHeight / double(imgHeight);
imgHeightInLabel = labelHeight;
imgWidthInLabel = int(scaledRate * imgWidth);
imageRect.setRect((labelWidth - imgWidthInLabel) / 2, 0,
imgWidthInLabel, imgHeightInLabel);
}
else {
scaledRate = labelWidth / double(imgWidth);
imgWidthInLabel = labelWidth;
imgHeightInLabel = int(scaledRate * imgHeight);
imageRect.setRect(0, (labelHeight - imgHeightInLabel) / 2,
imgWidthInLabel, imgHeightInLabel);
}

tempImage = originalImage.scaled(imgWidthInLabel, imgHeightInLabel,
Qt::KeepAspectRatio, Qt::SmoothTransformation);
this->setPixmap(tempImage);

if (cropperShape >= CropperShape::FIXED_RECT) {
cropperRect.setWidth(int(cropperRect_.width() * scaledRate));
cropperRect.setHeight(int(cropperRect_.height() * scaledRate));
}
resetCropperPos();
}


/*****************************************
* set cropper's shape (and size)
*****************************************/
void ImageCropperLabel::setRectCropper() {
cropperShape = CropperShape::RECT;
resetCropperPos();
}

void ImageCropperLabel::setSquareCropper() {
cropperShape = CropperShape::SQUARE;
resetCropperPos();
}

void ImageCropperLabel::setEllipseCropper() {
cropperShape = CropperShape::ELLIPSE;
resetCropperPos();
}

void ImageCropperLabel::setCircleCropper() {
cropperShape = CropperShape::CIRCLE;
resetCropperPos();
}

void ImageCropperLabel::setFixedRectCropper(QSize size) {
cropperShape = CropperShape::FIXED_RECT;
cropperRect_.setSize(size);
resetCropperPos();
}

void ImageCropperLabel::setFixedEllipseCropper(QSize size) {
cropperShape = CropperShape::FIXED_ELLIPSE;
cropperRect_.setSize(size);
resetCropperPos();
}

// not recommended
void ImageCropperLabel::setCropper(CropperShape shape, QSize size) {
cropperShape = shape;
cropperRect_.setSize(size);
resetCropperPos();
}

/*****************************************************************************
* Set cropper's fixed size
*****************************************************************************/
void ImageCropperLabel::setCropperFixedSize(int fixedWidth, int fixedHeight) {
cropperRect_.setSize(QSize(fixedWidth, fixedHeight));
resetCropperPos();
}

void ImageCropperLabel::setCropperFixedWidth(int fixedWidth) {
cropperRect_.setWidth(fixedWidth);
resetCropperPos();
}

void ImageCropperLabel::setCropperFixedHeight(int fixedHeight) {
cropperRect_.setHeight(fixedHeight);
resetCropperPos();
}

/**********************************************
* Move cropper to the center of the image
* And resize to default
**********************************************/
void ImageCropperLabel::resetCropperPos() {
int labelWidth = this->width();
int labelHeight = this->height();

if (cropperShape == CropperShape::FIXED_RECT || cropperShape == CropperShape::FIXED_ELLIPSE) {
cropperRect.setWidth(int(cropperRect_.width() * scaledRate));
cropperRect.setHeight(int(cropperRect_.height() * scaledRate));
}

switch (cropperShape) {
case CropperShape::UNDEFINED:
break;
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE: {
cropperRect.setRect((labelWidth - cropperRect.width()) / 2,
(labelHeight - cropperRect.height()) / 2,
cropperRect.width(), cropperRect.height());
break;
}
case CropperShape::RECT:
case CropperShape::SQUARE:
case CropperShape::ELLIPSE:
case CropperShape::CIRCLE: {
int imgWidth = tempImage.width();
int imgHeight = tempImage.height();
int edge = int((imgWidth > imgHeight ? imgHeight : imgWidth) * 3 / 4.0);
cropperRect.setRect((labelWidth - edge) / 2, (labelHeight - edge) / 2, edge, edge);
break;
}
}
}

QPixmap ImageCropperLabel::getCroppedImage() {
return getCroppedImage(this->outputShape);
}

QPixmap ImageCropperLabel::getCroppedImage(OutputShape shape) {
int startX = int((cropperRect.left() - imageRect.left()) / scaledRate);
int startY = int((cropperRect.top() - imageRect.top()) / scaledRate);
int croppedWidth = int(cropperRect.width() / scaledRate);
int croppedHeight = int(cropperRect.height() / scaledRate);

QPixmap resultImage(croppedWidth, croppedHeight);
resultImage = originalImage.copy(startX, startY, croppedWidth, croppedHeight);

// Set ellipse mask (cut to ellipse shape)
if (shape == OutputShape::ELLIPSE) {
QSize size(croppedWidth, croppedHeight);
QBitmap mask(size);
QPainter painter(&mask);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.fillRect(0, 0, size.width(), size.height(), Qt::white);
painter.setBrush(QColor(0, 0, 0));
painter.drawRoundRect(0, 0, size.width(), size.height(), 99, 99);
resultImage.setMask(mask);
}

return resultImage;
}


void ImageCropperLabel::paintEvent(QPaintEvent *event) {
// Draw original image
QLabel::paintEvent(event);

// Draw cropper and set some effects
switch (cropperShape) {
case CropperShape::UNDEFINED:
break;
case CropperShape::FIXED_RECT:
drawRectOpacity();
break;
case CropperShape::FIXED_ELLIPSE:
drawEllipseOpacity();
break;
case CropperShape::RECT:
drawRectOpacity();
drawSquareEdge(!ONLY_FOUR_CORNERS);
break;
case CropperShape::SQUARE:
drawRectOpacity();
drawSquareEdge(ONLY_FOUR_CORNERS);
break;
case CropperShape::ELLIPSE:
drawEllipseOpacity();
drawSquareEdge(!ONLY_FOUR_CORNERS);
break;
case CropperShape::CIRCLE:
drawEllipseOpacity();
drawSquareEdge(ONLY_FOUR_CORNERS);
break;
}

// Draw cropper rect
if (isShowRectBorder) {
QPainter painter(this);
painter.setPen(borderPen);
painter.drawRect(cropperRect);
}
}

void ImageCropperLabel::drawSquareEdge(bool onlyFourCorners) {
if (!isShowDragSquare)
return;

// Four corners
drawFillRect(cropperRect.topLeft(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.topRight(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.bottomLeft(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.bottomRight(), dragSquareEdge, dragSquareColor);

// Four edges
if (!onlyFourCorners) {
int centralX = cropperRect.left() + cropperRect.width() / 2;
int centralY = cropperRect.top() + cropperRect.height() / 2;
drawFillRect(QPoint(cropperRect.left(), centralY), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(centralX, cropperRect.top()), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(cropperRect.right(), centralY), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(centralX, cropperRect.bottom()), dragSquareEdge, dragSquareColor);
}
}

void ImageCropperLabel::drawFillRect(QPoint centralPoint, int edge, QColor color) {
QRect rect(centralPoint.x() - edge / 2, centralPoint.y() - edge / 2, edge, edge);
QPainter painter(this);
painter.fillRect(rect, color);
}

// Opacity effect
void ImageCropperLabel::drawOpacity(const QPainterPath& path) {
QPainter painterOpac(this);
painterOpac.setOpacity(opacity);
painterOpac.fillPath(path, QBrush(Qt::black));
}

void ImageCropperLabel::drawRectOpacity() {
if (isShowOpacityEffect) {
QPainterPath p1, p2, p;
p1.addRect(imageRect);
p2.addRect(cropperRect);
p = p1.subtracted(p2);
drawOpacity(p);
}
}

void ImageCropperLabel::drawEllipseOpacity() {
if (isShowOpacityEffect) {
QPainterPath p1, p2, p;
p1.addRect(imageRect);
p2.addEllipse(cropperRect);
p = p1.subtracted(p2);
drawOpacity(p);
}
}

bool ImageCropperLabel::isPosNearDragSquare(const QPoint& pt1, const QPoint& pt2) {
return abs(pt1.x() - pt2.x()) * 2 <= dragSquareEdge
&& abs(pt1.y() - pt2.y()) * 2 <= dragSquareEdge;
}

int ImageCropperLabel::getPosInCropperRect(const QPoint &pt) {
if (isPosNearDragSquare(pt, QPoint(cropperRect.right(), cropperRect.center().y())))
return RECT_RIGHT;
if (isPosNearDragSquare(pt, cropperRect.bottomRight()))
return RECT_BOTTOM_RIGHT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.center().x(), cropperRect.bottom())))
return RECT_BOTTOM;
if (isPosNearDragSquare(pt, cropperRect.bottomLeft()))
return RECT_BOTTOM_LEFT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.left(), cropperRect.center().y())))
return RECT_LEFT;
if (isPosNearDragSquare(pt, cropperRect.topLeft()))
return RECT_TOP_LEFT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.center().x(), cropperRect.top())))
return RECT_TOP;
if (isPosNearDragSquare(pt, cropperRect.topRight()))
return RECT_TOP_RIGHT;
if (cropperRect.contains(pt, true))
return RECT_INSIDE;
return RECT_OUTSIZD;
}

/*************************************************
*
* Change mouse cursor type
* Arrow, SizeHor, SizeVer, etc...
*
*************************************************/

void ImageCropperLabel::changeCursor() {
switch (cursorPosInCropperRect) {
case RECT_OUTSIZD:
setCursor(Qt::ArrowCursor);
break;
case RECT_BOTTOM_RIGHT: {
switch (cropperShape) {
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeFDiagCursor);
break;
default:
break;
}
break;
}
case RECT_RIGHT: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeHorCursor);
break;
default:
break;
}
break;
}
case RECT_BOTTOM: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeVerCursor);
break;
default:
break;
}
break;
}
case RECT_BOTTOM_LEFT: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
setCursor(Qt::SizeBDiagCursor);
break;
default:
break;
}
break;
}
case RECT_LEFT: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeHorCursor);
break;
default:
break;
}
break;
}
case RECT_TOP_LEFT: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
setCursor(Qt::SizeFDiagCursor);
break;
default:
break;
}
break;
}
case RECT_TOP: {
switch (cropperShape) {
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeVerCursor);
break;
default:
break;
}
break;
}
case RECT_TOP_RIGHT: {
switch (cropperShape) {
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeBDiagCursor);
break;
default:
break;
}
break;
}
case RECT_INSIDE: {
setCursor(Qt::SizeAllCursor);
break;
}
}
}

/*****************************************************
*
* Mouse Events
*
*****************************************************/

void ImageCropperLabel::mousePressEvent(QMouseEvent *e) {
currPos = lastPos = e->pos();
isLButtonPressed = true;
}

void ImageCropperLabel::mouseMoveEvent(QMouseEvent *e) {
currPos = e->pos();
if (!isCursorPosCalculated) {
cursorPosInCropperRect = getPosInCropperRect(currPos);
changeCursor();
}

if (!isLButtonPressed)
return;
if (!imageRect.contains(currPos))
return;

isCursorPosCalculated = true;

int xOffset = currPos.x() - lastPos.x();
int yOffset = currPos.y() - lastPos.y();
lastPos = currPos;

int disX = 0;
int disY = 0;

// Move cropper
switch (cursorPosInCropperRect) {
case RECT_OUTSIZD:
break;
case RECT_BOTTOM_RIGHT: {
disX = currPos.x() - cropperRect.left();
disY = currPos.y() - cropperRect.top();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
break;
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
setCursor(Qt::SizeFDiagCursor);
if (disX >= cropperMinimumWidth && disY >= cropperMinimumHeight) {
if (disX > disY && cropperRect.top() + disX <= imageRect.bottom()) {
cropperRect.setRight(currPos.x());
cropperRect.setBottom(cropperRect.top() + disX);
emit croppedImageChanged();
}
else if (disX <= disY && cropperRect.left() + disY <= imageRect.right()) {
cropperRect.setBottom(currPos.y());
cropperRect.setRight(cropperRect.left() + disY);
emit croppedImageChanged();
}
}
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeFDiagCursor);
if (disX >= cropperMinimumWidth) {
cropperRect.setRight(currPos.x());
emit croppedImageChanged();
}
if (disY >= cropperMinimumHeight) {
cropperRect.setBottom(currPos.y());
emit croppedImageChanged();
}
break;
}
break;
}
case RECT_RIGHT: {
disX = currPos.x() - cropperRect.left();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disX >= cropperMinimumWidth) {
cropperRect.setRight(currPos.x());
emit croppedImageChanged();
}
break;
}
break;
}
case RECT_BOTTOM: {
disY = currPos.y() - cropperRect.top();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disY >= cropperMinimumHeight) {
cropperRect.setBottom(cropperRect.bottom() + yOffset);
emit croppedImageChanged();
}
break;
}
break;
}
case RECT_BOTTOM_LEFT: {
disX = cropperRect.right() - currPos.x();
disY = currPos.y() - cropperRect.top();
switch (cropperShape) {
case CropperShape::UNDEFINED:
break;
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disX >= cropperMinimumWidth) {
cropperRect.setLeft(currPos.x());
emit croppedImageChanged();
}
if (disY >= cropperMinimumHeight) {
cropperRect.setBottom(currPos.y());
emit croppedImageChanged();
}
break;
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
if (disX >= cropperMinimumWidth && disY >= cropperMinimumHeight) {
if (disX > disY && cropperRect.top() + disX <= imageRect.bottom()) {
cropperRect.setLeft(currPos.x());
cropperRect.setBottom(cropperRect.top() + disX);
emit croppedImageChanged();
}
else if (disX <= disY && cropperRect.right() - disY >= imageRect.left()) {
cropperRect.setBottom(currPos.y());
cropperRect.setLeft(cropperRect.right() - disY);
emit croppedImageChanged();
}
}
break;
}
break;
}
case RECT_LEFT: {
disX = cropperRect.right() - currPos.x();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disX >= cropperMinimumHeight) {
cropperRect.setLeft(cropperRect.left() + xOffset);
emit croppedImageChanged();
}
break;
}
break;
}
case RECT_TOP_LEFT: {
disX = cropperRect.right() - currPos.x();
disY = cropperRect.bottom() - currPos.y();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disX >= cropperMinimumWidth) {
cropperRect.setLeft(currPos.x());
emit croppedImageChanged();
}
if (disY >= cropperMinimumHeight) {
cropperRect.setTop(currPos.y());
emit croppedImageChanged();
}
break;
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
if (disX >= cropperMinimumWidth && disY >= cropperMinimumHeight) {
if (disX > disY && cropperRect.bottom() - disX >= imageRect.top()) {
cropperRect.setLeft(currPos.x());
cropperRect.setTop(cropperRect.bottom() - disX);
emit croppedImageChanged();
}
else if (disX <= disY && cropperRect.right() - disY >= imageRect.left()) {
cropperRect.setTop(currPos.y());
cropperRect.setLeft(cropperRect.right() - disY);
emit croppedImageChanged();
}
}
break;
}
break;
}
case RECT_TOP: {
disY = cropperRect.bottom() - currPos.y();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disY >= cropperMinimumHeight) {
cropperRect.setTop(cropperRect.top() + yOffset);
emit croppedImageChanged();
}
break;
}
break;
}
case RECT_TOP_RIGHT: {
disX = currPos.x() - cropperRect.left();
disY = cropperRect.bottom() - currPos.y();
switch (cropperShape) {
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
break;
case CropperShape::RECT:
case CropperShape::ELLIPSE:
if (disX >= cropperMinimumWidth) {
cropperRect.setRight(currPos.x());
emit croppedImageChanged();
}
if (disY >= cropperMinimumHeight) {
cropperRect.setTop(currPos.y());
emit croppedImageChanged();
}
break;
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
if (disX >= cropperMinimumWidth && disY >= cropperMinimumHeight) {
if (disX < disY && cropperRect.left() + disY <= imageRect.right()) {
cropperRect.setTop(currPos.y());
cropperRect.setRight(cropperRect.left() + disY);
emit croppedImageChanged();
}
else if (disX >= disY && cropperRect.bottom() - disX >= imageRect.top()) {
cropperRect.setRight(currPos.x());
cropperRect.setTop(cropperRect.bottom() - disX);
emit croppedImageChanged();
}
}
break;
}
break;
}
case RECT_INSIDE: {
// Make sure the cropperRect is entirely inside the imageRecct
if (xOffset > 0) {
if (cropperRect.right() + xOffset > imageRect.right())
xOffset = 0;
}
else if (xOffset < 0) {
if (cropperRect.left() + xOffset < imageRect.left())
xOffset = 0;
}
if (yOffset > 0) {
if (cropperRect.bottom() + yOffset > imageRect.bottom())
yOffset = 0;
}
else if (yOffset < 0) {
if (cropperRect.top() + yOffset < imageRect.top())
yOffset = 0;
}
cropperRect.moveTo(cropperRect.left() + xOffset, cropperRect.top() + yOffset);
emit croppedImageChanged();
}
break;
}

repaint();
}

void ImageCropperLabel::mouseReleaseEvent(QMouseEvent *) {
isLButtonPressed = false;
isCursorPosCalculated = false;
setCursor(Qt::ArrowCursor);
}

下面逐步讲解代码实现

枚举类型定义

1
2
3
enum class CropperShape { … };
enum class OutputShape { … };
enum class SizeType { … };
  • CropperShape:裁剪框的形状(矩形、正方形、椭圆、圆、以及固定尺寸的变种)。
  • OutputShape:导出时输出的形状,仅矩形或椭圆两种。
  • SizeType:内部用来控制当图片过大/过小时如何缩放至 Label 尺寸。

这些枚举让 API 更语义化、调用更直观。

类声明与成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class ImageCropperLabel : public QLabel {
Q_OBJECT
public:
ImageCropperLabel(int width, int height, QWidget* parent);
// … 设置图片、设置裁剪形状、获取结果等方法 …

signals:
void croppedImageChanged();

protected:
// 重载绘制与鼠标事件函数

private:
// 绘制辅助:drawFillRect、drawOpacity、drawRectOpacity 等
// 工具方法:getPosInCropperRect、resetCropperPos、changeCursor 等

// 状态变量
QPixmap originalImage; // 原始图片
QPixmap tempImage; // 缩放至 Label 尺寸后的临时位图

bool isShowRectBorder = true; // 是否画裁剪框边框
QPen borderPen; // 边框样式

CropperShape cropperShape = CropperShape::UNDEFINED;
OutputShape outputShape = OutputShape::RECT;

QRect imageRect; // 在 Label 中显示图片的区域(可能有留白)
QRect cropperRect; // 裁剪框在 Label 坐标系下的位置与大小
QRect cropperRect_; // “真实”像素尺寸下的参考矩形(仅固定尺寸时有效)
double scaledRate = 1.0;

// 拖拽、缩放交互相关
bool isLButtonPressed = false;
bool isCursorPosCalculated = false;
int cursorPosInCropperRect = 0; // 用上述匿名 enum 表示鼠标在裁剪框哪个位置
QPoint lastPos, currPos;

// 拖拽控制点样式
bool isShowDragSquare = true;
int dragSquareEdge = 8;
QColor dragSquareColor = Qt::white;

int cropperMinimumWidth = dragSquareEdge * 2;
int cropperMinimumHeight = dragSquareEdge * 2;

// 半透明遮罩
bool isShowOpacityEffect = false;
double opacity = 0.6;
};
  • 核心状态:存了原图、临时图、裁剪框位置、缩放比例等。
  • 交互状态:鼠标按下/移动、在哪个拖拽点、是否在拖拽中。
  • 可配置属性:边框、拖拽手柄、最小尺寸、遮罩效果等,通过 public 方法暴露给外部。

构造函数(Label 初始化)

1
2
3
4
5
6
7
8
9
10
11
ImageCropperLabel::ImageCropperLabel(int width, int height, QWidget* parent)
: QLabel(parent)
{
setFixedSize(width, height);
setAlignment(Qt::AlignCenter);
setMouseTracking(true); // 即使不按按钮也能收到 mouseMove 事件

borderPen.setWidth(1);
borderPen.setColor(Qt::white);
borderPen.setDashPattern(QVector<qreal>() << 3 << 3); // 虚线
}
  • 固定尺寸:确保裁剪界面大小一致,不随容器拉伸。
  • 居中显示:图片展示时居中。
  • 边框样式:白色虚线。

加载并缩放原图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void ImageCropperLabel::setOriginalImage(const QPixmap &pixmap) {
originalImage = pixmap;

// 计算在 label 里显示时的缩放比例和目标尺寸
if (imgWidth * labelHeight < imgHeight * labelWidth) {
scaledRate = labelHeight / double(imgHeight);
… compute imgWidthInLabel, imageRect …
} else {
… 另一种缩放方式 …
}

tempImage = originalImage.scaled(imgWidthInLabel, imgHeightInLabel,
Qt::KeepAspectRatio, Qt::SmoothTransformation);
setPixmap(tempImage);

// 如果是固定尺寸裁剪框,需要按同样比例缩放
if (cropperShape >= CropperShape::FIXED_RECT) {
cropperRect.setWidth(int(cropperRect_.width() * scaledRate));
…
}
resetCropperPos();
}
  • 按保持长宽比的方式,把原图缩放到 Label 区域内(letterbox 模式)。
  • **imageRect**:记录图像在 Label 坐标系下的实际绘制区域。
  • **tempImage**:在 Label 上展示的图,用于用户交互。

image-20250511114718983


image-20250511115038528

裁剪形状设置与重置

1
2
3
4
5
6
7
8
void ImageCropperLabel::setRectCropper()     { cropperShape = RECT; resetCropperPos(); }
… // 各种 setXXXCropper()

void ImageCropperLabel::resetCropperPos() {
// 根据 cropperShape,计算初始的 cropperRect:
// - 固定尺寸时居中铺满
// - 可变尺寸时取图片较短边的 3/4,居中
}
  • 统一调用:每次改变 shape 或大小,都调用 resetCropperPos() 让裁剪框回到可见区域中央。

image-20250511115825046


image-20250511120205707

获取裁剪结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
QPixmap ImageCropperLabel::getCroppedImage(OutputShape shape) {
// 1. 根据缩放比例,把 cropperRect 从 Label 坐标系映射到原图坐标系:
int startX = (cropperRect.left() - imageRect.left()) / scaledRate;
… compute croppedWidth, croppedHeight …

// 2. 从 originalImage 上 copy 出子图
QPixmap resultImage = originalImage.copy(startX, startY, cw, ch);

// 3. 如果输出椭圆,则用 QBitmap+setMask 做裁切
if (shape == OutputShape::ELLIPSE) {
QBitmap mask(size);
QPainter p(&mask);
p.fillRect(…, Qt::white);
p.setBrush(Qt::black);
p.drawRoundRect(0,0,w,h,99,99);
resultImage.setMask(mask);
}
return resultImage;
}

image-20250511120838187

  • 核心思路:先把用户框映射回原图,再按需求做矩形或椭圆裁剪。

为什么要除以 scaledRate?

  1. 背景:裁剪区域的坐标 (cropperRect) 和尺寸 (cropperRect.width(), cropperRect.height()) 都是相对于图像在显示中的位置和大小,而不是原始图像的大小。这意味着显示上的裁剪框可能已经被缩放过。因此,scaledRate 是一个缩放比例,用来将裁剪区域从显示坐标系统(可能已经缩放)转换回原始图像的坐标系统。

  2. 代码解释:

    • cropperRect.left() - imageRect.left() 表示裁剪框左边缘与原始图像左边缘的偏移量(即裁剪框相对于图像的起始位置)。
    • scaledRate 是图像在显示时的缩放比例(例如,显示的图像比原图小或大,scaledRate 可以是 1、0.5、2 等)。
    • 除以 scaledRate 就是将显示的坐标转换为原始图像的坐标。这样得到的是裁剪框在原始图像中的位置和大小。

    例如:假设 scaledRate = 0.5(显示图像是原图的 50%),则 cropperRect 表示的区域实际在原图中要乘以 2 才能得到正确的大小和位置。

为什么椭圆要单独处理?

裁剪区域的形状是矩形的,而图像本身可能要根据需求切割成不同的形状。如果要求裁剪区域是椭圆形状,那么矩形的裁剪区域必须通过遮罩(mask)来实现。

  1. 遮罩的作用:
    • 默认情况下,裁剪区域是矩形的。为了让裁剪后的图像呈现椭圆形状,我们需要用一个遮罩来过滤掉矩形区域之外的部分。
    • 通过绘制一个椭圆(在矩形区域内),并设置遮罩(mask),使得图像在该遮罩的范围内显示,超出范围的部分将变为透明。
  2. 椭圆处理的步骤:
    • 通过 QBitmap mask(size) 创建一个与裁剪区域大小相同的二值遮罩(黑白图像)。
    • 然后使用 QPainter 绘制一个椭圆形状。 drawRoundRect 方法画的其实是一个圆角矩形,但由于宽度和高度一样,且角的弯曲度非常高(99, 99),所以它的效果看起来是一个椭圆。
    • 最后,通过 resultImage.setMask(mask) 将这个椭圆形状应用到裁剪后的图像上,从而实现椭圆形的裁剪效果。

painter.setBrush(QColor(0, 0, 0)); 在这里的唯一目的是往那个 QBitmap 遮罩(mask) 上「画」一个黑色的圆角矩形,用来告诉 Qt 哪一块区域要保留、哪一块区域要透明——它并不是在往你的 resultImage 上画黑色。

  • 在 mask 上:
    • 黑色 → 可见
    • 白色 → 透明

如果你不 setBrush(QColor(0, 0, 0)) 去把圆角矩形「涂黑」,那么整张 mask 就只有白色(或只有透明),结果就是 整张图片都被裁成透明了,你看不见任何内容。

所以,setBrush(QColor(0, 0, 0)) 的作用只是:

  1. 在 mask 上,填充一个黑色的圆角矩形;
  2. 当你调用 resultImage.setMask(mask); 时,Qt 会把这部分“黑色”区域映射为 保留原图像素,而把剩下的(白色)区域变成透明。

image-20250511121434047


绘制与遮罩效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void ImageCropperLabel::paintEvent(QPaintEvent *event) {
// 1. 先调用父类,实现原始图像的绘制
QLabel::paintEvent(event);

// 2. 根据当前裁剪形状,绘制不同的“半透明遮罩”或“高光边”
switch (cropperShape) {
case CropperShape::UNDEFINED:
break;
case CropperShape::FIXED_RECT:
drawRectOpacity();
break;
case CropperShape::FIXED_ELLIPSE:
drawEllipseOpacity();
break;
case CropperShape::RECT:
drawRectOpacity();
drawSquareEdge(!ONLY_FOUR_CORNERS);
break;
case CropperShape::SQUARE:
drawRectOpacity();
drawSquareEdge(ONLY_FOUR_CORNERS);
break;
case CropperShape::ELLIPSE:
drawEllipseOpacity();
drawSquareEdge(!ONLY_FOUR_CORNERS);
break;
case CropperShape::CIRCLE:
drawEllipseOpacity();
drawSquareEdge(ONLY_FOUR_CORNERS);
break;
}

// 3. 如果需要,给裁剪框本身画一条边框
if (isShowRectBorder) {
QPainter painter(this);
painter.setPen(borderPen);
painter.drawRect(cropperRect);
}
}

  • 绘制原图
    QLabel::paintEvent(event) 会根据当前设置的 pixmap 或者绘图内容,把“完整的”图像画到控件上。我们不做任何改动,保留原始像素。

    叠加遮罩或高光边
    根据 cropperShape(枚举当前选中的裁剪形状),有两类主要操作:

    • **drawRectOpacity() / drawEllipseOpacity()**:在裁剪框以外的区域绘制半透明黑色遮罩,突出裁剪区域本身。
    • **drawSquareEdge(...)**:在裁剪框的四条边或者四个角上绘制高对比度的“小方块”或“手柄”,以便用户拖动调整大小。

    绘制裁剪框边线
    如果 isShowRectBorder==true,再用 borderPen(一般是明亮的颜色或宽度可见的线条)精确地把 cropperRect 描边一次,让裁剪范围更清晰。

半透明遮罩

1
2
3
4
5
void ImageCropperLabel::drawOpacity(const QPainterPath& path) {
QPainter painterOpac(this);
painterOpac.setOpacity(opacity); // 设定当前 painter 的透明度
painterOpac.fillPath(path, QBrush(Qt::black)); // 用黑色填充整个 path 区域
}
  • **opacity**:这是一个 [0.0 … 1.0] 之间的浮点值,控制遮罩的“浓度”。越接近 1.0,黑得越不透明;越接近 0.0,则越接近“无色”。
  • **fillPath(path, QBrush(Qt::black))**:把传入的 QPainterPath 区域,用半透明的黑色一次性“盖”上去。

drawRectOpacity()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void ImageCropperLabel::drawRectOpacity() {
if (!isShowOpacityEffect) return;

// 1. p1:整个图像区域
QPainterPath p1;
p1.addRect(imageRect);

// 2. p2:裁剪框区域
QPainterPath p2;
p2.addRect(cropperRect);

// 3. 求差集:p = p1 - p2
QPainterPath p = p1.subtracted(p2);

// 4. 对 p 区域绘制半透明黑色遮罩
drawOpacity(p);
}
  • **imageRect**:通常是整个图片在控件上的显示区域。
  • **cropperRect**:用户定义的“裁剪框”矩形。
  • **p1.subtracted(p2)**:把裁剪框内部切掉,结果 p 就是“图片区域减去裁剪框”的外部部分。
  • 遮罩效果:只有外部部分被半透明黑色盖住,裁剪框内——也就是用户关心的区域——保持原样未被遮盖。

椭圆遮罩 —— drawEllipseOpacity()(原理同上)

虽然你没贴出函数体,但它与 drawRectOpacity() 唯一区别就是把 p2.addRect(cropperRect) 换成:

1
2
QPainterPath p2;
p2.addEllipse(cropperRect);

这样 p1.subtracted(p2) 就是“整张图片减去椭圆区域”,半透明遮罩会围着椭圆“环绕”绘制。


image-20250511122719029

“方块手柄”高光 —— drawSquareEdge(bool onlyCorners)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ImageCropperLabel::drawSquareEdge(bool onlyFourCorners) {
if (!isShowDragSquare)
return;

// Four corners
drawFillRect(cropperRect.topLeft(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.topRight(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.bottomLeft(), dragSquareEdge, dragSquareColor);
drawFillRect(cropperRect.bottomRight(), dragSquareEdge, dragSquareColor);

// Four edges
if (!onlyFourCorners) {
int centralX = cropperRect.left() + cropperRect.width() / 2;
int centralY = cropperRect.top() + cropperRect.height() / 2;
drawFillRect(QPoint(cropperRect.left(), centralY), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(centralX, cropperRect.top()), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(cropperRect.right(), centralY), dragSquareEdge, dragSquareColor);
drawFillRect(QPoint(centralX, cropperRect.bottom()), dragSquareEdge, dragSquareColor);
}
}

image-20250511123344886

此函数通常会:

  1. 在 cropperRect 的四条边(或四个角)各计算几个固定大小的小矩形位置。
  2. 用不透明画刷(如白色或蓝色)绘制这些 “拖拽手柄”,让用户知道可以从这些点出发拖动调整大小。

onlyCorners 参数决定是只在四个角显示手柄,还是在四条边中央也显示。

手柄检测

isPosNearDragSquare(pt1, pt2):手柄附近检测

1
2
3
4
bool ImageCropperLabel::isPosNearDragSquare(const QPoint& pt1, const QPoint& pt2) {
return abs(pt1.x() - pt2.x()) * 2 <= dragSquareEdge
&& abs(pt1.y() - pt2.y()) * 2 <= dragSquareEdge;
}
  • **pt1**:当前鼠标点(或触点)坐标。
  • **pt2**:某个拖拽手柄中心点坐标。
  • **dragSquareEdge**:定义手柄大小(宽或高)的常量。

逻辑:如果鼠标点到手柄中心的水平距离和垂直距离都不超过 dragSquareEdge/2,就认为“在手柄区域内”。乘以 2 只是把“不超过半边”转成”两倍距离不超过边长“的判断。


getPosInCropperRect(pt):整体位置分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int ImageCropperLabel::getPosInCropperRect(const QPoint &pt) {
if (isPosNearDragSquare(pt, QPoint(cropperRect.right(), cropperRect.center().y())))
return RECT_RIGHT;
if (isPosNearDragSquare(pt, cropperRect.bottomRight()))
return RECT_BOTTOM_RIGHT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.center().x(), cropperRect.bottom())))
return RECT_BOTTOM;
if (isPosNearDragSquare(pt, cropperRect.bottomLeft()))
return RECT_BOTTOM_LEFT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.left(), cropperRect.center().y())))
return RECT_LEFT;
if (isPosNearDragSquare(pt, cropperRect.topLeft()))
return RECT_TOP_LEFT;
if (isPosNearDragSquare(pt, QPoint(cropperRect.center().x(), cropperRect.top())))
return RECT_TOP;
if (isPosNearDragSquare(pt, cropperRect.topRight()))
return RECT_TOP_RIGHT;

if (cropperRect.contains(pt, true))
return RECT_INSIDE;

return RECT_OUTSIZD;
}

按照顺序,它分别检测:

  1. 右边中点 RECT_RIGHT
    以 (cropperRect.right(), cropperRect.center().y()) 为中心,看鼠标是否落在右侧手柄区域。
  2. 右下角 RECT_BOTTOM_RIGHT
    以 cropperRect.bottomRight() 为中心,看鼠标是否落在这个角的手柄。
  3. 下边中点 RECT_BOTTOM
    中点为 (center.x(), bottom)。
  4. 左下角 RECT_BOTTOM_LEFT
  5. 左边中点 RECT_LEFT
  6. 左上角 RECT_TOP_LEFT
  7. 上边中点 RECT_TOP
  8. 右上角 RECT_TOP_RIGHT

如果以上八个拖拽手柄区域都没有命中,接着:

  • **RECT_INSIDE**:如果点严格落在 cropperRect 内部(第二个参数 true 表示内边缘也算),就返回“内部”标志。
  • **RECT_OUTSIZD**:都不符合,则认为在裁剪框外。

综合效果

  • 在 鼠标按下 或 移动 时,调用 getPosInCropperRect(pt),能够快速定位出当前点相对于裁剪框的位置类型。
  • 上层逻辑(如鼠标事件处理)根据这个返回值,决定要进行哪种操作:
    • 如果是某个角或边的手柄,就进入“调整大小”模式,且拖拽方向锁定;
    • 如果是 RECT_INSIDE,则进入“移动整个裁剪框”模式;
    • 如果是 RECT_OUTSIZD,则不做任何裁剪框相关的拖拽操作。

这样,就实现了一个用户友好的「拖拽四角/边来调整裁剪框大小,或者拖拽内部来移动框」的交互体验。


鼠标按下移动释放

mousePressEvent

1
2
3
4
void ImageCropperLabel::mousePressEvent(QMouseEvent *e) {
currPos = lastPos = e->pos();
isLButtonPressed = true;
}

功能:当鼠标左键按下时调用。

做了什么:

  1. 用 e->pos()(相对于控件左上角的坐标)初始化 currPos、lastPos,为后续移动计算做准备。
  2. 将 isLButtonPressed 置为 true,开启拖动或缩放模式。

mouseMoveEvent

这是核心函数,处理移动和缩放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
void ImageCropperLabel::mouseMoveEvent(QMouseEvent *e) {
currPos = e->pos();
// 首次进入时,确定鼠标在哪个区域:边角、边缘、框内或框外
if (!isCursorPosCalculated) {
cursorPosInCropperRect = getPosInCropperRect(currPos);
changeCursor(); // 根据区域切换不同形状的鼠标指针
}

// 如果左键没有按下或鼠标移出了图片范围,就不做任何处理
if (!isLButtonPressed || !imageRect.contains(currPos))
return;

isCursorPosCalculated = true; // 保证只计算一次区域
// 计算本次移动增量
int xOffset = currPos.x() - lastPos.x();
int yOffset = currPos.y() - lastPos.y();
lastPos = currPos;

int disX = 0, disY = 0; // 用于后续缩放计算

// 根据鼠标所在区域,选择对应的移动/缩放逻辑
switch (cursorPosInCropperRect) {
case RECT_OUTSIZD:
break; // 在框外:不处理

// —— 右下角 缩放 ——
case RECT_BOTTOM_RIGHT: {
disX = currPos.x() - cropperRect.left();
disY = currPos.y() - cropperRect.top();
switch (cropperShape) {
// 固定模式:不允许缩放
case CropperShape::UNDEFINED:
case CropperShape::FIXED_RECT:
case CropperShape::FIXED_ELLIPSE:
break;
// 正方形/圆形:强制保持宽高一致
case CropperShape::SQUARE:
case CropperShape::CIRCLE:
setCursor(Qt::SizeFDiagCursor);
// 保证没有小于最小尺寸且不超出图片下/right 边
if (disX >= cropperMinimumWidth && disY >= cropperMinimumHeight) {
if (disX > disY && cropperRect.top() + disX <= imageRect.bottom()) {
// 宽度主导,伸长底边
cropperRect.setRight(currPos.x());
cropperRect.setBottom(cropperRect.top() + disX);
}
else if (disY >= disX && cropperRect.left() + disY <= imageRect.right()) {
// 高度主导,伸长右边
cropperRect.setBottom(currPos.y());
cropperRect.setRight(cropperRect.left() + disY);
}
emit croppedImageChanged();
}
break;
// 普通矩形/椭圆:独立伸缩宽或高
case CropperShape::RECT:
case CropperShape::ELLIPSE:
setCursor(Qt::SizeFDiagCursor);
if (disX >= cropperMinimumWidth) {
cropperRect.setRight(currPos.x());
emit croppedImageChanged();
}
if (disY >= cropperMinimumHeight) {
cropperRect.setBottom(currPos.y());
emit croppedImageChanged();
}
break;
}
break;
}

// —— 右侧边 缩放 ——
case RECT_RIGHT: {
disX = currPos.x() - cropperRect.left();
if (cropperShape==CropperShape::RECT||cropperShape==CropperShape::ELLIPSE) {
if (disX >= cropperMinimumWidth) {
cropperRect.setRight(currPos.x());
emit croppedImageChanged();
}
}
break;
}

// —— 底部边 缩放 ——
case RECT_BOTTOM: {
disY = currPos.y() - cropperRect.top();
if (cropperShape==CropperShape::RECT||cropperShape==CropperShape::ELLIPSE) {
if (disY >= cropperMinimumHeight) {
cropperRect.setBottom(cropperRect.bottom() + yOffset);
emit croppedImageChanged();
}
}
break;
}

// —— 左下角、左侧、上边…… 各角/边 缩放逻辑同上 ——
// (代码中分别处理了 RECT_BOTTOM_LEFT、RECT_LEFT、RECT_TOP_LEFT、
// RECT_TOP、RECT_TOP_RIGHT,核心思想与右下相似:计算 disX/disY,
// 判断形状、最小尺寸、边界,再更新对应边或角的坐标并 emit。)

// —— 框内拖动 ——
case RECT_INSIDE: {
// 先检测移动后是否会超出图片范围,将偏移量 xOffset/yOffset 裁剪到合法区间
if (cropperRect.left() + xOffset < imageRect.left()) xOffset = imageRect.left() - cropperRect.left();
if (cropperRect.right()+ xOffset > imageRect.right()) xOffset = imageRect.right() - cropperRect.right();
if (cropperRect.top() + yOffset < imageRect.top()) yOffset = imageRect.top() - cropperRect.top();
if (cropperRect.bottom()+ yOffset > imageRect.bottom()) yOffset = imageRect.bottom() - cropperRect.bottom();
// 移动整个裁剪框
cropperRect.translate(xOffset, yOffset);
emit croppedImageChanged();
break;
}
}

repaint(); // 触发重绘,及时在界面上更新新的裁剪框
}

关键点总结

  1. 首次定位
    当鼠标首次进入 mouseMoveEvent,用 getPosInCropperRect(currPos) 判断鼠标在裁剪框的哪个“热区”——外部、框内、四边、四角中的哪一个,并调用 changeCursor() 切换对应的鼠标指针样式(如移动箭头、水平/垂直/对角调整形状等),以提示用户下一步操作。

  2. 左右、上下、四角缩放

    • 对于矩形/椭圆,宽高可独立调整;
    • 对于正方形/圆,则保证 width == height,并根据位移量较大的一边来驱动另一边;
    • 对于“固定”模式,则完全不允许用户改变大小。
  3. 边界与最小尺寸约束

    • 缩放时先判断新的宽度/高度是否 ≥ cropperMinimumWidth/Height;
    • 再判断新坐标是否会跑出 imageRect(图片区域)之外;
    • 最后才更新 cropperRect 并发信号 croppedImageChanged() 以便上层 UI 或逻辑更新裁剪后的图像。
  4. 拖动整个裁剪框

    • 鼠标在框内部拖动(RECT_INSIDE),计算每次的偏移 xOffset,yOffset,
    • 并先“裁剪”偏移量,使整个框保持在图片范围内,
    • 最后调用 translate() 平移 cropperRect。

mouseReleaseEvent(QMouseEvent *)

1
2
3
4
5
void ImageCropperLabel::mouseReleaseEvent(QMouseEvent *) {
isLButtonPressed = false;
isCursorPosCalculated = false;
setCursor(Qt::ArrowCursor);
}
  • 功能:当鼠标左键松开时调用。
  • 做了什么:
    1. 将 isLButtonPressed 置为 false,停止后续的拖动/缩放处理。
    2. 重置 isCursorPosCalculated = false,下次再移动时会重新计算在哪个区域。
    3. 恢复默认箭头指针。

保存逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
//上传头像
void UserInfoPage::on_up_btn_clicked()
{
// 1. 让对话框也能选 *.webp
QString filename = QFileDialog::getOpenFileName(
this,
tr("选择图片"),
QString(),
tr("图片文件 (*.png *.jpg *.jpeg *.bmp *.webp)")
);
if (filename.isEmpty())
return;

// 2. 直接用 QPixmap::load() 加载,无需手动区分格式
QPixmap inputImage;
if (!inputImage.load(filename)) {
QMessageBox::critical(
this,
tr("错误"),
tr("加载图片失败!请确认已部署 WebP 插件。"),
QMessageBox::Ok
);
return;
}

QPixmap image = ImageCropperDialog::getCroppedImage(filename, 600, 400, CropperShape::CIRCLE);
if (image.isNull())
return;

QPixmap scaledPixmap = image.scaled( ui->head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); // 将图片缩放到label的大小
ui->head_lb->setPixmap(scaledPixmap); // 将缩放后的图片设置到QLabel上
ui->head_lb->setScaledContents(true); // 设置QLabel自动缩放图片内容以适应大小

QString storageDir = QStandardPaths::writableLocation(
QStandardPaths::AppDataLocation);
// 2. 在其下再建一个 avatars 子目录
QDir dir(storageDir);
if (!dir.exists("avatars")) {
if (!dir.mkpath("avatars")) {
qWarning() << "无法创建 avatars 目录:" << dir.filePath("avatars");
QMessageBox::warning(
this,
tr("错误"),
tr("无法创建存储目录,请检查权限或磁盘空间。")
);
return;
}
}
// 3. 拼接最终的文件名 head.png
QString filePath = dir.filePath("avatars/head.png");

// 4. 保存 scaledPixmap 为 PNG(无损、最高质量)
if (!scaledPixmap.save(filePath, "PNG")) {
QMessageBox::warning(
this,
tr("保存失败"),
tr("头像保存失败,请检查权限或磁盘空间。")
);
} else {
qDebug() << "头像已保存到:" << filePath;
// 以后读取直接用同一路径:storageDir/avatars/head.png
}
}
  1. 选择图片文件(支持多种格式)
1
2
3
4
5
6
7
8
QString filename = QFileDialog::getOpenFileName(
this,
tr("选择图片"),
QString(),
tr("图片文件 (*.png *.jpg *.jpeg *.bmp *.webp)")
);
if (filename.isEmpty())
return;
  • 功能:当用户点击上传头像按钮时,弹出文件选择对话框(QFileDialog),允许用户选择图片文件。此对话框支持的文件格式包括 .png、.jpg、.jpeg、.bmp 和 .webp。如果用户没有选择文件(即点击了取消),则返回并不执行后续操作。
  1. 加载图片文件
1
2
3
4
5
6
7
8
9
10
QPixmap inputImage;
if (!inputImage.load(filename)) {
QMessageBox::critical(
this,
tr("错误"),
tr("加载图片失败!请确认已部署 WebP 插件。"),
QMessageBox::Ok
);
return;
}
  • 功能:通过 QPixmap 类加载用户选定的图片文件。如果加载失败(如文件损坏、格式不支持等),则弹出错误对话框提示用户,并退出当前函数。
  1. 裁剪图片
1
2
3
QPixmap image = ImageCropperDialog::getCroppedImage(filename, 600, 400, CropperShape::CIRCLE);
if (image.isNull())
return;
  • 功能:调用 ImageCropperDialog::getCroppedImage 函数裁剪图片。这个函数会根据传入的文件路径(filename)、目标大小(600x400)和裁剪形状(此处是圆形 CropperShape::CIRCLE)返回一个裁剪后的图片 QPixmap。如果裁剪过程失败(即返回空 QPixmap),则函数直接退出。
  1. 缩放图片到指定的 QLabel 大小
1
2
3
QPixmap scaledPixmap = image.scaled( ui->head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
ui->head_lb->setPixmap(scaledPixmap);
ui->head_lb->setScaledContents(true);
  • 功能:将裁剪后的图片缩放到与界面上显示头像的 QLabel(head_lb)大小相匹配。使用 scaled() 方法,保持图片的宽高比 (Qt::KeepAspectRatio),并且应用平滑的图像转换(Qt::SmoothTransformation),保证缩放后的图片质量尽可能高。最后,将缩放后的图片设置到 QLabel 上,并开启 setScaledContents(true),使得 QLabel 自动调整内容大小以适应其尺寸。
  1. 获取应用程序的存储目录
1
QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
  • 功能:通过 QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) 获取应用程序的可写数据存储目录。这个目录是操作系统为应用程序提供的一个常规存储路径,通常用于存储配置文件、数据文件等。
  1. 创建头像存储目录
1
2
3
4
5
6
7
8
9
10
11
12
QDir dir(storageDir);
if (!dir.exists("avatars")) {
if (!dir.mkpath("avatars")) {
qWarning() << "无法创建 avatars 目录:" << dir.filePath("avatars");
QMessageBox::warning(
this,
tr("错误"),
tr("无法创建存储目录,请检查权限或磁盘空间。")
);
return;
}
}
  • 功能:检查存储目录下是否已经存在一个名为 avatars 的子目录。如果不存在,则通过 mkpath() 创建该子目录。若创建失败,弹出警告对话框提示用户检查权限或磁盘空间。
  1. 拼接最终的保存路径
1
QString filePath = dir.filePath("avatars/head.png");
  • 功能:拼接最终的文件路径,存储头像的文件名为 head.png,并位于 avatars 目录下。filePath 即为头像图片的完整存储路径。
  1. 保存裁剪后的图片
1
2
3
4
5
6
7
8
9
if (!scaledPixmap.save(filePath, "PNG")) {
QMessageBox::warning(
this,
tr("保存失败"),
tr("头像保存失败,请检查权限或磁盘空间。")
);
} else {
qDebug() << "头像已保存到:" << filePath;
}
  • 功能:使用 QPixmap::save() 方法将裁剪并缩放后的图片保存到指定路径 filePath。保存格式为 PNG。如果保存失败,则弹出警告对话框提示用户;否则,输出日志,显示头像已成功保存的路径。

源码连接

https://gitee.com/secondtonone1/llfcchat

心跳检测实现

Posted on 2025-05-01 | In C++聊天项目

前情回顾

前文我们实现了跨服踢人逻辑,通过分布式锁锁住不同服务器相同用户登录的操作,保证逻辑的原子性。

今天我们来谈一谈心跳机制,以及为什么要有心跳机制,以及该如何实现心跳机制,而且是分布式情况下心跳配合踢人逻辑该如何实现。

心跳概念

在一个“长连接”(如 TCP 持久连接、WebSocket、gRPC 长流等)中,客户端和服务端之间会保持一个持续打开的通道,以便双方可以随时双向发送数据。与一次性请求/响应模型(短连接)相比,长连接最大的挑战在于如何做到“及时发现网络或对端异常”、并“防止连接在中间节点(如路由器、NAT、防火墙)被静默地回收”。心跳机制正是为了解决这两个核心问题而引入的:

心跳示意图

image-20250501122225534

没有心跳

image-20250501122318014

当没有心跳机制的时候,如果设备异常断线(拔掉网线),tcp层面可能无法立即感知,导致僵尸连接挂在服务器上。除非服务器发送数据给客户端才会感知到。或者被中间设备超时回收。

防止中间设备超时回收

许多网络设备(尤其是 NAT、负载均衡、防火墙)会对空闲连接设定一个超时阈值:

  • 如果某段时间内连接上没有任何数据包经过,它会自动“回收”这条路由/会话,导致真正的数据到达时被丢弃或重置。
  • 心跳包可以视作“活动信号”,让中间设备认为连接仍在活跃,从而维持映射表或会话状态,避免意外断开。

服务器心跳实现

服务器可以启动一个定时器,每隔60s检测一下所有连接,判断连接是否’’活着’’, 所谓”活着”就是连接没有断开。

怎么设置”活着”呢?就是对每一个Session(会话)设置一个时间戳,这个Session收到消息后,就更新这个时间戳。

服务器定时检测当前时间和这个时间戳的差值,如果大于一个阈值就说明连接断开了。这个阈值看服务器设定,一般60s即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void CServer::on_timer(const boost::system::error_code& e)
{
lock_guard<mutex> lock(_mutex);
time_t now = time(nullptr);
for(auto iter = _sessions.begin(); iter != _sessions.end();) {
auto b_expired = iter->second->IsHeartbeatExpired(now);
if (b_expired) {
iter->second->Close();
iter = _sessions.erase(iter);
auto uid_str = iter->second->GetUserId();
auto lock_key = LOCK_PREFIX+uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
Defer defer([identifier, lock_key](){
RedisMgr::GetInstance()->releaseLock(lock_key,identifier);
});

//写清除redis逻辑
}
else {
++iter;
}
}

}

大家仔细观察这个代码,有没有发现什么问题?

这段代码是先加线程锁_mutex, 然后加分布式锁lock_key

但是我们看下Session读取掉线连接信息时会清空redis信息,流程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void CSession::AsyncReadHead(int total_len)
{
auto self = shared_from_this();
asyncReadFull(HEAD_TOTAL_LEN, [self, this](const boost::system::error_code& ec, std::size_t bytes_transfered) {
try {
if (ec) {
std::cout << "handle read failed, error is " << ec.what() << endl;
Close();
auto self = shared_from_this();
//加锁清除session
auto uid_str = std::to_string(_user_uid);
auto lock_key = LOCK_PREFIX + uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
Defer defer([identifier, lock_key, self, this]() {
_server->ClearSession(_session_id);
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});

if (identifier.empty()) {
return;
}
std::string redis_session_id = "";
auto bsuccess = RedisMgr::GetInstance()->Get(USER_SESSION_PREFIX + uid_str, redis_session_id);
if (!bsuccess) {
return;
}

if (redis_session_id != _session_id) {
//说明有客户在其他服务器异地登录了
return;
}

RedisMgr::GetInstance()->Del(USER_SESSION_PREFIX + uid_str);
//清除用户登录信息
RedisMgr::GetInstance()->Del(USERIPPREFIX + uid_str);
return;
}

//....省略正常逻辑
}catch (std::exception& e) {
std::cout << "Exception code is " << e.what() << endl;
}
});
}

AsyncReadHead错误处理中先加了分布式锁lock_key,再加线程锁_mutex

图示如下

image-20250501153401691

上面图示已经很明显了,有概率造成死锁。

接下来谈谈死锁如何避免

如何避免死锁

线程锁避免死锁

如果是两个线程锁,避免死锁的最简单方式就是同时加锁,或者顺序一致性加锁

在 C++17 里,std::scoped_lock(也有人称它为“scope lock”)提供了对多个互斥量无死锁地一次性加锁的能力。它的核心在于内部调用了函数模板 std::lock(m1, m2, …),该函数会:

  1. 尝试按某种顺序非阻塞地抓取所有 mutex:
    • std::lock 会循环地对每一个 mutex 做 try_lock(),
    • 如果有任何一个 try_lock() 失败,就立刻释放前面已经成功抓到的所有 mutex,退避(backoff),然后重试。
  2. 保证最终所有 mutex 要么全部抓到了,要么都没抓到:
    • 这样就避免了“线程 A 拿了 m1 等待 m2,而线程 B 拿了 m2 等待 m1”这种经典死锁情形。

只要你的所有代码都用同一个调用 std::scoped_lock(m1, m2, …) 的方式去加这几把锁,就不会出现交叉锁导致的死锁。

用法示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <mutex>
#include <thread>
#include <iostream>

std::mutex mtx1, mtx2;

void worker1() {
// 同时加 mtx1、mtx2,不会与另一个线程交叉死锁
std::scoped_lock lock(mtx1, mtx2);
std::cout << "worker1 got both locks\n";
// … 操作受两把锁保护的资源 …
}

void worker2() {
// 即便另一线程也是先 mtx2、后 mtx1,只要都改成 scoped_lock(mtx1, mtx2),
// 底层 std::lock 会保证不会死锁
std::scoped_lock lock(mtx1, mtx2);
std::cout << "worker2 got both locks\n";
// … 相同资源操作 …
}

int main() {
std::thread t1(worker1);
std::thread t2(worker2);
t1.join();
t2.join();
return 0;
}

为什么不会死锁?

  • std::scoped_lock(mtx1, mtx2) 在构造时等价于:

    1
    std::lock(mtx1, mtx2);
  • std::lock 会:

    1. 先 try_lock() mtx1、再 try_lock() mtx2。
    2. 如果第二步失败,就释放第一把、稍作退避后重试。
    3. 直到两把都一次性成功为止。

这样就不会出现“线程 A 拿到 mtx1 → 等 mtx2”同时“线程 B 拿到 mtx2 → 等 mtx1”互相卡死的情况。

分布式锁

要解决“分布式锁 ↔ 线程锁”互相嵌套导致死锁的问题,核心思路就是:

  1. 统一锁的获取顺序

    • 始终按同一个顺序去申请锁。
    • 比如:不论是业务 A(先分布式锁后线程锁)还是心跳(先线程锁后分布式锁),都改成 “先拿分布式锁 → 再拿线程锁” 或者 “先拿线程锁 → 再拿分布式锁” 之一即可。
    • 只要保证两个场景里锁的申请顺序一致,就不会互相等待导致死锁。
  2. 使用带超时的尝试锁(tryLock)+ 重试/回退策略

    • 对于线程锁(例如 ReentrantLock)和分布式锁(例如 Redisson 的 tryLock(long waitTime, long leaseTime, TimeUnit unit)),都用 tryLock 而非阻塞式 lock()。
    • 如果某把锁在指定时间内拿不到,就释放已持有的那把锁,稍微退避(sleep 随机短时长)后重试。
    • 这样可以在检测到可能的死锁倾向时主动放弃,避免无限等待。
  3. 合并锁或升级锁策略

    • 如果分布式节点上并发线程只是共享同一把“逻辑锁”,可以考虑把本地线程锁和分布式锁做一次封装:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      class CombinedLock {
      RLock distLock;
      std::mutex mtx;
      public void lock() {
      distLock.lock();
      mtx.lock();
      }
      public void unlock() {
      mtx.unlock();
      distLock.unlock();
      }
      }
    • 这样业务层只用 combinedLock.lock(),根本不用关心哪把先后,底层永远是固定顺序。

  4. 只用分布式锁或只用线程锁

    • 如果心跳更新 Redis 的操作本身就是分布式的,就完全用分布式锁保护它,不再加线程锁。
    • 反之,如果这段更新完全在本机线程间协作,也可直接把分布式锁封装进本地锁里,让它表现得像本地锁。
  5. 利用 Redis Lua 脚本保证原子性

    • 将所有对 Redis 的读写操作放到一个 Lua 脚本里一次执行,借助 Redis 的单线程特性保证原子。
    • 这样就不需要额外的分布式锁,线程里也不用再加锁。

改造心跳服务


举例:改造心跳服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void CServer::on_timer(const boost::system::error_code& ec) {
std::vector<std::shared_ptr<CSession>> _expired_sessions;
int session_count = 0;
//此处加锁遍历session
{
lock_guard<mutex> lock(_mutex);
time_t now = std::time(nullptr);
for (auto iter = _sessions.begin(); iter != _sessions.end(); iter++) {
auto b_expired = iter->second->IsHeartbeatExpired(now);
if (b_expired) {
//关闭socket, 其实这里也会触发async_read的错误处理
iter->second->Close();
//收集过期信息
_expired_sessions.push_back(iter->second);
continue;
}
session_count++;
}
}

//设置session数量
auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
auto count_str = std::to_string(session_count);
RedisMgr::GetInstance()->HSet(LOGIN_COUNT, self_name, count_str);

//处理过期session, 单独提出,防止死锁
for (auto &session : _expired_sessions) {
session->DealExceptionSession();
}

//再次设置,下一个60s检测
_timer.expires_after(std::chrono::seconds(60));
_timer.async_wait([this](boost::system::error_code ec) {
on_timer(ec);
});
}

将清除逻辑提炼到函数DealExceptionSession

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void CSession::DealExceptionSession()
{
auto self = shared_from_this();
//加锁清除session
auto uid_str = std::to_string(_user_uid);
auto lock_key = LOCK_PREFIX + uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
Defer defer([identifier, lock_key, self, this]() {
_server->ClearSession(_session_id);
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});

if (identifier.empty()) {
return;
}
std::string redis_session_id = "";
auto bsuccess = RedisMgr::GetInstance()->Get(USER_SESSION_PREFIX + uid_str, redis_session_id);
if (!bsuccess) {
return;
}

if (redis_session_id != _session_id) {
//说明有客户在其他服务器异地登录了
return;
}

RedisMgr::GetInstance()->Del(USER_SESSION_PREFIX + uid_str);
//清除用户登录信息
RedisMgr::GetInstance()->Del(USERIPPREFIX + uid_str);
}
  1. 持有本地线程锁只做遍历、收集过期 UID,不做删除;

  2. 释放线程锁后,对每个 UID 按 “分布式锁→线程锁” 顺序逐个清理。

这样,所有“同时持有两把锁”的位置,顺序均为:

1
分布式锁 → 本地线程锁

从而避免死锁

提炼异常处理

比如异步读处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void CSession::AsyncReadBody(int total_len)
{
auto self = shared_from_this();
asyncReadFull(total_len, [self, this, total_len](const boost::system::error_code& ec, std::size_t bytes_transfered) {
try {
if (ec) {
std::cout << "handle read failed, error is " << ec.what() << endl;
Close();
DealExceptionSession();
return;
}

if (bytes_transfered < total_len) {
std::cout << "read length not match, read [" << bytes_transfered << "] , total ["
<< total_len<<"]" << endl;
Close();
_server->ClearSession(_session_id);
return;
}

//判断连接无效
if (!_server->CheckValid(_session_id)) {
Close();
return;
}

memcpy(_recv_msg_node->_data , _data , bytes_transfered);
_recv_msg_node->_cur_len += bytes_transfered;
_recv_msg_node->_data[_recv_msg_node->_total_len] = '\0';
cout << "receive data is " << _recv_msg_node->_data << endl;
//更新session心跳时间
UpdateHeartbeat();
//此处将消息投递到逻辑队列中
LogicSystem::GetInstance()->PostMsgToQue(make_shared<LogicNode>(shared_from_this(), _recv_msg_node));
//继续监听头部接受事件
AsyncReadHead(HEAD_TOTAL_LEN);
}
catch (std::exception& e) {
std::cout << "Exception code is " << e.what() << endl;
}
});
}

类似的还有,读头部,写数据等。

过期判断

判断现在时间和上一次心跳时间戳的差值,超过20s就认为连接过期。实际服务器心跳阈值最好60s,这里为了方便演示效果。

1
2
3
4
5
6
7
8
9
bool CSession::IsHeartbeatExpired(std::time_t& now) {
double diff_sec = std::difftime(now, _last_heartbeat);
if (diff_sec > 20) {
std::cout << "heartbeat expired, session id is " << _session_id << endl;
return true;
}

return false;
}

更新心跳

1
2
3
4
5
void CSession::UpdateHeartbeat()
{
time_t now = std::time(nullptr);
_last_heartbeat = now;
}

在读取消息时做了更新

增加心跳请求处理

服务器增加心跳处理请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void LogicSystem::HeartBeatHandler(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["fromuid"].asInt();
std::cout << "receive heart beat msg, uid is " << uid << std::endl;
Json::Value rtvalue;
rtvalue["error"] = ErrorCodes::Success;
session->Send(rtvalue.toStyledString(), ID_HEARTBEAT_RSP);
}

void LogicSystem::RegisterCallBacks() {
_fun_callbacks[MSG_CHAT_LOGIN] = std::bind(&LogicSystem::LoginHandler, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_SEARCH_USER_REQ] = std::bind(&LogicSystem::SearchInfo, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_ADD_FRIEND_REQ] = std::bind(&LogicSystem::AddFriendApply, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_AUTH_FRIEND_REQ] = std::bind(&LogicSystem::AuthFriendApply, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_TEXT_CHAT_MSG_REQ] = std::bind(&LogicSystem::DealChatTextMsg, this,
placeholders::_1, placeholders::_2, placeholders::_3);

_fun_callbacks[ID_HEART_BEAT_REQ] = std::bind(&LogicSystem::HeartBeatHandler, this,
placeholders::_1, placeholders::_2, placeholders::_3);

}

客户端增加心跳处理发包和回复

发包处理

在ChatDialog构造函数中添加

1
2
3
4
5
6
7
8
9
10
11
_timer = new QTimer(this);
connect(_timer, &QTimer::timeout, this, [this](){
auto user_info = UserMgr::GetInstance()->GetUserInfo();
QJsonObject textObj;
textObj["fromuid"] = user_info->_uid;
QJsonDocument doc(textObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_HEART_BEAT_REQ, jsonData);
});

_timer->start(10000);

在析构函数中添加

1
2
3
4
5
ChatDialog::~ChatDialog()
{
_timer->stop();
delete ui;
}

回包处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
_handlers.insert(ID_HEARTBEAT_RSP,[this](ReqId id, int len, QByteArray data){
Q_UNUSED(len);
qDebug() << "handle id is " << id << " data is " << data;
// 将QByteArray转换为QJsonDocument
QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

// 检查转换是否成功
if (jsonDoc.isNull()) {
qDebug() << "Failed to create QJsonDocument.";
return;
}

QJsonObject jsonObj = jsonDoc.object();

if (!jsonObj.contains("error")) {
int err = ErrorCodes::ERR_JSON;
qDebug() << "Heart Beat Msg Failed, err is Json Parse Err" << err;
return;
}

int err = jsonObj["error"].toInt();
if (err != ErrorCodes::SUCCESS) {
qDebug() << "Heart Beat Msg Failed, err is " << err;
return;
}

qDebug() << "Receive Heart Beat Msg Success" ;

});

客户端增加断线提示

在TcpMgr构造函数中添加

1
2
3
4
5
6
// 处理连接断开
QObject::connect(&_socket, &QTcpSocket::disconnected, [&]() {
qDebug() << "Disconnected from server.";
//并且发送通知到界面
emit sig_connection_closed();
});

MainWindow构造函数中添加信号连接

1
2
//连接服务器断开心跳超时或异常连接信息
connect(TcpMgr::GetInstance().get(),&TcpMgr::sig_connection_closed, this, &MainWindow::SlotExcepConOffline);

槽函数

1
2
3
4
5
6
7
void MainWindow::SlotExcepConOffline()
{
// 使用静态方法直接弹出一个信息框
QMessageBox::information(this, "下线提示", "心跳超时或临界异常,该终端下线!");
TcpMgr::GetInstance()->CloseConnection();
offlineLogin();
}

效果测试

为了方便测试,我们修改StatusServer中GetServer逻辑只返回第一个ChatServer1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
ChatServer StatusServiceImpl::getChatServer() {
std::lock_guard<std::mutex> guard(_server_mtx);
auto minServer = _servers.begin()->second;
auto lock_key = LOCK_COUNT;
//暂时注释
//auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
////利用defer解锁
//Defer defer2([this, identifier, lock_key]() {
// RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
// });

//auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, minServer.name);
//if (count_str.empty()) {
// //不存在则默认设置为最大
// minServer.con_count = INT_MAX;
//}
//else {
// minServer.con_count = std::stoi(count_str);
//}


//// 使用范围基于for循环
//for ( auto& server : _servers) {
//
// if (server.second.name == minServer.name) {
// continue;
// }

// auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
// if (count_str.empty()) {
// server.second.con_count = INT_MAX;
// }
// else {
// server.second.con_count = std::stoi(count_str);
// }

// if (server.second.con_count < minServer.con_count) {
// minServer = server.second;
// }
//}

return minServer;
}

我们启动客户端,以及服务器,先屏蔽客户端发送心跳逻辑,可以看到服务器检测心跳超时后会切断客户端连接

image-20250501173202038

客户端添加心跳包发送

可以看到每隔10s客户端就发送心跳包给服务器,服务器收到后,打印日志,客户端也打印日志

image-20250501173829231

优化连接数统计

之前我们统计一个服务器连接数,都是在服务器检测登录一个用户就增加连接数写入redis,以及CSession析构减少连接数写入redis, 还加了分布式锁,这种做法频繁加锁会影响效率,现在我们有了心跳检测,只需要在心跳检测结束后将统计的连接数写入redis即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void CServer::on_timer(const boost::system::error_code& ec) {
std::vector<std::shared_ptr<CSession>> _expired_sessions;
int session_count = 0;
//此处加锁遍历session
{
lock_guard<mutex> lock(_mutex);
time_t now = std::time(nullptr);
for (auto iter = _sessions.begin(); iter != _sessions.end(); iter++) {
auto b_expired = iter->second->IsHeartbeatExpired(now);
if (b_expired) {
//关闭socket, 其实这里也会触发async_read的错误处理
iter->second->Close();
//收集过期信息
_expired_sessions.push_back(iter->second);
continue;
}
session_count++;
}
}

//设置session数量
auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
auto count_str = std::to_string(session_count);
RedisMgr::GetInstance()->HSet(LOGIN_COUNT, self_name, count_str);

//处理过期session, 单独提出,防止死锁
for (auto &session : _expired_sessions) {
session->DealExceptionSession();
}

//再次设置,下一个60s检测
_timer.expires_after(std::chrono::seconds(60));
_timer.async_wait([this](boost::system::error_code ec) {
on_timer(ec);
});
}

状态服务器中获取连接数返回ChatServer也可以简化了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ChatServer StatusServiceImpl::getChatServer() {
std::lock_guard<std::mutex> guard(_server_mtx);
auto minServer = _servers.begin()->second;

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, minServer.name);
if (count_str.empty()) {
//不存在则默认设置为最大
minServer.con_count = INT_MAX;
}
else {
minServer.con_count = std::stoi(count_str);
}


// 使用范围基于for循环
for ( auto& server : _servers) {

if (server.second.name == minServer.name) {
continue;
}

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
if (count_str.empty()) {
server.second.con_count = INT_MAX;
}
else {
server.second.con_count = std::stoi(count_str);
}

if (server.second.con_count < minServer.con_count) {
minServer = server.second;
}
}

return minServer;
}

哪怕我们获取的信息是旧的数据也没关系,负载分配没必要太精确,还有心跳每隔60s会更新依次连接逻辑,所以问题不大。

我们把ChatServer2的逻辑也更新成和ChatServer1,再次测试分布式情况下踢人+心跳逻辑

image-20250501180339785

可以看到同账号异地登录,旧客户端收到被踢掉消息,旧的客户端关闭连接,所以弹出心跳超时或异常,该终端下线

所以我们这期踢人+心跳检测就实现了。

源码和视频

源码连接:

https://gitee.com/secondtonone1/llfcchat

视频连接:

https://www.bilibili.com/video/BV1ct5xzcEka/?vd_source=8be9e83424c2ed2c9b2a3ed1d01385e9

跨服踢人逻辑实现

Posted on 2025-04-19 | In C++聊天项目

前情回顾

前文我们实现了单服务器踢人的逻辑,通过分布式锁锁住登录过程,在这个期间对用户相关的信息进行更改,主要包括用户id对应的serverip, sessionid等。

同时对用户离线消息进行了处理,也是通过分布式锁锁住退出过程,判断此时用户id对应的sessionid是否和本服记录相等,如果不相等则说明有用户异地登录,此时只要退出即可,否则要清理id对应的sessionid以及serverip等信息。

接下来我们实现跨服踢人逻辑

RPC封装

因为跨服踢人,所以要调用Grpc踢人,我们在message.proto中添加踢人消息

1
2
3
4
5
6
7
8
message KickUserReq{
int32 uid = 1;
}

message KickUserRsp{
int32 error = 1;
int32 uid = 2;
}

同时添加服务调用

1
2
3
4
service ChatService {
//...其他服务略去
rpc NotifyKickUser(KickUserReq) returns (KickUserRsp){}
}

编写bat脚本自动生成, start.bat内容如下

1
2
3
4
5
6
7
8
9
10
11
12
@echo off
set PROTOC_PATH=D:\cppsoft\grpc\visualpro\third_party\protobuf\Debug\protoc.exe
set GRPC_PLUGIN_PATH=D:\cppsoft\grpc\visualpro\Debug\grpc_cpp_plugin.exe
set PROTO_FILE=message.proto

echo Generating gRPC code...
%PROTOC_PATH% -I="." --grpc_out="." --plugin=protoc-gen-grpc="%GRPC_PLUGIN_PATH%" "%PROTO_FILE%"

echo Generating C++ code...
%PROTOC_PATH% --cpp_out=. "%PROTO_FILE%"

echo Done.

双击start.bat或者在cmd中执行start.bat也可以

执行后可以发现产生了四个文件

image-20250419114210735

跨服踢人示意图

image-20250419115212041

逻辑编写

StatusServer动态分配

StatusServer中修改动态分配server逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
ChatServer StatusServiceImpl::getChatServer() {
std::lock_guard<std::mutex> guard(_server_mtx);
auto minServer = _servers.begin()->second;
auto lock_key = LOCK_COUNT;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
//利用defer解锁
Defer defer2([this, identifier, lock_key]() {
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, minServer.name);
if (count_str.empty()) {
//不存在则默认设置为最大
minServer.con_count = INT_MAX;
}
else {
minServer.con_count = std::stoi(count_str);
}


// 使用范围基于for循环
for ( auto& server : _servers) {

if (server.second.name == minServer.name) {
continue;
}

auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
if (count_str.empty()) {
server.second.con_count = INT_MAX;
}
else {
server.second.con_count = std::stoi(count_str);
}

if (server.second.con_count < minServer.con_count) {
minServer = server.second;
}
}

return minServer;
}

注意这里用到了另一个分布式锁,用来控制服务器人数记录

1
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);

ChatServer踢人逻辑

ChatSever中登录逻辑里添加跨服踢人调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
void LogicSystem::LoginHandler(shared_ptr<CSession> session, const short &msg_id, const string &msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["uid"].asInt();
auto token = root["token"].asString();
std::cout << "user login uid is " << uid << " user token is "
<< token << endl;

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, MSG_CHAT_LOGIN_RSP);
});


//从redis获取用户token是否正确
std::string uid_str = std::to_string(uid);
std::string token_key = USERTOKENPREFIX + uid_str;
std::string token_value = "";
bool success = RedisMgr::GetInstance()->Get(token_key, token_value);
if (!success) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return ;
}

if (token_value != token) {
rtvalue["error"] = ErrorCodes::TokenInvalid;
return ;
}

rtvalue["error"] = ErrorCodes::Success;


std::string base_key = USER_BASE_INFO + uid_str;
auto user_info = std::make_shared<UserInfo>();
bool b_base = GetBaseInfo(base_key, uid, user_info);
if (!b_base) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return;
}
rtvalue["uid"] = uid;
rtvalue["pwd"] = user_info->pwd;
rtvalue["name"] = user_info->name;
rtvalue["email"] = user_info->email;
rtvalue["nick"] = user_info->nick;
rtvalue["desc"] = user_info->desc;
rtvalue["sex"] = user_info->sex;
rtvalue["icon"] = user_info->icon;

//从数据库获取申请列表
std::vector<std::shared_ptr<ApplyInfo>> apply_list;
auto b_apply = GetFriendApplyInfo(uid, apply_list);
if (b_apply) {
for (auto& apply : apply_list) {
Json::Value obj;
obj["name"] = apply->_name;
obj["uid"] = apply->_uid;
obj["icon"] = apply->_icon;
obj["nick"] = apply->_nick;
obj["sex"] = apply->_sex;
obj["desc"] = apply->_desc;
obj["status"] = apply->_status;
rtvalue["apply_list"].append(obj);
}
}

//获取好友列表
std::vector<std::shared_ptr<UserInfo>> friend_list;
bool b_friend_list = GetFriendList(uid, friend_list);
for (auto& friend_ele : friend_list) {
Json::Value obj;
obj["name"] = friend_ele->name;
obj["uid"] = friend_ele->uid;
obj["icon"] = friend_ele->icon;
obj["nick"] = friend_ele->nick;
obj["sex"] = friend_ele->sex;
obj["desc"] = friend_ele->desc;
obj["back"] = friend_ele->back;
rtvalue["friend_list"].append(obj);
}

auto server_name = ConfigMgr::Inst().GetValue("SelfServer", "Name");
{
//此处添加分布式锁,让该线程独占登录
//拼接用户ip对应的key
auto lock_key = LOCK_PREFIX + uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
//利用defer解锁
Defer defer2([this, identifier, lock_key]() {
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});
//此处判断该用户是否在别处或者本服务器登录

std::string uid_ip_value = "";
auto uid_ip_key = USERIPPREFIX + uid_str;
bool b_ip = RedisMgr::GetInstance()->Get(uid_ip_key, uid_ip_value);
//说明用户已经登录了,此处应该踢掉之前的用户登录状态
if (b_ip) {
//获取当前服务器ip信息
auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
//如果之前登录的服务器和当前相同,则直接在本服务器踢掉
if (uid_ip_value == self_name) {
//查找旧有的连接
auto old_session = UserMgr::GetInstance()->GetSession(uid);

//此处应该发送踢人消息
if (old_session) {
old_session->NotifyOffline(uid);
//清除旧的连接
_p_server->ClearSession(old_session->GetSessionId());
}

}
else {
//如果不是本服务器,则通知grpc通知其他服务器踢掉
//发送通知
KickUserReq kick_req;
kick_req.set_uid(uid);
ChatGrpcClient::GetInstance()->NotifyKickUser(uid_ip_value, kick_req);
}
}

//session绑定用户uid
session->SetUserId(uid);
//为用户设置登录ip server的名字
std::string ipkey = USERIPPREFIX + uid_str;
RedisMgr::GetInstance()->Set(ipkey, server_name);
//uid和session绑定管理,方便以后踢人操作
UserMgr::GetInstance()->SetUserSession(uid, session);
std::string uid_session_key = USER_SESSION_PREFIX + uid_str;
RedisMgr::GetInstance()->Set(uid_session_key, session->GetSessionId());

}

RedisMgr::GetInstance()->IncreaseCount(server_name);
return;
}

注意上面代码,这段代码就是跨服踢人逻辑。

1
2
3
4
5
6
7
else {
//如果不是本服务器,则通知grpc通知其他服务器踢掉
//发送通知
KickUserReq kick_req;
kick_req.set_uid(uid);
ChatGrpcClient::GetInstance()->NotifyKickUser(uid_ip_value, kick_req);
}

关于KickUserReq其实是我们在message.pb.h中生成的。但是我们在自己的文件中使用要用作用域messag::, 所以我们在GrpcClient.h中添加声明

1
2
using message::KickUserReq;
using message::KickUserRsp;

以后我们包含GrpcClient.h就可以使用这些类了。

封装rpc踢人

接下来我们封装rpc接口实现踢人逻辑

rpc客户端接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
KickUserRsp ChatGrpcClient::NotifyKickUser(std::string server_ip, const KickUserReq& req)
{
KickUserRsp rsp;
Defer defer([&rsp, &req]() {
rsp.set_error(ErrorCodes::Success);
rsp.set_uid(req.uid());
});

auto find_iter = _pools.find(server_ip);
if (find_iter == _pools.end()) {
return rsp;
}

auto& pool = find_iter->second;
ClientContext context;
auto stub = pool->getConnection();
Defer defercon([&stub, this, &pool]() {
pool->returnConnection(std::move(stub));
});
Status status = stub->NotifyKickUser(&context, req, &rsp);

if (!status.ok()) {
rsp.set_error(ErrorCodes::RPCFailed);
return rsp;
}

return rsp;
}

rpc服务端接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Status ChatServiceImpl::NotifyKickUser(::grpc::ServerContext* context, 
const KickUserReq* request, KickUserRsp* reply)
{
//查找用户是否在本服务器
auto uid = request->uid();
auto session = UserMgr::GetInstance()->GetSession(uid);

Defer defer([request, reply]() {
reply->set_error(ErrorCodes::Success);
reply->set_uid(request->uid());
});

//用户不在内存中则直接返回
if (session == nullptr) {
return Status::OK;
}

//在内存中则直接发送通知对方
session->NotifyOffline(uid);
//清除旧的连接
_p_server->ClearSession(session->GetSessionId());

return Status::OK;
}

为了让ChatServiceImpl 获取CServer, 所以我们提供了注册函数

1
2
3
4
void ChatServiceImpl::RegisterServer(std::shared_ptr<CServer> pServer)
{
_p_server = pServer;
}

这个函数在main函数中启动grpc服务前注册即可。

登录数量统计

在StatusServer中利用分布式锁获取登录数量,动态分配Server给客户端,这里我们也要用ChatServer启动和退出时清空登录数量

重新调整ChatServer启动逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using namespace std;
bool bstop = false;
std::condition_variable cond_quit;
std::mutex mutex_quit;

int main()
{
auto& cfg = ConfigMgr::Inst();
auto server_name = cfg["SelfServer"]["Name"];
try {
auto pool = AsioIOServicePool::GetInstance();
//将登录数设置为0
RedisMgr::GetInstance()->InitCount(server_name);
Defer derfer ([server_name]() {
RedisMgr::GetInstance()->HDel(LOGIN_COUNT, server_name);
RedisMgr::GetInstance()->Close();
});

boost::asio::io_context io_context;
auto port_str = cfg["SelfServer"]["Port"];
//创建Cserver智能指针
auto pointer_server = std::make_shared<CServer>(io_context, atoi(port_str.c_str()));
//定义一个GrpcServer
std::string server_address(cfg["SelfServer"]["Host"] + ":" + cfg["SelfServer"]["RPCPort"]);
ChatServiceImpl service;
grpc::ServerBuilder builder;
// 监听端口和添加服务
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
builder.RegisterService(&service);
service.RegisterServer(pointer_server);
// 构建并启动gRPC服务器
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
std::cout << "RPC Server listening on " << server_address << std::endl;

//单独启动一个线程处理grpc服务
std::thread grpc_server_thread([&server]() {
server->Wait();
});

boost::asio::signal_set signals(io_context, SIGINT, SIGTERM);
signals.async_wait([&io_context, pool, &server](auto, auto) {
io_context.stop();
pool->Stop();
server->Shutdown();
});

//将Cserver注册给逻辑类方便以后清除连接
LogicSystem::GetInstance()->SetServer(pointer_server);
io_context.run();

grpc_server_thread.join();
}
catch (std::exception& e) {
std::cerr << "Exception: " << e.what() << endl;
}

}

上面的逻辑有这样一段,要格外注意

1
2
3
4
5
6
//将登录数设置为0
RedisMgr::GetInstance()->InitCount(server_name);
Defer derfer ([server_name]() {
RedisMgr::GetInstance()->HDel(LOGIN_COUNT, server_name);
RedisMgr::GetInstance()->Close();
});

这段逻辑是在服务器启动后将对应服务器中连接数清零写入redis,在服务器结束后从redis中删除数量信息,最后关闭Redis连接池

源码

源码地址

https://gitee.com/secondtonone1/llfcchat

单服务器踢人逻辑实现

Posted on 2025-04-12 | In C++聊天项目

1. 为什么要有踢人逻辑

在服务器中经常会设计的同账户异地登陆时,将旧有账号的连接断开,必要时先发送下线消息通知旧账号的客户端,然后关闭这个连接。

服务器设计中尽量不要采用服务器主动关闭连接,那样会造成大量TIME_WAIT问题,这个之后再说。

先用一个图示说明踢人逻辑

旧客户端登录

image-20250412120427206

当有新客户端连接时

image-20250412121139480

上述图形演示的是单服务器踢人逻辑,多服务器踢人逻辑应配合分布式锁,锁住分布式的操作,保证在一个时刻只能一个客户端登录,客户端登陆完再解锁。

分布式登录我们放在下一节,这一节我们模拟两个客户端同账号登录同一个服务器,实现踢人逻辑。

2. 分布式锁和redis封装

为了更方便操作,我们将分布式锁加锁和解锁的操作封装到redis接口中

因为分布式锁也会占用连接,为了防止连接被占用耗尽连接池,所以我们提前扩大连接池的数量为10

1
2
3
4
5
6
7
RedisMgr::RedisMgr() {
auto& gCfgMgr = ConfigMgr::Inst();
auto host = gCfgMgr["Redis"]["Host"];
auto port = gCfgMgr["Redis"]["Port"];
auto pwd = gCfgMgr["Redis"]["Passwd"];
_con_pool.reset(new RedisConPool(10, host.c_str(), atoi(port.c_str()), pwd.c_str()));
}

封装加锁操作, 内部调用了之前封装的分布式锁DistLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::string RedisMgr::acquireLock(const std::string& lockName,
int lockTimeout, int acquireTimeout) {

auto connect = _con_pool->getConnection();
if (connect == nullptr) {
return "";
}

Defer defer([&connect, this]() {
_con_pool->returnConnection(connect);
});

return DistLock::Inst().acquireLock(connect, lockName, lockTimeout, acquireTimeout);
}

解锁操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool RedisMgr::releaseLock(const std::string& lockName,
const std::string& identifier) {
if (identifier.empty()) {
return true;
}
auto connect = _con_pool->getConnection();
if (connect == nullptr) {
return false;
}


Defer defer([&connect, this]() {
_con_pool->returnConnection(connect);
});

return DistLock::Inst().releaseLock(connect, lockName, identifier);
}

3. 加锁和解锁调用

对于踢人逻辑,最难的就是思考如何加锁和解锁,进行踢人,以保证将来分布式登录也会安全。

这里我们先考虑几个情形

  1. B新登录,此时A已登录,这种最简单,根据uid找到A的session发送踢人通知。
  2. B新登录,此时A将下线,这种要保证B和A互斥,要么B先登陆完,A再下线,要么A先下线,B再登录。

​ 这么做的好处就是保证互斥

​ 如果B先登录,会将uid对应的session更新为最新的。A下线时会优先查找uid对应的session,发现不是自己,则直接退出即可,同时不需要修改uid对应的session为空。

​ 如果A先退出,A下线时会优先查找uid对应的session, 发现uid对应的session和自己的连接吻合,则会将uid对应的session设置为空,然后B登录,将uid对应的session设置为新连接,这样是安全的。

  1. B登录,A退出,此时C查找uid发送消息,三个操作都会添加分布式锁。谁先竞争到锁谁操作,能保证操作的互斥。

基本就是这三种情况。接下来我们回顾下uid和Session的对应关系

4. 用户和会话关系

添加用户和会话关联

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserMgr: public Singleton<UserMgr>
{
friend class Singleton<UserMgr>;
public:
~UserMgr();
std::shared_ptr<CSession> GetSession(int uid);
void SetUserSession(int uid, std::shared_ptr<CSession> session);
void RmvUserSession(int uid, std::string session_id);
private:
UserMgr();
std::mutex _session_mtx;
std::unordered_map<int, std::shared_ptr<CSession>> _uid_to_session;
};

UserMgr中可以根据uid查找到对应的CSession。具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include "UserMgr.h"
#include "CSession.h"
#include "RedisMgr.h"

UserMgr:: ~ UserMgr(){
_uid_to_session.clear();
}


std::shared_ptr<CSession> UserMgr::GetSession(int uid)
{
std::lock_guard<std::mutex> lock(_session_mtx);
auto iter = _uid_to_session.find(uid);
if (iter == _uid_to_session.end()) {
return nullptr;
}

return iter->second;
}

void UserMgr::SetUserSession(int uid, std::shared_ptr<CSession> session)
{
std::lock_guard<std::mutex> lock(_session_mtx);
_uid_to_session[uid] = session;
}

void UserMgr::RmvUserSession(int uid, std::string session_id)
{
{
std::lock_guard<std::mutex> lock(_session_mtx);
auto iter = _uid_to_session.find(uid);
if (iter != _uid_to_session.end()) {
return;
}

auto session_id_ = iter->second->GetSessionId();
//不相等说明是其他地方登录了
if (session_id_ != session_id) {
return;
}
_uid_to_session.erase(uid);
}

}

UserMgr::UserMgr()
{

}

大家有没有注意到,对Session的操作没有加分布式锁,只加了线程锁,因为我的思路是在最外层加分布式锁,而接口内部只加线程锁,保证同一个服务器操作的原子性。

CSession类和之前一样, 里面有user_id和session_id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class CSession: public std::enable_shared_from_this<CSession>
{
public:
CSession(boost::asio::io_context& io_context, CServer* server);
~CSession();
tcp::socket& GetSocket();
std::string& GetSessionId();
void SetUserId(int uid);
int GetUserId();
void Start();
void Send(char* msg, short max_length, short msgid);
void Send(std::string msg, short msgid);
void Close();
std::shared_ptr<CSession> SharedSelf();
void AsyncReadBody(int length);
void AsyncReadHead(int total_len);
void NotifyOffline(int uid);
private:
void asyncReadFull(std::size_t maxLength, std::function<void(const boost::system::error_code& , std::size_t)> handler);
void asyncReadLen(std::size_t read_len, std::size_t total_len,
std::function<void(const boost::system::error_code&, std::size_t)> handler);


void HandleWrite(const boost::system::error_code& error, std::shared_ptr<CSession> shared_self);
tcp::socket _socket;
std::string _session_id;
char _data[MAX_LENGTH];
CServer* _server;
bool _b_close;
std::queue<shared_ptr<SendNode> > _send_que;
std::mutex _send_lock;
//收到的消息结构
std::shared_ptr<RecvNode> _recv_msg_node;
bool _b_head_parse;
//收到的头部结构
std::shared_ptr<MsgNode> _recv_head_node;
int _user_uid;
};

通过上述结构,我们可以通过UserMgr查找到CSession, 也可以通过CSession查找到userid, 实现了双向关联

5.登录添加分布式锁

我们需要对登录流程添加分布式锁,收到登录请求会做如下事情

  1. 判断token和uid是否合理
  2. 根据uid构造分布式锁key,然后实现分布式锁加锁操作。比如uid为1001,则分布式锁的key为”lock_1001”
  3. 加锁后通过defer自动析构解锁
  4. 通过uid获取用户之前登录的服务器,如果存在则说明uid对应的用户还在线,此时要做踢人,判断serverip和现在的服务器ip是否相等,如果相等则说明是

​ 本服务器踢人,只需要通过线程锁控制好并发逻辑即可,将uid对应的旧session发送信息通知客户端下线,并且将旧session从server中移除。

​ 如果不是本服务器,则要做跨服踢人,调用grpc踢人即可,留作之后做。

  1. 登录成功后,要将uid和对应的ip信息写入redis,方便以后跨服查找。另外uid对应的session信息也要写入redis, 同时将uid和session关联,这样可以通过uid快速找到session
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
void LogicSystem::LoginHandler(shared_ptr<CSession> session, const short &msg_id, const string &msg_data) {
Json::Reader reader;
Json::Value root;
reader.parse(msg_data, root);
auto uid = root["uid"].asInt();
auto token = root["token"].asString();
std::cout << "user login uid is " << uid << " user token is "
<< token << endl;

Json::Value rtvalue;
Defer defer([this, &rtvalue, session]() {
std::string return_str = rtvalue.toStyledString();
session->Send(return_str, MSG_CHAT_LOGIN_RSP);
});


//从redis获取用户token是否正确
std::string uid_str = std::to_string(uid);
std::string token_key = USERTOKENPREFIX + uid_str;
std::string token_value = "";
bool success = RedisMgr::GetInstance()->Get(token_key, token_value);
if (!success) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return ;
}

if (token_value != token) {
rtvalue["error"] = ErrorCodes::TokenInvalid;
return ;
}

rtvalue["error"] = ErrorCodes::Success;

//此处添加分布式锁,让该线程独占登录
//拼接用户ip对应的key
auto lock_key = LOCK_PREFIX + uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
//利用defer解锁
Defer defer2([this, identifier, lock_key]() {
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});
//此处判断该用户是否在别处或者本服务器登录

std::string uid_ip_value = "";
auto uid_ip_key = USERIPPREFIX + uid_str;
bool b_ip = RedisMgr::GetInstance()->Get(uid_ip_key, uid_ip_value);
//说明用户已经登录了,此处应该踢掉之前的用户登录状态
if (b_ip) {
//获取当前服务器ip信息
auto& cfg = ConfigMgr::Inst();
auto self_name = cfg["SelfServer"]["Name"];
//如果之前登录的服务器和当前相同,则直接在本服务器踢掉
if (uid_ip_value == self_name) {
//查找旧有的连接
auto old_session = UserMgr::GetInstance()->GetSession(uid);

//此处应该发送踢人消息
if (old_session) {
old_session->NotifyOffline(uid);
//清除旧的连接
_p_server->ClearSession(old_session->GetSessionId());
}

}
else {
//如果不是本服务器,则通知grpc通知其他服务器踢掉
}
}


std::string base_key = USER_BASE_INFO + uid_str;
auto user_info = std::make_shared<UserInfo>();
bool b_base = GetBaseInfo(base_key, uid, user_info);
if (!b_base) {
rtvalue["error"] = ErrorCodes::UidInvalid;
return;
}
rtvalue["uid"] = uid;
rtvalue["pwd"] = user_info->pwd;
rtvalue["name"] = user_info->name;
rtvalue["email"] = user_info->email;
rtvalue["nick"] = user_info->nick;
rtvalue["desc"] = user_info->desc;
rtvalue["sex"] = user_info->sex;
rtvalue["icon"] = user_info->icon;

//从数据库获取申请列表
std::vector<std::shared_ptr<ApplyInfo>> apply_list;
auto b_apply = GetFriendApplyInfo(uid,apply_list);
if (b_apply) {
for (auto & apply : apply_list) {
Json::Value obj;
obj["name"] = apply->_name;
obj["uid"] = apply->_uid;
obj["icon"] = apply->_icon;
obj["nick"] = apply->_nick;
obj["sex"] = apply->_sex;
obj["desc"] = apply->_desc;
obj["status"] = apply->_status;
rtvalue["apply_list"].append(obj);
}
}

//获取好友列表
std::vector<std::shared_ptr<UserInfo>> friend_list;
bool b_friend_list = GetFriendList(uid, friend_list);
for (auto& friend_ele : friend_list) {
Json::Value obj;
obj["name"] = friend_ele->name;
obj["uid"] = friend_ele->uid;
obj["icon"] = friend_ele->icon;
obj["nick"] = friend_ele->nick;
obj["sex"] = friend_ele->sex;
obj["desc"] = friend_ele->desc;
obj["back"] = friend_ele->back;
rtvalue["friend_list"].append(obj);
}

auto server_name = ConfigMgr::Inst().GetValue("SelfServer", "Name");
//将登录数量增加
auto rd_res = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server_name);
int count = 0;
if (!rd_res.empty()) {
count = std::stoi(rd_res);
}

count++;
auto count_str = std::to_string(count);
RedisMgr::GetInstance()->HSet(LOGIN_COUNT, server_name, count_str);

//session绑定用户uid
session->SetUserId(uid);
//为用户设置登录ip server的名字
std::string ipkey = USERIPPREFIX + uid_str;
RedisMgr::GetInstance()->Set(ipkey, server_name);
//uid和session绑定管理,方便以后踢人操作
UserMgr::GetInstance()->SetUserSession(uid, session);
std::string uid_session_key = USER_SESSION_PREFIX + uid_str;
RedisMgr::GetInstance()->Set(uid_session_key, session->GetSessionId());
return;
}

6. 检测离线处理

服务器也会检测到离线也会清理连接,但是要注意,连接可以不按照分布式锁加锁清理,但是连接的信息要加分布式锁后再更新。

比如是否将uid对应的session更新到redis中,因为很可能用户在别的新服务器登录,新服务器给旧的客户端通知离线,旧的客户端不按理连接,导致旧的服务器检测连接断开,此时不能将uid对应的session清空,因为uid对应的session已经被新服务器更新了。

image-20250412144455898

在发送和接收的时候都可能检测到对方离线而报错,所以在AsyncReadBody和AsyncReadHead以及AsyncWrite等错误处理的时候记得加上连接清理操作

我们以读取body为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void CSession::AsyncReadBody(int total_len)
{
auto self = shared_from_this();
asyncReadFull(total_len, [self, this, total_len](const boost::system::error_code& ec, std::size_t bytes_transfered) {
try {
if (ec) {
std::cout << "handle read failed, error is " << ec.what() << endl;
Close();

//加锁清除session
auto uid_str = std::to_string(_user_uid);
auto lock_key = LOCK_PREFIX + uid_str;
auto identifier = RedisMgr::GetInstance()->acquireLock(lock_key, LOCK_TIME_OUT, ACQUIRE_TIME_OUT);
Defer defer([identifier, lock_key,self,this]() {
_server->ClearSession(_session_id);
RedisMgr::GetInstance()->releaseLock(lock_key, identifier);
});

if (identifier.empty()) {
return;
}
std::string redis_session_id = "";
auto bsuccess = RedisMgr::GetInstance()->Get(USER_SESSION_PREFIX + uid_str, redis_session_id);
if (!bsuccess) {
return;
}

if (redis_session_id != _session_id) {
//说明有客户在其他服务器异地登录了
return;
}

RedisMgr::GetInstance()->Del(USER_SESSION_PREFIX + uid_str);
//清除用户登录信息
RedisMgr::GetInstance()->Del(USERIPPREFIX + uid_str);
return;
}

if (bytes_transfered < total_len) {
std::cout << "read length not match, read [" << bytes_transfered << "] , total ["
<< total_len<<"]" << endl;
Close();
_server->ClearSession(_session_id);
return;
}

memcpy(_recv_msg_node->_data , _data , bytes_transfered);
_recv_msg_node->_cur_len += bytes_transfered;
_recv_msg_node->_data[_recv_msg_node->_total_len] = '\0';
cout << "receive data is " << _recv_msg_node->_data << endl;
//此处将消息投递到逻辑队列中
LogicSystem::GetInstance()->PostMsgToQue(make_shared<LogicNode>(shared_from_this(), _recv_msg_node));
//继续监听头部接受事件
AsyncReadHead(HEAD_TOTAL_LEN);
}
catch (std::exception& e) {
std::cout << "Exception code is " << e.what() << endl;
}
});
}

7. 测试效果

本节先测试单服务器同账号不同客户端登录情况,为了将同账号客户端派发到同一个服务器,暂时修改StatusServer的派发逻辑为同一个服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ChatServer StatusServiceImpl::getChatServer() {
std::lock_guard<std::mutex> guard(_server_mtx);
auto minServer = _servers.begin()->second;
//暂时注释,测试单服务器模式
//auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, minServer.name);
//if (count_str.empty()) {
// //不存在则默认设置为最大
// minServer.con_count = INT_MAX;
//}
//else {
// minServer.con_count = std::stoi(count_str);
//}


//// 使用范围基于for循环
//for ( auto& server : _servers) {
//
// if (server.second.name == minServer.name) {
// continue;
// }

// auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
// if (count_str.empty()) {
// server.second.con_count = INT_MAX;
// }
// else {
// server.second.con_count = std::stoi(count_str);
// }

// if (server.second.con_count < minServer.con_count) {
// minServer = server.second;
// }
//}

return minServer;
}

image-20250412150004397

image-20250412150038381

8. 待做事项

  1. 跨服踢人留作下一节处理
  2. 心跳检测未作,留作以后处理
  3. 心跳检测发现僵尸连接,需要踢人,留作以后处理。

9. 源码

https://gitee.com/secondtonone1/llfcchat

分布式锁设计思路

Posted on 2025-03-31 | In C++聊天项目

1. 引言

在分布式系统中,多个客户端可能同时访问和操作共享资源。为了防止数据竞争和不一致,分布式锁是一个常见的解决方案。Redis 提供了强大的功能来实现高效且可靠的分布式锁。本文将通过 C++ 和 Redis(通过 hredis 库)实现一个简单的分布式锁。

2. 项目背景

  • 分布式锁:它是一种机制,用于保证在分布式系统中,某一时刻只有一个客户端能够执行某些共享资源的操作。
  • 使用 Redis 作为锁存储:Redis 被用作集中式存储,可以确保锁的状态在所有参与者之间同步。

3. 设计思路

分布式锁的核心思想是:

  1. 加锁:客户端通过设置一个 Redis 键来获取锁。通过 Redis 的原子操作,确保只有一个客户端能够成功设置该键。
  2. 持有者标识符:每个客户端在加锁时生成一个唯一的标识符(UUID),该标识符用来标识锁的持有者。
  3. 超时机制:锁会在一定时间后自动释放(过期),防止因程序异常导致的死锁。
  4. 解锁:只有锁的持有者才能释放锁,这通过 Redis Lua 脚本来保证。

4. 代码实现步骤

4.1 生成全局唯一标识符 (UUID)

使用 Boost UUID 库生成一个全局唯一的标识符(UUID)。这个标识符会被用作锁的持有者标识符。它确保每个客户端在加锁时拥有唯一的标识,从而能够确保锁的唯一性。

代码:

1
2
3
4
std::string generateUUID() {
boost::uuids::uuid uuid = boost::uuids::random_generator()();
return to_string(uuid);
}

4.2 尝试加锁(acquireLock 函数)

客户端通过 Redis 的 SET 命令尝试加锁。该命令的参数如下:

  • NX:确保只有当键不存在时才能成功设置(即,只有一个客户端能够成功设置锁)。
  • EX:设置一个超时时间,锁会在超时后自动释放,避免死锁。

如果加锁成功,返回一个唯一标识符。如果加锁失败,则会在指定的超时时间内多次尝试。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 尝试获取锁,返回锁的唯一标识符(UUID),如果获取失败则返回空字符串
std::string acquireLock(redisContext* context, const std::string& lockName, int lockTimeout, int acquireTimeout) {
std::string identifier = generateUUID();
std::string lockKey = "lock:" + lockName;
auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);

while (std::chrono::steady_clock::now() < endTime) {
// 使用 SET 命令尝试加锁:SET lockKey identifier NX EX lockTimeout
redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d", lockKey.c_str(), identifier.c_str(), lockTimeout);
if (reply != nullptr) {
// 判断返回结果是否为 OK
if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
freeReplyObject(reply);
return identifier;
}
freeReplyObject(reply);
}
// 暂停 1 毫秒后重试,防止忙等待
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return "";
}

函数参数说明

redisContext* context
这是一个指向 Redis 连接上下文的指针,用于与 Redis 服务器通信。通过这个上下文,你可以发送命令和接收响应。

const std::string& lockName
这是你想要加锁的资源名称。例如,如果你需要对某个资源加锁,可以用 "my_resource",函数内部会把它拼接成 "lock:my_resource" 作为 Redis 中的 key。

int lockTimeout
这是锁的有效期,单位是秒。设置这个值的目的是防止因程序异常或崩溃而导致的死锁。当锁达到这个超时时间后,Redis 会自动删除这个 key,从而释放锁。

int acquireTimeout
这是获取锁的最大等待时间,单位也是秒。如果在这个时间内没有成功获取到锁,函数就会停止尝试,并返回空字符串。这样可以避免程序无限等待。

Redis 命令解释

在 acquireLock 函数中,使用的 Redis 命令格式是:

1
"SET %s %s NX EX %d"

这个命令实际上是一个格式化字符串,参数会被填入以下位置:

  1. SET
    Redis 的基本命令,用于设置一个 key 的值。
  2. %s(第一个 %s)
    代表锁的 key(例如 "lock:my_resource")。
  3. %s(第二个 %s)
    代表锁的持有者标识符,也就是通过 generateUUID() 生成的 UUID。
  4. NX
    表示 “Not eXists”,意思是“只有当 key 不存在时才进行设置”。这可以保证如果其他客户端已经设置了这个 key(即已经有锁了),那么当前客户端就不会覆盖原来的锁。
  5. EX %d
    EX 参数用于指定 key 的过期时间,%d 表示锁的有效期(lockTimeout),单位为秒。这样即使客户端因某些原因没有正常释放锁,锁也会在指定时间后自动失效。

构造锁的 Redis 键

1
std::string lockKey = "lock:" + lockName;
  • 构造出 Redis 键,格式为 lock:lockName,用于存储锁的状态。

设置获取锁的截止时间

1
auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);
  • 设置一个截止时间 endTime,表示最多尝试获取锁的时间,单位为秒。当前时间加上 acquireTimeout 秒即为截止时间。

尝试获取锁

1
while (std::chrono::steady_clock::now() < endTime) {
  • 通过一个 while 循环,不断尝试获取锁,直到超时或成功获取锁。

使用 Redis 的 SET 命令尝试加锁

1
redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d", lockKey.c_str(), identifier.c_str(), lockTimeout);
  • 通过 Redis 的 SET 命令来尝试获取锁,命令格式为:
    • SET lockKey identifier NX EX lockTimeout:
      • NX:只有当 lockKey 不存在时才会设置成功(即实现了锁的功能,防止其他客户端重入)。
      • EX lockTimeout:设置锁的过期时间为 lockTimeout 秒,防止锁永远占用。
    • 如果锁成功获取,Redis 会返回 OK。

检查返回结果

1
2
3
4
5
6
7
if (reply != nullptr) {
if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
freeReplyObject(reply);
return identifier;
}
freeReplyObject(reply);
}
  • 如果 Redis 返回的 reply 不为空,检查返回值类型是否为 REDIS_REPLY_STATUS,并且返回的字符串是否是 OK,表示锁成功获取。
  • 如果获取锁成功,释放 redisReply 对象,并返回生成的唯一标识符 identifier,表示锁已经成功获得。

暂停并重试

1
std::this_thread::sleep_for(std::chrono::milliseconds(1));
  • 如果获取锁失败,则通过 std::this_thread::sleep_for 暂停 1 毫秒,避免忙等待,提高 CPU 的利用率。

超时返回空字符串

1
return "";
  • 如果在指定的 acquireTimeout 时间内没有成功获取锁,函数返回空字符串,表示获取锁失败。

4.3 释放锁(releaseLock 函数)

释放锁的操作使用 Redis Lua 脚本,确保只有持有锁的客户端才能释放锁。脚本通过判断当前锁的持有者是否与传入的标识符一致来决定是否删除锁。

Lua 脚本:

1
2
3
4
5
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
  • KEYS[1]:锁的 key(例如 lock:my_resource)。
  • ARGV[1]:客户端在加锁时生成的唯一标识符。
  • 如果当前锁的值(标识符)与传入的标识符一致,删除该锁。

Lua 脚本的作用是:

  1. redis.call('get', KEYS[1]):从 Redis 获取 lockKey 对应的值。
  2. if redis.call('get', KEYS[1]) == ARGV[1]:检查获取到的值是否与传入的 identifier 相同,只有标识符匹配时才能删除锁。
  3. return redis.call('del', KEYS[1]):如果匹配,执行删除操作,释放锁。
  4. else return 0:如果标识符不匹配,返回 0,表示没有成功释放锁。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 释放锁,只有锁的持有者才能释放,返回是否成功
bool releaseLock(redisContext* context, const std::string& lockName, const std::string& identifier) {
std::string lockKey = "lock:" + lockName;
// Lua 脚本:判断锁标识是否匹配,匹配则删除锁
const char* luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then \
return redis.call('del', KEYS[1]) \
else \
return 0 \
end";
// 调用 EVAL 命令执行 Lua 脚本,第一个参数为脚本,后面依次为 key 的数量、key 以及对应的参数
redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s", luaScript, lockKey.c_str(), identifier.c_str());
bool success = false;
if (reply != nullptr) {
// 当返回整数值为 1 时,表示成功删除了锁
if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
success = true;
}
freeReplyObject(reply);
}
return success;
}

函数参数说明

  • redisContext* context:指向 Redis 连接上下文的指针,用于执行 Redis 命令。
  • const std::string& lockName:锁的名称,用于生成 Redis 键的名称。
  • const std::string& identifier:标识符,用于标识哪个客户端持有锁。

函数返回一个布尔值,通常表示释放锁操作是否成功。

执行 Lua 脚本

1
redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s", luaScript, lockKey.c_str(), identifier.c_str());
  • 使用 redisCommand 函数执行 Redis 的 EVAL 命令,传入脚本、键的数量(在这里是 1,因为只有一个 lockKey),然后依次传入 lockKey 和 identifier。
  • redisCommand 会返回一个 redisReply 指针,表示命令的返回结果。

处理返回结果

1
2
3
4
5
6
7
8
cpp复制bool success = false;
if (reply != nullptr) {
// 当返回整数值为 1 时,表示成功删除了锁
if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
success = true;
}
freeReplyObject(reply);
}
  • 如果返回的 redisReply 不为空,表示 Redis 执行了命令。
  • 检查返回值的类型是否是整数(REDIS_REPLY_INTEGER),并且它的值是否是 1。如果是 1,表示删除锁成功,将 success 设置为 true。
  • 释放 redisReply 对象,防止内存泄漏。

4.4 主函数(main 函数)

主函数执行以下操作:

  1. 创建 Redis 客户端并连接到 Redis 服务器。
  2. 尝试加锁,若成功获取锁,则执行临界区代码。
  3. 在临界区代码执行完后,释放锁。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int main() {
// 连接到 Redis 服务器(根据实际情况修改主机和端口)
redisContext* context = redisConnect("127.0.0.1", 6379);
if (context == nullptr || context->err) {
if (context) {
std::cerr << "连接错误: " << context->errstr << std::endl;
redisFree(context);
} else {
std::cerr << "无法分配 redis context" << std::endl;
}
return 1;
}

// 尝试获取锁(锁有效期 10 秒,获取超时时间 5 秒)
std::string lockId = acquireLock(context, "my_resource", 10, 5);
if (!lockId.empty()) {
std::cout << "子进程 " << GetCurrentProcessId() << " 成功获取锁,锁 ID: " << lockId << std::endl;
// 执行需要保护的临界区代码
std::this_thread::sleep_for(std::chrono::seconds(2));

// 释放锁
if (releaseLock(context, "my_resource", lockId)) {
std::cout << "成功释放锁" << std::endl;
} else {
std::cout << "释放锁失败" << std::endl;
}
} else {
std::cout << "获取锁失败" << std::endl;
}

// 释放 Redis 连接
redisFree(context);
return 0;
}

5. 封装为单例类操作

类声明如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string>
#include <hiredis.h>
class DistLock
{
public:
static DistLock& Inst();
~DistLock();
std::string acquireLock(redisContext* context, const std::string& lockName,
int lockTimeout, int acquireTimeout);

bool releaseLock(redisContext* context, const std::string& lockName,
const std::string& identifier);
private:
DistLock() = default;
};

类定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <iostream>
#include <string>
#include <chrono>
#include <thread>
#include <cstdlib>
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>
#include <hiredis.h>

DistLock& DistLock::Inst() {
static DistLock lock;
return lock;
}


DistLock::~DistLock() {

}

// 尝试获取锁,返回锁的唯一标识符(UUID),如果获取失败则返回空字符串
std::string DistLock::acquireLock(redisContext* context, const std::string& lockName,
int lockTimeout, int acquireTimeout) {
std::string identifier = generateUUID();
std::string lockKey = "lock:" + lockName;
auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);

while (std::chrono::steady_clock::now() < endTime) {
// 使用 SET 命令尝试加锁:SET lockKey identifier NX EX lockTimeout
redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d",
lockKey.c_str(), identifier.c_str(), lockTimeout);
if (reply != nullptr) {
// 判断返回结果是否为 OK
if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
freeReplyObject(reply);
return identifier;
}
freeReplyObject(reply);
}
// 暂停 1 毫秒后重试,防止忙等待
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return "";
}

// 释放锁,只有锁的持有者才能释放,返回是否成功
bool DistLock::releaseLock(redisContext* context, const std::string& lockName,
const std::string& identifier) {
std::string lockKey = "lock:" + lockName;
// Lua 脚本:判断锁标识是否匹配,匹配则删除锁
const char* luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then \
return redis.call('del', KEYS[1]) \
else \
return 0 \
end";
// 调用 EVAL 命令执行 Lua 脚本,第一个参数为脚本,后面依次为 key 的数量、key 以及对应的参数
redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s",
luaScript, lockKey.c_str(), identifier.c_str());
bool success = false;
if (reply != nullptr) {
// 当返回整数值为 1 时,表示成功删除了锁
if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
success = true;
}
freeReplyObject(reply);
}
return success;
}

测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int TestDisLock() {
// 连接到 Redis 服务器(根据实际情况修改主机和端口)
redisContext* context = redisConnect("81.68.86.146", 6380);
if (context == nullptr || context->err) {
if (context) {
std::cerr << "连接错误: " << context->errstr << std::endl;
redisFree(context);
}
else {
std::cerr << "无法分配 redis context" << std::endl;
}
return 1;
}

std::string redis_password = "123456";
redisReply* r = (redisReply*)redisCommand(context, "AUTH %s", redis_password.c_str());
if (r->type == REDIS_REPLY_ERROR) {
printf("Redis认证失败!\n");
}
else {
printf("Redis认证成功!\n");
}

// 尝试获取锁(锁有效期 10 秒,获取超时时间 5 秒)
std::string lockId = DistLock::Inst().acquireLock(context, "my_resource", 10, 5);

if (!lockId.empty()) {
std::cout << "子进程 " << GetCurrentProcessId() << " 成功获取锁,锁 ID: " << lockId << std::endl;
// 执行需要保护的临界区代码
std::this_thread::sleep_for(std::chrono::seconds(2));

// 释放锁
if (DistLock::Inst().releaseLock(context, "my_resource", lockId)) {
std::cout << "子进程 " << GetCurrentProcessId() << " 成功释放锁" << std::endl;
}
else {
std::cout << "子进程 " << GetCurrentProcessId() << " 释放锁失败" << std::endl;
}
}
else {
std::cout << "子进程 " << GetCurrentProcessId() << " 获取锁失败" << std::endl;
}

// 释放 Redis 连接
redisFree(context);
}

6. 多进程测试

我们可以创建另一个项目,调用之前生成好的distribute.exe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <windows.h>
#include <iostream>
#include <vector>

int main() {
const int numProcesses = 5; // 需要启动 5 个子进程
std::vector<PROCESS_INFORMATION> procInfos;

for (int i = 0; i < numProcesses; i++) {
STARTUPINFO si = { 0 };
si.cb = sizeof(si);
PROCESS_INFORMATION pi = { 0 };

// 这里假设 ChildTest.exe 在当前目录下
if (CreateProcess(TEXT("DistributeLock.exe"), // 应用程序名
NULL, // 命令行参数
NULL, // 进程句柄不可继承
NULL, // 线程句柄不可继承
FALSE, // 不继承句柄
0, // 没有特殊创建标志
NULL, // 使用父进程的环境
NULL, // 使用父进程的当前目录
&si, // 指向 STARTUPINFO 结构体的指针
&pi)) // 指向 PROCESS_INFORMATION 结构体的指针
{
std::cout << "成功创建子进程, PID: " << pi.dwProcessId << std::endl;
procInfos.push_back(pi);
}
else {
std::cerr << "创建子进程失败: " << GetLastError() << std::endl;
}
}

// 等待所有子进程结束
for (auto& pi : procInfos) {
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}

std::cout << "所有子进程已结束" << std::endl;
system("pause");
return 0;
}

测试效果

image-20250331215038920

7. 总结

  • 分布式锁:使用 Redis 和 Boost UUID 实现了一个简单的分布式锁,确保多个客户端可以同步地访问共享资源。
  • 加锁:通过 Redis 的 SET 命令,使用 NX 和 EX 参数确保只有一个客户端可以成功加锁。
  • 解锁:通过 Lua 脚本确保只有锁的持有者能够释放锁,避免其他客户端误释放锁。
  • 持有者标识符:每个客户端在加锁时生成一个唯一的标识符(UUID),它作为锁的持有者标识。

零基础C++(36) 完结-封装继承多态

Posted on 2025-03-19 | In 零基础C++

C++ 静态库与动态库详解及 CMake 构建指南

一、引言

在软件开发中,**库(Library)**是指一组预先编写好的、可复用的代码模块,旨在简化开发过程、提高代码复用性和维护性。库本质上是一种可执行代码的二进制形式,可以被操作系统载入内存执行。根据链接方式的不同,库主要分为**静态库(Static Library)**和**动态库(Dynamic Library)**。

二、库的基本概念

1. 静态库(Static Library)

  • 文件后缀:
    • Windows 下:.lib
    • Unix/Linux 下:.a
  • 特点:
    • 编译时链接:在编译过程中,静态库的代码会被复制并链接到最终的可执行文件中。
    • 独立性:生成的可执行文件不依赖于外部库文件,运行时无需额外的库支持。
    • 体积较大:因为库代码被嵌入到每个使用它的可执行文件中,导致可执行文件体积增加。
    • 版本一致性:不同项目使用的静态库版本是固定的,更新库后需重新编译依赖的可执行文件以应用新版本。

2. 动态库(Dynamic Library)

  • 文件后缀:
    • Windows 下:.dll
    • Unix/Linux 下:.so
  • 特点:
    • 运行时链接:动态库在程序运行时加载,库代码不被嵌入到可执行文件中。
    • 共享性:多个可执行文件可以共享同一个动态库,节省内存和存储空间。
    • 灵活性:更新动态库后,所有依赖该库的程序无需重新编译即可使用新版本。
    • 依赖性:运行时需要确保动态库文件存在,否则程序无法启动。

三、静态库与动态库的对比

特性 静态库(.lib/.a) 动态库(.dll/.so)
链接时间 编译时 运行时
文件大小 可执行文件较大 可执行文件较小,库单独存放
更新与维护 更新库需要重新编译所有依赖项 更新库无需重新编译,多个程序可共享库
内存使用 每个程序独立加载库代码 多个程序共享同一库的内存空间
平台依赖性 较少(生成的可执行文件独立) 需要确保运行环境中存在相应的动态库
性能 链接时已完成,可能略快于动态库 运行时需加载库,初次加载可能稍慢

四、C++ 库的创建与使用

1. 创建库的源代码示例

以一个简单的数学库 mymath 为例,提供计算整数和浮点数之和的功能。

a. 头文件 mymath.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef MYMATH_H
#define MYMATH_H

#ifdef MYMATH_EXPORTS
#define MYMATH_API __declspec(dllexport)
#else
#define MYMATH_API __declspec(dllimport)
#endif

extern "C" {
MYMATH_API int add_int(int a, int b);
MYMATH_API float add_float(float a, float b);
}

#endif // MYMATH_H
  • 解释

    :

    • MYMATH_API 宏用于控制符号的导出与导入。在构建动态库时定义 MYMATH_EXPORTS 以导出符号;在使用库时不定义该宏,以导入符号。
    • extern "C" 避免 C++ 的名字修饰(Name Mangling),确保库函数具有 C 风格的接口,便于跨语言调用。

b. 源文件 mymath.cpp

1
2
3
4
5
6
7
8
9
#include "mymath.h"

int add_int(int a, int b) {
return a + b;
}

float add_float(float a, float b) {
return a + b;
}

2. 使用 CMake 构建库

CMake 是一个跨平台的自动化构建系统,能够简化构建过程。以下介绍如何使用 CMake 构建静态库和动态库。

a. 项目结构

假设项目结构如下:

1
2
3
4
5
6
MyMathLib/
├── CMakeLists.txt
├── include/
│ └── mymath.h
└── src/
└── mymath.cpp

b. 创建静态库的 CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cmake_minimum_required(VERSION 3.0)
project(MyMathLib)

# 指定库的头文件路径
include_directories(${CMAKE_SOURCE_DIR}/include)

# 创建静态库
add_library(mymath STATIC src/mymath.cpp)

# 设置库的版本号(可选)
set_target_properties(mymath PROPERTIES VERSION 1.0.0 SOVERSION 1)

# 安装目标
install(TARGETS mymath
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)

# 安装头文件
install(FILES include/mymath.h DESTINATION include)

c. 创建动态库的 CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cmake_minimum_required(VERSION 3.0)
project(MyMathLib)

# 定义宏用于导出符号(仅在 Windows 平台)
if (WIN32)
add_definitions(-DMYMATH_EXPORTS)
endif()

# 指定库的头文件路径
include_directories(${CMAKE_SOURCE_DIR}/include)

# 创建动态库
add_library(mymath SHARED src/mymath.cpp)

# 设置库的版本号(可选)
set_target_properties(mymath PROPERTIES VERSION 1.0.0 SOVERSION 1)

# 安装目标
install(TARGETS mymath
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)

# 安装头文件
install(FILES include/mymath.h DESTINATION include)

d. 同时构建静态库和动态库的 CMakeLists.txt

如果需要同时生成静态库和动态库,可以在同一个 CMakeLists.txt 中添加多个库目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
cmake_minimum_required(VERSION 3.0)
project(MyMathLib)

# 定义宏用于导出符号(仅在 Windows 平台)
if (WIN32)
add_definitions(-DMYMATH_EXPORTS)
endif()

# 指定库的头文件路径
include_directories(${CMAKE_SOURCE_DIR}/include)

# 创建静态库
add_library(mymath_static STATIC src/mymath.cpp)

# 创建动态库
add_library(mymath_shared SHARED src/mymath.cpp)

# 设置动态库的版本号(可选)
set_target_properties(mymath_shared PROPERTIES VERSION 1.0.0 SOVERSION 1)

# 安装静态库和动态库
install(TARGETS mymath_static mymath_shared
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)

# 安装头文件
install(FILES include/mymath.h DESTINATION include)

3. 编译库

在项目根目录下执行以下命令以编译库:

1
2
3
4
mkdir build
cd build
cmake ..
cmake --build .

根据 CMakeLists.txt 的配置,这将在 build 目录下生成静态库和/或动态库文件。

五、在项目中使用库

1. 示例项目结构

假设有一个应用程序项目 MyApp,其结构如下:

1
2
3
MyApp/
├── CMakeLists.txt
├── main.cpp

2. 编写应用程序源代码 main.cpp

1
2
3
4
5
6
7
8
9
10
#include "mymath.h"
#include <iostream>

int main() {
int sum_int = add_int(3, 4);
float sum_float = add_float(3.5f, 4.2f);
std::cout << "Sum (int): " << sum_int << std::endl;
std::cout << "Sum (float): " << sum_float << std::endl;
return 0;
}

3. 编写应用程序的 CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cmake_minimum_required(VERSION 3.0)
project(MyApp)

# 指定库的头文件路径
include_directories(${CMAKE_SOURCE_DIR}/../MyMathLib/include)

# 添加可执行文件
add_executable(MyApp main.cpp)

# 链接静态库或动态库
# 静态库
target_link_libraries(MyApp ${CMAKE_SOURCE_DIR}/../MyMathLib/build/lib/libmymath_static.a)

# 或者动态库(需要确保运行时可以找到动态库)
# target_link_libraries(MyApp ${CMAKE_SOURCE_DIR}/../MyMathLib/build/lib/libmymath_shared.so)

注意:

  • 对于 Windows 平台,动态库的链接和部署可能需要额外配置,确保 .dll 文件在可执行文件的同一目录或系统路径下。
  • 使用相对路径链接库时,应确保路径的正确性,或考虑使用 CMake 的 find_library 等功能进行更灵活的库查找。

4. 构建并运行应用程序

在 MyApp 项目根目录下执行:

1
2
3
4
5
mkdir build
cd build
cmake ..
cmake --build .
./MyApp

运行后,应输出:

1
2
Sum (int): 7
Sum (float): 7.7

六、注意事项

  1. 宏定义与符号导出:
    • 在动态库的头文件中,通过宏定义控制符号的导出和导入。在 Windows 平台,使用 __declspec(dllexport) 导出符号,__declspec(dllimport) 导入符号;而在 Unix/Linux 平台,通常不需要特殊标记。
  2. 跨平台兼容性:
    • 不同平台的动态库文件后缀和符号导出方式不同。CMake 可以根据平台条件设置宏定义和库属性,确保库在各个平台上的正确构建和使用。
  3. 安装与部署:
    • 使用 install 指令指定库文件和头文件的安装路径,使得其他项目可以方便地引用和链接这些库。
    • 动态库需要在运行时能够被找到,可以通过环境变量 PATH(Windows)或 LD_LIBRARY_PATH(Unix/Linux)设置库的查找路径,或将库文件放在系统默认路径下。
  4. 依赖管理:
    • 对于较大的项目,可以使用 CMake 的 find_package 或 add_subdirectory 来管理多个库和依赖,保持项目结构清晰。
  5. 版本控制:
    • 设置库的版本号,有助于管理不同版本的库及其兼容性。例如,使用 VERSION 和 SOVERSION 属性控制动态库的主版本和次版本,便于动态链接器进行版本匹配。

封装任务管理系统

日志库设计

Logger.h声明不变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#ifndef LOGGER_LOGGER_H
#define LOGGER_LOGGER_H
#include <fstream>
#include <mutex>

#ifdef _WIN32
#ifdef LOGGER_BUILDING_DLL
#define LOGGER_API __declspec(dllexport)
#else
#define LOGGER_API __declspec(dllimport)
#endif
#else
#define LOGGER_API
#endif

LOGGER_API class Logger {
public:
//获取单例
static Logger& getInstance();
//禁止拷贝和赋值
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
//记录日志
void log(const std::string & message);
~Logger();
private:
Logger();
std::ofstream logFile;
std::mutex mutex;
};

#endif //LOGGER_LOGGER_H

Logger.cpp具体实现也不变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "Logger.h"

Logger& Logger::getInstance() {
static Logger logger;
return logger;
}

Logger::Logger(){
logFile.open("log.txt", std::ios::app);
if(!logFile.is_open()){
throw std::runtime_error("Failed to open log file");
}
}

Logger::~Logger(){
if(logFile.is_open()){
logFile.close();
}
}

void Logger::log(const std::string& message){
std::lock_guard<std::mutex> lock(mutex);
if(logFile.is_open()){
auto now = std::chrono::system_clock::now();
auto now_time = std::chrono::system_clock::to_time_t(now);
char buffer[100];
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", std::localtime(&now_time));
logFile << std::string(buffer) << ": " << message << std::endl;
}
}

Logger库编写

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.28)
project(Logger)

set(CMAKE_CXX_STANDARD 17)

add_library(Logger SHARED Logger.cpp
)
target_compile_definitions(Logger PRIVATE LOGGER_BUILDING_DLL)

纯虚类设计

我们之前采用CRTP方式封装了任务管理系统,这一节用封装继承多态的思想封装这个任务,我们期望将Command以及任务类等统一封装到一个libCommand.dll中,对外只提供Command.h文件,外界无法知晓Command内部实现细节。

Command.h仅提供纯虚类的声明,以及唯一一个获取纯虚类指针的函数GetCommand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef COMMAND_COMMAND_H
#define COMMAND_COMMAND_H
#include <string>
#include <memory>
#ifdef _WIN32
#ifdef COMMAND_BUILDING_DLL
#define COMMAND_API __declspec(dllexport)
#else
#define COMMAND_API __declspec(dllimport)
#endif
#else
#define COMMAND_API
#endif
class COMMAND_API Command{
public:
virtual void execute(const std::string& args) = 0;
};

COMMAND_API std::shared_ptr<Command> GetCommand(const std::string& command);

#endif //COMMAND_COMMAND_H

Command.cpp提供GetCommand的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "Command.h"
#include "CommandImpl.h"
#include <memory>

TaskManager mgsr;
std::shared_ptr<Command> GetCommand(const std::string& command){
if(command == "add"){
return std::make_shared<AddCommand>(mgsr);
}

if(command == "del"){
return std::make_shared<DeleteCommand>(mgsr);
}

if(command == "list"){
return std::make_shared<ListCommand>(mgsr);
}

if(command == "update"){
return std::make_shared<UpdateCommand>(mgsr);
}
return nullptr;
}

Command.cpp中创建了不同的命令,并且定义了全局的TaskManager

重写纯虚类

命令的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
//
// Created by secon on 2025/3/14.
//

#ifndef COMMAND_COMMANDIMPL_H
#define COMMAND_COMMANDIMPL_H
#include "Command.h"
#include "TaskManager.h"
#include "Logger.h"
class AddCommand: public Command {
public:
AddCommand(TaskManager& manager):taskManager(manager){}
void execute(const std::string& args){
//解析参数
size_t pos1 = args.find(',');
size_t pos2 = args.find(',',pos1+1);
if(pos1 == std::string::npos || pos2 == std::string::npos){
// Logger::getInstance().log("参数格式错误");
std::cout << "参数格式错误。请使用: add <描述>,<优先级>,<截止日期>" << std::endl;
return;
}

std::string description = args.substr(0,pos1);
int priority = std::stoi(args.substr(pos1+1,pos2-pos1-1));
std::string dueDate = args.substr(pos2+1);
taskManager.addTask(description,priority,dueDate);
std::cout << "任务添加成功."<< std::endl;
}

private:
TaskManager& taskManager;
};

class DeleteCommand: public Command {
public:
DeleteCommand(TaskManager& manager):taskManager(manager){}
void execute(const std::string& args){
try{
size_t pos ;
int id = std::stoi(args,&pos);
if(pos != args.length()){
std::cout << "参数格式错误。请使用: delete <ID>" << std::endl;
return;
}
taskManager.deleteTask(id);

std::cout << "任务删除成功."<< std::endl;
}catch(const std::invalid_argument& e){
Logger::getInstance().log("参数格式错误");
return;
}catch(const std::out_of_range& e){
Logger::getInstance().log("参数格式错误");
return;
}

}
private:
TaskManager& taskManager;
};

class ListCommand: public Command {
public:
ListCommand(TaskManager& manager):taskManager(manager){}
void execute(const std::string& args){
try{
int sortOption = 0;
if(!args.empty()){
sortOption = std::stoi(args);
}

std::cout << "当前任务列表:"<< std::endl;
taskManager.listTasks(sortOption);
}catch (const std::invalid_argument& e){
Logger::getInstance().log("参数格式错误");
return;
}catch (const std::out_of_range& e){
Logger::getInstance().log("参数格式错误");
return;
}

}
private:
TaskManager& taskManager;
};


class UpdateCommand: public Command {
public:
UpdateCommand(TaskManager& manager):taskManager(manager){}
void execute(const std::string& args){
try{
size_t pos1 = args.find(',');
size_t pos2 = args.find(',',pos1+1);
size_t pos3 = args.find(',',pos2+1);
if(pos1 == std::string::npos || pos2 == std::string::npos || pos3 == std::string::npos){
std::cout << "参数格式错误。请使用: update <ID>,<描述>,<优先级>,<截止日期>" << std::endl;
return;
}
int id = std::stoi(args.substr(0,pos1));
std::string description = args.substr(pos1+1, pos2-pos1-1);
int priority = std::stoi(args.substr(pos2+1, pos3-pos2-1));
std::string dueDate = args.substr(pos3+1);
taskManager.updateTask(id, description, priority, dueDate);
std::cout << "任务更新成功."<< std::endl;
}catch (const std::invalid_argument& e){
Logger::getInstance().log("参数格式错误");
return;
}catch (const std::out_of_range& e){
Logger::getInstance().log("参数格式错误");
return;
}
}
private:
TaskManager& taskManager;
};

#endif //COMMAND_COMMANDIMPL_H

TaskManager和之前一样,无需改动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//
// Created by secon on 2025/3/14.
//

#ifndef COMMAND_TASKMANAGER_H
#define COMMAND_TASKMANAGER_H


#include "Task.h"
#include <vector>
#include <iostream>
#include <string>
#include <algorithm>
#include <fstream>
#include <sstream>

class TaskManager {
public:
TaskManager();
void addTask(const std::string& description, int priority, const std::string& date);
void deleteTask(int id);
void updateTask(int id, const std::string& description, int priority, const std::string& date);
void listTasks(int sortOption) const ; // 0: 按ID, 1:按优先级升序,2:按日期升序
void loadTasks();
void saveTasks() const;
private:
std::vector<Task> tasks;
int nextId;
static bool compareByPriority(const Task& a, const Task& b);
static bool compareByDueDate(const Task& a, const Task& b);
};



#endif //COMMAND_TASKMANAGER_H

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
//
// Created by secon on 2025/3/14.
//

#include "TaskManager.h"

#include "TaskManager.h"
#include "Logger.h"
#include <iostream>

TaskManager::TaskManager():nextId(1) {
loadTasks();// 始化任务管理器,例如设置默认的线程池大小等。
}

void TaskManager::addTask(const std::string &description, int priority, const std::string &dueDate) {
Task task;
task.id = nextId++;
task.description = description;
task.priority = priority;
task.dueDate = dueDate;
tasks.push_back(task);
Logger::getInstance().log("Task added: " + task.toString());
saveTasks();// 保存任务到文件。
}

void TaskManager::deleteTask(int id){
auto it = std::find_if(tasks.begin(), tasks.end(), [id](const Task &task){
return task.id == id;
});
if(it != tasks.end()){
tasks.erase(it);
Logger::getInstance().log("Task deleted: " + it->toString());
saveTasks();// 保存任务到文件。
}else{
std::cout << "Task not found." << std::endl;
}
}

void TaskManager::updateTask(int id, const std::string &description, int priority, const std::string &date) {
auto it = std::find_if(tasks.begin(), tasks.end(), [id](const Task &task){
return task.id == id;
});
if(it != tasks.end()){
it->description = description;
it->priority = priority;
it->dueDate = date;
Logger::getInstance().log("Task updated: " + it->toString());
saveTasks();// 保存任务到文件。
}else{
std::cout << "Task not found." << std::
endl;
}
}

void TaskManager::saveTasks() const {
std::ofstream outFile("tasks.txt");
if(!outFile.is_open()) {
Logger::getInstance().log( "Failed to open tasks file for writing.");
return;
}

for(const auto &task : tasks){
outFile << task.id << "," << task.description << "," << task.priority << "," << task.dueDate << std::endl;
}

outFile.close();
Logger::getInstance().log("Tasks saved successfully.");
}

void TaskManager::listTasks(int sortOption) const {
std::vector<Task> sortedTasks = tasks;
switch (sortOption) {
case 1:
std::sort(sortedTasks.begin(), sortedTasks.end(), compareByPriority);
break;
case 2:
std::sort(sortedTasks.begin(), sortedTasks.end(), compareByDueDate);
break;
default:
// 不排序,直接输出原始顺序。
break;
}

for(const auto &task : sortedTasks){
std::cout << task.toString() << std::endl;
}
}

void TaskManager::loadTasks() {
std::ifstream inFile("tasks.txt");
if(!inFile.is_open()) {
Logger::getInstance().log( "Failed to open tasks file.");
return;
}

std::string line;
while(std::getline(inFile,line)){
std::istringstream iss(line);
Task task;
char delimiter;
iss >> task.id >> delimiter;
std::getline(iss, task.description,',');
iss >> task.priority >> delimiter;
iss >> task.dueDate;
tasks.push_back(task);
if(task.id >= nextId){
nextId = task.id + 1;
}
}

inFile.close();
Logger::getInstance().log("Tasks loaded successfully.");
}

bool TaskManager::compareByPriority(const Task &a, const Task &b) {
return a.priority < b.priority;
}

bool TaskManager::compareByDueDate(const Task &a, const Task &b) {
return a.dueDate < b.dueDate;
}

Task实现也没有改变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifndef UNTITLED_TASK_H
#define UNTITLED_TASK_H
#include <string>
#include <sstream>
#include <iomanip>

class Task {
public:
int id;
std::string description;
int priority;
std::string dueDate;

std::string toString() const{
std::ostringstream oss;
oss << "ID: " << id
<<", 描述: " << description
<<", 优先级: " << priority
<<", 截止日期: " << dueDate;

return oss.str();
}
};

Command库编写

我们重写Command库的CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cmake_minimum_required(VERSION 3.28)
project(Command)
set(CMAKE_CXX_STANDARD 17)
add_library(Command SHARED Command.cpp
Command.h
CommandImpl.cpp
CommandImpl.h
TaskManager.cpp
TaskManager.h
Task.cpp
Task.h)

# 定义导出宏
target_compile_definitions(Command PRIVATE COMMAND_BUILDING_DLL)
# 添加 Logger 的包含目录
target_include_directories(Command PRIVATE
${CMAKE_CURRENT_SOURCE_DIR} # 包含当前目录的头文件(如CommandExport.h)
../Logger # 包含Logger的头文件
)


# 指定Logger的库文件路径并链接
target_link_directories(Command PRIVATE ../Logger) # 假设Logger.lib和Logger.dll在此目录
target_link_libraries(Command PRIVATE Logger) # 链接Logger库

主函数调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include "Command.h"
#include "Logger.h"
#include "unordered_map"

int main() {
system("chcp 65001 > nul");
std::unordered_map<std::string, std::shared_ptr<Command>> commands;
commands.emplace("add", GetCommand("add"));
commands.emplace("delete", GetCommand("delete"));
commands.emplace("list", GetCommand("list"));
commands.emplace("update", GetCommand("update"));

std::cout << "欢迎使用任务管理系统!" << std::endl;
std::cout << "可用命令: add, delete, list, update, exit" << std::endl;

std::string input;
while (true) {
std::cout << "\n> ";
std::getline(std::cin, input);
if (input.empty()) continue;

// 分离命令和参数
size_t spacePos = input.find(' ');
std::string cmd = input.substr(0, spacePos);
std::string args;
if (spacePos != std::string::npos) {
args = input.substr(spacePos + 1);
}

if (cmd == "exit") {
std::cout << "退出程序。" << std::endl;
break;
}

auto it = commands.find(cmd);
if (it != commands.end()) {
it->second->execute(args);
} else {
std::cout << "未知命令:" << cmd << std::endl;
}
}
return 0;
}

CMakeLists.txt编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cmake_minimum_required(VERSION 3.28)
project(untitled)

set(CMAKE_CXX_STANDARD 17)

add_executable(untitled main.cpp
)

# 添加 Logger 的包含目录
target_include_directories(untitled PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/Logger
${CMAKE_CURRENT_SOURCE_DIR}/Command
)


# 指定Logger的库文件路径并链接
target_link_directories(untitled PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/Logger
${CMAKE_CURRENT_SOURCE_DIR}/Command
)

target_link_libraries(untitled PRIVATE
Logger
Command
) # 链接Logger库

完结撒花

从2024年8月开始了第一个零基础C++视频的创作,历时七个多月完成了这一系列的创作,很多粉丝留言跟着我的节奏学会了C++基础用法,也能自己动手开发一些功能了,这让我很开心,比我自己学会了一套知识带来的快感还多。这套教程涵盖了C++98到C++20的内容,旨在帮助更多迷茫中找不到学习道路的人。

这套教程做完了,是结束也是新的开始,我会回归到聊天项目第二季的开发中,我觉得分享最好的状态就是陪伴,所以大家不用担心我更新完基础就离开。很多人问我为什么做免费教程,知识付费的时代做付费教程已成为大趋势,但我和别人不同的一点是我是真的喜欢技术的那类人,也喜欢把自己的认知分享出来,哪怕以后被时代淘汰,找不到合适的开发工作,甚至去做了其他行业,我也会在业余时间学习和分享编程技术,编程已经成为我生活中不可分割的一个乐趣。在分享中与大家交流,让我不再孤单,很多人素未相识,但是通过共同的兴趣走到一起,没有任何利益关系,这是一种君子之交淡如水的快乐,这种快乐和解忧杂货店那本书有些共鸣。

也祝福大家能学会C++并且找到合适的工作。再次感谢大家一路陪伴。 ————————-恋恋风辰 写于2025年3月初春

零基础C++(35) 结课项目二(任务管理系统)

Posted on 2025-03-03 | In 零基础C++

项目概述:任务管理系统(To-Do List Manager)

项目简介

开发一个命令行界面的任务管理系统,允许用户添加、删除、查看和管理他们的待办事项。该系统将涵盖以下功能:

  • 添加任务:用户可以添加新的待办任务,包含任务描述、优先级和截止日期。
  • 删除任务:用户可以根据任务ID删除指定的任务。
  • 查看任务:用户可以查看所有任务,按优先级或截止日期排序。
  • 更新任务:用户可以更新任务的详细信息。
  • 数据持久化:任务数据将保存在本地文件中,以便在程序重启后仍能保留数据。
  • 日志记录:系统将记录用户的操作日志,使用单例模式实现日志系统。
  • 扩展性:采用CRTP模式实现基础命令接口,方便未来扩展更多命令。

项目结构

  1. 主程序(main.cpp):处理用户输入和命令调用。
  2. 任务管理模块(Task.h, Task.cpp, TaskManager.h, TaskManager.cpp):负责任务的创建、删除、更新和查询。
  3. 日志系统(Logger.h, Logger.cpp):使用单例模式实现日志记录。
  4. 命令接口(Command.h, Command.cpp):采用CRTP模式实现命令的基础接口和具体命令类。
  5. 数据持久化(DataStore.h, DataStore.cpp):负责将任务数据保存到文件和从文件加载数据。

设计思路

1. 单例模式:日志系统

日志系统是典型的单例模式应用场景,确保整个程序中只有一个日志实例,便于集中管理日志记录。

2. CRTP模式:命令接口

采用CRTP模式实现命令的基础接口,使得每个具体命令类在编译时期就确定了具体的行为,避免了运行时多态的开销,并提升了性能。

3. 面向对象设计

通过类的封装,将任务管理和数据持久化模块分离,增强代码的可维护性和可扩展性。

CRTP vs 虚函数继承:区别与优劣分析

CRTP(奇异递归模板模式)和虚函数继承是C++中实现多态的两种不同方式,它们各有优缺点。

基本区别

  1. 多态实现机制:

    • CRTP:静态多态(编译时确定)
    • 虚函数:动态多态(运行时确定)
  2. 示例对比:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // CRTP方式(代码中的实现)
    template <typename Derived>
    class Command {
    public:
    void execute(const std::string& args) {
    static_cast<Derived*>(this)->executeImpl(args);
    }
    };

    // 虚函数方式
    class Command {
    public:
    virtual void execute(const std::string& args) = 0;
    virtual ~Command() {}
    };

CRTP的优势

  1. 性能优化:
    • 无虚函数调用开销(无需查虚函数表)
    • 支持内联,允许更多编译器优化
    • 零运行时开销的抽象
  2. 编译时检查:
    • 类型安全,编译期捕获错误
    • 方法不存在时会产生编译错误
  3. 适用场景:
    • 高性能计算
    • 模板库设计
    • 嵌入式系统等资源受限环境

CRTP的劣势

  1. 代码膨胀:每个派生类都会实例化一套基类模板代码
  2. 编译时间:增加编译时间和编译复杂度
  3. 调试困难:模板错误提示往往难以理解
  4. 接口不明确:缺少明确的接口约束机制
  5. 不支持动态加载:无法支持运行时动态库加载等场景

虚函数继承的优势

  1. 运行时灵活性:
    • 支持运行时确定的多态行为
    • 可以处理编译时未知的类型
  2. 接口清晰:
    • 明确定义接口,强制派生类实现
    • 符合大多数开发者的OOP直觉
  3. 支持动态加载:可与动态链接库配合使用
  4. 适用场景:
    • 插件系统
    • 框架设计
    • 需要运行时类型确定的场景

虚函数继承的劣势

  1. 性能开销:
    • 虚函数调用的间接寻址开销
    • 每个对象额外的vptr(虚表指针)开销
  2. 内联限制:虚函数通常不能被内联
  3. 内存开销:虚函数表消耗额外内存

本项目选择CRTP

代码使用CRTP模式实现了命令模式,适合于:

  • 性能敏感的应用
  • 命令类型在编译期已确定的系统
  • 希望减少运行时开销的场景

如果您需要在运行时动态加载新命令或通过配置文件确定命令类型,则虚函数实现会更合适。

选择建议

根据您的具体需求选择:

  • 需要极致性能且类型在编译时已知:选CRTP
  • 需要运行时灵活性和动态行为:选虚函数继承

源码实现

以下是项目的详细源码,包括各个模块的实现。

1. Logger(日志系统)

Logger.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#ifndef LOGGER_H
#define LOGGER_H

#include <string>
#include <fstream>
#include <mutex>

class Logger {
public:
// 获取单例实例
static Logger& getInstance();

// 禁止拷贝和赋值
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;

// 记录日志
void log(const std::string& message);

private:
Logger(); // 私有构造函数
~Logger();

std::ofstream logFile;
std::mutex mtx;
};

#endif // LOGGER_H

Logger.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "Logger.h"
#include <iostream>
#include <chrono>
#include <ctime>

Logger& Logger::getInstance() {
static Logger instance;
return instance;
}

Logger::Logger() {
logFile.open("log.txt", std::ios::app);
if (!logFile.is_open()) {
std::cerr << "无法打开日志文件。" << std::endl;
}
}

Logger::~Logger() {
if (logFile.is_open()) {
logFile.close();
}
}

void Logger::log(const std::string& message) {
std::lock_guard<std::mutex> lock(mtx);
if (logFile.is_open()) {
// 获取当前时间
auto now = std::chrono::system_clock::now();
std::time_t now_time = std::chrono::system_clock::to_time_t(now);
logFile << std::ctime(&now_time) << ": " << message << std::endl;
}
}

2. Task(任务模块)

Task.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef TASK_H
#define TASK_H

#include <string>
#include <sstream>
#include <iomanip>

struct Task {
int id;
std::string description;
int priority; // 1-高, 2-中, 3-低
std::string dueDate; // 格式: YYYY-MM-DD

std::string toString() const {
std::ostringstream oss;
oss << "ID: " << id
<< ", 描述: " << description
<< ", 优先级: " << priority
<< ", 截止日期: " << dueDate;
return oss.str();
}
};

#endif // TASK_H

TaskManager.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#ifndef TASKMANAGER_H
#define TASKMANAGER_H

#include "Task.h"
#include <vector>
#include <string>
#include <fstream>
#include <algorithm>
#include <sstream>

class TaskManager {
public:
TaskManager();

void addTask(const std::string& description, int priority, const std::string& dueDate);
void deleteTask(int id);
void updateTask(int id, const std::string& description, int priority, const std::string& dueDate);
void listTasks(int sortOption = 0) const; // 0-按ID, 1-按优先级, 2-按截止日期
void loadTasks();
void saveTasks() const;

private:
std::vector<Task> tasks;
int nextId;

// 辅助排序函数
static bool compareByPriority(const Task& a, const Task& b);
static bool compareByDueDate(const Task& a, const Task& b);
};

#endif // TASKMANAGER_H

TaskManager.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include "TaskManager.h"
#include "Logger.h"
#include <iostream>

TaskManager::TaskManager() : nextId(1) {
loadTasks();
}

void TaskManager::addTask(const std::string& description, int priority, const std::string& dueDate) {
Task task;
task.id = nextId++;
task.description = description;
task.priority = priority;
task.dueDate = dueDate;
tasks.push_back(task);
Logger::getInstance().log("添加任务: " + task.toString());
saveTasks();
}

void TaskManager::deleteTask(int id) {
auto it = std::find_if(tasks.begin(), tasks.end(), [id](const Task& task) {
return task.id == id;
});
if (it != tasks.end()) {
Logger::getInstance().log("删除任务: " + it->toString());
tasks.erase(it);
saveTasks();
} else {
std::cout << "未找到ID为 " << id << " 的任务。" << std::endl;
}
}

void TaskManager::updateTask(int id, const std::string& description, int priority, const std::string& dueDate) {
for (auto& task : tasks) {
if (task.id == id) {
Logger::getInstance().log("更新前任务: " + task.toString());
task.description = description;
task.priority = priority;
task.dueDate = dueDate;
Logger::getInstance().log("更新后任务: " + task.toString());
saveTasks();
return;
}
}
std::cout << "未找到ID为 " << id << " 的任务。" << std::endl;
}

void TaskManager::listTasks(int sortOption) const {
std::vector<Task> sortedTasks = tasks;
switch (sortOption) {
case 1:
std::sort(sortedTasks.begin(), sortedTasks.end(), compareByPriority);
break;
case 2:
std::sort(sortedTasks.begin(), sortedTasks.end(), compareByDueDate);
break;
default:
break;
}
for (const auto& task : sortedTasks) {
std::cout << task.toString() << std::endl;
}
}

void TaskManager::loadTasks() {
std::ifstream inFile("tasks.txt");
if (!inFile.is_open()) {
Logger::getInstance().log("任务文件不存在,开始新建。");
return;
}
std::string line;
while (std::getline(inFile, line)) {
std::istringstream iss(line);
Task task;
char delimiter;
iss >> task.id >> delimiter;
std::getline(iss, task.description, ',');
iss >> task.priority >> delimiter;
iss >> task.dueDate;
tasks.push_back(task);
if (task.id >= nextId) {
nextId = task.id + 1;
}
}
inFile.close();
Logger::getInstance().log("加载任务成功。");
}

void TaskManager::saveTasks() const {
std::ofstream outFile("tasks.txt");
if (!outFile.is_open()) {
Logger::getInstance().log("无法打开任务文件进行保存。");
return;
}
for (const auto& task : tasks) {
outFile << task.id << "," << task.description << "," << task.priority << "," << task.dueDate << "\n";
}
outFile.close();
Logger::getInstance().log("保存任务成功。");
}

bool TaskManager::compareByPriority(const Task& a, const Task& b) {
return a.priority < b.priority;
}

bool TaskManager::compareByDueDate(const Task& a, const Task& b) {
return a.dueDate < b.dueDate;
}

3. Command(命令接口)

Command.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#ifndef COMMAND_H
#define COMMAND_H

#include <string>

class CommandBase{
public:
virtual void execute(const std::string& args) = 0;
};

// CRTP 基类模板
template <typename Derived>
class Command :public CommandBase{
public:
void execute(const std::string& args) {
static_cast<Derived*>(this)->executeImpl(args);
}
};

// 具体命令类示例
#include "TaskManager.h"
#include "Logger.h"
#include <iostream>

// 添加任务命令
class AddCommand : public Command<AddCommand> {
public:
AddCommand(TaskManager& manager) : taskManager(manager) {}
void executeImpl(const std::string& args) {
// 简单的参数解析:描述,优先级,截止日期
size_t pos1 = args.find(',');
size_t pos2 = args.find(',', pos1 + 1);
if (pos1 == std::string::npos || pos2 == std::string::npos) {
std::cout << "参数格式错误。请使用: add <描述>,<优先级>,<截止日期>" << std::endl;
return;
}
std::string description = args.substr(0, pos1);
int priority = std::stoi(args.substr(pos1 + 1, pos2 - pos1 - 1));
std::string dueDate = args.substr(pos2 + 1);
taskManager.addTask(description, priority, dueDate);
std::cout << "任务添加成功。" << std::endl;
}
private:
TaskManager& taskManager;
};

// 删除任务命令
class DeleteCommand : public Command<DeleteCommand> {
public:
DeleteCommand(TaskManager& manager) : taskManager(manager) {}
void executeImpl(const std::string& args) {
try{
size_t pos;
int id = std::stoi(args, &pos);
if(pos != args.length()){
std::cout << "参数格式错误。请使用: delete <ID>" << std::endl;
return;
}
taskManager.deleteTask(id);
std::cout << "任务删除成功。" << std::endl;
}catch(const std::invalid_argument& e){
std::cout << "参数格式错误。请使用: delete <ID>" << std::endl;
return;
}catch(const std::out_of_range& e){
std::cout << "ID超出范围。请使用有效的任务ID。" << std::endl;
return;
}

}
private:
TaskManager& taskManager;
};

// 列出任务命令
class ListCommand : public Command<ListCommand> {
public:
ListCommand(TaskManager& manager) : taskManager(manager) {}
void executeImpl(const std::string& args) {
int sortOption = 0;
if (!args.empty()) {
sortOption = std::stoi(args);
}
taskManager.listTasks(sortOption);
}
private:
TaskManager& taskManager;
};

// 更新任务命令
class UpdateCommand : public Command<UpdateCommand> {
public:
UpdateCommand(TaskManager& manager) : taskManager(manager) {}
void executeImpl(const std::string& args) {
// 参数格式: ID,描述,优先级,截止日期
size_t pos1 = args.find(',');
size_t pos2 = args.find(',', pos1 + 1);
size_t pos3 = args.find(',', pos2 + 1);
if (pos1 == std::string::npos || pos2 == std::string::npos || pos3 == std::string::npos) {
std::cout << "参数格式错误。请使用: update <ID>,<描述>,<优先级>,<截止日期>" << std::endl;
return;
}
int id = std::stoi(args.substr(0, pos1));
std::string description = args.substr(pos1 + 1, pos2 - pos1 - 1);
int priority = std::stoi(args.substr(pos2 + 1, pos3 - pos2 - 1));
std::string dueDate = args.substr(pos3 + 1);
taskManager.updateTask(id, description, priority, dueDate);
std::cout << "任务更新成功。" << std::endl;
}
private:
TaskManager& taskManager;
};

#endif // COMMAND_H

4. 主程序

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <string>
#include <unordered_map>
#include <memory>
#include "TaskManager.h"
#include "Command.h"

int main() {
TaskManager taskManager;

// 创建命令对象
AddCommand addCmd(taskManager);
DeleteCommand delCmd(taskManager);
ListCommand listCmd(taskManager);
UpdateCommand updateCmd(taskManager);

// 命令映射
std::unordered_map<std::string, std::unique_ptr<CommandBase>> commands;
commands["add"] = std::make_unique<AddCommand>(taskManager);
commands["delete"] = std::make_unique<DeleteCommand>(taskManager);
commands["list"] = std::make_unique<ListCommand>(taskManager);
commands["update"] = std::make_unique<UpdateCommand>(taskManager);

std::cout << "欢迎使用任务管理系统!" << std::endl;
std::cout << "可用命令: add, delete, list, update, exit" << std::endl;

std::string input;
while (true) {
std::cout << "\n> ";
std::getline(std::cin, input);
if (input.empty()) continue;

// 分离命令和参数
size_t spacePos = input.find(' ');
std::string cmd = input.substr(0, spacePos);
std::string args;
if (spacePos != std::string::npos) {
args = input.substr(spacePos + 1);
}

if (cmd == "exit") {
std::cout << "退出程序。" << std::endl;
break;
}

auto it = commands.find(cmd);
if (it != commands.end()) {
it->second->execute(args);
} else {
std::cout << "未知命令:" << cmd << std::endl;
}
}

return 0;
}

5. 编译和运行

确保你将所有的源文件(Logger.h, Logger.cpp, Task.h, Task.cpp, TaskManager.h, TaskManager.cpp, Command.h, main.cpp)放在同一个目录下。然后使用以下命令编译和运行:

1
2
3
4
5
# 使用 g++ 编译
g++ -std=c++11 main.cpp Logger.cpp TaskManager.cpp -o TaskManager

# 运行
./TaskManager

6. 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
欢迎使用任务管理系统!
可用命令: add, delete, list, update, exit

> add 完成项目报告,1,2023-12-31
任务添加成功。

> add 购买日用品,2,2023-10-15
任务添加成功。

> list
ID: 1, 描述: 完成项目报告, 优先级: 1, 截止日期: 2023-12-31
ID: 2, 描述: 购买日用品, 优先级: 2, 截止日期: 2023-10-15

> update 2,购买更多日用品,1,2023-10-20
任务更新成功。

> list 1
ID: 1, 描述: 完成项目报告, 优先级: 1, 截止日期: 2023-12-31
ID: 2, 描述: 购买更多日用品, 优先级: 1, 截止日期: 2023-10-20

> delete 1
任务删除成功。

> list
ID: 2, 描述: 购买更多日用品, 优先级: 1, 截止日期: 2023-10-20

> exit
退出程序。

在CRTP模式下存储多种命令类型的解决方案

当使用CRTP模式时,存储不同类型的命令确实是个挑战,因为AddCommand、DeleteCommand等各自继承自不同的模板实例,没有共同的基类。以下是几种解决方案:

1. 使用非模板的公共基类

创建一个普通基类,然后让CRTP模板类继承自它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 普通基类
class CommandBase {
public:
virtual void execute(const std::string& args) = 0;
virtual ~CommandBase() = default;
};

// 修改CRTP模板类
template <typename Derived>
class Command : public CommandBase {
public:
void execute(const std::string& args) override {
static_cast<Derived*>(this)->executeImpl(args);
}
};

// 使用方式
std::unordered_map<std::string, std::unique_ptr<CommandBase>> commands;
commands["add"] = std::make_unique<AddCommand>(taskManager);
commands["delete"] = std::make_unique<DeleteCommand>(taskManager);
// 调用
commands["add"]->execute("任务描述,1,2023-12-31");

2. 使用类型擦除技术(std::function)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//方式二、 类型擦除
auto add_command = std::make_shared<AddCommand>(taskManager);
auto delete_command = std::make_shared<DeleteCommand>(taskManager);
auto list_command = std::make_shared<ListCommand>(taskManager);
auto update_command = std::make_shared<UpdateCommand>(taskManager);
std::unordered_map<std::string, std::function<void(const std::string&)> > commands;
//放入map
commands["add"] = [add_command](const std::string& args)
{ add_command->execute(args); };
commands["delete"] = [delete_command](const std::string& args)
{ delete_command->execute(args); };
commands["list"] = [list_command](const std::string& args)
{ list_command->execute(args); };
commands["update"] = [update_command](const std::string& args)
{ update_command->execute(args); };

//调用方式
auto it = commands.find("add");
if (it != commands.end()) {
it->second("喂狗,1,2025-03-12");
} else {
std::cout << "未知命令:" << cmd << std::endl;
}

3. 使用std::any或std::variant (C++17)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 使用std::any
std::unordered_map<std::string, std::any> commands;
commands["add"] = AddCommand(taskManager);
commands["delete"] = DeleteCommand(taskManager);

// 调用(需要知道具体类型)
std::any_cast<AddCommand&>(commands["add"]).execute("任务描述,1,2023-12-31");

// 或使用std::variant
using CommandVariant = std::variant<AddCommand, DeleteCommand, ListCommand, UpdateCommand>;
std::unordered_map<std::string, CommandVariant> commands;
commands["add"] = AddCommand(taskManager);

// 调用
std::visit([&args](auto&& cmd) { cmd.execute(args); }, commands["add"]);
  1. 定义CommandVariant类型

    1
    using CommandVariant = std::variant<AddCommand, DeleteCommand, ListCommand, UpdateCommand>;
    • std::variant: 是C++17引入的一个类型安全的联合体,能够持有预定义类型中的一种。这意味着CommandVariant可以保存AddCommand、DeleteCommand、ListCommand或UpdateCommand中的任意一个实例。
    • 用途: 用于需要在运行时存储和管理多种不同类型对象的场景,同时保持类型安全。
  2. 创建命令映射表

    1
    std::unordered_map<std::string, CommandVariant> commands;
    • std::unordered_map<std::string, CommandVariant>: 创建一个哈希表,键是字符串(如”add”),值是CommandVariant类型。
    • 用途: 通过字符串键来索引和管理不同的命令对象,这使得根据用户输入或其他条件动态调用不同的命令变得容易。
  3. 添加命令到映射表

    1
    commands["add"] = AddCommand(taskManager);
    • AddCommand(taskManager): 创建一个AddCommand对象,假设taskManager是其构造函数所需的参数。
    • 赋值给commands["add"]: 将AddCommand实例存储在命令映射表中,键为”add”。
  4. 调用命令

    1
    std::visit([&args](auto&& cmd) { cmd.execute(args); }, commands["add"]);
    • std::visit: 是一个用于访问std::variant中当前存储的值的函数。它接受一个访问者(通常是一个函数或lambda)和一个std::variant。

    • Lambda表达式

      :

      1
      [&args](auto&& cmd) { cmd.execute(args); }
      • 捕获列表: [&args] 表示按引用捕获args,用于在lambda内部使用。
      • 参数: auto&& cmd 是一个通用引用,表示std::variant中存储的当前活跃类型(如AddCommand)。
      • 操作: 调用当前命令对象的execute方法,并传入args作为参数。
    • commands["add"]: 这是std::variant实例,当前存储的是AddCommand对象。

    作用: std::visit会自动检测commands["add"]中当前存储的具体类型(在这个例子中是AddCommand),然后调用lambda表达式,进而执行AddCommand的execute方法。

为什么使用std::variant和std::visit?

  1. 类型安全: 与传统的基类指针相比,std::variant在编译时就知道所有可能的类型,减少了运行时错误的风险。
  2. 性能: std::variant通常比基类多态更高效,因为它避免了虚函数调用的开销。
  3. 简洁性: 使用std::variant和std::visit可以避免复杂的类型检查和转换逻辑,使代码更简洁和易于维护。

4. 创建命令包装器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class CommandWrapper {
struct Concept {
virtual void execute(const std::string& args) = 0;
virtual ~Concept() = default;
};

template<typename T>
struct Model : Concept {
T command;
Model(T cmd) : command(std::move(cmd)) {}
void execute(const std::string& args) override {
command.execute(args);
}
};

std::unique_ptr<Concept> impl;

public:
template<typename T>
CommandWrapper(T cmd) : impl(std::make_unique<Model<T>>(std::move(cmd))) {}

void execute(const std::string& args) {
impl->execute(args);
}
};

// 使用方式
std::unordered_map<std::string, CommandWrapper> commands;
commands.emplace("add", AddCommand(taskManager));
commands.emplace("delete", DeleteCommand(taskManager));

// 调用
commands["add"].execute("任务描述,1,2023-12-31");

推荐方案

方案1(非模板基类)和方案4(命令包装器)是最佳选择:

  • 方案1简单直接,只需少量修改现有代码
  • 方案4保持CRTP的性能优势,并提供类型安全的接口

如果性能是最优先考虑的,建议使用方案4,因为它避免了虚函数调用的开销。否则,方案1是最简单实用的解决方案。

功能扩展建议

  1. 数据持久化增强:目前的数据存储较为简单,可以考虑使用JSON格式存储任务,使用第三方库(如nlohmann/json)进行序列化和反序列化。
  2. 用户认证:添加用户登录功能,每个用户有独立的任务列表。
  3. 图形界面:使用图形库(如Qt或SFML)为任务管理系统添加图形用户界面。
  4. 高级搜索和过滤:支持根据关键字、日期范围、优先级等多条件搜索和过滤任务。
  5. 任务分类:为任务添加类别标签,便于分类管理。

代码优化与改进

  • 命令注册改进:当前命令注册比较硬编码,可以考虑使用工厂模式或宏来自动注册命令,增强灵活性。
  • 错误处理:增加更多的错误检查和处理机制,提升程序的健壮性。
  • 多线程支持:如果程序规模扩大,可以考虑将日志记录等耗时操作放到独立线程中,避免阻塞主线程。
<1234…41>

401 posts
18 categories
21 tags
RSS
GitHub ZhiHu
© 2026 恋恋风辰 本站总访问量次 | 本站访客数人
Powered by Hexo
|
Theme — NexT.Muse v5.1.3