里程碑3: DSL领域模型设计及dashboard模版实现

54 阅读4分钟

DSL是什么

DSL:Domain-Specific Language,即领域特定语言。 领域模型:是对业务领域中核心概念、数据、规则和流程的抽象化、结构化的知识表示。它不是一个具体的框架,而是一个概念模型

领域模型设计

设计领域模型基类,继承派生出具体的子类,使用配置来表示一个项目,根据这一份配置可以抽象出页面中的表格信息,搜索信息,以及数据库表信息。

0、首先设计出一套基本的领域模型模版,后续在开发过程中逐步添加需要的配置。

由于考虑要校验字段的合法性,定义时,参考标准schema模型设计,配置每个字段,每个字段中有相对应的option选项,来配置search或者table等地方该字段的表现形式。

1、设计一个项目配置项的解析器,针对不同模型及其下的项目进行解析,得到配置信息。

const glob = require('glob');
const path = require('path');
const { sep } = path;

/**
 * project 继承 model
 * @param {object} model
 * @param {object} project
 */
const projectExtendModule = (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(
          (projItem) => projItem.key === modelItem.key
        );
        // project 有的键值,model也有,则递归调用 projectExtendModule 方法覆盖修改
        result.push(
          projItem ? projectExtendModule(modelItem, projItem) : modelItem
        );
      }

      // 处理新增
      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(projItem);
        }
      }

      return result;
    }
  });
};

/**
 * 解析 model 配置,并返回组织且继承后的数据结构
   [{
    model: ${model},
    project: {
        proj1Key: ${proj1},
        proj2Key: ${proj2}
    },
   }, ...]
 * @param {object} 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 = file.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model';

    if (type === 'project') {
      const modelKey = file.match(/\/model\/(.*?)\/project\//)?.[1];
      const projKey = file.match(/\/project\/(.*?)\.js/)?.[1];
      const modelItem = modelList.find((item) => item.model?.key === modelKey);
      if (!modelItem) {
        // 初始化 model 数据结构
        modelItem = {};
        modelList.push(modelItem);
      }
      if (!modelItem.project) {
        modelItem.project = {};
      }
      modelItem.project[projKey] = require(file);
      modelItem.project[projKey].key = projKey; // 注入 projectKey
      modelItem.project[projKey].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) {
        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] = projectExtendModule(model, project[key]);
    }
  });

  return modelList;
};

2、开发dashboard页面入口,该入口打包后的产物由koa服务渲染。前端使用vue-router进行路由管理,在开发过程中,发现项目id这个字段在项目页面调接口时是一直使用的,考虑服务端应该要能直接拿到该参数,前端路由hash模式不适合,所以应该使用路由的history模式,此时对代码进行轻量重构。(在开发过程中,发现有方案改动,及时进行重构,以免后续重构困难。)

3、页面中实现的功能需要抽象出来的组件模块。根据DSL定义的模型,该模型中有菜单,有不同类型页面,要实现菜单模块及不同类型页面。

实现一个hook,提取数据

定义一个hook,实现方法来从全量数据中提取需要的数据,消除噪音。在不同模块中使用时按需获取。

image.png

schema类型页面,是一个可根据配置项生成的表格页面。包括表格区域,头部搜索区域,除了这些配置项,还预留可自行扩展区域,供开发者定制扩展自己的组件。

组件封装时需要考虑什么

在封装组件的时候考虑三要素:接收什么参数(defineProps、provide/inject、eventBus...);暴露什么事件(defineEmoits);提供什么方法(defineExpose)。 如下例组件:

  <el-form
    v-if="schema && schema.properties"
    :inline="true"
    class="schema-search-bar"
  >
    <!-- 动态组件 -->
    <el-form-item
      v-for="(schemaItem, key) in schema.properties"
      :key="key"
      :label="schemaItem.label"
    >
      <!-- 展示子组件 动态组件-->
      <!-- vue动态组件 -->
      <component
        :ref="searchComList"
        :is="SearchItemConfig[schemaItem.option?.comType]?.component"
        :schemaKey="key"
        :schema="schemaItem"
        @loaded="handleChildLoaded"
      ></component>
    </el-form-item>
    <el-form-item>
      <!-- 操作区域 -->
      <el-button type="primary" plain class="search-btn" @click="search">
        搜索
      </el-button>
      <el-button type="primary" plain class="reset-btn" @click="reset">
        重置
      </el-button>
    </el-form-item>
  </el-form>
</template>
<script setup>
import { ref, toRefs } from 'vue';
import SearchItemConfig from './search-item-config.js';
const props = defineProps({
  /**
     * schema 配置,结构如下:
      {
        type: 'object',
        properties: {
          key: {
            ...schema, // 标准schema配置
            type: '', // 字段类型
            label: '', // 字段的中文名
            // 字段在 search-bar 中的相关配置
            option: {
              ...eleComponentConfig, // 标准 el-component 组件的配置
              comType: '', // 配置组件类型 input / select 或其他
              default: '', // 默认值
            },
          },
          ...
        },
      };
  */
  schema: Object,
});

const { schema } = toRefs(props);

const emit = defineEmits(['load', 'search', 'reset']);

const searchComList = ref([]); // 所有的动态组件

const getValue = () => {
  let dtoObj = {};
  // 遍历列表,可以获取所有的动态组件
  searchComList.value.forEach((component) => {
    dtoObj = {
      ...dtoObj,
      ...component?.getValue(),
    };
  });
  return dtoObj;
};

let childComLoadedCount = 0;
const handleChildLoaded = () => {
  childComLoadedCount++;
  if (childComLoadedCount >= Object.keys(schema?.value?.properties).length) {
    emit('load', getValue());
  }
};

const search = () => {
  emit('search', getValue());
};

const reset = () => {
  searchComList.value.forEach((component) => {
    component.reset();
  });
  emit('reset');
};

defineExpose({
  reset,
  getValue,
});
</script>
<style lang="less">
.schema-search-bar {
  min-width: 500px;

  .input,
  .select,
  .dynamic-select {
    width: 280px;
  }

  .search-btn {
    width: 100px;
  }
  .reset-btn {
    width: 100px;
  }
}
</style>
动态组件使用
      <component
        ref="searchComList"
        //is参数是接受具体的组件
        :is="SearchItemConfig[schemaItem.option?.comType]?.component"
        // 动态组件的接收参数 schemaKey及schema
        :schemaKey="key"
        :schema="schemaItem"
        // 动态组件emit出的事件
        @loaded="handleChildLoaded"
      ></component>

总结:在设计DSL时,一套模版里是包含了这个项目中的多个配置,页面展示及数据库表设计都可以从这个模版中提取出来,注意模型的设计。