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
创建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:
- 将DSL文件格式化成约定的样子
- 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
这里是纯写页面,就快速 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>