A Java DSL for reading JSON documents. --jayway document
这么长时间过去,又重新捡起写博客的习惯。我发现自己博客的文章大多是源自于平日里遇到的问题,很少写与实际没有半点关系的纯理论文章,所以真正回归写代码以后,又产生了写下这篇博客的想法。不过最近工作实在是有点太忙,一来二去截稿已经是发现问题的半个月之后了。
上一篇博客还是上半年实习期间的写的,再上一篇技术博更是去年上半年实习时的东西了。好歹现在也是个正式的程序员了,差不多该写一些跟这一份工作的相关的东西。
闲话之后再开一篇说(我是不是给自己挖了个新坑),说正事。
这篇想讲的是我在JSONPath使用中遇到的一些事情。首先,什么是JSONPath,我假设点进这篇博客的人可能并不全都了解JsonPath。如我在文章最初引用的那句话,A Java DSL for reading JSON documents.
jsonpath其实相当于是java中的对象查询语言。可以通过编写表达式的方式从json串中提取所需内容。科普这一点应该够了。
接着讲一下场景吧。
项目遇到了需要从json格式数据中定位数据,进行取值的需求。出现问题的点在于取值位置并不固定,会根据条件动态变化。这种需求感觉和查mongoDB数据库差不多,算是一种query language了。
如果只是普通的按层取值,毫无难度,我膨胀到觉得自己都能撸一个出来。但是需求并不总是按照我的心意来。举一个稍微复杂的场景的例子吧,需要进行取值的是一个二维数组内的元素,而这个元素是个map,需要通过map里的某对key-value来定位这个map,再取另一个key的value。数组里的元素都是无序的。从这种复杂的情景中取值,这就是项目目前面临的需求。
然后想说下项目的演进变化。
最初这个项目是打算使用python写的,pyhon由于本身对于json的解析支持得很好,原生python就能够支持简单的定位。但是缺点就是几乎找不到jsonpath的实现库,只找到了一个go语言写的gjson。所以本来的打算是自己用python实现一个阉割版,因为暂时用不到太多的功能。
再到后来,决定项目整体换用java实现,心情从最初的担心实现不了变成了担心开源组件的功能不能完美兼容需求。好在后来找到了jayway和fastjson两种关于jsonpath的实现,又纠结起到底该使用哪一个,算是幸福的烦恼。
fastjson是阿里的一个开源项目,主要业务其实不是jsonpath,它主要是作为Json序列化和反序列化的工具存在,宣传口号是快,但是根据一些网上的benchmark来看,相比于jackson也并不是碾压级别的,只能说在性能上小胜。同时根据网络上的风评以及我自己的使用经验来看,bug稍多,并且足以影响到系统安全性和稳定性,所以我认为fastjson只有易用这一个优点。
至于jayway,在文章的开头那句话就引自jayway文档。此外,对于jayway也没啥要介绍的了,当然也不排除存在bug的可能,毕竟github上还是有100多个issue,不过比fastjson的issue数少了一个数量级,而且在我的使用过程中暂时还没有出现问题,感觉在简单的场景下应该不太会出什么问题。
需要声明一点的是,这篇文章重点也不是教人如何使用这些工具,这样的教程不管是百度还是谷歌都一搜一大把,虽然质量上良莠不齐。因此,我就不在这里赘述表达式怎么写,api如何使用这些东西了。
那么在项目开发中到底出现了什么问题呢?
从语法上来说,jayway是很规范的jsonpath语法的实现,但是fastjson似乎在实现上有自己的想法,对部分语法进行了简化,可以支持更加简单的规则写法,在使用面向非开发人员时,会更具有优势。而从需求的部分来看,其实大部分的复杂取值表达式写法都使用不到,只是需要会使用到的那部分功能保持稳定。
在最初考虑如何取舍这两个项目时,我内心是趋向于fastjson的,第一是因为简化的jsonpath确实会便于使用,在项目需要使用方写表达式的情况下,能简化是最好的结果了。第二是因为这个项目毕竟来自阿里,根据以前使用开源组件的经验来看,其实阿里大多数时候还是很靠谱的,并且在本土化方面做得很不错,很适合国内使用的环境。第三是团队内部大概是因为有很多前阿里员工的缘故,很多项目都是使用fastjson来做json与object的转换,虽然因此也在前不久遭遇了一次小小问题,发现fastjson出现安全漏洞要求整个部门项目立刻升级版本,即使有这种问题,但是我作为一个新人,感觉在技术栈上配合团队是比较好的选择。
但是,跌宕起伏。
我这人有一个习惯,喜欢在正式引入某个库之前先写个demo来用一用,一来是可以先熟悉一下api,而来是找一些具有代表性的场景来检测下是否能满足功能需要。这次,在写这个demo的时候,发现了fastjson的一个小bug。
这里我举一个二维数组的例子来说明一下发现的问题。
首先定义一个带有二维数组的json串。这里用的是某个应用真实的响应数据结构,截取掉不必要的code、message等字段,删减了无关数据,修改简化了展示的值,只保留了二维数组的基本结构。
{
"data": [
[
{
"value": 1958,
"hb": -0.03
},
{
"value": 0.28,
"hb": -0.04
}
]
]
}
可以看出data这个键的值是一个二维数组。如果要取出内层第二个数组元素中的value值,正确的表达式写法应该是$.data[0][1].value
,当然表达式还有另一种写法,我这里就不写了,jayway和fastjson使用这种写法获得的结果都是0.28
。到这里都还是没有问题的。
根据需求,我需要由数组元素内部的某个值来判断这个元素是不是我需要的,之后再在这个元素内取值进行取值。以这个例子来说,假设我需要的值是当value == 0.28
对应的元素(这只是示例,正常的需求里并不是使用hb,而是会有标识的字段用来匹配),那么表达式应该写成$.data[*][?(@.value == 0.28)]
,这个表达式写法在两种jsonpath里应该都是通用的。这时,两个jsonpath库的处理结果出现了变化。
jayway [{"value":0.28,"hb":-0.04}]
fastjson []
发现fastjson没有获取到应该获取的元素值,只留下了一个空的数组结构,这就很奇怪了。在再三确认我的表达式写法没有问题以后,我确定这时fastjson出了问题,但是不知道是啥问题。我怀疑是*
使用了这个符号导致的,但是没有证据。于是我设计了一个实验,不使用判断逻辑,只把简单定位的一部分替换成用*
这个符号,来控制变量找问题。
当希望取所有内层第二个value时,正确的取值表达式写法是$.data[*][1].value
,根据这个表达式,两种jsonpath给出了不同的答案。
jayway [0.28]
fastjson null
jayway取到了想要的结果,而fastjson返回了null,令人费解。而更加让人摸不着头脑的还在后面。对表达式稍作变化,如果想取的值是内层的第一个value,使用$.data[*][0].value
,按理说结果至少会跟上面的执行结果差异不大,但是事实出乎意料。
jayway [1958]
fastjson [1958,0.28]
jayway返回了期望的结果,但是fastjson获取了内层的所有value值。这时基本就能确定是fastjson在解析*
所表示的数组时是有问题的。这个问题导致的结果是第二层数组里的表达式或是数字没有对应到第二层去,而是对应到了第一层。所以在第一个表达式中$.data.[*][1]
,因为第一层只有一个元素的关系,不存在data[1]
,解析结果就为null;第二个表达式$.data[*][0]
实际的解析结果也对应的是data[0]
,所以最终结果是拿到了内部所有的value。
感觉这么一分析,问题出现的原因甚至也清晰了起来。为了验证我的这个想法,稍微debug一下。这里稍微有一点要提一下,文章接下来部分用到的代码都是.class
文件反编译出来的代码,并不是真正的源码。(后来看了下,感觉也没啥影响人判断的差别)
fastjson的JSONPath部分只是粗看的话其实并不复杂,eval
方法是一个根据分段进行循环处理的方法,segment
是按照表达式解析分段处理,例如$.data[0][1].value
,分段之后就是data 0 1 value
四个部分,实例中保存着当前的属性名,除此之外还有rootObject
和currentObject
两个对象,这两个对象从名字上就能知道是什么东西了,对象内部是key-value结构。eval
方法根据当前segment
属性的不同,做了重载,使用不同的方法来处理。
但是当表达式变成$.data[*][1].value
时,最初进入eval方法进行init
时,发现segment
少了一段,也就是之前的长度为4的segment
数组变成了只有data 1 value
三个元素的数组。segment
的赋值操作是用的explain
方法,那么问题就出在init
里的这个方法上了。
while(true) {
JSONPath.Segment segment;
JSONPath.PropertySegment propertySegment;
do {
segment = this.readSegement();
if (segment == null) {
if (this.level == segments.length) {
return segments;
}
JSONPath.Segment[] result = new JSONPath.Segment[this.level];
System.arraycopy(segments, 0, result, 0, this.level);
return result;
}
if (!(segment instanceof JSONPath.PropertySegment)) {
break;
}
propertySegment = (JSONPath.PropertySegment)segment;
} while(!propertySegment.deep && propertySegment.propertyName.equals("*"));
这里有一部分的逻辑不太能理解,毕竟拿不到一点注释根本不知道有些变量时干嘛的,但是看这代码的意思似乎是如果segment
的类型不是PropertySegment
就会转换成PropertySegment
,但是这时deep
会默认赋值为false
,于是由于有*
且deep == false
就会继续循环下去,获得下一层数组的值。所以就漏了一层。
而这里解析segment
少了一层,直接影响就是导致后面eval
方法根据segment
循环取值,在外层数组的object
直接使用了内层数组的index,所以导致了类似indexoutofbounds
的问题,结果就直接返回null
了。
写到这里,关于jsonpath相关的内容就结束了,fastjson一败涂地。又想到了一桩旧事,说是旧事,发生也不过两个月。七月的某个周五下午,我们组聚餐活动已经临近出门了,收到了安全部要求更新fastjson版本解决0day漏洞的通知,饭点被迫延迟了一个多小时(也就是我被饿了一个多小时,比较要命)。
想到这里,我就去给fastjson的github仓库提了个issue给开发团队,截止到两周后我写完这篇文章的时候,仍然没有收到任何反馈,也许jsonpath这块代码都已经无人维护了。阿里确实是国内java方向最强的公司无疑,但是也没必要神话,这种功能性上的重大缺失,问题有点大。