一、协议设计的三个目标
任何自定义协议都要回答三个问题:
-
怎么区分不同的操作? ------ 用户要注册还是登录?
-
怎么携带参数? ------ 注册要手机号、用户名、密码,怎么传?
-
怎么知道一条消息到哪结束? ------ 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 的理由:
-
学习价值:HTTP 把连接管理、报文解析、状态码全封装了,看不到底层。自己写 TCP 才能理解三次握手、字节流、粘包。
-
开销更小:HTTP 请求头动辄几百字节,本项目的 JSON 请求才几十字节。头部比正文还大。
-
协议简单 :没有方法、路径、版本号、头部字段,只有一个
type。 -
面试展示:面试官更想看你懂不懂 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 传数据",而是"怎么在字节流里划出消息边界"------长度前缀是最简单可靠的答案,而本项目靠一问一答暂时绕过了这个问题。