DSL设计

DSL设计

备注引用: 抖音"哲玄前端"《大前端全栈实践》

为什么要有DSL?

中后台有大量重复的组件样式,例如头部菜单左侧菜单等通用组件,这些通用的可以通过一份配置去进行解析,然后渲染页面上,其他的个性化定义则再进行二次开发。例如下图所示:

针对这种通用的,即需要DSL来进行规范和渲染,也就是深蓝色的部分,定义SDL:模版配置 去渲染通用的内容。

所以为了生成一下类似的通用页面,需要DSL对组件进行描述

DSL设计

通过一份DSL配置,并解析DSL,渲染对应的内容,并且由于例如电商系统 menu A 和B menu都是相同的,那么这一份配置可以成为一个父类,利用面向对象的方式抽离,让子类继承。这样就可以最大的程度减少重复代码。

例如下图中的 电商系统 领域模型(基类)

DSL描述

将需要生成的页面通过DSL进行描述

javascript 复制代码
const config = {
  mode: "dashboard", // 模块类型, 不同模块类型对应不一样的模版数据结构
  name: "",
  desc: "",
  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 === 'iframe' 时
      iframeConfig: {
        path: "", // iframe地址
      },
      // 当 moduleType === 'custom' 时
      customConfig: {
        path: "", // 自定义路由路径
      },
      // 当 moduleType === 'schema' 时
      schemaConfig: {
        api: "", // 数据源API (遵循 RESTFUL规范)
        schema: {
          // 板块数据结构 JSON-Schema
          type: "object",
          properties: {
            key: {
              ...schema, // 标准 schema配置
              type: "", // 字段类型
              label: "", // 字段的中文名
            },
          },
        },
        tableConfig: {}, // table相关配置
        searchConfig: {}, // search-bar相关配置
        components: {}, // 模块组件
      },
    },
  ],
};

生成DSL

![](https://cdn.nlark.com/yuque/0/2024/png/35194051/1734771144458-1a9ba5a4-c8cb-4fe6-9311-9530751f5885.png)

创建model文件夹 用来集中处理 DSL配置和解析DSL

model.js 用于管理当前项目下公共的配置文件,

project文件夹管理各个对于分类的项目,

拿pdd举例子

pdd和taobao的相同的DSL配置卸载 buiness/model.js下,

可以理解成 pdd和taobao 在继承 model 父类对象下,再针对不同的项目进行个性化定制

目标 DSL的数据结构,需要参照规约的DSL格式,例如 dashboard

javascript 复制代码
[
  {
    // 每一个 project 对象中 公共的配置
   "model":{ 
    "model":"dashboard",
    "name": "项目名称",
    "key": "项目唯一标识",
    // 菜单配置项
    "menu": [
      {详见enum-文档}
    ],
   },
   "project":{
      "taobao":{
         "model":"dashboard",
          "name": "项目名称",
          "key": "项目唯一标识",
          // 菜单配置项
          "menu": [
            {详见DSL描述}
          ],
      },
     "jindong":{
        "model":"dashboard",
          "name": "项目名称",
          "key": "项目唯一标识",
          // 菜单配置项
          "menu": [
            {详见DSL描述}
          ],
     },
     "xxx其他项目":{}
   }
  },
  {
    其他分类
  }
]

按照淘宝举例 在文件中

javascript 复制代码
/**
 * 电商系统
 * 公共模块配置
 */
module.exports = {
  model: "dashboard",
  name: "电商系统",
  menu: [
    {
      key: "product",
      name: "商品管理",
      menuType: "module",
      moduleType: "custom",
      customConfig: {
        path: "/todo",
      },
    },
    {
      key: "order",
      name: "订单管理",
      menuType: "module",
      moduleType: "custom",
      customConfig: {
        path: "/todo",
      },
    },
    {
      key: "client",
      name: "客户管理",
      menuType: "module",
      moduleType: "custom",
      customConfig: {
        path: "/todo",
      },
    },
  ],
};

淘宝的其他配置

javascript 复制代码
module.exports = {
  name: "淘宝",
  desc: "淘宝电商系统",
  homePage: "",
  menu: [
    {
      key: "order",
      moduleType: "iframe",
      iframeConfig: {
        path: "http://www.baidu.com",
      },
    },
    {
      key: "operating",
      name: "运营活动",
      menuType: "module",
      moduleType: "sider",
      siderConfig: {
        menu: [
          {
            key: "coupon",
            name: "优惠券",
            menuType: "module",
            moduleType: "custom",
            customConfig: {
              path: "/todo",
            },
          },
          {
            key: "limited",
            name: "限量购",
            menuType: "module",
            moduleType: "custom",
            customConfig: {
              path: "/todo",
            },
          },
          {
            key: "festival",
            name: "节日活动",
            menuType: "module",
            moduleType: "custom",
            customConfig: {
              path: "/todo",
            },
          },
        ],
      },
    },
  ],
};

生成DSL:

  1. 将DSL文件格式化成约定的样子
  2. project 需要继承 model
javascript 复制代码
const path = require("path");
const { sep } = path;
const glob = require("glob");
const _ = require("lodash");

// project 继承 model 方法
const projectExtendModal = (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 (const modelItem of modelValue) {
        const projItem = projValue.find((proj) => proj.key === modelItem.key);
        result.push(
          projItem ? projectExtendModal(modelItem, projItem) : modelItem
        );
      }

      // 处理新增
      for (const projItem of projValue) {
        const modelItem = modelValue.find(
          (model) => model.key === projItem.key
        );
        if (!modelItem) {
          result.push(projItem);
        }
      }
      return result;
    }
  });
};

/**
 * 解析 model 配置,并返回组织且继承后的数据结构
 * [{
 *   model: ${model}
 *    project: {
 *     projKey1: ${proj1},
 *     projKey2: ${proj2},
 *   },...
 * }]
 */
module.exports = (app) => {
  const modelList = [];
  // 遍历当前文件夹, 构造数据模型结构, 挂载到 modelList 上
  const modelPath = path.resolve(app.businessPath, `.${sep}model`);
  const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`));

  fileList.forEach((file) => {
    // 1. 如果是本文件(index.js) 则需要跳过不执行
    if (file.includes("index.js")) return;
    // 2. 区分配置类型(model | project) project 为项目配置是一个文件夹也就是子类, model 为模型配置是一个单独的文件也就是父类
    const type = file.includes(`${sep}project${sep}`) ? "project" : "model";
    // 3. 如果是项目配置,则需要解析出父类和子类的key

    /**
     * type 为 project 这是一个子类
     * 1. 找到所以在 将 project 文件夹下的配置项文件
     * 2. 将文件名作为对象的key
     * 3. 将文件作为 这个 key 的 值
     * 形成的结构
     *  [
     *    {
     *      project: {
     *         // projKey(文件的名称)
     *         taobao: {
     *            // 文件的内容
     *            name,desc,key,homePage,menu:[xxx],
     *         }, ...
     *       }
     *    }
     *  ]
     */
    if (type === "project") {
      // 3.1 例如:/model/course/model.js 如果是类似这样的路径
      const modelKey = file.match(/\/model\/(.*?)\/project/)?.[1];
      // 3.2 例如: /model/buiness/project/pdd.js
      const projKey = file.match(/\/project\/(.*?)\.js/)?.[1];

      let modelItem = modelList.find((item) => item.model?.key === modelKey);
      // 3.3 如果不存在父类,则创建一个父类 默认是一个空对象
      if (!modelItem) {
        modelItem = {};
        modelList.push(modelItem);
      }
      // 3.4 如果不存在父类的配置,则创建一个父类的配置 默认是一个空对象
      if (!modelItem?.project) {
        modelItem.project = {};
      }
      // 3.5 将文件注入到子类的配置项中
      modelItem.project[projKey] = require(path.resolve(file));
      // 3.6 将 子类的文件名 作为子类配置项的 key
      modelItem.project[projKey].key = projKey;
    }

    // 4. 如果是模型配置也就是一个单独的文件 是父类
    /**
     * type 为 model 这是个父类 是每个项目文件夹下的model.js 文件中的配置
     * 形成的结构
     *  [
     *    {
     *      model: {
     *         // projKey(文件的名称)
     *         taobao: {
     *            // 文件的内容
     *            name,desc,key,homePage,menu:[xxx],
     *         },
     *       },
     *      model: {xxxxx}
     *    }
     *  ]
     */
    if (type === "model") {
      // /app/model/buiness/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;
    }
  });

  modelList.forEach((item) => {
    const { model, project } = item;
    for (const key in project) {
      project[key] = projectExtendModal(model, project[key]);
    }
  });

  console.log("🚀 ~ fileList:", fileList);
  return modelList;
};

项目列表 API 实现

  • 现在 DSL 已经组装好了,前端就需要通过服务器获取,并且只获取重要的信息,这里的 menu 信息暂时不需要 + 通过 assert 及 supertest 对接口进行验证 (测试用例)

**实现 mdoel_list 接口**

**获取 api/project/mdoel_list**

定义路由

javascript 复制代码
/**
 * 获取项目列表
 * @param {*} app
 * @param {*} router
 */
module.exports = (app, router) => {
  const { project: projectController } = app.controller;
  router.get(
    "/api/project/model_list",
    projectController.getModelList.bind(projectController)
  );
};

将 model.js 数据导入至 service

javascript 复制代码
/**
 * 获取项目列表
 * @param {*} app
 * @param {*} router
 */
module.exports = (app) => {
  const BaseService = require("./base")(app);
  const modelList = require("../model/index")(app);
  return class ProjectService extends BaseService {
    async getModelList() {
      return modelList;
    }
  };
};

router-schema 验证

javascript 复制代码
/**
 * JSON-Schema
 * 用来描述API的请求参数
 * 通过Ajv来校验 实际的请求参数和schema是否匹配
 */
module.exports = {
  "/api/project/model_list": {
    get: {
    },
  },
};

处理逻辑,截取关键数据,将 menu 去除

javascript 复制代码
module.exports = (app) => {
  const BaseController = require("./base")(app);
  /**
   * 获取项目列表
   * @param {object} ctx 上下文
   */
  return class ProjectController extends BaseController {
    async getModelList(ctx) {
      const { project: projectService } = app.service;
      const modelList = await projectService.getModelList();

      // 构造返回结果,只范围关键数据
      const dtoModelList = modelList.reduce((preList, item) => {
        const { model, project } = item;
        // 构造model关键数据
        const { key, name, desc } = model;
        const dtoModel = { key, name, desc };

        // 构造project关键数据
        const dtoProject = Object.keys(project).reduce((preObj, proKey) => {
          const { key, name, desc, homePage } = project[proKey];
          preObj[proKey] = { key, name, desc, homePage };
          return preObj;
        }, {});

        // 整合返回结构
        preList.push({ model: dtoModel, project: dtoProject });
        return preList;
      }, []);
      this.success(ctx, dtoModelList);
    }
  };
};

验证 接口(单元测试)

**supertest 启动单元测试服务**

assert 对数据进行类型断言

javascript 复制代码
const assert = require("assert");
const supertest = require("supertest");
const md5 = require("md5");
const ellisCore = require("../../elpis-core");

const signKey = "前后端对称加密的密钥";
const st = Date.now();

describe("测试 project 相关接口", function () {
  this.timeout(60000);
  let request;

  it("启动服务", async () => {
    const app = await ellisCore.start();
    request = supertest(app.listen());
  });
  it("GET /api/project/model_list", async () => {
    let tempRequest = request.get("/api/project/model_list");
    tempRequest = tempRequest.set("s_t", st);
    tempRequest = tempRequest.set("s_sign", md5(`${signKey}${st}`));
    const res = await tempRequest;
    assert(res.body.success === true);

    const resData = res.body.data;

    assert(resData.length > 0);

    for (const item of resData) {
      assert(item.model);
      assert(item.model.key);
      assert(item.model.name);
      assert(item.project);

      for (const proKey in item.project) {
        assert(item.project[proKey].name);
        assert(item.project[proKey].key);
      }
    }
  });
});

渲染 Project-list

![](https://cdn.nlark.com/yuque/0/2025/png/35194051/1735699688570-7b14cca9-5f6b-448b-aa63-34a5f7f12fc8.png)

这里是纯写页面,就快速 cv 了

javascript 复制代码
import boot from "$pages/boot";
import ProjectList from "./project-list.vue";

boot(ProjectList);
javascript 复制代码
<script setup>
import { onMounted, ref } from "vue";
import HeaderContainer from "$widgets/header-container/header-container.vue";
import $curl from "$common/curl";
const projectList = ref([]);
const loading = ref(false);

const getProjectList = async () => {
  loading.value = true;
  const res = await $curl({
    url: "/api/project/model_list",
    method: "get",
  });
  projectList.value = res.data ?? [];
  loading.value = false;
};

const handleEntry = (item) => {
  console.log(item);
};

onMounted(() => {
  getProjectList();
});
</script>

<template>
  <header-container title="Elpis">
    <template #main-content>
      <div v-loading="loading">
        <div v-for="item in projectList" :key="item.model?.key">
          <!-- 展示model -->
          <div class="model-panel">
            <el-row>
              <div class="title">{{ item.model?.name }}</div>
            </el-row>
            <el-divider />
          </div>
          <!-- 展示project -->
          <el-row flex class="project-list">
            <el-card
              class="project-card"
              v-for="projItem in item.project"
              :key="projItem.key"
            >
              <template #header>
                <div class="title">
                  <span>{{ projItem.name }}</span>
                </div>
              </template>
              <div class="content">
                {{ projItem.desc ?? "----" }}
              </div>
              <template #footer>
                <el-row justify="end">
                  <el-button
                    type="primary"
                    size="mini"
                    @click="handleEntry(item)"
                    >进入</el-button
                  >
                </el-row>
              </template>
            </el-card>
          </el-row>
        </div>
      </div>
    </template>
  </header-container>
</template>

<style lang="less" scoped>
.model-panel {
  margin: 20px 50px;
  min-width: 500px;
  .title {
    font-size: 25px;
    font-weight: bold;
    color: #f1dada;
  }
}
.project-list {
  margin: 0 50px;
  .project-card {
    margin-right: 30px;
    margin-bottom: 20px;
    width: 300px;
    .title {
      font-size: 17px;
      color: #47a2ff;
      font-weight: bold;
    }
    .content {
      height: 70px;
      font-size: 15px;
      color: #666;
      overflow: scroll;
    }
  }
}
</style>
  • 小部件,heander-container 组件 预留基础样式和插槽
javascript 复制代码
<script setup>
import { ref } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
defineProps({
  title: {
    type: String,
    default: "Elpis",
  },
});
const username = ref("admin");

const handleUserCommand = (command) => {
  console.log(`click on item ${command}`);
};
</script>

<template>
  <el-container class="header-container">
    <el-header class="header">
      <el-row type="flex" align="middle" class="header-row">
        <el-row type="flex" align="middle" class="title-panel">
          <img src="./asserts/logo.png" alt="" class="logo" />
          <el-row class="text">
            {{ title }}
          </el-row>
        </el-row>

        <!-- 插槽:菜单区域 -->
        <slot name="menu-content"></slot>
        <!-- 右上方区域 -->
        <el-row type="flex" justify="end" align="middle" class="setting-panel">
          <slot name="setting-content"></slot>
          <img src="./asserts/avatar.png" class="avatar" />
          <el-dropdown @command="handleUserCommand">
            <span class="username">
              {{ username }}
              <el-icon class="el-icon--right">
                <arrow-down />
              </el-icon>
            </span>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="logout">退出登录</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </el-row>
      </el-row>
    </el-header>
    <el-main class="main-container">
      <slot name="main-content"></slot>
    </el-main>
  </el-container>
</template>

<style lang="less" scoped>
.header-container {
  height: 100%;
  min-width: 1000px;
  overflow: hidden;
  .header {
    max-height: 120px;
    border-bottom: 1px solid #e8e8e8;
    .header-row {
      height: 60px;
      padding: 0 20px;
      .title-panel {
        width: 180px;
        min-width: 180px;
        .logo {
          margin-right: 10px;
          width: 25px;
          height: 25px;
          border-radius: 50%;
        }
        .text {
          font-size: 15px;
          font-weight: 600;
        }
      }
    }
    .setting-panel {
      margin-left: auto;
      min-width: 180px;
      .avatar {
        margin-right: 12px;
        width: 30px;
        height: 30px;
        border-radius: 50%;
      }
      .username {
        display: flex;
        align-items: center;
        font-size: 16px;
        font-weight: 600;
        cursor: pointer;
        height: 60px;
        line-height: 60px;
        outline: none;
        vertical-align: middle;
      }
    }
  }
  .main-container {
  }
}
:deep(.el-header) {
  padding: 0;
}
</style>
相关推荐
在无清风1 小时前
Java实现Redis
前端·windows·bootstrap
_一条咸鱼_3 小时前
Vue 配置模块深度剖析(十一)
前端·javascript·面试
yechaoa3 小时前
Widget开发实践指南
android·前端
前端切图仔0014 小时前
WebSocket 技术详解
前端·网络·websocket·网络协议
JarvanMo4 小时前
关于Flutter架构的小小探讨
前端·flutter
前端开发张小七5 小时前
每日一练:4.有效的括号
前端·python
顾林海5 小时前
Flutter 图标和按钮组件
android·开发语言·前端·flutter·面试
雯0609~5 小时前
js:循环查询数组对象中的某一项的值是否为空
开发语言·前端·javascript
bingbingyihao5 小时前
个人博客系统
前端·javascript·vue.js
尘寰ya5 小时前
前端面试-HTML5与CSS3
前端·面试·css3·html5