领域模型驱动设计解析(基于elpis框架)

从重复 CRUD 到领域模型 DSL:Elpis 配置化 Dashboard 的设计与实践

本文聚焦 elpis 项目中的领域模型 DSL(Domain Specific Language)层,聊一聊:为什么需要一个模型 DSL、它是怎么被设计出来的、几个关键技术点,以及未来可以往哪些方向扩展。适合正在做低代码/中后台配置化平台、或者希望用"配置即页面"思路减少重复开发的同学。


一、为什么需要领域模型 DSL?

做过后台系统的同学应该都有同感:

  • 同一个业务域下往往有多个相似项目/租户,比如"电商系统"下面有京东、淘宝、拼多多;"课程系统"下面有抖音课堂、B站课堂;
  • 每个项目的菜单、表格、搜索条件、按钮大同小异,但细节又不一样;
  • 如果按照传统方式开发,每个项目都要写一套前端页面 + 后端接口 + 路由 + 校验,重复劳动非常多。

elpis 给出的解法就是:把页面和接口的元信息抽象成一套领域模型 DSL,用配置驱动 UI 和 API 行为。

这样做的好处很明显:

传统开发 模型 DSL 驱动
每个项目新建页面文件 新增一个 project/*.js 配置文件即可
表格/搜索/按钮硬编码 通过 JSON Schema + UI Option 配置化
公共逻辑复制粘贴 通过 Model/Project 继承自动复用并覆盖
接口校验单独写 复用 DSL 中的 schema 定义
新增字段要改多处 改一处 DSL 配置,前后端同步生效

所以,这里的 DSL 并不仅仅是一个"JSON 配置文件",它是一整套描述业务领域、页面结构、交互行为的语言


二、DSL 的整体结构

elpis 中,领域模型 DSL 主要存放在 model/ 目录下,核心分为两层:

text 复制代码
model/
├── business/
│   ├── model.js              # 领域模型:定义"电商系统"的通用结构
│   └── project/
│       ├── jd.js             # 项目:京东,继承并覆盖电商模型
│       ├── pdd.js            # 项目:拼多多
│       └── taobao.js         # 项目:淘宝
└── course/
    ├── model.js              # 领域模型:定义"课程系统"的通用结构
    └── project/
        ├── bilibili.js       # 项目:B站课堂
        └── douyin.js         # 项目:抖音课堂

它的 DSL 结构可以抽象为下面这张图:

图 1:领域模型 DSL 的层级结构

一个最小可运行的 DSL 配置示例,来自 model/business/model.js

js 复制代码
module.exports = {
  model: '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: '商品名称',
            searchOption: { comType: 'dynamicSelect', api: '/api/proj/product_enum/list' }
          },
          price: {
            type: 'number',
            label: '价格',
            searchOption: {
              comType: 'select',
              enumList: [{ value: 39.9, label: '¥39.9' }, { value: 199, label: '¥199.9' }]
            }
          }
        }
      },
      tableConfig: {
        headerButtons: [{ label: '新增商品', eventKey: 'showComponent', type: 'primary' }],
        rowButtons: [
          { label: '修改', eventKey: 'showComponent', type: 'warning' },
          { label: '删除', eventKey: 'remove', eventOption: { params: { product_id: 'schema::product_id' } }, type: 'danger' }
        ]
      }
    }
  }]
}

这段配置已经描述了:

  • 一个菜单项"商品管理";
  • 模块类型是 schema,即由 DSL 自动渲染表格和搜索;
  • 数据模型是 product_idproduct_nameprice 等字段;
  • 表格列宽、搜索组件类型、下拉选项、操作按钮。

后端不用为每个字段写 Controller,前端也不用为每个页面写 Vue 组件,这就是 DSL 的威力。


三、核心设计一:Model / Project 两层继承

Elpis 的 DSL 没有把所有项目都写成独立文件,而是把"公共结构"和"项目差异"拆开了。

3.1 为什么拆两层?

  • Domain Model:描述一个业务域的通用能力,比如电商系统都有商品、订单、用户;
  • Project:描述具体租户/品牌如何使用这些能力,比如京东只保留商品删除按钮,拼多多增加客户管理,淘宝把订单改为 iframe 嵌入。

这样新增一个项目时,只需要写"差异部分",公共部分自动继承。

3.2 继承合并规则

model/index.js 中实现了 ProjectExtendModel

js 复制代码
const ProjectExtendModel = (model, project) => {
  return _.mergeWith({}, model, project, (modelValue, projectValue) => {
    if (Array.isArray(modelValue) && Array.isArray(projectValue)) {
      let result = []
      // 1. 修改 & 保留:按 key 对齐,相同 key 递归合并
      for (let i = 0; i < modelValue.length; i++) {
        let modelItem = modelValue[i]
        const projectItem = projectValue.find(p => p.key === modelItem.key)
        result.push(projectItem ? ProjectExtendModel(modelItem, projectItem) : modelItem)
      }
      // 2. 新增:project 有但 model 没有的 key 直接追加
      for (let i = 0; i < projectValue.length; i++) {
        let projectItem = projectValue[i]
        let modelItem = modelValue.find(m => m.key === projectItem.key)
        if (!modelItem) result.push(projectItem)
      }
      return result
    }
  })
}

合并规则可以用一句话概括:

对象深度合并,数组按 key 对齐,项目覆盖模型,模型保留项目没有的部分。

对应的 DSL 语义如下:

场景 语义 示例
project 和 model 有相同 key 项目覆盖模型的该项 京东移除 model 的"新增/修改"按钮
project 有、model 没有 项目新增 拼多多新增"客户管理"菜单
project 没有、model 有 项目继承 京东继承 model 的字段定义

3.3 继承示例

以电商系统为例:

图 2:Model / Project 继承覆盖示例

代码上可以看到,京东的 rowButtons 只写了删除:

js 复制代码
// model/business/project/jd.js
rowButtons: [
  {
    label: '删除',
    eventKey: 'remove',
    eventOption: { params: { product_id: 'schema::product_id' } },
    type: 'danger'
  }
]

最终合并后,京东的商品管理页面会保留 model 中的字段和搜索配置,但表格操作列只有"删除",没有"新增"和"修改"。


四、核心设计二:Schema 驱动的页面渲染

DSL 的第二个关键设计是:数据模型与 UI 模型分离,但写在一起。

每个字段定义大致如下:

js 复制代码
product_name: {
  type: 'string',          // JSON Schema 数据类型
  label: '商品名称',        // UI 展示名
  tableOption: {           // 表格列配置(透传给 el-table-column)
    width: 200
  },
  searchOption: {          // 搜索组件配置(透传给 el-form-item)
    comType: 'dynamicSelect',
    api: '/api/proj/product_enum/list'
  }
}

这里同时包含了三层信息:

  1. 数据层type 等标准 JSON Schema 字段,可用于后端校验、前端类型推断;
  2. 展示层label 用于表格/搜索标签;
  3. 交互层tableOptionsearchOption 等透传给 Element Plus 组件。

4.1 前端如何消费 DSL?

前端通过 useSchema hook(app/pages/dashboard/complex-view/schema-view/hook/schema.js)把 DSL 中的"混合字段"拆成不同组件需要的 schema:

js 复制代码
const buildDtoSchema = function (_schema, comName) {
  if (!_schema?.properties) return {}
  const dtoSchema = { type: 'object', properties: {} }
  for (const key in _schema.properties) {
    const props = _schema.properties[key]
    if (props[`${comName}Option`]) {      // 只取有 tableOption / searchOption 的字段
      let dtoProps = {}
      for (const pKey in props) {
        if (pKey.indexOf('Option') < 0) dtoProps[pKey] = props[pKey]
      }
      dtoProps = Object.assign({}, dtoProps, { option: props[`${comName}Option`] })
      dtoSchema.properties[key] = dtoProps
    }
  }
  return dtoSchema
}

一段 DSL 配置会被拆成:

  • tableSchema:给 SchemaTable 渲染列;
  • searchSchema:给 SchemaSearchBar 渲染搜索条件;
  • api:表格数据请求地址。

整个渲染链路如下:

图 3:Schema 驱动的渲染链路

4.2 动态组件映射

搜索组件不是写死的,而是通过 comType 动态映射:

js 复制代码
// app/pages/widgets/schema-search-bar/search-item-config.js
import input from './complex-view/input/input.vue'
import select from './complex-view/select/select.vue'
import dynamicSelect from './complex-view/dynamic-select/dynamic-select.vue'
import dateRange from './complex-view/date-range/date-range.vue'

const SearchItemConfig = {
  input:         { component: input },
  select:        { component: select },
  dynamicSelect: { component: dynamicSelect },
  dateRange:     { component: dateRange }
}

模板里直接用 <component :is="..." /> 动态渲染,后续新增搜索组件类型只需要在 DSL 中增加 comType 并在映射表中注册一个新组件即可。


五、核心设计三:模块类型与菜单结构

DSL 不只是描述表格,它还描述了整个后台的导航结构。menuTypemoduleType 组合出了几种常见的页面形态:

graph TD A[Menu] --> B[menuType: group<br/>可折叠分组] A --> C[menuType: module<br/>具体模块] C --> D[moduleType: schema<br/>配置化表格/搜索] C --> E[moduleType: custom<br/>自定义页面路径] C --> F[moduleType: iframe<br/>嵌入外部页面] C --> G[moduleType: sider<br/>二级侧栏菜单] G --> H[子菜单可再嵌套 schema/custom/iframe/group]

图 4:DSL 支持的菜单/模块类型

5.1 模块类型说明

类型 含义 典型场景
schema 由 DSL 自动生成表格 + 搜索 + 按钮 商品管理、客户管理
custom 自定义前端路径,渲染独立 Vue 页面 订单管理、用户管理
iframe 嵌入外部页面 淘宝订单页、百度查询
sider 点击后展开左侧二级菜单 数据分析、运营活动
group 仅作为分组,可递归包含子菜单 分类数据、课程资料

比如淘宝的订单管理就是 iframe

js 复制代码
{
  key: 'order',
  name: '订单管理',
  menuType: 'module',
  moduleType: 'iframe',
  iframeConfig: { path: 'http://www.taobao.com' }
}

拼多多的数据分析是 sider

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

这样一套 DSL 就可以覆盖中后台 80% 以上的页面形态,而不需要为每种形态写死路由。


六、核心设计四:操作按钮与事件 DSL

表格操作不是硬编码事件函数,而是声明式的事件 DSL:

js 复制代码
rowButtons: [
  {
    label: '删除',
    eventKey: 'remove',
    eventOption: {
      params: { product_id: 'schema::product_id' }
    },
    type: 'danger'
  }
]

eventKey 是事件名,eventOption.params 是事件参数。其中 schema::product_id 是一个行内数据绑定表达式 ,表示从当前行的 product_id 字段取值。

前端 TablePanel 解析这个表达式:

js 复制代码
const removeValueList = params[removeKey].split('::')
if (removeValueList[0] === 'schema' && removeValueList[1]) {
  removeValue = rowData[removeValueList[1]]
}

这样,按钮文案、类型、参数来源、绑定字段全部在 DSL 中声明,前端不需要为每个表格单独写删除逻辑。后续新增按钮只需要定义新的 eventKey 并在事件中心注册对应处理函数即可。


七、核心设计五:从 DSL 到 API 校验与租户上下文

DSL 中定义的数据模型,可以直接用于后端 API 参数校验。项目中 app/router-schema/business.js 就是一份与 DSL 字段对应的校验规则:

js 复制代码
module.exports = {
  '/api/proj/product/list': {
    get: {
      query: {
        type: 'object',
        properties: { page: { type: 'string' }, size: { type: 'string' } },
        required: ['page', 'size']
      }
    }
  },
  '/api/proj/product': {
    delete: {
      body: {
        type: 'object',
        properties: { product_id: { type: 'string' } },
        required: ['product_id']
      }
    }
  }
}

api-params-verify 中间件用 AJV 自动读取这些规则并校验请求。

此外,对于 /api/proj/* 这类业务接口,DSL 还通过 proj_key 头区分租户:

js 复制代码
// app/middleware/project-handler.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (!ctx.path.startsWith('/api/proj/')) return await next()
    const { proj_key: projKey } = ctx.request.headers
    if (!projKey) {
      ctx.body = { success: false, code: 446, msg: 'proj_key不能为空' }
      return
    }
    ctx.projKey = projKey
    await next()
  }
}

这意味着:DSL 本身可以按项目区分,API 也可以按项目路由,天然支持多租户/多项目后台。


八、模型加载与生命周期

DSL 配置在应用启动时会被 model/index.js 扫描、合并并注入到内存中:

js 复制代码
const modelList = []
const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`))
fileList.forEach((file) => {
  if (file.indexOf('index.js') > -1) return
  const type = file.indexOf('/project/') > -1 ? 'project' : 'model'
  if (type === 'project') {
    const modelKey = file.match(/\/model\/(.*?)\/project/)?.[1]
    const projectKey = file.match(/\/project\/(.*?)\.js/)?.[1]
    // ... 挂载到 modelList[i].project[projectKey]
  }
  if (type === 'model') {
    const modelKey = file.match(/\/model\/(.*?)\/model\.js/)?.[1]
    // ... 挂载到 modelList[i].model
  }
})

// 最后执行一次继承合并
modelList.forEach(item => {
  const { model, project } = item
  for (const key in project) {
    project[key] = ProjectExtendModel(model, project[key])
  }
})
graph LR A[启动扫描 model/**/*.js] --> B[区分 model / project] B --> C[注入 modelKey / projectKey] D --> E[挂载到 app.service / app.controller] C --> D[按项目调用 ProjectExtendModel]

图 5:DSL 加载与合并生命周期

加载完成后,Service 和 Controller 直接读取内存中的 modelList,无需访问数据库。这种设计适合 DSL 相对稳定、变更不频繁的场景,启动时一次性构建好完整的领域模型视图。


九、未来可扩展方向

围绕这套领域模型 DSL,可以持续做深:

9.1 在线 DSL 编辑器

目前 DSL 是 JS 文件,需要改代码重启。可以把它持久化到数据库或 OSS,提供可视化编辑器:

  • 左侧模型树、右侧表单属性面板;
  • 实时预览 Schema 页面;
  • 发布时自动更新内存缓存。

9.2 表单/详情/弹窗 DSL

目前只覆盖了表格和搜索。可以继续扩展:

  • formOption:新增/编辑表单字段;
  • detailOption:详情页字段;
  • modalOption:弹窗布局;
  • 让"增删改查"全流程都能被 DSL 描述。

9.3 权限 DSL

增加角色与菜单/按钮的绑定:

js 复制代码
permission: {
  roles: ['admin', 'editor'],
  visible: true,
  buttons: { remove: ['admin'] }
}

前端根据权限隐藏菜单项或按钮,后端在接口层也做二次校验。

9.4 数据源抽象

目前 DSL 的 api 字段直接写死后端路径。可以抽象为:

js 复制代码
api: { type: 'http', path: '/api/proj/product' }
// 或
api: { type: 'sql', table: 'products' }
// 或
api: { type: 'mock', data: [...] }

让 DSL 不依赖具体后端实现,平台根据数据源类型自动路由。

9.5 DSL 生成物

既然 DSL 是结构化的,可以向上生长出很多产物:

  • 自动生成 API 文档(Swagger/OpenAPI);
  • 自动生成 TypeScript 类型定义;
  • 自动生成前端路由配置;
  • 自动生成测试用例和 mock 数据;
  • 自动生成接口参数校验规则,替代手写 router-schema

9.6 组件市场与插件化

moduleTypecomType 从硬编码改为插件注册:

js 复制代码
registerModule('echarts', EchartsModule)
registerSearchComponent('citySelect', CitySelect)

业务方可以像 npm 包一样安装自定义模块类型,平台自动加载。


十、总结

Elpis 的领域模型 DSL 层并不是"为了配置而配置",它解决的是一个非常真实的工程问题:中后台系统里大量相似业务线的重复开发。

它的核心设计可以概括为:

  1. 两层抽象:Domain Model + Project,公共能力复用,项目差异覆盖;
  2. 数据与 UI 统一描述 :JSON Schema 数据模型 + tableOption/searchOption UI 模型写在同一个字段里;
  3. 模块类型覆盖常见页面形态:schema、custom、iframe、sider、group;
  4. 声明式事件 DSLeventKey + eventOption.params 让按钮行为可配置;
  5. 启动时加载合并:约定目录 + 自动扫描 + 内存缓存,简化使用成本;
  6. 与 API 校验、租户上下文联动:schema 既驱动页面,也驱动接口校验和多项目隔离。

如果把它继续演进,目标会是一个"领域模型即代码/即配置"的低代码平台:业务人员改 DSL,系统自动生成页面、接口、校验、文档。这也是很多中后台配置化平台正在走的路。


参考位置

  • model/index.js:模型扫描与继承合并
  • model/business/model.js:电商领域模型示例
  • model/business/project/jd.js:京东项目继承覆盖示例
  • docs/dashboard-model.js:DSL 模板定义
  • app/pages/dashboard/complex-view/schema-view/hook/schema.js:前端 DSL 消费与拆分
  • app/pages/widgets/schema-search-bar/search-item-config.js:搜索组件动态映射
  • app/pages/widgets/schema-table/schema-table.vue:表格自动渲染
  • app/router-schema/business.js:基于 DSL 的接口校验
  • app/middleware/project-handler.js:多项目租户上下文

希望本文对你理解领域模型设计有所帮助!

项目来源哲玄AI全栈

相关推荐
swipe9 小时前
05|(前端转后全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
前端·后端·全栈
swipe9 小时前
04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
前端·后端·全栈
swipe1 天前
03|Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
前端·后端·全栈
swipe1 天前
02|从 `pnpm dev` 到 Spring Boot 启动:后端服务到底怎么跑起来?
前端·后端·全栈
swipe1 天前
01|前端人第一次打开 Spring Boot 项目,应该先看哪里?
前端·后端·全栈
人间凡尔赛2 天前
2026 年 React Server Components 完全指南:Next.js 16 全栈开发最佳实践
前端·typescript·react·全栈·next.js
达达尼昂2 天前
AI 编程的工程化实践:Flutter AI Harness 的设计与落地
人工智能·后端·全栈
jieyucx2 天前
Nuxt4阶段六:工程化与进阶 —— 模块、中间件、插件、TS 与测试
中间件·vue·web·nuxt·全栈·ssr
前端双越老师3 天前
如何以前端视角(非0基础)学 Java ?
java·node.js·全栈