【Linux网络】JsonCpp 怎么实现序列化?从 Request/Response 到网络计算器消息设计

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录


前言

前面已经约定了网络计算器的请求与应答字段。现在用 JsonCpp 把这些字段转成字节序列,再由接收端按相同规则还原。

这里沿用网络计算器的 Request、Response,在 Ubuntu、GCC 11.4.0、JsonCpp 1.9.5-3 上运行,使用 C++17。实际检查了 10+20 请求的序列化往返、缺失字段和错误字段类型;计算模块也分别返回了正常结果、除零及溢出状态。本文保留 Reader、FastWriter 等现有接口,先把这些已经用到的写法讲明白,不把不同库版本的接口混在一起。

一、JSON 与 JsonCpp

前面用空格分隔操作数和运算符,已经能完成简单的序列化。字段复杂以后,自己处理引号、转义和嵌套会比较麻烦,这里改用 JSON。

**JSON 是数据格式,JsonCpp 是 C++ 中处理这种格式的库。**我们仍然负责约定字段名称和含义,库负责生成、解析 JSON 文本。

下面先保留几个独立的小例子,观察输出形式,再把同样的方法放进 Request 和 Response。每个带 main 的例子分别保存、编译,不要合并进同一个源文件。

1.1 安装与头文件

bash 复制代码
sudo apt-get install libjsoncpp-dev
# 使用 yum 的发行版按仓库情况安装
sudo yum install jsoncpp-devel

JSON 对象使用键值对,键与值之间用冒号分隔,键必须使用双引号:

json 复制代码
{
 "name": "张三",
 "age": 23,
 "gender": true
}

安装完成后,先把下面任意一个小例子保存成 json_demo.cc,单独编译:

bash 复制代码
g++ -std=c++17 -Wall -Wextra json_demo.cc -ljsoncpp -o json_demo
./json_demo

这条命令对应文中的 <jsoncpp/json/json.h> 路径。**头文件能找到,与链接时能找到库,是两步不同的检查。**缺少头文件时先检查开发包与 include 路径;出现 undefined reference 时再检查链接选项,不能只反复修改 include。

如果本机的包提供了 pkg-config 信息,可以用 pkg-config --modversion jsoncpp 查看版本,用 pkg-config --cflags --libs jsoncpp 查看安装路径和链接参数。本文实际使用的包版本已在前言说明;在其他机器上操作时,仍应以本机安装路径为准。

1.2 序列化

序列化指的是将数据结构或对象转换为一种格式,以便在网络上传输或存储到文件中。Jsoncpp提供了多种方式进行序列化:

toStyledString:先观察字段和值

Json::Value 保存 JSON 对象,调用 toStyledString() 就能得到便于阅读的字符串:

cpp 复制代码
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
    Json::Value root;
    root["name"] = "张三";
    root["sex"] = "男";
    root["age"] = 18;

    std::string s = root.toStyledString();
    std::cout << s << std::endl;
    return 0;
}

这就将结构化数据转换成字符串了。

StreamWriter:把内容写到流中

需要配置输出形式时,可以使用 StreamWriterBuilder 创建 writer,再把结果写入流:

cpp 复制代码
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
    Json::Value root;
    root["name"] = "joe";
    root["sex"] = "男";
    Json::StreamWriterBuilder wbuilder; // StreamWriter的工厂
    std::unique_ptr<Json::StreamWriter>
    writer(wbuilder.newStreamWriter());
    std::stringstream ss;
    writer->write(root, &ss);
    std::cout << ss.str() << std::endl;
    return 0;
}

下面是对应示例输出:

text 复制代码
$ ./test.exe
{
"name" : "joe",
"sex" : "男"
}

FastWriter 与 StyledWriter:内容相同,显示形式不同

FastWriter 输出较紧凑;StyledWriter 方便直接查看层级。格式化空格和末尾换行也是字节,封包时要以实际字符串的 size() 计算长度。

cpp 复制代码
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
    Json::Value root;
    root["name"] = "joe";
    root["sex"] = "男";
    Json::FastWriter writer;
    std::string s = writer.write(root);
    std::cout << s << std::endl;
    return 0;
}

下面是对应示例输出:

text 复制代码
$ ./test.exe
{"name":"joe","sex":"男"}

下面改用 StyledWriter,观察格式化输出:

cpp 复制代码
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
    Json::Value root;
    root["name"] = "张三";
    root["sex"] = "男";
    root["age"] = 18;

    // Json::FastWriter writer;
    Json::StyledWriter writer;
    std::string s = writer.write(root);
    std::cout << s << std::endl;
    return 0;
}

FastWriter 的输出:

StyledWriter:\u5f20\u4e09 就是 张三 ,\u7537 就是 男 。

这张截图里的写出配置使用 Unicode 转义:把中文转成 unicode 转义序列,不是乱码。

\u5f20\u4e09 是 JSON 中的 Unicode 转义。反序列化后仍能得到"张三",不能仅凭输出里有反斜线就判断编码出错。

1.3 反序列化

反序列化指的是将序列化后的数据重新转换为原来的数据结构或对象。Jsoncpp提供了以下方法进行反序列化:

先看 Json::Reader。parse 返回失败时,不要继续提取字段,可以通过错误信息定位输入中的问题。

cpp 复制代码
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main() {
    // JSON 字符串
    std::string json_string = R"({"name":"张三", "age":30, "city":"北京"})";
    // 解析 JSON 字符串
    Json::Reader reader;
    Json::Value root;
    // 从字符串中读取 JSON 数据
    bool parsingSuccessful = reader.parse(json_string, root);
    if (!parsingSuccessful) {
        // 解析失败,输出错误信息
        std::cout << "Failed to parse JSON: " <<
        reader.getFormattedErrorMessages() << std::endl;
        return 1;
    }
    // 访问 JSON 数据
    std::string name = root["name"].asString();
    int age = root["age"].asInt();
    std::string city = root["city"].asString();
    // 输出结果
    std::cout << "Name: " << name << std::endl;
    std::cout << "Age: " << age << std::endl;
    std::cout << "City: " << city << std::endl;
    return 0;
}

下面是对应示例输出:

text 复制代码
$ ./test.exe
Name: 张三
Age: 30
City: 北京
  1. 还可以使用 Json::CharReaderBuilder 创建解析器,或配合 Json::parseFromStream 从流中解析。本文保留 Reader 示例便于对照现有代码,CharReaderBuilder 同样可以用于正常的 JSON 解析。

为什么这里使用键值对?因为接收方要根据约定的字段名提取值,例如 root["age"],而不是依赖某个字段恰好出现在第几个字符。

前面的 writer 只改变 JSON 的输出形式。选用哪种 writer,都不能省略字段约定,也不能把输出里的换行当成所有业务消息通用的分隔符。计算器后面统一在正文外增加长度报头。

二、Json::Value 中哪些操作会用到

Json::Value 可以表示对象、数组、字符串、数值、布尔值和 null。我们的计算器主要用对象保存字段。

用途 常用操作 需要注意
构造 Json::Value()、Json::Value(Json::objectValue) 默认值为 null,可明确指定对象类型
对象访问 root["x"]、root.isMember("x") 非 const 下标访问可能创建原本不存在的成员
默认值 root.get("name", "unknown") get 返回带默认值的结果,不是给 operator\[\] 传两个参数
类型判断 isNull/isBool/isInt/isInt64/isUInt/isUInt64/isIntegral/isDouble/isNumeric/isString/isArray/isObject 先判断约定的类型,再取值
读取 asInt/asInt64/asUInt/asUInt64/asDouble/asBool/asString 转换函数不是完整的业务合法性检查
数组 append、按索引访问、resize 注意索引与容量,避免无意扩张数组
容器 size/empty/clear 适用于相应的 JSON 容器

赋值时可以传入字符串、整数、布尔值等对应类型。不要为了让请求"看起来合法",把缺失字段默认为 0:缺少操作数和操作数刚好为 0,是两种情况。

**字段不存在、字段类型不对、字段值为 0,要分开处理。**例如 x 缺失时直接取默认值 0,程序可能继续计算,却把一条不完整的请求当成了合法请求。

三、把请求与应答写成可序列化的类

文件名为 Message.hpp。网络计算器的字段继续使用 x、y、oper、result、code,两端必须保持一致。

cpp 复制代码
#pragma once
#include <jsoncpp/json/json.h>
#include <string>
#include <iostream>

class Request
{
public:
    Request() : _x(0), _y(0), _oper(0)
    {
    }
    Request(int x, int y, char oper) : _x(x), _y(y), _oper(oper)
    {
    }
    std::string Serialize()
    {
        Json::Value root;
        root["x"] = _x;
        root["y"] = _y;
        root["oper"] = static_cast<int>(_oper);

        Json::FastWriter writer;
        std::string s = writer.write(root);
        return s;
    }
    bool Deserialize(std::string &in)
    {
        Json::Value root;
        Json::Reader reader;
        bool ok = reader.parse(in, root);
        if (ok && root.isObject() && root["x"].isInt() &&
            root["y"].isInt() && root["oper"].isInt() &&
            root["oper"].asInt() >= 0 && root["oper"].asInt() <= 127)
        {
            _x = root["x"].asInt();
            _y = root["y"].asInt();
            _oper = root["oper"].asInt();
            return true;
        }
        return false;
    }
    ~Request() {}
    int X() { return _x; }
    int Y() { return _y; }
    char Oper() { return _oper; }

private:
    int _x;
    int _y;
    char _oper;
};
class Response
{
public:
    Response() : _result(0), _code(0) {}
    Response(int result, int code) : _result(result), _code(code)
    {
    }
    std::string Serialize()
    {
        Json::Value root;
        root["result"] = _result;
        root["code"] = _code;

        Json::FastWriter writer;
        return writer.write(root);
    }
    bool Deserialize(std::string &in)
    {
        Json::Value root;
        Json::Reader reader;
        bool ok = reader.parse(in, root);
        if (ok && root.isObject() && root["result"].isInt() && root["code"].isInt())
        {
            _result = root["result"].asInt();
            _code = root["code"].asInt();
            return true;
        }
        return false;
    }
    ~Response() = default;
    void SetResult(int res)
    {
        _result = res;
    }
    void SetCode(int code)
    {
        _code = code;
    }

    void ShowResult()
    {
        std::cout << "计算结果是: " << _result << "[" << _code << "]" << std::endl;
    }

private:
    int _result;
    int _code;
};

3.1 char 写入 JSON 后是什么

这里有一个容易忽略的地方:oper 在 C++ 里是 char,但代码明确把它转成 int 写入 JSON,因此加号在常见 ASCII 环境下对应数值 43。接收端也按整数读取,再恢复成 char。

如果想让 JSON 里直接显示 "+",也可以写成 std::string(1, operation),但接收端必须同时改为字符串校验与提取。不能发送数字,接收时却按字符串解释。

本文继续沿用数值编码,保持客户端、服务器和计算模块的接口一致。

3.2 parse 返回 true 还不够

合法 JSON 不等于合法计算请求。{"name":"hello"} 是合法 JSON,却没有 x、y、oper。上面的 Deserialize 除了检查解析结果,还检查对象形态和字段类型。只有所有检查通过,才给成员赋值。

运算符是不是五种支持的操作,交给计算模块决定;数据类型错了,则在协议入口直接拒绝。这样可以分清"请求格式错误"和"业务不支持这种操作"。

先确认收到完整正文,再解析 JSON;先校验字段,再执行计算。不能把一次 parse 成功当成所有验证都完成了。

3.3 字段校验与消息长度

同样是 x,下面几种输入的意义并不一样:

json 复制代码
{"x":10,"y":20,"oper":43}

这条消息的操作数是整数,oper 使用我们约定的数值编码。若把 x 改成字符串 "10",虽然人看起来仍然是十,但它已经不符合当前字段类型。服务端不应在没有约定的情况下,自动把各种字符串都当成整数。

如果完全没有 x 字段,也不能把它当作 0。检查顺序是:根节点是不是对象、必需字段是否满足对应类型、数值是否在约定范围内,最后再执行计算。

同样,接收应答时先确认 result 和 code 的类型,业务层再判断 code 是否成功。JSON 的语法正确、消息的字段正确、业务执行成功,是三层不同的判断。

格式化输出中的空格、换行和 Unicode 转义,只要符合 JSON 规则,解析后可以得到相同字段。但外层长度报头统计的是传输前的实际字节,不能根据"显示出来有多少个字"估算。中文使用 UTF-8 时,一个字通常不止一个字节;被写成 Unicode 转义时,文本长度又会变化。

如果协议以后要支持新增字段,读取方可以明确约定忽略未知字段,或者严格拒绝。无论采用哪种规则,都应由两端一起决定。序列化库不会替我们设计版本兼容规则。

四、结果与状态码怎样配合

计算器把结果与状态分开保存:

  • code == 0:计算成功,result 才是有效结果;
  • code == 1:除数为 0;
  • code == 2:取模的右操作数为 0;
  • code == 3:不支持这个运算符;
  • code == 4:结果超出约定的 int 范围。

为什么不能只看 result?0+0 的结果就是 0,而 10/0 是计算失败。用同一个结果值表示这两种情况,客户端就无法区分。

这里仍以 int 范围内的计算为例。除法和取模除了检查零,还要考虑最小负数除以 -1 的溢出;加减乘同样要避免有符号整数溢出。后面给出的业务函数会先用更宽的整数计算,再检查结果是否能放回 int。

4.1 先在本地走通一来一回

网络收发之前,可以先把 Message.hpp 的对象转换过程看清楚。下面这个 message_demo.cc 只依赖 JsonCpp,不需要先启动服务器:

cpp 复制代码
#include "Message.hpp"

int main()
{
    Request outgoing(10, 20, '+');
    std::string body = outgoing.Serialize();
    std::cout << body;

    Request incoming;
    if (!incoming.Deserialize(body)) return 1;
    std::cout << incoming.X() << ' ' << incoming.Oper()
              << ' ' << incoming.Y() << '\n';

    Response response(incoming.X() + incoming.Y(), 0);
    std::string reply = response.Serialize();
    Response received;
    if (!received.Deserialize(reply)) return 1;
    received.ShowResult();
    return 0;
}

按前面的方法编译,把文件名改成 message_demo.cc 即可。这里固定使用 10+20,是为了单独观察字段转换;完整计算器的运算符判断和溢出处理仍放在业务类中。

读这段代码时,可以沿着三个位置看:body 保存发送文本,incoming 保存还原后的字段,received 保存应答。对象名、内存地址都可以不同,但通信双方约定的字段必须对应。

如果在本地对象往返时字段就不正确,应先检查 Serialize/Deserialize;还没有接入 TCP,就不该把问题归因于网络丢了数据。

五、序列化也可以用来保存文件

网络和普通文件都可以承载字节。我们把链表对象直接写进文件,保存下来的指针地址在下次进程启动后没有意义;应保存节点的实际字段和关系,而不是照搬指针。

把每条记录序列化,再带上长度或其他边界信息,读取时就能逐条还原。这与网络拆包的思路一致,不过文件错误、网络断开等外围处理仍各不相同。

JSON 可读性较好,但不是唯一选择。XML、Protobuf 也能表示结构化数据。选什么格式不影响本文的分层:业务认识字段,序列化负责表示,网络负责传输。

六、实际检查字段往返与异常输入

字段检查与计算业务使用相同的 Message.hpp,没有另外换一套消息格式。

检查时先创建 Request(10, 20, '+'),调用 Serialize 得到正文,再用新 Request 对象调用 Deserialize。比较还原后的三个字段,实际结果如下:

text 复制代码
PASS  JSON request round-trip
PASS  reject string operand
PASS  reject missing operand

后两项检查分别把 x 改成字符串 "10"、把 x 字段直接删掉。**两条消息都能被当作 JSON 文本理解,但不能被当作当前约定下的计算请求接受。**这就是 Deserialize 中检查字段类型的作用,不能只检查 reader.parse 的返回值。

再把还原后的请求交给同一个 Cal 对象,计算后序列化 Response,检查结果与状态。实际得到:

text 复制代码
PASS  10 + 20 => result=30 code=0
PASS  10 / 0 => result=0 code=1
PASS  10 % 0 => result=0 code=2
PASS  10 ? 20 => result=0 code=3
PASS  2147483647 + 1 => result=0 code=4
PASS  -2147483648 / -1 => result=0 code=4

这里的 PASS 是检查程序在"实际字段与预期字段相同"时输出的标记。比如除零一项检查的是 code 确实为 1,不是看到程序没有崩溃就算成功。状态非 0 时的 result 为默认值,不能拿来参与后续计算。

最后一项也容易漏掉。32 位 int 的最小值除以 -1,数学结果是 2147483648,放不回 int;上面的业务代码先转成 int64_t 计算,再检查结果范围,所以返回 code 为 4。

这组检查验证了对象转换、格式拒绝和业务状态,没有涉及实际网络分段。网络上的完整报文提取,仍要交给下一篇的长度报头处理。本地字段能正常往返,不代表 TCP 收到的每一段字节都能立即拿来解析。

总结

JsonCpp 帮我们处理 JSON 格式,但字段名称、类型和含义仍由通信双方约定。请求中保存计算参数,应答中分开保存结果与状态。下一篇解决剩下的问题:面对 TCP 字节流,怎样从缓存里准确取出一条完整 JSON 消息。

往期博客分享

【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork

【Linux网络】TCP 线程池服务器怎么写?从 accept、任务队列到连接调度与 fd 生命周期

【Linux网络】TCP 为什么一次 read 读不到完整消息?从应用层协议、序列化到网络计算器

相关推荐
Decay__12 分钟前
01_SQLServer 体系架构全解 —— 实例进程、关系引擎、SQLOS 与事务日志深入
运维·数据库·sql·mysql·架构
eggwyw34 分钟前
PHP8.5怎么用ffmpeg提取视频里的字幕轨道
开发语言·php
我是koten6 小时前
【运维杂谈】K8s遇到Terminating的名称空间删除不了怎么办?(解决方案亲测有效)
linux·运维·服务器·云原生·kubernetes·k8s·名称空间
2601_9679873714 小时前
安装 Redis 或 Redis Stack
linux·windows·redis·macos·安装
feng_you_ying_li14 小时前
linux之数据链路层
linux·网络·智能路由器
修己xj14 小时前
服务器无法解析域名?一次 DNS 配置缺失的排查与修复
运维
我叫黑大帅14 小时前
PHP 常驻 Worker 中 MySQL 连接中断问题报告
后端·面试·php
GeW15 小时前
高端化走到深处,是数据库和Linux的事
linux
国科安芯16 小时前
人在回路中的总线:商业空间站环境控制与生命保障系统CANFD网络的可靠性与功能安全
网络·嵌入式硬件·安全·架构·php·抗辐射