无需中间抽象语法树解析JSON对象
摘要
文章解释了一种无需中间抽象语法树解析JSON对象以提高性能的方法,该方法在Haskell中使用部分初始化的数据,并讨论了对速度、安全性和代码推导的影响。
<p><a href="https://lobste.rs/s/ehpn7n/parsing_json_objects_without">评论</a></p>
查看缓存全文
缓存时间: 2026/09/23 04:45
# 无需中间AST的JSON对象解析
来源:https://arthi-chaud.github.io/posts/json-ir/
让我们聊聊JSON。JSON无处不在。几乎没有编程语言缺少将数据序列化为JSON和解析JSON数据的库。
许多库通过中间表示(IR,通常是JSON对象的抽象语法树AST)来实现这一功能。若我们对性能有所要求,这就可能成为问题:依赖IR意味着额外的内存分配和计算,会拖慢整个流程...
本文将展示一种通过使用*部分初始化数据*来解析JSON对象的方法,无需借助中间表示。
我将使用Haskell来演示,但该方案应能在支持代数数据类型(ADT)和分阶段元编程的语言(如Rust)中实现。
## 解析JSON数据:朴素方式
https://arthi-chaud.github.io/posts/json-ir/#parsing-json-data-the-naive-way
首先,让我们看看"朴素"JSON解析器的大致形态。我们先定义JSON的AST类型:
```haskell
import Data.Text (Text)
import Data.Map (Map)
data JSON
= Number Number
| Bool Bool
| List [JSON]
| Null Null
| String String
| Object Object
newtype String = String Text
data Number = ...
data Null = Null
newtype Object = Object (Map Text JSON)
```
然后,借助解析器组合子库,我们可以编写解析器:
```haskell
parseNumber :: Parser Number
parseBool :: Parser Bool
parseNull :: Parser Null
parseString :: Parser String
parseObject :: Parser Object
parseList :: Parser [JSON]
parseJSON :: Parser JSON
parseJSON = (Number <$> parseNumber)
<|> (Bool <$> parseBool)
<|> (Null <$> parseNull)
...
```
最后,对于每个我们希望从JSON值解析的数据类型,我们定义一个函数,使用`JSON`值来构建该数据:
```haskell
data Album = Album
{ title :: T.Text
, artistName :: Maybe T.Text
, songCount :: Int
}
fromJSON :: JSON -> Maybe Album
fromJSON = \case
Object obj -> do
title <- obj !? "title"
artistName <- obj !? "artist_name"
songCount <- obj !? "song_count"
return Album{..}
_ -> Nothing
```
幸好,像`fromJSON`这样的函数可以通过Template Haskell或泛型自动推导(分别参见[deriveFromJSON](https://hackage-content.haskell.org/package/aeson/docs/Data-Aeson-TH.html#v:deriveFromJSON)和[genericToJSON](https://hackage-content.haskell.org/package/aeson/docs/Data-Aeson.html#v:genericToJSON))。
退一步看,数据反序列化过程可以概括为:
```
flowchart LR
A[ByteString] --parseJSON-->B[JSON AST]
B --fromJSON--> C[领域数据]
```
第二步简化了开发者的工作:通过将解析步骤与向领域数据(此处为`Album`)的值转换分离,我们得到了更易维护的代码。
## 能否从反序列化过程中移除这个JSON AST?
https://arthi-chaud.github.io/posts/json-ir/#can-we-remove-this-json-ast-from-the-deserialisation-process
如我在引言中所说,依赖JSON AST(充当IR)意味着在内存中分配一个额外的对象(该AST的大小*至少*与最终对象一样大),以及额外的计算(将此AST转换为`Album`时)。
因此我向自己提出这些问题:
- 能否移除此IR?
- 加速效果会明显/显著吗?
- 我们还能自动/以编程方式推导代码吗?
- 安全性如何?
在数据反序列化上下文中,*安全性*指能够从错误中恢复。此类错误可能包括:
- 序列化对象中缺少一个(非空)字段
- 序列化对象中字段的类型与数据类型定义中的类型不匹配
此外,解析器应能跳过*未知*字段(即存在于序列化对象中,但在数据类型定义中缺失的字段)。
因此最终的问题是:没有IR,我们还能安全地解析数据吗?
## 解决方案:部分初始化的对象与显式字段计数器
https://arthi-chaud.github.io/posts/json-ir/#solution-partially-initalised-objects-and-explicit-field-counter
如果我们传入一个部分初始化的对象(领域数据),并让解析函数在过程中设置其字段,会怎样?
```haskell
parseAlbum :: PartiallyInitalisedAlbum -> Parser PartiallyInitalisedAlbum
```
在Haskell中,有几种方式表示部分初始化的值。例如,我们可以推导一个`PartialAlbum`数据类型,其中所有字段都包裹在`Maybe`中,或者使用一个带类型参数的数据类型来表示哪些字段存在或缺失。无论哪种方式,都需要编写类似`PartialAlbum -> Maybe Album`的函数。问题在于`PartialAlbum`本身就是一种IR,所以我们想避免它。
相反,我们将利用语言的惰性求值,并将所有对象字段设置为`undefined`,至少是没有"默认值"的那些字段。我知道,这听起来很可怕且极其不安全。但我相信这种方法能帮助编译器进行优化。
```haskell
album = Album undefined undefined undefined
-- 或者这样,但编译器会报警
album = Album{}
```
需要注意的是,由于这种方法,领域数据类型的字段不能是严格求值的!
这引出了下一个问题:如何在解析函数终止后,检查所有字段是否都已被设置?
求值`undefined`会引发错误,导致程序崩溃,因此我们显然不能这样做。
相反,我们可以传递一个位集(例如`Word64`),初始值设为`maxBound`,在设置一个字段后更新它,即清除位置`n`的位,其中`n`是字段在数据类型定义中的位置。
这样,一旦解析器终止,我们只需检查该位集是否等于0,即可判断对象是否已完全初始化。
```haskell
parseAlbum :: Parser Album
parseAlbum = case runParser (go Album{} maxBound) of
(a, 0) -> return a
(_, bits) -> fail ...
where
go :: Album -> BitSet -> Parser (Album, BitSet)
```
在`fail`分支中,我们可以检查`bits`中的每一位,并构建一个列出缺失字段的错误消息。如果所有剩余置位的位对应于类型包裹在`Maybe`中的字段,我们可以将这些字段设为`Nothing`并返回成功。
`go`函数可以这样实现:
```haskell
import Data.Bits (clearBit)
go :: Album -> BitSet -> Parser (Album, BitSet)
go album bitset = do
char '{'
res <- manyWithSep (char ',') go' (album, bitset)
char '}'
return res
-- | 运行 go'' 直到对象结束
manyWithSep :: Parser () -> (a -> Parser a) -> a -> Parser a
manyWithSep sep parser state = do
res <- parser state
either (pure res) (manyWithSep sep parser res) (try sep)
go'' :: Album -> BitSet -> Parser (Album, BitSet)
go'' album bitset = do
fieldName <- parseString
char ':'
case fieldName of
"title" -> do
title <- parseString
return (album{title=title}, (bitset `clearBit` 0))
"artist_name" -> do
artistName <- parseString
return (album{artistName=artistName}, (bitset `clearBit` 1))
"song_count" -> do
songCount <- parseInt
return (album{songCount=songCount}, (bitset `clearBit` 2))
```
很容易观察到,`go''`中模式匹配表达式的结构可以从源数据类型推导出来,例如使用Template Haskell。
当然,还有几件事需要考虑,比如字节字符串中的空白处理、字段为`Maybe`时的错误恢复等。此代码片段为简洁起见刻意省略了这些内容。
## 实现
https://arthi-chaud.github.io/posts/json-ir/#implementation
我使用`flatparse`库实现了这种方法,并依赖Template Haskell在编译时生成专门的解析器。
源代码可在[GitHub](https://github.com/Arthi-chaud/serth/tree/main/serth-json-parser)上找到。该库依赖于同一仓库中的另一个包,该包抽象了通过名称获取ADT信息的方式。这与本文关系不大,因此我不在此详述。
我应该指出,标量值的解析器并非100%兼容:字符串中转义字符的处理不完整,并且仅支持数字格式的子集。不过,由于这只是一个概念验证,我认为这是可接受的,足以进行评估。
## 基准测试
https://arthi-chaud.github.io/posts/json-ir/#benchmarks
为了评估这种方法,我使用[Criterion](https://hackage.haskell.org/package/criterion)运行了几个基准测试案例。[^2]
[^2]: 使用硬件:Intel机器,2块 Xeon Gold 6244 CPU @ 3.60 GHz,32GB RAM,Ubuntu 22.04 LTS
基准测试的代码及完整结果同样在[GitHub](https://github.com/Arthi-chaud/serth/blob/main/serth-json-parser/benchmark/Main.hs)上。
我将重点放在解析对象(大小与`Album`类似)的基准测试案例上。请记住,这些是微基准测试,可能无法反映真实应用中的性能。
我针对使用以下库的代码运行了基准测试:
- `aeson` (https://hackage.haskell.org/package/aeson),Haskell的*标准*JSON库,使用IR。
- 本库,无IR
- 本库,但先将对象解析为通用`JSON`对象,然后使用手写的`fromJSON`函数。
采用后者是为了帮助隔离IR的成本。我们的JSON解析器与`aeson`的非常不同:我们的可能更慢,但`aeson`支持定制选项[^3],这可能影响运行时性能。目前,该库不支持此类选项。
| 库/对象 | Book | Author(含书籍列表) |
| :--- | :--- | :--- |
| Aeson(带IR) | 1.051 μs | 2.901 μs |
| 本库(带IR) | 922.7 ns | 2.813 μs |
| 本库(无IR) | 314.2 ns | 1.027 μs |
[^3]: https://arthi-chaud.github.io/posts/json-ir/#fn:3
使用IR与不使用IR的代码性能差异相当显著。使用IR时,解析速度大约慢3倍。这证实了IR确实对性能有影响。
## 结论
https://arthi-chaud.github.io/posts/json-ir/#conclusion
当然,我们的实现并不完美,缺少真实世界解析器的一些功能,这可能会影响性能,最突出的是定制选项。这些选项允许定义缺失字段的行为、对象字段的大小写处理、ADT的处理等。
本文的目的并非指责像`aeson`这样依赖IR的库。IR*确实*有用,能够将解析过程与验证步骤解耦。我们的方法将两者合并,使代码更复杂、更难维护,但显著更快。
相反,我想强调IR在运行时的成本及其对性能的影响,并展示一个(可能不够优雅)的避免IR的解决方案。
IR对性能的影响并非新知。Rust的序列化框架`serde`利用分阶段编程将IR提升至编译时。
相似文章
使用数据导向设计工程化高性能解析器
本文介绍了如何通过关注内存布局和数据导向设计来设计高性能解析器,并以用Zig编写的Yuku JavaScript/TypeScript解析器为例。
使用计算着色器在GPU上进行并行JSON解析
slurpjson是一个Rust库,它完全在GPU上使用wgpu计算着色器解析JSON,将解析过程分解为并行前缀扫描,用于研究目的。
优化Markdown解析器的内存使用
这篇博客文章详细介绍了优化C++ Markdown解析器内存使用的技术,通过arena分配和指针压缩,将AST节点大小从232字节显著减少到16字节,同时保持解析速度。
@shubh6200: 要了解如何处理海量文件,请阅读 @geofflangdale 和 @lemir 的《Parsing Gigabytes of JSON per Second》…
该论文介绍了 simdjson,这是第一个能够在单核上使用 SIMD 指令每秒处理数 GB 数据的验证性 JSON 解析器,相比 RapidJSON 等现有解析器实现了显著的加速。
在ARM处理器上使用SVE2加速JSON解析
文章介绍了如何利用ARM处理器上的SVE2 SIMD指令,通过字符匹配指令来优化simdjson库中的JSON解析,从而实现更快的性能。