什么是DSL
“在构建后台系统的过程中,我们常常会面对高度重复的页面和逻辑……有没有办法通过‘描述页面’而非‘编写页面’来快速生成?这正是 DSL 所能带来的价值。”
DSL 全称是 Domain-Specific Language(领域特定语言),指的是针对特定应用领域设计的编程语言或语法。 为什么叫领域特定语言呢?因为它专注于某一领域的问题,不像通用编程语言(如 Java、JS)那样面向所有编程任务。语法简洁、易读,更贴合业务逻辑或特定需求,常用于配置、描述、建模等场景。虽然听着有点陌生,但是我们基本都或多或少的用过一些。比如SQL就是一种用于数据查询的DSL,正则表达式可以看做是一种用于字符串匹配的DSL,还有我们最熟悉的CSS 也是一种 DSL,因为它也符合DSL的特点,只用于网页样式描述,你不能用 CSS 编写算法、控制流程、操作数据等,它只能描述样式。
DSL的设计与实现
如何设计一个DSL
一、首先第一步需求分析,明确目标:你为什么需要 DSL?
通常我们在写前端项目时,遇到相似的页面或者功能,大多数情况下就是新建一个页面文件或者一个方法,复制一份过来再进行修改达到目标。再到项目维度上,遇到类似的项目,比如大多数后台管理系统,就把以前的项目拉过来进行重构修改实现新的需求。这样虽然好像也没啥大问题,但是好像做了好多重复性的工作,对我们的技术提升没有什么帮助。
这时候可能就想到能不能通过一种通用的方案,来把这种相似性的重复工作沉淀下来,专注于新的技术需求开发。这些相同的重复性工作都能通过相似的需求来描述出来,既然能通过语言描述出来,那么也能通过DSL描述出来。
比如像我们的前端页面和项目,好多后台管理系统的页面其实都差不多,大约有80%的重复性,可能有20%的新的需求。像一个后台管理页面,上面是搜索栏,下面是搜索结果,一般通过表格展示,这些都能通过通用的一句话来描述,那么肯定也能通过DSL来描述。这种 DSL 与“低代码平台”的思想是一致的,前者更轻量灵活,适用于快速定制场景。
二、设计语法:DSL 应该长什么样?
通过什么来描述这种需求呢?首先我们可能会想到可以用一个json对象来描述
{
"search":{
"name":"input",
"address":"select"
},
"table":{
"name":"string",
"address":"string"
}
}
好像通过上面的json对象,我们写个js方法就能生成所需的页面组件来。
但是json它是DSL语言吗?好像不是,首先它是一种数据格式,不是一种语言,其次它没有“针对某个领域的问题解决语义”,它是通用的。它不具备 DSL 的特征,比如语义层次、特定关键字、领域相关规则等。 但是它好像又可以达到我们的要求,这时候我们就想能不能把json设计成DSL。
虽然 JSON 本身不是 DSL,但我们可以通过 定义固定结构、字段语义和规则,让它承载 DSL 的语义。JSON Schema 正是一个例子 —— 它用 JSON 语法定义了验证规则的语义,比如 type, properties, required 等,这些字段不是数据本身,而是描述数据的“规则”。
所以我们可以用JSON Schema来描述我们的需求,比如:
module.exports = {
model: 'dashboard',
name: '电商系统',
menu: [{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true
}
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200
},
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
}
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200
},
searchOption: {
comType: 'select',
enumList: [{
label: '全部',
value: -99
}, {
label: '¥39.9',
value: 39.9
}, {
label: '¥199',
value: 199
}, {
label: '¥699',
value: 699
}]
}
},
inventory: {
type: 'number',
label: '库存',
tableOption: {
width: 200
},
searchOption: {
comType: 'input'
}
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
searchOption: {
comType: 'dateRange'
}
}
}
},
tableConfig: {
headerButtons: [{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true
}],
rowButtons: [{
label: '修改',
eventKey: 'showComponent',
type: 'warning'
}, {
label: '删除',
eventKey: 'remove',
eventOption: {
params: {
product_id: 'schema::product_id'
}
},
type: 'danger',
}]
}
}
}, {
key: 'order',
name: '订单管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
}
}, {
key: 'client',
name: '客户管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
}
}]
}
上面的JSON schema就可以描述我们的需求,它描述了一个通用的电商系统,有商品管理模块,订单管理模块,客户管理模块。商品管理模块就描述了一个通用的后台管理页面,上面搜索栏,下面表格展示结果。 当然了一个后台管理系统没有那么简单,不同的系统也会有差异。所以我们可以使用面向对象的思想,将某一类通用的模块设计为一个基类,再通过基类继承派生出不同的系统DSL。 将上面的通用电商系统的模块设计为基类DSL,再通过继承派生出不同系统特定的DSL,比如下面就是通过通用电商系统派生出的pdd电商系统的DSL。
继承与扩展 DSL
通过key匹配到相同的模块可以对它进行重载满足系统的差异性,比如通过key: 'product'将商品管理模块的名字重载为商品管理(拼多多)。没有匹配到key就是新增的模块。
module.exports = {
name: '拼多多',
desc: '拼多多电商系统',
homePage: '/schema?proj_key=pdd&key=product',
menu: [{
key: 'product',
name: '商品管理(拼多多)'
}, {
key: 'client',
name: '客户管理(拼多多)',
moduleType: 'schema',
schemaConfig: {
api: '/api/client',
schema: {}
}
}, {
key: 'data',
name: '数据分析',
menuType: 'module',
moduleType: 'sider',
siderConfig: {
menu: [{
key: 'categories',
name: '分类数据',
menuType: 'group',
subMenu: [{
key: 'category-1',
name: '一级分类',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo'
}
}, {
key: 'category-2',
name: '二级分类',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com'
}
}, {
key: 'tags',
name: '标签',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/client',
schema: {}
}
}]
}, {
key: 'analysis',
name: '电商罗盘',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo'
}
}, {
key: 'sider-search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com'
}
}]
}
}, {
key: 'search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com'
}
}]
}
那么如何使用这个DSL生产所需页面呢?这这时候就需要写一个程序或者方法来编译解释我们的DSL了。
三、实现解析器(解释/编译)
我们可以用js写一个程序来读取我们的DSL配置
const _ = require('lodash');
const glob = require('glob');
const path = require('path');
const { sep } = path;
const projectExtendModel = (model, project) => {
return _.mergeWith({}, model, project, (modelValue, projValue) => {
// 处理数组合并的特殊情况
if (Array.isArray(modelValue) && Array.isArray(projValue)) {
let result = [];
//因为project继承model,所以要处理修改和新增内容的情况
//project有的键值,model也有 => 修改(重载)
//project有的键值,model没有 => 新增(拓展)
//model有的键值,project没有 => 保留(继承)
//处理修改和保留
for (let i = 0; i < modelValue.length; ++i) {
let modelItem = modelValue[i];
const projItem = projValue.find(projItem => projItem.key === modelItem.key);
//project有的键值,model也有,则递归调用projectExtendModel方法覆盖修改
result.push(projItem ? projectExtendModel(modelItem, projItem) : modelItem);
}
//处理新增
for (let i = 0; i < projValue.length; ++i) {
const projItem = projValue[i];
const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key);
//project有的键值,model没有,则直接push到结果中
if (!modelItem) {
result.push(projItem);
}
}
return result;
}
})
}
/**
* 解析model配置,并返回组织且继承后的数据结构
* [{
* model:${model}, // 业务模型名称
* project:{
* proj1:${proj1},
* proj2:${proj2},
* },...]
* @param {*} app
*/
module.exports = (app) => {
const modelList = [];
//遍历当前文件夹,构造模型数据结构,挂载到modelList上
const modelPath = path.resolve(app.baseDir, `.${sep}model`);
const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
if (file.indexOf('index.js') > -1) { return; } //排除index.js文件
//区分配置类型(model/project)
//path.sep 在 Windows 是 \,在 Linux/macOS 是 /
const normalizedFile = file.split(path.sep).join('/');//将路径统一成类 Unix 格式
const type = normalizedFile.indexOf('/project/') > -1 ? 'project' : 'model';
if (type === 'project') {
const modelKey = file.match(/\/model\/(.*?)\/project/)?.[1];
const projKey = file.match(/\/project\/(.*?)\.js/)?.[1];
let modelItem = modelList.find(item => item.model?.key === modelKey);
if (!modelItem) { //初始化model数据结构
modelItem = {};
modelList.push(modelItem);
}
if (!modelItem.project) { //初始化project数据结构
modelItem.project = {};
}
modelItem.project[projKey] = require(path.resolve(file));
modelItem.project[projKey].key = projKey; //注入projectKey
modelItem.project[projKey].modelKey = modelKey; //注入modelKey
}
if (type === 'model') {
const modelKey = file.match(/\/model\/(.*?)\/model\.js/)?.[1];
let modelItem = modelList.find(item => item.model?.key === modelKey);
if (!modelItem) { //初始化model数据结构
modelItem = {};
modelList.push(modelItem);
}
modelItem.model = require(path.resolve(file));
modelItem.model.key = modelKey; //注入modelKey
}
});
//数据进一步整理: project => 继承model
modelList.forEach(item => {
const { model, project } = item;
for (const key in project) {
project[key] = projectExtendModel(model, project[key]);
}
})
return modelList;
}
上述代码就读取了我们的DSL配置生成了一份清晰可用的数组数据结构,我们可以通过node服务接口返回这个数组供前端调用,动态生成我们所需的模版页面。
四、DSL配置详解
举个例子解释一下DSL配置如何解析渲染为一个模版页
{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true
}
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200
},
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
}
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200
},
searchOption: {
comType: 'select',
enumList: [{
label: '全部',
value: -99
}, {
label: '¥39.9',
value: 39.9
}, {
label: '¥199',
value: 199
}, {
label: '¥699',
value: 699
}]
}
},
inventory: {
type: 'number',
label: '库存',
tableOption: {
width: 200
},
searchOption: {
comType: 'input'
}
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
searchOption: {
comType: 'dateRange'
}
}
}
},
tableConfig: {
headerButtons: [{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true
}],
rowButtons: [{
label: '修改',
eventKey: 'showComponent',
type: 'warning'
}, {
label: '删除',
eventKey: 'remove',
eventOption: {
params: {
product_id: 'schema::product_id'
}
},
type: 'danger',
}]
}
}
}
上述代码为商品管理模块的DSL,通过key: 'product'可以在不同项目中对商品管理模块进行重载。 schemaConfig为商品管理模块的具体配置。其中api为此模块的数据源API,遵循RESTFUL规范。 schema为此模块的数据结构,properties中每一个属性都对应一个数据,tableOption决定这个数据是否在表格中展现以及展现方式。searchOption决定其在搜索栏的展示方式。如果配置页需要有其他模块,增加其对应的option配置即可。这样通过一个属性配置就可以决定其在整个页面的渲染方式。tableConfig决定表格在配置页的展现方式,searchConfig决定搜索栏在配置页的展示方式,同上如果有其他模块,增加其对应的config配置即可。
通过properties配置同样也能生成数据库表,这样通过一个DSL模型即可通用描述前端和后端,帮助我们完成大量重复性工作,提高开发效率,实现我们的初衷。
DSL渲染的优势
- 高复用:一套 DSL 可以支持多个页面结构
- 低代码 / 配置驱动:业务人员或其他开发可以只写 DSL,前端框架自动生成页面
- 易维护:结构清晰、修改简单,不用动大量前端代码
- 易扩展:通过扩展字段,可以支持自定义组件、事件、权限控制等
小结
通过 DSL 配置,我们不仅减少了重复性的页面开发工作,更将前后端建模方式统一,大大提高了系统的灵活性和可维护性。未来,我们还可以探索将 DSL 进一步扩展为“跨平台 UI 渲染”的核心基础,比如输出 Vue/React/小程序等多端组件。
注:本文灵感来自抖音“哲玄前端”《大前端全栈实践》