前言:DSL -- 领域特定语言
我们知道,TypeScript 语言的 类型定义系统 是 JavaScript 语言的超集,编码中的超集内容在编译后会被完全转化为JS代码。JavaScript 是一门通用编程语言 (General Purpose Language,简称GPL),即对图灵机在现实世界中的模拟,TypeScript 也是一门语言,拥有比JavaScript更完备的静态检查能力,但其 类型定义系统 这部分超集本身不满足图灵完备的标准,所以,TypeScript 核心的超集部分类型系统实则是一个拥有着自举编译器的外部领域特定语言(Domain-Specific Language,以下简称DSL)
看到这里,你已经知道什么是DSL了,即:关注于特定领域的编程语言。
仔细想想,工作中还遇到过哪些DSL?
太多太多,如:MarkDown、HTML、CSS、SQL......
DSL 能解决什么问题?
MarkDown文档编辑语言通过形如关键字的简单语法来规范一套独特的编程语言,很大程度上降低了纯文档类型Web页面的编写难度,让纯文本内容与Web系统开发彻底解耦。即使是完全不懂得HTML编码技术的使用者,也能快速产出具有良好排版风格的文档。
笔者认为,MarkDown语言最直观的价值则是将文档编辑和排版这一枯燥的编码需求交给产品经理来写,减轻了咱们前端程序员的负担。
还有什么能减轻咱们的负担?
之前我们讲到图形化低代码编程,实际上也是创建编程语言的一种方法,只是这样的”语言“由编码变为了可视化图形交互,大伙可以看看笔者的这篇低代码图形化编辑工具介绍:速览—低代码编辑器Blocky。
1、目标语言与语法设计
1.1、选定编译结果的编程语言
我们选择常见的通用编程语言(GPL)来作为DSL编译后的结果语言,如:Java、Python、C++、JavaScript,也可以是汇编语言。本文选择使用 JavaScript( ES6 标准)
1.2、语法设计
我们设计如下一套语法:
DSL名称:ArronLang
名称 | 语法 | 功能 | 编译结果 |
---|---|---|---|
条件执行语法 | if A then B | 表达式 A 为 true 时执行方法B | if(A){B} |
强等于判断表达式 | a eq x | 判断变量 a 是否严格等于 数字 x | a === x |
... | ... | ... | ... |
表中对“条件执行语法”的设计,语义化了扩充一套完整的DSL语法 我们可以参照表中的设计方式,从“语法”到“编译结果”衍生构思,设计更多常见语法来满足编程需求。
在后文中,我们会详细介绍 ArronLang 语言的”条件执行语法“编译方式,以此来描述一个DSL语法 从0到1 的全过程。
2、DSL编译为通用编程语言
常见地,我们使用抽象代码树的词法分析、语法分析、转换和编译四个过程来将DSL转化为目标语言。
2.1、词法分析:生成 token 对象数组。
词法分析将读取字符串形式的代码,将代码按照规则解析、生成由一个个 token 组成的 tokens 数组(令牌流),同时,它会移除空白、注释等。在代码中拆解出 token 对象的常用步骤如下:
- 确定 token 类型,如数字、字符串、关键词、变量等
- 确定 token 的匹配方法:tokenizer 函数。函数读取代码时,按照代码字符串中字符的下标递增进行迭代,递归执行 tokenizer 函数,根据 token 类型,函数以对应的正则表达式、强等于等方式匹配字符。
- 生成 token 对象:token 对象的属性常包括 token 的类型和代码内容。根据实际需要,token 对象也可以携带自身在编辑器中的坐标等辅助信息。
一段自定义语法规则的代码“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]
//匹配空格,并删去空格
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
}
//......
//可使用正则匹配数字,使用硬编码比较匹配特殊符号等其他类型的token
//其他匹配类型,略
throw new TypeError('I dont know what this character is: ' + char)
}
return tokens
}
复制代码
2.2、语法分析:生成抽象代码树(以下简称AST)
语法分析将每个 token 对象按照一定形式解析,形成树形结构,树的每一层结构称为节点,节点们共同作用于程序代码的静态分析,同时验证语法,抛出语法错误信息。
2.2.1 节点构造
语义本身就代表了一个值的节点是字面量节点,在树形结构担任“叶子”角色。由于每一个被解析出的 token 都携带 type(类型)属性,我们很容易通过type属性匹配得到对应的字面量节点。
如:type属性为“string”的 token,其本身的语义就代表了一个 string 类型值,作为叶子,在树结构中没有其他子节点可被其包含,因此可以由其生成一个字面量节点,为其设置 type 属性为“StringLiteral”,义为 string 类型字面量。字符串、布尔类型值、正则表达式等亦然。详细的节点命名可参照 AST 对象文档。
如:if 语句作为枝干节点,存在两个必要的属性:test、consequent,这两个属性作为if枝干节点的叶子存在。 1.test 属性是条件表达式。 2.consequent 属性是条件为 true 时的执行语句,通常是一个块状域节点。
string叶子节点和 if 语句节点的树形构建过程如下:
function parser(tokens) {
let current = 0
function walk() {
let token = tokens[current]
// 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 {
throw new TypeError('缺少条件')
}
if (token && token.type === 'Identifier' && token.value === 'then') {
node.consequent.push(walk())
token = tokens[current]
} else {
throw new TypeError('缺少关键词: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 {
throw new TypeError(`错误节点:${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',
}
}
throw new TypeError(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 构建 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
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.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',
}
)
})
</script>
</body>
</html>
复制代码
至此,你已经完成了一套DSL的设计以及对应的编译器定制,赶紧打开HTML文件编写你的DSL吧。
3.3、更多尝试
Monaco-Editor官方文档戳这里:Monaco-Editor官方文档
开发框架引入Monaco-Editor的方式戳这里:
4、结语
希望 DSL 能为你日后工作提供解决思路,解决业务难题。
从0到1系列已经写到第五辑了,举例笔者参加工作也刚好一年了,希望新的一个工作年里能努力学习,多多产出文章,搬砖功夫更上一层楼。
国庆之际,祝祖国生日快乐!!