【C++三方组件】RapidJSON 上:API 分类精讲
【摘要】:上一篇说过:性能敏感的热路径,从 nlohmann/json 换到 RapidJSON。但 RapidJSON 是一辆手动挡——分配器要自己递、类型要自己查、字符串默认不拷贝,API 面也比 nlohmann 宽一倍。本文按类精讲它的六类 API:解析(含 ParseFlags 全参数表)、错误处理、DOM 查询、DOM 修改(重点讲清拷贝与引用的分界)、序列化(Writer/PrettyWriter)、SAX(Handler 十三个回调),外加原位解析一节,每类配参数说明、示例代码与实测输出;末尾是与 nlohmann 的同任务写法对照表和六个必知的坑。下一篇拆它的性能实现。 【关键词】:RapidJSON、JSON、DOM、SAX、分配器、原位解析、序列化 【版本基准】:RapidJSON 1.1.0(master@24b5e7a,2024-12,MIT)|C++17|文中输出均为 g++ 11.2 实测
1. 开场:从自动挡换到手动挡
同一个「给对象加个成员」,两种世界观:
// nlohmann/json:自动挡
j["port"] = 8080;
// RapidJSON:手动挡
doc.AddMember("port", 8080, doc.GetAllocator());
自动挡舒服——类型、内存、拷贝全自动;代价藏在热路径上:一遍解析数十万次时,每个字符串一次堆分配、每次类型判断一层抽象,账单会说话。RapidJSON 的选择是把所有自动的东西交还给你:分配器显式传递、类型显式检查、字符串显式选择拷不拷。手动挡开好了,就是本专栏速度梯队里前排的那辆车。
本文只讲怎么开;它为什么快,留给下篇。所有示例可编译(g++ -std=c++17 -I<rapidjson/include>),输出为实测。
2. 30 秒接入
纯头文件库,接入三选一:
// vcpkg:vcpkg.json
{ "dependencies": [ "rapidjson" ] }
# vcpkg / 系统安装通用
find_package(RapidJSON CONFIG REQUIRED)
target_link_libraries(app PRIVATE rapidjson)
# 或 FetchContent(header-only,URL 即锁版本)
include(FetchContent)
FetchContent_Declare(rapidjson
GIT_REPOSITORY https://github.com/Tencent/rapidjson
GIT_TAG v1.1.0)
FetchContent_MakeAvailable(rapidjson)
最小验证一行:rapidjson::Document d; d.Parse("{\\"ok\\":true}");。
3. 心智模型:手动挡三定律
Value(GenericValue 的 UTF8 特化)是一个「类型位标记 + 紧凑联合体」的值,七种类型:
| null | IsNull() | —— | Value() |
| false / true | IsBool() | GetBool() | Value(false) |
| object | IsObject() | GetObject() | Value(kObjectType) |
| array | IsArray() | GetArray() | Value(kArrayType) |
| string | IsString() | GetString() | Value("s")(引用) |
| number | IsInt() 等六兄弟 | GetInt() 等 | Value(42) |
Document 是特殊的 Value,自带内存池与解析器。开好这辆手动挡,记住三定律,后面六节全是三定律的展开:
4. 解析类 API:Parse 家族与 ParseFlags
| Parse(const Ch* str) | 零终止字符串 | 默认 flags 解析 |
| Parse(const Ch* str, size_t length) | 带长度 | 可含 \\0 |
| ParseStream(InputStream& is) | 流 | StringStream(内存)/ FileReadStream(文件) |
| ParseInsitu(Ch* str) | 可写缓冲 | in-situ 解析,字符串零拷贝(§10) |
| Parse<parseFlags>(…) | 模板参数 | 组合下表任意 flags |
parseFlags 是模板位标志,取值与含义(源码 reader.h:148 起,值即位序):
| kParseInsituFlag | 1 | 原位(破坏性)解析,字符串零拷贝 |
| kParseValidateEncodingFlag | 2 | 校验字符串编码 |
| kParseIterativeFlag | 4 | 迭代式解析,函数栈 O(1),防深嵌套爆栈 |
| kParseStopWhenDoneFlag | 8 | 读到一个完整根值即停(一个流里多个 JSON 时用) |
| kParseFullPrecisionFlag | 16 | 数字全精度解析(慢;默认最多 3 ULP 误差) |
| kParseCommentsFlag | 32 | 允许 // 与 /* */ 注释 |
| kParseNumbersAsStringsFlag | 64 | 数字保留为字符串,不转数值 |
| kParseTrailingCommasFlag | 128 | 允许尾逗号 |
| kParseNanAndInfFlag | 256 | 允许 NaN/Inf/Infinity |
| kParseEscapedApostropheFlag | 512 | 允许 \\' |
using namespace rapidjson;
Document doc;
doc.Parse(R"({
"name": "asset-sync", "retries": 3,
"ratio": 0.75, "active": true,
"tags": ["fast", "safe"],
"server": {"host": "127.0.0.1", "port": 8080}
})");
// 成功:doc.IsObject() == true
5. 错误处理类 API:错误码 + 字节偏移
RapidJSON 不抛异常(默认),错误三件套挂在 Document 上:
| HasParseError() | bool | 是否出错 |
| GetParseError() | ParseErrorCode | 错误码枚举 |
| GetErrorOffset() | size_t | 出错处的字节偏移(从 0 起) |
| GetParseError_En(code) | const char* | 错误码 → 英文描述(error/en.h) |
错误码枚举覆盖 20 种语法错误(error/en.h 可查全表):文档级(kParseErrorDocumentEmpty)、对象级(缺名/缺冒号/缺逗号)、字符串级(非法转义/坏代理对/缺引号)、数字级(太大/缺小数部分)等。实测一个尾逗号:
Document d;
d.Parse("{\\"a\\":1,}"); // JSON 标准不允许尾逗号
std::cout << GetParseError_En(d.GetParseError())
<< " @" << d.GetErrorOffset() << "\\n";
实测输出:
Missing a name for object member. @7
偏移 7 正是 } 的位置——拿到偏移直接定位到字节,比「解析失败」四个字有用得多。要容忍尾逗号,Parse<kParseTrailingCommasFlag> 一行解决。
6. DOM 查询类 API:先 Is 后 Get
| IsInt() IsUint() IsInt64() IsUint64() IsDouble() IsNumber() | 数字六态判断 |
| GetInt() GetDouble() GetString() GetBool() | 取值(不检查,见 §11 坑 ①) |
| GetStringLength() | 字符串长度(可含 \\0,别用 strlen) |
| HasMember(name) | 有无此键(线性查找) |
| FindMember(name) | 返回迭代器,找不到为 MemberEnd() |
| d["name"] | 语法糖,缺键时断言/UB |
| MemberBegin()/MemberEnd() | 成员迭代器 |
| Size() Empty() Begin()/End() | 数组配套 |
| GetObject() GetArray() | C++17 风格范围(支持 range-for 与结构化绑定) |
assert(doc.IsObject() && doc["retries"].IsInt());
std::cout << doc["name"].GetString() << " "
<< doc["retries"].GetInt() << " "
<< doc["ratio"].GetDouble() << " "
<< doc["active"].GetBool() << "\\n";
std::cout << "has=" << doc.HasMember("tags")
<< " miss=" << (doc.FindMember("ghost")
== doc.MemberEnd()) << "\\n";
const Value& tags = doc["tags"]; // 数组
std::cout << "tags[" << tags.Size() << "]: ";
for (auto& t : tags.GetArray())
std::cout << t.GetString() << " ";
std::cout << "\\n";
for (auto& m : doc.GetObject()) { // C++17 范围式
if (m.value.IsNumber())
std::cout << m.name.GetString() << " ";
}
std::cout << "\\n";
实测输出:
asset-sync 3 0.75 1
has=1 miss=1
tags[2]: fast safe
retries ratio
注意最后一行:数字值的成员按插入序输出(retries 在 ratio 前)。RapidJSON 的 object 是成员数组不是 std::map——遍历序 = 写入序,这与 nlohmann/json 默认的字典序正好相反(那边要用 ordered_json 才有此待遇)。代价是 FindMember 线性查找,键很多时它不是哈希表的对手。
7. DOM 修改类 API:分配器贯穿全程
| SetObject() SetArray() SetNull() SetInt() | 重置类型 |
| AddMember(name, value, alloc) | 加成员;键是 StringRefType |
| PushBack(value, alloc) / PopBack() | 数组头尾操作 |
| EraseMember(name) / Erase(begin,end) | 删除 |
| SetString(…) | 见下方拷贝语义表 |
| Value(…).Move() | 显式移交所有权 |
拷贝语义表——手动挡的核心档位:
| AddMember("k", 42, a) | 引用 | ——(数字) | 字面量键 + 基本类型值 |
| AddMember("k", "lit", a) | 引用 | 引用 | 键与值都是长寿字面量 |
| SetString(s) / SetString(StringRef(s)) | —— | 引用 | 缓冲区与 Document 同寿 |
| SetString(s, len, a) | —— | 拷贝 | 临时缓冲区必须拷 |
| v.SetString(buf, len, a); AddMember("k", v, a) | 引用 | 拷贝 | 通用安全写法 |
Document d2(kObjectType);
auto& a = d2.GetAllocator(); // 分配器贯穿全程
d2.AddMember("name", "demo", a); // 字面量:引用,安全
d2.AddMember("port", 8080, a);
Value arr(kArrayType);
for (int i = 1; i <= 3; ++i)
arr.PushBack(i, a); // 数组生长也要递分配器
d2.AddMember("nums", arr, a); // arr 所有权移交 d2
Value tmp(kStringType);
char buf[] = "temp buffer"; // 栈上临时缓冲
tmp.SetString(buf, sizeof(buf)–1, a); // 显式拷贝
d2.AddMember("tmp", tmp, a);
AddMember("nums", arr, a) 之后 arr 变成 null——这是移动语义,不是拷贝;继续用 arr 是未定义行为。C++11 后也可以用右值直接写 AddMember("nums", Value(arr).Move(), a)。
8. 序列化类 API:Writer 一趟直出
| Writer<OutputStream> | 紧凑输出,无多余空白 |
| PrettyWriter<OutputStream> | 缩进美化(SetIndent 可调) |
| StringBuffer | 内存输出流(GetString() 取结果) |
| value.Accept(writer) | 把 DOM 写到 writer |
| FileWriteStream | 文件输出流(配 FileWrapper 用) |
StringBuffer sb;
Writer<StringBuffer> w(sb);
d2.Accept(w);
std::cout << sb.GetString() << "\\n";
StringBuffer sb2;
PrettyWriter<StringBuffer> pw(sb2);
d2.Accept(pw);
std::cout << sb2.GetString() << "\\n";
实测输出(节选):
{"name":"demo","port":8080,"nums":[1,2,3],"tmp":"temp buffer"}
{
"name": "demo",
"port": 8080,
…
}
Writer 也是一个 Handler——SAX 事件直通输出,中途不建 DOM。「SAX 解析 → Writer 输出」可以拼成一条零 DOM 的转换管道:过滤字段、改键名、缩进重排,全程内存 O(1)。
9. SAX 类 API:十三个回调读完就走
Reader::Parse(stream, handler) 把解析事件同步推给 handler。Handler 不是基类,是一个满足以下签名的鸭子类型(concept):
| Null() | —— | 读到 null |
| Bool(b) | bool | 布尔值 |
| Int(i) / Uint(u) / Int64 / Uint64 / Double(d) | 各整数类型 | 数字(解析器已按值域分流) |
| String(s, len, copy) | const Ch*, SizeType, bool | 字符串 |
| RawNumber(s, len, copy) | 同上 | 仅 kParseNumbersAsStringsFlag 时 |
| StartObject() / EndObject(n) | 成员数 | 对象起止 |
| Key(s, len, copy) | 同 String | 键 |
| StartArray() / EndArray(n) | 元素数 | 数组起止 |
回调返回 false 立即终止解析(错误码 kParseErrorTermination)——这是 SAX 的剪枝开关。写一个统计器:
struct CountHandler {
unsigned objects = 0, arrays = 0,
keys = 0, numbers = 0;
std::string lastKey;
bool StartObject() { ++objects; return true; }
bool Key(const char* s, SizeType len, bool) {
++keys; lastKey.assign(s, len); return true;
}
bool EndObject(SizeType) { return true; }
bool StartArray() { ++arrays; return true; }
bool EndArray(SizeType) { return true; }
bool Null() { return true; }
bool Bool(bool) { return true; }
bool Int(int) { ++numbers; return true; }
bool Uint(unsigned) { ++numbers; return true; }
bool Int64(int64_t) { ++numbers; return true; }
bool Uint64(uint64_t) { ++numbers; return true; }
bool Double(double) { ++numbers; return true; }
bool String(const char*, SizeType, bool) { return true; }
bool RawNumber(const char*, SizeType, bool) { return true; }
};
CountHandler h;
Reader reader;
StringStream ss(json);
bool ok = reader.Parse(ss, h); // 事件直推,不建 DOM
实测输出(解析 §4 的 json):
sax ok=1 objects=2 arrays=1 keys=8 numbers=3 lastKey=port
SAX 的价值:内存与输入规模无关,一遍流过;代价是只有一遍、没有随机访问。「一遍统计/过滤/校验」用它,「反复查询」用 DOM。
10. 原位解析:把字符串留在原地
原位(in-situ,拉丁语「在原处」):指算法直接在输入数据所在的内存上完成处理,不另辟缓冲——RapidJSON 官方中文文档译作「原位解析」,类比原位排序:无论输入多大,额外内存都是 O(1)。
char buf[64];
snprintf(buf, sizeof(buf),
R"({"msg":"hello world"})");
Document d;
d.ParseInsitu(buf); // buf 必须可写!
const char* p = d["msg"].GetString();
// 实测:p 落在 buf 内部——零拷贝、零字符串分配
ParseInsitu 把转义解码直接写回源缓冲(解码只会变短,原地写安全),DOM 里的字符串就是源文本本身。条件:缓冲可写、生命周期由你负责。省掉的不只是拷贝——是每个字符串一次的堆分配。原理与代价,下篇细拆。
11. 常见坑(实测依据)
12. 延伸与联动
- 与 nlohmann/json 的完整选型对比见第 2 篇 §5;一句话回顾:默认 nlohmann,热路径 RapidJSON,只读海量上 simdjson。
- 官方教程含中文版(doc/tutorial.zh-cn.md),配本篇按类精读效果最好。
- 这些 API 背后的性能机制——SIMD 跳空白、手写数字解析、原位零拷贝、24 字节 Value 与内存池——下一篇逐个拆开看源码。〔关联 第 4 篇〕
参考:RapidJSON 仓库(v1.1.0,MIT)及其 doc/ 中文文档。文中代码与输出在 g++ 11.2(-std=c++17)实测;ParseFlags 表、拷贝语义表、Handler 签名均核对自源码注释。




