如何构造属于自己的 DSL —— PEG.js 使用指南
zhangxiang958 opened this issue · 0 comments
DSL (Domain Sepecific Language)是一种为特定领域设定的具有受限表达性的编程语言。
DSL 有外部 DSL 与内部 DSL 之分。对于内部 DSL,它是基于宿主语言之上的特殊 DSL,可以把内部 DSL 视作是对于特定任务的封装,比如 JQuery 对于 DOM 操作的链式风格就可以被称作一种内部 DSL。对于外部 DSL,它的实现成本比较大但更灵活强大,它是一种独立的编程语言,需要自己实现对于 DSL 的编译工具,类似 JSX 就是一种外部 DSL。
本篇文章所谈的 DSL 默认为外部 DSL。
PEG.js 介绍
外部 DSL 是可以非常灵活的,并且依照场景与业务领域的不同,设计出更切合需求的语法。就像 Less, Sass 这样的,可以在 CSS 语法之上建立嵌套之类的语法。但是这样的外部 DSL 实现需要自己完成编译的工作,成本比较大。而实际上我们可以借助 pegjs 这一个利器来减轻我们编译的工作。
peg.js 是 javascript 中一个简单易用的 parser 生成器,可以利用它轻松地构建出转化器(transformer),转译器(interpreters)或编译器(compilers)等工具。
安装
npm i pegjs
简单使用
安装了 pegjs 之后,可以直接在 js 文件中引用:
import { readFileSync } from 'fs';
import { join as pathJoin } from 'path';
import { generate } from 'pegjs';
// 引入编写的 peg 规则
const grammar = readFileSync(pathJoin(__dirname, 'grammar_schema.pegjs')).toString();
// 构造生成器
const parser = generate(grammar);
// 引入编写的 dsl 文件
const schema = readFileSync(pathJoin(__dirname, 's.schema')).toString();
// 解析
console.log(JSON.stringify(parser.parse(schema), null, 4));
PEG.js 实战
现在我们来设计一个 DSL。我们现在需要设计一个 schema 语法,通过定义数据库的表描述,生成对应的 dao 文件与 model 文件提高编码效率。
schema 语法通过描述一个 model,使用 @ 前缀描述主键,自增属性,和需要添加到日志表的字段,还有 # 符号后面跟着的是字段的注释:
model t_user_tag {
@primary
@autoIncrement
@log(anchor_tag_id)
id unsigned Int(11) # 自增主键
user_id unsigned BigInt(20) # 用户 id
unique_id unsigned BigInt(20) # 特殊 id
tag_id unsigned Int(11) # 标签 id,取叶子标签 id
creator? unsigned BigInt(20) = null # 创建者
create_time DateTime # 创建时间
updater? unsigned BigInt(20) # 更新者
update_time DateTime # 更新时间
}
定义的 schema 文件类似上面这样,接下来我们需要定义 pegjs 规则。pegjs 规则文件使用 .pegjs 后缀,pegjs 的语法非常简单,peg 从第一个定义的规则开始解析:
我们首先需要解析 schema 的 model 声明块:
Block = blocks:ModelBlock* {
return {
type: 'yiderschema',
version: '1.0.0',
blocks
}
}
Pegjs 使用花括号({}) 包裹的是 javascript 代码,可以看到第一个规则就是对 schema 进行解析并返回一个 json 数据。前面的 ‘Block =’ 只是对于这个规则的命名,每个规则都需要一个命名。
{
type: 'yiderschema',
version: '1.0.0',
blocks: ...
}
注意到返回的 blocks 字段与 ModelBlock 前面的 blocks 名字相同,其实含义就是按照 ModelBlock 解析得到的数据存放到 blocks 变量中,而 ModelBloock 后面的 * 号其实和正则里面的 * 含义类似,就是没有或者多个的意思。
接下来就是 ModelBlock 的解析:
ModelBlock =
_ 'model'
_ name:Identifier
_ '{' field:Field* '}'
_ {
return {
name,
field
}
}
前面的下划线 _ 其实也是我们定义的规则,意思是空白符,解析的规则是空格,tab 符换行符,数量也是 0 个以上(* 号)。
_ "whitespace" = [ \t\r\n]* // "whitespace" 是 _ 规则的别名
所以上面 Model 的解析规则就是经过若干个空白符后,先遇到 'model' 字符串,然后再经过若干个空白符,就是 model 块的名字,名字的解析规则就是 Identifier:
Identifier = $([a-z_])+
这里和正则表达式的模式是类似的,[] 表示字符组,匹配小写字母或下划线,数量是至少是一个(+ 号)。$ 是将匹配到的字符集合到一起而不是返回一个数组。
所以这里 ModelBlock 的匹配规则已经明了了,在名字匹配之后,遇过若干个空白符后与 '{' 之后,就会解析 Model 里面的 field 定义,并且 modelBlock 返回的是如下 json 数据:
{
name: 'modelName',
fileds: [...]
}
对于 model 里面每一个 field 字段的解析,解析规则如下:
Field =
_ isPrimary:Primary?
_ isAutoIncrement:AutoIncrement?
_ log:Log?
_ cloumn:Identifier optioanl:'?'?
_ isUnsigned:Unsigned?
_ type:DataType
_ typeLength:DataTypeLength?
_ defaultValue:DefaultValue?
_ comment:Comment?
_ {
return {
isPrimary: isPrimary ? true : false,
isAutoIncrement: isAutoIncrement ? true : false,
isUnsigned: isUnsigned ? true : false,
log,
type,
typeLength,
defaultValue,
comment,
optioanl: optioanl ? true : false,
cloumn
}
}
和 model 类似,field 也是先遇到若干个空白符(_)之后,尝试解析 @primary:
model t_ilive_anchor_tag {
@primary
@autoIncrement
@log(anchor_tag_id)
id unsigned Int(11) # 自增主键
规则如下,并且可以看到在 field 里面定义的规则 primary 的解析后面加上了 '?',类似正则,这里表示有或无的意思:
Primary = '@primary'
所以在返回信息里面,判断如果有匹配 '@primary' 则返回 true,否则为 flase:
_ {
isPrimary: isPrimary ? true : false,
...
}
对于 autoincrement,unsigned 的匹配是类似的:
AutoIncrement = '@autoIncrement'
Unsigned = "unsigned"
对于 field 的类型与类型长度解析,规则如下:
DataType = 'String' / 'Int' / 'BigInt' / 'DateTime' / 'Timestamp'
DataTypeLength = '('length:$(Integer)*')' {
return +length
}
Integer = [0-9]
DataType 的匹配 'String','Int','BigInt','DateTime','Timestamp' 这几种类型,其中 '/' 这个符号表示的是 '或' 的意思。
对于类型长度的匹配,需要在 DataType 后面匹配到 '(' 和数字和 ')',括号中匹配到的数字会存放到指定变量名中,这里是 length 变量。
对于 '@log(xxx)' 的解析,与 DataTypeLength 是类似的,也是前面需要匹配 '@log(' 这个字符串,然后将 '@log()' 中包裹的小写英文字符或小下划线匹配并存放到 logCloumn 变量中。
Log = '@log('logColumn:$([a-z_])*')' {
return logColumn
}
对于每个 field 的默认值的匹配,匹配规则如下:
DefaultValue = '=' _ value:$([^\r\n\t #])* {
return value
}
需要先在 field 定义后面跟着一个 '=' 符号,然后将后面非空白符换行符和 '#' 符号的字符匹配起来,数量是大于等于 0。这里需要排除 '#' 是因为 '#' 后面是注释的匹配,而注释的定义是在默认值之后。
注释的匹配规则如下,和 defaultValue 的匹配规则类似:
Comment = '#'comment:$([^\r\n\t])* {
return comment
}
到此,我们对于规则的定义已经写好了,根据前面 pegjs 简单使用的 demo 代码,我们可以解析定义的 schema 并返回得到对应的数据:
{
"type": "yiderschema",
"version": '1.0.0',
"blocks": [
{
"name": "t_ilive_anchor_tag",
"field": [
{
"isPrimary": true,
"isAutoIncrement": "@autoIncrement",
"isUnsigned": "unsigned",
"log": "anchor_tag_id",
"type": "Int",
"typeLength": 11,
"defaultValue": null,
"comment": " 自增主键",
"optioanl": false,
"cloumn": "id"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": "unsigned",
"log": null,
"type": "BigInt",
"typeLength": 20,
"defaultValue": null,
"comment": " 用户 id ",
"optioanl": false,
"cloumn": "user_id"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": "unsigned",
"log": null,
"type": "BigInt",
"typeLength": 20,
"defaultValue": null,
"comment": " 独特 id",
"optioanl": false,
"cloumn": "unique_id"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": "unsigned",
"log": null,
"type": "Int",
"typeLength": 11,
"defaultValue": null,
"comment": " 标签 id,取叶子标签 id",
"optioanl": false,
"cloumn": "tag_id"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": "unsigned",
"log": null,
"type": "BigInt",
"typeLength": 20,
"defaultValue": "null",
"comment": " 创建者",
"optioanl": true,
"cloumn": "creator"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": null,
"log": null,
"type": "DateTime",
"typeLength": null,
"defaultValue": null,
"comment": " 创建时间",
"optioanl": false,
"cloumn": "create_time"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": "unsigned",
"log": null,
"type": "BigInt",
"typeLength": 20,
"defaultValue": null,
"comment": " 更新者",
"optioanl": true,
"cloumn": "updater"
},
{
"isPrimary": false,
"isAutoIncrement": null,
"isUnsigned": null,
"log": null,
"type": "DateTime",
"typeLength": null,
"defaultValue": null,
"comment": " 更新时间",
"optioanl": false,
"cloumn": "update_time"
}
]
}
]
}
以上就是利用 pegjs 来构建自己定义的 DSL 语法的简单用法。
利用 PEG.js 可以做什么
pegjs 在构造 DSL 方面是一个利器,可以大大减轻我们在构造 DSL 时候的编写编译器的工作量。
我们在构造 DSL 之前需要想清楚构造 DSL 是不是真的需要一个 DSL,而且构造出 DSL 的效率是否真的提高了,否则开发人员在面对一个新的 DSL,需要学习成本与维护成本。
像上面的例子所说,构造出一个 DSL 或许不是一个最好的方式,也可以利用 pegjs 来解析 sql 文件,也能达到类似的效果。
面对需要输出多种语言的 SDK 来说,可以先定义一个通用的 DSL,来定义 SDK 的规范,然后根据对于这个 DSL 的解析数据,适配多种语言的语法即可。