JSON:无垃圾的解析与构建
sj.json 是一个手写的增量式 JSON 解析器,外加一个配套的流式构建器 — 没有 derive 宏,没有反射,没有你不曾要求的中间对象图。本教程把这两半都过一遍:从最简单的 API 讲起,最后落到任何热路径(一个请求处理器、每 tick 一次的 SEDA 事件、任何在循环里被调用的东西)都该使用的零垃圾模式。每段代码都改编自 SDK 附带的演示(demo/sj/demo/JsonDemo.sj、demo/sj/demo/JsonBuilderDemo.sj),照原样即可编译运行。
1. 解析 — 一次性 API
读一个 JSON 文档最简单的方式:
import sj.json.JsonParser;
import sj.json.JsonNode;
byte[] json = "{\"name\":\"SuperJ\",\"versions\":[1,2,3],\"fast\":true}".getBytes();
JsonNode doc = JsonParser.parse(json);
if (doc.isObject()) {
System.out.println("top-level object, " + doc.childCount() + " fields");
}
JsonNode 是解析后文档的一张扁平"tape"视图 — isObject()、isArray()、isString()、isLong()、isDouble()、isBoolean()、isNull() 告诉你一个节点是什么;asLong()、asDouble()、asBoolean()、asString() 读取它的值。
对象与数组按子节点的链式序列遍历,而不是按索引:
JsonNode key = doc.firstChild();
while (key != null && key.isString()) {
JsonNode val = key.next();
System.out.println(key.asString() + " = " + describe(val));
key = val.next(); // advance past the value to the next key
}
对数组而言,firstChild()/nextSibling() 直接遍历元素(中间没有 key)。
查找 key,以及重复 key
at(key) 在一个对象的成员里查找并返回匹配的值,没有则返回 null。它是从对象第一个成员开始的线性扫描,不是索引 — 对象在 tape 上就是一个序列,没有任何东西对它做哈希。
如果同一个 key 在对象里出现两次,后写的赢。
JsonNode n = JsonParser.parse("{\"k\":1,\"k\":2}".getBytes());
n.at("k").asLong(); // 2
n.childCount(); // 2 — both members are still on the tape
RFC 8259 §4 说名称应当唯一,并把重复名称的含义留给实现自行决定。后写赢(last-wins)正是一个构建 map 的解析器给你的结果,因为后插入的会覆盖先插入的,所以这也是从 Jackson、Gson 或 serde_json 过来的调用者所期望的(#4325)。
没有任何东西被丢弃:解析保留每一个成员,childCount() 把它们全部计数,firstChild()/next() 的遍历两个都能看到。只有 at() 会挑出赢家。其结果是 at() 无法在第一个匹配处停下 — 要排除后面还有重复的可能,就得看完剩下的每个 key — 所以一次查找的代价是走完整个对象。如果你要从一个大对象里读出多个 key,自己遍历一次,而不是对每个 key 都调用一次 at();反正 at() 每次都从头开始。
这个 API 适合一次性的解析。JsonParser.parse(...) 会分配一个新的 JsonParser,而每次 firstChild()/next()/nextSibling()/at() 调用都会分配一个全新的 JsonNode。在热循环里这些会累积起来。
2. 解析 — 零垃圾模式
sj.json 的增量式设计(设计笔记见 doc/jsonDesign.md)意味着一个 JsonParser 会在多次调用之间保留它的 tape/string/double 缓冲区 — 此后剩下的唯一分配就是返回给你的那些 JsonNode 对象。如果你自己提供节点,让解析器(以及节点本身)原地重绑它们而不是返回新对象,这些分配也会消失:
JsonParser parser = new JsonParser(); // allocated once, outside the loop
JsonNode root = new JsonNode(); // unbound "out-parameter" node, reused every parse
JsonNode field = new JsonNode(); // unbound out-parameter, reused every navigation step
while (moreMessages()) {
parser.parseInto(root, nextMessage()); // rebinds `root`; allocates nothing
if (root.atInto(field, "name")) { // rebinds `field`; allocates nothing
boolean isBob = field.stringEquals("bob"); // byte-for-byte compare, no String
byte[] name = field.asStringBytes(); // only allocate when you need to keep it
}
}
- 不带参数的
new JsonNode()产生一个未绑定的节点 — 在有东西绑定它之前,每个访问器都会抛异常。它存在的唯一意义就是作为某个*Into方法的out参数被传入。 parseInto(JsonNode out, byte[] buf, ...)是parseInstance的不分配孪生版本:它复用解析器的内部缓冲区,并且重绑out,而不是返回新节点。firstChildInto、nextSiblingInto、nextInto和atInto分别是firstChild、nextSibling、next和at的不分配孪生版本。把你正在用来遍历的节点作为它自己的 out 参数传入,即可原地前进(cursor.nextInto(cursor));需要同时持有两个位置时,就传入第二个复用节点。stringEquals(string)和stringEquals(byte[], off, len)把 JSON 字符串的字节直接与一个 key 或期望值比较 — 不像asString()那样分配String。只有对那些你确实要留过当前迭代的值,才动用asString()/asStringBytes()。
atInto 每次调用仍然从对象开头线性遍历 — 它是查找,不是持久化索引 — 但遍历本身以及逐 key 的比较不做任何分配。
3. 构建 — 链式对象/数组构造
JsonBuilder 在你调用它的同时就把 JSON 直接写进字节缓冲区 — 没有"先建一棵中间树再序列化"这回事。
import sj.json.JsonBuilder;
import sj.util.ByteArray;
JsonBuilder jb = new JsonBuilder(new ByteArray(256));
ByteArray doc = jb.build(jb.object()
.add("name", "SuperJ")
.add("stars", 5)
.add(jb.object("author")
.add("org", "SuperJ")
.add("active", true))
.add(jb.array("tags")
.add("json")
.add("zero-garbage")));
System.out.println(doc.toString());
// {"name":"SuperJ","stars":5,"author":{"org":"SuperJ","active":true},"tags":["json","zero-garbage"]}
jb.object()/jb.object(name)开始一个对象;jb.array()/jb.array(name)开始一个数组。两者都返回一个 builder,其add(...)重载可以链下去。- 要把嵌套的对象/数组收回到父级,把嵌套 builder 本身传给
add(...):.add(jb.object("author").add(...))— 外层的add调用会闭合由内层jb.object("author")开始的对象。 build(...)闭合根容器,并把输出ByteArray翻转成可读。嵌套不匹配会在build()处抛异常,而不是静默产出畸形 JSON。
4. 构建 — 零垃圾模式
把 JsonBuilder 和它的后备 ByteArray 一次性分配好,放在循环外。build() 每次调用都会清空并重新翻转同一个缓冲区 — 每条消息不会有任何新分配:
ByteArray out = new ByteArray(256);
JsonBuilder jb = new JsonBuilder(out); // once, outside the loop
while (moreMessages()) {
ByteArray msg = jb.build(jb.object().add("seq", i).add("name", names[i]));
send(msg); // fully consume before the next build() call
}
两条规则保证这样做是安全的:
- 绝不要每条消息都
new JsonBuilder(...)。 构造函数会在输出缓冲区之外再分配两个 64KB 的内部暂存缓冲区(buf、vbuf),用于名字/值的 staging。JsonBuilder implements Reusable正是为此 — 构造一次,多次调用build()。 - 返回的
ByteArray就是 builder 自己的输出缓冲区,只在同一 builder 的下一次build()调用之前有效。把它写出去(写到 socket、日志,或为会拷贝的调用者toString()一份)之后再去构建下一条消息 — 不要跨迭代持有它。
如果你要自己把多个 JSON 片段拼进一个更大的缓冲区(而不是每次 build() 调用构建一个文档),用 new JsonBuilder(out, false) 构造 — flipping = false 会跳过自动翻转,共享缓冲区何时转为读取由你控制。
5. 小结
- 一次性 API(
JsonParser.parse、firstChild/next、每次调用普通地new JsonBuilder(...))是单次解析或构建的正确默认 — 简单,而且分配成本只发生一次,无关紧要。 - 在热路径上,切换到复用模式:在循环外分配一个
JsonParser/JsonBuilder(连同它的输出ByteArray),把JsonNode游标作为未绑定的 out 参数一次性创建,并用*Into方法家族(parseInto、firstChildInto、nextSiblingInto、nextInto、atInto)替代那些会分配的对应方法。 - 比较 JSON 字符串值用
stringEquals(...),而不是先用asString()物化一个String再对它调.equals()。