目录
[1 情景在线](#1 情景在线)
[2 HTTP请求(http_handler.h)](#2 HTTP请求(http_handler.h))
[3 分布式](#3 分布式)
项目运行
1.git clone http://gitlab.0voice.com/2410_vip/github-chatroom.git
2.进入server目录登录MySQL,导入数据库(确保已经安装mysql)
3.安装第三方库:libjsoncpp-dev(用于解析和生成json数据)、libhiredis-dev(服务端需要作为客户端利用Redis提供的hiredis访问redis服务器)、uuid-dev( 项目需要使用UUID 生成功能)
-- 注意hiredis0.14.0与Ubuntu20冲突,需要改为0.14.1的版本
4.安装redis-server:redis的服务端也配置在同一台虚拟机上
5.server端编译运行 项目的后台搭建成功,可以处理前端业务
6.安装Node.js,为web客户服务端提供环境支持
7.npm install安装web客户服务端需要的组件,"npm run dev"运行web客户端。直接在浏览器访问。
项目框架
模块逻辑

同一个端口,如何区分websocket和http服务?


业务逻辑

数据json格式:
数据存储
MySQL: 存储 "用户信息" ,在chatroom数据库中对应的user表
Redis :存储 "房间消息" 和 "用户cookie"
房间消息:使用redis的stream结构,key为消息id,value为具体的聊天消息
其中消息id = 时间戳 + 序列号 序列号用于区分1毫秒内的消息。
用户cookie:使用redis的string结构,key为cookie,value为用户id。为一个临时键值对,默认有效期是1天,超过则会被redis删除,需要重新登陆。
从Redis获取消息举例:
Redis> XREVRANGE mystream (1736936461668-0 - COUNT 5
解析:
1、XREVRANGE : 命令,指逆序(新->旧)读取stream消息
2、mystream :stream是Redis中严格按照时间排序的数据结构,可以看作一个持续更新的记事本,而mystream 就是这个记事本的名字,除此之外可以用 order_stream
存订单,log_stream
存日志
1、1736936461668指从1970.1.1到发送消息时间的毫秒数
2、( 表示不包含1736936461668标识的这条消息,此消息后的5条消息
代码编写
1 情景在线
用户A注册账号,建立http服务,解析json数据,验证信息在数据库不存在且存入redis后,给用户A返回一个cookie,限时一天,用户A可以拿着cookie与服务器发送消息,期间无需再验证密码。登录成功后,用户A给服务器发送握手信息,请求Websocket服务,建立连接。服务端在检验cookie有效后,把用户A的ID和连接建立map映射,并且给其订阅默认的五个聊天室,随后加载五个聊天室的历史数据返回给用户A,尽管没有数据。用户A收到数据,知道连接建立成功。随后用户A在1号聊天室发布一条消息,服务器收到A通过websocket发来的websocket数据帧,按照websocket格式解析出frame,再通过json反序列化获取payload_data,把这个data存入redis数据库后,消息就算是落盘了,再执行一遍相反的包装操作,把这个websocket数据帧发送给其余也订阅了1号聊天室的用户,尽管这时候只有用户A。
用户B注册账号,经历上面的阶段后,会在连接建立成功的时候看到1号聊天室用户A发送的消息,然后在里面也发送一条消息,这个时候经历上面的 decode - store - encode - send的阶段,A就会看到这条消息。
用户B随后创建一个6号聊天室,这个聊天室信息存入磁盘后,会自动把在线的用户A和用户B拉入到房间里,在服务器重启后,由于落盘了,6号聊天室也会变成默认聊天室,所有新登录的用户都会自动订阅这个聊天室,并且会看到历史聊天数据。
2 HTTP请求(http_handler.h)
1.接收到HTTP请求,通过http header选择 websocket或者 http服务
cpp
//http_handler.h
void OnRead(Buffer* buf) {
if(request_type_ == UNKNOWN) {
const char *in_buf = buf->peek();
int32_t len = buf->readableBytes();
std::cout << "in_buf: " << in_buf << std::endl;
auto headers = parseHttpHeaders(in_buf, len); //解析http头
if (isWebSocketRequest(headers)) {
// WebSocket 请求
request_type_ = WEBSOCKET;
http_conn_ = std::make_shared<CWebSocketConn>(tcp_conn_);
http_conn_->setHeaders(headers);
} else {
// HTTP 请求
request_type_ = HTTP;
http_conn_ = std::make_shared<CHttpConn>(tcp_conn_);
http_conn_->setHeaders(headers);
}
}
// 将数据交给具体的处理器
if(http_conn_)
http_conn_->OnRead(buf);
}
2.1注册/登录模块(http_conn.c)
2.注册/登录是http服务,接下来需要解析HTTP请求的URL的内容,判断是登录还是注册
cpp
//http_conn.c
//解析HTTP请求的内容 根据请求的URL判断是登录还是注册
void CHttpConn::OnRead(Buffer *buf) // CHttpConn业务层面的OnRead
{
const char *in_buf = buf->peek();
int32_t len = buf->readableBytes();
http_parser_.ParseHttpContent(in_buf, len);
if(http_parser_.IsReadAll()) {
string url = http_parser_.GetUrlString();
string content = http_parser_.GetBodyContentString();
LOG_INFO << "url: " << url << ", content: " << content;
if (strncmp(url.c_str(), "/api/login", 10) == 0) { // 登录
_HandleLoginRequest(url, content);
}
else if(strncmp(url.c_str(), "/api/create-account", 18) == 0) { // 注册
_HandleRegisterRequest(url, content);
}
else {//处理未匹配的URL
char *resp_content = new char[256];
string str_json = "{\"code\": 1}";
uint32_t len_json = str_json.size();
//暂时先放这里
#define HTTP_RESPONSE_REQ \
"HTTP/1.1 404 OK\r\n" \
"Connection:close\r\n" \
"Content-Length:%d\r\n" \
"Content-Type:application/json;charset=utf-8\r\n\r\n%s"
snprintf(resp_content, 256, HTTP_RESPONSE_REQ, len_json, str_json.c_str());
tcp_conn_->send(resp_content);
}
}
}
附:下面3.1与3.2都会用到的一段代码,cookie的生成以及存入redis的相关代码:
cpp
//api_common.cc
int ApiSetCookie(string email, string &cookie){
// 获取redis连接池
CacheManager *cache_manager = CacheManager::getInstance();
CacheConn *cache_conn = cache_manager->GetCacheConn("token"); //
AUTO_REL_CACHECONN(cache_manager, cache_conn);
if(!cache_conn) {
LOG_ERROR << "get cache conn failed";
return -1;
}
cookie = generateUUID(); //需要唯一性
string ret = cache_conn->SetEx(cookie, 86400, email); //存入redis key为cookie value为email 86400s = 24h
if(!ret.empty()) {
return 0;
} else {
return -1;
}
}
2.1.1注册(api_reg.cc)
(1)解析
解析注册请求post_data中的 JSON 数据
cpp
//api_reg.cc
int decodeRegisterJson(const std::string &str_json, string &username,
string &email, string &password)
{
bool res;
Json::Value root;
Json::Reader jsonReader;
res = jsonReader.parse(str_json, root);
if (!res) {
LOG_ERROR << "parse reg json failed ";
return -1;
}
// 用户名
if (root["username"].isNull()) {
LOG_ERROR << "username null";
return -1;
}
username = root["username"].asString();
//邮箱
if (root["email"].isNull()) {
LOG_WARN << "email null";
} else {
email = root["email"].asString();
}
//密码
if (root["password"].isNull()) {
LOG_ERROR << "password null";
return -1;
}
password = root["password"].asString();
return 0;
}
(2)验证
验证用户信息(如用户名和邮箱是否已存在),并将用户信息(密码加密后)存储到数据库中。
cpp
//api_reg.cc
int registerUser(string &username, string &email, string &password, api_error_id &error_id) {
int ret = -1;
//创建连接池和MySQL连接
CDBManager *db_manager = CDBManager::getInstance();
CDBConn *db_conn = db_manager->GetDBConn("chatroom_master");
AUTO_REL_DBCONN(db_manager, db_conn);
if(!db_conn) {
LOG_ERROR << "GetDBConn(chatroom_master) failed" ;
return 1;
}
// 先查询 用户名 邮箱是否存在 如果存在就报错
string str_sql = FormatString("select id, username, email from users where username='%s' or email='%s' ", username.c_str(), email.c_str());
CResultSet *result_set = db_conn->ExecuteQuery(str_sql.c_str());
if(result_set && result_set->Next()) {
if(result_set->GetString("username")) {
if(result_set->GetString("username") == username) {
error_id = api_error_id::username_exists;
LOG_WARN << "id: " << result_set->GetInt("id") << ", username: " << username << " 已经存在";
}
}
if(result_set->GetString("email")) {
if(result_set->GetString("email") == email) {
error_id = api_error_id::email_exists;
LOG_WARN << "id: " << result_set->GetInt("id") << ", email: " << email << " 已经存在";
}
}
delete result_set;
return -1;
}
//注册账号
// 随机数生成盐值
string salt = RandomString(16); //随机混淆码
MD5 md5(password+salt);
string password_hash = md5.toString();
LOG_INFO << "salt: " << salt;
//插入语句
str_sql = "insert into users (`username`,`email`,`password_hash`,`salt`) values(?,?,?,?)";
LOG_INFO << "执行: " << str_sql;
// 预处理方式写入数据
CPrepareStatement *stmt = new CPrepareStatement();
if (stmt->Init(db_conn->GetMysql(), str_sql)) {
uint32_t index = 0;
stmt->SetParam(index++, username);
stmt->SetParam(index++, email);
stmt->SetParam(index++, password_hash);
stmt->SetParam(index++, salt);
bool bRet = stmt->ExecuteUpdate(); //真正提交要写入的数据
if (bRet) { //提交正常返回 true
ret = 0;
LOG_INFO << "insert user_id: " << db_conn->GetInsertId() << ", username: " << username ;
} else {
LOG_ERROR << "insert users failed. " << str_sql;
ret = 1;
}
}
delete stmt;
return ret;
}
(3)返回
注册成功返回cookie,失败返回已存在。(在http_conn.c中有调用下面的函数)
cpp
//api_reg.cc
int ApiRegisterUser(std::string &post_data, std::string &resp_data){
string username;
string email;
string password;
int ret = decodeRegisterJson(post_data, username, email, password);
if(ret < 0) {
// 参数问题的encode
encdoeRegisterJson(api_error_id::bad_request, "请求参数不全", resp_data);
return -1;
}
// 封装registerUser(username email password)
api_error_id error_id = api_error_id::bad_request;
ret = registerUser(username, email, password, error_id);
//返回注册结果
if(ret == 0) {
// 注册成功 是需要产生cookie
ApiSetCookie(email, resp_data);
} else {
// 注册问题的encode
encdoeRegisterJson(error_id, api_error_id_to_string(error_id), resp_data);
}
//正常注册 返回cookie 在resp_data中
//异常 {} EMAIL_EXISTS 也在resp_data中 返回给http_conn.cc中传入的resp_data
return ret;
}
2.1.2登录(api_login.cc)
(1)解析
解析登录请求post_data中的登录请求数据
cpp
//api_login.cc
// / 解析登录信息
int decodeLoginJson(const std::string &str_json, string &email,
string &password) {
bool res;
Json::Value root;
Json::Reader jsonReader;
res = jsonReader.parse(str_json, root);
if (!res) {
LOG_ERROR << "parse login json failed ";
return -1;
}
if (root["email"].isNull()) {
LOG_ERROR << "email null";
return -1;
}
email = root["email"].asString();
//密码
if (root["password"].isNull()) {
LOG_ERROR << "password null";
return -1;
}
password = root["password"].asString();
return 0;
}
(2)验证

验证用户的邮箱和密码是否正确
cpp
//api_login.cc
int verifyUserPassword(string &email, string &password) {
int ret = -1;
// 只能使用email
CDBManager *db_manager = CDBManager::getInstance();
CDBConn *db_conn = db_manager->GetDBConn("chatroom_slave");
AUTO_REL_DBCONN(db_manager, db_conn); //析构时自动归还连接
if(!db_conn) {
LOG_ERROR << "get db conn failed";
return -1;
}
//根据email查询密码
string strSql = FormatString("select username, password_hash, salt from users where email='%s'", email.c_str());
LOG_INFO << "执行:" << strSql;
CResultSet *result_set = db_conn->ExecuteQuery(strSql.c_str());
if (result_set && result_set->Next()) { //如果存在则读取密码
string username = result_set->GetString("username");
string db_password_hash = result_set->GetString("password_hash");
string salt = result_set->GetString("salt");
MD5 md5(password + salt); // 计算出新的密码
string client_password_hash = md5.toString(); // 计算出新的密码
if(db_password_hash == client_password_hash) {
LOG_INFO << "username: " << username << " verify ok";
ret = 0;
} else {
LOG_INFO << "username: " << username << " verify failed";
ret = -1;
}
} else {
ret = -1;
}
if(result_set)
delete result_set;
return ret;
}
(3)返回
登录成功返回cookie,失败返回错误信息。
cpp
//api_login.cc
int ApiUserLogin(std::string &post_data, std::string &resp_data){
//
string email;
string password;
// json反序列化
// 解析json
if (decodeLoginJson(post_data, email, password) < 0) {
LOG_ERROR << "decodeRegisterJson failed";
encodeLoginJson(api_error_id::bad_request, "email or password no fill", resp_data);
return -1;
}
//校验邮箱 密码
int ret = verifyUserPassword(email, password);
if(ret == 0) {
// 设置cookie
ApiSetCookie(email, resp_data);
} else {
encodeLoginJson(api_error_id::bad_request, "email password no match", resp_data);
}
return ret;
}
2.2聊天模块(websocket_conn.cc)
客户端对服务器的主动发送消息,有可能是 "握手信号" ,有可能非握手信号。非握手信号有:"具体消息" 和 "历史消息的查看"
cpp
//websocket_conn.cc
void CWebSocketConn::OnRead(客户端发送来的Websocket数据帧)
{
if(连接还没建立){
-> 2.2.1建立websocket连接
}
else{
先解析Websocket数据帧,拿到其中的payload json字段
if(json中type字段 == "clientMessages")
-> 2.2.2处理用户消息
else if(json中type字段 == "requestRoomHistory")
-> 2.2.3请求历史消息
else if(json中type字段 == "clientCreateRoom")
-> 2.2.4创建聊天室
}
}
2.2.1建立websocket连接
(1)握手
获取Websocket数据帧中Sec-WebSocket-Key
字段的值,服务器基于它生成一个 Sec-WebSocket-Accept
值,返回给客户端以完成握手。
cpp
//websocket_conn.cc
void CWebSocketConn::OnRead(Buffer* buf){
if (!handshake_completed_) {
// 客户端 服务端 握手
string request = buf->retrieveAllAsString();
LOG_INFO << "request" << request;
size_t key_start = request.find("Sec-WebSocket-Key: ");
if(key_start != std::string::npos) {
//说明能找到Sec-WebSocket-Key:
key_start += 19; //19是 Sec-WebSocket-Key: 的长度
size_t key_end = request.find("\r\n", key_start); // key_start -- key_end = Y90uUZxPYAVrEFJrtYEfCg==
std::string key = request.substr(key_start, key_end - key_start);
// base 64编码(SHA1(key + 固定str))
string response = generateWebSocketHandshakeResponse(key);
send(response); //握手成功
handshake_completed_ = true;
LOG_INFO << "handshake_completed_ ok";
}
...cookie检验过程
}
(2)检验cookie
cpp
//websocket_conn.cc
void CWebSocketConn::OnRead(Buffer* buf){
...握手过程
string Cookie = headers_["Cookie"];
LOG_INFO << "Cookie: " << Cookie;
string sid;
if(!Cookie.empty()) {
sid = extractSid(Cookie);
}
LOG_INFO << "sid: " << sid;
// 根据cookie-sid ,获取 用户名 用户id Email
string email;
if(Cookie.empty() || ApiGetUserInfoByCookie(username_, userid_, email, sid) < 0) {
string reason;
if(email.empty()) {
reason = "cookie validation failed";
} else {
reason = "db may be has issue";
}
// 校验失败
sendCloseFrame(1008, reason);
} else {
//校验成功
// 把连接加入 s_user_ws_conn_map 统一管理websocket连接
LOG_INFO << "cookie validation ok";
s_mtx_user_ws_conn_map_.lock();
s_user_ws_conn_map.insert({userid_,shared_from_this()}); // 同样userid连接可能已经存在了
s_mtx_user_ws_conn_map_.unlock();
//获取用户的聊天室列表
std::vector<Room> &room_list = GetRoomList();
for(int i = 0; i < room_list.size(); i++) {
rooms_map_.insert({room_list[i].room_id, room_list[i]});
}
// 发送"hello"给 客户端
sendHelloMessage();
}
}
(3)发送"hello"信息
初始化客户端的界面,展示用户信息和聊天室的历史消息。
数据流:从Redis获取消息,json序列化为payload,再封装成websocket格式的frame,一个WebSocket 数据帧,发送。
cpp
//websocket_conn.cc
//获取用户信息和用户加入的聊天室的历史消息存入结构体,根据结构体字段值,构造json发送给客户端
int CWebSocketConn::sendHelloMessage()
{
Json::Value root;
root["type"] = "hello";
Json::Value payload;
Json::Value me;
me["id"] = userid_;
me["username"] = username_;
payload["me"] = me;
Json::Value rooms;
int it_index = 0;
//每个用户都有自己的rooms_map_
// 遍历加入的房间,获取每个房间的历史消息
for (auto it = rooms_map_.begin(); it != rooms_map_.end(); ++it)
{
Room &room_item = it->second;
string last_message_id;
MessageBatch message_batch;
到Redis获取房间的消息,存入message_batch结构中
ApiGetRoomHistory(room_item, message_batch);
LOG_INFO << "room: " << room_item.room_name << ", history_last_message_id:" << it->second.history_last_message_id;
Json::Value room;
room["id"] = room_item.room_id; //聊天室主题名称
room["name"] = room_item.room_name; //聊天室主题名称 先设置成一样的
room["hasMoreMessages"] = message_batch.has_more;
Json::Value messages;
for(int j = 0; j < message_batch.messages.size(); j++) {
Json::Value message;
Json::Value user;
message["id"] = message_batch.messages[j].id;
message["content"] = message_batch.messages[j].content;
user["id"] = userid_;
user["username"] = username_;
message["user"] = user;
message["timestamp"] = (Json::UInt64)message_batch.messages[j].timestamp;
messages[j] = message;
}
if(message_batch.messages.size() > 0)
room["messages"] = messages;
else
room["messages"] = Json::arrayValue; //不能为NULL,否则前端报异常
rooms[it_index] = room;
it_index++;
}
payload["rooms"] = rooms;
root["payload"] = payload;
Json::FastWriter writer;
string str_json = writer.write(root);
// 打印 JSON 字符串
LOG_INFO << "Serialized JSON: " << str_json;
string hello = buildWebSocketFrame(str_json);
send(hello);
}
2.2.2处理聊天消息
(1)房间管理
实现一个PubSubService类,全局房间订阅服务,提供获取所有房间ID,增加/删除房间,加入/退出房间,在房间内发布消息 六个接口。
cpp
//pub_sub_service.h
//单个房间信息
class RoomTopic
{
public:
RoomTopic(const string &room_id, const string &room_topic, uint32_t creator_id) {
room_id_ = room_id;
room_topic_ = room_topic;
creator_id_ = creator_id;
}
~RoomTopic() {
user_ids_.clear();
}
void AddSubscriber(uint32_t userid) {
user_ids_.insert(userid);
}
void DeleteSubscriber(uint32_t userid) {
user_ids_.erase(userid);
}
std::unordered_set<uint32_t> &getSubscribers() {
return user_ids_;
}
private:
string room_id_;
string room_topic_;
int creator_id_;
std::unordered_set<uint32_t> user_ids_;
};
using RoomTopicPtr = std::shared_ptr<RoomTopic>;
using PubSubCallback = std::function<void(const std::unordered_set<uint32_t> user_ids)>;
//全局的发布订阅服务
class PubSubService
{
public:
//单例模式
static PubSubService &GetInstance() {
static PubSubService instance;
return instance;
}
PubSubService() {}
~PubSubService(){}
bool AddRoomTopic(const string &room_id, const string &room_topic, int creator_id) {
std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
if (room_topic_map_.find(room_id) != room_topic_map_.end()) {
return false;
}
RoomTopicPtr room_topic_ptr = std::make_shared<RoomTopic>(room_id, room_topic, creator_id);
room_topic_map_[room_id] = room_topic_ptr;
return true;
}
void DeleteRoomTopic(const string &room_id) {
std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
if (room_topic_map_.find(room_id) != room_topic_map_.end()) {
return;
}
room_topic_map_.erase(room_id);
}
在websocket_conn.cc里,建立连接成功后,不仅会把userid加入s_user_ws_conn_map,还会用下面的函数订阅GetRoomList()里面默认存在的五个房间
bool AddSubscriber(const string &room_id, uint32_t userid) {
std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
return false;
}
room_topic_map_[room_id]->AddSubscriber(userid);
return true;
}
void DeleteSubscriber(const string &room_id, uint32_t userid) {
std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
return;
}
room_topic_map_[room_id]->DeleteSubscriber(userid);
}
void PublishMessage(const string &room_id, PubSubCallback callback) {
std::unordered_set<uint32_t> user_ids;
{
std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
return;
}
user_ids = room_topic_map_[room_id]->getSubscribers(); //获取当前房间的所有订阅者
}
callback(user_ids); //发送给所有订阅者
}
static std::vector<Room> &GetRoomList(); //获取当前的房间列表
private:
std::unordered_map<string, RoomTopicPtr> room_topic_map_;
std::mutex room_topic_map_mutex_;
};
(2)消息发送
客户端发送消息到服务端,服务端要把消息解析出来(按照websocket格式解析出frame,再通过json反序列化获取payload_data),写到redis里面,并且重新封装消息后,通过PublishMessage发送到房间里。具体的流程是通过回调函数,将消息发给每一个订阅了 房间的用户。
cpp
//websocket_conn.cc
int CWebSocketConn::handleClientMessages(Json::Value &root)
{
// Json反序列化把json消息解析出来
string room_id;
if(root["payload"].isNull()) {
return -1;
}
Json::Value payload = root["payload"];
if(payload["roomId"].isNull()) {
return -1;
}
room_id = payload["roomId"].asString();
if(payload["messages"].isNull()) {
return -1;
}
Json::Value arrayObj = payload["messages"];
if(arrayObj.isNull()) {
return -1;
}
std::vector<Message> msgs; //消息具体内容 和相关发送信息
uint64_t timestamp = getCurrentTimestamp();
for(int i = 0; i < arrayObj.size(); i++) {
Json::Value message = arrayObj[i];
Message msg;
msg.content = message["content"].asString();
msg.timestamp = timestamp;
msg.user_id = userid_;
msg.username = username_;
msgs.push_back(msg);
}
//持久化 写到redis里面
ApiStoreMessage(room_id, msgs);
//重新组织一个json
root = Json::Value();
payload = Json::Value();
root["type"] = "serverMessages";
payload["roomId"] = room_id;
Json::Value messages;
for(int j = 0; j < msgs.size(); j++) {
Json::Value message;
Json::Value user;
message["id"] = msgs[j].id;
message["content"] = msgs[j].content;
user["id"] = userid_;
user["username"] = username_;
message["user"] = user;
message["timestamp"] = (Json::UInt64)msgs[j].timestamp;
messages[j] = message;
}
if(msgs.size() > 0)
payload["messages"] = messages;
else
payload["messages"] = Json::arrayValue;
root["payload"] = payload;
Json::FastWriter writer;
string str_json = writer.write(root);
//json包装成Websocket帧
std::string response = buildWebSocketFrame(str_json);
//回调函数具体执行 将消息发送给房间的所有订阅者
auto callback = [&response, &room_id, this](const std::unordered_set<uint32_t> user_ids) {
for (uint32_t userId: user_ids)
{
CHttpConnPtr ws_conn_ptr = nullptr;
{
std::lock_guard<std::mutex> ulock(s_mtx_user_ws_conn_map_); //自动释放
ws_conn_ptr = s_user_ws_conn_map[userId];//获取每个用户ID对应的连接
}
if(ws_conn_ptr) {
ws_conn_ptr->send(response);
//如果找到用户的 WebSocket 连接,则调用 send 方法,将消息发送给该用户。
} else
{
LOG_WARN << "can't find userid: " << userId;
}
/* code */
}
};
// 广播给所有人 走回调函数
PubSubService::GetInstance().PublishMessage(room_id, callback);
return 0;
}
2.2.3请求历史消息
返回客户端要求的历史消息条数,count是从客户端发送的 JSON 数据中提取的,如果房间中剩余的消息少于 count,则返回所有剩余的消息。如果房间中剩余的消息多于 count,则只返回 count 条消息。客户端也可以通过动态设置count的值实现分页加载。
cpp
//websocket_conn.cc
//如果hasMoreMessages为ture说明还有更多历史消息可以继续获取,如果hasMoreMessages为false,则没有更多历史消息可以获取。
int CWebSocketConn::handleRequestRoomHistory(Json::Value &root) {
std::string roomId = root["payload"]["roomId"].asString();
std::string firstMessageId = root["payload"]["firstMessageId"].asString();
int count = root["payload"]["count"].asInt();
// 从数据库获取历史消息
string last_message_id;
MessageBatch message_batch;
Room &room_item = rooms_map_[roomId];
room_item.history_last_message_id = firstMessageId;
// 获取房间的消息
ApiGetRoomHistory(room_item, message_batch, count);
root = Json::Value(); //重新置空
// 构建响应
root["type"] = "serverRoomHistory";
Json::Value payload;
payload["roomId"] = room_item.room_id; //聊天室id
payload["name"] = room_item.room_name; //聊天室名称
payload["hasMoreMessages"] = message_batch.has_more; //是否还有更多消息
// 构建消息数组
Json::Value messages(Json::arrayValue);
for (const auto& msg : message_batch.messages) {
Json::Value message;
Json::Value user;
message["id"] = msg.id;
message["content"] = msg.content;
user["id"] = (Json::Int64)msg.user_id;
user["username"] = msg.username;
message["user"] = user;
message["timestamp"] = (Json::UInt64)msg.timestamp;
messages.append(message);
}
if(message_batch.messages.size() > 0)
payload["messages"] = messages;
else
payload["messages"] = Json::arrayValue; //不能为NULL,否则前端报异常
root["payload"] = payload;
std::string response = buildWebSocketFrame(root.toStyledString());
send(response);
return 0;
}
2.2.4创建聊天室
用户可以创建聊天室,所以我们需要把原先写死的room_list,改成一个数据库中的数据表,在数据库中存储起来,所以对应的也就会有很多聊天室相关的字段:room_id(UUID生成),room_name,room_creater。之后在服务器重启的时候,就会从mysql中读出所有的room_list,重新创建房间。
当用户创建聊天室,服务端收到相关的websocket请求,把json解析出来后,分配房间id,通过ApiCreateRoom() 写入到mysql数据库当中(不是redis),再通过PubSubService类的两个函数把新建的聊天室加入到 room_list(负责存储房间信息,给用户展示)和room_topic_map(负责管理订阅关系,发布消息),随后让每一个用户都强制订阅这个聊天室,并且给他们send有新聊天室创建的消息。
(1)业务处理
cpp
//websocket_conn.cc
//请求格式: {"type":"clientCreateRoom","payload":{"roomName":"dpdk教程"}}
//响应格式: {"type":"serverCreateRoom","payload":{"roomId":"3bb1b0b6-e91c-11ef-ba07-bd8c0260908d", "roomName":"dpdk教程"}}
int CWebSocketConn::handleClientCreateRoom(Json::Value &root)
{
LOG_INFO << "handleClientCreateRoom into";
// 把消息解析出来
string roomId;
string roomName;
Json::Value payload = root["payload"];
if(payload.isNull()) {
LOG_WARN << "payload is null";
return -1;
}
// 解析json 解析聊天室的名字
if(payload["roomName"].isNull()) {
LOG_WARN << "roomName is null";
return -1;
}
roomName = payload["roomName"].asString();
// 分配房间id
roomId = generateUUID();
LOG_INFO << "handleClientCreateRoom, roomName: " << roomName << ", roomId: " << roomId;
//存储到数据库
std::string error_msg;
bool ret = ApiCreateRoom(roomId, roomName, userid_, error_msg);
if(!ret ) {
LOG_ERROR << "ApiCreateRoom failed: " << error_msg;
return -1;
}
PubSubService::GetInstance().AddRoomTopic(roomId, roomName, userid_);
// 把新建的聊天室加入到 room_list
Room room;
room.room_id = roomId;
room.room_name = roomName;
room.create_time = getCurrentTimestamp();
room.creator_id = userid_;
PubSubService::AddRoom(room);
//每个人都强制订阅这个聊天室
{
std::lock_guard<std::mutex> lock(s_mtx_user_ws_conn_map_);
rooms_map_.insert({roomId, room});
for(auto it = s_user_ws_conn_map.begin(); it != s_user_ws_conn_map.end(); ++it) {
//房间id, 用户id 订阅
LOG_INFO << "AddSubscriber: " << roomId << ", userid: " << it->first;
PubSubService::GetInstance().AddSubscriber(roomId, it->first);
}
}
//发送消息给所有人,告诉有新的聊天室创建了
//先序列化消息
// Json::Value root;
root = Json::Value(); //重新置空
// Json::Value payload;
payload = Json::Value(); //重新置空
root["type"] = "serverCreateRoom";
payload["roomId"] = roomId;
payload["roomName"] = roomName;
root["payload"] = payload;
//json序列化
Json::FastWriter writer;
string json_str = writer.write(root);
LOG_INFO << "serverCreateRoom: " << json_str;
string response = buildWebSocketFrame(json_str);
//发送 创建聊天室的 roomId 和 roomName,通知所有订阅者有新的聊天室可用
auto callback = [&response, &roomId, this](const std::unordered_set<uint32_t> &user_ids) {
LOG_INFO << "room_id:" << roomId << ", callback " << ", user_ids.size(): " << user_ids.size();
for (uint32_t userId: user_ids) {
CHttpConnPtr ws_conn_ptr = nullptr;
{
std::lock_guard<std::mutex> ulock(s_mtx_user_ws_conn_map_); //自动释放
ws_conn_ptr = s_user_ws_conn_map[userId];
}
if(ws_conn_ptr) {
ws_conn_ptr->send(response);
} else {
LOG_WARN << "can't find userid: " << userId;
}
}
};
PubSubService::GetInstance().PublishMessage(roomId, callback);
return 0;
}
(2)提供数据的api
cpp
//api_room.cc
#include "api_room.h"
#include <sstream>
bool ApiCreateRoom(const std::string& room_id,
const std::string& room_name,
int creator_id,
std::string& error_msg)
{
CDBManager* db_manager = CDBManager::getInstance();
CDBConn* db_conn = db_manager->GetDBConn("chatroom_master");
if (!db_conn) {
error_msg = "无法获取数据库连接";
return false;
}
AUTO_REL_DBCONN(db_manager, db_conn);
std::stringstream ss;
ss << "INSERT INTO room_info (room_id, room_name, creator_id) VALUES ('"
<< room_id << "', '"
<< room_name << "', "
<< creator_id << ")";
if (!db_conn->ExecuteUpdate(ss.str().c_str(), true)) {
error_msg = "创建聊天室失败";
return false;
}
return true;
}
bool ApiGetRoomInfo(const std::string& room_id,
std::string& room_name,
int& creator_id,
std::string& create_time,
std::string& update_time,
std::string& error_msg)
{
CDBManager* db_manager = CDBManager::getInstance();
CDBConn* db_conn = db_manager->GetDBConn("chatroom_slave");
if (!db_conn) {
error_msg = "无法获取数据库连接";
return false;
}
AUTO_REL_DBCONN(db_manager, db_conn);
std::stringstream ss;
ss << "SELECT room_name, creator_id, create_time, update_time "
<< "FROM room_info WHERE room_id='" << room_id << "'";
CResultSet* result_set = db_conn->ExecuteQuery(ss.str().c_str());
if (!result_set) {
error_msg = "查询聊天室信息失败";
return false;
}
if (result_set->Next()) {
room_name = result_set->GetString("room_name");
creator_id = result_set->GetInt("creator_id");
create_time = result_set->GetString("create_time");
update_time = result_set->GetString("update_time");
delete result_set;
return true;
}
delete result_set;
error_msg = "聊天室不存在";
return false;
}
bool ApiGetAllRooms(std::vector<Room>& rooms,
std::string& error_msg,
const std::string& order_by)
{
CDBManager* db_manager = CDBManager::getInstance();
CDBConn* db_conn = db_manager->GetDBConn("chatroom_slave");
if (!db_conn) {
error_msg = "无法获取数据库连接";
return false;
}
AUTO_REL_DBCONN(db_manager, db_conn);
std::stringstream ss;
ss << "SELECT room_id, room_name, creator_id, create_time, update_time "
<< "FROM room_info ORDER BY " << order_by;
CResultSet* result_set = db_conn->ExecuteQuery(ss.str().c_str());
if (!result_set) {
error_msg = "查询聊天室列表失败";
return false;
}
while (result_set->Next()) {
Room room;
room.room_id = result_set->GetString("room_id");
room.room_name = result_set->GetString("room_name");
room.creator_id = result_set->GetInt("creator_id");
room.create_time = result_set->GetString("create_time");
room.update_time = result_set->GetString("update_time");
rooms.push_back(room);
}
delete result_set;
return true;
}
3 分布式
如果有三台服务器分别部署在北京、上海、深圳,然后这三个地方分别有用户A、B、C订阅了1号聊天室,如果A在里面发消息,如何让不同服务器的B和C看见呢?如果后端需要发布公告在1号聊天室,如何让三台不同服务器上的用户看见呢?
首先所有服务器上有聊天室注册上线的时候,都应该在一个"注册服务发现中心"注册自己的信息,比如来自于哪一台服务器,由 "job"服务从注册服务发现中心里面拉取chatroom列表,发起连接,由"grpc"推送消息到chatroom,然后再推送到web客户端。
而如果是后端消息公告的话,会把消息推送到"logic"模块,然后再push发送到Kafka消息队列中,再由job服务从Kafka队列获取数据。其中logic和job服务都可以是多个并发的。

各模块作用:
logic:业务逻辑核心,用户登录注册管理,消息管理。
Kafka:缓存消息
job:指代一个任务线程
grpc:远程过程调用。比如可以调用比如在北京的服务器上编写好的函数,进而让job完成工作
注册服务发现中心:
(1)告知job,chatroom grpc远程服务器的地址。由chatroomID和服务器IP对应。
(2)告知chatroom,可用的logic grpc远程服务器的地址。例如,用户发送一条消息,chatroom
服务需要把消息交给 logic
服务进行处理和广播
nginx:可以在chatroom和Web客户端之间加上nginx反向代理,实现负载均衡,给Web客户端返回负载最低的服务器。
上线前项目存在的问题
出现的问题1:
1.为什么客户端一发消息或者发起创建房间请求,服务端就段错误
出现的问题2:
2.为什么我的/server/bin文件夹下面没有logic和job可执行文件
面试题
- 这个项目框架是怎么样的,为什么这么设计?
- 答:采用了分层分块的框架设计,首先是负责接收和响应客户端的api处理层,有如api_login、api_msg、api_room这样的登录、发送消息、获取房间列表的接口,他们解析HTTP请求,校验参数,然后调用server层处理业务逻辑,最终组装返回HTTP响应。然后是server层,实现具体的业务流程,调用数据访问层获取或者存储数据。最底下是数据访问层,封装了所有对MySQL和Redis的操作,提供接口给服务层调用。这样设计便于维护和扩展。
-
- 整个系统如何实现水平扩展?
- 答:在服务端启用多个实例,监听多个端口。在多个实例前再部署负载均衡器如Nginx,将客户端请求分发给后端的实例。任务处理可以用Kafka消息队列解耦,提升吞吐能力。数据层如Redis可以搭建Redis Cluster水平扩展。
- 网络框架是如何实现的,有没有考虑过Boost.Asio
- 答:采用muduo实现高性能TCP通信,支持HTTP和WebSocket协议。Boost.Asio的接口偏底层,没有muduo网络接口封装的好,易于上手。
-
- 主 Reactor 和 Sub Reactor 如何分工?连接分发策略是什么?如何避免惊群效应?
- 答:主 Reactor 负责监听accept新连接,Sub Reactor 负责已建立连接的数据读写和事件分发。连接分发的策略是,一个连接对应一个子Reactor。采用了one loop per thread模型避免惊群效应,每个子Reactor都只管理自己线程内的连接。
- 如何在 网络框架 中实现连接的空闲超时检测和自动断开?请描述 TimerQueue 的工作原理
- 答:每个连接维护一个定时器,定期检查连接的活跃状态,如果连接在设定时间内没有任何读写事件,则通过定时器回调自动关闭连接,释放资源。TimerQueue 本质是一个最小堆,按照时间排序,定时触发回调。
- 当大量客户端并发连接时,如何优化 TCP 参数(如
SO_REUSEPORT
、TCP_NODELAY
)来提升网络性能? - 答:SO_REUSEPORT:允许多个进程/线程绑定同一端口,提升多核利用率,减少锁竞争,适合高并发场景。
TCP_NODELAY:
减少小包延迟,适合实时性要求高的应用
-
-
- 如果是文件传输,又该如何优化tcp参数(协议栈的发送/接收缓冲区,应用层的缓冲区)?
- 答:增大 协议栈的发送/接收缓冲区,提升传输效率,应用层采用分块/异步读写。
-
- HTTP相关
-
- 如何做登录验证
- 答:通过发送来的邮箱获取数据库中的加密后的密码和对应的salt值,把发送来的密码也与salt结合计算后,比对是否相同,若相同生成cookie后续请求带上表明身份。
- websocket和http有什么区别
- 答:http的短连接,请求-响应模式。WebSocket是长连接,全双工通信,适合实时推送。
- 客户端通过 HTTP 请求注册/登录,你是如何在网络框架中处理 HTTP 协议的?如何解析请求头、请求体?如何返回 JSON 响应?
- 答:网络框架中有专门的 HTTP 解析模块 http_parser,处理HTTP协议。具体的过程是:首先查找
\r\n\r\n
,这是 HTTP 请求头和请求体的分隔符。在分隔符之前,第一行为请求行,后续每一行都是请求头 。请求头中Content-Length
字段,表示请求体的长度,从缓冲区中读取对应长度的数据作为请求体。 返回JSON响应:组装json对象,将json对象序列化为字符串,按HTTP格式拼接响应头和响应体,将完整的HTTP响应发送给客户端。
- 答:网络框架中有专门的 HTTP 解析模块 http_parser,处理HTTP协议。具体的过程是:首先查找
- RPC相关
-
-
grpc远程调用起什么作用
-
答:gRPC 用于 chatroom与 logic和job 模块之间的消息推送、任务分发。提升了服务间通信的性能和开发效率。
-
grpc 目前客户端 -> 服务端 qps多少
-
答:2w
-
项目中用同步方式还是异步方式?qps是否有差别
-
答:异步方式,异步方式下 QPS 明显更高。
-
为什么选择 gRPC 而不是 RESTful API 在chatroom(comet) 和 Logic 之间通信?gRPC 的优势和潜在问题是什么
-
答:优势:相比 RESTful API(基于 HTTP/1.1、文本 JSON),gRPC(基于 HTTP/2 和 Protobuf) 延迟更低、带宽占用更小、接口更规范。 潜在问题:调试不如 REST 直观,跨语言支持需生成代码,协议升级需兼容处理。
-
Job 模块通过 gRPC 调用 chatroom 服务推送消息,你是如何设计 proto 文件的?消息体包含哪些字段?如何处理流式推送
-
答:
pre:gRPC 是一种高性能的远程过程调用(RPC)框架,必须用 proto 文件来定义通信协议。写好proto文件后,用 protoc 工具自动生成代码,Job 和 chatroom 服务都可以用这些代码来收发消息、调用接口。
proto文件设计:用 message 关键字定义了消息体的字段,保证Job、chatroom、logic 等模块之间传递的消息结构一致。用 service 关键字定义了 gRPC 服务接口。定义返回结果的结构。包含的字段:消息体包含:消息ID、房间ID、发送者ID、内容、时间戳、消息类型 字段
流式推送:流式推送可用 gRPC 的 server streaming 或 bidirectional streaming,这样服务端可以持续向客户端推送消息。 -
gRPC 调用失败时(如网络抖动、服务宕机),Job 模块如何实现重试机制?是同步重试还是异步重试?如何避免消息重复?
-
答:通过重试队列和定时任务实现,采用异步重试避免阻塞主流程,每条消息有唯一ID,防止重复推送。
-
gRPC 默认使用 HTTP/2,它相比 HTTP/1.1 在聊天室系统中带来了哪些性能优势?如何监控 gRPC 调用的延迟
-
答:性能上,HTTP/2 支持多路复用、头部压缩、二进制传输,极大提升了并发性能和带宽利用率,降低了延迟,非常适合高并发、实时推送的聊天室场景。
监控方面,可通过 gRPC 自带的拦截器、中间件采集调用延迟、QPS、错误率等指标,并接入 Prometheus、Grafana 等监控系统。
-
- kafka相关
-
- 为何要引入 Kafka?如果不用 Kafka,直接由 Logic 模块调用 Job 模块的 gRPC 接口推送消息,会有什么问题
- 答:Kafka可以有效缓解消息堆积。不用会有:1、强耦合问题,Logic 和 Job 必须同时在线,服务间强依赖,任何一方故障都会影响消息链路。2、扩展性差:gRPC 点对点推送,难以应对大规模并发和多 Job 实例的负载均衡。
- 为什么选择 Kafka 而不是 RabbitMQ 或 RocketMQ?
答:RabbitMQ与RocketMQ 更适合可靠性、实时性强的小消息场景,但吞吐和扩展性不如 Kafka。 - Logic 模块将消息写入 Kafka,如何保证消息不丢失?
答:Kafka Producer 设置acks=all
,确保消息被所有副本写入才算成功。
Kafka集群采用多副本机制,避免单点故障,而且持久化到磁盘。
Job 消费后手动提交 offset,确保消息被处理后才标记为已消费。 - Kafka 的 Topic 如何设计?是按房间 ID 分区,还是全局一个 Topic?分区数量如何确定?
答:
pre:Topic 就是 Kafka 里的"消息分类频道",所有消息都按 Topic 分类存储和消费,是 Kafka 消息流转的核心概念。
全局一个 Topic,这个Topic下分很多区,划分是按房间ID分区,这样可以保证同一个房间的消息在同一个区,保证消息顺序
分区数量设置为机器核数量的倍数。 - Kafka 消费者组(Consumer Group)是如何实现负载均衡的?
答:消费者组里有多个消费者实例。Kafka 会将 Topic 的分区分配给组内的消费者,每个分区只会被组内一个消费者消费。这样可以保证消息不会重复消费,同时实现高并发下的横向扩展。
- redis相关
-
-
Redis 中使用 String 缓存用户登录信息(cookie → user),如何设置过期时间?如果用户登出,如何主动删除缓存?
答:设置过期时间:在set命令后面加上EX秒数。
主动删除缓存:DEL cookie -
为什么选择 Redis Stream 而不是 List 或 Pub/Sub 来存储聊天消息?
答:List 只适合简单的队列,不能高效地做消息回溯和多消费者消费。
Pub/Sub 只支持实时推送,消息不落盘,订阅者不在线就会丢消息,无法拉历史消息。
Stream 结合了队列和消息持久化的优点 -
Redis 如何应对缓存穿透、缓存击穿、缓存雪崩?你采取了哪些措施 缓存穿透:对不存在的数据,查询数据库后也缓存一个空值(如 null),并设置较短过期时间,防止同一个 key 被频繁穿透。
缓存击穿:对热点 key 设置互斥锁(如分布式锁),只有一个线程去查数据库并回填缓存,其他线程等待,避免高并发下数据库被打爆。
缓存雪崩:不同 key 设置不同的过期时间(加随机数),避免大量 key 同时失效;同时监控 Redis 状态,提前预警。
-
Redis 是单线程的,为什么还能支持高并发?在你的系统中,如何监控 Redis 的性能瓶颈(如 bigkey、慢查询)?
答:Redis 虽然是单线程处理命令,但采用了高效的 IO 多路复用(epoll),加上所有操作都是内存级别,单线程反而避免了锁竞争,极大提升了并发处理能力。
检测性能:
bigkey:定期用 redis-cli --bigkeys 工具扫描,发现大 key 并优化数据结构。
慢查询:开启 Redis 慢查询日志(slowlog),分析和优化耗时命令。
-
- MySQL 相关
-
- MySQL 存储用户和房间信息,表结构如何设计?有哪些索引?为什么?
用户表:id(主键,自增)、username(普通索引,模糊查询)、email(唯一)、password_hash、salt、create_time、update_time
房间表:id(主键)、room_id(唯一,UUID)、room_name、creator_id(普通索引,便于查找用户创建的房间)、create_time - 如何通过数据库连接池优化 MySQL 访问?你使用的是哪种连接池 ?连接池大小如何设置?
MySQL内部是一个连接分配一个线程的,所以通过连接池建立多个连接,可以达到在MySQL内部多线程并发的效果。而且连接池可以复用连接,减少频繁创建和销毁连接的开销。
项目中用的是自研的连接池(见 mysql/db_pool.cc、db_pool.h),通过对象池管理 MySQL 连接,池子里维护一个可用连接队列,创建一定数量的MySQL连接放入队列,业务线程从池中获取空闲连接,没有空闲连接则等待,用完后归还连接。自研池 更适合 C++ 项目和高性能场景,代码量小,易于集成和维护。
如果是CPU密集型,线程数设置为CPU核心数+1,如果是IO密集型,线程数设置为2倍核心数。 - 当用户量增长到千万级,如何对 MySQL 进行分库分表?你是按用户 ID 还是房间 ID 分片 ?
有两类表,对于用户相关表,按用户IDhash分片,保证单表数量均衡。对于房间相关表,按房间 ID hash 分片,保证消息和房间分布均匀。 - 如何保证 MySQL 和 Redis 的数据一致性?是先更新 DB 再删缓存,还是先删缓存再更新 DB?为什么?
比如修改张三为李四。
先更新DB再删缓存。尽管这样可能会导致,如果删除缓存失败,会读取到原先缓存中的旧数据张三
但是如果是先删缓存再更新DB,那么如果删除缓存的张三后,更新DB前服务宕机了,那么用户就会到DB里面查到张三,而且同步到Redis缓存里面为张三,此时服务恢复,DB里面的张三改为了李四,则出现MySQL与Redis数据不一致的现象。
- MySQL 存储用户和房间信息,表结构如何设计?有哪些索引?为什么?