C++ Boost读写JSON实战:PropertyTree与Boost.JSON选型指南

发布时间:2026/10/17 10:25:22
C++ Boost读写JSON实战:PropertyTree与Boost.JSON选型指南 搞 C 的人迟早要碰 JSON。服务端接口数据、客户端配置文件、模块之间的状态交换几乎到处都有这种大括号套大括号的文本格式。而 Boost 作为老牌 C 库集合读写 JSON 的方案主要有两个一个是早期的 Boost.PropertyTree一个是 Boost 1.75 之后正式收录、专门为 JSON 设计的 Boost.JSON。这两个方案我都实打实用过这篇就把“Boost 学习之读写 json”这件事里从选型到踩坑的完整过程写出来给正准备入手的你省点时间。文章适合刚接触 Boost、对 JSON 解析有基本需求的 C 开发者也适合已经在用 PropertyTree 但想搞明白要不要换库的人。1. 为什么 C 程序需要 JSON以及 Boost 的两种方案1.1 JSON 在 C 开发中的角色JSON 本质上就是一个能无限嵌套的键值对文本结构。我经常给新人打比方它就像一套带标签的收纳盒盒子里面可以放数字、字符串、布尔值、数组还可以再放一组盒子。这种设计天然适合表达配置项、接口返回这种层次化数据而且人眼直接能读懂调试的时候打开文件一看就知道哪里不对所以它成了现代软件里事实上的数据交换标准。C 没有语言级的 JSON 支持所以你需要一个解析器把这段文本变成能在代码里直接访问和修改的内部结构用完再把它变回文本。在 C 项目里 JSON 最常见的应用场景大概这么几类。第一配置文件服务端的监听端口、日志级别、数据库连接参数用 JSON 写一目了然改动不需要重新编译。第二网络接口数据前后端或者微服务之间交换信息JSON 是默认格式C 服务端自然要能解析客户端发来的请求体。第三本地缓存和导入导出把运行时状态保存成文件下次启动直接恢复。之前接手一个模块客户端启动要读一个几百行的大 JSON 配置里面嵌套了三层对象和数组。最初那段代码是手写的解析逻辑用字符串查找一层层拆维护到后面非常痛苦。后来切到 Boost整个读写代码压缩到几行这才体会到为什么说“专业的事要交给专业的库”。1.2 Boost.JSON 与 Boost.PropertyTree选型对比Boost.PropertyTree 是个“老前辈”定位其实不是 JSON 专用而是一个通用属性树可以读写 JSON、INI、INFO 甚至 XML。它的抽象模型是 ptree一棵节点树每个节点有自己的 data 字符串下面挂子节点。很多老项目用它读 JSON是因为当年 Boost 里没有专门的 JSON 库它勉强能用。Boost.JSON 是后来专门为 JSON 设计的库从 Boost 1.75 开始进入官方库。结构上直接对应 JSON 的数据模型value 表示任意 JSON 值object 对应对象array 对应数组。解析速度、内存管理都比 PropertyTree 强类型语义更贴合 JSON 标准不会再出现“数组被映射成一组空 key 子节点”这种别扭的事。对比项Boost.PropertyTreeBoost.JSON定位通用属性树JSON 只是其中一种格式专为 JSON 设计引入时间很早大约 Boost 1.33 附近Boost 1.75 开始JSON 类型保真较差数组和数字类型处理有歧义完整支持 JSON 标准性能中等更优主要头文件boost/property_tree/json_parser.hppboost/json.hpp选型建议很简单新项目一律用 Boost.JSON只有接手老代码或者配置文件需要同时支持多种格式时才继续用 PropertyTree。写这篇的时候我的观点很明确能用专门处理 JSON 的库就不要用通用抽象去“模拟”JSON。2. 环境准备与依赖说明2.1 编译环境与 Boost 版本检查使用 Boost.JSON第一道门槛是版本。它从 Boost 1.75 才进官方库如果你的 Boost 太旧头文件根本不存在编译直接失败。检查版本的方式很简单# Linux 下查看已安装的 Boost 版本 dpkg -l | grep libboost # 或者在代码里直接看版本宏 grep BOOST_LIB_VERSION /usr/include/boost/version.hpp编译器方面Boost.JSON 官方支持 C11 以上标准但我还是建议至少用 C14 或 C17编译时可以少碰到一些模板推断上的边角问题。g -stdc17 -I/path/to/boost main.cpp -o demo头文件包含就两行新旧方案一目了然#include boost/json.hpp #include boost/property_tree/ptree.hpp #include boost/property_tree/json_parser.hpp2.2 链接和命名空间的处理细节Boost.JSON 大多情况下是 header-only简单项目直接包含头文件就能编译。不过我在实际工程里更推荐把 Boost 库完整编译出来再链接原因很简单很多部门都在用 Boost不同版本混在一起时header-only 偶尔会冒出符号重复或者版本不一致的问题链接统一的编译产物至少能少一个排查方向。命名空间方面建议用别名而不是全局using namespace boost::json;namespace bj boost::json;value、object、array这些单词太常见了全量 using 很容易和别的库冲突。我自己就遇到过和某个第三方库里的value类撞名编译报错找了一下午最后改成别名才解决。统一用bj::value之后代码可读性也更好看的人一眼知道类型来自哪里。3. Boost.JSON 读写 json 的完整流程3.1 从文本解析出 value先看最简单的解析调用#include boost/json.hpp #include iostream #include string namespace bj boost::json; int main() { std::string json_str R({name:boost,version:1.82,tags:[cpp,json]}); // 异常版本解析失败会抛 boost::system::system_error try { bj::value jv bj::parse(json_str); std::cout jv std::endl; } catch (const std::exception e) { std::cerr parse error: e.what() std::endl; } // error_code 版本适合不希望异常穿透的业务场景 boost::system::error_code ec; bj::value jv2 bj::parse(json_str, ec); if (ec) { std::cerr parse error: ec.message() std::endl; return 1; } }这里解释一下为什么第一步不直接用 object 而是 valueparse 返回的 value 可以装得下任何 JSON 实体对象、数组、字符串、数字、布尔、null它都能表示。等拿到 value 之后再用as_object()之类的方法转成具体类型。这个设计很符合 JSON 的动态特性你读到的最外层不一定是对象也可能是一个数组用 value 接住最稳妥。解析失败时的处理方式我强烈建议用 error_code 版本尤其是服务端代码。异常版本的语法看着省事但异常在大规模解析场景下的性能开销和心智负担都不小。如果你只是写个小工具异常版本完全够用做正式项目让错误沿着返回路径走得越显式越容易 debug。3.2 访问和修改对象与数组拿到 value 之后操作就非常直观了bj::object obj jv.as_object(); // at() 在 key 不存在时抛 std::out_of_range std::string name obj.at(name).as_string().c_str(); double version obj.at(version).as_double(); // 修改或新增字段对非 const 的 obj 直接下标key 不存在时会创建 null 值 obj[version] 1.90; obj[author] none; obj[tags] bj::array{cpp, json, boost};这里有一个新手最容易犯的错obj[author]在 key 不存在时会自动插入一个 null 值。如果你只是想“万一有就读出来”用[]会默默改掉原对象的结构。更安全的是用find()auto it obj.find(author); if (it ! obj.end()) { // 存在 }数组操作同样直接bj::array arr obj.at(tags).as_array(); arr.push_back(new_tag); for (auto item : arr) { if (item.is_string()) { std::cout item.as_string().c_str() std::endl; } }注意is_string()和as_string()的搭配先判断类型再转换否则类型不匹配会抛异常。调试时可以随时std::cout value库会把内部结构格式化输出这比断点看内存省事多了我几乎每次排查问题都会先打印一遍。3.3 序列化把 value 写回字符串修改完之后要拿回文本核心函数一个就够std::string printed bj::serialize(jv); std::cout printed std::endl;serialize输出的是紧凑 JSON。有一个很隐蔽的坑object 内部用的是开放寻址哈希表组织和存储节点所以序列化后键的顺序不保证和原来完全一致。如果你因为某种原因依赖 JSON 里键的书写顺序比如要做文件的逐字节 diff那最好别指望 Boost.JSON 帮你保留顺序而是要自己设计一个不依赖键顺序的校验逻辑。写入文件时我习惯打开二进制模式#include fstream void save_json_to_file(const std::string path, const bj::value jv) { std::ofstream out(path, std::ios::binary); if (!out) { throw std::runtime_error(cannot open file: path); } out bj::serialize(jv); }为什么用二进制模式因为 Windows 下文本模式会把换行符自动转换成\r\nJSON 字符串里那些可见的\n转义和实际换行是有严格语义的最好自己控制全部字节不要交给系统的自动转换。跨平台项目里这个细节经常被漏掉结果同一份代码在 Linux 和 Windows 上生成的文件字节不一致。3.4 完整示例读配置文件、改参数、写回去下面是一个完整的可编译示例。场景很典型配置文件里有服务端口、日志级别和一个 IP 白名单数组程序启动时读取按需修改再写回文件。#include boost/json.hpp #include fstream #include iostream #include sstream #include string namespace bj boost::json; std::string read_file(const std::string path) { std::ifstream in(path, std::ios::binary); if (!in) { throw std::runtime_error(cannot open file: path); } std::ostringstream oss; oss in.rdbuf(); return oss.str(); } int main() { std::string path config.json; boost::system::error_code ec; // 1. 读文件并解析 std::string text read_file(path); bj::value jv bj::parse(text, ec); if (ec) { std::cerr parse failed: ec.message() std::endl; return 1; } bj::object obj jv.as_object(); // 2. 修改配置端口 1把日志级别调低往白名单里加一个地址 if (obj.contains(port) obj[port].is_int64()) { obj[port] obj[port].as_int64() 1; } obj[log_level] debug; if (obj.contains(whitelist) obj[whitelist].is_array()) { bj::array wl obj[whitelist].as_array(); wl.push_back(192.0.2.10); } // 如果原来没有 version 字段就补一个 if (!obj.contains(version)) { obj.emplace(version, 1); } // 3. 写回文件 save_json_to_file(path, jv); std::cout bj::serialize(jv) std::endl; return 0; }这个例子里有几个细节值得展开。obj.contains()先检查 key 是否存在避免直接[]读取不存在的 key 时产生 null这是我最常在改造代码时看到的低级 bug。emplace插入新 key 时返回一个 pair可以判断是否真的插入成功语义比裸[]赋值更清楚。日志级别这种枚举字段在 JSON 里通常就是字符串直接赋值完全没问题。这里再补充一个关于数字类型的细节。JSON 字数没有严格区分整型和浮点型1和1.0在语义上都是数字。Boost.JSON 解析的时候会根据文本内容智能选择存储类型整数存在 int64带小数点的存在 double。读取端如果没有先判断is_int64()或is_double()就直接as_int64()遇到浮点数字就可能抛异常。跨系统对接时对方传来的数字字段到底是哪种类型事先一定要约定好。3.5 嵌套对象与类型转换真实配置经常出现三层以上的嵌套Boost.JSON 处理起来比较自然bj::value root bj::parse(config_text, ec); bj::value database_cfg root.at(plugins).at(database); std::string host database_cfg.at(host).as_string().c_str();一层层at()下去每步都明确取的是什么。如果觉得链式调用像天书可以把每一步拆开写反正最终要的是那个值。我更推荐自定义类型映射的方式用value_toT把 JSON 结构和 C struct 对应起来struct DbConfig { std::string host; int port; std::string user; }; // 为自定义类型实现 value 到结构的转换 void tag_invoke(const bj::value_from_tag, bj::value jv, const DbConfig cfg) { jv bj::object{ {host, cfg.host}, {port, cfg.port}, {user, cfg.user} }; } auto cfg bj::value_toDbConfig(root.at(plugins).at(database));这个写法需要一点模板机制适合字段结构比较固定的场景。如果你的 JSON 字段三天两头变直接操作 object 反而更灵活毕竟改代码总比改模板转化逻辑快。两者相辅相成灵活性和安全性之间需要自己权衡。4. Boost.PropertyTree 读写 json 的做法老代码兼容4.1 ptree 的基本模型PropertyTree 里的 ptree 是一棵节点树每个节点有一个data字符串下面挂着一堆子树。读取 JSON 时json_parser把 JSON 文本转成这棵树写出去时再把树转回 JSON 文本。基本用法长这样#include boost/property_tree/ptree.hpp #include boost/property_tree/json_parser.hpp namespace pt boost::property_tree; pt::ptree root; pt::read_json(config.json, root); std::string host root.getstd::string(host); int port root.getint(port);固定字段用getT很舒服路径用点号分隔a.b.c就能一路取到子节点。字段可能不存在时用get_optionalTboost::optionalint timeout root.get_optionalint(timeout); if (timeout) { // do something }这种按路径取值的写法在字段深度固定、结构稳定的配置文件场景里确实很顺手。4.2 数组和嵌套对象的遍历这里就要开始小心了。JSON 数组在 ptree 里被映射成一组 key 为空的子节点。比如{tags: [a, b]}在 ptree 里tags节点下会有两个子节点它们的 key 都是空字符串。遍历代码pt::ptree tags root.get_child(tags); for (const auto item : tags) { std::string tag item.second.get_valuestd::string(); std::cout tag std::endl; }每次遍历都要意识到自己在遍历“无名字节点”如果数组里还嵌套对象结构会更绕。这是 ptree 设计上的本质问题它不是 JSON 专用数组这个 JSON 核心结构在通用树模型里没有天然位置只能这么硬凑。结果就是代码的可读性差而且处理复杂 JSON 时容易出错。4.3 写 json 时类型保留的坑ptree 里每个节点的 data 都是字符串write_json写出时会“智能猜测”这个字符串是什么类型像123会写成数字true会写成布尔其余才加引号。这会带来几个很实际的问题如果读进来的 JSON 里有一个字符串0123再写回去中间的引号可能被省略语义直接变了数字在读写反复来回时类型可能发生微妙变化只有一个元素的数组在老版本里写回时甚至可能从数组变成对象。这些都是我在真实版本里踩过的坑。结论就是如果项目主要数据格式就是 JSON并且需要严谨保真ptree 真不是好选择。它更适合的场景是配置文件可能要同时支持 JSON 和 INI/INFO你希望用同一套代码处理多格式。这种情况下牺牲一点 JSON 精确度换取通用性是可以接受的。4.4 从 PropertyTree 迁移到 Boost.JSON 的建议迁移并不复杂。第一步把read_json换成bj::parse第二步把原来基于路径的getint(a.b.c)改成逐层at()第三步把数组遍历从空 key 子节点遍历换成真正的array遍历。一次性迁移时建议给读配置文件写单元测试重点盯数字类型和空数组这两个最容易出差异的地方。迁移完成后删掉旧的 json_parser 头文件引用免得两边混用。5. 常见问题与排查技巧实录5.1 编译和链接问题最常见的问题是“头文件存在但编译失败”。出现这种状况先确认 Boost 版本再确认编译器标准。Boost.JSON 需要较新的 Boost老版本编译时头文件根本不存在报错信息还不一定直接指向这一点容易绕圈子。链接错误相对少因为多数场景 header-only 就能用但如果项目里同时用了其他依赖 Boost 的库不同模块的 Boost 版本不一致就会出现怪异的符号问题。统一 Boost 版本是第一步。5.2 中文和编码乱码JSON 标准要求 UTF-8 编码但很多人在 Windows 上拿到的文本是 GBK 格式。Boost.JSON 解析严格按 UTF-8 处理非 UTF-8 字节可能导致解析失败或者输出乱码。实际操作中配置文件应该统一保存为无 BOM 的 UTF-8。我见过一个团队代码在 Linux 服务器上跑得好好的运维同学在 Windows 记事本里改了一下配置保存程序立刻解析失败查了半天最后发现文件被记事本存成了 UTF-8 with BOM。UTF-8 BOM 三个字节放在 JSON 文件开头解析器对最前面的 BOM 处理方式不一直接报错根本不奇怪。如果只是在终端里打印中文乱码那大概率不怪 Boost.JSON而是终端代码页和文件编码不一致先统一编码再排查。5.3 运行时类型转换异常调试时最常见的一行报错是terminate called after throwing an instance of boost::system::system_error原因基本都是as_int64()或as_object()对上错误类型。排查技巧很简单出错前先把 value 打印出来看结构std::cout value一目了然马上就能看到那个字段到底是字符串还是数组。想写健壮的代码所有跨模块的 JSON 字段在解析时都要做类型断言不要默认它一定是某个类型。尤其是接口联调阶段对方说好传整型结果空值传了个空字符串这种事故我见得多了。5.4 常见报错速查表现象可能原因处理办法编译找不到 boost/json.hppBoost 版本太旧升级到 1.75 或更高std::out_of_rangeat() 访问不存在的 key用 contains() 或 find() 先判断boost::system::system_error 未捕获解析失败且用了异常版本捕获异常或改用 error_code 版本中文输出乱码文件编码不是 UTF-8统一用无 BOM UTF-8 保存写回文件后格式变化ptree 类型保真问题新代码用 Boost.JSON数组遍历行为异常ptree 空 key 子节点模型迁移到 Boost.JSON 的 array 遍历6. 使用心得与经验建议6.1 从零开始的小项目直接 Boost.JSON以我现在写新代码的习惯不管项目大小只要涉及 JSON 读写第一选择就是 Boost.JSON。它把 JSON 的核心概念映射得非常直接value、object、array、string、number、bool每个都有对应的 C 类型。读代码的时候不需要在脑子里做“ptree 到 JSON”的转换省下很多心智负担。尤其数组操作真正变成数组了push_back 和遍历都顺理成章不用再绕道空 key。6.2 处理好“读写一致性”这个容易忽略的问题读写不只是对称的两个函数。读的时候 parse 成功写的时候 serialize产出的文本和输入可能有很大差别键顺序、数字写法、空白风格都会变。如果你的项目依赖 JSON 文件的精确格式比如要做版本 diff、文件内容对比那一定要在读写环节增加额外的格式校验或者自己控制输出格式。我个人会在写文件前把旧文件备份一份线上出问题可以快速恢复这个习惯救过我不少次。6.3 最后一个小技巧把公共读写封装成模板函数在项目里维护两个小而稳的公共函数能省掉大量重复样板代码template typename T T load_json_from_file(const std::string path) { auto text read_file(path); boost::system::error_code ec; auto jv bj::parse(text, ec); if (ec) { throw std::runtime_error(ec.message()); } return bj::value_toT(jv); } template typename T void save_json_to_file(const std::string path, const T data) { bj::value jv bj::value_from(data); std::ofstream out(path, std::ios::binary); out bj::serialize(jv); }这两个函数配合tag_invoke映射自定义类型整个项目的 JSON 读写代码就能收敛到很薄的一层。我用这套模式在几个项目里跑了挺长时间需要调整字段时加结构体映射就行特殊情况才直接操作 value整体维护成本低很多。如果你正准备在新的 C 项目里处理 JSON不妨从第一天就按这个思路统一封装。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询