簡單語法解析器實現參考

等你歸去來發表於2020-10-06

  有時候,我們為了遮蔽一些底層的差異,我們會要求上游系統按照某種約定進行傳參。而在我們自己的系統層則會按照具體的底層協議進行適配,這是通用的做法。但當我們要求上游系統傳入的引數非常複雜時,也許我們會有一套自己的語法定義,用以減輕所有引數的不停變化。比如sql協議,就是一個一級棒的語法,同樣是呼叫底層功能,但它可以很方便地讓使用者傳入任意的引數。

  如果我們自己能夠實現一套類似的東西,想來應該蠻有意思的。

  不過,我們完全沒有必要要實現一整套完整的東西,我們只是要體驗下這個語法解析的過程,那就好辦了。本文就來給個簡單的解析示例,供看官們參考。

 

1. 實現目標描述

  目標:

    基於我們自定義的一套語法,我們要實現一套類似於sql解析的工具,它可以幫助我們檢查語法錯誤、應對底層不同的查詢引擎,比如可能是 ES, HIVE, SPARK, PRESTO...  即我們可能將使用者傳入的語法轉換為任意種目標語言的語法,這是我們的核心任務。

  前提:

    為簡單化起見,我們並不想實現一整套的東西,我們僅處理where條件後面的東西。
  定義:
    $1234: 定義為欄位資訊, 我們可以通過該欄位查詢出一些更多的資訊;
    and/or/like...: 大部分時候我們都遵循sql語法, 含義一致;
    #{xxx}: 系統關鍵字定義格式, xxx 為具體的關鍵字;
    arr['f1']: 為陣列格式的欄位;

  示例:

    $15573 = 123 and (my_udf($123568, $82949) = 1) or $39741 = #{day+1} and $35289 like '%ccc'
    將會被翻譯成ES:(更多資訊的欄位替換請忽略)
    $15573 = 123 and ( $123568 = 1 ) or $39741 = '2020-10-07' and $35289 like '%ccc'

  實際上整個看下來,和一道普通的演算法題差不太多呢。但實際想要完整實現這麼個小東西,也是要費不少精力的。

 

2. 整體實現思路

  我們要做一個解析器,或者說翻譯器,首先第一步,自然是要從根本上理解原語義,然後再根據目標語言的表達方式,轉換過去就可以了。

  如果大家有看過一些編譯原理方面的書,就應該知道,整個編譯流程大概分為: 詞法分析;語法分析;語義分析;中間程式碼生成;程式碼優化;目的碼; 這麼幾個過程,而每個過程往往又是非常複雜的,而最複雜的往往又是其上下文關係。不過,我們不想搞得那麼複雜(也搞不了)。

  雖然我們不像做一個編譯器一樣複雜,但我們仍然可以參考其流程,可以為我們提供比較好的思路。

  我們就主要做3步就可以了:1. 分詞;2. 語義分析; 3. 目的碼生成;而且為了進一步簡化工作,我們省去了複雜的上下文依賴分析,我們假設所有的語義都可以從第一個關鍵詞中獲得,比如遇到一個函式,我就知道接下來會有幾個引數出現。而且我們不處理巢狀關係。

  所以,我們的工作就變得簡單起來。

 

3. 具體程式碼實現

  我們做這個解析器的目的,是為了讓呼叫者方便,它僅僅作為一個工具類存在,所以,我們需要將入口做得非常簡單。

  這裡主要為分為兩個入口:1. 傳入原始語法,返回解析出的語法樹; 2. 呼叫語法樹的translateTo 方法,將原始語法轉換為目標語法;

具體如下:

    
import com.my.mvc.app.common.helper.parser.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.util.*;

/**
 * 功能描述: 簡單語法解析器實現示例
 *
 */
@Slf4j
public class SimpleSyntaxParser {

    /**
     * 嚴格模式解析語法
     *
     * @see #parse(String, boolean)
     */
    public static ParsedClauseAst parse(String rawClause) {
        return parse(rawClause, true);
    }

    /**
     * 解析傳入詞為db可識別的語法
     *
     * @param rawClause 原始語法, 如:
     *                  $15573 = 123 and (my_udf($123568, $82949) = 1) or $39741 like '%abc%' (my_udf($35289)) = -1
     * @param strictMode 是否是嚴格模式, true:是, false:否
     * @return 解析後的結構
     */
    public static ParsedClauseAst parse(String rawClause, boolean strictMode) {
        log.info("開始解析: " + rawClause);
        List<TokenDescriptor> tokens = tokenize(rawClause, strictMode);
        Map<String, Object> idList = enhanceTokenType(tokens);
        return buildAst(tokens, idList);
    }

    /**
     * 構建抽象語法樹物件
     *
     * @param tokens 分詞解析出的tokens
     * @param idList id資訊(解析資料來源參照)
     * @return 構建好的語法樹
     */
    private static ParsedClauseAst buildAst(List<TokenDescriptor> tokens,
                                            Map<String, Object> idList) {
        List<SyntaxStatement> treesFlat = new ArrayList<>(tokens.size());
        Iterator<TokenDescriptor> tokenItr = tokens.iterator();
        while (tokenItr.hasNext()) {
            TokenDescriptor token = tokenItr.next();
            String word = token.getRawWord();
            TokenTypeEnum tokenType = token.getTokenType();
            SyntaxStatement branch;
            switch (tokenType) {
                case FUNCTION_SYS_CUSTOM:
                    String funcName = word.substring(0, word.indexOf('(')).trim();
                    SyntaxStatementHandlerFactory handlerFactory
                            = SyntaxSymbolTable.getUdfHandlerFactory(funcName);
                    branch = handlerFactory.newHandler(token, tokenItr, tokenType);
                    treesFlat.add(branch);
                    break;
                case KEYWORD_SYS_CUSTOM:
                    branch = SyntaxSymbolTable.getSysKeywordHandlerFactory()
                                .newHandler(token, tokenItr, tokenType);
                    treesFlat.add(branch);
                    break;
                case KEYWORD_SQL:
                    branch = SyntaxSymbolTable.getSqlKeywordHandlerFactory()
                                .newHandler(token, tokenItr, tokenType);
                    treesFlat.add(branch);
                    break;
                case WORD_NORMAL:
                case WORD_NUMBER:
                case WORD_STRING:
                case CLAUSE_SEPARATOR:
                case SIMPLE_MATH_OPERATOR:
                case WORD_ARRAY:
                case COMPARE_OPERATOR:
                case FUNCTION_NORMAL:
                case ID:
                case FUNCTION_SQL:
                default:
                    // 未解析的情況,直接使用原始值解析器處理
                    branch = SyntaxSymbolTable.getCommonHandlerFactory()
                                .newHandler(token, tokenItr, tokenType);
                    treesFlat.add(branch);
                    break;
            }
        }
        return new ParsedClauseAst(idList, treesFlat);
    }

    /**
     * 語義增強處理
     *
     *      加強token型別描述,並返回 id 資訊
     */
    private static Map<String, Object> enhanceTokenType(List<TokenDescriptor> tokens) {
        Map<String, Object> idList = new HashMap<>();
        for (TokenDescriptor token : tokens) {
            String word = token.getRawWord();
            TokenTypeEnum newTokenType = token.getTokenType();
            switch (token.getTokenType()) {
                case WORD_NORMAL:
                    if(word.startsWith("$")) {
                        newTokenType = TokenTypeEnum.ID;
                        idList.put(word, word.substring(1));
                    }
                    else if(StringUtils.isNumeric(word)) {
                        newTokenType = TokenTypeEnum.WORD_NUMBER;
                    }
                    else {
                        newTokenType = SyntaxSymbolTable.keywordTypeOf(word);
                    }
                    token.changeTokenType(newTokenType);
                    break;
                case WORD_STRING:
                    // 被引號包圍的關鍵字,如 '%#{monthpart}%'
                    String innerSysCustomKeyword = readSplitWord(
                            word.toCharArray(), 1, "#{", "}");
                    if(innerSysCustomKeyword.length() > 3) {
                        newTokenType = TokenTypeEnum.KEYWORD_SYS_CUSTOM;
                    }
                    token.changeTokenType(newTokenType);
                    break;
                case FUNCTION_NORMAL:
                    newTokenType = SyntaxSymbolTable.functionTypeOf(word);
                    token.changeTokenType(newTokenType);
                    break;
            }
        }
        return idList;
    }

    /**
     * 查詢語句分詞操作
     *
     *      拆分為單個細粒度的詞如:
     *          單詞
     *          分隔符
     *          運算子
     *          陣列
     *          函式
     *
     * @param rawClause 原始查詢語句
     * @param strictMode 是否是嚴格模式, true:是, false:否
     * @return token化的單詞
     */
    private static List<TokenDescriptor> tokenize(String rawClause, boolean strictMode) {
        char[] clauseItr = rawClause.toCharArray();
        List<TokenDescriptor> parsedTokenList = new ArrayList<>();
        Stack<ColumnNumDescriptor> specialSeparatorStack = new Stack<>();
        int clauseLength = clauseItr.length;
        StringBuilder field;
        String fieldGot;
        char nextChar;

        outer:
        for (int i = 0; i < clauseLength; ) {
            char currentChar = clauseItr[i];
            switch (currentChar) {
                case '\'':
                case '\"':
                    fieldGot = readSplitWord(clauseItr, i,
                            currentChar, currentChar);
                    i += fieldGot.length();
                    parsedTokenList.add(
                            new TokenDescriptor(fieldGot, TokenTypeEnum.WORD_STRING));
                    continue outer;
                case '[':
                case ']':
                case '(':
                case ')':
                case '{':
                case '}':
                    if(specialSeparatorStack.empty()) {
                        specialSeparatorStack.push(
                                ColumnNumDescriptor.newData(i, currentChar));
                        parsedTokenList.add(
                                new TokenDescriptor(currentChar,
                                        TokenTypeEnum.CLAUSE_SEPARATOR));
                        break;
                    }
                    parsedTokenList.add(
                            new TokenDescriptor(currentChar,
                                    TokenTypeEnum.CLAUSE_SEPARATOR));
                    char topSpecial = specialSeparatorStack.peek().getKeyword().charAt(0);
                    if(topSpecial == '(' && currentChar == ')'
                            || topSpecial == '[' && currentChar == ']'
                            || topSpecial == '{' && currentChar == '}') {
                        specialSeparatorStack.pop();
                        break;
                    }
                    specialSeparatorStack.push(
                            ColumnNumDescriptor.newData(i, currentChar));
                    break;
                case ' ':
                    // 空格忽略
                    break;
                case '@':
                    nextChar = clauseItr[i + 1];
                    // @{} 擴充套件id, 暫不解析, 原樣返回
                    if(nextChar == '{') {
                        fieldGot = readSplitWord(clauseItr, i,
                                "@{", "}@");
                        i += fieldGot.length();
                        parsedTokenList.add(
                                new TokenDescriptor(fieldGot,
                                        TokenTypeEnum.ID));
                        continue outer;
                    }
                    break;
                case '#':
                    nextChar = clauseItr[i + 1];
                    // #{} 系統關鍵字標識
                    if(nextChar == '{') {
                        fieldGot = readSplitWord(clauseItr, i,
                                "#{", "}");
                        i += fieldGot.length();
                        parsedTokenList.add(
                                new TokenDescriptor(fieldGot,
                                        TokenTypeEnum.KEYWORD_SYS_CUSTOM));
                        continue outer;
                    }
                    break;
                case '+':
                case '-':
                case '*':
                case '/':
                    nextChar = clauseItr[i + 1];
                    if(currentChar == '-'
                            && nextChar >= '0' && nextChar <= '9') {
                        StringBuilder numberBuff = new StringBuilder(currentChar + "" + nextChar);
                        ++i;
                        while ((i + 1) < clauseLength){
                            nextChar = clauseItr[i + 1];
                            if(nextChar >= '0' && nextChar <= '9'
                                    || nextChar == '.') {
                                ++i;
                                numberBuff.append(nextChar);
                                continue;
                            }
                            break;
                        }
                        parsedTokenList.add(
                                new TokenDescriptor(numberBuff.toString(),
                                        TokenTypeEnum.WORD_NUMBER));
                        break;
                    }
                    parsedTokenList.add(
                            new TokenDescriptor(currentChar,
                                    TokenTypeEnum.SIMPLE_MATH_OPERATOR));
                    break;
                case '=':
                case '>':
                case '<':
                case '!':
                    // >=, <=, !=, <>
                    nextChar = clauseItr[i + 1];
                    if(nextChar == '='
                            || currentChar == '<' && nextChar == '>') {
                        ++i;
                        parsedTokenList.add(
                                new TokenDescriptor(currentChar + "" + nextChar,
                                        TokenTypeEnum.COMPARE_OPERATOR));
                        break;
                    }
                    parsedTokenList.add(
                            new TokenDescriptor(currentChar,
                                    TokenTypeEnum.COMPARE_OPERATOR));
                    break;
                default:
                    field = new StringBuilder();
                    TokenTypeEnum tokenType = TokenTypeEnum.WORD_NORMAL;
                    do {
                        currentChar = clauseItr[i];
                        field.append(currentChar);
                        if(i + 1 < clauseLength) {
                            // 去除函式前置名後置空格
                            if(SyntaxSymbolTable.isUdfPrefix(field.toString())) {
                                do {
                                    if(clauseItr[i + 1] != ' ') {
                                        break;
                                    }
                                    ++i;
                                } while (i + 1 < clauseLength);
                            }
                            nextChar = clauseItr[i + 1];
                            if(nextChar == '(') {
                                fieldGot = readSplitWord(clauseItr, i + 1,
                                        nextChar, ')');
                                field.append(fieldGot);
                                tokenType = TokenTypeEnum.FUNCTION_NORMAL;
                                i += fieldGot.length();
                                break;
                            }
                            if(nextChar == '[') {
                                fieldGot = readSplitWord(clauseItr, i + 1,
                                        nextChar, ']');
                                field.append(fieldGot);
                                tokenType = TokenTypeEnum.WORD_ARRAY;
                                i += fieldGot.length();
                                break;
                            }
                            if(isSpecialChar(nextChar)) {
                                // 嚴格模式下,要求 -+ 符號前後必須帶空格, 即會將所有字母后緊連的 -+ 視為字元連線號
                                // 非嚴格模式下, 即只要是分隔符即停止字元解析(非標準分隔)
                                if(!strictMode
                                        || nextChar != '-' && nextChar != '+') {
                                    break;
                                }
                            }
                            ++i;
                        }
                    } while (i + 1 < clauseLength);
                    parsedTokenList.add(
                            new TokenDescriptor(field.toString(), tokenType));
                    break;
            }
            // 正常單字解析迭代
            i++;
        }
        if(!specialSeparatorStack.empty()) {
            ColumnNumDescriptor lineNumTableTop = specialSeparatorStack.peek();
            throw new RuntimeException("檢測到未閉合的符號, near '"
                        + lineNumTableTop.getKeyword()+ "' at column "
                        + lineNumTableTop.getColumnNum());
        }
        return parsedTokenList;
    }

    /**
     * 從源陣列中讀取某類詞資料
     *
     * @param src 資料來源
     * @param offset 要搜尋的起始位置 offset
     * @param openChar word 的開始字元,用於避免迴圈巢狀 如: '('
     * @param closeChar word 的閉合字元 如: ')'
     * @return 解析出的字元
     * @throws RuntimeException 解析不到正確的單詞時丟擲
     */
    private static String readSplitWord(char[] src, int offset,
                                        char openChar, char closeChar)
            throws RuntimeException {
        StringBuilder builder = new StringBuilder();
        for (int i = offset; i < src.length; i++) {
            if(openChar == src[i]) {
                int aroundOpenCharNum = -1;
                do {
                    builder.append(src[i]);
                    // 注意 openChar 可以 等於 closeChar
                    if(src[i] == openChar) {
                        aroundOpenCharNum++;
                    }
                    if(src[i] == closeChar) {
                        aroundOpenCharNum--;
                    }
                } while (++i < src.length
                        && (aroundOpenCharNum > 0 || src[i] != closeChar));
                if(aroundOpenCharNum > 0
                        || (openChar == closeChar && aroundOpenCharNum != -1)) {
                    throw new RuntimeException("syntax error, un closed clause near '"
                            + builder.toString() + "' at column " + --i);
                }
                builder.append(closeChar);
                return builder.toString();
            }
        }
        // 未找到匹配
        return "";
    }

    /**
     * 過載另一版,適用特殊場景 (不支援巢狀)
     *
     * @see #readSplitWord(char[], int, char, char)
     */
    private static String readSplitWord(char[] src, int offset,
                                        String openChar, String closeChar)
            throws RuntimeException {
        StringBuilder builder = new StringBuilder();
        for (int i = offset; i < src.length; i++) {
            if(openChar.charAt(0) == src[i]) {
                int j = 0;
                while (++j < openChar.length() && ++i < src.length) {
                    if(openChar.charAt(j) != src[i]) {
                        break;
                    }
                }
                // 未匹配開頭
                if(j < openChar.length()) {
                    continue;
                }
                builder.append(openChar);
                while (++i < src.length){
                    int k = 0;
                    if(src[i] == closeChar.charAt(0)) {
                        while (++k < closeChar.length() && ++i < src.length) {
                            if(closeChar.charAt(k) != src[i]) {
                                break;
                            }
                        }
                        if(k < closeChar.length()) {
                            throw new RuntimeException("un closed syntax, near '"
                                    + new String(src, i - k, k)
                                    + ", at column " + (i - k));
                        }
                        builder.append(closeChar);
                        break;
                    }
                    builder.append(src[i]);
                }
                return builder.toString();
            }
        }
        // 未找到匹配
        return " ";
    }

    /**
     * 檢測字元是否特殊運算子
     *
     * @param value 給定檢測字元
     * @return true:是特殊字元, false:普通
     */
    private static boolean isSpecialChar(char value) {
        return SyntaxSymbolTable.OPERATOR_ALL.indexOf(value) != -1;
    }

}

  入口即是 parse() 方法。其中,著重需要說明的是:我們必須要完整解釋出所有語義,所以,我們需要為每個token做型別定義,且每個具體語法需要有相應的處理器進行處理。這些東西,在解析完成時就是固定的了。但具體需要翻譯成什麼語言,需要由使用者進行定義,以便靈活使用。

  接下來我們來看看如何進行翻譯:

import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.Map;

/**
 * 功能描述: 解析出的各小塊語句
 *
 */
@Slf4j
public class ParsedClauseAst {

    /**
     * id 資訊容器
     */
    private Map<String, Object> idMapping;

    /**
     * 語法樹 列表
     */
    private List<SyntaxStatement> ast;

    public ParsedClauseAst(Map<String, Object> idMapping,
                           List<SyntaxStatement> ast) {
        this.idMapping = idMapping;
        this.ast = ast;
    }

    public Map<String, Object> getidMapping() {
        return idMapping;
    }

    /**
     * 轉換語言表示式
     *
     * @param sqlType sql型別
     * @see TargetDialectTypeEnum
     * @return 翻譯後的sql語句
     */
    public String translateTo(TargetDialectTypeEnum sqlType) {
        StringBuilder builder = new StringBuilder();
        for (SyntaxStatement tree : ast) {
            builder.append(tree.translateTo(sqlType));
        }
        String targetCode = builder.toString().trim();
        log.info("翻譯成目標語言:{}, targetCode: {}", sqlType, targetCode);
        return targetCode;
    }

    @Override
    public String toString() {
        return "ParsedClauseAst{" +
                "idMapping=" + idMapping +
                ", ast=" + ast +
                '}';
    }
}

  這裡的翻譯過程,實際上就是一個委託的過程,因為所有的語義都已被封裝到具體的處理器中,所以我們只需處理好各細節就可以了。最後將所有小語句拼接起來,就得到我們最終要的目標語言了。所以,具體翻譯的重點工作,需要各自處理,這是很合理的事。

  大體的思路和實現就是如上,著實也簡單。但可能你還跑不起來以上 demo, 因為還有非常多的細節。

 

4. token型別定義

  我們需要為每一個token有一個準確的描述,以便在後續的處理中,能夠準確處理。

/**
 * 功能描述: 拆分的token 描述
 *
 */
public class TokenDescriptor {

    /**
     * 原始字串
     */
    private String rawWord;

    /**
     * token型別
     *
     *      用於確定如何使用該token
     *      或者該token是如何被分割出的
     */
    private TokenTypeEnum tokenType;

    public TokenDescriptor(String rawWord, TokenTypeEnum tokenType) {
        this.rawWord = rawWord;
        this.tokenType = tokenType;
    }

    public TokenDescriptor(char rawWord, TokenTypeEnum tokenType) {
        this.rawWord = rawWord + "";
        this.tokenType = tokenType;
    }

    public void changeTokenType(TokenTypeEnum tokenType) {
        this.tokenType = tokenType;
    }

    public String getRawWord() {
        return rawWord;
    }

    public TokenTypeEnum getTokenType() {
        return tokenType;
    }

    @Override
    public String toString() {
        return "T{" +
                "rawWord='" + rawWord + '\'' +
                ", tokenType=" + tokenType +
                '}';
    }
}

// ------------- TokenTypeEnum -----------------
/**
 * 功能描述: 單個不可分割的token 型別定義
 *
 */
public enum TokenTypeEnum {

    LABEL_ID("基礎id如$123"),

    FUNCTION_NORMAL("是函式但型別未知(未解析)"),

    FUNCTION_SYS_CUSTOM("系統自定義函式如my_udf(a)"),

    FUNCTION_SQL("sql中自帶函式如date_diff(a)"),

    KEYWORD_SYS_CUSTOM("系統自定義關鍵字如datepart"),

    KEYWORD_SQL("sql中自帶的關鍵字如and"),

    CLAUSE_SEPARATOR("語句分隔符,如'\"(){}[]"),

    SIMPLE_MATH_OPERATOR("簡單數學運算子如+-*/"),

    COMPARE_OPERATOR("比較運算子如=><!=>=<="),

    WORD_ARRAY("陣列型別欄位如 arr['key1']"),

    WORD_STRING("字元型具體值如 '%abc'"),

    WORD_NUMBER("數字型具體值如 123.4"),

    WORD_NORMAL("普通欄位可以是資料庫欄位也可以是使用者定義的字元"),

    ;

    private TokenTypeEnum(String remark) {
        // ignore...
    }
}

  如上,基本可以描述各詞的型別了,如果不夠,我們可以視情況新增即可。從這裡,我們可以準確地看出一些分詞的規則。

 

5. 符號表的定義

  很明顯,我們需要一個統籌所有可被處理的片語的地方,這就是符號表,我們可以通過符號表,準確的查到哪些是系統關鍵詞,哪些是udf,哪些是被支援的方法等等。這是符號表的職責。而且,符號表也可以支援註冊,從而使其可擴充套件。具體如下:

import com.my.mvc.app.common.helper.parser.keyword.SysCustomKeywordAstHandler;
import com.my.mvc.app.common.helper.parser.udf.SimpleUdfAstHandler;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 功能描述: 語法符號表(提供查詢入口)
 */
public class SyntaxSymbolTable {

    /**
     * 所有操作符
     */
    public static final String OPERATOR_ALL = "'\"[ ](){}=+-*/><!";

    /**
     * 所有處理器
     */
    private static final Map<String, SyntaxStatementHandlerFactory> handlers
            = new ConcurrentHashMap<>();

    private static final String SYS_CUSTOM_KEYWORD_REF_NAME = "__sys_keyword_handler";

    private static final String SQL_KEYWORD_REF_NAME = "__sql_keyword_handler";

    private static final String COMMON_HANDLER_REF_NAME = "__common_handler";

    static {
        // 註冊udf, 也可以放到外部呼叫
        registerUdf(
                (masterToken, candidates, handlerType)
                        -> new SimpleUdfAstHandler(masterToken, candidates,
                TokenTypeEnum.FUNCTION_SYS_CUSTOM),
                "my_udf", "fact.my_udf", "default.my_udf");

        // 註冊系統自定義關鍵字處理器
        handlers.putIfAbsent(SYS_CUSTOM_KEYWORD_REF_NAME, SysCustomKeywordAstHandler::new);

        // 註冊兜底處理器
        handlers.putIfAbsent(COMMON_HANDLER_REF_NAME, CommonConditionAstBranch::new);
    }

    /**
     * 判斷給定詞彙的 keyword 型別
     *
     * @param keyword 指定判斷詞
     * @return 系統自定義關鍵字、sql關鍵字、普通字元
     */
    public static TokenTypeEnum keywordTypeOf(String keyword) {
        if("datepart".equals(keyword)) {
            return TokenTypeEnum.KEYWORD_SYS_CUSTOM;
        }
        if("and".equals(keyword)
                || "or".equals(keyword)
                || "in".equals(keyword)) {
            return TokenTypeEnum.KEYWORD_SQL;
        }
        return TokenTypeEnum.WORD_NORMAL;
    }

    /**
     * 註冊一個 udf 處理器例項
     *
     * @param handlerFactory 處理器工廠類
     *              tokens 必要引數列表,說到底自定義
     * @param callNameAliases 函式呼叫別名, 如 wee_diff, fact.my_udf...
     */
    public static void registerUdf(SyntaxStatementHandlerFactory handlerFactory,
                                   String... callNameAliases) {
        for (String alias : callNameAliases) {
            handlers.put(alias, handlerFactory);
        }
    }

    /**
     * 獲取udf處理器的工廠類 (可用於判定系統是否支援)
     *
     * @param udfFunctionName 函式名稱
     * @return 對應的工廠類
     */
    public static SyntaxStatementHandlerFactory getUdfHandlerFactory(String udfFunctionName) {
        SyntaxStatementHandlerFactory factory= handlers.get(udfFunctionName);
        if(factory == null) {
            throw new RuntimeException("不支援的函式操作: " + udfFunctionName);
        }
        return factory;
    }

    /**
     * 獲取系統自定義關鍵字處理器的工廠類  應固定格式為 #{xxx+1}
     *
     * @return 對應的工廠類
     */
    public static SyntaxStatementHandlerFactory getSysKeywordHandlerFactory() {
        return handlers.get(SYS_CUSTOM_KEYWORD_REF_NAME);
    }

    /**
     * 獲取sql關鍵字處理器的工廠類  遵守 sql 協議
     *
     * @return 對應的工廠類
     */
    public static SyntaxStatementHandlerFactory getSqlKeywordHandlerFactory() {
        return handlers.get(COMMON_HANDLER_REF_NAME);
    }

    /**
     * 獲取通用處理器的工廠類(兜底)
     *
     * @return 對應的工廠類
     */
    public static SyntaxStatementHandlerFactory getCommonHandlerFactory() {
        return handlers.get(COMMON_HANDLER_REF_NAME);
    }


    /**
     * 檢測名稱是否是udf 函式字首
     *
     * @param udfFunctionName 函式名稱
     * @return true:是, false:其他關鍵詞
     */
    public static boolean isUdfPrefix(String udfFunctionName) {
        return handlers.get(udfFunctionName) != null;
    }

    /**
     * 判斷給定詞彙的 keyword 型別
     *
     * @param functionFullDesc 函式整體使用方式
     * @return 系統自定義函式,系統函式、未知
     */
    public static TokenTypeEnum functionTypeOf(String functionFullDesc) {
        String funcName = functionFullDesc.substring(0, functionFullDesc.indexOf('('));
        funcName = funcName.trim();
        if("my_udf".equals(funcName)) {
            return TokenTypeEnum.FUNCTION_SYS_CUSTOM;
        }
        return TokenTypeEnum.FUNCTION_NORMAL;
    }

}

  實際上,整個解析器的完善過程,大部分時候就是符號表的一個完善過程。支援的符號越多了,則功能就越完善了。我們通過一個個的工廠類,實現了具體解析類的細節,遮蔽到內部的變化,從而使變化對上層的無感知。

  以下為處理器的定義,及工廠類定義:

import java.util.Iterator;

/**
 * 功能描述: 組合標籤語句處理器 工廠類
 *
 *      生產提供各處理器例項
 *
 */
public interface SyntaxStatementHandlerFactory {

    /*
     * 獲取本語句對應的運算元量
     *
     *      其中, 函式呼叫會被解析為單token, 如 my_udf($123) = -1
     *          my_udf($123) 為函式呼叫, 算一個token
     *          '=' 為運算子,算第二個token
     *          '-1' 為右值, 算第三個token
     *      所以此例應返回 3
     *
     * 此實現由具體的 StatementHandler 處理
     *      從 candidates 中獲取即可
     *
     */
    /**
     * 生成一個新的語句處理器例項
     *
     * @param masterToken 主控token, 如關鍵詞,函式呼叫...
     * @param candidates 候選片語(後續片語), 此實現基於本解析器無全域性說到底關聯性
     * @param handlerType 處理器型別,如函式、關鍵詞、sql...
     * @return 對應的處理器例項
     */
    SyntaxStatement newHandler(TokenDescriptor masterToken,
                               Iterator<TokenDescriptor> candidates,
                               TokenTypeEnum handlerType);
}    
    

// ----------- SyntaxStatement ------------------        
/**
 * 功能描述: 單個小片語處理器
 *
 */
public interface SyntaxStatement {

    /**
     * 轉換成目標語言表示
     *
     * @param targetSqlType 目標語言型別 es|hive|presto|spark
     * @return 翻譯後的語言表示
     */
    String translateTo(TargetDialectTypeEnum targetSqlType);

}

  有了這符號表和處理器的介面定義,後續的工作明顯方便很多。

  最後,還有一個行號指示器,需要定義下。它可以幫助我們給出準確的錯誤資訊提示,從而減少排錯時間。

    
/**
 * 功能描述: 行列號指示器
 */
public class ColumnNumDescriptor {

    /**
     * 列號
     */
    private int columnNum;

    /**
     * 關鍵詞
     */
    private String keyword;

    public ColumnNumDescriptor(int columnNumFromZero, String keyword) {
        this.columnNum = columnNumFromZero + 1;
        this.keyword = keyword;
    }

    public static ColumnNumDescriptor newData(int columnNum, String data) {
        return new ColumnNumDescriptor(columnNum, data);
    }
    public static ColumnNumDescriptor newData(int columnNum, char dataChar) {
        return new ColumnNumDescriptor(columnNum, dataChar + "");
    }

    public int getColumnNum() {
        return columnNum;
    }

    public String getKeyword() {
        return keyword;
    }

    @Override
    public String toString() {
        return "Col{" +
                "columnNum=" + columnNum +
                ", keyword='" + keyword + '\'' +
                '}';
    }
}

  

6. 目標語言定義

  系統可支援的目標語言是有限的,應當將其定義為列舉型別,以便使用者規範使用。

/**
 * 功能描述: 組合標籤可被翻譯成的 方言列舉
 *
 */
public enum TargetDialectTypeEnum {
    ES,
    HIVE,
    PRESTO,
    SPARK,

    /**
     * 原始語句
     */
    RAW,

    ;
}

  如果有一天,你新增了一個語言的實現,那你就可以將型別加上,這樣使用者也就可以呼叫了。

 

7. 詞義處理器實現示例

  解析器的幾大核心之一就是詞義處理器,前面很多的工作都是準備性質的,比如分詞,定義等。前面也看到,我們將詞義處理器統一定義了一個介面: SyntaxStatement . 即所有詞義處理,都只需實現該介面即可。但該詞義至少得獲取到相應的引數,所以通過一個通用的工廠類生成該處理器,也即需要在構造器中處理好上下文關係。

  首先,我們需要有一個兜底的處理器,以便在未知的情況下,可以保證原語義正確,而非直接出現異常,除非確認所有語義已實現,否則該兜底處理器都是有存在的必要的。

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * 功能描述: 通用抽象語法樹處理器(分支)
 *
 */
public class CommonConditionAstBranch implements SyntaxStatement {

    /**
     * 擴充套件片語列表(如 = -1, > xxx ...)
     *
     *      相當於片語上下文
     */
    protected final List<TokenDescriptor> extendTokens = new ArrayList<>();

    /**
     * 型別: 函式, 關鍵詞, 分隔符...
     */
    protected TokenTypeEnum tokenType;

    /**
     * 主控詞(如   and, my_udf($123))
     *
     *      可用於確定該語義大方向
     */
    protected TokenDescriptor masterToken;


    public CommonConditionAstBranch(TokenDescriptor masterToken,
                                    Iterator<TokenDescriptor> candidates,
                                    TokenTypeEnum tokenType) {
        this.masterToken = masterToken;
        this.tokenType = tokenType;
        for (int i = 0; i < getFixedExtTokenNum(); i++) {
            if(!candidates.hasNext()) {
                throw new RuntimeException("用法不正確: ["
                        + masterToken.getRawWord() + "] 缺少變數");
            }
            addExtendToken(candidates.next());
        }
    }

    /**
     * 新增附加片語,根據各解析器需要新增
     */
    protected void addExtendToken(TokenDescriptor token) {
        extendTokens.add(token);
    }

    @Override
    public String translateTo(TargetDialectTypeEnum targetSqlType) {
        String separator = " ";
        StringBuilder sb = new StringBuilder(masterToken.getRawWord()).append(separator);
        extendTokens.forEach(r -> sb.append(r.getRawWord()).append(separator));
        return sb.toString();
    }

    /**
     * 解析方法固定引數數量,由父類統一解析
     */
    protected int getFixedExtTokenNum() {
        return 0;
    }

    @Override
    public String toString() {
        return "CTree{" +
                "extendTokens=" + extendTokens +
                ", tokenType=" + tokenType +
                ", masterToken=" + masterToken +
                '}';
    }
}

  該處理器被註冊到符號表中,以 __common_handler 查詢。

  接下來,我們再另一個處理器的實現: udf。 udf 即使用者自定義函式,這應該是標準sql協議中不存在的關鍵詞,為業務需要而自行實現的函式,它在有的語言裡,可以表現為註冊後的函式,而在有語言裡,我們只能轉換為其他更直接的語法,方可執行。該處理器將作為一種相對複雜些的實現存在,處理的邏輯也是各有千秋。此處僅給一點點提示,大家可按需實現即可。

 

    
import com.my.mvc.app.common.helper.parser.*;

import java.util.Iterator;

/**
 * 功能描述: 自定義函式實現示例
 */
public class SimpleUdfAstHandler
        extends CommonConditionAstBranch
        implements SyntaxStatement {

    public SimpleUdfAstHandler(TokenDescriptor masterToken,
                                 Iterator<TokenDescriptor> candidates,
                                 TokenTypeEnum tokenType) {
        super(masterToken, candidates, tokenType);
    }

    @Override
    protected int getFixedExtTokenNum() {
        // 固定額外引數
        return 2;
    }

    @Override
    public String translateTo(TargetDialectTypeEnum targetSqlType) {
        // 自行實現
        String usage = masterToken.getRawWord();
        int paramStart = usage.indexOf('(');
        StringBuilder fieldBuilder = new StringBuilder();
        for (int i = paramStart; i < usage.length(); i++) {
            char ch = usage.charAt(i);
            if(ch == ' ') {
                continue;
            }
            if(ch == '$') {
                // 示例解析,只需一個id引數處理
                fieldBuilder.append(ch);
                while (++i < usage.length()) {
                    ch = usage.charAt(i);
                    if(ch >= '0' && ch <= '9') {
                        fieldBuilder.append(ch);
                        continue;
                    }
                    break;
                }
                break;
            }
        }
        String separator = " ";
        StringBuilder resultBuilder
                = new StringBuilder(fieldBuilder.toString())
                    .append(separator);
        // 根據各目標語言需要,做特別處理
        switch (targetSqlType) {
            case ES:
            case HIVE:
            case SPARK:
            case PRESTO:
            case RAW:
                extendTokens.forEach(r -> resultBuilder.append(r.getRawWord()).append(separator));
                return resultBuilder.toString();
        }
        throw new RuntimeException("unknown target dialect");
    }
}

  udf 作為一個重點處理物件,大家按需實現即可。

 

8. 自定義關鍵字的解析實現

  自定義關鍵字的目的,也許是為了讓使用者使用更方便,也許是為了理解更容易,也許是為系統處理方便,但它與udf實際有異曲同工之妙,不過自定義關鍵字可以儘量定義得簡單些,這也從另一個角度將其與udf區分開來。因此,我們可以將關鍵字處理歸納為一類處理器,簡化實現。

import com.my.mvc.app.common.helper.parser.*;
import com.my.mvc.app.common.util.ClassLoadUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 功能描述: 系統自定義常量解析類
 *
 */
@Slf4j
public class SysCustomKeywordAstHandler
        extends CommonConditionAstBranch
        implements SyntaxStatement {

    private static final Map<String, SysKeywordDefiner>
            keywordDefinerContainer = new ConcurrentHashMap<>();

    static {
        try {
            // 自動發現載入指定路徑下所有關鍵字解析器 keyword 子包
            String currentPackage = SysCustomKeywordAstHandler.class.getPackage().getName();
            ClassLoadUtil.loadPackageClasses(
                    currentPackage + ".custom");
        }
        catch (Throwable e) {
            log.error("載入包路徑下檔案失敗", e);
        }
    }

    public SysCustomKeywordAstHandler(TokenDescriptor masterToken,
                                      Iterator<TokenDescriptor> candidates,
                                      TokenTypeEnum tokenType) {
        super(masterToken, candidates, tokenType);
    }

    @Override
    public String translateTo(TargetDialectTypeEnum targetSqlType) {
        String usage = masterToken.getRawWord();
        String keywordName = parseSysKeywordName(usage);
        SysKeywordDefiner definer = getKeywordDefiner(keywordName);
        List<TokenDescriptor> mergedToken = new ArrayList<>(extendTokens);
        mergedToken.add(0, masterToken);
        if(definer == null) {
//            throw new BizException("不支援的關鍵字: " + keywordName);
            // 在未完全替換所有關鍵字功能之前,不得丟擲以上異常
            log.warn("系統關鍵字[{}]定義未找到,降級使用原始語句,請儘快補充功能.", keywordName);
            return translateToDefaultRaw(mergedToken);
        }
        return definer.translate(mergedToken, targetSqlType);
    }

    /**
     * 獲取關鍵字名稱
     *
     * 檢測關鍵詞是否是 '%%#{datepart}%' 格式的字元
     * @return 關鍵字標識如 datepart
     */
    private String parseSysKeywordName(String usage) {
        if('\'' == usage.charAt(0)) {
            String keywordName = getSysKeywordNameWithPreLikeStr(usage);
            if(keywordName == SYS_CUSTOM_EMPTY_KEYWORD_NAME) {
                throw new RuntimeException("系統關鍵詞定義非法, 請以 #{} 使用關鍵詞2");
            }
            return keywordName;
        }
        return getSysKeywordNameNormal(usage);
    }

    private static final String SYS_CUSTOM_EMPTY_KEYWORD_NAME = "";

    /**
     * 獲取關鍵字名稱('%#{datepart}%')
     *
     * @param usage 完整用法
     * @return 關鍵字名稱 如 datepart
     */
    public static String getSysKeywordNameWithPreLikeStr(String usage) {
        if('\'' != usage.charAt(0)) {
            return SYS_CUSTOM_EMPTY_KEYWORD_NAME;
        }
        StringBuilder keywordBuilder = new StringBuilder();
        int preLikeCharNum = 0;
        String separatorChars = " -+(){}[],";
        for (int i = 1; i < usage.length(); i++) {
            char ch = usage.charAt(i);
            if(ch == '%') {
                preLikeCharNum++;
                continue;
            }
            if(ch != '#'
                    || usage.charAt(++i) != '{') {
                return SYS_CUSTOM_EMPTY_KEYWORD_NAME;
            }

            while (++i < usage.length()) {
                ch = usage.charAt(i);
                keywordBuilder.append(ch);
                if(i + 1 < usage.length()) {
                    char nextChar = usage.charAt(i + 1);
                    if(separatorChars.indexOf(nextChar) != -1) {
                        break;
                    }
                }
            }
            break;
        }
        return keywordBuilder.length() == 0
                ? SYS_CUSTOM_EMPTY_KEYWORD_NAME
                : keywordBuilder.toString();
    }


    /**
     * 解析關鍵詞特別用法法為一個個token
     *
     * @param usage 原始使用方式如: #{day+1}
     * @param prefix 字元開頭
     * @param suffix 字元結尾
     * @return 拆分後的token, 已去除分界符 #{}
     */
    public static List<TokenDescriptor> parseSysCustomKeywordInnerTokens(String usage,
                                                                         String prefix,
                                                                         String suffix) {
//        String prefix = "#{day";
//        String suffix = "}";
        String separatorChars = " ,{}()[]-+";
        if (!usage.startsWith(prefix)
                || !usage.endsWith(suffix)) {
            throw new RuntimeException("關鍵字使用格式不正確: " + usage);
        }
        List<TokenDescriptor> innerTokens = new ArrayList<>(2);
        TokenDescriptor token;
        for (int i = prefix.length();
             i < usage.length() - suffix.length(); i++) {
            char ch = usage.charAt(i);
            if (ch == ' ') {
                continue;
            }
            if (ch == '}') {
                break;
            }
            if (ch == '-' || ch == '+') {
                token = new TokenDescriptor(ch, TokenTypeEnum.SIMPLE_MATH_OPERATOR);
                innerTokens.add(token);
                continue;
            }
            StringBuilder wordBuilder = new StringBuilder();
            do {
                ch = usage.charAt(i);
                wordBuilder.append(ch);
                if (i + 1 < usage.length()) {
                    char nextChar = usage.charAt(i + 1);
                    if (separatorChars.indexOf(nextChar) != -1) {
                        break;
                    }
                    ++i;
                }
            } while (i < usage.length());
            String word = wordBuilder.toString();
            TokenTypeEnum tokenType = TokenTypeEnum.WORD_STRING;
            if(StringUtils.isNumeric(word)) {
                tokenType = TokenTypeEnum.WORD_NUMBER;
            }
            innerTokens.add(new TokenDescriptor(wordBuilder.toString(), tokenType));
        }
        return innerTokens;
    }

    /**
     * 解析普通關鍵字定義 #{day+1}
     *
     * @return 關鍵字如: day
     */
    public static String getSysKeywordNameNormal(String usage) {
        if(!usage.startsWith("#{")) {
            throw new RuntimeException("系統關鍵詞定義非法, 請以 #{} 使用關鍵詞");
        }
        StringBuilder keywordBuilder = new StringBuilder();
        for (int i = 2; i < usage.length(); i++) {
            char ch = usage.charAt(i);
            if(ch == ' ' || ch == ','
                    || ch == '+' || ch == '-'
                    || ch == '(' || ch == ')' ) {
                break;
            }
            keywordBuilder.append(ch);
        }
        return keywordBuilder.toString();
    }
    /**
     * 預設使用原始語句返回()
     *
     * @return 原始關鍵字片語
     */
    private String translateToDefaultRaw(List<TokenDescriptor> tokens) {
        String separator = " ";
        StringBuilder sb = new StringBuilder();
        tokens.forEach(r -> sb.append(r.getRawWord()).append(separator));
        return sb.toString();
    }

    /**
     * 獲取關鍵詞定義處理器
     *
     */
    private SysKeywordDefiner getKeywordDefiner(String keyword) {
        return keywordDefinerContainer.get(keyword);
    }

    /**
     * 註冊新的關鍵詞
     *
     * @param definer 詞定義器
     * @param keywordNames 關鍵詞別名(支援多個,目前只有一個的場景)
     */
    public static void registerDefiner(SysKeywordDefiner definer, String... keywordNames) {
        for (String key : keywordNames) {
            keywordDefinerContainer.putIfAbsent(key, definer);
        }
    }
}

// ----------- SysKeywordDefiner ------------------    
import com.my.mvc.app.common.helper.parser.TargetDialectTypeEnum;
import com.my.mvc.app.common.helper.parser.TokenDescriptor;

import java.util.List;

/**
 * 功能描述: 系統關鍵詞定義介面
 *
 *      (關鍵詞一般被自動註冊,無需另外呼叫)
 *      關鍵詞名稱,如: day, dd, ddpart ...
 *      day
 *      '%#{datepart}%'
 *
 */
public interface SysKeywordDefiner {

    /**
     * 轉換成目標語言表示
     *
     *
     *
     * @param tokens 所有必要片語
     * @param targetSqlType 目標語言型別 es|hive|presto|spark
     * @return 翻譯後的語言表示
     */
    String translate(List<TokenDescriptor> tokens,
                     TargetDialectTypeEnum targetSqlType);

}


// ----------- SyntaxStatement ------------------    

import com.my.mvc.app.common.helper.parser.TargetDialectTypeEnum;
import com.my.mvc.app.common.helper.parser.TokenDescriptor;
import com.my.mvc.app.common.helper.parser.keyword.SysCustomKeywordAstHandler;
import com.my.mvc.app.common.helper.parser.keyword.SysKeywordDefiner;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;

/**
 * 功能描述: day 關鍵詞定義
 *
 *      翻譯當天日期,做相應運算
 *
 */
public class DayDefinerImpl implements SysKeywordDefiner {

    private static final String KEYWORD_NAME = "day";

    static {
        // 自動註冊關鍵詞到系統中
        SysCustomKeywordAstHandler.registerDefiner(new DayDefinerImpl(), KEYWORD_NAME);
    }

    @Override
    public String translate(List<TokenDescriptor> tokens,
                            TargetDialectTypeEnum targetSqlType) {
        String separator = " ";
        String usage = tokens.get(0).getRawWord();
        List<TokenDescriptor> innerTokens = SysCustomKeywordAstHandler
                .parseSysCustomKeywordInnerTokens(usage, "#{", "}");
        switch (targetSqlType) {
            case ES:
            case SPARK:
            case HIVE:
            case PRESTO:
                int dayAmount = 0;
                if(innerTokens.size() > 1) {
                    String comparator = innerTokens.get(1).getRawWord();
                    switch (comparator) {
                        case "-":
                            dayAmount = -Integer.valueOf(innerTokens.get(2).getRawWord());
                            break;
                        case "+":
                            dayAmount = Integer.valueOf(innerTokens.get(2).getRawWord());
                            break;
                        default:
                            throw new RuntimeException("day關鍵字不支援的操作符: " + comparator);
                    }
                }
                // 此處格式可能需要由外部傳入,配置化
                return "'"
                        + LocalDate.now().plusDays(dayAmount)
                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                        + "'" + separator;
            case RAW:
            default:
                StringBuilder sb = new StringBuilder();
                tokens.forEach(r -> sb.append(r.getRawWord()).append(separator));
                return sb.toString();
        }
    }

}

  關鍵詞的處理,值得一提是,使用了一個橋接類,且自動發現相應的實現。(可參考JDBC的 DriverManager 的實現) 從而在實現各關鍵字後,直接放入相應包路徑,即可生效。還算優雅吧。

 

9. 單元測試

  最後一部分,實際也是非常重要的部分,被我簡單化了。我們應該根據具體場景,羅列所有可能的情況,以滿足所有語義,單測通過。樣例如下:

import com.my.mvc.app.common.helper.SimpleSyntaxParser;
import com.my.mvc.app.common.helper.parser.ParsedClauseAst;
import com.my.mvc.app.common.helper.parser.TargetDialectTypeEnum;
import org.junit.Assert;
import org.junit.Test;

public class SimpleSyntaxParserTest {

    @Test
    public void testParse1() {
        String rawClause = "$15573 = 123 and (my_udf($123568, $82949) = 1) or $39741 = #{day+1} and my_udf($35289) = -1";
        ParsedClauseAst clauseAst = SimpleSyntaxParser.parse(rawClause);
        Assert.assertEquals("解析成目標語言ES不正確",
                "$15573 = 123 and ( $123568 = 1 ) or $39741 = '2020-10-07' and $35289 = -1",
                    clauseAst.translateTo(TargetDialectTypeEnum.ES));
    }
}

  以上,就是一個完整地、簡單的語法解析器的實現了。也許各自場景不同,但相信思想總是相通的。

相關文章