ESJ - C++ 的极简 JSON






4.92/5 (44投票s)
为 C++ 类添加跨平台往返 JSON 序列化。
引言
ESJ 是一个 C++ 的 JSON 映射器,它对编译器的要求不高(无需 C++11),并且不依赖任何第三方库。它是一个非常轻量级、易于使用的系统,用于与 Web 和数据库服务进行互操作。ESJ 可以快速集成到现有代码中,从而生成健壮且格式良好的 JSON 数据。
JSON(JavaScript 对象表示法)已成为 Web 数据交换的首选格式。JSON 非常具有表现力,易于解析和阅读,当然,与 JavaScript 语言本身也非常契合。除了在 AJAX(或更准确地说 AJAJ)环境中的普遍使用外,JSON 也非常适合基于 Web-socket 的通信。
也许不那么明显的是,JSON 对于支持 JSON 的数据库中的持久化存储也非常有用。以 PostgreSQL 和 MonetDB 为例,它们提供了对 JSON 数据库的优秀支持。
可能更不寻常的是,该代码已被部署在嵌入式环境中(通过 mbed 在 Freescale ARM Cortex-M4 K64F 芯片上),极大地简化了“物联网”设备的 Web-socket 数据交换。
附件的 ZIP 文件包括 Visual Studio (2012) 和 XCode (Clang) 的项目。该代码在使用 g++、在线 mbed 编译器以及 Keil ARM 编译器时也是无警告的。
该代码也托管在 Github 上。如果您有任何想分享的贡献或修复,请通过 ESJ 仓库进行。
背景
对于不熟悉 JSON 的用户,请访问 http://www.json.org 了解语言规范以及各种其他资源的链接,包括语言绑定、有用文档、工具等。
另一个非常有用的网络资源是位于 http://jsonlint.com/ 的 JSON“校验”工具。这在 ESJ 开发过程中被证明是无价的,在此感谢所有相关人员。
此项目的动机是为了能够从现有的 C++ 代码中快速准确地生成 JSON,供 JavaScript 和数据库消费者使用。有许多库试图在 C++ 中模仿 JavaScript 的动态类型和灵活的对象结构,提供双向 JSON 序列化。这与 ESJ 的方法完全相反——这里的意图是通过 C++ 强大的静态类型来最大化其优势,提供格式良好、结构高度严格的内容。
Using the Code
让我们从 JSON 序列化的典型示例开始。
//-----------------------------------------------------------------------------
// Code support required for serialization.
class JSONExample
{
public:
// to be JSON'ised
std::string text;
public:
// each class requires a public serialize function
void serialize(JSON::Adapter& adapter)
{
// this pattern is required
JSON::Class root(adapter,"JSONExample");
// this is the last member variable we serialize so use the _T variant
JSON_T(adapter,text);
}
};
#include "json_writer.h"
#include "json_reader.h"
- 对于您希望序列化的每个类,请添加一个与以下签名相同的 `public` 成员函数:`void serialize(JSON::Adapter& adapter)`
- 在 `serialize()` 内部,添加一个声明:`JSON::Class root(adapter,"JSONExample");`
- 对于您希望序列化的每个成员变量,请添加一个使用 `JSON_E` 或 `JSON_T` 宏的声明。对于一个名为 `text` 的 `std::string` 成员,我们有 `JSON_T(adapter,text);`
- 最后,像下面这样使用模板化的 `JSON::producer()` 和 `JSON::consumer()` 函数
// demonstrate how to first produce and then consume JSON strings
int main(int argc,char* argv[])
{
// try/catch omitted for brevity
// The JSON enabled class as above
JSONExample source;
source.text = "Hello JSON World";
// create JSON from a producer
std::string json = JSON::producer<JSONExample>::convert(source);
// and then create a new instance from a consumer ...
JSONExample sink = JSON::consumer<JSONExample>::convert(json);
// we are done ...
}
就是这样。序列化过程的结果如下所示。
{"JSONExample":{"text":"Hello JSON World"}}
这基本涵盖了要点。现在让我对迄今为止的代码片段进行更详细的说明。
内置支持以下 C++ 类型:
- `std::string` 映射到 JSON `string`。
- `std::wstring` 映射到 JSON `string`,支持 \UXXXX 编码和解码。
- `int` 映射到 JSON `number`(反序列化时忽略小数部分)。
- `double` 也映射到 JSON `number`。
- `bool` 映射到 JSON `true` 或 `false`。
- `std::vector<T>` 直接映射到 JSON 数组。如果 `T` 实现正确的 `serialize()` 函数,那么对于 `T` 的向量,序列化器将按预期工作。
- 序列化器还将正确处理嵌套的可序列化实例,从而能够轻松地将相当复杂的结构转换为 JSON 并从 JSON 转换回来。
如前所述,类需要实现 `serialize` 函数。调用此函数时将序列化成员,顺序(毫不奇怪)遵循声明的顺序。`JSON::Class` 实例必须始终首先出现,因为它控制着一些幕后魔法,这些魔法对于以正确的 JSON 格式输出对象声明至关重要。像往常一样,宏的使用仅限于用于简洁性的一行代码。有些令人恼火的是,有两个宏用于添加成员变量的序列化代码,并且需要确保它们的顺序正确。`JSON_E` (JSON Element) 用于除 **最后一个** 成员之外的所有成员的序列化支持。为什么?快速查看生成的 JSON 会发现,`JSON_E` 调用的代码生成了一个尾随逗号字符,而 `JSON_T` (JSON Terminator) 则不会。因此,必需的声明模式是:
JSON::Class root(adapter,"name of C++ class");
JSON_E(adaptor,first_member_variable);
JSON_E(adaptor,...);
JSON_T(adaptor,last_member_variable);
任何使用 `JSON` 函数的代码都应包装在 `try/catch` 块中,以确保正确的异常恢复。
最后,请注意所有直接相关的类和函数都在 `JSON` 命名空间中。
安全
很少有当代与互联网相关的代码可以忽略安全问题。在这种特定情况下,可预测的攻击向量将是格式错误的或过长的 `string` 用于“缓冲区溢出”,或者非法字符序列可能变成可执行代码。
JSON 扫描器可以设置为接受最大长度的 `string`,这有助于减轻资源耗尽类型的攻击。字符转换,特别是那些从转义的十六进制 \uXXXX 到 UTF16 或 UTF32 的转换,都经过了仔细处理,解码器会在出现非法代码点或截断序列时抛出异常。
JSON 解析器采用递归下降的模式,在遇到非常深层嵌套的编码时,其堆栈消耗显然会增加。虽然解析器中没有明确检查此条件,但添加它非常容易:`JSON::Class` 构造函数实际上会监视作用域的嵌套,如果达到应用程序指定的限制,则可以抛出异常。
在使用 Visual Studio 的代码分析模式编译测试平台时,不会生成任何警告或错误。
关注点
ESJ 实现为一组 C++ 头文件。这大大降低了跨平台工具链管理等的复杂性。最值得关注的文件是:
- json_adapter.h 包含 JSON::Adapter 序列化器代码的接口定义和关键流函数。
- json_writer.h 包含将支持的类型写入 UTF8 字符串的原语的实现。
- json_reader.h 实现读取器的原语。
- json_lexer.h 包含一个完整、独立的 JSON 标记器(本身就很有用,尤其是在资源非常受限的环境中)。
- stringer.h `sprintf` 及其类似函数的轻量级、类型安全替代品,它重载了 `operator <<` 来创建格式化字符串。
主要组件及其关联,以略微非标准的 UML 呈现(这些图作为 SVG 文件包含在源代码发行版中,以便于查看)。
从结构上讲,Writer 将 JSON 写入一个派生自 `ISink` 的类,在当前的层次结构中,sink 是 `StringSink`。JSON 从一个派生自 `ISource` 的类读取,在本例中是 `StringSource`。顾名思义,JSON 内容的内部容器实际上是 `std::string`,在许多情况下这会非常合适。然而,值得指出的是,这种架构也非常灵活。例如,如果您希望将 JSON 直接写入套接字或文件(例如,避免潜在的大量缓冲),您只需继承 `JSON::ISink` 类并实现如上 UML 类图所示的相关 `operator<<()` 函数。
辅助组件
这里工作的主要原理是将一组自由函数(泛称为 `stream()`,所有这些函数都在 `adapter` 类中实现)与另一组重载的虚拟函数相结合,这些虚拟函数在 `Reader` 和 `Writer` 类中实现,这两个类都继承自 `Adapter`。
所有核心数据类型都有重载的 `stream()` 函数。然后有一个通用的模板化 `stream` 函数,它期望其 `value` 参数实现 `serialize()` 函数。正是通过这种分解机制,该系统才能正常工作。
// overloaded types for streaming primitives
void stream(Adapter& adapter,std::string& value)
void stream(Adapter& adapter,int& value);
void stream(Adapter& adapter,double& value);
void stream(Adapter& adapter,bool& value);
// templated stream function for all types implementing serialize()
template <typename T> void stream(Adapter& adapter,T& arg)
{
// will fail if not implemented.
arg.serialize(adapter);
}
除了单参数函数外,还有另一组重载函数可以流式传输键/值对。对于 writer,实现很简单,只需在需要时创建正确加引号的 `string` 并将其追加(或输出)到目标。例如:
//---------------------------------------------------------------------
// write a key/value pair with optional continuation
virtual void serialize(const std::string& key,std::string& value,bool more)
{
m_content << "\"" << key << Quote() << ':' << Quote();
m_content << Chordia::escape(value) << Quote() << (more ? "," : "");
}
等效的读取函数与 JSON 扫描器协同工作如下:
//-----------------------------------------------------------------------------
// primitive to read type-checked key/value pair
virtual void serialize(const std::string& key,std::string& value,bool more)
{
// see implementation details in next snippet
GetNext(key,T_STRING,value,more);
}
//-----------------------------------------------------------------------------
// primitive to read type-checked key/value pair as in "count" : 123
void GetNext(const std::string& key,TokenType type,std::string& value,bool more)
{
// expecting a string token for "key"
GetNext(T_STRING);
// checked key name matches parsed value
throw_if(key != m_token.text,"key does not match");
// next token
GetNext(T_COLON);
// get the next token and match type
GetNext(type);
// conversions from text are performed one level further up the stack
value = m_token.text;
// this has to be one of ,]}
if (more)
{
Next();
}
}
//-----------------------------------------------------------------------------
// core type checking primitive. throws if token type not matched
virtual void GetNext(TokenType type)
{
// get the next token from the scanner
TokenType next = Next();
// does the expected token type match? throw if not
throw_if(next != type,"GetNext: type mismatch");
}
实现中唯一真正棘手的部分是支持 JSON 数组所需的代码。这同样在 adapter 中处理,并使用前面代码片段中显示的原始函数。这是读取和写入不对称的唯一情况。首先,读取器必须正确处理遇到空数组 `[]` 的情况,因此读取器使用 lexer/scanner 的 peek 功能来检查下一个令牌并相应地进行处理。
// expecting "key"
adapter.serialize(key);
// expecting ':'
adapter.serialize(T_COLON);
// and next the opening '['
adapter.serialize(T_ARRAY_BEGIN);
// cope with empty arrays so we need look-ahead here
if (adapter.peek(T_ARRAY_END))
{
// ']'
adapter.serialize(T_ARRAY_END);
}
else
{
// read the contents of the array
}
结论
了解 C++ 派生的 JSON 在 JavaScript 环境中是如何(正确地)表示的是有用的。下图显示了一个粘贴到 Chrome 控制台中的 JSON `string`。在调试器中显示了由此生成的 JavaScript `object (j)`。请注意,日文假名 `string` 已从其 UNICODE 表示形式正确翻译。
代码中没有什么是值得争议的或对编译器不友好的。但是,使用旧版本 Visual Studio 的用户可能需要一个 `stdint.h` 的克隆来处理某些 UTF8/UTF16/UTF32 转换函数中出现的 `uintN_t typedef`。
最后关于示例代码:这本质上是一系列针对每个组件的简单单元测试。还有一个更复杂的示例 `test_nesting()`,它演示并测试了一对更复杂类的序列化,其中一个类包含另一个类的向量。正是此测试的输出生成了上面 Chrome 控制台中显示的 JSON。
链接
- JSON.org: www.json.org
- JSON Lint: www.jsonlint.com
- PostgeSQL: www.postgresql.org
- MonetDB: www.monetdb.org
- ESS: ESS
- mbed: www.mbed.org
- Github 仓库: github.com/g40/esj
历史
- 1.01 - 2014 年 12 月 24 日
- 1.02 - 2014 年 1 月 24 日:更新以同步到 Github。修复了单元整数和双精度值中不正确引用的问题。
- 1.03 - 可在此处下载更新的 tarball,其中修复了嵌套问题: https://github.com/g40/esj/archive/master.zip
- 1.04 - 修复以处理 JSON 原始类型(字符串/数字/布尔值)的 `std::vector<T>`,可在以下地址获取: https://github.com/g40/esj/archive/master.zip
- 1.05 - 更新 ZIP 以镜像 Github 上的最新代码。添加了 Sebastian F. 建议的另一个测试用例。