DIP系统中的词法分析简介(DIP系统中的词法分析简介)
DIP接口平台简介
DIP(Data Interface Platform)是苏宁内部的接口数据管理平台,目前在苏宁内部广泛使用,月均接口Mock量在60w 以上。为苏宁内部的开发效率节省了30%人力成本。
DIP主要提供三套解决方案:
1. 接口测试,用户在DIP上录入接口后,可以直接测试接口的响应。
2. 接口Mock,用户在DIP上录入接口的响应体后,可以调用DIP提供的反向代理地址,获得Mock数据。
3. 接口联调,当后端完成接口后,用户调用对应的DIP反向代理地址,请求将会直接到对应的后端服务器上,获得真实数据。
这三个主要功能刚好完成了一个接口定义->Mock->联调的生命周期。也是很好的诠释了接口数据平台的主要意义。
词法分析的需求来源
作为数据接口平台,数据就是平台的生命。而在现代的HTTP接口里,JSON这个数据基本也是灵魂一样的存在。JSON(JavaScript Object Notation)是一个用于数据交换的文本格式,现时的标准为ECMA-404。
虽然 JSON 源至于 JavaScript 语言,但它只是一种数据格式,可用于任何编程语言。现时具类似功能的格式有 XML、YAML等,当中以 JSON 的语法最为简单。JSON的简单、直观、通用性也让它取代了XML成为现在接口中最普遍的数据结构。
但是传统的JSON有这样几个弊端:
1. 不支持注释。
2. 不支持单引号字符串。
3. 不支持键名不带引号。
并且,单独的JSON数据也无法满足接口文档的需求,用户无法从JSON中得知字段的限制性、字段是否必填等额外信息。
参考市面上竞品,对于以上这些问题的解决方法无非两种。
一种是只有代码模式,支持编辑器里面输入注释。在测试接口或者Mock请求的时候将注释删除。
另一种是只有可视化模式,用户在编辑的时候,通过在表格里依次输入键值,最后可以预览看到JSON。
第一种的弊端其实就是我们上面所提到的,额外信息无法记录,如果都写在注释中,会显得比较累赘。第二种的弊端更不用说,编辑的时候操作过于麻烦,如果将另一个地方的JSON拷贝过来,也无法使用。
所以DIP要将以上两种的功能都包含进来,并且两边都可以编辑,可以实时转化。
词法分析的思路浅谈
因为涉及到带注释的JSON,并且我需要知道注释属于哪个键名。于是这个注释的位置是需要固定的,笔者经过SQL对旧数据进行统计,发现用户基本习惯于将注释写在行末。所以将标准制定为,key-value结束后的那段注释属于这个key。
所以一个标准的带注释的JSON可以如下:
{
“k”:”v”,// comment
“a”:”b”
}
而写在其他位置的JSON,因为不能被可视化所记录,所以视为不正确的位置。
查阅标准
ECMA-404是JSON现在的标准,虽然我们要做的是不标准的词法分析,但是标准文档中的很多东西还是值得读一下。
通过文档我们可知:
1. JSON分Object类型和Array类型
2. JSON的value类型主要有object,array,number,string,boolean,null
测试驱动开发
一般来说软件开发是有一定周期的,我们平时开发项目的时候,都是先写好功能再提测。但是笔者准备采用另一种思路:测试驱动开发。测试驱动开发的步骤主要是:
1. 写一些测试用例
2. 写功能代码
3. 测试,如果失败则回到2
4. 调整代码,适当重构
5. 返回1
测试驱动开发的一个主要好处是,有一种立竿见影的感觉,并且会保证我们的代码不断迭代的过程中能够早点发现问题。不至于写了很多功能代码,结果发现某个地方有个小问题,但是修改的时候发现会牵一发而动全身,骑虎难下是最尴尬的。
例如一个最简单的用例:
it('Arraylike: []', function() {
let json = `[]`
let input = parse(json)
let output = {value:"[]",type:"Array"};
expect(input).to.deep.equal(output)
});
很多习惯了写业务的同学可能会觉得测试比较累赘。因为我们会下意识的觉得这些测试应该交给专门的测试人员去负责,我们只管实现就好。
其实不然,测试驱动开发就像是用尺子划线。而盲目的写代码就好比小心翼翼的画了线,用尺子测一下发现歪了。
词法分析的代码编写
第一版简析
这一版刚好是一份反面教材。代码比较杂乱,对于JSON的各种情况都没有考虑到。
export default {
parse(content) {
if (typeof content != "string") content = JSON.stringify(content);
if (!content) return;
let result = [];
let contentArray = content.split("");
if (contentArray[0] == "{") {
// object like json
result = objectJsonParser(content.split(""));
} else if (contentArray.shift() == "[") {
// array like json
let arrayLike = JSON.parse(content);
if (arrayLike.length == 0) return result;
arrayLike.forEach((v, i) => {
result.push(objectJsonParser(JSON.stringify(v)));
});
} else {
console.error("not a valid json");
}
return result;
}
};
主函数如此,这一版我的操作是,把传入的json字符串 转成一个数组,然后开始遍历这个数组,通过对遍历到的值,得到一些判断。主函数这里主要判断是object类型的json还是数组类型的json。
接着,主函数中主要任务就是把内容传入objectJsonParser中。
...
for (char of contentArray) {
// if {, push
if (char == "{" && !isStringValue) {
//// console.log("caaa",char);
if (counter > 0) {
stack.push(char);
}
counter = 1;
} else if (char == "}" && !isStringValue) {
if (counter > 1) {
stack.push(char);
}
counter -= 1;
}
...
这里主要是 做了字符的判断,通过counter来判断处于object的层级。同时还要注意,当前是否在字符串内。所以这个判断条件也是很恶心了。
if (char == ":" && counter == 1 && !isArrayValue && !isStringValue) {
//// console.log("caaa",char);
object.key = stack.join("").trim();
valueEnd = false;
isSemiPush = false;
isPushedValue = false;
stack = [];
} else if (
char == "," &&
counter == 1 &&
!isArrayValue &&
!isStringValue
) {
object.children =
typeof objectJsonParser(
stack
.join("")
.trim()
.split("")
) == "string"
? []
: objectJsonParser(
stack
.join("")
.trim()
.split("")
);
object.value = delEmpty(stack)
.join("")
.trim();
object.type = judgeType(delEmpty(stack));
valueEnd = true;
// result.push({...object});
result.push(JSON.parse(JSON.stringify(object)));
stack = [];
isSemiPush = true;
}
当然 更恶心的还在这个地方,比如':'的判断,这里我之前就没考虑到 字符串中包含冒号和数组中包含冒号的情况,导致判断条件写的越来越臃肿。随意感受一个这个代码,不但“抽象”,而且丝毫没有维护性,定位bug也是需要定位半天。所以,吃了这次亏,我决定重构代码。
第二版简析
第一版的代码算是对JSON语法的一个摸索,经过对官方文档的仔细阅读,基于第一版的部分思路我决定开始第二版的开发。
由于JSON的语法比较简单,所以采用递归下降的方法。我们也不需要做语法分析,只需要跳过空白对下一个字符进行检测就可以知道是哪个类型的值了。然后再对每个类型的语法做分治。分析出对应的value。
入口函数如下:
function parse (json) {
if(json[0] === '{') {
return {
value: parse_object(json).value,
type: 'Object',
};
} else if(json[0] ==='[') {
// array like json
return {
value: parse_array(json).children||parse_array(json).value,
type: 'Array'
};
} else {
// if comment is before json, throw error
throw new Error(`unsupport input: ${json}`)
}
}
module.exports = parse;
通过入口函数我们将json区分为Array类型的以及Object类型
而在parse_object中,我通过对每个字符遍历,去parse出对应的key以及value。
if(value[pointer] === ':' && !inValue && !inComment) {
pointer = skip_whitespace(value, pointer);
inValue = true;
}
通过inValue、inKey等一些关键字,确保parse的结果正确。
在对value的parse中,直接将剩余字段交给辅助函数parse_value解决,一方面可以在其他地方复用,一方面避免显得太乱。
if(inValue) {
let val = parse_value(value.substr(pointer));
pointer = val.len;
if(val.type === 'Object') {
tree.children = val.value
} else {
tree.value = val.value;
}
if(val.type === 'Array') {
tree.children = val.children;
}
tree.type = val.type;
result.push(JSON.parse(JSON.stringify(tree)));
inValue = false;
nextType = 'key';
}
视线再转到parse_value这边
let parse_value = function (value) {
value = value.trim();
switch(value[0]) {
// bool
case 't':
return parse_literal(value, 'true');
case 'f':
return parse_literal(value, 'false');
// null
case 'n':
return parse_literal(value, 'null');
// string
case '"':
return parse_string(value, '"');
case "'":
return parse_string(value, "'");
// array
case '[':
return parse_array(value);
// object
case '{':
return parse_object(value);
default:
return parse_number(value);
}
};
这里我们就很简单的通过一些标志来进入对应的parse value函数,而之前提到的JSON的value类型有六种。刚好可以通过关键符来区分。而对于parse_object也完美复用之前的函数。
这里进一步的以Number类型来分析一下对value的parse的逻辑。
let parse_number = (value:string) => {
let p = 0;
if(value[p] == '-')
p ;
if(value[p] == '0')
p ;
else {
if(!ISDIGIT1TO9(value[p]))
throw new Error(`${value}: not a valid number`);
for(p ; ISDIGIT(value[p]); p );
}
if (value[p] == '.') {
p ;
if (!ISDIGIT(value[p]))
throw new Error('not a valid number');
for (p ; ISDIGIT(value[p]); p );
}
if (value[p] == 'e' || value[p] == 'E') {
p ;
if (value[p] == ' ' || value[p] == '-') p ;
if (!ISDIGIT(value[p]))
throw new Error('not a valid number');
for (p ; ISDIGIT(value[p]); p );
}
return {
value: value.substr(0, p),
type: 'Number',
len: p
}
}
对于数字的解析,则依赖于官方提供的轨道图
数字一直是比较麻烦的存在,组成一个数字很简单,但是组成一个正确的数字却是一件不容易的事情。
比如: 001, 123都不应该是正确的数字。
依赖官方给的图,代码也就很容易的写出来了。其实在看到这张图之前,我都没有想到过要兼容科学计数法。所以即使逻辑再缜密也会有考虑不到的情况。
项目中前后端的运用
1. JSON的可视化
在完成了词法分析包之后,我基于此写了一个JSON可视化的组件。通过按钮可以实时切换展示形式。
这个组件基于vue开发,两边是可以互联的,而json_parser做的主要工作就是提供ast作为中间数据。思想类似babel等工具。而vue本身是数据驱动视图的,因此我们也只需要关注数据即可。
2. 非标准JSON的转化
经过对各方面用户的调研,在用户体验方面,用户最头疼的是对于json格式的录入。虽然DIP提供拦截保存等自动化的方式,但是对于不存在的接口,手动录入还是一个高频功能。
例如:
{
a: 1,
b:’c’
}
这个结构放在代码中都应该是一个正确的object,但是它不是一个正确的JSON,而我要做的就是将这个结构直接转化为标准正确的JSON,并且要求支持注释。
解决这个问题当时也有两个思路:
1. 正则匹配替换不正确的字符
2. 基于ast
当然第一种应该是比较简单的。但是遇到一个棘手的问题,如果我替换所有的单引号,那么本身处于字符串中的单引号也会被匹配。思考许久后决定修改json_parser,利用ast转化ok。
如图所示,非标准的JSON输入后,编辑器会有警告提示。点击格式化后,变为标准JSON。
3. 接口导出为apiDoc
考虑到市面上很多公司都是习惯用apiDoc来作为接口文档。DIP也支持将接口数据导出为apiDoc。而DIP数据库中保存的是代码形式的响应,和apiDoc所需要的格式并不一致。因此直接基于json_parser在导出的时候,将JSON处理成ast,再变为apiDoc的格式即可。
总结以及改进思路
通过这次功能的开发,笔者也是学到了许多知识。对于JSON这个数据结构能不能带注释的问题,我觉得是得分场景去讨论。虽然JSON的作者认为没有这个必要,但是我觉得如果我们可以支持注释,那么又为何不去支持呢?
此次完成代码也比较仓促,虽然经过一次重构,但由于本人水平有限,仍然有许多不足之处。也希望各位看官能够不吝赐教。
改进思路:
1. 考虑用C/C 重写词法分析,以wasm以及node的拓展形式来给前端以及node端调用。
2. 支持“非正确位置”的注释。但是需要在语义无歧义的情况下。
3. 对代码结构做更好的优化。
,免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com