elpis-core 领域模型驱动设计解析

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 导出对象,不需要学习一套新的语法或编写独立解析器;但对象中的 menuTypemoduleTypeschemaConfig 等字段具有框架定义的业务含义。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 可以声明必填字段。标准也提供 titledescription 等注解。参见 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 描述扩展"。typeproperties 属于标准词汇;labeltableOptionsearchOptioncomTypeenumList 则是框架自己的约定。标准注解使用 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.jsjd.jspdd.js 中。

课程领域也是同样的结构:model/course/model.js 定义课程系统的基础能力,model/course/project/bilibili.jsdouyin.js 描述 B 站课堂、抖音课堂的项目差异。

graph TD Domain[&#34;领域基模<br/>model/{domain}/model.js&#34;] --> Business[&#34;电商系统<br/>buiness/model.js&#34;] Domain --> Course[&#34;课程系统<br/>course/model.js&#34;] Business --> Taobao[&#34;淘宝项目<br/>project/taobao.js&#34;] Business --> JD[&#34;京东项目<br/>project/jd.js&#34;] Business --> PDD[&#34;拼多多项目<br/>project/pdd.js&#34;] Course --> Bili[&#34;B站课堂<br/>project/bilibili.js&#34;] Course --> Douyin[&#34;抖音课堂<br/>project/douyin.js&#34;]

这不是简单的目录分类,而是在代码层面形成了"领域基模 + 项目扩展"的继承关系。基模负责稳定部分,项目模型负责变化部分。

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
}

以拼多多项目为例,基模中已经有 productorderclient 三个模块。pdd.js 中重新声明 product,就可以覆盖商品管理的名称;新增 datasider,就可以扩展出数据分析和信息查询能力。

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.jsProjectService 中。

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 找到对应组件,例如 inputselectdynamicSelectdateRange

graph TD Model[&#34;领域模型<br/>model/*.js&#34;] --> Merge[&#34;模型加载与继承<br/>model/index.js&#34;] Merge --> API[&#34;项目模型接口<br/>/api/project&#34;] API --> MenuStore[&#34;前端菜单状态<br/>Pinia menuStore&#34;] MenuStore --> SchemaHook[&#34;useSchema<br/>拆分 table/search schema&#34;] SchemaHook --> Table[&#34;schema-table<br/>动态表格列&#34;] SchemaHook --> Search[&#34;schema-search-bar<br/>动态搜索组件&#34;] Search --> Params[&#34;查询参数&#34;] Params --> Table Table --> BizAPI[&#34;业务接口<br/>/api/proj/*&#34;]

最终形成的链路是:

text 复制代码
领域基模 -> 项目模型 -> 模型继承 -> 项目接口 -> 前端菜单 -> Schema 拆分 -> 表格/搜索栏渲染 -> 业务接口请求

当前实现已经具备领域模型驱动的雏形:模型负责描述业务,框架负责解释模型,页面组件负责消费模型。但如果继续演进,还可以在几个方向上增强。

第一,模型结构可以继续标准化。现在 docs/dashboard.model.js 已经描述了 menuTypemoduleTypeschemaConfigtableConfig 等字段,后续可以把它变成真正的校验 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 战术建模,但已经具备了领域抽象、项目继承、模型解释和前端动态渲染这几个关键能力。

这种设计最适合多租户、多项目、多业务线但页面形态高度相似的后台系统。系统越多,模型驱动带来的收益越明显;差异越集中,框架代码就越稳定。

相关推荐
北城笑笑1 小时前
Vue 104 ,AI + ECharts + Word:大模型数据可视化报告生成实战(前端导出图文并茂 Word 文档)
前端·vue.js·word·echarts
创新技术阁1 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
志尊宝1 小时前
Vue3 零基础每日笔记(010):watchEffect——用到谁就自动听谁的“懒人侦听器“
前端·vue.js·笔记
狗哥哥2 小时前
从“看对方向”到“做出行动”:投资决策卡
前端
拖孩2 小时前
一个人 + AI 做的小程序,一个月赚了 36 块
前端·后端·微信小程序
weixin_440730502 小时前
playwright实战-渠道应用操作
开发语言·前端·python
lhldsg2 小时前
从零构建智慧场馆解决方案小程序:开发全流程实战
java·前端·小程序
IMPYLH2 小时前
HTML 的 <rp> 元素
前端·javascript·html
晴天162 小时前
npm install -f(--force)深度解析:作用原理、报错根源与风险避坑指南
前端·npm·node.js