领域模型架构设计初理解

93 阅读10分钟

抖音-哲玄前端(全站) 《大前端实践课》学习有感

WHY - 领域模型

身为前端工程师,加班是家常便饭。仔细想想,多数时候我们都在重复劳动。做了一个 XXX 后台系统,紧接着又是 YYY 后台系统,大量时间消耗在重复性 CRUD 工作上。不仅技术难以提升,就连在项目中提炼重难点与亮点,都变成了一件难事。

有没有一种类似 “模板” 的东西,能囊括相似项目的共性?这样一来,我们就能把精力放在每个项目的独特之处,节省时间、提高效率。基于这样的需求,“领域模型” 的概念便诞生了。

WHAT - 领域模型

领域模型(Domain Model)是软件开发过程中,对特定业务领域,如电商、物流、金融等,其核心逻辑、规则和概念的抽象表达。作为领域驱动设计(DDD,Domain - Driven Design)的关键工具,它通过界定业务里的关键实体、行为、关系以及约束,助力开发团队准确理解并实现业务需求。

说句人话,领域模型就是对功能相似系统的抽象。以电商平台为例,京东、淘宝、拼多多,它们都具备商品管理、订单管理、用户管理等主要功能。这些平台可被视作电商领域模型的不同实例。我们抽象出的电商领域模型,涵盖的是这些平台的共性功能,像商品、订单、用户管理等。而各个平台的特色功能,不在领域模型的范畴内,需在具体实例中进行个性化定制

HOW - 领域模型

了解了引入领域模型的原因,以及它究竟是什么之后,接下来就要思考如何搭建这个能显著节省出摸鱼时间(bushi)的工具了。

第一步,我们得创建一份用于描述领域模型的 “说明书”。这份说明书,将梳理模型的通用特性。当面对不同实例时,如何在其基础上增添个性化内容呢?这里可以借助面向对象设计中的 “继承” 思想。模型下的所有实例,都会继承模型的基础内容,并基于自身需求进行定制,如此一来,便能解决共性与个性的问题 。

不过随之而来新问题:怎样撰写这份计算机能够理解的 “说明书” 呢?

DSL

DSL 即领域特定语言(Domain - Specific Language),是一种专门为特定领域或特定任务而设计的计算机语言。

DSL 针对特定的领域或业务需求进行设计,如金融领域的风险评估、游戏开发中的场景描述、数据处理中的 ETL 流程等。它专注于解决特定领域的问题,提供了一套专门针对该领域概念和操作的词汇与语法,使得领域专家能够更自然、更高效地表达和解决问题。

DSL 可以根据不同的领域需求进行定制化设计。开发人员可以根据具体的业务场景和用户需求,灵活地定义语言的语法、语义和规则,以满足特定领域的特殊要求。

在模型驱动的软件开发中,DSL 用于描述系统的模型,如 UML(统一建模语言)就是一种用于软件建模的 DSL。通过 DSL 描述的模型,可以自动生成代码、文档等相关 artifacts。

简单了解DSL这大哥后,这不巧了嘛,完全是我们撰写说明书的不二之选。

接下来我们就来简单介绍怎么来完成这份DSL

如何撰写我们的DSL

既然我们要使用领域模型来抽象某一类应用的基类,第一步就是要找到这些应用可能存在的共同点。

我们来想一想,这些后台,都包括哪些部分...

# 项目名
# 项目描述
# 菜单栏
# 内容展示区

接下来我们就要一步一步对这些共同点进行更加细致、深化的描述,来帮助我们尽量更加完整的以此为依据来生成站点

菜单

对于菜单,我们能够完善、细化的,无非是菜单包含的标签页、以及他们的跳转地址,不要忘了某些菜单可能会有多级子菜单存在:

# 菜单栏
	# 标签页1
		# 标签页1名称
		# 跳转路径1
		# ...
	# 标签页2
		# 标签页2名称
		# ...
		# 子菜单
			# 子标签页1
				# 子标签页1名称
				# 子标签页1跳转路径
				# ...
			# 子标签页2
				# 子标签页2名称
				# 子标签页2跳转路径
				# ...
	# 标签页3
		# 标签页3名称
		# 跳转路径3
		# ...
	# ...

到这里,我们就基本完成了对菜单的描述

内容展示区

这部分我们需要思考可能会存在哪些不同情况,数据展示类、内嵌网页类、emm...(想个damn,用户自己想去),于是我们有了下面的简单分类

# schema 标准化配置类
# iframe 内嵌类
# custom 自定义类

到此我们就基本完成了内容展示区的划分

菜单关联

完成菜单和内容的基本描述之后,我们就需要将二者对应起来,不然怎么知道菜单要跳到哪里去,内容要展示哪些内容(碗里吗,damn)

于是,我们对菜单描述进一步细化

# 菜单栏
	# 标签页1
		# 标签页1名称
		# 菜单类型 (module 模块)
		# 页面类型 (schema 标准化)
		# schema 配置
		# ...
	# 标签页2
		# 标签页2名称
		# 菜单类型 (group 菜单组)
		# ...
		# 子菜单
			# 子标签页1
				# 子标签页1名称
				# 菜单类型 (module 模块)
				# 页面类型 (iframe 内嵌)
				# iframe 配置 (子标签页1跳转路径 path: iframe地址)
				# ...
			# 子标签页2
				# 子标签页2名称
				# 菜单类型 (module 模块)
				# 页面类型 (custom 自定义)
				# custom 配置 (子标签页2跳转路径 path)
				# ...
	# 标签页3
		# 标签页3名称
		# 菜单类型 (module 模块)
		# 页面类型 (schema 标准化)
		# schema 配置
		# ...
	# ...

到这里,我们基本完成了二者的对应关系建立

增加可用性

接下来我们可以来丰富一下我们的标准化配置类页面,使其可以尽量覆盖更多的情况,沉淀更多可复用功能。

这类后台系统最常见的页面是什么呢,针对不同领域,实际情况可能不同,可能是图表展示?或是表格展示?又或是其他。我们这里用表格展示来举例。

表格展示页面呢,通常包含搜索区、表格区、操作区,那这些操作必然对应了不同接口,那么在我们这份精简的说明书中,大量充斥统一模块的 api 路径,似乎不太合理,我们可以遵循 RESTFUL Api的规则,将同模块的数据看作一份资源,不同的接口请求类型就对应了对这份数据的不同操作

于是,经过分析,我们有了更细化的标准化页面配置

# 菜单栏
	# 标签页1
		# 标签页1名称
		# 菜单类型 (module 模块)
		# 页面类型 (schema 标准化)
		# schema 配置
			# api 数据源 (页面由数据驱动) (遵循 RESTFUL 规范)
			# 板块数据结构
				# 表格列字段 1
					# 字段类型
					# 字段类型
					# 表格列配置
					# 搜索区配置
					# 操作表单配置
					# ...
				# 表格列字段 2
					# ...
				# 表格列字段 3
					# ...
		# 表格统一配置
		# 搜索区配置
		# 模块组件配置
		# ...

到此我们的DSL就基本完成了

转化

接下来我们需要把这份我们能看懂的说明书,让计算机也能看懂

module.exports = {
  model: 'dashboard',
  name: '电商系统',
  menu: [{
    key: 'product',
    name: '商品管理',
    menuType: 'module',
    moduleType: 'schema',
    schemaConfig: {
      api: '/api/proj/product',
      schema: {
        title: 'Product',
        descriprtion: "product's properties and they styles in table or other section",
        type: 'object',
        properties: {
          product_id: {
            type: 'string',
            label: '商品ID',
            tableOptions: {
              width: 150,
              'show-overflow-tooltip': true
            }
          },
          product_name: {
            type: 'string',
            label: '商品名称',
            tableOptions: {
              width: 200
            },
            searchOptions: {
              comType: 'dynamicSelect',
              api: '/api/proj/product_enum/list'
            }
          },
          price: {
            type: 'number',
            label: '价格',
            tableOptions: {
              width: 200
            },
            searchOptions: {
              comType: 'select',
              enumList: [{
                label: '全部',
                value: -1
              }, {
                label: '¥11.11',
                value: 11.11
              }, {
                label: '¥22.22',
                value: 22.22
              }, {
                label: '¥33.33',
                value: 33.33
              }]
            }
          },
          inventory: {
            type: 'number',
            label: '库存',
            tableOptions: {
              width: 200
            },
            searchOptions: {
              comType: 'input',
            }
          },
          create_time: {
            type: 'string',
            label: '创建时间',
            tableOptions: {
              'show-overflow-tooltip': true
            },
            searchOptions: {
              comType: 'dateRange'
            }
          }
        }
      },
      tableConfig: {
        headerButtons: [{
          label: '新增商品',
          type: 'primary',
          plain: true,
          eventKey: 'showComponent'
        }],
        rowButtons: [{
          label: '修改',
          type: 'warning',
          eventKey: 'showComponent'
        }, {
          label: '删除',
          type: 'danger',
          eventKey: 'remove',
          eventOptions: {
            params: {
              product_id: 'schema::product_id'
            }
          }
        }]
      }
    }
  }, {
    key: 'order',
    name: '订单管理',
    menuType: 'module',
    moduleType: 'custom',
    customConfig: {
      path: '/todo'
    },
  }, {
    key: 'client',
    name: '客户管理',
    menuType: 'module',
    moduleType: 'custom',
    customConfig: {
      path: '/todo'
    },
  }]
}

这里我们就完成了一份简单的DSL编写。

解析并继承基类配置

要注意,我们只是完成了模型的"说明书",也就是基类,各个特定的系统,也就是其子类,结构与之类似,但需要继承该份基类配置

下面是解析并继承基类配置的方法

const projectExtendModel = (model, project) => {
  return _.mergeWith({}, model, project, (modelValue, projValue) => {
    // 特殊情况:处理数组合并
    if (Array.isArray(modelValue) && Array.isArray(projValue)) {
      let result = []

      // 因为 project 继承 model,所以需要处理修改和新增内容的情况
      // project(+) model(+) => 修改 (重载)
      // project(+) model(-) => 新增 (拓展)
      // project(-) model(+) => 保留 (继承) (不用特殊处理)

      // 处理修改
      for (let i = 0; i < modelValue.length; i++) {
        let modelValItem = modelValue[i]
        const projValItem = projValue.find(projValItem => projValItem.key === modelValItem.key)
        // project(+) model(+) 则递归调用 projectExtendModel 方法覆盖修改
        result.push(projValItem ? projectExtendModel(modelValItem, projValItem) : modelValItem)
      }

      // 处理新增
      for (let i = 0; i < projValue.length; i++) {
        const projValItem = projValue[i]
        const modelValItem = modelValue.find(modelValItem => modelValItem.key === projValItem.key)
        if (!modelValItem) {
          result.push(projValItem)
        }
      }

      return result
    }
  })
}

/**
 * 解析 model 配置 并 返回组织且继承后的数据结构
 * @param {object} app 
 * 
   [{
      model: ${model},
      project: {
        proj1Key: ${proj1},
        proj2Key: ${proj2}
      }
   }, ...]
 */
module.exports = (app) => {
  const modelList = []

  // 遍历当前文件夹,构造模型数据结构,挂载到 modelList 上
  const modelPath = path.resolve(app.baseDir, `.${sep}model`)
  const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`))
  fileList.forEach(file => {
    const filePath = path.resolve(file)
    if (filePath.indexOf('index.js') > -1) return

    // 区分配置类型 (model / project)
    const type = filePath.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model'
    
    if (type === 'model') {
      const modelKey = filePath.match(/[\\/]model[\\/](.*?)[\\/]model\.js/)?.[1]
      let modelItem = modelList.find(item => item.model?.key === modelKey)
      if (!modelItem) {
        modelItem = {}
        modelList.push(modelItem)
        modelItem.model = require(filePath)
        modelItem.model.key = modelKey // 注入 modelKey
      }
    }
    
    if (type === 'project') {
      const modelKey = filePath.match(/[\\/]model[\\/](.*?)[\\/]project[\\/]/)?.[1]
      const projKey = filePath.match(/[\\/]project[\\/](.*?)\.js/)?.[1]
      let modelItem = modelList.find(item => item.model?.key === modelKey)
      if (!modelItem) {
        modelItem = {}
        modelList.push(modelItem)
      }
      if (!modelItem.project) { // 初始化 project 数据结构
        modelItem.project = {}
      }
      modelItem.project[projKey] = require(filePath)
      modelItem.project[projKey].key = projKey // 注入 projectKey
      modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
    }
  })

  // 数据进一步处理 project => 继承 model
  modelList.forEach(item => {
    const { model, project } = item
    for (const key in project) {
      project[key] = projectExtendModel(model, project[key])
    }
  })  

  return modelList
}

到这里,我们可以告一段落,我们以及实现了抽象基类、子类继承、以及解析配置的步骤,下面就是在项目中基于这份配置动态渲染的过程,这里不再赘述

思考

在复盘重复性工作时,我们发现系统开发存在大量重复劳动。于是,我们尝试把重复性系统开发,抽象为模型与实例的配置文件。通过解析这份配置文件,就能生成相应的站点。

目前,配置文件由开发人员手动填写。对此,我们可以进一步思考:能否实现配置文件的自动生成?若要自动生成,应该依据什么生成?生成的配置文件,又该如何精准描述目标系统?

基于这些思考,组件拖拽式系统搭建方案应运而生。我们只需将所需组件拖拽至画布,灵活调整组件的嵌套层级与关联内容。系统就能根据最终布局,自动生成配置文件。后续再对配置文件进行解析,便能完成站点搭建。按照这个思路,或许有望开发出一个低代码平台。当然,这只是我个人的一些想法