C++ 预约系统实战(二):TCP + JSON 自定义协议设计

一、协议设计的三个目标

任何自定义协议都要回答三个问题:

  1. 怎么区分不同的操作? ------ 用户要注册还是登录?

  2. 怎么携带参数? ------ 注册要手机号、用户名、密码,怎么传?

  3. 怎么知道一条消息到哪结束? ------ TCP 是字节流,没有天然的边界。

本项目的协议设计非常简单,用一句话概括:

每个请求和响应都是一个 JSON 对象,通过 type 字段区分操作,通过 TCP 字节流传输,一次 send 对应一条消息。

简单,但有隐患。下面逐个拆。


二、报文格式设计

2.1 请求报文

客户端发给服务端的 JSON,统一格式:

cpp 复制代码
{
    "type": 5,
    "tel": "13500000000",
    "index": 1
}

字段含义:

字段 类型 说明
type int 操作码,路由键
其他 任意 随操作不同而不同

不同 type 对应的参数:

2.2 响应报文

服务端返回的 JSON,两种形态:

通用成功

cpp 复制代码
{ "status": "OK" }

通用失败

cpp 复制代码
{ "status": "ERR" }

带数据的成功(如查看可预约列表):

cpp 复制代码
{
    "status": "OK",
    "num": 3,
    "arr": [
        { "tk_id": "1", "add": "A馆", "max": "10", "num": "3", "use_date": "2026-03-20" },
        { "tk_id": "2", "add": "B馆", "max": "20", "num": "5", "use_date": "2026-03-21" },
        { "tk_id": "3", "add": "C馆", "max": "15", "num": "0", "use_date": "2026-03-22" }
    ]
}

登录成功会额外带用户名

cpp 复制代码
{ "status": "OK", "user_name": "小王" }

2.3 报文结构图

统一 status 字段,让客户端的处理逻辑可以收敛:

cpp 复制代码
string st = val["status"].asString();
if (st.compare("OK") != 0)
{
    cout << "操作失败" << endl;
    return;
}

不管是注册、登录还是预约,客户端都能用同一套判断。这是协议设计的一致性原则


三、type 字段:极简路由

服务端收到请求后,核心就一个 switch

cpp 复制代码
void socket_con::Recv_data()
{
    char buff[256] = {0};
    int n = recv(c, buff, 255, 0);
    if (n <= 0) { delete this; return; }

    Json::Reader Read;
    if (!Read.parse(buff, val)) { Send_err(); return; }

    int ops = val["type"].asInt();
    switch (ops)
    {
    case DL:    User_Login();              break;
    case ZC:    User_Resgister();          break;
    case CKYY:  User_Show_Ticket();        break;
    case YD:    User_Subscribe_Ticket();   break;
    case CKYD:  User_Show_Sub_Ticket();    break;
    case QXYD:  User_Cancel_Sub_Ticket();  break;
    default: break;
    }
}

type 就是路由表的主键switch 就是分发器。这本质上是一个简化版的 RPC:

3.1 枚举定义

cpp 复制代码
enum OP_TYPE { DL=1, ZC, CKYY, YD, CKYD, QXYD, TC };
枚举值 数值 含义
DL 1 登录
ZC 2 注册
CKYY 3 查看可预约
YD 4 预约
CKYD 5 查看我的预约
QXYD 6 取消预约
TC 7 退出

注意:客户端的枚举顺序和服务端必须完全一致。 一旦有一方改了枚举,另一方不改,就会路由到错误的分支------比如客户端想预约,服务端却执行了"查看我的预约"。

这是隐式契约 ,没有任何编译期检查。改进方向是用 .proto 文件或共享头文件强制对齐。

3.2 客户端的 OFFSET 技巧

客户端菜单输入是 1~5,但登录后的操作对应枚举是 3~7

cpp 复制代码
const int OFFSET = 2;

void socket_client::print_info()
{
    if (dl_flg)  // 已登录
    {
        cout << "1.查看预约  2.预定  3.查看我的预约  4.取消预约  5.退出" << endl;
        cin >> user_op;
        user_op += OFFSET;   // 1→3, 2→4, 3→5, 4→6, 5→7
    }
    else         // 未登录
    {
        cout << "1.登陆   2.注册  3.退出" << endl;
        cin >> user_op;      // 1→DL, 2→ZC, 3→TC
        if (user_op == 3) user_op = TC;
    }
}

为什么未登录时 1→DL(1)2→ZC(2) 刚好对上,登录后就要加 2?

四、toStyledString() 的代价

客户端发送时用的是:

cpp 复制代码
send(sockfd, val.toStyledString().c_str(),
     strlen(val.toStyledString().c_str()), 0);

toStyledString() 生成的 JSON 是带缩进和换行的:

cpp 复制代码
{
   "tel" : "13500000000",
   "type" : 5,
   "index" : 1
}

实际字节数:约 60 字节

如果用 toFastString()(或 StreamWriterBuilder 配置紧凑模式):

cpp 复制代码
{"tel":"13500000000","type":5,"index":1}

实际字节数:约 42 字节

差了 30%。 在高频请求场景下,这是实打实的带宽浪费。

4.1 为什么本项目的代码能跑?

因为 jsoncpp 的 Reader::parse() 能正确解析带空白的 JSON。空白在 JSON 规范里只是分隔符,不影响语义。

但有个副作用要注意:toStyledString()对 key 排序 。jsoncpp 内部用 std::map 存 key,所以输出顺序是按字典序,不是插入顺序。这也是为什么测试代码里:

cpp 复制代码
val["name"] = "小王";
val["age"] = 23;

输出变成:

cpp 复制代码
{
   "age" : 23,
   "name" : "小王"
}

age 在前,name 在后------按字母序排的。

4.2 改进方案

cpp 复制代码
Json::StreamWriterBuilder builder;
builder["indentation"] = "";        // 无缩进
string compact = Json::writeString(builder, val);
send(sockfd, compact.c_str(), compact.length(), 0);

五、TCP 粘包问题:为什么现在能跑?

这是本文最重要的一节,也是面试最容易翻车的地方。

5.1 TCP 是字节流,没有消息边界

这就是粘包/半包问题。

5.2 当前代码为什么"看起来没问题"?

cpp 复制代码
// 客户端
send(sockfd, json_str, strlen(json_str), 0);
recv(sockfd, buff, 255, 0);   // 等响应

// 服务端
recv(c, buff, 255, 0);        // 收一个请求
send(c, res_json, strlen(res_json), 0);

能跑通的原因有三:

原因一:一问一答,客户端发完就等

客户端每次 send 后立刻 recv,在响应回来之前不会发第二个请求。所以服务端的 recv 一定对应一个完整的请求。

TCP 的四次挥手机制保证了这一点在当前交互模式下成立。

原因二:数据量远小于缓冲区

请求最长约 100 字节(含密码),响应最长约 4095 字节。TCP 的 MSS(最大段大小)通常 1460 字节,远大于请求。所以一个请求几乎不会跨越多个 TCP 段

但响应就危险了:User_Show_Ticket() 返回的列表可能超过 255 字节,此时客户端用:

cpp 复制代码
char buff[4096] = {0};
int n = recv(sockfd, buff, 4095, 0);

看起来缓冲区够大(4096),但 recv 不保证一次读完。 如果服务端数据被分成两个 TCP 段,客户端只读到了第一段,JSON 解析就会失败。

原因三:单线程 libevent,每个连接一次只处理一个请求

cpp 复制代码
struct event *c_ev = event_new(p->Get_base(), c,
                               EV_READ | EV_PERSIST,
                               SOCK_CON_CALLBACK, q);

EV_PERSIST 保证事件在可读时持续触发。但处理完一个请求后,如果客户端又发了第二个请求,libevent 可能把两个请求的数据一次性读进来

cpp 复制代码
int n = recv(c, buff, 255, 0);   // 可能读到两个 JSON
Read.parse(buff, val);            // 只解析第一个,第二个丢失

5.3 粘包的真实风险场景

把上面的交互模式改一下,问题立刻暴露:

或者数据被拆分:

5.4 解决方案:长度前缀协议

标准做法:每条消息前加固定长度的长度字段

发送方:

cpp 复制代码
void send_msg(int sockfd, const string &json)
{
    uint32_t len = htonl(json.length());    // 转网络字节序
    send(sockfd, &len, 4, 0);               // 先发长度
    send(sockfd, json.c_str(), json.length(), 0);  // 再发数据
}

接收方(关键:完整读取):

cpp 复制代码
bool recv_msg(int sockfd, string &out)
{
    uint32_t len_net = 0;
    if (recv_all(sockfd, (char*)&len_net, 4) != 4) return false;

    uint32_t len = ntohl(len_net);
    if (len > MAX_MSG_SIZE) return false;   // 防御超大包

    out.resize(len);
    return recv_all(sockfd, &out[0], len) == (ssize_t)len;
}

bool recv_all(int sockfd, char *buf, size_t n)
{
    size_t got = 0;
    while (got < n)
    {
        ssize_t r = recv(sockfd, buf + got, n - got, 0);
        if (r <= 0) return false;
        got += r;
    }
    return true;
}

核心思想:先读 4 字节长度,知道消息多大,再循环读满这么多字节。

5.5 其他方案对比

方案 原理 优缺点
长度前缀 消息前加固定长度字段 ✅ 高效,主流;❌ 需要处理字节序
分隔符 \n\0 分隔 ✅ 简单;❌ 数据里不能出现分隔符
定长消息 每条消息固定长度 ✅ 最简单;❌ 浪费空间
自描述格式 如 HTTP 的 Content-Length ✅ 灵活;❌ 解析复杂

本项目现在一个都没用------靠"一问一答 + 数据小"侥幸能跑。面试时这是必问点。


六、为什么不用 HTTP?

有人会问:既然要传 JSON,为什么不用 HTTP + JSON?这样客户端用 libcurl,服务端用 nginx,成熟稳定。

自定义 TCP 的理由

  1. 学习价值:HTTP 把连接管理、报文解析、状态码全封装了,看不到底层。自己写 TCP 才能理解三次握手、字节流、粘包。

  2. 开销更小:HTTP 请求头动辄几百字节,本项目的 JSON 请求才几十字节。头部比正文还大。

  3. 协议简单 :没有方法、路径、版本号、头部字段,只有一个 type

  4. 面试展示:面试官更想看你懂不懂 TCP 和协议设计,而不是会不会调 libcurl。

什么场景该用 HTTP

  • 需要浏览器访问

  • 需要 RESTful 语义(GET/POST/PUT/DELETE)

  • 需要缓存、重定向、认证等标准机制

  • 需要和第三方系统对接

一句话:内部小系统自定义协议够用;对外、浏览器、标准化场景用 HTTP。


七、面试高频题

Q1:TCP 粘包是什么?怎么解决?

答:TCP 是面向字节流的,不保留应用层的消息边界。发送方调两次 send,接收方可能一次 recv 全收到,也可能分多次收到。解决方案是应用层自己定界,常见有长度前缀、分隔符、定长消息。本项目目前靠"一问一答"侥幸没出问题,规范做法是加 4 字节长度前缀。

Q2:为什么 recv 不保证一次读完?

答:recv 只保证"有数据就返回",返回的是当前内核缓冲区里已有的字节数,不保证等于你请求的字节数。要读满 N 字节必须循环 recv,直到累计字节数达到 N。

Q3:JSON 协议有什么优缺点?

答:优点是文本可读、调试方便、语言无关、库成熟;缺点是体积大、解析慢、没有强类型约束。适合小规模、对性能不敏感的场景。高并发、内部 RPC 用 protobuf 更合适。

Q4:为什么客户端和服务端的枚举值必须完全一致?

答:因为 type 是路由键,服务端靠它 switch 分发。如果客户端改了枚举顺序而服务端没改,请求会被路由到错误的分支,且没有任何编译期或运行期检查能发现。改进方案是用共享头文件或 .proto 强制对齐。

八、一句话总结

自定义 TCP 协议的核心不是"用 JSON 传数据",而是"怎么在字节流里划出消息边界"------长度前缀是最简单可靠的答案,而本项目靠一问一答暂时绕过了这个问题。

相关推荐
渡我白衣1 小时前
Util工具类功能设计与类设计
linux·服务器·网络·c++·人工智能·目标检测·机器学习
小王的创意工坊1 小时前
C++ 在线判题平台(OJ)测试报告
c++·可用性测试
sdm0704272 小时前
仿muduo库实现高并发服务器-下
开发语言·网络·c++·多路转接
爱和冰阔落2 小时前
【Linux】手写日志与固定线程池:任务队列、工作线程和安全退出
linux·运维·c++·redis·安卓
All for pursuit.2 小时前
【矩阵-2】240.搜索二维矩阵 II
数据结构·c++·算法·leetcode
程序猿阿森2 小时前
C语言预处理完全指南:宏定义、条件编译与头文件规范,一篇搞懂工程化编程
c语言·c++·编译
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】LLMManager类架构与智能指针选型
网络·c++·人工智能·学习·面试·架构
6Hzlia2 小时前
【Classic 150 刷题计划】 LeetCode 209. 长度最小的子数组 | C++ 滑动窗口(毛毛虫算法)经典模板
c++·算法·leetcode
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 28. 找出字符串中第一个匹配项的下标 | C++ 滑动窗口与子串比对
c++·算法·leetcode