更多 C++ 文章见《修远之路(C++集萃)》专栏
nlohmann 是基于 Tagged Union + SAX/DOM 双路径解析的 Header-Only C++ JSON 库,通过 ADL (Argument-Dependent Lookup, 实参依赖查找 )实现零侵入式类型序列化。让 JSON 操作像 STL 容器一样自然,同时保留足够的扩展性以支撑二进制格式(CBOR/MessagePack/BSON/UBJSON)解析。
| 能力 | 适用场景 | 不适用场景 |
|---|---|---|
| STL 格式访问与遍历 | 配置文件读写、HTTP JSON 响应构建 | 流式增量解析(需 SAX 手动实现) |
| ADL 自动类型转换 | 结构体与 JSON 双向映射 | 超大规模 JSON(>100MB)零拷贝解析 |
| JSON Pointer (RFC 6901) | 深层嵌套路径定位 | 频繁路径查询(无缓存机制) |
| JSON Patch (RFC 6902) | 运行时动态修改 JSON 文档 | 高并发修改(非线程安全) |
架构与流程
核心模块
| 模块 | 核心职责 | 输入 → 输出 |
|---|---|---|
| Lexer | 将字节流切分为 Token 流 | 原始字节 → token_type 枚举 |
| Parser | 递归下降语法分析,驱动 SAX 事件 | Token 流 → SAX 事件调用 |
| json_sax | 定义解析事件协议(纯虚接口) | 事件信号 → bool 继续/终止 |
| json_value | Tagged Union 存储 JSON 值 | value_t 标签 + 联合体 → 具体值 |
| adl_serializer | ADL 实现类型转换 | basic_json ↔ 自定义类型 |
| serializer | DOM 递归序列化为文本/二进制 | basic_json → 字节流 |
解析流程
原理与设计
关键抽象与机制
Tagged Union
Tagged Union --- value_t + json_value;这是整个库最核心的数据抽象。
basic_json 内部仅持有两个成员:
cpp
value_t m_type = value_t::null; // 1 byte discriminator
json_value m_value = {}; // tagged union
json_value 是一个裸 union,其中 object/array/string/binary 以指针存储,而 boolean/number_* 直接内联:
cpp
union json_value {
object_t* object; // heap-allocated
array_t* array; // heap-allocated
string_t* string; // heap-allocated
binary_t* binary; // heap-allocated
boolean_t boolean; // inline
number_integer_t number_integer; // inline
number_unsigned_t number_unsigned; // inline
number_float_t number_float; // inline
};
标量类型直接内联避免堆分配;复合类型用指针使 sizeof(basic_json) 保持固定(通常 16 字节:1 字节 type + padding + 8 字节 union),与 std::vector<basic_json> 的连续存储兼容。
SAX 双路径解析
Parser 本身不直接构建 DOM,而是通过 json_sax 接口发射事件。库内置三种 SAX 消费者:
json_sax_dom_parser:无条件构建完整 DOMjson_sax_dom_callback_parser:支持用户回调过滤,可跳过不需要的子树json_sax_acceptor:仅校验语法合法性,不构建任何数据结构
这种设计使得同一套 Parser/Lexer 基础设施可服务于"解析并构建"、"仅校验"、"选择性构建"三种场景,无需修改解析器代码。
ADL 类型转换
adl_serializer 通过 Argument-Dependent Lookup 实现零侵入式序列化。当 json.get<MyType>() 被调用时,查找链为:
adl_serializer::from_json(j, val)- →
::nlohmann::from_json(j, val)(ADL 在nlohmann命名空间查找) - → 用户在
MyType所在命名空间提供的from_json重载
用户无需修改 MyType 定义,无需继承任何基类,只需在同命名空间提供自由函数即可。
核心设计
Header-Only
选择 Header-Only 最大化了易用性------#include <nlohmann/json.hpp> 即可使用,无需链接。代价是编译耗时:单文件约 25000 行(v3.9.1),每个翻译单元包含时均需完整编译。v3.11+ 提供了 json_fwd.hpp 前置声明以缓解前向引用场景的编译开销。
指针存储
object/array/string/binary 在 union 中以指针存储而非值存储。这是为了:
- 控制
sizeof(basic_json)固定为 16 字节,保证数组连续内存布局 - 移动语义仅需交换指针,无需深拷贝
- 空值(null)不分配堆内存
代价是每次访问复合类型都需一次指针间接寻址,且小字符串(SSO 优化)的优势被指针分配开销抵消。
SAX 事件驱动
SAX 中间层引入了虚函数调用开销(每个 JSON 值至少一次虚调用),但换来了:
- 回调过滤能力(
parser_callback_t) - 格式无关的解析管道(同一 SAX 接口服务 JSON/CBOR/MessagePack/BSON/UBJSON)
- 用户自定义 SAX 消费者的扩展能力
源码地图
plain
json.hpp (single-header amalgamation, ~25000 lines)
├── value_t enum # 类型标签枚举,DOM 分发核心
├── adl_serializer # ADL 类型转换策略
├── json_sax # SAX 事件接口定义
├── json_sax_dom_parser # SAX→DOM 构建器
├── lexer_base / lexer # 词法分析器
├── parser # 递归下降语法分析器
├── json_pointer # RFC 6901 JSON Pointer
├── serializer # DOM→文本/二进制序列化器
├── basic_json # 核心 DOM 类
└── json_value union # Tagged Union 存储层
API 详解
常用 API
| API | 参数 | 说明 |
|---|---|---|
json::parse(Input) |
输入:字符串/流/迭代器对 | 从输入构建 DOM;抛 parse_error |
json::sax_parse(Input, SAX*) |
输入 + SAX 消费者指针 | SAX 模式解析,返回 bool 成功/失败 |
json::dump(indent, ensure_ascii) |
缩进宽度、是否纯 ASCII | 序列化为字符串;抛 type_error |
json::operator[](key) |
字符串键或整数索引 | 访问/创建元素;越界抛 out_of_range |
json::get<T>() |
目标类型 | 类型转换;失败抛 type_error |
json::get_ptr<T*>() |
指针类型 | 零拷贝获取内部指针;类型不匹配返回 nullptr |
json::contains(key) |
键名 | 检查对象是否包含指定键 |
json::emplace(key, value) |
键值对 | 原位构造,避免临时对象 |
json::find(key) |
键名 | 返回迭代器;未找到返回 end() |
json::patch(patch_doc) |
RFC 6902 Patch 文档 | 应用 JSON Patch 操作 |
json::flatten() / unflatten() |
无 | 将嵌套对象扁平化为 JSON Pointer 路径键 |
样例
以下示例展示Engine 场景中典型的 JSON 构建、解析与错误处理:
cpp
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <fstream>
#include <stdexcept>
using json = nlohmann::json;
struct EngineConfig {
int threadCount;
float similarityThreshold;
std::string modelPath;
};
void from_json(const json& j, EngineConfig& cfg) {
j.at("threadCount").get_to(cfg.threadCount);
j.at("similarityThreshold").get_to(cfg.similarityThreshold);
j.at("modelPath").get_to(cfg.modelPath);
}
void to_json(json& j, const EngineConfig& cfg) {
j = json{
{"threadCount", cfg.threadCount},
{"similarityThreshold", cfg.similarityThreshold},
{"modelPath", cfg.modelPath}
};
}
json loadAndValidate(const std::string& filePath) {
std::ifstream ifs(filePath);
if (!ifs.is_open()) {
throw std::runtime_error("Cannot open config file: " + filePath);
}
try {
json doc = json::parse(ifs);
if (!doc.is_object()) {
throw std::runtime_error("Config root must be an object");
}
if (!doc.contains("threadCount") || !doc["threadCount"].is_number_integer()) {
throw std::runtime_error("Missing or invalid field: threadCount");
}
if (!doc.contains("similarityThreshold") || !doc["similarityThreshold"].is_number()) {
throw std::runtime_error("Missing or invalid field: similarityThreshold");
}
if (!doc.contains("modelPath") || !doc["modelPath"].is_string()) {
throw std::runtime_error("Missing or invalid field: modelPath");
}
return doc;
} catch (const json::parse_error& e) {
spdlog::error("JSON parse error at byte {}: {}", e.byte, e.what());
throw;
} catch (const json::type_error& e) {
spdlog::error("JSON type error: {}", e.what());
throw;
}
}
void buildResponse() {
json response;
response["status"] = "ok";
response["data"] = json::array();
for (int i = 0; i < 3; ++i) {
response["data"].emplace_back(json{
{"id", i},
{"score", 0.95 - i * 0.1},
{"label", "person_" + std::to_string(i)}
});
}
std::string payload = response.dump(2);
spdlog::info("Response payload size: {} bytes", payload.size());
}
int main() {
try {
json configDoc = loadAndValidate("config.json");
EngineConfig cfg = configDoc.get<EngineConfig>();
spdlog::info("Loaded config: threads={}, threshold={:.2f}, model={}",
cfg.threadCount, cfg.similarityThreshold, cfg.modelPath);
buildResponse();
json patch = json::parse(R"([{"op":"replace","path":"/threadCount","value":8}])");
json patched = configDoc.patch(patch);
spdlog::info("Patched threadCount: {}", patched["threadCount"].get<int>());
json flat = configDoc.flatten();
spdlog::info("Flattened keys: {}", flat.dump());
} catch (const std::exception& e) {
spdlog::error("Fatal: {}", e.what());
return 1;
}
return 0;
}
JSON与类直接映射
nlohmann/json 提供了三种将 JSON 对象与 C++ 结构体/类双向映射的机制,从全自动到全手动,覆盖不同控制粒度需求。
| 机制 | 声明位置 | 说明 |
|---|---|---|
| NLOHMANN_DEFINE_TYPE_INTRUSIVE | 类内部 | 修改类型定义,不支持默认值,适配 POD、字段名与 JSON 键一致 |
| NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE | 类外部 | 不修改类型定义,不支持默认值,适配不可修改第三方类、字段全公开 |
| ADL from_json/to_json 自由函数 | 类所在命名空间 | 不修改类型定义,支持默认值,支持校验、嵌套映射 |
| 手动静态方法 | 类内部 | 修改类型定义,支持默认值,支持完整控制、校验、日志、异常 |
宏映射(全自动)
适用于字段名与 JSON 键名完全一致、所有字段必须存在的简单结构体:
cpp
struct Person {
std::string name;
int age;
NLOHMANN_DEFINE_TYPE_INTRUSIVE(Person, name, age)
};
INTRUSIVE 版本需放在类内部(修改类定义),NON_INTRUSIVE 版本放在类外部:
cpp
struct Person {
std::string name;
int age;
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Person, name, age)
宏展开后生成的 from_json/to_json 使用 at() 访问字段------键不存在时抛出 out_of_range,无法提供默认值;字段顺序无关,但字段名必须精确匹配。
ADL 自由函数
在类所在命名空间提供 from_json 与 to_json 自由函数,nlohmann 通过 ADL 自动发现:
cpp
struct EngineConfig {
int threadCount;
float similarityThreshold;
std::string modelPath;
};
namespace nlohmann {
void from_json(const json& j, EngineConfig& cfg) {
cfg.threadCount = j.at("threadCount").get<int>();
cfg.similarityThreshold = j.at("similarityThreshold").get<float>();
cfg.modelPath = j.at("modelPath").get<std::string>();
}
void to_json(json& j, const EngineConfig& cfg) {
j = json{{"threadCount", cfg.threadCount},
{"similarityThreshold", cfg.similarityThreshold},
{"modelPath", cfg.modelPath}};
}
}
关键细节:
j.at("key")--- 键不存在抛out_of_range,用于必填字段j.value("key", default)--- 键不存在返回默认值,用于可选字段j.contains("key")--- 显式检查键是否存在,用于条件逻辑get_to()--- 将 JSON 值写入已有变量,避免临时对象:j.at("x").get_to(cfg.x)
带默认值与校验的完整示例:
cpp
namespace nlohmann {
void from_json(const json& j, EngineConfig& cfg) {
cfg.threadCount = j.value("threadCount", 4);
if (cfg.threadCount <= 0) {
throw std::invalid_argument("threadCount must be positive");
}
cfg.similarityThreshold = j.value("similarityThreshold", 0.8f);
cfg.modelPath = j.at("modelPath").get<std::string>();
}
}
嵌套对象映射:当 JSON 包含嵌套结构时,递归调用 get<>():
cpp
struct DatabaseConfig {
std::string host;
int port;
};
struct AppConfig {
DatabaseConfig database;
int logLevel;
};
namespace nlohmann {
void from_json(const json& j, DatabaseConfig& cfg) {
cfg.host = j.at("host").get<std::string>();
cfg.port = j.value("port", 5432);
}
void from_json(const json& j, AppConfig& cfg) {
cfg.database = j.at("database").get<DatabaseConfig>();
cfg.logLevel = j.value("logLevel", 1);
}
}
枚举映射:nlohmann 不内置枚举转换,需手动实现。推荐使用 std::unordered_map 双向查找:
cpp
enum class QueryType { Face, Body, Photo };
const std::unordered_map<std::string, QueryType> kQueryTypeMap = {
{"face", QueryType::Face},
{"body", QueryType::Body},
{"photo", QueryType::Photo}
};
namespace nlohmann {
void from_json(const json& j, QueryType& t) {
auto name = j.get<std::string>();
auto it = kQueryTypeMap.find(name);
if (it == kQueryTypeMap.end()) {
throw std::invalid_argument("Unknown QueryType: " + name);
}
t = it->second;
}
void to_json(json& j, QueryType t) {
for (const auto& [name, val] : kQueryTypeMap) {
if (val == t) { j = name; return; }
}
j = nullptr;
}
}
手动静态方法
静态 from_json 成员函数模式,将反序列化逻辑封装为类的工厂方法,优势在于:
- 可传入上下文参数(如默认值、配置项)
- 可在构造过程中执行业务校验与日志
- 调用方显式选择反序列化入口,避免隐式转换
cpp
class SearchQueryArgs {
public:
int topN;
int thresholdMin;
int thresholdMax;
std::string imgType;
static SearchQueryArgs from_json(const nlohmann::json& inJson, int defTopN) {
SearchQueryArgs tmp;
tmp.topN = inJson.value("topN", defTopN);
tmp.thresholdMin = inJson.value("thresholdMin", 0);
tmp.thresholdMax = inJson.value("thresholdMax", 100);
tmp.imgType = inJson.value("imgType", "face");
if (tmp.topN <= 0 || tmp.topN > 1000) {
throw EngineParamError("topN out of range");
}
return tmp;
}
};
ADL vs 手动静态方法选择指南:
| 维度 | ADL 自由函数 | 手动静态方法 |
|---|---|---|
| 调用方式 | 隐式:json.get<T>() |
显式:T::from_json(j, ctx) |
| 上下文传递 | 不支持(签名固定) | 支持额外参数 |
| 与 STL 算法兼容 | 是(get<vector<T>>() 递归调用) |
需手动处理容器 |
| 代码发现性 | 低(ADL 查找隐式) | 高(显式调用) |
| 第三方库集成 | 适合(不修改类定义) | 不适合(需修改类) |
内置支持的类型
nlohmann/json 开箱即支持以下类型的自动转换,无需手写 from_json/to_json:
| 类别 | 类型 |
|---|---|
| 标量 | bool, int, double, float, std::nullptr_t |
| 字符串 | std::string, const char* |
| 容器 | std::vector<T>, std::list<T>, std::deque<T>, std::array<T,N>, std::valarray<T> |
| 关联容器 | std::map<K,V>, std::unordered_map<K,V>, std::multimap<K,V> |
| 集合 | std::set<T>, std::unordered_set<T> |
| 元组 | std::tuple<Ts...>, std::pair<A,B> |
| 可选 | C++17 std::optional<T> (v3.11+) |
| 智能指针 | std::unique_ptr<T>, std::shared_ptr<T> |
当 T 本身已支持 from_json/to_json 时,std::vector<T> 等容器自动获得递归转换能力。
总结
nlohmann/json "通过接口解耦将单一职责推向极致":Parser 不构建 DOM,SAX 不持有数据,Serializer 不感知输出目标,类型转换不侵入用户类型。每一层都只做一件事,层与层之间通过极简接口(json_sax 的纯虚函数、output_adapter_t 的类型擦除、ADL 的自由函数)连接。这种设计使得库在保持 API 极简的同时,具备了远超表面复杂度的扩展能力。
单头文件约 25000 行,在大型项目中每个包含它的翻译单元均需完整编译(编译耗时);推荐使用 v3.11+ 的 json_fwd.hpp 减少头文件依赖传播。
每个 basic_json 对象固定 16 字节,加上 object_t/array_t/string_t 的堆分配。对于大量小 JSON 对象(如仅含一个整数的对象),实际内存远超数据本身。如:一个 {"a":1} 约占 16(根对象)+ 堆上 std::map 开销 + 堆上 std::string" "a" + 16(值对象)≈ 100+ 字节。
basic_json 非线程安全:不同线程访问同一 json 对象(即使只读)需外部同步;不同线程操作不同 json 对象是安全的;const 对象的并发读取在实践中安全,但标准未做保证。