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