Zergling 是咱們團隊自研的埋點管理平臺,默認的數據格式以下:javascript
{
"page": "dsong|ufm",
"resource": "song", // 歌曲
"resourceid": 1, // 資源 id
"target": 111, // 不感興趣
"targetid": "button",
"reason": "",
"reason_type": "fixed"
}
複製代碼
一種自定義 json 格式,比較不一樣在於:前端
|
分割符,當作數組用在實際過程當中有一些不符合規範的地方:java
應該爲git
id: 1111, // 活動 url
複製代碼
/
作數組分割符,而不是 |
。 除了上述錯誤類型以外,還有其餘錯誤類型。因而決定寫一個自定義的 json parser 來規範輸入問題。總的分爲詞法分析和語法分析兩部分。github
詞法分析主要將源碼分割成不少小的子字符串變成一系列的 token.json
好比下面的賦值語句。數組
var language = "lox";
複製代碼
詞法分析後,輸出 5 個 token 以下 數據結構
因此詞法分析的關鍵就在於如何分割字符串。函數
咱們先定義 token 的數據結構 (Token.js)ui
class Token {
constructor (type,value){
this.type = type;
this.value = value;
}
}
複製代碼
再定義 Token 類型 (TokenType.js), 參考 token type
const TokenType = {
OpenBrace: "{", // 左括號
CloseBrace: "}", // 右括號
StringLiteral: "StringLiteral", // 字符串類型
BitOr: "|",
SingleSlash: "/",
COLON: ":",
QUOTE: '"',
NUMBER: "NUMBER",
COMMA: ",",
NIL: "NIL", // 結束的字符
EOF: "EOF", //end token
};
複製代碼
作好上面準備以後,就能夠着手處理字符了。
先定義一個類 Lexer (Lexer.js)
class Lexer {
constructor (input) {
this.input = input;// 輸入
this.pos = 0;// 指針
this.currentChar = this.input [this.pos];
this.tokens = []; // 返回的全部 token
}
}
複製代碼
詞法處理是一個個讀取字符串,而後分別組裝成一個 Token。咱們先從簡單的符號好比 {
,=
開始,若是碰到符號,咱們就直接返回對應的 token。對於空白,咱們就忽略。
// 獲取全部的 token;
lex () {
while (this.currentChar && this.currentChar != TokenType.NIL) {// 若是當前不是結束的字符
this.skipWhiteSpace ();
let token = "";
switch (this.currentChar) {
case "{":
this.consume ();
token = new Token (TokenType.OpenBrace, TokenType.OpenBrace);
break;
case "}":
this.consume ();
token = new Token (TokenType.CloseBrace, TokenType.CloseBrace);
break;
case ":":
this.consume ();
token = new Token (TokenType.COLON, TokenType.COLON);
break;
case ",":
this.consume ();
token = new Token (TokenType.COMMA, TokenType.COMMA);
break;
}
if (token) this.tokens.push (token);
}
this.tokens.push (new Token (TokenType.EOF, TokenType.EOF));
}
複製代碼
this.skipWhiteSpace
主要是處理空白,若是當前字符是空白符,咱們就移動指針 pos++
,去判斷下一個字符,直到不是空白符爲止。this.consume
這個函數就是用來移動指針.
skipWhiteSpace () {
while (!this.isEnd () && this.isSpace (this.currentChar)) {
this.consume ();
}
}
isSpace (char) {
const re = /\s/gi;
return re.test (char);
}
/** 獲取下一個字符 */
consume () {
if (!this.isEnd ()) {
this.pos++;
this.currentChar = this.input [this.pos];
} else {
this.currentChar = TokenType.NIL;
}
}
// 判斷是否讀完
isEnd () {
return this.pos > this.input.length - 1;
}
複製代碼
對於符號的處理直接返回 token 便可,對於字符串稍微麻煩一點。好比 "page"
這個咱們須要讀 4 個字符組合在一塊兒。所以,當咱們碰到 "
雙引號的時候,咱們就進入 getStringToken 函數來處理。
(Lexer.js->lex)
case '"':
token = this.getStringToken ();
break;
複製代碼
對於 getStringToken
。咱們這裏比較特別,通常的 string 沒有 |
這個分隔符,好比 "page"
。而咱們的例子裏面如 "dsong|ufm"
, 將返回 dsong
, |
, ufm
, 三個 token。
getStringToken (){
let buffer = "";
while (this.isLetter (this.currentChar) || this.currentChar == TokenType.BitOr)
{
if (this.currentChar == TokenType.BitOr) {
if (buffer)
this.tokens.push (new Token (TokenType.StringLiteral, buffer));
this.tokens.push (new Token (TokenType.BitOr, TokenType.BitOr));
buffer = "";
}
}
}
複製代碼
對於 comment 相似,當咱們碰到字符是 /
的時候,咱們就假設他是註釋 //xxx
。對於 comment 就自動忽略。
(Lexer.js->lex)
case "/":
token = this.getCommentToken ();
break;
複製代碼
getCommentToken () {
// 簡單處理兩個 /
this.match (TokenType.SingleSlash);
this.match (TokenType.SingleSlash);
while (!this.isNewLine (this.currentChar) && !this.isEnd ()) {
this.consume ();
}
return;
}
isNewLine (char) {
const re = /\r?\n/;
return re.test (char);
}
複製代碼
接下來處理數字,相似 string, 好比 111,三個字符,咱們當作一個數字。因此咱們規定當字符是數字的時候,咱們就進入處理 getNumberToken
來處理數字。
(Lexer.js->lex)
default:
if (this.isNumber (this.currentChar)) {
token = this.getNumberToken ();
} else {
throw new Error (`${this.currentChar} is not a valid type`);
}
複製代碼
接下來處理 getNumberToken
函數
getNumberToken () {
let buffer = "";
while (this.isNumber (this.currentChar)&&!this.isEnd ()) {
buffer += this.currentChar;
this.consume ();
}
if (buffer) {
return new Token (TokenType.NUMBER, buffer);
}
}
isNumber (char) {
const re = /\d/g;
return re.test (char);
}
複製代碼
至此,全部的咱們就得到了全部的 token。
詞法分析能夠解決用 value 當作註釋的問題,好比 {id:"活動 id"}
這種寫法,可是沒法處理 {id:"page || dsong"}
這種。由於按照咱們的邏詞法處理 "page || dsong"
會返回 page,|,|,dsong
4 個 string token。 語法分析主要是對邏輯的驗證。
咱們先找到 json 的語法定義。
grammar JSON;
json
: value
;
value
: STRING
| NUMBER
| obj
| 'true'
| 'false'
;
obj
: "{" pair (,pair)* "}"
;
pair
String: value
STRING
: '"' (ESC | SAFECODEPOINT)* '"'
;
NUMBER
: '-'? INT ('.' [0-9] +)? EXP?
;
複製代碼
因爲咱們須要支持 a|b|c
, 因此修改一下對 string 的處理
value
: STRING
複製代碼
改成
value
: STRING (|STRING)*
複製代碼
獲得上面的語法定義以後,就是考慮如何將其轉爲代碼。 grammar json 這行只是定義,能夠忽略。
json
: value
;
value
: STRING
| NUMBER
| obj
| 'true'
| 'false'
;
NUMBER
: '-'? INT ('.' [0-9] +)? EXP?
;
複製代碼
這裏 json 能夠推導出 value, value 又能夠推導出 Number 和 'true'。Number 又能夠推導出其它,而 'true' 這種是基本數據類型沒法再推導其餘了。
對於上面這種能夠推導出其餘的好比 json,value,Number 咱們就叫作非終止符 nonterminal。
'true' 這種就叫作終止符 terminal。
對於 Number 和 String 右邊,因爲只是字符的範圍限定,咱們也當作 terminal 來處理。
由於,將上面的語法定義轉爲具體代碼,規則以下:
nonterminal
,則對應轉成函數terminal
。 匹配當前的 token 類型是 terminal 類型,而後指針移到下一個|
。則對應if
或者 switch
*
或者 +
。while
或者 for
循環?
。則轉化爲 if
因此左邊的 value,Number,json
等都是函數,而右邊的好比 {
,true
都是先匹配當前 token 類型,而後獲取下一個 token。
咱們將 json 的語法轉爲以下。
先定義 Parser (Parser.js),輸入是一個詞法分析 lexer。
class Parser {
constructor (lexer) {
this.lexer = lexer;
this.currentToken = lexer.getNextToken ();
}
}
複製代碼
而後解析第一條規則,將 json:value
都轉爲函數。
(Paser.js)
/** json: value */
paseJSON () {
this.parseValue ();
}
複製代碼
接下來解析 value 的語法,因爲 |
是選擇語句,咱們將其轉爲 switch。根據當前 token 類型是對象仍是 number,string, 走到不一樣的分支。
(Parser.js->parseValue)
/** * value : STRING (|STRING)* | NUMBER | obj | 'true' | 'false' ; */
parseValue () {
switch (this.currentToken.type) {
case TokenType.OpenBrace:
this.parseObject ();
break;
case TokenType.StringLiteral:
this.parseString ();
break;
case TokenType.NUMBER:
this.parseNumber ();
break;
case TokenType.TRUE:
break;
case TokenType.FLASE:
break;
}
}
複製代碼
根據規則 2,terminal, 匹配當前的 token 類型,而後獲取下一個 token. 因此當碰到 true
和 value
的時候,switch 語句改成以下。
case TokenType.TRUE:
this.eat (TokenType.TRUE);
break;
case TokenType.FLASE:
this.eat (TokenType.FALSE);
break;
複製代碼
咱們定義一個 eat
函數,匹配當前 token 再獲取下一個,若是不符合直接拋出錯誤信息。
/**match the current token and get the next */
eat (tokenType) {
if (this.currentToken.type == tokenType) {
this.currentToken = this.lexer.getNextToken ();
} else {
throw new Error (
`this.currentToken is ${JSON.stringify ( this.currentToken )} doesn't match the input ${tokenType}`
);
}
}
複製代碼
接下來處理 parseObject
,它的語法是 "{" pair (,pair)* "}
。
{
是 terminal,直接 eat
. pair
變量,直接轉爲函數。
(,pair)*
。根據規則 4,*
轉爲 while
語句。
*
是正則符號表示零或者更多的狀況,因此當碰到這種狀況的時候,咱們先判斷是否匹配逗號,而後執行 parsePair
函數。
代碼以下
/**obj : "{" pair (,pair)* "}" ; */
parseObject () {
this.eat (TokenType.OpenBrace);
this.parsePair ()
while (this.currentToken.type == TokenType.COMMA) {
this.eat (TokenType.COMMA);
this.parsePair ()
}
this.eat (TokenType.CloseBrace);
}
複製代碼
解決了上面的語法轉換以後,接下來的代碼能夠根據上面的處理轉換。
/** String: value */
parsePair () {
this.eat (TokenType.StringLiteral);
this.eat (TokenType.COLON);
this.parseValue ();
}
//STRING (|STRING)*
parseString () {
this.eat (TokenType.StringLiteral);
while (this.currentToken.type == TokenType.BitOr) {
this.eat (TokenType.BitOr);
this.eat (TokenType.StringLiteral);
}
}
parseNumber () {
this.eat (TokenType.NUMBER);
}
複製代碼
至此,咱們的工做已經完成。
對於開頭提出的兩個問題。
第一個用 value
當作註釋,而不用 comment
。這個在詞法解析階段解決。判斷字符串用的是 /w/ 的正則。 而這個正則在碰到中文會拋出錯誤提示。
第二個用 /
作數組分割符,而不是 |
。 這個在語法解析階段解決。 當解析 value: STRING (|STRING)*
這條規則的時候,若是碰到的字符串後面碰到的不是 | 分隔符,則會報錯。
上面的兩個 test 已經覆蓋,完整代碼及 test case 請查看 github
本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!