JSON(JavaScript Object Notation)是实际开发中最常用的一种轻量级数据交换格式。在前后端接口通信与程序配置文件中,到处都能看到它的身影。与前面平铺的 CSV 表格相比,JSON 不仅支持字符串、数字和布尔值,还能直接表示数组与多层嵌套结构。
需要注意的是,C++ 标准库本身没有内置 JSON 解析器,我们需要借助成熟的第三方库来完成解析。目前 C++ 社区最常用的是 nlohmann/json 库,它只需要引入一个 json.hpp 单头文件就能直接使用,非常适合初学的小伙伴快速上手。
JSON 结构与环境准备
在正式编写 C++ 解析代码之前,我们先来认识一下 JSON 的基本结构,并把本地编译环境准备好。
1. JSON 的基本结构
我们先来看一份保存了用户信息的简单 JSON 数据:
{
"name": "Jack",
"age": 20,
"active": true,
"address": {
"city": "广州"
},
"skills": ["C++", "Python"]
}观察上面这份数据,我们可以总结出 JSON 最基本的 2 种组织结构:
- JSON 对象:使用花括号({})包裹,里面保存的是一组 “键 : 值” 数据。比如
"name": "Jack"中,"name"是键(字段名),"Jack"是对应的值。如果某个键对应的值本身又是一对花括号(如上面的"address"),就构成了嵌套对象。 - JSON 数组:使用方括号([])包裹,用来按顺序保存一组数据。比如上面的
"skills"对应的["C++", "Python"],就是一个包含两个字符串的数组。
另外,JSON 对语法格式要求很严格。平时手动编写或修改测试数据时要注意两点:① 所有的键名和字符串都必须使用英文双引号(")括起来,不能改成单引号;② 同一层最后一个字段或数组元素的末尾不能多留逗号,否则解析时会直接报错。
在 C++ 中解析 JSON,说白了就是让解析器识别出花括号、方括号和各个字段,再把里面的数据读取为对应的 C++ 类型。常见的类型对应关系如下表所示。
| JSON 类型 | JSON 示例 | 对应的 C++ 类型 |
|---|---|---|
| 字符串(string) | "Jack"、"广州" | std::string |
| 整数(number) | 20、100 | int / long long |
| 浮点数(number) | 3.14、99.5 | double / float |
| 布尔值(boolean) | true、false | bool |
| 数组(array) | ["C++", "Python"] | nlohmann::json 或 std::vector |
| 对象(object) | {"city": "广州"} | nlohmann::json |
2. 准备 nlohmann/json 头文件
在使用 nlohmann/json 之前,我们可以先打开 nlohmann/json 官方发布页,滑到最新版本底部的【Assets】文件列表。在这里直接点击下载独立的 json.hpp 单头文件即可,不需要下载整个源码压缩包。
为了让目录结构尽量简单,下载好 json.hpp 后,我们可以直接把它和源代码文件 main.cpp 放在同一个项目文件夹下:
project/
main.cpp
json.hpp因为 json.hpp 就存放在当前项目目录中,我们在 C++ 代码开头通过双引号引入该头文件,并使用 using 关键字给它起一个简短的类型别名:
#include "json.hpp"
using json = nlohmann::json;上面代码中的 using json = nlohmann::json; 表示给原本较长的类型名 nlohmann::json 起了一个简短的别名 json。后面在代码中写 json,代表的就是该库提供的 JSON 数据类型。
写好代码后,我们打开终端进入 project 目录,直接执行 g++ -std=c++20 main.cpp -o main 就能完成编译(该库所有实现都在单个头文件里,无需额外配置链接参数)。编译通过后,在 Windows 终端输入 .\main.exe(macOS 或 Linux 下输入 ./main)即可运行程序。
提示 由于 nlohmann/json 属于第三方库,在线编译器默认未内置 json.hpp 头文件,因此这一节与下一节的示例代码需要在本地项目目录中编译运行。如果项目已通过 CMake 或包管理器安装过该库,也可以写成 #include <nlohmann/json.hpp>。
解析 JSON 字符串
在 C++ 中,当程序里已经有一段 JSON 格式的字符串时,我们可以调用 json::parse() 将其解析为 json 对象,然后再按字段名提取数据。
语法:
json data = json::parse(字符串);
目标类型 变量名 = data.at("键名").get<目标类型>();说明:
json::parse(字符串) 表示把普通的字符串解析成结构化的 json 对象 data。解析完成后,取值分为两步:我们先调用 data.at("键名") 找到指定键名对应的字段,再调用 .get<目标类型>() 将其转换成 std::string、int、bool 等目标 C++ 类型。
另外,JSON 文本内部包含大量双引号(")。如果使用普通字符串书写,每个双引号前面都要加上反斜杠写成 \",非常容易看花眼。
在实际开发中,我们通常使用 C++11 引入的原始字符串字面量 R"(...)" 来书写多行 JSON 文本。写在 R"( 和 )" 之间的双引号与换行都会保持原样,不需要转义。比如表示同一段 JSON 文本,下面 2 种方式是等价的。
// 方式 1
std::string s1 = "{\"name\": \"Jack\", \"age\": 20}";
// 方式 2(推荐)
std::string s2 = R"({"name": "Jack", "age": 20})";示例 1:解析基础 JSON 对象
#include <iostream>
#include <string>
#include "json.hpp"
using json = nlohmann::json;
int main() {
std::string text = R"({
"name": "Jack",
"age": 20,
"active": true
})";
// 解析 JSON 字符串
json data = json::parse(text);
// 按字段名提取数据
std::string name = data.at("name").get<std::string>();
int age = data.at("age").get<int>();
bool active = data.at("active").get<bool>();
std::cout << "name=" << name << '\n';
std::cout << "age=" << age << '\n';
std::cout << std::boolalpha << "active=" << active << '\n';
return 0;
}运行结果如下。
name=Jack
age=20
active=true分析:
json data = json::parse(text); 先把整段 JSON 字符串解析成了结构化的 data 对象。接着我们就不需要自己去字符串里查找冒号和逗号了,直接按字段名提取即可。
其中,data.at("name").get<std::string>() 提取出字符串 "Jack",data.at("age").get<int>() 提取出整数 20。最后一行输出中的 std::boolalpha 用来把布尔值打印为 true 或 false,而不是默认的 1 或 0。
顺便提一点,当等号左边已经明确写出了具体的变量类型(非 auto)时,nlohmann/json 也支持省掉末尾的 .get<...>() 自动完成转换。不过显式写出 .get<目标类型>() 能让人一眼看清期望的数据类型,初学阶段更推荐大家养成显式写出的习惯。
如果 JSON 里面还包含嵌套对象和数组,此时又应该怎么读取呢?下面我们来看一个例子。
示例 2:解析嵌套对象与数组
#include <iostream>
#include <string>
#include "json.hpp"
using json = nlohmann::json;
int main() {
std::string text = R"({
"name": "Jack",
"address": {
"city": "广州"
},
"skills": ["C++", "Python"]
})";
json data = json::parse(text);
// 读取嵌套对象字段
auto city = data.at("address").at("city").get<std::string>();
std::cout << "city=" << city << '\n';
// 遍历 JSON 数组
std::cout << "skills:";
for (const auto& skill : data.at("skills")) {
std::cout << ' ' << skill.get<std::string>();
}
std::cout << '\n';
return 0;
}运行结果如下。
city=广州
skills: C++ Python分析:
对于嵌套对象的读取代码,我们可以按 “从外往里” 的顺序一层层拆开看:先通过 data.at("address") 拿到外层的子对象,紧接着调用 .at("city") 从该子对象中取出城市字段,最后调用 .get<std::string>() 转为 C++ 字符串。
对于 JSON 数组 data.at("skills"),除了使用循环逐个读取元素外,还有一种更省事的写法。只要数组里的元素类型一致,我们在引入 <vector> 头文件后就能将其整体转为 std::vector 容器:
#include <vector>
// 整体转为 vector 容器
auto skills = data.at("skills").get<std::vector<std::string>>();从 JSON 文件中读取数据
在实际开发中,JSON 数据大多数时候保存在独立的 .json 文件中,而不是直接写死在代码里。这时我们只需要使用输入文件流 std::ifstream 打开文件,再把文件流对象直接传给 json::parse() 即可。
为了方便小伙伴们在本地直接复制代码运行测试,下面的示例会先在当前目录下生成一个 student.json 文件,然后再打开并解析它。
示例 3:读取并解析 JSON 文件
#include <fstream>
#include <iostream>
#include <string>
#include "json.hpp"
using json = nlohmann::json;
int main() {
// 生成测试用的 JSON 文件
std::ofstream outFile("student.json");
outFile << R"({
"name": "Lucy",
"score": 95,
"passed": true
})";
outFile.close();
// 打开文件并直接解析文件流
std::ifstream inFile("student.json");
if (!inFile.is_open()) {
std::cout << "无法打开文件 student.json\n";
return 1;
}
json data = json::parse(inFile);
std::string name = data.at("name").get<std::string>();
int score = data.at("score").get<int>();
bool passed = data.at("passed").get<bool>();
std::cout << "name=" << name << '\n';
std::cout << "score=" << score << '\n';
std::cout << std::boolalpha << "passed=" << passed << '\n';
return 0;
}运行结果如下。
name=Lucy
score=95
passed=true分析:
在这个例子中,json data = json::parse(inFile); 直接接收了输入文件流对象 inFile。这是因为 nlohmann/json 原生支持从文件流中直接读取并解析全文,不需要我们先调用 std::getline() 把文件内容一行行拼接成字符串,写起来非常方便。
注意 nlohmann/json 解析器要求输入的文本采用 UTF-8 编码。如果 .json 文件中包含中文,保存文件时记得选择 UTF-8 编码格式,避免因编码不一致导致解析报错。
读取可选字段:contains() 与 value()
前面的示例一直使用 at() 来提取字段。对于必须存在的 “必填字段” 来说,使用 at() 非常合适,因为一旦某个必填字段缺失,at() 会立即抛出异常提醒我们数据不完整。
但在实际业务中,像用户的邮箱、个人简介这类字段可能是可有可无的 “可选字段”。处理可选字段时,常用的有以下 2 个成员函数:
- contains(key):判断 JSON 对象中是否包含指定的键名。存在时返回
true;不存在时返回false。 - value(key, defaultVal):尝试读取指定键名的值。如果该字段存在就返回实际值;如果不存在则直接返回第 2 个参数指定的默认值
defaultVal,不会触发异常。
语法:
bool exists = data.contains("键名");
目标类型 变量名 = data.value("键名", 默认值);说明:
如果我们只需要 “有这个字段才处理,没有就跳过”,适合使用 if (data.contains("键名")) 来判断。如果我们希望在字段缺失时自动提供一个默认值(如默认等级为 1),直接调用 data.value("键名", 默认值) 即可,返回值类型会根据默认值的类型自动推导出来。
示例 4:读取可选字段
#include <iostream>
#include <string>
#include "json.hpp"
using json = nlohmann::json;
int main() {
std::string text = R"({
"name": "阿莫",
"site": "绿叶网"
})";
json data = json::parse(text);
// 先判断可选字段是否存在
if (data.contains("site")) {
std::string site = data.at("site").get<std::string>();
std::cout << "site=" << site << '\n';
}
// 读取可选字段并设置默认值
std::string email = data.value("email", "未填写");
int level = data.value("level", 1);
std::cout << "email=" << email << '\n';
std::cout << "level=" << level << '\n';
return 0;
}运行结果如下。
site=绿叶网
email=未填写
level=1分析:
由于 "email" 和 "level" 两个字段在原 JSON 数据中并不存在,当我们调用 data.value("email", "未填写") 和 data.value("level", 1) 时,程序不会报错,而是直接返回了预设的默认值 "未填写" 和 1。
学过 std::map 的小伙伴可能会好奇:为什么读取可选字段时不直接使用中括号 data["email"] 呢?这是因为当键名不存在时,data["email"] 会自动往原对象里新插入一个 "email": null 空字段,紧接着调用 .get<std::string>() 把空值 null 转为字符串时同样会触发类型异常。
// 推荐
std::string s1 = data.value("email", "未填写");
// 不推荐
std::string s2 = data["email"].get<std::string>();简单来说,读取外部传来的 JSON 数据时,必填字段使用 at(),可选字段使用 value()。这样既能保证安全,又不会意外修改原有的 JSON 对象。
JSON 解析常见异常与捕获
当 JSON 数据来自用户输入、本地配置文件或网络接口时,我们不能假设它永远是规范无误的。对于初学者来说,只要分清下面这 3 类常见异常,就能配合 try...catch 捕获并处理绝大多数 JSON 解析报错。
| 异常类型 | 触发原因 | 常见报错场景 |
|---|---|---|
| json::parse_error | JSON 文本语法不合法 | 漏写双引号、括号不配对、末尾多写了逗号 |
| json::out_of_range | 访问的字段不存在 | 调用 at("xxx") 读取了一个不存在的键名 |
| json::type_error | 字段类型与目标类型不匹配 | JSON 中是字符串 "20",代码却调用了 get<int>() |
下面我们通过一个例子,看看怎样在代码中捕获并区分这几类异常。
示例 5:捕获 JSON 解析异常
#include <iostream>
#include "json.hpp"
using json = nlohmann::json;
int main() {
// 捕获语法格式错误
try {
json badData = json::parse(R"({"name": "Jack",})");
} catch (const json::parse_error&) {
std::cout << "捕获异常:JSON 语法格式错误\n";
}
// 捕获字段缺失与类型不匹配异常
json data = json::parse(R"({"age": "20"})");
try {
int age = data.at("age").get<int>();
std::cout << age << '\n';
} catch (const json::out_of_range&) {
std::cout << "捕获异常:请求的字段不存在\n";
} catch (const json::type_error&) {
std::cout << "捕获异常:字段数据类型不匹配\n";
}
return 0;
}运行结果如下。
捕获异常:JSON 语法格式错误
捕获异常:字段数据类型不匹配分析:
从运行结果可以看出,两个 catch 分支分别捕获到了对应的错误。在第 1 处测试中,虽然 C++ 初始化数组或结构体时允许在最后一个元素后面保留逗号,但标准 JSON 语法不允许在末尾多留逗号,因此 json::parse() 直接抛出了 json::parse_error 异常。
在第 2 处测试中,很多初学的小伙伴容易把 "20" 和 20 搞混。在 JSON 中,不带引号的 20 是数字,而加上双引号的 "20" 则是字符串。nlohmann/json 不会把字符串隐式转换为整数,因此对字符串 "20" 调用 get<int>() 时就会触发 json::type_error 异常。
