目录
- 1. 将protobuf引入项目当中
- 2. 前后端交互接口定义
- 2.1 核心PB类
- 2.2 HTTP接口定义
- 2.3 websocket接口定义
- 3. 核心数据结构和PB之间的转换
- 4. 设计数据中心DataCenter类
- 5. 网络通信
- 5.1 定义NetClient类
- 5.2 引入HTTP
- 5.3 引入websocket
- 6. 小结
- 7. 搭建测试服务器
- 7.1 创建项目
- 7.2 服务器引入http
- 7.3 服务器引入websocket
- 7.4 服务器引protobuf
- 7.5 编写工具函数和构造数据函数
- 7.6 验证网络连通性
- 7.7 网络通信注意事项
- 8. 主界面逻辑的实现
- 8.1 获取个人信息
- 8.2 获取好友列表
- 8.3 获取会话列表
- 8.4 获取好友申请列表
- 8.5 获取指定会话的近期消息
- 8.6 点击某个好友项
- 9. 小结
1. 将protobuf引入项目当中
(1)创建 proto 目录, 并把服务器提供的 proto 拷贝过来:
(2)proto文件链接:https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client/ChatClient/proto
2. 前后端交互接口定义
2.1 核心PB类
(1)用户信息:
//用户信息结构
message UserInfo {string user_id = 1;//用户IDstring nickname = 2;//昵称string description = 3;//个人签名/描述string phone = 4; //绑定手机号bytes avatar = 5;//头像照片,文件内容使用二进制
}
(2)会话信息:
//聊天会话信息
message ChatSessionInfo {optional string single_chat_friend_id = 1;//群聊会话不需要设置,单聊会话设置为对方IDstring chat_session_id = 2; //会话IDstring chat_session_name = 3;//会话名称git optional MessageInfo prev_message = 4;//会话上一条消息,新建的会话没有最新消息optional bytes avatar = 5;//会话头像 --群聊会话不需要,直接由前端固定渲染,单聊就是对方的头像
}
(3)消息信息:
//消息类型
enum MessageType {STRING = 0;IMAGE = 1;FILE = 2;SPEECH = 3;
}
message StringMessageInfo {string content = 1;//文字聊天内容
}
message ImageMessageInfo {optional string file_id = 1;//图片文件id,客户端发送的时候不用设置,由transmit服务器进行设置后交给storage的时候设置optional bytes image_content = 2;//图片数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候需要原样转发
}
message FileMessageInfo {optional string file_id = 1;//文件id,客户端发送的时候不用设置int64 file_size = 2;//文件大小string file_name = 3;//文件名称optional bytes file_contents = 4;//文件数据,在ES中存储消息的时候只要id和元信息,不要文件数据, 服务端转发的时候也不需要填充
}
message SpeechMessageInfo {optional string file_id = 1;//语音文件id,客户端发送的时候不用设置optional bytes file_contents = 2;//文件数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候也不需要填充
}
message MessageContent {MessageType message_type = 1; //消息类型oneof msg_content {StringMessageInfo string_message = 2;//文字消息FileMessageInfo file_message = 3;//文件消息SpeechMessageInfo speech_message = 4;//语音消息ImageMessageInfo image_message = 5;//图片消息};
}
//消息结构
message MessageInfo {string message_id = 1;//消息IDstring chat_session_id = 2;//消息所属聊天会话IDint64 timestamp = 3;//消息产生时间UserInfo sender = 4;//消息发送者信息MessageContent message = 5;
}message Message {string request_id = 1;MessageInfo message = 2;
}message FileDownloadData {string file_id = 1;bytes file_content = 2;
}message FileUploadData {string file_name = 1;int64 file_size = 2;bytes file_content = 3;
}
2.2 HTTP接口定义
(1)请求响应基本格式:
//通信接口统一采用POST请求实现,正文采用protobuf协议进行组织
/* HTTP HEADER:POST /service/xxxxxContent-Type: application/x-protobufContent-Length: 123xxxxxx-------------------------------------------------------HTTP/1.1 200 OK Content-Type: application/x-protobufContent-Length: 123xxxxxxxxxx
*/
(2)约定路径:每个接口都提供对应的请求响应的 proto 对象:
//在客户端与网关服务器的通信中,使用HTTP协议进行通信
// 通信时采用POST请求作为请求方法
// 通信时,正文采用protobuf作为正文协议格式,具体内容字段以前边各个文件中定义的字段格式为准
/* 以下是HTTP请求的功能与接口路径对应关系:SERVICE HTTP PATH:{获取随机验证码 /service/user/get_random_verify_code获取短信验证码 /service/user/get_phone_verify_code用户名密码注册 /service/user/username_register用户名密码登录 /service/user/username_login手机号码注册 /service/user/phone_register手机号码登录 /service/user/phone_login获取个人信息 /service/user/get_user_info修改头像 /service/user/set_avatar修改昵称 /service/user/set_nickname修改签名 /service/user/set_description修改绑定手机 /service/user/set_phone获取好友列表 /service/friend/get_friend_list获取好友信息 /service/friend/get_friend_info发送好友申请 /service/friend/add_friend_apply好友申请处理 /service/friend/add_friend_process删除好友 /service/friend/remove_friend搜索用户 /service/friend/search_friend获取指定用户的消息会话列表 /service/friend/get_chat_session_list创建消息会话 /service/friend/create_chat_session获取消息会话成员列表 /service/friend/get_chat_session_member获取待处理好友申请事件列表 /service/friend/get_pending_friend_events获取历史消息/离线消息列表 /service/message_storage/get_history获取最近N条消息列表 /service/message_storage/get_recent搜索历史消息 /service/message_storage/search_history发送消息 /service/message_transmit/new_message获取单个文件数据 /service/file/get_single_file获取多个文件数据 /service/file/get_multi_file发送单个文件 /service/file/put_single_file发送多个文件 /service/file/put_multi_file语音转文字 /service/speech/recognition}*/
2.3 websocket接口定义
(1)身份认证:
/*消息推送使用websocket长连接进行websocket长连接转换请求:ws://host:ip/ws长连建立以后,需要客户端给服务器发送一个身份验证信息
*/
message ClientAuthenticationReq {string request_id = 1;string session_id = 2;
}
message ClientAuthenticationRsp {string request_id = 1;bool success = 2;string errmsg = 3;
}
(2)消息推送。当前存在五种消息推送:
- 申请好友通知。
- 好友申请处理通知 (同意/拒绝)。
- 创建消息会话通知。
- 收到消息通知。
- 删除好友通知。
enum NotifyType {FRIEND_ADD_APPLY_NOTIFY = 0;FRIEND_ADD_PROCESS_NOTIFY = 1;CHAT_SESSION_CREATE_NOTIFY = 2;CHAT_MESSAGE_NOTIFY = 3;FRIEND_REMOVE_NOTIFY = 4;
}message NotifyFriendAddApply {UserInfo user_info = 1; //申请人信息
}
message NotifyFriendAddProcess {bool agree = 1;UserInfo user_info = 2; //处理人信息
}
message NotifyFriendRemove {string user_id = 1; //删除自己的用户ID
}
message NotifyNewChatSession {ChatSessionInfo chat_session_info = 1; //新建会话信息
}
message NotifyNewMessage {MessageInfo message_info = 1; //新消息
}message NotifyMessage {optional string notify_event_id = 1;//通知事件操作id(有则填无则忽略)NotifyType notify_type = 2;//通知事件类型oneof notify_remarks { //事件备注信息NotifyFriendAddApply friend_add_apply = 3;NotifyFriendAddProcess friend_process_result = 4;NotifyFriendRemove friend_remove = 7;NotifyNewChatSession new_chat_session_info = 5;//会话信息NotifyNewMessage new_message_info = 6;//消息信息}
}
3. 核心数据结构和PB之间的转换
(1)以下是protobuf数据和QString的数据转化函数:(类里面的成员变量没有写出来):
//
/// 用户信息
//
class UserInfo
{
public:// 该类的成员变量没有写出来。。。// 从 protobuffer 的 UserInfo 对象, 转成当前代码的 UserInfo 对象void load(const bite_im::UserInfo& userInfo){this->userId = userInfo.userId();this->nickname = userInfo.nickname();this->description = userInfo.description();this->phone = userInfo.phone();if(userInfo.avatar().isEmpty()){// 使用默认头像即可this->avatar = QIcon(":/resource/image/defaultAvatar.png");}else{this->avatar = makeIcon(userInfo.avatar());}}
};//
/// 消息信息
//
enum MessageType
{TEXT_TYPE, // 文本消息IMAGE_TYPE, // 图片消息FILE_TYPE, // 文件消息SPEECH_TYPE // 语音消息
};class Message
{
public:// 该类的成员变量没有写出来。。。// 此处 extraInfo 目前只是在消息类型为文件消息时, 作为 "文件名" 补充.static Message makeMessage(MessageType messageType, const QString& chatSessionId,const UserInfo& sender, const QByteArray& content,const QString& extraInfo){if(messageType == TEXT_TYPE){return makeTextMessage(chatSessionId, sender, content);}else if(messageType == IMAGE_TYPE){return makeImageMessage(chatSessionId, sender, content);}else if(messageType == FILE_TYPE){return makeFileMessage(chatSessionId, sender, content, extraInfo);}else if(messageType == SPEECH_TYPE){return makeSpeechMessage(chatSessionId, sender, content);}else{// 触发了未知的消息类型return Message();}}void load(const bite_im::MessageInfo& messageInfo){this->messageId = messageInfo.messageId();this->chatSessionId = messageInfo.chatSessionId();this->time = formatTime(messageInfo.timestamp());this->sender.load(messageInfo.sender());// 设置消息类型auto type = messageInfo.message().messageType();if(type == bite_im::MessageTypeGadget::MessageType::STRING){this->messageType = TEXT_TYPE;this->content = messageInfo.message().stringMessage().content().toUtf8();}else if(type == bite_im::MessageTypeGadget::MessageType::IMAGE){this->messageType = IMAGE_TYPE;if(messageInfo.message().imageMessage().hasImageContent()){this->content = messageInfo.message().imageMessage().imageContent();}if(messageInfo.message().imageMessage().hasFileId()){this->fileId = messageInfo.message().imageMessage().fileId();}}else if(type == bite_im::MessageTypeGadget::MessageType::FILE){this->messageType = FILE_TYPE;if(messageInfo.message().fileMessage().hasFileContents()){this->content = messageInfo.message().fileMessage().fileContents();}if(messageInfo.message().fileMessage().hasFileId()){this->fileId = messageInfo.message().fileMessage().fileId();}this->fileName = messageInfo.message().fileMessage().fileName();}else if(type == bite_im::MessageTypeGadget::MessageType::SPEECH){this->messageType = SPEECH_TYPE;if(messageInfo.message().speechMessage().hasFileContents()){this->content = messageInfo.message().speechMessage().fileContents();}if(messageInfo.message().speechMessage().hasFileId()){this->fileId = messageInfo.message().speechMessage().fileId();}}else{// 错误的类型, 啥都不做了, 只是打印一个日志LOG() << "非法的消息类型! type=" << type;}}private:// 通过这个方法生成唯一的 messageIdstatic QString makeId(){return "M" + QUuid::createUuid().toString().sliced(25, 12);}static Message makeTextMessage(const QString& chatSessionId,const UserInfo& sender, const QByteArray& content){Message message;message.messageId = makeId();message.chatSessionId = chatSessionId;message.messageType = TEXT_TYPE;message.content = content;message.sender = sender;message.time = formatTime(getTime()); // 生成一个格式化时间// 对于文本消息来说, 这俩属性不使用, 设为 ""message.fileId = "";message.fileName = "";return message;}static Message makeImageMessage(const QString& chatSessionId,const UserInfo& sender, const QByteArray& content){Message message;message.messageId = makeId();message.chatSessionId = chatSessionId;message.messageType = IMAGE_TYPE;message.content = content;message.sender = sender;message.time = formatTime(getTime()); // 生成一个格式化时间// fileId 后续使用的时候再进一步设置message.fileId = "";// fileName 不使用, 直接设为 ""message.fileName = "";return message;}static Message makeFileMessage(const QString& chatSessionId, const UserInfo& sender,const QByteArray& content, const QString& fileName){Message message;message.messageId = makeId();message.chatSessionId = chatSessionId;message.messageType = FILE_TYPE;message.content = content;message.sender = sender;message.time = formatTime(getTime()); // 生成一个格式化时间// fileId 后续使用的时候进一步设置message.fileId = "";message.fileName = fileName;return message;}static Message makeSpeechMessage(const QString& chatSessionId,const UserInfo& sender, const QByteArray& content){Message message;message.messageId = makeId();message.chatSessionId = chatSessionId;message.messageType = SPEECH_TYPE;message.content = content;message.sender = sender;message.time = formatTime(getTime()); // 生成一个格式化时间// fileId 后续使用的时候进一步设置message.fileId = "";// fileName 不使用, 直接设为 ""message.fileName = "";return message;}
};//
/// 会话信息
//
class ChatSessionInfo
{
public:// 该类的成员变量没有写出来。。。void load(const bite_im::ChatSessionInfo& chatSessionInfo){this->chatSessionId = chatSessionInfo.chatSessionId();this->chatSessionName = chatSessionInfo.chatSessionName();if(chatSessionInfo.hasSingleChatFriendId()){this->userId = chatSessionInfo.singleChatFriendId();}if(chatSessionInfo.hasPrevMessage()){lastMessage.load(chatSessionInfo.prevMessage());}if(chatSessionInfo.hasAvatar() && !chatSessionInfo.avatar().isEmpty()){// 已经有头像了, 直接设置这个头像this->avatar = makeIcon(chatSessionInfo.avatar());}else{// 如果没有头像, 则根据当前会话是单聊还是群聊, 使用不同的默认头像.if(userId != ""){// 单聊this->avatar = QIcon(":/resource/image/defaultAvatar.png");}else{// 群聊this->avatar = QIcon(":/resource/image/groupAvatar.png");}}}
};
4. 设计数据中心DataCenter类
(1)在model文件夹当中创建datacenter.h的头文件,并且在该头文件当中创建DataCenter类来管理所有客户端需要的数据。这是一个单例类:
class DataCenter : public QObject
{Q_OBJECT
public:static DataCenter* getInstance();~DataCenter();private:DataCenter();static DataCenter* instance;// 列出 DataCenter 中要组织管理的所有的数据// 当前客户端登录到服务器对应的登录会话 idQString loginSessionId = "";// 当前的用户信息model::UserInfo* myself = nullptr;// 好友列表QList<model::UserInfo>* friendList = nullptr;// 会话列表QList<model::ChatSessionInfo>* chatSessionList = nullptr;// 记录当前选中的会话是哪个~~QString currentChatSessionId = "";// 记录每个会话中, 都有哪些成员(主要针对群聊). key 为 chatSessionId, value 为成员列表QHash<QString, QList<model::UserInfo>>* memberList = nullptr;// 待处理的好友申请列表QList<model::UserInfo>* applyList = nullptr;// 每个会话的最近消息列表, key 为 chatSessionId, value 为消息列表QHash<QString, QList<model::Message>>* recentMessages = nullptr;// 存储每个会话, 未读消息的个数. key 为 chatSessionId, value 为未读消息的个数.QHash<QString, int>* unreadMessageCount = nullptr;// 用户的好友搜索结果.QList<model::UserInfo>* searchUserResult = nullptr;// 历史消息搜索结果.QList<model::Message>* searchMessageResult = nullptr;// 短信验证码的验证 idQString currentVerifyCodeId = "";// 让 DataCenter 持有 NetClient 实例.network::NetClient netClient;public:// 初始化数据文件void initDataFile();// 存储数据到文件中void saveDataFile();// 从数据文件中加载数据到内存void loadDataFile();signals:
};
(2)具体实现:
DataCenter* DataCenter::instance = nullptr;DataCenter* DataCenter::getInstance()
{if(instance == nullptr){instance = new DataCenter();}return instance;
}DataCenter::DataCenter():netClient(this)
{// 此处只是把这几个 hash 类型的属性 new 出实例. 其他的 QList 类型的属性, 都暂时不实例化.// 主要是为了使用 nullptr 表示 "非法状态"// 对于 hash 来说, 不关心整个 QHash 是否是 nullptr, 而是关心, 某个 key 对应的 value 是否存在~~// 通过 key 是否存在, 也能表示该值是否有效.recentMessages = new QHash<QString, QList<Message>>();memberList = new QHash<QString, QList<UserInfo>>();unreadMessageCount = new QHash<QString, int>();
}DataCenter::~DataCenter()
{// 释放所有的成员// 此处不必判定 nullptr, 直接 delete 即可!// C++ 标准中明确规定, 针对 nullptr 进行 delete, 是合法行为, 不会有任何副作用.delete myself;delete friendList;delete chatSessionList;delete memberList;delete applyList;delete recentMessages;delete unreadMessageCount;delete searchUserResult;delete searchMessageResult;
}
NetClient 的实现后续完成。
(3)数据持久化:使用文件存储 sessionId 和 未读消息信息:
void DataCenter::initDataFile()
{// 构造出文件的路径, 使用 appData 存储文件QString basePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);QString filePath = basePath + "/ChatClient.json";LOG() << "filePath=" << filePath;QDir dir;if(!dir.exists(basePath)){dir.mkpath(basePath);}// 构造好文件路径之后, 把文件创建出来.// 写方式打开, 并且写入初始内容QFile file(filePath);if(!file.open(QIODevice::WriteOnly | QIODevice::Text)){LOG() << "打开文件失败!" << file.errorString();return;}// 打开成功, 写入初始内容.QString data = "{\n\n}";file.write(data.toUtf8());file.close();
}void DataCenter::saveDataFile()
{QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";QFile file(filePath);if(!file.open(QIODevice::WriteOnly | QIODevice::Text)){LOG() << "打开文件失败!" << file.errorString();return;}// 按照 json 格式来写入数据.// 这个对象就可以当做 map 一样来使用.QJsonObject jsonObj;jsonObj["loginSessionId"] = loginSessionId;QJsonObject jsonUnread;for(auto it = unreadMessageCount->begin(); it != unreadMessageCount->end(); ++it){// 注意 Qt 的迭代器使用细节和 STL 略有差别. 此处不是使用 first / second 的方式jsonUnread[it.key()] = it.value();}jsonObj["unread"] = jsonUnread;// 把 json 写入文件了QJsonDocument jsonDoc(jsonObj);QString s = jsonDoc.toJson();file.write(s.toUtf8());// 关闭文件file.close();
}void DataCenter::loadDataFile()
{// 确保在加载之前, 先针对文件进行初始化操作.QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";// 判定文件是否存在, 不存在则初始化, 并创建出新的空白的 json 文件QFileInfo fileInfo(filePath);if(!fileInfo.exists()){initDataFile();}QFile file(filePath);if(!file.open(QIODevice::ReadOnly | QIODevice::Text)){LOG() << "打开文件失败!" << file.errorString();return;}// 读取到文件内容, 解析为 JSON 对象QJsonDocument jsonDoc = QJsonDocument::fromJson(file.readAll());if(jsonDoc.isNull()){LOG() << "解析 JSON 文件失败! JSON 文件格式有错误!";file.close();return;}QJsonObject jsonObj = jsonDoc.object();this->loginSessionId = jsonObj["loginSessionId"].toString();this->unreadMessageCount->clear();QJsonObject jsonUnread = jsonObj["unread"].toObject();for(auto it = jsonUnread.begin(); it != jsonUnread.end(); ++it){this->unreadMessageCount->insert(it.key(), it.value().toInt());}file.close();
}void DataCenter::clearUnread(const QString& chatSessionId)
{(*unreadMessageCount)[chatSessionId] = 0;// 手动保存一下结果到文件中.saveDataFile();
}
未读消息的实现放到后面完成。
5. 网络通信
5.1 定义NetClient类
(1)创建network文件夹,在创建netclient.h头文件,在此头文件创建 NetClient 类来管理所有的和服务器通信的内容。NetClient 内部又分成 httpClient 和 websocketClient 两个部分。DataCenter 中会持有 NetClient 的指针。
class NetClient : public QObject
{Q_OBJECTprivate:// 定义重要常量. ip 都暂时使用本地的环回 ip. 端口号约定成 8000 和 8001const QString HTTP_URL = "http://127.0.0.1:8000";const QString WEBSOCKET_URL = "ws://127.0.0.1:8001/ws";public:NetClient(model::DataCenter* dataCenter);// 生成请求 idstatic QString makeRequestId();// 封装发送请求的逻辑QNetworkReply* sendHttpRequest(const QString& apiPath, const QByteArray& body);private:model::DataCenter* dataCenter;QNetworkAccessManager httpClient; // http 客户端QWebSocket websocketClient; // websocket 客户端QProtobufSerializer serializer; // 序列化器signals:
};
5.2 引入HTTP
(1)进行网络测试:
void NetClient::ping()
{QNetworkRequest httpReq;httpReq.setUrl(QUrl(HTTP_URL + "/ping"));QNetworkReply* httpResp = httpClient.get(httpReq);connect(httpResp, &QNetworkReply::finished, this, [=](){// 这里面, 说明响应已经回来了.if(httpResp->error() != QNetworkReply::NoError){// 请求失败!LOG() << "HTTP 请求失败! " << httpResp->errorString();httpResp->deleteLater();return;}// 获取到响应的 bodyQByteArray body = httpResp->readAll();LOG() << "响应内容: " << body;httpResp->deleteLater();});
}
(2)封装构造 HTTP 请求和处理响应以及请求id:
QString NetClient::makeRequestId()
{// 基本要求, 确保每个请求的 id 都是不重复(唯一的)// 通过 UUID 来实现上述效果.return "R" + QUuid::createUuid().toString().sliced(25, 12);
}// 通过这个函数, 把发送 HTTP 请求操作封装一下.
// apiPath 应该要以 / 开头
QNetworkReply* NetClient::sendHttpRequest(const QString &apiPath, const QByteArray &body)
{QNetworkRequest httpReq;httpReq.setUrl(QUrl(HTTP_URL + apiPath));httpReq.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-protobuf");QNetworkReply* httpResp = httpClient.post(httpReq, body);return httpResp;
}// 封装处理响应的逻辑(包括判定 HTTP 正确性, 反序列化, 判定业务上的正确性)
// 由于不同的 api, 返回的 pb 对象结构, 不同, 为了让一个函数能处理多种不同类型, 需要使用 模板.
// 通过输出型参数, 表示这次操作是成功还是失败, 以及失败的原因.
template <typename T>
std::shared_ptr<T> handleHttpResponse(QNetworkReply* httpResp, bool* ok, QString* reason)
{// 1. 判定 HTTP 层面上, 是否出错if(httpResp->error() != QNetworkReply::NoError){*ok = false;*reason = httpResp->errorString();httpResp->deleteLater();return std::shared_ptr<T>();}// 2. 获取到响应的 bodyQByteArray respBody = httpResp->readAll();// 3. 针对 body 反序列化std::shared_ptr<T> respObj = std::make_shared<T>();respObj->deserialize(&serializer, respBody);// 4. 判定业务上的结果是否正确if(!respObj->success()){*ok = false;*reason = respObj->errmsg();httpResp->deleteLater();return std::shared_ptr<T>();}// 5. 释放 httpResp 对象httpResp->deleteLater();*ok = true;return respObj;
}
5.3 引入websocket
(1)Websocket 在主窗口加载后,才和服务器建立连接,并且在建立连接后给服务器发送⼀个 认证请求之后, 才能收到后续数据。初始化 websocket:
void NetClient::initWebsocket()
{// 1. 准备好所有需要的信号槽connect(&websocketClient, &QWebSocket::connected, this, [=](){LOG() << "websocket 连接成功!";// 不要忘记! 在 websocket 连接成功之后, 发送身份认证消息!sendAuth();});connect(&websocketClient, &QWebSocket::disconnected, this, [=](){LOG() << "websocket 连接断开!";});connect(&websocketClient, &QWebSocket::errorOccurred, this, [=](QAbstractSocket::SocketError error){LOG() << "websocket 连接出错!" << error;});connect(&websocketClient, &QWebSocket::textMessageReceived, this, [=](const QString& message){LOG() << "websocket 收到文本消息!" << message;});connect(&websocketClient, &QWebSocket::binaryMessageReceived, this, [=](const QByteArray& byteArray){LOG() << "websocket 收到二进制消息!" << byteArray.length();bite_im::NotifyMessage notifyMessage;notifyMessage.deserialize(&serializer, byteArray);handleWsResponse(notifyMessage);});// 2. 和服务器真正建立连接websocketClient.open(WEBSOCKET_URL);
}
(2)初始化身份信息:
void NetClient::sendAuth()
{bite_im::ClientAuthenticationReq req;req.setRequestId(makeRequestId());req.setSessionId(dataCenter->getLoginSessionId());QByteArray body = req.serialize(&serializer);websocketClient.sendBinaryMessage(body);LOG() << "[WS身份认证] requestId=" << req.requestId() << ", loginSessionId=" << req.sessionId();
}
(3)搭建 websocket 消息推送的逻辑:
void NetClient::handleWsResponse(const bite_im::NotifyMessage& notifyMessage)
{if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::CHAT_MESSAGE_NOTIFY){// 收到消息// 1. 把 pb 中的 MessageInfo 转成客户端自己的 Messagemodel::Message message;message.load(notifyMessage.newMessageInfo().messageInfo());// 2. 针对自己的 message 做进一步的处理handleWsMessage(message);}else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::CHAT_SESSION_CREATE_NOTIFY){// 创建新的会话通知model::ChatSessionInfo chatSessionInfo;chatSessionInfo.load(notifyMessage.newChatSessionInfo().chatSessionInfo());handleWsSessionCreate(chatSessionInfo);}else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_ADD_APPLY_NOTIFY){// 添加好友申请通知model::UserInfo userInfo;userInfo.load(notifyMessage.friendAddApply().userInfo());handleWsAddFriendApply(userInfo);}else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_ADD_PROCESS_NOTIFY){// 添加好友申请的处理结果通知model::UserInfo userInfo;userInfo.load(notifyMessage.friendProcessResult().userInfo());bool agree = notifyMessage.friendProcessResult().agree();handleWsAddFriendProcess(userInfo, agree);}else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_REMOVE_NOTIFY){// 删除好友通知const QString& userId = notifyMessage.friendRemove().userId();handleWsRemoveFriend(userId);}
}
(4)针对上述每种消息的处理实现,后续再进⼀步完成。
6. 小结
(1)三个层次关系:
NetClient从网络拿到数据,只交给DataCenter通过网络收到的数据,DataCenter负责发送信号给 MainWidget,从而异步通知界面更新。
7. 搭建测试服务器
7.1 创建项目
(1)基于 CMake 创建 Qt 项目。虽然使用控制台项目也可以(创建成 Qt Core Application), 但是使用图形界面更合适⼀些。尤其是后面构造⼀些测试数据,图形界面更方便进行操作。比如在界面上提供不同的按钮,按下不同按钮就可以给客户端推送不同的数据:
cmake_minimum_required(VERSION 3.16)find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets HttpServer WebSockets Protobuf)file(GLOB PB_FILES "../ChatClient/proto/*.proto")qt_add_protobuf(ChatServerMock PROTO_FILES ${PB_FILES})target_link_libraries(ChatServerMock PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt6::HttpServer Qt6::WebSockets Qt6::Protobuf)
7.2 服务器引入http
(1)创建HttpServer类来实现此功能:
class HttpServer : public QObject
{Q_OBJECTpublic:static HttpServer* getInstance();// 通过这个函数, 针对 HTTP Server 进行初始化 (绑定端口, 配置路由....)bool init();private:static HttpServer* instance;HttpServer() {}QHttpServer httpServer;QProtobufSerializer serializer;signals:
};
(2)具体实现:
HttpServer* HttpServer::instance = nullptr;HttpServer* HttpServer::getInstance()
{if(instance == nullptr){instance = new HttpServer();}return instance;
}bool HttpServer::init()
{// 返回的值是 int, 表示成功绑定的端口号的数值.int ret = httpServer.listen(QHostAddress::Any, 8000);// 配置路由httpServer.route("/ping", [](const QHttpServerRequest& req){(void) req;qDebug() << "[http] 收到 ping 请求";return "pong";});return ret == 8000;
}
7.3 服务器引入websocket
(1)创建WebsocketServer类来实现此功能:
class WebsocketServer : public QObject
{Q_OBJECTprivate:static WebsocketServer* instance;WebsocketServer() : websocketServer("websocket server", QWebSocketServer::NonSecureMode) {}QWebSocketServer websocketServer;QProtobufSerializer serializer;public:static WebsocketServer* getInstance();bool init();int messageIndex = 0;signals:
};
(2)具体实现:
WebsocketServer* WebsocketServer::instance = nullptr;WebsocketServer *WebsocketServer::getInstance()
{if (instance == nullptr){instance = new WebsocketServer();}return instance;
}// 针对 websocket 服务器进行初始化操作
bool WebsocketServer::init()
{// 1. 连接信号槽connect(&websocketServer, &QWebSocketServer::newConnection, this, [=](){// 连接建立成功之后.qDebug() << "[websocket] 连接建立成功!";// 获取到用来通信的 socket 对象. nextPendingConnection 类似于 原生 socket 中的 acceptQWebSocket* socket = websocketServer.nextPendingConnection();// 针对这个 socket 对象, 进行剩余信号的处理connect(socket, &QWebSocket::disconnected, this, [=](){qDebug() << "[websocket] 连接断开!";});connect(socket, &QWebSocket::errorOccurred, this, [=](QAbstractSocket::SocketError error){qDebug() << "[websocket] 连接出错! " << error;});connect(socket, &QWebSocket::textMessageReceived, this, [=](const QString& message){qDebug() << "[websocket] 收到文本数据! message=" << message;});connect(socket, &QWebSocket::binaryMessageReceived, this, [=](const QByteArray& byteArray){qDebug() << "[websocket] 收到二进制数据! " << byteArray.length();});});// 2. 绑定端口, 启动服务bool ok = websocketServer.listen(QHostAddress::Any, 8001);return ok;
}
7.4 服务器引protobuf
(1)cmake增加内容文件:
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets HttpServer WebSockets Protobuf)
file(GLOB PB_FILES "../ChatClient/proto/*.proto")
直接从ChatClient项目中引入proto文件。
(2)如果出现下列报错:
- 则给 target_link_libraries 引入 PRIVATE。从
target_link_libraries(ChatServerMock Qt${QT_VERSION_MAJOR}::Core Qt6::Network Qt6::WebSockets)
- 修改为:
target_link_libraries(ChatServerMock PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt6::Network Qt6::WebSockets)
7.5 编写工具函数和构造数据函数
(1)工具函数:
// 读写文件操作.
// 从指定文件中, 读取所有的二进制内容. 得到一个 QByteArray
static inline QByteArray loadFileToByteArray(const QString& path) {QFile file(path);bool ok = file.open(QFile::ReadOnly);if (!ok) {LOG() << "文件打开失败!";return QByteArray();}QByteArray content = file.readAll();file.close();return content;
}// 把 QByteArray 中的内容, 写入到某个指定文件里
static inline void writeByteArrayToFile(const QString& path, const QByteArray& content) {QFile file(path);bool ok = file.open(QFile::WriteOnly);if (!ok) {LOG() << "文件打开失败!";return;}file.write(content);file.flush();file.close();
}
(2)构造数据函数:
// 生成默认的 UserInfo 对象
bite_im::UserInfo makeUserInfo(int index, const QByteArray& avatar)
{bite_im::UserInfo userInfo;userInfo.setUserId(QString::number(1000 + index));userInfo.setNickname("张三" + QString::number(index));userInfo.setDescription("个性签名" + QString::number(index));userInfo.setPhone("18612345678");userInfo.setAvatar(avatar);return userInfo;
}bite_im::MessageInfo makeTextMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{bite_im::MessageInfo messageInfo;messageInfo.setMessageId(QString::number(3000 + index));messageInfo.setChatSessionId(chatSessionId);messageInfo.setTimestamp(getTime());messageInfo.setSender(makeUserInfo(index, avatar));bite_im::StringMessageInfo stringMessageInfo;stringMessageInfo.setContent("这是一条消息内容" + QString::number(index));bite_im::MessageContent messageContent;messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::STRING);messageContent.setStringMessage(stringMessageInfo);messageInfo.setMessage(messageContent);return messageInfo;
}bite_im::MessageInfo makeImageMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{bite_im::MessageInfo messageInfo;messageInfo.setMessageId(QString::number(3000 + index));messageInfo.setChatSessionId(chatSessionId);messageInfo.setTimestamp(getTime());messageInfo.setSender(makeUserInfo(index, avatar));bite_im::ImageMessageInfo imageMessageInfo;imageMessageInfo.setFileId("testImage");// 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.// 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.// imageMessageInfo.setImageContent();bite_im::MessageContent messageContent;messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::IMAGE);messageContent.setImageMessage(imageMessageInfo);messageInfo.setMessage(messageContent);return messageInfo;
}bite_im::MessageInfo makeFileMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{bite_im::MessageInfo messageInfo;messageInfo.setMessageId(QString::number(3000 + index));messageInfo.setChatSessionId(chatSessionId);messageInfo.setTimestamp(getTime());messageInfo.setSender(makeUserInfo(index, avatar));bite_im::FileMessageInfo fileMessageInfo;fileMessageInfo.setFileId("testFile");// 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.// 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.fileMessageInfo.setFileName("test.txt");// 此处文件大小, 无法设置. 由于 fileSize 属性, 不是 optional , 此处先设置一个 0 进来fileMessageInfo.setFileSize(0);bite_im::MessageContent messageContent;messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::FILE);messageContent.setFileMessage(fileMessageInfo);messageInfo.setMessage(messageContent);return messageInfo;
}bite_im::MessageInfo makeSpeechMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{bite_im::MessageInfo messageInfo;messageInfo.setMessageId(QString::number(3000 + index));messageInfo.setChatSessionId(chatSessionId);messageInfo.setTimestamp(getTime());messageInfo.setSender(makeUserInfo(index, avatar));bite_im::SpeechMessageInfo speechMessageInfo;// 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.// 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.speechMessageInfo.setFileId("testSpeech");bite_im::MessageContent messageContent;messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::SPEECH);messageContent.setSpeechMessage(speechMessageInfo);messageInfo.setMessage(messageContent);return messageInfo;
}
7.6 验证网络连通性
(1)修改客户端的 main.cpp , 添加网络测试代码:
// 测试⽹络联通
#if TEST_NETWORKnetwork::NetClient netClient(nullptr);netClient.ping();
#endif
运行客户端, 连接测试服务器,并验证是否 HTTP / Websocket网络能连通。
7.7 网络通信注意事项
- 不能使用两个 Qt Creator 分别启动服务器和客户端。后启动的程序 qDebug 会失效。提示:“无法获取调试输出”。
- websocket 客户端代码要编写完整,再连接服务器。否则会直接崩溃,而没有任何具体提示。
- ⼀定要确保 websocket 的 connected 信号触发之后,才能 sendTextMessage。否则不会有任何提示,但是消息发送不成功。Qt 这⼀套信号槽,用起来和 Node.js 非常相似的。时刻注意 “异步” 的问题。
- 每次更新完 PB,⼀定要记得服务器和客户端都需要重新编译运行!!否则程序会出现不可预期的错误。
8. 主界面逻辑的实现
8.1 获取个人信息
(1)客户端发送请求:
- 在MainWidget::initSignalSlot函数当中添加获取信息的信号除力getMyselfDone槽函数:
connect(dataCenter, &DataCenter::getMyselfDone, this, [=]()
{// 从 DataCenter 中拿到响应结果的 myself, 把里面的头像取出来, 显示到界面上.const auto* myself = dataCenter->getMyself();this->userAvatar->setIcon(myself->avatar);
});dataCenter->getMyselfAsync();
- 编写 DataCenter::getMyselfAsync函数:
void DataCenter::getMyselfAsync()
{netClient.getMyself(loginSessionId);
}
- 编写NetClient::getMyself函数以及接口定义:
//个⼈信息获取-这个只⽤于获取当前登录⽤⼾的信息
// 客⼾端传递的时候只需要填充session_id即可
//其他个⼈/好友信息的获取在好友操作中完成
message GetUserInfoReq {string request_id = 1;optional string user_id = 2;optional string session_id = 3;
}message GetUserInfoRsp {string request_id = 1;bool success = 2;string errmsg = 3; UserInfo user_info = 4;
}// 具体实现:
void NetClient::getMyself(const QString& loginSessionId)
{// 1. 构造出 HTTP 请求 body 部分bite_im::GetUserInfoReq req;req.setRequestId(makeRequestId());req.setSessionId(loginSessionId);QByteArray body = req.serialize(&serializer);LOG() << "[获取个人信息] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;// 2. 构造出 HTTP 请求, 并发送出去.QNetworkReply* httpResp = sendHttpRequest("/service/user/get_user_info", body);// 3. 通过信号槽, 获取到当前的响应. finished 信号表示响应已经返回到客户端了.connect(httpResp, &QNetworkReply::finished, this, [=](){// a) 先处理响应对象bool ok = false;QString reason;auto resp = handleHttpResponse<bite_im::GetUserInfoRsp>(httpResp, &ok, &reason);// b) 判定响应是否正确if (!ok){LOG() << "[获取个人信息] 出错! requestId=" << req.requestId() << "reason=" << reason;return;}// c) 把结果保存在 DataCenter 中dataCenter->resetMyself(resp);// d) 通知调用逻辑, 响应已经处理完了. 仍然通过信号槽, 通知.emit dataCenter->getMyselfDone();// e) 打印日志.LOG() << "[获取个人信息] 处理响应 requestId=" << req.requestId();});
}
(2)客户端处理响应:
- 实现 DataCenter::resetMyself函数:
void DataCenter::resetMyself(std::shared_ptr<bite_im::GetUserInfoRsp> resp)
{if(myself == nullptr){myself = new UserInfo();}const bite_im::UserInfo userInfo = resp->userInfo();myself->load(userInfo);
}
- 定义DataCenter信号:
signals:// 获取个⼈信息完成void getMyselfDone();
(3)服务器处理请求:
- 编写 HttpServer::init 注册路由:
httpServer.route("/service/user/get_user_info", [=](const QHttpServerRequest& req)
{return this->getUserInfo(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getUserInfo(const QHttpServerRequest& req)
{// 解析请求, 把 req 的 body 取出来, 并且通过 pb 进行反序列化bite_im::GetUserInfoReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 获取用户信息] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();// 构造响应数据bite_im::GetUserInfoRsp pbResp;pbResp.setRequestId(pbReq.requestId());pbResp.setSuccess(true);pbResp.setErrmsg("");bite_im::UserInfo userInfo;userInfo.setUserId("1029"); // 调整自己的用户 id, 和返回的消息列表的内容匹配上userInfo.setNickname("张三");userInfo.setDescription("这是个性签名");userInfo.setPhone("18612345678");userInfo.setAvatar(loadFileToByteArray(":/resource/image/groupAvatar.png"));pbResp.setUserInfo(userInfo);QByteArray body = pbResp.serialize(&serializer);// 构造 HTTP 响应数据QHttpServerResponse httpResp(body, QHttpServerResponse::StatusCode::Ok);httpResp.setHeader("Content-Type", "application/x-protobuf");return httpResp;
}
(4)整体流程小结:
8.2 获取好友列表
(1)客户端发送请求:
- 在MainWidget::initSignalSlot添加槽函数:
/// 获取好友列表
loadFriendList();
- 具体实现loadFriendList函数:
// 加载好友列表
void MainWidget::loadFriendList()
{// 好友列表数据是在 DataCenter 中存储的// 首先需要判定 DataCenter 中是否已经有数据了. 如果有数据, 直接加载本地的数据.// 如果没有数据, 从服务器获取DataCenter* dataCenter = DataCenter::getInstance();if(dataCenter->getFriendList() != nullptr){// 从内存这个列表中加载数据updateFriendList();}else{// 通过网络来加载数据connect(dataCenter, &DataCenter::getFriendListDone, this, &MainWidget::updateFriendList, Qt::UniqueConnection);dataCenter->getFriendListAsync();}
}
-
注意:
- loadFriendList 不仅仅会在初始化时调用,也会在后续切换标签页时调用。
- 多次 connect 虽然不会报错,但是会导致槽函数被⼀个信号触发多次。
- 可以在 connect 的时候使用 Qt::UniqueConnection 参数(第五个参数),避免触发多次的情况。
-
实现 DataCenter 中的 getFriendList和getFriendListAsync函数:
QList<UserInfo>* DataCenter::getFriendList()
{return friendList;
}
void DataCenter::getFriendListAsync()
{netClient.getFriendList(loginSessionId);
}
- 实现 NetClient::getFriendList函数:
// 接⼝定义
//--------------------------------------
//好友列表获取
message GetFriendListReq {string request_id = 1;optional string user_id = 2;optional string session_id = 3;
}message GetFriendListRsp {string request_id = 1;bool success = 2;string errmsg = 3; repeated UserInfo friend_list = 4;
}// 代码实现
void NetClient::getFriendList(const QString& loginSessionId)
{// 1. 通过 protobuf 构造 bodybite_im::GetFriendListReq req;req.setRequestId(makeRequestId());req.setSessionId(loginSessionId);QByteArray body = req.serialize(&serializer);LOG() << "[获取好友列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;// 2. 发送 HTTP 请求QNetworkReply* httpResp = this->sendHttpRequest("/service/friend/get_friend_list", body);// 3. 处理响应connect(httpResp, &QNetworkReply::finished, this, [=](){// a) 先处理响应对象bool ok = false;QString reason;auto friendListResp = this->handleHttpResponse<bite_im::GetFriendListRsp>(httpResp, &ok, &reason);// b) 判定响应是否正确if(!ok){LOG() << "[获取好友列表] 失败! requestId=" << req.requestId() << ", reason=" << reason;return;}// c) 把结果保存在 DataCenter 中dataCenter->resetFriendList(friendListResp);// d) 发送信号, 通知界面, 当前这个操作完成了.emit dataCenter->getFriendListDone();// e) 打印日志.LOG() << "[获取好友列表] 处理响应 requestId=" << req.requestId();});
}
(2)客户端处理响应:
- 编写 DataCenter::resetFriendList函数:
void DataCenter::resetFriendList(std::shared_ptr<bite_im::GetFriendListRsp> resp)
{if(friendList == nullptr){friendList = new QList<UserInfo>();}friendList->clear();QList<bite_im::UserInfo>& friendListPB = resp->friendList();for(auto& f : friendListPB){UserInfo userinfo;userinfo.load(f);friendList->push_back(userinfo);}
}
- 定义 DataCenter 信号:
void getFriendListDone();
- 实现 MainWidget::updateFriendList函数:
void MainWidget::updateFriendList()
{if(activeTab != FRIEND_LIST){// 当前的标签页不是好友列表, 就不渲染任何数据到界面上return;}DataCenter* dataCenter = DataCenter::getInstance();QList<UserInfo>* friendList = dataCenter->getFriendList();// 清空一下之前界面上的数据.sessionFriendArea->clear();// 遍历好友列表, 添加到界面上for (const auto& f : *friendList){sessionFriendArea->addItem(FriendItemType, f.userId, f.avatar, f.nickname, f.description);}
}
(3)服务器处理请求:
- 编写 HttpServer::init 注册路由:
httpServer.route("/service/friend/get_friend_list", [=](constQHttpServerRequest& req)
{return this->getFriendList(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getFriendList(const QHttpServerRequest& req)
{// 解析请求, 把 req 的 body 拿出来.bite_im::GetFriendListReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 获取好友列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();// 构造响应bite_im::GetFriendListRsp pbRsp;pbRsp.setRequestId(pbReq.requestId());pbRsp.setSuccess(true);pbRsp.setErrmsg("");// 从文件读取数据操作, 其实是比较耗时的. (读取硬盘)// 耗时操作如果放在循环内部, 就会使整个的响应处理时间, 更长.QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");for(int i = 0; i < 20; i++){bite_im::UserInfo userInfo = makeUserInfo(i, avatar);pbRsp.friendList().push_back(userInfo);}// 进行序列化QByteArray body = pbRsp.serialize(&serializer);// 构造成 HTTP 响应对象QHttpServerResponse httpResp(body, QHttpServerResponse::StatusCode::Ok);httpResp.setHeader("Content-Type", "application/x-protobuf");return httpResp;
}
(4)整体流程小结:
8.3 获取会话列表
(1)客户端发送请求:
- 编写 MainWidget::init槽函数:
/// 获取会话列表
loadSessionList();
- 具体实现loadSessionList()函数:
// 加载会话列表
void MainWidget::loadSessionList()
{// 先判定会话列表数据是否在本地 (DataCenter) 中存在. 如果本地存在, 直接构造界面内容.// 如果本地不存在, 则从服务器获取数据.DataCenter* dataCenter = DataCenter::getInstance();if(dataCenter->getFriendList() != nullptr){// 从内存这个列表中加载数据updateChatSessionList();}else{// 从网络加载数据connect(dataCenter, &DataCenter::getChatSessionListDone, this, &MainWidget::updateChatSessionList, Qt::UniqueConnection);dataCenter->getChatSessionListAsync();}
}
- 编写 DataCenter:
QList<ChatSessionInfo>* DataCenter::getChatSessionList()
{return chatSessionList;
}
void DataCenter::getChatSessionListAsync()
{netClient.getChatSessionList(loginSessionId);
}
- 编写 NetClient以及接口定义:
//--------------------------------------
//会话列表获取
message GetChatSessionListReq {string request_id = 1;optional string session_id = 2;optional string user_id = 3;
}message GetChatSessionListRsp {string request_id = 1;bool success = 2;string errmsg = 3; repeated ChatSessionInfo chat_session_info_list = 4;
}// 函数实现
void NetClient::getChatSessionList(const QString& loginSessionId)
{// 1. 通过 protobuf 构造 bodybite_im::GetChatSessionListReq req;req.setRequestId(makeRequestId());req.setSessionId(loginSessionId);QByteArray body = req.serialize(&serializer);LOG() << "[获取会话列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;// 2. 发送 HTTP 请求QNetworkReply* resp = this->sendHttpRequest("/service/friend/get_chat_session_list", body);// 3. 针对响应进行处理connect(resp, &QNetworkReply::finished, this, [=](){// a) 解析响应bool ok = false;QString reason;auto pbResp = this->handleHttpResponse<bite_im::GetChatSessionListRsp>(resp, &ok, &reason);// b) 判定响应是否正确if (!ok){LOG() << "[获取会话列表] 失败! reason=" << reason;return;}// c) 把得到的数据, 写入到 DataCenter 里dataCenter->resetChatSessionList(pbResp);// d) 通知调用者, 此处响应处理完毕emit dataCenter->getChatSessionListDone();// e) 打印日志LOG() << "[获取会话列表] 处理响应完毕! requestId=" << pbResp->requestId();});
}
(2)客户端处理响应:
- 实现DataCenter::resetChatSessionList函数:
void DataCenter::resetChatSessionList(std::shared_ptr<bite_im::GetChatSessionListRsp> resp)
{if(chatSessionList == nullptr){chatSessionList = new QList<ChatSessionInfo>();}chatSessionList->clear();auto& chatSessionListPB = resp->chatSessionInfoList();for (auto& c : chatSessionListPB){ChatSessionInfo chatSessionInfo;chatSessionInfo.load(c);chatSessionList->push_back(chatSessionInfo);}
}
- 定义 DataCenter 信号:
// 获取会话列表完成
void getChatSessionListDone();
- 实现 MainWidget::updateChatSessionList函数:
void MainWidget::updateChatSessionList()
{if(activeTab != SESSION_LIST){// 当前的标签页不是好友列表, 就不渲染任何数据到界面上return;}DataCenter* dataCenter = DataCenter::getInstance();QList<ChatSessionInfo>* chatSessionList = dataCenter->getChatSessionList();sessionFriendArea->clear();// 遍历好友列表, 添加到界面上for (const auto& c : *chatSessionList){if(c.lastMessage.messageType == TEXT_TYPE){sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, c.lastMessage.content);}else if(c.lastMessage.messageType == IMAGE_TYPE){sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[图片]");}else if(c.lastMessage.messageType == FILE_TYPE){sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[文件]");}else if(c.lastMessage.messageType == SPEECH_TYPE){sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[语音]");}else{LOG() << "错误的消息类型! messageType=" << c.lastMessage.messageType;}}
}
(3)服务器处理请求:
- 编写 HttpServer::init 注册路由
httpServer.route("/service/friend/get_chat_session_list", [=](constQHttpServerRequest& req)
{return this->getChatSessionList(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getChatSessionList(const QHttpServerRequest& req)
{// 解析请求bite_im::GetChatSessionListReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 获取会话列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();// 构造响应bite_im::GetChatSessionListRsp pbRsp;pbRsp.setRequestId(pbReq.requestId());pbRsp.setSuccess(true);pbRsp.setErrmsg("");QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");// 构造若干个单聊会话for (int i = 0; i < 30; ++i){bite_im::ChatSessionInfo chatSessionInfo;chatSessionInfo.setChatSessionId(QString::number(2000 + i));chatSessionInfo.setChatSessionName("会话" + QString::number(i));chatSessionInfo.setSingleChatFriendId(QString::number(1000 + i));chatSessionInfo.setAvatar(avatar);bite_im::MessageInfo messageInfo = makeTextMessageInfo(i, chatSessionInfo.chatSessionId(), avatar);chatSessionInfo.setPrevMessage(messageInfo);pbRsp.chatSessionInfoList().push_back(chatSessionInfo);}// 序列化响应QByteArray body = pbRsp.serialize(&serializer);// 构造 HTTP 响应QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);resp.setHeader("Content-Type", "application/x-protobuf");return resp;
}
(4)整体流程小结:
8.4 获取好友申请列表
(1)客户端发送请求:
- 添加MainWidget::initSignalSlot槽函数:
loadApplyList();
- 具体实现loadApplyList()函数:
// 加载好友申请列表
void MainWidget::loadApplyList()
{// 好友申请列表在 DataCenter 中存储的// 首先判定 DataCenter 本地是否已经有数据了. 如果有, 直接加载到界面上.// 如果没有则需要从服务器获取DataCenter* dataCenter = DataCenter::getInstance();if(dataCenter->getApplyList() != nullptr){// 本地有数据, 直接加载updateApplyList();}else{// 本地没有数据, 通过网络加载connect(dataCenter, &DataCenter::getApplyListDone, this, &MainWidget::updateApplyList, Qt::UniqueConnection);dataCenter->getApplyListAsync();}
}
- 实现 getApplyList 和 getApplyListAsync函数:
QList<UserInfo> *DataCenter::getApplyList()
{return applyList;
}void DataCenter::getApplyListAsync()
{netClient.getApplyList(loginSessionId);
}
- 实现 NetClient::getApplyList和接口定义:
//获取待处理的,申请⾃⼰好友的信息列表
message GetPendingFriendEventListReq {string request_id = 1;optional string session_id = 2;optional string user_id = 3;
}message FriendEvent {string event_id = 1;UserInfo sender = 3;
}message GetPendingFriendEventListRsp {string request_id = 1;bool success = 2;string errmsg = 3; repeated FriendEvent event = 4;
}// 函数实现
void NetClient::getApplyList(const QString& loginSessionId)
{// 1. 通过 protobuf 构造 bodybite_im::GetPendingFriendEventListReq req;req.setRequestId(makeRequestId());req.setSessionId(loginSessionId);QByteArray body = req.serialize(&serializer);LOG() << "[获取好友申请列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;QNetworkReply* resp = sendHttpRequest("/service/friend/get_pending_friend_events", body);// 3. 处理响应connect(resp, &QNetworkReply::finished, this, [=](){// a) 解析响应bool ok = false;QString reason;auto pbResp = this->handleHttpResponse<bite_im::GetPendingFriendEventListRsp>(resp, &ok, &reason);// b) 判定结果是否出错if(!ok){LOG() << "[获取好友申请列表] 失败! reason=" << reason;return;}// c) 拿到的数据, 写入到 DataCenter 中dataCenter->resetApplyList(pbResp);// d) 通知界面, 处理完毕emit dataCenter->getApplyListDone();// e) 打印日志LOG() << "[获取好友申请列表] 处理响应完成! requestId=" << req.requestId();});
}
(2)客户端处理响应:
- 实现 DataCenter::resetApplyList函数:
void DataCenter::resetApplyList(std::shared_ptr<bite_im::GetPendingFriendEventListRsp> resp)
{if(applyList == nullptr){applyList = new QList<UserInfo>();}applyList->clear();auto& eventList = resp->event();for (auto& event : eventList){UserInfo userInfo;userInfo.load(event.sender());applyList->push_back(userInfo);}
}
- 定义 DataCenter 信号:
void getApplyListDone();
- 实现 MainWidget::updateApplyList函数:
void MainWidget::updateFriendList()
{if(activeTab != FRIEND_LIST){// 当前的标签页不是好友列表, 就不渲染任何数据到界面上return;}DataCenter* dataCenter = DataCenter::getInstance();QList<UserInfo>* friendList = dataCenter->getFriendList();// 清空一下之前界面上的数据.sessionFriendArea->clear();// 遍历好友列表, 添加到界面上for (const auto& f : *friendList){sessionFriendArea->addItem(FriendItemType, f.userId, f.avatar, f.nickname, f.description);}
}
(3)服务器逻辑实现:
- 注册路由:
httpServer.route("/service/friend/get_pending_friend_events", [=](constQHttpServerRequest& req)
{return this->getApplyList(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getApplyList(const QHttpServerRequest& req)
{// 解析请求bite_im::GetPendingFriendEventListReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 获取好友申请列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();// 构造响应bite_im::GetPendingFriendEventListRsp pbResp;pbResp.setRequestId(pbReq.requestId());pbResp.setSuccess(true);pbResp.setErrmsg("");// 循环构造出 event 对象, 构造出整个结果数组QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");for (int i = 0; i < 5; ++i){bite_im::FriendEvent friendEvent;friendEvent.setEventId(""); // 此处不再使用这个 eventId, 直接设为 ""friendEvent.setSender(makeUserInfo(i, avatar));pbResp.event().push_back(friendEvent);}// 序列化成字节数组QByteArray body = pbResp.serialize(&serializer);// 构造 HTTP 响应对象QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);resp.setHeader("Content-Type", "application/x-protobuf");return resp;
}
(4)整体流程小结:
8.5 获取指定会话的近期消息
(1)点击会话列表中的列表项,获取该会话的最后 N 个历史消息,并展示到界面上。客户端发送请求:
- 编写 SessionItem::active函数:
- 此处的 active 在 select 中已经通过多态的方式调用到了。只要用户点击,就能触发这个逻辑:
void SessionItem::active()
{// 点击之后, 要加载会话的历史消息列表LOG() << "点击 SessionItem 触发的逻辑! chatSessionId=" << chatSessionId;// 加载会话历史消息, 即会涉及到当前内存的数据操作, 又会涉及到网络通信, 还涉及到界面的变更.MainWidget* mainWidget = MainWidget::getInstance();mainWidget->loadRecentMessage(chatSessionId);// TODO 后续在这⾥添加针对未读消息的处理.
}
- 编写 MainWidget::loadRecentMessages函数:
void MainWidget::loadRecentMessage(const QString& chatSessionId)
{// 也是先判定, 本地内存中是否已经有对应的消息列表数据.// 有的话直接显示到界面上. 没有的话从网络获取.DataCenter* dataCenter = DataCenter::getInstance();if(dataCenter->getRecentMessageList(chatSessionId) != nullptr){// 拿着本地数据更新界面updateRecentMessage(chatSessionId);}else{// 本地没有数据, 从网络加载connect(dataCenter, &DataCenter::getRecentMessageListDone, this, &MainWidget::updateRecentMessage, Qt::UniqueConnection);dataCenter->getRecentMessageListAsync(chatSessionId, true);}
}
- 编写 DataCenter当中的对应函数:
void DataCenter::getRecentMessageListAsync(const QString& chatSessionId, bool updateUI)
{netClient.getRecentMessageList(loginSessionId, chatSessionId, updateUI);
}QList<Message>* DataCenter::getRecentMessageList(const QString& chatSessionId)
{if(!recentMessages->contains(chatSessionId)){return nullptr;}return &(*recentMessages)[chatSessionId];
}
- 编写 NetClient和接口定义:
message GetRecentMsgReq {string request_id = 1;string chat_session_id = 2;int64 msg_count = 3;optional int64 cur_time = 4;//⽤于扩展获取指定时间前的n条消息optional string user_id = 5;optional string session_id = 6;
}
message GetRecentMsgRsp {string request_id = 1;bool success = 2;string errmsg = 3; repeated MessageInfo msg_list = 4;
}// 函数实现
void NetClient::getRecentMessageList(const QString& loginSessionId, const QString& chatSessionId, bool updateUI)
{// 1. 通过 protobuf 构造请求 bodybite_im::GetRecentMsgReq req;req.setRequestId(makeRequestId());req.setChatSessionId(chatSessionId);req.setMsgCount(50); // 此处固定获取最近 50 条记录req.setSessionId(loginSessionId);QByteArray body = req.serialize(&serializer);LOG() << "[获取最近消息] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId << ", chatSessionId=" << chatSessionId;// 2. 发送 http 请求QNetworkReply* resp = this->sendHttpRequest("/service/message_storage/get_recent", body);// 3. 处理响应connect(resp, &QNetworkReply::finished, this, [=](){// a) 解析响应, 反序列化bool ok = false;QString reason;auto pbResp = this->handleHttpResponse<bite_im::GetRecentMsgRsp>(resp, &ok, &reason);// b) 判定响应是否出错if(!ok){LOG() << "[获取最近消息] 失败! reason=" << reason;return;}// c) 把拿到的数据, 设置到 DataCenter 中dataCenter->resetRecentMessageList(chatSessionId, pbResp);// d) 发送信号, 告知界面进行更新if (updateUI){emit dataCenter->getRecentMessageListDone(chatSessionId);}else{emit dataCenter->getRecentMessageListDoneNoUI(chatSessionId);}});
}
(2)客户端处理响应:
- 实现 DataCenter::resetRecentMsgList函数:
void DataCenter::resetRecentMessageList(const QString& chatSessionId, std::shared_ptr<bite_im::GetRecentMsgRsp> resp)
{// 拿到 chatSessionId 对应的消息列表, 并清空// 注意此处务必是引用类型, 才是修改哈希表内部的内容.QList<Message>& messageList = (*recentMessages)[chatSessionId];messageList.clear();for(auto& m : resp->msgList()){Message message;message.load(m);messageList.push_back(message);}
}
- 定义 DataCenter 信号:
// 获取近期消息完成
void getRecentMsgListDone(const QString& chatSessionId); // 更新UI
void getRecentMsgListDoneNoUI(const QString& chatSessionId); // 不更新 UI
- 实现 MainWidget::updateRecentMessages函数:
void MainWidget::updateRecentMessage(const QString& chatSessionId)
{// 1. 拿到该会话的最近消息列表DataCenter* dataCenter = DataCenter::getInstance();auto* recentMessageList = dataCenter->getRecentMessageList(chatSessionId);// 2. 清空原有界面上显示的消息列表messageShowArea->clear();// 3. 根据当前拿到的消息列表, 显示到界面上// 此处把数据显示到界面上, 可以使用头插, 也可以使用尾插.// 这里打算使用头插的方式来进行实现.// 主要因为消息列表来说, 用户首先看到的, 应该是 "最近" 的消息, 也就是 "末尾" 的消息.for(int i = recentMessageList->size() - 1; i >= 0; --i){const Message& message = recentMessageList->at(i);bool isLeft = message.sender.userId != dataCenter->getMyself()->userId;messageShowArea->addFrontMessage(isLeft, message);}// 4. 设置会话标题ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionById(chatSessionId);if(chatSessionInfo != nullptr){// 把会话名称显示到界面上.sessionTitleLabel->setText(chatSessionInfo->chatSessionName);}// 5. 保存当前选中的会话是哪个.dataCenter->setCurrentChatSessionId(chatSessionId);// 6. 自动把滚动条, 滚动到末尾messageShowArea->scrollToEnd();
}
- 实现 DataCenter::findChatSessionById函数方便找到对应的会话id:
ChatSessionInfo* DataCenter::findChatSessionById(const QString& chatSessionId)
{if(chatSessionList == nullptr){return nullptr;}for(auto& info : *chatSessionList){if (info.chatSessionId == chatSessionId){return &info;}}return nullptr;
}
- 实现 DataCenter::setCurrentChatSessionId 和DataCenter::getCurrentChatSessionId方便设置会话id和获取会话id:
void DataCenter::setCurrentChatSessionId(const QString &chatSessionId)
{this->currentChatSessionId = chatSessionId;
}const QString& DataCenter::getCurrentChatSessionId()
{return this->currentChatSessionId;
}
(3)服务器处理请求:
- 注册路由:
httpServer.route("/service/message_storage/get_recent", [=](constQHttpServerRequest& req)
{return this->getRecent(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getRecent(const QHttpServerRequest& req)
{// 解析请求bite_im::GetRecentMsgReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 获取最近消息列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()<< ", chatSessionId=" << pbReq.chatSessionId();// 构造响应bite_im::GetRecentMsgRsp pbResp;pbResp.setRequestId(pbReq.requestId());pbResp.setSuccess(true);pbResp.setErrmsg("");QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");for(int i = 0; i < 30; ++i){bite_im::MessageInfo messageInfo = makeTextMessageInfo(i, "2000", avatar);pbResp.msgList().push_back(messageInfo);}// 序列化QByteArray body = pbResp.serialize(&serializer);// 构造 HTTP 响应对象QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);resp.setHeader("Content-Type", "application/x-protobuf");return resp;
}
(4)整体流程小结:
8.6 点击某个好友项
(1)切换到会话列表:
- 编写 FriendItem::active:
- active 已经在 select 方法中通过多态的方式调用到了:
void FriendItem::active()
{LOG() << "FriendItem active. userId=" << userId;// 切换到当前会话. 如果没有就创建会话MainWidget* mainWidget = MainWidget::getInstance();mainWidget->switchToSession(userId);
}
(2)该会话置顶并被选中:
- 实现 MainWidget::switchSession函数:
void MainWidget::switchSession(const QString& userId)
{// 1. 在会话列表中, 先找到对应的会话元素DataCenter* dataCenter = DataCenter::getInstance();ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionByUserId(userId);if(chatSessionInfo == nullptr){// 正常来说, 每个好友, 都会有一个对应的会话(哪怕从来没说过话).// 添加好友的时候, 就创建出来的会话.LOG() << "[严重错误] 当前选中的好友, 对应的会话不存在!";return;}// 2. 把选中的会话置顶, 把这个会话信息放到整个会话列表的第一个位置.// 后续在界面显示的时候, 就是按照列表的顺序, 从前往后显示的.dataCenter->topChatSessionInfo(*chatSessionInfo);// 3. 切换到会话列表标签页switchTabToSession();// 4. 加载这个会话对应的历史消息. 刚刚做了一个 "置顶操作" , 被选中的好友对应的会话, 在会话列表的最前头, 也就是 0 号下标.sessionFriendArea->clickItem(0);
}
switchTabToSession已经在前⾯实现过了。
- 实现 DataCenter::findChatSessionByUserId函数方便找到用户id:
ChatSessionInfo* DataCenter::findChatSessionByUserId(const QString& userId)
{if(chatSessionList == nullptr){return nullptr;}for(auto& info : *chatSessionList){if (info.userId == userId){return &info;}}return nullptr;
}
- 实现 DataCenter::topChatSessionInfo函数将选中好友置顶:
void DataCenter::topChatSessionInfo(const ChatSessionInfo &chatSessionInfo)
{if(chatSessionList == nullptr){return;}// 1. 把这个元素从列表中找到auto iter = chatSessionList->begin();for(; iter != chatSessionList->end(); ++iter){if(iter->chatSessionId == chatSessionInfo.chatSessionId){break;}}if(iter == chatSessionList->end()){// 上面的循环没有找到匹配的元素, 直接返回. 正常来说, 不会走这个逻辑的.return;}// 2. 把这个元素备份一下, 然后删除ChatSessionInfo backup = chatSessionInfo;chatSessionList->erase(iter);// 3. 把备份的元素, 插入到头部chatSessionList->push_front(backup);
}
- 实现 SessionFriendArea::clickItem函数:
void SessionFriendArea::clickItem(int index)
{if(index < 0 || index >= container->layout()->count()){LOG() << "点击元素的下标超出范围! index=" << index;return;}QLayoutItem* layoutItem = container->layout()->itemAt(index);if(layoutItem == nullptr || layoutItem->widget() == nullptr){LOG() << "指定的元素不存在! index=" << index;return;}SessionFriendItem* item = dynamic_cast<SessionFriendItem*>(layoutItem->widget());item->select();
}
(3)加载该会话的最近消息并显示:
- 在上述 clickItem 中会调⽤ item->select() , 进⼀步调⽤到 active ⽅法, 从⽽触发加载最近消息的逻辑.
(4)整体流程小结:
(5)注意:
- 每个会话中的用户列表,应该是按需加载的,不应该是程序启动全都加载进来!!
- 创建会话操作放到同意好友申请时。换而言之每个用户都⼀定存在⼀个和他对应的会话。
9. 小结
(1)在进行前后端交互接口的实现的时候代码格式基本上都是一样的,只需要将其中一个流程搞清楚即可。如下图就是基本的流程图了:
(2)剩下的需要实现的前后端交互接口见博客:https://blog.csdn.net/m0_65558082/article/details/143817211?spm=1001.2014.3001.5502。
客户端整体代码链接:https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client。