个人学习笔记总结(三)关于DSL领域模型架构的设计与实现

140 阅读5分钟

出处:《哲玄课堂-大前端全栈实践》

ps: 本文为个人理解,不喜勿喷

DSL 简介

DSL(Domain Specific Language,领域特定语言)是针对某一领域,具有受限表达性的一种计算机程序设计语言。

为什么需要 DSL 以及适用场景

在日常开发某一领域的后台管理系统(例如电商系统),这些系统中或多或少都会有一些结构、功能高度相似的模块,当然也不排除会有一些定制化的功能,那么这就会产生重复的工作量,增加开发成本,因此基于DSL的特点可以通过实现简单的DSL语法,让业务去写修改、添加DSL模板,技术写DSL解析器,将DSL配置转换成js来渲染页面,进而减少重复的工作量,降低开发成本。

DSL 的特点

  • 针对性设计:仅解决特定领域的问题
  • 提高开发效率:通过DSL来抽象构建模型,抽取公共的[代码]减少重复的劳动;
  • 简单直观:提供统一标准化的配置,便于理解
  • 可维护性、可拓展性强:对于新的业务规则只需添加新的声明,而无需修改核心引擎

DSL 设计思路及实现

  1. 设计DSL需要思考的问题
  • 首要的就是如何设计DSL表达形式和你的领域模型
  • 如何解析DSL文件并根据情况生成相应配置数据
  • 如何根据相应的配置数据, 得到对应的系统站点
  1. 根据DSL生成站点的简易流程图

简易流程图.png

  1. 整体实现大致流程
  • DSL模板表达形式

首先我们要知道这份DSL配置是为了描述出怎样的一个页面,例如实现一个中后台系统的dashboard页,且页面结构如图所示

这里直接贴出我的DSL模板配置

{
  mode: 'dashboard', // 模板类型,不同模板类型对应不一样的模板数据结构
  name: '', // 名词
  desc: '', // 描述
  icon: '', // icon
  homePage: '', // 首页(项目配置)
  // 头部菜单
  menu: [{
    key: '',  // 菜单唯一描述
    name: '', //菜单名称
    menuType: '', // 枚举值,group / module
    // 当 menuType == group 时,可填(即当前菜单存在子菜单)
    subMenu: [{
      // 可递归 menuItem
    }, ...],

    // 当 menuType == module 时,可填
    moduleType: '', // 枚举值:sider / iframe / custom / schema

    // 当 moduleType == sider 时
    siderConfig: {
      menu: [{
        // 可递归 menuItem (除 moduleType == sider 外)
      }, ...]
    },
    // 当  moduleType == custom 时
    customConfig: {
      path: '', // 自定义路由路径
    },
    // 当  moduleType == schema 时 (常规列表页)
    schemaConfig: {
      api: '', // 数据源 API (遵循 RESTFUL 规范)
      schema: { // 板块数据结构
        type: 'object',
        properties: {
          key: {
            ...schema, // 标准 schema 配置
            type: '', // 字段类型
            label: '', // 字段的中文名
            // 字段在 table 中的相关配置
            tableOption: {
              ...elTableColumnConfig, // 标准 el-table-column 配置
              toFixed: 0, // 保留几位小数
              visible: true, // 默认为 true (false 时,表示不在表单中显示)
            },
          },
          ...
        },
        required: [], // 必填字段
      },
      tableConfig: {
        rowButtons: [{
          label: '', // 按钮中文名
          eventKey: '', // 按钮事件名
          eventOption: {
            // 当 eventKey === 'showComponent',
            comName: '', // 组件名称
            // 当 eventKey === 'remove'
            params: {
              // paramsKey = 参数的键值
              // rowValueKey = 参数值 (当格式为 schema::xxx 的时候,到 table 中找响应的字段)
              paramKey: rowValueKey
            }
          }, // 按钮事件具体配置
         ...elButtonConfig, // 标准 el-button 配置
        }, ...],
      }, // table 相关配置
    }
  }, ...]
}

然后根据上述DSL配置,我们就可以写出对应的领域模型,这里以电商系统为例,可以得到的领域模型如下所示

module.exports = {
  model: 'dashboard',
  name: '电商系统',
  menu: [
    {
      key: 'product',
      name: '商品管理',
      menuType: 'module',
      moduleType: 'schema',
      schemaConfig: {
        api: 'xxxxxx',
        schema: {
          type: 'object',
          properties: {
            product_id: {
              type: 'string',
              label: '商品ID',
              tableOption: {
                    ....
              }
            }
        }
      }
    },
    {
      key: 'order',
      name: '订单管理',
      menuType: 'module',
      moduleType: 'custom',
      customConfig: {
        path: '/todo'
      }
    },
    ...
  ]
}


  • 如何解析DSL文件并根据情况生成相应配置数据

上述我们已经得到了dsl模板配置以及根据模板配置所衍生出的领域模型,那么我们怎么根据这份配置通过继承、重载、新增的方式来得到我们的站点所需的项目配置数据呢?这就需要一个模板解析引擎来得到配置数据,模板引擎代码如下

const glob = require('glob')
const path = require('path')
const { sep } = path
const _ = require('lodash')

/**
 * project 继承 model 得到新数据
 * @param {*} project
 * @param {*} model
 */
function projectExtendModel(project, model) {
  // 合并 project ---> model

  return _.mergeWith({}, model, project, (modelValue, projectValue) => {
    // 处理数组合并的特殊情况
    if (Array.isArray(modelValue) && Array.isArray(projectValue)) {
      let result = []

      // 因为 project 继承 model,所以需要处理修改和新增内容的情况
      // project 存在的键值,如果 model 也存在,==> 修改(重载
      // project 存在的键值,如果 model 不存在,==> 新增
      // model 存在的键值,如果 project 不存在,==> 保留(继承

      // 处理修改
      for (let i = 0; i < modelValue.length; i++) {
        let modelItem = modelValue[i]

        const proejctItem = projectValue.find(item => item.key === modelItem.key)
        // project 有的键值,model 也有,则递归调用 projectExtendModel 覆盖修改
        result.push(proejctItem ? projectExtendModel(proejctItem, modelItem) : modelItem)
      }

      // 处理新增
      for (let i = 0; i < projectValue.length; i++) {
        // project 有的键值,model 没有,则新增
        if (!modelValue.find(item => item.key === projectValue[i].key)) {
          result.push(projectValue[i])
        }
      }

      return result
    }
  })
}

/**
 * 解析 model 配置,并返回组织且继承后的数据结构
 * [{
 *  model:${model},
 *  project: {
 *      project1Key: {},
 *      project2Key: {}
 *  }
 * } ...]
 */
module.exports = app => {
  const modelList = []

  // 遍历当前文件夹,构造模型数据结构,挂载到 modelList 上
  const modelPath = path.resolve(process.cwd(), `.${sep}model`)
  const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`))
  fileList.forEach(file => {
    if (file.indexOf('index.js') > -1) {
      return
    }

    // 区分配置类型 (model / project)
    const type = file.indexOf(`project`) > -1 ? 'project' : 'model'

    if (type === 'project') {
      const modelKey = file.match(/\/model\/(.*?)\/project/)?.[1]
      const projectKey = file.match(/\/project\/(.*?)\.js/)?.[1]
      let modelItem = modelList.find(item => item.model?.key === modelKey)
      if (!modelItem) {
        // 初始化 model 数据结构
        modelItem = {}
        modelList.push(modelItem)
      }
      if (!modelItem.project) {
        modelItem.project = {}
      }
      modelItem.project[projectKey] = require(path.resolve(file))
      modelItem.project[projectKey].key = projectKey // 注入 projectKey
      modelItem.project[projectKey].modelKey = modelKey // 注入 modelKey
    }

    if (type === 'model') {
      // 取的是 file 目录下 model 和 model.js 之间的字符串
      const modelKey = file.match(/\/model\/(.*?)\/model\.js/)?.[1]
      let modelItem = modelList.find(item => item.model?.key === modelKey)
      if (!modelItem) {
        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(project[key], model)
    }
  })

  return modelList
}

  • 如何根据相应的配置数据, 得到对应的系统站点

经过上述流程我们可以得到对应的项目的配置数据,这时候我们就需要书写对应的解析器来转换可渲染数据进而渲染页面,这里以一个通用页面为例,解析流程和所需解析器如图所示

解析流程图.png

至此DSL的设计和实践基本完成