序言

本手册提供关于 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 变量自定义。在创建了 主题扩展自定义主题 之后可以在可视化编辑器修改这些变量的值。



3.5.2.1.5. 日历控件

Calendar - 日历控件用来组织和展示日历事件。

gui calendar 1

该组件的 XML 名称是: calendar

以下为界面中日历组件定义的示例:

<calendar id="calendar"
          captionProperty="caption"
          startDate="2016-10-01"
          endDate="2016-10-31"
          height="100%"
          width="100%"/>

该组件在界面上的外观取决于日历的日期范围,日期范围由开始日期和结束日期定义。默认外观为周视图,对应的日期范围上限是一周七天。如果需要按天展示则把日期范围定义为一天以内。如果日期范围在七天以上,该组件将以月视图展示。

用来将日历控件往前或往后调一周的导航按钮默认不可见。可以通过设置 navigationButtonsVisible 属性使导航按钮可见(在周视图中):

<calendar width="100%"
          height="100%"
          navigationButtonsVisible="true"/>
gui calendar 2

calendar 日历控件的属性:

  • endDate - 日期范围中的结束日期。可以用 "yyyy-MM-dd" 或 "yyyy-MM-dd HH:mm" 格式。

  • endDateProperty - 包含结束日期的实体属性名称。

  • datatype - startDateendDate 以及日历事件的 datatype。可以设置为下列数据类型:

    • date

    • dateTime

    • localDate

    • localDateTime

    • offsetDateTime

      dateTime 是默认的数据类型。如果同时设置了 startDatePropertyendDateProperty,则日历控件会使用它们的数据类型。

  • descriptionProperty - 包含事件描述的实体属性名称。

  • isAllDayProperty - 标识是否为全天事件的实体属性名称。

  • startDate - 日期范围中的开始日期。可以用 "yyyy-MM-dd" 或 "yyyy-MM-dd HH:mm" 格式。

  • startDateProperty - 包含开始时间的是实体属性名称。

  • stylenameProperty - 标识事件样式名称的实体属性名称。

  • timeFormat - 时间格式: 12H 或 24H。

如何使用日历事件:

需要在日历控件单元格中显示事件时,可以通过 addEvent() 方法添加一个事件到 Calendar 对象;也可以使用 CalendarEventProvider 接口操作。 直接添加事件到对象的示例:

@Inject
private Calendar<Date> calendar;

public void generateEvent(String caption, String description, Date start, Date end, boolean isAllDay, String stylename) {
    SimpleCalendarEvent<Date> calendarEvent = new SimpleCalendarEvent<>();
    calendarEvent.setCaption(caption);
    calendarEvent.setDescription(description);
    calendarEvent.setStart(start);
    calendarEvent.setEnd(end);
    calendarEvent.setAllDay(isAllDay);
    calendarEvent.setStyleName(stylename);
    calendar.getEventProvider().addEvent(calendarEvent);
}

CalendarEventProvider 接口的 removeEvent() 方法可以通过指定索引删除事件:

CalendarEventProvider eventProvider = calendar.getEventProvider();
List<CalendarEvent> events = new ArrayList<>(eventProvider.getEvents());
eventProvider.removeEvent(events.get(events.size()-1));

removeAllEvents 方法依次删除所有事件:

CalendarEventProvider eventProvider = calendar.getEventProvider();
eventProvider.removeAllEvents();

有两个给日历控件添加事件数据的接口:ListCalendarEventProvider (默认创建) 和 ContainerCalendarEventProvider

ListCalendarEventProvideraddEvent() 方法需要 CalendarEvent 对象做为输入参数:

@Inject
private Calendar<LocalDateTime> calendar;

public void addEvents() {
    ListCalendarEventProvider listCalendarEventProvider = new ListCalendarEventProvider();
    calendar.setEventProvider(listCalendarEventProvider);
    listCalendarEventProvider.addEvent(generateEvent("Training", "Student training",
            LocalDateTime.of(2016, 10, 17, 9, 0), LocalDateTime.of(2016, 10, 17, 14, 0), false, "event-blue"));
    listCalendarEventProvider.addEvent(generateEvent("Development", "Platform development",
            LocalDateTime.of(2016, 10, 17, 15, 0), LocalDateTime.of(2016, 10, 17, 18, 0), false, "event-red"));
    listCalendarEventProvider.addEvent(generateEvent("Party", "Party with friends",
            LocalDateTime.of(2016, 10, 22, 13, 0), LocalDateTime.of(2016, 10, 22, 18, 0), false, "event-yellow"));
}

private SimpleCalendarEvent<LocalDateTime> generateEvent(String caption, String description, LocalDateTime start, LocalDateTime end, Boolean isAllDay, String stylename) {
    SimpleCalendarEvent<LocalDateTime> calendarEvent = new SimpleCalendarEvent<>();
    calendarEvent.setCaption(caption);
    calendarEvent.setDescription(description);
    calendarEvent.setStart(start);
    calendarEvent.setEnd(end);
    calendarEvent.setAllDay(isAllDay);
    calendarEvent.setStyleName(stylename);
    return calendarEvent;
}

ContainerCalendarEventProvider 可以直接将事件实体对象添加为日历的事件。事件实体对象必须 至少 包含事件开始日期、事件结束日期(数据类型为 datatypes 之一)和事件标题(String 类型)。

以下示例中假设 CalendarEvent 实体包含了所有需要的属性: eventCaptioneventDescriptioneventStartDateeventEndDateeventStylename, 他们的值将会设置到 calendar 组件的属性中:

<calendar id="calendar"
          dataContainer="calendarEventsDc"
          width="100%"
          height="100%"
          startDate="2016-10-01"
          endDate="2016-10-31"
          captionProperty="eventCaption"
          descriptionProperty="eventDescription"
          startDateProperty="eventStartDate"
          endDateProperty="eventEndDate"
          stylenameProperty="eventStylename"/>

Calendar 组件支持几个用来响应用户交互事件的监听器,比如日期、周标题,日期/事件范围选择,事件拖拽或者事件大小调整,往前或往后调日期的导航按钮。以下是监听器列表:

  • addDateClickListener(CalendarDateClickListener listener) - 日期点击监听器:

    calendar.addDateClickListener(
            calendarDateClickEvent ->
                    notifications.create()
                            .withCaption(String.format("Date clicked: %s", calendarDateClickEvent.getDate().toString()))
                            .show());
  • addWeekClickListener() - 周点击监听器。

  • addEventClickListener() - 日历事件点击监听器。

  • addEventResizeListener() - 日历事件大小调整监听器。

  • addEventMoveListener() - 事件拖拽、释放监听器。

  • addForwardClickListener() - 往前调日期导航钮的监听器。

  • addBackwardClickListener() - 往后调日期导航钮的监听器。

  • addRangeSelectListener() - 日历日期范围选择监听器。

日历事件可以通过 CSS 配置样式。需要配置 CSS 的样式时,创建一个样式名字,然后在 .scss 文件中设置需要的参数。例如,需要配置事件的背景颜色时:

.v-calendar-event.event-green {
  background-color: #c8f4c9;
  color: #00e026;
}

然后通过事件的 setStyleName 方法设置样式名字:

calendarEvent.setStyleName("event-green");

然后,事件的背景会被设置为绿色:

gui calendar 3

如果需要改变 Calendar 日历控件上日期和月份的名称,可以通过方法 setDayNames()setMonthNames() 实现,传入带新名称的 map 即可:

Map<DayOfWeek, String> days = new HashMap<>(7);
days.put(DayOfWeek.MONDAY,"Heavens and earth");
days.put(DayOfWeek.TUESDAY,"Sky");
days.put(DayOfWeek.WEDNESDAY,"Dry land");
days.put(DayOfWeek.THURSDAY,"Stars");
days.put(DayOfWeek.FRIDAY,"Fish and birds");
days.put(DayOfWeek.SATURDAY,"Animals and man");
days.put(DayOfWeek.SUNDAY,"Rest");
calendar.setDayNames(days);


3.5.2.1.6. 大小写锁定提示器

这个组件在用户使用 PasswordField 输入密码的时候提示用户是否开启了大小写锁定。

该组件对应的 XML 名称: capsLockIndicator

gui capsLockIndicator

可以用 capsLockOnMessagecapsLockOffMessage 属性来定义大小写锁定开启和关闭时的提示信息。

示例:

<hbox spacing="true">
    <passwordField id="passwordField"
                   capsLockIndicator="capsLockIndicator"/>
    <capsLockIndicator id="capsLockIndicator"/>
</hbox>
CapsLockIndicator capsLockIndicator = uiComponents.create(CapsLockIndicator.NAME);
capsLockIndicator.setId("capsLockIndicator");
passwordField.setCapsLockIndicator(capsLockIndicator);

CapsLockIndicator 组件被设计为配合 PasswordField 一起使用,当 PasswordField 获得焦点的时候该组件处理大小写锁定状态,当 PasswordField 失去焦点时,大小写锁定的状态就变成 inactive 了。因为此时没法监控关联的密码控件输入情况了。

可以用 visible 属性来动态改变 CapsLockIndicator 的可见性。但是如果此时界面已经打开了,控制可见性就不一定能按预想工作。

CapsLockIndicator 的展示可以使用带 $cuba-capslockindicator-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.7. 复选框

CheckBox 是一个拥有两个状态的组件: 选中或者未选中。

CheckBox

该组件对应的 XML 名称: checkBox

通过本地化消息包获取标签的复选框例子:

<checkBox id="accessField" caption="msg://accessFieldCaption"/>

勾选或者取消勾选复选框会改变它的值为 Boolean.TRUE 或者 Boolean.FALSE。这个值可以通过 getValue() 方法获取,也可以通过 setValue() 方法设置。通过 setValue() 设置 null 的话,会把值改成 Boolean.FALSE 然后取消复选框的选择。

复选框值的变化,跟其它组件一样,只要实现了 Field 接口,都可以通过 ValueChangeListener 监听到,ValueChangeEvent 事件的来源可以通过 isUserOriginated() 方法跟踪。比如:

@Inject
private CheckBox accessField;
@Inject
private Notifications notifications;

@Subscribe
protected void onInit(InitEvent event) {
    accessField.addValueChangeListener(valueChangeEvent -> {
        if (Boolean.TRUE.equals(valueChangeEvent.getValue())) {
            notifications.create()
                    .withCaption("set")
                    .show();
        } else {
            notifications.create()
                    .withCaption("not set")
                    .show();
        }
    });
}

通过 dataContainerproperty 属性可以创建关联数据源的复选框:

<data>
    <instance id="customerDc" class="com.company.sales.entity.Customer" view="_local">
        <loader/>
    </instance>
</data>
<layout>
    <checkBox dataContainer="customerDc" property="active"/>
</layout>

从这个例子可以看出,这个界面包含了一个关联 Customer 实体的 customerDs 数据容器,Customer 实体具有 active 属性。checkBoxdataContainer 属性需要指向一个数据容器;property 属性需要指向实体中需要展示到复选框控件的字段名字,这个字段必须要是 Boolean 类型。如果这个值是 null,那么复选框是默认为非选中状态。

CheckBox 的展示可以使用带 $cuba-checkbox-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.8. 颜色选择器

ColorPicker - 颜色选择器可以让用户预览并选择一种颜色。以 String 类型返回所选颜色的十六进制值。

gui color picker

以下为颜色选择器示例,它的标题取自本地化消息包:

<colorPicker id="colorPicker" caption="msg://colorPickerCaption"/>

下图为一个颜色选择弹窗关闭状态下的颜色选择器示例。

gui color picker mini

如果需要将颜色选择器与数据关联,需要使用 dataContainerproperty 属性。

<data>
    <collection id="carsDc" class="com.company.sales.entity.Car" view="_local">
        <loader>
            <query>
                <![CDATA[select e from sales_Car e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <colorPicker id="colorPicker" dataContainer="carsDc" property="color"/>
</layout>

colorPicker - 颜色选择器的属性:

  • buttonCaption - 显示在组件按钮上的标题。

  • defaultCaptionEnabled - 这个属性设置为 true 并且 buttonCaption 未设置时, 控件按钮上的标题为颜色的 HEX 值。

  • historyVisible - 标识是否在弹出窗口显示最近选择过的颜色。

以下属性用来控制哪些标签页可见:

  • rgbVisible - 控制 RGB 标签页是否可见。

  • hsvVisible - 控制 HSV 标签页是否可见。

  • swatchesVisible - 控制调色板标签页是否可见。

默认情况下只有 RGB 标签页可见。

如果需要在弹出窗口重新定义标签上的文字,可以使用以下属性:

  • popupCaption - 弹出窗口标题。

  • confirmButtonCaption - 确认按钮的标题。

  • cancelButtonCaption - 取消按钮的标题。

  • swatchesTabCaption - 调色板标签页标题。

  • lookupAllCaption - 查找所有颜色的标题。

  • lookupRedCaption - 查找红色的标题。

  • lookupGreenCaption - 查找绿色的标题。

  • lookupBlueCaption - 查找蓝色的标题。

getValue() 方法返回 String 类型,其中包含所选颜色的 HEX 码。



3.5.2.1.9. 复选框组

这是一个允许用户使用复选框从选项列表中选择多个值的组件。

gui CheckBoxGroup

该组件对应的 XML 名称: checkBoxGroup

可以使用 setOptions()setOptionsList()setOptionsMap()setOptionsEnum() 方法,或使用 optionsContainer 属性指定组件选项列表。

  • 使用 CheckBoxGroup 的最简单的情况是为实体属性选择枚举值。例如,Role 实体具有 RoleType 类型的 type 属性,它是一个枚举。然后可以通过 optionsEnum 属性来使用 CheckBoxGroup 显示这个属性,如下所示:

    <checkBoxGroup optionsEnum="com.haulmont.cuba.security.entity.RoleType"
                   property="type"/>

    setOptionsEnum() 将一个枚举类作为参数。选项列表将包含枚举值的本地化名称,组件的值将是一个枚举值。

    @Inject
    private CheckBoxGroup<RoleType> checkBoxGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        checkBoxGroup.setOptionsEnum(RoleType.class);
    }

    使用 setOptions() 方法可以得到相同的结果,该方法可以使用所有类型的选项:

    @Inject
    private CheckBoxGroup<RoleType> checkBoxGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        checkBoxGroup.setOptions(new EnumOptions<>(RoleType.class));
    }
  • setOptionsList() 能够以编程方式指定组件选项列表。为此请在 XML 描述中声明一个组件:

    <checkBoxGroup id="checkBoxGroup"/>

    然后将组件注入控制器并为其指定选项列表:

    @Inject
    private CheckBoxGroup<Integer> checkBoxGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        List<Integer> list = new ArrayList<>();
        list.add(2);
        list.add(4);
        list.add(5);
        list.add(7);
        checkBoxGroup.setOptionsList(list);
    }

    该组件将如下所示:

    gui CheckBoxGroup 2

    根据所选的选项,组件的 getValue() 方法将返回 Integer 类型的值:2、4、5、7。

  • setOptionsMap() 能够分别指定字符串名称和选项值。例如,我们可以为控制器中注入的 checkBoxGroup 组件设置以下选项字典:

    @Inject
    private CheckBoxGroup<Integer> checkBoxGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        Map<String, Integer> map = new LinkedHashMap<>();
        map.put("two", 2);
        map.put("four", 4);
        map.put("five", 5);
        map.put("seven", 7);
        checkBoxGroup.setOptionsMap(map);
    }

    该组件将如下所示:

    gui CheckBoxGroup 3

    根据所选的选项,组件的 getValue() 方法将返回 Integer 类型的值:2、4、5、7,而不是界面上显示的字符串。

  • 该组件可以从数据容器中获取选项列表。要做到这点,需要使用 optionsContainer 属性。例如:

    <data>
        <collection id="employeesCt" class="com.company.demo.entity.Employee" view="_minimal">
            <loader>
                <query><![CDATA[select e from demo_Employee e]]></query>
            </loader>
        </collection>
    </data>
    <layout>
        <checkBoxGroup optionsContainer="employeesCt"/>
    </layout>

    在这种情况下,checkBoxGroup 组件将显示位于 employeesCt 数据容器中的 Employee 实体的实例名,其 getValue() 方法将返回所选实体实例。

    gui CheckBoxGroup 4

    使用captionProperty属性,可以指定一个实体属性作为选项的显示名称,而不是使用实例名称作为选项的显示名称。

    可以使用 CheckBoxGroup 接口的 setOptions() 方法以编程方式定义选项容器:

    @Inject
    private CheckBoxGroup<Employee> checkBoxGroup;
    @Inject
    private CollectionContainer<Employee> employeesCt;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        checkBoxGroup.setOptions(new ContainerOptions<>(employeesCt));
    }

可以使用 OptionDescriptionProvider 为选项生成描述(提示)。通过 setOptionDescriptionProvider() 方法或 @Install 注解使用:

@Inject
private CheckBoxGroup<Product> checkBoxGroup;

@Subscribe
public void onInit(InitEvent event) {
    checkBoxGroup.setOptionDescriptionProvider(product -> "Price: " + product.getPrice());
}
@Install(to = "checkBoxGroup", subject = "optionDescriptionProvider")
private String checkBoxGroupOptionDescriptionProvider(Experience experience) {
    switch (experience) {
        case HIGH:
            return "Senior";
        case COMMON:
            return "Middle";
        default:
            return "Junior";
    }
}

orientation 属性定义了组元素的方向。默认情况下,元素垂直排列。设置为 horizontal 将水平排列。



3.5.2.1.10. 货币组件

CurrencyField 是文本字段的子类型,专门用来输入货币值。Studio 生成界面代码时,为带有 @CurrencyValue 注解的属性默认使用该字段。在这个字段内部有个货币符号,默认是右对齐状态。

gui currencyField

该组件对应的 XML 名称: currencyField

基本上,CurrencyFieldTextField 的功能是一样的。可以给这个字段手动设置一个数据类型,但是只支持从 NumericDatatype 继承的数字类型。如果提供的数据类型不能解析,程序会抛出异常。

CurrencyField 也可以通过 dataContainerproperty 属性绑定数据容器

<currencyField currency="$"
               dataContainer="orderDc"
               property="amount"/>

currencyField 属性:

  • currency - 作为货币符号的文本。

    <currencyField currency="USD"/>
  • currencyLabelPosition - 设置组件内货币符号的位置:

    • LEFT - 在组件文字输入的左侧

    • RIGHT - 在组件文字输入的右侧(默认值)。

  • showCurrencyLabel - 定义是否需要显示货币符号。



3.5.2.1.11. 数据网格

本章内容:

DataGrid - 数据网格组件,与 表格 组件类似, 适合用于展示、分类、排序表格类数据,由于使用了在滚动时加载数据的延迟加载方式,所以此组件具有更好的数据行、列操作性能。

gui dataGrid 1

该组件的 XML 名称为 dataGrid

以下为在 XML 文件中定义数据网格的示例:

<data>
    <collection id="ordersDc" class="com.company.sales.entity.Order" view="order-with-customer">
        <loader id="ordersDl">
            <query>
                <![CDATA[select e from sales_Order e order by e.date]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <dataGrid id="ordersDataGrid" dataContainer="ordersDc" height="100%" width="100%">
        <columns>
            <column id="date" property="date"/>
            <column id="customer" property="customer.name"/>
            <column id="amount" property="amount"/>
        </columns>
    </dataGrid>
</layout>

其中 <column> 元素里的 id 属性是列的标识,property 指示数据容器实体中的属性,对应的数据库中的数据会展示在该列。

如需以编程的方式在界面控制器定义数据源,则可以在 XML 中使用 metaClass 属性替换声明式的设置 dataContainer

dataGrid 元素

  • columns - 必要元素,定义 DataGrid 的所有列。columns 元素有如下属性:

    • includeAll – 加载 dataContainer 中定义的 view 的所有属性。

      在下面的例子中,我们显示了 customersDc 中使用视图的所有属性。如果视图包含系统属性,也同样会显示。

      <dataGrid id="dataGrid"
                width="100%"
                height="100%"
                dataContainer="customersDc">
          <columns includeAll="true"/>
      </dataGrid>

      如果实体的视图包含引用属性,该属性会按照其实例名称进行展示。如果需要展示一个特别的属性,则需要在视图和 column 元素中定义:

      <columns includeAll="true">
          <column id="address.street"/>
      </columns>

      如果未指定视图,includeAll 会加载给定实体及其祖先的所有属性。

    • exclude – 英文逗号分隔的属性列表,这些属性不会被加载到数据网格。

      在下面的例子中,我们会显示除了 nameorder 之外的所有属性:

      <dataGrid id="dataGrid"
                width="100%"
                height="100%"
                dataContainer="customersDc">
          <columns includeAll="true"
                   exclude="name, order"/>
      </dataGrid>

    每一列是在嵌套的 column 元素中描述,该元素有如下属性:

    • id - 非必须属性,标识一列。如果没有设置,对应的 property 值会被用作该列的标识,所以这种时候 property 值是必须的。否则会抛出 GuiDevelopmentException 异常。如果列是在界面控制器代码中创建的,则 id 属性是必须的。

    • property - 指对应的实体属性。可以是数据源/数据容器实体的属性,也可以是关联实体的属性,关联实体属性前面需要加上关联类名字并通过“.”连接。例如:

      <columns>
          <column id="date" property="date"/>
          <column id="customer" property="customer"/>
          <column id="customerName" property="customer.name"/>
          <column id="customerCountry" property="customer.address.country"/>
      </columns>
    • caption - 可选属性,定义列标题。如果未设置,对应的本地化属性名称会被做为列标题显示。

    • expandRatio - 设置列宽占比。默认情况下,所有列等宽(expandRatio = 1)。如果至少有一列设置了其它值,则忽略所有的隐式值,并且只使用设置的值。

    • collapsible - 定义用户是否可以通过 DataGrid 表格组件右上角的侧边栏菜单隐藏/显示该列。默认为 true

    • collapsed - 可选属性,设置为 true 时自动隐藏该列。该属性默认值为 false

    • collapsingToggleCaption - 设置在侧边栏菜单中该列的标题。默认为 null, 此时侧边栏菜单中该列的标题与数据网格中该列的标题一致。

      gui dataGrid 2
    • resizable - 定义用户是否可以调整列宽。

    • sortable - 可选属性,可以用来关闭针对该列的排序。当整个 DataGrid 数据网格控件的 sortable 属性设置为 true(默认值)时生效。

    • width - 可选属性,定义列宽。只支持以像素为单位的数值类型。

    • minimumWidth - 设置最小列宽,以像素为单位。

    • maximumWidth - 设置最大列宽,以像素为单位。

    column 元素可以包含一个内嵌的 formatter 元素,通过它可以用不同于数据类型的格式展示数据:

    <column id="date" property="date">
        <formatter class="com.haulmont.cuba.gui.components.formatters.DateFormatter"
                   format="yyyy-MM-dd HH:mm:ss"
                   useUserTimezone="true"/>
    </column>
  • actions - 可选元素,定义 DataGrid 数据网格的操作。除了自定义的操作,ListActionType 枚举类中定义的标准操作也支持,它们是: create(创建)、 edit(编辑)、 remove(删除)、 refresh(刷新)、 add(添加,从数据库中选择一条记录放入当前数据网格)、 exclude(移出,将所选行从当前数据网格中移出,但不会从数据库删除).

  • buttonsPanel - 按钮区 ButtonsPanel,位于 DataGrid 数据网格的上方,其中包含各操作对应的按钮。

  • rowsCount - 可选元素,会为数据网格控件创建一个行数( RowsCount )控件。行数控件会启用数据分页,在界面控制器中调用 CollectionLoader.setMaxResults() 方法可以控制数据加载器中的数据量,进而能控制每页最大行数。另外,绑定到相同数据源的通用过滤器组件也能实现此功能。

    rowsCount 控件也会显示数据结果总数,但不需要把这些数据全部加载出来。用户可以点击 "?" 按钮, 它会调用 com.haulmont.cuba.core.global.DataManager#getCount 方法,该方法使用相同的参数请求数据库,同时使用 COUNT(*) 聚合函数代替加载数据。返回的数值会显示在 "?" 位置。

    RowsCount 组件的 autoLoad 属性如果设置为 true,启用自动加载行数。可以在 XML 描述中设置:

    <rowsCount autoLoad="true"/>

    另外,在界面控制器中也可以通过 RowsCount API 启用或禁用该功能:

    boolean autoLoadEnabled = rowsCount.getAutoLoad();
    rowsCount.setAutoLoad(false);

dataGrid 属性

  • aggregatable 属性可以启用对 DataGrid 的行进行聚合运算,支持下列运算符:

    • SUM − 和

    • AVG − 均值

    • COUNT − 计数

    • MIN − 最小值

    • MAX − 最大值

    聚合 DataGrid 列的 aggregation 元素需要设置 type 属性,表示聚合的函数。 默认情况下,聚合列只支持数字类型,比如 IntegerDoubleLongBigDecimal。聚合值会显示在 DataGrid 顶部的附加行内。聚合的功能与 表格 组件一致。也就是说,同样可以使用 strategyClassvalueDescriptionformatter

    带聚合列的 DataGrid XML 描述示例:

    <dataGrid id="ordersDataGrid"
              dataContainer="ordersDc"
              aggregationPosition="BOTTOM"
              aggregatable="true">
        <columns>
            <column id="customerGrade" property="customer.grade">
                <aggregation strategyClass="com.company.sample.CustomerGradeAggregation"
                             valueDescription="msg://customerGradeAggregationDesc"/>
            </column>
            <column id="amount" property="amount">
                <aggregation type="SUM">
                    <formatter class="com.company.sample.MyFormatter"/>
                </aggregation>
            </column>
            ...
        </columns>
        ...
    </dataGrid>
  • aggregationPosition 属性可以设置聚合值行的位置: TOPBOTTOM。默认为 TOP

  • columnResizeMode - 设置调整列宽时的动画效果。支持两种效果:

    • ANIMATED - 动画效果,列宽跟随鼠标拖拽(默认)。

    • SIMPLE - 简单效果,列宽会在拖拽动作结束后才发生改变。

    列宽变化事件可以通过监听器 ColumnResizeListener 跟踪。可以使用 isUserOriginated() 方法跟踪列宽变化事件的来源。

  • columnsCollapsingAllowed - 定义用户是否可以在表格侧边菜单中隐藏/折叠某些列。侧边菜单中,显示的列会标记打勾。其他菜单项:

    • Select all − 显示所有列

    • Deselect all − 隐藏所有列

      gui dataGrid 16

      当列名选中/非选中是,每列的 collapsed 属性会更新。当 columnsCollapsingAllowedfalse 时,列的 collapsed 属性就不能被设置为 true

      列折叠状态的变化可以通过监听器 ColumnCollapsingChangeListener 跟踪。列折叠事件的来源可以使用 isUserOriginated() 方法进行跟踪.

  • contextMenuEnabled - 开启或关闭右键菜单。默认为 true

    DataGrid 数据网格控件的右键点击事件可以通过监听器 ContextClickListener 跟踪。

  • editorBuffered - 编辑器缓冲模式开启或关闭。默认为 true

  • editorCancelCaption - 设置 DataGrid 数据网组件编辑器中取消(cancel)按钮的名称。

  • editorCrossFieldValidate - 在行内编辑器启用跨字段验证。默认为 true

  • editorEnabled - 启用数据项的行内编辑器。默认为 false。如果数据网格组件是跟 KeyValueCollectionContainer 绑定的,则该数据是只读的,此时设置 editorEnabled 属性便没有意义。

  • editorSaveCaption - 设置数据网格组件编辑器中保存(save)按钮的名称。

  • frozenColumnCount - 设置固定列的个数。0 表示不需要固定任何列,除了开启多选模式时的选择列。设为 -1 的时候即使选择列也不固定。

  • headerVisible - 定义是否显示表头。默认为 true

  • htmlSanitizerEnabled - 启用或禁用 HTML 清理。DataGrid 组件有些 provider 可以渲染 HTML:

    • HtmlRenderer

    • RowDescriptionProvider,在设置了 ContentMode.HTML 时。

    • DescriptionProvider 在设置了 ContentMode.HTML 时。

      htmlSanitizerEnabled 属性设置为 true 时,这些 provider 的执行结果会被清理,返回安全的 HTML。

      protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
                  "color=\"moccasin\">my</font> " +
                  "<font size=\"7\">big</font> <sup>sphinx</sup> " +
                  "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
                  "red;\">quartz</span><svg/onload=alert(\"XSS\")>";
      
      @Inject
      private DataGrid<Customer> customersDataGrid;
      @Inject
      private DataGrid<Customer> customersDataGrid2;
      @Inject
      private DataGrid<Customer> customersDataGrid3;
      
      @Subscribe
      public void onInit(InitEvent event) {
          customersDataGrid.setHtmlSanitizerEnabled(true);
          customersDataGrid.getColumn("name")
                  .setRenderer(customersDataGrid.createRenderer(DataGrid.HtmlRenderer.class));
      
          customersDataGrid2.setHtmlSanitizerEnabled(true);
          customersDataGrid2.setRowDescriptionProvider(customer -> UNSAFE_HTML, ContentMode.HTML);
      
          customersDataGrid3.setHtmlSanitizerEnabled(true);
          customersDataGrid3.getColumn("name").setDescriptionProvider(customer -> UNSAFE_HTML, ContentMode.HTML);
      }

      htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

      如果使用 HtmlRenderer 时,想用自定义的 presentationProvider,则展示的值默认不会被清理。如需清理 HTML,则需要手动做一下:

      protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
                  "color=\"moccasin\">my</font> " +
                  "<font size=\"7\">big</font> <sup>sphinx</sup> " +
                  "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
                  "red;\">quartz</span><svg/onload=alert(\"XSS\")>";
      
      @Inject
      private DataGrid<Customer> customersDataGrid;
      
      @Inject
      private HtmlSanitizer htmlSanitizer;
      
      @Subscribe
      public void onInit(InitEvent event) {
          customersDataGrid.getColumn("name")
                  .setRenderer(customersDataGrid.createRenderer(DataGrid.HtmlRenderer.class),
                          (Function<String, String>) nameValue -> htmlSanitizer.sanitize(UNSAFE_HTML));
      }
  • reorderingAllowed - 定义用户是否可以通过鼠标拖拽重新设置列的顺序。默认值为 true

    列排序的改变事件可以通过监听器 ColumnReorderListener 跟踪。排序改变事件的来源可以通过 isUserOriginated() 方法跟踪。

  • selectionMode - 设置行选择模式,支持以下四种:

    • SINGLE - 单行选择。

    • MULTI - 多行选择。

    • MULTI_CHECK - 通过内嵌复选框列进行多选。

    • NONE - 不支持选择。

      行选中事件可以通过监听器 SelectionListener 跟踪。行选中事件的来源可以使用 isUserOriginated() 方法跟踪.

      gui dataGrid 3
  • sortable - 开启/关闭数据网格控件的排序功能。默认为 true。开启后,点击列名会在列名右边显示排序图标。使用列的 sortable 属性可以禁用该列的排序功能。

    DataGrid 的排序事件可以通过监听器 SortListener 跟踪。排序事件的来源可以通过 isUserOriginated() 方法跟踪。

  • textSelectionEnabled - 开启/关闭数据网格单元格中的文字选择功能。默认为 false

DataGrid 接口方法

  • getColumns() - 按当前界面的展示顺序获取列集合。

  • getSelected()getSingleSelected() - 返回所选行对应实体的实例。getSelected() 返回一个集合。如果没有选择任何行,则返回一个空的集合。如果设置的是 SelectionMode.SINGLE 单选模式,用 getSingleSelected() 会更方便,它直接返回一个被选择的实体实例,或者 null(没有选择任何行)。

  • getVisibleColumns() - 按当前界面中列的显示顺序获取用户可见的列集合。

  • scrollTo() - 将 DataGrid 滚动到指定行。需要一个实体实例做为输入参数来指定滚动到哪一行。除了实体实例参数,另有重载方法支持 ScrollDestination 参数,该参数可以为以下值:

    • ANY - 滚动尽量少的位置来展示所需要的数据。

    • START - 滚动 DataGrid ,使所需要的数据展示在可见部分的顶端。

    • MIDDLE - 滚动 DataGrid ,使所需要的数据展示在可见部分的中部。

    • END - 滚动 DataGrid ,使所需要的数据展示在可见部分的底部。

  • scrollToStart() and scrollToEnd() - 将 DataGrid 滚动到开头或结尾。

  • addCellStyleProvider() - 为 DataGrid 单元格添加 style provider。

  • addRowStyleProvider() - 为 DataGrid 行添加 style provider。

  • setEnterPressAction() - 设置按下 回车键 时需要执行的操作。如果没有定义这种操作,控件会尝试按以下顺序找一个合适的操作:

    • setItemClickAction() 方法定义的操作。

    • 通过 shortcut 快捷键属性定义给 回车键 的操作。

    • edit(编辑) 操作。

    • view(查看) 操作。

    如果找到一个操作,并且其属性 enabled = true,则会执行它。

  • setItemClickAction() - 设置双击时的操作。如果没有定义,组件会按以下顺序找一个合适的操作:

    • 通过 shortcut 快捷键属性定义给 回车键 的操作。

    • edit(编辑) 操作。

    • view(查看) 操作。

    如果找到一个操作并且属性 enabled = true,则会执行它。

    单击事件可以通过监听器 ItemClickListener 跟踪。

  • sort() - 根据指定列对数据进行排序,通过枚举值 SortDirection 控制排序方式:

    • ASCENDING - 升序 (A-Z, 1..9)。

    • DESCENDING - 降序 (Z-A, 9..1)。

  • getAggregationResults() 方法返回聚合结果的映射(map),键值为 DataGrid 的列标识符,值为聚合值。

使用 description providers

  • setDescriptionProvider() 方法用来为每个 DataGrid 列的单元格生成可选的描述(提示)。描述支持 HTML 标记。

    @Inject
    private DataGrid<Customer> customersDataGrid;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        customersDataGrid.getColumnNN("age").setDescriptionProvider(customer ->
                        getPropertyCaption(customer, "age") +
                                customer.getAge(),
                ContentMode.HTML);
    
        customersDataGrid.getColumnNN("active").setDescriptionProvider(customer ->
                        getPropertyCaption(customer, "active") +
                                getMessage(customer.getActive() ? "trueString" : "falseString"),
                ContentMode.HTML);
    
        customersDataGrid.getColumnNN("grade").setDescriptionProvider(customer ->
                        getPropertyCaption(customer, "grade") +
                                messages.getMessage(customer.getGrade()),
                ContentMode.HTML);
    }
    gui dataGrid 11
  • setRowDescriptionProvider() 方法用来为每个 DataGrid 行生成可选的描述(提示)。如果同时也设置了列描述提供者,只有在列描述提供者返回 null 时显示行描述提供器实例。

    customersDataGrid.setRowDescriptionProvider(Instance::getInstanceName);
    gui dataGrid 10

使用 DetailsGenerator 接口

使用 setDetailsGenerator() 方法设置 DetailsGenerator 接口,可以生成自定义控件来展示对应行的明细:

@Inject
private DataGrid<Order> ordersDataGrid;
@Inject
private UiComponents uiComponents;

@Install(to = "ordersDataGrid", subject = "detailsGenerator")
protected Component ordersDataGridDetailsGenerator(Order order) {
    VBoxLayout mainLayout = uiComponents.create(VBoxLayout.NAME);
    mainLayout.setWidth("100%");
    mainLayout.setMargin(true);

    HBoxLayout headerBox = uiComponents.create(HBoxLayout.NAME);
    headerBox.setWidth("100%");

    Label infoLabel = uiComponents.create(Label.NAME);
    infoLabel.setHtmlEnabled(true);
    infoLabel.setStyleName("h1");
    infoLabel.setValue("Order info:");

    Component closeButton = createCloseButton(order);
    headerBox.add(infoLabel);
    headerBox.add(closeButton);
    headerBox.expand(infoLabel);

    Component content = getContent(order);

    mainLayout.add(headerBox);
    mainLayout.add(content);
    mainLayout.expand(content);

    return mainLayout;
}

private Component createCloseButton(Order entity) {
    Button closeButton = uiComponents.create(Button.class);
    // ... (1)
    return closeButton;
}

private Component getContent(Order entity) {
    Label<String> content = uiComponents.create(Label.TYPE_STRING);
    content.setHtmlEnabled(true);
    StringBuilder sb = new StringBuilder();
    // ... (2)
    content.setValue(sb.toString());
    return content;
}
1 – 参考 DataGridDetailsGeneratorSample 类中的 createCloseButton 方法全部代码。
2 – 参考 DataGridDetailsGeneratorSample 类中的 getContent 方法全部代码。

结果如图所示:

gui dataGrid 15

使用 DataGrid 行内编辑器

DataGrid 组件支持行内编辑器来编辑单元格数据。当用户要编辑一个数据项时,行内编辑界面会显示并自带默认的保存和取消按钮。

行内编辑器对应的方法有:

  • getEditedItem() - 返回正在被编辑的数据项。

  • isEditorActive() - 是否正在行内编辑界面编辑某个数据项。

  • editItem(Object itemId)(废弃) - 为提供了 id 的数据项打开编辑界面。如果数据项在当前界面区域不可见,数据网格会将数据项滚动到可视区域。

  • edit(Entity item) - 为指定的数据项打开编辑界面。如果数据项在当前界面区域不可见,数据网格会将数据项滚动到可视区域。

DataGrid 行内编辑器可以使用实体约束(跨字段验证)。如果有验证错误,DataGrid 会显示错误消息。开启/禁用该功能或者获取当前状态可以使用下面方法:

  • setEditorCrossFieldValidate(boolean validate) - 启用、禁用行内编辑器的跨字段验证。默认为 true

  • isEditorCrossFieldValidate() - 如果行内编辑器的跨字段验证开启,则返回 true

使用以下方法添加/删除行内编辑界面打的监听器:

  • addEditorOpenListener(), removeEditorCloseListener() - 添加/删除行内编辑界面打开监听器。

    当用户双击 DataGrid 数据网格中某个区域时,行内编辑界面打开,使用上述监听器,可以获取被编辑行的其它字段并进行需要的修改。这种方法可以使得不用关闭当前行内编辑器就能修改其它字段。

    例如:

    customersTable.addEditorOpenListener(editorOpenEvent -> {
        Map<String, Field> fieldMap = editorOpenEvent.getFields();
        Field active = fieldMap.get("active");
        Field grade = fieldMap.get("grade");
    
        ValueChangeListener listener = e ->
                active.setValue(true);
        grade.addValueChangeListener(listener);
    });
  • addEditorCloseListener(), removeEditorCloseListener() - 添加/删除行内编辑界面关闭监听器。

  • addEditorPreCommitListener(), removeEditorPreCommitListener() - 添加/删除行内编辑界面数据提交前监听器。

  • addEditorPostCommitListener(), removeEditorPostCommitListener() - 添加/删除行内编辑界面数据提交后监听器。

行内编辑器所做的数据修改只提交到数据源或者数据容器。需要额外的代码把他们持久化到数据库。

可以通过 EditorFieldGenerationContext 类对编辑器组件进行定制,在某一列上使用 setEditFieldGenerator() 方法设置该列数据的编辑器组件:

@Inject
private DataGrid<Order> ordersDataGrid;
@Inject
private UiComponents uiComponents;

@Subscribe
protected void onInit(InitEvent event) {
    ordersDataGrid.getColumnNN("amount").setEditFieldGenerator(orderEditorFieldGenerationContext -> {
        LookupField<BigDecimal> lookupField = uiComponents.create(LookupField.NAME);
        lookupField.setValueSource((ValueSource<BigDecimal>) orderEditorFieldGenerationContext
                .getValueSourceProvider().getValueSource("amount"));
        lookupField.setOptionsList(Arrays.asList(BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TEN));

        return lookupField;
    });
}

结果如下:

gui dataGrid 14

使用 ColumnGenerator 接口

DataGrid 组件可以添加自定义列,有两种方式:

  • 声明式,在界面控制器使用 @Install 注解:

    @Inject
    private UiComponents uiComponents;
    
    @Install(to = "dataGrid.fullName", subject = "columnGenerator")
    protected Component fullNameColumnGenerator(DataGrid.ColumnGeneratorEvent<Customer> e) {
        Label<String> label = uiComponents.create(Label.TYPE_STRING);
        label.setValue(e.getItem().getFirstName() + " " + e.getItem().getLastName());
        return label;
    }

    ColumnGeneratorEvent 包含实体信息以及列标识符,该实体显示在 DataGrid 的当前行。

  • 使用下面的方法编程创建:

    • addGeneratedColumn(String columnId, ColumnGenerator generator)

    • addGeneratedColumn(String columnId, ColumnGenerator generator, int index)

    ColumnGenerator 是用来定义生成的列或者计算出的列的接口:

    • 该列每一行的数据值,

    • 该列数据类型。

    下面是一个生成列的示例,这个列显示大写的用户登录名:

    @Subscribe
    protected void onInit(InitEvent event) {
        DataGrid.Column column = usersGrid.addGeneratedColumn("loginUpperCase", new DataGrid.ColumnGenerator<User, String>(){
            @Override
            public String getValue(DataGrid.ColumnGeneratorEvent<User> event){
                return event.getItem().getLogin().toUpperCase();
            }
    
            @Override
            public Class<String> getType(){
                return String.class;
            }
        }, 1);
        column.setCaption("Login Upper Case");
    }

    结果如下:

    gui dataGrid 7

    默认情况下,新生成的列加在数据网格的最右边。有两种方法可以管理列的位置:代码中使用 index 或者在界面 XML 文件中提前定义好并设置 id, 然后在 addGeneratedColumn 方法中使用该 id。

使用 renderers

数据在列中的显示方式可以使用带参数的 renderer 以声明式的方法自定义。一些 DataGrid 的 renderer 通过特定的 XML 元素设置,并且定义了对应的属性作为参数。对非自定义生成列,可以声明 renderer。

平台支持的 renderer 列表:

  • ButtonRenderer – 以字符串值形式展示按钮标题。

    ButtonRenderer 不能在 XML 描述中定义,因为没有办法在 XML 描述中定义 renderer 点击监听器。 Studio 会在界面控制器的 init() 中生成 ButtonRenderer 的声明代码:

    @Inject
    private DataGrid<Customer> customersDataGrid;
    
    @Inject
    private Notifications notifications;
    
    @Subscribe
    public void onInit(InitEvent event) {
        DataGrid.ButtonRenderer<Customer> customersDataGridNameRenderer =
                    customersDataGrid.createRenderer(DataGrid.ButtonRenderer.class);
        customersDataGridNameRenderer.setRendererClickListener(clickableButtonRendererClickEvent ->
            {
                notifications.create()
                    .withType(Notifications.NotificationType.TRAY)
                    .withCaption("ButtonRenderer")
                    .withDescription("Column id: " + clickableButtonRendererClickEvent.getColumnId())
                    .show();
            });
        customersDataGrid.getColumn("name").setRenderer(customersDataGridNameRenderer);
    }
  • CheckBoxRenderer – 将 boolean 值展示为复选框图标。

    DataGridcolumn 元素有子元素 checkBoxRenderer

    <column property="checkBoxRenderer" id="checkBoxRendererColumn">
        <checkBoxRenderer/>
    </column>
  • ClickableTextRenderer – 将普通的文本字符串显示为链接,并有回调处理器。

    ClickableTextRenderer 不能在 XML 描述中定义,因为没有办法在 XML 描述中定义 renderer 点击监听器。 Studio 会在界面控制器的 init() 中生成 ClickableTextRenderer 的声明代码:

    @Inject
    private DataGrid<Customer> customersDataGrid;
    
    @Inject
    private Notifications notifications;
    
    @Subscribe
    public void onInit(InitEvent event) {
        DataGrid.ClickableTextRenderer<Customer> customersDataGridNameRenderer =
                    customersDataGrid.createRenderer(DataGrid.ClickableTextRenderer.class);
        customersDataGridNameRenderer.setRendererClickListener(clickableTextRendererClickEvent -> {
            notifications.create()
                    .withType(Notifications.NotificationType.TRAY)
                    .withCaption("ClickableTextRenderer")
                    .withDescription("Column id: " + clickableTextRendererClickEvent.getColumnId())
                    .show();
        });
        customersDataGrid.getColumn("name").setRenderer(customersDataGridNameRenderer);
    }
  • ComponentRenderer – UI 组件 renderer

    DataGridcolumn 元素有子元素 componentRenderer

    <column property="componentRenderer" id="componentRendererColumn">
        <componentRenderer/>
    </column>
  • DateRenderer – 用定义的格式显示日期

    DataGridcolumn 元素有子元素 dateRenderer,该元素有非必要的 nullRepresentation 属性和必填的 format 字符串属性:

    <column property="dateRenderer" id="dateRendererColumn">
        <dateRenderer nullRepresentation="null" format="yyyy-MM-dd HH:mm:ss"/>
    </column>
  • IconRenderer – 展示 CubaIcon 的 renderer。

    DataGridcolumn 元素有子元素 iconRenderer

    下面的例子展示将实体 String 类型的属性渲染成 CubaIcon

    <column id="iconOS" property="iconOS">
        <iconRenderer/>
    </column>
    @Install(to = "devicesTable.iconOS", subject = "columnGenerator")
    private Icons.Icon devicesTableIconOSColumnGenerator(DataGrid.ColumnGeneratorEvent<Device> event) {
        return CubaIcon.valueOf(event.getItem().getIconOS());
    }

    结果如下:

    gui dataGrid iconColumn
  • ImageRenderer – 使用图片的路径渲染图片。

    ImageRenderer 不能在 XML 描述中定义,因为没有办法在 XML 描述中定义 renderer 点击监听器。 Studio 会在界面控制器的 init() 中生成 ImageRenderer 的声明代码:

    @Inject
    private DataGrid<TestEntity> testEntitiesDataGrid;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    public void onInit(InitEvent event) {
        DataGrid.ImageRenderer<TestEntity> imageRenderer =
                testEntitiesDataGrid.createRenderer(DataGrid.ImageRenderer.class);
        imageRenderer.setRendererClickListener(imageRendererClickEvent -> notifications.create()
                .withType(Notifications.NotificationType.TRAY)
                .withCaption("ImageRenderer")
                .withDescription("Column id: " + imageRendererClickEvent.getColumnId())
                .show());
        testEntitiesDataGrid.getColumn("imageRendererColumn").setRenderer(imageRenderer);
    }
  • HtmlRenderer – 展示 HTML

    DataGridcolumn 元素有子元素 htmlRenderer,该元素有非必要的属性 nullRepresentation

    <column property="htmlRenderer" id="htmlRendererColumn">
        <htmlRenderer nullRepresentation="null"/>
    </column>
  • LocalDateRenderer – 以 LocalDate 值显示日期

    DataGridcolumn 元素有子元素 localDateRenderer,该元素有非必要的 nullRepresentation 属性和必填的 format 字符串属性:

    <column property="localDateRenderer" id="localDateRendererColumn">
        <localDateRenderer nullRepresentation="null" format="dd/MM/YYYY"/>
    </column>
  • LocalDateTimeRenderer – 以 LocalDateTime 值显示日期。

    DataGridcolumn 元素有子元素 localDateTimeRenderer,该元素有非必要的 nullRepresentation 属性和必填的 format 字符串属性:

    <column property="localDateTimeRenderer" id="localDateTimeRendererColumn">
        <localDateTimeRenderer nullRepresentation="null" format="dd/MM/YYYY HH:mm:ss"/>
    </column>
  • NumberRenderer – 以定义的格式显示数字。

    DataGridcolumn 元素有子元素 numberRenderer,该元素有非必要的 nullRepresentation 属性和必填的 format 字符串属性:

    <column property="numberRenderer" id="numberRendererColumn">
        <numberRenderer nullRepresentation="null" format="%f"/>
    </column>
  • ProgressBarRenderer – 将 0~1 之间 double 类型的值展示为 ProgressBar 组件。

    DataGridcolumn 元素有子元素 progressBarRenderer

    <column property="progressBar" id="progressBarColumn">
        <progressBarRenderer/>
    </column>
  • TextRenderer – 展示字符串

    DataGridcolumn 元素有子元素 textRenderer,该元素有非必要的 nullRepresentation 属性:

    <column property="textRenderer" id="textRendererColumn">
        <textRenderer nullRepresentation="null"/>
    </column>

WebComponentRenderer 接口可以使得在数据网格单元格中显示不同的 Web 控件。以下是生成一个带查找控件的列的例子:

@Inject
private DataGrid<User> usersGrid;
@Inject
private UiComponents uiComponents;
@Inject
private Configuration configuration;
@Inject
private Messages messages;

@Subscribe
protected void onInit(InitEvent event) {
    Map<String, Locale> locales = configuration.getConfig(GlobalConfig.class).getAvailableLocales();
    Map<String, String> options = new TreeMap<>();
    for (Map.Entry<String, Locale> entry : locales.entrySet()) {
        options.put(entry.getKey(), messages.getTools().localeToString(entry.getValue()));
    }

    DataGrid.Column column = usersGrid.addGeneratedColumn("language",
            new DataGrid.ColumnGenerator<User, Component>() {
                @Override
                public Component getValue(DataGrid.ColumnGeneratorEvent<User> event) {
                    LookupField<String> component = uiComponents.create(LookupField.NAME);
                    component.setOptionsMap(options);
                    component.setWidth("100%");

                    User user = event.getItem();
                    component.setValue(user.getLanguage());

                    component.addValueChangeListener(e -> user.setLanguage(e.getValue()));

                    return component;
                }

                @Override
                public Class<Component> getType() {
                    return Component.class;
                }
            });

    column.setRenderer(new WebComponentRenderer());
}

结果如下:

gui dataGrid 13

当字段类型与 renderer 支持的类型不匹配时,可以创建一个 Function 来匹配模型和视图的数据类型。比如,想把布尔类型用图标展示时,可以巧妙的使用 HtmlRenderer 来做 HTML 渲染以及实现布尔类型转换为图标展示的逻辑。

@Inject
private DataGrid<User> usersGrid;

@Subscribe
protected void onInit(InitEvent event) {

    DataGrid.Column<User> hasEmail = usersGrid.addGeneratedColumn("hasEmail", new DataGrid.ColumnGenerator<User, Boolean>() {
        @Override
        public Boolean getValue(DataGrid.ColumnGeneratorEvent<User> event) {
            return StringUtils.isNotEmpty(event.getItem().getEmail());
        }

        @Override
        public Class<Boolean> getType() {
            return Boolean.class;
        }
    });

    hasEmail.setCaption("Has Email");
    hasEmail.setRenderer(
        usersGrid.createRenderer(DataGrid.HtmlRenderer.class),
        (Function<Boolean, String>) hasEmailValue -> {
            return BooleanUtils.isTrue(hasEmailValue)
                    ? FontAwesome.PLUS_SQUARE.getHtml()
                    : FontAwesome.MINUS_SQUARE.getHtml();
        });
}

结果如下:

gui dataGrid 9

Renderer 可以通过三种方式创建:

  • 声明式的通过 DataGridcolumn 元素的特定元素。

  • DataGrid 接口的 set 方法中直接设置 renderer 接口。

  • 为对应的模块直接创建 renderer 实现:

    dataGrid.createRenderer(DataGrid.ImageRenderer.class) → new WebImageRenderer()

    目前,该方式只适合 Web 模块。

目前平台支持以下 renderer 接口:

Header 和 Footer

HeaderRowFooterRow 接口受制于分别展示表头和表尾单元格,支持跨列合并单元格。

DataGrid 的以下方法用于创建和管理表头、表尾:

  • appendHeaderRow()appendFooterRow() - 在表头或表尾区底部添加一个新行。

  • prependHeaderRow()prependFooterRow() - 在表头或表尾区顶部添加一个新行。

  • addHeaderRowAt()addFooterRowAt() - 在表头或表尾区的指定位置添加新行。该位置及其后面的行位置顺序下移,行索引增加。

  • removeHeaderRow()removeFooterRow() - 从表头或表尾区删除指定行。

  • getHeaderRowCount()getFooterRowCount() - 获取表头或表尾区行数。

  • setDefaultHeaderRow() - 设置表头默认行。默认表头行为用户提供排序功能。

HeaderCellFooterCell 接口提供自定义静态单元格功能:

  • setStyleName() - 为单元格设置自定义样式。

  • getCellType() - 返回单元格内容类型。静态单元格枚举类型 DataGridStaticCellType 有三个值:

    • TEXT - 文本

    • HTML - HTML

    • COMPONENT - 组件

  • getComponent()getHtml()getText() - 不同类型单元格获取内容的方法。

下面这个例子中,表头包含合并的单元格,表尾显示经计算得出的值:

<dataGrid id="dataGrid" dataContainer="countryGrowthDs" width="100%">
    <columns>
        <column property="country"/>
        <column property="year2017"/>
        <column property="year2018"/>
    </columns>
</dataGrid>
@Inject
private DataGrid<CountryGrowth> dataGrid;
@Inject
private UserSessionSource userSessionSource;
@Inject
private Messages messages;
@Inject
private CollectionContainer<CountryGrowth> countryGrowthsDc;

private DecimalFormat percentFormat;

@Subscribe
protected void onBeforeShow(BeforeShowEvent event) {
    initPercentFormat();
    initHeader();
    initFooter();
    initRenderers();
}

private DecimalFormat initPercentFormat() {
    percentFormat = (DecimalFormat) NumberFormat.getPercentInstance(userSessionSource.getLocale());
    percentFormat.setMultiplier(1);
    percentFormat.setMaximumFractionDigits(2);
    return percentFormat;
}

private void initRenderers() {
    dataGrid.getColumnNN("year2017").setRenderer(new WebNumberRenderer(percentFormat));
    dataGrid.getColumnNN("year2018").setRenderer(new WebNumberRenderer(percentFormat));
}

private void initHeader() {
    DataGrid.HeaderRow headerRow = dataGrid.prependHeaderRow();
    DataGrid.HeaderCell headerCell = headerRow.join("year2017", "year2018");
    headerCell.setText("GDP growth");
    headerCell.setStyleName("center-bold");
}

private void initFooter() {
    DataGrid.FooterRow footerRow = dataGrid.appendFooterRow();
    footerRow.getCell("country").setHtml("<strong>" + messages.getMainMessage("average") + "</strong>");
    footerRow.getCell("year2017").setText(percentFormat.format(getAverage("year2017")));
    footerRow.getCell("year2018").setText(percentFormat.format(getAverage("year2018")));
}

private double getAverage(String propertyId) {
    double average = 0.0;
    List<CountryGrowth> items = countryGrowthsDc.getItems();
    for (CountryGrowth countryGrowth : items) {
        Double value = countryGrowth.getValue(propertyId);
        average += value != null ? value : 0.0;
    }
    return average / items.size();
}
gui dataGrid 12

DataGrid 样式

可以在 XML 描述中使用 stylename 属性为 DataGrid 组件设置预定义样式。

<dataGrid id="dataGrid"
          width="100%"
          height="100%"
          stylename="borderless"
          dataContainer="customersDc">
</dataGrid>

或者在界面控制器使用编程的方式设置。

dataGrid.setStyleName("borderless");

预定义样式:

  • borderless - DataGrid 无边线。

  • no-horizontal-lines - DataGrid 行之间无水平分割线。

  • no-vertical-lines - DataGrid 列之间无垂直分割线。

  • no-stripes - 每行背景颜色统一。

DataGrid 的展示可以使用带 $cuba-datagrid-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。


DataGrid 的属性列表

aggregatable - aggregationPosition - align - caption - captionAsHtml - colspan - columnResizeMode - columnsCollapsingAllowed - contextHelpText - contextHelpTextHtmlEnabled - contextMenuEnabled - css - dataContainer - description - descriptionAsHtml - editorBuffered - editorCancelCaption - editorCrossFieldValidate - editorEnabled - editorSaveCaption - emptyStateLinkMessage - emptyStateMessage - enable - box.expandRatio - frozenColumnCount - headerVisible - height - htmlSanitizerEnabled - icon - id - metaClass - reorderingAllowed - responsive - rowspan - selectionMode - settingsEnabled - sortable - stylename - tabIndex - textSelectionEnabled - visible - width

DataGrid 的元素

actions - buttonsPanel - columns - rowsCount

columns 元素属性列表

includeAll - exclude

column 元素的属性列表

caption - collapsed - collapsible - collapsingToggleCaption - editable - expandRatio - id - maximumWidth - minimumWidth - property - resizable - sort - sortable - width

column 的元素

aggregation - checkBoxRenderer - componentRenderer - dateRenderer - formatter - iconRenderer - htmlRenderer - localDateRenderer - localDateTimeRenderer - numberRenderer - progressBarRenderer - textRenderer

aggregation 的属性

strategyClass - type - valueDescription

API

addGeneratedColumn - applySettings - createRenderer - edit - getAggregationResults - saveSettings - getColumns - setDescriptionProvider - addCellStyleProvider - setConverter - setDetailsGenerator - setEditorCrossFieldValidate - setEmptyStateLinkClickHandler - setEnterPressAction - setItemClickAction - setRenderer - setRowDescriptionProvider - addRowStyleProvider - sort

DataGrid 监听器

ColumnCollapsingChangeListener - ColumnReorderListener - ColumnResizeListener - ContextClickListener - EditorCloseListener - EditorOpenListener - EditorPostCommitListener - EditorPreCommitListener - ItemClickListener - SelectionListener - SortListener

预定义样式

borderless - no-horizontal-lines - no-vertical-lines - no-stripes


3.5.2.1.12. 日期时间组件

DateField 由日期控件和时间控件组成。日期控件是支持输入的控件,在输入框里面带有一个可以下拉选择日期的按钮,时间控件则在日期输入控件的右边:

gui dateFieldSimple

该组件对应的 XML 名称:dateField

  • 如需创建一个关联数据的日期控件,需要用 dataContainerproperty 属性来设置:

    <data>
        <instance id="orderDc"
                  class="com.company.sales.entity.Order"
                  view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <dateField dataContainer="orderDc"
                   property="date"/>
    </layout>

    在上面这个例子中,界面有 Order 实体的数据容器 orderDcOrder 实体拥有 date 属性。XML 里面将 dateFielddataContainer 属性指向这个数据容器,然后将 property 属性指向实体中需要显示在这个控件的字段。

  • 如果这个控件关联实体的一个属性,它能根据实体属性的类型自动填充日期时间格式:

    • 如果这个实体属性是 java.sql.Date 类型或者这个属性有 @Temporal(TemporalType.DATE) 注解,那么时间控件部分会被隐藏不显示。日期控件部分的格式会按照 date 数据类型的格式显示,这个格式从主本地化消息包中的 dateFormat 键获取。

    • 其它情况下,时间控件会显示小时和分钟。时间部分的格式会按照 time 数据类型的格式显示,这个格式从主本地化消息包timeFormat 键获取。

  • 如果该控件不与实体属性相关联(比如没有设置数据容器和属性名称),可以使用 datatype 属性设置数据类型。 DateField 使用如下数据类型:

    • date

    • dateTime

    • localDate

    • localDateTime

    • offsetDateTime

  • 日期时间格式也可以通过组件的 dateFormat 属性来设置。这个属性的值可以是一个定义日期时间格式的字符串或者语言包中的一个键。

    日期时间格式是使用 SimpleDateFormat 类提供的规则来定义。( http://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html )。 如果格式中没有 H 或者 h 的话,时间控件部分将不显示。

    <dateField dateFormat="MM/yy" caption="msg://monthOnlyDateField"/>
    gui dateField format

    DateField 主要目的是通过填充占位符来使用键盘快速输入。所以这个组件只支持包含数字和分隔符的日期时间格式。那些复杂的含有星期文字表述或者月文字表述的格式目前是不支持的。

  • 可以通过 rangeStartrangeEnd 属性来定义可选的日期范围。一旦日期范围设定了,其它在范围之外的日期都会变成不可选状态。日期范围可以用"yyyy-MM-dd"这样的格式在界面 XML 里面配置或者在程序里通过相应的 setter 来设置。

    <dateField id="dateField" rangeStart="2016-08-15" rangeEnd="2016-08-19"/>
    gui datefield month range
  • autofill 属性设置为 true 则会支持在输入了 “日” 之后会自动填充年和月。如果该属性未启用,且日期没有填全,DateField 会清空值。

    如果 autofill 启用,并且设置了 rangeStartrangeEnd,会在自动填写日期时考虑这两个值的设置。

  • DateField 组件值的变化,跟其它实现了 Field 接口的组件一样,都可以用 ValueChangeListener 来监听。可以使用isUserOriginated() 方法跟踪 ValueChangeEvent 的来源。

  • 日期和时间的精度可以用组件的 resolution 属性来定义,这个属性的值需要是 DateField.Resolution 枚举类型 - SECMINHOURDAYMONTHYEAR。默认精度是 MIN,精确到分钟。

    如果 resolution="DAY" 而且 dateFormat 没有给定的话,控件的显示格式会从主本地化消息包里的 dateFormat 键获取。

    如果 resolution="MIN" 而且 dateFormat 没有给定的话,控件的显示格式会从主本地化消息包里的 dateTimeFormat 键获取。下面这个例子是精确到月的日期时间组件的写法:

    <dateField resolution="MONTH" caption="msg://monthOnlyDateField"/>
gui dateField resolution
  • DateField 还可以在服务器和用户之间转换时间戳的时区,前提是用户通过 setTimeZone() 设置了时区。当这个组件绑定了一个实体里的时间戳类型的属性的时候,时区会通过当前的用户会话自动设定。如果组件没有绑定时间戳类型的属性,可以通过在界面控制器调用 setTimeZone() 手动设置时区,这样 DateField 可以自动进行时区转换。

  • 日历中的当前日期是根据用户浏览器的时间戳确定的,依赖操作系统时区的设置。用户会话的时区不会影响此功能。

  • 在选用 Halo-based 主题的 Web Client,如果需要无边框无背景的样式,可以通过预定义的 borderless 样式来实现。同样,也支持 XML 配置或者在界面控制器使用编程方法实现。

    <dateField id="dateField"
               stylename="borderless"/>

    当用编程方法实现的时候,选用 HaloTheme 类的以 DATEFIELD_ 开头的常量:

    dateField.setStyleName(HaloTheme.DATEFIELD_BORDERLESS);


3.5.2.1.13. 日期选择器

DatePicker 是用来显示和选择日期的控件。跟DateField里面的下拉式日期选择器是有一样的外观。

gui datepicker mini

该组件对应的 XML 名称: datePicker

  • 可以使用dataContainerproperty属性来创建一个关联数据源的日期选择器:

    <data>
        <instance id="orderDc"
                  class="com.company.sales.entity.Order"
                  view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <datePicker id="datePicker"
                    dataContainer="orderDc"
                    property="date"/>
    </layout>

    在上面这个例子中,界面有 Order 实体的数据容器 orderDcOrder 实体拥有 date 属性。XML 里面将 datePickerdataContainer 属性指向这个数据容器,然后将 property 属性指向实体中需要显示在这个控件的字段。

  • 可以通过 rangeStartrangeEnd 属性来定义可选的日期范围。一旦日期范围设定了,其它在范围之外的日期都会变成不可选状态。

    <datePicker id="datePicker" rangeStart="2016-08-15" rangeEnd="2016-08-19"/>
    gui datepicker month range
  • 日期和时间的精度可以用组件的 resolution 属性来定义,这个属性的值需要是 DatePicker.Resolution 枚举类型 - DAYMONTHYEAR。默认精度是 DAY

    <datePicker id="datePicker" resolution="MONTH"/>
    gui datepicker month resolution
    <datePicker id="datePicker" resolution="YEAR"/>
    gui datepicker year resolution
  • 日历中的当前日期是根据用户浏览器的时间戳确定的,依赖操作系统的时区设置。用户会话的时区不会影响此功能。



3.5.2.1.14. 嵌入式组件(废弃)

从 CUBA Platform 6.8 开始这个 Embedded 组件就不推荐使用了。替换方案是使用 Image 组件来显示图片,用 BrowserFrame 组件来嵌入网页。

Embedded 组件是用来显示图片和在应用程序界面中嵌入任何形式的网页。

该组件对应的 XML 名称: embedded

下面这个例子演示了怎样用这个组件显示一个从 FileStorage 读取的图片:

  • 在 XML 描述中定义该组件:

    <groupBox caption="Embedded" spacing="true"
              height="250px" width="250px" expand="embedded">
        <embedded id="embedded" width="100%"
                  align="MIDDLE_CENTER"/>
    </groupBox>
  • 在界面控制器中,注入该组件和 FileStorageService 接口。在 init() 方法中,从调用方获取 FileDescriptor,然后加载对应的文件到字节数组中,并通过字节数组创建 ByteArrayInputStream 然后传给组件的 setSource() 方法:

    @Inject
    private Embedded embedded;
    @Inject
    private FileStorageService fileStorageService;
    
    @Override
    public void init(Map<String, Object> params) {
        FileDescriptor imageFile = (FileDescriptor) params.get("imageFile");
        byte[] bytes = null;
        if (imageFile != null) {
            try {
                bytes = fileStorageService.loadFile(imageFile);
            } catch (FileStorageException e) {
                showNotification("Unable to load image file", NotificationType.HUMANIZED);
            }
        }
        if (bytes != null) {
            embedded.setSource(imageFile.getName(), new ByteArrayInputStream(bytes));
            embedded.setType(Embedded.Type.IMAGE);
        } else {
            embedded.setVisible(false);
        }
    }

Embedded 组件支持几种不同类型的内容,在 HTML 里以不同方式渲染。可以用 setType() 来设置内容类型。支持的类型如下:

  • OBJECT - 允许在 HTML 的 <object> 和 <embed> 标签里嵌入特定的文件类型。

  • IMAGE - 在 HTML 的 <img> 标签嵌入图片。

  • BROWSER - 在 HTML 的 <iframe> 中嵌入一个网页。

在 Web Client 里面,这个组件支持显示存储在 VAADIN 文件夹的文件。可以采用相对路径来访问这个文件夹的资源,比如:

<embedded id="embedded"
          relativeSrc="VAADIN/themes/halo/my-logo.png"/>

或者:

embedded.setRelativeSource("VAADIN/themes/halo/my-logo.png")

也可以通过应用程序属性 cuba.web.resourcesRoot 来定义源文件目录。然后采用 file://url://,或者 theme:// 这些前缀引用这个目录下的文件:

<embedded id="embedded"
          src="file://my-logo.png"/>

或者

embedded.setSource("theme://branding/app-icon-menu.png");

如果要显示外部网页,把外部网页的 URL 传给这个组件就行了:

try {
    embedded.setSource(new URL("http://www.cuba-platform.com"));
} catch (MalformedURLException e) {
    throw new RuntimeException(e);
}


3.5.2.1.15. 字段组

FieldGroup 用来集中显示和编辑实体的多个属性。

gui fieldGroup

该组件对应的 XML 名称: fieldGroup

FieldGroup 只能在基于历史 API 的界面中使用。当前 API 中通过 Form 组件提供类似功能。

下面这个例子展示了在 XML 中定义一组字段的情况:

<dsContext>
    <datasource id="orderDs"
                class="com.sample.sales.entity.Order"
                view="order-with-customer">
    </datasource>
</dsContext>
<layout>
    <fieldGroup id="orderFieldGroup" datasource="orderDs" width="250px">
        <field property="date"/>
        <field property="customer"/>
        <field property="amount"/>
    </fieldGroup>
</layout>

在上面这个例子中,dsContext 定义了一个包含单个实体 Order数据源 orderDs。这里用 fieldGroup 组件的 datasource 属性来定义这个数据源。XML 元素 field 定义了那些需要显示在界面上的实体属性。

fieldGroup 的 XML 元素:

  • column – 可选元素,用来把字段放到多个列显示。为了达到这样的效果,field 元素不能直接放在 fieldGroup 元素里面,而需要放在一个 column 元素里,比如:

    <fieldGroup id="orderFieldGroup" datasource="orderDs" width="100%">
        <column width="250px">
            <field property="num"/>
            <field property="date"/>
            <field property="amount"/>
        </column>
        <column width="400px">
            <field property="customer"/>
            <field property="info"/>
        </column>
    </fieldGroup>

    这样的话,字段会被排成两列;第一列包含的几个字段的宽度会是 250px,第二列几个字段的宽度会是 400px

    column 元素的属性:

    • width – 定义列中的字段宽度。默认的字段宽度是 200px。这里可以采用像素或者整个列宽的百分比来定义。

    • flex – 伸缩率,定义当 fieldGroup 整体的宽度发生变化时,此列相对于其它列水平伸缩的程度。比如,可以定义一列的 flex=1,另一列的 flex=3

    • id – 列 id,可选,在做界面扩展的时候会用到。

  • field – 主要的组件元素,定义组件的一个字段。

    自定义的字段也可以放在 field 元素里:

    <fieldGroup>
        <field id="demo">
            <lookupField id="demoField" datasource="userDs" property="group"/>
        </field>
    </fieldGroup>

    field 元素的 XML 属性:

    • id – 如果 property 没设置,那么必须设置 id;如果 property 设置了,那么 id 默认跟 property 取一样的值。id 属性需要使用唯一的标识符,要么是 property 定义的字段名,要么是通过编程的方式定义的一个字段。如果是采取编程方式定义,那么 field 也需要有 custom="true" (参阅下面 custom 的说明)

    • property - 如果 id 没设置,那么必须设置此属性;这个属性的值必须是一个实体属性的名称,用来显示这个绑定的字段。

    • caption − 定义字段的显示名称。如果没设置的话,则会显示实体的属性本地化名称

    • inputPrompt - 如果这个字段使用的组件支持 inputPrompt 属性的话,这里可以直接设置这个属性的值。

    • visible − 通过这个属性控制是否显示这个字段及其名称(caption)。

    • datasource − 可以设置该字段单独的数据源,而不用整个 fieldGroup 组件的数据源。这样的话,一个 fieldGroup 就可以显示来自不同实体的属性了。

    • optionsDatasource 定义了一个用来做选项列表的数据源名称。可以给实体属性关联的字段定义选项数据源。默认情况下,选择关联实体的时候是通过一个查找界面来操作。但是如果 optionsDatasource 属性设置了,则可以通过下拉列表来选择。也就是说,其实设置这个属性会导致原本默认的 LookupPickerField 会被 PickerField 替换掉。

    • width − 设置字段宽度,不包括显示名称。默认是 200px。宽度值可以是像素值或者整个列宽的百分比。需要同时设置一列中所有的字段统一宽度,可以通过设置上面提到过的 columnwidth 属性。

    • custom – 如果设置成 true,表示这个字段不关联实体的属性,也不关联一个对应的组件。然后这个字段需要通过 FieldGroupsetComponent() 方法以编程方式实现,具体可以参考下面对于 setComponent() 的解释。

    • generator 属性用来以声明的方式创建自定义字段,需要设置这个属性的值为一个可以返回自定义组件的方法名:

      <fieldGroup datasource="productDs">
          <column width="250px">
              <field property="description" generator="generateDescriptionField"/>
          </column>
      </fieldGroup>
      public Component generateDescriptionField(Datasource datasource, String fieldId) {
          TextArea textArea = uiComponents.create(TextArea.NAME);
          textArea.setRows(5);
          textArea.setDatasource(datasource, fieldId);
          return textArea;
      }
    • linkScreen - 当 link 属性设置成 true,用来定义点击链接时需要打开的界面的标识符。

    • linkScreenOpenType - 定义界面打开的类型(THIS_TABNEW_TAB 或者 DIALOG)。

    • linkInvoke - 定义一个控制器方法,在点击链接时调用这个方法,而不是打开界面。

    根据需要显示的实体属性类型的不同,可以使用下面这些 field 的属性:

    • mask 如果给一个文本型的实体属性设置这个 mask 属性,那么界面组件会用 MaskedField 替换 TextField,并使用适当的掩码,此时也可以设置 valueMode 属性。

    • rows 如果给一个文本型的实体属性设置这个 rows 属性,那么界面组件会用 TextArea 替换 TextField,并将文字重组成适当的行数,此时也可以设置 cols 属性。

    • maxLength 对于文本型实体属性,可以定义 maxLength 属性,跟 TextField 中描述的一致。

    • dateFormat 对于 date 或者 dateTime 类型的实体属性,可以设置 dateFormatresolution 参数,使用的组件是 DateField

    • showSeconds 如果实体属性是 time 类型,可以设置界面组件 TimeFieldshowSeconds 属性。

fieldGroup 的 XML 属性:

  • border 属性可以设置成 hidden 或者 visible。默认值是 hidden。如果设置成 visiblefieldGroup 组件会有边框(border)而且会被高亮。在 web 的实现中,通过添加 cuba-fieldgroup-border 这个 CSS 类显示边框。

  • captionAlignment 属性定义 FieldGroup 内字段的名称与字段的相对位置。可选项:LEFTTOP

  • fieldFactoryBean 在 XML 描述里面,声明式的字段默认是通过 FieldGroupFieldFactory 接口创建的。可以使用这个属性,覆盖默认工厂,将此属性设置成自定义 FieldGroupFieldFactory 实现的名称。

    通过编程的方式创建 FieldGroup 的话,可以使用 setFieldFactory() 方法。

FieldGroup 接口的方法:

  • addField 在运行时将字段添加到 FieldGroup。接受 FieldConfig 类型实例作为参数,也可以通过 colIndexrowIndex 参数定义字段的位置。

  • bind()setDatasource() 之后触发的方法,用来为添加的字段绑定相应的 UI 组件。

  • createField() 用来创建新的实现了 FieldConfig 接口的 FieldGroup 元素:

    fieldGroup.addField(fieldGroup.createField("newField"));
  • getComponent() 返回一个跟字段绑定的可视化组件。这个也许在需要设置额外的组件参数时,通过这个方法得到这个可视化组件。因为上面提到的 field 提供的 XML 配置的参数有限。

    在界面控制器中,如果想获取对可视化组件的引用,可以采用注入的方式,而不是通过显式地调用 getFieldNN("id").getComponentNN() 的方式。具体做法是,使用 @Named 注解,提供的注解参数是 fieldGroup 的标识符加 . 再加上字段标识符。

    比如下面例子中,在用一个字段选择关联的实体的时候,可以添加一个 Open 操作,然后删掉这个字段的 Clear 操作:

    <fieldGroup id="orderFieldGroup" datasource="orderDs">
        <field property="date"/>
        <field property="customer"/>
        <field property="amount"/>
    </fieldGroup>
    @Named("orderFieldGroup.customer")
    protected PickerField customerField;
    
    @Override
    public void init(Map<String, Object> params) {
        customerField.addOpenAction();
        customerField.removeAction(customerField.getAction(PickerField.ClearAction.NAME));
    }

    要使用 getComponent() 获取或者注入字段组件,需要知道字段中使用的组件的类型。下面这个表列举了实体的属性类型和组件的对应关系:

    实体属性类型 附加条件 字段可视化组件类型

    关联实体

    指定了 optionsDatasource

    LookupPickerField

    PickerField

    枚举类型 (enum)

    LookupField

    string

    指定了 mask

    MaskedField

    指定了 rows

    TextArea

    TextField

    boolean

    CheckBox

    date, dateTime

    DateField

    time

    TimeField

    int, long, double, decimal

    指定了 mask

    MaskedField

    TextField

    UUID

    MaskedField 16 进制掩码

  • removeField() 支持在运行时根据 id 移除字段.

  • setComponent() 为字段设置自定义的可视化组件。可以在 XML 元素 field 的属性 custom="true" 或者使用 createField() 方法创建字段时使用。当与 custom="true" 一起使用的时候,数据源(datasource)和对应的属性(property)需要手动设置。

    FieldConfig 接口类的实例可以通过 getField() 或者 getFieldNN() 方法获取,然后就可以调用它的 setComponent() 方法:

    @Inject
    protected FieldGroup fieldGroup;
    @Inject
    protected UiComponents uiComponents;
    @Inject
    private Datasource<User> userDs;
    
    @Override
    public void init(Map<String, Object> params) {
        PasswordField passwordField = uiComponents.create(PasswordField.NAME);
        passwordField.setDatasource(userDs, "password");
        fieldGroup.getFieldNN("password").setComponent(passwordField);
    }


3.5.2.1.16. 多文件上传控件

FileMultiUploadField 组件允许用户把文件上传到服务器。这个组件是个按钮;用户点击时,系统自带的文件选择器会弹出,此时用户可以选择多个文件来上传。

gui multipleUpload

该组件对应的 XML 名称: multiUpload

下面是一个使用 FileMultiUploadField 的示例。

  • 在界面的 XML 描述中声明这个组件:

    <multiUpload id="multiUploadField" caption="Upload Many"/>
  • 在界面控制器中,需要注入该组件本身,还需要注入 FileUploadingAPIDataManager 这两个接口。

    @Inject
    private FileMultiUploadField multiUploadField;
    @Inject
    private FileUploadingAPI fileUploadingAPI;
    @Inject
    private Notifications notifications;
    @Inject
    private DataManager dataManager;
    
    @Subscribe
    protected void onInit(InitEvent event) { (1)
    
        multiUploadField.addQueueUploadCompleteListener(queueUploadCompleteEvent -> { (2)
            for (Map.Entry<UUID, String> entry : multiUploadField.getUploadsMap().entrySet()) { (3)
                UUID fileId = entry.getKey();
                String fileName = entry.getValue();
                FileDescriptor fd = fileUploadingAPI.getFileDescriptor(fileId, fileName); (4)
                try {
                    fileUploadingAPI.putFileIntoStorage(fileId, fd); (5)
                } catch (FileStorageException e) {
                    throw new RuntimeException("Error saving file to FileStorage", e);
                }
                dataManager.commit(fd); (6)
            }
            notifications.create()
                    .withCaption("Uploaded files: " + multiUploadField.getUploadsMap().values())
                    .show();
            multiUploadField.clearUploads(); (7)
        });
    
        multiUploadField.addFileUploadErrorListener(queueFileUploadErrorEvent -> {
            notifications.create()
                    .withCaption("File upload error")
                    .show();
        });
    }
    1 onInit() 方法里面,添加了事件监听器,这样可以在文件上传成功或者出错时做出反馈。
    2 该组件将所有选择的文件上传到客户端层(client tier) 的临时存储(temporary storage)并且调用通过 addQueueUploadCompleteListener() 方法添加的监听器。
    3 在这个监听器里面,会调用 FileMultiUploadField.getUploadsMap() 方法获得临时存储的文件标识和文件名映射关系的 map。
    4 然后,通过调用 FileUploadingAPI.getFileDescriptor() 为每一条 map 记录创建相应的 FileDescriptor 对象。 com.haulmont.cuba.core.entity.FileDescriptor (别跟 java.io.FileDescriptor 混淆了) 是一个持久化实体,唯一定义一个上传的文件,并且也用这个类从系统下载文件。
    5 FileUploadingAPI.putFileIntoStorage() 方法用来把文件从客户端层的临时存储移动到 FileStorage。这个方法的参数是临时存储中文件的标识符和对应的 FileDescriptor 对象。
    6 在将文件上传到 FileStorage 之后,通过调用 DataManager.commit() 方法将 FileDescriptor 实例存到数据库。这个方法的返回值可以用来设置给一个实体的属性,这个属性关联此文件。这里,FileDescriptor 简单的保存在数据库。上传的文件可以通过 Administration > External Files 界面查看。
    7 完成整个上传过程之后,文件列表需要通过调用 clearUploads() 方法清空以便下一次上传再使用。

下面列出能跟踪上传进度的监听器:

  • FileUploadErrorListener

  • FileUploadStartListener

  • FileUploadFinishListener

  • QueueUploadCompleteListener

最大可上传的文件大小是由 cuba.maxUploadSizeMb 应用程序属性定义的,默认是 20MB。如果用户选择了更大的文件的话,会有相应的提示信息,并且中断上传过程。

multiUpload 属性:

  • accept XML 属性 (或者相应的 setAccept() 方法) 用来设置文件选择对话框里面的文件类型掩码,但是用户还是可以选择“所有文件”来上传任意文件。

    这个属性的值需要是以英文逗号分隔的文件后缀名,比如:*.jpg,*.png

  • fileSizeLimit XML 属性 (或者相应的 setFileSizeLimit() 方法) 用来设置最大允许上传的文件大小。这个设置是针对每一个文件都有效的。

    <multiUpload id="multiUploadField" fileSizeLimit="200000"/>
  • permittedExtensions XML 属性 (或者相应的 setPermittedExtensions() 方法) 设置允许的文件扩展名白名单。

    这个属性的值需要是字符串的集合,其中每个字符串是以 . 开头的允许的文件扩展名,比如:

    uploadField.setPermittedExtensions(Sets.newHashSet(".png", ".jpg"));
  • dropZone XML 属性允许设置一个特殊的 BoxLayout 用来作为从浏览器外部拖拽文件可以放置的目标容器区域。如果这个容器的样式没有特殊设置,当文件被拖拽到这块区域的时候,这个容器会被高亮显示,否则目标区域不会显示。

参考 在 CUBA 应用程序中使用图片 指南,了解上传文件的一些更加复杂的示例。



3.5.2.1.17. 文件上传控件

FileUploadField 允许用户上传文件到服务器。这个控件包含标题 、 已上传文件的链接 、 还有两个按钮:上传按钮和清除文件选择按钮。当点击上传按钮的时候,会弹出系统标准的文件选择器,用户可以在这里选择需要上传的文件。如果是要上传多个文件,可以用 FileMultiUploadField 控件。

gui upload 7.0

该控件对应的 XML 名称:upload

对于 FileDescriptor 类型的实体属性,可以在 FieldGroup 内用 datasource 属性使用此控件,也可以在 Form 中通过 dataContainer 属性使用,或者也能单独使用。如果此控件绑定到任何数据组件,上传的文件会被立即保存到文件存储,对应的 FileDescriptor 实例会保存到数据库。

<upload fileStoragePutMode="IMMEDIATE"
        dataContainer="personDc"
        property="photo"/>

还可以通过编程的方式控制文件和 FileDescriptor 的保存:

  • 在界面的 XML 描述中声明这个控件:

    <upload id="uploadField"
            fileStoragePutMode="MANUAL"/>
  • 在界面控制器中,需要注入该控件本身,还需要注入 FileUploadingAPIDataManager 这两个接口。然后,订阅上传成功或出错的事件:

    @Inject
    private FileUploadField uploadField;
    @Inject
    private FileUploadingAPI fileUploadingAPI;
    @Inject
    private DataManager dataManager;
    @Inject
    private Notifications notifications;
    
    @Subscribe("uploadField")
    public void onUploadFieldFileUploadSucceed(FileUploadField.FileUploadSucceedEvent event) {
        File file = fileUploadingAPI.getFile(uploadField.getFileId()); (1)
        if (file != null) {
            notifications.create()
                    .withCaption("File is uploaded to temporary storage at " + file.getAbsolutePath())
                    .show();
        }
    
        FileDescriptor fd = uploadField.getFileDescriptor(); (2)
        try {
            fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd); (3)
        } catch (FileStorageException e) {
            throw new RuntimeException("Error saving file to FileStorage", e);
        }
        dataManager.commit(fd); (4)
        notifications.create()
                .withCaption("Uploaded file: " + uploadField.getFileName())
                .show();
    }
    
    @Subscribe("uploadField")
    public void onUploadFieldFileUploadError(UploadField.FileUploadErrorEvent event) {
        notifications.create()
                .withCaption("File upload error")
                .show();
    }
1 此时如果需要的话,可以取到保存在临时存储的文件。
2 一般来说,文件需要保存到中间件的文件存储中。
3 将文件保存至 FileStorage。
4 将文件描述器保存到数据库。

该控件将所有选择的文件上传到客户端层 的临时存储(temporary storage)并且调用 FileUploadSucceedEvent 监听器。在这个监听器里面,从 uploadField 获取了一个 FileDescriptorcom.haulmont.cuba.core.entity.FileDescriptor (别跟 java.io.FileDescriptor 混淆了) 是一个持久化实体,唯一定义一个上传的文件,并且也用这个类从系统下载文件。

FileUploadingAPI.putFileIntoStorage() 方法把文件从客户端层的临时存储移动到文件存储。这个方法的参数是临时存储中文件的标识符和对应的 FileDescriptor 对象,这两个参数都是由 FileUploadField 提供的。

当上传文件到 FileStorage 完成后,通过调用 DataManager.commit()FileDescriptor 实例存到数据库。这个方法会返回保存的实体,可以用来赋值给其它实体里关联这个文件的属性。这里只是简单的把 FileDescriptor 存到了数据库。从 Administration > External Files 界面可以看到这个文件。

FileUploadErrorEvent 监听器会在从客户端上传文件到临时存储出错的时候被调用。

以下是可以订阅的事件,用来跟踪上传进度:

  • AfterValueClearEvent

  • BeforeValueClearEvent

  • FileUploadErrorEvent

  • FileUploadFinishEvent

  • FileUploadStartEvent

  • FileUploadSucceedEvent

  • ValueChangeEvent

fileUploadField 的属性:

  • fileStoragePutMode - 定义文件和相应的 FileDescriptor 怎么存储。

    • IMMEDIATE 模式,在文件存到客户端层临时存储之后立即存储。

    • MANUAL 模式, 需要手动在 FileUploadSucceedListener 里面编码实现。

      当在 FieldGroup 里面使用 FileUploadField 的时候,默认模式是 IMMEDIATE,其它情况下,默认模式是 MANUAL

  • uploadButtonCaptionuploadButtonIconuploadButtonDescription 这三个 XML 属性可以设置上传按钮的属性。

  • showFileName - 控制上传文件的名称是否要显示在上传按钮旁边,默认是 false 不显示。

  • showClearButton - 控制是否要显示清空按钮,默认 false 不显示。

  • clearButtonCaptionclearButtonIconclearButtonDescription 这三个 XML 属性可以设置清空按钮的属性。

  • accept XML 属性 (或者相应的 setAccept() 方法) 用来设置文件选择对话框里面的文件类型掩码,但是用户还是可以选择“所有文件”来上传任意文件。

    这个属性的值需要是以英文逗号分隔的文件扩展名,比如: *.jpg,*.png

  • 最大可上传的文件大小是由 cuba.maxUploadSizeMb 应用程序属性定义的,默认是 20MB。如果用户选择了更大的文件的话,会有相应的提示信息,并且中断上传过程。

  • fileSizeLimit XML 属性 (或者相应的 setFileSizeLimit() 方法) 用来设置最大允许上传的文件大小,以字节为单位。

    <upload id="uploadField" fileSizeLimit="2000"/>
  • permittedExtensions XML 属性 (或者相应的 setPermittedExtensions() 方法) 设置允许的文件扩展名白名单。

    这个属性的值需要是字符串的集合,其中每个字符串是以 . 开头的允许的文件扩展名,比如:

    uploadField.setPermittedExtensions(Sets.newHashSet(".png", ".jpg"));
  • dropZone - 允许设置一个特殊的 BoxLayout 用来作为从浏览器外部拖拽文件可以放置的目标容器区域。这个目标区域可以覆盖整个对话框的窗口。当文件被拖拽到这块区域的时候,这个容器会被高亮显示,否则目标区域不会显示。

    <layout spacing="true"
            width="100%">
        <vbox id="dropZone"
              height="AUTO"
              spacing="true">
            <textField id="textField"
                       caption="Title"
                       width="100%"/>
            <textArea id="textArea"
                      caption="Description"
                      width="100%"
                      rows="5"/>
            <checkBox caption="Is reference document"
                      width="100%"/>
            <upload id="upload"
                    dropZone="dropZone"
                    showClearButton="true"
                    showFileName="true"/>
        </vbox>
        <hbox spacing="true">
            <button caption="mainMsg://actions.Apply"/>
            <button caption="mainMsg://actions.Cancel"/>
        </hbox>
    </layout>
    gui dropZone

    如果想要 dropZone 不变并且一直显示,需要给这个容器设置预定义的样式名称 dropzone-container。此时这个容器应该是空的,只包含一个 label 组件:

    <layout spacing="true"
            width="100%">
        <textField id="textField"
                   caption="Title"
                   width="100%"/>
        <checkBox caption="Is reference document"
                  width="100%"/>
        <upload id="upload"
                dropZone="dropZone"
                showClearButton="true"
                showFileName="true"/>
        <vbox id="dropZone"
              height="150px"
              spacing="true"
              stylename="dropzone-container">
            <label stylename="dropzone-description"
                   value="Drop file here"
                   align="MIDDLE_CENTER"/>
        </vbox>
        <hbox spacing="true">
            <button caption="mainMsg://actions.Apply"/>
            <button caption="mainMsg://actions.Cancel"/>
        </hbox>
    </layout>
    gui dropZone static
  • pasteZone 允许设置一个特殊的容器用来处理粘贴(paste)的快捷键。此时需要这个容器内部的一个文字输入控件获得焦点(focused)。这个功能只支持基于 Chromium 的浏览器。

    <upload id="uploadField"
            pasteZone="vboxId"
            showClearButton="true"
            showFileName="true"/>

参考 在 CUBA 应用程序中使用图片 指南,了解上传文件的一些更加复杂的示例。



3.5.2.1.18. 过滤器

在这章节包含下面这些内容:

Filter 是一个具有非常多功能的过滤器,可以对表格形式展示的数据库实体列表进行过滤。这个组件支持按照任意条件对数据进行快速过滤,同时也支持创建可重复使用的过滤器。

Filter 需要连接到一个数据加载器,该加载器可以是为CollectionContainer或者KeyValueCollectionContainer定义的。加载器需要包含一个 JPQL 查询语句。过滤器操作的机制是按照用户设置的过滤条件对这个 JPQL 查询进行修改。所以,过滤其实是发生在数据库层面,通过执行修改后的 SQL,查询出来的数据被加载到中间件和客户端

使用过滤器

一个典型的过滤器是这样的:

gui filter descr

默认情况下,这个组件使用快速过滤模式。意味着用户可以添加一组过滤条件进行一次数据搜索,一旦这个界面关掉之后,设置的过滤条件也就没了。

创建一个快速过滤器,点击 Add search condition - 添加搜索条件 链接,会显示条件选择的界面:

gui filter conditions

下面是一些可用的过滤条件类型:

  • Attributes - 属性 – 实体属性和关联的实体,只能用持久化的实体属性。这些属性要满足下面两种情况之一:要么在过滤器的 XML 描述里面显式的设置在 property 元素里,要么符合 properties 元素定义的规则。

  • Custom conditions - 自定义条件 – 由开发人员在过滤器 XML 描述中的 custom 元素设置的过滤条件。

  • Create new…​ - 新建过滤器…​ – 创建新的 JPQL 条件过滤器。这个选项只对具有 cuba.gui.filter.customConditions 权限的用户开放。

选中的过滤条件会在过滤器区域顶部显示。这个 gui_filter_remove_condition 条件移除图标会在每个条件的旁边显示,允许移除已选择的条件。

可以保存快速过滤器以便将来使用。要保存一个快速过滤器,点击过滤器设置按钮,选择 Save/Save as - 保存/另存为 然后在对话框输入一个新的过滤器名字:

gui filter name

保存之后,这个过滤器就会在 Search - 搜索 按钮的下拉框中显示。

Reset filter 菜单可以用来重置当前应用的查询条件。

gui filter reset

用于过滤器设置的弹窗按钮提供一系列过滤器管理的选项:

  • Save - 保存 – 保存当前过滤器的修改

  • Save with values - 带值保存 – 保存当前过滤器的修改,并且将参数编辑器里面的值保存为过滤器的默认条件值。

  • Save as - 另存为 – 将过滤器另存为一个新名称。

  • Edit - 编辑 – 打开过滤器编辑(参阅下面)。

  • Make default - 设置默认 – 设置当前界面的默认过滤器。当界面打开时,这个过滤器会自动显示在过滤器区域。

  • Remove - 删除 – 删除当前的过滤器。

  • Pin applied - 保留已选 – 使用上次查询的结果来做级联过滤(参考级联过滤 )。

  • Save as search folder - 另存为搜索文件夹 – 以当前的过滤器创建一个文件夹

  • Save as application folder - 另存为应用程序文件夹 – 以当前的过滤器创建一个应用程序文件夹。此功能只对有 cuba.gui.appFolder.global 权限的用户开放。

Edit 选项打开过滤器编辑器,可以对当前过滤器进行高级设置:

gui filter editor

Name 字段应该填写过滤器的名称。这个名称会显示在当前界面可用的过滤器列表里。

过滤器可以通过 Available to all users 复选框设置成 全局 的(也就是所有用户都能用),或者通过 Global default 复选框设置成 全局默认 的。这些操作需要一个特殊的权限,叫做 CUBA > Filter > Create/modify global filters。如果这个过滤器被标记成 全局默认 的话,那么当任何用户打开这个界面的时候,就会自动加载这个过滤器的数据。用户可以使用 Default for me 复选框设置他们自己的默认过滤器,这个设置会覆盖 全局默认 过滤器。

这些过滤器的过滤条件包含在树状结构里,可以通过 Add 按钮添加,通过 gui_filter_cond_down 交换位置,或者通过 Remove 按钮删除。

AND 或者 OR 分组条件可以通过相应的按钮添加,所有顶层过滤条件(比如没有显式的分组)都是通过 AND 连接。

在树状结构选择过滤条件时,会在编辑器的右边打开一个条件属性的列表。

过滤条件可以通过相应的复选框设置成隐藏或者必要。隐藏的条件参数对用户来说是不可见的,所以应该在编辑过滤器的时候显示出来。

Width 属性是在过滤器区域为当前条件的字段设置显示宽度。默认情况下,在过滤器区域的条件都显示成三列。这里字段的显示宽度也就是字段需要占据的列的数目(1,2 或者 3)。

当前条件的默认值可以在 Default value 里面选择。

自定义的过滤器条件名称可以设置在 Caption 字段。

Operation 提供选择条件的运算符。跟据属性的类型确定可选的运算符列表。

如果实体有 DateTime 类型的属性,且此属性没有 @IgnoreUserTimeZone 注解,那么在过滤器里面会采用用户的时区默认作为这个属性的时区。如果是 Date 类型的话,可以通过自定义过滤条件编辑器里面的 Use time zone 标记来定义是否使用用户的时区来处理这个字段。

过滤器组件介绍

该组件对应的 XML 名称: filter

下面是在界面 XML 中定义这个组件的示例:

<data readOnly="true">
    <collection id="carsDc" class="com.haulmont.sample.core.entity.Car" view="carBrowse">
        <loader id="carsDl" maxResults="50">
            <query>
                <![CDATA[select e from sample_Car e order by e.createTs]]>
            </query>
        </loader>
    </collection>
</data>
<layout expand="carsTable" spacing="true">
    <filter id="filter" applyTo="carsTable" dataLoader="carsDl">
        <properties include=".*"/>
    </filter>
    <table id="carsTable" width="100%" dataContainer="carsDc">
        <columns>
            <column id="vin"/>
            <column id="colour"/>
            <column id="model"/>
        </columns>
        <rowsCount/>
    </table>
</layout>

在上面的例子中,数据容器包含 Car 实体实例的集合。数据加载器使用 JPQL 查询语句加载数据集合。过滤器组件会修改加载器的查询语句,因为过滤器是通过 dataLoader 连接到加载器。采用表格组件显示数据,关联了数据容器。

filter 可以包含嵌套元素。这些元素主要用来描述用户可以在 Add Condition 对话框中能使用的过滤条件:

  • properties – 多个实体属性通过这项配置成可用。这个元素有如下属性:

    • include – 必带属性。包含一个正则表达式,能匹配实体的属性名称。

    • exclude – 包含一个正则表达式,如果实体属性能匹配此项,那么会从之前的 include 配置中排除掉。

    • excludeProperties – 包含一个英文逗号分隔的应该被排除掉的属性路径列表。跟之前的 exclude 不同,这里支持遍历实体关系图,比如 customer.name

    • excludeRecursively - 设置 excludeProperties 里面定义的属性是否需要递归的排除掉。如果设置的 true,那么属性和它的嵌套属性,只要是相同名称的,都会被排除掉。

      示例:

      <filter id="filter"
              applyTo="ordersTable"
              dataLoader="ordersDl">
          <properties include=".*"
                      exclude="(amount)|(id)"
                      excludeProperties="version,createTs,createdBy,updateTs,updatedBy,deleteTs,deletedBy"
                      excludeRecursively="true"/>
      </filter>

      通过编程的方式排除属性,使用 Filter 组件的 setPropertiesFilterPredicate() 方法:

      filter.setPropertiesFilterPredicate(metaPropertyPath ->
              !metaPropertyPath.getMetaProperty().getName().equals("createTs"));

    当使用 properties 元素的时候,下面这些实体属性不能作为过滤条件:

    • 由于安全权限限制而不能访问的属性。

    • 集合属性(@OneToMany@ManyToMany)。

    • 非持久化属性。

    • 没有本地化名称的属性。

    • 使用 @SystemLevel 注解的属性。

    • byte[] 类型的属性。

    • version 属性。

  • property – 显式地根据属性名来包含一个实体属性。这个元素有下面这些属性:

    • name – 必须属性,指定需要包含的实体属性的名称。可以是实体关系图里面的路径(使用“.”)比如:

      <filter id="transactionsFilter" dataLoader="transactionsDl" applyTo="table">
          <properties include=".*" exclude="(masterTransaction)|(authCode)"/>
          <property name="creditCard.maskedPan" caption="msg://EmbeddedCreditCard.maskedPan"/>
          <property name="creditCard.startDate" caption="msg://EmbeddedCreditCard.startDate"/>
      </filter>
    • caption – 在过滤条件显示的本地化实体属性名称。通常是以 msg:// 开头的符合 MessageTools.loadString() 规则的字符串。

      如果 name 属性设置的是实体关系图里面的路径,那么 caption 必须要提供。

    • paramWhere − 在参数是关联的实体的情况下,用这个参数来设置 JPQL 表达式用以选取条件参数的列表。这里需要用 {E} 占位符来代表实体而不能用实体的别名。

      比如,假设 CarModel 的引用,那么参数值的列表可以限制到只取 Audi 型号:

      <filter id="carsFilter" dataLoader="carsDl">
          <property name="model" paramWhere="{E}.manufacturer = 'Audi'"/>
      </filter>

      界面参数 、 会话属性和界面组件(包含那些显示其它参数的)都可以用在 JPQL 表达式。查询参数的说明和规范可以参考 数据组件之间的依赖集合数据源查询

      使用会话(session)和界面参数的例子如下:

      {E}.createdBy = :session$userLogin and {E}.name like :param$groupName

      使用 paramWhere 语句,可以引入参数之间的依赖。比如,假设 Manufacturer 是一个独立的实体。CarModel 的属性,而 Model 又有 Manufacturer 的属性。那么可以给 Cars 创建两个过滤条件:第一个选择一个 Manufacturer,第二个选择 Model。为了用前一个过滤条件选出的 manufacturer 来限制第二个过滤条件 models 的列表,可以在 paramWhere 表达式添加一个参数:

      {E}.manufacturer.id = :component$filter.model_manufacturer90062

      这个参数引用了一个显示 Manufacturer 参数的组件。如果在过滤器编辑界面鼠标右键点击过滤条件列表的一行,可以在弹出菜单中看到组件的名称:

      gui filter component name
    • paramView − 指定一个视图。如果过滤器参数关联了一个实体,可以用视图来加载过滤条件参数值列表。比如,_local。如果视图没有指定,默认会使用 _minimal 视图。

  • custom 这个元素用来定义一个定制化的过滤条件。元素的内容需要是 JPQL 表达式(也能使用JPQL 宏),这个表达式会被添加到数据容器查询语句的 where 条件后面。这里需要用 {E} 占位符来代表实体而不能用实体的别名。这个条件最多只能用一个以“?”标记的参数。

    定制化的条件的值可以用特殊字符,比如"like"操作符需要的 "%" 或者 "_"。如果需要转义这些字符,可以在条件里面添加 escape '<char>',比如:

    {E}.name like ? escape '\'

    这样如果采用 foo\% 作为过滤条件的参数值,搜索时会将"%"作为普通字符而非特殊字符。

    下面这个例子演示了采用定制化条件的过滤器:

    <filter id="carsFilter" dataLoader="carsDl">
        <properties include=".*"/>
        <custom name="vin" paramClass="java.lang.String" caption="msg://vin">
          {E}.vin like ?
        </custom>
        <custom name="colour" paramClass="com.company.sample.entity.Colour" caption="msg://colour"
                inExpr="true">
          ({E}.colour.id in (?))
        </custom>
        <custom name="repair" paramClass="java.lang.String" caption="msg://repair"
                join="join {E}.repairs cr">
          cr.description like ?
        </custom>
        <custom name="updateTs" caption="msg://updateTs">
          @between({E}.updateTs, now-1, now+1, day)
        </custom>
    </filter>

    custom 过滤条件在 Add condition 窗口的 Custom conditions 区域显示:

    gui filter custom

    custom 的 XML 属性:

    • name − 必须,过滤条件的名称。

    • caption − 必须,过滤条件的本地化名称。通常是一个以 msg:// 开头的字符串,需要符合 MessageTools.loadString() 规范。

    • paramClass − 过滤条件参数的 Java 类。如果参数没有指定,这个参数就不必须。

    • inExpr − 如果 JPQL 表达式中包含 in (?) ,则这个属性就需要设置成 true。这样的话,用户能手动输入几个过滤条件参数值。

    • join − 可选属性。设置一个字符串,会被添加在数据容器查询语句的 from 部分。如果要依赖关联实体集合的属性来创建一个复杂的过滤条件,可以用这个属性。这个属性值应该包含 joinleft join 语句。

      比如,假设 Car 实体有 repairs 属性,关联 Repair 实体的集合。那么可以创建下面这个过滤条件来使用 Repair 实体的 description 属性过滤 Car

      <filter id="carsFilter" dataLoader="carsDl">
          <custom name="repair"
                  caption="msg://repair"
                  paramClass="java.lang.String"
                  join="join {E}.repairs cr">
              cr.description like ?
          </custom>
      </filter>

      如果用了上面这个过滤条件,原来的数据容器查询语句

      select c from sample_Car c order by c.createTs

      会被转化成下面这样:

      select c from sample_Car c join c.repairs cr
      where (cr.description like ?)
      order by c.createTs

      还有,可以使用一个不相关的实体创建一个自定义的条件,然后在条件的 where 部分使用该实体。 此时,在 join 属性值内,应当使用 ", " 而不是 join 或者 left join

      下面是一个自定义条件的例子,用来找出指定日期之后分配给司机的车:

      <filter id="carsFilter"
              dataLoader="carsLoader"
              applyTo="carsTable">
          <custom name="carsFilter"
                  caption="carsFilter"
                  paramClass="java.util.Date"
                  join=", ref$DriverAllocation da">
              da.car = {E} and da.createTs >= ?
          </custom>
      </filter>
    • paramWhere − 在参数是关联的实体的情况下,用这个参数来设置 JPQL 表达式用以选取条件参数的列表。参考 property 元素的同名属性的描述。

    • paramView − 指定一个 视图。如果过滤器参数关联了一个实体,可以用视图来加载过滤条件参数值列表。参考 property 元素的同名属性的描述。

filter 属性:

  • editable – 如果这个属性是 falseEdit 选项会被禁用。

  • applyImmediately – 设置过滤器何时生效。当设置成 false 时,过滤器会使用显式操作模式。此时,过滤器只有在点击 Search 按钮时才会生效。当设置成 true 时,过滤器会以即时模式工作,每个对于过滤器参数的调整都会立即生效,数据会自动刷新。下面这几个常见的场景过滤器会自动生效:

    • 参数字段的值变化之后;

    • 改变条件操作符;

    • 从过滤器移除一个条件;

    • Show rows 字段变化;

    • 当在过滤器编辑窗口点击 OK 按钮;

    • 在清空所有值之后;

    即时模式时,会使用 Refresh 按钮而非 Search

    applyImmediately 属性的优先级比 cuba.gui.genericFilterApplyImmediately 应用程序属性的优先级高。

  • manualApplyRequired − 定义过滤器生效的时机。如果这个属性设置的 false,这个过滤器(默认或者空)会在界面打开时生效。意味着数据容器会被刷新、与其关联的界面组件(比如表格)会显示数据。如果这个属性设置为 true,只有当用户点击 Search - 搜索 按钮的时候才会生效。

    这个属性的优先级比应用程序属性 cuba.gui.genericFilterManualApplyRequired 的优先级要高。

  • useMaxResults − 是否需要限制加载到数据容器的数据量。默认设置的 true

    如果这个属性设置的 false,过滤器不会显示 Show rows - 显示行数 控件。数据容器中的数据记录条数(同时也显示在表格里)只受到实体统计MaxFetchUI 参数限制,默认是 10000 条。

    如果这个属性没设置或者设置为 true,只有当用户有 cuba.gui.filter.maxResults 权限的情况下才显示 Show rows 控件。如果用户没有获得 cuba.gui.filter.maxResults 权限的授权,过滤器会强制加载 N 条数据,用户也没办法去禁用或者指定另一个 N 值。N 是用 FetchUIDefaultFetchUI 参数定义的,这两个参数是从实体统计机制中读取的。

    下面这个过滤器有这些参数:useMaxResults="true"cuba.gui.filter.maxResults 权限被禁止 、 DefaultFetchUI = 2

gui filter useMaxRezult
  • textMaxResults - 使用文本输入控件而不是下拉列表来作为 Show rows 控件。默认值 false

  • folderActionsEnabled − 如果设置成 false,这两个操作会被隐藏:Save as Search FolderSave as Application Folder。默认值是 true,这些操作都可用。

  • caption - 给过滤器区域设置自定义的标题。

  • columnsCount - 定义过滤器区域过滤条件所占的列数。默认是 3。

  • defaultMode - 定义过滤器的默认模式。可以选 genericfts。如果设置的 fts,过滤器会用全文检索的模式打开(实体需要建立全文检索的索引)。默认值是 generic

  • modeSwitchVisible - 定义是否显示切换到全文检索模式的复选框。如果全文检索不可用,不管这个值如何设定,复选框都不可见。默认是 true

Filter 接口的方法

  • setBorderVisible() - 设置是否需要显示过滤器的边框,默认是 true

Filter 的监听器

  • ExpandedStateChangeListener - 过滤器面板展开状态改变监听器。

  • FilterEntityChangeListener - 当组件初始化的时候第一次选择过滤器或者之后切换到其它保存的过滤器的时候触发。

Filter 的展示可以使用带 $cuba-filter-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



用户权限

  • 需要创建/修改/删除全局(对所有用户可见)过滤器,用户需要有 cuba.gui.filter.global 权限

  • 需要创建/修改 custom 过滤条件,用户需要有 cuba.gui.filter.customConditions 权限。

  • 需要使用 Show rows 控件修改表格每页加载的最大行数,用户需要有 cuba.gui.filter.maxResults 权限。参考过滤器属性 useMaxResults

其它特定的权限配置内容,参考 安全子系统

外部控制过滤器的参数

  • 界面调用参数

    在界面打开时,系统应当提供自动生效的过滤器及其默认参数。为了实现这个效果,这个过滤器需要提前创建,保存在数据库,SEC_FILTER 表需要有一条相应的记录,并且 CODE 字段需要有值。界面调用的参数都在 web-menu.xml 配置文件里面设置。

    要把过滤器保存在数据库,过滤器的 insert 脚本应当添加在实体的 30.create-db.sql 数据库脚本里。为了简化脚本的创建,可以在 Administration - 管理 菜单的 Entity Inspector - 实体探查 子菜单找到过滤器实体,右键点击过滤器列表选择 System Information - 系统信息,点击 Script for insert - 插入脚本 按钮,可以拷贝脚本内容。

    然后可以修改界面默认使用这个过滤器。要指定过滤器的代码,需要传给界面一个跟过滤器组件同名的参数,参数的值是过滤器的代码。

    需要设置过滤器参数值的话,需要传给界面参数的名称跟过滤器的参数名称一致,然后这些参数的值是过滤器的值,需要是字符串类型。

    下面是一个在描述文件中定义主菜单项的例子。这里用 FilterByVIN 代码为 sample_Car.browse 界面 carsFilter 组件设置过滤器。同时还给 component$carsFilter.vin79216 条件设置了参数值 TMA

    <item id="sample_Car.browse">
        <param name="carsFilter" value="FilterByVIN"/>
        <param name="component$carsFilter.vin79216" value="TMA"/>
    </item>

    需要注意的是,定义了 CODE 字段的过滤器有些特性:

    • 不能被用户编辑。

    • 过滤器的名称可以多语言显示。可以在主消息包里面给过滤器代码设置名称。

级联使用过滤器

如果设置了 cuba.allowQueryFromSelected 应用程序属性,可以用组件的界面上暂存(pin)上次和本次过滤器应用的结果。保存之后,另一个过滤器或者当前过滤器采用不同参数就可以在目前过滤出的数据范围内进行进一步的过滤。

这个方案可以达到以下两个目标:

  • 解耦复杂的过滤器,这样也能获得更好的性能。

  • 可以在应用程序或者搜索文件夹选中的数据上应用过滤器。

按照下面的步骤使用级联过滤。首先,选择并且应用一个过滤器。然后点击过滤器设置按钮选择 Pin applied。然后这个过滤器会被固定到过滤区域的顶部。之后另一个过滤器可以在此基础上应用,如此往复。级联过滤器数量没有限制。也可以通过 gui_filter_remove 按钮移除之前固定的过滤器。

gui filter sequential

能连续使用过滤器是基于 DataManager级联查询功能。

过滤器参数的 API

Filter 接口提供了在界面控制器读写过滤器参数的方法:

  • setParamValue(String paramName, Object value)

  • getParamValue(String paramName)

paramName - 过滤器参数名称。参数名称是显示参数值的组件的一部分。获取组件名称的过程上面说过了。参数名称放置在组件名称的最后一个 . 的后面。比如,如果组件名称是 component$filter.model_manufacturer90062,那么参数名称是 model_manufacturer90062

注意不能在界面控制器的 InitEvent 处理器中使用这些方法,因为过滤器在那时还没有初始化。比较合适的使用过滤器参数的地方是在 BeforeShowEvent 处理器中。

过滤器的全文搜索模式

如果过滤器的数据容器被全文检索子系统(参考 CUBA Platform. 全文搜索)做了全文索引的话,那么这个过滤器可以使用全文检索模式。用 Full-Text Search 复选框切换到这个模式。

gui filter fts 2

在全文检索模式里,过滤器包含了文本控件用来做搜索规则,搜索也是在 FTS 子系统做了索引的那些实体字段中进行。

如果在 applyTo 属性定义了一个表格,则可以展示实体的哪个属性满足了搜索条件。如果 cuba.gui.genericFilterFtsDetailsActionEnabled 应用程序属性设置为 true,表格会添加一个 Full-Text Search Details - 全文检索详情 操作。右键点击表格行然后选择这个操作 - 会打开一个对话框,展示全文检索详情。

如果 cuba.gui.genericFilterFtsTableTooltipsEnabled 应用程序属性设置为 true,当光标放到表格行上时,会显示一个提示窗,展示实体的哪个属性满足了搜索条件。需要注意的是,生成提示窗比较费时,所以默认是关闭的。

gui filter fts

如果要隐藏过滤器模式切换的复选框,可以设置 modeSwitchVisiblefalse

如果需要过滤器默认就打开全文检索模式,可以设置 defaultModefts

全文检索跟其它任意过滤器条件组合使用:

book publication fts filter

FTS condition 可以在条件选择器窗口进行选择。

3.5.2.1.19. 表单

Form 组件被用于多个实体属性的联合显示和编辑。它是一个类似于GridLayout的简单容器,可以有一定数量的嵌套列,嵌套字段的类型在 XML 中以声明方式定义,字段的标题位于字段的左侧。与 GridLayout 的主要区别在于 Form 能够将所有嵌套字段绑定到一个数据容器中。

从平台版本 7.0 开始,生成的编辑界面默认使用 Form 代替FieldGroup

gui Form 1

该组件对应的 XML 名称:form

下面是在界面 XML 描述中定义一组字段的示例:

<data>
    <instance id="orderDc" class="com.company.sales.entity.Order" view="order-edit">
        <loader/>
    </instance>
</data>
<layout>
    <form id="form" dataContainer="orderDc">
        <dateField property="date"/>
        <textField property="amount" description="Total amount"/>
        <pickerField property="customer"/>

        <field id="statusField" property="status"/>
    </form>
</layout>

在上面的例子中,form 组件显示了加载到 orderDc 数据容器中的实体属性。嵌套的 form 元素使用 property 这个 XML 属性定义了绑定到实体属性的可视化组件。会根据实体属性的本地化名称自动创建标题。嵌套的组件可以有任意的普通或者特定属性,比如例子中的 description

除了具体的可视化组件外,表单还能包含用嵌套的 field 元素定义的通用控件。框架会根据相应的实体属性和组件生成策略选择合适的可视化组件。field 元素可以有多个普通属性,比如 descriptioncontextHelpText 等。

如果要在界面控制器注入嵌套的组件,可以在 XML 中指定其 id 属性。组件会使用其具体的类型进行注入,比如 TextField。如果在界面中注入了一个通用控件,则其会是 Field 类型,该类是表单能展示的所有可视化组件的父类。

form 组件支持 colspanrowspan 属性。这些属性用来设置对应的内嵌组件占据的列数和行数。下面的例子演示 Field1 如何占据两列:

<form>
    <column width="250px">
        <textField caption="Field 1" colspan="2" width="100%"/>
        <textField caption="Field 2"/>
    </column>
    <column width="250px">
        <textField caption="Field 3"/>
    </column>
</form>

组件会按照下面的方式排布:

gui Form 2

类似的,Field 1 也可以扩展至两行:

<form>
    <column width="250px">
        <textField caption="Field 1" rowspan="2" height="100%"/>
    </column>
    <column width="250px">
        <textField caption="Field 2"/>
        <textField caption="Field 3"/>
    </column>
</form>

组件会按照下面的方式排布:

gui Form 3

form 的属性:

  • childrenCaptionWidth – 为所有嵌套列及其子元素指定固定标题宽度。设置 -1 使用自动大小。

  • childrenCaptionAlignment – 定义内嵌所有子组件标题的对齐方式。有两个可选项:LEFTRIGHT。默认值为 LEFT。只有当captionPosition设置为 LEFT 是才能有效。

  • captionPosition - 定义字段的标题位置:TOPLEFT

form 的元素:

  • column – 可选元素,允许将字段放置在多列。为此,嵌套字段不应该直接放在 form 元素中,而应放在 column 中。例如:

    <form id="form" dataContainer="orderDc">
        <column width="250px">
            <dateField property="date"/>
            <textField property="amount"/>
        </column>
        <column width="400px">
            <pickerField property="customer"/>
            <textArea property="info"/>
        </column>
    </form>

    在这种情况下,字段将排列成两列; 第一列所有字段的宽度为 250px,第二列所有字段的宽度为 400px

    column 的属性:

    • id – 一个可选的列标识符,允许在界面扩展时引用它。

    • width – 指定列的字段宽度。默认情况下,字段的宽度为 200px。在此属性中,可以以像素为单位指定宽度,也可以以列的水平宽度的百分比指定宽度。

    • childrenCaptionWidth – 为嵌套字段指定固定的标题宽度。设置 -1 使用自动大小。

    • childrenCaptionAlignment – 定义内嵌字段标题的对齐方式。有两个可选项:LEFTRIGHT。默认值为 LEFT。只有当captionPosition设置为 LEFT 是才能有效。

Form 接口的方法:

  • add() - 允许在向 Form 添加字段。它接受一个 Component 实例作为参数,也可以通过添加 columnrow 索引来定义新字段的位置。另外,还有一个重载方法可以使用 rowspancolspan 作为参数。

    框架不会为使用编程方式添加的组件指定数据容器,所以需要使用 setValueSource() 方法进行数据绑定。

    示例,声明一个带有 name 字段的表单:

    <data>
        <instance id="customerDc" class="com.company.demo.entity.Customer">
            <loader/>
        </instance>
    </data>
    <layout>
        <form id="form" dataContainer="customerDc">
            <column>
                <textField id="nameField" property="name"/>
            </column>
        </form>
    </layout>

    如下例所示,可以在界面控制器中使用编程的方式添加 email 字段:

    @Inject
    private UiComponents uiComponents;
    @Inject
    private InstanceContainer<Customer> customerDc;
    @Inject
    private Form form;
    
    @Subscribe
    private void onInit(InitEvent event) {
        TextField<String> emailField = uiComponents.create(TextField.TYPE_STRING);
        emailField.setCaption("Email");
        emailField.setWidthFull();
        emailField.setValueSource(new ContainerValueSource<>(customerDc, "email"));
        form.add(emailField);
    }
  • setChildrenCaptionAlignment(CaptionAlignment alignment) – 设置所有列中子组件的标题对齐方式。

  • setChildrenCaptionAlignment(int column, CaptionAlignment alignment) – 设置给定 index 列的子组件标题对齐方式。



3.5.2.1.20. 分组表格

GroupTable 分组表格能动态支持按任意字段把数据分组。如果需要基于某列分组,则把该列拖拽到 gui_groupTableIcon 元素的左侧。被分组的数据可以通过 gui_groupBox_plus/gui_groupBox_minus 按钮展开/收起。

gui groupTableDragColumn

该组件对应的 XML 名称为: groupTable

必须为 GroupTable 分组表格定义 CollectionContainer 类型的数据容器。否则,分组功能不可用。示例:

<data>
    <collection id="ordersDc"
                class="com.company.sales.entity.Order"
                view="order-with-customer">
        <loader id="ordersDl">
            <query>
                <![CDATA[select e from sales_Order e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <groupTable id="ordersTable"
                width="100%"
                dataContainer="ordersDc">
        <columns>
            <group>
                <column id="date"/>
            </group>
            <column id="customer"/>
            <column id="amount"/>
        </columns>
        <rowsCount/>
    </groupTable>
</layout>

groupcolumns 中的可选元素,它包含若干列 column,打开对应界面时,会默认将数据按这些列进行分组。

下面的例子中,我们会使用 columns 元素的 includeAll 属性,以及 group 元素。

<groupTable id="groupTable"
            width="100%"
            height="100%"
            dataContainer="customersDc">
    <columns includeAll="true">
        <group>
            <column id="address"/>
        </group>
        <column id="name"
                sortable="false"/>
    </columns>
</groupTable>

结果是给 name 列设置了一个特殊的属性,并且 GroupTable 按照 address 列做了分组。

可以针对每个列 column 设置 groupAllowed 布尔值属性,控制该列是否可以用来分组数据。

如果 aggregatable 属性设置为 true, 则会针对每组显示聚合结果;并在第一行显示针对所有行的聚合结果。如果 showTotalAggregation 属性设置为 false, 针对所有行的聚合结果则不会显示。

如果 multiselect 多选属性设置为 true, 按下 Ctrl 键并单击分组行时,该组会展开,该组的所有行都会被选上。但反过来不同,如果整组都被选上,Ctrl+单击 并不会反选所有组数据。通过 Ctrl 还是可以反选特定的行。

GroupTable 分组表格接口的方法:
  • groupByColumns() - 基于给定列进行分组。

    以下示例中,会将数据先以 department 分组,再以 city 分组:

    groupTable.groupByColumns("department", "city");
  • ungroupByColumns() - 取消基于给定列的分组。

    以下示例中,会取消针对 department 的分组, 但基于 city 的分组会保留。

    groupTable.ungroupByColumns("department");
  • ungroup() - 取消所有分组。

  • setAggregationDistributionProvider() 方法与 Table 组件的同名方法类似,不同之处在于,当创建 provider 时,会使用 GroupAggregationDistributionContext<V> 对象,包含了附加的信息:

    • GroupInfo groupInfo – 带有分组行信息的对象:分组的所有列的属性及其值。

  • getAggregationResults() 方法会为特定的 GroupInfo 对象返回一个聚合值的映射(map),键值为表格列的标识符,值为聚合值。

  • setStyleProvider() 方法可以为单元格设置显示样式。对于 GroupTable,该方法接收继承自 Table.StyleProviderGroupTable.GroupStyleProvider

    GroupStyleProvider 有个特殊方法,用来为分组行设置样式,该方法使用 GroupInfo 参数。GroupTable 会为每个分组行调用此方法。

    示例:

    @Inject
    private GroupTable<Customer> customerTable;
    
    @Subscribe
    public void onInit(InitEvent event) {
        customerTable.setStyleProvider(new GroupTable.GroupStyleProvider<Customer>() {
    
            @SuppressWarnings("unchecked")
            @Override
            public String getStyleName(GroupInfo info) {
                CustomerGrade grade = (CustomerGrade) info.getPropertyValue(info.getProperty());
                switch (grade) {
                    case PREMIUM:
                        return "premium-grade";
                    case HIGH:
                        return "high-grade";
                    case STANDARD:
                        return "standard-grade";
                }
                return null;
            }
    
            @Override
            public String getStyleName(Customer customer, @Nullable String property) {
                if (Boolean.TRUE.equals(customer.getActive())) {
                    return "active-customer";
                }
                return null;
            }
        });
    }

    然后,需要在应用程序主题中定义代码中用到的这组样式。创建主题的详细内容请参阅 Themes。对于 web 客户端,新的样式在 styles.scss 文件定义。控制器中定义的 style name,会变成 CSS 选择器。示例:

    .active-customer {
        font-weight: bold;
    }
    
    .premium-grade {
        background-color: red;
        color: white;
    }
    
    .high-grade {
        background-color: green;
        color: white;
    }
    
    .standard-grade {
        background-color: blue;
        color: white;
    }

GroupTable 分组表格的其它功能类似于普通表格.



3.5.2.1.21. 图片组件

Image 图片组件可以显示不同源的图片。可以绑定到数据容器或通过代码设置。

参考 在 CUBA 应用程序中使用图片 指南,了解如何在应用程序中上传和显示图片。

该组件的 XML 名称为: image

Image 图片组件可以显示实体属性为 FileDescriptorbyte[] 类型的数据。下面是一个简单的通过 dataContainerproperty 属性设置图片的例子:

<image id="image" dataContainer="employeeDc" property="avatar"/>

该组件展示 employeeDc 数据容器中 Employee 实体的 avatar 属性。

Image 图片组件还可以展示其它源的图片。可通过以下 image 的元素设置不同的源类型:

  • classpath - classpath 中的某个资源

    <image>
        <classpath path="com/company/sample/web/screens/myPic.jpg"/>
    </image>
  • file - 文件系统中的某个资源

    <image>
        <file path="D:\sample\modules\web\web\VAADIN\images\myImage.jpg"/>
    </image>
  • relativePath - 应用程序目录中的某个资源

    <image>
        <relativePath path="VAADIN/images/myImage.jpg"/>
    </image>
  • theme - 主题资源,例如 VAADIN/themes/customTheme/some/path/image.png

    <image>
        <theme path="com.company.sample/myPic.jpg"/>
    </image>
  • url - 可以从指定 URL 加载的资源

    <image>
        <url url="https://www.cuba-platform.com/sites/all/themes/cuba_adaptive/img/lori.png"/>
    </image>

image 图片组件的属性:

  • scaleMode - 缩放模式,有以下几种模式可选:

    • FILL - 根据组件大小拉伸图片。

    • CONTAIN - 保留长宽比压缩图片到能刚好在组件中全部展示。

    • COVER - 图片会被压缩或者拉升以适应组件的整个区域,维持组件本身的比例。如果图片的比例和组件的比例不匹配,会将图片做裁剪以适配组件的比例。

    • SCALE_DOWN - 在 NONECONTAIN 中选择图片能全部展示并且尺寸最小的方式。

    • NONE - 按实际大小显示。

  • alternateText - 设置替换文本,当资源未设置或找不到时显示该文本。

    <image id="image" alternateText="logo"/>

image 资源设置:

  • bufferSize - 下载该资源时的缓存大小,以字节为单位。

    <image>
        <file bufferSize="1024" path="C:/img.png"/>
    </image>
  • cacheTime - 该资源缓存过期时间,以毫秒为单位。

    <image>
        <file cacheTime="2400" path="C:/img.png"/>
    </image>
  • mimeType - 该资源的 MIME 类型。

    <image>
        <url url="https://avatars3.githubusercontent.com/u/17548514?v=4&#38;s=200"
             mimeType="image/png"/>
    </image>

如需以编程的方式管理 Image 组件,可以使用下列方法:

  • setValueSource() - 设置数据容器和实体属性名称,只支持 FileDescriptorbyte[] 两种类型的属性。

    数据容器可以通过编程的方式设置,比如在单元格中显示图片:

    frameworksTable.addGeneratedColumn("image", entity -> {
        Image image = uiComponents.create(Image.NAME);
        image.setValueSource(new ContainerValueSource<>(frameworksTable.getInstanceContainer(entity), "image"));
        image.setHeight("100px");
        return image;
    });
    gui Image 1
  • setSource() - 设置图片源内容。输入源类型,返回源对象,并继续通过流式接口配置源内容。每种源类型都有各自设置源内容的方法,比如 ThemeResource 主题源用 setPath()StreamResource 流资源用 setStreamSupplier()

    Image image = uiComponents.create(Image.NAME);
    
    image.setSource(ThemeResource.class)
            .setPath("images/image.png");

    或:

    image.setSource(StreamResource.class)
            .setStreamSupplier(() -> new FileDataProvider(fileDescriptor).provide())
            .setBufferSize(1024);

    使用以下实现了 Resource 接口的资源类型,或者通过扩展它实现自定义资源:

    • ClasspathResource - 位于 classpath 中的图片. 这类资源还可以通过 image 组件的 classpath 元素以声明的方式设置。

    • FileDescriptorResource - 通过 FileDescriptorFileStorage 中获取的图片。

    • FileResource - 文件系统中的图片。这类资源还可以通过 image 组件的 file 元素以声明的方式设置。

    • RelativePathResource - 应用程序中的图片。这类资源还可以通过 image 组件的 relativePath 元素以声明的方式设置。

    • StreamResource - 来自于流的图片。

    • ThemeResource - 主题的图片,比如 VAADIN/themes/yourtheme/some/path/image.png。这类资源还可以通过 image 组件的 theme 元素以声明的方式设置。

    • UrlResource - 从 URL 中加载的图片。这类源还可以通过 image 组件的 url 元素以声明的方式设置。

  • createResource() - 根据图片源类型创建图片资源。创建的对象可以传入 setSource() 方法。

    FileDescriptorResource resource = image.createResource(FileDescriptorResource.class)
            .setFileDescriptor(avatar);
    image.setSource(resource);
  • addClickListener() - 设置点击图片区域的监听器。

    image.addClickListener(clickEvent -> {
        if (clickEvent.isDoubleClick())
            notifications.create()
                    .withCaption("Double clicked")
                    .show();
    });
  • addSourceChangeListener() - 设置图片源改变的监听器。



3.5.2.1.22. 标签组件

Label 组件可以展示静态文本或者实体属性值。

该组件的 XML 名称是: label

下面是使用从本地化消息包中获取文本来设置标签的例子:

<label value="msg://orders"/>

value 属性设置标签的文本值。

在网页端,如果 value 属性设置的文本长度超出width值,文件会被分为多行显示。因此,显示一个多行标签,可以通过设置标签 width值实现. 如果文本过长但是 width值未定,文本会被截取。

<label value="Label, which should be split into multiple lines"
       width="200px"/>

可以在界面控制器中设置标签的参数,前提是给标签控件设置一个 id,然后在界面控制器中获取它的引用:

<label id="dynamicLabel"/>
@Inject
private Label dynamicLabel;

@Subscribe
protected void onInit(InitEvent event) {
    dynamicLabel.setValue("Some value");
}

Label 组件还可以显示实体属性值。这种情况需要设置 dataContainerproperty 属性,例如:

<data>
    <instance id="customerDc" class="com.company.sales.entity.Customer" view="_local">
        <loader/>
    </instance>
</data>
<layout>
    <label dataContainer="customerDc" property="name"/>
</layout>

上面例子里,标签组件显示 customerDс 数据容器中实体 Customername 字段。

htmlEnabled 属性控制如何解析 value 属性值:如果 htmlEnabled="true",则 value 值以 HTML 代码解析,否则按纯文本解析。

htmlSanitizerEnabled 属性可以启用或禁用 HTML 清理。如果 htmlEnabledhtmlSanitizerEnabled 属性都设置为 true,则标签的值会被清理。

protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
            "color=\"moccasin\">my</font> " +
            "<font size=\"7\">big</font> <sup>sphinx</sup> " +
            "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
            "red;\">quartz</span><svg/onload=alert(\"XSS\")>";

@Inject
private Label<String> label;

@Subscribe
public void onInit(InitEvent event) {
    label.setHtmlEnabled(true);
    label.setHtmlSanitizerEnabled(true);
    label.setValue(UNSAFE_HTML);
}

htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

标签样式

在基于 Halo 主题的网页端, 可以通过 stylename 属性定义样式。在 XML 描述里或者在界面控制器中:

<label value="Label to be styled"
       stylename="colored"/>

通过代码设置样式时,选择 HaloTheme 类中以 LABEL_ 开头的常量:

label.setStyleName(HaloTheme.LABEL_COLORED);
  • bold - 加粗。适用于重要的或者需要突出显示的文本。

  • colored - 彩色文本。

  • failure - 失败标签样式。标签外会有一个边框,文本旁边会有一个图标。适用于一些组件内部的上下文通知。

  • h1 - 标题样式,应用程序标题。

  • h2 - 标题样式,应用程序中章节标题。

  • h3 - 标题样式,应用程序子章节标题。

  • h4 - 标题样式,应用程序小章节标题。

  • light - 纤细。适用于附加/补充文本。

  • no-margin - 不要默认边距。

  • spinner - 回旋样式。添加到空 Label 组件则可以创建一个可用于表示任务进行中(比如数据正在加载中…​)的旋转图标。

  • success - 成功标签样式。标签外会有一个边框,文本旁会有一个图标。适用于一些组件内部的上下文通知。


Label 组件的属性列表

align - css - dataContainer - description - descriptionAsHtml - enable - box.expandRatio - height - htmlEnabled - htmlSanitizerEnabled - icon - id - property - stylename - value - visible - width

Label 组件的元素

formatter

Label 组件样式

bold - colored - failure - h1 - h2 - h3 - h4 - huge - large - light - no-margin - small - spinner - success - tiny

API

addValueChangeListener


Link 链接组件为一个超链接,可以打开外部 web 资源。

该组件的 XML 名称为: link

以下为一个 link 的 XML 描述示例:

<link caption="Link" url="https://www.cuba-platform.com" target="_blank" rel="noopener"/>

link 组件的属性:



3.5.2.1.24. 链接按钮

LinkButton 组件外观类似超链接,本质是一个按钮。

该组件的 XML 名称是: linkButton

LinkButton 可以包含文本或图标(或二者均有)。下图展示了不同类型的按钮:

gui linkButtonTypes

默认情况,LinkButton 的标题如果超出了width的值,会被分成多行展示。因此,要展示多行的链接按钮,指定其 width 为绝对值即可。如果链接按钮的标题过长,而 width 又没有设置,此时标题会被截断。

用户可以修改默认行为将 LinkButton 的标题展示在一行:

  1. 创建 主题扩展自定义主题

  2. 定义 SCSS 变量 $cuba-link-button-caption-wrap:

    $cuba-link-button-caption-wrap: false

LinkButton 与普通 Button 的不同仅在于外观。所有的属性和行为都与 Button 中描述的一样。

以下是一个 LinkButton 的 XML 描述示例,它调用了控制器的 someMethod() 方法。还设置了caption属性,description属性(做为提示)和icon属性:

<linkButton id="linkButton"
            caption="msg://linkButton"
            description="Press me"
            icon="SAVE"
            invoke="someMethod"/>


3.5.2.1.25. 下拉框

该控件支持从下拉列表中选择值。下拉列表提供基于用户的输入对数据进行过滤的功能,也支持分页显示数据。

gui lookupField

该组件的 XML 名称是: lookupField

  • 使用 LookupField 下拉框最简单的例子是从实体属性中选择枚举值。比如,Role 角色实体中有 type 属性,为枚举类型。用户可以通过 LookupField 下拉框控件编辑该属性:

    <data>
        <instance id="roleDc"
                  class="com.haulmont.cuba.security.entity.Role"
                  view="_local">
            <loader/>
        </instance>
    </data>
    <layout expand="editActions" spacing="true">
        <lookupField dataContainer="roleDc" property="type"/>
    </layout>

    在上面的例子中,使用 roleDc 数据容器选择 Role 实体数据。lookupField 下拉框组件中,数据容器的连接通过 dataContainer 属性定义,实体属性的名称定义在 property 属性中。此时,这个属性为枚举类型,控件的下拉列表中会显示所有枚举值的本地化名称

  • 类似的,LookupField 下拉框也可以用来选择实体实例。选项容器属性可以用来创建一系列选项:

    <data>
        <instance id="carDc" class="com.haulmont.sample.core.entity.Car" view="carEdit">
            <loader/>
        </instance>
        <collection id="colorsDc" class="com.haulmont.sample.core.entity.Color" view="_minimal">
            <loader id="colorsDl">
                <query>
                    <![CDATA[select e from sample_Color e]]>
                </query>
            </loader>
        </collection>
    </data>
    <layout>
        <lookupField dataContainer="carDc" property="color" optionsContainer="colorsDc"/>
    </layout>

    这种时候,colorsDc 数据容器中的 Color 实体的实例名称会显示在下拉列表中,被选择的值会被设置到 carDc 数据容器中 Car 实体的 color 属性中。

    captionProperty定义显示到下拉框的实体属性值,而非实例名称,这样可以设置下拉列表的文字值。

  • 使用 setOptionCaptionProvider() 方法可以为 LookupField 组件显示的字符串选项名定义标题:

    lookupField.setOptionCaptionProvider((item) -> item.getLocalizedName());
  • 选项列表还可以通过 setOptionsList()setOptionsMap()setOptionsEnum() 设置,也可以在 XML 描述中通过 optionsContainer 属性设置。

    • setOptionsList() - 通过代码指定选项列表。首先在 XML 描述中声明组件:

      <lookupField id="numberOfSeatsField" dataContainer="modelDc" property="numberOfSeats"/>

      然后将组件注入界面控制器,在 onInit() 方法中设置选项列表:

      @Inject
      protected LookupField<Integer> numberOfSeatsField;
      
      @Subscribe
      public void onInit(InitEvent event) {
          List<Integer> list = new ArrayList<>();
          list.add(2);
          list.add(4);
          list.add(5);
          list.add(7);
          numberOfSeatsField.setOptionsList(list);
      }

      组件的下拉列表中会显示 2, 4, 5 和 7。被选择的值会设置到 modelDc 数据容器中的 numberOfSeats 属性上。

    • setOptionsMap() 可以提供选项 map。比如为 XML 描述中声明的 numberOfSeatsField 组件指定选项 map(在 onInit() 方法中):

      @Inject
      protected LookupField<Integer> numberOfSeatsField;
      
      @Subscribe
      public void onInit(InitEvent event) {
          Map<String, Integer> map = new LinkedHashMap<>();
          map.put("two", 2);
          map.put("four", 4);
          map.put("five", 5);
          map.put("seven", 7);
          numberOfSeatsField.setOptionsMap(map);
      }

      组件下拉框会显示 twofourfiveseven 文本。但是组件的值则是与文本对应的数字值。数字值会被设置到 modelDc 数据容器中的 numberOfSeats 属性上。

    • setOptionsEnum() 需要枚举类做为参数。下拉列表会显示枚举值的本地化名称,组件的值则为枚举值。

  • setPopupWidth() 可以设置下拉列表的宽度,宽度用字符串格式传递给该方法。使用相对单位(比如,"50%")可以设置下拉列表的宽度是针对于 LookupField 本身的相对值。默认情况下,该宽度设置为 null,下拉列表的宽度为了适应显示内容的宽度而可以大于组件的宽度。通过设置该值为 "100%",可以使得下拉列表的宽度等于 LookupField 的宽度。

  • 通过 setOptionStyleProvider() 可以为组件显示的不同的选项设置分别的 style name:

    lookupField.setOptionStyleProvider(entity -> {
        User user = (User) entity;
        switch (user.getGroup().getName()) {
            case "Company":
                return "company";
            case "Premium":
                return "premium";
            default:
                return "company";
        }
    });
  • 下拉列表中的组件可以在左边设置对应的图标。在界面控制器中使用 setOptionIconProvider() 方法设置:

    lookupField.setOptionIconProvider(entity -> {
        if (entity.getType() == LegalStatus.LEGAL)
            return "icons/icon-office.png";
        return "icons/icon-user.png";
    });
    gui lookupField 2

    如果使用 SVG 图标, 显式设置图标大小以避免图标覆盖。

    <svg version="1.1"
         id="Capa_1"
         xmlns="http://www.w3.org/2000/svg"
         xmlns:xlink="http://www.w3.org/1999/xlink"
         xml:space="preserve"
    
         style="enable-background:new 0 0 55 55;"
         viewBox="0 0 55 55"
    
         height="25px"
         width="25px">
  • setOptionImageProvider() 方法可以定义 LookupField 组件显示的选项图片。该方法设置一个接收资源类型参数的函数。

    @Inject
    private LookupField<Customer> lookupField;
    @Inject
    private Image imageResource;
    
    @Subscribe
    private void onInit(InitEvent event) {
       lookupField.setOptionImageProvider(e ->
          imageResource.createResource(ThemeResource.class).setPath("icons/radio.svg"));
    }
  • 如果 LookupField 下拉框组件非required,并且对应的实体属性也非必须,下拉选项会包含一个空行。如果选择了空行,组件值为 null. 使用 nullName 属性设置在“空行”上显示的文本。以下为一个示例:

    <lookupField dataContainer="carDc" property="colour" optionsContainer="colorsDs" nullName="(none)"/>

    这样,下拉框中的“空行”上会显示 (none) 文本。如果用户选择了该行,对应的实体属性值会设置为 null

    如果在代码中通过 setOptionsList() 设置了选项, 可以用 setNullOption() 方法设置空行文本,这样,如果用户选择了该行,组件值则为 null

    LookupField 过滤器:
    • filterMode 属性设置基于用户输入的过滤模式:

      • NO − 不过滤。

      • STARTS_WITH − 选项文本以用户输入开头。

      • CONTAINS − 选项文本包含用户输入(默认模式)。

    • setFilterPredicate() 方法用来设置过滤方法,该方法判断元素是否跟查找文字匹配。比如:

      BiFunction<String, String, Boolean> predicate = String::contains;
      lookupField.setFilterPredicate((itemCaption, searchString) ->
              predicate.apply(itemCaption.toLowerCase(), searchString));

      FilterPredicatetest 方法可以用来客户化过滤逻辑,比如处理方言/特殊字符:

      lookupField.setFilterPredicate((itemCaption, searchString) ->
              StringUtils.replaceChars(itemCaption, "ÉÈËÏÎ", "EEEII")
                  .toLowerCase()
                  .contains(searchString));
  • LookupField 组件在没有合适选项的时候可以处理用户的输入,通过 setNewOptionHandler() 来处理,示例:

    @Inject
    private Metadata metadata;
    @Inject
    private LookupField<Color> colorField;
    @Inject
    private CollectionContainer<Color> colorsDc;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        colorField.setNewOptionHandler(caption -> {
            Color color = metadata.create(Color.class);
            color.setName(caption);
            colorsDc.getMutableItems()
                    .add(color);
            colorField.setValue(color);
        });
    }

    当用户输入不匹配任何选项的值并且按下回车键时,会触发调用新选项处理器。这时,上述代码会创建一个新的 Color 实例,name 属性值为用户输入,并且这个实例会加到下拉选项数据容器中,做为该控件当前被选中值。

    除了使用 setNewOptionHandler() 方法来处理用户输入,还可以在 XML 描述中通过 newOptionHandler 属性来指定控制器处理的方法。这个方法需要两个参数,一个是 LookupField 类型,指向组件实例,另一个是 String 类型,指向用户输入。newOptionAllowed 属性也可以用来开启是否允许输入新值。

  • nullOptionVisible XML 属性设置是否在下拉列表显示空值。可以配置 LookupField 下拉框非 required但是不提供空值选项。

  • textInputAllowed 属性可以禁止过滤器功能及键盘输入。对短列表来说很方便。默认值为 true

  • pageLength 属性重新设置下拉列表中一页选项的个数,默认值在cuba.gui.lookupFieldPageLength应用程序属性中定义。

  • 在基于 Halo 主题的 Web 客户端,可以自定义样式,通过 XML stylename 属性或在界面控制器中通过代码设置:

    <lookupField id="lookupField"
                 stylename="borderless"/>

    通过代码设置时,从 HaloTheme 类中选择 LOOKUPFIELD_ 开头的常量样式:

    lookupField.setStyleName(HaloTheme.LOOKUPFIELD_BORDERLESS);

    LookupField 下拉框样式有:

    • align-center - 文本居中对齐。

    • align-right - 文本靠右对齐。

    • borderless - 文本不要边框和背景。



3.5.2.1.26. 下拉选择器

LookupPickerField 下拉选择器支持在文本框中显示实体实例,从下拉列表选择实例,点击右侧的按钮触发操作。

gui lookupPickerField

该组件的 XML 名称为: lookupPickerField

事实上,LookupPickerField 下拉选择器是LookupFieldPickerField的组合。所以它与 LookupField 有相同的功能,但是默认的操作不一样。LookupPickerField 的默认操作是 lookup lookupBtnopen openBtn

下面是一个用 LookupPickerFieldCar 实体的 color 属性提供选项值的例子:

<data>
    <instance id="carDc" class="com.haulmont.sample.core.entity.Car" view="carEdit">
        <loader/>
    </instance>
    <collection id="colorsDc" class="com.haulmont.sample.core.entity.Color" view="_minimal">
        <loader id="colorsDl">
            <query>
                <![CDATA[select e from sample_Color e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <lookupPickerField dataContainer="carDc" property="color" optionsContainer="colorsDc"/>
</layout>


3.5.2.1.27. 掩码字段

这是一个文本字段控件,其中的数据以预定义格式输入。例如,使用 MaskedField 输入电话号码很方便。

该组件对应的 XML 名称: maskedField

MaskedField 基本上复制了 TextField 的功能,但是不能为掩码字段设置 datatype。因此,MaskedField 仅适用于 String 类型的文本和实体属性。MaskedField 具有以下特定属性:

  • mask – 为字段设置掩码。要设置掩码,请使用以下字符:

    • # – 数字

    • U – 大写字母

    • L – 小写字母

    • ? – 字母

    • А – 字母或数字

    • * – 任何字符

    • H – 大写十六进制字符

    • h – 小写十六进制字符

    • ~ – " +" 或者 "-" 字符

  • valueMode – 定义返回值的格式(带掩码或不带掩码),可以使用 maskedclear 作为值。

下面提供了带有用于输入电话号码的掩码的文本字段示例:

<maskedField id="phoneNumberField" mask="(###)###-##-##" valueMode="masked"/>
<button id="showPhoneNumberBtn" caption="msg://showPhoneNumberBtn"/>
@Inject
private MaskedField phoneNumberField;
@Inject
private Notifications notifications;

@Subscribe("showPhoneNumberBtn")
protected void onShowPhoneNumberBtnClick(Button.ClickEvent event) {
    notifications.create()
            .withCaption((String) phoneNumberField.getValue())
            .withType(Notifications.NotificationType.HUMANIZED)
            .show();
}
gui MaskedField
gui MaskedField maskedValueMode


3.5.2.1.28. 选项组

这是一个允许用户从选项列表中进行选择的组件。单选框用于选择单个值;一组复选框用于选择多个值。

gui optionsGroup

该组件对应的 XML 名称: optionsGroup

  • 使用 OptionsGroup 的最简单的情况是为实体属性选择枚举值。例如,Customer 实体具有 CustomerGrade 类型的 grade 属性,CustomerGrade 属性就是一个枚举值。就可以使用 OptionsGroup 来编辑这个属性,如下所示:

    <data>
        <instance id="customerDc"
                  class="com.company.app.entity.Customer"
                  view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <optionsGroup id="gradeField" property="grade" dataContainer="customerDc"/>
    </layout>

    上面的示例中,为 Customer 实体定义了数据容器 customerDc 。在 optionsGroup 组件中,指向数据容器的链接在 dataContainer 属性中指定,实体属性的名称在 property 属性中设置。

    组件的显示效果:

gui optionsGroup customerGrade
  • 组件选项列表可通过 setOptionsList()setOptionsMap()setOptionsEnum() 方法任意地指定,也可以使用 optionsContaineroptionsEnum XML 属性来指定。

  • setOptionsList() 方法允许以编程方式指定组件选项列表。为此,在 XML 描述中声明一个组件:

    <optionsGroup id="optionsGroupWithList"/>

    然后将该组件注入控制器,并在 onInit() 方法中指定选项列表:

    @Inject
    private OptionsGroup<Integer, Integer> optionsGroupWithList;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        List<Integer> list = new ArrayList<>();
        list.add(2);
        list.add(4);
        list.add(5);
        list.add(7);
        optionsGroupWithList.setOptionsList(list);
    }

    该组件将会如下显示:

    gui optionsGroup integerList

    根据所选的选项,组件的 getValue() 方法将返回 Integer 类型的值:2 、 4 、 5 、 7。

  • setOptionsMap() 方法允许分别指定选项的字符串名称和选项值。例如,我们可以在控制器的 onInit() 方法中为已经在 XML 描述中配置的 optionsGroupWithMap 组件设置以下选项 map:

    @Inject
    private OptionsGroup<Integer, Integer> optionsGroupWithMap;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("two", 2);
        map.put("four", 4);
        map.put("five", 5);
        map.put("seven", 7);
        optionsGroupWithMap.setOptionsMap(map);
    }

    该组件将会如下显示:

    gui optionsGroup integerMap

    根据所选的选项,该组件的 getValue() 方法将返回 Integer 类型的值:2、 4 、 5、 7,而不是界面上显示的字符串。

  • setOptionsEnum() 方法将一个枚举类作为参数。选项列表中将显示枚举值的本地化名称,而组件的值将是枚举值。

  • 该组件可以从数据容器中获取选项列表。为此,需要使用 optionsContainer 属性。示例:

    <data>
        <collection id="coloursDc"
                    class="com.haulmont.app.entity.Colour"
                    view="_local">
            <loader id="coloursLoader">
                <query>
                    <![CDATA[select c from app_Colour c]]>
                </query>
            </loader>
        </collection>
    </data>
    <layout>
        <optionsGroup id="coloursField" optionsContainer="coloursDc"/>
    </layout>

    在这种情况下,coloursField 组件将显示 coloursDc 数据容器中的 Colour 实体的实例名,它的 getValue() 方法将返回所选的实体实例。

    使用 captionProperty 属性,可以指定一个实体属性作为选项的显示名称。

  • multiselect 属性用于将 OptionsGroup 转换为多选模式。如果启用 multiselect,组件将显示为一组独立的复选框,组件值是所选选项的列表。

    例如,在 XML 描述中创建该组件:

    <optionsGroup id="roleTypesField" multiselect="true"/>

    并为其设置一个选项列表 – RoleType 枚举值:

    @Inject
    protected OptionsGroup roleTypesField;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        roleTypesField.setOptionsList(Arrays.asList(RoleType.values()));
    }

    那么该组件将会如下显示:

    gui optionsGroup roleType multi

    在这种情况下,组件的 getValue() 方法将返回一个 java.util.List,其中包含 RoleType.READONLYRoleType.DENYING 枚举值。

    上面的示例同时展示了 OptionsGroup 组件显示数据模型中枚举值的本地化名称的功能。

    还可以通过将 java.util.List 值传递给 setValue() 方法以编程方式选择一些值:

    optionsGroup.setValue(Arrays.asList(RoleType.STANDARD, RoleType.ADMIN));
  • orientation 属性定义了分组元素的排列方向。默认情况下元素垂直排列。可以使用 horizontal 值设置为水平方向。

OptionsGroup 的展示可以使用带 $cuba-optiongroup-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.29. 选项列表

OptionsListOptionsGroup 组件的变体,它将选项列表展示为可垂直滚动的列表。如果启用了多选,则可以通过单击时按住 Ctrl 键来选择多个选项,或按住 Shift 键来选择一个范围内的选项。

gui optionsList

该组件对应的 XML 名称: optionsList

默认情况下,OptionsList 组件在建议弹窗中显示第一个空元素,可以通过将 nullOptionVisible 属性设置为 false 来禁止此行为。

使用 addDoubleClickListener() 可以监听 DoubleClickEvent,用来拦截组件选项上的双击事件。

optionsList.addDoubleClickListener(doubleClickEvent ->
        notifications.create()
        .withCaption("Double clicked")
        .show());

同样,也可以在界面控制器中订阅组件的双击事件,示例:

@Subscribe("optionsList")
private void onOptionsListDoubleClick(OptionsList.DoubleClickEvent event) {
    notifications.create()
            .withCaption("Double clicked")
            .show();
}

OptionsListOptionsGroup API 之间的唯一区别是 OptionsList 没有 orientation 属性。



3.5.2.1.30. 密码字段

这是一个将用户输入字符显示为回显字符(echo characters)的字段。

该组件的 XML 名称: passwordField

除了不能设置 datatypePasswordFieldTextField 基本一样。PasswordField 仅用于处理文本和 String 类型实体属性。

示例:

<passwordField id="passwordField" caption="msg://name"/>
<button id="showPasswordBtn" caption="msg://buttonsName"/>
@Inject
private PasswordField passwordField;
@Inject
private Notifications notifications;

@Subscribe("showPasswordBtn")
protected void onShowPasswordBtnClick(Button.ClickEvent event) {
    notifications.create()
            .withCaption(passwordField.getValue())
            .show();
}
gui PasswordField

autocomplete 属性允许在 Web 浏览器中保存密码。默认不保存。

通过 capsLockIndicator 属性设置 CapsLockIndicator组件的 id,该组件指示 passwordField 的大小写锁定状态。此状态仅在 passwordField 获得焦点时处理。当失去焦点时,状态变为 "Caps Lock off"。

示例:

<passwordField id="passwordField"
               capsLockIndicator="capsLockIndicator"/>
<capsLockIndicator id="capsLockIndicator"
                   align="MIDDLE_CENTER"
                   capsLockOffMessage="Caps Lock is OFF"
                   capsLockOnMessage="Caps Lock is ON"/>


3.5.2.1.31. 选择器控件

PickerField 在文本字段中显示实体实例,并在用户单击右侧的按钮时执行操作。

PickerField

该组件的 XML 名称: pickerField

  • PickerField 有一个使用规则,就是它只用于引用类型的实体属性。使用时为组件指定 dataContainerproperty 属性就可以了:

    <data>
        <instance id="carDc" class="com.haulmont.sample.core.entity.Car" view="carEdit">
            <loader/>
        </instance>
    </data>
    <layout>
        <pickerField dataContainer="carDc" property="color"/>
    </layout>

    在上面的例子中,界面为具有 color 属性的 Car 实体定义了 id 为 carDc数据容器。在 pickerField 元素中,通过 dataContainer 属性连接到此数据容器,并给 property 属性设置了实体属性的名称。实体属性应该引用另一个实体,在上面的示例中就是 Color 实体。

  • 对于 PickerField,可以定义任意数量的操作,这些操作在组件右侧显示为按钮。

    操作的定义可以使用 actions 嵌套元素在 XML 描述中完成,也可以使用 addAction() 方法在控制器中以编程方式完成。

    • 平台提供一组标准的 PickerField 操作picker_lookuppicker_clearpicker_open。它们分别执行关联实体的选择、清空组件以及打开所选关联实体的编辑界面。在 XML 中声明标准操作时,应当定义操作的标识符并使用 type 属性定义操作类型。

      如果在声明组件时未定义 actions 元素中的动作,则 XML 加载器将默认为其定义 lookupclear 操作。要添加一个默认操作,比如 open,就需要定义 actions 元素,如下所示:

      <pickerField dataContainer="carDc" property="color">
          <actions>
              <action id="lookup" type="picker_lookup"/>
              <action id="open" type="picker_open"/>
              <action id="clear" type="picker_clear"/>
          </actions>
      </pickerField>

      action 元素能不能扩展,但可以按操作标识符来覆盖一组标准操作。所以必须明确定义所有需要的操作的标识符。该组件如下所示:

      gui pickerFieldActionsSt

      使用 addAction() 以编程方式设置标准操作。如果在组件的 XML 描述中没有 actions 嵌套元素,就可以使用这个方法添加缺少的操作:

      @Inject
      protected PickerField<Color> colorField;
      
      @Subscribe
      protected void onInit(InitEvent event) {
          colorField.addAction(actions.create(OpenAction.class));
      }

      如果组件是在控制器中创建的,则它将不会包含默认操作,需要显式添加所有需要的操作:

      @Inject
      private InstanceContainer<Car> carDc;
      @Inject
      private UiComponents uiComponents;
      @Inject
      private Actions actions;
      
      @Subscribe
      protected void onInit(InitEvent event) {
          PickerField<Color> colorField = uiComponents.create(PickerField.NAME);
          colorField.setValueSource(new ContainerValueSource<>(carDc, "color"));
          colorField.addAction(actions.create(LookupAction.class));
          colorField.addAction(actions.create(OpenAction.class));
          colorField.addAction(actions.create(ClearAction.class));
          getWindow().add(colorField);
      }

      可以通过订阅 ActionPerformedEvent 事件来自定义标准操作的行为并提供自定义的实现。比如,可以通过如下方式使用特定的查找界面:

      @Inject
      private ScreenBuilders screenBuilders;
      @Inject
      private PickerField<Color> pickerField;
      
      @Subscribe("pickerField.lookup")
      protected void onPickerFieldLookupActionPerformed(Action.ActionPerformedEvent event) {
              screenBuilders.lookup(pickerField)
                       .withScreenClass(CustomColorBrowser.class)
                       .build()
                       .show();
      }

      更多信息,请参阅 打开界面 部分。

    • 可以在 XML 描述中的 actions 嵌套元素中定义任何操作,这些操作的逻辑可以在操作的事件中实现,例如:

      <pickerField dataContainer="orderDc" property="customer">
          <actions>
              <action id="lookup"/>
              <action id="show" icon="PICKERFIELD_OPEN" caption="Show"/>
          </actions>
      </pickerField>
      @Inject
      private PickerField<Customer> pickerField;
      
      @Subscribe("pickerField.show")
      protected void onPickerFieldShowActionPerformed(Action.ActionPerformedEvent event) {
          CustomerEdit customerEdit = screenBuilders.editor(pickerField)
                  .withScreenClass(CustomerEdit.class)
                  .build();
          customerEdit.setDiscount(true);
          customerEdit.show();
      }

      操作的声明式创建和编程式创建在操作部分有描述。

  • 可以在不绑定实体的情况下使用 PickerField,即不设置 dataContainerproperty属性。在这种情况下,metaClass 属性应该用于指定 PickerField 的实体类型。例如:

    <pickerField id="colorField" metaClass="sample_Color"/>

    可以通过将组件注入控制器并调用其 getValue() 方法来获取所选实体的实例。

    要正确使用 PickerField 组件,需要设置 metaClass 属性,或者同时设置 dataContainerproperty 属性。

    可以在 PickerField 中使用键盘快捷键,有关详细信息,请参阅快捷键

  • PickerField 组件可以在左边有一个图标。下面的例子在界面控制器中使用 setOptionIconProvider() 提供的方法。"cancel" 图标会在字段值是 null 的时候显示,而 "chain" 图标会在其它情况显示。

    @Inject
    private PickerField<Customer> pickerField;
    
    protected String generateIcon(Customer customer) {
        return (customer!= null) ? "icons/chain.png" : "icons/cancel.png";
    }
    
    @Subscribe
    private void onInit(InitEvent event) {
        pickerField.setOptionIconProvider(this::generateIcon);
    }
    gui pickerField icons


3.5.2.1.32. 弹窗按钮

这是一个带弹窗的按钮。弹窗中可以包含操作列表或自定义内容。

PopupButton

该组件的 XML 名称: popupButton

PopupButton 可以使用caption属性指定按钮名称,使用icon属性指定按钮图标。使用description属性定义提示文字。下图显示了不同类型的按钮:

gui popupButtonTypes

popupButton 的元素:

  • actions - 指定下拉列表内的操作。

    操作的属性中只有 captionenablevisible 能起作用。descriptionshortcut 属性会被忽略。icon 属性的处理取方式决于应用程序属性 cuba.gui.showIconsForPopupMenuActions 和组件的 showActionIcons 属性。后者优先。

    下面是一个按钮示例,其中包含一个具有两个操作的下拉列表:

    <popupButton id="popupButton" caption="msg://popupButton" description="Press me">
        <actions>
            <action id="popupAction1" caption="msg://action1"/>
            <action id="popupAction2" caption="msg://action2"/>
        </actions>
    </popupButton>

    可以定义新的操作,也可以使用当前界面中元素已定义的操作,例如:

    <popupButton id="popupButton">
        <actions>
            <action id="ordersTable.create"/>
            <action id="ordersTable.edit"/>
            <action id="ordersTable.remove"/>
        </actions>
    </popupButton>
  • popup - 为弹窗设置自定义的内容。如果设置了自定义弹出内容,则会忽略操作。

    下面是自定义弹出布局的示例:

    <popupButton id="popupButton"
                 caption="Settings"
                 align="MIDDLE_CENTER"
                 icon="font-icon:GEARS"
                 closePopupOnOutsideClick="true"
                 popupOpenDirection="BOTTOM_CENTER">
        <popup>
            <vbox width="250px"
                  height="AUTO"
                  spacing="true"
                  margin="true">
                <label value="Settings"
                       align="MIDDLE_CENTER"
                       stylename="h2"/>
                <progressBar caption="Progress"
                             width="100%"/>
                <textField caption="New title"
                           width="100%"/>
                <lookupField caption="Status"
                             optionsEnum="com.haulmont.cuba.core.global.SendingStatus"
                             width="100%"/>
                <hbox spacing="true">
                    <button caption="Save" icon="SAVE"/>
                    <button caption="Reset" icon="REMOVE"/>
                </hbox>
            </vbox>
        </popup>
    </popupButton>
    gui popupButton custom

popupButton 的属性:

  • autoClose - 定义是否应在操作触发后自动关闭弹窗。

  • closePopupOnOutsideClick - 如果设置为 true,则单击弹窗外部时将其关闭。这不会影响单击按钮本身的行为。

  • menuWidth - 设置弹窗宽度。

  • popupOpenDirection - 设置弹窗的打开方向。可能的取值:

    • BOTTOM_LEFT,

    • BOTTOM_RIGHT,

    • BOTTOM_CENTER.

  • showActionIcons - 显示操作按钮的图标。

  • togglePopupVisibilityOnClick - 定义在弹窗上连续点击是否切换弹窗可见性。

PopupButton 接口的方法:

  • addPopupVisibilityListener() - 添加一个监听器来拦截组件的可见性更改事件。

    popupButton.addPopupVisibilityListener(popupVisibilityEvent ->
            notifications.create()
                    .withCaption("Popup visibility changed")
                    .show());

    也可以通过订阅相应事件来跟踪 PopupButton 的可见性状态更改。

    @Subscribe("popupButton")
    protected void onPopupButtonPopupVisibility(PopupButton.PopupVisibilityEvent event) {
        notifications.create()
                .withCaption("Popup visibility changed")
                .show();
    }

PopupButton 的展示可以使用带 $cuba-popupbutton-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.33. 弹窗查看控件

PopupView 是一个允许用容器打开弹出窗口的控件。可以通过单击简要值链接或以编程方式打开弹窗。可以通过鼠标移出或点击外部区域来关闭弹窗。

典型的 PopupView 如下所示:

Popup hidden
Figure 13. 弹窗隐藏状态
Popup visible
Figure 14. 弹窗打开状态

从本地化消息包中获取简要值的 PopupView 的示例:

<popupView id="popupView"
           minimizedValue="msg://minimizedValue"
           caption="PopupView caption">
    <vbox width="60px" height="40px">
        <label value="Content" align="MIDDLE_CENTER"/>
    </vbox>
</popupView>

PopupView 的内部内容应该是一个容器,例如 BoxLayout

PopupView 方法:

  • setPopupVisible() 允许以编程方式打开弹窗。

    @Inject
    private PopupView popupView;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        popupView.setMinimizedValue("Hello world!");
    }
  • setMinimizedValue() 允许以编程方式设置简要值。

    @Inject
    private PopupView popupView;
    
    @Override
    public void init(Map<String, Object> params) {
        popupView.setMinimizedValue("Hello world!");
    }
  • addPopupVisibilityListener(PopupVisibilityListener listener) 方法可用来添加一个跟踪弹窗可见性变化的监听器。

    @Inject
    private PopupView popupView;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        popupView.addPopupVisibilityListener(popupVisibilityEvent ->
                notifications.create()
                        .withCaption(popupVisibilityEvent.isPopupVisible() ? "The popup is visible" : "The popup is hidden")
                        .withType(Notifications.NotificationType.HUMANIZED)
                        .show()
        );
    }
  • PopupView 组件提供设置弹出框位置的方法。由 topleft 值决定弹出框左上角的位置。可以使用标准值或者自定义值设置这两个属性,标准值是:

    • TOP_RIGHT

    • TOP_LEFT

    • TOP_CENTER

    • MIDDLE_RIGHT

    • MIDDLE_LEFT

    • MIDDLE_CENTER

    • BOTTOM_RIGHT

    • BOTTOM_LEFT

    • BOTTOM_CENTER

      DEFAULT 值设置弹窗位置为简要值链接的中间。

      设置弹窗位置的 Java 方法:

    • void setPopupPosition(int top, int left) - 设置 topleft 弹窗位置。

    • void setPopupPositionTop(int top) - 设置 top 弹窗位置。

    • void setPopupPositionLeft(int left) - 设置 left 弹窗位置。

    • void setPopupPosition(PopupPosition position) - 使用标准值设置弹窗位置。

      @Inject
      private PopupView popupView;
      
      @Subscribe
      public void onInit(InitEvent event) {
              popupView.setPopupPosition(PopupView.PopupPosition.BOTTOM_CENTER);
      }

      如果使用标准值设置弹窗位置,则会重置 lefttop 的值,反之亦然。

  • 使用标准值显示的弹窗会有一点点的缩进。可以通过在样式中重写 $popup-horizontal-margin$popup-vertical-margin 的值去掉缩进。

  • 使用下面的 Java 方法获取弹窗位置:

    • int getPopupPositionTop() - 返回 top 弹窗位置。

    • int getPopupPositionLeft() - 返回 left 弹窗位置。

    • PopupPosition getPopupPosition() - 如果不是用标准值设置的位置,返回 null。

PopupView 的属性:

  • minimizedValue 属性定义弹窗按钮的简要值文本。此文本可包含 HTML 标记。

  • 如果 hideOnMouseOut 属性设置为 false,在弹窗外部单击时会关闭弹窗。



3.5.2.1.34. 进度条

ProgressBar 组件用于显示需要长时间处理的任务的进度。

gui progressBar

该组件的 XML 名称: progressBar

下面是该组件与后台任务机制一起使用的示例:

<progressBar id="progressBar" width="100%"/>
@Inject
private ProgressBar progressBar;
@Inject
private BackgroundWorker backgroundWorker;

private static final int ITERATIONS = 5;

@Subscribe
protected void onInit(InitEvent event){
    BackgroundTask<Integer, Void> task = new BackgroundTask<Integer, Void>(300, getWindow()) {
        @Override
        public Void run(TaskLifeCycle<Integer> taskLifeCycle) throws Exception{
            for(int i = 1; i <= ITERATIONS; i++) {
                TimeUnit.SECONDS.sleep(2); (1)
                taskLifeCycle.publish(i);
            }
            return null;
        }

        @Override
        public void progress(List<Integer> changes){
            double lastValue = changes.get(changes.size() - 1);
            progressBar.setValue((lastValue / ITERATIONS));
        }
    };

    BackgroundTaskHandler taskHandler = backgroundWorker.handle(task);
    taskHandler.execute();
}
1 一些比较耗时的任务

BackgroundTask.progress() 方法在 UI 线程中被执行,在这个方法里给 ProgressBar 组件设置当前的进度值。组件值应该是从 0.01.0double 类型的数值。

可以使用 ValueChangeListener 跟踪 ProgressBar 值的变化。可以使用 isUserOriginated() 方法跟踪 ValueChangeEvent 的来源。

如果正在运行的处理无法发送有关进度的信息,则可以显示表示不确定状态的指示符。将 indeterminate 设置为 true 以显示不确定状态。默认为 false。例如:

<progressBar id="progressBar" width="100%" indeterminate="true"/>

默认情况下,不定进度条显示为水平状态条。要改为显示螺旋状的进度条,可以设置属性 stylename="indeterminate-circle"

要使进度条指示器显示为在进度条上移动的点(而不是增长条),请使用 point 预定义样式:

progressBar.setStyleName(HaloTheme.PROGRESSBAR_POINT);


3.5.2.1.35. 单选按钮组

这是一个允许用户使用单选按钮从选项列表中选择单个值的组件。

gui RadioButtonGroup

该组件对应的 XML 名称: radioButtonGroup

可以使用 setOptions()setOptionsList()setOptionsMap()setOptionsEnum() 方法,或使用 optionsContainer 属性指定组件选项列表。

  • 使用 RadioButtonGroup 的最简单的场景是为实体属性选择枚举值。例如,Role 实体具有 RoleType 类型的 type 属性,它是一个枚举。那么可以使用 RadioButtonGroup 显示这个属性, 如下所示:

    <radioButtonGroup optionsEnum="com.haulmont.cuba.security.entity.RoleType"
                      property="type"/>

    setOptionsEnum() 将一个枚举类作为参数。选项列表将包含枚举值的本地化名称,组件的值将是一个枚举值。

    radioButtonGroup.setOptionsEnum(RoleType.class);

    使用 setOptions() 方法可以得到相同的结果,该方法允许使用任何类型的选项:

    radioButtonGroup.setOptions(new EnumOptions<>(RoleType.class));
  • setOptionsList() 能够以编程方式指定组件选项列表。为此在 XML 描述中声明一个组件:

    <radioButtonGroup id="radioButtonGroup"/>

    然后将组件注入控制器并为其指定选项列表:

    @Inject
    private RadioButtonGroup<Integer> radioButtonGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        List<Integer> list = new ArrayList<>();
        list.add(2);
        list.add(4);
        list.add(5);
        list.add(7);
        radioButtonGroup.setOptionsList(list);
    }

    该组件将如下所示:

    gui RadioButtonGroup 2

    根据所选的选项,组件的 getValue() 方法将返回 Integer 类型的值:2 、4 、5 、7。

  • setOptionsMap() 能够分别指定选项的显示名称和选项值。例如,我们可以为控制器中注入的 radioButtonGroup 组件设置以下选项 map:

    @Inject
    private RadioButtonGroup<Integer> radioButtonGroup;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        Map<String, Integer> map = new LinkedHashMap<>();
        map.put("two", 2);
        map.put("four", 4);
        map.put("five", 5);
        map.put("seven", 7);
        radioButtonGroup.setOptionsMap(map);
    }

    该组件将如下所示:

    gui RadioButtonGroup 3

    根据所选的选项,组件的 getValue() 方法将返回 Integer 类型的值:2 、4 、5 、 7,而不是界面上显示的字符串。

  • 该组件可以从数据容器中获取选项列表。要做到这点,需要使用 optionsContainer 属性。例如:

    <data>
        <collection id="employeesCt" class="com.company.demo.entity.Employee" view="_minimal">
            <loader>
                <query><![CDATA[select e from demo_Employee e]]></query>
            </loader>
        </collection>
    </data>
    <layout>
        <radioButtonGroup optionsContainer="employeesCt"/>
    </layout>

    在这种情况下,radioButtonGroup 组件将显示位于 employeesCt 数据容器中的 Employee 实体的实例名,其 getValue() 方法将返回所选实体实例。

    gui RadioButtonGroup 4

    使用captionProperty属性,可以指定一个实体属性作为选项的显示名称,而不是使用实例名称作为选项的显示名称。

    可以使用 RadioButtonGroup 接口的 setOptions() 方法以编程方式定义选项容器:

    @Inject
    private RadioButtonGroup<Employee> radioButtonGroup;
    @Inject
    private CollectionContainer<Employee> employeesCt;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        radioButtonGroup.setOptions(new ContainerOptions<>(employeesCt));
    }

可以使用 OptionDescriptionProvider 为选项生成描述(提示)。通过 setOptionDescriptionProvider() 方法或 @Install 注解使用:

@Inject
private RadioButtonGroup<Product> radioButtonGroup;

@Subscribe
public void onInit(InitEvent event) {
    radioButtonGroup.setOptionDescriptionProvider(product -> "Price: " + product.getPrice());
}
@Install(to = "radioButtonGroup", subject = "optionDescriptionProvider")
private String radioButtonGroupOptionDescriptionProvider(Experience experience) {
    switch (experience) {
        case HIGH:
            return "Senior";
        case COMMON:
            return "Middle";
        default:
            return "Junior";
    }
}

orientation 属性定义了分组元素的排列方向。默认情况下,元素垂直排列。设置值为 horizontal 将水平排列。



3.5.2.1.36. 关联实体组件

RelatedEntities 组件是一个弹窗按钮,其中包含了与表格中显示的实体相关的实体类的下拉列表。当用户选择了所需的实体类,就会打开一个新的浏览界面,其中包含与初始表格中选择的实体实例关联的实体实例。

gui relatedEntities

该组件的 XML 名称: relatedEntities

选中的关联实体受用户的实体、实体属性和界面权限机制的控制。

默认情况下,下拉列表中所选类的浏览界面使用约定的格式( {entity_name}.browse{entity_name}.lookup)定义。当然,也可以在组件中显式自定义浏览界面。

在新的浏览界面中会动态创建过滤器,这个过滤器只选择与选中实体相关的记录。

<table id="invoiceTable"
       multiselect="true"
       width="100%">
    <actions>
        <action id="create"/>
        <action id="edit"/>
        <action id="remove"/>
    </actions>

    <buttonsPanel id="buttonsPanel">
        <button id="createBtn"
                action="invoiceTable.create"/>
        <button id="editBtn"
                action="invoiceTable.edit"/>
        <button id="removeBtn"
                action="invoiceTable.remove"/>

        <relatedEntities for="invoiceTable"
                         openType="NEW_TAB">
            <property name="invoiceItems"
                      screen="sales_InvoiceItem.lookup"
                      filterCaption="msg://invoiceItems"/>
        </relatedEntities>
    </buttonsPanel>
    . . .
</table>

for 属性是必须的。使用这个属性指定要查看其关联实体的表格的标识符。

openType="NEW_TAB" 属性将查找窗口的打开模式设置为新标签页。默认情况下,实体浏览界面在当前标签页中打开。

property 元素允许显式定义显示在下拉列表中的相关实体。

property 元素的属性:

  • name – 当前实体的属性名称,这个属性是一个引用类型的属性,引用了关联实体。

  • screen – 要使用的浏览界面的标识符。

  • filterCaption – 动态生成的过滤器的标题。

可以使用 exclude 属性从下拉列表中排除一些关联实体。该属性的值是匹配要排除的引用属性的正则表达。

gui relatedEntitiesTable

平台提供了一个不使用 RelatedEntities 组件就可以打开关联实体界面的 API:RelatedEntitiesAPI 接口及其实现 RelatedEntitiesBean 。逻辑是在 openRelatedScreen() 方法定义的,该方法可接受三个参数:关系一侧的实体集合、该集合中单个实体的 MetaClass 、要查找其关联实体的字段。

<button id="related"
        caption="Related customer"/>
@UiController("sales_Order.browse")
@UiDescriptor("order-browse.xml")
@LookupComponent("ordersTable")
@LoadDataBeforeShow
public class OrderBrowse extends StandardLookup<Order> {

    @Inject
    private RelatedEntitiesAPI relatedEntitiesAPI;
    @Inject
    private GroupTable<Order> ordersTable;

    @Subscribe("related")
    protected void onRelatedClick(Button.ClickEvent event) {
        relatedEntitiesAPI.openRelatedScreen(ordersTable.getSelected(), Order.class, "customer");
    }

}

默认情况下,将打开标准实体浏览界面。可以使用 RelatedScreenDescriptor 参数使该方法打开另一个界面或使用其它参数打开界面。RelatedScreenDescriptor 是一个 POJO,可以存储界面标识符(String)、打开类型(WindowManager.OpenType)、过滤器标题(String)和界面参数(Map <String,Object>)。

relatedEntitiesAPI.openRelatedScreen(ordersTable.getSelected(),
        Order.class, "customer",
        new RelatedEntitiesAPI.RelatedScreenDescriptor("sales_Customer.lookup", WindowManager.OpenType.DIALOG));


3.5.2.1.37. 可调大小文本区

ResizableTextArea 是一个多行文本编辑器空间,具有能调整该组件大小的能力。

该组件的 XML 名称: resizableTextArea

ResizableTextArea 基本复制了文本区组件的功能,但是有下面特殊的属性:

  • resizableDirection – 定义用户能改变该组件大小的方式,当该组件的大小用百分比定义时除外。

    <resizableTextArea id="textArea" resizableDirection="BOTH"/>
    gui textField resizable

    有四种调整大小的模式:

    • BOTH – 组件可以在两个方向调整大小。BOTH 是默认值。如果组件大小设置的是百分比,则组件大小不可调整。

    • NONE – 组件大小不可调整。

    • VERTICAL – 组件只能在竖直方向调整大小。如果组件大小设置的是百分比,则组件大小竖直方向不可调整。

    • HORIZONTAL – 组件只能在水平方向调整大小。如果组件大小设置的是百分比,则组件大小水平方向不可调整。

    区域尺寸更改的事件可以通过 ResizeListener 接口跟踪。示例:

    resizableTextArea.addResizeListener(resizeEvent ->
            notifications.create()
                    .withCaption("Resized")
                    .show());


3.5.2.1.38. 富文本区

这是一个用于显示和输入带有格式的文本的文本区域。

该组件的 XML 名称: richTextArea

基本上,RichTextAreaTextField 的功能一致,除了不能为它设置 datatype。因此,RichTextArea 仅适用于文本和 String 类型的实体属性。

gui RichTextAreaInfo

RichTextArea 也可以用来输入输出 HTML 字符串。如果 htmlSanitizerEnabled 属性设置为 true,则 RichTextArea 的值会被清理。

protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
            "color=\"moccasin\">my</font> " +
            "<font size=\"7\">big</font> <sup>sphinx</sup> " +
            "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
            "red;\">quartz</span><svg/onload=alert(\"XSS\")>";

@Inject
private RichTextArea richTextArea;

@Subscribe
public void onInit(InitEvent event) {
    richTextAreasetHtmlSanitizerEnabled(true);
    richTextArea.setValue(UNSAFE_HTML);
}

htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。



3.5.2.1.39. 搜索选择器控件

SearchPickerField 组件用于根据输入的字符串搜索实体实例。用户可输入几个字符,然后按 Enter 键。如果找到了多个匹配项,则所有匹配项都将显示在下拉列表中。如果只有一个实例与搜索关键字匹配,则这个实例直接成为组件值。 还可以通过单击 SearchPickerField 组件右侧的按钮来执行操作。

gui searchPickerFieldOverlap

SearchPickerField 只能在使用遗留 API开发的界面中。当前 API 的类似功能通过SuggestionPickerField组件提供。

该组件的 XML 名称: searchPickerField

  • 要使用 SearchPickerField 组件,需要创建 collectionDatasource 并指定一个包含相应搜索条件的查询。条件必须包含名为 custom$searchString 的参数。此参数将被填充为用户输入的搜索关键字。应将组件的 optionsDatasource 属性设置为带有搜索条件的数据源。例如:

    <dsContext>
        <datasource id="carDs" class="com.company.sample.entity.Car" view="_local"/>
        <collectionDatasource id="colorsDs" class="com.company.sample.entity.Color" view="_local">
            <query>
                select c from sample_Color c
                where c.name like :(?i)custom$searchString
            </query>
        </collectionDatasource>
    </dsContext>
    <layout>
        <searchPickerField datasource="carDs" property="color" optionsDatasource="colorsDs"/>
    </layout>

    这时,组件会根据 Colour 实体的 name 属性中是否包含搜索关键字来查找 Colour 实体的实例。(?i) 前缀用于不区分大小写查找(请参阅不区分大小写查找)。选择的值将设置到 carDs 数据源中的 Car 实体的 colour 属性。

    escapeValueForLike 属性设置为 true 时允许使用 like 子句搜索特殊符号: %\_ 。要使用 escapeValueForLike = true,修改集合数据源的查询,为其添加转义符:

    select c from ref_Colour c
    where c.name like :(?i)custom$searchString or c.description like :(?i)custom$searchString escape '\'

    escapeValueForLike 属性适用于除 HSQLDB 之外的所有数据库。

  • 使用 minSearchStringLength 属性,设置要执行搜索应输入的最小字符数。

  • 在界面控制器中,可以使用 SearchField.SearchNotifications 类给用户显示一些搜索提示,需要实现这个类的两个方法:

    • 如果输入的字符数小于 minSearchStringLength 属性的值时调用的方法。

    • 如果用户输入的字符没有返回任何结果时调用的方法。

    下面是这两个方法的实现示例:

    @Inject
    private Notifications notifications;
    @Inject
    private SearchPickerField colorField;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        colorField.setSearchNotifications(new SearchField.SearchNotifications() {
            @Override
            public void notFoundSuggestions(String filterString) {
                notifications.create()
                        .withCaption("No colors found for search string: " + filterString)
                        .withType(Notifications.NotificationType.TRAY)
                        .show();
            }
    
            @Override
            public void needMinSearchStringLength(String filterString, int minSearchStringLength) {
                notifications.create()
                        .withCaption("Minimum length of search string is " + minSearchStringLength)
                        .withType(Notifications.NotificationType.TRAY)
                        .show();
            }
        });
    }
  • SearchPickerField 实现了 LookupFieldPickerField 接口。除了在 XML 中定义组件时添加的默认操作列表(对于 SearchPickerField,默认操作是 lookupopen 操作)不同,其它功能与 LookupFieldPickerField 接口定义的功能相同。



3.5.2.1.40. 侧边菜单

SideMenu 组件提供了定制主窗口布局、管理菜单项、添加图标和标记(badges)以及应用自定义样式的方法。

它也可以像其它可视化组件一样用在任何界面中。

gui sidemenu

该组件的 XML 名称: sideMenu

在界面 XML 描述中定义该组件的示例:

<sideMenu id="sideMenu"
          width="100%"
          selectOnClick="true"/>

CUBA Studio 为主窗口提供了界面模板,其中包含 sideMenu 组件和侧边面板中的预定义样式:

<layout>
    <hbox id="horizontalWrap"
          expand="workArea"
          height="100%"
          stylename="c-sidemenu-layout"
          width="100%">
        <vbox id="sideMenuPanel"
              expand="sideMenu"
              height="100%"
              margin="false,false,true,false"
              spacing="true"
              stylename="c-sidemenu-panel"
              width="250px">
            <hbox id="appTitleBox"
                  spacing="true"
                  stylename="c-sidemenu-title"
                  width="100%">
                <label id="appTitleLabel"
                       align="MIDDLE_CENTER"
                       value="mainMsg://application.logoLabel"/>
            </hbox>
            <embedded id="logoImage"
                      align="MIDDLE_CENTER"
                      stylename="c-app-icon"
                      type="IMAGE"/>
            <hbox id="userInfoBox"
                  align="MIDDLE_CENTER"
                  expand="userIndicator"
                  margin="true"
                  spacing="true"
                  width="100%">
                <userIndicator id="userIndicator"
                               align="MIDDLE_CENTER"/>
                <newWindowButton id="newWindowButton"
                                 description="mainMsg://newWindowBtnDescription"
                                 icon="app/images/new-window.png"/>
                <logoutButton id="logoutButton"
                              description="mainMsg://logoutBtnDescription"
                              icon="app/images/exit.png"/>
            </hbox>
            <sideMenu id="sideMenu"
                      width="100%"/>
            <ftsField id="ftsField"
                      width="100%"/>
        </vbox>
        <workArea id="workArea"
                  height="100%">
            <initialLayout margin="true"
                           spacing="true">
                <label id="welcomeLabel"
                       align="MIDDLE_CENTER"
                       stylename="c-welcome-text"
                       value="mainMsg://application.welcomeText"/>
            </initialLayout>
        </workArea>
    </hbox>
</layout>

sideMenu 属性:

  • selectOnClick 属性设置为 true 时,会在鼠标单击时突出显示选中的菜单项。默认值为 false

gui sidemenu 2

SideMenu 接口的方法:

  • createMenuItem - 创建一个新菜单项,但不将此项添加到菜单。对于整个菜单,Id 必须是唯一的。

  • addMenuItem - 添加菜单项到菜单。

  • removeMenuItem - 从菜单项列表中移除菜单项。

  • getMenuItem - 根据 id 从菜单树中获取菜单项。

  • hasMenuItems - 如果菜单包含菜单项,则返回 true

SideMenu 组件用于显示菜单项。MenuItem API 允许在界面控制器中创建菜单项。以下方法可用于根据应用程序业务逻辑动态更新菜单项。以编程方式添加菜单项的示例:

SideMenu.MenuItem item = sideMenu.createMenuItem("special");
item.setCaption("Daily offer");
item.setBadgeText("New");
item.setIconFromSet(CubaIcon.GIFT);
sideMenu.addMenuItem(item,0);
gui sidemenu 3

MenuItem 接口的方法:

  • setCaption - 设置菜单项名称。

  • setCaptionAsHtml - 启用或禁用 HTML 模式的菜单名称。

  • setBadgeText - 设置菜单项的标记文本。标记是显示在菜单项右侧的小部件,例如:

    int count = 5;
    SideMenu.MenuItem item = sideMenu.createMenuItem("count");
    item.setCaption("Messages");
    item.setBadgeText(count + " new");
    item.setIconFromSet(CubaIcon.ENVELOPE);
    sideMenu.addMenuItem(item,0);
    gui sidemenu 4

    标记文本可以在 Timer 组件的配合下动态更新:

    public void updateCounters(Timer source) {
        sideMenu.getMenuItemNN("sales")
                .setBadgeText(String.valueOf(LocalTime.MIDNIGHT.minusSeconds(timerCounter-source.getDelay())));
        timerCounter++;
    }
    gui sidemenu 5
  • setIcon - 设置菜单项图标。

  • setCommand - 设置菜单项命令,或点击菜单项时要执行的操作。

  • addChildItem/removeChildItem - 添加或移除子菜单的菜单项。

  • setExpanded - 默认展开或折叠包含子菜单的菜单项。

  • setStyleName - 给组件设置一个或多个自定义样式名,并且会覆盖所有已定义的用户样式。多个样式通过空格分隔的样式名列表指定。样式名必须是有效的 CSS class 名称。

    标准的 sideMenu 模板包含一些预定义样式: c-sidemenu-layoutc-sidemenu-panelc-sidemenu-title。默认的 c-sidemenu 样式在 HaloHover 这两个主题及它们的扩展主题中支持。

  • setTestId - 调用用于 UI 测试的 cuba-id 值。

PopupButton 的展示可以使用带 $cuba-sidemenu-*$cuba-responsive-sidemenu-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.41. 滑动条

Slider 是一个竖直或水平的条。通过鼠标拖拽条上的小控件可以为其设定某个范围内的一个数值。拖拽小控件同时也会显示值的大小。

gui slider

该组件的 XML 名称: slider

滑动条的默认数据类型是 double。可以用组件的 datatype 属性设置其数据类型,比如 intlongdoubledecimal,支持在 XML 描述中或者使用 API 设置。

dataContainerproperty 属性可以创建数据关联的滑动条。此时,数据类型由 property 指定的实体属性决定。

下面例子中,滑动条的数据类型设置为 Order 实体的 amount 数据类型。

<data>
    <instance id="orderDc" class="com.company.sales.entity.Order" view="_local">
        <loader/>
    </instance>
</data>
<layout>
    <slider dataContainer="orderDc" property="amount"/>
</layout>

滑动条组件有下列特殊属性:

  • max - 滑动条数值范围的最大值,默认 100。

  • min - 滑动条数值范围的最小值,默认 0。

  • resolution - 小数点后数字的位数。默认值 0。

  • orientation - 滑动条竖直还是水平。默认值 horizontal - 水平。

  • updateValueOnClick - 如果设置为 true,可以通过点击滑动条设置值。默认值 false

这个例子中滑动条竖直放置,使用整数类型,数值范围从 2 到 20。

<slider id="slider"
        orientation="vertical"
        datatype="int"
        min="2"
        max="20"/>

值可以通过 getValue() 方法获取,setValue() 方法设置。

如需跟踪滑动条值的变化,或者其他实现了 Field 接口的组件值的变化,使用 ValueChangeListener 监听器并订阅对应事件。 下面例子中,如果 Slider 的值发生变化,则将其值写入 TextField

@Inject
private TextField<Integer> textField;

@Subscribe("slider")
private void onSliderValueChange(HasValue.ValueChangeEvent<Integer> event) {
  textField.setValue(event.getValue());
}


3.5.2.1.42. 源码编辑器

SourceCodeEditor 用于显示和输入源码。它是一个多行文本区域,具有代码高亮显示和可选的打印边距以及带行号的侧栏。

该组件的 XML 元素: sourceCodeEditor

基本上,SourceCodeEditor 主要复制 TextField组件的功能,并具有以下特性:

  • 如果 handleTabKeytrueTab 按键被处理为缩进行,当为 false 时,它用于移动光标或焦点到下一个制表位。应在初始化界面时设置此属性,此属性不支持运行时更改。

可以在运行时更改所有以下属性:

  • highlightActiveLine 用于高亮光标所在行。

  • mode 提供语法高亮支持的语言列表。此列表在 SourceCodeEditor 接口的 Mode 枚举中定义,包括以下语言:Java、HTML、XML、Groovy、SQL、JavaScript、Properties 和不进行高亮显示的 Text。

  • printMargin 设置是否显示打印边距。

  • showGutter 用于设置显示行号的侧栏是否隐藏。

下面是在运行时调整 SourceCodeEditor 组件的示例。

XML-descriptor:

<hbox spacing="true">
    <checkBox id="highlightActiveLineCheck" align="BOTTOM_LEFT" caption="Highlight Active Line"/>
    <checkBox id="printMarginCheck" align="BOTTOM_LEFT" caption="Print Margin"/>
    <checkBox id="showGutterCheck" align="BOTTOM_LEFT" caption="Show Gutter"/>
    <lookupField id="modeField" align="BOTTOM_LEFT" caption="Mode" required="true"/>
</hbox>
<sourceCodeEditor id="simpleCodeEditor" width="100%"/>

控制器:

@Inject
private CheckBox highlightActiveLineCheck;
@Inject
private LookupField<HighlightMode> modeField;
@Inject
private CheckBox printMarginCheck;
@Inject
private CheckBox showGutterCheck;
@Inject
private SourceCodeEditor simpleCodeEditor;

@Subscribe
protected void onInit(InitEvent event) {
    highlightActiveLineCheck.setValue(simpleCodeEditor.isHighlightActiveLine());
    highlightActiveLineCheck.addValueChangeListener(e ->
            simpleCodeEditor.setHighlightActiveLine(Boolean.TRUE.equals(e.getValue())));

    printMarginCheck.setValue(simpleCodeEditor.isShowPrintMargin());
    printMarginCheck.addValueChangeListener(e ->
            simpleCodeEditor.setShowPrintMargin(Boolean.TRUE.equals(e.getValue())));

    showGutterCheck.setValue(simpleCodeEditor.isShowGutter());
    showGutterCheck.addValueChangeListener(e ->
            simpleCodeEditor.setShowGutter(Boolean.TRUE.equals(e.getValue())));

    Map<String, HighlightMode> modes = new HashMap<>();
    for (HighlightMode mode : SourceCodeEditor.Mode.values()) {
        modes.put(mode.toString(), mode);
    }

    modeField.setOptionsMap(modes);
    modeField.setValue(HighlightMode.TEXT);
    modeField.addValueChangeListener(e ->
            simpleCodeEditor.setMode(e.getValue()));
}

结果是:

gui SourceCodeEditor 1

SourceCodeEditor 也支持 Suggester 接口提供的代码自动完成功能。要激活自动完成功能,应调用 setSuggester 方法,例如:

@Inject
protected DataGrid<User> usersGrid;
@Inject
private SourceCodeEditor suggesterCodeEditor;
@Inject
private CollectionContainer<User> usersDc;
@Inject
private CollectionLoader<User> usersDl;

@Subscribe
protected void onInit(InitEvent event) {
    suggesterCodeEditor.setSuggester((source, text, cursorPosition) -> {
        List<Suggestion> suggestions = new ArrayList<>();
        usersDl.load();
        for (User user : usersDc.getItems()) {
            suggestions.add(new Suggestion(source, user.getLogin(), user.getName(), null, -1, -1));
        }
        return suggestions;
    });
}

结果:

gui SourceCodeEditor 2

Ctrl+Space 或者输入 . (点)时会调用智能建议。如需禁用该自动完成功能,设置 suggestOnDot 属性为 false。其默认值为 true

<sourceCodeEditor id="simpleCodeEditor" width="100%" suggestOnDot="false"/>

SourceCodeEditor 的展示可以使用带 $cuba-sourcecodeeditor-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.43. 建议字段

SuggestionField 组件用于根据用户输入的字符串搜索一些值。它与 SuggestionPickerField 的不同之处在于它可以使用任何类型的选项:例如,实体、字符串或枚举值,并且没有操作按钮。选项列表是根据应用程序开发人员定义的逻辑在后端加载。

gui suggestionField 1

该组件的 XML 名称: suggestionField

suggestionField 的属性:

  • asyncSearchDelayMs - 设置最后一次按键操作和执行异步搜索之前需要的延迟。

  • minSearchStringLength - 设置执行建议搜索需要的最小字符串长度。

  • popupWidth - 设置建议选项弹出框的宽度。

    可选项:

    • auto - 弹出框的宽度等于建议选项的最大长度,即自适应,

    • parent - 弹出框的宽度等于主组件的宽度,

    • 绝对(比如 "170px")或相对(比如 "50%")值。

  • suggestionsLimit - 设置可显示的建议选项的最大数量。

suggestionField 的元素:

  • query - 用于定义获取建议值的可选元素。query 元素有以下属性:

    • entityClass(必须) - 实体类的完全限定名。

    • view - 可选属性,指定用于加载查询实体的视图

    • escapeValueForLike - 允许搜索关键字中包含特殊符号:%\ 等。默认值为 false

    • searchStringFormat - 搜索字符串格式,一个 Groovy 字符串,可以使用任何有效的 Groovy 字符串表达式。

    <suggestionField id="suggestionField"
                     captionProperty="login">
        <query entityClass="com.haulmont.cuba.security.entity.User"
               escapeValueForLike="true"
               view="user.edit"
               searchStringFormat="%$searchString%">
            select e from sec$User e where e.login like :searchString escape '\'
        </query>
    </suggestionField>

    如果未定义查询,则必须使用 SearchExecutor 提供选项列表,以编程方式分配(见下文)。

大部分情况下,给组件设置 SearchExecutor 就足够了。SearchExecutor 是一个包含单个方法的功能接口:List<E> search(String searchString,Map <String,Object> searchParams)

suggestionField.setSearchExecutor((searchString, searchParams) -> {
    return Arrays.asList(entity1, entity2, ...);
});

SearchExecutor 可以返回任何类型的选项,例如实体、字符串或枚举值。

  • 实体:

customersDs.refresh();
List<Customer> customers = new ArrayList<>(customersDs.getItems());
suggestionField.setSearchExecutor((searchString, searchParams) ->
        customers.stream()
                .filter(customer -> StringUtils.containsIgnoreCase(customer.getName(), searchString))
                .collect(Collectors.toList()));
  • 字符串:

List<String> strings = Arrays.asList("Red", "Green", "Blue", "Cyan", "Magenta", "Yellow");
stringSuggestionField.setSearchExecutor((searchString, searchParams) ->
        strings.stream()
                .filter(str -> StringUtils.containsIgnoreCase(str, searchString))
                .collect(Collectors.toList()));
  • 枚举:

List<SendingStatus> enums = Arrays.asList(SendingStatus.values());
enumSuggestionField.setSearchExecutor((searchString, searchParams) ->
        enums.stream()
                .map(sendingStatus -> messages.getMessage(sendingStatus))
                .filter(str -> StringUtils.containsIgnoreCase(str, searchString))
                .collect(Collectors.toList()));
  • OptionWrapper 用于需要将任何类型的值与其字串表示分离的情况下:

List<OptionWrapper> wrappers = Arrays.asList(
        new OptionWrapper("One", 1),
        new OptionWrapper("Two", 2),
        new OptionWrapper("Three", 3);
suggestionField.setSearchExecutor((searchString, searchParams) ->
        wrappers.stream()
                .filter(optionWrapper -> StringUtils.containsIgnoreCase(optionWrapper.getCaption(), searchString))
                .collect(Collectors.toList()));

search() 方法在后台线程中执行,因此它无法访问可视化组件或可视化组件使用的数据源。可直接调用 DataManager 或中间件服务;或处理并返回预先加载到界面的数据。

searchString 参数可用于使用用户输入的字符串过滤候选值。也可以使用 escapeForLike() 方法来搜索包含特殊符号的值:

suggestionField.setSearchExecutor((searchString, searchParams) -> {
    searchString = QueryUtils.escapeForLike(searchString);
    return dataManager.loadList(LoadContext.create(Customer.class).setQuery(
            LoadContext.createQuery("select c from sample_Customer c where c.name like :name order by c.name escape '\\'")
                .setParameter("name", "%" + searchString + "%")));
});
  • OptionsStyleProvider 允许为 suggestionField 的建议选项中的每一项指定单独的样式名。

    suggestionField.setOptionsStyleProvider((field, item) -> {
        User user = (User) item;
        switch (user.getGroup().getName()) {
            case "Company":
                return "company";
            case "Premium":
                return "premium";
            default:
                return "company";
        }
    });

SuggestionField 的展示可以使用带 $cuba-suggestionfield-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.1.44. 建议选择器字段

SuggestionPickerField 组件用于根据用户输入的字符串搜索实体实例。它与 SearchPickerField 的不同之处在于它在用户输入每个字符时自动刷新选项列表,不需要按 Enter 键。 选项列表是根据应用程序开发人员定义的逻辑在后端加载。

SuggestionPickerField 也是一种PickerField,在右侧可以有操作按钮。

gui suggestionPickerField 1

该组件的 XML 名称: suggestionPickerField

SuggestionPickerField 用于选择并引用实体属性,因此通常设置它的 dataContainerproperty 属性:

<data>
    <instance id="orderDc"
              class="com.company.sales.entity.Order"
              view="order-with-customer">
        <loader id="orderDl"/>
    </instance>
</data>
<layout>
<suggestionPickerField id="suggestionPickerField"
                       captionProperty="name"
                       dataContainer="orderDc"
                       property="customer"/>
</layout>

suggestionPickerField 的属性:

  • asyncSearchDelayMs - 设置最后一次按键到执行异步搜索之前需要延迟的时间。

  • metaClass - 设置链接到组件的 MetaClass ,如果在不绑定数据组件的情况下使用该组件,比如,没有设置 dataContainerproperty

  • minSearchStringLength - 设置执行建议搜索需要的最小字符串长度。

  • popupWidth - 设置建议选项弹出框的宽度。

    可选项:

    • auto - 弹出框的宽度等于建议选项的最大长度,即自动宽度,

    • parent - 弹出框的宽度等于主组件的宽度,

    • 绝对(比如 "170px")或相对(比如 "50%")值。

  • suggestionsLimit - 设置显示的建议选项的最大数量。

suggestionPickerField 和其对应的弹出框的外观可以使用stylename属性进行定制。弹出框应该与主组件拥有相同的样式名:比如,如果主组件有自定义的样式名 "my-awesome-stylename",对应的弹出框应该有样式名 "c-suggestionfield-popup my-awesome-stylename"

suggestionPickerField 的元素:

  • actions - 可选元素,用于描述与组件相关的各种操作,除了自定义的操作,suggestionPickerField 支持下列标准 PickerField 操作picker_lookuppicker_clearpicker_open

SuggestionPickerField 的基本用法

一般情况下,给组件设置 SearchExecutor 就可以了。SearchExecutor 是一个包含单个方法的功能接口:List<E extends Entity> search(String searchString, Map<String, Object> searchParams):

suggestionPickerField.setSearchExecutor((searchString, searchParams) -> {
    return Arrays.asList(entity1, entity2, ...);
});

search() 方法在后台线程中执行,因此它无法访问可视化组件或数据组件。可直接调用 DataManager 或中间件服务;或处理并返回预先加载到界面的数据。

searchString 参数可用于使用用户输入的字符串过滤候选值。也可以使用 escapeForLike() 方法来搜索包含特殊符号的值:

suggestionPickerField.setSearchExecutor((searchString, searchParams) -> {
    searchString = QueryUtils.escapeForLike(searchString);
    return dataManager.load(Customer.class)
            .query("e.name like ?1 order by e.name escape '\\'", "%" + searchString + "%")
            .list();
});
ParametrizedSearchExecutor 的用法

在前面的例子中,searchParams 是一个空的字典。要定义参数,应该使用 ParametrizedSearchExecutor

suggestionPickerField.setSearchExecutor(new SuggestionField.ParametrizedSearchExecutor<Customer>(){
    @Override
    public Map<String, Object> getParams() {
        return ParamsMap.of(...);
    }

    @Override
    public List<Customer> search(String searchString, Map<String, Object> searchParams) {
        return executeSearch(searchString, searchParams);
    }
});
EnterActionHandler 和 ArrowDownActionHandler 的用法

使用该组件的另一种方法是设置 EnterActionHandlerArrowDownActionHandler。在建议弹出框隐藏的情况下,户按下 EnterArrow Down 键时会触发这些监听器。它们也是具有单一方法和单个参数 - currentSearchString 的功能接口。可以设置自己的操作处理器并使用 SuggestionField.showSuggestions() 方法,该方法接收一个实体列表参数用于显示建议选项。

suggestionPickerField.setArrowDownActionHandler(currentSearchString -> {
    List<Customer> suggestions = findSuggestions();
    suggestionPickerField.showSuggestions(suggestions);
});

suggestionPickerField.setEnterActionHandler(currentSearchString -> {
    List<Customer> suggestions = getDefaultSuggestions();
    suggestionPickerField.showSuggestions(suggestions);
});


3.5.2.1.45. 表格

本章内容:

Table 组件以表格的方式展示信息,对数据进行排序 、管理表格列和表头,并对选中的行执行操作。

gui table

组件对应的 XML 名称: table

在界面 XML 描述中定义组件的示例:

<data readOnly="true">
    <collection id="ordersDc" class="com.company.sales.entity.Order" view="order-with-customer">
        <loader id="ordersDl">
            <query>
                <![CDATA[select e from sales_Order e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
<table id="ordersTable" dataContainer="ordersDc" width="100%">
    <columns>
        <column id="date"/>
        <column id="amount"/>
        <column id="customer"/>
    </columns>
    <rowsCount/>
</table>
</layout>

在上面的示例中,data 元素定义集合数据容器,它使用 JPQL 查询 Order 实体。table 元素定义数据容器,而 columns 元素定义哪些实体属性用作表格列。

如需在界面控制器以编程的方式定义数据源,需要在 XML 中用 metaClass 属性而替换 dataContainer

table 元素

  • rows – 如果使用 datasource 属性来做数据绑定,则必须设置此元素。

    每行可以在左侧的附加列中有一个图标。在界面控制器中创建 ListComponent.IconProvider 接口的实现,并将其设置给表格:

    @Inject
    private Table<Customer> table;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        table.setIconProvider(new ListComponent.IconProvider<Customer>() {
            @Nullable
            @Override
            public String getItemIcon(Customer entity) {
                CustomerGrade grade = entity.getGrade();
                switch (grade) {
                    case PREMIUM: return "icons/premium_grade.png";
                    case HIGH: return "icons/high_grade.png";
                    case MEDIUM: return "icons/medium_grade.png";
                    default: return null;
                }
            }
        });
    }
  • columns – 定义表格列。如未指定,会根据 dataContainer 定义的 view 的属性自动生成。 columns 元素有如下属性:

    • includeAllincludeAll – 加载 dataContainer 中定义的 view 的所有属性。

      在下面的例子中,我们显示了 customersDc 中使用视图的所有属性。如果视图包含系统属性,也同样会显示。

      <table id="table"
             width="100%"
             height="100%"
             dataContainer="customersDc">
          <columns includeAll="true"/>
      </table>

      如果实体的视图包含引用属性,该属性会按照其实例名称进行展示。如果需要展示一个特别的属性,则需要在视图和 column 元素中定义:

      <columns includeAll="true">
          <column id="address.street"/>
      </columns>

      如果未指定视图,includeAll 会加载给定实体及其祖先的所有属性。

    • exclude – 英文逗号分隔的属性列表,这些属性不会被加载到表格。

      在下面的例子中,我们会显示除了 nameorder 之外的所有属性:

      <table id="table"
             width="100%"
             height="100%"
             dataContainer="customersDc">
          <columns includeAll="true"
                   exclude="name, order"/>
      </table>

    每个列都在嵌套的 column 元素中描述,column 元素具有以下属性:

    • id − 必须属性,包含列中要显示的实体属性的名称。可以是来自数据容器的实体的属性,也可以是关联实体的属性(使用 "." 来指定属性在对象关系图中的路径)。例如:

      <columns>
          <column id="date"/>
          <column id="customer"/>
          <column id="customer.name"/>
          <column id="customer.address.country"/>
      </columns>
    • collapsed − 可选属性;当设置为 true 时默认隐藏列。当表格的 columnControlVisible 属性不是 false 时,用户可以通过表格右上角的菜单中的按钮 gui_table_columnControl 控制列的可见性。默认情况下,collapsedfalse

    • expandRatio − 可选属性;指定每列的延展比例。比例值需大于或等于 0。如果至少有一列设置了该值,则会忽略其它隐式的值,只考虑显式设置的值。如果同时设置了widthexpandRatio,应用程序会出错。

    • width − 可选属性,控制默认列宽。只能是以像素为单位的数值。

    • align − 可选属性,用于设置单元格的文本对齐方式。可选值:LEFTRIGHTCENTER。默认为 LEFT

    • editable − 可选属性,允许编辑表中的相应列。为了使列可编辑,整个表的 editable 属性也应设置为 true。不支持在运行时更改此属性。

    • sortable − 可选属性,用于禁用列的排序。整个表的 sortable 属性为 true 此属性有效(默认为 true)。

    • sort − 可选属性,用于设置表格使用指定的列进行排序的初始化顺序,可选值:

      • ASCENDING – 顺序。

      • DESCENDING – 倒序。

      <columns>
          <column property="name" sort="DESCENDING"/>
      </columns>

    注意:如果 settingsEnabledtrue,表格可以按照用户的设置排序。

    每次只能按照一列排序,所以下面的示例会抛出异常:

    <columns>
        <column property="name" sort="DESCENDING"/>
        <column property="parent" sort="ASCENDING"/>
    </columns>

    另外,如果对某列同时设置了 sortsortable="false" 属性,会导致应用程序出错。

    • maxTextLength – 可选属性,允许限制单元格中的字符数。如果实际值和最大允许字符数之间的差异不超过 10 个字符,则多出来的字符不会被隐藏。用户可以点击可见部分来查看完整的文本。例如一列的字符数限制为 10 个字符:

      gui table column maxTextLength
    • linkScreen - 设置单击 link 属性为 true 的列中的链接时打开的界面的标识符。

    • linkScreenOpenType - 设置界面打开模式(THIS_TABNEW_TAB 或者 DIALOG)。

    • linkInvoke - 单击链接时调用控制器方法而不是打开界面。

      @Inject
      private Notifications notifications;
      
      public void linkedMethod(Entity item, String columnId) {
          Customer customer = (Customer) item;
          notifications.create()
                  .withCaption(customer.getName())
                  .show();
      }
    • captionProperty - 指定一个要显示在列中的实体属性名称,而不是显示 id 指定的实体属性值。例如,如果有一个包含 nameorderNo 属性的实体 Priority,则可以定义以下列:

      <column id="priority.orderNo" captionProperty="priority.name" caption="msg://priority" />

      此时,列中将会显示 Priority 实体的 name 属性,但是列的排序是根据 Priority 实体的 orderNo 属性。

    • 可选的 generator 属性包含指向界面控制器中方法,该方法可创建一个可视化组件显示在表格单元格中:

      <columns>
          <column id="name"/>
          <column id="imageFile"
                  generator="generateImageFileCell"/>
      </columns>
      public Component generateImageFileCell(Employee entity){
          Image image = uiComponents.create(Image.NAME);
          image.setSource(FileDescriptorResource.class).setFileDescriptor(entity.getImageFile());
          return image;
      }

      它可以用来为 addGeneratedColumn() 方法提供一个 Table.ColumnGenerator 的实现

    • column 元素可能包含一个嵌套的formatter元素,它允许以不同于Datatype的标准格式显示属性值:

      <column id="date">
          <formatter class="com.haulmont.cuba.gui.components.formatters.DateFormatter"
                     format="yyyy-MM-dd HH:mm:ss"
                     useUserTimezone="true"/>
      </column>
  • rowsCount − 可选元素,为表格添加 RowsCount 组件;此组件能够分页加载表格数据。可以使用数据加载器setMaxResults() 方法限制数据容器中的记录数来定义分页的大小。这个方法通常是由链接到表格数据加载器的过滤器组件来执行的。如果表格没有通用过滤器,则可以直接从界面控制器调用此方法。

    RowsCount 组件还可以显示当前数据容器查询的记录总数,而无需提取这些记录。当用户单击 ? 图标时,它会调用 com.haulmont.cuba.core.global.DataManager#getCount 方法,执行与当前查询条件相同的数据库查询,不过会使用 COUNT(*) 聚合函数代替查询列。然后显示检索到的数字,代替 ? 图标。

    RowsCount 组件的 autoLoad 属性如果设置为 true,启用自动加载行数。可以在 XML 描述中设置:

    <rowsCount autoLoad="true"/>

    另外,在界面控制器中也可以通过 RowsCount API 启用或禁用该功能:

    boolean autoLoadEnabled = rowsCount.getAutoLoad();
    rowsCount.setAutoLoad(false);
  • actions − 可选元素,用于描述和表格相关的操作。除了自定义的操作外,该元素还支持以下在 com.haulmont.cuba.gui.actions.list 里定义标准操作createeditremoverefreshaddexcludeexcel

  • 可选元素,在表格上方添加一个 ButtonsPanel 容器来显示操作按钮。

table 属性

  • emptyStateMessage 属性可以在没有加载数据、设置了空项或者使用了空的数据容器时设置提示消息。该属性通常与emptyStateLinkMessage属性一起使用。消息需要提示表格为空的原因,示例:

    <table id="table"
           emptyStateMessage="No data added to the table"
           ...
           width="100%">

    emptyStateMessage 属性支持从消息包加载消息。如果不想显示消息,则可以不设置该字段。

  • emptyStateLinkMessage 属性可以在没有加载数据、设置了空项或者使用了空的数据容器时设置链接提示消息。 该属性通常与emptyStateMessage属性一起使用。消息需要描述如何操作才能填补表格数据,示例:

    <table id="table"
           emptyStateMessage="No data added to the table"
           emptyStateLinkMessage="Add data (Ctrl+\)"
           ...
           width="100%">
    gui table emptyState

    emptyStateLinkMessage 属性支持从消息包加载消息。如果不想显示消息,则可以不设置该字段。

    处理链接点击事件,可以用setEmptyStateLinkClickHandler或者订阅对应的事件:

    @Install(to = "customersTable", subject = "emptyStateLinkClickHandler")
    private void customersTableEmptyStateLinkClickHandler(Table.EmptyStateClickEvent<Customer> emptyStateClickEvent) {
        screenBuilders.editor(emptyStateClickEvent.getSource())
                .newEntity()
                .show();
    }
  • multiselect 属性可以为表格行设置多选模式。如果 multiselecttrue,用户可以按住 CtrlShift 键在表格中选择多行。默认情况下关闭多选模式。

  • sortable 属性可以对表中的数据进行排序。默认情况下,它设置为 true 。如果允许排序,单击列标题在列名称右侧将显示图标 gui_sortable_down / gui_sortable_up。可以使用sortable属性禁用特定列的排序。

    根据是否将所有记录放在了一页上来使用不同的方式进行排序。如果所有记录在一页,则在内存中执行排序而不需要数据库查询。如果数据有多页,则通过发送具有相应 ORDER BY 条件的新的查询请求在数据库中执行排序。

    一个表格中的列可能包含本地属性或实体链接。例如:

    <table id="ordersTable"
           dataContainer="ordersDc">
        <columns>
            <column id="customer.name"/> <!-- the 'name' attribute of the 'Customer' entity -->
            <column id="contract"/>      <!-- the 'Contract' entity -->
        </columns>
    </table>

    在后一种情况下,数据排序是根据关联实体的 @NamePattern 注解中定义的属性进行的。如果实体中没有这个注解,则仅仅在内存中对当前页的数据进行排序。

    如果列引用了非持久化实体属性,则数据排序将根据 @MetaProperty 注解的 related() 参数中定义的属性执行。如果未指定相关属性,则仅仅在内存中对当前页的数据进行排序。

    如果表格链接到一个嵌套的属性容器,这个属性容器包含相关实体的集合。这个集合属性必须是有序类型(ListLinkedHashSet)才能使表格支持排序。如果属性的类型为 Set,则 sortable 属性不起作用,并且用户无法对表格进行排序。

    如果需要,也可以提供自定义的排序实现

  • presentations 属性控制展示设置。默认情况下,该值为 false。如果属性值为 true,则会在表格的右上角添加相应的图标 gui_presentation

  • 如果 columnControlVisible 属性设置为 false,则用户无法使用位于表头的右侧的下拉菜单按钮 gui_table_columnControl 隐藏列,gui_table_columnControl 按钮位于表头的右侧。当前显示的列在菜单中标记为选中状态。提供了额外的菜单选项:

    • Select all − 显示所有的表格列

    • Deselect all − 隐藏所有能隐藏的列,除了第一列。第一列不能隐藏,否则表格不能正确显示了。

gui table columnControl all
  • 如果 reorderingAllowed 属性设置为 false,则用户不能通过用鼠标拖动来更改列顺序。

  • 如果 columnHeaderVisible 属性设置为 false,则该表没有列标题。

  • 如果 showSelection 属性设置为 false,则不突出显示当前行。

  • contextMenuEnabled 属性启用右键菜单。默认情况下,此属性设置为 true。右键菜单中会列出表格操作(如果有的话)和 System Information 菜单项(如果用户具有 cuba.gui.showInfo 权限),通过 System Information 菜单项可查看选中实体的详细信息。

  • multiLineCells 设置为 true 可以让包含多行文本的单元格显示多行文本。在这种模式下,浏览器会一次加载表格中当前页的所有行,而不是延迟加载表格的可见部分。这就要求在 Web 客户端中适当的滚动。默认值为“false”。

  • aggregatable 属性启用表格行的聚合运算。支持以下操作:

    • SUM – 计算总和

    • AVG – 计算平均值

    • COUNT – 计算总数

    • MIN – 找到最小值

    • MAX – 找到最大值

    聚合列的 aggregation 元素应该设置 type 属性,在这个属性中设置聚合函数。默认情况下,聚合列仅支持数值类型,例如 IntegerDoubleLongBigDecimal。聚合表格值显示在表格顶部的附加行中。这是一个定义聚合表示例:

    <table id="itemsTable" aggregatable="true" dataContainer="itemsDc">
        <columns>
            <column id="product"/>
            <column id="quantity"/>
            <column id="amount">
                <aggregation type="SUM"/>
            </column>
        </columns>
    </table>

    aggregation 元素可以包含 editable 属性。设置该属性为 truesetAggregationDistributionProvider() 方法一起使用,开发者可以实现算法用来在表格不同行之间分发数据。

    aggregation 元素还可以包含 strategyClass 属性,指定一个实现 AggregationStrategy 接口的类(参阅下面以编程方式设置聚合策略的示例)。

    valueDescription 属性定义一个提示,当用户的光标悬浮在聚合值上时通过弹出框展示这个提示。对于上面列出的运算(SUMAVGCOUNTMINMAX),提示弹窗已经默认开启。

    可以指定不同于 Datatype 标准格式的格式化器显示聚合值:

    <column id="amount">
        <aggregation type="SUM">
            <formatter class="com.company.sample.MyFormatter"/>
        </aggregation>
    </column>

    aggregationStyle 属性允许指定聚合行的位置:TOPBOTTOM。默认情况下使用 TOP

    除了上面列出的操作之外,还可以自定义聚合策略,通过实现 AggregationStrategy 接口并将其传递给 AggregationInfo 实例中 Table.Column 类的 setAggregation() 方法。例如:

    public class TimeEntryAggregation implements AggregationStrategy<List<TimeEntry>, String> {
        @Override
        public String aggregate(Collection<List<TimeEntry>> propertyValues) {
            HoursAndMinutes total = new HoursAndMinutes();
            for (List<TimeEntry> list : propertyValues) {
                for (TimeEntry timeEntry : list) {
                    total.add(HoursAndMinutes.fromTimeEntry(timeEntry));
                }
            }
            return StringFormatHelper.getTotalDayAggregationString(total);
        }
        @Override
        public Class<String> getResultClass() {
            return String.class;
        }
    }
    AggregationInfo info = new AggregationInfo();
    info.setPropertyPath(metaPropertyPath);
    info.setStrategy(new TimeEntryAggregation());
    
    Table.Column column = weeklyReportsTable.getColumn(columnId);
    column.setAggregation(info);
  • editable 属性可以将表格转换为即时编辑模式。在这种模式下,具有 editable = true 属性的列显示用于编辑相应实体属性的组件。

    根据相应实体属性的类型自动选择每个可编辑列的组件类型。例如,对于字符串和数字属性,应用程序将使用 TextField;对于 Date 将使用 DateField;对于列表将使用 LookupField;对于指向其它实体的链接将使用 PickerField

    对于 Date 类型的可编辑列,还可以定义 dateFormatresolution 属性,类似于为 DateField 的属性。

    可以为显示链接实体的可编辑列定义 optionsContainercaptionProperty 属性。如果设置了 optionsContainer 属性,应用程序将使用 LookupField 而不是 PickerField

    可以使用 Table.addGeneratedColumn() 方法实现单元格的自定义配置(包括编辑) - 见下文。

  • 在具有基于 Halo-based 主题的 Web 客户端中,stylename 属性可以在 XML 描述中或者界面控制器中为 Table 组件设置预定义样式:

    <table id="table"
           dataContainer="itemsDc"
           stylename="no-stripes">
        <columns>
            <column id="product"/>
            <column id="quantity"/>
        </columns>
    </table>

    当以编程方式设置样式时,需要选择 HaloTheme 类的一个以 TABLE_ 为前缀的常量:

    table.setStyleName(HaloTheme.TABLE_NO_STRIPES);
    表格样式
    • borderless - 不显示表格的外部边线。

    • compact - 减少表格单元格内的空白区域。

    • no-header - 隐藏表格的列标题。

    • no-horizontal-lines - 删除行之间的水平分隔线。

    • no-stripes - 删除交替的行颜色。

    • no-vertical-lines - 删除列之间的垂直分隔线。

    • small - 使用小字体并减少表格单元格内的空白区域。

Table 接口方法

  • 可以使用 addColumnCollapsedListener 方法和 ColumnCollapsedListener 接口的实现跟踪列的可视化状态。

  • getSelected()getSingleSelected() 返回表格中的选定行对应的实体实例。可以通过调用 getSelected() 方法来获得集合。如果未选择任何内容,则程序将返回空集。如果禁用了 multiselect,应该使用 getSingleSelected() 方法返回一个选定实体,如果没有选择任何内容则返回 null

  • addSelectionListener() 可以跟踪表格选中行的变化,示例:

    customersTable.addSelectionListener(customerSelectionEvent ->
            notifications.create()
                    .withCaption("You selected " + customerSelectionEvent.getSelected().size() + " customers")
                    .show());

    也可以通过订阅相应的事件来跟踪选中行的变化:

    @Subscribe("customersTable")
    protected void onCustomersTableSelection(Table.SelectionEvent<Customer> event) {
        notifications.create()
                .withCaption("You selected " + customerSelectionEvent.getSelected().size() + " customers")
                .show();
    }

    可以使用isUserOriginated() 方法跟踪 SelectionEvent 事件的来源。

  • addGeneratedColumn() 方法允许在列中自定义数据的表现方式。它需要两个参数:列的标识符和 Table.ColumnGenerator 接口的实现。如果标识符可以匹配 XML 描述中为表格列设置的标识符 - 在这种情况下,插入新列代替 XML 中定义的列。如果标识符与任何列都不匹配,则会在右侧添加新列。

    对于表的每一行将调用 Table.ColumnGenerator 接口的 generateCell() 方法。该方法接受在相应行中显示的实体实例作为参数。generateCell() 方法应该返回一个可视化组件,该组件将显示在单元格中。

    使用组件的示例:

    @Inject
    private GroupTable<Car> carsTable;
    @Inject
    private CollectionContainer<Car> carsDc;
    @Inject
    private CollectionContainer<Color> colorsDc;
    @Inject
    private UiComponents uiComponents;
    @Inject
    private Actions actions;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        carsTable.addGeneratedColumn("color", entity -> {
            LookupPickerField<Color> field = uiComponents.create(LookupPickerField.NAME);
            field.setValueSource(new ContainerValueSource<>(carsTable.getInstanceContainer(entity), "color"));
            field.setOptions(new ContainerOptions<>(colorsDc));
            field.addAction(actions.create(LookupAction.class));
            field.addAction(actions.create(OpenAction.class));
            return field;
        });
    }

    在上面的示例中,表中 color 列中的所有单元格都显示了 LookupPickerField 组件。组件应将它的值保存到相应的行中的实体的 color 属性中。

    getInstanceContainer() 方法返回带有当前实体的容器,只能在绑定组件(创建于生成表格单元格时)和数据的时候使用。

    如果要显示动态文本,请使用特殊类 Table.PlainTextCell 而不是 Label 组件。它将简化渲染过程并使表格运行更快。

    如果 addGeneratedColumn() 方法接收到的参数是未在 XML 描述中声明的列的标识符,则新列的标题将设置如下:

    carsTable.getColumn("colour").setCaption("Colour");

    还可以考虑使用 XML 的 generator 属性做更具声明性的设置方案。

  • requestFocus() 方法允许将焦点设置在某一行的具体的可编辑字段上。需要两个参数:表示行的实体实例和列的标识符。请求焦点的示例如下:

    table.requestFocus(item, "count");
  • scrollTo() 方法允许将表格滚动到具体行。需要一个参数:表示行的实体实例。

    滚动条的示例:

    table.scrollTo(item);
  • 如果需要在单元格中显示自定义内容并且在用户单击单元格的时候能收到通知,可以使用 setClickListener() 方法实现这些功能。CellClickListener 接口的实现接收选中实体和列标识符作为参数。这些单元格的内容将被包装在一个 span 元素中,这个 span 元素带有 cuba-table-clickable-cell 样式,可以利用该样式来定义单元格外观。

    使用 CellClickListener 的示例:

    @Inject
    private Table<Customer> customersTable;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        customersTable.setCellClickListener("name", customerCellClickEvent ->
                notifications.create()
                        .withCaption(customerCellClickEvent.getItem().getName())
                        .show());
    }
  • 可以使用 setAggregationDistributionProvider() 方法指定 AggregationDistributionProvider,定义在表格行间分发聚合值的规则。如果用户在聚合单元格输入值,会根据自定义的算法将值分发到贡献至该聚合值的单元格。只支持 TOP 聚合样式。如果要使得聚合单元格可编辑,需要使用 xml 中 aggregation 元素的editable属性。

    当创建 provider 时,应当使用 AggregationDistributionContext<E> 对象,包含分发聚合值所需的数据:

    • Column column 合计或分组聚合值变更的列;

    • Object value − 新的聚合值;

    • Collection<E> scope − 一组实体,会受到聚合值的影响;

    • boolean isTotalAggregation 显示合计聚合值或者分组聚合值。

      作为示例,我们考虑一个展示预算的表格。用户创建预算种类,并设置每种预算在收入中需要分成的百分比。下一步,用户在聚合单元格设置收入的总额,然后会按照不同的种类分发。

      界面 XML 描述中表格的配置示例:

      <table id="budgetItemsTable"
             width="100%"
             dataContainer="budgetItemsDc"
             aggregatable="true"
             editable="true"
             showTotalAggregation="true">
              ...
          <columns>
              <column id="category"/>
              <column id="percent"/>
              <column id="sum">
                  <aggregation editable="true"
                               type="SUM"/>
              </column>
          </columns>
              ...
      </table>

      界面控制器示例:

      budgetItemsTable.setAggregationDistributionProvider(context -> {
          Collection<BudgetItem> scope = context.getScope();
          if (scope.isEmpty()) {
              return;
          }
      
          double value = context.getValue() != null ?
                  ((double) context.getValue()) : 0;
      
          for (BudgetItem budgetItem : scope) {
              budgetItem.setSum(value / 100 * budgetItem.getPercent());
          }
      });
  • getAggregationResults() 方法返回一个聚合值的映射(map),键值为表格列的标识符,值为聚合值。

  • setStyleProvider() 方法可以设置表格单元格显示样式。该方法接受 Table.StyleProvider 接口的实现类作为参数。表格的每一行和每个单元分别调用这个接口的 getStyleName() 方法。如果某一行调用该方法,则第一个参数包含该行显示的实体实例,第二个参数为 null。如果单元格调用该方法,则第二个参数包含单元格显示的属性的名称。

    设置样式的示例:

    @Inject
    protected Table customersTable;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        customersTable.setStyleProvider((customer, property) -> {
            if (property == null) {
                // style for row
                if (hasComplaints(customer)) {
                    return "unsatisfied-customer";
                }
            } else if (property.equals("grade")) {
                // style for column "grade"
                switch (customer.getGrade()) {
                    case PREMIUM: return "premium-grade";
                    case HIGH: return "high-grade";
                    case MEDIUM: return "medium-grade";
                    default: return null;
                }
            }
            return null;
        });
    }

    然后应该在应用程序主题中设置的单元格和行样式。有关创建主题的详细信息,请参阅 主题。对于 Web 客户端,新样式在 styles.scss 文件中。在控制器中定义的样式名称,以及表格行和列的前缀标识符构成 CSS 选择器。例如:

    .v-table-row.unsatisfied-customer {
      font-weight: bold;
    }
    .v-table-cell-content.premium-grade {
      background-color: red;
    }
    .v-table-cell-content.high-grade {
      background-color: green;
    }
    .v-table-cell-content.medium-grade {
      background-color: blue;
    }
  • addPrintable() 当通过 excel 标准操作或直接使用 ExcelExporter 类导出数据到 XLS 文件时,此方法可以给列中数据设置自定义展现。该方法接收的两个参数为列标识符和为列提供的 Table.Printable 接口实现。例如:

    ordersTable.addPrintable("customer", new Table.Printable<Customer, String>() {
        @Override
        public String getValue(Customer customer) {
            return "Name: " + customer.getName;
        }
    });

    Table.Printable 接口的 getValue() 方法应该返回在表格单元格中显示的数据。返回的数据不一定是字符串类型,该方法可以返回其它类型的值,比如数字或日期,它们将在 XLS 文件中以相应的类型展示。

    如果生成的列需要在输出到 XLS 时带有格式,则应该使用 addGeneratedColumn() 方法,传递一个 Table.PrintableColumnGenerator 接口的实现作为参数。XLS 文档中单元格的值在这个接口的 getValue() 方法中定义:

    ordersTable.addGeneratedColumn("product", new Table.PrintableColumnGenerator<Order, String>() {
        @Override
        public Component generateCell(Order entity) {
            Label label = uiComponents.create(Label.NAME);
            Product product = order.getProduct();
            label.setValue(product.getName() + ", " + product.getCost());
            return label;
        }
    
        @Override
        public String getValue(Order entity) {
            Product product = order.getProduct();
            return product.getName() + ", " + product.getCost();
        }
    });

    如果没有以某种方式为生成的列定义 Printable 描述,那么该列将显示相应实体属性的值,如果没有关联的实体属性,则不显示任何内容。

  • setItemClickAction() 方法能够定义一个双击表格行时将执行的操作。如果未定义此操作,表格将尝试按以下顺序在其操作列表中查找适当的操作:

    • shortcut 属性指定给 Enter 键的操作

    • edit 操作

    • view 操作

      如果找到此操作,并且操作具有 enabled=true 属性,则执行该操作。

  • setEnterPressAction() 方法可以定义按下 Enter 键时执行的操作。如果未定义此操作,则表将尝试按以下顺序在其操作列表中查找适当的操作:

    • setItemClickAction() 方法定义的动作

    • shortcut 属性指定给 Enter 键的操作

    • edit 操作

    • view 操作

    如果找到此操作,并且操作具有 enabled=true 属性,则执行该操作。

  • setEmptyStateLinkClickHandler 提供一个处理器,当点击空状态链接消息之后会调用:

    @Subscribe
    public void onInit(InitEvent event) {
        customersTable.setEmptyStateLinkClickHandler(emptyStateClickEvent ->
                    screenBuilders.editor(emptyStateClickEvent.getSource())
                        .newEntity()
                        .show());
    }
  • setItemDescriptionProvider 方法设置 item 的描述 provider,用来为 item 生成提示。

    下面的例子中,我们展示在 departmentsTable 使用 setItemDescriptionProviderDepartment 实体有三个属性:nameactiveparentDept

    @Inject
    private Table<Department> departmentsTable;
    
    @Subscribe
    public void onInit(InitEvent event) {
        departmentsTable.setItemDescriptionProvider(((department, property) -> {
            if (property == null) { (1)
                if (department.getParentDept() == null) {
                    return "Parent Department";
                }
            } else if (property.equals("active")) { (2)
                return department.getActive()
                        ? "Active department"
                        : "Inactive department";
            }
            return null;
        }));
    }
    1 – 行描述
    2 – "active" 列描述

Table 的展示可以使用带 $cuba-table-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。


table 的属性

align - aggregatable - aggregationStyle - caption - captionAsHtml - columnControlVisible - columnHeaderVisible - contextHelpText - contextHelpTextHtmlEnabled - contextMenuEnabled - css - dataContainer - description - descriptionAsHtml - editable - emptyStateLinkMessage - emptyStateMessage - enable - box.expandRatio - height - htmlSanitizerEnabled - id - metaClass - multiLineCells - multiselect - presentations - reorderingAllowed - settingsEnabled - showSelection - sortable - stylename - tabIndex - textSelectionEnabled - visible - width

table 的元素

actions - buttonsPanel - columns - rows - rowsCount

columns 元素的属性

includeAll - exclude

column 元素的属性

align - caption - captionProperty - collapsed - dateFormat - editable - expandRatio - generator - id - link - linkInvoke - linkScreen - linkScreenOpenType - maxTextLength - optionsContainer - resolution - sort - sortable - visible - width

column的元素

aggregation - formatter

aggregation的属性

editable - strategyClass - type - valueDescription

table 的预定义样式

borderless - compact - no-header - no-horizontal-lines - no-stripes - no-vertical-lines - small

API

addGeneratedColumn - addPrintable - addColumnCollapseListener - addSelectionListener - applySettings - generateCell - getAggregationResults - getSelected - requestFocus - saveSettings - scrollTo - setAggregationDistributionProvider - setClickListener - setEmptyStateLinkClickHandler - setEnterPressAction - setItemClickAction - setItemDescriptionProvider - setStyleProvider


3.5.2.1.46. 文本区

TextArea 是多行文本编辑字段。

该组件对应的 XML 名称: textArea

TextArea 的功能大部分与 TextField 组件相同,同时具有以下特有属性:

  • colsrows 设置文本的行数和列数:

    <textArea id="textArea" cols="20" rows="5" caption="msg://name"/>

    widthheight 的值优先于 colsrows 的值。

  • wordWrap - 将此属性设置为 false 以关闭自动换行。

    TextArea 支持在其父 TextInputField 接口中定义的 TextChangeListener。文本变化事件在输入时按顺序异步处理,不会阻塞输入。

    textArea.addTextChangeListener(event -> {
        int length = event.getText().length();
        textAreaLabel.setValue(length + " of " + textArea.getMaxLength());
    });
gui TextArea 2
  • textChangeEventMode 定义文本的变化被发送到服务器并触发服务端事件的方式。有 3 种预定义的事件模式:

    • LAZY (默认) - 文件输入暂停时触发事件。暂停时间可以通过 setTextChangeTimeout() 或者textChangeTimeout 属性修改。即使用户在输入文本时没有发生暂停,也会在可能发生的 ValueChangeEvent 之前强制触发文本更改事件。

    • TIMEOUT - 超时后触发事件。如果在超时周期内进行了多次更改,则将周期内自最后一次更改后发生的更改发送到服务端。可以使用 setTextChangeTimeout() 或者textChangeTimeout 属性设置超时时长。

      如果在超时期限之前发生 ValueChangeEvent,则在它之前触发 TextChangeEvent,条件是文本内容自上一个 TextChangeEvent 以来已经发生改变。

    • EAGER - 对于文本内容的每次更改,都会立即触发事件,通常是由按键触发。请求是独立且一个接一个地顺序处理。文本变化事件以异步方式与服务器交互,因此可以在处理事件请求的同时继续输入。

  • textChangeTimeouttextChangeEventModeLAZYTIMEOUT 时,定义编辑文本时暂停的时间或者超时时间。

    TextArea 样式

    Web Client 使用 Halo-based 主题时,在 XML 描述或者界面控制器中可以使用 stylename 属性给 TextArea 组件设置预定义的样式:

    <textArea id="textArea"
              stylename="borderless"/>

    如果使用编程的方式设置样式,可以选择一个前缀为 TEXTFIELD_HaloTheme class 常量:

    textArea.setStyleName(HaloTheme.TEXTAREA_BORDERLESS);
    • align-center - 使文本在文本区中居中显示。

    • align-right - 使文本在文本区中居右显示。

    • borderless - 移除文本区的边框和背景。



3.5.2.1.47. 文本控件

TextField 是用于文本编辑的控件。它可以用于处理实体属性,也可用于输入/显示任何文本信息。

该组件对应的 XML 名称:textField

  • 从本地消息包中获取标题(caption)的文本控件示例:

    <textField id="nameField" caption="msg://name"/>

    下图展示了一个简单文本控件示例:

    gui textField data
  • Web Client 使用 Halo-based 主题时,在 XML 描述或者界面控制器中可以使用 stylename 属性给文本框组件设置预定义的样式 :

    <textField id="textField"
               stylename="borderless"/>

    如果使用编程的方式设置样式,可以选择一个前缀为 TEXTFIELD_HaloTheme class 常量。

    textField.setStyleName(HaloTheme.TEXTFIELD_INLINE_ICON);

    TextField 样式:

    • align-center - 使文本在文本框中居中显示。

    • align-right - 使文本在文本框中居右显示。

    • borderless - 移除文本框的边框和背景。

    • inline-icon - 使标题图标显示在文本框里面。

    文本框支持自动大小写转换。 caseConvertion 属性包含下列取值:

    • UPPER - 转换为大写,

    • LOWER - 转换为小写,

    • NONE - 不转换(默认值)。用这个选项来支持在输入法连续输入(比如,在输入中文、日文、韩文的时候)

  • 要创建连接数据的文本框,使用数据容器property 属性。

    <data>
        <instance id="customerDc" class="com.company.sales.entity.Customer" view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <textField dataContainer="customerDc" property="name" caption="msg://name"/>
    </layout>

    如上所示,界面描述中为实体 Customer 定义了数据容器 customerDc ,并且 Customer 实体有一个 name 属性。文本控件通过 dataContainer 属性连接到数据容器;property 属性设置为要显示在控件中的实体属性的名称。

  • 如果文本控件没有连接到任何实体属性 (即,未设置数据容器和属性名称),可以使用 datatype 属性设置数据类型,数据类型用来格式化控件值。datatype 属性值可以是应用程序元数据中注册的任何数据类型 – 见 数据类型接口。通常,TextField 使用下面的数据类型:

    • decimal

    • double

    • int

    • long

    如果该字段有 datatype 属性,并且用户输入了一个错误的值,则会显示默认的转换错误消息。

    下面是一个数据类型是 Integer 的文本控件示例。

    <textField id="integerField" datatype="int" caption="msg://integerFieldName"/>

    如果用户输入的字符不能解析为整数,当该控件失去焦点时,应用程序将显示错误消息。

    gui datatype default message

    默认的消息是在主语言包中定义的,有这样的模板:databinding.conversion.error.<type>,比如:

    databinding.conversion.error.int = Must be Integer
  • 可以在界面 XML 描述中声明式的定义自己的类型转换错误消息,使用 conversionErrorMessage 属性:

    <textField conversionErrorMessage="This field can work only with Integers" datatype="int"/>

    或者在界面控制器中通过便层的方式创建:

    textField.setConversionErrorMessage("This field can work only with Integers");
  • 可以为文本控件分配一个验证器 - 实现了 Field.Validator 接口的类。在 datatype 对输入的字符格式进行验证后,验证器进行进一步的验证。例如,要创建一个正整数输入控件,需要创建一个验证器类:

    public class PositiveIntegerValidator implements Field.Validator {
        @Override
        public void validate(Object value) throws ValidationException {
            Integer i = (Integer) value;
            if (i <= 0)
                throw new ValidationException("Value must be positive");
        }
    }

    同时设置它为数据类型是 int 的文本控件的验证器:

    <textField id="integerField" datatype="int">
        <validator class="com.sample.sales.gui.PositiveIntegerValidator"/>
    </textField>

    与数据类型的输入时检查不同,验证不是在控件失去焦点时执行,而是在调用控件的 validate() 方法之后执行。这意味着控件(和连接的实体属性)可能暂时包含不满足验证条件的值(上例中的非正数)。这应该不是问题,因为要验证的控件通常用在编辑界面中,它会在提交之前自动调用所有控件的验证。如果该控件不在编辑界面中,则应在控制器中显式调用该控件的 validate() 方法。

  • TextField 支持其实现的 TextInputField 接口中定义的 TextChangeListener。为了不阻塞用户输入,文本变化事件的处理是异步进行的。

    textField.addTextChangeListener(event -> {
        int length = event.getText().length();
        textFieldLabel.setValue(length + " of " + textField.getMaxLength());
    });
    textField.setTextChangeEventMode(TextInputField.TextChangeEventMode.LAZY);
    gui textfield 2
  • The TextChangeEventMode 定义文本的变化被发送到服务器并触发服务端事件的方式。有 3 种预定义的事件模式:

    • LAZY (默认) - 文件输入暂停时触发事件。暂停时间可以通过 setInputEventTimeout() 修改。即使用户在输入文本时没有发生暂停,也会在可能发生的 ValueChangeEvent 之前强制触发文本更改事件。

    • TIMEOUT - 超时后触发事件。如果在超时周期内进行了多次更改,则将周期内自最后一次更改后发生的更改发送到服务端。可以使用 setInputEventTimeout() 设置超时时长。

      如果在超时期限之前发生 ValueChangeEvent,则在它之前触发 TextChangeEvent,条件是文本内容自上一个 TextChangeEvent 以来已经发生改变。

    • EAGER - 对于文本内容的每次更改,都会立即触发 TextChangeEvent 事件,通常是由按键触发。请求是独立且一个接一个地顺序处理。文本变化事件以异步方式与服务器交互,因此可以在处理事件请求的同时继续输入。

    • BLUR - 文本控件失去焦点时,发送事件。

  • EnterPressListener 允许定义一个在 Enter 键按下时被执行的操作

    textField.addEnterPressListener(enterPressEvent ->
            notifications.create()
                    .withCaption("Enter pressed")
                    .show());
  • ValueChangeListener 当用户完成文本输入时,即在按下 Enter 键或组件失去焦点时,将触发 ValueChangeListenerValueChangeEvent 类型的事件对象被传递给监听器,它有以下方法:

    • getPrevValue() 返回组件之前的值。

    • getValue() 返回组件的当前值。

      textField.addValueChangeListener(stringValueChangeEvent ->
              notifications.create()
                      .withCaption("Before: " + stringValueChangeEvent.getPrevValue() +
                              ". After: " + stringValueChangeEvent.getValue())
                      .show());

      可以使用 isUserOriginated() 方法跟踪 ValueChangeEvent 的来源。

  • 如果文本控件连接到实体属性(通过 dataContainerproperty),并且实体属性具有定义在 @Column JPA 注解中的 length 参数,那么 TextField 将相应地限制输入文本的最大长度。

    如果文本控件未链接到属性,或者属性未定义 length 值,或者需要覆盖此值,则可以使用 maxLength 属性限制输入文本的最大长度。值 "-1" 表示没有限制。 例如:

    <textField id="shortTextField" maxLength="10"/>
  • 默认情况下,文本控件截去字符串的前后空格。即,如果用户输入 " aaa bbb ",则 getValue() 方法返回并保存到连接实体属性的控件值将为 "aaa bbb"。可以通过将 trim 属性设置为 false 来禁用空格去除。

    应该注意的是,去除空格仅在用户输入新值时起作用。如果连接属性的值中已包含空格,则将显示空格,直到用户编辑该值。

  • 文本控件始终返回 null 而不是输入的空字符串。因此,启用 trim 属性后,任何只包含空格的字符串都将转换为 null

  • setCursorPosition() 方法可使控件获得焦点并将光标位置设置为指定索引,索引基于 0。



3.5.2.1.48. 时间组件

TimeField 是用来显示和输入时间的组件。

gui timeField

组件的 XML 名称: timeField

  • 要创建关联数据的时间组件,应该使用数据容器property 属性:

    <data>
        <instance id="orderDc" class="com.company.sales.entity.Order" view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <timeField dataContainer="orderDc" property="deliveryTime"/>
    </layout>

    如同上面的示例,在界面描述中为实体 Order 定义了数据容器 orderDc ,实体具有 deliveryTime 属性。时间输入组件的 dataContainer 属性包含到数据容器的连接, property 属性 – 设置要显示在时间字段中的实体属性名称。

    关联的实体属性类型应该是 java.util.Datejava.sql.Time 类型。

  • 如果该控件不与实体属性相关联(比如没有设置数据容器和属性名称),可以使用 datatype 属性设置数据类型。 TimeField 使用如下数据类型:

    • localTime

    • offsetTime

    • time

  • 时间格式通过 time 数据类型定义,并且在主本地化消息包中通过 timeFormat 键指定。

  • 时间格式也可以通过 timeFormat 属性指定,属性值可以是一个格式化字符串,或者是消息包中的键名(前缀:msg:// )。

  • 无论上面提到的属性如何设置,都可以通过 showSeconds 属性控制是否显示秒。默认情况下,如果时间格式中包含 ss,则显示秒。

    <timeField dataContainer="orderDc" property="createTs" showSeconds="true"/>
    gui timeFieldSec


3.5.2.1.49. 标签列表

TokenList 组件提供了一种使用列表的简单方式: 以水平或垂直的方式标签名称、使用下拉列表添加标签、使用每个标签旁边的按钮进行移除。

gui tokenList

组件 XML 名称: tokenList

下面是一个界面 XML 描述中定义 TokenList 组件的示例:

<data>
    <instance id="orderDc" class="com.company.sales.entity.Order" view="order-edit">
        <loader/>
        <collection id="productsDc" property="products"/>
    </instance>
    <collection id="allProductsDc" class="com.company.sales.entity.Product" view="_minimal">
        <loader id="allProductsDl">
            <query><![CDATA[select e from sales_Product e order by e.name]]></query>
        </loader>
    </collection>
</data>
<layout>
    <tokenList id="productsList" dataContainer="orderDc" property="products" inline="true" width="500px">
        <lookup optionsContainer="allProductsDc"/>
    </tokenList>
</layout>

在上面的示例中,嵌套的 productsDc 数据容器包含了一个订单的产品集合,同时 allProductsDc 数据容器包含了数据库中所有可用产品。id 为 productsListTokenList 组件展示 productsDc 数据容器的内容,同时可以通过从 allProductsDc 中添加实例来改变集合。

tokenList 属性:

  • position – 设置下拉列表的位置。这个属性的可选值包括: TOPBOTTOM。默认是:TOP

gui tokenListBottom
  • inline 属性定义包含选中条目的列表如何显示:vertically(垂直) 或 horizontally(水平)。true 表示水平对齐,false – 垂直对齐。 水平对齐的示例:

gui tokenListInline
  • simple – 设置为 true 时,将隐藏选择组件同时显示 Add 按钮。点击 Add 按钮将弹出一个选择界面,界面上有实体实例列表,其类型由数据容器定义。选择界面的标识符是根据 PickerField标准查找操作的规则来确定的。单击 Clear 按钮将删除 TokenList 组件数据源中的所有元素。

    gui tokenListSimple withClear
  • clearEnabled - 当设置为 falseClear 按钮将隐藏。

tokenList 元素:

  • lookup − 值选择组件的描述。

    lookup 元素属性:

    • lookup 属性使 TokenList 组件可以使用一个实体查找界面来选择值。

      gui tokenListLookup
    • inputPrompt - 显示在查找字段中的文本提示信息。如果没有设置,查找字段将不显示任何提示内容。

      <tokenList id="linesList" dataContainer="orderItemsDс" property="items" width="320px">
          <lookup optionsContainer="allItemsDс" inputPrompt="Choose an item"/>
      </tokenList>
      gui TokenList inputPrompt
    • lookupScreen 属性在 lookup="true" 时设置用于选择值的界面的标识符。如果没有设置此属性,则根据 com.haulmont.cuba.gui.actions.picker.LookupAction 标准操作的规则选择界面标识符。

    • openType 属性定义查找界面的打开方式,与 com.haulmont.cuba.gui.actions.picker.LookupAction 标准操作的描述相似。默认值 – THIS_TAB

    • multiselect - 如果这个属性设置为 true,实体查找界面将使用多选模式。每个要修改查找组件选择模式的界面,都必须实现 com.haulmont.cuba.gui.screen.MultiSelectLookupScreen 接口。标准的 StandardLookup 提供了该接口的默认实现。如需自定义实现,需要重写 setLookupComponentMultiSelect 方法,示例:

      public class ProductsList extends StandardLookup {
          @Inject
          private GroupTable<Product> productsTable;
      
          @Override
          public void setLookupComponentMultiSelect(boolean multiSelect) {
              productsTable.setMultiSelect(multiSelect);
          }
      }
  • addButton – 对添加条目的按钮的描述。可包含 captionicon 属性。

该组件可以从数据容器获取选项列表:此时需要用到 optionsContainer 属性。另外,组件的选项列表还可以通过 setOptions()setOptionsList()setOptionsMap() 方法设置。这样的话,XML 描述中的 `<lookup>`属性可以为空。

  • setOptionsList() 通过编程的方式制定一组实体作为组件的选项。首先,在 XML 描述中声明组件:

    <tokenList id="tokenList"
               dataContainer="orderDc"
               property="items"
               width="320px">
        <lookup/>
    </tokenList>

    然后,在控制器注入组件并指定选项:

    @Inject
    private TokenList<OrderItem> tokenList;
    @Inject
    private CollectionContainer<OrderItem> allItemsDc;
    
    @Subscribe
    public void onAfterShow(AfterShowEvent event) {
        tokenList.setOptionsList(allItemsDc.getItems());
    }

    setOptions() 方法也可以得到同样的效果,但是 setOptions() 方法可以使用任何类型的选项:

    @Inject
    private TokenList<OrderItem> tokenList;
    @Inject
    private CollectionContainer<OrderItem> allItemsDc;
    
    @Subscribe
    public void onAfterShow(AfterShowEvent event) {
        tokenList.setOptions(new ContainerOptions<>(allItemsDc));
    }

在没有合适的选项时,TokenList 还可以处理用户的输入。如果用户输入了一个值,并按下回车键,如果这个值不在目前的选项列表里,则会调用新选项处理器(New option handler)。

有两种方式使用 newOptionHandler

  • 在界面控制器通过 @Install 注解,声明式使用:

    @Inject
    private CollectionContainer<Tag> tagsDc;
    @Inject
    private Metadata metadata;
    
    @Install(to = "tokenList", subject = "newOptionHandler")
    private void tokenListNewOptionHandler(String string) {
        Tag newTag = metadata.create(Tag.class);
        newTag.setName(string);
        tagsDc.getMutableItems().add(newTag);
    }
  • setNewOptionHandler() 方法编程使用:

    @Inject
    private CollectionContainer<Tag> tagsDc;
    @Inject
    private Metadata metadata;
    @Inject
    private TokenList<Tag> tokenList;
    
    @Subscribe
    public void onInit(InitEvent event) {
        tokenList.setNewOptionHandler(string -> {
            Tag newTag = metadata.create(Tag.class);
            newTag.setName(string);
            tagsDc.getMutableItems().add(newTag);
        });
    }

tokenList 监听器:

  • ItemClickListener 允许跟踪 tokenList 列表条目上的点击操作。

  • ValueChangeListener 可以跟踪 tokenList 组件值的变化,像其它任何实现了 Field 接口的组件一样。可以使用 isUserOriginated() 方法来跟踪 ValueChangeEvent 的来源。



3.5.2.1.50. 树

Tree 组件用于将具有自引用关系的实体显示为树状层次结构。

gui Tree

组件的 XML 名称: tree

下面是一个在界面 XML 描述中定义 tree 组件的示例:

<data readOnly="true">
    <collection id="departmentsDc" class="com.company.sales.entity.Department" view="department-view">
        <loader id="departmentsDl">
            <query>
                <![CDATA[select e from sales_Department e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <tree id="departmentsTree" dataContainer="departmentsDc" hierarchyProperty="parentDept"/>
</layout>

这里,dataContainer 属性包含指向集合数据容器的引用,hierarchyProperty 属性定义了一个实体属性的名称,这个属性也指向同一个的实体(从而能形成树)。

使用 treechildren 元素的 captionProperty 属性指定要显示为树节点名称的实体属性的名称,如果这个属性没有定义,将默认显示实体的实例名称

contentMode 属性定义树节点名称的展示方式。有三种预定义的模式:

  • TEXT - 文本值直接显示

  • PREFORMATTED - 文本值以特定格式显示。此时,界面渲染时会保留回车。

  • HTML - 文本值以 HTML 方式显示。注意,用该模式时需要避免 XSS 问题。

使用 setItemCaptionProvider() 方法可以设置一个函数,用来将实体的属性名称作为标题放到数的每项中。

Tree 中进行选择:

  • multiselect 设置是否允许树节点多选。如果 multiselect 设置为 true,用户可在按住 CtrlShift 键的情况下使用键盘或鼠标选择多个节点。多选模式默认关闭。

  • selectionMode - 设置行选择模式。有三种预定义的选择模式:

    • SINGLE - 单一记录选择模式。

    • MULTI - 多选模式,跟在表格中多选类似。

    • NONE - 禁止选择。

    行选择事件可以通过 SelectionListener 监听器进行跟踪。选择事件的发起者可以通过isUserOriginated()方法跟踪。

    selectionMode 属性比废弃的 multiselect 属性有更高的优先级。

setItemClickAction() 用于定义一个操作,双击树节点时执行。

每个树节点左边可以定义一个图标。在界面控制器中的 setIconProvider() 方法中创建一个 Function 接口的实现来设置图标:

@Inject
private Tree<Department> tree;

@Subscribe
protected void onInit(InitEvent event) {
    tree.setIconProvider(department -> {
        if (department.getParentDept() == null) {
            return "icons/root.png";
        }
        return "icons/leaf.png";
    });
}

可以用下面的方式在树节点展开或收起时做交互:

@Subscribe("tree")
public void onTreeExpand(Tree.ExpandEvent<Task> event) {
    notifications.create()
            .withCaption("Expanded: " + event.getExpandedItem().getName())
            .show();
}

@Subscribe("tree")
public void onTreeCollapse(Tree.CollapseEvent<Task> event) {
    notifications.create()
            .withCaption("Collapsed: " + event.getCollapsedItem().getName())
            .show();
}

Tree 组件有 DescriptionProvider,因此,在 contentMode 设置为 ContentMode.HTML 时,可以渲染 HTML。如果 htmlSanitizerEnabled 属性设置为 true,该 provider 的执行结果会是清理后安全的 HTML。

htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

对于之前的老界面,Tree 组件可以绑定到一个数据源而不是数据容器。这种情况下,需要定义嵌套的 treechildren 元素,这个元素需要包含指向在 datasource 属性定义的 hierarchicalDatasource 的引用。hierarchicalDatasource 的声明需要包含 hierarchyProperty 属性,此属性定义了实体属性的名称,这个属性也指向相同的实体。



3.5.2.1.51. 树形数据网格

TreeDataGrid, 类似于DataGrid组件,用于显示和排序表格式数据,并提供显示层级结构数据和操作行列的方法,这些行和列由于只在滚动时按需加载数据,因而性能更好。

该组件用于显示具自引用关系的实体。例如,它可以用于展示文件系统或公司组织结构图。

gui TreeDataGrid

该组件对应的 XML 名称: treeDataGrid

对于 TreeDataGrid,应该设置两个属性:dataContainer,它将 treeDataGrid 绑定到数据容器hierarchyProperty,它是引用同一实体的实体属性的名称。

在界面 XML 描述中定义的组件示例:

<data readOnly="true">
    <collection id="departmentsDc" class="com.company.sales.entity.Department" view="department-view">
        <loader id="departmentsDl">
            <query>
                <![CDATA[select e from sales_Department e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <treeDataGrid id="treeDataGrid" dataContainer="departmentsDc" hierarchyProperty="parentDept">
        <columns>
            <column id="name" property="name"/>
            <column id="parentDept" property="parentDept"/>
        </columns>
    </treeDataGrid>
</layout>

TreeDataGrid 的功能类似于简单的DataGrid


treeDataGrid 的属性

aggregatable - aggregationPosition - align - caption - captionAsHtml - colspan - columnResizeMode - columnsCollapsingAllowed - contextMenuEnabled - css - dataContainer - description - descriptionAsHtml - editorBuffered - editorCancelCaption - editorEnabled - editorSaveCaption - emptyStateLinkMessage - emptyStateMessage - enable - box.expandRatio - frozenColumnCount - headerVisible - height - hierarchyProperty - htmlSanitizerEnabled - icon - id - metaClass - reorderingAllowed - responsive - rowspan - selectionMode - settingsEnabled - showOrphans - sortable - stylename - tabIndex - textSelectionEnabled - visible - width

treeDataGrid 的元素

actions - buttonsPanel - columns - rowsCount

columns 的属性

includeAll - exclude

column 的属性

caption - collapsed - collapsible - collapsingToggleCaption - editable - expandRatio - id - maximumWidth - minimumWidth - property - resizable - sort - sortable - width

column 的元素

aggregation - checkBoxRenderer - componentRenderer - dateRenderer - formatter - iconRenderer - htmlRenderer - localDateRenderer - localDateTimeRenderer - numberRenderer - progressBarRenderer - textRenderer

aggregation 的属性

strategyClass - type - valueDescription

API

addGeneratedColumn - applySettings - createRenderer - edit - getAggregationResults - saveSettings - getColumns - setDescriptionProvider - addCellStyleProvider - setConverter - setDetailsGenerator - setEmptyStateLinkClickHandler - setEnterPressAction - setItemClickAction - setRenderer - setRowDescriptionProvider - addRowStyleProvider - sort

树形数据网格的监听器

ColumnCollapsingChangeListener - ColumnReorderListener - ColumnResizeListener - ContextClickListener - EditorCloseListener - EditorOpenListener - EditorPostCommitListener - EditorPreCommitListener - ItemClickListener - SelectionListener - SortListener

Predefined styles

borderless - no-horizontal-lines - no-vertical-lines - no-stripes


3.5.2.1.52. 树形表格

TreeTable 组件是在最左列显示一个"树结构"的具有层级关系的表格。这个组件用于具有自引用关系的实体。比如,文件系统或公司的组织结构图。

gui treeTable

组件的 XML 名称: treeTable

TreeTable 组件的 dataContainer 属性应该包含指向集合数据容器的引用。hierarchyProperty 属性定义实体的一个属性,此属性也指向相同的实体。

下面是一个在界面 XML 描述中定义 treeTable 组件的示例:

<data readOnly="true">
    <collection id="departmentsDc" class="com.company.sales.entity.Department" view="_local">
        <loader id="departmentsDl">
            <query>
                <![CDATA[select e from sales_Department e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <treeTable id="departmentsTable" dataContainer="departmentsDc" hierarchyProperty="parentDept" width="100%">
        <columns>
            <column id="name"/>
            <column id="active"/>
        </columns>
    </treeTable>
</layout>

TreeTable 的功能与简单的Table组件相似。



3.5.2.1.53. 双列

twinColumn 是用于选择多个值的两个列表组件。左边的列表包含可选的未选择值列表,右边的列表是已经选择的值列表。用户通过在左右两个列表移动值来进行选择或取消选择值,移动操作可通过双击或点击按钮完成。每个值都可以定义自己的展示样式和图标。

TwinColumn

组件的 XML 名称: twinColumn

下面是一个使用 twinColumn 组件选择实体实例的示例:

<data>
    <instance id="orderDc" class="com.company.sales.entity.Order" view="order-edit">
        <loader/>
        <collection id="productsDc" property="products"/>
    </instance>
    <collection id="allProductsDc" class="com.company.sales.entity.Product" view="_minimal">
        <loader>
            <query>
                <![CDATA[select e from sales_Product e]]>
            </query>
        </loader>
    </collection>
</data>
<layout>
    <twinColumn id="twinColumn"
                dataContainer="productsDc"
                property="name"
                optionsContainer="allProductsDc"/>
</layout>

在这个例子中,twinColumn 组件将显示 allProductsDc 数据容器中的 Product 实体的实例名称,它的 getValue() 方法返回选中实例的集合。

addAllBtnEnabled 属性用于配置组件是否显示在两列之间移动所有值的按钮。

columns 属性设置一行中可显示的字符数,rows 属性– 配置每个列表中的行数。

reorderable 属性设置在进行选择之后,这些条目是否需要重新排序。默认开启重新排序。此时,条目会在选择之后重新排序以符合数据源中元素的顺序。如果该属性是 false,条目会按照选择的顺序进行添加。

leftColumnCaptionrightColumnCaption 属性 – 分别配置左列和右列的名称。

列表中每个条目的外观可通过实现 TwinColumn.StyleProvider 接口来定义,可以为列表中每个实体实例返回样式名和图标路径。

可以使用 CheckBoxGroup 组件中描述的 setOptionsList()setOptionsMap()setOptionsEnum() 方法任意指定组件的选项列表。



3.5.2.2. 布局容器
3.5.2.2.1. 折叠布局

Accordion 是可折叠内容的容器,允许在隐藏和显示大量内容之间切换。

gui accordion

该组件的 XML 名称:accordion

界面 XML 描述中的折叠布局示例:

<accordion id="accordion" height="100%">
    <tab id="tabStamford" caption="msg://tabStamford" margin="true" spacing="true">
        <label value="msg://sampleStamford"/>
    </tab>
    <tab id="tabBoston" caption="msg://tabBoston" margin="true" spacing="true">
        <label value="msg://sampleBoston"/>
    </tab>
    <tab id="tabLondon" caption="msg://tabLondon" margin="true" spacing="true">
        <label value="msg://sampleLondon"/>
    </tab>
</accordion>

accordion 组件应包含用于描述标签页的 tab 元素。每个标签页都是一个容器,具有类似于 vbox 的垂直组件布局。如果应用程序界面的空间有限或标签页的标题太长而无法显示在 TabSheet 中,则可以使用 Accordion 容器。Accordion 具有平滑切换的动态效果。

tab 元素的属性:

  • id – 标签页标识符。请注意,标签页不是组件,它们的 ID 仅在 Accordion 内部使用,以便在控制器中引用标签页。

  • caption – 标签页标题。

  • icon – 指定一个主题目录中的图标地址或图标集中的图标名称。有关使用图标的推荐做法,请参阅图标

  • lazy – 设置标签内容延迟加载。

    在打开界面时,延迟加载的标签页不会立即加载其内容,这样可以减少内存中的组件数量。仅当用户选择标签页时,才会加载标签页中的组件。此外,如果延迟标签页中包含的可视化组件连接到带有加载器的数据容器,加载器也不会被触发。因此,界面可以更快地打开,只有当用户选中标签页请求数据时才会加载数据。

    请注意,刚打开界面时,延迟加载标签页上包含的组件并不存在。因此,这些组件不能被注入到控制器,也不能在控制器的 init() 方法中使用 getComponent() 方法来获取。只有在用户打开延迟加载的标签页后,才能访问其中的组件。可以使用 Accordion.SelectedTabChangeListener 处理标签页选中事件,例如:

    @Inject
    private Accordion accordion;
    
    private boolean tabInitialized;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        accordion.addSelectedTabChangeListener(selectedTabChangeEvent -> {
            if ("tabCambridge".equals(selectedTabChangeEvent.getSelectedTab().getName())) {
                initCambridgeTab();
            }
        });
    }
    
    private void initCambridgeTab() {
        if (tabInitialized) {
            return;
        }
        tabInitialized = true;
        (1)
    }
    1 初始化代码写在这。在这里使用 getComponentNN("comp_id") 获取延迟加载标签页上的组件。

    默认情况下,标签页是非延迟的,这表示当界面打开时标签页中的内容将立即被加载。

  • 在使用了基于 Halo 主题的 Web 客户端,可以通过将 borderless 预定义样式应用到 stylename 属性来移除 accordion 组件的边框和背景。

    accordion.setStyleName(HaloTheme.ACCORDION_BORDERLESS);

accordion 标签页可以包含其它可视化组件,比如网格、表格等:

<accordion id="accordion" height="100%" width="100%" enable="true">
    <tab id="tabNY" caption="msg://tabNY" margin="true" spacing="true">
        <table id="nYTable" width="100%">
            <columns>
                <column id="borough"/>
                <column id="county"/>
                <column id="population"/>
                <column id="square"/>
            </columns>
            <rows datasource="newYorkDs"/>
        </table>
    </tab>
</accordion>
gui accordion 2


3.5.2.2.2. 盒子布局

BoxLayout 是一个顺序排列组件的容器。

有三种类型的 BoxLayout,它们对应的 XML 元素如下:

  • hbox − 组件在水平方向顺序排列。

    gui hbox
    <hbox spacing="true" margin="true">
        <dateField dataContainer="orderDc" property="date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc"/>
        <textField dataContainer="orderDc" property="amount"/>
    </hbox>
  • vbox − 组件在垂直方向顺序排列。vbox 默认具有 100%的宽度。

    gui vbox
    <vbox spacing="true" margin="true">
        <dateField dataContainer="orderDc" property="date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc"/>
        <textField dataContainer="orderDc" property="amount"/>
    </vbox>
  • flowBox − 组件被水平排列在一行。如果一行中没有足够的空间,则排列不下的组件将显示在下一行中(行为类似于 Swing 的 FlowLayout)。

    gui flowbox
    <flowBox spacing="true" margin="true">
        <dateField dataContainer="orderDc" property="date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc"/>
        <textField dataContainer="orderDc" property="amount"/>
    </flowBox>

在基于 Halo 的主题的 Web 客户端中,BoxLayout 可用于创建更复杂的组合布局。 使用两个 Box 布局,一个 vbox 布局,设置 stylenamecardwell。里面嵌套一个 hbox 布局, 并为其设置属性 stylename="v-panel-caption" , 使用这个方法可以定义一个具有标题的面板,看起来像 Vaadin Panel

  • card 使布局看起来像卡片。

  • well 样式使卡片的外看起来带有下沉阴影效果。

gui boxlayout
<vbox stylename="well"
      height="200px"
      width="300px"
      expand="message"
      spacing="true">
    <hbox stylename="v-panel-caption"
          width="100%">
        <label value="Widget caption"/>
        <button align="MIDDLE_RIGHT"
                icon="font-icon:EXPAND"
                stylename="borderless-colored"/>
    </hbox>
    <textArea id="message"
              inputPrompt="Enter your message here..."
              width="280"
              align="MIDDLE_CENTER"/>
    <button caption="Send message"
            width="100%"/>
</vbox>

getComponent()方法允许通过索引获取 BoxLayout 的子组件:

Button button = (Button) hbox.getComponent(0);

可以在 BoxLayout 中使用键盘快捷键。使用 addShortcutAction() 方法设置快捷方式和要执行的操作:

flowBox.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
        notifications.create()
                .withCaption("SHIFT-A action")
                .show()
));


3.5.2.2.3. 按钮面板

ButtonsPanel 是一个容器,它简化了表格上用于数据管理的组件(通常是按钮)的使用和排列。

gui buttonsPanel

该组件的 XML 名称:buttonsPanel

界面 XML 描述中定义 ButtonsPanel 的示例:

<table id="customersTable" dataContainer="customersDc" width="100%">
    <actions>
        <action id="create" type="create"/>
        <action id="edit" type="edit"/>
        <action id="remove" type="remove"/>
        <action id="excel" type="excel"/>
    </actions>
    <columns>
        <column id="name"/>
        <column id="email"/>
    </columns>
    <rowsCount/>
    <buttonsPanel id="buttonsPanel" alwaysVisible="true">
        <button id="createBtn" action="customersTable.create"/>
        <button id="editBtn" action="customersTable.edit"/>
        <button id="removeBtn" action="customersTable.remove"/>
        <button id="excelBtn" action="customersTable.excel"/>
    </buttonsPanel>
</table>

buttonsPanel 元素可以位于 table 内,也可以位于界面的其它任何位置。

如果 buttonsPanel 位于 table 中,则它会与表格的 rowsCount 组件组合,从而可以在垂直方向上节省空间。此外,如果使用 Frame.openLookup() 方法打开查找界面(例如,从 PickerField 组件),这时按钮面板会变为隐藏状态。

在列表组件(表格树形表格分组表格数据网格树形数据网格)中,buttonsPanel 的标题不会展示,只展示列表组件的标题。

使用 Frame.openLookup() 打开查找界面时,可以利用 alwaysVisible 属性禁止隐藏按钮面板。如果属性值为 true,则不会隐藏按钮面板。默认情况下,属性值为 false

默认情况下,buttonsPanel 中的按钮是水平的放置并带有换行。如果一行中没有足够的空间,放不下的按钮会被放置在下一行。

可以修改这个默认的行为,将 buttonsPanel 内的按钮显示在一行:

  1. 创建 主题扩展 或者 自定义主题

  2. 定义 SCSS 变量 $cuba-buttonspanel-flow

    $cuba-buttonspanel-flow: false

可以使用 LayoutClickListener 接口拦截对 buttonsPanel 区域的点击。

可以在 ButtonsPanel 中使用键盘快捷键。使用 addShortcutAction() 方法设置快捷方式和要执行的操作:

buttonsPanel.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
        notifications.create()
                .withCaption("SHIFT-A action")
                .show()
));


3.5.2.2.4. CSS 布局

CssLayout 是一个容器,可以使用 CSS 完全控制这个容器里的组件的位置和样式。

该组件的 XML 名称: cssLayout

下面是使用 cssLayout 实现简单地响应式界面的示例。

在宽屏中显示组件:

gui cssLayout 1

在窄屏中显示组件:

gui cssLayout 2

界面的 XML 描述:

<cssLayout responsive="true" stylename="responsive-container" width="100%">
    <vbox margin="true" spacing="true" stylename="group-panel">
        <textField caption="Field One" width="100%"/>
        <textField caption="Field Two" width="100%"/>
        <button caption="Button"/>
    </vbox>
    <vbox margin="true" spacing="true" stylename="group-panel">
        <textField caption="Field Three" width="100%"/>
        <textField caption="Field Four" width="100%"/>
        <button caption="Button"/>
    </vbox>
</cssLayout>

modules/web/themes/halo/halo-ext.scss 文件的内容 (参考 扩展现有主题 创建这个文件):

/* Define your theme modifications inside next mixin */
@mixin halo-ext {
  @include halo;

  .responsive-container {
    &[width-range~="0-900px"] {
      .group-panel {
        width: 100% !important;
      }
    }

    &[width-range~="901px-"] {
      .group-panel {
        width: 50% !important;
      }
    }
  }
}
  • stylename 属性允许在 XML 描述或界面控制器中为 CssLayout 组件设置预定义样式。

    • v-component-group 样式用于创建组件分组,即一行无缝连接的组件:

      <cssLayout stylename="v-component-group">
          <textField inputPrompt="Search..."/>
          <button caption="OK"/>
      </cssLayout>
      gui cssLayout 3
    • well 样式使窗口的外看起来带有下沉阴影效果。

    • card 样式使布局看起来像卡片。与嵌套的具有属性 stylename="v-panel-caption" 的布局组合使用,可以创建复杂的组合布局,例如:

      <cssLayout height="300px"
                 stylename="card"
                 width="300px">
          <hbox stylename="v-panel-caption"
                width="100%">
              <label value="Widget caption"/>
              <button align="MIDDLE_RIGHT"
                      icon="font-icon:EXPAND"
                      stylename="borderless-colored"/>
          </hbox>
          <vbox height="100%">
              <label value="Panel content"/>
          </vbox>
      </cssLayout>

      效果如下:

      gui cssLayout 4


3.5.2.2.5. 框架

frame 元素用于在界面中引入框架

属性:

  • src − 指向一个框架 XML 描述的路径。

  • screen – 框架在screens.xml 中的标识符。(如果框架已注册)。

应定义其中一个属性。如果定义了两个属性,则根据 src 显式设置的文件加载框架。



3.5.2.2.6. 网格布局

GridLayout 容器将组件放置到网格中。

gui gridlayout

该组件的 XML 名称:grid

使用示例:

<grid spacing="true">
    <columns count="4"/>
    <rows>
        <row>
            <label value="Date" align="MIDDLE_LEFT"/>
            <dateField dataContainer="orderDc" property="date"/>
            <label value="Customer" align="MIDDLE_LEFT"/>
            <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc"/>
        </row>
        <row>
            <label value="Amount" align="MIDDLE_LEFT"/>
            <textField dataContainer="orderDc" property="amount"/>
        </row>
    </rows>
</grid>

grid 元素:

  • columns – 必须的元素,描述网格列。该元素需要有一个 count 属性或嵌套的 column 元素。

    在最简单的情况下,只须使用 count 属性设置列数即可。如果容器宽度以像素或百分比显式定义,则列宽度平均分配。

    要非均等地分配界面空间,应为每列定义具有 flex 属性的 column 元素。

    网格示例,其中第二列和第四列占用所有剩余的水平空间,第四列占用的空间是第二列的三倍:

    <grid spacing="true" width="100%">
        <columns>
            <column/>
            <column flex="1"/>
            <column/>
            <column flex="3"/>
        </columns>
        <rows>
            <row>
                <label value="Date"/>
                <dateField dataContainer="orderDc" property="date" width="100%"/>
                <label value="Customer"/>
                <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc" width="100%"/>
            </row>
            <row>
                <label value="Amount"/>
                <textField dataContainer="orderDc" property="amount" width="100%"/>
            </row>
        </rows>
    </grid>

    如果未定义 flex,或者设置为 0,则根据其内容设置列的宽度,此时需要至少有另一列设置了非零的 flex。在上面的示例中,第一列和第三列将根据最大文本长度设置宽度。

    如果需要展示剩余可用空间,整个容器宽度应设置为像素或百分比。否则,将根据内容长度计算列宽,并且 flex 属性不会起作用,也就看不到可用空间了。

  • rows − 必须的元素,包含一组行。每一行都使用自己的 row 元素定义。

    row 元素也可以有 flex 属性,与 column 的 flex 定义类似,影响具有给定总高度的网格的垂直可用空间的分布。

    row 元素应包含显示在网格当前行单元格中的组件元素。一行中的组件数量不应超过定义的列数,但可以比定义的列数少。 在 grid 容器中的任何组件都可以有 colspanrowspan 属性。这些属性设置相应组件占用的列数和行数。例如,下面就是将 Field3 字段扩展为包含三列的方式:

<grid spacing="true">
    <columns count="4"/>
    <rows>
        <row>
            <label value="Name 1"/>
            <textField/>
            <label value="Name 2"/>
            <textField/>
        </row>
        <row>
            <label value="Name 3"/>
            <textField colspan="3" width="100%"/>
        </row>
    </rows>
</grid>

这时,组件会按以下方式放置:

gui gridlayout colspan

可以使用 LayoutClickListener 接口拦截在 GridLayout 区域上的点击。

getComponent() 方法允许通过其列和行索引获取 GridLayout 的子组件:

Button button = (Button) gridLayout.getComponent(0,1);

可以在 GridLayout 中使用键盘快捷键。使用 addShortcutAction() 方法设置快捷方式和要执行的操作:

grid.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
        notifications.create()
                .withCaption("SHIFT-A action")
                .show()
));


3.5.2.2.7. 分组框布局

GroupBoxLayout 是一个容器,可以将一组组件框在一个区域并为它们设置一个整体的标题。另外,这个区域还可以折叠起来。

gui groupBox

该组件的 XML 名称:groupBox

下面是一个分组框布局的 XML 描述示例:

<groupBox caption="Order">
    <dateField dataContainer="orderDc" property="date" caption="Date"/>
    <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc" caption="Customer"/>
    <textField dataContainer="orderDc" property="amount" caption="Amount"/>
</groupBox>

groupBox 的属性:

  • caption – 分组标题。

  • orientation – 定义组件放置的方向 - 水平或垂直。默认值为 vertical(垂直)。

  • collapsable – 如果该值设置为 true,可以使用 gui_groupBox_minus/gui_groupBox_plus 按钮将组件的内容隐藏。

  • collapsed – 如果设置为 true,初始状态下组件内容会被折叠。collapsed 属性在 collapsable="true" 有效。

    下面是一个折叠的 GroupBox 的例子:

    gui groupBox collapsed

    可以通过 ExpandedStateChangeListener 接口获取 groupBox 组件的展开状态改变事件。

  • outerMargin - 设置 groupBox 边框的外边距。如果设置为 true,组件的所有边都会添加外边距。要单独设置每一边的外边距,请为 groupBox 的每一边设置 truefalse

    <groupBox outerMargin="true, false, true, false">

    如果 showAsPanel 属性设置为 true,则忽略 outerMargin 属性。

  • showAsPanel – 如果设置为 true,该组件看起来就会像 Vaadin Panel。默认值为 false

    gui groupBox Panel

默认情况下,groupBox 容器的宽是 100%,类似于vbox

在 Web 客户端中,可以使用 XML 描述或界面控制器中的 stylename 属性为 groupBox 组件设置预定义样式。以编程方式设置样式时,选择一个以 LAYOUT_GROUPBOX_ 为前缀的 HaloTheme 类常量。showAsPanel 属性设置为 true 时可以与以下样式结合使用:

  • borderless 样式删除 groupBox 的边框和背景颜色:

    groupBox.setShowAsPanel(true);
    groupBox.setStyleName(HaloTheme.GROUPBOX_PANEL_BORDERLESS);
  • well 样式会使容器具有下沉阴影效果:

    <groupBox caption="Well-styled groupBox"
              showAsPanel="true"
              stylename="well"
              width="300px"
              height="200px"/>
    gui groupBox Panel 2

groupBox 容器还有一个额外的预定义样式 - light。使用 light 样式的 Groupbox 只有上边框,如下图所示。

gui groupBox light

可以在 Groupbox 中使用快捷键。使用 addShortcutAction() 方法设置快捷方式和要执行的操作:

groupBox.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
        notifications.create()
                .withCaption("SHIFT-A action")
                .show()
));


3.5.2.2.8. HTML 盒子布局

HtmlBoxLayout 是一个可以在 HTML 模板中定义组件位置的容器。布局模板包含在一个主题中。

不要将 HtmlBoxLayout 用于动态内容或嵌入 JavaScript 代码,如需要的话,请使用 BrowserFrame

该组件的 XML 名称:htmlBox

下面是一个使用 htmlBox 的简单界面的例子。

gui htmlBox 1

界面的 XML 描述:

<htmlBox align="TOP_CENTER"
         template="sample"
         width="500px">
    <label id="logo"
           value="Subscribe"
           stylename="logo"/>
    <textField id="email"
               width="100%"
               inputPrompt="email@test.test"/>
    <button id="submit"
            width="100%"
            invoke="showMessage"
            caption="Subscribe"/>
</htmlBox>

htmlBox 的属性:

  • template 属性定义了一个位于主题的 layouts 子目录中的 HTML 文件的名称。在创建模板之前,应该创建主题扩展自定义主题

    例如,如果使用 Halo 主题并且 template 属性是 my_template, 那么模板文件应该是 modules/web/themes/halo/layouts/my_template.html

    HTML 模板的内容在 modules/web/themes/halo/layouts/sample.html 文件中:

    <div location="logo" class="logo"></div>
    <table class="component-container">
        <tr>
            <td>
                <div location="email" class="email"></div>
            </td>
            <td>
                <div location="submit" class="submit"></div>
            </td>
        </tr>
    </table>

    模板应包含带有 location 属性的 <div> 元素。这些元素将显示 XML 描述中定义的有相应标识符的 CUBA 组件。

    modules/web/themes/halo/com.company.application/halo-ext.scss 文件的内容如下(要创建文件请参阅 扩展现有主题 ):

    @mixin com_company_application-halo-ext {
      .email {
        width: 390px;
      }
    
      .submit {
        width: 100px;
      }
    
      .logo {
        font-size: 96px;
        text-transform: uppercase;
        margin-top: 50px;
      }
    
      .component-container {
        display: inline-block;
        vertical-align: top;
        width: 100%;
      }
    }
  • templateContents 属性设置了模板的内容,用于直接定义布局。

    例如:

    <htmlBox height="256px"
             width="400px">
        <templateContents>
            <![CDATA[
                <table align="center" cellspacing="10"
                       style="width: 100%; height: 100%; color: #fff; padding: 20px;    background: #31629E repeat-x">
                    <tr>
                        <td colspan="2"><h1 style="margin-top: 0;">Login</h1>
                        <td>
                    </tr>
                    <tr>
                        <td align="right">User&nbsp;name:</td>
                        <td>
                            <div location="username"></div>
                        </td>
                    </tr>
                    <tr>
                        <td align="right">Password:</td>
                        <td>
                            <div location="password"></div>
                        </td>
                    </tr>
                    <tr>
                        <td align="right" colspan="2">
                            <div location="okbutton" style="padding: 10px;"></div>
                        </td>
                    </tr>
                    <tr>
                        <td colspan="2" style="padding: 7px; background-color: #4172AE"><span
                                style="font-family: FontAwesome; margin-right: 5px;">&#xf05a;</span> This information is in the layout.
                        <td>
                    </tr>
                </table>
            ]]>
        </templateContents>
        <textField id="username"
                   width="100%"/>
        <textField id="password"
                   width="100%"/>
        <button id="okbutton"
                caption="Login"/>
    </htmlBox>
  • htmlSanitizerEnabled 属性可以启用或禁用 HTML 清理。如果 htmlSanitizerEnabled 设置为 true,则 HtmlBoxLayout 内容会清理成安全的 HTML。

    示例:

    protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
                "color=\"moccasin\">my</font> " +
                "<font size=\"7\">big</font> <sup>sphinx</sup> " +
                "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
                "red;\">quartz</span><svg/onload=alert(\"XSS\")>";
    
    @Inject
    private HtmlBoxLayout htmlBox;
    
    @Subscribe
    public void onInit(InitEvent event) {
        htmlBox.setHtmlSanitizerEnabled(true);
        htmlBox.setTemplateContents(UNSAFE_HTML);
    }

    htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。



3.5.2.2.9. layout

layout界面布局的根节点元素,是一个可以对组件进行垂直布局的容器,类似 vbox

layout 的属性:

  • spacing - 设置布局中各组件之间的留白空隙。

  • margin - 设置外边框和布局内容之间的缩进

  • expand - 设置布局内的一个组件使用组件摆放方向的所有可用空间。

  • responsive - 设置容器应当按照可用空间进行响应式更改。

  • stylename - 定义布局的一个样式名称。

  • height - 设置布局的高度。

  • width - 设置布局的宽度。

  • maxHeight - 设置窗口布局最大的 CSS 高度,比如 "640px""100%"

  • minHeight - 设置窗口布局最小的 CSS 高度,比如 "640px""100%"

  • maxWidth - 设置窗口布局最大的 CSS 宽度,比如 "640px""100%"

  • minWidth - 设置窗口布局最小的 CSS 宽度,比如 "640px""100%"

示例:

<layout minWidth="600px"
        minHeight="200px">
    <textArea width="800px"/>
</layout>
layout 1
Figure 15. 布局中不带滚动条的完整大小的 textArea
layout 2
Figure 16. 当窗口的大小小于布局的最小尺寸时,滚动条出现

这些属性在弹出对话框中也有效:

<dialogMode forceDialog="true"
            width="500"
            height="250"/>
<layout minWidth="600px"
        minHeight="200px">
    <textArea width="250px"/>
</layout>
layout 3
Figure 17. 对话框模式,当窗口的大小小于布局的最小尺寸时,滚动条出现
3.5.2.2.10. 滚动盒子布局

ScrollBoxLayout − 一个支持内容滚动的容器。

gui scrollBox

该组件的 XML 名称: scrollBox

下面是一个 XML 描述示例:

<groupBox caption="Order" width="300" height="170">
    <scrollBox width="100%" height="100%" spacing="true" margin="true">
        <dateField dataContainer="orderDc" property="date" caption="Date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc" caption="Customer"/>
        <textField dataContainer="orderDc" property="amount" caption="Amount"/>
    </scrollBox>
</groupBox>
  • 组件排列的方向可以通过 orientation 属性定义 ,可选值: horizontalvertical。默认为 vertical

  • scrollBars 属性可以配置滚动条。它的值可以是 horizontal 或者 vertical - 分别用于水平滚动和垂直滚动,both - 两个方向都有滚动条。将值设置为 none 禁止向任何方向的滚动。

  • contentHeight - 设置内容高度。

  • contentWidth - 设置内容宽度。

  • contentMaxHeight - 设置内容的最大 CSS 高度,例如,"640px""100%"

  • contentMinHeight - 设置内容的最小 CSS 高度,例如,"640px""auto"

  • contentMaxWidth - 设置内容的最大 CSS 宽度,例如,"640px""100%"

  • contentMinWidth - 设置内容的最小 CSS 宽度,例如,"640px""auto"

<layout>
    <scrollBox contentMinWidth="600px"
               contentMinHeight="200px"
               height="100%"
               width="100%">
        <textArea height="150px"
                  width="800px"/>
    </scrollBox>
</layout>
gui scrollBox 1
Figure 18. 带有 textArea 的显示完整的 scrollBox
gui scrollBox 2
Figure 19. 窗口尺寸调整时滚动条出现,管理内容的宽度

建议设置内容的高和宽,否则,放置在 scrollBox 中的组件只能有固定大小或默认大小。

如果没有设置内容高和宽,不要给嵌套组件设置 height="100%"width="100%" 属性。

可以在 ScrollBox 中使用快捷键。使用 addShortcutAction() 方法设置快捷键和要执行的操作:

scrollBox.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
        notifications.create()
                .withCaption("SHIFT-A action")
                .show()
));


3.5.2.2.11. 分隔面板

SplitPanel − 由可移动的分隔条分隔成两个区域的容器。

gui splitPanel

该组件的 XML 名称:split

下面是一个分隔面板的 XML 描述示例:

<split orientation="horizontal" pos="30" width="100%" height="100%">
    <vbox margin="true" spacing="true">
        <dateField dataContainer="orderDc" property="date" caption="Date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc" caption="Customer"/>
    </vbox>
    <vbox margin="true" spacing="true">
        <textField dataContainer="orderDc" property="amount" caption="Amount"/>
    </vbox>
</split>

split 容器必须包含两个嵌套的容器或组件。它们将显示在分隔条的两侧。

split 的属性:

  • dockable - 启用或禁用 SplitPanel 停靠按钮,默认值为 false

    gui SplitPanel dockable

    停靠功能仅适用于水平方向的 SplitPanel

  • dockMode - 定义停靠方向。可以使用 LEFTRIGHT 作为值。

    <split orientation="horizontal"
           dockable="true"
           dockMode="RIGHT">
        ...
    </split>
  • minSplitPositionmaxSplitPosition - 可以通过像素或百分比来定义分割条的可用位置范围。

    如下所示,可以限制分隔条从组件左侧移动 100 到 300 像素:

    <split id="splitPanel" maxSplitPosition="300px" minSplitPosition="100px" width="100%" height="100%">
        <vbox margin="true" spacing="true">
            <button caption="Button 1"/>
            <button caption="Button 2"/>
        </vbox>
        <vbox margin="true" spacing="true">
            <button caption="Button 4"/>
            <button caption="Button 5"/>
        </vbox>
    </split>

    如果想以编程方式来设置范围,请使用 Component.UNITS_PIXELSComponent.UNITS_PERCENTAGE 来指定值的单位:

    splitPanel.setMinSplitPosition(100, Component.UNITS_PIXELS);
    splitPanel.setMaxSplitPosition(300, Component.UNITS_PIXELS);
  • orientation – 定义组件方向。horizontal- 嵌套的组件水平放置,vertical- 嵌套的组件垂直放置。

  • pos – 整数,定义第一个组件区域与第二个组件区域的百分比。例如,pos="30" 表示区域比例为 30/70。默认情况下,区域比例为 50/50。

  • reversePosition - 定义从组件的另一侧指定分隔条的 pos 属性。

  • 如果 locked 属性设置为 true,则用户无法更改分隔条位置。

  • 带有 large 值的 stylename 属性会使分隔条变地宽一点。

    split.setStyleName(HaloTheme.SPLITPANEL_LARGE);

SplitPanel 的方法:

  • 可以使用 getSplitPosition() 方法来获取分隔条的位置。

  • 可以使用 PositionUpdateListener() 方法来获取分隔条移动事件。可以使用 isUserOriginated() 方法来跟踪 SplitPositionChangeEvent 的来源。

  • 如果需要获取分隔条位置的单位,请使用 getSplitPositionUnit() 方法。返回值为 Component.UNITS_PIXELSComponent.UNITS_PERCENTAGE

  • 如果从组件的另一侧设置位置,那么 isSplitPositionReversed() 方法会返回 true

  • getMinSplitPosition()getMaxSplitPosition() 方法分别返回当前分隔条位置的最小最大值。

  • getMinSplitPositionSizeUnit()getMaxSplitPositionSizeUnit() 方法分别返回分隔条位置的最小最大值的单位。可能单位是:Component.UNITS_PIXELSComponent.UNITS_PERCENTAGE

SplitPanel 的展示可以使用带 $cuba-splitpanel-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.2.12. 标签页面板

TabSheet 容器是一个标签页面板。该面板一次只显示一个标签页的内容。

gui tabsheet

该组件的 XML 名称:tabSheet

下面是一个标签页面板的 XML 描述示例:

<tabSheet>
    <tab id="mainTab" caption="Tab1" margin="true" spacing="true">
        <dateField dataContainer="orderDc" property="date" caption="Date"/>
        <lookupField dataContainer="orderDc" property="customer" optionsContainer="customersDc" caption="Customer"/>
    </tab>
    <tab id="additionalTab" caption="Tab2" margin="true" spacing="true">
        <textField dataContainer="orderDc" property="amount" caption="Amount"/>
    </tab>
</tabSheet>

tabSheet 的 description 属性定义了一个提示信息,当用户将光标悬停在标签页区域上或单击标签页区域时,提示信息会显示在弹出窗口中。

gui tabsheet description

tabSheet 组件应该包含 tab 元素来描述标签页。每个标签页都是一个具有类似于 vbox 的垂直组件布局的容器。

tab 元素属性:

  • id – 标签页标识符。请注意,标签页不是组件,它们的 ID 仅在 TabSheet 中使用,以便在控制器中引用标签页。

  • caption – 标签页标题。

  • description - 提示文本,当用户将光标悬停在具体标签页上或单击具体标签页时,提示文本会在弹出窗口中显示。

    gui tabsheet tab description
  • closable - 定义是否显示用于关闭标签页的 x 按钮。默认值为 false

  • icon - 定义一个主题目录中的图标位置或图标集中的图标名称。有关使用图标的详细信息,请参阅图标

  • lazy – 设置标签页内容延迟加载。

    当界面打开时,延迟标签页不会加载其内容,这样可以减少内存中的组件数量。只有当用户选择某个标签页时,才会加载标签页中的组件。另外,如果延迟标签页包含使用加载器的数据容器的可视化组件,加载器不会被触发。因此,界面会打开得更快,并且只有当用户通过选择标签页请求数据时才会加载其中的数据。

    请注意,当界面打开时,在延迟标签页上的组件是不存在的。因此,它们不能注入到控制器中,并且不能通过调用控制器的 init() 方法中 getComponent() 方法来获得。只有在用户打开标签页后才能访问延迟标签页中的组件。可以使用 TabSheet.SelectedTabChangeListener 拦截这个操作,例如:

    @Inject
    private TabSheet tabSheet;
    
    private boolean detailsInitialized, historyInitialized;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        tabSheet.addSelectedTabChangeListener(selectedTabChangeEvent -> {
            if ("detailsTab".equals(selectedTabChangeEvent.getSelectedTab().getName())) {
                initDetails();
            } else if ("historyTab".equals(selectedTabChangeEvent.getSelectedTab().getName())) {
                initHistory();
            }
        });
    }
    
    private void initDetails() {
        if (detailsInitialized) {
            return;
        }
        detailsInitialized = true; (1)
    }
    
    private void initHistory() {
        if (historyInitialized) {
            return;
        }
        historyInitialized = true; (2)
    }
    1 在这里使用 getComponentNN("comp_id") 方法获取标签上的组件
    2 在这里使用 getComponentNN("comp_id") 方法获取标签上的组件

    默认情况下,标签页不是 lazy 延迟加载,在界面打开时就会加载所有内容。

    可以使用 isUserOriginated() 方法来跟踪 SelectedTabChangeEvent 事件的来源。

    标签页布局样式

    在具有 Halo-based 主题的 Web 客户端中,可以使用 XML 描述或界面控制器中的 stylename 属性为 TabSheet 容器设置预定义样式:

    <tabSheet stylename="framed">
        <tab id="mainTab" caption="Framed tab"/>
    </tabSheet>

    当以编程方式设置样式时,请使用 HaloTheme 类中的以 TABSHEET_ 为前缀的常量:

    tabSheet.setStyleName(HaloTheme.TABSHEET_COMPACT_TABBAR);
    • centered-tabs - 使得标签页在标签栏内居中。如果所有标签页完全适合标签栏(即没有标签栏滚动),效果最佳。

    • compact-tabbar - 减少标签栏中标签页周围的空白。

    • equal-width-tabs - 为标签栏中的所有标签页提供相等的宽度(即所有标签页的展开比例都为 1)。如果标签页标题不适合标签页会被缩短。应用此样式时标签页滚动将会被禁用(所有标签页将同时显示)。

    • framed - 在整个组件周围以及标签页栏中的各个标签页周围添加边框。

    • icons-on-top - 在标签页标题上显示标签页图标(默认情况下,图标位于标题的左侧)。

    • only-selected-closeable - 只有选中的标签页显示关闭按钮。不会阻止以编程方式关闭标签页,它仅仅是对用户隐藏了关闭按钮。

    • padded-tabbar - 为标签栏中的标签页周围添加少量内边距,以便它们不会紧挨着组件的外边缘。

TabSheet 的展示可以使用带 $cuba-tabsheet-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。



3.5.2.3. 界面布局规则

下面的章节介绍如何在界面上正确放置可视化组件和容器。

3.5.2.3.1. 组件位置
尺寸类型

组件的大小:widthheight,可以是以下几种类型:

  • 基于内容 - AUTO

  • 固定值(像素) - 10px

  • 相对值(百分比) - 100%

screen layout rules 1
适应内容的尺寸

组件将占用足够的空间以适应其内容。

例如:

  • 对于 Label,大小由文本长度确定。

  • 对于容器,大小由容器内所有组件的尺寸总和确定。

XML
<label width=AUTO/>
Java
label.setWidth(Component.AUTO_SIZE);

根据内容调整尺寸的组件将在界面布局初始化期间或内容尺寸更改时调整其尺寸。

screen layout rules 2
固定大小

固定大小表示组件的尺寸在运行时不会改变。

XML
<vbox width=320px height=240px/>
Java
vbox.setWidth(320px);
screen layout rules 3
相对大小

相对大小表示组件将按可用空间百分比来占用空间。

XML
<label width=100%/>
Java
label.setWidth(50%);

使用相对尺寸的组件会响应可用空间大小的变化,在界面上调整其实际大小。

screen layout rules 4
容器特性

默认情况下,没有 expand 属性的容器为所有内部嵌套的组件提供相等的空间。除了:flowBoxhtmlBox

例如:

<layout>
    <button caption="Button"/>
    <button caption="Button"/>
</layout>
screen layout rules 7

默认情况下,组件和容器的宽度和高度取决于其中的内容。一些容器有不同的默认尺寸:

容器

VBox

100%

AUTO

GroupBox

100%

AUTO

FlowBox

100%

AUTO

layout 元素是一个垂直布局的容器(VBox),它的宽度和高度都是 100%。弹窗模式下的高度可以是 AUTO

TabSheet 中的标签页是 VBox 容器。

GroupBox 组件包含 VBoxHBox,具体取决于其 orientation 属性值。

自适应大小的容器示例:

<layout>
    <vbox>
        <button caption="Button"/>
        <button caption="Button"/>
    </vbox>
</layout>
screen layout rules 8

具有相对尺寸的容器示例:

<layout spacing="true">
    <groupBox caption="GroupBox"
              height="100%"/>
    <button caption="Button"/>
</layout>
screen layout rules 9

这里,layout,以及 vbox ( 或 hbox ),为所有内部嵌套组件提供相等的空间,groupBox 的高度为 100%。除此之外,groupBox 的宽度默认为 100%并占用所有可用空间。

组件特性

建议为 TableTree 设置绝对高度或相对高度。否则,如果行或节点太多,表和树会无限大。

ScrollBox 必须具有固定或相对的(而不是 AUTO)宽度和高度。SrcollBox 内的组件,如果放置在滚动方向上,则不能有相对尺寸。

以下示例展示了水平和垂直 ScrollBox 容器的正确用法。如果两个方向都需要滚动,则必须为组件设置 heightwidth(AUTO 或绝对值)。

screen layout rules 5
扩展(expand)选项

容器的 expand 属性用来指定会被赋于最大可用空间的组件。

指定为 expand 的组件在组件扩展方向上(对于 VBox 是垂直方向,对于 HBox 是水平方向)会占用其容器的所有剩余空间。更改容器大小时,这种组件会相应地调整自身大小。

<vbox expand="bigBox">
    <vbox id="bigBox"/>
    <label value="Label"/>
</vbox>
screen layout rules 6

expand 在对组件的扩展上也只是相对有效,例如,下面示例中宽度固定的 groupBox 不能横向扩展:

<layout spacing="true"
        expand="groupBox">
    <groupBox id="groupBox"
              caption="GroupBox"
              width="200px"/>
    <button caption="Button"/>
</layout>
screen layout rules 10

在下面示例中,使用了一个起辅助作用的 Label(spacer)元素。由于将其指定为 expand,所以这个空标签占用了容器中剩余的所有空间。

<layout expand="spacer">
    <textField caption="Number"/>
    <dateField caption="Date"/>
    <label id="spacer"/>
    <hbox spacing="true">
        <button caption="OK"/>
        <button caption="Cancel"/>
    </hbox>
</layout>
screen layout rules 11
3.5.2.3.2. 外边距和间距
界面边框的外边距

margin 属性允许在容器边框和嵌套组件之间设置边距。

如果 margin 设置为 true,则容器所有的边都会有边距。

<layout>
    <vbox margin="true" height="100%">
        <groupBox caption="Group"
                  height="100%"/>
    </vbox>
    <groupBox caption="Group"
              height="100%"/>
</layout>
screen layout rules 12

也可以单独为每个的边(上、右、下、左)设置边距。为顶部和底部启用外边距的示例:

<vbox margin="true,false,true,false">
组件之间的间距

spacing 属性表明是否应在容器扩展方向上的嵌套组件之间添加间距。

screen layout rules 13

在某些嵌套组件变得不可见的情况下,间距也会正常工作,所以不要使用 margin 来模拟间距。

<layout spacing="true">
    <button caption="Button"/>
    <button caption="Button"/>
    <button caption="Button"/>
    <button caption="Button"/>
</layout>
screen layout rules 14
3.5.2.3.3. 对齐
容器内的组件对齐

使用 align 属性来对齐容器内的组件。

比如,下面的示例中标签(label)位于容器的中心:

<vbox height="100%">
    <label align="MIDDLE_CENTER"
           value="Label"/>
</vbox>
screen layout rules 15

指定了对齐方式的组件在对齐方向上不应设置 100%的大小。容器会提供比组件所需空间更多的空间。组件将在此空间内对齐。

在可用空间内的对齐示例:

<layout>
    <groupBox height="100%"
              caption="Group"/>
    <label align="MIDDLE_CENTER"
           value="Label"/>
</layout>
screen layout rules 16
3.5.2.3.4. 常见的布局错误
常见错误 1. 为自适应尺寸(根据内容)的容器内的组件设置相对尺寸

具有相对尺寸的错误布局示例:

screen layout rules 17

在此示例中,label 具有 100%的高度,而 VBox 的默认高度是 AUTO,即基于内容自适应。

使用 expand 的错误布局示例:

screen layout rules 18

Expand 隐式将标签设置为 100%的相对高度,与上面的示例一样,这种做法不正确。 在这种情况下,界面可能看起来不像预期的那样。某些组件可能会消失或大小为零。如果遇到一些奇怪的布局问题,请首先检查是否正确指定了相对尺寸。

常见错误 2. 给 ScrollBox 中的组件指定了 100%的尺寸

错误布局示例:

screen layout rules 19

由于这样的错误,即使嵌套组件的大小超过滚动区域,ScrollBox 中的滚动条也不会出现。

screen layout rules 20
常见错误 3. 没有足够空间情况下的组件对齐

错误布局的示例:

screen layout rules 21

在此示例中,HBox 根据内容自适应大小,因此标签对齐无效。

screen layout rules 22
3.5.2.4. 其它

本章介绍了跟可视化组件相关的一些通用组件。

3.5.2.4.1. UiComponents

UiComponents 是个工厂类,可以使用该类按名称、类或者类型标识创建 UI 组件。

如果创建关联数据的组件,使用类型标识执行特定值类型来对组件进行参数化。比如对于 LabelTextField 或者 DateField 组件,使用类型标识 TextField.TYPE_INTEGER。当创建关联到实体的组件,比如 PickerFieldLookupField 或者 Table,使用静态的 of() 方法来获取合适的类型标识。对于其它组件和容器,使用组件类作为参数。

示例:

@Inject
private UiComponents uiComponents;

@Subscribe
protected void onInit(InitEvent event) {
    // components working with simple data types
    Label<String> label = uiComponents.create(Label.TYPE_STRING);
    TextField<Integer> amountField = uiComponents.create(TextField.TYPE_INTEGER);
    LookupField<String> stringLookupField = uiComponents.create(LookupField.TYPE_STRING);

    // components working with entities
    LookupField<Customer> customerLookupField = uiComponents.create(LookupField.of(Customer.class));
    PickerField<Customer> pickerField = uiComponents.create(PickerField.of(Customer.class));
    Table<OrderLine> table = uiComponents.create(Table.of(OrderLine.class));

    // other components and containers
    Button okButton = uiComponents.create(Button.class);
    VBoxLayout vBox = uiComponents.create(VBoxLayout.class);

    // ...
}
3.5.2.4.2. 格式化控件

格式化控件只能跟只读组件一起用,比如LabelTable Column等等。对于可编辑的组件值,比如TextField 应该用 Datatype 机制来格式化。

在界面的 XML 描述中,组件的格式化控件可以在嵌套的 formatter 元素中定义。这个元素有一个单一的属性:

  • class − 实现了 com.haulmont.cuba.gui.components.Formatter 接口的一个类。

如果格式化控件的构造函数中有 org.dom4j.Element 的参数,那么这个格式化控件可以接受额外的属性来描述此格式化控件。比如,可以用格式化的字符串作为这个额外的参数。CUBA 框架里的 DateFormatterNumberFormatter 这两个类就可以从 format 属性读取格式化模板:

<column id="date">
    <formatter class="com.haulmont.cuba.gui.components.formatters.DateFormatter" format="yyyy-MM-dd HH:mm:ss"/>
</column>

另外,DateFormatter 类也能识别 type 属性,这个属性可以有 DATEDATETIME 两个值。如果用了这个属性,其实就是使用 Datatype 机制的 dateFormat 或者 dateTimeFormat 来做格式化,比如:

<column id="endDate">
    <formatter class="com.haulmont.cuba.gui.components.formatters.DateFormatter" type="DATE"/>
</column>

默认情况下,DateFormatter 用服务器的时区来显示日期和时间。如果需要使用用户的时区来显示,设置格式化控件的 useUserTimezone 属性为 true

如果格式化控件对应的类是一个内部类,那么这个类需要声明成 static 的,在 XML 描述中,需要用 "$" 符号来分隔包和内部类。比如:

<formatter class="com.sample.sales.gui.OrderBrowse$CurrencyFormatter"/>

格式化控件除了可以通过 XML 描述来分配给组件之外,也可以通过编程的方式实现 - 在组件的 setFormatter() 方法里设置一个格式化类的实例。

下面这个例子是声明一个自定义的格式化控件类,然后在表格的某一列里面使用:

public class CurrencyFormatter implements Function<BigDecimal, String> {

    @Override
    public String apply(BigDecimal bigDecimal) {
        return NumberFormat.getCurrencyInstance(Locale.getDefault()).format(bigDecimal);
    }
}
@Inject
private GroupTable<Order> ordersTable;

@Subscribe
public void onInit(InitEvent event) {
    Function currencyFormatter = new CurrencyFormatter();
    ordersTable.getColumn("totalPrice").setFormatter(currencyFormatter);
}
3.5.2.4.3. 展示设置

展示设置机制允许用户管理表格(table)的列宽、排序方式等外观属性。

gui presentations

用户通过展示设置可以:

  • 使用唯一的名称保存展示设置。表格的设置会自动保存在当前活动的展示设置中。

  • 编辑和删除展示设置。

  • 在不同设置之间切换。

  • 指定一个默认展示设置,当界面打开的时候会用这个设置来显示表格。

  • 创建全局的展示设置,对所有用户可见。如果需要创建、修改或者删除全局展示设置,此用户需要有 cuba.gui.presentations.global 安全权限

实现了 com.haulmont.cuba.gui.components.Component.HasPresentations 接口的组件都可以使用展示设置。这些组件是:

3.5.2.4.4. 验证器控件

Validator 设计用来检查可视化组件中输入的值。

验证和输入检查是需要区分开来的,输入检查是说:假设一个文本组件(比如,TextField)的数据类型设置的不是字符串(这种情况可能出现在绑定实体属性或者手动设置控件的 datatype ),那么这个组件会阻止用户输入不符合它定义的数据类型的值。当这个组件失去焦点时或者用户按了 回车,会显示验证错误信息。

验证不会在输入同时或者失去焦点时马上反馈,而是会在组件的 validate() 方法调用的时候。也就是说这个组件(还有这个组件关联的实体属性)暂时会包含一个可能并不符合验证规则的值。但是这没关系,因为需要验证的字段一般都会在编辑界面,所有的字段提交前会自动调用验证方法。如果组件不在一个编辑界面,那么这个组件的 validate() 方法需要在界面控制器显式的调用。

CUBA 框架包含了一组最常用的验证器实现,可以直接在项目中使用:

在界面的 XML 描述中,组件的验证器可以在嵌套的 validators 元素中定义。

可以通过 CUBA Studio 添加验证器。下面是给 TextField 组件添加验证器的例子:

gui validator

每个验证器都是一个 Prototype Bean,如果希望在 Java 代码中使用验证器,需要通过 BeanLocator 来获取。

有些验证器在错误消息中使用了 Groovy 字符串。这样的话,可以给错误消息传递参数(比如,$value)。这些参数会考虑用户的 locale 配置。

你可以使用自定义的 Java 类作为验证器,自定义类需要实现 Consumer 接口。

在界面的 XML 描述中,自定义的验证器可以在嵌套的 validator 元素中定义。

如果验证器是作为内部类实现的话,则需要使用 static 进行声明,然后在 XML 中类名用 "$" 分隔,示例:

<validator class="com.sample.sales.gui.AddressEdit$ZipValidator"/>

给组件设置验证类的方法除了 XML 描述之外,也可以使用编程的方式 - 使用组件的 addValidator() 方法添加验证器的实例。

创建验证邮编的验证器类:

public class ZipValidator implements Consumer<String> {
    @Override
    public void accept(String s) throws ValidationException {
        if (s != null && s.length() != 6)
            throw new ValidationException("Zip must be of 6 characters length");
    }
}

TextField组件中使用邮编验证器的示例:

<textField id="zipField" property="zip">
    <validator class="com.company.sample.web.ZipValidator"/>
</textField>

在界面控制器用编程的方式设置验证器:

zipField.addValidator(value -> {
    if (value != null && value.length() != 6)
        throw new ValidationException("Zip must be of 6 characters length");
});

下面我们看看框架预定义的这些验证器。

DecimalMaxValidator

检查值小于等于指定的最大值。 支持的类型:BigDecimalBigIntegerLongInteger 以及使用当前 locale 表示 BigDecimalString 类型。

它有如下属性:

  • value − 最大值(必须);

  • inclusive − 当设置成 true 时,值应当小于或等于指定的最大值。默认值是 true

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$max 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.decimalMaxInclusive

  • validation.constraints.decimalMax

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <decimalMax value="1000" inclusive="false" message="Value '$value' cannot be greater than `$max`"/>
    </validators>
</textField>

Java 代码用法:

DecimalMaxValidator maxValidator = beanLocator.getPrototype(DecimalMaxValidator.NAME, new BigDecimal(1000));
numberField.addValidator(maxValidator);
DecimalMinValidator

检查值大于等于指定的最小值。 支持的类型:BigDecimalBigIntegerLongInteger 以及使用当前 locale 表示 BigDecimalString 类型。

它有如下属性:

  • value − 最小值(必须);

  • inclusive − 当设置成 true 时,值应当大于或等于指定的最小值。默认值是 true

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$min 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.decimalMinInclusive

  • validation.constraints.decimalMin

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <decimalMin value="100" inclusive="false" message="Value '$value' cannot be less than `$min`"/>
    </validators>
</textField>

Java 代码用法:

DecimalMinValidator minValidator = beanLocator.getPrototype(DecimalMinValidator.NAME, new BigDecimal(100));
numberField.addValidator(minValidator);
DigitsValidator

检查值是一个指定范围内的数字。 支持的类型:BigDecimalBigIntegerLongInteger 以及使用当前 locale 表示 BigDecimalString 类型。

它有如下属性:

  • integer − 整数部分数字的个数(必须);

  • fraction − 小数部分数字的个数(必须);

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$integer$fraction 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.digits

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <digits integer="3" fraction="2" message="Value '$value' is out of bounds ($integer digits are expected in integer part and $fraction in fractional part)"/>
    </validators>
</textField>

Java 代码用法:

DigitsValidator digitsValidator = beanLocator.getPrototype(DigitsValidator.NAME, 3, 2);
numberField.addValidator(digitsValidator);
DoubleMaxValidator

检查值是否小于或等于指定的最大值。 支持的类型:Double 以及表示 Double 值的 `String`类型(使用当前 locale)。

它有如下属性:

  • value − 最大值(必须);

  • inclusive − 当设置为 true 时,值需要小于等于指定的最大值。默认值是 true

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$max 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.decimalMaxInclusive

  • validation.constraints.decimalMax

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <doubleMax value="1000" inclusive="false" message="Value '$value' cannot be greater than `$max`"/>
    </validators>
</textField>

Java 代码用法:

DoubleMaxValidator maxValidator = beanLocator.getPrototype(DoubleMaxValidator.NAME, new Double(1000));
numberField.addValidator(maxValidator);
DoubleMinValidator

检查值是否大于或等于指定的最大值。 支持的类型:Double 以及表示 Double 值的 `String`类型(使用当前 locale)。

它有如下属性:

  • value − 最小值(必须);

  • inclusive − 当设置为 true 时,值需要大于等于指定的最大值。默认值是 true

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$min 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.decimalMinInclusive

  • validation.constraints.decimalMin

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <doubleMin value="100" inclusive="false" message="Value '$value' cannot be less than `$min`"/>
    </validators>
</textField>

Java 代码用法:

DoubleMinValidator minValidator = beanLocator.getPrototype(DoubleMinValidator.NAME, new Double(100));
numberField.addValidator(minValidator);
FutureOrPresentValidator

检查日期或时间是否在将来或者现在。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。 支持的类型:java.util.DateLocalDateLocalDateTimeLocalTimeOffsetDateTimeOffsetTime

它有如下属性:

  • checkSeconds − 当设置成 true 时,验证器需要使用秒和毫秒比较日期或者时间。默认值是 false

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.futureOrPresent

XML 描述中用法:

<dateField id="dateTimePropertyField" property="dateTimeProperty">
    <validators>
        <futureOrPresent checkSeconds="true"/>
    </validators>
</dateField>

Java 代码用法:

FutureOrPresentValidator futureOrPresentValidator = beanLocator.getPrototype(FutureOrPresentValidator.NAME);
dateField.addValidator(futureOrPresentValidator);
FutureValidator

它验证时间或者日期必须在将来。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。 支持的类型:java.util.DateLocalDateLocalDateTimeLocalTimeOffsetDateTimeOffsetTime

它有如下属性:

  • checkSeconds − 当设置成 true 时,验证器需要使用秒和毫秒比较日期或者时间。默认值是 false

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.future

XML 描述中用法:

<timeField id="localTimeField" property="localTimeProperty" showSeconds="true">
    <validators>
        <future checkSeconds="true"/>
    </validators>
</timeField>

Java 代码用法:

FutureValidator futureValidator = beanLocator.getPrototype(FutureValidator.NAME);
timeField.addValidator(futureValidator);
MaxValidator

检查值小于或等于指定的最大值。 支持的类型:BigDecimalBigIntegerLongInteger

它有如下属性:

  • value − 最大值(必须);

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$max 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.max

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <max value="20500" message="Value '$value' must be less than or equal to '$max'"/>
    </validators>
</textField>

Java 代码用法:

MaxValidator maxValidator = beanLocator.getPrototype(MaxValidator.NAME, 20500);
numberField.addValidator(maxValidator);
MinValidator

检查值大于等于指定的最小值。 支持的类型:BigDecimalBigIntegerLongInteger

它有如下属性:

  • value − 最小值(必须);

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value$min 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.min

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <min value="30" message="Value '$value' must be greater than or equal to '$min'"/>
    </validators>
</textField>

Java 代码用法:

MinValidator minValidator = beanLocator.getPrototype(MinValidator.NAME, 30);
numberField.addValidator(minValidator);
NegativeOrZeroValidator

检查值小于等于 0。 支持的类型:BigDecimalBigIntegerLongIntegerDoubleFloat

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出。注意,Float 并没有它自己的数据类型,不会使用用户的 locale 进行格式化。

默认消息键值:

  • validation.constraints.negativeOrZero

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <negativeOrZero message="Value '$value' must be less than or equal to 0"/>
    </validators>
</textField>

Java 代码用法:

NegativeOrZeroValidator negativeOrZeroValidator = beanLocator.getPrototype(NegativeOrZeroValidator.NAME);
numberField.addValidator(negativeOrZeroValidator);
NegativeValidator

检查值严格小于 0。 支持的类型:BigDecimalBigIntegerLongIntegerDoubleFloat

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出。注意,Float 并没有它自己的数据类型,不会使用用户的 locale 进行格式化。

默认消息键值:

  • validation.constraints.negative

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <negative message="Value '$value' should be less than 0"/>
    </validators>
</textField>

Java 代码用法:

NegativeValidator negativeValidator = beanLocator.getPrototype(NegativeValidator.NAME);
numberField.addValidator(negativeValidator);
NotBlankValidator

检查值至少包含一个非空字符。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。 支持的类型:String

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.notBlank

XML 描述中用法:

<textField id="textField" property="textProperty">
    <validators>
        <notBlank message="Value must contain at least one non-whitespace character"/>
    </validators>
</textField>

Java 代码用法:

NotBlankValidator notBlankValidator = beanLocator.getPrototype(NotBlankValidator.NAME);
textField.addValidator(notBlankValidator);
NotEmptyValidator

检查值不是 null 也非空。 支持的类型:CollectionString

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出,只对 String 类型有效。

默认消息键值:

  • validation.constraints.notEmpty

XML 描述中用法:

<textField id="textField" property="textProperty">
    <validators>
        <notBlank message="Value must contain at least one non-whitespace character"/>
    </validators>
</textField>

Java 代码用法:

NotBlankValidator notBlankValidator = beanLocator.getPrototype(NotBlankValidator.NAME);
textField.addValidator(notBlankValidator);
NotNullValidator

检查值不是 null。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.notNull

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <notNull/>
    </validators>
</textField>

Java 代码用法:

NotNullValidator notNullValidator = beanLocator.getPrototype(NotNullValidator.NAME);
numberField.addValidator(notNullValidator);
PastOrPresentValidator

检查时间或者日期在过去或者现在。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。 支持的类型:java.util.DateLocalDateLocalDateTimeLocalTimeOffsetDateTimeOffsetTime

它有如下属性:

  • checkSeconds − 当设置为 ture 时,验证器需要使用秒和毫秒比较日期或者时间。默认值是 false

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.pastOrPresent

XML 描述中用法:

<dateField id="dateTimeField" property="dateTimeProperty">
    <validators>
        <pastOrPresent/>
    </validators>
</dateField>

Java 代码用法:

PastOrPresentValidator pastOrPresentValidator = beanLocator.getPrototype(PastOrPresentValidator.NAME);
numberField.addValidator(pastOrPresentValidator);
PastValidator

检查时间或者日期在过去。它不使用 Groovy 字符串,所以没有参数可用于消息格式化。 支持的类型:java.util.DateLocalDateLocalDateTimeLocalTimeOffsetDateTimeOffsetTime

它有如下属性:

  • checkSeconds − 当设置为 ture 时,验证器需要使用秒和毫秒比较日期或者时间。默认值是 false

  • message − 自定义的消息,用于在验证失败时展示给用户。

默认消息键值:

  • validation.constraints.past

XML 描述中用法:

<dateField id="dateTimeField" property="dateTimeProperty">
    <validators>
        <pastOrPresent/>
    </validators>
</dateField>

Java 代码用法:

PastOrPresentValidator pastOrPresentValidator = beanLocator.getPrototype(PastOrPresentValidator.NAME);
numberField.addValidator(pastOrPresentValidator);
PositiveOrZeroValidator

检查值大于等于 0。 支持的类型:BigDecimalBigIntegerLongIntegerDoubleFloat

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出。注意,Float 并没有它自己的数据类型,不会使用用户的 locale 进行格式化。

默认消息键值:

  • validation.constraints.positiveOrZero

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <positiveOrZero message="Value '$value' should be greater than or equal to '0'"/>
    </validators>
</textField>

Java 代码用法:

PositiveOrZeroValidator positiveOrZeroValidator = beanLocator.getPrototype(PositiveOrZeroValidator.NAME);
numberField.addValidator(positiveOrZeroValidator);
PositiveValidator

检查值严格大于 0。 支持的类型:BigDecimalBigIntegerLongIntegerDoubleFloat

它有如下属性:

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出。注意,Float 并没有它自己的数据类型,不会使用用户的 locale 进行格式化。

默认消息键值:

  • validation.constraints.positive

XML 描述中用法:

<textField id="numberField" property="numberProperty">
    <validators>
        <positive message="Value '$value' should be greater than '0'"/>
    </validators>
</textField>

Java 代码用法:

PositiveValidator positiveValidator = beanLocator.getPrototype(PositiveValidator.NAME);
numberField.addValidator(positiveValidator);
RegexpValidator

检查 String 的值是否能匹配提供的正则表达式。 支持的类型:String

它有如下属性:

  • regexp − 一个用于匹配的正则表达式(必须);

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.regexp

XML 描述中用法:

<textField id="textField" property="textProperty">
    <validators>
        <regexp regexp="[a-z]*"/>
    </validators>
</textField>

Java 代码用法:

RegexpValidator regexpValidator = beanLocator.getPrototype(RegexpValidator.NAME, "[a-z]*");
textField.addValidator(regexpValidator);
SizeValidator

检查值在一定范围内。 支持的类型:CollectionString

它有如下属性:

  • min − 最小值(不包含),不能小于 0。默认值是 0;

  • max − 最大值(不包含),不能小于 0。默认值是 Integer.MAX_VALUE

  • message − 自定义的消息,用于在验证失败时展示给用户。该消息可以包含 $value(只对 String 类型有效),$min$max 关键字,用于格式化输出。

默认消息键值:

  • validation.constraints.collectionSizeRange

  • validation.constraints.sizeRange

XML 描述中用法:

<textField id="textField" property="textProperty">
    <validators>
        <size min="2" max="10" message="Value '$value' should be between '$min' and '$max'"/>
    </validators>
</textField>

<twinColumn id="twinColumn">
    <validators>
        <size min="2" max="4" message="Collection size must be between $min and $max"/>
    </validators>
</twinColumn>

Java 代码用法:

SizeValidator sizeValidator = beanLocator.getPrototype(SizeValidator.NAME);
textField.addValidator(sizeValidator);
3.5.2.5. 可视化组件 API
通用
  • unwrap() - 返回针对不同客户端的组件实例(Vaadin 或者 Swing 组件)。可以在 Client 模块简化底层 API 的调用,参考 使用 Vaadin 组件 章节。

    com.vaadin.ui.TextField vTextField = textField.unwrap(com.vaadin.ui.TextField.class);
  • unwrapComposition() - 返回针对不同客户端的最外层的外部包裹容器(external container)。可以在 Client 模块简化底层 API 的调用。

这两个方法支持所有可视化组件。

Buffered-缓冲写入模式接口
  • commit() - 从上次更新之后的所有改动更新到数据源。

  • discard() - 从上次更新之后所有的改动都废弃掉。对象会从数据源更新数据。

  • isModified() - 如果从上次更新后这个对象有过改动,这个方法会返回 true

if (textArea.isModified()) {
    textArea.commit();
}

支持此接口的组件:

Collapsable-可折叠接口
  • addExpandedStateChangeListener() - 添加实现了 ExpandedStateChangeListener 接口的监听器来拦截组件的展开状态变化事件。

    @Subscribe("groupBox")
    protected void onGroupBoxExpandedStateChange(Collapsable.ExpandedStateChangeEvent event) {
        notifications.create()
                .withCaption("Expanded: " + groupBox.isExpanded())
                .show();
    }

    支持此接口的组件:

ComponentContainer-组件容器接口
  • add(component) - 添加子组件到容器。

  • remove(component) - 从容器移除子组件。

  • removeAll() - 移除容器内所有组件。

  • getOwnComponent(id) - 返回直接拥有(directly owned)的组件。

  • getComponent(id) - 返回这个容器下面组件树的一个组件。

  • getComponentNN(id) - 返回这个容器下面组件树的一个组件。如果没找到,会抛出异常。

  • getOwnComponents() - 返回这个容器直接拥有的所有组件。

  • getComponents() - 返回这个容器下的组件树的所有组件。

支持此接口的组件:

OrderedContainer-有序容器接口
  • indexOf() - 返回给定组件在有序容器中的索引位置。

支持此接口的组件:

HasContextHelp-内容提示接口
  • setContextHelpText() - 设置内容提示文本。如果设置的话,会为字段添加一个特殊的图标,参阅: contextHelpText

  • setContextHelpTextHtmlEnabled() - 定义是否用 HTML 渲染内容提示文本。参阅: contextHelpTextHtmlEnabled

  • setContextHelpIconClickHandler() - 设置内容提示图标点击处理函数。点击处理函数比 context help text 优先级高,也就是说,如果点击处理函数设置了的话,默认的弹出小窗便不会显示。

textArea.setContextHelpIconClickHandler(contextHelpIconClickEvent ->
        dialogs.createMessageDialog()
                .withCaption("Title")
                .withMessage("Message body")
                .withType(Dialogs.MessageType.CONFIRMATION)
                .show()
);

几乎所有组件都支持此接口:

HasSettings-用户设置接口
  • applySettings() - 恢复上次用户使用该组件的设置。

  • saveSettings() - 保存当前用户对该组件的设置。

支持此接口的组件:

HasUserOriginated-事件来源接口
  • isUserOriginated() - 提供事件来源的信息。如果事件是在客户端通过用户交互触发,则返回 true。如果是在服务端以编程方式触发,则返回 false

    用例示范:

    @Subscribe("customersTable")
    protected void onCustomersTableSelection(Table.SelectionEvent<Customer> event) {
        if (event.isUserOriginated())
            notifications.create()
                    .withCaption("You selected " + event.getSelected().size() + " customers")
                    .show();
    }

isUserOriginated() 方法支持对以下事件进行跟踪:

HasValue-有值处理接口
  • addValueChangeListener() - 添加实现了 ValueChangeListener 接口的监听器来拦截组件的值变化事件。

    @Inject
    private TextField<String> textField;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        textField.addValueChangeListener(stringValueChangeEvent ->
                notifications.create()
                        .withCaption("Before: " + stringValueChangeEvent.getPrevValue() +
                                ". After: " + stringValueChangeEvent.getValue())
                        .show());
    }

    为了达到相同的目的,也可以订阅组件特定的事件,示例:

    @Subscribe("textField")
    protected void onTextFieldValueChange(HasValue.ValueChangeEvent<String> event) {
        notifications.create()
                .withCaption("Before: " + event.getPrevValue() +
                        ". After: " + event.getValue())
                .show();
    }

也可参阅 UserOriginated.

支持此接口的组件:

LayoutClickNotifier-布局点击通知接口
  • addLayoutClickListener() - 添加实现了 LayoutClickListener 接口的监听器拦截鼠标在组件区域点击事件。

    vbox.addLayoutClickListener(layoutClickEvent ->
        notifications.create()
                .withCaption("Clicked")
                .show());

    为了达到相同的目的,也可以订阅组件特定的事件,示例:

    @Subscribe("vbox")
    protected void onVboxLayoutClick(LayoutClickNotifier.LayoutClickEvent event) {
        notifications.create()
                .withCaption("Clicked")
                .show();
    }

支持此接口的组件:

HasMargin-容器边距接口
  • setMargin() - 设置容器外边框和容器内容之间的边距。

    • 设置组件所有方向的边距:

      vbox.setMargin(true);
    • 只设置上边距和下边距:

      vbox.setMargin(true, false, true, false);
    • 创建 MarginInfo 配置类实例来设置边距:

      vbox.setMargin(new MarginInfo(true, false, false, true));
  • getMargin() - 以 MarginInfo 实例的方式返回组件的边距设置。

支持此接口的组件:

HasOuterMargin-组件外边距接口
  • setOuterMargin() - 设置组件外边框外的边距。

    • 设置所有方向的外边距:

      groupBox.setOuterMargin(true);
    • 只设置组件上下外边距:

      groupBox.setOuterMargin(true, false, true, false);
    • 创建 MarginInfo 配置类实例来设置外边距:

      groupBox.setOuterMargin(new MarginInfo(true, false, false, true));
  • getOuterMargin() - 以 MarginInfo 实例的方式返回组件的外边距设置。

支持此接口的组件:

HasSpacing-留白接口
  • setSpacing() - 在这个组件和他的子组件之间添加一些空白。

    vbox.setSpacing(true);

支持此接口的组件:

ShortcutNotifier-快捷键接口
  • addShortcutAction() - 添加一个操作,当用户按下配置的快捷键组合的时候触发。

    cssLayout.addShortcutAction(new ShortcutAction("SHIFT-A", shortcutTriggeredEvent ->
            notifications.create()
                    .withCaption("SHIFT-A action")
                    .show()));

支持此接口的组件:

3.5.2.6. 组件的 XML 属性
align - 对齐

定义组件跟父容器的相对位置,可选值有:

  • TOP_RIGHT

  • TOP_LEFT

  • TOP_CENTER

  • MIDDLE_RIGHT

  • MIDDLE_LEFT

  • MIDDLE_CENTER

  • BOTTOM_RIGHT

  • BOTTOM_LEFT

  • BOTTOM_CENTER

box.expandRatio - 箱式占比率

vboxhbox 容器中,组件放置在预定义的槽段里。box.expandRatio 属性指定每个槽段的延展比率。比率必须大于等于 0。

<hbox width="500px" expand="button1" spacing="true">
    <button id="button1" box.expandRatio="1"/>
    <button id="button2" width="100%" box.expandRatio="3"/>
    <button id="button3" width="100%" box.expandRatio="2"/>
</hbox>

如果我们为一个组件设置 box.expandRatio=1,并且它的是 100% (依据布局样式),该组件会被延展至使用组件放置方向的所有可用空间。

默认情况下,所有放置组件的槽段有相等的宽度或高度(即,box.expandRatio = 1)。如果至少有一个组件的该属性设置成别的值,则会忽略所有的隐式值,只考虑设置了的显式值。

也可参阅 expand 属性。

caption - 标题

设置组件的标题

这个属性的值可以是一段文本,或者消息包里的一个消息键名。如果是用的消息键名,那么这个值需要以 msg:// 前缀开头。

有两种方式设置键名:

  • 短键名 – 这种情况下,只在当前界面的消息包中搜索这个键名:

    caption="msg://infoFieldCaption"
  • 包含包名的长键名:

    caption="msg://com.company.sample.gui.screen/infoFieldCaption"
captionAsHtml - HTML 标题

定义是否在组件的标题中启用 HTML。如果设置为 true,标题在浏览器中按照 HTML 做渲染,开发者需要保证没有使用有害的 HTML。如果设置为 false,内容将会按照普通文本显示。

可选值 − truefalse。默认值 false

captionProperty - 属性名称

定义组件显示的实体属性的名称。captionProperty 只能用在关联了数据源的实体(比如,LookupField 组件的关联 optionsDatasource 数据源的属性)。

如果 captionProperty 没定义 ,组件会显示实体的实例名称

colspan - 占列数目

设置组件应占用的网格(grid columns)列数,默认是 1

这个属性可以给任何在 GridLayout 容器中的组件定义列宽。

contextHelpText - 内容提示文字

设置内容提示文字,如果设置了,那么一个特别的 ? 图标会添加在组件里。如果组件有外部的标题,比如设置了 caption 或者 icon 属性,那么这个内容提示的图标会显示在标题旁边,否则会显示在这个控件本身的旁边:

gui attr contextHelpIcon

web 客户端当用户光标悬浮到内容提示的 ? 图标时,会显示内容提示。

<textField id="textField"
           contextHelpText="msg://contextHelp"/>
gui attr contextHelp
contextHelpTextHtmlEnabled - 是否启用 HTML 格式内容提示

定义内容提示是否可以通过 HTML 格式显示。

<textField id="textField"
           description="Description"
           contextHelpText="<p><h1>Lorem ipsum dolor</h1> sit amet, <b>consectetur</b> adipiscing elit.</p><p>Donec a lobortis nisl.</p>"
           contextHelpTextHtmlEnabled="true"/>
gui attr contextHelpHtml

可选值: truefalse.

css - 样式

为 UI 组件提供声明式的方式来设置 CSS 属性。这个属性可以跟 stylename 属性一起使用,参阅下面的例子。

XML 定义:
<cssLayout css="display: grid; grid-gap: 10px; grid-template-columns: 33% 33% 33%"
           stylename="demo"
           width="100%"
           height="100%">
    <label value="A" css="grid-column: 1 / 3; grid-row: 1"/>
    <label value="B" css="grid-column: 3; grid-row: 1 / 3;"/>
    <label value="C" css="grid-column: 1; grid-row: 2;"/>
    <label value="D" css="grid-column: 2; grid-row: 2;"/>
</cssLayout>
附加的 CSS:
  .demo > .v-label {
    display: block;
    background-color: #444;
    color: #fff;
    border-radius: 5px;
    padding: 20px;
    font-size: 150%;
  }
dataContainer - 数据容器

在界面的 XML 描述中的 data 部分定义一个数据容器

当为组件设置 dataContainer 属性的时候,property 属性也需要同时设置。

dataLoader - 数据加载器

设置数据加载器,为界面 XML 描述的 data 部分定义一个数据容器。

datasource - 数据源

在 XML 描述的 dsContext 段定义一个数据源

当给实现了 DatasourceComponent 接口的组件设置 datasource 属性的时候,property 属性也需要同时设置。

datatype - 数据类型

如果控件不与实体属性相关联(比如没有设置数据容器和属性名称),则需要设置数据类型。该属性的值可以是注册在应用程序元数据中的一个数据类型 - 参考 数据类型接口

这些组件可以使用该属性: TextFieldDateFieldDatePickerTimeFieldSlider

description - 组件描述

当用户光标悬浮或者点击一个组件的区域的时候,在弹出框显示组件的描述信息。

descriptionAsHtml - HTML 组件描述

定义是否在组件的描述中启用 HTML。如果设置为 true,描述在浏览器中使用 HTML 做渲染,开发者需要保证没有使用有害的 HTML。如果设置为 false,内容将会按照普通文本显示。

可选值 − truefalse。默认值 false

editable - 是否可编辑

标明这个组件的内容是否可编辑(不要跟 enable 混淆)

可选值: truefalse。默认值 true

是否能编辑绑定数据的组件(继承了 DatasourceComponent 或者 ListComponent)的内容也收到 security subsystem 的影响。如果安全子系统示意这个组件不能编辑,那么 editable 属性的值会被忽略。

enable - 是否启用

定义组件的启用/禁用状态。

如果一个组件被禁用,那么这个组件将不能操作。禁用一个容器会禁用容器内的所有组件。可选值: truefalse。默认所有组件都是启用的。

expand - 延展

定义容器内的组件是否可以按照组件放置的方向延展占用位置至所有可用的空间。

对于垂直布局的容器,这个属性会设置组件的高度(height)到 100%;对于水平布局的容器,则是 100%宽度(width)。还有,重新设置容器的大小也会影响到自动延展的组件。

参考 box.expandRatio

height - 组件高度

设置组件的高度。可以用像素(pixels)或者父容器的高度的百分比。比如 100px100%50。如果数字没有单位,默认是用像素。

按照 % 来设置值表示组件会占用父容器可用空间的相应比例的高度。

当设置成 AUTO 或者 -1px 的时候,组件的高度会使用默认值。对于容器来说,默认的高度是由内容定义的,也就是所有嵌入组件的高度之和。

htmlSanitizerEnabled - 启用 HTML 清理

定义是否开启在组件内容(captiondescriptioncontextHelpText 属性)做 HTML 清理。如果设置为 true,且开启 HTML 功能的属性(captionAsHtmldescriptionAsHtmlcontextHelpTextHtmlEnabled)也设置为 true,此时,captiondescriptioncontextHelpText 属性的内容会被清理。

protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
            "color=\"moccasin\">my</font> " +
            "<font size=\"7\">big</font> <sup>sphinx</sup> " +
            "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
            "red;\">quartz</span><svg/onload=alert(\"XSS\")>";

@Inject
private TextField<String> textFieldOn;
@Inject
private TextField<String> textFieldOff;

@Subscribe
public void onInit(InitEvent event) {
    textFieldOn.setCaption(UNSAFE_HTML);
    textFieldOn.setCaptionAsHtml(true);
    textFieldOn.setHtmlSanitizerEnabled(true); (1)

    textFieldOff.setCaption(UNSAFE_HTML);
    textFieldOff.setCaptionAsHtml(true);
    textFieldOff.setHtmlSanitizerEnabled(false); (2)
}
1 TextField 使用安全的 HTML 作为 caption。
2 TextField 使用不安全的 HTML 作为 caption。

htmlSanitizerEnabled 会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

icon - 图标

设置组件的图标

这个属性的值需要指定一个图标的路径,这个路径相对于主题文件夹。

icon="icons/create.png"

或者图标集里面的图标名称:

icon="CREATE_ACTION"

如果希望根据用户的语言显示不同的图标,可以在消息包里面配置图标的路径,然后在 icon 属性指定这个消息的键名,比如:

icon="msg://addIcon"

在用了 Halo 主题 (或者继承了这个主题)的 web 客户端,可以用 Font Awesome 来替代图标文件。在 icon 属性中定义所需要的图标名称,名称可以从 com.vaadin.server.FontAwesome 类的常量中找到,然后用 font-icon: 前缀来设置,比如:

icon="font-icon:BOOK"

参阅 图标 章节了解使用图标的更多细节。

id - 组件标识符

设置组件的标识符。

推荐使用 Java 标识符的规则和驼峰命名法来给组件创建标识符,比如:userGridfilterPanel。任何组件都可以使用 id 属性,但是需要保证在一个界面中每个组件的标识符是唯一的。

inputPrompt - 输入提示

定义当组件的值是 null 的时候在这个组件显示的文字。

<suggestionField inputPrompt="Let's search something!"/>

这个属性只能在 TextFieldLookupFieldLookupPickerFieldSearchPickerFieldSuggestionPickerField 这些组件中使用,并且只支持 web 客户端。

margin - 边距

定义组件的外边框和容器内容之间的留白。

可以有两种形式的值设置:

  • margin="true" − 给所有方向都加了边距。

  • margin="true,false,true,false" − 只给上下两边加了边距(值格式是:“上,右,下,左”)。

默认情况下没有边距设置。

metaClass - 元类

如果没有声明式的定义 dataContainerdatasource 属性,则用 metaClass 定义表格组件的列类型。在 XML 中定义 metaClass 属性与设置 DataGridGroupTableTableTreeDataGridTreeTable 为空表格的效果一样,因此需要在界面控制器用编程的方式设置表格内容。

 <table id="table" metaClass="sec$User">
       <actions>
             <action id="refresh" type="refresh"/>
       </actions>
 </table>
nullName - null 名称

选择 nullName 属性定义的名称相当于设置组件的值为 null。换言之,nullName 就是组件 null 选项的显示名称。

这个属性可以在 LookupFieldLookupPickerFieldSearchPickerField 组件使用。

XML 描述内设置这个属性的值:

<lookupField datasource="orderDs"
             property="customer"
             nullName="(none)"
             optionsDatasource="customersDs" width="200px"/>

控制器中设置这个属性的值:

<lookupField id="customerLookupField" optionsDatasource="customersDs"
             width="200px" datasource="orderDs" property="customer"/>
customerLookupField.setNullOption("<null>");
openType - 界面打开类型

定义一个界面要以什么方式打开。对应 WindowManager.OpenType 枚举类型的值:NEW_TABTHIS_TABNEW_WINDOWDIALOG。默认是 THIS_TAB

optionsContainer - 选项容器

设置一个数据容器的名称,这个容器包含一个选项列表。

captionProperty 属性可以跟 optionsContainer 一起使用。

optionsDatasource - 选项数据源

设置包含一个选项列表的数据源

captionProperty 属性可以跟 optionsDatasource 一起使用。

optionsEnum - 选项枚举类型

设置一个含有选项列表的枚举类名称,一个枚举值就是一个选项。

property - 属性名称

设置实体属性的名称,这个属性会在这个组件用来编辑和显示。

property 属性总是和数据源属性一起使用。

required - 必须有值

表示是这个字段必须要有值。

可选值: truefalse。默认是 false

可以和 requiredMessage 属性一起使用。

requiredMessage - 必须有值提醒

总是跟 required 一起使用。这个属性设置一个消息,当组件没有值的时候会弹出这个消息提示。

这个属性也可以用消息包的键名,比如: requiredMessage="msg://infoTextField.requiredMessage"

responsive - 响应式

设置组件是否可以根据可用的空间的大小自动适应。可以通过 styles 来定制自适应的方式。

可选值:truefalse。默认值 false

rowspan - 占用行数

设置组件占据的网格行数(grid lines),默认是 1。

这个属性可以给直接放在在 GridLayout 容器内的任何组件使用。

settingsEnabled - 开启设置

定义用户是否可以保存/恢复对组件的设置。只有组件有 id,才能保存组件的设置。

可选值:truefalse。默认值 true

showOrphans - 显示孤儿记录

showOrphans 属性用来控制是否在树形组件中显示孤儿记录,孤儿记录是指那些父节点并不在当前数据集中的记录。 如果 showOrphans 属性设置为 false,则组件不会显示孤儿记录。否则,组件与之前的行为一样,会以顶层根节点的方式显示孤儿记录。

默认值为 true

在使用 过滤器 时,隐藏孤儿记录更符合人们的习惯。但是,这样会导致分页有问题,某些页可能是空的或者只有一半数据,所以在使用树形组件时,如果想隐藏孤儿记录应该关闭分页功能:

此属性可以在 TreeTreeDataGridTreeTable 组件使用。

spacing - 留白

设置容器内组件之间是否留白。

可选值:truefalse。默认值 false

stylename - 样式名称

定义组件的 CSS 类名称,更多细节,参考 主题

halo 主题中,有一些为组件预定义的 CSS 类名称:

  • huge - 设置组件大小为默认值的 160%

  • large - 设置组件大小为默认值的 120%

  • small - 设置组件大小为默认值的 85%

  • tiny - 设置组件大小为默认值的 75%

tabCaptionsAsHtml - 标签名称是否 HTML 格式

定义标签的标题是否可以用 HTML。如果设置成 true,标题在浏览器里会以 HTML 的方式渲染,开发者需要保证没有使用有害的 HTML。如果设置成 false,内容会按照纯文本来渲染。

可选值:truefalse。默认值 false

tabIndex - tab 键索引

定义组件是否可以获得焦点,同时也可以设置组件在界面上所有可获得焦点组件内部的顺序值。

可以使用正负整数:

  • 负值 表示组件可以获得焦点,但是不能被顺序的键盘导航访问到;

  • 0 表示组件可以获得焦点,也可以被顺序的键盘访问访问到;但是它的访问顺序是由它在界面中的相对位置来定;

  • 正值 表示组件可以获得焦点,也可以被顺序的键盘导航访问到;它被访问的相对顺序是通过这个属性的值来定义的:按照 tabIndex 升序来访问。如果几个组件有相同的 tabIndex 值,它们的相对访问顺序由它们在界面的相对位置来确定。

tabsVisible - 标签可见

设置在标签页面板中是否显示标签选择部分。

可选值:truefalse。默认值 true

textSelectionEnabled - 文本选择启用

定义在表格单元格中是否能选择文本。

可选值:truefalse。默认值 false

visible - 组件是否可见

设置组件是否可见。

如果容器被设置成不可见,那么他里面的所有组件也不可见。默认所有组件都是可见。

可选值:truefalse。默认值 true

width - 组件宽度

定义组件的宽度

可以用像素(pixels)或者父容器的宽度的百分比。比如 100px100%50。如果数字没有单位,默认是用像素。

按照 % 来设置值表示组件会占用父容器可用空间的相应比例的宽度。

当设置成 AUTO 或者 -1px 的时候,组件的宽度会使用默认值。对于容器来说,默认的宽度是由内容定义的,也就是所有嵌入组件的宽度之和。

3.5.3. 数据组件

数据组件是界面的不可见元素,用来从中间层加载数据,然后绑定数据到可视化组件,也可将数据改动保存回中间层。有以下数据组件:

  • 数据容器作为实体和具有数据感知能力的可视化组件之间薄薄的一层,不同类型的容器包含单一实体实例或者实体的集合。

  • 数据加载器将数据从中间层加载至数据容器。

  • 数据上下文跟踪实体的改动并且按照要求将改动的实例发送回中间层。

通常,数据组件在界面 XML 描述的 <data> 元素定义。可以跟可视化组件以相同的方式注入到控制器中:

@Inject
private CollectionLoader<Customer> customersDl;

private String customerName;

@Subscribe
protected void onBeforeShow(BeforeShowEvent event) {
    customersDl.setParameter("name", customerName)
    customersDl.load();
}

特定界面的数据组件注册在 ScreenData 对象中,这个对象跟控制器关联,可以通过控制器的 getScreenData() 方法获取。这个对象在需要加载界面所有的数据的时候很有用,示例:

@Subscribe
protected void onBeforeShow(BeforeShowEvent event) {
    getScreenData().loadAll();
}

需要注意的是,如果控制器带有 @LoadDataBeforeShow 注解,数据会自动加载。所以只有在没有此注解或注解的值是 false 的时候需要通过编程的方式加载数据。通常在需要设置一些加载参数的时候使用编程的方法,如上面例子所示。

3.5.3.1. 数据容器

数据容器在数据模型和可视化组件之间形成一个薄薄的处理层。容器用来容纳实体实例和实体集合、提供实体元类型、视图和选中的集合中实体的信息,以及为各种事件注册监听器。

containers
Figure 20. 数据容器接口
3.5.3.1.1. 单一实例容器

InstanceContainer 接口是数据容器层次结构的根节点。用来容纳单一实体实例,有下列方法:

  • setItem() - 为容器设置一个实体实例。

  • getItem() - 返回容器中保存的实例。如果容器是空的,此方法会抛出异常。所以需要在确保容器有设置实体的时候才使用此方法,然后就不需要检查返回值是否为 null。

  • getItemOrNull() - 返回容器中保存的实例。如果容器是空的,此方法会返回 null。所以在使用此方法返回值之前总是需要先检查返回的是否是 null。

  • getEntityMetaClass() - 返回能存储在此容器的实体的元类

  • setView() - 设置在加载容器实体时需要使用的视图。需要注意的是,容器本身不会加载数据,所以这个属性只是为此容器关联的数据加载器设定视图。

  • getView() - 返回在加载容器实体时需要使用的视图。

InstanceContainer 事件

使用 InstanceContainer 接口可以注册以下事件的监听器。

  • ItemPropertyChangeEvent 会在容器中存储的实例的属性值发生变化时发送。下面例子展示了订阅容器的事件,该容器在界面 XML 中使用 customerDc id定义:

    @Subscribe(id = "customerDc", target = Target.DATA_CONTAINER)
    private void onCustomerDcItemPropertyChange(
            InstanceContainer.ItemPropertyChangeEvent<Customer> event) {
        Customer customer = event.getItem();
        String changedProperty = event.getProperty();
        Object currentValue = event.getValue();
        Object previousValue = event.getPrevValue();
        // ...
    }
  • ItemChangeEvent 会在另一个实例(或者null)设置到容器时发送。下面例子展示了订阅容器的事件,该容器在界面 XML 中使用 customerDc id定义:

    @Subscribe(id = "customerDc", target = Target.DATA_CONTAINER)
    private void onCustomerDcItemChange(InstanceContainer.ItemChangeEvent<Customer> event) {
        Customer customer = event.getItem();
        Customer previouslySelectedCustomer = event.getPrevItem();
        // ...
    }
3.5.3.1.2. 集合容器

CollectionContainer 接口用来容纳相同类型实例的集合。这个接口是 InstanceContainer 的后代,定义了以下特有的方法:

  • setItems() - 为容器设置实体集合。

  • getItems() - 返回容器中保存的实体的不可变列表。可以用这个方法来遍历集合、获得实体集合流或者用根据索引获取单一实例。如果需要按照实体的 id 获取实例,使用 getItem(entityId) 方法。示例:

    @Inject
    private CollectionContainer<Customer> customersDc;
    
    private Optional<Customer> findByName(String name) {
        return customersDc.getItems().stream()
                .filter(customer -> Objects.equals(customer.getName(), name))
                .findFirst();
    }
  • getMutableItems() - 返回容器中保存的实体的可变列表。所有对列表的改动,包括 add()addAll()remove()removeAll()set()clear() 方法都会产生 CollectionChangeEvent 事件,所以订阅了这个事件的可视化组件也会根据变化更新,示例:

    @Inject
    private CollectionContainer<Customer> customersDc;
    
    private void createCustomer() {
        Customer customer = metadata.create(Customer.class);
        customer.setName("Homer Simpson");
        customersDc.getMutableItems().add(customer);
    }

    只有在需要更改集合的时候使用 getMutableItems(),否则应该使用 getItems(),防止意外改动。

  • setItem() - 为容器设置 当前 实例。如果提供的内容不是 null,则必须是集合中的一个对象。此方法会发送 ItemChangeEvent

    需要注意的是,类似 Table 的可视化组件不会监听容器发送的 ItemChangeEvent 事件。所以如果需要在表中选中一行,需要使用集合容器的 setSelected() 方法,而不是 setItem()。容器的当前 item 也会更改,因为容器同时也监听了组件。示例:

    @Inject
    private CollectionContainer<Customer> customersDc;
    @Inject
    private GroupTable<Customer> customersTable;
    
    private void selectFirstRow() {
        customersTable.setSelected(customersDc.getItems().get(0));
    }
  • getItem() - 重写了 InstanceContainer 的同名方法,返回 当前 实例。如果当前实例没有设置,此方法会抛出一个异常。所以需要在确保容器有选中当前实例的时候才使用此方法,然后就不需要检查返回值是否为 null。

  • getItemOrNull() - 重写了 InstanceContainer 的同名方法,返回 当前 实例。如果当前实例没有设置,此方法会返回 null。所以在使用此方法返回值之前总是需要先检查返回的是否是 null。

  • getItemIndex(entityId) - 返回实例在 getItems()getMutableItems() 方法返回的列表中的位置。此方法接收 Object 对象,因此可以传给它 id 或者实体实例本身。容器的实现维护了一个 id 到索引的映射,所以这个方法即使在非常大的列表中也有很高效率。

  • getItem(entityId) - 按照实例的 id 返回此实例。这个是一个快捷方法,首先用 getItemIndex(entityId) 得到实例的位置,然后通过 getItems().get(index) 返回实例。所以如果需要找的实例不在集合中存在,则会抛出异常。

  • getItemOrNull(entityId) - 跟 getItem(entityId) 类似,只不过在实例不存在的时候会返回 null。所以需要在使用前检查此方法的返回值是否是 null。

  • containsItem(entityId) - 如果指定 id 的实体在集合中存在的话,返回 true。底层其实调用了 getItemIndex(entityId) 方法。

  • replaceItem(entity) - 如果在容器中有相同 id 的实例,则会被方法的输入参数的实例替换。如果不存在,则会添加新的实例到实例列表中。此方法会发送 CollectionChangeEvent 事件,根据具体做了什么,事件类型可以是 SET_ITEM 或者 ADD_ITEMS

  • setSorter() - 设置此容器的排序器。Sorter 接口的标准实现是 CollectionContainerSorter。当容器关联到加载器时,会设置默认的排序器。如果需要,也可提供自定义的实现

  • getSorter() - 返回此容器当前设置的排序器。

CollectionContainer 事件

除了 InstanceContainer 的事件之外,还可以使用 CollectionContainer 接口注册 CollectionChangeEvent 事件的监听器,该事件在容器内的实体集合改动时发送,比如,添加、删除和替换集合内元素。下面例子展示了订阅容器的事件,该容器在界面 XML 中使用 customersDc id定义:

@Subscribe(id = "customersDc", target = Target.DATA_CONTAINER)
private void onCustomersDcCollectionChange(
        CollectionContainer.CollectionChangeEvent<Customer> event) {
    CollectionChangeType changeType = event.getChangeType(); (1)
    Collection<? extends Customer> changes = event.getChanges(); (2)
    // ...
}
1 - 改动类型:REFRESH,ADD_ITEMS,REMOVE_ITEMS,SET_ITEM。
2 - 从容器中添加或者删除的实体集合。如果改动类型是 REFRESH,框架不能确定具体是哪些实体添加或者删除,所以此时该集合为空。
3.5.3.1.3. 属性容器

InstancePropertyContainerCollectionPropertyContainer 是设计用来处理实体实例和集合,这些实体实例和集合是其它实体的属性。比如,如果 Order 实体有 orderLines 属性,这个属性是 OrderLine 实体的集合,那么可以使用 CollectionPropertyContainer 来绑定 orderLines 到一个表格组件。

属性容器实现了 Nested 接口,这个接口定义了获取主容器方法,以及获取此属性容器绑定的主容器的属性名称的方法。在 OrderOrderLine 实体的例子中,主容器是用来存储 Order 实例的容器。

InstancePropertyContainer 可以直接跟主实体的属性交互。也就是说,如果调用 setItem() 方法,这个值会直接设置到相应主实体的属性,同时主实体的 ItemPropertyChangeEvent 监听器会被触发。

CollectionPropertyContainer 包含主集合的拷贝,并且它的方法行为如下:

  • getMutableItems() 返回实体的可变列表,对列表的改动都会反映到底层的属性。也就是说,如果从列表中删除了一项,主实体的属性也会更改,主容器的 ItemPropertyChangeEvent 监听器会触发。

  • getDisconnectedItems() 返回实体的可变列表,但是这个列表内的改动不会反映到底层属性。也就是说如果从这个列表中删除了一项,主实体属性不变。

  • setItems() 为容器设置实体集合,同时也设置给了关联的主属性。因此,主容器的 ItemPropertyChangeEvent 监听器会被触发。

  • setDisconnectedItems() 为容器设置实体集合,但是底层关联的主属性不变。

getDisconnectedItems()setDisconnectedItems() 方法可以用来暂时改变集合在 UI 的展示,比如对表格做过滤:

@Inject
private CollectionPropertyContainer<OrderLine> orderLinesDc;

private void filterByProduct(String product) {
    List<OrderLine> filtered = getEditedEntity().getOrderLines().stream()
            .filter(orderLine -> orderLine.getProduct().equals(product))
            .collect(Collectors.toList());
    orderLinesDc.setDisconnectedItems(filtered);
}

private void resetFilter() {
    orderLinesDc.setDisconnectedItems(getEditedEntity().getOrderLines());
}
3.5.3.1.4. 键值对容器

KeyValueContainerKeyValueCollectionContainer 是用来处理 KeyValueEntity 的。这个实体可以包含在运行时定义的任意数量的属性。

键值对容器定义了下列特殊的方法:

  • addProperty() 由于容器可以保存带有任意数量属性的实体,需要在使用此方法的时候指定是添加什么属性。这个方法接收属性名称和对应的类型,类型可以是数据类型格式,也可以是 Java 类。在使用 Java 类的情况下,这个类要么是实体类,要么是有数据类型支持的类。

  • setIdName() 是一个可选择调用的方法,通过这个方法可以将一个属性定义为实体的标识符属性。也就是说,保存在此容器内的 KeyValueEntity 实体将使用指定的属性作为标识符。否则,KeyValueEntity 将使用随机生成的 UUID 作为标识符。

  • getEntityMetaClass() 返回 MetaClass 接口的动态实现类,这个类反映了 KeyValueEntity 实例的当前结构,实例的结构是通过之前调用 addProperty() 来定义的。

KeyValueContainerKeyValueCollectionContainer 也可以在界面 XML 描述中使用 KeyValueInstanceKeyValueCollection 元素定义。

KeyValue 容器的 XML 定义必须包含 properties 元素,用来定义 KeyValueEntity 实例属性。property 元素的顺序必须与查询返回的列顺序一致。比如,在下面的定义中,customer 属性会从 o.customer 列获取数据,sumsum(o.amount) 列获取:

<data readOnly="true">
        <keyValueCollection id="salesDc">
            <loader id="salesDl">
                <query>
                    <![CDATA[select o.customer, sum(o.amount) from sales_Order o group by o.customer]]>
                </query>
            </loader>
            <properties>
                <property name="customer" class="com.company.sales.entity.Customer"/>
                <property name="sum" datatype="decimal"/>
            </properties>
        </keyValueCollection>
</data>

KeyValue 容器设计用来只能读取数据,因为 KeyValueEntity 不是持久化实体,不能用标准的持久化机制保存。

3.5.3.2. 数据加载器

数据加载器用来从中间层加载数据到数据容器

根据交互的数据容器不同,数据加载器的接口有稍微的不同:

  • InstanceLoader 使用实体 id 或者 JPQL 查询语句加载单一实体到 InstanceContainer

  • CollectionLoader 使用 JPQL 查询语句加载实体集合到 CollectionContainer。可以设置分页、排序以及其它可选的参数。

  • KeyValueCollectionLoader 加载 KeyValueEntity 实体的集合到 KeyValueCollectionContainer。除了 CollectionLoader 参数,还可以指定一个数据存储参数。

在界面的 XML 描述中,所有的加载器都用同一个 <loader> 元素中定义,加载器的类型通过包裹它的容器类型确定。

数据加载器不是必选的,因为可以使用 DataManager 或者自定义的服务来加载数据,之后直接设置给容器。但是使用加载器通过在界面中声明式的定义可以简化数据加载的过程,特别是要使用过滤器组件的情况下。通常,集合加载器从界面的描述文件中获得 JPQL 查询语句,然后从过滤器组件拿到查询参数,之后创建 LoadContext 并且调用 DataManager 加载实体。所以,典型的 XML 描述看起来是这样:

<data>
    <collection id="customersDc" class="com.company.sample.entity.Customer" view="_local">
        <loader id="customersDl">
            <query>
                select e from sample_Customer e
            </query>
        </loader>
    </collection>
</data>
<layout>
    <filter id="filter" applyTo="customersTable" dataLoader="customersDl">
        <properties include=".*"/>
    </filter>
    <!-- ... -->
</layout>

loader XML 元素的属性可以用来定义可选参数,比如 cacheablesoftDeletion 等。

在实体编辑界面,加载器的 XML 元素通常是空的,因为实例加载器需要一个实体的标识符,这个标识符通过编程的方式使用 StandardEditor 基类指定。

<data>
    <instance id="customerDc" class="com.company.sample.entity.Customer" view="_local">
        <loader/>
    </instance>
</data>

加载器可以将实际的加载动作代理到一个函数,这个函数可以通过 setLoadDelegate() 方法或者通过在界面控制器中使用 @Install 注解来声明式的提供。示例:

@Inject
private DataManager dataManager;

@Install(to = "customersDl", target = Target.DATA_LOADER)
protected List<Customer> customersDlLoadDelegate(LoadContext<Customer> loadContext) {
    return dataManager.loadList(loadContext);
}

在上面的例子中,customersDl 加载器会使用 customersDlLoadDelegate() 方法来加载 Customer 实体列表。此方法接收 LoadContext 参数,加载器会按照它的参数(查询语句、过滤器等等)来创建这个参数。在这个例子中,数据加载是通过 DataManager 来完成的,这个实现跟标准加载器的实现一样高效,但是好处是可以使用自定义的服务或者可以在加载完实体之后做其它的事情。

可以通过监听 PreLoadEventPostLoadEvent 事件,在加载之前或之后添加一些业务逻辑:

@Subscribe(id = "customersDl", target = Target.DATA_LOADER)
private void onCustomersDlPreLoad(CollectionLoader.PreLoadEvent<Customer> event) {
    // do something before loading
}

@Subscribe(id = "customersDl", target = Target.DATA_LOADER)
private void onCustomersDlPostLoad(CollectionLoader.PostLoadEvent<Customer> event) {
    // do something after loading
}

一个加载器也可以通过编程的方式创建和配置,示例:

@Inject
private DataComponents dataComponents;

private void createCustomerLoader(CollectionContainer<Customer> container) {
    CollectionLoader<Customer> loader = dataComponents.createCollectionLoader();
    loader.setQuery("select e from sample_Customer e");
    loader.setContainer(container);
    loader.setDataContext(getScreenData().getDataContext());
}

当加载器设置了 DataContext (当使用 XML 描述定义加载器的时候是默认设置的),所有加载的实体都自动合并到数据上下文(data context)。

查询条件

有时需要在运行时修改数据加载器的查询语句,以便过滤数据库级别加载的数据。需要根据用户输入的参数进行过滤,最简单的方法就是将过滤器可视化组件与数据加载器关联起来。

不需要使用全局过滤器或者添加全局过滤器,而是可以为加载器查询语句单独创建一组过滤条件。一个过滤条件是一组带有参数的查询语句片段。在片段中所有的参数都设置了之后 ,这些片段才会被添加到生成的查询语句文本中。过滤条件会在数据存储级别传递,因此可以包含各个数据存储支持的不同语言的片段。框架会提供 JPQL 的过滤条件。

作为例子,考虑按照 Customer 实体的两个属性:string name 和 boolean status 对实体进行过滤,看看如何创建一组过滤条件。

加载器的查询过滤条件可以通过 <condition> XML 元素进行声明式的定义,或者通过 setCondition() 方法编程式的定义。下面是在 XML 中配置条件的示例:

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        xmlns:c="http://schemas.haulmont.com/cuba/screen/jpql_condition.xsd" (1)
        caption="Customers browser" focusComponent="customersTable">
    <data>
        <collection id="customersDc"
                    class="com.company.demo.entity.Customer" view="_local">
            <loader id="customersDl">
                <query><![CDATA[select e from demo_Customer e]]>
                    <condition> (2)
                        <and> (3)
                            <c:jpql> (4)
                                <c:where>e.name like :name</c:where>
                            </c:jpql>
                            <c:jpql>
                                <c:where>e.status = :status</c:where>
                            </c:jpql>
                        </and>
                    </condition>
                </query>
            </loader>
        </collection>
    </data>
1 - 添加 JPQL 条件命名空间
2 - 在 query 内定义 condition 元素
3 - 如果有多个条件,添加 andor 元素
4 - 使用可选的 join 元素和必须的 where 元素定义 JPQL 条件

假设界面有两个 UI 组件用来输入条件参数:nameFilterField 文本控件和 statusFilterField 复选框。为了在用户改变它们值的时候刷新数据,需要在界面控制器添加事件监听器:

@Inject
private CollectionLoader<Customer> customersDl;

@Subscribe("nameFilterField")
private void onNameFilterFieldValueChange(HasValue.ValueChangeEvent<String> event) {
    if (event.getValue() != null) {
        customersDl.setParameter("name", "(?i)%" + event.getValue() + "%"); (1)
    } else {
        customersDl.removeParameter("name");
    }
    customersDl.load();
}

@Subscribe("statusFilterField")
private void onStatusFilterFieldValueChange(HasValue.ValueChangeEvent<Boolean> event) {
    if (event.getValue()) {
        customersDl.setParameter("status", true);
    } else {
        customersDl.removeParameter("status");
    }
    customersDl.load();
}
1 - 注意这里怎么使用 ORM 提供的不区分大小写的子串搜索

如上面所说,只有在条件的参数都设置了之后才会将条件添加到查询语句中。所以在数据库会执行什么样的查询语句依赖于在 UI 组件如何输入参数:

只有 nameFilterField 有值
select e from demo_Customer e where e.name like :name
只有 statusFilterField 有值
select e from demo_Customer e where e.status = :status
nameFilterField 和 statusFilterField 都有值
select e from demo_Customer e where (e.name like :name) and (e.status = :status)
3.5.3.3. 数据上下文

DataContext 是跟踪加载到客户端层实体改动的接口。跟踪实体的任何属性修改后都标记成 “dirty”(表示发生变化),然后 DataContext 会在调用 commit() 方法的时候将发生变化的实体发送到中间件进行保存。

DataContext 内,具有唯一标识符的实体总是以单一的对象实例呈现,不管对象关系图中它在哪里被使用或者使用了多少次。

为了能跟踪实体变化,必须使用其 merge() 方法将实体放入 DataContext 中。如果数据上下文不包含同样id的实体,则会创建一个新实例,将传递的实体状态拷贝至新实例,并将新实例返回。如果上下文已经有同样id的实例,则会将传递实例的状态拷贝至已经存在的实例并返回。使用这个机制保证在数据上下文中对于同一个实例id始终只有一个实例。

当合并实体时,实体内包含根节点的整个实体对象关系图都会被合并。也就是说,所有的引用实体(包括集合)都会处于被跟踪状态。

使用 merge() 方法的重要原则就是,使用返回的实例进行继续操作而丢掉传入的那个实例。在很多情况下,返回的对象实例会跟传入的不同。唯一的例外是在给 merge() 方法传递实例时,如果该实例是在同一个数据上下文中调用另一个 merge() 或者 find() 返回的实例,则没有区别。

合并实体到 DataContext 的示例:

@Inject
private DataContext dataContext;

private void loadCustomer(Id<Customer, UUID> customerId) {
    Customer customer = dataManager.load(customerId).one();
    Customer trackedCustomer = dataContext.merge(customer);
    customersDc.getMutableItems().add(trackedCustomer);
}

对于一个特定的界面和它所有的内嵌的组件来说,只存在一个 DataContext 单例,在界面 XML 描述存在 <data> 元素的情况下创建。

<data> 元素可以有 readOnly="true" 属性,此时会使用一个特殊的 “不操作“ 的实现,此实现不需要跟踪实体的改动,因此不会影响性能。默认情况下,Studio 生成的实体浏览界面会有只读的数据上下文,所以如果需要在实体浏览界面跟踪实体改动并且提交脏实体,需要再删除 XML 的 readOnly="true" 属性。

获取 DataContext

界面的 DataContext 可以在控制器用注入的方式获取:

@Inject
private DataContext dataContext;

如果只有界面的引用,则可以通过 UiControllerUtils 类获取其 DataContext

DataContext dataContext = UiControllerUtils.getScreenData(screenOrFrame).getDataContext();

UI 组件可以通过下面的方法获取当前界面的 DataContext

DataContext dataContext = UiControllerUtils.getScreenData(getFrame().getFrameOwner()).getDataContext();
父数据上下文

DataContext 实例支持父子关系。如果一个 DataContext 有父上下文,它会将改动的实体提交给父上下文而不是提交给中间件。通过这个功能支持编辑组合关系,从实体只能跟主实体一起保存到数据库。如果一个实体属性使用 @Composition 注解,平台会自动在此属性的编辑界面设置父上下文,从而该属性的改动会保存到主实体的数据上下文。

可以很容易为任何实体和界面提供与此相同的行为。

如果打开的编辑界面需要提交数据到当前界面的数据上下文,可以使用 builder 的 withParentDataContext() 方法:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private DataContext dataContext;

private void editFooWithCurrentDataContextAsParent() {
    FooEdit fooEdit = screenBuilders.editor(Foo.class, this)
            .withScreenClass(FooEdit.class)
            .withParentDataContext(dataContext)
            .build();
    fooEdit.show();
}

如果使用 Screens bean 打开简单界面,需要提供 setter 方法接收父数据上下文:

public class FooScreen extends Screen {

    @Inject
    private DataContext dataContext;

    public void setParentDataContext(DataContext parentDataContext) {
        dataContext.setParent(parentDataContext);
    }
}

然后在创建了界面之后使用:

@Inject
private Screens screens;
@Inject
private DataContext dataContext;

private void openFooScreenWithCurrentDataContextAsParent() {
    FooScreen fooScreen = screens.create(FooScreen.class);
    fooScreen.setParentDataContext(dataContext);
    fooScreen.show();
}

确保父数据上下文没有使用 readOnly="true" 属性。否则在使用这个上下文作为父上下文的时候会抛出异常。

3.5.3.4. 使用数据组件

在本章节将展示使用数据组件的实战例子。

3.5.3.4.1. 声明式创建数据组件

为界面创建数据组件的最简单方法就是在界面的 XML 描述中的 <data> 元素中进行声明式的定义。

考虑包含 CustomerOrderOrderLine 实体的数据模型。Order 实体的编辑界面可以用下面的 XML 定义:

<data> (1)
    <instance id="orderDc" class="com.company.sales.entity.Order"> (2)
        <view extends="_local"> (3)
            <property name="lines" view="_minimal">
                <property name="product" view="_local"/>
                <property name="quantity"/>
            </property>
            <property name="customer" view="_minimal"/>
        </view>

        <loader/> (4)

        <collection id="linesDc" property="lines"/> (5)
    </instance>

    <collection id="customersDc" class="com.company.sales.entity.Customer" view="_minimal"> (6)
        <loader> (7)
            <query><![CDATA[select e from sales_Customer e]]></query>
        </loader>
    </collection>
</data>

这个例子中,会创建下列数据组件:

1 - DataContext 实例。
2 - Order 实体的 InstanceContainer
3 - 容器内实体实例的内联 view。内联视图也可以继承共享视图(定义在 views.xml)。
4 - 加载 Order 实例的 InstanceLoader
5 - 加载内嵌实体 OrderLinesCollectionPropertyContainer ,是绑定到 Order.lines 属性的实体。
6 - 加载 Customer 实体的 CollectionContainerview 属性可以绑定某个共享视图。
7 - CollectionLoader 使用指定的查询加载 Customer 实体。

数据容器可以在可视化组件中这样使用:

<layout>
    <dateField dataContainer="orderDc" property="date"/> (1)
    <form id="form" dataContainer="orderDc"> (2)
        <column>
            <textField property="amount"/>
            <lookupPickerField id="customerField" property="customer"
                               optionsContainer="customersDc"/> (3)
        </column>
    </form>
    <table dataContainer="linesDc"> (4)
        <columns>
            <column id="product"/>
            <column id="quantity"/>
        </columns>
    </table>
1 单独的控件具有 dataContainerproperty 属性。
2 form 会将 dataContainer 传递给 form 的字段,所以字段只需要 property 属性。
3 查找字段有 optionsContainer 属性。
4 表格只有 dataContainer 属性。
3.5.3.4.2. 编程式创建数据组件

可以使用编程的方式在可视化组件中创建和使用数据组件。

下面的例子中,创建了跟前一章一样的编辑界面,使用了相同的数据和可视化组件,只不过是用纯 Java 实现的。

package com.company.sales.web.order;

import com.company.sales.entity.Customer;
import com.company.sales.entity.Order;
import com.company.sales.entity.OrderLine;
import com.haulmont.cuba.core.global.View;
import com.haulmont.cuba.gui.UiComponents;
import com.haulmont.cuba.gui.components.*;
import com.haulmont.cuba.gui.components.data.options.ContainerOptions;
import com.haulmont.cuba.gui.components.data.table.ContainerTableItems;
import com.haulmont.cuba.gui.components.data.value.ContainerValueSource;
import com.haulmont.cuba.gui.model.*;
import com.haulmont.cuba.gui.screen.PrimaryEditorScreen;
import com.haulmont.cuba.gui.screen.StandardEditor;
import com.haulmont.cuba.gui.screen.Subscribe;
import com.haulmont.cuba.gui.screen.UiController;

import javax.inject.Inject;
import java.sql.Date;

@UiController("sales_Order.edit")
public class OrderEdit extends StandardEditor<Order> {

    @Inject
    private DataComponents dataComponents; (1)
    @Inject
    private UiComponents uiComponents;

    private InstanceContainer<Order> orderDc;
    private CollectionPropertyContainer<OrderLine> linesDc;
    private CollectionContainer<Customer> customersDc;
    private InstanceLoader<Order> orderDl;
    private CollectionLoader<Customer> customersDl;

    @Subscribe
    protected void onInit(InitEvent event) {
        createDataComponents();
        createUiComponents();
    }

    private void createDataComponents() {
        DataContext dataContext = dataComponents.createDataContext();
        getScreenData().setDataContext(dataContext); (2)

        orderDc = dataComponents.createInstanceContainer(Order.class);

        orderDl = dataComponents.createInstanceLoader();
        orderDl.setContainer(orderDc); (3)
        orderDl.setDataContext(dataContext); (4)
        orderDl.setView("order-edit");

        linesDc = dataComponents.createCollectionContainer(
                OrderLine.class, orderDc, "lines"); (5)

        customersDc = dataComponents.createCollectionContainer(Customer.class);

        customersDl = dataComponents.createCollectionLoader();
        customersDl.setContainer(customersDc);
        customersDl.setDataContext(dataContext);
        customersDl.setQuery("select e from sales_Customer e"); (6)
        customersDl.setView(View.MINIMAL);
    }

    private void createUiComponents() {
        DateField<Date> dateField = uiComponents.create(DateField.TYPE_DATE);
        getWindow().add(dateField);
        dateField.setValueSource(new ContainerValueSource<>(orderDc, "date")); (7)

        Form form = uiComponents.create(Form.class);
        getWindow().add(form);

        LookupPickerField<Customer> customerField = uiComponents.create(LookupField.of(Customer.class));
        form.add(customerField);
        customerField.setValueSource(new ContainerValueSource<>(orderDc, "customer"));
        customerField.setOptions(new ContainerOptions<>(customersDc)); (8)

        TextField<Integer> amountField = uiComponents.create(TextField.TYPE_INTEGER);
        amountField.setValueSource(new ContainerValueSource<>(orderDc, "amount"));

        Table<OrderLine> table = uiComponents.create(Table.of(OrderLine.class));
        getWindow().add(table);
        getWindow().expand(table);
        table.setItems(new ContainerTableItems<>(linesDc)); (9)

        Button okButton = uiComponents.create(Button.class);
        okButton.setAction(getWindow().getActionNN(WINDOW_COMMIT_AND_CLOSE));
        getWindow().add(okButton);

        Button cancelButton = uiComponents.create(Button.class);
        cancelButton.setAction(getWindow().getActionNN(WINDOW_CLOSE));
        getWindow().add(cancelButton);
    }

    @Override
    protected InstanceContainer<Order> getEditedEntityContainer() { (10)
        return orderDc;
    }

    @Subscribe
    protected void onBeforeShow(BeforeShowEvent event) { (11)
        orderDl.load();
        customersDl.load();
    }
}
1 DataComponents 是创建数据组件的工厂。
2 DataContext 实例在界面注册,以便标准的提交动作能正常工作。
3 orderDl 加载器会加载数据到 orderDc 容器。
4 orderDl 加载器会合并加载的实体到数据上下文以便跟踪改动。
5 linesDc 创建为属性容器。
6 customersDl 加载器指定了一个查询语句。
7 ContainerValueSource 用来绑定单一字段到容器。
8 ContainerOptions 用来为查找控件提供选项。
9 ContainerTableItems 用来绑定表格到容器。
10 getEditedEntityContainer() 被重写了,用来指定容器,替代了 @EditedEntityContainer 注解。
11 在界面展示前加载数据。平台会自动设置编辑实体的 id 到 orderDl
3.5.3.4.3. 数据组件之间的依赖

有时候需要加载和展示依赖同一界面上其它数据的数据。比如,在下面的截屏中,左边的表格展示 orders 的列表,右边的表格展示选中 order 的 lines。右边的列表会在左边列表每次选择改动时刷新。

dep data comp
Figure 21. 表格相互依赖

这个例子中,Order 实体包含了 orderLines 属性,这个是一对多的集合。所以实现这个界面的最简单的方法就是使用带有 orderLines 属性的视图加载 orders 列表,并且使用属性容器来装载依赖的 lines 列表。然后绑定左边的表格到主容器,绑定右边的表格到属性容器。

但是这个方案有一个隐藏的性能问题:会加载左边表格所有 orders 的所有 lines,尽管每次只是给单一的 order 展示 lines。orders 列表越长,会加载越多不需要的数据,因为用户只有很小的可能会查看每个 order 关联的 lines。这就是为什么推荐只在加载单一主实体的时候使用属性容器以及范围广的视图,比如在 order 编辑界面。

还有,主实体也许跟依赖的实体没有直接的属性关联关系。这种情况下,上面使用属性容器的方案根本就行不通。

组织界面内数据关系的通常方法是使用带参数的查询。依赖的加载器包含一个带参数的查询语句,这个参数关联到主实体的数据,当主容器的当前实体更改时,需要手动设置参数并且触发依赖的加载器。

下面这个例子的界面包含两对依赖的容器/加载器以及绑定的表格。

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd">
    <data>
        <collection id="ordersDc" (1)
                    class="com.company.sales.entity.Order" view="order-with-customer">
            <loader id="ordersDl">
                <query>select e from sales_Order e></query>
            </loader>
        </collection>

        <collection id="orderLinesDc" (2)
                    class="com.company.sales.entity.OrderLine" view="_local">
            <loader id="orderLinesDl">
                <query>select e from sales_OrderLine e where e.order = :order</query>
            </loader>
        </collection>
    </data>
    <layout>
        <hbox id="mainBox" width="100%" height="100%" spacing="true">
            <table id="ordersTable" width="100%" height="100%"
                   dataContainer="ordersDc"> (3)
                <columns>
                    <column id="customer"/>
                    <column id="date"/>
                    <column id="amount"/>
                </columns>
                <rows/>
            </table>
            <table id="orderLinesTable" width="100%" height="100%"
                   dataContainer="orderLinesDc"> (4)
                <columns>
                    <column id="product"/>
                    <column id="quantity"/>
                </columns>
                <rows/>
            </table>
        </hbox>
    </layout>
</window>
1 主容器和主加载器
2 依赖容器和加载器
3 主表格
4 从表格
package com.company.sales.web.order;

import com.company.sales.entity.Order;
import com.company.sales.entity.OrderLine;
import com.haulmont.cuba.gui.model.CollectionLoader;
import com.haulmont.cuba.gui.model.InstanceContainer;
import com.haulmont.cuba.gui.screen.*;
import javax.inject.Inject;

@UiController("order-list")
@UiDescriptor("order-list.xml")
@LookupComponent("ordersTable")
public class OrderList extends StandardLookup<Order> { (1)

    @Inject
    private CollectionLoader<Order> ordersDl;
    @Inject
    private CollectionLoader<OrderLine> orderLinesDl;

    @Subscribe
    protected void onBeforeShow(BeforeShowEvent event) {
        ordersDl.load(); (2)
    }

    @Subscribe(id = "ordersDc", target = Target.DATA_CONTAINER)
    protected void onOrdersDcItemChange(InstanceContainer.ItemChangeEvent<Order> event) {
        orderLinesDl.setParameter("order", event.getItem()); (3)
        orderLinesDl.load();
    }
}
1 界面控制器类没有 @LoadDataBeforeShow 注解,所以加载器不会自动触发。
2 主加载器在 BeforeShowEvent 处理器中触发。
3 在主容器的 ItemChangeEvent 处理器中,给依赖加载器设置了参数并且触发依赖加载。

使用 DataLoadCoordinator facet 可以将数据组件通过声明式的方式连接,不需要写 Java 代码。

3.5.3.4.4. 在数据加载器中使用界面参数

很多时候需要根据界面传递的参数加载界面需要的数据。下面是一个浏览界面的示例,使用了界面参数并且在加载数据时使用参数来过滤数据。

假设有两个实体:CountryCityCity 实体有 country 属性,是 Country 的引用。在 city 的浏览界面,可以接受一个 country 的实例,然后只展示该 country 的 city。

首先,看看 city 界面的 XML 描述。其数据加载器包含一个带有参数的查询:

<collection id="citiesDc"
            class="com.company.demo.entity.City"
            view="_local">
    <loader id="citiesDl">
        <query>
            <![CDATA[select e from demo_City e where e.country = :country]]>
        </query>
    </loader>
</collection>

city 界面控制器有一个 public 的参数setter,然后在 BeforeShowEvent 处理器中使用了参数。注意,该界面没有 @LoadDataBeforeShow 注解,因为需要显式的触发数据加载:

@UiController("demo_City.browse")
@UiDescriptor("city-browse.xml")
@LookupComponent("citiesTable")
public class CityBrowse extends StandardLookup<City> {

    @Inject
    private CollectionLoader<City> citiesDl;

    private Country country;

    public void setCountry(Country country) {
        this.country = country;
    }

    @Subscribe
    private void onBeforeShow(BeforeShowEvent event) {
        if (country == null)
            throw new IllegalStateException("country parameter is null");
        citiesDl.setParameter("country", country);
        citiesDl.load();
    }
}

city 界面可以从其它界面为其传递一个 country 实例并打开,示例:

@Inject
private ScreenBuilders screenBuilders;

private void showCitiesOfCountry(Country country) {
    CityBrowse cityBrowse = screenBuilders.screen(this)
            .withScreenClass(CityBrowse.class)
            .build();
    cityBrowse.setCountry(country);
    cityBrowse.show();
}
3.5.3.4.5. 自定义排序

UI table 中按照实体属性排序的功能是通过 CollectionContainerSorter 实现的,需要为 CollectionContainer 设置该排序器。标准的实现是,如果数据在一页以内能显示,则在内存做数据排序,否则会使用合适的 "order by" 语句发送数据库的请求。"order by" 语句是中间层的 JpqlSortExpressionProvider bean创建的。

有些实体属性需要一个特殊的排序实现。下面我们用个例子解释一下如何自定义排序:假设有 Foo 实体带有 String 类型的 number 属性,但是我们知道该属性其实是只保存数字。所以我们希望排序的顺序是 1, 2, 3, 10, 11。但是,默认的排序行为会产生这样的结果:1, 10, 11, 2, 3

首先,在 web 模块创建一个 CollectionContainerSorter 类的子类,在内存进行排序:

package com.company.demo.web;

import com.company.demo.entity.Foo;
import com.haulmont.chile.core.model.MetaClass;
import com.haulmont.chile.core.model.MetaPropertyPath;
import com.haulmont.cuba.core.entity.Entity;
import com.haulmont.cuba.core.global.Sort;
import com.haulmont.cuba.gui.model.BaseCollectionLoader;
import com.haulmont.cuba.gui.model.CollectionContainer;
import com.haulmont.cuba.gui.model.impl.CollectionContainerSorter;
import com.haulmont.cuba.gui.model.impl.EntityValuesComparator;

import javax.annotation.Nullable;
import java.util.Comparator;
import java.util.Objects;

public class CustomCollectionContainerSorter extends CollectionContainerSorter {

    public CustomCollectionContainerSorter(CollectionContainer container,
                                           @Nullable BaseCollectionLoader loader) {
        super(container, loader);
    }

    @Override
    protected Comparator<? extends Entity> createComparator(Sort sort, MetaClass metaClass) {
        MetaPropertyPath metaPropertyPath = Objects.requireNonNull(
                metaClass.getPropertyPath(sort.getOrders().get(0).getProperty()));

        if (metaPropertyPath.getMetaClass().getJavaClass().equals(Foo.class)
                && "number".equals(metaPropertyPath.toPathString())) {
            boolean isAsc = sort.getOrders().get(0).getDirection() == Sort.Direction.ASC;
            return Comparator.comparing(
                    (Foo e) -> e.getNumber() == null ? null : Integer.valueOf(e.getNumber()),
                    EntityValuesComparator.asc(isAsc));
        }
        return super.createComparator(sort, metaClass);
    }
}

如果有几个界面需要这个自定义的排序,可以在界面中实例化 CustomCollectionContainerSorter

public class FooBrowse extends StandardLookup<Foo> {

    @Inject
    private CollectionContainer<Foo> fooDc;
    @Inject
    private CollectionLoader<Foo> fooDl;

    @Subscribe
    private void onInit(InitEvent event) {
        CustomCollectionContainerSorter sorter = new CustomCollectionContainerSorter(fooDc, fooDl);
        fooDc.setSorter(sorter);
    }
}

如果排序器定义了一些全局的行为,则可以创建自定义的工厂在系统级别实例化该排序器:

package com.company.demo.web;

import com.haulmont.cuba.gui.model.*;
import javax.annotation.Nullable;

public class CustomSorterFactory extends SorterFactory {

    @Override
    public Sorter createCollectionContainerSorter(CollectionContainer container,
                                                  @Nullable BaseCollectionLoader loader) {
        return new CustomCollectionContainerSorter(container, loader);
    }
}

然后在 web-spring.xml 注册该工厂以替换默认工厂:

<bean id="cuba_SorterFactory" class="com.company.demo.web.CustomSorterFactory"/>

现在我们在 core 模块为数据库级别的排序创建我们自己定义的 JpqlSortExpressionProvider 实现:

package com.company.demo.core;

import com.company.demo.entity.Foo;
import com.haulmont.chile.core.model.MetaPropertyPath;
import com.haulmont.cuba.core.app.DefaultJpqlSortExpressionProvider;

public class CustomSortExpressionProvider extends DefaultJpqlSortExpressionProvider {

    @Override
    public String getDatatypeSortExpression(MetaPropertyPath metaPropertyPath, boolean sortDirectionAsc) {
        if (metaPropertyPath.getMetaClass().getJavaClass().equals(Foo.class)
                && "number".equals(metaPropertyPath.toPathString())) {
            return String.format("CAST({E}.%s BIGINT)", metaPropertyPath.toString());
        }
        return String.format("{E}.%s", metaPropertyPath.toString());
    }
}

spring.xml 注册此 expression provider,覆盖默认:

<bean id="cuba_JpqlSortExpressionProvider" class="com.company.demo.core.CustomSortExpressionProvider"/>

3.5.4. Facets

Facet 是不会被添加到界面布局的界面元素,与可视化组件不同,它们为界面或者界面组件提供补充功能。

在 XML 描述中,使用 facets 元素定义 facet。CUBA 框架提供以下 facet:

应用程序或者扩展组件可以提供其自有的 facet。可以按照下面的步骤创建自定义的 facet:

  1. 创建接口,继承自 com.haulmont.cuba.gui.components.Facet

  2. 创建基于 com.haulmont.cuba.web.gui.WebAbstractFacet 的实现类。

  3. 创建 Spring Bean,实现 com.haulmont.cuba.gui.xml.FacetProvider 接口,使用你定义的 facet 作为参数。

  4. 创建能在界面 XML 中使用的 XSD。

  5. 另外,还可以使用 元数据注解 标记 facet 接口和方法,这样可以在 CUBA Studio 的界面设计器支持这些 facet。

框架中的 ClipboardTriggerWebClipboardTriggerClipboardTriggerFacetProvider,这三个类可以作为 facet 的示范参考。

3.5.4.1. Timer

定时器是一个 facet,可以以一定的时间间隔运行一些界面控制器的代码。定时器是在一个处理用户事件的线程里面运行的,所以可以更新界面组件。当创建定时器的界面被关闭之后,定时器就会停止工作了。

创建定时器的主要方法就是在界面 XML 描述中的 facets 元素中进行声明。

定时器使用 timer 元素描述。

  • delay - 必选属性;按毫秒定义定时器执行的时间间隔。

  • autostart – 可选属性;当设置成 true 的时候,定时器会在界面打开的时候立即自动启动。默认值是 false,也就是说只有在调用定时器的 start() 方法之后才会启动。

  • repeating – 可选属性;开启定时器的重复执行模式。如果这个属性设置的是 true,定时器会按照 delay 设置的时间间隔反复一轮一轮的执行。否则只会在 delay 设定的毫秒时间之后执行一次。

可以在界面控制器中订阅 TimerActionEvent 事件,以便定时执行一些代码。

下面的示例演示了定义定时器并在控制器内订阅其事件:

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd" ...>
    <facets>
        <timer id="myTimer" delay="3000" autostart="true" repeating="true"/>
    </facets>
@Inject
private Notifications notifications;

@Subscribe("myTimer")
private void onTimer(Timer.TimerActionEvent event) {
    notifications.create(Notifications.NotificationType.TRAY)
        .withCaption("on timer")
        .show();
}

定时器可以作为字段注入一个界面控制器,也可以通过 getWindow().getFacet() 方法获得。定时器的执行可以用定时器的 start()stop() 方法控制。对于已经启动的定时器,会忽略再次调用 start(),但是当定时器使用 stop() 方法停止之后,可以通过 start() 方法再次启动。

下面示例展示了如何通过 XML 描述来定义定时器以及在控制器中使用定时器监听:

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd" ...>
    <facets>
        <timer id="helloTimer" delay="5000"/>
    <facets>
@Inject
private Timer helloTimer;
@Inject
private Notifications notifications;

@Subscribe("helloTimer")
protected void onHelloTimerTimerAction(Timer.TimerActionEvent event) { (1)
    notifications.create()
            .withCaption("Hello")
            .show();
}

@Subscribe("helloTimer")
protected void onHelloTimerTimerStop(Timer.TimerStopEvent event) { (2)
    notifications.create()
            .withCaption("Timer is stopped")
            .show();
}

@Subscribe
protected void onInit(InitEvent event) { (3)
    helloTimer.start();
}
1 定时器执行处理器
2 定时器停止事件
3 启动定时器

定时器也可以在控制器里面创建,如果是这样的话,需要显式的使用 addFacet() 方法把这个定时器加到界面中,比如:

@Inject
private Notifications notifications;
@Inject
private Facets facets;

@Subscribe
protected void onInit(InitEvent event) {
    Timer helloTimer = facets.create(Timer.class);
    getWindow().addFacet(helloTimer); (1)
    helloTimer.setId("helloTimer"); (2)
    helloTimer.setDelay(5000);
    helloTimer.setRepeating(true);

    helloTimer.addTimerActionListener(e -> { (3)
        notifications.create()
                .withCaption("Hello")
                .show();
    });

    helloTimer.addTimerStopListener(e -> { (4)
        notifications.create()
                .withCaption("Timer is stopped")
                .show();
    });

    helloTimer.start(); (5)
}
1 在页面中添加定时器
2 设置定时器参数
3 添加执行处理器
4 添加停止事件监听器
5 启动定时器
3.5.4.2. ClipboardTrigger

ClipboardTrigger 是一个 facet,可以用来从界面字段中复制内容至系统剪切板。在界面 XML 的 facets 元素定义,有如下属性:

  • input - 文本控件的标识符,必须是 TextInputField 的子类,比如 TextFieldTextArea 等。

  • button - Button 的标识符,点击该按钮可以触发复制的动作。

示例:

<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd" ...>
    <facets>
        <clipboardTrigger id="clipper" button="clipBtn" input="textArea"/>
    </facets>
    <layout expand="textArea" spacing="true">
        <textArea id="textArea" width="100%"/>
        <button id="clipBtn" caption="Clip text"/>
    </layout>
</window>
@Inject
private Notifications notifications;

@Subscribe("clipBtn")
private void onClipBtnClick(Button.ClickEvent event) {
    notifications.create().withCaption("Copied to clipboard").show();
}
3.5.4.3. DataLoadCoordinator

DataLoadCoordinator facet 设计用来声明式的将数据加载器和数据容器、可视化组件、界面事件进行连接。其有两种工作模式:

  • 自动模式,依赖于使用特定前缀的参数名称。前缀表示产生参数值和更改事件的组件。如果加载器的查询语句中没有参数(尽管在查询条件中可能有参数),则该加载器会在 界面BeforeShowEvent界面片段AttachEvent中自动刷新。

    默认情况下,数据容器的参数前缀是 container_,可视化组件的参数前缀是 component_

  • 手动模式,连接通过 facet 或者通过 API 配置。

也可以有半自动模式,有些连接通过显式指定,而其它的则配置为自动模式。

当在界面中使用 DataLoadCoordinator 是,界面控制器的 @LoadDataBeforeShow 注解将会失去作用,因为数据的加载通过 facet 和自定义的时间处理器(如果有的话)控制。

参阅下面的使用示例。

  1. 自动配置,auto 属性设置为 true

    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
            xmlns:c="http://schemas.haulmont.com/cuba/screen/jpql_condition.xsd" ...>
        <data readOnly="true">
            <collection id="ownersDc" class="com.company.demo.entity.Owner" view="owner-view">
                <loader id="ownersDl">
                    <query>
                        <![CDATA[select e from demo_Owner e]]> (1)
                        <condition>
                            <and>
                                <c:jpql>
                                    <c:where>e.category = :component_categoryFilterField</c:where> (2)
                                </c:jpql>
                                <c:jpql>
                                    <c:where>e.name like :component_nameFilterField</c:where> (3)
                                </c:jpql>
                            </and>
                        </condition>
                    </query>
                </loader>
            </collection>
            <collection id="petsDc" class="com.company.demo.entity.Pet">
                <loader id="petsDl">
                    <query><![CDATA[select e from demo_Pet e where e.owner = :container_ownersDc]]></query> (4)
                </loader>
            </collection>
        </data>
        <facets>
            <dataLoadCoordinator auto="true"/>
        </facets>
        <layout>
            <pickerField id="categoryFilterField" metaClass="demo_OwnerCategory"/>
            <textField id="nameFilterField"/>
    1 - 查询中没有参数,所以 ownersDl 加载器会在 BeforeShowEvent 触发。
    2 - ownersDl 加载器也会在 categoryFilterField 组件值更改的时候触发。
    3 - ownersDl 加载器也会在 nameFilterField 组件值更改的时候触发。由于条件使用了 like 子句,值会被自动包装在 '(?i)% %' 中,以便提供大小写不敏感查找
    4 - petsDl 加载器会在 ownersDc 数据容器内容变化时触发。
  2. 手动配置,auto 属性未设置(或设置为 false),嵌套的记录定义了数据加载器会何时触发。

    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
            xmlns:c="http://schemas.haulmont.com/cuba/screen/jpql_condition.xsd" ...>
        <data readOnly="true">
            <collection id="ownersDc" class="com.company.demo.entity.Owner" view="owner-view">
                <loader id="ownersDl">
                    <query>
                        <![CDATA[select e from demo_Owner e]]>
                        <condition>
                            <and>
                                <c:jpql>
                                    <c:where>e.category = :category</c:where>
                                </c:jpql>
                                <c:jpql>
                                    <c:where>e.name like :name</c:where>
                                </c:jpql>
                            </and>
                        </condition>
                    </query>
                </loader>
            </collection>
            <collection id="petsDc" class="com.company.demo.entity.Pet">
                <loader id="petsDl">
                    <query><![CDATA[select e from demo_Pet e where e.owner = :owner]]></query>
                </loader>
            </collection>
        </data>
        <facets>
            <dataLoadCoordinator>
                <refresh loader="ownersDl"
                         onScreenEvent="Init"/> (1)
    
                <refresh loader="ownersDl" param="category"
                         onComponentValueChanged="categoryFilterField"/> (2)
    
                <refresh loader="ownersDl" param="name"
                         onComponentValueChanged="nameFilterField" likeClause="CASE_INSENSITIVE"/> (3)
    
                <refresh loader="petsDl" param="owner"
                         onContainerItemChanged="ownersDc"/> (4)
            </dataLoadCoordinator>
        </facets>
        <layout>
            <pickerField id="categoryFilterField" metaClass="demo_OwnerCategory"/>
            <textField id="nameFilterField"/>
    1 - ownersDl 加载器会在 InitEvent 事件触发。
    2 - ownersDl 加载器会在 categoryFilterField 组件值更改的时候触发。
    3 - ownersDl 加载器会在 nameFilterField 组件值更改的时候触发。 由于条件使用了 like 子句,值会被自动包装在 '(?i)% %' 中,以便提供大小写不敏感查找
    4 - petsDl 加载器会在 ownersDc 数据容器内容变化时触发。
  3. 半自动配置,当 auto 属性设置为 true 并且也有一些手动配置的触发器,facet 会为所有没有手动配置的加载器做自动配置。

    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd" ...>
        <data readOnly="true">
            <collection id="ownersDc" class="com.company.demo.entity.Owner" view="owner-view">
                <loader id="ownersDl">
                    <query>
                        <![CDATA[select e from demo_Owner e]]>
                    </query>
                </loader>
            </collection>
            <collection id="petsDc" class="com.company.demo.entity.Pet">
                <loader id="petsDl">
                    <query><![CDATA[select e from demo_Pet e where e.owner = :container_ownersDc]]></query> (1)
                </loader>
            </collection>
        </data>
        <facets>
            <dataLoadCoordinator auto="true">
                <refresh loader="ownersDl" onScreenEvent="Init"/> (2)
            </dataLoadCoordinator>
        </facets>
    1 - petsDl 加载器配置在 ownersDc 数据容器内容变化时自动触发。
    2 - ownersDl 为手动配置,在 InitEvent 事件触发。
3.5.4.4. NotificationFacet

NotificationFacet facet 可以预配置 通知消息。用声明式的方法定义通知消息,而可以替代 Notifications.create() 方法。NotificationFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: notification

示例:

<facets>
    <notification id="notification"
                  caption="msg://notificationFacet"
                  description="msg://notificationDescription"
                  type="TRAY"/>
</facets>

NotificationFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected NotificationFacet notification;

public void showNotification() {
    notification.show();
}

notification 有如下属性:

  • onAction 属性设置 操作 标识符。表示当该操作完成后,需要显示通知消息。

    <actions>
        <action id="notificationAction"/>
    </actions>
    <facets>
        <notification id="notification"
                      caption="msg://notificationFacet"
                      onAction="notificationAction"
                      type="TRAY"/>
    </facets>
  • onButton 属性设置 按钮 标识符。表示当点击该按钮,需要显示通知消息。

    <facets>
        <notification id="notification"
                      caption="msg://notificationFacet"
                      onButton="notificationBtn"
                      type="TRAY"/>
    </facets>
    <layout>
        <button id="notificationBtn"
                caption="Show notification"/>
    </layout>
  • 如果 contentMode 属性设置为 ContentMode.HTML,可以用 htmlSanitizerEnabled 属性为通知内容启用 HTML 清理功能。

    <facets>
        <notification id="notificationFacetOn"
                      caption="NotificationFacet with Sanitizer"
                      contentMode="HTML"
                      htmlSanitizerEnabled="true"
                      onButton="showNotificationFacetOnBtn"
                      type="TRAY"/>
        <notification id="notificationFacetOff"
                      caption="NotificationFacet without Sanitizer"
                      contentMode="HTML"
                      htmlSanitizerEnabled="false"
                      onButton="showNotificationFacetOffBtn"
                      type="TRAY"/>
    </facets>

    htmlSanitizerEnabled 属性会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。


3.5.4.5. MessageDialogFacet

MessageDialogFacet facet 可以预配置 消息对话框。用声明式的方法定义消息对话框,而可以替代 Dialogs.createMessageDialog() 方法。MessageDialogFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: messageDialog

示例:

<facets>
    <messageDialog id="messageDialog"
                   caption="msg://msgDialogFacet"
                   message="msg://msgDialogDemo"
                   modal="true"
                   closeOnClickOutside="true"/>
</facets>

MessageDialogFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected MessageDialogFacet messageDialog;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    messageDialog.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。

<actions>
    <action id="dialogAction"/>
</actions>
<facets>
    <messageDialog id="messageDialog"
                   caption="msg://msgDialogFacet"
                   message="msg://msgDialogDemo"
                   onAction="dialogAction"/>
</facets>

3.5.4.6. OptionDialogFacet

OptionDialogFacet facet 可以预配置 选项对话框。用声明式的方法定义选项对话框,而可以替代 Dialogs.createOptionDialog() 方法。OptionDialogFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: optionDialog

示例:

<facets>
    <optionDialog id="optionDialog"
                  caption="msg://optionDialogCaption"
                  message="msg://optionDialogMsg"
                  onAction="dialogAction">
        <actions>
            <action id="ok"
                    caption="msg://optDialogOk"
                    icon="CHECK"
                    primary="true"/>
            <action id="cancel"
                    caption="msg://optDialogCancel"
                    icon="BAN"/>
        </actions>
    </optionDialog>
</facets>

OptionDialogFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected OptionDialogFacet optionDialog;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    optionDialog.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。

optionDialogactions 元素,可以配置一组 对话框操作

如需为对话框操作自定义逻辑,要在控制器创建适当的处理器方法:

@Inject
protected OptionDialogFacet optionDialog;
@Inject
protected Notifications notifications;

@Install(to = "optionDialog.ok", subject = "actionHandler") (1)
protected void onDialogOkAction(DialogActionPerformedEvent<OptionDialogFacet> event) {
    String actionId = event.getDialogAction().getId();

    notifications.create(Notifications.NotificationType.TRAY)
            .withCaption("Dialog action performed: " + actionId)
            .show();
}

@Install(to = "optionDialog.cancel", subject = "actionHandler") (2)
protected void onDialogCancelAction(DialogActionPerformedEvent<OptionDialogFacet> event) {
    String actionId = event.getDialogAction().getId();

    notifications.create(Notifications.NotificationType.TRAY)
            .withCaption("Dialog action performed: " + actionId)
            .show();
}
1 - 当用户在选项对话框点击 OK 按钮时调用的处理器。
2 - 当用户在选项对话框点击 Cancel 按钮时调用的处理器。

optionDialog 的属性

caption - contentMode - height - htmlSanitizerEnabled - id - maximized - message - onAction - onButton - stylename - width

optionDialog 的元素

actions

3.5.4.7. InputDialogFacet

InputDialogFacet facet 可以预配置 输入对话框。用声明式的方法定义输入对话框,而可以替代 Dialogs.createInputDialog() 方法。InputDialogFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: inputDialog

示例:

<facets>
    <inputDialog id="inputDialogFacet"
                 caption="msg://inputDialog"
                 onAction="dialogAction">
        <parameters>
            <booleanParameter id="boolParam"
                              caption="msg://boolParam"
                              defaultValue="true"
                              required="true"/>
            <intParameter id="intParam"
                          caption="msg://intParam"
                          required="true"/>
            <entityParameter id="userParam"
                             caption="msg://userParam"
                             entityClass="com.haulmont.cuba.security.entity.User"
                             required="true"/>
        </parameters>
    </inputDialog>
</facets>

InputDialogFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected InputDialogFacet inputDialog;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    inputDialog.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。

除了 OptionDialogFacet 描述的属性之外,inputDialog 还有 defaultActions 属性,用来定义对话框使用的一组操作。值可以是:

  • OK

  • OK_CANCEL

  • YES_NO

  • YES_NO_CANCEL

默认值是 OK_CANCEL

inputDialog 元素:

  • parameters 元素可以包含如下参数:

    • stringParameter

    • booleanParameter

    • intParameter

    • doubleParameter

    • longParameter

    • bigDecimalParameter

    • dateParameter

    • timeParameter

    • dateTimeParameter

    • entityParameter

    • enumParameter

如需为对话框操作自定义逻辑,要在控制器创建适当的处理器方法。

要处理输入对话框的结果,可以安装相应的代理:

@Install(to = "inputDialogFacet", subject = "dialogResultHandler")
public void handleDialogResults(InputDialog.InputDialogResult dialogResult) {
    String closeActionType = dialogResult.getCloseActionType().name();
    String values = dialogResult.getValues().entrySet()
            .stream()
            .map(entry -> String.format("%s = %s", entry.getKey(), entry.getValue()))
            .collect(Collectors.joining(", "));

    notifications.create(Notifications.NotificationType.HUMANIZED)
            .withCaption("InputDialog Result Handler")
            .withDescription("Close Action: " + closeActionType +
                    ". Values: " + values)
            .show();
}
inputDialog 的属性

caption - contentMode - defaultActions - height - id - maximized - message - onAction - onButton - stylename - width

inputDialog 的元素

actions - parameters

3.5.4.8. ScreenFacet

ScreenFacet facet 可以预配置 打开界面 以及为界面 传递参数。用声明式的方法定义界面,而可以替代 ScreenBuilders.screen() 方法。ScreenFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: screen

示例:

<facets>
    <screen id="testScreen"
            screenId="sample_TestScreen"
            onButton="openTestScreen">
        <properties>
            <property name="num" value="42"/>
        </properties>
    </screen>
</facets>

ScreenFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected ScreenFacet testScreen;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    testScreen.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。

ScreenFacet 有如下属性:

  • screenId – 指定需要打开的界面 id。

  • screenClass – 指定需要打开的界面控制器 Java 类。

  • openMode – 界面打开模式,对应于 OpenMode 枚举:NEW_TABDIALOGNEW_WINDOWROOTTHIS_TAB。默认值是 NEW_TAB

ScreenFacetproperties 元素,表示一组属性,会通过 public setter 注入到打开的界面中。参阅 为界面传递参数


screen 的属性

id - onAction - onButton - openMode - screenClass - screenId

screen 的元素

properties

3.5.4.9. EditorScreenFacet

EditorScreenFacet facet 可以预配置 编辑界面。用声明式的方法定义编辑界面,而可以替代 ScreenBuilders.editor() 方法。EditorScreenFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: editorScreen

示例:

<facets>
    <editorScreen id="userEditor"
                  openMode="DIALOG"
                  editMode="CREATE"
                  entityClass="com.haulmont.cuba.security.entity.User"
                  onAction="action"/>
</facets>

EditorScreenFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected EditorScreenFacet userEditor;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    userEditor.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。

EditorScreenFacet 有下列属性:

  • addFirst – 定义一个新 item 添加的位置,是集合的开始还是结尾。只影响单独的容器,对嵌套容器来说,新 item 总是添加至尾部。

  • container – 设置 CollectionContainer。当界面提交时会更新该容器。如果是用的 嵌套 容器,框架会自动初始化父实体的引用并设置编辑组件的数据上下文。

  • editMode – 设置界面的编辑模式,对应于 EditMode 枚举:CREATE(新建实体),或 EDIT(编辑已有实体)。

  • entityClass – 实体类的全名称。

  • field – 设置 PickerField 组件 id。如果设置了该字段,框架会在编辑器成功提交后,将提交的实体设置到这个 Field 上。

  • listComponent – 设置列表组件 id。如果没设置 container,则通过列表组件获取。通常,列表组件是 TableDataGrid,用来展示实体列表。


editorScreen 的属性

addFirst - container - editMode - entityClass - field - id - listComponent onAction - onButton - openMode - screenClass - screenId

editorScreen 的元素

properties

3.5.4.10. LookupScreenFacet

LookupScreenFacet facet 可以预配置 查找界面。用声明式的方法定义查找界面,而可以替代 ScreenBuilders.lookup() 方法。LookupScreenFacet 用界面 xml 描述的 facets 元素定义。

组件的 xml 名称: lookupScreen

示例:

<lookupScreen id="userLookup"
              openMode="DIALOG"
              entityClass="com.haulmont.cuba.security.entity.User"
              listComponent="usersTable"
              field="pickerField"
              container="userDc"
              onAction="lookupAction"/>

LookupScreenFacet 配置的界面,也可以显式的调用 show() 方法展示:

@Inject
protected LookupScreenFacet userLookup;

@Subscribe("showDialog")
public void onShowDialogClick(Button.ClickEvent event) {
    userLookup.show();
}

另外,这个 facet 还可以通过 id 关联至任何操作(参阅 onAction 属性)或按钮(参阅 onButton 属性)。


lookupScreen 的属性

container - entityClass - field - id - listComponent - onAction - onButton - openMode - screenClass - screenId

lookupScreen 的元素

properties

3.5.5. 操作

Action 是一个接口,这个接口是对可视化组件的操作(换句话说,一些功能)的抽象。当从不同的可视化组件中调用相同的操作时(例如,分别从按钮和表格右键菜单中调用同一个操作),它特别有用。此外,此接口定义了一些通用的属性,例如名称、键盘快捷键、可访问性和可见性标志等。

可以 声明式 的创建操作,也可以通过继承 BaseAction 创建操作类。另外,对于表格和选取器组件,框架提供了一组标准操作

与操作关联的可视化组件可以有两种类型:

  • 只单个操作的可视化组件,这类组件实现 Component.ActionOwner 接口。这类组件有ButtonLinkButton

    通过调用组件的 ActionOwner.setAction() 方法反操作链接到组件。此时,组件使用操作的属性设置自身相应的属性。(有关详细信息,请参阅组件概述)。

  • 包含多个操作的可视化组件,这类组件实现 Component.ActionsHolder 接口。这类组件有 WindowFragmentDataGridTable及其继承者, TreePopupButtonPickerFieldLookupPickerField

    ActionsHolder.addAction() 方法用于向组件添加操作。在组件中实现此方法会检查它是否已包含具有相同标识符的操作。如果包含,则现有操作将替换为新操作。因此,可以在界面描述中声明标准操作,然后在控制器中创建具有不同属性的新操作,并将其添加到组件中。

3.5.5.1. 声明式操作

可以在 XML 界面描述中为任何实现了 Component.ActionsHolder 接口的组件指定一组操作,包括整个窗口或 frame。 操作的定义使用 actions 元素完成,它包含嵌套的 action 元素。

action 元素有以下属性:

  • id − 标识符,在 ActionsHolder 组件中应该是唯一的。

  • type - 定义指定的操作类型。如果设置了该属性,框架会搜索带有 @ActionType 注解以及指定类型的类,并用该类实例化此操作。如果未指定类型,框架会创建一个BaseAction类的实例。参阅 标准操作 了解如何使用框架提供的操作类型,以及 自定义操作类型 了解如何创建自己的操作类型。

  • caption – 操作名称。

  • description – 操作描述。

  • enable – 可用性标识(true / false)。

  • icon – 操作图标。

  • primary - 属性,表明是否应使用特殊视觉样式(true / false)突出显示表示此操作的按钮。

    突出显示在 hover 主题中默认可用; 要在 halo 主题中启用此功能,请将 $cuba-highlight-primary-action 样式变量设置为 true

    默认情况下,create 标准列表操作和查找界面中的 lookupSelectAction 是突出显示的。

    actions primary
  • shortcut - 快捷键。

    可以在 XML 描述中对快捷键值进行硬编码。可选的修饰键:ALTCTRLSHIFT ,由“ - ”字符分隔。例如:

    <action id="create" shortcut="ALT-N"/>

    要避免使用硬编码值,可以使用下面列表中的预定义快捷键别名,例如:

    <action id="edit" shortcut="${TABLE_EDIT_SHORTCUT}"/>
    • TABLE_EDIT_SHORTCUT

    • COMMIT_SHORTCUT

    • CLOSE_SHORTCUT

    • FILTER_APPLY_SHORTCUT

    • FILTER_SELECT_SHORTCUT

    • NEXT_TAB_SHORTCUT

    • PREVIOUS_TAB_SHORTCUT

    • PICKER_LOOKUP_SHORTCUT

    • PICKER_OPEN_SHORTCUT

    • PICKER_CLEAR_SHORTCUT

    另一种选择是使用 Config 接口和方法的完全限定名称,这个方法返回快捷键定义:

    <action id="remove" shortcut="${com.haulmont.cuba.client.ClientConfig#getTableRemoveShortcut}"/>
  • visible – 可见性标识 (true / false).

下面是操作声明和处理的示例。

  • 为整个界面声明操作:

    <window>
        <actions>
            <action id="sayHello" caption="msg://sayHello" shortcut="ALT-T"/>
        </actions>
    
        <layout>
            <button action="sayHello"/>
        </layout>
    </window>
    // controller
    @Inject
    private Notifications notifications;
    
    @Subscribe("sayHello")
    protected void onSayHelloActionPerformed(Action.ActionPerformedEvent event) {
        notifications.create()
                    .withCaption("Hello")
                    .withType(Notifications.NotificationType.HUMANIZED)
                    .show();
    }

    在上面的示例中,声明了一个操作,它的标识符是 sayHello,标题来自界面的消息包。此操作被绑定到一个按钮,按钮的标题将被设置为操作的名称。界面控制器订阅操作的 ActionPerformedEvent,这样当用户单击按钮或按下 ALT-T 快捷键时,将调用 onSayHelloActionPerformed() 方法。

注意,为整个界面声明的操作不会刷新状态。也就是说,如果一个操作有特定的 enabledRule,直到手动调用 refreshState() 时才会应用。

  • PopupButton声明操作:

    <popupButton id="sayBtn" caption="Say">
        <actions>
            <action id="hello" caption="Say Hello"/>
            <action id="goodbye" caption="Say Goodbye"/>
        </actions>
    </popupButton>
    // controller
    @Inject
    private Notifications notifications;
    
    private void showNotification(String message) {
        notifications.create()
                .withCaption(message)
                .withType(NotificationType.HUMANIZED)
                .show();
    }
    
    @Subscribe("sayBtn.hello")
    private void onSayBtnHelloActionPerformed(Action.ActionPerformedEvent event) {
        notifications.create()
                .withCaption("Hello")
                .show();
    }
    
    @Subscribe("sayBtn.goodbye")
    private void onSayBtnGoodbyeActionPerformed(Action.ActionPerformedEvent event) {
        notifications.create()
                .withCaption("Hello")
                .show();
    }
  • Table声明操作:

    <groupTable id="customersTable" width="100%" dataContainer="customersDc">
        <actions>
            <action id="create" type="create"/>
            <action id="edit" type="edit"/>
            <action id="remove" type="remove"/>
            <action id="copy" caption="Copy" icon="COPY" trackSelection="true"/>
        </actions>
        <columns>
            <!-- -->
        </columns>
        <rowsCount/>
        <buttonsPanel alwaysVisible="true">
            <!-- -->
            <button action="customersTable.copy"/>
        </buttonsPanel>
    </groupTable>
    // controller
    
    @Subscribe("customersTable.copy")
    protected void onCustomersTableCopyActionPerformed(Action.ActionPerformedEvent event) {
        // ...
    }

    在这个例子中,除了表格的 createeditremove 标准动作之外,还声明了 copy 操作。trackSelection="true" 属性表示如果表格中没有行被选中,则操作和相应按钮将被禁用。如果要对当前选定的表格行执行操作, 这个属性就很有用。

  • 声明PickerField操作:

    <pickerField id="userPickerField" dataContainer="customerDc" property="user">
        <actions>
            <action id="lookup" type="picker_lookup"/>
            <action id="show" description="Show user" icon="USER"/>
        </actions>
    </pickerField>
    // controller
    
    @Subscribe("userPickerField.show")
    protected void onUserPickerFieldShowActionPerformed(Action.ActionPerformedEvent event) {
        //
    }

    在上面的例子中,为 PickerField 组件声明了标准的 picker_lookup 操作和一个额外的 show 操作。由于显示操作的 PickerField 按钮使用图标而不是标题,因此未设置标题属性。description 属性允许将光标悬停在操作按钮上时显示提示信息。

在界面控制器中可以通过直接注入或从实现 Component.ActionsHolder 接口的组件中获取中任何已声明操作的引用。这对于以编程方式设置操作属性非常有用。例如:

@Named("customersTable.copy")
private Action customersTableCopy;

@Inject
private PickerField<User> userPickerField;

@Subscribe
protected void onBeforeShow(BeforeShowEvent event) {
    customersTableCopy.setEnabled(false);
    userPickerField.getActionNN("show").setEnabled(false);
}
3.5.5.2. 标准操作

框架提供了一些标准操作用于处理常见任务,例如为表格中选择的实体调用编辑界面。通过在 type 属性中指定其类型,就可以在界面 XML 描述中声明标准操作,例如:

有两种类型的标准操作:

  • 列表操作,处理展示在表格或者树中的实体集合,CreateActionEditActionViewActionRemoveActionAddActionExcludeActionRefreshActionExcelActionBulkEditAction

    当表格、树或者数据网格添加了列表操作之后,可以从组件的右键菜单或者预定义的快捷键调用这些操作。通常也会使用添加在按钮面板的一个按钮调用操作。

    <groupTable id="customersTable">
        <actions>
            <action id="create" type="create"/>
            ...
            <buttonsPanel>
                <button id="createBtn" action="customersTable.create"/>
                ...
  • 选取器控件操作, 处理空间的内容,LookupActionOpenActionOpenCompositionActionClearAction

    当选取器控件添加了操作之后,自动使用控件内嵌的按钮进行操作。

    <pickerField id="customerField">
        <actions>
            <action id="lookup" type="picker_lookup"/>
            ...

每个标准操作都通过一个使用了 @ActionType("<some_type>") 注解的类实现。该类定义了操作的默认属性和行为。

可以通过制定基本操作的 XML 属性来覆盖通用的属性:caption、icon、shortcut 等。示例:

<action id="create" type="create" caption="Create customer" icon="USER_PLUS"/>

从 CUBA 7.2 开始,标准操作带有额外的参数可以在 XML 进行设置或者在 Java 中用 setter 方法进行设置。在 XML 中,使用嵌套的 <properties> 元素设置额外的属性,其中每个 <property> 元素对应一个操作类中的 setter 方法:

<action id="create" type="create">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.demo.web.CustomerEdit"/>
    </properties>
</action>

在 Java 控制器也可以做同样的设置:

@Named("customersTable.create")
private CreateAction createAction;

@Subscribe
public void onInit(InitEvent event) {
    createAction.setOpenMode(OpenMode.DIALOG);
    createAction.setScreenClass(CustomerEdit.class);
}

如果一个 setter 接受功能接口参数,可以在界面控制器安装一个处理方法。比如,CreateActionsetAfterCommitHandler(Consumer) 方法,设置一个处理方法会在创建的实体提交之后调用。那么可以按照下面的方式提供处理器:

@Install(to = "customersTable.create", subject = "afterCommitHandler")
protected void customersTableCreateAfterCommitHandler(Customer entity) {
    System.out.println("Created " + entity);
}

所有的操作都有一个通用的 enabledRule 处理器,可以根据不同的情景设置操作的 “启用” 状态。下面的例子中,对某些实体禁用了 RemoveAction:

@Inject
private GroupTable<Customer> customersTable;

@Install(to = "customersTable.remove", subject = "enabledRule")
private boolean customersTableRemoveEnabledRule() {
    Set<Customer> customers = customersTable.getSelected();
    return canBeRemoved(customers);
}

参考下面的章节了解框架提供的操作详情。参考 自定义操作类型 章节了解如何创建自己的操作类型或重载已有的操作类型。

3.5.5.2.1. AddAction

AddAction 是 列表操作,设计用来通过在查找界面选择实体实例的方法将已有的实体实例添加到数据容器。比如,可以用来填写多对多的集合。

该操作通过 com.haulmont.cuba.gui.actions.list.AddAction 类实现,在 XML 中需要使用操作属性 type="add" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 AddAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 查找界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,AddAction 用 THIS_TAB 模式打开查找界面。

  • screenId - 查找界面的字符串 id。AddAction 默认会使用带有 @PrimaryLookupScreen 注解的界面,或 <entity_name>.lookup<entity_name>.browse 格式的界面标识符,比如, demo_Customer.browse

  • screenClass - 查找界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的查找界面,可以在 XML 中这样配置操作:

<action id="add" type="add">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerBrowse"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.add")
private AddAction customersTableAdd;

@Subscribe
public void onInit(InitEvent event) {
    customersTableAdd.setOpenMode(OpenMode.DIALOG);
    customersTableAdd.setScreenClass(CustomerBrowse.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的查找界面。示例:

    @Install(to = "customersTable.add", subject = "screenOptionsSupplier")
    private ScreenOptions customersTableAddScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收查找界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customersTable.add", subject = "screenConfigurer")
    private void customersTableAddScreenConfigurer(Screen screen) {
        ((CustomerBrowse) screen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • selectValidator - 当用户在查找界面点击 Select 时调用的一个处理器。接收包含选中条目的对象作为参数。可以用这个处理器检查选中的条目是否满足某些条件。处理器必须返回 true 才能继续关闭查找界面。示例:

    @Install(to = "customersTable.add", subject = "selectValidator")
    private boolean customersTableAddSelectValidator(LookupScreen.ValidationContext<Customer> validationContext) {
        boolean valid = checkCustomers(validationContext.getSelectedItems());
        if (!valid) {
            notifications.create().withCaption("Selection is not valid").show();
        }
        return valid;
    }
  • transformation - 在查找界面选择实体并验证之后调用的处理器。接收选中的实体集合作为参数。可以用这个处理器对选中的实体做一些处理,然后再传递给接收的数据容器。示例:

    @Install(to = "customersTable.add", subject = "transformation")
    private Collection<Customer> customersTableAddTransformation(Collection<Customer> collection) {
        return reloadCustomers(collection);
    }
  • afterCloseHandler - 在查找界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customersTable.add", subject = "afterCloseHandler")
    private void customersTableAddAfterCloseHandler(AfterCloseEvent event) {
        if (event.closedWith(StandardOutcome.SELECT)) {
            System.out.println("Selected");
        }
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.add")
private AddAction customersTableAdd;

@Subscribe("customersTable.add")
public void onCustomersTableAdd(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to add a customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableAdd.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开查找界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private Table<Customer> customersTable;

@Subscribe("customersTable.add")
public void onCustomersTableAdd(Action.ActionPerformedEvent event) {
    screenBuilders.lookup(customersTable)
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerBrowse.class)
            .withSelectValidator(customerValidationContext -> {
                boolean valid = checkCustomers(customerValidationContext.getSelectedItems());
                if (!valid) {
                    notifications.create().withCaption("Selection is not valid").show();
                }
                return valid;

            })
            .build()
            .show();
}
3.5.5.2.2. BulkEditAction

BulkEditAction 是 列表操作,设计用来为多个实体实例统一修改属性值。它会打开一个特殊的界面,用户可以输入需要的属性值。之后,该操作会在数据库更新选中的实体,同时也会在数据容器更新以反映到 UI 变化。

该操作通过 com.haulmont.cuba.gui.actions.list.BulkEditAction 类实现,在 XML 中需要使用操作属性 type="bulkEdit" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 BulkEditAction 类特有的参数。

  • openMode - 批量编辑界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,BulkEditAction 用 DIALOG 模式打开界面。

  • columnsMode - 批量编辑界面列的数量,要求是 ColumnsMode 枚举类型的一个值。默认是 TWO_COLUMNS

  • exclude - 一个正则表达式,排除某些实体属性,不显示在编辑器。

  • includeProperties - 编辑器需要展示实体属性列表。该列表比 exclude 表达式优先级高。

  • loadDynamicAttributes - 是否在编辑界面展示动态属性。默认为 true。

  • useConfirmDialog - 是否在保存改动之前显示确认对话框。默认为 true。

示例:

<action id="bulkEdit" type="bulkEdit">
    <properties>
        <property name="openMode" value="THIS_TAB"/>
        <property name="includeProperties" value="name,email"/>
        <property name="columnsMode" value="ONE_COLUMN"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.bulkEdit")
private BulkEditAction customersTableBulkEdit;

@Subscribe
public void onInit(InitEvent event) {
    customersTableBulkEdit.setOpenMode(OpenMode.THIS_TAB);
    customersTableBulkEdit.setIncludeProperties(Arrays.asList("name", "email"));
    customersTableBulkEdit.setColumnsMode(ColumnsMode.ONE_COLUMN);
}

下面这些参数只能用 Java 代码配置。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • fieldSorter - 接收表示实体属性的 MetaProperty 对象列表,返回按照编辑界面需要顺序的这些对象。示例:

    @Install(to = "customersTable.bulkEdit", subject = "fieldSorter")
    private Map<MetaProperty, Integer> customersTableBulkEditFieldSorter(List<MetaProperty> properties) {
        Map<MetaProperty, Integer> result = new HashMap<>();
        for (MetaProperty property : properties) {
            switch (property.getName()) {
                case "name": result.put(property, 0); break;
                case "email": result.put(property, 1); break;
                default:
            }
        }
        return result;
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。下面的例子中,我们在执行操作前展示了一个自定义确认对话框:

@Named("customersTable.bulkEdit")
private BulkEditAction customersTableBulkEdit;

@Subscribe("customersTable.bulkEdit")
public void onCustomersTableBulkEdit(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Are you sure you want to edit the selected entities?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableBulkEdit.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是直接使用 BulkEditors API。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private BulkEditors bulkEditors;
@Inject
private GroupTable<Customer> customersTable;

@Subscribe("customersTable.bulkEdit")
public void onCustomersTableBulkEdit(Action.ActionPerformedEvent event) {
    bulkEditors.builder(metadata.getClassNN(Customer.class), customersTable.getSelected(), this)
            .withListComponent(customersTable)
            .withColumnsMode(ColumnsMode.ONE_COLUMN)
            .withIncludeProperties(Arrays.asList("name", "email"))
            .create()
            .show();
}

批量编辑界面的外观可以自定义,使用带 $c-bulk-editor-* 前缀的 SCSS 变量即可。在创建了 主题扩展自定义主题 之后,可以在可视化编辑器修改这些参数。

3.5.5.2.3. ClearAction

ClearAction 是 选取器控件操作 设计用来清空选取器控件。如果控件展示一对一组合实体,实体实例也会在 DataContext 提交时移除(如果界面是实体编辑器,会在用户点击 OK 是发生)。

该操作通过 com.haulmont.cuba.gui.actions.picker.ClearAction 类实现,在 XML 中需要使用操作属性 type="picker_clear" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customerField.clear")
private ClearAction customerFieldClear;

@Subscribe("customerField.clear")
public void onCustomerFieldClear(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to clear the field?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customerFieldClear.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}
3.5.5.2.4. CreateAction

CreateAction 是 列表操作 设计用来创建新的实体实例。它能创建新实例并使用新创建的实例打开实体编辑界面。在编辑界面保存了实体实例之后,操作会将其添加至 UI 组件的数据容器。

该操作通过 com.haulmont.cuba.gui.actions.list.CreateAction 类实现,在 XML 中需要使用操作属性 type="create" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 CreateAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 编辑界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,CreateAction 用 THIS_TAB 模式打开编辑界面。

  • screenId - 编辑界面的字符串 id。CreateAction 默认会使用带有 @PrimaryEditorScreen 注解的界面,或 <entity_name>.edit 格式的界面标识符,比如, demo_Customer.edit

  • screenClass - 编辑界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的编辑界面,可以在 XML 中这样配置操作:

<action id="create" type="create">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerEdit"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.create")
private CreateAction customersTableCreate;

@Subscribe
public void onInit(InitEvent event) {
    customersTableCreate.setOpenMode(OpenMode.DIALOG);
    customersTableCreate.setScreenClass(CustomerEdit.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的编辑界面。示例:

    @Install(to = "customersTable.create", subject = "screenOptionsSupplier")
    protected ScreenOptions customersTableCreateScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收编辑界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customersTable.create", subject = "screenConfigurer")
    protected void customersTableCreateScreenConfigurer(Screen editorScreen) {
        ((CustomerEdit) editorScreen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • newEntitySupplier - 返回一个展示在编辑界面实体新实例的处理器。示例:

    @Install(to = "customersTable.create", subject = "newEntitySupplier")
    protected Customer customersTableCreateNewEntitySupplier() {
        Customer customer = metadata.create(Customer.class);
        customer.setName("a customer");
        return customer;
    }
  • initializer - 接收新实体实例作为参数并能在展示到编辑界面之前对其做初始化的处理器。示例:

    @Install(to = "customersTable.create", subject = "initializer")
    protected void customersTableCreateInitializer(Customer entity) {
        entity.setName("a customer");
    }
  • afterCommitHandler - 当实体实例在编辑界面提交之后会被调用的处理器。接收创建的实体作为参数。示例:

    @Install(to = "customersTable.create", subject = "afterCommitHandler")
    protected void customersTableCreateAfterCommitHandler(Customer entity) {
        System.out.println("Created " + entity);
    }
  • afterCloseHandler - 在编辑界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customersTable.create", subject = "afterCloseHandler")
    protected void customersTableCreateAfterCloseHandler(AfterCloseEvent event) {
        if (event.closedWith(StandardOutcome.COMMIT)) {
            System.out.println("Committed");
        }
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.create")
private CreateAction customersTableCreate;

@Subscribe("customersTable.create")
public void onCustomersTableCreate(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to create new customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableCreate.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开编辑界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;

@Subscribe("customersTable.create")
public void onCustomersTableCreate(Action.ActionPerformedEvent event) {
    screenBuilders.editor(customersTable)
            .newEntity()
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerEdit.class)
            .withAfterCloseListener(afterScreenCloseEvent -> {
                if (afterScreenCloseEvent.closedWith(StandardOutcome.COMMIT)) {
                    Customer committedCustomer = (afterScreenCloseEvent.getScreen()).getEditedEntity();
                    System.out.println("Created " + committedCustomer);
                }
            })
            .build()
            .show();
}
3.5.5.2.5. EditAction

EditAction 是 列表操作 设计用来编辑一个实体实例。当在 UI 组件选择实体实例的时候,该操作会用选中的实例打开编辑界面。在编辑界面保存了实体实例之后,操作会将其添加至 UI 组件的数据容器。

该操作通过 com.haulmont.cuba.gui.actions.list.EditAction 类实现,在 XML 中需要使用操作属性 type="edit" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 EditAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 编辑界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,EditAction 用 THIS_TAB 模式打开编辑界面。

  • screenId - 编辑界面的字符串 id。EditAction 默认会使用带有 @PrimaryEditorScreen 注解的界面,或 <entity_name>.edit 格式的界面标识符,比如, demo_Customer.edit

  • screenClass - 编辑界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的编辑界面,可以在 XML 中这样配置操作:

<action id="edit" type="edit">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerEdit"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.edit")
private EditAction customersTableEdit;

@Subscribe
public void onInit(InitEvent event) {
    customersTableEdit.setOpenMode(OpenMode.DIALOG);
    customersTableEdit.setScreenClass(CustomerEdit.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的编辑界面。示例:

    @Install(to = "customersTable.edit", subject = "screenOptionsSupplier")
    protected ScreenOptions customersTableEditScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收编辑界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customersTable.edit", subject = "screenConfigurer")
    protected void customersTableEditScreenConfigurer(Screen editorScreen) {
        ((CustomerEdit) editorScreen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • afterCommitHandler - 当实体实例在编辑界面提交之后会被调用的处理器。接收创建的实体作为参数。示例:

    @Install(to = "customersTable.edit", subject = "afterCommitHandler")
    protected void customersTableEditAfterCommitHandler(Customer entity) {
        System.out.println("Updated " + entity);
    }
  • afterCloseHandler - 在编辑界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customersTable.edit", subject = "afterCloseHandler")
    protected void customersTableEditAfterCloseHandler(AfterCloseEvent event) {
        if (event.closedWith(StandardOutcome.COMMIT)) {
            System.out.println("Committed");
        }
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.edit")
private EditAction customersTableEdit;

@Subscribe("customersTable.edit")
public void onCustomersTableEdit(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to edit the customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableEdit.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开编辑界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;

@Subscribe("customersTable.edit")
public void onCustomersTableEdit(Action.ActionPerformedEvent event) {
    screenBuilders.editor(customersTable)
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerEdit.class)
            .withAfterCloseListener(afterScreenCloseEvent -> {
                if (afterScreenCloseEvent.closedWith(StandardOutcome.COMMIT)) {
                    Customer committedCustomer = (afterScreenCloseEvent.getScreen()).getEditedEntity();
                    System.out.println("Updated " + committedCustomer);
                }
            })
            .build()
            .show();
}
3.5.5.2.6. ExcelAction

ExcelAction 是 列表操作 设计用来导出表格内容至 XLS 文件。

如果用户在表格中选择了某些行,该操作会询问需要导出选中的行还是所有行。可以覆盖此弹窗的标题和消息内容,只需要在主消息包添加 actions.exportSelectedTitleactions.exportSelectedCaption 键值的消息即可。

该操作通过 com.haulmont.cuba.gui.actions.list.ExcelAction 类实现,在 XML 中需要使用操作属性 type="excel" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 ExcelAction 类特有的参数。

  • fileName - 导出的文件名。如果未指定,会根据实体名称自动生成。

  • exportAggregation - 是否导出表格的聚合行(如果有的话)。默认为 true。

示例:

<action id="excel" type="excel">
    <properties>
        <property name="fileName" value="customers"/>
        <property name="exportAggregation" value="false"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.excel")
private ExcelAction customersTableExcel;

@Subscribe
public void onInit(InitEvent event) {
    customersTableExcel.setFileName("customers");
    customersTableExcel.setExportAggregation(false);
}

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.excel")
private ExcelAction customersTableExcel;

@Subscribe("customersTable.excel")
public void onCustomersTableExcel(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Are you sure you want to print the content to XLS?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableExcel.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是直接使用 ExcelExporter 类。

3.5.5.2.7. ExcludeAction

ExcludeAction 是 列表操作 设计用来从 UI 的数据容器中移除实体实例。与 RemoveAction 不同,ExcludeAction 不会从数据库删除选中的实例。在处理多对多集合的时候,需要该操作。

该操作通过 com.haulmont.cuba.gui.actions.list.ExcludeAction 类实现,在 XML 中需要使用操作属性 type="exclude" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 ExcludeAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • confirmation - boolean 值,配置是否需要在移除选中实体之前展示确认对话框。默认为 true。

  • confirmationMessage - 确认窗口的消息。默认从主消息包的 dialogs.Confirmation.Remove 键值获取。

  • confirmationTitle - 确认窗口标题。默认从主消息包的 dialogs.Confirmation 键值获取。

比如,希望展示特殊的确认消息,可以在 XML 中如下配置:

<action id="exclude" type="exclude">
    <properties>
        <property name="confirmation" value="true"/>
        <property name="confirmationTitle" value="Removing customer..."/>
        <property name="confirmationMessage" value="Do you really want to remove the customer from the list?"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.exclude")
private ExcludeAction customersTableExclude;

@Subscribe
public void onInit(InitEvent event) {
    customersTableExclude.setConfirmation(true);
    customersTableExclude.setConfirmationTitle("Removing customer...");
    customersTableExclude.setConfirmationMessage("Do you really want to remove the customer from the list?");
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • afterActionPerformedHandler - 在选中实体移除之后调用的处理器。接收事件对象,可以用来获取那些选中要移除的实体。示例:

    @Install(to = "customersTable.exclude", subject = "afterActionPerformedHandler")
    private void customersTableExcludeAfterActionPerformedHandler(RemoveOperation.AfterActionPerformedEvent<Customer> event) {
        System.out.println("Removed " + event.getItems());
    }
  • actionCancelledHandler - 当用户在确认窗口取消移除操作时会调用的方法。接收事件对象,可以用来获取那些选中要移除的实体。示例:

    @Install(to = "customersTable.exclude", subject = "actionCancelledHandler")
    private void customersTableExcludeActionCancelledHandler(RemoveOperation.ActionCancelledEvent<Customer> event) {
        System.out.println("Cancelled");
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.exclude")
private ExcludeAction customersTableExclude;

@Subscribe("customersTable.exclude")
public void onCustomersTableExclude(Action.ActionPerformedEvent event) {
    customersTableExclude.setConfirmation(false);
    dialogs.createOptionDialog()
            .withCaption("My fancy confirm dialog")
            .withMessage("Do you really want to remove the customer from the list?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableExclude.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是直接使用 RemoveOperation API 移除选中的实体。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private RemoveOperation removeOperation;

@Subscribe("customersTable.exclude")
public void onCustomersTableExclude(Action.ActionPerformedEvent event) {
    removeOperation.builder(customersTable)
            .withConfirmationMessage("Do you really want to remove the customer from the list?")
            .withConfirmationTitle("Removing customer...")
            .exclude();
}
3.5.5.2.8. LookupAction

LookupAction 是 选取器控件操作 设计用来从查找界面选取实体实例并设置给选取器。

该操作通过 com.haulmont.cuba.gui.actions.picker.LookupAction 类实现,在 XML 中需要使用操作属性 type="picker_lookup" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 LookupAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 查找界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,LookupAction 用 THIS_TAB 模式打开查找界面。

  • screenId - 查找界面的字符串 id。LookupAction 默认会使用带有 @PrimaryLookupScreen 注解的界面,或 <entity_name>.lookup<entity_name>.browse 格式的界面标识符,比如, demo_Customer.browse

  • screenClass - 查找界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的查找界面,可以在 XML 中这样配置操作:

<action id="lookup" type="picker_lookup">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerBrowse"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customerField.lookup")
private LookupAction customerFieldLookup;

@Subscribe
public void onInit(InitEvent event) {
    customerFieldLookup.setOpenMode(OpenMode.DIALOG);
    customerFieldLookup.setScreenClass(CustomerBrowse.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的查找界面。示例:

    @Install(to = "customerField.lookup", subject = "screenOptionsSupplier")
    private ScreenOptions customerFieldLookupScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收查找界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customerField.lookup", subject = "screenConfigurer")
    private void customerFieldLookupScreenConfigurer(Screen screen) {
        ((CustomerBrowse) screen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • selectValidator - 当用户在查找界面点击 Select 时调用的一个处理器。接收包含选中条目集合的对象作为参数。集合的第一个条目会设置给该选取器。可以用这个处理器检查选中的条目是否满足某些条件。处理器必须返回 true 才能继续关闭查找界面。示例:

    @Install(to = "customerField.lookup", subject = "selectValidator")
    private boolean customerFieldLookupSelectValidator(LookupScreen.ValidationContext<Customer> validationContext) {
        boolean valid = validationContext.getSelectedItems().size() == 1;
        if (!valid) {
            notifications.create().withCaption("Select a single customer").show();
        }
        return valid;
    }
  • transformation - 在查找界面选择实体并验证之后调用的处理器。接收选中的实体集合作为参数。集合的第一个条目会设置给该选取器。可以用这个处理器对选中的实体做一些处理,然后再传递给选取器。示例:

    @Install(to = "customerField.lookup", subject = "transformation")
    private Collection<Customer> customerFieldLookupTransformation(Collection<Customer> collection) {
        return reloadCustomers(collection);
    }
  • afterCloseHandler - 在查找界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customerField.lookup", subject = "afterCloseHandler")
    private void customerFieldLookupAfterCloseHandler(AfterCloseEvent event) {
        if (event.closedWith(StandardOutcome.SELECT)) {
            System.out.println("Selected");
        }
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customerField.lookup")
private LookupAction customerFieldLookup;

@Subscribe("customerField.lookup")
public void onCustomerFieldLookup(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to select a customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customerFieldLookup.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开查找界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private LookupPickerField<Customer> customerField;

@Subscribe("customerField.lookup")
public void onCustomerFieldLookup(Action.ActionPerformedEvent event) {
    screenBuilders.lookup(customerField)
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerBrowse.class)
            .withSelectValidator(customerValidationContext -> {
                boolean valid = customerValidationContext.getSelectedItems().size() == 1;
                if (!valid) {
                    notifications.create().withCaption("Select a single customer").show();
                }
                return valid;

            })
            .build()
            .show();
}
3.5.5.2.9. OpenAction

OpenAction 是 选取器控件操作 设计用来为选取器当前选中的实体打开编辑界面。

该操作通过 com.haulmont.cuba.gui.actions.picker.OpenAction 类实现,在 XML 中需要使用操作属性 type="picker_open" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 OpenAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 编辑界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,OpenAction 用 THIS_TAB 模式打开编辑界面。

  • screenId - 编辑界面的字符串 id。OpenAction 默认会使用带有 @PrimaryEditorScreen 注解的界面,或 <entity_name>.edit 格式的界面标识符,比如, demo_Customer.edit

  • screenClass - 编辑界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的编辑界面,可以在 XML 中这样配置操作:

<action id="open" type="picker_open">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerEdit"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customerField.open")
private OpenAction customerFieldOpen;

@Subscribe
public void onInit(InitEvent event) {
    customerFieldOpen.setOpenMode(OpenMode.DIALOG);
    customerFieldOpen.setScreenClass(CustomerEdit.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的编辑界面。示例:

    @Install(to = "customerField.open", subject = "screenOptionsSupplier")
    private ScreenOptions customerFieldOpenScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收编辑界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customerField.open", subject = "screenConfigurer")
    private void customerFieldOpenScreenConfigurer(Screen screen) {
        ((CustomerEdit) screen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • afterCloseHandler - 在编辑界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customerField.open", subject = "afterCloseHandler")
    private void customerFieldOpenAfterCloseHandler(AfterCloseEvent event) {
        System.out.println("Closed with " + event.getCloseAction());
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customerField.open")
private OpenAction customerFieldOpen;

@Subscribe("customerField.open")
public void onCustomerFieldOpen(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to open the customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customerFieldOpen.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开编辑界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;
@Inject
private LookupPickerField<Customer> customerField;

@Subscribe("customerField.open")
public void onCustomerFieldOpen(Action.ActionPerformedEvent event) {
    screenBuilders.editor(customerField)
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerEdit.class)
            .build()
            .show();
}
3.5.5.2.10. OpenCompositionAction

OpenCompositionAction 是 选取器控件操作 设计用来为选取器当前选中的一对一组合实体打开编辑界面。如果此时没有关联实体(比如,字段为空),则会创建一个新的实例,将来会通过编辑界面保存。

该操作通过 com.haulmont.cuba.gui.actions.picker.OpenCompositionAction 类实现,在 XML 中需要使用操作属性 type="picker_open_composition" 定义。该操作的参数与 OpenAction 相同。

3.5.5.2.11. RefreshAction

RefreshAction 是 列表操作 设计用来为表格或者树组件重新加载数据容器。

该操作通过 com.haulmont.cuba.gui.actions.list.RefreshAction 类实现,在 XML 中需要使用操作属性 type="refresh" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.refresh")
private RefreshAction customersTableRefresh;

@Subscribe("customersTable.refresh")
public void onCustomersTableRefresh(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Are you sure you want to refresh the list?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableRefresh.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是直接出发数据加载器。示例:

@Inject
private CollectionLoader<Customer> customersDl;

@Subscribe("customersTable.refresh")
public void onCustomersTableRefresh(Action.ActionPerformedEvent event) {
    customersDl.load();
}
3.5.5.2.12. RemoveAction

RemoveAction 是 列表操作 设计用来从 UI 的数据容器移除实体实例,同时从数据库删除。

该操作通过 com.haulmont.cuba.gui.actions.list.RemoveAction 类实现,在 XML 中需要使用操作属性 type="remove" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 RemoveAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • confirmation - boolean 值,配置是否需要在移除选中实体之前展示确认对话框。默认为 true。

  • confirmationMessage - 确认窗口的消息。默认从主消息包的 dialogs.Confirmation.Remove 键值获取。

  • confirmationTitle - 确认窗口标题。默认从主消息包的 dialogs.Confirmation 键值获取。

比如,希望展示特殊的确认消息,可以在 XML 中如下配置:

<action id="remove" type="remove">
    <properties>
        <property name="confirmation" value="true"/>
        <property name="confirmationTitle" value="Removing customer..."/>
        <property name="confirmationMessage" value="Do you really want to remove the customer?"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.remove")
private RemoveAction customersTableRemove;

@Subscribe
public void onInit(InitEvent event) {
    customersTableRemove.setConfirmation(true);
    customersTableRemove.setConfirmationTitle("Removing customer...");
    customersTableRemove.setConfirmationMessage("Do you really want to remove the customer?");
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • afterActionPerformedHandler - 在选中实体移除之后调用的处理器。接收事件对象,可以用来获取那些选中要移除的实体。示例:

    @Install(to = "customersTable.remove", subject = "afterActionPerformedHandler")
    protected void customersTableRemoveAfterActionPerformedHandler(RemoveOperation.AfterActionPerformedEvent<Customer> event) {
        System.out.println("Removed " + event.getItems());
    }
  • actionCancelledHandler - 当用户在确认窗口取消移除操作时会调用的方法。接收事件对象,可以用来获取那些选中要移除的实体。示例:

    @Install(to = "customersTable.remove", subject = "actionCancelledHandler")
    protected void customersTableRemoveActionCancelledHandler(RemoveOperation.ActionCancelledEvent<Customer> event) {
        System.out.println("Cancelled");
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.remove")
private RemoveAction customersTableRemove;

@Subscribe("customersTable.remove")
public void onCustomersTableRemove(Action.ActionPerformedEvent event) {
    customersTableRemove.setConfirmation(false);
    dialogs.createOptionDialog()
            .withCaption("My fancy confirm dialog")
            .withMessage("Do you really want to remove the customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableRemove.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是直接使用 RemoveOperation API 移除选中的实体。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private RemoveOperation removeOperation;

@Subscribe("customersTable.remove")
public void onCustomersTableRemove(Action.ActionPerformedEvent event) {
    removeOperation.builder(customersTable)
            .withConfirmationTitle("Removing customer...")
            .withConfirmationMessage("Do you really want to remove the customer?")
            .remove();
}
3.5.5.2.13. ViewAction

ViewAction 是 列表操作 设计用来查看和编辑实体实例。与 EditAction 一样,会打开编辑界面,但是所有的字段都是不可编辑的,同时也会禁用那些实现了 Action.DisabledWhenScreenReadOnly 接口的操作。如果需要允许用户切换界面至编辑模式,可以添加一个按钮使用预定义的 enableEditing 操作:

<hbox id="editActions" spacing="true">
    <button action="windowCommitAndClose"/>
    <button action="windowClose"/>
    <button action="enableEditing"/> <!-- 此按钮只会在界面是只读模式时显示 -->
</hbox>

可以在主消息包使用 actions.EnableEditing 键值重定义 enableEditing 操作的标题,或者直接在使用的界面中通过指定相应按钮的 caption 属性进行重定义。

该操作通过 com.haulmont.cuba.gui.actions.list.ViewAction 类实现,在 XML 中需要使用操作属性 type="view" 定义。可以用 action 元素的 XML 属性定义通用的操作参数,参阅 声明式操作 了解细节。下面我们介绍 ViewAction 类特有的参数。

下列参数可以通过 XML 或 Java 的方式设置:

  • openMode - 编辑界面的打开模式,要求是 OpenMode 枚举类型的一个值:NEW_TABDIALOG 等。默认情况下,ViewAction 用 THIS_TAB 模式打开编辑界面。

  • screenId - 编辑界面的字符串 id。ViewAction 默认会使用带有 @PrimaryEditorScreen 注解的界面,或 <entity_name>.edit 格式的界面标识符,比如, demo_Customer.edit

  • screenClass - 编辑界面控制器的 Java 类。比 screenId 有更高的优先级。

示例,需要以对话框方式打开一个特定的编辑界面,可以在 XML 中这样配置操作:

<action id="view" type="view">
    <properties>
        <property name="openMode" value="DIALOG"/>
        <property name="screenClass" value="com.company.sales.web.customer.CustomerEdit"/>
    </properties>
</action>

或者,可以在界面控制器注入该操作,然后用 setter 配置:

@Named("customersTable.view")
private ViewAction customersTableView;

@Subscribe
public void onInit(InitEvent event) {
    customersTableView.setOpenMode(OpenMode.DIALOG);
    customersTableView.setScreenClass(CustomerEdit.class);
}

现在我们看看那些只能用 Java 代码配置的参数。如果要为这些参数生成带正确注解的方法桩代码,可以用 Studio 中 Component Inspector 工具窗口的 Handlers 标签页功能。

  • screenOptionsSupplier - 返回 ScreenOptions 对象的处理器,返回值可以传递给打开的编辑界面。示例:

    @Install(to = "customersTable.view", subject = "screenOptionsSupplier")
    protected ScreenOptions customersTableViewScreenOptionsSupplier() {
        return new MapScreenOptions(ParamsMap.of("someParameter", 10));
    }

    返回的 ScreenOptions 对象可以通过打开界面的 InitEvent 访问。

  • screenConfigurer - 接收编辑界面作为参数并能在打开之间初始化界面的处理器。示例:

    @Install(to = "customersTable.view", subject = "screenConfigurer")
    protected void customersTableViewScreenConfigurer(Screen editorScreen) {
        ((CustomerEdit) editorScreen).setSomeParameter(10);
    }

    注意,界面 configurer 会在界面已经初始化但是还未显示时生效,即在界面的 InitEventAfterInitEvent 事件之后,但是在 BeforeShowEvent 之前。

  • afterCommitHandler - 如果用户使用上面提到的 enableEditing 操作切换界面至编辑模式时,当实体实例在编辑界面提交之后会被调用的处理器。接收创建的实体作为参数。示例:

    @Install(to = "customersTable.view", subject = "afterCommitHandler")
    protected void customersTableViewAfterCommitHandler(Customer entity) {
        System.out.println("Updated " + entity);
    }
  • afterCloseHandler - 在编辑界面关闭后调用的处理器。AfterCloseEvent 事件会传递给该处理器。示例:

    @Install(to = "customersTable.view", subject = "afterCloseHandler")
    protected void customersTableViewAfterCloseHandler(AfterCloseEvent event) {
        if (event.closedWith(StandardOutcome.COMMIT)) {
            System.out.println("Enabled editing and then committed");
        }
    }

如果需要在该操作执行前做一些检查或者与用户做一些交互,可以订阅操作的 ActionPerformedEvent 事件并按需调用操作的 execute() 方法。操作会使用你为它定义的所有参数进行调用。下面的例子中,我们在执行操作前展示了一个确认对话框:

@Named("customersTable.view")
private ViewAction customersTableView;

@Subscribe("customersTable.view")
public void onCustomersTableView(Action.ActionPerformedEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Please confirm")
            .withMessage("Do you really want to view the customer?")
            .withActions(
                    new DialogAction(DialogAction.Type.YES)
                            .withHandler(e -> customersTableView.execute()), // execute action
                    new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

另外,还可以先订阅 ActionPerformedEvent,但是不调用操作的 execute() 方法,而是使用 ScreenBuilders API 直接打开编辑界面。此时,会忽略所有的操作参数和行为,只能用其通用参数,比如 caption, icon 等。示例:

@Inject
private ScreenBuilders screenBuilders;

@Subscribe("customersTable.view")
public void onCustomersTableView(Action.ActionPerformedEvent event) {
    CustomerEdit customerEdit = screenBuilders.editor(customersTable)
            .withOpenMode(OpenMode.DIALOG)
            .withScreenClass(CustomerEdit.class)
            .withAfterCloseListener(afterScreenCloseEvent -> {
                if (afterScreenCloseEvent.closedWith(StandardOutcome.COMMIT)) {
                    Customer committedCustomer = (afterScreenCloseEvent.getScreen()).getEditedEntity();
                    System.out.println("Updated " + committedCustomer);
                }
            })
            .build();
    customerEdit.setReadOnly(true);
    customerEdit.show();
}
3.5.5.3. 自定义操作类型

在项目中可以创建自己的操作类型或者重载已有的标准类型。

比如,假设需要一个操作显示表格中当前选中实体的实例名称,你还想在多个界面使用,只需要指定相同的操作类型即可。下面的步骤就是如何创建这种操作。

  1. 创建一个操作类并使用需要的操作类型添加 @ActionType 注解:

    package com.company.sample.web.actions;
    
    import com.haulmont.cuba.core.entity.Entity;
    import com.haulmont.cuba.core.global.MetadataTools;
    import com.haulmont.cuba.gui.ComponentsHelper;
    import com.haulmont.cuba.gui.Notifications;
    import com.haulmont.cuba.gui.components.ActionType;
    import com.haulmont.cuba.gui.components.Component;
    import com.haulmont.cuba.gui.components.actions.ItemTrackingAction;
    
    import javax.inject.Inject;
    
    @ActionType("showSelected")
    public class ShowSelectedAction extends ItemTrackingAction {
    
        @Inject
        private MetadataTools metadataTools;
    
        public ShowSelectedAction(String id) {
            super(id);
            setCaption("Show Selected");
        }
    
        @Override
        public void actionPerform(Component component) {
            Entity selected = getTarget().getSingleSelected();
            if (selected != null) {
                Notifications notifications = ComponentsHelper.getScreenContext(target).getNotifications();
                notifications.create()
                        .withType(Notifications.NotificationType.TRAY)
                        .withCaption(metadataTools.getInstanceName(selected))
                        .show();
            }
        }
    }
  2. web-spring.xml 文件中,添加 <gui:actions> 元素,其 base-packages 属性指向查找带注解操作的包名:

    <beans ... xmlns:gui="http://schemas.haulmont.com/cuba/spring/cuba-gui.xsd">
        <!-- ... -->
        <gui:actions base-packages="com.company.sample.web.actions"/>
    </beans>
  3. 现在可以在界面描述中指定操作类型使用该操作:

    <groupTable id="customersTable">
        <actions>
            <action id="show" type="showSelected"/>
        </actions>
        <columns>
            <!-- ... -->
        </columns>
        <buttonsPanel>
            <button action="customersTable.show"/>
        </buttonsPanel>
    </groupTable>

如果需要重载已有类型,只需要使用相同的名称注册新的操作即可。

CUBA Studio 支持和自定义操作的可配置属性

项目中实现的自定义操作类型可以集成到 CUBA Studio 的界面设计器中。界面设计器提供如下支持:

  • 支持在标准操作的列表中选择自定义操作,可以从工具箱(palette)选择,或者为表格通过 +AddAction 方式添加。

  • 支持从操作的使用处切换至操作类定义处的快速代码跳转。在界面 xml 描述中,当光标在 action type 时,通过按下 Ctrl + B 或者按下 ctrl 用鼠标点击类型名时,会自动跳转到操作类定义代码。比如,在这段 xml <action id="sel" type="showSelected"> 中,可以点击 showSelected

  • 支持在 Component Inspector 面板编辑用户定义的操作属性。

  • 支持生成操作提供的事件处理器和方法代理,以实现自定义逻辑。

  • 支持泛型参数。泛型根据表格(操作的所属组件)使用的实体类确定。

@com.haulmont.cuba.gui.meta.StudioAction 注解用来标注包含自定义属性的自定义操作类。自定义操作需要用该注解标注,但是目前 Studio 还不使用 @StudioAction 注解的任何属性。

@com.haulmont.cuba.gui.meta.StudioPropertiesItem 注解用来标注操作属性的 setter,表示该属性可编辑。这些属性会在界面编辑器的 Component Inspector 面板展示并编辑。该注解有如下属性:

  • name - xml 中该属性应该写的名称。如果未设置,则会从 setter 方法名生成。

  • type - 属性类型。这个字段在 Inspector 面板使用,为属性创建合适的输入组件并提供建议和基本验证。所有的属性类型参阅 这里

  • caption - 展示在 Inspector 面板该属性的名称。

  • description - 属性描述,在 Inspector 面板鼠标悬浮于该字段时显示。

  • category - Inspector 面板中属性的分类(目前还没用上)。

  • required - 表名属性是必须字段,此时 Inspector 面板不允许用户输入空值。

  • defaultValue - 默认值,当 xml 属性为配置该字段时默认使用的值。默认值在 xml 中不可见。

  • options - 操作属性的可选项。比如,对于 ENUMERATION - 枚举 属性类型。

注意,操作属性只支持部分 Java 类型:

  • 基础类型:StringBooleanByteShortIntegerLongFloatDouble

  • 枚举。

  • java.lang.Class

  • 上面提到类型的 java.util.List。这种类型在 Inspector 面板没有特定的输入组件,所以需要用字符串形式输入并标记为 PropertyType.STRING

示例

private String contentType = "PLAIN";
private Class<? extends Screen> dialogClass;
private List<Integer> columnNumbers = new ArrayList<>();

@StudioPropertiesItem(name = "ctype", type = PropertyType.ENUMERATION, description = "Email content type", (1)
        defaultValue = "PLAIN", options = {"PLAIN", "HTML"}
)
public void setContentType(String contentType) {
    this.contentType = contentType;
}

@StudioPropertiesItem(type = PropertyType.SCREEN_CLASS_NAME, required = true) (2)
public void setDialogClass(Class<? extends Screen> dialogClass) {
    this.dialogClass = dialogClass;
}

@StudioPropertiesItem(type = PropertyType.STRING) (3)
public void setColumnNumbers(List<Integer> columnNumbers) {
    this.columnNumbers = columnNumbers;
}
1 - 字符串属性,有默认值和有限的几个选项。
2 - 必要属性,选项局限于项目中定义的界面类。
3 - 整数列表,属性类型设置为 STRING,因为 Inspector 面板没有合适的输入组件。

Studio 还提供对于自定义操作中事件和代理方法的支持。支持方式与 CUBA 自带的 UI 组件一样。在操作类中声明事件监听器或代理方法时,不需要任何注解。示例:

自定义操作示例:SendByEmailAction

该示例展示了:

  • 声明并标注自定义操作类。

  • 标注操作的可编辑属性。

  • 声明操作产生的事件及其处理器。

  • 声明操作的代理方法。

SendByEmailAction 操作通过 email 发送实体信息,实体为其所属表格中选中的实体。这个操作是高度可配置的,因为大部分内部逻辑可以通过属性、代理方法和事件修改。

操作源码:

@StudioAction(category = "List Actions", description = "Sends selected entity by email") (1)
@ActionType("sendByEmail") (2)
public class SendByEmailAction<E extends Entity> extends ItemTrackingAction { (3)

    private final MetadataTools metadataTools;
    private final EmailService emailService;

    private String recipientAddress = "admin@example.com";

    private Function<E, String> bodyGenerator;
    private Function<E, List<EmailAttachment>> attachmentProvider;

    public SendByEmailAction(String id) {
        super(id);
        setCaption("Send by email");
        emailService = AppBeans.get(EmailService.NAME);
        metadataTools = AppBeans.get(MetadataTools.NAME);
    }

    @StudioPropertiesItem(required = true, defaultValue = "admin@example.com") (4)
    public void setRecipientAddress(String recipientAddress) {
        this.recipientAddress = recipientAddress;
    }

    public Subscription addEmailSentListener(Consumer<EmailSentEvent> listener) { (5)
        return getEventHub().subscribe(EmailSentEvent.class, listener);
    }

    public void setBodyGenerator(Function<E, String> bodyGenerator) { (6)
        this.bodyGenerator = bodyGenerator;
    }

    public void setAttachmentProvider(Function<E, List<EmailAttachment>> attachmentProvider) { (7)
        this.attachmentProvider = attachmentProvider;
    }

    @Override
    public void actionPerform(Component component) {
        if (recipientAddress == null || bodyGenerator == null) {
            throw new IllegalStateException("Required parameters are not set");
        }

        E selected = (E) getTarget().getSingleSelected();
        if (selected == null) {
            return;
        }

        String caption = "Entity " + metadataTools.getInstanceName(selected) + " info";
        String body = bodyGenerator.apply(selected); (8)
        List<EmailAttachment> attachments = attachmentProvider != null ? attachmentProvider.apply(selected) (9)
                : new ArrayList<>();

        EmailInfo info = EmailInfoBuilder.create()
                .setAddresses(recipientAddress)
                .setCaption(caption)
                .setBody(body)
                .setBodyContentType(EmailInfo.TEXT_CONTENT_TYPE)
                .setAttachments(attachments.toArray(new EmailAttachment[0]))
                .build();

        emailService.sendEmailAsync(info); (10)

        EmailSentEvent event = new EmailSentEvent(this, info);
        eventHub.publish(EmailSentEvent.class, event); (11)
    }

    public static class EmailSentEvent extends EventObject { (12)
        private final EmailInfo emailInfo;

        public EmailSentEvent(SendByEmailAction origin, EmailInfo emailInfo) {
            super(origin);
            this.emailInfo = emailInfo;
        }

        public EmailInfo getEmailInfo() {
            return emailInfo;
        }
    }
}
1 - 用 @StudioAction 注解的操作类。
2 - 用 @ActionType 设置操作类型。
3 - 操作类有 E 泛型参数 - 表示在所属表格中存储的实体类型
4 - email 收件人地址用操作属性开放出来。
5 - 为 EmailSentEvent 事件添加监听器的方法。Studio 检测出该方法作为操作的事件处理器。
6 - 设置代理 Function 对象的方法,将生成邮件体的逻辑代理给界面控制器。Studio 检测该方法作为操作的代理方法。
7 - 声明其他代理方法 - 例子中是将创建附件的咯及代理出去。注意,这里两个代理方法都用了 E 泛型参数。
8 - 在界面控制器必须实现的代理方法,生成邮件体。
9 - 如果设置了的话,调用可选的代理方法创建附件。
10 - 真正发送邮件的地方。
11 - 邮件发送成功后发布 EmailSentEvent 事件,如果界面控制器订阅了该事件,则会调用对应的事件处理器。
12 - 声明事件类,注意,这里可以为事件类添加更多字段,将有用的信息传递给事件处理逻辑。

用上面示例的方式完成代码后,Studio 会在创建操作的界面与标准操作一同显示新的自定义操作:

custom action wizard

为界面描述添加操作之后,可以在 Component Inspector 面板选择并修改其属性:

custom action properties

自定义操作的属性在 Inspector 修改后,会用下面的格式写入界面描述文件:

<action id="sendByEmail" type="sendByEmail">
    <properties>
        <property name="recipientAddress" value="peter@example.com"/>
    </properties>
</action>

Component Inspector 面板也同样会显示操作的事件处理器和代理方法,用来生成相应代码:

custom action handlers

在界面控制器使用生成代理方法和事件处理器代码的示例:

@UiController("sales_Customer.browse")
@UiDescriptor("customer-browse.xml")
@LookupComponent("customersTable")
@LoadDataBeforeShow
public class CustomerBrowse extends StandardLookup<Customer> {

    @Inject
    private Notifications notifications;

    @Named("customersTable.sendByEmail")
    private SendByEmailAction<Customer> customersTableSendByEmail; (1)

    @Subscribe("customersTable.sendByEmail")
    public void onCustomersTableSendByEmailEmailSent(SendByEmailAction.EmailSentEvent event) { (2)
        notifications.create(Notifications.NotificationType.HUMANIZED)
                .withCaption("Email sent")
                .show();
    }

    @Install(to = "customersTable.sendByEmail", subject = "bodyGenerator")
    private String customersTableSendByEmailBodyGenerator(Customer customer) { (3)
        return "Hello, " + customer.getName();
    }

    @Install(to = "customersTable.sendByEmail", subject = "attachmentProvider")
    private List<EmailAttachment> customersTableSendByEmailAttachmentProvider(Customer customer) { (4)
        return Collections.emptyList();
    }
}
1 - 操作的注入点使用正确的类型参数。
2 - 事件处理器实现。
3 - 代理方法 bodyGenerator 实现。方法签名使用了 Customer 类型参数。
4 - 代理方法 attachmentProvider 的实现。
3.5.5.4. 基础操作

BaseAction 是所有操作实现的基类。当声明式创建操作不能满足需求时,建议从这个类派生自定义操作。

在创建自定义操作类时,应该实现 actionPerform() 方法并将操作标识符传递给 BaseAction 构造函数。可以重写任何属性的 getter 方法: getCaption()getDescription()getIcon()getShortcut()isEnabled()isVisible()isPrimary()。除了 getCaption() 方法之外,这些方法的标准实现返回由 setter 方法设置的值。如果操作名称未通过 setCaption() 方法显式设置,则它使用操作标识符作为键从与操作类包对应的本地化消息包中检索消息。如果没有带有这种键的消息,则返回键本身,即操作标识符。

或者,可以使用流式 API 设置属性并提供 lambda 表达式来处理操作:请参阅 withXYZ() 方法。

BaseAction 可以根据用户权限和当前上下文更改其 enabledvisible 属性。

如果满足以下条件,则 BaseAction 是可见的:

  • setVisible(false) 方法没有被调用;

  • 此操作没有 hide UI 权限;

如果满足以下条件,则该操作被启用:

  • setEnabled(false) 方法没有被调用;

  • 此操作没有 hide 或只读 UI 权限;

  • isPermitted() 方法返回 true。

  • isApplicable() 方法返回 true。

用法示例:

  • Button 操作:

    @Inject
    private Notifications notifications;
    @Inject
    private Button helloBtn;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        helloBtn.setAction(new BaseAction("hello") {
            @Override
            public boolean isPrimary() {
                return true;
            }
    
            @Override
            public void actionPerform(Component component) {
                notifications.create()
                        .withCaption("Hello!")
                        .withType(Notifications.NotificationType.TRAY)
                        .show();
            }
        });
        // OR
        helloBtn.setAction(new BaseAction("hello")
                .withPrimary(true)
                .withHandler(e ->
                        notifications.create()
                                .withCaption("Hello!")
                                .withType(Notifications.NotificationType.TRAY)
                                .show()));
    }

    在这个例子中,helloBtn 按钮标题将被设置为位于消息包中的带有 hello 键的字符串。可以重写 getCaption() 操作方法以不同的方式初始化按钮名称。

  • 以编程方式创建PickerField的操作:

    @Inject
    private UiComponents uiComponents;
    @Inject
    private Notifications notifications;
    @Inject
    private MessageBundle messageBundle;
    @Inject
    private HBoxLayout box;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        PickerField pickerField = uiComponents.create(PickerField.NAME);
    
        pickerField.addAction(new BaseAction("hello") {
            @Override
            public String getCaption() {
                return null;
            }
    
            @Override
            public String getDescription() {
                return messageBundle.getMessage("helloDescription");
            }
    
            @Override
            public String getIcon() {
                return "icons/hello.png";
            }
    
            @Override
            public void actionPerform(Component component) {
                notifications.create()
                        .withCaption("Hello!")
                        .withType(Notifications.NotificationType.TRAY)
                        .show();
            }
        });
        // OR
        pickerField.addAction(new BaseAction("hello")
                .withCaption(null)
                .withDescription(messageBundle.getMessage("helloDescription"))
                .withIcon("icons/ok.png")
                .withHandler(e ->
                        notifications.create()
                                .withCaption("Hello!")
                                .withType(Notifications.NotificationType.TRAY)
                                .show()));
        box.add(pickerField);
    }

    在此示例中,匿名 BaseAction 派生类用于设置选择器字段按钮的操作。不显示按钮标题,而是在光标悬停在按钮时弹出的带有描述的图标。

  • Table操作:

    @Inject
    private Notifications notifications;
    @Inject
    private Table<Customer> table;
    @Inject
    private Security security;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        table.addAction(new HelloAction());
    }
    
    private class HelloAction extends BaseAction {
    
        public HelloAction() {
            super("hello");
        }
    
        @Override
        public void actionPerform(Component component) {
            notifications.create()
                    .withCaption("Hello " + table.getSingleSelected())
                    .withType(Notifications.NotificationType.TRAY)
                    .show();
        }
    
        @Override
        protected boolean isPermitted() {
            return security.isSpecificPermitted("myapp.allow-greeting");
        }
    
        @Override
        public boolean isApplicable() {
            return table != null && table.getSelected().size() == 1;
        }
    }

    在此示例中,声明了 HelloAction 类,它的实例被添加到表格的操作列表中。对具有 myapp.allow-greeting 安全权限的用户启用该操作且仅在只选择了表格中的一行时启用。后面这个选中一行启用能有效,是因为 BaseAction 的 target 属性会在操作添加到 ListComponent 的继承者(Table 或者 Tree)时被自动设置。

  • 如果需要一个在选择一行或多行时启用的操作,请使用 BaseAction 的子类 - ItemTrackingAction,它添加了 isApplicable() 方法的默认实现:

    @Inject
    private Table table;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        table.addAction(new ItemTrackingAction("hello") {
            @Override
            public void actionPerform(Component component) {
                notifications.create()
                        .withCaption("Hello " + table.getSelected().iterator().next())
                        .withType(Notifications.NotificationType.TRAY)
                        .show();
            }
        });
    }

3.5.6. 对话框消息

Dialogs 接口设计用来展示标准对话框窗口。其 createMessageDialog()createOptionDialog()createInputDialog() 方法是流式 API 的入口点,可以用来创建和显示对话框。

对话框的展示可以使用带 $cuba-window-modal-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。

消息对话框

下面的例子中,当用户点击按钮时,会显示一个消息对话框:

@Inject
private Dialogs dialogs;

@Subscribe("showDialogBtn")
protected void onShowDialogBtnClick(Button.ClickEvent event) {
    dialogs.createMessageDialog().withCaption("Information").withMessage("Message").show();
}

使用 withMessage() 方法传递消息文本。

可以在消息中使用 \n 字符来换行。如果要显示 HTML,可以用 withContentMode() 方法带 ContentMode.HTML 参数。当使用 HTML 时,别忘了转移数据内容以防恶意代码注入。

withHtmlSanitizer() 方法传参 true 可以启用对话框内容的 HTML 清理功能。此时,必须为 withContentMode() 方法传递 ContentMode.HTML 参数。

protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
            "color=\"moccasin\">my</font> " +
            "<font size=\"7\">big</font> <sup>sphinx</sup> " +
            "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
            "red;\">quartz</span><svg/onload=alert(\"XSS\")>";

@Inject
private Dialogs dialogs;

@Subscribe("showMessageDialogOnBtn")
public void onShowMessageDialogOnBtnClick(Button.ClickEvent event) {
    dialogs.createMessageDialog()
            .withCaption("MessageDialog with Sanitizer")
            .withMessage(UNSAFE_HTML)
            .withContentMode(ContentMode.HTML)
            .withHtmlSanitizer(true)
            .show();
}

@Subscribe("showMessageDialogOffBtn")
public void onShowMessageDialogOffBtnClick(Button.ClickEvent event) {
    dialogs.createMessageDialog()
            .withCaption("MessageDialog without Sanitizer")
            .withMessage(UNSAFE_HTML)
            .withContentMode(ContentMode.HTML)
            .withHtmlSanitizer(false)
            .show();
}

withHtmlSanitizer() 接收的参数会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

使用下面的方法可以自定义消息对话框的外观和行为:

  • withModal() - 如果使用 false,对话框不以模态窗展示,此时用户可以与应用程序的其它部分交互。

  • withCloseOnClickOutside() - 如果使用 true,并且窗口是模态展示时,用户可以点击对话框之外的地方来关闭对话框。

  • withMaximized() – 当设置为 true 时,窗口会被最大化。

  • withWidth()withHeight() 可以设置需要的弹窗大小。

示例:

@Inject
private Dialogs dialogs;

@Subscribe("showDialogBtn")
protected void onShowDialogBtnClick(Button.ClickEvent event) {
    dialogs.createMessageDialog()
            .withCaption("Information")
            .withMessage("<i>Message<i/>")
            .withContentMode(ContentMode.HTML)
            .withCloseOnClickOutside(true)
            .withWidth("100px")
            .withHeight("300px")
            .show();
}
选项对话框

选项对话框展示了一个消息和一组用户交互的按钮。使用 withActions() 方法可以提供操作,每个操作在对话框中以按钮的形式展示。示例:

@Inject
private Dialogs dialogs;

@Subscribe("showDialogBtn")
protected void onShowDialogBtnClick(Button.ClickEvent event) {
    dialogs.createOptionDialog()
            .withCaption("Confirm")
            .withMessage("Are you sure?")
            .withActions(
                new DialogAction(DialogAction.Type.YES, Action.Status.PRIMARY).withHandler(e -> {
                    doSomething();
                }),
                new DialogAction(DialogAction.Type.NO)
            )
            .show();
}

当按钮被点击时,对话框会关闭并且调用相应操作的 actionPerform() 方法。

DialogAction 基类设计用来创建带有标准名称和图标的操作。支持五种使用 DialogAction.Type 枚举定义的操作类型:OKCANCELYESNOCLOSE。对应的按钮名称通过主语言消息包获取。

DialogAction 构造器的第二个参数用来为操作的按钮设置特殊的可视化样式。c-primary-action 样式提供的 Status.PRIMARY 会高亮对应的按钮并使得它被选中。如果对话框中有多个操作使用了 Status.PRIMARY,只有第一个操作的按钮能使用正确的样式和图标。

输入对话框

输入对话框是一个多功能的工具,可以使用 API 构建输入表单,摆脱以前创建界面做数据输入。支持不同类型数据的输入、验证输入数据以及为用户提供不同的操作。

下面我们看几个例子。

  1. 带有标准类型参数和 OK/Cancel 按钮的输入对话框:

    @Inject
    private Dialogs dialogs;
    
    @Subscribe("showDialogBtn")
    private void onShowDialogBtnClick(Button.ClickEvent event) {
        dialogs.createInputDialog(this)
                .withCaption("Enter some values")
                .withParameters(
                        InputParameter.stringParameter("name")
                            .withCaption("Name").withRequired(true), (1)
                        InputParameter.doubleParameter("quantity")
                            .withCaption("Quantity").withDefaultValue(1.0), (2)
                        InputParameter.entityParameter("customer", Customer.class)
                            .withCaption("Customer"), (3)
                        InputParameter.enumParameter("status", Status.class)
                            .withCaption("Status") (4)
                )
                .withActions(DialogActions.OK_CANCEL) (5)
                .withCloseListener(closeEvent -> {
                    if (closeEvent.closedWith(DialogOutcome.OK)) { (6)
                        String name = closeEvent.getValue("name"); (7)
                        Double quantity = closeEvent.getValue("quantity");
                        Optional<Customer> customer = closeEvent.getOptional("customer"); (8)
                        Status status = closeEvent.getValue("status");
                        // process entered values...
                    }
                })
                .show();
    }
    1 - 指定一个必填的字符串参数。
    2 - 指定一个带有默认值的双浮点参数。
    3 - 指定一个实体参数。
    4 - 指定一个枚举参数。
    5 - 指定一组用按钮表示的操作,并放在对话框底部。
    6 - 在关闭事件监听器中,我们可以检查用户使用了什么操作。
    7 - 关闭事件包含了输入的值,可以通过参数标识符进行获取。
    8 - 可以得到一个用 Optional 包装的值。
  2. 自定义参数的输入对话框:

    @Inject
    private Dialogs dialogs;
    @Inject
    private UiComponents uiComponents;
    
    @Subscribe("showDialogBtn")
    private void onShowDialogBtnClick(Button.ClickEvent event) {
        dialogs.createInputDialog(this)
                .withCaption("Enter some values")
                .withParameters(
                        InputParameter.stringParameter("name").withCaption("Name"),
                        InputParameter.parameter("customer") (1)
                                .withField(() -> {
                                    LookupField<Customer> field = uiComponents.create(
                                            LookupField.of(Customer.class));
                                    field.setOptionsList(dataManager.load(Customer.class).list());
                                    field.setCaption("Customer"); (2)
                                    field.setWidthFull();
                                    return field;
                                })
                )
                .withActions(DialogActions.OK_CANCEL)
                .withCloseListener(closeEvent -> {
                    if (closeEvent.closedWith(DialogOutcome.OK)) {
                        String name = closeEvent.getValue("name");
                        Customer customer = closeEvent.getValue("customer"); (3)
                        // process entered values...
                    }
                })
                .show();
    }
    1 - 指定一个自定义参数
    2 - 在创建的组件中指定自定义参数的标题。
    3 - 跟标准的参数一样的方法获取自定义参数的值。
  3. 使用自定义操作的输入对话框:

    @Inject
    private Dialogs dialogs;
    
    @Subscribe("showDialogBtn")
    private void onShowDialogBtnClick(Button.ClickEvent event) {
        dialogs.createInputDialog(this)
                .withCaption("Enter some values")
                .withParameters(
                    InputParameter.stringParameter("name").withCaption("Name")
                )
                .withActions( (1)
                        InputDialogAction.action("confirm")
                                .withCaption("Confirm")
                                .withPrimary(true)
                                .withHandler(actionEvent -> {
                                    InputDialog dialog = actionEvent.getInputDialog();
                                    String name = dialog.getValue("name"); (2)
                                    dialog.closeWithDefaultAction(); (3)
                                    // process entered values...
                                }),
                        InputDialogAction.action("refuse")
                                .withCaption("Refuse")
                                .withValidationRequired(false)
                                .withHandler(actionEvent ->
                                    actionEvent.getInputDialog().closeWithDefaultAction())
                )
                .show();
    }
    1 - withActions() 方法能接收一组用户自定义的操作。
    2 - 在操作处理器中,可以从对话框获取参数值。
    3 - 自定义操作不会关闭对话框本身,所以需要同时手动关闭。
  4. 带自定义校验的输入对话框

    @Inject
    private Dialogs dialogs;
    
    @Subscribe("showDialogBtn")
    private void onShowDialogBtnClick(Button.ClickEvent event) {
        dialogs.createInputDialog(this)
                .withCaption("Enter some values")
                .withParameters(
                        InputParameter.stringParameter("name").withCaption("Name"),
                        InputParameter.entityParameter("customer", Customer.class).withCaption("Customer")
                )
                .withValidator(context -> { (1)
                    String name = context.getValue("name"); (2)
                    Customer customer = context.getValue("customer");
                    if (Strings.isNullOrEmpty(name) && customer == null) {
                        return ValidationErrors.of("Enter name or select a customer");
                    }
                    return ValidationErrors.none();
                })
                .withActions(DialogActions.OK_CANCEL)
                .withCloseListener(closeEvent -> {
                    if (closeEvent.closedWith(DialogOutcome.OK)) {
                        String name = closeEvent.getValue("name");
                        Customer customer = closeEvent.getValue("customer");
                        // process entered values...
                    }
                })
                .show();
    }
    1 - 需要自定义校验确保至少输入了一个参数。
    2 - 在校验器中,参数值可以通过上下文对象获取。
  5. 带有 FileDescriptor 参数的输入对话框:

    @Inject
    private Dialogs dialogs;
    
    @Subscribe("showDialogBtn")
    public void onShowDialogBtnClick(Button.ClickEvent event) {
        dialogs.createInputDialog(this)
                .withCaption("Select the file")
                .withParameters(
                        InputParameter.fileParameter("fileField") (1)
                                .withCaption("File"))
                .withCloseListener(closeEvent -> {
                    if (closeEvent.closedWith(DialogOutcome.OK)) {
                        FileDescriptor fileDescriptor = closeEvent.getValue("fileField");  (2)
                    }
                })
                .show();
    }
    1 - 指定一个 FileDescriptor 参数。
    2 - 关闭事件包含输入的值,可以用参数标识符获取。

3.5.7. 通知消息

通知消息是显示在主应用程序窗口中间或者角落的弹窗。这些弹窗能自动消失,也可以在用户点击界面或按下 Esc 时消失。

要显示通知消息,可以在界面控制器中注入 Notifications bean 然后使用其流式接口。在下面的例子中,点击按钮会显示通知消息:

@Inject
private Notifications notifications;

@Subscribe("sayHelloBtn")
protected void onSayHelloBtnClick(Button.ClickEvent event) {
    notifications.create().withCaption("Hello!").show();
}

一个通知消息可以有一条描述,描述使用更轻的字体展示在标题下面:

@Inject
private Notifications notifications;

@Subscribe("sayHelloBtn")
protected void onSayHelloBtnClick(Button.ClickEvent event) {
    notifications.create().withCaption("Greeting").withDescription("Hello World!").show();
}

通知消息有以下类型:

  • TRAY - 显示在应用程序右下角的消息,会自动消失。

  • HUMANIZED – 显示在界面中间的标准消息,会自动消失。

  • WARNING – 警告消息。当用户点击界面时消失。

  • ERROR– 错误消息。当用户点击界面时消失。

默认类型是 HUMANIZED。也可以在 create() 方法中使用其它类型:

@Inject
private Notifications notifications;

@Subscribe("sayHelloBtn")
protected void onSayHelloBtnClick(Button.ClickEvent event) {
    notifications.create(Notifications.NotificationType.TRAY).withCaption("Hello World!").show();
}

可以在消息中使用 \n 来换行。如果需要显示 HTML,可以用 withContentMode() 方法:

@Inject
private Notifications notifications;

@Subscribe("sayHelloBtn")
protected void onSayHelloBtnClick(Button.ClickEvent event) {
    notifications.create()
            .withContentMode(ContentMode.HTML)
            .withCaption("<i>Hello World!</i>")
            .show();
}

当使用 HTML 时,别忘了对数据进行转义,以防恶意代码注入。

withHtmlSanitizer() 方法传参 true 可以启用对话框内容的 HTML 清理功能。此时,必须为 withContentMode() 方法传递 ContentMode.HTML 参数。

protected static final String UNSAFE_HTML = "<i>Jackdaws </i><u>love</u> <font size=\"javascript:alert(1)\" " +
            "color=\"moccasin\">my</font> " +
            "<font size=\"7\">big</font> <sup>sphinx</sup> " +
            "<font face=\"Verdana\">of</font> <span style=\"background-color: " +
            "red;\">quartz</span><svg/onload=alert(\"XSS\")>";

@Inject
private Notifications notifications;

@Subscribe("showNotificationOnBtn")
public void onShowNotificationOnBtnClick(Button.ClickEvent event) {
    notifications.create()
            .withCaption("Notification with Sanitizer")
            .withDescription(UNSAFE_HTML)
            .withContentMode(ContentMode.HTML)
            .withHtmlSanitizer(true)
            .show();
}

@Subscribe("showNotificationOffBtn")
public void onShowNotificationOffBtnClick(Button.ClickEvent event) {
    notifications.create()
            .withCaption("Notification without Sanitizer")
            .withDescription(UNSAFE_HTML)
            .withContentMode(ContentMode.HTML)
            .withHtmlSanitizer(false)
            .show();
}

withHtmlSanitizer() 接收的参数会覆盖全局的 cuba.web.htmlSanitizerEnabled 配置。

withPosition() 方法可以设置通知消息的位置。支持的标准值为:

  • TOP_RIGHT

  • TOP_LEFT

  • TOP_CENTER

  • MIDDLE_RIGHT

  • MIDDLE_LEFT

  • MIDDLE_CENTER

  • BOTTOM_RIGHT

  • BOTTOM_LEFT

  • BOTTOM_CENTER

还可以用 withHideDelayMs() 方法设置通知消息停留的时间,以毫秒为单位。-1 表示需要用户点击消息才会消失。

3.5.8. 后台任务

后台任务机制用于在客户端层异步执行任务,不阻塞用户界面。

要使用后台任务,请执行以下操作:

  1. 定义一个继承自 BackgroundTask 抽象类的任务。将界面控制器的引用传递给任务的构造器,该控制器将与任务和任务超时关联起来。

    关闭界面将中断与其相关的任务。此外,任务将在指定的超时后自动中断。

    任务执行的实际操作在run()方法中实现。

  2. 通过将任务实例传递给 BackgroundWorker bean 的 handle() 方法,创建一个控制任务的 BackgroundTaskHandler 类。可以通过在界面控制器中注入或通过 AppBeans 类来获得对 BackgroundWorker 的引用。

  3. 通过调用 BackgroundTaskHandlerexecute() 方法来运行任务。

UI 组件的状态和数据容器不能在 BackgroundTask run() 方法中读取/更新:使用 done()progress()canceled() 回调方法代替。如果尝试从后台线程设置 UI 组件的值,则会抛出 IllegalConcurrentAccessException

示例:

@Inject
protected BackgroundWorker backgroundWorker;

@Override
public void init(Map<String, Object> params) {
    // Create task with 10 sec timeout and this screen as owner
    BackgroundTask<Integer, Void> task = new BackgroundTask<Integer, Void>(10, this) {
        @Override
        public Void run(TaskLifeCycle<Integer> taskLifeCycle) throws Exception {
            // Do something in background thread
            for (int i = 0; i < 5; i++) {
                TimeUnit.SECONDS.sleep(1); // time consuming computations
                taskLifeCycle.publish(i);  // publish current progress to show it in progress() method
            }
            return null;
        }

        @Override
        public void canceled() {
            // Do something in UI thread if the task is canceled
        }

        @Override
        public void done(Void result) {
            // Do something in UI thread when the task is done
        }

        @Override
        public void progress(List<Integer> changes) {
            // Show current progress in UI thread
        }
    };
    // Get task handler object and run the task
    BackgroundTaskHandler taskHandler = backgroundWorker.handle(task);
    taskHandler.execute();
}

JavaDocs 中提供了 BackgroundTaskTaskLifeCycleBackgroundTaskHandler 类的有关方法的详细信息。

请注意以下事项:

  • BackgroundTask<T, V> 是一个参数化类:

    • T − 显示任务进度的对象类型。在工作线程中调用 TaskLifeCycle.publish() 期间,将此类型对象传递给任务的 progress() 方法。

    • V − 任务结果类型被传递给 done() 方法。它也可以通过调用 BackgroundTaskHandler.getResult() 方法获得,该方法将等待任务完成。

  • canceled() 方法只在受控的任务取消时被调用,即在 TaskHandler 中调用 cancel() 时。

  • handleTimeoutException() 方法在任务超时时被调用。如果正在运行任务的窗口关闭,则任务将在没有通知的情况下停止。

  • 任务的 run() 方法应该支持外部中断。要确保这一点,建议在长时间运行的处理中定期检查 TaskLifeCycle.isInterrupted() 标识,并在需要时停止执行。另外,不应该静默地忽略掉 InterruptedException(或任何其它异常) - 而应该正确退出方法或根本不处理异常(将异常暴露给调用方)。

    • isCancelled() 如果通过调用 cancel() 方法中断任务,则此方法返回 true

      public String run(TaskLifeCycle<Integer> taskLifeCycle) {
          for (int i = 0; i < 9_000_000; i++) {
              if (taskLifeCycle.isCancelled()) {
                  log.info(" >>> Task was cancelled");
                  break;
              } else {
                  log.info(" >>> Task is working: iteration #" + i);
              }
          }
          return "Done";
      }
  • BackgroundTask 对象是无状态的。如果在实现任务类时没有为临时数据创建字段,则可以使用单个任务实例启动多个并行进程。

  • BackgroundHandler 对象(它的 execute() 方法)只能被启动一次。如果需要经常重启任务,请使用 BackgroundTaskWrapper 类。

  • 使用带有一组静态方法的 BackgroundWorkWindow 类或 BackgroundWorkProgressWindow 类来显示带进度指示器和 Cancel 按钮的模式窗口。可以定义进度指示类型,并允许或禁止取消窗口的后台任务。

  • 如果需要在任务线程中使用可视化组件的某个值,应该通过 getParams() 方法来获取,该方法在任务启动时在 UI 线程中运行一次。在 run()方法中,可以通过 TaskLifeCycle 对象的 getParams() 方法访问这些参数。

  • 如果发生任何异常,框架将在 UI 线程中调用 BackgroundTask.handleException() 方法,该方法可用于显示错误。

  • 后台任务受cuba.backgroundWorker.maxActiveTasksCountcuba.backgroundWorker.timeoutCheckInterval应用程序属性的影响。

在 Web 客户端中,后台任务是使用 Vaadin 框架提供的 HTTP 推送实现的。有关如何为此技术设置 Web 服务器的信息,请参阅 https://vaadin.com/wiki/-/wiki/Main/Working+around+push+issues

如果不使用后台任务,但想要从非 UI 线程更新 UI 状态,请使用 UIAccessor 接口的方法。应该在 UI 线程中使用 BackgroundWorker.getUIAccessor() 方法获取对 UIAccessor 的引用,之后可以从后台线程中调用它的 access()accessSynchronously() 方法来安全地读取或修改 UI 组件的状态。

3.5.8.1. 后台任务使用示例
使用 BackgroundWorkProgressWindow 展示和控制后台任务

启动后台任务时,我们一般会要显示一个简单的 UI 界面:

  1. 展示给用户,请求的任务还在执行中,

  2. 允许用户退出长时间执行的任务,

  3. 如果能获取任务执行进度的话,展示目前的进度。

平台通过 BackgroundWorkWindowBackgroundWorkProgressWindow 工具类满足了这些需求。 这些类带有静态方法,可以用来将后台任务和一个模态窗相关联,这个模态窗带有标题、描述、进度条以及一个可选的 Cancel 按钮。 这两个类的区别在于,BackgroundWorkProgressWindow 使用了一个确定的进度条,应当在能估算任务进度的情况下使用。相反,BackgroundWorkWindow 应当在无法估算任务时长的情况使用。

下面我们用一个开发任务作为示例:

  • 一个给定的界面包含展示学生列表的表格,可以多选。

  • 当用户按下某个按钮时,系统会给这些选中的学生发送邮件,而且此时 UI 不会被 block 住,并能取消发送邮件的操作。

bg task emails

示例实现:

import com.haulmont.cuba.gui.backgroundwork.BackgroundWorkProgressWindow;

public class StudentBrowse extends StandardLookup<Student> {

    @Inject
    private Table<Student> studentsTable;

    @Inject
    private EmailService emailService;

    @Subscribe("studentsTable.sendEmail")
    public void onStudentsTableSendEmail(Action.ActionPerformedEvent event) {
        Set<Student> selected = studentsTable.getSelected();
        if (selected.isEmpty()) {
            return;
        }
        BackgroundTask<Integer, Void> task = new EmailTask(selected);
        BackgroundWorkProgressWindow.show(task, (1)
                "Sending reminder emails", "Please wait while emails are being sent",
                selected.size(), true, true (2)
        );
    }

    private class EmailTask extends BackgroundTask<Integer, Void> { (3)
        private Set<Student> students; (4)

        public EmailTask(Set<Student> students) {
            super(10, TimeUnit.MINUTES, StudentBrowse.this); (5)
            this.students = students;
        }

        @Override
        public Void run(TaskLifeCycle<Integer> taskLifeCycle) throws Exception {
            int i = 0;
            for (Student student : students) {
                if (taskLifeCycle.isCancelled()) { (6)
                    break;
                }
                emailService.sendEmail(student.getEmail(), "Reminder", "Don't forget, the exam is tomorrow",
                        EmailInfo.TEXT_CONTENT_TYPE);

                i++;
                taskLifeCycle.publish(i); (7)
            }
            return null;
        }
    }
}
1 - 启动任务并显示模态进度窗口
2 - 设置对话框选项:进度条的总数、用户可以取消任务、展示进度百分比
3 - 任务进度单位是 Integer(已处理的表格项),结果类型是 Void,因为该任务不会产生结果
4 - 选中的表格项保存在一个变量中,变量在任务的构造器初始化。这是必须要的,因为 run() 方法会在一个后台进程中执行并且没法访问 UI 组件
5 - 设置超时时限为 10 分钟
6 - 周期性的检查 isCancelled(),这样用户按下 Cancel 按钮时能立即结束任务
7 - 每封邮件发出后更新进度条的位置
周期性的在后台使用定时器和 BackgroundTaskWrapper 刷新界面数据

BackgroundTaskWrapper 是一个 BackgroundWorker 的很小的工具包装类。 提供了简单的 API 用来重复的启动、重启和取消同类型的后台任务。

下面这个开发任务示例展示了使用方法:

  • 一个排名监控界面需要展示并自动更新数据。

  • 数据加载很慢,所以需要在后台加载。

  • 在界面展示最新的数据更新时间。

  • 数据通过简单的过滤器(复选框)进行过滤。

bg ranks ok
  • 由于某些原因,如果数据刷新失败了,界面应当告诉用户:

bg ranks error

示例实现:

@UiController("playground_RankMonitor")
@UiDescriptor("rank-monitor.xml")
public class RankMonitor extends Screen {
    @Inject
    private Notifications notifications;
    @Inject
    private Label<String> refreshTimeLabel;
    @Inject
    private CollectionContainer<Rank> ranksDc;
    @Inject
    private RankService rankService;
    @Inject
    private CheckBox onlyActiveBox;
    @Inject
    private Logger log;
    @Inject
    private TimeSource timeSource;
    @Inject
    private Timer refreshTimer;

    private BackgroundTaskWrapper<Void, List<Rank>> refreshTaskWrapper = new BackgroundTaskWrapper<>(); (1)

    @Subscribe
    public void onBeforeShow(BeforeShowEvent event) {
        refreshTimer.setDelay(5000);
        refreshTimer.setRepeating(true);
        refreshTimer.start();
    }

    @Subscribe("onlyActiveBox")
    public void onOnlyActiveBoxValueChange(HasValue.ValueChangeEvent<Boolean> event) {
        refreshTaskWrapper.restart(new RefreshScreenTask()); (2)
    }

    @Subscribe("refreshTimer")
    public void onRefreshTimerTimerAction(Timer.TimerActionEvent event) {
        refreshTaskWrapper.restart(new RefreshScreenTask()); (3)
    }

    public class RefreshScreenTask extends BackgroundTask<Void, List<Rank>> { (4)
        private boolean onlyActive; (5)
        protected RefreshScreenTask() {
            super(30, TimeUnit.SECONDS, RankMonitor.this);
            onlyActive = onlyActiveBox.getValue();
        }

        @Override
        public List<Rank> run(TaskLifeCycle<Void> taskLifeCycle) throws Exception {
            List<Rank> data = rankService.loadActiveRanks(onlyActive); (6)
            return data;
        }

        @Override
        public void done(List<Rank> result) { (7)
            List<Rank> mutableItems = ranksDc.getMutableItems();
            mutableItems.clear();
            mutableItems.addAll(result);

            String hhmmss = new SimpleDateFormat("HH:mm:ss").format(timeSource.currentTimestamp());
            refreshTimeLabel.setValue("Last time refreshed: " + hhmmss);
        }

        @Override
        public boolean handleTimeoutException() { (8)
            displayRefreshProblem();
            return true;
        }

        @Override
        public boolean handleException(Exception ex) { (9)
            log.debug("Auto-refresh error", ex);
            displayRefreshProblem();
            return true;
        }

        private void displayRefreshProblem() {
            if (!refreshTimeLabel.getValue().endsWith("(outdated)")) {
                refreshTimeLabel.setValue(refreshTimeLabel.getValue() + " (outdated)");
            }
            notifications.create(Notifications.NotificationType.TRAY)
                    .withCaption("Problem refreshing data")
                    .withHideDelayMs(10_000)
                    .show();
        }
    }
}
1 - 用无参数构造函数初始化 BackgroundTaskWrapper 实例;每次迭代都会提供一个新的任务实例
2 - 复选框值变化时,立即触发一次后台数据更新
3 - 每次计时器刷新触发后台数据更新
4 - 任务不会发布状态信息,所以状态单元是 Void;任务结果类型为 List<Rank>
5 - 复选框状态保存在一个变量中,变量在任务的构造器初始化。这是必须要的,因为 run() 方法会在一个后台进程中执行并且没法访问 UI 组件
6 - 调用自定义的服务加载数据(这是需要在后台执行的长时间任务)
7 - 将成功获取的结果展示到界面组件
8 - 如果数据加载超时,更新 UI:在界面的一个角落展示通知消息
9 - 用通知消息告知用户数据加载失败

3.5.9. 主题

主题用于管理应用程序的视觉展现。

一个 Web 主题由 SCSS 文件和其它的资源比如图像组成。

框架提供几个可以在项目中开箱即用的主题。通过主题扩展可以在项目级别修改使用的主题。除了标准的主题外,也可以创建自定义主题

如果希望在多个项目中使用同一个主题,可以在应用程序组件中包含此主题或者创建一个可重用的主题 JAR

3.5.9.1. 使用现有主题

平台包括三个即用型主题:Hover 、Halo 和 Havana。默认情况下,应用程序将使用cuba.web.theme应用程序属性中指定的主题。

用户可以在标准 Help > Settings 界面中选择其它主题。如果要禁用选择主题的选项,请在项目的web-screens.xml文件中注册 settings 界面,并为其设置 changeThemeEnabled = false 参数:

<screen id="settings" template="/com/haulmont/cuba/web/app/ui/core/settings/settings-window.xml">
    <param name="changeThemeEnabled" value="false"/>
</screen>
3.5.9.2. 扩展现有主题

平台主题可以在项目中被修改。在修改后的主题中,可以:

  • 改变品牌 Logo 图片。

  • 在可视化组件中添加图标并使用它们。请参阅下面的图标部分。

  • 为可视化组件创建新样式,并在stylename属性中使用它们。这需要一些 CSS 专业知识。

  • 修改可视化组件的现有样式。

  • 修改常用参数,例如背景颜色 、 边距 、间距等。

文件结构和构建脚本

主题在 SCSS 中定义。要修改(扩展)项目中的主题,应该在 web 模块中创建特定的文件结构。

一种便捷的方法是使用 CUBA Studio:在主菜单中,单击 CUBA > Advanced > Manage themes > Create theme extension。在弹出窗口中选择要扩展的主题。另一种方法是使用 CUBA CLI 中的 theme 命令。

最终,以下目录结构将在 modules/web 目录中被创建(对于 Halo 主题扩展):

themes/
  halo/
    branding/
        app-icon-login.png
        app-icon-menu.png
    com.company.application/
        app-component.scss
        halo-ext.scss
        halo-ext-defaults.scss
    favicon.ico
    styles.scss

除此之外,build.gradle脚本将会添加进 buildScssThemes 任务,该任务在每次构建 web 模块时自动执行。可选的deployThemes任务可用于将主题中的更改快速应用于正在运行的应用程序。

如果项目包含带有扩展主题的应用程序组件,并且希望此扩展用于整个项目,那么也应该为项目创建主题扩展。有关如何继承组件主题的详细信息,请参阅使用应用程序组件中的主题部分。

更改品牌

可以配置一些品牌相关的属性,例如图标、登录和主应用程序窗口标题以及网站图标(favicon.ico)。

要使用自定义图片,请替换 modules/web/themes/halo/branding 目录中的默认图片。

要设置窗口标题和登录窗口欢迎文本,请在 web 模块的主消息包中设置窗口标题和登录窗口欢迎文本(即 modules/web/<root_package>/web/messages.properties 文件及其针对不同语言环境的变体)。消息包允许为不同的用户区域设置使用不同的图像文件。示例 messages.properties 文件:

application.caption = MyApp
application.logoImage = branding/myapp-menu.png

loginWindow.caption = MyApp Login
loginWindow.welcomeLabel = Welcome to MyApp!
loginWindow.logoImage = branding/myapp-login.png

favicon.ico 的路径没有被指定,因为它必须位于主题的根目录中。

添加字体

可以为 Web 主题添加自定义字体。添加一个 Font Family,将其导入 styles.scss 文件的第一行,例如:

@import url(http://fonts.googleapis.com/css?family=Roboto);
创建新样式

为显示客户名称的字段设置黄色背景颜色的示例。

在 XML 描述中, 定义了 FieldGroup组件:

<fieldGroup id="fieldGroup" datasource="customerDs">
    <field property="name"/>
    <field property="address"/>
</fieldGroup>

FieldGroupfield 元素没有stylename属性,因此我们必须在控制器中设置字段的样式名称:

@Named("fieldGroup.name")
private TextField nameField;

@Override
public void init(Map<String, Object> params) {
    nameField.setStyleName("name-field");
}

halo-ext.scss 文件中,将新样式定义添加到 halo-ext mixin:

@mixin com_company_application-halo-ext {
  .name-field {
    background-color: lightyellow;
  }
}

重建项目后,字段将如下所示:

gui themes fieldgroup 1
修改可视化组件的现有样式

要修改现有组件的样式参数,请将相应的 CSS 代码添加到 halo-ext.scss 文件的 halo-ext mixin 中。使用 Web 浏览器的开发人员工具查找分配给可视化组件元素的 CSS 类。例如,要以粗体显示应用程序菜单项,halo-ext.scss 文件的内容应如下所示:

@mixin com_company_application-halo-ext {
  .v-menubar-menuitem-caption {
      font-weight: bold;
  }
}
修改通用参数

主题包含许多控制应用程序背景颜色、组件大小、边距和其它参数的 SCSS 变量。

以下是 Halo 主题扩展的示例,因为它基于来自 VaadinValo 主题,并提供最广泛的自定义选项。

themes/halo/halo-ext-defaults.scss 文件用于覆盖主题变量。大多数 Halo 变量对应于 Valo 文档 中描述的变量。以下是最常见的变量:

$v-background-color: #fafafa;        /* component background colour */
$v-app-background-color: #e7ebf2;    /* application background colour */
$v-panel-background-color: #fff;     /* panel background colour */
$v-focus-color: #3b5998;             /* focused element colour */
$v-error-indicator-color: #ed473b;   /* empty required fields colour */

$v-line-height: 1.35;                /* line height */
$v-font-size: 14px;                  /* font size */
$v-font-weight: 400;                 /* font weight */
$v-unit-size: 30px;                  /* base theme size, defines the height for buttons, fields and other elements */

$v-font-size--h1: 24px;              /* h1-style Label size */
$v-font-size--h2: 20px;              /* h2-style Label size */
$v-font-size--h3: 16px;              /* h3-style Label size */

/* margins for containers */
$v-layout-margin-top: 10px;
$v-layout-margin-left: 10px;
$v-layout-margin-right: 10px;
$v-layout-margin-bottom: 10px;

/* spacing between components in a container (if enabled) */
$v-layout-spacing-vertical: 10px;
$v-layout-spacing-horizontal: 10px;

/* whether filter search button should have "friendly" style*/
$cuba-filter-friendly-search-button: true;

/* whether button that has primary action or marked as primary itself should be highlighted*/
$cuba-highlight-primary-action: false;

/* basic table and datagrid settings */
$v-table-row-height: 30px;
$v-table-header-font-size: 13px;
$v-table-cell-padding-horizontal: 7px;
$v-grid-row-height
$v-grid-row-selected-background-color
$v-grid-cell-padding-horizontal

/* input field focus style */
$v-focus-style: inset 0px 0px 5px 1px rgba($v-focus-color, 0.5);
/* required fields focus style */
$v-error-focus-style: inset 0px 0px 5px 1px rgba($v-error-indicator-color, 0.5);

/* animation for elements is enabled by default */
$v-animations-enabled: true;
/* popup window animation is disabled by default */
$v-window-animations-enabled: false;

/* inverse header is controlled by cuba.web.useInverseHeader property */
$v-support-inverse-menu: true;

/* show "required" indicators for components */
$v-show-required-indicators: false !default;

下面提供了具有深色背景和略微减少外边距的示例主题 halo-ext-defaults.scss

$v-background-color: #444D50;

$v-font-size--h1: 22px;
$v-font-size--h2: 18px;
$v-font-size--h3: 16px;

$v-layout-margin-top: 8px;
$v-layout-margin-left: 8px;
$v-layout-margin-right: 8px;
$v-layout-margin-bottom: 8px;

$v-layout-spacing-vertical: 8px;
$v-layout-spacing-horizontal: 8px;

$v-table-row-height: 25px;
$v-table-header-font-size: 13px;
$v-table-cell-padding-horizontal: 5px;

$v-support-inverse-menu: false;

另外一个示例展示了使用一组变量使得 Halo 主题看上去跟旧的 Havana 主题差不多,Havana 主题从框架 7.0 版本开始已经移除了。

$cuba-menubar-background-color: #315379;
$cuba-menubar-border-color: #315379;
$v-table-row-height: 25px;
$v-selection-color: rgb(77, 122, 178);
$v-table-header-font-size: 12px;
$v-textfield-border: 1px solid #A5C4E0;

$v-selection-item-selection-color: #4D7AB2;

$v-app-background-color: #E3EAF1;
$v-font-size: 12px;
$v-font-weight: 400;
$v-unit-size: 25px;
$v-border-radius: 0px;
$v-border: 1px solid #9BB3D3 !default;
$v-font-family: Verdana,tahoma,arial,geneva,helvetica,sans-serif,"Trebuchet MS";

$v-panel-background-color: #ffffff;
$v-background-color: #ffffff;

$cuba-menubar-menuitem-text-color: #ffffff;

$cuba-app-menubar-padding-top: 8px;
$cuba-app-menubar-padding-bottom: 8px;

$cuba-menubar-text-color: #ffffff;
$cuba-menubar-submenu-padding: 1px;
更改应用程序标题

Halo 主题支持cuba.web.useInverseHeader属性,该属性控制应用程序标题的颜色。默认情况下,此属性设置为 true,它设置一个暗色(高对比)标题。只需将此属性设置为 false,即可不需要对主题进行任何更改而创建一个亮色标题。

3.5.9.3. 创建自定义主题

可以在项目中创建一个或多个应用程序主题,并为用户提供选择最合适的应用程序主题的时机。创建新主题还允许覆盖 *-theme.properties 文件 中的变量,这些变量定义了一些服务端参数:

  • 默认对话框窗口大小。

  • 默认输入框宽度。

  • 某些组件的尺寸(FilterFileMultiUploadField)。

  • 如果 cuba.web.useFontIcons 属性启用,则在标准操作和平台界面中使用 Font Awesome 图标时,图标名称和 com.vaadin.server.FontAwesome 枚举的常量值对应。

可以在 CUBA Studio 、 CUBA CLI 中轻松创地建新主题,也可以手动创建。我们看看以 Hover Dark 自定义主题为例的所有三种创建方式。

在 CUBA Studio 中创建:
  • 在主菜单中,单击 CUBA > Advanced > Manage themes > Create custom theme。输入新主题的名称: hover-dark。在 Base theme 下拉列表中选择 hover 主题。

    将在 web 模块中创建所需的文件结构。webThemesModule 模块及其配置将自动被添加到 settings.gradlebuild.gradle文件中。此外,生成的 deployThemes gradle 任务允许在不重启服务器的情况下查看主题更改。

手动创建:
  • 在项目的 web 模块中创建以下文件结构:

    web/
      src/
      themes/
        hover-dark/
          branding/
              app-icon-login.png
              app-icon-menu.png
          com.haulmont.cuba/
              app-component.scss
          favicon.ico
          hover-dark.scss
          hover-dark-defaults.scss
          styles.scss
  • app-component.scss 文件:

    @import "../hover-dark";
    
    @mixin com_haulmont_cuba {
      @include hover-dark;
    }
  • hover-dark.scss 文件:

    @import "../hover/hover";
    
    @mixin hover-dark {
      @include hover;
    }
  • styles.scss 文件:

    @import "hover-dark-defaults";
    @import "hover-dark";
    
    .hover-dark {
      @include hover-dark;
    }
  • web 模块的 web 子目录中创建 hover-dark-theme.properties 文件:

    @include=com/haulmont/cuba/hover-theme.properties
  • webThemesModule 模块添加到 settings.gradle 文件中:

    include(":${modulePrefix}-global", ":${modulePrefix}-core", ":${modulePrefix}-web", ":${modulePrefix}-web-themes")
    //...
    project(":${modulePrefix}-web-themes").projectDir = new File(settingsDir, 'modules/web/themes')
  • webThemesModule 模块配置添加到build.gradle文件中:

    def webThemesModule = project(":${modulePrefix}-web-themes")
    
    configure(webThemesModule) {
      apply(plugin: 'java')
      apply(plugin: 'maven')
      apply(plugin: 'cuba')
    
      appModuleType = 'web-themes'
    
      buildDir = file('../build/scss-themes')
    
      sourceSets {
        main {
          java {
            srcDir '.'
          }
          resources {
            srcDir '.'
          }
        }
      }
    }
  • 最后,在 build.gradle 中创建 deployThemes gradle 任务,以查看更改而不重启服务器:

    configure(webModule) {
      // . . .
      task buildScssThemes(type: CubaWebScssThemeCreation)
      task deployThemes(type: CubaDeployThemeTask, dependsOn: buildScssThemes)
      assemble.dependsOn buildScssThemes
    }
CUBA CLI中创建:
  • 运行 theme 命令,然后选择 hover 主题。

    将在项目的 web 模块中创建特定的文件结构。

  • 修改生成的文件结构和文件内容,使其与上面的文件相对应。

  • web 模块的资源目录中创建 hover-dark-theme.properties 文件:

    @include=com/haulmont/cuba/hover-theme.properties

CLI 将自动更新 build.gradlesettings.gradle 文件。

另请参阅创建 Facebook 主题部分中的示例。

修改服务端主题参数

在 Halo 主题中,标准操作和平台界面会默认使用 Font Awesome 图标(如果启用了cuba.web.useFontIcons)。在这种情况下,可以通过在 <your_theme>-theme.properties 文件中设置图标和字体元素名称之间所需的映射来替换标准图标。例如,要在新 Facebook 主题中要为 create 操作使用"plus"图标,facebook-theme.properties 文件应包含以下内容:

@include=com/haulmont/cuba/halo-theme.properties

cuba.web.icons.create.png = font-icon:PLUS

Facebook 主题中带有修改后的 create 操作的标准用户浏览界面的片段:

gui theme facebook 1
3.5.9.3.1. 创建 Hover Dark 主题

这里介绍创建 Hover Dark 主题的步骤,这个主题是默认 Hover 主题的暗色变体。使用此主题的示例应用程序可在 GitHub 上找到。

  1. 按照创建自定义主题部分中的说明在项目中创建新的 hover-dark 主题。

    会在 web 模块中创建所需的文件结构。新建的 webThemesModule 模块及其配置将自动被添加到 settings.gradlebuild.gradle 文件中。

  2. 重新设置 hover-dark-defaults.scss 文件中的默认样式变量,比如,可以用以下变量值替换其中的变量:

    @import "../hover/hover-defaults";
    
    $v-app-background-color: #262626;
    $v-background-color: lighten($v-app-background-color, 12%);
    $v-border: 1px solid (v-tint 0.8);
    $font-color: valo-font-color($v-background-color, 0.85);
    $v-button-font-color: $font-color;
    $v-font-color: $font-color;
    $v-link-font-color: lighten($v-focus-color, 15%);
    $v-link-text-decoration: none;
    $v-textfield-background-color: $v-background-color;
    
    $cuba-hover-color: #75a4c1;
    $cuba-maintabsheet-tabcontainer-background-color: $v-app-background-color;
    $cuba-menubar-background-color: lighten($v-app-background-color, 4%);
    $cuba-tabsheet-tab-caption-selected-color: $v-font-color;
    $cuba-window-modal-header-background: $v-background-color;
    
    $cuba-menubar-menuitem-border-radius: 0;
  3. 使用 cuba.themeConfig 应用程序属性定义要在应用程序中使用的主题:

    cuba.themeConfig = com/haulmont/cuba/hover-theme.properties /com/company/demo/web/hover-dark-theme.properties

于是,在应用程序中将有两个主题将可用:默认 Hover 主题及其暗色变体。

hover dark
3.5.9.3.2. 创建 Facebook 主题

以下是创建基于 Halo 的 Facebook 主题的示例,该主题风格类似流行的社交网络界面。

  1. 在 CUBA Studio 中,点击 CUBA > Advanced > Manage themes > Create custom theme。设置主题名称 - facebook,选择 halo 作为基础主题,然后单击 Create。在项目中将创建新的主题目录:

    themes/
        facebook/
            branding/
                app-icon-login.png
                app-icon-menu.png
            com.haulmont.cuba/
                app-component.scss                  // cuba app-component include
            facebook.scss                           // main theme file
            facebook-defaults.scss                  // main theme variables
            favicon.ico
            styles.scss                             // entry point of SCSS build procedure

    styles.scss 文件包含主题列表:

    @import "facebook-defaults";
    @import "facebook";
    
    .facebook {
      @include facebook;
    }

    facebook.scss 文件:

    @import "../halo/halo";
    
    @mixin facebook {
      @include halo;
    }

    com.haulmont.cuba 中的 app-component.scss 文件:

    @import "../facebook";
    
    @mixin com_haulmont_cuba {
      @include facebook;
    }
  2. 修改 facebook-defaults.scss 中的主题变量。可以通过在 Studio 中单击 Manage themes > Edit Facebook theme variables 或在 IDE 中执行此操作:

    @import "../halo/halo-defaults";
    
    $v-background-color: #fafafa;
    $v-app-background-color: #e7ebf2;
    $v-panel-background-color: #fff;
    $v-focus-color: #3b5998;
    
    $v-border-radius: 0;
    $v-textfield-border-radius: 0;
    
    $v-font-family: Helvetica, Arial, 'lucida grande', tahoma, verdana, arial, sans-serif;
    $v-font-size: 14px;
    $v-font-color: #37404E;
    $v-font-weight: 400;
    
    $v-link-text-decoration: none;
    $v-shadow: 0 1px 0 (v-shade 0.2);
    $v-bevel: inset 0 1px 0 v-tint;
    $v-unit-size: 30px;
    $v-gradient: v-linear 12%;
    $v-overlay-shadow: 0 3px 8px v-shade, 0 0 0 1px (v-shade 0.7);
    $v-shadow-opacity: 20%;
    $v-selection-overlay-padding-horizontal: 0;
    $v-selection-overlay-padding-vertical: 6px;
    $v-selection-item-border-radius: 0;
    
    $v-line-height: 1.35;
    $v-font-size: 14px;
    $v-font-weight: 400;
    $v-unit-size: 25px;
    
    $v-font-size--h1: 22px;
    $v-font-size--h2: 18px;
    $v-font-size--h3: 16px;
    
    $v-layout-margin-top: 8px;
    $v-layout-margin-left: 8px;
    $v-layout-margin-right: 8px;
    $v-layout-margin-bottom: 8px;
    
    $v-layout-spacing-vertical: 8px;
    $v-layout-spacing-horizontal: 8px;
    
    $v-table-row-height: 25px;
    $v-table-header-font-size: 13px;
    $v-table-cell-padding-horizontal: 5px;
    
    $v-focus-style: inset 0px 0px 1px 1px rgba($v-focus-color, 0.5);
    $v-error-focus-style: inset 0px 0px 1px 1px rgba($v-error-indicator-color, 0.5);
  3. web 模块的 src 目录中的 facebook-theme.properties 文件可用于覆盖服务端使用的平台的 halo-theme.properties 文件中的主题变量。

  4. 新主题已被自动添加到 web-app.properties 文件中:

    cuba.web.theme = facebook
    cuba.themeConfig = com/haulmont/cuba/halo-theme.properties /com/company/application/web/facebook-theme.properties

    cuba.themeConfig 属性定义了应用程序的 Settings 菜单中可供用户使用的主题。

重新构建应用程序并启动服务。现在,用户将在首次登录时看到使用 Facebook 主题的应用程序,并且可以在 Help > Settings 菜单中选择 Facebook、Halo 和 Havana 主题。

facebook theme
3.5.9.4. 使用应用程序组件中的主题

如果项目包含带有自定义主题的应用程序组件,则可以将此主题用于整个项目。

要按原样继承主题,只需将其添加到cuba.themeConfig应用程序属性:

cuba.web.theme = {theme-name}
cuba.themeConfig = com/haulmont/cuba/hover-theme.properties /com/company/{app-component-name}/{theme-name}-theme.properties

如果要覆盖父主题中的某些变量,则首先需要在项目中创建主题扩展。

在下面的例子中,我们将使用创建自定义主题部分中的 facebook 主题。

  1. 按照步骤为应用程序组件创建 facebook 主题。

  2. 使用 Studio 菜单安装应用程序组件,如应用程序组件示例部分所述。

  3. 在使用应用程序组件的项目中扩展 halo 主题。

  4. 通过 IDE,将 themes 目录下的所有文件名中的 halo 重命名为 facebook,以获得以下结构:

    themes/
        facebook/
            branding/
                app-icon-login.png
                app-icon-menu.png
            com.company.application/
                app-component.scss
                facebook-ext.scss
                facebook-ext-defaults.scss
            favicon.ico
            styles.scss
  5. app-component.scss 文件合并应用程序组件的主题修改。在 SCSS 构建过程中,Gradle 插件会自动查找应用程序组件并将其导入生成的 modules/web/build/themes-tmp/VAADIN/themes/{theme-name}/app-components.scss 文件中。

    默认情况下,app-component.scss 不包含来自 {theme-name}-ext-defaults 的变量改动。要包含变量改动到 app 组件包,需要在 app-component.scss 中手动导入:

    @import "facebook-ext";
    @import "facebook-ext-defaults";
    
    @mixin com_company_application {
      @include com_company_application-facebook-ext;
    }

    在这个阶段,facebook 主题已经从 app 组件导入到项目中。

  6. 现在,可以使用 com.company.application 包中的 facebook-ext.scssfacebook-ext-defaults.scss 文件覆盖 app 组件主题中的变量,并为具体项目自定义变量。

  7. 将以下属性添加到 web-app.properties 文件中,以使应用程序的 Settings 菜单中的 facebook 主题可用。使用相对路径从 app 组件引用 facebook-theme.properties

    cuba.web.theme = facebook
    cuba.themeConfig = com/haulmont/cuba/hover-theme.properties /com/company/{app-component-name}/facebook-theme.properties

如果主题构建有任何问题,请检查 modules/web/build/themes-tmp 目录。它包含所有文件和生成的 app-component.scss,从而能够查找 SCSS 编译问题。

3.5.9.5. 创建可复用主题

任何主题都可以在没有应用程序组件的情况下打包和重用。要创建主题包,需要从头开始创建 Java 项目并将其打包在单个 JAR 文件中。按照以下步骤创建前面示例中 facebook 主题的发布。

  1. 在 IDE 中使用以下结构创建新项目。它是一个简单的 Java 项目,由 SCSS 文件和主题属性组成:

    halo-facebook/
        src/                                            //sources root
            halo-facebook/
                com.haulmont.cuba/
                    app-component.scss
                halo-facebook.scss
                halo-facebook-defaults.scss
                halo-facebook-theme.properties
                styles.scss

    此示例主题项目可以从 GitHub 下载。

    • build.gradle 脚本:

      allprojects {
          group = 'com.haulmont.theme'
          version = '0.1'
      }
      
      apply(plugin: 'java')
      apply(plugin: 'maven')
      
      sourceSets {
          main {
              java {
                  srcDir 'src'
              }
              resources {
                  srcDir 'src'
              }
          }
      }
    • settings.gradle 文件:

      rootProject.name = 'halo-facebook'
    • app-component.scss 文件:

      @import "../halo-facebook";
      
      @mixin com_haulmont_cuba {
        @include halo-facebook;
      }
    • halo-facebook.scss 文件:

      @import "../@import "../";
      
      @mixin halo-facebook {
        @include halo;
      }
    • halo-facebook-defaults.scss 文件:

      @import "../halo/halo-defaults";
      
      $v-background-color: #fafafa;
      $v-app-background-color: #e7ebf2;
      $v-panel-background-color: #fff;
      $v-focus-color: #3b5998;
      $v-border-radius: 0;
      $v-textfield-border-radius: 0;
      $v-font-family: Helvetica, Arial, 'lucida grande', tahoma, verdana, arial, sans-serif;
      $v-font-size: 14px;
      $v-font-color: #37404E;
      $v-font-weight: 400;
      $v-link-text-decoration: none;
      $v-shadow: 0 1px 0 (v-shade 0.2);
      $v-bevel: inset 0 1px 0 v-tint;
      $v-unit-size: 30px;
      $v-gradient: v-linear 12%;
      $v-overlay-shadow: 0 3px 8px v-shade, 0 0 0 1px (v-shade 0.7);
      $v-shadow-opacity: 20%;
      $v-selection-overlay-padding-horizontal: 0;
      $v-selection-overlay-padding-vertical: 6px;
      $v-selection-item-border-radius: 0;
      
      $v-line-height: 1.35;
      $v-font-size: 14px;
      $v-font-weight: 400;
      $v-unit-size: 25px;
      
      $v-font-size--h1: 22px;
      $v-font-size--h2: 18px;
      $v-font-size--h3: 16px;
      
      $v-layout-margin-top: 8px;
      $v-layout-margin-left: 8px;
      $v-layout-margin-right: 8px;
      $v-layout-margin-bottom: 8px;
      
      $v-layout-spacing-vertical: 8px;
      $v-layout-spacing-horizontal: 8px;
      
      $v-table-row-height: 25px;
      $v-table-header-font-size: 13px;
      $v-table-cell-padding-horizontal: 5px;
      
      $v-focus-style: inset 0px 0px 1px 1px rgba($v-focus-color, 0.5);
      $v-error-focus-style: inset 0px 0px 1px 1px rgba($v-error-indicator-color, 0.5);
      
      $v-show-required-indicators: true;
    • halo-facebook-theme.properties 文件:

      @include=com/haulmont/cuba/halo-theme.properties
  2. 使用 Gradle 任务构建和安装项目:

    gradle assemble install
  3. 通过修改 build.gradle 文件,将主题作为 Maven 依赖项添加到基于 CUBA 的项目中,有两种配置方式(gradle configurations):themes 和 compile:

    configure(webModule) {
        //...
        dependencies {
            provided(servletApi)
            compile(guiModule)
    
            compile('com.haulmont.theme:halo-facebook:0.1')
            themes('com.haulmont.theme:halo-facebook:0.1')
        }
        //...
    }

    如果在本地安装主题,不要忘记将 mavenLocal() 添加到仓库列表中:在 Studio 中打 Project Properties 界面,选中 “User local maven repository” 复选框。

  4. 要在项目中继承此主题并修改它,必须扩展此主题。扩展 halo 主题并将 themes/halo 文件夹重命名为 themes/halo-facebook

    themes/
        halo-facebook/
            branding/
                app-icon-login.png
                app-icon-menu.png
            com.company.application/
                app-component.scss
                halo-ext.scss
                halo-ext-defaults.scss
            favicon.ico
            styles.scss
  5. 修改 styles.scss 文件:

    @import "halo-facebook-defaults";
    @import "com.company.application/halo-ext-defaults";
    @import "app-components";
    @import "com.company.application/halo-ext";
    
    .halo-facebook {
      // include auto-generated app components SCSS
      @include app_components;
    
      @include com_company_application-halo-ext;
    }
  6. 最后一步是在 web-app.properties 文件中定义 halo-facebook-theme.properties 文件:

    cuba.themeConfig = com/haulmont/cuba/hover-theme.properties /halo-facebook/halo-facebook-theme.properties

现在,可以从 Help > Settings 菜单中选择 halo-facebook 主题,或使用 cuba.web.theme 应用程序属性设置默认主题。

3.5.10. 图标

用于操作和可视化组件(如Button)的icon属性的图像文件,可以被添加到主题扩展中。

例如,要向 Halo 主题扩展添加图标,必须将图像文件添加到扩展现有主题部分描述的 modules/web/themes/halo 目录中(建议创建子文件夹):

themes/
  halo/
    icons/
      cool-icon.png

在以下章节中,我们将了解如何在可视化组件中使用图标以及如何从任意字体库添加图标。

3.5.10.1. 图标集

图标集允许将可视化组件中图标的使用与主题中图片的实际路径或字体元素常量解耦。它们还简化了对继承自应用程序组件的 UI 中使用的图标的覆盖。

图标集是一个枚举,包含了与图标对应的枚举值。图标集必须实现 Icons.Icon 接口,该接口有一个参数:表示图标资源的字符串,例如,font-icon:CHECKicons/myawesomeicon.png。要获取资源,请使用平台提供的 Icons bean。

可以在 web 或者 gui 模块中创建图标集。图标集中所有图标的名称应匹配正则表达式:[A-Z]_,即它们应仅包含大写字母和下划线。

例如:

public enum MyIcon implements Icons.Icon {

    COOL_ICON("icons/cool-icon.png"), (1)

    OK("icons/my-ok.png"); (2)

    protected String source;

    MyIcon(String source) {
        this.source = source;
    }

    @Override
    public String source() {
        return source;
    }

    @Override
    public String iconName() {
        return name();
    }
}
1 - 添加新图标
2 - 覆盖 CUBA 默认图标

图标集应该在cuba.iconsConfig应用程序属性中注册,例如:

web-app.properties
cuba.iconsConfig = +com.company.demo.gui.icons.MyIcon

要使某个应用程序组件中的图标集在目标项目中可用,此属性应该添加到应用程序组件描述

现在,可以在界面 XML 描述中以声明方式使用此图标集中的图标:

<button icon="COOL_ICON"/>

或在界面控制器中以编程方式使用:

button.setIconFromSet(MyIcon.COOL_ICON);

以下前缀允许以声明方式使用不同来源的图标:

  • theme - 图标将由当前主题目录提供,例如,web/themes/halo/awesomeFolder/superIcon.png

    <button icon="theme:awesomeFolder/superIcon.png"/>
  • file - 图标将由文件系统提供:

    <button icon="file:D:/superIcon.png"/>
  • classpath - 图标将由类路径提供,例如,com/company/demo/web/superIcon.png

    <button icon="classpath:/com/company/demo/web/superIcon.png"/>

平台提供了一个预定义的图标集 - CubaIcon。它包括几乎完整的 FontAwesome 图标集和 CUBA 特定的图标。可以在 Studio 图标编辑界面中选择这些图标:

icon set
3.5.10.2. 使用其它字体库中的图标

为了增强主题扩展,可能需要创建图标并将其嵌入到字体中,以及使用一些外部图标库。

  1. web 模块中,为新图标创建实现 com.vaadin.server.FontIcon 接口的 enum 类:

    import com.vaadin.server.FontIcon;
    import com.vaadin.server.GenericFontIcon;
    
    public enum IcoMoon implements FontIcon {
    
        HEADPHONES(0XE900),
        SPINNER(0XE905);
    
        public static final String FONT_FAMILY = "IcoMoon";
        private int codepoint;
    
        IcoMoon(int codepoint) {
            this.codepoint = codepoint;
        }
    
        @Override
        public String getFontFamily() {
            return FONT_FAMILY;
        }
    
        @Override
        public int getCodepoint() {
            return codepoint;
        }
    
        @Override
        public String getHtml() {
            return GenericFontIcon.getHtml(FONT_FAMILY, codepoint);
        }
    
        @Override
        public String getMIMEType() {
            throw new UnsupportedOperationException(FontIcon.class.getSimpleName()
                    + " should not be used where a MIME type is needed.");
        }
    
        public static IcoMoon fromCodepoint(final int codepoint) {
            for (IcoMoon f : values()) {
                if (f.getCodepoint() == codepoint) {
                    return f;
                }
            }
            throw new IllegalArgumentException("Codepoint " + codepoint
                    + " not found in IcoMoon");
        }
    }
  2. 向主题扩展添加新样式。建议在主题扩展的主文件夹中创建一个特定的子文件夹 fonts,例如 modules/web/themes/halo/com.company.demo/fonts。将样式和字体文件放在特有的子文件夹中,例如 fonts/icomoon

    字体文件由以下扩展名表示:

    • .eot,

    • .svg,

    • .ttf,

    • .woff.

      本例中使用了一个开源字体集 icomoon,由 4 个联合使用的文件组成:icomoon.eoticomoon.svgicomoon.ttficomoon.woff

  3. 创建一个包含 @font-face 样式和一个图标样式 CSS 类的文件。下面是 icomoon.scss 文件的示例,其中 IcoMoon css 类名对应于 FontIcon#getFontFamily 方法返回的值:

    @mixin icomoon-style {
        /* use !important to prevent issues with browser extensions that change fonts */
        font-family: 'icomoon' !important;
        speak: none;
        font-style: normal;
        font-weight: normal;
        font-variant: normal;
        text-transform: none;
        line-height: 1;
    
        /* Better Font Rendering =========== */
        -webkit-font-smoothing: antialiased;
        -moz-osx-font-smoothing: grayscale;
    }
    
    @font-face {
        font-family: 'icomoon';
        src:url('icomoon.eot?hwgbks');
        src:url('icomoon.eot?hwgbks#iefix') format('embedded-opentype'),
            url('icomoon.ttf?hwgbks') format('truetype'),
            url('icomoon.woff?hwgbks') format('woff'),
            url('icomoon.svg?hwgbks#icomoon') format('svg');
        font-weight: normal;
        font-style: normal;
    }
    
    .IcoMoon {
        @include icomoon-style;
    }
  4. halo-ext.scss 文件或其它主题扩展文件中添加对上述包含字体样式的文件的引用。

    @import "fonts/icomoon/icomoon";
  5. 然后创建新的图标集,这是一个实现 Icons.Icon 接口的枚举:

    import com.haulmont.cuba.gui.icons.Icons;
    
    public enum IcoMoonIcon implements Icons.Icon {
        HEADPHONES("ico-moon:HEADPHONES"),
        SPINNER("ico-moon:SPINNER");
    
        protected String source;
    
        IcoMoonIcon(String source) {
            this.source = source;
        }
    
        @Override
        public String source() {
            return source;
        }
    
        @Override
        public String iconName() {
            return name();
        }
    }
  6. 创建新的 IconProvider.

    为了管理自定义图标集,CUBA 框架提供了由 IconProviderIconResolver 组成的机制。

    IconProvider 是一个标记接口,只存在于 web 模块中,可以通过图标路径提供资源(com.vaadin.server.Resource)。

    IconResolver bean 获取实现 IconProvider 接口的所有 bean,并遍历它们以找到可以为图标提供资源的 bean。

    要使用这种机制,应该创建 IconProvider 的实现:

    import com.haulmont.cuba.web.gui.icons.IconProvider;
    import com.vaadin.server.Resource;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Component;
    
    @Order(10)
    @Component
    public class IcoMoonIconProvider implements IconProvider {
        private final Logger log = LoggerFactory.getLogger(IcoMoonIconProvider.class);
    
        @Override
        public Resource getIconResource(String iconPath) {
            Resource resource = null;
    
            iconPath = iconPath.split(":")[1];
    
            try {
                resource = ((Resource) IcoMoon.class
                        .getDeclaredField(iconPath)
                        .get(null));
            } catch (IllegalAccessException | NoSuchFieldException e) {
                log.warn("There is no icon with name {} in the FontAwesome icon set", iconPath);
            }
    
            return resource;
        }
    
        @Override
        public boolean canProvide(String iconPath) {
            return iconPath.startsWith("ico-moon:");
        }
    }

    这里我们用 @Order annotation 注解显式地为这个 bean 指定顺序。

  7. 在应用程序属性文件中注册自定义图标集:

    cuba.iconsConfig = +com.company.demo.gui.icons.IcoMoonIcon

现在,在界面 XML 描述中可以直接引用图标的类和 枚举 元素 :

<button caption="Headphones" icon="ico-moon:HEADPHONES"/>

或者在 Java 控制器中:

spinnerBtn.setIconFromSet(IcoMoonIcon.SPINNER);

这样新图标会添加到按钮上:

add icons
覆盖图标集的图标

图标集机制可以覆盖其它图标集中的图标。为此,应该创建并注册一个具有相同图标(枚举值)但不同图标路径(source)的新图标集(枚举)。在下面的示例中,创建了一个新的图标枚举 MyIcon ,用于覆盖 CubaIcon 图标集中的标准图标。

  1. 默认图标集:

    public enum CubaIcon implements Icons.Icon {
        OK("font-icon:CHECK"),
        CANCEL("font-icon:BAN"),
       ...
    }
  2. 新图标集:

    public enum MyIcon implements Icons.Icon {
        OK("icons/my-custom-ok.png"),
       ...
    }
  3. web-app.properties 中注册的新图标集:

    cuba.iconsConfig = +com.company.demo.gui.icons.MyIcon

现在,新的 OK 图标将代替标准图标而被使用:

Icons icons = AppBeans.get(Icons.NAME);
button.setIcon(icons.getIcon(CubaIcon.OK))

如果需要忽略重新定义的图标,仍然可以通过使用标准图标的路径而不是选项名称来使用标准图标:

<button caption="Created" icon="icons/create.png"/>

或者

button.setIcon(CubaIcon.CREATE_ACTION.source());

3.5.11. 可视化组件的 DOM 和 CSS 属性

框架提供了设置组件原生 HTML 属性的 API,用于为可视化组件设置 DOM 和 CSS 属性。

HtmlAttributes bean 允许使用以下方法通过编程方式设置 DOM/CSS 属性:

  • setDomAttribute(Component component, String attributeName, String value) - 在 UI 组件的最顶层 HTML 元素上设置 DOM 属性。

  • setCssProperty(Component component, String propertyName, String value) - 在 UI 组件的最顶层 HTML 元素上设置 CSS 属性值。

  • setDomAttribute(Component component, String querySelector, String attributeName, String value) – 为 UI 组件内满足查询选择器的所有嵌套元素设置 DOM 属性。

  • getDomAttribute(Component component, String querySelector, String attributeName) – 获取之前使用 HtmlAttributes 设置的 DOM 属性。并不能反映当前 DOM 的真实值。

  • removeDomAttribute(Component component, String querySelector, String attributeName) – 为 UI 组件内满足查询选择器的所有嵌套元素移除 DOM 属性。

  • setCssProperty(Component component, String querySelector, String propertyName, String value) – 为 UI 组件内满足查询选择器的所有嵌套元素设置 CSS 属性值。

  • getCssProperty(Component component, String querySelector, String propertyName) – 获取之前使用 HtmlAttributes 设置的 CSS 属性值。并不能反映当前 DOM 的真实值。

  • removeCssProperty(Component component, String querySelector, String propertyName) – 为 UI 组件内满足查询选择器的所有嵌套元素清除 CSS 属性值。

  • applyCss(Component component, String querySelector, String css) – 使用 CSS 字符串应用 CSS 属性。

以上方法接收下面这些参数:

  • component – 组件标识符。

  • querySelector – 字符串,包含一个或多个 选择器 用来做匹配。这个字符串必须是正确的 CSS 选择器字符串。

  • attributeName – DOM 属性名称(比如 title)。

  • propertyName – CSS 属性名称(比如 border-color)。

  • value – 属性值。

最常见的 DOM 属性名称和 CSS 属性名称在 HtmlAttributes bean 类中作为常量提供,但也可以使用任何自定义属性。

特定属性的功能可能会根据应用此属性的组件而有所不同。某些可视化组件可能为了特殊的目的隐式使用了相同的属性,因此上述方法在某些情况下可能不起作用。

HtmlAttributes bean 应该注入界面控制器中并按如下方式使用:

XML 描述
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="Demo"
        messagesPack="com.company.demo.web">
    <layout>
        <button id="demoButton"
                caption="msg://demoButton"
                width="33%"/>
    </layout>
</window>
界面控制器
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.components.HtmlAttributes;
import com.haulmont.cuba.gui.screen.Screen;
import com.haulmont.cuba.gui.screen.Subscribe;
import com.haulmont.cuba.gui.screen.UiController;
import com.haulmont.cuba.gui.screen.UiDescriptor;

import javax.inject.Inject;

@UiController("demo_DemoScreen")
@UiDescriptor("demo-screen.xml")
public class DemoScreen extends Screen {

    @Inject
    private Button demoButton;

    @Inject
    protected HtmlAttributes html;

    @Subscribe
    private void onBeforeShow(BeforeShowEvent event) {
        html.setDomAttribute(demoButton, HtmlAttributes.DOM.TITLE, "Hello!");

        html.setCssProperty(demoButton, HtmlAttributes.CSS.BACKGROUND_COLOR, "red");
        html.setCssProperty(demoButton, HtmlAttributes.CSS.BACKGROUND_IMAGE, "none");
        html.setCssProperty(demoButton, HtmlAttributes.CSS.BOX_SHADOW, "none");
        html.setCssProperty(demoButton, HtmlAttributes.CSS.BORDER_COLOR, "red");
        html.setCssProperty(demoButton, "color", "white");

        html.setCssProperty(demoButton, HtmlAttributes.CSS.MAX_WIDTH, "400px");
    }
}

3.5.12. 键盘快捷键

本节提供可在应用程序通用用户界面中使用的键盘快捷键列表。下面列出的所有应用程序属性都属于 ClientConfig 接口,可以在 Web 客户端应用程序模块中使用。

  • 主应用程序窗口。

    • CTRL-SHIFT-PAGE_DOWN – 切换到下一个标签页。由 cuba.gui.nextTabShortcut 属性定义。

    • CTRL-SHIFT-PAGE_UP – 切换到上一个标签页。由 cuba.gui.previousTabShortcut 属性定义。

  • 文件夹面板。要使用文件夹的快捷键,需要设置 cuba.web.foldersPaneEnabled 属性为 true

    • ENTER – 打开选择的文件夹

    • SPACE - 选择/取消选择获得焦点的文件夹。

    • ARROW UPARROW DOWN - 切换文件夹。

    • ARROW LEFTARROW RIGHT - 折叠/展开包含子文件夹的文件夹或跳转到已有文件夹。

  • 界面。

    • ESCAPE – 关闭当前界面。由 cuba.gui.closeShortcut 属性定义。

    • CTRL-ENTER – 关闭当前编辑器并保存更改。由 cuba.gui.commitShortcut 属性定义。

  • 列表组件(表格分组表格树形表格)的标准操作。除了这些应用程序属性之外,还可以通过调用其 setShortcut() 方法来设置特定操作的快捷键。

    • CTRL-\ – 调用 CreateAction。由 cuba.gui.tableShortcut.insert 属性定义。

    • CTRL-ALT-\ – 调用 AddAction。由 cuba.gui.tableShortcut.add 属性定义。

    • ENTER – 调用 EditAction。由 cuba.gui.tableShortcut.edit 属性定义。

    • CTRL-DELETE – 调用 RemoveActionExcludeAction。由 cuba.gui.tableShortcut.remove 属性定义。

  • 下拉列表(LookupFieldLookupPickerField)。

    • SHIFT-DELETE – 清除值。

  • Lookup 控件的标准操作(PickerFieldLookupPickerFieldSearchPickerField)。除了这些应用程序属性外,还可以通过调用其 setShortcut() 方法来设置特定操作的快捷键。

    • CTRL-ALT-L – 调用 LookupAction。由 cuba.gui.pickerShortcut.lookup 定义。

    • CTRL-ALT-O – 调用 OpenAction。由 cuba.gui.pickerShortcut.open 属性定义。

    • CTRL-ALT-C – 调用 ClearAction。由 cuba.gui.pickerShortcut.clear 属性定义。

    除了这些快捷键外,Lookup 控件还支持使用 CTRL-ALT-1 、CTRL-ALT-2 等操作调用,具体取决于操作个数。如果点击 CTRL-ALT-1,将调用列表中的第一个操作;点击 CTRL-ALT-2 调用第二个操作等。CTRL-ALT 组合可以替换为 cuba.gui.pickerShortcut.modifiers 属性中指定的任何其它组合。

  • Filter 组件。

    • SHIFT-BACKSPACE – 打开过滤器选择弹出窗口。由 cuba.gui.filterSelectShortcut 属性定义。

    • SHIFT-ENTER – 应用选择的过滤器。由 cuba.gui.filterApplyShortcut 属性定义。

3.5.13. URL 历史及导航

CUBA URL 历史和导航功能可以提供浏览器历史记录和导航功能,这对于很多 web 应用程序来说是最基本的功能。此功能包含以下部分:

  • 历史 – 支持浏览器的 后退(Back) 按钮。但是不支持 前进(Forward) 按钮,因为没法重现打开界面的所有条件。

  • 路由以及导航 – 注册和处理应用程序界面的路由。

  • 路由 API – 一组方法,用来在 URL 中反映界面的当前状态。

fragment 是 URL 里“#”后面的部分,这部分用来做 路由 值。

比如,下面这个 URL:

host:port/app/#main/42/orders/edit?id=17

这个 URL 中,fragment 是 main/42/orders/edit?id=17,由以下部分组成:

  • main – 根界面的路由(主窗口);

  • 42 – 一个 状态标记(state mark),导航机制内部使用;

  • orders/edit – 嵌套的界面路由;

  • ?id=17 – 参数部分;

所有打开的界面都会将它们的路由映射到当前的 URL。比如,当用户浏览界面打开并且是当前界面的时候,应用程序的 URL 可能是这样:

http://localhost:8080/app/#main/0/users

如果界面没有已经注册的路由,那么只会在 URL fragment 中添加状态标记。示例:

http://localhost:8080/app/#main/42

对于编辑界面,如果界面有注册的路由,则会在地址后面以参数形式加上被编辑实体的 id。示例:

http://localhost:8080/app/#main/1/users/edit?id=27zy3tj6f47p2e3m4w58vdca9y

UUID 类型的标识符会使用 Base32 Crockford 进行加密,其它类型则不会加密。

当没有用户登录的时候,又出于某些原因需要界面路由,则会使用重定向(redirect)参数。假设在地址栏中输入 app/#main/orders。当应用程序加载并且显示登录界面之后,地址会变成:app/#login?redirectTo=orders。在成功登录之后,才会打开 orders 路由对应的界面。

如果请求的路由不存在,应用程序会显示一个带有"Not Found"标题的空界面。

URL 历史和导航功能默认开启。设置应用程序属性cuba.web.urlHandlingModeNONE 可以关闭此功能,或者设置为 BACK_ONLY 回退到处理浏览器返回按钮的旧机制。

3.5.13.1. 处理 URL 改动

框架能根据应用程序 URL 的变动自动做出响应:会尝试对请求的路由进行解析然后进行历史导航,或者为注册了的路由打开新界面。

当界面通过带参数的路由打开时,框架会在界面显示前先给界面控制器发送 UrlParamsChangedEvent 事件,如果在界面打开了之后 URL 参数发生变化,框架也会做同样的事情。可以订阅此事件来处理界面的初始化参数或者参数的变化。比如,可以根据 URL 参数来加载数据或者隐藏/展示特定的界面 UI 组件。

在界面控制器订阅此事件的示例:

@Subscribe
protected void onUrlParamsChanged(UrlParamsChangedEvent event) {
    // handle
}

使用 UrlParamsChangedEvent 的完整示例请参阅后面章节

3.5.13.2. 路由 API

本章节介绍路由 API 的关键内容。

路由注册

要为一个界面注册路由,需要在界面控制器添加 @Route 注解,示例:

@Route("my-screen")
public class MyScreen extends Screen {
}

该注解有三个参数:

  • path(或 value)是路由本身的值;

  • parentPrefix 用来做路由压缩(squashing)(参阅 以下)。

  • root 是一个布尔值属性,用来确定一个路由是否是为根界面定义的(比如登录界面或者 主界面)。默认值为 false

    如果您创建了一个根界面,实用了非默认 login 的其他路径并且做成了可以不需要登录而通过链接直接访问的界面,您需要为匿名用户启用该界面。否则,当用户输入 URL,比如 /app/#your_root_screen,他们会被重定向到 /app/#login 界面。

    1. web-app.properties 文件设置 cuba.web.allowAnonymousAccess = true

    2. 为匿名用户启用该界面:启动应用程序,打开 Administration > Roles,然后创建一个新角色并分配该界面的访问权限。然后为 anonymous 用户分配该角色。

如果需要为旧界面定义路由,可以在screens.xml文件中为界面元素添加 route 属性(routeParentPrefix 可选,即对应 parentPrefix 参数;rootRoute 对应 root 参数),示例:

<screen id="myScreen" template="..." route="my-screen" />
路由压缩

此功能目的是为了在打开多个带有相同部分路由的界面时保持 URL 干净易读。举例说明:

Order 实体,假设有浏览和编辑界面:

@Route("orders")
public class OrderBrowser extends StandardLookup<Order> {
}

@Route("orders/edit")
public class OrderEditor extends StandardEditor<Order> {
}

在打开浏览界面之后马上打开编辑界面,就能用上 URL 压缩,此时,URL 压缩用来避免 URL 中重复的 orders 路由部分。需要在编辑界面的 @Route 注解中用 parentPrefix 参数指定路由的重复部分:

@Route("orders")
public class OrderBrowser extends StandardLookup<Order> {
}

@Route(value = "orders/edit", parentPrefix = "orders")
public class OrderEditor extends StandardEditor<Order> {
}

现在,当跟浏览界面在同一标签页打开编辑界面时,地址将像这样:app/#main/0/orders/edit?id=…​

UI 状态与 URL 的映射

使用 UrlRouting bean 可以根据当前界面和一些参数来更改当前应用程序的 URL。其包含如下方法:

  • pushState() – 更改地址并添加新的浏览器历史记录;

  • replaceState() – 替换地址但不添加新的浏览器历史记录;

  • getState() – 将当前状态作为 NavigationState 对象返回。

pushState()/replaceState() 方法接受当前界面控制器和一组参数(map 形式,可选)为输入参数。

使用 UrlRouting 的示例,请参阅后面部分。

导航过滤

导航过滤器可以用来阻止切换到某些路由。

导航过滤器是实现了 NavigationFilter 接口的 Spring bean。可以使用 @Order 注解来配置所有导航过滤器的调用顺序。常量 NavigationFilter.HIGHEST_PLATFORM_PRECEDENCENavigationFilter.LOWEST_PLATFORM_PRECEDENCE 用来定义框架中的过滤器优先级范围。

NavigationFilter 接口有 allowed() 方法,可以使用两个输入参数:当前导航状态 fromState 和请求的导航状态 toState。此方法返回 AccessCheckResult 实例并检查是否允许从当前导航状态切换到请求导航状态。

CubaLoginScreenFilter 是一个导航过滤器的例子。设计用来在用户已经登录的情况下,检查当前会话是否已授权能拒绝导航至登录界面:

@Component
@Order(NavigationFilter.LOWEST_PLATFORM_PRECEDENCE)
public class CubaLoginScreenFilter implements NavigationFilter {
    @Inject
    protected Messages messages;

    @Override
    public AccessCheckResult allowed(NavigationState fromState, NavigationState toState) {
        if (!"login".equals(toState.getRoot())) {
            return AccessCheckResult.allowed();
        }
        boolean authenticated = App.getInstance().getConnection().isAuthenticated();
        return authenticated
                ? AccessCheckResult.rejected(messages.getMainMessage("navigation.unableToGoToLogin"))
                : AccessCheckResult.allowed();
    }
}
3.5.13.3. 使用 URL 历史和导航 API

本章节包含使用 URL 历史和导航 API 的示例。

假设有 Task - 任务 实体和 TaskInfo 界面,用来展示选中任务的信息。

TaskInfo 界面控制器包含 @Route 注解,用来指定此界面的路由:

package com.company.demo.web.navigation;

import com.haulmont.cuba.gui.Route;
import com.haulmont.cuba.gui.screen.Screen;
import com.haulmont.cuba.gui.screen.UiController;
import com.haulmont.cuba.gui.screen.UiDescriptor;

@Route("task-info")
@UiController("demo_TaskInfoScreen")
@UiDescriptor("task-info.xml")
public class TaskInfoScreen extends Screen {
}

然后,用户可以在浏览器的地址栏输入 http://localhost:8080/app/#main/task-info 来打开此界面:

url screen by route

当界面打开时,地址还包含一个状态标记。

状态与 URL 的映射

假设 TaskInfo 界面每次展示一个任务的信息,并且能控制切换选取的任务。也许需要在 URL 中反应当前查看的任务,这样可以拷贝 URL,以便之后可以在浏览器的地址栏粘贴 URL 就能打开查看此特定任务的界面。

下面的代码实现了选中任务和 URL 的映射:

package com.company.demo.web.navigation;

import com.company.demo.entity.Task;
import com.google.common.collect.ImmutableMap;
import com.haulmont.cuba.gui.Route;
import com.haulmont.cuba.gui.UrlRouting;
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.components.LookupField;
import com.haulmont.cuba.gui.screen.*;
import com.haulmont.cuba.web.sys.navigation.UrlIdSerializer;

import javax.inject.Inject;

@Route("task-info")
@UiController("demo_TaskInfoScreen")
@UiDescriptor("task-info.xml")
@LoadDataBeforeShow
public class TaskInfoScreen extends Screen {

    @Inject
    private LookupField<Task> taskField;

    @Inject
    private UrlRouting urlRouting;

    @Subscribe("selectBtn")
    protected void onSelectBtnClick(Button.ClickEvent event) {
        Task task = taskField.getValue(); (1)
        if (task == null) {
            urlRouting.replaceState(this); (2)
            return;
        }
        String serializedTaskId = UrlIdSerializer.serializeId(task.getId()); (3)

        urlRouting.replaceState(this, ImmutableMap.of("task_id", serializedTaskId)); (4)
    }
}
1 - 从 LookupField 获取当前任务
2 - 如果没有选中任务,移除 URL 参数
3 - 使用 UrlIdSerializer 来序列化任务的 id
4 - 用包含序列化任务 id 参数的新状态替换当前的 URL 状态。

结果是,当用户选中任务然后点击 Select Task 按钮时,应用程序 URL 会变化:

url reflection state
UrlParamsChangedEvent

现在实现最后一个需求:当用户输入带路由和 task_id 参数的 URL 时,应用程序必须展示相应任务的界面。下面是完整的界面控制器代码。

package com.company.demo.web.navigation;

import com.company.demo.entity.Task;
import com.google.common.collect.ImmutableMap;
import com.haulmont.cuba.core.global.DataManager;
import com.haulmont.cuba.gui.Route;
import com.haulmont.cuba.gui.UrlRouting;
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.components.LookupField;
import com.haulmont.cuba.gui.navigation.UrlParamsChangedEvent;
import com.haulmont.cuba.gui.screen.*;
import com.haulmont.cuba.web.sys.navigation.UrlIdSerializer;

import javax.inject.Inject;
import java.util.UUID;

@Route("task-info")
@UiController("demo_TaskInfoScreen")
@UiDescriptor("task-info.xml")
@LoadDataBeforeShow
public class TaskInfoScreen extends Screen {

    @Inject
    private LookupField<Task> taskField;

    @Inject
    private UrlRouting urlRouting;

    @Inject
    private DataManager dataManager;

    @Subscribe
    protected void onUrlParamsChanged(UrlParamsChangedEvent event) {
        String serializedTaskId = event.getParams().get("task_id"); (1)

        UUID taskId = (UUID) UrlIdSerializer.deserializeId(UUID.class, serializedTaskId); (2)

        taskField.setValue(dataManager.load(Task.class).id(taskId).one()); (3)
    }

    @Subscribe("selectBtn")
    protected void onSelectBtnClick(Button.ClickEvent event) {
        Task task = taskField.getValue();
        if (task == null) {
            urlRouting.replaceState(this);
            return;
        }
        String serializedTaskId = UrlIdSerializer.serializeId(task.getId());

        urlRouting.replaceState(this, ImmutableMap.of("task_id", serializedTaskId));
    }
}
1 - 从 UrlParamsChangedEvent 获取参数值
2 - 对任务 id 进行反序列化
3 - 加载任务实例并设置到界面控件
3.5.13.4. URL 路由生成器

有时候,需要获取一些应用程序界面的 URL 通过 email 发给用户或者展示给用户。生成 URL 最简单的方法就是使用 URL 路由生成器。

URL 路由生成器提供 API 用来生成链接:实体实例编辑界面、带界面 id 或者界面类的界面。连接还能带 URL 参数,将界面内部的状态反映到 URL 上,以便将来使用。

使用 UrlRouting bean 的 getRouteGenerator() 方法可以获得一个 RouteGenerator 的实例。RouteGenerator 具有下列方法:

  • getRoute(String screenId) – 返回指定 screenId 的界面路由,示例:

    String route = urlRouting.getRouteGenerator().getRoute("demo_Customer.browse");

    结果将会是 route = "http://host:port/context/#main/customers"

  • getRoute(Class<? extends Screen> screenClass) – 返回指定 screenClass 的界面路由,示例:

    String route = urlRouting.getRouteGenerator().getRoute(CustomerBrowse.class);

    结果将会是 route = "http://host:port/context/#main/customers"

  • getEditorRoute(Entity entity) – 返回指定 entity 的默认编辑界面的路由,示例:

    Customer сustomer = customersTable.getSingleSelected();
    
    String route = urlRouting.getRouteGenerator().getEditorRoute(сustomer);

    结果将会是 route == "http://localhost:8080/app/#main/customers/edit?id=5jqtc3pwzx6g6mq1vv5gkyjn0s"

  • getEditorRoute(Entity entity, Class<? extends Screen> screenClass) – 生成指定 screenClassentity 的编辑界面路由。

  • getRoute(Class<? extends Screen> screenClass, Map<String, String> urlParams) – 生成指定 screenClassurlParams 的界面路由。

URL 路由生成器示例

假设我们有 Customer 实体,带有标准的界面并注册了路由。 我们在浏览界面添加一个按钮用来为选择的实体生成编辑界面的链接:

@Inject
private UrlRouting urlRouting;

@Inject
private GroupTable<Customer> customersTable;

@Inject
private Dialogs dialogs;

@Subscribe("getLinkButton")
public void onGetLinkButtonClick(Button.ClickEvent event) {
    Customer selectedCustomer = customersTable.getSingleSelected();
    if (selectedCustomer != null) {
        String routeToSelectedRole = urlRouting.getRouteGenerator()
                .getEditorRoute(selectedCustomer);

        dialogs.createMessageDialog()
                .withCaption("Generated route")
                .withMessage(routeToSelectedRole)
                .withWidth("710")
                .show();
    }
}

生成的路由结果:

url generate route

3.5.14. 组合组件

组合组件是由其它多个组件组合的组件。跟界面 fragment 类似,组合组件也是一种可重用组件,能复用展示布局和逻辑。下列情况我们建议使用组合组件:

  • 组件功能可以使用现存的通用 UI 组件以组合的方式来实现。如果需要非标准功能,可以封装 Vaadin 组件或者 JavaScript 库来创建自定义组件,或者使用通用 JavaScriptComponent

  • 组件相对比较简单,并不会加载或者保存数据。否则的话,考虑创建界面 fragment

组合组件的类必须继承 CompositeComponent 基类。组合组件必须以一个单一组件为内部组件树的基础 - 称为根组件。根组件可以通过 CompositeComponent.getComposition() 方法获取。

内部组件通常在 XML 描述中通过声明式的方式创建。因此,组件类必须要有 @CompositeDescriptor 注解,用来指定相应描述文件的路径。如果注解值不是以 / 开头的话,会从组件类的包内加载该文件。

需要注意,界面中的内部组件,必须有唯一的 ID,以避免监听器和注入时发生混乱。推荐使用一些带前缀的标识符,如 myCompositeComponent_currency

另外,内部组件树也可以在 CreateEvent 监听器内通过编程的方式创建。

当框架完成组件的初始化之后,会发出 CreateEvent 事件。此时,如果组件使用了 XML 描述,则会进行加载并通过 getComposition() 方法返回根组件。这个事件可以用来添加更多的任何组件初始化,或者用来创建内部组件(不使用XML)。

下面我们示范如何创建 Stepper (步进)组件,并通过点击控件旁边的上下按钮来编辑输入框的整数值。

我们假设项目的包结构以 com/company/demo 为基础。

组件布局描述

web 模块创建带有组件布局的 XML 描述文件 com/company/demo/web/components/stepper/stepper-component.xml

<composite xmlns="http://schemas.haulmont.com/cuba/screen/composite.xsd"> (1)
    <hbox id="rootBox" width="100%" expand="valueField"> (2)
        <textField id="valueField"/> (3)
        <button id="upBtn"
                icon="font-icon:CHEVRON_UP"/>
        <button id="downBtn"
                icon="font-icon:CHEVRON_DOWN"/>
    </hbox>
</composite>
1 - XSD 定义了组件描述的内容
2 - 单一的根组件
3 - 任何数量的内部组件
组件实现类

在同一个包内创建组件的实现类:

package com.company.demo.web.components.stepper;

import com.haulmont.bali.events.Subscription;
import com.haulmont.cuba.gui.components.*;
import com.haulmont.cuba.gui.components.data.ValueSource;
import com.haulmont.cuba.web.gui.components.*;

import java.util.Collection;
import java.util.function.Consumer;

@CompositeDescriptor("stepper-component.xml") (1)
public class StepperField
        extends CompositeComponent<HBoxLayout> (2)
        implements Field<Integer>, (3)
                    CompositeWithCaption, (4)
                    CompositeWithHtmlCaption,
                    CompositeWithHtmlDescription,
                    CompositeWithIcon,
                    CompositeWithContextHelp {

    public static final String NAME = "stepperField"; (5)

    private TextField<Integer> valueField; (6)
    private Button upBtn;
    private Button downBtn;

    private int step = 1; (7)

    public StepperField() {
        addCreateListener(this::onCreate); (8)
    }

    private void onCreate(CreateEvent createEvent) {
        valueField = getInnerComponent("valueField");
        upBtn = getInnerComponent("upBtn");
        downBtn = getInnerComponent("downBtn");

        upBtn.addClickListener(clickEvent -> updateValue(step));
        downBtn.addClickListener(clickEvent -> updateValue(-step));
    }

    private void updateValue(int delta) {
        Integer value = getValue();
        setValue(value != null ? value + delta : delta);
    }

    public int getStep() {
        return step;
    }

    public void setStep(int step) {
        this.step = step;
    }

    @Override
    public boolean isRequired() { (9)
        return valueField.isRequired();
    }

    @Override
    public void setRequired(boolean required) {
        valueField.setRequired(required);
        getComposition().setRequiredIndicatorVisible(required);
    }

    @Override
    public String getRequiredMessage() {
        return valueField.getRequiredMessage();
    }

    @Override
    public void setRequiredMessage(String msg) {
        valueField.setRequiredMessage(msg);
    }

    @Override
    public void addValidator(Consumer<? super Integer> validator) {
        valueField.addValidator(validator);
    }

    @Override
    public void removeValidator(Consumer<Integer> validator) {
        valueField.removeValidator(validator);
    }

    @Override
    public Collection<Consumer<Integer>> getValidators() {
        return valueField.getValidators();
    }

    @Override
    public boolean isEditable() {
        return valueField.isEditable();
    }

    @Override
    public void setEditable(boolean editable) {
        valueField.setEditable(editable);
        upBtn.setEnabled(editable);
        downBtn.setEnabled(editable);
    }

    @Override
    public Integer getValue() {
        return valueField.getValue();
    }

    @Override
    public void setValue(Integer value) {
        valueField.setValue(value);
    }

    @Override
    public Subscription addValueChangeListener(Consumer<ValueChangeEvent<Integer>> listener) {
        return valueField.addValueChangeListener(listener);
    }

    @Override
    public void removeValueChangeListener(Consumer<ValueChangeEvent<Integer>> listener) {
        valueField.removeValueChangeListener(listener);
    }

    @Override
    public boolean isValid() {
        return valueField.isValid();
    }

    @Override
    public void validate() throws ValidationException {
        valueField.validate();
    }

    @Override
    public void setValueSource(ValueSource<Integer> valueSource) {
        valueField.setValueSource(valueSource);
        getComposition().setRequiredIndicatorVisible(valueField.isRequired());
    }

    @Override
    public ValueSource<Integer> getValueSource() {
        return valueField.getValueSource();
    }
}
1 - @CompositeDescriptor 注解指定了组件布局的描述文件路径,这个文件也在同一包内。
2 - 组件类继承了 CompositeComponent,使用根组件的类型作为参数。
3 - 组件实现了 Field<Integer> 接口,因为组件要用来展示和编辑一个整数值。
4 - 一组带有默认方法的借口,实现了标准通用 UI 组件的功能。
5 - 组件名称,用来在 ui-component.xml 文件内注册组件,以便框架识别。
6 - 包含引用内部组件的字段。
7 - 组件的属性,定义单击一次上/下按钮能改变的值。具有公共 getter/setter,并能在界面 XML 中设置。
8 - 组件初始化在 CreateEvent 监听器内完成。
组件加载器

创建组件加载器,当组件在界面 XML 描述中使用的时候需要用加载器进行初始化:

package com.company.demo.web.components.stepper;

import com.google.common.base.Strings;
import com.haulmont.cuba.gui.xml.layout.loaders.AbstractFieldLoader;

public class StepperFieldLoader extends AbstractFieldLoader<StepperField> { (1)

    @Override
    public void createComponent() {
        resultComponent = factory.create(StepperField.NAME); (2)
        loadId(resultComponent, element);
    }

    @Override
    public void loadComponent() {
        super.loadComponent();
        String incrementStr = element.attributeValue("step"); (3)
        if (!Strings.isNullOrEmpty(incrementStr)) {
            resultComponent.setStep(Integer.parseInt(incrementStr));
        }
    }
}
1 - 加载器累必须使用组件的类作为参数继承 AbstractComponentLoader。由于我们的组件实现了 Field,所以可以用更具体的 AbstractFieldLoader 作为基类。
2 - 使用组件名称创建组件。
3 - 如果在 XML 中设置了 step 属性,则进行加载。
注册组件

为了在框架中注册组件及其加载器,在 web 模块创建 com/company/demo/ui-component.xml 文件:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<components xmlns="http://schemas.haulmont.com/cuba/components.xsd">
    <component>
        <name>stepperField</name>
        <componentLoader>com.company.demo.web.components.stepper.StepperFieldLoader</componentLoader>
        <class>com.company.demo.web.components.stepper.StepperField</class>
    </component>
</components>

com/company/demo/web-app.properties 中添加下列属性:

cuba.web.componentsConfig = +com/company/demo/ui-component.xml

现在框架能识别应用程序界面 XML 中包含的新组件了。

组件 XSD

如果需要在界面 XML 描述中使用组件,则 XSD 是必须的。在 web 模块的 com/company/demo/ui-component.xsd 文件定义:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xs:schema xmlns="http://schemas.company.com/demo/0.1/ui-component.xsd"
           attributeFormDefault="unqualified"
           elementFormDefault="qualified"
           targetNamespace="http://schemas.company.com/demo/0.1/ui-component.xsd"
           xmlns:xs="http://www.w3.org/2001/XMLSchema"
           xmlns:layout="http://schemas.haulmont.com/cuba/screen/layout.xsd">

    <xs:element name="stepperField">
        <xs:complexType>
            <xs:complexContent>
                <xs:extension base="layout:baseFieldComponent"> (1)
                    <xs:attribute name="step" type="xs:integer"/> (2)
                </xs:extension>
            </xs:complexContent>
        </xs:complexType>
    </xs:element>
</xs:schema>
1 - 继承所有基本的字段属性。
2 - 为 step 定义属性。
使用组件

下面的示例展示了如何在界面中使用该组件:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        xmlns:app="http://schemas.company.com/demo/0.1/ui-component.xsd" (1)
        caption="msg://caption"
        messagesPack="com.company.demo.web.components.sample">
    <data>
        <instance id="fooDc" class="com.company.demo.entity.Foo" view="_local">
            <loader/>
        </instance>
    </data>
    <layout>
        <form id="form" dataContainer="fooDc">
            <column width="250px">
                <textField id="nameField" property="name"/>
                <app:stepperField id="ageField" property="limit" step="10"/> (2)
            </column>
        </form>
    </layout>
</window>
1 - 命名空间引用了组件的 XSD。
2 - 组合组件连接到实体的 limit 属性。
自定义样式

现在我们使用一些自定义的样式让组件变得更好看一些。

首先,将根组件改为 CssLayout 并为内部组件分配样式名。除了项目中定义的自定义样式(见下面)外,下面这些预定义的样式也会使用: v-component-groupicon-only

<composite xmlns="http://schemas.haulmont.com/cuba/screen/composite.xsd">
    <cssLayout id="rootBox" width="100%" stylename="v-component-group stepper-field">
        <textField id="valueField"/>
        <button id="upBtn"
                icon="font-icon:CHEVRON_UP"
                stylename="stepper-btn icon-only"/>
        <button id="downBtn"
                icon="font-icon:CHEVRON_DOWN"
                stylename="stepper-btn icon-only"/>
    </cssLayout>
</composite>

相应的调整一下组件的类:

@CompositeDescriptor("stepper-component.xml")
public class StepperField
        extends CompositeComponent<CssLayout>
        implements ...

生成主题扩展(参阅 这里 了解如何在 Studio 中操作)并在 modules/web/themes/hover/com.company.demo/hover-ext.scss 文件添加如下代码:

@mixin com_company_demo-hover-ext {

  .stepper-field {
    display: flex;

    .stepper-btn {
      width: $v-unit-size;
      min-width: $v-unit-size;
    }
  }
}

重启应用程序服务并打开界面。带有我们组合步进组件的表单如下:

stepper final

3.5.15. 插件工厂

插件工厂机制扩展了标准组件的创建过程,允许在FormTableDataGrid 中创建不同的编辑字段。这意味着应用程序组件或应用程序项目本身可以提供自定义策略,以创建非标准的组件或支持自定义数据类型。

该机制的入口点是 UiComponentsGenerator.generate(ComponentGenerationContext) 方法。其工作原理如下:

  • 尝试查找 ComponentGenerationStrategy 实现。如果找到至少一种策略,那么:

    • 根据 org.springframework.core.Ordered 接口遍历策略。

    • 返回第一个创建的非 null 组件。

ComponentGenerationStrategy 实现用于创建 UI 组件。项目可以包含任意数量的此类策略。

ComponentGenerationContext 是一个类,该类存储创建组件时可以使用的以下信息:

  • metaClass - 定义为其创建组件的实体。

  • property - 定义为其创建组件的实体属性。

  • datasource - 数据源。

  • optionsDatasource - 可用于显示选项列表的数据源。

  • valueSource - 可用于创建组件的值来源。

  • options - 可用于显示选项的选项对象。

  • xmlDescriptor - 在组件以声明的方式在 XML 描述中定义时,包含附加信息的 XML 描述。

  • componentClass - 要创建的组件的类型。例如,FormTableDataGrid

有两种内置组件策略:

  • DefaultComponentGenerationStrategy - 用于根据给定的 ComponentGenerationContext 对象创建组件。顺序值为 ComponentGenerationStrategy.LOWEST_PLATFORM_PRECEDENCE (1000)。

  • DataGridEditorComponentGenerationStrategy - 用于根据给定的 ComponentGenerationContext 对象为数据网格编辑器创建组件。顺序值为 ComponentGenerationStrategy.HIGHEST_PLATFORM_PRECEDENCE + 30 (130)。

以下示例展示如何替换为特定实体的某个属性 Form 组件的默认生成过程。

import com.company.sales.entity.Order;
import com.haulmont.chile.core.model.MetaClass;
import com.haulmont.cuba.core.global.Metadata;
import com.haulmont.cuba.gui.UiComponents;
import com.haulmont.cuba.gui.components.*;
import com.haulmont.cuba.gui.components.data.ValueSource;
import org.springframework.core.Ordered;

import javax.annotation.Nullable;
import javax.inject.Inject;
import java.sql.Date;

@org.springframework.stereotype.Component(SalesComponentGenerationStrategy.NAME)
public class SalesComponentGenerationStrategy implements ComponentGenerationStrategy, Ordered {

    public static final String NAME = "sales_SalesComponentGenerationStrategy";

    @Inject
    private UiComponents uiComponents;

    @Inject
    private Metadata metadata;

    @Nullable
    @Override
    public Component createComponent(ComponentGenerationContext context) {
        String property = context.getProperty();
        MetaClass orderMetaClass = metadata.getClassNN(Order.class);

        // Check the specific field of the Order entity
        // and that the component is created for the Form component
        if (orderMetaClass.equals(context.getMetaClass())
                && "date".equals(property)
                && context.getComponentClass() != null
                && Form.class.isAssignableFrom(context.getComponentClass())) {
            DatePicker<Date> datePicker = uiComponents.create(DatePicker.TYPE_DATE);

            ValueSource valueSource = context.getValueSource();
            if (valueSource != null) {
                //noinspection unchecked
                datePicker.setValueSource(valueSource);
            }

            return datePicker;
        }

        return null;
    }

    @Override
    public int getOrder() {
        return 50;
    }
}

以下示例展示如何为特定的 datatype 定义 ComponentGenerationStrategy

import com.company.colordatatype.datatypes.ColorDatatype;
import com.haulmont.chile.core.datatypes.Datatype;
import com.haulmont.chile.core.model.MetaClass;
import com.haulmont.chile.core.model.MetaPropertyPath;
import com.haulmont.chile.core.model.Range;
import com.haulmont.cuba.core.app.dynamicattributes.DynamicAttributesUtils;
import com.haulmont.cuba.gui.UiComponents;
import com.haulmont.cuba.gui.components.ColorPicker;
import com.haulmont.cuba.gui.components.Component;
import com.haulmont.cuba.gui.components.ComponentGenerationContext;
import com.haulmont.cuba.gui.components.ComponentGenerationStrategy;
import com.haulmont.cuba.gui.components.data.ValueSource;
import org.springframework.core.annotation.Order;

import javax.annotation.Nullable;
import javax.inject.Inject;

@Order(100)
@org.springframework.stereotype.Component(ColorComponentGenerationStrategy.NAME)
public class ColorComponentGenerationStrategy implements ComponentGenerationStrategy {

    public static final String NAME = "colordatatype_ColorComponentGenerationStrategy";

    @Inject
    private UiComponents uiComponents;

    @Nullable
    @Override
    public Component createComponent(ComponentGenerationContext context) {
        String property = context.getProperty();
        MetaPropertyPath mpp = resolveMetaPropertyPath(context.getMetaClass(), property);

        if (mpp != null) {
            Range mppRange = mpp.getRange();
            if (mppRange.isDatatype()
                    && ((Datatype) mppRange.asDatatype()) instanceof ColorDatatype) {
                ColorPicker colorPicker = uiComponents.create(ColorPicker.class);
                colorPicker.setDefaultCaptionEnabled(true);

                ValueSource valueSource = context.getValueSource();
                if (valueSource != null) {
                    //noinspection unchecked
                    colorPicker.setValueSource(valueSource);
                }

                return colorPicker;
            }
        }

        return null;
    }

    protected MetaPropertyPath resolveMetaPropertyPath(MetaClass metaClass, String property) {
        MetaPropertyPath mpp = metaClass.getPropertyPath(property);

        if (mpp == null && DynamicAttributesUtils.isDynamicAttribute(property)) {
            mpp = DynamicAttributesUtils.getMetaPropertyPath(metaClass, property);
        }

        return mpp;
    }
}

3.5.16. 使用 Vaadin 组件

要在 Web 客户端直接使用实现了可视化组件库中所述组件接口的 Vaadin 组件,请使用以下 Component 接口方法

  • unwrap() – 获取给定 CUBA 组件的底层 Vaadin 组件。

  • unwrapComposition() - 获取 Vaadin 组件,该组件是给定 CUBA 组件实现中的最外层封装容器。对于简单的组件,例如Button,此方法返回与 unwrap() - com.vaadin.ui.Button 相同的对象。对于复杂的组件,例如Tableunwrap() 将返回相应的对象- com.vaadin.ui.Table,而 unwrapComposition() 将返回 com.vaadin.ui.VerticalLayout,它包含表格(Table)以及与其一起定义的ButtonsPanelRowsCount

这些方法接收要返回的底层组件的类,例如:

com.vaadin.ui.TextField vTextField = textField.unwrap(com.vaadin.ui.TextField.class);

还可以使用 WebComponentsHelper 类的 unwrap()getComposition() 静态方法,将 CUBA 组件传递给它们。

请注意,如果界面位于项目的 gui 模块中,则只能使用 CUBA 组件的通用接口。要使用组件的 unwrap() 方法,应该将整个界面放入 web 模块,或使用控制器友类机制。

3.5.17. 自定义可视化组件

本节概述了在 CUBA 应用程序中创建自定义 web UI 组件的不同方法。使用这些方法的实用教程位于 创建自定义可视化组件 部分。

在使用底层技术创建组件之前,需要首先考虑基于已存在通用界面组件的组合组件

可以使用以下技术创建新组件:

  1. 在 Vaadin 扩展的基础上。

    这是最简单的方法。在应用程序中使用扩展需要执行以下步骤:

    • 添加扩展工件的坐标到 build.gradle.

    • 在项目中创建 web-toolkit 模块。此模块包含一个 GWT widgetset 文件,用于创建可视化组件的客户端部分。

    • 将扩展项 widgetset 包含到项目的 widgetset 中。

    • 如果组件的外观不适合应用程序主题,请创建主题扩展并为新组件定义一些 CSS。

    请参阅 使用第三方 Vaadin 组件 部分中的示例。

  2. 对 JavaScript 库进行包装。

    如果已经拥有可以满足需要的 JavaScript 组件,则建议使用此方法。要使用这种方式,需要执行以下操作:

    • web 模块中创建服务端 Vaadin 组件。服务端组件为服务代码、访问方法、事件监听等定义 API,服务端组件必须继承 AbstractJavaScriptComponent 类。请注意,继承 JavaScript 组件时,不需要带有 widgetset 的 web-toolkit 模块。

    • 创建 JavaScript 连接器。连接器是一个用于初始化 JavaScript 组件并负责 JavaScript 和服务端代码之间的交互的函数。

    • 创建一个状态类。在其公共字段中定义从服务端发送到客户端的数据。此类必须继承 JavaScriptComponentState

    请参阅使用 JavaScript 库章节中的示例。

  3. 发布为 WebJar 。有关详细信息,请参阅后续章节。

  4. 发布为新的 GWT 组件。

    这是创建全新可视化组件的推荐方法。在应用程序中创建和使用 GWT 组件需要执行以下步骤:

    • 创建 web-toolkit 模块。

    • 创建一个客户端 GWT 部件类。

    • 创建一个服务端 Vaadin 组件。

    • 创建一个组件状态类来定义客户端和服务端之间发送的数据。

    • 创建一个连接器类来链接客户端代码与服务端组件。

    • 创建一个 RPC 接口,用于定义从客户端调用的服务器 API。

    请参阅 创建 GWT 组件 章节中的示例。

将新组件集成到平台中有三个级别。

  • 在第一级,新组件可用作本地 Vaadin 组件。应用程序开发人员可以直接在界面控制器中使用此组件:创建新实例并将其添加到 unwrapped 容器中。上述所有创建新组件的方法都支持这个级别的组件集成。

  • 在第二级,将新组件集成到 CUBA 通用 UI 中。在这种情况下,从应用程序开发者的角度来看,它看起来与可视化组件库中的标准组件相同。开发人员可以在界面 XML 描述中定义组件,或者通过控制器中的 UiComponents 创建组件。请参阅 集成 Vaadin 组件到通用 UI 中 章节中的示例。

  • 在第三级,新组件可在 Studio 组件面板上使用,并可在 WYSIWYG 布局编辑器中使用。请参阅 在 CUBA Studio 中支持自定义可视化组件和 Facet 章节中的示例。

3.5.17.1. 使用 WebJars

此方法允许使用打包到 JAR 文件中并在 Maven Central 上部署的各种 JS 库。要在应用程序中使用来自 WebJar 的组件需要执行以下步骤:

  • 添加依赖到 web 模块的 compile 方法:

    compile 'org.webjars.bower:jrcarousel:1.0.0'
  • 创建 web-toolkit 模块。

  • 创建一个客户端 GWT 部件(widget)类并实现用于创建组件的 JSNI 方法。

  • 使用 @WebJarResource 注解创建服务端组件类。

    这个注解只能用于 ClientConnector 继承者(通常是来自 web-toolkit 模块的 UI 组件类)。

    @WebJarResource 注解值(或资源定义) 应使用下面的格式之一:

    1. <webjar_name>:<sub_path>,例如:

      @WebJarResource("pivottable:plugins/c3/c3.min.css")
    2. <webjar_name>/<resource_version>/<webjar_resource>,例如:

      @WebJarResource("jquery-ui/1.12.1/jquery-ui.min.js")

    注解值可以包含一个或多个 WebJar 资源字符串定义,多个资源使用字符串数组表示:

    @WebJarResource({
            "jquery-ui:jquery-ui.min.js",
            "jquery-fileupload:jquery-fileupload.min.js",
            "jquery-fileupload:jquery-fileupload.min.js"
    })
    public class CubaFileUpload extends CubaAbstractUploadComponent {
        ...
    }

    WebJar 版本不是必须的,因为 Maven 版本解析策略将自动使用高版本的 WebJar。

    或者,可以在 VAADIN/webjars/ 中指定一个目录来提供静态资源。这样,可以通过在此目录中放入新版本的资源来覆盖 WebJar 资源。要设置路径,请使用 @WebJarResource 注解的 overridePath 属性,例如:

    @WebJarResource(value = "pivottable:plugins/c3/c3.min.css", overridePath = "pivottable")
  • 将新组件添加到界面。

3.5.17.2. 通用 JavaScriptComponent

JavaScriptComponent 是个简单的 UI 组件,通过它可以使用任何纯 JavaScript 组件,并且这个 JavaSctipt 组件不需要对应的 Vaadin 实现。因此,通过这个组件可以很容易地在基于 CUBA 的项目中集成任何纯 JavaScript 组件。

该组件可以在界面的 XML 描述中以声明的方式定义,因此可以在 XML 中配置动态属性和 JavaScript 依赖。

该组件的 XML 名称: jsComponent

定义依赖

可以为该组件定义一个依赖列表(JavaScript、CSS)。依赖可以从以下源获取:

  • WebJar 资源 - 以 webjar:// 开头。

  • VAADIN 目录下的文件 - 以 vaadin:// 开头。

  • Web 资源 - 以 http://https:// 开头。

如果依赖的类型不能从扩展中得知,需要在 XML 的 type 属性中指定类型或者传递 DependencyType 枚举值给 addDependency() 方法。

在 XML 中定义依赖的示例:

<jsComponent ...>
    <dependencies>
        <dependency path="webjar://leaflet.js"/>
        <dependency path="http://code.jquery.com/jquery-3.4.1.min.js"/>
        <dependency path="http://api.map.baidu.com/getscript?v=2.0"
                    type="JAVASCRIPT"/>
    </dependencies>
</jsComponent>

以编程方式添加依赖的示例:

jsComponent.addDependencies(
        "webjar://leaflet.js",
        "http://code.jquery.com/jquery-3.4.1.min.js"
);
jsComponent.addDependency(
        "http://api.map.baidu.com/getscript?v=2.0", DependencyType.JAVASCRIPT
);
定义初始化函数

该组件需要一个初始化函数。此函数的名称用来查找 JavaScript 组件连接器(connector)的入口(见下例)。

初始化函数的名称在一个 WEB 浏览器窗口内必须唯一。

函数名称可以通过 setInitFunctionName() 方法传递给组件:

jsComponent.setInitFunctionName("com_company_demo_web_screens_Sandbox");
定义 JavaScript 连接器(connector)

要使用 JavaScriptComponent 来包装 JavaScript 库,需要定义 JavaScript 连接器,其功能主要是初始化 JavaScript 组件并且处理服务端和 JavaScript 代码之间的通信。

连接器函数中可以使用下面的方法:

  • this.getElement() 返回组件的 HTML DOM 元素。

  • this.getState() 返回与服务端同步的带有当前状态的共享状态对象。

组件功能

JavaScriptComponent 组件有下列功能:

  • 设置一个状态对象,该对象可以在客户端层的 JavaScript 连接器中使用,并且可以通过组件状态的 data 字段访问,示例:

    MyState state = new MyState();
    state.minValue = 0;
    state.maxValue = 100;
    jsComponent.setState(state);
  • 注册一个函数,该函数可以在 JavaScript 中使用提供的名称进行调用,示例:

    jsComponent.addFunction("valueChanged", callbackEvent -> {
        JsonArray arguments = callbackEvent.getArguments();
    
        notifications.create()
                .withCaption(StringUtils.join(arguments, ", "))
                .show();
    });
    this.valueChanged(values);
  • 调用命名的函数,该函数由连接器的 JavaScript 代码添加到包装的对象中。

    jsComponent.callFunction("showNotification ");
    this.showNotification = function () {
            alert("TEST");
    };
JavaScriptComponent 使用示例

本节介绍如何在基于 CUBA 的应用中集成第三方 JavaScript 库,使用 https://quilljs.com/ 的 Quill 富文本编辑器作为例子。请按照下面的步骤集成。

  1. web 模块添加以下依赖:

    compile('org.webjars.npm:quill:1.3.6')
  2. 在 web 模块的 web/VAADIN/quill 目录内创建 quill-connector.js 文件。

  3. 在此文件内,添加连接器的实现:

    com_company_demo_web_screens_Sandbox = function () {
        var connector = this;
        var element = connector.getElement();
        element.innerHTML = "<div id=\"editor\">" +
            "<p>Hello World!</p>" +
            "<p>Some initial <strong>bold</strong> text</p>" +
            "<p><br></p>" +
            "</div>";
    
        connector.onStateChange = function () {
            var state = connector.getState();
            var data = state.data;
    
            var quill = new Quill('#editor', data.options);
    
            // Subscribe on textChange event
            quill.on('text-change', function (delta, oldDelta, source) {
                if (source === 'user') {
                    connector.valueChanged(quill.getText(), quill.getContents());
                }
            });
        }
    };
  4. 创建一个界面,包含以下 jsComponent 定义:

    <jsComponent id="quill"
                 initFunctionName="com_company_demo_web_screens_Sandbox"
                 height="200px"
                 width="400">
        <dependencies>
            <dependency path="webjar://quill:dist/quill.js"/>
            <dependency path="webjar://quill:dist/quill.snow.css"/>
            <dependency path="vaadin://quill/quill-connector.js"/>
        </dependencies>
    </jsComponent>
  5. 添加下面的界面控制器实现:

    @UiController("demo_Sandbox")
    @UiDescriptor("sandbox.xml")
    public class Sandbox extends Screen {
        @Inject
        private JavaScriptComponent quill;
    
        @Inject
        private Notifications notifications;
    
        @Subscribe
        protected void onInit(InitEvent event) {
            QuillState state = new QuillState();
            state.options = ParamsMap.of("theme", "snow",
                    "placeholder", "Compose an epic...");
    
            quill.setState(state);
    
            quill.addFunction("valueChanged", javaScriptCallbackEvent -> {
                String value = javaScriptCallbackEvent.getArguments().getString(0);
                notifications.create()
                        .withCaption(value)
                        .withPosition(Notifications.Position.BOTTOM_RIGHT)
                        .show();
            });
        }
    
        class QuillState {
            public Map<String, Object> options;
        }
    }

执行结果,界面中可以看到 Quill 富文本编辑器:

jsComponent example

另一个集成自定义 JavaScript 组件的例子可以参阅 使用 JavaScript 库

3.5.17.3. ScreenDependencyUtils

为当前界面或者界面片段添加诸如 CSS、JavaScript 或者 HTML 依赖的简单方法就是使用 ScreenDependencyUtils 帮助类。可以从下列源中获取依赖:

  • WebJar 资源 - 以 webjar:// 开头

  • 放置在 VAADIN 文件夹的文件 - 以 vaadin:// 开头

  • Web 资源 - 以 http://https:// 开头

该帮助类有以下方法可以用来添加或者获取依赖:

  • setScreenDependencies - 设置依赖列表

  • addScreenDependencies - 添加多个依赖的路径

  • addScreenDependency - 添加单个依赖路径

  • List<ClientDependency> getScreenDependencies - 返回已经添加的依赖列表。

下面的例子中,为登录界面添加了一个 CSS 文件:

protected void loadStyles() {
    ScreenDependencyUtils.addScreenDependency(this,
                  "vaadin://brand-login-screen/login.css", Dependency.Type.STYLESHEET);
}

结果会在界面的 header 中添加下列 import:

<link rel="stylesheet" type="text/css" href="http://localhost:8080/app/VAADIN/brand-login-screen/login.css">

添加的 CSS 文件只作用在登录界面:

branding login screen
3.5.17.4. 创建自定义可视化组件

自定义可视化组件部分所述,可以在项目中扩展标准的可视化组件集。有以下几种方式:

  1. 集成 Vaadin 扩展。许多第三方 Vaadin 组件作为扩展发布,可从 https://vaadin.com/directory 获取。

  2. 集成 JavaScript 组件。可以使用 JavaScript 库创建 Vaadin 组件。

  3. 使用 GWT 编写组件的客户端部分来创建新的 Vaadin 组件。

此外,可以将生成的 Vaadin 组件集成到 CUBA 通用 UI 中,以便能够在界面 XML 描述中以声明的方式使用它并绑定到数据容器。

集成的最后一步是在 Studio WYSIWYG 布局编辑器中支持新组件。

本节提供了使用上述所有方法创建新可视化组件的示例。集成通用 UI 和提供 Studio 中的支持对于所有方法都是相同的,因此这方面的内容仅在基于 Vaadin 扩展创建新组件的章节进行了描述。

3.5.17.4.1. 使用第三方 Vaadin 组件

这是在应用程序项目中使用 http://vaadin.com/addon/stepper 中提供的 Stepper 组件的示例。该组件允许使用键盘、鼠标滚动或组件右侧的上/下按钮逐步更改文本框的值。

在 CUBA Studio 中创建一个新项目,并将其命名为 addon-demo

只能在具有 web-toolkit 模块的应用程序项目中集成 Vaadin 扩展。使用 CUBA Studio 可以很方便的创建这个模块:在主菜单,点击 CUBA > Advanced > Manage modules > Create 'web-toolkit' Module

然后添加 vaadin 扩展需要的依赖:

  1. build.gradle中,在 web 模块配置中添加包含组件的扩展包的依赖:

    configure(webModule) {
        ...
        dependencies {
            ...
            compile("org.vaadin.addons:stepper:2.4.0")
        }
  2. web-toolkit 模块的 AppWidgetSet.gwt.xml 文件中,说明项目的部件继承自扩展的部件:

    <module>
        <inherits name="com.haulmont.cuba.web.widgets.WidgetSet" />
    
        <inherits name="org.vaadin.risto.stepper.StepperWidgetset" />
    
        <set-property name="user.agent" value="safari" />
    </module>

    可以通过定义 user.agent 属性来加速部件编译。在此示例中,部件仅针对基于 WebKit 的浏览器(Chrome、Safari 等)进行编译。

现在,来自 Vaadin 扩展的组件被包含到项目中。我们看看如何在项目界面中使用它。

  • 创建包含下面两个字段的新实体 Customer

    • String 类型的 name

    • Integer 类型的 score

  • 为新实体生成标准界面。确保 Module 字段设置为 Module: 'app-web_main'(这个字段只有在项目添加了 gui 模块之后才会显示)。直接使用 Vaadin 组件的界面必须放在 web 模块中。

    实际上,界面也可以放在 gui 模块中,但是这就需要使用 Vaadin 组件的代码移动到单独的companion

  • 接下来,我们将 stepper 组件添加到界面上。

    customer-edit.xml 界面的 form 组件的 score 字段替换成一个 hBox,这个 hBox 将用来作为 Vaadin 组件的容器。

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
            caption="msg://editorCaption"
            focusComponent="form"
            messagesPack="com.company.demo.web.customer">
        <data>
            <instance id="customerDc"
                      class="com.company.demo.entity.Customer"
                      view="_local">
                <loader/>
            </instance>
        </data>
        <dialogMode height="600"
                    width="800"/>
        <layout expand="editActions" spacing="true">
            <form id="form" dataContainer="customerDc">
                <column width="250px">
                    <textField id="nameField" property="name"/>
                    <!-- A box that will be used as a container for a Vaadin component -->
                    <hbox id="scoreBox"
                          caption="msg://com.company.demo.entity/Customer.score"
                          height="100%"
                          width="100%"/>
                </column>
            </form>
            <hbox id="editActions" spacing="true">
                <button action="windowCommitAndClose"/>
                <button action="windowClose"/>
            </hbox>
        </layout>
    </window>

    将以下代码添加到 CustomerEdit.java 控制器:

    package com.company.demo.web.customer;
    
    import com.company.demo.entity.Customer;
    import com.haulmont.cuba.gui.components.HBoxLayout;
    import com.haulmont.cuba.gui.screen.*;
    import com.vaadin.ui.Layout;
    import org.vaadin.risto.stepper.IntStepper;
    
    import javax.inject.Inject;
    
    @UiController("demo_Customer.edit")
    @UiDescriptor("customer-edit.xml")
    @EditedEntityContainer("customerDc")
    @LoadDataBeforeShow
    public class CustomerEdit extends StandardEditor<Customer> {
        @Inject
        private HBoxLayout scoreBox;
    
        private IntStepper stepper = new IntStepper();
    
        @Subscribe
        protected void onInit(InitEvent event) {
            scoreBox.unwrap(Layout.class)
                    .addComponent(stepper);
    
            stepper.setSizeFull();
            stepper.addValueChangeListener(valueChangeEvent ->
                    getEditedEntity().setScore(valueChangeEvent.getValue()));
        }
    
        @Subscribe
        protected void onInitEntity(InitEntityEvent<Customer> event) {
            event.getEntity().setScore(0);
        }
    
        @Subscribe
        protected void onBeforeShow(BeforeShowEvent event) {
            stepper.setValue(getEditedEntity().getScore());
        }
    }

    onInit() 方法会初始化一个 stepper 组件的实例,可以用 unwrap 方法取到 Vaadin 容器的链接,然后将新组件添加进去。

    数据绑定是通过编程的方式在 onBeforeShow() 方法中为 Customer 实例的 stepper 组件设置当前值来实现的。此外,对应的实体属性是在用户改变值时,通过值变化的监听器来更新的。

  • 要调整组件样式,请在项目中创建主题扩展。使用 CUBA Studio 可以很方便扩展主题,点击 CUBA > Advanced > Manage themes > Create theme extension。在弹出窗口选择 hover 主题。另一个方式时使用 CUBA CLIextend-theme 命令。之后,打开位于 web 模块中的 themes/hover/com.company.demo/hover-ext.scss 文件并添加以下代码:

    /* Define your theme modifications inside next mixin */
    @mixin com_company_demo-hover-ext {
      /* Basic styles for stepper inner text box */
      .stepper input[type="text"] {
        @include box-defaults;
        @include valo-textfield-style;
    
        &:focus {
          @include valo-textfield-focus-style;
        }
      }
    }
  • 启动应用程序服务。将生成如下所示的编辑界面:

customer edit result
3.5.17.4.2. 集成 Vaadin 组件到通用 UI 中

前一节中,我们在项目中包含了第三方 Stepper 组件。在本节中,我们将它集成到 CUBA 通用 UI 中。这样就允许开发人员在界面 XML 描述中以声明方式使用组件,并通过数据组件将其绑定到数据模型实体。

为了在 CUBA 通用 UI 中集成 Stepper,需要创建以下文件:

  • Stepper - 在 web 模块 gui 子文件夹的该组件接口。

  • WebStepper - 在 web 模块 gui 子文件夹的该组件实现。

  • StepperLoader - 在 web 模块 gui 子文件夹的组件 XML 加载器。

  • ui-component.xsd - 一个新的组件 XML 结构定义。如果这个文件已经存在,在文件中添加关于此新组件的信息。

  • cuba-ui-component.xml - 在 web 模块中注册新组件加载器的文件。如果该文件已存在,在文件中添加关于此新组件的信息。

在 IDE 中打开项目。

创建相应的文件,并添加必要的更改。

  • web 模块的 gui 子文件夹创建 Stepper 接口。用以下代码替换其内容:

    package com.company.demo.web.gui.components;
    
    import com.haulmont.cuba.gui.components.Field;
    
    // note that Stepper should extend Field
    public interface Stepper extends Field<Integer> {
    
        String NAME = "stepper";
    
        boolean isManualInputAllowed();
        void setManualInputAllowed(boolean value);
    
        boolean isMouseWheelEnabled();
        void setMouseWheelEnabled(boolean value);
    
        int getStepAmount();
        void setStepAmount(int amount);
    
        int getMaxValue();
        void setMaxValue(int maxValue);
    
        int getMinValue();
        void setMinValue(int minValue);
    }

    组件的基础接口是 Field,用于显示和编辑实体属性。

  • 创建 WebStepper 类 - web 模块的 gui 子文件夹中的组件实现。用以下代码替换其内容:

    package com.company.demo.web.gui.components;
    
    import com.haulmont.cuba.web.gui.components.WebV8AbstractField;
    import org.vaadin.risto.stepper.IntStepper;
    
    // note that WebStepper should extend WebV8AbstractField
    public class WebStepper extends WebV8AbstractField<IntStepper, Integer, Integer> implements Stepper {
    
        public WebStepper() {
            this.component = createComponent();
    
            attachValueChangeListener(component);
        }
    
        private IntStepper createComponent() {
            return new IntStepper();
        }
    
        @Override
        public boolean isManualInputAllowed() {
            return component.isManualInputAllowed();
        }
    
        @Override
        public void setManualInputAllowed(boolean value) {
            component.setManualInputAllowed(value);
        }
    
        @Override
        public boolean isMouseWheelEnabled() {
            return component.isMouseWheelEnabled();
        }
    
        @Override
        public void setMouseWheelEnabled(boolean value) {
            component.setMouseWheelEnabled(value);
        }
    
        @Override
        public int getStepAmount() {
            return component.getStepAmount();
        }
    
        @Override
        public void setStepAmount(int amount) {
            component.setStepAmount(amount);
        }
    
        @Override
        public int getMaxValue() {
            return component.getMaxValue();
        }
    
        @Override
        public void setMaxValue(int maxValue) {
            component.setMaxValue(maxValue);
        }
    
        @Override
        public int getMinValue() {
            return component.getMinValue();
        }
    
        @Override
        public void setMinValue(int minValue) {
            component.setMinValue(minValue);
        }
    }

    所选择的基类是 WebV8AbstractField,其实现了 Field 接口的方法。

  • web 模块的 gui 子文件夹中的 StepperLoader 类从 XML 描述中加载组件。

    package com.company.demo.web.gui.xml.layout.loaders;
    
    import com.company.demo.web.gui.components.Stepper;
    import com.haulmont.cuba.gui.xml.layout.loaders.AbstractFieldLoader;
    
    public class StepperLoader extends AbstractFieldLoader<Stepper> {
        @Override
        public void createComponent() {
            resultComponent = factory.create(Stepper.class);
            loadId(resultComponent, element);
        }
    
        @Override
        public void loadComponent() {
            super.loadComponent();
    
            String manualInput = element.attributeValue("manualInput");
            if (manualInput != null) {
                resultComponent.setManualInputAllowed(Boolean.parseBoolean(manualInput));
            }
            String mouseWheel = element.attributeValue("mouseWheel");
            if (mouseWheel != null) {
                resultComponent.setMouseWheelEnabled(Boolean.parseBoolean(mouseWheel));
            }
            String stepAmount = element.attributeValue("stepAmount");
            if (stepAmount != null) {
                resultComponent.setStepAmount(Integer.parseInt(stepAmount));
            }
            String maxValue = element.attributeValue("maxValue");
            if (maxValue != null) {
                resultComponent.setMaxValue(Integer.parseInt(maxValue));
            }
            String minValue = element.attributeValue("minValue");
            if (minValue != null) {
                resultComponent.setMinValue(Integer.parseInt(minValue));
            }
        }
    }

    AbstractFieldLoader 类包含用于加载 Field 组件的基本属性的代码。所以 StepperLoader 只加载特定于 Stepper 组件的属性。

  • web 模块中的 cuba-ui-component.xml 文件注册新组件及其加载器。用以下代码替换其内容:

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <components xmlns="http://schemas.haulmont.com/cuba/components.xsd">
        <component>
            <name>stepper</name>
            <componentLoader>com.company.demo.web.gui.xml.layout.loaders.StepperLoader</componentLoader>
            <class>com.company.demo.web.gui.components.WebStepper</class>
        </component>
    </components>
  • web 模块中的 ui-component.xsd 文件包含自定义可视化组件的 XSD。添加 stepper 元素及其属性定义。

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <xs:schema xmlns="http://schemas.company.com/agd/0.1/ui-component.xsd"
               elementFormDefault="qualified"
               targetNamespace="http://schemas.company.com/agd/0.1/ui-component.xsd"
               xmlns:xs="http://www.w3.org/2001/XMLSchema">
        <xs:element name="stepper">
            <xs:complexType>
                <xs:attribute name="id" type="xs:string"/>
    
                <xs:attribute name="caption" type="xs:string"/>
                <xs:attribute name="height" type="xs:string"/>
                <xs:attribute name="width" type="xs:string"/>
    
                <xs:attribute name="dataContainer" type="xs:string"/>
                <xs:attribute name="property" type="xs:string"/>
    
                <xs:attribute name="manualInput" type="xs:boolean"/>
                <xs:attribute name="mouseWheel" type="xs:boolean"/>
                <xs:attribute name="stepAmount" type="xs:int"/>
                <xs:attribute name="maxValue" type="xs:int"/>
                <xs:attribute name="minValue" type="xs:int"/>
            </xs:complexType>
        </xs:element>
    </xs:schema>

我们来看一下如何将新组件添加到界面。

  • 可以删除前一章节的改动或者为实体生成编辑界面。

  • stepper 组件添加到编辑界面。可以使用声明式的方式或者编程的方式进行添加。我们分别看看这两种方法。

    1. 在 XML 描述中声明式的使用该组件。

      • 打开 customer-edit.xml 文件。

      • 定义新的命名空间 xmlns:app="http://schemas.company.com/agd/0.1/ui-component.xsd"

      • form 中删除 score 字段。

      • stepper 组件添加到界面上。

      这时,界面 XML 描述应如下所示:

      <?xml version="1.0" encoding="UTF-8" standalone="no"?>
      <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
              xmlns:app="http://schemas.company.com/agd/0.1/ui-component.xsd"
              caption="msg://editorCaption"
              focusComponent="form"
              messagesPack="com.company.demo.web.customer">
          <data>
              <instance id="customerDc"
                        class="com.company.demo.entity.Customer"
                        view="_local">
                  <loader/>
              </instance>
          </data>
          <dialogMode height="600"
                      width="800"/>
          <layout expand="editActions" spacing="true">
              <form id="form" dataContainer="customerDc">
                  <column width="250px">
                      <textField id="nameField" property="name"/>
                      <app:stepper id="stepper"
                                   dataContainer="customerDc" property="score"
                                   minValue="0" maxValue="20"/>
                  </column>
              </form>
              <hbox id="editActions" spacing="true">
                  <button action="windowCommitAndClose"/>
                  <button action="windowClose"/>
              </hbox>
          </layout>
      </window>

      在上面的例子中,stepper 组件与 Customer 实体的 score 属性相关联。该实体的实例由 customerDc 实例容器管理。

    2. 在 Java 控制器中以编程的方式创建组件。

      <?xml version="1.0" encoding="UTF-8" standalone="no"?>
      <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
              caption="msg://editorCaption"
              focusComponent="form"
              messagesPack="com.company.demo.web.customer">
          <data>
              <instance id="customerDc"
                        class="com.company.demo.entity.Customer"
                        view="_local">
                  <loader/>
              </instance>
          </data>
          <dialogMode height="600"
                      width="800"/>
          <layout expand="editActions" spacing="true">
              <form id="form" dataContainer="customerDc">
                  <column width="250px">
                      <textField id="nameField" property="name"/>
                  </column>
              </form>
              <hbox id="editActions" spacing="true">
                  <button action="windowCommitAndClose"/>
                  <button action="windowClose"/>
              </hbox>
          </layout>
      </window>
      package com.company.demo.web.customer;
      
      import com.company.demo.entity.Customer;
      import com.company.demo.web.gui.components.Stepper;
      import com.haulmont.cuba.gui.UiComponents;
      import com.haulmont.cuba.gui.components.Form;
      import com.haulmont.cuba.gui.components.data.value.ContainerValueSource;
      import com.haulmont.cuba.gui.model.InstanceContainer;
      import com.haulmont.cuba.gui.screen.*;
      
      import javax.inject.Inject;
      
      @UiController("demo_Customer.edit")
      @UiDescriptor("customer-edit.xml")
      @EditedEntityContainer("customerDc")
      @LoadDataBeforeShow
      public class CustomerEdit extends StandardEditor<Customer> {
          @Inject
          private Form form;
          @Inject
          private InstanceContainer<Customer> customerDc;
          @Inject
          private UiComponents uiComponents;
      
          @Subscribe
          protected void onInit(InitEvent event) {
              Stepper stepper = uiComponents.create(Stepper.NAME);
              stepper.setValueSource(new ContainerValueSource<>(customerDc, "score"));
              stepper.setCaption("Score");
              stepper.setWidthFull();
              stepper.setMinValue(0);
              stepper.setMaxValue(20);
      
              form.add(stepper);
          }
      
          @Subscribe
          protected void onInitEntity(InitEntityEvent<Customer> event) {
              event.getEntity().setScore(0);
          }
      }
  • 启动应用程序服务。将生成如下所示的编辑界面:

customer edit result
3.5.17.4.3. 使用 JavaScript 库

在此示例中,我们将使用 jQuery UI 库中的 Slider 组件。拥有两个拖拽手柄的滑动条,用于定义取值范围。

CUBA 平台已经有一个 Slider 组件,可以设置预定义范围内 jQuery UI 库中的 Slider 组件。的一个数值。但是也许您需要一个类似的组件,但是要有两个能拖拽的手柄,用来定义值域范围。所以在这个例子中,我们使用 jQuery UI 库中的 Slider 组件。

在 CUBA Studio 中创建一个新项目,并将其命名为 jscomponent

为了使用 Slider 组件,需要创建以下文件:

  • SliderServerComponent - 与 JavaScript 集成的 Vaadin 组件。

  • SliderState - Vaadin 组件的状态类。

  • slider-connector.js - Vaadin 组件的 JavaScript 连接器。

集成至通用 UI 的过程跟集成 Vaadin 组件到通用 UI 中描述的一致,这里就不再重复了。

下面在 web 模块的 toolkit/ui/slider 子目录创建需要的文件,并做相应修改。

  • SlideState 状态类定义在服务器和客户端之间传输的数据。在这个例子中,它是最小值、最大值和选定值。

    package com.company.jscomponent.web.toolkit.ui.slider;
    
    import com.vaadin.shared.ui.JavaScriptComponentState;
    
    public class SliderState extends JavaScriptComponentState {
        public double[] values;
        public double minValue;
        public double maxValue;
    }
  • Vaadin 服务端组件 SliderServerComponent

    package com.company.jscomponent.web.toolkit.ui.slider;
    
    import com.haulmont.cuba.web.widgets.WebJarResource;
    import com.vaadin.annotations.JavaScript;
    import com.vaadin.ui.AbstractJavaScriptComponent;
    import elemental.json.JsonArray;
    
    @WebJarResource({"jquery:jquery.min.js", "jquery-ui:jquery-ui.min.js", "jquery-ui:jquery-ui.css"})
    @JavaScript({"slider-connector.js"})
    public class SliderServerComponent extends AbstractJavaScriptComponent {
    
        public interface ValueChangeListener {
            void valueChanged(double[] newValue);
        }
    
        private ValueChangeListener listener;
    
        public SliderServerComponent() {
            addFunction("valueChanged", arguments -> {
                JsonArray array = arguments.getArray(0);
                double[] values = new double[2];
                values[0] = array.getNumber(0);
                values[1] = array.getNumber(1);
    
                getState(false).values = values;
    
                listener.valueChanged(values);
            });
        }
    
        public void setValue(double[] value) {
            getState().values = value;
        }
    
        public double[] getValue() {
            return getState().values;
        }
    
        public double getMinValue() {
            return getState().minValue;
        }
    
        public void setMinValue(double minValue) {
            getState().minValue = minValue;
        }
    
        public double getMaxValue() {
            return getState().maxValue;
        }
    
        public void setMaxValue(double maxValue) {
            getState().maxValue = maxValue;
        }
    
        @Override
        protected SliderState getState() {
            return (SliderState) super.getState();
        }
    
        @Override
        public SliderState getState(boolean markAsDirty) {
            return (SliderState) super.getState(markAsDirty);
        }
    
        public ValueChangeListener getListener() {
            return listener;
        }
    
        public void setListener(ValueChangeListener listener) {
            this.listener = listener;
        }
    }

    服务端组件定义了 getter 方法和 setter 方法来处理滑块状态,也定义了一个值更改监听器接口。该类继承自 AbstractJavaScriptComponent

    在类构造器中的调用 addFunction() 方法为客户端的 valueChanged() 方法的 RPC 调用定义了一个处理程序。

    @JavaScript@StyleSheet 注解指向的文件,必须在网页上加载。在这个的示例中,这些是 jquery-ui 库的 JavaScript 文件、位于WebJar 资源的 jquery-ui 的样式表以及 Vaadin 服务组件 Java 包里面的连接器。

  • JavaScript 连接器 slider-connector.js.

    com_company_jscomponent_web_toolkit_ui_slider_SliderServerComponent = function() {
        var connector = this;
        var element = connector.getElement();
        $(element).html("<div/>");
        $(element).css("padding", "5px 0px");
    
        var slider = $("div", element).slider({
            range: true,
            slide: function(event, ui) {
                connector.valueChanged(ui.values);
            }
        });
    
        connector.onStateChange = function() {
            var state = connector.getState();
            slider.slider("values", state.values);
            slider.slider("option", "min", state.minValue);
            slider.slider("option", "max", state.maxValue);
            $(element).width(state.width);
        }
    }

    连接器是一个在加载网页时初始化 JavaScript 组件的函数。该函数名称必须与服务端组件类名对应,其中包名中的点用下划线代替。

    Vaadin 为连接器函数添加了几种有用的方法。this.getElement() 返回组件的 HTML DOM 元素,this.getState() 返回一个状态对象。

    这里的连接器执行以下操作:

    • 初始化 jQuery UI 库的 slider 组件。当滑块的位置发生任何变化时,将调用 slide() 函数,该函数又调用连接器的 valueChanged() 方法。valuedChanged() 是在服务端 SliderServerComponent 类中定义的方法。

    • 定义 onStateChange() 函数。在服务端更改状态对象时调用它。

为了演示组件的工作原理,我们创建有三个属性的 Product 实体:

  • String 类型的 name

  • Double 类型的 minDiscount

  • Double 类型的 maxDiscount

为实体生成标准界面。确保 Module 字段的值为 Module: 'app-web_main'(只有在项目添加了 gui 模块之后才会显示这个字段)。

slider 组件将设置产品的最小和最大折扣值。

打开 product-edit.xml 文件。通过将 editable="false" 属性添加到相应的元素,使 minDiscountmaxDiscount 字段不可编辑。然后添加一个 box,将作为 Vaadin 组件的容器使用。

这时,编辑界面的 XML 描述应如下所示:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="msg://editorCaption"
        focusComponent="form"
        messagesPack="com.company.jscomponent.web.product">
    <data>
        <instance id="productDc"
                  class="com.company.jscomponent.entity.Product"
                  view="_local">
            <loader/>
        </instance>
    </data>
    <dialogMode height="600"
                width="800"/>
    <layout expand="editActions" spacing="true">
        <form id="form" dataContainer="productDc">
            <column width="250px">
                <textField id="nameField" property="name"/>
                <textField id="minDiscountField" property="minDiscount" editable="false"/>
                <textField id="maxDiscountField" property="maxDiscount" editable="false"/>
                <hbox id="sliderBox" width="100%"/>
            </column>
        </form>
        <hbox id="editActions" spacing="true">
            <button action="windowCommitAndClose"/>
            <button action="windowClose"/>
        </hbox>
    </layout>
</window>

打开 ProductEdit.java 文件。用以下代码替换其内容:

package com.company.jscomponent.web.product;

import com.company.jscomponent.entity.Product;
import com.company.jscomponent.web.toolkit.ui.slider.SliderServerComponent;
import com.haulmont.cuba.gui.components.HBoxLayout;
import com.haulmont.cuba.gui.screen.*;
import com.vaadin.ui.Layout;

import javax.inject.Inject;

@UiController("jscomponent_Product.edit")
@UiDescriptor("product-edit.xml")
@EditedEntityContainer("productDc")
@LoadDataBeforeShow
public class ProductEdit extends StandardEditor<Product> {

    @Inject
    private HBoxLayout sliderBox;

    @Subscribe
    protected void onInitEntity(InitEntityEvent<Product> event) {
        event.getEntity().setMinDiscount(15.0);
        event.getEntity().setMaxDiscount(70.0);
    }

    @Subscribe
    protected void onBeforeShow(BeforeShowEvent event) {
        SliderServerComponent slider = new SliderServerComponent();
        slider.setValue(new double[]{
                getEditedEntity().getMinDiscount(),
                getEditedEntity().getMaxDiscount()
        });
        slider.setMinValue(0);
        slider.setMaxValue(100);
        slider.setWidth("250px");
        slider.setListener(newValue -> {
            getEditedEntity().setMinDiscount(newValue[0]);
            getEditedEntity().setMaxDiscount(newValue[1]);
        });

        sliderBox.unwrap(Layout.class).addComponent(slider);
    }
}

onInitEntity() 方法为新产品的折扣设置初始值。

onBeforeShow() 方法初始化 slider 组件,设置 slider 的当前值、最小值和最大值,并定义值更改监听器。当滑块移动时,可编辑实体的相应字段将被设置成新值。

启动应用程序服务并打开产品编辑界面。更改滑块位置时会改变文本框的值。

product edit
3.5.17.4.4. 创建 GWT 组件

在本节中,我们介绍如何创建一个简单的 GWT 组件(由 5 颗星组成的评级字段)及其在应用程序界面中的用法。

rating field component

在 CUBA Studio 中创建一个新项目,并将其命名为 ratingsample

创建 web-toolkit 模块。一个简便的方法就是使用 CUBA Studio:在主菜单,点击 CUBA > Advanced > Manage modules > Create 'web-toolkit' Module

为了创建 GWT 组件,需要创建下列文件:

  • RatingFieldWidget.java - web-toolkit 模块中的 GWT 部件。

  • RatingFieldServerComponent.java - Vaadin 组件类。

  • RatingFieldState.java - 组件状态类。

  • RatingFieldConnector.java - 连接器,用于连接客户端代码和服务器组件。

  • RatingFieldServerRpc.java - 为客户端定义服务器 API 的类。

现在创建需要的文件并对其进行必要的更改。

  • web-toolkit 模块中创建 RatingFieldWidget 类。使用下面代码作为其内容:

    package com.company.ratingsample.web.toolkit.ui.client.ratingfield;
    
    import com.google.gwt.dom.client.DivElement;
    import com.google.gwt.dom.client.SpanElement;
    import com.google.gwt.dom.client.Style.Display;
    import com.google.gwt.user.client.DOM;
    import com.google.gwt.user.client.Event;
    import com.google.gwt.user.client.ui.FocusWidget;
    
    import java.util.ArrayList;
    import java.util.List;
    
    public class RatingFieldWidget extends FocusWidget {
    
        private static final String CLASSNAME = "ratingfield";
    
        // API for handle clicks
        public interface StarClickListener {
            void starClicked(int value);
        }
    
        protected List<SpanElement> stars = new ArrayList<>(5);
        protected StarClickListener listener;
        protected int value = 0;
    
        public RatingFieldWidget() {
            DivElement container = DOM.createDiv().cast();
            container.getStyle().setDisplay(Display.INLINE_BLOCK);
            for (int i = 0; i < 5; i++) {
                SpanElement star = DOM.createSpan().cast();
    
                // add star element to the container
                DOM.insertChild(container, star, i);
                // subscribe on ONCLICK event
                DOM.sinkEvents(star, Event.ONCLICK);
    
                stars.add(star);
            }
            setElement(container);
    
            setStylePrimaryName(CLASSNAME);
        }
    
        // main method for handling events in GWT widgets
        @Override
        public void onBrowserEvent(Event event) {
            super.onBrowserEvent(event);
    
            switch (event.getTypeInt()) {
                // react on ONCLICK event
                case Event.ONCLICK:
                    SpanElement element = event.getEventTarget().cast();
                    // if click was on the star
                    int index = stars.indexOf(element);
                    if (index >= 0) {
                        int value = index + 1;
                        // set internal value
                        setValue(value);
    
                        // notify listeners
                        if (listener != null) {
                            listener.starClicked(value);
                        }
                    }
                    break;
            }
        }
    
        @Override
        public void setStylePrimaryName(String style) {
            super.setStylePrimaryName(style);
    
            for (SpanElement star : stars) {
                star.setClassName(style + "-star");
            }
    
            updateStarsStyle(this.value);
        }
    
        // let application code change the state
        public void setValue(int value) {
            this.value = value;
            updateStarsStyle(value);
        }
    
        // refresh visual representation
        private void updateStarsStyle(int value) {
            for (SpanElement star : stars) {
                star.removeClassName(getStylePrimaryName() + "-star-selected");
            }
    
            for (int i = 0; i < value; i++) {
                stars.get(i).addClassName(getStylePrimaryName() + "-star-selected");
            }
        }
    }

    部件(Widget)是一个客户端类,负责在 Web 浏览器中显示组件并处理浏览器事件。它定义了与服务端配合起来工作的接口。在这个的例子中,这些接口是 setValue() 方法和 StarClickListener 接口。

  • RatingFieldServerComponent 是一个 Vaadin 组件类。它定义了服务端代码 API、访问器方法、事件监听器和数据源连接。开发人员在应用程序代码中使用的是这个类的方法。

    package com.company.ratingsample.web.toolkit.ui;
    
    import com.company.ratingsample.web.toolkit.ui.client.ratingfield.RatingFieldServerRpc;
    import com.company.ratingsample.web.toolkit.ui.client.ratingfield.RatingFieldState;
    import com.vaadin.ui.AbstractField;
    
    // the field will have a value with integer type
    public class RatingFieldServerComponent extends AbstractField<Integer> {
    
        public RatingFieldServerComponent() {
            // register an interface implementation that will be invoked on a request from the client
            registerRpc((RatingFieldServerRpc) value -> setValue(value, true));
        }
    
        @Override
        protected void doSetValue(Integer value) {
            if (value == null) {
                value = 0;
            }
            getState().value = value;
        }
    
        @Override
        public Integer getValue() {
            return getState().value;
        }
    
        // define own state class
        @Override
        protected RatingFieldState getState() {
            return (RatingFieldState) super.getState();
        }
    
        @Override
        protected RatingFieldState getState(boolean markAsDirty) {
            return (RatingFieldState) super.getState(markAsDirty);
        }
    }
  • RatingFieldState 状态类定义客户端和服务器之间发送的数据。它包含在服务端自动序列化并在客户端上反序列化的公共字段。

    package com.company.ratingsample.web.toolkit.ui.client.ratingfield;
    
    import com.vaadin.shared.AbstractFieldState;
    
    public class RatingFieldState extends AbstractFieldState {
        {   // change the main style name of the component
            primaryStyleName = "ratingfield";
        }
        // define a field for the value
        public int value = 0;
    }
  • RatingFieldServerRpc 接口定义了客户端可调用的服务器 API。它的方法可以由 Vaadin 内置的 RPC 机制调用。我们将在此组件中实现此接口。

    package com.company.ratingsample.web.toolkit.ui.client.ratingfield;
    
    import com.vaadin.shared.communication.ServerRpc;
    
    public interface RatingFieldServerRpc extends ServerRpc {
        //method will be invoked in the client code
        void starClicked(int value);
    }
  • web-toolkit 模块中创建 RatingFieldConnector 类,连接器将客户端代码与服务端连接起来。

    package com.company.ratingsample.web.toolkit.ui.client.ratingfield;
    
    import com.company.ratingsample.web.toolkit.ui.RatingFieldServerComponent;
    import com.vaadin.client.communication.StateChangeEvent;
    import com.vaadin.client.ui.AbstractFieldConnector;
    import com.vaadin.shared.ui.Connect;
    
    // link the connector with the server implementation of RatingField
    // extend AbstractField connector
    @Connect(RatingFieldServerComponent.class)
    public class RatingFieldConnector extends AbstractFieldConnector {
    
        // we will use a RatingFieldWidget widget
        @Override
        public RatingFieldWidget getWidget() {
            RatingFieldWidget widget = (RatingFieldWidget) super.getWidget();
    
            if (widget.listener == null) {
                widget.listener = value ->
                        getRpcProxy(RatingFieldServerRpc.class).starClicked(value);
            }
            return widget;
        }
    
        // our state class is RatingFieldState
        @Override
        public RatingFieldState getState() {
            return (RatingFieldState) super.getState();
        }
    
        // react on server state change
        @Override
        public void onStateChanged(StateChangeEvent stateChangeEvent) {
            super.onStateChanged(stateChangeEvent);
    
            // refresh the widget if the value on server has changed
            if (stateChangeEvent.hasPropertyChanged("value")) {
                getWidget().setValue(getState().value);
            }
        }
    }

RatingFieldWidget 类中不定义组件的外观样式,只为关键元素指定样式名称。要定义组件的外观,需要创建样式表文件。简便方法就是使用 CUBA Studio:在主菜单,点击 CUBA > Advanced > Manage themes > Create theme extension。在弹窗中选择 hover 主题。另一个方法是使用 CUBA CLIextend-theme 命令。hover 主题使用了 FontAwesome 的象形符号字体替代了 icons

建议以 SCSS 混入(Mixin)的形式将组件样式放到 components/componentname 目录中的单独文件 componentname.scss 中。在 web 模块的 themes/hover/com.company.ratingsample 目录中创建 components/ratingfield 目录结构。然后在 ratingfield 目录中创建 ratingfield.scss 文件:

gwt theme ext structure
@mixin ratingfield($primary-stylename: ratingfield) {
  .#{$primary-stylename}-star {
    font-family: FontAwesome;
    font-size: $v-font-size--h2;
    padding-right: round($v-unit-size/4);
    cursor: pointer;

    &:after {
          content: '\f006'; // 'fa-star-o'
    }
  }

  .#{$primary-stylename}-star-selected {
    &:after {
          content: '\f005'; // 'fa-star'
    }
  }

  .#{$primary-stylename} .#{$primary-stylename}-star:last-child {
    padding-right: 0;
  }

  .#{$primary-stylename}.v-disabled .#{$primary-stylename}-star {
    cursor: default;
  }
}

将此文件包含在 hover-ext.scss 主题文件中:

@import "components/ratingfield/ratingfield";

@mixin com_company_ratingsample-hover-ext {
  @include ratingfield;
}

为了演示组件的工作原理,我们在 web 模块中创建一个新的界面。

将界面命名为 rating-screen

在 IDE 中打开 rating-screen.xml 文件。Rating 组件需要一个容器,我们在界面 XML 中声明它:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="msg://caption"
        messagesPack="com.company.ratingsample.web.screens.rating">
    <layout expand="container">
        <vbox id="container">
            <!-- we'll add vaadin component here-->
        </vbox>
    </layout>
</window>

打开 RatingScreen.java 界面控制器并添加将组件放置到界面上的代码。

package com.company.ratingsample.web.screens.rating;

import com.company.ratingsample.web.toolkit.ui.RatingFieldServerComponent;
import com.haulmont.cuba.gui.components.VBoxLayout;
import com.haulmont.cuba.gui.screen.Screen;
import com.haulmont.cuba.gui.screen.Subscribe;
import com.haulmont.cuba.gui.screen.UiController;
import com.haulmont.cuba.gui.screen.UiDescriptor;
import com.vaadin.ui.Layout;

import javax.inject.Inject;

@UiController("ratingsample_RatingScreen")
@UiDescriptor("rating-screen.xml")
public class RatingScreen extends Screen {
    @Inject
    private VBoxLayout container;

    @Subscribe
    protected void onInit(InitEvent event) {
        RatingFieldServerComponent field = new RatingFieldServerComponent();
        field.setCaption("Rate this!");
        container.unwrap(Layout.class).addComponent(field);
    }
}

下图显示完成的项目结构

gwt project structure

启动应用程序服务并查看结果。

rating screen result
3.5.17.4.5. 在 CUBA Studio 中支持自定义可视化组件和 Facet

开发者可以使用 CUBA Studio 中的 界面设计器 集成自定义的 UI 组件(或 Facet),这些组件可以是在项目中,也可以是在扩展插件中。通过在组件定义处添加特殊的元数据注解实现。

自定义 UI 组件和 Facet 的支持需要 CUBA Studio 版本 14.0 以及平台版本 7.2.5 以上。

界面设计器中支持 UI 组件有以下功能:

  • 在工具箱面板(Palette panel) 展示组件。

  • 如果从工具箱添加组件,则能生成组件的 XML 脚手架代码。如果在组件的元数据配置了 XSD 命名空间,则可以自定添加至 XML。

  • 支持在界面层级结构面板(Hierarchy panel)展示新增组件的图标。

  • 支持在 Inspector 面板展示和修改组件属性。当组件属性修改时,能自动修改相应的 XML 标签属性。

  • 支持属性值提示。

  • 支持验证属性值。

  • 在界面控制器注入组件。

  • 支持生成组件的事件处理器和代理方法。

  • 支持在布局预览面板展示组件原型。

  • 支持跳转至文档的 web 页,需要开发者提供文档链接。



预先准备

我们知道,界面编辑器的工作是在界面描述中生成 XML 脚手架代码。但是,如果要在运行程序中成功加载自定义的组件或 facet,项目中还需要实现下列代码:

  • 组件或 Facet 接口

  • 对组件来说,要实现组件 loader。对 Facet 来说,要创建 facet provider,provider 是一个实现了 com.haulmont.cuba.gui.xml.FacetProvider 接口的 Spring bean,并且用 facet class 作为参数。

  • 组件及其 loader 在 cuba-ui-component.xml 文件注册。

  • 可选:需要在界面描述中定义描述组件(或facet)结构和限制的 XML shema。

这些步骤在 集成 Vaadin 组件到通用 UI 中 章节介绍。

Stepper 示例项目

使用元数据注解创建自定义 UI 组件的完整示例可以参考 Stepper Vaadin 插件集成。源码在这里:https://github.com/cuba-labs/vaadin-stepper-addon-integration

需要注意其中的部分文件:

  • 元数据注解添加至组件接口:com.company.demo.web.gui.components.Stepper

  • 组件显示在工具箱的图标(stepper.svgstepper_dark.svg)放置于 modules/web/src/com/company/demo/web/gui/components/icons 目录。

  • customer-edit.xml 界面描述在布局中使用了 stepper 组件。

import com.haulmont.cuba.gui.meta.*;

@StudioComponent(category = "Samples",
        unsupportedProperties = {"description", "icon", "responsive"},
        xmlns = "http://schemas.company.com/demo/0.1/ui-component.xsd",
        xmlnsAlias = "app",
        icon = "com/company/demo/web/gui/components/icons/stepper.svg",
        canvasBehaviour = CanvasBehaviour.INPUT_FIELD)
@StudioProperties(properties = {
        @StudioProperty(name = "dataContainer", type = PropertyType.DATACONTAINER_REF),
        @StudioProperty(name = "property", type = PropertyType.PROPERTY_PATH_REF, options = "int"),
}, groups = @PropertiesGroup(
        properties = {"dataContainer", "property"}, constraint = PropertiesConstraint.ALL_OR_NOTHING
))
public interface Stepper extends Field<Integer> {

    @StudioProperty(name = "manualInput", type = PropertyType.BOOLEAN, defaultValue = "true")
    void setManualInputAllowed(boolean value);

    boolean isManualInputAllowed();

// ...

}

如果在界面设计器打开 customer-edit.xml 界面,可以看到组件是如何集成到设计器的各个面板中。

组件工具箱面板包含 Stepper 组件:

palette

组件结构面板将组件与其他组件一起显示在树中:

hierarchy

组件 Inspector 面板展示并提供编辑组件属性的功能:

inspector

最后,布局预览面板以文本控件的形式展示组件:

preview

下面我们了解一下如果要达到这种集成效果,需要在组件接口添加哪些注解和属性。

元数据注解列表

所有 UI 元数据注解和相关类都在 com.haulmont.cuba.gui.meta 包内。下列 UI 元素支持 UI 元数据注解:

全部注解列表如下:

  • @StudioComponent - 表示带有此注解的 UI 组件可以用在界面设计器中。还需要提供一些属性支持界面设计器中其他的面板。带有该注解的接口必须是 com.haulmont.cuba.gui.components.Component 的直接或间接子类。

  • @StudioFacet - 表示带有此注解的接口可以作为 facet 用在界面设计器中。还需要提供一些属性支持界面设计器中其他的面板。带有该注解的接口必须是 com.haulmont.cuba.gui.components.Facet 的直接或间接子类。此 facet 还需要在项目中有一个关联的 FacetProvider bean。

  • @StudioProperty - 表示带有此注解的 setter 方法作为 UI 组件或 facet 的一个属性需要在 Inspector 面板展示。

  • @StudioProperties - 声明 UI 组件或 facet 的附加属性和属性组。可以用来声明与组件属性 setter 无关的其他属性,或者用来重写继承的属性,亦或从语义上验证相关的属性组。

  • @PropertiesGroup - 声明属性分组:表示一组依赖的属性,要么一起使用要么互斥。

  • @StudioElementsGroup - 表示带有此注解的 setter 方法需要在界面设计器中显示为 UI 组件或 facet 的嵌套元素组,比如 columns(多列),actions(多操作)或属性映射。

  • @StudioElement - 表示带有此注解的类或接口,在界面设计器中,需要以 UI 组件或 facet 的一部分出现,比如 column,action 或属性映射。

  • @StudioEmbedded - 用在需要将一些组件参数被抽取到 单独的 POJO 时。

  • @StudioCollection - 为嵌套子元素组声明元数据,这些子元素组需要在界面控制器支持,比如列、操作、字段。

UI 组件定义

com.haulmont.cuba.gui.meta.StudioComponent 标注组件接口,即表明该 UI 组件可用于界面设计器。

@StudioComponent(caption = "GridLayout", xmlElement = "bgrid", category = "Containers")
private interface BGridLayout extends BLayout {
    // ...
}

@StudioComponent 注解有如下属性:

  • caption - 显示在工具箱面板的组件名称。

  • description - 显示在工具箱面板的组件描述,鼠标浮上时,作为 tooltip 展示。

  • category - 工具箱面板中的分类(Containers、Components 等),组件将放到指定的分类中。

  • icon - 用在工具箱和界面结构中的组件图标路径,SVG 或 PNG 格式,路径为以组件模块根路径为起点的相对路径。注意,组件图标可以有两类,分别支持 IDE 的 light 和 dark 主题。Dark 图标的文件名需要在图标文件名后添加 _dark 后缀,比如 stepper.svgstepper_dark.svg 分别对应 light 和 dark 主题。

  • xmlElement - XML 标签的名称,当组件添加到界面时,用该名称添加至 XML 描述。

  • xmlns - XML 命名空间。当组件添加至界面,Studio 会自动在界面描述中添加对命名空间的引入。

  • xmlnsAlias - XML 命名空间别名。比如,如果命名空间别名是 track 且 XML 标签名是 googleTracker,则组件会作为 <track:googleTracker/> 标签添加至界面。

  • defaultProperty - 组件默认属性名称,当在布局选中该组件时,这些默认属性会自动在 Inspector 面板选中。

  • unsupportedProperties - 从组件父接口继承下来的属性,但是本组件并不支持。这些属性会在 Inspector 面板隐藏。

  • canvasBehavior - 定义此 UI 组件如何在布局预览面板展示。支持的选项:

    • COMPONENT - 组件在预览面板显示为一个方框,带有图标。

    • INPUT_FIELD - 组件在预览面板显示为文本输入控件。

    • CONTAINER - 组件预览面板显示为组件容器。

  • canvasIcon - 组件显示在预览面板的图标路径。canvasBehaviour 属性需要是 COMPONENT 值。图标文件需要 SVG 或 PNG 格式。如果没有设置该属性,则使用 icon 属性。

  • canvasIconSize - 显示在预览面板的图标大小。支持:

    • SMALL - 小图标

    • LARGE - 大图标,且组件 id 显示在图标下方。

  • containerType - 容器布局的类型(vertical,horizontal,flow),canvasBehaviour 属性需要是 CONTAINER

  • documentationURL - UI 组件文档的 URL。界面设计器的 CUBA Documentation 操作会用到该属性。如果文档路径是有版本的,则可以用 %VERSION% 作为占位符。平台会使用包含 UI 组件的库的 小版本(比如 1.2) 替换。

Facet 定义

com.haulmont.cuba.gui.meta.StudioFacet 标注 facet 接口,即表明该 facet 可用于界面设计器。

需要在界面设计器支持自定义 facet,还需要在项目实现关联的 FacetProvider。

FacetProvider 是实现了 com.haulmont.cuba.gui.xml.FacetProvider 接口的 Spring bean,用 facet 类作为参数。参考平台的 com.haulmont.cuba.web.gui.facets.ClipboardTriggerFacetProvider 作为示例。

@StudioFacet 注解的属性与上面介绍的 @StudioComponent 类似。

示例:

@StudioFacet(
        xmlElement = "clipboardTrigger",
        category = "Facets",
        icon = "icon/clipboardTrigger.svg",
        documentationURL = "https://doc.cuba-platform.com/manual-%VERSION%/gui_ClipboardTrigger.html"
)
public interface ClipboardTrigger extends Facet {

    @StudioProperty(type = PropertyType.COMPONENT_REF, options = "com.haulmont.cuba.gui.components.TextInputField")
    void setInput(TextInputField<?> input);

    @StudioProperty(type = PropertyType.COMPONENT_REF, options = "com.haulmont.cuba.gui.components.Button")
    void setButton(Button button);

    // ...
}
标准组件属性

组件属性通过使用两个注解声明:

  • @StudioProperty - 表示注解的方法(setter,setXxx)需要在 Inspector 面板作为组件属性显示。

  • @StudioProperties - 用在接口上,定义与 setter 方法不相关的其他组件属性和属性分组。

示例:

@StudioComponent(caption = "RichTextArea")
@StudioProperties(properties = {
        @StudioProperty(name = "css", type = PropertyType.CSS_BLOCK)
})
interface RichTextArea extends Component {
    @StudioProperty(type = PropertyType.CSS_CLASSNAME_LIST)
    void setStylename(String stylename);

    @StudioProperty(type = PropertyType.SIZE, defaultValue = "auto")
    void setWidth(String width);

    @StudioProperty(type = PropertyType.SIZE, defaultValue = "auto")
    void setHeight(String height);

    @StudioProperty(type = PropertyType.LOCALIZED_STRING)
    void setContextHelpText(String contextHelpText);

    @StudioProperty(type = PropertyType.LOCALIZED_STRING)
    void setDescription(String description);

    @StudioProperty
    @Min(-1)
    void setTabIndex(int tabIndex);
}

setter 方法可以用不带任何额外数据的 @StudioProperty 注解。此时:

  • 属性名和显示名称会从 setter 方法名生成。

  • 属性类型从方法的参数类型获取。

@StudioProperty 注解有如下属性:

  • name - 属性名

  • type - 定义该属性中存储的什么内容,比如可以是字符串,实体名或在同一界面中其他组件的引用。支持的属性类型在 下面列出

  • caption - 显示名,展示在 Inspector 面板。

  • description - 附加描述,作为 Inspector 面板中鼠标的 tooltip 展示。

  • category - Inspector 面板中属性的分类。(目前在 Studio 14.0 还未实现此功能)

  • required - 属性是必须的,此时界面设计器不允许用空值声明该属性。

  • defaultValue - 当 XML 中省略此属性时,组件会用该默认值。默认值不会在 XML 代码显示。

  • options - 依赖上下文的组件属性列表:

    • 对于 ENUMERATION 属性类型 - 枚举选项

    • 对于 BEAN_REF 属性类型 - Spring bean 基类列表

    • 对于 COMPONENT_REF 属性类型 - 组件基类列表

    • 对于 PROPERTY_PATH_REF 属性类型 - 实体属性类型列表。对 datatype 属性需要使用已注册的 Datatype 名称;对于关联属性则使用 to_oneto_many

  • xmlAttribute - 目标 XML 属性名称,如果未设置,则与属性名相同。

  • xmlElement - 目标 XML 元素名称。用该属性可以将组件属性映射为主组件 XML 标签的子标签,参阅这里

  • typeParameter - 指定泛型参数的名称,泛型参数由该属性为组件提供。参考 下面 详细介绍。

组件属性类型

支持下列属性类型(com.haulmont.cuba.gui.meta.PropertyType):

  • INTEGERLONGFLOATDOUBLESTRINGBOOLEANCHARACTER - 基本类型。

  • DATE - YYYY-MM-DD 格式的日期类型。

  • DATE_TIME - YYYY-MM-DD hh:mm:ss 格式的时间日期类型。

  • TIME - hh:mm:ss 格式的时间类型。

  • ENUMERATION - 枚举值。枚举选项列表由 options 注解属性提供。

  • COMPONENT_ID - 组件、子组件或操作的 标识符。必须是有效的 Java 标识符。

  • ICON_ID - CUBA 或项目提供的 图标路径或图标 ID

  • SIZE - 大小值,即宽高。

  • LOCALIZED_STRING - 本地化消息,用字符串表示或用 msg://mainMsg:// 前缀的消息键值表示。

  • JPA_QUERY - JPA QL 字符串。

  • ENTITY_NAME - 实体名(通过 javax.persistence.Entity#name 注解属性指定)

  • ENTITY_CLASS - 项目中定义的实体全路径名称。

  • JAVA_CLASS_NAME - Java 类的全路径名称。

  • CSS_CLASSNAME_LIST - 用空格分隔的 CSS 类名

  • CSS_BLOCK - 行内 CSS 属性。

  • BEAN_REF - 项目中定义的 Spring bean ID。允许使用的 Spring bean 基类通过 options 注解属性提供。

  • COMPONENT_REF - 界面中定义的组件 ID。允许使用的组件基类通过 options 注解属性提供。

  • DATASOURCE_REF - 界面中定义的 数据源 ID(legacy API)。

  • COLLECTION_DATASOURCE_REF - 界面定义的 集合数据源 ID(legacy API)。

  • DATALOADER_REF - 界面中定义的 数据加载器 ID。

  • DATACONTAINER_REF - 界面中定义的 数据容器 ID。

  • COLLECTION_DATACONTAINER_REF - 界面中定义的 集合数据容器 ID。

  • PROPERTY_REF - 实体属性名称。允许使用的实体属性类型通过 options 注解属性提供。如需显示该字段提示,此组件属性需要跟另外一个定义了数据容器或数据源的其他组件属性关联,通过 属性组 实现。

  • PROPERTY_PATH_REF - 实体属性名,或者实体关系图的属性路径,比如 user.group.name。允许使用的实体属性类型通过 options 注解属性提供。如需显示该字段提示,此组件属性需要跟另外一个定义了数据容器或数据源的其他组件属性关联,通过 属性组 实现。

  • DATATYPE_ID - Datatype ID,比如 stringdecimal

  • SHORTCUT - 快捷键,比如 CTRL-SHIFT-U

  • SCREEN_CLASS - 项目中定义的 界面控制器 类全路径名。

  • SCREEN_ID - 项目中定义的界面 ID。

  • SCREEN_OPEN_MODE - 界面 打开模式

组件属性验证

Inspector 面板支持有限的一些组件属性验证,使用 BeanValidation 注解:

  • @Min, @Max, @DecimalMin, @DecimalMax.

  • @Negative, @Positive, @PosizitiveOrZero, @NegativeOrZero.

  • @NotBlank, @NotEmpty.

  • @Digits.

  • @Pattern.

  • @Size, @Length.

  • @URL.

示例:

@StudioProperty(type = PropertyType.INTEGER)
@Positive
void setStepAmount(int amount);
int getStepAmount();

如果用户尝试输入无效属性值,会显示如下错误:

bean validation
@StudioProperties 和属性分组

@StudioProperty 定义的元数据可以用组件接口上的 @StudioProperties 注解覆盖。

@StudioProperties 注解在 groups 属性内可以有 0 个或多个 @PropertiesGroup 类型声明。每个分组定义一个属性组,类型通过 @PropertiesGroup#constraint 属性确定:

  • ONE_OF - 分组的特殊属性,表示组内的属性是互斥的。

  • ALL_OR_NOTHING - 一组互相依赖的属性,只能一起使用。

属性分组的一个特别重要的应用场景就是组件中能绑定数据容器的 dataContainerproperty 两个属性。这两个属性必须包含在 ALL_OR_NOTHING 分组中。参考下面包含这种属性分组的示例:

@StudioComponent(
        caption = "RichTextField",
        category = "Fields",
        canvasBehaviour = CanvasBehaviour.INPUT_FIELD)
@StudioProperties(properties = {
        @StudioProperty(name = "dataContainer", type = PropertyType.DATACONTAINER_REF),
        @StudioProperty(name = "property", type = PropertyType.PROPERTY_PATH_REF, options = "string")
}, groups = @PropertiesGroup(
        properties = {"dataContainer", "property"}, constraint = PropertiesConstraint.ALL_OR_NOTHING
))
interface RichTextField extends Component {
    // ...
}
@StudioCollection 声明子元素元数据

组合组件,比如 tablepickerField 或 charts 在界面描述中是用几个嵌套的 XML 标签定义。子标签也是组件的一部分,通过父组件的 ComponentLoader 加载至界面。声明子元素的元数据,有下面两种方式:

  • 使用 @StudioCollection - 在组件接口直接指定子元素的元数据。

  • 使用 @StudioElementGroup@StudioElement - 子元素的元数据在单独表示 XML 子标签的类指定。

com.haulmont.cuba.gui.meta.StudioCollection 注解有如下属性:

  • xmlElement - 集合的 XML 标签

  • itemXmlElement - 集合中元素的 XML 标签

  • documentationURL - 子元素文档的 URL。界面设计器的 CUBA Documentation 操作会用到该属性。如果文档路径是有版本的,则可以用 %VERSION% 作为占位符。平台会使用包含 UI 组件的库的 小版本(比如 1.2) 替换。

  • itemProperties - 一组 @StudioProperty 注解,定义集合元素的属性。

以下是示例。

这是在界面描述中需要的 XML 结构:

<layout>
    <langPicker>
        <options>
            <option caption="msg://lang.english" code="en" flagIcon="icons/english.png"/>
            <option caption="msg://lang.french" code="fr" flagIcon="icons/french.png"/>
        </options>
    </langPicker>
</layout>

带有 @StudioCollection 的组件类:

@StudioComponent(xmlElement = "langPicker", category = "Samples")
public interface LanguagePicker extends Field<Locale> {

    @StudioCollection(xmlElement = "options", itemXmlElement = "option",
            itemProperties = {
                    @StudioProperty(name = "caption", type = PropertyType.LOCALIZED_STRING, required = true),
                    @StudioProperty(name = "code", type = PropertyType.STRING),
                    @StudioProperty(name = "flagIcon", type = PropertyType.ICON_ID)
            })
    void setOptions(List<LanguageOption> options);
    List<LanguageOption> getOptions();
}

在父组件的 Inspector 面板还会额外显示 Add{element caption} 按钮,可以添加子元素:

collection owner inspector

如果在布局中选中子元素,Inspector 面板会显示子元素指定在 StudioCollection 注解中的属性:

collection element inspector
@StudioElementGroup 和 @StudioElement 声明子元素元数据

@StudioElementGroup 用来标记组件接口中的 setter 方法。这样就告诉 Studio,需要在引用的类中查找子元素的元数据。

@StudioElementsGroup(xmlElement = "subElementGroupTagName")
void setSubElements(List<ComponentSubElement> subElements);

@StudioElementGroup 注解有如下属性:

  • xmlElement - 子元素分组的 XML 标签名

  • icon - 用在工具箱和界面结构中的子元素分组的图标路径,SVG 或 PNG 格式,路径为以组件模块根路径为起点的相对路径。

  • documentationURL - 子元素分组文档的 URL。界面设计器的 CUBA Documentation 操作会用到该属性。如果文档路径是有版本的,则可以用 %VERSION% 作为占位符。平台会使用包含 UI 组件的库的 小版本(比如 1.2) 替换。

@StudioElement 用来标记表示组件子元素的类。XML 标签可用的属性通过 @StudioProperty@StudioProperties 属性声明。

@StudioElement(xmlElement = "subElement", caption = "Sub Element")
public interface SubElement {
    @StudioProperty
    void setElementProperty(String elementProperty);
    // ...
}

@StudioElement 注解的属性与 @StudioComponent 类似:

  • xmlElement - 子元素的 XML 标签名。

  • caption - 显示在 Inspector 面板的子元素名称。

  • description - 显示在 Inspector 面板的组件描述,鼠标浮上时,作为 tooltip 展示。

  • icon - 用在工具箱和界面结构中的子元素图标路径,SVG 或 PNG 格式,路径为以组件模块根路径为起点的相对路径。

  • xmlns - XML 命名空间。当子元素添加至界面,Studio 会自动在界面描述中添加对命名空间的引入。

  • xmlnsAlias - XML 命名空间别名。比如,如果命名空间别名是 map 且 XML 标签名是 layer,则组件会作为 <map:layer/> 标签添加至界面。

  • defaultProperty - 子元素默认属性名称,当在布局选中子元素时,这些默认属性会自动在 Inspector 面板选中。

  • unsupportedProperties - 从子元素父接口继承下来的属性,但是本元素并不支持。这些属性会在 Inspector 面板隐藏。

  • documentationURL - 子元素文档的 URL。界面设计器的 CUBA Documentation 操作会用到该属性。如果文档路径是有版本的,则可以用 %VERSION% 作为占位符。平台会使用包含 UI 组件的库的 小版本(比如 1.2) 替换。

请看下面的示例。

这是在界面描述中需要的 XML 结构:

<layout>
    <serialChart backgroundColor="#ffffff" caption="Weekly Stats">
        <graphs>
            <graph colorProperty="color" valueProperty="price"/>
            <graph colorProperty="costColor" valueProperty="cost"/>
        </graphs>
    </serialChart>
</layout>

带有 @StudioElementsGroup 注解的组件类:

@StudioComponent(xmlElement = "serialChart", category = "Samples")
public interface SerialChart extends Component {

    @StudioProperty
    void setCaption(String caption);
    String getCaption();

    @StudioProperty(type = PropertyType.STRING)
    void setBackgroundColor(Color backgroundColor);
    Color getBackgroundColor();

    @StudioElementsGroup(xmlElement = "graphs")
    void setGraphs(List<ChartGraph> graphs);
    List<ChartGraph> getGraphs();
}

@StudioElement 声明的子元素:

@StudioElement(xmlElement = "graph", caption = "Graph")
public interface ChartGraph {

    @StudioProperty(type = PropertyType.PROPERTY_PATH_REF)
    void setValueProperty(String valueProperty);
    String getValueProperty();

    @StudioProperty(type = PropertyType.PROPERTY_PATH_REF)
    void setColorProperty(String colorProperty);
    String getColorProperty();
}

在父组件的 Inspector 面板还会额外显示 Add{element caption} 按钮,可以添加子元素:

element group owner inspector

如果在布局中选中子元素,Inspector 面板会显示子元素声明时指定的属性:

element group element inspector
声明子标签属性

还可以声明某些组件属性不在主标签定义,而是在主标签内的某个子标签定义。请看下面例子的 XML 布局:

<myChart>
    <scrollBar color="white" position="TOP"/>
</myChart>

这里,scrollBarmyChart 组件的一部分,并不是一个独立组件,因此我们希望在主组件接口中声明属性。

定义子标签属性的元数据注解可以在主组件声明,使用 @StudioProperty 注解的 xmlElement 属性即可。该属性定义子标签的名称。带注解的组件定义如下:

@StudioComponent(xmlElement = "myChart", category = "Samples")
public interface MyChart extends Component {

    @StudioProperty(name = "position", caption = "scrollbar position",
            xmlElement = "scrollBar", xmlAttribute = "position",
            type = PropertyType.ENUMERATION, options = {"TOP", "BOTTOM"})
    void setScrollBarPosition(String scrollBarPosition);
    String getScrollBarPosition();

    @StudioProperty(name = "color", caption = "scrollbar color",
            xmlElement = "scrollBar", xmlAttribute = "color")
    void setScrollBarColor(String scrollBarColor);
    String getScrollBarColor();
}
@StudioEmbedded 声明标签属性并抽取至 POJO

有些情况下,您可能希望抽取一组组件的属性至一个单独的 POJO。但同时,在 XML schema 中,这些抽取出来的属性仍然作为主 XML 标签的属性使用。此时,可以用 @StudioEmbedded 注解满足这个要求。需要将接收这个 POJO 对象的 setter 标注为 @StudioEmbedded,意思是这里的 POJO 包含了额外的组件属性。

com.haulmont.cuba.gui.meta.StudioEmbedded 注解无属性。

下面是一个用例:

需要的 XML 结构,注意,所有的属性都是给主组件标签设置的:

<layout>
    <richTextField textColor="blue" editable="false" id="rtf"/>
</layout>

带有注解属性的 POJO 类:

public class FormattingOptions {
    private String textColor = "black";
    private boolean foldComments = true;

    @StudioProperty(defaultValue = "black", description = "Main text color")
    public void setTextColor(String textColor) {
        this.textColor = textColor;
    }

    @StudioProperty(defaultValue = "true")
    public void setFoldComments(boolean foldComments) {
        this.foldComments = foldComments;
    }
}

组件接口:

@StudioComponent(category = "Samples",
        unsupportedProperties = {"icon", "responsive"},
        description = "Text field with html support")
public interface RichTextField extends Field<String> {

    @StudioEmbedded
    void setFormattingOptions(FormattingOptions formattingOptions);
    FormattingOptions getFormattingOptions(FormattingOptions formattingOptions);
}

在组件的 Inspector 面板,这些属性与其他属性一起显示:

embedded inspector
事件和代理方法支持

Studio 为自定义 UI 组件(或 Facet)提供了与自带 UI 组件相同的事件和代理方法支持。在组件接口中声明事件监听器或代理方法时,不需要任何注解。

下面是声明事件处理器和它自己事件类的组件示例:

@StudioComponent(category = "Samples")
public interface LazyTreeTable extends Component {

    // ...

    Subscription addNodeExpandListener(Consumer<NodeExpandEvent> listener);

    class NodeExpandEvent extends EventObject {
        private final Object nodeId;

        public NodeExpandEvent(LazyTreeTable source, Object nodeId) {
            super(source);
            this.nodeId = nodeId;
        }

        public Object getNodeId() {
            return nodeId;
        }
    }
}

在 Inspector 面板,可以看到声明的事件处理器:

event handler in inspector

Studio 生成的事件处理器实现的桩代码如下:

@UiController("demo_Dashboard")
@UiDescriptor("dashboard.xml")
public class Dashboard extends Screen {
    // ...

    @Subscribe("regionTable")
    public void onRegionTableNodeExpand(LazyTreeTable.NodeExpandEvent event) {

    }
}

下面的例子演示了在支持泛型的 facet 中如何声明代理方法:

@StudioFacet
public interface LookupScreenFacet<E extends Entity> extends Facet {

    // ...

    void setSelectHandler(Consumer<Collection<E>> selectHandler);

    void setOptionsProvider(Supplier<ScreenOptions> optionsProvider);

    void setTransformation(Function<Collection<E>, Collection<E>> transformation);

    @StudioProperty(type = PropertyType.COMPONENT_REF, typeParameter = "E",
            options = "com.haulmont.cuba.gui.components.ListComponent")
    void setListComponent(ListComponent<E> listComponent);
}
泛型参数支持

Studio 支持使用泛型参数定义的组件。参数可以是实体类,界面类或者其他的 Java 类。类型参数会在组件注入到界面控制器和生成代理方法的桩代码时使用。

Studio 的类型推断,在特定组件内是通过检查 XML 中组件的属性配置完成的。组件属性可以直接或间接的指定泛型类型。比如,table 组件展示实体列表,所以泛型参数是实体类。为了推断具体的实体类型,Studio 会检查 dataContainer 属性,查看集合数据容器的定义。如果集合数据容器的所有属性都有赋值,那么集合数据容器用到的实体类会被推断为表格使用的泛型参数。

@StudioProperty 注解的 typeParameter 参数指定泛型 UI 组件或 facet 的类型参数。类型参数可以为下面这些属性类型推断具体具体类:

  • PropertyType.JAVA_CLASS_NAME - 使用指定类。

  • PropertyType.ENTITY_CLASS - 使用指定的实体类。

  • PropertyType.SCREEN_CLASS_NAME - 使用指定的界面类。

  • PropertyType.DATACONTAINER_REF, PropertyType.COLLECTION_DATACONTAINER_REF - 使用指定数据容器中用到的实体类。

  • PropertyType.DATASOURCE_REF, PropertyType.COLLECTION_DATASOURCE_REF - 使用指定数据源中用到的实体类。

  • PropertyType.COMPONENT_REF - 使用指定组件绑定的实体类(实体类通过绑定的数据容器或者数据源确定)

下面是用例。

组件接口代码,UI 组件用来展示通过集合容器提供的一组实体:

@StudioComponent(category = "Samples")
public interface MyTable<E extends Entity> extends Component { (1)

    @StudioProperty(type = PropertyType.COLLECTION_DATACONTAINER_REF,
            typeParameter = "E") (2)
    void setContainer(CollectionContainer<E> container);

    void setStyleProvider(@Nullable Function<? super E, String> styleProvider); (3)
}
1 - 组件接口使用 E 作为参数,E 表示展示在表格内的实体类。
2 - 在 COLLECTION_DATACONTAINER_REF 类型的属性上指定 typeParameter 这个注解属性,我们可以告诉 Studio 通过查看关联的集合容器推断具体实体类型。
3 - 泛型参数也用在了组件的代理方法上。

为了让 Studio 能自动推断组件的类型参数,此组件需要在界面描述中关联集合数据容器:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="msg://dashboard.caption"
        messagesPack="com.company.demo.web.screens">
    <data>
        <collection id="regionsDc" class="com.company.demo.entity.Region">
            <!-- ... -->
        </collection>
    </data>
    <layout>
        <myTable id="regionTable" container="regionsDc"/>
    </layout>
</window>

Studio 会在界面控制器生成如下代码:

@UiController("demo_Dashboard")
@UiDescriptor("dashboard.xml")
public class Dashboard extends Screen {
    @Inject
    private MyTable<Region> regionTable; (1)

    @Install(to = "regionTable", subject = "styleProvider")
    private String regionTableStyleProvider(Region region) { (2)
        return "bold-text";
    }
}
1 - 控制器中注入了正确类型参数的组件。
2 - 代理方法的签名也是用了正确的类型参数。

3.5.18. 通用 UI 基础设施

本节介绍通用 UI 的基础设施类,可以在应用程序中对它们进行扩展。

WebClientInfrastructure
Figure 22. 通用 UI 基础设施类
  • AppUI 继承于 com.vaadin.ui.UI。对于 Web 浏览器打开的每个标签页,都有一个此类的实例。它指向一个 RootWindow,根据连接状态的不同,一个 RootWindow 可能包含了一个登录界面或者主界面。可以使用 AppUI.getCurrent() 静态方法获取对当前浏览器标签页的 AppUI 的引用。

    如果想自定义项目中 AppUI 的功能,需要在 web 模块创建一个继承 AppUI 的类,并在web-spring.xml中使用 cuba_AppUI id 和 prototype scope 进行注册,示例:

    <bean id="cuba_AppUI" class="com.company.sample.web.MyAppUI" scope="prototype"/>
  • Connection 是一个接口,此接口提供连接到中间件和保持用户会话的功能。ConnectionImpl 是此接口的标准实现。

    如果想自定义项目中 Connection 的功能,需要在 web 模块创建一个继承 ConnectionImpl 的类,并在web-spring.xml中使用 cuba_Connection id 和 vaadin scope 进行注册,示例:

    <bean id="cuba_Connection" class="com.company.sample.web.MyConnection" scope="vaadin"/>
  • ExceptionHandlers 类包含客户端级(client-level)异常处理器的集合。

  • App 包含 ConnectionExceptionHandlers 以及其它基础设施对象的链接。框架会为每一个 HTTP 会话创建一个该类的单例,并存储在会话的属性中。可以使用 App.getInstance() 静态方法获取 App 实例的引用。

    如果想自定义项目中 App 的功能,需要在 web 模块创建一个继承 DefaultApp 的类,并在web-spring.xml中使用 cuba_App id 和 vaadin scope 进行注册,示例:

    <bean name="cuba_App" class="com.company.sample.web.MyApp" scope="vaadin"/>

3.5.19. Web 登录

本节介绍 Web 客户端身份验证的工作原理以及如何在项目中进行扩展。有关中间层身份验证的信息,请参阅登录

参考 匿名访问 & 社交登录 向导学习如何为应用程序中某些界面设置公共访问权限,并实现用 Google、Facebook 或 GitHub 账号的自定义登录。

Web 客户端 block 的登录过程的实现机制如下:

  • ConnectionImpl 实现了 Connection

  • LoginProvider 实现。

  • HttpRequestFilter 实现。

WebLoginStructure
Figure 23. Web 客户端的登录机制

Web 登录子系统的主要接口是 Connection,它包含以下关键方法:

  • login() - 验证用户、启动会话并更改连接状态。

  • logout() - 退出系统。

  • substituteUser() - 用另一个用户替换当前会话中的用户。此方法会创建一个新的 UserSession 实例,但会话 ID 不变。

  • getSession() - 获取当前用户会话。

成功登录后,ConnectionUserSession 对象存储到 VaadinSession 的属性中并设置 SecurityContextConnection 对象被绑定到 VaadinSession,因此无法从非 UI 线程使用它,如果在非 UI 线程调用 login/logout ,则会抛出 IllegalConcurrentAccessException

通常,登录是通过 LoginScreen 界面执行的,该界面支持使用用户名/密码和 “记住我” 凭据登录。

Connection 的默认实现是 ConnectionImpl,它将登录委托给 LoginProvider 实例链。LoginProvider 是一个可以处理特定 Credentials 实现的登录模块,它还有一个特殊的 supports() 方法,允许调用者查询它是否支持给定的 Credentials 类型。

WebLoginProcedure
Figure 24. 标准用户登录过程

标准用户登录过程:

  • 用户输入用户名和密码。

  • Web 客户端 block 创建一个 LoginPasswordCredentials 对象,将用户名和密码传递给其构造函数,并使用此凭据调用 Connection.login() 方法。

  • Connection 查找对象 LoginProvider 对象链。 这种情况下使用的是 LoginPasswordLoginProvider ,它支持 LoginPasswordCredentials 凭据。LoginPasswordLoginProvider 使用 PasswordEncryption bean 的 getPlainHash() 方法散列密码,并调用 AuthenticationService.login(Credentials)。 根据 cuba.checkPasswordOnClient属性设置,它要使用户名和密码调用 AuthenticationService.login(Credentials) 方法;或者通过用户名加载 User 实体、根据加载的密码哈希验证密码,验证通过后使用 TrustedClientCredentialscuba.trustedClientPassword作为可信客户端登录。

  • 如果验证成功,则创建的具有活动UserSessionAuthenticationDetails 实例将被回传给 Connection

  • Connection 创建一个 ClientUserSession 包装器并将其设置到 VaadinSession 的属性中。

  • Connection 创建一个 SecurityContext 实例并将其设置为 AppContext

  • Connection 触发 StateChangeEvent,此事件会触发 UI 更新和 MainScreen 初始化。

所有 LoginProvider 实现必须:

  • 使用 Credentials 对象验证用户。

  • 使用 AuthenticationService 启动新用户会话或返回另一个活动会话(例如,匿名的)。

  • 返回身份验证详细信息,如果无法使用此 Credentials 对象登录用户,则返回空,例如,如果登录提供程序已被禁用或未正确配置。

  • 如果出现错误的 Credentials,则抛出 LoginException 或将 LoginException 从中间件传递给调用者。

HttpRequestFilter - bean 的标记接口,这种 bean 将作为 HTTP 过滤器自动被添加到应用程序过滤器链: https://docs.oracle.com/javaee/6/api/javax/servlet/Filter.html 。可以使用它来实现其它形式的身份验证、对 HTTP 请求和响应进行预处理或后处理。

要添加额外的 Filter , 可以创建 Spring Framework 组件并实现 HttpRequestFilter 接口:

@Component
public class CustomHttpFilter implements HttpRequestFilter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain)
            throws IOException, ServletException {
        // delegate to the next filter/servlet
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

请注意,最简单的实现必须将执行委托给 FilterChain,否则应用程序将无法工作。默认情况下,作为 HttpRequestFilter bean 被添加的过滤器将不会收到对 VAADIN 目录和 cuba.web.cubaHttpFilterBypassUrls app 属性中指定的其它路径的请求。

内置登录提供程序

平台包含以下 LoginProvider 接口的实现:

  • AnonymousLoginProvider - 为不需登录的用户提供匿名登录。

  • LoginPasswordLoginProvider -将登录委托给使用 LoginPasswordCredentialsAuthenticationService

  • RememberMeLoginProvider- 将登录委托给使用 RememberMeCredentialsAuthenticationService

  • LdapLoginProvider - 授受 LoginPasswordCredentials 参数,使用 LDAP 执行身份验证并将登录委托给使用 TrustedClientCredentialsAuthenticationService 服务。

  • ExternalUserLoginProvider - 授受 ExternalUserCredentials 参数,将登录委托给使用 TrustedClientCredentialsAuthenticationService 服务。可使用提供的用户名执行登录。

所有实现都使用 AuthenticationService.login() 创建一个活动的用户会话。

可以使用 Spring Framework 的机制覆盖它们中的任何一个。

事件

Connection 的标准实现 - ConnectionImpl 在登录过程中触发以下应用程序事件

  • BeforeLoginEvent / AfterLoginEvent

  • LoginFailureEvent

  • UserConnectedEvent / UserDisconnectedEvent

  • UserSessionStartedEvent / UserSessionFinishedEvent

  • UserSessionSubstitutedEvent

BeforeLoginEventLoginFailureEvent 的事件处理程序可能抛出 LoginException 来取消登录过程或覆盖初始登录失败异常。

例如,可以使用 BeforeLoginEvent 实现只允许登录名中包含有公司域名的用户登录 Web 客户端。

@Component
public class BeforeLoginEventListener {
    @Order(10)
    @EventListener
    protected void onBeforeLogin(BeforeLoginEvent event) throws LoginException {
        if (event.getCredentials() instanceof LoginPasswordCredentials) {
            LoginPasswordCredentials loginPassword = (LoginPasswordCredentials) event.getCredentials();

            if (loginPassword.getLogin() != null
                    && !loginPassword.getLogin().contains("@company")) {
                throw new LoginException(
                        "Only users from @company are allowed to login");
            }
        }
    }
}

此外,标准应用程序类 - DefaultApp 会触发以下事件:

  • AppInitializedEvent - 在 App 初始化后触发,每个 HTTP 会话执行一次。

  • AppStartedEvent - 在以匿名用户身份登录进行第一次请求处理时触发。事件处理器可以使用绑定到 AppConnection 对象来完成用户登录。

  • AppLoggedInEvent - 用户登录成功时的 App UI 初始化后触发。

  • AppLoggedOutEvent - 用户注销时的 App UI 初始化后触发。

  • SessionHeartbeatEvent - 收到来自客户端 Web 浏览器的心跳请求时触发。

AppStartedEvent 可用于使用第三方认证系统实现 SSO 登录,例如 Jasig CAS。通常,它与自定义 HttpRequestFilter bean 一起使用,该 bean 应收集并提供其它身份验证数据。

我们假设:如果用户有一个特殊的 cookie 值 - PROMO_USER,应用程序将自动登录。

@Order(10)
@Component
public class AppStartedEventListener implements ApplicationListener<AppStartedEvent> {

    private static final String PROMO_USER_COOKIE = "PROMO_USER";

    @Inject
    private Logger log;

    @Override
    public void onApplicationEvent(AppStartedEvent event) {
        String promoUserLogin = event.getApp().getCookieValue(PROMO_USER_COOKIE);
        if (promoUserLogin != null) {
            Connection connection = event.getApp().getConnection();
            if (!connection.isAuthenticated()) {
                try {
                    connection.login(new ExternalUserCredentials(promoUserLogin));
                } catch (LoginException e) {
                    log.warn("Unable to login promo user {}: {}", promoUserLogin, e.getMessage());
                } finally {
                    event.getApp().removeCookie(PROMO_USER_COOKIE);
                }
            }
        }
    }
}

因此,如果用户拥有“PROMO_USER”cookie 并打开应用程序,它们将自动以 promoUserLogin 身份登录。

如果要在登录和 UI 初始化后执行其它操作,可以使用 AppLoggedInEvent。 需要注意的是,在事件处理程序中必须检查用户是否进行了身份验证,因为所有事件也会对 anonymous 用户触发。

Web 会话生命周期事件

框架会发送与 HTTP 会话生命周期相关的两个事件:

  • WebSessionInitializedEvent - 当 HTTP 会话初始化时。

  • WebSessionDestroyedEvent - 当 HTTP 会话销毁时。

可以用这些事件做一些系统级别的动作。注意,在线程中没有可用的 SecurityContext

扩展点

可以使用以下类型的扩展点扩展登录机制:

  • Connection - 替换现有的 ConnectionImpl

  • HttpRequestFilter - 实现额外的 HttpRequestFilter

  • LoginProvider 实现 - 实现额外的或替换现有的 LoginProvider

  • 事件 - 为一个可用的事件实现事件处理器。

可以使用 Spring Framework 机制替换现有 bean,例如通过在 web 模块的 Spring XML 配置中注册新 bean。

<bean id="cuba_LoginPasswordLoginProvider"
      class="com.company.demo.web.CustomLoginProvider"/>

3.5.20. 匿名访问界面

默认情况下,匿名(未认证)用户会话只能访问登录界面。通过扩展登录界面,可以在界面上添加任何信息,甚至添加 WorkArea 组件,然后便能在该组件内为匿名用户打开其它界面。但是一旦用户登录了,所有在匿名模式下打开的界面都会关闭。

有时也许我们需要将某些应用程序的界面呈现给用户而无论用户是否进行登录认证。比如下面这个需求:

  • 当用户打开应用程序,他们能看见 欢迎 界面.

  • 还有一个 信息 界面,提供公共访问的信息。信息 界面必须在最高层的界面窗口展示,比如,不带主菜单和其它主窗口的控制。

  • 用户可以从 欢迎 界面或者直接通过浏览器输入 URL 打开 信息 界面。

  • 还有,用户需要能从 欢迎 界面跳转到登录界面并以认证用户的身份继续在系统里操作。

下面我们看看实现步骤。

  1. 创建 信息 界面并使用 @Route 注解其控制器类,提供能使用链接方式打开的功能:

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
            caption="msg://caption"
            messagesPack="com.company.demo.web.info">
        <layout margin="true">
            <label value="Info" stylename="h1"/>
        </layout>
    </window>
    package com.company.demo.web.info;
    
    import com.haulmont.cuba.gui.Route;
    import com.haulmont.cuba.gui.screen.*;
    
    @UiController("demo_InfoScreen")
    @UiDescriptor("info-screen.xml")
    @Route(path = "info") (1)
    public class InfoScreen extends Screen {
    }
    1 - 指定该界面的地址。当该界面在最高层打开的时候,地址栏会显示类似 http://localhost:8080/app/#info 的地址。
  2. 在项目中扩展默认的主界面,以实现需要的 欢迎 界面。在 Studio 的界面创建向导中使用 Main screen …​ 中的一种作为模板,然后在 initialLayout 元素中添加一些组件,示例:

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
            xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd"
            extends="/com/haulmont/cuba/web/app/main/main-screen.xml">
        <layout>
            <hbox id="horizontalWrap">
                <workArea id="workArea">
                    <initialLayout>
                        <label id="welcomeLab" stylename="h1" value="Welcome!"/>
                        <button id="openInfoBtn" caption="Go to Info screen"/>
                    </initialLayout>
                </workArea>
            </hbox>
        </layout>
    </window>
    package com.company.demo.web.main;
    
    import com.company.demo.web.info.InfoScreen;
    import com.haulmont.cuba.gui.Screens;
    import com.haulmont.cuba.gui.components.Button;
    import com.haulmont.cuba.gui.screen.*;
    import com.haulmont.cuba.web.app.main.MainScreen;
    
    import javax.inject.Inject;
    
    @UiController("main")
    @UiDescriptor("ext-main-screen.xml")
    public class ExtMainScreen extends MainScreen {
    
        @Inject
        private Screens screens;
    
        @Subscribe("openInfoBtn")
        private void onOpenInfoBtnClick(Button.ClickEvent event) {
            screens.create(InfoScreen.class, OpenMode.ROOT).show(); (1)
        }
    }
    1 - 创建 信息界面 并且在用户点击按钮时在根窗口打开。
  3. 为了实现在用户进入应用程时打开 欢迎 界面而非登录界面,需要在 web-app.properties 文件添加以下属性:

    cuba.web.initialScreenId = main
    cuba.web.allowAnonymousAccess = true
  4. 为匿名用户启用 信息 界面:启动应用程序,打开 Administration - 管理 > Roles - 角色,在角色编辑器创建 Anonymous 角色,然后为该角色开启能访问 信息 界面的权限。之后,将这个角色分配给 anonymous 用户。

最后,当用户打开应用程序时,他们能看到 欢迎 界面:

welcome_screen

用户不需要认证也能打开 信息 界面,或者点击登录按钮访问应用程序的安全部分。

3.5.21. 不支持浏览器的界面

如果应用程序不支持某个版本的浏览器,用户将会看到一个带有通知消息的标准界面,通知消息会建议升级浏览器并提供推荐的浏览器列表。

用户只有升级了浏览器才能继续使用应用系统。

unsupported browser page
Figure 25. 不支持浏览器界面

可以修改或者本地化默认界面的内容。如果需要本地化该界面,可以在 web 模块的主消息包中使用以下键值:

  • unsupportedPage.captionMessage – 通知消息标题;

  • unsupportedPage.descriptionMessage – 通知消息描述;

  • unsupportedPage.browserListCaption – 浏览器列表的标题;

  • unsupportedPage.chromeMessage – Chrome 浏览器的信息;

  • unsupportedPage.firefoxMessage – Firefox 浏览器的信息;

  • unsupportedPage.safariMessage – Safari 浏览器的信息;

  • unsupportedPage.operaMessage – Opera 浏览器的信息;

  • unsupportedPage.edgeMessage – Edge 浏览器的信息;

  • unsupportedPage.explorerMessage – Explorer 浏览器的信息。

另外,整个界面也可以用自定义的模板替换:

  1. 创建一个新的 *.html 文件模板。

  2. web-app.properties 文件中,使用 cuba.web.unsupportedPagePath 应用程序属性设置新模板的路径:

    cuba.web.unsupportedPagePath = /com/company/sample/web/sys/unsupported-page-template.html

3.5.22. 打开外部 URL

WebBrowserTools 是用来打开外部 URL 的一个工具类 bean。BrowserFrame 组件是用来展示一个内嵌网页,而 WebBrowserTools 是在用户的浏览器 tab 访问 URL。

WebBrowserTools 是一个功能接口,包含单一方法 void showWebPage(String url, @Nullable Map<String, Object> params)

@Inject
private WebBrowserTools webBrowserTools;

@Subscribe("button")
public void onButtonClick(Button.ClickEvent event) {
    webBrowserTools.showWebPage("https://cuba-platform.com", ParamsMap.of("_target", "blank"));
}

showWebPage() 方法有一些可选参数:

  • target - 字符串值,作为客户端 window.open 调用时的 target 名称。也就是说,只能考虑 "_blank""_self""_top""_parent"。如果未指定,则使用 "_blank"

  • width - 整型值,指定浏览器窗口宽度,单位是像素。

  • height - 整型值,指定浏览器窗口高度,单位是像素。

  • border - 字符串值,设置浏览器窗口的 border 样式。可以是 "DEFAULT""MINIMAL""NONE" 之一。

WebBrowserTools 并非普通的 Spring bean,所以只能在界面控制器注入,或者通过 AppUI.getCurrent().getWebBrowserTools() 方法获取。不能注入到 Spring bean,也不能通过 AppBeans.get() 获取。

比如,如需从 菜单项 直接打开 URL,应该创建一个实现了 Runnable 接口的类,由于这里不能使用注入,因此我们用 AppUI.getCurrent() 静态方法:

public class ExternalUrlMenuItemRunner implements Runnable {

    @Override
    public void run() {
        AppUI.getCurrent().getWebBrowserTools().showWebPage("http://www.cuba-platform.com", null);
    }
}

关于 AppUI 类,可以参阅 通用 UI 基础设施 了解更多。

3.5.23. 浏览器打开静态资源

在浏览器可以直接通过 URL 加载和读取静态资源,而不需要认证、REST 或 FileDescriptor。只需将静态资源放置在项目的 /modules/web/web/VAADIN/ 目录。然后可以通过 URL http://localhost:8080/app/VAADIN/{fileName} 访问,示例:

http://localhost:8080/app/VAADIN/customers_list.txt

3.5.24. 网页刷新事件

UIRefreshEvent 事件会在每次网页刷新时发送。这个事件是一个 UiEvent,所以只能在界面控制器的事件监听器处理。

@UiController("extMainScreen")
@UiDescriptor("ext-main-screen.xml")
public class ExtMainScreen extends MainScreen {

    @Inject
    private Notifications notifications;

    @EventListener
    protected void onPageRefresh(UIRefreshEvent event) {
        notifications.create()
                .withCaption("Page is refreshed " + event.getTimestamp())
                .show();
    }
}

3.6. GUI 历史版本 API

3.6.1. 界面(历史版本)

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面和界面片段(Fragments)

通过一个 XML 描述和一个界面控制器来定义一个通用 UI 界面。XML 描述中含有对控制器类的链接。

要从主菜单或者通过 Java 代码(比如从不同界面的控制器)调用一个界面,这个界面的 XML 描述需要在项目的 screens.xml 文件里注册。用户登录之后打开的默认界面可以通过 cuba.web.defaultScreenId 这个应用程序属性来设置。

应用程序的主菜单内容根据menu.xml文件生成。

3.6.1.1. 界面类型

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

本章节描述以下几种基本的界面类型:

3.6.1.1.1. 界面子框架

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

子框架(Frame)是可重用的界面。界面子框架通过 frame XML 元素添加在别的界面中。

子框架的控制器必须扩展 AbstractFrame 类。

在 Studio 里面可以使用 Blank frame 模板创建界面框架。

以下是一些界面框架和框架内包含的其它界面交互的规则:

  • 框架内的界面组件可以通过“.”来获得引用:frame_id.component_id

  • 框架内的界面组件也可以在控制器中通过调用 getComponent(component_id) 方法获得,但是这个方法只有在框架内没有相同 id 的组件才能用。比如,frame 内的组件名称会覆盖界面的组件名称。

  • 界面的数据源可以在子界面框架内访问,有三种途径:通过调用 getDsContext().get(ds_id) 方法获得、通过依赖注入获得、通过数据源查询中的 ds$ds_id 获得。但是只有在子框架没有声明同名的数据源情况下才能取到(跟组件的情况类似)。

  • 从界面上想要获取子框架的数据源,只能通过遍历 getDsContext().getChildren() 集合的方法得到。

界面的提交会促使界面内所有子框架内有更改的 datasource 一起提交。

3.6.1.1.2. 简单界面

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

简单界面可以用来展示和编辑信息,包括单一实体实例或者实体列表。这种类型的界面只能在应用程序主窗口打开并且使用数据源的核心功能。

简单界面的控制器必须从 AbstractWindow 类继承。

可以在 Studio 使用 Blank screen 模板创建简单界面。

3.6.1.1.3. 查找界面

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

查找界面设计用来选择并且返回实体实例或者实体列表。可视化组件中的 PickerFieldLookupPickerField 使用的标准 LookupAction 就是调用查找界面来选择相关的实体。

当通过 openLookup() 方法调用查找界面的时候,界面会包含一个面板以及用来选择的一些按钮。当用户选择了一个实例或者多个实例的时候,查找界面调用之前传递给它的处理器函数,从而能将查找结果返回给调用者的代码。当通过 openWindow() 方法或者比如说通过主菜单打开查找界面的时候,用来选择的面板不会显示,查找界面会被有效的转化成一个简单界面

查找界面的控制器必须从 AbstractLookup 类继承。界面 XML 描述中的 lookupComponent 属性必须指向一个组件(比如表格),从这个组件中选择需要的实体实例作为查找结果。

可以在 Studio 里使用 Entity browser 或者 Entity combined screen 模板来创建实体的查找界面。

默认情况下,LookupAction 会使用注册在 screens.xml 文件中的一个查找界面,注册的标识符是 {entity_name}.lookup 或者 {entity_name}.browse,比如,sales_Customer.lookup。所以在使用上面提到的组件的时候,确保有个查找界面已经注册。Studio 会使用 {entity_name}.browse 标识符注册浏览界面,所以这些界面会被默认当作查找界面使用。

自定义查找界面样式和行为
  • 需要更改项目中所有查找界面的查找按钮面板(SelectCancel 按钮),可以创建一个界面子框架(frame)并且使用 lookupWindowActions 标识符注册。系统默认的界面框架在 /com/haulmont/cuba/gui/lookup-window.actions.xml。自定义的界面框架必须包含一个链接到 lookupSelectAction 行为的按钮(当界面作为查找界面打开时会自动添加这个按钮)。

  • 需要在某些特定的界面替换查找按钮面板,只需要在界面中创建一个链接到 lookupSelectAction 行为的按钮,这样的话平台不会添加默认的按钮面板。示例:

    <layout expand="table">
        <hbox>
            <button id="selectBtn" caption="Select item"
                    action="lookupSelectAction"/>
        </hbox>
        <!-- ... -->
    </layout>
  • 需要用自定义的操作替换掉默认的选择动作时,只需要在控制器添加自定义的 action:

    @Override
    public void init(Map<String, Object> params) {
        addAction(new SelectAction(this) {
            @Override
            protected Collection getSelectedItems(LookupComponent lookupComponent) {
                Set<MyEntity> selected = new HashSet<>();
                // ...
                return selected;
            }
        });
    }

    使用 com.haulmont.cuba.gui.components.SelectAction 作为 action 的基类,如果需要的话,重写里面的方法。

3.6.1.1.4. 编辑界面

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

编辑界面用来展示和编辑实体实例。编辑界面通过需要编辑的实体来做界面初始化,并且包含操作,能把对实体的改动保存到数据库。编辑界面需要通过 openEditor() 方法打开,此方法接收一个实体的实例作为参数。

默认情况下,标准的 CreateAction - 创建EditAction - 编辑操作会打开编辑界面,编辑界面使用 {entity_name}.edit 标识符注册在 screens.xml 文件内,比如,sales_Customer.edit

编辑界面控制器必须从 AbstractEditor 类继承。

可以在 Studio 里使用 Entity editor 模板来创建实体的编辑界面。

编辑界面的 XML 描述中,datasource 属性应当指向一个包含编辑实体实例的数据源。以下这些 XML 中标准的按钮子框架组可以用来展示提交或者撤销操作:

  • editWindowActionscom/haulmont/cuba/gui/edit-window.actions.xml 文件) – 包含 OKCancel 按钮。

  • extendedEditWindowActionscom/haulmont/cuba/gui/extended-edit-window.actions.xml 文件) – 包含 OK & CloseOKCancel

下列操作需要在编辑界面显式初始化:

  • windowCommitAndClose (对应于 Window.Editor.WINDOW_COMMIT_AND_CLOSE 常量) – 提交改动到数据库并且关闭界面的操作。如果界面有 windowCommitAndClose 标识符的可视化组件,则会初始化这个操作。当使用上面提到的 extendedEditWindowActions 子框架的时候,这个操作会显示为 OK & Close 按钮。

  • windowCommit (对应于 Window.Editor.WINDOW_COMMIT 常量) – 提交改动到数据库的操作。如果界面没有 windowCommitAndClose,此操作会在提交数据之后关闭界面。如果界面有上面提到的标准子框架,这个操作会显示为 OK 按钮。

  • windowClose (对应于 Window.Editor.WINDOW_CLOSE 常量) – 关闭界面不提交改动。界面总是会初始化这个操作。如果使用上面提到的标准子框架,这个动作显示为 Cancel 按钮。

因此,如果界面包含 editWindowActions 子界面框架,使用 OK 按钮来提交改动并且关闭界面,使用 Cancel 按钮关闭界面不提交改动。如果界面包含 extendedEditWindowActions 子界面框架,使用 OK 按钮只用来提交改动,OK & Close 按钮用来提交改动并且关闭界面,使用 Cancel 按钮关闭界面不提交改动。

除了标准的子界面框架之外,也可以用一些其它的组件来展现界面行为,比如 LinkButton

3.6.1.1.5. 组合界面

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

通过组合界面可以在界面左边部分显示实体列表,在界面右边部分显示编辑选择实例的表格。所以组合界面是组合了查找编辑界面。

组合界面控制器必须从 EntityCombinedScreen 类继承。

可以在 Studio 里使用 Entity combined screen 模板来创建实体的组合界面。

3.6.1.2. 界面 XML 描述

这是历史版本 API。对于 v.7.0 的新 API,请参阅界面 XML 描述

XML 描述是一个 XML 格式的文件,用来描述数据源和界面布局。

描述文件有如下结构:

window − 根节点元素

window 的属性:

  • class界面控制器类名。

  • messagesPack − 界面默认的消息语言包。在控制器中可以通过 getMessage() 方法或者在 XML 描述中使用消息键值来获取语言包里面的本地化消息语言,使用消息键值的时候,不需要指定包名。

  • caption − 窗口标题,可以包含指向上面提到的语言包的一个 消息键值链接,比如:

    caption="msg://credits"
  • focusComponent − 一个组件的标识符,当界面展示的时候会默认聚焦到这个组件。

  • lookupComponent查找界面的必须属性;定义一个可视化组件的标识符,通过这个组件选取实体实例。支持以下类型的组件(及其它们的子类):

    • Table - 表格

    • Tree - 树形组件

    • LookupField - 下拉框控件

    • PickerField - 选取器控件

    • OptionsGroup - 选项组控件

  • datasource编辑界面的必须属性,用来定义包含需要编辑的实体的数据源标识符。

window 的元素:

  • metadataContext − 这个元素用来初始化界面需要的视图。建议在同一个 views.xml 文件里定义所有的视图,因为所有的视图描述都部署在同一个仓库(repository)中,所以如果视图描述散落在很多个文件中,很难保证视图名称的唯一性。

  • dsContext − 定义界面的数据源

  • dialogMode - 在界面通过对话框的方式打开时,定义窗口的几何属性以及行为。

    dialogMode 的属性:

    • closeable - 定义对话框是否带有关闭按钮。可选值:truefalse

    • closeOnClickOutside - 当窗口通过模态窗(modal)模式打开时,定义对话框是否可以通过点击窗口之外的区域关闭。可选值: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 – 定义界面的定时器列表。

  • companions – 定义界面控制器的 companion - 友类列表。

    companions 的元素:

    • web – 定义 web 模块的友类实现。

    • desktop – 定义 desktop 模块的友类实现。

    这两个元素都有一个 class 属性,用来定义友类。

  • layout − 界面布局的根节点元素,是一个具有组件纵向布局的容器。

3.6.1.3. 界面控制器

这是历史版本 API。对于 v.7.0 的新 API,请参阅界面控制器

界面控制器是一个 Java 或者 Groovy 的类,链接到一个界面 XML 描述并且包含界面初始化以及事件处理逻辑。

控制器需要继承下列基类之一:

如果界面不需要额外添加处理逻辑,也可以使用基类本身作为控制器 - AbstractWindowAbstractLookup 或者 AbstractEditor,通过在 XML 描述中指定即可(这些类实际上并不是不能实例化的抽象类,只是名称带有 Abstract 而已)。对于界面子框架,可以省掉控制器类定义。

控制器类需要在界面的 XML 描述的 window 根节点元素的 class 属性里注册。

Controllers
Figure 26. 控制器基类组
3.6.1.3.1. AbstractFrame

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

AbstractFrame 是控制器类结构的根节点基类。以下是其主要方法的介绍:

  • init() 在创建了 XML 描述中的所有组件之后,但是在界面显示之前会被调用。

    init() 接受一组以 map 类型传递的参数,可以用在控制器里面。这些参数可以通过调用界面的方法传递(使用 openWindow(), openLookup() 或者 openEditor() 方法),或者在界面注册文件 screens.xml 里面定义。

    如果需要初始化界面组件,必须实现 init() 方法,示例:

    @Inject
    private Table someTable;
    
    @Override
    public void init(Map<String, Object> params) {
        someTable.addGeneratedColumn("someColumn", new Table.ColumnGenerator<Colour>() {
            @Override
            public Component generateCell(Colour entity) {
                ...
            }
        });
    }
  • getMessage(), formatMessage() – 用来从语言包获取本地化翻译消息的方法,在 XML 描述中定义,是调用相应消息接口的捷径。

  • openFrame() – 根据在 screens.xml 文件中注册的标识符加载一个子界面框架。如果调用者给这个方法传递了一个容器组件的参数,子界面框架会在这个容器内打开。此方法返回子框架控制器。示例:

    @Inject
    private BoxLayout container;
    
    @Override
    public void init(Map<String, Object> params) {
        SomeFrame frame = openFrame(container, "someFrame");
        frame.setHeight("100%");
        frame.someInitMethod();
    }

    但这并不是说非要在 openFrame() 方法中带入容器参数,其实也可以先加载子界面框架然后再添加到所需的容器中:

    @Inject
    private BoxLayout container;
    
    @Override
    public void init(Map<String, Object> params) {
        SomeFrame frame = openFrame(null, "someFrame");
        frame.setHeight("100%");
        frame.someInitMethod();
        container.add(frame);
    }
  • openWindow(), openLookup(), openEditor() – 分别用来打开简单界面、查找界面、以及编辑界面。方法会返回这些界面的控制器。

    对于对话框模式,openWindow() 方法可以带参数调用,示例:

    @Override
    public void actionPerform(Component component) {
        openWindow("sec$User.browse", WindowManager.OpenType.DIALOG.width(800).height(300).closeable(true).resizable(true).modal(false));
    }

    这些参数只有在不与窗口更高优先级的参数冲突的时候才会生效。这些高优先级的参数可以通过界面控制器的 getDialogOptions() 方法设置,或者在界面的 XML 描述中定义:

    <dialogMode forceDialog="true" width="300" height="200" closeable="true" modal="true" closeOnClickOutside="true"/>

    如果需要在界面关闭之后做一些操作,可以添加 CloseListener,示例:

    CustomerEdit editor = openEditor("sales$Customer.edit", customer, WindowManager.OpenType.THIS_TAB);
    editor.addCloseListener((String actionId) -> {
        // do something
    });

    只有在打开的窗口通过 Window.COMMIT_ACTION_ID 名称的操作(比如 OK 按钮)关闭的时候才需要使用 CloseWithCommitListener 来处理关闭事件:

    CustomerEdit editor = openEditor("sales$Customer.edit", customer, WindowManager.OpenType.THIS_TAB);
    editor.addCloseWithCommitListener(() -> {
        // do something
    });
  • showMessageDialog() – 显示消息对话框。

  • showOptionDialog() – 显示带消息的对话框,并且为用户提供一些功能操作。操作通过 Action 的数组定义,这些操作会作为按钮显示。

    推荐使用 DialogAction 对象来显示标准按钮,比如 OKCancel 或者其它按钮,示例:

    showOptionDialog("PLease confirm", "Are you sure?",
            MessageType.CONFIRMATION,
            new Action[] {
                new DialogAction(DialogAction.Type.YES) {
                    @Override
                    public void actionPerform(Component component) {
                        // do something
                    }
                },
                new DialogAction(DialogAction.Type.NO)
            });
  • showNotification() – 显示弹出消息。

  • showWebPage() – 在浏览器打开特定网页。



3.6.1.3.2. AbstractWindow

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

AbstractWindowAbstractFrame 的子类,定义下列方法:

  • getDialogOptions() – 返回一个 DialogOptions - 对话框选项 对象,在界面以对话框模式(WindowManager.OpenType.DIALOG)打开的时候用来控制窗口的几何属性以及行为。这些选项可以在界面初始化的时候设置,也可以在运行时设置。参考下面的例子。

    设置宽度和高度:

    @Override
    public void init(Map<String, Object> params) {
        getDialogOptions().setWidth("480px").setHeight("320px");
    }

    设置对话框在界面中的位置:

    getDialogOptions()
            .setPositionX(100)
            .setPositionY(100);

    设置对话框可通过点击外部区域关闭:

    getDialogOptions().setModal(true).setCloseOnClickOutside(true);

    设置对话框非模态(non-modal)打开,并且可改变大小:

    @Override
    public void init(Map<String, Object> params) {
        getDialogOptions().setModal(false).setResizable(true);
    }

    设置对话框打开时候最大化:

    getDialogOptions().setMaximized(true);

    设置界面总是按照对话框的方式打开,不论在调用时 WindowManager.OpenType 参数是如何选择:

    @Override
    public void init(Map<String, Object> params) {
        getDialogOptions().setForceDialog(true);
    }
  • setContentSwitchMode() - 定义在指定的窗口中 主 tab 标签应当怎样切换标签页:隐藏还是清除界面内容。

    有三个可选项:

    • DEFAULT - 切换模式由 TabSheet 模式 cuba.web.managedMainTabSheetMode 应用程序属性定义。

    • HIDE - 不考虑 TabSheet 的模式,直接隐藏。

    • UNLOAD - 不考虑 TabSheet 的模式,清除界面内容。

  • saveSettings() - 界面关闭时,保存当前用户对界面的设置到数据库。

    比如,界面包含一个复选框 showPanel 用来管理某个面板是否可见。在下面的方法中,为复选框创建一个 XML 元素,然后用这个组件的值添加一个 showPanel 属性,最后为当前用户保存 XML 描述的 settings 元素内容到数据库:

    @Inject
    private CheckBox showPanel;
    
    @Override
    public void saveSettings() {
        boolean showPanelValue = showPanel.getValue();
        Element xmlDescriptor = getSettings().get(showPanel.getId());
        xmlDescriptor.addAttribute("showPanel", String.valueOf(showPanelValue));
        super.saveSettings();
    }
  • applySettings() - 当界面打开时,为当前用户恢复数据库保存的设置。

    这个方法可以被重写用来保存自定义设置。比如,在下面的方法中,取到上面例子中复选框的 XML 元素,然后确保需要的属性不是 null,之后将恢复的值设置到复选框上:

    @Override
    public void applySettings(Settings settings) {
        super.applySettings(settings);
        Element xmlDescriptor = settings.get(showPanel.getId());
        if (xmlDescriptor.attribute("showPanel") != null) {
            showPanel.setValue(Boolean.parseBoolean(xmlDescriptor.attributeValue("showPanel")));
        }
    }

    另一个管理设置的例子就是应用程序内的 Administration - 管理 菜单的标准 Server Log - 服务器日志 界面,它能自动保存和恢复最近打开的日志文件。

  • ready() - 控制器中可以实现的一个模板方法,用来拦截界面打开动作。当界面全部初始化完毕并且打开之后会调用这个方法。

  • validateAll() – 验证一个界面。方法默认实现就是对所有实现了 Component.Validatable 接口的界面组件调用 validate() 方法,然后搜集异常的信息,并且显示相应的消息。如果有任何异常,方法会返回 false,否则返回 true

    只有在需要完全重写界面验证过程的情况下,才需要重写这个方法。如果需要补充额外的验证,实现一个特殊的模板方法就足够了 - postValidate()

  • postValidate() – 可以在控制器实现的模板方法,用来做额外的界面验证。这个方法将验证错误信息保存在传给它的参数 ValidationErrors 对象内。之后,这些错误信息会跟标准验证的错误信息一起显示,示例:

    private Pattern pattern = Pattern.compile("\\d");
    
    @Override
    protected void postValidate(ValidationErrors errors) {
        if (getItem().getAddress().getCity() != null) {
            if (pattern.matcher(getItem().getAddress().getCity()).find()) {
                errors.add("City name can't contain digits");
            }
        }
    }
  • showValidationErrors() - 显示验证错误警告。可以重写这个方法来更改默认的警告行为。通知类型可以通过 cuba.gui.validationNotificationType 应用程序属性来定义。

    @Override
    public void showValidationErrors(ValidationErrors errors) {
        super.showValidationErrors(errors);
    }
  • close() – 关闭当前界面。

    这个方法可以传入字符串参数,这个参数然后传递给 preClose() 模板方法,再传递给 CloseListener 监听器。因此,关于窗口关闭原因的信息可以通过发起关闭事件的代码获得。推荐使用这些常量来关闭编辑界面:提交改动后使用 Window.COMMIT_ACTION_ID,不提交改动的话使用 Window.CLOSE_ACTION_ID

    如果任何数据源包含没保存的改动,在界面关闭前会弹出窗口提示相关信息。通知的类型可以通过 cuba.gui.useSaveConfirmation 应用程序属性调整。

    close() 方法还有一个带有 force = true 参数的变体,这个方法可以不调用 preClose() 就关闭界面,也不会出现关于未保存信息的对话框消息。

    如果界面顺利关闭,close() 方法返回 true。如果关闭的过程中断,则会返回 false。、

  • preClose() 控制器中可以实现的一个模板方法,用来拦截界面关闭动作。这个方法可以接收从 close() 方法传递过来的参数。

    如果关闭的过程中断,preClose() 方法会返回 false

  • addBeforeCloseWithCloseButtonListener() - 当界面通过下列方法关闭的时候添加一个关闭时的监听器:界面的关闭按钮、面包屑(bread crumbs)或者 TabSheet 标签的关闭操作(Close - 关闭, Close All - 关闭全部, Close Others - 关闭其它)。如果需要避免用户误操作关闭窗口,可以调用 BeforeCloseEvent 事件的 preventWindowClose() 方法:

    addBeforeCloseWithCloseButtonListener(BeforeCloseEvent::preventWindowClose);
  • addBeforeCloseWithShortcutListener - 当界面通过快捷键方式(比如,Esc 按钮)关闭的时候,添加一个关闭时的监听器。如果需要避免用户误操作关闭窗口,可以调用 BeforeCloseEvent 事件的 preventWindowClose() 方法:

    addBeforeCloseWithShortcutListener(BeforeCloseEvent::preventWindowClose);


3.6.1.3.3. AbstractLookup

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

AbstractLookup查找界面控制器的基类,AbstractWindow 的子类。定义了下列方法:

  • setLookupComponent() – 设置查找组件,用来选择实体实例。

    作为一条编码规则,用来做选择的组件在 XML 描述中定义,不需要在应用程序代码中调用这个方法。

  • setLookupValidator() – 为界面设置 Window.Lookup.Validator 对象,在返回实体的实例之前,这里的 validate() 方法会被平台调用。如果 validate() 方法返回 false,查找实体的过程或者窗口关闭的过程会被中断。

    默认情况不设置这个验证器。



3.6.1.3.4. AbstractEditor

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

AbstractEditor编辑界面控制器的基类,AbstractWindow 的子类。

创建控制器类时,推荐使用需要编辑的实体作为 AbstractEditor 的泛型参数。这样会使 getItem()initNewItem() 方法能操作指定的实体类型,应用程序代码也不需要做额外的类型转换。示例:

public class CustomerEdit extends AbstractEditor<Customer> {

    @Override
    protected void initNewItem(Customer item) {
        ...

AbstractEditor 定义了下列方法:

  • getItem() – 返回编辑的实体实例,通过界面主数据源设置(比如在 XML 描述的根节点元素中通过 datasource 属性设置)。

    如果编辑的实体实例不是新建的,界面打开过程会按照主数据源设置的 view 来重新加载实体的实例。

    getItem() 返回实例的更改,会在数据源的状态中反映出来,之后会被送到 Middleware 然后提交给数据库。

    需要注意的是,只有在界面通过 setItem() 初始化之后,getItem() 方法才能返回一个值。在此之前,这个方法只会返回 null,比如在 init() 或者 initNewItem() 方法里调用的时候。

    但是,在 init() 方法中,可以通过以下方法取到传递给 openEditor() 方法参数的实体实例:

    @Override
    public void init(Map<String, Object> params) {
        Customer item = WindowParams.ITEM.getEntity(params);
        // do something
    }

    initNewItem() 方法可以接收正确类型的实体作为参数。

    这两种情况下,获取到的实体实例之后都会被重新加载,除非是新建的。因此,不能对实体做修改,或者将此时的实体保存在某个字段留着将来用。

  • setItem() – 当窗口通过 openEditor() 方式打开的时候,平台会调用这个方法将需要编辑的实体设置到主数据源。调用此方法时,所有的界面组件和数据源都已经创建了,并且控制器的 init() 方法也已经执行。

    如果是要初始化界面的话,推荐使用模板方法 initNewItem()postInit(),而不要重写 setItem()

  • initNewItem() – 在设置编辑实体实例到主数据源之前平台会自动调用的模板方法。

    initNewItem() 方法只能给新创建的实体实例调用。这个方法不会给游离实体调用。如果新实体实例必须在设置到主数据源之前做初始化,可以在控制器实现此方法。示例:

    @Inject
    private UserSession userSession;
    
    @Override
    protected void initNewItem(Complaint item) {
        item.setOpenedBy(userSession.getUser());
        item.setStatus(ComplaintStatus.OPENED);
    }

    关于使用 initNewItem() 方法的更复杂的例子可以参阅 cookbook

  • postInit() – 在编辑实体实例被设置到主数据源之后马上被平台调用的模板方法。在这个方法中,可以使用 getItem() 来获取新建的实体实例或者在界面初始化过程中重新加载的实体。

    这个方法可以在控制器作为界面初始化的最后一步实现:

    @Inject
    private EntityStates entityStates;
    @Inject
    protected EntityDiffViewer diffFrame;
    
    @Override
    protected void postInit() {
        if (!entityStates.isNew(getItem())) {
            diffFrame.loadVersions(getItem());
        }
    }
  • commit() – 验证界面,并且通过 DataSupplier 提交数据改动至 Middleware。

    如果调用带有 validate = false 参数的方法,提交时不会做验证。

    建议不要重写此方法,改为使用特定的模板方法 - postValidate()preCommit()postCommit()

  • commitAndClose() – 验证界面,提交改动到 Middleware 并且关闭界面。Window.COMMIT_ACTION_ID 的值会传递给 preClose() 方法以及注册过的 CloseListener 监听器。

    建议不要重写此方法,改为使用特定的模板方法 - postValidate()preCommit()postCommit()

  • preCommit() – 在提交改动的过程中被平台调用的模板方法,在成功验证之后,但是在数据提交到 Middleware 之前。

    这个方法可以在控制器实现。如果返回值是 false,提交的过程会被中断,如果是关闭窗口过程(如果调用的 commitAndClose())中的话,也会被中断。示例:

    @Override
    protected boolean preCommit() {
        if (somethingWentWrong) {
            notifications.create()
                    .withCaption("Something went wrong")
                    .withType(Notifications.NotificationType.WARNING)
                    .show();
            return false;
        }
        return true;
    }
  • postCommit() – 在提交改动的最后阶段被平台调用的模板方法。方法参数:

    • committed – 如果界面有改动,并且已经提交给 Middleware,设置为 true

    • close – 如果界面需要在提交改动之后关闭的话,设置为 true

      如果界面没有关闭,此方法的默认实现会展示关于成功提交的信息并且调用 postInit()

      可以在控制器重写此方法,以便在成功提交改动之后做额外操作,比如:

      @Inject
      private Datasource<Driver> driverDs;
      @Inject
      private EntitySnapshotService entitySnapshotService;
      
      @Override
      protected boolean postCommit(boolean committed, boolean close) {
          if (committed) {
              entitySnapshotService.createSnapshot(driverDs.getItem(), driverDs.getView());
          }
          return super.postCommit(committed, close);
      }

下列图表展示初始化序列过程,以及编辑界面的不同提交改动方法。

EditorInit
Figure 27. 编辑界面初始化过程
EditorCommit
Figure 28. 使用 editWindowActions 子框架提交并关闭窗口
ExtendedEditorCommit
Figure 29. 使用 extendedEditWindowActions 子框架提交界面改动
ExtendedEditorCommitAndClose
Figure 30. 使用 extendedEditWindowActions 子框架提交界面改动并关闭窗口


3.6.1.3.5. EntityCombinedScreen

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

EntityCombinedScreen组合界面控制器的基类,是 AbstractLookup 的子类。

EntityCombinedScreen 类使用硬编码的标识符查找关键组件,比如表格、字段组或者其它组件。如果给组件做另外不同的命名,需要重写类中保护(protected)的方法并返回自定义的标识符,以便控制器能找到自定义的组件。参考类 JavaDocs 了解细节。

3.6.1.3.6. 界面控制器依赖注入

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 界面控制器

在界面控制器进行依赖注入可以用来请求有效对象的引用。基于这个目的,要求在控制器内声明一个相应类型的字段,或者写一个带有相应参数类型的访问方法(setter),使用下面注解之一:

  • @Inject – 最简单的方法,会按照 JavaBeans 规则搜索匹配字段/方法类型以及字段名称的对象用来注入。

  • @Named("someName") – 显示的定义目标对象的名称。

以下类型可以用来注入到控制器:

  • 在 XML 描述中定义的此界面的可视化组件。如果属性的类型是从 Component 类派生的,系统会搜索当前界面中相应名称的组件。

  • 在 XML 描述中定义的操作行为 - 参考 操作

  • 在 XML 描述中定义的数据源。如果属性的类型是从 Datasource 派生,系统会搜索当前界面中相应名称的数据源。

  • UserSession。如果属性的类型是 UserSession,系统会注入当前用户会话的对象。

  • DsContext。如果属性的类型是 DsContext,系统会注入当前界面的 DsContext

  • WindowContext。如果属性的类型是 WindowContext,系统会注入当前界面的 WindowContext

  • DataSupplier。如果属性的类型是 DataSupplier,系统会注入相应的实例。

  • 任何定义在对应客户端 block 上下文的 bean。包括:

  • 如果以上提到的都不合适,并且控制器有友类,如果类型匹配的话当前客户端的友类会被注入。

还可以在控制器内注入传递给 init() 方法的 map 类型的参数,使用 @WindowParam 注解。此注解有 name 属性用来定义参数的名称(map 的键值)以及一个可选的 required 的属性。如果 required = true 并且 map 中没有相应的参数,则会在日志中添加一行 WARNING 的信息。

下面例子注入了传递给控制器 init() 方法的 Job 实体:

@WindowParam(name = "job", required = true)
protected Job job;
3.6.1.3.7. 界面控制器友类

本章节从 7.0 开始就无效了,因为不再支持桌面客户端。不需要创建友类,只要将 界面 放到 web 模块即可。

3.6.1.4. 界面代理

在 v.7.0 中界面代理已经被移除,并且没有替代方案。可以使用 DeviceInfoProvider bean 获取 DeviceInfo,然后可以为每种设备类型创建不同的界面或在界面中打开 fragments。

3.6.2. 数据源(历史版本)

这是旧版本 API。从 v7.0 开始的新数据 API 请参考 数据组件

数据源为数据感知组件提供数据。

可视化组件本身并不访问 Middleware 中间件:它们从关联的数据源中获得实体实例。还有,如果多个可视化组件需要相同的一组实例,此时一个数据源可以跟多个可视化组件关联工作。

  • 当用户在组件中改变一个值的时候,这个新的值会被设置到数据源中实体的对应属性。

  • 当实体属性的值在代码中被修改,新的值会展示到可视化组件中。

  • 用户输入可以通过两种方式监听,一种是数据源监听器,另一种是组件的值监听器 - 这两个监听器会按照顺序接收到事件。

  • 需要在应用代码中读写属性的值,推荐使用数据源,而不是组件本身。以下是一个读取属性值的示例:

    @Inject
    private FieldGroup fieldGroup;
    
    @Inject
    private Datasource<Order> orderDs;
    
    @Named("fieldGroup.customer")
    private PickerField customerField;
    
    public void init(Map<String, Object> params){
        Customer customer;
        // Get customer from component: not for common use
        Component component = fieldGroup.getFieldNN("customer").getComponentNN();
        customer = ((HasValue)component).getValue();
        // Get customer from component
        customer = customerField.getValue();
        // Get customer from datasource: recommended
        customer = orderDs.getItem().getCustomer();
    }

    从这个例子可以看出,通过组件获取实体属性值并不是那么直接。在第一个通过组件获取值的例子中,除了需要做类型转换之外,还需要通过一个字符串来指定 FieldGroup 中的 id。第二个例子更加安全和直接,但是需要知道注入的控件的准确类型。最后一个例子就最简单了。如果实例是从数据源通过 getItem() 方法获取到,则可以直接读取和修改里面的属性值。

数据源也会跟踪内部包含的实体的改动,之后可以将改动后的实体实例发送回中间件从而保存在数据库。

典型的场景中,一个可视化组件通常跟数据源中实体的一个直接属性进行绑定。比如上面的例子中,组件绑定到 Order 实体的 customer 属性。

但是组件也可以跟关联实体的一个属性进行绑定,比如 customer.name。在这种情况下,这个组件会显示 name 属性的值,但是当用户更改值的时候,数据源监听器不会被调用而且改动也不会被保存。因此,只有在用来做数据展示的时候,绑定组件到实体的第二级属性才有意义。比如在标签表格的列, 或者在文本控件中当 editable = false 不让编辑的时候。

以下介绍数据源的基础接口。

Datasources
Figure 31. 数据源接口
  • Datasource 是一个简单的数据源,用来绑定一个实体实例。实例通过 setItem() 方法设置,通过 getItem() 方法访问。

    DatasourceImpl 类是这种数据源的标准实现,这个类会用在比如说实体的编辑界面,作为主数据源。

  • CollectionDatasource 是一个绑定实体实例集合的数据源。数据集合会通过调用 refresh() 方法加载,实例的主键可以通过 getItemIds() 方法访问。setItem() 方法只能设置集合中“当前”的实例,并且通过 getItem() 方法获取这个实例(比如,跟当前选中的表格中一行对应的实例)。

    根据具体实现决定加载数据集合的方式。最典型的方式是通过 DataManager 从 Middleware 加载;这样的话,使用 setQuery()setQueryFilter() 来组建 JPQL 查询。

    CollectionDatasourceImpl 类是这种数据源的标准实现,使用在带有实体列表的界面。

    • GroupDatasourceCollectionDatasource 的子类型,用来跟 GroupTable 组件一起工作。

      这种数据源的标准实现是 GroupDatasourceImpl 类。

    • HierarchicalDatasourceCollectionDatasource 的子类型,用来跟 TreeTreeTable 组件一起工作。

      这种数据源的标准实现是 HierarchicalDatasourceImpl 类。

  • NestedDatasource 是一个用来绑定实体中关联到实体的属性加载对应的实例的数据源。这样的话,那个绑定父实体的数据源可以通过 getMaster() 方法访问;包含这个数据源对应实例的那个父属性的元属性可以通过 getProperty() 方法访问。

    比如一个 Order 实例包含了指向 Customer 实例的引用,Order 实例通过 dsOrder 数据源绑定。然后,如需绑定 Customer 实例到可视化组件,只需创建以 dsOrder 为父数据源的 NestedDatasource,并且创建 meta property 指向 Order.customer 属性。

    • PropertyDatasourceNestedDatasource 的子类型,用来跟单实例或者没有嵌套关系的关联实体集合绑定。

      标准实现:跟单实例绑定 - PropertyDatasourceImpl。跟集合绑定 - CollectionPropertyDatasourceImpl, GroupPropertyDatasourceImpl, HierarchicalPropertyDatasourceImpl。跟集合绑定的数据源同时还实现了 CollectionDatasource 接口,但是 CollectionDatasource 接口中一些不相关、不支持的方法比如 setQuery() 会直接抛出 UnsupportedOperationException 异常。

    • EmbeddedDatasourceNestedDatasource 的子类型,包含了一个嵌套实体的实例。

      标准实现是 EmbeddedDatasourceImpl 类。

  • RuntimePropsDatasource 是个特殊的数据源,用来操作实体的动态属性

一般情况下,数据源在界面描述文件dsContext 部分声明。

CollectionDatasource 自动刷新

当界面打开时,连接到集合数据源的可视化组件促使数据源加载数据。结果会使得表格在界面打开之后马上就能显示数据而不需要用户的任何特定的操作。如果需要阻止数据源集合的自动加载,可以设置界面参数 DISABLE_AUTO_REFRESHtrue,一种是在界面的 init() 方法中设置,另一种是通过调用者代码传递。这个界面参数定义在 WindowParams 枚举类型内,所以可以通过下面方式设置:

@Override
public void init(Map<String, Object> params) {
    WindowParams.DISABLE_AUTO_REFRESH.set(params, true);
}

在这种情况下,界面的集合数据源只有当它们的 refresh() 方法被调用时才会加载数据。可以通过应用代码调用或者当用户在过滤器组件点击 Search 按钮时触发。

3.6.2.1. 创建数据源

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

数据源对象可以通过两种方式创建,一种是在界面的 XML 描述中通过声明式的方式,另一种是在界面控制器通过编程的方式创建。通常情况下,创建的数据源默认会使用其标准实现,但是如果需要的话,也可以通过继承标准实现类来自定义数据源。

3.6.2.1.1. 声明式创建

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

典型的情况下,数据源声明在界面描述文件的 dsContext 元素中。根据声明元素相对位置的不同,可以创建两种类型的数据源:

  • 如果元素直接落在了 dsContext 的范围内,比如一个普通的 Datasource 或者 CollectionDatasource,会创建一个能独立加载实体或者实体集合的数据源;

  • 如果元素落在了其它数据源的元素内,则会创建一个 NestedDatasource - 嵌套的数据源,它是外层数据源的子数据源。

下面为声明一个数据源的示例:

<dsContext>
    <datasource id="carDs" class="com.haulmont.sample.entity.Car" view="carEdit">
        <collectionDatasource id="allocationsDs" property="driverAllocations"/>
        <collectionDatasource id="repairsDs" property="repairs"/>
    </datasource>

    <collectionDatasource id="colorsDs" class="com.haulmont.sample.entity.Color" view="_local">
        <query>
            <![CDATA[select c from sample$Color c order by c.name]]>
        </query>
    </collectionDatasource>
</dsContext>

在上述例子中,carDs 包含一个实体实例 Car,其中嵌套 allocationsDsrepairsDs,分别指向 Car.driverAllocationsCar.repairs 两个关联属性。Car 实例和其相关实体都由外部调用时设置到数据源中。如果当前界面为编辑界面,上述设置在界面打开时会自动设置。colorsDs 还包含指向 Color 实体的集合数据源,这个数据源则是由特定 JPQL查询语句使用 _local 视图设置。

以下是 XML 描述:

dsContext – 根节点。

dsContext 元素:

  • datasource – 定义包含一个实体示例的数据源。

    属性:

    • id – 数据源标识符,需要在当前 DsContext 中唯一。

    • class – 对应数据源 Java 实体类。

    • view – 实体视图的名称。如果数据源需要自己载入实例,该视图会在载入时用到。否则,这个视图为外部程序指示如何为当前数据源载入实体。

    • allowCommit – 如果设置为 false,该数据源的 isModified() 方法永远返回 false,并且 commit() 方法什么都不做。因此,实体内该数据源所有改动都被忽略。该属性默认为 true,即,改动都会被记录并且保存。

    • datasourceClass - 必要时设置,为数据源的 自定义实现类

  • collectionDatasource – 指对应实例集合的数据源。

    collectionDatasource 属性:

    • refreshMode – 数据源更新模式,默认为 ALWAYS。如果设置为 NEVER,当调用 refresh() 时,数据源不载入数据,只是将状态置为 Datasource.State.VALID,通知监听器和需要排序的实例。当你在代码中使用预先载入或者创建好的实体设置 CollectionDatasource 时,NEVER 模式会有用。例如:

      @Override
      public void init(Map<String, Object> params) {
          Set<Customer> entities = (Set<Customer>) params.get("customers");
          for (Customer entity : entities) {
              customersDs.includeItem(entity);
          }
          customersDs.refresh();
      }
    • softDeletion – 设置为 false 时,载入数据时禁用软删除模式,即,被删除的示例也会被载入。默认值为 true

    collectionDatasource 元素:

    • query – 载入实体的查询语句。

  • groupDatasource – 与 collectionDatasource 完全类似,但是会创建适合与 GroupTable 组件结合使用的数据源。

  • hierarchicalDatasource – 类似 collectionDatasource,但是会创建适合与 TreeTreeTable 组件结合使用的数据源。

    hierarchyProperty 为特定属性,指定基于哪个属性组建 hierarchy 层级树数据结构。

如上所述,数据源对应类需要由 XML 元素明确指定,以及通过 XML 元素的相互关系确定。不过如果需要定制化数据源,可以通过 datasourceClass 指定。

3.6.2.1.2. 编程方式创建

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

如果需要在 Java 代码中创建数据源,推荐使用一个特殊的类 - DsBuilder

DsBuilder 是通过流式接口调用链的方式传递参数的。如果设置了 masterproperty 参数,会创建 NestedDatasource,否则,创建 Datasource 或者 CollectionDatasource

示例:

CollectionDatasource ds = new DsBuilder(getDsContext())
        .setJavaClass(Order.class)
        .setViewName(View.LOCAL)
        .setId("ordersDs")
        .buildCollectionDatasource();
3.6.2.1.3. 自定义实现类

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

如果需要实现使用自定义方法来加载实体,可以创建自定义的数据源类,从 CustomCollectionDatasourceCustomGroupDatasource, 或者 CustomHierarchicalDatasource 继承,然后实现 getEntities() 方法。

示例:

public class MyDatasource extends CustomCollectionDatasource<SomeEntity, UUID> {

    private SomeService someService = AppBeans.get(SomeService.NAME);

    @Override
    protected Collection<SomeEntity> getEntities(Map<String, Object> params) {
        return someService.getEntities();
    }
}

还可以通过声明的方式创建自定义数据源,在数据源的 XML 元素中,通过 datasourceClass 属性指定自定义的类名即可。通过 DsBuilder 类采用编程的方式创建的话,使用 setDsClass() 方法来指定自定义类,或者在 build*() 方法中将类以参数的形式传入。

3.6.2.2. 集合数据源查询

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

CollectionDatasourceImpl 类及其子类 GroupDatasourceImplHierarchicalDatasourceImpl 都是数据源的标准实现类,用来处理实体实例的集合。这些数据源发送 JPQL 查询语句给 DataManager 来加载数据。下面介绍这些查询语句的格式。

3.6.2.2.1. 返回值

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

一个查询语句需要返回在创建数据源时指定类型的实体。在以声明式的方式创建数据源时,返回实体的类型通过 XML 元素的 class 属性指定;如果是使用了 DsBuilder 以编程的方式创建,那么通过 setJavaClass() 或者 setMetaClass() 指定。

比如,Customer 实体的数据源的查询语句:

select c from sales_Customer c

或者

select o.customer from sales_Order o

不能使用返回单个属性或者属性聚合值(比如 sum,avg,max 等)的查询语句,示例:

select c.id, c.name from sales_Customer c /* 无效 – 返回了单个字段而不是整个 Customer 对象 */

如果需要执行返回值是纯数值(scalar value)或者属性聚合值(aggregates)的查询语句,并且将返回值通过标准数据绑定显示在可视化组件上,可以使用值数据源

3.6.2.2.2. 查询参数

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

数据源中的 JPQL 查询语句可能包含几种不同类型的参数。参数类型是通过参数名称的前缀决定的,参数名称中 $ 符号之前的部分就是名称前缀,下面针对不同的前缀分别介绍一下 $ 符号之后部分的理解。

  • ds 前缀

    这个参数的值是在同一个 DsContext 注册的其它数据源的数据。示例:

    <collectionDatasource id="customersDs" class="com.sample.sales.entity.Customer" view="_local">
        <query>
             <![CDATA[select c from sales$Customer c]]>
        </query>
    </collectionDatasource>
    
    <collectionDatasource id="ordersDs" class="com.sample.sales.entity.Order" view="_local">
        <query>
             <![CDATA[select o from sales$Order o where o.customer.id = :ds$customersDs]]>
        </query>
    </collectionDatasource>

    上面这个例子中,ordersDs 数据源的查询参数是 customersDs 数据源当前加载的实体实例。

    如果使用了 ds 开头的参数,会自动创建数据源之间的依赖关系。因此能在参数变化了的情况下数据源自动更新。比如在上面的例子中,如果选择的 Customer 改变了,Order 的列表会自动更新。

    需要注意的是,在上面这个带参数的查询中,等号左边的部分是 o.customer.id 标识符的值,右边部分则是 customersDs 数据源中的 Customer 实例。这个等式能成立是因为在 Middleware 运行这个查询的时候,Query 接口的实现类会在给查询参数赋值的时候自动将实体的实例替换成实体的 ID。

    也可以在 $ 符号后面的数据源中使用实体关系图(entity graph)中的路径来指定一个深层的属性(直接用这个属性的值),示例:

    <query>
        <![CDATA[select o from sales$Order o where o.customer.id = :ds$customersDs.id]]>
    </query>

    或者

    <query>
        <![CDATA[select o from sales$Order o where o.tagName = :ds$customersDs.group.tagName]]>
    </query>
  • custom 前缀

    参数值会从传给数据源 refresh() 方法的 Map<String, Object> 对象中获取。示例:

    <collectionDatasource id="ordersDs" class="com.sample.sales.entity.Order" view="_local">
        <query>
            <![CDATA[select o from sales$Order o where o.number = :custom$number]]>
        </query>
    </collectionDatasource>
    ordersDs.refresh(ParamsMap.of("number", "1"));

    如果需要的话,这里也会将实体实例转成标识符,这个机制跟 ds 前缀里面描述的类似。但是这里不支持实体关系图路径。

  • param 前缀

    参数值从传递给界面控制器 init() 方法的 Map<String, Object> 对象中获取。示例:

    <query>
        <![CDATA[select e from sales$Order e where e.customer = :param$customer]]>
    </query>
    openWindow("sales$Order.lookup", WindowManager.OpenType.DIALOG, ParamsMap.of("customer", customersTable.getSingleSelected()));

    如果需要的话,这里也会将实体实例转成标识符,这个机制跟 ds 前缀里面描述的类似。这里也支持使用实体关系图路径的参数名称。

  • component 前缀

    使用一个可视化组件当前的值作为参数值,通过参数名称来定义组件路径。示例:

    <query>
        <![CDATA[select o from sales$Order o where o.number = :component$filter.orderNumberField]]>
    </query>

    组件的路径需要包含所有嵌套的界面子框架

    如果需要的话,这里也会将实体实例转成标识符,这个机制跟 ds 前缀里面描述的类似。这里也支持使用实体关系图路径的参数名称,但是实体属性的路径要继续添加在组件路径之后。

    数据源不会因为组件的值改变而自动刷新。

  • session 前缀

    用户会话的属性中获取跟参数名称相同的属性值作为参数值。

    通过 UserSession.getAttribute() 方法获取这个值,所以会话中预定义的名称都支持。

    • userId – 当前注册用户或者被替代用户的 ID;

    • userLogin – 当前注册用户或者被替代用户的用户名(英文小写)。

    示例:

    <query>
        <![CDATA[select o from sales$Order o where o.createdBy = :session$userLogin]]>
    </query>

    如果需要的话,这里也会将实体实例转成标识符,这个机制跟 ds 前缀里面描述的类似。但是这里不支持实体关系图路径。

3.6.2.2.3. 查询条件过滤

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

根据用户输入的条件不同,可以在运行时改变数据源的查询结果。因而可以有效的在数据库级别做数据过滤。

提供此功能最简单的方法就是将数据源连接到一个特殊的可视化组件上:过滤器控件

如果因为某些原因,全局的过滤器不太合适,可以在查询语句的文本中嵌入一个特殊的 XML 标记。通过这个标记可以根据用户在界面的可视化组件中输入的值进行过滤。

此过滤器中可以使用以下 XML 元素:

  • filter – 过滤器的根节点元素。这个元素只能直接包含一个条件

    • and, or – 逻辑条件,可以包含任意数量的其它条件和语句。

    • c – JPQL 条件,会被添加在查询语句的 where 部分。如果此查询语句不包含 where 从句,则会被添加在第一个条件前面。可以通过一个可选的 join 属性来指定需要关联查询的实体,join 属性的值会被原封不懂的添加到查询的主实体之后,所以 join 属性的内容需要包含必要的 join 关键字或者逗号。

条件和语句只有在相应的参数有值的时候才会被添加到最终形成的查询语句中,比如当这些值不是 null 的时候。

只能在查询过滤器中使用 customparamcomponentsession 这四个参数。ds 参数有可能会出问题。

示例:

<query>
    <![CDATA[select distinct d from app$GeneralDoc d]]>
    <filter>
        <or>
            <and>
                <c join=", app$DocRole dr">dr.doc.id = d.id and d.processState = :custom$state</c>
                <c>d.barCode like :component$barCodeFilterField</c>
            </and>
            <c join=", app$DocRole dr">dr.doc.id = d.id and dr.user.id = :custom$initiator</c>
        </or>
    </filter>
</query>

上面的例子中,如果给数据源的 refresh() 方法传递了 stateinitiator 参数,并且 barCodeFilterField 这个可视化组件也有值,那么组成的查询语句会是这样:

select distinct d from app_GeneralDoc d, app_DocRole dr
where
(
  (dr.doc.id = d.id and d.processState = :custom$state)
  and
  (d.barCode like :component$barCodeFilterField)
)
or
(dr.doc.id = d.id and dr.user.id = :custom$initiator)

但是如果 barCodeFilterField 组件是空的,并且只有 initiator 参数传给了 refresh() 方法,那么组成的语句会是这样:

select distinct d from app_GeneralDoc d, app_DocRole dr
where
(dr.doc.id = d.id and dr.user.id = :custom$initiator)
3.6.2.2.4. 不区分大小写查找

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

在数据源中可以使用 JPQL 查询语句的一个特殊功能,在 Middleware 层级的 Query 接口中有描述:可以使用 (?i) 前缀来轻松创建大小写不敏感的包含任意子串的查询条件。但是由于查询的值通常是显式传入的,所以会有以下不同:

  • (?i) 前缀需要放置在参数名称之前而不是放置在参数值内。

  • 参数值会被自动转化成小写。

  • 如果参数值不包含 % 字符,则会在参数值的前后加上 % 字符。

以下示例介绍如何处理下面这个查询语句:

select c from sales_Customer c where c.name like :(?i)component$customerNameField

customerNameField 组件拿到的参数会被自动转化成小写,并且前后放置 % 字符,然后在数据库会执行 lower(C.NAME) like ? 这样的 SQL 查询语句。

注意在这种情况下,按照 NAME 字段在数据中创建的索引将不会起作用。

3.6.2.3. 值数据源

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

通过值数据源可以执行返回纯数值或者数值聚合(aggregates)的查询语句。比如,可以为 customer 做一些数据统计:

select o.customer, sum(o.amount) from demo_Order o group by o.customer

值数据源通过称为 KeyValueEntity 的特殊类型的实体跟其它实体交互。这种类型的实体可以在运行时包含任意数量的属性。所以在上面的例子中,KeyValueEntity 实例会包含两个属性:第一个是 Customer 类型的属性,第二个是 BigDecimal 类型的属性。

值数据源的实现类继承了其它广泛使用的集合数据源类,并且实现了一个特殊的接口:ValueDatasource。下面这个图展示了值数据源的实现类以及它们的基类:

ValueDatasources

ValueDatasource 接口声明了以下方法:

  • addProperty() - 由于这个数据源可以返回带有任意数量属性的实体,可以通过此方法添加期待返回的属性。这个方法接收属性的名称和对应的类型作为参数,类型可以用数据类型或者 Java 类表示。如果是 Java 类的话,那么要求这个类必须是实体类或者是一种数据类型支持的类。

  • setIdName() 是一个可选调用的方法,通过这个方法来定义返回的实体中作为主键的属性。也就是说,数据源中返回的 KeyValueEntity 实例会用这个方法指定的属性作为唯一标识符。否则的话,KeyValueEntity 实例会用随机的 UUID 做主键。

  • getMetaClass() 返回一个动态生成的 MetaClass 接口的实现对象,用来表示当前 KeyValueEntity 实例的元数据。这些元数据是通过之前调用的 addProperty() 来定义的。

值数据源可以在 XML 描述中声明式的使用。对应不同的实现类,有三中 XML 元素:

  • valueCollectionDatasource

  • valueGroupDatasource

  • valueHierarchicalDatasource

值数据源的 XML 定义必须包含 properties 元素,用来定义数据源中包含的 KeyValueEntity 实例的属性(参考上面提到的 addProperty() 方法)。property 元素的顺序需要按照查询语句返回值的顺序排列。比如,在下面的定义中,customer 属性会从 o.customer 列取得值,sum 属性会从 sum(o.amount) 列取得值:

<dsContext>
    <valueCollectionDatasource id="salesDs">
        <query>
            <![CDATA[select o.customer, sum(o.amount) from demo$Order o group by o.customer]]>
        </query>
        <properties>
            <property class="com.company.demo.entity.Customer" name="customer"/>
            <property datatype="decimal" name="sum"/>
        </properties>
    </valueCollectionDatasource>
</dsContext>

值数据源设计只能用来读取数据,因为 KeyValueEntity 并不是可持久化实体,不能通过标准的持久化机制保存到数据库。

可以手动创建值数据源或者在 Studio 中通过 Screen designer 界面的 Datasources 标签页创建。

ValueDatasources Studio

通过 Properties 编辑器可以创建针对某种数据类型或者 Java 类的数据源属性。

ValueDatasources Studio properties
3.6.2.4. 数据源监听器

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

通过数据源监听器接收数据源及其包含实体的状态变化的消息通知。

一共有四种类型的监听器。其中三个,ItemPropertyChangeListenerItemChangeListenerStateChangeListener 都是定义在 Datasource 接口中的,可以在任何数据源使用。CollectionChangeListener 定义在 CollectionDatasource 里,只能在集合数据源使用。

跟 GUI 的 ValueChangeListener 相比,数据源的监听器在界面的生命周期之上提供了更好的控制,建议在界面有绑定数据源的可视化组件的情况下使用。

使用数据源监听器的示例:

public class EmployeeBrowse extends AbstractLookup {

    private Logger log = LoggerFactory.getLogger(EmployeeBrowse.class);

    @Inject
    private CollectionDatasource<Employee, UUID> employeesDs;

    @Override
    public void init(Map<String, Object> params) {
        employeesDs.addItemPropertyChangeListener(event -> {
            log.info("Property {} of {} has been changed from {} to {}",
                    event.getProperty(), event.getItem(), event.getPrevValue(), event.getValue());
        });

        employeesDs.addStateChangeListener(event -> {
            log.info("State of {} has been changed from {} to {}",
                    event.getDs(), event.getPrevState(), event.getState());
        });

        employeesDs.addItemChangeListener(event -> {
            log.info("Datasource {} item has been changed from {} to {}",
                    event.getDs(), event.getPrevItem(), event.getItem());
        });

        employeesDs.addCollectionChangeListener(event -> {
            log.info("Datasource {} content has been changed due to {}",
                    event.getDs(), event.getOperation());
        });
    }
}

以下介绍上面用到的监听器接口:

  • ItemPropertyChangeListener 通过 Datasource.addItemPropertyChangeListener() 方法添加。当数据源包含的实体的一个属性值发生改变的时候,会触发这个监听。可以通过传递给监听器的 event 对象获取实体本身的实例、改变的属性名称以及该属性的新旧值。

    ItemPropertyChangeListener 可以对通过界面组件修改实体内容而引起变化的情况作出反应,比如,当用户修改了文本输入框的内容。

  • ItemChangeListener 通过 Datasource.addItemChangeListener() 方法添加。当通过 Datasource.getItem() 方法返回的选中的实体发生改变时触发。

    对于 Datasource 的情况,当另外一个实例(或者 null)通过 setItem() 方法赋值给数据源的时候会触发此事件。

    对于 CollectionDatasource 的情况,当在关联的可视化组件中,选中的元素变化的时候会触发此事件。比如,可以是选中的表格的一行,树的一个节点或者下拉列表中的一个元素。

  • StateChangeListener 通过 Datasource.addStateChangeListener() 方法添加。当数据源的状态发生变化时触发。数据源的状态可以是 Datasource.State 枚举类型对应的三种状态之一:

    • NOT_INITIALIZED – 数据源刚被创建。

    • INVALID – 数据源关联的整个 DsContext 刚创建。

    • VALID – 数据源可用状态,此时,Datasource 包含了一个实体或者 null,CollectionDatasource 则是包含了一组实体实例或者一个空的集合。

    对于复杂的编辑界面来说,接收数据源状态变化的消息通知可能很重要,因为复杂的编辑界面中通常包含了好几个界面子框架,所以有时候很难跟踪到设置编辑实体到数据源的时刻。在这种情况下,就可以用 StateChangeListener 来做界面中某些元素的延时初始化:

    employeesDs.addStateChangeListener(event -> {
        if (event.getState() == Datasource.State.VALID)
            initDataTypeColumn();
    });
  • CollectionChangeListener 通过 CollectionDatasource.addCollectionChangeListener() 方法添加。当数据源中保存的实体集合发生变化的时候触发。event 对象提供 getOperation() 方法返回 CollectionDatasource.Operation 类型的值:REFRESH - 刷新CLEAR - 清空ADD - 添加REMOVE - 删除UPDATE - 更新。这些值反映了引起集合变化的操作。

3.6.2.5. DsContext

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

所有通过声明式方法创建的数据源都在界面的 DsContext 对象中注册。DsContext 的引用可以通过在界面控制器中调用 getDsContext() 方法获得,也可以通过 界面控制器依赖注入 获得。

DsContext 是为以下任务设计的:

  1. 组织数据源的依赖关系,当在一个数据源中设置某个记录(比如,通过 setItem() 方法更改“当前”实例)的时候,引起了另一个关联的数据源的改动。通过这些数据源之间的依赖关系,可以组织界面中可视化组件的主从(master-detail)关系

    数据源之间的依赖关系通过使用带有 ds$ 前缀的查询参数来组织。

  2. 收集所有修改了的实体实例,然后通过一次单一的调用 DataManager.commit() 将数据提交给 Middleware,比如,可以通过这种方式在单一的数据库事务中保存所有数据更改。

    举例说明,假设用户可以在某个界面上编辑 Order 实体以及属于 Order 的一组 OrderLine 实体。Order 实体在 Datasource 中,OrderLine 集合在一个嵌套的 CollectionDatasource 数据源中,这个嵌套的数据源通过 Order.lines 属性创建。

    如果用户更改了 Order 的某些属性并且创建了一个 OrderLine 的新实例,接下来,当界面的改动提交给 DataManager 的时候,两个实体(改动的 Order 和新的 OrderLine)会被同时发送给 Middleware。之后,这两个实体会一起被合并到同一个持久化上下文,最后,在数据库事务提交的时候再被保存到数据库。这样的话可以不需要在 ORM 层指定 cascade 参数,而且也避免了在 @OneToMany 注解描述中提到的问题。

    提交数据库的事务之后,DsContext 会从 Middleware 收到一组保存到数据库的对象实例(如果是乐观锁的情况,至少这些实体的 version 属性会增加),然后将这些收到的实例设置到数据源,替换旧的实体。因此,可以在提交改动之后马上在数据源使用最新的实体实例而不需要再次向 Middleware 和数据库发起额外的刷新请求。

  3. 声明两个监听器:BeforeCommitListenerAfterCommitListener。这两个监听器分别接收提交实体改动之前和之后的消息通知。通过 BeforeCommitListener 可以添加实体集合到 DataManager 然后跟需要提交的数据在一个事务提交。在数据库事务提交之后,可以通过 AfterCommitListener 监听器来获得 DataManager 返回的提交之后的保存的实体。

    这个机制在某些时候很有用,比如一些实体,虽然跟界面元素绑定,但是不受数据源的控制,而且在界面控制器创建和修改。比如,FileUploadField 这个可视化组件,当上传文件完成之后,创建了一个 FileDescriptor 的实例,就可以通过这种机制在 BeforeCommitListener 添加到 CommitContext 跟其它的界面元素一起在提交到数据库。

    在下面的例子中,当界面提交的时候,一个 Customer 的新实例会被发送到 Middleware 然后跟其它修改过的实体一起被提交到数据库:

    protected Customer customer;
    
    protected void createNewCustomer() {
        customer = metadata.create(Customer.class);
        customer.setName("John Doe");
    }
    
    @Override
    public void init(Map<String, Object> params) {
        getDsContext().addBeforeCommitListener(context -> {
            if (customer != null)
                context.getCommitInstances().add(customer);
        }
    }
3.6.2.6. DataSupplier

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 数据组件

DataSupplier – 接口,数据源通过这个接口访问到 Middleware 以便加载和保存实体。接口的标准实现只是简单的做了 DataManager 的代理。界面可以在 window 元素的 dataSupplier 属性定义它自己的 DataSupplier 实现类。

DataSupplier 的引用可以通过注入的方式或者通过 DsContextDatasource 实例获得。这两种方式下,如果有自定义的实现类,则会使用自定义类。

3.6.3. 对话框消息和通知消息(历史版本)

这是旧版本 API。从 7.0 版本开始的新 API,请参考 对话框消息通知消息

对话框消息和通知消息可以用来为用户呈现消息。

对话框消息有标题、关闭按钮并且总是在应用程序主窗口的中间展示。通知消息可以显示在窗口中间或者角落,并且能自动消失。

3.6.3.1. 对话框消息

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 对话框消息

通用对话框

通用对话框可以通过 Frame 接口的 showMessageDialog()showOptionDialog() 方法来调用。由于界面控制器都实现了这个接口,所以可以直接在界面控制器调用这些方法。

  • showMessageDialog() 用来展示一条消息,此方法有下列参数:

    • title – 窗口标题

    • message - 消息内容。对于 HTML 类型的消息,可以使用 HTML 标签来格式化消息内容。使用 HTML 时,确保对数据库取出的数据进行转义保护,避免 web 客户端进行代码注入。在非 HTML 格式的消息中可以使用 \n 来换行。

    • messageType – 消息类型。可能的类型:

      • CONFIRMATIONCONFIRMATION_HTML – 确认窗口。

      • WARNINGWARNING_HTML – 警告窗口

        消息类型的不同只反映在桌面客户端。

        也可以通过参数设置消息类型:

        • width - 窗口宽度。

        • modal - 窗口是否模态弹出。

        • maximized - 对话框是否最大化到整个界面。

        • closeOnClickOutside - 对话框是否可以通过点击界面对话框外面的部分进行关闭。

          显示对话框消息示例:

          showMessageDialog("Warning", "Something is wrong", MessageType.WARNING.modal(true).closeOnClickOutside(true));
  • showOptionDialog() 用来展示消息以及一些用户可以操作的按钮。除了上面提到的 showMessageDialog() 的参数外,这个方法还可以接收一个 action 的数组或者列表,并且会为每个 action 创建一个按钮。当按钮点击后,窗口调用相应操作的 actionPerform() 方法然后关闭。

    对于采用标准名称和图标的按钮来说,使用匿名类继承 DialogAction 很方便,支持使用 DialogAction.Type 枚举类型定义的五种动作:OKCANCELYESNOCLOSE。相应的按钮名称从主语言包中取得。

    下面这个例子是一个有 YesNo 按钮的消息对话框,并且从语言包中获取到当前界面的标题和消息文本:

    showOptionDialog(
        getMessage("confirmCopy.title"),
        getMessage("confirmCopy.msg"),
        MessageType.CONFIRMATION,
        new Action[] {
            new DialogAction(DialogAction.Type.YES, Status.PRIMARY).withHandler(e -> copySettings()),
            new DialogAction(DialogAction.Type.NO, Status.NORMAL)
        }
    );

    DialogActionStatus 参数用来给动作的按钮设置特殊的显示样式。Status.PRIMARY 会使相应的按钮高亮并且被选中。Status 参数也可以省去,这样的话会默认的高亮样式。如果给 showOptionDialog 传递了多个 Status.PRIMARY 的操作,只有第一个动作的按钮会被设置成 cuba-primary-action 样式并且被选中。

文件上传对话框

使用 FileUploadDialog 窗口来提供上传文件到临时存储的基本功能。这个窗口包含了一个可以投放文件的区域,可以通过拖拽的方式从浏览器外将文件投放到指定区域进行上传,同时也提供了一个上传文件的按钮。

gui fileUploadDialog

上传窗口是通过 openWindow() 方法打开的,当上传成功的时候,窗口关闭会返回 COMMIT_ACTION_ID。可以通过 CloseListener 或者 CloseWithCommitListener 监听器来跟踪窗口的关闭动作,然后用 getFileId()getFileName() 方法来取到上传文件的 UUID 和名称。之后可以创建一个 FileDescriptor 对象用来作为这个文件在数据模型层的引用,可以用这个对象来实现其它业务逻辑。

FileUploadDialog dialog = (FileUploadDialog) openWindow("fileUploadDialog", OpenType.DIALOG);
dialog.addCloseWithCommitListener(() -> {
    UUID fileId = dialog.getFileId();
    String fileName = dialog.getFileName();

    FileDescriptor fileDescriptor = fileUploadingAPI.getFileDescriptor(fileId, fileName);
    // your logic here
});

Dialogs 的展示可以使用带 $cuba-window-modal-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。

3.6.3.2. 通知消息

这是旧版本的 API。对于从 7.0 开始的新 API,请参阅 通知消息

通知消息可以通过 Frame 接口的 showNotification() 方法来调用。由于界面控制器都实现了这个接口,所以可以直接在界面控制器调用此方法。

showNotification() 方法有下列参数:

  • caption - 通知文本。对于 HTML 类型的消息,可以使用 HTML 标签来格式化消息内容。使用 HTML 时,确保对数据库取出的数据进行转义保护,避免 web 客户端进行代码注入。在非 HTML 格式的消息中可以使用 \n 来换行。

  • description – 在 caption 下显示的一条可选的描述信息。也可以使用 HTML 和非 HTML 格式。

  • type – 消息类型。可能的类型:

    • TRAY, TRAY_HTML - 在界面右下角显示通知消息,之后会自动消失。

    • HUMANIZED, HUMANIZED_HTML – 显示在界面中间的标准通知消息,之后会自动消失。

    • WARNING, WARNING_HTML – 警告通知消息,点击时消失。

    • ERROR, ERROR_HTML – 错误通知消息,点击时消失。

显示通知消息示例:

showNotification(getMessage("selectBook.text"), NotificationType.HUMANIZED);

showNotification("Validation error", "<b>Date</b> is incorrect", NotificationType.TRAY_HTML);

3.6.4. 集合的标准行为(历史版本)

这是旧版本 API。从 7.0 版本开始的新 API,请参考 列表组件操作

对于 ListComponent 的继承者们(TableGroupTableTreeTableTree),标准的行为是通过 ListActionType 枚举类型来定义的;这些操作的实现类在 com.haulmont.cuba.gui.components.actions 包。

在表格中使用标准行为的示例:

<table id="usersTable" width="100%">
  <actions>
      <action id="create"/>
      <action id="edit"/>
      <action id="remove"/>
      <action id="refresh"/>
  </actions>
  <buttonsPanel>
      <button action="usersTable.create"/>
      <button action="usersTable.edit"/>
      <button action="usersTable.remove"/>
      <button action="usersTable.refresh"/>
  </buttonsPanel>
  <rowsCount/>
  <columns>
      <column id="login"/>
      ...
  </columns>
  <rows datasource="usersDs"/>
</table>

下面详细介绍这些行为:

CreateAction - 创建

CreateAction – 使用 create 标识符的 action。用来创建新的实例并且打开编辑界面。如果在编辑界面成功的提交了一个新的实体实例到数据库,CreateAction 会将这个新的实例添加到表格的数据源,并且在界面上使这个实体成为选中状态。

CreateAction 类中定义了下面这些特殊的方法:

  • setOpenType() 可以设置新实体编辑界面的打开模式。默认 THIS_TAB - 当前标签页

    因为通过其它模式打开编辑界面的需求是很常见的(比如,DIALOG - 对话框 模式),可以在使用声明式方式创建 create 行为的时候,在 action 元素的 openType 属性指定需要的打开模式。通过这种方式可以避免在界面控制器获取 action 引用通过编程的方式设置。示例:

    <table id="usersTable">
      <actions>
          <action id="create" openType="DIALOG"/>
  • setWindowId() 可以设置实体编辑界面的标识符。默认情况下,使用 {entity_name}.edit,比如 sales_Customer.edit

  • setWindowParams() 可以设置传递给编辑界面的 init() 方法的参数。这些参数可以通过 @WindowParam 注解注入到界面控制器中,或者也可以在数据源查询中通过 param$ 前缀直接使用。

  • setWindowParamsSupplier()setWindowParams() 的不同之处在于,这个方法可以在 action 即将要被调用的时候修改编辑窗口的参数值。可以提供新的参数,新的参数会跟 setWindowParams() 方法中提供的参数合并,并且覆盖之前的参数。示例:

    createAction.setWindowParamsSupplier(() -> {
       Customer customer = metadata.create(Customer.class);
       customer.setCategory(/* some value dependent on the current state of the screen */);
       return ParamsMap.of("customer", customer);
    });
  • setInitialValues() 可以设置将要编辑的实体的属性初始化值。这个方法接收一个 Map 对象,键值是属性名称,值为属性值。示例:

    Map<String, Object> values = new HashMap<>();
    values.put("type", CarType.PASSENGER);
    carCreateAction.setInitialValues(values);

    使用创建操作做初始化 章节也提供一个使用 setInitialValues() 的例子。

  • setInitialValuesSupplier()setInitialValues() 的不同之处在于,这个方法可以在 action 即将要被调用的时候修改实体初始化的值。可以提供新的参数,新的参数会跟 setInitialValues() 方法中提供的参数合并,并且覆盖之前的参数。示例:

    carCreateAction.setInitialValuesSupplier(() ->
        ParamsMap.of("type", /* value depends on the current state of the screen */));
  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

    customersTableCreate.setBeforeActionPerformedHandler(() -> {
        showNotification("The new customer instance will be created");
        return isValid();
    });
  • afterCommit() 在新实体成功提交到数据库并且编辑界面关闭之后会调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • setAfterCommitHandler() 提供一个处理函数,在新实体成功提交到数据库并且编辑界面关闭之后会调用此函数。可以通过提供此函数避免创建 action 的子类并重写 afterCommit() 方法。示例:

    @Named("customersTable.create")
    private CreateAction customersTableCreate;
    
    @Override
    public void init(Map<String, Object> params) {
        customersTableCreate.setAfterCommitHandler(new CreateAction.AfterCommitHandler() {
            @Override
            public void handle(Entity entity) {
                showNotification("Committed", NotificationType.HUMANIZED);
            }
        });
    }
  • afterWindowClosed() 不管实体是否提交,只要关闭了编辑界面就会最后调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • setAfterWindowClosedHandler() 提供一个处理函数,不管实体是否提交,只要关闭了编辑界面就会最后调用此函数。可以通过提供此函数避免创建 action 的子类并重写 afterWindowClosed() 方法。

EditAction - 编辑

EditAction 是使用 edit 标识符的 action,用来为选中的实体实例打开编辑界面。如果编辑界面成功的将实例保存到数据库,EditAction 会更新表格数据源中的实例。

EditAction 类中定义了下面这些特殊的方法:

  • setOpenType() 可以设置实体编辑界面的打开模式。默认 THIS_TAB - 当前标签页

    因为通过其它模式打开编辑界面的需求是很常见的(比如,DIALOG - 对话框 模式),可以在使用声明式方式创建 edit 行为的时候,在 action 元素的 openType 属性指定需要的打开模式。通过这种方式可以避免在界面控制器获取 action 引用通过编程的方式设置。示例:

    <table id="usersTable">
      <actions>
          <action id="edit" openType="DIALOG"/>
  • setWindowId() 可以设置实体编辑界面的标识符。默认情况下,使用 {entity_name}.edit,比如 sales_Customer.edit

  • setWindowParams() 可以设置传递给编辑界面的 init() 方法的参数。这些参数可以通过 @WindowParam 注解注入到界面控制器中,或者也可以在数据源查询中通过 param$ 前缀直接使用。

  • setWindowParamsSupplier()setWindowParams() 的不同之处在于,这个方法可以在 action 即将要被调用的时候修改编辑窗口的参数值。可以提供新的参数,新的参数会跟 setWindowParams() 方法中提供的参数合并,并且覆盖之前的参数。示例:

    customersTableEdit.setWindowParamsSupplier(() ->
        ParamsMap.of("category", /* some value dependent on the current state of the screen */));
  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

    customersTableEdit.setBeforeActionPerformedHandler(() -> {
        showNotification("The customer instance will be edited");
        return isValid();
    });
  • afterCommit() 在新实体成功提交到数据库并且编辑界面关闭之后会调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • setAfterCommitHandler() 提供一个处理函数,在新实体成功提交到数据库并且编辑界面关闭之后会调用此函数。可以通过提供此函数避免创建 action 的子类并重写 afterCommit() 方法。示例:

    @Named("customersTable.edit")
    private EditAction customersTableEdit;
    
    @Override
    public void init(Map<String, Object> params) {
        customersTableEdit.setAfterCommitHandler(new EditAction.AfterCommitHandler() {
            @Override
            public void handle(Entity entity) {
                showNotification("Committed", NotificationType.HUMANIZED);
            }
        });
    }
  • afterWindowClosed() 不管实体是否提交,只要关闭了编辑界面就会最后调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • setAfterWindowClosedHandler() 提供一个处理函数,不管实体是否提交,只要关闭了编辑界面就会最后调用此函数。可以通过提供此函数避免创建 action 的子类并重写 afterWindowClosed() 方法。

  • getBulkEditorIntegration() 为表格批量编辑提供了可能性。表格需要设置 multiselect 属性启用。当表格选中多行的时候,如果触发 EditAction 行为,则会打开批量编辑器组件进行批量编辑。

    返回的 BulkEditorIntegration 实例可以通过下面的方法进行进一步处理:

    • setOpenType(),

    • setExcludePropertiesRegex(),

    • setFieldValidators(),

    • setModelValidators(),

    • setAfterEditCloseHandler().

    @Named("clientsTable.edit")
    private EditAction clientsTableEdit;
    
    @Override
    public void init(Map<String, Object> params) {
        super.init(params);
    
        clientsTableEdit.getBulkEditorIntegration()
            .setEnabled(true)
            .setOpenType(WindowManager.OpenType.DIALOG);
    }

RemoveAction - 删除

RemoveAction - 是使用 remove 标识符的 action,用来删除选中的实体实例。

RemoveAction 类中定义了下面这些特殊的方法:

  • setAutocommit() 可以控制从数据库删除的动作是否提交。默认情况下,在动作触发之后会调用 commit() 提交从数据库删除实体。可以通过 setAutocommit() 方法或者设置构造器中对应的参数将 autocommit 属性设置为 false 来禁用自动提交。这样的话,需要显式调用数据源的 commit() 方法来提交改动。

    autocommit 的值不会影响 Datasource.CommitMode.PARENT 模式下的数据源,比如,提供组合实体编辑的数据源。

  • setConfirmationMessage() 设置删除数据确认窗口的信息文本。

  • setConfirmationTitle() 设置删除确认窗口的标题。

  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

    customersTableRemove.setBeforeActionPerformedHandler(() -> {
        showNotification("The customer instance will be removed");
        return isValid();
    });
  • afterRemove() 当实体被成功删除之后,调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • setAfterRemoveHandler() 提供一个处理函数,在实体成功从数据库删除之后会调用此函数。可以通过提供此函数避免创建 action 的子类并重写 afterRemove() 方法。示例:

    @Named("customersTable.remove")
    private RemoveAction customersTableRemove;
    
    @Override
    public void init(Map<String, Object> params) {
        customersTableRemove.setAfterRemoveHandler(new RemoveAction.AfterRemoveHandler() {
            @Override
            public void handle(Set removedItems) {
                showNotification("Removed", NotificationType.HUMANIZED);
            }
        });
    }

RefreshAction - 刷新

RefreshAction - 是使用 refresh 标识符的 action。用来更新(重新加载)实体集合。当触发时,这个动作会调用相应组件绑定的数据源中的 refresh() 方法。

RefreshAction 类中定义了下面这些特殊的方法:

  • setRefreshParams() 可以设置传递给 CollectionDatasource.refresh() 方法的参数,这些参数之后会用在数据源查询中。默认情况不会带任何参数。

  • setRefreshParamsSupplier()setRefreshParams() 的不同之处在于,这个方法可以在 action 即将要被调用的时候修改编辑窗口的参数值。可以提供新的参数,新的参数会跟 setRefreshParams() 方法中提供的参数合并,并且覆盖之前的参数。示例:

    customersTableRefresh.setRefreshParamsSupplier(() ->
        ParamsMap.of("number", /* some value dependent on the current state of the screen */));

AddAction - 添加

AddAction – 是使用 add 标识符的 action,用来选择一个已存在的实体并添加到集合中。当触发时,会打开实体的查找界面

AddAction 类中定义了下面这些特殊的方法:

  • setOpenType() 可以设置实体选择界面的打开模式。默认 THIS_TAB - 当前标签页

    因为通过其它模式打开查找界面的需求是很常见的(比如,DIALOG - 对话框 模式),可以在使用声明式方式创建 add 行为的时候,在 action 元素的 openType 属性指定需要的打开模式。通过这种方式可以避免在界面控制器获取 action 引用通过编程的方式设置。示例:

    <table id="usersTable">
        <actions>
            <action id="add" openType="DIALOG"/>
  • setWindowId() 可以设置实体查找界面的标识符。默认情况下,使用 {entity_name}.lookup,比如 sales_Customer.lookup。如果不存在这种类型的界面,则会尝试打开 {entity_name}.browse 界面,比如 sales_Customer.browse

  • setWindowParams() 可以设置传递给查找界面的 init() 方法的参数。这些参数可以通过 @WindowParam 注解注入到界面控制器中,或者也可以在数据源查询中通过 param$ 前缀直接使用。

  • setWindowParamsSupplier()setWindowParams() 的不同之处在于,这个方法可以在 action 即将要被调用的时候修改编辑窗口的参数值。可以提供新的参数,新的参数会跟 setWindowParams() 方法中提供的参数合并,并且覆盖之前的参数。示例:

    tableAdd.setWindowParamsSupplier(() ->
        ParamsMap.of("customer", getItem()));
  • setHandler() 可以设置一个实现了 Window.Lookup.Handler 接口的对象,这个对象会传递给查找界面。默认情况下,会使用 AddAction.DefaultHandler 对象。

  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

    customersTableAdd.setBeforeActionPerformedHandler(() -> {
        notifications.create()
                .withCaption("The new customer will be added")
                .show();
        return isValid();
    });

ExcludeAction - 排除

ExcludeAction - 是使用 exclude 标识符的 action。允许用户从一个实体集合中排除实体实例,而并不会从数据库删除。这个 action 的类是继承于 RemoveAction,但是在触发这个动作的时候调用的是 CollectionDatasource 里的 excludeItem() 而不是 removeItem()。此外,对于嵌套数据源中的实体,ExcludeAction 动作会将子实体跟父实体的连接断开。因此这个 action 可以用来编辑一对多的关联关系。

除了 RemoveAction 里面的方法外,ExcludeAction 类中定义了下面这些特殊的方法:

  • setConfirm() – 定义是否要显示确认删除窗口。也可以通过 action 的构造器设置这个参数。默认值是 false

  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

customersTableExclude.setBeforeActionPerformedHandler(() -> {
    showNotification("The selected customer will be excluded");
    return isValid();
});

ExcelAction - 导出 Excel

ExcelAction - 是使用 excel 标识符的 action。用来将表格数据导出成 XLS 格式的文件,并且下载。只能在 TableGroupTableTreeTable 组件添加此行为。

当通过编程的方式创建这个行为的时候,可以用实现了 ExportDisplay 接口的类为文件下载设置 display 参数。默认情况下使用标准的实现类。

ExcelAction 类中定义了下面这些特殊的方法:

  • setFileName() - 设置 Excel 文件名称,不包含文件名后缀。

  • getFileName() - 返回 Excel 文件名称,不包含文件名后缀。

  • setBeforeActionPerformedHandler() 可以提供一个处理函数,这个函数在 action 执行之前调用。这个函数返回值是 true 的话,action 会继续执行;返回 false 终止执行。示例:

    customersTableExcel.setBeforeActionPerformedHandler(() -> {
        showNotification("The selected data will ve downloaded as an XLS file");
        return isValid();
    });

3.6.5. 选取器控件的标准行为(历史版本)

这是旧版本 API。从 7.0 版本开始的新 API,请参考 选取器控件操作

对于 PickerFieldLookupPickerFieldSearchPickerField 组件,一组标准行为是通过 PickerField.ActionType 枚举类型来定义的;操作的实现是 PickerField 接口的内部类,下面详细介绍这些实现。

在选取器组件中使用标准操作的示例:

<searchPickerField optionsDatasource="coloursDs"
                 datasource="carDs" property="colour">
  <actions>
      <action id="clear"/>
      <action id="lookup"/>
      <action id="open"/>
  </actions>
</searchPickerField>

LookupAction - 查找

LookupAction – 使用 lookup 标识符的 action。用来选取实体实例然后将选中的实体设置成组件的值。当触发这个动作的时候,会打开实体的查找界面

LookupAction 类中定义了下面这些特殊的方法:

  • setLookupScreenOpenType() 可以设置实体选择界面的打开模式。默认 THIS_TAB - 当前标签页

  • setLookupScreen() 可以设置实体查找界面的标识符。默认情况下,使用 {entity_name}.lookup,比如 sales_Customer.lookup。如果不存在这种类型的界面,则会尝试打开 {entity_name}.browse 界面,比如 sales_Customer.browse

  • setLookupScreenParams() 可以设置传递给查找界面的 init() 方法的参数。

  • afterSelect() 当选择的实体被设置到可视化组件的值之后,调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

  • afterCloseLookup() 不管是否选择了实体,只要关闭了查找界面就会最后调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

ClearAction - 清空

ClearAction - 使用 clear 标识符的 action。用来清空组件的值(比如设置成 null)。

OpenAction - 打开

OpenAction - 使用 open 标识符的 action。用来打开编辑组件当前值关联实体的编辑界面。

OpenAction 类中定义了下面这些特殊的方法:

  • setEditScreenOpenType() 可以设置实体编辑界面的打开模式。默认 THIS_TAB - 当前标签页

  • setEditScreen() 可以设置实体编辑界面的标识符。默认情况下,使用 {entity_name}.edit,比如 sales_Customer.edit

  • setEditScreenParams() 可以设置传递给编辑界面的 init() 方法的参数。

  • afterWindowClosed() 关闭了编辑界面之后调用此方法。这个方法本身没有实现,可以通过继承重写此方法来处理这个事件。

3.6.6. screens.xml (历史版本)

使用从 v.7.0 新 API 写的界面不需要注册。而是通过 @UiController 注解自动发现。

这种类型的文件是在 Web 客户端的通用用户界面中用来注册界面的 XML 描述。

文件路径通过 cuba.windowConfig 应用程序属性指定。当在 Studio 创建新项目时,会在 web 模块包的根目录创建 web-screens.xml 文件,比如 modules/web/src/com/company/sample/web-screens.xml

这个文件有如下结构:

screen-config – 根节点元素。包含如下元素:

  1. screen – 界面描述元素。

    screen 属性:

    • id – 界面标识符,可以使用程序代码的标识符(比如在 Frame.openWindow() 和其它方法中),也可以是用 menu.xml 中的标识符。

    • template – 界面 XML 描述路径。使用 Resources 接口规则用来加载这些描述文件。

    • class – 如果没设置 template 属性,使用此属性指定实现了 Callable 或者 Runnable 接口的类。

      如果是实现了 Callable 接口,call() 方法应当返回一个 Window 的实例,这个实例会返回给调用段代码,作为 WindowManager.openWindow() 的结果。这个类可以包含带有字符串参数的构造器,通过嵌套的 param 元素定义(参考下面)。

    • agent - 如果使用同一个 id 注册了多个模板(templates),使用这个属性来选择打开哪个模板。有三种标准的代理(agent)类型:DESKTOP 桌面, TABLET 平板, PHONE 手机。这些类型支持按照当前设备和显示参数选择界面模板。参考界面代理了解细节。

    • multipleOpen – 可选属性,允许设置界面可以多次打开。如果没设置或者设置成 false,并且由此标识符定义的界面已经在主窗口打开了,系统会显示已经存在的界面,而不会打开一个新的。如果设置成 true,则可以打开任意数量的界面。

    screen 的元素:

    • param – 以 map 的形式定义界面参数传入控制器init() 方法。而通过调用 openWindow() 方法传递的参数,会覆盖 screens.xml 中定义的相应名称的参数。

      param 属性:

      • name – 参数名称

      • value – 参数值。字符串类型,如果是 true 或者 false,则会被转换成相应的 Boolean 值。

  2. include – 包含一个不通的文件,比如 screens.xml

    include 属性:

    • file – 按照 Resources 接口规则定义的文件路径。

screens.xml 文件示例:

<screen-config xmlns="http://schemas.haulmont.com/cuba/screens.xsd">

  <screen id="sales_Customer.lookup" template="/com/sample/sales/gui/customer/customer-browse.xml"/>
  <screen id="sales_Customer.edit" template="/com/sample/sales/gui/customer/customer-edit.xml"/>

  <screen id="sales_Order.lookup" template="/com/sample/sales/gui/order/order-browse.xml"/>
  <screen id="sales_Order.edit" template="/com/sample/sales/gui/order/order-edit.xml"/>

</screen-config>

3.7. 前端用户界面

在 CUBA 平台中,除了 通用 UI之外,我们还提供 Frontend UI。通用 UI 主要设计为后台用户(内部维护管理人员等)使用,而 Frontend UI 的目标用户则是外部用户,因此 Frontend UI 在布局自定义方面更加灵活。并且与通用 UI 使用的技术不同,更适合前端开发人员,非常容易集成 JavaScript 生态内的 UI 库和组件。但是,要求开发人员对现代前端技术栈理解更深。

Frontend UI 可使用下列技术之一:

  • React

  • React Native

  • Polymer(废弃)

  • 您喜欢的其他框架(Angular 或 Vue):可以使用与框架无关的 Typescript SDK

我们提供了几个工具和库,为了方便您构建 Frontend UI 应用程序(或称之为 frontend client - 前端客户端):

Frontend Generator

The Frontend Generator 是一个脚手架工具,可以用来加速前端客户端的开发。可以在 Studio 中使用,也可以作为单独的 CLI 工具使用。用这个工具可以生成初始化 app 项目,也可以用来添加组件,比如实体浏览和编辑界面。还可以用来生成 Typescript SDK。

React 和 React Native 适用的组件和工具库

对于由 Frontend Generator 生成的 React 和 React Native 客户端,代码中用到了两个库,这两个库也可以独立使用。一个是 CUBA React Core ,这个库主要负责一些核心功能,比如操作 CUBA 实体。这个库在 React 和 React Native 都用到了。另一个是 CUBA React UI ,这个库包含了 UI 组件。使用 Ant Design UI 套件开发,在 React 端使用。

CUBA REST JS 库

CUBA REST JS 是一个用来与 后台通用 REST API 交互的库。前端客户端通过 REST API 与中间件通信。但是,不需要手动在代码里发送这些请求。如果您的客户端是用 React 或者 React Native 开发,那么 CUBA React Core 组件会在底层使用 CUBA REST JS 与后台通信。如果您使用其他前端框架,或者希望更加灵活的处理这些请求,那么可以直接使用 CUBA REST JS。

TypeScript SDK

TypeScript SDK 是由 Frontend Generator 生成,主要包含项目中实体、视图以及其他用于访问 REST 服务的 TypeScript 代码。SDK 可以用在任何您喜欢的前端框架中(如果您选择的框架并不是 CUBA 开箱即支持的话),也可以用在基于 Node.js 的 BFF(Backend for Frontend - 服务前端的后端) 开发中。

如需学习关于这些工具的更多内容,请参阅 Frontend UI manual

3.7.1. 使用 Studio 添加前端 UI

参考 CUBA Studio 用户手册Frontend UI 部分。

3.7.2. 基于 React 的用户界面

参考 Frontend UI 手册

3.7.3. Polymer 用户界面(废弃)

从平台 7.2 开始,Polymer UI 会被废弃,推荐使用 React UI

参考 Frontend UI 手册

3.8. Portal 组件

在本手册中,portal 是一个客户端 block,这个客户端主要用来:

  • 提供另一个可选的 web 页面,一般是为组织外部的用户使用;

  • 提供集成手机应用和第三方系统的接口。

一个具体的应用程序可能因为不同的目的而设计几个不同的 portal 模块;比如,在一个出租车业务自动化的应用中,可能有一个对外部用户的公共 web 网站,还有一个手机应用中集成的模块用来预约出租车,以及一个给司机用的手机应用集成的界面,等等。

cuba 应用程序组件包含的 portal 模块,是一个用来在项目中创建 portal 的模板。提供了客户端 block 跟 Middleware 交互的基本功能。另外,全局 REST API 作为依赖被包含在 portal 模块中,并且默认开启。

下面是平台 portal 模块提供的主要组件的简介。

  • PortalAppContextLoader – 用来加载 AppContext;必须在 web.xml 文件的 listener 元素中注册。

  • PortalDispatcherServlet – 分发请求到 Spring MVC 控制器的主要 servlet,包括分发 web 页面请求和 REST API 请求。Spring 上下文配置文件通过 cuba.dispatcherSpringContextConfig 应用程序属性定义。servlet 必须在 web.xml 注册并且映射到 web 应用的 URL 根节点。

  • App – 包含当前 HTTP 请求信息和 Connection 对象引用的对象。App 实例可以通过在应用程序代码中调用 App.getInstance() 静态方法来取得。

  • Connection – 允许用户登入/登出 Middleware。

  • PortalSession – portal 特定的用户会话对象。通过 UserSessionSource 基础接口以及 PortalSessionProvider.getUserSession() 静态方法返回。

    它有一个额外的 isAuthenticated() 方法,如果会话属于一个非匿名用户,则会返回 true。比如,用户使用用户名密码登录的时候。

    当用户第一次访问 portal 的时候,SecurityContextHandlerInterceptor 会为此用户先创建一个匿名会话(或者绑定到已经存在的匿名会话),这个匿名会话是通过使用指定在 cuba.portal.anonymousUserLogin 参数的匿名用户名在 Middleware 注册得到的。这个注册是通过 loginTrusted() 方法完成,所以也需要在 portal 设置 cuba.trustedClientPassword 参数。因此 portal 的任何匿名用户可以使用 cuba.portal.anonymousUserLogin 用户权限跟 Middleware 交互。

    如果 portal 包含使用用户名和密码的用户注册界面,SecurityContextHandlerInterceptor 会在 Connection.login() 执行后,将这个注册用户的会话传递给执行业务逻辑的线程,从而使得接下来跟 Middleware 的交互是以此用户的名义发出的。

  • PortalLogoutHandler – 负责导航到登出页面。需要在 portal-security-spring.xml 项目文件中注册。

3.9. 平台功能

本节介绍平台提供的各种可选功能

3.9.1. 动态属性

Dynamic attributes - 动态属性 是额外的实体属性,可以在不需要修改数据库表结构或者重启应用的情况下为实体添加。动态属性通常用来在部署或者生产阶段为实体定义新属性。

CUBA 动态属性实现了 Entity-Attribute-Value 模型。

dynamic attributes
Figure 32. 动态属性类关系图
  • Category - 定义对象的一个 category - 类别 以及相应的一组动态属性。这个类别必须分配给某些实体类型。

    比如,有个实体是 Car 类型的。可以为它定义两个类别:卡车(Truck)和客车(Passenger)。卡车类别需要包含载重量和车身类型属性,客车类别需要包含座位数和儿童座位属性。

  • CategoryAttribute - 定义关联某些类别的动态属性。每个属性需要为一个明确类型描述一个字段。必要的 Code 字段包含属性的系统名称。Name 字段包含具有可读性的名称。

  • CategoryAttributeValue - 特定实体实例动态属性的值。动态属性值物理存储在专门的 SYS_ATTR_VALUE 表内。表的每一行都有指向某些实体的 id(ENTITY_ID 列)。

一个实体实例可以拥有跟这个实体类型相关的所有类别的动态属性。所以如果按照上面创建了两个关于 Car 的类别,则可以为一个 Car 实例定义两种类别中的任何动态属性。如果需要将实体实例分类到具体的某一类别(比如 Car 可以是卡车或者客车),那么实体需要实现 Categorized 接口。这样实体实例就会有指向类别的引用,也只能包含此类别的动态属性。

加载和保存动态属性是通过 DataManager 来处理的。LoadContextsetLoadDynamicAttributes() 方法或流式 API 的 dynamicAttributes() 方法可以用来设置是否需要为实体实例加载动态属性。默认情况下,不会加载动态属性。同时,DataManager 总是会保存传递给 commit() 方法的实体实例的动态属性。

对于任何继承自 BaseGenericIdEntity 的持久化实体,动态属性的值可以通过 getValue() / setValue() 方法来读写。此时,需要给方法传递一个带 + 前缀的属性 code。示例:

Car entity = dataManager.load(Car.class).id(carId).dynamicAttributes(true).one;

Double capacity = entity.getValue("+loadCapacity");
entity.setValue("+loadCapacity", capacity + 10);

dataManager.commit(entity);

事实上,在应用中直接访问动态属性是很少用到的。任何动态属性都可以在绑定了包含动态属性的实体数据源的任何表格或者表单中自动展示。下面说的属性编辑器可以用来指定需要显示动态属性的界面和组件。

访问动态属性的用户权限可以在安全角色编辑器中跟普通属性一样配置。动态属性显示为带 + 前缀的属性。

3.9.1.1. 管理动态属性

可以在 Administration>Dynamic Attributes 界面管理动态属性。界面的左边有类别列表,右边是属于选中分类的属性。

如果要给一个实体创建动态属性,首先需要创建一个分类。如果该实体类实现了 Categorized 接口,分类编辑器里面的 Default 复选框表示该分类会自动选为新实例的类型。如果实体没有实现 Categorized 接口,则不会用复选框的值,你可以自己为该实体创建单一类型,或者创建多个类型 - 实体的所有属性都会按照动态属性可见性设置展示。

在修改了动态属性配置之后,点击分类浏览部分的 Apply settings 按钮。改动也可以通过菜单的 Administration > JMX Console 调用 app-core.cuba:type=CachingFacade JMX bean 的 clearDynamicAttributesCache() 方法应用。

下面是类别编辑器的界面示例:

categoryEditor
Figure 33. 类别编辑界面

如果应用程序支持多种语言,则会显示 Name localization 分组框。它允许为每个可用的语言环境设置类别的本地化名称。

categoryLocalization
Figure 34. 本地化类别名称

Attributes Location 标签页,可以在 DynamicAttributesPanel 内设置每个动态属性的位置。

dynamic attributes location
Figure 35. 设置动态属性的位置

Columns count 下拉列表中指定列的数量。如要更改属性的位置,从属性列表拖拽该属性放置到目的行列的位置。也可以添加空的单元格或者更改属性的顺序。做完更改后,点击 Save configuration 按钮。

实体编辑器的 DynamicAttributesPanel 面板中属性的位置:

dynamic attributes location rezult

动态属性编辑界面可以设置属性的名称、系统代码、值类型、属性的默认值,以及验证脚本。

runtimePropertyEditor
Figure 36. 动态属性编辑界面

对于除 Boolean 以外的所有值类型,都有一个 Width 字段可用于设置 Form 中的字段宽度(以像素为单位或百分比)。如果 Width 字段为空,则假定其值为 100%。

对于除 Boolean 之外的所有值类型,还有一个 Is collection 复选框。允许为所选类型创建多值动态属性。

对于所有的数字类型:DoubleFixed-point numberInteger - 可以用下列字段: * Minimum value – 当输入属性值时,会检查属性值必须大于等于指定的最小值。 * Maximum value – 当输入属性值时,会检查属性值必须小于等于指定的最大值。

对于 Fixed-point number 值类型,可以使用 Number format pattern 字段设置格式模板。模板按照 DecimalFormat 介绍的规则设置。

对于所有的值类型,可以在 Validation script 字段设置脚本用于验证用户输入的值。验证逻辑在 Groovy 脚本中。如果 Groovy 验证失败,脚本应当返回一个错误消息。否则,脚本可以不返回任何值或者返回 null。被检查的值在脚本中可以使用 value 变量获取。错误消息使用一个 Groovy 字符串;其中可以用 $value 关键字来生成格式化的消息。

示例:

if (!value.startsWith("correctValue")) return "the value '\$value' is incorrect"

对于 Enumeration 值类型,通过列表编辑器在 Enumeration 字段中定义命名值集合。

runtimePropertyEnum
Figure 37. Enumeration 类型的动态属性编辑界面

每个枚举值可以进行本地化显示设置。

runtimePropertyEnumLocalization
Figure 38. Enumeration 类型动态属性本地化设置

对于 StringDoubleEntityFixed-point numberInteger 数据类型,可以使用 Lookup field 复选框。如果设置了该复选框,用户可以从下拉列表中选择属性值。可选值列表可在 Calculated values and options 标签页配置Entity 数据类型会配置 Where 和 Join 语句。

再看看 Calculated values and options 标签页。在 Attribute depends on 字段,可以设置当前属性依赖的其它属性。当改变其中一个依赖属性时,则会重新执行计算该属性值的脚本或者执行计算可能值列表的脚本。

计算属性值的 Groovy 脚本通过 Recalculation value script 字段设置。脚本必须返回一个新的参数值。脚本会收到下面这些参数:

  • entity – 编辑的实体;

  • dynamicAttributes – 一个 map 映射,key – 属性代码,value – 动态属性的值。

dynamic attributes recalculation
Figure 39. 值重算脚本

使用 dynamicAttributes map 重算脚本示例:

if (dynamicAttributes['PassengerNumberofseats'] > 9) return 'Bus' else return 'Passenger car'

脚本会在属性依赖的其它属性中任何一个发生变化时进行调用。

如果定义了脚本,属性的输入字段将变成不可编辑状态。

重算只能在这些 UI 组件有效:FormDynamicAttributesPanel

Options type 字段定义选项加载器的类型,如果 General 标签页的查找控件复选框选中,则必须选择 Options type。如果复选框没有选中,Options type 会不可用。

可用的选项加载器类型:Groovy、SQL、JPQL(仅对于 Entity 数据类型)。

  • Groovy 选项加载器会使用 Groovy 脚本加载值的列表。entity 变量会传递给脚本,因此可以在脚本中使用实体的属性(包括动态属性)。String 类型的属性脚本示例:

    dynamic attributes Groovy options
    Figure 40. Groovy 选项加载器的脚本
  • SQL 选项加载器使用 SQL 脚本加载选项值。可以在脚本中使用 ${entity} 变量访问实体。使用 ${entity.<field>} 访问实体参数,field 是实体参数的名称。+ 前缀可以用来访问实体的动态属性,比如 ${entity.+<field>}。脚本示例(这里我们访问实体和实体的动态属性 Categorytype):

    select name from DYNAMICATTRIBUTESLOADER_TAG
    where CUSTOMER_ID = ${entity}
    and NAME = ${entity.+Categorytype}
  • JPQL 选项加载器只能使用在 Entity 类型的动态属性。JPQL 条件通过 JoinClauseWhere Clause 字段设置。另外,可以使用 Constraint Wizard,能动态创建 JPQL 条件。在 JPQL 参数中可以使用 {entity}{entity.<field>}

所有类型的动态属性都支持本地化:

runtimePropertyLocalization
Figure 41. 动态属性本地化
动态属性的可见性

动态属性还可以设置可见性,定义在哪些界面中显示。默认情况下,动态属性不显示。

runtimePropertyVisibility
Figure 42. 动态属性可见性设置

除了界面之外,还可以为属性指定显示组件(比如,可以在界面中,指定多个Form组件显示同一实体的字段)。

如果该属性在界面上标记为可见,则在界面上用来展示相应实体的所有表单和表格中会自动显示该属性。

对动态属性的访问也受用户角色设置的限制。动态属性的安全设置与常规属性的安全设置类似。

动态属性可以手动添加到界面,给数据加载器添加 dynamicAttributes="true" 属性并使用带 + 前缀的动态属性代码绑定组件:

<data>
    <instance id="carDc" class="com.company.app.entity.Car" view="_local">
        <loader id="carDl" dynamicAttributes="true"/>
    </instance>
</data>
<layout>
    <form id="form"
          dataContainer="carDc">
        <!--...-->
        <textField property="+PassengerNumberofseats"/>
    </form>
3.9.1.2. DynamicAttributesPanel

如果实体实现了 com.haulmont.cuba.core.entity.Categorized 接口,则可以使用 DynamicAttributesPanel 组件来显示该实体的动态属性。此组件允许用户为特定实体实例选择类别,并指定此类别的动态属性的值。

要在编辑界面中使用 DynamicAttributesPanel 组件,请执行以下操作:

  • 在实体中,需要在视图中包含 category 属性:

    <view entity="ref_Car" name="car-view" extends="_local">
        <property name="category" view="_minimal"/>
    </view>
  • data 部分,申明一个InstanceContainer:

    <data>
       <instance id="carDc"
                 class="com.company.ref.entity.Car"
                 view="car-view">
          <loader dynamicAttributes="true"/>
       </instance>
    </data>

    设置 loaderdynamicAttributes 参数为 true,以便加载实体的动态属性。动态属性不是默认加载的。

  • 现在可以将 dynamicAttributesPanel 可视化组件添加在界面的 XML 描述中:

    <dynamicAttributesPanel dataContainer="carDc"
                            cols="2"
                            rows="2"
                            width="AUTO"/>

    可以使用 cols 参数设置展示动态属性的列数。或者也可以使用 rows 来指定行数(但是这种情况下,列数会自动计算)。默认情况下,所有属性会显示在一列内。

    在分类编辑器的 Attributes Location 标签页,可以更灵活的自定义动态属性的位置。如此做的话,colsrows 参数的值会被忽略。

3.9.2. 发送邮件

平台的电子邮件发送提供以下基础功能:

  • 同步或异步发送。在同步发送的情况下,调用代码将一直等待,直到消息被发送到 SMTP 服务器。在异步发送的情况下,消息被持久化到数据库,并且将控制权立即交回给调用代码。实际发送动作稍后由计划任务完成。

  • 对于同步和异步模式,都能可靠地跟踪消息发送的时间戳或数据库中的错误。

  • 提供用户界面,用于搜索和查看有关已发送消息的信息,包括所有消息属性和内容、发送状态和尝试次数。

3.9.2.1. 发送方法

要发送电子邮件,在中间件上应使用 EmailerAPI bean,在客户端层使用 EmailService 服务。

这些组件的基本方法如下所述:

  • sendEmail() – 同步发送消息。调用代码在消息发送到 SMTP 服务器前一直等待。

    消息内容由一组参数(逗号分隔的收件人列表,主题,内容,附件数组)组成,并以 EmailInfo 对象的形式传输,该对象封装了所有这些信息并允许显式设置发件人的地址并使用 FreeMarker 模板构造邮件正文。

    同步发送时可能会抛出 EmailException,其中包含投递失败的收件人地址的信息,以及相应的错误消息。

    执行该方法期间,在数据库中为每个收件人创建一个 SendingMessage 实例。发送状态初始值为 SendingStatus.SENDING,成功发送后状态变为 SendingStatus.SENT。如果消息发送错误,消息状态将更改为 SendingStatus.NOTSENT

  • sendEmailAsync() - 异步发送消息。此方法返回在数据库中创建的 SendingStatus.QUEUE 状态的 SendingMessage 实例的列表(列表数量按收件人数)。实际发送是通过随后调用 EmailerAPI.processQueuedEmails() 方法执行的,需要为 计划任务设置符合期望的发送邮件频率来调用此方法。

3.9.2.2. 电子邮件附件

EmailAttachment 对象是一个包装器,包含附件的信息:字节数组( data 字段)、文件名( name 字段),如有必要,还可在包含邮件中可以使用的附件唯一标识(contentId 字段,虽然是可选字段,但很有用)。

附件标识可用于在邮件消息体中插入图片。为此,在创建 EmailAttachment 时指定了唯一的 contentId (例如,myPic)。使用 cid:myPic 格式的表达式可以在消息体中插入附件路径。因此,要插入图片,可以指定以下 HTML 元素:

<img src="cid:myPic"/>
3.9.2.3. 配置电子邮件发送参数

使用下面列出的应用程序属性配置电子邮件发送参数。它们都是运行时参数并存储在数据库中,但对于特定的 Middleware 模块可以在其 app.properties 文件覆盖这些参数。

所有电子邮件发送参数均可通过 EmailerConfig 配置接口获取。

  • cuba.email.fromAddress – 默认发件人地址。如果没有指定 EmailInfo.from 属性时使用它。

    默认值:DoNotReply@localhost

  • cuba.email.smtpHost – SMTP 服务器的地址。

    默认值:test.host

  • cuba.email.smtpPort – SMTP 服务器的端口。

    默认值:25

  • cuba.email.smtpAuthRequired 标记 SMTP 服务器是否需要身份认证。对应 mail.smtp.auth 参数,该参数在创建 javax.mail.Session 对象时传递。

    默认值:false

  • cuba.email.smtpSslEnabled 标记 SMTP 是否启用了 SSL 协议。对应于带有 smtps 值的 mail.transport.protocol 参数,该值在创建 javax.mail.Session 对象时传递。

    默认值:false

  • cuba.email.smtpStarttlsEnable – 标记在 SMTP 服务器上进行身份验证时使用 STARTTLS 命令。对应 mail.smtp.starttls.enable 参数,该参数在创建 javax.mail.Session 对象时传递。

    默认值:false

  • cuba.email.smtpUser – SMTP 服务器身份验证的用户名。

  • cuba.email.smtpPassword – SMTP 服务器身份验证的用户密码。

  • cuba.email.delayCallCount – 用于邮件的异步发送,以便在服务器启动后跳过对 EmailManager.queueEmailsToSend() 的前几次调用,这样可以减少应用程序初始化期间的负荷。电子邮件发送将从下一次调用开始。

    默认值:2

  • cuba.email.messageQueueCapacity – 用于异步发送,从队列中读取并在调用一次 EmailManager.queueEmailsToSend() 时发送的最大消息数。

    默认值:100

  • cuba.email.defaultSendingAttemptsCount 用于异步发送,发送电子邮件的默认尝试次数。如果在调用 Emailer.sendEmailAsync() 时未指定 attemptsCount 参数则使用它。

    默认值:10

  • cuba.email.maxSendingTimeSec – 将电子邮件发送到 SMTP 服务器所需的最长预期时间(以秒为单位)。用于异步发送,优化从 DB 队列选择 SendingMessage 对象。

    默认值:120

  • cuba.email.sendAllToAdmin – 表示无论指定的收件人地址如何指定,都应将所有消息发送到 cuba.email.adminAddress 地址。建议在系统开发和调式期间使用此参数。

    默认值:false

  • cuba.email.adminAddress – 如果启用 cuba.email.sendAllToAdmin 属性,则所有消息都会被发送到这个地址。

    默认值:admin@localhost

  • cuba.emailerUserLogin –系统用户的登录名,由异步电子邮件发送代码使用,以便能够将信息保存到数据库中。建议创建没有密码的单独的用户(例如,emailer),这样的话此用户不能在用户界面使用用户名登录。这也便于在服务端日志中搜索与电子邮件发送相关的消息。

    默认值:admin

  • cuba.email.exceptionReportEmailTemplateBody - 异常报告邮件正文的 *.gsp 模板路径。

    模板基于 Groovy 的 SimpleTemplateEngine 语法,因此可以在模板内容中使用 Groovy 代码块:

    • toHtml() 方法通过转义和替换特殊符号将字符串转换为 HTML 字符串,

    • timestamp - 最后一次尝试发送电子邮件的日期,

    • errorMessage - 错误消息,

    • stacktrace - 错误的堆栈跟踪,

    • user - User 对象的引用。

    模板文件的示例:

    <html>
    <body>
    <p>${timestamp}</p>
    <p>${toHtml(errorMessage)}</p>
    <p>${toHtml(stacktrace)}</p>
    <p>User login: ${user.getLogin()}</p>
    </body>
    </html>
  • cuba.email.allowutf8 - 如设置为 true。允许在消息头中使用 UTF-8 编码,比如,邮件地址。该属性只有在邮件服务器也支持 UTF-8 时才能设置。对应于 mail.mime.allowutf8 参数,在创建 javax.mail.Session 对象时使用。

    默认值:false

  • cuba.email.exceptionReportEmailTemplateSubject - 异常报告邮件主题的 *.gsp 模板路径。

    模板文件的示例:

    [${systemId}] [${userLogin}] Exception Report

还可以使用 JavaMail API 中的属性,将它们添加到 core 模块的 app.properties 文件中。在创建 javax.mail.Session 对象时将传递 mail.* 属性。

可以使用 app-core.cuba:type=Emailer JMX bean 查看当前参数值并发送测试消息。

3.9.2.4. 发送电子邮件

本节包含使用 CUBA 邮件发送机制发送电子邮件的实用指南。

我们看看以下任务:

  • NewsItem 实体和 NewsItemEdit 界面。

  • NewsItem 实体包含以下属性: datecaptioncontent

  • 我们希望每次通过 NewsItemEdit 界面创建新的 NewsItem 实例时向某些地址发送电子邮件。电子邮件应包含 NewsItem.caption 作为主题,并且应该从包含 NewsItem.content 的模板创建邮件消息正文。

  1. 将以下代码添加到 NewsItemEdit.java

    @UiController("sample_NewsItem.edit")
    @UiDescriptor("news-item-edit.xml")
    @EditedEntityContainer("newsItemDc")
    @LoadDataBeforeShow
    public class NewsItemEdit extends StandardEditor<NewsItem> {
    
        private boolean justCreated; (1)
    
        @Inject
        protected EmailService emailService;
    
        @Inject
        protected Dialogs dialogs;
    
        @Subscribe
        public void onInitEntity(InitEntityEvent<NewsItem> event) { (2)
            justCreated = true;
        }
    
        @Subscribe(target = Target.DATA_CONTEXT)
        public void onPostCommit(DataContext.PostCommitEvent event) { (3)
            if (justCreated) {
                dialogs.createOptionDialog() (4)
                        .withCaption("Email")
                        .withMessage("Send the news item by email?")
                        .withType(Dialogs.MessageType.CONFIRMATION)
                        .withActions(
                                new DialogAction(DialogAction.Type.YES) {
                                    @Override
                                    public void actionPerform(Component component) {
                                        sendByEmail();
                                    }
                                },
                                new DialogAction(DialogAction.Type.NO)
                        )
                        .show();
            }
        }
    
        private void sendByEmail() { (5)
            NewsItem newsItem = getEditedEntity();
            EmailInfo emailInfo = EmailInfoBuilder.create()
                    .setAddresses("john.doe@company.com,jane.roe@company.com") (6)
                    .setCaption(newsItem.getCaption()) (7)
                    .setFrom(null) (8)
                    .setTemplatePath("com/company/demo/templates/news_item.txt") (9)
                    .setTemplateParameters(Collections.singletonMap("newsItem", newsItem)) (10)
                    .build();
            emailService.sendEmailAsync(emailInfo);
        }
    }
    1 - 指示在当前编辑器中是否有新 item。
    2 - 当新 item 初始化时调用此方法。
    3 - data context 提交之后调用此方法。
    4 - 如果新实体保存到数据库,询问用户是否发送邮件。
    5 - 添加邮件至队列,异步发送。
    6 - 收件人
    7 - 标题
    8 - 从 cuba.email.fromAddress 应用程序属性获取 from - 发送 地址
    9 - 邮件体模板路径
    10 - 模板参数

    在上面的代码中,在 sendByEmail() 方法中调用 EmailService 并传递描述邮件消息的 EmailInfo 实例给 sendEmailAsync 方法。邮件消息的主体将基于 news_item.txt 模板创建。

  2. core 模块的 com.company.demo.templates 包中创建邮件消息主体模板文件 news_item.txt

    The company news:
    ${newsItem.content}

    这是一个 Freemarker 模板,其使用 EmailInfo 实例中传递的参数(在本例中为 newsItem )。

  3. 运行应用程序,打开 NewsItem 实体浏览界面并点击 Create。编辑界面将被打开。填写字段并点击 OK。将显示一个确认对话框,询问是否发送邮件。点击 Yes

  4. 切换到应用程序的 Administration > Email History 界面。将看到两个处于 Queue 状态的记录(按收件人数量)。这表示电子邮件在队列中但尚未发送。

  5. 要处理队列,请设置计划任务。切换到应用程序的 Administration > Scheduled Tasks 界面。创建一个新任务并设置以下参数:

    • Bean Name - cuba_Emailer

    • Method Name - processQueuedEmails()

    • Singleton - yes(这对于中间层服务集群很重要)

    • Period, sec - 10

    保存任务并点击 Activate

    如果之前没有为此项目设置定时任务的执行,则此阶段不会发生任何事情 - 在启动整个定时机制之前,任务不会执行。

  6. 打开 modules/core/src/app.properties 文件并添加以下 属性

    cuba.schedulingActive = true

    重启应用服务。定时机制现在处于激活状态并调用电子邮件队列处理程序。

  7. 转到 Administration > Email History 界面。如果发送成功,电子邮件的状态将为 Sent。否则最有可能为 SendingQueue。在后一种情况下,可以在 build/tomcat/logs/app.log 中打开应用程序日志并找出原因。电子邮件发送机制将尝试多次(默认为 10 次)发送邮件消息,如果失败,设置状态为 Not sent

  8. 无法发送电子邮件的最常见原因是没有设置 SMTP 服务器参数。可以通过 app-core.cuba:type=Emailer JMX bean 或中间件中的应用程序属性文件中设置参数。我们看看后者。打开 modules/core/src/app.properties 文件并添加所需的参数

    cuba.email.fromAddress = do-not-reply@company.com
    cuba.email.smtpHost = mail.company.com

    重启应用程序服务。转到 Administration > JMX Console,找到 Emailer JMX bean 并尝试使用 sendTestEmail() 操作向自己发送测试邮件。

  9. 现在发送机制已经正确设置,但它不会发送在 Not sent 状态中的邮件消息。所以必须在编辑界面中创建另一个 NewsItem。执行此操作然后观察在 Email History 界面中新邮件消息的状态如何更改为 Sent

3.9.3. 实体探查

实体探查器可以在任何应用程序对象上使用,而无需创建专用界面。探查器动态生成界面来浏览和编辑所选的实体实例。

这使系统管理员有机会查看和编辑由于设计原因而无法从标准界面访问的数据,并能在原型设计阶段创建数据模型以及创建仅链接到实体探查器的主菜单部分。

探查器的入口是 com/haulmont/cuba/gui/app/core/entityinspector/entity-inspector-browse.xml 界面。

如果使用名为 entityString 类型参数将实体名称作为参数传递给实体探查器,则探查器将显示具有过滤、选择和编辑功能的实体列表。在screens.xml中配置界面时可以指定参数,例如:

screens.xml

<screen id="sales_Product.lookup"
      template="/com/haulmont/cuba/gui/app/core/entityinspector/entity-inspector-browse.xml">
  <param name="entity"
         value="sales_Product"/>
</screen>

menu.xml

<item id="sales_Product.lookup"/>

界面标识符定义为 {entity_name}.lookup 时将允许PickerFieldLookupPickerField组件在 PickerField.LookupAction 标准操作中使用此界面。

通常可以在没有任何参数的情况下调用界面。在这种情况下,界面顶部将包含一个实体选择字段。在 cuba 应用程序组件中,探查器界面使用 entityInspector.browse 标识符进行注册,因此在菜单项中可以很容易引用:

<item id="entityInspector.browse"/>
使用实体探查进行导入导出

使用实体探查器,您可以对简单实体(包括系统实体,比如 scheduled taskslocks)进行导入导出。

选择了实体类型之后,实体探查界面会展示用来导出成 ZIP 或 JSON 格式以及导入实体的操作(使用 Export/Import 按钮)。

注意,使用实体探查导出的实体,不会导出一对多或者多对多的关联属性。实体探查的导入导出只能用在简单的情况下,如果您需要导出复杂的对象关系图,需要在您的程序中使用 EntityImportExportService

3.9.4. 实体日志

此机制在实体监听器级别跟踪实体的持久化,即确保能跟踪所有经过EntityManager的持久化上下文的数据库变更。但是不跟踪使用 SQL 对数据库实体的直接更改,包括使用NativeQueryQueryRunner执行的更改。

修改后的实体实例在保存到数据库之前传递给 EntityLogAPI bean 的 registerCreate()registerModify()registerDelete() 方法。每个方法都有 auto 参数,通过这个参数控制实体监听器添加的自动日志与通过从应用程序代码调用这些方法添加的手动日志分离。当从实体监听器调用这些方法时,auto 参数的值为 true

日志包含有关修改时间、修改实体的用户以及修改后属性新值的信息。日志实体存储在与 EntityLogItem 实体对应的 SEC_ENTITY_LOG 表中。更改的属性值存储在 CHANGES 列中,在中间件加载时,将属性转换为 EntityLogAttr 实体的实例。

3.9.4.1. 配置实体日志

配置实体日志的最简单方法是使用 Administration > Entity Log > Setup 应用程序界面。

如果要将配置包含在数据库初始化脚本中,还可以通过在数据库中添加一些记录来配置实体日志。

使用 LoggedEntity 实体和 LoggedAttribute 实体配置日志记录,分别对应于数据库的 SEC_LOGGED_ENTITY 表和 SEC_LOGGED_ATTR 表。

LoggedEntity 定义了需要记录日志的实体类型。LoggedEntity 具有以下属性:

  • name ( NAME 列) – 实体元类名称,例如 sales_Customer

  • auto ( AUTO 列) – 定义当使用 auto = true 参数调用 EntityLogAPI 时(即通过实体监听器调用)系统是否应记录变更。

  • manual ( MANUAL 列) – 定义当使用 auto = false 参数调用 EntityLogAPI 时系统是否应记录更改。

LoggedAttribute 定义要记录的实体属性,并包含指向 LoggedEntity 的链接和属性名称。

要为某个实体配置日志记录,应将相应的配置项添加到 SEC_LOGGED_ENTITYSEC_LOGGED_ATTR 表中。例如,使用以下语句将记录 Customer 实体的 namegrade 属性的更改:

insert into SEC_LOGGED_ENTITY (ID, CREATE_TS, CREATED_BY, NAME, AUTO, MANUAL)
values ('25eeb644-e609-11e1-9ada-3860770d7eaf', now(), 'admin', 'sales_Customer', true, true);

insert into SEC_LOGGED_ATTR (ID, CREATE_TS, CREATED_BY, ENTITY_ID, NAME)
values (newid(), now(), 'admin', '25eeb644-e609-11e1-9ada-3860770d7eaf', 'name');

insert into SEC_LOGGED_ATTR (ID, CREATE_TS, CREATED_BY, ENTITY_ID, NAME)
values (newid(), now(), 'admin', '25eeb644-e609-11e1-9ada-3860770d7eaf', 'grade');

默认情况下会激活日志记录机制。如果要停止它,请设置 app-core.cuba:type=EntityLog JMX bean 的 Enabled 属性为 false,然后调用其 invalidateCache() 方法。或者,将cuba.entityLog.enabled应用程序属性设置为 false 并重新启动服务。

3.9.4.2. 查看实体日志

实体日志内容可以在 Administration > Entity Log 上的专用界面上查看。

除此之外,也能在其它应用程序界面访问实体更改日志,只要加载 EntityLogItem 集合及其关联的 EntityLogAttr 实例到数据容器,再创建连接到这些数据容器的可视化组件。

下面的例子展示了 Customer 实体界面的 XML 描述片段,这里有一个带有实体日志内容的标签页。

customer-edit.xml 代码片段
<data>
    <instance id="customerDc"
              class="com.company.sample.entity.Customer"
              view="customer-view">
        <loader id="customerDl"/>
    </instance>
    <collection id="entitylogsDc"
                class="com.haulmont.cuba.security.entity.EntityLogItem"
                view="logView" >
        <loader id="entityLogItemsDl">
            <query><![CDATA[select i from sec$EntityLog i where i.entityRef.entityId = :customer
                            order by i.eventTs]]>
            </query>
        </loader>
        <collection id="logAttrDc"
                    property="attributes"/>
    </collection>
</data>
<layout>
    <tabSheet id="tabSheet">
        <tab id="propertyTab">
            <!--...-->
        </tab>
        <tab id="logTab">
            <table id="logTable"
                   dataContainer="entitylogsDc"
                   width="100%"
                   height="100%">
                <columns>
                    <column id="eventTs"/>
                    <column id="user.login"/>
                    <column id="type"/>
                </columns>
            </table>
            <table id="attrTable"
                   height="100%"
                   width="100%"
                   dataContainer="logAttrDc">
                <columns>
                    <column id="name"/>
                    <column id="oldValue"/>
                    <column id="value"/>
                </columns>
            </table>
        </tab>
    </tabSheet>
</layout>

看看 Customer 界面控制器:

Customer 界面控制器代码片段
@UiController("sample_Customer.edit")
@UiDescriptor("customer-edit.xml")
@EditedEntityContainer("customerDc")
public class CustomerEdit extends StandardEditor<Customer> {
    @Inject
    private InstanceLoader<Customer> customerDl;
    @Inject
    private CollectionLoader<EntityLogItem> entityLogItemsDl;

    @Subscribe
    private void onBeforeShow(BeforeShowEvent event) { (1)
        customerDl.load();
    }

    @Subscribe(id = "customerDc", target = Target.DATA_CONTAINER)
    private void onCustomerDcItemChange(InstanceContainer.ItemChangeEvent<Customer> event) { (2)
        entityLogItemsDl.setParameter("customer", event.getItem().getId());
        entityLogItemsDl.load();
    }
}

注意该界面并没有 @LoadDataBeforeShow 注解,因为加载数据是显式触发的。

1 onBeforeShow 方法在界面展示前加载数据。
2 − 在 customerDc 容器的 ItemChangeEvent 处理器中,为依赖的加载器设置了参数并触发加载。

3.9.5. 实体快照

实体保存机制与实体日志非常相似,目的在于跟踪运行时的数据变化,具有以下独特的特征:

  • 能保存通过特定视图定义的整个实体关系图的状态(或快照)。

  • 应用程序代码能显式调用快照保存机制。

  • 平台允许查看和比较实体快照。

3.9.5.1. 保存快照

为了保存给定实体关系图的快照,需要调用 EntitySnapshotService.createSnapshot() 方法,并至少传递两个参数 - 实体和视图,实体是对象关系图入口点实体,视图用于描述关系图。将会使用已加载的实体创建快照,不做任何对数据库的调用。因此,如果加载实体的视图中不包含某些字段,快照也不会包含这些字段。

Java 的对象图被转换为 XML 并与主实体的链接一起保存在 SYS_ENTITY_SNAPSHOT 表(对应 EntitySnapshot 实体)中。

通常,在编辑界面提交后需要保存快照。可以通过重写界面控制器的 postCommit() 方法来实现这种需求。例如:

public class CustomerEditor extends AbstractEditor<Customer> {

    @Inject
    protected Datasource<Customer> customerDs;
    @Inject
    protected EntitySnapshotService entitySnapshotService;

...
    @Override
    protected boolean postCommit(boolean committed, boolean close) {
        if (committed) {
            entitySnapshotService.createSnapshot(customerDs.getItem(), customerDs.getView());
        }
        return super.postCommit(committed, close);
    }
}
3.9.5.2. 查看快照

使用 com/haulmont/cuba/gui/app/core/entitydiff/diff-view.xml 子框架可以查看任意实体的快照。例如:

<frame id="diffFrame"
      src="/com/haulmont/cuba/gui/app/core/entitydiff/diff-view.xml"
      width="100%"
      height="100%"/>

快照应该通过编辑界面控制器加载到框架中:

public class CustomerEditor extends AbstractEditor<Customer> {

    @Inject
    private EntityStates entityStates;
    @Inject
    protected EntityDiffViewer diffFrame;

...
    @Override
    protected void postInit() {
        if (!entityStates.isNew(getItem())) {
            diffFrame.loadVersions(getItem());
        }
    }
}

diff-view.xml 子框架显示给定实体的快照列表,并能够对它们进行比较。每一个快照视图包含用户、日期和时间。当从列表中选中一个快照,将显示与上一个快照相比的变化。第一个快照的所有属性被标记为已更改。选择两个快照在表格中展示比较的结果。

比较结果表展示属性名称及其新值。当一行被选中,将显示两个快照上属性更改的详细信息。引用字段则会显示相应实体的实例名。 当比较集合时,新元素和删除的元素分别以高亮的绿色和红色显示。只有属性发生更改的集合元素不会高亮显示。不记录元素位置的改变。

3.9.6. 实体统计

实体统计机制提供了数据库中当前实体实例数量的信息。此数据用于自动为关联实体选择最佳查找策略,并限制 UI 界面中显示的搜索结果的数量。

统计信息存储在 SYS_ENTITY_STATISTICS 表中,该表映射到 EntityStatistics 实体。可以使用PersistenceManagerMBean JMX bean 的 refreshStatistics() 方法自动更新。如果将实体名称作为参数传递,则将收集给定实体的统计信息,否则为所有实体收集统计信息。如果要定期更新统计信息,可以创建调用此方法的计划任务。请注意,收集数据过程将为每个实体执行 select count(*),这样会增加对数据库的压力。

可以通过中间层的 PersistenceManagerAPI 接口和客户端上的 PersistenceManagerService 来以编程方式访问实体统计信息。统计信息缓存在内存中,因此只有在服务器重启之后或在调用 PersistenceManagerMBean.flushStatisticsCache() 方法之后,对数据库中统计信息的直接更改才会生效。

EntityStatistics 有如下属性:

  • name (NAME 列) – 实体元类名称,例如 sales_Customer

  • instanceCount (INSTANCE_COUNT 列) – 实体实例的近似数量。

  • fetchUI (FETCH_UI 列) – 界面上显示的所获取实体列表的数据量。

    例如,Filter组件在 Show N rows 字段中使用此数值。

  • maxFetchUI ( MAX_FETCH_UI 列) – 允许获取并传递到客户端的实体实例的最大数量。

    在某些组件上显示实体列表时会应用此限制,这些组件包括 LookupFieldLookupPickerField 以及不带过滤器的表格,表格没有通过 CollectionLoader.setMaxResults() 方法限制连接的数据加载器。在这种情况下,数据源本身将获取实例的数量限制为 maxFetchUI

  • lookupScreenThreshold ( LOOKUP_SCREEN_THRESHOLD 列) – 以实体数量衡量的阈值,确定何时应使用查找界面而不是下拉列表查找实体。

    选择过滤器参数时,过滤器组件会使用此参数。在达到阈值之前,系统使用LookupField组件,一旦超过阈值,就使用PickerField组件。因此,对于过滤器参数中的特定实体,如果想要使用查找界面,则可以将 lookupScreenThreshold 的值设置为低于 instanceCount 的值。

PersistenceManagerMBean JMX bean 能够通过 DefaultFetchUIDefaultMaxFetchUIDefaultLookupScreenThreshold 属性为上面提到的所有参数设置默认值。当实体没有统计信息时,系统将使用相应的默认值,这是一种常见情况。

此外,PersistenceManagerMBean.enterStatistics() 方法允许用户输入实体的统计数据。例如,将以下参数传递给该方法,用来将默认每页记录数设置为 1,000,并将加载到LookupField中最大实例数设置为 30,000:

entityName: sales_Customer
fetchUI: 1000
maxFetchUI: 30000

另一个示例:假设 Customer 实体具有过滤条件,并且希望在条件参数中选择 Customer 时使用查找界面而不是下拉列表。可以使用以下参数调用 enterStatistics() 方法:

entityName: sales_Customer
instanceCount: 2
lookupScreenThreshold: 1

这里忽略了数据库中的实际客户记录数,并手动指定始终超过阈值的数量。

3.9.7. 以 JSON 格式导入和导出实体

平台提供了一个 API,用于以 JSON 格式导出和导入实体关系图。它可以通过 EntityImportExportAPI 接口在中间件使用,也可以通过 EntityImportExportService 在客户端使用。这些接口具有一组相同的方法,如下所述。导出/导入实现委托给 EntitySerializationAPI 接口,也可以直接使用这个接口。

  • exportEntitiesToJSON() - 将一组实体序列化为 JSON。

    @Inject
    private EntityImportExportService entityImportExportService;
    @Inject
    private GroupDatasource<Customer, UUID> customersDs;
    
    ...
    String jsonFromDs = entityImportExportService.exportEntitiesToJSON(customersDs.getItems());
  • exportEntitiesToZIP() - 将一组实体序列化为 JSON,并将 JSON 文件打包为 ZIP 文件。在下面的示例中,使用FileLoader接口将 ZIP 文件保存到文件存储中:

    @Inject
    private EntityImportExportService entityImportExportService;
    @Inject
    private GroupDatasource<Customer, UUID> customersDs;
    @Inject
    private Metadata metadata;
    @Inject
    private DataManager dataManager;
    
    ...
    byte[] array = entityImportExportService.exportEntitiesToZIP(customersDs.getItems());
    FileDescriptor descriptor = metadata.create(FileDescriptor.class);
    descriptor.setName("customersDs.zip");
    descriptor.setExtension("zip");
    descriptor.setSize((long) array.length);
    descriptor.setCreateDate(new Date());
    try {
        fileLoader.saveStream(descriptor, () -> new ByteArrayInputStream(array));
    } catch (FileStorageException e) {
        throw new RuntimeException(e);
    }
    dataManager.commit(descriptor);
  • importEntitiesFromJSON() - 反序列化 JSON 并根据由 entityImportView 参数(参阅 JavaDocs 中的 EntityImportView 类)描述的规则持久化反序列后的实体。如果一个实体在数据库中不存在,则会创建该实体。否则,将更新 entityImportView 中指定的现有实体的字段。

  • importEntitiesFromZIP() - 读取包含 JSON 文件的 ZIP 存档,像 importEntitiesFromJSON() 方法一样反序列化 JSON 并持久化反序列化的实体。

    @Inject
    private EntityImportExportService entityImportExportService;
    @Inject
    private FileLoader fileLoader;
    
    private FileDescriptor descriptor;
    
    ...
    EntityImportView view = new EntityImportView(Customer.class);
    view.addLocalProperties();
    try {
        byte[] array = IOUtils.toByteArray(fileLoader.openStream(descriptor));
        Collection<Entity> collection = entityImportExportService.importEntitiesFromZIP(array, view);
    } catch (FileStorageException e) {
        throw new RuntimeException(e);
    }

3.9.8. 文件存储

文件存储允许上传、存储和下载与实体相关联的任意文件。在平台标准实现中,使用文件系统中的特定结构将文件存储在主数据库之外。

文件存储机制包括以下部分:

  • FileDescriptor 实体 – 用于表示上传的文件(不要与 java.io.FileDescriptor 混淆),使用这个实体就可以通过实体模型对象来引用文件。

  • FileStorageAPI 接口 – 提供对中间的文件存储的访问。其主要方法有:

    • saveStream() – 根据指定的 FileDescriptor 保存文件内容,该文件可以作为 InputStream 传递。

    • openStream() – 根据指定的 FileDescriptor 获取文件内容,文件内容以打开的 InputStream 的形式返回。

  • FileUploadController 类 – Spring MVC 控制器,它使用 HTTP POST 请求将文件从客户端发送到中间件。

  • FileDownloadController 类 – Spring MVC 控制器,它使用 HTTP GET 请求将文件从中间件下载到客户端。

  • FileUploadFileMultiUpload可视化组件 - 能够将文件从用户的计算机上传到应用程序的客户端层,然后将它们传输到中间件。

  • FileUploadingAPI 接口 – 上传文件到客户端的临时存储。上面提到的可视化组件通过它将文件上传到客户端层。在应用程序代码中可以使用 putFileIntoStorage() 方法将文件移动到中间件的永久存储中。

  • FileLoader - 处理文件存储的接口,这个接口在中间层和客户端层都可以使用。

  • ExportDisplay – 用于将各种应用程序资源下载到用户计算机的客户端层接口。可以使用 show() 方法从永久存储中获取文件,该方法需要一个 FileDescriptor 参数。可以通过调用 AppConfig.createExportDisplay() 静态方法来获得 ExportDisplay 的实例,或通过在控制器类中使用注入来获取。

在用户计算机和存储之间的文件双向传输总是通过在输入和输出流之间复制数据来进行。在应用程序的任何层,文件都不会被完全加载到内存中,因此可以传输几乎任何大小的文件。

3.9.8.1. 上传文件

通过使用FileUploadFileMultiUpload组件可以将用户计算机上的文件上传到存储中。本手册中的相应组件说明提供了用法示例。

参考 在 CUBA 应用程序中使用图片 指南,了解如何在应用程序中上传和显示图片。

FileUpload 组件也可在即用型 FileUploadDialog 窗口中使用,这个窗口设计用来加载文件至临时存储。

FileUploadDialog 对话框提供将文件上传到临时存储的基本功能。窗口包含了上传按钮和投放区域,可以支持从浏览器外拖拽文件至投放区进行上传。

gui fileUploadDialog

此对话框是使用旧版的界面 API 实现,可以按照下面的方式使用:

@Inject
private Screens screens;

@Inject
private FileUploadingAPI fileUploadingAPI;

@Inject
private DataManager dataManager;

@Subscribe("showUploadDialogBtn")
protected void onShowUploadDialogBtnClick(Button.ClickEvent event) {
    FileUploadDialog dialog = (FileUploadDialog) screens.create("fileUploadDialog", OpenMode.DIALOG);
    dialog.addCloseWithCommitListener(() -> {
        UUID fileId = dialog.getFileId();
        String fileName = dialog.getFileName();

        File file = fileUploadingAPI.getFile(fileId); (1)

        FileDescriptor fileDescriptor = fileUploadingAPI.getFileDescriptor(fileId, fileName); (2)
        try {
            fileUploadingAPI.putFileIntoStorage(fileId, fileDescriptor); (3)
            dataManager.commit(fileDescriptor); (4)
        } catch (FileStorageException e) {
            throw new RuntimeException(e);
        }
    });
    screens.show(dialog);
}
1 - 获取 java.io.File 对象,此对象指向文件在 Web 客户端文件系统的位置。一般情况下,文件上传后回放到文件存储中,如果需要处理该文件,则需要获取 File 对象。
2 - 创建一个 FileDescriptor 实体。
3 - 上传文件至中间层的文件存储。
4 - 保存 FileDescriptor 实体。

在上传成功后,对话框使用 COMMIT_ACTION_ID 关闭。在 CloseWithCommitListener 监听器中,可以用 getFileId()getFileName() 方法获取上传文件的 UUID 和名称。然后可以获取文件本身或者创建 FileDescriptor 对象并上传文件到文件存储,也可以实现其它的逻辑。

临时客户端级别的存储(FileUploadingAPI)将临时文件存在由 cuba.tempDir 应用程序属性定义的文件夹中。临时文件会在下面的情况自动删除:

  • 如果 FileUploadField 使用 IMMEDIATE 模式

  • 如果您使用 FileUploadingAPI.putFileIntoStorage(UUID, FileDescriptor) 方法。

否则,需要用 FileUploadingAPI.deleteFile(UUID) 方法删除临时文件。

出现任何失败的情况,临时文件都会保留在上面提到的文件夹中。cuba-web-spring.xml 文件定义了定时任务,调用 cuba_FileUploading bean 的 clearTempDirectory() 方法定时清理。默认情况下,会在周二、周四、周六的晚上 0 点执行,删除保存时间大于 2 天的所有文件。

3.9.8.2. 下载文件

可以使用 ExportDisplay 接口将文件从文件存储下载到用户的计算机。可以通过调用 AppConfig.createExportDisplay() 静态方法或通过在控制器类中使用注入来获取 ExportDisplay 实例 。例如:

AppConfig.createExportDisplay(this).show(fileDescriptor);

show() 方法接收一个可选的 ExportFormat 类型的参数,该参数定义内容的类型和文件扩展名。如果没有提供格式,则从 FileDescriptor 中检索扩展名,并将内容类型设置为 application/octet-stream

文件扩展名定义文件是通过浏览器标准打开/保存对话框(Content-Disposition = attachment)下载,还是在浏览器窗口中直接显示(Content-Disposition = inline)。需要在浏览器窗口中直接显示的文件的扩展名列表由 cuba.web.viewFileExtensions 应用程序属性定义。

如果使用 ByteArrayDataProvider 作为 show() 方法的参数,ExportDisplay 也可以下载任意数据。例如:

public class SampleScreen extends AbstractWindow {

    @Inject
    private ExportDisplay exportDisplay;

    public void onDownloadBtnClick(Component source) {
        String html = "<html><head title='Test'></head><body><h1>Test</h1></body></html>";
        byte[] bytes;
        try {
            bytes = html.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
        exportDisplay.show(new ByteArrayDataProvider(bytes), "test.html", ExportFormat.HTML);
    }
}
3.9.8.3. FileLoader 接口

使用 FileLoader 接口可以在中间层和客户端层使用相同的一组方法处理文件存储。文件的上传和下载是使用“流”的方式执行的:

  • saveStream() – 将 InputStream 内容保存到文件存储中。

  • openStream() – 返回输入流以从文件存储加载文件内容。

FileLoader 的客户端和服务端实现遵循通用规则:始终通过在输入和输出流之间复制数据来进行文件传输。在应用程序的任何层,文件都不会完全加载到内存中,从而可以传输几乎任何大小的文件。

作为使用 FileLoader 的一个例子,我们考虑一个简单的任务,将用户输入的内容保存到文本文件中,并在同一界面上的另一个字段中显示文件内容。

该界面包含两个 textArea 字段。假设用户在第一个 textArea 中输入文本,单击下面的 buttonIn,文本将保存到 FileStorage。通过单击 buttonOut,第二个 textArea 将显示保存文件的内容。

下面是上述界面的 XML 描述片段:

<hbox margin="true"
      spacing="true">
    <vbox spacing="true">
        <textArea id="textAreaIn"/>
        <button id="buttonIn"
                caption="Save text in file"
                invoke="onButtonInClick"/>
    </vbox>
    <vbox spacing="true">
        <textArea id="textAreaOut"
                  editable="false"/>
        <button id="buttonOut"
                caption="Show the saved text"
                invoke="onButtonOutClick"/>
    </vbox>
</hbox>

界面控制器包含两个按钮上调用的方法:

  • onButtonInClick() 方法中,我们用第一个 textArea 的输入内容创建一个字节数组。然后我们创建一个 FileDescriptor 对象,并使用其属性定义新文件名、扩展名、大小和创建日期。

    然后我们使用 FileLoadersaveStream() 方法保存新文件,将 FileDescriptor 传递给它,并使用 InputStream supplier 提供文件内容。最后使用 DataManager 接口将 FileDescriptor 提交到数据存储。

  • onButtonOutClick() 方法中,我们使用 FileLoaderopenStream() 方法提取保存的文件的内容。然后我们在第二个 textArea 中显示文件的内容。

import com.haulmont.cuba.core.entity.FileDescriptor;
import com.haulmont.cuba.core.global.DataManager;
import com.haulmont.cuba.core.global.FileLoader;
import com.haulmont.cuba.core.global.FileStorageException;
import com.haulmont.cuba.core.global.Metadata;
import com.haulmont.cuba.gui.components.AbstractWindow;
import com.haulmont.cuba.gui.components.ResizableTextArea;
import com.haulmont.cuba.gui.upload.FileUploadingAPI;
import org.apache.commons.io.IOUtils;

import javax.inject.Inject;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;

public class FileLoaderScreen extends AbstractWindow {

    @Inject
    private Metadata metadata;
    @Inject
    private FileLoader fileLoader;
    @Inject
    private DataManager dataManager;
    @Inject
    private ResizableTextArea textAreaIn;
    @Inject
    private ResizableTextArea textAreaOut;

    private FileDescriptor fileDescriptor;

    public void onButtonInClick() {
        byte[] bytes = textAreaIn.getRawValue().getBytes();

        fileDescriptor = metadata.create(FileDescriptor.class);
        fileDescriptor.setName("Input.txt");
        fileDescriptor.setExtension("txt");
        fileDescriptor.setSize((long) bytes.length);
        fileDescriptor.setCreateDate(new Date());

        try {
            fileLoader.saveStream(fileDescriptor, () -> new ByteArrayInputStream(bytes));
        } catch (FileStorageException e) {
            throw new RuntimeException(e);
        }

        dataManager.commit(fileDescriptor);
    }

    public void onButtonOutClick() {
        try {
            InputStream inputStream = fileLoader.openStream(fileDescriptor);
            textAreaOut.setValue(IOUtils.toString(inputStream));
        } catch (FileStorageException | IOException e) {
            throw new RuntimeException(e);
        }
    }
}
fileLoader recipe
3.9.8.4. 标准文件存储实现

文件存储的标准实现以特定的文件夹结构将文件存储在一个或多个位置。

特定文件夹结构的根目录可以在cuba.fileStorageDir应用程序属性中定义,格式是以逗号分隔的路径列表。例如:

cuba.fileStorageDir=/work/sales/filestorage,/mnt/backup/filestorage

如果未定义该属性,则存储将位于中间件工作目录filestorage 子文件夹中。该文件夹在标准 Tomcat 部署中是 tomcat/work/app-core/filestorage

如果定义了多个存储位置,存储的行为如下:

  • 列表中的第一个文件夹被视为 主(primary),其它文件夹被视为 备份(backup)

  • 存储的文件首先放在主文件夹中,然后复制到所有的备份目录中。

    在存储文件之前,系统会检查每个文件夹是否可访问。如果无法访问主目录,系统将抛出异常而不存储文件。如果有备份目录无法访问,则文件将存储在一个可用的目录中,并记录相应的错误。

  • 从主目录中读取文件。

    如果无法访问主目录,系统将从包含所需文件的第一个可用备份目录中读取文件,并记录相应的错误。

存储文件夹结构按以下方式组织:

  • 子目录有三层,代表文件上传日期 – 年 、月 、 日。

  • 文件实际保存在 目录中。文件名与相应的 FileDescriptor 对象的标识符一致。文件扩展名与源文件的扩展名一致。

  • 文件夹结构的根文件夹包含一个 storage.log 文件,其中包含每个存储文件的信息,包括用户和上传时间。存储机制本身不需要此日志,但这个日志可用于故障排除。

app-core.cuba:type=FileStorage JMX bean 显示当前的存储根目录集合,并提供以下排除故障的方法:

  • findOrphanDescriptors() – 查找数据库中有,而存储中没有对应文件的记录。

  • findOrphanFiles() – 查找存储中有,而数据库中没相应记录的文件。

3.9.8.5. Amazon S3 文件存储实现

标准文件存储实现可以使用云存储服务代替。建议为云部署使用单独的云文件存储服务,因为部署应用的云服务器通常不能保证外部文件在其硬盘上的永久存储。

AWS 文件存储 扩展提供了针对 Amazon S3 文件存储服务的开箱即用支持。如要支持其他的服务,需要自己实现自定义逻辑。

如需在应用程序中使用 Amazon S3 支持,请按照 README 的介绍进行安装和配置。

存储文件夹结构的组织方式与标准实现类似。

3.9.9. 快捷文件夹面板

文件夹面板提供对常用信息的快速访问。它是主应用程序窗口左侧的一个包含多级文件夹的面板。点击文件夹会使用特定参数打开相应的系统界面。

在撰写本文时,该面板仅适用于 Web Client

平台支持三种类型的文件夹:应用程序文件夹搜索文件夹记录集

  • 应用程序文件夹:

    • 打开应用程序界面,界面上可以没有过滤器

    • 文件夹集合取决于当前用户会话信息。文件夹的可见性通过 Groovy 脚本定义。

    • 只有具有特定权限的用户才能创建和更改应用程序文件夹。

    • 文件夹标题上可以显示通过 Groovy 脚本获取的记录数。

    • 文件夹标题通过计时器事件更新,这意味着可以动态更新每个文件夹的记录数和显示样式。

  • 搜索文件夹:

    • 打开带有过滤器的界面。

    • 搜索文件夹可以是私有的,也可以是全局的,私有文件夹只能由创建它的用户访问,全局文件夹所有用户都可以访问。

    • 任何用户都可以创建私有文件夹,而只有具有特定权限的用户才能创建全局文件夹。

  • 记录集:

    • 打开带有过滤器的界面,这个过滤器包含了根据标识符选择特定记录的条件。

    • 可以使用专门的表格操作编辑记录集内容:Add to setAdd to current setRemove from current set

    • 记录集仅能供创建它们的用户使用。

应用程序文件夹作为一个单独的树展示在面板的顶部。搜索文件夹和记录集展示在面板底部的组合树中。启用文件夹面板,需要:

  1. 设置 cuba.web.showFolderIcons 属性为 true

  2. 在 Studio 的界面创建向导中使用 Main screen with top menu 模板扩展 主界面。该模板包含一个特殊的 FoldersPane 组件。

文件夹面板的文件夹可以在左侧带一个图标。设置 cuba.web.foldersPaneEnabled 属性为 true 启用该功能。此时会使用标准的图标。

folder default icons
Figure 43. 应用程序和搜索文件夹 - 使用标准图标

如要设置其他图标,使用 FoldersPane 组件的 setFolderIconProvider() 方法。下面的例子展示在自定义的主界面的 setFolderIconProvider() 内使用函数的用法。"category" 图标会用在应用程序文件夹上,其他的文件夹,使用 "tag" 图标。

foldersPane.setFolderIconProvider(e -> {
    if (e instanceof AppFolder) {
        return "icons/category.png";
    }
    return "icons/tag.png";
});
folder custom items
Figure 44. 应用程序和搜索文件夹 - 使用自定义图标

要还原标准的图标,给 setFolderIconProvider() 方法传递 null 参数。

通过下列应用程序属性可以定制文件夹面板的功能:

3.9.9.1. 应用程序文件夹

创建或编辑应用程序文件夹需要特殊的权限 Create/modify application folderscuba.gui.appFolder.global)。这种权限在角色编辑界面的 Specific - 特殊权限 标签页设置。

可以通过文件夹面板右键菜单创建一个简单的应用程序文件夹。这类文件夹不会链接到系统界面,只用于对文件夹树中的其它文件夹进行分组。

打开带有过滤器界面的文件夹可以按以下方式创建:

  • 打开界面并根据需要过滤记录。

  • Filter…​ 按钮菜单中选择 Save as application folder 选项。

  • Add 对话框中填写文件夹属性:

    • Folder name

    • Screen Caption – 从文件夹中打开窗口时要添加到窗口标题的字符串。

    • Parent folder – 确定新文件夹在文件夹树中的位置。

    • Visibility script – 确定文件夹可见性的 Groovy 脚本,在用户会话建立时执行。

      该脚本应该返回一个 Boolean 值。如果未定义脚本或脚本执行结果为 true 或者 null,则文件夹可见。Groovy 脚本的示例:

      userSession.currentOrSubstitutedUser.login == 'admin'
    • Quantity script – 一个用于定义文件夹上显示的记录数和样式的 Groovy 脚本。在用户会话建立时、计器调用时执行。

      该脚本会返回一个数值,其整数部分将用作记录数。如果未定义脚本或脚本执行返回 null,则不会显示记录数。除了返回值之外,该脚本还可以设置 style 变量,该变量将用作文件夹显示样式。Groovy 脚本的示例:

      def em = persistence.getEntityManager()
      def q = em.createQuery('select count(o) from sales_Order o')
      def count = q.getSingleResult()
      
      style = count > 0 ? 'emphasized' : null
      return count

      要显示样式,应该在程序主题中定义 cuba-folders-panev-tree-node 元素样式,例如:

      .c-folders-pane .v-tree-node.emphasized {
        font-weight: bold;
      }
    • Order No – 文件夹的在树中的顺序。

脚本可以使用 groovy.lang.Binding 上下文中定义的以下变量:

  • folder – 执行脚本的 AppFolder 实体的实例。

  • persistencePersistence接口的实现。

  • metadataMetadata接口的实现。

在更新文件夹时,平台对所有脚本使用相同的 groovy.lang.Binding 实例。因此,可以在它们之间传递变量,这样可以消除重复的请求并能提高性能。

脚本源代码可以存储在 AppFolder 实体的属性中,也可以存储在单独的文件中。如果要存储在单独文件中,属性值设置为扩展名为 ".groovy" 的文件路径,这是Resources接口需要的。如果属性值是以".groovy"结尾的字符串,则将从相应的文件加载脚本;否则,将属性内容本身将用作脚本。

应用程序文件夹是 AppFolder 实体的实例,存储在相关的 SYS_FOLDER 表和 SYS_APP_FOLDER 表中。

3.9.9.2. 搜索文件夹

用户可以创建与应用程序文件夹类似的搜索文件夹。分组文件夹可以直接通过文件夹面板的右键菜单创建。链接到界面的文件夹可以使用 Filter…​ 按钮菜单上的“Save as search folder”选项创建。

要创建全局搜索文件夹,要求用户具有 Create/edit global search folders 权限cuba.gui.searchFolder.global)。这种权限在角色编辑界面的 Specific - 特殊权限 标签页设置。

可以在创建文件夹后再次编辑搜索文件夹的过滤器:打开文件夹并更改 Folder:{folder name} 过滤器,保存过滤器的同时会更改文件夹过滤器。

搜索文件夹是 SearchFolder 实体的实例,存储在相关的 SYS_FOLDERSEC_SEARCH_FOLDER 表。

3.9.9.3. 记录集

如要在界面中使用记录集,按照下列步骤操作。

  1. 过滤器 组件与 表格 组件通过 applyTo 属性进行关联。

  2. 为表格添加预定义的 addToSet 操作。之后,用户可以使用表格的右键菜单将实体添加到记录集。

  3. 在按钮面板添加按钮提供此操作。

示例:

<layout>
  <filter id="customerFilter" dataLoader="customersDl"
          applyTo="customersTable"/>

  <groupTable id="customersTable" dataContainer="customersDc">
      <actions>
          <action id="addToSet" type="addToSet"/>
          ...
      </actions>
      <buttonsPanel>
          <button action="customersTable.addToSet"/>
          ...
      </buttonsPanel>
...

当界面展示某些记录集的时候,比如是通过文件夹面板点击记录集打开,表格会在右键菜单自动显示 Add to current set / Remove from set。如果一个表格包含 buttonsPanel(如上例所示),则还会添加相应的表格按钮。

记录集是 SearchFolder 实体的实例,存储在相关的 SYS_FOLDERSEC_SEARCH_FOLDER 表。

3.9.10. 关于软件组件信息

平台提供了注册应用程序中使用的第三方软件组件信息(credits)以及在 UI 中显示这些组件信息的功能。组件信息包括软件组件名称、网站链接和许可文本。

平台提供的应用程序组件包含了自己的描述文件,如 com/haulmont/cuba/credits.xmlcom/haulmont/reports/credits.xml 等。cuba.creditsConfig应用程序属性可用于指定应用程序的描述文件。

credits.xml 文件的结构如下:

  • items 元素列出了使用的库及其许可文本,许可文本既可以在嵌入的 license 元素中声明,也可以在 license 属性中指定一个指向 licenses 文本的链接。

    可以引用在当前文件中声明的许可,也可以引用在 cuba.creditsConfig 变量中声明的任何其它文件中的许可,前提是这些文件在当前文件之前。

  • licenses 元素列出了用到的通用许可的文本(例如 LGPL)。

可以使用 com/haulmont/cuba/gui/app/core/credits/credits-frame.xml 框架(frame)显示所有第三方软件组件列表,该框架从 cuba. creditsConfig 中定义的文件加载信息。下面是在界面中嵌入这个框架的示例:

<dialogMode width="500" height="400"/>
<layout expand="creditsBox">
  <groupBox id="creditsBox"
            caption="msg://credits"
            width="100%">
      <frame id="credits"
              src="/com/haulmont/cuba/gui/app/core/credits/credits-frame.xml"
              width="100%"
              height="100%"/>
  </groupBox>
</layout>

如果以对话框模式(WindowManager.OpenType.DIALOG)打开包含该框架的界面,则必须指定高度;否则,滚动条可能无法正常工作。请参阅上面示例中的 dialogMode 元素。

3.9.11. MyBatis 集成

MyBatis 框架,与 ORM 本地查询QueryRunner相比,提供了更广泛的执行 SQL 和将查询结果映射到对象的功能。

按照下面的步骤在 CUBA 项目中集成 MyBatis。

  1. core 模块的根 java 包创建处理 UUID 类型的类。

    import com.haulmont.cuba.core.global.UuidProvider;
    import org.apache.ibatis.type.JdbcType;
    import org.apache.ibatis.type.TypeHandler;
    
    import java.sql.*;
    
    public class UUIDTypeHandler implements TypeHandler {
    
        @Override
        public void setParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException {
            ps.setObject(i, parameter, Types.OTHER);
        }
    
        @Override
        public Object getResult(ResultSet rs, String columnName) throws SQLException {
            String val = rs.getString(columnName);
            if (val != null) {
                return UuidProvider.fromString(val);
            } else {
                return null;
            }
        }
    
        @Override
        public Object getResult(ResultSet rs, int columnIndex) throws SQLException {
            String val = rs.getString(columnIndex);
            if (val != null) {
                return UuidProvider.fromString(val);
            } else {
                return null;
            }
        }
    
        @Override
        public Object getResult(CallableStatement cs, int columnIndex) throws SQLException {
            String val = cs.getString(columnIndex);
            if (val != null) {
                return UuidProvider.fromString(val);
            } else {
                return null;
            }
        }
    }
  2. core 模块的 spring.xml 文件所在目录创建 mybatis.xml 配置文件,在文件内正确引用 UUIDTypeHandler

    <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
    <configuration>
        <settings>
            <setting name="lazyLoadingEnabled" value="false"/>
        </settings>
        <typeHandlers>
            <typeHandler javaType="java.util.UUID"
                         handler="com.company.demo.core.UUIDTypeHandler"/>
        </typeHandlers>
    </configuration>
  3. 将下面的 bean 都添加到 spring.xml 文件以便在项目中使用 MyBatis:

    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="cubaDataSource"/>
        <property name="configLocation" value="com/company/demo/mybatis.xml"/>
        <property name="mapperLocations" value="com/company/demo/core/sqlmap/*.xml"/>
    </bean>
    
    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="com/company/demo.core.dao"/>
        <property name="sqlSessionFactory" ref="sqlSessionFactory"/>
    </bean>
    
    <bean id="sqlSession" class="org.mybatis.spring.SqlSessionTemplate">
        <constructor-arg index="0" ref="sqlSessionFactory" />
    </bean>

    sqlSessionFactory bean 包含了指向 mybatis.xml 的引用。

    MapperLocations 参数定义了 mapperLocations 映射文件的路径(根据 Spring 中 ResourceLoader 接口的资源解析规则)。

  4. 最后,在 build.gradle 中的 core 模块添加 MyBatis 的依赖:

    compile('org.mybatis:mybatis:3.2.8')
    compile('org.mybatis:mybatis-spring:1.2.5')

下面是一个映射文件的示例,用于加载 订单(Order) 的实例以及相关的 客户(Customer)订单商品(order item) 集合:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sample.sales">

    <select id="selectOrder" resultMap="orderResultMap">
        select
        o.ID as order_id,
        o.DATE as order_date,
        o.AMOUNT as order_amount,
        c.ID as customer_id,
        c.NAME as customer_name,
        c.EMAIL as customer_email,
        i.ID as item_id,
        i.QUANTITY as item_quantity,
        p.ID as product_id,
        p.NAME as product_name
        from
        SALES_ORDER o
        left join SALES_CUSTOMER c on c.ID = o.CUSTOMER_ID
        left join SALES_ITEM i on i.ORDER_ID = o.id and i.DELETE_TS is null
        left join SALES_PRODUCT p on p.ID = i.PRODUCT_ID
        where
        c.id = #{id}
    </select>

    <resultMap id="orderResultMap" type="com.sample.sales.entity.Order">
        <id property="id" column="order_id"/>
        <result property="date" column="order_date"/>
        <result property="amount" column="order_amount"/>

        <association property="customer" column="customer_id" javaType="com.sample.sales.entity.Customer">
            <id property="id" column="customer_id"/>
            <result property="name" column="customer_name"/>
            <result property="email" column="customer_email"/>
        </association>

        <collection property="items" ofType="com.sample.sales.entity.Item">
            <id property="id" column="item_id"/>
            <result property="quantity" column="item_quantity"/>
            <association property="product" column="product_id" javaType="com.sample.sales.entity.Product">
                <id property="id" column="product_id"/>
                <result property="name" column="product_name"/>
            </association>
        </collection>
    </resultMap>

</mapper>

以下代码可用于获取上面示例中的查询结果:

try (Transaction tx = persistence.createTransaction()) {
    SqlSession sqlSession = AppBeans.get("sqlSession");
    Order order = (Order) sqlSession.selectOne("com.sample.sales.selectOrder", orderId);
    tx.commit();

3.9.12. 悲观锁

当同时编辑单个实体实例的机率很高的情况下,应使用悲观锁。在这种情况下,基于实体版本控制的标准乐观锁通常会产生很多冲突。

悲观锁在编辑界面中打开实体实例时显式地锁定实体实例。这样,在同一时刻只有一个用户可以编辑这个实体实例。

悲观锁机制也可用于管理其它任何任务的并发处理,它提供的关键的好处在于锁是分布式的,这是因为锁会在中间件集群中进行复制。JavaDocs 中提供了更多有关 LockManagerAPILockService 接口的详细信息。

可以使用 Administration > Locks > Setup 界面在应用程序开发或生产环境为任何实体类启用悲观锁,或者进行如下操作:

  • SYS_LOCK_CONFIG 表中插入一条包含以下字段值的新记录:

    • ID – 任意 UUID 类型的标识符。

    • NAME – 要锁定的对象的名称。对于实体,应该是其元类的名称。

    • TIMEOUT_SEC – 以秒为单位的锁定超时时间。

    例如:

    insert into sys_lock_config (id, create_ts, name, timeout_sec) values (newid(), current_timestamp, 'sales_Order', 300)
  • 重启应用程序服务或调用 app-core.cuba:type=LockManager JMX bean 的 reloadConfiguration() 方法。

可以通过 app-core.cuba:type=LockManager JMX bean 或通过 Administration > Locks 界面跟踪当前的锁状态。此界面还可以对任何对象进行解锁。

3.9.13. 使用 QueryRunner 执行 SQL

QueryRunner 是一个用于执行 SQL 的类。在以下情况应该使用它来代替 JDBC:必须使用纯 SQL,并且不需要使用具有相同功能的ORM 工具

平台的 QueryRunner 是 Apache DbUtils QueryRunner 的一种变体,增加了对 Java 泛型的支持。

用法示例:

QueryRunner runner = new QueryRunner(persistence.getDataSource());
try {
  Set<String> scripts = runner.query("select SCRIPT_NAME from SYS_DB_CHANGELOG",
          new ResultSetHandler<Set<String>>() {
              public Set<String> handle(ResultSet rs) throws SQLException {
                  Set<String> rows = new HashSet<String>();
                  while (rs.next()) {
                      rows.add(rs.getString(1));
                  }
                  return rows;
              }
          });
  return scripts;
} catch (SQLException e) {
  throw new RuntimeException(e);
}

有两种使用 QueryRunner 的方法:在当前事务或自动提交模式的单独事务中使用。

  • 要在当前事务中使用 QueryRunner 查询必须使用无参数构造函数创建 QueryRunner 的实例 。然后,应该使用 EntityManager.getConnection() 返回的 Connection 作为参数来调用 query()update() 方法。在查询之后不需要关闭 Connection,因为连接会在提交事务时关闭。

  • 要在单独的事务中运行查询,必须调用带参数的构造函数创建 QueryRunner 实例,该构造函数使用 Persistence.getDataSource() 方法返回的 DataSource 作为参数。然后,调用 query()update() 方法,不需要 Connection 参数。这时将从指定的 DataSource 创建连接,查询完成后这个连接会立即关闭。

3.9.14. 计划任务的执行

平台提供了两种运行计划任务的方法:

  • Spring Framework 中标准的 TaskScheduler 机制。

  • 使用平台自身的计划任务执行机制。

3.9.14.1. Spring 任务调度

Spring Framework 手册的 Task Execution and Scheduling 部分详细描述了这种机制。

TaskScheduler 可用于在中间层和客户端层的任何应用程序块(block)中运行任意的 Spring bean 对象方法。

spring.xml中的配置示例:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:task="http://www.springframework.org/schema/task"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task-4.3.xsd">

  <!--...-->

  <task:scheduled-tasks scheduler="scheduler">
      <task:scheduled ref="sales_Processor" method="someMethod" fixed-rate="60000"/>
      <task:scheduled ref="sales_Processor" method="someOtherMethod" cron="0 0 1 * * MON-FRI"/>
  </task:scheduled-tasks>
</beans>

在上面的例子中,声明了两个任务,它们调用 sales_Processor bean 的 someMethod() 方法和 someOtherMethod() 方法。从应用程序启动时起,将以固定的时间间隔(60 秒)调用 someMethod() 方法。根据 Cron 表达式定义的时间表调用 someOtherMethod() 方法(有关此表达式格式的描述,请参阅 https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/support/CronSequenceGenerator.html )。

任务的实际启动由 scheduled-tasks 元素的 scheduler 属性中指定的 bean 执行。它是 CubaThreadPoolTaskScheduler 类型的 bean,在 cuba 应用程序组件的 coreweb 模块中配置(参阅 cuba-spring.xmlcuba-web-spring.xml )。该类提供处理一些 CUBA 框架内特定任务的功能。

如果要向中间层的 Spring 定时任务执行的代码提供SecurityContext,请使用系统身份验证

3.9.14.2. CUBA 计划任务

CUBA 计划任务机制用于有计划地执行中间件中任意 Spring bean 方法。此机制的目标与上述标准 Spring Framework 调度程序的区别在于:

  • 能够在应用程序运行过程中时配置任务而无需重启服务。

  • 在中间件集群中协调单例任务的执行,提供以下功能:

    • 可有效地防止任务同时执行。

    • 按优先级将任务绑定到服务。

单例(singleton) 任务指在同一时刻只允许在一个服务上执行的任务。例如,从队列中读取并发送电子邮件的任务。

3.9.14.2.1. 任务注册

任务在数据库表 SYS_SCHEDULED_TASK 中注册,该表对应于 ScheduledTask 实体。平台提供了用于管理任务的浏览和编辑界面: AdministrationScheduled Tasks

下面是关于任务属性的描述:

  • Defined by – 描述哪个软件对象实现该任务。可能的值是:

    • Bean – 该任务由 Springbean的方法实现。额外属性:

      • Bean name – Spring bean 的名称。

        只有在 core 模块中定义并且具有接口(该接口包含适合任务调用的方法)时,才会列出该 bean 并且可供选择。不支持没有接口的 bean。

      • Method name – 执行的 bean 接口方法。该方法要么没有参数,要么所有参数必须是 String 类型。方法的返回值类型可以是 voidString。后一种情况的返回值将存储在执行表中(参阅下面的 Log finish )。

      • Method parameters – 所选方法的参数。仅支持 String 类型的参数。

    • Class – 该任务是一个实现 java.util.concurrent.Callable 接口的类。该类必须具有 public 修饰符的无参构造函数。附加属性:

      • Class name – 类名。

    • Script – 该任务是一个 Groovy 脚本。该脚本由Scripting.runGroovyScript()执行。附加属性:

      • Script name – 脚本名。

  • User name – 一个用户名,以其身份执行任务。如果未指定,则以cuba.jmxUserLogin应用程序属性中指定的用户的身份来执行该任务。

  • Singleton – 表示该任务是单例,即应该只在一个应用程序服务上运行。

  • Scheduling type – 任务调度的方式:

    • Cron – Cron 表达式是由六个字段组成的序列,用空格分隔:秒、分钟、小时、日、月、星期。月份和星期可以用英文名称的前三个字母表示。例如:

      • 0 0 * * * * – 每天每小时的开始时刻

      • */10 * * * * * – 每 10 秒钟

      • 0 0 8-10 * * * – 每天 8 点、9 点和 10 点

      • 0 0/30 8-10 * * * – 每天 8:00、 8:30、 9:00、 9:30 和 10 点

      • 0 0 9-17 * * MON-FRI – 工作日每天 9 点到 17 点的整点时刻

      • 0 0 0 25 DEC ? – 每个圣诞节的午夜 12 点

    • Period – 以秒为单位周期性执行

    • Fixed Delay – 完成前一次执行后,将延迟在 Period 中指定的时间之后再次执行任务。

  • Period – 如果 Scheduling typePeriodFixed Delay,任务将以秒为单位周期性重复执行或延迟固定时间后执行。

  • Start date – 首次执行的日期或时间。如果未指定,则在服务启动时立即执行该任务。如果指定,则在 startDate + period * N 启动执行任务,其中 N 是整数。对于周期任务来说,N 可以是大于 1 的数,对延迟任务来说 N 是 1。

    只为偶发任务指定 Start date 是合理的,即每小时运行一次、每天运行一次等。

  • Timeout – 以秒为单位的时间,到期时无论是否存在关于任务完成的信息,都认为任务已执行完成。如果未明确设置超时时间,则假定为 3 小时。

    推荐在使用集群部署时,将超时时限(timeout)设置为一个切实可行的值。如果使用标准值,当一个正在执行计划任务的集群节点宕机之后,其他节点要等候 3 小时才开始执行新的任务。

  • Time frame – 如果指定了 Start date ,Time frame 定义了以秒为单位的时间窗口,任务将在 startDate + period * N 时间到期后的 Time frame 秒内启动。如果没有明确指定 Time frame,则它等于 period / 2

    如果未指定 Start date,则忽略 Time frame,即任务将在上一次任务到期执行后的 Period 之后的任何时间启动。

  • Start delay - 服务启动并激活调度后,延迟执行的秒数。如果任务会拖慢服务启动的速度,可考虑为任务设置此参数。

  • Permitted servers – 以逗号分隔的具有运行此任务权限的服务器标识符列表,如果未指定列表,则可以在任何服务器上执行该任务。

    对于单例任务,列表中服务器的顺序决定了执行优先级:第一个服务器的优先级高于最后一个。具有较高优先级的服务器将拦截单例的执行,如下所示:如果具有较高优先级的服务器检测到该任务先前已由具有较低优先级的服务器执行,则无论 Period 是否过期,它都会启动该任务。

    服务器优先级仅在 Scheduling typePeriod 并且未指定 Start date 属性时有效。否则,服务会同时开始,也就没有机会进行拦截了。

  • Log start – 标记任务启动是否应该在 SYS_SCHEDULED_EXECUTION 表中记录,该表对应于 ScheduledExecution 实体。

    在目前的实现中,如果任务是单例,无论此标志是什么都会记录启动状态。

  • Log finish – 标记任务完成是否应该在 SYS_SCHEDULED_EXECUTION 表中记录,该表对应于 ScheduledExecution 实体。

    在目前的实现中,如果任务是单例,则无论此标志是什么都会记录完成状态。

  • Description – 任务的文本描述。

任务也具有激活标志,可以在任务列表界面中设置。非激活任务会被忽略。

3.9.14.2.2. 任务处理控制
  • 为了启用任务处理,cuba.schedulingActive应用程序属性应设置为 true。可以在 Administration > Application Properties 界面中执行此操作,也可以通过 app-core.cuba:type=Scheduling JMX bean(请参阅它的 Active 属性)执行此操作。

  • 一切通过系统界面对任务所做的更改将立即对集群中的所有服务器生效。

  • app-core.cuba:type=Scheduling JMX bean 的 removeExecutionHistory() 方法可用于删除旧的执行历史记录。该方法有两个参数:

    • age – 任务执行后经过的时间(以小时为单位)。

    • maxPeriod – 应删除的任务执行历史记录的最大 Period(以小时为单位)。这样可以仅删除频繁执行的任务的历史记录,同时保留每天一次执行历史记录。

      该方法可以自动调用。使用以下参数创建新任务:

      • Bean namecuba_SchedulingMBean

      • Method nameremoveExecutionHistory(String age, String maxPeriod)

      • Method parameters – 例如,age = 72,maxPeriod = 12。

3.9.14.2.3. 调度实现细节
  • 任务处理(SchedulingAPI.processScheduledTasks() 方法)的调用时间间隔在 cuba-spring.xml 中设定,默认为 1 秒。它设置了调用任务之间的最小间隔,该间隔应该高于两倍,即 2 秒。建议不要降低这些值。

  • 目前定时任务调度的实现是使用数据库表中的行级锁实现的同步。这表示在高负载下,数据库可能无法及时响应调度程序,并且可能需要增加启动间隔(大于 1 秒),因此启动任务的最小周期将相应增加。

  • 如果未设定 允许的服务器(Permitted servers) 属性,单例任务仅在群集中的主节点上执行(如果满足其它条件)。需要注意的是群集外的独立服务器也被视为主服务器。

  • 如果先前执行的任务尚未完成且指定的 Timeout 尚未到期,则不会启动该任务。对于当前实现中的单例任务,这是使用数据库中的信息实现的;对于非单例任务,执行状态表保存在服务器内存中。

  • 此执行机制为用户创建并缓存用户会话,可以在任务的 User name 属性中指定,或者在cuba.jmxUserLogin应用程序属性中指定。会话信息可以通过标准的UserSessionSource接口在已启动任务的执行线程中获得。

正确执行单例任务需要中间件服务器精确的时间同步!

参阅 URL 历史及导航 部分,这里描述了 URL 和应用程序界面映射的更多功能。

Web 客户端block允许通过 URL 中提供的命令打开应用程序界面。如果浏览器没有已登录用户的会话信息,则应用程序将首先显示登录界面,在身份验证成功后进入应用程序主窗口,同时打开请求的界面。

支持的命令列表由cuba.web.linkHandlerActions应用程序属性定义。默认情况下是 openo。在处理 HTTP 请求时,将分析 URL 的最后一部分,如果它与已注册的命令匹配,则将控制权传递给适当的处理器,该处理器是实现 LinkHandlerProcessor 接口的 bean。

平台提供了一个接受以下请求参数的处理器:

  • screenscreens.xml中定义的界面名称,例如:

    http://localhost:8080/app/open?screen=sec$User.browse
  • item – 要传递给编辑界面的实体实例,根据 EntityLoadInfo 类的约定进行编码,即 entityName-instanceIdentityName-instanceId-viewName。例如:

    http://localhost:8080/app/open?screen=sec$User.edit&item=sec$User-60885987-1b61-4247-94c7-dff348347f93
    
    http://localhost:8080/app/open?screen=sec$User.edit&item=sec$User-60885987-1b61-4247-94c7-dff348347f93-user.edit

    要在打开的编辑器界面中直接创建新的实体实例,请在实体类名称前添加 NEW- 前缀,例如:

    http://localhost:8080/app/open?screen=sec$User.edit&item=NEW-sec$User
  • params – 界面控制器init() 方法的参数。参数格式为 name1:value1,name2:value2。参数值可以包括根据 EntityLoadInfo 类的约定编码的实体实例。例如:

    http://localhost:8080/app/open?screen=sales_Customer.lookup&params=p1:v1,p2:v2
    
    http://localhost:8080/app/open?screen=sales_Customer.lookup&params=p1:sales_Customer-01e37691-1a9b-11de-b900-da881aea47a6

如果要提供其它 URL 命令,请执行以下操作:

  • 在项目的 web 模块中创建一个实现了 LinkHandlerProcessor 接口的bean

  • 如果应当由新的 bean 处理当前的 URL(URL 参数存储在 ExternalLinkContext 对象),那么这个 bean 的 canHandle() 方法必须返回 true。

  • handle() 方法中执行请求的操作。

bean 可以选择实现 Spring 的 Ordered 接口或包含 Order 注解。这样,可以在处理器链中指定 bean 的顺序。使用 LinkHandlerProcessor 接口的 HIGHEST_PLATFORM_PRECEDENCELOWEST_PLATFORM_PRECEDENCE 常量将 bean 放在平台中定义的处理器之前或之后。因此,如果指定的顺序小于 HIGHEST_PLATFORM_PRECEDENCE,则会更早地请求 bean,并且可以根据需要覆盖平台处理器定义的操作。

3.9.16. 序列生成

该机制可以通过单个 API 生成唯一的数字序列,并且与 DBMS 类型无关。

这个机制的主要部分是实现了 UniqueNumbersAPI 接口的 UniqueNumbers bean。这个 bean 对象可以在中间件block中找到。这个接口具有以下方法:

  • getNextNumber(String domain) – 获取序列的下一个值。该机制能够同时管理由任意字符串标识的多个序列。需要获取值的序列的名称通过 domain 参数传递。

    序列不需要初始化。当第一次调用 getNextNumber() 方法时,将创建相应的序列并返回 1。

  • getCurrentNumber(String domain) – 获得当前值,也就是序列生成的最后一个值。domain 参数设置序列名称。

  • setCurrentNumber(String domain) – 设置序列的当前值。下一次调用 getNextNumber() 方法时将返回这个值递增 1 之后的值。

下面是在一个中间件 bean 对象中获取序列中的下一个值的示例:

@Inject
private UniqueNumbersAPI uniqueNumbers;

private long getNextValue() {
  return uniqueNumbers.getNextNumber("mySequence");
}

服务getNextNumber() 方法用于在客户端 block 中获取序列值。

UniqueNumbersAPI 拥有相同方法的 app-core.cuba:type=UniqueNumbers JMX bean用于管理序列。

序列生成机制的实现取决于 DBMS 类型。序列参数也可以直接在数据库中管理,但方式不同。

  • 对于 HSQL、 Microsoft SQL Server 2012+ 、 PostgreSQL 和 Oracle,每个 UniqueNumbersAPI 序列对应于数据库中名称为 SEC_UN_{domain} 格式的数据库原生序列。

  • 对于 2012 之前的 Microsoft SQL Server,每个序列对应一个带有 IDENTITY 类型主键的 SEC_UN_{domain} 表。

  • 对于 MySQL,序列对应于 SYS_SEQUENCE 表中的记录。

3.9.17. 用户会话日志

这个机制用于使系统管理员可以获得用户登录和注销的历史数据。日志记录机制基于跟踪用户会话。每次创建 UserSession 对象时,日志记录都会将以下字段信息保存到数据库中:

  • 用户会话 ID。

  • 用户 ID。

  • 代替用户 ID。

  • 用户的最后一次动作(登录/注销/超时/终止)。

  • 请求登录的远程 IP 地址。

  • 用户会话的客户端类型(网页、门户)。

  • 服务器 ID(例如,localhost:8080/app-core)。

  • 事件开始日期。

  • 事件结束日期。

  • 客户端信息(会话环境:操作系统、Web 浏览器等)。

默认情况下,不启用用户会话日志记录机制。启用日志记录机制的最简单方法是使用 Administration > User Session Log 应用界面上的 Enable Logging 按钮。或者使用 cuba.UserSessionLogEnabled 应用属性。

如果需要的话可以创建 sec$SessionLogEntry 实体的报表。

3.10. 功能扩展

平台可以在应用程序中扩展和覆盖其以下方面的功能:

  • 扩展实体属性集。

  • 扩展界面功能。

  • 扩展和覆盖 Spring bean 中包含的业务逻辑。

下面是前两个操作的示例,通过将 "Address" 字段添加到平台安全子系统User 实体来说明。

3.10.1. 扩展实体

在应用程序项目中,从 com.haulmont.cuba.security.entity.User 中派生一个实体类,添加需要的属性和相应的访问方法:

@Entity(name = "sales_ExtUser")
@Extends(User.class)
public class ExtUser extends User {

    @Column(name = "ADDRESS", length = 100)
    private String address;

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

应使用@Entity注解指定实体的新名称。由于父实体没有定义继承策略,因此使用的是默认继承策略 SINGLE_TABLE。这意味着子实体将与父实体存储在同一个表中,并且不需要给子实体添加@Table注解。其它父实体注解(@NamePattern@Listeners等)会自动应用于子实体,并且可以在子实体中覆盖。

新实体类的一个重要元素是 @Extends 注解,它需要一个父类作为参数。它可以创建一个子实体的注册表,并强制平台机制在所有地方使用子实体来代替父实体。注册表由 ExtendedEntities 类实现,该类是一个名为 cuba_ExtendedEntitiesSpring bean,也可以通过元数据接口访问。

将新属性的本地化名称添加到 com.sample.sales.entity消息包

messages.properties

ExtUser.address=Address

messages_ru.properties

ExtUser.address=Адрес

在项目的persistence.xml文件中注册新实体:

<class>com.sample.sales.entity.ExtUser</class>

将相应表的更新脚本添加到数据库创建和更新脚本

-- add column for "address" attribute
alter table SEC_USER add column ADDRESS varchar(100)
^
-- add discriminator column required for entity inheritance
alter table SEC_USER add column DTYPE varchar(100)
^
-- set discriminator value for existing records
update SEC_USER set DTYPE = 'sales_ExtUser' where DTYPE is null
^

要在界面中使用新的实体属性,请为新实体创建视图,新视图的名称与基础实体的名称保持一致。新视图应继承基础视图并定义新属性,例如:

<view class="com.sample.sales.entity.ExtUser"
      name="user.browse"
      extends="user.browse">

    <property name="address"/>
</view>

继承的视图将包含其父视图中的所有属性。如果基础视图继承 _local 视图,并且在新视图中只添加本地属性,则不需要继承视图,在这种情况下,可以省略该步骤。

3.10.2. 扩展界面

平台支持通过继承现有的界面描述来创建新的界面 XML 描述

通过在 window 根元素的 extends 属性中指定父描述路径来实现 XML 继承。

XML 界面元素覆盖规则:

  • 如果新扩展的界面描述中有某个元素,则将使用以下算法在父描述中搜索相应的元素:

    • 如果覆盖的元素有 id 属性,则会在父描述中搜索具有相同 id 的相应元素。

    • 如果搜索成功,则找到的元素被 覆盖

    • 否则,平台将先确定父描述中包含具有提供的路径和名称的元素数量。如果只有一个元素,则它被 覆盖

    • 如果搜索没有产生结果,并且在父描述中没有具有给定路径和名称的元素或者有多个这种元素,则会 添加 新元素。

  • 被覆盖或添加的元素的文本将从扩展的新元素中复制。

  • 扩展的新元素的所有属性都会复制到被覆盖或添加的元素中。如果属性名称匹配,则从扩展的新元素中获取值。

  • 默认情况下,新元素将添加到元素列表的末尾。要将新元素添加到开头或使用任意位置,可以执行以下操作:

    • 在继承描述中定义一个额外的命名空间: xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd"

    • 添加带有所需索引的 ext:index 属性到扩展元素,例如:ext:index="0"

要调试 XML 描述的转换过程,可以通过在 Logback 配置文件中将 com.haulmont.cuba.gui.xml.XmlInheritanceProcessor 记录器指定为 TRACE 级别,从而将结果 XML 输出到服务端日志。

扩展历史版本界面

框架中包含了一组使用历史 API实现的历史界面,以提供向后兼容性。下面这个例子是扩展安全子系统中实体 User 的界面。

首先,看看 ExtUser 实体的浏览界面:

ext-user-browse.xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd"
        extends="/com/haulmont/cuba/gui/app/security/user/browse/user-browse.xml">
    <layout>
        <groupTable id="usersTable">
            <columns>
                <column ext:index="2" id="address"/>
            </columns>
        </groupTable>
    </layout>
</window>

在此示例中,XML 描述继承自框架的标准 User 实体浏览界面。address 列以 2 为索引被添加到表中,因此它在 loginname 列之后显示。

如果在screens.xml中注册一个新界面,新界面使用与父界面相同的标识符,这样新界面就会代替旧界面。

<screen id="sec$User.browse"
        template="com/sample/sales/gui/extuser/extuser-browse.xml"/>
<screen id="sec$User.lookup"
        template="com/sample/sales/gui/extuser/extuser-browse.xml"/>

同样,创建一个编辑界面:

ext-user-edit.xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd"
        extends="/com/haulmont/cuba/gui/app/security/user/edit/user-edit.xml">
    <layout>
        <groupBox id="propertiesBox">
            <grid id="propertiesGrid">
                <rows>
                    <row id="propertiesRow">
                        <fieldGroup id="fieldGroupLeft">
                            <column>
                                <field ext:index="3" id="address" property="address"/>
                            </column>
                        </fieldGroup>
                    </row>
                </rows>
            </grid>
        </groupBox>
    </layout>
</window>

使用父界面的标识符在 screens.xml 中注册:

<screen id="sec$User.edit"
        template="com/sample/sales/gui/extuser/extuser-edit.xml"/>

一旦上面提到的这些步骤都完成了,应用程序会使用 ExtUser 以及相应的界面替换平台中标准的 User 实体和界面。

界面控制器可以通过创建继承自界面控制器基类的新类进行扩展。类名需要在扩展的 XML 描述中通过根元素的 class 属性指定;上面提到的 XML 继承通用规则也会有效。

使用 CUBA Studio 扩展界面

这个例子中,我们将对应用程序组件示例中提到的客户管理组件的 Customer 实体界面进行扩展,为其客户浏览表格添加一个 Excel 按钮,用来导出 Excel 表格。

  1. 在 Studio 中创建一个新项目,并添加 Customer Manangemnt 组件。

  2. 在 CUBA 项目树中右键点击 Generic UI,右键菜单中选择 New > Screen。然后在 Screen Templates 标签页选择 Extend an existing screen。在 Extend Screen 列表中,选择 customer-browse.xml。然后会在 web 模块创建新的 ext-customer-browse.xmlExtCustomerBrowse.java 文件。

  3. 打开 ext-customer-browse.xml 切换到 Designer 标签页。父界面的组件会展示在设计器的工作区。

  4. 选择 customersTable 并添加一个新的 excel 操作

  5. buttonsPanel 添加一个按钮,链接到 customersTable.excel 操作。

最后,ext-customer-browse.xml 的代码在 Text 标签页是如下:

ext-customer-browse.xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        messagesPack="com.company.sales2.web"
        extends="com/company/customers/web/customer/customer-browse.xml">
    <layout>
        <groupTable id="customersTable">
            <actions>
                <action id="excel" type="excel"/>
            </actions>
            <buttonsPanel id="buttonsPanel">
                <button id="excelButton" action="customersTable.excel"/>
            </buttonsPanel>
        </groupTable>
    </layout>
</window>

再看看 ExtCustomerBrowse 界面控制器。

ExtCustomerBrowse.java
@UiController("customers_Customer.browse")
@UiDescriptor("ext-customer-browse.xml")
public class ExtCustomerBrowse extends CustomerBrowse {
}

由于界面标识符 customers_Customer.browse 与父界面的标识符一样,因此,每次调用时会用新界面替换旧界面。

3.10.3. 扩展业务逻辑

平台业务逻辑的主要部分包含在 Spring bean 中。这样可以在应用程序中轻松地继承或重写它。

要替换 bean 实现,应该创建一个类来实现接口或继承平台基础类并在应用程序的spring.xml中注册它。不能将 @Component 注解用到于继承类;只能在 XML 配置中覆盖 bean。

下面是向PersistenceTools bean 添加方法的示例。

首先,创建一个带有必要方法的类:

public class ExtPersistenceTools extends PersistenceTools {

  public Entity reloadInSeparateTransaction(final Entity entity, final String... viewNames) {
      Entity result = persistence.createTransaction().execute(new Transaction.Callable<Entity>() {
          @Override
          public Entity call(EntityManager em) {
              return em.reload(entity, viewNames);
          }
      });
      return result;
  }
}

在项目 core 模块的 spring.xml 中注册类,其标识符与平台 bean 相同:

<bean id="cuba_PersistenceTools" class="com.sample.sales.core.ExtPersistenceTools"/>

之后,Spring 上下文将始终返回 ExtPersistenceTools 实例而不是基类 PersistenceTools 的 实例。检查代码示例:

Persistence persistence;
PersistenceTools tools;

persistence = AppBeans.get(Persistence.class);
tools = persistence.getTools();
assertTrue(tools instanceof ExtPersistenceTools);

tools = AppBeans.get(PersistenceTools.class);
assertTrue(tools instanceof ExtPersistenceTools);

tools = AppBeans.get(PersistenceTools.NAME);
assertTrue(tools instanceof ExtPersistenceTools);

可以使用相同的逻辑来重写应用程序组件中的服务,例如:要替换 bean 实现,应该创建一个类,这个类扩展基础服务功能。在下面的示例中,创建了一个新类 NewOrderServiceBean ,并重写基类 OrderServiceBean 中的方法:

public class NewOrderServiceBean extends OrderServiceBean {
    @Override
    public BigDecimal calculateOrderAmount(Order order) {
        BigDecimal total = super.calculateOrderAmount(order);
        BigDecimal vatPercent = new BigDecimal(0.18);
        return total.multiply(BigDecimal.ONE.add(vatPercent));
    }
}

然后,如果在 spring.xml 中注册新创建的这个类,平台将会使用新的实现代替旧的实现 OrderServiceBean 。请注意,注册新的 bean 时,id 属性应该与应用程序组件中的基础服务的 id 一致,在 class 属性中指定新类的完全限定名:

<bean id="workshop_OrderService" class="com.company.retail.service.NewOrderServiceBean"/>

3.10.4. Servlet 和过滤器的注册

应用程序组件中定义的 servlet 和 filter,需要以编程的方式进行注册。一般来说,它们在web.xml配置文件中注册过了,但是组件的 web.xml 配置在目标应用程序中不会生效。

ServletRegistrationManager bean 能够使用正确的 ClassLoader 动态注册 servlet 和 filter,并允许使用类似于AppContext的静态类。它还能保证对于各种部署选项,都能正确工作。

ServletRegistrationManager 有两个方法:

  1. createServlet() - 创建给定 servlet 类的 servlet。它使用从应用程序上下文对象获取的正确的 ClassLoader 加载 servlet 类。这意味着新的 servlet 将能够使用平台的一些静态类,例如,AppContextMessages bean。

  2. createFilter() - 以相同的方式创建过滤器.

要使用这个 bean,建议在应用程序组件中创建一个初始化 bean。这个 bean 需要包含监听 ServletContextInitializedEventServletContextDestroyedEvent 的监听器。

示例:

@Component
public class WebInitializer {

    @Inject
    private ServletRegistrationManager servletRegistrationManager;

    @EventListener
    public void initializeHttpServlet(ServletContextInitializedEvent e) {
        Servlet myServlet = servletRegistrationManager.createServlet(
                e.getApplicationContext(), "com.demo.comp.MyHttpServlet");

        e.getSource().addServlet("my_servlet", myServlet)
                .addMapping("/myservlet/*");
    }
}

这里的 WebInitializer 类只有一个事件监听器,用于从组件中为目标应用程序注册 HTTP servlet。

createServlet() 方法使用从 ServletContextInitializedEvent 获取的应用程序上下文和 HTTP servlet 的完全限定名。然后使用名称(my_servlet)注册 servlet 并定义 HTTP-mapping(/myservlet/)。现在,如果将此应用程序组件添加到应用程序中,将在初始化 servlet 和应用程序上下文后会立即注册 MyHttpServlet

Servlet 是用 myservlet 映射进行注册的,则根据应用程序上下文的不同,访问路径可以是: /app/myservlet 或者 /app-core/myservlet/

相关更复杂的示例,请参阅在应用程序组件中注册 DispatcherServlet部分。

3.10.5. 编程方式注册 Main Servlets 和 Filters

通常,main servlets(CubaApplicationServletCubaDispatcherServlet)以及 filters(CubaHttpFilter)在 Web Client block 的 web.xml 配置文件注册:

<servlet>
    <servlet-name>app_servlet</servlet-name>
    <servlet-class>com.haulmont.cuba.web.sys.CubaApplicationServlet</servlet-class>
    <async-supported>true</async-supported>
</servlet>
<servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>com.haulmont.cuba.web.sys.CubaDispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/dispatch/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
    <servlet-name>app_servlet</servlet-name>
    <url-pattern>/*</url-pattern>
</servlet-mapping>

<filter>
    <filter-name>cuba_filter</filter-name>
    <filter-class>com.haulmont.cuba.web.sys.CubaHttpFilter</filter-class>
    <async-supported>true</async-supported>
</filter>
<filter-mapping>
    <filter-name>cuba_filter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

但是有时需要以编程的方式进行注册。

下面是初始化 Main Servlets 和 Filters 的一个 bean 示例:

@Component(MainServletsInitializer.NAME)
public class MainServletsInitializer {

    public static final String NAME = "demo_MainServletsInitializer";

    @Inject
    protected ServletRegistrationManager servletRegistrationManager;

    @EventListener
    public void initServlets(ServletContextInitializedEvent event){
        initAppServlet(event); (1)
        initDispatcherServlet(event); (2)
        initCubaFilter(event); (3)
    }

    protected void initAppServlet(ServletContextInitializedEvent event) {
        CubaApplicationServlet cubaServlet = (CubaApplicationServlet) servletRegistrationManager.createServlet(
                event.getApplicationContext(),
                "com.haulmont.cuba.web.sys.CubaApplicationServlet");
        cubaServlet.setClassLoader(Thread.currentThread().getContextClassLoader());
        ServletRegistration.Dynamic registration = event.getSource()
                .addServlet("app_servlet", cubaServlet); (4)
        registration.setLoadOnStartup(0);
        registration.setAsyncSupported(true);
        registration.addMapping("/*");
        JSR356WebsocketInitializer.initAtmosphereForVaadinServlet(registration, event.getSource());  (5)
        try {
            cubaServlet.init(new AbstractWebAppContextLoader.CubaServletConfig("app_servlet", event.getSource()));  (6)
        } catch (ServletException e) {
            throw new RuntimeException("An error occurred while initializing app_servlet servlet", e);
        }
    }

    protected void initDispatcherServlet(ServletContextInitializedEvent event) {
        CubaDispatcherServlet cubaDispatcherServlet = (CubaDispatcherServlet) servletRegistrationManager.createServlet(
                event.getApplicationContext(),
                "com.haulmont.cuba.web.sys.CubaDispatcherServlet");
        try {
            cubaDispatcherServlet.init(
                    new AbstractWebAppContextLoader.CubaServletConfig("dispatcher", event.getSource()));
        } catch (ServletException e) {
            throw new RuntimeException("An error occurred while initializing dispatcher servlet", e);
        }
        ServletRegistration.Dynamic cubaDispatcherServletReg = event.getSource()
                .addServlet("dispatcher", cubaDispatcherServlet);
        cubaDispatcherServletReg.setLoadOnStartup(1);
        cubaDispatcherServletReg.addMapping("/dispatch/*");
    }

    protected void initCubaFilter(ServletContextInitializedEvent event) {
        CubaHttpFilter cubaHttpFilter = (CubaHttpFilter) servletRegistrationManager.createFilter(
                event.getApplicationContext(),
                "com.haulmont.cuba.web.sys.CubaHttpFilter");
        FilterRegistration.Dynamic registration = event.getSource()
                .addFilter("cuba_filter", cubaHttpFilter);
        registration.setAsyncSupported(true);
        registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*");
    }
}
1 - 注册并初始化 CubaApplicationServlet
2 - 注册并初始化 CubaDispatcherServlet
3 - 注册并初始化 CubaHttpFilter
4 - 我们需要先注册 servlet 才能初始化 Atmosphere 框架
5 - 显式初始化 JSR 356
6 - 初始化 servlet

参考 SingleAppWebContextLoader 了解细节。

4. 应用程序开发

本章节从实践角度介绍了怎么开发基于 CUBA 框架的应用程序。

代码格式

  • 对于 Java 和 Groovey 代码,推荐按照 Java 代码规范 定义的编码标准。如果是在 IntelliJ IDEA 中编码,可以使用默认的编码格式,或者使用快捷键 Ctrl-Alt-L 进行代码格式化。

    每行最大长度是 120 个字符,缩进 4 个字符,用空格替换 tab。

  • XML 代码: 缩进 4 个字符,用空格替换 tab。

命名规范

标识符 命名规则 示例

Java 和 Groovy 类

界面控制器类

UpperCamelCase(首字母大写驼峰)

浏览界面控制器 − {EntityClass}Browse

编辑界面控制器 − {EntityClass}Edit.

CustomerBrowse

OrderEdit

XML 界面描述文件

组件标识符,查询语句中参数名称

lowerCamelCase(首字母小写驼峰),只包含字母和数字

attributesTable

:component$relevantTo

:ds$attributesDs

数据源标识符

lowerCamelCase(首字母小写驼峰),只包含字母和数字以 Ds 结尾。

attributesDs

SQL 脚本

保留字

lowercase(小写)

create table

表名

UPPER_CASE(单词全大写下划线分隔)。名称需要以项目名称开头以区分命名空间。推荐表名使用单数形式,比如 ORDER,而不是 ORDERS

SALES_CUSTOMER

列名

UPPER_CASE(单词全大写下划线分隔)

CUSTOMER

TOTAL_AMOUNT

外键列名

UPPER_CASE(单词全大写下划线分隔),包含此列指向的表名(去掉项目前缀)加上_ID 后缀。

CUSTOMER_ID

索引名

UPPER_CASE(单词全大写下划线分隔),以 IDX_开头,包含带有项目名称的表名加上作为索引字段的名称。

IDX_SALES_CUSTOMER_NAME

4.2. 项目文件结构

以下是一个简单应用程序 Sales 的项目文件结构,由 MiddlewareWeb Client blocks 组成。

project structure
Figure 45. 项目文件结构

项目根目录包含构建脚本 build.gradlesettings.gradle

modules 目录包含项目的模块子目录 - globalcoreweb

global 模块包含代码目录 src,里面有三个配置文件 - metadata.xmlpersistence.xmlviews.xmlcom.sample.sales.service 包里面有 Middleware 服务的接口代码;com.sample.sales.entity 包里面有实体类以及他们的本地消息文件

project structure global
Figure 46. global 模块结构

core 模块包含以下目录:

project structure core
Figure 47. core 模块结构

web 模块包含以下目录:

project structure web
Figure 48. web 模块结构

4.3. 构建脚本

基于 CUBA 框架的项目采用 Gradle 系统来构建。构建脚本是在项目根目录下的两个文件:

  • settings.gradle – 定义项目名称和模块组

  • build.gradle – 构建配置文件。

本章节介绍构建脚本的结构以及 Gradle 任务参数说明。

4.3.1. build.gradle 的结构

本章节介绍 build.gradle 脚本的结构和主要元素。

buildscript

脚本的 buildscript 部分定义了以下内容:

  • 平台的版本。

  • 一组用来加载项目依赖的 仓库。查看仓库章节了解如何配置仓库。

  • 构建系统的依赖,包括 CUBA 的 Gradle 插件。

buildscript 下面,是一些变量的定义。会在后面的脚本中用到。

cuba

CUBA 特殊的构建逻辑封装在 cuba Gradle 插件里。CUBA 插件在构建脚本的根节点引用,同时也需要在所有模块的 configure 部分使用下面这个语句引用进来:

apply(plugin: 'cuba')

cuba 插件的配置在 cuba 部分定义:

cuba {
    artifact {
        group = 'com.company.sales'
        version = '0.1'
        isSnapshot = true
    }
    tomcat {
        dir = "$project.rootDir/build/tomcat"
    }
    ide {
        copyright = '...'
        classComment = '...'
        vcs = 'Git'
    }
}

以下是一些可选的参数:

  • artifact - 这里定义项目工件的分组和版本信息。工件的名称按照 settings.gradle 里面设置的模块名称来设置。

    • group - 工件组

    • version - 工件版本

    • isSnapshot - 如果设置 true,工件名称会被添加 SNAPSHOT 后缀。

      可以通过命令行参数来覆盖工件的版本,示例:

      gradle assemble -Pcuba.artifact.version=1.1.1
  • tomcat - 这部分定义了用来 快速部署的 Tomcat 服务的设置。

    • dir - Tomcat 的 安装目录。

    • port - Tomcat 监听端口,默认 8080。

    • debugPort - Java 调试监听端口,默认 8787。

    • shutdownPort - 监听 SHUTDOWN 命令的端口,默认 8005。

    • ajpPort - AJP 连接器端口,默认 8009。

  • ide - 这部分包含 Studio 和 IDE 的内容

    • vcs - 项目的版本控制系统配置,目前只支持 Git 或者 svn

    • copyright - 插入到每个源文件开头的版权信息。

    • classComment - Java 源文件类声明开头插入的注释信息。

  • uploadRepository - 这部分定义了使用 uploadArchives 任务上传打包的项目工件目标 仓库的设置。

    • url - 仓库的地址 URL。如果不设置,默认会使用 Haulmont 的仓库地址。

    • user - 访问仓库的用户名。

    • password - 访问仓库的密码。

      也可以通过命令行参数的方式给这个上传仓库的任务提供参数:

      gradlew uploadArchives -PuploadUrl=http://myrepo.com/content/repositories/snapshots -PuploadUser=me -PuploadPassword=mypassword
dependencies

这部分包含了项目中使用的一组应用程序组件。有两种添加组件依赖的方式:appComponent - 用于添加 CUBA 应用程序组件;uberJar - 用于添加需要在应用程序启动之前加载的库。 组件通过他们各自的 global 模块来指定。在下面的例子中,使用了三个组件:com.haulmont.cuba (cuba 平台组件), com.haulmont.reports (reports premium 组件) 和 com.company.base (自定义组件):

dependencies {
  appComponent("com.haulmont.cuba:cuba-global:$cubaVersion")
  appComponent("com.haulmont.reports:reports-global:$cubaVersion")
  appComponent("com.company.base:base-global:0.1-SNAPSHOT")
}
configure

configure 部分包含模块的配置。其中最主要的部分就是声明依赖,示例:

configure(coreModule) {

    dependencies {
        // standard dependencies using variables defined in the script above
        compile(globalModule)
        provided(servletApi)
        jdbc(hsql)
        testRuntime(hsql)
        // add a custom repository-based dependency
        compile('com.company.foo:foo:1.0.0')
        // add a custom file-based dependency
        compile(files("${rootProject.projectDir}/lib/my-library-0.1.jar"))
        // add all JAR files in the directory to dependencies
        compile(fileTree(dir: 'libs', include: ['*.jar']))
    }

对于 corewebportal 模块(带有 CubaDeployment 类型 gradle 任务的模块 ),您可以通过 server 配置添加依赖。例如,在 UberJar 部署时,需要在应用程序启动之前访问依赖库,并且该库对于某个特定模块无论用何种部署选项都需要。那么,可以在模块中单独声明该依赖(这是必要的,比如,对于 WAR 部署时),因为如果在项目级别通过 uberjar 配置的话,会导致不必要的重复依赖。这些依赖会在执行 deploybuildWarbuildUberJar 任务时被放置在 server 的 libs 中。

entitiesEnhancing 配置模块用来对实体类进行字节码增强(weaving - 也叫织入),至少需要在 global 模块声明这个任务,也可以在其它模块分别声明。

这里的 maintest 分别是项目和测试的代码目录,可选的 persistenceConfig 参数用来指定各自的 persistence.xml 文件。如果这个可选参数没设置,这个任务会对 CLASSPATH 里能找到的 *persistence.xml 文件中的所有实体做增强。

configure(coreModule) {
    ...
    entitiesEnhancing {
        main {
            enabled = true
            persistenceConfig = 'custom-persistence.xml'
        }
        test {
            enabled = true
            persistenceConfig = 'test-persistence.xml'
        }
    }
}

非标准的模块依赖可以在 Studio 中通过 CUBA 项目视图的 Project properties 部分来设置。

对于动态版本依赖和版本冲突的问题,可以用 Maven 对此场景的解决方法。按照这个方法,正式版本的优先级会高于快照版本,而且越新的版本有越精确的版本编号。所有条件都一样的情况下,版本编号按照字母表的顺序定优先级,示例:

1.0-beta1-SNAPSHOT         // 最低优先级
1.0-beta1
1.0-beta2-SNAPSHOT         |
1.0-rc1-SNAPSHOT           |
1.0-rc1                    |
1.0-SNAPSHOT               |
1.0                        |
1.0-sp                     V
1.0-whatever
1.0.1                      // 最高优先级

有的时候,项目恰巧需要某些库的特定版本,但是由于传递依赖,导致这个库的另外一个版本已经出现在依赖树中了。比如,您需要使用 CUBA 平台的 7.2-SNAPSHOT 版本,但是同时又使用了一个基于版本 7.2.0 构建的 又一次新组建。根据上面解释的依赖优先级,最后组装的项目会使用平台版本 7.2.0,尽管 build.gradle 中已经声明了 ext.cubaVersion = '7.2-SNAPSHOT'

为解决这个问题,可以在 build.gradle 文件中如下配置 Gradle 的依赖解决方案:

allprojects {
    configurations {
        all {
            resolutionStrategy.eachDependency { details ->
                if (details.requested.group == 'com.haulmont.cuba') {
                    details.useVersion '7.2-SNAPSHOT'
                }
            }
        }
    }
}

这段代码中,我们添加了一条规则,所有的 com.haulmont.cuba 依赖都使用 7.2-SNAPSHOT 版本。

4.3.2. 配置仓库连接

主仓库

当创建项目的时候,需要选择包含 CUBA 工件的主仓库。默认情况下有两种选择(如果配置了私仓的话就有更多选择):

  • https://repo.cuba-platform.com/content/groups/work - Haulmont 服务器提供的仓库。需要在构建脚本中指定通用的密钥:(cuba / cuba123)。

  • https://dl.bintray.com/cuba-platform/main - JFrog Bintray提供的仓库,支持匿名访问。

这两个仓库有相同的最新平台版本的工件内容,但是 Bintray 不包含版本快照(snapshots)。对于全球访问来说,Bintray 应当更加可靠。

使用 Bintray 的情况下,新项目的构建脚本会配置成分别使用 Maven Central,JCenter 和 Vaadin 插件仓库。

使用 CUBA Premium 插件

从 7.0 开始,BPM,Charts,全文检索(Full-Text Search)和 Reports 扩展插件将会免费和开源。这些扩展插件目前在上面提到的主仓库,所以只需要为使用其它 premium 插件配置 premium 仓库,比如,WebDAV。

如果项目使用了 CUBA Premium 插件,在 build.gradle 添加一个仓库:

  • 如果主仓库是 repo.cuba-platform.com,则需要添加 https://repo.cuba-platform.com/content/groups/premium

  • 如果主仓库是 Bintray,则需要添加 https://cuba-platform.bintray.com/premium

添加 https://repo.cuba-platform.com/content/groups/premium 仓库的示例:

buildscript {
    // ...
    repositories {
        // ...
        maven {
            url 'https://repo.cuba-platform.com/content/groups/premium'
            credentials {
                username(rootProject.hasProperty('premiumRepoUser') ?
                        rootProject['premiumRepoUser'] : System.getenv('CUBA_PREMIUM_USER'))
                password(rootProject.hasProperty('premiumRepoPass') ?
                        rootProject['premiumRepoPass'] : System.getenv('CUBA_PREMIUM_PASSWORD'))
            }
        }
    }

添加 https://cuba-platform.bintray.com/premium 仓库的示例:

buildscript {
    // ...
    repositories {
        // ...
        maven {
            url 'https://cuba-platform.bintray.com/premium'
            credentials {
                username(rootProject.hasProperty('bintrayPremiumRepoUser') ?
                        rootProject['bintrayPremiumRepoUser'] : System.getenv('CUBA_PREMIUM_USER'))
                password(rootProject.hasProperty('premiumRepoPass') ?
                        rootProject['premiumRepoPass'] : System.getenv('CUBA_PREMIUM_PASSWORD'))
            }
        }
    }

上面提到的两个 Premium 插件仓库都需要使用提供给每个开发者的用户名和密码。授权码短横前的前半部分是仓库用户名,后半部分是密码。比如,如果授权码是 111111222222-abcdefabcdef,那么用户名是 111111222222,密码是 abcdefabcdef。如果是使用 Bintray 仓库,用户名需要添加 @cuba-platform

可以按照以下方法之一来提供用户凭证。

  • 推荐的方法是在用户主目录创建 ~/.gradle/gradle.properties 文件,然后在文件内设置属性:

    • https://repo.cuba-platform.com/content/groups/premium 仓库设置凭证的示例:

      ~/.gradle/gradle.properties
      premiumRepoUser=111111222222
      premiumRepoPass=abcdefabcdef
    • https://cuba-platform.bintray.com/premium 仓库设置凭证的示例:

      ~/.gradle/gradle.properties
      bintrayPremiumRepoUser=111111222222@cuba-platform
      premiumRepoPass=abcdefabcdef
  • 另外一个方法是在操作系统中设置以下环境变量:

    • CUBA_PREMIUM_USER - 如果 premiumRepoUser 没有设置,则会使用这个环境变量。

    • CUBA_PREMIUM_PASSWORD - 如果 premiumRepoPass 没有设置,则会使用这个环境变量。

当从命令行执行 Gradle 任务的时候,也可以通过 -P 开头的命令行参数传递这些属性,示例:

gradlew assemble -PpremiumRepoUser=111111222222 -PpremiumRepoPass=abcdefabcdef
自定义仓库

项目可以包含任意数量的自定义仓库,这些仓库可以包含应用程序组件。需要在 build.gradle 里手动将这些仓库添加到主仓库 之后 的位置,示例:

repositories {
    // main repository containing CUBA artifacts
    maven {
        url 'https://repo.cuba-platform.com/content/groups/work'
        credentials {
            // ...
        }
    }
    // custom repository
    maven {
        url 'http://localhost:8081/repository/maven-snapshots'
    }
}

4.3.3. 配置支持 Kotlin

如果在 Studio 中创建新项目,在创建项目向导的第一页可以选择语言偏好(Java、Kotlin、Java+Groovy),构建脚本也会相应做配置。

如果想在已有项目添加对于 Kotlin 的支持,可以对 build.gradle 文件做一些修改:

buildscript {
    ext.cubaVersion = '7.2.0'
    ext.kotlinVersion = '1.3.61' // add this line
    // ...
    dependencies {
        classpath "com.haulmont.gradle:cuba-plugin:$cubaVersion"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // add this line
        classpath "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion"   // add this line
    }
}
// ...
apply(plugin: 'cuba')
apply(plugin: 'org.jetbrains.kotlin.jvm') // add this line
// ...
configure([globalModule, coreModule, webModule]) {
    // ...
    apply(plugin: 'cuba')
    apply(plugin: 'org.jetbrains.kotlin.jvm') // add this line

    dependencies {
        compile("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") // add this line
        compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") // add this line
// ...

项目如果配置了支持 Kotlin 或 Groovy,则可以选择 Studio 生成的代码语言。查看 Settings/Preferences > Languages & Frameworks > CUBA > Project settings > Scaffolding language

4.3.4. 构建任务

Tasks - 任务 是 Gradle 的可执行单元。任务可以在插件内定义或者在构建脚本中定义。以下是一些 CUBA 特定的任务,任务的参数可以通过 build.gradle 来配置。

4.3.4.1. 构建信息

CUBA gradle 插件自动在 global 模块的配置里添加 buildInfo 任务。这个任务会在 build-info.properties 文件里写入应用程序的信息,然后这个文件会被打包到 global 工件(app-global-1.0.0.jar)里。BuildInfo bean 在运行时会读取这些信息,然后显示在 Help > About 窗口。其它的业务机制也可以调用这个 bean,以便于获取应用程序名称,版本之类的信息。

下面这些任务参数可以根据需要指定:

  • appName - 应用程序名称。默认值会从 settings.gradle 中的项目名称(project name)读取。

  • artifactGroup - 工件组,一般习惯上会使用项目的包名根目录。

  • version - 应用程序版本。默认值是通过 cuba.artifact.version 属性设定的。

  • properties - 一组任意的属性键值对,默认是空。

使用自定义的 buildInfo 任务示例:

configure(globalModule) {
    buildInfo {
        appName = 'MyApp'
        properties = ['prop1': 'val1', 'prop2': 'val2']
    }
    // ...
4.3.4.2. 构建 UberJar

buildUberJarCubaUberJarBuilding 类型的任务,会创建一个包含应用程序代码和所有依赖包在一起并且还有嵌入的 Jetty Http 服务的 JAR 包。可以选择创建一个大而全的包含所有的 JAR 包,或者选择给每一个应用程序 block 创建单独的 JAR 包,例如可以给中间件(middleware)创建 app-core.jar,给 web 客户端创建 app.jar

这个任务需要在 build.gradle 的根节点声明。生成的 JAR 包会放在项目的 build/distributions 目录。参考 UberJAR 部署 章节了解怎么运行生成的 JAR 包。

可以通过 Studio 里的 Deployment > UberJAR settings 界面配置这个任务。

任务参数:

  • appProperties - 应用程序属性的键值对。这里面提供的属性键值对会以生成的 JAR 包里的 WEB-INF/local.app.properties 文件定义的属性为基础添加。

    task buildUberJar(type: CubaUberJarBuilding) {
        appProperties = ['cuba.automaticDatabaseUpdate' : true]
        // ...
    }
  • singleJar - 如果设置成 true,会创建一个包含所有模块(core,web,portal)的 JAR 包。默认是 false

    task buildUberJar(type: CubaUberJarBuilding) {
        singleJar = true
        // ...
    }
  • webPort - 单一 JAR 包(singleJar=true)或者 Web 模块的 JAR 包的 Http 服务端口,默认是 8080。也可以在运行时通过 -port 命令行参数动态指定。

  • corePort - core 模块 JAR 包的 Http 服务端口,默认是 8079。也可以在运行时启动相应的 JAR 包的时候用命令行参数 -port 来指定。

  • portalPort - partal 模块 JAR 包的 Http 服务端口,默认是 8081。也可以在运行时启动相应的 JAR 包的时候用命令行参数 -port 来指定。

  • appName - 应用程序名称,默认是 app。可以在 Studio 中通过 Project Properties 窗口里的 Module prefix 来给整个项目设置名称,或者也可以通过这个参数只给 buildUberJar 任务设置。例如:

    task buildUberJar(type: CubaUberJarBuilding) {
        appName = 'sales'
        // ...
    }

    当把应用程序名称改成 sales 之后,这个任务会生成 sales-core.jarsales.jar 文件,web 客户端可以通过 http://localhost:8080/sales 访问。还能通过运行时的 -contextName 命令行参数改变 web 上下文,而不需要修改应用程序名称,甚至直接修改 JAR 包的名字也行。

  • logbackConfigurationFile - 日志配置文件的相对目录。

    比如:

    logbackConfigurationFile = "/modules/global/src/logback.xml"
  • useDefaultLogbackConfiguration - 当设置成 true (也是默认值)的时候,这个任务会拷贝标准的 logback.xml 配置文件。

  • coreJettyEnvPath - 定义一个相对路径(相对于项目根目录),指向包含 JNDI 资源的文件,用来为 Jetty HTTP 服务。

    task buildUberJar(type: CubaUberJarBuilding) {
        coreJettyEnvPath = 'modules/core/web/META-INF/jetty-env.xml'
        // ...
    }
  • webJettyConfPath - Jetty Server 配置文件的相对路径,可以给 UberJar(singleJar=true)或者 web JAR(singleJar=false)配置。参考: https://www.eclipse.org/jetty/documentation/9.4.x/jetty-xml-config.html

  • coreJettyConfPath - core JAR(singleJar=false)的 Jetty Server 配置文件相对目录,要注意跟上面描述的 coreJettyEnvPath 相区别。

  • portalJettyConfPath - portal JAR singleJar=false)的 Jetty Server 配置文件相对目录。

  • coreWebXmlPath - 用来作为 core 模块 web.xml 的文件的相对目录。

  • webWebXmlPath - 用来作为 web 模块 web.xml 的文件的相对目录。

  • portalWebXmlPath - 用来作为 portal 模块 web.xml 的文件的相对目录。

  • excludeResources - 正则表达式,表示不需要包含在 JAR 包里面的那些 resource 文件。

  • mergeResources - 正则表达式,表示需要整合在 JAR 包里面的那些 resource 文件。

  • webContentExclude - 正则表达式,表示不需要包含在 web content 里面的那些文件。

  • coreProject - 用来作为 core 模块(Middleware)的 Gradle 项目。如果没定义,则会使用标准的 core 模块。

  • webProject - 用来作为 web 模块(Web Client)的 Gradle 项目。如果没定义,则会使用标准的 web 模块。

  • portalProject - 用来作为 portal 模块(Web Portal)的 Gradle 项目。如果没定义,则会使用标准的 portal 模块。

  • frontProject - 用来作为 前端用户界面 模块的 Gradle 项目。如果没定义,则会使用标准的 front 模块。

4.3.4.2.1. 添加依赖

如需在应用程序启动前加载某些依赖,需要在构建文件的顶层使用 uberJar 配置。比如,添加 logback appender 就是个好例子。构建文件如下:

buildscript {
    //build script definitions
}
dependencies {
    //app components definitions
    uberJar ('net.logstash.logback:logstash-logback-encoder:6.3')
}
//modules and task definitions, etc.

当这样声明时,logstash-logback-encoder 解压出来的包和类都会被放置到 uberJar 工件的根目录。

4.3.4.3. 构建 War

buildWarCubaWarBuilding 类型的任务,会构建一个 WAR 包,包含所有的应用程序代码和所有的依赖包。需要在 build.gradle 文件的根目录声明。生成的 WAR 包会在项目的 build/distributions 目录。

可以通过 Studio 里的 Deployment > WAR Settings 界面配置这个任务。

任何 CUBA 应用程序包含至少两个 block:中间件(Middleware)和 web 客户端(Web Client)。所以部署程序最自然的方法就是创建两个单独的 WAR 包:中间件一个,web 客户端一个。这样做的话也可以在用户数量增长的时候方便进行扩展。但是单独的包会重复包含一些两边都依赖的包,因此会增加整个程序的大小。还有就是扩展的部署选择也不常用,反而会使得部署流程更加复杂。CubaWarBuilding 任务可以创建这两种类型的 WAR 包:每个 block 一个包,或者包含两个 block 的包,后面这种情况下,应用程序的那些 block 会在一个 web 应用程序里面被不同的类加载器加载。

给中间件和 web 客户端创建不同的 WAR 包

可以采用这样的 task 配置:

task buildWar(type: CubaWarBuilding) {
    appProperties = ['cuba.automaticDatabaseUpdate': 'true']
    singleWar = false
}

任务参数:

  • appName - web 应用程序的名称,默认值是读取 Modules prefix,比如 app

  • appProperties - 定义应用程序属性键值对。这些属性会以 WAR 包里面的 /WEB-INF/local.app.properties 文件为基础增加。

    appProperties = ['cuba.automaticDatabaseUpdate': 'true'] 会在应用程序第一次启动的时候创建数据库,如果没有现成的数据库,需要设置这个参数。

  • singleWar - 如果要为 blocks 构建单独的 WAR,需要设置这个为 false

  • includeJdbcDriver - 是否包含项目里当前使用的 JDBC 驱动,默认是 false

  • includeContextXml - 是否包含项目里当前使用的 Tomcat 的 context.xml 文件,默认值是 false

  • coreContextXmlPath - 如果 includeContextXml 设置为 true,使用这个参数设置一个配置文件的相对目录用来替换项目 context.xml 文件。

  • hsqlInProcess - 如果设置成 truecontext.xml 里面的数据库 URL 会按照 HSQL in-process 模式修改。

  • coreProject - 用来作为 core 模块(Middleware)的 Gradle 项目。如果没定义,则会使用标准的 core 模块。

  • webProject - 用来作为 web 模块(Web Client)的 Gradle 项目。如果没定义,则会使用标准的 web 模块。

  • portalProject - 用来作为 portal 模块(Web Portal)的 Gradle 项目。如果项目包含了 portal 模块,需要设置这个参数,比如,portalProject = project(':app-portal')

  • coreWebXmlPath, webWebXmlPath, portalWebXmlPath - 用来作为相应模块 web.xml 的文件的相对目录。

    使用自定义 web.xml 文件的例子:

    task buildWar(type: CubaWarBuilding) {
        singleWar = false
        // ...
        coreWebXmlPath = 'modules/core/web/WEB-INF/production-web.xml'
        webWebXmlPath = 'modules/web/web/WEB-INF/production-web.xml'
    }
  • logbackConfigurationFile - 日志配置文件的相对目录。

    示例:

    task buildWar(type: CubaWarBuilding) {
        // ...
        logbackConfigurationFile = 'etc/war-logback.xml'
    }
  • useDefaultLogbackConfiguration - 当设置成 true (也是默认值)的时候,这个任务会拷贝标准的 logback.xml 配置文件。

  • frontBuildDir - Front UI 构建生成的目录名称。默认是 build。如需自定义,可以修改这个参数。

创建单一的 WAR 包

需要创建包含中间件和 web 客户端 block 的单一 WAR 包,可以用下面这个配置:

task buildWar(type: CubaWarBuilding) {
    webXmlPath = 'modules/web/web/WEB-INF/single-war-web.xml'
}

除了上面这些参数之外,以下这些参数也可以使用:

  • singleWar - 不设置或者设置成 true

  • webXmlPath - 用来作为单一 WAR 包 web.xml 的文件相对目录。这个文件定义了两个 servlet 上下文的监听器,用来加载程序 block:SingleAppCoreServletListenerSingleAppWebServletListener。通过上下文参数给这两个监听器传递所需要的所有参数。

    single-war-web.xml 示例:

    <?xml version="1.0" encoding="UTF-8"?>
    <web-app xmlns="http://java.sun.com/xml/ns/javaee"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
             version="3.0">
    
        <!--Application components-->
        <context-param>
            <param-name>appComponents</param-name>
            <param-value>com.haulmont.cuba</param-value>
        </context-param>
    
        <!-- Web Client parameters -->
    
        <context-param>
            <description>List of app properties files for Web Client</description>
            <param-name>appPropertiesConfigWeb</param-name>
            <param-value>
                classpath:com/company/sample/web-app.properties
                /WEB-INF/local.app.properties
                file:${app.home}/local.app.properties
            </param-value>
        </context-param>
    
        <context-param>
            <description>Web resources version for correct caching in browser</description>
            <param-name>webResourcesTs</param-name>
            <param-value>${webResourcesTs}</param-value>
        </context-param>
    
        <!-- Middleware parameters -->
    
        <context-param>
            <description>List of app properties files for Middleware</description>
            <param-name>appPropertiesConfigCore</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>
    
        <!-- Servlet context listeners that load the application blocks -->
    
        <listener>
            <listener-class>
                com.vaadin.server.communication.JSR356WebsocketInitializer
            </listener-class>
        </listener>
        <listener>
            <listener-class>
                com.haulmont.cuba.core.sys.singleapp.SingleAppCoreServletListener
            </listener-class>
        </listener>
        <listener>
            <listener-class>
                com.haulmont.cuba.web.sys.singleapp.SingleAppWebServletListener
            </listener-class>
        </listener>
    </web-app>

单个 WAR 包部署时所有的 sevlets 和 filters 需要通过编程的方式注册,参考Servlet 和过滤器的注册

单一 WAR 包只包含 coreweb 客户端 模块。如果需要部署 portal 模块,需要用另外的 WAR 包。

如果项目包含 front 模块,则可以通过 /<appName>/front 路径访问。为了让 frontend 界面在单一 WAR 也能正常工作,需要在构建时修改 PUBLIC_URL= /app/front/ 的值。(比如,在 .env.production.local 内修改 (see README))。

参考 部署 WAR 至 Jetty 章节详细介绍 WAR 部署的各种情况和步骤。

4.3.4.4. 构建 WidgetSet

buildWidgetSet - CubaWidgetSetBuilding 类型的任务,如果项目中有 web-toolkit 模块的话,可以用这个任务构建一个自定义的 GWT WidgetSet。这个模块可以用来开发自定义可视化组件

可用参数:

  • style - 脚本的输出样式:OBFPRETTY 或者 DETAILED。默认是 OBF

  • logLevel - 日志级别:ERRORWARNINFOTRACEDEBUGSPAM, 或者 ALL。默认是 INFO

  • draft - 使用最小优化进行快速编译。默认值 false

使用示例:

task buildWidgetSet(type: CubaWidgetSetBuilding) {
    widgetSetClass = 'com.company.sample.web.toolkit.ui.AppWidgetSet'
    style = 'PRETTY'
}
4.3.4.5. 创建数据库

createDbCubaDbCreation 类型的任务,通过执行相应的数据库脚本来创建应用程序数据库。定义在 core 模块。

如果使用应用程序属性配置数据源,下面这些参数会自动从应用程序属性中获取,所以任务定义可以为空:

task createDb(dependsOn: assemble, description: 'Creates local database', type: CubaDbCreation) {
}

当然也可以显式的设置这些参数:

  • storeName - 附加数据存储的名称。如果未设置,任务会在主数据存储上运行。

  • dbms数据库类型hsqlpostgresmssql 或者 oracle

  • dbName – 数据库名称

  • dbUser – 数据库用户名

  • dbPassword – 用户的密码

  • host – 数据库服务的地址和端口(可选),使用 host[:port] 格式。如果没设置,则会使用 localhost

  • connectionParams - 可选的连接参数,添加到连接 URL 最后面。

  • masterUrl – 数据库连接串 URL。如果没设置,默认会根据 dbmshost 生成。

  • dropDbSql – 删除数据库的 SQL 命令。如果没设置,默认会根据 dbms 生成。

  • createDbSql – 创建数据库的 SQL 命令。如果没设置,默认会根据 dbms 生成。

  • driverClasspath – 包含 JDBC 驱动的 JAR 包文件列表。在 Linux 系统使用 ":" 分隔列表里的文件,在 Windows 系统使用 ";" 分隔。如果没设置,系统会用当前模块的 jdbc 配置所需要的依赖。使用 Oracle 的时候需要显式的定义 driverClasspath,因为 Oracle 的 JDBC 驱动没有能自动下载的可用依赖,需要手动配置。

  • oracleSystemPassword – Oracle 的 SYSTEM 用户密码。

PostgreSQL 示例:

task createDb(dependsOn: assemble, description: 'Creates local database', type: CubaDbCreation) {
    dbms = 'postgres'
    dbName = 'sales'
    dbUser = 'cuba'
    dbPassword = 'cuba'
}

微软 SQL Server 示例:

task createDb(dependsOn: assemble, description: 'Creates local database', type: CubaDbCreation) {
    dbms = 'mssql'
    dbName = 'sales'
    dbUser = 'sa'
    dbPassword = 'saPass1'
    connectionParams = ';instance=myinstance'
}

Oracle 示例:

task createDb(dependsOn: assemble, description: 'Creates database', type: CubaDbCreation) {
    dbms = 'oracle'
    host = '192.168.1.10'
    dbName = 'orcl'
    dbUser = 'sales'
    dbPassword = 'sales'
    oracleSystemPassword = 'manager'
    driverClasspath = "$tomcatDir/lib/ojdbc6.jar"
}
4.3.4.6. 调试 WidgetSet

debugWidgetSet - CubaWidgetSetDebug 类型的任务,启动 GWT 代码服务器(Code Server)用来在浏览器里面调试 widgets。

使用示例:

task debugWidgetSet(type: CubaWidgetSetDebug) {
    widgetSetClass = 'com.company.sample.web.toolkit.ui.AppWidgetSet'
}

需要确保 web-toolkit 模块在 runtime 配置里有一个 Servlet API 库的依赖:

configure(webToolkitModule) {
    dependencies {
        runtime(servletApi)
    }
...

参考 调试 web Widgets 查询怎么在浏览器里面调试代码。

4.3.4.7. 部署

deployCubaDeployment 类型的任务,快速部署一个模块到 Tomcat。在 build.gradle 里的 corewebportal 模块中声明。可用参数:

  • appName – 从模块创建的 web 应用程序名称。实际上是 tomcat/webapps 一个子目录的名称。

  • jarNames – 构建一个模块时生成的 JAR 文件(不带版本号)列表。这些 JAR 文件需要放在 web 程序的 WEB-INF/lib 目录里。所有其它的模块工件(artifacts)和依赖包将会放在 tomcat/shared/lib

示例:

task deploy(dependsOn: assemble, type: CubaDeployment) {
    appName = 'app-core'
    jarNames = ['cuba-global', 'cuba-core', 'app-global', 'app-core']
}
4.3.4.8. 部署样式主题

deployThemes - CubaDeployThemeTask 类型的任务,构建和部署项目内定义的主题至目前已经 部署且在运行的 web 应用程序中。不需要重启服务的情况下,主题的改动就能生效。

示例:

task deployThemes(type: CubaDeployThemeTask, dependsOn: buildScssThemes) {
}
4.3.4.9. 部署 War

deployWar - CubaJelasticDeploy 类型的任务,部署 WAR 包到 Jelastic 服务器。

示例:

task deployWar(type: CubaJelasticDeploy, dependsOn: buildWar) {
   email = '<your@email.address>'
   password = '<your password>'
   context = '<app contex>'
   environment = '<environment name or ID>'
   hostUrl = '<Host API url>'
}

任务参数:

  • appName - web 应用程序的名称。默认是使用模块前缀,比如 app

  • email - Jelastic 服务的登录名。

  • password - Jelastic 账号的密码

  • context - 应用程序上下文。默认值 ROOT

  • environment - 部署 WAR 的环境(environment)。可以设置成环境的名称或者 ID。

  • hostUrl - API 服务的地址,典型值 app.jelastic.<host name>

  • srcDir - WAR 包放置的目录。默认是 "${project.buildDir}/distributions/war"

4.3.4.10. 重启服务

restart – 此任务会关停本地 Tomcat 服务,然后运行快速部署,再启动本地 Tomcat 服务。

4.3.4.11. 配置 Tomcat

setupTomcatCubaSetupTomcat 类型的任务,为应用程序的快速部署方式安装并且初始化本地 Tomcat。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。Tomcat 的安装目录通过 cuba 任务的 tomcat.dir 来指定。默认值是项目的 build/tomcat 子目录。

4.3.4.12. 启动 Tomcat 服务

startCubaStartTomcat 类型的任务,用来启动在 setupTomcat 任务中安装的本地 Tomcat 服务。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.4.13. 启动本地 HSQL 数据库

startDbCubaHsqlStart 类型的任务,用来启动本地 HSQLDB 服务。 任务参数:

  • dbName – 数据库名称,默认 cubadb

  • dbDataDir – 数据库目录,默认是项目的 deploy/hsqldb 子目录。

  • dbPort – 数据库服务端口号,默认是 9001

示例:

task startDb(type: CubaHsqlStart) {
    dbName = 'sales'
}
4.3.4.14. 停止 Tomcat 服务

stopCubaStopTomcat 类型的任务,用来停止在 setupTomcat 任务中安装的本地 Tomcat 服务。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.4.15. 停止本地 HSQL 数据库

stopDbCubaHsqlStop 类型的任务,用来停止本地 HSQLDB 服务,任务参数跟 startDb 类似。

4.3.4.16. 打开 Tomcat 窗口

tomcatExec 类型的任务,在打开的终端窗口内运行本地 Tomcat 服务,即便启动失败,终端窗口也会打开。这个任务在调试 Tomcat 问题的时候会很有用,比如在服务启动时由于 Java 版本不匹配引起的启动失败。

4.3.4.17. 更新数据库

updateDbCubaDbUpdate 类型的任务,通过执行相应的数据库脚本文件来更新数据库。跟 createDb 任务类似,只是没有 dropDbSqlcreateDbSql 这两个参数。

如果使用应用程序属性配置数据源,下面这些参数会自动从应用程序属性中获取,所以任务定义可以为空:

task updateDb(dependsOn: assembleDbScripts, description: 'Updates local database', type: CubaDbUpdate) {
}

也可以按照 createDb 中描述的参数显式设置(除了 dropDbSqlcreateDbSql)。

4.3.4.18. 项目打包

zipProjectCubaZipProject 类型的任务,创建项目的 ZIP 格式压缩包。这个压缩包不会包含 IDE 项目文件、编译结果和 Tomcat。但是如果 HSQL 数据库在 build 目录的话,会被包含进去。

当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.5. 启动构建任务

构建脚本中定义的 Gradle 任务可以通过如下方式启动:

  • 如果是在 CUBA Studio 中使用项目,很多从 CUBA 主菜单执行的命令都实际上代理给 Gradle 任务:Build Tasks 菜单下的所有命令,以及 Start/Stop/Restart Application ServerCreate/Update Database 命令。

  • 或者,也可以通过执行项目中的 gradlew 脚本(Gradle Wrapper)来执行任务。

  • 还有一个方式就是使用手动安装的 Gradle 版本 5.6.4。这种情况可以直接执行 Gradle 安装目录下 bin 子目录里的 gradle 可执行文件。

比如,通过执行以下命令可以编译 Java 文件,并且构建项目的 JAR 包:

Windows:
gradlew assemble
Linux & macOS:
./gradlew assemble

如果项目用了 Premium 插件,并且不是用 Studio 启动项目构建,那么需要给 Gradle 传递 Premium 插件的密钥,可以参考 这里有更多细节。

按照通常使用的顺序,以下是一些典型需要使用的构建任务。

  • assemble – 编译 Java 文件并且构建项目工件的 JAR 包,存在各个模块的 build 子目录。

  • clean – 删除项目各个模块下的 build 子目录。

  • setupTomcat – 安装并配置 Tomcat 到 build.gradle 脚本中 cuba.tomcat.dir 属性定义的路径。

  • deploy – 部署应用程序到 Tomcat 服务。Tomcat 服务是之前通过 setupTomcat 任务预先安装的。

  • createDb – 创建应用程序数据库,并且执行相应的数据库脚本

  • updateDb – 通过执行相应的数据库脚本来更新已有的数据库。

  • start – 启动 Tomcat 服务。

  • stop – 停止已经启动的 Tomcat 服务。

  • restart – 顺序执行 stop, deploy, start 这三个任务。

4.3.6. 安装配置私仓

这节介绍了怎样安装一个 Maven 私仓,使用这个私仓替换 CUBA 公共仓库,并且用来存储平台工件和其它的依赖。以下这些情况推荐使用私仓:

  • 在不稳定或者很慢的互联网环境。尽管 Gradle 会在开发者的机器上缓存下载下来的工件,但是时不时的还是需要连接到工件仓库,比如当第一次运行构建或者在一个新版本的平台上开始构建。

  • 由于组织级别的安全措施,不能直接访问互联网。

  • 不打算为 CUBA Premium 插件续费,但是将来还需要使用已经下载过的付费插件构建项目。

以下是安装和配置私仓的步骤:

  • 在有互联网的网络安装仓库管理软件。

  • 配置私仓作为 CUBA 公共仓库的代理。

  • 修改项目构建脚本使用私仓。可以通过 Studio 或者直接修改 build.gradle

  • 启动完整项目构建流程,以此来在私仓中缓存所有必须的工件。

4.3.6.1. 安装仓库管理软件

这里使用 Sonatype Nexus OSS 仓库管理软件作为示例。

在微软 Windows 操作系统
  • 下载 Sonatype Nexus OSS 版本 2.x (2.14.3 测试通过)

  • 解压压缩包至 c:\nexus-2.14.3-02

  • 修改配置文件内容 c:\nexus-2.14.3-02\conf\nexus.properties:

    • 可以配置服务器端口,默认是 8081

    • 配置仓库数据目录:

      替换掉

      nexus-work=${bundleBasedir}/../sonatype-work/nexus

      使用方便缓存数据的目录,比如

      nexus-work=${bundleBasedir}/nexus/sonatype-work/content
  • 切换到目录 c:\nexus-2.14.3-02\bin

  • 安装 wrapper(以管理员身份运行),用来以服务的方式启动和停止 Nexus:

    nexus.bat install
  • 启动 nexus 服务

  • 在浏览器打开 http://localhost:8081/nexus,用默认的管理员账号登录:用户名 admin,密码 admin123

使用 Docker

另外,我们也可以用 Docker 来简化本地使用的安装步骤。也可以在 Docker Hub 找到介绍。

  • 运行 docker pull sonatype/nexus:oss 下载最新稳定的 OSS 镜像

  • 使用 docker run -d -p 8081:8081 --name nexus sonatype/nexus:oss 命令构建容器

  • Docker 容器会在几分钟内运行 nexus。用以下任意方法测试:

    • curl http://localhost:8081/nexus/service/local/status

    • 浏览器访问 http://localhost:8081/nexus

  • 密钥是一样的:用户名:admin,密码:admin123

4.3.6.2. 配置代理仓库

点击左边面板的 Repositories 连接。

在打开的 Repositories 页,点击 Add 按钮,然后选择 Proxy Repository。此处会添加一个新的仓库,在 Configuration 标签页填写所需的信息:

  • Repository ID: cuba-work

  • Repository Name: cuba-work

  • Provider: Maven2

  • Remote Storage Location: https://repo.cuba-platform.com/content/groups/work

  • Auto Blocking Enabled: false

  • 启用 Authentication, 设置 Username: cuba, Password: cuba123

  • 点击 Save 按钮.

创建一个仓库组(Repository Group):在 Nexus 点击 Add 按钮,然后选择 Repository Group 然后在 Configuration 标签页填写:

  • Group ID: cuba-group

  • Group Name: cuba-group

  • Provider: Maven2

  • Available Repositories 添加仓库 cuba-workOrdered Group Repositories

  • 点击 Save 按钮

如果订购了 Premium 插件服务,可以添加一个 premium 的仓库:

  • Repository ID: cuba-premium

  • Repository Name: cuba-premium

  • Provider: Maven2

  • Remote Storage Location: https://repo.cuba-platform.com/content/groups/premium

  • Auto Blocking Enabled: false

  • 启用 Authentication,使用授权码的前半部分(短横之前的部分)作为 Username,后半部分(短横之后的部分)作为 Password

  • 点击 Save 按钮。

  • 点击 Refresh 按钮。

  • 选择 cuba-group 组。

  • Configuration 标签页,添加 cuba-premium 仓库到组里,放到 cuba-work 之后。

  • 点击 Save 按钮。

4.3.6.3. 使用私仓

至此私仓已经可以用了。在界面上方显示 cuba-group 的 URL,比如:

http://localhost:8081/nexus/content/groups/cuba-group
  • Studio 中找到已注册的仓库列表。如果正在创建新项目,列表在 New Project 窗口。如果是打开已经存在的项目,列表在 CUBA > Project Properties 窗口。

  • 在新建仓库的对话框中,输入仓库的 URL 和认证信息:admin / admin123

  • 保存仓库信息之后,勾选该仓库的复选框则可以在项目中使用该仓库。

  • 保存项目属性或者继续使用项目创建向导。

在初次构建的过程中,新仓库会下载必要的工件(artifacts)并且存在缓存以供下次使用。可以从 c:\nexus-2.14.3-02\sonatype-work 找到这些文件。

4.3.6.4. 孤立网络中的私仓

如果需要在没有互联网连接的环境开发 CUBA 应用,可以这样:

  • 在网络中安装私仓管理工具。

  • 从有网的环境拷贝私仓缓存的内容到孤立网络。如果是按照之前的步骤安装的软件,拷贝的内容保存在:

    c:\nexus-2.14.3-02\sonatype-work
  • 重启 nexus 服务。

如果需要在孤立网络中添加新平台版本的工件,到有网的环境,通过有网的仓库进行一次基于新版本的构建,然后再拷贝这些下载下来的新版本工件到孤立网络的私仓。

4.4. 创建项目

推荐使用 CUBA Studio 创建新项目。 快速开始 部分有个示例可供参考。

另一个方式是通过 CUBA CLI 创建:

  1. 打开终端并且启动 CUBA CLI。

  2. 输入命令 create-app。可以用 tab 键自动补全命令。

  3. CLI 会询问项目配置。按回车键使用默认值,或者也可以自定义配置:

    • Project name – 项目名称。对于示例项目,CLI 会生成随机名称可以用来做默认选项。

    • Project namespace – 命名空间,用来做实体名称和数据库表名称的前缀。命名空间只能由拉丁字母组成,越短越好。

    • Platform version – 项目中使用的平台版本。平台工件会在项目构建的过程中从仓库自动下载。

    • Root package – Java 类的包名根目录。

    • Database – 使用的 SQL 数据库。

完成之后,在当前目录的一个新目录会创建这个空项目。可以使用 Studio、CLI 或者任意其它 IDE 继续开发。

4.5. 使用应用程序组件

任何 CUBA 应用程序都可以用作另一个应用程序的组件。应用程序组件是一个提供所有功能的全栈库 - 从数据库架构到业务逻辑和 UI。

CUBA 市场 上发布的应用程序组件被称为扩展组件(add-on),因为它们扩展了框架的功能,并且可以在任何基于 CUBA 的应用程序中使用。

4.5.1. 使用公共扩展组件

可以通过以下方式将发布在市场上的扩展组件添加到项目中。第一种和第二种方法假设你使用的是一个标准 CUBA 仓库。最后一种方法适用于开源扩展组件,不涉及任何远程仓库。

通过 Studio

如果你使用 CUBA Studio 11+ 以上版本,需要使用 CUBA Add-Ons 窗口管理扩展,参考 Studio 文档

如果使用的之前版本的 CUBA Studio,按照下面的步骤:

  1. 编辑 Project properties 并在 App components 面板上单击 Custom components 旁边的加号按钮。

  2. 从 Marketplace 页面或扩展组件的文档中复制扩展组件的 Maven 坐标,并将其粘贴到坐标输入框中,例如:

    com.haulmont.addon.cubajm:cuba-jm-global:0.3.1
  3. 单击对话框中的 OK。Studio 将尝试在当前项目选择的仓库中查找扩展组件的二进制文件。如果找到,对话框将关闭,扩展组件将显示在自定义组件列表中。

  4. 单击 OK 保存项目属性。

通过手动编辑
  1. 编辑 build.gradle 并在根 dependencies 元素中指定扩展组件的坐标:

    dependencies {
        appComponent("com.haulmont.cuba:cuba-global:$cubaVersion")
        // your add-ons go here
        appComponent("com.haulmont.addon.cubajm:cuba-jm-global:0.3.1")
    }
  2. 在 IDE 中刷新 Gradle 项目,比如在 Studio 中,通过 CUBA → Re-Import Gradle Project 主菜单项可以为项目的开发环境添加扩展。

  3. 编辑 coreweb 模块的 web.xml 文件,并将扩展组件的标识符(等同于 Maven groupId)添加到 appComponents 上下文参数中以空格分隔的应用程序组件列表:

    <context-param>
        <param-name>appComponents</param-name>
        <param-value>com.haulmont.cuba com.haulmont.addon.cubajm</param-value>
    </context-param>
通过源码构建
  1. 将扩展组件的仓库克隆到本地目录,然后将项目导入 Studio。

  2. 执行 CUBA > Advanced > Install app component 主菜单命令将扩展组件安装到本地 Maven 仓库(默认为 ~/.m2 目录)。

  3. 在 Studio 中打开项目,然后在 Project > Properties 中选上 Use local Maven repository

  4. 使用 Studio 中的 CUBA Add-ons 界面将扩展安装至项目中。参考 Studio 用户手册管理扩展 章节的 使用坐标安装扩展 部分内容了解更多细节。

  5. 单击对话框中的 OK 并保存项目属性。

如果一个项目使用了多个包含 web-toolkit 模块的扩展组件,那么项目本身也必须要有 web-toolkit 模块。如果没有的话,只会为应用程序从一个扩展中加载一个 widgetset。所以如果要集成所有的 widgetset,必须要有 web-toolkit 模块。

4.5.2. 创建应用程序组件

如果正在开发可复用的应用程序组件,本节包含一些有用的建议。

命名规则
  1. 使用标准的反转域名表示法选择根 java 包,例如 com.jupiter.amazingsearch

    根包不应该以任何其它组件或应用程序的根包开头。例如,如果有一个带有 com.jupiter.tickets 根包的应用程序,则不能将 com.jupiter.tickets.amazingsearch 包用于组件。原因是 Spring 从指定的根包开始扫描 bean 的类路径,并且每个组件的扫描空间必须是唯一的。

  2. 命名空间用作数据库表的前缀,因此对于公共组件,应该是一个表示综合含义的单词(或缩写),如 jptams,而不仅仅是 search。这样可以将目标应用程序中命名冲突的风险降到最低。不能在命名空间中使用下划线和短横,只能使用字母和数字。

  3. 模块前缀应该与命名空间名称一致,但可以包含短横,如 jpt-amsearch

  4. 使用命名空间作为 bean 名称和应用程序属性的前缀,例如:

    @Component("jptams_Finder")
    @Property("jptams.ignoreCase")
安装到本地 Maven 仓库

要使组件可用于本地计算机上的项目,请通过执行 CUBA > Advanced > Install app component 主命令将其安装到本地 Maven 仓库中。这个命令实际上只是在停止 Gradle 守护进程之后运行 install Gradle 任务。

上传到远程 Maven 仓库
  1. 按照安装配置私仓中的说明设置私仓。

  2. 指定项目的仓库和凭证,由于使用私仓,此处不能设置标准 CUBA 仓库。

  3. 在文本编辑器中打开组件项目的 build.gradle,并将 uploadRepository 添加到 cuba 部分:

    cuba {
        //...
        // repository for uploading your artifacts
        uploadRepository {
            url = 'http://repo.company.com/nexus/content/repositories/snapshots'
            user = 'admin'
            password = 'admin123'
        }
    }
  4. 在 Studio 中打开组件项目。

  5. 从命令行运行 uploadArchives Gradle 任务。组件的工件将被上传到仓库。

  6. 从本地 Maven 仓库中删除组件工件,以确保在下一次装载应用程序项目时会从远程仓库下载组件:只需删除位于用户主目录中的 .m2/repository/com/company 文件夹。

  7. 现在,当装载并运行使用此组件的应用程序时,将从远程仓库中下载依赖的扩展组件。

上传到 Bintray
  1. 首先,在 https://bintray.com/signup/oss 注册账号

    可以在 Bintray 上使用社交账户登录(GitHub,Gmail,Twitter),但稍后必须重置密码,因为获取 API 密钥需要此帐户的密码(见下文)。

  2. 获取 Bintray 用户名。可以在登录 Bintray 后看到的 URL 中找到。例如,在 https://bintray.com/vividphoenix 中,vividphoenix 是用户名。

  3. 获取 API 密钥。可以在 Bintray 编辑个人资料界面中找到。在 API 密钥部分,系统会要求输入帐户密码以获取密钥。然后,将能够使用此密钥和用户名进行 Bintray 的 Gradle 插件验证:

    • Bintray 凭证可以添加为环境变量:

      BINTRAY_USER=your_bintray_user
      BINTRAY_API_KEY=9696c1cb90752357ded8fdf20eb3fa921bf9dbbb
    • 除了环境变量,也可以在项目的 build.gradle 文件中显式定义这些参数:

      bintray {
          user = 'bintray_user'
          key = 'bintray_api_key'
          ...
      }
    • 或者,可以在命令行中提供 Bintray 凭据的参数:

      ./gradlew clean assemble bintrayUpload -Pcuba.artifact.version=1.0.0 -PbintrayUser=your_bintray_user -PbintrayApiKey=9696c1cb90752357ded8fdf20eb3fa921bf9dbbb
  4. 创建 Maven 类型的公共仓库。对于开源(OSS)仓库,必须设置许可证类型。

    Bintray 会隐式的使用仓库内的包。此时,还不是必须要创建组件包,因为在之后的 gradle bintrayUpload 任务会自动创建。

  5. build.gradle 中,添加 Bintray 上传插件的依赖如下:

    buildscript {
        // ...
        dependencies {
            classpath "com.haulmont.gradle:cuba-plugin:$cubaVersion"
            // Bintray upload plugin
            classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.0"
        }
    }
  6. build.gradle 结尾,添加 Bintray 插件设置:

    /** * If you have a multi-project build, make sure to apply the plugin and the plugin configuration to every project which artifacts you want to publish to Bintray. */
    subprojects {
        apply plugin: 'com.jfrog.bintray'
    
        bintray {
            user = project.hasProperty('bintrayUser') ? project.property('bintrayUser') : System.getenv('BINTRAY_USER')
            key = project.hasProperty('bintrayApiKey') ? project.property('bintrayApiKey') : System.getenv('BINTRAY_API_KEY')
    
            configurations = ['archives']
    
            // make files public ?
            publish = true
            // override existing artifacts?
            override = false
    
            // metadata
            pkg {
                repo = 'main'           // your repository name
                name = 'amazingsearch'  // package name - it will be created upon upload
                desc = 'AmasingSearch'  // optional package description
    
                // organization name, if your repository is created inside an organization.
                // remove this parameter if you don't have an organization
                userOrg = 'jupiter-org'
    
                websiteUrl = 'https://github.com/jupiter/amazing-search'
                issueTrackerUrl = 'https://github.com/jupiter/amazing-search/issues'
                vcsUrl = 'https://github.com/jupiter/amazing-search.git' // mandatory for Open Source projects
    
                licenses = ["Apache-2.0"]
                labels = ['cuba-platform', 'opensource']
    
                //githubRepo = 'amazingsearch/cuba-platform' // optional Github repository
                //githubReleaseNotesFile = 'README.md' // optional Github readme file
            }
        }
    }
    • pkg:repo 是仓库(使用 main),

    • pkg:name 是包名(使用唯一名称,例如 amazingsearch),

    • pkg:desc 将显示在 Bintray 界面上的可选组件包描述,

    • pkg:userOrg - 是仓库所属组织的名称(如果没有设置,默认情况下 BINTRAY_USER 将用作组织名称)。

  7. 现在,可以使用以下命令构建和上传项目:

    ./gradlew clean assemble bintrayUpload -Pcuba.artifact.version=1.0.0
  8. 如果在 CUBA 市场上发布扩展组件,则其仓库将链接到标准 CUBA 仓库,用户不必在其项目中指定仓库。

4.5.3. 应用程序组件示例

在本节中,我们将展示创建应用程序组件并在项目中使用的完整示例。该组件将提供 "客户管理(Customer Management)"功能,并包括 客户(Customer) 实体和相应的 UI 界面。应用程序将使用组件中的 Customer 实体作为其 订单(Order) 实体中的引用。

app components sample
创建客户管理组件
  1. 在 Studio 中创建一个新项目,并在 New project 界面上指定以下参数:

    • Project name - customers

    • Project namespace - cust

    • Root package - com.company.customers

  2. 打开 Project properties 窗口,将 Module prefix 设置为 cust

  3. 创建至少有 name 属性的 Customer 实体。

    如果组件包含 @MappedSuperclass 持久化类,请确保它们在同一个项目中有后代实体(即使用 @Entity 注解)。否则,这些基类将无法被正确增强(enhanced),并无法在应用程序中使用它们。

  4. 生成 DB 脚本并为 Customer 实体创建标准界面:cust_Customer.browsecust_Customer.edit

  5. 切换到菜单编辑器(menu designer),将 application-cust 菜单项更名为 customerManagement。然后,打开 Main Message Pack 部分的 messages.properties,为新的 customerManagement 设置标题。

  6. 通过点击主菜单 CUBA > Advanced > App Component Descriptor 生成 app-component.xml 组件描述文件。

  7. 测试客户管理功能:

    • 主菜单选择 CUBA > Create Database

    • 启动应用程序:点击主工具栏 CUBA Application 配置旁边的调试按钮。

    • 浏览器打开 http://localhost:8080/cust

  8. 通过点击 CUBA > Advanced > Install App Component 菜单项将应用程序组件安装到本地 Maven 仓库中。

创建 Sales 应用程序
  1. 在 Studio 中创建一个新项目,并在 New project 界面上指定以下参数:

    • Project name - sales

    • Project namespace - sales

    • Root package - com.company.sales

  2. 打开 Project properties 窗口,并选中 Use local Maven repository 复选框。

  3. 按照 Studio User GuideInstalling add-on by coordinates 章节的描述在项目中添加应用程序组件。使用客户管理组件的 Maven 坐标,比如,com.company.customers:cust-global:0.1-SNAPSHOT

  4. 创建 Order 实体并添加 dateamount 属性。然后添加 customer 属性,与 Customer 实体多对一关联 - Customer 在 Type 下拉列表中可用。

  5. 生成 DB 脚本并为 Order 实体创建标准界面。在创建标准界面时,先创建一个包含 customer 属性的 order-with-customer 视图,并将该视图用于界面展示。

  6. 测试应用程序功能:

    • 在主菜单选择 CUBA > Create Database

    • 启动应用程序:点击主工具栏 CUBA Application 配置旁边的调试按钮。

    • 浏览器打开 http://localhost:8080/cust。应用程序将包含两个顶层菜单:Customer ManagementApplication,并都带有相应的功能。

修改客户管理组件

假设现在必须更改组件功能(在 Customer 中添加一个属性),然后重新装配应用程序以合并更改。

  1. 在 Studio 中打开 customers 项目。

  2. 编辑 Customer 实体并添加 address 属性。在浏览和编辑界面都需要包含此属性。

  3. 生成数据库脚本 - 将创建更新表的脚本。保存脚本。

  4. 测试组件中的更改:

    • 在主菜单选择 CUBA > Update Database

    • 启动应用程序:点击主工具栏 CUBA Application 配置旁边的调试按钮。

    • 浏览器打开 http://localhost:8080/cust

  5. 通过执行 CUBA > Advanced > Install App Component 菜单项将应用程序组件重新安装到本地 Maven 仓库中。

  6. 在 Studio 中切换到 sales 项目

  7. 点击 CUBA > Build Tasks > Clean

  8. 点击主菜单 CUBA > Update Database - 会执行客户管理组件的更新脚本。

  9. 启动应用程序:点击主工具栏 CUBA Application 配置旁边的调试按钮。

  10. 浏览器打开 http://localhost:8080/app 应用程序会有包含新 address 属性的 Customer 实体以及界面。

4.5.4. 应用程序组建里的附加数据存储

如果一个应用程序组件使用了 附加数据存储,应用程序必须定义一个同名同类型的数据存储。比如,如果组件使用了 db1 连接到 PostgreSQL 的数据存储,应用程序也必须有一个名称为 db1 的 PostgreSQL 数据存储。

如果你使用 Studio 的话,可以按照 Studio 文档 的说明创建附加数据存储。否则,按照 数据存储 章节的介绍进行配置。

4.5.5. 注册组件中的 DispatcherServlet

本节将介绍如何将一个应用程序组件中的 servlet 和 filter 配置传递到使用该组件的应用程序。为了避免web.xml文件中的代码重复,需要在组件中使用特殊的 ServletRegistrationManager bean 注册 servlet 和 filter。

关于 Servlet 注册的最常见情况在示例HTTP servlet 注册中介绍。我们考虑一个更复杂的例子:一个应用程序组件带有一个用于处理 Web 请求的自定义 DispatcherServlet 的实现。

这个 servlet 从 demo-dispatcher-spring.xml 文件加载配置,如果要该 servlet 正常工作,应该在源码根目录(例如 web/src)先创建一个同名的空文件。

public class WebDispatcherServlet extends DispatcherServlet {
    private volatile boolean initialized = false;

    @Override
    public String getContextConfigLocation() {
        String configFile = "demo-dispatcher-spring.xml";
        File baseDir = new File(AppContext.getProperty("cuba.confDir"));

        String[] tokenArray = new StrTokenizer(configFile).getTokenArray();
        StringBuilder locations = new StringBuilder();

        for (String token : tokenArray) {
            String location;
            if (ResourceUtils.isUrl(token)) {
                location = token;
            } else {
                if (token.startsWith("/"))
                    token = token.substring(1);
                File file = new File(baseDir, token);
                if (file.exists()) {
                    location = file.toURI().toString();
                } else {
                    location = "classpath:" + token;
                }
            }
            locations.append(location).append(" ");
        }
        return locations.toString();
    }

    @Override
    protected WebApplicationContext initWebApplicationContext() {
        WebApplicationContext wac = findWebApplicationContext();
        if (wac == null) {
            ApplicationContext parent = AppContext.getApplicationContext();
            wac = createWebApplicationContext(parent);
        }

        onRefresh(wac);

        String attrName = getServletContextAttributeName();
        getServletContext().setAttribute(attrName, wac);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Published WebApplicationContext of servlet '" + getServletName() +
                    "' as ServletContext attribute with name [" + attrName + "]");
        }

        return wac;
    }

    @Override
    public void init(ServletConfig config) throws ServletException {
        if (!initialized) {
            super.init(config);
            initialized = true;
        }
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        _service(response);
    }

    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        _service(res);
    }

    private void _service(ServletResponse res) throws IOException {
        String testMessage = AppContext.getApplicationContext().getBean(Messages.class).getMainMessage("testMessage");

        res.getWriter()
                .write("WebDispatcherServlet test message: " + testMessage);
    }
}

要注册 DispatcherServlet,必须手动对此类进行加载、实例化、初始化,否则不同的类加载器可能会在 SingleWAR/SingleUberJAR 部署的情况下引发问题。而且,自定义 DispatcherServlet 应该需要进行双重初始化 - 第一次手动初始化,第二次由 servlet 容器初始化。

下面是一个初始化 WebDispatcherServlet 的组件示例:

@Component
public class WebInitializer {

    private static final String WEB_DISPATCHER_CLASS = "com.demo.comp.web.WebDispatcherServlet";
    private static final String WEB_DISPATCHER_NAME = "web_dispatcher_servlet";
    private final Logger log = LoggerFactory.getLogger(WebInitializer.class);

    @Inject
    private ServletRegistrationManager servletRegistrationManager;

    @EventListener
    public void initialize(ServletContextInitializedEvent e) {
        Servlet webDispatcherServlet = servletRegistrationManager.createServlet(e.getApplicationContext(), WEB_DISPATCHER_CLASS);
        ServletContext servletContext = e.getSource();
        try {
            webDispatcherServlet.init(new AbstractWebAppContextLoader.CubaServletConfig(WEB_DISPATCHER_NAME, servletContext));
        } catch (ServletException ex) {
            throw new RuntimeException("Failed to init WebDispatcherServlet");
        }
        servletContext.addServlet(WEB_DISPATCHER_NAME, webDispatcherServlet)
                .addMapping("/webd/*");
    }
}

注入的 ServletRegistrationManager bean 的 createServlet() 方法从 ServletContextInitializedEvent 获取应用程序上下文,并获取 WebDispatcherServlet 类的完全限定名。要初始化 servlet,需要传递从 ServletContextInitializedEvent 获得的 ServletContext 实例和 servlet 名称。

Servlet 使用 webd 映射注册,根据应用程序上下文的不同,可以从 /app/webd//app-core/webd/ 访问。

4.6. 使用 Spring Profiles

使用 Spring profile 可以对应用程序在不同运行环境做定制化。根据生效的 profile 不同,可以对同一个 bean 的不同实现做实例化,并且可以设置不同的应用程序属性值。

如果一个 Spring bean 有 @Profile 注解,则只会在匹配到相应的生效 profile 时才会做实例化。下面的例子中,SomeDevServiceBean 会在 dev profile 生效时使用; SomeProdServiceBean 会在 prod profile 生效时使用:

public interface SomeService {
    String NAME = "demo_SomeService";

    String hello(String input);
}

@Service(SomeService.NAME)
@Profile("dev")
public class SomeDevServiceBean implements SomeService {
    @Override
    public String hello(String input) {
        return "Service stub: hello " + input;
    }
}

@Service(SomeService.NAME)
@Profile("prod")
public class SomeProdServiceBean implements SomeService {
    @Override
    public String hello(String input) {
        return "Real service: hello " + input;
    }
}

如需定义一些针对特定 profile 的应用程序属性,需要在基础的 app.properties 文件所在包内创建 <profile>-app.properties 文件(对于 web 模块是 <profile>-web-app.properties)。

比如,对于 core 模块:

com/company/demo/app.properties
com/company/demo/prod-app.properties

对于 web 模块:

com/company/demo/web-app.properties
com/company/demo/prod-web-app.properties

针对特定 profile 的配置文件会在基础的配置文件之后加载,所以其中的应用程序属性会覆盖基础配置文件中的属性。下面例子中,我们针对 prod profile 定义了指定数据库的连接:

prod-app.properties
cuba.dbmsType = postgres
cuba.dataSourceProvider = application
cuba.dataSource.dbName = my-prod-db
cuba.dataSource.host = my-prod-host
cuba.dataSource.username = cuba
cuba.dataSource.password = cuba

生效的 profile 列表可以通过两种方式为应用程序设置:

  • web.xml 文件内的 spring.profiles.active servlet 上下文参数中,示例:

    <web-app ...>
    
        <context-param>
            <param-name>spring.profiles.active</param-name>
            <param-value>prod</param-value>
        </context-param>
  • 使用 spring.profiles.active Java 系统属性。比如,当运行 Uber JAR 时:

    java -Dspring.profiles.active=prod -jar app.jar

4.7. 日志

平台使用 Logback 框架来处理日志。

参考 CUBA 应用程序日志 指南学习如何集成、配置、查看日志以及如何使用外部工具分析日志。

日志的输出,采用 SLF4J API:取到当前类的一个 logger,然后调用 logger 的方法打印日志,示例:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Foo {
    // create logger
    private static Logger log = LoggerFactory.getLogger(Foo.class);

    private void someMethod() {
        // output message with DEBUG level
        log.debug("invoked someMethod");
    }
}
日志配置

日志配置在 logback.xml 文件中定义。

  • 在开发阶段,快速部署之后,可以在项目的 deploy/app_home 目录找到该文件。日志文件都创建在 deploy/app_home/logs 目录。

    注意,deploy 目录不包含在版本控制中,并且可以随时创建和删除,所以在 deploy/app_home/logback.xml 内做的改动很容易丢失。

    如果需要对开发环境的日志配置做持久的改动,创建 etc/logback.xml(可以复制原始的 deploy/app_home/logback.xml 文件,再做必要的改动)。该文件会每次在 Studio 运行应用或者执行 deploy Gradle 任务时复制到 deploy/app_home

    my_project/
        deploy/
            app_home/
                logback.xml
        ...
        etc/
            logback.xml - if exists, will be automatically copied to deploy/app_home
  • 当创建 WAR 或者 UberJAR 包时,可以在 buildWarbuildUberJar 任务的 logbackConfigurationFile 参数指定 logback.xml 的相对路径。如果未指定该参数,默认的输出至控制台日志配置会被包含进 WAR/UberJAR。

    注意,开发环境创建的 etc/logback.xml 不会默认给 WAR/UberJar 使用,必须显式指定一个日志配置文件,示例:

    my_project/
        etc/
            logback.xml
            war-logback.xml
    build.gradle
    task buildWar(type: CubaWarBuilding) {
        // ...
        logbackConfigurationFile = 'etc/war-logback.xml'
    }
  • 生产环境,可以在 应用程序主目录 放置 logback.xml 文件重写 WAR 或者 UberJAR 中嵌入的日志配置。

    应用程序主目录的 logback.xml 文件只有在命令行设置了 app.home Java 系统参数之后才会被识别。所以如果应用程序主目录是使用自动的 tomcat/work/app_home~/.app_home 的话,这个配置不会生效。

logback.xml 的结构

logback.xml 文件结构如下:

  • appender 元素定义了日志的 “输出设备”。主要的 appenders 有 FILE - 文件CONSOLE - 终端ThresholdFilterlevel 参数定义了消息的阈值。文件输出的默认值是 DEBUG,终端输出的默认值是 INFO。也就是说 ERRORWARNINFODEBUG 消息会输出到文件,但是只有 ERRORWARNINFO 级别的消息输出到终端。

    内嵌的 file 参数定义了文件输出目标文件的路径。

  • logger 元素定义了编码打印消息的 logger 参数。Logger 名称是有级别的,比如对于 com.company.sample logger 的设置也会影响 com.company.sample.core.CustomerServiceBeancom.company.sample.web.CustomerBrowse 的 logger,前提是这两个类没有显式的声明他们各自的 logger 参数。

    日志的打印级别是通过 level 属性来定义最低级别。比如,如果定义的是 INFO 级别,那么 DEBUGTRACE 类型的消息就不会被日志记录。需要注意的一点就是,在 appender 里面设置的级别也会影响日志的打印。

可以在 web 客户端的 Administration > Server Log 界面快速修改正在运行的服务的 logger 级别和 appender 的阈值。任何对日志的改动只会影响正在运行的服务,设置并不会保存在文件里。这个界面也支持从日志目录 (tomcat/logs)查看和加载 Tomcat 服务的日志。

Log 消息格式

平台会自动添加以下信息到基于文件的日志消息中:

  • application – 打印消息的应用程序名称。这个信息可以帮助定位消息是从哪个 block 打印的(Middleware, Web Client),因为这两个模块写的是同一个日志文件。

  • user – 调用打印消息代码的登录用户名。用来在日志里跟踪具体用户的行为。如果打印消息的代码没有被特定用户的会话调用,就不会在日志里添加用户信息。

比如,下面这个消息是被 admin 会话下调用的中间件(app-core)代码写入的:

16:12:20.498 DEBUG [http-nio-8080-exec-7/app-core/admin] com.haulmont.cuba.core.app.DataManagerBean - loadList: ...

4.7.1. 一些有用的 Logger 配置

以下是一些框架里面比较有用的 loggers,可以用来调试问题。

eclipselink.sql

如果设置成 DEBUG,EclipseLink ORM 框架会打印所有执行的 SQL 语句和执行时间。这个 logger 已经在标准的 logback.xml 里面定义了,所以使用的时候只需要修改它的日志级别,示例:

<configuration>
    ...
    <logger name="eclipselink.sql" level="DEBUG"/>

日志输出的样例:

2018-09-21 12:48:18.583 DEBUG [http-nio-8080-exec-5/app-core/admin] com.haulmont.cuba.core.app.RdbmsStore - loadList: metaClass=sec$User, view=com.haulmont.cuba.security.entity.User/user.browse, query=select u from sec$User u, max=50
2018-09-21 12:48:18.586 DEBUG [http-nio-8080-exec-5/app-core/admin] eclipselink.sql - <t 891235430, conn 1084868057> SELECT t1.ID AS a1, t1.ACTIVE AS a2, t1.CHANGE_PASSWORD_AT_LOGON AS a3, t1.CREATE_TS AS a4, t1.CREATED_BY AS a5, t1.DELETE_TS AS a6, t1.DELETED_BY AS a7, t1.EMAIL AS a8, t1.FIRST_NAME AS a9, t1.IP_MASK AS a10, t1.LANGUAGE_ AS a11, t1.LAST_NAME AS a12, t1.LOGIN AS a13, t1.LOGIN_LC AS a14, t1.MIDDLE_NAME AS a15, t1.NAME AS a16, t1.PASSWORD AS a17, t1.POSITION_ AS a18, t1.TIME_ZONE AS a19, t1.TIME_ZONE_AUTO AS a20, t1.UPDATE_TS AS a21, t1.UPDATED_BY AS a22, t1.VERSION AS a23, t1.GROUP_ID AS a24, t0.ID AS a25, t0.DELETE_TS AS a26, t0.DELETED_BY AS a27, t0.NAME AS a28, t0.VERSION AS a29 FROM SEC_USER t1 LEFT OUTER JOIN SEC_GROUP t0 ON (t0.ID = t1.GROUP_ID) WHERE (t1.DELETE_TS IS NULL) LIMIT ? OFFSET ?
        bind => [50, 0]
2018-09-21 12:48:18.587 DEBUG [http-nio-8080-exec-5/app-core/admin] eclipselink.sql - <t 891235430, conn 1084868057> [1 ms] spent
com.haulmont.cuba.core.sys.AbstractWebAppContextLoader

如果设置成 TRACE,框架会在服务启动时打印文件中定义的应用程序属性和程序模块中定义的属性,可以用来调试启动时候出现的问题。

需要注意的是,应该给一个合适的 appender 的日志级别设置成 TRACE,因为通常 appenders 的日志级别都设定的比较高。比如:

<configuration>
    ...
    <appender name="File" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>TRACE</level>
        </filter>
    ...
    <logger name="com.haulmont.cuba.core.sys.AbstractWebAppContextLoader" level="TRACE"/>

日志输出的样例:

2018-09-21 12:38:59.525 TRACE [localhost-startStop-1] com.haulmont.cuba.core.sys.AbstractWebAppContextLoader - AppProperties of the 'core' block:
cuba.automaticDatabaseUpdate=true
...

4.7.2. 日志配置的内部机制

该章节介绍 Logback 配置的内部运行机制,对排查问题很有帮助。

平台提供 LogbackConfigurator 类,作为 Configurator 的一个实现钩入标准的 Logback 初始化 过程 。 这个 Configurator 执行以下步骤寻找配置源:

  • 在 应用程序主目录(即通过 app.home 系统参数指定的目录)寻找 logback.xml

  • 如果没找到,在 classpath 根目录寻找 app-logback.xml

  • 如果没找到,执行基本的配置:输出至命令行,使用 WARN 阈值。

需要记住的是,这个过程只有在 classpath 中没找到 logback.xml 才会生效。

setupTomcat Gradle 任务会在 deploy/app_home 目录创建 logback.xml,所以上面解释的初始化过程会在第一步就找到了日志文件。结果就是,所有的开发环境会有一个默认的 logback 配置,将日志写入 deploy/app_home/logs 目录。

deploy Gradle 任务会复制 etc/logback.xml 项目文件(如果存在)至 deploy/app_home,所以开发者可以在项目中创建并自定义 logback 配置,之后会被自动使用在开发环境的 Tomcat 中。

buildWarbuildUberJar Gradle 任务会在 classpath 根目录(对于 WAR:/WEB-INF/classes,对于 UberJAR:/)创建 app-logback.xml,其来源为下列文件:

  • 如果设置了 logbackConfigurationFile 任务参数,则从此获取。

  • 如果设置 useDefaultLogbackConfiguration 任务参数为 true(默认值),则从 cuba-gradle-plugin 内的 logback.xml 获取。

如果 logbackConfigurationFile 没指定,并且 useDefaultLogbackConfiguration 设置为 false,包内不会包含任何 logback 配置。

假设 classpath 中没有 logback.xml,基于上面介绍的 LogbackConfigurator 的初始化过程,嵌入 WAR/UberJAR 的配置可以通过应用程序主目录的 logback.xml 文件覆盖。这样的话,可以实现自定义生产环境的日志,而不需要重新构建 WAR/UberJAR。

4.8. 调试

本章节介绍使用 CUBA 应用程序调试的步骤。

4.8.1. 连接调试器

可以通过两种方式启动 Tomcat 服务的调试模式。一种是通过 Gradle 任务

gradlew start

另一种是通过运行安装的 Tomcatbin/debug.* 文件。

启动之后,应用服务可以从 8787 端口接收调试器的连接。端口号可以在 bin/setenv.* 文件中的 JPDA_OPTS 变量修改。

如果使用 Intellij IDEA 调试,需要创建一个 Remote 类型的 Run/Debug Configuration 元素,并且设置调试连接的 Port 为 8787(默认值)。

4.8.2. 调试 Widgetset 版本

不使用 GWT Super Dev Mode 在客户端最容易调试应用程序的方法就是使用 web 模块设置里面的调试配置(configuration)。

  1. webModule 中添加新调试配置:

    configure(webModule) {
        configurations {
            webcontent
            debug // a new configuration
        }
        ''''''
    }
  2. webModule 里的 dependencies 部分添加调试的依赖:

    dependencies {
        provided(servletApi)
        compile(guiModule)
        debug("com.haulmont.cuba:cuba-web-toolkit:$cubaVersion:debug@zip")
    }

    如果使用了 charts 组件,那么必须添加 debug("com.haulmont.charts:charts-web-toolkit:$cubaVersion:debug@zip")

  3. webModule 的配置部分添加 deploy.doLast 任务:

    task deploy.doLast {
        project.delete "$cuba.tomcat.dir/webapps/app/VAADIN/widgetsets"
    
        project.copy {
            from zipTree(configurations.debug.singleFile)
            into "$cuba.tomcat.dir/webapps/app"
        }
    }

调试场景会被部署在项目的 $cuba.tomcat.dir/webapps/app/VAADIN/widgetsets/com.haulmont.cuba.web.toolkit.ui.WidgetSet 目录。

4.8.3. 调试 web Widgets

可以在浏览器使用 GWT Super Dev Mode - GWT 超级开发模式 来调试 web widgets。

  1. build.gradle 里设置 debugWidgetSet 任务。

  2. 部署应用程序并启动 Tomcat。

  3. 执行 debugWidgetSet 任务:

    gradlew debugWidgetSet

    运行中的 GWT 代码服务会在修改代码的时候自动重编译。

  4. 在 Chrome 浏览器打开 http://localhost:8080/app?debug&superdevmode 然后等待 widgetset 第一次构建。

  5. 在 Chrome 打开调试控制器窗口:

    debugWidgetSet chrome console
  6. web-toolkit 模块修改了 Java 代码之后,刷新浏览器页面。Widgetset 会重新增量构建,大概需要 8-10 秒时间。

4.9. 测试

CUBA 应用程序可以使用众所周知的方式进行测试:单元测试、集成测试、以及界面 UI 测试。

单元测试非常适合测试封装在特定类中以及与应用程序基础设施松耦合的业务逻辑。只需要在项目的 globalcoreweb 模块中创建 test 目录,然后就可以编写 JUnit 测试用例了。如果需要模拟数据,可以添加最喜欢的 mocking 框架,或者 CUBA 已经使用的 JMockit 。Mocking 框架的依赖需要添加到 build.gradle,放在 JUnit 之前:

configure([globalModule, coreModule, webModule]) {
    // ...
    dependencies {
        testCompile('org.jmockit:jmockit:1.48') (1)
        testCompile('org.junit.jupiter:junit-jupiter-api:5.5.2')
        testCompile('org.junit.jupiter:junit-jupiter-engine:5.5.2')
        testCompile('org.junit.vintage:junit-vintage-engine:5.5.2')
    }
    // ...
    test {
        useJUnitPlatform()
        jvmArgumentProviders.add(new JmockitAgent(classpath)) (2)
    }
}

class JmockitAgent implements CommandLineArgumentProvider { (3)

    FileCollection classpath

    JmockitAgent(FileCollection classpath) {
        this.classpath = classpath
    }

    Iterable<String> asArguments() {
        def path = classpath.find { it.name.contains("jmockit") }.absolutePath
        ["-javaagent:${path}"]
    }
}
1 - 添加 mocking 框架
2 - 如果使用的 JMockit, 在运行测试用例时,需要指定 -javaagent 参数
3 - 在 classpath 搜索 JMockit JAR 并且构造 -javaagent 值的一个类。

可以参考 CUBA 应用程序单元测试 指南。

集成测试运行在 Spring 容器中,所以可以用来测试应用程序的各个方面,包括与数据库和 UI 界面的交互。本章节介绍如何在中间层和 web 层创建集成测试。

对于 UI 测试,我们推荐使用 Masquerade 库,其为测试 CUBA 应用程序提供了一组非常有用的抽象。可以参阅 GitHub 上的 README 和 Wiki。

4.9.1. 中间件集成测试

中间件集成测试运行在具有完整功能的 Spring 容器里,而且可以连接数据库。在这些测试类里面,可以运行中间件里面各细分层的代码,比如从 ORM 层到 Service 层。

Studio 创建新项目之后,可以在 core 模块的包内找到两个类:一个测试容器类和一个测试示例类。测试容器类会启动中间件的 Spring 容器,该容器用来做测试。测试示例类使用这个容器并演示如何用实体测试一些操作。

我们看看生成的测试容器类,怎么能满足我们的需求。

该类必须继承 CUBA 提供的 TestContainer 类。在构造器中,需要做下面这些事:

  • 需要在 appComponents 列表中添加项目中使用的 应用程序组件(扩展插件)。

  • 如果需要,在 appPropertiesFiles 列表指定附加的应用程序属性文件。

  • 调用 autoConfigureDataSource() 方法使用来自应用程序属性或者 context.xml 的信息初始化测试数据源。

生成的测试容器会提供与应用程序相同的数据库连接,所以即使修改了类型或者 JDBC 数据源的定义,测试用例还是会使用主数据存储运行。

使用同一个数据库进行测试和运行应用程序有一个弊端:手动输入的数据会干扰测试数据并有可能破坏测试用例的执行。避免的方法就是为测试单独配置一个数据库。我们建议使用和主数据存储同类型的数据库作为测试数据库,以便使用同一组 数据库迁移脚本。下面是在本地 PostgreSQL 配置测试数据库的一个示例。

首先,在 build.gradle 添加测试数据库创建的 任务

configure(coreModule) {
    // ...
    task createTestDb(dependsOn: assembleDbScripts, type: CubaDbCreation) {
        dbms = 'postgres'
        host = 'localhost'
        dbName = 'demo_test'
        dbUser = 'cuba'
        dbPassword = 'cuba'
    }

然后在测试源码的根包(比如 modules/core/test/com/company/demo/test-app.properties)内创建 test-app.properties 文件,并制定测试数据库的连接属性:

cuba.dataSource.host = localhost
cuba.dataSource.dbName = demo_test
cuba.dataSource.username = cuba
cuba.dataSource.password = cuba

添加该文件至测试容器的 appPropertiesFiles 列表:

public class DemoTestContainer extends TestContainer {

    public DemoTestContainer() {
        super();
        appComponents = Arrays.asList(
                "com.haulmont.cuba"
        );
        appPropertiesFiles = Arrays.asList(
                "com/company/demo/app.properties",
                "com/haulmont/cuba/testsupport/test-app.properties",
                "com/company/demo/test-app.properties" // your test properties
        );
        autoConfigureDataSource();
    }

运行测试用例之前,执行这个任务创建测试数据库:

./gradlew createTestDb

这个测试容器应当在测试类里面作为 @RegisterExtension 注解指定的 JUnit 5 扩展:

package com.company.demo.core;

import com.company.demo.DemoTestContainer;
import com.company.demo.entity.Customer;
import com.haulmont.cuba.core.entity.contracts.Id;
import com.haulmont.cuba.core.global.AppBeans;
import com.haulmont.cuba.core.global.DataManager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class CustomerTest {

    // Using the common singleton instance of the test container which is initialized once for all tests
    @RegisterExtension
    static DemoTestContainer cont = DemoTestContainer.Common.INSTANCE;

    static DataManager dataManager;

    @BeforeAll
    static void beforeAll() {
        // Get a bean from the container
        dataManager = AppBeans.get(DataManager.class);
    }

    @Test
    void testCreateLoadRemove() {
        Customer customer = cont.metadata().create(Customer.class);
        customer.setName("c1");

        Customer committedCustomer = dataManager.commit(customer);
        assertEquals(customer, committedCustomer);

        Customer loadedCustomer = dataManager.load(Id.of(customer)).one();
        assertEquals(customer, loadedCustomer);

        dataManager.remove(loadedCustomer);
    }
}
几个有用的测试容器方法

TestContainer 类包含了以下几个方法,可以在测试类里面使用(参考上面的 CustomerTest 例子):

  • persistence() – 返回 Persistence 接口的引用。

  • metadata() – 返回 Metadata 接口的引用。

  • deleteRecord() – 这一组重载方法的目的是在 @After 方法里面使用,在测试完成后清理数据库。

还有,可以按照上面例子中的方法使用 AppBeans.get() 静态方法获取任何 bean。

日志

测试容器根据平台提供的 test-logback.xml 文件来配置日志。

可以通过以下方法配置测试的日志级别:

  • 在项目 core 模块的 test 目录内创建 my-test-logback.xml 文件。

  • my-test-logback.xml 里面配置 appenders 和 loggers。可以从 cuba-core-tests 工件内的 test-logback.xml 文件复制默认的文件内容。

  • 在测试容器里面添加一段静态初始化代码,这段代码通过设置 logback.configurationFile 这个系统属性来指定日志配置文件的位置:

    public class DemoTestContainer extends TestContainer {
    
        static {
            System.setProperty("logback.configurationFile", "com/company/demo/my-test-logback.xml");
        }
附加数据存储

如果项目使用了附加数据存储,并且附加数据库类型与主数据库不同,需要在 build.gradlecore 模块将数据库的驱动添加到 testRuntime 依赖中。示例:

configure(coreModule) {
    // ...
    dependencies {
        // ...
        testRuntime(hsql)
        jdbc('org.postgresql:postgresql:9.4.1212')
        testRuntime('org.postgresql:postgresql:9.4.1212') // add this
    }

4.9.2. Web 集成测试

Web 集成测试运行在 Web 客户端 block 的 Spring 容器中。测试容器独立于中间件工作,因为框架会自动为所有中间件服务创建桩代码。测试基础设施由 com.haulmont.cuba.web.testsupport 及其内部包的下列类组成:

  • TestContainer - Spring 容器的包装器,用来作为项目特定容器的基类。

  • TestServiceProxy - 为中间件服务提供默认的桩代码。该类可以用来注册为特定用例 mock 的服务,参考其 mock() 静态方法。

  • DataServiceProxy - DataManager 的默认桩代码。其包含一个 commit() 方法的实现,能模拟真正的数据存储的行为:能让新实体 detach,增加实体版本,等等。加载方法返回 null 和空集合。

  • TestUiEnvironment - 提供一组方法用来配置和获取 TestContainer。该类的实例在测试中需要作为 JUnit 5 的扩展来使用。

  • TestEntityFactory - 测试中为方便创建实体实例的工厂。可以通过 TestContainer 获取工厂。

尽管框架为服务提供了默认桩代码,但是在测试中也许需要自己创建服务的 mock。要创建 mock,可以使用任何 mocking 框架,通过添加其为依赖即可,如上节所说。服务的 mock 均使用 TestServiceProxy.mock() 方法注册。

Web 集成测试容器示例

web 模块创建 test 目录。然后在 test 目录合适的包内创建项目的测试容器类:

package com.company.demo;

import com.haulmont.cuba.web.testsupport.TestContainer;

import java.util.Arrays;

public class DemoWebTestContainer extends TestContainer {

    public DemoWebTestContainer() {
        appComponents = Arrays.asList(
                "com.haulmont.cuba"
                // add CUBA add-ons and custom app components here
        );
        appPropertiesFiles = Arrays.asList(
                // List the files defined in your web.xml
                // in appPropertiesConfig context parameter of the web module
                "com/company/demo/web-app.properties",
                // Add this file which is located in CUBA and defines some properties
                // specifically for test environment. You can replace it with your own
                // or add another one in the end.
                "com/haulmont/cuba/web/testsupport/test-web-app.properties"
        );
    }

    public static class Common extends DemoWebTestContainer {

        // A common singleton instance of the test container which is initialized once for all tests
        public static final DemoWebTestContainer.Common INSTANCE = new DemoWebTestContainer.Common();

        private static volatile boolean initialized;

        private Common() {
        }

        @Override
        public void before() throws Throwable {
            if (!initialized) {
                super.before();
                initialized = true;
            }
            setupContext();
        }

        @Override
        public void after() {
            cleanupContext();
            // never stops - do not call super
        }
    }
}
UI 界面测试示例

下面是 Web 集成测试的示例,在一些用户操作之后检查了编辑实体的状态。

package com.company.demo.customer;

import com.company.demo.DemoWebTestContainer;
import com.company.demo.entity.Customer;
import com.company.demo.web.screens.customer.CustomerEdit;
import com.haulmont.cuba.gui.Screens;
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.screen.OpenMode;
import com.haulmont.cuba.web.app.main.MainScreen;
import com.haulmont.cuba.web.testsupport.TestEntityFactory;
import com.haulmont.cuba.web.testsupport.TestEntityState;
import com.haulmont.cuba.web.testsupport.TestUiEnvironment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import java.util.Collections;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

public class CustomerEditInteractionTest {

    @RegisterExtension
    TestUiEnvironment environment =
            new TestUiEnvironment(DemoWebTestContainer.Common.INSTANCE).withUserLogin("admin"); (1)

    private Customer customer;

    @BeforeEach
    public void setUp() throws Exception {
        TestEntityFactory<Customer> customersFactory =
                environment.getContainer().getEntityFactory(Customer.class, TestEntityState.NEW);

        customer = customersFactory.create(Collections.emptyMap()); (2)
    }

    @Test
    public void testGenerateName() {
        Screens screens = environment.getScreens(); (3)

        screens.create(MainScreen.class, OpenMode.ROOT).show(); (4)

        CustomerEdit customerEdit = screens.create(CustomerEdit.class); (5)
        customerEdit.setEntityToEdit(customer);
        customerEdit.show();

        assertNull(customerEdit.getEditedEntity().getName());

        Button generateBtn = (Button) customerEdit.getWindow().getComponent("generateBtn"); (6)
        customerEdit.onGenerateBtnClick(new Button.ClickEvent(generateBtn)); (7)

        assertEquals("Generated name", customerEdit.getEditedEntity().getName());
    }
}
1 - 定义带共享容器和带有 admin 的用户会话存根的测试环境。
2 - 创建 new 状态的实体实例。
3 - 从环境获取 Screens 基础设施对象。
4 - 打开主界面,打开应用程序界面必须的步骤。
5 - 创建、初始化并打开实体编辑界面。
6 - 获取 Button 组件。
7 - 创建一个点击事件,并以调用控制器方法的方式响应点击操作。
测试在界面加载数据的示例

下面是一个 web 集成测试的示例,检查加载数据的正确性。

package com.company.demo.customer;

import com.company.demo.DemoWebTestContainer;
import com.company.demo.entity.Customer;
import com.company.demo.web.screens.customer.CustomerEdit;
import com.haulmont.cuba.core.app.DataService;
import com.haulmont.cuba.core.entity.Entity;
import com.haulmont.cuba.core.global.LoadContext;
import com.haulmont.cuba.gui.Screens;
import com.haulmont.cuba.gui.model.InstanceContainer;
import com.haulmont.cuba.gui.screen.OpenMode;
import com.haulmont.cuba.gui.screen.UiControllerUtils;
import com.haulmont.cuba.web.app.main.MainScreen;
import com.haulmont.cuba.web.testsupport.TestEntityFactory;
import com.haulmont.cuba.web.testsupport.TestEntityState;
import com.haulmont.cuba.web.testsupport.TestUiEnvironment;
import com.haulmont.cuba.web.testsupport.proxy.TestServiceProxy;
import mockit.Delegate;
import mockit.Expectations;
import mockit.Mocked;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class CustomerEditLoadDataTest {

    @RegisterExtension
    TestUiEnvironment environment =
            new TestUiEnvironment(DemoWebTestContainer.Common.INSTANCE).withUserLogin("admin"); (1)

    @Mocked
    private DataService dataService; (1)

    private Customer customer;

    @BeforeEach
    public void setUp() throws Exception {
        new Expectations() {{ (2)
            dataService.load((LoadContext<? extends Entity>) any);
            result = new Delegate() {
                Entity load(LoadContext lc) {
                    if ("demo_Customer".equals(lc.getEntityMetaClass())) {
                        return customer;
                    } else
                        return null;
                }
            };
        }};

        TestServiceProxy.mock(DataService.class, dataService); (3)

        TestEntityFactory<Customer> customersFactory =
                environment.getContainer().getEntityFactory(Customer.class, TestEntityState.DETACHED);

        customer = customersFactory.create(
                "name", "Homer", "email", "homer@simpson.com"); (4)
    }

    @AfterEach
    public void tearDown() throws Exception {
        TestServiceProxy.clear(); (5)
    }

    @Test
    public void testLoadData() {
        Screens screens = environment.getScreens();

        screens.create(MainScreen.class, OpenMode.ROOT).show();

        CustomerEdit customerEdit = screens.create(CustomerEdit.class);
        customerEdit.setEntityToEdit(customer);
        customerEdit.show();

        InstanceContainer customerDc = UiControllerUtils.getScreenData(customerEdit).getContainer("customerDc"); (6)
        assertEquals(customer, customerDc.getItem());
    }
}
1 - 使用 JMockit framework 定义数据服务 mock。
2 - 定义 mock 行为。
3 - 注册 mock。
4 - 创建 detached 状态的实体实例。
5 - 测试完成后移除 mock。
6 - 获取数据容器。

4.10. 热部署

CUBA 框架支持热部署技术,可以在项目运行时进行项目改动的部署,改动即时生效而且不需要重启应用服务。本质上,热部署是将项目的资源改动和 Java 源文件改动拷贝到应用的配置目录,然后运行中的应用程序会编译源文件并且加载新的类和资源。

工作原理

当项目中的源代码改动时,Studio 会拷贝改动过的文件到 web 应用程序的配置目录 (tomcat/conf/app 或者 tomcat/conf/app-core)。由于 Tomcat 配置目录中资源的优先级比应用程序 JAR 包里面的高,所以程序会在下次需要这些资源的时候从配置目录加载。如果需要加载的是 Java 源码,则会先编译再加载编译过后的类。

Studio 也会给应用程序发信号,通知它清理掉缓存以便加载改动过的资源,这些缓存包含信息(messages)缓存、view 的配置、注册的界面还有菜单。

当应用程序服务重启的时候,所有在配置目录的文件都会被删除,因为新的 JAR 包会包含代码的最新改动。

能做热部署的部分

其它 UI 和中间件类或者 bean(包含它们的静态方法),只有在需要它们的某些界面文件或者中间件服务的实现也发生了改动的时候才会做热部署。

原因是,类加载是靠信号驱动的:对于界面控制器来说,这个信号是用户重新打开了界面;对于服务来说 - Studio 生成了一个特殊的触发器文件(trigger file),这个文件可以被应用服务识别,并且使用这个文件来加载里面提到的特定的类和相关的依赖。

不能热部署的部分
在 Studio 里面使用热部署

热部署的设置可以在 Studio 中进行配置:主菜单点击 CUBA > Settings,然后选择 CUBA > Project settings 元素。

  • 点击 Hot Deploy Settings 链接可以配置源代码路径和 Tomcat 路径的映射关系。

  • Instant hot deploy 复选框可以设置关闭当前项目的热部署

当热部署禁用之后,可以通过在主菜单点击 CUBA > Build Tasks > Hot Deploy To Configuration Directory 手动触发。

4.11. 故障分析

本章介绍在 CUBA 应用程序开发过程中遇到的各种问题的解决方案。

4.11.1. 在 Windows 构建 widgetset

当在 Windows 构建带有 web-toolkit 模块和自定义 widgetset 时,有时候您会在控制台窗口碰到下面的错误:

Execution failed for task ':app-web-toolkit:buildWidgetSet'.
> A problem occurred starting process 'command 'C:\Program Files\AdoptOpenJDK\jdk-8.0.242.08-hotspot\bin\java.exe''

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

这个错误消息没有显示问题产生的原因。打开终端窗口(或 IntelliJ IDEA 或 CUBA Studio 的终端工具窗),在项目目录运行带有 --stacktrace 选项的命令(如果您项目前缀名不是 app,则需替换):

gradlew :app-web-toolkit:buildWidgetSet --stacktrace

错误输出如下:

...
Caused by: java.io.IOException: Cannot run program "C:\Program Files\AdoptOpenJDK\jdk-8.0.242.08-hotspot\bin\java.exe" (in directory "C:\projects\proj\modules\web-toolkit"): CreateProcess error=206, The filename or extension is too long
        at net.rubygrapefruit.platform.internal.DefaultProcessLauncher.start(DefaultProcessLauncher.java:25)
        ... 8 more
Caused by: java.io.IOException: CreateProcess error=206, The filename or extension is too long
        ... 9 more

如果 错误消息包含 "CreateProcess error=206" - 那就是说您遇上了 臭名昭著的 Windows 限制(此处链接为 Google 搜索) - 在命令行中无法创建超过 32K 字符长度的进程。

不幸的是,没有能自动避免该问题的方法。下面是几个能解决这个 "The filename or extension is too long" 问题的办法:

  • 切换至其他操作系统开发,MacOS 或 Linux。

  • 升级项目的 Gradle 版本至 6 或者更新的版本,需要修改项目中 gradle\wrapper\gradle.properties 文件的 distributionUrl 属性。Gradle 6 不会出现这个问题,因为修改了往命令行传参的方式。注意,CUBA 默认使用 5.6.4 开发并测试,所以修改 Gradle 版本可能需要您手动调整构建脚本。

  • 缩短 buildWidgetSet Gradle 任务的字符数,少于 32K 即可。

缩短 buildWidgetSet 命令

使用下列方式可以缩短 buildWidgetSet 命令:

  1. 将项目移至较短的目录,比如 C:\proj\

  2. 将 Gradle 用户 home 目录移至尽可能最短的目录。下面有详解。

  3. 移除 app-web-toolkit 模块构建 widgetset 不需要的传递依赖。下面有详解。

确定 buildWidgetSet 命令行长度

如需检测构建 widgetset 命令的 Java 进程实际长度,可以在终端运行以下命令:

gradlew -i :app-web-toolkit:buildWidgetSet --stacktrace > build.log

然后打开 build.log 文件,搜索下面内容:

GWT Compiler args:
	[...]
JVM Args:
	[...]
Starting process 'command 'C:\...\bin\java.exe''. Working directory: ... Command: C:\...\java.exe <THOUSANDS OF CHARACTERS> com.company.project.web.toolkit.ui.AppWidgetSet

"Starting process …​" 这一行,包含了所有命令以及参数,所以这行的长度基本反映了实际执行命令的长度,我们需要缩短它。

修改 Gradle 用户 home 目录

Gradle 手册 有关于 Gradle 用户 home 目录的介绍。

  1. 选择用户 home 目录的新名称,推荐使用磁盘根目录下的一个字母,比如,C:\g\

  2. 为系统添加新的环境变量 GRADLE_USER_HOME,值为上一步修改的目录,比如,C:\g

  3. 在文件管理器打开用户根目录:C:\users\%myusername%

  4. .gradle 文件夹移至新位置,并按照刚才选定的那个字母重命名,即: C:\users\%myusername%\.gradleC:\g

  5. 重新打开 IntelliJ IDEA 或 CUBA Studio IDE 使得新创建的环境变量生效。

移除 app-web-toolkit 模块不需要的传递依赖

首先,需要知道 app-web-toolkit 模块有哪些传递依赖。在终端运行:

gradlew :app-web-toolkit:dependencies > deps.log

然后打开 deps.log 文件,在依赖列表中查找 compile

然后打开 build.gradle 文件,修改 configure(webToolkitModule) { 部分。按下面示例一样添加排除依赖的规则:

configure(webToolkitModule) {
    configurations.compile {
        // library dependencies that aren't necessary for widgetset compilation
        exclude group: 'org.springframework'
        exclude group: 'org.springframework.security.oauth'
        exclude group: 'org.eclipse.persistence'
        exclude group: 'org.codehaus.groovy'
        exclude group: 'org.apache.ant'
        exclude group: 'org.eclipse.jetty'
        exclude group: 'com.esotericsoftware'
        exclude group: 'com.googlecode.owasp-java-html-sanitizer'
        exclude group: 'net.sourceforge.htmlunit'

        // add-on dependencies that don't contain web components or widgetset
        // and therefore aren't necessary for widgetset compilation
        exclude group: 'com.haulmont.addon.restapi'
        exclude group: 'com.haulmont.reports'
        exclude group: 'com.haulmont.addon.admintools'
        exclude group: 'com.haulmont.addon.search'
        exclude group: 'com.haulmont.addon.emailtemplates'
        exclude group: 'de.diedavids.cuba.metadataextensions'
        exclude group: 'de.diedavids.cuba.instantlauncher'
    }
    // ...
}

上面的示例仅供参考。您的项目中需要移除的依赖库或者插件可能更多。

多尝试几次移除依赖库直到 buildWidgetSet 命令行的长度小于 32k。

5. 应用程序部署

本章节介绍了 CUBA 应用程序部署和操作的不同概念。

下图是一个可能的部署架构。这个架构消除了单点故障,提供了负载均衡和不同客户端的连接。

DeploymentStructure

最简单的情况,应用程序可以安装在一台机器,并且包含了数据库。根据负载和容错性要求可以选择多样的部署场景,细节请参考 应用程序扩展

5.1. 应用程序主目录

应用程序主目录是一个文件系统目录,CUBA 应用程序在这里保存临时文件,也可以放置local.app.propertieslogback.xml这样的配置文件。下面提到的大多数应用程序文件夹都放在应用程序主目录中。文件存储默认也是用了应用程序主目录下的一个子目录。

由于 CUBA 应用程序会在主目录创建多种文件(临时文件、日志文件等),所以运行应用程序的用户对这个目录需要有写权限。

框架从 app.home java 系统属性获取应用程序主目录路径。

推荐在启动应用程序服务时,使用命令行参数 -D 显式的指定该属性。

显式的设置应用程序主目录

当运行 UberJAR 时,指定 -D 命令行参数,示例:

java -Dapp.home=/opt/app_home -jar app.jar

当使用 WAR 部署时,在启动脚本的合适位置用 -D 命令行参数设置 app.home,或者用应用程序服务器的其他推荐方法设置。比如,在 Tomcat 中,在 bin/setenv.sh 内添加下面内容:

CATALINA_OPTS="-Dapp.home=\"$CATALINA_BASE/work/app_home\" -Xmx512m -Dfile.encoding=UTF-8"

如果您使用部署至 Tomcat Windows 服务,需要将每个属性在 Tomcat 服务设置窗口的 Java Options 字段单独配置一行。

应用程序主目录自动检测

如果命令行参数 app.home 未提供,则根据下面规则自动设置:

  1. 如果应用程序是以 UberJAR 的方式启动,当前工作目录作为应用程序主目录。

  2. 如果 catalina.base 系统参数已设置(即应用程序运行在 Tomcat 中),应用程序主目录设置为 ${catalina.base}/work/app_home

  3. 其他情况,应用程序主目录设置为用户根目录的 .app_home 文件夹。

选项 2 和 3 有这个缺点:Logback 初始化的过程会在 app.home 属性设置之前,所以在 logback.xml 文件中的类似 ${app.home} 的引用不会生效,日志文件的位置(如果是用这种方式设置的话)也不可确定。还有,此时也不能通过简单的添加 logback.xml 至应用程序主目录覆盖日志属性,因为在初始化的时候找不到该文件。

在开发过程中使用 快速部署 时,从 7.2 版本开始,应用程序主目录会设置为项目目录下的 deploy/app_home 目录。如果项目是基于平台旧版本的话,应用程序目录会是在 Tomcat 的 confwork 目录。

5.2. 应用程序文件目录

本章节介绍应用程序中各个 blocks 在运行时使用到的系统文件目录。

5.2.1. 配置文件目录

配置文件目录包含一些资源,这些资源可以在应用程序部署之后对项目配置、用户界面或者业务逻辑进行补充或者重写。重写是由 Resources 基础设施接口加载机制提供的,此机制先在配置文件目录进行搜索,然后才是 classpath,因此配置文件目录资源会比 JAR 文件和 classpath 里面同名资源的优先级要高。

配置文件目录可能包含以下类型的资源:

配置文件目录通过 cuba.confDir 应用程序属性指定。默认情况下,配置目录在 应用程序主目录 内。

5.2.2. 工作目录

应用程序使用工作目录来存储一些持久化数据和配置。

比如,文件存储机制默认使用工作目录的 filestorage 子目录。还有,中间件模块会将启动时自动生成的 persistence.xmlorm.xml 保存到工作目录。

工作目录是由 cuba.dataDir 应用程序属性指定。默认情况,工作目录在应用程序主目录下。

5.2.3. 日志目录

日志目录是应用程序创建日志文件的位置。其地址和内容是由 Logback 框架的配置决定,配置由 logback.xml 提供。参阅 日志 了解细节。

日志文件的位置一般用应用程序主目录的相对位置指定,示例:

<configuration debug="false">
    <property name="logDir" value="${app.home}/logs"/>
    <!-- ... -->

还需要设置 cuba.logDir 这个应用程序属性与 logback.xml 里面配置的目录一致。这样管理员可以通过 Administration > Server Log 界面加载和查看日志。

5.2.4. 临时目录

应用程序会在运行时使用这个目录来存放一些临时文件。通过 cuba.tempDir 应用程序属性指定这个目录。默认情况,临时目录在应用程序主目录下。

5.2.5. 数据库脚本目录

这个目录存放了用来创建和更新数据库的 SQL 脚本。是中间件 block 独有的目录。

脚本目录结构是按照 创建和更新数据库的脚本 里面描述的构建的,但是这些目录还有额外的更高一级的目录,用来区分应用程序组件和应用程序本身的脚本。高一级目录的数字编号是项目构建 任务创建的。

数据库脚本目录是由 cuba.dbDir 应用程序属性指定。在 Tomcat 的快速部署模式下,这个目录是中间件 web 应用程序目录下的 WEB-INF/db 目录,比如:tomcat/webapps/app-core/WEB-INF/db。如果是其它的部署方式,这个目录会 WAR 或者 UberJAR 文件里的 /WEB-INF/db 目录。

5.3. 部署选项

本章节介绍了部署 CUBA 应用程序的不同方法。

5.3.1. 快速部署

快速部署在开发程序的时候是默认的部署方式,因为快速部署的构建用时最少,能自动安装并且启动应用服务。生产环境也可以用这种方式部署。

快速部署是指在 Studio 中运行/调试应用程序或者点击主菜单的 CUBA > Build Tasks > Deploy。其实底层机制是 Studio 执行 deploy Gradle 任务,该任务在 build.gradle 文件中,分别为 coreweb 模块做了任务声明。在第一次执行 deploy 前,它还会运行 setupTomcat 任务安装和初始化本地的 Tomcat 服务。也可以不使用 Studio 手动运行这些任务。

需要确保系统环境没有 CATALINA_HOMECATALINA_BASECLASSPATH 这三个环境变量。这些环境变量可能会导致启动 Tomcat 出问题并且在日志里没有任何提示。移除这些环境变量后需要重启机器。

快速部署会在 deploy 目录创建如下目录结构(只列出重要的文件和目录):

deploy/
    app_home/
        app/
            conf/
            temp/
            work/
        app-core/
            conf/
            temp/
            work/
        logs/
            app.log
        local.app.properties
        logback.xml

    tomcat/
        bin/
            setenv.bat, setenv.sh
            startup.bat, startup.sh
            debug.bat, debug.sh
            shutdown.bat, shutdown.sh
        conf/
            catalina.properties
            server.xml
            logging.properties
            Catalina/
                localhost/
        lib/
            hsqldb-2.4.1.jar
        logs/
        shared/
            lib/
        webapps/
            app/
            app-core/
  • deploy/app_home - 应用程序主目录

    • app/conf, app-core/conf - web 客户端和中间件应用程序的配置目录

    • app/temp, app-core/temp – web 客户端和中间件应用程序的临时目录

    • app/work, app-core/work – web 客户端和中间件应用程序的工作目录

    • logs - 日志目录。应用程序的默认主日志文件为 app.log

    • local.app.properties - 为此次部署设置特殊的应用程序属性的文件。

    • logback.xml - 日志配置。

  • deploy/tomcat - 本地 Tomcat 目录。

    • bin – 包含配置、启动和停止 Tomcat 服务的脚本:

      • setenv.bat, setenv.sh – 这两个脚本用来设置环境变量。这些脚本可以用来设置 JVM 内存参数、配置访问 JMX,以及连接调试器的参数。

        如果在 Linux 虚拟机(VPS)中,Tomcat 启动的很慢,可以尝试在 setenv.sh 里给 JVM 配置非阻塞熵源(non-blocking entropy source):

        CATALINA_OPTS="$CATALINA_OPTS -Djava.security.egd=file:/dev/./urandom"
      • startup.bat, startup.sh – 启动 Tomcat 服务的脚本。在 Windows 环境,Tomcat 会在一个单独的终端窗口启动,但是在类 Unix 系列的系统服务会在后台启动。

        需要在当前终端窗口启动服务,使用以下命令代替 startup.*

        > catalina.bat run

        $ ./catalina.sh run

      • debug.bat, debug.sh – 跟 startup.* 类似,但是会启动能连接调试器的 Tomcat 服务。这些脚本会在执行构建脚本中的 start 任务的时候使用。

      • shutdown.bat, shutdown.sh – 停止 Tomcat 服务。

    • conf – 包含 Tomcat 的配置文件。

      • catalina.properties – Tomcat 属性文件。如果需要从 shared/lib 目录加载共享库(参阅下文),这个文件需要配置下面这行:

        shared.loader=${catalina.home}/shared/lib/*.jar
      • server.xml – Tomcat 配置文件。

      • logging.properties – Tomcat 服务日志配置文件。

      • Catalina/localhost – 这个目录下可以放置 context.xml 应用程序部署描述文件。这个目录下放置的描述文件会比 META-INF 目录下的描述文件优先级高。这种机制可以用在生产环境。比如,通过这种机制可以配置跟应用程序本身不同的数据库连接参数,从而达到生产环境连接不同数据库的要求。

        针对不同服务的描述文件需要有不同服务的应用程序名称和 .xml 扩展名。所以,如果是为 app-core 创建部署描述文件,需要拷贝 webapps/app-core/META-INF/context.xml 文件成 conf/Catalina/localhost/app-core.xml 文件,然后通过修改 conf/Catalina/localhost/app-core.xml 内容覆盖设置。

    • lib – 服务的 通用类加载器(common classloader) 加载类库的目录。这些类库可以被这个 Tomcat 服务和所有部署在其中的 web 应用程序加载。还有,这个目录应该有数据库的 JDBC 驱动(hsqldb-XYZ.jar, postgresql-XYZ.jar 等)。

    • logs – Tomcat 日志目录。

    • shared/lib – 所有部署的应用可访问的类库目录。服务的 共享类加载器(shared classloader) 会加载这些类库。使用这个目录的方法在上面 conf/catalina.properties 文件中提到过。

      构建脚本的 deploy 任务会拷贝所有不在 jarNames 参数列举的类库到这个目录。

    • webapps – web 应用程序目录。每个应用程序在它自己的子目录里,子目录按照 展开成文件夹的 WAR(exploded WAR) 形式命名。

      构建脚本的 deploy 任务会按照 appName 参数来创建应用程序子目录,除了其它的文件之外,还会拷贝列在 jarNames 参数的类库到每个应用程序的 WEB-INF/lib 目录。

可以在 build.gradle 里指定 Tomcat 的路径和应用程序主目录的路径,分别使用 cuba.tomcat.dircuba.appHome 属性,示例:

cuba {
    // ...
    tomcat {
        dir = "$project.rootDir/some_path/tomcat"
    }
    appHome = "$project.rootDir/some_path/app_home"
}
5.3.1.1. 生产环境使用 Tomcat

默认情况下,快速部署过程会在本地 Tomcat 实例下创建 appapp-core 这两个 web 服务,并且运行在 8080 端口。也就是说 web 客户端可以通过 http://localhost:8080/app 访问。

这个 Tomcat 可以作为生产环境使用,只需要拷贝 tomcat 目录和 app_home 目录到生产环境服务器,运行 Tomcat 的用户需要对这两个目录有读写权限。

之后,在 app_home/local.app.properties 文件设置服务器宿主名称:

cuba.webHostName = myserver
cuba.webAppUrl = http://myserver:8080/app

另外,需要在生产环境修改数据库连接以便使用生产库。可以通过修改 web 应用程序的context.xmltomcat/webapps/app-core/META-INF/context.xml)文件。也可以按照前面章节介绍的拷贝这个文件为 tomcat/conf/Catalina/localhost/app-core.xml,这样的话可以分别使用独立的开发和测试库环境配置。

可以从开发库备份来创建生产库,或者可以配置自动创建和更新生产库,参考生产环境更新数据库

5.3.2. 部署 WAR 至 Jetty

以下是一个部署 WAR 包到 Jetty web 服务器的示例。

我们将使用下面的文件目录结构:

  • C:\work\jetty-home\ - Jetty 安装目录

  • C:\work\jetty-base\ - Jetty 配置目录,用来保存 Jetty 的配置文件,额外的库以及 web 应用程序。

  • C:\work\app_home\ - CUBA 应用程序主目录

    1. 使用 Studio 中的 CUBA project tree > Project > Deployment > WAR Settings 对话框或者手动在build.gradle 末尾添加 buildWar 任务:

      task buildWar(type: CubaWarBuilding) {
          appProperties = ['cuba.automaticDatabaseUpdate': 'true']
          singleWar = false
      }

      需要注意的是,这里给 Middleware 和 web 客户端构建了单独的两个 WAR 文件。

    2. 从命令行启动 buildWar 任务(假设已经预先创建了 Gradle wrapper):

      gradlew buildWar

      如果成功的话,会在项目的 build\distributions\war 目录创建 app-core.warapp.war

    3. 创建一个应用程序主目录目录,比如,c:\work\app_home

    4. 从开发环境的 Tomcat(deploy/tomcat/conf 项目子目录)复制 logback.xml 到应用程序主目录,并编辑文件中的 logDir 属性:

      <property name="logDir" value="${app.home}/logs"/>
    5. 下载并安装 Jetty 到本地目录,比如 c:\work\jetty-home。本示例使用 jetty-distribution-9.4.22.v20191022.zip 测试通过。

    6. 创建 c:\work\jetty-base 目录,并且在这个目录打开命令行窗口执行以下命令:

      java -jar c:\work\jetty-home\start.jar --add-to-start=http,jndi,deploy,plus,ext,resources
    7. 创建 c:\work\jetty-base\app-jetty.xml 文件,定义数据库连接池。对于 PostgreSQL 数据库的该文件内容应当基于以下模板:

      <?xml version="1.0"?>
      <!DOCTYPE Configure PUBLIC "-" "http://www.eclipse.org/jetty/configure_9_0.dtd">
      <Configure id="wac" class="org.eclipse.jetty.webapp.WebAppContext">
          <New id="CubaDS" class="org.eclipse.jetty.plus.jndi.Resource">
              <Arg/>
              <Arg>jdbc/CubaDS</Arg>
              <Arg>
                  <New class="org.apache.commons.dbcp2.BasicDataSource">
                      <Set name="driverClassName">org.postgresql.Driver</Set>
                      <Set name="url">jdbc:postgresql://localhost/db_name</Set>
                      <Set name="username">username</Set>
                      <Set name="password">password</Set>
                      <Set name="maxIdle">2</Set>
                      <Set name="maxTotal">20</Set>
                      <Set name="maxWaitMillis">5000</Set>
                  </New>
              </Arg>
          </New>
      </Configure>

      MS SQL 数据库的 app-jetty.xml 文件需要使用下面这个模板:

      <?xml version="1.0"?>
      <!DOCTYPE Configure PUBLIC "-" "http://www.eclipse.org/jetty/configure_9_0.dtd">
      <Configure id="wac" class="org.eclipse.jetty.webapp.WebAppContext">
          <New id="CubaDS" class="org.eclipse.jetty.plus.jndi.Resource">
              <Arg/>
              <Arg>jdbc/CubaDS</Arg>
              <Arg>
                  <New class="org.apache.commons.dbcp2.BasicDataSource">
                      <Set name="driverClassName">com.microsoft.sqlserver.jdbc.SQLServerDriver</Set>
                      <Set name="url">jdbc:sqlserver://server_name;databaseName=db_name</Set>
                      <Set name="username">username</Set>
                      <Set name="password">password</Set>
                      <Set name="maxIdle">2</Set>
                      <Set name="maxTotal">20</Set>
                      <Set name="maxWaitMillis">5000</Set>
                  </New>
              </Arg>
          </New>
      </Configure>
    8. 数据库连接池需要下载以下这些 JAR 并且添加到 c:\work\jetty-base\lib\ext 目录。其中有两个文件可以在 deploy\tomcat\shared\lib 项目子目录中找到:

      commons-pool2-2.6.2.jar
      commons-dbcp2-2.7.0.jar
      commons-logging-1.2.jar
    9. 从 Jetty 安装目录复制 start.ini 文件至 c:\work\jetty-base 目录。在 c:\work\jetty-base\start.ini 文件开始处添加下列内容:

      --exec
      -Xdebug
      -agentlib:jdwp=transport=dt_socket,address=8787,server=y,suspend=n
      -Dapp.home=c:\work\app_home
      -Dlogback.configurationFile=c:\work\app_home\logback.xml
      app-jetty.xml
    10. 复制数据库的 JDBC 驱动到 c:\work\jetty-base\lib\ext 目录。可以从项目的 deploy\tomcat\lib 目录复制驱动。比如对于 PostgreSQL,驱动文件是 postgresql-42.2.5.jar

    11. 复制 WAR 文件到 c:\work\jetty-base\webapps 目录。

    12. c:\work\jetty-base 目录打开命令行窗口并且执行:

      java -jar c:\work\jetty-home\start.jar
    13. 在浏览器打开 http://localhost:8080/app

5.3.3. 部署 WAR 到 WildFly

CUBA 应用程序的 WAR 包可以部署在 WildFly 应用服务中。下面这个示例是部署使用 PostgreSQL 的 CUBA 应用程序到 Windows 的 WildFly 18.0.1。

  1. 编辑 build.gradle 并在 global 模块的 dependencies 部分添加依赖:

    runtime 'org.reactivestreams:reactive-streams:1.0.1'
  2. 组装并且部署项目到默认的 Tomcat 服务,以便加载项目所有的依赖到本地。

  3. 为程序配置应用程序主目录

    • 创建一个 WildFly 服务能完全控制的目录,比如 C:\Users\UserName\app_home

    • tomcat/conf 拷贝 logback.xml 文件到这个目录,并修改 logDir 属性:

    <property name="logDir" value="${app.home}/logs"/>
  4. 配置 WildFly 服务

    • 在本地目录安装 WildFly,比如 C:\wildfly

    • 编辑 C:\wildfly\bin\standalone.conf.bat 文件,并将下面这行添加到文件末尾:

    set "JAVA_OPTS=%JAVA_OPTS% -Dapp.home=%USERPROFILE%/app_home -Dlogback.configurationFile=%USERPROFILE%/app_home/logback.xml"

    这行里将 app.home 系统属性指向之前创建的应用程序主目录,并且设置日志的配置文件指向先前创建的 logback.xml 文件。也可以使用绝对路径替换 %USERPROFILE% 变量。

    • 对比一下 WildFly 的 Hibernate Validator 版本跟 CUBA 的有没有不同,如果 CUBA 使用的是比较新的版本,那么用 tomcat\shared\lib 下面的版本(比如 hibernate-validator-6.1.1.Final.jar)替换掉 C:\wildfly\modules\system\layers\base\org\hibernate\validator\main\hibernate-validator-x.y.z-sometext.jar

    • \wildfly\modules\system\layers\base\org\hibernate\validator\main\module.xml 文件中更新一下替换的 JAR 包的版本。

    • 在 WildFly 中注册 PostgreSQL 驱动,从 tomcat\lib 目录拷贝 postgresql-42.2.5.jarC:\wildfly\standalone\deployments 目录。

    • 配置 WildFly logger:打开 \wildfly\standalone\configuration\standalone.xml 文件,在 <subsystem xmlns="urn:jboss:domain:logging:{version}" 区域添加下面两行:

      <subsystem xmlns="urn:jboss:domain:logging:8.0">
          <add-logging-api-dependencies value="false"/>
          <use-deployment-logging-config value="false"/>
          . . .
      </subsystem>
  5. 创建 JDBC 数据源

    • 执行 standalone.bat 启动 WildFly。

    • 浏览器打开管理员窗口 http://localhost:9990。第一次登录的时候,需要创建用户名和密码。

    • 打开 Configuration - Subsystems - Datasources and Drivers - Datasources 标签页,为应用程序创建新数据源:

    Name: Cuba
    JNDI Name: java:/jdbc/CubaDS
    JDBC Driver: postgresql-42.2.5.jar
    Driver Module Name: org.postgresql
    Driver Class Name: org.postgresql.Driver
    Connection URL: your database URL
    Username: your database username
    Password: your database password

    如果按照之前的步骤拷贝了 postgresql-x.y.z.jar,那么 JDBC 驱动会在自动检测到的驱动列表里显示。

    点击 Test connection 按钮测试数据库连接。

    • 启用这个数据源。

    • 或者,可以使用 bin/jboss-cli.bat 命令行工具创建 JDBC 数据源:

      [disconnected /] connect
      [standalone@localhost:9990 /] data-source add --name=Cuba --jndi-name="java:/jdbc/CubaDS" --driver-name=postgresql-42.2.5.jar --user-name=dblogin --password=dbpassword --connection-url="jdbc:postgresql://dbhost/dbname"
      [standalone@localhost:9990 /] quit
  6. 构建应用程序

    • 在 Studio 中打开 CUBA 项目树 > Project > Deployment > WAR Settings 窗口。

    • 勾选 Build WAR 复选框。

    • 保存设置。

    • 在 IDE 中打开 build.gradle 并且在 buildWar 任务中添加 doAfter 属性。这个属性会控制拷贝 WildFly 部署描述文件:

      task buildWar(type: CubaWarBuilding) {
          appProperties = ['cuba.automaticDatabaseUpdate' : true]
          singleWar = false
          doAfter = {
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/tmp/buildWar/core/war/META-INF/"
              }
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/tmp/buildWar/web/war/META-INF/"
              }
          }
      }

      对于单一 WAR(singleWAR)配置,这个任务稍有不同:

      task buildWar(type: CubaWarBuilding) {
          webXmlPath = 'modules/web/web/WEB-INF/single-war-web.xml'
          appProperties = ['cuba.automaticDatabaseUpdate' : true]
          doAfter = {
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/tmp/buildWar/META-INF/"
              }
          }
      }

      如果项目还包含了 Polymer 模块,在 single-war-web.xml 文件中添加下面这些配置:

      <servlet>
          <servlet-name>default</servlet-name>
          <init-param>
              <param-name>resolve-against-context-root</param-name>
              <param-value>true</param-value>
          </init-param>
      </servlet>
    • 在项目根目录,创建 jboss-deployment-structure.xml 文件,在里面添加 WildFly 部署描述文件:

    <?xml version="1.0" encoding="UTF-8"?>
    <jboss-deployment-structure xmlns="urn:jboss:deployment-structure:1.0">
        <deployment>
            <exclusions>
                <module name="org.apache.commons.logging" />
                <module name="org.apache.log4j" />
                <module name="org.jboss.logging" />
                <module name="org.jboss.logging.jul-to-slf4j-stub" />
                <module name="org.jboss.logmanager" />
                <module name="org.jboss.logmanager.log4j" />
                <module name="org.slf4j" />
                <module name="org.slf4j.impl" />
                <module name="org.slf4j.jcl-over-slf4j" />
            </exclusions>
        </deployment>
    </jboss-deployment-structure>
    • 执行 buildWar 任务创建 WAR 包。

  7. build\distributions\war 目录拷贝 app-core.warapp.war 到 WildFly 目录 \wildfly\standalone\deployments

  8. 重启 WildFly 服务。

  9. 可以通过 http://localhost:8080/app 访问应用,日志文件会保存在应用程序主目录的 C:\Users\UserName\app_home\logs 目录下。

5.3.4. 部署 WAR 至 Tomcat Windows 服务

  1. 使用 CUBA Studio 中的 Project > Deployment > WAR Settings 窗口或在 build.gradle 末尾添加 buildWar 任务:

    task buildWar(type: CubaWarBuilding) {
        singleWar = true
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = ['cuba.automaticDatabaseUpdate': true]
    }

    如果目标 Tomcat 服务的参数跟快速部署里用到的本地 Tomcat 的参数不同,需要提供相应的应用程序属性。比如,如果目标 Tomcat 运行在 9999 端口并且使用分离的 WAR 包,任务定义会是这样:

    task buildWar(type: CubaWarBuilding) {
        singleWar = false
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = [
            'cuba.automaticDatabaseUpdate': true,
            'cuba.webPort': 9999,
            'cuba.connectionUrlList': 'http://localhost:9999/app-core'
        ]
    }

    可以指定另外一个 context.xml 文件用来设置生产环境的数据库,或者之后在服务器提供这个文件,示例:

    task buildWar(type: CubaWarBuilding) {
        singleWar = true
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = ['cuba.automaticDatabaseUpdate': true]
        coreContextXmlPath = 'modules/core/web/META-INF/war-context.xml'
    }
  2. 执行 buildWar Gradle 任务。会在项目 build/distributions 目录生成 app.war 文件(或者几个文件,如果选择的是分离的 WAR)

    gradlew buildWar
  3. 创建应用程序主目录,比如,C:\app_home

  4. Apache Tomcat 官网 下载并安装 Tomcat 9 Windows Service Installer。

  5. 切换到安装好的服务的 bin 目录,使用管理员权限执行 tomcat9w.exe,以便设置 Tomcat 服务配置。

    1. Java 标签页设置 Maximum memory pool 为 1024MB。

    2. Java Options 字段,添加 -Dfile.encoding=UTF-8,配置 Tomcat 使用 UTF-8 编码。

    3. Java Options 字段,添加 -Dapp.home=c:/app_home,指定应用程序主目录。

      tomcat service settings
  6. 如需提供一个本地文件配置生产环境数据库连接属性的话,可以在 Tomcat 服务的子目录 conf\Catalina\localhost 内创建配置文件。根据 WAR 文件的不同,配置文件名不一样,比如,对于单一 WAR 部署,app.xml 就可以。对于多个 WAR 部署,名称为 app-core.xml。复制 context.xml 的内容至该文件。

  7. 使用默认配置的情况下,所有的应用程序日志消息都添加在 logs/tomcat9-stdout.log 文件内。有两个选择可以自定义日志的配置:

    • 在项目中创建 logback 配置文件。然后在 buildWar 任务设置 logbackConfigurationFile 参数指定该文件的路径(手动设置或者通过 Studio 的 WAR Settings 窗口配置)。

    • 在生产环境服务器创建日志配置文件。

      从开发环境的 Tomca(项目中的 deploy/tomcat/conf 子目录)复制 logback.xml应用程序主目录并且修改文件中的 logDir 属性:

      <property name="logDir" value="${app.home}/logs"/>

      在 Tomcat 9 Windows 服务的 Java Options 字段添加下面这行,指定日志的配置文件路径:

      -Dlogback.configurationFile=C:/app_home/logback.xml
  8. 拷贝项目生成的 WAR 文件(或多个 WAR)到 Tomcat 服务的 webapps 目录。

  9. 重启 Tomcat 服务。

  10. 在浏览器打开 http://localhost:8080/app

5.3.5. 部署 WAR 至 Tomcat Linux 服务

以下示例在 Ubuntu 18.04 测试通过,使用的 tomcat9 和 tomcat8 的包。

  1. build.gradle 末尾添加 buildWar 任务。可以指定不同的 context.xml 文件来设置生产环境数据库连接:

    task buildWar(type: CubaWarBuilding) {
        singleWar = true
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = ['cuba.automaticDatabaseUpdate': true]
        webXmlPath = 'modules/web/web/WEB-INF/single-war-web.xml'
        coreContextXmlPath = 'modules/core/web/META-INF/war-context.xml'
    }

    如果目标 Tomcat 服务的参数跟快速部署里用到的本地 Tomcat 的参数不同,需要提供相应的应用程序属性。比如,如果目标 Tomcat 运行在 9999 端口,任务定义会是这样:

    task buildWar(type: CubaWarBuilding) {
        singleWar = false
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = [
            'cuba.automaticDatabaseUpdate': true,
            'cuba.webPort': 9999,
            'cuba.connectionUrlList': 'http://localhost:9999/app-core'
        ]
    }
  2. 执行 buildWar Gradle 任务。会在项目 build/distributions 目录生成 app.war 文件。

    gradlew buildWar
  3. 安装 tomcat9 的包:

    sudo apt install tomcat9
  4. 拷贝项目生成的 app.war 文件到 Tomcat 服务的 /var/lib/tomcat9/webapps 目录。如果目录下存在示例文件夹,比如 /var/lib/tomcat9/webapps/ROOT,可以先删除。

    Tomcat 9 服务默认是使用 tomcat 用户来启动的。所以 webapps 目录的所有者也是 tomcat

  5. 创建 应用程序主目录。例如,/opt/app_home,然后修改 Tomcat 服务用户(tomcat)为该目录的所有者:

    sudo mkdir /opt/app_home
    sudo chown tomcat:tomcat /opt/app_home
  6. Tomcat 9 服务(跟之前 Tomcat Debian 包不同)是由 systemd 做沙箱管理,因此对于系统文件只有有限的写权限。关于这点,可以阅读 /usr/share/doc/tomcat9/README.Debian 了解更多详情。需要修改 systemd 的配置来允许 Tomcat 服务对应用程序主目录有写权限:

    1. /etc/systemd/system/tomcat9.service.d/ 目录创建 override.conf 文件:

      sudo mkdir /etc/systemd/system/tomcat9.service.d/
      sudo nano /etc/systemd/system/tomcat9.service.d/override.conf
    2. override.conf 文件的内容如下:

      [Service]
      ReadWritePaths=/opt/app_home/
    3. 重加载 systemd 的配置:

      sudo systemctl daemon-reload
  7. 创建配置文件 /usr/share/tomcat9/bin/setenv.sh 包含以下设置:

    CATALINA_OPTS="$CATALINA_OPTS -Xmx1024m"
    CATALINA_OPTS="$CATALINA_OPTS -Dapp.home=/opt/app_home"

    如果觉得虚拟机(VPS)中的 Tomcat 启动慢,可以在 setenv.sh 文件添加这行:

    CATALINA_OPTS="$CATALINA_OPTS -Djava.security.egd=file:/dev/./urandom"
  8. 如果想通过服务器上一个本地文件提供生产环境的数据库连接属性的话,可以在 /var/lib/tomcat9/conf/Catalina/localhost/ 目录创建一个文件。文件的命名需要根据 WAR 文件的名称来定,比如对于单一 WAR,可以命名为 app.xml,如果是多个 WAR 部署,则是 app-core.xml。然后复制 context.xml 文件的内容到这个文件。

  9. 默认配置下,所有应用程序的日志消息都添加在 /var/log/syslog 系统日志中。自定义应用程序的日志配置有两种方法:

    • 在项目中创建 logback 配置文件。然后在 Gradle 的buildWar任务中设置 logbackConfigurationFile 参数指定为该文件的路径(可以手动设置,或者通过 Studio 的 WAR Settings 窗口)。

    • 在生产环境服务器创建日志配置文件。

      从开发 Tomcat(deploy/tomcat/conf 项目子目录)复制 logback.xml 文件至应用程序主目录并修改文件内的 logDir 属性:

      <property name="logDir" value="${app.home}/logs"/>

      setenv.sh 脚本添加下面这行,用于指定日志配置文件:

      CATALINA_OPTS="$CATALINA_OPTS -Dlogback.configurationFile=/opt/app_home/logback.xml"
  10. 重启 Tomcat 服务:

    sudo systemctl restart tomcat9
  11. 在浏览器打开 http://localhost:8080/app

使用 tomcat8 安装包的不同之处

CUBA 支持部署至 Tomcat 9 和 Tomcat 8.5。当部署至 Tomcat 8.5 时,有下列不同点:

  • Tomcat 8.5 通过 tomcat8 包提供

  • 系统用户名为 tomcat8

  • Tomcat base 目录是 /var/lib/tomcat8

  • Tomcat home 目录是 /usr/share/tomcat8

  • Tomcat 服务不使用 systemd 沙箱机制,所以不需要修改 systemd 配置。

  • 标准的输出和标准错误输出都在 /var/lib/tomcat8/logs/catalina.out 文件中。

排查 LibreOffice 报表与 tomcat9 集成的错误

在部署至 tomcat9 包并且集成了 LibreOffice 使用 Reporting 扩展时,也许会遇到问题,错误消息可能如下:

2019-12-04 09:52:37.015 DEBUG [OOServer: ERR] com.haulmont.yarg.formatters.impl.doc.connector.OOServer - ERR: (process:10403): dconf-CRITICAL **: 09:52:37.014: unable to create directory '/.cache/dconf': Read-only file system.  dconf will not work properly.

这个错误的原因是 tomcat 用户的 home 目录指向了一个不可写的地址。可以通过修改 tomcat 用户的 home 目录为 /var/lib/tomcat9/work 进行修复:

# bad value
echo ~tomcat
/

# fix
sudo systemctl stop tomcat9
sudo usermod -d /var/lib/tomcat9/work tomcat
sudo systemctl start tomcat9

5.3.6. UberJAR 部署

这是在生产环境运行 CUBA 应用程序最简单的方法。可以通过 buildUberJar Gradle 任务来构建一个包含所有依赖的 JAR 文件(可以参考 Studio 的 *Deployment > UberJAR setting 界面),然后可以在命令行使用 java 来运行应用程序:

java -jar app.jar

所以应用程序的参数都是在构建时候定义的,但是可以在运行时覆盖这些参数(参阅以下)。web 应用程序默认的端口是 8080,地址是 http://host:8080/app。如果项目中有 Polymer UI,默认的 Polymer 客户端地址是 http://host:8080/app-front

如果为 Middleware 和 Web 客户款各自构建了单独的 JAR 包,可以用相同的方式运行:

java -jar app-core.jar

java -jar app.jar

web 客户端的默认端口是 8080,它会尝试连接中间件的默认地址 localhost:8079。所以如果在两个单独的窗口中运行了上面的两行命令,可以通过 http://localhost:8080/app 地址访问 web 客户端。

可以通过 Java 系统参数的方式提供应用程序属性来更改构建时候设置的应用程序默认参数。而且,端口号、上下文名称和 Jetty 配置文件的路径都可以通过命令行参数修改。

命令行参数
  • port - 定义嵌入的 HTTP 服务的端口,示例:

    java -jar app.jar -port 9090

    需要注意的是,如果客户端和 core 模块分别构建了单独的 JAR 包,且为 core 模块指定了端口,那么要为客户端模块提供带有相应 block 地址的 cuba.connectionUrlList 应用程序属性,示例:

    java -jar app-core.jar -port 7070
    
    java -Dcuba.connectionUrlList=http://localhost:7070/app-core -jar app.jar
  • contextName - 应用程序 block 的 web 上下文名称。比如,如果需要在 http://localhost:8080/sales 地址访问 web 客户端,运行如下命令:

    java -jar app.jar -contextName sales

    如需直接通过 http://localhost:8080 访问,运行命令:

    java -jar app.jar -contextName /
  • frontContextName - Polymer UI web 上下文名称(对单一 JAR,web JAR 或者 portal JAR 都有效)。

  • portalContextName - 单一 JAR 里面配置 portal 上下文名称。

  • jettyEnvPath - Jetty 环境配置文件的路径。用来重写构建时候指定的 coreJettyEnvPath 参数的值。这个新值可以设定为绝对路径或者工作目录的相对路径。

  • jettyConfPath - Jetty 服务配置文件的路径。用来重写构建时候指定的 webJettyConfPath/coreJettyConfPath/portalJettyConfPath 参数的值。这个新值可以设定为绝对路径或者工作目录的相对路径。

应用程序主目录

默认情况下,应用程序主目录就是程序工作的目录。也就是说应用程序子目录会在运行应用程序的目录里面创建。主目录可以通过 Java 系统参数 app.home 来重新指定。比如,如果想设置应用程序主目录为 /opt/app_home,可以在命令行添加如下参数:

java -Dapp.home=/opt/app_home -jar app.jar
日志

如果需要更改内嵌的日志设置,可以通过 Java 系统参数 logback.configurationFile 来提供一个指向新配置文件的 URL,示例:

java -Dlogback.configurationFile=file:./logback.xml -jar app.jar

这里假设 logback.xml 文件是在启动服务的目录下。

如果需要正确的设定日志输出目录,需要确保 logback.xml 文件里的 logDir 属性指定了应用程序主目录的 logs 子目录。

<configuration debug="false">
    <property name="logDir" value="${app.home}/logs"/>
    <!-- ... -->
设置部署时应用程序属性

可以在部署环境设置应用程序属性,设置一些在应用程序构建时无法提供的属性设置(比如处于安全考虑)。

可以使用 local.app.properties 文件来配置,操作系统环境变量或者 Java 系统属性通过命令行参数来提供。

local.app.properties 文件必须放置在应用程序主目录。如果未指定 -Dapp.home=some_path 命令行参数,则该目录为当前目录。示例:

local.app.properties
cuba.web.loginDialogDefaultUser = <disabled>
cuba.web.loginDialogDefaultPassword = <disabled>

对于操作系统环境变量,可以使用应用程序属性名称的全大写,点替换成下划线:

export CUBA_WEB_LOGINDIALOGDEFAULTUSER="<disabled>"
export CUBA_WEB_LOGINDIALOGDEFAULTPASSWORD="<disabled>"

Java 系统参数需要在命令行指定(注意,如果用 ps 命令查看进程会显示这些参数):

java -Dcuba.web.loginDialogDefaultUser=<disabled> -Dcuba.web.loginDialogDefaultPassword=<disabled> -jar app.jar

系统环境变量会覆盖 app.properties 文件设置的值,Java 系统参数会覆盖前两种。

停止应用程序

可以通过下面几种方式平和的停掉应用程序服务:

  • 在应用程序运行的终端窗口按下 Ctrl+C

  • 在类 Unix 系统执行 kill <PID>

  • 发送停止键值(比如,特定字符序列)到应用程序的特定端口,这个端口是在启动应用程序的时候通过命令行指定的。有以下命令行参数:

    • stopPort - 监听停止键值或者发送停止键值的端口。

    • stopKey - 停止键值。默认值 SHUTDOWN

    • stop - 通过发送停止键值停掉其它进程。

示例:

# 启动应用 1 并且在 9090 监听 SHUTDOWN
java -jar app.jar -stopPort 9090

# 启动应用 2 并且在 9090 监听 MYKEY
java -jar app.jar -stopPort 9090 -stopKey MYKEY

# 关停应用 1
java -jar app.jar -stop -stopPort 9090

# 关停应用 2
java -jar app.jar -stop -stopPort 9090 -stopKey MYKEY
5.3.6.1. 为 UberJAR 配置 HTTPS

下面的示例是 UberJAR 部署的情况下配置自签发认证的 HTTPS。

  1. 使用 JDK 自带的 Java Keytool 工具生成密钥和认证:

    keytool -keystore keystore.jks -alias jetty -genkey -keyalg RSA
  2. 在项目根目录配置带有 SSL 配置的 jetty.xml 文件:

    <Configure id="Server" class="org.eclipse.jetty.server.Server">
        <Call name="addConnector">
            <Arg>
                <New class="org.eclipse.jetty.server.ServerConnector">
                    <Arg name="server">
                        <Ref refid="Server"/>
                    </Arg>
                    <Set name="port">8090</Set>
                </New>
            </Arg>
        </Call>
        <Call name="addConnector">
            <Arg>
                <New class="org.eclipse.jetty.server.ServerConnector">
                    <Arg name="server">
                        <Ref refid="Server"/>
                    </Arg>
                    <Arg>
                        <New class="org.eclipse.jetty.util.ssl.SslContextFactory">
                            <Set name="keyStorePath">keystore.jks</Set>
                            <Set name="keyStorePassword">password</Set>
                            <Set name="keyManagerPassword">password</Set>
                            <Set name="trustStorePath">keystore.jks</Set>
                            <Set name="trustStorePassword">password</Set>
                        </New>
                    </Arg>
                    <Set name="port">8443</Set>
                </New>
            </Arg>
        </Call>
    </Configure>

    keyStorePasswordkeyManagerPasswordtrustStorePassword 需要按照 Keytool 的设置来配置。

  3. 在构建任务的配置中添加 jetty.xml

    task buildUberJar(type: CubaUberJarBuilding) {
        singleJar = true
        coreJettyEnvPath = 'modules/core/web/META-INF/jetty-env.xml'
        appProperties = ['cuba.automaticDatabaseUpdate' : true]
        webJettyConfPath = 'jetty.xml'
    }
  4. 按照部署 UberJAR 章节的介绍构建 Uber JAR。

  5. keystore.jks 跟项目的 JAR 放在一个目录下,然后启动 Uber JAR。

    通过浏览器访问 https://localhost:8443/app

5.3.7. 使用 Docker 部署

本章节介绍了如何在 Docker 容器内部署 CUBA 应用程序。

我们将使用 Sales Application 项目作为例子,迁移到 PostgreSQL 数据库,并构建 UberJAR,最后运行在容器中。事实上,构建为 WAR 的应用程序也能在容器化的 Tomcat 中运行,但是需要做更多的配置,所以如果只是示例,我们就用 UberJAR。

配置并构建 UberJAR

https://github.com/cuba-platform/sample-sales-cuba7 克隆示例程序,并且在 CUBA Studio中 打开

首先,将数据库类型改为 PostgreSQL:

  1. 在主菜单点击 CUBA > Main Data Store Settings…​

  2. Database type 字段选择 PostgreSQL 然后点击 OK

  3. 在主菜单点击 CUBA > Generate Database Scripts。Studio 打开包含自动生成脚本的 Database Scripts 窗口。点击 Save and close

  4. 在主菜单点击 CUBA > Create Database。Studio 会在本地 PostgreSQL 服务创建 sales 数据库。

接下来,配置构建 UberJAR 的 Gradle 任务。

  1. 在主菜单点击 CUBA > Deployment > Edit UberJAR Settings

  2. 勾选 Build Uber JARSingle Uber JAR 复选框。

  3. 点击 Logback configuration file 字段旁边的 Generate 按钮。

  4. 点击 Custom data store configuration 复选框旁边的 Configure 按钮。

  5. 确保 Database Properties 内的 URLjdbc:postgresql:// 开头。在第一个 URL 文本字段中输入 postgres 作为主机名而不是 localhost。下面介绍的连接容器数据库需要该配置。

  6. 点击 OK。Studio 会在 build.gradle 文件中添加 构建 UberJar 任务。

  7. 打开生成的 etc/uber-jar-logback.xml 文件或其他用于 Logback 配置的文件,确保 logDir 属性有如下值:

    <property name="logDir" value="${app.home}/logs"/>

    还有,确保 Logback 配置文件限制了 org.eclipse.jetty logger 的级别至少为 INFO。如果文件中没有该 logger,可以添加:

    <logger name="org.eclipse.jetty" level="INFO"/>

执行创建 JAR 的命令,使用主菜单的 CUBADeploymentBuild UberJAR 或者在终端执行下列命令:

./gradlew buildUberJar
创建 Docker 镜像

现在创建 Dockerfile 然后构建我们应用程序的 Docker 镜像。

  1. 在项目中创建 docker-image 目录。

  2. build/distributions/uberJar 复制 JAR 文件至该目录。

  3. 使用下面的内容创建一个 Dockerfile 文件:

    FROM openjdk:8
    
    COPY . /opt/sales
    
    CMD java -Dapp.home=/opt/sales-home -jar /opt/sales/app.jar

app.home Java 系统参数定义了应用程序主目录的文件夹,这里会存放日志文件和应用程序创建的其它文件。当运行容器时,我们能将该目录映射至宿主机的一个目录,这样能方便访问日志以及其它数据,包括上传至 FileStorage 的文件。

现在构建镜像:

  1. 在项目根目录打开终端窗口。

  2. 运行构建命令,在 -t 选项后面传递镜像名称以及 Dockerfile 所在的文件夹:

    docker build -t sales docker-image

当执行 docker images 命令时,确保能显示 sales 镜像。

运行应用程序和数据库容器

现在应用程序已经准备好可以在容器中运行了,但是我们还需要一个容器化的 PostgreSQL 数据库。为了管理两个容器 - 一个应用程序,一个数据库,我们使用 Docker Compose。

在项目根目录创建 docker-compose.yml 文件,使用以下内容:

version: '2'

services:
  postgres:
    image: postgres:12
    environment:
      - POSTGRES_DB=sales
      - POSTGRES_USER=cuba
      - POSTGRES_PASSWORD=cuba
    ports:
      - "5433:5432"
  web:
    depends_on:
      - postgres
    image: sales
    volumes:
      - /Users/me/sales-home:/opt/sales-home
    ports:
      - "8080:8080"

需要注意此文件中这些部分:

  • volumes 部分将容器的 /opt/sales-home 目录,也就是应用程序的主目录,映射到宿主机的 /Users/me/sales-home 目录。这样一来,应用程序的日志可以通过宿主机的 /Users/me/sales-home/logs 目录访问。

  • PostgreSQL 内部端口 5432 映射至宿主机的 5433 端口,避免与宿主机可能运行的 PostgreSQL 实例相冲突。使用该端口,可以在容器外访问数据库,比如,做数据库备份:

    pg_dump -Fc -h localhost -p 5433 -d sales -U cuba > /Users/me/sales.backup
  • 应用程序容器开放了端口 8080,所以应用程序 UI 可以通过 http://localhost:8080/app 在宿主机访问。

要启动应用程序和数据库,在 docker-compose.yml 文件所在的文件夹打开终端,运行:

docker-compose up

5.3.8. 部署至 Jelastic Cloud

下面是构建应用程序并部署至 Jelastic 云的示例。

注意,目前只支持使用 PostgreSQL 或者 HSQL 数据库的项目。

  1. 首先,浏览器打开 Jelastic 云创建一个免费的测试账号。

  2. 创建一个将要部署应用程序 WAR 的新环境:

    • 点击 New Environment

      jelasticEnvironment
    • 在显示的窗口中指定配置的值:兼容性强的环境需要有 Java 8,Tomcat 8 以及 PostgreSQL 9.1+(如果项目使用 PostgreSQL 的话)。在 Environment Name 字段,设置一个唯一的环境名称并点击 Create

    • 如果创建的环境使用 PostgreSQL,你会收到一封带有数据库连接信息的 email。使用 email 里面的链接打开数据库管理界面,然后创建一个空数据库。数据库名称可以稍后在自定义的 context.xml 文件设置。

  3. 使用 CUBA Studio 构建 Single WAR 文件:

    • 在主菜单选择 CUBA > Deployment > WAR Settings

    • 勾选 Build WAR 复选框。

    • Application home directory 字段输入 ..

    • 勾选 Include JDBC driverInclude Tomcat’s context.xml 复选框。

    • 如果项目使用了 PostgreSQL,点击 Custom context.xml path 旁边的 Generate 按钮。设置数据库的用户名、密码、主机以及之前创建的数据库名。

      customContextXml
    • 勾选 Single WAR for Middleware and Web Client 复选框。

    • 点击 Custom web.xml path 旁边的 Generate 按钮。Studio 将会生成一个特殊的单一 WAR web.xml,组合了中间件和 Web 客户端应用程序 block。

      jelasticWarSettings
    • App properties 字段填写 cuba.logDir 属性:

      appProperties = ['cuba.automaticDatabaseUpdate': true,
      'cuba.logDir': '${catalina.base}/logs']
    • 点击 OK 按钮。Studio 会在 build.gradle 文件添加 buildWar 任务。

      task buildWar(type: CubaWarBuilding) {
          includeJdbcDriver = true
          includeContextXml = true
          appProperties = ['cuba.automaticDatabaseUpdate': true,
                           'cuba.logDir'                 : '${catalina.base}/logs']
          webXmlPath = 'modules/web/web/WEB-INF/single-war-web.xml'
          coreContextXmlPath = 'modules/core/web/META-INF/war-context.xml'
      }
    • 如果项目使用的 HSQLDB,在 build.gradle 中找到 buildWar 任务,然后添加 hsqlInProcess = true 属性,这样可以在部署 WAR 文件的时候运行嵌入的 HSQL 服务。确保没有设置 coreContextXmlPath 属性。

      task buildWar(type: CubaWarBuilding) {
          appProperties = ['cuba.automaticDatabaseUpdate': true, 'cuba.logDir': '${catalina.base}/logs']
          includeContextXml = true
          webXmlPath = 'modules/web/web/WEB-INF/single-war-web.xml'
          includeJdbcDriver = true
          hsqlInProcess = true
      }
    • 命令行运行 buildWar 启动构建过程:

      gradlew buildWar

      最后,会在 build\distributions\war 项目子文件夹创建 app.war

  4. 使用 部署 War Gradle 任务将 WAR 文件部署至 Jelastic。

    task deployWar(type: CubaJelasticDeploy, dependsOn: buildWar){
        email = ****
        password = ****
        hostUrl = 'app.j.layershift.co.uk'
        environment = 'my-env-1'
    }
  5. 部署过程完成后,应用程序已经在 Jelastic 云上可用了。在浏览器输入 <environment>.<hostUrl> 即可打开项目。

    比如:

    http://my-env-1.j.layershift.co.uk

    也可以用 Jelastic 环境面板的 Open in Browser 按钮打开应用程序。

    jelasticDeploy

5.3.9. 部署至 Bluemix Cloud

CUBA Studio 通过几个简单步骤支持 IBM® Bluemix® 云部署。

Bluemix 云部署目前只支持使用 PostgreSQL 数据库的项目。HSQLDB 只支持 in-process 的情况,也就是说,每次应用程序重启时,会重建数据库,之前的用户数据会丢失。

  1. 创建一个 Bluemix 账号。下载并且安装:

    1. Bluemix CLI: http://clis.ng.bluemix.net/ui/home.html

    2. Cloud Foundry CLI: https://github.com/cloudfoundry/cli/releases

    3. 确保 bluemixcf 命令在命令行窗口有效。如果不行的话,添加 Bluemix 的 bin 目录,比如 \IBM\Bluemix\binPATH 环境变量。

  2. 在 Bluemix 创建一个空间(Space),需要的话,可以在一个空间里面使用几个应用程序。

  3. 在空间中创建一个应用服务:Create AppCloudFoundry AppsTomcat

  4. 给应用指定一个名称。名称需要是唯一的,因为这个名称会作为应用程序 URL 的一部分。

  5. 创建一个数据库服务,点击空间 dashboard 中的 Create service 然后选择 ElephantSQL

  6. 打开应用管理并且将数据库服务连接至应用程序。点击 Connect Existing。要使改动生效,系统需要重新加载(restaging/updating)应用程序。目前暂时不需要做这一步,因为应用程序会重新部署。

  7. 数据库服务连接上之后,数据库的用户密码可以通过点击 View Credentials 看到。数据库的属性存在程序运行时的 VCAP_SERVICES 环境变量里面,可以通过 cf env 命令看到。创建的数据库也可以从空间外面访问到,因此可以从开发环境连接线上的数据库。

  8. 设置 CUBA 项目运行在 PostgreSQL 数据库上(跟 Bluemix 类似的数据库环境)

  9. 生成数据库脚本然后启动本地 Tomcat 服务。确保应用程序启动没问题。

  10. 生成 WAR 文件用来部署到 Tomcat。

    1. 在 CUBA 项目视图的 Project 部分点击 Deployment > WAR Settings

    2. 勾选全部的复选框启用所有的功能,因为正确的部署是 Single WAR 带有 JDBC 驱动和 context.xml

      bluemix war settings
    3. 点击 Custom context.XML field 旁边的 Generate 按钮。在弹出的对话框中填写 Bluemix 里面创建的数据库的用户密码信息。

      从 DB 服务拿到的 uri 里面包含数据库的用户密码信息,按照下面这个示例使用:

      {
        "elephantsql": [
          {
            "credentials": {
              "uri": "postgres://ixbtsvsq:F_KyeQjpEdpQfd4n0KpEFCYyzKAbN1W9@qdjjtnkv.db.elephantsql.com:5432/ixbtsvsq",
              "max_conns": "5"
            }
          }
        ]
      }

      Database user: ixbtsvsq

      Database password: F_KyeQjpEdpQfd4n0KpEFCYyzKAbN1W9

      Database URL: qdjjtnkv.db.elephantsql.com:5432

      Database name: ixbtsvsq

    4. 点击 Generate 按钮生成单一 WAR 需要的自定义 web.xml 文件。

    5. 保存设置。使用 Studio 的 buildWar Gradle 任务或者命令行生成 WAR 包。

      bluemix buildWar

      成功的话,会在项目的 build/distributions/war/ 目录生成 app.war

  11. 在项目的根目录手动创建 manifest.yml 文件。文件内容需要包含下列信息:

    applications:
    - path: build/distributions/war/app.war
      memory: 1G
      instances: 1
      domain: eu-gb.mybluemix.net
      name: myluckycuba
      host: myluckycuba
      disk_quota: 1024M
      buildpack: java_buildpack
      env:
        JBP_CONFIG_TOMCAT: '{tomcat: { version: 8.0.+ }}'
        JBP_CONFIG_OPEN_JDK_JRE: '{jre: { version: 1.8.0_+ }}'

    这里的参数:

    • path - WAR 包文件的相对路径。

    • memory - 默认的内存限制是 1G。可以根据应用的具体情况增加或者减少,也可以通过 Bluemix 的 web 页面调整。需要注意内存大小直接影响运行费用。

    • name - 上面在云服务里创建的 Tomcat 应用的名称(取决于项目地址,参考 App URL,比如 https://myluckycuba.eu-gb.mybluemix.net/)。

    • host - 跟名称一样。

    • env - 设置 Tomcat 版本和 Java 版本的环境变量。

  12. 在命令行切换到 CUBA 项目的根目录。

    cd your_project_directory
  13. 连接到 Bluemix(再次检查域名)

    cf api https://api.eu-gb.bluemix.net
  14. 登录 Bluemix 账号。

    cf login -u your_bluemix_id -o your_bluemix_ORG
  15. 部署 WAR 到 Tomcat

    cf push

    push 命令从 manifest.yml 文件中读取所有需要的参数信息。

  16. 可以通过 Bluemix 的 web 页面 dashboard 的 Log 标签页查看 Tomcat 服务的日志,也可以在命令行通过以下命令查看:

    cf logs cuba-app --recent
  17. 部署过程完成后,可以在浏览器通过 host.domain URL 来访问。这个 URL 会显示在 Cloud Foundry Apps 表格的 ROUTE 字段。

5.3.10. 部署至 Heroku Cloud

本章节介绍如何部署 CUBA 应用程序至 Heroku® 云平台。

这个指导基于部署使用 PostgreSQL 数据库的项目做介绍。

5.3.10.1. 部署 WAR 至 Heroku
Heroku 账号

首先,使用浏览器在 Heroku 创建一个账号,免费账号类型 hobby-dev 已经足够使用。然后登录账号,并且点击界面顶部的 New 按钮创建新项目。

选择一个唯一的名称(或者这个字段空着等着自动分配)并且选择一个服务器地址。然后 Heroku 会创建一个应用,比如 morning-beach-4895

第一次使用的时候,Heroku 会切换到 Deploy 标签页,选择使用 Heroku 的 Git 部署方式。

Heroku CLI
  • 在电脑安装 Heroku CLI

  • 切换到 CUBA 项目目录。接下来这个目录会用 $PROJECT_FOLDER 来代称。

  • $PROJECT_FOLDER 目录打开命令行窗口并输入:

    heroku login
  • 按提示输入用户名密码。下面的步骤应该不需要再给这个项目输入用户名密码了。

  • 安装 Heroku CLI 插件:

    heroku plugins:install heroku-cli-deploy
PostgreSQL 数据库

使用浏览器访问 Heroku 数据 网页

可以选择已有的 Postgres 数据库或者创建一个新的。以下步骤描述怎么创建一个新数据库。

  • 找到 Heroku Postgres 区域点击 Create one

  • 然后新界面点击 Install Heroku Postgr…​

  • 下拉列表选择 Heroku 应用程序并连接数据库

  • 选择账号付费计划(比如 hobby-dev

或者也可以通过 Heroku CLI 来安装 PostgreSQL:

heroku addons:create heroku-postgresql:hobby-dev --app morning-beach-4895

这里的 morning-beach-4895 是 Heroku 应用的名称。

然后可以在 Resources 标签页看到新建的数据库。数据库已经连接 Heroku 应用程序。通过这个方式可以看到数据库的用户名密码:切换到 Heroku 数据库的 Datasource 界面,下翻到 Administration 区域然后点击 View credentials 按钮。

Host compute.amazonaws.com
Database d2tk
User nmmd
Port 5432
Password 9c05
URI postgres://nmmd:9c05@compute.amazonaws.com:5432/d2tk
项目部署设置
  • 假设 CUBA 项目使用的是 PostgreSQL 数据库。

  • 在 Studio 打开 CUBA 项目,在 CUBA 项目树选择 Deployment,打开 WAR Settings 对话框,按照下面的介绍对选项进行配置。

    • 勾选 Build WAR

    • 设置 application home 目录为 .(当前目录)

    • 勾选 Include JDBC driver

    • 勾选 Include Tomcat’s context.xml

    • 点击 Custom context.xml path 旁边的 Generate 按钮,在弹窗中填写数据库连接信息

    • 点开生成的 modules/core/web/META-INF/war-context.xml 文件,检查连接参数和用户名密码:

      <Context>
          <!-- Database connection -->
          <Resource
            name="jdbc/CubaDS"
            type="javax.sql.DataSource"
            maxTotal="20"
            maxIdle="2"
            maxWaitMillis="5000"
            driverClassName="org.postgresql.Driver"
            url="jdbc:postgresql://compute.amazonaws.com/d2tk"
            username="nmmd"
            password="9c05"/>
      
            <!-- ... -->
      </Context>
    • 勾选 Single WAR for Middleware and Web Client

    • 点击 Custom web.xml path 旁边的 Generate 按钮。

    • 拷贝下面的代码然后粘贴到 App properties 字段:

      [
        'cuba.automaticDatabaseUpdate' : true
      ]
    • 保存部署设置,等待 Gradle 刷新项目结构。

构建 WAR 文件

双击新的 Build WAR 项目树节点或者执行 buildWar Gradle 任务构建 WAR 文件:

gradlew buildWar
应用程序配置
  • https://mvnrepository.com/artifact/com.github.jsimone/webapp-runner 下载 Tomcat Webapp Runner。Webapp Runner 的版本需要跟 Tomcat 的版本匹配,比如,Webapp Runner 8.5.11.3 版本能兼容 Tomcat 8.5.11 版本。下载下来的 JAR 包重命名成 webapp-runner.jar 然后放到 $PROJECT_FOLDER 目录。

  • https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-dbcp 下载 Tomcat DBCP。版本需要能兼容 Tomcat 的版本,比如 8.5.11。创建 $PROJECT_FOLDER/libs 目录,将下载的 JAR 文件重命名为 tomcat-dbcp.jar 然后放到这个目录。

  • $PROJECT_FOLDER 目录创建新文件 Procfile。文件包含如下内容:

    web: java $JAVA_OPTS -cp webapp-runner.jar:libs/* webapp.runner.launch.Main --enable-naming --port $PORT build/distributions/war/app.war
Git 配置

$PROJECT_FOLDER 目录打开终端窗口,运行以下命令:

git init
heroku git:remote -a morning-beach-4895
git add .
git commit -am "Initial commit"
应用部署

打开终端窗口,运行以下命令:

在 *nix 系统:

heroku jar:deploy webapp-runner.jar --includes libs/tomcat-dbcp.jar:build/distributions/war/app.war --app morning-beach-4895

Windows 系统:

heroku jar:deploy webapp-runner.jar --includes libs\tomcat-dbcp.jar;build\distributions\war\app.war --app morning-beach-4895

在 Heroku 的 dashboard 打开 Resources 标签页。能看到一个新的 Dyno(Heroku 的服务实例),还有从 Procfile 读取出来的命令:

heroku dyno

应用程序现在正在部署了,可以查看日志了解部署进度。

日志监控

在命令行窗口等待弹出 https://morning-beach-4895.herokuapp.com/ deployed to Heroku 消息。

需要跟踪查看应用系统的日志,可以使用以下命令:

heroku logs --tail --app morning-beach-4895

部署成功之后,可以通过浏览器访问类似 https://morning-beach-4895.herokuapp.com 这样的 URL 来访问服务。

或者可以通过 Heroku dashboard 的 Open app 按钮打开应用。

5.3.10.2. 从 GitHub 部署到 Heroku

这个向导是给使用 GitHub 开发 CUBA 项目的开发者作为参考。

Heroku 账号

首先,使用浏览器在 Heroku 创建一个账号,免费账号类型 hobby-dev 已经足够使用。然后登录账号,并且点击界面顶部的 New 按钮创建新项目。

选择一个唯一的名称(或者这个字段空着等着自动分配)并且选择一个服务器地址。然后 Heroku 会创建一个应用,比如 space-sheep-02453,这个就是 Heroku 应用程序名称。

第一次使用的时候,Heroku 会切换到 Deploy 标签页,选择使用 GitHub 部署方式。根据界面提示完成 GitHub 账号的授权。 点击 Search 按钮列出所有的 Git 仓库(repositories),连接需要使用的仓库。当 Heroku 应用程序连接到 GitHub 之后,就可以启用 Automatic Deploys - 自动部署。自动部署可以在每次触发 Git push 事件的时候实现自动重新部署 Heroku 应用。在这个向导里面,启用了此自动部署功能。

Heroku CLI
  • 安装 Heroku CLI

  • 在电脑的任何目录打开命令行窗口,并输入:

    heroku login
  • 按提示输入用户名密码。下面的步骤应该不需要再给这个项目输入用户名密码了。

PostgreSQL 数据库
  • 返回浏览器打开 Heroku dashboard

  • 切换到 Resources 标签页

  • 点击 Find more add-ons 按钮查找数据库插件

  • 找到 Heroku Postgres 区域然后点击。根据界面介绍选择 Login to install / Install Heroku Postgres

或者也可以通过 Heroku CLI 来安装 PostgreSQL:

heroku addons:create heroku-postgresql:hobby-dev --app space-sheep-02453

这里的 space-sheep-02453 是 Heroku 应用的名称。

然后可以在 Resources 标签页看到新建的数据库。数据库已经连接 Heroku 应用程序。通过这个方式可以看到数据库的用户名密码:切换到 Heroku 数据库的 Datasource 界面,下翻到 Administration 区域然后点击 View credentials 按钮。

Host compute.amazonaws.com
Database zodt
User artd
Port 5432
Password 367f
URI postgres://artd:367f@compute.amazonaws.com:5432/zodt
项目部署设置
  • 切换到项目根目录 ($PROJECT_FOLDER)

  • 拷贝 modules/core/web/META-INF/context.xmlmodules/core/web/META-INF/heroku-context.xml

  • 修改 heroku-context.xml 文件将数据库连接信息更新进去(参考下面的例子):

    <Context>
        <Resource driverClassName="org.postgresql.Driver"
                  maxIdle="2"
                  maxTotal="20"
                  maxWaitMillis="5000"
                  name="jdbc/CubaDS"
                  password="367f"
                  type="javax.sql.DataSource"
                  url="jdbc:postgresql://compute.amazonaws.com/zodt"
                  username="artd"/>
    
        <Manager pathname=""/>
    </Context>
构建配置

将下面的 Gradle 任务添加到 $PROJECT_FOLDER/build.gradle

task stage(dependsOn: ['setupTomcat', ':app-core:deploy', ':app-web:deploy']) {
    doLast {
        // replace context.xml with heroku-context.xml
        def src = new File('modules/core/web/META-INF/heroku-context.xml')
        def dst = new File('deploy/tomcat/webapps/app-core/META-INF/context.xml')
        dst.delete()
        dst << src.text

        // change port from 8080 to heroku $PORT
        def file = new File('deploy/tomcat/conf/server.xml')
        file.text = file.text.replace('8080', '${port.http}')

        // add local.app.properties for core application
        def coreConfDir = new File('deploy/tomcat/conf/app-core/')
        coreConfDir.mkdirs()
        def coreProperties = new File(coreConfDir, 'local.app.properties')
        coreProperties.text = ''' cuba.automaticDatabaseUpdate = true '''

        // rename deploy/tomcat/webapps/app to deploy/tomcat/webapps/ROOT
        def rootFolder = new File('deploy/tomcat/webapps/ROOT')
        if (rootFolder.exists()) {
            rootFolder.deleteDir()
        }

        def webAppDir = new File('deploy/tomcat/webapps/app')
        webAppDir.renameTo( new File(rootFolder.path) )

        // add local.app.properties for web application
        def webConfDir = new File('deploy/tomcat/conf/ROOT/')
        webConfDir.mkdirs()
        def webProperties = new File(webConfDir, 'local.app.properties')
        webProperties.text = ''' cuba.webContextName = / '''
    }
}
Procfile

在 Heroku 启动应用的命令是通过一个特殊文件 Procfile 来传递的。在 $PROJECT_FOLDER 创建 Procfile 文件,使用以下内容:

web: cd ./deploy/tomcat/bin && export 'JAVA_OPTS=-Dport.http=$PORT' && ./catalina.sh run

此处提供了 Tomcat 在启动 Catalina 脚本时需要的 JAVA_OPTS 环境变量值。

Premium 插件

如果项目使用了 CUBA Premium 插件,需要为 Heroku 应用程序设置额外的变量。

  • 打开 Heroku dashboard。

  • 切换到 Settings 标签页。

  • 展开 Config Variables 区域,点击 Reveal Config Vars 按钮。

  • 添加新 Config Vars,使用授权码的前后两部分(短横分隔)分别作为 用户名密码

CUBA_PREMIUIM_USER    | username
CUBA_PREMIUM_PASSWORD | password
Gradle wrapper

项目需要使用 Gradle wrapper。可以通过 CUBA Studio 来添加:Build > Create or update Gradle wrapper

  • $PROJECT_FOLDER 创建 system.properties 文件,使用如下内容(示例是假设本地安装的 JDK 是 1.8.0_121 版本):

    java.runtime.version=1.8.0_121
  • 检查确保这些文件不在 .gitignore 里:Procfilesystem.propertiesgradlewgradlew.batgradle

  • 将这些文件添加到仓库并且提交

git add gradlew gradlew.bat gradle/* system.properties Procfile
git commit -am "Added Gradle wrapper and Procfile"
应用程序部署

一旦提交并且推送了所有的改动到 GitHub,Heroku 会自动开始重新部署应用。

git push

构建的过程可以通过 Heroku dashboard 的 Activity 标签页看到,点击 View build log 监控构建日志。

构建过程完成后,可以通过浏览器访问类似 https://space-sheep-02453.herokuapp.com 这样的 URL 来访问服务。可以通过 Heroku dashboard 的 Open app 按钮打开应用。

日志监控

需要跟踪查看应用系统的日志,可以使用以下命令:

heroku logs --tail --app space-sheep-02453

Tomcat 日志也可以通过应用系统的 Menu > Administration > Server Log 查看。

5.3.10.3. 部署容器至 Heroku

按照使用 Docker 部署章节介绍的内容配置单一 UberJAR。创建 Heroku 账号然后安装 Heroku CLI,可以参考部署 WAR 至 Heroku 章节。

用以下命令创建应用程序并且连接数据库

heroku create cuba-sales-docker --addons heroku-postgresql:hobby-dev

等这个任务完成之后需要在 jetty-env.xml 文件中配置 Heroku 创建的数据库连接的用户名和密码。

  1. 浏览器打开 https://dashboard.heroku.com

  2. 选择创建的项目,打开 Resources 标签页,选择数据库。

  3. 在新打开的窗口中,打开 Settings 标签页并且点击 View Credentials 按钮。

Db

切换到 IDE 打开 jetty-env.xml 文件。修改 URL(host 和数据库名称),用户名和密码。从网页拷贝用户名和密码到这个文件。

<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-" "http://www.eclipse.org/jetty/configure_9_0.dtd">
<Configure id='wac' class="org.eclipse.jetty.webapp.WebAppContext">
    <New id="CubaDS" class="org.eclipse.jetty.plus.jndi.Resource">
        <Arg/>
        <Arg>jdbc/CubaDS</Arg>
        <Arg>
            <New class="org.apache.commons.dbcp2.BasicDataSource">
                <Set name="driverClassName">org.postgresql.Driver</Set>
                <Set name="url">jdbc:postgresql://<Host>/<Database></Set>
                <Set name="username"><User></Set>
                <Set name="password"><Password></Set>
                <Set name="maxIdle">2</Set>
                <Set name="maxTotal">20</Set>
                <Set name="maxWaitMillis">5000</Set>
            </New>
        </Arg>
    </New>
</Configure>

执行以下 Gradle 任务创建单一 Uber JAR:

gradle buldUberJar

另外,需要对 Dockerfile 进行一些修改。首先,如果使用的是 Heroku 的免费账号,需要限制应用程序使用的内存大小;然后需要从 Heroku 获得应用程序的端口号并添加到镜像中。

修改后的 Dockerfile 示例:

### Dockerfile

FROM openjdk:8

COPY . /usr/src/cuba-sales

CMD java -Xmx512m -Dapp.home=/usr/src/cuba-sales/home -jar /usr/src/cuba-sales/app.jar -port $PORT

通过下面的命令设置 Git:

git init
heroku git:remote -a cuba-sales-docker
git add .
git commit -am "Initial commit"

登录容器仓库,是 Heroku 存储镜像的地址:

heroku container:login

接下来,构建镜像并推送到容器仓库:

heroku container:push web

这里 web 是应用程序的处理类型(process type)。当执行这个命令的时候,Heroku 默认会使用当前目录的 Dockerfile 来构建镜像,然后把镜像推送到 Heroku。

当部署流程完成后,可以通过浏览器打开类似这样的 URL https://cuba-sales-docker.herokuapp.com/app 访问应用。

或者可以通过 Heroku dashboard 的 Open app 按钮打开应用。

打开运行中应用的第三种方式是使用如下命令(链接最后需要添加 apphttps://cuba-sales-docker.herokuapp.com/app ):

heroku open

5.4. Tomcat 的代理设置

对于系统集成的情况,可能需要一个代理服务器。本章节介绍配置 Nginx HTTP-server 作为 CUBA 应用程序的代理服务。

设置代理的时候,别忘了设置 cuba.webAppUrl 的值。

Tomcat 配置

如果 Tomcat 是在一个代理服务的后面工作,也许要进行一些配置,以便 Tomcat 能正确的分发代理服务器的请求头。

首先,在 Tomcat 配置文件 conf/server.xml 中添加 Valve 属性,拷贝粘贴以下代码:

<Valve className="org.apache.catalina.valves.RemoteIpValve"
        remoteIpHeader="X-Forwarded-For"
        requestAttributesEnabled="true"
        internalProxies="127\.0\.0\.1"/>

conf/server.xml 文件中还有另一个配置需要考虑是否修改 - AccessLogValve 模式。 可以在模式中添加 %{x-forwarded-for}i,这样 Tomcat 的访问日志会记录源 IP 地址和代理服务器 IP 地址:

<Valve className="org.apache.catalina.valves.AccessLogValve"
    ...
    pattern="%h %{x-forwarded-for}i %l %u %t &quot;%r&quot; %s %b" />

然后重启 Tomcat 服务:

sudo service tomcat8 restart
NGINX

Nginx 这边有下面两个配置。示例都在 Ubuntu 18.04 上测试通过。

比如,你的应用程序在地址:http://localhost:8080/app

运行下面命令安装 Nginx:

sudo apt-get install nginx

浏览器打开 http://localhost 确保 Nginx 能工作,应该打开的是 Nginx 的欢迎页面。

现在可以删除默认 Nginx 网页的符号链接(symlink)了:

rm /etc/nginx/sites-enabled/default

下一步,按照下面两种方式的任意一种配置代理。

直接代理

直接代理的情况下,网页请求都是由代理处理,然后直接透明的转发给应用程序。

创建 Nginx 网站配置文件 /etc/nginx/sites-enabled/direct_proxy

server {
    listen 80;
    server_name localhost;

    location /app/ {
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Proto  $scheme;

        # Required to send real client IP to application server
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP          $remote_addr;

        # Optional timeouts
        proxy_read_timeout      3600;
        proxy_connect_timeout   240;
        proxy_http_version      1.1;

        # Required for WebSocket:
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_pass              http://127.0.0.1:8080/app/;
    }
}

重启 Nginx:

sudo service nginx restart

现在可以通过 http://localhost/app 访问应用程序。

转发路径

这个例子说明如何将应用程序的 URL 路径从 /app 更换成 /,就像应用程序是直接部署在根目录(类似部署在/ROOT 的效果)。这种方法允许通过 http://localhost 访问应用程序。

创建 Nginx 网站配置文件 /etc/nginx/sites-enabled/root_proxy

server {
    listen 80;
    server_name localhost;

    location / {
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Proto  $scheme;

        # Required to send real client IP to application server
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP          $remote_addr;

        # Optional timeouts
        proxy_read_timeout      3600;
        proxy_connect_timeout   240;
        proxy_http_version      1.1;

        # Required for WebSocket:
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_pass              http://127.0.0.1:8080/app/;

        # Required for folder redirect
        proxy_cookie_path       /app /;
        proxy_set_header Cookie $http_cookie;
        proxy_redirect http://localhost/app/ http://localhost/;
    }
}

然后重启 Nginx

sudo service nginx restart

现在可以通过 http://localhost 访问应用程序。

类似的部署指令对于 JettyWildFly 等等 web 服务器也有效。但是可能也需要对这些 web 服务器添加一些附加的配置。

5.5. UberJAR 的代理服务配置

本章节介绍配置 Nginx HTTP-server 作为 CUBA Uber JAR 应用程序的代理。

NGINX

对于 Nginx,下面有两种配置方法,所有示例都在 Ubuntu 16.04 测试通过。

  1. Direct Proxy - 直接代理

  2. Redirect to Path - 转发路径

假设,web 应用程序运行在 http://localhost:8080/app

Uber JAR 应用程序使用 Jetty 9.2 web 服务器。需要提前在 JAR 中配置 Jetty 用来分发 Nginx headers。

Jetty 配置
  • 使用内部的 jetty.xml

    首先,在项目根目录创建 Jetty 配置文件 jetty.xml,拷贝以下代码:

    <?xml version="1.0" encoding="utf-8"?>
    <!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
    
    <Configure id="Server" class="org.eclipse.jetty.server.Server">
    
        <New id="httpConfig" class="org.eclipse.jetty.server.HttpConfiguration">
            <Set name="outputBufferSize">32768</Set>
            <Set name="requestHeaderSize">8192</Set>
            <Set name="responseHeaderSize">8192</Set>
    
            <Call name="addCustomizer">
                <Arg>
                    <New class="org.eclipse.jetty.server.ForwardedRequestCustomizer"/>
                </Arg>
            </Call>
        </New>
    
        <Call name="addConnector">
            <Arg>
                <New class="org.eclipse.jetty.server.ServerConnector">
                    <Arg name="server">
                        <Ref refid="Server"/>
                    </Arg>
                    <Arg name="factories">
                        <Array type="org.eclipse.jetty.server.ConnectionFactory">
                            <Item>
                                <New class="org.eclipse.jetty.server.HttpConnectionFactory">
                                    <Arg name="config">
                                        <Ref refid="httpConfig"/>
                                    </Arg>
                                </New>
                            </Item>
                        </Array>
                    </Arg>
                    <Set name="port">8080</Set>
                </New>
            </Arg>
        </Call>
    </Configure>

    build.gradle 中添加 webJettyConfPath 属性到 buildUberJar 任务:

    task buildUberJar(type: CubaUberJarBuilding) {
        singleJar = true
        coreJettyEnvPath = 'modules/core/web/META-INF/jetty-env.xml'
        appProperties = ['cuba.automaticDatabaseUpdate' : true]
        webJettyConfPath = 'jetty.xml'
    }

    可以在 Studio 中通过 Deployment > UberJAR Settings 来生成 jetty-env.xml 文件,或者用以下代码:

    <?xml version="1.0"?>
    <!DOCTYPE Configure PUBLIC "-" "http://www.eclipse.org/jetty/configure_9_0.dtd">
    <Configure id='wac' class="org.eclipse.jetty.webapp.WebAppContext">
        <New id="CubaDS" class="org.eclipse.jetty.plus.jndi.Resource">
            <Arg/>
            <Arg>jdbc/CubaDS</Arg>
            <Arg>
                <New class="org.apache.commons.dbcp2.BasicDataSource">
                    <Set name="driverClassName">org.postgresql.Driver</Set>
                    <Set name="url">jdbc:postgresql://<Host>/<Database></Set>
                    <Set name="username"><User></Set>
                    <Set name="password"><Password></Set>
                    <Set name="maxIdle">2</Set>
                    <Set name="maxTotal">20</Set>
                    <Set name="maxWaitMillis">5000</Set>
                </New>
            </Arg>
        </New>
    </Configure>

    使用以下命令构建 Uber JAR:

    gradlew buildUberJar

    应用程序 JAR 包会被放置在 build/distributions/uberJar 目录,名称为 app.jar

    运行应用程序:

    java -jar app.jar

    按照 Tomcat 部分的介绍安装和配置 Nginx。

    按照选择配置 Nginx 的方法不同,可以通过 http://localhost/app 或者 http://localhost 地址访问应用。

  • 使用外部的 jetty.xml

    按照上面的描述使用和项目根目录的 jetty.xml 文件相同的配置文件。将这个文件放在其它目录,比如说用户主目录,不需要修改 build.gradle 里的 buildUberJar 任务。

    使用下列命令构建 Uber JAR:

    gradlew buildUberJar

    应用程序打包完了放在 build/distributions/uberJar 目录,默认名称是 app.jar

    首先,用带参数 -jettyConfPath 运行程序:

    java -jar app.jar -jettyConfPath jetty.xml

    然后按照 Tomcat 部分的介绍安装和配置 Nginx。

    按照选择配置 Nginx 的方法和 jetty.xml 文件的配置不同,可以通过 http://localhost/app 或者 http://localhost 地址访问应用。

5.6. 应用程序扩展

本章节介绍在负载增加或者有更强的容错需求的时候怎样对包含 MiddlewareWeb Client blocks 的 CUBA 应用程序进行扩展。

扩展级别 1. 两个 blocks 部署在同一个应用服务内

这是使用标准快速部署流程的最简单情况。

在这种情况下,Web ClientMiddleware 之间的数据传输性能可以达到最大化,因为当启用 cuba.useLocalServiceInvocation 应用程序属性时,可以跳过网络堆栈直接调用 Middleware 服务。

scaling_1

扩展级别 2. Middleware 和 web 客户端部署在不同的应用程序服务内

这个选择可以在两个应用服务器之间分散负载,从而更好的使用两个服务器的资源。还有,用这种部署方式从 web 用户来的负载会对其它进程的执行影响小一些。这里的其它进程是指处理其它客户端类型、运行计划任务还有潜在可能的一些从中间层(middle layer)来的集成任务(integration tasks)。

对服务器资源的要求:

  • Tomcat 1 (Web 客户端):

    • 内存大小 – 按比例分配给同时在线的用户

    • CPU – 按照使用的强度

  • Tomcat 2 (Middleware):

    • 内存大小 – 固定大小,而且相对来说不大

    • CPU – 取决于 web 客户端和其它进程的使用强度

在这种部署选择或者更复杂的部署情况下,web 客户端的 cuba.useLocalServiceInvocation 应用程序属性应该设置成 falsecuba.connectionUrlList 属性需要包含 Middleware block 的 URL。

scaling_2

扩展级别 3. Web 客户端集群搭配单一 Middleware 服务

这种部署选择是用在,由于并发用户数太大导致 Web 客户端的内存需求超过了单一 JVM 的承载能力的场景。集群中多个 Web 客户端启动后,用户连接通过负载均衡来处理。所有的 Web 客户端都连接同一个 Middleware 服务器。

多个 Web 客户端自动提供了一定级别的容错。但是,不支持在客户端之间复制 HTTP 会话,如果有一个 web 客户端服务器计划外宕机了,所有连到这个服务器的用户需要重新登录应用程序。

负载均衡器需要保持 sticky sessions,以保证在会话期间从用户发出的所有请求转发至同一个 Web 客户端节点。

这个部署选择的配置在 配置 Web 客户端集群 介绍。

scaling_3

扩展级别 4. Web 客户端集群搭配 Middleware 集群

这个方案是最强大的部署选择,能提供全面的 Web 客户端和 Middleware 容错性和负载均衡。

连接到 Web 客户端的用户通过负载均衡(需要保持 sticky sessions)接入。多个 Web 客户端服务器跟 Middleware 集群协作提供服务。Middleware 服务不需要额外的负载均衡 - 通过 cuba.connectionUrlList 应用程序属性已经足够定义 Middleware 服务器 URL 列表。另外还可以通过Apache ZooKeeper 集成插件做 Middleware 服务的动态发现。

多个 Middleware 服务会交换用户会话、锁等信息。这样的话,Middleware 可以提供全容错 - 其中一台服务宕机之后,另一台可用的 Middleware 服务会接过用户的请求继续处理,从而不会影响终端用户的感受。

这个部署选择的配置在 配置 Middleware 集群 介绍。

scaling_4

5.6.1. 配置 Web 客户端集群

本章节介绍如下部署配置:

cluster webclient

host1host2 服务器上部署了实现 Web 客户端 block 的 app Tomcat 实例。用户通过 http://host0/app 地址访问负载均衡,负载均衡会转发用户请求到不同的服务。host3 服务器部署了实现 Middleware block 的 app-core Tomcat 实例。

用户的 通用 UI 状态和 UserSession 都位于用户登录的那个 Web Client 节点中。因此,负载均衡器需要保持 sticky sessions(也称为 session affinity),以保证在会话期间用户的所有请求都发送至同一个 Web Client 节点。

5.6.1.1. 安装和配置负载均衡

这里介绍在 Ubuntu 14.04 上安装 Apache HTTP Server 作为负载均衡。

  1. 安装 Apache HTTP Servermod_jk 模块:

    $ sudo apt-get install apache2 libapache2-mod-jk

  2. 用以下内容替换 /etc/libapache2-mod-jk/workers.properties 文件内容:

    workers.tomcat_home=
    workers.java_home=
    ps=/
    
    worker.list=tomcat1,tomcat2,loadbalancer,jkstatus
    
    worker.tomcat1.port=8009
    worker.tomcat1.host=host1
    worker.tomcat1.type=ajp13
    worker.tomcat1.connection_pool_timeout=600
    worker.tomcat1.lbfactor=1
    
    worker.tomcat2.port=8009
    worker.tomcat2.host=host2
    worker.tomcat2.type=ajp13
    worker.tomcat2.connection_pool_timeout=600
    worker.tomcat2.lbfactor=1
    
    worker.loadbalancer.type=lb
    worker.loadbalancer.balance_workers=tomcat1,tomcat2
    
    worker.jkstatus.type=status
  3. 添加下面的这些内容到 /etc/apache2/sites-available/000-default.conf

    <VirtualHost *:80>
    ...
        <Location /jkmanager>
            JkMount jkstatus
            Order deny,allow
            Allow from all
        </Location>
    
        JkMount /jkmanager/* jkstatus
        JkMount /app loadbalancer
        JkMount /app/* loadbalancer
    
    </VirtualHost>
  4. 重启 Apache HTTP 服务:

    $ sudo service apache2 restart

5.6.1.2. 设置多个 Web 客户端服务器

在下面的示例中,配置文件的路径都是按照使用快速部署的情况提供的。

在 Tomcat 1 和 Tomcat 2 服务器,做以下配置:

  1. tomcat/conf/server.xml 文件中,添加 jvmRoute 参数,其值为在负载均衡配置中为 tomcat1tomcat2 设置的 worker 的名称:

    <Server port="8005" shutdown="SHUTDOWN">
      ...
      <Service name="Catalina">
        ...
        <Engine name="Catalina" defaultHost="localhost" jvmRoute="tomcat1">
          ...
        </Engine>
      </Service>
    </Server>
  2. app_home/local.app.properties 中设置下列应用程序属性:

    cuba.useLocalServiceInvocation = false
    cuba.connectionUrlList = http://host3:8080/app-core
    
    cuba.webHostName = host1
    cuba.webPort = 8080

    cuba.webHostNamecuba.webPort 参数对于 Web 客户端集群来说不是必须的,但是这些参数为在平台的其它功能中辨识服务器提供了方便,比如 JMX 控制台。另外 User Sessions 界面的 Client Info 属性会显示用户目前使用的 Web 客户端的标识符。

5.6.2. 配置 Middleware 集群

本章节介绍如下部署配置:

cluster mw

host1host2 服务器上部署了实现 Web 客户端 block 的 app Tomcat 实例。这些服务的集群配置在前一章节介绍过了。host3host4 服务器部署了实现 Middleware block 的 app-core Tomcat 实例。这两个服务器配置了交互和共享用户会话、锁、缓存清空等信息。

在下面的示例中,配置文件的路径都是按照使用快速部署的情况提供的。

5.6.2.1. 配置连接 Middleware 集群

为了使客户端 blocks 能跟多个 Middleware 服务器工作,服务器 URL 列表需要通过 cuba.connectionUrlList 应用程序属性来配置。这个配置可以写在 app_home/local.app.properties

cuba.useLocalServiceInvocation = false
cuba.connectionUrlList = http://host3:8080/app-core,http://host4:8080/app-core

cuba.webHostName = host1
cuba.webPort = 8080

第一次用户会话远程连接的中间件服务器是随机选择的,而且对于整个会话的生命周期这个服务器都是固定的("sticky session")。从匿名用户会话来的请求或者不带用户会话的请求不固定在特定的服务器,也是随机选择服务器执行。

选择中间件服务器的算法是由 cuba_ServerSorter bean 提供的,这个 bean 默认是由 RandomServerSorter 类实现。也可以在项目中提供自定义的实现。

5.6.2.2. 配置多个 Middleware 服务交互

多个 Middleware 服务器可以共同维护用户会话共享列表和其它类对象,还能协调处理过期的缓存。每个中间件服务都需要开启 cuba.cluster.enabled 参数启用这个功能。以下是 app_home/local.app.properties 文件的示例:

cuba.cluster.enabled = true

cuba.webHostName = host3
cuba.webPort = 8080

对于中间件服务来说,需要设定正确的 cuba.webHostNamecuba.webPort 这两个属性的值,这样能用这两个属性组成唯一的服务器 ID

服务之间的交互机制是基于 JGroups。平台为 JGroups 提供了两个配置文件:

  • jgroups.xml - 基于 UDP 的协议栈,适用于启用了广播通信的本地网络。这个配置当集群功能开启的时候会被默认使用。

  • jgroups_tcp.xml - 基于 TCP 的协议栈,适用于任何网络。使用这个协议要求在 TCP.bind_addrTCPPING.initial_hosts 参数中显式设定集群成员的地址。如果需要使用这个配置,需要设定 cuba.cluster.jgroupsConfig 这个应用程序属性。

    如果您的 middleware 服务器之间有防火墙,别忘了根据您的 JGroups 配置开启防火墙端口。

为了配置环境中的 JGroups 参数,从 cuba-core-<version>.jar 的根目录拷贝合适的 jgroups.xml 文件到项目的 core 模块根目录(src 目录)或者部署环境的 tomcat/conf/app-core 目录 ,并且修改这个文件。

ClusterManagerAPI bean 提供 Middleware 集群中服务器交互的编程接口。可以在应用程序中使用,需要时可参考 JavaDocs 和平台代码的用法。

用户会话的同步复制

默认情况下,所有发送至集群的消息都是 异步 的。也就是说在其他集群成员收到集群消息之前,中间件代码已经将响应返回给了客户端层。

这样的行为能提高系统的响应时间,但是在负载均衡使用轮询的方式(比如 NGINX 或 Kubernetes)在客户端层和中间件之间路由请求时,会产生问题。那就是,web 客户端的登录请求会在用户会话会话完全复制到其他集群成员之前返回。因此,接下来从 web 客户端发送的请求会被轮询机制重定向至其他的中间件节点,而此时由于其他节点还未收到用户会话,会导致请求失败并产生 NoUserSessionException 异常。

所以,如果集群使用了轮询负载均衡,为避免 NoUserSessionException 的发生,用户会话采取 同步 复制机制。可以在 app.properties 文件(或者目标服务的 app_home/local.app.properties 文件)中设置一下属性:

cuba.syncNewUserSessionReplication = true

# 如果使用 REST API 扩展插件
cuba.rest.syncTokenReplication = true
5.6.2.3. 使用 ZooKeeper 来协调集群

为了能让中间件服务之间互相通信,并且帮助客户端请求中间件服务,有个应用程序组件可以启用动态发现中间件服务。这个组件是基于集成 Apache ZooKeeper 完成的,ZooKeeper 是个中心化的服务,用来维护配置信息。当项目引入这个组件之后,运行应用程序 block 的时候只需要指定一个 ZooKeeper 的静态地址。Middleware 服务将会通过在 ZooKeeper 目录发布它们的地址的方式进行广播,然后发现机制会向 ZooKeeper 请求能用的服务器的地址。如果一个中间件服务宕机了,这个服务会被马上从目录自动移除或者等到超时再被移除。

这个应用程序组件的源代码可以在 GitHub 找到,构建的工件在标准 CUBA 仓库发布。参考 README 了解引入和配置这个组件的信息。

5.6.3. 服务器 ID

Server ID 用来在 Middleware 集群中提供服务器的可靠标识。标识符的格式是 host:port/context

tezis.haulmont.com:80/app-core
192.168.44.55:8080/app-core

标识符是使用配置参数 cuba.webHostNamecuba.webPortcuba.webContextName 来组合的,所以对于在集群中的 Middleware blocks 来说,设定这几个参数非常重要。

Server ID 可以通过 ServerInfoAPI bean 来获取,或者通过 ServerInfoMBean 这个 JMX 接口获取。

5.7. 使用 JMX 工具

本章节介绍在基于 CUBA 的应用程序中使用 Java Management Extensions 的各方面内容。

5.7.1. 内置 JMX 控制台

cuba 应用程序组件的 Web 客户端模块包含 JMX 对象查看和编辑工具。工具的入口是注册在 jmxConsole 标识符下的 com/haulmont/cuba/web/app/ui/jmxcontrol/browse/display-mbeans.xml 界面,可以通过标准应用程序菜单的 Administration > JMX Console 访问。

不需要额外的配置,这个控制台能显示当前用户正在运行的 web 客户端 JVM 内注册的所有 JMX 对象。因此,在最简单的情况下,当所有的应用程序 block 都部署到一个 web 容器实例的时候,JMX 控制台可以访问所有层(tier)的 JMX beans 甚至包括 JVM 和 web 容器的 JMX 对象。

应用程序 beans 的名称都带有一个拥有这些 bean 的 web-app 名称的前缀。比如,app-core.cuba:type=CachingFacade bean 会被 app-core web-app 加载,该 web-app 实现了中间件 block;而 app.cuba:type=CachingFacade bean 会被 app web-app 加载,该 web-app 实现了 Web 客户端 block。

jmx console
Figure 49. JMX 控制台

JMX 控制台也可以访问远程 JVM 的 JMX 对象。这个功能在应用程序 blocks 部署在几台不同的 web 容器是很有用,比如,分开部署的 web 客户端和中间件。

需要连接远程 JVM,可以通过控制台的 JMX Connection 字段选择一个之前建立的连接或者创建一个新连接:

jmx connection edit
Figure 50. 编辑 JMX 连接

要建立连接,需要提供 JMX 主机地址,端口,登录名和密码。还有个 Node name - 节点名称 字段,如果在指定的地址监测到 CUBA 应用服务的 block 的话,会自动填充。这种情况下,这个字段的值会被定义成此 block 的cuba.webHostNamecuba.webPort 的组合,这样也利于辨认包含这个服务的服务器。如果连接是通过第三方 JMX 接口建立的,那么 Node name 字段会有"Unknown JMX interface"值。但是也可以手动修改它。

为了提供远程 JVM 连接,JVM 需要做适当的配置(见下面)。

5.7.2. 设置远程 JMX 连接

本章节介绍需要进行远程 JMX 工具连接的 Tomcat 启动配置。

5.7.2.1. Windows 下 Tomcat JMX 配置
  • 按照下面方法编辑 bin/setenv.bat

    set CATALINA_OPTS=%CATALINA_OPTS% ^
    -Dcom.sun.management.jmxremote ^
    -Djava.rmi.server.hostname=192.168.10.10 ^
    -Dcom.sun.management.jmxremote.ssl=false ^
    -Dcom.sun.management.jmxremote.port=7777 ^
    -Dcom.sun.management.jmxremote.authenticate=true ^
    -Dcom.sun.management.jmxremote.password.file=../conf/jmxremote.password ^
    -Dcom.sun.management.jmxremote.access.file=../conf/jmxremote.access

    这里,java.rmi.server.hostname 参数需要包含服务运行的机器的实际 IP 地址或者 DNS 名称;com.sun.management.jmxremote.port 用来设置 JMX 工具连接的端口号。

  • 编辑 conf/jmxremote.access 文件,需要包含连接 JMX 的用户名以及他们的访问级别,示例:

    admin readwrite
  • 编辑 conf/jmxremote.password 文件,需要包含 JMX 用户的密码,示例:

    admin admin
  • 对于运行 Tomcat 服务的用户,他们应当只有密码文件的只读权限。可以通过以下方式配置权限:

    • 打开命令行窗口,切换到 conf 目录

    • 执行命令:

      cacls jmxremote.password /P "domain_name\user_name":R

      这里 domain_name\user_name 是用户所在的域和用户名称。

    • 这个命令执行之后,这个文件在 Explorer 会显示锁住状态(有个锁的图标)。

  • 如果 Tomcat 是按照 Windows 服务的方式安装的,那么服务需要以具有能访问 jmxremote.password 权限的用户身份启动。需要注意的是,这种情况下会忽略 bin/setenv.bat 文件,相应的 JVM 启动参数应该通过配置 Tomcat 服务的应用程序来设置。

5.7.2.2. Linux 下 Tomcat JMX 配置
  • 按照下面方法编辑 bin/setenv.sh

    CATALINA_OPTS="$CATALINA_OPTS -Dcom.sun.management.jmxremote \
    -Djava.rmi.server.hostname=192.168.10.10 \
    -Dcom.sun.management.jmxremote.port=7777 \
    -Dcom.sun.management.jmxremote.ssl=false \
    -Dcom.sun.management.jmxremote.authenticate=true"
    
    CATALINA_OPTS="$CATALINA_OPTS -Dcom.sun.management.jmxremote.password.file=../conf/jmxremote.password -Dcom.sun.management.jmxremote.access.file=../conf/jmxremote.access"

    这里,java.rmi.server.hostname 参数需要包含服务运行的机器的实际 IP 地址或者 DNS 名称;com.sun.management.jmxremote.port 用来设置 JMX 工具连接的端口号。

  • 编辑 conf/jmxremote.access 文件,需要包含连接 JMX 的用户名以及他们的访问级别,示例:

    admin readwrite
  • 编辑 conf/jmxremote.password 文件,需要包含 JMX 用户的密码,示例:

    admin admin
  • 对于运行 Tomcat 服务的用户,他们应当只有密码文件的只读权限。可以通过以下方式配置权限:

    • 打开命令行窗口,切换到 conf 目录

    • 执行命令:

      chmod go-rwx jmxremote.password

5.8. 服务推送设置

CUBA 应用程序的后台任务机制采用服务推送技术。可能需要对应用程序或者代理服务做一些额外的配置。

默认情况下,服务推送使用的 WebSocket 协议。下面这些应用程序属性影响平台的服务推送功能:

下面这些信息是从 Vaadin 网页上摘录的 - 为你的环境配置推送

Chrome 错误消息 ERR_INCOMPLETE_CHUNKED_ENCODING

这个完全正常,表示长轮询(long-polling)推送(push)连接由于第三方软件的原因断掉了。典型的场景就是当浏览器和服务器之间有个代理,如果这个代理配置了时限规则,一旦超时就断掉连接。浏览器应当在这个事件发生之后重新建立跟服务器的连接。

Tomcat 8 + Websockets 错误消息
java.lang.ClassNotFoundException: org.eclipse.jetty.websocket.WebSocketFactory$Acceptor

这个错误暗示在 classpath 里面可能配置有 Jetty。这样的话运行环境就可能被误导,会尝试使用 Jetty 的 WebSocket 而不是使用 Tomcat 的。一个常见的原因是因为不小心部署了 vaadin-client-compiler,这里面有个 Jetty 依赖(比如 SuperDevMode 需要 Jetty)。

Glassfish 4 + Streaming

Glassfish 4 要求 comet 选项启动,这样才能使用 streaming。

设置

(Configurations → server-config → Network Config → Protocols → http-listener-1 → HTTP → Comet Support)

或者使用

asadmin set server-config.network-config.protocols.protocol.http-listener-1.http.comet-support-enabled="true"
Glassfish 4 + Websockets

如果使用的 Glassfish 4.0,升级到 Glassfish 4.1 就没问题了。

Weblogic 12 + Websockets

使用 WebLogic 12.1.3 或者更高的版本。WebLogic 12 默认指定了 WebSocket 超时的时间是 30 秒。为了避免定期重连,可以设置 WebLogic 的初始参数 weblogic.websocket.tyrus.session-max-idle-timeout-1(无时限)或者一个比较大的值(单位是毫秒)。

JBoss EAP 6.4 + Websockets

JBoss EAP 6.4 支持 websockets,但是默认这个功能是禁用的。要启用 WebSocket,需要更改 JBoss 使用 NIO 连接器:

$ bin/jboss-cli.sh --connect

然后运行下面这个命令:

batch
/subsystem=web/connector=http/:write-attribute(name=protocol,value=org.apache.coyote.http11.Http11NioProtocol)
run-batch
:reload

然后把下面这些内容添加到 WEB-INF/jboss-web.xml 文件,这个文件加到 war 包里面启用 WebSockets:

<jboss-web version="7.2" xmlns="http://www.jboss.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.jboss.com/xml/ns/javaee schema/jboss-web_7_2.xsd">
    <enable-websockets>true</enable-websockets>
</jboss-web>
重复资源错误

如果服务的日志包含

Duplicate resource xyz-abc-def-ghi-jkl. Could be caused by a dead connection not detected by your server. Replacing the old one with the fresh one

这意味着,首先,浏览器连接到了服务端,并且使用了提供的推送连接的标识符。一切工作正常。然后,浏览器(很可能跟之前的是同一个)使用了相同的标识符再次连接,但是从服务端来看,之前的浏览器连接还是有效的。于是服务端就把之前的连接断掉然后在日志打印了这个警告。

这个情况发生一般来说主要是在浏览器跟服务端之间有个代理,代理配置了在一定的无活动超时之后就断掉打开的连接(在服务端执行 push 命令之前不会有数据发送)。依据 TCP/IP 的工作原理,服务端根本不知道连接已经断了,然后认为旧连接还能用。

有两种选择避免这个问题:

  1. 如果能控制中间的代理,配置代理不要限时或者不要断掉推送连接(连接以 /PUSH 结尾)

  2. 如果知道代理的时限是多少,配置应用程序的推送连接时限稍微小于代理的这个时限值,从而使服务端能在代理断掉连接之前先主动结束空闲连接并且知晓这个状态。

    1. 设置 cuba.web.pushLongPolling 参数为 true 来启用长轮询传输替代 WebSocket。

    2. 设置 cuba.web.pushLongPollingSuspendTimeoutMs 参数来控制 push 连接的时限,单位毫秒。

即便没有配置代理,服务端也就能知道连接断掉的状态,但是还是有一些可能导致丢失推送的数据。如果碰巧服务端在连接刚刚好断掉之后推送了数据,服务端不会意识到这些数据推送到了一个关闭的连接中(根据 socket 的工作原理,特别是 Java 中 socket 的工作原理)。所以通过禁用连接时限或者设置服务端连接时限小于浏览器端也能解决这个潜在问题。

使用代理

如果用户使用了一个不支持 WebSocket 的代理连接应用程序服务,设置 cuba.web.pushLongPollingtrue 并且增加代理请求的超时时限至 10 分钟或者更多。

如下是一个 Nginx 使用 WebSocket 的 web 服务的设置:

location / {
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout     3600;
    proxy_connect_timeout  240;
    proxy_set_header Host $host;
    proxy_set_header X-RealIP $remote_addr;

    proxy_pass http://127.0.0.1:8080/;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

5.9. 应用程序健康检查 URL

每个作为 web 应用程序部署的应用程序模块都提供了健康检查的 URL。对这个 URL 进行 HTTP GET 操作,如果返回是 ok 的话,表明这个模块可以开始运行了。

不同 block 的 URL 路径列表如下:

  • Middleware: /remoting/health

  • Web Client: /dispatch/health

  • Web Portal: /rest/health (需要 REST API 扩展

所以对于名称叫 app 并且部署在 localhost:8080 的应用程序,这些 URL 将会是:

  • http://localhost:8080/app-core/remoting/health

  • http://localhost:8080/app/dispatch/health

  • http://localhost:8080/app-portal/rest/health

可以使用 cuba.healthCheckResponse 应用程序属性将返回的 ok 替换成任意字符串。

监控检查的控制器也会发送类型为 HealthCheckEvent事件。因此可以添加自定义的检查应用健康的逻辑。 GitHub 的这个例子 演示了 web 层的一个 bean 监听健康检查的事件,并且调用中间件服务,最后在数据库做了一次操作。

5.10. 在生产环境中创建和更新数据库

本节介绍在应用程序部署和运行期间创建和更新数据库的几种方法。要了解有关数据库脚本结构的更多信息,请参阅创建和更新数据库的脚本

5.10.1. 在服务器上执行数据库脚本

服务器执行数据脚本机制可用于初始化数据库及后续对应用程序开发期间发生的数据库架构调整进行更新。

按照以下操作完成对新数据库的初始化:

  • 通过将下面这行内容添加到应用程序主目录local.app.properties 文件(如果没有在项目的 app.properties 中添加的话),启用cuba.automaticDatabaseUpdate应用程序属性:

    cuba.automaticDatabaseUpdate = true
  • 按照定义数据源创建一个空数据库。

  • 启动包含中间件 block 的应用程序服务。在应用程序启动时,数据库将被初始化并准备就绪。

之后,每次应用程序服务启动时,脚本执行机制都会将位于数据库脚本目录中的脚本与在数据库中注册的已执行脚本列表进行比较。如果找到新脚本,新脚本将被执行并注册。典型情况下,在每个新的应用程序版本中包含更新脚本就足够了,数据库会在每次应用程序重新启动的时候进行更新。

在服务启动时使用数据库脚本执行机制时,应考虑以下事项:

  • 如果在运行脚本时发生任何错误,则中间件 block 将停止初始化并变得不可用。客户端 block 会生成关于无法连接到中间件的错误提示消息。

    检查位于服务器日志文件夹中的 app.log 文件,从 com.haulmont.cuba.core.sys.DbUpdaterEngine 日志中获取关于 SQL 执行的消息,可能会有其它可以用来识别错误原因的错误消息。

  • 更新脚本和脚本中用 "^" 分隔的 DDL 和 SQL 命令一样在单独的事务中执行。这就是为什么当更新失败时,仍然很有可能一部分脚本甚至最后一个脚本的个别命令已被执行并提交给数据库。

    考虑到这一点,强烈建议在启动服务之前创建数据库的备份。然后,更新脚本的错误得到修复时,可以恢复数据库并重新进行数据库更新。

    如果没有进行备份,则应在错误修复后确定脚本的哪些部分已被执行并已提交。如果整个脚本执行失败,则简单地重启服务并运行自动更新即可。如果错误出现之前的一些用 "^" 字符分隔的命令已在单独的事务中执行并且提交,这种情况下只需运行脚本中剩余未执行的命令,同时手动在 SYS_DB_CHANGELOG 中注册这个手动执行的脚本。之后,可以启动服务,自动更新机制将继续处理下一个未执行的脚本。

    CUBA Studio 为所有数据库类型生成带有 ";" 分隔符的更新脚本,除了 Oracle。如果更新脚本命令由分号分隔,则脚本在一个事务中执行,并在发生错误时完全回滚。此行为可确保数据库架构与已执行的更新脚本列表之间的一致性。

5.10.2. 从命令行初始化和更新数据库

可以使用平台的中间层 block 中包含的 com.haulmont.cuba.core.sys.utils.DbUpdaterUtil 类通过命令行运行数据库创建和更新脚本。在启动时,应指定以下参数:

  • dbType数据库类型,可选值: postgres、mssql、oracle、mysql。

  • dbVersionDBMS 版本 (可选参数)。

  • dbDriver - JDBC 驱动程序类名(可选参数)。如果没有提供,将根据 dbType 确定合适的驱动程序类名。

  • dbUser – 数据库用户名。

  • dbPassword – 数据库用户密码

  • dbUrl – 连接数据库的 URL。对数据库的初始化来说,这里指定的数据库应该是空库,初始化程序不会自动清空数据库。

  • scriptsDir – 以标准目录结构包含脚本的文件夹的绝对路径。通常,这是应用程序提供的数据库脚本目录

  • 可用的命令:

    • create – 初始化数据库。

    • check – 显示所有未执行的更新脚本。

    • update – 更新数据库。

运行 DbUpdaterUtil 的 Linux 脚本示例:

#!/bin/sh

DB_URL="jdbc:postgresql://localhost/mydb"

APP_CORE_DIR="./../webapps/app-core"
WEBLIB="$APP_CORE_DIR/WEB-INF/lib"
SCRIPTS="$APP_CORE_DIR/WEB-INF/db"
TOMCAT="./../lib"
SHARED="./../shared/lib"

CLASSPATH=""
for jar in `ls "$TOMCAT/"`
do
  CLASSPATH="$TOMCAT/$jar:$CLASSPATH"
done

for jar in `ls "$WEBLIB/"`
do
  CLASSPATH="$WEBLIB/$jar:$CLASSPATH"
done

for jar in `ls "$SHARED/"`
do
  CLASSPATH="$SHARED/$jar:$CLASSPATH"
done

java -cp $CLASSPATH com.haulmont.cuba.core.sys.utils.DbUpdaterUtil \
 -dbType postgres -dbUrl $DB_URL \
 -dbUser $1 -dbPassword $2 \
 -scriptsDir $SCRIPTS \
 -$3

此脚本用于与本地 PostgreSQL 服务器上运行的名为 mydb 的数据库一起使用。此脚本应位于 Tomcat 服务的 bin 文件夹中,并且应该以 {username}{password}{command} 参数启动,例如:

./dbupdate.sh cuba cuba123 update

脚本执行进度显示在控制台中。如果发生任何错误,则上一章节中针对自动更新机制所描述的操作在这里同样适用。

从命令行更新数据库时,会启动现有的 Groovy 脚本,但只会执行其主要部分。由于缺少服务的上下文,脚本的 PostUpdate 部分会被忽略同时输出相应信息到控制台。

6. 安全子系统

CUBA 框架带有成熟的安全子系统,能解决企业级应用中常见的问题:

  • 使用自带的 users 存储,LDAP单点登录 或者 社交网站登录 进行用户验证。

  • 在数据模型(实体操作和属性)、UI 界面和任何能表达权限许可上(比如,张三可以查看文档,但是不能创建、更新或者删除任何文档,他也可以查看文档的所有属性,但是除了文档的 amount 属性),CUBA 提供了 基于角色 的访问控制。

  • 数据库行级别的访问控制 - 针对某个实体实例的权限控制。比如,张三只能查看他所在部门创建的文档。

6.1. WEB 安全

CUBA 开发的应用程序安全吗?

CUBA 框架作为开发框架遵循了良好的安全实践,为 web 应用程序中最通常最易受攻击的部分做了自动防护。框架的架构提供了安全编程模型,可以使得开发者专注于业务和应用程序逻辑。

1. 用户界面(UI)状态和验证

Web 客户端是个服务器端的应用,应用中所有的状态、业务和 UI 逻辑都在服务端处理。不像其它客户端驱动的框架,CUBA 的 Web 客户端永远不会把内部逻辑暴露给浏览器,而浏览器是攻击者最容易攻击的地方。数据的验证也是在服务端处理,因此客户端方面的攻击不能绕过这些验证,在 REST API 也有同样的验证机制。

2. Cross-Site Scripting (XSS) - 跨站脚本攻击

Web 客户端集成了对于跨站脚本攻击的防护措施。它会在数据在用户浏览器做渲染之前,先把数据转成 HTML 实体。

3. Cross-Site Request Forgery (CSRF) 跨站请求伪造

所有在客户端和服务端之间的请求都包含了用户会话特定的 CSRF token。所有服务端跟客户端的通信都由 Vaadin 框架 来处理,所以不需要手动处理去包含 CSRF token。

4. Web 服务

所有 Web 客户端的通信都通过一个支持 RPC 请求的 web 服务来做。项目中不会开放包含业务逻辑代码的 web 服务,因此减少了应用中的攻击入口点。

通用 REST API 接口也为登录用户或者匿名用户自动提供了角色、权限和其它的安全限制。

5. SQL 注入

平台使用基于 EclipseLink 的 ORM 层,EclipseLink 已经做了针对 SQL 注入的防护。SQL 查询的参数是通过参数数组的形式传递给 JDBC 的,而不是跟查询语句做字符串拼接。

6.2. 安全组件

CUBA 安全子系统主要组件展示在下图。

Security
Figure 51. 安全子系统组件图

下面是这些组件的概览:

Security management screens - 安全管理界面 – 系统管理员可用的一组界面,用来配置用户访问权限。

Login screen - 登录界面 − 系统登录窗口。通过用户名和密码提供用户认证。数据库保存密码哈希值以保证安全。

登录时会创建 UserSession 对象。这是当前认证用户主要的安全元素,包含了用户数据访问权限的信息。

用户登录过程的描述,请参考 登录 章节。

Roles - 角色 − 用户角色。角色是一个对象,定义了一组 权限。一个用户可以有多个角色。

Access Groups - 访问组 − 用户访问组。拥有层级关系的结构,每个组定义一组约束,允许用来控制对单个实体实例的访问(数据表的行级别)。

6.2.1. 登录界面

登录界面提供使用登录名和密码登录的功能。登录名大小写敏感。

Web Client 中的 Remember Me - 记住我 复选框可以通过应用程序属性cuba.web.rememberMeEnabled 设置。标准登录页中可选语言的下拉列表可以通过应用程序属性 cuba.localeSelectVisiblecuba.availableLocales 设置。

在 Web Client,可以在 Studio 中自定义或完全替换标准登录窗口。在项目树选择 Generic UI 并点击右键菜单的 New > Screen。然后在 Screen Templates 标签页选择 Login screenLogin screen with branding image 模板。会在 Web 模块创建新的界面描述和控制器文件。新的登录界面标识符会自动设置为 cuba.web.loginScreenId 应用程序属性的值。 参阅 根界面

平台提供防暴力破解密码机制:参阅应用程序属性 cuba.bruteForceProtection.enabled

需要更深层次的自定义鉴权流程,参阅 登录Web 登录 章节。

Login Screen 的展示可以使用带 $cuba-login-* 前缀的 SCSS 变量进行自定义。可以在创建一个 主题扩展 或者一个 自定义主题 之后在可视化编辑器里修改这些变量的值。

6.2.2. 用户

译者注 — 这里很多英文是界面上内容的描述,所以在翻译时,界面元素保留了很多英文以及对应的中文翻译。

每个系统用户都会有对应的 sec$User 实例,包含唯一登录名,密码 hash 值,访问组引用和角色列表,还有其它属性。用户管理功能在 Administration > Users screen 界面:

security user browser

除了标准的 create - 创建,update - 更新,和 delete - 删除 动作,还支持以下动作:

  • Copy – 复制,基于当前所选用户快速创建新用户。新用户与所选用户拥有相同的访问组和角色集合。在用户编辑界面可以进一步修改它们。

  • Copy settings – 复制设置。复制用户界面设置到其它一或多个用户。这些设置包括表格展示分隔面板分隔符位置,过滤器搜索文件夹

  • Change password – 为所选用户修改密码。

  • Reset passwords – 为所选用户做以下操作:

    • Reset passwords for selected users 重置密码对话框,如果没有选择 Generate new passwords - 生成新密码标记,会给所选用户设置上 Change password at next logon 下次登录修改密码的标记。有这类标记的用户下次登录成功后,会被要求修改密码。

      用户想要修改密码的话,需要有 sec$User.changePassword 界面的权限。在配置角色的时候需要注意这一点,尤其是给用户分配 Denying- 拒绝 权限的时候。角色编辑界面中,sec$User.changePassword 界面的设置在 Other screens 其它界面节点内。

    • 如果选择了 Generate new passwords flag 生成新密码标记,系统会随机生成密码显示给系统管理员。这些密码可以导出到 XLS 文件然后发送给相关的用户。并且,Change password at next logon 下次登录修改密码的标记也会被设置,保证用户下次登录时修改密码。

    • 如果设置了 Generate new passwords 的同时也设置了 Send emails with generated passwords 将密码发送到邮件标记,自动生成的一次性密码会被直接发送给对应的用户,系统管理员不会看到。邮件发送是异步的,所以需要一个特定的计划任务,参考 发送方法 章节。

      密码重置邮件的模板可以修改。可以在 core 模块创建本地化模板, reset-password-subject.gspreset-password-body.gsp 可以做为例子。需要本地化模板时,创建 locale(语种)结尾的文件, platform 可以作为参考。

      模板是基于 Groovy SimpleTemplateEngine 语法,所以支持 Groovy blocks,例如:

      Hello <% print (user.active ? user.login : 'user') %>
      
      <% if (user.email.endsWith('@company.com')) { %>
      
      The password for your account has been reset.
      
      Please login with the following temporary password:
      ${password}
      and immediately create a new one for the further work.
      
      Thank you
      
      <% } else {%>
      
      Please contact your system administrator for a new password.
      
      <%}%>

      这些模板的数据绑定包括 userpasswordpersistence 变量。也可以使用任何 middleware 中 的 Spring beans(前提是 import AppBeans,能在 AppBeans.get() 方法中使用)。

      需要覆盖模板的话,在 core 模块的 app.properties 中指定以下属性:

      cuba.security.resetPasswordTemplateBody = <relative path to your file>
      cuba.security.resetPasswordTemplateSubject = <relative path to your file>

      生产环境中,可以在配置目录放置模板, 将属性配置到 local.app.properties 文件。

用户编辑界面描述如下:

security user editor
  • Login – 必输项,唯一登录名。

  • Group访问组

  • Last name, First name, Middle name – 用户全名的各部分。

  • Name – 基于上述 name 自动生成的用户的全名。生成的规则定义在应用程序属性 cuba.user.fullNamePattern 中,全名也可以手动修改设置。

  • Position – 职位。

  • Language – 语言,用户选择的界面语言。如果要禁止用户选择语言,使用应用程序属性 cuba.localeSelectVisible

  • Time Zone时区 ,显示、输入时期时使用的时区。

  • Email – 电子邮件。

  • Active – 如果没有设置,用户则不能登录系统。

  • Permitted IP Mask – IP 地址掩码,定义用户可以从哪些 IP 地址登录。

    掩码是逗号分隔的 IP 地址列表。IPv4 和 IPv6 地址都支持。IPv4 地址应该包含四部分数字用“.”分隔。IPv6 分 8 部分,每部分是 4 个 16 进制码,以冒号分隔。可以使用 "*" 符号来匹配任何值。同一次只支持一种 IP 地址类型(IPv4 或 IPv6)。

    例子: 192.168.*.*

  • Roles用户角色列表

  • Substituted Users可被替代的用户列表。

6.2.2.1. 用户替代

系统管理员可以给用户 substitute 替代 另一用户的能力。替代用户与被替代用户拥有相同的会话,但是不同的角色约束会话属性

建议在应用程序代码中使用 UserSession.getCurrentOrSubstitutedUser() 方法来获取当前用户,当有激活的替代发生时,这个方法会返回被替代的用户。平台监控机制(createdByupdatedBy 属性,实体修改历史实体快照) 总是使用真正登录的用户。

当某个用户有可替代用户时,应用右上角会显示一个下拉列表,而不是纯文本:

user subst select

当在这个列表中选择另一用户时,所有已经打开的界面会关闭,替代激活。UserSession.getUser() 方法返回当前登录用户,但是 UserSession.getSubstitutedUser() 会返回被替代的用户。如果没有替代发生,UserSession.getSubstitutedUser() 返回 null

在用户编辑界面,通过 Substituted Users - 被替代用户 表格管理可被替代的用户。替代界面描述如下:

user subst edit
  • User – 被编辑用户,该用户会替代其它用户。

  • Substituted user – 可以被替代的用户。

  • Start date, End date – 非必须属性,替代生效时间。该时间区域以外则不能替代。如果不知道时间区域,会一直生效,直到管理删除它。

6.2.2.2. 时区

默认情况下,所以时间有关的值都可以在服务器时区中显示。通过在应用程序 block 中调用 TimeZone.getDefault() 获取时区。默认时区基于操作系统而来,也可以通过设置 Java 系统属性 user.timezone 显式设置。例如,Unix 环境下,给运行于 Tomcat 中的 web client 和 middleware 设置时区为 GMT 时,添加以下代码到 tomcat/bin/setenv.sh 文件:

CATALINA_OPTS="$CATALINA_OPTS -Duser.timezone=GMT"

用户可以查看/编辑与服务器不同时区的时间戳值,有两种方法管理用户时区:

  • 管理员可以在用户编辑界面修改。

  • 用户自己在 Help > Settings 窗口自行修改。

两种方法中,时区都包含两个域:

  • 时区名称,用户可以在下拉列表选择。

  • Auto 复选框,勾选后时区会从当前环境自动获取(对于 web client 是浏览器)。

如果两个域都为空,则不会对该用户做任何时区转换。否则,用户登录时,系统会在 UserSession 中保存时区设置,并在显示或输入时间值时使用它。应用程序代码也可以在需要的时候使用 UserSession.getTimeZone 获取时区的值。

如果时区值在当前会话被使用到,其简称和 GMT 值会在应用程序主窗口的用户名旁边显示。

时区转换会在使用 DateTimeDatatype 类型时发生,比如,时间值。使用(DateDatatype)日期类型和 (TimeDatatype) 时间类型单独保存日期和时间时不会受到影响。也可以使用 @IgnoreUserTimeZone 注解禁止为时间值属性做时区转换。

6.2.3. 权限许可

permission - 权限许可 定义用户对系统对象或功能的权限,例如 UI 界面,实体操作等。在讨论权限许可时,这些对象称为 target - 客体

通过给用户分配角色为用户赋予权限。

在通过角色为用户赋予特定的权限之前,用户对客体无任何权限。因此,无角色的用户没有任何权限,不能通过通用 UI 或 REST API 访问系统。

根据客体的不同,有以下类型的权限许可:

许可类型描述如下:

界面权限

界面可以被允许或者拒绝。

在构建主菜单和使用 Screens 接口的 create() 方法创建界面时,框架都会检查界面权限。如需在代码中检查界面权限,可以使用 Security 接口的 isScreenPermitted() 方法。

实体操作权限

对每个实体,可以设置以下权限:Create、Read、Update、Delete.

参阅 数据访问检查 章节了解框架中的不同机制如何使用实体操作权限。如需在代码中检查实体操作权限,可以使用 Security 接口的 isEntityOpPermitted() 方法。

实体属性权限

每个实体的每个属性都可以被赋予查看或者修改的权限。

参阅 数据访问检查 章节了解框架中不同机制如何使用实体属性权限。在应用程序中检查实体属性许可时,可以使用 Security 接口的 isEntityAttrPermitted() 方法。

特定功能权限

这些是任意功能的权限许可配置。项目中特定的权限是在 permissions.xml 配置文件中设置。

检查特定功能权限的示例:

@Inject
private Security security;

public void calculateBalance() {
    if (!security.isSpecificPermitted("myapp.calculateBalance"))
        return;
    //...
}
界面组件权限

界面组件权限可以用来隐藏或者将界面中的特定 UI 组件设为只读状态,不论这些组件是否绑定了实体。界面组件权限会在框架发送 AfterInitEventBeforeShowEvent 消息之间生效。

界面组件权限与其他类型权限不同的地方在于,只限制其指定的客体的权限。也就是说除非为组件客体定义了隐藏/只读权限,否则该组件客户对于用户来说没有任何限制。

客体组件按照下列规则通过其路径指定:

  • 如果该组件属于界面,只需要简单的指定其组件标识符。

  • 如果该组件属于界面内使用的界面片段,则指定为 “界面片段标识符.组件标识符” 的格式。

  • 如需为标签页面板标签页或者表单字段配置,则指定为 “组件标识符[标签页或字段标识符]” 的格式。

  • 如需配置界面操作权限,则指定为 “组件标识符<操作标识符>” 的格式,比如:customersTable<changeGrade>

6.2.4. 角色

角色包含权限许可集合,可以给用户分配角色。

一个用户可以有多个角色,角色之间以组合(逻辑或)的关系进行计算。例如,一个用户拥有角色 A 和 B,角色 A 未设置对 X 的权限,角色 B 允许 X,那么,X 对用户是允许的。

一个角色可以对单独客体赋予权限,也可以为一个种类的客体:界面、实体操作、实体属性、特殊功能权限。比如,可以很容易的配置所有实体的读取权限以及这些实体属性的查看权限。

但是界面组件权限却是上面规则的例外:只能给具体组件定义,而且如果没有角色定义组件的权限,则该组件对用户没有限制。

对于用户来说,可以有一个 “默认” 角色,也就是说这个角色会自动分配给新创建的用户,以便默认给每个新用户一组特定的权限。

设计时(Design-time)定义角色

推荐定义角色的方式是创建一个继承 AnnotatedRoleDefinition 的类,重写返回不同客体类型权限的方法,并添加注解指定角色所包含的权限。该类必须存在于 core 模块。例如,一个能赋予使用 Customer 实体及其浏览和编辑界面的角色可以如下配置:

@Role(name = "Customers Full Access")
public class CustomersFullAccessRole extends AnnotatedRoleDefinition {

    @EntityAccess(entityClass = Customer.class,
            operations = {EntityOp.CREATE, EntityOp.READ, EntityOp.UPDATE, EntityOp.DELETE})
    @Override
    public EntityPermissionsContainer entityPermissions() {
        return super.entityPermissions();
    }

    @EntityAttributeAccess(entityClass = Customer.class, modify = "*")
    @Override
    public EntityAttributePermissionsContainer entityAttributePermissions() {
        return super.entityAttributePermissions();
    }

    @ScreenAccess(screenIds = {"application-demo", "demo_Customer.browse", "demo_Customer.edit"})
    @Override
    public ScreenPermissionsContainer screenPermissions() {
        return super.screenPermissions();
    }
}

注解可以指定多次。例如,下面的角色赋予所有实体和属性的读取权限、允许修改 customer 的 gradecomments 属性、允许创建/更新 order 实体及其所有属性:

@Role(name = "Order Management")
public class OrderManagementRole extends AnnotatedRoleDefinition {

    @EntityAccess(entityName = "*", operations = {EntityOp.READ})
    @EntityAccess(entityClass = Order.class, operations = {EntityOp.CREATE, EntityOp.UPDATE})
    @Override
    public EntityPermissionsContainer entityPermissions() {
        return super.entityPermissions();
    }

    @EntityAttributeAccess(entityName = "*", view = "*")
    @EntityAttributeAccess(entityClass = Customer.class, modify = {"grade", "comments"})
    @EntityAttributeAccess(entityClass = Order.class, modify = "*")
    @Override
    public EntityAttributePermissionsContainer entityAttributePermissions() {
        return super.entityAttributePermissions();
    }
}

只有 cuba.security.rolesPolicyVersion 设置为 2 时,才能在设计时创建角色,该配置是使用 CUBA 7.2+ 创建新项目的默认配置。如果是从之前版本迁移至新版,请参阅 遗留版本角色和权限许可

运行时定义角色

框架带有在已经运行应用程序中定义角色的 UI 界面:Administration - 管理 > Roles - 角色。运行时定义的角色可以修改或删除。设计时定义的角色为只读。

在角色编辑界面的顶部,可以定义通用角色参数。界面的底部有定义权限的标签页。

  • Screens - 界面权限 标签页配置界面权限。树状结构展示应用程序主菜单的结构。如需设置主菜单访问不到的界面权限(比如,实体编辑界面),可以在最后一个树节点 Other screens - 其它界面 内找到。

    Allow all screens - 允许所有界面 复选框能一次性允许所有界面访问。与 @ScreenAccess(screenIds = "*") 功能一样。

  • Entities - 实体权限 标签页配置实体操作权限。Assigned only - 显示已分配 复选框默认选中,此时表格中只显示该角色已配置权限的实体。因此,如果是新角色,表格中无数据。如要添加权限,反选 Assigned only 并点击 Apply - 应用。如要过滤实体列表,可在 Entity - 实体 字段输入实体名称的一部分并点击 ApplySystem level - 系统级别 复选框选中可以查看并选择系统实体,系统实体使用 @SystemLevel 注解标记,默认不显示。

    使用 Allow all entities - 允许所有实体 面板启用对所有实体的操作,与 @EntityAccess(entityName = "*", …​) 功能一样。

  • Attributes - 属性权限 标签页配置实体属性权限。实体表格的 Permissions - 权限 列展示已配置权限的实体属性列表。实体列表与 Entities 标签页的实体列表使用一样的管理方式。

    使用 Allow all attributes - 允许所有属性 面板启用对所有实体所有属性的查看或编辑。如果您需要对某一实体启用全部属性,为该实体在 Permissions 面板底部选择 "*" 复选框。代码中,可以在 @EntityAttributeAccess 注解中使用 "*" 通配符作为 entityNameview/modify 属性的值。

  • Specific - 特定权限 标签页配置特殊功能权限。由 permissions.xml 文件定义项目中用到的特殊权限名称。

    Allow all specific permissions - 允许所有特定权限 复选框功能与 @SpecificAccess(permissions = "*") 一样。

  • UI - 界面元素权限 标签页配置 UI 界面组件权限。如需创建一个许可,在 Screen - 界面 下拉框选择需要配置的界面,然后在 Component - 组件 字段配置组件路径,并点击 Add - 添加。根据 权限许可 章节描述的规则配置客体组件。可以使用 Components tree - 组件树 按钮查看界面组件结构:在树状结构中选择一个组件然后点击右键菜单的 Copy id to path - 复制id

安全范围

Security scopes - 安全范围 可以依据使用的不同客户端技术为用户配置不同的角色组(因此有不同的权限)。安全范围使用 @Role 注解的 securityScope 属性设置,如果此角色是运行时定义的,则可以使用角色编辑界面的 Security scope - 安全范围 字段设置。

核心框架只有单一客户端 - 通用用户界面,因此所有角色默认具有 GENERIC_UI 权限范围。所有登录通用 UI 的用户将获得有该标签的一组角色。

REST API 插件 定义其自有的 REST 权限范围,因此如果项目中添加了该插件,需要为使用 REST API 登录系统的用户配置另一组角色。如果不这样做,用户将不能通过 REST 登录,因为这些用户不会有任何权限,也包括 cuba.restApi.enabled 的特定权限。

系统角色

框架为 GENERIC_UI 范围提供两个预定义的角色:

  • system-minimal 角色包含最小的一组权限,允许用户使用通用 UI。通过 MinimalRoleDefinition 类定义。该角色赋予用户 cuba.gui.loginToClient 特定权限,以及访问某些系统级实体和界面的权限。system-minimal 角色设置了 default 属性,因此会自动为新用户分配该角色。

  • system-full-access 角色具有全部的许可,可以用来创建系统管理员。系统自带的 admin 用户就默认分配该角色。

定义主菜单访问权限

需要配置查看某些界面的权限,也需要同时配置能访问该界面的整个主菜单结构的权限。

  • 对于 设计时角色,可以使用 @ScreenAccess 注解,示例:

    @ScreenAccess(screenIds = {"application-demo", "demo_Customer.browse", "demo_Customer.edit"})

    除了界面标识符之外,还需要在列表中添加顶层菜单项(比如,screenIds = {"application-demo"})。

  • 对于 运行时角色

    在角色编辑界面的 Screens - 界面权限 标签页,除了允许访问界面之外,还需要孕妇访问从该界面开始往上的所有菜单项。否则,用户不会看到该界面。

6.2.5. 访问组

访问组能将用户以树形层级关系组织,分配约束和自定义任意会话属性

一个用户只能加入一个访问组,但是用户加入的访问组树形层级以上的约束列表和会话属性都会被继承。

通过 Administration > Access Groups 界面管理访问组:

group users
6.2.5.1. 约束

Constraints - 约束 可以配置 行级访问控制,即能管理对于数据特定行的访问。与影响整个实体类别的权限许可不同,约束至影响特定的实体实例。约束可以设置增删查改(CRUD)操作,所以在加载或者禁用实体实例操作的时候,框架会过滤掉某些符合约束配置的实体。另外,也可以添加自定义约束,不限于 CRUD 操作。

从用户自身的访问组上的约束开始,直到其访问组层级上的所有访问组约束都会对该用户生效。所以,当用户所在访问组树形层级越低,给用户配置的约束会越多。

所有客户端层通过标准 DataManager 发起的操作都会触发约束检查。如果实体不满足约束,添加、更改或删除的时候会抛出 RowLevelSecurityException 异常。参阅 数据访问检查 章节了解框架中不同机制如何使用安全约束。

约束有两种类型:在数据库检查约束和内存中检查的约束。

  1. 对在数据库检查的约束,其条件通过 JPQL 子句设置。设置以后会被追加到查询语句之后,这样不满足条件的结果在数据库级别会被过滤掉。数据库检查的约束只能用到查询操作中,并且只影响加载的对象关系图中的根节点实体。

  2. 对在内存检查的约束,其条件通过 Java 代码(如果约束是设计时定义)或 Groovy 表达式(如果约束是运行时定义)设置。这类表达式执行在对象图中的每个实体上,当不满足条件时,数据会被从对象图中过滤掉。

设计时定义约束

约束可以在一个继承了 AnnotatedAccessGroupDefinition 的类中定义,那个类用于定义 访问组。继承类必须存于 core 模块。下面是一个访问组的示例,为 CustomerOrder 实体定义了几个约束:

@AccessGroup(name = "Sales", parent = RootGroup.class)
public class SalesGroup extends AnnotatedAccessGroupDefinition {

    @JpqlConstraint(target = Customer.class, where = "{E}.grade = 'B'") (1)
    @JpqlConstraint(target = Order.class, where = "{E}.customer.grade = 'B'") (2)
    @Override
    public ConstraintsContainer accessConstraints() {
        return super.accessConstraints();
    }

    @Constraint(operations = {EntityOp.CREATE, EntityOp.READ, EntityOp.UPDATE, EntityOp.DELETE}) (3)
    public boolean customerConstraints(Customer customer) {
        return Grade.BRONZE.equals(customer.getGrade());
    }

    @Constraint(operations = {EntityOp.CREATE, EntityOp.READ, EntityOp.UPDATE, EntityOp.DELETE}) (4)
    public boolean orderConstraints(Order order) {
        return order.getCustomer() != null && Grade.BRONZE.equals(order.getCustomer().getGrade());
    }

    @Constraint(operations = {EntityOp.UPDATE, EntityOp.DELETE}) (5)
    public boolean orderUpdateConstraints(Order order) {
        return order.getAmount().compareTo(new BigDecimal(100)) < 1;
    }
}
1 - 只加载 grade 属性是 B(对应于 Grade.BRONZE 枚举值) 的 customer。
2 - 只加载 grade 属性是 B 的用户的 orders。
3 - 内存约束,从加载的对象图中过滤掉 grade 属性不是 Grade.BRONZE 的 customer。
4 - 内存约束,允许使用 grade == Grade.BRONZE 的 customer 的 orders。
5 - 内存约束,允许修改或删除 amount < 100 的 orders。

编写 JPQL 约束是需要遵从以下规则:

  • {E} 需要作为被加载实体的别名,当执行查询语句时,它会被查询语句中真正使用的别名替代。

  • 以下预定义常量可以用作 JPQL 参数:

    • session$userLogin – 当前用户的 login 登录名,(如果是替代用户 – 则为被替代用户的 login)。

    • session$userId – 当前用户的 ID,(如果是替代用户 – 则为被替代用户的 ID)。

    • session$userGroupId – 当前用户的 group ID,(如果是替代用户 − 则为被替代用户的 group ID)。

    • session$XYZ – 当前用户会话的其它任意属性,将 XYZ 替换为属性名使用。

  • where 属性中的内容会被添加到 where 子句并用 and 连接。不需要显式添加 where 单词,系统会自动添加。

  • join 属性中的内容会被添加到 from 子句,该字段需要用逗号“,”、joinleft join 开头。

运行时定义约束

如需创建约束,打开 Access Groups - 访问组 界面,选择一个需要创建约束的组,然后切换到 Constraints - 约束 标签页。约束编辑界面带有 Constraint Wizard - 约束向导,能帮助用实体属性创建简单的 JPQL 和 Groovy 表达式。当选择 Custom - 自定义 作为操作类型时,需要填写 Code - 代码 字段,设置一个用于标记该约束的代码。

Join ClauseWhere Clause 字段内的 JPQL 编辑器支持实体名称和属性的自动完成功能。如需调用自动完成功能,按下 Ctrl+Space。如果是在输入“.”之后出现的智能提示,则会显示符合当前上下文的实体属性列表,否则显示所有数据模型的实体。

Groovy 内存约束中,使用 {E} 占位符变量表示被检查实体实例。另外,userSession 作为 UserSession 类型的变量也会传递给脚本。下面的例子展示的约束用来检查实体是由当前用户创建的:

{E}.createdBy == userSession.user.login

当违反约束时,会给用户展示一个通知消息。每个约束的通知消息标题和内容都可以做本地化:使用 Access Groups - 访问组 界面 Constraints - 约束 标签页的 Localization - 本地化 按钮。

Checking constraints in application code

开发者可以使用以下 Security 接口检查某一实体的约束条件:

  • isPermitted(Entity, ConstraintOperationType) - 根据操作类型检查是否约束。

  • isPermitted(Entity, String) - 根据输入字符串检查自定义约束。

也可以基于 ItemTrackingAction 连接 action 和特定约束。在 action 的 XML 节点中设置 constraintOperationType 属性或者使用 setConstraintOperationType() 方法设置。注意,约束的代码会在客户端层执行,所以不能使用中间层的类。

示例:

<table>
    ...
    <actions>
        <action id="create"/>
        <action id="edit" constraintOperationType="update"/>
        <action id="remove" constraintOperationType="delete"/>
    </actions>
</table>
6.2.5.2. 会话属性

访问组可以为组中的用户定义用户会话属性。这些属性可以在设置约束时使用,也可以在其他应用程序代码中使用。

当用户登录时,用户访问组的所有属性集合,以及组以上树形层级的属性集合都会被置于用户会话中。如果不同树形层级有相同的属性,最上层的会生效。所以,不可能在低树形层级组中覆盖属性值。如果发现有覆盖倾向,会在服务器日志中记录一条警告日志。

在定义 访问组 的类中,也可以同时定义约束和会话属性。该类需位于 core 模块。下面是一个访问组的例子,其中定义了 accessLevel 会话属性值为 1

@AccessGroup(name = "Level 1", parent = RootGroup.class)
public class FirstLevelGroup extends AnnotatedAccessGroupDefinition {

    @SessionAttribute(name = "accessLevel", value = "1", javaClass = Integer.class)
    @Override
    public Map<String, Serializable> sessionAttributes() {
        return super.sessionAttributes();
    }
}

会话属性也可以在运行时通过 Access Groups - 访问组 界面定义:选择一个访问组然后切换到其 Session Attributes - 会话属性 标签页。

会话属性可在代码中通过 UserSession 对象访问:

@Inject
private UserSessionSource userSessionSource;
...
Integer accessLevel = userSessionSource.getUserSession().getAttribute("accessLevel");

使用 session$ 前缀,会话属性可以在约束中做为 JPQL 参数使用:

{E}.accessLevel = :session$accessLevel

6.2.6. 导入导出角色和访问组

Roles - 角色Access Groups - 访问组 界面带有导出选中角色、访问组为 ZIP 或者 JSON 以及为系统导入角色、访问组的操作(使用 Export/Import 按钮)。

导入导出只能对运行时定义的角色和访问组生效。

6.2.7. 遗留版本角色和权限许可

在 CUBA 7.2 之前,计算有效权限的方法与现在不同:

  1. 有两种类型的权限许可:"allow - 允许" 和 "deny - 拒绝"。

  2. 如果对于某个客体,未指定拒绝权限,则允许对其访问。

  3. 可以显式的使用针对客体的 “allow/deny” 赋予权限或者通过某种类型的角色来赋予。比如,“Denying” 角色赋予除了实体属性之外所有客体的 “deny” 权限。如果客体没有对其的特殊许可设置,或者没有配置角色类型对其的许可,则用户具有对该客体的完全控制权。因此,如果一个用户没有配置角色,则其拥有系统的所有权限。

那时推荐为普通用户首先分配一个 “Denying” 角色,然后再为其分配一组其他带有特殊允许权限的角色。现在不需要 “Denying” 角色了,因为除非通过角色赋予其特殊的访问权限,否则用户对于任何客体都是无权限的。

还有,之前的版本中,没有安全范围,以至于对角色的配置影响既通用界面也影响 REST API 客户端。

安全子系统的行为通过一些应用程序属性控制,这些属性默认设置为新行为的对应参数。如果您从之前的版本迁移至 CUBA 7.2,Studio 会添加下列属性切换至之前的行为并保留您已有的安全配置。如果希望使用新功能(比如设计时角色定义),重新配置安全子系统,可以删除这些属性。

core 模块中,用之前版本的安全策略、default-permission-values.xml配置文件以及忽略新的 system-minimal 角色的配置属性:

app.properties
cuba.security.rolesPolicyVersion = 1
cuba.security.defaultPermissionValuesConfigEnabled = true
cuba.security.minimalRoleIsDefault = false

如果您的系统使用 REST API 插件,会在 webportal 模块设置下列属性,将 REST 安全范围配置成与通用 UI 一样:

web-app.properties
cuba.rest.securityScope = GENERIC_UI
portal-app.properties
cuba.rest.securityScope = GENERIC_UI

6.3. 数据访问检查

下面的表格解释框架中不同的机制如何使用数据访问权限约束

实体操作

实体属性

读约束
在数据库检查 (1)

读约束
在内存中检查 (2)

创建/更新/删除
约束

EntityManager

DataManager 在中间件层


(3) (4)


(5)


(4)


(4)

DataManager.secure 在中间件层

DataManager 在客户端层

(3)


(5)

通用 UI 数据感知组件

- (6)

- (6)

- (6)

REST API /entities

REST API /queries

- (7)

REST API /services

- (8)

- (8)

- (8)

注:

1) 在数据库检查的读约束只影响根实体

// 只有在满足 Order 实体的约束情况下才会加载 order
Order order = dataManager.load(Order.class).viewProperties("date", "amount", "customer.name").one();
// 关联的 customer 会被加载,忽略 Customer 实体在数据库检查的约束
assert order.getCustomer() != null;

2) 读约束 checked in memory affects the root entity and all linked entities in the loaded graph.

// 只有在满足 Order 实体的约束情况下才会加载 order
Order order = dataManager.load(Order.class).viewProperties("date", "amount", "customer.name").one();
// 关联的 customer 非空,只有满足了 Customer 实体在内存中检查的约束
if (order.getCustomer() != null) ...

3) DataManager 中的实体操作检查只对根实体生效。

// 加载 Order
Order order = dataManager.load(Order.class).viewProperties("date", "amount", "customer.name").one();
// 关联 customer 会被加载,即便用户没有权限读取 Customer 实体
assert order.getCustomer() != null;

4) 只有在设置了 cuba.dataManagerChecksSecurityOnMiddleware 应用程序属性为 true 时,DataManager 才会在中间件层检查实体操作权限和内存中约束。

5) 只有在设置了 cuba.entityAttributePermissionChecking 应用程序属性为 true 时,DataManager 才检查实体属性权限。

6) UI 组件本身不检查约束,但是当数据是通过标准机制加载时,DataManager 会使用约束。所以,如果实体实例受到某些约束的限制被过滤掉了,相应的 UI 组件还是会显示,但是内容为空。另外,可以将基于 ItemTrackingAction 类的操作与特定的约束相关联,这样只有在选中的实体通过了关联的约束检查之后这个操作才会启用。

7) REST 查询都是只读的。

8) REST 服务方法参数和结果不会被检查,因为要与访问组约束一致。服务中与约束相关的定义是由服务如何加载和保存数据决定的,比如是否使用 DataManagerDataManager.secure()

6.4. 访问权限控制示例

本章节提供一些关于如何配置用户数据访问权限的实践建议。

6.4.1. 配置角色

下面是权限配置的快速参考,以配置访问 Administration - 管理 功能为例。 比如,只开放实体日志功能,在相应部分设置提到的权限。

推荐至少提供 sys$FileDescriptor 实体的只读权限,因为这个实体在平台很多地方都会用到:邮件、附件、日志等。

Users - 用户

User 实体可以在数据模型中用来做关联引用实体。需要在查询组件或者下拉框组件使用用户实体,只需要设置 sec$User 实体的权限就足够。

如果需要创建或者编辑 User 实体,还需要设置以下权限:

  • 实体: sec$User, sec$Group; (可选) sec$Role, sec$UserRole, sec$UserSubstitution.

读取 sec$UserSubstitution 实体的权限对代替用户功能是至关重要的。

  • 界面: Users 菜单项, sec$User.edit, sec$Group.lookup; (可选) sec$Group.edit, sec$Role.edit, sec$Role.lookup, sec$User.changePassword, sec$User.copySettings, sec$User.newPasswords, sec$User.resetPasswords, sec$UserSubstitution.edit.

Access Groups - 访问组

创建或者管理用户访问组以及安全限制。

  • 实体: sec$Group, sec$Constraint, sec$SessionAttribute, sec$LocalizedConstraintMessage.

  • 界面: Access Groups 菜单项, sec$Group.lookup, sec$Group.edit, sec$Constraint.edit, sec$SessionAttribute.edit, sec$LocalizedConstraintMessage.edit.

Dynamic Attributes - 动态属性

访问额外的实体非持久化属性

  • 实体: sys$Category, sys$CategoryAttribute, 以及数据模型需要的其它实体。

  • 界面: Dynamic Attributes 菜单项, sys$Category.edit, sys$CategoryAttribute.edit, dynamicAttributesConditionEditor, dynamicAttributesConditionFrame.

User Sessions - 用户会话

查看用户会话数据。

  • 实体: sec$User, sec$UserSessionEntity.

  • 界面: User Sessions 菜单项, sessionMessageWindow.

Locks - 锁

设置实体的悲观锁

  • 实体: sys$LockInfo, sys$LockDescriptor, 以及数据模型需要的其它实体。

  • 界面: Locks 菜单项, sys$LockDescriptor.edit.

External Files - 外部文件

访问应用的文件存储

  • 实体: sys$FileDescriptor.

  • 界面: External Files 菜单项; (可选) sys$FileDescriptor.edit.

Scheduled Tasks - 定时任务

创建和管理定时任务

  • 实体: sys$ScheduledTask, sys$ScheduledExecution.

  • 界面: Scheduled Tasks 菜单项, sys$ScheduledExecution.browse, sys$ScheduledTask.edit.

Entity Inspector - 实体探查

实体探查动态生成的界面中操作应用对象实体。

  • 实体: 数据模型需要的实体。

  • 界面: Entity Inspector 菜单项, entityInspector.edit, 以及数据模型需要的其它实体。

Entity Log - 实体日志

记录实体变化。

  • 实体: sec$EntityLog, sec$User, sec$EntityLogAttr, sec$LoggedAttribute, sec$LoggedEntity, 以及数据模型需要的其它实体。

  • 界面: Entity Log 菜单项.

User Session Log - 用户会话日志

查看用户登入登出或者用户会话的历史数据。

  • 实体: sec$SessionLogEntry.

  • 界面: User Session Log 菜单项.

Email History - 邮件历史

查看从应用发出的电子邮件

  • 实体: sys$SendingMessage, sys$SendingAttachment, sys$FileDescriptor (邮件附件需要).

  • 界面: Email History 菜单项, sys$SendingMessage.attachments.

Server Log - 服务器日志

查看并下载应用的日志文件

  • 实体: sys$FileDescriptor.

  • 界面: Server Log 菜单项, serverLogDownloadOptionsDialog.

  • 特殊功能: 下载日志文件

Reports - 报表

需要运行报表,参考 报表生成器 插件。

  • 实体: report$Report, report$ReportInputParameter, report$ReportGroup.

  • 界面: report$inputParameters, commonLookup, report$Report.run, report$showChart (如果包含图表模板)。

6.4.2. 创建本地管理员

访问组的树形层级结构加上约束能够支持创建 本地管理员(部门管理员),可以用来在组织部门内代理做一些创建、配置用户和权限管理的工作。

本地管理员可以访问安全子系统的界面,但是他们只能看到自己访问组或以下的用户和访问组。本地管理员还可以创建子访问组、用户以及能分配系统上的角色。其创建的新用户也至少有跟创建他们的本地管理员一样的权限限制。

访问组根节点下面的全局管理员需要创建能被本地管理员看到的角色,然后本地管理员才能分配给用户。本地管理员不能创建或修改角色。

下面是一个访问组结构的例子:

local admins groups

问题有:

  • Departments 下面的用户应该只能看到该组下面的用户,或低于该组层级的组里的用户。

  • Dept 1, Dept 2, 这些子组都应该有自己的管理员可以创建用户,分配角色。

方案是:

  • Departments 里添加以下约束:

    • 对于 sec$Group 实体:

      {E}.id in (
        select h.group.id from sec$GroupHierarchy h
        where h.group.id = :session$userGroupId or h.parent.id = :session$userGroupId
      )

      这样,用户就不会看到比自己所在组层级高的组。

    • 对于 sec$User 实体:

      {E}.group.id in (
        select h.group.id from sec$GroupHierarchy h
        where h.group.id = :session$userGroupId or h.parent.id = :session$userGroupId
      )

      这样,用户不会看到比自己所在组层级高的组里的用户。

    • 对于 sec$Role 实体(一个在内存中检查的 Groovy 约束):

      !['system-full-access', 'Some Role to Hide 1', 'Some Role to Hide 2'].contains({E}.name)

      有了这个约束,用户将无法查看和分配不想要的角色。

  • 为本地管理员创建一个 Department Administrator 角色:

    • Screens - 界面权限 标签页,允许如下界面:

      AdministrationUsersAccess GroupsRolessec$Group.editsec$Group.lookupsec$Role.lookupsec$User.changePasswordsec$User.copySettingssec$User.editsec$User.lookupsec$User.newPasswordssec$User.resetPasswordssec$UserSubstitution.edit

    • Entities - 实体权限 标签页,允许对下列实体的所有操作:sec$Groupsec$Usersec$UserRole 实体,允许 Read 操作实体 sec$Role(需要选中 System level 才能为 sec$UserRole 添加权限)。

    • Attributes - 属性权限 标签页,为这些实体选择 "*":sec$Groupsec$Usersec$Role

  • 按照上面的截屏在他们的部门内创建本地管理员并为其分配 Department Administrator 角色。

当本地管理员登录系统之后,他们只能看到自己的部门分组以及子分组:

local admins res

本地管理员可以创建新用户并且为其分配已有的角色,当然,除了那些约束中列举的角色。

6.5. 集成 LDAP

CUBA 应用程序可以跟 LDAP 集成以便提供以下便利:

  1. 集中的将用户名和密码保存在 LDAP 数据库。

  2. 对于 Windows 域用户,可以使用单点登录机制来登录应用系统而不需要提供用户名和密码。

如果启用了 LDAP 集成,用户在应用系统中还是需要一个账号。用户的所有权限和其它属性(除了密码)都保存在应用程序数据库,LDAP 只是用来做用户认证。推荐将大部分用户的应用程序密码留空,除了那些需要标准认证的用户(参考下面)。如果 cuba.web.requirePasswordForNewUsers 属性设置成 false,那么用户编辑界面不需要显示密码控件。

如果用户的登录名列在 cuba.web.standardAuthenticationUsers 应用程序属性中,应用程序会尝试使用数据库保存的密码哈希值来做用户认证。这样的话,这个列表里的用户可以使用数据的密码登录系统,即便这个用户不在 LDAP 注册过。

基于 CUBA 的应用程序通过 LdapLoginProvider bean 来跟 LDAP 交互。

可以用 使用 Jespa 集成活动目录 章节描述的 Jespa 类库和相应的 LoginProvider 来启用跟活动目录(Active Directory)的进一步集成,包括使用 Windows 域用户的单点登录。

可以通过自定义的 LoginProviderHttpRequestFilter 或者 Web 登录规范描述的事件来实现自定义的登录机制。

还有,可以为 REST API 客户端启用 LDAP 认证:https://doc.cuba-platform.cn/restapi-7.2#rest_api_v2_ldap[REST API 使用 LDAP 做认证] 。

6.5.1. 基础 LDAP 集成

如果参数 cuba.web.ldap.enabled 设置为 true,则启用了 LdapLoginProvider。 这种情况下,使用 Spring LDAP 类库做用户认证。

下列 Web 客户端应用程序属性用来设置集成 LDAP:

local.app.properties 文件示例:

cuba.web.ldap.enabled = true
cuba.web.ldap.urls = ldap://192.168.1.1:389
cuba.web.ldap.base = ou=Employees,dc=mycompany,dc=com
cuba.web.ldap.user = cn=System User,ou=Employees,dc=mycompany,dc=com
cuba.web.ldap.password = system_user_password

在集成了活动目录(Active Directory)的情况下,当使用应用程序来创建用户的时候,用不带域的用户名设置 sAMAccountName

6.5.2. 使用 Jespa 集成活动目录

Jespa 是用来集成活动目录和 Java 应用程序的 Java 类库,Jespa 使用 NTLMv2。参考 http://www.ioplex.com 了解更多细节。

6.5.2.1. 引入类库

http://www.ioplex.com 下载这个库,然后把这个 JAR 上传到 build.gradle 脚本中注册的一个仓库中。仓库可以是 mavenLocal() 或者内部仓库(私仓)。

build.gradle 中的 web 模块配置部分添加以下依赖:

configure(webModule) {
    ...
    dependencies {
        compile('com.company.thirdparty:jespa:1.1.17')  // from a custom repository
        compile('jcifs:jcifs:1.3.17')                   // from Maven Central
        ...

web 模块创建一个 LoginProvider 的实现类:

package com.company.jespatest.web;

import com.google.common.collect.ImmutableMap;
import com.haulmont.cuba.core.global.ClientType;
import com.haulmont.cuba.core.global.GlobalConfig;
import com.haulmont.cuba.core.sys.AppContext;
import com.haulmont.cuba.core.sys.ConditionalOnAppProperty;
import com.haulmont.cuba.security.auth.*;
import com.haulmont.cuba.security.global.LoginException;
import com.haulmont.cuba.web.App;
import com.haulmont.cuba.web.Connection;
import com.haulmont.cuba.web.auth.WebAuthConfig;
import com.haulmont.cuba.web.security.ExternalUserCredentials;
import com.haulmont.cuba.web.security.LoginProvider;
import com.haulmont.cuba.web.security.events.AppStartedEvent;
import com.haulmont.cuba.web.sys.RequestContext;
import jespa.http.HttpSecurityService;
import jespa.ntlm.NtlmSecurityProvider;
import jespa.security.PasswordCredential;
import jespa.security.SecurityProviderException;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;

import javax.annotation.Nullable;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.Serializable;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;

import static com.haulmont.cuba.web.security.ExternalUserCredentials.EXTERNAL_AUTH_USER_SESSION_ATTRIBUTE;

@ConditionalOnAppProperty(property = "activeDirectory.integrationEnabled", value = "true")
@Component("sample_JespaAuthProvider")
public class JespaAuthProvider extends HttpSecurityService implements LoginProvider, Ordered, Filter {

    private static final Logger log = LoggerFactory.getLogger(JespaAuthProvider.class);

    @Inject
    private GlobalConfig globalConfig;
    @Inject
    private WebAuthConfig webAuthConfig;
    @Inject
    private DomainAliasesResolver domainAliasesResolver;
    @Inject
    private AuthenticationService authenticationService;

    private static Map<String, DomainInfo> domains = new HashMap<>();
    private static String defaultDomain;

    @PostConstruct
    public void init() throws ServletException {
        initDomains();

        Map<String, String> properties = new HashMap<>();
        properties.put("jespa.bindstr", getBindStr());
        properties.put("jespa.service.acctname", getAcctName());
        properties.put("jespa.service.password", getAcctPassword());
        properties.put("jespa.account.canonicalForm", "3");
        properties.put("jespa.log.path", globalConfig.getLogDir() + "/jespa.log");
        properties.put("http.parameter.anonymous.name", "anon");
        fillFromSystemProperties(properties);

        try {
            super.init(JespaAuthProvider.class.getName(), null, properties);
        } catch (SecurityProviderException e) {
            throw new ServletException(e);
        }
    }

    @Nullable
    @Override
    public AuthenticationDetails login(Credentials credentials) throws LoginException {
        LoginPasswordCredentials lpCredentials = (LoginPasswordCredentials) credentials;

        String login = lpCredentials.getLogin();
        // parse domain by login
        String domain;
        int atSignPos = login.indexOf("@");
        if (atSignPos >= 0) {
            String domainAlias = login.substring(atSignPos + 1);
            domain = domainAliasesResolver.getDomainName(domainAlias).toUpperCase();
        } else {
            int slashPos = login.indexOf('\\');
            if (slashPos <= 0) {
                throw new LoginException("Invalid name: %s", login);
            }
            String domainAlias = login.substring(0, slashPos);
            domain = domainAliasesResolver.getDomainName(domainAlias).toUpperCase();
        }

        DomainInfo domainInfo = domains.get(domain);
        if (domainInfo == null) {
            throw new LoginException("Unknown domain: %s", domain);
        }

        Map<String, String> securityProviderProps = new HashMap<>();
        securityProviderProps.put("bindstr", domainInfo.getBindStr());
        securityProviderProps.put("service.acctname", domainInfo.getAcctName());
        securityProviderProps.put("service.password", domainInfo.getAcctPassword());
        securityProviderProps.put("account.canonicalForm", "3");
        fillFromSystemProperties(securityProviderProps);

        NtlmSecurityProvider provider = new NtlmSecurityProvider(securityProviderProps);
        try {
            PasswordCredential credential = new PasswordCredential(login, lpCredentials.getPassword().toCharArray());
            provider.authenticate(credential);
        } catch (SecurityProviderException e) {
            throw new LoginException("Authentication error: %s", e.getMessage());
        }

        TrustedClientCredentials trustedCredentials = new TrustedClientCredentials(
                lpCredentials.getLogin(),
                webAuthConfig.getTrustedClientPassword(),
                lpCredentials.getLocale(),
                lpCredentials.getParams());

        trustedCredentials.setClientInfo(lpCredentials.getClientInfo());
        trustedCredentials.setClientType(ClientType.WEB);
        trustedCredentials.setIpAddress(lpCredentials.getIpAddress());
        trustedCredentials.setOverrideLocale(lpCredentials.isOverrideLocale());
        trustedCredentials.setSyncNewUserSessionReplication(lpCredentials.isSyncNewUserSessionReplication());

        Map<String, Serializable> targetSessionAttributes;
        Map<String, Serializable> sessionAttributes = lpCredentials.getSessionAttributes();
        if (sessionAttributes != null
                && !sessionAttributes.isEmpty()) {
            targetSessionAttributes = new HashMap<>(sessionAttributes);
            targetSessionAttributes.put(EXTERNAL_AUTH_USER_SESSION_ATTRIBUTE, true);
        } else {
            targetSessionAttributes = ImmutableMap.of(EXTERNAL_AUTH_USER_SESSION_ATTRIBUTE, true);
        }
        trustedCredentials.setSessionAttributes(targetSessionAttributes);

        return authenticationService.login(trustedCredentials);
    }

    @Override
    public boolean supports(Class<?> credentialsClass) {
        return LoginPasswordCredentials.class.isAssignableFrom(credentialsClass);
    }

    @Override
    public int getOrder() {
        return HIGHEST_PLATFORM_PRECEDENCE + 50;
    }

    @Override
    public void destroy() {
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @EventListener
    public void loginOnAppStart(AppStartedEvent appStartedEvent) {
        App app = appStartedEvent.getApp();
        Connection connection = app.getConnection();
        Principal userPrincipal = RequestContext.get().getRequest().getUserPrincipal();
        if (userPrincipal != null) {
            String login = userPrincipal.getName();
            log.debug("Trying to login using jespa principal " + login);
            try {
                connection.login(new ExternalUserCredentials(login, App.getInstance().getLocale()));
            } catch (LoginException e) {
                log.trace("Unable to login on start", e);
            }
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        if (httpServletRequest.getHeader("User-Agent") != null) {
            String ua = httpServletRequest.getHeader("User-Agent")
                    .toLowerCase();

            boolean windows = ua.contains("windows");
            boolean gecko = ua.contains("gecko") && !ua.contains("webkit");

            if (!windows && gecko) {
                chain.doFilter(request, response);
                return;
            }
        }
        super.doFilter(request, response, chain);
    }

    private void initDomains() {
        String domainsStr = AppContext.getProperty("activeDirectory.domains");
        if (StringUtils.isEmpty(domainsStr)) {
            return;
        }

        String[] strings = domainsStr.split(";");
        for (int i = 0; i < strings.length; i++) {
            String domain = strings[i];
            domain = domain.trim();

            if (StringUtils.isEmpty(domain)) {
                continue;
            }

            String[] parts = domain.split("\\|");
            if (parts.length != 4) {
                log.error("Invalid ActiveDirectory domain definition: " + domain);
                break;
            } else {
                domains.put(parts[0], new DomainInfo(parts[1], parts[2], parts[3]));
                if (i == 0) {
                    defaultDomain = parts[0];
                }
            }
        }
    }

    public String getDefaultDomain() {
        return defaultDomain != null ? defaultDomain : "";
    }

    public String getBindStr() {
        return getBindStr(getDefaultDomain());
    }

    public String getBindStr(String domain) {
        initDomains();
        DomainInfo domainInfo = domains.get(domain);
        return domainInfo != null ? domainInfo.getBindStr() : "";
    }

    public String getAcctName() {
        return getAcctName(getDefaultDomain());
    }

    public String getAcctName(String domain) {
        initDomains();
        DomainInfo domainInfo = domains.get(domain);
        return domainInfo != null ? domainInfo.getAcctName() : "";
    }

    public String getAcctPassword() {
        return getAcctPassword(getDefaultDomain());
    }

    public String getAcctPassword(String domain) {
        initDomains();
        DomainInfo domainInfo = domains.get(domain);
        return domainInfo != null ? domainInfo.getAcctPassword() : "";
    }

    public void fillFromSystemProperties(Map<String, String> params) {
        for (String name : AppContext.getPropertyNames()) {
            if (name.startsWith("jespa.")) {
                params.put(name, AppContext.getProperty(name));
            }
        }
    }

    public static class DomainInfo {

        private final String bindStr;
        private final String acctName;
        private final String acctPassword;

        DomainInfo(String bindStr, String acctName, String acctPassword) {
            this.acctName = acctName;
            this.acctPassword = acctPassword;
            this.bindStr = bindStr;
        }

        public String getBindStr() {
            return bindStr;
        }

        public String getAcctName() {
            return acctName;
        }

        public String getAcctPassword() {
            return acctPassword;
        }
    }
}

modules/web/WEB-INF/web.xml 注册 LoginProvider 为 filter:

    <filter>
        <filter-name>jespa_Filter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
        <init-param>
            <param-name>targetBeanName</param-name>
            <param-value>sample_JespaAuthProvider</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>jespa_Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

创建一个 bean 用来在 web 模块使用别名解析域名:

package com.company.sample.web;

import com.haulmont.cuba.core.sys.AppContext;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component(DomainAliasesResolver.NAME)
public class DomainAliasesResolver {

    public static final String NAME = "sample_DomainAliasesResolver";

    private static final Logger log = LoggerFactory.getLogger(DomainAliasesResolver.class);

    private Map<String, String> aliases = new HashMap<>();

    public DomainAliasesResolver() {
        String domainAliases = AppContext.getProperty("activeDirectory.aliases");
        if (StringUtils.isEmpty(domainAliases)) {
            return;
        }

        List<String> aliasesPairs = Arrays.stream(StringUtils.split(domainAliases, ';'))
                .filter(StringUtils::isNotEmpty)
                .collect(Collectors.toList());

        for (String aliasDefinition : aliasesPairs) {
            String[] aliasParts = StringUtils.split(aliasDefinition, '|');
            if (aliasParts == null
                    || aliasParts.length != 2
                    || StringUtils.isBlank(aliasParts[0])
                    || StringUtils.isBlank(aliasParts[1])) {
                log.warn("Incorrect domain alias definition: '{}'", aliasDefinition);
            } else {
                aliases.put(aliasParts[0].toLowerCase(), aliasParts[1]);
            }
        }
    }

    public String getDomainName(String alias) {
        String alias_lc = alias.toLowerCase();

        String domain = aliases.get(alias_lc);
        if (domain == null) {
            return alias;
        }

        log.debug("Resolved domain '{}' from alias '{}'", domain, alias);

        return domain;
    }
}
6.5.2.2. 安装和配置
  • 按步骤完成 Jespa Operator’s ManualInstallationStep 1: Create the Computer Account for NETLOGON Communication,参考 http://www.ioplex.com/support.html

  • local.app.properties 文件的 activeDirectory.domains 属性里设置域参数。每个域的描述符应该按照这个格式:domain_name|full_domain_name|service_account_name|service_account_password。域描述符之间通过分号分隔。

    示例:

    activeDirectory.domains = MYCOMPANY|mycompany.com|JESPA$@MYCOMPANY.COM|password1;TEST|test.com|JESPA$@TEST.COM|password2
  • 启用集成活动目录,通过设置 local.app.properties 文件的 activeDirectory.integrationEnabled 属性:

    activeDirectory.integrationEnabled = true
  • local.app.properties 文件配置其它的 Jespa 属性(参考 Jespa Operator’s Manual),示例:

    jespa.log.level=3

    如果应用程序部署在 Tomcat,Jespa 的日志保存在 tomcat/logs

  • 在浏览器设置添加服务器地址到本地网络:

    • Internet ExplorerChromeSettings > Security > Local intranet > Sites > Advanced

    • Firefoxabout:config > network.automatic-ntlm-auth.trusted-uris=http://myapp.mycompany.com

  • 在应用程序中创建有域账号的用户。

6.6. CUBA 应用程序单点登录

CUBA 应用程序单点登录(SSO)允许用户只在浏览器的一个会话输入一次用户名和密码登录之后就可以访问多个运行的应用。

IDP 插件设计用来简化 CUBA 应用程序中的单点登录设置。参阅插件 文档 了解更多细节。

6.7. 社交网站登录

本章节提到的主要是使用 Facebook,Twitter 和 Google+这三个社交网络,依据网络情况,有些网址可能需要科学上网访问。

社交网站登录也是单点登录(SSO) 的一种形式,可以通过社交网站的账号(比如 Facebook,Twitter 或者 Google+)来登录 CUBA 系统,而不需要为 CUBA 应用程序创建特定的账号。

参考 匿名访问和社交网站登录 指南,了解如何为应用程序的某些界面设置公共访问,以及使用 Google、Facebook 或 GitHub 账号自定义登录的实现。

下面将使用 Facebook 来作为社交网络登录的示例。Facebook 使用 OAuth2 认证机制,想了解更多细节请参考 Facebook API 和 Facebook Login Flow: https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow

示例项目代码在这里: GitHub,以下列出关键点的实现。

  1. 为了让项目连接到 Facebook,需要创建 App ID (唯一应用程序标识符)和 App Secret (为应用程序项目发送到 Facebook 的请求做认证的一种密码)。按照 介绍 申请,然后在 core 模块的 app.properties 文件中分别以 facebook.appIdfacebook.appSecret 这两个属性注册申请到的值。示例:

    facebook.appId = 123456789101112
    facebook.appSecret = 123456789101112abcde131415fghi16

    启用 email 权限,允许您的 app 查看用户的主邮箱地址。

    然后,在应用程序配置在 Facebook app 注册的 URL,填写在 coreweb 模块的应用程序属性文件的 cuba.webAppUrl 参数。示例:

    cuba.webAppUrl = http://cuba-fb.test:8080/app
  2. 扩展 登录界面 并添加社交登录按钮。订阅该按钮点击事件,作为社交登录流程的起点。

    <linkButton id="facebookBtn"
                align="MIDDLE_CENTER"
                caption="Facebook"
                icon="font-icon:FACEBOOK_SQUARE"/>
  3. 为了使用 Facebook 用户账号,需要在 CUBA 标准的用户账号中添加一个额外字段。扩展 User 实体并添加字符串类型的属性 facebookId

    @Column(name = "FACEBOOK_ID")
    protected String facebookId;
  4. 创建一个 FacebookAccessRole 角色,允许用户查看 HelpSettingsAbout 界面:

    @Role(name = "facebook-access")
    public class FacebookAccessRole extends AnnotatedRoleDefinition {
        @ScreenAccess(screenIds = {
                "help",
                "aboutWindow",
                "settings",
        })
        @Override
        public ScreenPermissionsContainer screenPermissions() {
            return super.screenPermissions();
        }
    }
  5. 创建 服务,根据提供的 facebookId 在应用数据库查找用户,然后要么返回已有用户,要么创建新用户:

    public interface SocialRegistrationService {
        String NAME = "demo_SocialRegistrationService";
    
        User findOrRegisterUser(String facebookId, String email, String name);
    }
    @Service(SocialRegistrationService.NAME)
    public class SocialRegistrationServiceBean implements SocialRegistrationService {
    
        @Inject
        private DataManager dataManager;
        @Inject
        private Configuration configuration;
    
        @Override
        public User findOrRegisterUser(String facebookId, String email, String name) {
            User existingUser = dataManager.load(User.class)
                    .query("select u from sec$User u where u.facebookId = :facebookId")
                    .parameter("facebookId", facebookId)
                    .optional()
                    .orElse(null);
            if (existingUser != null) {
                return existingUser;
            }
            SocialUser user = dataManager.create(SocialUser.class);
            user.setLogin(email);
            user.setName(name);
            user.setGroup(getDefaultGroup());
            user.setActive(true);
            user.setEmail(email);
            user.setFacebookId(facebookId);
            UserRole fbUserRole = dataManager.create(UserRole.class);
            fbUserRole.setRoleName("facebook-access");
            fbUserRole.setUser(user);
            EntitySet eSet = dataManager.commit(user, fbUserRole);
            return eSet.get(user);
        }
    
        private Group getDefaultGroup() {
            SocialRegistrationConfig config = configuration.getConfig(SocialRegistrationConfig.class);
    
            return dataManager.load(Group.class)
                    .query("select g from sec$Group g where g.id = :defaultGroupId")
                    .parameter("defaultGroupId", config.getDefaultGroupId())
                    .one();
        }
    }
  6. 创建服务来管理登录过程。本示例中是: FacebookService 包含两个方法: getLoginUrl()getUserData()

    • getLoginUrl() 生成登录 URL,基于应用程序 URL 和 OAuth2 返回类型(代码、访问令牌(access token)或者两者都有,参考 Facebook API 文档 了解更多返回类型)。这个方法的实现可以参考 FacebookServiceBean.java 文件。

    • getUserData() 使用提供的应用程序 URL 和代码来查找 Facebook 用户,并且返回已有用户的数据或者创建新用户。在这个例子中,希望获取用户的 idnameemailid 也就是上面创建的 facebookId

  7. core 模块的 app.properties 文件中定义 facebook.fields 应用程序属性:

    facebook.fields = id,name,email
  8. 返回扩展登录窗口控制器的 Facebook 登录按钮事件方法。这个控制器的所有代码在 ExtAppLoginWindow.java 文件。

    在这个方法中,有针对当前会话的请求处理(request handler),保存当前 URL 并且调用重定向到 Facebook 认证表单:

    private RequestHandler facebookCallBackRequestHandler =
            this::handleFacebookCallBackRequest;
    
    private URI redirectUri;
    
    @Inject
    private FacebookService facebookService;
    
    @Inject
    private GlobalConfig globalConfig;
    
    @Subscribe("facebookBtn")
    public void onFacebookBtnClick(Button.ClickEvent event) {
        VaadinSession.getCurrent()
            .addRequestHandler(facebookCallBackRequestHandler);
    
        this.redirectUri = Page.getCurrent().getLocation();
    
        String loginUrl = facebookService.getLoginUrl(globalConfig.getWebAppUrl(), FacebookService.OAuth2ResponseType.CODE);
        Page.getCurrent()
            .setLocation(loginUrl);
    }

    handleFacebookCallBackRequest() 方法会处理 Facebook 认证表单之后的函数回调。首先,使用 UIAccessor 实例来锁住 UI 直到登录请求处理完毕。

    然后,FacebookService 会获取 facebook 用户账号的 emailid。在这之后,相应的 CUBA 用户会通过 facebookId 被查找到,或者在此过程中被系统创建。

    接下来,认证会被触发,这个用户的用户会话会被加载,然后 UI 会更新。之后会移除 Facebook 回调处理,因为此时不再需要认证了。

    public boolean handleFacebookCallBackRequest(VaadinSession session, VaadinRequest request,
                                                 VaadinResponse response) throws IOException {
        if (request.getParameter("code") != null) {
            uiAccessor.accessSynchronously(() -> {
                try {
                    String code = request.getParameter("code");
    
                    FacebookService.FacebookUserData userData = facebookService.getUserData(globalConfig.getWebAppUrl(), code);
    
                    User user = socialRegistrationService.findOrRegisterUser(
                            userData.getId(), userData.getEmail(), userData.getName());
    
                    Connection connection = app.getConnection();
    
                    Locale defaultLocale = messages.getTools().getDefaultLocale();
                    connection.login(new ExternalUserCredentials(user.getLogin(), defaultLocale));
                } catch (Exception e) {
                    log.error("Unable to login using Facebook", e);
                } finally {
                    session.removeRequestHandler(facebookCallBackRequestHandler);
                }
            });
    
            ((VaadinServletResponse) response).getHttpServletResponse().
                    sendRedirect(ControllerUtils.getLocationWithoutParams(redirectUri));
    
            return true;
        }
    
        return false;
    }

现在,当用户在登录界面点击 Facebook 按钮时,应用程序会跟用户请求使用 Facebook 账号和邮箱,如果得到用户授权,这个账号登录后会直接跳转到应用程序主界面。

可以通过使用自定义的 LoginProvider, HttpRequestFilter 或者 Web 登录 章节提到的事件来实现定制化登录机制。

Appendix A: 项目配置文件

本附录介绍 CUBA 应用程序中包含的主要配置文件。

A.1. app-component.xml

需要把当前应用程序作为别的应用程序的组件时,需要使用 app-component.xml 配置文件。此文件定义了对于其它组件的依赖、描述了目前存在的应用程序模块,生成的工件以及暴露的应用程序属性

app-component.xml 文件应该被放在一个包内,通过 global 模块 JAR 包组装清单文件(manifest)的 App-Component-Id 记录来指定。这个组装文件的记录使得构建系统能从构建 classpath 中查找需要的项目的组件。因此,如果需要在项目中使用某些组件,只需要在 build.gradle 文件中的 dependencies/appComponent 条目定义组件的 global 工件即可。

按照惯例,app-component.xml 在项目的包名根路径(root package)(定义在 metadata.xml),这个根路径跟 build.gradle 中定义的项目工件的组名称一样:

App-Component-Id == root-package == cuba.artifact.group == e.g. 'com.company.sample'

使用 CUBA Studio 来自动生成 app-component.xml 文件和当前项目的组装清单记录。

用第三方依赖作为 appJars:

如果需要被引入组件的第三方依赖跟应用程序的工件(app-comp-core 或者 app-comp-web)一起部署到 tomcat/webapps/app[-core]/WEB-INF/lib/ 目录,需要将这些第三方依赖作为 appJar 类库添加:

<module blocks="core"
        dependsOn="global,jm"
        name="core">
    <artifact appJar="true"
              name="cuba-jm-core"/>
    <artifact classifier="db"
              configuration="dbscripts"
              ext="zip"
              name="cuba-jm-core"/>
    <!-- Specify only the artifact name for your appJar 3rd party library -->
    <artifact name="javamelody-core"
              appJar="true"
              library="true"/>
</module>

如果不希望将项目作为 app 组件使用,需要在 build.gradledeploy 任务中将这些依赖作为 appJars 添加:

configure(coreModule) {
    //...
    task deploy(dependsOn: assemble, type: CubaDeployment) {
        appName = 'app-core'
        appJars('app-global', 'app-core', 'javamelody-core')
    }
    //...
}

A.2. context.xml

context.xml 文件是应用程序部署到 Apache Tomcat 服务的描述文件。在部署的程序中,这个文件在 web 应用程序目录或者 WAR 文件的 META-INF 目录,比如 tomcat/webapps/app-core/META-INF/context.xml。在应用程序项目中,这个文件在 core, webportal 模块的 /web/META-INF 目录。

Middleware block 中,此文件的主要目的是用来定义使用 JNDI 名称的 JDBC 数据源,JNDI 名称定义在 cuba.dataSourceJndiName 应用程序属性中。

从 CUBA 7.2 开始,可以使用一个简化的方式在应用程序属性中定义数据源,参阅 为应用程序配置数据源

PostgreSQL 数据源定义示例:

<Resource
  name="jdbc/CubaDS"
  type="javax.sql.DataSource"
  maxIdle="2"
  maxTotal="20"
  maxWaitMillis="5000"
  driverClassName="org.postgresql.Driver"
  username="cuba"
  password="cuba"
  url="jdbc:postgresql://localhost/sales"/>

Microsoft SQL Server 2005 数据源定义示例:

<Resource
  name="jdbc/CubaDS"
  type="javax.sql.DataSource"
  maxIdle="2"
  maxTotal="20"
  maxWaitMillis="5000"
  driverClassName="net.sourceforge.jtds.jdbc.Driver"
  username="sa"
  password="saPass1"
  url="jdbc:jtds:sqlserver://localhost/sales"/>

Microsoft SQL Server 2008+ 数据源定义示例:

<Resource
  name="jdbc/CubaDS"
  type="javax.sql.DataSource"
  maxIdle="2"
  maxTotal="20"
  maxWaitMillis="5000"
  driverClassName="com.microsoft.sqlserver.jdbc.SQLServerDriver"
  username="sa"
  password="saPass1"
  url="jdbc:sqlserver://localhost;databaseName=sales"/>

Oracle 数据源定义示例:

<Resource
  name="jdbc/CubaDS"
  type="javax.sql.DataSource"
  maxIdle="2"
  maxTotal="20"
  maxWaitMillis="5000"
  driverClassName="oracle.jdbc.OracleDriver"
  username="sales"
  password="sales"
  url="jdbc:oracle:thin:@//localhost:1521/orcl"/>

MySQL 数据源定义示例:

<Resource
  type="javax.sql.DataSource"
  name="jdbc/CubaDS"
  maxIdle="2"
  maxTotal="20"
  maxWaitMillis="5000"
  driverClassName="com.mysql.jdbc.Driver"
  password="cuba"
  username="cuba"
  url="jdbc:mysql://localhost/sales?useSSL=false&amp;allowMultiQueries=true"/>

下面这一行禁用了 HTTP 会话的序列化:

<Manager pathname=""/>

A.3. default-permission-values.xml

这种类型的文件用在 CUBA 版本 小于 7.2 的项目中,或者用于迁移到新平台的项目,需要保留之前计算有效权限的方法,参阅遗留版本角色和权限许可章节。

如果没有角色为需要权限的目标显式定义权限值的时候,就会使用默认权限值。对于有拒绝访问权限的用户来说,这个文件很多时候是需要的:因为如果没有这个文件,有拒绝访问角色的用户默认情况下是连主窗口界面和过滤器界面都不能访问的。

该文件需要在 core 模块创建。

文件的地址通过 cuba.defaultPermissionValuesConfig 应用程序属性定义。如果应用程序中这个属性没有定义,则会使用默认的 cuba-default-permission-values.xml 文件。

这个文件有如下结构:

default-permission-values - 根元素,只有一个嵌套元素 - permission

permission - 权限许可:定义对象类型和针对这个对象类型的权限。

permission 有三个属性:

  • target - 许可对象:定义权限应用的特殊对象。根据许可类型来定义这个属性的格式:对于界面 - 界面的 id,对于实体操作 - 实体的 id 和操作类型,比如,target="sec$Filter:read",等等。

  • value - 许可值。可以是 0 或者 1,分别表示拒绝或者许可。

  • type - 权限许可对象的类型:

    • 10 - screen - 界面,

    • 20 - entity operation - 实体操作,

    • 30 - entity attribute - 实体属性,

    • 40 - application-specific permission - 程序特定功能权限,

    • 50 - UI component - 界面组件.

示例:

<?xml version="1.0" encoding="UTF-8"?>
<default-permission-values xmlns="http://schemas.haulmont.com/cuba/default-permission-values.xsd">
    <permission target="dynamicAttributesConditionEditor" value="0" type="10"/>
    <permission target="dynamicAttributesConditionFrame" value="0" type="10"/>
    <permission target="sec$Filter:read" value="1" type="20"/>
    <permission target="cuba.gui.loginToClient" value="1" type="40"/>
</default-permission-values>

A.4. dispatcher-spring.xml

这种类型的文件为包含 Spring MVC 控制器(controller)的客户端 blocks 定义了一个额外的 Spring 框架容器的配置。

这个给控制器创建的额外 Spring 容器是使用 Spring 主容器(配置在 spring.xml 文件)作为父容器来创建的。因此,在这个控制器容器内的 bean 可以使用主容器的 bean,但是主容器的 bean 却“看不见”控制器容器内的 bean。

项目的 dispatcher-spring.xml 文件是通过 cuba.dispatcherSpringContextConfig 应用程序属性来定义的。

平台的 webportal 模块已经分别包含了这个配置文件:cuba-dispatcher-spring.xmlcuba-portal-dispatcher-spring.xml

如果在项目中创建了 Spring MVC 控制器(比如在 web 模块),需要添加如下配置:

  • 假设控制器都在 com.company.sample.web.controller 包内创建,创建 modules/web/src/com/company/sample/web/dispatcher-config.xml 文件并且添加如下内容:

    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:context="http://www.springframework.org/schema/context"
           xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.2.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.2.xsd">
    
        <context:annotation-config/>
    
        <context:component-scan base-package="com.company.sample.web.controller"/>
    
    </beans>
  • web-app.properties 文件的 cuba.dispatcherSpringContextConfig 属性添加这个配置文件:

    cuba.dispatcherSpringContextConfig = +com/company/sample/web/dispatcher-config.xml

web 模块中定义的控制器可以通过 dispatcher servlet 的 URL 地址访问,默认是以 /dispatch 开头。示例:

http://localhost:8080/app/dispatch/my-controller-endpoint

portal 模块中定义的控制器可以通过 web 应用程序的根节点访问,比如:

http://localhost:8080/app-portal/my-controller-endpoint

这个类型的文件在 Web 客户端使用,定义应用程序的主菜单结构。

这个文件的路径由 cuba.menuConfig 应用程序属性指定。当在 Studio 里面创建新项目的时候,会在 web 模块包的根目录创建 web-menu.xml 文件,比如 modules/web/src/com/company/sample/web-menu.xml

menu-config – XML 根节点元素。menu-config 的元素组成了一个树状结构,这里 menu 元素是树枝,itemseparator 元素是树叶。

menu 元素的属性:

  • id – 元素的标识符。

  • caption - 菜单元素的名称。如果未设置,名称会按照下文介绍的规则确定。

  • description - 光标悬浮提示窗的内容。可以使用主语言包里面的本地化语言。

  • icon - 菜单元素的图标。参考 icon 了解细节。

  • insertBeforeinsertAfter – 设置此菜单项应当放在特定的元素或者特定的名称的菜单项之前或之后。这个属性用来在应用程序组件菜单文件定义的菜单中找一个合适的位置插入此菜单项。insertBeforeinsertAfter 不能同时使用。

  • stylename - 为菜单项定义一个样式名称。参考 主题 了解细节。

item 元素的属性:

  • id – 元素的唯一标识符。如果没有定义任何 screenbeanclass 属性,此 id 用来指向具有相同 id 值的界面。当用户点击菜单项时,会在程序主窗口打开对应的界面。

    <item id="sample_Foo.browse"/>
  • caption - 菜单元素的名称。如果未设置,名称会按照下文介绍的规则确定。

    <item id="sample_Foo.browse" caption="mainMsg://fooBrowseCaption"/>
  • screen - 界面标识符。可以用这个标识符在菜单中多次包含同一界面。当用户点击菜单项时,会在程序主窗口打开对应的界面。

    <item id="foo1" screen="sample_Foo.browse"/>
    <item id="foo2" screen="sample_Foo.browse"/>
  • bean - bean 名称。必须跟 beanMethod 一起使用。当用户点击菜单项时,会调用 bean 的此方法。

    <item bean="sample_FooProcessor" beanMethod="processFoo"/>
  • class - 实现了 RunnableConsumer<Map<String, Object>>MenuItemRunnable 接口的类全名。当用户点击菜单项时,会创建指定类的一个对象,并调用其方法。

    <item class="com.company.sample.web.FooProcessor"/>
  • description - 光标悬浮提示窗显示的文字。可以从主语言包中使用本地化语言。

    <item id="sample_Foo.browse" description="mainMsg://fooBrowseDescription"/>
  • shortcut – 此菜单项的快捷键。可以用 ALT, CTRL, SHIFT 功能键,用 - 分隔,比如:

    shortcut="ALT-C"
    shortcut="ALT-CTRL-C"
    shortcut="ALT-CTRL-SHIFT-C"

    快捷键也可以通过应用程序属性来配置,然后在 menu.xml 文件中通过下列方式来使用:

    shortcut="${sales.menu.customer}"
  • openType – 界面打开模式。对应于 OpenMode 枚举:NEW_TABTHIS_TABDIALOG。默认值是 NEW_TAB

  • icon - 菜单元素的图标。参考 icon 了解细节。

  • insertBefore, insertAfter – 设定此项应当在一个特定元素或者标识符指定的特定菜单项之前或者之后。

  • resizable – 只跟 DIALOG 界面打开模式有关。控制界面是否能改变大小。可选值:truefalse。默认情况下主菜单不会影响弹出窗口的改变大小的功能。

  • stylename - 为菜单项定义一个样式名称。参考 主题 了解细节。

    • item 的子元素:

菜单文件的示例:

<menu-config xmlns="http://schemas.haulmont.com/cuba/menu.xsd">

    <menu id="sales" insertBefore="administration">
        <item id="sales_Order.lookup"/>

        <separator/>

        <item id="sales_Customer.lookup" openType="DIALOG"/> (1)

        <item screen="sales_CustomerInfo">
            <properties>
                <property name="stringParam" value="some string"/> (2)
                <property name="customerParam" (3)
                          entityClass="com.company.demo.entity.Customer"
                          entityId="0118cfbe-b520-797e-98d6-7d54146fd586"/>
            </properties>
        </item>

        <item screen="sales_Customer.edit">
            <properties>
                <property name="entityToEdit" (4)
                          entityClass="com.company.demo.entity.Customer"
                          entityId="0118cfbe-b520-797e-98d6-7d54146fd586"
                          entityView="_local"/>
            </properties>
        </item>
    </menu>

</menu-config>
1 - 以弹出框的方式打开界面。
2 - 调用 setStringParam() 方法,传递 some string
3 - 调用 setCustomerParam() 方法,传递使用给定 id 加载的实体实例。
4 - 调用 StandardEditorsetEntityToEdit() 方法,传递使用给定 id 和视图加载的实体实例。
menu-config.sales=Sales
menu-config.sales_Customer.lookup=Customers

如果没设置 id,菜单元素的名称会通过类名(如果设置了 class 属性)或者 bean 名称和 bean 方法名称(如果设置了 bean 属性)生成,因此,推荐设置 id 属性。

A.6. metadata.xml

这种类型的文件用来注册自定义的数据类型以及非持久化实体并且设置元注解(meta-annotations)

项目的 metadata.xml 文件通过 cuba.metadataConfig 应用程序属性来指定。

文件有如下结构:

metadata – 根元素。

metadata 的元素:

  • datatypes - 自定义类型的一个可选描述。

    datatypes 的元素:

    • datatype - 数据类型描述,有如下属性:

      • id - 标识符,用来在 @MetaProperty 注解中表示这个数据类型。

      • class - 定义实现类

      • sqlType - 可选参数,用来保存此数据类型值的数据库 SQL 类型(数据库字段类型)。CUBA Studio 会在生成数据库脚本的时候使用这个 SQL 类型。参考 自定义数据类型示例 了解细节。

      datatype 元素可以包含其它依赖这个数据类型实现的属性。

  • metadata-model – 项目元数据模型描述符。

    metadata-model 的属性:

    • root-package – 项目包的根目录。

    metadata-model 的元素:

    • class – 非持久化实体类

  • annotations – 包含实体元注解的设置。

    annotations 元素包含 entity 元素定义元注解设置的实体类。每个 entity 元素必须包含 class 属性来指定实体类,以及一组 annotation 元素。

    annotation 元素用来定义元注解,用 name 属性来指定元注解的名称。元注解的其它属性通过一组 attribute 子元素来指定。

示例:

<metadata xmlns="http://schemas.haulmont.com/cuba/metadata.xsd">

    <metadata-model root-package="com.sample.sales">
        <class>com.sample.sales.entity.SomeNonPersistentEntity</class>
        <class>com.sample.sales.entity.OtherNonPersistentEntity</class>
    </metadata-model>

    <annotations>
        <entity class="com.haulmont.cuba.security.entity.User">
            <annotation name="com.haulmont.cuba.core.entity.annotation.TrackEditScreenHistory">
                <attribute name="value" value="true" datatype="boolean"/>
            </annotation>

            <annotation name="com.haulmont.cuba.core.entity.annotation.EnableRestore">
                <attribute name="value" value="true" datatype="boolean"/>
            </annotation>
        </entity>

        <entity class="com.haulmont.cuba.core.entity.Category">
            <annotation name="com.haulmont.cuba.core.entity.annotation.SystemLevel">
                <attribute name="value" value="false" datatype="boolean"/>
            </annotation>
        </entity>
    </annotations>

</metadata>

A.7. permissions.xml

这个类型的文件用在 Web 客户端 block,用来注册特殊的用户权限

文件的路径通过 cuba.permissionConfig 应用程序属性指定。当通过 Studio 创建新项目的时候,会在 web 模块包的根目录创建 web-permissions.xml 文件,比如 modules/web/src/com/company/sample/web-permissions.xml

这个文件有如下结构:

permission-config - 根节点元素。

permission-config 的元素:

  • specific - 特殊权限描述符。

    specific 的元素:

    • category - 权限种类,用来给角色编辑界面的权限做分组。id 属性用来作为从主语言包种获取种类的本地化语言翻译的键值。

    • permission - 已配置的权限。id 属性用来通过 Security.isSpecificPermitted() 方法获取权限值,也作为从主语言包种获取权限名称本地化语言翻译的键值,作为显示在角色编辑界面权限的名称。

示例:

<permission-config xmlns="http://schemas.haulmont.com/cuba/permissions.xsd">
    <specific>
        <category id="app">
            <permission id="app.payments.exportTransactionsToPdf"/>
            <permission id="app.orders.modifyInvoicedOrders"/>
        </category>
    </specific>
</permission-config>

如需对分类和特殊权限的名称做本地化,可以在 主语言包 配置:

permission-config.app = Demo application permissions
permission-config.app.payments.exportTransactionsToPdf = Export transactions to pdf
permission-config.app.orders.modifyInvoicedOrders = Modify invoiced orders

A.8. persistence.xml

这种类型的文件是 JPA 的标准文件,用来注册持久化实体以及 ORM 框架参数配置。

项目的 persistence.xml 文件通过应用程序属性 cuba.persistenceConfig 定义。

当 Middleware block 启动时,这些文件会被组合成单一的 persistence.xml 文件,保存在应用程序的 work folder 目录。文件的顺序很重要,因为列表中后面的文件会覆盖前面文件的 ORM 参数设置。

一个文件示例:

<persistence xmlns="http://java.sun.com/xml/ns/persistence" version="1.0">
  <persistence-unit name="sales" transaction-type="RESOURCE_LOCAL">
      <class>com.sample.sales.entity.Customer</class>
      <class>com.sample.sales.entity.Order</class>
  </persistence-unit>
</persistence>

A.9. remoting-spring.xml

这种类型的文件为 Middleware block 配置了一个额外的 Spring Framework 容器,用来暴露 service 和其它中间件组件,以便客户端层访问(从这里开始称之为 remote access container - 远程访问容器)。

项目的 remoting-spring.xml 文件通过 cuba.remotingSpringContextConfig 应用程序属性指定。

远程访问容器使用 Spring 主容器(在 spring.xml 文件配置主容器)作为父容器进行创建。因此,远程访问容器里的 bean 可以使用主容器内的 bean,但是主容器内的 bean 却“看不见”远程访问容器内的 bean。

远程访问的主要目的是使 Middleware 的服务能从客户端级别通过 Spring HttpInvoker 机制访问。在 cuba 应用程序组件中的 cuba-remoting-spring.xml 文件定义了 RemoteServicesBeanCreator 类型的 servicesExporter bean,这个 bean 从主容器获得所有的 service 类,然后 export 他们。作为通常带注解的 service 的补充,远程访问容器 export 了一批特殊的 bean,比如 AuthenticationService

还有,cuba-remoting-spring.xml 文件定义了一个基础包用来作为查找带有注解的 Spring MVC 下载和上传控制器类的入口。

项目中的 remoting-spring.xml 文件只有在使用了特殊的 Spring MVC 控制器的时候才需要创建。项目中的 services 会通过定义在 cuba 应用程序组件中标准的 servicesExporter bean 来引入。

A.10. spring.xml

这个类型的文件为每个应用程序 block 配置 Spring Framework 的主容器。

项目的 spring.xml 文件路径通过 cuba.springContextConfig 应用程序属性指定。

大多数主容器的配置都通过 bean 的注解完成(比如 @Component, @Service, @Inject 等等),因此项目中 spring.xml 的唯一必须部分就是定义 context:component-scan 元素,用来指定查找注解类的基本 Java 包名。示例:

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

其它的配置取决于容器配置的 block 本身,比如为 Middleware block 配置 JMX-beans,或者为客户端 block 配置服务导入

A.11. views.xml

这个类型的文件用来描述共享视图(views),参考 创建视图

views – 根节点元素。

views 的元素:

  • viewview 视图描述元素。

    view 属性:

    • class – 实体类。

    • entity – 实体名称,比如 sales_Order。这个属性可以用来替代 class 属性。

    • name – 仓库中的视图名称,实体范围内需要名称唯一。

    • systemProperties – 启用包含定义在持久化实体 BaseEntity 基类和 Updatable 接口中的基础接口系统属性。此参数为可选参数,默认为 true

    • overwrite – 启用覆盖视图功能,通过同一类以及部署在仓库(repository)的名称来覆盖同名视图。可选参数,默认为 false

    • extends – 指定一个用来继承实体属性的实体视图。比如,声明 extends="_local",这样会将实体的所有 local attributes 添加到当前视图。也是可选参数。

    view 的元素:

    • propertyViewProperty 视图属性描述元素。

    property 的属性:

    • name – 实体属性名称。

    • view – 对于引用类型属性,设定一个实体关联的视图名称,用来加载实体的属性。

    • fetch - 对于引用类型属性,设定如何从数据库取关联实体。参考 视图 了解细节。

    property 的元素:

    • property – 跟实体属性描述关联。这个用来在当前描述中定义一个关联实体的无命名单行(inline)视图。

  • include – 包含另外一个 views.xml 文件。

    include 的属性:

    • file – 文件路径,符合 Resources 接口规范。

示例:

<views xmlns="http://schemas.haulmont.com/cuba/view.xsd">

  <view class="com.sample.sales.entity.Order"
        name="order-with-customer"
        extends="_local">
      <property name="customer" view="_minimal"/>
  </view>

  <view class="com.sample.sales.entity.Item"
        name="itemsInOrder">
      <property name="quantity"/>
      <property name="product" view="_minimal"/>
  </view>

  <view class="com.sample.sales.entity.Order"
        name="order-with-customer-defined-inline"
        extends="_local">
      <property name="customer">
          <property name="name"/>
          <property name="email"/>
      </property>
  </view>

</views>

也可以参考 cuba.viewsConfig 应用程序属性。

A.12. web.xml

web.xml 是 Java web 应用程序的标准描述文件。需要为 Middleware、web 客户端以及 Web Portal 客户端 block 创建此文件。

在一个应用程序项目中,web.xml 文件在相应模块web/WEB-INF 目录。

  • Middleware block(core 项目模块)的 web.xml 文件有如下内容:

    <?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <web-app xmlns="http://java.sun.com/xml/ns/javaee"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
             version="3.0">
        <!-- Application properties config files -->
        <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>
        <!--Application components-->
        <context-param>
            <param-name>appComponents</param-name>
            <param-value>com.haulmont.cuba com.haulmont.reports</param-value>
        </context-param>
        <listener>
            <listener-class>com.haulmont.cuba.core.sys.AppContextLoader</listener-class>
        </listener>
        <servlet>
            <servlet-name>remoting</servlet-name>
            <servlet-class>com.haulmont.cuba.core.sys.remoting.RemotingServlet</servlet-class>
            <load-on-startup>1</load-on-startup>
        </servlet>
        <servlet-mapping>
            <servlet-name>remoting</servlet-name>
            <url-pattern>/remoting/*</url-pattern>
        </servlet-mapping>
    </web-app>

    context-param 元素定义了当前 web 应用程序 ServletContext 对象的初始化参数。应用程序组件列表定义在 appComponents 参数,应用程序属性文件列表定义在 appPropertiesConfig 参数。

    listener 元素定义了实现 ServletContextListener 接口的监听类。Middleware block 使用 AppContextLoader 类作为监听器。这个类初始化了 AppContext

    然后是 Servlet 描述,包括 RemotingServlet 类,对于 Middleware block 来说,这是必须的。这个 servlet 可以通过 /remoting/* URL 来访问,跟远程访问容器相关联,参考 remoting-spring.xml

  • Web 客户端 block(web 项目模块)的 web.xml 文件有如下内容:

    <?xml version="1.0" encoding="UTF-8"?>
    <web-app xmlns="http://java.sun.com/xml/ns/javaee"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
             version="3.0">
    
        <!-- Application properties config files -->
        <context-param>
            <param-name>appPropertiesConfig</param-name>
            <param-value>
                classpath:com/company/demo/web-app.properties
                /WEB-INF/local.app.properties
                "file:${app.home}/local.app.properties"
            </param-value>
        </context-param>
        <!--Application components-->
        <context-param>
            <param-name>appComponents</param-name>
            <param-value>com.haulmont.cuba com.haulmont.reports</param-value>
        </context-param>
    
        <listener>
            <listener-class>com.vaadin.server.communication.JSR356WebsocketInitializer</listener-class>
        </listener>
        <listener>
            <listener-class>com.haulmont.cuba.web.sys.WebAppContextLoader</listener-class>
        </listener>
    
        <servlet>
            <servlet-name>app_servlet</servlet-name>
            <servlet-class>com.haulmont.cuba.web.sys.CubaApplicationServlet</servlet-class>
            <async-supported>true</async-supported>
        </servlet>
        <servlet>
            <servlet-name>dispatcher</servlet-name>
            <servlet-class>com.haulmont.cuba.web.sys.CubaDispatcherServlet</servlet-class>
            <load-on-startup>1</load-on-startup>
        </servlet>
        <servlet-mapping>
            <servlet-name>dispatcher</servlet-name>
            <url-pattern>/dispatch/*</url-pattern>
        </servlet-mapping>
        <servlet-mapping>
            <servlet-name>app_servlet</servlet-name>
            <url-pattern>/*</url-pattern>
        </servlet-mapping>
    
        <filter>
            <filter-name>cuba_filter</filter-name>
            <filter-class>com.haulmont.cuba.web.sys.CubaHttpFilter</filter-class>
            <async-supported>true</async-supported>
        </filter>
        <filter-mapping>
            <filter-name>cuba_filter</filter-name>
            <url-pattern>/*</url-pattern>
        </filter-mapping>
    </web-app>

    context-param 元素中,定义了应用程序组件列表和应用程序属性文件列表。

    Web 客户端 block 使用 WebAppContextLoader 类作为 ServletContextListener

    JSR356WebsocketInitializer 是支持 WebSockets 协议需要的监听器。

    CubaApplicationServlet 提供了基于 Vaadin 框架实现的通用用户界面

    CubaDispatcherServlet 为 Spring MCV 控制器初始化了一个额外的 Spring context。这个 context 通过 dispatcher-spring.xml 文件来配置。

Appendix B: 应用程序属性

本附录章节按字母顺序介绍所有可用的应用程序属性

cuba.additionalStores

定义应用中使用的附加数据存储的名称。

在所有标准的 blocks 中都可以使用。

示例:

cuba.additionalStores = db1, mem1
cuba.allowQueryFromSelected

启用通用过滤器的级联过滤模式。参考 级联查询(Sequential Queries)

默认值: true

保存在数据库。

配置接口: GlobalConfig

可以在 Web 客户端和 Middleware 这两个 block 使用。

cuba.anonymousLogin

用来创建匿名用户会话的用户名称。

默认值: anonymous

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.automaticDatabaseUpdate

定义服务器是否在应用程序启动时运行数据库更新脚本

从 CUBA 7.2 开始,可以控制为主数据存储和附加数据存储分别执行数据库更新脚本:对主数据存储,使用 cuba.automaticDatabaseUpdate_MAIN 应用程序属性,对于附加数据存储,使用 cuba.automaticDatabaseUpdate_<store_name> 格式的应用程序属性。特定的属性比通用的优先级更高。

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.availableLocales

支持的用户界面语言列表。

属性格式: {language_name1}|{language_code_1};{language_name2}|{language_code_2};…​

示例:

cuba.availableLocales=French|fr;English|en

{language_name} – 显示在可用语言列表中的语言名称。比如这个列表会被用在登录界面提供给用户选择系统语言,也在用户编辑界面,编辑用户的语言。

{language_code} – 对应于 Locale.getLanguage() 方法返回的语言代码。用来作为语言包文件名称的后缀,比如,messages_fr.properties

如果语言列表中没有跟用户操作系统语言相匹配的条目,那么 cuba.availableLocales 属性定义的语言列表的第一个语言将被用来作为默认语言。否则,即会选择跟用户操作系统语言相匹配的做为默认语言。

默认值: English|en;Russian|ru;French|fr

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.backgroundWorker.maxActiveTasksCount

活跃的后台任务的最大值。

默认值: 100

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.backgroundWorker.timeoutCheckInterval

定义检查后台任务超时的间隔,单位是毫秒。

默认值: 5000

配置接口: ClientConfig

可以在 web 客户端使用。

cuba.bruteForceProtection.enabled

启用针对野蛮破解密码的保护措施。

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.bruteForceProtection.blockIntervalSec

超过最大失败登录尝试次数之后屏蔽登录的时间间隔,单位是秒,需要先启用 cuba.bruteForceProtection.enabled

默认值: 60

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.bruteForceProtection.maxLoginAttemptsNumber

针对用户名和登录 IP 设定的最大失败登录尝试次数,需要先启用 cuba.bruteForceProtection.enabled

默认值: 5

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.checkConnectionToAdditionalDataStoresOnStartup

如果设置为 true,框架会在应用程序启动时检查所有附加数据存储的连接。如果连接失败,日志会记录失败消息。注意,这种检查会使得启动过程变慢。

默认值: false

可以在 Middleware block 使用。

cuba.checkPasswordOnClient

当设置为 false(默认值)时,客户端块 block 的 LoginPasswordLoginProvider 将用户密码明文发送给中间件的 AuthenticationService.login() 方法。在客户端和中间件 block 共同位于同一 JVM 中的情况下,这是合适的处理方式。对于客户端块(block)位于网络上的其它计算机上的分布式部署的情况,客户端和中间件之间的连接应使用 SSL 加密。

如果设置为 true,LoginPasswordLoginProvider 本身将通过输入的登录名加载 User 实体并检查密码。如果密码与加载的密码哈希匹配,则提供程序使用cuba.trustedClientPassword 属性中指定的密码作为可信客户端执行登录。此模式使您无需在受信任网络中的客户端和中间件之间设置 SSL 连接,同时不会向网络公开用户密码:仅传输哈希值。但请注意,可信客户端密码仍然通过网络传输,因此受 SSL 保护的连接仍然更加安全。

默认值: false

接口: WebAuthConfigPortalConfig

可在 Web 和 Porta block 使用。

cuba.cluster.enabled

启用 Middleware 服务集群中各个服务之间的互相交互。参考 配置多个 Middleware 服务交互

默认值: false

可以在 Middleware block 使用。

cuba.cluster.jgroupsConfig

JGroups 配置文件的路径。平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

示例:

cuba.cluster.jgroupsConfig = my_jgroups_tcp.xml

默认值: jgroups.xml

可以在 Middleware block 使用。

cuba.cluster.messageSendingQueueCapacity

限制 Middleware 集群中消息队列的长度。当消息队列超过了最大长度,新消息会被拒绝。

默认值: Integer.MAX_VALUE

可以在 Middleware block 使用。

cuba.cluster.stateTransferTimeout

设置节点启动时从集群接收最新状态的超时时间。单位是毫秒。

默认值: 10000

可以在 Middleware block 使用。

cuba.confDir

为应用程序 block 设置配置文件目录的位置。

默认值: ${app.home}/${cuba.webContextName}/conf,指向应用程序根目录下的一个目录。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.connectionReadTimeout

在客户端 block 设置连接 Middleware 读取超时的时限。平台会将非负值传递给 URLConnectionsetReadTimeout() 方法。

也可参考 cuba.connectionTimeout

默认值: -1

可以在 Web 客户端,Web Portal blocks 使用。

cuba.connectionTimeout

在客户端 block 设置连接 Middleware 超时的时限。平台会将非负值传递给 URLConnectionsetConnectTimeout() 方法。

也可参考 cuba.connectionReadTimeout

默认值: -1

可以在 Web 客户端,Web Portal blcoks 使用。

cuba.connectionUrlList

为客户端 blocks 设置连接 Middleware 服务的 URL。

此属性的值应该包含多个用英文逗号分隔 URL http[s]://host[:port]/app-corehost 是服务器名称,port 是服务器端口,app-core 是 Middleware web app 的名称。比如:

cuba.connectionUrlList = http://localhost:8080/app-core

当使用 Middleware 服务集群的时候,这些服务的地址需要用英文逗号分隔:

cuba.connectionUrlList = http://server1:8080/app-core,http://server2:8080/app-core

配置接口: ClientConfig

可以在 Web 客户端,Web Portal blcoks 使用。

cuba.creditsConfig

累加属性定义 credits.xml 文件。此文件包含应用程序使用的软件组件信息

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Web 客户端 block 使用。

示例:

cuba.creditsConfig = +com/company/base/credits.xml
cuba.crossDataStoreReferenceLoadingBatchSize

DataManager不同数据存储批量加载关联实体的最大值。

默认值: 50

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.dataManagerBeanValidation

设置 DataManager 在保存实体时需要进行 bean 验证

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.dataManagerChecksSecurityOnMiddleware

配置在 Middleware,确定 DataManager 是否检查实体操作的 权限 和内存约束

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.dataSourceJndiName

定义应用数据库连接中使用的 javax.sql.DataSource 的 JNDI 名称。

默认值: java:comp/env/jdbc/CubaDS

可以在 Middleware block 使用。

cuba.dataDir

为应用程序 block 设置工作目录的位置。

默认值: ${app.home}/${cuba.webContextName}/work,指向应用程序根目录下的一个目录。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.dbDir

设置数据库脚本目录的位置。

对于 快速部署,默认值:${catalina.home}/webapps/${cuba.webContextName}/WEB-INF/db,指向 Tomcat 中 web app 的 WEB-INF/db 子目录。

对于 WAR 和 UberJAR 部署情况:web-inf:db,指向 WAR 或者 UberJAR 内的 WEB-INF/db 目录。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.dbmsType

定义 DBMS 类型。跟 cuba.dbmsVersion 一起作用,影响对于 DBMS 集成接口实现的选取,以及查找数据库初始化和更新脚本。

参考 DBMS 类型 了解细节。

默认值: hsql

可以在 Middleware block 使用。

cuba.dbmsVersion

可选属性,设置数据库版本。跟 cuba.dbmsType 一起作用,影响对于 DBMS 集成接口实现的选取,以及查找数据库初始化和更新脚本。

参考 DBMS 类型 了解细节。

默认值: none

可以在 Middleware block 使用。

cuba.defaultPermissionValuesConfig

当使用遗留版本角色和权限许可时,定义包含用户默认权限的一组文件。当没有为许可对象设置角色的时候,会使用默认权限值。通常用来为“拒绝”角色使用,参考 default-permission-values.xml 章节了解更多信息。

默认值: cuba-default-permission-values.xml

可以在 Middleware block 使用。

示例:

cuba.defaultPermissionValuesConfig = +my-default-permission-values.xml
cuba.defaultQueryTimeoutSec

设置默认的数据库事务超时时限.

默认值: 0 (no timeout).

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.disableEntityEnhancementCheck

禁用启动检查,该检查用来确保所有实体都已经加强。

默认值: true

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.disableEscapingLikeForDataStores

定义一组数据存储,对于这些数据存储,平台会在 filters 中对使用了 LIKE 操作符的 JPQL 查询禁用转义。

保存在数据库。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.disableOrmXmlGeneration

扩展实体,禁用自动生成 orm.xml 文件的功能。

默认值: false(如果存在扩展实体,则会自动创建 orm.xml)。

可以在 Middleware block 使用。

cuba.dispatcherSpringContextConfig

累加属性,为客户端 block 定义 dispatcher-spring.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Web 客户端和 Web Portal blocks 使用。

示例:

cuba.dispatcherSpringContextConfig = +com/company/sample/portal-dispatcher-spring.xml
cuba.download.directories

定义一组文件目录,Middleware 可以从这些文件目录通过 com.haulmont.cuba.core.controllers.FileDownloadController 下载文件。比如,web 客户端系统菜单的 Administration > Server Log 就是利用这个机制下载 log 文件进行展示。

目录列表需要使用英文分号分隔。

默认值: ${cuba.tempDir};${cuba.logDir} (可以从临时文件夹日志文件夹目录下载文件)。

可以在 Middleware block 使用。

cuba.email.*

配置电子邮件发送参数 有关于发送邮件参数的介绍。

cuba.fileStorageDir

定义文件存储目录结构的根目录。更多信息,参考 标准文件存储实现

默认值: null

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.enableDeleteStatementInSoftDeleteMode

向后兼容的开关。如果设置为 true,在软删除模式开启的情况下,开启为软删除的实体执行 delete from 的 JPQL 语句(软删除开启的情况,对实体只是运行 update 而非 delete)。这样的 delete 语句会被转换成删除所有没有标记为“已删除”的数据。这样的话,有点违反直观理解,所以默认情况下是关闭此功能的。

默认值: false

可以在 Middleware block 使用。

cuba.enableSessionParamsInQueryFilter

向后兼容的开关。如果设置为 false,在数据源查询过滤器界面过滤器组件的过滤条件会被应用一次,至少会使用一个参数;会话(session)参数不会起作用。

默认值: true

可以在 web 客户端 block 使用。

cuba.entityAttributePermissionChecking

如果设置为 trueDataManager会做实体属性权限检查。如果设置为 false,属性权限检查则在 Generic UI中的数据感知组件和 REST API 中。

默认值: false

保存在数据库。

可以在 Middleware block 使用。

cuba.entityLog.enabled

开启实体日志机制。

默认值: true

保存在数据库。

配置接口: EntityLogConfig

可以在 Middleware block 使用。

cuba.groovyEvaluationPoolMaxIdle

在执行 Scripting.evaluateGroovy() 方法的过程中,设置资源池中未使用的编译过的 Groovy 表达式的最大值。当需要集中执行 Groovy 表达式的时候,推荐将这个值设置得大一些,比如,按照应用程序目录的数量来设置。

默认值: 8

在所有标准的 blocks 中都可以使用。

cuba.groovyEvaluatorImport

在执行脚本的时候,定义一组需要被所有 Groovy 表达式引入的类。

列表中的类名需要使用英文逗号或者分号分隔。

默认值: com.haulmont.cuba.core.global.PersistenceHelper

在所有标准的 blocks 中都可以使用。

示例:

cuba.groovyEvaluatorImport = com.haulmont.cuba.core.global.PersistenceHelper,com.abc.sales.CommonUtils
cuba.gui.genericFilterApplyImmediately

当设置成 true 时,过滤器会以即时模式工作,每个对于过滤器参数的调整都会立即生效,数据会自动刷新。当设置成 false 时,过滤器会使用显式操作模式。此时,过滤器只有在点击 Search 按钮时才会生效。参阅 applyImmediately 过滤器属性。

默认值: true

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterChecking

影响的过滤器组件行为。

当设置为 true,不允许执行不指定参数的过滤器。

默认值: false

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterColumnsCount

过滤器组件定义含有过滤条件的列的数量。

默认值: 3

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterConditionsLocation

定义在过滤器组件中条件面板的位置。两种位置可以选择:top(在过滤器控制器元素之上)和 bottom(在过滤器控制器元素之下)。

默认值: top

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterControlsLayout

过滤器组件的控制器布局设置模板。每个控制器有这样的格式:[component_name | options-comma-separated],比如,[pin | no-caption, no-icon]

可用的控制器:

  • filters_popup - 选择过滤器的弹窗按钮,整合了 Search 按钮功能。

  • filters_lookup - 选择过滤器的查找字段。需要单独添加 Search 按钮。

  • search - Search 按钮。如果使用 filters_popup 则不需要添加。

  • add_condition - 添加新条件的按钮。

  • spacer - 控制器之间空白的分隔符。

  • settings - Settings 按钮。设置在 Settings 弹窗中显示的 action 选项的名称。(参考下面)。

  • max_results - 控制器组,用来设置选择记录的最大值。

  • fts_switch - 用来切换到全文检索(Full-Text Search)模式的复选框。

以下这些操作可以在 settings 中作为选项使用:save - 保存, save_as - 另存为, edit - 编辑, remove - 删除, pin - 固定位置, make_default - 设置默认, save_search_folder - 保存搜索目录, save_app_folder - 保存应用目录, clear_values - 清空

这些操作也可以在 Settings 弹窗外作为单独的控制器使用。这种情况下,它们可以做如下设置:

  • no-icon - 设置动作按钮是否不带图标,示例: [save | no-icon]

  • no-caption - 设置动作按钮是否不带名称,示例: [pin | no-caption]

默认值:

[filters_popup] [add_condition] [spacer] \
[settings | save, save_as, edit, remove, make_default, pin, save_search_folder, save_app_folder, clear_values] \
[max_results] [fts_switch]

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterManualApplyRequired

影响过滤器组件的行为。

当设置为 true,包含过滤器的界面不会在打开时触发相应的数据加载器,需要用户手动点击过滤器的 Apply 按钮。

当使用应用或者查找目录打开界面的时候,cuba.gui.genericFilterManualApplyRequired 的设置会被忽略,因为此时过滤器已经生效了。但是对于某个查找目录如果它的 applyDefault 设置为 false,过滤器不会生效。

默认值: false

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterMaxResultsOptions

定义过滤器组件Show rows 下拉框中的选项值。

NULL 选项表示这个列表需要包含一个空值。

默认值: NULL, 20, 50, 100, 500, 1000, 5000

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterPopupListSize

定义在 Search 按钮的弹窗列表中显示项目的数量。如果过滤器的数量超过了这个值,则会添加 Show more…​ 到列表最后,这个行为会打开一个新的弹窗用来显示其它的过滤器。

默认值: 10

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterPropertiesHierarchyDepth

定义“添加查询条件”对话框窗口中属性层级的深度。例如,如果深度是 2,那么可以选择实体属性 contractor.city.country,如果深度是 3,可以选择 contractor.city.country.name 等。

默认值: 2

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.genericFilterTrimParamValues

定义所有的通用过滤器是否需要去掉输入值两端的空格。当设置为 false,文本过滤器将保留空格。

默认值: true

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.layoutAnalyzerEnabled

可以禁用主窗口标签页以及模式窗口标题的右键菜单中的界面分析器。

默认值: true

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.lookupFieldPageLength

定义在下拉框控件下拉框选择器控件中下拉列表一页显示的选项默认数量。可以通过 XML 属性 pageLength 在具体实例中覆盖这个参数的设置。

默认值: 10

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端使用。

cuba.gui.manualScreenSettingsSaving

如果此属性设置为 true,界面不会在关闭时自动保存界面设置。在这个模式下,用户可以通过右键点击界面 tab 标签或者弹窗的标题类保存或者重置界面设置。

默认值: false

配置接口: ClientConfig

保存在数据库。

可以在 Web 客户端 block 使用。

cuba.gui.showIconsForPopupMenuActions

启用在 Table 右键菜单和 PopupButton 中显示动作的图标。

默认值: false

保存在数据库。

配置接口: ClientConfig

可以在 web 客户端 block 使用。

cuba.gui.systemInfoScriptsEnabled

启用在 System Information 窗口创建/更新/获取实体实例的时候显示 SQL 脚本。

这些脚本实际上显示的选中实体实例的数据库行内容,而不管是否有安全设置可能禁止显示某些实体属性。所以这就是为什么除了 administrator 用户之外需要取消其它所有用户角色的 CUBA / Generic UI / System Information 特殊权限。也可以通过设置 cuba.gui.systemInfoScriptsEnabledfalse 在整个应用级别禁止这个功能。

默认值: true

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.useSaveConfirmation

当用户尝试关闭带有未保存改动DataContext界面时,定义对话框的布局样式。

true 对应带有三个功能的布局:Save changes - 保存修改, Don’t Save - 不保存修改, Don’t close the screen - 不关窗口。

false 对应带有两个功能的布局:Close the screen without saving changes - 关闭窗口不保存修改, Don’t close the screen - 不关窗口。

默认值: true

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.gui.validationNotificationType

定义标准的界面验证错误的通知消息类型。

可选值是 com.haulmont.cuba.gui.components.Frame.NotificationType 枚举类型的元素:

  • TRAY - 右下角的通知消息,带有普通消息文本。

  • TRAY_HTML - 右下角的通知消息,带有 HTML 消息文本。

  • HUMANIZED - 标准通知消息,显示在界面中间,带有普通消息文本。

  • HUMANIZED_HTML - 标准通知消息,显示在界面中间,带有 HTML 消息文本。

  • WARNING - 警告通知消息,显示在界面中间,带有普通消息文本,点击时消失。

  • WARNING_HTML - 警告通知消息,显示在界面中间,带有 HTML 消息文本,点击时消失。

  • ERROR - 错误通知消息,显示在界面中间,带有普通消息文本,点击时消失。

  • ERROR_HTML - 错误通知消息,显示在界面中间,带有 HTML 消息文本,点击时消失。

默认值: TRAY.

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.hasMultipleTableConstraintDependency

针对实体组合采用 JOINED继承策略。如果设置为 true,为在数据库插入新实体提供正确的顺序。

默认值: false

cuba.healthCheckResponse

定义从应用健康检查 URL 请求返回的文本。

默认值: ok

配置接口: GlobalConfig

可以用在所有 blocks。

cuba.httpSessionExpirationTimeoutSec

定义 HTTP 会话非活动状态的超时时限,单位为秒

默认值: 1800

配置接口: WebConfig

可以在 web 客户端 block 使用。

推荐对于 cuba.userSessionExpirationTimeoutSeccuba.httpSessionExpirationTimeoutSec 属性使用相同的值。

不要在 web.xml 中配置 HTTP 会话超时时限,系统会忽略这个设置。

cuba.iconsConfig

可以在 Web 客户端 block 使用。

示例:

cuba.iconsConfig = +com.company.demo.web.MyIconSet
cuba.inMemoryDistinct

启用基于内存的重复记录过滤,而不使用数据库级别的 select distinct。用在 DataManager 中。

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.jmxUserLogin

定义可以用在系统认证的用户名。

默认值: admin

可以在 Middleware block 使用。

cuba.keyForSecurityTokenEncryption

作为实体安全令牌(security token)AES 加密的密钥。当实体通过下面方式在 Middleware 加载的时候,这个令牌会放置在实体实例内发送:

尽管安全令牌不包含任何属性值(只有属性名称和过滤了的实体标识符),仍然高度建议在生产环境中修改默认的加密密钥值。

默认值: CUBA.Platform

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.numberIdCacheSize

当继承了 BaseLongIdEntity 或者 BaseIntegerIdEntity 的实体实例通过 Metadata.create() 方法在内存创建的时候,会给创建的实体分配一个唯一标识符。这个值通过从数据序列取下一个值的机制得到的。为了减少对 Middleware 和数据库调用的次数,序列每次的增加值默认是设置的 100,也就是说平台每次从数据库调用一次能获取 100 个 id。也就是说按照这种方式“缓存”了序列值,直到 100 个 id 用完之前都可以直接从内存获取 id。

这个属性定义了每次序列自增的值,以及对应的内存中缓存的大小。

如果在数据库已经有部分实体存在的情况下需要修改这个属性的值,此时会重新创建所有已经存在的序列:用新的自增值(必须等于 cuba.numberIdCacheSize),起始值是目前已经存在 id 的最大值。

别忘了在应用的所有 block 都设置这个属性。比如,如果有 Web 客户端,Portal 客户端和 Middleware,需要在 web-app.properties, portal-app.propertiesapp.properties 中将这个属性设置成相同的值。

默认值: 100

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.legacyPasswordEncryptionModule

cuba.passwordEncryptionModule 相同,但这个配置是用于为在旧版本中(版本 7 之前)创建并且 SEC_USER.PASSWORD_ENCRYPTION 字段为空的用户定义用于用户密码哈希的 bean 的名称。

默认值: cuba_Sha1EncryptionModule

用于所有所标准 block

cuba.localeSelectVisible

登录时禁用用户界面语言选择。

如果 cuba.localeSelectVisible 设置成 false,用户会话的 locale 会按照下面方式选择:

  • 如果 User 实例定义了 language 属性,系统会使用这个属性定义的语言。

  • 如果用户的操作系统语言在可选的区域列表里(通过 cuba.availableLocales 设置),系统会使用这个语言。

  • 其它情况下,系统会使用定义在 cuba.availableLocales 属性中的第一个语言。

默认值: true

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.logDir

为应用程序 block 设置日志目录的位置。

默认值: ${app.home}/logs,指向应用程序根目录下的 logs 目录。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.mainMessagePack

累加属性,为一个 block 定义主语言包

属性值可以包含单一语言包,或者用空格分隔的语言包列表。

在所有标准的 blocks 中都可以使用。

示例:

cuba.mainMessagePack = +com.company.sample.gui com.company.sample.web
cuba.maxUploadSizeMb

定义可以使用文件上传控件多个文件上传控件组件能上传的文件大小的最大值,单位是 MB。

默认值: 20

保存在数据库。

配置接口: ClientConfig

可以在 Web 客户端 block 使用。

cuba.menuConfig

累加属性,定义 menu.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Web 客户端 block 使用。

示例:

cuba.menuConfig = +com/company/sample/web-menu.xml
cuba.metadataConfig

累加属性,定义 metadata.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

在所有标准的 blocks 中都可以使用。

示例:

cuba.metadataConfig = +com/company/sample/metadata.xml
cuba.passwordEncryptionModule

定义用作用户密码 Hash 的 bean 名称。创建新用户或更新用户密码时,将在 SEC_USER.PASSWORD_ENCRYPTION 数据库字段中为该用户存储此属性的值。

默认值:cuba_BCryptEncryptionModule

在所有标准的 blocks 中都可以使用。

cuba.passwordPolicyEnabled

启用强制密码策略。如果此属性设置为 true,所有新的用户密码都会按照 cuba.passwordPolicyRegExp 属性定义的策略检查。

默认值: false

保存在数据库。

配置接口: ClientConfig

使用在所有的客户端 blocks:web 客户端,web Portal。

cuba.passwordPolicyRegExp

定义一个正则表达式,用来做密码检查策略。

默认值:

((?=.*\\d)(?=.*\\p{javaLowerCase}) (?=.*\\p{javaUpperCase}).{6,20})

上面这个表达式确保密码需要包含 6~20 个字符,使用数字和英文字母,包含至少一个数字,一个小写字母,一个大写字母。更多关于正则表达式语法可以参考 https://en.wikipedia.org/wiki/Regular_expressionhttp://docs.oracle.com/javase/6/docs/api/java/util/regex/Pattern.html

保存在数据库。

配置接口: ClientConfig

使用在所有的客户端 blocks:web 客户端,web Portal。

cuba.performanceLogDisabled

如果需要禁用 PerformanceLogInterceptor,此参数必须要设置为 true

PerformanceLogInterceptor 通过类或者方法的 @PerformanceLog 注解触发,此拦截器会在 perfstat.log 文件中记录每次方法的调用记录以及执行时间。如果不需要这些日志,建议禁用 PerformanceLogInterceptor 以提高性能。如需重新启用,删除此参数或者设置为 false

默认值: false

可以在 Middleware block 使用。

cuba.performanceTestMode

应用程序在做性能测试的时候必须设置成 true。

配置接口: GlobalConfig

默认值: false

可以用在 Middleware 和 web 客户端。

cuba.permissionConfig

累加属性,用来定义 permissions.xml 文件。

可以在 Web 客户端 block 使用。

示例:

cuba.permissionConfig = +com/company/sample/web-permissions.xml
cuba.persistenceConfig

累加属性,用来定义 persistence.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

在所有标准的 blocks 中都可以使用。

示例:

cuba.persistenceConfig = +com/company/sample/persistence.xml
cuba.portal.anonymousUserLogin

定义在 Web Portal block 可以做匿名用户会话的用户名称。

此属性设置的用户名需要在安全子弟痛存在,并且有需要的权限。不需要为此用户设置密码,因为匿名用户会话是通过 loginTrusted() 方法创建的,使用的是 cuba.trustedClientPassword 属性定义的密码。

配置接口: PortalConfig

可以在 Web Portal block 使用。

cuba.queryCache.enabled

如果设置为 false查询缓存功能禁用。

默认值: true

配置接口: QueryCacheConfig

可以在 Middleware block 使用。

cuba.queryCache.maxSize

设置查询缓存实体数量的最大值。一条缓存记录是通过查询语句文本,查询语句参数,分页参数以及软删除配置确定。

由于缓存大小会慢慢增长到最大值,所以缓存机制会清除掉那些不大可能会被再次使用的记录。

默认值: 100

配置接口: QueryCacheConfig

可以在 Middleware block 使用。

cuba.rememberMeExpirationTimeoutSec

定义 "记住我" cookie 和 RememberMeToken 实体实例的过期时间。

默认值: 30 * 24 * 60 * 60 (30 days)

配置接口: GlobalConfig

可以在 Web 客户端和 Middleware block 使用。

cuba.remotingSpringContextConfig

累加属性,用来定义 Middleware block 的 remoting-spring.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Middleware block 使用。

示例:

cuba.remotingSpringContextConfig = +com/company/sample/remoting-spring.xml
cuba.schedulingActive

启用 CUBA 计划任务机制。

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.security.defaultPermissionValuesConfigEnabled

如需向后兼容,开启使用 default-permission-values.xml 配置文件。参阅 遗留版本角色和权限许可 了解细节。

默认值: false

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.security.rolesPolicyVersion

如需向后兼容,该参数决定安全角色的行为。参阅 遗留版本角色和权限许可 了解细节。

可能值:

  • 1 - CUBA 7.2 之前:如果未定义某种许可,则可访问许可目标;使用角色类型。

  • 2 - CUBA 7.2 之后:如果未定义某种许可,则拒绝访问许可目标;只能使用 “允许” 许可;可使用设计时角色。

默认值: 2

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.serialization.impl

指定 Serialization 接口的实现类,用来在应用程序 blocks 之间做对象传递时做序列化。平台包含两个实现类:

  • com.haulmont.cuba.core.sys.serialization.StandardSerialization - 标准 Java 序列化。

  • com.haulmont.cuba.core.sys.serialization.KryoSerialization - 基于 Kryo 框架的序列化实现。

默认值: com.haulmont.cuba.core.sys.serialization.StandardSerialization

在所有标准的 blocks 中都可以使用。

cuba.springContextConfig

累加属性,用来为各个 block 定义 spring.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

在所有标准的 blocks 中都可以使用。

示例:

cuba.springContextConfig = +com/company/sample/spring.xml
cuba.supportEmail

定义一个 email 地址。从默认异常处理界面发送的异常报告,从 Help > Feedback 界面发送的用户消息都会被发送到这个地址。

如果这个属性没有设置,系统会隐藏异常处理界面的 Report 按钮。

为了成功的发送邮件,配置电子邮件发送参数 中描述的参数也必须设置。

默认值: empty string.

保存在数据库。

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.syncNewUserSessionReplication

在集群中,启用用户登录会话同步机制。参阅 用户会话同步复制 了解详情。

默认值: false

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.tempDir

为应用程序 block 设置临时目录的位置。

默认值: ${app.home}/${cuba.webContextName}/temp,指向应用程序根目录下的一个目录。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.testMode

如果在运行自动化用户界面测试,这个属性必须设置成 true。

配置接口: GlobalConfig

默认值: false

可以用在 Web 客户端和 Middleware blcoks。

cuba.themeConfig

定义一组 *-theme.properties 文件,存储 theme 变量,比如默认的对话框范围以及文本输入框的宽度。

这个属性的值是一组用空格分隔的文件列表,文件通过使用 Resource - 资源接口来加载。

Web 客户端的默认值: com/haulmont/cuba/havana-theme.properties com/haulmont/cuba/halo-theme.properties com/haulmont/cuba/hover-theme.properties

可以在 Web 客户端使用。

cuba.triggerFilesCheck

启用对 bean 调用触发器文件的处理。

触发器文件是放在应用程序 block 的临时文件夹triggers 子目录。触发器文件命名是包含使用“点“分隔的两部分。前半部分是 bean 名称,后半部分是 bean 中需要调用的方法名称。示例: cuba_Messages.clearCache。触发器文件处理器会监控这个目录是否有新文件,如果有的话,会调用指定的方法,然后删除这些文件。

默认情况下,触发器文件的处理是配置在 cuba-web-spring.xml 文件中,并且只在 Web 客户端配置。在项目级别,其它模块的处理可以通过周期性的调用 cuba_TriggerFilesProcessor bean 的 process() 方法来做。

默认值: true

可以在配置了需要处理的 blocks 中使用,默认是 web 客户端。

cuba.triggerFilesCheckInterval

定义检查触发器文件的时间间隔,需要开启 cuba.triggerFilesCheck 参数。单位是毫秒。

默认值: 5000

可以在配置了需要处理的 blocks 中使用,默认是 web 客户端。

cuba.trustedClientPassword

定义创建 TrustedClientCredentials 要使用的密码。Middleware 层可以对使用信任的客户端 block 连接的用户进行认证,而不需要检查用户的密码。

这个属性在用户的密码不存在数据库的时候使用,客户端 block 会自己做实际的认证。比如,集成 Active Directory 的时候。

配置接口: ServerConfig, WebAuthConfig, PortalConfig

可以使用的 blocks:Middleware,Web 客户端,Web Portal。

cuba.trustedClientPermittedIpList

定义一组 IP 地址,这些 IP 地址与 TrustedClientCredentialsTrustedClientService 一起使用。示例:

cuba.trustedClientPermittedIpList = 127.0.0.1, 10.17.*.*

默认值: 127.0.0.1

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.uniqueConstraintViolationPattern

UniqueConstraintViolationHandler使用的正则表达式,用来找出由于数据库违反唯一性约束造成的异常。约束的名称会从表达式的第一个非空组获得,示例:

ERROR: duplicate key value violates unique constraint "(.+)"

根据 DBMS locale 和版本的不同,这个属性还可以定义针对违反唯一性约束需要作出的响应。

当前 DBMS 的默认值通过 PersistenceManagerService.getUniqueConstraintViolationPattern() 方法返回。

可以定义在数据库。

可以在所有的客户端 blocks 使用。

cuba.useCurrentTxForConfigEntityLoad

对于通过配置接口加载实体实例,如果当前已经有事务了,则使用当前事务加载。这个对性能有提高。否则,会创建一个新的连接,并且做提交,然后会返回游离(detached)的实体实例。

默认值: false

可以在 Middleware block 使用。

cuba.useEntityDataStoreForIdSequence

如果此属性设置为 true,为 BaseLongIdEntityBaseIntegerIdEntity 子类生成唯一标识符的序列会创建在相应实体存在的数据存储中。否则,这些序列存在主数据存储中。

默认值: false

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.useInnerJoinOnClause

EclipseLink ORM 会在 inner join 中使用 JOIN ON 从句,而不会在 WHERE 从句中使用条件语句。

默认值: false

可以在 Middleware block 使用。

cuba.useLocalServiceInvocation

当设置为 true,Web 客户端和 Web Portal blocks 会在本地调用 Middleware 服务,避开网络堆栈,这样有利于提高系统性能。这个情况在 快速部署、单一 WAR 或者单一 UberJAR 部署的时候可以用到。对于其它的部署形式,这个值需要设置成 false。

默认值: true

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.useReadOnlyTransactionForLoad

DataManager 中的所有 load 方法都会使用只读事务

默认值: true

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.user.fullNamePattern

为用户定义显示全部名称的模式。

默认值: {FF| }{LL}

全名称模式可以用用户的姓,名,和中间名(外国人)来构成。模式需要按照下面的规则:

  • 模式的各部分通过 {} 来分隔。

  • {} 内的部分必须包含一下列出的字符的一种,然后紧跟一个 |,没有空格:

    LL – 用户姓的长格式 (Smith)

    L – 用户姓的短格式 (S)

    FF – 用户名的长格式 (John)

    F – 用户名的短格式 (J)

    MM – 用户中间名的长格式 (Paul)

    M – 用户中间名的短格式 (P)

  • | 字符后面可以跟任何符号,也可以包含空格。

可以在 Web 客户端 block 使用。

cuba.user.namePattern

User 实体定义显示名称模式。显示名称用在几个不同的地方,包括显示在系统主窗口右上角。

默认值: {1} [{0}]

{0} 会用 login 属性替换, {1} 会用 name 属性替换。

可以在 Middleware,Web 客户端 blocks 使用。

cuba.userSessionExpirationTimeoutSec

定义用户会话超时的时限。单位是秒。

默认值: 1800

配置接口: ServerConfig

可以在 Middleware block 使用。

建议为 cuba.userSessionExpirationTimeoutSeccuba.httpSessionExpirationTimeoutSec 设置相同的值。

cuba.userSessionLogEnabled

开启用户会话日志功能。

默认值: false

保存在数据库。

配置接口: GlobalConfig.

在所有标准的 blocks 中都可以使用。

cuba.userSessionProviderUrl

定义 Middleware block 的 URL 来作为用户登录服务。

这个参数需要在额外的 middleware blcoks 中设置,这些 middleware 可以执行客户端请求,但是不分享用户会话缓存。如果在请求发起的时候,在本地的缓存中没有需要的会话,这个 block 会在指定的 URL 执行 TrustedClientService.findSession() 方法,然后将取到的会话缓存到本地。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.viewsConfig

累加属性,用来定义 views.xml 文件。参考 视图

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

在所有标准的 blocks 中都可以使用。

示例:

cuba.viewsConfig = +com/company/sample/views.xml
cuba.webAppUrl

定义 web 客户端应用的 URL。

在特殊情况下,这个属性也用来生成外部应用程序的界面链接,也会被 ScreenHistorySupport 类使用。

默认值: http://localhost:8080/app

保存在数据库。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.windowConfig

累加属性,用来定义 screens.xml 文件。

Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Web 客户端 block 使用。

示例:

cuba.windowConfig = +com/company/sample/web-screens.xml
cuba.web.allowAnonymousAccess

允许使用非认证用户访问应用程序界面。

参阅 匿名访问界面

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.allowHandleBrowserHistoryBack

如果登录界面和/或者主窗口实现了 CubaHistoryControl.HistoryBackHandler 接口的话,用此参数开启对于浏览器 Back - 返回 按钮的处理。如果此属性设置为 true,浏览器标准的行为会替换成调用接口的处理方法。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.appFoldersRefreshPeriodSec

定义应用程序目录刷新时间间隔,单位是秒。

默认值: 180

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.appWindowMode

定义主应用窗口的初始化模式 - “标签式”还是“单页式”(TABBED 或者 SINGLE)。在“单页式”模式下,当使用 NEW_TAB 参数打开界面时,新界面会完全替换现有界面而不是打开一个新的标签页。

用户之后可以在 Help > Settings 界面更改此模式。

默认值: TABBED

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.closeIdleHttpSessions

当上一次 非心跳请求之后,会话超时也已经过期,Web 客户端是否可以关闭 UI 和会话。

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.componentsConfig

累加属性,用来定义包含应用程序组件信息的配置文件,这些文件由不同的 Jar 包提供或者在 web 模块的 cuba-ui-component.xml 中描述。

示例:

cuba.web.componentsConfig =+demo-web-components.xml
cuba.web.customDeviceWidthForViewport

自定义 HTML 页面的 viewport 宽度。影响 Vaadin HTML 页面上的 "viewport" 元标签(viewport meta tag)。

默认值: -1

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.defaultScreenCanBeClosed

定义默认界面是否也可以通过关闭按钮、ESC 按钮或者标签页右键菜单(当使用 TABBED 工作区模式)进行关闭。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.defaultScreenId

定义登录后默认打开的界面。这个设置对所有用户生效。

示例:

cuba.web.defaultScreenId = sys$SendingMessage.browse

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.foldersPaneDefaultWidth

文件夹面板设置默认的宽度,单位是像素。

默认值: 200

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.foldersPaneEnabled

启用文件夹面板功能并在文件夹中使用键盘快捷键

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.foldersPaneVisibleByDefault

设置是否要默认展开文件夹面板

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.htmlSanitizerEnabled

设置实现了 HasHtmlSanitizer 接口的 UI 组件是否使用 HtmlSanitizer bean 在 HTML 中防止跨站脚本(XSS)。该配置也可以在单独的组件中通过 htmlSanitizerEnabled 属性启用或禁用。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.initialScreenId

定义非认证用户第一次打开应用程序 URL 地址时,系统会打开什么界面。需要 cuba.web.allowAnonymousAccess 设置为 true

参阅 匿名访问界面

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.enabled

在 Web 客户端启用/禁用 LDAP 登录机制。

示例:

cuba.web.ldap.enabled = true

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.urls

设置 LDAP 服务器 URL。

示例:

cuba.web.ldap.urls = ldap://192.168.1.1:389

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.base

为在 LDAP 中搜索用户设置基本域名称(DN)。

示例:

cuba.web.ldap.base = ou=Employees,dc=mycompany,dc=com

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.user

系统用户别名,系统用户有权限从目录读取信息。

示例:

cuba.web.ldap.user = cn=System User,ou=Employees,dc=mycompany,dc=com

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.password

cuba.web.ldap.user 属性定义的系统用户的密码。

示例:

cuba.web.ldap.password = system_user_password

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.ldap.userLoginField

LDAP 用户的用来匹配登录名的属性名称。默认是 sAMAccountName (使用于 Active Directory)。

示例:

cuba.web.ldap.userLoginField = username

配置接口: WebLdapConfig

可以在 web 客户端 block 使用。

cuba.web.linkHandlerActions

定义一组可以由 LinkHandler bean 处理的 URL 命令。参考 界面链接 了解更多信息。

值需要用 | 字符分隔。

默认值: open|o

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.loginDialogDefaultUser

设置默认的用户名称,会在登录窗口自动填充。这个在开发阶段会非常有用。这个属性在生产环境需要设置成 <disabled> 的值。

默认值: admin

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.loginDialogDefaultPassword

设置默认的用户密码,会在登录窗口自动填充。这个在开发阶段会非常有用。这个属性在生产环境需要设置成 <disabled> 的值。

默认值: admin

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.loginDialogPoweredByLinkVisible

设置成 false 在登录窗口隐藏 "powered by CUBA Platform"。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.loginScreenId

用来作为应用程序登录界面的界面标识符。

默认值: login

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.mainScreenId

用来作为应用程序主界面的界面标识符。

默认值: main

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.mainTabSheetMode

定义在主窗口的 标签页模式使用哪个组件管理界面。可以选取 MainTabSheetMode 枚举类型中的一个值:

  • DEFAULT:使用 CubaTabSheet 组件。会在每次用户切换标签页的时候加载和卸载界面组件。

  • MANAGED: 使用 CubaManagedTabSheet 组件。这个不会在用户切换标签页的时候卸载界面组件。

默认值: DEFAULT.

配置接口: WebConfig.

可以在 web 客户端 block 使用。

cuba.web.managedMainTabSheetMode

如果 cuba.web.mainTabSheetMode 属性设置为 MANAGED,定义主 TabSheet 怎么切换标签页:隐藏还是只卸载它们的组件。

默认值: HIDE_TABS

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.maxTabCount

定义可以在应用程序主窗口打开的标签页的最大数量。0 值表示不限制。

默认值: 20

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.pageInitialScale

定义当设置了 cuba.web.customDeviceWidthForViewport 或者 cuba.web.useDeviceWidthForViewporttrue 的时候 HTML 界面的初始化缩放比。影响 Vaadin HTML 界面的 "viewport" 元标签。

默认值: 0.8

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.productionMode

可以完全关闭通过在 URL 中添加 ?debug 打开的 Vaadin 开发者模式。因此,也同时关闭了 JavaScript 调试模式,减少了从浏览器获取的服务器信息。

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.pushEnabled

可以完全禁用服务器推送。但是后台任务机制不受此影响。

默认值: true

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.pushLongPolling

对于服务器推送实现,从 WebSocket 切换成长轮询(long polling)。

默认值: false

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.pushLongPollingSuspendTimeoutMs

定义服务器推送的超时时限,单位是毫秒。当服务器推送实现切换成长轮询的时候会用到。比如当 cuba.web.pushLongPolling="true" 时。

默认值: -1

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.rememberMeEnabled

在 web 客户端的登录界面显示 Remember Me - 记住我 复选框。

默认值: true

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.resourcesCacheTime

启用是否缓存 web 资源。单位是秒。0 值表示不做缓存。示例:

cuba.web.resourcesCacheTime = 136

默认值: 60 * 60 (1 小时).

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.webJarResourcesCacheTime

启用是否缓存 WebJar 资源。单位是秒。0 值表示不做缓存。示例:

cuba.web.webJarResourcesCacheTime = 631

默认值: 60 * 60 * 24 * 365 (1 年).

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.resourcesRoot

设置用来显示 Embedded 组件的文件目录。示例:

cuba.web.resourcesRoot = ${cuba.confDir}/resources

默认值: null

配置接口: WebConfig

可以在 Web 客户端使用。

cuba.web.requirePasswordForNewUsers

如果设置为 true,在 Web 客户端创建用户的时候需要密码。如果是使用 LDAP 认证的话,建议将此参数设置为 false

默认值: true

配置接口: WebAuthConfig

可以在 web 客户端 block 使用。

cuba.web.showBreadCrumbs

设置是否在主窗口的工作区显示面包屑(breadcrumbs)面板。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.showFolderIcons

启用文件夹面板图标。当启用时,会使用下面这些样式文件:

  • icons/app-folder-small.png – 用于应用目录

  • icons/search-folder-small.png – 用户查找目录

  • icons/set-small.png – 用于记录集合

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.standardAuthenticationUsers

以英文逗号分隔的用户列表,这些用户不允许使用外部认证(比如 LDAP 或者 IDP SSO),需要使用标准认证登录系统。

空列表表示所有用户都可以用外部认证来登录系统。

默认值: <empty list>

配置接口: WebAuthConfig

可以在 web 客户端 block 使用。

cuba.web.table.cacheRate

调整网页浏览器中的 Table 缓存。缓存行的数量是由 cacheRate * pageLength 的值决定。

默认值: 2

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.table.pageLength

Table 刷新第一次渲染的时候,设置从服务端获取数据的行数。也可参考 cuba.web.table.cacheRate

默认值: 15

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.theme

定义 web 客户端使用的默认主题的名称。也可参考 cuba.themeConfig

默认值: halo

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.uiHeartbeatIntervalSec

定义 Web 客户端用户界面心跳请求的间隔。如果没设置,则会使用 cuba.httpSessionExpirationTimeoutSec / 3。

默认值: HTTP-session 非活动状态超时时限 / 3,单位为秒

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.unsupportedPagePath

定义HTML 界面的路径,这个界面用来在应用程序不支持当前浏览器版本时显示。

cuba.web.unsupportedPagePath = /com/company/sales/web/sys/unsupported-browser-page.html

默认值: /com/haulmont/cuba/web/sys/unsupported-page-template.html

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.urlHandlingMode

定义如何处理 URL 更改。

可选值为 UrlHandlingMode 枚举类型的元素:

默认值: URL_ROUTES

配置接口: WebConfig

cuba.web.useFontIcons

如果在 Halo 主题中开启了这个属性,Font Awesome 的象形图标会被用来作为平台界面标准行为的图标,而不是使用图片。

可视化组件或者操作的 icon 属性和字体元素之间的关联关系通过平台的 halo-theme.properties 文件定义。以 cuba.web.icons 为前缀的键值对应图标名称,然后它们的值,用 com.vaadin.server.FontAwesome 枚举类型常量定义。比如,标准 create 动作的字体元素定义如下:

cuba.web.icons.create.png = font-icon:FILE_O

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.useInverseHeader

控制使用了 Halo 主题和它的扩展主题的 Web 客户端应用程序的 header。如果是 true,header 会使用暗色调(反色),如果是 false,header 采用主程序背景色。

这个熟悉忽略大小写。

$v-support-inverse-menu: false;

属性在应用的主题内设置。如果用户能选择亮色和暗色主题的话,这个选项对于暗色主题有效。此时,在暗色主题中,header 会作为亮色主题的反色,主程序的背景也会相应的作反色处理。

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.userCanChooseDefaultScreen

Defines whether a user is able to choose the default screen. If the false value is set, the Default screen field in the Settings screen is read-only.

默认值: true

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.useDeviceWidthForViewport

处理 viewport 的宽度。如果需要使用设备宽度作为 viewport 宽度时设置为 true。这个属性影响 Vaadin HTML 界面 viewport 的元标签。

默认值: false

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.web.viewFileExtensions

当使用 ExportDisplay.show() 方法下载文件时,定义一组浏览器直接显示的文件后缀名的列表。使用 | 字符来分隔列表中的后缀名。

默认值: htm|html|jpg|png|jpeg|pdf

配置接口: WebConfig

可以在 web 客户端 block 使用。

cuba.webContextName

定义 web 应用的上下文(context)名称。通常跟包含此应用程序 block 的目录或者 WAR 文件同名。

配置接口: GlobalConfig

可以使用的 blocks:Middleware,Web 客户端,Web Portal。

比如,对 Middleware block 来说,如果放置的目录是 tomcat/webapps/app-core,并且可以通过 http://somehost:8080/app-core 访问,则此属性应该设置为:

cuba.webContextName = app-core
cuba.webHostName

定义运行应用程序 block 的机器主机名称。

默认值: localhost

配置接口: GlobalConfig

可以使用的 blocks:Middleware,Web 客户端,Web Portal。

比如,对 Middleware block 来说,可以通过 http://somehost:8080/app-core 访问,则此属性应该设置为:

cuba.webHostName = somehost
cuba.webPort

定义运行应用程序 block 的端口。

默认值: 8080

配置接口: GlobalConfig

可以使用的 blocks:Middleware,Web 客户端,Web Portal。

比如,对 Middleware block 来说,可以通过 http://somehost:8080/app-core 访问,则此属性应该设置为:

cuba.webPort = 8080

Appendix C: 系统参数

系统参数可以在 JVM 启动的时候通过命令行参数 -D 指定。此外,系统参数可以通过 System 类的 getProperty()setProperty() 方法来读写。

可以用系统参数来设置或者覆盖应用程序属性的值。比如,下面命令行参数就会覆盖 cuba.connectionUrlList 属性的值,这个属性一般在 web-app.properties 文件中设置:

-Dcuba.connectionUrlList=http://somehost:8080/app-core

牢记于心,系统参数是会影响整个 JVM 的,也就是说,在此 JVM 上运行的所有 blocks 对于同一个参数,都读取的同一个值。

框架会在服务启动时缓存系统参数,所以应用程序不应该依赖在运行时修改系统参数来改变应用程序属性。如果确实需要这么做,使用 CachingFacadeMBean JMX bean 的 clearSystemPropertiesCache() 方法在改变系统参数之后清一下缓存。

以下是平台使用的系统参数,这些参数不是应用程序属性。

logback.configurationFile

定义 Logback 框架配置文件位置。

对于运行在 Tomcat web 服务的应用程序 blocks,这个系统参数配置在 tomcat/bin/setenv.battomcat/bin/setenv.sh 文件。默认值是指向 tomcat/conf/logback.xml 配置文件。

cuba.unitTestMode

CubaTestCase 基类运行集成测试的时候,这个系统参数设置成 true

示例:

if (!Boolean.valueOf(System.getProperty("cuba.unitTestMode")))
  return "Not in test mode";

Appendix D: 移除的部分

D.1. 组织业务逻辑

D.2. 控制器中的业务逻辑

D.3. 使用客户端层 Beans

D.4. 使用中间件服务

D.5. 使用实体监听器

示例请参阅 实体监听器 部分。

D.6. 使用 JMX Beans

示例请参阅 创建 JMX Bean 部分。

D.7. 在程序启动时执行代码

示例参阅注册实体监听器部分。

D.8. 使用通用 UI

D.9. Web 应用程序主题

参阅 主题

D.10. 从 Havana 迁移至功能丰富的 Halo 主题

示例请参阅修改主题通用参数部分。

D.11. 为界面传递参数

参阅 打开界面

D.12. 从调用的界面返回值

参阅 打开界面

D.13. Using Individual Fields instead of FieldGroup

TODO

D.14. Setting up Logging in The Desktop Client

本章节从 7.0 开始就无效了,因为不再支持桌面客户端。

D.16. 多对多关联

D.17. 直接多对多关联

参阅 直接多对多关联 向导。

D.18. 使用关联实体进行多对多关联

D.19. 实体继承

参阅 数据模型:实体继承 向导。

D.20. 组合结构

参阅 数据模型:组合 向导。

D.21. 一对多:一层嵌套

参阅 数据模型:组合 向导。

D.22. 一对多:两层嵌套

参阅 数据模型:组合 向导。

D.23. 一对多:三层嵌套

参阅 数据模型:组合 向导。

D.24. 一对一组合

参阅 数据模型:组合 向导。

D.25. 带单一编辑器的一对一组合

参阅 数据模型:组合 向导。

D.26. 设置初始值

D.27. 实体字段初始化

D.28. 使用创建操作做初始化

D.29. 使用initNewItem方法

D.30. 获取本地化消息

D.31. 主窗口布局

参阅 根界面

D.32. REST API

REST API 已经迁移到单独的扩展,参阅 文档

D.33. 使用数据库

参阅 数据库

D.34. 创建数据库 Schema

参阅 {studio_man_url}/#database_migration[Studio 用户向导].

D.35. 在表格列展示图片

参阅 使用图片 指南。

D.36. 加载和展示图片

参阅 使用图片 指南。

D.37. Cookbook

参阅 指南.

D.38. 在 Tomcat 配置日志

参阅 日志

D.39. 列表组件操作

参阅 标准操作

D.40. 选取器控件操作

参阅 标准操作

7. 术语表

Application Tiers - 应用分层

参考 应用程序层和块

Application Properties - 应用程序属性

应用程序属性是为应用程序配置和功能等不同方面配置的带有名称的数据值。参考 应用程序属性

Application Blocks - 应用程序 block

参考 应用程序层和块。block,作为专用词,中文一般不翻译。

Artifact - 工件

在本手册的上下文中,工件是指一个文件(通常是 JAR 或者 ZIP 文件)包含了可执行代码或者构建项目生成的其它代码。工件具有按照特定规则定义的名称和版本号,并且可以保存在工件仓库里。

Artifact Repository - 工件仓库

按特定目录结构保存 artifacts 的服务器,当项目启动构建时,项目依赖的工件都从这个仓库加载。

Base Projects - 基本项目

应用程序组件一个意思,这个术语在之前版本的平台和文档中使用。

Container - 容器

容器控制应用程序里面对象的生命周期以及配置。这个是依赖注入(反转控制)机制的基本组件。

CUBA 框架使用 Spring 框架 的容器。

DB - 数据库

关系型数据库。

Dependency Injection - 依赖注入

也称反转控制(IoC)。一个用来获取使用的对象链接的机制,这个机制会假设一个对象只需要声明它依赖的那些对象,而由容器来帮助创建所有需要的对象并将这些对象注入到依赖他们的对象中去。

Eager Fetching - 预加载

跟请求的实体一起加载子类实体以及关联实体的数据。

Entity - 实体

数据模型的主要元素,参考 数据模型

Entity Browser - 实体浏览界面

包含一个用来显示实体列表的表格以及一些用来创建、编辑和删除实体的按钮的界面。

EntityManager

中间件层的组件,用来持久化实体

参考 EntityManager.

Interceptor - 拦截器

面向切面编程的一个概念,可以用来改变或者扩展对象方法的执行过程。

JMX

Java Management Extensions − 提供工具来管理应用、系统对象和设备的一种技术。为 JMX-components 定义了标准。

也可参考 使用 JMX 工具

JPA

Java Persistence API – ORM 技术的一套标准规范。CUBA 框架使用实现了此规范的 EclipseLink 框架。

JPQL

数据库独立的面向对象的查询语言,是 JPA 规范的一部分。参考 https://en.wikibooks.org/wiki/Java_Persistence/JPQL

Lazy loading - 懒加载

参考 延迟加载.

Local attribute - 本地属性

实体的属性,此属性不关联其它实体,也不是其它实体的集合。典型情况下,实体的所有本地属性都存在数据库的一张表内(当然不包括特定的实体继承的情况)。

Localized message pack - 本地化语言消息包

参考 语言消息包。翻译时有时候简称语言包,本地化语言包等。

Managed Beans - 托管 bean

托管至容器的组件,包含应用程序业务逻辑。

参考 Spring Beans.

Main Message Pack - 主语言消息包

参考 主语言消息包.

MBeans

带有 JMX 接口的Spring bean。典型情况下,这些 bean 包含一个内部状态(比如可以是缓存,配置数据或者统计数据),这个内部状态需要通过 JMX 访问。

Middleware - 中间件

中间层 –  包含业务逻辑的应用程序层,跟数据库通信并且为更高的客户端层提供接口通用接口服务。有时候不翻译,有时候翻译成中间件或者中间层。

Optimistic locking - 乐观锁

乐观锁 - 用来管理不同用户访问共享数据的一种方式,这里假设不同用户对同一实体实例只有非常低的可能性进行并发访问。采用这种方案并不会真正意义上对数据加锁,而是在数据发生变动时检查在数据库是否存在更新版本的数据。如果存在,则会抛出异常,用户需要重新加载实体。

ORM

对象关系映射 – 将关系型数据库的表跟编程语言中对象进行关联的一种技术。

参考 ORM 层.

Persistent context - 持久化上下文

一组从数据库加载的或者刚创建的实体实例。持久化上下文作为当前事务的数据缓存。当事务提交时,所有持久化上下文内的实体变更都被保存到数据库。

参考 EntityManager.

Screen Controller - 界面控制器

包含界面初始化和事件处理逻辑的 Java 类。结合界面的 XML 描述一起工作。

参考 界面控制器.

Services - 服务

中间件服务为客户端调用提供业务接口并且形成中间件壁垒。服务可以封装业务逻辑或者将执行过程代理给其他的Spring Beans

参考 服务.

Soft deletion - 软删除

参考 软删除.

UI

用户界面。

View - 视图

参考 视图

XML-descriptor - 界面 XML 描述

包含界面可视化组件布局和数据组件的 XML 文件。

. . .