有時候,我們為了遮蔽一些底層的差異,我們會要求上游系統按照某種約定進行傳參。而在我們自己的系統層則會按照具體的底層協議進行適配,這是通用的做法。但當我們要求上游系統傳入的引數非常複雜時,也許我們會有一套自己的語法定義,用以減輕所有引數的不停變化。比如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)); } }
以上,就是一個完整地、簡單的語法解析器的實現了。也許各自場景不同,但相信思想總是相通的。