用JS解析JSON

2,909 阅读10分钟

大家好。今天我的任务是写一些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

  ...
}

其中TrueFalseNull都是非常简单的,我们直接用字面常量就可以定义:

  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', {
  ...
};

TrueFalseNull最为直接:

  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));
  },

这样的话例如wholeNumberdecimal之类的节点类型都不会被触及到,因此也不用为它们定义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_1items一样是_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,
};