用JS解析JSON

笨笨小撒發表於2018-07-22

大家好。今天我的任務是寫一些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,
};
複製程式碼

相關文章