我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:奇銘
目前數棧的多個產品中都支援線上編輯 SQL 來生成對應的任務。比如離線開發產品和實時開發產品。在使用 MonacoEditor 為編輯器的基礎上,我們還支援瞭如下幾個重要功能:
本文旨在講解上述功能的實現思路,對於技術細節,由於篇幅原因不會闡述的太詳細。
Monaco Editor 內建了相當多的 languages,比如 javaScript
、CSS
、Shell
等。
Monaco Editor 依賴包的 ESM 入口檔案為 ./esm/vs/editor/editor.main.ts
而在這個檔案中,Monaco Editor 引入了所有內建的 Languages。
這裡 languages 檔案可以分為兩類,一類是../language
資料夾下的,支援自動補全和飄紅提示功能;另一類則是../basic-languages
資料夾下的,不支援自動補全功能和飄紅提示功能。
以使用 typescript
為例
import { editor } from 'monaco-editor';
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
此時我們會發現,我們的編輯器已經有語法高亮的功能了,但是瀏覽器控制檯會拋異常,另外也沒有自動補全功能和飄紅提示功能,
這其實是因為,Monaco Editor 無法載入到 language 對應的 worker,對應的解決辦法看這裡: Monaco integrate-esm。
這裡我們使用 Using plain webpack
的方式,首先將對應的 worker 檔案設定為 webpack entry
module.exports = {
entry: {
index: path.resolve( __dirname, './src/index.ts'),
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js'
},
}
另外還需要設定 Monaco Editor 的全域性環境變數,這主要是為了告訴 Monaco Editor 對應的 worker 檔案的路徑
import { editor } from 'monaco-editor';
(window as any).MonacoEnvironment = {
getWorkerUrl: function (_moduleId, label) {
switch (label) {
case 'flink': {
return './flink.worker.js';
}
case 'typescript': {
return './ts.worker.js'
}
default: {
return './editor.worker.js';
}
}
}
};
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
這樣一個具有語法高亮
、自動補全
、飄紅提示
功能的 typescript 編輯器就設定好了
首先上文中提到了當我們直接從 Monaco Editor 的入口檔案中匯入時,會自動的引入所有內建的 Languages,但是實際上這其中絕大都是我們不需要的,而由於其匯入方式,很顯然我們不需要的 languages 也無法被 treeShaking。要解決這個問題我們可以選擇從 monaco-editor/esm/vs/editor/editor.api
檔案中匯入Monaco Editor 核心 API,然後通過 monaco-editor-webpack-plugin 來按需匯入所需要的功能。另外這個外掛也可以自動處理Monaco Editor 內建的 worker 檔案的打包問題,以及自動注入 MonacoEnvironment
全域性環境變數。
Monaco Editor 提供了 monaco.languages.register
方法,用來自定義 language
/**
* Register information about a new language.
*/
export function register(language: ILanguageExtensionPoint): void;
export interface ILanguageExtensionPoint {
id: string;
extensions?: string[];
filenames?: string[];
filenamePatterns?: string[];
firstLine?: string;
aliases?: string[];
mimetypes?: string[];
configuration?: Uri;
}
第一步,我們需要註冊一個 language, 設定項中 id 對應的就是語言名稱(其他設定項可以暫時不填),這裡自定義的 language 名為 myLang
import { editor, languages } from 'monaco-editor';
languages.register({
id: "myLang"
});
const container = document.getElementById('container');
editor.create(container, {
language: 'myLang'
})
此時可以發現,頁面上的編輯器沒有任何其他附加功能,就是普通的文字編輯器。
通過 monaco.languages.setLanguageConfiguration
,可以對 language 進行設定
/**
* Set the editing configuration for a language.
*/
export function setLanguageConfiguration(
languageId: string,
configuration: LanguageConfiguration
): IDisposable;
/**
* The language configuration interface defines the contract between extensions and
* various editor features, like automatic bracket insertion, automatic indentation etc.
*/
export interface LanguageConfiguration {
comments?: CommentRule;
brackets?: CharacterPair[];
wordPattern?: RegExp;
indentationRules?: IndentationRule;
onEnterRules?: OnEnterRule[];
autoClosingPairs?: IAutoClosingPairConditional[];
surroundingPairs?: IAutoClosingPair[];
colorizedBracketPairs?: CharacterPair[];
autoCloseBefore?: string;
folding?: FoldingRules;
}
這些設定會影響 Monaco Editor 的一些預設行為,比如設定 autoClosingPairs
中有一項為一對圓括號,那麼當輸入左圓括號後,會自動補全右圓括號。
import { languages } from "monaco-editor";
const conf: languages.LanguageConfiguration = {
comments: {
lineComment: "--",
blockComment: ["/*", "*/"],
},
brackets: [
["(", ")"],
],
autoClosingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
};
languages.setLanguageConfiguration('myLang', conf)
Moanco Editor 內建了 Monarch,用於實現語法高亮功能,它本質上是一個有限狀態機,我們可以通過JSON的形式來設定其狀態流轉邏輯,並通過monaco.languages.setMonarchTokensProvider
API 應用該設定。關於Monarch 的具體用法可以看一下這篇文章 以及 Monarch Document。
設定中最重要的是 tokenizer
屬性,意思是分詞器,分詞器會自動對編輯器內部的文字進行分詞處理,每個分詞器都有一個 root state,在 root state 中可以有多條規則,規則內部可以參照其他 state。
下面是一個簡單的設定範例
import { languages } from "monaco-editor";
export const language: languages.IMonarchLanguage = {
ignoreCase: true,
tokenizer: {
root: [
{ include: '@comments' }, // 參照下面的 comments 規則
{ include: '@whitespace' }, // 參照下面的 whiteSpace 規則
{ include: '@strings' },// 參照下面的 strings 規則
],
whitespace: [[/\s+/, 'white']],
comments: [
[/--+.*/, 'comment'],
[/\/\*/, { token: 'comment.quote', next: '@comment' }]
],
comment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment.quote', next: '@pop' }],
[/./, 'comment']
],
strings: [
[/'/, { token: 'string', next: '@string' }]
],
string: [
[/[^']+/, 'string'],
[/''/, 'string'],
[/'/, { token: 'string', next: '@pop' }]
],
}
};
languages.setMonarchTokensProvider("myLang", language);
上面的設定中 root 下面有三條規則分別匹配 註釋(comments)
、字串(strings)
以及空白字元(whiteSpace)
, 每條規則可以大體分為兩部分:
比如上述設定中 tokenizer.comments
規則
comments: [
[/--+.*/, 'comment'], // 左邊是正規表示式用來匹配文字,右邊是該規則對應的 token 名稱
[/\/\*/, { token: 'comment.quote', next: '@comment' }] // 左邊是正規表示式用來匹配文字,右邊顯示宣告對應的 token 名稱
],
設定了如上 Monarch 之後,在編輯器內部輸入註釋或者字串,那麼Monaco editor 就會根據輸入的內容進行分詞處理
可以看到目前字串和註釋已經被高亮了。這裡有一個新的問題,不同型別的分詞的顏色是怎麼設定的?
從上圖中右側的 Elements 面板中可以看到,不同型別的分詞,對應的標籤的 className 不同,它們是由 Monarch 設定中的 token 對映而來的。MonacoEditor 內建了一些 Theme,預設的 Theme 是 vs
,而預設的 theme 中已經設定了上述 Monarch 中的 token 對應的顏色,所以我們應用上述設定後,對應的分詞直接就有了高亮顏色。
我們可以通過 monaco.editor.defineTheme
來定義一種新的 theme,如下例所示:
editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [
{ token: 'comment', foreground: 'ff4400' },
{ token: 'string', foreground: '0000ff' }
],
colors: {
},
});
// xxxx
editor.create(container, {
language: "myLang",
theme: "myTheme"
});
這裡將註釋設定為紅色,字串設定為藍色,顯示效果如下圖所示
飄紅提示的功能就是在程式碼錯誤的位置打上標記(一般是紅色波浪線),可以通過 monaco.editor.setModelMarkers
API 來實現。比如我們想為 第1行的第1個字元到第2行的第2個字元 之間打上錯誤標記:
const editorIns = editor.create(container, {
language: "myLang",
theme: "myTheme",
value:
`hello
world`
});
const model = editorIns.getModel();
editor.setModelMarkers(model, 'myLang', [
{
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 2,
message: "語法錯誤",
severity: MarkerSeverity.Error
}
])
severity 是標記型別,message 是提示資訊,效果如下所示。
到此為止,實現了飄紅的功能,但是沒有實現在語法錯誤處飄紅的功能,這需要額外的語法解析器支援,會在下文中講到。
Monaco Editor 提供了 monaco.languages.registerCompletionItemProvider
API 來實現自動補全功能
import { editor, languages, MarkerSeverity, Position, CancellationToken, Range } from "monaco-editor";
languages.registerCompletionItemProvider('myLang', {
triggerCharacters: ['.', '*'],
provideCompletionItems(
model: editor.IReadOnlyModel,
position: Position,
context: languages.CompletionContext,
token: CancellationToken
){
const wordInfo = model.getWordUntilPosition(position);
const wordRange = new Range(
position.lineNumber,
wordInfo.startColumn,
position.lineNumber,
wordInfo.endColumn
);
return new Promise((resolve) => {
resolve({
suggestions: [
{
label: "SELECT",
kind: languages.CompletionItemKind.Keyword,
insertText: "SELECT",
range: wordRange,
detail: '關鍵字',
},
{
label: "SET",
kind: languages.CompletionItemKind.Keyword,
insertText: "SET",
range: wordRange,
detail: '關鍵字',
},
{
label: "SHOW",
kind: languages.CompletionItemKind.Keyword,
insertText: "SHOW",
range: wordRange,
detail: '關鍵字',
},
]
})
})
}
})
registerCompletionItemProvider
接受兩個引數,第一個引數是 languageId 也就是 language 名稱,
第二個引數是一個 CompletionItemProvider
,CompletionItemProvider
中 triggerCharacters
用來設定觸發自動補全的字元有哪些,而 provideCompletionItems
則是一個函數,它接收 Monaco Editor 提供的當前的上下文資訊,返回自動補全項列表。如上例中返回了三個自動補全項,那麼當我們在編輯器中輸入 S
時,就會出現設定的自動補全項候選選單。
通過這個 API 我們可以實現一種語言的關鍵字自動補全,只需要在CompletionItemProvider
中返回該語言所有的關鍵字對應的自動補全項即可。
但是registerCompletionItemProvider
目前做不到根據語意進行自動補全。
比如使用者寫一段 flinkSQL,當用戶輸入完 CREATE
關鍵字並按下空格後,應該出現的自動補全項應該是隻有TABLE
、CATALOG
、DATABASE
、FUNCTION
、 VIEW
。
再比如當用戶輸入 SELECT * FROM
時,後面應該提示表名而不是其他無關的關鍵字。與上文中的飄紅提示一樣,這些語意資訊需要單獨的語法解析器來分析。
到此為止,在**自定義 language **這一節中,我們已經瞭解了,在 Monaco Editor 中如何實現自定義語言的 語法高亮
、錯誤處飄紅提示
、自動補全
。
在數棧產品中,本節講到的功能都通過引入 monaco-sql-languages 依賴來實現,這是我們數棧 UED 團隊自研的開源專案,目前已經支援多種 SQL Languages。
由於目前為止沒有實現自定義 language 的語意分析功能,導致目前實現的編輯器不夠智慧。 另外,對於第一節中提到的 web worker ,在第二節中也沒有有提到,實際上 Monaco Editor 自帶的 web worker,也都是為了實現 language 的語意分析功能,下一節將闡述這一部分內容。
要實現語意分析功能,很顯然我們需要一個語法解析器。除了基本的語法解析的基礎功能以外,我們還需要
SELECT
關鍵字後面可以跟欄位或者函數,那麼我們所要實現的 sql parser 就應該提示出在 SELECT
關鍵字後面的候選項應該是欄位或者函數。我們使用 Antlr4 來實現一個基本的 SQL Parser。Antlr4 是一個強大的解析器生成器,它能根據使用者自定義的語法檔案來生成對應的解析器。Antlr4 的語法檔案為 .g4
檔案,內部可以包含多條規則,規則可以分為詞法規則和語法規則,詞法規則用於生成詞法分析器,語法規則用於生成語法解析器。
例,我們現在寫一份語法規則,匹配最簡單的 SELECT 語句(不包括子查詢、別名等規則),比如
SELECT * FROM table1; -- eg1
SELECT table2.name, age FROM schema2.table2; -- eg2
那麼在antlr4中這份語法檔案應該這樣寫:
grammar SelectStatement;
/** 語法規則 begin */
program: selectStatement? EOF;
// 宣告 語句的匹配規則
selectStatement: KW_SELECT columnGroup KW_FROM tablePath SEMICOLON?;
// 宣告 語句中欄位部分的匹配規則,欄位部分可能為 col1, col2 的形式
columnGroup: columnPath (COMMA columnPath)*;
// 宣告 欄位名匹配規則,欄位名有可能為 db.table.col 或者 * 的形式
columnPath: dot_id | OP_STAR;
// 宣告 表名匹配規則,表名有可能為 db.table 的形式
tablePath: dot_id;
// 匹配 id.id 形式的識別符號號
dot_id: IDENTIFIER_LITERAL (DOT IDENTIFIER_LITERAL)*;
/** 語法規則 end */
/** 詞法規則 begin */
KW_SELECT: 'SELECT'; // 匹配 SELECT 關鍵字
KW_FROM: 'FROM'; // 匹配 FROM 關鍵字
OP_STAR: '*'; // 匹配 *
DOT: '.'; // 匹配 .
COMMA: ','; // 匹配 ,
SEMICOLON: ';'; // 匹配 ;
IDENTIFIER_LITERAL: [A-Z_a-z][A-Z_0-9a-z]*; // 匹配識別符號
WS: [ \t\n\r]+ -> skip ; // 忽略空格換行等空白字元
/** 詞法規則 end */
語法規則的編寫格式類似於 EBNF。
然後執行 antlr4 命令,根據所寫的語法檔案生成對應的解析器。可以直接使用官方檔案中提供的方式 antlr4 typescript-target doc ,或者直接使用社群提供的 antlr4ts 包,這裡以使用 antlr4ts 為例。
生成的檔案結果如下所示:
在使用Antlr4 的生成的 Parser 之前我們需要安裝,Antlr4 的執行時包。你可以將 Antlr4 的執行時包與通過語法檔案生成的parser檔案之間的關係,類比為 react 和 react-dom之間的關係。這裡以使用 antlr4ts 為執行時
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
class SelectParser {
private createLexer(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new SelectStatementLexer(inputStream);
return lexer
}
private createParser (input: string) {
const lexer = this.createLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new SelectStatementParser(tokens);
return parser
}
parse (sql: string) {
const parser = this.createParser(sql)
const parseTree = parser.selectStatement();
return parseTree;
}
}
// 試一下效果
const selectParser = new SelectParser();
const parseTree = selectParser.parse('SELECT * FROM table1');
當解析一個含有錯誤的文字時,Antlr4 會輸出錯誤資訊,例如輸入
selectParser.parse('SELECT id FRO');
控制檯列印
可以看到錯誤資訊中包含了文字中的錯誤所處的位置,我們可以通過使用 Antlr4 ParserErrorListener 來獲取錯誤資訊。
import { ParserErrorListener } from 'antlr4ts';
export class SelectErrorListener implements ParserErrorListener {
private _parserErrorSet: Set<any> = new Set();
syntaxError(_rec,_ofSym, line, charPosInLine,msg) {
let endCol = charPosInLine + 1;
this._parserErrorSet.add({
startLine: line,
endLine: line,
startCol: charPosInLine,
endCol: endCol,
message: msg,
})
}
clear () {
this._parserErrorSet.clear();
}
get parserErrors () {
return Array.from(this._parserErrorSet)
}
}
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
class SelectParser {
private _errorListener = new SelectErrorListener();
createLexer(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new SelectStatementLexer(inputStream);
this._errorListener.clear();
lexer.removeErrorListeners(); // 移除 Antlr4 內建的 ErrorListener
lexer.addErrorListener(this._errorListener)
return lexer
}
createParser (input: string) {
const lexer = this.createLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new SelectStatementParser(tokens);
parser.removeErrorListeners(); // 移除 Antlr4 內建的 ErrorListener
parser.addErrorListener(this._errorListener);
return parser
}
parse (sql: string) {
const parser = this.createParser(sql)
const parseTree = parser.selectStatement();
console.log(this._errorListener.parserErrors);
return {
parseTree,
errors: this._errorListener.parserErrors,
};
}
}
// 試一下效果
const selectParser = new SelectParser();
const { errors } = selectParser.parse('SELECT id FRO');
console.log(errors);
列印結果
這樣我們就獲取到了文字中的語法錯誤出現的位置,以及錯誤資訊。
到此為止上文中遺留的第一個問題就已經差不多解決了,我們只需要在合適的時機將編輯器的內容進行解析,拿到錯誤資訊並且通過 editor.setModelMarkers
這個 API 讓錯誤的位置飄紅就大功告成了。
對於自動補全功能,Antlr4 並沒有直接提供,但是社群已經有了比較優秀的解決方案 - antlr-c3 。它的作用是根據Antlr4 Parser 的解析結果,分析指定位置填哪些詞法/語法規則是合法的。
antlr4-c3 的使用方式比較簡單。
import { CodeCompletionCore } from "antlr4-c3";
// 這裡 parser 是 parser 範例
let core = new CodeCompletionCore(parser);
// tokenIndex 是想要自動補全的位置,對應由編輯器的遊標位置轉換而來
// parserContext 則是解析完之後的返回的 ParserTree 或者 ParserTree 的子節點(傳入子節點可以更高效)
let candidates = core.collectCandidates(tokenIndex, parserContext);
那麼結合上文中寫的 SelectParser,程式碼應該是這樣
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
/**
* input 源文字
* caretPosition 編輯器遊標位置
*/
function getSuggestions(input: string, caretPosition) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
const parserContext = parserIns.selectStatement();
// 虛擬碼
const tokenIndex = caretPosition2TokenIndex(caretPosition)
let candidates = core.collectCandidates(tokenIndex, parserContext);
}
core.collectCandidates
的返回值的資料型別如下
interface CandidatesCollection {
tokens: Map<number, TokenList>;
rules: Map<number, CandidateRule>;
}
tokens 對應的是詞法規則提示,比如關鍵字等,rules 對應的是語法規則,比如上述語法檔案中的 columnPath
和 tablePath
等。
需要注意的是,antlr4-c3 預設不收集語法規則,需要我們手動設定需要收集的語法規則
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
// 設定需要收集 tablePath 和 columnPath
這樣我們就收集到了在指定位置的可以填什麼。接下來我們需要將結果進行轉換成我們需要的資料結果
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
/**
* input 源文字
* caretPosition 編輯器遊標位置
*/
export function getSuggestions(input: string, caretPosition?: any) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
const parserContext = parserIns.selectStatement();
const tokenIndex = caretPosition2TokenIndex(caretPosition);
let candidates = core.collectCandidates(tokenIndex, parserContext);
const rule = [];
const keywords = []
for (let candidate of candidates.rules) {
const [ruleType] = candidate;
let syntaxContextType;
switch (ruleType) {
case SelectStatementParser.RULE_tablePath: {
syntaxContextType = 'table';
break;
}
case SelectStatementParser.RULE_columnPath: {
syntaxContextType = 'column';
break;
}
default:
break;
}
if (syntaxContextType) {
rule.push(syntaxContextType)
}
}
for (let candidate of candidates.tokens) {
const symbolicName = parserIns.vocabulary.getSymbolicName(candidate[0]);
const displayName = parserIns.vocabulary.getDisplayName(candidate[0]);
if(symbolicName && symbolicName.startsWith('KW_')) {
const keyword = displayName.startsWith("'") && displayName.endsWith("'")
? displayName.slice(1, -1)
: displayName
keywords.push(keyword);
}
}
console.log('===== suggest keywords: ',keywords);
console.log('===== suggest rules:', rule);
}
這樣我們就拿到了要提示的關鍵字和語法規則。關鍵字可以直接用於生成自動補全項,語法規則可以用於提示表名、欄位名等。
在這一節中,我們已經瞭解了,如何使用 Antlr4 和 antlr4-c3 來實現更加智慧的飄紅提示以及自動補全功能。
這一部分功能,在 monaco-sql-languages 中通過引入數棧前端團隊自研的開源專案 dt-sql-parser 實現。
前文中提到的 worker 檔案也正是用於執行 sql parser,因為dt-sql-parser 的解析可能會比較耗時,為了避免用項使用者互動,將 sql parser 放到 web worker 中執行顯然是更明智的選擇。
總的來說
三個功能大部分都可以通過 MonacoEditor 內建的 API 來實現,只是關鍵的語法解析功能需要使用 Antlr4 實現。整體上來說大部分的工作在編寫 Antlr4 的語法檔案以及方案整合上面。
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star