搞 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.PropertyTree | Boost.JSON |
|---|---|---|
| 定位 | 通用属性树,JSON 只是其中一种格式 | 专为 JSON 设计 |
| 引入时间 | 很早(大约 Boost 1.33 附近) | Boost 1.75 开始 |
| JSON 类型保真 | 较差,数组和数字类型处理有歧义 | 完整支持 JSON 标准 |
| 性能 | 中等 | 更优 |
| 主要头文件 | boost/property_tree/json_parser.hpp | boost/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 官方支持 C++11 以上标准,但我还是建议至少用 C++14 或 C++17,编译时可以少碰到一些模板推断上的边角问题。
g++ -std=c++17 -I/path/to/boost main.cpp -o demo头文件包含就两行,新旧方案一目了然:
#include <boost/json.hpp> #include <boost/property_tree/ptree.hpp> #include <boost/property_tree/json_parser.hpp>2.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 而是 value:parse 返回的 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\n,JSON 字符串里那些可见的\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_to<T>把 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_to<DbConfig>(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.get<std::string>("host"); int port = root.get<int>("port");固定字段用get<T>很舒服,路径用点号分隔,"a.b.c"就能一路取到子节点。字段可能不存在时,用get_optional<T>:
boost::optional<int> timeout = root.get_optional<int>("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_value<std::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;第二步,把原来基于路径的get<int>("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.hpp | Boost 版本太旧 | 升级到 1.75 或更高 |
| std::out_of_range | at() 访问不存在的 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_to<T>(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,不妨从第一天就按这个思路统一封装。