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 类型定义
javascript
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 令牌流如下:
javascript
let tokens = [
{
token: "if",
type: "Identifier",
},
{
token: "A",
type: "identifier",
},
{
token: "then",
type: "identifier",
},
{
token: "B",
type: "identifier",
},
];
相对应的词法分析递归函数如下:
javascript
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 节点类型定义
javascript
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 语句节点的树形构建过程如下:
javascript
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 函数深度遍历每一个节点进行转化。
javascript
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代码。函数对每种类型的节点提供解析方法,从枝干节点开始,递归解析其子节点并返回编译内容。
javascript
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、执行过程及数据
javascript
//执行函数:
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 构建 AST
const 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
bash
npm install monaco-editor
B. 我的安装目录在
ruby
C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor/
3.2、一切尽在代码中
html
<!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,我让它做这些小事,屈才了。