从重复 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_id、product_name、price等字段; - 表格列宽、搜索组件类型、下拉选项、操作按钮。
后端不用为每个字段写 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'
}
}
这里同时包含了三层信息:
- 数据层 :
type等标准 JSON Schema 字段,可用于后端校验、前端类型推断; - 展示层 :
label用于表格/搜索标签; - 交互层 :
tableOption、searchOption等透传给 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 不只是描述表格,它还描述了整个后台的导航结构。menuType 和 moduleType 组合出了几种常见的页面形态:
图 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])
}
})
图 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 组件市场与插件化
把 moduleType 和 comType 从硬编码改为插件注册:
js
registerModule('echarts', EchartsModule)
registerSearchComponent('citySelect', CitySelect)
业务方可以像 npm 包一样安装自定义模块类型,平台自动加载。
十、总结
Elpis 的领域模型 DSL 层并不是"为了配置而配置",它解决的是一个非常真实的工程问题:中后台系统里大量相似业务线的重复开发。
它的核心设计可以概括为:
- 两层抽象:Domain Model + Project,公共能力复用,项目差异覆盖;
- 数据与 UI 统一描述 :JSON Schema 数据模型 +
tableOption/searchOptionUI 模型写在同一个字段里; - 模块类型覆盖常见页面形态:schema、custom、iframe、sider、group;
- 声明式事件 DSL :
eventKey+eventOption.params让按钮行为可配置; - 启动时加载合并:约定目录 + 自动扫描 + 内存缓存,简化使用成本;
- 与 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全栈