jRead -就地 JSON 元素读取器
不是“另一个解析器”,它以简单且无内存开销的方式在 C 中读取 JSON 元素
引言
jRead 是一个简单的原位 JSON 元素读取器,它将输入的 JSON 保持为未更改的文本,并允许直接对其进行查询。jRead 用 C 语言编写,体积小、速度快,并且不分配任何内存 - 这使其成为嵌入式应用程序或只需要从 JSON 块中读取少量值而无需学习大型 API 且程序会生成大量相互关联的结构来表示 JSON 的理想选择。
基本上,API 是一个单一函数调用
jRead( pJsonText, pQueryString, pOutputStruct );
如果您真的很有冒险精神,还有另外两个函数和一些可选的“辅助函数”。
背景
在许多情况下,在 C 或 C++ 中处理 JSON 非常痛苦。我查看了几个 C++ JSON 解析器,它们都有一个学习曲线,并且必须使用结构或类来表示 JSON 的节点。
此外,“我只想从这么多 JSON 中获取几个值!”、“我该如何????为如此简单的工作使用这个 API?”、“我不在乎编写 JSON 回复 - 那是容易的部分!”以及“我的嵌入式目标不是由内存组成的!”之类的感叹,都是我最终编写 jRead 的原因。
关注点
代码是用纯 C 编写的,全部在 jRead.c 和 jRead.h 中,它没有任何依赖项,因此应该可以在任何系统上为任何目标进行编译。它不会 malloc()
任何内存,只需要一些栈用于函数递归。
它与其说是解析 JSON,不如说是遍历它 - 查找您感兴趣的元素。它可以用于提取终端值(string
、数字等),也可以用于提取整个对象或数组。它使得读取 JSON 并根据您的应用程序将值放入本机 C 变量、数组或结构中变得容易。
代码还附带 main.c,其中包含大量示例,并作为命令行实用程序运行以查询 JSON 文件。项目文件包含用于 Visual Studio 10 的项目文件以及用于 Windows 的命令行 .exe。
jRead 的示例
假设我们有一个简单的 JSON string
,我们已将其读入某个缓冲区 pJson
,如下所示:
{
"astring":"This is a string",
"anumber":42,
"myarray":[ "zero", 1, {"description":"element 2"}, null ],
"yesno":true,
"PI":"3.1415926",
"foo":null
}
我们可以使用 jRead
获取任何元素的值,例如:
struct jReadElement result;
jRead( pJson, "{'astring'", &result );
在这种情况下,结果将是
result.dataType= JREAD_STRING
result.elements= 1
result.byteLen= 16
result.pValue -> "This is a string"
请注意,没有任何内容被复制,结果结构只是为您提供指向元素开始位置的指针及其长度。
查询字符串定义了如何遍历 JSON 以最终获得返回的值。查询的元素可以是:
"{'keyname'" Object element "keyname", returns value of that key
"{NUMBER" Object element[NUMBER], returns keyname of that element
"[NUMBER" Array element[NUMBER], returns value from array
用您的 JavaScript 思维方式来考虑,很明显
jRead( pJson, "{'myarray'", &result );
将返回
result.dataType= JREAD_ARRAY
result.elements= 4
result.byteLen= 46
result.pValue -> [ "zero", 1, {"description":"element 2"}, null ]
将查询元素串联起来,我们可以按此方式检索封闭对象中的值:
jRead( pJson, "{'myarray' [2 {'description'", &result );
给出
result.pValue -> "element 2"
您可能已经注意到,查询字符串使用 '
单引号'
来括住键名,而不是 JSON 所需的 "
双引号"
- 这只是为了方便输入查询字符串(有一个选项可以更改此设置)。必须使用双引号会显得很乱,例如:
jRead( pJson, "{\"myarray\"[2{\"description\"", &result ); \\ ugh!
辅助函数可以轻松地将单个值提取到 C 变量中,例如:
jRead_string( pJson, "{'astring'", destString, MAXLEN );
my_int= jRead_int( pJson, "{'myarray'[1" );
my_long= jRead_long( pJson, "{'anumber'" );
my_double= jRead_double( pJson, "{'PI'[3" );
请注意,'int
' 辅助函数会执行一些“类型强制转换” - 因为无论如何一切都是 string
,调用 jReadInt()
总是会返回一个值:对于 JSON 42
或 "42
" 它将返回 42,对于 "foo
" 则返回零。它还为 true
返回 1,为 false
或 null
返回 0。
高级用法
假设我们有一些合理的 JSON(而不是上面示例中那种牵强的混搭),您可能会有对象数组,然后您会说,通过数组索引很麻烦,因为您必须构建一个查询字符串。
啊哈!我说,您可以使用 查询参数
!
更可能的是,您会得到一些 JSON,其中您想要的部分嵌入其中,如下所示:
{
"Company": "The Most Excellent Example Company",
"Address": "Planet Earth",
"Numbers":[
{ "Name":"Fred", "Ident":12345 },
{ "Name":"Jim", "Ident":"87654" },
{ "Name":"Zaphod", "Ident":"0777621" }
]
}
您可以通过以下方式提取名称和数字:
#define NAMELEN 32
struct NamesAndNumbers{ // our application wants the JSON "Numbers" array data
char Name[NAMELEN]; // in an array of these structures
long Number;
};
...
struct NamesAndNumbers people[42];
struct jReadElement element;
int i;
jRead( pJson, "{'Numbers'", &element ); // we expect "Numbers" to be an array
if( element.dataType == JREAD_ARRAY )
{
for( i=0; i<element.elements; i++ ) // loop for no. of elements in JSON
{
jRead_string( pJson, "{'Numbers'[*{'Name'", people[i].Name, NAMELEN, &i );
people[i].Number= jRead_long( pJson, "{'Numbers'[*{'Ident'", &i );
}
}
...换句话说,您可以提供一个 int
(或 int[]
)的指针,其值在查询字符串中用于替换 *
标记(有点像 prinf()
中的参数)。有关索引数组的使用,请参见 main.c runExamples()
。
代码工作原理
包含 JSON 的输入 string
必须以 '\0'
结尾,这为我们提供了搜索的后备,同样,查询字符串也以 '\0'
结尾。
与普通解析器不同,它在遍历查询 string
的同时遍历 JSON,只需跳过元素同时跟踪对象和数组的层次结构。
该例程从 JSON 和查询中提取一个标记,这有两个有效的结果:
- 查询中没有剩余内容
- 标记匹配
如果查询中没有剩余内容,我们希望结果是此时剩余的 JSON,即我们返回整个 JSON 值(如果查询最初为空,则返回整个输入 JSON)。
如果标记匹配(例如,它们都是 '{'
,即标记 JREAD_OBJECT
),那么我们期望查询字符串有一个附加的说明符:
- 如果它是数组(或者我们想要对象的“键”)的索引。
- 如果它是对象的键值。
此时,我们需要逐步遍历 JSON 直到找到感兴趣的元素,在对象的情况下,我们期望元素的形式为:
"key string" JREAD_COLON <value> JREAD_COMMA | JREAD_EOBJECT
如果键字符串是我们想要的(它与查询键匹配),那么我们递归调用 jReadParam()
,指向 JSON 中预期 <value>
的开始位置,并指向查询字符串的剩余部分。
如果此键字符串不是我们想要的,我们只需调用 jRead()
并传入一个空查询字符串 来遍历我们不感兴趣的整个 JSON 值。
在查询字符串的末尾,我们可能有一个“终端值”(数字、字符串、布尔值等),或者我们可能有一个对象或数组。如果是终端值,我们只返回该单个值元素。否则,我们希望返回对象/数组中的元素数量。
使用 jReadCountObject()
和 jReadCountArray()
这两个单独的例程来遍历对象和数组并返回它们的长度和元素计数。
“遍历”与“解析”
虽然严格来说,“解析”意味着将某些输入分解为其组成部分,但 JSON 解析器通常被期望创建表示输入 JSON 的数据结构。jRead 也必须“解析”输入,但可能更好地描述为遍历 JSON 以提取元素。
为了使应用程序能够利用任何 JSON 数据,这些数据最终必须成为某种本机数据类型(或结构/对象)。解析器通常不会这样做,解析后的 JSON 会进入一个数据结构,该结构由形成 API 的函数或方法访问,而 jRead 的目的是直接填充某些应用程序特定的数据结构。
这里的示例是将 JSON 对象数组中的整数值(“Users
”)读取到 C 整数数组中。
速度
嗯,这取决于...
由于 jRead 遍历 JSON 以查找要返回的元素,因此它可以很快地完成此操作 - 但是,它必须为每个您想要的值执行此操作。此外,位于前面的元素可以快速找到(输入 JSON 的其余部分甚至不需要查看),而对于位于输入末尾的元素,我们必须遍历整个输入。
在上面的“高级”查询参数示例中,我们通过从对象中获取“Numbers”元素,然后对其执行进一步查询(即,我们启动一个 jRead 查询,指向 JSON 中嵌入的数组)来进一步混淆了“速度”问题 - 这可以节省一次又一次地遍历封闭对象。
我进行了一些速度测试,最新版本中的命令行程序中内置了一个简单的测试。使用此程序,我在 Windows 7 下的 DOS 框中的 3.4GHz i7 处理器每秒可以处理 770,000 次查询(通过让它在一个循环中执行数百万次来计时),大约是每秒 1.3 微秒/查询。进一步捣鼓更长的 JSON 文件使我能够估计每遍历一个 JSON 元素大约需要 50 纳秒。这听起来相当快!
啊,没那么快... 如果您有一个包含 10,000 个对象的 JSON 数组,例如:
[ {"DateTime":"2014-02-08T00:00:00Z","Users":1},
...
{"DateTime":"2014-02-14T22:39:00Z","Users":10000} ]
那么获取最后一个大约需要 1.4 毫秒。但是,如果您调用 jRead 10,000 次来读取每个条目,平均每条目需要 0.74 毫秒 - 总共需要 7.4 秒才能完成所有操作。
这就是为什么我添加了“步进”函数 - 见下文。
添加数组步进函数
由于 jRead 在大型数组上的性能非常差,我添加了另一个函数:
jReadArrayStep( char *pJsonArray, struct jsonElement *result )
意识到 jRead 的全部意义在于简单地将 JSON 中的值提取到您拥有的任何漂亮的 C 变量中,并且 jRead 的工作方式是遍历 JSON - 添加此函数是很自然的。
jReadArrayStep()
仅从数组中提取一个元素,将其返回在 jsonElement
中,并返回指向数组其余部分的指针。
// identify the whole JSON element
jRead( (char *)json.data, "", &arrayElement );
if( arrayElement.dataType == JREAD_ARRAY ) // make sure we have an array
{
int step;
pJsonArray= (char *)arrayElement.pValue;
for( step=0; step<arrayElement.elements; step++ ) // step thru all array elements
{
pJsonArray= jReadArrayStep( pJsonArray, &objectElement );
if( objectElement.dataType == JREAD_OBJECT ) // we expect an object
{
// query the object and save in our application array
UserCounts[step]= jRead_int( (char *)objectElement.pValue, "{'Users'", NULL );
}else
{
printf("Array element wasn't an object!\n");
}
}
}
现在我们开始有点进展了:与单独执行索引查询需要 7.4 秒相比,使用 jReadArrayStep()
可以在短短 3.1 毫秒内读取所有 10,000 个值(使用上述代码,识别整个输入以获取 arrayElement
需要额外的 1.5 毫秒 - 总共 4.8 毫秒)。
您可以为 JSON 对象添加类似的“步进”函数,并返回 Key
和 Value
。我没有实现这一点,因为您仍然需要进行 string
匹配返回的 Key
才能使用 Value
,而且 JSON 对象通常没有大量的键。
与解析器的比较
我将 jRead 与我修改过的 C++ 中的 simpleJSON 版本进行了比较(原始项目是:https://github.com/MJPA/SimpleJSON)
使用 SimpleJSON 解析包含 10,000 个对象的数组 JSON 需要 48.4 毫秒,再加上另外 0.4 毫秒将 10,000 个值读取到 int
数组中,总共 48.8 毫秒,并使用了几个兆字节的内存用于结构。
与无内存开销的 4.8 毫秒相比,jRead 在这种情况下看起来是明显的赢家。
当然,几毫秒的差异可能无关紧要 - 但这都是在快速的 3.4GHz i7 机器上进行的,在嵌入式设备上运行可能会慢 100 倍,差异会非常显著(在一台旧的 Sony 1.2GHz Pentium 'M' 笔记本电脑上,simpleJson 需要 282.2 毫秒,而 jRead 需要 19.1 毫秒)。
jRead 的用例
如果您有嵌入式系统或无法承受内存占用,那么 jRead 具有明显优势。如果您的输入 JSON 相对较小(几 KB) - 通常用于在通信应用程序之间传递参数 - 那么 jRead 可以节省解析的内存和时间。如果您只需要整个 JSON 的一部分,那么您可以使用 jRead 定位您感兴趣的元素,然后要么对其进行查询 - 甚至对其使用解析器。
如果您使用数组步进函数,则将大型对象数组转换为某些本机格式的速度相当快,因为它只需遍历源一次即可将所有值放到您想要的位置。
请记住,在 C(或 C++)中,JSON 文件中定义的结构与语言的映射非常不好,解析器必须构建数组/结构/对象来表示 JSON 中的每个节点。一旦解析器完成了它的工作,您仍然需要遍历结构才能真正访问终端值。
jRead 的最后一个优势是它的简单性:它只需编译并运行,您无需学习某些 API 或在调用 yetAnotherParser::Parse()
后弄清楚如何遍历一堆类对象。
如果您必须处理大量 JSON 并且不想将数据翻译成您自己的应用程序存储,但又想不断访问值,那么 jRead 在每次都必须遍历源时都处于不利地位。如果您想读取/修改/写入一定量的 JSON,那么解析器会是更好的选择。
Arduino 版本
jRead 的同一版本已“移植”用于 Arduino。源代码是 .cpp 文件,并进行了一些小的修改以兼容 Arduino IDE。演示 sketch 完全自包含,可以在 Arduino UNO 上运行。许多示例查询的结果会打印到串行监视器。
结论
希望这对大家有所帮助,它是免费使用的,可以复制、重写或随意使用。看看代码,里面有很多注释、信息和示例。
如果您使用它,并且碰巧在一家出售好啤酒的酒吧里遇到我……我会来一杯 Tim Taylors - 谢谢!
TonyWilk