ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RapidJSON源码拆解:C++高性能JSON解析与工程避坑

RapidJSON源码拆解:C++高性能JSON解析与工程避坑

简介:这份源码包面向需要高性能 JSON 处理的 C++ 开发者,适合有一定 C++ 基础并关注性能与跨平台适配的人群,核心是 RapidJSON 的完整实现,可用于服务端接口数据解析、客户端配置文件读写及大规模 JSON 序列化等场景。它在内存管理与算法层面做了针对性优化,实际测试速度优于 jsoncpp 和 lib_json,并兼顾多字节编码与 Unicode 兼容,便于在 Qt 等使用 Unicode 字符串的项目中集成。压缩包共 366 个文件,约 1.91MB,以 json 测试数据、cpp 源文件与 h 头文件为主,辅以 md 说明文档、CMake/Makefile 构建配置及少量图片资源,目录结构完整,适合直接纳入工程或按需裁剪。已有 927 人学习,读者可获得可编译的库源码、配套测试用例与构建脚本,既能快速替换现有 JSON 库验证性能,也能通过阅读实现与测试代码理解解析器设计、编码处理和定制优化思路。

1. 从一个绕不开的性能热点说起

服务端跑着跑着 CPU 打满,火焰图最顶上那一大坨经常不是业务逻辑,而是 JSON 解析:入口先 parse 一遍,业务层再拷一份 Value,落盘前又序列化一遍,同一份数据被处理三次。这时候选型就变得很直接——RapidJSON 这个 C++ 的 JSON 序列化解析器,几万行代码全塞在头文件里,DOM 和 SAX 两套接口并存,多字节字符和 Unicode 编码(UTF-8 / UTF-16 / UTF-32)都能直接处理,在和其他 JSON 库的反复对比测试中,它的解析速度常年优于 jsoncpp 和 lib_json 这类老牌库。这份源码包适合两类人:被解析性能拖慢过、想知道快在哪儿的后端开发;以及要在客户端或资源受限环境里塞一个体积小、可裁剪 JSON 库的人。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆一遍。

2. 目录结构与选型:header-only 到底省掉了哪一层开销

2.1 include/rapidjson 里的头文件分工

把源码包解开,第一层几乎只有include/和test/、thirdparty/、CMakeLists.txt。真正要读的是include/rapidjson/这一层,头文件的划分本身就暗示了作者的设计意图:一个文件对应一个能力,用不到就别 include,编译期自然也不会展开。

头文件职责什么时候会用到
rapidjson.h全局宏、断言、版本宏想改默认解析行为时
document.hDocument/Value,DOM 入口需要随机读字段
reader.hReader,SAX 解析器只需提取少数几个字段
writer.h/prettywriter.h紧凑输出 / 带缩进输出序列化、调试打印
stringbuffer.h内存输出缓冲配合 Writer 拿结果
allocators.hMemoryPoolAllocator/CrtAllocator换分配策略
encodings.hUTF8/UTF16/UTF32/AutoUTF非 UTF-8 输入
error/en.h错误码转英文描述打日志定位
filereadstream.h/istreamwrapper.hFILE* 与 std::istream 适配大文件流式解析
pointer.hJSON Pointer 按路径取值路径式配置读取
schema.hJSON Schema 校验报文结构校验
internal/*dtoa、itoa、strfunc、stack、diyfp 等调性能时才翻

internal/才是性能真正落地的地方。internal/strfunc.h里的SkipWhitespace在开了RAPIDJSON_SSE2/RAPIDJSON_SSE42/RAPIDJSON_NEON之后会走指令集批量扫描空白字符,这就是「解析快」最直观的一段;internal/dtoa.h实现的是 Grisu2 浮点转字符串,internal/itoa.h负责整数分支。第一次读源码时容易一头扎进document.h的模板里出不来,我的建议是先看reader.h的ParseValue主循环,再看strfunc.h的跳过逻辑,剩下的都是配套设施。

2.2 header-only 换来了什么,又付出了什么

RapidJSON 的解析器不是继承体系,Reader::Parse的 Handler 是模板参数,编译期静态分发,调用点直接展开成内联代码,一次StartObject就是一次函数调用,没有虚表间接跳转。这一点和 jsoncpp 的思路完全不同:jsoncpp 的Json::Value内部用std::map<std::string, Value>存对象,每个节点一次堆分配、每个 key 一次std::string构造,解析一万个对象就是几万次 malloc。RapidJSON 的Value在 64 位下是 16 字节的紧凑结构,字符串和数组元素全部从MemoryPoolAllocator的大块内存里线性切出来,默认按 64KB 一块向后申请。

代价也很实在。模板全展开意味着编译时间明显变长,一个把document.h引进公共头文件的工程,增量编译经常从 2 秒变成十几秒;模板报错信息动辄几百行,第一次踩坑会怀疑人生;二进制体积也会涨。所以工程上的常规做法是:业务代码里只 include 必要的那几个头,再包一层自己的小接口,把rapidjson的名字挡在实现文件里,公共头文件里一个字都不出现。

接入方式极其简单,因为它没有需要编译链接的源文件:

# 直接把 include 目录加到搜索路径即可,不需要链接任何库 g++ -std=c++11 -O2 -I third_party/rapidjson/include demo.cpp -o demo

用 CMake 的项目一般这么写,把它做成 INTERFACE 目标,谁用谁target_link_libraries:

add_library(rapidjson INTERFACE) target_include_directories(rapidjson INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/rapidjson/include) target_compile_features(rapidjson INTERFACE cxx_std_11)

INTERFACE库不产出任何二进制,target_include_directories后面的INTERFACE关键字表示「用我的人才会拿到这个路径」,自己的工程不会被污染。cxx_std_11是硬要求,没有 C++11 就没有移动语义,后面AddMember那一堆.Move()会写得非常难受。

有个隐蔽的坑要提前说:rapidjson.h里有一批RAPIDJSON_*宏,比如RAPIDJSON_HAS_STDSTRING打开后Value才能和std::string互转。这类宏必须在所有引用 rapidjson 的编译单元里保持一致,否则同一个Document在不同 TU 里布局不同,链接期不报错,运行期直接崩,属于典型的玄学问题。要么统一写在 CMake 的target_compile_definitions里,要么谁也别开。

3. DOM 上手:Document、Value 与分配器的生命周期

3.1 最小可跑示例与错误定位

DOM 是最容易上手的一层,Document继承自Value,解析完就当成一个巨大的树来读。下面这段是能直接编译运行的完整例子,重点看错误处理那几行——很多人第一次用会把HasParseError忘掉,结果报文格式一错,后面全是空指针。

#include "rapidjson/document.h" #include "rapidjson/error/en.h" #include <cstdio> #include <string> int main() { const char* json = R"({"id":1024,"name":"张三","score":97.5, "tags":["cpp","json"],"vip":true})"; rapidjson::Document doc; // 默认使用 MemoryPoolAllocator doc.Parse(json); // 复制模式:字符串会被拷进 doc 自己的内存池 if (doc.HasParseError()) { // 一定要判,否则后面全是未定义行为 std::printf("parse error: %s at offset %zu\n", rapidjson::GetParseError_En(doc.GetParseError()), doc.GetErrorOffset()); return 1; } if (!doc.IsObject() || !doc.HasMember("id") || !doc["id"].IsInt()) return 2; std::printf("id=%d name=%s score=%.2f tags=%u\n", doc["id"].GetInt(), doc["name"].GetString(), doc["score"].GetDouble(), doc["tags"].Size()); return 0; }

Parse返回的是Document&,本身不带成功标志,判断全靠HasParseError();GetParseError()拿到的是枚举,GetParseError_En()把它转成人能读的英文,GetErrorOffset()给出出错字节偏移。线上排查报文问题时,把 offset 和原始字符串一起打到日志里,能省掉大量来回对包的时间。

IsXxx()先行、GetXxx()后取,是这一层的铁律。doc["id"]用的是operator[],key 不存在时会触发RAPIDJSON_ASSERT,Release 下断言被关掉就变成越界读。所以要么先HasMember,要么用FindMember:

// FindMember 返回迭代器,找不到时等于 MemberEnd(),比 operator[] 安全 auto it = doc.FindMember("name"); if (it != doc.MemberEnd() && it->value.IsString()) { const char* name = it->value.GetString(); // 注意:指向 doc 内部内存 rapidjson::SizeType len = it->value.GetStringLength(); // 中间可能含 \0 }

GetString()拿到的是裸指针,它指向Document内部内存池;Document一析构,指针立刻失效。这是我见过最多的翻车方式:把const char*塞进结构体缓存起来,等下一帧再用,结果读到一片已经被复用的内存,日志里全是乱码。要留就立刻std::string(str, len)拷走,GetStringLength()不能省,因为 JSON 字符串里允许出现\u0000,用strlen会截断。

3.2 in-situ 解析与零拷贝的边界

Parse默认是把字符串拷进内存池的,安全但有拷贝成本。体积大的报文可以用ParseInsitu,直接在你给的缓冲区上就地改写,把转义字符原地处理掉,字符串指针指回原缓冲区,一次拷贝都不做:

std::string buf(json); // 必须可写 doc.ParseInsitu(&buf[0]); // C++11 起 &buf[0] 可写;不要用 c_str() // 此后 buf 的内容已被修改,doc 里的字符串全部指向 buf // buf 不能重新赋值、不能析构,直到 doc 用完

这里的约束比想象中严:缓冲区必须以'\0'结尾(RapidJSON 靠它判断结束,不额外传长度),解析过程中内容会被改写,解析完原来的 JSON 文本就没了。所以千万别对同一个 buffer 调两次ParseInsitu,第二次解析的是被改坏的数据,报错位置还莫名其妙。另外std::string::c_str()在 C++11 之后虽然返回const char*,但&buf[0]的语义更明确,别在这上面省事。

3.3 内存池与字符串写入

要自己构造Value往外输出时,分配器必须显式带上。这是新手最容易卡住的地方:AddMember的第二个参数如果是含字符串的Value,它需要分配器来拷字符串,不传就编译不过。

rapidjson::MemoryPoolAllocator<> alloc(4096); // 每块 4KB,按需增长 rapidjson::Document doc(&alloc); doc.SetObject(); rapidjson::Value key("name", alloc); // key 也走同一个池 rapidjson::Value val("张三", alloc); // 字符串被拷进池里 doc.AddMember(key, val, alloc); // 第三个参数是分配器 doc.AddMember("id", 1024, alloc); // 字面量 key 有重载,可省一次构造

MemoryPoolAllocator的模板参数是底层分配器,默认CrtAllocator,也就是malloc/free。它只在块用完时申请新块,析构时统一释放,所以单个Value的销毁是空操作,这也是 DOM 树析构几乎不花时间的原因。反过来,池只在Document析构时统一归还,中途删掉某个成员并不会把内存还给池——解析超大文档时如果只留一小部分字段,内存峰值依然是整棵树。

需要跨Document搬数据时用CopyFrom,同样要带分配器:

rapidjson::Document dst; dst.SetObject(); dst.CopyFrom(src, dst.GetAllocator()); // 深拷贝,字符串重新分配在 dst 的池里

顺带提一句RAPIDJSON_HAS_STDSTRING=1之后,可以直接doc.AddMember("name", std::string("张三"), alloc),省掉手写Value(...)的啰嗦,但记得前面说的宏一致性问题。

4. SAX 与 Writer:把解析链路的开销压到最低

4.1 Reader + Handler 的事件流

只要几十个字段里的三五个,构造整棵 DOM 纯属浪费。SAX 模式下你写一个 Handler,解析器每遇到一个 token 就回调一次,全程不建树。Handler 不需要继承什么基类,只要提供Null/Bool/Int/Uint/Int64/Uint64/Double/String/StartObject/Key/EndObject/StartArray/EndArray这些方法,缺的用BaseReaderHandler的默认空实现补上就行。

#include "rapidjson/reader.h" #include "rapidjson/stringbuffer.h" #include <cstring> #include <string> #include <vector> // 只收集所有 "name" 字段的值,其余 token 一律忽略 struct NameCollector : rapidjson::BaseReaderHandler<rapidjson::UTF8<>, NameCollector> { std::vector<std::string> names; bool expect_name = false; bool Key(const char* str, rapidjson::SizeType len, bool /*copy*/) { expect_name = (len == 4 && std::memcmp(str, "name", 4) == 0); return true; // 返回 false 会立刻中止解析 } bool String(const char* str, rapidjson::SizeType len, bool /*copy*/) { if (expect_name) { names.emplace_back(str, len); // 按长度构造,兼容 \u0000 expect_name = false; } return true; } }; // 调用侧 rapidjson::Reader reader; rapidjson::StringStream ss(json); // 也可以是 FileReadStream NameCollector h; rapidjson::ParseResult ok = reader.Parse<rapidjson::kParseIterativeFlag>(ss, h); if (!ok) { /* ok.Code() / ok.Offset() 可直接取错误码和偏移 */ }

Key的第三个参数copy在 SAX 模式下恒为false,字符串指针指向源缓冲区,回调返回后就失效,所以必须当场拷走。ParseResult比 DOM 那套更友好,ok.IsError()、ok.Code()、ok.Offset()一步到位。

kParseIterativeFlag值得单独说:默认的解析器是递归下降的,JSON 嵌套几百层就会爆栈,遇到恶意构造的深嵌套报文直接被拉挂。加上这个 flag 后改用显式栈,internal/stack.h那个自带的栈实现接管了递归,深度只受内存限制。线上服务接口一律带上,这是血泪经验。

4.2 Writer 输出与浮点位数控制

序列化这边,Writer是紧凑输出,PrettyWriter带缩进和换行,调试打印或者生成人读配置文件时用后者。两者都写到一个StringBuffer里,最后GetString()/GetSize()取结果。

#include "rapidjson/prettywriter.h" #include "rapidjson/stringbuffer.h" rapidjson::StringBuffer sb; rapidjson::PrettyWriter<rapidjson::StringBuffer> w(sb); w.SetIndent(' ', 2); // 两空格缩进 w.SetMaxDecimalPlaces(4); // double 最多保留 4 位小数,默认是 324 w.StartObject(); w.Key("id"); w.Int(1024); w.Key("name"); w.String("张三"); w.Key("score");w.Double(97.5); w.Key("tags"); w.StartArray(); w.String("cpp"); w.String("json"); w.EndArray(); w.EndObject(); std::fwrite(sb.GetString(), 1, sb.GetSize(), stdout);

SetMaxDecimalPlaces的默认值极大,意味着0.1 + 0.2这种浮点误差会原样吐出来,前端同学看到一长串0.30000000000000004会来问你是不是有 bug。业务上一般按字段语义设成 2~6 位。另外 Writer 默认把非有限值(NaN、Inf)当非法处理,需要输出时得在解析侧开kParseNanAndInfFlag,写出侧用w.Nan()/w.Inf(),两边都要开,只开一边照样对不上。

Writer还有个RawValue(json, len, type),可以把一段已经序列化好的 JSON 原样嵌进去,避免「字符串里套字符串」再解析一次。用它的时候一定要保证那段的类型标注(kObjectType之类)和实际内容一致,标错了生成的 JSON 结构就乱了,而且这种错误在解析侧才会暴露。

4.3 多字节与 Unicode 编码的三种处理方式

RapidJSON 的编码设计是把「源编码」和「目标编码」拆成两个模板参数,默认都是UTF8<>。处理中文这类多字节字符时,有三种典型写法:

第一种,输入本来就是 UTF-8,什么都不用做,Reader<UTF8<>, UTF8<>>是默认值。中文在 UTF-8 里是 3 字节,解析器不解释字符语义,只按字节处理转义和边界,所以性能不受影响。

第二种,输入是 UTF-16(Windows 侧或者某些协议常见),显式指定源编码:

// 源数据是 UTF-16LE,目标 DOM 仍用 UTF-8 存储 rapidjson::GenericDocument<rapidjson::UTF8<>> doc; doc.Parse<rapidjson::kParseDefaultFlags, rapidjson::UTF16LE<>>(u16str);

模板参数顺序是<parseFlags, SourceEncoding>,容易写反,写反了不报错但解析结果全是乱码——这是我在一个跨平台模块上排查了半天的坑。

第三种,输入编码不确定,用AutoUTFInputStream读 BOM 自动判定,配合AutoUTF<>作为源编码:

using AutoStream = rapidjson::AutoUTFInputStream<unsigned, rapidjson::FileReadStream>; FILE* fp = std::fopen("data.json", "rb"); char buf[65536]; rapidjson::FileReadStream fs(fp, buf, sizeof(buf)); AutoStream as(fs); // 读 BOM,决定 UTF-8 / UTF-16LE / UTF-16BE rapidjson::Reader reader; NameCollector h; reader.Parse<rapidjson::kParseDefaultFlags, rapidjson::AutoUTF<unsigned>>(as, h);

AutoUTF的代价是每个字符多一次分支判断,比写死编码慢一截,只在输入来源不确定时用。另外\uXXXX转义的处理是统一的:解析器会把它解码成 UTF-8 序列写进结果,遇到代理对(\uD83D\uDE00这种)会合并成一个码点,不需要自己拼。但如果自己手动String()写入包含代理对的裸 UTF-16,那就要自己保证序列合法。

4.4 大文件流式读取

几百 MB 的 JSON 一次性读进内存再解析,内存曲线会很难看。FileReadStream配合ParseStream增量喂数据,ParseStream内部会维护跨块的 token 状态:

FILE* fp = std::fopen("big.json", "rb"); char buf[65536]; // 缓冲区大小自己调,64KB 起步 rapidjson::FileReadStream fs(fp, buf, sizeof(buf)); rapidjson::Document doc; doc.ParseStream(fs); // 只读一遍,缓冲区会被反复复用 std::fclose(fp);

缓冲区是复用的,所以不能指望解析完还能从buf里读原文;ParseStream之后buf里只剩最后一小段。如果是std::ifstream,用IStreamWrapper包一层,接口一模一样。再往上,kParseStopWhenDoneFlag可以让解析器在第一个完整 JSON 值结束时停下,处理「一行一个 JSON」的日志文件时非常有用,配合GetErrorOffset()就能按行切分。

5. 避坑与排查:几个真会让人加班到深夜的坑

5.1 编译与集成类

现象:代码在自己机器上编过,到 CI 上链接报undefined reference,指向某个rapidjson::符号。

原因:多半是某个 TU 里定义了RAPIDJSON_HAS_STDSTRING之类的宏,另一些 TU 没定义,同一个内联函数展开出不同实现,ODR 被破坏。

解决:所有RAPIDJSON_*宏统一放 CMake 的target_compile_definitions(... PUBLIC ...),不要写在某个 .cpp 文件顶上。

现象:MSVC 下编译时间爆炸,或者模板报错几千行,定位不到自己写的哪一行错了。

原因:document.h被公共头文件 include,模板在每个引用点都完整展开一遍。

解决:公共头里只前置声明自己的封装类型,rapidjson的头全部挪进 .cpp;必要时开RAPIDJSON_NOMEMBERITERATORCLASS,牺牲一点遍历便利换编译速度。

5.2 内存与生命周期类

现象:接口偶发返回乱码,重启后正常,压测一段时间必现。

原因:把Value::GetString()返回的const char*存进了缓存或结构体,Document析构后指针悬空,那块内存被内存池复用。

解决:出作用域前一律std::string(str, GetStringLength())拷走;需要长期持有就别存指针。这类问题用 ASan 跑一遍基本都能抓出来。

现象:用ParseInsitu之后,同一个 buffer 再解析一次,报错位置完全对不上。

原因:in-situ 是就地改写,第一次解析完原文本已经被破坏,第二次解析的是残缺数据。

解决:in-situ 只用于「解析一次就丢」的场景;需要重复解析就保留一份原始副本,或者干脆用普通Parse。

5.3 数值与解析行为类

现象:一个 19 位的订单号,解析出来末几位变成了 0。

原因:数字被当成double处理。RapidJSON 会根据字面量自动判断整数还是浮点,但超过int64范围或者带小数点时仍是double,超过 2^53 就丢精度。

解决:ID 类字段在协议层就用字符串传;必须用数字时开kParseNumbersAsStringsFlag全当字符串收,自己按需转;浮点比对场景再叠kParseFullPrecisionFlag。

现象:本地解析结果和线上差最后一位小数,怀疑解析器有问题。

原因:默认的浮点转换走 Grisu2 快速路径,不保证和strtod逐位一致。

解决:需要严格一致时开kParseFullPrecisionFlag,代价是慢一些。做数据比对、写单测断言时这个 flag 要常开。

现象:报文里带\u0000的字符串,取出来被截断了。

原因:用strlen(GetString())算长度,遇到\0就停。

解决:永远用GetStringLength();写出去的时候也别用%s,用fwrite或std::string(ptr, len)。

6. 二次开发:裁剪、封装与验证的一套做法

真正把 RapidJSON 接进项目,改造点通常不在「加功能」,而在「把它的灵活性收窄」。我的习惯是先建一个json_util.h+json_util.cpp的两文件层,头文件里一个rapidjson的类型都不出现,这样上层业务换库、或者以后想换string_view版本,只改这两个文件。

裁剪先从宏开始,常用的几个开关如下:

宏作用建议
RAPIDJSON_HAS_STDSTRINGValue与std::string互转开了省事,但要全局一致
RAPIDJSON_HAS_CXX11_RVALUE_REFS启用移动语义C++11 以上默认开
RAPIDJSON_PARSE_DEFAULT_FLAGS全局默认解析 flag把kParseIterativeFlag焊死在这里
RAPIDJSON_WRITE_DEFAULT_FLAGS全局默认写出 flag统一浮点行为
RAPIDJSON_ASSERT替换断言实现换成写日志 + 上报,别直接 abort
RAPIDJSON_SSE42/RAPIDJSON_NEON指令集加速空白跳过部署环境确定支持再开

RAPIDJSON_PARSE_DEFAULT_FLAGS这个宏特别值得用,把防爆栈的 flag 写死在编译选项里,比每个调用点都记得传要可靠得多。封装层的接口我一般只留三四个函数,返回值统一成一个带错误码和偏移的结构:

// json_util.h —— 不暴露任何 rapidjson 类型 #pragma once #include <cstddef> #include <string> namespace jsonutil { struct Status { bool ok = false; std::string message; // 已经是人可读的英文描述 std::size_t offset = 0; }; // 从扁平对象里取一个字符串字段,找不到或类型不符都返回非 ok Status GetString(const std::string& text, const char* key, std::string& out); // 序列化:字段少的时候直接拼,字段多再走 Writer Status Dump(const std::string& key, const std::string& value, std::string& out); } // namespace jsonutil

实现侧的关键是把两个 flag 固定住:解析用kParseIterativeFlag | kParseStopWhenDoneFlag,避免深嵌套爆栈,也让「尾部有多余字符」不再静默通过;出错时把GetParseError_En和 offset 一起塞进Status::message,上层只管透传给调用方。这套东西写完,业务侧看到的就只是if (!st.ok) { log(st.message); },不需要知道底下发生了什么。

验证方式我推荐一步到位:写一个只用 20 行左右的冒烟用例,喂进去五种输入——正常对象、截断的 JSON、深嵌套 500 层、超长整数、含\u0000的字符串——断言前两个成功、中间两个报错且 offset 合理、最后两个能原样取回。这五个用例跑通,基本可以认定接入姿势没问题;后面每次升级源码包,重跑一遍就知道有没有行为变化。这个习惯是被坑出来的:曾经有次换了编译选项,RAPIDJSON_ASSERT被NDEBUG关掉,原本断言拦住的越界访问变成了静默读脏数据,问题飘到业务层才炸,查了两天。从那以后我每次把这个库接进新工程,都先把这五个用例跑一遍再接业务代码。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进