序言

本手册提供关于 CUBA 框架的参考信息,并且介绍了使用此平台开发商业应用程序的最重要的内容。

如果要使用此平台,需要掌握以下技术知识:

  • Java SE

  • 关系型数据库(SQL, DDL)

另外,掌握以下技术和框架对更深层次的了解此平台有很大帮助:

如果有任何关于改进本手册的建议,请提交 issue 到 GitHub。如果发现拼写或者单词错误,缺陷或者不一致的地方,请直接 fork 这个仓库并提交修改。谢谢!

1. 安装

系统要求
  • 64-bit 操作系统: WindowsLinux 或者 macOS

  • 内存 – 使用 CUBA Studio 开发,8GB。

  • 硬盘空间 – 10 GB

Java SE Development Kit (JDK)
  • 安装 JDK 8 然后在终端运行下面命令检查 Java 版本:

    java -version

    这个命令会输出 Java 版本信息,比如: 1.8.0_202

    CUBA 7.2 支持 Java 8,9,10 和 11。如果不需要支持使用 CUBA 老版本创建的项目(包括迁移到 CUBA 7.2 的项目),那么可以直接安装 Java 11。

    注意:不支持 OpenJ9 JVM。

  • 设置 JAVA_HOME 环境变量为 JDK 的根目录路径,比如:C:\Java\jdk8u202-b08

    • Windows 系统,可以通过 ComputerPropertiesAdvanced System SettingsAdvancedEnvironment variables 来设置。这个变量的值需要加到 系统变量(System variables) 列表中。

    • macOS 系统,建议在 /Library/Java/JavaVirtualMachines 安装 JDK,比如 /Library/Java/JavaVirtualMachines/jdk8u202-b08,然后在 ~/.bash_profile 用下面的命令设置 JAVA_HOME

      export JAVA_HOME="$(/usr/libexec/java_home -v 1.8)"

  • 如果是通过代理服务器上网,在运行开发工具和 Gradle 的时候需要给 JVM 设置一些关于代理的系统参数。这些参数在 这里 有解释(可以查查 HTTP 和 HTTPS 协议的相关参数)。建议在系统级别通过 JAVA_OPTS 环境变量设置这些参数。

开发工具

下面这些工具有助于使用 CUBA 框架开发:

  • CUBA Studio - 构建在 IntelliJ 平台之上并且针对 CUBA 项目量身定做的集成开发环境。可以安装为系统的独立运行程序,或者作为 IntelliJ IDEA 的插件安装(社区版或者终极版都行)。更多信息参考 CUBA Studio 使用手册

  • CUBA CLI - 一个命令行工具,能帮助创建项目和项目元素的基本脚手架代码:实体,界面,服务,等等。使用这个工具的话,可以用任何 Java IDE 开发 CUBA 应用程序。更多信息参考 CUBA CLI GitHub

如果不熟悉 Java,推荐使用 CUBA Studio,因为这是最高级最直观的工具。

数据库

最基本的场景中,内置的 HyperSQL (http://hsqldb.org) 就可以用来做数据库服务。对于研究平台能力和做应用程序原型来说足够了。如果需要构建生产环境应用程序,还是推荐安装平台支持的全功能 DBMS,比如 PostgreSQL

网页浏览器

基于平台开发的应用的 Web 界面支持所有流行的浏览器,包括 Google ChromeMozilla FirefoxSafariOpera 15+Internet Explorer 11Microsoft Edge

2. 快速开始

访问平台网站的 快速开始 页面,学习创建 web 应用程序的主要内容:如何创建项目和数据库,如果设计数据模型以及如何创建用户界面。确保您的电脑已经安装了必要的软件,参阅安装

如想进一步学习 CUBA,CUBA 平台的 学习 网页有更多的培训示例。

下一个开发中必要的步骤就是如何在您的应用程序中实现业务逻辑。参考以下指南:

如需设计更为复杂的数据模型,参考以下指南:

通过下面的指南,您可以了解到在 CUBA 应用程序中如何读写数据:

其他关于如何处理事件,本地化语言消息,提供用户访问权限以及如何测试应用程序,可以访问 指南 页面。

本手册中的大多数示例代码都是基于 Sales Application 的数据模型。

更多信息

视频 - YouTube

网课指导详细介绍了概念和技术

向导

对平台中各个要素做了重点指导

在线示例

在线应用程序演示,展示平台功能

3. 框架详细介绍

本章节包含关于平台架构、通用组件以及工作机制的详细介绍。

3.1. 架构

本节从不同的角度介绍 CUBA 应用程序的体系结构,将按层(tier)、块(block)、模块(module)和组件(component)来介绍。

3.1.1. 应用程序层和块

框架支持使用多种客户端、中间层、数据库层构建多层应用程序。后续将主要介绍中间层和客户端层,因此,后面看到的“所有层”仅指这两个层。

应用程序的每个层都可以创建一个或多个应用程序 块(block)。每一个块都是独立可执行程序,可与应用程序中的其它块(block)交互。通常,一个块就是在 JVM 上运行的一个 web 应用程序。

AppTiers
Figure 1. 应用程序层(tier)和块(block)
Middleware - 中间件

中间件层包含应用程序的核心业务逻辑,并提供对数据库的访问功能。由运行在 Java servlet 容器上的单独 Web 应用程序表示。参阅中间件组件

Web Client - Web 客户端

是客户端层的主要块(block)。包含主要为内部用户(即管理系统用户)设计的界面。由运行在 Java servlet 容器上的单独 Web 应用程序表示。用户界面基于 Vaadin 框架实现。参阅通用用户界面(GUI)

Web Portal - Web 门户

客户端层的附加块(block)。可以包含外部用户的界面以及作为与移动设备和第三方应用程序集成的入口点。由运行在 Java servlet 容器上的单独 Web 应用程序表示。用户界面基于 Spring MVC 框架实现。参阅Portal 组件

Frontend UI - 前端 UI

为外部用户设计的可选客户端。与 Web Portal 不同,这个是纯客户端侧应用程序(例如,一个执行在浏览器中的 JavaScript 应用程序)。与运行在 Web Client 或 Web Portal 内的中间件通过 REST API 通信。可以使用 React 或其他库、框架开发。参阅 前端用户界面

Middleware 是任何应用程序都需要的 block。用户界面可以由一个或多个 block 实现,例如 Web Client 和 Web Portal。

所有基于 Java 的客户端 block 都通过 HTTP 协议统一与中间层交互,从而可以任意部署中间层,即使在防火墙内也可以。在最简单的情况下,当中间层和 Web 客户端部署在同一服务器上时,它们之间的本地交互可以绕过网络栈以获得更好的性能。

3.1.2. 应用程序模块

模块是 CUBA 应用程序的最小组成部分。是应用程序项目中的单个模块,并具有相应的包含可执行代码的 JAR 文件。

标准模块:

  • global – 包含所有层共享的实体类、服务接口和其它类。用于所有应用程序 block

  • core – 实现中间层的服务和所有其它组件。

  • gui – 包含通用用户界面的组件。用于 Web Client block。

  • web – 基于 Vaadin 的通用用户界面实现。

  • portal – 可选模块 - 基于 Spring MVC 的 Web 门户的实现。

  • front – 可选模块 - 使用 JavaScript 实现的前端用户界面

AppModules
Figure 2. 应用程序模块

3.1.3. 应用程序组件

框架支持将应用程序功能分成不同的组件。每个 应用程序组件(又名 扩展插件 )都可以有自己的数据模型、业务逻辑以及用户界面。应用程序将组件作为类库来使用并且包含了组件的所有功能。

应用程序组件的概念可以使得基础框架保持在一个比较小的范围,同时通过组件来交付一些可选功能,比如报表功能、全文检索功能、图表功能、WebDAV 以及其它功能。另外,应用程序开发人员也可以用这个机制来对大项目进行解耦,拆分成一组功能模块,从而每个模块可以有单独的开发计划和不同的发布周期。当然,应用程序组件是可重用的,并且可以在基础框架之上提供针对特定领域的抽象层。

技术上来说,核心框架也是一个名为 cuba 的应用程序组件。唯一不同的是,这个组件对于任何应用程序来说都是必不可少的。所有其它的组件都依赖 cuba,也可以互相依赖。

下面的图展示了应用中使用的标准组件之间的依赖关系。实线表示强制依赖,虚线表示可选依赖。

BaseProjects

下面的图展示了标准组件和自定义应用程序组件之间可能的依赖结构。

AppComponents2

任何 CUBA 应用程序都可以很容易的变成一个组件,从而为其它应用程序提供一些功能。要作为组件使用,应用程序项目应包含 app-component.xml 文件以及在 global 模块 JAR 的 manifest 中配置特殊的条目。CUBA Studio 可以为当前项目自动生成 XML 文件和 manifest 条目。

请参阅应用程序组件示例部分中使用自定义应用程序组件的分步介绍。

3.1.4. 应用程序结构

上面列出的架构原则在组装完成的应用程序结构能直接反映出来。假设我们有个简单的应用程序,包含两个 block - MiddlewareWeb Client ;并依赖了两个应用程序组件的功能 - cubareports

SampleAppArtifacts
Figure 3. 一个简单的应用程序的结构

该图展示了 Tomcat 服务的几个目录的内容,其中包含已部署的应用程序。

Middleware blockapp-core WEB 应用程序表示,Web Client block 由 app Web 应用程序表示。Web 应用程序包含 WEB-INF/lib 目录下的 JAR 文件。每个 JAR(工件)都是一个应用程序模块组件构建的结果。

比如,中间层 Web 应用程序 app-core 包含哪些 JAR 文件是由以下情况决定的:Middleware block 包含 globalcore 模块,同时应用程序依赖了 cubareports 组件。

3.2. 通用组件

本章介绍平台组件,这些组件对应用程序的所有都是通用的。

3.2.1. 数据模型

数据模型实体分为两类:

  • 持久化实体 - 使用 ORM 将此类实体的实例存储在数据库中。

  • 非持久化实体 – 实例仅存在于内存中,或通过不同的机制存储在某处。

可以参考我们关于如何进行数据模型设计的指南。 数据建模: 多对多关系 演示了不同情况下怎么使用多对多关联。 数据建模:组合 举了不同的例子,演示如何使用实体间的组合关系。

实体通过其属性描述。属性对应到实体代码的字段以及字段的访问方法(get / set)。如果省略 setter,则该属性变为只读。

持久化实体可能包含未存储在数据库中的属性。对于非持久化属性,Java 字段不是必须的,可以只创建访问方法。

实体类应满足以下要求:

  • 继承自平台提供的基类之一(参阅下面的描述)。

  • 有一组对应于属性的字段和访问方法。

  • 类及其字段(或访问方法,有的属性没有对应的字段)必须是带注解的,用于为 ORM (持久化实体)和元数据框架提供信息。

以下类型可用于实体属性:

  • java.lang.String

  • java.lang.Boolean

  • java.lang.Integer

  • java.lang.Long

  • java.lang.Double

  • java.math.BigDecimal

  • java.time.LocalDate

  • java.time.LocalTime

  • java.time.LocalDateTime

  • java.time.OffsetTime

  • java.time.OffsetDateTime

  • java.util.Date

  • java.sql.Date

  • java.sql.Time

  • java.util.UUID

  • byte[]

  • enum

  • Entity

基础实体类(见下文)重写 equals()hashCode() 方法,这样可以通过比较它们的标识符来检查实体实例是否相同。即,如果它们的标识符相等,则认为他们是同一个实例。

3.2.1.1. 基础实体类

本节将详细介绍基础实体类和接口。

EntityClasses
  • Instance – 定义了使用应用程序领域对象的基本方法:

    • 获取对象元类的引用;

    • 生成实例名称;

    • 根据名称读写属性值;

    • 添加监听器用于接收有关属性更改的通知。

  • Entity – 继承自 Instance,添加了实体标识符;同时 Entity 没有定义标识符的类型,该类型由其继承者决定。

  • AbstractInstance – 实现了使用属性更改监听器的逻辑。

    AbstractInstance 以弱引用的方式存储监听器,如果添加的监听器没有外部引用,将立即被 GC 销毁。通常情况下,属性更改监听器是可视化数据组件,必定有其它对象引用,因此没有监听器丢失的问题。但是,如果监听器是由应用程序代码创建的,并且没有任何对象以自然方式引用它,则除了将其添加到 Instance 之外,还必须将其保存在某个对象字段中。

  • BaseGenericIdEntity – 持久化和非持久化实体的基础类。实现了 Entity 接口但没有指定实体标识符(即主键)的类型。

  • EmbeddableEntity - 可嵌入的持久化实体的基础类。

下面是关于在项目实体中继承基础实体的一些建议。非持久化实体应该继承与持久化实体相同的基类。框架是根据实体所在注册文件:persistence.xmlmetadata.xml 来确定实体是否是持久化实体。

StandardEntity

继承自 StandardEntity 的实体带有一组标准功能:UUID 类型的主键、包含创建人和修改人及创建时间和修改时间、还支持乐观锁和软删除机制。

EntityClasses Standard
  • HasUuid – 具有全局唯一标识符实体的接口。

  • Versioned – 支持 乐观锁实体的接口。

  • Creatable – 记录有关创建实例的时间和人员信息实体的接口。

  • Updatable – 记录有关上次修改实例的时间和人员的信息实体的接口。

  • SoftDelete – 支持软删除实体的接口。

BaseUuidEntity

继承自 BaseUuidEntity 的实体带有 UUID 类型主键,但不具备 StandardEntity 的所有功能。可以在具体实体类中有选择地实现一些接口,如 CreatableVersioned 等。

EntityClasses Uuid
BaseLongIdEntity

继承自 BaseLongIdEntityBaseIntegerIdEntity 的实体具有 LongInteger 类型的主键。可以在具体实体类中有选择地实现一些接口,CreatableVersioned 等。强烈建议实现 HasUuid,因为它可以提供一些优化,并可以确保实例在分布式环境中的唯一标识。

EntityClasses Long
BaseStringIdEntity

继承自 BaseStringIdEntity 的实体具有 String 类型的主键。可以在具体实体类中有选择地实现一些接口,如 CreatableVersioned 等。强烈建议实现 HasUuid,因为它可以提供一些优化,并可以确保实例在分布式环境中的唯一标识。具体实体类必须有一个使用 @Id JPA 注解的字符串字段用来作为实体的主键。

EntityClasses String
BaseIdentityIdEntity

继承自 BaseIdentityIdEntity 的实体,会映射到具有 IDENTITY 主键的表。可以在具体实体类中有选择地实现一些接口,如 CreatableVersioned 等。强烈建议实现 HasUuid,因为它可以实现一些优化,并可以确保实例在分布式环境中的唯一标识。实体的 id 属性(即 getId()/setId())是 IdProxy 类型,用来替换真实标识符,真实标识符会在插入数据时由数据库生成。

EntityClasses Identity
BaseIntIdentityIdEntity

继承 BaseIntIdentityIdEntity 的实体,会映射到 Integer 类型的 IDENTITY 为主键的表(区别于 BaseIdentityIdEntity 中的 Long )。在其它方面,BaseIntIdentityIdEntityBaseIdentityIdEntity 类似。

EntityClasses IntIdentity
BaseGenericIdEntity

除了上面情况之外,如果需要将实体映射到具有复合主键的表,则直接继承 BaseGenericIdEntity 。在这种情况下,具体实体类必须有一个嵌入类型的字段代表复合主键,并使用 @EmbeddedId JPA 注解。

3.2.1.2. 实体注解

本节介绍平台支持的实体类和属性的所有注解。

其中,JPA 的注解需要 javax.persistence 包依赖,框架中元数据管理和其它机制则会使用 com.haulmont.* 包中的注解。

在本手册中,如果注解的标识是一个简单的类名,那么指的是 com.haulmont.* 包中的框架类之一。

3.2.1.2.1. 类注解
@Embeddable

定义一个与所属实体存储在同一表中的嵌入实体。

应使用 @MetaClass 注解来指定实体名称。

@EnableRestore

表示软删除的实体实例是否可以通过 Administration > Data Recovery 菜单打开的 core$Entity.restore 界面进行恢复。

@Entity

声明一个类是一个数据模型实体。

参数:

  • name – 实体的名称,必须带有前缀,以 _ 符号分隔前缀。建议使用项目的简称作为前缀来形成单独的命名空间。

例如:

@Entity(name = "sales_Customer")
@Extends

表示该实体是一个对基础实体的扩展,在应用程序中应该使用它来代替其基础实体。参阅 功能扩展

@DiscriminatorColumn

SINGLE_TABLEJOINED 继承策略的情况下,用于定义负责区分实体类型的数据库列。

参数:

  • name – 鉴别器列名。

  • discriminatorType – 鉴别器列的类型。

例如:

@DiscriminatorColumn(name = "TYPE", discriminatorType = DiscriminatorType.INTEGER)
@DiscriminatorValue

定义此实体的鉴别器列值。

例如:

@DiscriminatorValue("0")
@IdSequence

如果实体是 BaseLongIdEntityBaseIntegerIdEntity 的子类,则应明确定义用于生成标识符的数据库序列名称。如果实体没有此注解,则框架将自动生成名称并创建一个序列。

参数:

  • name – 序列名称。

  • cached - 可选参数,定义序列应该以 cuba.numberIdCacheSize 递增,并将未使用的 ID 值缓存在内存中。默认为 False。

默认情况下,序列都在主 数据存储 创建。但是如果 cuba.useEntityDataStoreForIdSequence 应用程序属性设置为 true,序列则会创建在实体所在的数据存储中。

@Inheritance

定义实体类的继承策略。此注解在实体继承层次的根类上指定。

参数:

  • strategy – 继承策略,默认为 SINGLE_TABLE

@Listeners

定义监听器列表,用来响应中间的实体实例生命周期事件。

在注解值中指定监听器 bean 名称,可以是字符串或字符串数组。请参阅实体监听器

例如:

@Listeners("sample_UserEntityListener")
@Listeners({"sample_FooListener","sample_BarListener"})
@MappedSuperclass

表示该类用作其它实体类的父类,其属性必须用作后代实体的一部分。这种类不关联任何特定的数据库表。

数据建模:实体继承 向导演示了如何定义实体继承关系。

@MetaClass

用于声明非持久化或嵌入实体(也就是不能用 @javax.persistence.Entity 注解)

参数:

  • name – 实体名称,必须以一个前缀开头,以 _ 符号分隔前缀。建议使用项目的简称作为前缀来形成单独的命名空间。

例如:

@MetaClass(name = "sales_Customer")
@NamePattern

定义如何创建表示单一实体的字符串。可以认为是应用程序级别的 toString() 方法。在 UI 中到处都用得上,比如在类似 TextField 或者 LookupField 的单一字段中需要展示一个实体。也可以通过编程的方式使用 MetadataTools.getInstanceName() 方法获取实例名称。

注解值应该是 {0}|{1} 格式的字符串,其中:

  • {0} – 可以有两种类型的格式化字符串:

    • 带有 %s 占位符的字符串,用来对实体属性进行格式化。属性值会根据其 datatypes 被格式化成字符串。

    • 带有 # 前缀的对象方法名称。方法必须返回 String,并且没有参数。

  • {1} - 使用逗号分隔的属性名称列表, {0} 部分定义的字符串格式中的变量与这部分的字段名对应。即使在 {0} 中使用的是方法名,仍然需要此字段列表,因为这个列表也被用于构造 _minimal 视图

例如:

@NamePattern("%s|name")
@NamePattern("%s - %s|name,date")
@NamePattern("#getCaption|amount,customer")
...
public String getCaption(){
    String prefix = "";
    if (amount > 5000) {
        prefix = "Grade 1 ";
    } else {
        prefix = "Grade 2 ";
    }
    return prefix + customer.name;
}
@PostConstruct

可以为方法指定此注解。在使用 Metadata.create() 或者类似的 DataManager.create()DataContext.create() 方法创建实体实例之后将立即调用带此注解的方法。

参考 初始化实体值 指南,了解如何在实体类中使用 @PostConstruct 注解直接定义初始值。

使用此注解的方法可以使用在 global 模块可用的 Spring bean 作为参数。示例:

@PostConstruct
public void postConstruct(Metadata metadata, SomeBean someBean) {
    // ...
}
@PrimaryKeyJoinColumn

JOINED 继承策略的情况下用于为实体指定外键列,该外键是父类实体主键的引用。

参数:

  • name – 实体的外键列的名称

  • referencedColumnName – 父类实体的主键列的名称

例如:

@PrimaryKeyJoinColumn(name = "CARD_ID", referencedColumnName = "ID")
@PublishEntityChangedEvents

表示实体在数据库改动时,框架会发送 EntityChangedEvent 事件。

@SystemLevel

表示该实体是系统级别的实体,不能在各种实体列表中进行选择,例如通用过滤器参数类型或动态属性类型。

@Table

定义实体的数据库表。

参数:

  • name – 表名

例如:

@Table(name = "SALES_CUSTOMER")
@TrackEditScreenHistory

表示系统将会保存编辑界面的打开历史记录,并能够在 sec$ScreenHistory.browse 展示。界面可以使用下面的 web-menu.xml 元素添加到主菜单:

<item id="sec$ScreenHistory.browse" insertAfter="settings"/>
3.2.1.2.2. 属性注解

应该为相应的字段设置属性注解,此情况除外:如果需要声明只读且非持久属性 foo,则只创建 getFoo() 方法并使用 @MetaProperty 注解就可以了。

@CaseConversion

表明使用此注解的实体属性绑定的文本输入框控件会自动转换大小写。

参数:

  • type - 转换类型:UPPER(默认值)、LOWER

示例:

@CaseConversion(type = ConversionType.UPPER)
@Column(name = "COUNTRY_CODE")
protected String countryCode;
@Column

定义用于存储属性值的 DB 列。

参数:

  • name – 列名。

  • length – (可选参数,默认为 255 ) - 列字段的长度。还用于生成元数据,可以限制绑定到此属性的可视化组件中输入文本的最大长度。添加 @Lob 注解可以移除对属性长度的限制。

  • nullable – (可选参数,默认为 true ) - 确定属性是否可以包含 null 值。当 nullable = false 时,JPA 会确保该字段在保存时有值。此外,带有该属性的可视化组件会要求用户必须输入值。

@Composition

表示一种组合的关系,是比关联更紧密的一种关系。本质上,这意味着相关实体仅作为已有实体的一部分存在,也就是与已有实体一起创建和删除。

数据建模:组合 举了不同的例子,演示如何使用实体间的组合关系。

例如,订单中的条目列表( Order 类包含 Item 实例的集合,Item 实例不能独立存在):

@OneToMany(mappedBy = "order")
@Composition
protected List<Item> items;

另一个例子是一对一的关系:

@Composition
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "DETAILS_ID")
protected CustomerDetails details;

选择 @Composition 注解类型的关系可以在编辑界面中使用数据源的特殊提交模式。在此模式下,仅在提交主实体时保存对关联实例的更改。详细信息,请参阅组合结构

@CurrencyValue

表示带有该注解的字段包含货币值。如果用了该注解,Studio 在实体编辑界面的 表单 中会为该属性生成 CurrencyField

参数:

  • currency – 货币名称:USD,GBP,EUR,$,或其他货币符号。

  • labelPosition - 货币标签的位置:RIGHT(默认),或 LEFT

例如:

@CurrencyValue(currency = "$", labelPosition = CurrencyLabelPosition.LEFT)
@Column(name = "PRICE")
protected BigDecimal price;
@Embedded

定义可嵌入类型的引用属性。被引用实体应该具有 @Embeddable 注解。

例如:

@Embedded
protected Address address;
@EmbeddedParameters

默认情况下,如果嵌入实体的所有属性在数据库中为空,则 ORM 不会创建嵌入实体的实例。当实例始终为非空时,可以使用 @EmbeddedParameters 注解指定不同的行为,例如:

@Embedded
@EmbeddedParameters(nullAllowed = false)
protected Address address;
@Id

表示该属性是实体主键。通常,此注解在基类的字段上设置,例如 BaseUuidEntity。仅在继承 BaseStringIdEntity 基类(即用于创建主键是字符串类型的实体)的情况下,才需要对特定实体类使用此注解。

@IgnoreUserTimeZone

使框架忽略时间戳类型属性的用户时区(如果当前会话设置了时区),时间戳属性使用 @javax.persistence.Temporal.TIMESTAMP 注解。

@JoinColumn

定义确定实体之间关系的 DB 列。存在此注解则表示该关联关系的拥有方(owning side)。

参数:

  • name – 列名

例如:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CUSTOMER_ID")
protected Customer customer;
@JoinTable

定义 @ManyToMany 关系拥有方的关联表。

参数:

  • name – 关联表表名

  • joinColumns – 关联表中的 @JoinColumn 元素对应于关联关系拥有方的主键(包含 @JoinTable 注解的那个)

  • inverseJoinColumns – 关联表中的 @JoinColumn 元素对应于关系非拥有方(non-owning side)的主键。

关系拥有方的 Group 类的 customers 属性示例:

@ManyToMany
@JoinTable(name = "SALES_CUSTOMER_GROUP_LINK",
 joinColumns = @JoinColumn(name = "GROUP_ID"),
 inverseJoinColumns = @JoinColumn(name = "CUSTOMER_ID"))
protected Set<Customer> customers;

同一关系非拥有方的 Customer 类的 groups 属性的示例:

@ManyToMany(mappedBy = "customers")
protected Set<Group> groups;
@Lob

表示该属性没有任何长度限制。此注解与 @Column 注解一起使用。如果设置了 @Lob,则忽略 @Column 中的默认或明确定义的长度。

示例:

@Column(name = "DESCRIPTION")
@Lob
private String description;
@Lookup

定义引用属性的查找类型设置。

参数:

  • type - 默认值为 SCREEN,表示从查找界面中选择引用。 DROPDOWN 表示从下拉列表中选择引用。如果查找类型设置为 DROPDOWN,则在创建编辑界面时,Studio 将生成集合数据容器。因此,应在生成实体编辑界面之前设置查找类型参数。此外,Filter 组件将允许用户从下拉列表中而不是查找界面中选择此类型的参数。

  • actions - 定义默认情况下要在 FieldGroup 内的 PickerField 组件中使用的操作。可能的值:lookupclearopen

@Lookup(type = LookupType.DROPDOWN, actions = {"open"})
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CUSTOMER_ID")
protected Customer customer;
@ManyToMany

定义具有多对多关系类型的集合属性。

数据建模: 多对多关系 演示了不同情况下怎么使用多对多关联。

多对多关系可以有一个拥有方和一个反向的非拥有方。拥有方应使用 @JoinTable 注解,非拥有方则使用 mappedBy 参数。

参数:

  • mappedBy – 关系拥有方引用实体的字段。只能在关系的非拥有方进行设置。

  • targetEntity – 引用实体的类型。如果使用 Java 泛型声明集合,则此参数是可选的。

  • fetch – (可选参数,默认为 LAZY ) - 定义 JPA 是否会以贪婪的方式加载引用实体的集合。此参数应始终保持为 LAZY,因为 CUBA 应用程序中是通过视图机制确定如何加载引用实体。

不推荐使用 cascade 注解属性。使用此注解会隐式的对实体进行持久化和合并,这将绕过某些系统机制。特别是,EntityStates bean 将不能正确地检测托管状态,并且根本不会调用实体监听器

@ManyToOne

定义具有多对一关系类型的引用属性。

参数:

  • fetch – (默认情况下为 EAGER )参数,用于确定 JPA 是否以 贪婪的方式加载引用的实体。此参数应始终设置为 LAZY,因为 CUBA 应用程序中是通过视图机制确定如何加载引用实体。

  • optional – (可选参数,默认情况下为 true)– 表明属性是否可以包含 null 值。如果 optional = false,JPA 会确保在保存实体时引用存在。此外,使用此属性的可视化组件会要求用户输入值。

例如,几个 Order 实例引用相同的 Customer 实例。在这种情况下,Order.customer 属性应该具有以下注解:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CUSTOMER_ID")
protected Customer customer;

不推荐使用 cascade 注解属性。使用此注解会隐式的对实体进行持久化和合并,这将绕过某些系统机制。特别是,EntityStates bean 将不能正确地检测托管状态,并且根本不会调用 实体监听器

@MetaProperty

表明元数据应包含带有此注解的属性。可以为字段设置此注解,但是如果没有字段,也可以为 getter 方法设置此注解。

已经带有 javax.persistence 包中的以下注解的字段不需要这个注解:@Column@OneToOne@OneToMany@ManyToOne@ManyToMany@Embedded。这些字段自动包含在元数据中。因此,@MetaProperty 主要用于定义实体的非持久化属性。

参数(可选):

  • mandatory - 确定属性是否可以包含 null 值。如果 mandatory = true,使用此属性的可视化组件会要求用户输入值。

  • datatype - 显式定义数据类型,将会覆盖根据属性的 Java 类型推断的数据类型。

  • related - 当此属性包含在视图中时,定义从数据库中提取的与该属性相关联的其他持久化属性的数组。还有,如果此注解用在 getter 方法上,比如该属性为只读属性,那么关联属性的改动会为该只读属性产生 PropertyChangeEvent 事件。此功能可以帮助展示该只读属性的 UI 组件更新页面。

字段示例:

@Transient
@MetaProperty
protected String token;

方法示例:

@MetaProperty(related = "firstName,lastName")
public String getFullName() {
    return firstName + " " + lastName;
}
@NumberFormat

指定 Number 类型(BigDecimalIntegerLongDouble)属性的格式。在所有的 UI 展示中,将按照注解参数提供的格式对属性值进行格式化和解析:

  • pattern - DecimalFormat 所描述的格式模板.

  • decimalSeparator - 用作小数位分隔符的字符(可选)。

  • groupingSeparator - 用作千位分隔符的字符(可选)。

如果未指定 decimalSeparatorgroupingSeparator,框架会使用当前用户的本地化格式字符串或服务器操作系统的本地化格式字符串。

例如:

@Column(name = "PRECISE_NUMBER", precision = 19, scale = 4)
@NumberFormat(pattern = "0.0000")
protected BigDecimal preciseNumber;

@Column(name = "WEIRD_NUMBER", precision = 19, scale = 4)
@NumberFormat(pattern = "#,##0.0000", decimalSeparator = "_", groupingSeparator = "`")
protected BigDecimal weirdNumber;

@Column(name = "SIMPLE_NUMBER")
@NumberFormat(pattern = "#")
protected Integer simpleNumber;

@Column(name = "PERCENT_NUMBER", precision = 19, scale = 4)
@NumberFormat(pattern = "#%")
protected BigDecimal percentNumber;
@OnDelete

在实体软删除的情况下,确定关联实体的处理策略。参阅软删除

例如:

@OneToMany(mappedBy = "group")
@OnDelete(DeletePolicy.CASCADE)
private Set<Constraint> constraints;
@OnDeleteInverse

从关系的反向软删除实体的情况下,确定属性关联实体的处理策略。参阅 软删除

例如:

@ManyToOne
@JoinColumn(name = "DRIVER_ID")
@OnDeleteInverse(DeletePolicy.DENY)
private Driver driver;
@OneToMany

定义一对多关系类型的集合属性。

参数:

  • mappedBy – 引用实体的字段,通过此字段建立关联。

  • targetEntity – 引用实体的类型。如果使用 Java 泛型声明的集合,则此参数是可选的。

  • fetch – (可选参数,默认为 LAZY) - 确定 JPA 是否以贪婪的方式加载引用实体的集合。此参数应始终保持为 LAZY,因为 CUBA 应用程序中是通过视图机制确定如何加载引用实体。

例如,几个 Item 实例使用 @ManyToOne 注解的字段 Item.order 引用相同的 Order 实例。在这种情况下,Order 类可以包含 Item 实例的集合:

@OneToMany(mappedBy = "order")
protected Set<Item> items;

不推荐使用 JPA cascadeorphanRemoval 注解属性。使用此注解会隐式的对实体进行持久化和合并,这将绕过某些系统机制。特别是,EntityStates bean 将不能正确地检测托管状态,并且根本不会调用 实体监听器orphanRemoval 注解属性不遵循 软删除机制。

@OneToOne

使用一对一关系类型定义引用属性。

参数:

  • fetch –(默认情况下为 EAGER)确定 JPA 是否会 贪婪的方式加载引用的实体。此参数应设置为 LAZY,因为 CUBA 应用程序中是通过视图机制确定如何加载引用实体。

  • mappedBy – 引用实体的字段,使用这个字段建立关联。只能设置在关系的非拥有方。

  • optional – (可选参数,默认为 true )- 指示属性是否可以包含 null 值。如果 optional = false,JPA 确保在保存实体时存在引用。此外,使用此属性的可视化组件会要求用户输入值。

Driver 类中关系的拥有方示例:

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CALLSIGN_ID")
protected DriverCallsign callsign;

DriverCallsign 类中关系的非拥有方示例:

@OneToOne(fetch = FetchType.LAZY, mappedBy = "callsign")
protected Driver driver;
@OrderBy

定义从数据库检索关联时集合属性中元素的顺序。需要对有序的 Java 集合(例如 ListLinkedHashSet )指定此注解,这样获得可预测的元素序列。

参数:

  • value – 确定排序的字符串格式:

orderby_list::= orderby_item [,orderby_item]*
orderby_item::= property_or_field_name [ASC | DESC]

例如:

@OneToMany(mappedBy = "user")
@OrderBy("createTs")
protected List<UserRole> userRoles;
@Temporal

指定 java.util.Date 类型属性的存储值类型:日期、时间或日期加时间。

参数:

  • value – 存储值的类型:DATETIMETIMESTAMP

例如:

@Column(name = "START_DATE")
@Temporal(TemporalType.DATE)
protected Date startDate;
@Transient

表示该字段不存储在数据库中,即属性是非持久化的。

字段的类型如果是 JPA 支持的(请参阅 Basic 注解类型),那么在默认情况下是持久化的,这就是为什么对于这些类型的非持久化属性必须要加上 @Transient 注解。

如果元数据中需要包含 @Transient 属性,应该给属性加上 @MetaProperty 注解。

@Version

表明注解的字段是用于支持乐观锁的版本字段。

当实体类实现 Versioned 接口时,需要这样的字段,StandardEntity 基类已经包含这样的字段。

例如:

@Version
@Column(name = "VERSION")
private Integer version;
3.2.1.3. 枚举属性

JPAenum 属性的标准用法是使用数据库的整型字段,保存从 ordinal() 方法获得的值。在生产环境下对系统进行扩展时,这种方法可能会导致以下问题:

  • 如果数据库中枚举的值不等于任何 ordinal 值,则无法加载实体实例。

  • 不能在现有的值之间添加新的枚举值,但是这在需要按枚举值排序时很重要。

CUBA 中解决这些问题的方式是将存储在数据库中的值与枚举的 ordinal 值分离。要到这一点,实体的字段应该用存储在数据库中的字段类型声明(IntegerString 型),而实体的访问方法(getter / setter)则使用实际的枚举类型来定义。

例如:

@Entity(name = "sales_Customer")
@Table(name = "SALES_CUSTOMER")
public class Customer extends StandardEntity {

    @Column(name = "GRADE")
    protected Integer grade;

    public CustomerGrade getGrade() {
        return grade == null ? null : CustomerGrade.fromId(grade);
    }

    public void setGrade(CustomerGrade grade) {
        this.grade = grade == null ? null : grade.getId();
    }
    ...
}

在这种情况下,枚举类可以如下所示:

public enum CustomerGrade implements EnumClass<Integer> {

    PREMIUM(10),
    HIGH(20),
    MEDIUM(30);

    private Integer id;

    CustomerGrade(Integer id) {
        this.id = id;
    }

    @Override
    public Integer getId() {
        return id;
    }

    public static CustomerGrade fromId(Integer id) {
        for (CustomerGrade grade : CustomerGrade.values()) {
            if (grade.getId().equals(id))
                return grade;
        }
        return null;
    }
}

为了将枚举属性正常地反映在元数据中,枚举类必须实现 EnumClass 接口。

如示例所示,grade 属性对应于存储在数据库中的 Integer 类型值,该值由 CustomerGrade 枚举的 id 字段指定,即 102030。同时,应用程序代码和元数据框架通过访问方法(getter/setter)使用 CustomerGrade 枚举,这些方法中执行类型的转换。

如果数据库中的值没有对应的枚举值,这时 getGrade() 方法将只返回 null。如果要添加一个新枚举值,例如 HIGHERHIGHPREMIUM 之间,只需添加 id = 15 的新枚举值就可以了,这样可以确保按 Customer.grade 字段正确排序。

Integer 字段类型可以提供有序的常量列表,并允许在 JPQL 和 SQL 查询中排序( ><>=order by ),同时也基本没有存储空间和性能方面的问题。但另一方面,在查询结果中,Integer 值不是“自描述”的,这使得在对数据库的原始数据或序列化后的数据进行调试时变地复杂了。就这点而言,使用 String 类型更方便。

可以在 CUBA Studio 中使用 Data Model > New > Enumeration 菜单创建枚举。要将枚举用作实体属性,请在属性编辑器的 Attribute type 字段中选择 ENUM,然后在 Type 字段中选择枚举类。枚举值可以与在应用程序界面中显示的本地化名称相关联。

3.2.1.4. 软删除

CUBA 框架支持软删除模式,记录并不从数据库中物理删除,而是打上特定的标记,此时常规的方式将不能访问这些记录。后续可以使用某种定时任务将这些记录从数据库中完全删除或恢复。

软删除机制对于应用程序开发人员来说是透明的,唯一的要求是实体类实现 SoftDelete 接口。平台将自动调整数据操作。

软删除模式具有以下优点:

  • 显著降低因不正确的用户操作而导致数据丢失的风险。

  • 可以将某些记录标记为不可访问,即使系统中还存在对这些记录的引用。

    使用 Orders-Customers 数据模型作为示例,假设某个客户已经下了多个订单,但我们需要禁止用户访问此客户实例。对于传统的硬删除来说,这是不可能的,因为删除客户实例需要删除所有相关订单或在订单中将所有对客户的引用设置为 null(意味着数据丢失)。软删除后,虽然客户实例无法进行搜索和修改,但是用户依然可以在订单编辑中看到客户的名称,因为在获取相关实体时有意忽略了删除属性。

    上述标准行为可以使用关联实体处理策略进行修改。

已删除的实体实例可以在应用程序的 Administration 菜单中的 Restore Deleted Entities 界面上手动恢复。此功能仅适用于对所有实体具有所有操作权限的应用程序管理员,并且应该谨慎使用,因此建议拒绝让普通用户访问此界面。

软删除的负面影响是会增加数据库大小,以及可能需要额外的数据清理程序。

3.2.1.4.1. 软删除的使用

要支持软删除,实体类应该实现 SoftDelete 接口,相应的数据库表应该包含以下列:

  • DELETE_TS – 删除记录的时间。

  • DELETED_BY – 删除记录的用户登录名。

实现 SoftDelete 接口的实例的默认行为是,查询或按 id 搜索不返回软删除的实体。如果需要,可以使用以下方法动态关闭此特性:

  • 当前EntityManager实例调用 setSoftDeletion(false)

  • 通过DataManager请求数据时,LoadContext 对象调用 setSoftDeletion(false) 方法。

  • 数据加载器级别 - 调用 DataLoader.setSoftDeletion(false) 方法或在界面的 XML 描述中设置 loader 元素的 softDeletion="false" 属性。

在软删除模式下,平台在按标识符加载实例时、使用JPQL 查询加载实例时以及在实体的集合属性中都会自动过滤掉被删除的实例。但是,在单一值(*ToOne)关联实体的时候却会加载相关联的实体,不管这个关联的实体是否已经被删除。

3.2.1.4.2. 关联实体处理策略

对于软删除实体,平台提供了一种在删除时管理关联实体的机制,很大程度上类似于数据库外键的 ON DELETE 规则。此机制在中间有效,并且需要在实体属性上使用 @OnDelete@OnDeleteInverse 注解。

使用 @OnDelete 注解的实体被删除时会处理此注解,而不是此注解指向的实体(这是与数据库级别级联删除的主要区别)。

@OnDeleteInverse 注解指向的实体被删除时处理此注解(类似于数据库中外键级别的级联删除)。当被删除的对象没有可以在删除之前检查的属性时,此注解很有用。典型情况下,被检查的对象具有要删除的对象引用,并且此属性应该使用 @OnDeleteInverse 注解。

注解值可以是:

  • DeletePolicy.DENY – 如果带注解的属性不是 null 或不是空集合,则禁止删除实体。

  • DeletePolicy.CASCADE – 级联删除带注解的属性。

  • DeletePolicy.UNLINK – 与注解属性断开链接。仅在关联关系拥有方(在实体类中具有 @JoinColumn 注解的实体)断开链接是合理的。

例如:

  1. 禁止删除被引用的实体:如果尝试删除被至少一个 Order 引用的 Customer 实例,则将抛出 DeletePolicyException

    Order.java

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CUSTOMER_ID")
    @OnDeleteInverse(DeletePolicy.DENY)
    protected Customer customer;

    Customer.java

    @OneToMany(mappedBy = "customer")
    protected List<Order> orders;

    异常窗口中的消息可以在主消息包中进行本地化。使用以下键值:

    • deletePolicy.caption - 通知标题。

    • deletePolicy.references.message - 通知消息。

    • deletePolicy.caption.sales_Customer - 具体实体的通知标题。

    • deletePolicy.references.message.sales_Customer - 具体实体的通知消息。

  2. 关联集合元素的级联删除:删除 Role 实例也会导致所有 Permission 实例被删除。

    Role.java

    @OneToMany(mappedBy = "role")
    @OnDelete(DeletePolicy.CASCADE)
    protected Set<Permission> permissions;

    Permission.java

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ROLE_ID")
    protected Role role;
  3. 断开与关联集合元素的连接:删除 Role 实例会导致对集合中包含的所有 Permission 实例的 Role 属性设置为空引用。

    Role.java

    @OneToMany(mappedBy = "role")
    protected Set<Permission> permissions;

    Permission.java

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ROLE_ID")
    @OnDeleteInverse(DeletePolicy.UNLINK)
    protected Role role;

实现说明:

  1. 当保存实现了 SoftDelete 接口的实体到数据库时,会在中间件上处理关联实体策略。

  2. @OnDeleteInverseCASCADEUNLINK 策略一起使用时要注意。在此过程中,将从数据库中提取关联对象的所有实例,进行修改然后保存。

    例如,如果 @OnDeleteInverse(CASCADE) 策略设置在 CustomerJob 关联内的 Job.customer 属性上,代表一个 customer 有多个 job,删除 Customer 实例时将获取和修改所有 Job。这可能会导致应用程序服务器或数据库超负荷。

    另一方面,使用 @OnDeleteInverse(DENY) 是安全的,因为它只涉及统计关联对象的数量。如果数量大于 0,则抛出异常。这使得 @OnDeleteInverse(DENY) 适用于 Job.customer 属性。

3.2.1.4.3. 数据库级别的唯一约束

为了在软删除模式中对特定值应用唯一约束,也就是说在数据库中可以存在至少一个具有该值的未删除记录和任意数量的具有相同值的已删除记录。

此逻辑可以针对每种数据库服务类型以特定方式实现:

  • 如果数据库服务支持部分索引(例如 PostgreSQL),则可以按如下方式实现唯一约束:

    create unique index IDX_SEC_USER_UNIQ_LOGIN on SEC_USER (LOGIN_LC) where DELETE_TS is null
  • 如果数据库服务不支持部分索引(例如 Microsoft SQL Server 2005),则 DELETE_TS 字段可以包含在唯一索引中:

    create unique index IDX_SEC_USER_UNIQ_LOGIN on SEC_USER (LOGIN_LC, DELETE_TS)

3.2.2. 元数据框架

元数据框架用于在 CUBA 应用程序中高效处理数据模型。此框架:

  • 提供 API 以获取有关实体、实体属性和实体之间关系的信息,还能用来遍历对象关系图;

  • 作为 Java 反射 API 更专用和更方便的替代品;

  • 控制允许的数据类型以及实体之间的关系;

  • 允许实现通用的数据操作机制;

3.2.2.1. 元数据接口

以下是基本的元数据接口:

MetadataFramework
Figure 4. 元数据框架接口
Session

元数据框架的入口点。允许按名称或相应的 Java 类获取 MetaClass 实例。注意方法的不同:getClass() 方法可以返回 nullgetClassNN()(NotNull)方法不能。

可以使用元数据基础接口获得 Session 对象。

示例:

@Inject
protected Metadata metadata;
...
Session session = metadata.getSession();
MetaClass metaClass1 = session.getClassNN("sec$User");
MetaClass metaClass2 = session.getClassNN(User.class);
assert metaClass1 == metaClass2;
MetaModel

很少使用的接口,用于对元类进行分组。

元类按照 metadata.xml 文件中指定的 Java 项目包的根名称进行分组。

MetaClass

实体类元数据接口。MetaClass 始终与它所代表的 Java 类相关联。

基本方法:

  • getName() – 实体名称,根据惯例,在 _ 符号之前名称的第一部分是项目命名空间代码,例如 sales_Customer

  • getProperties() – 元属性列表(MetaProperty)。

  • getProperty()getPropertyNN() – 按名称返回元属性。如果没有与提供的名称对应的属性,则第一个方法返回 null,第二个方法抛出异常。

    示例:

    MetaClass userClass = session.getClassNN(User.class);
    MetaProperty groupProperty = userClass.getPropertyNN("group");
  • getPropertyPath() – 允许通过引用进行属性遍历。此方法接收属性路径参数 - 以点分隔的属性名字符串。方法返回 MetaPropertyPath 对象,可通过调用此对象的 getMetaProperty() 方法访问所需的(路径中的最后一个)属性。

    示例:

    MetaClass userClass = session.getClassNN(User.class);
    MetaProperty groupNameProp = userClass.getPropertyPath("group.name").getMetaProperty();
    assert groupNameProp.getDomain().getName().equals("sec$Group");
  • getJavaClass() – 对应于这个 MetaClass 的实体类。

  • getAnnotations()元注解集合。

MetaProperty

实体属性元数据接口。

基本方法:

  • getName() – 属性名,对应于实体属性名。

  • getDomain() – 拥有这个属性的元类。

  • getType()- 属性类型:

    • 简单类型: DATATYPE

    • 枚举: ENUM

    • 两种引用类型:

      • ASSOCIATION - 关联 − 简单引用另一个实体。例如,订单 - 客户的关系就是一种关联关系。

      • COMPOSITION - 组合 − 引用一个实体,这个实体在脱离拥有它的实体时会失去实际意义。COMPOSITION 被认为是一种比 ASSOCIATION 更紧密的关系。例如,Order 和它的 Items 之间的关系是一个 COMPOSITION,因为如果没有 Item 所属的 Order,Item 就不能存在。

        引用属性的 ASSOCIATIONCOMPOSITION 类型影响实体编辑模式:在第一种情况下,相关实体独立地持久化到数据库,在第二种情况下,相关实体仅与父实体一起持久化。有关详细信息,请参阅组合结构

  • getRange()Range 接口提供属性类型的详细描述。

  • isMandatory() – 表示必须属性。例如,可视化组件以此来提示用户必须输入值。

  • isReadOnly() – 表示只读属性。

  • getInverse() – 对于引用类型的属性,返回关联另一侧的元属性(如果存在)。

  • getAnnotatedElement() – 对应于实体属性的字段(java.lang.reflect.Field)或方法(java.lang.reflect.Method)。

  • getJavaType() – 实体属性的 Java 类。可以是相应字段的类型,也可以是相应方法的返回值的类型。

  • getDeclaringClass() – 包含这个属性的 Java 类。

Range

详细描述实体属性类型的接口。

基本方法:

  • isDatatype() – 如果是简单类型属性则返回 true

  • asDatatype() – 对于简单类型属性返回对应的Datatype实例。

  • isEnum() – 如果是枚举类型返回 true

  • asEnumeration() – 对于枚举类型属性返回Enumeration实例。

  • isClass() – 如果是 ASSOCIATIONCOMPOSITION 类型属性,则返回 true

  • asClass() – 对于引用属性返回关联实体的元类

  • isOrdered() – 如果属性由有序集合(例如 List)表示,则返回 true

  • getCardinality() – 引用属性的关系类型: ONE_TO_ONEMANY_TO_ONEONE_TO_MANYMANY_TO_MANY

3.2.2.2. 元数据构建

元数据结构生成的主要来源是带注解的实体类。

实体类满足以下情况将出现在元数据中:

  • @Entity@Embeddable@MappedSuperclass 注解并且位于 metadata.xml 中指定的根包中的持久化实体类。

  • @MetaClass 注解且位于 metadata.xml 中指定的根包中的非持久化实体类。

同一根包中的所有实体都放在同一个 MetaModel 实例中,该实例被赋予该包的名称。同一个 MetaModel 中的实体可以包含任意彼此的引用。可以按 cuba.metadataConfig 属性中的 metadata.xml 文件的声明顺序创建来自不同元模型的实体之间的引用。

实体属性满足以下条件时将出现在元数据中:

  • 使用 @Column@OneToOne@OneToMany@ManyToOne@ManyToMany@Embedded 注解的类字段。

  • 使用 @MetaProperty 注解的类字段或字段访问方法(getter)。

Metaclass 和 metaproperty 参数是基于上面列出的注解参数以及字段类型和类方法确定的。但是,如果属性没有写设置方法(setter),那么该属性将是只读的。

3.2.2.3. 数据类型接口

Datatype 接口定义了值和字符串互转的方法(格式化和解析)。每个实体属性,如果不是引用属性的话,就会有一个对应的 Datatype,平台用 Datatype 来格式化和解析属性值。

数据类型注册在 DatatypeRegistry bean 中,这个 bean 根据项目以及项目依赖的应用程序组件中的 metadata.xml 文件对 Datatype 的实现类进行加载和初始化。

实体属性的数据类型可以通过相应的元属性使用 getRange().asDatatype() 方法获取。

也可以使用注册的数据类型来格式化或者解析支持的数据类型的任意属性值。如需这么做,可以从 DatatypeRegistry 通过调用 get(Class) 或者 getNN(Class) 方法得到数据类型实例,传入需要转换的 Java 类型即可。

数据类型通过下列规则跟实体属性关联:

  • 大多数情况下,一个实体属性都关联一个注册的 Datatype 实例,用来处理属性的 Java 类型。

    下面的例子中,amount 属性关联的是 BigDecimalDatatype

    @Column(name = "AMOUNT")
    private BigDecimal amount;

    因为 com/haulmont/cuba/metadata.xml 有下面的记录:

    <datatype id="decimal" class="com.haulmont.chile.core.datatypes.impl.BigDecimalDatatype"
              default="true"
              format="0.####" decimalSeparator="." groupingSeparator=""/>
  • 可以通过 @MetaProperty 注解的 datatype 属性来显式指定一个数据类型。

    下面的例子中,实体的 issueYear 属性将会关联 year 数据类型。

    @MetaProperty(datatype = "year")
    @Column(name = "ISSUE_YEAR")
    private Integer issueYear;

    如果项目的 metadata.xml 文件有下面的记录:

    <datatype id="year" class="com.company.sample.YearDatatype"/>

    所以可以看到,@MetaPropertydatatype 属性包含标识符,这个标识符用来在 metadata.xml 文件中注册数据类型的实现类。

datatype 接口的基本方法:

  • format() – 将传入的值格式化成字符串。

  • parse() – 将字符串转换成相应类型的值。

  • getJavaClass() – 返回这个数据类型对应的 Java 类型。这个方法有一个默认实现,如果类带了 @JavaClass 注解,这个实现会返回此注解的值。

Datatype 定义了用来做格式化和解析的两组方法:一组会考虑应用所在的 locale,另一组不会考虑。考虑了 locale 的转换用在用户界面的各个地方,不考虑 locale 的方法用在系统机制中,比如,在 REST API 的序列化时。

使用忽略 locale 的解析需要在代码中写死或者在 metadata.xml 文件注册数据类型时显式指定。

参考下一节了解怎么使用依赖 locale 的解析。

3.2.2.3.1. 数据类型格式化字符串

依赖 locale 的解析格式通过应用程序或者应用程序组件主语言消息包来提供。遵循标准的 Java SE 类逻辑,比如,DecimalFormat(参阅 https://docs.oracle.com/javase/tutorial/i18n/format/decimalFormat.html)或 SimpleDateFormat(参阅 https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)。

格式需要使用下列键值提供:

  • numberDecimalSeparator – 数值类型的小数分隔符。

    # 逗号作为小数分隔符
    numberDecimalSeparator=,
  • numberGroupingSeparator – 数值类型的千分位符。

    # 空格作为千分位符
    numberGroupingSeparator = \u0020
  • integerFormatIntegerLong 类型的格式。

    # 禁止在整数使用千分位符
    integerFormat = #0
  • doubleFormatDouble 类型的格式。注意,用来做小数点和千分位符使用它们自己的键值定义,如上所述。

    # 四舍五入至小数点后三位
    doubleFormat=#,##0.###
  • decimalFormatBigDecimal 类型的格式。注意,用来做小数点和千分位符使用它们自己的键值定义,如上所述。

    # 小数点后总是显示两位数。比如,显示货币时
    decimalFormat = #,##0.00
  • dateTimeFormatjava.util.Date 类型的格式。

    # 俄罗斯的日期时间显示方法
    dateTimeFormat = dd.MM.yyyy HH:mm
  • dateFormatjava.sql.Date 类型的格式。

    # 美国日期时间显示
    dateFormat = MM/dd/yyyy
  • timeFormatjava.sql.Time 类型的格式。

    # hours:minutes 时间格式
    timeFormat=HH:mm
  • offsetDateTimeFormatjava.time.OffsetDateTime 类型的格式。

    # 用与 GMT 时区偏移的方式显示日期时间
    offsetDateTimeFormat = dd/MM/yyyy HH:mm Z
  • offsetTimeFormatjava.time.OffsetTime 类型的格式。

    # hours:minutes 用与 GMT 时区偏移的方式显示
    offsetTimeFormat=HH:mm Z
  • trueStringBoolean.TRUE 类型对应的显示字符串。

    # boolean 值的显示方法
    trueString = yes
  • falseStringBoolean.FALSE 类型对应的显示字符串。

    # boolean 值的显示方法
    falseString = no

通过 Studio 可以设置针对应用程序使用的语言的格式化字符串。编辑 Project Properties,点击 Available locales 字段的编辑按钮,然后勾选 Show data format strings

locale 相应的格式化字符串可以通过 FormatStringsRegistry bean 获得。

3.2.2.3.2. 自定义数据类型示例

假设应用程序中实体的某些属性用来存储日期的年份,用整数数字表示。用户能查看和编辑年份,并且如果用户只是输入两个数字,应用程序会将其转换为 2000 至 2100 之间的一个年份,否则,将整个输入的数字作为年份。

首先,在 global 模块创建下面的类:

package com.company.sample.entity;

import com.google.common.base.Strings;
import com.haulmont.chile.core.annotations.JavaClass;
import com.haulmont.chile.core.datatypes.Datatype;

import javax.annotation.Nullable;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.util.Locale;

@JavaClass(Integer.class)
public class YearDatatype implements Datatype<Integer> {

    private static final String PATTERN = "##00";

    @Override
    public String format(@Nullable Object value) {
        if (value == null)
            return "";

        DecimalFormat format = new DecimalFormat(PATTERN);
        return format.format(value);
    }

    @Override
    public String format(@Nullable Object value, Locale locale) {
        return format(value);
    }

    @Nullable
    @Override
    public Integer parse(@Nullable String value) throws ParseException {
        if (Strings.isNullOrEmpty(value))
            return null;

        DecimalFormat format = new DecimalFormat(PATTERN);
        int year = format.parse(value).intValue();
        if (year > 2100 || year < 0)
            throw new ParseException("Invalid year", 0);
        if (year < 100)
            year += 2000;
        return year;
    }

    @Nullable
    @Override
    public Integer parse(@Nullable String value, Locale locale) throws ParseException {
        return parse(value);
    }
}

然后在项目的 metadata.xml 文件中添加 datatypes 元素:

<metadata xmlns="http://schemas.haulmont.com/cuba/metadata.xsd">
    <datatypes>
        <datatype id="year" class="com.company.sample.entity.YearDatatype"/>
    </datatypes>
    <!-- ... -->
</metadata>

datatype 元素中,还可以指定 sqlType 属性来包含一个数据库的 SQL 类型,这个 SQL 类型适合在数据库保存这个新类型。SQL 类型会在 CUBA Studio 生成数据库脚本的时候使用。Studio 可以为下面这些 Java 类型自动确定 SQL 类型:

  • java.lang.Boolean

  • java.lang.Integer

  • java.lang.Long

  • java.math.BigDecimal

  • java.lang.Double

  • java.lang.String

  • java.util.Date

  • java.util.UUID

  • byte[]

在上面的例子中,年份应该绑定 Integer 类型(这个类型通过 @JavaClass 注解的 Integer.class 值指定),所以 sqlType 可以省去。

最终,为实体属性设置新的数据类型(编程的方式或者通过 Studio 的帮助):

@MetaProperty(datatype = "year")
@Column(name = "ISSUE_YEAR")
private Integer issueYear;
3.2.2.3.3. 用户界面中数据格式化示例

这里以如何在 Orders 表格中显示 Order.date 属性为例。

order-browse.xml

<table id="ordersTable">
    <columns>
        <column id="date"/>
        <!--...-->

Order 类的 date 属性是用 "date" 类型来定义的:

@Column(name = "DATE_", nullable = false)
@Temporal(TemporalType.DATE)
private Date date;

如果当前用户使用俄语 locale 登录,会从主语言消息包获取下面的字符串:

dateFormat=dd.MM.yyyy

结果是 "2012-08-06" 日期会被转换为 "06.08.2012" 字符串显示在表格的单元格中。

3.2.2.3.4. 代码中的日期和数字格式化示例

如果需要按照用户当前的 locale 格式化或者解析 BigDecimalIntegerLongDoubleBoolean 或者 Date 类型,可以使用 DatatypeFormatter bean,示例:

@Inject
private DatatypeFormatter formatter;

void sample() {
    String dateStr = formatter.formatDate(dateField.getValue());
    // ...
}

下面是直接使用 Datatype 的例子。

  • 日期格式化示例:

    @Inject
    protected UserSessionSource userSessionSource;
    @Inject
    protected DatatypeRegistry datatypes;
    
    void sample() {
        Date date;
        // ...
        String dateStr = datatypes.getNN(Date.class).format(date, userSessionSource.getLocale());
        // ...
    }
  • 在 Web 客户端格式化数字,显示最多 5 位小数示例:

    com/sample/sales/web/messages_ru.properties
    coordinateFormat = #,##0.00000
    @Inject
    protected Messages messages;
    @Inject
    protected UserSessionSource userSessionSource;
    @Inject
    protected FormatStringsRegistry formatStringsRegistry;
    
    void sample() {
        String coordinateFormat = messages.getMainMessage("coordinateFormat");
        FormatStrings formatStrings = formatStringsRegistry.getFormatStrings(userSessionSource.getLocale());
        NumberFormat format = new DecimalFormat(coordinateFormat, formatStrings.getFormatSymbols());
        String formattedValue = format.format(value);
        // ...
    }
3.2.2.4. 元注解

实体元注解是一组提供有关实体的附加信息的键值对(key-value)。 使用元类getAnnotations() 方法访问元注解。 元注解的来源是:

  • @OnDelete@OnDeleteInverse@Extends 注解。通过这些注解将创建描述实体之间关系的特殊元注解。

  • 可扩展的元注解用 @MetaAnnotation 标明。这些注解会转换成元注解,key 是注解对应的 Java 类的全名称,value 是一组注解属性的映射。比如,@TrackEditScreenHistory 注解的 value 就是一个单一的映射:value → true。平台提供几个这种类型的注解:@NamePattern, @SystemLevel, @EnableRestore, @TrackEditScreenHistory。在应用程序或者应用程序组件中,可以创建自定义的注解类然后使用 @MetaAnnotation 对自定义注解类进行标注。

  • 可选:实体元注解也可以在metadata.xml文件中定义。如果 XML 中的元注解与 Java 实体类注解创建的元注解具有相同的名称,那么它将覆盖后者。

    下面的示例演示了如何通过 metadata.xml 中的元注解覆盖 Java 类中的注解:

    <metadata xmlns="http://schemas.haulmont.com/cuba/metadata.xsd">
        <!-- ... -->
    
        <annotations>
            <entity class="com.company.customers.entity.Customer">
                <annotation name="com.haulmont.cuba.core.entity.annotation.TrackEditScreenHistory">
                    <attribute name="value" value="true" datatype="boolean"/>
                </annotation>
    
                <property name="name">
                    <annotation name="length" value="200"/>
                </property>
    
                <property name="customerGroup">
                    <annotation name="com.haulmont.cuba.core.entity.annotation.Lookup">
                        <attribute name="type" class="com.haulmont.cuba.core.entity.annotation.LookupType" value="DROPDOWN"/>
                        <attribute name="actions" datatype="string">
                            <value>lookup</value>
                            <value>open</value>
                        </attribute>
                    </annotation>
                </property>
            </entity>
    
            <entity class="com.company.customers.entity.CustomerGroup">
                <annotation name="com.haulmont.cuba.core.entity.annotation.EnableRestore">
                    <attribute name="value" value="false" datatype="boolean"/>
                </annotation>
            </entity>
        </annotations>
    </metadata>

3.2.3. 视图

从数据库中检索实体时,我们经常面临一个问题:如何确保将关联实体加载到所需的深度?

例如,需要在 Order 浏览界面中显示日期、金额以及客户名称,这意味着需要获取相关的 Customer 实例。还有,在 Order 编辑界面,需要获取 Item 集合,此外每个 Item 应包含一个相关的 Product 实例以显示其名称。

延迟加载在大多数情况下都无济于事,因为数据处理通常不是在加载实体的事务中执行,而是在像 UI 这样的客户端执行。同样,使用实体注解来应用贪婪加载也是不可行的,因为这样做会导致总是查询对象关系图中所有的关联实体,这可能是非常大的数据。

另一个类似的问题是需要限制加载对象图中的本地属性的集合:例如,某个实体可以有 50 个属性,包括 BLOB,但是只需要在界面上显示 10 个属性。此时,对于不需要的 40 个额外属性,为什么要从数据库加载然后将它们序列化并传输到客户端?

视图 机制通过从数据库中检索并向客户端传输有深度和属性限制的实体关系图来解决这些问题。特定 UI 界面或数据处理操作所需的对象图通过 视图 来描述。

视图处理过程按以下方式执行:

  • 数据模型中的所有关系都使用延迟加载属性声明(fetch = FetchType.LAZY。请参阅实体注解)。

  • 在数据加载过程中,调用方提供所需的视图以及 JPQL 查询语句或实体标识符。

  • 所谓的 FetchGroup 是在视图的基础上产生的 - 这是 EclipseLink 框架基础ORM层的一个特殊功能。FetchGroup 在两方面影响对数据库的 SQL 查询语句的生成:返回的字段列表以及与包含关联实体的其它表的连接。

无论视图中的属性如何定义,始终会加载以下属性:

  • id – 实体标识符。

  • version – 用于版本化( Versioned ) 实体的乐观锁。

  • deleteTsdeletedBy – 用于实现了软删除的实体。

尝试获取或设置未加载属性的值(未包含在视图中)会引发异常。您可以使用 EntityStates.isLoaded() 方法检查属性是否已加载。

参考下一章节了解如何定义视图。

下面解释一些视图机制的内部原理。

View
Figure 5. View 类

视图由 View 类的实例确定,其中:

  • entityClass – 定义视图的实体类。换句话说,它是加载的实体树的“根”。

  • name – 视图名称。可以是“null”或实体的所有视图中的唯一名称。

  • properties – 对应需要加载的实体属性的 ViewProperty 实例的集合。

  • includeSystemProperties – 如果设置,则视图将包含系统属性(由持久化实体的基础接口定义,如 BaseEntityUpdatable)。

  • loadPartialEntities - 指定视图是否影响本地(立即加载)属性的加载。如果为 false,则仅影响引用属性,并且会始终加载本地属性,而无论它们是否存在于视图中。

    此属性在某种程度上由平台数据加载机制控制,请参阅有关在 DataManagerEntityManager 中加载部分实体的章节。

ViewProperty 类具有以下属性:

  • name – 实体属性名。

  • view – 对于引用属性,指定将用于加载关联实体的视图。

  • fetch - 对于引用属性,指定如何从数据库中获取关联实体。其值对应于 FetchMode 枚举类型:

    • AUTO - 平台将根据关系类型选择最佳模式。

    • UNDEFINED - 将根据 JPA 规则执行提取,这实际上意味着通过单独的 select 进行加载。

    • JOIN - 通过关联引用的表来在同一个 select 查询中获取。

    • BATCH - 相关对象的查询将通过分批的方式进行优化。参阅:这里

    如果未指定 fetch 属性,则应用 AUTO 模式。如果引用是一个可缓存的实体,则无论视图中指定的什么值,都将使用 UNDEFINED

3.2.3.1. 创建视图

视图可以通过下列方式创建:

  • 编程方式 – 通过创建 View 实例。这种方式适合在业务逻辑中创建并视图。

    视图实例,包括内嵌的视图,可以通过构造器创建:

    View view = new View(Order.class)
            .addProperty("date")
            .addProperty("amount")
            .addProperty("customer", new View(Customer.class)
                .addProperty("name")
            );

    也可以使用 ViewBuilder

    View view = ViewBuilder.of(Order.class)
            .addAll("date", "amount", "customer.name")
            .build();

    ViewBuilder 还能用于 DataManager 的流式操作接口:

    // explicit view builder
    dataManager.load(Order.class)
            .view(viewBuilder ->
                viewBuilder.addAll("date", "amount", "customer.name"))
            .list();
    
    // implicit view builder
    dataManager.load(Order.class)
            .viewProperties("date", "amount", "customer.name")
            .list();
  • 界面中声明式创建 – 通过在界面的 XML 中定义内嵌的视图,参考 声明式创建数据组件 中的例子。推荐在 CUBA 7.2+ 中使用这种方式在 Generic UI 界面中加载数据。

  • 共享仓库中声明式创建 – 通过在项目的 views.xml 文件中定义视图。应用程序启动时会部署 views.xml 文件,创建的 View 实例都缓存在 ViewRepository 中。此外,通过调用 ViewRepository 并提供实体类和视图名称,可以在应用程序代码的任何部分中获取所需的视图。

下面是 XML 视图声明的示例,能加载 Order 实体的所有本地属性、关联的 Customer 以及 Items 集合:

<view class="com.sample.sales.entity.Order"
      name="order-with-customer"
      extends="_local">
    <property name="customer" view="_minimal"/>
    <property name="items" view="item-in-order"/>
</view>
使用共享的视图仓库

ViewRepository 是一个 Spring bean,所有应用程序 block 都可以访问这个 bean。可以使用注入或通过 AppBeans 静态方法得到 ViewRepository 的引用。然后用 ViewRepository.getView() 方法从视图库中获取视图实例。

默认情况下,每个实体的视图库中都有三个名为 _local_minimal_base 的视图:

  • _local 包含所有本地实体属性。

  • _minimal 包含实体实例名称中包含的属性,这些属性通过@NamePattern注解指定。如果未在实体中指定 @NamePattern 注解,则此视图不包含任何属性。

  • _base 包括所有本地非系统属性和由 @NamePattern 定义的属性(实际上是 _minimal + _local)。

CUBA Studio 会在项目内自动创建并维护单一的 views.xml 文件。但是,也可以按照下面的方法创建多个视图描述文件:

  • global 模块必须包含带有所有视图描述文件的 views.xml 文件,这些视图文件必须是全局可访问的(即能被所有的层访问)。该文件需要在所有层的 cuba.viewsConfig 应用程序属性中注册,比如,core 模块的 app.propertiesweb 模块的 web-app.properties 等。Studio 默认会做这些事。

  • 如果有大量的共享视图,可以将它们放在多个文件内,比如在标准的 views.xml 文件以及附加的 foo-views.xmlbar-views.xml 文件。然后在 cuba.viewsConfig 应用程序属性中注册所有这些文件:

    cuba.viewsConfig = +com/company/sales/views.xml com/company/sales/foo-views.xml com/company/sales/bar-views.xml
  • 如果有某些视图只在一个应用程序层使用,则可以单独在该层的类似文件中定义,比如 web-views.xml,然后在该层的 cuba.viewsConfig 属性中注册该文件。

如果视图库已经包含某个实体的特定名称的视图,则不会为此实体部署其它同名视图。如果需要使用新的视图替换视图库中的现有视图并保证其部署,需要为新视图指定 overwrite="true" 属性。

建议为共享视图提供描述性名称。例如,不仅仅是 "browse",而是 "customerBrowse"。这样可以简化 XML 描述中视图的搜索。

3.2.4. Spring Beans

Spring Beans 是一些类,它们实例的创建和依赖关系的管理都由 Spring 框架容器处理。Beans 设计用于实现应用程序业务逻辑。

默认情况下,Spring Bean 是 单例,即每个应用程序 block 中只存在此类的一个实例。如果单例 bean 在字段中包含可变数据(换句话说,具有状态),对这些数据的访问必须同步。

3.2.4.1. 创建 Spring bean

参考 在 CUBA 中创建业务逻辑 指南,了解如何使用 Spring bean 创建业务逻辑。

如需创建 Spring bean,只需要在 Java 类添加 @org.springframework.stereotype.Component 注解。示例:

package com.sample.sales.core;

import com.sample.sales.entity.Order;
import org.springframework.stereotype.Component;

@Component(OrderWorker.NAME)
public class OrderWorker {
    public static final String NAME = "sales_OrderWorker";

    public void calculateTotals(Order order) {
    }
}

建议为 bean 分配一个唯一的名称,按照 {project_name}_{class_name} 格式,并且用常量 NAME 定义。

@javax.annotation.ManagedBean 注解也可以用来定义 bean,但是在部署应用至某些应用服务器的时候有可能会出问题。因此,建议只使用 Spring 框架的 @Component 注解。

Bean 的类定义需要放在 spring.xml 文件的 context:component-scan 元素指定的扫描目录树下。对于上面例子中的 bean,spring.xml 需要包含元素:

<context:component-scan base-package="com.sample.sales"/>

也就是说,扫描此应用程序 block 中带注解的 bean 会从包的 com.sample.sales 目录开始。

Spring bean 可以在任何创建。

3.2.4.2. 使用 Spring bean

托管给 Spring 的 bean 可以通过注入或者通过 AppBeans 类获取。作为使用 bean 的示例,看看 OrderService bean 的实现,它将具体的执行代理给了 OrderWorker bean 来做:

package com.sample.sales.core;

import com.haulmont.cuba.core.Persistence;
import com.sample.sales.entity.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.inject.Inject;

@Service(OrderService.NAME)
public class OrderServiceBean implements OrderService {

    @Inject
    protected Persistence persistence;

    @Inject
    protected OrderWorker orderWorker;

    @Transactional
    @Override
    public BigDecimal calculateTotals(Order order) {
        Order entity = persistence.getEntityManager().merge(order);
        return orderWorker.calculateTotals(entity);
    }
}

这个例子中,服务启动一个数据库事务,将从客户端层获取的 detached 实体合并到持久化上下文,然后将控制交给 OrderWorker bean,这个 bean 包含主要逻辑。

3.2.5. JMX Beans

有时,有必要让系统管理员能够在运行时查看和更改某些Spring bean 的状态。在这种情况下,建议创建一个 JMX bean - 一个具有 JMX 接口的程序组件。JMX bean 通常是一个包装器,代理了对托管 bean 的调用,但实际上还是由托管 bean 维护状态:比如缓存、配置数据或统计信息。

JMXBeans
Figure 6. JMX Bean Class Diagram

从图中可以看出,JMX bean 由接口和实现类组成。实现类应该是一个Spring bean,即应该具有 @Component 注解和唯一名称。JMX bean 的接口以一种特殊的方式在 spring.xml 中注册,以便在当前 JVM 中创建 JMX 接口。

使用 Spring AOP 通过 MBeanInterceptor 拦截器类拦截对所有 JMX bean 接口方法的调用,该类在当前线程中设置正确的 ClassLoader 并对未处理异常进行日志记录。

JMX bean 接口名称必须符合以下格式:{class_name}MBean

JMX 接口可以由外部工具使用,例如 jconsolejvisualvm。此外,平台的 Web 客户端提供了 JMX console 功能,可以通过这个功能查看托管 Bean 状态、调用 JMX bean 方法。

3.2.5.1. 创建 JMX Bean

以下示例展示如何创建 JMX Bean。

  • JMX bean 接口:

    package com.sample.sales.core;
    
    import org.springframework.jmx.export.annotation.*;
    import com.haulmont.cuba.core.sys.jmx.JmxBean;
    
    @JmxBean(module = "sales", alias = "OrdersMBean")
    @ManagedResource(description = "Performs operations on Orders")
    public interface OrdersMBean {
        @ManagedOperation(description = "Recalculates an order amount")
        @ManagedOperationParameters({@ManagedOperationParameter(name = "orderId", description = "")})
        String calculateTotals(String orderId);
    }
    • 接口和其方法上可定义注解,通过这些注解来对 JMX Bean 和它提供的操作进行描述。这些描述会显示在使用 JMX 接口的所有工具中,这样可以帮助系统管理员了解接口。

    • 可选的 @JmxBean 注解用来自动注册类实例至 JMX 服务,按照 modulealias 属性来注册。可以使用这个注解来注册 JMX bean,代替在 spring.xml 中配置。

    • 可选的 @JmxRunAsync 注解用来标识出一个需要长时间执行的操作。当使用内置的 JMX console 运行此操作时,平台将会显示一个带有无限进度条和 Cancel 按钮的对话框。用户可以中断操作并继续使用应用程序。该注解还可以包含 timeout 参数,该参数用来设置操作的最长执行时间(以毫秒为单位),例如:

      @JmxRunAsync(timeout = 30000)
      String calculateTotals();

      如果执行超时,对话框将关闭并显示错误消息。

      请注意,如果在用户界面上取消操作,或者观察到操作超时,但操作实际上继续在后台运行,也就是说这些操作不能实际终止,只是将控制权交给用户。

    • 由于 JMX 工具只支持有限的数据类型,因此最好使用 String 作为方法的参数和结果类型,必要时,需要在方法内进行类型转换。除了 String 外,还支持以下参数类型:booleandoublefloatintlongBooleanInteger

  • JMX bean 类:

    package com.sample.sales.core;
    
    import com.haulmont.cuba.core.*;
    import com.haulmont.cuba.core.app.*;
    import com.sample.sales.entity.Order;
    import org.apache.commons.lang.exception.ExceptionUtils;
    import org.springframework.stereotype.Component;
    import javax.inject.Inject;
    import java.util.UUID;
    
    @Component("sales_OrdersMBean")
    public class Orders implements OrdersMBean {
    
        @Inject
        protected OrderWorker orderWorker;
    
        @Inject
        protected Persistence persistence;
    
        @Authenticated
        @Override
        public String calculateTotals(String orderId) {
            try {
                try (Transaction tx = persistence.createTransaction()) {
                    Order entity = persistence.getEntityManager().find(Order.class, UUID.fromString(orderId));
                    orderWorker.calculateTotals(entity);
                    tx.commit();
                };
                return "Done";
            } catch (Throwable e) {
                return ExceptionUtils.getStackTrace(e);
            }
        }
    }

    @Component 注解将类定义为具有 sales_OrdersMBean 名称的 Spring bean,该名称直接在注解中指定而不是在常量中,因为不需要从 Java 代码访问 JMX bean。

    下面概述一下 calculateTotals() 方法的实现。

    • 该方法有 @Authenticated 注解,也就是说在没有用户会话的情况下在方法入口处进行了系统身份验证

    • 方法主体包含在 try/catch 块中,因此,如果成功,则方法返回 "Done",在出现错误时,则会以字符串形式返回异常堆栈的跟踪信息。

      在这种情况下,全部异常都会被妥善处理,所以异常不会被自动记录到日志中,因为异常根本到不了 MBeanInterceptor。如果需要记录异常日志信息,应该在 catch 部分调用 Logger。

    • 该方法开启事务、通过标识符加载 Order 实体实例、传递控制权给 OrderWorker bean 进行处理。

  • spring.xml 中注册 JMX bean:

    <bean id="sales_MBeanExporter" lazy-init="false"
          class="com.haulmont.cuba.core.sys.jmx.MBeanExporter">
        <property name="beans">
            <map>
                <entry key="${cuba.webContextName}.sales:type=Orders"
                       value-ref="sales_OrdersMBean"/>
            </map>
        </property>
    </bean>

    项目的所有 JMX beans 都是在 beans 属性的 map/entry 元素中的一个 MBeanExporter 实例中声明的。键是 JMX ObjectName,值是在 @Component 注解中指定的 bean 名称。ObjectName 以 web 应用程序的名称开始,因为可以将输出相同 JMX 接口的多个 web 应用程序部署到一个应用程序服务实例中(部署到 JVM 中)。

3.2.5.2. 平台内置 JMX Beans

本节介绍平台中可用的一些 JMX beans。

3.2.5.2.1. CachingFacadeMBean

CachingFacadeMBean 提供了在 中间件Web 客户端 模块中清除各种缓存的方法。

JMX 对象名: app-core.cuba:type=CachingFacadeapp.cuba:type=CachingFacade

3.2.5.2.2. ConfigStorageMBean

ConfigStorageMBean 能在 中间件Web 客户端Web 门户 模块中查看和设置应用程序属性的值。

该接口具有独立的一组操作,用于处理存储在文件 (*AppProperties) 中和存储在数据库 (*DbProperties) 中的属性。这些操作只显示在对应的存储中明确设置了值的属性。也就是说如果配置接口定义了属性和其默认值,但未在数据库(或文件)中设置该值,则这些方法将不显示该属性及其当前值。

请注意,对存储在文件中的属性值的修改不是持久化的,并且只有在重启应用程序模块后才能生效。

与上述操作不同,getConfigValue() 操作返回的值与在应用程序代码中调用配置接口的相应方法返回的值完全相同。

JMX 对象名:

  • app-core.cuba:type=ConfigStorage

  • app.cuba:type=ConfigStorage

  • app-portal.cuba:type=ConfigStorage

3.2.5.2.3. EmailerMBean

EmailerMBean 能够查看当前配置的邮件发送参数,并可以发送测试消息。

JMX 对象名: app-core.cuba:type=Emailer

3.2.5.2.4. PersistenceManagerMBean

PersistenceManagerMBean 提供以下功能:

  • 管理实体统计信息机制。

  • 使用 findUpdateDatabaseScripts() 方法查看新的数据库更新脚本。使用 updateDatabase() 方法触发数据库更新。

  • 使用 jpqlLoadList()jpqlExecuteUpdate() 方法可以在中间层上下文中执行任意的 JPQL 查询

JMX 对象名: app-core.cuba:type=PersistenceManager

3.2.5.2.5. ScriptingManagerMBean

ScriptingManagerMBeanScripting 基础接口的 JMX 外观设计模式实现。

JMX 对象名: app-core.cuba:type=ScriptingManager

JMX 属性:

JMX 操作:

  • runGroovyScript() – 在中间层上下文中执行 Groovy 脚本并返回结果。会将以下变量传递给脚本:

    • persistence - Persistence 类型的参数。

    • metadata - Metadata 类型的参数。

    • configuration - Configuration 类型的参数。

    • dataManager - DataManager 类型的参数。

      结果类型应该是可显示 JMX 界面上的字符串类型。除此之外,此方法与 Scripting.runGroovyScript() 完全一样。

      用于创建一组测试用户的示例脚本,如下所示:

      import com.haulmont.cuba.core.*
      import com.haulmont.cuba.core.global.*
      import com.haulmont.cuba.security.entity.*
      
      PasswordEncryption passwordEncryption = AppBeans.get(PasswordEncryption.class)
      
      Transaction tx = persistence.createTransaction()
      try {
          EntityManager em = persistence.getEntityManager()
          Group group = em.getReference(Group.class, UUID.fromString('0fa2b1a5-1d68-4d69-9fbd-dff348347f93'))
          for (i in (1..250)) {
              User user = new User()
              user.setGroup(group)
              user.setLogin("user_${i.toString().padLeft(3, '0')}")
              user.setName(user.login)
              user.setPassword(passwordEncryption.getPasswordHash(user.id, '1'));
              em.persist(user)
          }
          tx.commit()
      } finally {
          tx.end()
      }
3.2.5.2.6. ServerInfoMBean

ServerInfoMBean 提供了有关此中间层模块的通用信息:内部版本号、 构建日期 和 服务器 ID.

JMX 对象名: app-core.cuba:type=ServerInfo

3.2.6. 基础设施接口

基础设施接口提供对平台的常用功能的访问。它们中的大多数位于 global 模块中,并且可以在中间层和客户端层使用。但是,其中一些(例如 Persistence )只能用于中间层代码。

基础接口由 Spring bean 实现,因此可以将它们注入其它任何托管组件(beans中间件服务、通用界面控制器)。

此外,与任何其它 bean 一样,基础接口可以通过 AppBeans 类的静态方法获取,然后可以在非托管组件(POJO、helper 类等)中使用。

3.2.6.1. Configuration

该接口有助于获取配置接口的引用。

例如:

// field injection

@Inject
protected Configuration configuration;
...
String tempDir = configuration.getConfig(GlobalConfig.class).getTempDir();
// setter injection

protected GlobalConfig globalConfig;

@Inject
public void setConfiguration(Configuration configuration) {
    this.globalConfig = configuration.getConfig(GlobalConfig.class);
}
// location

String tempDir = AppBeans.get(Configuration.class).getConfig(GlobalConfig.class).getTempDir();
3.2.6.2. DataManager

DataManager 接口在中间层和客户端层提供 CRUD 功能,是一种通用工具,用于从数据库加载实体关系图并保存已更改的游离实体实例。

参考 在 CUBA 中进行数据处理 指南,了解在 CUBA 中如何使用 DataManager API 处理不同的数据访问。

有关 DataManager 与 EntityManager 之间差异的信息,请参阅 DataManager 与 EntityManager

实际上,DataManager 只是委托给一个数据存储实现,并在需要时处理跨数据库引用。当使用标准 RdbmsStore 处理存储在关系型数据库中的实体时,下面描述的大多数实现细节都有效。对于另一种类型的数据存储,除接口方法名称之外的所有内容都可能不同。为简单起见,DataManager 在没有另外说明时指的是 基于 RdbmsStore 的 DataManager

下面列出了 DataManager 的方法:

  • load(Class) - 加载指定类的实体。此方法是流式 API 的入口点:

    @Inject
    private DataManager dataManager;
    
    private Book loadBookById(UUID bookId) {
        return dataManager.load(Book.class).id(bookId).view("book.edit").one();
    }
    
    private List<BookPublication> loadBookPublications(UUID bookId) {
        return dataManager.load(BookPublication.class)
            .query("select p from library_BookPublication p where p.book.id = :bookId")
            .parameter("bookId", bookId)
            .view("bookPublication.full")
            .list();
    }
  • loadValues(String query) - 通过查询纯数值加载键值对。此方法是流式 API 的入口点:

    List<KeyValueEntity> list = dataManager.loadValues(
            "select o.customer, sum(o.amount) from demo_Order o " +
            "where o.date >= :date group by o.customer")
        .store("legacy_db") (1)
        .properties("customer", "sum") (2)
        .parameter("date", orderDate)
        .list();
    1 - 指定实体所在的数据存储。 如果实体位于主数据存储,那么可以忽略这个方法。
    2 - 指定返回的 KeyValueEntity 实体中的属性名称。 属性的顺序必须与查询结果集的列对应。
  • loadValue(String query, Class valueType) - 通过查询纯数值加载单个值。此方法是流式 API 的入口点:

    BigDecimal sum = dataManager.loadValue(
            "select sum(o.amount) from demo_Order o " +
            "where o.date >= :date group by o.customer", BigDecimal.class)
        .store("legacy_db") (1)
        .parameter("date", orderDate)
        .one();
    1 - 指定实体所在的数据存储。 如果实体位于主数据存储,那么可以忽略这个方法。
  • load(LoadContext), loadList(LoadContext) – 根据传递给它的 LoadContext 对象的参数加载实体。LoadContext 必须包含 JPQL 查询语句或实体标识符。如果两者都定义的话,则使用查询语句而忽略实体标识符。

    例如:

    @Inject
    private DataManager dataManager;
    
    private Book loadBookById(UUID bookId) {
        LoadContext<Book> loadContext = LoadContext.create(Book.class)
                .setId(bookId).setView("book.edit");
        return dataManager.load(loadContext);
    }
    
    private List<BookPublication> loadBookPublications(UUID bookId) {
        LoadContext<BookPublication> loadContext = LoadContext.create(BookPublication.class)
                .setQuery(LoadContext.createQuery("select p from library_BookPublication p where p.book.id = :bookId")
                    .setParameter("bookId", bookId))
                .setView("bookPublication.full");
        return dataManager.loadList(loadContext);
    }
  • loadValues(ValueLoadContext) - 加载键值对列表。该方法接受 ValueLoadContext,定义纯数值的查询语句和键值列表。返回包含 KeyValueEntity 实例的列表。例如:

    ValueLoadContext context = ValueLoadContext.create()
            .setQuery(ValueLoadContext.createQuery(
                        "select o.customer, sum(o.amount) from demo_Order o " +
                        "where o.date >= :date group by o.customer")
                .setParameter("date", orderDate))
            .addProperty("customer")
            .addProperty("sum");
    List<KeyValueEntity> list = dataManager.loadValues(context);
  • getCount(LoadContext) - 返回传递给方法的查询语句的记录数。可能的情况下,RdbmsStore 中的标准实现使用与原始查询相同的条件执行 select count() 查询,以获得最佳性能。

  • commit(CommitContext) – 将 CommitContext 中传递的一组实体保存到数据库中。必须分别指定用于更新和删除的实体的集合。

    该方法返回 EntityManager.merge() 返回的实体实例集合,实际上这些就是刚刚在 DB 中更新的新实例。后续的操作需要使用这些返回的实例,以防止数据丢失或造成乐观锁。通过使用 CommitContext.getViews() 获得的视图映射为每个保存的实例设置视图,这样可以确保返回实体中包含需要的属性。

    DataManager 可以为保存的实体进行 bean 验证

    保存实体集合的示例:

    @Inject
    private DataManager dataManager;
    
    private void saveBookInstances(List<BookInstance> toSave, List<BookInstance> toDelete) {
        CommitContext commitContext = new CommitContext(toSave, toDelete);
        dataManager.commit(commitContext);
    }
    
    private Set<Entity> saveAndReturnBookInstances(List<BookInstance> toSave, View view) {
        CommitContext commitContext = new CommitContext();
        for (BookInstance bookInstance : toSave) {
            commitContext.addInstanceToCommit(bookInstance, view);
        }
        return dataManager.commit(commitContext);
    }
  • reload(Entity, View) - 使用视图从数据库重新加载指定实例的便捷方法。委托给 load() 方法执行。

  • remove(Entity) - 从数据库中删除指定的实例。委托给 commit() 方法执行。

  • create(Class) - 在内存中创建给定实体的实例。这是一个便捷的方法,委托给了 Metadata.create()

  • getReference(Class, Object) - 返回一个实体实例,该实例可以用作对数据库中存在的对象的引用。

    例如,如果要创建 User,则必须设置用户所属的 Group。如果知道 group ID,可以从数据库加载然后设置给用户。此方法可以避免不必要的数据库多次访问:

    user.setGroup(dataManager.getReference(Group.class, groupId));
    dataManager.commit(user);

    引用也可用于通过 id 删除现有对象:

    dataManager.remove(dataManager.getReference(Customer.class, customerId));
查询

当系统使用关系型数据库时,用JPQL查询语句加载数据。参阅 JPQL 函数不区分大小写的子串搜索JPQL 中的宏 章节了解 CUBA 中的 JPQL 和 JPA 标准之间的差异。另外需要注意,DataManager 只能执行 "select" 查询。

流式接口的 query() 方法可以接收完整的或者省略的查询语句。如果使用省略的查询语句,需要符合下面的规则:

  • 可以省略 "select <alias>" 子句。

  • 如果 "from" 子句包含单一实体,而且又不想用实体别名,则可以省略 "from <entity> <alias> where" 子句。此时,框架会使用 e 作为该实体的别名。

  • 可以使用位置标记查询条件并同时将调价值作为额外的参数传递给 query() 方法。

示例:

// named parameter
dataManager.load(Customer.class)
        .query("e.name like :name")
        .parameter("name", value)
        .list();

// positional parameter
dataManager.load(Customer.class)
        .query("e.name like ?1", value)
        .list();

// case-insensitive positional parameter
dataManager.load(Customer.class)
        .query("e.name like ?1 or e.email like ?1", "(?i)%joe%")
        .list();

// multiple positional parameters
dataManager.load(Order.class)
        .query("e.date between ?1 and ?2", date1, date2)
        .list();

// omitting "select" and using a positional parameter
dataManager.load(Order.class)
        .query("from sales_Order o, sales_OrderLine l " +
                "where l.order = o and l.product.name = ?1", productName)
        .list();

需要注意的是,位置标记条件参数只在流式接口支持。LoadContext.Query 还是只支持命名条件参数。

事务

DataManager 总是启动一个新的事务并在操作完成时提交事务,从而返回游离状态的实体。在中间层,如果需要实现复杂的事务行为,可以使用 TransactionalDataManager

部分实体

部分(Partial) 实体是一个实体实例,这个实例的属性可以是已加载的本地属性的一个子集。默认情况下,DataManager 根据视图加载部分实体(事实上,RdbmsStore 只是将视图的 loadPartialEntities 属性设置为 true 并将其传递给 EntityManager )。

在下面这些情况下,DataManager 会加载所有本地属性,视图仅用来获取引用:

  • 加载的实体是可缓存的

  • 为实体定义了内存 "读取" 约束

  • 为实体设置了动态属性访问控制

  • LoadContextloadPartialEntities 属性设置为 false。

3.2.6.2.1. DataManager 与 EntityManager

DataManagerEntityManager都可以用于实体的 CRUD 操作。这两个接口之间存在以下差异:

DataManager EntityManager

DataManager 在中间层和客户端都可用。

EntityManager 仅在中间层可用。

DataManager 是一个单例 bean。它可以通过 AppBeans.get() 注入或获取。

应该通过Persistence接口获取 EntityManager 的引用。

DataManager 定义了一些使用游离实体的高级方法: load()loadList()reload()commit()

EntityManager 与标准的 javax.persistence.EntityManager 非常相似。

DataManager 在保存实体是可以进行 bean 验证

EntityManager 不进行 bean 验证。

实际上,DataManager 委托给DataStore实现,因此下面列出的 DataManager 功能仅适用于处理关系型数据库中的实体最常见的情况:

DataManager EntityManager

DataManager 始终在内部启动新的事务。在中间层,如果需要实现复杂的事务行为,可以使用 TransactionalDataManager

在使用 EntityManager 之前,必须先打开一个事务。

DataManager 根据视图加载 部分 实体。也有一些例外,详细信息请参阅这里

EntityManager 加载所有本地属性。如果指定了视图,则仅影响引用属性。详情请参阅这里

DataManager 仅执行 JPQL 查询。此外,它有单独的加载实体的方法:load()loadList(); 加载标量值和聚合值的方法: loadValues()

EntityManager 可以运行任何 JPQL 或原生(SQL)查询。

在客户端层调用时,DataManager 会检查安全限制。

EntityManager 不会应用安全限制。

在客户端层处理数据时,只有一个选择 - DataManager。在中间件层,当需要在事务内部实现某些原子逻辑或者 EntityManager 接口更适合该任务时,请使用 TransactionalDataManager。通常来说,在中间件上两者都可以使用。

如果需要在客户端层克服 DataManager 的一些限制,可创建自己的服务并使用 TransactionalDataManagerEntityManager 来处理数据。在服务中,可以使用Security接口检查权限,并以持久化实体、非持久化实体或任意值的形式将数据返回到客户端。

3.2.6.2.2. TransactionalDataManager

TransactionalDataManager 是一个中间层的 bean,跟 DataManager 接口类似,但是可以使用一个已经存在的事务。有如下功能:

  • 如果有活跃的事务,则会使用它,否则跟 DataManager 一样会创建并提交一个新事务。

  • 接收并返回游离态的实体。开发者需要使用合适的视图加载实体并使用 save() 方法显式的保存修改的实例至数据库。

  • 使用行级安全机制,使用跟 DataManager 一样的方式处理动态属性和跨数据存储引用。

下面是在 service 的方法中使用 TransactionalDataManager 的简单示例:

@Inject
private TransactionalDataManager txDataManager;

@Transactional
public void transfer(Id<Account, UUID> acc1Id, Id<Account, UUID> acc2Id, Long amount) {
    Account acc1 = txDataManager.load(acc1Id).one();
    Account acc2 = txDataManager.load(acc2Id).one();
    acc1.setBalance(acc1.getBalance() - amount);
    acc2.setBalance(acc2.getBalance() + amount);
    txDataManager.save(acc1);
    txDataManager.save(acc2);
}

可以在框架测试代码中找到更复杂的例子: DataManagerTransactionalUsageTest.java

如果需要在当前事务中处理 EntityChangedEvent 事件的话,TransactionalDataManager 特别有用。可以在事务提交之前从数据库获取更改实体的当前状态。

3.2.6.2.3. DataManager 安全机制

DataManager 的 load()loadList()loadValues()getCount() 方法会检查用户是否对要加载的实体有 READ 权限。此外,从数据库加载实体也受访问组约束的限制。

commit() 方法检查新建实体需要的 CREATE 权限、更新实体需要的 UPDATE 权限和删除实体需要的 DELETE 权限。

默认情况下,在客户端调用时,DataManager 会检查实体操作(READ / CREATE / UPDATE / DELETE)的权限,但是在从中间件代码调用时忽略这些检查。默认情况不强制检查实体属性权限。

如果要在中间件代码中使用 DataManager 时检查实体操作权限,请通过 DataManager.secure() 方法获取包装类并调用其方法。或者,可以设置 cuba.dataManagerChecksSecurityOnMiddleware 应用程序属性以打开整个应用程序的安全检查。

只有在将 cuba.entityAttributePermissionChecking 应用程序属性设置为 true 时,才会在中间件上强制执行属性权限。如果中间件为理论上可以被攻击的远程客户端提供服务,比如桌面客户端(这是可能的)。在这种情况下,还要将 cuba.keyForSecurityTokenEncryption 应用程序属性设置为唯一值。如果应用程序仅使用 Web 或 Portal 客户端,则可以安全地保留这些属性的默认值。

请注意,无论上述条件如何始终都会实施访问组约束 (行级别安全性)。

参阅 数据访问检查 章节全面了解框架中如何使用安全权限和约束。

3.2.6.2.4. 去重查询

如果一个界面包含带分页的表格,以及用于加载数据的 JPQL 查询,由于应用了通用过滤器或访问组约束,JPQL 查询在运行时会被修改。在 JPQL 查询中省略 distinct 运算符时会发生以下情况:

  • 如果在数据库级别进行数据集合的组合,则加载的数据集将包含重复行。

  • 在客户端级别,由于数据被添加到 map 中(java.util.Map),重复项在数据源中会消失。

  • 对于分页表格,界面可能显示的行数少于请求的行数,而总行数超出请求的数量。

因此,我们建议在 JPQL 查询中包含 distinct,以确保从数据库返回的数据集中不存在重复项。但是,如果返回的记录数很大(超过 10000),则在使用 distinct 执行 SQL 查询时,某些数据库服务器(特别是 PostgreSQL)会出现性能问题。

为了解决这个问题,平台包含一种在 SQL 级别上没有 distinct 的情况下正常运行的机制。此机制由 cuba.inMemoryDistinct 应用程序属性启用。启用后,会执行以下操作:

  • JPQL 查询仍然应包含 select distinct

  • 在将数据发送到 ORM 之前,DataManager 会从 JPQL 查询中删除 distinct

  • 数据页由 DataManager 加载后,会删除重复项在 DB 运行其它查询,用来检索返回给客户端需要的行数。

3.2.6.2.5. 级联查询(Sequential Queries)

DataManager 可以从之前的请求的结果中再次选择数据。此功能被通用过滤器用在连续使用过滤器的场景。

该机制的工作原理如下:

  • 如果提供一个定义了 prevQueriesqueryKey 属性的 LoadContextDataManager 将执行先前的查询并将检索到的实体的标识符保存在 SYS_QUERY_RESULT 表中(对应于 sys$QueryResult 实体),保存时会根据用户会话和查询会话的 queryKey 分隔记录集。

  • 修改当前查询以便与前一个查询的结果组合,这样能得到按照 AND 组合两个查询条件的结果。

  • 之后重复该过程。在这种情况下,逐渐减少的之前的结果集会被从 SYS_QUERY_RESULT 表中删除并重填。

已经终止的用户会话在 SYS_QUERY_RESULT 表中留下的历史查询结果会被定期清理。这是由 QueryResultsManagerAPI bean 的 deleteForInactiveSessions() 方法完成的,该方法由 cuba-spring.xml 中定义的 Spring 调度程序调用。 默认情况下,每 10 分钟执行一次,但可以使用 core 模块的 cuba.deleteOldQueryResultsInterval 应用程序属性按需要设置不同的时间间隔(以毫秒为单位)。

3.2.6.3. EntityStates

获取由 ORM 管理的持久化实体信息的接口。与 PersistencePersistenceTools bean 不同,此接口可用于所有

EntityStates 接口具有以下方法:

  • isNew() – 确定传递的实例是否是新创建的,即是否在 New 状态。如果此实例实际上处于 Managed 状态但在当前事务中刚被持久化,或者不是持久化实体,也会返回 true

  • isManaged() - 确定传递的实例是否被托管,比如是否添加到持久化上下文。

  • isDetached() – 确定传递的实例是否处于游离状态。如果此实例不是持久化实体,也返回 true

  • isLoaded() - 确定是否从数据库加载了属性。属性已加载:如果属性包含在视图中,或者如果是本地属性并且未向加载机制(EntityManagerDataManager)提供视图。此方法只能检查实体的直接属性。

  • checkLoaded() - 与 isLoaded() 一样,但如果传递给方法的属性中至少有一个未加载,则抛出 IllegalArgumentException

  • isLoadedWithView() - 接收实例和视图作为参数,如果实际加载了视图所需的所有属性,则返回 true。

  • checkLoadedWithView() - 与 isLoadedWithView() 一样,只不过是抛出 IllegalArgumentException 而不返回 false。

  • makeDetached() - 接收新创建的实体实例并将其转换为游离状态。游离的对象可以传递给 DataManager.commit()EntityManager.merge() 以将其状态保存在数据库中。请参阅 API 文档中的详细信息。

  • makePatch() - 接受新创建的实体实例并使其成为 补丁对象(patch object)。该补丁对象可以传递给 DataManager.commit()EntityManager.merge() 以将其状态保存在数据库中。与游离对象不同,补丁对象只保存非空属性。请参阅 API 文档中的详细信息。

3.2.6.3.1. PersistenceHelper

一个具有 EntityStates 接口中静态方法的辅助类。

3.2.6.4. Events

参考 用应用程序事件解耦业务逻辑 向导,了解应用程序事件的不同示例。

Events bean 封装了应用程序范围内的事件发布功能。应用程序事件可用于在松耦合的组件之间交换信息。Events bean 是 Spring Framework 的 ApplicationEventPublisher 的简单外观设计模式实现。

public interface Events {
    String NAME = "cuba_Events";

    void publish(ApplicationEvent event);
}

此接口只有接收一个事件对象作为参数的 publish() 方法。Events.publish() 会通知所有应用程序内注册的匹配的监听器。可以使用 PayloadApplicationEvent 将任何对象发布为事件。

另请参阅 Spring 框架入门

bean 中的事件处理

首先,创建一个新的事件类,继承 ApplicationEvent 类。事件类可以包含任何其它数据。例如:

package com.company.sales.core;

import com.haulmont.cuba.security.entity.User;
import org.springframework.context.ApplicationEvent;

public class DemoEvent extends ApplicationEvent {

    private User user;

    public DemoEvent(Object source, User user) {
        super(source);
        this.user = user;
    }

    public User getUser() {
        return user;
    }
}

Bean 对象可以使用 Events bean 发布事件:

package com.company.sales.core;

import com.haulmont.cuba.core.global.Events;
import com.haulmont.cuba.core.global.UserSessionSource;
import com.haulmont.cuba.security.global.UserSession;
import org.springframework.stereotype.Component;
import javax.inject.Inject;

@Component
public class DemoBean {

    @Inject
    private Events events;
    @Inject
    private UserSessionSource userSessionSource;

    public void demo() {
        UserSession userSession = userSessionSource.getUserSession();
        events.publish(new DemoEvent(this, userSession.getUser()));
    }
}

默认情况下,所有事件都是同步处理的。

处理事件有两种方法:

  • 实现 ApplicationListener 接口。

  • 方法使用 @EventListener 注解。

在第一种情况下,必须创建一个实现 ApplicationListener 接口的 bean,以需要事件类型作为泛型参数:

@Component
public class DemoEventListener implements ApplicationListener<DemoEvent> {
    @Inject
    private Logger log;

    @Override
    public void onApplicationEvent(DemoEvent event) {
        log.debug("Demo event is published");
    }
}

第二种方法可用于隐藏实现接口的细节并在单个 bean 中监听多个事件:

@Component
public class MultipleEventListener {
    @Order(10)
    @EventListener
    protected void handleDemoEvent(DemoEvent event) {
        // handle event
    }

    @Order(1010)
    @EventListener
    protected void handleUserLoginEvent(UserLoggedInEvent event) {
        // handle event
    }
}

使用 @EventListener 注解的方法不适用于 servicesJMX beans 以及其它带有接口的 bean。如果使用此方法,将在应用程序启动时看到以下错误:

BeanInitializationException: Failed to process @EventListener annotation on bean. Need to invoke method declared on target class, but not found in any interface(s) of the exposed proxy type. Either pull the method up to an interface or switch to CGLIB proxies by enforcing proxy-target-class mode in your configuration.

如果确实需要在带有接口的 bean 中监听事件,可以通过实现 ApplicationListener 接口来代替。

可以使用 Spring 框架的 Ordered 接口和 @Order 注解来对事件处理方法排序。框架中所有 bean 和事件处理方法使用的是 100 到 1000 之间的 order 值,因此可以在框架的 order 值之前或之后添加自定义的处理顺序。如果要在平台 bean 之前添加 bean 或事件处理程序,可使用小于 100 的值。

另请参阅登录事件.

UI 界面中的事件处理

通常,Events 将事件的发布委托给 ApplicationContext 执行。在 Web 层,可以为事件类使用特殊的接口 - UiEvent。这是一个事件的标记接口,实现这个接口的事件被发送到当前 UI 实例中的 UI 界面(当前 Web 浏览器标签页)。请

注意,UiEvent 实例不会发送到 Spring bean。

示例事件类:

package com.company.sales.web;

import com.haulmont.cuba.gui.events.UiEvent;
import com.haulmont.cuba.security.entity.User;
import org.springframework.context.ApplicationEvent;

public class UserRemovedEvent extends ApplicationEvent implements UiEvent {

    private User user;

    public UserRemovedEvent(Object source, User user) {
        super(source);
        this.user = user;
    }

    public User getUser() {
        return user;
    }
}

可以在窗口的控制器中使用 Events bean 触发事件,使用方式和在 bean 中一样:

@Inject
Events events;
// ...
UserRemovedEvent event = new UserRemovedEvent(this, removedUser);
events.publish(event);

为了处理事件,必须在 UI 界面中使用注解 @EventListener 定义方法(不支持 ApplicationListener 接口):

@Order(15)
@EventListener
protected void onUserRemove(UserRemovedEvent event) {
    notifications.create()
            .withCaption("User is removed " + event.getUser())
            .show();
}

可以使用 @Order 注解为事件监听器设定执行顺序。

如果一个事件是 UiEvent 并使用来自 UI 线程的 Events bean 触发,那么带有 @EventListener 方法的打开的窗口或 frame 将接收到该事件。事件处理是同步的。只有用户打开的当前 Web 浏览器标签页中 UI 界面才会收到该事件。

3.2.6.5. Messages

Messages 接口提供了获取本地消息字符串的方法。

主要方法如下:

  • getMessage() – 根据消息键名、包名和需要的语言环境返回本地化的消息。该方法有几个重载,如果未在方法参数中指定语言环境,则使用当前用户语言设置。

    例如:

    @Inject
    protected Messages messages;
    ...
    String message1 = messages.getMessage(getClass(), "someMessage");
    String message2 = messages.getMessage("com.abc.sales.web.customer", "someMessage");
    String message3 = messages.getMessage(RoleType.STANDARD);
  • formatMessage() – 通过消息键名、包名和需要的语言环境检索本地化消息,然后使用获取到的消息格式化输入的参数。格式化消息根据 String.format() 方法的规则定义。该方法有几个重载,如果未在方法参数中指定语言环境,则使用当前用户的语言设置。

    例如:

    String formattedValue = messages.formatMessage(getClass(), "someFormat", someValue);
  • getMainMessage() – 从应用程序 block主消息包返回本地化消息。

    例如:

    protected Messages messages = AppBeans.get(Messages.class);
    ...
    messages.getMainMessage("actions.Ok");
  • getMainMessagePack() – 返回应用程序 block 的主消息包的名称。

    例如:

    String formattedValue = messages.formatMessage(messages.getMainMessagePack(), "someFormat", someValue);
  • getTools() – 返回 MessageTools 接口实例(见下文)。

3.2.6.5.1. MessageTools

MessageTools 接口是一个Spring bean,包含用于处理本地化消息的其它方法。可以使用 Messages.getTools() 方法访问 MessageTools 接口,或像任何其它任何 bean 一样,通过注入的方式或 AppBeans 类来访问。

MessageTools 的方法:

  • loadString() – 返回一个本地化的消息,由 msg://{messagePack}/{key} 格式的消息引用指定

    消息引用字符串的结构:

    • msg:// – 必须的前缀。

    • {messagePack} – 消息包的可选名称。如果未指定,则假定包名称作为单独的参数传递给 loadString()

    • {key} – 包中消息的键名。

    消息引用字符串的示例:

    msg://someMessage
    msg://com.abc.sales.web.customer/someMessage
  • getEntityCaption() – 返回实体的本地化名称。

  • getPropertyCaption() – 返回实体属性的本地化名称。

  • hasPropertyCaption() – 检查实体属性是否被赋予了本地化名称。

  • getMessageRef() – 为元属性构造一个 消息引用字符串,可以用这个引用字符串检索实体属性的本地化名称。

  • getDefaultLocale() – 返回应用程序默认语言环境,这是 cuba.availableLocales 应用程序属性中列出的第一个语言环境。

  • useLocaleLanguageOnly() – 如果应用程序支持的所有语言环境(在 cuba.availableLocales 属性中定义)都只指定了语言参数,没有指定 country 和 variant,则返回 true。平台机制使用此方法,当语言环境信息是从外部源(例如操作系统或 HTTP 请求)接收到的,此机制需要确定一个最合适的支持语言。

  • trimLocale() – 如果 useLocaleLanguageOnly() 方法返回 true,则从传递的语言环境信息中清除除语言参数外的所有信息(country 和 variant)。

在应用程序中可以通过 覆盖 MessageTools bean 来扩展它的方法集。下面是使用扩展接口的示例:

MyMessageTools tools = messages.getTools();
tools.foo();
((MyMessageTools) messages.getTools()).foo();
3.2.6.6. Metadata

Metadata 接口提供对元数据会话和视图仓库的访问功能。

接口方法:

  • getSession() – 返回元数据会话实例。

  • getViewRepository() – 返回视图仓库实例。

  • getExtendedEntities() – 返回 ExtendedEntities 类实例,用于处理扩展实体。在扩展实体章节查看更多信息。

  • create() – 创建一个实体实例,此方法考虑了可能的实体扩展

    对于持久化的 BaseLongIdEntityBaseIntegerIdEntity 子类,在创建后立即分配标识符。新标识符是通过自动创建的数据库序列获取的。默认情况下,序列在主数据存储中创建。但是,如果 cuba.useEntityDataStoreForIdSequence 应用程序属性设置为 true,则会在实体所属的数据存储中创建序列。

  • getTools() – 返回 MetadataTools 接口实例(见下文)。

3.2.6.6.1. MetadataTools

MetadataTools 是一个Spring bean,包含了处理元数据的其它方法。可以通过使用 Metadata.getTools() 方法访问 MetadataTools 接口,也可以像其它 bean 一样,通过注入或 AppBeans 类来获取。

MetadataTools 接口的方法:

  • getAllPersistentMetaClasses() – 返回持久化实体元类的集合。

  • getAllEmbeddableMetaClasses() – 返回可嵌入实体元类的集合。

  • getAllEnums() – 返回用作实体属性类型的枚举类的集合。

  • format() – 根据给定元属性(meta-property) 的数据类型格式化传递的值。

  • isSystem() – 检查元属性是否是系统级属性,即在基础实体接口中定义的。

  • isPersistent() – 检查元属性是否是持久化属性,即属性有对应的数据库字段。

  • isTransient() – 检查元属性或任意属性是否为非持久化的。

  • isEmbedded() – 检查元属性是否为嵌入对象。

  • isAnnotationPresent() – 检查在一个类或其父类中是否存在指定的注解。

  • getNamePatternProperties() – 返回实例名称中包含的属性的元属性集合,实例名由 Instance.getInstanceName() 方法返回。请参阅 @NamePattern

可以通过在应用程序中 覆盖 MetadataTools bean 来扩展其方法集。使用扩展接口的示例:

MyMetadataTools tools = metadata.getTools();
tools.foo();
((MyMetadataTools) metadata.getTools()).foo();
3.2.6.7. Resources

Resources 接口根据以下规则管理资源的加载:

  1. 如果提供的位置是 URL,则通过该 URL 下载资源;

  2. 如果提供的位置以 classpath: 前缀开头,则从类路径(classpath)加载资源;

  3. 如果该位置不是 URL 并且它不以 classpath: 开头,那么:

    1. 使用提供的位置作为相对路径,在应用程序的 配置文件目录 中搜索该文件。如果找到该文件,则从中下载资源;

    2. 如果在前面的步骤中找不到资源,则从类路径(classpath)下载。

实际上,很少使用显式的 URL 标识或 classpath: 前缀,因此通常可以通过配置文件目录或类路径下载资源。配置文件目录中的资源会覆盖类路径下相同名称的资源,即配置文件目录下的资源的优先级更高。

Resources 接口的方法:

  • getResourceAsStream() – 返回指定资源的 InputStream,如果找不到资源则返回 null。应该在使用后关闭流,例如:

    @Inject
    protected Resources resources;
    ...
    InputStream stream = null;
    try {
        stream = resources.getResourceAsStream(resourceLocation);
        ...
    } finally {
        IOUtils.closeQuietly(stream);
    }

    也可以使用 "try with resources" 的编码方式:

    try (InputStream stream = resources.getResourceAsStream(resourceLocation)) {
        ...
    }
  • getResourceAsString() – 则以字符串形式返回所指定的资源内容,如果没有找到资源,返回 null

3.2.6.8. Scripting

Scripting 接口用于动态地编译和加载 Java 和 Groovy 类(在运行时),以及执行 Groovy 脚本和表达式。

Scripting 接口的方法:

  • evaluateGroovy() – 执行 Groovy 表达式并返回执行结果。

    cuba.groovyEvaluatorImport 应用程序属性用于定义插入到每个被执行表达式中的导入类的公共集合。默认情况下,所有标准应用程序 block 都导入 PersistenceHelper 类。

    已编译的表达式会被缓存,这大大加快了重复执行的速度。

    例如:

    @Inject
    protected Scripting scripting;
    ...
    Integer intResult = scripting.evaluateGroovy("2 + 2", new Binding());
    
    Binding binding = new Binding();
    binding.setVariable("instance", new User());
    Boolean boolResult = scripting.evaluateGroovy("return PersistenceHelper.isNew(instance)", binding);
  • runGroovyScript() – 执行 Groovy 脚本并返回执行结果。

    该脚本应位于应用程序 配置文件夹或类路径(classpath)中(当前的 Scripting 实现仅支持 JAR 文件中的类路径资源)。配置文件夹中的脚本将覆盖类路径中相同名称的脚本。

    脚本的路径是使用分隔符 / 构造的,路径的开头不需要分隔符。

    例如:

    @Inject
    protected Scripting scripting;
    ...
    Binding binding = new Binding();
    binding.setVariable("itemId", itemId);
    BigDecimal amount = scripting.runGroovyScript("com/abc/sales/CalculatePrice.groovy", binding);
  • loadClass() – 使用以下步骤加载 Java 或 Groovy 类:

    1. 如果类已加载,则返回该类。

    2. 在配置文件夹中搜索 Groovy 源代码( *.groovy 文件)。如果找到 Groovy 文件,它将被编译并返回 class 文件。

    3. 在配置文件夹中搜索 Java 源代码( *.java 文件)。如果找到它,它将被编译并返回 class 文件。

    4. 在类路径中搜索已编译的 class 文件。如果找到,它将被加载并返回。

    5. 如果找不到任何内容,将返回 null

    可以在运行时修改配置文件夹中的包含 Java 和 Groovy 源代码的文件。在下一次调用 loadClass() 时,将重新编译相应的类,并返回新的类,这个机制有以下限制:

    • 源代码的类型不能从 Groovy 更改为 Java;

    • Groovy 源代码一旦编译,删除这个源代码文件不会导类重新加载,而是仍然返回之前编译好的类。

    例如:

    @Inject
    protected Scripting scripting;
    ...
    Class calculatorClass = scripting.loadClass("com.abc.sales.PriceCalculator");
  • getClassLoader() – 返回一个 类加载器(ClassLoader),它能够按照上述 loadClass() 方法的规则加载类。

可以使用CachingFacadeMBean JMX bean 在运行时清理编译的类的缓存。

另请参阅 ScriptingManagerMBean

3.2.6.9. Security

此接口用于检查用户对系统中不同对象的访问权限。大多数接口方法委托给当前 UserSession 对象的相应方法,但在此之前,会搜索实体的原始 meta-class,这对于具有扩展的项目很重要。 除了复制 UserSession 功能的方法之外,该接口还包含 isEntityAttrReadPermitted()isEntityAttrUpdatePermitted() 方法,这两个方法用于检查指定属性路径的可用性,在检查的过程中会检查包含在这个路径中的所有实体和属性的可用性。

建议应用程序代码中使用 Security 接口,而不是直接调用 UserSession.isXYXPermitted() 方法。

用户认证中查看更多信息。

3.2.6.10. TimeSource

TimeSource 接口用于获取当前时间。不建议在应用程序代码中使用 new Date() 或类似的方法来获取当时时间。

例如:

@Inject
protected TimeSource timeSource;
...
Date date = timeSource.currentTimestamp();
long startTime = AppBeans.get(TimeSource.class).currentTimeMillis();
3.2.6.11. UserSessionSource

该接口用于获取当前用户会话对象。在用户认证章节查看更多信息。

3.2.6.12. UuidSource

该接口用于获取 UUID 值,包括用于实体标识符的值。建议不要在应用程序代码中使用 UUID.randomUUID()

要从静态上下文调用,可以使用 UuidProvider 类,它还有一个额外的 fromString() 方法,它比标准的 UUID.fromString() 方法更快。

3.2.7. 应用程序上下文(AppContext)

AppContext 是一个系统类,它在其静态字段中存储对每个应用程序block的某些公共组件的引用,包括:

  • Spring 框架的 ApplicationContext

  • app.properties 文件加载的应用程序属性集合。

  • ThreadLocal 变量,存储SecurityContext实例。

  • 应用程序生命周期监听器的集合( AppContext.Listener )。

启动应用程序时,使用加载器类初始化 AppContext,对于每个应用程序block

  • 中间件加载器 – AppContextLoader

  • Web 客户端加载器 – WebAppContextLoader

  • Web 门户加载器 – PortalAppContextLoader

AppContext 可以在应用程序代码中用于以下任务:

  • 在属性值不能通过配置接口访问时,获取存储在 app.properties 文件中的应用程序属性值。

  • SecurityContext 传递给新的执行线程,请参阅用户认证

  • 注册监听器,在完全初始化之后或应用程序终止之前触发,例如:

    AppContext.addListener(new AppContext.Listener() {
        @Override
        public void applicationStarted() {
            System.out.println("Application is ready");
        }
    
        @Override
        public void applicationStopped() {
            System.out.println("Application is closing");
        }
    });

    请注意,在应用程序启动和关闭时执行代码的推荐方法是使用应用程序生命周期事件

3.2.8. 应用程序生命周期事件

CUBA 应用程序中有以下几种类型的生命周期事件

AppContextInitializedEvent

AppContext初始化后立即触发。 此时:

  • 所有bean都已完全初始化,并且执行了 @PostConstruct 方法。

  • 可通过静态方法 AppBeans.get() 获取 bean。

  • AppContext.isStarted() 方法返回 false

  • AppContext.isReady() 方法返回 false

AppContextStartedEvent

AppContextInitializedEvent 之后并且运行完所有 AppContext.Listener.applicationStarted() 之后触发。此时:

  • AppContext.isStarted() 方法返回 true

  • AppContext.isReady() 方法返回 false

  • 在中间件上,如果启用了 cuba.automaticDatabaseUpdate应用程序属性,所有数据库更新脚本已成功执行。

AppContextStoppedEvent

在应用程序关闭之前并且运行完所有 AppContext.Listener.applicationStopped() 之后触发。此时:

  • 所有 bean 都可以运行并且可以通过 AppBeans.get() 方法获得。

  • AppContext.isStarted() 方法返回 false

  • AppContext.isReady() 方法返回 false

通过指定 @Order 注解可以影响监听器调用的顺序。Events.HIGHEST_PLATFORM_PRECEDENCEEvents.LOWEST_PLATFORM_PRECEDENCE 常量指定平台中定义的监听器的使用范围。

例如:

package com.company.demo.core;

import com.haulmont.cuba.core.global.Events;
import com.haulmont.cuba.core.sys.events.*;
import org.slf4j.Logger;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import javax.inject.Inject;

@Component
public class MyAppLifecycleBean {

    @Inject
    private Logger log;

    // event type is defined by annotation parameter
    @EventListener(AppContextInitializedEvent.class)
    // run after all platform listeners
    @Order(Events.LOWEST_PLATFORM_PRECEDENCE + 100)
    protected void appInitialized() {
        log.info("Initialized");
    }

    // event type is defined by method parameter
    @EventListener
    protected void appStarted(AppContextStartedEvent event) {
        log.info("Started");
    }

    @EventListener
    protected void appStopped(AppContextStoppedEvent event) {
        log.info("Stopped");
    }
}
ServletContextInitializedEvent

在 servlet 和应用程序上下文初始化之后触发。此时:

  • 可以通过静态方法 AppBeans.get() 获取 bean。

  • 此事件包含应用程序和 servlet 上下文,因此可以注册自定义 servlet、过滤器和监听器,请参阅Servlet 和过滤器的注册

ServletContextDestroyedEvent

在 Servlet 和应用程序即将关闭时触发,在这个阶段可以手动释放资源。

例如:

@Component
public class MyInitializerBean {

    @Inject
    private Logger log;

    @EventListener
    public void foo(ServletContextInitializedEvent e) {
        log.info("Application and servlet context is initialized");
    }

    @EventListener
    public void bar(ServletContextDestroyedEvent e) {
        log.info("Application is about to shut down, all contexts are now destroyed");
    }
}

3.2.9. 应用程序属性

应用程序属性表示不同类型的命名值,它们决定着应用程序配置和功能的各个方面。平台广泛使用应用程序属性,还可以使用它们来配置应用程序的某些特性。

平台应用程序属性可按预期目的进行分类,如下所示:

设置应用程序属性

应用程序属性的值可以设置在数据库属性文件 ,也可以通过 Java 系统属性和操作系统的环境变量设置。如果同样名称的属性在多个地方定义,会按照下面的优先级决定其属性值:

  1. Java 系统属性(最高优先级)

  2. 操作系统环境变量

  3. 属性文件

  4. 数据库(最低优先级)

比如,在属性文件中定义的值会覆盖在数据库中定义的值。

对于操作系统环境变量,框架会先尝试寻找严格匹配的属性名称,如果没找到,则会尝试找名称全大写、点被下划线替代的属性名称。举个例子,环境变量 MYAPP_SOMEPROPERTY 可以赋值给 myapp.someProperty 应用程序属性。如果想禁用这个功能,还是使用严格匹配的话,可以设置 cuba.disableUppercaseEnvironmentProperties 应用程序属性为 true。

某些属性不支持设置在数据库中,原因是:在应用程序代码还无法访问数据库时就需要使用这些属性值。这些属性是上面提到的配置和部署参数。因此,只能在属性文件中或通过 Java 系统属性和操作系统环境变量定义它们。运行时参数始终设置在数据库中(可能被文件或系统属性中的值覆盖)。

通常情况下,应用程序属性用于一个或多个应用程序block。例如cuba.persistenceConfig仅用于中间件,cuba.web.appWindowMode用于 Web 客户端,而cuba.springContextConfig用于所有的 block。这意味着如果你需要为一个属性设置值,就应该在所有使用这个属性的block中进行设置。存储在数据库中的属性可自动用于所有 block,因此可以仅在一个位置设置它们的值(在数据库表中),而不用关心哪个 block 将会使用它们。而且,还有一个标准 UI 界面来管理此类属性:请参考 Administration > Application Properties 。存储在文件中的属性应分别在各 block 的相应文件中设置。

当需要为平台属性设置值时,请在文档中找到相关属性。如果文档声明该属性存储在数据库中,请使用 Administration > Application Properties 界面设置其值。否则,找出哪些 block 使用该属性并在这些 block 的 app.properties 文件中定义它。例如,如果文档声明该属性在所有 block 中使用,并且应用程序由中间件和 Web 客户端组成,则应在 core 模块的 app.properties 文件和 web 模块的 web-app.properties 文件中定义该属性。部署参数也可以在外部的 local.app.properties 文件设置。有关详细信息,请参阅在文件中存储属性

应用程序组件属性

应用程序组件可以通过在app-component.xml文件定义属性来对外暴露这些属性。 如果使用该组件的应用程序没有为这些属性指定值,则从该组件获取这些属性值。如果应用程序使用的多个组件定义了相同的属性,则应用程序中的实际值将从组件依赖关系层次结构从最近的组件中获得。如果多个组件位于依赖层次中的同一级,则该属性值会变得不确定。

累加 Properties

有时,需要从项目中使用的所有应用程序组件中获取属性值的组合。特别是应用程序的行为依赖于多个组件的同一属性的情况,平台需要读取多个组件的同一属性来配置应用程序。

通过在属性值的开头添加加号来使这些属性成为_累加_属性。此符号表示在运行时从应用程序组件组装属性值。例如,cuba.persistenceConfig是一个累加属性。在项目中,指定了一个定义项目数据模型的 persistence.xml 文件。但是由于实际的属性值还包括应用程序所依赖的应用程序组件的 persistence.xml 文件,因此应用程序的完整数据模型还要包括组件中定义的实体。

如果属性省略了 +,则只能从当前项目获取其值。如果不想从组件继承某些配置(例如,在定义菜单结构时),它会很有用。

在运行时获得的累加属性值是由 空格 连接的元素组成。

以编程的方式访问应用程序属性

可以使用以下机制访问代码中的应用程序属性:

  • Configuration 接口。如果将应用程序属性定义为配置接口的带注解方法,则应用程序代码将可对属性进行类型化访问。配置接口允许定义和访问所有存储类型的属性:数据库、文件和系统属性。

  • AppContext类的 getProperty() 方法。如果在文件或 Java 系统属性中设置属性,则可以使用此方法读属性值。该方法有以下弊端:

    • 不支持存储在数据库中的属性。

    • 与调用接口方法不同,必须提供 String 类型的属性名称。

    • 不能获取特定类型的属性值,只能获取 String 类型的属性值。

3.2.9.1. 在文件中存储属性

确定配置和部署参数的属性在特定的属性文件中指定,这些文件以 *app.properties 模式命名。每个应用程序block都包含一组此类文件,定义在web.xmlappPropertiesConfig 参数中。

例如,中间件 block 的属性文件在 core 模块的 web/WEB-INF/web.xml 文件中指定,如下所示:

<context-param>
    <param-name>appPropertiesConfig</param-name>
    <param-value>
        classpath:com/company/sample/app.properties
        /WEB-INF/local.app.properties
        "file:${app.home}/local.app.properties"
    </param-value>
</context-param>

classpath: 前缀表示在 Java 类路径中查找相应的文件,而 file: 前缀表示它应该从文件系统加载。没有此前缀的路径表示相对于 Web 应用程序根目录的路径。可以使用 Java 系统属性:在此示例中,app.home 指向应用程序主目录

声明文件的顺序很重要,因为在每个后续文件中指定的值将覆盖前面文件中指定的相同名称的属性值。如果其中有任何指定的文件不存在,则会忽略该文件。

上面集合中的最后一个文件是 local.app.properties。它可用于在部署特定环境时设置或者覆盖应用程序属性。

创建 *.properties 文件时,请使用以下规则:

  • 文件编码 – UTF-8

  • 关键字可以包含拉丁字母 、数字 、句号和下划线。

  • 具体属性值在( = )后输入。

  • 不要使用 " 或 ' 将属性值括起来。

  • 以 UNIX 格式( /opt/haulmont/ )或 Windows 格式( c:\\haulmont\\ )设置文件路径。

  • 可以使用 \n \t \r 转义符。 \ 符号是保留符号,使用 \\ 将其插入值中。请参阅:http://docs.oracle.com/javase/tutorial/java/data/characters.html

  • 属性中要包含多行值,请在每行末尾使用 \ 符号。

3.2.9.2. 在数据库中存储属性

表示运行时参数的应用程序属性存储在 SYS_CONFIG 表中。

这些属性具有以下特征:

  • 由于属性值存储在数据库中,因此无论哪个应用程序 block 使用,都只要在一个地方定义。

  • 可以通过以下方式在运行时更改和保存属性值:

    • 使用 Administration > Application Properties 界面。

    • 使用 ConfigStorageMBean JMX bean。

    • 如果配置接口有 setter 方法,则可以在应用程序代码中设置属性值。

  • 有几种方式重写属性值:在特定应用程序 block 的 *app.properties 文件、Java 系统属性或者操作系统环境变量中定义同名属性。

需要注意的是,客户端通过向中间件发送请求来访问存储在数据库中的属性,要比从本地 *app.properties 文件中检索属性效率低。为了减少请求数,客户端在配置接口实例生命周期内对属性进行了缓存。因此,如果需要从某个 UI 界面多次访问配置接口的属性,建议在界面初始化时获取配置接口的引用,并将其保存到界面控制器的字段中以便后续访问。

3.2.9.3. 配置接口

配置接口机制允许使用 Java 接口方法处理应用程序属性,从而带来以下好处:

  • 类型化访问 - 在应用程序代码中可以使用实际数据类型(String 、 Boolean 、 Integer 等)。

  • 应用程序代码使用接口方法而不是字符串类型的属性标识符,这些接口方法可由编译器检查,并且在集成开发环境中使用代码自动完成。

读取中间件 block 中的事务超时属性值的示例:

@Inject
private ServerConfig serverConfig;

public void doSomething() {
    int timeout = serverConfig.getDefaultQueryTimeoutSec();
    ...
}

如果无法注入,可以通过Configuration基础接口获取配置接口引用:

int timeout = AppBeans.get(Configuration.class)
        .getConfig(ServerConfig.class)
        .getDefaultQueryTimeoutSec();

配置接口不是常规的 Spring bean。它们只能通过显式接口注入或通过 Configuration.getConfig() 获取,但不能通过 AppBeans.get() 获取。

3.2.9.3.1. 使用配置接口

要在应用程序中创建配置接口,请执行以下操作:

  • 创建一个继承自 com.haulmont.cuba.core.config.Config 的接口(不要与实体类 com.haulmont.cuba.core.entity.Config 混淆)。

  • 添加 @Source 注解以指定应属性值的存储位置:

    • SourceType.SYSTEM – 将使用 System.getProperty() 方法从给定 JVM 的系统属性中获取值。

    • SourceType.APP – 将从 *app.properties 文件中获取值。

    • SourceType.DATABASE – 将从数据库中获取值。

  • 创建属性访问方法(getters / setters)。如果不打算通过代码更改属性值,那么就不要创建 setter 方法。getter 方法的返回类型即是属性的类型。可能的属性类型描述在这里

  • 添加 @Property 注解,定义 getter 方法对应的属性名称。

  • 如果某个特定属性的来源与接口上定义的来源不同,可以为该属性单独设置 @Source 注解。

  • 如果 @Source 值是 SourceType.DATABASE,则可以在平台提供的 Administration > Application Properties 界面上编辑该属性。可以使用 @Secret 注解以掩码的方式在界面上显示属性值(将使用PasswordField而不是常规的文本字段)。

配置接口必须定义在应用程序的根包内(或者根包的内部包内)。

例如:

@Source(type = SourceType.DATABASE)
public interface SalesConfig extends Config {

    @Property("sales.companyName")
    String getCompanyName();

    @Property("sales.ftpPassword")
    @Secret
    String getFtpPassword();
}

不要创建任何实现类,因为当注入配置接口或通过Configuration获取配置接口时,平台将自动创建所需的代理类。

3.2.9.3.2. 属性类型

平台支持以下开箱即用的属性类型:

  • String, 原始类型及其封装类型(booleanBooleanintInteger 等)

  • enum,属性值作为枚举的值名称存储在文件或数据库中。

    如果枚举实现了 EnumClass 接口并且具有用于通过标识符获取值的静态方法 fromId(),则可以使用 @EnumStore 注解指定存储枚举标识符而不是具体值。例如:

    @Property("myapp.defaultCustomerGrade")
    @DefaultInteger(10)
    @EnumStore(EnumStoreMode.ID)
    CustomerGrade getDefaultCustomerGrade();
    
    @EnumStore(EnumStoreMode.ID)
    void setDefaultCustomerGrade(CustomerGrade grade);
  • 持久化实体类。访问实体类型的属性时,将从数据库加载由属性值定义的实例。

要支持任意类型,请使用 TypeStringifyTypeFactory 类将值转换为字符串或从字符串转换值,并使用 @Stringify@Factory 注解为属性指定这些类。

我们以 UUID 类型为例来了解这个过程。

  1. 创建类 com.haulmont.cuba.core.config.type.UuidTypeFactory 继承于 com.haulmont.cuba.core.config.type.TypeFactory 类 并实现下面的方法:

    public Object build(String string) {
        if (string == null) {
            return null;
        }
        return UUID.fromString(string);
    }
  2. 在这种情况下没有必要创建 TypeStringify,因为有 toString() 方法。

  3. 在配置接口中注解属性:

    @Factory(factory = UuidTypeFactory.class)
    UUID getUuidProp();
    void setUuidProp(UUID value);

平台为以下类型提供了 TypeFactoryStringify 实现:

  • UUIDUuidTypeFactory, 如上所述。TypeStringify 冗余了,可以直接使用 UUIDtoString() 方法。

  • java.util.DateDateFactoryDateStringify。日期值必须以 yyyy-MM-dd HH:mm:ss.SSS 格式指定,例如:

    cuba.test.dateProp = 2013-12-12 00:00:00.000

    在配置接口中定义一个 date 属性的示例:

    @Property("cuba.test.dateProp")
    @Factory(factory = DateFactory.class)
    @Stringify(stringify = DateStringify.class)
    Date getDateProp();
    
    void setDateProp(Date date);
  • List<Integer> (整数列表) – IntegerListTypeFactoryIntegerListStringify。必须以数字的形式指定属性值,用空格分隔,例如:

    cuba.test.integerListProp = 1 2 3

    在配置接口中定义一个 List<Integer> 属性的示例:

    @Property("cuba.test.integerListProp")
    @Factory(factory = IntegerListTypeFactory.class)
    @Stringify(stringify = IntegerListStringify.class)
    List<Integer> getIntegerListProp();
    
    void setIntegerListProp(List<Integer> list);
  • List<String> (字符串列表) – StringListTypeFactoryStringListStringify。必须将属性值指定为由 "|" 分隔的字符串列表,例如:

    cuba.test.stringListProp = aaa|bbb|ccc

    在配置接口中定义一个 List<String> 属性的示例:

    @Property("cuba.test.stringListProp")
    @Factory(factory = StringListTypeFactory.class)
    @Stringify(stringify = StringListStringify.class)
    List<String> getStringListProp();
    
    void setStringListProp(List<String> list);
3.2.9.3.3. 默认值

可以为配置接口定义的属性指定默认值。如果未在存储位置(数据库或 *app.properties 文件)中设置该属性,则将返回这里定义的默认值而不是 null

可以使用 @Default 注解将默认值指定为字符串,或使用 com.haulmont.cuba.core.config.defaults 包中的其它注解将默认值指定为其它特定类型:

@Property("cuba.email.adminAddress")
@Default("address@company.com")
String getAdminAddress();

@Property("cuba.email.delayCallCount")
@Default("2")
int getDelayCallCount();

@Property("cuba.email.defaultSendingAttemptsCount")
@DefaultInt(10)
int getDefaultSendingAttemptsCount();

@Property("cuba.test.dateProp")
@Default("2013-12-12 00:00:00.000")
@Factory(factory = DateFactory.class)
Date getDateProp();

@Property("cuba.test.integerList")
@Default("1 2 3")
@Factory(factory = IntegerListTypeFactory.class)
List<Integer> getIntegerList();

@Property("cuba.test.stringList")
@Default("aaa|bbb|ccc")
@Factory(factory = StringListTypeFactory.class)
List<String> getStringList();

实体的默认值是 {entity_name}-{id}-{optional_view_name} 格式的字符串,例如:

@Default("sec$User-98e5e66c-3ac9-11e2-94c1-3860770d7eaf-browse")
User getAdminUser();

@Default("sec$Role-a294aef0-3ac9-11e2-9433-3860770d7eaf")
Role getAdminRole();

3.2.10. 消息本地化

基于 CUBA 框架的应用程序支持消息本地化,这意味着所有用户界面元素都可以以用户选择的语言显示。

参考 CUBA 应用程序中的本地化 指南,了解 CUBA 应用程序中本地化消息是如何定义并使用的。

语言选择选项由 cuba.localeSelectVisiblecuba.availableLocales 应用程序属性共同确定。

本节介绍本地化机制和本地化消息创建规则。

3.2.10.1. 语言消息包

消息包是一组位于单个 Java 包中名称为 messages{_XX}.properties 格式的属性文件。XX 后缀表示在此文件中消息的语言,对应 Locale.getLanguage() 中的语言代码。也能够使用其它 Locale 属性,例如,country。在这种情况下,消息包文件名看起来像 messages{_XX_YY}.properties。如果包中的某个消息文件没有语言后缀 - 则是默认消息文件。消息的包名称对应于包含消息文件的 Java 包的名称。

我们看看以下示例:

/com/abc/sales/gui/customer/messages.properties
/com/abc/sales/gui/customer/messages_fr.properties
/com/abc/sales/gui/customer/messages_ru.properties
/com/abc/sales/gui/customer/messages_en_US.properties

这个包由 4 个文件组成 - 一个用于俄语,一个用于法语,一个用于美式英语(带有美国国家代码),以及一个默认文件。包名是 com.abc.sales.gui.customer

消息文件包含键/值对,其中键是应用程序代码中引用消息的标识符,值是对应语言的消息本身。配对规则与 java.util.Properties 属性文件类似,同时具有以下特点:

  • 文件编码 – 仅限 UTF-8

  • 可以使用 @include 键引入其它消息包。可以使用以逗号分隔的列表包含多个包。在这种情况下,如果在当前包和引入包中都找到某个消息键,则使用来自当前包的消息。引入包的示例:

    @include=com.haulmont.cuba.web, com.abc.sales.web
    
    someMessage=Some Message
    ...

根据以下规则使用 Messages 接口方法来从包中检索消息:

  • 首先在应用的配置目录中执行搜索。

    • 在消息包名称指定的目录中搜索 messages_XX.properties 文件,其中 XX 是所需语言的代码。

    • 如果没有这样的文件,则在同一目录中搜索默认的 messages.properties 文件。

    • 如果找到所需的语言文件或默认文件,则将其与所有 @include 文件一起加载,并在其中搜索消息键名。

    • 如果没有找到文件或者文件中没有正确的消息键,则将目录更改为父目录并重复搜索过程。搜索将继续,直到到达配置目录的根目录。

  • 如果在配置目录中没有找到该消息,则根据相同的算法在类路径中执行搜索。

  • 如果找到该消息,则将其缓存并返回。如果没有,则消息不存在的事实也被缓存并返回搜索时传递的键。因此,复杂搜索过程仅执行一次,后续将从应用程序模块的本地高速缓存中加载结果。

建议按如下方式组织消息包:

  • 如果应用程序不是用于国际化,可以将消息字符串直接包含在应用程序代码中,而不是使用包或使用 messages.properties 默认文件将资源从源码中分离。

  • 如果应用程序是国际化应用程序,则可以使用应用程序主要受众的语言或用英语为默认文件,以便在找不到所需语言的消息时向用户显示这些默认文件的消息。

3.2.10.2. 主语言消息包

每个标准的应用程序 block 都应该有它自己的 主(main) 消息包。对于客户端层的 block,主消息包包含主菜单条目和常用的 ui 元素名称(例如,okcancel 按钮的名称)。主程序包还决定所有应用程序模块(包括中间层)的数据类型转换格式。

cuba.mainMessagePack 应用程序属性用于指定主消息包。其属性值可以是单个包或由空格分隔的包名列表。例如:

cuba.mainMessagePack=com.haulmont.cuba.web com.abc.sales.web

在这种情况下,列表第二个包的消息将覆盖第一个包中的消息。因此,可以在应用程序项目覆盖应用程序组件中定义的消息。

通过在项目的主消息包中指定新消息也可以覆盖 CUBA 基础项目中已存在的消息。

com.haulmont.cuba.gui.backgroundwork/backgroundworkprogress.timeoutmessage = overridden error message

违反数据库唯一约束的消息需要包含与数据库约束名称全大写相同的键值:

IDX_SEC_USER_UNIQ_LOGIN = A user with the same login already exists
3.2.10.3. 实体和属性名称本地化

要在 UI 中显示实体和属性的本地化名称,请在包含实体的 Java 包中创建特殊的消息包。在消息文件中使用以下格式:

  • 实体名称键 – 简单类名(不带包名)。

  • 属性名称键 – 简单类名,后面跟上以点分隔的属性名。

com.abc.sales.entity.Customer 实体的默认英文本地化示例 – /com/abc/sales/entity/messages.properties 文件:

Customer=Customer
Customer.name=Name
Customer.email=Email

Order=Order
Order.customer=Customer
Order.date=Date
Order.amount=Amount

此类消息包通常由框架隐式地使用,例如,被 TableFieldGroup 可视化组件使用。除此之外,还可以使用以下方法来获取实体和属性的名称:

  • 编程方式 – 通过 MessageTools getEntityCaption()getPropertyCaption() 方法;

  • 在界面 XML 描述中 – 根据 MessageTools.loadString() 规则引用消息: msg://{entity_package}/{key},例如:

    caption="msg://com.abc.sales.entity/Customer.name"
3.2.10.4. 枚举名称本地化

要本地化枚举的名称(names)和值(values),将具有以下键的消息添加到枚举类所在 Java 包的本地化消息包中:

  • 枚举名称键 – 简单的类名(不带包名);

  • 值健 – 简单类名,后面跟上以点分隔的值名称。

例如,对于枚举

package com.abc.sales;

public enum CustomerGrade {
    PREMIUM,
    HIGH,
    STANDARD
}

默认的英文本地化文件 /com/abc/sales/messages.properties 应包含以下行:

CustomerGrade=Customer Grade
CustomerGrade.PREMIUM=Premium
CustomerGrade.HIGH=High
CustomerGrade.STANDARD=Standard

本地化的枚举值可被不同的可视化组件自动利用,例如 LookupField。也可以通过编程方式获取本地化的枚举值:使用 Messages 接口的 getMessage() 方法并简单地将 enum 实例传递给它。

3.2.11. 用户认证

本节从开发人员的角度描述了一些访问控制方面的内容。有关配置用户数据访问限制的完整信息,请参阅安全子系统

3.2.11.1. 用户会话

用户会话信息是 CUBA 应用程序访问控制机制的主要元素。它由 UserSession 对象表示,该对象与当前已通过身份验证的用户关联,并包含了用户的权限信息。在任何应用程序 block 中都可以使用 UserSessionSource 基础接口获取 UserSession 对象。

在使用用户名和密码对用户进行身份验证后,会执行 AuthenticationManager.login() 方法,此方法执行时会在中间件上创建 UserSession 对象。然后将对象缓存在中间件 block 中并返回到客户端层。在集群中运行时,会话对象将复制到所有集群成员。客户端层也会在接收会话对象之后将其存储下来,并以某种方式将其与活动用户相关联(例如,将其存储在 HTTP 会话中)。后续,此用户的所有中间件方法调用都会带上会话标识符( UUID 类型)。此过程不需要在应用程序代码中提供任何特殊处理,会话标识符会自动传递,与调用的方法的签名无关(即会话信息不通过方法参数传递)。中间件在处理客户端调用时,首先通过获得的标识符从缓存中检索会话,然后会话被与请求(http request)的执行线程关联。调用 AuthenticationManager.logout() 方法时或 cuba.userSessionExpirationTimeoutSec 应用程序属性定义的超时时间到期时,会从缓存中删除会话对象。

这样,在用户登录系统时创建的会话标识符用于在每次中间件调用期间进行用户验证。

UserSession 对象还包含当前用户_验证相关_的方法 - 验证对系统对象的访问权限: isScreenPermitted()isEntityOpPermitted()isEntityAttrPermitted()isSpecificPermitted()。但是,建议使用 Security 基础接口以编程的方式进行权限验证。

UserSession 对象可以包含任意可序列化类型的命名属性。属性由 setAttribute() 方法设置,并由 getAttribute() 方法获取。后者也能够像属性一样返回以下会话参数:

  • userId – 当前注册的或代替的用户的 ID;

  • userLogin – 当前注册的或代替的用户登录名的小写形式。

会话属性与其它用户会话数据一样在中间件集群中被复制分发。

3.2.11.2. 登录

CUBA 框架提供内置的可扩展身份验证机制。这些机制包括不同的身份验证方案,例如登录/密码、记住账号、信任和匿名登录。

参考 匿名访问 & 社交登录 向导学习如何为应用程序中某些界面设置公共访问权限,并实现用 Google、Facebook 或 GitHub 账号的自定义登录。

本节主要介绍中间层的身份验证机制。有关 Web 客户端身份验证机制的详细信息,请参阅 Web 登录

平台在中间件包含以下身份验证机制:

  • AuthenticationManagerBean 实现的 AuthenticationManager

  • AuthenticationProvider 实现。

  • AuthenticationServiceBean 实现的 AuthenticationService

  • UserSessionLog - 参阅用户会话日志

MiddlewareAuthenticationStructure
Figure 7. 中间件身份验证机制

此外,它还使用以下附加组件:

  • TrustedClientServiceBean 实现的 TrustedClientService - 为受信任客户端提供匿名会话或系统会话。

  • AnonymousSessionHolder - 为受信任的客户端创建并保存匿名会话实例。

  • UserCredentialsChecker - 检查用户凭据是否可以使用,比如可用于防止暴力破解。

  • UserAccessChecker - 检查用户是否可以通过给定的上下文访问系统,例如,控制用户是否可以通过 REST 访问系统、控制指定的 IP 地址是否可以访问系统。

身份验证的主要接口是 AuthenticationManager,它包含四个方法:

public interface AuthenticationManager {

    AuthenticationDetails authenticate(Credentials credentials) throws LoginException;

    AuthenticationDetails login(Credentials credentials) throws LoginException;

    UserSession substituteUser(User substitutedUser);

    void logout();
}

有两个方法具有相似的功能: authenticate()login()。两个方法都检查提供的凭据是否有效且对应于有效用户,然后返回 AuthenticationDetails 对象。它们之间的主要区别在于 login 方法还激活了用户会话,这样它随后就可以用于调用服务方法。

Credentials 表示用于身份验证子系统的一组凭据。平台有 AuthenticationManager 支持的以下几种类型的凭据:

适用于所有层:

  • LoginPasswordCredentials

  • RememberMeCredentials

  • TrustedClientCredentials

仅适用于中间层:

  • SystemUserCredentials

  • AnonymousUserCredentials

AuthenticationManager 的 login / authenticate 方法返回 AuthenticationDetails 实例,其中包含 UserSession 对象。此对象可用于检查其它权限、读取 User 属性和会话属性。平台只有一个内置的 AuthenticationDetails 接口实现 - SimpleAuthenticationDetails,它只存储用户会话对象,但是应用程序可以提供自己的带有附加信息的 AuthenticationDetails 实现。

AuthenticationManagerauthenticate() 方法中执行以下三种操作之一:

  • 如果可以验证输入的是一个有效用户,则返回 AuthenticationDetails

  • 如果无法通过传递的凭据对象对用户进行身份验证,则抛出 LoginException

  • 如果不支持传递的凭据对象,则抛出 UnsupportedCredentialsException

AuthenticationManager 的默认实现是 AuthenticationManagerBean,它将身份验证委托给 AuthenticationProvider 实例链。 AuthenticationProvider 是一个可以处理特定 Credentials 实现的身份验证模块,它还有一个特殊的方法 supports(),允许调用者查询它是否支持给定的 Credentials 类型。

LoginProcedure
Figure 8. 标准的用户登录过程

标准的用户登录过程:

  • 用户输入用户名和密码。

  • 应用程序客户端使用用户名和密码作为参数调用 Connection.login() 方法。

  • Connection 创建 Credentials 对象并调用 AuthenticationServicelogin() 方法。

  • AuthenticationService 将验证操作委托给 AuthenticationManager bean ,AuthenticationManager bean 使用了 AuthenticationProvider 对象链 。LoginPasswordAuthenticationProvider 可以使用 LoginPasswordCredentials 对象。它通过输入的登录名加载 User 对象,使用用户标识符作为盐值再次散列获得的密码哈希值,并将获得的哈希值与存储在 DB 中的密码哈希值进行比较。如果不匹配,则抛出 LoginException

  • 如果身份验证成功,则将用户的所有访问参数(角色列表、权限、约束和会话属性)加载到创建的 UserSession 实例中。

  • 如果启用了用户会话日志,则会把包含用户会话信息的记录保存到数据库中。

另外请参阅 Web 登录过程

密码散列算法由 EncryptionModule 类型 bean 实现,并在 cuba.passwordEncryptionModule 应用程序属性中指定。默认情况下使用 BCrypt。

内置验证提供程序

平台包含以下 AuthenticationProvider 接口的实现:

  • LoginPasswordAuthenticationProvider

  • RememberMeAuthenticationProvider

  • TrustedClientAuthenticationProvider

  • SystemAuthenticationProvider

  • AnonymousAuthenticationProvider

所有实现都从数据库加载用户,使用 UserSessionManager 验证传递的凭据对象并创建非活动的用户会话。随后调用 AuthenticationManager.login() 后,该会话实例将变为活动状态。

LoginPasswordAuthenticationProviderRememberMeAuthenticationProviderTrustedClientAuthenticationProvider 使用额外检查插件:实现了 UserAccessChecker 接口的 bean。如果有一个 UserAccessChecker 实例抛出 LoginException,则认为验证失败并抛出 LoginException

此外,LoginPasswordAuthenticationProviderRememberMeAuthenticationProvider 使用 UserCredentialsChecker beans 检查凭据实例。UserCredentialsChecker 接口只有一个内置实现 - BruteForceUserCredentialsChecker,用于检查用户是否使用暴力破解攻击来找出有效凭据。

异常类型

AuthenticationManagerAuthenticationProviderauthenticate() 方法和 login() 方法会抛出 LoginException 或其子类异常。需要确认 此外,如果传递的凭据对象没有可用的 AuthenticationProvider bean,则抛出 UnsupportedCredentialsException

请参阅以下异常类:

  • UnsupportedCredentialsException

  • LoginException

  • AccountLockedException

  • UserIpRestrictedException

  • RestApiAccessDeniedException

事件

AuthenticationManager 的标准实现 - AuthenticationManagerBean 在登录或验证过程中触发以下应用程序 事件

  • BeforeAuthenticationEvent / AfterAuthenticationEvent

  • BeforeLoginEvent / AfterLoginEvent

  • AuthenticationSuccessEvent / AuthenticationFailureEvent

  • UserLoggedInEvent / UserLoggedOutEvent

  • UserSubstitutedEvent

中间层的 Spring bean 可以使用 Spring @EventListener 注解来处理这些事件:

@Component
public class LoginEventListener {
    @Inject
    private Logger log;

    @EventListener
    protected void onUserLoggedIn(UserLoggedInEvent event) {
        User user = event.getUserSession().getUser();
        log.info("Logged in user {}", user.getLogin());
    }
}

上面提到的所有事件的事件处理器(不包括 AfterLoginEventUserSubstitutedEventUserLoggedInEvent )都可以抛出 LoginException 来中断身份验证/登录过程。

例如,可以为应用程序实现一个维护模式开关,如果维护模式处于激活状态,它将阻止登录。

@Component
public class MaintenanceModeValve {
    private volatile boolean maintenance = true;

    public boolean isMaintenance() {
        return maintenance;
    }

    public void setMaintenance(boolean maintenance) {
        this.maintenance = maintenance;
    }

    @EventListener
    protected void onBeforeLogin(BeforeLoginEvent event) throws LoginException {
        if (maintenance && event.getCredentials() instanceof AbstractClientCredentials) {
            throw new LoginException("Sorry, system is unavailable");
        }
    }
}
扩展点

可以使用以下类型的扩展点来扩展身份验证机制:

  • AuthenticationService - 替换现有的 AuthenticationServiceBean

  • AuthenticationManager - 替换现有的 AuthenticationManagerBean

  • AuthenticationProvider 实现类 - 实现额外的或替换现有的 AuthenticationProvider

  • Events - 实现事件处理.

可以使用 Spring Framework 机制替换现有 bean,例如通过在 core 模块的 Spring XML 配置中注册新 bean。

<bean id="cuba_LoginPasswordAuthenticationProvider"
      class="com.company.authext.core.CustomLoginPasswordAuthenticationProvider"/>
public class CustomLoginPasswordAuthenticationProvider extends LoginPasswordAuthenticationProvider {
    @Inject
    public CustomLoginPasswordAuthenticationProvider(Persistence persistence, Messages messages) {
        super(persistence, messages);
    }

    @Override
    public AuthenticationDetails authenticate(Credentials credentials) throws LoginException {
        LoginPasswordCredentials loginPassword = (LoginPasswordCredentials) credentials;
        // for instance, add new check before login
        if ("demo".equals(loginPassword.getLogin())) {
            throw new LoginException("Demo account is disabled");
        }

        return super.authenticate(credentials);
    }
}

事件处理器可以使用 @Order 注解来排序。所有平台 bean 和事件处理器都使用 100 到 1000 之间的 order 值,因此可以在平台代码之前或之后添加自定义处理。如果要在平台 bean 之前添加 bean 或事件处理器 - 请使用小于 100 的值。

事件处理器排序:

@Component
public class DemoEventListener {
    @Inject
    private Logger log;

    @Order(10)
    @EventListener
    protected void onUserLoggedIn(UserLoggedInEvent event) {
        log.info("Demo");
    }
}

AuthenticationProvider 可以使用 Ordered 接口并实现 getOrder() 方法。

@Component
public class DemoAuthenticationProvider extends AbstractAuthenticationProvider
        implements AuthenticationProvider, Ordered {
    @Inject
    private UserSessionManager userSessionManager;

    @Inject
    public DemoAuthenticationProvider(Persistence persistence, Messages messages) {
        super(persistence, messages);
    }

    @Nullable
    @Override
    public AuthenticationDetails authenticate(Credentials credentials) throws LoginException {
        // ...
    }

    @Override
    public boolean supports(Class<?> credentialsClass) {
        return LoginPasswordCredentials.class.isAssignableFrom(credentialsClass);
    }

    @Override
    public int getOrder() {
        return 10;
    }
}
额外功能
  • 平台具有防止暴力破解密码的机制。通过中间件上的 cuba.bruteForceProtection.enabled 应用程序属性启用保护。如果启用了保护,则在多次登录尝试失败的情况下,用户登录名和 IP 地址的组合将被限制一段时间。 用户登录名和 IP 地址组合的最大登录尝试次数由 cuba.bruteForceProtection.maxLoginAttemptsNumber 应用程序属性定义(默认值为 5)。锁定间隔时间以秒为单位由 cuba.bruteForceProtection.blockIntervalSec 应用程序属性定义(默认值为 60)。

  • 用户密码(实际上是密码哈希值)可能不存储在数据库中,而是通过外部手段验证,例如,通过 LDAP 集成的方式。在这种情况下,身份验证实际上是由客户端 block 执行的,而中间件通过使用带有 TrustedClientCredentialsAuthenticationService.login() 方法创建基于用户登录名而没有密码的会话来“信任”客户端。此方法需要满足以下条件:

  • 对于计划的自动处理程序和使用 JMX 接口连接中间件 bean 也需要登录系统。事实上,这些操作被视为管理操作,只要数据库中没有更改实体,就不需要身份验证。当实体持久化到数据库时,该过程需要正在执行更改的用户登录,以保证登录的用户对存储的更改负责。

    要求自动处理程序或 JMX 调用登录系统的另一个好处是,如果给执行线程设置了用户会话,服务器日志输出就可以显示出日志对应的用户信息。这对日志分析很有帮助,可以很方便地搜索特定处理程序产生的日志。

    中间件中处理程序对系统的访问是使用 AuthenticationManager.login()SystemUserCredentials 完成的,SystemUserCredentials 包含将要执行处理程序的用户登录信息(无密码)。最终,会在相应的中间件 block 中创建并缓存 UserSession 对象,但这个对象不会在集群中分发。

可在 系统身份验证 中查看有关中间件处理身份验证的更多信息。

3.2.11.3. 安全上下文

SecurityContext 类实例存储关于当前执行线程的用户会话信息。它在以下情况下被创建并传递给 AppContext.setSecurityContext() 方法:

  • 对于 Web 客户端 block 和 Web 门户 block - 在开始处理用户浏览器的每个 HTTP 请求时。

  • 对于中间件 block - 在开始处理来自客户端层和 CUBA 计划任务 的每个请求时。

在前两种情况下,当请求执行完成时,将从执行线程中删除 SecurityContext

如果通过应用程序代码创建一个新的执行线程,需要将当前的 SecurityContext 实例传递给它,示例如下:

final SecurityContext securityContext = AppContext.getSecurityContext();
executor.submit(new Runnable() {
    public void run() {
        AppContext.setSecurityContext(securityContext);
        // business logic here
    }
});

使用 SecurityContextAwareRunnableSecurityContextAwareCallable 封装类可以完成同样的操作,例如:

executor.submit(new SecurityContextAwareRunnable<>(() -> {
     // business logic here
}));
Future<String> future = executor.submit(new SecurityContextAwareCallable<>(() -> {
    // business logic here
    return some_string;
}));

3.2.12. 异常处理

本节介绍在 CUBA 应用程序中处理异常的各方面知识。

3.2.12.1. 异常类

创建自己的异常类时应该遵循以下规则:

  • 如果异常是业务逻辑的一部分且需要请求一些非常重要的操作来处理它,则应该使用受检异常类(继承自 Exception)。这些异常可被调用代码进行处理。

  • 如果异常表示一个错误、执行已经中断,同时进行一个简单操作(如显示一个错误信息给用户),则应使用非受检异常类 (继承自 RuntimeException)。此类异常由在应用程序客户端 block 中注册的特殊处理类进行处理。

  • 如果在同一个 block 中抛出并处理异常,则应在相应的模块中声明类。如果在中间层抛出异常并在客户端层进行处理,则应该在 global 模块中声明异常类。

平台包含一个特殊的非受检异常类 SilentException。它可用于中断执行而不向用户显示任何消息或将其写入到日志。SilentException 声明在全局模块中,因此在中间层和客户端模块都能够访问到。

3.2.12.2. 传递中间件异常

如果从中间件抛出的异常是由于处理客户端请求引发的,则中止执行并且将异常对象返回给客户端。该对象通常包括底层异常链。此链可能包含客户端层不能够访问的类(例如,JDBC 驱动异常)。因此,平台不是发送此异常链到客户端,而是将其描述存储到专门创建的 RemoteException 对象中,将 RemoteException 对象发送到客户端。

导致异常的信息存储为 RemoteException.Cause 对象列表。每个 Cause 对象都包含一个异常类名及其消息。此外,如果异常类是 "客户端支持" 的,则 Cause 也会存储异常对象。这样可以在 Exception 对象的字段中传递信息给客户端。

如果异常类的对象应该作为 Java 对象传递给客户端层,则异常类应使用 @SupportedByClient 注解。例如:

@SupportedByClient
public class WorkflowException extends RuntimeException {
...

这样,当在中间件上抛出没有使用 @SupportedByClient 注解的异常时,客户端调用代码将接收到包含字符串形式的原始异常信息的 RemoteException。 如果源异常带有 @SupportedByClient 注解,则调用者能直接接收到异常实例。这使得在客户端能够以传统方式(使用 try/catch 块)处理应用程序代码中间层服务声明的异常。

请注意,如果需要将客户端支持的异常作为对象传递给客户端。则它的 getCause() 链上不应该包含任何不支持的异常。因此,如果在中间层创建异常实例并且希望将其传递给客户端, 请确保 cause 参数包含的异常类型能被客户端识别。

ServiceInterceptor 类是一个服务拦截器,可以在异常对象传递给客户层之前将其打包。此外,它还执行异常日志记录。 默认情况下会记录有关异常的所有信息,包含完整的 stacktrace 日志。如果不需要,可添加 @Logging 注解到异常类并指定日志级别:

  • FULL – 完整信息,包括 stacktrace(默认)。

  • BRIEF – 仅包含异常类名称和消息。

  • NONE – 没有输出。

例如:

@SupportedByClient
@Logging(Logging.Type.BRIEF)
public class FinancialTransactionException extends Exception {
...
3.2.12.3. 处理客户端层的异常

在客户端层抛出或从中间层传递的未处理的异常,将被传递给 Web 客户端 block 的特殊处理机制。

一个异常处理类是实现了 UiExceptionHandler 接口的Spring bean,在其 handle() 方法中执行处理异常的逻辑并返回 true,或者如果这个处理器无法处理传递的异常,则立即返回 false。这个行为能够为处理器创建一个 "责任链"。

建议这个处理器继承 AbstractUiExceptionHandler 基类,该基类能够解析异常链(包括打包在 RemoteException 中的异常链)并且可以处理特定的异常类型。此处理器支持的异常类型是通过将字符串数组从处理器的构造器传递给基类构造器来定义的。数组中的每个字符串应包含一个要处理的异常的完整类名。

假设有如下异常类:

package com.company.demo.web;

public class ZeroBalanceException extends RuntimeException {

    public ZeroBalanceException() {
        super("Insufficient funds in your account");
    }
}

那么处理此异常的处理器必须有如下构造器:

@Component("demo_ZeroBalanceExceptionHandler")
public class ZeroBalanceExceptionHandler extends AbstractUiExceptionHandler {

    public ZeroBalanceExceptionHandler() {
        super(ZeroBalanceException.class.getName());
    }
...

如果在客户端无法访问异常类,请使用字符串指定其名称:

@Component("sample_ForeignKeyViolationExceptionHandler")
public class ForeignKeyViolationExceptionHandler extends AbstractUiExceptionHandler {

    public ForeignKeyViolationExceptionHandler() {
        super("java.sql.SQLIntegrityConstraintViolationException");
    }
...

在使用 AbstractUiExceptionHandler 作为基类的情况下,业务逻辑在 doHandle() 方法中,如下所示:

package com.company.demo.web;

import com.haulmont.cuba.gui.Notifications;
import com.haulmont.cuba.gui.exception.AbstractUiExceptionHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Nullable;

@Component("demo_ZeroBalanceExceptionHandler")
public class ZeroBalanceExceptionHandler extends AbstractUiExceptionHandler {

    public ZeroBalanceExceptionHandler() {
        super(ZeroBalanceException.class.getName());
    }

    @Override
    protected void doHandle(String className, String message, @Nullable Throwable throwable, UiContext context) {
        context.getNotifications().create(Notifications.NotificationType.ERROR)
                .withCaption("Error")
                .withDescription(message)
                .show();
    }
}

如果异常类的名称不足以确定是否可以将此处理器应用于此异常,则应该定义 canHandle() 方法。此方法也接收异常的文本信息。如果处理器能够应用于此异常,则方法必须返回 true。例如:

package com.company.demo.web.exceptions;

import com.haulmont.cuba.gui.Notifications;
import com.haulmont.cuba.gui.exception.AbstractUiExceptionHandler;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import javax.annotation.Nullable;

@Component("demo_ZeroBalanceExceptionHandler")
public class ZeroBalanceExceptionHandler extends AbstractUiExceptionHandler {

    public ZeroBalanceExceptionHandler() {
        super(ZeroBalanceException.class.getName());
    }

    @Override
    protected void doHandle(String className, String message, @Nullable Throwable throwable, UiContext context) {
        context.getNotifications().create(Notifications.NotificationType.ERROR)
                .withCaption("Error")
                .withDescription(message)
                .show();
    }

    @Override
    protected boolean canHandle(String className, String message, @Nullable Throwable throwable) {
        return StringUtils.containsIgnoreCase(message, "Insufficient funds in your account");
    }
}

通过 doHandle() 方法的 UiContext 参数可以获取到 Dialogs 接口,此接口提供了一个用来展示异常的特殊对话框,对话框中包含了一个可折叠的区域,这里能展示异常所有堆栈信息。这个对话框用在默认的异常处理器中,但是也可以为自己定义的异常使用,示例:

@Override
protected void doHandle(String className, String message, @Nullable Throwable throwable, UiContext context) {
    if (throwable != null) {
        context.getDialogs().createExceptionDialog()
                .withThrowable(throwable)
                .withCaption("Error")
                .withMessage(message)
                .show();
    } else {
        context.getNotifications().create(Notifications.NotificationType.ERROR)
                .withCaption("Error")
                .withDescription(message)
                .show();
    }
}
3.2.12.3.1. 处理违反唯一约束异常

CUBA 框架中,可以为 exception handler 展示的违反数据库约束的异常消息进行自定义。

自定义的消息需要在 web 模块的主消息包内定义,消息键值与数据库中唯一索引的名称全大写一致。示例:

IDX_SEC_USER_UNIQ_LOGIN = A user with the same login already exists

这样,界面展示的通知如下:

unique constraint message

然后添加

IDX_DEMO_PRODUCT_UNIQ_NAME = A product with this name already exists

则会看到这样的消息:

unique constraint message 2

检测数据库违反约束错误是通过 UniqueConstraintViolationHandler 类实现,根据数据库类型不同使用正则表达式做匹配。如果默认的表达式未能识别您数据库的异常,可以通过 cuba.uniqueConstraintViolationPattern 应用程序属性调整。

当然,也可以完全替换标准的 handler,将您自己的 handler 优先级调高,比如,@Order(HIGHEST_PLATFORM_PRECEDENCE - 10)

3.2.13. Bean 验证

Bean 验证是一种可选机制,可在通用 UIREST API 中提供中间件上数据的统一验证。它基于 JSR 380 - Bean Validation 2.0 及其参考实现: Hibernate Validator

3.2.13.1. 定义约束

可以使用 javax.validation.constraints 包中的注解或者自定义注解来定义约束。可以在一个实体或 POJO 类声明、字段或 getter 方法以及中间件服务方法上设置注解。

在实体字段上使用标准验证注解的示例:

@Table(name = "DEMO_CUSTOMER")
@Entity(name = "demo_Customer")
public class Customer extends StandardEntity {

    @Size(min = 3) // length of value must be longer then 3 characters
    @Column(name = "NAME", nullable = false)
    protected String name;

    @Min(1) // minimum value
    @Max(5) // maximum value
    @Column(name = "GRADE", nullable = false)
    protected Integer grade;

    @Pattern(regexp = "\\S+@\\S+") // value must conform to the pattern
    @Column(name = "EMAIL")
    protected String email;

    //...
}

使用自定义类级别注解的示例(见下文):

@CheckTaskFeasibility(groups = {Default.class, UiCrossFieldChecks.class}) // custom validation annotation
@Table(name = "DEMO_TASK")
@Entity(name = "demo_Task")
public class Task extends StandardEntity {
    //...
}

验证服务方法的参数和返回值的示例

public interface TaskService {
    String NAME = "demo_TaskService";

    @Validated // indicates that the method should be validated
    @NotNull
    String completeTask(@Size(min = 5) String comment, @Valid @NotNull Task task);
}

如果需要方法参数的级联验证,可以使用 @Valid 注解,在上面的例子中,还将验证声明在 Task 对象上的约束。

约束组

约束组允许根据应用程序逻辑仅应用所有已定义约束的子集。例如,可能想强制用户输入实体属性的值,但是同时又能够通过某种内部机制设置此属性为空,为此,应该在约束注解上指定 groups 属性。然后,只有将相同的组传递给验证机制时,约束才会生效。

平台将以下约束组传递给验证机制:

  • RestApiChecks - 在 REST API 中验证时。

  • ServiceParametersChecks - 验证服务参数时。

  • ServiceResultChecks - 验证服务返回值时。

  • UiComponentChecks - 验证单个 UI 字段时。

  • UiCrossFieldChecks - 在实体编辑器提交时进行类级别约束验证时。

  • javax.validation.groups.Default - 除了 UI 编辑器上的提交操作之外,都会传递这个组。

验证消息

约束可包含要显示给用户的消息。

消息可以直接在验证注解上设置,例如:

@Pattern(regexp = "\\S+@\\S+", message = "Invalid format")
@Column(name = "EMAIL")
protected String email;

也可以将消息放在本地化消息包中并且使用以下格式在注解中指定消息: {msg://message_pack/message_key} 或简单的 {msg://message_key} (仅用于实体中)。例如:

@Pattern(regexp = "\\S+@\\S+", message = "{msg://com.company.demo.entity/Customer.email.validationMsg}")
@Column(name = "EMAIL")
protected String email;

或者,如果为实体定义约束并且消息在实体消息包中:

@Pattern(regexp = "\\S+@\\S+", message = "{msg://Customer.email.validationMsg}")
@Column(name = "EMAIL")
protected String email;

消息可以包含参数和表达式。参数包含在 {} 中,可使用的参数包括本地化消息或注解参数,例如 {min}{max}{value}。表达式包含在 ${} 中并且可以包含验证值变量( validatedValue ) 、注解参数(如 valuemin) 和 JSR-341 (EL 3.0)表达式。例如:

@Pattern(regexp = "\\S+@\\S+", message = "Invalid email: ${validatedValue}, pattern: {regexp}")
@Column(name = "EMAIL")
protected String email;

本地化消息值也可以包含参数和表达式。

自定义约束

可以使用编程或声明式验证来创建自己的特定领域约束。

要以编程方式的验证器创建约束,请执行以下操作:

  1. 在项目的 global 模块中创建注解。使用 @Constraint 进行标注。这个注解必须包含 messagegroupspayload 属性:

    @Target({ ElementType.TYPE })
    @Retention(RUNTIME)
    @Constraint(validatedBy = TaskFeasibilityValidator.class)
    public @interface CheckTaskFeasibility {
    
        String message() default "{msg://com.company.demo.entity/CheckTaskFeasibility.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    }
  2. 在项目的 global 模块中创建验证器类:

    public class TaskFeasibilityValidator implements ConstraintValidator<CheckTaskFeasibility, Task> {
    
        @Override
        public void initialize(CheckTaskFeasibility constraintAnnotation) {
        }
    
        @Override
        public boolean isValid(Task value, ConstraintValidatorContext context) {
            Date now = AppBeans.get(TimeSource.class).currentTimestamp();
            return !(value.getDueDate().before(DateUtils.addDays(now, 3)) && value.getProgress() < 90);
        }
    }
  3. 使用注解:

    @CheckTaskFeasibility(groups = UiCrossFieldChecks.class)
    @Table(name = "DEMO_TASK")
    @Entity(name = "demo_Task")
    public class Task extends StandardEntity {
    
        @Future
        @Temporal(TemporalType.DATE)
        @Column(name = "DUE_DATE")
        protected Date dueDate;
    
        @Min(0)
        @Max(100)
        @Column(name = "PROGRESS", nullable = false)
        protected Integer progress;
    
        //...
    }

还可以使用现有的约束的组合来创建自定义约束,例如:

@NotNull
@Size(min = 2, max = 14)
@Pattern(regexp = "\\d+")
@Target({METHOD, FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = {})
public @interface ValidProductCode {
    String message() default "{msg://om.company.demo.entity/ValidProductCode.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

当使用复合约束时,生成的“约束违反”集合将包含每个约束的“约束违反”。如果想返回单个“约束违反”,请使用 @ReportAsSingleViolation 注解这个复合注解类。

CUBA 定义的验证注解

除了使用 javax.validation.constraints 包中的标准注解之外,可以使用在 CUBA 框架中定义的以下注解:

  • @RequiredView - 可以添加到服务方法定义中,以确保实体实例加载了视图中指定的所有属性。如果注解标记到方法上,则检查返回值。如果注解标记到参数上,则检查参数。如果返回值或者参数是集合,则检查集合中的所有元素。例如:

public interface MyService {
    String NAME = "sample_MyService";

    @Validated
    void processFoo(@RequiredView("foo-view") Foo foo);

    @Validated
    void processFooList(@RequiredView("foo-view") List<Foo> fooList);

    @Validated
    @RequiredView("bar-view")
    Bar loadBar(@RequiredView("foo-view") Foo foo);
}
3.2.13.2. 运行时验证
在 UI 中验证

连接到数据源的通用 UI 组件获取 BeanValidator 实例来检查字段的值。验证器是从可视化组件实现的 Component.Validatable.validate() 方法调用的。如果验证不通过,会抛出 CompositeValidationException 异常,这个异常实例中包含了一组违规信息的集合。

可以移除标准的验证器,也可以使用不同的约束组初始化标准验证器:

@UiController("sample_NewScreen")
@UiDescriptor("new-screen.xml")
public class NewScreen extends Screen {

    @Inject
    private TextField<String> field1;
    @Inject
    private TextField<String> field2;

    @Subscribe
    protected void onInit(InitEvent event) {
        field1.getValidators().stream()
                .filter(BeanPropertyValidator.class::isInstance)
                .forEach(field1::removeValidator); (1)

        field2.getValidators().stream()
                .filter(BeanPropertyValidator.class::isInstance)
                .forEach(validator -> {
                    ((BeanPropertyValidator) validator).setValidationGroups(new Class[] {UiComponentChecks.class}); (2)
                });
    }
}
1 从 UI 组件中完全删除 bean 验证。
2 这里,验证器将仅检查显式设置了 UiComponentChecks 组的约束,因为没有传递默认组。

默认情况下,BeanValidator 具有 DefaultUiComponentChecks 分组。

如果实体属性带有 @NotNull 注解且没有定义约束组,则在元数据中这个属性会被标记为强制的(mandatory),并且通过数据源使用此属性的 UI 组件将具有 required = true 属性。

DateFieldDatePicker组件使用 @Past@PastOrPresent@Future@FutureOrPresent 注解自动设置其 rangeStartrangeEnd 属性,不过这里忽略了时间部分。

如果约束包含 UiCrossFieldChecks 组并且所有属性级别的检查都通过了,编辑界面将在提交时做类级别约束的验证。可以在控制器使用 setCrossFieldValidate() 关闭此验证:

public class EventEdit extends StandardEditor<Event> {
    @Subscribe
    public void onInit(InitEvent event) {
        setCrossFieldValidate(false);
    }
}
DataManager中的验证

DataManager 可以对保存的实体实例进行验证。以下参数会影响验证:

  • cuba.dataManagerBeanValidation 应用程序属性设置设置是否进行验证的全局默认值。

  • 也可以覆盖上面的全局默认值,在 UI 界面保存数据的时候,可以在使用 DataManager.commit() 或者 DataContext.PreCommitEvent 里为 CommitContext 设置 CommitContext.ValidationMode

  • 也可以提供一个 验证组 的列表给 CommitContext 或者 DataContext.PreCommitEvent,这样可以只应用定义的约束的一部分。

中间件服务验证

如果服务接口中的方法带有 @Validated 注解,则中间件服务会对方法的参数和返回结果执行验证。例如:

public interface TaskService {
    String NAME = "demo_TaskService";

    @Validated
    @NotNull
    String completeTask(@Size(min = 5) String comment, @NotNull Task task);
}

@Validated 注解可以指定约束组以使验证应用到某组约束上,如果没有指定任何组,默认使用以下约束组:

  • DefaultServiceParametersChecks - 进行方法参数验证时

  • DefaultServiceResultChecks - 进行方法返回值验证时

在验证错误时会抛出 MethodParametersValidationExceptionMethodResultValidationException 异常。

如果要在服务中以编程的方式执行某些自定义验证,请使用 CustomValidationException 来通知客户端有关验证的错误信息,这样可以与标准 bean 验证错误信息保持相同的格式。此异常也可以跟 REST API 客户端有特定的关联。

在 REST API 中验证

对于创建和更新操作, 通常 REST API 会自动执行 bean 验证。验证错误会以如下方式返回给客户端:

  • MethodResultValidationExceptionValidationException 导致 500 Server error HTTP 状态

  • MethodParametersValidationExceptionConstraintViolationExceptionCustomValidationException 导致 400 Bad request HTTP 状态

  • 格式为 Content-Type: application/json 的响应体将包含一个对象列表,每个对象都包含属性 messagemessageTemplatepathinvalidValue 属,例如:

    [
        {
            "message": "Invalid email: aaa",
            "messageTemplate": "{msg://com.company.demo.entity/Customer.email.validationMsg}",
            "path": "email",
            "invalidValue": "aaa"
        }
    ]
    • path - 表示被验证对象的无效属性在对象关系图中的路径。

    • messageTemplate - 消息模板字符串,这个模板字符串是在 message 注解属性中定义。

    • message - 包含验证消息的实际值 。

    • invalidValue - 属性值类型是 StringDateNumberEnumUUID 中的其中之一时才返回。

以编程的方式进行验证

可以使用 BeanValidation 基础设施接口以编程的方式执行验证。该接口可在中间件和客户端层使用。它用于获取执行验证的 javax.validation.Validator 实现。验证的结果是一组 ConstraintViolation 对象。例如:

@Inject
private BeanValidation beanValidation;

public void save(Foo foo) {
    Validator validator = beanValidation.getValidator();
    Set<ConstraintViolation<Foo>> violations = validator.validate(foo);
    // ...
}

参阅我们的 blog Java中的数据验证 了解更多内容。 文章中的示例程序可以在 GitHub 找到。

3.2.14. 实体属性访问控制

安全子系统 允许根据用户权限设置对实体属性的访问。也就是说,框架可以根据分配给当前用户的角色自动将属性设置为只读或隐藏。但有时可能还想根据实体或其关联实体的当前状态动态更改对属性的访问。

属性访问控制机制允许对特定实体实例创建其属性的隐藏、只读或必须(required)规则,并自动将这些规则应用于通用 UI 组件和 REST API

该机制的工作原理如下:

  • DataManager 加载一个实体时,它会找到实现 SetupAttributeAccessHandler 接口的所有 Spring bean,并传递 SetupAttributeAccessEvent 对象作为参数调用它们的 setupAccess() 方法。此对象包含处于托管状态的已加载实例,以及三个用于存储属性名称的集合:只读属性集合、隐藏属性集合和必须属性集合(这些集合最初为空)。

  • SetupAttributeAccessHandler 接口的实现类分析实体的状态并适当地填充事件对象中的属性名称集合。这些类实际上是用于定义给定实例的属性访问规则的容器。

  • 该机制将由规则定义的属性名称保存在实体实例本身(在关联的 SecurityState 对象中)。

  • 在客户端层,通用 UI 和 REST API 使用 SecurityState 对象来控制对实体属性的访问。

要为特定实体类型创建规则,请执行以下操作:

  • 在项目的 core 模块中创建Spring Bean 并实现 SetupAttributeAccessHandler 接口。使用被处理实体的类型对接口进行参数化。bean 范围(scope)必须是默认的单例(singleton)。必须实现接口方法:

    • supports(Class) 如果处理器设计为处理给定的实体类,则返回 true。

    • setupAccess(SetupAttributeAccessEvent) 在这个方法中通过操作属性集合来设置访问权限。应该使用事件对象的 addHidden()addReadOnly()addRequired() 方法填充只读、隐藏和必须属性的集合。可通过 getEntity() 方法获得实体实例,实体实例处于托管状态,因此可以安全地访问其属性和其关联实体的属性。

例如,假设 Order 实体具有 customeramount 属性,可以根据客户(customer)创建以下规则来限制对 amount 属性的访问:

package com.company.sample.core;

import com.company.sample.entity.Order;
import com.haulmont.cuba.core.app.SetupAttributeAccessHandler;
import com.haulmont.cuba.core.app.events.SetupAttributeAccessEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component("sample_OrderAttributeAccessHandler")
public class OrderAttributeAccessHandler implements SetupAttributeAccessHandler<Order> {

    @Override
    public boolean supports(Class clazz) {
        return Order.class.isAssignableFrom(clazz);
    }

    @Override
    public void setupAccess(SetupAttributeAccessEvent<Order> event) {
        Order order = event.getEntity();
        if (order.getCustomer() != null) {
            if ("PLATINUM".equals(order.getCustomer().getGrade().getCode())) {
                event.addHidden("amount");
            } else if ("GOLD".equals(order.getCustomer().getGrade().getCode())) {
                event.addReadOnly("amount");
            }
        }
    }
}
通用 UI 中的属性访问控制

在发送BeforeShowEventAfterShowEvent事件之间,框架会自动在界面应用属性访问限制。如果不想在特定界面中使用,可以在界面控制器类添加 @DisableAttributeAccessControl 注解。

可能希望在界面打开时重新计算并应用限制,以响应用户操作。您可以使用 AttributeAccessSupport bean 来完成它,传递当前界面和状态已更改的实体。例如:

@UiController("sales_Order.edit")
@UiDescriptor("order-edit.xml")
@EditedEntityContainer("orderDc")
@LoadDataBeforeShow
public class OrderEdit extends StandardEditor<Order> {

    @Inject
    private AttributeAccessSupport attributeAccessSupport;

    @Subscribe(id = "orderDc", target = Target.DATA_CONTAINER)
    protected void onOrderDcItemPropertyChange(InstanceContainer.ItemPropertyChangeEvent<Order> event) {
        if ("customer".equals(event.getProperty())) {
            attributeAccessSupport.applyAttributeAccess(this, true, getEditedEntity());
        }
    }

}

applyAttributeAccess() 方法的第二个参数是一个布尔值,它指定在应用新限制之前是否将组件访问权限重置为默认值。如果参数值为 true,则程序中对组件状态的更改(如果有)将丢失。在界面打开时自动调用该方法时,此参数的值为 false。但是在响应 UI 事件时调用该方法时,将其设置为 true,否则对组件的限制将被累加而不是替换。

属性访问限制仅适用于绑定到单个实体属性的组件,如 TextFieldLookupFieldTable 和实现 ListComponent 接口的其它组件不受影响。因此,如果要编写可以隐藏多个实体实例的一个属性的规则,建议直接不要在表格中显示此属性。

3.3. 数据库

本节介绍如何配置数据库连接以使用特定的 DBMS。同时介绍了一种数据库迁移的机制,该机制可以创建数据库,并在应用程序开发和上线运行后的整个周期中使其保持最新。

数据库相关的组件属于 Middleware block;应用程序的其它 block 无法直接访问数据库。

3.3.1. 连接至数据库

CUBA 应用程序通过 JDBC DataSource-数据源 获取数据库的连接。一个数据源可以在应用程序中配置,也可以通过 JNDI 获取。获取数据源的方法通过应用程序属性 cuba.dataSourceProvider 来指定:可以设置为 applicationjndi

使用 CUBA Studio 可以很方便的配置主数据库连接和附加数据存储,参阅 文档 。下面的信息对于排查问题很有帮助,也可以用来定义那些 Studio 中没有的参数,比如连接池配置。

在应用程序中配置数据源

当在应用程序中配置数据源时,框架会使用 HikariCP 创建一个连接池。连接参数和连接池配置都通过 core 模块 app.properties 中的应用程序属性来配置。如果不需要特别指定由应用程序服务提供的连接池,推荐直接使用这种配置。

下列应用程序属性定义数据库类型和连接参数:

  • cuba.dbmsType - 定义 DBMS 类型

  • cuba.dataSourceProvider - application 值表示数据源必须使用应用程序属性来配置。

  • cuba.dataSource.username - 数据库的用户名称。

  • cuba.dataSource.password - 数据库的用户密码。

  • cuba.dataSource.dbName - 数据库的名称。

  • cuba.dataSource.host - 数据库的地址。

  • cuba.dataSource.port - 可选参数,设定数据库端口,如果使用了非标准端口的话。

  • cuba.dataSource.jdbcUrl - 可选参数,设置 JDBC URL 的全路径,如果需要一些额外的连接参数。注意,对于数据迁移任务,所有以上的单独属性还是需要配置的。

如需配置连接池参数,使用 cuba.dataSource. 前缀指定 HikariCP 的属性,比如 cuba.dataSource.maximumPoolSizecuba.dataSource.connectionTimeout。参考 HikariCP 文档 了解所有支持的参数及其默认值。

如果应用程序使用了附加数据存储,需要同样为每个数据存储定义一组参数。数据存储的名称添加到每个属性名称的第二部分:

示例:

# main data store connection parameters
cuba.dbmsType = hsql
cuba.dataSourceProvider = application
cuba.dataSource.username = sa
cuba.dataSource.password =
cuba.dataSource.dbName = demo
cuba.dataSource.host = localhost
cuba.dataSource.port = 9111
cuba.dataSource.maximumPoolSize = 20

# names of additional data stores
cuba.additionalStores = clients,orders

# 'clients' data store connection parameters
cuba.dbmsType_clients = postgres
cuba.dataSourceProvider_clients = application
cuba.dataSource_clients.username = postgres
cuba.dataSource_clients.password = postgres
cuba.dataSource_clients.dbName = clients_db
cuba.dataSource_clients.host = localhost

# 'orders' data store connection parameters
cuba.dbmsType_orders = mssql
cuba.dataSourceProvider_orders = application
cuba.dataSource_orders.jdbcUrl = jdbc:sqlserver://localhost;databaseName=orders_db;currentSchema=my_schema
cuba.dataSource_orders.username = sa
cuba.dataSource_orders.password = myPass123
cuba.dataSource_orders.dbName = orders_db
cuba.dataSource_orders.host = localhost

另外,对于每个附加数据存储,core 模块的 spring.xml 文件必须包含一个 CubaDataSourceFactoryBean bean 的定义,该定义需要使用合适的 storeName 参数,示例:

<bean id="cubaDataSource_clients" class="com.haulmont.cuba.core.sys.CubaDataSourceFactoryBean">
    <property name="storeName" value="clients"/>
</bean>

<bean id="cubaDataSource_orders" class="com.haulmont.cuba.core.sys.CubaDataSourceFactoryBean">
    <property name="storeName" value="orders"/>
</bean>

如果在应用程序中配置数据源,数据库迁移的 Gradle 任务可能没有参数,因为这些参数会通过应用程序属性获取。这是在应用程序中配置数据源的另外一个好处。示例:

task createDb(dependsOn: assembleDbScripts, description: 'Creates local database', type: CubaDbCreation) {
}

task updateDb(dependsOn: assembleDbScripts, description: 'Updates local database', type: CubaDbUpdate) {
}
从 JNDI 获取数据源

如果需要通过 JNDI 使用由应用程序服务提供的数据源,需要在 core 模块的 app.properties 文件定义以下应用程序属性:

  • cuba.dbmsType - 定义 DBMS 类型

  • cuba.dataSourceProvider - jndi 值表示数据源必须通过 JNDI 获取。

数据源的 JNDI 名称通过 cuba.dataSourceJndiName 应用程序属性指定,默认为 java:comp/env/jdbc/CubaDS。对于附加数据存储,定义同样的属性,但是需要添加数据存储名称。

示例:

# main data store connection parameters
cuba.dbmsType = hsql
cuba.dataSourceProvider = jndi

# names of additional data stores
cuba.additionalStores = clients,orders

# 'clients' data store connection parameters
cuba.dbmsType_clients = postgres
cuba.dataSourceProvider_clients = jndi
cuba.dataSourceJndiName_clients = jdbc/ClientsDS

# 'orders' data store connection parameters
cuba.dbmsType_orders = mssql
cuba.dataSourceProvider_orders = jndi
cuba.dataSourceJndiName_orders = jdbc/OrdersDS

另外,对于每个附加数据存储,core 模块的 spring.xml 文件必须包含一个 CubaDataSourceFactoryBean bean 的定义,该定义需要使用合适的 storeName 参数和 jndiNameAppProperty 参数,示例:

<bean id="cubaDataSource_clients" class="com.haulmont.cuba.core.sys.CubaDataSourceFactoryBean">
    <property name="storeName" value="clients"/>
    <property name="jndiNameAppProperty" value="cuba.dataSourceJndiName_clients"/>
</bean>

<bean id="cubaDataSource_orders" class="com.haulmont.cuba.core.sys.CubaDataSourceFactoryBean">
    <property name="storeName" value="orders"/>
    <property name="jndiNameAppProperty" value="cuba.dataSourceJndiName_orders"/>
</bean>

通过 JNDI 提供的数据源需要根据应用程序服务做特殊的配置。在 Tomcat 中,配置在 context.xml 文件。CUBA Studio 会将连接参数写入 modules/core/web/META-INF/context.xml 并在开发应用程序时通过标准部署过程使用该文件。

如果数据源配置在 context.xml,数据库迁移的 Gradle 任务必须有定义数据库连接的参数,示例:

task createDb(dependsOn: assembleDbScripts, description: 'Creates local database', type: CubaDbCreation) {
    dbms = 'hsql'
    host = 'localhost:9111'
    dbName = 'demo'
    dbUser = 'sa'
    dbPassword = ''
}

task updateDb(dependsOn: assembleDbScripts, description: 'Updates local database', type: CubaDbUpdate) {
    dbms = 'hsql'
    host = 'localhost:9111'
    dbName = 'demo'
    dbUser = 'sa'
    dbPassword = ''
}
3.3.1.1. 连接到非默认数据库schema

PostgreSQL 和 Microsoft SQL Server 支持连接到指定的数据库架构。默认情况下,PostgreSQL 会连接到 public,SQL Server 会连接到 dbo

PostgreSQL

如果使用的 Studio,在 Data Store Properties 窗口的 Connection params 字段添加 currentSchema 连接参数。Studio 会根据数据源的配置方法自动更新项目文件。否则,需要按照下面的介绍手动配置连接参数。

如果在应用程序中配置数据源,添加 URL 属性全路径,示例:

cuba.dataSource.jdbcUrl = jdbc:postgresql://localhost/demo?currentSchema=my_schema

如果是从 JNDI 获取数据源,需要在数据源定义的连接 URL 添加参数 currentSchema(Tomcat 中是在 context.xml 文件),还需要为 createDbupdateDb Gradle 任务添加 connectionParams 属性。示例:

task createDb(dependsOn: assembleDbScripts, type: CubaDbCreation) {
    dbms = 'postgres'
    host = 'localhost'
    dbName = 'demo'
    connectionParams = '?currentSchema=my_schema'
    dbUser = 'postgres'
    dbPassword = 'postgres'
}

现在可以更新或者重新创建数据库,所有的表格会在指定的 schema 中创建。

Microsoft SQL Server

此时,只提供一个连接属性就不够了,需要将 schema 和用户做关联。下面是创建一个新数据库并使用非默认 schema 的例子。

  • 创建一个 login:

    create login JohnDoe with password='saPass1'
  • 创建一个新数据库:

    create database my_db
  • sa 连接至新数据库,创建一个 schema,然后创建用户并赋予权限:

    create schema my_schema
    
    create user JohnDoe for login JohnDoe with default_schema = my_schema
    
    exec sp_addrolemember 'db_owner', 'JohnDoe'

如果是使用 Studio,在 Data Store Properties 窗口的 Connection params 字段添加 currentSchema 连接参数。Studio 会根据数据源的配置方法自动更新项目文件。否则,需要按照下面的介绍手动配置连接参数。

如果在应用程序中配置数据源,添加 URL 属性全路径,示例:

cuba.dataSource.jdbcUrl = jdbc:sqlserver://localhost;databaseName=demo;currentSchema=my_schema

如果是从 JNDI 获取数据源,需要在数据源定义的连接 URL 添加参数 currentSchema(Tomcat 中是在 context.xml 文件),还需要为 createDbupdateDb Gradle 任务添加 connectionParams 属性。

task updateDb(dependsOn: assembleDbScripts, type: CubaDbUpdate) {
    dbms = 'mssql'
    dbmsVersion = '2012'
    host = 'localhost'
    dbName = 'demo'
    connectionParams = ';currentSchema=my_schema'
    dbUser = 'JohnDoe'
    dbPassword = 'saPass1'
}

请注意,由于 SQL Server 的特性 - 非默认 schema 需要与用户关联,所以无法从 Studio 或在命令行中执行 createDb 来重新创建 SQL Server 数据库。但是,如果在 Studio 中运行 Update database 或在命令行中运行 updateDb,则现有数据库中指定 schema 下所有必须的表都会被创建。

3.3.2. DBMS 类型

应用程序中使用的 DBMS 的类型由cuba.dbmsTypecuba.dbmsVersion(可选)应用程序属性定义。这些属性会影响各种依赖于数据库类型的平台机制。

应用程序通过 javax.sql.DataSource 连接数据库,javax.sql.DataSource 是通过cuba.dataSourceJndiNamecuba.dataSourceJndiName中指定的名称(默认情况下是 java:comp/env/jdbc/CubaDS)从 JNDI 中获取的。标准部署方式的数据源配置在 core 模块的 context.xml 文件中定义。数据源应使用适用于所选 DBMS 的 JDBC 驱动程序。

平台支持以下“开箱即用”的 DBMS 类型:

cuba.dbmsType cuba.dbmsVersion JDBC driver

HSQLDB

hsql

org.hsqldb.jdbc.JDBCDriver

PostgreSQL 8.4+

postgres

org.postgresql.Driver

Microsoft SQL Server 2005

mssql

2005

net.sourceforge.jtds.jdbc.Driver

Microsoft SQL Server 2008

mssql

com.microsoft.sqlserver.jdbc.SQLServerDriver

Microsoft SQL Server 2012+

mssql

2012

com.microsoft.sqlserver.jdbc.SQLServerDriver

Oracle Database 11g+

oracle

oracle.jdbc.OracleDriver

MySQL 5.6+

mysql

com.mysql.jdbc.Driver

MariaDB 5.5+

mysql

org.mariadb.jdbc.Driver

下表描述了 Java 中的实体属性与不同 DBMS 中的表列之间推荐的数据类型映射关系。生成创建和更新数据库的脚本时,CUBA Studio 会自动使用这些类型。使用这些类型,可以保证所有平台机制正常运行。

Java HSQL PostgreSQL MS SQL Server Oracle MySQL MariaDB

UUID

varchar(36)

uuid

uniqueidentifier

varchar2(32)

varchar(32)

varchar(32)

Date

timestamp

timestamp

datetime

timestamp

datetime(3)

datetime(3)

java.sql.Date

timestamp

date

datetime

date

date

date

java.sql.Time

timestamp

time

datetime

timestamp

time(3)

time(3)

BigDecimal

decimal(p, s)

decimal(p, s)

decimal(p, s)

number(p, s)

decimal(p, s)

decimal(p, s)

Double

double precision

double precision

double precision

float

double precision

double precision

Long

bigint

bigint

bigint

number(19)

bigint

bigint

Integer

integer

integer

integer

integer

integer

integer

Boolean

boolean

boolean

tinyint

char(1)

boolean

boolean

String (limited)

varchar(n)

varchar(n)

varchar(n)

varchar2(n)

varchar(n)

varchar(n)

String (unlimited)

longvarchar

text

varchar(max)

clob

longtext

longtext

byte[]

longvarbinary

bytea

image

blob

longblob

longblob

通常,在数据库和 Java 代码之间转换数据的整个工作由ORM 层使用合适 JDBC 驱动程序来完成。这意味着使用EntityManager方法和JPQL 查询处理数据时不需要手动转换数据;对于开发人员来说,在编写与数据库交互的代码时应避免使用表格左栏没有列出的 Java 类型。

当通过 EntityManager.createNativeQuery()QueryRunner 使用本地 SQL 时,Java 代码中的某些类型将与上面提到的类型不同,具体取决于所使用的 DBMS。特别是对于 UUID 类型的属性 - 只有 PostgreSQL 驱动程序使用此类型返回相应列的值; 其它数据库服务都返回 String。要对不同的数据库类型抽象出通用应用程序代码,建议使用DbTypeConverter接口转换参数类型和查询结果。

3.3.2.1. 对其它 DBMS 的支持

在应用程序项目中,可以使用ORM框架(EclipseLink)支持的任何 DBMS。请按照以下步骤操作:

  • cuba.dbmsType 属性中以任意形式代码的指定数据库的类型。代码必须与平台中使用的代码不同:hsqlpostgresmssqloracle

  • 实现 DbmsFeaturesSequenceSupportDbTypeConverter 接口,实现类用以下格式命名:<Type>DbmsFeatures<Type>SequenceSupport<Type>DbTypeConverter,其中 Type 是 DBMS 类型代码。实现类的包必须与接口的包相同。

  • 如果在应用程序中配置了数据源,按照 连接至数据库 章节的介绍和要求的格式配置连接 URL 的全路径。

  • 在以 DBMS 类型代码命名的目录中创建数据库初始化和更新脚本。初始化脚本必须创建平台实体所需的所有数据库对象(可以从现有的 10-cuba 等目录中复制并修改这些脚本,使其适用于新的 DBMS 类型)。

  • 要通过 Gradle 任务创建和更新数据库,需要在 build.gradle 中为这些任务指定额外的参数:

    task createDb(dependsOn: assemble, type: CubaDbCreation) {
        dbms = 'my'                                            // DBMS code
        driver = 'net.my.jdbc.Driver'                          // JDBC driver class
        dbUrl = 'jdbc:my:myserver://192.168.47.45/mydb'        // Database URL
        masterUrl = 'jdbc:my:myserver://192.168.47.45/master'  // URL of a master DB to connect to for creating the application DB
        dropDbSql = 'drop database mydb;'                      // Drop database statement
        createDbSql = 'create database mydb;'                  // Create database statement
        timeStampType = 'datetime'                             // Date and time datatype - needed for SYS_DB_CHANGELOG table creation
        dbUser = 'sa'
        dbPassword = 'saPass1'
    }
    
    task updateDb(dependsOn: assemble, type: CubaDbUpdate) {
        dbms = 'my'                                            // DBMS code
        driver = 'net.my.jdbc.Driver'                          // JDBC driver class
        dbUrl = 'jdbc:my:myserver://192.168.47.45/mydb'        // Database URL
        dbUser = 'sa'
        dbPassword = 'saPass1'
    }

也可以在 CUBA Studio 中添加自定义数据库的支持。实现了集成之后,开发者可以在 Studio 中使用标准的对话框修改数据存储的设置。还有,最重要的是,Studio 能为平台、扩展插件以及项目中定义的实体自动生成数据库迁移脚本。集成自定义数据库的介绍在 Studio 用户向导 的相应章节。

在 CUBA 平台和 Studio 集成自定义数据库(Firebird)的示例,可以参考: https://github.com/cuba-labs/firebird-sample

3.3.2.2. DBMS 版本

除了 cuba.dbmsType 应用程序属性外,还有一个可选的cuba.dbmsVersion属性。数据库版本属性影响对 DbmsFeaturesSequenceSupportDbTypeConverter 的接口实现的选择,同时也影响对数据库初始化和更新脚本的搜索。

这些集成接口实现类的名称结构如下:<Type><Version><Name>。这里 Typecuba.dbmsType 属性的值(大写),Versioncuba.dbmsVersion 的值,Name 是接口名称。类的包必须与接口的包一致。如果这个名称的类不可用,则会尝试查找名称中不带版本的类:<Type><Name>。如果这样的类也不存在,则会抛出异常。

例如,在平台中定义了 com.haulmont.cuba.core.sys.persistence.Mssql2012SequenceSupport 类。如果在项目中指定了以下属性,则此类会生效:

cuba.dbmsType = mssql
cuba.dbmsVersion = 2012

对于数据库初始和更新脚本的搜索,<type>-<version> 目录优先于 type 目录。这意味着 <type>-<version> 目录中的脚本会代替 <type> 目录中具有相同名称的脚本。<type>-<version> 目录还可以包含一些具有唯一名称的脚本;它们也会被添加到公共的脚本集中以供执行。脚本按照路径排序,从 <type><type>-<version> 目录的第一个子目录开始,即不管脚本在哪个的目录(是否有版本)。

例如,对于 Microsoft SQL Server 2012 之前和之后版本的数据库,其初始化脚本应如下所示:

modules/core/db/init/
   mssql/
       10.create-db.sql
       20.create-db.sql
       30.create-db.sql
   mssql-2012/
       10.create-db.sql

3.3.3. 创建和更新数据库的脚本

CUBA 应用程序项目总是包含两组脚本:

  • 用于创建数据库的脚本,该脚本用于从零开始创建数据库。它们包含一组 DDL 和 DML 操作,这些操作会创建一个空数据库结构,该结构与应用程序数据模型的当前状态完全一致。这些脚本还可以使用必要的初始化数据填充数据库。

  • 用于更新数据库的脚本,用于将数据库结构从任意一个旧的结构更新为与当前数据模型对应的结构。

更改数据模型时,必须通过 Create 和 Update 脚本使数据库结构同步相应的更改。例如,在将 address 属性添加到 Customer 实体时,有必要:

  1. 更改建表脚本:

    create table SALES_CUSTOMER (
      ID varchar(36) not null,
      CREATE_TS timestamp,
      CREATED_BY varchar(50),
      NAME varchar(100),
      ADDRESS varchar(200), -- added column
      primary key (ID)
    )
  2. 添加修改同一个表的更新脚本:

    alter table SALES_CUSTOMER add ADDRESS varchar(200)

    请注意,Studio 更新脚本生成器不会跟踪属性 Column definition 和自定义数据类型sqlType 的更改。因此,如果更改了它们,请手动创建相应的更新脚本。

主数据存储的创建脚本位于 core 模块的 /db/init 目录下。附加数据存储(如果有)的创建脚本位于 /db/init_<datastore_name> 目录。对于应用程序支持的每种类型的 DBMS,都会创建一组单独的脚本,这些脚本位于cuba.dbmsType应用程序属性中指定的子目录中,例如 /db/init/postgres。创建脚本的名称应该符合以下格式:{optional_prefix}create-db.sql

主数据存储的更新脚本位于 core 模块的 /db/update 目录中。附加数据存储(如果有)的更新脚本位于 /db/update_<datastore_name> 目录。对于应用程序支持的每种类型的 DBMS,都会创建一组单独的脚本,这些脚本位于cuba.dbmsType应用程序属性中指定的子目录中,例如 /db/update/postgres

更新脚本有两种类型:使用 *.sql 扩展名或 *.groovy 扩展名。更新数据库的主要方法是使用 SQL 脚本。Groovy 脚本仅由数据库脚本服务器执行机制执行,因此它们主要用于生产环境,在数据迁移或导入无法使用纯 SQL 实现时。如果要跳过 Groovy 更新脚本,可以在命令行中运行以下命令:

delete from sys_db_changelog where script_name like '%groovy' and create_ts > (now() - interval '1 hour')

更新脚本应具有特定格式的名称,更新机制会按脚本名称的字母顺序排序以形成正确的执行顺序(一般情况下,脚本名称根据创建时间来命名)。因此,在手动创建此类脚本时,建议使用以下格式指定更新脚本的名称: {yymmdd}-{description}.sql,其中 yy 表示年份,mm 是表示月份,dd 表示日期,description 是脚本的简短描述。例如,121003-addCodeToCategoryAttribute.sql。Studio 自动生成脚本时也遵循此格式。

要使用 updateDb 任务(gradle task)执行 groovy 更新脚本,groovy 脚本的扩展名应该是 .upgrade.groovy, 并且遵循相同的命名规则。注意在此时,该脚本中不允许 Post Update 操作,相同的 ds(访问数据源)和 log(访问日志记录)变量需要用来做数据绑定。可以通过在 build.gradleupdateDb 任务中设置 executeGroovy = false 来禁用 groovy 脚本的执行。

可以将更新脚本分组到子目录中,但是,带有子目录的脚本路径不应该违背按时间排序的顺序。例如,可以使用年份或年份和月份创建子目录。

在已部署的应用程序中,用于创建和更新数据库的脚本位于特定的数据库脚本目录中,该目录由cuba.dbDir应用程序属性设置。

3.3.3.1. SQL 脚本的结构

数据库迁移的 SQL 脚本是一组由 “^” 字符分隔的 DDL 和 DML 命令组成的文本文件。这里使用了 “^” 字符,以便 “;” 分隔符可以用来分隔复杂的命令;例如,在创建函数或触发器时。脚本执行机制使用 “^” 分隔符将输入文件拆分为单独的命令,并在独立的事务中执行每个命令。这意味着如果需要的话可以将几条单独的语句(例如,insert)组合在一起,用分号分隔,确保它们在同一个事务中执行。

“^” 分隔符可以通过使用两个 “^” 来转义。例如,如果要将 ^[0-9\s]+$ 传递给语句,脚本应包含 ^^[0-9\s]+$

SQL 格式的更新脚本示例:

create table LIBRARY_COUNTRY (
  ID varchar(36) not null,
  CREATE_TS time,
  CREATED_BY varchar(50),
  NAME varchar(100) not null,
  primary key (ID)
)^

alter table LIBRARY_TOWN add column COUNTRY_ID varchar(36) ^
alter table LIBRARY_TOWN add constraint FK_LIBRARY_TOWN_COUNTRY_ID foreign key (COUNTRY_ID) references LIBRARY_COUNTRY(ID)^
create index IDX_LIBRARY_TOWN_COUNTRY on LIBRARY_TOWN (COUNTRY_ID)^
3.3.3.2. Groovy 脚本的结构

Groovy 更新脚本的结构如下:

  • 主要(main) 部分,包含要在应用程序上下文启动之前执行的代码。在这一部分,可以使用任何 Java、Groovy 和 Middleware 应用程序块(block)中的类。但要注意,这时尚未实例化 bean、基础设施接口和其它应用程序对象,所以无法使用它们。

    这部分主要用于更新数据库 schema,通常使用普通的 SQL 脚本。

  • PostUpdate 部分 - 一组闭包(Groovy 中的概念),将在应用程序上下文启动后和更新过程完成后执行。在这些闭包中,可以使用任何中间件对象。

    在脚本的这一部分中,可以比较方便地执行数据导入,因为可以使用Persistence接口和数据模型对象。

执行机制将以下变量传递给 Groovy 脚本:

  • ds – 用于应用程序数据库的 javax.sql.DataSource 实例;

  • logorg.apache.commons.logging.Log 实例,用于在服务端日志中输出信息;

  • postUpdate – 包含 add(Closure closure) 方法的对象,用于添加上述 PostUpdate 闭包。

Groovy 脚本仅由执行数据库脚本的服务端机制执行。

Groovy 更新脚本的示例:

import com.haulmont.cuba.core.Persistence
import com.haulmont.cuba.core.global.AppBeans
import com.haulmont.refapp.core.entity.Colour
import groovy.sql.Sql

log.info('Executing actions in update phase')

Sql sql = new Sql(ds)
sql.execute """ alter table MY_COLOR add DESCRIPTION varchar(100); """

// Add post update action
postUpdate.add({
    log.info('Executing post update action using fully functioning server')

    def p = AppBeans.get(Persistence.class)
    def tr = p.createTransaction()
    try {
        def em = p.getEntityManager()

        Colour c = new Colour()
        c.name = 'yellow'
        c.description = 'a description'

        em.persist(c)
        tr.commit()
    } finally {
        tr.end()
    }
})
3.3.3.3. Gradle 任务执行数据库脚本

应用程序开发人员通常使用此机制来更新自己的数据库实例。脚本的执行本质上是通过运行定义在build.gradle中特定的 Gradle 任务。这个操作可以通过命令行或 Studio 界面完成。

要运行脚本创建数据库,需使用 createDb 任务。在 Studio 中,它对应于主菜单中的 CUBA > Create Database 命令。启动此任务时,会执行以下操作:

  1. 当前项目的应用程序组件的脚本和 core 模块的 db/**/*.sql 脚本被构建到 modules/core/build/db 目录中。应用程序组件的脚本集位于有数字前缀的子目录中。前缀用于根据组件之间的依赖关系提供脚本执行的字母排序。

  2. 如果数据库存在,它会被完全清除。一个新的空数据库会被创建。

  3. modules/core/build/db/init/**/*create-db.sql 子目录中的所有创建脚本按字母顺序依次执行,并且它们的名称以及相对于 db 目录的路径会注册在 SYS_DB_CHANGELOG 表中。

  4. 类似地,所有当前可用的 modules/core/build/db/update/**/*.sql 更新脚本会注册在 SYS_DB_CHANGELOG 表中。这对于后续进行数据库增量更新是必须的。

要运行脚本更新数据库,需使用 updateDb 任务。在 Studio 中,它对应于主菜单中的 CUBA > Update Database 命令。启动此任务时,会执行以下操作:

  1. 脚本的构建方式与上述 createDb 命令的构建方式相同。

  2. 执行机制检查是否已运行应用程序组件的所有创建脚本(通过检查 SYS_DB_CHANGELOG 表)。如果没有,则执行应用程序组件创建脚本并在 SYS_DB_CHANGELOG 表中注册。

  3. modules/core/build/db/update/** 目录中搜索未在 SYS_DB_CHANGELOG 表中注册的更新脚本,即以前没有执行过的更新脚本。

  4. 对上一步中找到的所有脚本按字母顺序依次执行,同时脚本的名称以及相对于 db 目录的路径都会注册到 SYS_DB_CHANGELOG 表中。

3.3.3.4. 在 web Server 中执行数据库脚本

Web Server 执行数据库脚本的机制用于更新数据库,这个操作在应用程序服务启动、中间件块(block)初始化期间激活。显然,应用程序应该已经构建并部署在 Web Server 上,即在生产环境或开发人员的 Tomcat 实例中。

根据下面描述的条件,该机制执行创建或更新脚本,也就是它可以从头开始初始化 DB 并对其进行更新。但是,与上一节中描述的 Gradle createDb 任务不同,数据库必须存在才能初始化 - 在 Web Server 中不会自动创建 DB,而只是执行脚本。

Web Server 中执行脚本的机制如下:

  • 应用程序从数据库脚本目录读取脚本,该目录由cuba.dbDir应用程序属性定义,该属性的默认设置为 WEB-INF/db

  • 如果数据库没有 SEC_USER 表,则被视为空数据库,会使用创建脚本运行完整初始化过程。执行初始化脚本后,这些脚本的名称会存储在 SYS_DB_CHANGELOG 表中。所有可用的更新脚本的名称也存储在同一个表中,但是并没有执行过这些更新脚本

  • 如果数据库有 SEC_USER 表,但没有 SYS_DB_CHANGELOG 表(当在现有生产环境数据库上首次启动这里描述的机制时就会出现这种情况),这种情况下不执行任何脚本,而是创建 SYS_DB_CHANGELOG 表,并存储所有当前可用的创建和更新脚本的名称。

  • 如果数据库同时具有 SEC_USERSYS_DB_CHANGELOG 表,则执行先前未将名称存储在 SYS_DB_CHANGELOG 表中的更新脚本,然后将这些脚本名称存储到 SYS_DB_CHANGELOG 表中。脚本执行的顺序由两个因素决定:应用程序组件的优先级(参阅数据库脚本目录: 10-cuba20-bpm,…​)和按字母顺序排序的脚本文件名称(参考 update 目录的子目录)。

    在执行更新脚本之前,先检查是否已运行应用程序组件的所有创建脚本(通过检查 SYS_DB_CHANGELOG 表)。如果某个应用程序组件使用的数据库未初始化,则会执行其创建脚本。

通过cuba.automaticDatabaseUpdate应用程序属性启用在服务器启动时执行脚本的机制。

在运行中的应用程序中,可以使用 update 作为参数调用 app-core.cuba:type=PersistenceManager JMX bean 的 updateDatabase() 方法来启动脚本执行机制。显然,这种方式只能更新现有的 DB,因为无法登录只有空 DB 的系统来运行 JMX bean 方法。请注意,如果部分在中间件启动或用户登录期间初始化数据模型与数据架构不匹配的话,会发生不可恢复的错误。这就是一般都在服务器启动时数据模型初始化前执行数据库自动更新的原因。

JMX app-core.cuba:type=PersistenceManager bean 还有一个与 DB 更新机制相关的方法: findUpdateDatabaseScripts()。它返回目录中可用的但未在 DB 中注册(尚未执行)新的更新脚本列表。

有关使用服务器数据库更新机制的建议,请参阅在生产环境中创建和更新数据库

3.4. 中间件组件

下图显示了 CUBA 应用程序中间件的主要组件。

Middleware
Figure 9. 中间件组件

服务(Service)Spring beans,用于形成应用边界并为客户端提供接口。服务自身可以包含业务逻辑,也可以将业务逻辑的实现委托给托管 bean。

托管 Bean是 Spring beans,包含应用程序的业务逻辑。它们由服务、其它 bean 或通过可选的JMX接口调用。

Persistence是一个基础设施接口,用于访问数据存储功能:ORM事务管理。

3.4.1. 服务

服务构成了应用程序的一层,在这一层定义了客户端可用的一组中间层操作。换句话说,服务是中间层业务逻辑的入口点。在服务中,可以管理事务、检测用户权限、使用数据库或将操作委托给中间层的其它Spring Bean去执行。

下图展示了服务层组件的类关系:

MiddlewareServices

服务接口位于 global 模块中,所以在中间层和客户端层都是可用的。在运行时,在客户端层会为服务接口创建代理。代理使用 Spring HTTP Invoker 机制提供服务 bean 方法的调用。

服务实现 bean 位于 core 模块,仅在中间层可用。

使用 Spring AOP 的任何服务方法都会自动调用 ServiceInterceptor。它检测当前线程中用户会话的可用性,并且会在从客户端层调用服务时执行转换和记录异常。

3.4.1.1. 创建服务

参考 在 CUBA 中创建业务逻辑 指南,了解如何将业务逻辑做成中间件服务。

服务接口的名称应以 Service 结尾,实现类的名称应以 ServiceBean 结尾。

CUBA Studio 能帮助轻松创建服务接口的脚手架代码和存根类。Studio 还会自动在 spring.xml 中注册新服务。要创建服务,请使用 CUBA 项目树的 Middleware 节点中的 New>Service 任务。

如果要手动创建服务,请按照以下步骤操作。

  1. global 模块中创建服务接口(因为服务接口必须在所有中可用),并在其中指定服务名称。建议使用以下格式指定名称:{project_name}_{interface_name}。例如:

    package com.sample.sales.core;
    
    import com.sample.sales.entity.Order;
    
    public interface OrderService {
        String NAME = "sales_OrderService";
    
        void calculateTotals(Order order);
    }
  2. core 模块中创建服务类,并使用接口中指定的名称向其添加 @org.springframework.stereotype.Service 注解:

    package com.sample.sales.core;
    
    import com.sample.sales.entity.Order;
    import org.springframework.stereotype.Service;
    
    @Service(OrderService.NAME)
    public class OrderServiceBean implements OrderService {
        @Override
        public void calculateTotals(Order order) {
        }
    }

作为Spring bean 的服务类应放在包的树结构中,其根目录需要在spring.xml文件的 context:component-scan 元素中指定。这时,spring.xml 文件会包含以下元素:

<context:component-scan base-package="com.sample.sales"/>

这意味着将从 com.sample.sales 包开始搜索此应用程序 block 中带注解的 bean。

如果不同的服务或其它中间件组件需要调用相同的业务逻辑,则应将其提取并封装在适当的Spring bean中。例如:

// service interface
public interface SalesService {
    String NAME = "sample_SalesService";

    BigDecimal calculateSales(UUID customerId);
}
// service implementation
@Service(SalesService.NAME)
public class SalesServiceBean implements SalesService {

    @Inject
    private SalesCalculator salesCalculator;

    @Transactional
    @Override
    public BigDecimal calculateSales(UUID customerId) {
        return salesCalculator.calculateSales(customerId);
    }
}
// managed bean encapsulating business logic
@Component
public class SalesCalculator {

    @Inject
    private Persistence persistence;

    public BigDecimal calculateSales(UUID customerId) {
        Query query = persistence.getEntityManager().createQuery(
                "select sum(o.amount) from sample_Order o where o.customer.id = :customerId");
        query.setParameter("customerId", customerId);
        return (BigDecimal) query.getFirstResult();
    }
}
3.4.1.2. 使用服务

为了调用服务,应该在应用程序的客户端 block 中创建相应的代理对象。各个 block 中都有一个特殊的工厂可以创建服务代理,Web 客户端:WebRemoteProxyBeanCreator、Web 门户:PortalRemoteProxyBeanCreator

代理对象工厂在相应客户端 block 的spring.xml中配置,并包含服务名称和接口。

例如,要从 sales 应用程序中的 Web 客户端调用 sales_OrderService 服务,请将以下代码添加到 web 模块的 web-spring.xml 文件中:

<bean id="sales_proxyCreator" class="com.haulmont.cuba.web.sys.remoting.WebRemoteProxyBeanCreator">
    <property name="serverSelector" ref="cuba_ServerSelector"/>
    <property name="remoteServices">
        <map>
            <entry key="sales_OrderService" value="com.sample.sales.core.OrderService"/>
        </map>
    </property>
</bean>

所有导入的服务都应该在 remoteServices 属性中使用 map/entry 元素声明。

CUBA Studio 自动在项目的所有客户端 block 中注册服务。

从应用程序代码的角度来看,客户端级别的服务代理对象是标准的 Spring bean,可以通过注入或通过 AppBeans 类获得。例如:

@Inject
private OrderService orderService;

public void calculateTotals() {
    orderService.calculateTotals(order);
}

或者

public void calculateTotals() {
    AppBeans.get(OrderService.class).calculateTotals(order);
}
3.4.1.3. 数据服务

DataService 使用外观设计模式提供了从客户端层调用DataManager中间件实现的功能。建议不要在应用程序代码中使用 DataService 接口,应该直接在中间层和客户端层使用 DataManager

3.4.2. 数据存储

在 CUBA 应用程序中处理数据的常用方法是操作实体 - 可通过具有数据感知功能的可视化组件进行声明式处理,也可通过 DataManagerEntityManager 进行编程式处理。实体映射到数据存储中的数据,数据存储通常是关系型数据库。应用程序可以连接到多个数据存储,因此其数据模型将包含映射到位于不同数据库中的数据的实体。

实体只能属于单个数据存储,但是可以在单个 UI 界面上显示来自不同数据存储的实体,DataManager 可以确保在保存时将实体分派到适当的数据存储中去。根据实体类型,DataManager 选择一个已注册的数据存储,这个数据存储实现了 DataStore 接口,然后委托其加载和保存实体。当以编程的方式控制事务并通过 EntityManager 使用实体时,必须明确指定要使用的数据存储。有关详细信息,请参阅 Persistence 接口方法和 @Transactional 注解参数。

平台提供了 DataStore 接口的单一实现,名称为 RdbmsStore,这个实现的目的是通过 ORM 层来使用关系型数据库。可以在自己的项目中实现 DataStore 接口以进行数据整合,例如,可以与非关系型数据库或具有 REST 接口的外部系统进行数据整合。

在任何 CUBA 应用程序中,必定存在一个主数据存储,它包含系统实体和安全实体,用户登录也是在主数据存储。在本手册中提及数据库时,如果没有明确说明,则指的是主数据存储。主数据存储必须是通过 JDBC 数据源连接的关系型数据库。附加数据存储可以是任何 DataStore 接口的实现。

使用 CUBA Studio 可以配置附加数据存储,参考 文档 。会自动创建所有需要的应用程序属性和 JDBC 数据源,并能维护额外添加的 persistence.xml 文件。设置好数据存储之后,在实体设计器界面的 Data store 字段可以选择其为实体的存储位置。在使用 Generate Model 向导为已有数据库 schema 创建映射实体时,也可以选择不同的数据存储。

如果没有使用 Studio 的话,下面的信息可以帮助你排查问题。

附加数据存储的名称通过 cuba.additionalStores 应用程序属性指定。如果附加存储是 RdbmsStore,还为其定义了下面这些属性:

  • cuba.dbmsType_<store_name> - 数据存储 DBMS 类型。

  • cuba.persistenceConfig_<store_name> - 数据存储对应的 persistence.xml 文件位置。

  • cuba.dataSource…​ - 连接至数据库描述的连接参数。

如果项目中实现了 DataStore 接口,实现类的名称必须使用 cuba.storeImpl_<store_name> 属性定义。

比如,如果使用了两个附加存储 db1(PostgreSQL 数据库)和 mem1(一个自定义的内存存储,通过某些项目中的 bean 实现),core 模块的 app.properties 文件必须定义以下属性:

cuba.additionalStores = db1, mem1

# RdbmsStore for Postgres database with data source obtained from JNDI

cuba.dbmsType_db1 = postgres
cuba.persistenceConfig_db1 = com/company/sample/db1-persistence.xml
cuba.dataSourceJndiName_db1 = jdbc/db1

# Custom store
cuba.storeImpl_mem1 = sample_InMemoryStore

还应在所有应用程序 block 使用的属性文件(web-app.propertiesportal-app.properties 等)中指定 cuba.additionalStorescuba.persistenceConfig_db1 属性。

来自不同数据存储的实体之间的引用

如果正确定义了 DataManager,则可以自动维护来自不同数据存储的实体之间的 TO-ONE 引用。比如,在主数据存储中有 Order 实体,在附加数据存储中有 Customer 实体,并且希望在 Order 中引用 Customer 。可以这样做:

  • Order 实体中,定义一个存储 Customer 实体 ID 的属性。该属性应使用 @SystemLevel 注解,以将其从用户可用的各种列表中排除,例如 Filter 中的可选属性:

    @SystemLevel
    @Column(name = "CUSTOMER_ID")
    private Long customerId;
  • Order 实体中,定义对 Customer 的非持久引用,并将 "related" 指定为 customerId

    @Transient
    @MetaProperty(related = "customerId")
    private Customer customer;
  • 在适当的视图中包含非持久化的 customer 属性。

之后,当使用包含 customer 属性的视图加载 Order 时,DataManager 会自动从附加数据存储加载关联的 Customer。集合的加载针对性能进行了优化:在加载 Order 列表之后,从附加数据存储加载引用的 Customer 是分批完成的。每批加载的记录数由 cuba.crossDataStoreReferenceLoadingBatchSize 应用程序属性定义。

当提交引用了 CustomerOrder 实体图时,DataManager 会通过相应的 DataStore 进行数据保存,然后将 Customer 的 ID 保存在 Order 的 customerId 属性中。

Filter 组件也支持跨数据存储引用。

在 Studio 中,如果使用了跨数据存储的实体引用属性,Studio 会自动维护这种引用关系。

3.4.3. 持久化接口

Persistence 接口是ORM层数据存储功能的入口。

该接口有以下方法:

  • createTransaction()getTransaction() – 获取管理事务的接口。该方法可以接受一个数据存储名称作为参数。如果不指定数据存储名称,则使用主数据存储。

  • callInTransaction()runInTransaction() - 在新的事务中执行指定操作,操作可以有返回值,也可以没有。该方法可以接受一个数据存储名称作为参数。如果不指定数据存储,则使用主数据存储。

  • isInTransaction() – 检查当前是否有活动的事务。

  • getEntityManager() – 返回绑定到当前事务的EntityManager实例。该方法可以接受一个数据存储名称作为参数。如果不指定数据存储,则使用主数据存储。

  • isSoftDeletion() – 检查是否启用了软删除模式。

  • setSoftDeletion() – 启用或禁用软删除模式。设置此属性会影响所有新创建的 EntityManager 实例。默认启用软删除。

  • getDbTypeConverter() – 返回主数据存储或其它数据存储的DbTypeConverter实例。

  • getDataSource() – 返回主数据存储或附加数据存储的 javax.sql.DataSource 实例。

    对于通过 getDataSource().getConnection() 方法获得的所有 javax.sql.Connection 对象,在使用连接后,应在 finally 中调用 close() 方法。否则,连接不会被重新放回连接池。随着时间的推移连接池将溢出,应用程序将无法执行数据库查询。

  • getTools() – 返回 PersistenceTools 接口的实例(见下文)。

3.4.3.1. PersistenceTools

Spring bean,包含了与数据存储功能相关的辅助方法。可以通过调用 Persistence.getTools() 方法获得,或者像任何其它 bean 一样,通过注入或 AppBeans 类来获得。

PersistenceTools bean 有以下方法:

  • getDirtyFields() – 返回自最后一次从数据库加载实例以来已更改的实体属性的名称集合,对于新实例,返回空集合。

  • isLoaded() – 检查是否从数据库加载了指定的实例属性。如果在加载实例时指定的视图中不存在该属性,则可能没有加载该属性。

    此方法仅适用于托管状态的实例.

  • getReferenceId() – 返回关联实体的 ID 而不需要从数据库加载关联实体的数据。

    假设在持久化上下文中加载了一个 Order 实体并且需要获得这个 Order 关联的 Customer 实例的 ID 值。如果调用 order.getCustomer().getId() 方法,将执行数据库查询来加载 Customer 实例,但此时这个数据库查询是没必要的,因为 Customer ID 的值作为外键也存在于 Order 表中。而执行

    persistence.getTools().getReferenceId(order, "customer")

    则不会向数据库发送任何其它查询。

    此方法仅适用于托管状态的实例。

在应用程序中,可以通过重写 PersistenceTools bea 来扩展默认的辅助方法的集合。使用扩展接口的示例如下所示:

MyPersistenceTools tools = persistence.getTools();
tools.foo();
((MyPersistenceTools) persistence.getTools()).foo();
3.4.3.2. DbTypeConverter

该接口包含了在数据模型属性值和 JDBC 查询的参数/结果之间的进行转换的方法。通过Persistence.getDbTypeConverter()方法可以获得此接口的实例对象。

DbTypeConverter 接口有以下方法:

  • getJavaObject() – 将 JDBC 查询的结果转换成为适合分配给实体属性的类型。

  • getSqlObject() – 将实体属性的值转换为适合分配给 JDBC 查询参数的类型。

  • getSqlType() – 返回与传递的实体属性类型相应的 java.sql.Types 常量

3.4.4. ORM 层

对象关系映射(ORM)是一种将关系型数据库表映射到编程语言对象的技术。CUBA 使用基于 EclipseLink 框架的 ORM 实现。

ORM 提供一些明显的优点:

  • 通过操控 Java 对象来操控关系型 DBMS。

  • 通过消除枯燥的 SQL 查询语句的编写来简化编程。

  • 通过一个命令来加载和保存整个对象图来简化编程。

  • 允许将应用程序轻松地移植到不同的 DBMS。

  • 允许使用简洁的对象查询语言 – JPQL

同时,ORM 也存在一些缺点。首先,直接使用 ORM 的开发者需要对它有很深的了解,知道它是如何工作的。此外,由于使用了 ORM,使得 SQL 的直接优化和使用 DBMS 的特性变得困难。

如果对数据库访问出现任何性能问题,首先应检查的是实际执行的 SQL 语句。可以使用 eclipselink.sql logger 将 ORM 生成的所有 SQL 语句输出到日志文件。

3.4.4.1. EntityManager

EntityManager - 用于处理持久化实体的主要 ORM 接口。

有关 EntityManager 和 DataManager 之间差异的信息,请参阅 DataManager 与 EntityManager

可以通过调用 Persistence 接口的 getEntityManager() 方法获取 EntityManager 的引用。获取到的 EntityManager 实例绑定到当前事务,在一个事务中对 getEntityManager() 方法的所有调用都将返回同一个 EntityManager 实例。在事务结束后便不能再使用此事务的 EntityManager 实例。

EntityManager 的实例包含 持久化上下文 ,持久化上下文存储了从数据库加载的或新创建的一组实体实例。持久化上下文是处于事务中的数据缓存。EntityManager 会自动将持久化上下文中所做的所有更改在事务提交时或者调用 EntityManager.flush() 方法时更新到数据库。

CUBA 应用程序中使用的 EntityManager 接口主要复制标准 javax.persistence.EntityManager 接口。下面是它的主要方法:

  • persist() – 将实体的新实例添加到持久化上下文中。提交事务时,会使用 INSERT SQL 语句在 DB 中创建相应的记录。

  • merge() – 通过以下方式将游离实例的状态复制到持久化上下文:从 DB 中加载相同标识符的实例,并将传递的游离实例的状态复制到这个加载的实例,然后返回加载的托管实例。之后,应该使用返回的托管实例。在事务提交时,会使用 UPDATE SQL 语句将该实体的状态存储到 DB 中。

  • remove() – 从数据库中删除对象,或者,如果启用了软删除模式,则只设置 deleteTsdeletedBy 属性。

    如果传递的实例处于游离状态,则首先执行 merge() 方法。

  • find() – 通过标识符加载实体实例。

    当向数据库发送请求时,系统会将传递的视图作为参数传递给该方法。因此,持久化上下文将包含加载了所有视图属性的对象图。如果没有传递视图,则默认使用 _local 视图。

  • createQuery() – 创建一个 QueryTypedQuery 对象来执行JPQL 查询

  • createNativeQuery() – 创建一个 Query 对象来执行SQL 查询

  • reload() – 使用提供的视图重新加载实体实例。

  • isSoftDeletion() – 检查 EntityManager 是否处于软删除模式。

  • setSoftDeletion() – 为 EntityManager 设置软删除模式。

  • getConnection() – 返回当前事务对应的连接。这种连接不需要主动关闭,它会在事务完成时自动关闭。

  • getDelegate() – 返回 ORM 实现提供的 javax.persistence.EntityManager

服务中使用 EntityManager 的示例:

@Service(SalesService.NAME)
public class SalesServiceBean implements SalesService {

    @Inject
    private Persistence persistence;

    @Override
    public BigDecimal calculateSales(UUID customerId) {
        BigDecimal result;
        // start transaction
        try (Transaction tx = persistence.createTransaction()) {
            // get EntityManager for the current transaction
            EntityManager em = persistence.getEntityManager();
            // create and execute Query
            Query query = em.createQuery(
                    "select sum(o.amount) from sample_Order o where o.customer.id = :customerId");
            query.setParameter("customerId", customerId);
            result = (BigDecimal) query.getFirstResult();
            // commit transaction
            tx.commit();
        }
        return result != null ? result : BigDecimal.ZERO;
    }
}
部分实体

默认情况下,在 EntityManager 中,视图仅影响引用属性,本地属性会被全部加载。

如果将视图的loadPartialEntities属性设置为 true,则可以强制 EntityManager 加载 部分(partial) 实体(像DataManager所做的一样)。但是,如果加载的实体是缓存的(cached),则忽略此视图属性,仍将加载实体的所有本地属性。

3.4.4.2. 实体状态
New(新建状态)

刚在内存中创建的实例: Car car = new Car()

可以将新实例传递给 EntityManager.persist() 以存储到数据库,在这种情况下,会将其状态更改为 Managed。

Managed(托管状态)

从数据库加载的实例,或传递给 EntityManager.persist() 的新实例。这个实例属于 EntityManager 实例,即包含在其持久化上下文中。

当提交 EntityManager 所属的事务时,托管实例的任何更改都将保存到数据库中。

Detached(游离状态)

从数据库加载并与其持久化上下文分离的实例(事务结束或实体实例通过序列化产生)。

只有通 EntityManager.merge() 方法将此实例变成托管状态时,应用到游离实例的更改才能保存到数据库中。

3.4.4.3. 延迟加载

延迟加载(按需加载,也称懒加载)启用关联实体的延迟加载,即在第一次访问其属性时加载它们。

延迟加载会比贪婪加载生成更多的数据库查询,但是这些查询会在时间维度上推迟。

  • 例如,在延迟加载实体 A 的 N 个实例的列表的情况下,每个实例包含到实体 B 的实例的链接,将需要对 DB 进行 N+1 个请求,1 个请求用来加载 N 个 A,N 个请求用来为每个 A 加载 B。

  • 在大多数情况下,最小化对数据库的请求数会使响应时间和数据库负担减少。平台使用视图的机制来实现这一目标。使用视图允许 ORM 仅为上述关联表的情况执行一次数据库请求。

延迟加载仅适用于托管状态的实例,即在加载实例的事务中。

3.4.4.4. 执行 JPQL 查询

本章节介绍 Query 接口,该接口用于在 ORM 级别执行 JPQL 查询。可以通过调用 createQuery() 方法从当前的 EntityManager 实例获得其引用。如果查询语句用于加载实体,建议使用结果类型作为参数调用 createQuery()。这将创建一个 TypedQuery 实例。

Query 的方法主要对应于标准 JPA javax.persistence.Query 接口的方法。我们了解下之间的差异。

  • setView()addView() – 定义用于加载数据的视图

  • getDelegate() – 返回由 ORM 实现提供的 javax.persistence.Query 实例。

如果为查询指定了视图 ,则默认情况下查询使用 FlushModeType.AUTO 模式,这会影响当前持久化上下文包含已更改实体实例的情况:这些实例将在执行查询之前保存到数据库中。换句话说,ORM 首先同步持久化上下文和数据库中的实体状态,之后才执行查询。这样可以保证查询结果包含所有相关实例,即使它们尚未明确地保存到数据库中。这样做的缺点是会有一个隐式刷新(flush),也就是为所有当前已更改的实体实例执行 SQL 更新语句,这可能会影响性能。

如果在没有视图的情况下执行查询,则默认情况下查询使用 FlushModeType.COMMIT 模式,这意味着查询不会导致刷新(flush),并且查询结果将不会体现出当前持久化上下文数据。

在大多数情况下,忽略当前的持久化上下文是可以接受的,并且是首选行为,因为它不会导致额外的 SQL 更新。但是在使用视图时存在以下问题:如果持久化上下文中存在已更改的实体实例,并且使用视图以 FlushModeType.COMMIT 模式执行查询去加载相同的实例,则更改将丢失。这就是在运行带有视图的查询时默认使用 FlushModeType.AUTO 的原因。

还可以使用 Query 接口的 setFlushMode() 方法显式设置刷新(flush)模式,这样将覆盖上述默认设置。

对软删除的实体使用 DELETE FROM 语句

如果开启了软删除模式,然后对已经软删除的实体执行 DELETE FROM 的 JPQL 语句的话会抛出异常。这种语句在翻译成 SQL 时,会删除所有没有标记删除的数据。这种不好理解的行为默认是关闭的,可以通过 cuba.enableDeleteStatementInSoftDeleteMode 应用程序属性开启。

查询提示

使用 Query.setHint() 方法可以为生成的 SQL 语句添加 hints - 提示。提示通常用来设置查询语句应如何使用索引或其它数据库特性。框架定义了下面这些常量,可以用来传递给该方法作为提示名称:

  • QueryHints.SQL_HINT - 提示的值添加在生成的 SQL 语句后面。这里需要提供全部的提示字符串,也包括注释分隔符。

  • QueryHints.MSSQL_RECOMPILE_HINT - 为 MS SQL Server 数据库添加 OPTION(RECOMPILE) SQL 提示。提示的值会被忽略。

当使用DataManager的时候,查询提示可以使用 LoadContext.setHint() 方法添加。

3.4.4.4.1. JPQL 函数

下表描述了 CUBA 框架支持和不支持的 JPQL 函数。

函数 支持 查询

聚合函数

支持

SELECT AVG(o.quantity) FROM app_Order o

不支持: 带标量表达式的聚合函数(EclipseLink 特性)

SELECT AVG(o.quantity)/2.0 FROM app_Order o

SELECT AVG(o.quantity * o.price) FROM app_Order o

ALL、 ANY、 SOME

支持

SELECT emp FROM app_Employee emp WHERE emp.salary > ALL (SELECT m.salary FROM app_Manager m WHERE m.department = emp.department)

算术函数 (INDEX 、 SIZE 、 ABS 、 SQRT 、 MOD)

支持

SELECT w.name FROM app_Course c JOIN c.studentWaitlist w WHERE c.name = 'Calculus' AND INDEX(w) = 0

SELECT w.name FROM app_Course c WHERE c.name = 'Calculus' AND SIZE(c.studentWaitlist) = 1

SELECT w.name FROM app_Course c WHERE c.name = 'Calculus' AND ABS(c.time) = 10

SELECT w.name FROM app_Course c WHERE c.name = 'Calculus' AND SQRT(c.time) = 10.5

SELECT w.name FROM app_Course c WHERE c.name = 'Calculus' AND MOD(c.time, c.time1) = 2

CASE 表达式

支持

SELECT e.name, f.name, CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum ' WHEN f.annualMiles > 25000 THEN 'Gold ' ELSE '' END, 'Frequent Flyer') FROM app_Employee e JOIN e.frequentFlierPlan f

不支持: UPDATE 查询中的 CASE

UPDATE app_Employee e SET e.salary = CASE e.rating WHEN 1 THEN e.salary * 1.1 WHEN 2 THEN e.salary * 1.05 ELSE e.salary * 1.01 END

日期 函数(CURRENT_DATE、CURRENT_TIME、CURRENT_TIMESTAMP)

支持

SELECT e FROM app_Order e WHERE e.date = CURRENT_DATE

EclipseLink 函数 (CAST、 REGEXP、 EXTRACT)

支持

SELECT EXTRACT(YEAR FROM e.createTs) FROM app_MyEntity e WHERE EXTRACT(YEAR FROM e.createTs) > 2012

SELECT e FROM app_MyEntity e WHERE e.name REGEXP '.*'

SELECT CAST(e.number text) FROM app_MyEntity e WHERE e.path LIKE CAST(:ds$myEntityDs.id text)

不支持: GROUP BY 子句中的 CAST

SELECT e FROM app_Order e WHERE e.amount > 100 GROUP BY CAST(e.orderDate date)

实体类型表达式

支持: 实体类型作为参数

SELECT e FROM app_Employee e WHERE TYPE(e) IN (:empType1, :empType2)

不支持: 直接链接到实体类型

SELECT e FROM app_Employee e WHERE TYPE(e) IN (app_Exempt, app_Contractor)

函数调用

支持: 比较子句中使用函数结果

SELECT u FROM sec$User u WHERE function('DAYOFMONTH', u.createTs) = 1

不支持: 直接使用函数返回值

SELECT u FROM sec$User u WHERE function('hasRoles', u.createdBy, u.login)

IN

支持

SELECT e FROM Employee e, IN(e.projects) p WHERE p.budget > 1000000

IS EMPTY 集合

支持

SELECT e FROM Employee e WHERE e.projects IS EMPTY

键/值 KEY/VALUE

不支持

SELECT v.location.street, KEY(i).title, VALUE(i) FROM app_VideoStore v JOIN v.videoInventory i WHERE v.location.zipcode = '94301' AND VALUE(i) > 0

字面量

支持

SELECT e FROM app_Employee e WHERE e.name = 'Bob'

SELECT e FROM app_Employee e WHERE e.id = 1234

SELECT e FROM app_Employee e WHERE e.id = 1234L

SELECT s FROM app_Stat s WHERE s.ratio > 3.14F

SELECT s FROM app_Stat s WHERE s.ratio > 3.14e32D

SELECT e FROM app_Employee e WHERE e.active = TRUE

不支持: 时间和日期字符串

SELECT e FROM app_Employee e WHERE e.startDate = {d'2012-01-03'}

SELECT e FROM app_Employee e WHERE e.startTime = {t'09:00:00'}

SELECT e FROM app_Employee e WHERE e.version = {ts'2012-01-03 09:00:00.000000001'}

MEMBER OF

支持: 字段或者查询结果

SELECT d FROM app_Department d WHERE (select e from app_Employee e where e.id = :eParam) MEMBER OF e.employees

不支持: 字面量

SELECT e FROM app_Employee e WHERE 'write code' MEMBER OF e.codes

SELECT 中使用 NEW

支持

SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count) FROM app_Customer c JOIN c.orders o WHERE o.count > 100

NULLIF/COALESCE

支持

SELECT NULLIF(emp.salary, 10) FROM app_Employee emp

SELECT COALESCE(emp.salary, emp.salaryOld, 10) FROM app_Employee emp

order by 中使用 NULLS FIRST, NULLS LAST

支持

SELECT h FROM sec$GroupHierarchy h ORDER BY h.level DESC NULLS FIRST

字符串函数 (CONCAT、 SUBSTRING 、 TRIM 、 LOWER 、 UPPER 、 LENGTH 、 LOCATE)

支持

SELECT x FROM app_Magazine x WHERE CONCAT(x.title, 's') = 'JDJs'

SELECT x FROM app_Magazine x WHERE SUBSTRING(x.title, 1, 1) = 'J'

SELECT x FROM app_Magazine x WHERE LOWER(x.title) = 'd'

SELECT x FROM app_Magazine x WHERE UPPER(x.title) = 'D'

SELECT x FROM app_Magazine x WHERE LENGTH(x.title) = 10

SELECT x FROM app_Magazine x WHERE LOCATE('A', x.title, 4) = 6

SELECT x FROM app_Magazine x WHERE TRIM(TRAILING FROM x.title) = 'D'

不支持: 带特定字符的 TRIM

SELECT x FROM app_Magazine x WHERE TRIM(TRAILING 'J' FROM x.title) = 'D'

子查询

支持

SELECT goodCustomer FROM app_Customer goodCustomer WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) FROM app_Customer c)

不支持: 子查询语句 FROM 中使用路径表达式而不是实体名称

SELECT c FROM app_Customer c WHERE (SELECT AVG(o.price) FROM c.orders o) > 100

TREAT

支持

SELECT e FROM app_Employee e JOIN TREAT(e.projects AS app_LargeProject) p WHERE p.budget > 1000000

不支持: WHERE 从句中使用 TREAT

SELECT e FROM Employee e JOIN e.projects p WHERE TREAT(p as LargeProject).budget > 1000000

3.4.4.4.2. 不区分大小写的子串搜索

可以在查询参数的值中使用 (?i) 前缀来简单地指定忽略大小写的子串(Substring)搜索。例如这条查询语句:

select c from sales_Customer c where c.name like :name

如果传递字符串 (?i)%doe% 作为 name 参数的值,则查询将返回 John Doe,如果数据库中存在此类记录,即使字符的大小写不同。出现这种结果是因为 ORM 将执行条件为 lower(C.NAME) like ? 的 SQL 查询。

请注意,这样的查询不能使用在此字段上的索引,即使数据库中已对该字段建立了索引。

3.4.4.4.3. JPQL 中的宏

JPQL 查询文本可以包含宏,这些宏在执行查询之前执行会被转换为可执行的 JPQL,并且还可以修改查询参数集。

宏解决了以下问题:

  • JPQL 有一个限制,这个限制导致条件中不能依赖给定的当前时间字段,(即像“current_date -1”这样的表达式不起作用),宏为这个限制提供一个解决方法。

  • 能够将 Timestamp 类型字段(日期/时间字段)与日期进行比较。

下面是更多细节:

@between

格式为 @between(field_name, moment1, moment2, time_unit)@between(field_name, moment1, moment2, time_unit, user_timezone),其中

  • field_name 是要比较的属性的名称。

  • moment1moment2 – 开始时间点、结束时间点, field_name 的值在这两个时间点之间。时间点应该使用一个表达式定义,这个表达式包含了 now 变量与整数的加减运算。

  • time_unit – 定义在时间点表达式中 now 中增加或减去的时间间隔的单位和时间点精度。下面是可能用到的值:yearmonthdayhourminutesecond

  • user_timezone - 一个可选参数,用于定义在查询中要使用的当前用户时区

宏在 JPQL 中转换为以下表达式:field_name >= :moment1 and field_name < :moment2

例 1. 查询今天创建的 Customer:

select c from sales_Customer where @between(c.createTs, now, now+1, day)

例 2. 查询过去 10 分钟内创建的 Customer:

select c from sales_Customer where @between(c.createTs, now-10, now, minute)

例 3. 查询过去 5 天内的文件,考虑当前用户时区:

select d from sales_Doc where @between(d.createTs, now-5, now, day, user_timezone)
@today

格式为 @today(field_name)@today(field_name, user_timezone) ,帮助定义检查属性值是否属于当天条件。从本质上讲,这是 @between 宏的一个特例。

例:查询今天创建的 Customer:

select d from sales_Doc where @today(d.createTs)
@dateEquals

格式为 @dateEquals(field_name, parameter)@dateEquals(field_name, parameter, user_timezone),允许定义一个检查 field_nameTimestamp 格式)是否落入 parameter 传递的日期范围的条件。

例如:

select d from sales_Doc where @dateEquals(d.createTs, :param)

可以使用 now 属性来传入当前日期。如果需要设置日期偏移量,则可以将 now+ 或者 - 一起使用,示例:

select d from sales_Doc where @dateEquals(d.createTs, now-1)
@dateBefore

格式为 @dateBefore(field_name, parameter)@dateBefore(field_name, parameter, user_timezone) ,允许定义一个条件检查 field_name 值(Timestamp 格式)小于 parameter 传递的日期。

例如:

select d from sales_Doc where @dateBefore(d.createTs, :param, user_timezone)

可以使用 now 属性来传入当前日期。如果需要设置日期偏移量,则可以将 now+ 或者 - 一起使用,示例:

select d from sales_Doc where @dateBefore(d.createTs, now+1)
@dateAfter

格式为 @dateAfter(field_name, parameter)@dateAfter(field_name, parameter, user_timezone),允许定义条件,即 field_name 值的日期 (Timestamp 格式)大于或等于 parameter 传递的日期。

例如:

select d from sales_Doc where @dateAfter(d.createTs, :param)

可以使用 now 属性来传入当前日期。如果需要设置日期偏移量,则可以将 now+ 或者 - 一起使用,示例:

select d from sales_Doc where @dateAfter(d.createTs, now-1)
@enum

允许使用完全限定的枚举常量名称而不是其数据库标识符。这可以简化在整个应用程序代码中搜索枚举用例的过程。

例如:

select r from sec$Role where r.type = @enum(com.haulmont.cuba.security.entity.RoleType.SUPER) order by r.name
3.4.4.5. 执行 SQL 查询

ORM 允许执行返回个别字段列表或实体实例的 SQL 查询。为此,通过调用 EntityManager.createNativeQuery() 方法创建 QueryTypedQuery 对象。

如果选择了个别列,则结果列表中每行的类型为 Object[]。例如:

Query query = persistence.getEntityManager().createNativeQuery(
        "select ID, NAME from SALES_CUSTOMER where NAME like ?1");
query.setParameter(1, "%Company%");
List list = query.getResultList();
for (Iterator it = list.iterator(); it.hasNext(); ) {
    Object[] row = (Object[]) it.next();
    UUID id = (UUID) row[0];
    String name = (String) row[1];
}

如果选择了单个列或聚合函数,结果列表将直接包含这些值:

Query query = persistence.getEntityManager().createNativeQuery(
        "select count(*) from SEC_USER where login = #login");
query.setParameter("login", "admin");
long count = (long) query.getSingleResult();

如果要生成的实体类型与查询语句一起传递给 EntityManager.createNativeQuery(),则返回 TypedQuery 对象,并且 ORM 尝试将查询结果映射到相应的实体属性。例如:

TypedQuery<Customer> query = em.createNativeQuery(
    "select * from SALES_CUSTOMER where NAME like ?1",
    Customer.class);
query.setParameter(1, "%Company%");
List<Customer> list = query.getResultList();

在使用 SQL 时需要注意,对应于 UUID 类型的实体属性的列将以 UUID 类型或 String 类型返回,具体取决于所使用的 DBMS:

  • HSQLDBString

  • PostgreSQLUUID

  • Microsoft SQL ServerString

  • OracleString

  • MySQLString

此类型的参数也应该以 UUID 或字符串格式传递,具体取决于 DBMS。要确保代码不依赖于 DBMS 细节,请使用 DbTypeConverter ,它提供了在 Java 对象与 JDBC 参数和结果之间转换数据的方法。

原生查询语句支持位置和命名参数。位置参数在查询语句中以 ? 标记,后而跟从 1 开始的参数序号。命名参数用数字符号(#)标记。请参阅上面的示例。

与当前持久化上下文相关的返回实体的 SQL 查询和修改查询(updatedelete)的行为类似于上面描述的JPQL 查询

3.4.4.6. 实体监听器

实体监听器 目的在于响应中间层上的实体实例的生命周期事件。

监听器是一个实现 com.haulmont.cuba.core.listener 包中的一个或多个接口的类。监听器将根据所实现的接口对相应的事件做出响应。

BeforeDetachEntityListener

onBeforeDetach() 方法在事务提交时对象从 EntityManager 分离之前调用。

此监听器可用于将非持久化实体属性发送到客户端层之前填充它们。

BeforeAttachEntityListener

onBeforeAttach() 方法在执行了 EntityManager.merge() 操作后,对象附加到持久化上下文之前调用。

例如,可以使用此监听器在将持久化实体属性保存到数据库之前填充它们。

BeforeInsertEntityListener

onBeforeInsert() 方法在将记录插入数据库之前调用。可以使用此方法中当前可用的 EntityManager 执行所有类型的操作。

AfterInsertEntityListener

onAfterInsert() 方法在将记录插入数据库之后但在事务提交之前调用。此方法不允许修改当前持久化上下文,但是,可以使用 QueryRunner 实现对数据库的更改。

BeforeUpdateEntityListener

onBeforeUpdate() 方法在记录更新到数据库中之前调用。可以使用此方法中当前可用的 EntityManager 执行所有类型的操作。

AfterUpdateEntityListener

onAfterUpdate() 方法在将记录插入数据库之后但在事务提交之前调用。此方法不允许修改当前持久化上下文,但是,可以使用 QueryRunner 实现对数据库的更改。

BeforeDeleteEntityListener

onBeforeDelete() 方法在从数据库中删除记录之前调用(在软删除的情况下是在更新记录之前)。可以使用此方法中当前可用的 EntityManager 执行所有类型的操作。

AfterDeleteEntityListener

onAfterDelete() 方法从数据库中删除记录后(在软删除的情况下是在更新记录之后),但在事务提交之前调用。此方法不允许修改当前持久化上下文,但是,可以使用 QueryRunner 实现对数据库的更改。

实体监听器必须是Spring Bean,因此可以在字段和 setters 方法上使用注入。对于特定类的所有实例,一种类型的监听器只会创建一个实例,因此监听器应该是无状态的。

需要知道,对于 BeforeInsertEntityListener,框架只会保证传入监听器的根实体为托管状态。该实体内对象关系图中其它对象的引用可能是 游离(detached) 状态。所以如果需要更新这些对象,需要用 EntityManager.merge() 方法,或者使用 EntityManager.find() 来访问其所有属性。示例:

package com.company.sample.listener;

import com.company.sample.core.DiscountCalculator;
import com.company.sample.entity.*;
import com.haulmont.cuba.core.EntityManager;
import com.haulmont.cuba.core.listener.*;
import org.springframework.stereotype.Component;
import javax.inject.Inject;
import java.math.BigDecimal;

@Component("sample_OrderEntityListener")
public class OrderEntityListener implements
        BeforeInsertEntityListener<Order>,
        BeforeUpdateEntityListener<Order>,
        BeforeDeleteEntityListener<Order> {

    @Inject
    private DiscountCalculator discountCalculator; // a managed bean of the middle tier

    @Override
    public void onBeforeInsert(Order entity, EntityManager entityManager) {
        calculateDiscount(entity.getCustomer(), entityManager);
    }

    @Override
    public void onBeforeUpdate(Order entity, EntityManager entityManager) {
        calculateDiscount(entity.getCustomer(), entityManager);
    }

    @Override
    public void onBeforeDelete(Order entity, EntityManager entityManager) {
        calculateDiscount(entity.getCustomer(), entityManager);
    }

    private void calculateDiscount(Customer customer, EntityManager entityManager) {
        if (customer == null)
            return;

        // Delegate calculation to a managed bean of the middle tier
        BigDecimal discount = discountCalculator.calculateDiscount(customer.getId());

        // Merge customer instance because it comes to onBeforeInsert as part of another
        // entity's object graph and can be detached
        Customer managedCustomer = entityManager.merge(customer);

        // Set the discount for the customer. It will be saved on transaction commit.
        managedCustomer.setDiscount(discount);
    }
}

除了 BeforeAttachEntityListener 之外,所有的监听器都在一个数据库事务中工作。也就是说,如果在监听器中抛出异常,当前事务会被回退,所有数据库的改动也会被丢弃。

如果需要在事务提交成功之后做一些操作,可以使用 Spring 的 TransactionSynchronization 回调函数在事务完成之后执行任务。示例:

package com.company.sales.service;

import com.company.sales.entity.Customer;
import com.haulmont.cuba.core.EntityManager;
import com.haulmont.cuba.core.listener.BeforeInsertEntityListener;
import com.haulmont.cuba.core.listener.BeforeUpdateEntityListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Component("sales_CustomerEntityListener")
public class CustomerEntityListener implements BeforeInsertEntityListener<Customer>, BeforeUpdateEntityListener<Customer> {

    @Override
    public void onBeforeInsert(Customer entity, EntityManager entityManager) {
        printCustomer(entity);
    }

    @Override
    public void onBeforeUpdate(Customer entity, EntityManager entityManager) {
        printCustomer(entity);
    }

    private void printCustomer(Customer customer) {
        System.out.println("In transaction: " + customer);

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                System.out.println("After transaction commit: " + customer);
            }
        });
    }
}
注册实体监听器

可以通过两种方式为实体指定实体监听器:

  • 静态方式 – 监听器的 bean 名称列在实体类的 @Listeners 注解中。

    @Entity(...)
    @Table(...)
    @Listeners("sample_MyEntityListener")
    public class MyEntity extends StandardEntity {
        ...
    }
  • 动态方式 – 将监听器的 bean 名称传递给 EntityListenerManager bean 的 addListener() 方法。这种方法可以为应用程序组件中的实体添加监听器。在下面的例子中,为框架定义的 User 实体添加了一个监听器,监听器由 sample_UserEntityListener bean 实现:

    package com.company.sample.core;
    
    import com.haulmont.cuba.core.global.Events;
    import com.haulmont.cuba.core.sys.events.AppContextInitializedEvent;
    import com.haulmont.cuba.core.sys.listener.EntityListenerManager;
    import com.haulmont.cuba.security.entity.User;
    import org.springframework.context.event.EventListener;
    import org.springframework.core.annotation.Order;
    import org.springframework.stereotype.Component;
    import javax.inject.Inject;
    
    @Component("sample_AppLifecycle")
    public class AppLifecycle {
    
        @Inject
        private EntityListenerManager entityListenerManager;
    
        @EventListener(AppContextInitializedEvent.class) // notify after AppContext is initialized
        @Order(Events.LOWEST_PLATFORM_PRECEDENCE + 100)  // run after all framework listeners
        public void initEntityListeners() {
            entityListenerManager.addListener(User.class, "sample_UserEntityListener");
        }
    }

如果为一个实体声明了几个相同类型的监听器,有来自实体类及其父类的注解,还有动态添加的,则将按以下顺序调用它们:

  1. 对于每个被继承对象,从最远的父级对象开始,首先调用动态添加的监听器,然后是静态分配的监听器。

  2. 父类的调用完之后,首先调用给实体类动态添加的监听器,然后调用静态分配的。

3.4.5. 事务管理

本节介绍在 CUBA 应用程序中事务管理的各个方面。

3.4.5.1. 编程式事务管理

编程式事务管理使用 com.haulmont.cuba.core.Transaction 接口完成。可以通过Persistence基础接口的 createTransaction()getTransaction() 方法获得对它的引用。

createTransaction() 方法创建一个新事务并返回 Transaction 接口。后续调用此接口的 commit()commitRetaining()end() 方法控制创建的事务。如果在创建时有另一个活动的事务,它将先暂停并在新创建的事务完成后恢复。

getTransaction() 方法要么创建新事务,要么附加到已有事务并返回一个嵌套事务。如果在调用时有一个活动的当前事务,那么该方法会成功完成,但后续调用嵌套事务的 commit()commitRetaining()end() 方法对当前事务没有影响。但是,在没有调用嵌套事务的 commit() 方法的情况下调用 end() 方法,会将当前事务标记为 RollbackOnly。简单来说,就是只有嵌套事务成功提交了,外层事务才能提交。

编程式事务管理的示例:

@Inject
private Metadata metadata;
@Inject
private Persistence persistence;
...
// try-with-resources style
try (Transaction tx = persistence.createTransaction()) {
    Customer customer = metadata.create(Customer.class);
    customer.setName("John Smith");
    persistence.getEntityManager().persist(customer);
    tx.commit();
}
// plain style
Transaction tx = persistence.createTransaction();
try {
    Customer customer = metadata.create(Customer.class);
    customer.setName("John Smith");
    persistence.getEntityManager().persist(customer);
    tx.commit();
} finally {
    tx.end();
}

Transaction 接口还有 execute() 方法接受 action 类或 lambda 表达式。action 类或 lambda 表达式表示的操作将在事务中执行。这样可以以函数式编程风格组织事务管理,例如:

UUID customerId = persistence.createTransaction().execute((EntityManager em) -> {
    Customer customer = metadata.create(Customer.class);
    customer.setName("ABC");
    em.persist(customer);
    return customer.getId();
});

Customer customer = persistence.createTransaction().execute(em ->
        em.find(Customer.class, customerId, "_local"));

需要注意,给定 Transaction 实例的 execute() 方法只能调用一次,因为事务在执行完操作代码后结束。

3.4.5.2. 声明式事务管理

中间件Spring bean的任何方法都可以使用 @org.springframework.transaction.annotation.Transactional 进行注解,这将在调用方法时自动创建事务。因此这种方法不需要调用 Persistence.createTransaction(),可以直接获取 EntityManager 进行使用。

@Transactional 注解支持多个参数,包括:

  • propagation - 事务创建模式。REQUIRED 值对应于 getTransaction()REQUIRES_NEW 值对应于 createTransaction()。默认值为 REQUIRED

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void doSomething() {
    }
  • value - 数据存储名称。如果省略,则使用主数据存储。例如:

    @Transactional("db1")
    public void doSomething() {
    }

声明式事务管理可以减少 脚手架代码 的数量,但它有以下缺点:事务在应用程序代码之外提交,这通常会使调试变得复杂,因为它不会暴露将更改发送到数据库和实体变为Detached的时刻。另外,需要注意,声明性标记仅在容器调用方法时才有效,即从同一对象的另一个方法调用事务方法不会启动事务。

考虑到这一点,我们建议仅对简单的情况使用声明式事务管理,例如服务方法方法读取某个对象并将其返回给客户端。

3.4.5.3. 事务交互示例
嵌套事务的回滚

如果嵌套事务是通过 getTransaction() 创建并回滚,则无法提交外层事务。例如:

void methodA() {
    Transaction tx = persistence.createTransaction();
    try {
        methodB(); (1)
        tx.commit(); (4)
    } finally {
        tx.end();
    }
}

void methodB() {
    Transaction tx = persistence.getTransaction();
    try {
        tx.commit(); (2)
    } catch (Exception e) {
        return; (3)
    } finally {
        tx.end();
    }
}
1 调用方法创建嵌套事务
2 假设发生异常
3 处理异常并退出
4 在这里将抛出异常,因为事务被标记为仅回滚(rollback only)。

如果使用 createTransaction() 创建 methodB() 中的事务,那么回滚它将不会影响 methodA() 中的外层事务。

在嵌套事务中读取和修改数据

首先看一下使用 getTransaction() 创建的依赖嵌套事务:

void methodA() {
    Transaction tx = persistence.createTransaction();
    try {
        EntityManager em = persistence.getEntityManager();

        Employee employee = em.find(Employee.class, id); (1)
        assertEquals("old name", employee.getName());

        employee.setName("name A"); (2)

        methodB(); (3)

        tx.commit(); (8)
    } finally {
      tx.end();
    }
}

void methodB() {
    Transaction tx = persistence.getTransaction();
    try {
        EntityManager em = persistence.getEntityManager(); (4)

        Employee employee = em.find(Employee.class, id); (5)

        assertEquals("name A", employee.getName()); (6)
        employee.setName("name B");

        tx.commit(); (7)
    } finally {
      tx.end();
    }
}
1 使用 name == "old name" 加载实体
2 给字段设置新值
3 调用方法创建嵌套事务
4 获取与方法 methodA 中相同的 EntityManager 实例
5 使用同样的标识符加载实体
6 字段值是新的,因为我们使用相同的持久化上下文,并且根本没有调用 DB
7 此时不进行实际的提交
8 更改提交到 DB,它将包含 "name B"

现在,看一下使用 createTransaction() 创建的独立嵌套事务的相同示例:

void methodA() {
    Transaction tx = persistence.createTransaction();
    try {
        EntityManager em = persistence.getEntityManager();

        Employee employee = em.find(Employee.class, id); (1)
        assertEquals("old name", employee.getName());

        employee.setName("name A"); (2)

        methodB(); (3)

        tx.commit(); (8)
    } finally {
      tx.end();
    }
}

void methodB() {
    Transaction tx = persistence.createTransaction();
    try {
        EntityManager em = persistence.getEntityManager(); (4)

        Employee employee = em.find(Employee.class, id); (5)

        assertEquals("old name", employee.getName()); (6)

        employee.setName("name B"); (7)

        tx.commit();
    } finally {
      tx.end();
    }
}
1 使用 name == "old name" 加载实体
2 给字段设置新值
3 调用方法创建嵌套事务
4 创建新的 EntityManager 实例, 因为这是一个新事务
5 使用相同的标识符加载一个实体
6 字段值是旧的,因为一个旧的实体实例被从数据库加载了
7 变更被提交到 DB,现在 "name B"值被存储到数据 DB
8 由于启用乐观锁,这里将发生异常,提交失败

在最后一个例子中,只有当实体支持乐观锁时,即只有它实现了 Versioned 接口时,才会发生第(8)点的异常。

3.4.5.4. 事务参数
事务超时

可以为创建的事务设置超时时限(以秒为单位)。如果发生超时,事务将被中断并回滚。事务超时设置可以有效地限制数据库请求的最大持续时间。

以编程方式管理事务时,通过传递给 Persistence.createTransaction() 方法的参数 TransactionParams 对象来指定超时。例如:

Transaction tx = persistence.createTransaction(new TransactionParams().setTimeout(2));

在声明式事务管理的情况下,使用 @Transactional 注解的 timeout 参数:

@Transactional(timeout = 2)
public void someServiceMethod() {
...

可以使用cuba.defaultQueryTimeoutSec应用程序属性定义默认超时时限。

只读事务

如果事务仅用于从数据库读取数据,则可以将事务标记为只读。例如,DataManager的所有 load 方法默认使用只读事务。只读事务会有更好的性能,因为平台不执行处理可能修改实体的代码。BeforeCommit 事务监听器也不会被调用。

如果只读事务的持久化上下文包含已修改的实体,则在提交事务时将抛出 IllegalStateException。这意味着只有在确定不修改任何实体时,才应将事务标记为只读。

以编程方式管理事务时,通过将 TransactionParams 对象传递给 Persistence.createTransaction() 方法来指定只读标识。例如:

Transaction tx = persistence.createTransaction(new TransactionParams().setReadOnly(true));

在声明式事务管理的情况下,使用 @Transactional 注解的 readOnly 参数:

@Transactional(readOnly = true)
public void someServiceMethod() {
...
3.4.5.5. 事务监听器

事务监听器旨在对事务生命周期事件做出响应。与实体监听器不同,它们不与何实体类型绑定,可以被每个事务调用。

监听器是一个Spring bean,它实现了 BeforeCommitTransactionListenerAfterCompleteTransactionListener 接口或者同时实现这两个接口。

BeforeCommitTransactionListener

如果事务不是只读的,则在所有实体监听器之后,事务提交之前调用 beforeCommit() 方法。该方法接受当前持久化上下文中的实体集合和当前的EntityManager作为参数。

监听器可用于执行涉及多个实体的复杂业务规则。在下面的例子中,Order 实体的 amount 属性必须根据订单中的 discount 值计算,OrderLine 实体的 pricequantity 构成订单。

@Component("demo_OrdersTransactionListener")
public class OrdersTransactionListener implements BeforeCommitTransactionListener {

    @Inject
    private PersistenceTools persistenceTools;

    @Override
    public void beforeCommit(EntityManager entityManager, Collection<Entity> managedEntities) {
        // gather all orders affected by changes in the current transaction
        Set<Order> affectedOrders = new HashSet<>();

        for (Entity entity : managedEntities) {
            // skip not modified entities
            if (!persistenceTools.isDirty(entity))
                continue;

            if (entity instanceof Order)
                affectedOrders.add((Order) entity);
            else if (entity instanceof OrderLine) {
                Order order = ((OrderLine) entity).getOrder();
                // a reference can be detached, so merge it into current persistence context
                affectedOrders.add(entityManager.merge(order));
            }
        }
        // calculate amount for each affected order by its lines and discount
        for (Order order : affectedOrders) {
            BigDecimal amount = BigDecimal.ZERO;
            for (OrderLine orderLine : order.getOrderLines()) {
                if (!orderLine.isDeleted()) {
                    amount = amount.add(orderLine.getPrice().multiply(orderLine.getQuantity()));
                }
            }
            BigDecimal discount = order.getDiscount().divide(BigDecimal.valueOf(100), 2, BigDecimal.ROUND_DOWN);
            order.setAmount(amount.subtract(amount.multiply(discount)));
        }
    }
}
AfterCompleteTransactionListener

事务完成后调用 afterComplete() 方法。该方法接受一个参数,该参数表明事务是否已成功提交,以及已完成事务的持久化上下文中包含的已分离实体的集合。

用法示例:

@Component("demo_OrdersTransactionListener")
public class OrdersTransactionListener implements AfterCompleteTransactionListener {

    private Logger log = LoggerFactory.getLogger(OrdersTransactionListener.class);

    @Override
    public void afterComplete(boolean committed, Collection<Entity> detachedEntities) {
        if (!committed)
            return;

        for (Entity entity : detachedEntities) {
            if (entity instanceof Order) {
                log.info("Order: " + entity);
            }
        }
    }
}

3.4.6. 实体以及查询语句缓存

实体缓存

实体缓存由 EclipseLink ORM 框架提供。它将内存中最近读取或写入的实体实例存储,从而最大限度地减少数据库访问并提高应用程序性能

实体缓存仅在根据 ID 检索实体时使用,因此根据其它属性的查询仍在数据库上执行。但是,如果相关实体位于缓存中,则这些查询可以更简单、更快速。例如,如果查询与客户相关的订单并且不使用缓存,则 SQL 查询将包含客户表的 JOIN 关联。如果客户实体被缓存,则 SQL 查询将仅选择订单,并且将从缓存中检索相关客户。

要启用实体缓存,请在 core 模块的 app.properties 文件中设置以下属性:

  • eclipselink.cache.shared.sales_Customer = true - 启用 sales_Customer 实体的缓存。

  • eclipselink.cache.size.sales_Customer = 500 - 将 sales_Customer 的缓存大小设置为 500 个实例。默认大小为 100。

    如果启用了实体缓存,则始终建议增加缓存大小的值。否则,如果查询返回的记录数超过 100,则将对查询结果的每条记录执行大量的获取操作。

实体是否被缓存会影响平台选择的用于加载实体关系图的获取模式。如果引用属性是可缓存的实体,则获取模式始终为 UNDEFINED,这允许 ORM 从缓存中检索引用,而不是使用 JOIN 执行查询或单独的批量查询。

平台在中间件集群中提供实体缓存协调机制。在一个群集节点上更新或删除缓存实体时,其它节点(如果有)上的相同缓存实例将失效,因此使用此实例的下一个操作将从数据库中读取新状态。

查询缓存

查询缓存存储由 JPQL 查询返回的实体实例的标识符,因此它很自然地补充了实体缓存机制。

例如,如果为实体启用了实体缓存(例如,sales_Customer),并且首次执行查询语句 select c from sales_Customer c where c.grade = :grade,则会发生以下情况:

  • ORM 在数据库上运行查询。

  • 已加载的 Customer 实例放置在实体缓存中。

  • 查询文本和返回实例的标识符列表参数的映射被放到查询缓存中。

当第二次使用相同的参数执行相同的查询时,平台会在查询缓存中查找查询结果,并通过标识符从实体缓存中加载实体实例。不需要数据库操作。

默认情况下不缓存查询。可以指定应用程序的不同层缓存查询:

  • 使用 EntityManager 时,用 Query 接口的 setCacheable() 方法。

  • 使用DataManager时,用 LoadContext.Query 接口的 setCacheable() 方法。

  • 使用数据加载器时,用 CollectionLoader 接口的 setCacheable() 方法或 cacheable XML 属性

仅在为返回的实体启用实体缓存时才使用可缓存查询。否则,每个查询实体实例将通过其标识符逐个从数据库中获取。

ORM 执行实体的实例的创建、更新或删除时,相应的查询缓存会自动失效,并且会在整个中间件集群都失效。

app-core.cuba:type=QueryCacheSupport JMX-bean 可用于监视缓存状态并手动释放缓存的查询。例如,如果已直接在数据库中修改了 sales_Customer 实体的实例,则应使用带有 sales_Customer 参数的 evict() 操作释放该实体的所有缓存的查询。

以下应用程序属性会影响查询缓存:

3.4.7. EntityChangedEvent

参考 使用应用程序事件解耦业务逻辑 指南,学习如何使用 EntityChangedEvent

EntityChangedEvent 是一个 Spring 的 ApplicationEvent 事件,会在实体保存到数据库时从中间层发送该事件。可以在事务中或者事务完成后处理该事件(使用 @TransactionalEventListener )。

只会为使用了 @PublishEntityChangedEvents 注解的实体发送该事件。如果实体需要监听 EntityChangedEvent 事件,别忘了为实体添加该注解。

EntityChangedEvent 不包含更改的实体本身,而只包含了其 id。还有,getOldValue(attributeName) 方法也只会返回引用的 id 而不是对象。所以如果需要的话,开发者可以使用合适的视图或者其它参数重新加载实体。

下面的例子展示了在当前事务中和事务后处理 Customer 实体的 EntityChangedEvent 事件。

package com.company.demo.core;

import com.company.demo.entity.Customer;
import com.haulmont.cuba.core.app.events.AttributeChanges;
import com.haulmont.cuba.core.app.events.EntityChangedEvent;
import com.haulmont.cuba.core.entity.contracts.Id;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.UUID;

@Component("demo_CustomerChangedListener")
public class CustomerChangedListener {

    @TransactionalEventListener(
            phase = TransactionPhase.BEFORE_COMMIT (1)
    )
    public void beforeCommit(EntityChangedEvent<Customer, UUID> event) {
        Id<Customer, UUID> entityId = event.getEntityId(); (2)
        EntityChangedEvent.Type changeType = event.getType(); (3)

        AttributeChanges changes = event.getChanges();
        if (changes.isChanged("name")) { (4)
            String oldName = changes.getOldValue("name"); (5)
            // ...
        }
    }

    @TransactionalEventListener(
            phase = TransactionPhase.AFTER_COMMIT (6)
    )
    public void afterCommit(EntityChangedEvent<Customer, UUID> event) {
        (7)
    }
}
1 - 该监听器会在当前事务中调用。
2 - 更改实体的 id。
3 - 更改类型: CREATEDUPDATEDDELETED
4 - 可以检查是否某个特定属性有变化。
5 - 可以获取变化属性的旧值。
6 - 该监听器在事务提交之后会被调用。
7 - 在事务提交之后,事件包含跟提交之前相同的信息。

如果监听器在事务内部调用,可以通过抛出异常的方法回滚事务,这样不会有数据保存至数据库。如果不想用户看到任何错误提示,可以用 SilentException

如果一个 "after commit" 监听器抛出了异常,该异常会被日志记录,而不会呈现给客户端(用户不会在 UI 看到该错误)。

如果在当前事务(TransactionPhase.BEFORE_COMMIT)中处理 EntityChangedEvent,请确保使用了 TransactionalDataManager 从数据库获取更改实体的当前状态。如果使用的是 DataManager,它会创建新的数据库事务,如果尝试读取未提交的数据会容易导致数据库死锁。

在 "after commit" 监听器(TransactionPhase.AFTER_COMMIT)中,在使用 TransactionalDataManager 之前需要使用 DataManager 或者显式创建一个新事务。

下面是使用 EntityChangedEvent 更新关联实体的示例。

假设根据 Sales Application ,我们有 OrderOrderLineProduct 实体。但是 Product 还有额外的 special 布尔类型属性,并且 OrdernumberOfSpecialProducts 整型属性,该属性需要根据每次从 Order 中添加或者删除 OrderLine 时重新计算。

创建下面带有 @EventListener 方法的类,此方法会在事务提交之前, OrderLine 实体发生改变时调用。

package com.company.sales.listener;

import com.company.sales.entity.Order;
import com.company.sales.entity.OrderLine;
import com.haulmont.cuba.core.TransactionalDataManager;
import com.haulmont.cuba.core.app.events.EntityChangedEvent;
import com.haulmont.cuba.core.entity.contracts.Id;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import javax.inject.Inject;
import java.util.UUID;

@Component("sales_OrderLineChangedListener")
public class OrderLineChangedListener {

    @Inject
    private TransactionalDataManager txDm;

    @TransactionalEventListener(
            phase = TransactionPhase.BEFORE_COMMIT
    )
    public void beforeCommit(EntityChangedEvent<OrderLine, UUID> event) {
        Order order;
        if (event.getType() != EntityChangedEvent.Type.DELETED) { (1)
            order = txDm.load(event.getEntityId()) (2)
                    .view("orderLine-with-order") (3)
                    .one()
                    .getOrder(); (4)
        } else {
            Id<Order, UUID> orderId = event.getChanges().getOldReferenceId("order"); (5)
            order = txDm.load(orderId).one();
        }

        long count = txDm.load(OrderLine.class) (6)
                .query("select o from sales_OrderLine o where o.order = :order")
                .parameter("order", order)
                .view("orderLine-with-product")
                .list().stream()
                .filter(orderLine -> Boolean.TRUE.equals(orderLine.getProduct().getSpecial()))
                .count();

        order.setNumberOfSpecialProducts((int) count);

        txDm.save(order); (7)
    }
}
1 - 如果没有删除 OrderLine,我们可以使用 id 从数据库加载。
2 - event.getEntityId() 方法返回更改的 OrderLine id。
3 - 使用包含 OrderLine 并带有其关联 Order 的视图。试图必须包含 Order.numberOfSpecialProducts 属性,因为我们之后会更新这个值。
4 - 从加载的 OrderLine 中获取 Order
5 - 如果 OrderLine 已经被删除了,则不能从数据库加载,但是 event.getChanges() 方法会返回实体的所有属性,也包含了关联实体的 id。所以我们可以用 id 从中获取关联的 Order
6 - 为给定的 Order 加载所有的 OrderLine 实例,使用 Product.special 进行过滤并对它们进行计数。视图必须包含 OrderLine 以及关联的 Product
7 - 改变了属性之后再保存 Order

3.4.8. EntityPersistingEvent

EntityPersistingEvent 是 Spring 的 ApplicationEvent,是框架在中间层 新实体 保存到数据库之前触发的事件。在触发事件的时候,存在一个活动的数据库事务。

EntityPersistingEvent 可以用来在写入数据库之前初始化实体属性:

package com.company.demo.core;

import com.company.demo.entity.Customer;
import com.haulmont.cuba.core.app.events.EntityPersistingEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component("demo_CustomerChangedListener")
public class CustomerChangedListener {

    @EventListener
    void beforePersist(EntityPersistingEvent<Customer> event) {
        Customer customer = event.getEntity();
        customer.setCode(obtainNewCustomerCode(customer));
    }

    // ...
}

3.4.9. 系统身份验证

执行用户请求时,中间件程序代码始终可以通过UserSessionSource接口访问当前用户的信息。这是可能的,因为当从客户端层收到请求时,会自动为当前线程设置相应的SecurityContext对象。

但是,在某些情况下当前线程与任何系统用户都没有关联,例如,从定时任务或通过 JMX 接口调用 bean 的方法时。如果 bean 修改数据库中的实体,则需要实施更改的用户的信息,即身份验证。

这种身份验证称为“系统身份验证”,因为它不需要用户参与 - 应用程序中间层只是创建或使用现有的用户会话,为当前线程设置相应的 SecurityContext 对象。

可以使用以下方法为代码块提供系统身份验证:

  • 使用 com.haulmont.cuba.security.app.Authentication bean:

    @Inject
    protected Authentication authentication;
    ...
    authentication.begin();
    try {
        // authenticated code
    } finally {
        authentication.end();
    }
  • 在 bean 的方法上添加 @Authenticated 注解:

    @Authenticated
    public String foo(String value) {
        // authenticated code
    }

第二种情况通过 AuthenticationInterceptor 对象隐式使用 Authentication bean,该对象拦截所有 @Authenticated 注解 bean 方法。

在上面的示例中,将代表一个用户创建用户会话,该用户的登录名在cuba.jmxUserLogin应用程序属性中指定。如果需要代表另一个用户进行身份验证,请将所需用户的登录名传递给第一种变体的 begin() 方法。

如果当前线程在执行 Authentication.begin() 时已分配了激活的用户会话,则不会进行替换。因此代码将使用现有会话执行,随后调用 end() 方法将不会清空线程里的会话。

例如,如果 bean 与用户当前连接的 Web 客户端 block 位于同一 JVM 中,则执行内置JMX console的 Web 客户端对 JMX bean 方法的调用将使用当前登录的用户的信息,而忽略系统身份验证。

3.5. 通用用户界面(GUI)

通用用户界面 (Generic UI, GUI) 框架可以使用 Java 和 XML 来创建 UI 界面。XML 是方式是可选的,但是使用这个方式可以声明式的创建界面布局并且减少构建用户界面的代码量。

ClientStructure
Figure 10. 通用用户界面结构

应用程序的界面包含了以下部分:

  • 界面 XML 描述 – 声明式定义界面布局和数据组件的 XML 文件。

  • 界面控制器 – 处理界面生成事件、UI 展示控制以及编程方式操控界面组件的 Java 类。

应用程序界面的代码跟可视化组件接口(VCL 接口)交互。这些接口通过使用 Vaadin 框架组件实现。

可视化组件库(VCL)包含大量即用型组件。

数据组件为可视化组件绑定到实体以及在界面控制器中处理实体提供统一的接口。

客户端的基础设施包含包含主应用程序窗口和其它的通用客户端机制。

3.5.1. 界面和界面片段(Fragments)

界面(Screen)是通用 UI 的主要部分。它由可视化组件、数据容器和非可视化组件组成。界面可以显示在应用程序主窗口的标签页中,也可以显示为模式对话框。

界面的主要组成部分是称作控制器的 Java 或 Groovy 类。界面的布局通常在称作界面描述的 XML 文件中定义。

要显示一个界面,框架会创建一个可视化组件 Window 的新实例,将窗口与界面控制器连接起来,并将界面布局组件作为窗口的子组件加载。最终,界面的窗口将被添加到应用程序主窗口中。

界面片段(fragment)是另一种 UI 构成组件,可以用作界面的一部分或者使用在别的界面片段中。界面片段跟界面本质上非常相似,只不过界面片段有特殊的生命周期;另外在组件树中,片段会作为 Fragment 可视化组件而非 Window。界面片段也有控制器和 XML 描述。

3.5.1.1. 界面控制器

界面控制器是一个 Java 或 Groovy 类,包含界面初始化和事件处理逻辑。通常,控制器链接到XML 描述,XML 描述中定义了界面布局和数据容器,但也可以以编程方式创建所有可视化组件和非可视化组件。

所有界面控制器都实现了 FrameOwner 标记接口。此接口的名称表示它引用了一个框架(frame),框架是一个在主应用程序窗口中显示界面的可视化组件。框架有两种类型:

  • Window - 一个独立的窗口,可以显示在应用程序主窗口内的标签页中,也可以显示为模式对话框。

  • Fragment - 一个轻量级组件,可以被添加到窗口或其它 Fragment。

根据所使用的框架,控制器被分为两个不同的类别:

  • Screen - 窗口控制器的基类。

  • ScreenFragment - fragment 控制器的基类。

screens
Figure 11. 控制器和框架

Screen 类为所有独立界面提供大部分基本的功能。还有其它一些特定的界面基类可用于处理实体:

  • StandardEditor - 实体编辑界面的基类。

  • StandardLookup - 实体浏览和查找界面的基类。

  • MasterDetailScreen - 组合界面,在左侧显示实体列表、在右侧显示所选实体的详细信息。

controller base classes
Figure 12. 控制器基类
3.5.1.1.1. 界面控制器注解

控制器上的类级别的注解用于向框架提供界面相关的信息。一部分注解适用于任何类型的界面,也有一部分仅用于实体编辑或查找界面。

以下示例演示了常见的界面注解的用法:

package com.company.demo.web.screens;

import com.haulmont.cuba.gui.screen.*;

@UiController("demo_FooScreen")
@UiDescriptor("foo-screen.xml")
@LoadDataBeforeShow
@MultipleOpen
@DialogMode(forceDialog = true)
public class FooScreen extends Screen {
}
  • @UiController 注解表示该类是一个界面控制器。注解的值是界面的 ID,可用于从主菜单或以编程方式打开界面时引用界面。

  • @UiDescriptor 注解将界面控制器连接到界面 XML 描述。注解的值指定描述文件的路径。如果该值仅包含文件名,则假定该文件与控制器类位于同一个包中。

  • @LoadDataBeforeShow 注解表示在显示界面之前应自动触发所有数据加载器。准确的说,数据会在调用所有的 BeforeShowEvent 监听器之后,但是在 AfterShowEvent 监听器之前加载。如果需要在界面显示之前的加载数据时刻执行某些操作,删除此注解或将其值设置为 false,这样可以在 BeforeShowEvent 事件监听器中使用 getScreenData().loadAll() 方法或个别加载器的 load() 方法来手动加载数据。可以考虑使用DataLoadCoordinator facet 来做细致的数据加载控制。

  • @MultipleOpen 注解表示可以从主菜单多次打开界面。默认情况下,当用户点击主菜单项时,框架会检查是否已在主窗口标签页上打开相同类和 ID 的界面。如果找到此类界面,则会关闭该界面,并在新标签页中打开新的界面实例。当存在 @MultipleOpen 注解时,不执行任何检查,只在新标签页中打开一个新的界面实例。

    可以通过覆盖界面控制器中的 isSameScreen() 方法,提供自定义的界面实例同一性检查方法。

  • @DialogMode 注解允许指定界面以对话框窗口方式打开时的尺寸和行为。它对应于界面描述<dialogMode> 元素,可以替代使用。对于除 forceDialog 之外的所有参数,XML 中的设置优先于注解。forceDialog 参数会合并生效,即:只要在注解或 XML 其中之一将其设置为 true,界面就总是以对话框的方式打开。

针对查找界面的注解示例:

package com.company.demo.web.screens;

import com.haulmont.cuba.gui.screen.*;
import com.company.demo.entity.Customer;

// common annotations
@UiController("demo_Customer.browse")
@UiDescriptor("customer-browse.xml")
@LoadDataBeforeShow
// lookup-specific annotations
@LookupComponent("customersTable")
@PrimaryLookupScreen(Customer.class)
public class CustomerBrowse extends StandardLookup<Customer> {
}
  • @LookupComponent 注解指定一个 UI 组件的 ID,这个组件用于获取查找界面的返回值。

    可以通过覆盖界面控制器的 getLookupComponent() 方法以编程方式指定查找界面,而不是使用注解。

  • @PrimaryLookupScreen 注解表示此界面是指定实体类型的默认查找界面。注解比 {entity_name}.lookup / {entity_name}.browse 名称约定具有更高的优先级。

编辑器界面特有的注解示例:

package com.company.demo.web.data.sort;

import com.haulmont.cuba.gui.screen.*;
import com.company.demo.entity.Customer;

// common annotations
@UiController("demo_Customer.edit")
@UiDescriptor("customer-edit.xml")
@LoadDataBeforeShow
// editor-specific annotations
@EditedEntityContainer("customerDc")
@PrimaryEditorScreen(Customer.class)
public class CustomerEdit extends StandardEditor<Customer> {
}
  • @EditedEntityContainer 注解指定一个数据容器,这个数据容器包含被编辑的实体。

    不使用注解的话,可以通过覆盖界面控制器的 getEditedEntityContainer() 方法以编程方式指定容器。

  • @PrimaryEditorScreen 注解表示此界面是指定实体类型的默认编辑界面。 注解比 {entity_name}.edit 名称约定具有更高的优先级。

3.5.1.1.2. 界面控制器方法

本章节我们介绍一些界面控制器基类的方法,可以直接在代码中调用或者重写。

所有界面都可用的方法
  • show() - 展示界面。该方法通常在创建界面之后调用,参阅 打开界面

  • close() - 关闭界面,使用 StandardOutcome 枚举参数或者 CloseAction 对象。示例:

    @Subscribe("closeBtn")
    public void onCloseBtnClick(Button.ClickEvent event) {
        close(StandardOutcome.CLOSE);
    }

    参数值也传递至 BeforeCloseEventAfterCloseEvent 事件中,因此可以在事件监听器中也能获取界面关闭的原因。参阅 界面关闭后执行代码以及返回值 了解更多使用这些监听器的方法。

  • getScreenData() - 返回 ScreenData 对象,该对象作为所有在界面 XML 描述中定义的 数据组件 的寄存器,因此可以使用 loadAll() 方法为界面加载数据:

    @Subscribe
    public void onBeforeShow(BeforeShowEvent event) {
        getScreenData().loadAll();
    }
  • getSettings() - 返回 Settings 对象,可以读写当前用户对界面的自定义设置。

  • saveSettings() - 保存 Settings 对象中的设置。如果 cuba.gui.manualScreenSettingsSaving 设置为 false(默认值),则会自动调用该方法。

StandardEditor 的方法
  • getEditedEntity() - 当界面展示时,返回编辑实体的实例。实例是 @EditedEntityContainer 注解内指定的数据容器中的实例。

    InitEventAfterInitEvent 事件监听器内,该方法会返回 null。在 BeforeShowEvent 事件监听器内,该方法会返回传递给界面用来编辑的实体,之后在界面打开的过程中,实体会重新加载,一个不同的实例会设置给数据容器。

可以用下面的方法关闭编辑界面:

  • closeWithCommit() - 验证并保存数据,然后用 StandardOutcome.COMMIT 关闭界面。

  • closeWithDiscard() - 忽略任何未保存的数据并用 StandardOutcome.DISCARD 关闭界面。

如果界面的 DataContext 有未保存的改动,则会在界面关闭前弹出对应的消息对话框。可以用 cuba.gui.useSaveConfirmation 应用程序属性调整通知类型。如果用 closeWithDiscard()close(StandardOutcome.DISCARD) 方法,则会忽略未保存的改动而且没有通知。

  • commitChanges() - 保存数据,但不关闭界面。可以从自定义事件监听器调用该方法或者重写默认的 windowCommit 操作监听器,这样能在数据保存后做一些其他的事情,示例:

    @Override
    protected void commit(Action.ActionPerformedEvent event) {
        commitChanges().then(() -> {
            // this flag is used for returning correct outcome on subsequent screen closing
            commitActionPerformed = true;
            // perform actions after the data has been saved
        });
    }

    commit() 方法的默认实现展示成功提交的通知消息。可以在界面初始化过程中用 setShowSaveNotification(false) 方法关闭。

  • 重写 validateAdditionalRules() 方法为保存数据前添加额外的数据验证。方法应该在传入的 ValidationErrors 对象中保存验证错误的信息。之后,此信息会与标准验证的错误信息一起展示。示例:

private Pattern pattern = Pattern.compile("\\d");

@Override
protected void validateAdditionalRules(ValidationErrors errors) {
    if (getEditedEntity().getAddress().getCity() != null) {
        if (pattern.matcher(getEditedEntity().getAddress().getCity()).find()) {
            errors.add("City name cannot contain digits");
        }
    }
    super.validateAdditionalRules(errors);
}
MasterDetailScreen 的方法
  • getEditedEntity() - 当界面在编辑模式时,返回正在编辑的实体实例。即设置在 form 组件数据容器中的实例。如果界面不在编辑模式,此方法会抛出 IllegalStateException

  • 重写 validateAdditionalRules() 方法可以添加额外的数据验证,与上面 StandardEditor 介绍的一样。

3.5.1.1.3. 界面事件

本节介绍可以在控制器中处理的界面生命周期事件。

参考 使用应用程序事件解耦业务逻辑 指南,了解如何在 UI 层使用事件。



InitEvent

InitEvent 在界面控制器及其所有以声明方式定义的组件创建后并完成依赖注入时发送的事件。此时,嵌套的界面 fragment 尚未初始化,某些可视化组件未完全初始化,例如按钮还未与操作关联起来。

@Subscribe
protected void onInit(InitEvent event) {
    Label<String> label = uiComponents.create(Label.TYPE_STRING);
    label.setValue("Hello World");
    getWindow().add(label);
}
AfterInitEvent

AfterInitEvent 在界面控制器及其所有以声明方式定义的组件被创建并完成依赖注入,并且所有组件都已完成其内部初始化过程时发送此事件。此时,嵌套的界面 fragment(如果有的话)已经发送了 InitEventAfterInitEvent 事件。在此事件监听器中,可以创建可视化组件或数据组件并且进行一些依赖于嵌套 fragment 的额外初始化过程。

InitEntityEvent

InitEntityEvent 继承自 StandardEditorMasterDetailScreen 的界面中,在新实体实例设置给被编辑实体的容器之前发送的事件。使用此事件监听器初始化新实体实例中的默认值,例如:

也可参考 初始化实体值 指南,了解如何使用 InitEntityEvent 监听器。

用该监听器可以为新实体实例设置初始化值,示例:

@Subscribe
protected void onInitEntity(InitEntityEvent<Foo> event) {
    event.getEntity().setStatus(Status.ACTIVE);
}
BeforeShowEvent

BeforeShowEvent 在界面将要展示之前发送的事件,此时,界面尚未被添加到应用程序 UI 中、UI 组件已经应用安全限制、保存的组件设置尚未应用到 UI 组件。对于使用 @LoadDataBeforeShow 注解的界面,尚未加载数据。在此事件监听器中,可以加载数据、检查权限和修改 UI 组件。例如:

@Subscribe
protected void onBeforeShow(BeforeShowEvent event) {
    customersDl.load();
}
AfterShowEvent

AfterShowEvent 在显示界面之后立即发送此事件,此时,界面已经被添加到应用程序 UI 中。保存的组件设置已应用到 UI 组件。在此事件监听器中,可以显示通知、对话框或其它界面。例如:

+

@Subscribe
protected void onAfterShow(AfterShowEvent event) {
    notifications.create().withCaption("Just opened").show();
}
BeforeCommitChangesEvent

BeforeCommitChangesEvent 在继承自 StandardEditorMasterDetailScreen 的界面中发送,在通过 commitChanges() 方法保存数据改动之前发送。在此事件监听器中,可以做一些检查、与用户交互然后通过事件对象的 preventCommit()resume() 方法退出操作或者继续操作。

我们看几个用例:

  1. 退出保存操作并弹出通知消息:

    @Subscribe
    public void onBeforeCommitChanges(BeforeCommitChangesEvent event) {
        if (getEditedEntity().getStatus() == null) {
            notifications.create().withCaption("Enter status!").show();
            event.preventCommit();
        }
    }
  2. 退出保存,显示一个对话框,如果用户选择继续,则继续保存操作:

    @Subscribe
    public void onBeforeCommitChanges(BeforeCommitChangesEvent event) {
        if (getEditedEntity().getStatus() == null) {
            dialogs.createOptionDialog()
                    .withCaption("Confirmation")
                    .withMessage("Status is empty. Do you really want to commit?")
                    .withActions(
                            new DialogAction(DialogAction.Type.OK).withHandler(e -> {
                                // resume with default behavior
                                event.resume();
                            }),
                            new DialogAction(DialogAction.Type.CANCEL)
                    )
                    .show();
            // abort
            event.preventCommit();
        }
    }
  3. 退出保存,显示对话框,用户确认之后重试 commitChanges()

    @Subscribe
    public void onBeforeCommitChanges(BeforeCommitChangesEvent event) {
        if (getEditedEntity().getStatus() == null) {
            dialogs.createOptionDialog()
                    .withCaption("Confirmation")
                    .withMessage("Status is empty. Do you want to use default?")
                    .withActions(
                            new DialogAction(DialogAction.Type.OK).withHandler(e -> {
                                getEditedEntity().setStatus(getDefaultStatus());
                                // retry commit and resume action
                                event.resume(commitChanges());
                            }),
                            new DialogAction(DialogAction.Type.CANCEL)
                    )
                    .show();
            // abort
            event.preventCommit();
        }
    }
AfterCommitChangesEvent

AfterCommitChangesEvent 在继承自 StandardEditorMasterDetailScreen 的界面中发送,在通过 commitChanges() 方法保存数据改动之后发送。示例:

@Subscribe
public void onAfterCommitChanges(AfterCommitChangesEvent event) {
    notifications.create()
            .withCaption("Saved!")
            .show();
}
BeforeCloseEvent

BeforeCloseEvent 在界面通过其 close(CloseAction) 方法关闭之前发送的事件。此刻界面仍然正常显示并且功能完整。组件设置尚未保存。在此事件监听器中,可以做任何条件检查并使用事件的 preventWindowClose() 方法阻止界面关闭,例如:

@Subscribe
protected void onBeforeClose(BeforeCloseEvent event) {
    if (Strings.isNullOrEmpty(textField.getValue())) {
        notifications.create().withCaption("Input required").show();
        event.preventWindowClose();
    }
}

还有一个同名的事件,它定义在 Window 接口内。此事件会在界面被外部(界面控制器之外)操作关闭之前发送,比如点击窗口标签页的关闭按钮或者按下 Esc 键。可以使用 getCloseOrigin() 方法获取窗口关闭的类型,此方法返回一个实现了 CloseOrigin 接口的对象。CloseOrigin 接口的默认实现是 CloseOriginType,有三个值:

  • BREADCRUMBS - 界面通过点击面包屑链接关闭。

  • CLOSE_BUTTON - 界面通过窗口顶部的关闭按钮关闭,或者通过窗口标签页的关闭按钮或右键菜单的操作:关闭、关闭全部、关闭其它。

  • SHORTCUT - 界面通过 cuba.gui.closeShortcut 应用程序属性定义的快捷键关闭。

可以通过在 @Subscribe 注解中指定 Target.FRAME 的方法来订阅 Window.BeforeCloseEvent

@Subscribe(target = Target.FRAME)
protected void onBeforeClose(Window.BeforeCloseEvent event) {
    if (event.getCloseOrigin() == CloseOriginType.BREADCRUMBS) {
        event.preventWindowClose();
    }
}
AfterCloseEvent

AfterCloseEvent 在界面通过 close(CloseAction) 方法关闭并且在 Screen.AfterDetachEvent 之后发送的事件。组件设置已保存。在此事件监听器中,可以在关闭界面后显示通知或对话框,例如:

@Subscribe
protected void onAfterClose(AfterCloseEvent event) {
    notifications.create().withCaption("Just closed").show();
}
AfterDetachEvent

AfterDetachEvent 在用户关闭界面或用户注销时从应用程序 UI 中移除界面后发送的事件。此事件监听器可用于释放界面持有的资源。请注意,在 HTTP 会话过期时不会发送此事件。

UrlParamsChangedEvent
  • UrlParamsChangedEvent 打开的界面对应的浏览器 URL 参数更改时发送的事件。事件在显示界面之前触发,以便能够进行一些准备工作。在此事件监听器中,可以根据新的参数加载一些数据或更改界面控件的状态:

@Subscribe
protected void onUrlParamsChanged(UrlParamsChangedEvent event) {
    Map<String, String> params = event.getParams();
    // handle new params
}
3.5.1.1.4. 界面片段事件

本节介绍界面片段控制器能处理的生命周期事件。

  • InitEvent 会在片段控制器和其所有以声明方式定义的组件创建之后,并且依赖注入完成才触发。此时,嵌套的片段还没有初始化。有些可视化组件没有完全初始化,比如按钮还没有连接到操作。如果该片段是通过 XML 的方式绑定到宿主界面的话,该事件会在宿主控制器的InitEvent事件之后触发。否则会在该片段被添加到宿主组件树的时候触发。

  • AfterInitEvent 会在片段控制器和其所有以声明方式定义的组件创建之后,并且依赖注入完成,左右组件内部的初始化过程也已经结束之后触发。此时,嵌套的界面片段(如果有的话)已经触发了它们自己的 InitEventAfterInitEvent 事件。在该事件的监听器中,可以创建可视化和数据组件,并能执行依赖嵌套组件初始化完成的额外初始化过程。

  • AttachEvent 会在片段被添加到宿主的组件树时触发,片段已经完全初始化了,InitEventAfterInitEvent 事件已经触发。在该事件的监听器中,可以通过 getHostScreen()getHostController() 访问宿主界面的界面和方法。

  • DetachEvent 会在片段以编程的方式从宿主的组件树中移除时触发。在该事件监听器中也能访问宿主界面。

监听界面片段事件的示例:

@UiController("demo_AddressFragment")
@UiDescriptor("address-fragment.xml")
public class AddressFragment extends ScreenFragment {

    private static final Logger log = LoggerFactory.getLogger(AddressFragment.class);

    @Subscribe
    private void onAttach(AttachEvent event) {
        Screen hostScreen = getHostScreen();
        FrameOwner hostController = getHostController();
        log.info("onAttach to screen {} with controller {}", hostScreen, hostController);
    }

    @Subscribe
    private void onDetach(DetachEvent event) {
        log.info("onDetach");
    }
}
订阅父界面事件

在 fragment 控制器中,可以订阅父界面的事件,需要在注解中为 target 属性指定 PARENT_CONTROLLER 值,示例:

@Subscribe(target = Target.PARENT_CONTROLLER)
private void onBeforeShowHost(Screen.BeforeShowEvent event) {
    //
}

这个方法可以处理任何事件,包括实体编辑界面发送的 InitEntityEvent 事件。

3.5.1.2. 界面 XML 描述

界面描述是一个 XML 文件,包含可视化组件数据组件和一些界面参数的声明性定义。

示例:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Sample Screen"
        messagesPack="com.company.sample.web.screens.monitor">
    <layout>
    </layout>
</window>

界面描述具有 window 根元素。

根元素属性:

  • class控制器类的名称。

  • messagesPack − 界面的默认消息包。它用于在控制器中使用 getMessage() 方法获取本地化消息,也作为 XML 描述中使用不指定包名的消息键获取本地化消息时的默认消息包。

  • caption − 窗口标题,可以是一个对上述消息包中的消息的链接,例如,

    caption="msg://credits"
  • focusComponent − 界面显示时应该获得输入焦点的组件的标识符。

界面描述的元素:

  • data − 定义界面的数据组件

  • dialogMode - 定义界面作为对话框打开时的尺寸及行为的设置。

    dialogMode 的属性:

    • closeable - 定义对话框窗口是否有关闭按钮。可选值:truefalse

    • closeOnClickOutside - 当窗口是模式窗口时,定义是否允许通过单击窗口之外的区域来关闭对话框窗口。可选值:truefalse

    • forceDialog - 指定界面应始终以对话框方式打开,无论在调用代码中选择了哪种 WindowManager.OpenType。可选值:truefalse

    • height - 设置对话框窗口的高度。

    • maximized - 如果设置了 true 值,则对话窗口将最大化显示。可选值:truefalse

    • modal -指定对话框窗口是否是模式窗口。可选值:truefalse

    • positionX - 设置对话框窗口左上角的 x 坐标。

    • positionY - 设置对话框窗口左上角的 y 坐标。

    • resizable - 定义用户是否可以更改对话窗口的大小。可选值:truefalse

    • width - 设置对话框窗口的宽度。

    例如:

    <dialogMode height="600"
                width="800"
                positionX="200"
                positionY="200"
                forceDialog="true"
                closeOnClickOutside="false"
                resizable="true"/>
  • actions – 定义界面的操作列表。

  • timers – 定义界面的计时器列表。

  • layout − 界面布局的根元素。

3.5.1.3. 打开界面

可以通过主菜单URL 导航标准操作(当使用浏览和编辑实体界面时)或从另外一个界面以编程方式打开。在本节,将介绍如何以编程的方式打开界面。



使用 Screens 接口

Screens 接口允许创建和显示任何类型的界面。

假设有一个用于显示具有一些特殊格式的消息的界面:

界面控制器
@UiController("demo_FancyMessageScreen")
@UiDescriptor("fancy-message-screen.xml")
@DialogMode(forceDialog = true, width = "300px")
public class FancyMessageScreen extends Screen {

    @Inject
    private Label<String> messageLabel;

    public void setFancyMessage(String message) { (1)
        messageLabel.setValue(message);
    }

    @Subscribe("closeBtn")
    protected void onCloseBtnClick(Button.ClickEvent event) {
        closeWithDefaultAction();
    }
}
1 - 一个界面参数
界面描述
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd" caption="Fancy Message">
    <layout>
        <label id="messageLabel" value="A message" stylename="h1"/>
        <button id="closeBtn" caption="Close"/>
    </layout>
</window>

那么可以从另一个界面创建并打开它,如下所示:

@Inject
private Screens screens;

private void showFancyMessage(String message) {
    FancyMessageScreen screen = screens.create(FancyMessageScreen.class);
    screen.setFancyMessage(message);
    screens.show(screen);
}

请注意这里是如何创建界面实例、为其提供参数,然后显示界面。

如果界面不需要来自调用方的任何参数,可以仅用一行代码创建并打开它:

@Inject
private Screens screens;

private void showDefaultFancyMessage() {
    screens.create(FancyMessageScreen.class).show();
}

screens 不是 Spring bean,所以只能将它注入到界面控制器或使用 ComponentsHelper.getScreenContext(component).getScreens() 静态方法获取。

使用 ScreenBuilders bean

通过 ScreenBuilders bean 可以使用各种参数打开所有类型的界面。

参考 初始化实体值 指南,了解如何通过 ScreenBuilders 在外部进行初始化。

下面是用它打开界面并且在界面关闭之后执行一些代码的例子(更多细节参考这里):

@Inject
private ScreenBuilders screenBuilders;
@Inject
private Notifications notifications;

private void openOtherScreen() {
    screenBuilders.screen(this)
            .withScreenClass(OtherScreen.class)
            .withAfterCloseListener(e -> {
                notifications.create().withCaption("Closed").show();
            })
            .build()
            .show();
}

下面我们看看如何操作编辑界面和查找界面。注意,在大多数情况下你会使用 标准操作(比如 CreateActionLookupAction)来打开这类界面,所以并不是非要直接用 ScreenBuilders API。但是,如果不用标准的操作而是想通过 基础操作 或者 按钮 处理打开界面的话,可以参考下面的例子。

Customer 实体实例打开默认编辑界面的示例:

@Inject
private ScreenBuilders screenBuilders;

private void editSelectedEntity(Customer entity) {
    screenBuilders.editor(Customer.class, this)
            .editEntity(entity)
            .build()
            .show();
}

在这种情况下,编辑界面将更新实体,但调用界面将不会接收到更新后的实例。

我们经常需要编辑某些用 TableDataGrid 组件显示的实体。那么应该使用以下调用方式,它更简洁且能自动更新表格组件:

@Inject
private GroupTable<Customer> customersTable;
@Inject
private ScreenBuilders screenBuilders;

private void editSelectedEntity() {
    screenBuilders.editor(customersTable).build().show();
}

要创建一个新的实体实例并打开它的编辑界面,只需在构建器上调用 newEntity() 方法:

@Inject
private GroupTable<Customer> customersTable;
@Inject
private ScreenBuilders screenBuilders;

private void createNewEntity() {
    screenBuilders.editor(customersTable)
            .newEntity()
            .build()
            .show();
}

默认编辑界面的确定过程如下:

  1. 如果存在使用@PrimaryEditorScreen注解的编辑界面,则使用它。

  2. 否则,使用 id 是 <entity_name>.edit 的编辑界面(例如,sales_Customer.edit)。

界面构建器提供了许多方法来设置被打开界面的可选参数。例如,以下代码以对话框的方式打开的特定编辑界面,同时新建并初始化实体:

@Inject
private GroupTable<Customer> customersTable;
@Inject
private ScreenBuilders screenBuilders;

private void editSelectedEntity() {
    screenBuilders.editor(customersTable).build().show();
}

private void createNewEntity() {
    screenBuilders.editor(customersTable)
            .newEntity()
            .withInitializer(customer -> {          // lambda to initialize new instance
                customer.setName("New customer");
            })
            .withScreenClass(CustomerEdit.class)    // specific editor screen
            .withLaunchMode(OpenMode.DIALOG)        // open as modal dialog
            .build()
            .show();
}

实体查找界面也能使用不同参数打开。

下面是打开 User 实体的默认查找界面的示例:

@Inject
private TextField<String> userField;
@Inject
private ScreenBuilders screenBuilders;

private void lookupUser() {
    screenBuilders.lookup(User.class, this)
            .withSelectHandler(users -> {
                User user = users.iterator().next();
                userField.setValue(user.getName());
            })
            .build()
            .show();
}

如果需要将找到的实体设置到字段,可使用更简洁的方式:

@Inject
private PickerField<User> userPickerField;
@Inject
private ScreenBuilders screenBuilders;

private void lookupUser() {
    screenBuilders.lookup(User.class, this)
            .withField(userPickerField)     // set result to the field
            .build()
            .show();
}

默认查找界面的确定过程如下:

  1. 如果存在使用@PrimaryLookupScreen注解的查找界面,则使用它。

  2. 否则,如果存在 id 为 <entity_name>.lookup 的界面,则使用它(例如,sales_Customer.lookup)。

  3. 否则,使用 id 为 <entity_name>.browse 的界面(例如,sales_Customer.browse)。

与使用编辑界面一样,使用构建器方法设置打开界面的可选参数。例如,以下代码以对话框的方式打开特定的查找界面,在这个界面中查找 User 实体:

@Inject
private TextField<String> userField;
@Inject
private ScreenBuilders screenBuilders;

private void lookupUser() {
    screenBuilders.lookup(User.class, this)
            .withScreenId("sec$User.browse")          // specific lookup screen
            .withLaunchMode(OpenMode.DIALOG)        // open as modal dialog
            .withSelectHandler(users -> {
                User user = users.iterator().next();
                userField.setValue(user.getName());
            })
            .build()
            .show();
}
为界面传递参数

为打开界面传递参数的推荐方式是使用界面控制器的公共 setter 方法,如上面界面接口部分示范。

使用这个方式,可以为任意类型的界面传递参数,包括使用ScreenBuilders或者从主菜单打开的实体编辑和查找界面。带有传参使用 ScreenBuilders 来调用 FancyMessageScreen 如下所示:

@Inject
private ScreenBuilders screenBuilders;

private void showFancyMessage(String message) {
    FancyMessageScreen screen = screenBuilders.screen(this)
            .withScreenClass(FancyMessageScreen.class)
            .build();
    screen.setFancyMessage(message);
    screen.show();
}

如果使用类似 CreateAction 的标准操作打开界面,可以用它的 screenConfigurer 处理器通过界面的公共 setters 传递参数。

另一个方式是为参数定义一个特殊的类,然后在界面构造器中将该类的实例传递给标准的 withOptions() 方法。参数类必需实现 ScreenOptions 标记接口。示例:

import com.haulmont.cuba.gui.screen.ScreenOptions;

public class FancyMessageOptions implements ScreenOptions {

    private String message;

    public FancyMessageOptions(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

在打开的 FancyMessageScreen 界面,可以通过InitEventAfterInitEvent处理器获取参数:

@Subscribe
private void onInit(InitEvent event) {
    ScreenOptions options = event.getOptions();
    if (options instanceof FancyMessageOptions) {
        String message = ((FancyMessageOptions) options).getMessage();
        messageLabel.setValue(message);
    }
}

带有传递 ScreenOptions 参数使用 ScreenBuilders 来调用 FancyMessageScreen 如下所示:

@Inject
private ScreenBuilders screenBuilders;

private void showFancyMessage(String message) {
    screenBuilders.screen(this)
            .withScreenClass(FancyMessageScreen.class)
            .withOptions(new FancyMessageOptions(message))
            .build()
            .show();
}

可以看到,这个方式需要在控制界接收参数的时候进行类型转换,所以需要考虑清楚再使用。推荐还是用上面介绍的类型安全的使用 setter 的方式。

如果使用类似 CreateAction 的标准操作打开界面,可以用它的 screenOptionsSupplier 处理器创建并初始化所需的 ScreenOptions 对象。

如果界面是基于legacy API从另一个界面打开的,那么使用 ScreenOptions 对象是唯一能获取到参数的方法。此时,参数对象是 MapScreenOptions 类型的,可以在打开的界面中按照如下处理:

@Subscribe
private void onInit(InitEvent event) {
    ScreenOptions options = event.getOptions();
    if (options instanceof MapScreenOptions) {
        String message = (String) ((MapScreenOptions) options).getParams().get("message");
        messageLabel.setValue(message);
    }
}
关闭界面后执行代码以及关闭界面返回值

每个界面在关闭时都会发送 AfterCloseEvent 事件。可以为界面添加监听器,这样可以在界面关闭时收到通知,示例:

@Inject
private Screens screens;
@Inject
private Notifications notifications;

private void openOtherScreen() {
    OtherScreen otherScreen = screens.create(OtherScreen.class);
    otherScreen.addAfterCloseListener(afterCloseEvent -> {
        notifications.create().withCaption("Closed " + afterCloseEvent.getScreen()).show();
    });
    otherScreen.show();
}

当使用 ScreenBuilders 时,可以在 withAfterCloseListener() 方法中提供监听器:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private Notifications notifications;

private void openOtherScreen() {
    screenBuilders.screen(this)
            .withScreenClass(OtherScreen.class)
            .withAfterCloseListener(afterCloseEvent -> {
                notifications.create().withCaption("Closed " + afterCloseEvent.getScreen()).show();
            })
            .build()
            .show();
}

事件对象能提供关于界面是如何关闭的信息。信息可以有两种方式获取:一种是检测界面是否通过 StandardOutcome 枚举类型定义的一种标准输出关闭,另一种是获取 CloseAction 对象。前一种方法比较简单,但是后一种方法比较灵活。

先看看第一种方式:使用标准输出关闭界面然后在调用代码内进行检测。

将调用下面这个界面:

package com.company.demo.web.screens;

import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.screen.*;

@UiController("demo_OtherScreen")
@UiDescriptor("other-screen.xml")
public class OtherScreen extends Screen {

    private String result;

    public String getResult() {
        return result;
    }

    @Subscribe("okBtn")
    public void onOkBtnClick(Button.ClickEvent event) {
        result = "Done";
        close(StandardOutcome.COMMIT); (1)
    }

    @Subscribe("cancelBtn")
    public void onCancelBtnClick(Button.ClickEvent event) {
        close(StandardOutcome.CLOSE); (2)
    }
}
1 - 在点击 "OK" 按钮时,设置一些结果状态,并使用 StandardOutcome.COMMIT 枚举值关闭界面。
2 - 在点击 "Cancel" 按钮时,使用 StandardOutcome.CLOSE 关闭界面。

AfterCloseEvent 监听器使用事件的 closedWith() 方法检查界面是如何关闭的,并且需要的话可以读取结果:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private Notifications notifications;

private void openOtherScreen() {
        screenBuilders.screen(this)
                .withScreenClass(OtherScreen.class)
                .withAfterCloseListener(afterCloseEvent -> {
                    OtherScreen otherScreen = afterCloseEvent.getScreen();
                    if (afterCloseEvent.closedWith(StandardOutcome.COMMIT)) {
                        String result = otherScreen.getResult();
                        notifications.create().withCaption("Result: " + result).show();
                    }
                })
                .build()
                .show();
}

从界面返回值的另一个方法是使用自定义的 CloseAction 实现。重写一下上面的示例,使用如下操作类:

package com.company.demo.web.screens;

import com.haulmont.cuba.gui.screen.StandardCloseAction;

public class MyCloseAction extends StandardCloseAction {

    private String result;

    public MyCloseAction(String result) {
        super("myCloseAction");
        this.result = result;
    }

    public String getResult() {
        return result;
    }
}

然后可以使用该操作类关闭界面:

package com.company.demo.web.screens;

import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.screen.*;

@UiController("demo_OtherScreen2")
@UiDescriptor("other-screen.xml")
public class OtherScreen2 extends Screen {

    @Subscribe("okBtn")
    public void onOkBtnClick(Button.ClickEvent event) {
        close(new MyCloseAction("Done")); (1)
    }

    @Subscribe("cancelBtn")
    public void onCancelBtnClick(Button.ClickEvent event) {
        closeWithDefaultAction(); (2)
    }
}
1 - 在点击 "OK" 按钮时,创建自定义关闭操作,并设置结果值。
2 - 在点击 "Cancel" 按钮时,使用框架提供的默认操作关闭界面。

AfterCloseEvent 监听器从事件获取 CloseAction 并读取结果:

@Inject
private Screens screens;
@Inject
private Notifications notifications;

private void openOtherScreen() {
    Screen otherScreen = screens.create("demo_OtherScreen2", OpenMode.THIS_TAB);
    otherScreen.addAfterCloseListener(afterCloseEvent -> {
        CloseAction closeAction = afterCloseEvent.getCloseAction();
        if (closeAction instanceof MyCloseAction) {
            String result = ((MyCloseAction) closeAction).getResult();
            notifications.create().withCaption("Result: " + result).show();
        }
    });
    otherScreen.show();
}

可以看到,当使用自定义的 CloseAction 返回值时,调用方不需要知道打开的界面类是什么,因为不会调用具体的界面控制器内的方法。所以界面可以只通过其字符串 id 来创建。

当然,在使用 ScreenBuilders 打开界面时,也可以使用相同的方式通过关闭操作返回结果。

3.5.1.4. 使用界面片段

在本章节,介绍如何定义和使用界面片段。参考界面片段事件了解如何处理界面片段的生命周期事件。



声明式使用 fragment

假设我们有用来输入地址的 fragment:

AddressFragment.java
@UiController("demo_AddressFragment")
@UiDescriptor("address-fragment.xml")
public class AddressFragment extends ScreenFragment {
}
address-fragment.xml
<fragment xmlns="http://schemas.haulmont.com/cuba/screen/fragment.xsd">
    <layout>
        <textField id="cityField" caption="City"/>
        <textField id="zipField" caption="Zip"/>
    </layout>
</fragment>

然后我们可以在其它界面使用 fragment 元素来包含此 fragment,fragment 元素需要有指向 fragment id 的 screen 属性,fragment id 在其 @UiController 注解设置:

host-screen.xml
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Some Screen">
    <layout>
        <groupBox id="addressBox" caption="Address">
            <fragment screen="demo_AddressFragment"/>
        </groupBox>
    </layout>
</window>

fragment 元素可以添加在界面任意的 UI 容器中,也包含最顶上的 layout 元素。

编程式使用 fragment

同一个片段也可以通过编程的方式添加到界面,需要在 InitEventAfterInitEvent 事件处理器中添加:

host-screen.xml
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Some Screen">
    <layout>
        <groupBox id="addressBox" caption="Address"/>
    </layout>
</window>
HostScreen.java
@UiController("demo_HostScreen")
@UiDescriptor("host-screen.xml")
public class HostScreen extends Screen {

    @Inject
    private Fragments fragments; (1)

    @Inject
    private GroupBoxLayout addressBox;

    @Subscribe
    private void onInit(InitEvent event) {
        AddressFragment addressFragment = fragments.create(this, AddressFragment.class); (2)
        addressBox.add(addressFragment.getFragment()); (3)
    }
}
1 - 注入 Fragments bean,用来实例化界面片段
2 - 用 class 创建片段控制器
3 - 从控制器中获取 Fragment 可视化组件,并添加到 UI 容器中。

如果该片段有参数,可以在将界面片段添加到界面之前通过公共 setter 方法设置。之后,片段控制器的 InitEventAfterInitEvent 处理方法里面可以访问到这些参数。

给界面片段传递参数

界面片段控制器可以有公共的 setter 方法用来接收参数,和打开界面一样的做法。如果界面片段是使用编程的方式打开,可以显式的调用 setters:

@UiController("demo_HostScreen")
@UiDescriptor("host-screen.xml")
public class HostScreen extends Screen {

    @Inject
    private Fragments fragments;

    @Inject
    private GroupBoxLayout addressBox;

    @Subscribe
    private void onInit(InitEvent event) {
        AddressFragment addressFragment = fragments.create(this, AddressFragment.class);
        addressFragment.setStrParam("some value"); (1)
        addressBox.add(addressFragment.getFragment());
    }
}
1 - 在将片段添加到界面之前先传递参数。

如果片段是通过 XML 以声明的方式添加到界面,使用 properties 元素来传递参数,示例:

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Some Screen">
    <data>
        <instance id="someDc" class="com.company.demo.entity.Demo"/>
    </data>
    <layout>
        <textField id="someField"/>
        <fragment screen="demo_AddressFragment">
            <properties>
                <property name="strParam" value="some value"/> (1)
                <property name="dataContainerParam" ref="someDc"/> (2)
                <property name="componentParam" ref="someField"/> (3)
            </properties>
        </fragment>
    </layout>
</window>
1 - 传递一个字符串参数给 setStrParam() 方法。
2 - 传递一个数据容器给 setDataContainerParam() 方法。
3 - 传递 TextField 组件给 setComponentParam() 方法。

使用 value 属性设置值,用 ref 属性指定界面组件的标识符。setters 必须使用合适类型的参数。

界面 fragment 中的数据组件

界面 fragment 可以有自己的数据容器和数据加载器,通过 XML 元素 data 定义。同时,框架会为界面及其所有 fragments 创建DataContext的单例。因此,所有加载的实体都合并到同一数据上下文,并在父界面提交的时候一起保存更改。

下面的例子中,会使用界面 fragment 自己的数据容器和加载器。

假设在 fragment 中有 City 实体,我们希望使用下拉列表框展示可选的城市而不仅仅用文本控件来展示。可以跟普通界面一样,在 fragment 的 XML 描述中定义数据组件。

address-fragment.xml
<fragment xmlns="http://schemas.haulmont.com/cuba/screen/fragment.xsd">
    <data>
        <collection id="citiesDc" class="com.company.demo.entity.City" view="_base">
            <loader id="citiesLd">
                <query><![CDATA[select e from demo_City e ]]></query>
            </loader>
        </collection>
    </data>
    <layout>
        <lookupField id="cityField" caption="City" optionsContainer="citiesDc"/>
        <textField id="zipField" caption="Zip"/>
    </layout>
</fragment>

如果需要在父界面打开时加载 fragment 的数据,需要订阅父界面事件:

AddressFragment.java
@UiController("demo_AddressFragment")
@UiDescriptor("address-fragment.xml")
public class AddressFragment extends ScreenFragment {

    @Inject
    private CollectionLoader<City> citiesLd;

    @Subscribe(target = Target.PARENT_CONTROLLER) (1)
    private void onBeforeShowHost(Screen.BeforeShowEvent event) {
        citiesLd.load();
    }
}
1 - 订阅父界面的 BeforeShowEvent 事件

@LoadDataBeforeShow 对界面 fragments 无效。

使用已有的数据容器

下一个例子展示了如何在 fragment 中使用父界面的数据容器。

host-screen.xml
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Some Screen">
    <data>
        <instance id="addressDc" class="com.company.demo.entity.Address"/> (1)
    </data>
    <layout>
        <groupBox id="addressBox" caption="Address">
            <fragment screen="demo_AddressFragment"/>
        </groupBox>
    </layout>
</window>
1 - fragment 将在下面使用的数据容器
address-fragment.xml
<fragment xmlns="http://schemas.haulmont.com/cuba/screen/fragment.xsd">
    <data>
        <instance id="addressDc" class="com.company.demo.entity.Address"
                  provided="true"/> (1)

        <collection id="citiesDc" class="com.company.demo.entity.City" view="_base">
            <loader id="citiesLd">
                <query><![CDATA[select e from demo_City e]]></query>
            </loader>
        </collection>
    </data>
    <layout>
        <lookupField id="cityField" caption="City" optionsContainer="citiesDc"
                     dataContainer="addressDc" property="city"/> (2)
        <textField id="zipField" caption="Zip"
                   dataContainer="addressDc" property="zip"/>
    </layout>
</fragment>
1 - provided="true" 表示使用同样 id 的容器必须存在于父界面或者嵌套的 fragment 中,也就是说必须在该 fragment 外部提供
2 - UI 组件连接到提供的数据容器

在包含 provided="true" 属性的 XML 元素中,除了 id 之外其它所有的属性都会被忽略,但是也可以加上,以便提供设计思路。

3.5.1.5. 界面 Mixins

通过 Mixin 可以创建能在多个UI界面中重复使用的功能,而且不需要从公共基类继承界面。Mixin 通过 Java 接口实现,使用了接口的默认方法。

Mixin 有如下特性:

  • 一个界面可以有多个 Mixin。

  • Mixin 接口可以订阅 界面事件

  • Mixin 可以在界面中保存一些状态,如果需要的话。

  • Mixin 也可以获取界面组件和基础架构 bean,比如 DialogsNotifications 等。

  • 如果需要参数化 mixin 的行为,mixin 可以依赖界面的注解或者引入抽象方法交由界面实现。

使用 mixin 与在界面控制器中实现特定的接口一样简单。下面的示例中,CustomerEditor 界面使用了由 HasCommentsHasHistoryHasAttachments 接口实现的 mixin 功能:

public class CustomerEditor extends StandardEditor<Customer>
                            implements HasComments, HasHistory, HasAttachments {
    // ...
}

Mixin 可以使用以下类来处理界面和界面基础架构:

  • com.haulmont.cuba.gui.screen.Extensions 提供静态方法,用来保存和获取 mixin 使用的界面状态,还能访问 BeanLocator,这可以用来获取任何 Spring bean。

  • UiControllerUtils 提供对界面UI和数据组件的访问。

下面是展示如何创建和使用 mixin 的示例。

Banner mixin

这个是非常简单的例子,用来在界面顶端展示一个标签。

package com.company.demo.web.mixins;

import com.haulmont.cuba.core.global.BeanLocator;
import com.haulmont.cuba.gui.UiComponents;
import com.haulmont.cuba.gui.components.Label;
import com.haulmont.cuba.gui.screen.*;
import com.haulmont.cuba.web.theme.HaloTheme;

public interface HasBanner {

    @Subscribe
    default void initBanner(Screen.InitEvent event) {
        BeanLocator beanLocator = Extensions.getBeanLocator(event.getSource()); (1)
        UiComponents uiComponents = beanLocator.get(UiComponents.class); (2)

        Label<String> banner = uiComponents.create(Label.TYPE_STRING); (3)
        banner.setStyleName(HaloTheme.LABEL_H2);
        banner.setValue("Hello, world!");

        event.getSource().getWindow().add(banner, 0); (4)
    }
}
1 - 获取 BeanLocator
2 - 获取 UI 组件的工厂。
3 - 创建 Label 并设置其属性。
4 - 将标签添加到界面的根 UI 组件中。

在界面中可以这样使用该 mixin:

package com.company.demo.web.customer;

import com.company.demo.web.mixins.HasBanner;
import com.haulmont.cuba.gui.screen.*;
import com.company.demo.entity.Customer;

@UiController("demo_Customer.edit")
@UiDescriptor("customer-edit.xml")
// ...
public class CustomerEdit extends StandardEditor<Customer> implements HasBanner {
    // ...
}
DeclarativeLoaderParameters mixin

下面这个 mixin 可以帮助在数据容器之间创建主从关系。通常的做法,是需要订阅主容器的 ItemChangeEvent 事件,将改动的主容器内容设置为从容器的数据加载器参数,如数据组件之间的依赖所述。但是如果参数是指向主容器的特殊名称,mixin 能自动完成此功能。

Mixin 会使用状态对象在事件处理器之间传递信息。这里为了演示,我们将逻辑分散开,但实际上所有的逻辑都可以在一个 BeforeShowEvent 处理器中完成。

首先,为共享状态创建一个类。包含单一字段,用来保存将在 BeforeShowEvent 处理器中触发的一组数据加载器:

package com.company.demo.web.mixins;

import com.haulmont.cuba.gui.model.DataLoader;
import java.util.Set;

public class DeclarativeLoaderParametersState {

    private Set<DataLoader> loadersToLoadBeforeShow;

    public DeclarativeLoaderParametersState(Set<DataLoader> loadersToLoadBeforeShow) {
        this.loadersToLoadBeforeShow = loadersToLoadBeforeShow;
    }

    public Set<DataLoader> getLoadersToLoadBeforeShow() {
        return loadersToLoadBeforeShow;
    }
}

接下来,创建 mixin 接口:

package com.company.demo.web.mixins;

import com.haulmont.cuba.gui.model.*;
import com.haulmont.cuba.gui.screen.*;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public interface DeclarativeLoaderParameters {

    Pattern CONTAINER_REF_PATTERN = Pattern.compile(":(container\\$(\\w+))");

    @Subscribe
    default void onDeclarativeLoaderParametersInit(Screen.InitEvent event) { (1)
        Screen screen = event.getSource();
        ScreenData screenData = UiControllerUtils.getScreenData(screen); (2)

        Set<DataLoader> loadersToLoadBeforeShow = new HashSet<>();

        for (String loaderId : screenData.getLoaderIds()) {
            DataLoader loader = screenData.getLoader(loaderId);
            String query = loader.getQuery();
            Matcher matcher = CONTAINER_REF_PATTERN.matcher(query);
            while (matcher.find()) { (3)
                String paramName = matcher.group(1);
                String containerId = matcher.group(2);
                InstanceContainer<?> container = screenData.getContainer(containerId);
                container.addItemChangeListener(itemChangeEvent -> { (4)
                    loader.setParameter(paramName, itemChangeEvent.getItem()); (5)
                    loader.load();
                });
                if (container instanceof HasLoader) { (6)
                    loadersToLoadBeforeShow.add(((HasLoader) container).getLoader());
                }
            }
        }

        DeclarativeLoaderParametersState state =
                new DeclarativeLoaderParametersState(loadersToLoadBeforeShow); (7)
        Extensions.register(screen, DeclarativeLoaderParametersState.class, state);
    }

    @Subscribe
    default void onDeclarativeLoaderParametersBeforeShow(Screen.BeforeShowEvent event) { (8)
        Screen screen = event.getSource();
        DeclarativeLoaderParametersState state =
                Extensions.get(screen, DeclarativeLoaderParametersState.class);
        for (DataLoader loader : state.getLoadersToLoadBeforeShow()) {
            loader.load(); (9)
        }
    }
}
1 - 订阅 InitEvent
2 - 获取 ScreenData 对象,其中注册了 XML 中定义的所有数据容器和加载器。
3 - 检查加载器的参数是否符合 :container$masterContainerId 模式的定义。
4 - 从参数名中抽取主容器id,然后为该容器注册一个 ItemChangeEvent 监听器。
5 - 使用新的主实体重新加载从实体数据加载器。
6 - 将主加载器添加到集合中,以便之后在 BeforeShowEvent 处理器中能触发。
7 - 创建共享状态对象,使用 Extensions 工具类将该对象保存在界面中。
8 - 订阅 BeforeShowEvent 事件。
9 - 触发在 InitEvent 处理器中找到的所有主加载器。

在界面 XML 描述中定义主从容器以及数据加载器。从加载器需要带有一个参数,其名称类似 :container$masterContainerId

<collection id="countriesDc"
            class="com.company.demo.entity.Country" view="_local">
    <loader id="countriesDl">
        <query><![CDATA[select e from demo_Country e]]></query>
    </loader>
</collection>
<collection id="citiesDc"
            class="com.company.demo.entity.City" view="city-view">
    <loader id="citiesDl">
        <query><![CDATA[
        select e from demo_City e
        where e.country = :container$countriesDc
        ]]></query>
    </loader>
</collection>

在界面控制器中,只需要添加 mixin 接口,然后就能自动触发加载器了:

package com.company.demo.web.country;

import com.company.demo.entity.Country;
import com.company.demo.web.mixins.DeclarativeLoaderParameters;
import com.haulmont.cuba.gui.screen.*;

@UiController("demo_Country.browse")
@UiDescriptor("country-browse.xml")
@LookupComponent("countriesTable")
public class CountryBrowse extends StandardLookup<Country>
                           implements DeclarativeLoaderParameters {
}
3.5.1.6. 根界面

根界面是一个通用 UI 界面,直接展示在 web 浏览器的标签页中。有两种类型的这种界面:登录界面和主界面。其它的组件中,任何根界面都可以包含 WorkArea 组件,这样使得可以在内部的标签页中打开其它的应用程序界面。如果根界面不包含 WorkArea,应用程序界面只能以 DIALOG 模式打开。

登录界面

登录界面是在用户登录之前展示的界面。可以通过扩展框架提供的登录界面或者创建全新的登录界面对该界面进行自定义。

如果要扩展已有的界面,在 Studio 界面创建向导中使用 Login screen 模板。Studio 会帮助创建一个扩展了标准登录界面的界面。该界面会替代标准的登录界面,因为它在 @UiController 注解中使用了相同的 login 标识符。

如果想从头创建一个新的界面,可以使用 Blank screen 模板。一个极简的登录界面源码差不多是这样:

my-login-screen.xml
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Login"
        messagesPack="com.company.sample.web">
    <layout>
        <label value="Hello World"/>
        <button id="loginBtn" caption="Login"/>
    </layout>
</window>
MyLoginScreen.java
package com.company.sample.web;

import com.haulmont.cuba.gui.Route;
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.screen.*;
import com.haulmont.cuba.security.auth.LoginPasswordCredentials;
import com.haulmont.cuba.web.App;

@UiController("myLogin")
@UiDescriptor("my-login-screen.xml")
@Route(path = "login", root = true)
public class MyLoginScreen extends Screen {

    @Subscribe("loginBtn")
    private void onLoginBtnClick(Button.ClickEvent event) {
        App.getInstance().getConnection().login(
                new LoginPasswordCredentials("admin", "admin"));
    }
}

为了使用这个界面替代系统默认的界面,需要在 web-app.properties 文件中将 cuba.web.loginScreenId 配置项设置为该界面的 id。

cuba.web.loginScreenId = myLogin

当然,也可以直接将新界面的 id 设置为 login,就不需要修改这个配置了。

主界面

主界面是用户登录之后看到的应用程序的根界面。默认情况下,框架使用带侧边菜单的主界面

Studio 有一些创建自定义主界面的模板,这些模板都使用相同的 MainScreen 类作为控制器的基类。

  • Main screen with side menu 创建标准主界面的扩展,并使用 main id。带有侧边菜单的主界面,默认可以使用左下角的 Collapse 按钮展开或者收起侧边菜单。

    可以使用 SCSS 变量自定义侧边菜单的行为(在创建了主题扩展或者自定义主题后,可以用可视化编辑器修改这些变量。):

    • $cuba-sidemenu-layout-collapse-enabled 启用或禁用侧边菜单收起模式。默认为 true

    • $cuba-sidemenu-layout-collapsed-width 指定收起后侧边菜单的宽度。

    • $cuba-sidemenu-layout-expanded-width 指定展开后侧边菜单的宽度。

    • $cuba-sidemenu-layout-collapse-animation-time 指定侧边菜单展开收起的动画时间。

      $cuba-sidemenu-layout-collapse-enabled 变量设置为 false 时,会隐藏 Collapse 按钮,侧边菜单呈展开状态。

  • Main screen with responsive side menu 创建一个类似的界面,但是侧边菜单是响应式的,能在窄的显示环境中自动收起。该界面会带有自己生成的 id,因此,必须在 web-app.properties 里面进行注册:

    cuba.web.mainScreenId = respSideMenuMainScreen
  • Main screen with top menu 创建一个带有顶部菜单栏的界面,并且能在左侧显示 文件夹面板。该界面会带有自己生成的 id,因此,必须在 web-app.properties 里面进行注册:

    cuba.web.mainScreenId = topMenuMainScreen

除了标准 UI 组件之外,下面这些特殊的组件也可以用在主界面:

  • SideMenu - 应用程序菜单,以垂直树的形势展示。

  • AppMenu – 应用程序菜单栏。

  • AppWorkArea – 工作区,如果需要以 THIS_TABNEW_TABNEW_WINDOW 模式打开界面,则需要该组件。

  • FoldersPane应用程序和搜索文件夹的面板。

  • UserIndicator – 显示当前用户的控件,也包括选择替代用户的功能。

    使用 setUserNameFormatter() 方法可以设置不同于 User 实例名称的用户名称展示:

    userIndicator.setUserNameFormatter(value -> value.getName() + " - [" + value.getEmail() + "]");
    userIndicator
  • NewWindowButton – 在单独的浏览器标签页打开新主界面的按钮。

  • UserActionsButton – 如果用户会话没有认证,显示登录界面的链接。否则,显示一个菜单:用户设置界面的链接和登出操作。

    可以在主界面控制器装载 LoginHandlerLogoutHandler 以实现自定义逻辑:

    @Install(to = "userActionsButton", subject = "loginHandler")
    private void loginHandler(UserActionsButton.LoginHandlerContext ctx) {
        // do custom logic
    }
    
    @Install(to = "userActionsButton", subject = "logoutHandler")
    private void logoutHandler(UserActionsButton.LogoutHandlerContext ctx) {
        // do custom logic
    }
  • LogoutButton – 应用程序登出按钮。

  • TimeZoneIndicator – 显示当前用户时区的标签。

  • FtsField – 全文搜索控件。

下列应用程序属性可能影响主界面:

3.5.1.7. 界面中的验证

ScreenValidation bean 可以用来运行界面中的验证逻辑,有如下方法:

  • ValidationErrors validateUiComponents(),默认在 StandardEditorInputDialogMasterDetailScreen 提交改动时使用。该方法接收一组界面组件或者一个组件容器作为参数,返回这些组件中的验证错误(ValidationErrors 对象)。validateUiComponents() 方法还可以用在其他任意的界面中。示例:

    @UiController("demo_DemoScreen")
    @UiDescriptor("demo-screen.xml")
    public class DemoScreen extends Screen {
        @Inject
        private ScreenValidation screenValidation;
        @Inject
        private Form demoForm;
    
        @Subscribe("validateBtn")
        public void onValidateBtnClick(Button.ClickEvent event) {
            ValidationErrors errors = screenValidation.validateUiComponents(demoForm);
            if (!errors.isEmpty()) {
                screenValidation.showValidationErrors(this, errors);
                return;
            }
        }
    }
  • showValidationErrors() - 显示所有的错误和有问题的组件。该方法接收界面和 ValidationErrors 对象作为参数。默认在 StandardEditorInputDialogMasterDetailScreen 中使用。

  • validateCrossFieldRules() - 接收界面和实体作为参数,返回 ValidationErrors 对象。执行跨字段验证规则。如果编辑界面的约束中包含 UiCrossFieldChecks 并且所有的属性级别验证通过,则会在提交时,进行类级别约束的验证(更多信息参考 自定义约束 章节)。可以使用控制器的 setCrossFieldValidate() 方法禁用此类验证。默认情况下,在 StandardEditorMasterDetailScreen 以及 DataGrid编辑界面使用。另外,validateCrossFieldRules() 方法也可以用在任意界面中。

    举个例子,对于 Event 实体,我们可以定义类级别注解 @EventDate 来检查 Start date 必须小于 End date

    Event entity
    @Table(name = "DEMO_EVENT")
    @Entity(name = "demo_Event")
    @NamePattern("%s|name")
    @EventDate(groups = {Default.class, UiCrossFieldChecks.class})
    public class Event extends StandardEntity {
        private static final long serialVersionUID = 1477125422077150455L;
    
        @Column(name = "NAME")
        private String name;
    
        @Temporal(TemporalType.TIMESTAMP)
        @Column(name = "START_DATE")
        private Date startDate;
    
        @Temporal(TemporalType.TIMESTAMP)
        @Column(name = "END_DATE")
        private Date endDate;
    
        ...
    }

    注解定义如下:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = EventDateValidator.class)
    public @interface EventDate {
    
        String message() default "The Start date must be earlier than the End date";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    }
    EventDateValidator
    public class EventDateValidator implements ConstraintValidator<EventDate, Event> {
        @Override
        public boolean isValid(Event event, ConstraintValidatorContext context) {
            if (event == null) {
                return false;
            }
    
            if (event.getStartDate() == null || event.getEndDate() == null) {
                return false;
            }
    
            return event.getStartDate().before(event.getEndDate());
        }
    }

    然后可以在任意界面中使用 validateCrossFieldRules() 方法。

    @UiController("demo_DemoScreen")
    @UiDescriptor("demo-screen.xml")
    public class DemoScreen extends Screen {
    
        @Inject
        protected Metadata metadata;
        @Inject
        protected ScreenValidation screenValidation;
        @Inject
        protected TimeSource timeSource;
    
        @Subscribe("validateBtn")
        public void onValidateBtnClick(Button.ClickEvent event) {
            Event event = metadata.create(Event.class);
            event.setName("Demo event");
            event.setStartDate(timeSource.currentTimestamp());
    
            // We make the endDate earlier than the startDate
            event.setEndDate(DateUtils.addDays(event.getStartDate(), -1));
    
            ValidationErrors errors = screenValidation.validateCrossFieldRules(this, event);
            if (!errors.isEmpty()) {
                screenValidation.showValidationErrors(this, errors);
            }
        }
    }
  • showUnsavedChangesDialog() - 为没保存的数据显示标准对话框("Do you want to discard unsaved changes?" - “您要放弃更改吗?”),带有 Yes - 是No - 否 按钮。用在 StandardEditor 中。showUnsavedChangesDialog() 带有处理用户操作(点击的按钮)的处理器:

    screenValidation.showUnsavedChangesDialog(this, action)
                            .onDiscard(() -> result.resume(closeWithDiscard()))
                            .onCancel(result::fail);
  • showSaveConfirmationDialog() - 为确认保存更改的数据显示标准对话框("Do you want to save changes before close?" - “关闭页面前需要保存修改吗?”)带有 Save - 保存Do not save - 不保存Cancel - 取消 按钮。用在 StandardEditor 中。showSaveConfirmationDialog() 带有处理用户操作(点击的按钮)的处理器:

    screenValidation.showSaveConfirmationDialog(this, action)
                        .onCommit(() -> result.resume(closeWithCommit()))
                        .onDiscard(() -> result.resume(closeWithDiscard()))
                        .onCancel(result::fail);

可以用 cuba.gui.useSaveConfirmation 应用程序属性修改对话框类型。

3.5.2. 可视化组件库

3.5.2.1. 可视化组件

菜单

应用程序菜单

gui_AppMenu

侧边菜单

gui_sidemenu

按钮

按钮

Button

弹窗按钮

PopupButton

链接按钮

LinkButton

文本

标签

gui_label

文本输入

文本控件

gui_textField_data

密码控件

gui_PasswordField

掩码控件

gui_MaskedField

文本区

gui_TextArea

可调大小文本区

gui_textField_resizable

富文本区

gui_RichTextArea

源码编辑器

gui_SourceCodeEditor_1

日期输入

日期时间组件

gui_dateField

日期选择器

gui_datepicker_mini

时间组件

gui_timeField

选择

复选框

CheckBox

复选框组

gui_CheckBoxGroup

下拉框

LookupField

下拉选择器

LookupPickerField

选项组

gui_optionsGroup

选项列表

gui_optionsList

选择器控件

PickerField

单选按钮组

gui_RadioButtonGroup

搜索选择器控件

gui_searchPickerField

建议选择器控件

gui_suggestionPickerField_1

双列

TwinColumn

上传

文件上传控件

Upload

多文件上传控件

表格和树

数据网格

gui_dataGrid

表格

gui_table

分组表格

gui_groupTable

树形数据网格

gui_TreeDataGrid

树形表格

gui_treeTable

gui_Tree

其它

浏览器框架

gui_browserFrame

批量编辑器

gui_invoiceBulkEdit

日历控件

gui_calendar_1

大小写锁定提示器

gui_capsLockIndicator

颜色选择器

gui_color_picker

字段组

gui_fieldGroup

过滤器

gui_filter_mini

表单

gui_Form_1

图片组件

gui_Image_1

弹窗查看控件

gui_popup_view_mini_open

Slider

gui_slider

标签列表

gui_tokenList

3.5.2.1.1. 应用程序菜单

AppMenu 应用程序菜单组件提供了在主界面中自定义主菜单的方式,通过它可以动态管理菜单项。

gui AppMenu

CUBA Studio 基于标准的 MainScreen 主窗口界面提供了一些界面模板。下面的例子中,这个模板扩展了 MainScreen 类,通过它可以直接访问 AppMenu 实例:

public class ExtMainScreen extends MainScreen implements Window.HasFoldersPane {

    @Inject
    private Notifications notifications;
    @Inject
    private AppMenu mainMenu;

    @Subscribe
    public void onInit(InitEvent event) {
        AppMenu.MenuItem item = mainMenu.createMenuItem("shop", "Shop");
        AppMenu.MenuItem subItem = mainMenu.createMenuItem("customer", "Customers", null, menuItem -> {
            notifications.create()
                    .withCaption("Customers menu item clicked")
                    .withType(Notifications.NotificationType.HUMANIZED)
                    .show();
        });
        item.addChildItem(subItem);
        mainMenu.addMenuItem(item, 0);
    }
}

AppMenu - 应用程序菜单接口的方法有:

  • addMenuItem() - 往根菜单列表末尾指定位置添加菜单项目。

  • createMenuItem() - 是一个创建新菜单项目的工厂方法。并不会把菜单项目添加到菜单里。id 必须在菜单中唯一。

  • createSeparator() - 创建菜单分隔线。

  • getMenuItem()/getMenuItemNN() - 根据菜单树中的 id 返回菜单项目。

  • getMenuItems() - 返回菜单根菜单项列表。

  • hasMenuItems() - 如果该菜单包含菜单项则返回 true

MenuItem - 菜单项接口的方法有:

  • addChildItem() / removeChildItem() - 在子菜单项列表末尾或者指定位置添加/删除菜单项。

  • getCaption() - 返回菜单项标题。

  • getChildren() - 返回子菜单项列表。

  • setCommand() - 设置菜单项命令或点击菜单项目需要执行的动作。

  • setDescription() - 设置菜单项的描述,会在弹出提示中显示。

  • setIconFromSet() - 设置菜单项的图标。

  • getId() - 返回菜单项的 id。

  • getMenu() - 返回菜单项所属菜单。

  • setStylename() - 设置一个或多个自定义的样式,会替换已有的所有自定义样式。多个样式之间用空格分开。样式名称必须是有效的 CSS 类名称。

  • hasChildren() - 该菜单项有子菜单项时返回 true

  • isSeparator() - 如果该菜单项是分隔线的话返回 true

  • setVisible() - 设置菜单项是否可见。

AppMenu 的展示可以使用带 $cuba-menubar-*$cuba-app-menubar-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.2. 浏览器框架

BrowserFrame 是用来显示嵌入的网页,跟 HTML 里面的 iframe 元素的效果是一样的。

gui browserFrame

该组件对应的 XML 名称: browserFrame

下面是界面 XML 描述中这个组件定义的一个例子:

<browserFrame id="browserFrame"
              height="280px"
              width="600px"
              align="MIDDLE_CENTER">
    <url url="https://www.cuba-platform.com/blog/cuba-7-the-new-chapter"/>
</browserFrame>

Image 组件类似, BrowserFrame 组件也可以显示不同来源的图片。可以用下面提到的 browserFrame 的 XML 元素来声明式的设置图片来源的类型:

  • classpath - classpath 中能访问的资源。

    <browserFrame>
        <classpath path="com/company/sample/web/screens/myPic.jpg"/>
    </browserFrame>
  • file - 文件系统的资源。

    <browserFrame>
        <file path="D:\sample\modules\web\web\VAADIN\images\myImage.jpg"/>
    </browserFrame>
  • relativePath - 应用程序目录中的资源。

    <browserFrame>
        <relativePath path="VAADIN/images/myImage.jpg"/>
    </browserFrame>
  • theme - 主题中用到的资源,比如:

    <browserFrame>
        <theme path="../halo/com.company.demo/myPic.jpg"/>
    </browserFrame>
  • url - 可从指定 URL 加载的资源。

    <browserFrame>
        <url url="http://www.foobar2000.org/"/>
    </browserFrame>

browserFrame 的属性:

  • allow - 指定组件的“功能政策”。属性的值可以是空格隔开的下面值的组合:

    • autoplay – 控制当前网页是否允许自动播放接口的请求的媒体。

    • camera – 控制当前网页是否允许使用视频输入设备。

    • document-domain – 控制当前网页是否允许设置 document.domain

    • encrypted-media – 控制当前网页是否允许使用加密媒体扩展 API(EME)。

    • fullscreen – 控制当前网页是否允许使用 Element.requestFullScreen()

    • geolocation – 控制当前网页是否允许使用地理位置接口。

    • microphone – 控制当前网页是否允许使用音频输入设备。

    • midi – 控制当前网页是否允许使用 Web MIDI API。

    • payment – 控制当前网页是否允许使用付款请求 API。

    • vr – 控制当前网页是否允许使用 WebVR API。

  • alternateText - 如果 frame 中没有设置内容源或者内容源不可用的情况下,作为默认显示的文字。

browserFrame 内容源的配置信息:

  • referrerpolicy - 设置当获取 frame 的资源时,发送给哪个 referrer。ReferrerPolicy – 该属性的标准值枚举:

    • no-referrer – 不会发送 referer header。

    • no-referrer-when-downgrade – 如果没有 TLS(HTTPS),则不会发送 referer header 给 origins。

    • origin – 发送的 referrer 限制在 referrer page 的 origin:scheme、host、port。

    • origin-when-cross-origin – 发送给其它 origins 的 referrer 会被限制在 scheme、host 和 port。同源浏览也仍会包含 path。

    • same-origin – 同源则会发送 referrer,但是跨域的请求不会包含 referrer 信息。

    • strict-origin – 在协议的安全级别相同(HTTPS->HTTPS)时,将网页的 orign 作为 referrer 发送,但是不会发送给低安全级别的目的地(HTTPS->HTTP)。

    • strict-origin-when-cross-origin – 当发起同源请求时发送全部 URL,在协议的安全级别相同(HTTPS->HTTPS)时,只发送 origin, 如果是低安全级别的目的地(HTTPS->HTTP),则不会发送 header。

    • unsafe-url – referrer 会包含 origin 和 path。该值不安全,因为从 TLS 保护的资源转到了不安全的 origins,从而泄露了 origins 和 paths。

  • sandbox - 对 frame 的内容使用更多的限制。该属性的值如果是空则使用所有限制,或者设置为空格分隔的标记升级特殊的限制。Sandbox – 该属性的标准值枚举:

    • allow-forms – 允许资源提交表单。

    • allow-modals – 资源可以打开模态窗。

    • allow-orientation-lock – 资源可以锁住屏幕朝向。

    • allow-pointer-lock – 资源可以使用 Pointer Lock API.

    • allow-popups – 允许弹窗(比如 window.open()target="_blank"showModalDialog())。

    • allow-popups-to-escape-sandbox – 允许沙盒内的网页打开新窗口,并且这些新窗口不继承当前的沙盒。

    • allow-presentation – 允许资源开启一个展示会话。

    • allow-same-origin – 允许 iframe 的内容被当做同源处理。

    • allow-scripts – 允许资源运行脚本。

    • allow-storage-access-by-user-activation – 允许资源请求访问父网页的存储能力,能使用 Storage Access API。

    • allow-top-navigation – 允许资源浏览最顶级网页(以 _top 命名)浏览的内容。

    • allow-top-navigation-by-user-activation – 允许资源浏览最顶级网页浏览的内容,但是只有在该网页是通过用户交互产生。

    • allow-downloads-without-user-activation – 允许在没有用户交互的情况下进行下载。

    • "" – 应用所有的限制。

  • srcdoc – 可嵌入的行内 HTML,会覆盖 src 属性。IE 和 Edge 浏览器不支持该属性。也可以在 xml 中使用 srcdocFile 属性指定 HTML 代码的文件。

  • srcdocFile – 文件的路径,文件的内容会被设置到 srcdoc 属性。文件内容通过 classPath 资源获取。只能在 XML 描述中设置该属性。

  • bufferSize - 下载资源时的缓存大小,以字节为单位。

    <browserFrame>
        <file bufferSize="1024" path="C:/img.png"/>
    </browserFrame>
  • cacheTime - 缓存的失效时间,以毫秒为单位.

    <browserFrame>
        <file cacheTime="2400" path="C:/img.png"/>
    </browserFrame>
  • mimeType - 资源的 MIME 类型。

    <browserFrame>
        <url url="https://avatars3.githubusercontent.com/u/17548514?v=4&#38;s=200"
             mimeType="image/png"/>
    </browserFrame>

BrowserFrame 定义的接口方法:

  • addSourceChangeListener() - 添加一个监听器,当 frame 的内容源发生变化时触发。

    @Inject
    private Notifications notifications;
    @Inject
    BrowserFrame browserFrame;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        browserFrame.addSourceChangeListener(sourceChangeEvent ->
                notifications.create()
                        .withCaption("Content updated")
                        .show());
    }
  • setSource() - 设置 frame 的内容源。这个方法接受一个描述资源类型的参数,然后返回相应的资源对象,以便可以继续使用流式操作对资源进行更多的设置。每种资源类型有其独特的设置方法,比如,ThemeResource 具有的 setPath() 方法、 StreamResource 具有 setStreamSupplier() 方法等:

    BrowserFrame frame = uiComponents.create(BrowserFrame.NAME);
    try {
        frame.setSource(UrlResource.class).setUrl(new URL("http://www.foobar2000.org/"));
    } catch (MalformedURLException e) {
        throw new RuntimeException(e);
    }

    也可以使用用于 Image 组件的资源类型

  • createResource() - 用资源类型自己实现的方法创建资源对象,然后这个对象可以传递给 setSource() 方法使用。

    UrlResource resource = browserFrame.createResource(UrlResource.class)
            .setUrl(new URL(fromString));
    browserFrame.setSource(resource);
在 BrowserFrame 中使用 HTML 标记:

BrowserFrame 可以集成 HTML 标记到应用程序中。比如,可以在运行时根据用户的输入生成 HTML 内容。

<textArea id="textArea"
          height="250px"
          width="400px"/>
<browserFrame id="browserFrame"
              height="250px"
              width="500px"/>
textArea.addTextChangeListener(event -> {
    byte[] bytes = event.getText().getBytes(StandardCharsets.UTF_8);

    browserFrame.setSource(StreamResource.class)
            .setStreamSupplier(() -> new ByteArrayInputStream(bytes))
            .setMimeType("text/html");
});
gui browserFrame 2
用 BrowserFrame 预览 PDF:

除了 HTML,BrowserFrame 还可以用来展示 PDF 文件的内容。需要配置使用文件类型的资源和相应的 MIME 类型:

@Inject
private BrowserFrame browserFrame;
@Inject
private Resources resources;

@Subscribe
protected void onInit(InitEvent event) {
    browserFramePdf.setSource(StreamResource.class)
            .setStreamSupplier(() -> resources.getResourceAsStream("/com/company/demo/" +
                    "web/screens/CUBA_Hands_on_Lab_6.8.pdf"))
            .setMimeType("application/pdf");
}
gui browserFrame 3


3.5.2.1.3. 按钮

当用户点击一个按钮,就会执行一个操作。

Button

该组件对应的 XML 名称: button

按钮上可以有标题、图标、或者两者皆有。下面这个图列举了一些不同类型的按钮。

gui buttonTypes

下面是从本地化消息包获取文本显示到按钮和提示上的例子:

<button id="textButton" caption="msg://someAction" description="Press me"/>

按钮上的标题是用 caption 属性来设置,弹出提示用 description 来设置。

如果 disableOnClick 属性设置成 true 这个按钮在点击之后就会变成不可点击的状态,主要用来防止多次(意外的)点击这个按钮。之后可以通过调用 setEnabled(true) 把按钮恢复成可点击状态。

icon 属性定义了图标的位置或者图标集中的名称。详细信息请参看图标

创建带有图标的按钮的例子:

<button id="iconButton" caption="" icon="SAVE"/>

按钮的主要功能是在点击的时候执行一个动作(action)。点击之后调用的控制器方法可以通过 invoke 属性来定义。这个属性的值需要是控制器的方法名,这个方法需要满足下面的条件:

  • 方法应该是 public

  • 方法返回值是 void

  • 方法不能有任何参数, 或者只能有一个 Component 组件类型的参数。 如果方法带有 Component 参数, 那么这个组件就是调用此方法的按钮实例。

以下是按钮调用 someMethod 的例子:

<button invoke="someMethod" caption="msg://someButton"/>

在界面控制器里需要定义名称为 someMethod 的方法:

public void someMethod() {
    //some actions
}

如果设置了 action 属性,那么就会忽略 invoke 属性。action 属性包含了按钮中相应操作的名称。

带有 action 属性的按钮的例子:

<actions>
    <action id="someAction" caption="msg://someAction"/>
</actions>
<layout>
    <button action="someAction"/>
</layout>

实现了 Component.ActionsHolder 接口的组件中的任何操作都可以指定给按钮。表格分组表格树形表格中的操作都可以指定给按钮。有两种添加操作的方法,一种是在 XML 描述中以声明的方式添加,另一种是在界面控制器里以编程的方式添加,这两种方式没有区别。不管使用哪种方式,在使用操作的时候,组件的名称和操作的标识符必须定义在 action 属性中,并且它们之间用 . 分隔。比如,下面的例子中,将 coloursTablecreate 操作指定给一个按钮:

<button action="coloursTable.create"/>

按钮的操作也可以通过编程创建,方法是在界面控制器中创建继承自BaseAction的类。

如果给 Button 定义了 Action 实例,那么按钮会从操作获取以下属性:captiondescriptioniconenableshortcutvisible。其中 captiondescriptionicon 属性只有在 Button 本身没有设置时才会使用操作的对应属性,其它的 Action 的属性比 Button 的相同属性有更高的优先级。

如果 操作 的属性在其被指定给 Button 之后发生了改变, 那么 Button 的相应属性也会跟着改变,也就是说按钮监听 操作 的变化。这种情况下,如果操作的 captiondescriptionicon 改变的话,即便按钮本身也定义了这些属性,这些属性还是会跟随操作的属性变化而变化。

shortcut 属性用来为按钮指定一个快捷键组合。可选的功能键为:ALTCTRLSHIFT,使用 "-" 与其他键分隔。示例:

<button id="button" caption="msg://shortcutButton" shortcut="ALT-C"/>
按钮样式

primary 属性用来将按钮设置为高亮显示,默认情况下,如果这个按钮调用的操作的primary属性为 true,这个按钮会被设置为高亮显示。

<button primary="true"
        invoke="foo"/>

这个高亮样式在 Hover 主题中是默认开启的;如果希望在 Halo-based 主题中使用 primary 样式,可以通过设置 $cuba-highlight-primary-action 样式变量true 来开启。

actions primary

接下来,在使用了 Halo-based 主题的 Web Client 里,可以通过 stylename 属性来给按钮组件设置一些预定义的样式,可以通过 XML 或者编程的方法设置:

<button id="button"
        caption="Friendly button"
        stylename="friendly"/>

如果使用编程的方式来设置样式, 可以直接用 HaloTheme 主题类里面的以 BUTTON_ 开头的一些主题常量:

button.setStyleName(HaloTheme.BUTTON_FRIENDLY);
  • borderless - 无边框的按钮。

  • borderless-colored - 无边框但是具有彩色按钮标题。

  • danger - 当按钮的操作比较危险时可以使用的一种警示按钮,比如会导致数据丢失或者其它不可撤消的操作。

  • friendly - 当按钮的操作比较安全的时候可以用的一种友好的按钮,比如不会导致数据丢失或者其它不可撤消的操作。

  • icon-align-right - 将按钮的图标对齐在按钮名称的右侧。

  • icon-align-top - 将按钮的图标放到按钮标题的上方。

  • icon-only - 只显示按钮的图标,并且把按钮调整成正方形。

  • primary - 主要的操作按钮,比如用户在填写表单时按下回车键的操作按钮。尽量少用,一般来说一个界面只有一个主要(primary)按钮。

  • quiet - "安静的" 按钮,跟 borderless 很像,只有在光标悬浮到这个按钮上面才会有样式变化。

Button 的展示可以使用带 $cuba-button-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.4. 批量编辑器

BulkEditor - 批量编辑器支持一次修改多个实体对象的属性值。它是个按钮,一般可以加到表格组件, 点击它时打开批量编辑器界面。

gui bulkEdit

该组件对应的 XML 名称: bulkEditor

BulkEditor 只能用在使用了遗留 API的界面中。最新 API 的类似功能通过BulkEditAction提供。

要使用 BulkEditor , 相应的表格或者树组件的 multiselect 属性需设置为 "true"

批量实体编辑界面是基于定义的 view(view 里一般包括实体的字段和引用)、实体动态属性和用户权限自动生成的。系统属性不会显示在生成的界面里。

实体属性名称会按字母排序。默认情况下,值都为空,界面提交的时候,非空值会更新到所有的实体对象中。

批量实体编辑界面也支持批量删除值 - 实体对象的对应字段会设置为空( null)。操作方法是点击字段旁边的 gui_bulkEditorSetNullButton 按钮,点击之后,该字段变为不可编辑, 再次点击该按钮则该字段恢复可编辑。

gui invoiceBulkEdit

以下为在表格中使用 bulkEditor 批量编辑器的例子:

<table id="invoiceTable"
       multiselect="true"
       width="100%">
    <actions>
        <!-- ... -->
    </actions>
    <buttonsPanel>
        <!-- ... -->
        <bulkEditor for="invoiceTable"
                    exclude="customer"/>
    </buttonsPanel>
bulkEditor 批量编辑器的属性有
  • 属性 for 是必须的,它指向需要该功能的数据网格表格组件的标识;在上述例子中,应该是 invoiceTable

  • 属性 exclude 标识需要在批量编辑界面排除的字段,它可以包含一个正则表达式。比如: date|customer

    gui TableBulkEdit
  • includeProperties - 定义批量编辑界面需要包含的字段;设置它以后,其它字段会被忽略。

    includeProperties 不会应用到动态属性。

    以声明的方式设置时,多个属性之间应该用逗号隔开:

    <bulkEditor for="ordersTable" includeProperties="name, description"/>

    这些属性也可以在界面控制器中以编程的方式设置:

    bulkEditor.setIncludeProperties(Arrays.asList("name", "description"));
  • loadDynamicAttributes 定义实体的动态属性是否在批量编辑界面显示。默认为 true

  • useConfirmDialog 定义保存之前是否弹出确认对话框,默认为 true

    gui BulkEditor useConfirmDialog
  • columnsMode − 定义批量编辑界面列的数量,是 ColumnsMode 枚举的值。默认为 TWO_COLUMNS。示例:

    <groupTable id="customersTable"
                width="100%">
        <actions>...</actions>
        <columns>...</columns>
        <buttonsPanel id="buttonsPanel"
                      alwaysVisible="true">
             ...
            <bulkEditor for="customersTable" columnsMode="ONE_COLUMN"/>
        </buttonsPanel>
    </groupTable>

批量编辑界面的外观可以通过以 $c-bulk-editor-* 开头的 SCSS 变量自定义。在创建了 主题扩展自定义主题 之后可以在可视化编辑器修改这些变量的值。