如果说章节一和章节二都是在为我们的项目做基建的话,那么章节三就是我们这个项目的核心。这个章节会围绕整个项目的架构设计来展开,解读其背后的设计思想和原理,以及这个项目架构是如何解决我们在一开始所提出的重复性工作以及多套系统建设交付成本高等问题的。
我们还是先回归到我们的痛点问题上来;重复性工作。
一般来说,我们在日常开发工作中所遇到的重复性工作,本质上都是对一张表数据进行CRUD操作。如何理解这句话呢?举例来说有一张商品表(数据库层面),这个表包含商品ID、商品名称两列。那么无论前端后端,本质上都是在针对这张表里面的这两个字段在进行CRUD工作而已。前端在他的页面上使用表组件(例如el-table)展示这两列,甚至可能做一些弹窗里面套一张el-form让用户填写并新增一行表数据,修改和删除也是同理。对于后端同学而言也是一样,无非也就是从数据库中搂出这张表,制作新接口筛选数据后提供给前端同学消费。例如前端同学只需要商品名称这一列,亦或者是根据用户输入的关键词筛选其合适的行数据等等。所以总的来说,无论前端、后端也好,重复性的工作本质上都是在围绕一份数据做对应CRUD的工作,这一份数据可能来自于一张表,也可能来自于多张表的联合查询,甚至是经过各种聚合、分组筛选等等处理过,但本质上都是一份数据。这种重复性的工作模式往往就和下图所示一样
因此我们便从这种问题的本质去进行推导,是否可能有那么一种架构或者方法,能够以数据为核心去驱动项目?注意我这里的描述,并不是以数据去驱动视图,而是以数据去驱动项目,抽象程度更高。也就是说我想表达的是,能否仅通过一份数据就能够直接生成一整个覆盖了从前端到后端一体化的完整项目?这便是我们的领域模型架构的核心出发点。
由这个出发点为基准,我们便起草出了下面这种方案: 我们想要通过书写一份配置文件,然后为这个配置文件做出各种各样的解析器,从而读取、解析某些特定字段后,将其输出为我们页面、API、建表语句等等。这样,未来我们的这些重复性工作便可以通过配置的方式就能够得以解放了。其理念上很类似于低代码平台,只不过区别在于我们没有可视化拖拉拽的界面,而且低代码平台往往只局限于前端的范畴,无法延伸拓展至后端。
另一个问题就是,这个配置文件该怎么写?又应该用什么样的文件格式来保存?这是一个很关键的问题,事关我们架构的拓展性和后期解析器的实现。为此我们参考了业内众多技术方案,还是决定使用了JSON来进行存储。其原因之一是JSON的语法格式和规范比较简洁易懂,而且轻量化,未来即便扔给一个不懂编程的人例如产品经理他们依然能够理解。其次就是因为我们项目本身采用Nodejs搭建,因此语法层面上能够天然适配、解析JSON文件。 文件格式的问题解决后,另一个问题是数据项的拓展问题,我们在写配置的过程中发现,这些数据项本身还需要更多的描述项目来辅助简历和完善这个对应的字段,例如我想将商品名称这个字段进入前端,这个字段它可以在前端的table中展示,也可能在搜索栏里面展示,那么具体需要展示在哪里?又如何进行展示?后续围绕这个字段的操作又该如何描述?因此,与其直接配置数据项,不如我们在数据项的层面上再抽象一个层级,即直接撰写schema,它是用来描述数据项的数据项。这里我们直接采用了业界最通用最热门的JSON-Schema来完成。 按照JSON-Schema的规范来书写对应的这几列数据,就应当是下列形式,首先是它的必填字段,type和properties。
{
type:object,
properties:{
product_id:{
type:'string'
},
product_name:{
type:'string'
}
}
}
接下来围绕这两个字段,我们便可以自行拓展需要的辅助字段了,比如label用来表示它的中文名称,tableOption表示它在我们前端表格中的一些配置选项,这些选项可以来自于element-ui亦或者是自行定义的等等
{
type:object,
properties:{
product_id:{
type:'string',
label:'商品ID',
//该字段在表格中的配置项
tableOption:{
visible:false, //自定义配置项,用来控制字段的显示和隐藏
show-overflow-tooltip: false //element-ui中的配置项,溢出是否换行
...element/各种UI框架的配置项等等
}
},
product_name:{
type:'string',
label:‘商品名称’,
}
}
}
此外,如果我们需要配置对应的字段有搜索项/API项/数据库项,那么我们统一命名为xxxOption
{
type:object,
properties:{
product_id:{
type:'string',
label:'商品ID',
//该字段在表格中的配置项
tableOption:{
visible:false, //自定义配置项,用来控制字段的显示和隐藏
show-overflow-tooltip: false //element-ui中的配置项,溢出是否换行
...element/各种UI框架的配置项等等
},
//搜索配置项
searchOption:{
comType:'input', //搜索栏中的渲染组件,input/select等等
defaultValue:'xxx'
},
//生成的接口等配置
apiOption:{
url:'xxxx',
controllerName:'xxx',
serviceName:'xxx',
response:{
}
...
},
//数据库配置项
dbOption:{
tableName:'xxx',
tableIndex:'xxx'
}
},
product_name:{
type:'string',
label:‘商品名称’,
....
}
}
}
通过这种方式,我们就可以使用配置文件来描述整个项目,进而实现从数据出发构造整个项目(数据驱动项目)。
但是仅仅只有上述这些配置是远远不够的,因为我们一整个前端项目中,不仅仅只有表单、搜索栏等等,一个管理后台至少还包含了例如导航栏、导航菜单在内的各种路由甚至是嵌套的iframe等等。所以我们的配置也应当随着拓展,进而能够支持这些。如下图所示。头部是一个header-container,里边由各种各样一级菜单组成。旁边有一个sider-container,内部也包含了各式各样的二级导航菜单,主体内容放在sider-container右侧区域中。无论一级或二级导航按钮被点击了,那么变更的只有主体内容区域。 主体内容区域也大致按照页面内容分为三种视图,一种是搜索栏 + 表格所组成的Schema View,一种是仅由iframe元素所组成的iframe View,最后一种则是自定义的Custom View(因为有些时候有些需求可能Schema View 完成不了,Iframe View也无法完成,那么就只能由前端自行开发实现了)。
所以,沉淀一下市面上常见的后台管理项目,我们最终能写出如下的一种模板配置文件。我们在这份模板文件中约定:
- moduleType具有四种类型,分别是iframe/custom/schema/sider,用来标识当前菜单对应的内容区域启用哪种视图,这个选项和menuType相关联。如果menuType为module时这个配置项才会生效。
- 如果填写了对应的moduleType,那么就必须填写对应的config配置项,例如moduleType为iframe时,必填iframeConfig
- menuType用来配置当前菜单项是否为单一菜单/组菜单,如果是组菜单,那么需要配置subMenu这个配置项,该配置项可递归,模拟我们现实后台项目中存在一级以上的导航按钮/菜单
- 为什么只有三种视图却要给出四种moduleType?因为需要考虑到有些后台管理系统不需要配置侧边栏的场景,另外就是因为要增加这一项,解析器才好判断目标路由是从sider的menuItem中取路由还是从头部的menuItem中取路由。
{
mode: 'dashboard', //模板类型,不同模板类型对应不一样的模板数据结构。这里考虑到未来要开发和维护除了后台管理以外的其他模板,所以就加入了这个字段
name: '', //项目名称
desc: '', //项目描述
icon: '', //项目的icon
homePage: '', //项目首页的路由
//头部菜单
menu: [
{
key: '', //菜单唯一键
name: '', //菜单名称
menuType: '', // 枚举值, group/ module
//当 menuType === group时,可填
subMenu: [
{
...//可递归,都是menuItem
},
...
],
//当menuType === module时,可填
moduleType: '', //枚举值:iframe/ custom/schema,
//当moduleType === iframe时
iframeConfig: {
path: '' //iframe 路径
},
//当moduleType === custom时
customConfig: {
path: '' //自定义路由路径
},
//当moduleType === schema时
schemaConfig: {
api: '', //数据源api(遵循Restful规范)
schema: {
type: 'object',
properties: {
key: {
...schema, //继承原有的标准schema
type: '', //字段类型
label: '', //字段中文名
//字段在table中的相关配置
tableOption: {
...elTableColumnConfig, //标准的el-table-column 配置,参考element文档
visible: true, //决定这个key是否在表单中展示。默认为true(false时,表示不在表单中显示)
},
//字段在 search-bar 中的相关配置
searchOption: {
...eleComponentConfig, //标准el-table-column 配置
comType: '', //组件类型,例如select组件,input组件等等
default: '', //默认值
//comType === 'select' 时,可配置下拉选项
enumList: [], //下拉框可选项
//comType === 'dynamicSelect'时,需配置api选项
api: ''
}
},
}
}, //板块数据结构
//table相关配置
tableConfig: {
headerButtons: [{
label: '', //按钮中文名
eventKey: '', //按钮的事件名称
eventOption: {}, //按钮具体配置
...elButtonConfig, //可拓展el-button的配置
},],
rowButtons: [
{
label: '', //中文名
eventKey: '', //按钮事件名称,例如remove
eventOption: {
//用来配置事件的请求参数
params: {
//paramKey = 参数的键值对
//rowValueKey = 参数值(当格式为schema::tableKey的时候,由解析引擎去到对应的table row 中找到相应的字段)
//例如我的paramKey为user_id, 若rowValueKey = schema::user_id, 那么解析引擎会去到表格对应的这一行中找到user_id字段,作为接口的请求参数填入
paramKey: rowValueKey
}
}, //按钮事件配置
...elButtonConfig //element的button 配置
}
],
},
searchConfig: {}, //search-bar相关配置
components: {
}
},
//当moduleType === sider 时,可配置侧边栏配置项
siderConfig: {
menu: [{
//可递归menuItem(除 moduleType === sider)
}]
}
}
]
}
这份由市面上常见的后台管理系统所沉淀下来的配置模板文件,就是后台管理领域模型。未来,我们可能还从各个不同的领域(例如大屏项目、可视化项目等等)沉淀总结出类似的领域模型。
沉淀出领域模型的意义在于复用,例如未来我想快速生成一个电商平台的后台管理系统,那么我就可以直接复用这个模型来完成这一点。那么怎么复用呢?这就又涉及到了另一个问题。通常来说我们复用一个组件,在现实工作中可能大概率都是通过CV代码来完成。这种方式虽然看似节约时间但是也面临着许多问题,不太优雅是其中一点以外,另一个就是可维护性比较低。试想一下一个项目中有组件A,你在复用以后将其命名为了A-1,A-2等类似命名的组件,未来其他同事如何接手,他该用哪个?此外组件A也不是说CV过来完全不改动任何东西就能够直接用的,你大概率还是要改动一些业务逻辑亦或者是组件的入参、出参等等,看似节约了时间但其实真正节约下来的并不多。因此在复用性的建设上,我们并没有考量CV的方法,而是利用了一种更加优雅的解决方式——面向对象。其原因有两点,第一个是可拓展性高,未来如果有任何系统需要使用该领域模型,那么就直接开个新配置文件继承该领域模型后,重载对应的配置项就行。第二点是可维护性更高,如果我们想要为所有已派生的系统增加一个功能或者配置项,那么我们可以直接在领域模型上进行操作,所有的派生系统都会受益。按照这个思路,我们未来就能建立许多领域模型,并派生出多个不同的系统实例,如下图所示
在有了领域模型后,接下来就是对于各大解析器的实现,首先是领域模型的解析器,它的功能主要是读取所有的领域模型和对应的已经实现了模型的派生类,实现面向对象中的继承,最终合并产出一份最终的配置文件。大致代码如下。重点在于projectExtendModel函数,其使用了loadash的mergeWith方法,合并对应的领域模型和实例项目配置文件。
const glob = require('glob');
const path = require('path');
const { sep } = path;
const _ = require('lodash');
/**
* 让项目继承对应的领域模型,实现数据和配置的复用
* @param {*} model
* @param {*} project
* @returns
*/
const projectExtendModel = (model, project) => {
//使用lodash的merge方法, 将两个对象进行合并,但是合并需要注意,数组需要特殊处理,因此使用了mergeWith方法,自定义合并逻辑
return _.mergeWith({}, model, project, (modelValue, projectValue) => {
//处理数组合并的特殊情况
if (Array.isArray(modelValue) && Array.isArray(projectValue)) {
let result = [];
//因为project继承自model, 所以需要特殊处理修改和新增内容的情况
/**
* 1.project有的键值,model也有 => 修改(面向对象中的重载)
* 2.project有的键值,model没有 => 新增
* 3.model有的键值,project没有 => 保留
*/
//处理修改和保留
for (let i = 0; i < modelValue.length; i++) {
let modelItem = modelValue[i];
const projItem = projectValue.find(item => item.key === modelItem.key);
//project有的键值,model也有 => 修改(面向对象中的重载),这里需要递归调用projectExtendModel方法,因为可能存在多层嵌套的情况
result.push(projItem ? projectExtendModel(modelItem, projItem) : modelItem); //如果projItem不存在,则保留原来的modelItem
}
//处理新增
for (let i = 0; i < projectValue.length; i++) {
const projItem = projectValue[i];
const modelItem = modelValue.find(item => item.key === projItem.key);
if (!modelItem) {
result.push(projItem);
}
}
return result;
}
})
}
/**
* 解析 model 配置,并返回组织且继承后的数据结构
* [
* {
* model:${model},
* project:{
* proj1:${proj1},
* proj2:${proj2},
* }
* }
* ]
*/
module.exports = (app) => {
const modelList = [];
//遍历当前文件夹,构造模型数据结构,挂载到 modelList 上
const modelPath = path.resolve(app.baseDir, 'model');
const fileList = glob.sync(`${modelPath}${sep}**${sep}*.json`);
//整理,组装每一个modelItem
fileList.forEach(filePath => {
if (filePath.indexOf('index') > -1) {
return;
}
//区分配置类型(project/model)
const type = filePath.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model';
if (type === 'project') {
const modelKey = filePath.match(/\/model\/(.*?)\/project/)?.[1];
const projKey = filePath.match(/\/project\/(.*?)\.json/)?.[1];
let modelItem = modelList.find(item => item?.model?.key === modelKey);
//如果从modelList中没有找到modelItem,则说明当前model还没有被初始化过,需要先初始化model数据结构
if (!modelItem) {
//初始化 model 数据结构
modelItem = {};
}
//如果说有modelItem但是却没有project属性,意味着在初始化model的时候已经初始化了modelItem,但是没有挂载project
if (!modelItem.project) {
modelItem.project = {}
}
modelItem.project[projKey] = require(filePath);
modelItem.project[projKey].key = projKey; //注入 projectKey
modelItem.project[projKey].modelKey = modelKey; //注入 modelKey,方便后续查找对应的model
return;
}
//model类型
const modelKey = filePath.match(/\/model\/(.*?)\/model.json/)?.[1]; //从路径中去匹配当前model的key名称
let modelItem = modelList.find(item => item.model?.key === modelKey)
if (!modelItem) {
modelItem = {}
modelList.push(modelItem);
}
modelItem.model = require(filePath);
modelItem.model.key = modelKey; //注入model key
})
//数据进一步整理,对每一项project实现继承 model
modelList.forEach(({ model, project }) => {
for (const key in project) {
project[key] = projectExtendModel(model, project[key]);
}
})
return modelList;
}
最终的输出大约长这样
[{
model:领域模型配置项,
project:{ //继承了该领域模型的所有子项目
project1:{...},
project2:{...},
}
}]
前端视图建设
入口 dashboard.vue:
<template>
<el-config-provider :locale="zhCn">
<header-view :proj-name="projName" @menu-select="onMenuSelect">
<template #main-content>
<router-view></router-view>
</template>
</header-view>
</el-config-provider>
</template>
header-view (固定在管理系统上方的导航栏)
<header-container :title="projName">
<template #menu-content>
<!--根据menuStore.menuList渲染-->
<el-menu
:default-active="activeKey"
:ellipsis="false"
mode="horizontal"
@select="onMenuSelect"
>
<template v-for="item in menuStore.menuList">
<sub-menu
v-if="item.subMenu && item.subMenu.length > 0"
:menu-item="item"
/>
<el-menu-item
v-else
:index="item.key"
>
{{ item.name }}
</el-menu-item>
</template>
</el-menu>
</template>
<template #setting-content>
<!--根据ProjectStore.projectList渲染-->
<el-dropdown @command="handleProjectCommand">
<span class="project-list">
{{ projName }}
<el-icon
v-if="projectStore.projectList.length > 1"
class="el-icon--right"
>
<ArrowDown />
</el-icon>
</span>
<template
v-if="projectStore.projectList.length > 1"
#dropdown
>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in projectStore.projectList"
:key="item.key"
:command="item.key"
:disabled="item.name === projName"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template #main-content>
<slot name="main-content" />
</template>
</header-container>
iframe-view(Iframe视图):
<template>
<iframe :src="path" class="iframe"/>
</template>
schema-view(标准化的一个表单 + 一个表格的schema视图):
<template>
<el-row class="schema-view">
<search-panel
v-if="searchSchema?.properties && Object.keys(searchSchema.properties).length > 0"
@search="onSearch"
/>
<table-panel
@operate="onTableOperate"
/>
</el-row>
</template>
sider-view:
<sider-container>
<template #menu-content>
<el-menu
:default-active="activeKey"
:ellipsis="false"
@select="onMenuSelect"
>
<template v-for="item in menuList">
<!-- group -->
<sub-menu v-if="item.subMenu && item.subMenu.length > 0"
:menu-item="item">
</sub-menu>
<!-- module -->
<el-menu-item v-else :index="item.key">
{{ item.name }}
</el-menu-item>
</template>
</el-menu>
</template>
<template #main-content>
<router-view></router-view>
</template>
</sider-container>
custom-view(前端开发自行实现的视图,这里用一个To do代替):
<template>
<h1>To do..</h1>
</template>
前端解析器(关键):主要目的是读取对应的项目配置项,过滤一些没用的配置项后,仅保留与该组件相关的配置。例如table组件只会用到tableOption中的内容,因此将过滤掉领域模型中诸如searchOption等配置项。这里考虑到复用封装成了一个vue hook。未来如果新增了比如组件的配置componentOption,或者说各式各样的前端配置,都应该在这个hooks里面继续新增解析函数。
import { ref, watch, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useMenuStore } from '$store/menu';
//解析领域模型文件中的Schema配置项目,例如table的Schema,搜索栏的Schema等等,将对应组件的配置解析、剥离出来。同时剔除那些与对应组件没有关联的噪音配置项
//备注:key是project_key,用来区分每个从领域模型派生出来的实例化项目;sider_key:页面导航,用于判断当前项目的路由,所访问的菜单等等。menuList即我们上面解析领域模型后的产物数组
export const useSchema = () => {
const route = useRoute();
const menuStore = useMenuStore();
const api = ref('');
const tableSchema = ref(null);
const tableConfig = ref(null);
const searchSchema = ref(null);
const searchConfig = ref(null);
//构造schemaConfig相关配置,输送给schema-view解析。
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');
//与tableSchema不同,搜索栏的schema需要考量搜索跳转的场景;例如从A页面带上搜索项目跳转到本页面,那么我们需要将这种有搜索项的字段开放并且重置他的默认值
for (const key in dtoSearchSchema.properties) {
if (route.query?.[key]) {
dtoSchema.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;
}
//当监测到路由key发生变化(即访问的项目发生了变化),sider_key(即访问的导航栏菜单发生了变化)以及menuList(我们后端解析的领域模型内容发生了变化)后,重新调用解析器提取数据
watch([
() => route.query.key,
() => route.query.sider_key,
() => menuStore.menuList,
], () => {
buildData()
}, {
deep: true,
})
onMounted(() => {
buildData();
})
return {
api,
tableSchema,
tableConfig,
searchSchema,
searchConfig,
}
}
至此,我们就已经完成了下列架构图中的所有设计部分内容。当然这中间有些接口的实现还有前端的组件细节等部分没有展现出来,因为我觉得没有必要花大篇幅介绍这些部分。关键还是在于了解领域模型的架构背后的核心思想
总结:
- 领域模型架构指的是以数据驱动项目为指引思想的快速生成对应领域系统的一种系统架构,其本质是一份由表数据项拓展开来的配置文件(项目中使用的是JSON-Schema举例)。
- 日常大部分的重复性工作是由于前端、后端对于同一份表数据的CRUD操作而造成的,其直接影响是前端需要新增组件、改动接口请求参数等来适配这份表数据,对于后端而言就是新增接口,老接口逻辑改动等操作。所以数据驱动项目就是为了解决这个问题,它允许用户通过仅改写配置文件项来快速完成一个数据项的CRUD操作,范围涵盖前端到后端。
- 仅通过配置数据项(字段)不足以覆盖我们一个完整系统,因此我们需要新增一些与项目相关的配置项进而完善整个项目的描述,文章中用了后台管理系统举例。沉淀了多个同领域的系统的配置项而抽象出来的配置项称之为领域模型。我们在文章中就因为观察大部分市面上后台管理系统的共性,继而沉淀出了后台管理系统领域模型
- 沉淀出领域模型的意义在于未来有相关开发需求或者新增项目的场景时,可以快速复用并生成对应的领域系统。例如后台管理系统领域模型可以用来快速生成一个电商管理系统,教育管理系统等等。
- 在复用性和可维护性、可拓展性的考量上,采用了面向对象的设计模式。因为面向对象的核心就是继承、封装和多态。其中继承就是一种最方便的拓展领域模型的方法。我们可以将领域模型作为基类,领域模型下的系统作为子类继承自领域模型,这样子未来如果需要加公共功能/配置,就可以直接在领域模型上完成。特定系统需要定制化配置时,只需要重载对应的属性配置项就可以了。
- 继承的实现方法是通过nodejs解析器完成的,核心使用了lodash的mergeWith方法。
- 前端层面上,考虑到拓展性因此设置了四种视图,分别是schema-view,iframe-view,sider-view以及custom-view。对于80%的场景和系统,schema-view和sider-view是最常用的视图,区别在于一个无侧边栏一个有侧边栏。当然为了未来自定义开发的可能性,也开放了custom-view和iframe-view给开发者所使用。前端层面对于配置的解析依靠的是一个vue hook完成,这个hook主要做的事情是解析我们在后端所继承、封装的领域模型配置后提供给对应组件使用。