DSL领域模型设计:
DSL(领域特定语言)是一种专门为某个业务领域设计的结构化语言(通常是 JSON 形式的schema),用来声明式地描述该领域的核心概念、规则和关系------只描述"是什么",不关心"怎么实现"。它本质上是一份契约:定义方(业务配置)与执行方(引擎/渲染器)基于同一份规范协作,彼此解耦、变更受控。基于这套 DSL,可以为不同业务系统产出各自的 model(schema 实例),其中稳定的、共性的部分沉淀为可复用的 80%;剩下 20% 的个性化需求,通过 model 预留的扩展点(自定义组件、扩展配置、业务 API)承接,且扩展必须在不破坏契约的前提下进行。
模板即 dashboard.model:一套全字段的基础模型说明,即 80% 的共性沉淀。不同业务系统共享这份基础模型,各自再补充私有的模型数据------私有模型继承基础模型并做覆盖与扩展,由解析器将两者合并,产出一份完整的模型。落到业务层视角:基于 80% 的固定业务,拓展 20% 的变更业务。**
js
{
mode: "dashboard", // 模板类型,不同模板类型对应不一样的模板数据结构
name: "", // 名称
desc: "", // 描述
icon: "", // 图标
homePage: "", // 首页(项目配置)
//头部菜单
menu: [
{
key: "", // 菜单唯一描述
name: "", // 菜单名称
menuType: "", // 枚举值:group/module
//当 menuType === group 时,可填 -----------------------
subMenu: [
{
// 可递归 menuItem
},
],
// 当 menuType === module 时,可填 -----------------------
moduleType: "", //枚举值:sider/iframe/custom/schema
// 当 menuType === sider 时,可填 -----------------------
siderConfig: {
menu: [
{
// 可递归 menuItem(除 menuType === sider)
},
],
},
// 当 menuType === iframe 时,可填 -----------------------
iframeConfig: {
path: "", // iframe 路径
},
// 当 menuType === custom 时,可填 -----------------------
customConfig: {
path: "", // 自定义路由路径
},
// 当 menuType === schema 时,可填 -----------------------
schemaConfig: {
api: "/api/user", // 数据源API(遵循 RESTFUL 规范)
schema: {
// 板块数据结构
tpye: "object",
properties: {
key: {
...schema, // 标准 schema 配置
type: "", // 字段类型
label: "", // 字段的中文名
// 字段在 table 中的相关配置
tableOption:{
...elTableColumnConfig, // 标准 el-table-columu 配置
toFixed:0, // 保留小数点后几位
visible:true, // 默认为 true (false 时,表示不在表单中显示 )
},
// 字段在 search-bar 中的相关配置
searchOption:{
...elComponentConfig, // 标准 el-component-columu 配置
comType:"", // 配置组件类型 input/select/...
default:"", // 对应 comType 表单的默认值
// comType === select
enumList:[], // 下拉框可选项
// comType === dynamicSelect
api:"", // 下拉框可选项
}
},
...
},
},
// table 相关配置
tableConfig: {
headerButtons:[{
label:"", // 按钮名称
eventKey:"", // 按钮事件名
eventOption:{}, // 按钮事件具体配置
...elButtonConfig // 标准的 el-button 配置
}, ...],
rowButtons:[{
label:"" , // 按钮中文名
event:"", // 按钮事件名
eventOption:{
// 当 eventKey === "remove"
params:{
// paramKey = 参数的键值
// rowValueKey = 参数值(当格式为 schaema::xxx 的时候,到 table 中找相应的字段)
paramKey: rowValueKey
}
}, // 按钮事件具体配置
...elButtonConfig // 标准的 el-button 配置
}, ...]
},
searchConfig: {}, // search-bar 相关配置
components: {}, // 模块组件
},
},
],
};
基于这份模板可以衍生处理很多的领域模型。而模型是业务维度的;模型可以派生出不同的业务项目,项目是配合解析器去生成一个交付的项目系统。
基础模型 model (80%固定业务)
js
module.exports = {
mode: "dashboard",
name: "电商系统",
menu: [
{
key: "product",
name: "商品管理",
menuType: "module",
moduleType: "schema",
schemaConfig: {
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: "dynamicSelect",
api: "/api/proj/product_enum/list",
},
},
price: {
type: "number",
label: "价格",
tableOption: {
width: 200,
},
searchOption: {
comType: "select",
enumList: [
{
label: "全部",
value: -999,
},
{
label: "¥111",
value: 111,
},
{
label: "¥222",
value: 222,
},
{
label: "¥333",
value: 333,
},
],
},
},
inventory: {
type: "number",
label: "库存",
tableOption: {
width: 200,
},
searchOption: {
comType: "input",
},
},
create_time: {
type: "strgin",
label: "创建时间",
tableOption: {},
searchOption: {
comType: "dateRange",
},
},
},
},
tableConfig: {
headerButtons: [
{
label: "新增商品",
eventKey: "showComponeent",
type: "primary",
plain: true,
},
],
rowButtons: [
{
label: "修改",
eventKey: "showComponeent",
type: "warning",
},
{
label: "删除",
eventKey: "remove",
eventOption: {
params: {
product_id: "schema::product_id",
},
},
type: "danger",
},
],
},
},
},
{
key: "order",
name: "订单管理",
menuType: "module",
moduleType: "custom",
customConfig: {
path: "/todo",
},
},
{
key: "client",
name: "客服管理",
menuType: "module",
moduleType: "custom",
customConfig: {
path: "/todo",
},
},
],
};
私有模型(20%变更业务):
js
module.exports = {
name: "拼多多",
desc: "拼多多电商系统",
homePage: "/schema?proj_key=pdd&key=product",
menu: [
{
key: "product",
name: "商品管理(拼多多)",
},
{
key: "client",
name: "客户管理(拼多多)",
moduleType: "schema",
schemaConfig: {
api: "/api/client",
},
},
{
key: "data",
name: "数据分析",
menuType: "module",
moduleType: "sider",
siderConfig: {
menu: [
{
key: "analysis",
name: "电商落盘",
menuType: "module",
moduleType: "custom",
customConfig: {
path: "/todo",
},
},
{
key: "sider-search",
name: "信息查询",
menuType: "module",
moduleType: "iframe",
iframeConfig: { path: "http://www.baidu.com" },
},
{
key: "categories",
name: "分类数据",
menuType: "group",
subMenu: [
{
key: "categories-1",
name: "一级分类",
menuType: "module",
moduleType: "custom",
customConfig: {
path: "/todo",
},
},
{
key: "categories-2",
name: "二级分类",
menuType: "module",
moduleType: "iframe",
iframeConfig: { path: "http://www.baidu.com" },
},
{
key: "tags",
name: "标签",
menuType: "module",
moduleType: "schema",
schemaConfig: {
api: "/api/client",
},
},
],
},
],
},
},
{
key: "search",
name: "信息查询",
menuType: "module",
moduleType: "iframe",
iframeConfig: { path: "http://www.baidu.com" },
},
],
};
模型解析器:
key是数组合并的主键 :menu 数组按key匹配做"改/增/留",所以所有菜单项的 key 必须全局唯一,这是合并正确性的前提。 合并只叠加、不删除:project 无法"删掉" model 里的菜单项,只能改或增。
js
const _ = require("lodash");
const glob = require("glob");
const path = require("path");
const { sep } = path;
// project 继承 model 方法
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(
(projItem) => projItem.key === modelItem.key,
);
// project 有的键值 model 也有, 则调用递归 projectExtendModel 方法覆盖修改
result.push(
projItem ? projectExtendModel(modelItem, projItem) : modelItem,
);
}
// 处理新增
for (let i = 0; i < projValue.length; ++i) {
const parjItem = projValue[i];
const modelItem = modelValue.find(
(modelItem) => modelItem.key === parjItem.key,
);
if (!modelItem) {
result.push(parjItem);
}
}
return result;
}
});
};
/**
* 解析 model 配置,并返回组织且继承后的数据结构
* [{
* model: ${model},
* project: {
* proj1Key: ${parj1},
* proj2Key: ${parj2},
* }
* }, ...]
*/
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("/project/") > -1 ? "project" : "model";
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);
if (!modelItem) {
// 初始化 model 数据结构
modelItem = {};
modelList.push(modelItem);
}
if (!modelItem.project) {
// 初始化 project 数据结构
modelItem.project = {};
}
modelItem.project[projKey] = require(path.resolve(file));
modelItem.project[projKey].key = projKey; // 注入 projectKey
modelItem.project[projKey].modelKey = modelKey; // 注入 modelKey
} else 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;
};
模型应用 & API实现
基于 dashboard.vue 去解析 model,而 model 中可以衍生出很多的配置项;它可以是参数、配置、API接口、组件配置、自 定义场景等等的可拓展定义。
menuType 和 moduleType 是两个正交维度:menuType=group 的节点只有 subMenu 子菜单,menuType=module 时存在moduleType,决定叶子怎么渲染。moduleType=sider子菜单 | iframe加载外部页面 | custom自定义页面 | schema模板定制沉淀的组件 配置(遵循json-schema规范)。通过这份源数据可以描述出一份菜单、页面、组 件、数据库表等设计。
- URL query 就是运行时上下文:proj_key + key + sider_key 三个参数决定了渲染哪个项目、哪个菜单、哪个 sider 子菜单。homePage: "/schema?proj_key=pdd&key=product" 的意义正在于此。
- 启动链路:dashboard.vue → /api/project 存 menuStore(渲染 header 菜单)、/api/project/list 存projectStore(右上角项目切换)→ onMenuSelect 按 moduleType 路由到/view/dashboard/{schema|sider|iframe|custom}。
- schema-view 链路:hook/useSchema 用 menuStore.findMenuItem({ key: "key", value: siderKey ?? key }) 从菜单反查 schemaConfig → buildDtoSchema 把 *Option 清洗成 option(噪音分离)→ provide("schemaViewData") 注入 search-panel/table-panel。
业务实现 & 接口测试
传统的测试需要注释校验中间件去测试接口,这么人为处理会导致忘记打开注释的安全风险,所以可以通过mocha插件来处理验证接口的可用性;supertest模拟请求服务工具。开发接口后须要校验接口的可用性且要严谨的校验接口的参数和开发场景。
- Mocha :测试运行器,负责
describe/it组织用例、管理生命周期钩子,并输出测试报告。- Supertest:HTTP 接口测试库,接收任意 HTTP 应用实例发起请求,不需要真正启动服务器端口。本项目是 Koa 应用,用法为 supertest(app.listen())------首个 it 启动服务拿到 request 实例,后续用例复用。
- router-schema 定义接口
- router 注册接口 & 调用 controller
- controller 调用 service 并处理返回数据
- service 调用数据源
小课间
- 编写代码的过程中,在对应的业务模块尽量保持独立文件夹独自管理自己的业务和资源,这样可以便于在迭代的过程中更好的管理和维护对应的增、删、改,这也是一种长远的设计原则,还有优化方式:懒加载路由、组件内置slot、提供iframe微前端引入入口等。
- 懒加载分为分包策略和加载文件策略,根据分包规则配置,结合代码的实际引用,它可以实现自己页面内容根据分包规则打出来的文件独自加载自己所需的资源,才是实现真正意义上的懒加载。
- 组件搭建的时候一定要有3个核心要素:可以通过什么方式可以接收什么参数、可以向外暴露出什么事件响应、可以提供给外部什么调用方法。
- 在开发的过程中,要阶段性的复盘和回顾,及时的发现"局限"或者"问题",去做优化或者小重构,不要堆积屎山导致到最后无从下手。