【Linux】应用层自定义协议与序列化

本文主题内容

  • 理解应用层协议的作用
  • 理解 TCP 字节流与消息边界
  • 使用长度字段设计完整报文
  • 实现编码、解码与缓冲区解析
  • 理解序列化与反序列化
  • 使用 JSON 表示结构化数据
  • 处理半包、粘包和异常输入

引言:TCP 保证字节可靠有序到达,却不知道哪些字节属于一条完整业务消息。应用层协议的核心任务,就是让通信双方能够从连续字节流中准确识别、解析并处理一条条请求与响应。

一、应用层协议

1.1 协议解决什么问题

Socket 读写的是字节序列,业务处理的却通常是结构化对象。以计算请求为例,至少包含:

  • 左操作数
  • 运算符
  • 右操作数
  • 请求版本或类型

响应可能包含:

  • 计算结果
  • 状态码
  • 错误信息

通信双方必须约定字段的顺序、类型、长度、编码和错误处理方式,这些约定共同组成应用层协议。

结构化业务字段需要按双方约定序列化成字节串,接收端再按相同约定反序列化。

1.2 文本协议与二进制协议

文本协议可读性好,调试方便,例如 HTTP 和 JSON;二进制协议更紧凑,解析速度和类型约束通常更强。

选择协议格式时需要考虑:

  • 可读性与调试成本
  • 报文大小
  • 解析效率
  • 跨语言能力
  • 版本兼容
  • 安全边界

二、TCP 字节流与消息边界

TCP 的每一端都有独立的发送缓冲区和接收缓冲区,writesend 把字节复制到发送缓冲区,readrecv 从接收缓冲区取出字节,因此一条连接可以同时收发;这些缓冲区只保存连续字节,不保存应用层消息边界。

2.1 为什么会出现半包与粘包

TCP 不保留 send 调用边界。发送方连续发送两条消息,接收方可能出现:

  • 第一次只读到半条消息
  • 一次读到一条半消息
  • 一次读到多条完整消息
  • 一条消息被分多次读取

这不是 TCP 异常,而是字节流协议的正常行为。

2.2 常见定界方案

方案 优点 注意事项
固定长度 解析简单 浪费空间,扩展困难
分隔符 可读性好 正文出现分隔符时需要转义
长度字段 通用、高效 必须限制最大长度
自描述格式 扩展性好 解析成本较高

本文使用长度字段:

text 复制代码
正文长度\r\n正文\r\n

例:

text 复制代码
6\r\n10 + 5\r\n

其中长度只统计正文,不统计两个 \r\n

长度字段解决的是从 TCP 字节流中提取完整报文,序列化解决的是结构化对象怎样转换成正文。

三、请求与响应对象

3.1 Request

例:

cpp 复制代码
class Request
{
public:
    Request() = default;

    Request(int x, int y, char op)
        : x_(x), y_(y), op_(op)
    {
    }

    int X() const
    {
        return x_;
    }

    int Y() const
    {
        return y_;
    }

    char Op() const
    {
        return op_;
    }

private:
    int x_ = 0;
    int y_ = 0;
    char op_ = '+';
};

3.2 Response

例:

cpp 复制代码
class Response
{
public:
    int result = 0;
    int code = 0;
    std::string message = "ok";
};

状态码可以约定为:

状态码 含义
0 成功
1 除零错误
2 非法运算符
3 请求格式错误

四、序列化与反序列化

4.1 序列化

序列化是把内存中的对象转换成适合存储或传输的字节序列。不能直接发送 C++ 对象内存,因为对象可能包含指针、填充字节和平台相关布局。

使用 JSON 表示请求:

json 复制代码
{
  "x": 10,
  "y": 5,
  "op": "+"
}

4.2 使用 JsonCpp

例:

cpp 复制代码
#include <json/json.h>
#include <memory>
#include <string>

bool SerializeRequest(const Request &req, std::string *out)
{
    Json::Value root;
    root["x"] = req.X();
    root["y"] = req.Y();
    root["op"] = std::string(1, req.Op());

    Json::StreamWriterBuilder builder;
    builder["indentation"] = "";
    *out = Json::writeString(builder, root);
    return true;
}

反序列化必须检查类型和字段:

cpp 复制代码
bool DeserializeRequest(const std::string &text, Request *req)
{
    Json::CharReaderBuilder builder;
    std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
    Json::Value root;
    std::string errors;

    if (!reader->parse(text.data(), text.data() + text.size(), &root, &errors))
    {
        return false;
    }
    if (!root["x"].isInt() || !root["y"].isInt() || !root["op"].isString())
    {
        return false;
    }

    std::string op = root["op"].asString();
    if (op.size() != 1)
    {
        return false;
    }

    *req = Request(root["x"].asInt(), root["y"].asInt(), op[0]);
    return true;
}

注意:反序列化成功只表示 JSON 语法正确,还要继续验证字段是否存在、类型是否正确、数值是否越界。

五、报文编码

5.1 Encode

例:

cpp 复制代码
const std::string kLineBreak = "\r\n";

std::string Encode(const std::string &payload)
{
    return std::to_string(payload.size()) + kLineBreak
         + payload + kLineBreak;
}

5.2 为什么需要最大长度

长度字段来自不可信网络。如果攻击者声明一个极大长度,服务器可能持续缓存数据并耗尽内存。

例:

cpp 复制代码
constexpr size_t kMaxPayload = 1024 * 1024;

解析长度后必须确认:

  • 长度字符串只包含数字
  • 数值转换没有溢出
  • 长度不超过上限
  • 缓冲区确实包含完整正文和尾部分隔符

六、从缓冲区解析完整报文

服务器为每条连接维护输入缓冲区。每次收到数据后追加到缓冲区,再循环提取零条、一条或多条完整报文。

例:

cpp 复制代码
#include <charconv>
#include <string>

enum class DecodeResult
{
    Ok,
    NeedMore,
    BadMessage
};

DecodeResult DecodeOne(std::string *buffer, std::string *payload)
{
    size_t pos = buffer->find("\r\n");
    if (pos == std::string::npos)
    {
        return DecodeResult::NeedMore;
    }
    if (pos == 0 || pos > 20)
    {
        return DecodeResult::BadMessage;
    }

    size_t length = 0;
    const char *begin = buffer->data();
    const char *end = begin + pos;
    auto [ptr, ec] = std::from_chars(begin, end, length);
    if (ec != std::errc{} || ptr != end || length > kMaxPayload)
    {
        return DecodeResult::BadMessage;
    }

    size_t frameSize = pos + 2 + length + 2;
    if (buffer->size() < frameSize)
    {
        return DecodeResult::NeedMore;
    }

    size_t payloadBegin = pos + 2;
    if (buffer->compare(payloadBegin + length, 2, "\r\n") != 0)
    {
        return DecodeResult::BadMessage;
    }

    *payload = buffer->substr(payloadBegin, length);
    buffer->erase(0, frameSize);
    return DecodeResult::Ok;
}

处理循环:

cpp 复制代码
for (;;)
{
    std::string payload;
    DecodeResult result = DecodeOne(&inbuffer, &payload);
    if (result == DecodeResult::NeedMore)
    {
        break;
    }
    if (result == DecodeResult::BadMessage)
    {
        CloseConnection();
        break;
    }
    HandleRequest(payload);
}

这个循环同时处理半包和粘包:不完整就保留缓冲区,完整就提取并继续解析下一条。

七、业务处理

例:

cpp 复制代码
Response Calculate(const Request &req)
{
    Response resp;
    switch (req.Op())
    {
        case '+':
            resp.result = req.X() + req.Y();
            break;
        case '-':
            resp.result = req.X() - req.Y();
            break;
        case '*':
            resp.result = req.X() * req.Y();
            break;
        case '/':
            if (req.Y() == 0)
            {
                resp.code = 1;
                resp.message = "division by zero";
            }
            else
            {
                resp.result = req.X() / req.Y();
            }
            break;
        default:
            resp.code = 2;
            resp.message = "unsupported operator";
            break;
    }
    return resp;
}

完整处理链路是:

text 复制代码
recv -> 追加输入缓冲区 -> 解码完整报文 -> 反序列化
-> 业务处理 -> 序列化 -> 编码 -> 追加输出缓冲区 -> send

八、协议设计中的工程问题

8.1 版本兼容

协议应预留版本或消息类型字段。新增字段时,接收端应能忽略未知可选字段,必要字段缺失则明确报错。

8.2 部分写

输出缓冲区不一定一次发送完。send 返回部分字节后,只能删除已经发送的前缀,剩余内容继续等待写就绪。

8.3 超时与资源限制

需要限制:

  • 单条报文最大长度
  • 单连接输入缓冲区上限
  • 空闲连接时间
  • 单次解析的报文数量
  • JSON 嵌套深度和字段数量

8.4 错误响应

协议错误、业务错误和服务器错误应该使用不同状态码。严重格式错误通常应关闭连接,避免解析状态失去同步。

九、总结

应用层协议把业务对象转换成双方都能理解的报文。TCP 是字节流,不能依赖一次收发对应一条消息,必须通过固定长度、分隔符或长度字段建立消息边界。

长度前缀协议通常经历编码、接收缓冲、解码、反序列化、业务处理、序列化和发送缓冲等阶段。每一步都必须处理不完整数据和异常输入,并限制报文长度与资源占用。

协议设计的正确性不只体现在正常请求能够解析,更体现在半包、粘包、恶意长度、字段缺失和部分写出现时仍能保持清晰状态。

相关推荐
赴生-1 小时前
Lunix 操作系统 基本指令(一)
linux
春风解人意1 小时前
从零开始学习嵌入式P33----网络基础之HTTP
网络·学习·http
oushaojun21 小时前
大厂 C++ 后端面试:深度拆解 Linux页表 机制(转)
linux
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之Action与命令系统
linux·开发语言·c++·qt·学习·系统架构
fengyehongWorld1 小时前
Linux caddy的安装与配置
linux·运维·服务器
晚风叙码1 小时前
Linux(三)apt/yum 软件安装与 vim 编辑器入门指南
linux·编辑器·vim
洪流之源1 小时前
Windows 远程 Ubuntu 24.04 桌面
linux·windows·ubuntu
2401_862880821 小时前
Linux应用层开发 --- HTTP
linux·运维·http
s_w.h2 小时前
【 linux 】线程互斥与同步
java·linux·服务器·开发语言