目录
- 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;//用户ID
string nickname = 2;//昵称
string description = 3;//个人签名/描述
string phone = 4; //绑定手机号
bytes avatar = 5;//头像照片,文件内容使用二进制
}
(2)会话信息:
//聊天会话信息
message ChatSessionInfo {
optional string single_chat_friend_id = 1;//群聊会话不需要设置,单聊会话设置为对方ID
string chat_session_id = 2; //会话ID
string 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;//消息ID
string chat_session_id = 2;//消息所属聊天会话ID
int64 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/xxxxx
Content-Type: application/x-protobuf
Content-Length: 123
xxxxxx
-------------------------------------------------------
HTTP/1.1 200 OK
Content-Type: application/x-protobuf
Content-Length: 123
xxxxxxxxxx
*/
(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:
// 通过这个方法生成唯一的 messageId
static 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 中要组织管理的所有的数据
// 当前客户端登录到服务器对应的登录会话 id
QString 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;
// 短信验证码的验证 id
QString 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_OBJECT
private:
// 定义重要常量. ip 都暂时使用本地的环回 ip. 端口号约定成 8000 和 8001
const 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);
// 生成请求 id
static 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;
}
// 获取到响应的 body
QByteArray 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. 获取到响应的 body
QByteArray 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 转成客户端自己的 Message
model::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_OBJECT
public:
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_OBJECT
private:
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 中的 accept
QWebSocket* 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_NETWORK
network::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 构造 body
bite_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 构造 body
bite_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 构造 body
bite_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 构造请求 body
bite_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。