1 项目地址
项目配套视频简介:程序员老廖的个人空间-程序员老廖个人主页-哔哩哔哩视频 (bilibili.com)
1.1 项目原有功能
https://github.com/anarthal/servertech-chat.git
功能:
- 支持HTTP请求,掌握HTTP API + json的请求相应
- 支持Websocket,掌握json做序列化和反序列化
- 支持多房间聊天
- 支持多人聊天
- 支持MySQL存储用户信息
- 支持Redis缓存token,存储聊天消息
- json序列化
- 静态网页支持
- 支持单元测试
- 支持python脚本性能测试
1.2 建议扩展功能
- 基于Reactor网络模型构建HTTP服务和Websocket服务,替换现有的协程框架;
- 使用rapidjson做序列化和反序列化;
- 仿写MySQL/Redis连接池;
- 增加房间创建/修改/删除接口,并将房间成员存储到MySQL;
- 单元测试替换为gtest;
- ........可以不断扩展,总而言之,就是比做单纯的webserver项目强
2 开发环境
对gcc/g++编译版本要求比较高,建议升级到10.0以后的编译器版本。
Ubuntu 20.04 ,如果Linux没有基础可以参考:Linux C/C++开发环境搭建(系列视频)教程,vscode远程ubuntu调试多个c++文件,让你少走弯路_哔哩哔哩_bilibili
MySQL 8.0
Redis 6.0
gcc/g++ 10.5.0,如果你的编译器版本较低则可以参考【小记】Ubuntu 工具链升级 gcc 流程 - 芯片烤电池 - 博客园 (cnblogs.com) 进行升级。
boost库 1.86版本
3 部署服务端
3.1 安装boost库
该项目依赖boost库,需要先安装boost库,我们从官网下载(也可以从我提供的百度云链接下载)
# 下载
wget https://archives.boost.io/release/1.86.0/source/boost_1_86_0_rc1.zip --no-check-certificate
#解压
unzip -x boost_1_86_0_rc1.zip
#进入boost
cd boost_1_86_0
#配置boost库
./bootstrap.sh
#编译Boost库
./b2
#安装Boost库
sudo ./b2 install
#这将Boost库安装到系统默认的位置(一般是/usr/local)。
3.2 编译聊天室服务
- 下载源码
git clone https://github.com/anarthal/servertech-chat.git
PS:下载时最新的commit 0008f72e9bf7d
- 编译源码
cd servertech-chat/
cd server/
mkdir build
cmake .. -DCMAKE_CXX_STANDARD=17
make
在make时可能会报错,我编译时遇到的报错情况以及修改方法,可以参考以下方法把 **三处报错 **修改后再一起重新编译:
(1)CMake Error at /usr/lib/x86_64-linux-gnu/cmake/Boost-1.71.0/BoostConfig.cmake:117 (find_package): Could not find a configuration file for package "boost_json" that exactly
解决方法:修改servertech-chat/server/CMakeLists.txt,手动指定boost的路径: PATHS /usr/local/lib
大约在14行修改:
find_package(Boost REQUIRED COMPONENTS headers context json regex url PATHS /usr/local/lib)
(2) undefined reference to symbol 'pthread_condattr_setclock@@GLIBC_2.3.3'
undefined reference to `boost::charconv::to_chars(char, char, double, boost::charconv::chars_format)'
解决方法:修改servertech-chat/server/CMakeLists.txt,增加pthread,boost_charconv两个库
大约在67行的target_link_libraries()里添加,如下所示:
target_link_libraries(
servertech_chat
PUBLIC
Boost::headers
Boost::context
Boost::json
Boost::regex
Boost::url
OpenSSL::Crypto
OpenSSL::SSL
ICU::data
ICU::i18n
ICU::uc
boost_charconv
pthread
)
(3)boost库的头文件报错
/usr/local/include/boost/redis/adapter/detail/adapters.hpp 报错
添加 #define _LIBCPP_VERSION
然后重新编译
#确保此时是在servertech-chat/server/build目录
# 删除之前cmake产生的文件,但要注意你一定是在servertech-chat/server/build目录
rm -rf *
#重新cmake配置
cmake .. -DCMAKE_CXX_STANDARD=17
# 重新编译
make
编译成功后产生一个 main的执行文件,就是我们聊天室的服务程序。
现在我们还不能直接运行,还要配置MySQL和Redis。
3.3 配置MySQL和Redis
3.3.1 配置MySQL
- 启动MySQL
如果MySQL没有启动则需要启动
- 修改程序访问MySQL的用户名和密码
/home/lqf/long/servertech-chat/server/src/services/mysql_client.cpp
修改用户和密码,我这里用户名是root,密码123456,所以改成如下所示
- 修改程序访问MySQL的地址
host我们用默认的就行,因为当前部署是在MySQL所在机器部署的
3.3.2 配置Redis
以不需要密码的方式启动redis即可。
3.4 重新编译和启动服务程序
- 重新编译程序
因为我们重新修改了源码文件,所以需要使用make命令重新编译
#确保此时是在servertech-chat/server/build目录
# 重新编译
make
- 启动服务程序
启动服务程序,这里要注意命令格式:
Usage: ./main <address> <port> <doc_root>
Example:
./main 0.0.0.0 8080 .
doc_root的路径一定要设置对,比如./main 0.0.0.0 8080 ../../doc ,即是要正确给出这个项目自带的doc的目录
我目前是在build目录下启动的,因为doc是在servertech-chat目录下,我的启动格式如下所示(8080端口是web客户端调用http api时访问的端口,这里不要改其他的端口):
lqf@ubuntu:~/long/servertech-chat/server/build$ ./main 0.0.0.0 8080 ../../doc
正常启动后(没有信息输出是正常的):
我们光有服务程序还不行,需要在 《4 部署客户端》 继续部署Web客户端,这样才能访问服务程序。
- 查看数据库情况
(这里只是告诉大家这个服务程序对应的数据库名字,以及有哪些表,表结构是怎么样的)
服务程序启动后,数据库servertech_chat不存在则自动创建,我们使用mysql命令进入MySQL命令行控制台,可以查看到数据库servertech_chat被创建了。
数据库只有一个表,用来存储用户信息。
4 部署客户端
需要安装node 16.14以上的版本
4.1 安装node
- 下载node
wget https://cdn.npmmirror.com/binaries/node/v21.7.3/node-v21.7.3-linux-x64.tar.gz
- 解压
tar zxf node-v21.7.3-linux-x64.tar.gz
- 使用node /npm命令生效
创建软链接,注意自己的路径,比如我的node路径是/home/lqf/long/node-v21.7.3-linux-x64
sudo ln -s /home/lqf/long/node-v21.7.3-linux-x64/bin/node /usr/local/bin/node
sudo ln -s /home/lqf/long/node-v21.7.3-linux-x64/bin/npm /usr/local/bin/npm
- 配置国内的源
国内源速度快一些。
# 设置国内源
npm config set registry https://registry.npmmirror.com
# 查看国内源
npm get registry
- 验证安装的版本是否正确
node -v
显示
v21.7.3
npm -v
显示
10.5.0
4.2 部署Web客户端
- 使用npm安装web客户端需要的组件
Web客户端程序目录:servertech-chat/client
安装客户端需要的node组件
# 进入Web客户端代码目录
cd client
# 安装web客户端需要的组件
npm install
- 启动客户端
npm run dev
服务器会将任何匹配 URL http://localhost:3000/api/(.*) 的传入 HTTP 流量路由到位于 http://localhost:8080/api/ 的 C++ 服务器。如果你的 C++ 服务器在不同的端口上运行,请相应地编辑 client/.env.development 文件修改端口。
访问web客户端
在浏览器访问 http://localhost:3000, 如果是在服务器外部访问,则把localhost改成 服务器的ip地址,比如:
http://192.168.1.27:3000
进入界面:
创建账号
登录聊天室
在聊天窗口根据提示发送消息就可以了。
5 项目架构分析
我们主要关注服务端的代码。我们的重点不是学习boost,而是理清楚框架,然后可以改造成自己的聊天室。
get_hello_data获取房间的历史消息
request_room_history_event
5.1 数据存储
MySQL:存储用户信息,在servertech_chat数据库对应的users表。
Redis:存储房间消息和用户cookie
- 房间消息:使用redis的stream结构,key为房间id,value为房间的聊天消息,更多详情参考:Redis Stream | 菜鸟教程 (runoob.com)
- 用户cookie,使用redis的string结构,key为cookie,value为用户id,cookie默认有效期是7天,超过七天redis就将他删除,就需要用户重新登录。
5.2 消息格式
5.2.1 HTTP请求消息格式
create_account创建账号消息
API URL:http:xxx.xxx.xxx.xxx:3000/api/create-account
{
"username": "darren",
"email": "326873713@qq.com",
"password": "xxxxxxx"
}
测试范例:
login登录消息
API URL:http:xxx.xxx.xxx.xxx:3000/api/login
{
"email": "326873713@qq.com",
"password": "xxxxxxx"
}
测试范例:
5.2.2 Websocket交互消息格式
刚websocket连接的消息
服务器回应客户端的数据
{
"type": "hello",
"payload": {
"me": {
"id": 5,
"username": "小鸭子米奇"
},
"rooms": [
{
"id": "beast",
"name": "程序员老廖",
"hasMoreMessages": false,
"messages": [
{
"id": "1726840364728-0",
"content": "222222",
"user": {
"id": 5,
"username": "小鸭子米奇"
},
"timestamp": 1726840364726
},
{
"id": "1726840317055-0",
"content": "222",
"user": {
"id": 5,
"username": "小鸭子米奇"
},
"timestamp": 1726840317055
}
.......
]
},
{
"id": "async",
"name": "Boost.Async",
"hasMoreMessages": false,
"messages": [
{
"id": "1726839255147-0",
"content": "2",
"user": {
"id": 5,
"username": "小鸭子米奇"
},
"timestamp": 1726839255146
},
{
"id": "1726836482227-0",
"content": "22222222",
"user": {
"id": 5,
"username": "小鸭子米奇"
},
"timestamp": 1726836482218
}
]
},
{
"id": "db",
"name": "Database connectors",
"hasMoreMessages": false,
"messages": []
},
{
"id": "wasm",
"name": "Web assembly",
"hasMoreMessages": false,
"messages": []
}
]
}
}
聊天消息格式
发送端:比如用户名:小鸭子米奇,用户id:5发送的消息,此时会携带cookie
{
"type": "clientMessages",
"payload": {
"roomId": "beast",
"messages": [
{
"content": "这是小鸭子发送的消息"
}
]
}
}
经过服务端处理后转发给其他接收者的消息,此时消息类型type 变为“serverMessages”,message字段增加了消息id,并增加了用户信息 "user": { "id": 5, "username": "小鸭子米奇"},,以及时间戳timestamp。
{
"type": "serverMessages",
"payload": {
"roomId": "beast",
"messages": [
{
"id": "1726839290525-0",
"content": "这是小鸭子发送的消息",
"user": {
"id": 5,
"username": "小鸭子米奇"
},
"timestamp": 1726839290524
}
]
}
}
发送端的json数据只所以不带用户信息,是因为其可以通过cookie从redis读取user_id,再根据 user_id去MySQL查询到username,这里这个设计可以了解,但这种做法虽然减少了客户端发送的数据量,但每条消息都访问MySQL对性能有影响的。
5.3 HTTP或者Websocket数据处理
服务端程序入口servertech-chat/server/src/main.cpp的main函数,重点在于launch_http_listener函数。
int main(int argc, char* argv[])
{
........
// 对外提供服务的入口
auto ec = launch_http_listener(ioc.get_executor(), listening_endpoint, st);
........
}
接下来分析launch_http_listener函数的重点内容,这里就是一套tcp server的操作,我们重点是看accept_loop函数。
error_code chat::launch_http_listener(
boost::asio::any_io_executor ex,
boost::asio::ip::tcp::endpoint listening_endpoint,
std::shared_ptr<shared_state> state
)
{
.........
boost::asio::spawn(
std::move(ex),
[acceptor = std::move(acceptor), st = std::move(state)](boost::asio::yield_context yield) mutable {
accept_loop(std::move(acceptor), std::move(st), yield);
},
rethrow_handler // Propagate exceptions to the io_context
);
............
}
继续分析accept_loop(), 我们有tcp server端的基础,应该能理解每个新连接过来,需要通过accept获取新连接,这里我们只关注拿到新连接后怎么处理,即是run_http_session是我们关注的重点
static void accept_loop(
boost::asio::ip::tcp::acceptor acceptor,
std::shared_ptr<chat::shared_state> st,
boost::asio::yield_context yield
)
{
........
while (true)
{
// Accept a new connection
auto sock = acceptor.async_accept(yield[ec]);
if (ec)
return chat::log_error(ec, "accept");
// Launch a new session for this connection. Each session gets its
// own stackful coroutine, so we can get back to listening for new connections.
boost::asio::spawn(
sock.get_executor(),
[state = st, socket = std::move(sock)](boost::asio::yield_context yield) mutable {
//重点在于run_http_session
run_http_session(std::move(socket), std::move(state), yield);
},
rethrow_handler // Propagate exceptions to the io_context
);
}
.......
}
继续分析chat::run_http_session()函数,该函数读取socket数据,然后分析是否是websocket或者http协议,不同的协议调用不同函数处理:
- handle_chat_websocket 聊天的时候是websockt协议
- chat_websocket_session::run() 这里负责读取聊天消息,并转发给房间里的其他人
- 本质是调用event_handler_visitor的error_with_message operator()(client_messages_event& evt)
- chat_websocket_session::run() 这里负责读取聊天消息,并转发给房间里的其他人
- handle_http_request 注册和登录是http协议
- handle_http_request_impl 根据url解析api请求,以http://xxx/api 开头的是http api请求,其他的认为是静态文件请求
5.3.1 HTTP请求处理流程
handle_http_request_impl函数
- api/create-account 创建账号,调用chat::handle_create_account
- 将用户信息写入MySQL
- 生成cookie返回给客户端,并且服务端将该cookie存储在redis,以string类型存储,cookie作为key,用户id作为value。
- api/login 登录账号,调用chat::handle_login:
- 解析json获取邮箱和密码
- 根据邮箱获取用户id,然后校验密码
- 校验成功则生成cookie返回给客户端并存储在服务端。
5.3.2 Websocket处理流程
servertech-chat/server/src/api/chat_websocket.cpp
分析websocket的处理函数event_handler_visitor 的 error_with_message operator()(client_messages_event& evt),这里主要的流程:
- 先把消息存储到std::vector msgs;
- 将消息存储到redis ,调用 result_with_message<std::vectorstd::string> store_messages函数
- 使用XADD把消息加载到redis,其实是stream模式,使用room_id作为key。参考:Redis Stream | 菜鸟教程 (runoob.com)
- redis-cli里,可以使用 XREAD COUNT 3 STREAMS beast 0 来读取beast房间的消息。
- 将redis返回的消息id赋值给msgs,并重新封装成消息
- 将重新封装后带消息id的消息 发给所有的客户端 st.pubsub().publish(evt.roomId, server_evt.to_json());
- chat_websocket_session::on_message
- websocket::write 发送消息给接收端
- chat_websocket_session::on_message
6 项目建议
如果不打算深入理解,只需要把这个项目的流程梳理清楚,然后基于自己的webserver扩展这些逻辑。
扩展建议在《1.2 建议扩展功能》。
通过扩展增加代码量,这样在面试的时候更游刃有余。
本文由博客一文多发平台 OpenWrite 发布!