從零實現自定義 JSON Parser

簡介

Zergling 是咱們團隊自研的埋點管理平臺,默認的數據格式以下:javascript

{
    "page": "dsong|ufm", 
    "resource": "song", // 歌曲
    "resourceid": 1, // 資源 id
    "target": 111, // 不感興趣
    "targetid": "button", 
    "reason": "", 
    "reason_type": "fixed" 
}
複製代碼

一種自定義 json 格式,比較不一樣在於:前端

  1. 帶註釋
  2. 字符串經過 | 分割符,當作數組用
  3. value 爲基本類型,沒有 object。

在實際過程當中有一些不符合規範的地方:java

  1. 用 value 當作註釋,而不用 comment

應該爲git

id: 1111, // 活動 url
複製代碼
  1. / 作數組分割符,而不是 |

除了上述錯誤類型以外,還有其餘錯誤類型。因而決定寫一個自定義的 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 來處理。

由於,將上面的語法定義轉爲具體代碼,規則以下:

  1. 若是是 nonterminal,則對應轉成函數
  2. terminal。 匹配當前的 token 類型是 terminal 類型,而後指針移到下一個
  3. 若是是|。則對應if 或者 switch
  4. 若是是 * 或者 +while 或者 for 循環
  5. 若是是問號。則轉化爲 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. 因此當碰到 truevalue 的時候,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

本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們

相關文章
相關標籤/搜索