欢迎光临
我们一直在努力

【C++实战】chat_room_online 多人在线聊天室(上):项目全貌、分层架构与协议层

【C++实战】chat_room_online 多人在线聊天室(上):项目全貌、分层架构与协议层

本系列共三篇,按工程分层顺序拆开,章节编号前后连续,便于对照源码阅读。

篇覆盖章节主要内容
上(本篇) 一 ~ 五(5.1 至 5.3) 项目定位、目录结构、分层架构、文件职责总表、构建脚本、协议与编解码层、网络层拆包
中 五(5.4 至 5.6) 服务端业务层(1062 行)、MySQL 通用连接池、User / Group 模型
下 五(5.7 至 5.12)、六、七 Redis 跨进程投递、服务端入口、客户端、压测器、建表脚本、配置文件、速查与常见追问

整个工程按分层顺序拆成三篇贴源码。上篇先把骨架交代清楚:项目定位、目录结构、分层架构、28 个文件的职责总表,然后把构建脚本、public.hpp 协议定义、crypto 编解码和网络层拆包全部贴完。真正的业务量在后两篇。


一、项目定位

说明
形态 命令行客户端 + 常驻服务端,多终端模拟多用户
网络 服务端 muduo TcpServer + EventLoopThreadPool;客户端 POSIX socket + 双线程
协议 JSON over TCP,外层统一 Message 信封,请求/响应以 seq 配对
传输 默认走 AES-256-GCM 加密帧,编译期可关掉退回 JSON + '\\n' 明文行协议
存储 MySQL 6 张表(用户、好友、群、群成员、消息、离线消息)
跨进程 Redis PUBLISH / SUBSCRIBE,按用户 id 建频道
构建 CMake + build.sh,三个目标:chat_room_server、chat_room_client、bench

功能覆盖账号、好友、群组、私聊、群聊五块,共 17 类请求、35 个消息类型,外加 1 个服务端主动下发的 Chat_Push。


二、目录结构

chat_room_online/
├── CMakeLists.txt # 顶层构建:C++14、构建类型默认值、ENABLE_CRYPTO 开关
├── build.sh # 一键清空 build 并重新编译
├── bin/ # 可执行文件输出,不入库
├── docs/schema.sql # 建库建表脚本
├── include/
│ ├── public.hpp # 两端共用协议:消息类型、返回码、业务结构体
│ ├── crypto.hpp # wire 命名空间:encode / decode 接口
│ └── server/
│ ├── chatserver.hpp # 网络层声明:拆包 + 连接生命周期
│ ├── chatservice.hpp # 业务层声明:handler 注册表 + 在线表
│ ├── db/db.h # MySQL 通用连接池
│ ├── model/user.hpp # 用户模型
│ ├── model/group.hpp # 群组模型
│ └── redis/redis.hpp # Redis 客户端(pub / sub 双连接)
├── src/
│ ├── crypto.cpp # 编解码实现(AES-256-GCM / 明文两条路径)
│ ├── client/
│ │ ├── main.cpp # 客户端全部逻辑(约 870 行)
│ │ ├── CMakeLists.txt
│ │ └── conf/client.conf
│ └── server/
│ ├── main.cpp # 读配置 → 初始化 → 起服务 → 信号退出
│ ├── chatserver.cpp
│ ├── chatservice.cpp
│ ├── CMakeLists.txt
│ ├── db/db.cpp
│ ├── model/user.cpp
│ ├── model/group.cpp
│ ├── redis/redis.cpp
│ └── conf/{server,db,redis}.conf
├── tools/bench.cpp # 分场景压测器
└── thirdparty/json.hpp # nlohmann/json 单头文件(第三方库,正文不收录)

以上 28 个文件合计 4,984 行(不含 thirdparty/json.hpp 的 21,000 行第三方库)。单文件最大的是 chatservice.cpp(1062 行)和 src/client/main.cpp(872 行)。


三、分层架构

从下往上看:连接池和 Redis 是最底层的资源,模型层只描述业务对象本身,业务层把两者编排起来,网络层只管字节流。

#mermaid-svg-dSKVDuuchCxYbsxy{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dSKVDuuchCxYbsxy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dSKVDuuchCxYbsxy .error-icon{fill:#552222;}#mermaid-svg-dSKVDuuchCxYbsxy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dSKVDuuchCxYbsxy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dSKVDuuchCxYbsxy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dSKVDuuchCxYbsxy .marker.cross{stroke:#333333;}#mermaid-svg-dSKVDuuchCxYbsxy svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dSKVDuuchCxYbsxy p{margin:0;}#mermaid-svg-dSKVDuuchCxYbsxy .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-dSKVDuuchCxYbsxy .cluster-label text{fill:#333;}#mermaid-svg-dSKVDuuchCxYbsxy .cluster-label span{color:#333;}#mermaid-svg-dSKVDuuchCxYbsxy .cluster-label span p{background-color:transparent;}#mermaid-svg-dSKVDuuchCxYbsxy .label text,#mermaid-svg-dSKVDuuchCxYbsxy span{fill:#333;color:#333;}#mermaid-svg-dSKVDuuchCxYbsxy .node rect,#mermaid-svg-dSKVDuuchCxYbsxy .node circle,#mermaid-svg-dSKVDuuchCxYbsxy .node ellipse,#mermaid-svg-dSKVDuuchCxYbsxy .node polygon,#mermaid-svg-dSKVDuuchCxYbsxy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dSKVDuuchCxYbsxy .rough-node .label text,#mermaid-svg-dSKVDuuchCxYbsxy .node .label text,#mermaid-svg-dSKVDuuchCxYbsxy .image-shape .label,#mermaid-svg-dSKVDuuchCxYbsxy .icon-shape .label{text-anchor:middle;}#mermaid-svg-dSKVDuuchCxYbsxy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dSKVDuuchCxYbsxy .rough-node .label,#mermaid-svg-dSKVDuuchCxYbsxy .node .label,#mermaid-svg-dSKVDuuchCxYbsxy .image-shape .label,#mermaid-svg-dSKVDuuchCxYbsxy .icon-shape .label{text-align:center;}#mermaid-svg-dSKVDuuchCxYbsxy .node.clickable{cursor:pointer;}#mermaid-svg-dSKVDuuchCxYbsxy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dSKVDuuchCxYbsxy .arrowheadPath{fill:#333333;}#mermaid-svg-dSKVDuuchCxYbsxy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dSKVDuuchCxYbsxy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dSKVDuuchCxYbsxy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dSKVDuuchCxYbsxy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dSKVDuuchCxYbsxy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dSKVDuuchCxYbsxy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dSKVDuuchCxYbsxy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dSKVDuuchCxYbsxy .cluster text{fill:#333;}#mermaid-svg-dSKVDuuchCxYbsxy .cluster span{color:#333;}#mermaid-svg-dSKVDuuchCxYbsxy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dSKVDuuchCxYbsxy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dSKVDuuchCxYbsxy rect.text{fill:none;stroke-width:0;}#mermaid-svg-dSKVDuuchCxYbsxy .icon-shape,#mermaid-svg-dSKVDuuchCxYbsxy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dSKVDuuchCxYbsxy .icon-shape p,#mermaid-svg-dSKVDuuchCxYbsxy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dSKVDuuchCxYbsxy .icon-shape .label rect,#mermaid-svg-dSKVDuuchCxYbsxy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dSKVDuuchCxYbsxy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dSKVDuuchCxYbsxy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dSKVDuuchCxYbsxy :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-dSKVDuuchCxYbsxy .box>*{fill:#eef4ff!important;stroke:#4a6fa5!important;color:#1b2b45!important;}#mermaid-svg-dSKVDuuchCxYbsxy .box span{fill:#eef4ff!important;stroke:#4a6fa5!important;color:#1b2b45!important;}#mermaid-svg-dSKVDuuchCxYbsxy .box tspan{fill:#1b2b45!important;}#mermaid-svg-dSKVDuuchCxYbsxy .store>*{fill:#fff3e6!important;stroke:#b2762f!important;color:#1b2b45!important;}#mermaid-svg-dSKVDuuchCxYbsxy .store span{fill:#fff3e6!important;stroke:#b2762f!important;color:#1b2b45!important;}#mermaid-svg-dSKVDuuchCxYbsxy .store tspan{fill:#1b2b45!important;}

客户端进程 chat_room_client

跨进程投递

主线程菜单 + 发请求 + 等响应

接收线程收包 + 分发 Chat_Push

wire::encode / decode密文帧 或 明文行

Chat_server 网络层muduo TcpServer

ChatService 业务层handlers_ 注册表 + online_ 在线表

User / Group 模型只描述对象状态

MySQL 连接池RAII Guard 借还

Redis 客户端pub 一条 / sub 一条

MySQLchat_room_db

Redischannel = 用户 id

其他服务进程

三层边界的划分依据很简单:网络层只认「一条已经切完的完整消息」,不知道消息里是什么;业务层只认「Message + 连接」,不知道底下是 muduo 还是别的框架;模型层只操作自身字段,不碰网络也不碰 SQL。

同一个私聊请求,完整走一遍是这样的:

接收方客户端RedisMySQLChatServiceChat_server发送方客户端接收方客户端RedisMySQLChatServiceChat_server发送方客户端#mermaid-svg-lT4xwIGt4m0odA0j{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lT4xwIGt4m0odA0j .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lT4xwIGt4m0odA0j .error-icon{fill:#552222;}#mermaid-svg-lT4xwIGt4m0odA0j .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lT4xwIGt4m0odA0j .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lT4xwIGt4m0odA0j .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lT4xwIGt4m0odA0j .marker.cross{stroke:#333333;}#mermaid-svg-lT4xwIGt4m0odA0j svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lT4xwIGt4m0odA0j p{margin:0;}#mermaid-svg-lT4xwIGt4m0odA0j .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lT4xwIGt4m0odA0j text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lT4xwIGt4m0odA0j .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lT4xwIGt4m0odA0j .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lT4xwIGt4m0odA0j .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lT4xwIGt4m0odA0j .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lT4xwIGt4m0odA0j #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lT4xwIGt4m0odA0j .sequenceNumber{fill:white;}#mermaid-svg-lT4xwIGt4m0odA0j #sequencenumber{fill:#333;}#mermaid-svg-lT4xwIGt4m0odA0j #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lT4xwIGt4m0odA0j .messageText{fill:#333;stroke:none;}#mermaid-svg-lT4xwIGt4m0odA0j .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lT4xwIGt4m0odA0j .labelText,#mermaid-svg-lT4xwIGt4m0odA0j .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lT4xwIGt4m0odA0j .loopText,#mermaid-svg-lT4xwIGt4m0odA0j .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lT4xwIGt4m0odA0j .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lT4xwIGt4m0odA0j .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lT4xwIGt4m0odA0j .noteText,#mermaid-svg-lT4xwIGt4m0odA0j .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lT4xwIGt4m0odA0j .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lT4xwIGt4m0odA0j .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lT4xwIGt4m0odA0j .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lT4xwIGt4m0odA0j .actorPopupMenu{position:absolute;}#mermaid-svg-lT4xwIGt4m0odA0j .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-lT4xwIGt4m0odA0j .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lT4xwIGt4m0odA0j .actor-man circle,#mermaid-svg-lT4xwIGt4m0odA0j line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lT4xwIGt4m0odA0j :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}alt[目标在本进程][目标在别的进程][没有任何订阅者]Chat_Private_Req(密文帧)1wire::decode 切帧2handle_message(conn, plain)3handlers_[type] 查表4INSERT messages5online_ 查 to_id 连接6Chat_Push7PUBLISH channel(to_id)8订阅回调 on_redis_message9Chat_Push10INSERT offline_messages11Chat_Private_Rsp12

send_or_offline() 是这三条分支的分叉口:先查本进程在线表,命中直接投递;本进程没有才发到 Redis;publish() 返回订阅者数量为 0,说明对端整体离线,才落离线表。顺序不能反,否则同进程内两个客户端聊天也要绕一圈 Redis。


四、文件职责总表

文件行数职责
CMakeLists.txt 64 顶层构建:C++14、默认 RelWithDebInfo、ENABLE_CRYPTO 开关、muduo 与 Threads 查找、bench 独立目标
build.sh 27 清空 build/ 后 cmake + make -j$(nproc),支持 –no-crypto
src/CMakeLists.txt 4 递归两个子目录
src/server/CMakeLists.txt 23 收集 server/、db/、model/、redis/ 四处源文件,链接 muduo + mysqlclient + hiredis,注入 SERVER_CONF_DIR
src/client/CMakeLists.txt 18 客户端目标,注入 CLIENT_CONF_DIR,只链 pthread(加密时加 crypto)
include/public.hpp 490 两端共用协议:MsgType、StatusCode、Message 信封、17 组业务结构体与它们的 to_json / from_json
include/crypto.hpp 34 wire 命名空间对外接口:init / enabled / encode / decode
src/crypto.cpp 187 编解码实现:明文按 \\n 切,加密按「长度 + IV + 密文 + tag」切
include/server/chatserver.hpp 38 网络层声明:持有 TcpServer 与 EventLoop*,两个私有回调
src/server/chatserver.cpp 61 注册 muduo 回调,循环 wire::decode 拆包,超长报文断开
include/server/chatservice.hpp 96 业务层声明:handlers_ 表、online_ 表、redis_,17 个 handler 原型
src/server/chatservice.cpp 1062 全部业务实现:注册登录、好友、群组、私聊群聊、离线补投
include/server/db/db.h 88 连接池声明:Guard RAII 句柄、acquire、四个执行接口
src/server/db/db.cpp 339 连接池实现:建连、取还、mysql_ping 探活、断线重建、读重试写不重试
include/server/model/user.hpp 45 用户模型:id、fd、昵称、密码、好友列表、群列表
src/server/model/user.cpp 104 用户模型实现:加删好友去重、改密、进出群
include/server/model/group.hpp 44 群组模型:群 id、群主、名称、简介、成员 id 列表
src/server/model/group.cpp 91 群组模型实现:构造时群主入群、转让群主保持成员一致性
include/server/redis/redis.hpp 90 Redis 客户端声明:pub / sub 双连接 + 订阅台账
src/server/redis/redis.cpp 327 Redis 实现:订阅线程、重连补订、publish 返回订阅数
src/server/main.cpp 160 服务端入口:解析配置 → 初始化加密 → 建连接池 → 起 muduo → 信号退出
src/client/main.cpp 872 客户端全部逻辑:菜单、请求、响应配对、推送打印
tools/bench.cpp 575 压测器:ping / register / login / chat 四场景,输出 QPS 与分位延迟
docs/schema.sql 72 建库 chat_room_db 与 6 张表,全部 CREATE TABLE IF NOT EXISTS
src/server/conf/server.conf 14 监听地址、端口、IO 线程数
src/server/conf/db.conf 27 MySQL 地址、库名、账号、字符集、池容量
src/server/conf/redis.conf 21 Redis 地址、超时、唤醒粒度、重连间隔
src/client/conf/client.conf 11 客户端要连的服务器地址与端口

下面按「构建 → 协议 → 网络层 → 业务层 → 数据访问 → 模型 → Redis → 入口 → 客户端 → 压测 → 建表 → 配置」的顺序逐个展开,上篇只到网络层(5.1 ~ 5.3)。


五、逐文件源码

5.1 构建系统

五个 CMake 文件加一个 shell 脚本。真正决定编译行为的是顶层给的标准、构建类型和加密开关,子目录只负责收源文件、注入配置目录宏、链库。

CMakeLists.txt(64 行)

顶层构建。三件事最关键:CMAKE_BUILD_TYPE 兜底成 RelWithDebInfo(留空会整体退化成 -O0,压测差一个量级);显式写 CXX_STANDARD 14(协议结构体用了默认成员初值加聚合初始化);ENABLE_CRYPTO 默认 ON。muduo 用 find_path / find_library 且带 NO_DEFAULT_PATH,找不到就在配置阶段直接报错,不拖到链接。

cmake_minimum_required(VERSION 3.0)
project(chat_room_online)

#导出 compile_commands.json 给 IDE/语言服务器用(它靠这个拿到 include 路径和 C++ 标准,否则满屏红线)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

#默认开优化:build.sh 是裸跑 cmake 的,CMAKE_BUILD_TYPE 为空时整个项目按 -O0 编译,
#压测里能差好几倍,这里兜一个默认值(-O2 + 调试信息)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "构建类型" FORCE)
endif()

#是否启用 AES-256-GCM 加密传输;默认关,保证不改变原有行为
option(ENABLE_CRYPTO "启用 AES-256-GCM 加密传输" ON)

#配置编译选项
set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} -g)

#显式指定 C++ 标准:协议里的结构体用了默认成员初始值 + 聚合初始化,需要 C++14 及以上
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

#配置最终的可执行文件输出路径
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_SOURCE_DIR}/bin)

#配置头文件的搜索路径
include_directories(${CMAKE_SOURCE_DIR}/include)
include_directories(${CMAKE_SOURCE_DIR}/include/server)
include_directories(${CMAKE_SOURCE_DIR}/include/server/db)
include_directories(${CMAKE_SOURCE_DIR}/include/server/model)
include_directories(${CMAKE_SOURCE_DIR}/include/server/redis)
include_directories(${CMAKE_SOURCE_DIR}/thirdparty)
link_directories(/usr/lib64/mysql)

#查找 muduo 的头文件与静态库,找不到就直接报错,避免拖到链接阶段才暴露问题
find_path(MUDUO_INCLUDE_DIR muduo/net/TcpServer.h PATHS /usr/local/include /usr/include NO_DEFAULT_PATH)
find_library(MUDUO_NET_LIB muduo_net PATHS /usr/local/lib NO_DEFAULT_PATH)
find_library(MUDUO_BASE_LIB muduo_base PATHS /usr/local/lib NO_DEFAULT_PATH)
if(NOT MUDUO_INCLUDE_DIR OR NOT MUDUO_NET_LIB OR NOT MUDUO_BASE_LIB)
message(FATAL_ERROR "未找到 muduo,请确认 /usr/local 下已安装 muduo")
endif()

include_directories(${MUDUO_INCLUDE_DIR})

#C++11 线程库需要显式链接 Threads::Threads
find_package(Threads REQUIRED)

#压测器是纯 C(用了 pthread_barrier_t 等 POSIX 接口),显式指定 C 标准;
#不写的话 IDE 拿不到正确的编译参数,bench.c 会满屏红线
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

#加载子目录
add_subdirectory(src)
#CMAkE构建的时候会在这个语句下递归跳转到所穿目录名下的CMAKE文件。

#压测器单独一个目标,固定开 -O2
add_executable(bench tools/bench.cpp ${CMAKE_SOURCE_DIR}/src/crypto.cpp)
target_compile_options(bench PRIVATE -O2)
target_link_libraries(bench pthread)
if(ENABLE_CRYPTO)
target_compile_definitions(bench PRIVATE ENABLE_CRYPTO)
target_link_libraries(bench crypto)
endif()

build.sh(27 行)

一键构建。先 cd "$(dirname "$0")" 保证在任意路径调用都成立,再清空 build/、cmake -DENABLE_CRYPTO=…、make -j$(nproc)。参数只认 –no-crypto,其余一律退出码 2,避免拼错参数却静默按默认值编译。

#!/bin/bash
#指定脚本用bash来解释执行(注释必须单独一行,不能跟在 shebang 后面,否则会被当成 bash 的参数)

set -x #这是bash的内置命令,相当于设置一种模式,这里是每执行一条命令前把命令本身打印出来,方便去构建过程/排查错误
cd "$(dirname "$0")" || exit 1 #dirname "$0"表示这是脚本自身的路径,意思就是说到达脚本所在的目录,
#不管你在哪一个路径下执行 ./build.sh 都有效,因为自动会自动到达这个脚本所在的目录

#$()这种格式叫做命令替换,bash先执行里面括号里面的命令,把输出结果当作字符串类型替换到这里,所以最外层要加一个双引号。

# 加密传输默认开启,传 –no-crypto 可关闭(关闭后编译明文行协议版本,不需要链接 OpenSSL)
CRYPTO=ON
for arg in "$@"; do
case "$arg" in
–no-crypto) CRYPTO=OFF ;;
*) echo "未知参数: $arg(可用: –no-crypto)" >&2; exit 2 ;;
esac
done

mkdir -p "$(pwd)/build" #创建build目录,-p表示就算目录已存在也不会报错

rm -rf "$(pwd)/build"/* #清空build目录里的所有文件(保留build目录本身)
#引号用来保护空格/特殊字符;需要通配符展开的`*` 、`?` 、`[…]` 一定要放在引号外面。

cd "$(pwd)/build" && cmake -DENABLE_CRYPTO=${CRYPTO} .. && make -j"$(nproc)"
# 进 build,cmake .. 读取上一级的 CMakeLists.txt 生成 Makefile,再 make 编译
# 显式传 -DENABLE_CRYPTO:默认值写在 CMakeLists 里,这里传是为了让 –no-crypto 生效
# make -j"$(nproc)" 用满所有核并行编译,比串行快数倍

src/CMakeLists.txt(4 行)

两行 add_subdirectory,把 server/ 与 client/ 递归进来。

#跳转到对应目录下的CMAKELists.txt文件中
add_subdirectory(server)

add_subdirectory(client)

src/server/CMakeLists.txt(23 行)

用 aux_source_directory 分别收集 ./、./db、./model、./redis 四处源文件,加 src/crypto.cpp 编成 chat_room_server。target_compile_definitions 注入 SERVER_CONF_DIR(配置目录绝对路径),链接 muduo_net / muduo_base / mysqlclient / hiredis / Threads,开了加密再加 OpenSSL 的 crypto。

#定义一个SRC_LIST 变量,存储了该目录下的所有源文件
#更准确的来说,这是将左边参数目录路径下的所有文件收集给右边的参数变量中,方便下一步进行编译链接
aux_source_directory(. SRC_LIST)
aux_source_directory(./db DB_LIST)
aux_source_directory(./model MODEL_LIST)
aux_source_directory(./redis REDIS_LIST)

#指定生成可执行文件,即定义一个chat_room_server的可执行目标,用后面列出的所有变量展开后的源文件来编译链接。
add_executable(chat_room_server ${SRC_LIST} ${DB_LIST} ${MODEL_LIST} ${REDIS_LIST} ${CMAKE_SOURCE_DIR}/src/crypto.cpp)

#把配置目录的绝对路径注入成宏 SERVER_CONF_DIR,作为运行时查找配置的默认值;
#启动时传入的 argv[1] 优先级更高,可以覆盖它。
#这是给指定目标追加 -D 宏定义,PRIVATE表示宏只作用于自身的源文件,后面的SERVER_CONF_DIR就是宏名字
target_compile_definitions(chat_room_server PRIVATE SERVER_CONF_DIR="${CMAKE_CURRENT_SOURCE_DIR}/conf")

#指定可执行文件链接时所需要依赖的库文件
target_link_libraries(chat_room_server ${MUDUO_NET_LIB} ${MUDUO_BASE_LIB} mysqlclient hiredis Threads::Threads)

#加密传输:开了开关才链接 OpenSSL 并定义同名宏
if(ENABLE_CRYPTO)
target_compile_definitions(chat_room_server PRIVATE ENABLE_CRYPTO)
target_link_libraries(chat_room_server crypto)
endif()

src/client/CMakeLists.txt(18 行)

同样收源文件编成 chat_room_client,注入 CLIENT_CONF_DIR,只链 pthread(加密时加 crypto)。客户端不需要数据库和 Redis,依赖比服务端干净得多。

#同样的道理,先定义一个SRC_LIST 变量,存储了该目录下的所有源文件
aux_source_directory(. SRC_LIST)

#指定生成可执行文件
add_executable(chat_room_client ${SRC_LIST} ${CMAKE_SOURCE_DIR}/src/crypto.cpp)

#把配置目录的绝对路径注入成宏 CLIENT_CONF_DIR,作为运行时查找配置的默认值;
#启动时传入的 argv[1] 优先级更高,可以覆盖它。
#跟服务端的 SERVER_CONF_DIR 是同一套做法
target_compile_definitions(chat_room_client PRIVATE CLIENT_CONF_DIR="${CMAKE_CURRENT_SOURCE_DIR}/conf")

#指定可执行文件链接时所需要依赖的库文件
target_link_libraries(chat_room_client pthread)

if(ENABLE_CRYPTO)
target_compile_definitions(chat_room_client PRIVATE ENABLE_CRYPTO)
target_link_libraries(chat_room_client crypto)
endif()

5.2 协议与编解码层

public.hpp 是两端唯一的契约,谁都不能私自改字段名;crypto.hpp / crypto.cpp 把「字节流 ↔ 明文」这一层彻底隔离,业务代码不需要知道底下是不是密文。

include/public.hpp(490 行)

全工程最重要的文件。包含四块:MsgType(17 组请求响应 + 1 个 Chat_Push)、StatusCode(14 个返回码)、Message 信封、17 组业务结构体。每个结构体都手写一对 to_json / from_json,没有用宏,好处是字段名与 JSON 键的对应关系一眼可查。from_json 里 msg 与 data 用 j.find() 判存在,允许缺省,避免空响应直接解析失败。msg 与 data 都缺省时仍然能解析出 type / seq / code 三个必填字段。

// 此文件是协议文件,也就是客户端与服务器端之间彼此操作的一些约定。
// 两端必须共用本文件,保证消息类型、返回码、字段名完全一致。
//这里将行为进行了规范,将行为的数据封装成了一个struct结构体,并且对不同消息(结构体)的收发接口进行了json化。
//

// 传输格式: JSON 正文(UTF-8)+自定义分隔符
// 正文示例:{"type":3,"seq":1,"code":0,"msg":"","data":{"name":"tom","passwd":"123"}}
//Req为request即请求,Rsp即Response。
#ifndef PUBLIC_HPP
#define PUBLIC_HPP

#include <string>
#include <vector>

#include "json.hpp"

// 给 nlohmann::json 起个别名,全文件统一用 json,省掉前缀
using json = nlohmann::json;

// 消息类型:请求以 _Req 结尾(request),响应以 _Rsp 结尾(response),成对出现
enum MsgType : int {//指定类型为int
// ———- 用户 ———-
Register_Req = 1, // 注册
Register_Rsp,
Login_Req, // 登录
Login_Rsp,
Logout_Req, // 退出登录
Logout_Rsp,
Deregister_Req, // 注销账号
Deregister_Rsp,
Change_Passwd_Req, // 修改密码
Change_Passwd_Rsp,

// ———- 好友 ———-
Add_Friend_Req, // 添加好友
Add_Friend_Rsp,
Del_Friend_Req, // 删除好友
Del_Friend_Rsp,
Get_Friend_List_Req, // 拉取好友列表
Get_Friend_List_Rsp,

// ———- 群组 ———-
Create_Group_Req, // 建群
Create_Group_Rsp,
Join_Group_Req, // 加入群
Join_Group_Rsp,
Exit_Group_Req, // 退群
Exit_Group_Rsp,
Modify_Group_Req, // 修改群名/群简介
Modify_Group_Rsp,
Transfer_Group_Req, // 转让群主
Transfer_Group_Rsp,
Get_Group_List_Req, // 拉取自己加入的群列表
Get_Group_List_Rsp,
Get_Group_Members_Req, // 拉取某个群的成员
Get_Group_Members_Rsp,

// ———- 聊天 ———-
Chat_Private_Req, // 一对一发送
Chat_Private_Rsp,
Chat_Group_Req, // 群内发送
Chat_Group_Rsp,
Chat_Push // 服务端主动下发的消息(对方发来的 / 离线消息)
};

// 返回码,放在 Message::code 里
enum StatusCode : int {
Ok = 0, // 成功
Param_Error, // 参数缺失或格式错误
User_Not_Exist, // 用户不存在
User_Exist, // 用户名已被占用
Passwd_Error, // 密码错误
Not_Login, // 未登录
Friend_Exist, // 已经是好友
Friend_Not_Exist, // 不是好友
Group_Not_Exist, // 群不存在
Group_Exist, // 群名已存在
Already_Group_Member, // 已经在群里
Not_Group_Member, // 不在群里
No_Group_Permission, // 只有群主能做的操作
Db_Error // 数据库操作失败
};

// 统一消息信封:所有请求/响应都先包一层,业务字段放 data
//所有具体的业务消息都是通过外包一层message进行发送,具体的业务细分由data决定
struct Message {
int type = 0; // MsgType
int seq = 0; // 请求序号,服务端在响应里原样带回,便于客户端匹配
int code = 0; // StatusCode,请求时填 Ok,响应时填结果
std::string msg; // 附加说明,失败时放原因
//协议层采用Niels Lohmann的C加加Json库
json data = json::object(); // 业务字段(见下方各结构体)
//object是一个静态工厂函数,返回一个空的Json对象{}。
//有两个问题:为什么data不直接为空,因为我们设计的时候,就是默认已经有一个json载体,也就是说我们默认Message
//本身就负责JSON对象的创建
//为什么不用空值的Json形式呢,因为这个json的这个空值具有歧义,我这么写,这也是官方推荐使用的
};

inline void to_json(json &j, const Message &m) {
j = json{{"type", m.type}, {"seq", m.seq}, {"code", m.code},
{"msg", m.msg}, {"data", m.data}};
}

inline void from_json(const json &j, Message &m) {
j.at("type").get_to(m.type);//取出 "type" 这个键对应的 JSON 值(引用,不拷贝),把这个值转成 m.type 的类型(int)并写进 m.type
j.at("seq").get_to(m.seq);
j.at("code").get_to(m.code);
// msg 和 data 允许缺省,避免客户端有空响应时直接解析失败
if (j.find("msg") != j.end()) {
j.at("msg").get_to(m.msg);
}
//j.find的作用就是查找是否有这个键,如果没有则是返回j.end
if (j.find("data") != j.end()) {
m.data = j.at("data");
}
}

// ==================== 用户相关 ====================

struct RegisterReq {
std::string name; // 用户名
std::string passwd; // 密码
};

struct LoginReq {
std::string name; // 用户名
std::string passwd; // 密码
};

// 登录成功后返回该用户的完整快照,对应 User 模型里的字段
struct LoginRsp {
int user_id = 0; // 用户 id(后续所有请求都要带)
std::string name; // 用户名
std::vector<int> friend_ids; // friend_ids_
std::vector<int> group_ids; // group_ids_
};

struct LogoutReq {
int user_id = 0;
};

struct DeregisterReq {
int user_id = 0;
std::string passwd; // 注销需要再校验一次密码
};

struct ChangePasswdReq {
int user_id = 0;
std::string new_passwd; // 新密码,对应 User::change_passwd
};

// ==================== 好友相关 ====================

struct AddFriendReq {
int user_id = 0; // 发起者
int friend_id = 0; // 对方
};

struct DelFriendReq {
int user_id = 0; // 发起者
int friend_id = 0; // 对方
};

struct GetFriendListReq {
int user_id = 0;
};

struct GetFriendListRsp {
std::vector<int> friend_ids; // 对应 User::friend_ids()
};

// ==================== 群组相关 ====================

struct CreateGroupReq {
int user_id = 0; // 创建者,自动成为群主
std::string name; // name_
std::string desc; // desc_
std::vector<int> member_ids; // 建群时一并拉进来的成员
};

struct CreateGroupRsp {
int group_id = 0; // 数据库生成的群 id
};

struct JoinGroupReq {
int user_id = 0;
int group_id = 0;
};

struct ExitGroupReq {
int user_id = 0;
int group_id = 0;
};

// name / desc 传空串表示这一项不修改
struct ModifyGroupReq {
int user_id = 0; // 操作者,必须是群主
int group_id = 0;
std::string name; // 对应 Group::set_name
std::string desc; // 对应 Group::set_desc
};

struct TransferGroupReq {
int user_id = 0; // 操作者,必须是原群主
int group_id = 0;
int new_owner_id = 0; // 新群主,对应 Group::set_owner_id
};

struct GetGroupListReq {
int user_id = 0;
};

struct GetGroupListRsp {
std::vector<int> group_ids; // 对应 User::group_ids()
};

struct GetGroupMembersReq {
int user_id = 0; // 操作者,必须是群成员
int group_id = 0;
};

struct GetGroupMembersRsp {
int group_id = 0;
int owner_id = 0; // 对应 Group::owner_id()
std::vector<int> member_ids; // 对应 Group::member_ids()
};

// ==================== 聊天相关 ====================

struct ChatPrivateReq {
int from_id = 0;
int to_id = 0;
std::string content; // 消息正文
long long timestamp = 0; // 客户端发送时间(秒)
};

struct ChatPrivateRsp {
int from_id = 0;
int to_id = 0;
long long timestamp = 0; // 服务端落库时间
};

struct ChatGroupReq {
int from_id = 0;
int group_id = 0;
std::string content;
long long timestamp = 0;
};

struct ChatGroupRsp {
int from_id = 0;
int group_id = 0;
long long timestamp = 0;
};

// 服务端主动下发:群消息 group_id 有效、to_id 为 0;私聊消息 to_id 有效、group_id 为 0
struct ChatPush {
int from_id = 0;
int to_id = 0;
int group_id = 0;
std::string content;
long long timestamp = 0;
};

// ==================== JSON 序列化 ====================
// 客户端发请求时:json j = req; 服务端解析时:auto req = msg.data.get<AddFriendReq>();

inline void to_json(json &j, const RegisterReq &v) {
j = json{{"name", v.name}, {"passwd", v.passwd}};
}
inline void from_json(const json &j, RegisterReq &v) {
j.at("name").get_to(v.name);
j.at("passwd").get_to(v.passwd);
}

inline void to_json(json &j, const LoginReq &v) {
j = json{{"name", v.name}, {"passwd", v.passwd}};
}
inline void from_json(const json &j, LoginReq &v) {
j.at("name").get_to(v.name);
j.at("passwd").get_to(v.passwd);
}

inline void to_json(json &j, const LoginRsp &v) {
j = json{{"user_id", v.user_id}, {"name", v.name},
{"friend_ids", v.friend_ids}, {"group_ids", v.group_ids}};
}
inline void from_json(const json &j, LoginRsp &v) {
j.at("user_id").get_to(v.user_id);
j.at("name").get_to(v.name);
j.at("friend_ids").get_to(v.friend_ids);
j.at("group_ids").get_to(v.group_ids);
}

inline void to_json(json &j, const LogoutReq &v) {
j = json{{"user_id", v.user_id}};
}
inline void from_json(const json &j, LogoutReq &v) {
j.at("user_id").get_to(v.user_id);
}

inline void to_json(json &j, const DeregisterReq &v) {
j = json{{"user_id", v.user_id}, {"passwd", v.passwd}};
}
inline void from_json(const json &j, DeregisterReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("passwd").get_to(v.passwd);
}

inline void to_json(json &j, const ChangePasswdReq &v) {
j = json{{"user_id", v.user_id}, {"new_passwd", v.new_passwd}};
}
inline void from_json(const json &j, ChangePasswdReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("new_passwd").get_to(v.new_passwd);
}

inline void to_json(json &j, const AddFriendReq &v) {
j = json{{"user_id", v.user_id}, {"friend_id", v.friend_id}};
}
inline void from_json(const json &j, AddFriendReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("friend_id").get_to(v.friend_id);
}

inline void to_json(json &j, const DelFriendReq &v) {
j = json{{"user_id", v.user_id}, {"friend_id", v.friend_id}};
}
inline void from_json(const json &j, DelFriendReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("friend_id").get_to(v.friend_id);
}

inline void to_json(json &j, const GetFriendListReq &v) {
j = json{{"user_id", v.user_id}};
}
inline void from_json(const json &j, GetFriendListReq &v) {
j.at("user_id").get_to(v.user_id);
}

inline void to_json(json &j, const GetFriendListRsp &v) {
j = json{{"friend_ids", v.friend_ids}};
}
inline void from_json(const json &j, GetFriendListRsp &v) {
j.at("friend_ids").get_to(v.friend_ids);
}

inline void to_json(json &j, const CreateGroupReq &v) {
j = json{{"user_id", v.user_id}, {"name", v.name},
{"desc", v.desc}, {"member_ids", v.member_ids}};
}
inline void from_json(const json &j, CreateGroupReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("name").get_to(v.name);
j.at("desc").get_to(v.desc);
j.at("member_ids").get_to(v.member_ids);
}

inline void to_json(json &j, const CreateGroupRsp &v) {
j = json{{"group_id", v.group_id}};
}
inline void from_json(const json &j, CreateGroupRsp &v) {
j.at("group_id").get_to(v.group_id);
}

inline void to_json(json &j, const JoinGroupReq &v) {
j = json{{"user_id", v.user_id}, {"group_id", v.group_id}};
}
inline void from_json(const json &j, JoinGroupReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("group_id").get_to(v.group_id);
}

inline void to_json(json &j, const ExitGroupReq &v) {
j = json{{"user_id", v.user_id}, {"group_id", v.group_id}};
}
inline void from_json(const json &j, ExitGroupReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("group_id").get_to(v.group_id);
}

inline void to_json(json &j, const ModifyGroupReq &v) {
j = json{{"user_id", v.user_id}, {"group_id", v.group_id},
{"name", v.name}, {"desc", v.desc}};
}
inline void from_json(const json &j, ModifyGroupReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("group_id").get_to(v.group_id);
j.at("name").get_to(v.name);
j.at("desc").get_to(v.desc);
}

inline void to_json(json &j, const TransferGroupReq &v) {
j = json{{"user_id", v.user_id}, {"group_id", v.group_id},
{"new_owner_id", v.new_owner_id}};
}
inline void from_json(const json &j, TransferGroupReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("group_id").get_to(v.group_id);
j.at("new_owner_id").get_to(v.new_owner_id);
}

inline void to_json(json &j, const GetGroupListReq &v) {
j = json{{"user_id", v.user_id}};
}
inline void from_json(const json &j, GetGroupListReq &v) {
j.at("user_id").get_to(v.user_id);
}

inline void to_json(json &j, const GetGroupListRsp &v) {
j = json{{"group_ids", v.group_ids}};
}
inline void from_json(const json &j, GetGroupListRsp &v) {
j.at("group_ids").get_to(v.group_ids);
}

inline void to_json(json &j, const GetGroupMembersReq &v) {
j = json{{"user_id", v.user_id}, {"group_id", v.group_id}};
}
inline void from_json(const json &j, GetGroupMembersReq &v) {
j.at("user_id").get_to(v.user_id);
j.at("group_id").get_to(v.group_id);
}

inline void to_json(json &j, const GetGroupMembersRsp &v) {
j = json{{"group_id", v.group_id}, {"owner_id", v.owner_id},
{"member_ids", v.member_ids}};
}
inline void from_json(const json &j, GetGroupMembersRsp &v) {
j.at("group_id").get_to(v.group_id);
j.at("owner_id").get_to(v.owner_id);
j.at("member_ids").get_to(v.member_ids);
}

inline void to_json(json &j, const ChatPrivateReq &v) {
j = json{{"from_id", v.from_id}, {"to_id", v.to_id},
{"content", v.content}, {"timestamp", v.timestamp}};
}
inline void from_json(const json &j, ChatPrivateReq &v) {
j.at("from_id").get_to(v.from_id);
j.at("to_id").get_to(v.to_id);
j.at("content").get_to(v.content);
j.at("timestamp").get_to(v.timestamp);
}

inline void to_json(json &j, const ChatPrivateRsp &v) {
j = json{{"from_id", v.from_id}, {"to_id", v.to_id},
{"timestamp", v.timestamp}};
}
inline void from_json(const json &j, ChatPrivateRsp &v) {
j.at("from_id").get_to(v.from_id);
j.at("to_id").get_to(v.to_id);
j.at("timestamp").get_to(v.timestamp);
}

inline void to_json(json &j, const ChatGroupReq &v) {
j = json{{"from_id", v.from_id}, {"group_id", v.group_id},
{"content", v.content}, {"timestamp", v.timestamp}};
}
inline void from_json(const json &j, ChatGroupReq &v) {
j.at("from_id").get_to(v.from_id);
j.at("group_id").get_to(v.group_id);
j.at("content").get_to(v.content);
j.at("timestamp").get_to(v.timestamp);
}

inline void to_json(json &j, const ChatGroupRsp &v) {
j = json{{"from_id", v.from_id}, {"group_id", v.group_id},
{"timestamp", v.timestamp}};
}
inline void from_json(const json &j, ChatGroupRsp &v) {
j.at("from_id").get_to(v.from_id);
j.at("group_id").get_to(v.group_id);
j.at("timestamp").get_to(v.timestamp);
}

inline void to_json(json &j, const ChatPush &v) {
j = json{{"from_id", v.from_id}, {"to_id", v.to_id},
{"group_id", v.group_id}, {"content", v.content},
{"timestamp", v.timestamp}};
}
inline void from_json(const json &j, ChatPush &v) {
j.at("from_id").get_to(v.from_id);
j.at("to_id").get_to(v.to_id);
j.at("group_id").get_to(v.group_id);
j.at("content").get_to(v.content);
j.at("timestamp").get_to(v.timestamp);
}

#endif

include/crypto.hpp(34 行)

wire 命名空间的对外接口,只有四个函数:init(64 位 hex 密钥)、enabled(编译期开关的运行时反映)、encode(明文换线上字节)、decode(从缓冲区尝试解一帧)。kMaxFrame 定 1 MB,防止畸形长度头把内存撑爆。接口刻意做成「一次 decode 只解一帧,返回吃掉的字节数」,把粘包半包的责任留给调用方循环。

#ifndef CRYPTO_HPP
#define CRYPTO_HPP

#include<cstddef>
#include<string>

//线上字节流的编解码层
//不开 ENABLE_CRYPTO 时就是原来的「JSON + '\\n'」,开了以后每条消息独立加密封装
//业务代码只认 encode / decode,不关心底下是不是密文
namespace wire {

//单帧上限,防止畸形长度头把内存撑爆
const size_t kMaxFrame = 1024 * 1024;

//初始化密钥:64 位十六进制(32 字节);传空串用内置默认值
//返回 false 表示密钥格式不对
bool init(const std::string &hex_key = "");

//当前编译进来的版本是否启用了加密
bool enabled();

//明文 → 线上字节
// 明文模式:plain + '\\n'
// 加密模式:长度(4,大端) + IV(12) + 密文 + 认证标签(16)
std::string encode(const std::string &plain);

//从缓冲区里尝试解出一帧
// 返回 true :consumed 为本次吃掉的字节数,plain 为解出的明文
// 返回 false:还不够一帧(或认证明文校验失败),调用方应继续收数据或断开
bool decode(const char *buf,size_t avail,size_t &consumed,std::string &plain);

}

#endif

src/crypto.cpp(187 行)

两条路径的实现。明文模式 encode 就是 plain + '\\n',decode 用 memchr 找换行;加密模式 encode 先随机 12 字节 IV、EVP_EncryptUpdate 出密文、取 16 字节 tag,再拼成 len(4, 大端) + IV(12) + ciphertext + tag(16)。decode 先判长度够不够,够才 EVP_DecryptFinal_ex,认证失败直接返回 false 让上层断开——既不吐明文也不泄漏细节。密钥默认内置,init() 传 64 位 hex 可外部覆盖。

#include"crypto.hpp"

#include<cstdint>
#include<cstdlib>
#include<cstring>
#include<iostream>

#ifdef ENABLE_CRYPTO
#include<openssl/evp.h>
#include<openssl/rand.h>
#endif

namespace wire {
namespace {

#ifdef ENABLE_CRYPTO
const size_t kKeyLen = 32;
const size_t kIvLen = 12;
const size_t kTagLen = 16;
//帧头 4 + IV + 标签,长度头本身不算在 body 里
const size_t kOverhead = 4 + kIvLen + kTagLen;
const size_t kHeadLen = 4 + kIvLen;

//内置默认密钥,编译期常量;生产环境应该用 init() 传自己的密钥覆盖
const char *kDefaultKeyHex =
"6b1f8c2d4a7e90315c8b6d2f4a1e7039c5b8d2a6f4e1093857c2b6d4a8f0e1c3";

unsigned char g_key[kKeyLen];
bool g_ready = false;

bool hex_decode(const std::string &hex,unsigned char *out,size_t n){
if(hex.size() != n * 2){
return false;
}
for(size_t i = 0;i < n;++i){
const char pair[3] = {hex[i * 2],hex[i * 2 + 1],'\\0'};
char *end = nullptr;
const long v = strtol(pair,&end,16);
if(end == pair || v < 0 || v > 255){
return false;
}
out[i] = static_cast<unsigned char>(v);
}
return true;
}
#endif

}

bool enabled(){
#ifdef ENABLE_CRYPTO
return true;
#else
return false;
#endif
}

bool init(const std::string &hex_key){
#ifdef ENABLE_CRYPTO
const std::string k = hex_key.empty() ? std::string(kDefaultKeyHex) : hex_key;
if(!hex_decode(k,g_key,kKeyLen)){
std::cerr << "密钥格式错误:需要 64 位十六进制字符(32 字节)" << std::endl;
return false;
}
g_ready = true;
return true;
#else
(void)hex_key;
return true;
#endif
}

std::string encode(const std::string &plain){
#ifdef ENABLE_CRYPTO
if(!g_ready){
return std::string();
}

unsigned char iv[kIvLen];
if(RAND_bytes(iv,kIvLen) != 1){
return std::string();
}

//密文长度与明文相同(GCM 是流式认证加密,不填充),预留足量空间
std::string out(kHeadLen + plain.size() + kTagLen,'\\0');
unsigned char *base = reinterpret_cast<unsigned char*>(&out[0]);
unsigned char *ct = base + kHeadLen;

int clen = 0;
int flen = 0;
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if(ctx == nullptr){
return std::string();
}
const bool ok =
EVP_EncryptInit_ex(ctx,EVP_aes_256_gcm(),nullptr,g_key,iv) == 1
&& EVP_EncryptUpdate(ctx,ct,&clen,
reinterpret_cast<const unsigned char*>(plain.data()),
static_cast<int>(plain.size())) == 1
&& EVP_EncryptFinal_ex(ctx,ct + clen,&flen) == 1
&& EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_GET_TAG,kTagLen,ct + clen + flen) == 1;
EVP_CIPHER_CTX_free(ctx);
if(!ok){
return std::string();
}

const uint32_t body = static_cast<uint32_t>(kIvLen + clen + flen + kTagLen);
memcpy(base + 4,iv,kIvLen);
base[0] = static_cast<unsigned char>((body >> 24) & 0xFF);
base[1] = static_cast<unsigned char>((body >> 16) & 0xFF);
base[2] = static_cast<unsigned char>((body >> 8) & 0xFF);
base[3] = static_cast<unsigned char>(body & 0xFF);

out.resize(4 + body);
return out;
#else
std::string out = plain;
out.push_back('\\n');
return out;
#endif
}

bool decode(const char *buf,size_t avail,size_t &consumed,std::string &plain){
#ifdef ENABLE_CRYPTO
if(!g_ready || avail < 4){
return false;
}

const unsigned char *p = reinterpret_cast<const unsigned char*>(buf);
const uint32_t body = (static_cast<uint32_t>(p[0]) << 24)
| (static_cast<uint32_t>(p[1]) << 16)
| (static_cast<uint32_t>(p[2]) << 8)
| static_cast<uint32_t>(p[3]);

//长度头不合法:小于最小开销,或超过上限
if(body < kIvLen + kTagLen || body > kMaxFrame){
return false;
}
if(avail < 4 + static_cast<size_t>(body)){
return false;
}

const unsigned char *iv = p + 4;
const unsigned char *ct = p + kHeadLen;
const int clen = static_cast<int>(body) – static_cast<int>(kIvLen) – static_cast<int>(kTagLen);

plain.assign(static_cast<size_t>(clen > 0 ? clen : 0),'\\0');
int outlen = 0;
int flen = 0;
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if(ctx == nullptr){
return false;
}
unsigned char *dst = clen > 0 ? reinterpret_cast<unsigned char*>(&plain[0]) : nullptr;
const bool ok =
EVP_DecryptInit_ex(ctx,EVP_aes_256_gcm(),nullptr,g_key,iv) == 1
&& (clen == 0 || EVP_DecryptUpdate(ctx,dst,&outlen,ct,clen) == 1)
//标签必须用可写指针传进去,OpenSSL 的接口签名如此
&& EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_SET_TAG,kTagLen,
const_cast<unsigned char*>(ct + clen)) == 1
&& EVP_DecryptFinal_ex(ctx,dst != nullptr ? dst + outlen : nullptr,&flen) == 1;
EVP_CIPHER_CTX_free(ctx);
if(!ok){
//认证失败:内容被篡改或密钥不对,整帧丢弃
return false;
}

plain.resize(static_cast<size_t>(outlen + flen));
consumed = 4 + static_cast<size_t>(body);
return true;
#else
for(size_t i = 0;i < avail;++i){
if(buf[i] == '\\n'){
size_t n = i;
if(n > 0 && buf[n – 1] == '\\r'){
—n;
}
plain.assign(buf,n);
consumed = i + 1;
return true;
}
}
return false;
#endif
}

}

5.3 服务端网络层

只做三件事:注册回调、循环拆包、连接断开时通知业务层。整个文件没有一个 SQL 关键字,也没有一次 JSON 字段解析。

include/server/chatserver.hpp(38 行)

Chat_server 组合 muduo 的 TcpServer 和一个 EventLoop*,两个回调都是私有:on_connection 管连接建立与断开,on_message 管数据到达。构造参数直接是 ip / port / 名字 / 线程数,setThreadNum 在构造函数里就设好。

//这一层是负责网络传输层,也就是说将服务器的功能进行细分,
//这一层只负责服务器与客户端的连接与数据传输,不管用户具体的业务。
#ifndef CHATSERVER_H
#define CHATSERVER_H
//这是muduo库的tcp服务器类
#include<muduo/net/TcpServer.h>
//里面包含了:
//Acceptor(持有监听socket,负责accept()新连接)
//EventLoopThreadPool 包含vector存储的固定式线程池组件,里面的每个子线程都有一个轮询调度的i/o。
//EventLoop* loop_ 主线程的事件循环,只管accept,不处理请求
#include<muduo/net/EventLoop.h>
#include<string>
#include<cstdint>
//事件循环,muduo的核心,一个EventLoop绑定一个线程,内部跑epoll_wait。

class Chat_server{
private:
//上报链接相关信息的回调函数
//回调函数的意思是:你不是主动去调用它,而是muduo在特定事件发生时替你调用它
//里面的参数指的是之管理一个连接对象的智能指针
//负责数据的连接以及断开
void on_connection(const muduo::net::TcpConnectionPtr &);
//智能指针加上缓冲区和时间戳
//负责数据的到达与处理
void on_message(const muduo::net::TcpConnectionPtr &, muduo::net::Buffer *, muduo::Timestamp);

muduo::net::TcpServer server_; //组合muduo库中的tcp服务器类对象
muduo::net::EventLoop * loop_; //事件循环对象,也就是之前cs项目事件轮询

public:
Chat_server(muduo::net::EventLoop * loop, const std::string &ip, uint16_t port, const std::string &nameArg, int threads);
~Chat_server();

//启动服务器
void start();
};

#endif

src/server/chatserver.cpp(61 行)

构造函数里把两个回调绑成 lambda 转调私有方法。on_message 的核心是一个 while (wire::decode(…)) 循环:decode 返回 true 就 buf->retrieve(consumed) 吃掉这段并交给 ChatService::handle_message,返回 false 说明还不够一帧,原地等下次数据。循环后再判 readableBytes() > 1 MB 就 forceClose(),防止客户端一直发不带边界的垃圾把缓冲区撑爆。连接断开时只调 client_close(conn),清理逻辑全在业务层。

#include"crypto.hpp"
#include"chatserver.hpp"
#include"chatservice.hpp"

#include<muduo/net/InetAddress.h>

#include<iostream>
#include<string>

using namespace std;
using namespace muduo::net;

namespace{
//单条消息在缓冲区里迟迟等不到换行时的上限,超过就判定为畸形报文并断开
const size_t kMaxBufferBytes = 1024 * 1024;
}

Chat_server::Chat_server(EventLoop *loop,const string &ip,uint16_t port,const string &nameArg,int threads)
:server_(loop,InetAddress(ip,port),nameArg),loop_(loop){
server_.setThreadNum(threads);

//这两个回调由 muduo 在事件发生时调用,这里只做转发,业务全部交给 ChatService
server_.setConnectionCallback(
[this](const TcpConnectionPtr &conn){ on_connection(conn); });
server_.setMessageCallback(
[this](const TcpConnectionPtr &conn,Buffer *buf,muduo::Timestamp){ on_message(conn,buf,muduo::Timestamp()); });
}

Chat_server::~Chat_server() = default;

void Chat_server::start(){
server_.start();
}

void Chat_server::on_connection(const TcpConnectionPtr &conn){
if(conn->connected()){
cout << conn->peerAddress().toIpPort() << " 已连接" << endl;
}else{
cout << conn->peerAddress().toIpPort() << " 已断开" << endl;
//连接的清理属于业务层,网络层只负责通知
ChatService::instance()->client_close(conn);
}
}

void Chat_server::on_message(const TcpConnectionPtr &conn,Buffer *buf,muduo::Timestamp){
//客户端固定以 '\\n' 结尾,所以用 findEOL 而不是 findCRLF
//拆包统一交给 wire::decode:明文模式按 '\\n' 切,加密模式按长度头切
size_t consumed = 0;
string plain;
while(wire::decode(buf->peek(),buf->readableBytes(),consumed,plain)){
buf->retrieve(consumed);
if(!plain.empty()){
ChatService::instance()->handle_message(conn,plain);
}
}

if(buf->readableBytes() > kMaxBufferBytes){
cout << "报文过长,主动断开连接" << endl;
conn->forceClose();
}
}


下一篇:(中)服务端核心。ChatService 怎么用一张表把 17 个请求分派出去、连接池怎么做到「借还不断线」、User 与 Group 两个模型如何只靠 id 互相引用而不暴露头文件。

赞(0)
未经允许不得转载:171主机测评 » 【C++实战】chat_room_online 多人在线聊天室(上):项目全貌、分层架构与协议层
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址