30分钟,定制属于你的编程语言
前言:DSL -- 领域特定语言
嘿,伙计们!今天我们要聊一个特别酷的话题 - DSL(领域特定语言)。别被这个名字吓到,它其实就像是一个"专治各种不服"的编程语言。
让我们先来看看 TypeScript 这个"大明星"。它的类型系统就像是 JavaScript 的"超能力套装",虽然看起来很酷,但实际上它并不满足图灵完备的标准。所以,TypeScript 的类型系统其实就是一个带着自举编译器的外部 DSL。是不是感觉一下子就接地气多了?
DSL 与 GPL 的区别
想象一下,DSL 和 GPL 就像是"专科医生"和"全科医生"的区别:
应用范围
- GPL: 就像全科医生,啥病都能看DSL: 就像专科医生,只治特定领域的"病"
表达能力
- GPL: 十八般武艺样样精通DSL: 一招鲜吃遍天
学习曲线
- GPL: 爬珠穆朗玛峰DSL: 爬家门口的小山坡
DSL 的分类
DSL 家族有两个主要分支:
内部 DSL
- 就像是在别人家房子里装修代表: jQuery、RSpec优点: 省心省力,还能蹭别人的基础设施缺点: 装修风格受限于原房子
外部 DSL
- 就像自己盖房子代表: SQL、HTML优点: 想怎么盖就怎么盖缺点: 从地基到装修都得自己来
DSL 的优缺点
优点(为什么选择 DSL):
- 开发效率蹭蹭往上涨学习成本直线下降代码可读性堪比小说错误率低到令人发指
缺点(为什么有时候不选 DSL):
- 维护成本可能让你怀疑人生性能开销可能让你钱包哭泣调试难度堪比解谜游戏工具支持可能让你想砸键盘
看到这里,你是不是已经对 DSL 有了一个清晰的认识?没错,它就是那个专注于特定领域的编程语言。
仔细想想,你每天都在和哪些 DSL 打交道?
太多了!比如 MarkDown(写文档必备)、HTML(网页骨架)、CSS(网页美容师)、SQL(数据库管家)......
DSL 能解决什么问题?
MarkDown 就像是一个神奇的"文档翻译官",它能把简单的文本变成漂亮的网页。最棒的是,它让产品经理也能写出漂亮的文档,再也不用求着前端工程师帮忙排版了。
说到减轻负担,还记得我们之前聊过的低代码编程吗?它就像是把编程变成了搭积木,让编程变得像玩游戏一样有趣。感兴趣的话,可以看看这篇介绍:速览—低代码编辑器Blocky。
1、目标语言与语法设计
1.1、选定编译结果的编程语言
选择目标语言就像选择结婚对象,要慎重!我们有很多选择:Java、Python、C++、JavaScript,甚至汇编语言。本文我们选择 JavaScript(ES6 标准),因为它:
- 应用场景广到没朋友工具链丰富到挑花眼性能表现好到让人感动语法简单到让人想哭
1.2、语法设计
让我们来设计一个叫 ArronLang 的 DSL:
名称 | 语法 | 功能 | 编译结果 |
---|---|---|---|
条件执行语法 | if A then B | 当 A 为真时执行 B | if(A){B} |
强等于判断表达式 | a eq x | 判断 a 是否严格等于 x | a === x |
循环语法 | repeat N times B | 重复执行 B N次 | for(let i=0;i<N;i++){B} |
函数定义 | def name(args) do B | 定义函数 name | function name(args){B} |
变量声明 | let x = y | 声明变量 x | let x = y |
表中对"条件执行语法"的设计,语义化了扩充一套完整的DSL语法。我们可以参照表中的设计方式,从"语法"到"编译结果"衍生构思,设计更多常见语法来满足编程需求。
在后文中,我们会详细介绍 ArronLang 语言的"条件执行语法"编译方式,以此来描述一个DSL语法从0到1的全过程。
2、DSL编译为通用编程语言
常见地,我们使用抽象代码树的词法分析、语法分析、转换和编译四个过程来将DSL转化为目标语言。
2.1、词法分析:生成 token 对象数组
词法分析将读取字符串形式的代码,将代码按照规则解析、生成由一个个 token 组成的 tokens 数组(令牌流),同时,它会移除空白、注释等。在代码中拆解出 token 对象的常用步骤如下:
- 确定 token 类型,如数字、字符串、关键词、变量等确定 token 的匹配方法:tokenizer 函数。函数读取代码时,按照代码字符串中字符的下标递增进行迭代,递归执行 tokenizer 函数,根据 token 类型,函数以对应的正则表达式、强等于等方式匹配字符。生成 token 对象:token 对象的属性常包括 token 的类型和代码内容。根据实际需要,token 对象也可以携带自身在编辑器中的坐标等辅助信息。
2.1.1 Token 类型定义
const TokenType = { // 关键字 KEYWORD: 'KEYWORD', // 标识符 IDENTIFIER: 'IDENTIFIER', // 数字 NUMBER: 'NUMBER', // 字符串 STRING: 'STRING', // 运算符 OPERATOR: 'OPERATOR', // 分隔符 DELIMITER: 'DELIMITER', // 注释 COMMENT: 'COMMENT', // 空白 WHITESPACE: 'WHITESPACE', // 行尾 EOL: 'EOL', // 文件结束 EOF: 'EOF'}
一段自定义语法规则的代码"if A do B"转化而成的 tokens 令牌流如下:
let tokens = [ { token: "if", type: "Identifier", }, { token: "A", type: "identifier", }, { token: "then", type: "identifier", }, { token: "B", type: "identifier", },];
相对应的词法分析递归函数如下:
function tokenizer(input) { let current = 0 let tokens = [] while (current < input.length) { let char = input[current] // 匹配注释 if (char === '/' && input[current + 1] === '/') { let value = '' char = input[++current] while (char !== '\n' && current < input.length) { value += char char = input[++current] } tokens.push({ type: 'Comment', value }) continue } // 匹配数字 const NUMBERS = /[0-9]/ if (NUMBERS.test(char)) { let value = '' while (NUMBERS.test(char)) { value += char char = input[++current] } tokens.push({ type: 'Number', value: Number(value) }) continue } //匹配空格,并删去空格 const WHITESPACE = /\s/ if (WHITESPACE.test(char)) { current++ continue } const LETTERS = /[a-z]/i //匹配表达式或关键词 if (LETTERS.test(char)) { let value = '' while (char !== undefined && LETTERS.test(char)) { value += char char = input[++current] } tokens.push({ type: 'Identifier', value }) continue } //匹配字符串 if (char === '"') { let value = '' char = input[++current] while (char !== '"') { value += char char = input[++current] } char = input[++current] tokens.push({ type: 'string', value }) continue } // 匹配运算符 const OPERATORS = /[+\-*/=<>!&|]/ if (OPERATORS.test(char)) { let value = '' while (OPERATORS.test(char)) { value += char char = input[++current] } tokens.push({ type: 'Operator', value }) continue } // 匹配分隔符 const DELIMITERS = /[(){}[\],;]/ if (DELIMITERS.test(char)) { tokens.push({ type: 'Delimiter', value: char }) current++ continue } throw new TypeError('I dont know what this character is: ' + char) } return tokens}
2.2、语法分析:生成抽象代码树(以下简称AST)
语法分析将每个 token 对象按照一定形式解析,形成树形结构,树的每一层结构称为节点,节点们共同作用于程序代码的静态分析,同时验证语法,抛出语法错误信息。
2.2.1 AST 节点类型定义
const NodeType = { // 程序 Program: 'Program', // 函数声明 FunctionDeclaration: 'FunctionDeclaration', // 变量声明 VariableDeclaration: 'VariableDeclaration', // 表达式语句 ExpressionStatement: 'ExpressionStatement', // 块语句 BlockStatement: 'BlockStatement', // If语句 IfStatement: 'IfStatement', // While语句 WhileStatement: 'WhileStatement', // For语句 ForStatement: 'ForStatement', // 二元表达式 BinaryExpression: 'BinaryExpression', // 一元表达式 UnaryExpression: 'UnaryExpression', // 字面量 Literal: 'Literal', // 标识符 Identifier: 'Identifier', // 函数调用 CallExpression: 'CallExpression', // 成员表达式 MemberExpression: 'MemberExpression'}
2.2.2 节点构造
语义本身就代表了一个值的节点是字面量节点,在树形结构担任"叶子"角色。由于每一个被解析出的 token 都携带 type(类型)属性,我们很容易通过type属性匹配得到对应的字面量节点。
如:type属性为"string"的 token,其本身的语义就代表了一个 string 类型值,作为叶子,在树结构中没有其他子节点可被其包含,因此可以由其生成一个字面量节点,为其设置 type 属性为"StringLiteral",义为 string 类型字面量。字符串、布尔类型值、正则表达式等亦然。详细的节点命名可参照 AST 对象文档。
如:if 语句作为枝干节点,存在两个必要的属性:test、consequent,这两个属性作为if枝干节点的叶子存在:
- test 属性是条件表达式consequent 属性是条件为 true 时的执行语句,通常是一个块状域节点
string叶子节点和 if 语句节点的树形构建过程如下:
function parser(tokens) { let current = 0 // 错误处理 function throwError(message) { throw new SyntaxError(`Line ${current}: ${message}`) } function walk() { let token = tokens[current] // 错误处理 if (!token) { throwError('Unexpected end of input') } // string 类型值 字符串 叶子节点 if (token.type === 'string') { current++ return { type: 'StringLiteral', value: token.value, } } // if 语句 枝干节点 if (token.type === 'Identifier' && token.value === 'if') { let node = { type: 'IfStatement', name: 'if', test: [], consequent: [], } token = tokens[++current] if (token && token.type === 'Identifier' && token.value !== 'then') { node.test.push(walk()) token = tokens[current] } else { throwError('缺少条件') } if (token && token.type === 'Identifier' && token.value === 'then') { node.consequent.push(walk()) token = tokens[current] } else { throwError('缺少关键词:then') } return node } // then节点,会成为if节点的子节点 if (token.type === 'Identifier' && token.value === 'then') { let node = { type: 'BlockStatement', params: [], } token = tokens[++current] if (token && token.type === 'Identifier') { node.params.push(walk()) token = tokens[current] } else { throwError(`错误节点:${token.value}`) } return node } //假装匹配表达式A和B //在实际情况中,表达式较为复杂,如表达式 2 === 1 ,需要设计详细的表达式匹配规则 if (token.type === 'Identifier' && token.value === 'A') { current++ token = tokens[current] return { type: 'fake', value: 'A', } } if (token.type === 'Identifier' && token.value === 'B') { current++ token = tokens[current] return { type: 'fake', value: 'B', } } throwError(token.value) } let ast = { type: 'Program', body: [], } while (current < tokens.length) { ast.body.push(walk()) } return ast}
2.3、AST转换
在此步骤,我们把AST结构转为适合编译的形态,将参数或关键字写入树的节点中。为每种节点设计了不同类型的转换函数: visitor[type].enter,运用 traverseNode 函数深度遍历每一个节点进行转化。
const visitor = { StringLiteral: { enter(node, parent) { const expression = { type: 'StringLiteral', value: node.value, } parent._context.push(expression) }, }, fake: { enter(node, parent) { const expression = { type: 'FakeExpression', value: node.value, } parent._context.push(expression) }, }, BlockStatement: { enter(node, parent) { let expression = { type: 'BlockStatement', arguments: [], } node._context = expression.arguments parent._context.push(expression) }, }, IfStatement: { enter(node, parent) { console.log(node) let expression = { type: 'IfStatement', callee: { type: 'Identifier', name: 'if', }, arguments: [], } node._context = expression.arguments parent._context.push(expression) }, },}function traverser(ast) { function traverseArray(array, parent) { array.forEach((child) => { traverseNode(child, parent) }) } function traverseNode(node, parent) { let methods = visitor[node.type] if (methods && methods.enter) { methods.enter(node, parent) } switch (node.type) { case 'Program': traverseArray(node.body, node) break case 'BlockStatement': traverseArray(node.params, node) break case 'IfStatement': traverseArray(node.test, node) traverseArray(node.consequent, node) break //叶子节点,没有子节点,不进行转换 case 'StringLiteral': case 'fake': break default: throw new TypeError(node.type) } if (methods && methods.exit) { methods.exit(node, parent) } } traverseNode(ast, null)}function transformer(ast) { let newAst = { type: 'Program', body: [], } ast._context = newAst.body traverser(ast) return newAst}
2.4、编译AST,生成目标代码
在最后,我们解析转换后的代码树,编译成为JS代码。函数对每种类型的节点提供解析方法,从枝干节点开始,递归解析其子节点并返回编译内容。
function codeGenerator(node) { if (node) { switch (node.type) { //项目 case 'Program': return node.body.map(codeGenerator).join(';\n') //if语句节点 case 'IfStatement': let length = node.arguments.length console.log('node', node) return ( codeGenerator(node.callee) + '(' + node.arguments .slice(0, length - 1) .map(codeGenerator) .join(' ') + ')' + codeGenerator(node.arguments[length - 1]) ) //块状域 case 'BlockStatement': return '{' + node.arguments.map(codeGenerator) + '}' //关键字 case 'Identifier': return node.name //假表达式,用于编译本文中的A、B case 'FakeExpression': return node.value //字符串 case 'StringLiteral': return '"' + node.value + '"' default: throw new TypeError(node.type) } }}
2.5、执行过程及数据
//执行函数:function complieCode(code = 'if A then B') { //tokenizer(code) //parser(tokenizer(code)) //transformer(parser(tokenizer(code))) //codeGenerator(transformer(parser(tokenizer(code)))) return codeGenerator(transformer(parser(tokenizer(code))))}//过程代码如下://1.ArronLang 代码: if A then B//2.tokens令牌流const testToken = [ { type: 'Identifier', value: 'if' }, { type: 'Identifier', value: 'A' }, { type: 'Identifier', value: 'then' }, { type: 'Identifier', value: 'B' },]//3.tokens 构建 ASTconst testParse = { type: 'Program', body: [ { type: 'IfStatement', test: [ { type: 'fake', value: 'A', }, ], consequent: [ { type: 'BlockStatement', params: [ { type: 'fake', value: 'B', }, ], }, ], }, ],}//4.AST结构转换const testTansformer = { type: 'Program', body: [ { type: 'IfStatement', callee: { type: 'Identifier', name: 'if', }, arguments: [ { type: 'FakeExpression', value: 'A', }, { type: 'BlockStatement', arguments: [ { type: 'FakeExpression', value: 'B', }, ], }, ], }, ],}//5.目标代码(JS):if(A){B}
3、搭建功能完善的在线编译器
为了让自创的DSL具有更高的可用性,还应该为语法设计一套代码报错、代码提示与高亮规则。笔者习惯使用 vscode 进行编码,对 vscode 的编译器风格更加习惯,因此,本节介绍如何接入使用能最大程度模拟 vscode 操作风格的Web端编译器:Monaco-Editor。
3.1、安装Monaco-Editor
A. 下载安装monaco-editor
npm install monaco-editor
B. 我的安装目录在
C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor/
3.2、一切尽在代码中
<!DOCTYPE html><html> <head> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="Content-Type" content="text/html;charset=utf-8" /> </head> <body> <div id="container" style="width: 800px; height: 600px; border: 1px solid grey;" ></div> <script src="C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor//min//vs//loader.js"></script> <script type="module"> require.config({ paths: { vs:'C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor//min//vs', }, }) require(['vs/editor/editor.main'], function () { const LangId = 'ArronLang' // 注册编辑器语言 monaco.languages.register({ id: LangId }) // 定制高亮与提示 monaco.languages.setMonarchTokensProvider(LangId, { tokenizer: { root: [ [/\s*(if|then)\s*/, 'IfStatement'], [/\s*([A-Za-z0-9\_])\s*/, 'Expression'], ], }, keywords: ['if', 'then'], whitespace: [ [/[ \t\r\n]+/, 'white'], [/#(.*)/, 'comment', '@comment'], ], }) // 添加代码提示 monaco.languages.registerCompletionItemProvider(LangId, { provideCompletionItems: function(model, position) { const suggestions = [ { label: 'if', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'if ${1:condition} then ${2:action}', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: '条件执行语句' }, { label: 'then', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'then', documentation: 'then 关键字' } ] return { suggestions } } }) // 添加错误提示 monaco.languages.registerDiagnosticsAdapter(LangId, { getDiagnostics: function(model) { const text = model.getValue() const errors = [] // 简单的语法检查 if (!text.includes('then') && text.includes('if')) { errors.push({ severity: monaco.MarkerSeverity.Error, message: '缺少 then 关键字', startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: text.length }) } return errors } }) // 定制主题与样式 monaco.editor.defineTheme(LangId, { base: 'vs', inherit: true, rules: [ { token: 'IfStatement', foreground: '840095' }, { token: 'Expression', foreground: '0082FF' }, ], colors: { 'editorLineNumber.foreground': '#999999', }, }) let editor = monaco.editor.create( document.getElementById('container'), { value: 'if A then B', language: LangId, theme: LangId, fontSize: 15, fontWeight: 400, lineHeight: 25, letterSpacing: 1, automaticLayout: true, scrollBeyondLastLine: false, renderLineHighlight: 'none', minimap: { enabled: true }, suggestOnTriggerCharacters: true, quickSuggestions: true, parameterHints: { enabled: true } } ) }) </script> </body></html>
至此,你已经完成了一套DSL的设计以及对应的编译器定制,赶紧打开HTML文件编写你的DSL吧。
3.3、更多尝试
4、结语
希望 DSL 能为你日后工作提供解决思路,解决业务难题。
Arron 快要有一年没更文了,这一年真的变化了很多,不光是 Arron,还有整个编码世界。不知道大家有没有一种感觉,从前需要在掘金检索攻克的技术难题,如今敲给大模型就能得到答案,甚至全套的解决方案。掘金的海量文章和各站的技术文档,被大模型采样学习转化为更一针见血的Agent,输入自然语言就能获得整个世界。
这篇文章我使用了Cursor来帮忙,我让ChatGPT-4.1为我优化了诙谐幽默的行文风格,并补充了基础用例。我想了想,说不定Cursor直接就能写出这篇文章并创造一套更完整的DSL,我让它做这些小事,屈才了。