领域模型DSL的设计与实现

220 阅读9分钟

什么是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/小程序等多端组件。

注:本文灵感来自抖音“哲玄前端”《大前端全栈实践》