# 领域模型相关开发

本系统的领域模型以 Grails 的 Domain 定义,使用 Groovy 语言编写,使用 GORM 作为 ORM 框架,底层使用 Hibernate。

以下描述了,领域模型支持相关功能的一些开发说明,当前包括

  • 领域模型的回收站支持
  • 领域模型的多版本支持

# 目标读者

本文档的目标读者为:本系统的开发人员

# 前置知识

读者需要对 Hibernate, GORM, Grails 有一定的了解。如下是相关的参考资料:

# 平台相关元数据

  1. 通过 Domain 中静态的 labelField 属性指定在前端的对象控件上,显示对象的哪个属性 作为标识。

  2. 通过 Domain 中静态的 inlineSearchColumn 属性指定在前端的对象输入控件中,快捷搜 索对象时,搜索哪些属性。

  3. 通过 Domain 中静态的 loadAfter 属性指定在导入种子数据时,该对象需要在哪些对象 之后导入。具体描述请参考 导入顺序及依赖

样例如下:

@ManagedEntity
class DynamicDashboardWidget implements MultiTenant<DynamicDashboardWidget>,
  Auditable, Stampable<DynamicDashboardWidget>, Serializable {
  // ...
  // 用户快捷搜索时,使用 name 和 label 两个字段进行搜索匹配
  static inlineSearchColumns = ['name', 'label']
  // 界面上显示 Object 控件时,显示其 label 字段的值作为标识
  static labelField = 'label'
  // ...
}
1
2
3
4
5
6
7
8
9

# 回收站支持

领域模型的回收站支持主要包括如下功能

  1. 对象删除时,系统不进行物理删除,而是将对象移动到回收站中
  2. 回收站中的对象可以进行恢复,恢复时其外键关联也会一并被恢复

数据丢失警告

回收收操作会首先将对象从待回收模型的数据库表中删除,然后将其保存到回收站模型 的数据库表中。因此如果待回收对象的外键关联定义了级联删除,则回收时,该关联对 象会被物理删除,在后续恢复时,该关联对象将无法被恢复造成数据丢失, 需要注意。

# 模型定义

# 待回收模型

回收站支持的模型定义,需要实现如下的 trait:

tech.muyan.spec.Trashable<?>

当模型继承了该 trait 后,在对对象作删除操作时,系统会将其放入回收站中,而不是进行物理删除。

# 回收站模型

  1. 回收站模型定义,需要遵循如下的命名规则: 待回收模型名 + Trash,如 User 的 回收站模型为 UserTrash

  2. 回收站模型中,需要包含待回收模型中的所有属性,以避免任何属性丢失。

回收站模型定义,需要实现如下的 trait:

tech.muyan.spec.DomainTrash

  • 其中定义了如下的属性:

    • originalId: 原始对象的 ID, 供参考用
    • trashedBy: 删除该对象的用户 ID
    • dateTrashed: 删除对象的时间
    • foreignkeys: JSON 字符串,记录了被删除对象的所有外键关联,用于在恢复时,一 并恢复外键关联
  • 其中定义了如下的接口方法,需要实现:

    • abstract Trashable<?> getNullObject() 用于对象被软删除时,填充被删除对象的 关联对象中,指向被删除对象的,必填的外键字段的一个占位符对象,该对象需要在数 据库中真实存在,但不应该被赋予实际业务含义。

Domain 设计提示

如果待删除的对象是 one to many 关系中的 many 端,那么可以在回收站的 domain 定义 中,不包含 one 端的那个关联字段,系统会记录外键并在恢复回收站时,自动处理。

举例,如果待放入回收站的对象是 DynamicConfig,而 UserDynamicConfig 之 间是 one to many 的关系。

class User {
    String name
    static hasMany = [dynamicConfigs: DynamicConfig]
}

class DynamicConfig {
    String name
    // 在 DynamicConfig 对象被放入回收站时,下述 foreignKey 会被记录在 foreignKeys 字段中
    // 因此如无必要,可以无需在 DynamicConfigTrash 的模型定义中,显式声明
    User user
}
1
2
3
4
5
6
7
8
9
10
11

那么,可以在回收站的 domain 定义 DynamicConfigTrash 中,可以不包含 User 关联关 系,因为该关联关系会被系统自动处理,保存在 foreignKeys 字段中。从回收站恢复时, 可以恢复该关联关系。

Domain 设计注意

在定义回收站 domain 时候,对于原对象中有唯一校验的字段,如 name, key 等类型字段, 可以不加唯一校验,避免出现用户在删除了某对象后,再次创建的新的同名称的对象后, 无法被删除的场景。

# Dynamic Action 关联

平台定义了名为 RestoreFromTrash 的 Action,用于支持回收站对象的恢复功能,如果 希望支持回收站的恢复功能,需要将该 Action 与回收站模型进行关联。具体关联的操作, 请参考 Action 与 domain 关联

# Form 定义

  • 回收站模型中,foreignkeys 字段是一个 JSON 对象,因此在 form field 中,需要将 其前台显示控件设置为 json 类型,可在 DomainColumnClientSideTypeConfig 中创建 记录以对此进行设定。

# API 支持

如果需要在客制化代码中将对象放入回收站, 请使用如下的代码:

import grails.core.GrailsApplication

GrailsApplication grailsApplication = (application as GrailsApplication)
DomainDataService domainDataService = (grailsApplication.getMainContext().getBean("domainDataService") as DomainDataService)

//domainDataService.deleteDomainObjWithHook(GormEntity<?> domainObj, 
//    GrailsUser grailsUser, 
//    GrailsApplication application, 
//    TransactionStatus ts, 
//    ClassLoader classLoader)
domainDataService.deleteDomainObjWithHook(domainObj, grailsUser, application, ts, classLoader)
1
2
3
4
5
6
7
8
9
10
11

注意

不能直接调用 Grails domain 对象的 delete 方法对对象进行直接删除

# 历史版本支持

领域模型的历史版本支持主要包括如下功能:

  1. 对象保存时,系统会根据需要升版本的字段列表配置,如有必要,会自动保存一个历史版本
  2. 将历史版本与当前版本进行比较,查看历史版本与当前版本的差异
  3. 比较两个历史版本,查看历史版本之间的差异
  4. 将某个历史版本恢复为当前版本

# 模型定义

# 待版本化模型

版本化支持的模型定义,需要实现如下的 trait:

tech.muyan.spec.HasRevision<?>

当模型继承了该 trait 后,该模型即具备了版本化功能。

其中定义了如下的属性:

  • revision: 当前对象的版本号,从 1 开始,每次保存时,如果有需要升版本的字段发生变 化,版本号会自动加一。如果没有需要升版本的字段发生变化,版本号不会变化。

另外,模型中还要定义如下的属性:

  • revisions: 用于指向所有历史版本的集合,该属性应该是一个指向 tech.muyan.spec.DomainRevision<?>OneToMany 关联

其中定义了如下的接口方法,需要实现:

  • getAllRevisions(): 获取当前对象的所有历史版本, 按照版本号从大到小排列
  • getBumpRevisionPropertyNames(): 定义需要升版本的字段列表

# 历史版本模型

  1. 历史版本模型定义,需要遵循如下的命名规则: 待版本化模型名 + Revision, 如待版 本化模型为 DynamicLogic, 则历史版本模型为 DynamicLogicRevision

  2. 历史版本模型中,需要包含主模型中的所有属性,以避免升版时,任何属性无法保存导 致数据丢失。

需要实现如下的 trait:

tech.muyan.spec.DomainRevision<?>

其中定义了如下的属性:

  • revision: 该历史版本的版本号

另外,历史版本模型中,还需要定义如下的属性,用于保存到当前版本的引用:

  • mainObject: 到当前版本的引用

# Dynamic Action 关联

平台定义了如下的 Action,用于支持领域模型的历史版本功能:

  • RevertRevisionToMainObject 应定义在历史版本模型上,将历史版本恢复为当前版本
  • CompareTwoRevisions 应定义在历史版本模型上,用于比较两个历史版本
  • CompareWithMainObject 应定义在历史版本模型上,比较历史版本与当前版本

具体关联的操作,请参考 Action 与 domain 关联

# Form 定义

# 待版本化模型

待版本化模型的 form 中,如果要显示其所有历史版本的列表,需要包含如下字段:

  • revisions

# 历史版本模型

历史版本模型的 form 中,如果需要显示其对应的当前版本的对象,需要包含如下字段:

  • mainObject

# 评论支持

系统提供了针对 Domain 对象的评论功能,可以通过如下的方式为 Domain 对象增加评论功能:

# Domain 定义

  1. 在 Domain Class 定义上增加 HasComment<?> trait, 其中定义了 getComments() 方法,用于获取该对象的所有评论
  2. 在 Domain Class 中的字段定义中,增加如下的字段: List<DomainComment> comments
  3. 修改其 HasMany 部分定义,增加如下部分 comments : DomainComment
  4. 修改其 fetchMode 部分定义,增加如下部分 comments : org.hibernate.FetchMode.JOIN

# 表单定义

按照实际场景需要,在该 Domain 的列表和更新表单定义中,增加 comments 字段,该字 段在前端,会以一个在右边停靠的评论抽屉的形式展现出来

# 权限说明

默认情况下,系统对于评论的创建、修改、删除、和完成操作的权限控制如下:

  1. 默认所有用户均可针对其可见的主对象,创建评论
  2. 评论的创建者,可以修改和删除其评论
  3. 评论所关联的主对象的创建者,可以删除针对该主对象的评论
  4. 评论的创建者和评论所关联的主对象的创建者,可以将某评论标记为完成

# 系统中的实际案例

以下以一个平台中的实际案例为例,介绍如何为 Domain 对象增加版本化和回收站的支持。

# 业务场景

DynamicLogic 是开发平台的核心资产,因此希望能够对其进行版本化管理、回收站支持及评论支持, 以便于在开发过程中避免任何开发内容的丢失,且支持逻辑出错时快速恢复,同时增加相关的说明。

# Domain 定义

  • DynamicLogic: 主对象
  • DynamicLogicRevision: 历史版本对象
  • DynamicLogicTrash: 回收站对象
  • HasComment: 标识该对象支持评论

# DynamicLogic

本处只列出与回收站、多版本支持及评论支持相关的属性,其他属性请参考平台源代码中 DynamicLogic 的定义。

import tech.muyan.spec.DomainRevision
import tech.muyan.spec.HasRevision
import tech.muyan.spec.Trashable

@ManagedEntity
class DynamicLogic implements MultiTenant<DynamicLogic>,
  Auditable, Stampable<DynamicLogic>, Serializable,
  HasRevision<DynamicLogic>, // --> 声明支持版本化
  Trashable<DynamicLogic>,  // --> 声明支持回收站
  HasComment<DynamicLogic> { // --> 声明支持评论
  
  // ....
  // --> revision 字段从 HasRevision 继承,用于记录当前主对像的版本号
  
  // Comments 字段,记录当前主对象的所有评论
  List<DomainComment> comments
  
  static fetchMode = [
    // .....
    
    // Comments, 使用 JOIN 方式查询
    comments: org.hibernate.FetchMode.JOIN
  ]
  
  static hasMany = [
    // .....

    // Revisions
    revisions    : DynamicLogicRevision // --> 历史版本列表
    comments     : DomainComment // --> 评论列表,与主对象是一对多关系
  ]
  
  static mappedBy = [ // --> 与历史版本的映射  
    revisions    : 'mainObject' // --> 历史版本中的主对象引用为 mainObject 字段
  ]
  
  @Override // --> 获取经过排序的历史版本列表,版本从大到小排序
  Collection<DomainRevision<DynamicLogicRevision>> getAllRevisions() {
    revisions.sort((r1, r2) -> {
      if (r1.revision == r2.revision) {
        return 0
      } else if (r1.revision > r2.revision) {
        return 1
      }
      return -1
    }) as Collection<DomainRevision<DynamicLogicRevision>>
  }

  @Override
  Collection<String> getBumpRevisionPropertyNames() { // --> 获取升版的属性列表
    return ['code']  // --> 只有 code 属性发生变化时,才会升版
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

# DynamicLogicRevision

本处只列出与多版本支持相关的属性,其他属性请参考平台源代码中 DynamicLogicRevision 的定义。

@ManagedEntity
class DynamicLogicRevision implements   // --> 命名规范: 主模型类名 + Revision
  DomainRevision<DynamicLogicRevision>, // --> 声明为历史版本对象
  MultiTenant<DynamicLogicRevision>, Auditable,
  Stampable<DynamicLogicRevision> {
  
  DynamicLogic mainObject  // --> 历史版本的主对象
  
  // --> revision 字段从 DomainRevision 继承,用于记录历史版本的版本号
  // --> DomainRevision 中还要包括主对像中的所有非外键字段
}
1
2
3
4
5
6
7
8
9
10
11

# DynamicLogicTrash

本处只列出与回收站相关的属性,其他属性请参考平台源代码中 DynamicLogicTrash 的定义。

@ManagedEntity
class DynamicLogicTrash implements // --> 命名规范: 主模型类名 + Trash
  DomainTrash<DynamicLogicTrash>,  // --> 声明为回收站对象
  MultiTenant<DynamicLogicTrash> {
  
  static constraints = {
    foreignKeys nullable: true, blank: false // --> 将外键字段设置为可以为 null, 但是不可以为空字符串
  }

  static mapping = {    
    // --> 外键字段的类型定义,使用 JSON 格式存储,读取到 Java 中时,使用 String 类型
    foreignKeys type: 'tech.muyan.rdbms.postgres.JSONBType', sqlType: "jsonb", defaultValue: "'{ }'"
  }

  Trashable<DynamicLogic> getNullObject() { 
    // --> 用于回收主对像时,设置到主对像的外键关联为该 NullObject, 避免出现外键约束错误
    return DynamicLogic.findByName("Dummy Dynamic Logic")
  }

  static transients = ['nullObject'] // --> nullObject 字段(对应 getNullObject 方法) 不需要持久化到数据库中
  
  // --> DomainTrash 模型定义中还要包括主对像中的所有非外键字段  
}  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# Action 关联

系统内置可用的 Action 包括

  • 从回收站中恢复已删除对象
  • 将历史版本设置为最新版本
  • 比较两个历史版本的差异
  • 比较历史版本与当前版本的差异
;; 文件 DynamicActionDomainClass.csv
RestoreFromTrash,DynamicLogicTrash,1
RevertRevisionToMainObject,DynamicLogicRevision,1
CompareTwoRevisions,DynamicLogicRevision,10
CompareWithMainObject,DynamicLogicRevision,20
1
2
3
4
5

# Form 定义

# DynamicForm.csv

;; 文件 DynamicForm.csv
; Dynamic Logic Revision
$ROOT_ORG$,Show dynamic logic revisions,,,DynamicLogicRevision,LIST,,ROLE_DEVELOPER,
$ROOT_ORG$,Dynamic logic revision detail,,,DynamicLogicRevision,UPDATE,,ROLE_DEVELOPER,

; Dynamic Logic Trash bin
$ROOT_ORG$,Show dynamic logic trash bin,,,DynamicLogicTrash,LIST,Dynamic Logic Trash,ROLE_DEVELOPER,
1
2
3
4
5
6
7

# DynamicFormField.csv

;; Dynamic Logic revision details
Dynamic logic revision detail,id,0,Id,,STATIC_FIELD,,,
Dynamic logic revision detail,name,1,Name,Name of this dynamic logic,STATIC_FIELD,,,
Dynamic logic revision detail,mainObject,3,Main object,Current main object of this revision,STATIC_FIELD,,,
Dynamic logic revision detail,logicType,4,Logic Type,Type of the logic,STATIC_FIELD,,,
Dynamic logic revision detail,description,5,Description,Describe reason & background etc of this dynamic logic,STATIC_FIELD,,,
Dynamic logic revision detail,revision,7,Revision,,STATIC_FIELD,,,
Dynamic logic revision detail,code,10,Code,"Code(written in groovy), refer to https://docs.muyan.tech/zh/cookbook/ for documentation, can use Ctrl + Shift + B to reformat code",STATIC_FIELD,,,"{""codeLanguage"": ""groovy"", ""hasDetailPanel"": true}"
Dynamic logic revision detail,revisions,20,Revisions,,STATIC_FIELD,,,
1
2
3
4
5
6
7
8
9

# Domain Instance 动态字段支持

系统可以创建与特定 Domain Instance 关联的动态字段,具体步骤如下

  1. 实现接口 tech.muyan.dynamic.meta.DynamicEntityType, 该 Domain 是决定对象中 包含哪些动态字段的属性,在创建动态字段实例时,需要通过指定某个 DynamicEntityTypename 属性,与其关联。
  2. 实现接口 tech.muyan.dynamic.meta.DynamicEntityInstance, 该 Domain 是实际包 含动态字段的值的对象,具体包含哪些动态字段由其中的 dynamicEntityType 字段决 定,该字段的值为上一步实现的 DynamicEntityType 的实例。

只需要在创建 DynamicFieldInstance 时,选择与之关联的 DynamicEntityType 对象的类 型,并指定其 name 属性即可。

在创建 DynamicFieldInstance 时,指定其 dynamicEntityType 属性后,系统会在界面 上,自动显示出所有与其关联的动态字段,用户可以在界面上进行相应字段的值编辑。

系统内置了DynamicEntityTypeDynamicEntityInstance 的默认实现,分别为 DefaultDynamicEntityTypeDefaultDynamicEntityInstance,可以基于这两个类, 创建动态字段。

其实现分别如下

  • DefaultDynamicEntityType
package tech.muyan.dynamic.meta

import grails.gorm.MultiTenant
import grails.gorm.hibernate.annotation.ManagedEntity
import tech.muyan.auditable.Auditable
import tech.muyan.auditable.Stampable

@ManagedEntity
class DefaultDynamicEntityType implements MultiTenant<DefaultDynamicEntityType>,
    Auditable, Stampable<DefaultDynamicEntityType>, Serializable, DynamicEntityType<DefaultDynamicEntityType> {

  @SuppressWarnings("unused")
  private static final long serialVersionUID = 1L

  String tenant

  static mapping = {
    tenant index: 'default_dynamic_entity_type_definition_tenant_idx'
    tenantId name: 'tenant'
    cache true
  }

  static constraints = {
    // Name 字段用于用户创建动态字段实例时,与某个 DefaultDynamicEntityType 的具体对象关联
    name nullable: false, blank: false, unique: ['tenant']
  }

  static inlineSearchColumns = ['name']

  static labelField = 'name'

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  • DefaultDynamicEntityInstance
package tech.muyan.dynamic.meta

import grails.gorm.MultiTenant
import grails.gorm.hibernate.annotation.ManagedEntity
import tech.muyan.auditable.Auditable
import tech.muyan.auditable.Stampable

@ManagedEntity
class DefaultDynamicEntityInstance implements MultiTenant<DefaultDynamicEntityInstance>,
    Auditable, Stampable<DefaultDynamicEntityInstance>, Serializable, DynamicEntityInstance<DefaultDynamicEntityInstance, DefaultDynamicEntityType> {

  @SuppressWarnings("unused")
  private static final long serialVersionUID = 1L

  String tenant

  static mapping = {
    tenant index: 'sample_entity_instance_definition_tenant_idx'
    tenantId name: 'tenant'
    cache true
  }

  static constraints = {
    // dynamicEntityType 字段的值决定了该对象会包含哪些动态字段
    dynamicEntityType nullable: false, updatable: false
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# Domain 设计相关约定

# extInfo 约定

使用 extInfo 字段来存储结构比较复杂,异构、或者与 Domain 的特定子类型相关,但又 需要进行结构化存储的信息。

该字段的相关定义样例如下

// 如下是在平台中实际实现的 extInfo 字段的样例
class DynamicFormField implements MultiTenant<DynamicFormField>,
  Auditable, Stampable<DynamicFormField>, Serializable {
  // ....
  // Java 的字段类型为 String
  String extInfo
  // 设置为可以为 null, 但是不能为空字符
  static constraints = {
    extInfo nullable: true, blank: false
  }
  static mapping = {
    // 使用 jsonb 类型的数据库字段,数据库会自动增加相关校验,并提供 JSON 查询相关功能
    // 在转换为 Java 对象时,会被转换为 String 类型
    extInfo type: 'tech.muyan.rdbms.postgres.JSONBType', sqlType: "jsonb", defaultValue: "'{ }'"
  }
  // ....
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# displaySequence 约定

通常使用 displaySequence 字段来指定字段、字段组、树节点、DynamicAction 等在前端 的显示顺序

# 树状结构规范

  1. 使用 parent 和 children 字段进行自关联树状结构的标识,包含这两个字段的领域模 型可以在前端显示为一棵树,parent 字段为指向父节点的 id, children 为所有子节点 的集合,具体样例如下
  2. 前端树显示控件会根据 domain 中是否定义有 displaySequence 属性来决定显示的树是 否允许拖拽排序。
// 菜单 Domain 的定义样例,树相关的字段及属性
@CompileDynamic @ManagedEntity
class DynamicMenu implements MultiTenant<DynamicMenu>,
  Auditable, Stampable<DynamicMenu>, Serializable {
  // 父节点
  DynamicMenu parent
  // 所有子节点的集合,通过 parent 关联
  static hasMany = [children: DynamicMenu]
  // 显示顺序
  int displaySequence
  // 菜单显示名称
  String label
}
1
2
3
4
5
6
7
8
9
10
11
12

提示

需要将 parent, children, displaySequence 字段均通过 tree 数据的 API 返回 前端,前端才可以将该对象显示为树状结构。

# name / label / description 约定

Domain 字段常设计不可更新的 name 字段和可更新的 label 字段。

  1. 通常使用 name 字段用于在 csv 文件中与其他对象进行外键关联的指定,具体在 CSV 文件中指定外键关联可参考 关联对象的查询 章节,name 字段通常设计为不可更新。
  2. 通常使用 label 字段用于在界面的 Object 显示控件中显示该对象的摘要。
  3. 通常使用 description 字段来存储业务上的描述或帮助信息等。
  4. 通常将 namelabel 字段均加入 Domain 的 inlineSearchColumns 属性中。
  5. 通常将 label 字段设置为 Domain 的 labelField.

# enableLogic 约定

在使用 DynamicLogic 进行业务判断时,使用 enableLogic 判断该定制、动态动作 (Dynamic Action) 、定时任务(Dynamic Task) 等是否执行或显示,当前支持使用 enableLogic 进行启用及显示逻辑定制的包括:

  1. DynamicTask
  2. DynamicAction
  3. DynamicIntegration
  4. DynamicFormGroup
  5. DynamicDashboardWidget

# objectType/objectId(s) 约定

针对某一些特定的业务场景,需要在某些 Domain 中记录其关联的对象的类型及 id,通常 使用 objectType/objectId(s) 字段的组合来记录。

  • objectType 通常使用 objectType 字段来记录对象的类型,该字段为指向 tech.muyan.DomainClass 类型对象的外键引用。

  • objectId(s) 通常使用 objectId(s) 字段来记录对象的一个或者多个 id,如果有多 个 id 需要记录,使用逗号分隔的 id 列表形式进行存储。

该约定在系统中的使用举例如下:

  • tech.muyan.message.Message 中的 objectTypeobjectIds 字段用于记录该消 息所关联的对象的类型及 id。
  • tech.muyan.comment.DomainComment 中的 objectTypeobjectId 字段用于记 录该评论所关联的对象的类型及 id。
  • tech.muyan.dynamic.hook.DynamicObjectHook 中的 objectType 字段用于记录该客 制化钩子所关联的对象的类型。

# isSystem 约定

针对某些数据,是系统运行所必须的,不允许用户进行删除或者修改,通常使用 名为 isSystemboolean 字段进行标识

系统提供了名为 beforeDeleteObjectWithIsSystembeforeDeleteDynamicLogic ,可用于在删除 Domain 对象前,进行如下检查: 如果 isSystem 为 true, 则不允许删除。

最后更新: 2024/2/23 07:44:20