序言

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

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

  • Java SE

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

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

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

1. 安装

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

  • 内存 – 至少 4GB,推荐 8GB

  • 硬盘空间 – 至少 5 GB

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

    java -version

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

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

  • 设置 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. 快速开始

本章节介绍使用 CUBA Studio 创建应用程序的过程。

确保必要的软件已经安装并且配置好了,参考 安装

开发应用程序的关键步骤:

  1. 开发数据模型 - 包括创建描述应用程序领域模型的实体以及相对应的数据库表。

  2. 开发用户界面 - 用来创建,查看,更改和删除数据模型实体。

2.1. 示例应用程序详情

此应用主要用来维护客户(customer)信息以及客户订单(order)信息。

customer 有如下属性:

  • Name

  • Email

order 属性:

  • 指向 customer 的引用

  • Date

  • Amount

quick start 1

应用程序界面需要包含:

  • Customers 浏览界面;

  • Customer 编辑界面,也包含 customer 的 orders 列表;

  • General orders 浏览界面;

  • Order 编辑界面。

2.2. 创建项目

  1. 启动 CUBA Studio

  2. 点击 Create New Project 创建新项目。

    start studio
  3. 确保安装了 Java SE Development Kit (JDK) 8,并且选择这个版本为项目默认的 JDK。

    仓库(Repository)的列表已经包含了仓库 URL 以及认证需要的参数。

    create project
  4. New project 窗口的 Project name 字段指定新项目名称 - 比如 sales。名称只能包含拉丁字母,数字以及下划线。这里需要仔细考虑项目名称,因为之后想要修改的话,会需要比较麻烦的手动操作。

    1. 下面这些字段会自动填充:

      • Project location – 项目目录的路径。您可以通过点击右边的 …​ 按钮来进行手动选择。点击之后会出现 Select folder 的窗口,这里会给出你硬盘上的目录列表。您可以选择一个或者创建一个新目录。

      • Project namespace – 会被加在实体名字和数据库表名之前作为前缀的一个命名空间的名字。命名空间只能包含拉丁字母,而且应当越短越好。比如,项目的名字是 sales_2,命名空间可以用 sales 或者 sal

      • Root package − Java 类的根包名。这个可以之后再调整,但是那些在项目创建时就生成的类将来是不会自动修改的。

      • Platform version – 项目中需要采用的 CUBA 框架的版本。在项目编译的时候相应的平台的依赖包会从仓库自动下载。

    new project
  5. 点击 Finish。会在指定的 sales 目录创建新的空项目,Studio 的主工作界面也会打开。

    如果您是首次使用 Studio,Studio 启动时会下载以及连接 Gradle 后台程序。还有,在首次使用 CUBA 框架构建的时候,Studio 会自动下载平台的源码和相应的依赖库。因此,打开项目以及组装项目可能会费一些时间。在开始项目工作之前,需要等待 Studio 的同步和创建索引工作完成。

    studio workspace
  6. 在本地 HyperSQL 上创建数据库:选择菜单项 CUBA > Create database。数据库的名字默认就是项目的命名空间。

  7. 选择 CUBA > Start application server。或者也可以通过工具栏的 Run Configuration 下拉列表来启动应用程序。在 CUBA 项目树的 Runs at…​ 部分的项目链接可以用来直接从 Studio 打开浏览器展示项目。

    默认的用户名密码是 admin / admin

    应用运行起来会自带两个主菜单项(AdministrationHelp),以及 CUBA 框架提供的安全子系统功能、管理子系统功能。

2.3. 创建实体

下面我们创建客户(Customer)实体类。

  • 选中 CUBA 项目树面板中的 Data Model 部分,右键点击该节点,然后点击 New > Entity。这时会出现 New CUBA Entity 对话框窗口。

  • Entity name 字段输入新实体类的名字 – Customer

    new entity
  • 点击 OK。然后会在工作区出现实体设计的界面。

    entity designer
  • 这里会自动生成实体名字和数据库表名字,分别填写在 Entity nameTable 字段。

  • Parent 字段就按照默认给的值 StandardEntity 就可以。

  • Inheritance 字段不需改动。

下一步,创建实体的属性。点击 Attributes 表格下面的 New 按钮。

  • 会出现 Create attribute 窗口。在 Name 字段输入属性名称 name,在 Attribute type 字段选择属性的数据类型,选择 DATATYPE,在 Type 字段选择 String。勾上 Mandatory 复选框。然后会在 Column 字段自动生成数据库表的列名。

    new attribute

    点击 Add 添加属性。

  • email 属性也按照相同的方式创建。但是对于该字段,我们需要添加验证。在创建该属性之后,点击在字段属性配置栏的 Validation 区域点击 Email - not set 链接。

    email attribute
  • 在窗口中勾选 Enabled 复选框并且填写验证错误消息 Email address is not valid 然后点击 OK

    email validation dialog

现在切换到 Text 标签页,这里包含了 Customer 类的源代码。

点击类名称,然后用 Studio 的检查功能(inspection)来为 Customer 实体指定实例名称。在可选的属性列表中选择 name。然后会生成类注解: @NamePattern("%s|name")

name pattern

现在 Customer 实体创建完了。

下面创建 Order 实体。

右键点击 CUBA 项目树中的 Data Model 部分,然后点击 New > Entity。输入 Entity nameOrder。实体需要包含如下属性:

  • Namecustomer, Attribute typeASSOCIATION, TypeCustomer, CardinalityMANY_TO_ONE

  • Namedate, Attribute typeDATATYPE, TypeDate。勾选 Mandatory

  • Nameamount, Attribute typeDATATYPE, TypeBigDecimal

new entity order

2.4. 创建数据库表

通过主菜单的 CUBA > Generate Database Scripts创建数据库表。之后,会弹出 Database Scripts 页。

包含针对当前数据库状态的增量 DB 更新脚本会展示在 Updates 标签页:

db scripts

生成的数据库初始化脚本在 Init Tables, Init Constraints, 和 Init Data 标签页。

db scripts init

点击 Save and close 按钮保存生成的脚本。

如果要执行更新脚本,点击 CUBA > Update database 即可。也许需要先停止应用程序服务才能进行该操作。

2.5. 创建用户界面

现在开始创建 customer 和 order 管理界面。

2.5.1. Customer 界面

在 CUBA 项目树的 Data Model 部分右键点击 Customer 实体,选择 New > Screen 来创建查看和编辑 Customer 的标准界面。然后,Studio 会打开模板浏览界面。

在可用模板列表里选择 Entity browser and editor screens,然后点击 Next

screen templates

这个窗口的所有字段都已经填上了默认值,不需要修改了。点击 Next,下个界面中,本地化消息内容保持不变,然后点击 Finish

customer screens

界面文件会显示在 Generic UI 部分的 Screens 部分:

  • customer-browse.xml - 浏览界面描述文件

  • CustomerBrowse - 浏览界面控制器

  • customer-edit.xml - 编辑界面描述文件

  • CustomerEdit - 编辑界面控制器

2.5.2. Order 界面

Order 实体有一点不同的地方,由于 Order.customer 属性是引用属性,需要定义一个视图包含这个属性(因为标准的 _local 视图不包含引用属性)。

在 CUBA 项目树的 Data Model 部分右键点击 Order 实体,在菜单中点击 New > View。然后在视图设计界面,输入 order-with-customer 作为视图的名称,在右边的面板点击 customer 属性然后选择 Customer 实体的 _minimal 视图。

new view

点击 OK

之后,选择 Order 然后在右键菜单点击 New > Screen

选择 Entity browser and editor screens 模板。

View 字段为浏览和编辑模板界面选择 order-with-customer 作为视图,然后点击 Next,下个界面点击 Finish

order screens

界面文件会显示在 Generic UI 部分的 Screens

  • order-browse.xml - 浏览界面描述文件

  • OrderBrowse - 浏览界面控制器

  • order-edit.xml - 编辑界面描述文件

  • OrderEdit - 编辑界面控制器

2.5.3. 应用程序菜单

在创建界面的同时,这些界面会被自动添加到默认的应用程序菜单的 application 菜单项中。现在重命名这个菜单,在 Studio 的 CUBA 项目树中切换到 Generic UI 部分,双击 Web Menu,这时会打开 web-menu.xml 描述文件。

将菜单标识符从 application-sales 改为 shop,可以从代码直接改,也可以通过 Structure 标签页的可视化编辑器修改,效果一样。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<menu-config xmlns="http://schemas.haulmont.com/cuba/menu.xsd">
    <menu id="shop" insertBefore="administration">
        <item screen="sales_Customer.browse"/>
        <item screen="sales_Order.browse"/>
    </menu>
</menu-config>

然后,在 Main Message Pack 部分打开 messages.properties 文件,修改菜单项的标题:

menu-config.shop = Shop

2.5.4. 带有 Order 列表的自定义编辑界面

按照下面的步骤在 Customer 编辑界面展示 orders 列表:

在 CUBA 项目树切换到 Generic UI 部分。打开 customer-edit.xml 进行编辑。

切换到 Designer 标签页。

在工具箱(palette)的数据组件分组中找到 Collection。将这个组件拖拽至组件树面板的 data 部分。

为数据容器选择 com.company.sales.entity.Order 实体及其 _local 视图。用 generate_id 按钮生成加载器 ID。

在生成的查询语句中添加 WHERE 从句,选取关联到编辑的用户的 orders:

select e from sales_Order e where e.customer = :customer

最后,会得到一个在 customer-edit 界面 XML 描述中加载 Order 实例的数据容器:

<data>
    <instance id="customerDc" class="com.company.sales.entity.Customer" view="_local">
        <loader/>
    </instance>
    <collection id="ordersDc" class="com.company.sales.entity.Order" view="_local">
        <loader id="ordersDl">
            <query><![CDATA[select e from sales_Order e where e.customer = :customer]]></query>
        </loader>
    </collection>
</data>

从工具箱将 Table 组件拖至组件树面板,放置在 labeleditActions 中间。在组件结构(Hierarchy)面板选中此组件,然后在 Properties 标签页设定表格的大小:在 width 字段设置 300px,在 height 字段设置 200px。从可用的数据容器中选择 ordersDc。然后使用 id 字段边上的 generate_id 按钮来生成表格的标识符:ordersTable

接下来,右键点击表格并选择 Wrap Into > Group Box。切换到 group box 属性面板的 Properties 标签页然后在 caption 字段输入 Orders 并设置 group box的宽度为 320 px。

如果应用程序需要使用多国语言,用 caption 字段旁边的 localization 按钮来创建新的语言消息 msg://orders 然后按照需要的语言给标签定义值。

customer edit

最后,Text 标签页的 customer-edit.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.sales.web.customer">
    <data>
        <instance id="customerDc" class="com.company.sales.entity.Customer" view="_local">
            <loader/>
        </instance>
        <collection id="ordersDc" class="com.company.sales.entity.Order" view="_local">
            <loader id="ordersDl">
                <query><![CDATA[select e from sales_Order e where e.customer = :customer]]></query>
            </loader>
        </collection>
    </data>
    <dialogMode height="600" width="800"/>
    <layout expand="editActions" spacing="true">
        <form id="form" dataContainer="customerDc">
            <column width="250px">
                <textField id="nameField" property="name"/>
                <textField id="emailField" property="email"/>
            </column>
        </form>
        <groupBox caption="Orders">
            <table id="ordersDcTable" dataContainer="ordersDc" height="200px" width="300px">
                <columns>
                    <column id="date"/>
                    <column id="amount"/>
                </columns>
            </table>
        </groupBox>
        <hbox id="editActions" spacing="true">
            <button action="windowCommitAndClose"/>
            <button action="windowClose"/>
        </hbox>
    </layout>
</window>

打开 CustomerEdit 界面控制器。左边栏的 ©<> 按钮用来在界面描述器和控制器之间快速切换。

首先,我们需要禁止界面自动加载数据,因为我们需要自定义数据加载的过程。从类上删除 @LoadDataBeforeShow 就可以禁止自动加载数据了。

在控制器类中注入 orders 数据加载器,可以在类定义中按下 Alt+Insert 然后选择 ordersDl。或者可以手动输入以下代码:

@Inject
private CollectionLoader<Order> ordersDl;

然后,订阅 BeforeLoadDataEvent 事件,为 ordersDl 数据加载器设置 customer 参数:按下 Alt+Insert 然后在 Generate 菜单中选择 Subscribe to Event > BeforeShowEvent。或者,也可以直接使用下面的代码:

@UiController("sales_Customer.edit")
@UiDescriptor("customer-edit.xml")
@EditedEntityContainer("customerDc")
public class CustomerEdit extends StandardEditor<Customer> {
    @Inject
    private CollectionLoader<Order> ordersDl;

    @Subscribe
    protected void onBeforeShow(BeforeShowEvent event) {
        ordersDl.setParameter("customer", getEditedEntity());
        getScreenData().loadAll();
    }
}

这个方法将负责加载关联的 Order 实例。

2.6. 运行应用程序

现在我们看看真实的应用程序中刚才创建的界面都是什么样的。选择 CUBA > Start application server 或者直接点击 IDEA 工具栏的 "Run" 按钮。

在登录窗口用默认的用户和密码登录。打开 Shop > Customers 菜单项:

customer browse
Figure 1. Customer 浏览界面

点击 Create 创建一个新 customer:

customer edit 2
Figure 2. Customer 编辑界面

打开 Shop > Orders 菜单项:

orders browse
Figure 3. Orders 浏览界面

点击 Create 创建一个新 order,在 Customer 字段选择新创建的 customer:

order edit
Figure 4. Order 编辑界面

现在在 customer 编辑界面显示新的 order:

customer edit 3
Figure 5. Customer 编辑界面

3. 框架详细介绍

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

3.1. 架构

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

3.1.1. 应用程序层和块

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

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

AppTiers
Figure 6. 应用程序层(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

为外部用户设计的可选客户端,使用纯 JavaScript 编写。基于 Google Polymer 或者 React 框架实现,并通过在 Web Client 或 web Portal 块中运行的 REST API 与中间件进行通信。参阅 前端用户界面

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 7. 应用程序模块

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 8. 一个简单的应用程序的结构

该图展示了 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.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 销毁。通常情况下,属性更改监听器是可视化组件UI 数据源,必定有其它对象引用,因此没有监听器丢失的问题。但是,如果监听器是由应用程序代码创建的,并且没有任何对象以自然方式引用它,则除了将其添加到 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。

@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|login,name")
@PostConstruct

可以为方法指定此注解。在 Metadata.create() 方法创建实体实例之后将立即调用此方法。当实例初始化需要调用托管 Bean 时非常方便。请参阅 初始化实体字段

@PrimaryKeyJoinColumn

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

参数:

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

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

例如:

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

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

@SystemLevel

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

@Table

定义实体的数据库表。

参数:

  • name – 表名

例如:

@Table(name = "SALES_CUSTOMER")
@TrackEditScreenHistory

表示系统将会保存编辑界面的打开历史记录,并能够通过 Help > History 主菜单项在 sec$ScreenHistory.browse 界面上显示。

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

@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;
@LocalizedValue

确定获取属性的本地化值的方法,实现是使用 MessageTools.getLocValue() 方法获取本地化值。

参数:

  • messagePack – 显式定义从哪个包中获取本地化消息的包名,例如,com.haulmont.cuba.core.entity

  • messagePackExpr – 定义包名路径的表达式,包含本地化消息的包名称(例如,proc.messagesPack)。路径从当前实体的属性开始。

下面示例中的注解表明 state 属性值的本地化消息应该从 proc 实体的 messagesPack 属性中定义的包中获取。

@Column(name = "STATE")
@LocalizedValue(messagePackExpr = "proc.messagesPack")
protected String state;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "PROC_ID")
protected Proc proc;
@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 - 当此属性包含在视图中时,定义从数据库中提取的相关持久化属性的数组。

字段示例:

@Transient
@MetaProperty
protected String token;

方法示例:

@MetaProperty
public String getLocValue() {
    if (!StringUtils.isEmpty(messagesPack)) {
        return AppBeans.get(Messsages.class).getMessage(messagesPack, value);
    } else {
        return value;
    }
}
@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) 方法。

  • 数据源级别 - 在界面的 XML 描述中调用 CollectionDatasource.setSoftDeletion(false) 方法或设置 collectionDatasource 元素的 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 9. 元数据框架接口
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 的解析格式通过应用程序或者应用程序组件主语言消息包来提供,使用下面这些键值:

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

  • numberGroupingSeparator – 数值类型的千分位符。

  • integerFormatIntegerLong 类型的格式。

  • doubleFormatDouble 类型的格式。

  • decimalFormatBigDecimal 类型的格式。

  • dateTimeFormatjava.util.Date 类型的格式。

  • dateFormatjava.sql.Date 类型的格式。

  • timeFormatjava.sql.Time 类型的格式。

  • trueStringBoolean.TRUE 类型对应的显示字符串。

  • falseStringBoolean.FALSE 类型对应的显示字符串。

通过 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 查询语句的生成:返回的字段列表以及与包含关联实体的其它表的连接。

View
Figure 10. 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

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

  • id – 实体标识符。

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

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

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

3.2.3.1. 创建视图

视图可以通过两种方式创建:

  • 编程方式 – 通过创建 View 实例,例如:

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

    通常,这种方式适用于创建在单个业务逻辑中使用的视图。

  • 声明式 – 通过创建 XML 描述并将其部署到 ViewRepository。部署 XML 描述时,会创建并缓存 View 实例。此外,通过 ViewRepository(调用时提供实体类和视图名称),可以在应用程序代码的任何部分中获取所需的视图。

下面看看使用声明式方式创建和使用视图的细节。

ViewRepository 是一个 Spring bean,所有应用程序 block 都可以访问这个 bean。可以使用注入或通过元数据基础接口得到 ViewRepository 的引用。然后用 ViewRepository.getView() 方法从视图库中获取视图实例。AbstractViewRepository 基实现中的 deployViews() 方法用于将 XML 描述部署到视图库。

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

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

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

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

视图 XML 描述的详细结构说明在这里

下面的示例展示了 Order 实体的视图描述,它提供了所有本地属性、关联的 CustomerItems 集合的加载。

<view class="com.sample.sales.entity.Order"
      name="order-with-customer"
      extends="_local">
    <property name="customer" view="_minimal"/>
    <property name="items" view="itemInOrder"/>
</view>

推荐按照下面方法进行视图描述分组和部署:

  • global 模块的 src 根目录中创建 views.xml文件,并将所有可全局(即在所有应用程序层)访问的视图描述放入其中。

  • 在所有 block 的 cuba.viewsConfig应用程序属性中注册此文件,即 core 模块的 app.propertiesweb 模块的 web-app.properties,这将确保在应用程序启动时自动部署视图到视图库中。

  • 如果视图仅在一个应用程序 block 中使用,则可以在此 block 的类似文件中定义,例如,web-views.xml,然后只在这个 block 的cuba.viewsConfig属性中注册。

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

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

3.2.4. 托管 Bean

Managed Beans - 托管 bean 是用于实现应用程序业务逻辑的程序组件。这里,Managed 意味着实例的创建和依赖关系的管理都由容器处理,容器是 Spring 框架的主要部分。

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

3.2.4.1. 创建托管 Bean

如需创建托管 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 元素指定的扫描目录树下。此时,spring.xml 的示例:

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

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

托管 bean 可以在任何创建,因为 Spring 框架的容器在应用程序所有标准的 block 里面都有用到。

3.2.4.2. 使用托管 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);
        orderWorker.calculateTotals(entity);
    }
}

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

3.2.5. JMX Beans

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

JMXBeans
Figure 11. JMX Bean Class Diagram

从图中可以看出,JMX bean 由接口和实现类组成。实现类应该是一个托管 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.*;
    
    @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 接口的所有工具中,这样可以帮助系统管理员了解接口。

    • 可选的 @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 名称的托管 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 功能,是一种通用工具,用于从数据库加载实体关系图并保存已更改的游离实体实例。

有关 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() 获得的视图映射为每个保存的实例设置视图,这样可以确保返回实体中包含需要的属性。

    保存实体集合的示例:

    @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 查询 中描述的规则类似。不同之处在于,通过 DataManager 执行的查询只能使用命名参数,不支持位置参数。

事务

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 委托给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 接口是一个托管 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() – 检查实体属性是否被赋予了本地化名称。

  • getLocValue() – 返回使用 @LocalizedValue 注解的实体属性的本地化值。

  • 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 是一个托管 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 系统属性设置。此外,文件中设置的值将覆盖数据库中具有相同名称的值,通过 Java 系统属性设置的值将覆盖文件和数据库中的值。

某些属性不支持设置在数据库中,原因是:在应用程序代码还无法访问数据库时就需要使用这些属性值。这些属性是上面提到的配置和部署参数。因此,只能在属性文件中或通过 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 文件中定义该属性。部署参数也可以在项目文件之外的配置目录中设置。有关详细信息,请参阅在文件中存储属性

应用程序组件属性

应用程序组件可以通过在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:${catalina.base}/conf/app-core/local.app.properties"
    </param-value>
</context-param>

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

声明文件的顺序很重要,因为在每个后续文件中指定的值将覆盖前面文件中指定的相同名称的属性值。

上面集合中的最后一个文件是 local.app.properties。它可用于在部署时覆盖应用程序属性。如果该文件不存在,则会忽略该它。可以在应用程序服务器上创建此文件,在其中定义特定于该环境的所有属性。这样,配置信息将与应用程序分离,能够做到更新应用程序而不必担心丢失特定于环境的配置信息。生产环境使用 Tomcat部分包含使用 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, 原始类型及其封装类型(booleanBooleanint 、 `Integer`等)

  • 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 类型为例来了解这个过程。

  • 创建类 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);
    }
  • 在这种情况下没有必要创建 TypeStringify,因为有 toString() 方法。

  • 在配置接口中注解属性:

    @Factory(factory = UuidTypeFactory.class)
    UUID getUuidProp();
    void setUuidProp(UUID value);

平台为以下类型提供了 TypeFactory 实现:

  • UUIDUuidTypeFactory, 如上所述。

  • java.util.DateDateFactory。日期值必须以 yyyy-MM-dd HH:mm:ss.SSS 格式指定,例如:

    cuba.test.dateProp = 2013-12-12 00:00:00.000
  • List<Integer> (整数列表) – IntegerListTypeFactory。必须以数字的形式指定属性值,用空格分隔,例如:

    cuba.test.integerListProp = 1 2 3
  • List<String> (字符串列表) – StringListTypeFactory。必须将属性值指定为由"|"分隔的字符串列表,例如:

    cuba.test.stringListProp = aaa|bbb|ccc
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.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
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 框架提供内置的可扩展身份验证机制。这些机制包括不同的身份验证方案,例如登录/密码、记住账号、信任和匿名登录。

本节主要介绍中间层的身份验证机制。有关 Web 客户端身份验证机制的详细信息,请参阅 Web 登录

平台在中间件包含以下身份验证机制:

  • AuthenticationManagerBean 实现的 AuthenticationManager

  • AuthenticationProvider 实现。

  • AuthenticationServiceBean 实现的 AuthenticationService

  • UserSessionLog - 参阅用户会话日志

MiddlewareAuthenticationStructure
Figure 12. 中间件身份验证机制

此外,它还使用以下附加组件:

  • 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 13. 标准的用户登录过程

标准的用户登录过程:

  • 用户输入用户名和密码。

  • 应用程序客户端使用用户名和密码作为参数调用 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.getSource().getUser();
        log.info("Logged in user {}", user.getInstanceName());
    }
}

上面提到的所有事件的事件处理器(不包括 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 对象,但这个对象不会在集群中分发。

可在 系统身份验证 中查看有关中间件处理身份验证的更多信息。

Obsolete/Deprecated

以下组件已被弃用:

  • LoginService 将登录方法的执行委托给 AuthenticationService

  • LoginWorker 将登录方法的执行委托给 AuthenticationManager

不要在代码中使用这些组件。它们将在平台的下一个版本中删除。

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 接口的托管 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.13. Bean 验证

Bean 验证是一种可选机制,可在通用 UIREST API 中提供中间件上数据的统一验证。它基于 JSR 349 - Bean Validation 1.1 及其参考实现: 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@Future 注解自动设置其 rangeStartrangeEnd 属性,不过这里忽略了时间部分。

如果约束包含 UiCrossFieldChecks 组并且所有属性级别的检查都通过了,编辑界面将在提交时做类级别约束的验证。可以在 XML 描述或界面控制器使用 crossFieldValidate 属性关闭此验证:

<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        caption="msg://editorCaption"
        class="com.company.demo.web.task.TaskEdit"
        datasource="taskDs"
        crossFieldValidate="false">
    <!-- ... -->
</window>
public class TaskEdit extends StandardEditor<Task> {
    @Subscribe
    protected void onInit(InitEvent event) {
        setCrossFieldValidate(false);
    }
}
中间件服务验证

如果服务接口中的方法带有 @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);
    // ...
}

3.2.14. 实体属性访问控制

安全子系统 允许根据用户权限设置对实体属性的访问。也就是说,框架可以根据分配给当前用户的角色自动将属性设置为只读或隐藏。但有时可能还想根据实体或其关联实体的当前状态动态更改对属性的访问。

属性访问控制机制允许对特定实体实例创建其属性的隐藏、只读或必须(required)规则,并自动将这些规则应用于通用 UI 组件和 REST API

该机制的工作原理如下:

  • DataManager 加载一个实体时,它会找到实现 SetupAttributeAccessHandler 接口的所有托管 bean,并传递 SetupAttributeAccessEvent 对象作为参数调用它们的 setupAccess() 方法。此对象包含处于托管状态的已加载实例,以及三个用于存储属性名称的集合:只读属性集合、隐藏属性集合和必须属性集合(这些集合最初为空)。

  • SetupAttributeAccessHandler 接口的实现类分析实体的状态并适当地填充事件对象中的属性名称集合。这些类实际上是用于定义给定实例的属性访问规则的容器。

  • 该机制将由规则定义的属性名称保存在实体实例本身(在关联的 SecurityState 对象中)。

  • 在客户端层,通用 UI 和 REST API 使用 SecurityState 对象来控制对实体属性的访问。

要为特定实体类型创建规则,请执行以下操作:

  • 在项目的 core 模块中创建托管 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。同时描述了一种基于脚本的机制,该机制可以创建数据库,并在应用程序开发和上线运行后的整个周期中使其保持最新。

数据库组件属于中间件块(block); 应用程序的其它块(block)无法直接访问数据库。

有关使用数据库的一些其它信息,请参阅使用数据库部分。

3.3.1. 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

下表描述了 Java 中的实体属性与不同 DBMS 中的表列之间推荐的数据类型映射关系。生成创建和更新数据库的脚本时,CUBA Studio 会自动使用这些类型。使用这些类型,可以保证所有平台机制正常运行。

Java HSQL PostgreSQL MS SQL Server Oracle MySQL

UUID

varchar(36)

uuid

uniqueidentifier

varchar2(32)

varchar(32)

Date

timestamp

timestamp

datetime

timestamp

datetime(3)

java.sql.Date

timestamp

date

datetime

date

date

java.sql.Time

timestamp

time

datetime

timestamp

time(3)

BigDecimal

decimal(p, s)

decimal(p, s)

decimal(p, s)

number(p, s)

decimal(p, s)

Double

double precision

double precision

double precision

float

double precision

Long

bigint

bigint

bigint

number(19)

bigint

Integer

integer

integer

integer

integer

integer

Boolean

boolean

boolean

tinyint

char(1)

boolean

String (limited)

varchar(n)

varchar(n)

varchar(n)

varchar2(n)

varchar(n)

String (unlimited)

longvarchar

text

varchar(max)

clob

longtext

byte[]

longvarbinary

bytea

image

blob

longblob

通常,在数据库和 Java 代码之间转换数据的整个工作由ORM 层使用合适 JDBC 驱动程序来完成。这意味着使用EntityManager方法和JPQL 查询处理数据时不需要手动转换数据;对于开发人员来说,在编写与数据库交互的代码时应避免使用表格左栏没有列出的 Java 类型。

当通过 EntityManager.createNativeQuery()QueryRunner 使用本地 SQL 时,Java 代码中的某些类型将与上面提到的类型不同,具体取决于所使用的 DBMS。特别是对于 UUID 类型的属性 - 只有 PostgreSQL 驱动程序使用此类型返回相应列的值; 其它数据库服务都返回 String。要对不同的数据库类型抽象出通用应用程序代码,建议使用DbTypeConverter接口转换参数类型和查询结果。

3.3.1.1. 对其它 DBMS 的支持

在应用程序项目中,可以使用ORM框架(EclipseLink)支持的任何 DBMS。请按照以下步骤操作:

  • cuba.dbmsType 属性中以任意形式代码的指定数据库的类型。代码必须与平台中使用的代码不同:hsqlpostgresmssqloracle

  • 实现 DbmsFeaturesSequenceSupportDbTypeConverter 接口,实现类用以下格式命名:<Type>DbmsFeatures<Type>SequenceSupport<Type>DbTypeConverter,其中 Type 是 DBMS 类型代码。实现类的包必须与接口的包相同。

  • 在以 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'
    }
3.3.1.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.2. 创建和更新数据库的脚本

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 目录下。对于应用程序支持的每种类型的 DBMS,都会创建一组单独的脚本,这些脚本位于cuba.dbmsType应用程序属性中指定的子目录中,例如 /db/init/postgres。创建脚本的名称应该符合以下格式:{optional_prefix}create-db.sql

更新脚本位于 core 模块的 /db/update 目录中。对于应用程序支持的每种类型的 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.2.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.2.2. Groovy 脚本的结构

Groovy 更新脚本的结构如下:

  • 主要(main) 部分,包含要在应用程序上下文启动之前执行的代码。在这一部分,可以使用任何 Java、Groovy 和 Middleware 应用程序块(block)中的类。但要注意,这时尚未实例化 bean、基础设施接口和其它应用程序对象,所以无法使用它们。

    这部分主要用于更新数据库结构,通常使用普通的 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. 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.4. 在 web Server 中执行数据库脚本

Web Server 执行数据库脚本的机制用于更新数据库,这个操作在应用程序服务启动、中间件块(block)初始化期间激活。显然,应用程序应该已经构建并部署在 Web Server 上,即在生产环境或开发人员的 Tomcat 实例中。

根据下面描述的条件,该机制执行创建或更新脚本,也就是它可以从头开始初始化 DB 并对其进行更新。但是,与上一节中描述的 Gradle createDb 任务不同,数据库必须存在才能初始化 - 在 Web Server 中不会自动创建 DB,而只是执行脚本。

Web Server 中执行脚本的机制如下:

  • 这些脚本是从数据库脚本目录中提取的,该目录由cuba.dbDir应用程序属性定义,该属性的默认设置为 tomcat/webapps/app-core/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 14. 中间件组件

服务(Service)容器托管组件,用于形成应用边界并为客户端提供接口。服务自身可以包含业务逻辑,也可以将业务逻辑的实现委托给托管 bean。

托管 Bean是容器托管组件,包含应用程序的业务逻辑。它们由服务、其它 bean 或通过可选的JMX接口调用。

Persistence是一个基础设施接口,用于访问数据存储功能:ORM事务管理。

3.4.1. 服务

服务构成了一个层,在这一层定义了客户端可用的一组中间层操作。换句话说,服务是中间层业务逻辑的入口点。在服务中,可以管理事务、检测用户授权、使用数据库或将操作委托给中间层的其它托管 Bean去执行。

下图展示了服务层组件的类关系:

MiddlewareServices

服务接口位于 global 模块中,所以在中间层和客户端层都是可用的。在运行时,在客户端层会为服务接口创建代理。代理使用 Spring HTTP Invoker 机制提供服务 bean 方法的调用。

服务实现 bean 位于 core 模块,仅在中间层可用。

使用 Spring AOP 的任何服务方法都会自动调用 ServiceInterceptor。它检测当前线程中用户会话的可用性,并且会在从客户端层调用服务时执行转换和记录异常。

3.4.1.1. 创建服务

服务接口的名称应以 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) {
        }
    }

作为托管 bean 的服务类应放在包的树结构中,其根目录需要在spring.xml文件的 context:component-scan 元素中指定。这时,spring.xml 文件会包含以下元素:

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

这意味着将从 com.sample.sales 包开始搜索此应用程序 block 中带注解的 bean。

如果不同的服务或其它中间件组件需要调用相同的业务逻辑,则应将其提取并封装在适当的托管 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 数据源连接的关系型数据库。主数据源位于 JNDI 中,其名称应在 cuba.dataSourceJndiName 应用程序属性中指定,默认情况下为 jdbc/CubaDS

可以在 cuba.additionalStores 应用程序属性中指定其它附加数据存储名称。如果附加存储是 RdbmsStore,需要提供以下属性:

  • cuba.dataSourceJndiName_{store_name} - 相应 JDBC 数据源的 JNDI 名称。

  • cuba.dbmsType_{store_name} - 数据存储 DBMS 的类型。

  • cuba.persistenceConfig_{store_name} - 数据存储 persistence.xml 文件的位置。

如果在项目中为附加存储实现了 DataStore 接口,需要在 cuba.storeImpl_{store_name} 应用程序属性中指定实现 bean 的名称。

例如,如果需要使用另外两个数据存储:db1(PostgreSQL 数据库)和 mem1(由某个项目 bean 实现的内存存储),请在 core 模块的 app.properties 中指定以下应用程序属性:

cuba.additionalStores = db1, mem1
cuba.dataSourceJndiName_db1 = jdbc/db1
cuba.dbmsType_db1 = postgres
cuba.persistenceConfig_db1 = com/company/sample/db1-persistence.xml
cuba.storeImpl_mem1 = sample_InMemoryStore

还应在所有应用程序 block 使用的属性文件(web-app.propertiesportal-app.properties 等)中指定 cuba.additionalStorescuba.persistenceConfig_db1 属性。

CUBA Studio 允许在 CUBA Project Properties 窗口的 Data Stores 标签页上设置其它数据存储。它会自动创建所有必须的应用程序属性和 JDBC 数据源,同时维护额外的 persistence.xml 文件。之后,可以在实体设计器的 Data store 字段中为实体选择数据存储。当使用 Generate model 向导为已有数据库创建实体时,也可以选择数据存储。

来自不同数据存储的实体之间的引用

如果正确定义了 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

托管 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 接口用于执行 JPQL 查询。可以通过调用 createQuery() 方法从当前的 EntityManager 实例获得其引用。如果该查询用于加载实体,建议使用结果类型作为参数调用 createQuery()。这将创建一个 TypedQuery 实例。

Query 的方法主要对应于标准 JPA javax.persistence.Query 接口的方法。但有以下差异:

  • setParameter() – 为查询参数设置值。如果该值是实体实例,则隐式将实例转换为它的标识符。例如:

    Customer customer = ...;
    TypedQuery<Order> query = entityManager.createQuery(
        "select o from sales$Order o where o.customer.id = ?1", Order.class);
    query.setParameter(1, customer);

    请注意,虽然参数中传递了一个实体实例,但是查询中的比较使用的是实例的标识符。

    使用了 implicitConversions = false 的重载方法不执行此类转换。

  • setView()addView() – 定义用于加载数据的视图

  • getDelegate() – 返回由 ORM 实现提供的 javax.persistence.Query 实例。

如果为查询指定了视图 ,则默认情况下查询使用 FlushModeType.AUTO 模式,这会影响当前持久化上下文包含已更改实体实例的情况:这些实例将在执行查询之前保存到数据库中。换句话说,ORM 首先同步持久化上下文和数据库中的实体状态,之后才执行查询。这样可以保证查询结果包含所有相关实例,即使它们尚未明确地保存到数据库中。这样做的缺点是会有一个隐式刷新(flush),也就是为所有当前已更改的实体实例执行 SQL 更新语句,这可能会影响性能。

如果在没有视图的情况下执行查询,则默认情况下查询使用 FlushModeType.COMMIT 模式,这意味着查询不会导致刷新(flush),并且查询结果将不会体现出当前持久化上下文数据。

在大多数情况下,忽略当前的持久化上下文是可以接受的,并且是首选行为,因为它不会导致额外的 SQL 更新。但是在使用视图时存在以下问题:如果持久化上下文中存在已更改的实体实例,并且使用视图以 FlushModeType.COMMIT 模式执行查询去加载相同的实例,则更改将丢失。这就是在运行带有视图的查询时默认使用 FlushModeType.AUTO 的原因。

还可以使用 Query 接口的 setFlushMode() 方法显式设置刷新(flush)模式,这样将覆盖上述默认设置。

3.4.4.4.1. JPQL 函数

CUBA 和标准 JPA 之间 JPQL 的主要区别在于 CUBA 函数总是需要一个实体别名,包括 SELECTUPDATE/DELETE 语句。

CUBA JPA

UPDATE app$DeliveryAddress e SET e.customer = :target WHERE e.customer = :source

UPDATE app$DeliveryAddress SET customer = :target WHERE customer = :source

在开启实体的软删除模式时,如果对已经软删除的实体执行 JPQL DELETE FROM 语句将抛出异常。这样的语句实际上转换为 SQL 来删除那些没有标记为删除的所有实例。默认情况下禁用软删除,可以使用 cuba.enableDeleteStatementInSoftDeleteMode 应用程序属性启用软删除。

下表描述了 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

UPDATE 查询中的 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. 实体监听器

实体监听器 目的在于响应中间层上的实体实例的生命周期事件。请参阅 cookbook 中的示例。

监听器是一个实现 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 实现对数据库的更改。

实体监听器必须是托管 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. 声明式事务管理

中间件托管 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. 事务监听器

事务监听器旨在对事务生命周期事件做出响应。与实体监听器不同,它们不与何实体类型绑定,可以被每个事务调用。

监听器是一个托管 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() 方法。

  • 使用datasources时,使用 CollectionDatasource 接口的 setCacheable() 方法或 cacheable XML 属性

仅在为返回的实体启用实体缓存时才使用可缓存查询。否则,每个查询实体实例将通过其标识符逐个从数据库中获取。

ORM 执行实体的实例的创建、更新或删除时,相应的查询缓存会自动失效,并且会在整个中间件集群都失效。

app-core.cuba:type=QueryCacheSupport JMX-bean 可用于监视缓存状态并手动释放缓存的查询。例如,如果已直接在数据库中修改了 sales$Customer 实体的实例,则应使用带有 sales$Customer 参数的 evict() 操作释放该实体的所有缓存的查询。

以下应用程序属性会影响查询缓存:

3.4.7. EntityChangedEvent

EntityChangedEvent 是一个 Spring 的 ApplicationEvent 事件,会在实体保存到数据库时从中间层发送该事件。可以在事务中或者事务完成后处理该事件(使用 @TransactionalEventListener )。只有实体使用了 @PublishEntityChangedEvents 注解,才会发送该事件。

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 {

    @EventListener (1)
    private 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)
    private void afterCommit(EntityChangedEvent<Customer, UUID> event) {
        (7)
    }
}
1 - 该监听器会在当前事务中调用。使用 @EventListener 注解跟 @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 是一样的。
2 - 更改实体的 id。
3 - 更改类型: CREATEDUPDATEDDELETED
4 - 可以检查是否某个特定属性有变化。
5 - 可以获取变化属性的旧值。
6 - 该监听器在事务提交之后会被调用。
7 - 在事务提交之后,事件包含跟提交之前相同的信息。

如果监听器在事务内部调用,可以通过抛出异常的方法回滚事务,这样不会有数据保存至数据库。如果不想用户看到任何错误提示,可以用 SilentException

如果在当前事务中处理 EntityChangedEvent,可以使用 TransactionalDataManager 从数据库获取更改实体的当前状态。如果在事务提交之后的监听器中,可以使用 DataManager 来创建新事务并加载数据。

别忘了为需要监听 EntityChangedEvent 事件的实体添加 @PublishEntityChangedEvents 注解。

3.4.8. 系统身份验证

执行用户请求时,中间件程序代码始终可以通过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 15. 通用用户界面结构

应用程序的界面包含了以下部分:

  • 界面 XML 描述 – 声明式定义界面布局和数据组件的 XML 文件。

  • 界面控制器 – 处理界面生成事件、UI 展示控制以及编程方式操控界面组件的 Java 类。

应用程序界面的代码跟可视化组件接口(VCL 接口)交互。这些接口通过使用 Vaadin 框架组件实现。

可视化组件库(VCL)包含大量即用型组件。

数据组件为可视化组件绑定到实体以及在界面控制器中处理实体提供统一的接口。

客户端的基础设施包含包含主应用程序窗口和其他的通用客户端机制。

3.5.1. 界面

界面(Screen)是通用 UI 的主要部分。它由可视化组件、数据容器和其它非可视化组件组成。界面可以显示在应用程序主窗口的标签页中,也可以显示为模式对话框。

界面的主要组成部分是称作控制器的 Java 或 Groovy 类。界面的布局通常在称作界面描述的 XML 文件中定义。

要显示一个界面,框架会创建一个可视化组件 Window 的新实例,将窗口与界面控制器连接起来,并将界面布局组件作为窗口的子组件加载。最终,界面的窗口将被添加到应用程序主窗口中。

3.5.1.1. 界面控制器

界面控制器是一个 Java 或 Groovy 类,包含界面初始化和事件处理逻辑。通常,控制器链接到XML 描述,XML 描述中定义了界面布局和数据容器,但也可以以编程方式创建所有可视化组件和非可视化组件。

所有界面控制器都实现了 FrameOwner 标记接口。此接口的名称表示它引用了一个框架(frame),框架是一个在主应用程序窗口中显示界面的可视化组件。框架有两种类型:

  • Window - 一个独立的窗口,可以显示在应用程序主窗口内的标签页中,也可以显示为模式对话框。

  • Fragment - 一个轻量级组件,可以被添加到窗口或其它 Fragment。

根据所使用的框架,控制器被分为两个不同的类别:

  • Screen - 窗口控制器的基类。

  • ScreenFragment - fragment 控制器的基类。

screens
Figure 16. 控制器和框架

Screen 类为所有独立界面提供大部分基本的功能。还有其它一些特定的界面基类可用于处理实体:

  • StandardEditor - 实体编辑界面的基类。

  • StandardLookup - 实体浏览和查找界面的基类。

  • MasterDetailScreen - 组合界面,在左侧显示实体列表、在右侧显示所选实体的详细信息。

controller base classes
Figure 17. 控制器基类
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() 方法来手动加载数据。

  • @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. 界面控制器事件

本节介绍可以在界面控制器中处理的生命周期事件。

  • InitEvent 在界面控制器及其所有以声明方式定义的组件创建后并完成依赖注入时发送的事件。此时,嵌套的界面 fragment 尚未初始化,某些可视化组件未完全初始化,例如按钮还未与操作关联起来。

    @Subscribe
    protected void onInit(InitEvent event) {
        Label<String> label = uiComponents.create(Label.TYPE_STRING);
        label.setValue("Hello World");
        getWindow().add(label);
    }
  • AfterInitEvent 在界面控制器及其所有以声明方式定义的组件被创建并完成依赖注入,并且所有组件都已完成其内部初始化过程时发送此事件。此时,嵌套的界面 fragment(如果有的话)已经发送了 InitEventAfterInitEvent 事件。在此事件监听器中,可以创建可视化组件或数据组件并且进行一些依赖于嵌套 fragment 的额外初始化过程。

  • InitEntityEvent 继承自 StandardEditorMasterDetailScreen 的界面中,在新实体实例设置给被编辑实体的容器之前发送的事件。使用此事件监听器初始化新实体实例中的默认值,例如:

    @Subscribe
    protected void onInitEntity(InitEntityEvent<Foo> event) {
        event.getEntity().setStatus(Status.ACTIVE);
    }
  • BeforeShowEvent 在界面将要展示之前发送的事件,此时,界面尚未被添加到应用程序 UI 中、UI 组件已经应用安全限制、保存的组件设置尚未应用到 UI 组件。对于使用 @LoadDataBeforeShow 注解的界面,尚未加载数据。在此事件监听器中,可以加载数据、检查权限和修改 UI 组件。例如:

    @Subscribe
    protected void onBeforeShow(BeforeShowEvent event) {
        customersDl.load();
    }
  • AfterShowEvent 在显示界面之后立即发送此事件,此时,界面已经被添加到应用程序 UI 中。保存的组件设置已应用到 UI 组件。在此事件监听器中,可以显示通知、对话框或其它界面。例如:

    @Subscribe
    protected void onAfterShow(AfterShowEvent event) {
        notifications.create().withCaption("Just opened").show();
    }
  • 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 在界面通过 close(CloseAction) 方法关闭并且在 Screen.AfterDetachEvent 之后发送的事件。组件设置已保存。在此事件监听器中,可以在关闭界面后显示通知或对话框,例如:

    @Subscribe
    protected void onAfterClose(AfterCloseEvent event) {
        notifications.create().withCaption("Just closed").show();
    }
  • AfterDetachEvent 在用户关闭界面或用户注销时从应用程序 UI 中移除界面后发送的事件。此事件监听器可用于释放界面持有的资源。请注意,在 HTTP 会话过期时不会发送此事件。

  • UrlParamsChangedEvent 打开的界面对应的浏览器 URL 参数更改时发送的事件。事件在显示界面之前触发,以便能够进行一些准备工作。在此事件监听器中,可以根据新的参数加载一些数据或更改界面控件的状态:

    @Subscribe
    protected void onUrlParamsChanged(UrlParamsChangedEvent event) {
        Map<String, String> params = event.getParams();
        // handle new params
    }
3.5.1.1.3. 界面控制器注解

控制器上的类级别的注解用于向框架提供界面相关的信息。一部分注解适用于任何类型的界面,也有一部分仅用于实体编辑或查找界面。

以下示例演示了常见的界面注解的用法:

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() 方法来手动加载数据。

  • @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.2. 界面 XML 描述

界面描述是一个 XML 文件,包含可视化组件数据组件和一些界面参数的声明性定义。

界面描述具有 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 可以使用各种参数打开所有类型的界面。下面是用它打开界面并且在界面关闭之后执行一些代码的例子(更多细节参考这里):

@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();
}

下面我们看看如何操作编辑界面和查找界面。

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();
}

另一个方式是为参数定义一个特殊的类,然后在界面构造器中将该类的实例传递给标准的 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 的方式。

如果界面是基于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();
}

事件对象能提供关于界面是如何关闭的信息:其 getCloseAction() 方法返回带 CloseAction 接口的对象。界面控制器实现的 FrameOwner 接口包含几个常量,定义了框架使用的 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")
    private void onOkBtnClick(Button.ClickEvent event) {
        result = "Done";
        close(WINDOW_COMMIT_AND_CLOSE_ACTION); (1)
    }

    @Subscribe("cancelBtn")
    private void onCancelBtnClick(Button.ClickEvent event) {
        closeWithDefaultAction(); (2)
    }
}
1 - 在点击 "OK" 按钮时,设置一些结果状态,并使用标准的 WINDOW_COMMIT_AND_CLOSE_ACTION 操作关闭界面。
2 - 在点击 "Cancel" 按钮时,使用默认操作关闭界面。

于是,在 AfterCloseEvent 监听器我们能分析界面是如何关闭的,并且如果需要的话可以读取结果:

@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.getCloseAction().equals(WINDOW_COMMIT_AND_CLOSE_ACTION)) {
                        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;
    }
}

然后可以使用该操作类关闭界面:

@Inject
private Screens screens;
@Inject
private Notifications notifications;

private void openOtherScreen() {
    Screen otherScreen = screens.create("demo_OtherScreen", 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. 使用界面 Fragments

在本章节,介绍如何定义和使用界面 fragments



声明式使用 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

同一个 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)
        addressFragment.init(); (3)
        addressBox.add(addressFragment.getFragment()); (4)
    }
}
1 - 注入 Fragments bean,用来实例化界面 fragment
2 - 用 class 创建 fragment 控制器
3 - 初始化界面 fragment
4 - 从控制器获取 Fragment 可视化组件,并添加到 UI 容器

别忘了在用编程的方式创建 fragment 之后需要使用 init() 方法进行初始化。如果 fragment 带有参数,可以在调用 init() 之前使用 public setters 进行设置。之后,fragment 控制器的 InitEventAfterInitEvent 处理方法里面可以访问到这些参数。

界面 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 之外其它所有的属性都会被忽略,但是也可以加上,以便提供设计思路。

订阅父界面事件

在 fragment 控制器中,可以订阅父界面的事件,需要在注解中为 target 属性指定 PARENT_CONTROLLER 值,示例:

@Subscribe(target = Target.PARENT_CONTROLLER)
private void onBeforeShowHost(Screen.BeforeShowEvent event) {
    //
}

这个方法可以处理任何事件,包括实体编辑界面发送的 InitEntityEvent 事件。

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.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

标签列表

gui_tokenList

3.5.2.1.1. 应用程序菜单

AppMenu 应用程序菜单组件提供了在主窗口布局中自定义主菜单的方式,通过它可以动态管理菜单项。

gui AppMenu

CUBA 框架提供标准的 mainWindow screen - 主窗口界面;CUBA Studio 也基于这个主窗口界面提供了界面模板。这个模板扩展了 AppMainWindow 类,通过它可以直接访问 AppMenu - 应用程序菜单实例:

public class ExtAppMainWindow extends AppMainWindow {

    @Inject
    private Notifications notifications;

    @Subscribe
    protected 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() - 设置菜单项是否可见。



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 的属性:

  • alternateText - 如果 frame 中没有设置内容源或者内容源不可用的情况下,作为默认显示的文字。

browserFrame 内容源的配置信息:

  • 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 实例,那么按钮会从操作获取以下属性: captiondescriptionicon, enablevisible 。其中 captiondescriptionicon 属性只有在 Button 本身没有设置时才会使用操作的对应属性,其它的 Action 的属性比 Button 的相同属性有更高的优先级。

如果 操作 的属性在其被指定给 Button 之后发生了改变, 那么 Button 的相应属性也会跟着改变,也就是说按钮监听 操作 的变化。这种情况下,如果操作的 captiondescriptionicon 改变的话,即便按钮本身也定义了这些属性,这些属性还是会跟随操作的属性变化而变化。

按钮样式

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 很像,只有在光标悬浮到这个按钮上面才会有样式变化。



3.5.2.1.4. 批量编辑器

BulkEditor - 批量编辑器支持一次修改多个实体对象的属性值。它是个按钮,一般可以加到表格组件, 点击它时打开批量编辑器界面。

gui bulkEdit

该组件对应的 XML 名称: bulkEditor

要使用 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


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 - 日期范围中的结束日期。

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

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

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

  • startDate - 日期范围中的开始日期。

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

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

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

    如何使用日历事件:

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

    @Inject
    private Calendar calendar;
    
    public void generateEvent(String caption, String description, Date start, Date end, boolean isAllDay, String stylename) {
        SimpleCalendarEvent 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 (默认创建) 和 EntityCalendarEventProvider

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

    @Inject
    private Calendar calendar;
    
    public void addEvents() {
        ListCalendarEventProvider listCalendarEventProvider = new ListCalendarEventProvider();
        calendar.setEventProvider(listCalendarEventProvider);
        listCalendarEventProvider.addEvent(generateEvent(
                "Training", "Student training", "2016-10-17 09:00", "2016-10-17 14:00", false, "event-blue"));
        listCalendarEventProvider.addEvent(generateEvent(
                "Development", "Platform development", "2016-10-17 15:00", "2016-10-17 18:00", false, "event-red"));
        listCalendarEventProvider.addEvent(generateEvent(
                "Party", "Party with friends", "2016-10-22 13:00", "2016-10-22 18:00", false, "event-yellow"));
    }
    
    private SimpleCalendarEvent generateEvent(String caption, String description, String start, String end, Boolean allDay, String style) {
        SimpleCalendarEvent calendarEvent = new SimpleCalendarEvent();
        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
        calendarEvent.setCaption(caption);
        calendarEvent.setDescription(description);
        calendarEvent.setStart(df.parse(start));
        calendarEvent.setEnd(df.parse(end));
        calendarEvent.setAllDay(allDay);
        calendarEvent.setStyleName(style);
        return calendarEvent;
    }

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

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

    <calendar id="calendar"
              datasource="calendarEventsDs"
              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 的可见性。但是如果此时界面已经打开了,控制可见性就不一定能按预想工作。



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,那么复选框是默认为非选中状态。



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() 方法,或使用 optionsDatasourceoptionsContainer 属性指定组件选项列表。

  • 使用 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));
    }

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



3.5.2.1.10. 货币组件

CurrencyField 是文本字段的子类型,专门用来输入货币值。在这个字段内部有个货币符号,默认是右对齐状态。

gui currencyField

该组件对应的 XML 名称: currencyField

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

CurrencyField 也可以通过 datasourceproperty 属性绑定数据源

<currencyField currency="$"
               datasource="orderDs"
               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 指示数据容器实体中的属性,对应的数据库中的数据会展示在该列。

dataGrid - 数据网格中的元素:

  • columns - 必要元素,定义数据网格的所有列。其中每一列在单独内嵌的 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() 方法可以控制数据加载器中的数据量,进而能控制每页最大行数。另外,绑定到相同数据源的通用过滤器组件也能实现此功能。

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

数据网格控件属性:

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

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

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

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

  • columnsCollapsingAllowed - 允许隐藏/折叠列,定义用户是否可以在侧边栏菜单中隐藏/折叠某些列。侧边栏菜单中显示的列旁边会有复选框。当用户选择或者取消选择某列时,对应列的 collapsed 属性值会变化。当 columnsCollapsingAllowed 属性为 false 时,列对应的 collapsed 属性不能被设置为 true

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

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

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

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

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

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

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

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

  • 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 滚动到开头或结尾。

  • setCellStyleProvider() - 设置 DataGrid 单元格的显示样式。

  • setRowStyleProvider() - 设置 DataGrid 行显示样式。

  • 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)。

使用描述提供者:

  • 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); (1)
    headerBox.add(infoLabel);
    headerBox.add(closeButton);
    headerBox.expand(infoLabel);

    Component content = getContent(order); (2)

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

    return mainLayout;
}
1 – 参考 DataGridDetailsGeneratorSample 类中的 createCloseButton 方法描述。
2 – 参考 DataGridDetailsGeneratorSample 类中的 getContent 方法描述。

结果如图所示:

gui dataGrid 15

使用数据网格行内编辑器:

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

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

  • getEditedItemId() - 返回正在被编辑的数据项的 id

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

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

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

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

  • 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 组件通过以下方法生成列:

  • 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

ColumnGeneratorEvent 通过 getValue 方法传入, 它包含实体信息,在当前行的 ID 为 loginUpperCase 的列显示需要的数据。

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

使用渲染器:

数据在列中的显示方式可以通过渲染器自定义。比如想在单元格中显示图标,可以使用 ImageRenderer 类和图标路径实现:

@Subscribe
protected void onInit(InitEvent event) {
    DataGrid.Column avatar = usersGrid.addGeneratedColumn("userAvatar", new DataGrid.ColumnGenerator<User, String>() {
        @Override
        public String getValue(DataGrid.ColumnGeneratorEvent<User> event) {
            return "icons/user.png";
        }

        @Override
        public Class<String> getType() {
            return String.class;
        }
    }, 0);
    avatar.setCaption("Avatar");
    avatar.setRenderer(usersGrid.createRenderer(DataGrid.ImageRenderer.class));
}

结果如下:

gui dataGrid 8

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

当字段类型与渲染器支持的类型不匹配时,可以创建一个 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.CHECK_SQUARE_O.getHtml()
                    : FontAwesome.SQUARE_O.getHtml();
        });
}

结果如下:

gui dataGrid 9

渲染器可以通过两种方式创建:

  • DataGrid 接口的 set 方法中直接设置渲染器接口。

  • 为对应的模块直接创建渲染器实现:

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

目前平台支持以下渲染器接口:

  • TextRenderer - 显示文本。

  • HtmlRenderer - 显示 HTML 布局。

  • ProgressBarRenderer - 把 0 到 1 之间的 double 浮点值作为 ProgressBar 进度条组件显示。

  • DateRenderer - 以预定义格式显示日期。

  • NumberRenderer - 以预定义格式显示数字。

  • ButtonRenderer - 把字符串值做为按钮文字展示。

  • ImageRenderer - 将指定路径的图像显示。

  • CheckBoxRenderer - 将布尔值做为复选框显示。

表头(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" datasource="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

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
  • 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

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

    <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

下面这个例子展示了在 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 用来作为从浏览器外部拖拽文件可以放置的目标容器区域。如果这个容器的样式没有特殊设置,当文件被拖拽到这块区域的时候,这个容器会被高亮显示,否则目标区域不会显示。

参考 加载和显示图片 有更多复杂的使用上传文件的例子。



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"/>
  • 在界面控制器中,需要注入该控件本身,还需要注入 FileUploadingAPIDataSupplier 这两个接口。订阅 InitEvent 事件并且添加事件监听器,这样可以对文件上传成功或者出错做出相应的操作:

    @Inject
    private FileUploadField uploadField;
    @Inject
    private FileUploadingAPI fileUploadingAPI;
    @Inject
    private DataSupplier dataSupplier;
    @Inject
    private Notifications notifications;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        uploadField.addFileUploadSucceedListener(uploadSucceedEvent -> {
    
            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);
            }
            dataSupplier.commit(fd); (4)
            notifications.create()
                    .withCaption("Uploaded file: " + uploadField.getFileName())
                    .show();
        });
    
        uploadField.addFileUploadErrorListener(uploadErrorEvent ->
                notifications.create()
                        .withCaption("File upload error")
                        .show());
    }
1 此时如果需要的话,可以取到保存在临时存储的文件。
2 一般来说,文件需要保存到中间件的文件存储中。
3 将文件保存至 FileStorage。
4 将文件描述器保存到数据库。

该控件将所有选择的文件上传到客户端层(client tier) 的临时存储(temporary storage)并且调用通过 addFileUploadSucceedListener() 添加的监听器。在这个监听器里面,从 uploadField 获取了一个 FileDescriptor

com.haulmont.cuba.core.entity.FileDescriptor (别跟 java.io.FileDescriptor 混淆了) 是一个持久化实体,唯一定义一个上传的文件,并且也用这个类从系统下载文件。

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

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

通过 addFileUploadErrorListener() 方法添加的监听器会在从客户端上传文件到临时存储出错的时候被调用。

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

  • AfterValueClearListener

  • BeforeValueClearListener

  • FileUploadErrorListener

  • FileUploadFinishListener

  • FileUploadStartListener

  • FileUploadSucceedListener

  • ValueChangeListener

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"/>

参考 加载和显示图片 有更多复杂的使用上传文件的例子。



3.5.2.1.18. 过滤器

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

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

Filter 需要连接到一个包含数据加载器集合型数据容器或者包含 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>

在上面的例子中,在界面的 data 层定义了一个数据容器使用 JPQL 查询 Car 实体。在 filter 组件的 loader 属性中定义了需要被过滤的数据,数据由 数据加载器 提供。采用表格组件显示数据,也是关联了相同的数据容器。

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
    • paramWhere − 在参数是关联的实体的情况下,用这个参数来设置 JPQL 表达式用以选取条件参数的列表。参考 property 元素的同名属性的描述。

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

filter 属性:

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

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



用户权限

  • 需要创建/修改/删除全局(对所有用户可见)过滤器,用户需要有 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

注意不能在界面的 init() 方法中使用这些方法,因为过滤器在那时还没有初始化。比较合适的使用过滤器参数的地方是 ready() 方法。

过滤器的全文搜索模式

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

gui filter fts

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

如果在 applyTo 属性定义了一个表格,当把鼠标指针移到表格的某一行的时候,就会提示哪些实体属性支持查询条件。

如果要隐藏过滤器模式切换的复选框,可以设置 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 expand="editActions" spacing="true">
    <form id="form" dataContainer="orderDc">
        <dateField property="date"/>
        <textField property="amount"/>
        <pickerField property="customer"/>
    </form>
</layout>

在上面的例子中,data 上下文定义了一个 orderDc 数据容器,它包含 Order 实体的单个实例。数据容器在 form 组件的 dataContainer 属性中指定。嵌套元素指的是需要在组件中显示的实体属性。它们会根据属性数据类型自动类型化。

form 的属性:

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

  • 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 使用自动大小。

Form 接口的方法:

  • add() - 允许在运行时向 Form 添加一个或多个字段。它接受一个 Component 实例作为参数,也可以通过添加 columnrow 索引来定义新字段的位置。

    框架不会为使用编程方式添加的组件指定数据容器,在这种情况下,使用 setValueSource() 方法进行数据绑定。

    示例:

    <data>
        <instance id="container"/>
    </data>
    <layout>
        <form id="form"/>
    </layout>
    @Inject
    private KeyValueContainer container;
    @Inject
    private Form form;
    
    @Subscribe
    protected void onInit(InitEvent event) {
        Field fooField = (Field) uiComponentsGenerator.generate(
                new ComponentGenerationContext(container.getEntityMetaClass(), "foo"));
        fooField.setCaption("Foo");
        fooField.setValueSource(new ContainerValueSource<>(container, "foo"));
        form.add(fooField);
    }


3.5.2.1.20. 分组表格

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

gui groupTableDragColumn

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

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

<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,打开对应界面时,会默认将数据按这些列进行分组。

可以针对每个列 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() - 取消所有分组。

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



3.5.2.1.21. 图片组件

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

该组件的 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 - 保留长宽比压缩图片到能刚好在组件中全部展示。

    • 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 图片组件接口的方法:

  • setDatasource() - 设置图片数据源类型,只支持 FileDescriptorbyte[] 两种类型。

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

    frameworksTable.addGeneratedColumn("image", entity -> {
        Image image = uiComponents.create(Image.NAME);
        image.setDatasource(frameworksTable.getItemDatasource(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 代码解析,否则按纯文本解析。

标签样式

在基于 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 - datasource - description - descriptionAsHtml - enable - box.expandRatio - height - htmlEnabled - 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 与普通 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

  1. 使用 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 属性中。此时,这个属性为枚举类型,控件的下拉列表中会显示所有枚举值的本地化名称

  2. 类似的,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定义显示到下拉框的实体属性值,而非实例名称,这样可以设置下拉列表的文字值。

  3. 选项列表还可以通过 setOptionsList()setOptionsMap()setOptionsEnum() 设置,也可以在 XML 描述中通过 optionsContainer 或者 optionsDatasource 属性设置。

    • 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() 需要枚举类做为参数。下拉列表会显示枚举值的本地化名称,组件的值则为枚举值。

  • 通过 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">
  • 如果 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 的最简单的情况是为实体属性选择枚举值。例如,Role 实体具有 RoleType 类型的 type 属性,type 属性就是一个枚举值。就可以使用 OptionsGroup 来编辑这个属性,如下所示:

    <dsContext>
        <datasource id="roleDs" class="com.haulmont.cuba.security.entity.Role" view="_local"/>
    </dsContext>
    <layout>
        <optionsGroup datasource="roleDs" property="type"/>
    </layout>

    上面的示例中为 Role 实体定义了数据源 : roleDs 。在 optionsGroup 组件中,指向数据源的链接在 datasource 属性中指定,实体属性的名称在 property 属性中设置。

    组件的显示效果:

gui optionsGroup roleType
  • 组件选项列表可通过 setOptionsList()setOptionsMap()setOptionsEnum() 方法任意地指定,或者使用 optionsDatasource 属性来指定。

  • setOptionsList() 方法允许以编程方式指定组件选项列表。为此,在 XML 描述中声明一个组件:

    <optionsGroup id="numberOfSeatsField"/>

    然后将该组件注入控制器,并在 init() 方法中指定选项列表:

    @Inject
    protected OptionsGroup numberOfSeatsField;
    
    @Override
    public void init(Map<String, Object> params) {
        List<Integer> list = new ArrayList<>();
        list.add(2);
        list.add(4);
        list.add(5);
        list.add(7);
        numberOfSeatsField.setOptionsList(list);
    }

    该组件将会如下显示:

    gui optionsGroup integerList

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

  • setOptionsMap() 方法允许分别指定选项的字符串名称和选项值。例如,我们可以在控制器的 init() 方法中为已经在 XML 描述中配置的 numberOfSeatsField 组件设置以下选项 map:

    @Inject
    protected OptionsGroup numberOfSeatsField;
    
    @Override
    public void init(Map<String, Object> params) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("two", 2);
        map.put("four", 4);
        map.put("five", 5);
        map.put("seven", 7);
        numberOfSeatsField.setOptionsMap(map);
    }

    该组件将会如下显示:

    gui optionsGroup integerMap

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

  • setOptionsEnum() 方法将一个枚举类作为参数。选项列表中将显示枚举值的本地化名称,而组件的值将是枚举值。

  • 该组件可以从数据源中获取选项列表。为此,需要使用 optionsDatasource 属性。例如:

    <dsContext>
        <collectionDatasource id="coloursDs" class="com.company.sample.entity.Colour" view="_local">
            <query>select c from sample$Colour c</query>
        </collectionDatasource>
    </dsContext>
    <layout>
        <optionsGroup id="coloursField" optionsDatasource="coloursDs"/>
    </layout>

    在这种情况下,coloursField 组件将显示 coloursDs 数据源中的 Colour 实体的实例名,它的 getValue() 方法将返回所选的实体实例。

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

  • multiselect 属性用于将 OptionsGroup 转换为多选模式。如果启用 multiselect,组件将显示为一组独立的复选框,组件值是所选选项的列表。

    例如,在 XML 描述中创建该组件:

    <optionsGroup id="roleTypesField" multiselect="true"/>

    并为其设置一个选项列表 – RoleType 枚举值:

    @Inject
    protected OptionsGroup roleTypesField;
    
    @Override
    public void init(Map<String, Object> params) {
        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 值设置为水平方向。



3.5.2.1.29. 选项列表

OptionsListOptionsGroup 组件的变体,它将选项列表展示为可垂直滚动的列表。如果启用了多选,则可以通过单击时按住 Ctrl 键来选择多个选项,或按住 Shift 键来选择一个范围内的选项。

gui optionsList

该组件对应的 XML 名称: optionsList

默认情况下,OptionsList 组件在建议弹窗中显示第一个空元素,可以通过将 nullOptionVisible 属性设置为 false 来禁止此行为。

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);
      }

      可以在编程时使用相应的组件事件来对标准操作进行参数化。比如,可以通过如下方式设置特定的查找界面 ID:

      @Inject
      private LookupScreens lookupScreens;
      @Inject
      private PickerField<Color> pickerField;
      
      @Subscribe("pickerField.lookup")
      protected void onPickerFieldLookupActionPerformed(Action.ActionPerformedEvent event) {
          lookupScreens.builder(pickerField)
                       .withScreen("custom-lookup")
                       .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 = editorScreens.builder(pickerField)
                  .withScreen(CustomerEdit.class)
                  .build();
          customerEdit.setDiscount(true);
          customerEdit.show();
      }

      操作的声明式创建和编程式创建在操作以及操作接口部分有描述。

  • 可以在不绑定实体的情况下使用 PickerField,即不设置 dataContainer/datasourceproperty属性。在这种情况下,metaClass 属性应该用于指定 PickerField 的实体类型。例如:

    <pickerField id="colorField" metaClass="sample$Color"/>

    可以通过将组件注入控制器并调用其 getValue() 方法来获取所选实体的实例。

    要正确使用 PickerField 组件,需要设置 metaClass 属性,或者同时设置 dataContainer/datasourceproperty 属性。

    可以在 PickerField 中使用键盘快捷键,有关详细信息,请参阅快捷键



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();
    }


3.5.2.1.33. 弹窗查看控件

PopupView 是一个允许用容器打开弹出窗口的控件。可以通过单击简要值或以编程方式打开弹窗。可以通过鼠标移出或点击外部区域来关闭弹窗。

典型的 PopupView 如下所示:

Popup hidden
Figure 18. 弹窗隐藏状态
Popup visible
Figure 19. 弹窗打开状态

从本地化消息包中获取简要值的 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 的属性:

  • 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() 方法,或使用 optionsDatasourceoptionsContainer 属性指定组件选项列表。

  • 使用 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));
    }

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 – 定义用户能改变该组件大小的方式,当该组件的大小用百分比定义时除外。

    <textArea 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


3.5.2.1.39. 搜索选择器控件

SearchPickerField 组件用于根据输入的字符串搜索实体实例。用户可输入几个字符,然后按 Enter 键。如果找到了多个匹配项,则所有匹配项都将显示在下拉列表中。如果只有一个实例与搜索关键字匹配,则这个实例直接成为组件值。 还可以通过单击 SearchPickerField 组件右侧的按钮来执行操作。

gui searchPickerFieldOverlap

该组件的 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)以及应用自定义样式的方法。

它也可以像其它可视化组件一样用在任何界面中。要将 SideMenu 组件添加到界面,应该将 xmlns:main ="http://schemas.haulmont.com/cuba/mainwindow.xsd" 命名空间添加到界面描述中。

gui sidemenu

该组件的 XML 名称: sideMenu

在界面 XML 描述中定义该组件的示例:

<main: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%">
                <main:userIndicator id="userIndicator"
                                    align="MIDDLE_CENTER"/>
                <main:newWindowButton id="newWindowButton"
                                      description="mainMsg://newWindowBtnDescription"
                                      icon="app/images/new-window.png"/>
                <main:logoutButton id="logoutButton"
                                   description="mainMsg://logoutBtnDescription"
                                   icon="app/images/exit.png"/>
            </hbox>
            <main:sideMenu id="sideMenu"
                           width="100%"/>
            <main:ftsField id="ftsField"
                           width="100%"/>
        </vbox>
        <main:workArea id="workArea"
                       height="100%">
            <main:initialLayout margin="true"
                                spacing="true">
                <label id="welcomeLabel"
                       align="MIDDLE_CENTER"
                       stylename="c-welcome-text"
                       value="mainMsg://application.welcomeText"/>
            </main:initialLayout>
        </main: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 值。



3.5.2.1.41. 源码编辑器

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


3.5.2.1.42. 建议字段

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";
        }
    });


3.5.2.1.43. 建议选择器字段

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.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 + "%")));
});
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.44. 表格

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 元素定义哪些实体属性用作表格列。

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 – 定义表格列的必须元素。

    每个列都在嵌套的 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

    • width − 可选属性,控制默认列宽。只能是以像素为单位的数值。

    • align − 可选属性,用于设置单元格的文本对齐方式。可选值:LEFTRIGHTCENTER。默认为 LEFT

    • editable − 可选属性,允许编辑表中的相应列。为了使列可编辑,整个表的 editable 属性也应设置为 true。不支持在运行时更改此属性。

    • sortable − 可选属性,用于禁用列的排序。整个表的 sortable 属性为 true 此属性有效(默认为 true)。

    • 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(*) 聚合函数代替查询列。然后显示检索到的数字,代替 ? 图标。

  • actions − 可选元素,用于描述和表格相关的操作。除了自定义的操作外,该元素还支持以下在 com.haulmont.cuba.gui.actions.list 里定义标准操作createeditremoverefreshaddexcludeexcel

  • 可选元素,在表格上方添加一个 ButtonsPanel 容器来显示操作按钮。

table 属性:

  • 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 按钮位于表头的右侧。当前显示的列在菜单中标记为选中状态。

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 属性,在这个属性中设置聚合函数。默认情况下,聚合列仅支持数值类型,例如 Integer 、 Double 、 LongBigDecimal。聚合表格值显示在表格顶部的附加行中。这是一个定义聚合表示例:

    <table id="itemsTable" aggregatable="true" dataContainer="itemsDc">
        <columns>
            <column id="product"/>
            <column id="quantity"/>
            <column id="amount">
                <aggregation type="SUM"/>
            </column>
        </columns>
    </table>

    aggregation 元素还可以包含 strategyClass 属性,指定一个实现 AggregationStrategy 接口的类(参阅下面以编程方式设置聚合策略的示例)。

    可以指定不同于 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<>(carsDc, "color"));
            field.setOptions(new ContainerOptions<>(colorsDc));
            field.addAction(actions.create(LookupAction.class));
            field.addAction(actions.create(OpenAction.class));
            return field;
        });
    }

    在上面的示例中,表中 color 列中的所有单元格都显示了 LookupPickerField 组件。组件应将它的值保存到相应的行中的实体的 color 属性中。

    如果要显示动态文本,请使用特殊类 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());
    }
  • 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 属性,则执行该操作。



3.5.2.1.45. 文本区

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.46. 文本控件

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 事件,通常是由按键触发。请求是独立且一个接一个地顺序处理。文本变化事件以异步方式与服务器交互,因此可以在处理事件请求的同时继续输入。

  • 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 的来源。

  • 如果文本控件连接到实体属性(通过 datasourceproperty),并且实体属性具有定义在 @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.47. 时间组件

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.48. 标签列表

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 optionsDatasource="allItemsDs" 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, true 将传递到查找界面的参数字典的 MULTI_SELECT 键。这个标志用于设置界面为多选模式。这个标志定义在 WindowParams 枚举中,因此在旧版本的界面中可以通过以下方式使用:

      @Override
      public void init(Map<String, Object> params) {
          if (WindowParams.MULTI_SELECT.getBool(getContext())) {
              usersTable.setMultiSelect(true);
          }
      }
  • addButton – 对添加条目的按钮的描述。可包含 captionicon 属性。

tokenList 监听器:

  • ItemClickListener 允许跟踪 tokenList 列表条目上的点击操作。

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



3.5.2.1.49. 树

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 属性指定要显示为树节点名称的实体属性的名称,如果这个属性没有定义,将默认显示实体的实例名称

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";
    });
}

对于之前的老界面,Tree 组件可以绑定到一个数据源而不是数据容器。这种情况下,需要定义嵌套的 treechildren 元素,这个元素需要包含指向在 datasource 属性定义的 hierarchicalDatasource 的引用。hierarchicalDatasource 的声明需要包含 hierarchyProperty 属性,此属性定义了实体属性的名称,这个属性也指向相同的实体。



3.5.2.1.50. 树形数据网格

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


3.5.2.1.51. 树形表格

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.52. 双列

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 属性– 配置每个列表中的行数。

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 – 设置标签内容延迟加载。

    在打开界面时,延迟加载的标签页不会立即加载其内容,这样可以减少内存中的组件数量。仅当用户选择标签页时,才会加载标签页中的组件。此外,如果延迟标签页中包含的可视化组件带有连接到 JPQL 查询的数据源,则也不会执行此查询。因此,界面可以更快地打开,只有当用户选中标签页请求数据时才会加载数据。

    请注意,刚打开界面时,延迟加载标签页上包含的组件并不存在。因此,这些组件不能被注入到控制器,也不能在控制器的 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 组件),这时按钮面板会变为隐藏状态。

使用 Frame.openLookup() 打开查找界面时,可以利用 alwaysVisible 属性禁止隐藏按钮面板。如果属性值为 true,则不会隐藏按钮面板。默认情况下,属性值为 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

在基于 Halo 主题的 Web 客户端中,可以使用 XML 描述或界面控制器中的 stylename 属性为 groupBox 组件设置预定义样式。以编程方式设置样式时,选择一个以 LAYOUT_GROUPBOX_ 为前缀的 HaloTheme 类常量。showAsPanel 属性设置为 true 时可以与以下样式结合使用:

  • borderless 样式删除 groupBox 的边框和背景颜色:

    groupBox.setShowAsPanel(true);
    groupBox.setStyleName(HaloTheme.GROUPBOX_PANEL_BORDERLESS);
  • card 样式会使布局看起来像卡片布局。

  • well 样式会使容器具有下沉阴影效果:

    <groupBox caption="Well-styled groupBox"
              showAsPanel="true"
              stylename="well"
              width="300px"
              height="200px"/>
    gui groupBox Panel 2

可以在 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>


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 20. 布局中不带滚动条的完整大小的 textArea
layout 2
Figure 21. 当窗口的大小小于布局的最小尺寸时,滚动条出现

这些属性在弹出对话框中也有效:

<dialogMode forceDialog="true"
            width="500"
            height="250"/>
<layout minWidth="600px"
        minHeight="200px">
    <textArea width="250px"/>
</layout>
layout 3
Figure 22. 对话框模式,当窗口的大小小于布局的最小尺寸时,滚动条出现
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 23. 带有 textArea 的显示完整的 scrollBox
gui scrollBox 2
Figure 24. 窗口尺寸调整时滚动条出现,管理内容的宽度

建议设置内容的高和宽,否则,放置在 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



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 – 设置标签页内容延迟加载。

    当界面打开时,延迟标签页不会加载其内容,这样可以减少内存中的组件数量。只有当用户选择某个标签页时,才会加载标签页中的组件。另外,如果延迟标签页包含连接到使用 JPQL 查询的数据源的可视化组件,也不会立即执行 JPQL 查询。因此,界面会打开得更快,并且只有当用户通过选择标签页请求数据时才会加载其中的数据。

    请注意,当界面打开时,在延迟标签页上的组件是不存在的。因此,它们不能注入到控制器中,并且不能通过调用控制器的 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 - 为标签栏中的标签页周围添加少量内边距,以便它们不会紧挨着组件的外边缘。



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 Formatter<BigDecimal> {

    protected GeneralConfiguration generalConfiguration;
    protected Currency currentCurrency;

    public CurrencyFormatter(GeneralConfiguration generalConfiguration) {
        this.generalConfiguration = generalConfiguration;
        currentCurrency = generalConfiguration.getCurrency();
    }

    @Override
    public String format(BigDecimal value) {
        return currentCurrency.format(value);
    }
}
protected void initTableColumns() {
    Formatter<BigDecimal> currencyFormatter = new CurrencyFormatter(generalConfiguration);
    table.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. 定时器

定时器是一个非可见的界面组件,可以以一定的时间间隔运行一些界面控制器的代码。定时器是在一个处理用户事件的线程里面运行的,所以可以更新界面组件。当创建定时器的界面被关闭之后,定时器就会停止工作了。

该组件对应的 XML 名称:timer

在 Web 客户端,定时器是基于浏览器的轮询(polling)机制实现,在 desktop 客户端,是基于 javax.swing.Timer 实现的。

创建定时器的主要方法是在界面的 XML 描述中进行声明 - 使用 timers 元素,位置在 dsContextlayout 元素之间。

  • delay - 必选属性;按毫秒定义定时器执行的时间间隔。

  • autostart – 可选属性;当设置成 true 的时候,定时器会在界面打开的时候立即自动启动。默认值是 false,也就是说只有在调用定时器的 start() 方法之后才会启动。

  • repeating – 可选属性;开启定时器的重复执行模式。如果这个属性设置的是 true,定时器会按照 delay 设置的时间间隔反复一轮一轮的执行。否则只会在 delay 设定的毫秒时间之后执行一次。

  • onTimer – 可选属性;定义一个当定时器触发的时候需要执行的方法名称。这个对应的方法必须定义在界面控制器中,需要是 public 的,而且有一个 com.haulmont.cuba.gui.components.Timer 类型的参数。

下面这个例子演示了使用定时器来周期性的刷新表格内容:

<window ...
  <dsContext>
      <collectionDatasource id="bookInstanceDs" ...
  </dsContext>
  <timers>
      <timer delay="3000" autostart="true" repeating="true" onTimer="refreshData"/>
  </timers>
  <layout ...
@Inject
private CollectionDatasource bookInstanceDs;

public void refreshData(Timer timer) {
    bookInstanceDs.refresh();
}

定时器可以注入一个界面控制器,也可以通过 Window.getTimer() 方法获得。定时器的执行可以用定时器的 start()stop() 方法控制。对于已经启动的定时器,会忽略再次调用 start(),但是当定时器使用 stop() 方法停止之后,可以通过 start() 方法再次启动。

下面这个例子演示了在 XML 中定义定时器,然后在控制器里面使用定时器监听:

<timers>
    <timer id="helloTimer" delay="5000"/>
</timers>
@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 启动定时器

定时器也可以在控制器里面创建,如果是这样的话,需要显式的把这个定时器加到界面中,比如:

@Inject
private Notifications notifications;
@Inject
private UiComponents uiComponents;

@Subscribe
protected void onInit(InitEvent event) {
    Timer helloTimer = uiComponents.create(Timer.NAME);
    getWindow().addTimer(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.2.4.5. 验证器控件

Validator 设计用来检查可视化组件中输入的值。

验证和输入检查是需要区分开来的,输入检查是说:假设一个文本组件(比如,TextField)的数据类型设置的不是字符串(这种情况可能出现在绑定实体属性或者手动设置控件的 datatype ),那么这个组件会阻止用户输入不符合它定义的数据类型的值。当这个组件失去焦点时或者用户按了回车,这个组件会显示之前正确的值。

验证不会在输入同时或者失去焦点时马上反馈,而是会在组件的 validate() 方法调用的时候。也就是说这个组件(还有这个组件关联的实体属性)暂时会包含一个可能并不符合验证规则的值。但是这没关系,因为需要验证的字段一般都会在编辑界面,所有的字段提交前会自动调用验证方法。如果组件不在一个编辑界面,那么这个组件的 validate() 方法需要在界面控制器显式的调用。

在界面的 XML 描述中,组件的验证器可以在嵌套的 validator 元素中定义。validator 元素可以有这些属性:

  • script − Groovy 脚本的路径。这个脚本用来做验证。

  • class − 实现了 Field.Validator 接口的 Java 类名。

Groovy 验证脚本和标准 Java 验证器类,位置在 com.haulmont.cuba.gui.components.validators 包,支持 message 属性,用来定义验证失败时显示的消息。这个属性的值可以是包含消息内容的字符串或者一个当前界面语言消息包内的一个消息键名。比如:

<validator class="com.haulmont.cuba.gui.components.validators.PatternValidator"
           message="msg://validationError"
           pattern="\d{3}"/>
# messages.properties
validationError = Input error

可以通过 CUBA Studio 添加验证器。下面是给 fieldGroup 组件添加验证器的例子:

gui validator

验证机制是按如下方式确定的:

  • 如果 script 属性的值没有设置,并且 validator 元素也没有包含 Groovy 表达式的文本,系统会使用 class 属性定义的类来进行验证。

    <field property="amount">
        <validator class="com.haulmont.cuba.gui.components.validators.DoubleValidator"/>
    </field>
  • 如果 validator 元素包含了文本,那这个文本会被当作 Groovy 表达式通过 Scripting 来执行。

    <field property="year">
        <validator>
            value ==~ /\d+/
        </validator>
    </field>
  • 否则,系统会使用 Scripting 来运行定义在 script 里的 Groovy 脚本。

    <field property="zipCode">
        <validator script="com.company.demo.web.address.ZipValidator"/>
    </field>

变量 value 会被传入 Groovy 表达式或者脚本,这个值包含了可视化组件中用户输入的值。表达式或者脚本需要返回一个 boolean 值: true 表示验证通过,false 表示没通过。

如果使用 Java 类作为验证器,这个类要么有一个不带任何参数的默认构造函数,要么有带有如下参数组合的构造函数:

  • org.dom4j.Element, String – 这个构造函数会接收验证器的 XML 元素和界面消息包的名字。

  • org.dom4j.Element – 这个构造函数会接收验证器的 XML 元素。

如果验证器是用内部类的方式实现的,这个类需要是 static 的,名字用 "$" 跟外部类分隔,比如:

<validator class="com.sample.sales.gui.AddressEdit$ZipValidator"/>

平台已经包含了一组常用的验证器实现(参考 com.haulmont.cuba.gui.components.validators 包),可以在项目里使用:

  • DateValidator - 日期验证

  • DoubleValidator - 双浮点数验证

  • EmailValidator - 邮件地址验证

  • IntegerValidator - 整数类型验证

  • LongValidator - 长整型验证

  • PatternValidator - 正则模式验证

  • ScriptValidator - 脚本验证

  • StringValidator - 字符串类型验证

验证器不仅可以通过嵌套的 XML 配置给组件,也可以通过编程的方式 - 通过提交一个验证器实例给组件的 addValidator() 方法。

下面的例子就是以编程方式创建了一个邮编的验证器类:

public class ZipValidator implements Field.Validator {
    @Override
    public void validate(Object value) throws ValidationException {
        if (value != null && ((String) value).length() != 6)
            throw new ValidationException("Zip must be of 6 characters length");
    }
}

下面例子演示在 FieldGroup 组件使用邮编验证器和标准的正则模式验证器:

<fieldGroup>
    <field property="zip" required="true">
         <validator class="com.company.sample.gui.ZipValidator"/>
    </field>
    <field property="imei">
        <validator class="com.haulmont.cuba.gui.components.validators.PatternValidator"
               pattern="\d{15}"
               message="IMEI validation failed"/>
    </field>
</fieldGroup>

这个是在界面控制器中以编程方式设置验证器:

if (Boolean.TRUE.equals(parameter.getRequired())) {
    tokenList.addValidator(new Field.Validator() {
        @Override
        public void validate(Object value) throws ValidationException {
            if (value instanceof Collection && CollectionUtils.isEmpty((Collection) value)) {
                throw new ValidationException(getMessage("paramIsRequiredButEmpty"));
            }
        }
    });
}
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() 方法支持对以下事件进行跟踪:

  • CollapseEvent,所属组件: TreeDataGrid

  • ColumnCollapsingChangeEvent,所属组件: DataGrid

  • ColumnReorderEvent,所属组件: DataGrid

  • ColumnResizeEvent,所属组件: DataGrid

  • ExpandedStateChangeEvent,所属组件: FilterGroupBoxLayout (参阅 Collapsable),

  • ExpandEvent,所属组件: TreeDataGrid

  • SelectedTabChangeEvent,所属组件: TabSheet

  • SelectionEvent,所属组件: DataGrid

  • SelectionEvent,所属组件: Table

  • SortEvent,所属组件: DataGrid

  • SplitPositionChangeEvent,所属组件: SplitPanel

  • ValueChangeEvent,所属组件:所有实现了 HasValue 接口的组件 (参阅: ValueChangeListener)。

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 - 数据类型

如果控件不与实体属性相关联(比如没有设置数据容器和属性名称),则需要设置数据类型。该属性的值可以是注册在应用程序元数据中的一个数据类型 - 参考 数据类型接口

这些组件可以使用该属性: TextField, DateField, DatePicker, TimeField

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 的时候,组件的高度会使用默认值。对于容器来说,默认的高度是由内容定义的,也就是所有嵌入组件的高度之和。

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" − 只给上下两边加了边距(值格式是:“上,右,下,左”)。

默认情况下没有边距设置。

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

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 25. 数据容器接口
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) {
    orderLinesDc.getDisconnectedItems().removeIf(
            orderLine -> !orderLine.getProduct().equals(product));
}

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() 来定义的。

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 来完成的,这个实现跟标准加载器的实现一样高效,但是好处是可以使用自定义的服务或者可以在加载完实体之后做其它的事情。

一个加载器也可以通过编程的方式创建和配置,示例:

@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 有父上下文,它会将改动的实体提交给父上下文而不是提交给中间件。通过这个功能支持编辑组合关系,从实体只能跟主实体一起保存到数据库。如果一个实体属性使用 @Composition 注解,平台会自动在此属性的编辑界面设置父上下文,从而该属性的改动会保存到主实体的数据上下文。

可以很容易为任何实体和界面提供与此相同的行为。

如果打开的编辑界面需要提交数据到当前界面的数据上下文,可以使用 builder 的 withParentDataContext() 方法:

@Inject
private EditorScreens editorScreens;
@Inject
private DataContext dataContext;

private void editFooWithCurrentDataContextAsParent() {
    FooEdit fooEdit = editorScreens.builder(Foo.class, this)
            .withScreen(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>
    <instance id="orderDc" class="com.company.sales.entity.Order" view="order-edit">
        <loader/>

        <collection id="linesDc" property="lines"/>
    </instance>

    <collection id="customersDc" class="com.company.sales.entity.Customer" view="_minimal">
        <loader>
            <query><![CDATA[select e from sales_Customer e]]></query>
        </loader>
    </collection>
</data>

这个例子中,会创建下列数据组件:

  • DataContext 实例。

  • 使用 orderDc 作为 id 的 InstanceContainer 以及 InstanceLoader ,用来加载 Order 实体。

  • OrderLines 实体创建的使用 linesDc 作为 id 的 CollectionPropertyContainer。绑定到 Order.lines 集合属性。

  • Customer 实体创建的使用 customersDc id 的 CollectionContainer。通过 CollectionLoader 使用指定的查询语句加载。

数据容器可以在可视化组件中这样使用:

<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 26. 表格相互依赖

这个例子中,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 处理器中,给依赖加载器设置了参数并且触发依赖加载。
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.4. 操作以及操作接口

Action 是一个接口,这个接口是对可视化组件的操作(换句话说,一些功能)的抽象。当从不同的可视化组件中调用相同的操作时(例如,分别从按钮和表格右键菜单中调用同一个操作),它特别有用。此外,此接口允许为操作提供其它属性,例如名称、可访问性和可见性标志等。

下面是 Action 接口方法:

  • actionPerform() - 方法由与此操作关联的可视化组件调用。调用者实例被传递给该方法。

  • getId() - 方法返回操作的标识符。标识符通常在实现 Action 的类的构造函数中设置,并且在操作对象创建后的整个生命周期中不会更改。

  • 获取和设置 captiondescriptionshortcuticonenabledvisible 属性的方法。通常,相关可视化组件使用所有这些属性来设置其自身相应的属性。

  • addPropertyChangeListener()removePropertyChangeListener() 方法用于添加和删除处理上述属性更改的监听器。监听器接收 java.beans.PropertyChangeEvent 类型的通知,其中包含已更改属性的名称 、 旧值和新值。

  • refreshState() - 一个在特定的操作类中实现的方法,根据一些外部因素(例如用户权限)初始化上述属性。它通常在实现类的构造函数或从相关可视化组件中调用。

  • addOwner()removeOwner()getOwner()getOwners() – 用于控制操作和可视化组件之间关系的方法。

建议使用声明式创建或继承BaseAction类来实现操作。此外,还有一组适用于表格和选择器组件的标准操作

与操作关联的可视化组件可以有两种类型:

  • 只单个操作的可视化组件,这类组件实现 Component.ActionOwner 接口。这类组件有ButtonLinkButton

    通过调用组件的 ActionOwner.setAction() 方法反操作链接到组件。此时,组件使用操作的属性设置自身相应的属性。(有关详细信息,请参阅组件概述)。

  • 包含多个操作的可视化组件,这类组件实现 Component.ActionsHolder 接口。这类组件有 WindowFragmentDataGridTable及其继承者, TreePopupButtonPickerFieldLookupPickerField

    ActionsHolder.addAction() 方法用于向组件添加操作。在组件中实现此方法会检查它是否已包含具有相同标识符的操作。如果包含,则现有操作将替换为新操作。因此,可以在界面描述中声明标准操作,然后在控制器中创建具有不同属性的新操作,并将其添加到组件中。

3.5.4.1. 声明式创建操作

可以在 XML 界面描述中为任何实现了 Component.ActionsHolder 接口的组件指定一组操作,包括整个窗口或 frame。 操作的定义使用 actions 元素完成,它包含嵌套的 action 元素。

action 元素有以下属性:

  • id − 标识符,在 ActionsHolder 组件中应该是唯一的。

  • 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().setCaption("Hello").setType(Notifications.NotificationType.HUMANIZED).show();
    }

    在上面的示例中,声明了一个操作,它的标识符是 sayHello,标题来自界面的消息包。此操作被绑定到一个按钮,按钮的标题将被设置为操作的名称。界面控制器订阅操作的 ActionPerformedEvent,这样当用户单击按钮或按下 ALT-T 快捷键时,将调用 onSayHelloActionPerformed() 方法。

  • 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.4.2. 标准操作

框架提供了一些标准操作用于处理常见任务,例如为表格中选择的实体调用编辑界面。通过在 type 属性中指定其类型,就可以在界面 XML 描述中声明标准操作,例如:

<!-- in a table -->
<action type="create"/>

<!-- in a PickerField -->
<action id="lookup" type="picker_lookup"/>

标准操作根据自身的类型和所关联的组件对自身进行配置,有情况下需要指定额外的配置参数。

标准操作分为两类:

可以在项目中创建相似的操作或覆盖现有的标准操作类型。

例如,假设需要一个操作来显示表中当前所选实体的实例名称,并且希望通过仅指定其类型在多个界面中使用此操作。以下是创建此类操作的步骤。

  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()
                        .setType(Notifications.NotificationType.TRAY)
                        .setCaption(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>
3.5.4.2.1. 列表组件操作

框架为实现了 ListComponent 接口的可视化组件(DataGridTableGroupTableTreeTableTree)提供了一组标准操作,这些操作在 com.haulmont.cuba.gui.actions.list 包中。

在表格中使用标准操作的示例:

<groupTable id="customersTable" width="100%" dataContainer="customersDc">
    <actions>
        <action id="create" type="create"/>
        <action id="edit" type="edit"/>
        <action id="remove" type="remove"/>
    </actions>
    <columns>
        <column id="name"/>
        <column id="email"/>
    </columns>
    <buttonsPanel>
        <button id="createBtn" action="customersTable.create"/>
        <button id="editBtn" action="customersTable.edit"/>
        <button id="removeBtn" action="customersTable.remove"/>
    </buttonsPanel>
</groupTable>

标准列表组件操作包括以下类型:

  • create - 类型由 com.haulmont.cuba.gui.actions.list.CreateAction 类实现。它被设计用于使用其默认编辑界面创建新实体。

  • edit - 类型由 com.haulmont.cuba.gui.actions.list.EditAction 类实现。它被设计用于使用其默认编辑界面编辑所选实体。

  • remove - 类型由 com.haulmont.cuba.gui.actions.list.RemoveAction 类实现。它被设计用于删除所选实体。

  • add - 类型由 com.haulmont.cuba.gui.actions.list.AddAction 类实现。它被设计用于从默认查找界面选取实体然后将其添加到关联的数据容器。这个操作典型的用例是用来为多对多集合添加实体。

  • exclude - 类型由 com.haulmont.cuba.gui.actions.list.ExcludeAction 类实现。它被设计用于从集合数据容器中删除实体但是不从数据库删除。这个操作典型的用例是从多对多集合中删除实体。

  • refresh - 类型由 com.haulmont.cuba.gui.actions.list.RefreshAction 类实现。它被设计用于重新加载列表组件使用的数据容器。

  • excel - 类型由 com.haulmont.cuba.gui.actions.list.ExcelAction 类实现。它被设计用于将列表组件内容输出到 XLS 文件。

标准操作为基本参数(题、图标和快捷键)提供默认值以及执行时的默认行为。可以在 XML 中为基本参数提供自己的值,就像其它任何操作一样。例如,可以指定自定义图标:

<action id="create" type="create" icon="USER"/>

要自定义执行行为,应该订阅操作的 ActionPerformedEvent。如果提供了自己的操作监听器,则所有标准操作都不会执行。这意味着 自定义的 ActionPerformedEvent 处理器已经覆盖了默认的操作行为。

例如,以下代码将覆盖默认的 create 操作行为,以使用以模式对话框打开的特定界面创建 Customer 实体:

public class CustomerBrowse extends StandardLookup<Customer> {
    @Inject
    private GroupTable<Customer> customersTable;
    @Inject
    private ScreenBuilders screenBuilders;

    @Subscribe("customersTable.create")
    protected void onCustomersTableCreateActionPerformed(Action.ActionPerformedEvent event) {
        screenBuilders.editor(customersTable)
                .newEntity()
                .withScreenClass(CustomerEdit.class)     // specific editor screen
                .withLaunchMode(OpenMode.DIALOG)        // open as modal dialog
                .build()
                .show();
    }
}

跟自定义 create 一样,可以对 edit 的行为进行定制。对有关更多详细信息,请参阅 ScreenBuilders bean 描述.

add 行为使用的是 ScreenBuilders bean,所以可以按照下面的方式进行定制:

public class CustomerEdit extends StandardEditor<Customer> {
    @Inject
    private ScreenBuilders screenBuilders;
    @Inject
    private Table<Employee> accountableTable;

    @Subscribe("accountableTable.add")
    protected void onAccountableTableAddActionPerformed(Action.ActionPerformedEvent event) {
        screenBuilders.lookup(Employee.class, this)
                .withListComponent(accountableTable)
                .withScreenClass(EmployeeBrowse.class)   // specific editor screen
                .withLaunchMode(OpenMode.DIALOG)        // open as modal dialog
                .build()
                .show();
    }
}
3.5.4.2.2. 选择器组件操作

框架为 PickerFieldLookupPickerFieldSearchPickerField组件提供了一系列标准操作。

在选择器组件中使用标准操作的示例:

<pickerField id="userPickerField" dataContainer="employeeDc" property="user">
    <actions>
        <action id="lookup" type="picker_lookup"/>
        <action id="open" type="picker_open"/>
        <action id="clear" type="picker_clear"/>
    </actions>
</pickerField>

标准选择器组件操作包括以下类型:

  • picker_lookup - 类型由 com.haulmont.cuba.gui.actions.picker.LookupAction 类实现。它被设计用于从查找界面中选择实体实例并将其设置到选择器字段中。

  • picker_open - 类型由 com.haulmont.cuba.gui.actions.picker.OpenAction 类实现。它被设计用于为选择器字段中当前选定的实体打开编辑界面。

  • picker_clear - 类型由 com.haulmont.cuba.gui.actions.picker.ClearAction 类实现。它被设计用于清空选择器。

标准操作为基本参数(标题、图标和快捷方键)提供默认值和默认的执行行为。可以为 XML 中的基本参数提供特定的值,就像其它任何操作一样。例如,可以指定自定义图标:

<action id="open" type="picker_open" icon="USER"/>

要自定义执行行为,应该订阅操作的 ActionPerformedEvent。如果提供了自己的操作监听器,则所有标准操作都不会执行。这意味着 ActionPerformedEvent 处理器已经覆盖了默认的操作行为。

例如,以下代码覆盖了默认的 picker_lookup 操作行为,使用以模式对话框模式打开的特定界面选择 User 实体:

public class CustomerBrowse extends StandardLookup<Customer> {
    @Inject
    private LookupScreens lookupScreens;
    @Inject
    private PickerField<User> userPickerField;

    @Subscribe("userPickerField.lookup")
    protected void onUserPickerFieldLookupActionPerformed(Action.ActionPerformedEvent event) {
        lookupScreens.builder(User.class, this)
                .withField(userPickerField)
                .withScreen("sec$User.browse")      // specific lookup screen
                .withLaunchMode(OpenMode.DIALOG)    // open as modal dialog
                .build()
                .show();
    }

有关更多详细信息,请参阅 ScreenBuilders bean 描述。

3.5.4.3. 基础操作

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.5. 对话框消息

Dialogs 接口设计用来展示标准对话框窗口。其 createMessageDialog()createOptionDialog() 方法是流式 API 的入口点,可以用来创建和显示对话框。

下面的例子中,当用户点击按钮时,会显示一个消息对话框:

@Inject
private Dialogs dialogs;

@Subscribe("showDialogBtn")
protected void onShowDialogBtnClick(Button.ClickEvent event) {
    dialogs.createMessageDialog().withCaption("Information").withMessage("Message").show();
}

可以在消息中使用 \n 字符来换行。如果要显示 HTML,可以用 withContentMode() 方法带 ContentMode.HTML 参数。当使用 HTML 时,别忘了转移数据内容以防恶意代码注入。

使用下面的方法可以自定义消息对话框的外观和行为:

  • withModal() - 如果使用 false,对话框不以模态窗展示,此时用户可以与应用程序的其他部分交互。

  • withCloseOnClickOutside() - 如果使用 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,只有第一个操作的按钮能使用正确的样式和图标。

3.5.6. 通知消息

通知消息是显示在主应用程序窗口中间或者角落的弹窗。这些弹窗能自动消失,也可以在用户点击界面或按下 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 时,别忘了对数据进行转义,以防恶意代码注入。

其他诸如 withHideDelayMs()withPosition()withStyleName() 的方法可以用来自定义通知消息的外观和行为。

3.5.7. 后台任务

后台任务机制用于在客户端层异步执行任务,不阻塞用户界面。

要使用后台任务,请执行以下操作:

  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. 主题

主题用于管理应用程序的视觉展现。

一个 Web 主题由 SCSS 文件和其它的资源比如图像组成。

框架提供几个可以在项目中开箱即用的主题。通过主题扩展可以在项目级别修改使用的主题。除了标准的主题外,也可以创建自定义主题

如果希望在多个项目中使用同一个主题,可以在应用程序组件中包含此主题或者创建一个可重用的主题 JAR

3.5.8.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.8.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.8.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=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=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=halo-theme.properties

cuba.web.icons.create.png = font-icon:PLUS

Facebook 主题中带有修改后的 create 操作的标准用户浏览界面的片段:

gui theme facebook 1
3.5.8.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.8.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.8.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.8.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=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 部分,并将本地 Maven 仓库坐标添加到仓库列表中。

  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.9. 图标

用于操作和可视化组件(如Button)的icon属性的图像文件,可以被添加到主题扩展中。

例如,要向 Halo 主题扩展添加图标,必须将图像文件添加到扩展现有主题部分描述的 modules/web/themes/halo 目录中(建议创建子文件夹):

themes/
  halo/
    icons/
      cool-icon.png

在以下章节中,我们将了解如何在可视化组件中使用图标以及如何从任意字体库添加图标。

3.5.9.1. 图标集

图标集允许将可视化组件中图标的使用与主题中图片的实际路径或字体元素常量解耦。它们还简化了对继承自应用程序组件的 UI 中使用的图标的覆盖。

图标集是一个枚举,包含了与图标对应的枚举值。图标集必须实现 Icons.Icon 接口,该接口有一个参数:表示图标资源的字符串,例如,font-icon:CHECKicons/myawesomeicon.png。要获取资源,请使用平台提供的 Icons bean。

可以在 web 或者 gui 模块中创建图标集。图标集中所有图标的名称应匹配正则表达式:[A-Z]_,即它们应仅包含大写字母和下划线。

例如:

public enum MyIcon implements Icons.Icon {

    // adding new icon
    COOL_ICON("icons/cool-icon.png"),

    // overriding a CUBA default icon
    OK("icons/my-ok.png");

    protected String source;

    MyIcon(String source) {
        this.source = source;
    }

    @Override
    public String source() {
        return source;
    }
}

图标集应该在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.9.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;
        }
    }
  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("ico-moon: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.10. 可视化组件的 DOM 和 CSS 属性

框架提供了设置组件原生 HTML 属性的 API,用于为可视化组件设置 DOM 和 CSS 属性。

HtmlAttributes bean 允许使用以下方法通过编程方式设置 DOM/CSS 属性:

  • setDomAttribute() - 在 UI 组件的最顶层 HTML 元素上设置 DOM 属性。接受组件的标识符、DOM 属性名称(例如 title)和属性值作为参数。

  • setCssProperty() - 在 UI 组件的最顶层 HTML 元素上设置 CSS 属性值。接受组件的标识符、CSS 属性名称(例如 border-color)和属性值作为参数。

最常见的 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.11. 键盘快捷键

本节提供可在应用程序通用用户界面中使用的键盘快捷键列表。下面列出的所有应用程序属性都属于 ClientConfig 接口,可以在 Web 客户端应用程序模块中使用。

  • 主应用程序窗口。

    • CTRL-SHIFT-PAGE_DOWN – 切换到下一个标签页。由 cuba.gui.nextTabShortcut 属性定义。

    • CTRL-SHIFT-PAGE_UP – 切换到上一个标签页。由 cuba.gui.previousTabShortcut 属性定义。

  • 文件夹面板。

    • 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.12. 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.12.1. 处理 URL 改动

框架能根据应用程序 URL 的变动自动做出响应:会尝试对请求的路由进行解析然后进行历史导航,或者为注册了的路由打开新界面。

当界面通过带参数的路由打开时,框架会在界面显示前先给界面控制器发送 UrlParamsChangedEvent 事件,如果在界面打开了之后 URL 参数发生变化,框架也会做同样的事情。可以订阅此事件来处理界面的初始化参数或者参数的变化。比如,可以根据 URL 参数来加载数据或者隐藏/展示特定的界面 UI 组件。

在界面控制器订阅此事件的示例:

@Subscribe
protected void onUrlParamsChanged(UrlParamsChangedEvent event) {
    // handle
}

使用 UrlParamsChangedEvent 的完整示例请参阅后面章节

3.5.12.2. 路由 API

本章节介绍路由 API 的关键内容。

路由注册

要为一个界面注册路由,需要在界面控制器添加 @Route 注解,示例:

@Route("my-screen")
public class MyScreen extends Screen {
}

该注解有三个参数:

  • path(或 value)是路由本身的值;

  • parentPrefix 用来做路由压缩(squashing)(参阅 以下)。

  • root 是一个布尔值属性,用来确定一个路由是否是为根界面定义的(比如登录界面或者 主界面)。默认值为 false

如果需要为旧界面定义路由,可以在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("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 接口的托管 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.12.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. 插件工厂

插件工厂机制扩展了标准组件的创建过程,允许在 FieldGroupTableDataGrid 中创建不同的编辑字段。这意味着应用程序组件或应用程序项目本身可以提供自定义策略,以创建非标准的组件或支持自定义数据类型。

该机制的入口点是 UiComponentsGenerator.generate(ComponentGenerationContext) 方法。其工作原理如下:

  • 尝试查找 ComponentGenerationStrategy 实现。如果找到至少一种策略,那么:

    • 根据 org.springframework.core.Ordered 接口遍历策略。

    • 返回第一个创建的非 null 组件。

ComponentGenerationStrategy 实现用于创建 UI 组件。项目可以包含任意数量的此类策略。

ComponentGenerationContext 是一个类,该类存储创建组件时可以使用的以下信息:

  • metaClass - 定义为其创建组件的实体。

  • property - 定义为其创建组件的实体属性。

  • datasource - 数据源。

  • optionsDatasource - 可用于显示选项列表的数据源。

  • valueSource - 可用于创建组件的值来源。

  • options - 可用于显示选项的选项对象。

  • xmlDescriptor - 在组件以声明的方式在 XML 描述中定义时,包含附加信息的 XML 描述。

  • componentClass - 要创建的组件的类型。例如,FieldGroupTableDataGrid

有两种内置组件策略:

  • DefaultComponentGenerationStrategy - 用于根据给定的 ComponentGenerationContext 对象创建组件。顺序值为 ComponentGenerationStrategy.LOWEST_PLATFORM_PRECEDENCE (1000)。

  • DataGridEditorComponentGenerationStrategy - 用于根据给定的 ComponentGenerationContext 对象为数据网格编辑器创建组件。顺序值为 ComponentGenerationStrategy.HIGHEST_PLATFORM_PRECEDENCE + 30 (130)。

以下示例展示如何替换为特定实体的某个属性 FieldGroup 组件的默认生成过程。

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 FieldGroup component
        if (orderMetaClass.equals(context.getMetaClass())
                && "date".equals(property)
                && context.getComponentClass() != null
                && FieldGroup.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;
    }
}

请注意,在更改返回的组件类型时,覆盖现有的生成策略可能会产生错误,因为界面控制器中的代码可能依赖于特定的组件类型。

例如,在使用上述策略的情况下,以下注入将产生异常:

@Named("fieldGroup.date")
private DateField<Date> dateField;

如果尝试打开此类界面,则会出现以下异常:

IllegalArgumentException: Can not set com.haulmont.cuba.gui.components.DateField field com.company.sales.web.order.OrderEdit.dateField to com.haulmont.cuba.web.gui.components.WebDatePicker

以下示例展示如何为特定的 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.14. 使用 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.15. 自定义可视化组件

本节概述了在 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 对自定义可视化组件的支持 章节中的示例。

3.5.15.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.15.2. 通用 JavaScriptComponent

JavaScriptComponent 是个简单的 UI 组件,通过它可以使用任何纯 JavaScript 组件,并且这个 JavaSctipt 组件不需要对应的 Vaadin 实现。因此,通过这个组件可以很容易地在基于 CUBA 的项目中集成任何纯 JavaScript 组件。

该组件可以在界面的 XML 描述中以声明的方式定义,因此可以在 XML 中配置动态属性和 JavaScript 依赖。

该组件的 XML 名称: jsComponent

定义依赖

可以为该组件定义一个依赖列表(jscss)。可以使用下面的格式定义依赖:

  • WebJar 资源 - 以 webjar:// 开头。

  • VAADIN 目录下的文件 - 以 vaadin:// 开头。

示例:

jsComponent.addDependencies(
        "webjar://leaflet.js",
        "webjar://leaflet-draw.js"
);
定义初始化函数

该组件需要一个初始化函数。此函数的名称用来查找 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.15.3. 创建自定义可视化组件

自定义可视化组件部分所述,可以在项目中扩展标准的可视化组件集。有以下几种方式:

  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.15.3.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.15.3.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.15.3.3. 使用 JavaScript 库

在此示例中,我们将使用 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>

打开 ProductEit.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.15.3.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);
    }
}

启动应用程序服务并查看结果。

rating screen result
3.5.15.3.5. CUBA Studio 对自定义可视化组件的支持

Coming soon.

3.5.16. Web 客户端基础设施

Web 客户端block的通用用户界面基于 Vaadin 框架实现。Web 客户端基础设施中可用的主要类如下所述。

WebClientInfrastructure
Figure 27. Web 客户端基础设施类
  • App - 应用程序基础设施的中心类。包含指向 Connection 和其它基础设施对象的引用。对于给定的 HTTP 会话,只存在一个 App 实例。可以使用 App.getInstance() 静态方法获取 App 实例的引用。如果要在项目中自定义 App 的功能,请在 web 模块的根包中创建一个类继承 DefaultApp,并将其作为 cuba_App bean 注册在 web-spring.xml中,例如:

    <bean name="cuba_App" class="com.company.sample.web.MyApp" scope="vaadin"/>
  • Connection 是一个接口,此接口提供连接到中间件和保持用户会话的功能。ConnectionImpl 是此接口的标准实现。

  • ExceptionHandlers 类包含客户端级(client-level)异常处理器的集合。

  • AppUI 是一个继承 com.vaadin.ui.UI 类的平台类。对于 Web 浏览器打开的每个标签页,都有一个此类的实例。它包含指向 TopLevelWindow 实现( 登录窗口或主窗口) 的链接,具体取决于连接状态。可以使用 AppUI.getCurrent() 静态方法获取对当前浏览器标签页的 AppUI 的引用。

  • AppLoginWindow – 在用户登录之前显示的窗口。可以通过继承 AppLoginWindow 来自定义登录窗口,或者通过继承AbstractWindow创建全新的登录窗口。Studio 中,在 CUBA 项目树中选择 Generic UI > Screens 部分然后点击 New > Screen。然后在 Legacy Screen Templates 标签页选择 Login window 模板。如果在自定义类中重写了 init() 方法,确保调用了 super.init(params) 方法。

  • AppMainWindow – 用户登录后显示的主应用程序窗口。可以通过继承 AppMainWindow 来自定义主窗口,或者通过继承 AbstractMainWindow 并在 XML 描述中定义所需的布局来创建全新的主窗口。Studio 中,在 CUBA 项目树中选择 Generic UI > Screens 部分然后点击 New > Screen。然后在 Legacy Screen Templates 标签页选择 Main window 模板。如果在自定义类中重写了 init() 方法,确保调用了 super.init(params) 方法。

    可以使用以下应用程序属性控制某些主窗口参数,而无需重新定义默认实现:

如果要处理浏览器的 Back 按钮,请在 TopLevelWindow(登录窗口或主窗口)中实现 CubaHistoryControl.HistoryBackHandler 接口。如果cuba.web.allowHandleBrowserHistoryBack应用程序属性设置为 true,则会调用这个接口实现的 onHistoryBackPerformed() 方法而不是浏览器的标准行为。

3.5.17. 主窗口布局

下面描述的机制允许使用 CUBA 通用 UI 技术设计应用程序主窗口布局,即通过创建 XML 描述和 Java 控制器、同时使用UI 组件数据源

主窗口由具有 mainWindow 标识符的特定界面定义。它的控制器应该派生自 AbstractMainWindow 类。

除了标准 UI 组件之外,主窗口中还可以使用以下特殊组件:

  • AppMenu – 应用程序主菜单。

  • FoldersPane – 应用程序和搜索文件夹面板

  • AppWorkArea – 工作区,以 THIS_TABNEW_TABNEW_WINDOW 模式打开界面时需要的组件。

  • UserIndicator – 显示当前用户名称的字段,也可以用于选择替换用户(如果有)。

    setUserNameFormatter() 方法允许以不同于 User 实例名称的格式显示用户名:

    userIndicator.setUserNameFormatter(value -> value.getName() + " - [" + value.getEmail() + "]");
    userIndicator
  • NewWindowButton – 一个按钮,用于在单独的浏览标签页中打开新的应用程序主窗口。

  • LogoutButton – 应用程序注销按钮。

  • TimeZoneIndicator – 显示当前用户时区的标签。

  • FtsField – 全文搜索框。

要定义特定组件,请在界面上添加 xmlns:main 命名空间:

<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        xmlns:main="http://schemas.haulmont.com/cuba/mainwindow.xsd"
        class="com.company.sample.gui.MainWindow">
    <layout>
    </layout>
</window>

AppWorkArea 组件用于显示应用程序界面。如果cuba.web.appWindowMode应用程序属性为 TABBED (默认值),则工作区显示带有打开界面的 TabSheet。否则只打开单个应用程序界面。cuba.web.mainTabSheetModecuba.web.managedMainTabSheetMode应用程序属性定义切换标签页时如何处理标签页内容。当没有界面被打开时,工作区显示 initialLayout 元素中定义的组件:

<main:workArea id="workArea" width="100%" height="100%">
    <main:initialLayout spacing="true" margin="true">
        <!-- content shown when there are no open screens -->
    </main:initialLayout>
</main:workArea>

在打开第一个应用程序界面时从 AppWorkArea 中删除初始界面(initialLayout),在所有界面关闭后初始界面又被重新添加上。可以添加 AppWorkArea.StateChangeListener 来处理工作区中初始界面和应用程序界面之间切换。例如,这里使用这个监听器刷新初始界面的数据。

平台提供两个标准主窗口实现。顶部有横向菜单的默认主窗口,其 XML 描述在 /com/haulmont/cuba/web/app/mainwindow/mainwindow.xml 中,对应的控制器类是 AppMainWindow 。另一个可用的模板包含垂直侧边菜单

标准实现可以在项目中被继承,和任何其它应用程序界面一样。继承界面的示例:

<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd"
        extends="com/haulmont/cuba/web/app/mainwindow/mainwindow.xml"
        class="com.haulmont.cuba.web.app.mainwindow.AppMainWindow">
    <layout>
        <vbox ext:index="0">
            <label value="This is my main window!" stylename="h2"/>
        </vbox>
    </layout>
</window>

此界面应在web-screens.xml文件中注册,并使用 mainWindow 标识符。

扩展主界面的最简单方法是在 CUBA Studio 中完成:选中 CUBA 项目视图的 Generic UI 部分,在右键菜单中点击 New > Screen ,然后在弹出窗口的 Legacy Screen Templates 标签页为新的主界面选择模板。会在 Web 模块创建新的 ext-mainwindow.xml 文件,并自动注册到 web-screens.xml 中。

CUBA 平台提供两个标准主窗口布局模板:横向菜单和侧边菜单。标准主窗口实现可以被自定义窗口实现完全替换。例如:

<window xmlns="http://schemas.haulmont.com/cuba/window.xsd"
        xmlns:main="http://schemas.haulmont.com/cuba/mainwindow.xsd"
        class="com.company.sample.gui.MainWindow">
    <layout expand="middlePanel">
        <hbox margin="true"
              stylename="gray"
              width="100%">
            <label align="MIDDLE_CENTER"
                   value="Header"/>
        </hbox>
        <main:menu width="100%"/>
        <split id="middlePanel"
               orientation="horizontal"
               pos="80"
               width="100%">
            <main:workArea id="workArea"
                           height="100%"
                           width="100%">
                <main:initialLayout stylename="red">
                    <label align="MIDDLE_CENTER"
                           value="Work Area (Initial Layout)"/>
                </main:initialLayout>
            </main:workArea>
            <main:foldersPane height="100%"
                              stylename="blue"
                              width="100%"/>
        </split>
        <hbox margin="true"
              stylename="gray"
              width="100%">
            <label align="MIDDLE_CENTER"
                   value="Footer"/>
        </hbox>
    </layout>
</window>

生成的主窗口如下所示:

main window 1

在这个完全自定义的主窗口中打开应用程序界面:

main window 2

cuba.web.showBreadCrumbs 应用程序属性允许隐藏打开的界面上方的导航面板。

3.5.18. Web 登录

本节介绍 Web 客户端身份验证的工作原理以及如何在项目中进行扩展。有关中间层身份验证的信息,请参阅登录

Web 客户端 block 的登录过程的实现机制如下:

  • ConnectionImpl 实现了 Connection

  • LoginProvider 实现。

  • HttpRequestFilter 实现。

WebLoginStructure
Figure 28. Web 客户端的登录机制

Web 登录子系统的主要接口是 Connection,它包含以下关键方法:

  • login() - 验证用户、启动会话并更改连接状态。

  • logout() - 退出系统。

  • substituteUser() - 用另一个用户替换当前会话中的用户。此方法会创建一个新的 UserSession 实例,但会话 ID 不变。

  • getSession() - 获取当前用户会话。

成功登录后,ConnectionUserSession 对象存储到 VaadinSession 的属性中并设置 SecurityContextConnection 对象被绑定到 VaadinSession,因此无法从非 UI 线程使用它,如果在非 UI 线程调用 login/logout ,则会抛出 IllegalConcurrentAccessException

通常,登录是通过 AppLoginWindow 界面执行的,该界面支持使用用户名/密码和“记住我”凭据登录。

Connection 的默认实现是 ConnectionImpl,它将登录委托给 LoginProvider 实例链。LoginProvider 是一个可以处理特定 Credentials 实现的登录模块,它还有一个特殊的 supports() 方法,允许调用者查询它是否支持给定的 Credentials 类型。

WebLoginProcedure
Figure 29. 标准用户登录过程

标准用户登录过程:

  • 用户输入用户名和密码。

  • 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 更新和 AppMainWindow 初始化。

所有 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 用户触发。

扩展点

可以使用以下类型的扩展点扩展登录机制:

  • Connection - 替换现有的 ConnectionImpl

  • HttpRequestFilter - 实现额外的 HttpRequestFilter

  • LoginProvider 实现 - 实现额外的或替换现有的 LoginProvider

  • 事件 - 为一个可用的事件实现事件处理器。

可以使用 Spring Framework 机制替换现有 bean,例如通过在 web 模块的 Spring XML 配置中注册新 bean。

<bean id="cuba_LoginPasswordLoginProvider"
      class="com.company.demo.web.CustomLoginProvider"/>
过时/弃用

可以通过设置以下应用程序属性来启用 CubaAuthProvider 接口的自定义实现:

cuba.web.externalAuthentication = true
cuba.web.externalAuthenticationProviderClass = com.company.sample.web.MyAuthProvider

现已弃用以下组件:

  • CubaAuthProvider 及其实现在兼容模式下可用。使用事件 、LoginProviderHttpRequestFilter 代替。

  • LdapAuthProvider 已替换为 LdapLoginProvider,可按这里的描述启用:LDAP 集成

  • IdpAuthProvider 已替换为 IdpLoginProvider,可按 IDP 插件 wiki 的描述启用。

不要使用这些组件。它们会在平台的下一个主版本中被删除。

请使用WEB 登录扩展点代替。

3.5.19. 不支持浏览器的界面

如果应用程序不支持某个版本的浏览器,用户将会看到一个带有通知消息的标准界面,通知消息会建议升级浏览器并提供推荐的浏览器列表。

用户只有升级了浏览器才能继续使用应用系统。

unsupported browser page
Figure 30. 不支持浏览器界面

可以修改或者本地化默认界面的内容。如果需要本地化该界面,可以在 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.6. GUI 历史版本 API

3.6.1. 界面(历史版本)

对于从 v.7.0 开始的新 API,请参阅 界面

通过一个 XML 描述和一个界面控制器来定义一个通用 UI 界面。XML 描述中含有对控制器类的链接。

要从主菜单或者通过 Java 代码(比如从不同界面的控制器)调用一个界面,这个界面的 XML 描述需要在项目的 screens.xml 文件里注册。用户登录之后打开的默认界面可以通过 cuba.web.defaultScreenId 这个应用程序属性来设置。

应用程序的主菜单内容根据menu.xml文件生成。

3.6.1.1. 界面类型

本章节描述以下几种基本的界面类型:

3.6.1.1.1. 界面子框架

子框架(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. 简单界面

简单界面可以用来展示和编辑信息,包括单一实体实例或者实体列表。这种类型的界面只能在应用程序主窗口打开并且使用数据源的核心功能。

简单界面的控制器必须从 AbstractWindow 类继承。

可以在 Studio 使用 Blank screen 模板创建简单界面。

3.6.1.1.3. 查找界面

查找界面设计用来选择并且返回实体实例或者实体列表。可视化组件中的 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. 编辑界面

编辑界面用来展示和编辑实体实例。编辑界面通过需要编辑的实体来做界面初始化,并且包含操作,能把对实体的改动保存到数据库。编辑界面需要通过 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. 组合界面

通过组合界面可以在界面左边部分显示实体列表,在界面右边部分显示编辑选择实例的表格。所以组合界面是组合了查找编辑界面。

组合界面控制器必须从 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 31. 控制器基类组
3.6.1.3.1. AbstractFrame

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

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

AbstractLookup查找界面控制器的基类,AbstractWindow 的子类。定义了下列方法:

  • setLookupComponent() – 设置查找组件,用来选择实体实例。

    作为一条编码规则,用来做选择的组件在 XML 描述中定义,不需要在应用程序代码中调用这个方法。

  • setLookupValidator() – 为界面设置 Window.Lookup.Validator 对象,在返回实体的实例之前,这里的 validate() 方法会被平台调用。如果 validate() 方法返回 false,查找实体的过程或者窗口关闭的过程会被中断。

    默认情况不设置这个验证器。



3.6.1.3.4. AbstractEditor

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 32. 编辑界面初始化过程
EditorCommit
Figure 33. 使用 editWindowActions 子框架提交并关闭窗口
ExtendedEditorCommit
Figure 34. 使用 extendedEditWindowActions 子框架提交界面改动
ExtendedEditorCommitAndClose
Figure 35. 使用 extendedEditWindowActions 子框架提交界面改动并关闭窗口


3.6.1.3.5. EntityCombinedScreen

EntityCombinedScreen组合界面控制器的基类,是 AbstractLookup 的子类。

EntityCombinedScreen 类使用硬编码的标识符查找关键组件,比如表格、字段组或者其它组件。如果给组件做另外不同的命名,需要重写类中保护(protected)的方法并返回自定义的标识符,以便控制器能找到自定义的组件。参考类 JavaDocs 了解细节。

3.6.1.3.6. 界面控制器依赖注入

在界面控制器进行依赖注入可以用来请求有效对象的引用。基于这个目的,要求在控制器内声明一个相应类型的字段,或者写一个带有相应参数类型的访问方法(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. 数据源(历史版本)

从 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 36. 数据源接口
  • 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. 创建数据源

数据源对象可以通过两种方式创建,一种是在界面的 XML 描述中通过声明式的方式,另一种是在界面控制器通过编程的方式创建。通常情况下,创建的数据源默认会使用其标准实现,但是如果需要的话,也可以通过继承标准实现类来自定义数据源。

3.6.2.1.1. 声明式创建

典型的情况下,数据源声明在界面描述文件的 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. 编程方式创建

如果需要在 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. 自定义实现类

如果需要实现使用自定义方法来加载实体,可以创建自定义的数据源类,从 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. 集合数据源查询

CollectionDatasourceImpl 类及其子类 GroupDatasourceImplHierarchicalDatasourceImpl 都是数据源的标准实现类,用来处理实体实例的集合。这些数据源发送 JPQL 查询语句给 DataManager 来加载数据。下面介绍这些查询语句的格式。

3.6.2.2.1. 返回值

一个查询语句需要返回在创建数据源时指定类型的实体。在以声明式的方式创建数据源时,返回实体的类型通过 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. 查询参数

数据源中的 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. 查询条件过滤

根据用户输入的条件不同,可以在运行时改变数据源的查询结果。因而可以有效的在数据库级别做数据过滤。

提供此功能最简单的方法就是将数据源连接到一个特殊的可视化组件上:过滤器控件

如果因为某些原因,全局的过滤器不太合适,可以在查询语句的文本中嵌入一个特殊的 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. 不区分大小写查找

在数据源中可以使用 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. 值数据源

通过值数据源可以执行返回纯数值或者数值聚合(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. 数据源监听器

通过数据源监听器接收数据源及其包含实体的状态变化的消息通知。

一共有四种类型的监听器。其中三个,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

所有通过声明式方法创建的数据源都在界面的 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

DataSupplier – 接口,数据源通过这个接口访问到 Middleware 以便加载和保存实体。接口的标准实现只是简单的做了 DataManager 的代理。界面可以在 window 元素的 dataSupplier 属性定义它自己的 DataSupplier 实现类。

DataSupplier 的引用可以通过注入的方式或者通过 DsContextDatasource 实例获得。这两种方式下,如果有自定义的实现类,则会使用自定义类。

3.6.3. 对话框消息和通知消息(历史版本)

对话框消息和通知消息可以用来为用户呈现消息。

对话框消息有标题、关闭按钮并且总是在应用程序主窗口的中间展示。通知消息可以显示在窗口中间或者角落,并且能自动消失。

3.6.3.1. 对话框消息

对 v.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
});
3.6.3.2. 通知消息

对 v.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. 集合的标准行为(历史版本)

对于 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);

    使用 CreateAction 进行初始化 章节也提供一个使用 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. 选取器控件的标准行为(历史版本)

对于 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. 前端用户界面

前端界面客户端block提供了使用移动端优先的响应式 webUI 快速创建面向客户的门户的能力。

使用基于 Node.js 的 来创建前端客户端及其界面。

3.7.1. 在 Studio 中添加前端 UI

如需在项目中添加前端客户端模块:

  • 在 CUBA Studio 中打开 CUBA 项目树;

  • 右键点击 Modules 节点;

  • 选择 Manage modules > Create 'front' module 条目。

Studio 会在 modules/front/generation 目录安装 前端生成器 (也许要花一些时间)。然后会出现提示,需要选择前端使用什么技术(Polymer 还是 React)。

在创建模块完成之后,启动应用程序,并在浏览器打开 http://localhost:8080/app-front。会展示一个登录表单。登录之后,会显示带有垂直菜单栏以及响应式布局的主窗口。

3.7.2. Polymer 用户界面

基于 Polymer 库,提供跟手机端浏览器紧密集成的功能,可以添加 web 应用程序到设备主页面或者离线工作。

CUBA 框架 Polymer UI 有如下功能:

  • Polymer 构建系统完全整合到基于 Gradle 的项目构建环境,因此所有的构建工具都会自动下载并安装。同时,一旦在项目中创建了 Polymer 模块,它可以由前端工程师使用 Polymer 标准的工具单独进行进一步开发。

  • 平台提供了一组 web 组件,可以通过标准 CUBA REST API 跟中间件层通信。之后有这些组件的描述。

  • 通过 CUBA Studio 可以很容易创建 Polymer 客户端模块并且围绕项目数据模型和中间件服务搭建应用程序 web 组件。Studio 包含一组可扩展的模板用来创建应用级别的组件。

平台目前的策略还是关注用来创建 Progressive Web Apps 的 Polymer 技术和工具。目标是为了按照 Polymer 的规范和最佳实践,提供 CUBA 用户类似的学习曲线和开发体验。Polymer 应用具有基于组件的架构,由 Web 组件 构成。

要快速开始开发,需要熟悉 Polymer 的基本概念。这里有个关于 Polymer 的非常快速的介绍: https://github.com/Polymer/polymer#polymer-in-1-minute 。但是,最好还是进行深入了解: ttps://polymer-library.polymer-project.org/2.0/start/ 。因为 Polymer 是按照大多数情况下学习 web 平台本身的标准构建的。

3.7.2.1. Polymer 需求

安装 Git 并确保在命令行可用。对于前端应用来说,需要安装 bower 包管理器。

3.7.2.2. 支持的浏览器

参考 Polymer 的 网站 了解支持的浏览器。

如果需要适配老浏览器,千万不要使用 Polymer 客户端。

3.7.2.3. 构建系统和项目结构

Polymer 客户端构建链会使用以下工具:

默认 Gradle 会处理这些工具的安装和调用。但是也可以直接使用这些工具,参考使用原生 Polymer 工具

Polymer 2.x 和相应的原生元素都是用 ES6 标准编写的,因此需要一个额外的编译步骤(ES6 → ES5 ttps://polymer-library.polymer-project.org/2.0/docs/es6[编译])来适应老的浏览器。

Polymer 客户端的默认 preset 是 es6-unbundled。意味着如果需要适配老浏览器进行生产环境部署,这个需要改为 es5-bundled

修改构建 preset,打开 polymer.json 文件,修改 builds 属性内容,示例:

  "builds": [
    {
      "preset": "es5-bundled",
      "basePath": "/app-front/",
      "addServiceWorker": false
    }
  ]

可以在 polymer.json 中设置多个 preset 或者自定义构建流程。参考 Polymer 网站 了解更多关于 preset 的信息以及编译选项。

如果需要将特殊的 preset 编译结果部署到 Tomcat,需要修改 build.gradle 内的 deploy 任务:

    task deploy(type: Copy, dependsOn: [assemble, deployUnbundled]) {
        from file('build/es5-unbundled')
        into "$cuba.tomcat.dir/webapps/$frontAppDir"
    }

注意,es6-bundledes5-unbundled 需要在 polymer.jsonbuild.gradle 两处进行修改。

3.7.2.3.1. 目录结构
front/
|-- src/
|   |-- app-shell.html
|   |-- shared-styles.html
|-- images
|   |-- app-icon/
|   |-- favicon.ico
|-- .gitignore
|-- bower.json
|-- index.html
|-- manifest.json
|-- package.json
|-- polymer.json
|-- service-worker.js
|-- sw-precache-config.js
src

放置前端组件的目录。

package.json

Node.js 的依赖列表,用在构建阶段。

bower.json

web 类库依赖列表(主要是 web 组件),运行时使用。

polymer.json

Polymer 构建配置.

index.html

应用程序入口。包含加载 polyfills 的逻辑以及导入 <appname>-shell.html。

manifest.json

Web app 的装箱清单。包含当应用被添加到设备主界面时所需的信息。 参考了解更多: https://developer.mozilla.org/en-US/docs/Web/Manifest

service-worker.js

Service worker 脚手架代码。

sw-precache-config.js

sw-precache 类库使用的配置文件,用来在构建时(默认关闭)生成 Service worker。参考 离线能力

3.7.2.3.2. 热部署

当使用 CUBA Studio 或者 Gradle 运行或者部署应用程序时,构建系统会按照 polymer.json 文件的配置对界面组件进行打包。默认情况下,整个应用程序会被打包成一个 app-shell.html 文件。当对某些应用组件进行更改的时候,Studio 会将改动热部署至 Tomcat。还有,会使用未打包的版本来替换打包的 app-shell.html 文件,以便更改能生效,如果直接在 tomcat/webapps 部署生产环境的话,需要注意这一点。

如果使用 es5-bundled preset 编译设置,热部署不会生效,因为 Studio 不会在过程中做任何 JavaScript 翻译。

如果使用基于 TypeScript 的客户端,需要手动运行 npm run watch 命令对组件类的改动做热部署。

3.7.2.3.3. 使用原生 Polymer 工具

在开发 Polymer 组件的时候可以使用原生的 Polymer 框架工具集。这样的话如果项目里有一个单独的团队做前端就会很方便,系统需要安装 Node.js

全局安装 bowerpolymer-cli

npm install bower polymer-cli -g

然后可以不用 Gradle 来构建和运行 web 应用程序:

cd modules/front
npm install
bower install
polymer serve

如果想要使用 Polymer 服务来做 app 的后台服务(而不是 Tomcat),需要在 modules/front/index.html 里面指定 REST API 的绝对路径,示例:

<myapp-shell api-url="http://localhost:8080/app/rest/"></myapp-shell>

然后可以通过 http://localhost:8081(也许需要根据命令行输出更改端口号)访问 web 应用,这个 web 应用对应的后台 REST API 在 http://localhost:8080/app/rest/

3.7.2.4. CUBA Polymer Web 组件

CUBA 元素的详细 API 参考可以在 这里 找到。

3.7.2.4.1. 初始化

为了使用任何 cuba- 元素,需要用 cuba-app 元素来初始化通用类库以及 REST API 连接:

<cuba-app api-url="/app/rest/"></cuba-app>

这个只需要在 app 中放在一个地方(一次),而且尽可能在程序初始化的早期。不要在初始化之后动态改变属性值,也不要挂载/脱挂(attach/detach)其它元素。

3.7.2.4.2. 处理数据

如果需要加载数据,只需要在 HTML 中放置需要的 cuba-data 元素,并且设置必须属性。

实体加载

使用 cuba-entities 来加载实体。一旦设置了 entity-nameview 属性,这个元素可以加载实体列表,并且通过 data 属性将数据暴露给 Polymer 数据绑定的变量:

<cuba-entities entity-name="sec$User" view="_local" data="{{users}}"></cuba-entities>

然后可以很简单的展示数据:

<template is="dom-repeat" items="[[users]]" as="user">
  <div>[[user.login]]</div>
</template>

实体查询

按照 此处描述定义一个查询。

使用 cuba-query 元素来获取查询结果。可以选择性的通过 params 属性传递参数:

<cuba-query id="query"
            auto="[[auto]]"
            entity-name="sec$User"
            query-name="usersByName"
            data="{{users}}">
</cuba-query>

<template is="dom-repeat" items="[[users]]" as="user">
  <div>[[user.login]]</div>
</template>

服务调用

此处的方法暴露服务及其方法。使用 cuba-service 元素来调用这个方法:

<cuba-service service-name="cuba_ServerInfoService"
              method="getReleaseNumber"
              data="{{releaseNumber}}"
              handle-as="text"></cuba-service>

Release number: [[releaseNumber]]

实体创建

cuba-entity-formcuba-service-form 元素能帮助发送数据到后台。

在下面的例子中,绑定 user 对象到 entity 属性,并通过这个属性保存到数据库。

<cuba-entity-form id="entityForm"
                  entity-name="sec$User"
                  entity="[[user]]"
                  on-cuba-form-response="_handleFormResponse"
                  on-cuba-form-error="_handleFormError">

  <label>Login: <input type="text" name="login" value="{{user.login::input}}"></label>
  <label>Name: <input type="text" name="login" value="{{user.name::input}}"></label>

  <button on-tap="_submit">Submit</button>

</cuba-entity-form>

<paper-toast id="successToast">Entity created</paper-toast>
<paper-toast id="errorToast">Entity creation error</paper-toast>
_submit: function() {
  this.$.entityForm.submit();
},
_handleFormResponse: function() {
  this.user = getUserStub();
  this.$.successToast.open();
},
_handleFormError: function() {
  this.$.errorToast.open();
}

如果在使用上面的例子中的 REST API 时不想强制用户登录,需要启用 REST API 匿名访问

3.7.2.5. 样式

参考 Polymer 的 样式调整向导。跟传统的调样式方式最大的不同点就是怎样设置全局样式。因为 Polymer 元素使用的 Shadow DOM 全局样式,样式不会在组件内渗透。因此需要使用 style-modules。在 Polymer 客户端有个 shares-styles.html 文件,会被自动引用到 Studio 创建的新组件中。

3.7.2.6. 离线能力

试验功能!

下面列出的技术目前并不被所有浏览器支持(比如 service worker 在 Safari 还没实现

目前,跟 Polymer 一起,还提供 Progressive Web Applications 技术,比如 web app manifest 2 在用户的主页面展示 native-like - 接近原生 的样式。参考 Polymer 客户端模块的 manifest.json 文件。

主要有两种方式:

  • Service Worker 主要用来缓存应用本身。可以看看跟 Polymer 客户端一起生成的 sw-precache-config.js 文件。如果要启用自动生成 Service Worker,在 polymer.json 中设置 "addServiceWorker": true

更多关于配置和使用 Service Worker 的内容可以参考 这里

3.7.2.7. TypeScript 支持

从 CUBA Release 6.9 开始,可以通过 Studio 搭建基于 TypeScript 的 Polymer 客户端脚手架代码。当创建 Polymer 客户端模块的时候,可以选择客户端的 polymer2-typescript preset。跟基本 JavaScript 版本的主要不同是:

组件类(Component classes)放在分开单独的 *.ts 文件
myapp-component.ts
namespace myapp {

  const {customElement} = Polymer.decorators;

  @customElement('myapp-component')
  class MyappComponent extends Polymer.Element {
  }
}
myapp-component.html
<link rel="import" href="../bower_components/polymer/polymer.html">

<link rel="import" href="./shared-styles.html">

<dom-module id="myapp-component">
  <template>
     <!-- some html markup -->
  </template>
  <script src="myapp-component.js"></script>
</dom-module>
构建过程额外增加了一个阶段 - TypeScript 编译

参考 package.jsonscripts 部分:

{
  "scripts": {
    "build": "npm run compile && polymer build",
    "compile": "tsc",
    "watch": "tsc -w"
  }
}

现在,在 polymer build 命令之前,需要先运行 npm run compile 命令,这个命令会有效的执行 TypeScript 编译(tsc)。

如果修改了组件类的代码,并且希望通过 Studio 的热部署使改动生效,需要先手动在 modules/front 目录运行 npm run watch 命令。

3.7.2.7.1. 使用 TypeScript 创建 Polymer 组件

Polymer 团队通过 TypeScript 装饰器提供了更方便和简洁的创建组件类的途径。先看看下面这个例子:

/// <reference path="../bower_components/cuba-app/cuba-app.d.ts" />
/// <reference path="../bower_components/app-layout/app-drawer/app-drawer.d.ts" />
/// <reference path="../bower_components/app-layout/app-drawer-layout/app-drawer-layout.d.ts" />

namespace myapp {

  // Create shortcuts to decorators
  const {customElement, property, observe, query} = Polymer.decorators;

  @customElement('myapp-component')
  class MyappComponent extends (Polymer.mixinBehaviors([CubaAppAwareBehavior, CubaLocalizeBehavior], Polymer.Element) as
    new () => Polymer.Element & CubaAppAwareBehavior & CubaLocalizeBehavior) {

    @property({type: Boolean})
    enabled: boolean;

    @property({type: String})
    caption: string;

    @query('#drawer')
    drawer: AppDrawerElement;

    @observe('app')
    _init(app: cuba.CubaApp) {
      ...
    }

    @computed('enabled', 'caption')
    get enabledCaption() {
      ...
    }
  }
}
  • /// <reference path="…​"/> - 允许通过这种方式引入 TypeScript 中其它元素或者类库。

  • @customElements('element-name') 装饰器使得无需定义 static get is() 方法,并且无需手动调用 customElements.define()

  • @property() 装饰器可以指定组件的属性。

  • @query('.css-selector') 装饰器可以给组件选择 DOM 元素。

  • @observe('propertyName') 装饰器可以定义属性观察者。

  • @computed() 装饰器可以定义计算方法。

参考 polymer-decorators github 仓库了解更多示例。

3.7.2.8. 问题分析
Proxy - 代理

如果在一个有代理服务器的环境工作,需要配置按照要求配置 bower 和 npm。为了使得 bower 和 npm 能在代理服务器环境工作,需要在 modules/front/ 目录创建以下文件:

.bowerrc
{
    "proxy":"http://<user>:<password>@<host>:<port>",
    "https-proxy":"http://<user>:<password>@<host>:<port>"
}
.npmrc
proxy=http://<user>:<password>@<host>:<port>
https-proxy=http://<user>:<password>@<host>:<port>
NPM install 失败

Windows 环境的 npm install 有个 已知问题

有可能在构建过程遇到下列错误:

npm ERR! code EPERM
npm ERR! errno -4048
npm ERR! syscall rename
npm ERR! Error: EPERM: operation not permitted,

作为暂时方案(workaround),可以禁用 Windows 防火墙(Defender)或者其它的反病毒软件,确保没在任何 IDE 打开项目,然后再次运行构建过程。

可以关注这个 问题 了解将来可能的解决方案。

3.7.3. 基于 React 的用户界面

React UI 的文档放在 front 模块的 README.md 中。最新版可以在 GitHub 找到。

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. REST API

全局的 REST API 提供以下功能:

  • 实体的增删查改(CRUD)操作。

  • 执行预定义的 JPQL 查询语句。

  • 执行服务(Service)方法。

  • 获取元数据(实体、视图、枚举、数据类型)。

  • 获取当前用户权限(访问实体、实体属性、特殊权限)。

  • 获取当前用户信息(名称、语言、时区等)。

  • 上传和下载文件。

REST API 使用 OAuth2 协议来做认证,并且支持匿名访问。

API 端点(endpoint)的详细文档: http://files.cuba-platform.com/swagger/7.0

使用 REST API cookbook 章节包含了很多实际使用 REST API 的例子。以下提供一些功能和配置选项的正式说明。

3.9.1. 预定义 JPQL 查询配置

在 CUBA 应用程序中,预定义的 JPQL 查询语句需要写在特定的配置文件中,这些配置文件是由 web 或者 portal 模块的 cuba.rest.queriesConfig 应用程序属性来指定,比如在 web-app.properties 文件中:

cuba.rest.queriesConfig = +com/company/myapp/rest-queries.xml

rest-queries.xml 这个文件必须放在 web 或者 portal 模块的根目录(比如 com.company.myapp)。文件内容格式是由 rest-queries.xsd shema 定义。示例:

<?xml version="1.0"?>
<queries xmlns="http://schemas.haulmont.com/cuba/rest-queries.xsd">
    <query name="carByVin" entity="sample$Car" view="carEdit">
        <jpql><![CDATA[select c from sample$Car c where c.vin = :vin]]></jpql>
        <params>
            <param name="vin" type="java.lang.String"/>
        </params>
    </query>
    <query name="allColours" entity="sample$Colour" view="_local">
        <jpql><![CDATA[select u from sample$Colour u order by u.name]]></jpql>
    </query>
    <query name="carsByIds" entity="sample$Car" view="carEdit" cacheable="true">
        <jpql><![CDATA[select c from sample$Car c where c.id in :ids]]></jpql>
        <params>
            <param name="ids" type="java.util.UUID[]"/>
        </params>
    </query>
    <query name="myOrders" entity="sample$Order" view="orderBrowse">
        <jpql><![CDATA[select o from sample$Order o where o.createdBy = :session$userLogin]]></jpql>
    </query>
</queries>

关于配置和执行查询语句的示例,可以参考 执行 JPQL 查询(GET)执行 JPQL 查询(POST) 章节。

平台还提供了预定义的 all 查询用来获取指定实体类型的所有实例。这个还能和 /count 一起使用,用来获得实体的总数,示例:

http://localhost:8080/app/rest/v2/queries/sales$Order/all/count

XML 里面的 query 元素还可以使用 cacheable 属性来启用查询语句的缓存

查询语句可以包含预定义的参数,这些参数使用当前用户的 id 和登录名:session$userIdsession$userLogin。不需要在 params 元素声明这两个参数(参考上面的例子)。

3.9.2. 服务配置

CUBA 应用中,service 的方法可以通过 REST API 访问,这些方法的列表需要配置在 xml 配置文件中,这个 xml 配置文件需要通过 web 或者 portal 模块的 cuba.rest.servicesConfig 应用程序属性来指定,比如在 web-app.properties 文件中:

cuba.rest.servicesConfig = +com/company/myapp/rest-services.xml

rest-services.xml 文件需要放在 web 或者 portal 模块的根目录(比如 com.company.myapp)。文件内容格式是由 rest-services-v2.xsd shema 来定义,示例:

<?xml version="1.0" encoding="UTF-8"?>
<services xmlns="http://schemas.haulmont.com/cuba/rest-services-v2.xsd">
    <service name="myapp_SomeService">
        <method name="sum">
            <param name="number1"/>
            <param name="number2"/>
        </method>
        <method name="emptyMethod"/>
        <method name="overloadedMethod">
            <param name="intParam" type="int"/>
        </method>
        <method name="overloadedMethod">
            <param name="stringParam" type="java.lang.String"/>
        </method>
    </service>
</services>

如果服务没有重载带有相同数量参数的方法的话,方法参数的类型可以省略。否则,参数类型必须要显式定义。

关于配置和调用服务的示例可以参考 调用服务方法(GET) 章节。

如果某些服务的方法需要在 匿名访问被禁用的情况下进行无认证访问,那么可以在服务配置文件中给这个方法添加 anonymousAllowed="true" 属性:

<?xml version="1.0" encoding="UTF-8"?>
<services xmlns="http://schemas.haulmont.com/cuba/rest-services-v2.xsd">
    <service name="myapp_SomeService">
        <method name="sum" anonymousAllowed="true">
            <param name="number1"/>
            <param name="number2"/>
        </method>
    </service>
</services>

3.9.3. 数据模型版本

REST API 可以处理数据模型更改。这个功能在这个场景很有用,比如,有些实体属性被重命名了,但是 REST API 客户端不知道这个改动,并且还是希望这个属性保留原来的名字。

这种情况下,REST API 允许定义实体 JSON 的转换规则。如果客户端应用在发送请求时,在查询参数里带上数据模型的版本,REST API 的 response 或者 request body 中的 JSON 会按照请求中特定的领域模型版本的转换规则进行转换。

JSON 的转换规则必须写配置文件中,这些配置文件要通过 web 或者 portal 模块的 cuba.rest.jsonTransformationConfig 应用程序属性来定义,比如 web-app.properties 文件:

cuba.rest.jsonTransformationConfig = +com/company/myapp/rest-json-transformations.xml

rest-json-transformations.xml 文件需要放在 web 或者 portal 模块(比如 com.company.myapp)。这个文件的内容是遵循 rest-json-transformations.xsd schema 的。文件示例:

<?xml version="1.0"?>
<transformations xmlns="http://schemas.haulmont.com/cuba/rest-json-transformations.xsd">

    <transformation modelVersion="1.0" oldEntityName="sales$OldOrder" currentEntityName="sales$NewOrder">
        <renameAttribute oldName="oldNumber" currentName="number"/>
        <renameAttribute oldName="date" currentName="deliveryDate"/>
        <toVersion>
            <removeAttribute name="discount"/>
        </toVersion>
    </transformation>

    <transformation modelVersion="1.0" currentEntityName="sales$Contractor">
        <renameAttribute oldName="summary" currentName="total"/>
        <renameAttribute oldName="familyName" currentName="lastName"/>
        <fromVersion>
            <removeAttribute name="city"/>
            <removeAttribute name="country"/>
        </fromVersion>
        <toVersion>
            <removeAttribute name="phone"/>
        </toVersion>
    </transformation>

    <transformation modelVersion="1.1" currentEntityName="sales$NewOrder">
        <renameAttribute oldName="date" currentName="deliveryDate"/>
    </transformation>

</transformations>

在配置文件中定义的标准转换器可以进行下列实体 JSON 转换任务:

  • 重命名实体

  • 重命名实体属性

  • 删除实体属性

JSON 转换在下列 REST API 端点(endpoint)生效:

  • /entities - 获取实体列表、获取单个实体、实体创建、实体更新、实体删除

  • /queries - 查询返回的实体 JSON 会被转换

  • /services - JSON 转换会发生在两个地方,service 方法返回的实体,以及作为参数传入 service 方法的实体。

如果对 REST API 的请求使用了 modelVersion URL 参数指定数据模型版本,那么 JSON 转换就会生效。

参考数据模型版本化示例了解如何配置数据模型版本以及怎样在客户端程序使用。

3.9.4. 跨域请求(CORS)设置

默认情况下,CUBA 允许所有的 REST API 的跨域请求。限制请求来源地址列表,可以通过设置 cuba.rest.allowedOrigins 应用程序属性。

3.9.5. 匿名访问

默认情况下,匿名访问时被禁用的。通过应用程序属性 cuba.rest.anonymousEnabled 来启用。如果请求中没有包含 Authentication 信息头,这个请求会被认为是匿名的。匿名访问的情况下,SecurityContext 会包含匿名用户会话。

给匿名用户配置权限,必须给 cuba.anonymousLogin 参数定义的匿名用户指定 角色

3.9.6. 其它 REST API 设置

cuba.rest.client.id - 定义默认 REST API 客户端 id。这个客户端不是用户,而是使用 API 的应用程序。

cuba.rest.client.secret - 定义默认 REST API 客户端密码。这个客户端不是用户,而是使用 API 的应用程序。

cuba.rest.client.tokenExpirationTimeSec - 定义默认客户端访问令牌(access token)的过期时间,单位为秒。

cuba.rest.client.refreshTokenExpirationTimeSec - 定义默认客户端刷新令牌(refresh token)的过期时间,单位为秒。

cuba.rest.client.authorizedGrantTypes - 默认客户端认证授权类型的列表。如果要禁用刷新令牌,从列表中删除 refresh_token 项。

cuba.rest.maxUploadSize - 定义通过 REST API 能上传的文件的最大体积。

cuba.rest.reuseRefreshToken - 定义是否可以重用刷新令牌。

cuba.rest.requiresSecurityToken - 定义是否需要在 JSON 中传递额外的系统属性。细节参考 集合属性的安全约束

cuba.rest.tokenMaskingEnabled - 定义在系统日志中是否需要对 REST API 的令牌进行掩码处理。

3.9.7. 创建带 OAuth2 保护的自定义控制器

如果需要创建由 OAuth2 保护的自定义 REST 控制器,可以按照以下步骤:

  1. 假设有如下 REST 控制器:

    package com.company.test.portal.myapi;
    
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    import com.company.test.services.SomeService;
    
    @RestController
    @RequestMapping("/myapi")
    public class MyController {
    
        @Inject
        protected SomeService someService;
    
        @GetMapping("/dosmth")
        public String doSmth() {
            return someService.getResult();
        }
    }
  2. web 或者 portal 模块包的根目录(com.company.test)创建一个新的 Spring 配置文件 rest-dispatcher-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:security="http://www.springframework.org/schema/security"
           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/security http://www.springframework.org/schema/security/spring-security-4.2.xsd">
    
        <!-- Define a base package for your controllers-->
        <context:component-scan base-package="com.company.test.portal.myapi"/>
    
        <security:http pattern="/rest/myapi/**"
                       create-session="stateless"
                       entry-point-ref="oauthAuthenticationEntryPoint"
                       xmlns="http://www.springframework.org/schema/security">
            <!-- Specify one or more protected URL patterns-->
            <intercept-url pattern="/rest/myapi/**" access="isAuthenticated()"/>
            <anonymous enabled="false"/>
            <csrf disabled="true"/>
            <cors configuration-source-ref="cuba_RestCorsSource"/>
            <custom-filter ref="resourceFilter" before="PRE_AUTH_FILTER"/>
            <custom-filter ref="cuba_AnonymousAuthenticationFilter" after="PRE_AUTH_FILTER"/>
        </security:http>
    </beans>
  3. 在模块的属性文件(比如 portal-app.properties)里定义一个累加属性 cuba.restSpringContextConfig

    cuba.restSpringContextConfig = +com/company/test/rest-dispatcher-spring.xml
  4. 这个新的控制器在 CubaRestApiServlet 上下文内运行。所以控制器内方法的 URL 以 /rest 开头,比如 doSmth()方法可以通过 URL http://localhost:8080/app-portal/rest/myapi/dosmth 来访问。

    自定义控制器的 URL 绝对不能/rest/v2 开头。

3.9.8. 集合属性的安全约束

考虑下面这个情况:

  • 数据模型包含 OrderOrderLine 实体,形成了一对多的关系。

  • REST 客户端获取了一个 Order 实例,并且带着嵌套的 OrderLine 实例集合。

  • 有些安全约束可以过滤掉某些 OrderLine 实例,从而使得客户端不加载这些实例,也不知道这些实例的存在。比方说,line5 不会被加载,但是存在于数据库。

  • 如果客户端从集合中删除了一条,比如说 line2,然后将整个组合通过 /entities/{entityName}/{entityId} API 端点进行保存,会有两种可能的输出:

    1. 如果这个约束从实体被加载之后没有更改过,框架会首先在 OrderLine 集合里面恢复被过滤的 line5 实例然后只删除 line2,这个是正确的行为。

    2. 但是如果这个约束在什么时候更改了,导致 line5 能被用户看见,此时,框架就不会正确的在集合内恢复这些被过滤的实例(因为从目前的约束状态看,line5 应该被客户加载过)。结果导致 line2line5 都会被删除。

如果对上面描述的场景有担忧,可以通过这种方式排除数据丢失的隐患:在表示实体的 JSON 中发送一个特殊的系统属性。这个属性被称为 __securityToken 并且会自动包含在返回的 JSON 中,如果 cuba.rest.requiresSecurityToken 设置成 true 的话。REST 客户端的责任就是在保存实体的时候将这个属性带入并返回。

实体 JSON 包含安全令牌的示例:

{
  "id": "fa430b56-ceb2-150f-6a85-12c691908bd1",
  "number": "OR-000001",
  "items": [
    {
      "id": "82e6e6d2-be97-c81c-c58d-5e2760ae095a",
      "description": "Item 1"
    },
    {
      "id": "988a8cb5-d61a-e493-c401-f717dd9a2d66",
      "description": "Item 2"
    }
  ],
  "__securityToken": "0NXc6bQh+vZuXE4Fsk4mJX4QnhS3lOBfxzUniltchpxPfi1rZ5htEmekfV60sbEuWUykbDoY+rCxdhzORaYQNQ=="
}

__securityToken 属性包含了被过滤的实例标识符的加密串,从而使得框架总是能恢复需要的信息而不管约束怎么改变。

3.9.9. 令牌存储持久化

默认情况下,OAuth 令牌只是保存在内存。如果需要在数据库保存这些令牌,设置 cuba.rest.storeTokensInDb 应用程序属性为 true。这个属性的值是保存在数据库的,因此可以在系统界面 Administration > Application Properties 修改这个值。

数据库中保存的过期令牌需要定期清理。这个定时任务的时间安排 cron 表达式通过 cuba.rest.deleteExpiredTokensCron 定义。

3.9.10. 项目特定的 Swagger 文档

REST API 通用接口文档可以通过 http://files.cuba-platform.com/swagger/7.0 获得。

任何运行的 CUBA 应用程序还有项目特定的接口文档,文档结构按照 Swagger specification version 2.0 生成。

文档可以通过下面这些 URL 获得:

  • /rest/v2/docs/swagger.yaml - YAML 版本的通用接口文档

  • /rest/v2/docs/swagger.json - JSON 版本的通用接口文档

  • /rest/v2/docs/swaggerDetailed.yaml - YAML 版本的项目特定 Swagger 接口文档

  • /rest/v2/docs/swaggerDetailed.json - JSON 版本的项目特定 Swagger 接口文档

示例:

http://localhost:8080/app/rest/v2/docs/swagger.yaml
http://localhost:8080/app/rest/v2/docs/swaggerDetailed.yaml

接口文档可以用来查看,测试或者生成 REST API 的客户端代码。参考以下工具: Swagger UISwagger InspectorPostmanSwagger Codegen

生成的文档包括:

  1. CRUD 操作,比如:

    所有 CRUD 的参数和相应都有对应的模型,示例:

    swagger crud model
  2. 预定义的 REST 查询:

    swagger query
  3. 暴露的 services 接口:

    swagger service

3.9.11. 使用 REST API

本节包含 REST API 的用法示例。

相关 REST API 方法的详细信息是根据 Swagger 规范编写的,可在地址 http://files.cuba-platform.com/swagger/7.0 中找到。

3.9.11.1. 获取 OAuth 令牌

任何 REST API 方法都需要 OAuth 令牌(除了使用匿名访问)。可以通过 POST 方式请求此地址获取令牌:

http://localhost:8080/app/rest/v2/oauth/token

此端点使用基本身份验证进行访问保护,REST API 客户端 ID 和密码用于这里的份验证。请注意,这里的客户端 ID 和密码不是应用程序的用户登录名和密码。REST API 客户端 ID 和密码通过应用程序属性 cuba.rest.client.idcuba.rest.client.secret 定义。(默认值分别为 clientsecret)。必须在 Authorization 请求头中以 base64 编码的字符串传递客户端 ID 和密码,客户端 ID 和密码使用冒号(":")分隔。

请求类型必须是 application/x-www-form-urlencoded,编码方式为 UTF-8

请求必须包含以下参数:

  • grant_type - password

  • username - 应用程序用户登录名。

  • password - 应用程序用户密码。

POST /oauth/token
Authorization: Basic Y2xpZW50OnNlY3JldA==
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=smith&password=qwerty123

也可以使用 cURL:

curl -H "Content-type: application/x-www-form-urlencoded" -H "Authorization: Basic Y2xpZW50OnNlY3JldA==" -d "grant_type=password&username=admin&password=admin" http://localhost:8080/app/rest/v2/oauth/token

方法返回一个 JSON 对象:

{
  "access_token": "29bc6b45-83cd-4050-8c7a-2a8a60adf251",
  "token_type": "bearer",
  "refresh_token": "e765446f-d49e-4634-a6d3-2d0583a0e7ea",
  "expires_in": 43198,
  "scope": "rest-api"
}

访问令牌值在 access_token 属性中。

要使用访问令牌,将其放在带有 Bearer 类型的 Authorization 请求头中,例如:

Authorization: Bearer 29bc6b45-83cd-4050-8c7a-2a8a60adf251

refresh_token 属性包含刷新令牌值。刷新令牌不能用于访问受保护的资源,但它具有比访问令牌更长的生命周期,并且可以用于在当前的访问令牌到期时获取新的访问令牌。

使用刷新令牌获取新访问令牌的请求必须包含以下参数:

  • grant_type - refresh_token.

  • refresh_token - 刷新令牌值.

POST /oauth/token
Authorization: Basic Y2xpZW50OnNlY3JldA==
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=e765446f-d49e-4634-a6d3-2d0583a0e7ea

另请参阅以下与令牌相关的应用程序属性:

3.9.11.2. 使用 LDAP 进行 REST API 身份验证

可以使用以下属性启用 REST 的 LDAP 身份验证:

  • cuba.rest.ldap.enabled - 是否启用 LDAP 身份验证。

  • cuba.rest.ldap.urls – LDAP 服务器 URL。

  • cuba.rest.ldap.base – 用户搜索的基准 DN。

  • cuba.rest.ldap.user – 有权从目录中读取信息的系统用户的专有名称。

  • cuba.rest.ldap.passwordcuba.web.ldap.user属性中定义的系统用户的密码。

  • cuba.rest.ldap.userLoginField - 用于匹配登录名的 LDAP 用户属性的名称。默认情况下是 sAMAccountName(适用于 Active Directory)。

local.app.properties文件示例:

cuba.rest.ldap.enabled = true
cuba.rest.ldap.urls = ldap://192.168.1.1:389
cuba.rest.ldap.base = ou=Employees,dc=mycompany,dc=com
cuba.rest.ldap.user = cn=System User,ou=Employees,dc=mycompany,dc=com
cuba.rest.ldap.password = system_user_password

可以使用以下端点获取 OAuth 令牌:

http://localhost:8080/app/rest/v2/ldap/token

此端点使用基本身份验证进行访问保护。REST API 客户端标识符和密码用于基本身份验证。请注意,这些不是应用程序用户登录名和密码。REST API 客户端 ID 和密码在应用程序属性 cuba.rest.client.idcuba.rest.client.secret 中定义。(默认值分别为 clientsecret)。必须在 Authorization 请求头中以 base64 编码的字符串传递客户端 ID 和密码,客户端 ID 和密码使用冒号(":")分隔。

请求参数与标准身份验证相同:

  • grant_type - 总是 password.

  • username - 应用程序用户登录名。

  • password - 应用程序用户密码。

请求类型必须是 application/x-www-form-urlencoded,编码方式为 UTF-8

此外,可以禁用使用登录名和密码的标准身份验证:

cuba.rest.standardAuthenticationEnabled = false
3.9.11.3. 自定义验证

身份验证机制可以通过密钥、链接、LDAP 登录名和密码等提供访问令牌。REST API 使用特有的身份验证机制,无法被修改。要使用自定义身份验证过程,需要创建 REST 控制器并使用其 URL。

下面我们看看自定义身份验证机制,该机制可以通过推广码获取 OAuth 令牌。在下面的示例中,我们将使用包含带有 code 属性的 Coupon 实体的示例应用程序。我们将此属性的值作为 GET 请求中的身份验证参数发送。

  1. 创建一个带有 code 属性的 Coupon 实体:

    @Column(name = "CODE", unique = true, length = 4)
    protected String code;
  2. 使用 promo-user 登录名创建一个用户,这个用户将会进行验证。

  3. web 模块的根包(com.company.demo)下创建一个新的名为 rest-dispatcher-spring.xml 的 Spring 配置文件。文件内容必须如下:

    <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.3.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd">
    
        <context:component-scan base-package="com.company.demo.web.rest"/>
    
    </beans>
  4. 将文件包含在 modules/web/src/web-app.properties 文件中的 cuba.restSpringContextConfig 应用程序属性中:

    cuba.restSpringContextConfig = +com/company/demo/rest-dispatcher-spring.xml
  5. web 模块的根包下创建 rest 包,并在其中实现自定义 Spring MVC 控制器。在自定义身份验证后,使用 OAuthTokenIssuer bean 为用户生成并发送 REST API 令牌:

    @RestController
    @RequestMapping("auth-code")
    public class AuthCodeController {
        @Inject
        private OAuthTokenIssuer oAuthTokenIssuer;
        @Inject
        private LoginService loginService;
        @Inject
        private Configuration configuration;
        @Inject
        private DataManager dataManager;
        @Inject
        private MessageTools messageTools;
    
        // here we check secret code and issue token using OAuthTokenIssuer
        @RequestMapping(method = RequestMethod.GET)
        public ResponseEntity get(@RequestParam("code") String authCode) {
            // obtain system session to be able to call middleware services
            WebAuthConfig webAuthConfig = configuration.getConfig(WebAuthConfig.class);
            UserSession systemSession;
            try {
                systemSession = loginService.getSystemSession(webAuthConfig.getTrustedClientPassword());
            } catch (LoginException e) {
                throw new RuntimeException("Error during system auth");
            }
    
            // set security context
            AppContext.setSecurityContext(new SecurityContext(systemSession));
            try {
                // find coupon with code
                LoadContext<Coupon> loadContext = LoadContext.create(Coupon.class)
                        .setQuery(LoadContext.createQuery("select c from demo$Coupon c where c.code = :code")
                                .setParameter("code", authCode));
    
                if (dataManager.load(loadContext) == null) {
                    // if coupon is not found - code is incorrect
                    return new ResponseEntity<>(new ErrorInfo("invalid_grant", "Bad credentials"), HttpStatus.BAD_REQUEST);
                }
    
                // generate token for "promo-user"
                OAuthTokenIssuer.OAuth2AccessTokenResult tokenResult =
                        oAuthTokenIssuer.issueToken("promo-user", messageTools.getDefaultLocale(), Collections.emptyMap());
                OAuth2AccessToken accessToken = tokenResult.getAccessToken();
    
                // set security HTTP headers to prevent browser caching of security token
                HttpHeaders headers = new HttpHeaders();
                headers.set(HttpHeaders.CACHE_CONTROL, "no-store");
                headers.set(HttpHeaders.PRAGMA, "no-cache");
                return new ResponseEntity<>(accessToken, headers, HttpStatus.OK);
            } finally {
                // clean up security context
                AppContext.setSecurityContext(null);
            }
        }
    
        // POJO for JSON error messages
        public static class ErrorInfo implements Serializable {
            private String error;
            private String error_description;
    
            public ErrorInfo(String error, String error_description) {
                this.error = error;
                this.error_description = error_description;
            }
    
            public String getError() {
                return error;
            }
    
            public String getError_description() {
                return error_description;
            }
        }
    }
  6. web/core 模块的扫描中排除 rest 包:OAuthTokenIssuer bean 仅在 REST API 上下文中可用,在应用程序上下文中扫描它会导致错误。

    <context:component-scan base-package="com.company.demo">
        <context:exclude-filter type="regex" expression="com\.company\.demo\.web\.rest\..*"/>
    </context:component-scan>
  7. 现在,用户将能够使用带有 code 参数的 GET HTTP 请求获取 OAuth2 访问代码

    http://localhost:8080/app/rest/auth-code?code=A325

    结果将是:

    {"access_token":"74202587-6c2b-4d74-bcf2-0d687ea85dca","token_type":"bearer","expires_in":43199,"scope":"rest-api"}

    然后,应将获得的访问令牌传递给 REST API,如文档中所述。

3.9.11.3.1. REST API 中的社交账号登录

社交账号登录的机制也可以在 REST API 中使用。完整的示例应用程序可以在 GitHub 上找到,同时在社交网站登录部分有详细描述,下面是使用 Facebook 帐户获取访问令牌的关键点。

  1. web 模块的根包下创建 restapi 包,并在其中实现自定义 Spring MVC 控制器。该控制器应包含两个主要方法:get() 方法获取 ResponseEntity 实例,login() 方法获取 OAuth 令牌。

    @RequestMapping(method = RequestMethod.GET)
    public ResponseEntity get() {
        String loginUrl = getAsPrivilegedUser(() ->
                facebookService.getLoginUrl(getAppUrl(), OAuth2ResponseType.CODE_TOKEN)
        );
    
        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.LOCATION, loginUrl);
        return new ResponseEntity<>(headers, HttpStatus.FOUND);
    }

    在这里,我们检查 Facebook code,获取访问代码并使用 OAuthTokenIssuer 发出访问令牌:

    @RequestMapping(method = RequestMethod.POST, value = "login")
    public ResponseEntity<OAuth2AccessToken> login(@RequestParam("code") String code) {
        User user = getAsPrivilegedUser(() -> {
            FacebookUserData userData = facebookService.getUserData(getAppUrl(), code);
    
            return socialRegistrationService.findOrRegisterUser(
                userData.getId(), userData.getEmail(), userData.getName());
        });
    
        OAuth2AccessTokenResult tokenResult = oAuthTokenIssuer.issueToken(user.getLogin(),
                messageTools.getDefaultLocale(), Collections.emptyMap());
    
        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.CACHE_CONTROL, "no-store");
        headers.set(HttpHeaders.PRAGMA, "no-cache");
        return new ResponseEntity<>(tokenResult.getAccessToken(), headers, HttpStatus.OK);
    }
  2. web/core 模块扫描中排除 restapi 包:OAuthTokenIssuer bean 仅在 REST API 上下文中可用,在应用程序上下文中对其进行扫描将导致错误。

    <context:component-scan base-package="com.company.demo">
        <context:exclude-filter type="regex" expression="com\.company\.demo\.restapi\..*"/>
    </context:component-scan>
  3. 在项目的 modules/web/web/VAADIN 文件夹中创建 facebook-login-demo.html 文件,包含在 HTML 页面上运行的 JavaScript 代码:

    <html>
    <head>
        <title>Facebook login demo with REST-API</title>
        <script src="jquery-3.2.1.min.js"></script>
        <style type="text/css">
     #users { display: none; }
        </style>
    </head>
    <body>
    <h1>Facebook login demo with REST-API</h1>
    
    <script type="application/javascript"...>
    </script>
    
    <a id="fbLink" href="/app/rest/facebook">Login with Facebook</a>
    
    <div id="users">
        You are logged in!
    
        <h1>Users</h1>
    
        <div id="usersList">
        </div>
    </div>
    
    </body>
    </html>

    以下脚本将尝试使用 Facebook 登录。首先,它将从 URL 中删除 code 参数,然后它将 code 传递给 REST API 以获取 OAuth 访问令牌,在成功验证的情况下,将能够正常加载和保存数据。

    var oauth2Token = null;
    
    function tryToLoginWithFacebook() {
        var urlHash = window.location.hash;
    
        if (urlHash && urlHash.indexOf('&code=') >= 0) {
            console.log("Try to login to CUBA REST-API!");
    
            var urlCode = urlHash.substring(urlHash.indexOf('&code=') + '&code='.length);
            console.log("Facebook code: " + urlCode);
    
            history.pushState("", document.title, window.location.pathname);
    
            $.post({
                url: '/app/rest/facebook/login',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                },
                dataType: 'json',
                data: {code: urlCode},
                success: function (data) {
                    oauth2Token = data.access_token;
    
                    loadUsers();
                }
            })
        }
    }
    
    function loadUsers() {
        $.get({
            url: '/app/rest/v2/entities/sec$User?view=_local',
            headers: {
                'Authorization': 'Bearer ' + oauth2Token,
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            success: function (data) {
                $('#fbLink').hide();
                $('#users').show();
    
                $.each(data, function (i, user) {
                    $('#usersList').append("<li>" + user.name + " (" + user.email + ")</li>");
                });
            }
        });
    }
    
    tryToLoginWithFacebook();

    另一个示例或在 CUBA 应用程序中运行 JavaScript 代码,可以在JavaScript 用法示例部分找到。

3.9.11.4. 获取实体实例列表

假设系统有一个 sales$Order 实体,我们需要得到这个实体实例的列表。还有,我们需要得到的不是所有记录,而只是从第 100 条记录开始的 50 条记录。响应必须不仅包含 sales$Order 实体的简单属性,还包含有关客户的信息(名为 customer 的引用字段)。订单必须按日期排序。

获取 sales$Order 实体的所有实例的基本 URL 如下:

http://localhost:8080/app/rest/v2/entities/sales$Order

要实现上述所有条件,必须指定以下请求参数:

  • view - 一个用于加载实体的视图。在我们的例子中,order-edit-view 包含一个 customer 引用。

  • limit - 要返回的实体数。

  • offset - 第一个被提取记录的位置。

  • sort - 将用于排序的实体属性名称。

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中:

Authorization: Bearer 29bc6b45-83cd-4050-8c7a-2a8a60adf251

这样,我们获得以下 GET 请求 URL:

http://localhost:8080/app/rest/v2/entities/sales$Order?view=order-edit-view&limit=50&offset=100&sort=date

使用 cURL,请求将如下所示:

curl -H "Authorization: Bearer d335902c-9cb4-455e-bf92-24ca1d66d72f" http://localhost:8080/app/rest/v2/entities/sales$Order?view=order-edit&limit=50&offset=100&sort=date

响应将是这样的:

[
  {
    "_entityName": "sales$Order",
    "_instanceName": "00001",
    "id": "46322d73-2374-1d65-a5f2-160461da22bf",
    "date": "2016-10-31",
    "description": "Vacation order",
    "number": "00001",
    "items": [
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Beach umbrella",
        "id": "95a04f46-af7a-a307-de4e-f2d73cfc74f7",
        "price": 23,
        "name": "Beach umbrella"
      },
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Sun lotion",
        "id": "a2129675-d158-9e3a-5496-41bf1a315917",
        "price": 9.9,
        "name": "Sun lotion"
      }
    ],
    "customer": {
      "_entityName": "sales$Customer",
      "_instanceName": "Toby Burns",
      "id": "4aa9a9d8-01df-c8df-34c8-c385b566ea05",
      "firstName": "Toby",
      "lastName": "Burns"
    }
  },
  {
    "_entityName": "sales$Order",
    "_instanceName": "00002",
    "id": "b2ad3059-384c-3e03-b62d-b8c76621b4a8",
    "date": "2016-12-31",
    "description": "New Year party set",
    "number": "00002",
    "items": [
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Jack Daniels",
        "id": "0c566c9d-7078-4567-a85b-c67a44f9d5fe",
        "price": 50.7,
        "name": "Jack Daniels"
      },
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Hennessy X.O",
        "id": "c01be87b-3f91-7a86-50b5-30f2f0a49127",
        "price": 79.9,
        "name": "Hennessy X.O"
      }
    ],
    "customer": {
      "_entityName": "sales$Customer",
      "_instanceName": "Morgan Collins",
      "id": "5d111245-2ed0-abec-3bee-1a196da92e3e",
      "firstName": "Morgan",
      "lastName": "Collins"
    }
  }
]

请注意,响应中的每个实体都有一个带有实体名称的 _entityName 属性和一个带有实体实例名_instanceName 属性。

3.9.11.5. 新建实体实例

可以使用 POST 请求地址创建新的 sales$Order 实体实例:

http://localhost:8080/app/rest/v2/entities/sales$Order

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

请求体必须包含一个描述新实体实例的 JSON 对象,例如:

{
  "number": "00017",
  "date": "2016-09-01",
  "description": "Back to school",
  "items": [
    {
      "_entityName": "sales$OrderItem",
      "price": 100,
      "name": "School bag"
    },
    {
      "_entityName": "sales$OrderItem",
      "price": 9.90,
      "name": "Pencils"
    }
  ],
  "customer": {
    "id": "4aa9a9d8-01df-c8df-34c8-c385b566ea05"
  }
}

以下是 cURL POST 请求的示例,该请求创建了一个新的 Order 实例:

curl -H "Authorization: Bearer d335902c-9cb4-455e-bf92-24ca1d66d72f" -H "Content-Type: application/json" -X POST -d "{\"date\": \"2018-10-12 15:47:28\", \"amount\":  9.90, \"customer\": {\"id\": \"383ebce2-b295-7378-36a1-bcf93693821f\"}}" http://localhost:8080/app/rest/v2/entities/sales$Order

订单项(items)集合和 customer 的引用需要通过请求体传递。我们来看看如何处理这些属性。

首先,我们快速浏览一下 Order 类:

package com.company.sales.entity;

import com.haulmont.chile.core.annotations.Composition;
import com.haulmont.chile.core.annotations.NamePattern;
import com.haulmont.cuba.core.entity.StandardEntity;
import com.haulmont.cuba.core.entity.annotation.OnDelete;
import com.haulmont.cuba.core.global.DeletePolicy;

import javax.persistence.*;
import java.util.Date;
import java.util.Set;

@NamePattern("%s|number")
@Table(name = "SALES_ORDER")
@Entity(name = "sales$Order")
public class Order extends StandardEntity {
    private static final long serialVersionUID = 7565070704618724997L;

    @Column(name = "NUMBER_")
    protected String number;

    @Temporal(TemporalType.DATE)
    @Column(name = "DATE_")
    protected Date date;

    @Column(name = "DESCRIPTION")
    protected String description;

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

    @Composition
    @OnDelete(DeletePolicy.CASCADE)
    @OneToMany(mappedBy = "order")
    protected Set<OrderItem> items;

    //getters and setters omitted
}

items 集合属性使用@Composition注解。用于实体创建和更新的 REST API 方法将为此类集合的所有成员创建新的实体实例。在我们的例子中,将使用 Order 实体创建两个 OrderItem 实体实例。

customer 引用没有 @Composition 注解,这也是为什么 REST API 会尝试查找具有给定 id 的客户并将其设置给 customer 字段。如果未找到客户,则不会创建订单,并且该方法将返回错误。

在成功执行方法的情况下,返回所创建实体的完整对象图:

{
  "_entityName": "sales$Order",
  "id": "5d7ff8e3-7828-ba94-d6ba-155c5c4f2a50",
  "date": "2016-09-01",
  "description": "Back to school",
  "version": 1,
  "number": "00017",
  "createdBy": "admin",
  "createTs": "2016-10-13 18:12:21.047",
  "updateTs": "2016-10-13 18:12:21.047",
  "items": [
    {
      "_entityName": "sales$OrderItem",
      "id": "3158b8ed-7b7a-568e-aec5-0822c3ebbc24",
      "createdBy": "admin",
      "price": 9.9,
      "name": "Pencils",
      "createTs": "2016-10-13 18:12:21.047",
      "version": 1,
      "updateTs": "2016-10-13 18:12:21.047",
      "order": {
        "_entityName": "sales$Order",
        "id": "5d7ff8e3-7828-ba94-d6ba-155c5c4f2a50"
      }
    },
    {
      "_entityName": "sales$OrderItem",
      "id": "72774b8b-4fea-6403-7b52-4a6a749215fc",
      "createdBy": "admin",
      "price": 100,
      "name": "School bag",
      "createTs": "2016-10-13 18:12:21.047",
      "version": 1,
      "updateTs": "2016-10-13 18:12:21.047",
      "order": {
        "_entityName": "sales$Order",
        "id": "5d7ff8e3-7828-ba94-d6ba-155c5c4f2a50"
      }
    }
  ],
  "customer": {
    "_entityName": "sales$Customer",
    "id": "4aa9a9d8-01df-c8df-34c8-c385b566ea05",
    "firstName": "Toby",
    "lastName": "Burns",
    "createdBy": "admin",
    "createTs": "2016-10-13 15:32:01.657",
    "version": 1,
    "updateTs": "2016-10-13 15:32:01.657"
  }
}
3.9.11.6. 更新现有实体实例

可以使用 PUT 请求地址更新现有的 sales$Order 实体实例:

http://localhost:8080/app/rest/v2/entities/sales$Order/5d7ff8e3-7828-ba94-d6ba-155c5c4f2a50

这里查询的最后一部分是实体标识符。

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

请求体必须包含一个只包含要更新的字段的 JSON 对象,例如:

{
  "date": "2017-10-01",
  "customer" : {
    "id" : "5d111245-2ed0-abec-3bee-1a196da92e3e"
  }
}

响应体将包含一个修改过的实体:

{
  "_entityName": "sales$Order",
  "id": "5d7ff8e3-7828-ba94-d6ba-155c5c4f2a50",
  "date": "2017-10-01",
  "updatedBy": "admin",
  "description": "Back to school",
  "version": 2,
  "number": "00017",
  "createdBy": "admin",
  "createTs": "2016-10-13 18:12:21.047",
  "updateTs": "2016-10-13 19:13:02.656",
  "customer": {
    "_entityName": "sales$Customer",
    "id": "5d111245-2ed0-abec-3bee-1a196da92e3e",
    "firstName": "Morgan",
    "lastName": "Collins",
    "createdBy": "admin",
    "createTs": "2016-10-13 15:31:27.821",
    "version": 1,
    "updateTs": "2016-10-13 15:31:27.821",
    "email": "collins@gmail.com"
  }
}
3.9.11.7. 执行 JPQL 查询(GET)

在使用 REST API 执行查询之前,查询语句必须在配置文件中进行描述。需要在 web 模块的主包中创建 rest-queries.xml 文件(例如 com.company.sales)。然后,必须在 web 模块的应用程序属性文件(web-app.properties)中定义该文件。

cuba.rest.queriesConfig = +com/company/sales/rest-queries.xml

rest-queries.xml 内容:

<?xml version="1.0"?>
<queries xmlns="http://schemas.haulmont.com/cuba/rest-queries.xsd">
    <query name="ordersAfterDate" entity="sales$Order" view="order-edit-view">
        <jpql><![CDATA[select o from sales$Order o where o.date >= :startDate and o.date <= :endDate]]></jpql>
        <params>
            <param name="startDate" type="java.util.Date"/>
            <param name="endDate" type="java.util.Date"/>
        </params>
    </query>
</queries>

要执行 JPQL 查询,必须执行以下 GET 请求:

http://localhost:8080/app/rest/v2/queries/sales$Order/ordersAfterDate?startDate=2016-11-01&endDate=2017-11-01

请求 URL 部分:

  • sales$Order - 提取的实体名称。

  • ordersAfterDate - 配置文件中的查询名称。

  • startDateendDate - 带有具体值的请求参数。

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

该方法返回提取的实体实例的 JSON 数组:

[
  {
    "_entityName": "sales$Order",
    "_instanceName": "00002",
    "id": "b2ad3059-384c-3e03-b62d-b8c76621b4a8",
    "date": "2016-12-31",
    "description": "New Year party set",
    "number": "00002",
    "items": [
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Jack Daniels",
        "id": "0c566c9d-7078-4567-a85b-c67a44f9d5fe",
        "price": 50.7,
        "name": "Jack Daniels"
      },
      {
        "_entityName": "sales$OrderItem",
        "_instanceName": "Hennessy X.O",
        "id": "c01be87b-3f91-7a86-50b5-30f2f0a49127",
        "price": 79.9,
        "name": "Hennessy X.O"
      }
    ],
    "customer": {
      "_entityName": "sales$Customer",
      "_instanceName": "Morgan Collins",
      "id": "5d111245-2ed0-abec-3bee-1a196da92e3e",
      "firstName": "Morgan",
      "lastName": "Collins"
    }
  }
]

Swagger 文档 中提供了可能的请求参数的完整列表。

3.9.11.8. 执行 JPQL 查询(POST)

也可以使用 POST HTTP 请求执行查询。当需要将集合作为查询参数值传递时,可以使用 POST 请求。在这种情况下,REST 查询配置文件中查询参数的类型必须以方括号结尾:java.lang.String[]java.util.UUID[] 等。

<?xml version="1.0"?>
<queries xmlns="http://schemas.haulmont.com/cuba/rest-queries.xsd">
    <query name="ordersByIds" entity="sales$Order" view="order-edit-view">
        <jpql><![CDATA[select o from sales$Order o where o.id in :ids and o.status = :status]]></jpql>
        <params>
            <param name="ids" type="java.util.UUID[]"/>
            <param name="status" type="java.lang.String"/>
        </params>
    </query>
</queries>

查询参数值必须作为 JSON map 在请求体中传递:

{
  "ids": ["c273fca1-33c2-0229-2a0c-78bc6d09110a", "e6c04c18-c8a1-b741-7363-a2d58589d800", "d268a4e1-f316-a7c8-7a96-87ba06afbbbd"],
  "status": "ready"
}

POST 请求 URL:

http://localhost:8080/app/rest/v2/queries/sales$Order/ordersByIds?returnCount=true

3.9.11.9. 调用服务方法(GET)

假设系统中存在 OrderService 服务。实现如下:

package com.company.sales.service;

import com.haulmont.cuba.core.EntityManager;
import com.haulmont.cuba.core.Persistence;
import com.haulmont.cuba.core.Transaction;
import org.springframework.stereotype.Service;
import javax.inject.Inject;
import java.math.BigDecimal;

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

    @Inject
    private Persistence persistence;

    @Override
    public BigDecimal calculatePrice(String orderNumber) {
        BigDecimal orderPrice = null;
        try (Transaction tx = persistence.createTransaction()) {
            EntityManager em = persistence.getEntityManager();
            orderPrice = (BigDecimal) em.createQuery("select sum(oi.price) from sales$OrderItem oi where oi.order.number = :orderNumber")
                    .setParameter("orderNumber", orderNumber)
                    .getSingleResult();
            tx.commit();
        }

        return orderPrice;
    }
}

在使用 REST API 执行之前,必须在配置文件中允许服务方法通过 REST 调用。需要在 web 模块的主包中创建 rest-services.xml 文件(例如 com.company.sales)。然后,必须在 web 模块的应用程序属性文件(web-app.properties)中定义该文件。

cuba.rest.servicesConfig = +com/company/sales/rest-services.xml

rest-services.xml 内容:

<?xml version="1.0" encoding="UTF-8"?>
<services xmlns="http://schemas.haulmont.com/cuba/rest-services-v2.xsd">
    <service name="sales_OrderService">
        <method name="calculatePrice">
            <param name="orderNumber"/>
        </method>
    </service>
</services>

要调用该服务的方法,可以执行以下 GET 请求:

http://localhost:8080/app/rest/v2/services/sales_OrderService/calculatePrice?orderNumber=00001

请求 URL 部分:

  • sales_OrderService - 服务名。

  • calculatePrice - 方法名。

  • orderNumber - 带有具体值的参数名称。

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

服务方法可以返回简单数据类型、实体、实体集合或可序列化 POJO 的结果。在我们的例子中返回 BigDecimal,因此响应体中只包含一个数字:

39.2
3.9.11.10. 调用服务方法(POST)

REST API 不仅可以执行具有简单数据类型参数的方法,还可以执行具有以下参数的方法:

  • 实体

  • 实体集合

  • 可序列化的 POJO

假设我们在上一节中创建的 OrderService 中添加一个新方法:

@Override
public OrderValidationResult validateOrder(Order order, Date validationDate){
    OrderValidationResult result=new OrderValidationResult();
    result.setSuccess(false);
    result.setErrorMessage("Validation of order "+order.getNumber()+" failed. validationDate parameter is: "+validationDate);
    return result;
}

OrderValidationResult 类如下所示:

package com.company.sales.service;

import java.io.Serializable;

public class OrderValidationResult implements Serializable {

    private boolean success;

    private String errorMessage;

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public String getErrorMessage() {
        return errorMessage;
    }

    public void setErrorMessage(String errorMessage) {
        this.errorMessage = errorMessage;
    }
}

新方法在参数列表中有一个 Order 实体,并返回一个 POJO。

在使用 REST API 调用之前该方法必须允许 REST 调用,因此我们添加一条记录到 rest-services.xml 配置文件中(在 调用服务方法(GET) 中描述过)。

<?xml version="1.0" encoding="UTF-8"?>
<services xmlns="http://schemas.haulmont.com/cuba/rest-services-v2.xsd">
    <service name="sales_OrderService">
        <method name="calculatePrice">
            <param name="orderNumber"/>
        </method>
        <method name="validateOrder">
            <param name="order"/>
            <param name="validationDate"/>
        </method>
    </service>
</services>

可以使用以下地址以 POST 请求调用 validateOrder 服务的方法:

http://localhost:8080/app/rest/v2/services/sales_OrderService/validateOrder

如果 POST 请求参数在请求体中传递。请求体必须包含一个 JSON 对象,此对象的每个字段对应于服务的方法参数。

{
  "order" : {
    "number": "00050",
    "date" : "2016-01-01"
  },
  "validationDate": "2016-10-01"
}

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

REST API 方法返回可序列化的 POJO:

{
  "success": false,
  "errorMessage": "Validation of order 00050 failed. validationDate parameter is: 2016-10-01"
}
3.9.11.11. 文件下载

下载文件 时,在请求头中传递安全令牌通常很不方便。用户更希望有一个用于下载的 URL,这个 URL 可以设置给 img 标签的 src 属性。

作为该问题的解决方案,OAuth 令牌还可以在请求 URL 中以 access_token 参数传递。

例如,图片被上传到应用程序。其 FileDescriptor 实例的 id 为 44809679-e81c-e5ae-dd81-f56f223761d6

在这种情况下,下载图像的 URL 如下所示:

http://localhost:8080/app/rest/v2/files/44809679-e81c-e5ae-dd81-f56f223761d6?access_token=a2f0bb4e-773f-6b59-3450-3934cbf0a2d6

3.9.11.12. 文件上传

要上传文件,应该先获得一个访问令牌,在后续请求中要使用这个令牌。

假设我们有如下文件上传表单:

<form id="fileForm">
    <h2>Select a file:</h2>
    <input type="file" name="file" id="fileUpload"/>
    <br/>
    <button type="submit">Upload</button>
</form>

<h2>Result:</h2>
<img id="uploadedFile" src="" style="display: none"/>

我们将使用 jQuery 进行上传,并使用 data 获取返回的 JSON,其内容是为上传的文件新建的 FileDescriptor 实例。之后我们可以将访问令牌作为参数通过其 FileDescriptor 的 id 访问上传的文件:

$('#fileForm').submit(function (e) {
    e.preventDefault();

    var file = $('#fileUpload')[0].files[0];
    var url = 'http://localhost:8080/app/rest/v2/files?name=' + file.name; // send file name as parameter

    $.ajax({
        type: 'POST',
        url: url,
        headers: {
            'Authorization': 'Bearer ' + oauthToken // add header with access token
        },
        processData: false,
        contentType: false,
        dataType: 'json',
        data: file,
        success: function (data) {
            alert('Upload successful');

            $('#uploadedFile').attr('src',
                'http://localhost:8080/app/rest/v2/files/' + data.id + '?access_token=' + oauthToken); // update image url
            $('#uploadedFile').show();
        }
    });
});
3.9.11.13. JavaScript 用法示例

本节包含在 JavaScript 中使用 REST API v2 的示例,此处 JavaScript 运行在 HTML 页面中。该页面最初显示登录表单,登录成功后显示消息和实体列表。

为简单起见,我们将使用 modules/web/web/VAADIN 文件夹存储 HTML、CSS 和 JavaScript 文件,因为默认情况下,部署的 Web 应用程序的相应的文件夹用来提供静态资源。因此无需对 Tomcat 应用程序服务器进行任何配置。生成的 URL 将以 http://localhost:8080/app/VAADIN 开头,因此不要在真实环境的应用程序中使用此方法 - 而应该为静态资源创建一个具有特定上下文的单独 Web 应用程序。

下载 jQueryBootstrap 并复制到项目的 modules/web/web/VAADIN 文件夹。创建 customers.htmlcustomers.js 文件,因此文件夹的内容应如下所示:

bootstrap.min.css
customers.html
customers.js
jquery-3.1.1.min.js

customers.html 文件内容:

<html>
    <head>
        <script type="text/javascript" src="jquery-3.1.1.min.js"></script>
        <link rel="stylesheet" href="bootstrap.min.css"/>
    </head>
    <body>
        <div style="width: 300px; margin: auto;">
            <h1>Sales</h1>

            <div id="loggedInStatus" style="display: none" class="alert alert-success">
                Logged in successfully
            </div>
            <div id="loginForm">
                <div class="form-group">
                    <label for="loginField">Login:</label>
                    <input type="text" class="form-control" id="loginField">
                </div>
                <div class="form-group">
                    <label for="passwordField">Password:</label>
                    <input type="password" class="form-control" id="passwordField">
                </div>
                <button type="submit" class="btn btn-default" onclick="login()">Submit</button>
            </div>

            <div id="customers" style="display: none">
                <h2>Customers</h2>
                <ul id="customersList"></ul>
            </div>
        </div>
        <script type="text/javascript" src="customers.js"></script>
    </body>
</html>

customers.js 文件内容:

var oauthToken = null;
function login() {
    var userLogin = $('#loginField').val();
    var userPassword = $('#passwordField').val();
    $.post({
        url: 'http://localhost:8080/app/rest/v2/oauth/token',
        headers: {
            'Authorization': 'Basic Y2xpZW50OnNlY3JldA==',
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        dataType: 'json',
        data: {grant_type: 'password', username: userLogin, password: userPassword},
        success: function (data) {
            oauthToken = data.access_token;
            $('#loggedInStatus').show();
            $('#loginForm').hide();
            loadCustomers();
        }
    })
}

function loadCustomers() {
    $.get({
        url: 'http://localhost:8080/app/rest/v2/entities/sales$Customer?view=_local',
        headers: {
            'Authorization': 'Bearer ' + oauthToken,
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        success: function (data) {
            $('#customers').show();
            $.each(data, function (i, customer) {
                $('#customersList').append("<li>" + customer.name + " (" + customer.email + ")</li>");
            });
        }
    });
}

来自用户输入的登录名和密码通过 POST 请求发送到服务器,并在 Authorization 请求头中使用 Base64 编码的客户端凭证,如获取 OAuth 令牌部分中所述。如果验证成功,则网页从服务器接收访问令牌值,令牌存储在 oauthToken 变量中,之后,隐藏 loginForm div 并显示 loggedInStatus div。

需要显示客户列表,请求被发送到服务器去获取 sales$Customer 实体的实例,在 Authorization 请求头中传递 oauthToken 值。

如果成功处理请求,则显示 customers div,并且 customersList 元素填充包含客户名称和电子邮件的内容。

rest js 1
rest js 2
3.9.11.14. 获取本地化消息

REST API 中有一些方法可以获取实体,及其属性和枚举的本地化消息。

例如,要获取 sec$User 实体的本地化消息列表,必须执行以下 GET 请求:

http://localhost:8080/app/rest/v2/messages/entities/sec$User

必须将 OAuth 令牌放在带有 Bearer 类型的 Authorization 请求头中。

可以使用 Accept-Language http 请求头显式指定所需的区域设置。

响应如下:

{
  "sec$User": "User",
  "sec$User.active": "Active",
  "sec$User.changePasswordAtNextLogon": "Change Password at Next Logon",
  "sec$User.createTs": "Created At",
  "sec$User.createdBy": "Created By",
  "sec$User.deleteTs": "Deleted At",
  "sec$User.deletedBy": "Deleted By",
  "sec$User.email": "Email",
  "sec$User.firstName": "First Name",
  "sec$User.group": "Group",
  "sec$User.id": "ID",
  "sec$User.ipMask": "Permitted IP Mask",
  "sec$User.language": "Language",
  "sec$User.lastName": "Last Name",
  "sec$User.login": "Login",
  "sec$User.loginLowerCase": "Login",
  "sec$User.middleName": "Middle Name",
  "sec$User.name": "Name",
  "sec$User.password": "Password",
  "sec$User.position": "Position",
  "sec$User.substitutions": "Substitutions",
  "sec$User.timeZone": "Time Zone",
  "sec$User.timeZoneAuto": "Autodetect Time Zone",
  "sec$User.updateTs": "Updated At",
  "sec$User.updatedBy": "Updated By",
  "sec$User.userRoles": "User Roles",
  "sec$User.version": "Version"
}

要获取枚举的本地化值,请使用以下 URL:

http://localhost:8080/app/rest/v2/messages/enums/com.haulmont.cuba.security.entity.RoleType

如果省略 URL 中的实体名称或枚举名称部分,将获得所有实体或枚举的本地化值。

3.9.11.15. 数据模型版本化示例
实体属性被重命名

我们假设 sales$Order 实体的 oldNumber 属性被重命名为 newNumber 并且 date 被重命名为 deliveryDate。在这种情况下,转换配置将如下所示:

<?xml version="1.0"?>
<transformations xmlns="http://schemas.haulmont.com/cuba/rest-json-transformations.xsd">
    <transformation modelVersion="1.0" currentEntityName="sales$Order">
        <renameAttribute oldName="oldNumber" currentName="newNumber"/>
        <renameAttribute oldName="date" currentName="deliveryDate"/>
    </transformation>
    ...
</transformations>

如果客户端应用程序需要使用旧版的 sales$Order 实体,那么它必须在 URL 参数中传递 modelVersion 值:

http://localhost:8080/app/rest/v2/entities/sales$Order/c838be0a-96d0-4ef4-a7c0-dff348347f93?modelVersion=1.0

将返回以下结果:

{
  "_entityName": "sales$Order",
  "_instanceName": "00001",
  "id": "46322d73-2374-1d65-a5f2-160461da22bf",
  "date": "2016-10-31",
  "description": "Vacation order",
  "oldNumber": "00001"
}

响应 JSON 包含 oldNumberdate 属性,尽管 CUBA 应用程序中的实体具有 newNumberdeliveryDate 属性。

实体名称被更改

接下来,试想一下,在应用程序的某个版本中,sales$Order 实体的名称也发生了变化。新名称是 sales$NewOrder

版本 1.1 的转换配置将如下所示:

<?xml version="1.0"?>
<transformations xmlns="http://schemas.haulmont.com/cuba/rest-json-transformations.xsd">
    <transformation modelVersion="1.1" oldEntityName="sales$Order" currentEntityName="sales$NewOrder">
        <renameAttribute oldName="oldNumber" currentName="newNumber"/>
    </transformation>
    ...
</transformations>

除了上一个示例中的配置之外,还在此处添加了 oldEntityName 属性。它指定对模型版本 1.1 有效的实体名称。currentEntityName 属性指定当前实体名称。

虽然名称为 sales$Order 的实体不再存在,但以下请求也可以正常运行:

http://localhost:8080/app/rest/v2/entities/sales$Order/c838be0a-96d0-4ef4-a7c0-dff348347f93?modelVersion=1.1

REST API 控制器将认为它必须在 sales$NewOrder 实体里进行搜索,并且在找到具有给定 id 的实体之后,在结果 JSON 中替换实体的名称和 newNumber 属性的名称:

{
  "_entityName": "sales$Order",
  "_instanceName": "00001",
  "id": "46322d73-2374-1d65-a5f2-160461da22bf",
  "date": "2016-10-31",
  "description": "Vacation order",
  "oldNumber": "00001"
}

客户端应用程序也可以使用旧版本的数据模型进行实体更新和创建。

POST 请求使用旧实体名称,并且在请求体使用旧的 JSON,这个 POST 请求可以正常工作:

http://localhost:8080/app/rest/v2/entities/sales$Order

{
  "_entityName": "sales$Order",
  "_instanceName": "00001",
  "id": "46322d73-2374-1d65-a5f2-160461da22bf",
  "date": "2016-10-31",
  "description": "Vacation order",
  "oldNumber": "00001"
}
必须从 JSON 中删除的实体属性

如果某个属性已添加到实体,但使用旧版数据模型的客户端不希望有此新属性,则可以从结果 JSON 中删除新属性。

此示例的转换配置如下所示:

<?xml version="1.0"?>
<transformations xmlns="http://schemas.haulmont.com/cuba/rest-json-transformations.xsd">
    <transformation modelVersion="1.5" currentEntityName="sales$Order">
        <toVersion>
            <removeAttribute name="discount"/>
        </toVersion>
    </transformation>
    ...
</transformations>

此配置文件中的转换包含带有嵌套 removeAttribute 命令的 toVersion 标记。这意味着当执行从当前状态到特定版本的转换时(即当请求实体列表时),必须从结果 JSON 中删除 discount 属性。

在这种情况下,如果在没有 modelVersion 属性的情况下执行请求,discount 属性将会被返回:

http://localhost:8080/app/rest/v2/entities/sales$Order/c838be0a-96d0-4ef4-a7c0-dff348347f93

{
    "_entityName": "sales$Order",
    "_instanceName": "00001",
    "id": "46322d73-2374-1d65-a5f2-160461da22bf",
    "deliveryDate": "2016-10-31",
    "description": "Vacation order",
    "number": "00001",
    "discount": 50
}

如果指定 modelVersion,则将删除 discount 属性

http://localhost:8080/app/rest/v2/entities/sales$Order/c838be0a-96d0-4ef4-a7c0-dff348347f93?modelVersion=1.1

{
    "_entityName": "sales$Order",
    "_instanceName": "00001",
    "id": "46322d73-2374-1d65-a5f2-160461da22bf",
    "deliveryDate": "2016-10-31",
    "description": "Vacation order",
    "oldNumber": "00001"
}
使用自定义转换器

还可以创建和注册自定义 JSON 转换器。举个例子,我们来看看下面的情况:有一个实体 sales$OldOrder 被重命名为 sales$NewOrder。该实体具有 orderDate 字段。在之前的版本中,此日期字段包含时间部分,但在最新版本的实体中,时间部分将被删除。请求旧模型版本 1.0 的实体的 REST API 客户端期望日期字段有时间部分,因此转换器必须修改 JSON 中的值。

首先,转换器配置必须如下所示:

<?xml version="1.0"?>
<transformations xmlns="http://schemas.haulmont.com/cuba/rest-json-transformations.xsd">

    <transformation modelVersion="1.0" oldEntityName="sales$OldOrder" currentEntityName="sales$NewOrder">
        <custom>
            <fromVersion transformerBeanRef="sales_OrderJsonTransformerFromVersion"/>
            <toVersion transformerBeanRef="sales_OrderJsonTransformerToVersion"/>
        </custom>
    </transformation>

    ...
</transformations>

有一个 custom 元素和嵌套的 toVersionfromVersion 元素。这些元素引用了转换器 bean。这意味着自定义转换器必须注册为 Spring bean。这里有一件重要的事情:自定义转换器可以使用 RestTransformations 平台 bean(如果需要,这个 bean 可以访问其它实体转换器)。但是 RestTransformations bean 在 REST API servlet 的 Spring 上下文中注册,而不是在 Web 应用程序的主上下文中注册。这意味着自定义转换器 bean 也必须在 REST API Spring 上下文中注册。

我们怎样做到这一点?

首先,在 webportal 模块中创建一个 rest-dispatcher-spring.xml (例如,在 com.company.test 包中)。

接下来,在 Web 或 portal 模块的 app.properties 中注册此文件:

cuba.restSpringContextConfig = +com/company/test/rest-dispatcher-spring.xml

rest-dispatcher-spring.xml 必须包含自定义转换器 bean 定义:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd">

    <bean name="sales_OrderJsonTransformerFromVersion" class="com.company.test.transformer.OrderJsonTransformerFromVersion"/>
    <bean name="sales_OrderJsonTransformerToVersion" class="com.company.test.transformer.OrderJsonTransformerToVersion"/>

</beans>

sales_OrderJsonTransformerToVersion 转换器的内容如下:

package com.company.test.transformer;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.base.Strings;
import com.haulmont.restapi.transform.AbstractEntityJsonTransformer;
import com.haulmont.restapi.transform.JsonTransformationDirection;

public class OrderJsonTransformerToVersion extends AbstractEntityJsonTransformer {

    public OrderJsonTransformerToVersion() {
        super("sales$NewOrder", "sales$OldOrder", "1.0", JsonTransformationDirection.TO_VERSION);
    }

    @Override
    protected void doCustomTransformations(ObjectNode rootObjectNode, ObjectMapper objectMapper) {
        JsonNode orderDateNode = rootObjectNode.get("orderDate");
        if (orderDateNode != null) {
            String orderDateNodeValue = orderDateNode.asText();
            if (!Strings.isNullOrEmpty(orderDateNodeValue))
                rootObjectNode.put("orderDate", orderDateNodeValue + " 00:00:00.000");
        }
    }
}

此转换器在 JSON 对象中找到 orderDate 节点,并通过将时间部分添加到该值来修改该值。

当请求数据模型版本 1.0sales$OldOrder 实体时,结果 JSON 将包括含有时间部分的 orderDate 字段的实体,尽管它不再存储在数据库中。

关于定制转换器的更多内容:它们必须实现 EntityJsonTransformer 接口。还可以继承 AbstractEntityJsonTransformer 类并覆盖其 doCustomTransformations 方法。AbstractEntityJsonTransformer 类包含标准转换器的所有功能。

3.9.11.16. 使用实体搜索过滤器

REST API 可以在获取实体列表时可以指定即时搜索条件。

假设我们有两个实体:

  • Author 有两个字段: lastNamefirstName

  • Book 有三个字段: title(String)、 author(Author) 和 publicationYear(Integer)

要使用条件执行搜索,必须使用以下 URL:

http://localhost:8080/app/rest/v2/entities/test$Book/search

搜索条件需要在 filter 参数中传递。这是一个包含一组条件的 JSON 对象。如果使用 GET 请求执行搜索,则需要在 URL 中传递 filter 参数。

例 1

我们需要找到 2007 年出版的所有书籍,并且有一个名字以"Alex"开头的作者。过滤器 JSON 应如下所示:

{
    "conditions": [
        {
            "property": "author.firstName",
            "operator": "startsWith",
            "value": "Alex"
        },
        {
            "property": "publicationDate",
            "operator": "=",
            "value": 2007
        }
    ]
}

默认情况下,搜索条件之间使用 AND 连接。

此示例还演示了对嵌套属性(author.firstName)的支持。

例 2

下一个示例演示了两件事:如何使用 POST 请求执行搜索以及如何使用 OR 分组条件。如果是 POST 请求,则必须在请求体的 JSON 对象中传递所有参数。搜索过滤器必须放在名为 filter 的对象字段中。所有其它参数(视图名称、限制条件等)必须放在相应名称的字段中:

{
  "filter": {
    "conditions": [
      {
        "group": "OR",
        "conditions": [
          {
            "property": "author.lastName",
            "operator": "contains",
            "value": "Stev"
          },
          {
            "property": "author.lastName",
            "operator": "=",
            "value": "Dumas"
          }
        ]
      },
      {
        "property": "publicationDate",
        "operator": "=",
        "in": [2007, 2008]
      }
    ]
  },
  "view": "book-view"
}

在此示例中,conditions 集合不仅包含条件对象,还包含 OR 分组条件。所以最终搜索条件将是:

((author.lastName contains Stev) OR (author.lastName = Duma) AND (publicationDate in [2007, 2008]))

请注意,view 参数也在请求体中传递。

3.10. 平台功能

本节介绍平台提供的各种可选功能

3.10.1. 动态属性

Dynamic attributes - 动态属性 是额外的实体属性,可以在不需要修改数据库表结构或者重启应用的情况下为实体添加。动态属性通常用来在部署或者生产阶段为实体定义新属性。

CUBA 动态属性实现了 Entity-Attribute-Value 模型。

dynamic attributes
Figure 37. 动态属性类关系图
  • Category - 定义对象的一个 category - 类别 以及相应的一组动态属性。这个类别必须分配给某些实体类型。

    比如,有个实体是 Car 类型的。可以为它定义两个类别:卡车(Truck)和客车(Passenger)。卡车类别需要包含载重量和车身类型属性,客车类别需要包含座位数和儿童座位属性。

  • CategoryAttribute - 定义关联某些类别的动态属性。每个属性需要为一个明确类型描述一个字段。必要的 Code 字段包含属性的系统名称。Name 字段包含具有可读性的名称。

  • CategoryAttributeValue - 特定实体实例动态属性的值。动态属性值物理存储在专门的 SYS_ATTR_VALUE 表内。表的每一行都有指向某些实体的 id(ENTITY_ID 列)。

一个实体实例可以拥有跟这个实体类型相关的所有类别的动态属性。所以如果按照上面创建了两个关于 Car 的类别,则可以为一个 Car 实例定义两种类别中的任何动态属性。如果需要将实体实例分类到具体的某一类别(比如 Car 可以是卡车或者客车),那么实体需要实现 Categorized 接口。这样实体实例就会有指向类别的引用,也只能包含此类别的动态属性。

加载和保存动态属性是通过 DataManager 来处理的。LoadContext.setLoadDynamicAttributes() 方法可以用来设置是否需要为实体实例加载动态属性。默认情况下,不会加载动态属性。同时,DataManager 总是会保存传递给 commit() 方法的实体实例的动态属性。

对于任何继承自 BaseGenericIdEntity 的持久化实体,动态属性的值可以通过 getValue() / setValue() 方法来读写。此时,需要给方法传递一个带 + 前缀的属性 code。示例:

LoadContext lc = new LoadContext(Car.class).setId(id);
lc.setLoadDynamicAttributes(true);
Entity entity = dataManager.load(lc);

Double capacity = entity.getValue("+loadCapacity");
entity.setValue("+loadCapacity", capacity + 10);

dataManager.commit(entity);

事实上,在应用中直接访问动态属性是很少用到的。任何动态属性都可以在绑定了包含动态属性的实体数据源的任何表格或者字段组中自动展示。下面说的属性编辑器可以用来指定需要显示动态属性的界面和组件。

访问动态属性的用户权限可以在安全角色编辑器中跟普通属性一样配置。动态属性显示为带 + 前缀的属性。

3.10.1.1. 管理动态属性

管理属性类别和描述是通过 Administration>Dynamic Attributes 菜单中提供的特定界面完成的。

类别浏览界面显示所有已注册类别的列表。

categoryBrowser
Figure 38. 类别浏览界面

类别编辑界面可以为实体创建新类别并定义一组动态属性。类别名称和相关实体类型字段是必填字段。Default 复选框表示将为实现了 Categorized 接口的新实体实例自动选择此类别。

categoryEditor
Figure 39. 类别编辑界面

如果应用程序支持多种语言,则会显示 Name localization 分组框。它允许为每个可用的语言环境设置类别的本地化名称。

categoryLocalization
Figure 40. 本地化类别名称

动态属性编辑界面可以设置属性的名称、系统代码、值类型和默认值。

runtimePropertyEditor
Figure 41. 动态属性编辑界面

对于除 Boolean 以外的所有值类型,都有一个 Width 字段可用于设置 FieldGroup 中的字段宽度(以像素为单位或百分比)。如果 Width 字段为空,则假定其值为 100%。

对于除 BooleanEnumeration 之外的所有值类型,还有一个 Is collection 复选框。允许为所选类型创建多值动态属性。

所有类型的动态属性都支持本地化:

runtimePropertyLocalization
Figure 42. 动态属性本地化

对于 Enumeration 值类型,通过列表编辑器在 Enumeration 字段中定义命名值集合。

runtimePropertyEnum
Figure 43. Enumeration 类型的动态属性编辑界面

每个枚举值也可以进行本地化显示设置。

runtimePropertyEnumLocalization
Figure 44. Enumeration 类型动态属性本地化设置

动态属性还可以设置可见性,定义在哪些界面中显示。默认情况下,动态属性在任何界面上都是不可见的。

runtimePropertyVisibility
Figure 45. 动态属性可见性设置

除了界面之外,还可以为属性指定显示组件(比如,可以在界面中,指定多个FieldGroup组件显示同一实体的字段)。

如果该属性在界面上标记为可见,则在界面上用来展示相应实体的所有字段组(FieldGroup)和表格(Table)中会自动显示该属性。

对动态属性的访问也受用户角色设置的限制。动态属性的安全设置与常规属性的安全设置类似。

要使属性和可见性设置的更改生效,请单击类别浏览界面中的 Apply settings。也可以通过在 Administration>JMX Console 中调用 app-core.cuba:type = CachingFacade 的 JMX bean 的 clearDynamicAttributesCache() 方法使更改生效。

通过可见性配置能将动态属性自动添加到界面,如下所示:

runtimePropsApplyChanges

也可以手动将动态属性添加到界面。操作步骤如下:

  • 在界面 XML 描述的 dsContext 部分,为加载实体的数据源将 loadDynamicAttributes 属性设置为 true,例如:

    <dsContext>
        <datasource id="carDs" class="com.company.sample.entity.Car" view="_local" loadDynamicAttributes="true"/>
    </dsContext>
  • 在组件定义的 property XML 属性中使用 + 前缀指定动态属性 code:

    <textField id="numberOfSeats" datasource="carDs" property="+numberOfSeats"/>
3.10.1.2. 实体分类

如果实体实现了 com.haulmont.cuba.core.entity.Categorized 接口,则可以使用 com.haulmont.cuba.gui.components.RuntimePropertiesFrame 组件来显示该实体的动态属性。此组件允许用户为特定实体实例选择类别,并指定此类别的动态属性的值。

要在编辑界面中使用 RuntimePropertiesFrame 组件,请执行以下操作:

  • dsContext 部分声明两个datasources

    • runtimePropsDatasource - 加载 CategoryAttributeValue 实例的特定数据源。mainDs 属性必须引用主数据源,其中包含被编辑的实体。

    • 一个常规的 collectionDatasource 来加载这个实体类型的类别列表。

    示例:

    <dsContext>
      <datasource id="carDs"
          class="com.company.sample.entity.Car"
          view="carEdit"/>
    
      <runtimePropsDatasource id="runtimePropsDs"
          mainDs="carDs"/>
    
      <collectionDatasource id="categories"
          class="com.haulmont.cuba.core.entity.Category"
          view="_local">
        <query>
             select c from sys$Category c where c.entityType='sample$Car'
        </query>
      </collectionDatasource>
    </dsContext>
  • 现在,数据源配置好了,可以将 runtimeProperties 可视化组件添加在界面的 XML 描述中:

    <runtimeProperties id="runtimePropsFrame"
      runtimeDs="runtimePropsDs"
      categoriesDs="categories"/>

3.10.2. 发送邮件

平台的电子邮件发送提供以下基础功能:

  • 同步或异步发送。在同步发送的情况下,调用代码将一直等待,直到消息被发送到 SMTP 服务器。在异步发送的情况下,消息被持久化到数据库,并且将控制权立即交回给调用代码。实际发送动作稍后由计划任务完成。

  • 对于同步和异步模式,都能可靠地跟踪消息发送的时间戳或数据库中的错误。

  • 提供用户界面,用于搜索和查看有关已发送消息的信息,包括所有消息属性和内容、发送状态和尝试次数。

3.10.2.1. 发送方法

要发送电子邮件,在中间件上应使用 EmailerAPI bean,在客户端层使用 EmailService 服务。

这些组件的基本方法如下所述:

  • sendEmail() – 同步发送消息。调用代码在消息发送到 SMTP 服务器前一直等待。

    消息内容由一组参数(逗号分隔的收件人列表,主题,内容,附件数组)组成,并以 EmailInfo 对象的形式传输,该对象封装了所有这些信息并允许显式设置发件人的地址并使用 FreeMarker 模板构造邮件正文。

    同步发送时可能会抛出 EmailException,其中包含投递失败的收件人地址的信息,以及相应的错误消息。

    执行该方法期间,在数据库中为每个收件人创建一个 SendingMessage 实例。发送状态初始值为 SendingStatus.SENDING,成功发送后状态变为 SendingStatus.SENT。如果消息发送错误,消息状态将更改为 SendingStatus.NOTSENT

  • sendEmailAsync() - 异步发送消息。此方法返回在数据库中创建的 SendingStatus.QUEUE 状态的 SendingMessage 实例的列表(列表数量按收件人数)。实际发送是通过随后调用 EmailerAPI.processQueuedEmails() 方法执行的,需要为 计划任务设置符合期望的发送邮件频率来调用此方法。

3.10.2.2. 电子邮件附件

EmailAttachment 对象是一个包装器,包含附件的信息:字节数组( data 字段)、文件名( name 字段),如有必要,还可在包含邮件中可以使用的附件唯一标识(contentId 字段,虽然是可选字段,但很有用)。

附件标识可用于在邮件消息体中插入图片。为此,在创建 EmailAttachment 时指定了唯一的 contentId (例如,myPic)。使用 cid:myPic 格式的表达式可以在消息体中插入附件路径。因此,要插入图片,可以指定以下 HTML 元素:

<img src="cid:myPic"/>
3.10.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 - 错误的堆栈跟踪。

    模板文件的示例:

    <html>
    <body>
    <p>${timestamp}</p>
    <p>${toHtml(errorMessage)}</p>
    <p>${toHtml(stacktrace)}</p>
    </body>
    </html>
  • 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.10.2.4. 发送电子邮件

本节包含使用 CUBA 邮件发送机制发送电子邮件的实用指南。

我们看看以下任务:

  • NewsItem 实体和 NewsItemEdit 界面。

  • NewsItem 实体包含以下属性: datecaptioncontent

  • 我们希望每次通过 NewsItemEdit 界面创建新的 NewsItem 实例时向某些地址发送电子邮件。电子邮件应包含 NewsItem.caption 作为主题,并且应该从包含 NewsItem.content 的模板创建邮件消息正文。

  1. 将以下代码添加到 NewsItemEdit.java

    public class NewsItemEdit extends AbstractEditor<NewsItem> {
    
        // Indicates that a new item was created in this editor
        private boolean justCreated;
    
        @Inject
        protected EmailService emailService;
    
        // This method is invoked when a new item is initialized
        @Override
        protected void initNewItem(NewsItem item) {
            justCreated = true;
        }
    
        // This method is invoked after the screen commit
        @Override
        protected boolean postCommit(boolean committed, boolean close) {
            if (committed && justCreated) {
                // If a new entity was saved to the database, ask a user about sending an email
                showOptionDialog(
                        "Email",
                        "Send the news item by email?",
                        MessageType.CONFIRMATION,
                        new Action[] {
                                new DialogAction(DialogAction.Type.YES) {
                                    @Override
                                    public void actionPerform(Component component) {
                                        sendByEmail();
                                    }
                                },
                                new DialogAction(DialogAction.Type.NO)
                        }
                );
            }
            return super.postCommit(committed, close);
        }
    
        // Queues an email for sending asynchronously
        private void sendByEmail() {
            NewsItem newsItem = getItem();
            EmailInfo emailInfo = new EmailInfo(
                    "john.doe@company.com,jane.roe@company.com", // recipients
                    newsItem.getCaption(), // subject
                    null, // the "from" address will be taken from the "cuba.email.fromAddress" app property
                    "com/company/demo/templates/news_item.txt", // body template
                    Collections.singletonMap("newsItem", newsItem) // template parameters
            );
            emailService.sendEmailAsync(emailInfo);
        }
    }

    在上面的代码中,在 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.10.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"/>

3.10.4. 实体日志

此机制在实体监听器级别跟踪实体的持久化,即确保能跟踪所有经过EntityManager的持久化上下文的数据库变更。但是不跟踪使用 SQL 对数据库实体的直接更改,包括使用NativeQueryQueryRunner执行的更改。

修改后的实体实例在保存到数据库之前传递给 EntityLogAPI bean 的 registerCreate()registerModify()registerDelete() 方法。每个方法都有 auto 参数,通过这个参数控制实体监听器添加的自动日志与通过从应用程序代码调用这些方法添加的手动日志分离。当从实体监听器调用这些方法时,auto 参数的值为 true

日志包含有关修改时间、修改实体的用户以及修改后属性新值的信息。日志实体存储在与 EntityLogItem 实体对应的 SEC_ENTITY_LOG 表中。更改的属性值存储在 CHANGES 列中,在中间件加载时,将属性转换为 EntityLogAttr 实体的实例。

3.10.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.10.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 处理器中,为依赖的加载器设置了参数并触发加载。

要显示本地化的值,启用日志记录的属性应包含@LocalizedValue注解。有此注解时,日志记录机制将填写 EntityLogAttr.messagesPack 字段,从而上面示例中的表格可以使用 locValue 列代替 value 列来显示本地化值:

<table id="attrTable" width="100%" height="200px" dataContainer="logAttrDc">
  <columns>
      <column id="name"/>
      <column id="locValue"/>
  </columns>
</table>

3.10.5. 实体快照

实体保存机制与实体日志非常相似,目的在于跟踪运行时的数据变化,具有以下独特的特征:

  • 能保存通过特定视图定义的整个实体关系图的状态(或快照)。

  • 应用程序代码能显式调用快照保存机制。

  • 平台允许查看和比较实体快照。

3.10.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.10.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.10.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 以及不带过滤器的表格,表格没有通过 CollectionDatasource.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.10.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.10.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.10.8.1. 上传文件

通过使用FileUploadFileMultiUpload组件可以将用户计算机上的文件上传到存储中。本手册中的相应组件说明以及加载和显示图片章节都提供了用法示例。

FileUpload 组件也可在即用型 FileUploadDialog 窗口中使用,这个窗口设计用来加载文件至临时存储。

临时客户端级存储( FileUploadingAPI )将临时文件存储在由cuba.tempDir应用程序属性定义的文件夹中。如果出现任何故障,临时文件还是保留在文件夹中。cuba_FileUploading bean 的 clearTempDirectory() 方法由cuba-web-spring.xml文件中定义的定时任务周期性调用来清理临时存储。

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 对象并上传文件到文件存储,也可以实现其他的逻辑。

3.10.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.10.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.10.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.10.8.5. Amazon S3 文件存储实现

标准文件存储实现可以使用云存储服务代替。建议为云部署使用单独的云文件存储服务,因为部署应用的云服务器通常不能保证外部文件在其硬盘上的永久存储。

平台提供了开箱即用的 Amazon S3 文件存储服务支持。如果要支持其它服务就需要实现自定义逻辑。

要添加 Amazon S3 支持,首先,需要在 core 模块的 spring.xml 文件中注册 AmazonS3FileStorage 类:

<bean name="cuba_FileStorage"
          class="com.haulmont.cuba.core.app.filestorage.amazon.AmazonS3FileStorage"/>

接下来,应该在 core 模块的 app.properties 文件中定义 Amazon 设置:

cuba.amazonS3.accessKey = <Access Key>
cuba.amazonS3.secretAccessKey = <Secret Access Key>
cuba.amazonS3.region = <Region>
cuba.amazonS3.bucket = <Bucket Name>

accessKeysecretAccessKey 应该是 AWS IAM 用户的帐户,而不是 AWS 帐户。可以在 AWS 控制台的 Users 标签页上找到正确的凭据。

存储文件夹结构的组织方式与标准实现类似。

3.10.9. 文件夹面板

文件夹面板提供对常用信息的快速访问。它是主应用程序窗口左侧的一个包含多级文件夹的面板。点击文件夹会使用特定参数打开相应的系统界面。

在撰写本文时,该面板仅适用于 Web Client

平台支持三种类型的文件夹:应用程序文件夹搜索文件夹记录集。应用程序文件夹作为单独的文件夹树显示在面板顶部;搜索文件夹和记录集显示在面板底部的组合树中。如果设置cuba.web.foldersPaneEnabled属性为 true,文件夹将支持键盘快捷键

  • 应用程序文件夹:

    • 打开应用程序界面,界面上可以没有过滤器

    • 文件夹集合取决于当前用户会话信息。文件夹的可见性通过 Groovy 脚本定义。

    • 只有具有特定权限的用户才能创建和更改应用程序文件夹。

    • 文件夹标题上可以显示通过 Groovy 脚本获取的记录数。

    • 文件夹标题通过计时器事件更新,这意味着可以动态更新每个文件夹的记录数和显示样式。

  • 搜索文件夹:

    • 打开带有过滤器的界面。

    • 搜索文件夹可以是私有的,也可以是全局的,私有文件夹只能由创建它的用户访问,全局文件夹所有用户都可以访问。

    • 任何用户都可以创建私有文件夹,而只有具有特定权限的用户才能创建全局文件夹。

  • 记录集:

    • 打开带有过滤器的界面,这个过滤器包含了根据标识符选择特定记录的条件。

    • 可以使用专门的表格操作编辑记录集内容:将记录添加到记录集中或从记录集中删除记录。

    • 记录集仅能供创建它们的用户使用。

通过下列应用程序属性可以定制文件夹面板的功能:

3.10.9.1. 应用程序文件夹

创建或编辑应用程序文件夹需要特殊的权限(cuba.gui.appFolder.global)。

可以通过文件夹面板右键菜单创建一个简单的应用程序文件夹。这类文件夹不会链接到系统界面,只用于对文件夹树中的其它文件夹进行分组。

打开带有过滤器界面的文件夹可以按以下方式创建:

  • 打开界面并根据需要过滤记录。

  • 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.10.9.2. 搜索文件夹

用户可以创建与应用程序文件夹类似的搜索文件夹。分组文件夹可以直接通过文件夹面板的右键菜单创建。链接到界面的文件夹可以使用 Filter…​ 按钮菜单上的“Save as search folder”选项创建。

要创建全局搜索文件夹,要求用户具有 Create/edit global search folders 权限(cuba.gui.searchFolder.global)。

可以在创建文件夹后再次编辑搜索文件夹的过滤器:打开文件夹并更改 Folder:{folder name} 过滤器,保存过滤器的同时会更改文件夹过滤器。

搜索文件夹是 SearchFolder 实体的实例,存储在相关的 SYS_FOLDERSEC_SEARCH_FOLDER 表。

3.10.9.3. 记录集

如果过滤器applyTo 属性中定义了相应的表格组件,则可以在界面中使用记录集。例如:

<layout>
  <filter id="customerFilter"
          datasource="customersDs"
          applyTo="customersTable"/>

  <groupTable id="customersTable"
              width="100%">
      <buttonsPanel>
          <button action="customersTable.create"/>
...
      </buttonsPanel>
...

Add to setAdd to current set / Remove from set 会出现在表格右键菜单中。如果一个表格包含 buttonsPanel(如上例所示),则还会添加相应的表格按钮。

记录集是 SearchFolder 实体的实例,存储在相关的 SYS_FOLDERSEC_SEARCH_FOLDER 表。

3.10.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.10.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.10.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.10.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.10.14. 计划任务的执行

平台提供了两种运行计划任务的方法:

  • Spring Framework 中标准的 TaskScheduler 机制。

  • 使用平台自身的计划任务执行机制。

3.10.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() 方法(有关此表达式格式的描述,请参阅 http://quartz-scheduler.org/documentation/quartz-1.x/tutorials/crontrigger )。

任务的实际启动由 scheduled-tasks 元素的 scheduler 属性中指定的 bean 执行。它是 CubaThreadPoolTaskScheduler 类型的 bean,在 cuba 应用程序组件的 coreweb 模块中配置(参阅 cuba-spring.xmlcuba-web-spring.xml )。该类提供处理一些 CUBA 框架内特定任务的功能。

如果要向中间层的 Spring 定时任务执行的代码提供SecurityContext,请使用系统身份验证

3.10.14.2. CUBA 计划任务

CUBA 计划任务机制用于有计划地执行中间件中任意 Spring bean 方法。此机制的目标与上述标准 Spring Framework 调度程序的区别在于:

  • 能够在应用程序运行过程中时配置任务而无需重启服务。

  • 在中间件集群中协调单例任务的执行,提供以下功能:

    • 可有效地防止任务同时执行。

    • 按优先级将任务绑定到服务。

单例(singleton) 任务指在同一时刻只允许在一个服务上执行的任务。例如,从队列中读取并发送电子邮件的任务。

3.10.14.2.1. 任务注册

任务在数据库表 SYS_SCHEDULED_TASK 中注册,该表对应于 ScheduledTask 实体。平台提供了用于管理任务的浏览和编辑界面: AdministrationScheduled Tasks

下面是关于任务属性的描述:

  • Defined by – 描述哪个软件对象实现该任务。可能的值是:

    • Bean – 该任务由 Spring托管 bean的方法实现。额外属性:

      • Bean name – 托管 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,任务将以秒为单位周期性重复执行或延迟固定时间后执行。

  • Timeout – 以秒为单位的时间,到期时无论是否存在关于任务完成的信息,都认为任务已执行完成。如果未明确设置超时时间,则假定为 3 小时。

  • Start date – 首次执行的日期或时间。如果未指定,则在服务启动时立即执行该任务。如果指定,则在 startDate + period * N 启动执行任务,其中 N 是整数。对于周期任务来说,N 可以是大于 1 的数,对延迟任务来说 N 是 1。

    只为偶发任务指定 Start date 是合理的,即每小时运行一次、每天运行一次等。

  • 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.10.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.10.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.10.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.10.17. 用户会话日志

这个机制用于使系统管理员可以获得用户登录和注销的历史数据。日志记录机制基于跟踪用户会话。每次创建 UserSession 对象时,日志记录都会将以下字段信息保存到数据库中:

  • 用户会话 ID。

  • 用户 ID。

  • 代替用户 ID。

  • 用户的最后一次动作(登录/注销/超时/终止)。

  • 请求登录的远程 IP 地址。

  • 用户会话的客户端类型(网页、门户)。

  • 服务器 ID(例如,localhost:8080/app-core)。

  • 事件开始日期。

  • 事件结束日期。

  • 客户端信息(会话环境:操作系统、Web 浏览器等)。

默认情况下,不启用用户会话日志记录机制。启用日志记录机制的最简单方法是使用 Administration > User Session Log 应用界面上的 Enable Logging 按钮。或者使用 cuba.UserSessionLogEnabled 应用属性。

如果需要的话可以创建 sec$SessionLogEntry 实体的报表。

3.11. 功能扩展

平台可以在应用程序中扩展和覆盖其以下方面的功能:

  • 扩展实体属性集。

  • 扩展界面功能。

  • 扩展和覆盖 Spring bean 中包含的业务逻辑。

下面是前两个操作的示例,通过将 "Address" 字段添加到平台安全子系统User 实体来说明。

3.11.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.11.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.11.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.11.4. Servlet 和过滤器的注册

要使用应用程序组件中定义的 servlet 和安全过滤器,需要在应用程序中注册它们。servlet 通常在web.xml配置文件中注册,但是,这种配置不会传递到使用此应用程序组件的应用程序中。

ServletRegistrationManager bean 能够使用正确的类加载器动态注册 servlet 和过滤器,并允许使用类似于AppContext的静态类。它还保证对所有部署选项都能正确工作。

ServletRegistrationManager 有两个方法:

  1. createServlet() - 创建给定 servlet 类的 servlet。它使用从应用程序上下文对象获取的正确的 ClassLoader 加载 servlet 类。这意味着新的 servlet 将能够使用平台的一些静态类,例如,AppContextMessages bean。

  2. createFilter() - 以相同的方式创建过滤器.

要使用这个 bean,建议在应用程序组件中创建一个托管的初始化 bean。这个 bean 应该使用 @Component 注解,并包含监听应用程序上下文初始化和销毁事件(ServletContextInitializedEventServletContextDestroyedEvent)的监听器。

初始化 bean 的一个示例:

@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

相关更复杂的示例,请参阅在应用程序组件中注册 DispatcherServlet部分。

单个 WAR 包部署模式下的 Servlet 注册

要在单 WAR 部署中正确加载自定义过滤器和 servlet,请按照以下步骤操作:

  1. 创建一个继承 javax.servlet.ServletContextListener 的类。这个类将负责 servlet 和过滤器的创建:

    public class CustomWebListener implements ServletContextListener {
        @Override
        public void contextInitialized(ServletContextEvent servletContextEvent) {
            ServletContext servletContext = servletContextEvent.getServletContext();
            registerServlet(servletContext);
        }
    
        @Override
        public void contextDestroyed(ServletContextEvent sce) {
        }
    
        protected void registerServlet(ServletContext servletContext) {
            Servlet testServlet = new TestServlet();
            ServletRegistration.Dynamic servletReg = servletContext.addServlet("test_servlet", cubaServlet);
            servletReg.setLoadOnStartup(0);
            servletReg.setAsyncSupported(true);
            servletReg.addMapping("/testServlet");
        }
    }
  2. single-war-web.xml 文件中添加一个引用所创建类的新参数 context-param

    <context-param>
        <param-name>webServletContextListener</param-name>
        <param-value>com.company.CustomWebListener</param-value>
    </context-param>

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 46. 项目文件结构

项目根目录包含构建脚本 build.gradlesettings.gradle

modules 目录包含项目的模块子目录 - globalcoreweb

global 模块包含代码目录 src,里面有三个配置文件 - metadata.xmlpersistence.xmlviews.xmlcom.sample.sales.service 包里面有 Middleware 服务的接口代码;com.sample.sales.entity 包里面有实体类以及他们的本地消息文件

project structure global
Figure 47. global 模块结构

core 模块包含以下目录:

project structure core
Figure 48. core 模块结构

web 模块包含以下目录:

project structure web
Figure 49. 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

这部分包含了项目中使用的一组应用程序组件。组件通过他们各自的 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']))
    }

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                      // 最高优先级

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. 构建任务

Tasks - 任务 是 Gradle 的可执行单元。任务可以在插件内定义或者在构建脚本中定义。以下是一些 CUBA 特定的任务,任务的参数可以通过 build.gradle 来配置。

4.3.3.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.3.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 界面配置这个任务。

任务参数:

  • coreJettyEnvPath - 必要参数,设置一个项目根目录的相对路径,这个路径需要指向一个包含 JNDI resource 的配置文件,这些配置是给 Jetty Http Server 用的。配置文件里至少需要包含一份关于主数据库的 JDBC 数据源定义。Studio 可以根据配置的数据库连接参数生成这个文件。

    task buildUberJar(type: CubaUberJarBuilding) {
        coreJettyEnvPath = 'modules/core/web/META-INF/jetty-env.xml'
        // ...
    }

    还可以使用不同的 jetty-env.xml 文件和 -jettyEnvPath 命令行参数在运行时为同一个 UberJar 提供不同的数据库设置。

  • 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 配置文件。

  • 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 - 用来作为 Polymer UI 模块的 Gradle 项目。如果没定义,则会使用标准的 polymer-client 模块。

  • polymerBuildDir - Polymer UI 构建生成的目录名称。默认是 es6-unbundled。如果在 polymer.json 里面修改了预设的编译参数的话,需要提供这个参数。

4.3.3.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) {
    appHome = '${app.home}'
    appProperties = ['cuba.automaticDatabaseUpdate': 'true']
    singleWar = false
}

任务参数:

  • appName - web 应用程序的名称,默认值是读取 Modules prefix,比如 app

  • appHome – 指向应用程序主目录。可以使用绝对目录,或者相对于 home 目录的相对目录,亦或是 Java 系统变量的占位符,系统变量需要在服务启动时设置。

  • 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 - 日志配置文件的相对目录。

    示例:

    logbackConfigurationFile = "/modules/global/src/logback.xml"
  • useDefaultLogbackConfiguration - 当设置成 true (也是默认值)的时候,这个任务会拷贝标准的 logback.xml 配置文件。

  • polymerBuildDir - Polymer UI 构建生成的目录名称。默认是 es6-unbundled。如果在 polymer.json 里面修改了预设的编译参数的话,需要提供这个参数。

创建单一的 WAR 包

需要创建包含中间件和 web 客户端 block 的单一 WAR 包,可以用下面这个配置:

task buildWar(type: CubaWarBuilding) {
    appHome = '${app.home}'
    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
            </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
            </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 需要通过编程的方式注册,参考单一 WAR 包部署注册 Servlets

单一 WAR 包只包含 coreweb 客户端 模块。如果需要部署 portal 模块,需要用另外的 WAR 包。

参考 部署 WAR 至 Jetty 章节详细介绍 WAR 部署的各种情况和步骤。

4.3.3.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.3.5. 创建数据库

createDbCubaDbCreation 类型的任务,通过执行相应的数据库脚本来创建应用程序数据库。在 build.gradle 里的 core 模块里面声明这个任务。支持参数:

  • 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.3.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.3.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.3.8. 部署样式主题

deployThemes - CubaDeployThemeTask 类型的任务,构建和部署项目内定义的主题至目前已经 部署且在运行的 web 应用程序中。不需要重启服务的情况下,主题的改动就能生效。

示例:

task deployThemes(type: CubaDeployThemeTask, dependsOn: buildScssThemes) {
}
4.3.3.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.3.10. 重启服务

restart – 此任务会关停本地 Tomcat 服务,然后运行快速部署,再启动本地 Tomcat 服务。

4.3.3.11. 配置 Tomcat

setupTomcatCubaSetupTomcat 类型的任务,为应用程序的快速部署方式安装并且初始化本地 Tomcat。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。Tomcat 的安装目录通过 cuba 任务的 tomcat.dir 来指定。默认值是项目的 build/tomcat 子目录。

4.3.3.12. 启动 Tomcat 服务

startCubaStartTomcat 类型的任务,用来启动在 setupTomcat 任务中安装的本地 Tomcat 服务。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.3.13. 启动本地 HSQL 数据库

startDbCubaHsqlStart 类型的任务,用来启动本地 HSQLDB 服务。 任务参数:

  • dbName – 数据库名称,默认 cubadb

  • dbDataDir – 数据库目录,默认是项目的 deploy/hsqldb 子目录。

  • dbPort – 数据库服务端口号,默认是 9001

示例:

task startDb(type: CubaHsqlStart) {
    dbName = 'sales'
}
4.3.3.14. 停止 Tomcat 服务

stopCubaStopTomcat 类型的任务,用来停止在 setupTomcat 任务中安装的本地 Tomcat 服务。当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.3.15. 停止本地 HSQL 数据库

stopDbCubaHsqlStop 类型的任务,用来停止本地 HSQLDB 服务,任务参数跟 startDb 类似。

4.3.3.16. 打开 Tomcat 窗口

tomcatExec 类型的任务,在打开的终端窗口内运行本地 Tomcat 服务,即便启动失败,终端窗口也会打开。这个任务在调试 Tomcat 问题的时候会很有用,比如在服务启动时由于 Java 版本不匹配引起的启动失败。

4.3.3.17. 更新数据库

updateDbCubaDbUpdate 类型的任务,通过执行相应的数据库脚本文件来更新数据库。跟 createDb 任务类似,只是没有 dropDbSqlcreateDbSql 这两个参数。

4.3.3.18. 项目打包

zipProjectCubaZipProject 类型的任务,创建项目的 ZIP 格式压缩包。这个压缩包不会包含 IDE 项目文件、编译结果和 Tomcat。但是如果 HSQL 数据库在 build 目录的话,会被包含进去。

当使用 cuba Gradle 插件的时候,此任务会自动创建并添加到项目中,所以不需要在 build.gradle 中声明这个任务。

4.3.4. 启动构建任务

构建脚本中定义的 Gradle 任务可以通过如下方式启动:

  • 如果是在 CUBA Studio 中使用项目,很多从 CUBA 主菜单执行的命令都实际上代理给 Gradle 任务:Build Tasks 菜单下的所有命令,以及 Start/Stop/Restart Application ServerCreate/Update Database 命令。

  • 或者,也可以通过执行项目中的 gradlew 脚本(Gradle Wrapper)来执行任务。

  • 还有一个方式就是使用手动安装的 Gradle 版本 4.10.3。这种情况可以直接执行 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.5. 安装配置私仓

这节介绍了怎样安装一个 Maven 私仓,使用这个私仓替换 CUBA 公共仓库,并且用来存储平台工件和其它的依赖。以下这些情况推荐使用私仓:

  • 在不稳定或者很慢的互联网环境。尽管 Gradle 会在开发者的机器上缓存下载下来的工件,但是时不时的还是需要连接到工件仓库,比如当第一次运行构建或者在一个新版本的平台上开始构建。

  • 由于组织级别的安全措施,不能直接访问互联网。

  • 不打算为 CUBA Premium 插件续费,但是将来还需要使用已经下载过的付费插件构建项目。

以下是安装和配置私仓的步骤:

  • 在有互联网的网络安装仓库管理软件。

  • 配置私仓作为 CUBA 公共仓库的代理。

  • 修改项目构建脚本使用私仓。可以通过 Studio 或者直接修改 build.gradle

  • 启动完整项目构建流程,以此来在私仓中缓存所有必须的工件。

4.3.5.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.5.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.5.3. 使用私仓

至此私仓已经可以用了。在界面上方显示 cuba-group 的 URL,比如:

http://localhost:8081/nexus/content/groups/cuba-group
  • 如果创建新项目,点击 New project 窗口中 Repository 旁边的按钮。

  • 对于已有的项目,编辑 Project properties 然后点击 Repository 旁边的按钮。

  • 在弹出框中,点击 Add,输入私仓地址 URL 和用户名密码:admin / admin123

  • 选择新仓库,点击 OK 按钮后就可以项目中使用这个仓库。

  • 构建(build)项目。

在初次构建的过程中,新仓库会下载必要的工件(artifacts)并且存在缓存以供下次使用。可以从 c:\nexus-2.14.3-02\sonatype-work 找到这些文件。

4.3.5.4. 孤立网络中的私仓

如果需要在没有互联网连接的环境开发 CUBA 应用,可以这样:

  • 在网络中安装私仓管理工具。

  • 从有网的环境拷贝私仓缓存的内容到孤立网络。如果是按照之前的步骤安装的软件,拷贝的内容保存在:

    c:\nexus-2.14.3-02\sonatype-work
  • 重启 nexus 服务。

如果需要在孤立网络中添加新平台版本的工件,到有网的环境,通过有网的仓库进行一次基于新版本的构建,然后再拷贝这些下载下来的新版本工件到孤立网络的私仓。

4.3.5.5. 孤立网络使用 CUBA Studio

如果要在孤立网络使用 CUBA Studio,按照下面这些步骤:

  • 下载 Gradle (需要下载相应的 4.10.3 版本) 然后在开发机器安装。

  • 打开 CUBA Studio 服务窗口。

  • 点击 Advanced 按钮并且设置安装的 Gradle 目录。

  • 启动 Studio 服务。

  • 按照上面提到的步骤配置项目。

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
  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. 在命令行中执行 gradlew idea 以在项目的开发环境中包含扩展组件。

  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. App components 面板上,单击 Custom components 旁边的加号按钮,然后在对话框底部的下拉列表中选择扩展组件。扩展组件的坐标将显示在顶部的字段中。

  5. 单击对话框中的 OK 并保存项目属性。

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 窗口,在 Main 标签页上将 Module prefix 设置为 cust

  3. 创建至少有 name 属性的 Customer 实体。在 Instance name 字段,设置 name pattern 属性。

    如果组件包含 @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 窗口,并在 Repositories 部分中将本地 Maven 仓库添加到仓库列表。

  3. App components 面板上单击 Custom components 旁边的加号按钮。在 Custom application component 对话框中,输入客户管理组件的 Maven 坐标,比如,com.company.customers:cust-global:0.1-SNAPSHOT。点击 OK 按钮保存项目属性设置。如果 Studio 建议更改 Gradle 脚本,同意即可。

  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 项目(如果已打开)并再次打开它。这是在 Studio 中下载新组件源代码所必须的。

  7. 点击 CUBA > Build Tasks > Clean,然后再点击 CUBA > Build Tasks > Assemble 菜单项。

  8. 点击主菜单 CUBA > Update Database - 会执行客户管理组件的更新脚本。

  9. 启动应用程序:点击主工具栏 CUBA Application 配置旁边的调试按钮。

  10. 浏览器打开 http://localhost:8080/app 应用程序会有包含新 address 属性的 Customer 实体以及界面。

4.5.4. 注册组件中的 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 名称。addMapping() 方法用于定义通过 URL:/webd/ 访问 servlet 的 HTTP 映射。

4.6. 日志

平台使用 Logback 框架来处理日志。

日志的输出,采用 SLF4J API:取到当前类的一个 logger,然后调用 logger 的方法打印日志,示例:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Foo {
    // create logger
    private Logger log = LoggerFactory.getLogger(Foo.class);

    private void someMethod() {
        // output message with DEBUG level
        log.debug("someMethod invoked");
    }
}

中间件,web 客户端和 web protal blocks 的日志在应用程序服务级别配置,快速部署模式下,这个服务就是 Tomcat。

4.6.1. 在 Tomcat 里面配置日志

本章介绍在开发环境怎么配置日志。

执行 Gradle setupTomcat 任务会将 Tomcat 安装到项目目录并且添加 Tomcat 的其它配置。具体来说,会在 tomcat/bin 目录创建 setenv.batsetenv.sh 环境配置文件,还有在 tomcat/conf 目录创建 logback.xml 文件。

除了所有的其它配置,setenv.* 还使用 CATALINA_OPTS 变量定义了 logback.xml 配置文件需要加载的参数。

logback.xml 定义了日志的配置。文件有如下结构:

  • appender 元素定义了日志的 “输出设备”。主要的 appenders 有 FILE - 文件CONSOLE - 终端ThresholdFilterlevel 参数定义了消息的阈值。文件输出的默认值是 DEBUG,终端输出的默认值是 INFO。也就是说 ERRORWARNINFODEBUG 消息会输出到文件,但是只有 ERRORWARNINFO 级别的消息输出到终端。

    file 参数定义了文件输出目标文件的路径。默认值是 tomcat/logs/app.log

  • logger 元素定义了编码角度打印消息的 logger 参数。Logger 名称是有级别的,比如对于 com.company.sample logger 的设置也会影响 com.company.sample.core.CustomerServiceBeancom.company.sample.web.CustomerBrowse 的 logger,前提是这两个类没有显式的声明他们各自的 logger 参数。

    日志的打印级别是通过 level 属性来定义最低级别。比如,如果定义的是 INFO 级别,那么 DEBUGTRACE 类型的消息就不会被日志记录。需要注意的一点就是,在 appender 里面设置的级别也会影响日志的打印。Logger 是针对类或者包的日志定义,而 appender 是针对文件或者终端的设置。

可以在 web 客户端的 Administration > Server Log 界面快速修改正在运行的服务的 logger 级别和 appender 的阈值。任何对日志的改动只会影响正在运行的服务,设置并不会保存在文件里。这个界面也支持从日志目录 (tomcat/logs)查看和加载 Tomcat 服务的日志。

平台会自动添加以下信息到日志文件的消息中:

  • 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.6.2. 一些有用的 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.anonymousSessionId=9c91dbdf-3e73-428e-9088-d586da2434c5
cuba.automaticDatabaseUpdate=true
...

4.7. 调试

本章节介绍使用 CUBA 应用程序调试的步骤。

4.7.1. 连接调试器

可以通过两种方式启动 Tomcat 服务的调试模式。一种是通过 Gradle 任务

gradlew start

另一种是通过运行安装的 Tomcatbin/debug.* 文件。

启动之后,应用服务可以从 8787 端口接收调试器的连接。端口号可以在 bin/setenv.* 文件中的 JPDA_OPTS 变量修改。

如果使用 Intellij IDEA 调试,需要创建一个 Remote 类型的 Run/Debug Configuration 元素,并且设置调试连接的 Port 为 8787(默认值)。

4.7.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.7.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.8. 测试

本章节介绍 CUBA 应用程序在不同层(layers)的测试方法。

4.8.1. 单元测试

可以在中间件和客户端这两创建和执行单元测试用例。平台集成了 JUnitJMockit 用来做单元测试。

假设有下面这个界面控制器:

public class OrderEditor extends AbstractEditor {

    @Named("itemsTable.add")
    protected AddAction addAction;

    @Override
    public void init(Map<String, Object> params) {
        addAction.setWindowId("sales$Product.browse");
        addAction.setHandler(new Lookup.Handler() {
            @Override
            public void handleLookup(Collection items) {
                // some code
            }
        });
    }
}

可以编写以下测试类来检查 init() 方法:

public class OrderEditorTest {

    OrderEditor editor;

    @Mocked
    Window.Editor frame;

    @Mocked
    AddAction addAction;

    @Before
    public void setUp() throws Exception {
        editor = new OrderEditor();
        editor.setWrappedFrame(frame);
        editor.addAction = addAction;
    }

    @Test
    public void testInit() {
        editor.init(Collections.<String, Object>emptyMap());
        editor.setItem(new Order());

        new Verifications() {
            {
                addAction.setWindowId("sales$Product.browse");
                addAction.setHandler(withInstanceOf(Window.Lookup.Handler.class));
            }
        };
    }
}

4.8.2. 中间件集成测试

在中间件层,可以创建运行在具有完整功能的 Spring 容器里的测试用例,而且可以连接数据库。在这些测试类里面,可以运行中间件里面各细分层的代码,比如从 ORM 层到 Service 层。

首先,在 core 模块跟 src 目录平级创建 test 目录。然后在 Studio 里面重新创建 IDE 项目文件,以便在 IDE 里面运行测试。

平台自有的 TestContainer 类可以用来作为项目里面测试容器的基类。在 core 模块的 test 目录创建一个 TestContainer 类的子类,然后在这个子类的构造方法里面,重新定义加载 应用程序组件应用程序属性以及测试数据库连接的参数,示例:

public class SalesTestContainer extends TestContainer {

    public SalesTestContainer() {
        super();
        appComponents = Arrays.asList(
                "com.haulmont.cuba"
                // add CUBA premium add-ons here
                // "com.haulmont.bpm",
                // "com.haulmont.charts",
                // "com.haulmont.fts",
                // "com.haulmont.reports",
                // and custom app components if any
        );
        appPropertiesFiles = Arrays.asList(
                // List the files defined in your web.xml
                // in appPropertiesConfig context parameter of the core module
                "com/company/sales/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.
                "test-app.properties",
                "com/company/sales/test-app.properties");
        dbDriver = "org.postgresql.Driver";
        dbUrl = "jdbc:postgresql://localhost/sales_test";
        dbUser = "cuba";
        dbPassword = "cuba";
    }
}

自定义 test-app.properties 文件的示例:

cuba.webContextName = app-core
sales.someProperty = someValue

推荐使用单独的测试数据库,可以通过 build.gradle 里面定义的 createDb 任务来创建:

configure(coreModule) {
...
    task createTestDb(dependsOn: assemble, description: 'Creates local Postgres database for tests', type: CubaDbCreation) {
        dbms = 'postgres'
        dbName = 'sales_test'
        dbUser = 'cuba'
        dbPassword = 'cuba'
    }

这个测试容器应当在测试类里面作为 @ClassRule 注解指定的 JUnit 规则(rule):

public class CustomerLoadTest {

    @ClassRule
    public static SalesTestContainer cont = SalesTestContainer.Common.INSTANCE;

    private Customer customer;

    @Before
    public void setUp() throws Exception {
        customer = cont.persistence().createTransaction().execute(em -> {
            Customer customer = cont.metadata().create(Customer.class);
            customer.setName("testCustomer");
            em.persist(customer);
            return customer;
        });
    }

    @After
    public void tearDown() throws Exception {
        cont.deleteRecord(customer);
    }

    @Test
    public void test() {
        try (Transaction tx = cont.persistence().createTransaction()) {
            EntityManager em = cont.persistence().getEntityManager();
            TypedQuery<Customer> query = em.createQuery(
                "select c from sales$Customer c", Customer.class);
            List<Customer> list = query.getResultList();
            tx.commit();
            assertTrue(list.size() > 0);
        }
    }
}

在上面的例子中,测试容器只初始化了一次,但是可以给这个类所有的测试方法使用,等他们执行完之后就销毁了。

容器的初始化需要一些时间,有没有办法只创建一个测试容器但是给好几个测试类使用呢?可以通过创建一个通用的测试容器单例:

public class SalesTestContainer extends TestContainer {

    public SalesTestContainer() {
        ...
    }

    public static class Common extends SalesTestContainer {

        public static final SalesTestContainer.Common INSTANCE = new SalesTestContainer.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
        }
    }
}

在测试类里使用:

public class CustomerLoadTest {

    @ClassRule
    public static SalesTestContainer cont = SalesTestContainer.Common.INSTANCE;

    ...
}
几个有用的测试容器方法

TestContainer 类包含了以下几个方法,可以在测试类里面使用(参考上面的 CustomerLoadTest 例子):

  • persistence() – 返回 Persistence 接口的引用。

  • metadata() – 返回 Metadata 接口的引用。

  • deleteRecord() – 这一组重载方法的目的是在 @After 方法里面使用,在测试完成后清理数据库。

日志

测试容器根据平台提供的 test-logback.xml 文件来配置日志。这个文件在 cuba-core-tests 工件的根目录。

可以通过以下方法配置测试的日志级别:

  • 从平台的包里面拷贝 test-logback.xml 到项目 core 模块 test 根目录下,比如可以重命名为 my-test-logback.xml

  • my-test-logback.xml 里面配置 appenders 和 loggers。

  • 在测试容器里面添加一段静态初始化代码,这段代码通过设置 logback.configurationFile 这个系统属性来指定日志配置文件的位置:

    public class MyTestContainer extends TestContainer {
    
        static {
            System.setProperty("logback.configurationFile", "my-test-logback.xml");
        }
    
        // ...
    }
额外数据存储

如果项目使用了额外数据存储,需要在测试容器里创建相应的 JDBC 数据源。比如,如果有名为 mydb 的数据存储,而且是 PostgreSQL 的数据库,则需要在测试容器中添加如下代码:

public class MyTestContainer extends TestContainer {
    // ...

    @Override
    protected void initDataSources() {
        super.initDataSources();
        try {
            Class.forName("org.postgresql.Driver");
            TestDataSource mydbDataSource = new TestDataSource(
                    "jdbc:postgresql://localhost/mydatabase", "db_user", "db_password");
            TestContext.getInstance().bind(
                    AppContext.getProperty("cuba.dataSourceJndiName_mydb"), mydbDataSource);
        } catch (ClassNotFoundException | NamingException e) {
            throw new RuntimeException("Error initializing datasource", e);
        }
    }
}

还有,如果额外的数据库类型跟主数据库不一致,需要在 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.8.3. 客户端层集成测试

很快更新。

4.9. 热部署

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 手动触发。

5. 应用程序部署

本章节介绍了 CUBA 应用程序部署和操作的不同概念。

下图是一个可能的部署架构。这个架构消除了单点故障,提供了负载均衡和不同客户端的连接。

DeploymentStructure

最简单的情况,应用程序可以安装在一台机器,并且包含了数据库。根据负载和容错性要求可以选择多样的部署场景,细节请参考 应用程序扩展

5.1. 应用程序主目录

应用程序主目录是一个文件系统目录,这里可以存放所有的 应用程序文件目录。这个目录在除了快速部署之外的其它全部部署场景都会使用。在后面的例子中,应用程序的目录都在特定的 Tomcat 文件夹中。

应用程序主目录即是所有应用程序目录的根目录。通常在 WAR 或者 UberJAR 包中的 /WEB-INF/local.app.properties 文件指定。

  • 如果构建一个 WAR 包,必须在 buildWar Gradle 任务中定义应用程序主目录的路径。可以设置为绝对路径或者相对路径,如果是相对路径的话,需要提前知道 WAR 将会被部署在服务器的哪个工作目录。如果不知道的话,可以用 Java 系统变量的占位符,然后在运行时提供真实路径。

    在运行时设置应用程序主目录的示例:

    • 任务配置:

      task buildWar(type: CubaWarBuilding) {
          appHome = '${app.home}'
          // ...
      }
    • 构建了 WAR 之后 /WEB-INF/local.app.properties 文件的内容:

      cuba.logDir = ${app.home}/logs
      cuba.confDir = ${app.home}/${cuba.webContextName}/conf
      cuba.tempDir = ${app.home}/${cuba.webContextName}/temp
      cuba.dataDir = ${app.home}/${cuba.webContextName}/work
      ...
    • 命令行传递 app.home 系统参数:

      java -Dapp.home=/opt/app_home ...

      设置 Java 系统参数的方式取决于应用程序服务。对于 Tomcat,推荐设置在 bin/setenv.sh (或者 bin/setenv.bat)文件内。

    • 生成的目录结构:

      /opt/app_home/
        app/
          conf/
          temp/
          work/
        app-core/
          conf/
          temp/
          work/
        logs/
  • 对于 UberJAR 的情况,应用程序主目录默认设置为工作目录,但是也可以通过 Java 系统参数 app.home 来重定义。所以如果需要跟上面提到的 WAR 的例子一样设定同样的主目录,可以通过命令行执行:

    java -Dapp.home=/opt/app_home -jar app.jar

5.2. 应用程序文件目录

本章节介绍应用程序中各个 blocks 在运行时使用到的系统文件目录。

5.2.1. 配置文件目录

配置文件目录包含一些资源,这些资源可以在应用程序部署之后对项目配置、用户界面或者业务逻辑进行补充或者重写。重写是由 Resources 基础设施接口加载机制提供的,此机制先在配置文件目录进行搜索,然后才是 classpath,因此配置文件目录资源会比 JAR 文件和 classpath 里面同名资源的优先级要高。

配置文件目录可能包含以下类型的资源:

配置文件目录通过 cuba.confDir 应用程序属性指定。在 Tomcat 的快速部署模式下,这个目录是 tomcat/conf 下面的一个用应用程序名字命名的目录,比如中间件的 tomcat/conf/app-core 目录。如果是其它的部署方式,这个目录会在应用程序主目录里。

5.2.2. 工作目录

应用程序使用工作目录来存储一些持久化数据和配置。

比如,文件存储机制默认使用工作目录的 filestorage 子目录。还有,中间件模块会将启动时自动生成的 persistence.xmlorm.xml 保存到工作目录。

工作目录是由 cuba.dataDir 应用程序属性指定。在 Tomcat 的快速部署模式下,这个目录是 tomcat/work 下面的一个用应用程序名字命名的目录。如果是其它的部署方式,这个目录会在应用程序主目录里。

5.2.3. 日志目录

日志文件的内容是由 Logback 框架的配置决定的。平台在 classpath 的根目录提供了默认的配置文件 logback.xml。根据这个文件的设定,日志信息会在标准输出打印。

如果要做自定义的日志配置,可以通过 Java 系统属性 logback.configurationFile 来指向自定义的配置文件。参考 Tomcat 日志配置了解如何在快速部署中进行配置。

日志配置决定了日志文件的位置。可以是 Tomcat 下一个特定的目录(快速部署下的 tomcat/logs 目录),或者是应用程序主目录的一个目录。可以通过修改项目目录下 deploy/tomcat/conf 里面的 logback.xml 来控制日志目录,修改 logDir 属性即可,示例:

<configuration debug="false">
    <property name="logDir" value="${app.home}/logs"/>
    <!-- ... -->

应用程序需要知道日志文件存放在哪里,以便系统管理员通过 Administration > Server Log 界面加载和查看日志。所以,需要设置 cuba.logDir 这个应用程序属性与 logback.xml 里面配置的目录一致。

参考日志

5.2.4. 临时目录

应用程序会在运行时使用这个目录来存放一些临时文件。通过 cuba.tempDir 应用程序属性指定这个目录。在 Tomcat 的快速部署模式下,这个目录是 tomcat/temp 下面的一个用应用程序名字命名的目录。如果是其它的部署方式,这个目录会在应用程序主目录里。

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. Tomcat 快速部署

快速部署在开发程序的时候是默认的部署方式,因为快速部署的构建用时最少,能自动安装并且启动应用服务。生产环境也可以用这种方式部署。

快速部署是使用 build.gradle 文件中的 coreweb 模块的 deploy 任务。在第一次执行 deploy 之前,应当先执行 setupTomcat 任务来安装并且初始化本地 Tomcat 服务。

需要确保系统环境没有 CATALINA_HOMECATALINA_BASECLASSPATH 这三个环境变量。这些环境变量可能会导致启动 Tomcat 出问题并且在日志里没有任何提示。移除这些环境变量后需要重启机器。

快速部署之后,会在 Tomcat 目录下创建以下目录结构(只列出重要的目录和文件),Tomcat 目录是由 build.gradle 脚本的 cuba.tomcat.dir 参数指定:

bin/
    setenv.bat, setenv.sh
    startup.bat, startup.sh
    debug.bat, debug.sh
    shutdown.bat, shutdown.sh

conf/
    catalina.properties
    server.xml
    logback.xml
    logging.properties
    Catalina/
        localhost/
    app/
    app-core/

lib/
    hsqldb-2.2.9.jar

logs/
    app.log

shared/
    lib/

temp/
    app/
    app-core/

webapps/
    app/
    app-core/

work/
    app/
    app-core/
  • bin – 包含配置、启动和停止 Tomcat 服务的工具:

    • setenv.bat, setenv.sh – 这两个脚本用来设置环境变量。这些脚本可以用来设置 JVM 内存参数、logging 日志的配置文件、配置访问 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 会在一个单独的终端窗口启动,但是在 *nix 系统服务会在后台启动。

      需要在当前终端窗口启动服务,使用以下命令代替 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 配置描述文件。

    • logback.xml – 应用程序日志配置描述文件。

    • 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 内容覆盖设置。

    • app – web 客户端应用程序配置目录

    • app-core – 中间件应用程序配置目录

  • lib – 服务的 通用类加载器(common classloader) 加载类库的目录。这些类库可以被这个 Tomcat 服务和所有部署在其中的 web 应用程序加载。还有,这个目录应该有数据库的 JDBC 驱动(hsqldb-XYZ.jar, postgresql-XYZ.jar 等)。

  • logs – 应用程序和 Tomcat 服务的日志目录。应用程序的主要日志文件是 app.log(参考为 Tomcat 设置日志)。

  • shared/lib – 所有部署的应用可访问的类库目录。服务的 共享类加载器(shared classloader) 会加载这些类库。使用这个目录的方法在上面 conf/catalina.properties 文件中提到过。

    构建脚本的 deploy 任务会拷贝所有不在 jarNames 参数列举的类库到这个目录。

  • temp/app, temp/app-core – web 客户端和中间件应用程序的临时目录

  • webapps – web 应用程序目录。每个应用程序在它自己的子目录里,子目录按照 展开成文件夹的 WAR(exploded WAR) 形式命名。

    构建脚本的 deploy 任务会按照 appName 参数来创建应用程序子目录,除了其它的文件之外,还会拷贝列在 jarNames 参数的类库到每个应用程序的 WEB-INF/lib 目录。

  • work/app, work/app-core – web 客户端和中间件应用的工作目录

5.3.1.1. 生产环境使用 Tomcat

默认情况下,快速部署过程会在本地 Tomcat 实例下创建 appapp-core 这两个 web 服务,并且运行在 8080 端口。也就是说 web 客户端可以通过 http://localhost:8080/app 访问。

这个 Tomcat 可以作为生产环境使用,只需要拷贝 tomcat 目录到生产环境服务器,只需要修改 conf/app/local.app.propertiesconf/app-core/local.app.properties 里面的服务器名称,这两个文件如果没有的话,需要创建:

cuba.webHostName = myserver
cuba.webAppUrl = http://myserver:8080/app

另外,如果开发的时候使用的是测试库,那么需要在生产环境修改数据库连接以便使用生产库。可以通过修改 web 应用程序的context.xmlwebapps/app-core/META-INF/context.xml)文件。也可以按照前面章节介绍的拷贝这个文件为 conf/Catalina/localhost/app-core.xml,这样的话可以分别使用独立的开发和测试库环境配置。

可以从开发库备份来创建生产库,或者可以配置自动创建和更新生产库,参考生产环境更新数据库

可选配置
  1. 如果需要更改 Tomcat 端口或者 web 上下文(也就是 URL 里面 / 最后的那部分),可通过 Studio 进行配置:

    • 在 Studio 中打开项目。

    • 转到 Project Properties

    • 需要更改 web 上下文,可以编辑 Modules prefix 字段

    • 需要改变 Tomcat 端口,可以编辑 Tomcat ports > HTTP port 字段

  2. 如果希望使用根节点上下文(http://myserver:8080/),可以将 Modules prefix 直接设置成 ROOT

    tomcat/
        conf/
            ROOT/
                local.app.properties
            app-core/
                local.app.properties
        webapps/
            ROOT/
            app-core/

    然后在 conf/ROOT/local.app.properties 文件里,使用 / 作为 web 上下文名称:

    cuba.webContextName = /

5.3.2. 部署 WAR 至 Jetty

以下是一个部署 WAR 包到 Jetty web 服务器的示例,假设应用程序使用的是 PostgreSQL 数据库。

  1. 使用 Studio 中的 Deployment > WAR settings 界面或者手动在 build.gradle 末尾添加 buildWar 任务。

    task buildWar(type: CubaWarBuilding) {
        appHome = '${app.home}'
        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. 下载并安装 Jetty 到本地目录,比如 c:\work\jetty-home。本示例使用 jetty-distribution-9.3.6.v20151106.zip 测试通过。

  5. 创建 c:\work\jetty-base 目录,并且在这个目录打开命令行窗口执行以下命令:

    java -jar c:\work\jetty-home\start.jar --add-to-start=http,jndi,deploy,plus,ext,resources
  6. 创建 c:\work\jetty-base\app-jetty.xml 文件,添加以下内容(假设使用名为 test PostgreSQL 数据库):

    <?xml version="1.0"?>
    <!DOCTYPE Configure PUBLIC "-" "http://www.eclipse.org/jetty/configure_9_0.dtd">
    <Configure id="Server" class="org.eclipse.jetty.server.Server">
        <New id="CubaDS" class="org.eclipse.jetty.plus.jndi.Resource">
            <Arg></Arg>
            <Arg>jdbc/CubaDS</Arg>
            <Arg>
                <New class="org.postgresql.ds.PGSimpleDataSource">
                    <Set name="ServerName">localhost</Set>
                    <Set name="PortNumber">5432</Set>
                    <Set name="DatabaseName">test</Set>
                    <Set name="User">cuba</Set>
                    <Set name="Password">cuba</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>
  7. 或许(比如对于 MS SQL 数据库),需要下载以下这些 JAR 并且添加到 c:\work\jetty-base\lib\ext 目录。

    commons-pool2-2.4.2.jar
    commons-dbcp2-2.1.1.jar
    commons-logging-1.2.jar
  8. 将下面这些添加到 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
  9. 拷贝数据库的 JDBC 驱动到 c:\work\jetty-base\lib\ext 目录。可以从 CUBA Studio 的 lib 目录或者项目的 build\tomcat\lib 目录拷贝这些驱动。比如对于 PostgreSQL,驱动文件是 postgresql-9.1-901.jdbc4.jar

  10. 拷贝 WAR 文件到 c:\work\jetty-base\webapps 目录。

  11. c:\work\jetty-base 目录打开命令行窗口并且执行:

    java -jar c:\work\jetty-home\start.jar
  12. 在浏览器打开 http://localhost:8080/app

5.3.3. 部署 WAR 到 WildFly

CUBA 应用程序的 WAR 包可以部署在 WildFly 应用服务中。下面这个示例是部署使用 PostgreSQL 9.6 的 CUBA 应用程序到 Windows 的 WildFly 14.0.0。

  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-5.4.2.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-9.4.1212.jarC:\wildfly\standalone\deployments 目录。

    • 配置 WildFly logger:打开 \wildfly\standalone\configuration\standalone.xml 文件,在 <subsystem xmlns="urn:jboss:domain:logging:{version}" 区域添加下面两行:

      <subsystem xmlns="urn:jboss:domain:logging:6.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-9.4.1212.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 按钮测试数据库连接。

    • 启用这个数据源。

  6. 构建应用程序

    • 在 Studio 打开 Deployment > *WAR settings

    • 勾选 Build WAR 复选框。

    • Application home directory 字段设置 ${app.home}

    • 保存设置。

    • 在 IDE 中打开 build.gradle 并且在 buildWar 任务中添加 doAfter 属性。这个属性会控制拷贝 WildFly 部署描述文件:

      task buildWar(type: CubaWarBuilding) {
          appProperties = ['cuba.automaticDatabaseUpdate' : true]
          singleWar = false
          appHome = '${app.home}'
          doAfter = {
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/buildWar/core/war/META-INF/"
              }
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/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]
          appHome = '${app.home}'
          doAfter = {
              copy {
                  from 'jboss-deployment-structure.xml'
                  into "${project.buildDir}/buildWar/war/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. build.gradle 末尾添加 buildWar 任务:

    task buildWar(type: CubaWarBuilding) {
        appHome = './app_home'
        singleWar = false
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = ['cuba.automaticDatabaseUpdate': true]
    }

    如果目标 Tomcat 服务的参数跟快速部署里用到的本地 Tomcat 的参数不同,需要提供相应的应用程序属性。比如,如果目标 Tomcat 运行在 9999 端口,任务定义会是这样:

    task buildWar(type: CubaWarBuilding) {
        appHome = './app_home'
        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) {
        appHome = './app_home'
        singleWar = false
        includeContextXml = true
        includeJdbcDriver = true
        appProperties = ['cuba.automaticDatabaseUpdate': true]
        coreContextXmlPath = 'modules/core/web/META-INF/production-context.xml'
    }
  2. 执行 buildWar Gradle 任务。会在项目 build/distributions 目录生成 app.warapp-core.war 文件。

    gradlew buildWar
  3. 下载并执行 Tomcat 8 Windows Service Installer。

  4. 切换到安装好的服务的 bin 目录,使用管理员权限执行 tomcat8w.exe。 在 Java 标签页,设置 Maximum memory pool 为 1024MB。然后在 General 标签页重启服务。

    tomcatPropeties
  5. Java Options 字段添加 -Dfile.encoding=UTF-8

  6. 拷贝项目生成的 app.warapp-core.war 文件到 Tomcat 服务的 webapps 目录。

  7. 启动 Tomcat 服务。

  8. 在浏览器打开 http://localhost:8080/app

5.3.5. 部署 WAR 至 Tomcat Linux 服务

以下示例在 Ubuntu 16.04 测试通过

  1. build.gradle 末尾添加 buildWar 任务。可以指定不同的 context.xml 文件来设置生产环境数据库连接:

    task buildWar(type: CubaWarBuilding) {
        appHome = '${catalina.base}/work'
        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) {
        appHome = './app_home'
        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. 安装 Tomcat 8 Linux Service:

    sudo apt-get install tomcat8
  4. 拷贝项目生成的 app.war 文件到 Tomcat 服务的 /var/lib/tomcat8/webapps 目录。

    Tomcat 服务默认是使用 tomcat8 用户来启动的。所以 webapps 目录的所有者也是 tomcat8

  5. 创建配置文件 /usr/share/tomcat8/bin/setenv.sh 包含以下设置:

    export CATALINA_OPTS="$CATALINA_OPTS -Xmx1024m"
  6. 重启 Tomcat 服务:

    sudo service tomcat8 restart
  7. 在浏览器打开 http://localhost:8080/app

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
  • 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"/>
    <!-- ... -->
停止应用程序

可以通过下面几种方式平和的停掉应用程序服务:

  • 在应用程序运行的终端窗口按下 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 应用程序。

我们将使用在快速开始部分开发的示例项目作为例子,迁移到 PostgreSQL 数据库,并构建 UberJAR,最后运行在容器中。事实上,构建为 WAR 的应用程序也能在容器化的 Tomcat 中运行,但是需要做更多的配置,所以如果只是示例,我们就用 UberJAR。

配置并构建 UberJAR

https://github.com/cuba-platform/sample-sales-cuba7 克隆示例程序,并且在 CUBA Studio中 打开

首先,将数据库类型改为 PostgreSQL:

  1. 在主菜单点击 CUBA > Project Properties 然后切换到 Data Stores 标签页。

  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 > UberJAR Settings

  2. 勾选 Build Uber JARSingle Uber JAR 复选框。

  3. 点击 Logback configuration file 字段旁边的 Generate 按钮。

  4. 点击 Custom Jetty environment file 字段旁边的 Generate 按钮。确保选择了 PostgreSQL 然后在 Database URL 字段输入 postgres 替换 localhost。这个配置是在使用下面描述的容器化的数据库时需要的。

  5. 点击 OK。Studio 会在 build.gradle 文件中添加 构建 UberJar 任务。

  6. 为了保证日志文件在正确的位置,打开生成的 etc/uber-jar-logback.xml 文件,修改 logDir 属性:

    <property name="logDir" value="${app.home}/logs"/>

    还有,确保 Logback 配置文件限制了 org.eclipse.jetty logger 的级别至少为 INFO。如果文件中没有该 logger,可以添加:

    <logger name="org.eclipse.jetty" level="INFO"/>

执行创建 JAR 的命令:

./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:9
    environment:
      - POSTGRES_DB=sales
      - POSTGRES_USER=cuba
      - POSTGRES_PASSWORD=cuba
    volumes:
      - /Users/me/sales-home:/opt/sales-home
    ports:
      - "5433:5432"
  web:
    image: sales
    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'
          appHome = '..'
          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']
          appHome = '..'
          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 打开 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 文件,检查连接参数和用户名密码:

    • 勾选 Single WAR for Middleware and Web Client

    • 点击 Custom web.xml path 旁边的 Generate 按钮。

    • 拷贝下面的代码然后粘贴到 App properties 字段:

      [
        'cuba.automaticDatabaseUpdate' : true
      ]
    • 保存部署设置。

构建 WAR 文件

执行 buildWar Gradle 任务构建 WAR 文件:

gradlew buildWar

通过 Studio 的 Build 菜单命令可以预先创建 Gradle wrapper 以便在命令行窗口使用 gradlew 命令。

应用程序配置
  • 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 的值。

NGINX

对于 Nginx,下面有两种配置方法,所有示例都在 Ubuntu 16.04 测试通过。

假设,web 应用程序运行在 http://localhost:8080/app

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"  />

然后重启 Tomcat 服务:

sudo service tomcat8 restart

这个配置可以使得不需要修改 web 应用程序的情况下使用 Tomcat 来分发 Nginx headers。

然后安装 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 客户端服务器计划外宕机了,所有连到这个服务器的用户需要重新登录应用程序。

这个部署选择的配置在 配置 Web 客户端集群 介绍。

scaling_3

扩展级别 4. Web 客户端集群搭配 Middleware 集群

这个方案是最强大的部署选择,能提供全面的 Web 客户端和 Middleware 容错性和负载均衡。

连接到 Web 客户端的用户通过负载均衡接入。多个 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 实例。

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. tomcat/conf/app/local.app.properties 中设置下列应用程序属性:

    cuba.useLocalServiceInvocation = false
    cuba.connectionUrlList = http://host3:8080/app-core
    
    cuba.webHostName = host1
    cuba.webPort = 8080
    cuba.webContextName = app

    cuba.webHostName, cuba.webPortcuba.webContextName 参数对于 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.connectionUrl 应用程序属性来配置。在 Web 客户端,这个配置可以写在 tomcat/conf/app/local.app.properties

cuba.useLocalServiceInvocation = false
cuba.connectionUrlList = http://host3:8080/app-core,http://host4:8080/app-core

cuba.webHostName = host1
cuba.webPort = 8080
cuba.webContextName = app

第一次用户会话远程连接的中间件服务器是随机选择的,而且对于整个会话的生命周期这个服务器都是固定的("sticky session")。从匿名用户会话来的请求或者不带用户会话的请求不固定在特定的服务器,也是随机选择服务器执行。

选择中间件服务器的算法是由 cuba_ServerSorter bean 提供的,这个 bean 默认是由 RandomServerSorter 类实现。也可以在项目中提供自定义的实现。

5.6.2.2. 配置多个 Middleware 服务交互

多个 Middleware 服务器可以共同维护用户会话共享列表和其它类对象,还能协调处理过期的缓存。每个中间件服务都需要开启 cuba.cluster.enabled 参数启用这个功能。以下是 tomcat/conf/app-core/local.app.properties 文件的示例:

cuba.cluster.enabled = true

cuba.webHostName = host3
cuba.webPort = 8080
cuba.webContextName = app-core

对于中间件服务来说,需要设定正确的 cuba.webHostNamecuba.webPortcuba.webContextName 这三个属性的值,这样能用这三个属性组成唯一的服务器 ID

服务之间的交互机制是基于 JGroups。平台为 JGroups 提供了两个配置文件:

  • jgroups.xml - 基于 UDP 的协议栈,适用于启用了广播通信的本地网络。这个配置当集群功能开启的时候会被默认使用。

  • jgroups_tcp.xml - 基于 TCP 的协议栈,适用于任何网络。使用这个协议要求在 TCP.bind_addrTCPPING.initial_hosts 参数中显式设定集群成员的地址。如果需要使用这个配置,需要设定 cuba.cluster.jgroupsConfig 这个应用程序属性。

为了配置环境中的 JGroups 参数,从 cuba-core-<version>.jar 的根目录拷贝合适的 jgroups.xml 文件到项目的 core 模块或者 tomcat/conf/app-core 目录,并且修改这个文件。

ClusterManagerAPI bean 提供 Middleware 集群中服务器交互的编程接口。可以在应用程序中使用,需要时可参考 JavaDocs 和平台代码的用法。

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 50. JMX 控制台

JMX 控制台也可以访问远程 JVM 的 JMX 对象。这个功能在应用程序 blocks 部署在几台不同的 web 容器是很有用,比如,分开部署的 web 客户端和中间件。

需要连接远程 JVM,可以通过控制台的 JMX Connection 字段选择一个之前建立的连接或者创建一个新连接:

jmx connection edit
Figure 51. 编辑 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: /rest/health

  • Web Portal: /rest/health

所以对于名称叫 app 并且部署在 localhost:8080 的应用程序,这些 URL 将会是:

  • http://localhost:8080/app-core/remoting/health

  • http://localhost:8080/app/rest/health

  • http://localhost:8080/app-portal/rest/health

可以使用 cuba.healthCheckResponse 应用程序属性将返回的 ok 替换成任意字符串。

监控检查的控制器也会发送类型为 HealthCheckEvent事件。因此可以添加自定义的检查应用健康的逻辑。 GitHub 的这个例子 演示了 web 层的一个 bean 监听健康检查的事件,并且调用中间件服务,最后在数据库做了一次操作。

6. 安全子系统

CUBA 框架使用下面的方法来控制访问权限:

  • 使用基于角色的系统来分配用户权限。系统管理员在系统部署的时候或者生产环境可以配置一组角色和用户。

  • 使用具有权限约束继承的分级访问组结构。

  • 访问权限控制有下列级别:

    • 实体的操作(CRUD):比如,用户 Smith 可以查看文档,但是不能创建,更改或者删除文档。

    • 实体属性(修改,读取,访问禁止):用户 Smith 可以查看除了 amount 之外的文档的所有属性。

    • 对特定实体实例的访问(行级访问控制):用户 Smith 只能查看他们部门创建的文档。

  • 集成LDAP,可以为 Windows 用户提供单点登录(Single Sign-On)。更多关于 SSO 的信息,请参阅 IDP 插件 文档

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 52. 安全子系统组件图

下面是这些组件的概览:

Security management screens - 安全管理界面 – 系统管理员可用的一组界面,用来配置用户访问权限。

Login screen - 登录界面 − 系统登录窗口。通过用户名和密码提供用户认证。数据库保存密码哈希值以保证安全。

登录时会创建 UserSession 对象。这是当前认证用户主要的安全元素,包含了用户数据访问权限的信息。

用户登录过程的描述,请参考 登录

Roles - 角色 − 用户角色。角色是一个系统对象,一方面用来匹配进行特殊功能操作的权限,另一方面用来限定一组用户必须拥有这些权限。

有以下类型的权限:

  • Screen Permissions - 界面权限 − 打开界面的权限。

  • Entity Operation Permissions - 实体操作权限 − 对实体进行 CRUD 操作的权限。

  • Entity Attribute Permissions - 实体属性权限 − 访问任意实体属性的权限,包括:修改,只读,禁止访问。也可参考 实体属性访问控制

  • Specific Permissions - 特殊权限 − 对一些定义了名称的功能的访问权限控制。

  • UI Permissions - UI 组件权限 − 控制对于界面元素的访问。

Access Groups - 访问组 − 用户访问组。拥有层级关系的结构,每个组定义一组约束,允许用来控制对单个实体实例的访问(数据表的行级别)。比如,用户只能查看他们部门创建的文档。

6.2.1. 登录界面

登录界面提供使用登录名和密码登录的功能。登录名大小写敏感。

Web Client 中的 Remember Me - 记住我 复选框可以通过应用程序属性cuba.web.rememberMeEnabled 设置。标准登录页中可选语言的下拉列表可以通过应用程序属性 cuba.localeSelectVisiblecuba.availableLocales 设置。

在 Web Client,可以在 Studio 中自定义或完全替换标准登录窗口。在项目树选择 Generic UI 并点击右键菜单的 New > Screen。然后在 Legacy Screen Templates 标签页选择 Login Window 模板。会在 Web 模块创建新的 ext-loginWindow.xml 文件,并自动注册到 web-screens.xml 中。参阅 Web 客户端基础设施

平台提供防暴力破解密码机制:参阅应用程序属性 cuba.bruteForceProtection.enabled

需要更深层次的自定义鉴权流程,参阅 登录Web 登录 章节。

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 -权限许可 定义用户对系统对象或功能的权限,例如界面,实体操作等。可以定义允许的权限,也可以定义 prohibition - 拒绝 的权限。

默认情况下,用户对对象拥有允许的权限,除非显式的定义了拒绝权限。

权限许可通过 sec$Permission 实体实例控制,包含以下属性:

  • type – 许可类型,许可施加的对象类型。

  • target – 许可对象,许可施加的对象。该属性的格式基于许可类型。

  • value – 许可值,可选值基于许可类型。

许可类型描述如下:

  • PermissionType.SCREEN – 界面许可

    界面标识在 target 属性中指定,value 属性值可以为 0(拒绝) 或 1(允许)。

    界面许可检查发生在组建系统主菜单,和每次调用 Frame 接口的 openWindow(), openEditor(), openLookup() 方法时。

    在应用程序中检查界面许可时,可以使用 Security 接口的 isScreenPermitted() 方法。

  • PermissionType.ENTITY_OP – 实体操作许可

    target 属性中指定 实体名 + “:” + 操作类型,操作类型有: create, read, update, delete。例如:library$Book:deletevalue 属性值可以为 0(拒绝)或 1(允许)。

    实体操作许可检查发生在使用 DataManager,数据感知可视化组件,和标准行为操作数据时,也适用于实体列表。所以,操作权限也会影响 client blocks 和 REST API。但是在 Middleware 通过 EntityManager 操作数据时不会触发实体操作许可检查。

    在应用程序中检查实体操作许可时,可以使用 Security 接口的 isEntityOpPermitted() 方法。

  • PermissionType.ENTITY_ATTR – 实体属性许可

    target 属性中指定 实体名 + “:” + 属性名,例如:library$Book:namevalue 属性值可以为 0(不可见), 1(只读) 或 2(读写)。

    实体属性许可只在数据感知可视化组件REST API 中检查。

    在应用程序中检查实体属性许可时,可以使用 Security 接口的 isEntityAttrPermitted() 方法。

  • PermissionType.SPECIFIC – 特定功能许可。特定的许可可以用来取代角色对项目功能的许可/禁止,因为角色是许可的合集。

    target 属性中指定功能标识,value 属性值可以为 0(拒绝)或 1(允许)。

    项目中特点的许可在配置文件 permissions.xml 中设置。

    例如:

    @Inject
    private Security security;
    
    private void calculateBalance() {
        if (!security.isSpecificPermitted("myapp.calculateBalance"))
            return;
        ...
    }
  • PermissionType.UI – 界面组件许可

    target 属性中指定 界面标识 + “:" + 组件路径。组件路径格式描述详见下一章节。

检查许可的时候,不要直接使用 UserSession 的方法,建议使用 Security 接口中同样名字的方法,Security 接口也支持可能发生的实体扩展

6.2.4. 角色

译者注 — 这里很多英文是界面上内容的描述,所以在翻译时,界面元素保留了很多英文以及对应的中文翻译。

角色包含权限许可集合,可以给用户分配角色。

一个用户可以有多个角色,角色之间设计为组合(逻辑或)的关系。例如,一个用户拥有角色 A,B 和 C,角色 A 拒绝 X,角色 B 允许 X,角色 C 未明确设置对 X 的权限,那么,X 对用户是允许的。

如果没有角色显式定义某个对象的权限许可,那么用户拥有该对象的许可权限。所以,当没有任何角色显式定义所有对象的权限许可时,或者至少有一个角色定义了许可权限,那么用户拥有所有对象的许可权限。

如果一个用户只有一个角色,并且该角色没有显示设置任何许可权限,或者用户没有任何角色,那么用户拥有所有对象的许可权限。

角色列表在 Administration > Roles screen 界面显示。除了标准的 create(创建), update(编辑), 和 delete(删除) 操作,该界面还有一个 Assign to users - 分配给用户 按钮,可以将所选的角色分配给多个用户。

角色编辑界面描述如下。角色属性显示在上方:

role attributes
  • Name – 角色的唯一名称或标识,必填项。角色创建好以后该名称不能被修改。

  • Localized name – 本地化角色名称。

  • Description – 对于该角色的文本描述。

  • Type – 角色类型,可以是以下值:

    • Standard – 标准,该角色类型提供明确的权限许可。

    • Super – 超级,自动提供所有许可。应该分配给系统管理员,其它角色的拒绝设置都会被它移除。

    • Read-only – 只读,自动拒绝以下实体操作: CREATE-创建,UPDATE-修改,DELETE-删除。因此,带这个角色的用户只能读数据而不能更改(除非用户还拥有其它显式的设置许可上述操作的角色)。

    • Denying – 拒绝,自动拒绝所有对象的访问,除了实体属性。如果需要查看或者更新系统数据,需要额外分配给用户其它显式的允许必要操作的角色。

      所有类型的角色都可以再额外的显式的设置权限许可。例如,可以给 Read-only 角色添加修改实体的权限。不过,给 Super 角色添加拒绝访问的设置也没有意义,因为这个角色类型会移除任何的拒绝设置。

  • Default role – 默认角色标记。开启了这个标记的角色会自动分配给新创建的用户。

下面是权限许可管理各个标签页的描述。

  • Screens 界面标签页配置界面的权限:

    role screen permissions

    该标签页左侧的树形列表对应应用程序的主菜单。最后一个树节点是 Other screens - 其它界面,包含不在主菜单的界面(例如: 实体编辑界面)。

  • Entities 实体标签页配置实体的操作权限:

    role entity permissions

    Assigned only - 只显示已分配 复选框默认生效,所以列表里只显示对该角色明确设置了的实体。因此,新创建的角色这里显示空白。需要设置权限的时候,取消勾选 Assigned only,然后点击 Apply - 应用。也可以在 Entity - 实体文本框 输入部分实体名称,点击 Apply 来做筛选。

    System level - 系统 复选框允许查看选择以 @SystemLevel 注解的系统实体,这类实体默认不会显示。

    当违反这类限制时,用户会收到错误通知。如果要本地化这类错误信息,需要在主语言包中重写 RowLevelSecurityExceptionHandler

  • Attributes 属性标签页配置实体属性权限:

    role attr permissions

    Permissions 权限列展示了显式设置过权限的熟悉。具有 modify 修改权限的标记为绿色,具有 read-only 只读权限的标记为蓝色,具有 hide 隐藏权限的标记为红色。

    实体列表的展示、筛选类似于 Entities 实体标签页。

    如果需要动态改变属性权限,依赖于实体当前状态或者关联实体,使用 SetupAttributeAccessHandler 接口的实体属性访问控制机制。

  • Specific 特定功能标签页配置特定功能权限:

    role specific permissions

    项目配置文件 permissions.xml 配置能分配特定权限的对象名称。

  • UI 界面组件标签页配置界面中组件的权限:

    role ui permissions

    这里允许设置任何界面中组件的权限,包括非数据感知组件(比如容器组件)。配置这类权限需要知道组件 ID,所以需要看源代码来查找组件 ID。

    创建这类权限时,在 Screen 界面下拉列表选择需要的界面,在 Component 组件 输入框输入组件 ID,点击 Add - 添加。然后在 Permissions - 权限 面板设置需要的访问权限。

    组件 ID 名称的规则是:

    • 如果组件属于当前界面,只要输入组件 id 即可。

    • 如果组件属于嵌入当前界面的 frame(子框架窗口),需要输入: frame 的 Id + “.” + 组件 Id。

    • 如果为 TabSheet 或者 FieldGroup 中的组件设置权限,需要输入:组件 Id + “[” + TabSheet Id 或 FieldGroup Id + “]”。

    • 如果为 action 配置权限,需要输入:action 操作的组件 Id + “<” + actionId + “>”, 例如 customersTable<changeGrade>

6.2.5. 访问组

访问组能将用户以树形层级关系组织,分配约束和自定义任意会话属性

一个用户只能加入一个访问组,但是用户加入的访问组树形层级以上的约束列表和会话属性都会被继承。

通过 Administration > Access Groups 界面管理访问组:

group users
6.2.5.1. 约束

Constraints - 约束 限制用户访问实体实例。与应用到 级别的许可不同,约束是应用到 特定实体实例 的,当实体实例不满足约束条件时,则不允许访问。约束可以设置到增删改查级别。同时,还可以配置自定义约束。

配置到用户访问组的约束,及其树形层级以上的访问组的约束都会对该用户生效。所以,当用户所在访问组树形层级越低,给用户配置的约束会越多。

所有客户端层通过标准 DataManager 发起的操作都会触发约束检查. 如果实体不满足约束,添加、更改或删除的时候会抛出 RowLevelSecurityException 异常。

约束检查有三种类型:数据库约束检查,内存约束检查,数据库加内存约束检查。

  1. 数据库约束检查条件通过 JPQL 子句设置。设置以后会被追加到查询语句之后,这样不满足条件的结果在数据库级别会被过滤掉。数据库检查的约束只能用到查询操作中。

  2. 内存约束检查通过 Groovy 表达式设置。这类表达式执行在对象图中的实例上,当不满足条件时,数据会被从对象图中过滤掉。

  3. 数据库加内存约束检查兼有上述二者。

需要创建约束时,打开 Access Groups - 访问组 界面,选择一个 group,在 Constraints - 约束 标签页中点击 Create - 创建:

constraint edit

Entity Name - 实体名称 下拉列表中选择一个实体, 从 Operation Type - 操作类型 下拉列表中选择操作类型,基于选择的约束检查类型,用 Join ClauseWhere Clause 设置 JPQL 条件或者用 Groovy 脚本 字段设置 Groovy 条件。也可以使用 Constraint Wizard - 约束向导,可以直观的创建 JPQL 和 Groovy 条件。当选择自定义操作类型时,会出现 Code 字段,设置特定的 code 来定义约束。

JPQL 编辑器中的 Join ClauseWhere Clause 字段支持自动填写实体名和对应的属性。按下 Ctrl+Space 可以触发自动填写。在“.”之后触发会列出匹配上下文的属性名,其它情况下,会列出实体列表。

JPQL 约束需要遵从以下规则:

  • {E} 需要被做为查询实体的别名,当执行查询语句时,它会被查询语句中真正使用的别名替代。

  • 以下预定义常量可以用作 JPQL 参数:

    • session$userLogin – 当前用户的 login 登录名,(如果是替代用户 – 则为被替代用户的 login)。

    • session$userId – 当前用户的 ID,(如果是替代用户 – 则为被替代用户的 ID)。

    • session$userGroupId – 当前用户的 group ID,(如果是替代用户 − 则为被替代用户的 group ID)。

    • session$XYZ – 当前用户会话的其它任意属性,将 XYZ 替换为属性名使用。

  • Where Clause 字段中的内容会被添加到 where 子句并用 and 连接。不需要显式添加 where 单词,系统会自动添加。

  • Join Clause 字段中的内容会被添加到 from 子句,该字段需要用逗号“,”开头,使用 joinleft join

一个简单的约束示例如上图所示:用户只能看到 ref$Car 中 VIN 以 ‘00’ 开头实体数据。

另外一个常见的例子:假如有一个实体与 User 实体为多对多关系,想让用户只能看到跟自己相关的数据,可以在 Where Clause 中使用 JPQL 的 member of 操作符:

(select u from sec$User u where u.id = :session$userId) member of {E}.users

内存约束检查时,UserSession 类型的 userSession 会被传入 Groovy 脚本,可以用它获取当前会话的属性,例如:

{E}.createdBy == userSession.user.login

开发者可以使用以下 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>

违反约束时,会弹出通知给用户。每个约束的提示标题与内容可以在运行时重写,在 Access Groups - 访问组 界面的 Constraints - 约束 标签页,点击 Localization - 本地化,然后就可以设置通知的标题和内容文本了。

6.2.5.2. 会话属性

访问组中可以定义该组中用户会话的属性列表。这些属性可以在设置约束时使用。开发阶段可以在应用程序代码中检查会话属性是否可用,最终用户访问组的系统行为可以在运维阶段控制。

登录时,用户访问组的所有属性集合,以及组以上树形层级的属性集合都会被置于用户会话中。如果不同树形层级有相同的属性,最上层的会生效。所以,在低树形层级组中覆盖属性值是不可能的。如果发现有覆盖倾向,会在服务器日志中显示 WARN 级别日志。

Access Groups - 访问组 界面的 Session Attributes - 会话属性 标签页点击 Create - 创建 创建会话属性:

session attr edit

需要指定:唯一的属性名,数据类型和属性值。

在应用程序代码中使用 session 属性:

@Inject
private UserSessionSource userSessionSource;
...
Integer accessLevel = userSessionSource.getUserSession().getAttribute("accessLevel");

使用 session$ 前缀,会话属性可以在约束中做为 JPQL 参数使用:

{E}.accessLevel = :session$accessLevel

6.3. 访问权限控制示例

本章节提供一些关于如何配置用户数据访问权限的实践建议。

6.3.1. 配置角色

建议按以下方式配置角色权限许可

  1. 创建一个 Default - 默认 角色,这个角色禁止所有系统权限。最简单的方法是创建一个 Denying - 拒绝 类型的角色。然后为其勾选 Default role 默认复选框,这样所有新用户都默认带有该角色。

  2. 为各类用户类别创建一系列允许特定权限的角色,有两种策略:

    • 粗放化(Coarse-grained)角色 – 每个角色中都配置上某类用户责任范畴所需的所有权限,例如 Sales Manager 销售经理,Accountant 会计。这样的话,除了 Default 默认角色,每个用户只分配一个角色。

    • 精细化(Fine-grained)角色 – 每个角色都配置为可以操作某类功能。例如 Task Creator 任务创建,References Editor 引用编辑。这样的话,每个用户基于他的责任范畴会分配到多个角色。

    两种策略也可以组合使用。为不同的用户类别创建一系列角色来分配权限。

  3. 系统管理员可能会不需要分配任何角色,他们拥有所有系统对象的所有权限。也可以为其分配一个 Super 类型的角色,覆盖所有其它角色指定的限制。

管理功能访问权限

需要给 Denying - 拒绝 角色提供访问管理功能的时候,需要放开一些权限,以下是快速参考。比如,只开放实体日志功能,在相应部分设置提到的权限。

推荐至少提供 sys$FileDescriptor 实体的只读权限,因为这个实体在平台很多地方都会用到:邮件、附件、日志等。

下面提到的权限可以通过相应标签页的 Role 编辑窗口进行配置:Entity - 实体, Screen - 界面 或者 Specific - 特殊功能

另外,可以通过 cuba.defaultPermissionValuesConfig 应用程序属性配置系统实体的默认访问权限。

Users - 用户

User 实体可以在数据模型中用来做关联引用实体。需要在查询组件或者下拉框组件使用用户实体,只需要设置 sec$User 实体的权限就足够。

如果需要使用 Denying - 拒绝 角色创建或者编辑 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.

  • 特殊功能: 下载日志文件

Screen Profiler - 界面资料

应用界面的使用量和使用时间的统计数据。

  • 实体: sec$User, sys$ScreenProfilerEvent.

  • 界面: Screen Profiler 菜单项.

Reports - 报表

需要运行报表,参考 报表生成器 插件。

  • 实体: report$Report, report$ReportInputParameter, report$ReportGroup.

  • 界面: report$inputParameters, commonLookup, report$Report.run, report$showChart (如果包含图表模板)。

6.3.2. 创建本地管理员

访问组的树形层级结构加上约束能够支持创建 本地管理员,可以用来在组织部门内代理做一些创建、配置用户和权限管理的工作。

本地管理员可以访问安全子系统的界面,但是他们只能看到自己访问组或以下的用户和访问组。本地管理员还可以创建子访问组、用户以及能分配系统上的角色。其创建的新用户也至少有跟创建他们的本地管理员一样的权限限制。

访问组根节点下面的全局管理员需要创建能被本地管理员看到的角色,然后本地管理员才能分配给用户。本地管理员不能创建或修改角色。

下面是一个访问组结构的例子:

local admins groups

问题有:

  • Departments 下面的用户应该只能看到该组下面的用户,或低于该组层级的组里的用户。

  • Dept 1, Dept 2, 这些子组都应该有自己的管理员可以创建用户,分配角色。

方案是:

  • Departments 里添加以下约束:

    local admins constraints
    • 对于 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 实体:

      ({E}.description is null or {E}.description not like '[hide]')

      这样,用户不会看到 description 属性里包含 [hide] 的角色。

  • 创建一个拒绝编辑角色和权限许可的角色:

    local admins role
    • 勾选 Default 默认角色复选框。

    • [hide] 字符加到 Description 属性。

    • Entities 实体标签页,为 sec$Role 配置拒绝 create, updatedelete 操作。(给 sec$Permission 对象添加权限许可的时候,需要勾选 System level - 系统级 复选框 )。

    所有创建的用户,包括本地管理员,会分配 local_user 角色。这个角色对 Departments 组的用户不可见,所以本地管理员也不能把这个角色从用户移除。本地管理员只能操作全局管理员给他们创建的角色。显然,department 用户可见的其它角色也不能移除 local_user 角色指定的限制。

6.4. 集成 LDAP

CUBA 应用程序可以跟 LDAP 集成以便提供以下便利:

  1. 集中的将用户名和密码保存在 LDAP 数据库。

  2. 对于 Windows 域用户,可以使用单点登录机制来登录应用系统而不需要提供用户名和密码。

如果启用了 LDAP 集成,用户在应用系统中还是需要一个账号。用户的所有权限和其它属性(除了密码)都保存在应用程序数据库,LDAP 只是用来做用户认证。推荐将大部分用户的应用程序密码留空,除了那些需要标准认证的用户(参考下面)。如果 cuba.web.requirePasswordForNewUsers 属性设置成 false,那么用户编辑界面不需要显示密码控件。

如果用户的登录名列在 cuba.web.standardAuthenticationUsers 应用程序属性中,应用程序会尝试使用数据库保存的密码哈希值来做用户认证。这样的话,这个列表里的用户可以使用数据的密码登录系统,即便这个用户不在 LDAP 注册过。

基于 CUBA 的应用程序通过 LdapLoginProvider bean 来跟 LDAP 交互。

可以用 使用 Jespa 集成活动目录 章节描述的 Jespa 类库和相应的 CubaAuthProvider 来启用跟活动目录(Active Directory)的进一步集成,包括使用 Windows 域用户的单点登录。

可以通过自定义的 LoginProviderHttpRequestFilter 或者 Web 登录规范描述的事件来实现自定义的登录机制。

还有,可以为 REST API 客户端启用 LDAP 认证:REST API 使用 LDAP 做认证

6.4.1. 基础 LDAP 集成

如果参数 cuba.web.ldap.enabled 设置为 true,则启用了 LdapLoginProvider。 这种情况下,使用 Spring LDAP 类库做用户认证。

下列 Web 客户端应用程序属性用来设置集成 LDAP:

Web 客户端的 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.4.2. 使用 Jespa 集成活动目录

Jespa 是用来集成活动目录和 Java 应用程序的 Java 类库,Jespa 使用 NTLMv2。参考 http://www.ioplex.com 了解更多细节。

6.4.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 模块创建一个 CubaAuthProvider 的实现类:

package com.company.sample.web;

import com.haulmont.cuba.core.global.AppBeans;
import com.haulmont.cuba.core.global.Configuration;
import com.haulmont.cuba.core.global.GlobalConfig;
import com.haulmont.cuba.core.global.Messages;
import com.haulmont.cuba.core.sys.AppContext;
import com.haulmont.cuba.security.global.LoginException;
import com.haulmont.cuba.web.auth.CubaAuthProvider;
import com.haulmont.cuba.web.auth.DomainAliasesResolver;
import jespa.http.HttpSecurityService;
import jespa.ntlm.NtlmSecurityProvider;
import jespa.security.PasswordCredential;
import jespa.security.SecurityProviderException;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import javax.inject.Inject;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

public class JespaAuthProvider extends HttpSecurityService implements CubaAuthProvider {

    private static class DomainInfo {
        private String bindStr;
        private String acctName;
        private String acctPassword;

        private DomainInfo(String bindStr, String acctName, String acctPassword) {
            this.acctName = acctName;
            this.acctPassword = acctPassword;
            this.bindStr = bindStr;
        }
    }

    private static Map<String, DomainInfo> domains = new HashMap<>();

    private static String defaultDomain;

    private Log log = LogFactory.getLog(getClass());

    @Inject
    private Configuration configuration;

    @Inject
    private Messages messages;

    @SuppressWarnings("deprecation")
    @Override
    public void init(FilterConfig filterConfig) 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", configuration.getConfig(GlobalConfig.class).getLogDir() + "/jespa.log");
        properties.put("http.parameter.anonymous.name", "anon");

        fillFromSystemProperties(properties);

        try {
            super.init(properties);
        } catch (SecurityProviderException e) {
            throw new ServletException(e);
        }
    }

    @Override
    public void destroy() {
    }

    @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);
    }

    @Override
    public void authenticate(String login, String password, Locale loc) throws LoginException {
        DomainAliasesResolver aliasesResolver = AppBeans.get(DomainAliasesResolver.NAME);

        String domain;
        String userName;

        int atSignPos = login.indexOf("@");
        if (atSignPos >= 0) {
            String domainAlias = login.substring(atSignPos + 1);
            domain = aliasesResolver.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 = aliasesResolver.getDomainName(domainAlias).toUpperCase();
        }

        userName = login;

        DomainInfo domainInfo = domains.get(domain);
        if (domainInfo == null) {
            throw new LoginException("Unknown domain: %s", domain);
        }

        Map<String, String> params = new HashMap<>();
        params.put("bindstr", domainInfo.bindStr);
        params.put("service.acctname", domainInfo.acctName);
        params.put("service.password", domainInfo.acctPassword);
        params.put("account.canonicalForm", "3");
        fillFromSystemProperties(params);

        NtlmSecurityProvider provider = new NtlmSecurityProvider(params);
        try {
            PasswordCredential credential = new PasswordCredential(userName, password.toCharArray());
            provider.authenticate(credential);
        } catch (SecurityProviderException e) {
            throw new LoginException("Authentication error: %s", e.getMessage());
        }
    }

    private void initDomains() {
        String domainsStr = AppContext.getProperty("cuba.web.activeDirectoryDomains");
        if (!StringUtils.isBlank(domainsStr)) {
            String[] strings = domainsStr.split(";");
            for (int i = 0; i < strings.length; i++) {
                String domain = strings[i];
                domain = domain.trim();
                if (!StringUtils.isBlank(domain)) {
                    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.bindStr : "";
    }

    public String getAcctName() {
        return getAcctName(getDefaultDomain());
    }

    public String getAcctName(String domain) {
        initDomains();
        DomainInfo domainInfo = domains.get(domain);
        return domainInfo != null ? domainInfo.acctName : "";
    }

    public String getAcctPassword() {
        return getAcctPassword(getDefaultDomain());
    }

    public String getAcctPassword(String domain) {
        initDomains();
        DomainInfo domainInfo = domains.get(domain);
        return domainInfo != null ? domainInfo.acctPassword : "";
    }

    public void fillFromSystemProperties(Map<String, String> params) {
        for (String name : AppContext.getPropertyNames()) {
            if (name.startsWith("jespa.")) {
                params.put(name, AppContext.getProperty(name));
            }
        }
    }
}
6.4.2.2. 安装和配置
  • 按步骤完成 Jespa Operator’s ManualInstallationStep 1: Create the Computer Account for NETLOGON Communication,参考 http://www.ioplex.com/support.html

  • local.app.properties 文件的 cuba.web.activeDirectoryDomains 属性里设置域参数。每个域的描述符应该按照这个格式:domain_name|full_domain_name|service_account_name|service_account_password。域描述符之间通过分号分隔。

    示例:

    cuba.web.activeDirectoryDomains = MYCOMPANY|mycompany.com|JESPA$@MYCOMPANY.COM|password1;TEST|test.com|JESPA$@TEST.COM|password2
  • 启用集成活动目录,通过设置 local.app.properties 文件的 cuba.web.externalAuthentication 属性:

    cuba.web.externalAuthentication = true
  • 配置 Jespa 授权 provider,通过设置 local.app.properties 文件的 cuba.web.externalAuthenticationProviderClass 属性,这个属性的值是 provider 类的全路径名称(Fully Qualified Name):

    cuba.web.externalAuthenticationProviderClass = com.company.sample.web.JespaAuthProvider
  • 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.5. CUBA 应用程序单点登录

CUBA 应用程序单点登录(SSO)允许用户只在浏览器的一个会话输入一次用户名和密码登录之后就可以访问多个运行的应用。

IDP 插件设计用来简化 CUBA 应用程序中的单点登录设置。参阅插件 文档 了解更多细节。

6.6. 社交网站登录

本章节提到的主要是使用 Facebook,Twitter 和 Google+这三个社交网络,依据网络情况,有些网址可能需要科学上网访问。

社交网站登录也是单点登录(SSO) 的一种形式,可以通过社交网站的账号(比如 Facebook,Twitter 或者 Google+)来登录 CUBA 系统,而不需要为 CUBA 应用程序创建特定的账号。

下面将使用 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

    同样也需要配置在 Facebook app 注册的 URL,填写在 coreweb 模块的应用程序属性文件的 cuba.webAppUrl 参数。示例:

    cuba.webAppUrl = http://cuba-fb.test:8080/app
  2. 扩展 登录界面 添加社交网络登录按钮。这个按钮将调用 loginFacebook() 方法 - 社交网络登录流程的入口。

  3. 为了使用 Facebook 用户账号,需要在 CUBA 标准的用户账号中添加一个额外字段。扩展 User 实体并添加字符串类型的属性 facebookId

    @Column(name = "FACEBOOK_ID")
    protected String facebookId;
  4. 创建服务,根据提供的 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 Metadata metadata;
    
        @Inject
        private Persistence persistence;
    
        @Inject
        private Configuration configuration;
    
        @Override
        @Transactional
        public User findOrRegisterUser(String facebookId, String email, String name) {
            EntityManager em = persistence.getEntityManager();
    
            TypedQuery<SocialUser> query = em.createQuery("select u from sec$User u where u.facebookId = :facebookId",
                    SocialUser.class);
            query.setParameter("facebookId", facebookId);
            query.setViewName(View.LOCAL);
    
            SocialUser existingUser = query.getFirstResult();
            if (existingUser != null) {
                return existingUser;
            }
    
            SocialRegistrationConfig config = configuration.getConfig(SocialRegistrationConfig.class);
    
            Group defaultGroup = em.find(Group.class, config.getDefaultGroupId(), View.MINIMAL);
    
            SocialUser user = metadata.create(SocialUser.class);
            user.setFacebookId(facebookId);
            user.setEmail(email);
            user.setName(name);
            user.setGroup(defaultGroup);
            user.setActive(true);
            user.setLogin(email);
    
            em.persist(user);
    
            return user;
        }
    }
  5. 创建服务来管理登录过程。本示例中是: FacebookService 包含两个方法: getLoginUrl()getUserData()

    • getLoginUrl() 生成登录 URL,基于应用程序 URL 和 OAuth2 返回类型(代码、访问令牌(access token)或者两者都有,参考 Facebook API 文档 了解更多返回类型)。这个方法的实现可以参考 FacebookServiceBean.java 文件。

    • getUserData() 使用提供的应用程序 URL 和代码来查找 Facebook 用户,并且返回已有用户的数据或者创建新用户。在这个例子中,希望获取用户的 id,姓名和 email,id 也就是上面创建的 facebookId

  6. core 模块的 app.properties 文件中定义 facebook.fieldsfacebook.scope 应用程序属性:

    facebook.fields = id,name,email
    facebook.scope = email
  7. 返回扩展登录窗口控制器的 loginFacebook() 方法。这个控制器的所有代码在 ExtAppLoginWindow.java 文件。

    在这个方法中,有针对当前会话的请求处理(request handler),保存当前 URL 并且调用重定向到 Facebook 认证表单:

    private RequestHandler facebookCallBackRequestHandler =
            this::handleFacebookCallBackRequest;
    
    private URI redirectUri;
    
    @Inject
    private FacebookService facebookService;
    
    @Inject
    private GlobalConfig globalConfig;
    
    public void loginFacebook() {
        VaadinSession.getCurrent()
            .addRequestHandler(facebookCallBackRequestHandler);
    
        this.redirectUri = Page.getCurrent().getLocation();
    
        String loginUrl = facebookService.getLoginUrl(globalConfig.getWebAppUrl(), OAuth2ResponseType.CODE);
        Page.getCurrent()
            .setLocation(loginUrl);
    }

    handleFacebookCallBackRequest() 方法会处理 Facebook 认证表单之后的函数回调。首先,使用 UIAccessor 实例来锁住 UI 直到登录请求处理完毕。

    然后,FacebookService 会获取 facebook 用户账号的 email 和 id。在这之后,相应的 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");
    
                    FacebookUserData userData = facebookService.getUserData(globalConfig.getWebAppUrl(), code);
    
                    User user = socialRegistrationService.findOrRegisterUser(
                            userData.getId(), userData.getEmail(), userData.getName());
    
                    App app = App.getInstance();
                    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 登录 章节提到的事件来实现定制化登录机制。

7. Cookbook

本节包含一些实际操作的例子,手册中其它地方没有找到合适的章节来介绍这些例子,也(还)没有包含在 向导 中。

7.1. 建立业务模型

在本节中,可以找到数据模型设计和使用实体属性的方法。

7.1.1. 分配初始值

有多种方式可以为新实体实例的属性分配初始值。

7.1.1.1. 初始化实体字段

简单属性 ( BooleanInteger 等。) 和枚举可以在实体类的相应字段的声明中初始化,参考在 Customer.java 中的 activegrade 字段示例。

此外,可以在实体类中创建具有 @PostConstruct 注解的特定初始化方法。此时,在初始化期间可以调用任何全局基础设施接口beans,参考在 Customer.java 示例中的 init() 方法。

7.1.1.2. 使用 CreateAction 进行初始化

如果属性的初始值取决于调用界面的数据,可以使用 CreateAction 类的 setInitialValues()setInitialValuesSupplier() 方法。

请参考在演示应用程序中处理 CustomerCustomerAddress 实体的示例:

init values 1
  • customer-address-browse.xml - 包含两个关联表格的界面描述,一个用于客户,另一个用于其地址。

  • CustomerAddressBrowse.java - 界面控制器。在其 init() 方法中,使用 setInitialValuesSupplier() 为新建地址的 customer 属性提供初始值,设置为第一个表格中当前选中的客户。

7.1.1.3. 使用 initNewItem 方法

初始值也可以在新建实体的界面控制器的 initNewItem() 方法中定义。

考虑如下实体:

composition recipe 3

在演示应用程序中,CustomerDetails 的属性 infoCustomer 实体自身的属性在相同的界面上编辑。需要跟所属的 Customer 实例一起创建 CustomerDetails 实例。

  • customer-edit.xml - 客户编辑界面描述。包含一个链接到 CustomerDetails 实例的嵌套数据源。infoField 文本区组件连接到此数据源。

  • CustomerEdit.java - 界面控制器。定义了 initNewItem() 方法,该方法创建了一个新的 CustomerDetails 实例并将其设置给新的 Customer。创建的实例可通过嵌套数据源访问,并在提交界面后保存到数据库中。

7.1.2. 组合结构

CUBA 框架支持实体之间的两种关系类型:关联和组合。在 CUBA Studio 界面它们分别被称为 ASSOCIATION 和 COMPOSITION。关联是可以彼此独立存在的对象之间的关系。另外,当明细实例仅能作为主实例的一部分存在时,这种 "主从" 关系可以使用组合而不是关联。比如,机场及其航站楼可以被视为组合关系的实例:不属于任何机场的航站楼没有任何意义。

通常,属于组合关系的实体会被一起编辑,这样更自然。例如,用户打开机场编辑界面并查看航站楼列表,这样用户可以创建和编辑它们,此时,机场和航站楼的所有更改都会在一次事务中一起保存到数据库中,并且仅在用户确认保存主实体后(机场)。

7.1.2.1. 一对多:一级嵌套

让我们以 AirportTerminal 实体实现一对多组合示例:

composition recipe 1
  • Terminal.java - Terminal 实体包含指向 Airport 的必要链接。

    在 Studio 实体设计器中,设置 airport 属性: Attribute type - ASSOCIATION,Cardinality - MANY_TO_ONE,Mandatory - ON。

  • Airport.java - Airport 实体包含一对多的航站楼集合。相应的字段使用 @Composition 注解以实现组合关系,而 @OnDelete 注解用于级联软删除。

    在 Studio 实体设计器中,设置 terminals 属性: Attribute type - COMPOSITION,Cardinality - ONE_TO_MANY,On delete - CASCADE。

  • views.xml - 机场编辑界面的 airport-terminals 视图包含 terminals 集合属性。我们为此属性使用 _local 视图,因为 Terminal 实体的 airport 属性只在创建新的 Terminal 实例时设置并且在此之后永远不会改变,所以我们不需要加载它。

  • airport-edit.xml - 机场编辑界面的 XML 描述定义了用于 Airport 实例的数据源和用于其航站楼的嵌套实例,它还包含一个显示航站楼的表格。

  • terminal-edit.xml - 一个 Terminal 实体的标准编辑界面。

因此,机场实例的编辑工作如下:

  • 机场编辑界面显示航站楼列表。

  • 用户可以选择航站楼并打开其编辑界面。当在航站楼编辑界面中点击 OK 时,航站楼的更新实例不会保存到数据库,而是保存到机场编辑界面的 terminalsDs 数据源。

  • 用户可以创建新的航站楼和删除现有航站楼。所有改变都将保存到 terminalsDs 数据源。

  • 当用户在机场编辑界面点击 OK 时,更新的 Airport 实例与所有更新的 Terminal 实例将一起被提交到中间层的 DataManager.commit() 方法并使用单个事务保存到数据库中。

7.1.2.2. 一对多:二级嵌套

组合关系可以更深,但是最多到两个嵌套级别,我们通过添加用于描述航站楼汇合点的 MeetingPoint 实体来扩展前一个示例:

composition recipe 2

Terminal 实体包含 meetingPoints 属性 – 一个 MeetingPoint 实例的集合。为了使所有三个实体成为一个组合并一起编辑,除了之前描述的步骤外,还应完成以下步骤:

  • Terminal.java - Terminal 类的 meetingPoints 属性带有 @Composition@OnDelete 注解,跟 Airport 类的 terminals 属性类似。

  • views.xml - Terminal 类的 terminal-meetingPoints-view 视图包含 meetingPoints 集合属性。该视图在 Airport 实体的 airport-terminals-meetingPoints-view 视图中使用。

  • airport-edit.xml - Airport 编辑界面 XML 描述,包含 Airport 的实例的数据源和整个组合(airportDs > terminalsDs > meetingPointsDs)的嵌套实体。

    这里,meetingPointsDs 数据源不与任何可视化组件关联,但是用于正确处理组合的联合编辑。

  • terminal-edit.xml - 航站楼编辑界面 XML 描述,包含嵌套数据源以及对应于 meetingPoints 集合的表格。

这样,更新的 MeetingPoint 实例以及 Terminal 实例将仅与 Airport 实例在同一事务中一起保存到数据库。

7.1.2.3. 一对多:三级嵌套

假设需要添加包含汇合点的一些细节信息的附加实体:Note。现在整个结构如下: Airport > Terminal > Meeting Point > Note

composition recipe 4

CUBA 可以处理最多两层嵌套的组合。这里我们有三个级别,所以我们应该从顶部或底部限制深度。下面我们考虑从组合中移除机场的两种不同方法(从用户体验的角度来看)。此时有个问题,由于移除机场后,航站楼将会独立于机场保存到数据库中,因此无法为尚未保存到数据库的新建机场保存航站楼。两种方法都能解决这个问题:

  • 在第一种方法中,机场浏览界面和编辑界面看起来与上面相同,但是编辑界面有额外的 Save 按钮用来保存新机场而不关闭界面。在保存新机场前,用户无法为新机场创建航站楼。

    • airport-edit.xml 包含航站楼的独立数据源,而不是嵌套数据源。该独立数据源链接到机场数据源,这样可以为被编辑的机场加载航站楼。此外,机场编辑界面包含 extendedEditWindowActions 界面子框架,允许用户保存机场而不关闭界面。

    • AirportEdit.java - 这里,在机场编辑界面的 postInit() 方法内,我们控制航站楼的 Create 操作是否启用,然后传递当前机场实例用来初始化新建航站楼的机场属性。

  • 在第二种方法中,我们将机场浏览界面拆分为两个面板:一个用于机场列表,另一个用于展示该机场依赖的航站楼列表。航站楼列表现在在机场编辑界面之外。选择机场后,才会启用航站楼的 Create 操作。

    • airport-browse.xml 包含航站楼列表的独立数据源。它链接到机场数据源,这样可以为所选机场加载航站楼。

    • AirportBrowse.java - 在机场浏览界面控制器的 init() 方法中,控制航站楼的 Create 操作是否启用,然后传递当前机场实例用来初始化新建航站楼的机场属性。

7.1.2.4. 一对一组合

一对一组合将通过 CustomerCustomerDetails 实体说明:

composition recipe 3
  • Customer.java - Customer 实体包含一个到 CustomerDetails 实体的引用属性,并且使用了 @Composition 注解。

  • CustomerDetails.java - CustomerDetails 实体。

  • customer-edit.xml - 客户编辑界面描述。包含指向 CustomerDetails 实例的嵌套数据源。为了加载嵌套实例,根数据源使用了一个包含 details 属性的 Customer 实体的视图。客户编辑界面中的字段组仅定义一个用于显示 details 属性的字段。

这样,客户编辑的工作方式如下:

composition recipe oto 1
  • 当调用打开操作时,会创建一个新的 CustomerDetails 实例并显示其编辑界面。在详细信息编辑界面中点击 OK 时,详细信息实例不会保存到数据库中,而是保存到客户编辑界面的 detailsDs 数据源中。

  • 选择器字段显示细节实体的实例名

composition recipe oto 2
  • 当用户在客户编辑界面中点击 OK 时,更新后的 Customer 实例与 CustomerDetails 实例一起被提交到中间层的 DataManager.commit() 方法并使用单个事务保存到数据库中。

  • 如果用户调用选择器字段的清除操作,则会删除 CustomerDetails 实例,并在用户提交客户编辑界面后的同一事务中清除对该实例的引用。

7.1.2.5. 用单一编辑界面处理一对一组合

在一个编辑界面中编辑 One-to-One 组合通常很方便。我们看看以下示例如何实现编辑 CustomerCustomerDetails 一对一关系。

  • customer-edit.xml 描述包含主数据源 customerDs 和嵌套数据源 detailsDs

    <dsContext>
        <datasource id="customerDs"
                    class="sample.entity.Customer"
                    view="customer-view">
            <datasource id="detailsDs"
                        property="details"/>
        </datasource>
    </dsContext>

    两个实体需要编辑的所有字段都被分到同一个 fieldGroup,其中一些字段被绑定到嵌套数据源:

    <fieldGroup id="customerGroup"
                datasource="customerDs">
        <column width="200px">
            <field property="name"/>
            <field property="email"/>
            <field datasource="detailsDs"
                   property="address"
                   rows="3"/>
            <field datasource="detailsDs"
                   property="note"
                   rows="3"/>
        </column>
    </fieldGroup>
  • CustomerEdit 控制器中重写 initNewItem() 方法,创建一个 CustomerDetails 实例,并将此示例关联到刚创建的 Customer 实例:

    @Inject
    private Metadata metadata;
    
    @Override
    protected void initNewItem(Customer customer) {
        customer.setDetails(metadata.create(CustomerDetails.class));
    }

    最后,处理用户单击 Create 然后想要关闭编辑界面而不修改任何内容的情况。该用户将被要求保存更改,因为 detailsDs 数据源已经包含一个空实例,AbstractEditorisModified() 方法将始终返回 true。为避免出现确认对话框,应该让 isModified() 方法仅关注主数据源中的更改:

    @Override
    public boolean isModified() {
        return customerDs.isModified();
    }

现在,可以在一个编辑界面界面中创建和编辑两个关联的实体。

7.2. 使用数据库

本节提供有关在应用程序开发环境和生产环境使用数据库的实用建议。

有关使用特定 DBMS 的配置参数的信息,请参阅数据库组件部分。

7.2.1. 创建数据库架构

在应用程序开发过程中,需要创建和维护与模型实体对应的数据库架构。平台提供了一种基于数据库创建和更新脚本的方法来完成此任务。下面介绍使用这种方法的实际步骤。

创建和维护数据库架构的任务包括两部分:创建脚本和执行脚本。

可以手动创建脚本,也可以使用 Studio 创建脚本。下面介绍在 Studio 中创建脚本的过程。在 CUBA 主菜单或者 Data Model 右键菜单中运行 Generate Database Scripts 命令。这时,Studio 会连接到 Project Properties 窗口中定义的数据库,并将可用的 DB 架构与当前数据模型进行比较。

如果数据库不存在或没有 SYS_DB_CHANGELOGSEC_USER 表,则系统仅生成数据库初始化脚本。否则,也会创建更新脚本。脚本生成后,会打开含有生成脚本的窗口。

更新脚本显示在 Updates 标签页上。状态是 new 的脚本反映数据模型的当前状态与 DB 架构之间的差异。对每个新表或修改的表会创建单独的脚本。某些脚本还包含一组参照完整性约束。单击 Save and close 关闭界面时,脚本将保存在 core 模块的 db/update/{db_type} 目录中。

项目中存在并且已应用到 DB 的脚本将显示为 applied 状态。这种脚本不能被修改或删除。

Updates 标签页还会显示出 to be deleted 状态的脚本。这些脚本是项目中有效脚本,但没有应用到 DB。单击 Save and close 关闭界面时,这些脚本会被删除。如果脚本由之前的脚本生成过程创建,但没有通过调用 Update Database 将脚本应用到 DB,这是标准行为。在这种情况下,不再需要这些脚本,因为当前数据库架构和数据模型之间的差异反映在最新生成的脚本中。但是,如果脚本由其它开发人员编写并从版本控制系统获取,则应取消保存并先应用这些脚本到数据库,然后再生成新脚本。

Init TablesInit ConstraintsInit Data 标签页中显示位于 core 模块的 db/init/{db_type} 目录中的 DB 创建脚本。

Init Tables 标签页显示用于创建表的 10.create-db.sql 脚本。同一个表相关的代码由 begin {table_name} ... end {table_name} 注释包围起来。当模型中的实体发生更改时,Studio 仅替换注释之间(begin …​ end 之间)的相应表的代码,而保留其余代码。这样,如果这个文件中有手工编写的代码,也不会被覆盖。因此,请勿在手工编辑代码时删除这些注释,否则 Studio 将无法将更改正确地应用到现有文件。

Init Constraints 标签页显示用于创建完整性约束的 20.create-db.sql 脚本。同样,它也有不应被删除的用于分隔表的注释。

Init Data 标签页显示 30.create-db.sql 脚本,该脚本用于在初始化 DB 时提供额外信息。这些额外的信息可以是函数、触发器、DML 操作或必要的数据库初始数据。如有必要,可手工创建此脚本的内容。

在应用程序开发的初始阶段,数据模型更改比较频繁,建议仅使用 DB 创建脚本(位于 Init TablesInit ConstraintsInit Data 标签页中)并在调用 Generate DB scripts 命令后立即移除 Updats 标签页中的更新脚本。这是使数据库保持最新状态的最简单可靠的方法。当然,它有一个明显的缺点,那就是应用这些脚本会从头开始重新创建数据库,所有数据都会丢失。在开发阶段可以通过向 Init Data 脚本添加命令(填充数据的脚本)从一定程度上弥补这一缺陷,该脚本可以在初始化时填充一些基本的数据。

更新脚本会成为后期开发和维护数据库的方便又必要的工具,当数据模型相对稳定时,开发和生产数据库中的数据不会因为从头开始重新创建数据库而丢失。

使用Gradle 任务执行数据库脚本来应用脚本:调用 CUBA > Create Database 重新创建数据库,调用 CUBA > Update Database 应用脚本;Data Model 的右键菜单也有同样的命令。请注意,只有应用程序服务停止时,这些操作才可用。当然,也可以随时从命令行调用相应的 Gradle 任务( createDbupdateDb),但如果数据库或其任何数据库对象被锁定,脚本执行可能会失败。

7.2.2. MS SQL Server 特性

Microsoft SQL Server 使用表的聚集索引。

默认情况下,聚集索引以表的主键为基础,但是,CUBA 应用程序使用的 UUID 类型的键不适合聚集索引。建议使用 nonclustered 修饰符创建 UUID 主键:

create table SALES_CUSTOMER (
    ID uniqueidentifier not null,
    CREATE_TS datetime,
    ...
    primary key nonclustered (ID)
)^

7.2.3. Oracle 数据库特性

由于 Oracle JDBC 驱动程序的分发策略限制,只能从 http://www.oracle.com/technetwork/database/features/jdbc/index-091264.html 手动下载。

下载后,将 ojdbc6.jar 文件复制到 CUBA Studio 安装路径的 lib 子目录和已安装的 Tomcat 服务的 lib 子目录。然后停止 Studio,通过在命令行中执行

gradle --stop

来停止 Gradle 守护进程,然后重启 Studio。

对于 SE 版本,将 JAR 文件复制到 ~/.haulmont/studio/lib/ 目录和已安装的 Tomcat 服务的 lib 子目录。

7.2.4. MySQL 数据库特性

由于许可限制,MySQL JDBC 驱动程序不随 CUBA Studio 一起分发,因此应该执行以下操作:

  • https://dev.mysql.com/downloads/connector/j 下载驱动程序存档文件

  • 解压缩 JAR 文件并将其重命名为 mysql-connector-java.jar

  • 将 JAR 文件复制到 Program Files/Haulmont/CUBA Studio 2018.3/plugins/cuba-studio/lib/ 目录以及已安装的 Tomcat 服务的 lib 子目录

  • 通过在命令行中执行 gradle --stop 停止 Gradle 守护进程,然后再次启动 Studio

对于不使用 UTF-8 字符集的数据库,不能执行框架中带有约束的数据脚本。此时需要修改数据库的 CharsetCollation name 属性,可以通过在项目属性的 Connection params 字段传递以下参数实现:

?useUnicode=true&characterEncoding=UTF-8

MySQL 不支持部分索引(partial indexes),因此为支持软删除的实体实现唯一约束的唯一方法是在索引中使用 DELETE_TS 列。但是还有另一个问题:MySQL 在具有唯一约束的列中允许存在多个 NULL 值。由于标准 DELETE_TS 列可以为 null,因此它不能在唯一索引中使用。建议使用以下变通方案为支持软删除的实体创建唯一约束:

  1. 在数据库表中创建一个 DELETE_TS_NN 列。此列不为空,并被初始化为默认值:

    create table DEMO_CUSTOMER (
        ...
        DELETE_TS_NN datetime(3) not null default '1000-01-01 00:00:00.000',
        ...
    )
  2. 创建一个触发器,当更改 DELETE_TS 值时将更改 DELETE_TS_NN 值:

    create trigger DEMO_CUSTOMER_DELETE_TS_NN_TRIGGER before update on DEMO_CUSTOMER
    for each row
        if not(NEW.DELETE_TS <=> OLD.DELETE_TS) then
            set NEW.DELETE_TS_NN = if (NEW.DELETE_TS is null, '1000-01-01 00:00:00.000', NEW.DELETE_TS);
        end if
  3. 创建一个唯一索引,包括唯一列和 DELETE_TS_NN

    create unique index IDX_DEMO_CUSTOMER_UNIQ_NAME on DEMO_CUSTOMER (NAME, DELETE_TS_NN)

7.2.5. 连接到非默认数据库架构

PostgreSQL 和 Microsoft SQL Server 支持连接到指定的数据库架构。默认情况下,PostgreSQL 会连接到 public,SQL Server 会连接到 dbo

PostgreSQL

要在 PostgreSQL 上使用非默认架构,请在createDbconnectionParams 属性和 updateDb Gradle 任务中指定 currentSchema 参数,例如:

task createDb(dependsOn: assembleDbScripts, type: CubaDbCreation) {
    dbms = 'postgres'
    host = 'localhost'
    dbName = 'my_db'
    connectionParams = '?currentSchema=my_schema'
    dbUser = 'cuba'
    dbPassword = 'cuba'
}

如果正在使用 Studio,请将此连接参数添加到 Project properties 窗口的 Connection params 字段。Studio 会自动更新 build.gradle。之后,可以更新或重新创建数据库,所有表将在指定的架构下创建。

Microsoft SQL Server

在 Microsoft SQL Server 上,仅提供连接属性是不够的,必须将架构与数据库用户相关联。下面是创建一个新数据库并使用非默认架构的示例。

  • 创建一个登录用户:

    create login JohnDoe with password='saPass1'
  • 创建一个新数据库:

    create database my_db
  • sa 身份连接到新数据库,创建架构,然后创建用户并授予其所有者权限:

    create schema my_schema
    
    create user JohnDoe for login JohnDoe with default_schema = my_schema
    
    exec sp_addrolemember 'db_owner', 'JohnDoe'

现在,应该在 updateDb Gradle 任务(或 Studio 项目属性)的 connectionParams 属性中指定 currentSchema 参数。实际上,此属性不是由 SQL Server JDBC 驱动程序处理的,但它会告诉 Studio 和 CUBA Gradle 插件使用什么架构。

task updateDb(dependsOn: assembleDbScripts, type: CubaDbUpdate) {
    dbms = 'mssql'
    dbmsVersion = '2012'
    host = 'localhost'
    dbName = 'my_db'
    connectionParams = ';currentSchema=my_schema'
    dbUser = 'JohnDoe'
    dbPassword = 'saPass1'
}

请注意,由于 SQL Server 的特性 - 非默认架构需要与用户关联,所以无法从 Studio 或在命令行中执行 createDb 来重新创建 SQL Server 数据库。但是,如果在 Studio 中运行 Update database 或在命令行中运行 updateDb,则现有数据库中指定架构下所有必须的表都会被创建。

7.2.6. 在生产环境中创建和更新数据库

本节介绍在应用程序部署和运行期间创建和更新数据库的几种方法。要了解有关数据库脚本结构和规则的更多信息,请参阅创建和更新数据库的脚本创建数据库架构

7.2.6.1. 在服务器上执行数据库脚本

服务器执行数据脚本机制可用于初始化数据库及后续对应用程序开发期间发生的数据库架构调整进行更新。

这里的“服务器”指的是生产环境的服务器

按照以下操作完成对新数据库的初始化:

  • 通过将以下行添加到中间件 block 的local.app.properties文件中以启用cuba.automaticDatabaseUpdate应用程序属性:

    cuba.automaticDatabaseUpdate = true

    对于快速部署的 Tomcat,该文件位于 tomcat/conf/app-core 目录中。如果该文件不存在,请创建它。

  • 创建一个与context.xml中的数据源描述中指定的 URL 对应的空数据库。

  • 启动包含中间件 block 的应用程序服务。在应用程序启动时,数据库将被初始化并准备就绪。

之后,每次应用程序服务启动时,脚本执行机制都会将位于数据库脚本目录中的脚本与在数据库中注册的已执行脚本列表进行比较。如果找到新脚本,新脚本将被执行并注册。典型情况下,在每个新的应用程序版本中包含更新脚本就足够了,数据库会在每次应用程序重新启动的时候进行更新。

在服务启动时使用数据库脚本执行机制时,应考虑以下事项:

  • 如果在运行脚本时发生任何错误,则中间件 block 将停止初始化并变得不可用。客户端 block 会生成关于无法连接到中间件的错误提示消息。

    检查位于服务器日志文件夹中的 app.log 文件,从 com.haulmont.cuba.core.sys.DbUpdaterEngine 日志中获取关于 SQL 执行的消息,可能会有其它可以用来识别错误原因的错误消息。

  • 更新脚本和脚本中用 "^" 分隔的 DDL 和 SQL 命令一样在单独的事务中执行。这就是为什么当更新失败时,仍然很有可能一部分脚本甚至最后一个脚本的个别命令已被执行并提交给数据库。

    考虑到这一点,强烈建议在启动服务之前创建数据库的备份。然后,更新脚本的错误得到修复时,可以恢复数据库并重新进行数据库更新。

    如果没有进行备份,则应在错误修复后确定脚本的哪些部分已被执行并已提交。如果整个脚本执行失败,则简单地重启服务并运行自动更新即可。如果错误出现之前的一些用 "^" 字符分隔的命令已在单独的事务中执行并且提交,这种情况下只需运行脚本中剩余未执行的命令,同时手动在 SYS_DB_CHANGELOG 中注册这个手动执行的脚本。之后,可以启动服务,自动更新机制将继续处理下一个未执行的脚本。

    CUBA Studio 为所有数据库类型生成带有 ";" 分隔符的更新脚本,除了 Oracle。如果更新脚本命令由分号分隔,则脚本在一个事务中执行,并在发生错误时完全回滚。此行为可确保数据库架构与已执行的更新脚本列表之间的一致性。

7.2.6.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 部分会被忽略同时输出相应信息到控制台。

7.2.7. 使用 Squirrel SQL 连接 HSQLDB

HSQLDB,也称为 HyperSQL,是一种简单方便的 DBMS,一般用于应用程序原型,这种数据库不需要安装,并且如果项目已配置为使用此 DBMS,那么它会在 CUBA Studio 中自动启动。本节介绍使用外部工具连接到 HSQLDB 的方法,该工具允许直接通过 SQL 处理数据库结构和数据。

SquirreL SQL Client 是一个开源 Java 应用程序,能够通过 JDBC 处理数据库。可以从这里下载 Squirrel SQL: http://squirrel-sql.sourceforge.net

在启动 Squirrel SQL 之前,在 CUBA Studio 安装路径的 lib 文件夹中找到 hsqldb-x.x.x.jar 文件,并将其复制到 Squirrel SQLlib 文件夹中。

启动 Squirrel SQL 并定位到 Drivers。确保 HSQLDB Server 驱动处于活动状态。

打开 Aliases 标签页,然后单击 Create a new Alias 按钮。

在出现的窗口中填写连接属性:数据库 URL、用户名和密码。默认用户名为 sa,密码为空。可以从 CUBA 项目视图中的 Project properties 部分或应用程序项目的 modules/core/web/META-INF/context.xml 文件中复制数据库 URL。

db hsql setAliasProperties

7.3. 获取本地化消息

本节介绍在应用程序的不同部分中获取本地化消息的方法。

  • 在界面 XML 描述中,用于显示静态文本的组件属性(例如 caption)可以使用MessageTools.loadString()方法的规则来处理本地化消息。例如:

    • caption ="msg://roleName" - 获取当前界面的消息包中 roleName 键定义的消息。界面消息包由 XML 描述中根 window 元素的 messagesPack 属性定义。

    • caption ="msg://com.company.sample.entity/Role.name" - 获取由 com.company.sample.entity 消息包中的 Role.name 键定义的消息。

  • 界面控制器中,可以通过以下方式检索本地化字符串:

    • 从当前界面消息包:

      • 使用继承自 AbstractFrame 基类的 getMessage() 方法。例如:

    String msg = getMessage("warningMessage");
    • 使用继承自 AbstractFrame 基类的 formatMessage() 方法。此时,按照 String.format() 方法定义的规则使用提取的消息对提交的参数进行格式化。例如:

      messages.properties:

      warningMessage = Invalid email address: '%s'

      Java 控制器:

      String msg = formatMessage("warningMessage", email);
      • 使用注入的 Messages 基础接口从各种消息包获取消息。例如:

        @Inject
        private Messages messages;
        
        @Override
        public void init(Map<String, Object> params) {
            String msg = messages.getMessage(getClass(), "warningMessage");
            ...
        }
  • 对于由 Spring 容器管理的组件(托管 beanservicesJMX-beansportal 模块的 Spring MVC 控制器),本地化消息可以使用注入的Messages 基础接口进行检索:

    @Inject
    protected Messages messages;
    ...
    String msg = messages.getMessage(getClass(), "warningMessage");

    portal 模块中 Thymeleaf 模板的本地化消息也可以通过 portal 模块的主消息包中的消息键获得:

    模板
    <h1 th:text="#{messageKey}"></h1>
    portal 主消息包
    messageKey = Localized message
  • 在无法进行注入的应用程序代码中,可以使用 AppBeans 类的静态 get() 方法获取 Messages 接口:

    protected Messages messages = AppBeans.get(Messages.class);
    ...
    String msg = messages.getMessage(getClass(), "warningMessage");

7.4. 加载和显示图片

我们看看加载、存储和显示员工图片的任务:

  • 员工由 Employee 实体表示。

  • 图片文件存储在 FileStorage 中。Employee 实体包含指向相应 FileDescriptor 的链接。

  • Employee 编辑界面显示图片,还支持上传,下载和清除图片。

带有图片文件链接的实体类:

@Table(name = "SAMPLE_EMPLOYEE")
@Entity(name = "sample$Employee")
public class Employee extends StandardEntity {
...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "IMAGE_FILE_ID")
    protected FileDescriptor imageFile;

    public void setImageFile(FileDescriptor imageFile) {
        this.imageFile = imageFile;
    }

    public FileDescriptor getImageFile() {
        return imageFile;
    }
}

同时加载 EmployeeFileDescriptor视图需要包含 FileDescriptor 的所有本地属性:

<view entity="demo_Employee"
      name="employee-edit"
      extends="_local">
    <property name="imageFile"
              view="_local"/>
</view>

Employee 编辑界面 XML 描述的一个片段:

<groupBox caption="Photo" spacing="true"
          height="300px" width="300px" expand="image">
    <image id="image"
           width="100%"
           align="MIDDLE_CENTER"
           scaleMode="CONTAIN"/>
    <hbox align="BOTTOM_LEFT"
          spacing="true">
        <upload id="uploadField"/>
        <button id="downloadImageBtn"
                caption="Download"
                invoke="onDownloadImageBtnClick"/>
        <button id="clearImageBtn"
                caption="Clear"
                invoke="onClearImageBtnClick"/>
    </hbox>
</groupBox>

用于显示、上传和下载图片的组件包含在 groupBox 容器中。容器顶部使用 image 组件显示一张图片,而它的底部从左到右包含上传控件以及下载和清除图片的按钮。因此,界面的这一部分如下所示:

images recipe

现在,现在我们来看看编辑界面控制器

import com.company.sales.entity.Employee;
import com.haulmont.cuba.core.entity.FileDescriptor;
import com.haulmont.cuba.core.global.DataManager;
import com.haulmont.cuba.core.global.FileStorageException;
import com.haulmont.cuba.gui.Notifications;
import com.haulmont.cuba.gui.components.Button;
import com.haulmont.cuba.gui.components.FileDescriptorResource;
import com.haulmont.cuba.gui.components.FileUploadField;
import com.haulmont.cuba.gui.components.Image;
import com.haulmont.cuba.gui.export.ExportDisplay;
import com.haulmont.cuba.gui.export.ExportFormat;
import com.haulmont.cuba.gui.model.InstanceContainer;
import com.haulmont.cuba.gui.screen.*;
import com.haulmont.cuba.gui.upload.FileUploadingAPI;

import javax.inject.Inject;

@UiController("sales_Employee.edit")
@UiDescriptor("employee-edit.xml")
@EditedEntityContainer("employeeDc")
@LoadDataBeforeShow
public class EmployeeEdit extends StandardEditor<Employee> {

    @Inject
    private InstanceContainer<Employee> employeeDc;
    @Inject
    private Image image;
    @Inject
    private FileUploadField uploadField;
    @Inject
    private FileUploadingAPI fileUploadingAPI;
    @Inject
    private DataManager dataManager;
    @Inject
    private Notifications notifications;
    @Inject
    private Button downloadImageBtn;
    @Inject
    private Button clearImageBtn;

    @Subscribe
    protected void onInit(InitEvent event) { (1)
        uploadField.addFileUploadSucceedListener(uploadSucceedEvent -> {
            FileDescriptor fd = uploadField.getFileDescriptor(); (2)
            try {
                fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd);
            } catch (FileStorageException e) {
                throw new RuntimeException("Error saving file to FileStorage", e);
            }
            getEditedEntity().setImageFile(dataManager.commit(fd)); (3)
            displayImage(); (4)
        });

        uploadField.addFileUploadErrorListener(uploadErrorEvent ->
                notifications.create()
                        .withCaption("File upload error")
                        .show());

        employeeDc.addItemPropertyChangeListener(employeeItemPropertyChangeEvent -> { (5)
            if ("imageFile".equals(employeeItemPropertyChangeEvent.getProperty()))
                updateImageButtons(employeeItemPropertyChangeEvent.getValue() != null);
        });
    }

    @Subscribe
    protected void onAfterShow(AfterShowEvent event) { (6)
        displayImage();
        updateImageButtons(getEditedEntity().getImageFile() != null);
    }

    public void onDownloadImageBtnClick() { (7)
        if (getItem().getImageFile() != null)
            exportDisplay.show(getItem().getImageFile(), ExportFormat.OCTET_STREAM);
    }

    public void onClearImageBtnClick() { (8)
        getEditedEntity().setImageFile(null);
        displayImage();
    }

    private void updateImageButtons(boolean enable) {
        downloadImageBtn.setEnabled(enable);
        clearImageBtn.setEnabled(enable);
    }

    private void displayImage() { (9)
        if (getEditedEntity().getImageFile() != null) {
            image.setSource(FileDescriptorResource.class).setFileDescriptor(getEditedEntity().getImageFile());
            image.setVisible(true);
        } else {
            image.setVisible(false);
        }
    }
}
1 onInit() 方法首先初始化用于上传新图片的 uploadField 组件。
2 在成功上传的情况下,从组件获取新的 FileDescriptor 实例,并通过调用 FileUploadingAPI.putFileIntoStorage() 将相应的文件从临时客户端存储发送到 FileStorage
3 之后,通过调用 DataManager.commit()FileDescriptor 保存到数据库中,并将保存的实例分配给 Employee 实体的 imageFile 属性。
4 然后,调用控制器的 displayImage() 方法显示上传的图片。
5 之后,将一个监听器添加到包含 Employee 实例的数据容器中。监听器根据文件加载的状态启用或禁用下载和清除按钮。
6 onAfterShow() 方法根据加载的文件是否存在来决定是否显示图片并且更新按钮状态。
7 点击 downloadImageBtn 按钮时调用 onDownloadImageBtnClick(),使用 ExportDisplay 接口下载文件。
8 点击 clearImageBtn 时调用 onClearImageBtnClick();它只是清除了 Employee 实体的 imageFile 属性。该文件不会从存储中删除。
9 displayImage() 方法从存储中加载文件并设置 image 组件的内容。

7.4.1. 在表格列中显示图片

为了扩展上一个任务,我们在 Employee 浏览界面上将图片作为员工头像添加到表格中。

图片可以单独显示在某列,也可以跟其它内容一起显示在现有的列中。这两种情况都使用 Table.ColumnGenerator 接口。

下面是 Employee 浏览界面 XML 描述的一个片段:

<groupTable id="employeesTable"
            width="100%"
            dataContainer="employeesDc">
    <actions>
        <action id="create" type="create"/>
        <action id="edit" type="edit"/>
        <action id="remove" type="remove"/>
    </actions>
    <columns>
        <column id="name"/>
    </columns>
    <rowsCount/>
    <buttonsPanel id="buttonsPanel"
                  alwaysVisible="true">
        <button id="createBtn" action="employeesTable.create"/>
        <button id="editBtn" action="employeesTable.edit"/>
        <button id="removeBtn" action="employeesTable.remove"/>
    </buttonsPanel>
</groupTable>

要在 name 列中将员工姓名与图片显示在一行,我们要修改此列中数据的标准展示方式。将使用 HBoxLayout 容器并将 Image 组件放入其中:

import com.company.demo.entity.Employee;
import com.haulmont.cuba.core.entity.FileDescriptor;
import com.haulmont.cuba.gui.UiComponents;
import com.haulmont.cuba.gui.components.*;
import com.haulmont.cuba.gui.screen.LookupComponent;
import com.haulmont.cuba.gui.screen.*;

import javax.inject.Inject;

@UiController("sales_Employee.browse")
@UiDescriptor("employee-browse.xml")
@LookupComponent("employeesTable")
@LoadDataBeforeShow
public class EmployeeBrowse extends StandardLookup<Employee> {

    @Inject
    private UiComponents uiComponents;
    @Inject
    private GroupTable<Employee> employeesTable;

    @Subscribe
    protected void onInit(InitEvent event) { (1)
        employeesTable.addGeneratedColumn("name", entity -> {
            Image image = uiComponents.create(Image.NAME); (2)
            image.setScaleMode(Image.ScaleMode.CONTAIN);
            image.setHeight("40");
            image.setWidth("40");

            FileDescriptor imageFile = entity.getImageFile(); (3)
            image.setSource(FileDescriptorResource.class)
                    .setFileDescriptor(imageFile);

            Label userLogin = uiComponents.create(Label.NAME); (4)
            userLogin.setValue(entity.getName());
            userLogin.setAlignment(Component.Alignment.MIDDLE_LEFT);

            HBoxLayout hBoxLayout = uiComponents.create(HBoxLayout.NAME); (5)
            hBoxLayout.setSpacing(true);
            hBoxLayout.add(image);
            hBoxLayout.add(userLogin);

            return hBoxLayout;
        });
    }
}
1 onInit() 方法调用 addGeneratedColumn() 方法,此方法接收两个参数:列标识符和 Table.ColumnGenerator 接口的实现。后者用于在 name 列中定义数据的自定义显示方式。
2 在这个方法中,我们使用 UiComponents 接口创建一个 Image 组件,设置了组件的缩放模式(设置为 CONTAIN ) 及其尺寸参数。
3 然后获取存储在 File Storage 中图片的 FileDescriptor 实例。该图片的链接存储在 Employee 实体的 imageFile 属性中。FileDescriptorImageResource 资源类型用于设置 Image 组件的资源。
4 我们将在图片旁边的 Label 组件中显示 name 属性。
5 我们将 ImageLabel 组件包装到 HBoxLayout 容器,并使 addGeneratedColumn() 方法返回此容器作为新的表格单元格布局。
images in table

另外,也可以使用 XML 的generator 属性以更具“声明式”的方式进行定义。

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 应用程序属性中。

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

这种类型的文件用来定义用户的默认权限值。如果没有角色为需要权限的目标显式定义权限值的时候,就会使用默认权限值。对于有拒绝访问权限的用户来说,这个文件很多时候是需要的:因为如果没有这个文件,有拒绝访问角色的用户默认情况下是连主窗口界面和过滤器界面都不能访问的。

这种类型的文件是不能通过 Studio 自动生成的,需要在 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 – 元素的标识符,菜单元素的本地化标题通过 id 来决定(参考如下)。

  • description - 光标悬浮提示窗的内容。可以使用主语言包里面的本地化语言。

  • icon - 菜单元素的图标。参考 icon 了解细节。

  • insertBeforeinsertAfter – 设置此菜单项应当放在特定的元素或者特定的名称的菜单项之前或之后。这个属性用来在应用程序组件菜单文件定义的菜单中找一个合适的位置插入此菜单项。insertBeforeinsertAfter 不能同时使用。

  • stylename - 为菜单项定义一个样式名称。参考 主题 了解细节。

item 元素的属性:

  • id – 元素的唯一标识符,菜单元素的本地化标题通过 id 来决定(参考如下)。如果没有定义任何 screenbeanclass 属性,此 id 用来指向具有相同 id 值的界面。当用户点击菜单项时,会在程序主窗口打开对应的界面。

    <item id="sample_Foo.browse"/>
  • 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 - 实现了 Runnable 的类全名。当用户点击菜单项时,会创建指定类的一个对象,并调用其 run() 方法。

    <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 的子元素:

  • param – 传递到界面旧控制器init() 方法的参数。在 menu.xml 里配置的参数会覆盖在 screens.xml 里配置的同名参数。

    param 的属性:

    • name – 参数名称

    • value – 参数值。字符串类型,按照下面的规则可以转成其它类型的对象:

      • 如果字符串的值是一个实体的标识符,按照 EntityLoadInfo 类规定的格式,系统会加载指定的实体实例。

      • 如果字符串是 ${some_name} 格式的,参数的值会被设置为 some_name 应用程序属性的值。

      • truefalse 的字符串会被转化为 Boolean 类型的相应值。

      • 其它情况,用字符串本身的值作为参数值。

  • permissions – 设置一组权限,用户只有满足这些权限才能使用此菜单项。这个机制应当在菜单项需要绑定到特定的权限或者绑定到多个权限时使用。大多数情况下,安全子系统的标准功能应该足以满足根据界面标识符控制菜单可用性的要求。

    这个元素应当包含一组 permission 子元素,每个元素描述一条需要的权限。菜单项只有在所有权限都满足的情况下才可用。

    permission 属性:

    • type – 权限类型。可用这些 PermissionType: SCREEN, ENTITY_OP, ENTITY_ATTR, SPECIFIC, UI

    • target – 用来检查权限的对象(object)。根据权限类型会不同:

      • SCREEN – 界面标识符,比如 sales$Customer.lookup

      • ENTITY_OP – 使用 {entity_name}:{op} 格式的字符串,这里 {op} 可以是:read, create, update, delete。比如 sales$Customer:create

      • ENTITY_ATTR – 使用 {entity_name}:{attribute} 格式的字符串,比如 sales$Customer:name

      • SPECIFIC – 特定的权限标识符,比如 sales.runInvoicing

      • UI – 界面可视化组件的路径。

菜单文件的示例:

<menu-config xmlns="http://schemas.haulmont.com/cuba/menu.xsd">

  <menu id="sales" insertBefore="administration">
      <item id="sales_Customer.lookup"/>
      <separator/>
      <item id="sales_Order.lookup"/>
  </menu>

</menu-config>
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.doSomething"/>
            <permission id="app.doSomethingOther"/>
        </category>
    </specific>
</permission-config>

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,比如 LoginService

还有,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 EE 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:${catalina.base}/conf/app-core/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" 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">
        <context-param>
            <description>Web resources version for correct caching in browser</description>
            <param-name>webResourcesTs</param-name>
            <param-value>${webResourcesTs}</param-value>
        </context-param>
        <!-- Application properties config files -->
        <context-param>
            <param-name>appPropertiesConfig</param-name>
            <param-value>
                classpath:com/company/sample/web-app.properties
                /WEB-INF/local.app.properties
                "file:${catalina.base}/conf/app/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>
            <servlet-name>rest_api</servlet-name>
            <servlet-class>com.haulmont.restapi.sys.CubaRestApiServlet</servlet-class>
            <load-on-startup>2</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>
        <servlet-mapping>
            <servlet-name>rest_api</servlet-name>
            <url-pattern>/rest/*</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>
        <filter>
            <filter-name>restSpringSecurityFilterChain</filter-name>
            <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
            <init-param>
                <param-name>contextAttribute</param-name>
                <param-value>org.springframework.web.servlet.FrameworkServlet.CONTEXT.rest_api</param-value>
            </init-param>
            <init-param>
                <param-name>targetBeanName</param-name>
                <param-value>springSecurityFilterChain</param-value>
            </init-param>
        </filter>
        <filter-mapping>
            <filter-name>restSpringSecurityFilterChain</filter-name>
            <url-pattern>/rest/*</url-pattern>
        </filter-mapping>
    </web-app>

    context-param 元素中,定义了应用程序组件列表和应用程序属性文件列表。webResourcesTs 参数以及在构建时替换的这个参数的值,保证了在网页浏览器中能正确的缓存静态资源。

    Web 客户端 block 使用 WebAppContextLoader 类作为 ServletContextListener

    JSR356WebsocketInitializer 是支持 WebSockets 协议需要的监听器。

    CubaApplicationServlet 提供了基于 Vaadin 框架实现的通用用户界面

    CubaDispatcherServlet 为 Spring MCV 控制器初始化了一个额外的 Spring context。这个 context 通过 dispatcher-spring.xml 文件来配置。

    CubaRestApiServlet 提供全局的 REST API

Appendix B: 应用程序属性

本附录章节按字母顺序介绍所有可用的应用程序属性

cuba.additionalStores

定义应用中使用的额外数据存储的名称。

在所有标准的 blocks 中都可以使用。

示例:

cuba.additionalStores = db1, mem1
cuba.allowQueryFromSelected

启用通用过滤器的级联过滤模式。参考 级联查询(Sequential Queries)

默认值: true

保存在数据库。

配置接口: GlobalConfig

可以在 Web 客户端和 Middleware 这两个 block 使用。

cuba.anonymousLogin

用来创建匿名用户会话的用户名称。(参考 cuba.anonymousSessionId)。

默认值: anonymous

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.anonymousSessionId

定义匿名用户会话的 UUID,此会话可以在没有用户登录的状态下使用。在服务启动的时候,会自动创建这个匿名会话。参考 cuba.anonymousLogin

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.automaticDatabaseUpdate

定义服务器是否在应用程序启动时运行数据库更新脚本

默认值: 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.checkPasswordOnClient

当设置为 false(默认值)时,客户端块 block 的 LoginPasswordLoginProvider 将用户密码明文发送给中间件的 AuthenticationService.login() 方法。在客户端和中间件 block 共同位于同一 JVM 中的情况下,这是合适的处理方式。对于客户端块(block)位于网络上的其它计算机上的分布式部署的情况,客户端和中间件之间的连接应使用 SSL 加密。

如果设置为 true,LoginPasswordLoginProvider`本身将通过输入的登录名加载 `User 实体并检查密码。如果密码与加载的密码哈希匹配,则提供程序使用cuba.trustedClientPassword 属性中指定的密码作为可信客户端执行登录。此模式使您无需在受信任网络中的客户端和中间件之间设置 SSL 连接,同时不会向网络公开用户密码:仅传输哈希值。但请注意,可信客户端密码仍然通过网络传输,因此受 SSL 保护的连接仍然更加安全。

默认值 : false

接口: WebAuthConfig, PortalConfig, RestApiConfig

可在 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 设置配置文件目录的位置。

对于 Tomcat 快速部署,默认值:${catalina.home}/conf/${cuba.webContextName},指向 tomcat/conf 目录下 web app 名称的目录,比如 tomcat/conf/app-core

对于 WAR 和 UberJAR 部署情况:${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.dataManagerChecksSecurityOnMiddleware

配置在 Middleware 调用 DataManager 时是否启用安全限制。

默认值: false

保存在数据库。

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.dataSourceJndiName

定义应用数据库连接中使用的 javax.sql.DataSource 的 JNDI 名称。

默认值: java:comp/env/jdbc/CubaDS

可以在 Middleware block 使用。

cuba.dataDir

为应用程序 block 设置工作目录的位置。

对于 Tomcat 快速部署,默认值:${catalina.home}/work/${cuba.webContextName},指向 tomcat/work 目录下 web app 名称的目录,比如 tomcat/work/app-core

对于 WAR 和 UberJAR 部署情况:${app.home}/${cuba.webContextName}/work,指向应用程序根目录下的一个目录。

配置接口: GlobalConfig

在所有标准的 blocks 中都可以使用。

cuba.dbDir

设置数据库脚本目录的位置。

对于 Tomcat 快速部署,默认值:${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.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} (可以从临时文件夹 and the 日志文件夹目录下载文件)。

可以在 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

如果设置为 true,在 Middleware 开启实体属性权限检查。如果设置为 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.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.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

当用户关闭带有未保存改动数据源的界面时,定义对话框的布局样式。

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 属性使用相同的值。

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 设置日志目录的位置。

对于 Tomcat 快速部署,默认值:${catalina.home}/logs,指向 tomcat/logs 目录。

对于 WAR 和 UberJAR 部署情况:${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.remotingSpringContextConfig

累加属性,用来定义 Middleware block 的 remoting-spring.xml 文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Middleware block 使用。

示例:

cuba.remotingSpringContextConfig = +com/company/sample/remoting-spring.xml
cuba.rest.allowedOrigins

定义英文逗号分隔的可以访问 REST API 的来源。需要开启跨域访问时可以用这个参数。

默认值: *

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.anonymousEnabled

对匿名用户启用 REST API 访问。

默认值: false

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.client.authorizedGrantTypes

为默认的 REST API 客户端定义支持的许可类型列表。需要禁用刷新令牌(refresh token),可以从列表中删除 refresh_token

默认值: password,external,refresh_token

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.client.id

定义 REST API 客户端的标识符。客户端,在这里,并不是说平台的用户,而是一个使用 REST API 的应用(某些 web portal 或者手机 app)。使用客户端用户名密码做访问 REST API token 端点的基本认证。

默认值: client

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.client.secret

定义 REST API 客户端的密码。客户端,在这里,并不是说平台的用户,而是一个使用 REST API 的应用(某些 web portal 或者手机 app)。使用客户端用户名密码做访问 REST API token 端点的基本认证。应用程序属性的值需要在具体的密码值(比如 secret)之前包含一个 PasswordEncoder 的标识符(比如 {noop})。

默认值: {noop}secret

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.client.tokenExpirationTimeSec

定义默认客户端 REST API 访问令牌过期时间的时限,单位是秒。

默认值: 43200 (12 小时)

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.client.refreshTokenExpirationTimeSec

定义默认客户端 REST API 刷新令牌过期时间的时限,单位是秒。

默认值: 31536000 (365 天)

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.deleteExpiredTokensCron

指定用来做定时任务的表达式,从数据库删除过期的令牌。

默认值: 0 0 3 * * ?

可以在 Middleware block 使用。

cuba.rest.jsonTransformationConfig

累加属性,用来定义 REST API 使用的 JSON 转换配置文件,这些文件在客户端请求一些特殊版本数据模型的信息时会用到。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

默认值: none

示例:

cuba.rest.jsonTransformationConfig = +com/company/sample/json-transformations.xml

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.maxUploadSize

可以使用 REST API 上传的文件大小的最大值,单位是字节。

默认值: 20971520 (20 Mb)

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.optimisticLockingEnabled

如果在 JSON 中提供了实体的 version 属性,启用 Versioned 实体的乐观锁。

默认值: false

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.requiresSecurityToken

如果是 true,会在加载的实体中包含一个特殊的系统属性,并且需要在保存实体的时候给 REST 接口传回一个同样的属性。细节参考 集合属性的安全约束

默认值: false

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.reuseRefreshToken

定义是否可以重复使用刷新令牌。如果设置为 false,当使用刷新令牌获取访问令牌的时候,系统会发出一个新的刷新令牌,老的刷新令牌将会失效。

默认值: true

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.servicesConfig

累加属性,用来定义包含应用 REST API 对应的 services 列表的文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

默认值: none

示例:

cuba.rest.servicesConfig = +com/company/sample/app-rest-services.xml

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.storeTokensInDb

启用在数据库保存 REST API 安全令牌。默认情况下,令牌只是在内存中存储。

保存在数据库。

配置接口: ServerConfig

默认值: false

可以在 Middleware block 使用。

cuba.rest.tokenMaskingEnabled

设置是否在应用程序日志中对 REST API 进行掩码处理。

默认值: true

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.rest.queriesConfig

累加属性,用来定义包含应用程序 REST API 可用的 JPQL 查询列表的文件。

平台使用 Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

默认值: none

示例:

cuba.rest.queriesConfig = +com/company/sample/app-rest-queries.xml

可以在 Web 客户端和 Web Portal blocks 使用。

cuba.schedulingActive

启用 CUBA 计划任务机制。

默认值: false

保存在数据库。

配置接口: 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.tempDir

为应用程序 block 设置临时目录的位置。

对于 Tomcat 快速部署,默认值:${catalina.home}/temp/${cuba.webContextName},指向 tomcat/temp 目录下 web app 名称的目录,比如 tomcat/temp/app-core

对于 WAR 和 UberJAR 部署情况:${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

定义 LoginService.loginTrusted() 方法要使用的密码。Middleware 层可以使用这个密码而不是用户密码来做用户认证,这些用户通过信任的客户端 block 连接。

这个属性在用户的密码不存在数据库的时候使用,客户端 block 会自己做实际的认证。比如,集成 Active Directory 的时候。

配置接口: ServerConfig, WebAuthConfig, PortalConfig

可以使用的 blocks:Middleware,Web 客户端,Web Portal。

cuba.trustedClientPermittedIpList

定义一组 IP 地址,从这些地址可以调用 LoginService.loginTrusted() 方法。示例:

cuba.trustedClientPermittedIpList = 127.0.0.1, 10.17.*.*

默认值: 127.0.0.1

配置接口: ServerConfig

可以在 Middleware block 使用。

cuba.uniqueConstraintViolationPattern

正则表达式,用来找出由于数据库违反唯一性约束造成的异常。约束的名称会从表达式的第一个非空组获得,示例:

ERROR: duplicate key value violates unique constraint "(.+)"

约束的名称可以用来显示本地化消息,以便找出是什么实体引起的。因此,需要在主语言包中包含为约束名定义的键值。示例:

IDX_SEC_USER_UNIQ_LOGIN = A user with the same login already exists

根据 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 服务,避开网络堆栈,这样有利于系统性能。这个情况在 Tomcat 快速部署和单一 WAR 或者单一 UberJAR 部署的时候可以用到。对于其它的部署形式,这个值需要设置成 false。

默认值: false

可以在 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 执行 LoginService.getSession() 方法,然后将取到的会话缓存到本地。

配置接口: 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.web.urlHandlingMode

定义如何处理 URL 更改。

可选值为 UrlHandlingMode 枚举类型的元素:

默认值: URL_ROUTES

配置接口: WebConfig

cuba.windowConfig

累加属性,用来定义 screens.xml 文件。

Resource - 资源接口来加载此文件,所以这个文件可以放在 classpath 或者配置文件目录

可以在 Web 客户端 block 使用。

示例:

cuba.windowConfig = +com/company/sample/web-screens.xml
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.externalAuthentication

Deprecated - 废弃. 使用 web 登录扩展点替代.

通过外部认证机制进行认证,比如 LDAP 或者 SSO IDP。也可参考cuba.web.externalAuthenticationProviderClass以及 IDP 插件 wiki

默认值: false

配置接口: WebAuthConfig

可以在 web 客户端 block 使用。

cuba.web.externalAuthenticationProviderClass

Deprecated - 废弃. 使用 web 登录扩展点替代.

实现了 CubaAuthProvider 接口的类,当 cuba.web.externalAuthentication 设置成 true 的时候会使用这个类做认证。

参考 集成 LDAPIDP 插件 wiki 了解更多示例。

配置接口: WebAuthConfig

可以在 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.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.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 值表示不限制。

默认值: 7

配置接口: 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.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.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.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. Passing Parameters to a Screen

TODO

D.12. Returning Values from an Invoked Screen

TODO

D.13. Using Individual Fields instead of FieldGroup

TODO

D.14. Setting up Logging in The Desktop Client

本章节从 7.0 开始就无效了,因为不再支持桌面客户端。

D.15. 多对多关联

D.16. 直接多对多关联

参阅 直接多对多关联 向导。

D.17. 使用关联实体进行多对多关联

D.18. 实体继承

参阅 数据模型:实体继承 向导。

8. 术语表

Application Tiers - 应用分层

参考 应用程序层和块

Application Properties - 应用程序属性

应用程序属性是为应用程序配置和功能等不同方面配置的带有名称的数据值。参考 应用程序属性

Application Blocks - 应用程序 block

参考 应用程序层和块。block,作为专用词,中文一般不翻译。

Artifact - 工件

在本手册的上下文中,工件是指一个文件(通常是 JAR 或者 ZIP 文件)包含了可执行代码或者构建项目生成的其它代码。工件具有按照特定规则定义的名称和版本号,并且可以保存在工件仓库里。

Artifact Repository - 工件仓库

按特定目录结构保存 artifacts 的服务器,当项目启动构建时,项目依赖的工件都从这个仓库加载。

Base Projects - 基本项目

应用程序组件一个意思,这个术语在之前版本的平台和文档中使用。

Container - 容器

容器控制应用程序里面对象的生命周期以及配置。这个是依赖注入(反转控制)机制的基本组件。

CUBA 框架使用 Spring 框架 的容器。

DB - 数据库

关系型数据库。

Datasource - 数据源

参考 数据源(历史版本).

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 规范的一部分。

Lazy loading - 懒加载

参考 延迟加载.

Local attribute - 本地属性

实体的属性,此属性不关联其它实体,也不是其它实体的集合。典型情况下,实体的所有本地属性都存在数据库的一张表内(当然不包括特定的实体继承的情况)。

Localized message pack - 本地化语言消息包

参考 语言消息包。翻译时有时候简称语言包,本地化语言包等。

Managed Beans - 托管 bean

包含应用程序业务逻辑的组件。

参考 托管 Bean.

Main Message Pack - 主语言消息包

参考 主语言消息包.

MBeans

带有 JMX 接口的托管 bean。典型情况下,这些 bean 包含一个内部状态(比如可以是缓存,配置数据或者统计数据),这个内部状态需要通过 JMX 访问。

Middleware - 中间件

中间层 –  包含业务逻辑的应用程序层,跟数据库通信并且为更高的客户端层提供接口通用接口服务。有时候不翻译,有时候翻译成中间件或者中间层。

Optimistic locking - 乐观锁

乐观锁 - 用来管理不同用户访问共享数据的一种方式,这里假设不同用户对同一实体实例只有非常低的可能性进行并发访问。采用这种方案并不会真正意义上对数据加锁,而是在数据发生变动时检查在数据库是否存在更新版本的数据。如果存在,则会抛出异常,用户需要重新加载实体。

ORM

对象关系映射 – 将关系型数据库的表跟编程语言中对象进行关联的一种技术。

参考 ORM 层.

Persistent context - 持久化上下文

一组从数据库加载的或者刚创建的实体实例。持久化上下文作为当前事务的数据缓存。当事务提交时,所有持久化上下文内的实体变更都被保存到数据库。

参考 EntityManager.

Screen Controller - 界面控制器

包含界面初始化和事件处理逻辑的 Java 类。结合界面的 XML 描述一起工作。

参考 界面控制器.

Services - 服务

中间件服务为客户端调用提供业务接口并且形成中间件壁垒。服务可以封装业务逻辑或者将执行过程代理给 Managed Beans

参考 服务.

Soft deletion - 软删除

参考 软删除.

UI

用户界面。

View - 视图

参考 视图

XML-descriptor - 界面 XML 描述

包含界面可视化组件布局和数据源的 XML 文件。

. . .