Elpis -- DSL设计与实现(1)
一、DSL简介
DSL(Domain Specific Language)是针对某一领域,具有受限表达性的一种计算机程序设计语言。 常用于聚焦指定的领域或问题,这就要求 DSL 具备强大的表现力,同时在使用起来要简单。
简单来说,就是根据自定义的语言规则写出一份DSL脚本,通过脚本来生成想要达到的内容(代码、流程、页面等)。
二、规则设计
这是一份 mode 为 dashboard 的 DS L配置规定,有了一份配置之后,可以通过这一份配置来生成对应的页面。该配置生成的页面草图如下:
三、书写DSL
上面我们对做了一份简单的 DSL 规定,这一份 DSL 的目的是生成一份后台管理系统的页面,但是如果我们有许多类似的项目需要实现,那么我们需要书写三份大致相同的 DSL 配置,这会浪费很多时间。
例如上述的三份配置,配置中仅仅只有 key 和 name 不一样,对于该问题,可以采用类似面相对象的思维方式,先设计一个基类,将所有重复性的内容放到基类当中,再将每个项目不同的配置配置文件继承基类。
3.1、配置合并
确定上述内容后,开始编写具体的配置文件,对同一类的模型进行合并。具体文件目录如下
具体合并代码如下:
const glob = require('glob');
const _ = require('lodash');
const path = require('path');
const { sep } = path;
/**
* 项目继承 model 方法
* @param {*} model
* @param {*} project
*/
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((item) => item.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((item) => item.key === projItem.key);
if (!modelItem) {
result.push(projItem);
}
}
return result;
}
});
};
/**
* 解析 model 配置,并返回组织且继承后的数据结构
[{
model: ${model}
project: {
proj1Key: ${proj1},
proj2Key: ${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;
// 区分配置类型(model / project)
const type =
path.resolve(file).indexOf(`${sep}project${sep}`) > -1
? 'project'
: 'model';
if (type === 'project') {
const modelKey = file.match(/\/model\/(.*?)\/project/)?.[1];
const proj1Key = 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[proj1Key] = require(path.resolve(file));
modelItem.project[proj1Key].key = proj1Key; // 注入 projectKey
modelItem.project[proj1Key].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;
};
四、实现
4.1 准备
当我们得到一份配置的文件的时候,我们需要做的事情就是将配置文件渲染成一个具体的页面。使用 DSL 的目的将大部分的重复内容抽取出来,在设计页面时不仅仅需要完成对应的配置渲染,还需要给用户预留足够的自定义空间。
首页结构如下:
<template>
<el-config-provider :locale="zhCn">
<HeaderView :proj-name="projName" @menu-select="onMenuSelect">
<template #main-content>
<router-view></router-view>
</template>
</HeaderView>
</el-config-provider>
</template>
首页主要由两个部分组成,Header 和 Content,Header 部分负责渲染一个按钮,Content 部分根据每一个按钮的 modelType 值来切换路由生成对应的页面。
// 点击按钮时切换对应页面
const onMenuSelect = (menuItem) => {
const { moduleType, key, customConfig } = menuItem;
// 如果是当前页面,不处理
if (route.query.key === key) return;
const pathMap = {
sider: "/sider",
iframe: "/iframe",
schema: "/schema",
custom: customConfig?.path,
};
router.push({
path: "/view/dashboard" + pathMap[moduleType],
query: {
key,
proj_key: route.query.proj_key,
},
});
};
4.2 HeaderView
HeaderView主要功负责渲染头部菜单按钮,同时也要为用户预留自定义空间。具体组件结构如下:
4.3 SiderView
SiderView 是当 moduleType === sider时,渲染在 Content 左边的左侧菜单,菜单功能与头部菜单一致,点击不同类型的按钮时会在右侧渲染对应的页面。
4.4 IframeView
IframeView 是当 moduleType === iframe时,读取 iframeConfig 配置中的 path 路径,渲染第三方页面到 Content 中。
// 一份按钮 moduletype === iframe 的配置
{
key: "quality-setting",
name: "店铺资质",
menuType: "module",
moduleType: "iframe",
iframeConfig: {
path: "http://www.baidu.com",
},
},
4.5 SchemaView
SchemaView 时当 moduleType === schema时,读取相应的 schemaConfig 配置,渲染对应的自定义内容到 Content 中。
SchemaConfig 主要结构如下:
一份 SchemaConfig 配置示例:
chemaConfig: {
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: "dynamic-select",
api: "/api/proj/product_enum/list",
},
},
price: {
type: "number",
label: "价格",
tableOption: {
width: 200,
},
searchOption: {
comType: "select",
enumList: [
{
label: "全部",
value: -1,
},
{
label: "¥200",
value: 200,
},
{
label: "¥400",
value: 500,
},
{
label: "¥600",
value: 600,
},
],
},
},
inventory: {
type: "number",
label: "库存",
tableOption: {
width: 200,
},
searchOption: {
comType: "input",
},
},
create_time: {
type: "string",
label: "创建时间",
tableOption: {},
searchOption: {
comType: "date-range",
},
},
},
},
tableConfig: {
headerButtons: [
{
label: "新增商品",
eventKey: "showComponent",
type: "primary",
plain: true,
},
],
rowButtons: [
{
label: "修改",
eventKey: "showComponent",
type: "warning",
},
{
label: "删除",
eventKey: "remove",
type: "danger",
eventOption: {
params: {
product_id: "schema::product_id",
},
},
},
],
},
},
ScheamConfig 渲染逻辑:
拿到一份 SchemaConifg 数据后,数据中不同的 Option 对应不同的组件解析器,需要对数据进行解析后传递给不同的组件进行渲染,为处理这类逻辑设计一份hooks。
export const useSchema = () => {
const route = useRoute();
const menuStore = useMenuStore();
const api = ref("");
const tableSchema = ref({});
const tableConfig = ref({});
const searchSchema = ref({});
const searchConfig = ref({});
/**
* 构造 schemaConfig 相关配置,输送给 schema 解析
*/
const buildData = () => {
const { key, sider_key: siderKey } = route.query;
const mItem = menuStore.findMenuItem({
key: "key",
value: siderKey ?? key,
});
if (mItem && mItem.schemaConfig) {
const { schemaConfig: sConfig } = mItem;
const configSchema = JSON.parse(JSON.stringify(sConfig.schema));
api.value = sConfig.api ?? "";
tableSchema.value = {};
tableConfig.value = undefined;
searchSchema.value = {};
searchConfig.value = undefined;
nextTick(() => {
// 构造 tableSchema 和 tableConfig
tableSchema.value = buildDtoSchema(configSchema, "table");
tableConfig.value = sConfig.tableConfig;
// 构造 searchSchema 和 searchConfig
const dtoSearchSchema = buildDtoSchema(configSchema, "search");
for (const key in dtoSearchSchema.properties) {
if (route.query[key] !== undefined) {
dtoSearchSchema.properties[key].option.default = route.query[key];
}
}
searchSchema.value = dtoSearchSchema;
searchConfig.value = sConfig.searchConfig;
});
}
};
// 通用构建 schema 方法 (消除噪音)
const buildDtoSchema = (_schema, _comName) => {
if (!_schema?.properties) return {};
const dtoSchema = {
type: "object",
properties: {},
};
// 提取有效 schema 字段信息
for (const key in _schema.properties) {
const props = _schema.properties[key];
// tableOption searchBarOption formOption
if (props[`${_comName}Option`]) {
let dtoProps = {};
// 提取 props 中 非 option 的部分, 存放到 dtoProps 中
for (const pKey in props) {
if (pKey.indexOf("Option") < 0) {
dtoProps[pKey] = props[pKey];
}
}
// 处理 comName Option
dtoProps = Object.assign({}, dtoProps, {
option: props[`${_comName}Option`],
});
dtoSchema.properties[key] = dtoProps;
}
}
return dtoSchema;
};
return {
api,
tableSchema,
tableConfig,
searchSchema,
searchConfig,
};
};
理解上述内容后,书写对应的解析器来完成 SearchBar 和 table 的页面生成。
出处:《哲玄课堂-大前端全栈实践》