大家好。今天我的任务是写一些JS代码来完成JSON字符串的解析。
JSON
JSON的全称是JavaScript Object Notation,它是一种轻量级的数据交换格式。其数据类型包含对象、数组、字符串、数字、布尔值(true和false)以及空(null)。
我们将一起来完成一个函数,其输入为JSON字符串,而输出为一个JS对象。
在这里我们将使用两种不同的方式:前者使用了ohm.js(https://github.com/harc/ohm),这意味着我们将定义语法规则(以及词法规则);而后者我们将手写解析器。
使用ohm.js
ohm.js简介
ohm.js是一个帮助我们创建语法解析器、解释器和编译器的工具库。
让我们来看一个非常简单的例子:
const ohm = require('ohm-js');
// 定义语法规则
const myGrammar = ohm.grammar('MyGrammar { greeting = "Hello" | "Hola" }');
// 解析代码
const m = myGrammar.match('Hello');
// 定义节点求值规则
const semantics = myGrammar.createSemantics().addOperation('eval', {
greeting: (e) => {
console.log(e.sourceString);
},
});
// 求值
semantics(m).eval();
首先我们使用ohm所提供的DSL(Domain Specific Language,领域特定语言)来定义我们的语法(grammar)规则。对于ohm所提供的语法(syntax)请参考这里。
随后我们使用我们定义的语法来解析代码。生成的结果会包含一个_cst
属性,即其解析得到的具体语法树。
随后,我们需要定义语意(semantic)。我们需要通过addOperation
方法为各种类型的节点定义处理规则。上例中我们定义了一套eval
规则,来进行语法树的求值,这相当于实现了我们自创语言的解释器。我们还可以同时添加多套处理规则,例如通过再定义一套transpile
规则来将我们的语言转换为其他语言,这就相当于又实现了源码到源码的编译器(transpiler)。
定语语法
一个JSON格式的对象可以是:数字、字符串、布尔值、空、数组、对象,以及它们的嵌套。因此,我们可以将根语法定义如下:
JSON {
Value =
Object
| Array
| String
| Number
| True
| False
| Null
...
}
其中True
、False
、Null
都是非常简单的,我们直接用字面常量就可以定义:
True = "true"
False = "false"
Null = "null"
定义数字
无符号证书数字的基本组成部分包括0~9
的字符,其中我们不允许整数部分以0
起始,而整数可以是无符号整数或是其在头部添加-
:
wholeNumber =
"-" unsignedWholeNumber -- negative
| unsignedWholeNumber -- nonNegative
unsignedWholeNumber =
"0" -- zero
| nonZeroDigit digit* -- nonZero
nonZeroDigit = "1".."9"
而数字包含整数与小数:
decimal =
wholeNumber "." digit+ -- withFract
| wholeNumber -- withoutFract
需要注意的是,至此为止,还没有完整的定义数字,因为在JSON规范中还允许科学记数法,例如以5.2E2
代表520
:
exponent =
exponentMark ("+"|"-") digit+ -- signed
| exponentMark digit+ -- unsigned
Number =
decimal exponent -- withExponent
| decimal -- withoutExponent
定义字符串
字符串需要注意的部分是各种转义:
doubleStringCharacter (character) =
~("\\"" | "\\\\") any -- nonEscaped
| "\\\\" escapeSequence -- escaped
escapeSequence =
"\\"" -- doubleQuote
| "\\\\" -- reverseSolidus
| "/" -- solidus
| "b" -- backspace
| "f" -- formfeed
| "n" -- newline
| "r" -- carriageReturn
| "t" -- horizontalTab
| "u" fourHexDigits -- codePoint
fourHexDigits = hexDigit hexDigit hexDigit hexDigit
注意的一点是,为了使用换行我这里使用了模板字符串来定义语法,因此里面的转义符\
需要再次转义为\\
。
定义数组、对象
数组的定义也很直观:
Array =
"[" "]" -- empty
| "[" Value ("," Value)* "]" -- nonEmpty
这里我们区分了空数组与非空数组。("," Value)*
代表,
加上Value
的组合重复0、1或多次。
定义巴斯克范式(BNF)的语法和正则表达式的语法有许多相似之处,而它们的却别之一就是BNF可以互相嵌套的,例如这里Value
包含了Array
,而Array
又可以包含Value
。
对象也是类似的:
Object =
"{" "}" -- empty
| "{" Pair ("," Pair)* "}" -- nonEmpty
Pair =
String ":" Value
求值
接下去我们创建一个称为eval
的求值操作:
const semantics = myGrammar.createSemantics().addOperation('eval', {
...
};
True
、False
、Null
最为直接:
True(_) {
return true;
},
False(_) {
return false;
},
Null(_) {
return null;
},
Number
在语法定义时比较复杂,但求值时我们可以在上层直接处理,而不必一层层深入:
Number(item) {
return parseFloat(item.sourceString, 10);
},
而对于String
我们则需要给那些转义字符串定义返回值:
String(_0, item, _1) {
return item.children.map(x => x.eval()).join("");
},
doubleStringCharacter_nonEscaped(item) { return item.sourceString; },
doubleStringCharacter_escaped(_, item) { return item.eval(); },
escapeSequence_doubleQuote (_) { return '"'; },
escapeSequence_reverseSolidus (_) { return '\\'; },
escapeSequence_solidus (_) { return '/'; },
escapeSequence_backspace (_) { return '\b'; },
escapeSequence_formfeed (_) { return '\f'; },
escapeSequence_newline (_) { return '\n'; },
escapeSequence_carriageReturn (_) { return '\r'; },
escapeSequence_horizontalTab (_) { return '\t'; },
escapeSequence_codePoint (_, item) {
return String.fromCharCode(parseInt(item.sourceString, 16));
},
这样的话例如wholeNumber
、decimal
之类的节点类型都不会被触及到,因此也不用为它们定义eval
方法了。
数组分为非空和空两种情况,后者直接返回空数组就可以了:
Array_nonEmpty (_0, item, _1, items, _2) {
return [item.eval()].concat(items.children.map(x => x.eval()));
},
Array_empty (_0, _1) {
return [];
},
这里简单解释一下,Array_empty
中_0
和_1
(以及Array_nonEmpty
的_0
和_2
)分别是左右方括号。Array_nonEmpty
的_1
和items
一样是_iter
(迭代器)类型的节点,不过它代表就是,
而已。
对象也是类似的:
Object_nonEmpty(_0, item, _1, items, _2) {
const obj = {};
obj[item.children[0].eval()] = item.children[2].eval();
for (let d of items.children) {
obj[d.children[0].eval()] = d.children[2].eval();
}
return obj;
},
Object_empty (_0, _1) {
return {};
},
手写解析器
FSM、DFA、NFA
这里首先提起几个名词:
- 有限状态机(FSM):有限状态机由状态以及状态之间的转移动作组成。
- 确定的有限状态机(DFA):在输入一个状态时,只得到一个固定的状态。
- 非确定的有限状态机(NFA):当输入一个字符或者条件得到一个状态机的集合。
这里我就不多讲概念了(其实博主也并不懂)。总之就是我们要实现一个FSM,而对于JSON这样的语法是可以很轻松的编写DFA解析的,而DFA要比NFA简单和直观。
解析JSON
我先定义了一个全局记录状态的env
对象:
const env = {
str: null,
pos: 0,
};
parse
方法会重置全局状态并调用doParse
方法,并在最终检查整个字符串是否已经解析完毕,如果存在未解析的部分即代表解析失败了:
const parse = function parse(str) {
env.str = str;
env.pos = 0;
const result = doParse();
if (env.pos !== str.length) throw new Error('parse failed');
return result;
};
doParse
方法会依次尝试以不同类型(进入不同解析状态)来解析:
const doParse = function doParse() {
for(let parseFunc of parseAll) {
const val = parseFunc();
if (val !== undefined) {
return val;
}
}
};
const parseAll = [
parseObject,
parseArray,
parseString,
parseNumber,
parseTrue,
parseFalse,
parseNull,
];
即一开始是起始状态,其实状态会一次尝试进入对象解析状态、数组解析状态等等。
true, false, null
我们用substr
直接比较接下来的字符串,如果匹配,当前位置前移相应的数字,否则返回undefined
表示跳过当前解析状态并进入下一个解析状态。
const parseTrue = function parseTrue() {
const flag = env.str.substr(env.pos, 4) === 'true';
if (flag) {
env.pos += 4;
return true;
}
};
const parseFalse = function parseFalse() {
const flag = env.str.substr(env.pos, 5) === 'false';
if (flag) {
env.pos += 5;
return false;
}
};
const parseNull = function parseNull() {
const flag = env.str.substr(env.pos, 4) === 'null';
if (flag) {
env.pos += 4;
return null;
}
};
数字
这里我们要考虑负数、小数、科学计数等问题(不过我这里并没有对0
做为非零整数的第一位的情况报错):
const parseNumber = function parseNumber() {
let str = '';
let pos = env.pos;
const get = function get() {
const char = env.str.charAt(pos);
let val = null;
if (char === '-' || char === '.' || char === 'e' || char === 'E' || (char >= '0' && char <= '9')) {
pos += 1;
return char;
};
};
let next;
while(next = get()) {
str += next;
}
if (str.length !== 0) {
if (isNaN(str)) throw new Error('bad number');
env.pos += str.length;
return parseFloat(str, 10);
}
};
我们将依次读取字符,如果其为数字或负号、小数点、科学计数符号,则将其保留下来,否则跳出循环。之后如果保留下的字符串的长度为0
,代表并为进入数组解析状态,返回undefined
。否则使用isNaN
检查数字的合法性,不合法则抛出解析错误,否则返回parseFloat
的结果。
字符串
是否进入字符串解析状态,取决于接下来的第一个字符是否是"
。
如果进入此状态,则依次读取字符,直到再次遇到"
。同时这里要注意处理转义字符:
const parseString = function parseString() {
let str = '';
let pos = env.pos;
if (env.str.charAt(pos) !== '"') return;
pos += 1;
const get = function get() {
const char = env.str.charAt(pos);
let val = null;
if (char === '"') return;
if (char === '\\') {
pos += 2;
return escape(env.str.substr(pos - 2, 2));
}
pos += 1;
return char;
};
let next;
while(env.str.charAt(pos) !== '"' && (next = get())) {
str += next;
}
if (env.str.charAt(pos) === '"') {
env.pos = pos + 1;
return str;
}
throw new Error('bad string');
};
const escape = function escape(str) {
console.log(str);
switch(str) {
case '\\\\': return '\\';
case '\\b': return '\b';
case '\\f': return '\f';
case '\\n': return '\n';
case '\\r': return '\r';
case '\\t': return '\t';
case '\\"': return '"';
default: return str;
}
};
在最后,我们需要检查字符串是否以"
结尾,因为这里如果字符串已经读取到最后也会跳出循环(例如"abc
),否则抛出错误。
数组、对象
数组的进入动作的判断是[
。
在其中我们递归调用doParse
来读取数组每一项的值。这里我们还用了white
方法来跳过允许的空格和回车。同样在最后需要以检查]
作为退出动作。
const parseArray = function parseArray() {
const arr = [];
let pos = env.pos;
if (env.str.charAt(pos) !== '[') return;
env.pos += 1;
white();
let next;
while(env.str.charAt(env.pos) !== ']' && (next = doParse())) {
arr.push(next);
if (env.str.charAt(env.pos) === ',') env.pos += 1;
white();
}
if (env.str.charAt(env.pos) === ']') {
env.pos += 1;
return arr;
}
throw new Error('bad array');
};
...
const white = function white() {
let char = env.str.charAt(env.pos);
while(char === ' ' || char === '\n') {
env.pos += 1;
char = env.str.charAt(env.pos)
}
};
对象也是类似的,其中键值对的键为字符串,因此同样使用parseString
来处理:
const parseObject = function parseObject() {
const obj = {};
let pos = env.pos;
if (env.str.charAt(pos) !== '{') return;
env.pos += 1;
white();
let key;
while(env.str.charAt(env.pos) !== '}' && (key = parseString())) {
if (env.str.charAt(env.pos) !== ':') throw new Error('bad object');
env.pos += 1;
white();
const val = doParse();
if (!val) throw new Error('bad object');
obj[key] = val;
if (env.str.charAt(env.pos) === ',') env.pos += 1;
white();
}
if (env.str.charAt(env.pos) === '}') {
env.pos += 1;
return obj;
}
throw new Error('bad object');
};
最后提一句,之所以说解析JSON是确定的,因为例如如果我们遇到了{
,那我们可以确定要进入对象解析,如果解析失败就可以直接抛错而不必回退;而类似Javascript,则可能存在{ a: 1 }
的对象和{ a = 1; }
的代码块的多种情况,需要回退机制。
附录:
ohm解析JSON:
const ohm = require('ohm-js');
const myGrammar = ohm.grammar(`
JSON {
Value =
Object
| Array
| String
| Number
| True
| False
| Null
Object =
"{" "}" -- empty
| "{" Pair ("," Pair)* "}" -- nonEmpty
Pair =
String ":" Value
Array =
"[" "]" -- empty
| "[" Value ("," Value)* "]" -- nonEmpty
String =
"\\"" doubleStringCharacter* "\\""
doubleStringCharacter (character) =
~("\\"" | "\\\\") any -- nonEscaped
| "\\\\" escapeSequence -- escaped
escapeSequence =
"\\"" -- doubleQuote
| "\\\\" -- reverseSolidus
| "/" -- solidus
| "b" -- backspace
| "f" -- formfeed
| "n" -- newline
| "r" -- carriageReturn
| "t" -- horizontalTab
| "u" fourHexDigits -- codePoint
fourHexDigits = hexDigit hexDigit hexDigit hexDigit
Number =
decimal exponent -- withExponent
| decimal -- withoutExponent
decimal =
wholeNumber "." digit+ -- withFract
| wholeNumber -- withoutFract
wholeNumber =
"-" unsignedWholeNumber -- negative
| unsignedWholeNumber -- nonNegative
unsignedWholeNumber =
"0" -- zero
| nonZeroDigit digit* -- nonZero
nonZeroDigit = "1".."9"
exponent =
exponentMark ("+"|"-") digit+ -- signed
| exponentMark digit+ -- unsigned
exponentMark = "e" | "E"
True = "true"
False = "false"
Null = "null"
}
`);
const parse = function parse(str) {
const m = myGrammar.match(str);
return semantics(m).eval();
};
const semantics = myGrammar.createSemantics().addOperation('eval', {
Object_nonEmpty(_0, item, _1, items, _2) {
const obj = {};
obj[item.children[0].eval()] = item.children[2].eval();
for (let d of items.children) {
obj[d.children[0].eval()] = d.children[2].eval();
}
return obj;
},
Object_empty (_0, _1) {
return {};
},
Array_nonEmpty (_0, item, _1, items, _2) {
return [item.eval()].concat(items.children.map(x => x.eval()));
},
Array_empty (_0, _1) {
return [];
},
String(_0, item, _1) {
return item.children.map(x => x.eval()).join("");
},
doubleStringCharacter_nonEscaped(item) { return item.sourceString; },
doubleStringCharacter_escaped(_, item) { return item.eval(); },
escapeSequence_doubleQuote (_) { return '"'; },
escapeSequence_reverseSolidus (_) { return '\\'; },
escapeSequence_solidus (_) { return '/'; },
escapeSequence_backspace (_) { return '\b'; },
escapeSequence_formfeed (_) { return '\f'; },
escapeSequence_newline (_) { return '\n'; },
escapeSequence_carriageReturn (_) { return '\r'; },
escapeSequence_horizontalTab (_) { return '\t'; },
escapeSequence_codePoint (_, item) {
return String.fromCharCode(parseInt(item.sourceString, 16));
},
Number(item) {
return parseFloat(item.sourceString, 10);
},
True(_) {
return true;
},
False(_) {
return false;
},
Null(_) {
return null;
},
});
export default {
parse,
};
手动解析:
const env = {
str: null,
pos: 0,
};
const parse = function parse(str) {
env.str = str;
env.pos = 0;
const result = doParse();
if (env.pos !== str.length) throw new Error('parse failed');
return result;
};
const doParse = function doParse() {
for(let parseFunc of parseAll) {
const val = parseFunc();
if (val !== undefined) {
return val;
}
}
};
const parseTrue = function parseTrue() {
const flag = env.str.substr(env.pos, 4) === 'true';
if (flag) {
env.pos += 4;
return true;
}
};
const parseFalse = function parseFalse() {
const flag = env.str.substr(env.pos, 5) === 'false';
if (flag) {
env.pos += 5;
return false;
}
};
const parseNull = function parseNull() {
const flag = env.str.substr(env.pos, 4) === 'null';
if (flag) {
env.pos += 4;
return null;
}
};
const parseNumber = function parseNumber() {
let str = '';
let pos = env.pos;
const get = function get() {
const char = env.str.charAt(pos);
let val = null;
if (char === '-' || char === '.' || char === 'e' || char === 'E' || (char >= '0' && char <= '9')) {
pos += 1;
return char;
};
};
let next;
while(next = get()) {
str += next;
}
if (str.length !== 0) {
if (isNaN(str)) throw new Error('bad number');
env.pos += str.length;
return parseFloat(str, 10);
}
};
const parseString = function parseString() {
let str = '';
let pos = env.pos;
if (env.str.charAt(pos) !== '"') return;
pos += 1;
const get = function get() {
const char = env.str.charAt(pos);
let val = null;
if (char === '"') return;
if (char === '\\') {
pos += 2;
return escape(env.str.substr(pos - 2, 2));
}
pos += 1;
return char;
};
let next;
while(env.str.charAt(pos) !== '"' && (next = get())) {
str += next;
}
if (env.str.charAt(pos) === '"') {
env.pos = pos + 1;
return str;
}
throw new Error('bad string');
};
const escape = function escape(str) {
console.log(str);
switch(str) {
case '\\\\': return '\\';
case '\\b': return '\b';
case '\\f': return '\f';
case '\\n': return '\n';
case '\\r': return '\r';
case '\\t': return '\t';
case '\\"': return '"';
default: return str;
}
};
const parseArray = function parseArray() {
const arr = [];
let pos = env.pos;
if (env.str.charAt(pos) !== '[') return;
env.pos += 1;
white();
let next;
while(env.str.charAt(env.pos) !== ']' && (next = doParse())) {
arr.push(next);
if (env.str.charAt(env.pos) === ',') env.pos += 1;
white();
}
if (env.str.charAt(env.pos) === ']') {
env.pos += 1;
return arr;
}
throw new Error('bad array');
};
const parseObject = function parseObject() {
const obj = {};
let pos = env.pos;
if (env.str.charAt(pos) !== '{') return;
env.pos += 1;
white();
let key;
while(env.str.charAt(env.pos) !== '}' && (key = parseString())) {
if (env.str.charAt(env.pos) !== ':') throw new Error('bad object');
env.pos += 1;
white();
const val = doParse();
if (!val) throw new Error('bad object');
obj[key] = val;
if (env.str.charAt(env.pos) === ',') env.pos += 1;
white();
}
if (env.str.charAt(env.pos) === '}') {
env.pos += 1;
return obj;
}
throw new Error('bad object');
};
const white = function white() {
let char = env.str.charAt(env.pos);
while(char === ' ' || char === '\n') {
env.pos += 1;
char = env.str.charAt(env.pos)
}
};
const parseAll = [
parseObject,
parseArray,
parseString,
parseNumber,
parseTrue,
parseFalse,
parseNull,
];
export default {
parse,
};