文章目录
- 前言
- 1. 核心数据结构
- 1.1 用户信息
- 1.2 会话信息
- 1.3 消息信息
- 2. 建立目录
- 3. 编写代码
- 3.1 用户信息
- 3.2 会话信息
- 3.3 消息信息
- 3.4 工具函数
- 4. data.h 完整代码
- 总结
前言
在构建现代微服务架构的即时通讯系统时,核心数据结构的设计是至关重要的。它们不仅决定了系统的性能和可扩展性,而且也影响着用户交互的直观性和便捷性。本文将深入探讨即时通讯系统中的三个核心数据结构:用户信息、会话信息和消息信息,以及它们是如何在C++和Qt框架下实现的。通过详细解析这些数据结构的设计和实现,我们希望能够为开发者提供一个清晰的指导,帮助他们构建高效、稳定且用户友好的即时通讯应用。
1. 核心数据结构
1.1 用户信息
1.2 会话信息
用户和用户之间,聊天会话:
例如:
用户A:有2个好友:B、C
此时A可以和B单聊,也能和C单聊(2个聊天会话)
此时这个群组,也对应一个聊天会话
在聊天程序中,会话的生命周期,是比较长的;一直持续到把对方好友删除/退出群组才会随之销毁。
1.3 消息信息
- 文本消息
- 图片消息
- 文件消息
- 语音消息
2. 建立目录
在文件中显示
创建一个文件夹存放目录
新建一个文件
添加进来
此时cmake 就自动添加上了
qt_add_executable
:指定编译qt需要依赖哪些文件(源代码,肯定是要依赖的)
关于命名空间的约定(这种约定,仅限于当前项目):
如果代码所在的文件,就是在项目的顶层目录中,此时就直接使用全局命名空间(不手动指定)
如果代码所在的文件,在某个子目录中;此时,就指定一个和目录名字相同的命名空间。
使代码中的命名空间的结构和文件在目录中的结构一致。(尤其是在目录结构更复杂,嵌套多层)
3. 编写代码
3.1 用户信息
/// 用户信息
class UserInfo {
public:
QString userId = ""; // 用户编号
QString nickname = ""; // 用户昵称
QString description = ""; // 用户签名
QString phone = ""; // 手机号码
QIcon avatar; // 用户头像
};
QString userId;
使用字符串的方式来作为id,可以有更灵活的方式来生成。(也为了能够适应分布式后端)
mysql 数据库,支持自增主键:
如果是单个节点的 mysql,用上述方式没有任何问题
但是,如果是多个节点的分布式
mysql,就无法使用整数自增组件了 分布式 mysql 下,很可能需要针对用户信息“分库分表”
后续中可以通过例如:uuid 这样的方式,或者是 雪花算法这样的方式 来生成分布式系统中唯一id
3.2 会话信息
/// 会话信息
class ChatSessionInfo {
QString chatSessionId = ""; // 会话编号
QString chatSessionName = ""; // 会话名字,如果是会话是单聊,名字就是对方的昵称;如果是群聊,名字就是群聊的名称
Message lastMessage; // 表示最新的消息
QIcon avatar; // 会话头像,如果会话是单聊,头像就是对方的头像;如果是群聊,头像群聊的头像
QString userId = ""; // 对于单聊来说,表示对方的用户 id,对于群聊设置为 ""
};
ChatSessionInfo
前面谈到的“会话”都是针对 聊天过程中的,组织消息的 会话。
后面还会涉及到,客户端连接到服务器之后,也有一个“登录”用到的会话
Message lastMessage;
这个内容就是为了在会话列表中,能够起到“显示-提示”这样的效果。
针对会话信息来说:
一个会话,里面其实是可以包含多个用户的
这个会话里具体有哪些用户,
后续会通过单独的方式进行组织管理。
此处列出 QString userId;
表示的含义是:
- 如果会话是单聊会话,此时
userId
表示“对方”
的用户id
- 如果会话是群聊会话,此时
userId
设为“”
,后续通过其他的方式来吧完整的用户id
列表拿到
会话 - 消息 是 “一对多” 这样的关系。
消息:会话id。
QByteArray content; // 消息发送的正文内容
如果是 文本消息,正文就是一个字符串
如果是 图片,文件,语音消息,正文就是一个“二进制序列”
| 在C/C++ 中没有
byte
这样的类型,表示byte
都是拿char / unsigned char
凑合一下
|
char / unsigned char
正常来说,一个char(字符) 不一定是一个字节
- 对于 应用 ascii 来说,一个字符就是一个字节
- 对于 中文 gbk 编码来说,一个字符就是2个字节
- 对于 中文 utf8 编码来说,一个字符就是3个字节
由于 C/C++ 有点太老了,对于这一块的支持,比较有限
C++ 中,一个char
就是固定的一个字节了。
| 比方说,C++中:
std::string name = "张三";
C++ 中,通过代码取出“三”这个汉字,老麻烦了!
- 先判定是那种编码方式
- 计算第二个汉字 所属的范围
- 取字符串子串
| 像其他主流语言,Java/Python 之类的
string s = "张三";
s.charAt(1) => "三"
| 相比之下,Qt做了更好的处理;
QString
就对上述情况处理的更好了。 Qt 也明确区分了 “字节” 和 “字符”
QString fileId; // 文件的身份标识,当类型为 文件,图片,语言 的时候,才有效;当消息为文本,则为""
文件/图片/语言 这些消息,体积可能是比较大的!(网络带宽)
- 一旦一个聊天会话中,包含多个上述这样的消息,就会使从服务器消息列表这样的操作,变得非常低效。
一般的做法,都是“获取消息列表”,只是拿到文件/图片/语言 消息的filed
等到客户端得到“消息列表”之后,再更具拿到的filed
,给服务器发送额外的请求,获取文件内容。(化整为零)
QString fileName; // 文件名称,只是当消息类型为 文件消息时, 才有效,其他消息均为 ""
虽然图片/语音,这两个也是“文件”但是文件名不需要显示到界面上。
对于文件消息,希望界面上显示“文件名”,点击之后可以进行“另存为”这样的操作。
3.3 消息信息
/// 消息信息
enum MessageType {
TEXT_TYPE, // 文本消息
IMAGE_TYPE, // 图片消息
FILE_TYPE, // 文件消息
SPEECH_TYPE, // 语音消息
};
class Message {
public:
QString messageId = ""; // 消息的编号
QString chatSessionId = ""; // 消息所属会话的编号
QString time = ""; // 消息时间,通过“格式化”时间的方式来表示, 形如:06-07 12:00:00
MessageType messageType = TEXT_TYPE; // 消息类型
UserInfo sender; // 发送者的信息
QByteArray content = ""; // 消息发送的正文内容
QString fileId = ""; // 文件的身份标识,当类型为 文件,图片,语言 的时候,才有效;当消息为文本,则为""
QString fileName = ""; // 文件名称,只是当消息类型为 文件消息时, 才有效,其他消息均为 ""
// 此处 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();
}
}
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;
// 此处需要确保,设置的 messageId 是 “唯一” 的
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime()); // 生成一个格式化时间
message.content = content;
message.messageType = TEXT_TYPE;
// 对于文本消息来说,这两个属性不使用,设为""
message.fileId = "";
message.fileName = "";
return message;
}
static Message makeImageMessage(const QString& chatSessionId, const UserInfo& sender, const QByteArray& content) {
Message message;
// 此处确保,设置的 messageId 是“唯一”的
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = IMAGE_TYPE;
// 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.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = FILE_TYPE;
// 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.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = SPEECH_TYPE;
// fileId 后续使用的时候进一步设置
message.fileId = "";
// fileName 不使用,直接设为“”
message.fileName = "";
return message;
}
};
- 工厂方法:(工厂模式)
解决 C++/Java 等语言中,构造函数,不够用的问题。
虽然平时构建对象,都是通过对 构造函数 来完成。
如果有不同的方式来构造对象,此时构造函数就不太够用了。
比方说 “点”
class Point {
public:
Point(double x, double y); // 直角坐标的方式构造
Point(double r, double a); // 极坐标的构造方式
}
够高函数想要提供不同版本,必须要 “重载” 要求 函数名 相同(都是一样)
参数的个数/类型不同
使用普通的函数来实现不同的构造方式;普通函数,函数名可以随便起,也就不再受到“重载”的约束了。
这里说“普通”,一般都要使用
static
修饰的 静态函数。(调用已有的构造函数,根据不同的需求,进一步的实现初始化的细节)
相比于 UserInfo
和 ChatSessionInfo
, Message
是更需要“工厂模式”的,Message
需要支持多种构造方式;
- 文本消息
- 图片消息
- 语音消息
- 文件消息
messageId
是一个“唯一”这样的内容
UUID 这个东西,背后是一套算法,通过这个算法,就能生成“全球唯一的身份标识”,Qt对这个算法也是有封装的。
这一串,其实是16进制的整数,实际开发中,为了提高“可读性”也可以截取uuid中一部分来进行使用。
file.write(content);
file.flush();
file.close();
flush
: 刷新缓冲区
3.4 工具函数
/// 工具函数,后续很多模块可能都要用到
static inline QString getFileName(const QString& path) {
QFileInfo fileInfo(path);
return fileInfo.fileName();
}
// 封装一个“宏”作为打印日志的方式
#define TAG QString("[%1:%2]").arg(model::getFileName(__FILE__), QString::number(__LINE__))
// qDebug 打印字符串的时候,就会自动加上" "
#define LOG() qDebug().noquote() << TAG
// 要求函数的定义如果写在 .h 中,必须加 static 或者 inline(当然两个都加也可以),避链接阶段出现“函数重定义”的问题
static inline QString formatTime(int64_t timestamp) { // 为了防止在2038年溢出,用64位整数
// 先把时间戳,转换成QDateTime 对象
QDateTime dateTime = QDateTime::fromSecsSinceEpoch(timestamp);
// 把 QDateTime 对象转换成“格式时间”
return dateTime.toString("MM-dd HH:mm:ss");
}
// 通过这个函数得到 秒级 的时间
static inline int64_t getTime() {
return QDateTime::currentMSecsSinceEpoch();
}
// 根据 QByteArray, 转成 QIcon
static inline QIcon makeIcon(const QByteArray& byteArray) {
QPixmap pixmap;
pixmap.loadFromData(byteArray);
QIcon icon(pixmap);
return icon;
}
// 读写文件操作
// 从读取文件中,读取所有的二进制内容,得到一个 QByteArray
static inline QByteArray loadFileToByteArray(const QString& path) {
QFile file(path);
bool ok = file.open(QFile::ReadOnly);
if (!ok) {
qDebug() << "文件打开失败";
return QByteArray();
}
QByteArray content = file.readAll();
file.close();
return content;
}
// 把 QByteArray 中的内容,写入到某个指定的文件夹里
static inline void writeByteArryToFile(const QString& path, const QByteArray& content) {
QFile file(path);
bool ok = file.open(QFile::WriteOnly);
if (!ok) {
qDebug() << "文件打开失败";
return;
}
file.write(content);
file.flush(); // 刷新缓冲区
file.close();
}
4. data.h 完整代码
#pragma once
#include <QString>
#include <QIcon>
#include <QUuid>
#include <QDateTime>
#include <QFile>
#include <QFileInfo>
#include <QDebug>
// 创建命名空间
namespace model {
/// 工具函数,后续很多模块可能都要用到
static inline QString getFileName(const QString& path) {
QFileInfo fileInfo(path);
return fileInfo.fileName();
}
// 封装一个“宏”作为打印日志的方式
#define TAG QString("[%1:%2]").arg(model::getFileName(__FILE__), QString::number(__LINE__))
// qDebug 打印字符串的时候,就会自动加上" "
#define LOG() qDebug().noquote() << TAG
// 要求函数的定义如果写在 .h 中,必须加 static 或者 inline(当然两个都加也可以),避链接阶段出现“函数重定义”的问题
static inline QString formatTime(int64_t timestamp) { // 为了防止在2038年溢出,用64位整数
// 先把时间戳,转换成QDateTime 对象
QDateTime dateTime = QDateTime::fromSecsSinceEpoch(timestamp);
// 把 QDateTime 对象转换成“格式时间”
return dateTime.toString("MM-dd HH:mm:ss");
}
// 通过这个函数得到 秒级 的时间
static inline int64_t getTime() {
return QDateTime::currentMSecsSinceEpoch();
}
// 根据 QByteArray, 转成 QIcon
static inline QIcon makeIcon(const QByteArray& byteArray) {
QPixmap pixmap;
pixmap.loadFromData(byteArray);
QIcon icon(pixmap);
return icon;
}
// 读写文件操作
// 从读取文件中,读取所有的二进制内容,得到一个 QByteArray
static inline QByteArray loadFileToByteArray(const QString& path) {
QFile file(path);
bool ok = file.open(QFile::ReadOnly);
if (!ok) {
qDebug() << "文件打开失败";
return QByteArray();
}
QByteArray content = file.readAll();
file.close();
return content;
}
// 把 QByteArray 中的内容,写入到某个指定的文件夹里
static inline void writeByteArryToFile(const QString& path, const QByteArray& content) {
QFile file(path);
bool ok = file.open(QFile::WriteOnly);
if (!ok) {
qDebug() << "文件打开失败";
return;
}
file.write(content);
file.flush(); // 刷新缓冲区
file.close();
}
/// 用户信息
class UserInfo {
public:
QString userId = ""; // 用户编号
QString nickname = ""; // 用户昵称
QString description = ""; // 用户签名
QString phone = ""; // 手机号码
QIcon avatar; // 用户头像
};
/// 消息信息
enum MessageType {
TEXT_TYPE, // 文本消息
IMAGE_TYPE, // 图片消息
FILE_TYPE, // 文件消息
SPEECH_TYPE, // 语音消息
};
class Message {
public:
QString messageId = ""; // 消息的编号
QString chatSessionId = ""; // 消息所属会话的编号
QString time = ""; // 消息时间,通过“格式化”时间的方式来表示, 形如:06-07 12:00:00
MessageType messageType = TEXT_TYPE; // 消息类型
UserInfo sender; // 发送者的信息
QByteArray content = ""; // 消息发送的正文内容
QString fileId = ""; // 文件的身份标识,当类型为 文件,图片,语言 的时候,才有效;当消息为文本,则为""
QString fileName = ""; // 文件名称,只是当消息类型为 文件消息时, 才有效,其他消息均为 ""
// 此处 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();
}
}
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;
// 此处需要确保,设置的 messageId 是 “唯一” 的
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime()); // 生成一个格式化时间
message.content = content;
message.messageType = TEXT_TYPE;
// 对于文本消息来说,这两个属性不使用,设为""
message.fileId = "";
message.fileName = "";
return message;
}
static Message makeImageMessage(const QString& chatSessionId, const UserInfo& sender, const QByteArray& content) {
Message message;
// 此处确保,设置的 messageId 是“唯一”的
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = IMAGE_TYPE;
// 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.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = FILE_TYPE;
// 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.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = SPEECH_TYPE;
// fileId 后续使用的时候进一步设置
message.fileId = "";
// fileName 不使用,直接设为“”
message.fileName = "";
return message;
}
};
/// 会话信息
class ChatSessionInfo {
QString chatSessionId = ""; // 会话编号
QString chatSessionName = ""; // 会话名字,如果是会话是单聊,名字就是对方的昵称;如果是群聊,名字就是群聊的名称
Message lastMessage; // 表示最新的消息
QIcon avatar; // 会话头像,如果会话是单聊,头像就是对方的头像;如果是群聊,头像群聊的头像
QString userId = ""; // 对于单聊来说,表示对方的用户 id,对于群聊设置为 ""
};
} // end model
总结
本文详细介绍了微服务即时通讯系统中的核心数据结构,包括用户信息、会话信息和消息信息,以及它们在C++和Qt环境下的具体实现。我们首先对每个数据结构的功能和属性进行了概述,然后通过代码示例展示了如何定义这些结构和相关的工具函数。特别地,我们使用了工厂模式来简化消息对象的创建过程,以支持不同类型的消息构造。此外,文中还探讨了UUID生成机制,确保了消息ID的唯一性,这对于分布式系统尤为重要。
通过本文的阅读,开发者应该能够理解并实现一个高效的消息处理系统,它不仅能够处理文本消息,还能够处理图片、文件和语音等多种类型的消息。此外,通过合理组织代码和使用适当的设计模式,可以提高代码的可维护性和可扩展性,为未来的功能扩展打下坚实的基础。最后,本文提供的代码示例和设计思路可以作为构建即时通讯系统的一个参考起点,帮助开发者快速进入项目开发阶段。