基于领域模型的建站模式——DSL设计与实现(一)

前言

在当下,绝大多数的开发者在开发中后台系统,不管是前端、后端都是在干着一些CRUD的工作,绝大部分都是重复的工作,一些搜索条件、一个表格、一些按钮。看似忙碌的工作,实则对个人的提升意义不大,因为做一个跟做一百个的影响差别不大。如果能有一个可以沉淀出来可复用、支持灵活拓展的方案,那么我们就可以将重复的工作尽可能的抹平,只需要对定制化的功能进行开发,这对我们的开发效率将是一个很大的提升。下面介绍的基于领域模型的建站模式的DSL的设计与实现将实现这一想法,将80%的可复用的业务能力交给配置完成,剩余20%的内容通过定制化开发完成。

一、什么是 DSL ?

在理解什么是DSL之前,我们先了解一下什么是 领域模型。

领域模型就是解决方案。就是描述具体的某一个业务领域的抽象和简化的表示,它是针对特定领域里的关键事物以及其关联的表现,它是为了解决特定问题的抽象的模型。比如一个公司中人力资源部门、财务部门、技术部门都属于一个特定的领域,它解决了特定的问题。 总的来说,领域模型我通俗的理解是它先找到业务中的实际场景,以领域模型为中心去驱动项目的开发,精髓在于面向对象去分析,抽象事物的能力。
DSL(Domain-Specific Language) 是一种专用于特定领域的计算机语言,设计针对某一特定领域的问题解决。通常用于描述界面布局、组件配置、和行为逻辑,以简化和加速前端开发过程。这也就要求 DSL 具备强大的配置能力,让使用者用起来更简单。DSL就是根据自定义的DSL规则来书写一份DSL脚本,也就是具体的 Schema Config 配置,里面包含了想要生成的页面内容(搜索条件、表格、按钮、弹窗、抽屉...)。

二、如何设计 DSL ?

上面是一份 mode 为 dashborad 的 DSL 配置(根据业务可以由很多mode不一样的配置),这一份配置用来生成对应的页面。生成的页面设计图如下:

三、如何书写 DSL?

根据上面的设计,我们一份DSL的设计是生成一份中后台的系统,他的颗粒度是一个站点,而不是一个模块或者一个页面。这时候问题就来了,如果我们有多个站点,比如拼多多电商系统、淘宝电商系统、京东电商系统,那么我们就需要三份DSL配置,而且其中大部分的配置都是相同,这样的话,我们目标解决80%重复的工作提升的并没有想象中的极致。这只是由原来重复的书写页面变成重复的配置,依然会有大量的时间浪费。如下图所示一样。

基于上面的问题,我们可以知道三份配置只有一小部分的字段属性可能不一样,一部分还是一样的配置。我们思考一下,能不能将重复的配置抽象出来,不一样的配置由各自的项目配置去实现呢?这就需要借助面向对象的思想了。面向对象的三大特征:封装、继承、多态。

最终设计呈现:

四、具体实现

3.1 文件结构

3.2 子类继承基类的实现

js 复制代码
const _ = require("lodash");
const glob = require("glob");
const path = require("path");
const { sep } = path;

/**
 * project 继承 model 的方法
 * @param {object} model 
 * @param {object} project 
 */
const projectExtendModel = (model, project) => {
  return _.mergeWith({}, model, project, (modelValue, projValue) => {
    // 处理数组合并的特殊情况
    if (Array.isArray(modelValue) && Array.isArray(projValue)) {
      let result = [];
      // 如果 project 有的键值, model 也有 --> 修改(重载)
      // 如果 project 没有的键值,model有 --> 则继承
      for (let i = 0; i < modelValue.length; i++) {
        const modelItem = modelValue[i];
        const projItem = projValue.find(projItem => projItem.key === modelItem.key);
       result.push(!projItem ? modelItem : projectExtendModel(modelItem, projItem));
      }

       // 如果是 project 有的键值,model 没有 --> 新增
       for (let i = 0; i < projValue.length; i++) {
        const projItem = projValue[i];
        const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key);
        if (!modelItem) {
          result.push(projValue[i]);
        }
      }

      return result;
    }
  })
}

/**
 * 解析 model 配置,并返回组织且继承 model 之后的数据节后
 * [{
 *   model: ${model},
 *   project: {
 *     proj1Key: ${proj1},
 *     proj2Key: ${proj2}
 *   }
 * }, ...]
 */

module.exports = (app) => {
  // 存储组织之后的数据
  const modelList = [];
  // 获取 model 目录
  const modelPath = path.resolve(app.baseDir, `.${sep}model`);
  // 获取 model 下面的所有文件
  const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`));
  fileList.forEach(file => {
    // file = path.resolve(file);
    // 如果 file 是当前目录的话,就不处理
    if (file.indexOf('index.js') > -1) {
      return;
    }
    // 如果不是,区分 model 和 project
    // 在 windows 中 glob 会将 \ 转为 /, 所以直接用 / 来判断
    const type = file.indexOf(`/project/`) > -1 ? "project" : "model";
    // 如果是 model
    if (type === 'model') {
      const modelKey = file.match(/\/model\/(.*?)\/model\.js/)?.[1];
      let modelItem = modelList.find(item => item?.model?.key === modelKey);
      // 如果 modelItem 不存在,则创建一个
      if (!modelItem) {
        modelItem = {};
        modelList.push(modelItem);
      }
      modelItem.model = require(path.resolve(file));
      // 注入 modelKey
      modelItem.model.key = modelKey;
    }
    // 如果是 project
    if (type === "project") {
      const modelKey = file.match(/\/model\/(.*?)\/project\//)?.[1];
      const projKey = file.match(/\/project\/(.*?)\.js/)?.[1];
      let modelItem = modelList.find(item => item?.model?.key === modelKey);
      // 如果 modelItem 不存在,则创建一个
      if (!modelItem) {
        modelItem = {};
        modelList.push(modelItem);
      }

      // 如果 modelItem.project 不存在,则创建一个
      if (!modelItem.project) {
        modelItem.project = {};
      }

      modelItem.project[projKey] = require(path.resolve(file));
      // 注入 projKey
      modelItem.project[projKey].key = projKey;
      // 注入 modelKey
      modelItem.project[projKey].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.3 页面实现

4.3.1 入口准备

当我们通过子类继承基类之后,我们就得到了不同站点的具体的schema配置,接下来就是需要将配置的内容转化为页面。 首页结构:

js 复制代码
<template>
  <!-- 全局配置国际化 -->
  <el-config-provider :locale="zhCn">
    <header-view
      :proj-name="projName"
      @menu-select="onMenuSelect"
    >
      <template #main-content>
        <router-view />
      </template>
    </header-view>
  </el-config-provider>
</template>
4.3.2 headerView

headerView 主要负责头部菜单的实现,同时为用户预留定制化的空间。主要有三部分内容,如下图所示:

具体实现:

js 复制代码
// header-view
<header-container :title="projName">
    <!-- 菜单插槽 -->
    <template #menu-content>
      <!-- 根据 menuList 渲染 -->
      
    </template>
    <!-- 设置区域插槽 -->
    <template #setting-content>
      <!-- 根据 projectList 渲染 -->
    </template>
    <!-- 主要内容插槽 -->
    <template #main-content>
      <slot name="main-content" />
    </template>
  </header-container>
  
  // header-container
  <template>
  <!-- 布局容器 -->
  <el-container class="container">
    <!-- 头部区域 -->
    <el-header class="header-container">
      <el-row class="header-row">
        <!-- 左上角:logo、项目标题区域 -->
      </el-row>
        <!-- 插槽:中间菜单区域 -->
        <slot name="menu-content" />
        <!-- 右上方用户信息区域 -->
        <el-row
          align="middle"
          justify="end"
          class="setting-panel"
        >
          <!-- 插槽:设置区域 -->
          <slot name="setting-content" />
        </el-row>
      </el-row>
    </el-header>
    <!-- 内容区域 -->
    <el-main class="main-container">
      <!-- 插槽:核心内容区域 -->
      <slot name="main-content" />
    </el-main>
  </el-container>
</template>
4.3.3 siderView

moduleType === sider ,实现类似headerView

js 复制代码
{
    key: "data",
    name: "数据分析",
    menuType: "module",
    moduleType: "sider",
    siderConfig: {
      menu: [{
        key: "analysis",
        name: "电商罗盘",
        menuType: "module",
        moduleType: "custom",
        customConfig: {
          path: "/todo"
        }
      }]
4.3.4 iframeView

moduleType === iframe

js 复制代码
{
            key: "shop-data",
            name: "店铺数据",
            menuType: "module",
            moduleType: "iframe",
            iframeConfig: {
              path: "https://www/baidu.com"
            }
          },
4.3.5 customView
js 复制代码
{
        key: "analysis",
        name: "电商罗盘",
        menuType: "module",
        moduleType: "custom",
        customConfig: {
          path: "/todo"
        }
      }
4.3.6 schemaView

schemaView 主要通过一份jsonschema的配置渲染出来main-content的内容,下面是一份schemaConfig的配置:

js 复制代码
 schemaConfig: {
      api: "/api/proj/product",
      schema: {
        type: "object",
        properties: {
          product_id: {
            type: "string",
            label: "商品ID",
            tableOption: {
              // width: 180,
              "show-overflow-tooltip": true
            },
            searchOption: {
              comType: "input"
            }
          },
          product_name: {
            type: "string",
            label: "商品名称",
            tableOption: {
              // width: 180
            },
            searchOption: {
              comType: "input",
            }
          },
          price: {
            type: "number",
            label: "价格",
            tableOption: {
              // width: 120
            }
          },
          inventory: {
            type: "number",
            label: "库存",
            tableOption: {
              // width: 120
            }
          },
          status: {
            type: "status",
            label: "状态",
            tableOption: {
              // width: 120
              // enum: { 1: '上架', 0: '下架' }
            },
            searchOption: {
              comType: "dynamicSelect",
              // enumList: [{ value: 1, label: "上架" }, { value: 0, label: "下架" }],
              api: '/api/proj/status_enum'
            }
          },
          create_time: {
            type: "string",
            label: "创建时间",
            tableOption: {
              // width: 180
            },
            searchOption: {
              comType: "dateRange"
            }
          },
          create_person: {
            type: "string",
            label: "创建人",
            tableOption: {
              // width: 120
            }
          },
        },
      },
      tableConfig: {
        headerButtons: [{
          type: "primary",
          plain: true,
          label: "新增",
          eventKey: "addShowComponent",
          eventOption: {},
        }],
        rowButtons: [{
          type: "warning",
          label: "修改",
          eventKey: "editShowComponent",
          eventOption: {}
        }, {
          type: "danger",
          label: "删除",
          eventKey: "remove",
          eventOption: {
            params: {
              product_id: "schema::product_id"
            }
          }
        }]
      }
    }

schemaConfig 渲染逻辑,上面的 schemaConfig 只是生成了schema-search-bar、schema-table以及一些动态组件(即search-bar 的输入框、选择框、时间选择器...)。

schemaConfig的解析器通过书写一份 schema hooks 来解析不同 option 的schema,并将处理后的 option 提供给 schema-view 解析使用

js 复制代码
import { ref, watch, onMounted, nextTick } from "vue";
import { useRoute } from "vue-router";
import { useMenuStore } from "$store";
import { cloneDeep } from "lodash";

export const useSchema = () => {
  const route = useRoute();
  const menuStore = useMenuStore();
  const { sider_key: siderKey, key } = route.query;
  const api = ref("");
  const tableSchema = ref({});
  const tableConfig = ref({});
  const searchSchema = ref({});
  const searchConfig = ref({});

  onMounted(()=> {
    buildData();
  });

  watch([
    () => key,
    () => siderKey,
    () => menuStore.menuList,
  ], () => {
    buildData();
  }, { deep: true });

  // 构造 schema 相关配置,返回给 schema-view 解析使用
  const buildData = () => {
    const menuItem = menuStore.findMenuItem({
      key: "key",
      value: siderKey ?? route.query.key
    });

    if (menuItem?.schemaConfig) {
      const { schemaConfig: sConfig } = menuItem;
      api.value = sConfig?.api ?? "";

      const configSchema = cloneDeep(sConfig?.schema ?? {});
      tableSchema.value = {};
      tableConfig.value = undefined;
      searchSchema.value = {};
      searchConfig.value = undefined;
      nextTick(() => {
        // 构造 tableSchema 和 tableConfig
        tableSchema.value = buildSchemaDto(configSchema, "table");
        tableConfig.value = sConfig.tableConfig;
        // 构造 searchSchema 和 searchConfig
        const schemaDto = buildSchemaDto(configSchema, "search");
        for (const key in schemaDto.properties) {
          if (route.query[key] !== undefined) {
            schemaDto.properties[key].option.default = route.query[key];
          };
        };
        searchSchema.value = schemaDto;
        searchConfig.value = sConfig.searchConfig;
      });
    };
  };

  /**
   * 通用构建 schema 方法
   * 只返回需要的 schema 信息
   * @param {object} _schema
   * @param {string} comName
   */
  const buildSchemaDto = (_schema, comName) => {
    if (!_schema?.properties) {
      return {};
    };

    const schemaDto = {
      api: "",
      properties: {}
    };

    // 提取有效的 schema 信息(清除杂质)
    for (const key in _schema.properties) {
      const props = _schema.properties[key];
      if (props[`${comName}Option`]) {
        let propsDto = {};
        // 提取 props 中的非 option 的属性,存储到 propsDto 中
        for (const pKey in props) {
          // 如果属性不包含Option, 则添加到 propsDto 中
          if (pKey.indexOf("Option") === -1) {
            propsDto[pKey] = props[pKey];
          };
        };
        // 处理 `${comName}Option` 中的信息,将其属性统一处理成 option, 存储到 propsDto 中
        propsDto = Object.assign({}, propsDto, { option: props[`${comName}Option`] });
        schemaDto.properties[key] = propsDto;
      }
    };

    return schemaDto;
  };
  return {
    api,
    tableSchema,
    tableConfig,
    searchSchema,
    searchConfig
  };
};

这份schema不仅仅是上面样例的功能,它还可以配置不同站点同一模块的schema在API的对应字段属性,请求方法。还可以通过这份schema生成库表,描述属性在库表的字段等等。随着业务的进行,这份schema会越来越强大,覆盖的场景越来越多。这也就解决我们开头说的磨平80%的重复的工作,剩余20%定制化。

学习声明 :本文知识体系来源于哲玄前端(抖音ID:44622831736)大前端全栈实践课程,结合个人学习实践进行整理。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
yunteng5218 小时前
通用架构(同城双活)(单点接入)
架构·同城双活·单点接入