社区精选 | 几百行代码实现一个 JSON 解析器

业界 作者:SegmentFault 2022-07-02 20:40:53

本期为大家推荐的是社区作者 crossoverJie 的文章 ,在这篇文章中作者经过实践发现只运用编译原理前端的部分知识,就可以实现一个 JSON 解析器。到底如何操作呢?


话不多说,马上进入正文学习啦!


前言



之前在写 gscript时我就在想有没有利用编译原理实现一个更实际工具?毕竟真写一个语言的难度不低,并且也很难真的应用起来。


一次无意间看到有人提起 JSON 解析器,这类工具充斥着我们的日常开发,运用非常广泛。


以前我也有思考过它是如何实现的,过程中一旦和编译原理扯上关系就不由自主的劝退了;但经过这段时间的实践我发现实现一个 JSON 解析器似乎也不困难,只是运用到了编译原理前端的部分知识就完全足够了。


得益于 JSON 的轻量级,同时语法也很简单,所以核心代码大概只用了 800 行便实现了一个语法完善的 JSON 解析器。


<!--more-->


首先还是来看看效果:


import "github.com/crossoverJie/gjson"
func TestJson(t *testing.T) {
    str := `{
   "glossary": {
       "title""example glossary",
        "age":1,
        "long":99.99,
        "GlossDiv": {
           "title""S",
            "GlossList": {
               "GlossEntry": {
                   "ID""SGML",
                    "SortAs""SGML",
                    "GlossTerm""Standard Generalized Markup Language",
                    "Acronym""SGML",
                    "Abbrev""ISO 8879:1986",
                    "GlossDef": {
                       "para""A meta-markup language, used to create markup languages such as DocBook.",
                        "GlossSeeAlso": ["GML""XML"true, null]
                   },
                    "GlossSee""markup"
               }
           }
       }
   }
}`
    decode, err := gjson.Decode(str)
    assert.Nil(t, err)
    fmt.Println(decode)
    v := decode.(map[string]interface{})
    glossary := v["glossary"].(map[string]interface{})
    assert.Equal(t, glossary["title"], "example glossary")
    assert.Equal(t, glossary["age"], 1)
    assert.Equal(t, glossary["long"], 99.99)
    glossDiv := glossary["GlossDiv"].(map[string]interface{})
    assert.Equal(t, glossDiv["title"], "S")
    glossList := glossDiv["GlossList"].(map[string]interface{})
    glossEntry := glossList["GlossEntry"].(map[string]interface{})
    assert.Equal(t, glossEntry["ID"], "SGML")
    assert.Equal(t, glossEntry["SortAs"], "SGML")
    assert.Equal(t, glossEntry["GlossTerm"], "Standard Generalized Markup Language")
    assert.Equal(t, glossEntry["Acronym"], "SGML")
    assert.Equal(t, glossEntry["Abbrev"], "ISO 8879:1986")
    glossDef := glossEntry["GlossDef"].(map[string]interface{})
    assert.Equal(t, glossDef["para"], "A meta-markup language, used to create markup languages such as DocBook.")
    glossSeeAlso := glossDef["GlossSeeAlso"].(*[]interface{})
    assert.Equal(t, (*glossSeeAlso)[0], "GML")
    assert.Equal(t, (*glossSeeAlso)[1], "XML")
    assert.Equal(t, (*glossSeeAlso)[2], true)
    assert.Equal(t, (*glossSeeAlso)[3], "")
    assert.Equal(t, glossEntry["GlossSee"], "markup")
}


从这个用例中可以看到支持字符串、布尔值、浮点、整形、数组以及各种嵌套关系。


实现原理




这里简要说明一下实现原理,本质上就是两步:


  1. 词法解析:根据原始输入的 JSON 字符串解析出 token,也就是类似于 "{" "obj" "age" "1" "[" "]" 这样的标识符,只是要给这类标识符分类。

  2. 根据生成的一组 token 集合,以流的方式进行读取,最终可以生成图中的树状结构,也就是一个 JSONObject


下面来重点看看这两个步骤具体做了哪些事情。

词法分析


BeginObject  {
String  "name"
SepColon  :
String  "cj"
SepComma  ,
String  "object"
SepColon  :
BeginObject  {
String  "age"
SepColon  :
Number  10
SepComma  ,
String  "sex"
SepColon  :
String  "girl"
EndObject  }
SepComma  ,
String  "list"
SepColon  :
BeginArray  [


其实词法解析就是构建一个有限自动机的过程(DFA),目的是可以生成这样的集合(token),只是我们需要将这些 token进行分类以便后续做语法分析的时候进行处理。


比如 "{" 这样的左花括号就是一个 BeginObject 代表一个对象声明的开始,而 "}" 则是 EndObject 代表一个对象的结束。


其中 "name" 这样的就被认为是 String 字符串,以此类推 "[" 代表 BeginArray


这里我一共定义以下几种 token 类型:


type Token string
const (
    Init        Token = "Init"
    BeginObject       = "BeginObject"
    EndObject         = "EndObject"
    BeginArray        = "BeginArray"
    EndArray          = "EndArray"
    Null              = "Null"
    Null1             = "Null1"
    Null2             = "Null2"
    Null3             = "Null3"
    Number            = "Number"
    Float             = "Float"
    BeginString       = "BeginString"
    EndString         = "EndString"
    String            = "String"
    True              = "True"
    True1             = "True1"
    True2             = "True2"
    True3             = "True3"
    False             = "False"
    False1            = "False1"
    False2            = "False2"
    False3            = "False3"
    False4            = "False4"
    // SepColon :
    SepColon = "SepColon"
    // SepComma ,
    SepComma = "SepComma"
    EndJson  = "EndJson"
)


其中可以看到 true/false/null 会有多个类型,这点先忽略,后续会解释。


以这段 JSON 为例:{"age":1},它的状态扭转如下图:



总的来说就是依次遍历字符串,然后更新一个全局状态,根据该状态的值进行不同的操作。


部分代码如下:




感兴趣的朋友可以跑跑单例 debug 一下就很容易理解:

https://github.com/crossoverJie/gjson/blob/main/token_test.go


以这段 JSON 为例:


func TestInitStatus(t *testing.T) {
    str := `{"name":"cj""age":10}`
    tokenize, err := Tokenize(str)
    assert.Nil(t, err)
    for _, tokenType := range tokenize {
        fmt.Printf("%s  %s\n", tokenType.T, tokenType.Value)
    }
}


最终生成的 token 集合如下:


BeginObject  {
String  "name"
SepColon  :
String  "cj"
SepComma  ,
String  "age"
SepColon  :
Number  10
EndObject  }


提前检查


由于 JSON 的语法简单,一些规则甚至在词法规则中就能校验。


举个例子:


JSON 中允许 null 值,当我们字符串中存在 nu nul 这类不匹配 null 的值时,就可以提前抛出异常。




比如当检测到第一个字符串为 n 时,那后续的必须为 u->l->l 不然就抛出异常。


浮点数同理,当一个数值中存在多个 . 点时,依然需要抛出异常。



这也是前文提到 true/false/null 这些类型需要有多个中间状态的原因。


生成 JSONObject 树


在讨论生成 JSONObject 树之前我们先来看这么一个问题,给定一个括号集合,判断是否合法。


  • [<()>] 这样是合法的。

  • [<()>) 而这样是不合法的。


如何实现呢?其实也很简单,只需要利用栈就能完成,如下图所示:




利用栈的特性,依次遍历数据,遇到是左边的符号就入栈,当遇到是右符号时就与栈顶数据匹配,能匹配上就出栈。


当匹配不上时则说明格式错误,数据遍历完毕后如果栈为空时说明数据合法。


其实仔细观察 JSON 的语法也是类似的:


{
    "name""cj",
    "object": {
        "age": 10,
        "sex""girl"
    },
    "list": [
        {
            "1""a"
        },
        {
            "2""b"
        }
    ]
}


BeginObject:{ EndObject:} 一定是成对出现的,中间如论怎么嵌套也是成对的。


而对于 
"age":10 这样的数据,: 冒号后也得有数据进行匹配,不然就是非法格式。


所以基于刚才的括号匹配原理,我们也能用类似的方法来解析 token 集合。


我们也需要创建一个栈,当遇到 BeginObject 时就入栈一个 Map,当遇到一个 String 键时也将该值入栈。


当遇到 value 时,就将出栈一个 key,同时将数据写入当前栈顶的 map 中。


当然在遍历 token 的过程中也需要一个全局状态,所以这里也是一个有限状态机


举个例子:当我们遍历到 Token 类型为 String,值为 "name" 时,预期下一个 token 应当是 :冒号;


所以我们得将当前的 status 记录为 StatusColon,一旦后续解析到 token 为 SepColon 时,就需要判断当前的 status 是否为 StatusColon ,如果不是则说明语法错误,就可以抛出异常。




同时值得注意的是这里的 status 其实是一个集合,因为下一个状态可能是多种情况。


{"e":[1,[2,3],{"d":{"f":"f"}}]}


比如当我们解析到一个 SepColon 冒号时,后续的状态可能是 value 或 BeginObject { 或 BeginArray [




因此这里就得把这三种情况都考虑到,其他的以此类推。


具体解析过程可以参考源码:
https://github.com/crossoverJie/gjson/blob/main/parse.go


虽然是借助一个栈结构就能将 JSON 解析完毕,不知道大家发现一个问题没有:


这样非常容易遗漏规则,比如刚才提到的一个冒号后面就有三种情况,而一个 
BeginArray 后甚至有四种情况(StatusArrayValue, StatusBeginArray, StatusBeginObject, StatusEndArray


这样的代码读起来也不是很直观,同时容易遗漏语法,只能出现问题再进行修复。


既然提到了问题那自然也有相应的解决方案,其实就是语法分析中常见的递归下降算法。




我们只需要根据 JSON 的文法定义,递归的写出算法即可,这样代码阅读起来非常清晰,同时也不会遗漏规则。


完整的 JSON 语法查看这里:
https://github.com/antlr/grammars-v4/blob/master/json/JSON.g4


我也预计将下个版本改为递归下降算法来实现。


总结



当目前为止其实只是实现了一个非常基础的 JSON 解析,也没有做性能优化,和官方的 JSON 包对比性能差的不是一星半点。


cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkJsonDecode-12            372298             15506 ns/op             512 B/op         12 allocs/op
BenchmarkDecode-12                141482             43516 ns/op           30589 B/op        962 allocs/op
PASS


同时还有一些基础功能没有实现,比如将解析后的 JSONObject 可以反射生成自定义的 Struct,以及我最终想实现的支持 JSON 的四则运算:


gjson.Get("glossary.age+long*(a.b+a.c)")


目前我貌似没有发现有类似的库实现了这个功能,后面真的完成后应该会很有意思,感兴趣的朋友请持续关注。


源码:

https://github.com/crossoverJie/gjson




SegmentFault 思否社区小编说


自 2022-07-01 起 SegmentFault 思否公众号改版啦!之后将陆续推出新的栏目和大家见面!(请拭目以待呀~


在「社区精选」栏目中,我们将为广大开发者推荐来自 SegmentFault 思否开发者社区的优质技术文章,这些文章全部出自社区中充满智慧的技术创作者哦!


希望通过这一栏目,大家可以共同学习技术干货,GET 新技能和各种花式技术小 Tips。


欢迎越来越多的开发者加入创作者的行列,我们将持续甄选出社区中优质的内容推介给更多人,让闪闪发光的技术创作者们走到聚光灯下,被更多人认识。


「社区精选」投稿邮箱:pr@segmentfault.com

投稿请附上社区文章地址




点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


关注公众号:拾黑(shiheibook)了解更多

赞助链接:

关注数据与安全,洞悉企业级服务市场:https://www.ijiandao.com/
四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接