簡易表示式解析器編寫

frontdog發表於2018-09-14

前言

在做一個Node監控系統的時候要做了一個郵件報警的需求,這時候就需要自定義一套規則來書寫觸發報警的表示式,本文就介紹一下如何編寫一個簡易的表示式解析器。 附上介面截圖,圖中就是一個表示式的例子。

Alt pic

參考書籍:《程式語言實現模式》

確定一些基本規則

在開始編寫之前你需要確定你的表示式需要擁有些什麼能力。本文的表示式是判斷是否觸發報警的條件,表示式的規則不應該過於複雜。同時,由於書寫表示式的使用者是前端開發或者node開發,所以語法貼近js表示式語法最合適:

  • 支援變數,這裡規定變數以@開頭,由字母和數字組成,如: @load15
  • 支援常量:布林值、數字、字串
  • 支援加法(+)、減法(-)、乘法(*)、除法(/)、取餘(%)運算
  • 支援全等(===)、不等(!==)、大於(>)、小於(<)、大於等於(>=)、小於等於(<=)等比較運算
  • 支援與(&&)、或(||)、非(!)等邏輯運算
  • 擴充套件字串包含include運算
  • 支援括號()

構建抽象語法樹(AST)

構建抽象語法樹的目的是設計一種資料結構來說明一個表示式的含義。比如@var > 5,在程式碼中它作為一個字串,是沒有規定其是怎樣去進行運算的。

如果解析成如下抽象語法樹:

Alt pic

根節點為運算子,子節點為運算物件,這樣我們就用二叉樹抽象了一個運算。

當然,上面的例子是一個較為簡單的運算表示式,一旦表示式變得複雜,無法避免的是運算的優先順序,那麼二叉樹如何來表示運算的優先順序呢?

我們用兩個表示式來說明:

Alt pic

從圖中可以看出,第二個表示式加入括號後改變了原來的運算順序,對應的抽象語法樹也發生了變化,不難看出:運算的優先順序越高,在語法樹中的位置越低

AST的資料結構舉個例子來看,如以下表示式

@load > 1 + 5
複製程式碼

解析成的token為:

{
  "type": "LOGICAL_EXP",
  "operator": ">",
  "left": {
    "type": "VARIABLE",
    "value": "load",
    "raw": "@load"
  },
  "right": {
    "type": "BINARY_EXP",
    "operator": "+",
    "left": {
      "type": "NUMBER",
      "value": 1,
      "raw": "1"
    },
    "right": {
      "type": "NUMBER",
      "value": 5,
      "raw": "5"
    }
  }
}
複製程式碼

確定了抽象語法樹的結構,我們就可以開始考慮怎麼去解析表示式字串了。

使用LL(1)遞迴下降詞法解析器

詞法單元(tokenizing)是指字元的語法,類似我們用字母以一定的語法拼成一個單詞一樣。

LL(1)遞迴下降詞法解析器是向前看一個詞法單元的自頂向下的解析器,LL中的兩個L都是"left-to-right",第一個L表示解析器按"從做到右"的順序解析輸入內容,第二個L表示下降解析時也時從左到右遍歷子節點。

這個是最簡單的解析器,能夠滿足需求的情況下,使用這個模式更為通俗易懂。

詞法單元羅列

既然要向前看一個詞法單元,那麼我們首先應該羅列出這個表示式可能擁有的詞法單元,本文的表示式可能擁有如下詞法單元:

  • 變數詞法單元,標誌:以"@"開頭
  • 數字詞法單元,標誌:以0-9開頭或者"."開頭
  • 字串詞法單元,標誌:以單引號或者雙引號開頭
  • 括號詞法單元,標誌:以左括號為開頭
  • 一元運算子詞法單元,標誌:以"!"、"+"、"-"開頭

下面我們就可以正式開始寫程式碼了。

尋找遞迴點

一個表示式可以分解成多個沒有括號的表示式,如以下表示式:

5 * (3 + 2 * (5 + 6))
複製程式碼

我們可以把它分解成以下表示式:

5 * expression_a
3 + 2 * expression_b // expression_a 
5 + 6 // expression_b
複製程式碼

整個字串表示的就是一個表示式,這就是我們要找的遞迴點,定義一個函式eatExpression去解析一個表示式,遇到括號就遞迴eatExpression,直到無法匹配任何詞法單元。

下面開始擼程式碼。

class ExpressionParser

class ExpressionParser {
  constructor(expr) {
    if (typeof expr !== 'string') {
      throw new Error(`[expression-parser] constructor need a string parameter, but get [${typeof expr}]`);
    }
    this.expr = expr;
    this.parse();
  }

  parse() {
    this.index = 0;
    this.tokens = this.eatExpression();
    if (this.index < this.expr.length) {
      this.throwError(`非法字元"${this.charAt()}"`);
    }
  }
}

const expression
const parser = new ExpressionParser(expression)
複製程式碼

這是解析器類,初始工作就是儲存expression,然後使用eatExpression去解析,當我們開始解析的時候,我們是一位一位字元地檢視,我們形象地用"eat"來表示遍歷這個動作,因此要有幾個輔助引數和輔助函式:

this.index // 標記當前遍歷的字元的下標

// 獲取當前遍歷字元
charAt(index = this.index) {
  return this.expr.charAt(index);
}

// 獲取當前字元的 Unicode 編碼
charCodeAt(index = this.index) {
  return this.expr.charCodeAt(index);
}
複製程式碼

ExpressionParser.prototype.eatExpression

這個函式是整個解析器的入口,這個函式的思路很重要。一個表示式可以分為兩種:

  • 有二元運算子的表示式
  • 沒有二元運算子的表示式

沒有二元運算子的表示式很簡單,只要遍歷分解成上述詞法單元的集合即可,而如果有二元運算子且大於1個的時候,這時候就比較複雜了,因為我們解析的順序是從左到右,而運算子的順序是不確定的。

那麼如何解決呢?下面用一個例子的處理流程解釋核心的優先順序計算思想:

Alt pic

主要的思想就是利用一個堆疊,把解析的token存進堆疊,當解析到運算子(一元運算子解析成token,這裡指二元運算子)的時候,對比棧中最靠近頂部的運算子,如果發現新解析的運算子優先順序更高,直接推進棧內。所以,在棧中,運算子的優先順序保證是從低到高的,一旦新解析的運算子優先順序更低,說明棧內的token可以合成一個node直到棧內的運算子優先順序全部都是從低到高。最後,再從右向左依次合成node,得到一個完整的表示式二叉樹。

最核心的思想就是保證棧內優先順序從低到高,下面貼程式碼讓大家再鞏固理解:

eatExpression() {
    let left = this.eatToken();
    let operator = this.eatBinaryOperator();
    // 說明這個運算樹只有左側
    if (!operator) {
        return left;
    }
    let operatorInfo = {
        precedence: this.getOperatorPrecedence(operator), // 獲取運算子優先順序
        value: operator,
    };
    let right = this.eatToken();
    if (!right) {
        this.throwError(`"${operator}"運算子後應該為表示式`);
    }
    const stack = [left, operatorInfo, right];
    // 獲取下一個運算子
    while (operator = this.eatBinaryOperator()) {
        const precedence = this.getOperatorPrecedence(operator);
        // 如果遇到了非法的yuan fa
        if (precedence === 0) {
            break;
        }
        operatorInfo = {
            precedence,
            value: operator,
        };
        while (stack.length > 2 && precedence < stack[stack.length - 2].precedence) {
            right = stack.pop();
            operator = stack.pop().value;
            left = stack.pop();
            const node = this.createNode(operator, left, right);
            stack.push(node);
        }
        const node = this.eatToken();
        if (!node) {
            this.throwError(`"${operator}"運算子後應該為表示式`);
        }
        stack.push(operatorInfo, node);
    }
    let i = stack.length - 1;
    let node = stack[i];
    while (i > 1) {
        node = this.createNode(stack[i - 1].value, stack[i - 2], node);
        i -= 2;
    }
    return node;
}
複製程式碼

createNode:

const LOGICAL_OPERATORS = ['||', '&&', '===', '!==', '>', '<', '>=', '<=', 'include'];

...

createNode(operator, left, right) {
    const type = LOGICAL_OPERATORS.indexOf(operator) !== -1 ? 'LOGICAL_EXP' : 'BINARY_EXP';
    return {
        type,
        operator,
        left,
        right,
    };
}
複製程式碼

getOperatorPrecedence:

const BINARY_OPERATORS = {
    '||': 1, 
    '&&': 2,
    '===': 6, '!==': 6,
    '<': 7, '>': 7, '<=': 7, '>=': 7,
    '+': 9, '-': 9,
    '*': 10, '/': 10, '%': 10,
    include: 11,
};

...

getOperatorPrecedence(operator) {
    return BINARY_OPERATORS[operator] || 0;
}
複製程式碼

相信現在你對於整體的思路已經很清晰了,接下來還有一個比較重要的需要講一下,這就是token的解析

Token解析

token解析的過程,可以想象成有個下標一個字元一個字元地從左到右移動,遇到可以辨識的token開頭標誌,就解析token,然後繼續移動下標直到整個字串結束或者無法遇到可辨識的標誌。

Alt pic

具體每種型別的token如何去匹配,可以檢視完整的程式碼。

計算結果值

token解析好後,我們是需要計算表示式值的,由於變數的存在,所以我們需要提供一個context來供變數獲取值,形如:

const expr = new ExpressionParser('@load > 5');
console.log(expr.valueOf({ load: 8 })); // true
複製程式碼

因為我們已經把表示式生成一個二叉樹了,所以只要遞迴遍歷計算每個二叉樹的值即可,由於是遞迴計算,越底下的樹越早計算,與我們開始設計優先順序的思路一致。

完整程式碼:

const OPEN_PAREN_CODE = 40; // (
const CLOSE_PAREN_CODE = 41; // )
const VARIABLE_BEGIN_CODE = 64; // @,變數開頭
const PERIOD_CODE = 46; // '.'
const SINGLE_QUOTE_CODE = 39; // single quote
const DOUBLE_QUOTE_CODE = 34; // double quotes
const SPACE_CODES = [32, 9, 10, 13]; // space
// 一元運算子
const UNARY_OPERATORS = { '-': true, '!': true, '+': true };
// 二元運算子
const LOGICAL_OPERATORS = ['||', '&&', '===', '!==', '>', '<', '>=', '<=', 'include'];
const BINARY_OPERATORS = {
  '||': 1,
  '&&': 2,
  '===': 6, '!==': 6,
  '<': 7, '>': 7, '<=': 7, '>=': 7,
  '+': 9, '-': 9,
  '*': 10, '/': 10, '%': 10,
  include: 11,
};

// 獲取物件鍵的最大長度
const getMaxKeyLen = function getMaxKeyLen(obj) {
  let max = 0;
  Object.keys(obj).forEach((key) => {
    if (key.length > max && obj.hasOwnProperty(key)) {
      max = key.length;
    }
  });
  return max;
};
const maxBinaryOperatorLength = getMaxKeyLen(BINARY_OPERATORS);
const maxUnaryOperatorLength = getMaxKeyLen(UNARY_OPERATORS);

class ExpressionParser {
  constructor(expr) {
    if (typeof expr !== 'string') {
      throw new Error(`[expression-parser] constructor need a string parameter, but get [${typeof expr}]`);
    }
    this.expr = expr;
  }

  parse() {
    this.index = 0;
    try {
      this.tokens = this.eatExpression();
      if (this.index < this.expr.length) {
        throw new Error(`非法字元"${this.charAt()}"`);
      }
    } catch (error) {
      this.tokens = undefined;
      if (typeof this.onErrorCallback === 'function') {
        this.onErrorCallback(error.message, this.index, this.charAt());
      } else {
        throw new Error(error.message);
      }
    }
    return this;
  }

  eatExpression() {
    let left = this.eatToken();
    let operator = this.eatBinaryOperator();
    // 說明這個運算樹只有左側
    if (!operator) {
      return left;
    }
    let operatorInfo = {
      precedence: this.getOperatorPrecedence(operator), // 獲取運算子優先順序
      value: operator,
    };
    let right = this.eatToken();
    if (!right) {
      throw new Error(`"${operator}"運算子後應該為表示式`);
    }
    const stack = [left, operatorInfo, right];
    // 獲取下一個運算子
    while (operator = this.eatBinaryOperator()) {
      const precedence = this.getOperatorPrecedence(operator);
      // 如果遇到了非法的yuan fa
      if (precedence === 0) {
        break;
      }
      operatorInfo = {
        precedence,
        value: operator,
      };
      while (stack.length > 2 && precedence < stack[stack.length - 2].precedence) {
        right = stack.pop();
        operator = stack.pop().value;
        left = stack.pop();
        const node = this.createNode(operator, left, right);
        stack.push(node);
      }
      const node = this.eatToken();
      if (!node) {
        throw new Error(`"${operator}"運算子後應該為表示式`);
      }
      stack.push(operatorInfo, node);
    }
    let i = stack.length - 1;
    let node = stack[i];
    while (i > 1) {
      node = this.createNode(stack[i - 1].value, stack[i - 2], node);
      i -= 2;
    }
    return node;
  }

  eatToken() {
    this.eatSpaces();
    const ch = this.charCodeAt();
    if (ch === VARIABLE_BEGIN_CODE) {
      // 變數
      return this.eatVariable();
    } else if (this.isDigit(ch) || ch === PERIOD_CODE) {
      // 數字
      return this.eatNumber();
    } else if (ch === SINGLE_QUOTE_CODE || ch === DOUBLE_QUOTE_CODE) {
      // 字串
      return this.eatString();
    } else if (ch === OPEN_PAREN_CODE) {
      // 括號
      return this.eatGroup();
    } else {
      // 檢查單操作符 !+ -
      let toCheck = this.expr.substr(this.index, maxUnaryOperatorLength);
      let toCheckLength;
      while (toCheckLength = toCheck.length) {
        if (
          UNARY_OPERATORS.hasOwnProperty(toCheck) &&
          this.index + toCheckLength <= this.expr.length
        ) {
          this.index += toCheckLength;
          return {
            type: 'UNARY_EXP',
            operator: toCheck,
            argument: this.eatToken(),
          };
        }
        toCheck = toCheck.substr(0, --toCheckLength);
      }
    }
  }

  eatGroup() {
    this.index++; // eat "("
    const node = this.eatExpression();
    this.eatSpaces();
    const ch = this.charCodeAt();
    if (ch !== CLOSE_PAREN_CODE) {
      throw new Error('括號沒有閉合');
    } else {
      this.index++; // eat ")"
      return node;
    }
  }

  eatVariable() {
    const ch = this.charAt();
    this.index++; // eat "@"
    const start = this.index;
    while (this.index < this.expr.length) {
      const ch = this.charCodeAt();
      if (this.isVariablePart(ch)) {
        this.index++;
      } else {
        break;
      }
    }
    const identifier = this.expr.slice(start, this.index);
    return {
      type: 'VARIABLE',
      value: identifier,
      raw: ch + identifier,
    };
  }

  eatNumber() {
    let number = '';
    // 數字開頭
    while (this.isDigit(this.charCodeAt())) {
      number += this.charAt(this.index++);
    }
    // '.'開頭
    if (this.charCodeAt() === PERIOD_CODE) {
      number += this.charAt(this.index++);
      while (this.isDigit(this.charCodeAt())) {
        number += this.charAt(this.index++);
      }
    }
    // 科學計數法
    let ch = this.charAt();
    if (ch === 'e' || ch === 'E') {
      number += this.charAt(this.index++);
      ch = this.charAt();
      if (ch === '+' || ch === '-') {
        number += this.charAt(this.index++);
      }
      while (this.isDigit(this.charCodeAt())) {
        number += this.charAt(this.index++);
      }
      // 如果e + - 後無數字,報錯
      if (!this.isDigit(this.charCodeAt(this.index - 1))) {
        throw new Error(`非法數字(${number}${this.charAt()}),缺少指數`);
      }
    }

    return {
      type: 'NUMBER',
      value: parseFloat(number),
      raw: number,
    };
  }

  eatString() {
    let str = '';
    const quote = this.charAt(this.index++);
    let closed = false;
    while (this.index < this.expr.length) {
      let ch = this.charAt(this.index++);
      if (ch === quote) {
        closed = true;
        break;
      } else if (ch === '\\') {
        // Check for all of the common escape codes
        ch = this.charAt(this.index++);
        switch (ch) {
          case 'n':
            str += '\n';
            break;
          case 'r':
            str += '\r';
            break;
          case 't':
            str += '\t';
            break;
          case 'b':
            str += '\b';
            break;
          case 'f':
            str += '\f';
            break;
          case 'v':
            str += '\x0B';
            break;
          default:
            str += ch;
        }
      } else {
        str += ch;
      }
    }

    if (!closed) {
      throw new Error(`字元"${str}"缺少閉合括號`);
    }

    return {
      type: 'STRING',
      value: str,
      raw: quote + str + quote,
    };
  }

  eatBinaryOperator() {
    this.eatSpaces();
    let toCheck = this.expr.substr(this.index, maxBinaryOperatorLength);
    let toCheckLength = toCheck.length;
    while (toCheckLength) {
      if (
        BINARY_OPERATORS.hasOwnProperty(toCheck) &&
        this.index + toCheckLength <= this.expr.length
      ) {
        this.index += toCheckLength;
        return toCheck;
      }
      toCheck = toCheck.substr(0, --toCheckLength);
    }
    return false;
  }

  getOperatorPrecedence(operator) {
    return BINARY_OPERATORS[operator] || 0;
  }

  createNode(operator, left, right) {
    const type = LOGICAL_OPERATORS.indexOf(operator) !== -1
      ? 'LOGICAL_EXP'
      : 'BINARY_EXP';
    return {
      type,
      operator,
      left,
      right,
    };
  }

  isVariablePart(ch) {
    return (ch >= 65 && ch <= 90) || // A...Z
      (ch >= 97 && ch <= 122) || // a...z
      (ch >= 48 && ch <= 57); // 0...9
  }

  isDigit(ch) {
    return ch >= 48 && ch <= 57; // 0...9
  }

  eatSpaces() {
    let ch = this.charCodeAt();
    while (SPACE_CODES.indexOf(ch) !== -1) {
      ch = this.charCodeAt(++this.index);
    }
  }

  onError(callback) {
    this.onErrorCallback = callback;
    return this;
  }

  charAt(index = this.index) {
    return this.expr.charAt(index);
  }

  charCodeAt(index = this.index) {
    return this.expr.charCodeAt(index);
  }

  valueOf(scope = {}) {
    if (this.tokens == null) {
      return undefined;
    }
    const value = this.getNodeValue(this.tokens, scope);
    return !!value;
  }

  getNodeValue(node, scope = {}) {
    const { type, value, left, right, operator } = node;
    if (type === 'VARIABLE') {
      return scope[value];
    } else if (type === 'NUMBER' || type === 'STRING') {
      return value;
    } else if (type === 'LOGICAL_EXP') {
      const leftValue = this.getNodeValue(left, scope);
      // 如果是邏輯運算的&&和||,那麼可能不需要解析右邊的值
      if (operator === '&&' && !leftValue) {
        return false;
      }
      if (operator === '||' && !!leftValue) {
        return true;
      }
      const rightValue = this.getNodeValue(right, scope);
      switch (node.operator) {
        case '&&': return leftValue && rightValue;
        case '||': return leftValue || rightValue;
        case '>': return leftValue > rightValue;
        case '>=': return leftValue >= rightValue;
        case '<': return leftValue < rightValue;
        case '<=': return leftValue <= rightValue;
        case '===': return leftValue === rightValue;
        case '!==': return leftValue !== rightValue;
        case 'include': return leftValue.toString &&
          rightValue.toString &&
          leftValue.toString().indexOf(rightValue.toString()) !== -1;
        // skip default case
      }
    } else if (type === 'BINARY_EXP') {
      const leftValue = this.getNodeValue(left, scope);
      const rightValue = this.getNodeValue(right, scope);
      switch (node.operator) {
        case '+': return leftValue + rightValue;
        case '-': return leftValue - rightValue;
        case '*': return leftValue * rightValue;
        case '/': return leftValue - rightValue;
        case '%': return leftValue % rightValue;
        // skip default case
      }
    } else if (type === 'UNARY_EXP') {
      switch (node.operator) {
        case '!': return !this.getNodeValue(node.argument, scope);
        case '+': return +this.getNodeValue(node.argument, scope);
        case '-': return -this.getNodeValue(node.argument, scope);
        // skip default case
      }
    }
  }
}

const expression = new ExpressionParser('@load + 3');
expression.onError((message, index, ch) => {
  console.log(message, index, ch);
}).parse();
console.log(expression.valueOf({ load: 5 }));
複製程式碼

相關文章