本阶段目标

在 Echo Server 的基础上,设计并实现自定义通信协议,解决 TCP 粘包/拆包问题,让服务端能区分不同类型的消息(登录、聊天、心跳)。


一、为什么需要自定义协议?

回顾:TCP 的天然缺陷

TCP 是字节流,没有消息边界。客户端连续发送两条消息:

1
2
发送1: "hello"
发送2: "world"

服务端可能收到:

1
2
3
情况A(正常):   "hello"    "world"     ← 分开
情况B(粘包): "helloworld" ← 合并成一条
情况C(拆包): "hel" "low" "orld" ← 被切成碎片

如果服务端分不清消息边界,聊天应用根本没法用。

解决方案:给每条消息加”信封”

1
2
3
4
┌──────────────┬──────────────┬────────────────────┐
│ 4字节长度 │ 4字节类型 │ JSON消息体 │
│ "消息有多长" │ "是什么消息" │ "消息的实际内容" │
└──────────────┴──────────────┴────────────────────┘

读取流程:

  1. 先读 4 字节 → 知道消息体长度是 N
  2. 再读 4 字节 → 知道消息类型(登录?聊天?心跳?)
  3. 再读 N 字节 → 正好读完一条完整消息
  4. 重复步骤 1 → 处理下一条

二、消息类型设计

类型值 枚举名 方向 说明 JSON示例
1 LOGIN_REQ 客户端→服务端 登录请求 "tom"
2 LOGIN_RESP 服务端→客户端 登录响应 {"success":true,"username":"tom"}
3 CHAT_MSG 双向 聊天消息 {"from":"tom","text":"你好"}
4 HEARTBEAT 双向 心跳保活 {"pong":true}
5 ERROR_MSG 服务端→客户端 错误提示 {"code":400,"msg":"xxx"}

三、核心代码实现

3.1 协议头文件 protocol.h

定义三个核心东西:

① 消息类型枚举

1
2
3
4
5
6
7
enum class MsgType : uint32_t {
LOGIN_REQ = 1,
LOGIN_RESP = 2,
CHAT_MSG = 3,
HEARTBEAT = 4,
ERROR_MSG = 5,
};

② 打包函数 —— 把消息装进信封

1
2
3
4
5
6
7
8
9
10
11
std::string pack_message(MsgType type, const std::string& json_body) {
uint32_t body_len = htonl(json_body.size()); // 长度 → 大端序
uint32_t type_val = htonl(static_cast<uint32_t>(type)); // 类型 → 大端序

std::string packet;
packet.resize(8 + json_body.size());
memcpy(&packet[0], &body_len, 4); // 写入长度头
memcpy(&packet[4], &type_val, 4); // 写入类型头
memcpy(&packet[8], json_body.data(), json_body.size()); // 写入消息体
return packet;
}

htonl() 是什么? — Host TO Network Long,把主机字节序转成网络字节序(大端)。因为不同 CPU 存储多字节数据的方式不同,统一成大端序才能跨平台通信。

③ 缓冲区类 —— 解决粘包/拆包的核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MessageBuffer {
std::string buffer_; // 内部缓冲区,累积收到的原始字节

void append(const char* data, size_t len) {
buffer_.append(data, len); // 把新收到的数据追加到末尾
}

bool try_parse(ParsedMessage& result) {
if (buffer_.size() < 8) return false; // 头部没收齐
// 读长度头 → 判断消息体收齐没有
// 收齐了 → 解析出来,从缓冲区删掉
// 没收齐 → 返回 false,等更多数据
}
};

关键思路: 缓冲区就像一个”积木盒”,每次 read() 收到新的字节就丢进去,然后不断尝试从里面”拼出”完整消息。

3.2 服务端 chat_server.cpp

相比 Echo Server 的三大升级:

Echo Server chat_server
阻塞 I/O,一次只能服务一个客户端 epoll 非阻塞,同时管理多个客户端
原样回显,不解析内容 按协议解包,区分消息类型
没有状态管理 维护在线用户表,支持广播

epoll 事件循环的核心结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
while (true) {
int n = epoll_wait(epoll_fd, events, MAX, -1); // 等任意 fd 有数据

for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;

if (fd == server_fd) {
// 新连接 → accept() → 加入 epoll
} else {
// 已有客户端发来数据 → read() → 喂入 MessageBuffer → try_parse()
}
}
}

3.3 客户端 chat_client.cpp

双线程设计:

  • 主线程:读取用户键盘输入 → 封装协议 → 发送
  • 接收线程:不断 read()MessageBuffer 解析 → 打印消息
1
2
3
4
5
6
7
8
9
10
11
┌─────────────────┐        ┌─────────────────┐
│ 主线程 │ │ 接收线程 │
│ │ │ │
│ cin >> input │ │ read(sock) │
│ ↓ │ │ ↓ │
│ pack_message() │ TCP │ buffer.append() │
│ ↓ │═══════>│ ↓ │
│ write(sock) │ │ try_parse() │
│ │ │ ↓ │
│ │ │ cout << msg │
└─────────────────┘ └─────────────────┘

四、编译与测试

编译

1
2
3
4
5
6
7
cd backend

# 编译服务端
g++ chat_server.cpp -o chat_server -std=c++17

# 编译客户端
g++ chat_client.cpp -o chat_client -std=c++17 -pthread

启动测试

终端1(服务端):

1
./chat_server

输出:

1
2
3
4
========================================
聊天服务器已启动,端口 8080
等待客户端连接...
========================================

终端2(客户端A - tom):

1
2
3
4
5
6
7
./chat_client
请输入用户名: tom
已连接到聊天服务器!
[登录] {"success":true,"username":"tom","msg":"登录成功"}
[消息] {"system":true,"text":"tom 加入了聊天室"}
开始聊天!(输入 /quit 退出)
----------------------------------------

终端3(客户端B - jerry):

1
2
3
4
5
6
./chat_client
请输入用户名: jerry
[消息] {"system":true,"text":"jerry 加入了聊天室"}
开始聊天!
----------------------------------------
你好啊

此时客户端A(tom)会收到:

1
[消息] {"from":"jerry","text":"你好啊"}

五、和上一阶段的对比

Echo Server 聊天服务端
代码行数 ~140 行 ~250 行
并发能力 1 个客户端 多客户端(epoll)
消息解析 原始字符串 自定义协议解包
消息类型 无区分 登录/聊天/心跳/错误
业务逻辑 回显 登录 + 广播 + 心跳

六、本节小结

学到了什么

知识点 关键理解
自定义协议 长度头 + 类型头 → 解决粘包/拆包,区分消息类型
字节序转换 htonl() / ntohl() → 保证跨平台数据一致性
缓冲区设计 MessageBuffer 累积字节 → 逐条解包
epoll 多路复用 一个线程管理多个连接,谁有数据就处理谁
双线程客户端 主线程发消息 + 接收线程收消息

下一步

加入 JSON 解析库(nlohmann/json),让消息体不再手动拼接字符串,支持结构化的 JSON 数据处理。


上一篇:聊天项目-01-TCP底层通信原理
下一篇:聊天项目-03-JSON序列化与数据库