1. 从一段字符串说起:为什么C/C++里处理JSON总让人头疼
如果你写过C或者C++,大概率遇到过这样的场景:设备上报了一串数据,格式是JSON,你需要把它解析出来,取出某个字段做判断;或者反过来,你采集到的几个传感器数值,需要拼成JSON发给上层服务。听起来很简单对吧?但真动手的时候,很多人第一反应是——C语言里连个字典都没有,字符串操作又那么原始,这活儿到底怎么干?
我刚开始接触这类需求的时候,也走过不少弯路。最早的做法是直接用strstr去查找字段名,然后手动截取冒号后面的内容,再自己去掉引号。这种方法对付一两个固定字段还行,一旦JSON嵌套一层,或者数组里再套对象,代码就彻底失控了。后来也试过自己写一个简易的解析器,状态机画了一整页纸,结果边界情况层出不穷:转义字符没处理、数字和字符串分不清、空数组和空对象搞混。折腾了好几天,最后发现——与其自己造轮子,不如找一个成熟但轻量的库,把精力放在业务逻辑上。
这篇内容就是把我这些年在这个环节上积累的经验整理出来。核心围绕四件事:字符串转JSON、JSON转字符串、数组解析、数组添加。我会讲清楚每一步为什么这么做、选哪个库、怎么避坑,以及那些文档里不会写的实操细节。无论你是刚接触C/C++的新手,还是已经用过一些JSON库但总觉得不够顺手的老手,应该都能从中找到有用的东西。
提示:本文所有代码示例基于C++11及以上标准,C语言部分会单独说明。涉及的库均为开源且广泛使用的方案,不涉及任何特定平台绑定。
2. 选库这件事:为什么我最终选择了这几个方案
2.1 常见JSON库的横向对比
在C/C++生态里,处理JSON的库不算少,但真正适合嵌入到项目里的其实就那么几个。我整理了一个对比表格,方便你根据自己的场景做选择:
| 库名称 | 语言 | 是否需编译 | 内存管理 | 数组支持 | 适用场景 |
|---|---|---|---|---|---|
| cJSON | C | 源码集成 | 手动释放 | 完善 | 嵌入式、纯C项目 |
| nlohmann/json | C++ | 单头文件 | RAII自动 | 完善 | 现代C++项目、快速开发 |
| RapidJSON | C++ | 单头文件 | 可选 | 完善 | 高性能场景、大数据量 |
| jsoncpp | C++ | 需编译 | RAII自动 | 完善 | 传统C++项目 |
这张表里,cJSON和nlohmann/json是我用得最多的两个。cJSON的优势在于纯C、体积极小、源码就两个文件,直接拖进工程就能用,特别适合资源受限的环境。nlohmann/json的优势在于语法极其自然,像写Python一样操作JSON,代码可读性极高,适合业务逻辑复杂的场景。
RapidJSON性能最好,但API相对繁琐,学习曲线陡一些。jsoncpp比较老牌,但近年来用得越来越少,新项目不太推荐。
2.2 选型背后的逻辑:不是越强越好
很多人选库的时候喜欢看性能跑分,觉得解析速度越快越好。但实际项目中,解析性能往往不是瓶颈。一个典型的JSON报文可能就几百字节到几KB,解析耗时在微秒级别,相比网络传输和业务处理,完全可以忽略。
真正影响开发效率的是API的易用性和内存安全性。我见过太多项目因为手动管理cJSON的内存,在某个分支忘记cJSON_Delete,导致内存泄漏,跑了几天才暴露出来。也见过用nlohmann/json因为异常处理没做好,一个格式错误的报文直接让程序崩溃。
所以我的建议是:
- 如果是纯C环境或者资源极度受限(比如单片机),选cJSON,但一定要封装一层内存管理。
- 如果是C++11及以上,优先选nlohmann/json,开发效率高,内存自动管理,不容易出错。
- 如果是性能敏感的大数据量场景(比如每秒解析几万条),考虑RapidJSON。
注意:无论选哪个库,都不要在核心业务代码里直接裸用它的API。封装一层自己的接口,后续换库或者加日志、加校验都会方便很多。
2.3 集成方式:单头文件 vs 源码编译
nlohmann/json和RapidJSON都是单头文件库,直接把.hpp拖进项目,#include就能用,不需要改CMake或者Makefile。这一点对快速原型开发非常友好。
cJSON需要把.c和.h两个文件加入编译,如果是嵌入式环境,可能还需要配置malloc和free的钩子函数。我一般会在cJSON之上再包一层,比如定义一个JsonWrapper结构体,把创建、解析、释放都封装成函数,业务代码只调封装后的接口。
这里有个小技巧:如果你用的是cJSON,可以在项目里定义一个宏,统一替换cJSON_malloc和cJSON_free,方便后续接入自己的内存池或者做内存统计。
3. 字符串转JSON:解析过程中的那些坑
3.1 基本解析流程与代码骨架
先看一个最基础的例子,用nlohmann/json把一个字符串解析成JSON对象:
#include <nlohmann/json.hpp> #include <string> #include <iostream> using json = nlohmann::json; int main() { std::string raw = R"({"name":"sensor01","value":36.5,"status":"ok"})"; try { json j = json::parse(raw); std::string name = j["name"]; double value = j["value"]; std::string status = j["status"]; std::cout << name << " " << value << " " << status << std::endl; } catch (json::parse_error& e) { std::cerr << "解析失败: " << e.what() << std::endl; } return 0; }这段代码看起来很简单,但里面有几个关键点值得展开说。
第一,json::parse会抛出异常,必须用try-catch包住。很多人图省事不写异常处理,结果线上遇到一个格式错误的报文,整个服务就挂了。我一般会在catch里记录原始字符串,方便排查问题。
第二,用j["name"]取值的时候,如果字段不存在,nlohmann/json会返回一个null值,不会抛异常。但如果你直接把它赋值给std::string,就会得到一个空字符串。这个行为有时候会掩盖问题,建议用j.contains("name")先判断,或者用j.value("name", "default")指定默认值。
第三,对于数字类型,j["value"]返回的是json对象,赋值给double时会自动转换。但如果原始JSON里这个字段是字符串类型的"36.5",转换就会抛异常。所以解析之前最好对字段类型心里有数。
3.2 嵌套对象和数组的解析策略
实际报文往往不是扁平的,而是嵌套的。比如:
{ "device": { "id": "A001", "sensors": [ {"type": "temp", "value": 36.5}, {"type": "humidity", "value": 62.0} ] } }解析这种结构,用nlohmann/json可以这样写:
json j = json::parse(raw); std::string deviceId = j["device"]["id"]; for (auto& sensor : j["device"]["sensors"]) { std::string type = sensor["type"]; double value = sensor["value"]; // 处理每个传感器 }这里有个容易踩的坑:如果sensors字段不存在,j["device"]["sensors"]会返回null,对它做范围for循环会直接抛异常。所以稳妥的做法是先判断:
if (j.contains("device") && j["device"].contains("sensors") && j["device"]["sensors"].is_array()) { for (auto& sensor : j["device"]["sensors"]) { // ... } }用cJSON的话,解析嵌套结构需要一层层调用cJSON_GetObjectItem,代码会啰嗦一些:
cJSON* root = cJSON_Parse(raw); if (root == NULL) { // 解析失败 return; } cJSON* device = cJSON_GetObjectItem(root, "device"); if (device == NULL) { cJSON_Delete(root); return; } cJSON* sensors = cJSON_GetObjectItem(device, "sensors"); if (sensors == NULL || !cJSON_IsArray(sensors)) { cJSON_Delete(root); return; } int count = cJSON_GetArraySize(sensors); for (int i = 0; i < count; i++) { cJSON* sensor = cJSON_GetArrayItem(sensors, i); cJSON* type = cJSON_GetObjectItem(sensor, "type"); cJSON* value = cJSON_GetObjectItem(sensor, "value"); // 处理 } cJSON_Delete(root);可以看到,cJSON的每一步都要判空,而且最后必须记得cJSON_Delete。我一般会写一个辅助宏来简化判空逻辑,但即便如此,代码量还是比nlohmann/json多不少。
3.3 解析时的编码与转义问题
JSON标准要求字符串使用UTF-8编码,但实际项目中,你可能会遇到GBK编码的中文字符串,或者包含转义字符的内容。比如:
{"message": "温度过高\n请检查"}这里的\n是转义字符,解析后应该变成真正的换行符。nlohmann/json和cJSON都会自动处理标准转义,但如果你自己手动解析字符串,就很容易漏掉。
另一个常见问题是BOM头。有些系统生成的JSON文件会在开头带一个UTF-8 BOM(EF BB BF),直接解析会失败。处理方法是解析前先检查并跳过BOM:
if (raw.size() >= 3 && (unsigned char)raw[0] == 0xEF && (unsigned char)raw[1] == 0xBB && (unsigned char)raw[2] == 0xBF) { raw = raw.substr(3); }还有一个坑是数字精度。JSON里的数字如果很大,比如超过double的精度范围,解析后可能会丢失精度。如果业务涉及大整数(比如订单号),建议在JSON里用字符串存储,解析后再按需转换。
提示:解析之前先做一次格式校验,可以用在线的JSON校验工具确认报文是否合法。很多解析失败的问题,根源其实是发送方生成的JSON本身就不合法。
4. JSON转字符串:序列化时的格式控制与性能考量
4.1 基本序列化与紧凑格式
把JSON对象转回字符串,用nlohmann/json只需要一行:
json j; j["name"] = "sensor01"; j["value"] = 36.5; j["status"] = "ok"; std::string output = j.dump(); // 输出: {"name":"sensor01","status":"ok","value":36.5}注意,nlohmann/json默认会按key的字母顺序排序,所以输出顺序可能和你插入的顺序不一样。如果对顺序有要求,需要用ordered_json或者自己控制。
dump()默认生成紧凑格式,没有空格和换行。如果希望输出可读性更好的格式,可以传参数:
std::string pretty = j.dump(4); // 缩进4个空格cJSON的序列化:
cJSON* root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "name", "sensor01"); cJSON_AddNumberToObject(root, "value", 36.5); cJSON_AddStringToObject(root, "status", "ok"); char* output = cJSON_PrintUnformatted(root); // 使用output free(output); cJSON_Delete(root);cJSON_Print会生成带缩进的格式,cJSON_PrintUnformatted生成紧凑格式。注意返回的字符串是用malloc分配的,必须手动free,否则内存泄漏。
4.2 浮点数精度与特殊字符处理
浮点数序列化是个容易出问题的地方。比如36.5这个值,dump()后可能变成36.5,但也可能变成36.50000000000001,取决于底层浮点表示。如果对精度敏感,建议在序列化前先做四舍五入,或者用字符串存储。
特殊字符的处理也需要注意。比如字符串里包含双引号、反斜杠、控制字符,序列化时必须正确转义。nlohmann/json和cJSON都会自动处理,但如果你自己拼接字符串,就很容易出错。
我见过一个案例:某系统把用户输入直接拼进JSON字符串,用户输入了带双引号的内容,导致生成的JSON格式错误,接收方解析失败。正确的做法是始终用库的序列化接口,不要手动拼接。
4.3 大对象序列化的性能优化
当JSON对象很大(比如包含几千个数组元素)时,序列化可能成为性能瓶颈。几个优化思路:
第一,避免不必要的拷贝。nlohmann/json的dump()会返回一个新的std::string,如果对象很大,这个拷贝开销不小。可以考虑用dump的输出直接写入目标缓冲区。
第二,使用dump的ensure_ascii参数。默认情况下,非ASCII字符会被转义成\uXXXX形式,这会增加输出长度。如果接收方支持UTF-8,可以设置ensure_ascii=false:
std::string output = j.dump(-1, ' ', false, json::error_handler_t::replace);第三个参数就是ensure_ascii,设为false可以保留原始UTF-8字符。
第三,对于RapidJSON,可以用Writer直接写入预分配的缓冲区,避免动态内存分配。这在嵌入式环境里特别有用。
注意:序列化后的字符串如果用于网络传输,记得确认接收方的编码支持。有些老系统只认ASCII,这时候就必须保持
ensure_ascii=true。
5. 数组解析:从JSON数组到C/C++数据结构
5.1 遍历数组的几种方式
JSON数组解析成C/C++数组或容器,最直接的方式是遍历。用nlohmann/json:
json j = json::parse(R"([1, 2, 3, 4, 5])"); // 方式一:范围for for (auto& element : j) { int val = element; // 处理val } // 方式二:下标访问 for (size_t i = 0; i < j.size(); i++) { int val = j[i]; // 处理val } // 方式三:转成vector std::vector<int> vec = j.get<std::vector<int>>();方式三最简洁,但要求数组里所有元素类型一致,否则会抛异常。如果数组里混合了不同类型,就只能用前两种方式,逐个判断类型。
用cJSON遍历数组:
cJSON* array = cJSON_Parse(R"([1, 2, 3, 4, 5])"); int size = cJSON_GetArraySize(array); for (int i = 0; i < size; i++) { cJSON* item = cJSON_GetArrayItem(array, i); if (cJSON_IsNumber(item)) { int val = item->valueint; // 处理val } } cJSON_Delete(array);5.2 数组元素类型不一致时的处理
实际报文里,数组元素类型不一致的情况很常见。比如:
[ {"type": "temp", "value": 36.5}, {"type": "humidity", "value": 62}, {"type": "pressure", "value": 1013.25} ]这里每个元素都是对象,但value字段有的是浮点数,有的是整数。用nlohmann/json处理时,可以统一按double读取:
for (auto& item : j) { std::string type = item["type"]; double value = item["value"].get<double>(); // 处理 }get<double>()会把整数也转成浮点数,不会丢精度(在double范围内)。
如果数组里既有对象又有字符串,就需要先判断类型:
for (auto& item : j) { if (item.is_object()) { // 按对象处理 } else if (item.is_string()) { // 按字符串处理 } else if (item.is_number()) { // 按数字处理 } }5.3 嵌套数组的递归解析
嵌套数组,比如[[1,2],[3,4],[5,6]],需要递归或者双层循环处理:
json j = json::parse(R"([[1,2],[3,4],[5,6]])"); for (auto& row : j) { for (auto& col : row) { int val = col; // 处理val } }如果嵌套层数不确定,可以写一个递归函数:
void processArray(const json& j) { if (j.is_array()) { for (auto& item : j) { processArray(item); } } else if (j.is_number()) { // 处理叶子节点 } }这种递归方式在处理树形结构数据时特别有用,比如组织架构、文件目录等。
提示:递归解析时要注意栈深度。如果嵌套层数可能很深(比如超过1000层),建议改用迭代方式加显式栈,避免栈溢出。
6. 数组添加:动态构建JSON数组的实操细节
6.1 从空数组开始逐步添加元素
构建JSON数组,最常用的方式是从空数组开始,逐个添加元素。用nlohmann/json:
json arr = json::array(); arr.push_back(1); arr.push_back("hello"); arr.push_back({{"key", "value"}}); std::string output = arr.dump(); // 输出: [1,"hello",{"key":"value"}]json::array()创建一个空数组,push_back添加元素。注意,nlohmann/json的数组可以混合类型,这一点比C++的std::vector灵活。
用cJSON:
cJSON* arr = cJSON_CreateArray(); cJSON_AddItemToArray(arr, cJSON_CreateNumber(1)); cJSON_AddItemToArray(arr, cJSON_CreateString("hello")); cJSON* obj = cJSON_CreateObject(); cJSON_AddStringToObject(obj, "key", "value"); cJSON_AddItemToArray(arr, obj); char* output = cJSON_PrintUnformatted(arr); free(output); cJSON_Delete(arr);cJSON的cJSON_AddItemToArray会把元素的所有权转移给数组,所以最后只需要cJSON_Delete(arr),不需要单独释放每个元素。
6.2 批量添加与性能注意事项
如果需要添加大量元素,逐个push_back可能会有性能问题,因为数组可能需要多次扩容。nlohmann/json的数组底层是std::vector,扩容策略和vector类似。如果预先知道元素数量,可以先reserve:
json arr = json::array(); arr.get_ref<json::array_t&>().reserve(1000); for (int i = 0; i < 1000; i++) { arr.push_back(i); }不过说实话,对于大多数场景,这点性能差异可以忽略。除非你要添加几十万个元素,否则没必要过度优化。
cJSON的数组是链表结构,添加元素是O(1)操作,不需要预分配。但链表的内存开销比连续数组大,遍历时缓存局部性也差一些。
6.3 在已有数组中插入和删除元素
除了在末尾添加,有时候需要在中间插入或删除元素。nlohmann/json支持insert和erase:
json arr = {1, 2, 3, 4, 5}; // 在索引2处插入99 arr.insert(arr.begin() + 2, 99); // arr变成 [1,2,99,3,4,5] // 删除索引0处的元素 arr.erase(arr.begin()); // arr变成 [2,99,3,4,5]cJSON没有直接的插入接口,需要先创建新数组,按顺序添加元素,再替换原数组。或者用cJSON_InsertItemInArray(部分版本支持)。
这里有个坑:nlohmann/json的insert和erase会改变数组大小,如果之前保存了迭代器或引用,可能会失效。操作后需要重新获取。
注意:在遍历数组的过程中删除元素,容易导致迭代器失效或者跳过元素。如果确实需要边遍历边删除,建议先记录要删除的索引,遍历结束后统一删除。
7. 实战中的经验与避坑清单
7.1 内存管理:最容易出问题的地方
cJSON的内存管理是手动模式,每个cJSON_Create*和cJSON_Parse返回的对象都需要cJSON_Delete释放。我总结了几条规则:
cJSON_Parse返回的根对象,最后必须cJSON_Delete。cJSON_Print返回的字符串,必须free。cJSON_AddItemToArray和cJSON_AddItemToObject会转移所有权,不需要单独释放被添加的元素。- 如果解析失败,
cJSON_Parse返回NULL,不需要释放。
nlohmann/json是RAII模式,对象析构时自动释放内存,基本不用担心泄漏。但有一个例外:如果用了json::parse的allow_exceptions=false版本,解析失败会返回一个discarded值,后续操作需要判断。
7.2 错误处理:不要假设输入永远合法
线上环境里,你永远不知道会收到什么样的报文。可能缺字段、可能类型不对、可能整个JSON就是坏的。我的做法是:
- 解析时用
try-catch包住,记录原始报文和错误信息。 - 取字段前先判断存在性和类型。
- 对于关键字段,设置默认值或走降级逻辑。
- 记录解析失败的次数和频率,方便监控告警。
7.3 性能与可读性的平衡
有些团队为了追求极致性能,把所有JSON操作都写成手动字符串拼接,结果代码难以维护,bug频出。我的观点是:除非性能测试证明JSON处理是瓶颈,否则优先保证代码可读性和安全性。
用成熟的库,代码量少,出错概率低,后续维护成本也低。真到了性能瓶颈,再针对性优化也不迟。
7.4 跨平台兼容性注意事项
如果你的代码需要在多个平台编译,注意以下几点:
- 浮点数序列化的结果可能因平台而异,如果对格式有严格要求,建议统一用字符串存储。
- 字节序问题:JSON本身是文本格式,不涉及字节序,但如果JSON里嵌入了二进制数据的Base64编码,解码时要注意。
- 编译器差异:不同编译器对C++标准的支持程度不同,nlohmann/json需要C++11,RapidJSON对C++版本要求更低。
我在实际项目中遇到过一个问题:在某个平台上double序列化后多了一位小数,导致接收方校验失败。后来改成用std::ostringstream手动控制精度,才解决。
8. 一个完整的示例:从解析到修改再到序列化
最后,用一个完整的例子把前面讲的内容串起来。假设我们收到一个设备状态报文,需要解析出来,给每个传感器数组添加一个时间戳字段,再序列化发回去。
#include <nlohmann/json.hpp> #include <iostream> #include <string> using json = nlohmann::json; int main() { std::string raw = R"({ "device": "A001", "sensors": [ {"type": "temp", "value": 36.5}, {"type": "humidity", "value": 62.0} ] })"; try { json j = json::parse(raw); // 解析 std::string device = j.value("device", "unknown"); std::cout << "设备: " << device << std::endl; // 数组解析与添加 if (j.contains("sensors") && j["sensors"].is_array()) { for (auto& sensor : j["sensors"]) { std::string type = sensor.value("type", ""); double value = sensor.value("value", 0.0); std::cout << "传感器: " << type << " = " << value << std::endl; // 添加时间戳字段 sensor["timestamp"] = 1699999999; } } // 序列化 std::string output = j.dump(2); std::cout << "输出:\n" << output << std::endl; } catch (json::parse_error& e) { std::cerr << "解析错误: " << e.what() << std::endl; } catch (json::type_error& e) { std::cerr << "类型错误: " << e.what() << std::endl; } return 0; }这个例子里,j.value("device", "unknown")在字段不存在时返回默认值,比直接用j["device"]更安全。sensor["timestamp"] = 1699999999直接在遍历过程中修改了数组元素,因为用的是引用,修改会反映到原数组。
用cJSON实现同样的逻辑会复杂一些,需要手动创建时间戳对象并添加到每个传感器对象中,最后别忘了释放内存。
我在实际使用中发现,nlohmann/json的value方法配合默认值,能省掉大量判空代码,代码可读性提升明显。而cJSON虽然啰嗦,但在纯C环境和资源受限场景下依然是首选。选哪个,取决于你的项目约束和团队习惯。
最后再分享一个小技巧:如果JSON报文里字段名经常变,可以考虑用json::object的迭代器遍历所有key,做通用处理,而不是硬编码字段名。这样面对协议变更时,代码改动量会小很多。