一、配置应该描述「是什么」,而不是「怎么做」
假设让一个不写代码的人描述一个后台管理系统,他大概会说:「有一个电商系统,顶部有商品管理、订单管理、客户管理三个菜单。点商品管理进去,能看到一张表,有商品名、价格、库存这些列,上面有个搜索栏可以按名称筛选。」
他不会说「需要一个 el-table 组件,绑定一个 columns 数组」。他描述的是业务结构,不是 UI 实现。
这就是 Elpis DSL 的第一个设计原则:DSL 描述领域知识,不是渲染指令。
{ key: "product", name: "商品管理", menuType: "module", moduleType: "schema" }
这行配置没有说「渲染一个页面,左边放搜索栏,右边放表格」。它只是声明:这是一个叫「商品管理」的模块,类型是 schema。至于 schema 类型长什么样、怎么渲染——那是框架的事,不是配置的事。
这个区分很重要。一旦 DSL 开始描述「怎么做」,它就退化成了另一种形式的代码,配置的人需要理解渲染逻辑,维护成本并没有真正降低。
二、一个字段的本质:一次定义,全栈投影
「商品名称」这个字段,在表格里是一列,在搜索栏里是一个下拉框,在表单里是一个输入框,在数据库里是一个 VARCHAR,在 API 参数校验里是一个 string 约束。
传统做法是在每个场景分别定义它——表格组件写一遍列配置,搜索组件写一遍控件配置,后端建表再写一遍 DDL。同一个字段的信息被打散到了五六个地方。
换个角度想:一个字段就是一个字段,它在不同场景下的表现只是它的不同「投影」。
product_name: {
type: "string",
label: "商品名称",
dbOption: { length: 128, index: true },
apiOption: { required: true, validator: "string" },
tableOption: { width: 200 },
searchOption: { comType: "dynamicSelect", api: "/api/proj/product_enum/list" },
formOption: { comType: "input", placeholder: "请输入商品名称" }
}
每个 Option 后缀就是一个投影面:
| Option | 投影方向 | 作用 |
|---|---|---|
dbOption | 数据库 | 驱动建表语句生成(VARCHAR(128)、索引) |
apiOption | 接口层 | 驱动 API 参数校验和路由生成 |
tableOption | 表格 | 驱动列渲染(列宽、溢出提示) |
searchOption | 搜索栏 | 驱动搜索控件渲染(下拉、输入、日期) |
formOption | 表单 | 驱动表单控件渲染(输入框、选择器) |
字段的本质信息(type、label)只存在一个地方,各场景的差异化表现通过 Option 挂载。框架在运行时按需提取——渲染表格时遍历所有 tableOption,生成建表语句时遍历所有 dbOption,注册 API 路由时遍历所有 apiOption。
统一定义,按需投影。一份 Schema 驱动全栈。
这意味着新增一个业务模块的完整流程可以收敛为:写一份字段 Schema → 框架自动生成数据库表 → 自动注册 CRUD API → 自动渲染前端搜索栏、表格、表单。从数据库到浏览器,中间不需要手写任何胶水代码。
当前 Elpis 已经实现了 tableOption 和 searchOption 的前端投影,router-schema 中的 API Schema 也在驱动参数校验中间件。dbOption → 自动建表、apiOption → 自动生成 RESTful 接口,是这套架构自然延伸的方向——不是另起炉灶,而是在同一份 Schema 上继续挂载新的投影面。
三、容器与内容的分离:让布局成为可选项
后台系统的页面布局无非几种:纯内容区、顶部导航 + 内容区、侧边栏 + 内容区。传统做法是每种布局写一个页面模板,业务代码和布局代码耦合在一起。
Elpis 的思路是把布局抽象为容器,把业务抽象为内容,两者通过插槽组合。
header-container 只关心一件事:顶部放什么、主体放什么。它通过三个插槽(menu-content、setting-content、main-content)把位置暴露出来,但完全不关心填进去的是什么。sider-container 同理:左边放什么、右边放什么,仅此而已。
这意味着 DSL 中的 moduleType 实际上是在选择容器组合策略:
schema/iframe/custom→ 直接渲染在当前容器的内容区sider→ 套一层sider-container,内容区里再嵌套router-view
容器是稳定的骨架,内容是流动的业务。DSL 选择容器,插槽填充内容。布局策略可以独立演进,不影响业务配置;业务配置可以自由组合,不受布局约束。
更有意思的是嵌套。sider-view 被渲染在 header-container 的 main-content 插槽里,而它内部又用 sider-container 创建了自己的侧边栏布局,它的内容区里放的是另一个 router-view——这个 router-view 里可以是 schema-view,也可以是 iframe-view,甚至理论上可以再嵌套一个 sider-view。
容器嵌套容器,插槽嵌套插槽,DSL 的递归结构自然映射为视图的递归嵌套。 这不是刻意设计出来的,而是当容器和内容分离之后,递归能力就自然涌现了。
四、菜单即架构:一棵树的多重身份
menuType 和 moduleType 的二维类型系统是这棵树的骨架:
menuType
├── group → 纯粹的分组,不对应功能,只包含子菜单(支持递归)
└── module → 对应一个具体功能
├── schema → 零代码 CRUD(配置驱动)
├── iframe → 嵌入外部页面(零开发)
├── sider → 侧边栏复合布局(内部可继续嵌套菜单树)
└── custom → 自定义组件(开发者手写)
group 是结构节点,module 是功能节点。group 可以嵌套 group,sider 内部又可以包含一棵完整的子菜单树。菜单结构可以无限深,但每一层的语义都是清晰的:要么在组织结构,要么在提供功能。
五、用差异描述变化:继承体系
多租户场景下,不同项目之间往往有 80% 的相似度和 20% 的差异。复制整份配置再修改,那 80% 的相同部分就变成了维护负担。
Elpis 的解法是 Model → Project 的继承:定义一个基础 Model,每个 Project 只描述和 Model 的差异。
Model(电商基础模型):商品管理 + 订单管理 + 客户管理
Project 拼多多(只写差异):
商品管理 → 改个名字 // 同 key → 递归合并
数据分析 → 新增侧边栏模块 // 新 key → 追加
(订单管理、客户管理自动继承) // Model 独有 → 保留
合并算法基于菜单项的 key 做递归匹配,规则只有三条:同 key 递归合并、Model 独有则保留、Project 独有则追加。
这套机制的哲学是:用差异描述变化,而不是用全量描述状态。 就像 Git 存储的是 diff 而不是快照,Project 配置存储的是「和 Model 的不同之处」。新增一个项目的成本从「复制一整套配置」降低到「写一个差异文件」。
而且继承是深度递归的。如果 Model 中某个菜单项有完整的 schemaConfig,Project 中同 key 的菜单项只修改了 name,合并后 schemaConfig 会被完整保留。不需要为了改一个名字而重新声明整个配置块。
六、边界感:DSL 不应该做什么
有些东西看起来可以配置化,但不应该放进 DSL。
不描述交互流程。 「点击按钮后弹确认框,确认后发请求,成功后刷新表格」——这是流程,不是结构。DSL 中只声明按钮的存在和事件标识(eventKey: "remove"),具体的交互流程由框架内置的事件处理器完成。
不描述样式细节。 表格列宽可以配,因为它影响信息可读性,属于业务决策。但颜色、间距、动画这些纯视觉层面的东西不应该出现在 DSL 中,那是主题系统的职责。
不追求万能。 一旦 DSL 试图覆盖所有场景,它就会变得和通用编程语言一样复杂,失去「领域特定」的意义。Elpis 保留了 custom 类型作为逃生舱——当配置无法满足需求时,随时可以回到代码世界。
覆盖 80% 的通用场景,为剩下的 20% 留好出口。 这个边界感可能是 DSL 设计中最难把握、也最值得反复推敲的部分。
七、回到原点
回头看,Elpis 的 DSL 设计可以归结为几个朴素的想法:
- 描述「是什么」而不是「怎么做」 —— 让配置停留在业务语义层
- 一次定义,全栈投影 —— 一个字段通过 Option 后缀向数据库、API、表格、搜索栏、表单多方向投影
- 容器与内容分离 —— 布局是骨架,业务是内容,插槽是连接方式
- 菜单即架构 —— 一棵树承载导航、路由、模块、配置四重职责
- 用差异描述变化 —— 继承体系让多租户定制从复制全量变为声明差异
- 守住边界 —— 覆盖通用场景,为特殊需求保留代码出口
这些想法散落在面向对象、领域驱动设计等各种方法论中,Elpis 只是把它们组合在一起,落地到了一个具体的后台管理框架中。
好的 DSL 不是让人少写代码,而是让人少做决策。面对一个新模块时,不需要思考「用什么布局、怎么组织路由、表格列怎么定义、建表语句怎么写」,只需要回答一个问题:「这个模块的业务结构是什么?」 剩下的,交给框架。
🛠 技术栈:Koa + Vue 3 + Element Plus + Pinia + Webpack
学习与哲玄全栈课程