JSON vs XML vs 二进制:3种序列化终极选型

三种格式对应三种场景,选错代价惨重。存配置文件用QSetting,存文档用JSON,存大数据用SQLite。


一、一次事故

凌晨两点,产线停了。

客户传来的工艺参数文件,我们解析出来全是乱码。小数点后第三位莫名其妙变成了 0.10000000000000001。XML 的 BOM 头没处理,Qt 的 XML 解析器直接报错。

我蹲在车间地上,用手机查了半小时才找到原因:写入用 printf("%.2f") 格式化成字符串,但读的时候用的是 sscanf,本地化设置不同,逗号和点号搞混了。

你说这是序列化的问题吗?

是。也不是。

根子是:我们从一开始就没想清楚,到底该用哪种格式。

项目里 JSON、XML、ini、自定义二进制混着用,同一个配置有人用 QSetting 写,有人用 json 写,还有人直接 memcpy 结构体进文件。最后谁也不知道哪个文件是什么格式,改一个字段要翻三个文件。

如果你也在几个格式之间犹豫不决,这篇就是为你写的。


二、一张表说清三兄弟

先看最核心的比较。后面所有的选择都基于这张表。

维度 JSON XML 二进制
可读性 高(人能看懂) 中(标签太多) 无(得用工具看)
解析速度 快(~50MB/s) 慢(~10MB/s) 极快(~500MB/s+)
体积 中(有冗余括号) 大(重复标签) 极小(紧凑二进制)
Schema 校验 无原生(靠外部) XSD 完整校验 全靠代码控制
元数据支持 弱(无命名空间) 强(命名空间+属性) 需自建
流式解析 不支持(全量解析) 支持(SAX) 全量/流式均可
Qt 原生支持 QJsonDocument QXmlStreamReader/Writer QDataStream
工业标准 RFC 8259 W3C 标准 无统一标准
版本兼容 好(字段可增删) 好(XSD 版本化) 最差(结构定死)

读这张表的时候,别只看"快"和"小"。大多数项目死在可读性上。

你问我为什么?

因为你的程序不是只跑一次。它要被人维护 3 年 5 年。到那时候,一个你能肉眼直接读的配置文件,和一个必须用 Hex Viewer 才能打开的二进制文件,维护成本差 10 倍。


三、Qt 项目里到底该用哪个

Aether 项目里,三种格式全在用。不是乱用,是每种格式都有自己的地盘

场景 1:应用配置(QSettings + TOML)

应用级的用户设置,比如窗口位置、上次打开的文件、主题偏好。这些东西的特点是:量小、改得频繁、需要跨平台一致性。

cpp 复制代码
// QSettings: 不用自己写序列化
QSettings settings("Aether", "App");
settings.setValue("window/geometry", saveGeometry());
settings.setValue("theme", "dark");

// 下次启动
restoreGeometry(settings.value("window/geometry").toByteArray());
QString theme = settings.value("theme", "light").toString();

QSettings 的好处是跨平台自动处理好 Registry(Windows)/ plist(macOS)/ ini(Linux)的差异。你不关心存哪,Qt 帮你管。

但对于结构化配置,比如设备参数、相机曝光时间、IO 通道映射,QSettings 的扁平的键值对就不够用了。Aether 用的是 TOML

toml 复制代码
# config/app.toml
[device]
camera_exposure_ms = 30.5
io_trigger_channel = 3
enable_autofocus = true

[network]
mqtt_broker = "192.168.1.100"
mqtt_port = 1883

TOML 比 JSON 更清晰,写注释方便,解析库 toml11 是 header-only,直接包含就能用。Aether 的 config 模块就基于 toml11 做的。

场景 2:文档数据(nlohmann/json + Qt JSON)

什么叫"文档数据"?工艺参数模板、检测方案配置、多工位流程定义。这些东西的特点是:结构复杂、嵌套深、需要人手动编辑或用工具生成。

Aether 的 scheduler 引擎就用 JSON 来定义流程:

json 复制代码
{
    "flow_id": "inspection_001",
    "name": "标准检测流程",
    "version": "2.1.0",
    "steps": [
        {"type": "load", "station": "infeed", "timeout_s": 10},
        {"type": "inspect", "camera": "cam_01", "algo": "edge_detect"},
        {"type": "judge", "threshold": 0.85},
        {"type": "unload", "station": "outfeed"}
    ]
}

nlohmann/json 解析非常直接:

cpp 复制代码
#include <nlohmann/json.hpp>
using json = nlohmann::json;

// 解析
json config;
std::ifstream file("flow_inspection.json");
file >> config;

std::string name = config["name"];
double threshold = config["steps"][2]["threshold"];

// 序列化
json output;
output["result"] = "pass";
output["timestamp"] = QDateTime::currentDateTime()
    .toString(Qt::ISODate).toStdString();

std::ofstream out("result.json");
out << output.dump(4);  // 缩进4空格,人类友好

Aether 在 common/utility/json_tool/ 下封装了 json_utils.h,提供统一的文件读写接口,处理了文件不存在、目录不存在等边缘情况。还提供了 qt_json_adapters.h,让 QString、QDateTime、QPointF 能和 nlohmann/json 互转。

场景 3:大数据 + 高性能(QDataStream + 自定义二进制)

什么数据走二进制?传感器采集数据、相机图像帧、日志缓存快照。 这些的特点是:量巨大、对实时性有要求、不需要人直接读。

cpp 复制代码
// QDataStream 序列化结构体
struct SensorFrame {
    quint32 timestamp;
    float   temperature;
    float   pressure;
    quint16 status_flags;
};

QByteArray serializeFrame(const SensorFrame& frame) {
    QByteArray data;
    QDataStream stream(&data, QIODevice::WriteOnly);
    stream.setByteOrder(QDataStream::LittleEndian);
    stream << frame.timestamp << frame.temperature
           << frame.pressure << frame.status_flags;
    return data;
}

二进制格式的坑在后面讲。这里只记住一条:如果你不确定要不要用二进制,那就别用。


四、Aether 的序列化架构

Aether 没有搞一个"万能序列化引擎"。它的做法是分层管理,每一层选最适合的格式。

复制代码
应用配置 ─→ QSettings(ini/reg/plist)
                  ↓
结构化配置 ─→ TOML(toml11)
                  ↓
流程定义  ─→ JSON(nlohmann/json)
                  ↓
运行时快照 ─→ JSON + 二进制混合
                  ↓
传感器数据 ─→ QDataStream(二进制)

每一层的接口是抽象的,底层实现可以随时换。

比如持久化层定义了 IFlowPersistence

cpp 复制代码
class IFlowPersistence {
public:
    virtual ~IFlowPersistence() = default;
    virtual void saveInstance(const FlowInstanceSnapshot& snap) = 0;
    virtual std::vector<FlowInstanceSnapshot> loadAllRunning() const = 0;
};

当前默认实现是 JsonFilePersistence(就是 JSON 文件)。如果哪天觉得 JSON 太慢,可以换 SqlitePersistenceBinaryPersistence,插件式替换,调用方不用改一行。

这就是面向接口编程的实战用法。不是炫技,是给你留条后路


五、版本兼容设计

序列化最头疼的问题:你今天存的文件,明天的新版本还能读吗?

Aether 的做法分三层。

5.1 文件格式版本号

每个 JSON 结构文件的第一行,永远是一个 version 字段:

json 复制代码
{
    "format_version": "2.1.0",
    "flow_id": "..."
}

读文件时,第一件事检查版本:

cpp 复制代码
json root = json::parse(fileContent);
std::string ver = root.value("format_version", "0.0.0");
if (ver < "2.0.0") {
    // 走旧版迁移逻辑
    root = migrateV1toV2(root);
}

5.2 向前兼容(Forward Compatibility)

旧版程序能读新版本文件,但会忽略不认识的字段。关键是宽容读取

cpp 复制代码
// Aether 的 json_utils 设计
struct JsonParseFileOptions {
    bool allow_missing_file   = false;
    bool normalize_to_object  = false;
};

// 读取时,不认识的字段自动忽略
// 新增字段不要影响旧字段的解析

约定:新增字段必须有默认值,不能破坏旧字段的位置。

5.3 向后兼容(Backward Compatibility)

新版程序能读旧版文件。做法是用默认值补缺失的字段

cpp 复制代码
// 读取字段,不给就返回默认值
template <typename T>
T json_getValue(const json& data, const string& key_path,
                const T& default_val = T());

// 使用
double threshold = json_getValue(step, "threshold", 0.8);

新版本加了 timeout_s 字段,旧文件没有,自动用默认值 30 秒。不会 crash。

5.4 二进制版本的噩梦

二进制格式的版本兼容是最难的。结构体大小一变,所有旧文件作废。

cpp 复制代码
// 别这么做
struct FrameData {
    quint32 timestamp;
    float temperature;
    // 新版本加了这个:
    // float humidity;    // ← 加了4字节,所有旧文件偏移错位
};

二进制格式一定要带魔数和版本头:

cpp 复制代码
struct BinaryHeader {
    quint32 magic = 0xA5A5A5A5;     // 文件标识
    quint32 version = 1;             // 格式版本
    quint32 data_size;               // 数据体大小
    quint32 checksum;                // 校验和
};

读文件时先验魔数,再验版本,最后才读数据体。版本不匹配就走迁移逻辑。


六、三个致命陷阱(附带代码)

陷阱 1:浮点精度

cpp 复制代码
// 写入
double value = 0.1;
json["value"] = value;  // 序列化成 0.1

// ???读出来变成了 0.10000000000000001

二进制表示上,0.1 本身就没法精确表示。JSON 本身没有这个问题(它存的是文本),但 JSON 解析器把字符串转 double 的时候,精度丢失就来了。

nlohmann/json 默认保留全精度。如果你用 dump(4),它会输出 0.1,但在某些编译器上 JSON 解析器可能会引入微小的精度漂移。

解决办法:比较时用容差,不用 ==

cpp 复制代码
bool approxEqual(double a, double b, double eps = 1e-9) {
    return std::fabs(a - b) < eps;
}

如果是货币或者关键参数,存整数(毫/微米)而不是小数(毫米)。 比如存 100 表示 100um,而不是 0.1mm

陷阱 2:大小端

QDataStream 默认是大端(Big Endian,Qt 5 之前),但 x86 是小端(Little Endian)。你不设置字节序,在不同平台上写入和读取就可能翻车。

cpp 复制代码
QDataStream stream(&data, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);  // 强制小端
// 这样在 ARM 板子和 x86 之间传输就不会有问题

显式设置字节序,永远不依赖默认值。

陷阱 3:BOM

Windows 下记事本保存 UTF-8 文件,会在文件头加 3 个字节的 BOM(Byte Order Mark):EF BB BF

Qt 的 XML 解析器遇到 BOM 直接歇菜:

cpp 复制代码
// ❌ 带 BOM 的文件,Qt XML 解析器报错
QFile file("config.xml");
file.open(QIODevice::ReadOnly);
QXmlStreamReader reader(&file);
reader.readNext();  // 错误!前三个字节是 BOM

解决办法:读文件后跳过 BOM:

cpp 复制代码
QByteArray data = file.readAll();
if (data.startsWith("\xEF\xBB\xBF")) {
    data.remove(0, 3);  // 砍掉 BOM
}
QXmlStreamReader reader(data);

nlohmann/json 不介意 BOM,但 QJsonDocument 也不处理。统一去掉最保险。


七、中场回顾(第 5-9 篇的干货浓缩)

这篇是第 10 篇,正好过半。如果你是从头跟过来的,帮你捋一下前 10 篇的完整脉络。

第 1 篇:认知建立

60 万行 C++ 项目的真实崩溃经历。建立了"工业级不等于语法难,等于架构思维"的认知框架。项目三层结构:应用层、插件层、公共基础层。

第 2 篇:插件架构选型

3 种方案对比:编译期静态链接、运行时动态加载(Aether的选择)、混合模式。Aether 选运行时动态加载的理由:团队迭代效率最高。

第 3 篇:ExtensionSystem 拆解

插件生命周期状态机(Invalid -> Read -> Resolved -> Loaded -> Initialized -> Running -> Stopped -> Deleted),依赖解析的拓扑排序,对象池机制。

第 4 篇:IoC 容器

Laravel 风格的依赖注入容器。type_index + shared_ptr<void> 实现类型擦除。三种绑定生命周期(Transient / Singleton / Instance)。ServiceProvider 两阶段引导。

第 5 篇(DataBinding 数据绑定):

ViewModel 属性变更 -> DataBinding -> UI 控件更新的完整链路。Qt 信号槽做不了 MVVM 的深层原因(它不知道"哪个属性变了")。

第 6 篇(中间件管道):

洋葱模型的两条管道:属性变更中间件(日志、校验、审计)和命令执行中间件(权限、事务、异常处理)。

第 7 篇(主题系统):

亮色/暗色一键切换的设计。全局 QSS + 动态属性选择器。颜色 token 化,不写死任何色值。

第 8 篇(权限管理):

基于角色的访问控制(RBAC)。导航权限 / 子页面权限 / 操作权限三级粒度。登录后动态刷新的 grants 机制。

第 9 篇(国际化):

TR() 宏 + ts 文件 + QM 文件的标准 Qt 路径。运行时语言切换而不重启。字符串提取和翻译工作流。

到现在为止,你看到的是一条渐进式路线

复制代码
插件解耦(2-3) → IoC 管理(4) → 数据绑定(5)
→ 切面拦截(6) → UI 基础设施(7-9) → 序列化(10→当前)

所有模块最终都依赖"持久化": 数据怎么进来、怎么出去、怎么存。序列化不是独立的功能,是贯穿整个框架的基础设施


八、互动 + 下期预告

本期互动

翻翻你的项目,回答三个问题:

  1. 你的序列化格式选型有书面标准吗,还是各自"想用啥用啥"?
  2. 你上次因为精度问题排查了多久?
  3. 你项目的配置文件,3 年前的旧版本还能读吗?

评论区说说你的答案,我挑最"惨"的送一次免费序列化架构咨询。

下期预告

Phase 3 正式开启。

从第 11 篇开始,进入C++硬核阶段。先放一张路线图:

序号 标题
11 类型擦除与模板技巧
12 对象生命周期管理
13 Qt 并发与线程安全
14 信号槽深度剖析
15 跨模块通信设计

下一期,第 11 篇:类型擦除

shared_ptr<void>std::function 再到虚函数表, 没有反射的 C++ 是怎么做到"持有任意类型"的?我们把 Aether 容器里的模板魔法摊在桌上拆给你看。

硬核模式,准备启动。


💬 评论区聊聊:

你的项目序列化踩过什么坑?二进制文件版本不兼容?JSON 精度翻车?还是 XML 配置写到想哭?评论区见。

🔄 觉得有用?

收藏这篇,下次项目定序列化方案时翻出来对着表选。也转给你团队里还在争论"到底用 JSON 还是 XML"的同事。只看那张表就够了。

相关推荐
迪丽热爱1 小时前
多媒体应用5-817
学习
世人万千丶1 小时前
收纳格卡片风:ArkUI 让鸿蒙物品清单像收纳盒贴标
学习·华为·harmonyos·鸿蒙
MartinYeung51 小时前
[论文学习]X-Boundary:为LLM建立精确安全边界以抵御多轮越狱攻击
学习·安全
xiaoxiangsiyan1 小时前
RHCE之2026年全套考点详解
运维·前端·网络·chrome·学习·rhce
秋田君1 小时前
QT_实战TCP 聊天程序说明文档以及实现
数据库·qt·tcp/ip
郝学胜-神的一滴2 小时前
并查集深度入门:从玄学抽象到 QuickFind & QuickUnion 源码实战
数据结构·c++·python·程序人生·算法·软件开发
爱莉希雅&&&2 小时前
Kubernetes Service 完整学习笔记
笔记·学习·kubernetes
ShineWinsu10 小时前
对于C++:IO流状态的详细解析
c++·面试·io·cin·io流状态·goodbit·failbit
Lzg_na12 小时前
constexpr的优势
c++