手写Json解析器学习心得
一. 介绍
一周前,老同学阿立给我转了一篇知乎回答,答主说检验一门语言是否掌握的标准是实现一个Json解析器,某大厂过去的Python入门培训作业之一就是五天时间实现一个Json解析器。
该回答对应的问题提及了一个开源的“从零开始的JSON库教程”,恰好我刚开始学习go语言,对Json的理解也仅停留在一种端到端之间交互的数据格式,于是便跟着教程写了一遍,受益良多,至少对我这种编程经验少的人来说十分有帮助,以下是我的学习心得。
二. 总体收获
1. 测试与重构
其实在刚开始接触编程的时候,也经常听说要给自己的代码写测试,但是一直没有学过相关的方法论,也不知道如何实践,直到在公司实习的时候才慢慢意识到测试的重要性。当时每写完一个功能,导师都会要求我造数据进行测试,从我当时的理解上看,自己写测试用例的目的在于尽量去覆盖用户的各种行为,保证系统运行的稳定性。
但是在经历了这门Json解析器教程后,我对编写测试用例又有了更进一步的理解。该教程详细地介绍了一种叫TDD的开发模式,中文是测试驱动开发,并从第一单元开始就贯彻执行。
在我看来,先写测试后进行开发能帮助我们明确我们想要开发的功能,减少我们走弯路的可能性。但有时提前做计划往往不太容易,可能会出现测试不太好写的情况,这个时候我们先把功能开发出来反而会更轻松一些。教程作者也推荐我们在实际开发中两种风格并用,以达到平衡。
说实话,刚开始看到自己的代码能顺利通过全部测试的时候还蛮有成就感的。但是随着课程的深入,我发现完备的测试不只是给我成就感这么简单,更多的是一种安全感。
因为随着解析器的功能增加,我们的代码会出现一些通用的模块,为了提高通用性,我们需要进行重构,而完备的单元测试是我们放胆去重构的重要保障。
另外,由于和教程使用的语言不一样,有些地方需要按自己的理解去写,不能够全盘照搬,许多地方一开始实现得不太周全。我印象最深的地方是一开始我们要解析null,false,true,数字和字符串,这些都是单个功能,各自通过单独的测试用例不会很难。但是当我们要解析数组的时候,由于数组中有多个值,而且还可能有嵌套数组,这个时候就要保证单个值的解析不影响全局的解析。
我当时在做数组解析的时候遇到了不少的问题,基本都是单值解析的代码不够完善而导致的。还好之前跟着教程写了足够的测试用例,支撑着我把整个数组解析功能写正确,从此爱上写单元测试。
2. C语言的魅力
教程是用标准的C语言写的,作者本身是C/C++的大牛,功力深厚。虽然我对C语言了解不多,但是跟着教程的解释去阅读C代码也没有太大的问题。
教程关于C语言的知识点很多,比如宏的定义,内存的分配与释放,内存泄漏检测等,最令我赞叹的是作者对指针的运用,太精巧了。虽然Go语言里面也有指针,但是在我做这个教程的过程中,Go的指针更多时候只是用来传址。也由于指针没那么强大,我不太方便像作者一样实现一个通用强大的堆栈,用于暂存Json的解析内容。但Go语言有强大的Slices,用起来也很爽,很方便。
既然是用不同的语言实现同样的功能,那我们肯定要充分发挥自己所用语言的优势了,这也是我们想通过项目入门一门语言的关键。
三. 项目各个阶段的收获
1. 启程
开始的第一章中我最大的收获就是弄清楚了整个解析器的结构。
在项目的开始阶段,我们首先搭建一个简单测试框架,比如把测试通过的数量,没有通过的数量和出错信息打印出来,方便自己观察测试通过情况。
然后需要定义好Json解析器的数据结构,一旦数据结构定义好了,软件就完成了一半。这里我们会用一个树状结构来组织我们解析到的数据,每个数据保存在一个节点里,我们要做的就是把这个节点定义出来。
根据Json协议,Json一共有7种数据类型:
object, array, string, number, "true", "false", "null"
为了分辨一个节点是哪种数据类型,我们需要给节点增加一个type字段,用于标识节点的类型,type的数值我们可以用一个枚举进行维护。同时为各种数据类型准备一个接收的字段(为了方便处理,没有为true/false/null
设置字段)。
type EasyValue struct {
vType int //节点数据类型
num float64
str []byte
len int
e []EasyValue
o []EasyObj
}
数据结构搭建好了之后,我们整个解析器的框架就很清晰了:
- 传入一个Json字符串,创建一个根节点,并用解析器进行解析,具体来说就是逐个字符进行分析。
- 假设分析出是一个数字,那么就把这个根节点的数字类型设置为数字,并将解析出来的数字放入到节点的num字段中。
- 当我们想要获取解析的结果时,只需要根据节点的数据类型到节点相应的字段获取对应的值就好了。
以上就是整个Json解析器的思路了,在第一章脑子里奠定了这样的基础后,就有了整体的大局观,后面的章节就是根据各种数据类型进行解析。
2. 解析数字
在解析数字的时候,作者选择了直接调用字符串转数字的库函数,由于库函数的接收域比较宽,有些错误情况需要我们提前做处理,总体来说还是好实现的。
但是在处理的过程中我却遇到了一个Go语言中比较棘手的问题:在我们调用字符串转数字的库函数时,是有可能出错的,通常会有两种错误,一个是数字非法(这个字符串不是一个数字),另一个是数字溢出。在其他语言中都能够很好地判断错误类型,然后向用户端返回相应的错误码。但是Go语言对错误的处理比较简洁,它只提供了一个error接口,接口中只有一个string字段用于说明错误信息。这意味着如果一个函数里同时抛出两个错误,得通过错误信息来判断发生了什么错误。具体来说就是通过判断一个字符串中是否包含另一个字符串来分辨错误类型,这似乎有点土。
f, err := strconv.ParseFloat(convStr, 64)
if err != nil {
if strings.Contains(err.Error(), strconv.ErrRange.Error()) {
return EASY_PARSE_NUMBER_TOO_BIG
}
return EASY_PARSE_INVALID_VALUE
}
谷歌一番后似乎还是没有特别好的解决方案,现有的开源方案和官方给出的方案基本都是对错误进行多一层封装,但这招好像对库函数不太管用。也可能是我刚开始用go语言,阅历比较少,在今后的使用中我得留意一下这个问题。
3. 解析字符串 - 4. Unicode
接下来到了解析字符串,在这章被作者的一顿指针操作所折服,但是到了自己实现,发现用Go的Slices似乎很简单就实现了,就是不知道性能差得大不大。
在这章最大的收获是,入门了Unicode编码。以前编程就是一把梭,编码这些知识扫两眼就跳过去了,出了乱码就谷歌解决方案,没有考虑过背后的知识。但在这里得实打实地处理字符的转换,我们的目标是把字符串存储为UTF-8的形式,背后的关系得搞清楚。
最早的时候用的是ASCII码,ASCII码只有7位,也就是只能表示128个字符。但是世界上的字符太多了,128远远不够,这个时候就出来了Unicode编码。Unicode编码记录了成千上万个字符,但这也意味着它要更多的存储空间,Unicode的转换形式的缩写就是我们常见的UTF,而UTF-8就是说把Unicode以8位为一个单元进行存储。
有了这些前置知识之后,我们就需要对字符串中的Unicode编码进行转换,具体的过程是把Unicode字符转换为对应的码元(十六进制数),然后把十六进制数编码成UTF-8的形式。
按照教程做下来对编码也有了初步的认识,感觉良好,这估计就是知识的乐趣吧^_^
5. 解析数组 - 6. 解析对象
到了做解析数组和对象功能时,我感受到了递归的力量,这可能就是作者称之为递归下降解析器的原因吧。
但是在这个部分,我最大的收获是深度体会到了单元测试的好处。当解析数组的时候,我们很可能需要对多个类型的值进行解析,这个时候就把之前单独实现的解析功能给串起来了。
比如说这样一个字符串:
"[123,null,\"abc\",[1,2,3]]"
首先需要解析123
,然后解析null
,在我们解析完123
的时候,指针应该来到,
的位置,通过,
进行划分后再继续下一个值的解析。记得当时我在解析单个值的时候没有处理好指针的位置,导致整个数组解析失败了,不过这也加深了我对整个Json字符串解析过程的理解。
至此整个Json解析器的功能已经基本完成,后面两个小节是关于生成器和解析对象访问及其他功能的。
四. 总结
这个教程是用C语言写的,作者用了很多C语言的特性,能很好地提高性能,而我刚入门Go语言,对Go的特性了解甚少,可能在一些地方没有用更适合Go语言的处理方式去处理。
而在我们日常的开发中,通常会这么用Json:把一个自定义的数据结构转化成Json串,或者是把Json串转换为我们自定义的结构,目前我还没有实现这样的功能。而对于这样的功能,Go语言给予了原生支持。
我看了一下Go原生解析Json的源码,在解析的思路上和教程是有很多相通之处的。比较大的区别是:在我们手写的Json解析器中,我们把解析后的数据存储放我们自定义的节点结构中。而在Go语言中,由于Json的使用场景常常和结构体相关联,Go语言会把解析出来的数值通过反射直接赋给相应的结构体,这么一来省去了自建数据结构的步骤。
最后非常感谢这个教程,让我对Json的解析有了初步的认识,对测试与重构有了更深的理解,同时也达到了自己的初衷,能熟悉地使用Go语言写分支循环判断了。但我知道Go语言的魅力不在于此,还有很多特性等待着我去学习,继续加油~