Elpis -- DSL设计与实现(1)

455 阅读5分钟

Elpis -- DSL设计与实现(1)

一、DSL简介

DSL(Domain Specific Language)是针对某一领域,具有受限表达性的一种计算机程序设计语言。 常用于聚焦指定的领域或问题,这就要求 DSL 具备强大的表现力,同时在使用起来要简单。

image-20250206191802475

简单来说,就是根据自定义的语言规则写出一份DSL脚本,通过脚本来生成想要达到的内容(代码、流程、页面等)。

二、规则设计

image-20250206200359457

这是一份 mode 为 dashboard 的 DS L配置规定,有了一份配置之后,可以通过这一份配置来生成对应的页面。该配置生成的页面草图如下:

image-20250206194739670

三、书写DSL

上面我们对做了一份简单的 DSL 规定,这一份 DSL 的目的是生成一份后台管理系统的页面,但是如果我们有许多类似的项目需要实现,那么我们需要书写三份大致相同的 DSL 配置,这会浪费很多时间。

image-20250206200748434

例如上述的三份配置,配置中仅仅只有 key 和 name 不一样,对于该问题,可以采用类似面相对象的思维方式,先设计一个基类,将所有重复性的内容放到基类当中,再将每个项目不同的配置配置文件继承基类。

image-20250206201605568

3.1、配置合并

确定上述内容后,开始编写具体的配置文件,对同一类的模型进行合并。具体文件目录如下

image-20250207184848460

具体合并代码如下:

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 值来切换路由生成对应的页面。

image-20250207194507177

// 点击按钮时切换对应页面
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主要功负责渲染头部菜单按钮,同时也要为用户预留自定义空间。具体组件结构如下:

image-20250208135649288

4.3 SiderView

SiderView 是当 moduleType === sider时,渲染在 Content 左边的左侧菜单,菜单功能与头部菜单一致,点击不同类型的按钮时会在右侧渲染对应的页面。

image-20250208144045812

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",
  },
},

image-20250208150109570

4.5 SchemaView

SchemaView 时当 moduleType === schema时,读取相应的 schemaConfig 配置,渲染对应的自定义内容到 Content 中。

image-20250208151423293

SchemaConfig 主要结构如下:

image-20250208160901276

一份 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 渲染逻辑:

image-20250208155420388

拿到一份 SchemaConifg 数据后,数据中不同的 Option 对应不同的组件解析器,需要对数据进行解析后传递给不同的组件进行渲染,为处理这类逻辑设计一份hooks。

image-20250208160518853

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 的页面生成。

image-20250208162844100

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