1. 背景:为什么 elpis-core 需要领域模型
elpis-core 不只是一个基于 Koa 的轻量 Web 引擎。它开始承担更高一层的目标:用统一的领域模型描述不同业务系统,再由框架把模型转换成后端接口、菜单结构、页面模块和前端渲染配置。
传统后台系统常见的问题是:每接入一个业务项目,就新增一套菜单、接口、表格、搜索栏和页面组件。代码能跑,但重复度高,业务差异分散在 controller、service、Vue 页面和路由里。elpis-core 当前分支采用的思路是把这些差异前置到 model/ 目录中,让模型成为系统的核心输入。
这套设计可以概括为一句话:业务项目不直接驱动页面和接口,领域模型先描述业务能力,框架再把模型翻译成可运行的 Web 应用。
1.1 什么是 DSL
DSL(Domain-Specific Language,领域专用语言)是为特定问题空间设计的表达方式。Elpis 使用 JavaScript 对象作为内部 DSL 的载体,由框架约定字段、结构与解释规则。这里的问题空间,是后台系统中的项目、菜单、页面模块、表格和搜索能力。
"内部 DSL"表示它借用了已有语言的语法。在 elpis-core 中,开发者通过 module.exports 导出对象,不需要学习一套新的语法或编写独立解析器;但对象中的 menuType、moduleType、schemaConfig 等字段具有框架定义的业务含义。JavaScript 负责表达对象,框架负责解释对象所声明的能力。
下面是基于当前商品模型缩减的示例:
js
const productModule = {
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_name: {
type: 'string',
label: '商品名称',
tableOption: { width: 200 },
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
}
}
}
}
}
}
这段配置表达的是:声明一个商品管理模块,使用 Schema 页面,以指定接口作为数据源,让商品名称同时出现在表格和动态下拉搜索中。模块选择逻辑、useSchema、表格组件和搜索组件共同承担解释执行的职责。开发者声明"页面有哪些能力",通用组件完成相应渲染。
因此,DSL 的关键在于稳定的领域词汇和解释规则。单独一个 JavaScript 对象只是数据;当框架赋予其字段一致的业务语义,并据此执行加载、合并和渲染时,它就成为了配置式的领域表达语言。整个项目模型属于 Elpis DSL,嵌套的 schemaConfig.schema 则负责字段层的描述。
1.2 什么是 JSON Schema,为什么采用它
JSON 是数据表示格式;JSON Schema 是描述 JSON 数据结构及约束的规范。例如 type: 'object' 表示对象,properties 声明对象各字段的 Schema,required 可以声明必填字段。标准也提供 title、description 等注解。参见 JSON Schema 对象说明 与 注解说明。
结合当前分支,可以从以下几个方面理解采用这种结构的工程价值:
- 统一字段描述。 商品名称、价格、库存等字段都放在
properties下,并携带类型和展示信息。表格与搜索栏可以共用字段定义,减少多处维护字段名造成的不一致。 - 便于生成不同界面。
buildDtoSchema(schema, 'table')选出有tableOption的字段,buildDtoSchema(schema, 'search')选出有searchOption的字段,再将对应配置转换为组件使用的option。同一份模型可以投影成表格和搜索两种视图。 - 便于接口传输。 当前页面 Schema 采用可 JSON 序列化的数据结构,可以由服务端通过项目接口下发,前端按约定消费。
useSchema也通过 JSON 序列化进行复制,因此这一部分应保持为可序列化数据,不能依赖函数随接口传递。 - 便于项目覆盖。 字段以属性名组织,项目可以沿着
schemaConfig.schema.properties覆盖局部配置。例如只修改商品名称列的宽度,就能复用基模中的其他字段与搜索配置。 - 为后续校验提供基础。 采用标准字段结构,便于将来接入明确版本的 JSON Schema 校验器,复用类型、必填项等规则。这个收益需要实际接入和规则对齐才能实现。
1.3 标准字段与 Elpis 扩展字段的边界
当前实现更准确地说,是"采用 JSON Schema 风格的数据结构,并加入 Elpis 的 UI 描述扩展"。type、properties 属于标准词汇;label、tableOption、searchOption、comType、enumList 则是框架自己的约定。标准注解使用 title 等字段,Elpis 当前显示名称使用的是 label。
例如,searchOption.enumList 表达下拉框的展示选项,并不等同于 JSON Schema 的 enum 数据约束。商品价格搜索中的 'all' 是查询条件的约定,也不能直接视为商品价格字段的合法数值。搜索条件与业务实体数据需要分别设计校验规则。
JSON Schema 本身不负责创建 Vue 组件。当前页面生成依赖 useSchema 和 Schema 组件对扩展字段的解释;仅写下 type: 'number',也不意味着该渲染链路会自动完成数值转换或业务数据校验。后续若接入标准校验,应明确规范版本,并选择分离 UI 配置,或为校验器注册相应扩展。
这使各层职责清楚:JavaScript 对象承载 DSL,JSON Schema 风格结构描述字段,Elpis 扩展描述界面行为,框架组件执行渲染。后文将继续分析这份模型如何被加载、继承、下发和消费。
2. 领域模型的分层结构
当前项目中,领域模型由两层组成:
model/{domain}/model.js:领域基模,描述某一类系统的通用能力。model/{domain}/project/{projectKey}.js:项目模型,描述具体项目在基模上的覆盖、扩展和差异。
例如电商领域的基模位于 model/buiness/model.js,它定义了商品管理、订单管理、客户管理等通用菜单,也定义了商品列表的 schemaConfig。淘宝、京东、拼多多则分别放在 model/buiness/project/taobao.js、jd.js、pdd.js 中。
课程领域也是同样的结构:model/course/model.js 定义课程系统的基础能力,model/course/project/bilibili.js 和 douyin.js 描述 B 站课堂、抖音课堂的项目差异。
这不是简单的目录分类,而是在代码层面形成了"领域基模 + 项目扩展"的继承关系。基模负责稳定部分,项目模型负责变化部分。
3. 模型加载:把文件目录转换成运行时结构
模型入口在 model/index.js。它会扫描 model/ 目录下的所有 JS 文件,并根据路径判断当前文件属于基模还是项目模型。
核心流程如下:
js
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 }
const type = file.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model'
if (type === 'project') {
const modelKey = file.match(/\/model\/(.*?)\/project\//)?.[1]
const projectKey = file.match(/\/project\/(.*?)\.js/)?.[1]
modeItem.project[projectKey] = require(path.resolve(file))
modeItem.project[projectKey].key = projectKey
modeItem.project[projectKey].modelKey = modelKey
}
if (type === 'model') {
const modelKey = file.match(/\/model\/(.*?)\/model\.js/)?.[1]
modeItem.model = require(path.resolve(file))
modeItem.model.key = modelKey
}
})
加载后的数据结构大致是:
js
[
{
model: {
key: 'buiness',
model: 'dashboard',
name: '电商系统',
menu: []
},
project: {
taobao: {
key: 'taobao',
modelKey: 'buiness',
name: '淘宝',
menu: []
},
jd: {},
pdd: {}
}
}
]
这一步的价值在于,框架不需要在业务代码里手动维护项目清单。只要按照约定新增模型文件,运行时就能自动识别领域、项目和它们之间的关系。
4. 模型继承:用 key 合并业务差异
领域模型真正有意思的地方在于继承策略。projceExtenModel 使用 lodash.mergeWith 合并基模和项目模型。普通对象会递归合并,数组则根据是否存在 key 决定合并方式。
当数组中的每一项都有 key 时,项目模型不会粗暴替换整个数组,而是执行三类操作:
- 项目项和基模项
key相同:递归合并,实现覆盖。 - 项目项在基模中不存在:追加为新增能力。
- 基模项在项目中不存在:保留通用能力。
js
if (Array.isArray(modelValue) && Array.isArray(projValue)) {
const canMergeByKey = modelValue.every(hasMergeKey) && projValue.every(hasMergeKey)
if (!canMergeByKey) {
return _.cloneDeep(projValue)
}
let result = []
for (let i = 0; i < modelValue.length; i++) {
let modelItem = modelValue[i]
const projItem = projValue.find(projItem => projItem.key === modelItem.key)
result.push(projItem ? projceExtenModel(modelItem, projItem) : _.cloneDeep(modelItem))
}
for (let i = 0; i < projValue.length; i++) {
const projItem = projValue[i]
const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key)
if (!modelItem) {
result.push(_.cloneDeep(projItem))
}
}
return result
}
以拼多多项目为例,基模中已经有 product、order、client 三个模块。pdd.js 中重新声明 product,就可以覆盖商品管理的名称;新增 data、sider,就可以扩展出数据分析和信息查询能力。
js
module.exports = {
name: 'pdd',
desc: 'pdd电商',
homePage: '/schema?proj_key=pdd&key=product',
menu: [
{
key: 'product',
name: 'pdd(商品管理)',
},
{
key: 'data',
name: '数据分析',
menuType: 'module',
moduleType: 'sider'
}
]
}
这套继承策略让模型具备了领域抽象能力。电商系统的通用能力只需要定义一次,具体项目只描述自己不同的地方。
5. 模型到接口:ProjectService 是模型查询层
模型加载完成后,并不会直接暴露给前端。app/service/project.js 承担了模型查询层的职责,它把运行时模型转换成 controller 可使用的数据。
js
const modelList = require('../../model')(app)
module.exports = (app) => {
const BaseService = require('./base')(app)
return class ProjectService extends BaseService {
get({ projKey }) {
let projConfig = null
modelList.forEach(modelItem => {
const { project } = modelItem
if (project[projKey]) {
projConfig = project[projKey]
}
})
return projConfig
}
getList({ projKey }) {
return modelList.reduce((preList, modelItem) => {
const { project } = modelItem
if (projKey && !project[projKey]) {
return preList
}
for (const pKey in project) {
preList.push(project[pKey])
}
return preList
}, [])
}
}
}
ProjectController 再通过三个接口向前端提供模型能力:
/api/project:根据proj_key获取某个项目的完整模型。/api/project/list:获取项目列表,支持按当前项目所在领域过滤。/api/project/model_list:获取所有领域和项目的结构化数据。
这里有一个值得注意的边界:controller 不关心模型如何扫描、如何继承、如何合并。它只负责 DTO 裁剪和响应格式。真正的领域模型处理被放在 model/index.js 和 ProjectService 中。
6. 模型到页面:Schema 驱动前端渲染与演进方向
前端侧的关键在 schemaConfig。领域模型不仅描述菜单,还可以描述一个模块的表格列、搜索项、按钮和数据源。
电商商品管理中的部分配置如下:
js
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
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: 'all' },
{ label: '100', value: 100 }
]
}
}
}
}
}
useSchema 会根据路由参数从菜单模型中找到当前模块,再把同一份 schema 拆成表格 schema 和搜索 schema。
js
const buildDtoSchema = (_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`]) {
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
}
表格组件 schema-table.vue 根据 schema.properties 动态生成 el-table-column。搜索栏组件 schema-search-bar.vue 根据 searchOption.comType 找到对应组件,例如 input、select、dynamicSelect、dateRange。
最终形成的链路是:
text
领域基模 -> 项目模型 -> 模型继承 -> 项目接口 -> 前端菜单 -> Schema 拆分 -> 表格/搜索栏渲染 -> 业务接口请求
当前实现已经具备领域模型驱动的雏形:模型负责描述业务,框架负责解释模型,页面组件负责消费模型。但如果继续演进,还可以在几个方向上增强。
第一,模型结构可以继续标准化。现在 docs/dashboard.model.js 已经描述了 menuType、moduleType、schemaConfig、tableConfig 等字段,后续可以把它变成真正的校验 schema,避免模型配置拼写错误在运行期才暴露。
第二,项目接口可以进一步收敛模型 DTO。当前 /api/project 会返回较完整的项目模型,而 /api/project/model_list 已经做了部分字段裁剪。后续可以按前端场景拆分成项目列表 DTO、菜单 DTO、schema DTO,让模型内部结构和接口输出结构解耦。
第三,业务接口和项目上下文已经通过 project-handler 建立联系。/api/proj/* 请求必须携带 proj_key,中间件会注入 ctx.projKey。这使同一个业务 controller 能根据项目上下文返回不同数据,是模型驱动走向多项目复用的重要基础。
js
if (ctx.path.indexOf('/api/proj/') < 0) {
return await next()
}
const { proj_key: projKey } = ctx.request.headers
if (!projKey) {
ctx.body = {
success: false,
code: 446,
message: 'proj_key is required',
}
return
}
ctx.projKey = projKey
await next()
elpis-core 领域模型驱动设计,本质上是在做一件很务实的事:把后台系统中反复出现的菜单、页面、表格、搜索、项目差异抽象成模型,再让框架自动把模型运行起来。它还不是完整意义上的 DDD 战术建模,但已经具备了领域抽象、项目继承、模型解释和前端动态渲染这几个关键能力。
这种设计最适合多租户、多项目、多业务线但页面形态高度相似的后台系统。系统越多,模型驱动带来的收益越明显;差异越集中,框架代码就越稳定。