Vaadin-数据中心应用-全-

Vaadin 数据中心应用(全)

原文:zh.annas-archive.org/md5/27e945b0cb8a70bfd88292cf3003de87

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Vaadin 框架是一个开源的 Java Web 框架,在 Apache 许可证下发布。该框架文档齐全,包括复杂的 UI 组件和主题,已在现实应用程序中得到实战检验,并由一家承诺的公司和一个充满活力的社区支持,他们通过论坛回答和数百个附加组件为框架做出贡献。

Vaadin 框架允许开发者使用在服务器 JVM 上运行的 Java 代码实现 Web 用户界面。UI 在浏览器上以 HTML5 的形式渲染。该框架通过类似于 Swing 或 AWT 的编程模型,提供浏览器和服务器之间完全自动化的通信。这允许开发者/程序员将面向对象技术的优势带到 Web 应用程序的表现层。

使用 Vaadin 8 实现以数据为中心的应用程序是一本实用指南,它教您如何在数据管理是核心的 Web 应用程序中实现一些最典型的需求。您将了解国际化、身份验证、授权、数据库连接、CRUD 视图、报告生成和数据懒加载。

本书还将通过向您展示如何在 UX 和代码层面做出良好决策,帮助您锻炼编程和软件设计技能。您将学习如何模块化您的应用程序,以及如何在 UI 组件之上提供 API 以增加可重用性和可维护性。

本书面向读者

本书非常适合对 Java 编程语言有良好理解且对 Vaadin 框架有基本知识的开发者,他们希望通过框架提高自己的技能。如果您想学习概念、技术、技术和实践,以帮助您掌握使用 Vaadin 进行 Web 开发,并了解现实应用程序中常见应用程序功能的开发方式,这本书适合您。

本书涵盖内容

第一章,创建新的 Vaadin 项目,演示了如何从头开始创建新的 Vaadin Maven 项目,并解释了 Vaadin 应用程序的主要架构和组成部分。

第二章,模块化和主屏幕,解释了如何设计用于实现主屏幕的 API,并展示了如何创建在运行时注册的功能性应用程序模块。

第三章,使用国际化实现服务器端组件,讨论了实现具有国际化支持的定制 UI 组件的实施策略。

第四章,实现身份验证和授权,探讨了在 Vaadin 应用程序中实现安全的身份验证和授权机制的不同方法。

第五章,使用 JDBC 连接 SQL 数据库,重点介绍了 JDBC、连接池和仓库类,以便连接到 SQL 数据库。

第六章,使用 ORM 框架连接 SQL 数据库,概述了如何使用 JPA、MyBatis 和 jOOQ 从 Vaadin 应用程序连接到 SQL 数据库。

第七章,实现 CRUD 用户界面,带你了解用户界面设计和 CRUD(创建、读取、更新和删除)视图的实现。

第八章,添加报告功能,展示了如何使用 JasperReports 生成和可视化打印预览报告。

第九章,懒加载,探讨了如何实现懒加载,以使你的应用程序在处理大数据集时消耗更少的资源。

为了充分利用这本书

如果你已经对 Vaadin 框架有一些经验,那么你将能从这本书中获得最大收益。如果你没有,请在继续阅读这本书之前,先通过官方在线教程了解vaadin.com/docs/v8/framework/tutorial.html

为了使用配套代码,你需要 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。

下载示例代码文件

你可以从www.packtpub.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 版的 WinRAR/7-Zip

  • Mac 版的 Zipeg/iZip/UnRarX

  • Linux 版的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Data-Centric-Applications-with-Vaadin-8。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图片

我们还提供包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/DataCentricApplicationswithVaadin8_ColorImages.pdf

代码实战

访问以下链接查看代码运行的视频:

goo.gl/qFmc3L

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“应用程序应在 http://localhost:8080 上可用。”

代码块设置如下:

LoginForm loginForm = new LoginForm()
loginForm.addLoginListener(e ->  {
    String password = e.getLoginParameter("password");
    String username = e.getLoginParameter("username");
    ...
});

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

LoginForm loginForm = new LoginForm() {
    @Override
    protected Component createContent(TextField username,
            PasswordField password, Button loginButton) {

        CheckBox rememberMe = new CheckBox();
        rememberMe.setCaption("Remember me");

        return new VerticalLayout(username, password, loginButton,
                rememberMe);
    }
};

任何命令行输入或输出应如下编写:

cd Data-centric-Applications-with-Vaadin-8
mvn install

粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

我们欢迎读者的反馈。

一般反馈:请发送电子邮件至 feedback@packtpub.com 并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您能提供位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过本书,为何不在购买该书的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 的我们能够了解您对我们产品的看法,而我们的作者可以查看他们对本书的反馈。谢谢!

如需更多关于 Packt 的信息,请访问 packtpub.com

第一章:创建新的 Vaadin 项目

这第一章是通往充满有趣技术、激动人心的挑战和有用代码之旅的基础。如果您正在阅读这本书,您之前编写过 Vaadin 应用程序的可能性很高。您可能对 Vaadin 应用程序中的关键角色有基本了解:组件、布局、监听器、绑定器、资源、主题和小部件集;当然,您也分享过 Java 编码的经验!

在开始项目时拥有坚实的基础,不仅对于 Vaadin,对于任何其他技术也是如此,在成功项目中起着重要作用。了解您的代码做什么以及为什么需要它有助于您做出更好的决策并提高生产力。本章将帮助您了解运行 Vaadin 应用程序真正需要什么,以及您如何对启动新 Vaadin 项目所需的依赖项和 Maven 配置更有信心。

本章涵盖以下主题:

  • Vaadin 中的主要 Java 依赖项

  • Servlets 和 UIs

  • Maven 插件

  • Vaadin 应用程序中的关键元素

技术要求

您需要安装 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。您还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,您需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-01

查看以下视频以查看代码的实际运行情况:

goo.gl/RHavBs

关于演示应用程序

本书以两种方式提供价值:书本身及其解释,以及其配套源代码。本书不是开发一个单一的应用程序,而是通过几个小型演示应用程序来展示每章中解释的概念。这有助于您跳转到任何感兴趣的章节,并完全理解代码各部分的用途,而无需担心我们在其他章节中探讨的技术细节。

理解源代码

在编译项目之前,您必须启动一个 H2 数据库实例。为了您的方便,Data-centric-Applications-with-Vaadin-8/chapter-05 Maven 模块中已配置了一个服务器。您可以为此 Maven 命令创建一个运行配置,或者您可以直接在命令行上运行它:

cd Data-centric-Applications-with-Vaadin-8/chapter-05
mvn test exec:java -Dexec.mainClass="packt.vaadin.datacentric.chapter05.jdbc.H2Server"

数据库启动并运行后,您可以通过执行以下操作构建所有演示应用程序:

cd Data-centric-Applications-with-Vaadin-8
mvn install

所有演示应用程序都聚合在一个多模块 Maven 项目中,其中每个模块对应本书的一章。

本书假设您对 Maven 已经足够熟悉,能够跟随每一章的示例应用程序。如果您没有 Maven 或多模块 Maven 项目的先前经验,请花些时间浏览以下教程和文档:maven.apache.org/guides

每一章的模块可能包含多个子模块,具体取决于该章节所解释的概念。我们将使用 Jetty Maven 插件来运行示例。如今,大多数 IDE 都对 Maven 有很好的支持。使用本书代码的最佳方式是将 Data-centric-Applications-with-Vaadin-8 Maven 项目导入您的 IDE,并为每个演示应用程序创建单独的 运行配置。网上有大量资源解释如何为最流行的 IDE(如 IntelliJ IDEA、NetBeans 和 Eclipse)执行此操作。例如,要在 IntelliJ IDEA 中运行本章的示例应用程序,创建一个新的运行配置,如下所示:

确保工作目录对应于项目中的正确模块。或者,您可以在命令行上执行以下操作来运行应用程序:

cd Data-centric-Applications-with-Vaadin-8/chapter-01
mvn package jetty:run

这将执行 Maven 的打包阶段并启动 Jetty 服务器。应用程序应在 http://localhost:8080 上可用。

因此,继续前进!下载源代码,将其导入您的 IDE,并运行几个示例。请随意探索代码,修改它,甚至将其用于您自己的项目中。

理解 Vaadin 应用程序的架构

最好的开始新 Vaadin 项目的办法是什么?很难说。这取决于您的先前经验、当前的开发环境设置以及您的个人偏好。创建新 Vaadin 项目的最流行方法之一是使用官方的 Maven 架构模板。您可能已经使用过 vaadin-archetype-application Maven 架构模板,这对于快速开始使用 Vaadin 很有帮助。也许您已经使用过 vaadin-archetype-widgetset 架构模板来创建 Vaadin 扩展,或者您可能使用过 vaadin-archetype-application-multimodulevaadin-archetype-application-example 架构模板来启动一些应用程序。例如,Eclipse 这样的 IDE 提供了创建 Vaadin 项目而不必考虑 Maven 架构模板的工具。

所有这些架构模板和工具都很好,因为它们能快速让您开始,并展示一些良好的实践。然而,当您从头开始创建项目时,您能更好地理解整个应用程序的架构。当然,如果您已经对生成的 pom.xml 文件中的每个部分都感到足够舒适,您可以使用这些架构模板。然而,从头开始构建项目是真正理解和控制您的 Vaadin 应用程序配置的好方法。

从头开始创建新项目

通常,您会使用 vaadin-archetype-applicationvaadin-archetype-application-multimodule Maven 原型来创建一个新的 Vaadin 应用程序。如果您生成的代码符合您的需求,使用这些原型是没有问题的。然而,这些原型生成的代码比您需要的多,部分原因是因为它们试图向您展示如何开始使用 Vaadin,部分原因是因为它们是通用型启动器,非常适合大多数项目。但是,让我们通过以非常不同的方式创建一个 Vaadin 项目来完全控制(和理解)网络应用程序——一种更细粒度、更受控制的方式。

从根本上说,Vaadin 应用程序是一个打包为 WAR 文件的 Java 应用程序。您可以将它视为一个标准网络应用程序,其中您放置一些 JAR 文件,允许您使用 Java 编程语言而不是 HTML 和 JavaScript 来构建 Web UI。这就像将一些 JAR 文件放入您的 Java 项目中那么简单吗?让我们来看看!

使用 maven-archetype-webapp 通过在命令行中执行以下操作来生成一个简单的 Java 网络应用程序:

mvn archetype:generate -DarchetypeGroupId=org.apache.maven.archetypes -DarchetypeArtifactId=maven-archetype-webapp

在提示时使用以下属性:

  • groupId: packt.vaadin.datacentric.chapter01

  • artifactId: chapter-01

  • version: 1.0-SNAPSHOT

  • package: packt.vaadin.datacentric.chapter01

NetBeans、Eclipse 和 IntelliJ IDEA 等集成开发环境对 Maven 有很好的支持。您应该能够在 IDE 中使用之前提供的原型创建一个新的 Maven 项目,只需提供相应的 Maven 坐标,无需使用命令行。

清理 pom.xml 文件,使其看起来如下所示:

<project ...>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>chapter-01</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>
</project>

注意,在本书提供的代码中,您会在 chapter-01 项目的 pom.xml 文件中找到一个 <parent> 部分。这是因为本书的所有演示应用程序都已聚合到一个单独的 Data-centric-Applications-with-Vaadin-8 Maven 项目中,以便您方便使用。如果您按照本章的步骤进行操作,您不需要在项目中添加任何 <parent> 部分。

删除 src/main/webappsrc/main/resources 目录。这将删除生成的 web.xml 文件,这将使 Maven 抱怨。为了告诉它这是故意的,请将以下属性添加到您的 pom.xml 文件中:

    ...
    <packaging>war</packaging>

 <properties>
 <failOnMissingWebXml>false</failOnMissingWebXml>
    </properties>
    ...

此外,添加以下属性以配置 Maven 使用 Java 8:

        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>

Maven 依赖项

到目前为止,我们有一个非常简单的 Java 项目设置,它将被打包成 WAR 文件。下一步自然的步骤是添加所需的依赖项或库。Vaadin,就像许多其他 Java 网络应用程序一样,需要 Servlet API。按照以下步骤将其添加到 pom.xml 文件中:

    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

注意,这个依赖项的范围被设置为 provided,这意味着服务器,或者更具体地说,Servlet 容器,如 Jetty 或 Tomcat,将提供实现。

让我们继续添加所需的 Vaadin 依赖项。首先,将 vaadin-bom 依赖项添加到您的 pom.xml 文件中:

     <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.vaadin</groupId>
                <artifactId>vaadin-bom</artifactId>
                <version>8.3.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

本书使用 Vaadin 框架版本 8.3.2,这是撰写本书时的最新生产就绪版本。

Maven BOM,或物料清单,让你免于担心相关依赖项的版本;在这种情况下,是 Vaadin 依赖项。让我们接下来删除这些依赖项。将以下内容添加到你的 pom.xml 文件中:

    <dependency>
        <groupId>com.vaadin</groupId>
        <artifactId>vaadin-server</artifactId>
    </dependency>
    <dependency>
        <groupId>com.vaadin</groupId>
        <artifactId>vaadin-client-compiled</artifactId>
    </dependency>
    <dependency>
        <groupId>com.vaadin</groupId>
        <artifactId>vaadin-themes</artifactId>
    </dependency>

由于 vaadin-bom 依赖,你无需显式设置这些版本的值。我们刚刚添加了一个服务器端 API (vaadin-server),一个客户端引擎或小部件集 (vaadin-client-compiled),以及 Valo 主题 (vaadin-themes)。

在这一点上,你可以在 chapter-01 目录内运行以下命令来编译项目:

mvn clean install

如果你之前没有使用过 Vaadin 8.3.2,这将下载依赖项到你的本地 Maven 仓库。

Servlet 和 UI

简单形式的 Vaadin 应用程序是一个将用户界面逻辑委托给 UI 实现的 Servletvaadin-server 依赖项包括 Servlet 实现:VaadinServlet 类。让我们来配置一个。

src/main 目录内创建一个名为 java 的新目录。

你可能需要告诉你的 IDE 这是一个源目录。你很可能会通过右键单击目录并选择将其标记为源目录的选项来找到它。请查阅你 IDE 的文档以获取详细说明。

创建一个名为 packt.vaadin.datacentric.chapter01 的新包,并在该包内添加一个简单的 UI 实现:

public class VaadinUI extends UI {

    @Override
    protected void init(VaadinRequest vaadinRequest) {
        setContent(new Label("Welcome to Data-Centric Applications with Vaadin 8!"));
    }
}

添加一个新的 WebConfig 类来封装与网络配置相关的所有内容,并将 VaadinServlet 定义为一个内部类:

public class WebConfig {

    @WebServlet("/*")
    @VaadinServletConfiguration(
          ui = VaadinUI.class, productionMode = false)
    public static class WebappVaadinServlet extends VaadinServlet {
    }
}

WebappVaadinServlet 类必须是 public static,以便 Servlet 容器可以实例化它。注意我们是如何使用 @WebServlet 注解将 /* 配置为 servlet URL 映射的。这使得应用程序在部署路径的根目录下可用。注意,@VaadinServletConfiguration 注解如何将 Servlet 连接到 UI 实现,即我们在上一步中实现的 VaadinUI 类。

Maven 插件

你可能已经使用过,或者至少见过 Vaadin Maven 插件。它允许你编译小部件集和主题,以及其他任务。然而,在创建一个新的 Vaadin 应用程序时,你没有任何附加组件、自定义客户端组件或主题。这意味着你目前不需要 Vaadin Maven 插件。你可以使用由 vaadin-client-compiled 依赖提供的默认小部件集。

在这一点上,我们可以从至少一个 Maven 插件中受益:Jetty Maven 插件。虽然你可以配置大多数 IDE 以使用各种服务器来在开发期间部署你的应用程序,但 Jetty Maven 插件让你免去了进一步的特定配置,这使得开发者可以简单地选择他们偏好的工具。要使用此插件,请将以下内容添加到 pom.xml 文件中:

<build>
    <plugins>
        <plugin>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-maven-plugin</artifactId>
            <version>9.3.7.v20160115</version>
        </plugin>
    </plugins>
</build>

在此基础上,你可以通过在 IDE 中创建一个新的运行配置来执行mvn jetty:run来运行应用程序。将你的浏览器指向http://localhost:8080,你应该会看到应用程序正在运行:

图片

组件和布局

为了全面了解 Vaadin 应用程序的主要部分,让我们快速回顾一些你应该已经熟悉的最重要的类。在 Vaadin 应用程序中,大部分代码都与组件和布局相关。简而言之,你将LabelTextFieldCheckBoxComboBoxGrid等组件添加到VerticalLayoutFormLayoutGridLayoutHorizontalLayoutCSSLayout等布局中。你还可以将布局添加到布局中。

在设计或开发过程中,你可能想要探索框架中可用的组件和布局,以便你可以为特定场景选择最佳选项。查看框架中包含的所有组件和布局的一种方法是通过访问 Vaadin 示例器:demo.vaadin.com/sampler。你可以通过点击页面右上角的“信息”图标来查看代码示例:

图片

监听器和绑定器

Vaadin 应用程序通过监听器和绑定器与服务器交互。监听器允许你处理用户交互,而绑定器允许你保持输入组件(如TextField)和域对象(例如,自定义的User类)的值同步。

事件和监听器

在 Vaadin 应用程序中,行为是通过监听器添加的。当发生相应的动作时,监听器会触发一个事件,这通常是由用户与 UI 的交互引起的。Vaadin 中最常见的两个监听器是ClickListener(用于按钮)和ValueChangeListener(用于输入组件)。监听器通常通过实现功能接口来定义,这允许你使用方法引用来响应事件:

protected void init(VaadinRequest vaadinRequest) { 
   Button button = new Button("Click this");
   button.addClickListener(this::buttonClicked);
}
...
private void buttonClicked(Button.ClickEvent event) {
    Notification.show("Thanks for clicking");
}

你也可以使用 Lambda 表达式:

button.addClickListener(
        event -> Notification.show("Thanks for clicking"));

为了使其更易于阅读和测试,将监听器逻辑提取到新的方法中,只传递所需的参数(在这种情况下,不需要传递任何参数):

protected void init(VaadinRequest vaadinRequest) { 
   ...
   button.addClickListener(event -> buttonClicked());
}
...
private void buttonClicked() {
    Notification.show("Thanks for clicking");
}

数据绑定

数据绑定通常通过Binder类来完成。这个类允许你将一个或多个字段中的值连接到域类中的 Java 属性。假设你有一个User类(域类),它有一个作为其属性之一的 Java String类型的password。你可以创建一个TextField并将其值绑定到password属性,如下所示:

TextField textField = new TextField(“Email”);
Binder binder = new Binder<User>()
    .forField(textField)
    .bind(User::getPassword, User::setPassword);

这是一种强大且类型安全的实现数据绑定的方式。想象一下,在开发过程中,你决定将 User 类中的 password 属性重命名为类似 pin 的名称。你可以使用 IDE 的重构工具来重命名属性,IDE 将重命名获取器、设置器和调用这些方法的任何代码。当然,你必须自己将标题 "Email" 改为 "PIN",但这也适用于其他绑定机制。

绑定器也用于添加验证器和转换器。这些可以使用 Lambda 表达式或方法引用添加。例如,以下代码片段检查一个 String 是否恰好有 4 个字符,并将其转换为整数:

binder.withValidator(s -> s.length() == 4, “Must be 4 characters")
      .withConverter(Integer::parseInt, Object::toString);

资源和主题

Resource 接口及其实现是 Java 代码与图像、可下载文件或嵌入式内容等资源之间的连接。你可能已经使用过 StreamResource 来动态生成用户可以下载的文件,或者使用 ThemeResource 在你的 UI 中显示图像。

主题,反过来,是一组用于配置 Vaadin 应用程序外观的静态资源。默认情况下,Vaadin 应用程序使用 Valo 主题,这是一组强大的样式,可以使用变量进行配置。

小部件集和附加组件

到目前为止,你已经了解了 Vaadin 应用程序最常见的一部分。Vaadin 主要关于使用在服务器端运行的 Java API。这段 Java 代码定义了应用程序的外观和行为,但 Vaadin 应用程序是在浏览器上使用 HTML 5 和 JavaScript 运行的。你不需要编写一行 HTML 或 JavaScript 代码来实现 Vaadin 应用程序。这是如何实现的?Java 类是如何定义在浏览器中渲染的 HTML 的?

理解这一点的关键是 小部件集。小部件集是在客户端运行的 JavaScript 引擎,其中包含显示组件和与服务器端通信所需的所有代码。小部件集是通过使用 GWT 将一组 Java 类编译成 JavaScript 生成的。这些 Java 类由 Vaadin 框架提供,如果你想的话,可以添加自己的。如果你没有使用自定义客户端组件(你自己的或第三方 Vaadin 附加组件提供的),你可以使用已经编译好的小部件集,该小部件集包含在 vaadin-client-compiled 依赖项中。

摘要

本章作为 Vaadin 应用程序架构及其主要角色的介绍。我们解释了 Vaadin 应用程序最重要的部分以及它们是如何连接的。我们还学习了如何从头开始创建一个最小的 Vaadin 应用程序,通过添加我们自己需要的每一个配置。

在下一章中,你将学习如何实现主屏幕和自定义应用程序模块,这些模块在运行时会与 Vaadin 应用程序发现并注册。

第二章:模块化和主屏幕

模块化的主要目的是降低系统的复杂性。通过将功能划分为多个模块,开发者可以 忘记 与正在开发的功能不相关的系统部分。它还通过例如允许根据环境或客户激活功能,以及创建第三方模块以自定义和扩展应用程序的功能等方式,使部署过程更加高效。

本章演示了如何模块化您的应用程序以使其更易于管理和维护,以及如何实现一个支持在运行时注册新模块的主屏幕。

本章涵盖了以下主题:

  • Vaadin 应用程序的模块化

  • 应用程序主屏幕的实现

技术要求

您需要拥有 Java SE 开发工具包和 Java EE 开发工具包版本 8 或更高版本。您还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,您需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-02

查看以下视频以查看代码的实际运行情况:

goo.gl/VnLouE

创建多模块 Maven 项目

一个 多模块 Maven 项目将多个 Maven 项目聚合为一个单一的项目。在本章中,我们将创建三个模块,形成一个完整的应用程序:

  • webapp: 一个打包为 WAR 文件的 Vaadin 网络应用程序,包括部署到服务器(如 Tomcat、Wildfly、Jetty 或任何其他 Java 服务器)所需的一切

  • api: 一个打包为 JAR 的 Java API,由 webapp 和任何 功能模块 使用

  • example-module: 一个使用 api JAR 添加应用程序功能的 功能模块 示例

所有这些模块都聚合到一个名为 chapter-02 的单个 Maven 项目中。让我们首先使用 pom-root Maven 架构创建这个聚合项目。在终端中运行以下命令:

mvn archetype:generate \
-DarchetypeGroupId=org.codehaus.mojo.archetypes \
-DarchetypeArtifactId=pom-root \
-DarchetypeVersion=RELEASE

当提示时,请使用以下属性:

  • groupId: packt.vaadin.datacentric

  • artifactId: chapter-02

  • version: 1.0-SNAPSHOT

  • package: packt.vaadin.datacentric.chapter02

当使用此架构时,Maven 为顶级多模块或聚合项目生成一个 pom.xml 文件。您可以删除 <name> 标签,因为它对我们来说是不必要的。修改文件以包含一个用于 Vaadin 版本的属性:

<project  xsi:schemaLocation="…">
    <modelVersion>4.0.0</modelVersion>

    <groupId>packt.vaadin.datacentric</groupId>
    <artifactId>chapter-02</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <vaadin.version>8.3.2</vaadin.version>
    </properties>
</project>

注意,在本书提供的代码中,您会在chapter-02项目的pom.xml文件中找到一个<parent>部分。这是因为本书的所有演示应用程序都已聚合到一个单独的Data-centric-Applications-with-Vaadin-8 Maven 项目中,以便您使用。如果您遵循本章中的步骤,则不需要在项目中添加任何<parent>部分。

这个项目(chapter-02)可以被视为一个完整应用程序的根目录,该应用程序包含多个 Maven 模块,每个模块都致力于系统功能的一个特定方面。

实现应用程序的主屏幕

让我们从实现一个具体组件开始:一个主屏幕,这是每个 Web 应用程序都需要的东西。请记住,实现主屏幕的方式不止一种。这里提供的示例可能适合您的应用程序,或者它可能激发您开发更复杂的实现。

在本例中的主屏幕主要由一个标题栏、一个菜单和一个工作区域组成,当用户从主菜单中选择一个选项时,其他组件会显示在工作区域中。对于外部世界来说,这个组件应该包括以下功能:

  • 向标题栏添加组件

  • 向工作区域添加组件

  • 向主菜单添加选项

  • 添加监听器以响应用户操作

  • 从工作区域和标题栏获取组件

定义应用程序主屏幕的 API

为了探索和学习使用 Vaadin 进行 Web 开发中的 API 设计,让我们假设我们希望主屏幕是一个通用组件,不仅仅用于这个演示应用程序。因此,我们需要将组件提供在一个单独的 JAR 文件中。首先,在chapter-02项目中使用maven-archetype-simple存档创建一个新的 Maven 模块,如下所示:

cd chapter-02
mvn archetype:generate -DarchetypeGroupId=org.apache.maven.archetypes -DarchetypeArtifactId=maven-archetype-quickstart

当提示时,请使用以下属性:

  • groupId: packt.vaadin.datacentric.chapter02

  • artifactId: api

  • version: 1.0-SNAPSHOT

  • package: packt.vaadin.datacentric.chapter02.api

确认新的api模块已列在chapter-02/pom.xml文件中:

<project ...>
    ...
    <modules>
        <module>api</module>
    </modules>
</project>

根据需要清理,并添加 Vaadin BOM 和vaadin-server依赖项。您还可以删除生成的AppAppTest类。您还需要使用属性配置 Java 8,类似于上一章中所述的方法。

您可以在本书所附源代码的Data-centric-Applications-with-Vaadin-8\chapter-02\api Maven 项目的pom.xml文件中找到完整的pom.xml文件。

API 应该允许开发者创建具有类似功能的其他具体主屏幕实现。可以通过定义以下 Java 接口来抽象这个功能:

public interface ApplicationLayout extends Component {

 void addHeaderComponent(Component component);

 void addWorkingAreaComponent(WorkingAreaComponent
         component);

 Collection<Component> getHeaderComponents();

 Collection< WorkingAreaComponent> getWorkingAreaComponents();

 void addMenuOption(MenuOption menuOption,
         SerializableConsumer<MenuOption> clickListener);

}

ApplicationLayout接口和相关类位于本书所附源代码的Data-centric-Applications-with-Vaadin-8/chapter-02/api Maven 项目中。

此接口扩展了 Component,因此任何具体的实现都可以用作常规 UI 组件,并添加到任何 Vaadin 组件容器中,例如 VerticalLayout。具体的实现将扩展 Composite,这将在稍后展示。

实现支持类

之前的界面无法编译。需要实现两个类:WorkingAreaComponentMenuOptionaddWorkingAreaComponent(WorkingAreaComponent) 方法期望传入一个 WorkingAreaComponent,它封装了一个标题和要显示的相应的 Vaadin 组件。此接口定义如下:

public interface ApplicationLayout extends Component {

    public static class WorkingAreaComponent
            implements Serializable {

        private final String caption;
        private final Component component;

        public WorkingAreaComponent(String caption,
                Component component) {
            this.caption = caption;
            this.component = component;
        }

       ... hashCode(), equals(), and getters 
    }
    ...
}

WorkingAreaComponent 类实现了 Serializable 接口。Vaadin 主要是一个服务器端框架。组件存储在 HTTP 会话中。为了序列化会话,所有包含的对象都必须是 Serializable 的。例如,在停止像 Jetty 或 Tomcat 这样的 Web 容器时,会进行这种序列化。所有 HTTP 会话都会序列化到磁盘,下次服务器启动时,会话会被恢复。注意,SerializableConsumerApplicationLayout 接口中也是出于同样的原因被使用的。

为什么需要这样做?为什么不简单地将 addWorkingAreaComponent(WorkingAreaComponent) 方法的参数设置为标题和组件,就像以下代码片段所示?

void addWorkingAreaComponent(String caption, Component component);

如果你百分之百确定,当你向工作区域添加新组件时,只需要一个标题和一个组件,那就没问题。然而,你不知道具体的 ApplicationLayouts 将如何发展。如果需要图标怎么办?颜色或帮助文本呢?

假设你已经决定将方法实现为 addWorkingAreaComponent(String, Component),几个月后,某个使用该组件的应用程序需要为添加到工作区域中的每个组件提供一个图标。一个可能的解决方案是将方法修改为接受一个用于图标的新的参数,如下所示:

void addWorkingAreaComponent(String caption, Component component,
        Resource icon);

这种修改将破坏任何引用旧方法签名的现有客户端。另一种方法是通过添加新参数来重载方法。然而,这将破坏所有当前的 ApplicationLayout 实现。封装可能发生变化的内容总是一个好主意。

封装 addWorkingAreaComponent(WorkingAreaComponent) 方法的参数的另一个原因是 getWorkingAreaComponents() 方法。假设你想实现一个具体的 ApplicationLayout,允许用户在标签页和窗口之间切换。为了实现这个功能,你需要获取当前显示在工作区域中的所有组件(使用 getWorkingAreaComponents(WorkingAreaComponent) 方法),并将它们相应地放置在标签页或窗口中。对于每个组件,你需要创建一个标签页或窗口,设置其标题,并添加相应的 Vaadin 组件。你需要标题和组件。将这些对象封装在单个类中可以极大地简化这项任务;否则,我们需要有一个额外的方法来返回有序集合形式的标题。此外,getWorkingAreaComponents() 方法也应该返回一个有序集合。

关于 ApplicationLayout 类需要注意的最后一件事是 addMenuOption(MenuOption, SerializableConsumer<MenuOption>) 方法。此方法期望一个 MenuOption(封装要渲染的标题)和一个 SerializableConsumer,它作为菜单选项的点击监听器。当用户点击选项时,会调用 Consumer.accept(MenuOption) 方法,并将点击的 MenuOption 作为其参数传递。

SerializableConsumerConsumer 类的可序列化版本,它是 Java 8 中引入的一种函数式接口。函数式接口只有一个抽象方法。这允许客户端使用 lambda 表达式创建接口的实例。有关函数式接口的更多信息,请参阅:

docs.oracle.com/javase/8/docs/api/java/lang/FunctionalInterface.html.

MenuOption 类可以按照以下方式实现:

public interface ApplicationLayout extends Component {

    public static class MenuOption implements Serializable {
        private final String caption;

        public MenuOption(String caption) {
            this.caption = caption;
        }

        public String getCaption() {
            return caption;
        }
    }
}

实现具体应用程序的主屏幕

本节解释了如何使用上一节中开发的 ApplicationLayout 接口实现和使用基本的基于标签的布局。布局包括顶部的标题和左侧的侧边菜单。当用户在主菜单上点击选项时,会在新的标签页内添加一个新的组件。以下是这个布局的截图:

图片

添加和配置所需的 UI 组件

第一步是创建所需的 Vaadin UI 组件,并使用标准 Vaadin API 进行配置。这可以按照以下方式完成:

public class TabBasedApplicationLayout extends Composite {

    private VerticalLayout mainLayout = new VerticalLayout();
    private HorizontalLayout header = new HorizontalLayout();
    private HorizontalSplitPanel splitPanel
            = new HorizontalSplitPanel();
    private VerticalLayout menuLayout = new VerticalLayout();
    private TabSheet tabSheet = new TabSheet();

    public TabBasedApplicationLayout(String caption) {
        ... layout and components configuration
    }
}

配置 UI 元素的代码被省略了,因为本书的目的不是解释 Vaadin UI 组件的基本使用和配置。完整的实现可以在本书附带的源代码的 Data-centric-Applications-with-Vaadin-8\chapter-02\api Maven 项目中找到。

实现 ApplicationLayout 接口

下一步是实现 ApplicationLayout 接口并添加所需的方法:

  • void addHeaderComponent(Component)

  • void addWorkingAreaComponent(WorkingAreaComponent)

  • Collection<Component> getHeaderComponents()

  • Collection<WorkingAreaComponent> getWorkingAreaComponents()

  • void addMenuOption(MenuOption, SerializableConsumer<MenuOption>)

实现addHeaderComponent(Component)方法相当直接:

@Override
public void addHeaderComponent(Component component) {
    component.setWidth(null);
    header.addComponent(component);
    header.setComponentAlignment(component,
            Alignment.MIDDLE_RIGHT);
}

addWorkingAreaComponent(WorkingAreaComponent)方法应避免添加具有相同标题的两个标签页。而不是两次添加相同的标签页,它应该选择相应的现有标签页。使用Collection来跟踪添加的组件,如下面的代码所示:

public class TabBasedApplicationLayout extends CustomComponent
        implements ApplicationLayout {
    ...

    private Collection<WorkingAreaComponent> workingAreaComponents
            = new HashSet<>();

    @Override
    public void addWorkingAreaComponent(WorkingAreaComponent
            component) {
        addWorkingAreaComponent(component, true);
    }

    public void addWorkingAreaComponent(WorkingAreaComponent
            component, boolean closable) {
        if (!workingAreaComponents.contains(component)) {
            TabSheet.Tab tab = tabSheet.addTab(
                    component.getComponent(),
                            component.getCaption());
            tab.setClosable(closable);
            tabSheet.setSelectedTab(tab);
            workingAreaComponents.add(component);
        } else {
            showComponent(component.getCaption());
        }
    }

    public void showComponent(String caption) {
        IntStream.range(0, tabSheet.getComponentCount())
                .mapToObj(tabSheet::getTab)
                .filter(tab -> tab.getCaption().equals(caption))
                .forEach(tabSheet::setSelectedTab);
    }
}

由于这个具体实现基于一个TabSheet,其中每个标签页可以关闭或不能关闭,因此重载ApplicationLayout.addWorkingAreaComponent(WorkingAreaComponent)方法以允许客户端指定此行为是有意义的。

上一段代码中一个有趣的部分是showComponent(String)方法,该方法通过标题选择标签页。此方法使用IntStream遍历TabSheet中的标签页。此方法等同于以下方法:

public void showComponent(String caption) {
   for(int i = 0; i < tabSheet.getComponentCount(); i++) {
       TabSheet.Tab tab = tabSheet.getTab(i);

       if(tab.getCaption().equals(caption)) {
           tabSheet.setSelectedTab(tab);
       }
   }
}

showComponents(String) 方法的实现使用了两个 Java 8 特性:流和管道。有关流和管道的更多信息,请参阅docs.oracle.com/javase/tutorial/collections/streams/index.html

下一个要实现的方法是getHeaderComponents()

@Override
public Collection<Component> getHeaderComponents() {
    return IntStream.range(0, header.getComponentCount())
            .mapToObj(header::getComponent)
            .collect(Collectors.toList());
}

此方法使用与showComponent(String)方法中类似的IntStream。使用Collector创建一个包含标题中所有组件的List

由于我们已经有了一个包含工作区域所有组件的Collection对象,因此getWorkingAreaComponents()方法的实现只是一个常规的 getter:

@Override
public Collection<WorkingAreaComponent> getWorkingAreaComponents() {
    return workingAreaComponents;
}

实现菜单

为了使菜单正常工作,我们可以按照以下方式实现addMenuOption(MenuOption, SerializableConsumer<MenuOption>)方法:

public class TabBasedApplicationLayout ... {
    ...
    private Collection<String> menuButtonStyles = new HashSet<>();
    ...

    @Override
    public void addMenuOption(MenuOption menuOption,
            SerializableConsumer<MenuOption> clickListener) {
        Button button = new Button(menuOption.getCaption(),
                event -> clickListener.accept(menuOption));
        menuButtonStyles.forEach(button::addStyleName);
        menuLayout.addComponent(button);
    }
    ...
}

此方法遍历menuButtonStyles集合,将每个样式添加到新按钮中。最后,设置菜单选项和标题样式的相关方法应如下所示:

public void setHeaderStyleName(String styleName) {
    header.setStyleName(styleName);
}

public void addHeaderStyleName(String styleName) {
    header.addStyleName(styleName);
}

public void setMenuButtonsStyleName(String styleName) {
    menuButtonStyles.clear();
    menuButtonStyles.add(styleName);
    updateMenuButtonsStyle(styleName,
            Component::setStyleName);
}

public void addMenuButtonsStyleName(String styleName) {
    menuButtonStyles.add(styleName);
    updateMenuButtonsStyle(styleName,
            Component::addStyleName);
}

private void updateMenuButtonsStyle(String styleName,
        BiConsumer<Component, String> setOrAddStyleMethod) {
    IntStream.range(0, menuLayout.getComponentCount())
            .mapToObj(menuLayout::getComponent)
            .forEach(component ->
                    setOrAddStyleMethod.accept(
                            component, styleName));
}

组件现在已准备就绪!我们可以在任何 Vaadin 应用程序中使用它。您可以通过与上一章类似的方式创建 Vaadin 应用程序,或者使用标准的 Vaadin Maven 存档。chapter-02模块包括webapp子模块,一个 Vaadin 网络应用程序。以下是在webapp模块中UI实现的初始化方法:

protected void init(VaadinRequest request) {
    TabBasedApplicationLayout layout =
            new TabBasedApplicationLayout("Caption");
    IntStream.range(1, 4)
            .mapToObj(i -> new Label("Component " + i))
            .map(l -> new ApplicationLayout.WorkingAreaComponent(
                    l.getValue(), l))
            .forEach(c -> layout.addMenuOption(
                    new ApplicationLayout.MenuOption(
                            c.getCaption()),
                    (option) ->
                            layout.addWorkingAreaComponent(
                                    c, true)));
    layout.setMenuButtonsStyleName(ValoTheme.BUTTON_LINK);
    setContent(layout);
}

在编译和再次运行应用程序之前,请记住将api依赖项添加到webapp模块的pom.xml文件中:

<dependency>
    <groupId>packt.vaadin.datacentric.chapter02</groupId>
    <artifactId>api</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

尽管在前几节中我们已经通过使用 Vaadin 框架的核心学习了如何构建一个裸骨的主屏幕,但你应该考虑使用在 Vaadin 目录网站发布的 SideMenu Add-onvaadin.com/directory/component/sidemenu-add-on)。这个组件允许你快速实现类似于在官方 dashboard demo 中的侧菜单,你可以在 demo.vaadin.com/dashboard 上看到这个 demo。

模块化 Vaadin 应用程序

在本书中,我们使用术语模块来指代一个可以独立开发和部署的软件组件。从这个意义上讲,模块化应用程序可以在不分发或修改原始应用程序源代码的情况下进行定制和扩展。就我们的目的而言,当一个新的模块被部署时,它必须向应用程序注册。模块的功能在运行时被整合到应用程序中。

请记住,也存在 Maven 模块。本书在提到这类模块时使用完整的术语 Maven 模块或 Maven 项目。

识别模块化的替代方案

在 Java 中实现模块化应用程序有几种机制和方法。例如,如果你需要提供热部署——即运行时部署和卸载模块的能力,你可以使用 OSGi。另一个选项是 服务提供者接口SPI),它是 Java SE 中包含的一组标准接口和类,有助于开发可扩展的应用程序。你甚至可以使用 上下文和依赖注入CDI)或一个 控制反转 框架,例如 Spring 框架提供的框架,来开发基于注入机制的定制模块系统。此外,你还可以使用 Java 反射 API 来创建编译时未知的类的实例。

由于解释所有这些替代方案超出了本书的范围,我们将使用最简单的替代方案:SPI。

在应用程序中注册模块

在应用程序中注册模块意味着将模块的功能添加到应用程序中。这个模块注册执行的操作取决于应用程序的需求。例如,如果应用程序包含主菜单,那么一个模块的可能的注册操作是向主菜单添加菜单项。如果应用程序基于标签页,一个可能的注册操作可以是向主屏幕添加标签页。所有这些操作都需要通过一个共享的 API 来执行。以添加菜单项为例。在这种情况下,一个可能的接口可能如下所示:

public interface MenuItemRegistration {
    void addMenuItem(MenuBar menu);
}

模块可以实现此接口,将菜单项添加到现有应用程序的主菜单中。

由于我们已经有了一个 ApplicationLayout 接口,该接口定义了用于操作布局的方法,所以以下接口就足够好了:

public interface AppModule {
    void register(ApplicationLayout layout);
}

AppModule 接口位于本书附带源代码的 Data-centric-Applications-with-Vaadin-8\chapter-02\api Maven 项目中。

此接口可以打包到一个单独的 JAR 文件中,以便可以将其分发给任何第三方开发者。此 JAR 应包含模块实现可能需要的所有类和接口。这就是我们之前创建 api Maven 模块的原因。这也有另一个优点:api JAR 可以分发给第三方开发者,使他们能够在不分发整个 Web 应用程序编译代码的情况下为应用程序创建新功能。

发现模块

webapp 应用程序应在运行时检测所有 AppModule 的实现。对于每个实现,它应该创建一个新的实例并调用 register(ApplicationLayout) 方法。使用 Java SPI 来做这件事非常简单:

public class VaadinUI extends UI {

    protected void init(VaadinRequest vaadinRequest) {
        TabBasedApplicationLayout layout
              = new TabBasedApplicationLayout("Caption");
        setContent(layout);
        loadModules(layout);
    }

    private void loadModules(
            ApplicationLayout applicationLayout) {
        ServiceLoader<AppModule> moduleLoader =
                ServiceLoader.load(AppModule.class);
        moduleLoader.forEach(
                module -> module.register(applicationLayout));
    }
}

使用 ServiceLoader 类来发现实现 AppModule 接口的所有类。对于每个模块,我们调用其 register 方法,传递应用程序布局以给模块机会初始化自身并在需要时修改布局。

实现新模块

新模块必须实现 AppModule 接口,并遵循 SPI 要求进行打包,通过在 META-INF/services 目录中添加一个名为 packt.vaadin.datacentric.chapter02.api.AppModule 的新文件来实现。此文件必须包含 AppModule 实现的完全限定名称。

假设你想开发一个模块,该模块在点击主菜单时显示通知选项。这可以很容易地实现如下:

package com.example;
...

public class ExampleModule implements AppModule {

    @Override
    public void register(ApplicationLayout layout) {
        ApplicationLayout.MenuOption menuOption
            = new ApplicationLayout.MenuOption("Example module");
        layout.addMenuOption(menuOption, this::optionClicked);
    }

    private void optionClicked(
                ApplicationLayout.MenuOption menuOption) {
        Notification.show("It works!",
                Notification.Type.TRAY_NOTIFICATION);
    }
}

此类可以位于一个单独的 Maven 项目中,并应包含 api 依赖项。

ExampleModule 的实现位于本书附带源代码的 Data-centric-Applications-with-Vaadin-8\chapter-02\example-module Maven 项目中。

要使模块可由 webapp 应用程序发现,你必须在新模块的 main/resources/META-INF/services 目录中添加一个名为 packt.vaadin.datacentric.chapter02.api.AppModule 的文件。该文件必须包含 AppModule 实现的完全限定名称,如下所示:

packt.vaadin.datacentric.chapter02.example.module.ExampleModule

一旦打包,你可以独立部署 JAR 文件,并且 webapp 应用程序应该自动发现并注册该模块。

要将模块与 Web 应用程序一起部署,你可以在 Data-centric-Applications-with-Vaadin-8/chapter-02/webapp Maven 项目的 pom.xml 文件中将其添加为依赖项。如果你将应用程序作为 WAR 文件部署到 Servlet 容器,你可以将 JAR 文件添加到 WEB-INF/lib 目录。

下图是应用程序的截图,显示了正在运行的示例模块:

截图

摘要

在本章中,我们开发了一个自包装的 UI 组件(一个主屏幕组件),创建了一个多模块 Maven 项目,并学习了如何在运行时通过 Vaadin 应用程序发现和注册特定应用模块的实现方法。在解释这些概念的同时,我们还看到了一些 Java 8 和 Vaadin 8 的代码片段,这些片段突出了良好的实践,例如使代码更易于维护和扩展。

在下一章中,你将学习如何实现具有多语言功能的登录表单。

第三章:使用国际化实现服务器端组件

在 Web 应用程序中,拥有一个登录表单可以说是最常见的需求之一。在本章中,你将学习如何实现一个可重用且可扩展的登录表单,它支持多种语言,并看到在实现 UI 组件时优先考虑组合而非扩展的优势。通过示例,我们将讨论为什么扩展并不总是最佳方法,并探讨使用 Vaadin 实现自定义服务器端 UI 组件的几种替代方案。

本章涵盖了以下主题:

  • 扩展布局组件

  • 使用Composite

  • 外部化 Java 字符串

技术要求

你需要拥有 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,你需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-03

查看以下视频以查看代码的实际应用:

goo.gl/fu8W3W

使用扩展来开发 UI 组件

让我们探讨如何实现一个登录表单组件。在开始开发 UI 组件时,脑海中首先浮现的想法是在 Java 意义上扩展现有组件。大多数情况下,自然的选择是扩展布局组件,如VerticalLayoutHorizontalLayout。例如,登录表单通常至少包括用户名字段、密码字段、登录按钮和一个记住我复选框,所有这些都垂直对齐。因此,让我们首先直接扩展VerticalLayout

扩展 VerticalLayout

以下代码片段展示了扩展VerticalLayout以实现 UI 组件的典型方式,在这种情况下,是登录表单:

public class LoginFormLayout extends VerticalLayout {

    private TextField username = new PasswordField();
    private PasswordField password = new PasswordField();
    private Button logIn = new Button();
    private CheckBox rememberMe = new CheckBox();

    public LoginFormLayout() {
        ...
        addComponents(username, password, logIn, rememberMe);
    }
    ...
}

在上一个示例中省略了处理事件和可能需要的额外 UI 配置的逻辑。

LoginFormLayout类的完整实现位于本书附带源代码的Data-centric-Applications-with-Vaadin-8/chapter-03 Maven 项目中。

为什么避免扩展?

前一个实现有什么问题?嗯,它本身并没有什么固有的问题。然而,它可以得到极大的改进。《LoginFormLayout》类违反了封装性!该类的客户端知道使用了VerticalLayoutVerticalLayout的所有公共方法都暴露给了LoginFormLayout的客户端。如果出于某种原因,实现需要更改为不同的布局(例如FormLayoutCssLayout,甚至是Panel),调用VerticalLayout中任何不在新基类中的方法的客户端将会出错。

对于外界来说,LoginFormLayout 是一个 Layout。登录表单的目的不是作为布局(定位其他组件),而是显示用于验证用户与应用程序交互所需的字段。所以,让我们尝试在设计中获得更多的封装性!

使用组合来开发自定义组件

如果扩展 VerticalLayout 是一个问题,一个可能的解决方案是完全不扩展任何类。然而,在这种情况下,我们不会得到一个 Component,而是一个其实例不能添加到组件树中的类。那么扩展层次结构中更合适的组件怎么样?让我们从接口开始。以下图显示了层次结构中的一些顶级接口:

图片

在层次结构中向上,我们找到了 Component 接口,它有超过 20 个需要实现的方法。其他接口继承这些 20 多个方法并添加一些。幸运的是,Vaadin 为这些接口提供了抽象实现。以下图显示了其中的一些等效实现:

图片

AbstractComponentComponent 的默认实现。许多 Vaadin 组件直接扩展了这个类。然而,对于登录表单来说,它并不是一个方便的类,因为它太通用,并且不提供添加其他组件的方法。AbstractFieldAbstractListing 也可以被丢弃,因为登录表单不仅仅是一个显示值或值列表的字段。

下一个候选类是 AbstractSingleComponentContainerAbstractComponentContainerAbstractLayout。这些类有助于布局的实现,但正如我们之前讨论的,登录表单不应该在对外界看来像布局一样。

实现工厂

那么组件工厂怎么样?以下是一个包含返回 Component 方法的工厂实现:

public class LoginFormFactory {

    public static Component getComponent() {

        ... create and configure all required components

        return new VerticalLayout(
                username, password, button, rememberMe);
    }
}

这隐藏了实现细节,但也使得向客户端提供功能变得更加困难和复杂。例如,类客户端如何获取用户在表单中输入的用户名或密码值?一个选项是在工厂类中实现获取器,但这将需要在 LoginFormFactory 类中进行一些调整。最终,这种实现将需要你为单个自定义组件实现(并维护)两个高度耦合的类。这不是一个好主意。

使用复合类

如果你有一些 Vaadin 的经验,那么你很可能已经知道CustomComponent类。Composite类以与CustomComponent类相同的方式工作,但它更轻量,因为它只在浏览器 DOM 中添加一个简单的<div>元素。Composite类通过消除之前描述的一些问题来简化组件组合的开发。Composite直接扩展了AbstractComponent,这意味着任何扩展Composite的类本身就是一个Component,可以添加到任何 Vaadin 布局中。Composite可以指定一个组合根,作为组件树(通常是布局)的根,例如:

public class LoginFormComponent extends Composite {

    public LoginFormComponent() {

        ... create and configure all required components

        VerticalLayout layout = new VerticalLayout(
                username, password, button, rememberMe);

        setCompositionRoot(layout);
    }

    ... getters and setters
}

使用LoginForm

Vaadin 自带一个LoginForm类,默认情况下,它会渲染用户名和密码字段。它还在浏览器中添加了自动完成自动填充功能。LoginForm类是一个很好的扩展候选(如果你想要覆盖其默认设置,你必须扩展它)。例如,以下代码片段创建了一个loginForm和一个监听器,当用户点击登录按钮时会被调用:

LoginForm loginForm = new LoginForm()
loginForm.addLoginListener(e ->  {
    String password = e.getLoginParameter("password");
    String username = e.getLoginParameter("username");
    ...
});

要向表单中添加更多字段,请覆盖createContent方法。例如:

LoginForm loginForm = new LoginForm() {
    @Override
    protected Component createContent(TextField username,
            PasswordField password, Button loginButton) {

        CheckBox rememberMe = new CheckBox();
        rememberMe.setCaption("Remember me");

        return new VerticalLayout(username, password, loginButton,
                rememberMe);
    }
};

尽管其设计是为了扩展,但总是通过扩展Composite并抽象出底层的LoginForm类来隐藏实现细节是一个好主意。以下代码片段展示了新LoginFormComponent类的一个初始迭代:

public class LoginFormComponent extends Composite {

    private TextField username;
    private PasswordField password;
    private CheckBox rememberMe = new CheckBox();

    public LoginFormComponent() {
        LoginForm loginForm = new LoginForm() {
            @Override
            protected Component createContent(TextField username,
                    PasswordField password, Button loginButton) {
                LoginFormComponent.this.username = userNameField;
                LoginFormComponent.this.password = passwordField;

                rememberMe.setCaption("Remember me");

                return new VerticalLayout(username,password,
                        loginButton, rememberMe);
            }
        };

        setCompositionRoot(loginForm);
    }
}

createContent方法由LoginForm类内部调用。注意usernamepassword变量是如何在LoginFormComponent类中被分配给引用的。这些引用可以在以后用来检索字段中的值。

允许LoginFormComponent类的客户端在用户点击登录按钮时被通知可以通过自定义LoginListener接口来实现:

public class LoginFormComponent extends Composite {

    public interface LoginListener {
        void logInClicked(LoginFormComponent loginForm);
    }
    ...

    private LoginListener loginListener;

    public LoginFormComponent(LoginListener loginListener) {
        this();
        this.loginListener = loginListener;
    }

    public LoginFormComponent() {
        ...

        loginForm.addLoginListener(this::logInClicked);
        ...
    }

    public void setLoginListener(LoginListener loginListener) {
        this.loginListener = loginListener;
    }

    private void logInClicked(LoginForm.LoginEvent loginEvent) {
        if (loginListener != null) {
            loginListener.logInClicked(this);
        }
    }
}

LoginListener接口定义了一个接受LoginFormComponent的方法。现在,定义 getter 以允许客户端获取字段中的值变得很容易:

public class LoginFormComponent extends Composite {
    ...

    public String getUsername() {
        return username.getValue();
    }

    public String getPassword() {
        return password.getValue();
    }

    public boolean isRememberMe() {
        return rememberMe.getValue();
    }
}

如果将来在登录表单中添加了新的组件,可以添加一个 getter 来返回添加的字段中的值,而不会破坏现有客户端的类。

LoginFormComponent类的最终版本可以在本书所附源代码的Data-centric-Applications-with-Vaadin-8\chapter-03 Maven 项目中找到。

使用国际化支持多语言

国际化是指使一个应用程序准备好支持多种语言和数据格式的过程。一个国际化的应用程序可以被适应特定的语言和地区,这个过程称为本地化,它包括向国际化的应用程序添加一组特定的资源(通常是文本、图像和数据格式)。理想情况下,本地化不应该需要重新构建应用程序,而只需添加本地化资源,最多只需要重新启动 Web 容器。

在软件开发项目的早期阶段处理国际化,并了解受众,可以使这个过程变得容易得多。国际化与所有应用程序层都是正交的,本地化的过程可能涉及翻译和定义多个资源,如文本、图像、视频、音频文件、数字格式、日期格式、货币符号,甚至颜色。

移除硬编码的字符串

自定义可重用 UI 组件不应依赖于处理国际化的机制。例如,LoginFormComponent应该包含设置器(或者也可以在构造函数中提供参数)来配置内部 UI 组件的标题。以下实现展示了如何使用设置器在登录表单中配置标题:

public class LoginFormComponent extends Composite {
    ...

    private String usernameCaption = "Username";
    private String passwordCaption = "Password";
    private String loginButtonCaption = "Log in";
    private String rememberMeCaption = "Remember me";

    public LoginFormComponent() {
        LoginForm loginForm = new LoginForm() {
            @Override
            protected Component createContent(...) {
                username.setPlaceholder(usernameCaption);
                password.setPlaceholder(passwordCaption);
                loginButton.setCaption(loginButtonCaption);
                rememberMe.setCaption(rememberMeCaption);
                ... 
           }
        };

        ...
    }

    public void setUsernameCaption(String usernameCaption) {
        this.usernameCaption = usernameCaption;
    }

    ... similar setters for password, login, and remember me ...
} 

提供默认值并提供一种方法,在一次调用中设置所有标题,这是一个好主意。示例应用程序中的实现包括这些功能。

获取本地化字符串

在这一点上,LoginFormComponent可以进行国际化。下一步是传递包含正确语言标题的字符串。通常,LocaleResourceBundle标准 Java 类足够用于外部化本地化消息。然而,将字符串外部化逻辑隔离到单独的类中也是一个好主意,这样客户端可以通过名称添加资源包并获取本地化字符串。将此逻辑封装到单独的类中允许你更改底层机制(例如,从数据库中读取消息)并添加诸如缓存等特性,而不会影响应用程序的其他部分。

以下是一个Messages实用类实现的示例,用于封装字符串外部化逻辑:

public class Messages { 

    private static final TreeSet<String> baseNames = 
            new TreeSet<>(); 

    public static void addBundle(String baseName) { 
        baseNames.add(baseName); 
    } 

    public static String get(String key) { 
        return baseNames.stream() 
                .map(baseName -> ResourceBundle.getBundle( 
                        baseName, UI.getCurrent().getLocale())) 
                .filter(bundle -> bundle.containsKey(key)) 
                .map(bundle -> bundle.getString(key)) 
                .findFirst().get(); 
    } 

} 

这个类可以用来注册标准ResourceBundle类内部使用的基本名称。这个基本名称应该与包含翻译的属性文件名称匹配。例如,为了添加英语和西班牙语消息,你必须创建两个文件,messages_en.propertiesmessages_es.properties。这些文件名称中的messages部分对应于基本名称。你可以通过调用Messages.addBundle("messages")来加载这些资源包。

Messages 类位于与本书配套的源代码的 Data-centric-Applications-with-Vaadin-8\chapter-03 Maven 项目中。该类包含一个获取所有可用语言的方法,以便允许最终用户从 UI 中更改语言。

支持新语言与添加新的 .properties 文件(在 resources 目录中)一样简单(或复杂),该文件包含翻译后的属性。例如,messages_en.properties 文件可以定义以下属性:

auth.username=Username
auth.password=Password
auth.login=Login
auth.rememberMe=Remember me
auth.logout=Logout
auth.bad.credentials=Wrong username or password

要支持西班牙语,例如,您需要添加一个包含以下内容的 messages_es.properties 文件:

auth.username=Usuario
auth.password=Contrase\u00f1a
auth.login=Acceder
auth.rememberMe=Recordarme
auth.logout=Salir
auth.bad.credentials=Usuario o contraseña incorrectos

注意,如果您想包含特殊字符(如示例中的西班牙语 n 带有 波浪号),则必须使用 Unicode 转义 语法。

您可以通过调用 Messages.get("property") 来获取浏览器语言的短信。例如,以下代码片段为 LoginFormComponent 中的组件设置了正确的标签:

loginForm.setCaptions( 
        Messages.get("auth.username"), 
        Messages.get("auth.password"), 
        Messages.get("auth.login"), 
        Messages.get("auth.rememberMe")); 

获取和设置区域设置

Vaadin 会自动设置浏览器报告的 Locale。您可以通过调用 UI::getLocale() 方法来获取此 Locale,并通过调用 UI::setLocale(Locale) 方法来设置当前用户的 Locale。本章的示例应用程序使用浏览器报告的 Locale。除了使用辅助 Messages 类添加资源包外,无需做其他任何事情。示例应用程序在 UI 实现的静态块中这样做(VaadinUI 类):

static {
    Messages.addBundle("messages");
}

在更复杂的场景中,您应该使用事件监听器,如 ServletContextListener,在上下文启动时添加资源包,例如。

您可以配置浏览器以不同的语言来测试此功能。如何配置可能取决于您的浏览器和操作系统供应商。然而,在 Chrome 中,您可以使用语言设置。只需将您要测试的语言移动到列表的顶部。您必须重新启动 Chrome 以使此更改生效。

下图是使用西班牙语区域设置的 LoginFormComponent 的截图:

图片

国际化需要在 UI 开发的整个过程中持续努力。尝试捕捉自己 硬编码 字符串,并立即通过在适当的属性文件中创建条目来修复它们。将此实践纳入您的编码常规中。

在实现真正国际化的应用程序时,您应该有一个定义良好且简单的流程,允许翻译者创建所有新资源的所有本地化(翻译)。这样做的一种方法是通过使用定义良好的目录或文件,翻译者可以在构建新的生产就绪工件之前取走并完成(例如,通过翻译字符串)。

摘要

在本章中,我们通过考虑几种方法,如扩展布局组件、扩展专用组件以及使用Composite类进行组合,学习了如何在面向对象技术的帮助下设计 UI 组件。我们开发了一个LoginForm类,该类使用浏览器的语言来显示适当的语言字幕。

在下一章中,你将学习如何通过添加身份验证和授权功能来使登录表单变得可用。

第四章:实现认证和授权

认证是确保用户身份的过程,通常通过提供一组识别凭证(用户名和密码)来完成。授权是确定用户在应用程序中访问级别的安全过程。在本章中,我们将继续开发在第三章中实现的登录表单,使用国际化实现服务器端组件,通过添加认证和授权功能。我们还将学习如何在登录表单中实现记住我选项。

本章涵盖了以下主题:

  • HTTP 会话

  • Cookie 管理

  • 授权和认证机制

技术要求

您需要具备 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。您还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,您需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-04

查看以下视频以查看代码的实际效果:

goo.gl/RM8KNY

实现公共和私有视图

通常,Web 应用程序有两个主要屏幕。一个用于未认证的访客,另一个用于已认证的用户。以这种方式实现 UI 以反映这一点是有意义的。想法是创建两个自定义组件(使用Composite类);一个用于公开访问,另一个用于已认证用户。所以,现在让我们假设我们有一个自定义的PublicComponent类,它显示登录表单,以及一个PrivateComponent,它显示如下内容:

当用户成功认证后,将显示PrivateComponent。当点击注销按钮时,用户应被重定向到PublicComponent。在本章示例中,Vaadin UI实现(VaadinUI)应反映这样一个事实:根据用户是否已认证,可以显示两个主要屏幕。

UI实现的init方法应验证用户是否已经认证,如果是,则显示PrivateComponent。否则,应显示PublicComponent。这是必要的,以覆盖用户在浏览器中重新加载页面的情况:我们不希望用户在页面重新加载后需要重新认证。在纯 Java 中,此功能看起来如下:

public class VaadinUI extends UI {

    static { Messages.addBundle("messages"); }

    @Override
    protected void init(VaadinRequest vaadinRequest) {
        if (AuthService.isAuthenticated()) {
            setContent(new PrivateComponent());
        } else {
            setContent(new PublicComponent());
        }
    }
}

我们将在稍后开发 AuthService 类,但本节的重点是向你展示 UI 实现可以多么简单。始终尝试保持你的 UI 实现简单!将应用程序的实际功能委托给其他类。在你的 UI 实现中仅反映应用程序顶级工作流程的本质。在我们的案例中,一个简单的类显示两个主要屏幕之一,以及根据认证状态的一个合理的默认值,就足够了。

Vaadin 框架的一个安全特性是默认情况下,无法通过请求不同的 URL 来访问代码的执行。在先前的示例中,请求 http://localhost:8080 总是会调用 init 方法,这给了我们机会通过询问一个服务类来检查用户是否已认证。你可能想知道认证状态是如何在服务器上保持的。答案是 HTTP 会话。

你可以在 vaadin.com/security 上了解更多关于 Vaadin 框架应用中的安全信息。

使用 HTTP 会话和 cookies 来识别用户

跟踪 Web 应用程序状态的一种方式是利用 HTTP 会话。当前认证的用户是应用程序状态的一部分,可以存储在 HTTP 会话中。在 Vaadin 应用程序中,你可以使用 VaadinSession.setAttribute(String, Object) 方法在 HTTP 会话中存储值。第一个参数是值的自定义标识符,它使用第二个参数指定。例如,我们可以在 HTTP 会话中按以下方式将数字 777 存储在名为 number 的属性中:

VaadinSession.getCurrent().setAttribute("number", 777);

你可以通过传递 null 来从会话中移除值:

VaadinSession.getCurrent().setAttribute("number", null);

跟踪认证用户

采用这种方法,我们可以在用户成功认证时在 HTTP 会话中存储 username。我们还可以通过检查 HTTP 会话中是否存在值来检查用户是否已认证。这可以按以下方式实现:

public class AuthService {

    private static final String USERNAME_ATTRIBUTE = "username";

    public static boolean authenticate(
            String username, String password) {

        boolean authentic = "admin".equals(username) &&
                "admin".equals(password);

        if (authentic) {
            VaadinSession.getCurrent().setAttribute(
                    USERNAME_ATTRIBUTE, username);
        }

        return authentic;
    }

    public static boolean isAuthenticated() {
        return VaadinSession.getCurrent().getAttribute(
                USERNAME_ATTRIBUTE) != null;
    }
}

在这里有几个需要注意的地方。首先,为了简化这个示例,代码检查 usernamepassword 是否都等于字符串 "admin"。在实际应用中,这应该查询数据库或委托给任何其他认证过程。例如,如果你有一个提供查询用户数据功能的类,布尔检查可能看起来像以下这样:

User existingUser = userRepository.findByUsernameAndPassword(
        username, password);
boolean authentic = existingUser != null;

永远不要以可以恢复的方式存储密码。换句话说,总是存储密码的盐值哈希而不是密码本身。这不仅可以保护你的用户,也可以保护你自己!如果你将密码存储为其哈希值,你可以确信没有人,包括你自己,能够知道真正的密码。如果数据库被破坏,至少密码将会是垃圾。假设你有一个使用 SHA 或其他任何安全算法的hash方法。在设置密码时,你可以保存一个实体,如下所示:

user.setPassword(hash(theActualPassword));

为了检查密码是否正确(例如,在认证过程中),你可以将给定密码的哈希值与数据库中存储的值进行比较。如下所示:

  • String stored = user.getPassword();

  • String hash = hash(attemptedPassword);

  • if (stored.equals(hash) {...}

其次,AuthService类中包含了一些 Vaadin 相关的内容。服务类应该与表示技术解耦,但就我们而言,这是可以的,因为我们不太可能改变我们的 Web 框架!在现实生活中的应用程序中通常也是这种情况。此外,在 Vaadin 应用程序之外重用这个类似乎不太可能,但如果有必要,你可以通过直接使用 HTTP 会话将其从 Vaadin 中解耦。

如果你的应用程序允许第三方开发者向你的应用程序添加新功能,并且它公开了 HTTP 会话,那么如果开发者知道用户的用户名,他们可能能够冒充用户。由于将用户声明为已认证的唯一条件是在 HTTP 会话中有一个条目,其键为相应的用户名,恶意开发者可以添加这样的用户名并代表他们调用其他功能。在这种情况下,考虑对称加密键(用户名)或者甚至使用 HTTP 会话的替代存储机制。

实现登录/注销过程

让我们回顾一下到目前为止我们已经实现的内容。我们有一个多语言的LoginFormComponent准备使用(在第三章,使用国际化实现服务器端组件中开发),一个UI实现,根据用户是否认证显示PublicComponentPrivateComponent,以及一个AuthService类,允许我们验证用户(如果他们的登录凭证正确)并检查会话中是否有已认证的用户。

是时候通过实现PublicComponentPrivateComponent类来完成登录/注销过程了。让我们从PublicComponent类开始:

public class PublicComponent extends Composite {

    public PublicComponent() {
        LoginFormComponent loginForm = new LoginFormComponent();
        loginForm.setCaptions(
                Messages.get("auth.username"),
                Messages.get("auth.password"),
                Messages.get("auth.login"),
                Messages.get("auth.rememberMe"));

        loginForm.setLoginListener(form -> loginClicked(form));
        setCompositionRoot(loginForm);
    }

    private void loginClicked(LoginFormComponent form) {
        if (!AuthService.authenticate(
                form.getUsername(), form.getPassword())) {
            Notification.show(
                    Messages.get("auth.bad.credentials"),
                            Notification.Type.ERROR_MESSAGE);
        }
    }
}

这个组件扩展了Composite,并使用LoginFormComponent作为其组合根。当用户点击相应的按钮时,会调用loginClicked方法,并且就在这个方法中我们尝试验证用户。如果凭据正确,我们显示一个错误通知,但如果它们是正确的,我们实际上根本不需要做任何事情!实际上,我们在这个类中真的不需要做其他任何事情。你还记得我们是如何实现VaadinUI类,以便根据认证状态显示一个屏幕或另一个屏幕的吗?好吧,为了使这个功能正常工作,我们只需要在认证成功时将简单的页面刷新添加到AuthService.authenticate方法中:

public class AuthService {

    private static final String USERNAME_ATTRIBUTE = "username";

    public static boolean authenticate(
            String username, String password) {

        boolean authentic = "admin".equals(username) &&
                "admin".equals(password);

        if (authentic) {
            VaadinSession.getCurrent().setAttribute(
                    USERNAME_ATTRIBUTE, username);
            Page.getCurrent().reload();
        }

        return authentic;
    }
    ...
}

确实如此!因为当用户刷新浏览器时,会调用VaadinUI.init方法,并且我们的实现会检查 HTTP 会话中是否存在已认证的用户(通过AuthService类),所以我们不需要做其他任何事情。

那反过来呢?当用户登出时,我们应该执行两个操作:

  1. 删除 HTTP 会话中的所有数据(使会话无效)。

  2. 刷新浏览器(以调用VaadinUI.init方法并自动显示PublicComponent)。

AuthService类中实现这个功能是合理的:

public class AuthService {
    ...

    public static void logout() {
        VaadinService.getCurrentRequest().getWrappedSession()
                .invalidate();
        Page.getCurrent().setLocation("");
    }
}

invalidate方法会从 HTTP 会话中删除任何值并使其无效。如果再次请求应用程序,服务器将创建一个新的会话。

服务器通过多种方式维护会话,例如通过 cookie 或 URL 重写。根据您的特定服务器,您可能需要调用VaadinService.reinitializeSession(VaadinService.getCurrentRequest())以确保在使会话无效后生成新的会话密钥。

注意我们这次是如何重新加载浏览器的。我们不是调用Page.reload()方法,而是确保浏览器中的 URL 请求 Web 应用程序的起始 URL。这也会删除例如可能包含敏感信息的任何片段或参数。

敏感信息指的是任何必须保护免受未经授权访问的数据、信息或知识。

最后,PrivateComponent类的实现应该相当直接。为了完整性,以下是代码:

public class PrivateComponent extends Composite {

    public PrivateComponent() {
        Label label = new Label(
                "User: " + AuthService.getAuthenticatedUser());
        Button logOutButton = new Button(
                Messages.get("auth.logout"),e -> logoutClicked());
        setCompositionRoot(new VerticalLayout(label,
                logOutButton));
    }

    private void logoutClicked() {
        AuthService.logout();
    }
}

注意AuthService.getAuthenticatedUser()方法。您可以使用一行代码实现该方法:

public class AuthService { 
    ... 

    public static String getAuthenticatedUser() { 
        return (String) VaadinSession.getCurrent().getAttribute( 
                USERNAME_ATTRIBUTE); 
    } 
} 

记得在拥有发送用户凭据通过网络登录表单的 Web 应用程序时,始终使用 HTTPS(HTTP 安全)。通过启用 HTTPS,数据会被加密,防止中间人攻击。您可以在vaadin.com/blog/enabling-https-in-your-java-server-using-a-free-certificate了解更多关于如何启用 HTTPS 的信息。

实现记住我功能

“记住我”功能允许用户在关闭浏览器或 HTTP 会话被销毁后,无需输入用户名和密码,自动通过 Web 应用程序进行身份验证。如果用户之前已经进行了身份验证并选择被记住,Web 应用程序将使用 HTTP cookie 记住用户。

实际上,使用“记住我”功能,您的应用程序可以消耗两种类型的“登录凭证”:

  • 用户名和密码组合

  • 之前由 Web 应用程序创建的有效 HTTP cookie

让我们考虑登录/注销过程,这次将“记住我”功能付诸实践。当用户第一次请求 Web 应用程序时,会调用VaadinUI.init方法。此方法将检查用户是否已认证,以便显示相应的UI组件。在我们的示例中,这被委托给AuthService类。AuthService.isAuthenticated方法检查 HTTP 会话中是否有已认证的用户。一开始,没有用户,因此它应该检查用户之前是否被“记住”。忽略细节,我们知道用户之前没有被记住。因此,显示PublicComponent,用户可以使用用户名和密码登录。但这次,用户勾选了“记住我”复选框。

我们需要将这个选择告诉AuthService.authenticate方法(通过从复选框传递一个布尔值),然后它将检查用户名和密码是否正确,如果是,则执行记住用户的逻辑。这是有趣的部分。

通过创建一个名为,例如remember-me的 HTTP cookie 并存储一个允许我们以后识别用户的值,用户会被记住。我们可能会被诱惑简单地在这个 cookie 中存储纯用户名,但这会导致一个严重的安全问题;如果恶意用户能够访问浏览器并获取remember-me cookie 的值,他们只需创建一个带有被盗值的 cookie,就能以该用户身份登录。

我们可以将敏感信息存储在 cookie 中,而是存储一个随机生成的字符串,并使用 Java Map在服务器上存储用户名,其中键是随机字符串,值是用户名。

在本章的示例中,使用 Java Map就足够了。然而,请记住,如果您重新启动服务器,被记住的用户将不再被记住(有意为之)。现实生活中的应用程序应该使用持久Map,例如 SQL 表,但原理完全相同。此外,您可能还想以与用户密码相同的方式存储随机键的散列。这将保护用户,如果此表中的数据被泄露。

因此,让我们回顾一下。用户通过提供用户名和密码并勾选“记住我”选项来登录,Web 应用程序创建了一个包含随机密钥的 cookie,并使用该密钥在 Map 中存储用户名。现在,让我们看看当用户关闭浏览器(或等待 HTTP 会话关闭)并再次请求 Web 应用程序时会发生什么。

如同往常,VaadinUI.init 方法被调用,AuthService.isAuthenticated 方法检查 HTTP 会话中是否有已认证的用户。当然,没有,它继续进行 cookie 检查。这次,有一个 remember-me cookie,所以该方法只是在记住用户的 Map 中搜索用户名并获取用户名的值。现在,它应该将用户名存储在 HTTP 会话中并返回 true。用户已被自动认证!

我们需要考虑的最后一部分是注销操作。当用户注销时,remember-me cookie 应该被销毁,同时也会销毁 Java Map 中记住的用户对应的条目。

我强烈建议您自己尝试实现所有这些。我已经在本书所附源代码中创建了一个名为 remember-me-exercise 的分支。如果您想进行练习,可以使用这个分支作为起点。您可以通过运行以下命令来检出它:

    cd Data-centric-Applications-with-Vaadin-8
    git checkout remember-me-exercise

如果您想查看解决方案,只需检查 master 分支中的代码。

让我们看看一些可用于练习的代码片段。让我们从 HTTP cookie 管理开始。您可以通过使用 VaadinRequest.addCookie 方法向浏览器发送一个新的 cookie。以下代码片段创建了一个名为 remember-me 且值为 admin 的新 cookie 并将其发送到浏览器:

Cookie cookie = new Cookie("remember-me", "admin"); 
cookie.setPath("/"); 
cookie.setMaxAge(60 * 60 * 24 * 15); 
VaadinService.getCurrentResponse().addCookie(cookie); 

setPath 定义了 cookie 的路径。浏览器会在随后的请求中将与该路径关联的 cookie 发送到服务器。

注意,路径应包括 servlet 的上下文路径。您可以通过调用 VaadinServlet.getCurrent().getServletContext().getContextPath() 来获取它。

setMaxAge 方法允许您设置 cookie 有效的时长。时间以秒为单位,这意味着前面的代码片段创建了一个有效期为 15 天的 cookie。

要删除 cookie,将其年龄设置为零。例如,以下代码删除了 remember-me cookie:

Cookie cookie = new Cookie("remember-me", ""); 
cookie.setPath("/"); 
cookie.setMaxAge(0); 
VaadinService.getCurrentResponse().addCookie(cookie); 

您可以通过使用 VaadinRequest.getCookies 方法获取浏览器报告的所有 cookie。您可以通过调用 VaadinService.getCurrent() 获取 VaadinRequest 的实例。以下代码片段检索名为 remember-me 的 cookie 的 Optional 实例:

Cookie[] cookies = VaadinService.getCurrentRequest().getCookies(); 

Optional<Cookie> cookie = Arrays.stream(cookies) 
        .filter(c -> "remember-me".equals(c.getName())) 
        .findFirst(); 

最后,这里有一个提示,用于生成适合记住用户 Map 的随机字符串:

SecureRandom random = new SecureRandom(); 
String randomKey = new BigInteger(130, random).toString(32); 

简而言之,这会将由130位组成的随机生成的BigInteger转换为一系列基于 32 的字符。尽管128位已经足够安全,但一个基于 32 的字符可以占用五个比特。128/5 = 25.6,因此我们需要额外的几个比特来得到下一个 5 的倍数,这导致130/5=26。总之,我们得到 26 个随机字符。请记住,UUID 不是设计成不可预测的,不应该用于标识会话。

一个好的实现应该定期清理存储已记住用户的Map。这可以通过添加一个自定义数据类型来实现,该数据类型不仅存储用户名,还存储过期日期。一个后台进程可以每天运行,检查过期的条目并将它们从Map中删除。

根据用户的角色启用功能

本节讨论授权实现策略。授权是根据定义的政策授予资源访问权限的过程。请记住,认证是验证用户或其他系统是否是他们所声称的身份的过程,授权处理特定用户可以做什么。

授权机制可以根据应用程序的具体要求以多种方式实现。一些应用程序使用基本的公共/私有方法(就像我们在本章中迄今为止所使用的那样),其中策略很简单,即检查用户是否已认证,以便授予对某个 UI 组件的访问权限。其他应用程序可能需要多个角色,每个角色都有不同的权限集。此外,用户可能同时拥有多个角色,并且这些角色可能在运行时发生变化。而且,为了使事情更加复杂,一个角色可以定义一组权限,这些权限也可能在运行时发生变化。

根据您的应用程序必须支持的认证规则复杂度,您将使用一种或另一种授权方法。让我们讨论一些方法,希望它们能给您以启发,并为您提供关于如何实现适合您应用程序的授权机制的想法。

在 UI 组件中编码授权逻辑

我们将要讨论的第一种方法是将授权逻辑包含在 UI 组件本身中。这就是我们在示例应用程序中所做的,其中如果用户已认证,则显示PrivateComponent,如果没有认证,则显示PublicComponent。你可以扩展这个方法,例如使用角色。假设有两个角色:员工管理员。你必须向具有管理员角色的用户显示假设的AdminComponent,向具有员工角色的用户显示EmployeeComponent。你可以轻松编写一个方法,根据角色返回正确的组件,如下所示:

private Optional<Component> getComponent(User user) {

    if (user.getRole().equals(Role.Admin)) {
        return new AdminComponent();

    } else if (user.getRole().equals(Role.Employee)) {
        return new EmployeeComponent();
    }

    return Optional.empty();
}

如果将来出现新的Role,你可以简单地添加另一个if子句来处理该情况。

如果不需要为某个角色创建一个全新的 UI 组件,会怎样呢?例如,假设 EmployeeComponent 必须只为具有 employee 角色的用户显示 delete 按钮,而不是为具有 trainee 角色的用户显示。一个更简单的解决方案是在 EmployeeComponent 类内部编码这个逻辑,如下所示:

public class EmployeeComponent extends Composite {
    public EmployeeComponent() {
        ...

        User user = AuthService.getCurrentUser();

        if (user.getRole().equals(Role.Employee)) {
            Button delete = new Button();
            someLayout.addComponent(delete);
        }
        ...
    }
}

这种方法的一个好处是你可以通过代码来了解哪些内容可见,哪些不可见。然而,你可能会在源代码的各个地方看到授权代码。至少在 UI 相关的类中是这样。然而,这是一个有效的方法,你应该至少考虑它。

这种实现授权方式的一个缺点是它将 UI 代码与授权代码耦合在一起。这使得软件重用变得稍微困难一些。例如,前面的类如果没有携带 AuthService 类就无法在另一个应用程序中使用。幸运的是,我们可以轻松地将这个类从认证相关的内容中解耦。关键是最小权限原则

最小权限原则指出,一个软件实体应该只访问它执行其功能所需的最少或最小量的数据。你能看到 EmployeeComponent 类是如何违反这个原则的吗?这个类只需要知道是否显示 delete 按钮。它并不真正关心角色和认证逻辑。我们向它传递了过多的信息。这个类需要的最小信息量是多少?一个简单的布尔值,告诉它是否显示 delete 按钮。就是这样。可能的实现可以包括一个构造函数参数来达到这个目的。以下是一个例子:

public class EmployeeComponent extends Composite { 
    public EmployeeComponent(boolean showDeleteButton) { 
        ... 

        if (showDeleteButton) { 
            Button delete = new Button(); 
            someLayout.addComponent(delete); 
            ... 
        } 
        ... 
    } 
} 

我们刚刚解除了这个类与认证逻辑之间的耦合。然而,我们将认证逻辑移动到了其他地方。现在,EmployeeComponent 类的客户端必须根据授权规则进行配置。考虑到这样的客户端已经与 AuthService 类耦合,这并不是一件坏事,对吧?看看新的实现:

private Optional<Component> getComponent(User user) { 

    if (user.getRole().equals(Role.Admin)) { 
        return new AdminComponent(); 

    } else if (user.getRole().equals(Role.Employee)) { 
        return new EmployeeComponent(true); 

    } else if (user.getRole().equals(Role.Trainee)) { 
        return new EmployeeComponent(false); 
    } 

    return Optional.empty(); 
} 

Optional 类用作可能为 null 或不为 null 的值的容器(我们这里不是在谈论 Vaadin 的 ContainerContainer 接口在 Vaadin 框架 8.0 中已被移除)。Optional 有助于减少代码中的空值检查。你不需要从方法中返回一个 null 值,而是可以返回一个 Optional,当封装的值是 null 时,它将是空的。这样,方法的客户端就知道返回的值可能为 null。记住,Optional 类的原始目的是作为可选返回值。避免在方法参数中使用 Optional

本讨论的主要收获是要记住,你可以为你的 UI 组件提供配置选项。不要仅仅无谓地将它们与认证类耦合在一起。在构造函数、设置器或甚至在需要时使用配置类提供参数,以便告诉 UI 组件它应该如何看起来以及如何表现。

使用请求数据编码授权

让我们研究一种在 UI 组件外部实现授权的策略。Web 框架可以分为:

  • 基于组件的框架

  • 基于请求/响应的框架

Vaadin 框架是一个基于组件的框架。它抽象掉了请求和响应的概念。在开发 Vaadin 应用程序时,你不需要过多地考虑它,这也是框架的关键特性之一。得益于其允许开发者直接使用 Java 编程语言实现 Web 应用程序的能力,开发者可以使用任何面向对象的技术来实现诸如授权等特性。实际上,在前一节中,我们探讨了如何使用简单的 Java if 语句来实现这一点。

相反,基于请求/响应的框架通常会使我们在前一节中讨论的方法(直接在 UI 组件中编码授权逻辑)的使用变得有些困难,部分原因是因为 UI 层运行在客户端。在客户端编码认证规则是不可行的。基于请求/响应的框架是如何实现授权的?通常,这些框架包括一个 前端控制器,这是一个处理所有请求并决定你的代码中哪一部分应该被调用的软件实体。然后很容易添加一个 过滤器 来根据一组规则保护请求的资源。简而言之,授权是通过服务器端代码(决定在浏览器中显示什么)和根据授权规则保护 URL 的过滤器相结合来实现的。

我们能否在 Vaadin 中使用类似的东西?让我们探索 Vaadin 在 请求信息 方面的能力,看看我们如何利用它来设计一个完全与实际 UI 组件解耦的认证机制。

获取请求信息

当我们谈论对 Web 应用程序的 请求 时,我们是在谈论客户端(通常是浏览器)向 Web 服务器发出的 HTTP 请求。服务器获取 上下文路径 并将请求路由到适当的 Web 应用程序(例如,Vaadin 应用程序)。HTTP 请求的一个重要部分是用于访问应用程序及其资源的 URL。以下截图显示了 URL 的最重要的部分:

图片

使用 Vaadin 框架,你可以访问所有这些部分。例如,为了获取 URL 的 路径信息 部分,你可以调用:

String pathInfo = VaadinRequest.getCurrent().getPathInfo(); 
assert(pathInfo.equals("users")); 

要获取 参数 值,你可以调用:

String name = VaadinRequest.getCurrent().getParameter("name"); 
assert(name.equals("Alejandro")); 

将请求路由到 UI 组件

使用路径信息部分和参数,你就可以实现一个机制,将请求路由到特定的组件,这类似于在基于请求/响应的框架中前端控制器所做的工作。例如:

public class FrontController { 
    public static void route(VaadinRequest request, 
        SingleComponentContainer container) { 

        String path = request.getPathInfo(); 

        if ("users".equals(path)) { 
            container.setContent(new UsersComponent()); 

        } else if ("orders".equals(path)) { 
            container.setContent(new OrdersComponent()); 

        } else { ... }         
    } 
} 

相应的UI实现可能看起来像这样:

public class VaadinUI extends UI { 
    @Override 
    protected void init(VaadinRequest request) { 
        FrontController.route(request, this); 
    } 
} 

FrontController类可以在将请求路由到 UI 组件之前,调用任何授权逻辑,以决定当前用户是否可以看到UI组件。例如:

public class FrontController { 
    public static void route(VaadinRequest request, 
        SingleComponentContainer container) { 

        String path = request.getPathInfo(); 

        if (!AuthService.userCanAccess(path)) { 
            container.setContent(new ErrorComponent( 
                "Access denied.")); 
            return; 
       } 

       ... 
    } 
} 

AuthService.userCanAccess方法可以以各种方式实现:

  1. 一组检查每个路径/角色组合的if/else语句

  2. 对一个 Java Map进行检查,其中每个键是一个路径,每个值是该路径允许的角色Set

  3. 使用外部资源(如 SQL 数据库、Web 服务或properties文件)进行检查

  4. 结合先前替代方案的算法

实施这些解决方案中的每一个都会占用本书太多的空间,而且它们更多地与 Java 相关,而不是 Vaadin 本身,所以我会让你决定如何实现这个方法。

在 Navigator 的帮助下进行授权编码

你可能已经听说过 Vaadin Framework 中的Navigator类。简而言之,Navigator类允许你将 URI 片段与 UI 组件配对。当浏览器中的片段部分发生变化时,相关的 UI 组件将被渲染。它还允许你通过指定其关联的片段来程序性地导航到特定的 UI 组件。例如:

public class VaadinUI extends UI { 
    @Override 
    protected void init(VaadinRequest vaadinRequest) { 
        Navigator navigator = new Navigator(this, this); 

        navigator.addView("view1", new View1()); 
        navigator.addView("view2", new View2()); 
    } 
} 

当你创建一个Navigator时,你指定Navigator附加到的UI以及一个ComponentContainer(例如VerticalLayout),其内容将在视图可见时(例如在浏览器中更改片段时)被替换。你通过使用addView方法将视图名称与 UI 组件关联。在先前的例子中,我们传递了 UI 组件的实例(使用new关键字)。Navigator类将在整个会话中使用这些实例,因此即使导航离开一个视图,每个视图的状态也会得到保持。你可以通过使用重载的addView(String, Class<? extends View>)方法让Navigator类在每次请求视图时创建 UI 组件的新实例。以下是一个示例:

navigator.addView("view1", View1.class);

你可以添加到Navigator中的 UI 组件必须实现View接口,如下面的类所示:

public class View1 extends Composite implements View { 
    public View1() { 
        setContent(new Label("View 1")); 
    } 
}

自从 Vaadin Framework 8.0 以来,View接口包含了一个 Java 8 默认enter方法,因此你不必实现它。Vaadin Framework 8.1 包含了一些额外的默认方法,如果你需要的话可以实施。查看View接口的参考 API 以获取更多信息:https://vaadin.com/api/8.3.2/com/vaadin/navigator/View.html

但让我们回到授权策略的讨论。Navigator类允许你添加一个ViewChangeListener。我们可以使用这个监听器来引入授权规则和安全的 UI 组件。例如:

public class AuthViewListener implements ViewChangeListener { 

    @Override 
    public boolean beforeViewChange(ViewChangeEvent event) { 
        if (AuthService.userCanAccess(event.getViewName())) { 
            return true; 
        } 

        return false; 
    } 
} 

beforeViewChange方法必须返回true以允许视图更改,返回false以阻止它。

Vaadin 框架 8.0 增加了对HTML 5 历史 API的支持。有了它,你可以避免在 URL 中包含hashbangs(那个小的!#序列)。Vaadin 框架 8.2 通过Navigator类增加了对 HTML 5 历史 API 的支持。你可以通过在UI实现上使用@PushStateNavigation注解来激活这项支持。

摘要

在本章中,我们学习了如何通过使用 HTTP 会话来跟踪认证用户。我们还学习了如何通过安全地使用 cookie 来实现“记住我”功能。最后,我们讨论了授权策略,包括直接在 UI 组件中编码授权逻辑以及通过请求数据进行编码。

在下一章中,你将学习如何使用多个 Java 持久化框架通过 Vaadin 连接到 SQL 数据库。

第五章:使用 JDBC 连接到 SQL 数据库

管理信息意味着在数据存储中执行诸如存储、修改、删除、排序、排列、链接和匹配数据等操作。数据库管理系统提供了执行这些操作的手段,而关系数据库是用于 Web 应用程序的最常见的数据存储类型。

本章首先简要讨论了持久化的基本 Java 技术,Java 数据库连接JDBC)。我们将学习如何连接以及如何使用连接池和 SQL 查询从关系数据库中获取数据。我们还将描述 数据存储库 的概念,这是一种封装持久化实现细节的方法。

我们将开发一个非常简单的 Web UI,列出数据库中的数据。这个示例的目的是向您展示数据库连接的基本原理。第六章,使用 ORM 框架连接 SQL 数据库,将专注于更高级的数据库操作和基本数据绑定。

本章涵盖以下主题:

  • JDBC 技术

  • JDBC 驱动程序

  • 连接池

  • SQL 查询执行

  • 数据存储库

技术要求

你需要拥有 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,你需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-05

查看以下视频以查看代码的实际应用:

goo.gl/7VonXg

JDBC 简介

如果你使用 Java 开发了 商业应用程序,你很可能直接或间接(通过对象关系映射框架)使用了 JDBC 来连接和使用关系数据库。关系数据库 是一种以表格形式存储信息的系统;也就是说,在表格中。有许多供应商提供免费和商业的 关系数据库管理系统RDBMS)。最受欢迎的两个开源 RDBMS 是 PostgreSQLMySQL,而 Oracle 数据库Microsoft SQL Server 在商业产品中是知名的选择。这些系统理解 结构化查询语言SQL),这是一种用于执行诸如在表中添加或删除行等任务的 声明性语言

当使用 声明性语言 时,你指定程序需要做什么。相比之下,当使用 命令性语言,例如 Java 编程语言时,你指定如何做。

在我们开始实际代码之前,尝试编译并运行位于Data-centric-Applications-with-Vaadin-8/chapter-05 Maven 模块中的示例应用程序。按照以下步骤操作:

  1. 如果您还没有这样做,将Data-centric-Applications-with-Vaadin-8 Maven 项目导入到您的 IDE 中。

  2. packt.vaadin.datacentric.chapter05.jdbc.H2Server类创建一个运行配置并运行它。这是您的数据库服务器。它在您的计算机上的一个单独进程中运行。或者,您可以从chapter-05目录使用 Maven 运行 H2 服务器:mvn test exec:java -Dexec.mainClass="packt.vaadin.datacentric.chapter05.jdbc.H2Server"

  3. packt.vaadin.datacentric.chapter05.jdbc.DatabaseInitialization类创建一个运行配置并运行它。您应该在日志中看到一个初始化成功的消息。此初始化在数据库中创建了一个新的表(messages),并向其中添加了一些演示行。或者,您可以使用 Maven 运行初始化应用程序:mvn exec:java -Dexec.mainClass="packt.vaadin.datacentric.chapter05.jdbc.DatabaseInitialization"

  4. chapter-05模块中为 Jetty Maven 插件创建一个运行配置。

  5. 将您的浏览器指向http://localhost:8080。您应该会看到由 Vaadin 应用程序渲染的一些演示数据。

  6. 在步骤 2 中启动的 H2 服务器还启动了一个您可以使用它来运行 SQL 查询的 Web 应用程序。让我们试试!将您的浏览器指向http://localhost:8082并使用以下配置进行连接:

图片

  1. 通过执行以下 SQL 语句将新行插入到messages表中:INSERT INTO messages VALUES('Welcome to JDBC!')

  2. 将您的浏览器指向(或重新加载)Vaadin 应用程序。您应该会看到那里列出了新消息:

图片

如果您愿意,您可以停止 Vaadin 应用程序和 H2 服务器,然后再次运行它们。您应该看到与之前相同的所有数据,包括新插入的行。只需记住,您需要首先运行 H2 服务器!

如果您好奇,实际的 H2 数据库文件位置是<home-directory>/h2-databases/demo.mv.db。如果您想重新创建数据库的初始状态,可以删除此文件并再次运行DatabaseInitialization应用程序。

Java 数据库连接JDBCAPI使您的应用程序能够连接到关系型数据库管理系统(RDBMS)并向其发出 SQL 调用。其他 SQL 持久化技术通常是在 JDBC 之上实现的。了解 JDBC 的关键方面将使您在使用其他持久化技术(即使您计划使用或已经使用)时生活更加轻松。

通常,您的应用程序应该按照以下五个步骤来使用 JDBC API 与数据库进行交互:

  1. 为您的数据库添加一个JDBC 驱动

  2. 建立与数据库的连接

  3. 创建一个语句执行一个 SQL 查询。

  4. 获取并处理结果集

  5. 关闭连接。

为你的数据库添加 JDBC 驱动

从 Java 应用程序连接到 RDBMS 是通过 JDBC 驱动 实现的。大多数(如果不是所有)数据库供应商都为其 RDBMS 提供了 JDBC 驱动。在实践中,JDBC 驱动只是你项目中的一个 Java 依赖项(一个 JAR 文件)。例如,如果你需要将应用程序连接到 PostgreSQL 数据库,你需要将 postgresql-x.x.x.jar 文件添加到你的类路径中。当然,这也可以使用 Maven 完成。正是通过这个 JDBC 驱动,你的 Java 应用程序与 RDBMS 进行通信,它通过建立连接并执行 SQL 语句来检索数据。

本书不涉及 RDBMS 和 SQL 的细节。这些主题本身就足够复杂,值得有一本完整的书来介绍。有许多优秀的参考文献和在线资源,你可以查阅以了解更多关于这些主题的信息。

在本书中,我们将使用 H2 数据库。H2 是一个流行的开源数据库引擎,它不需要你在电脑上安装任何东西。所有这些概念也适用于其他 RDBMS,我们将在配套代码中包含片段或注释部分,展示 MySQL 和 PostgreSQL 的具体细节,以便你可以在自己尝试这些数据库时参考。

添加 JDBC 驱动就像在你的项目中包含正确的依赖项一样简单。例如,要包含 H2 JDBC 驱动,将以下依赖项添加到你的 pom.xml 文件中:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
</dependency>

或者,如果你想使用 MySQL 或 PostgreSQL,添加以下依赖项:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>6.0.6</version>
</dependency>

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.1.4</version>
</dependency>

JDBC 被设计为不仅支持关系型数据库,还支持任何类型的数据源,包括文件系统或面向对象系统。当你需要将应用程序连接到任何类型的数据源时,请记住这一点;可能存在一个 JDBC 驱动程序可以用于它。

你当然可以在同一个项目中包含多个 JDBC 驱动。chapter-05 应用程序包含了所有之前的驱动。

在 JDBC 的旧版本中,你必须手动使用 Class.forName 方法加载 JDBC 驱动类。在 JDBC 4.0 中,这不再需要了。类路径中的任何 JDBC 4.0 驱动都会自动加载。

通过连接池建立数据库连接

在使用 Vaadin 开发 Web 应用程序时,最常见的陷阱之一就是忘记你所开发的是一个实际的 Web 应用程序!由于 API 类似于桌面型 UI 框架,很容易忘记 Vaadin 应用程序很可能是同时被多个用户使用的。在建立数据库连接时,你需要牢记 Vaadin 应用的多用户特性。

你在本地机器上运行的桌面应用程序可能在其执行时间内能够与数据库的单个连接完美工作(当然,这取决于应用程序的复杂性)。这是因为应用程序的单用户性质;你知道每个实例只有一个用户。另一方面,一个网络应用程序的单个实例同时被许多用户使用。它需要多个连接才能正常工作。你不想让用户 A、B、C...、X 等待贪婪的用户 Z 释放连接,对吧?然而,建立连接是昂贵的!每次新用户请求应用程序时都打开和关闭连接不是一种选择,因为你的应用程序可能达到大量的并发用户,以及相应的连接。

这就是连接池派上用场的地方。连接池是一个类,它维护到数据库的多个连接,如果你愿意,就像一个连接的缓存。连接池保持所有连接都打开,以便客户端类在需要时可以重用它们。没有连接池,每次你的应用程序需要执行数据库操作时,它都必须创建一个新的连接,执行查询,然后关闭连接。如前所述,这是昂贵的,并且浪费资源。相反,连接池创建一组连接,并将它们“借”给客户端类。一旦连接被使用,它不会被关闭,而是返回到池中并再次使用。

如你所猜,连接池是一个如此知名的模式,以至于存在许多实现。让我们看看如何使用其中之一,BoneCP,这是一个免费的开放源代码 JDBC 连接池实现。

其他流行的连接池实现包括 C3P0Apache DBCP。此外,应用程序服务器和 Servlet 容器提供定义池化数据源的可能性(请参阅 Java 的DataSource接口文档),作为它们配置的一部分。这将数据源配置与你的运行环境解耦,同时免费提供连接池机制。

首先,这里是你需要添加的依赖项:

<dependency>
    <groupId>com.jolbox</groupId>
    <artifactId>bonecp</artifactId>
    <version>0.8.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.25</version>
    <scope>test</scope>
</dependency>

BoneCP 需要 SLF4J,这是一个提供多个日志框架的外观的日志库。这是为了能够在控制台或 Web 服务器日志中看到BoneCP的日志。

每个 Web 应用程序实例应该有一个连接池实例。在前一章中,我们使用了一个静态 Java 块来初始化应用程序级别的资源。这在资源不依赖于其他资源的简单应用程序中是有效的。在更复杂的应用程序中,你的初始化代码可能依赖于其他服务(例如依赖注入)才能工作,所以这次让我们使用一个更实际的方法,并使用ServletContextListenerinit连接池。ServletContextListener允许你的应用程序对servlet 上下文生命周期中的事件做出反应;特别是,初始化 servlet 上下文及其销毁。

与之前的示例一样,chapter-05 Vaadin 应用程序包含一个WebConfig类,它定义了所有 Web 相关的内容;也就是说,servlet 和事件监听器。除了VaadinServlet之外,我们还可以包括一个ServletContextListener,它在创建 servlet 上下文时初始化数据库(即当 Web 应用程序启动时... sort of):

@WebListener
public static class JdbcExampleContextListener implements ServletContextListener {

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        try {
            DatabaseService.init();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
    }
}

多亏了@WebListener注解,这个类将被 servlet 容器发现并自动注册到 servlet 上下文中。在contextInitialized方法中,我们可以添加初始化连接池的代码;在这种情况下,通过委托给我们将要实现的自定义DatabaseService类。

基础设施代码已经准备好了;现在是我们实际使用BoneCp连接池的时候了。让我们从init方法开始:

public class DatabaseService {

    private static BoneCP pool;

    public static void init() throws SQLException {
        BoneCPConfig config = new BoneCPConfig();
        config.setJdbcUrl("jdbc:h2:tcp://localhost/~/h2-databases/demo");
        config.setUsername("sa");
        config.setPassword("");

        pool = new BoneCP(config);
    }
}

这个实现定义了一个静态(每个应用程序只有一个实例)的BoneCP字段,pool,这是实际的连接池。pool字段在init方法中初始化,该方法在应用程序启动时被调用(参见JdbcExampleContextListener类)。

当使用 JDBC 连接数据库时,你需要指定三件事情:

  • 连接 URL:在 JDBC 中,数据库由一个连接 URL 表示。JDBC 使用这个 URL 来获取有关如何连接数据库的信息。在之前的示例中,我们可以看到字符串包含数据库的名称(h2)、主机(localhost)和数据库名称(~/h2-databases/demo)。

  • 用户名:数据库允许你定义一组用户、角色和权限。用户名是数据库可以检查的标识符,以便在数据上授予权限。默认情况下,H2 数据库定义了用户名sa

  • 密码:正如你所猜到的,这是允许数据库引擎运行身份验证检查的东西。默认情况下,H2 为默认的sa用户使用空密码。

如果您现在想使用 MySQL 或 PostgreSQL,您将不得不更改此类中的String文本,重新编译和重新部署。一个更好的方法是外部化这个String。一种方法是用标准的 Java Properties类加载带有连接 URL、用户名和密码的键/值对。例如,chapter-05应用程序在/src/main/resources目录中包含一个datasource.properties文件:

datasource.url=jdbc:h2:tcp://localhost/~/h2-databases/demo
datasource.username=sa
datasource.password=

对于 MySQL 数据库,使用:datasource.url=jdbc:mysql://localhost/demo

对于 PostgreSQL 数据库,使用:datasource.url=jdbc:postgresql://localhost:5432/demo

DatabaseService类现在可以使用这些属性(datasource.*)而不是硬编码的文本:

public class DatabaseService {

    private static String url;
    private static String password;
    private static String username;
    private static BoneCP pool;

    public static void init() throws SQLException, IOException {
        loadProperties();
        createPool();
    }

    private static void loadProperties() throws IOException {
        try (InputStream inputStream = DatabaseService.class.getClassLoader().getResourceAsStream("datasource.properties")) {
            Properties properties = new Properties();
            properties.load(inputStream);

 url = properties.getProperty("datasource.url");
            username = properties.getProperty("datasource.username");
            password = properties.getProperty("datasource.password");
        }
    }

    private static void createPool() {
        ...
 config.setJdbcUrl(url);
        config.setUsername(username);
        config.setPassword(password);        ...
    }
}

连接属性(urlusernamepassword)现在是类中的静态字段,由datasource.properties文件填充。

要使您的 Web 应用程序配置独立于运行环境,可以使用操作系统的环境变量。例如,假设您在计算机上定义了一个名为MY-WEBAPP-CONF-DIRECTORY的环境变量,并将其值设置为~/my-webapp-conf。在这个目录内,您可以放置所有构成配置的.properties文件,例如,datasource.properties文件。Web 应用程序可以像这样读取环境变量:String confDirectory = System.getenv("MY-WEBAPP-CONF-DIRECTORY"),并读取此目录内的任何文件以相应地配置应用程序。使用这种技术,团队中的每个开发者都可以定义他们自己的本地配置。此外,您可以通过定义环境变量并将相应的配置文件放置在对应的环境中轻松地配置测试生产环境——除了检查所有配置属性是否就绪外,无需担心在部署到这些环境时替换文件。确保当属性不存在时显示良好的错误或警告信息。

现在我们已经准备好了连接池,我们可以获取到数据库的实际连接。以下是方法:

Connection connection = pool.getConnection();

一个连接代表与数据库的会话。此接口包含许多方法,用于获取有关数据库功能和连接状态的信息,但最重要的部分允许您创建语句对象。

连接池实现为开发或测试环境提供了良好的配置。这很可能不适合生产环境。请查阅实现文档,并在部署到生产环境时相应地调整配置。

创建语句并执行 SQL 查询

语句对象用于在数据库中调用 SQL 语句。以下代码片段展示了如何从连接池中检索连接对象。此对象用于创建一个新的语句,然后用于执行 SQL 语句:

try (Connection connection = pool.getConnection()) {
    Statement statement = connection.createStatement();
 ResultSet resultSet = statement.execute("SELECT content FROM messages");
}

在本章中,我们使用 Statement 接口及其 createStatement 对应方法。在更关键的应用中,你应该使用 PreparedStatement 接口和 prepareStatement 方法以提高性能并防止 SQL 注入攻击。

获取和处理结果集

如你所见,Statement 类的 execute 方法返回一个 ResultSet 对象。ResultSet 对象表示数据库中的数据。它就像一个指向数据行中的游标。首先,游标放置在第一行之前。你可以使用 next 方法如下迭代行:

while (resultSet.next()) {
    String content = resultSet.getString("content"));
}

在前面的例子中,我们使用 getString 方法来获取与 content 列相对应的值。有各种数据类型的方法:例如,getInt 方法将指定列的值作为 Java int 返回。

关闭数据库连接

当使用连接池时,连接池的实现会负责关闭 JDBC 连接。根据具体实现,你可能需要调用此过程。通常,你希望连接池在 Web 应用程序的生命周期内保持活跃。还记得我们用来初始化连接池的 ServletContextListener 实现吗?嗯,我们可以用它来关闭池。我们只需要实现 contextDestroyed 方法:

@WebListener
public static class JdbcExampleContextListener implements ServletContextListener {
    ...

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
 DatabaseService.shutdown();
    }
}

最后,shutdown 方法实现如下:

public class DatabaseService {
    ...
    public static void shutdown() {
        pool.shutdown();
    }
}

现在,是你再次尝试 chapter-05 演示应用程序的好时机。仔细看看 DatabaseService 类以及它在 VaadinUI 类中的使用。特别是 findAllMessages 方法非常有趣,因为它作为 Vaadin 应用程序和 UI 之间的主要通信点:

package packt.vaadin.datacentric.chapter05.jdbc;

import com.jolbox.bonecp.BoneCP;
import com.jolbox.bonecp.BoneCPConfig;

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

/**
 * @author Alejandro Duarte
 */
public class DatabaseService {

    private static final String SELECT_SQL = "SELECT content FROM messages";
    private static final String CONTENT_COLUMN = "content";
    ...

    public static List<String> findAllMessages() throws SQLException {
        try (Connection connection = pool.getConnection()) {
            Statement statement = connection.createStatement();
            ResultSet resultSet = statement.executeQuery(SELECT_SQL);

            List<String> messages = new ArrayList<>();
            while (resultSet.next()) {
                messages.add(resultSet.getString(CONTENT_COLUMN));
            }

            return messages;
        }
    }
    ...

}

看看 SQL 查询是如何在 String 常量中定义的。你能想到更好的方法来做这件事吗?在一个更复杂的应用中,你可能会有成百上千个 SQL 查询。在这些情况下,一个更好的做法是将 SQL 代码外部化。属性文件可能会有所帮助,但至少可以说,在单行中定义 SQL 查询可能是一个挑战。一个更好的方法是使用 XML,这种格式允许你使用多行来定义值。我们将在 第六章 中看到 MyBatis 如何推广这种方法,使用 ORM 框架连接 SQL 数据库

findAllMessages 方法中,另一个有趣的细节是返回类型。为什么不返回一个 ResultSet 对象呢?我们这样做是为了避免将表示层与持久化实现细节耦合。这就是我们将在下一节中要探讨的内容。

你可能已经注意到findAllMessages方法可能会抛出SQLException。这使得此方法的使用者对某些层面的实现细节有所了解。尽管异常类名暗示正在使用 SQL,但根据 API 文档,SQLException是"一个提供数据库访问错误或其他错误信息的异常"。这个异常在NoSQL数据库的驱动程序中也被使用。

实现数据存储库

在本书的目的下,存储库是一个包含所有或部分CRUD操作(创建、读取、更新和删除)的类。存储库封装了应用程序的持久性细节。存储库持有领域模型(或实体)的手段。

更确切地说,领域模型不仅包括数据,还包括行为。另一个广泛使用的术语是数据传输对象DTO)。尽管 DTO 的原始定义是为了描述在进程之间传输数据的方式,但许多架构(不准确)将 DTO 定义为在相同进程中的软件组件之间携带数据的对象。更复杂的是,还有值对象(如果它们的属性相等,则相等的对象)和实体(基于其身份相等的对象,这可以由单个属性定义)。在记录和设计你的软件时,花些时间研究这些术语,并尝试选择最适合你设计的一个。

定义领域模型

让我们通过一个例子来学习这个。假设你正在实现一个简单的电子商务应用程序。你负责的模块是订单模块,需要通过 Web UI 提供与此模块相关的数据访问。在阅读了规范后,你设计了一个简单的领域模型,由以下类组成:

图片

这相当直接:一个Order对象有一个或多个Product对象和一个Customer对象。你希望使用假设的技术 X来持久化数据(在这个例子中,哪个技术不重要),并且你希望你的 Vaadin UI实现能够直接使用领域类;然而,你不想将你的 UI 实现与 Technology X 耦合。此外,你必须通过 Web 服务公开订单和产品数据,以便外部会计系统使用。因此,你决定实现三个存储库,每个领域类一个:ProductRepositoryOrderRepositoryCustomerRepository

实现存储库和服务

在这一点上,你开始实现 Vaadin UI,并对存储库应该公开的方法有了清晰的理解。然后你在相应的存储库类中实现了这些方法:

图片

让我们更仔细地看看仓库类中的方法。正如你所看到的,所有方法名都以 findget 开头。这是行业中的一个众所周知的标准,并被诸如 Spring DataApache DeltaSpike 这样的库所使用。以 find 开头的方法返回对象集合,而以 get 开头的方法返回单个、可直接使用的值(例如领域实例或其属性之一)。

注意到每个仓库都有一个代表使用技术 X 的入口点的私有 persistence 字段,我们将在本章后面看到具体的例子。如果由于某种原因,你必须将持久化技术更改为其他技术,客户端类不会受到影响。此外,你可以为不同的仓库使用不同的持久化技术,而无需客户端类处理不同的 API。以下代码将清楚地说明这些仓库可以如何实现:

public class OrderRepository {

    private TechnologyX persistence = ...

    public List<Product> findAll() {
        ... use Technology X through the persistence instance to fetch the data ...
        ... convert the data to a List ...
        return list;
    }
    ...
}

所有关于如何获取数据的实现细节都被封装在仓库类中。现在,让我们继续看看如何从 Vaadin 应用程序中使用它。

在进行结对编程时,你的同事建议你应该使用服务类来从 Vaadin UI 中抽象出 仓库 的概念。她认为应该为每个仓库有一个 service 类:ProductServiceOrderServiceCustomerService。这个想法在你看来也很不错;然而,她立刻注意到这些服务类将只是其仓库对应类的简单 门面,并且不会包含任何额外的逻辑。你指出应用程序必须通过会计系统使用的网络服务来公开数据,并且服务类可能被用于此目的。在你和你的同事调查了网络服务必须公开的确切数据后,你们俩决定 追求简洁,而不是为每个 repository 类实现一个 service 类。

相反,Vaadin UI 将被允许引用仓库类。你还决定实现一个单独的 AccountingWebService 类来公开会计系统的数据,这样你就可以知道并控制这个系统未来“看到”的内容。与 Vaadin UI 类一样,网络服务实现将使用仓库类来获取数据。

之前的假设例子并不意味着你不应该在项目中强制实施仓库/服务配对的设计。在做出此类决定之前,总是要停下来思考。例子中的情况显示了开发者如何考虑替代方案,更深入地调查需求,然后做出明智的决定。考虑到将来会维护你代码的开发者。记住你的遗产。

活动记录模式

在项目中,可能有许多架构模式可能会或可能不会有所帮助;特别是关于领域模型和持久化。你可能想了解一下活动记录模式。活动记录类不仅封装了数据,还封装了其持久化操作。例如,前一个示例中的Order类看起来如下:

图片

注意 CRUD 操作是如何在领域类中实现的,这些操作与之前在repository类中实现的方法并列。虽然这是一个值得考虑的替代方案,但在这本书中,我们不会进一步介绍或使用活动记录模式。

摘要

本章介绍了使用 Java 进行数据库连接的基础知识。我们学习了诸如 JDBC 驱动程序(允许应用程序连接到特定的关系型数据库引擎)和连接池(以更好地使用连接资源)等概念。我们学习了如何使用ContextServletListener来初始化连接池或数据库相关服务。我们看到了一个简单的领域模型示例,以及如何通过仓库类来封装对在此模型中表示的数据的访问。

在下一章中,我们将学习几种持久化技术,以及如何将它们与 Vaadin 框架集成。

第六章:使用 ORM 框架连接到 SQL 数据库

Vaadin 框架是一个Web 框架——如果愿意的话,也可以是一个库,它有助于Web 开发*。作为开发者,你有机会将其与其他 Java 技术集成,特别是与任何持久化技术集成。由于最流行的数据持久化技术是 SQL,因此本章致力于探讨从 Vaadin 应用程序连接到 SQL 数据库的几种替代方案。

我们将首先研究对象关系映射的概念,这是一种允许开发者使用面向对象编程语言在否则不兼容的系统中消费和操作数据的技术。然后,我们将继续探讨用于连接到 SQL 数据库的三个最流行的 Java 框架:JPAMyBatisjOOQ。这些技术在行业中广泛使用,了解每个技术的哲学和基础对于选择最适合你项目的选项非常重要。

在本章的各个部分中,我们将开发非常简单的 Web UI,列出数据库中的数据(使用Grid类),并展示一个简单的表单来添加数据。这些示例的目的是向你展示使用 Vaadin 进行数据绑定的基本原理。下一章将专注于更高级的数据绑定,以开发复杂的CRUD(创建、读取、更新和删除)用户界面。

本章涵盖了以下主题:

  • 对象关系映射 (ORM)框架

  • Java 持久化 API (JPA)

  • MyBatis

  • Java 面向对象查询 (jOOQ)

技术要求

你需要拥有 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,你需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-06

观看以下视频,以查看代码的实际运行情况:

goo.gl/p1CGkr

使用对象关系映射框架

在关系型数据库中,数据以的形式表示。在 Java 程序中,数据以对象的形式表示。例如,如果你有与客户相关的数据,你可以将这些数据存储在customers表中。同样,你也可以将这些数据存储在Customer类的实例对象中。对象关系映射框架允许你在这两个系统之间转换数据。

我们已经在上一章学习了如何通过 JDBC 和ResultSet接口获取数据。你可以获取这个接口的一个实例,遍历行,并手动设置 Java 类如Customer的字段。当你这样做的时候,你就是在做 ORM 框架的工作。为什么要重新发明轮子呢?Java 生态系统提供了几个选项来解决对象-关系阻抗不匹配。在接下来的几节中,我们将检查三种最流行的替代方案来解决这种阻抗不匹配。

面向对象范式基于软件工程原则,而关系范式基于数学原则。对象-关系阻抗不匹配指的是这两个范式之间的不兼容性。

在接下来的几节中开发的示例使用的是我们在上一章介绍 JDBC 时初始化的 H2 数据库实例。请确保 H2 服务器正在运行,并且messages表存在(有关详细信息,请参阅packt.vaadin.datacentric.chapter05.jdbc.DatabaseInitialization类)。如果你想使用其他数据库,请确保你的数据库服务器正在运行,创建messages表,并配置你的应用程序以指向你的新数据库。

使用 JPA 连接 SQL 数据库

关于 JPA 的第一件事你需要知道的是,它是一个规范,而不是一个实现。有几个实现,其中HibernateEclipseLink可能是最受欢迎的。在这本书中,我们将使用 Hibernate。关于 JPA 的其他一些事情,通过编码学习会更好!让我们看看如何创建一个简单的 Vaadin 应用程序,该程序显示数据库中的Grid消息。

你认为开始使用 JPA,或者更具体地说,Hibernate 的第一件事需要做什么?当然是添加依赖项。创建一个 Vaadin 项目,并将以下依赖项添加到你的pom.xml文件中:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.2.10.Final</version>
</dependency>

你可以在本节的Data-centric-Applications-with-Vaadin-8/chapter-06/jpa-example Maven 项目中找到所有开发的代码。

定义持久化单元

因此,JPA 已经包含在类路径中。下一步的逻辑步骤可能是什么?定义数据库连接是合理的。最简单的方法是创建一个persistence.xml文件。JPA 将自动读取类路径中META-INF目录下的此文件。在 Maven 项目中,位置是resources/META-INF/persistence.xml。在这个文件中,你可以定义一个或多个持久化单元(数据库连接)及其连接属性。以下是一个你可以用来连接 H2 数据库的最小persistence.xml文件示例:

<persistence xmlns="http://java.sun.com/xml/ns/persistence"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://java.sun.com/xml/ns/persistence   
           http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">
        <persistence-unit name="jpa-example-pu">
          <properties>
            <property name="javax.persistence.jdbc.url" 
            value="jdbc:h2:tcp://localhost/~/h2-databases/demo" />
           <property name="javax.persistence.jdbc.user" value="sa" />
           <property name="javax.persistence.jdbc.password" value="" />
          </properties>
        </persistence-unit>
</persistence>

由于你可以定义多个持久化单元(例如,当你的应用程序需要连接到多个数据库时),每个持久化单元都必须有一个名称来标识它。我们为我们的持久化单元使用了jpa-example-pu。注意我们如何使用了之前与纯 JDBC 相同的连接属性(URL、用户、密码)。

创建 EntityManagerFactory

我们已经通过持久化单元定义了一个连接,但应用程序实际上还没有真正使用它。应用程序使用 JPA 的方式是通过EntityManagerFactory。你可以将EntityManagerFactory视为 JPA 的入口点EntityManagerFactory允许你与特定的持久化单元进行交互。如果你喜欢,它几乎,但不完全像连接池。

JPA 实现,如 Hibernate 或 EclipseLink,提供内置的连接池机制,这些机制由内部处理。你通常可以通过在持久化单元定义中使用额外的属性来调整池配置。有关详细信息,请参阅你使用的 JPA 实现的文档。

在我们的案例中,只有一个持久化单元jpa-example-pu,因此对于每个应用程序实例只有一个EntityManagerFactory实例是有意义的。与纯 JDBC 一样,我们可以使用ServletContextListener来创建EntityManagerFactory,但再次,让我们将此委托给另一个类来封装与 JPA 相关的内容:

public class JPAService {

    private static EntityManagerFactory factory;

    public static void init() {
        if (factory == null) {
            factory = Persistence.createEntityManagerFactory("jpa-
             example-pu");
        }
    }

    public static void close() {
        factory.close();
    }

    public static EntityManagerFactory getFactory() {
        return factory;
    }
 }

我们已经定义了initclose方法,分别用于初始化和关闭EntityManagerFactory。这些方法可以在ServletContextListener中使用:

@WebListener
public class JpaExampleContextListener implements ServletContextListener {

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        JPAService.init();
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        JPAService.close();
    }
}

JPAService方法还通过getFactory方法公开了EntityManagerFactory实例。从这里开始,事情开始变得更加功能化。使用 JPA 时,你使用EntityManagerFactory来创建EntityManager实例。将EntityManager视为一个工作单元,例如,你需要与数据库进行的具体交互,例如保存数据或读取数据。某个类可以使用JPAService.getFactory方法,例如,从数据库中获取所有消息。省略实际查询数据库和异常处理的代码,以下是与 JPA 交互的通用基础设施代码:

EntityManager entityManager = JPAService.getFactory().createEntityManager();
entityManager.getTransaction().begin();

... run queries ...

entityManager.getTransaction().commit();
entityManager.close();

此代码获取EntityManagerFactory以创建一个新的EntityManager。有了它,可以开始一个新的数据库事务。在此之后,你可以放置实际运行数据库查询的代码,但在我们揭示...运行查询...代码之前,我们需要实现一个我们缺少的重要类。

实现实体类

JPA 是一个 ORM 框架。它映射SQL 表到 Java 对象。我们已经有 SQL 表messages,但 Java 对应的部分是什么?我们需要定义一个Message类。如果你看过 JDBC 部分的代码,你可能看到了创建消息表的 SQL 代码。如果没有,这里就是:

CREATE TABLE messages(id BIGINT auto_increment, content VARCHAR(255))

我们希望这个表在 Java 端表示如下:

public class Message {

    private Long id;
    private String content;

    ... getters and setters ...
}

这个类将成为 JPA 所说的实体类,一个POJOPlain Old Java Object)类,它被注解以匹配一个 SQL 表。你可以使用注解来告诉JPA 如何映射对象到表。代码本身就能说明一切:

@Entity
@Table(name = "messages")
public class Message {

 @Id    private Long id;

    private String content;

    ... getters and setters ...
}

我们使用@Entity注解来标记类为一个实体。@Table告诉 JPA 映射到哪个 SQL 表。@Id标记与 SQL 表中的主键对应的属性。关于消息表中的id列的auto_increment定义如何?我们不想担心计数ID 以跟踪下一个要使用的值,对吧?我们可以这样告诉 JPA 该列是由数据库生成的:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

根据你的数据库,你可能想要使用不同的策略。例如,对于 PostgreSQL,你很可能会使用GenerationType.SEQUENCE

消息表中的id列定义了行的标识。它必须与其 Java 对应项相同。我们可以通过重写equals方法来实现这一点,由于Object.hashCode方法的契约,我们还需要重写hashCode方法:

  public class Message {
     ...

     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;

         Message message = (Message) o;

        return id != null ? id.equals(message.id) : message.id == null;
     }

     @Override
     public int hashCode() {
         return id != null ? id.hashCode() : 0;
     }

     ...
 }

根据 JavaDocs 中Object.equals方法的说明:“...通常在重写此方法时需要重写hashCode方法,以便维护hashCode方法的通用契约,该契约指出,相等的对象必须具有相等的哈希码”。大多数 IDE 都包含一个功能可以为你生成此代码。

执行查询

连接属性已准备就绪,EntityManagerFactory已准备就绪,实体类也已准备就绪。现在是时候揭露... run queries ...部分了:

Query query = entityManager.createQuery("select m from Message m");
List<Message> messages = query.getResultList();

起初,你可能会认为select m from Message m是 SQL 代码。但事实并非如此!首先,数据库中没有Messages表(它是messages)。其次,这个查询是一个Java Persistence Query LanguageJPQL)查询。

JPQL 是一种与 SQL 类似的平台无关的语言。JPA 将 JPQL 查询转换为相应的 SQL 查询,这些查询可以发送到数据库。如果你想要了解更多关于 JPQL 的信息,网上有很多资源。

之前的代码看起来就像你会在仓库类中封装的东西。让我们这样做,并实现一个MessageRepository类,如下所示:

public class MessageRepository {

    public static List<Message> findAll() {
        EntityManager entityManager = null;
        try {
            entityManager =    
            JPAService.getFactory().createEntityManager();
            entityManager.getTransaction().begin();

 Query query = entityManager.createQuery("select m from   
            Message m");
 List<Message> messages = query.getResultList();

            entityManager.getTransaction().commit();
            return messages;

        } finally {
            if (entityManager != null) {
                entityManager.close();
            }
        }
     }

 }

有很多样板代码。仓库类喜欢有很多方法,其中大多数都需要相同类型的基础设施代码来运行单个查询。幸运的是,我们可以使用一些 Java 结构来封装创建EntityManager、打开和关闭事务以及关闭EntityManager的逻辑。JPAService类看起来是完美的候选者:

public class JPAService {
    ...

    public static <T> T runInTransaction(Function<EntityManager, T> 
      function) {
        EntityManager entityManager = null;

        try {
            entityManager = 
            JPAService.getFactory().createEntityManager();
            entityManager.getTransaction().begin();

 T result = function.apply(entityManager); 
            entityManager.getTransaction().commit();
            return result;

        } finally {
            if (entityManager != null) {
                entityManager.close();
            }
        }
     }

 }

runInTransaction方法是一个通用方法,它使用 Java Function将实际的查询逻辑委托给客户端。得益于 Java lambda 表达式,我们可以按照以下方式清理MessagesService类的代码:

public class MessageRepository {

    public static List<Message> findAll() {
 return JPAService.runInTransaction(em ->
 em.createQuery("select m from Message m").getResultList()
 );    }
}

我们还可以添加一个保存新消息的方法。使用 JPA,这很简单:

public class MessageRepository {
    ...

    public static void save(Message message) {
        JPAService.runInTransaction(em -> {
            em.persist(message);
            return null;
        });
    }
}

注意EntityManager.persist方法直接接受Message实体类的实例。

注意,本章中的配置和代码示例仅在仅使用 Servlet 规范的网络应用程序的上下文中有效。当使用完整的 Jakarta EE(之前称为 Java EE)规范或 Spring 框架时,配置和代码有细微的差异和实践。例如,您应该使用服务器中配置的 JNDI 可发现的数据源,而不是使用 Jakarta EE 指定数据库连接的用户名和密码。此外,可以使用 Jakarta EE 和 Spring 框架自动管理事务边界,这意味着您不需要实现和使用runInTransaction方法。

实现一个用于列出和保存实体的 Vaadin UI

我们如何从 Vaadin UI 中使用它?这根本不是什么秘密,对吧?只需使用 Vaadin 组件,并在需要时调用MessageRepository类。让我们看看它是如何工作的!首先实现一个基本的 UI,显示一个Grid、一个TextField和一个Button,如下所示:

图片

随意实现不同的布局。以下是对应于上一张截图的实现:

public class VaadinUI extends UI {

    private Grid<Message> grid;
    private TextField textField;
    private Button button;

    @Override
    protected void init(VaadinRequest request) {
        initLayout();
        initBehavior();
    }

    private void initLayout() {
        grid = new Grid<>(Message.class);
        grid.setSizeFull();
        grid.getColumn("id").setWidth(100);

        textField = new TextField();
        textField.setPlaceholder("Enter a new message...");
        textField.setSizeFull();

        button = new Button("Save");

        HorizontalLayout formLayout = new HorizontalLayout(textField, button);
        formLayout.setWidth("100%");
        formLayout.setExpandRatio(textField, 1);

        VerticalLayout layout = new VerticalLayout(grid, formLayout);
        layout.setWidth("600px");
        setContent(layout);
    }

    private void initBehavior() {
        // not yet implemented! Stay tuned!
    }
}

之前的实现展示了良好的实践:将构建 UI 的代码与添加到 UI 上的行为代码分离。在这个例子中,行为意味着添加一个ClickListener来保存TextField中的消息,并在网格中显示数据库中的消息。以下完成了 UI 行为实现的代码:

public class VaadinUI extends UI {
    ...

    private void initBehavior() {
 button.addClickListener(e -> saveCurrentMessage());        update();
    }

    private void saveCurrentMessage() {
        Message message = new Message();
        message.setContent(textField.getValue());
        MessageRepository.save(message);

        update();
        grid.select(message);
        grid.scrollToEnd();
    }

    private void update() {
 grid.setItems(MessageRepository.findAll());
        textField.clear();
        textField.focus();
    }
}

我们直接使用MessageRepository类来调用与持久性相关的逻辑。注意saveCurrentMessage方法中是如何进行数据绑定的。这种绑定是单向的:从 UI 到实体。这是您可以使用 Vaadin 的最为基本的数据绑定形式。在Grid的情况下,数据绑定方向相反:从实体到 UI。我们将在下一章中看到更高级的数据绑定技术。

何时应该使用 JPA?一般来说,JPA 适合 RDBMS 的可移植性。JPA 在业界得到广泛应用,并且有许多工具和资源可用。JPA 是一个官方的 Java 规范,多个供应商提供实现(如 Hibernate 和 EclipseLink)。JPA 不是唯一的官方 Java 持久性规范。Java 数据对象JDO)是另一个您可能至少想要考虑的 Java 规范。

使用 MyBatis 连接 SQL 数据库

MyBatis 是一个将 SQL 映射到 Java 对象的持久化框架。MyBatis 的学习曲线比 JPA 平坦,并且利用了 SQL,这使得如果你对 SQL 有很好的了解或者有很多复杂的 SQL 查询需要重用,它是一个很好的选择。

如同往常一样,你首先需要添加依赖项。以下是如何使用 Maven 来做这件事的示例:

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.4.5</version>
</dependency>

你可以在本节中开发的示例的完整实现,在 Data-centric-Applications-with-Vaadin-8/chapter-06/mybatis-example Maven 项目中找到。

定义数据库连接

使用 MyBatis,你可以使用 Java API 或配置 XML 文件来定义数据库连接。最简单的方法是将一个 XML 文件放在类路径中(当使用 Maven 时,是 resources 目录)。以下是一个这样的配置文件的示例:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="org.h2.Driver"/>
                <property name="url" 
                 value="jdbc:h2:tcp://localhost/~/h2-databases/demo"/>
                <property name="username" value="sa"/>
                <property name="password" value=""/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper  
     class="packt.vaadin.datacentric.chapter06.mybatis.MessageMapper"/>
    </mappers>
</configuration>

你可以为这个文件命名任何名称。本节的示例使用 mybatis-config.xml

如你所见,我们使用了与 JDBC 和 JPA 相同的连接属性,但添加了一个 driver 属性。它的值应该对应于你将要用于数据库连接的 JDBC 驱动程序的名称。

我们如何使用这个文件?再一次,我们可以使用 ServletContextListener 来初始化 MyBatis。此外,ServletContextListener 可以委托给一个像以下这样的服务类:

public class MyBatisService {

    private static SqlSessionFactory sqlSessionFactory;

    public static void init() {
        InputStream inputStream = MyBatisService.class.getResourceAsStream("/mybatis-config.xml");
        sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    }

    public static SqlSessionFactory getSqlSessionFactory() {
        return sqlSessionFactory;
    }
}

SqlSessionFactory 类是 MyBatis 的 入口点。前面的类提供了一个可以从 ServletContextListener 调用的初始化方法,它为应用程序的每个实例创建一个 SqlSessionFactory,并通过 getter 暴露它。这与我们之前与 JPA 一起使用的模式类似。

实现映射类

MyBatis 使用 映射类(实际上,是接口)来定义将 SQL 查询映射到 Java 对象的方法。这些几乎等同于我们迄今为止开发的仓库类。然而,当使用 MyBatis 时,使用 MyBatis 术语是有意义的。此外,正如我们稍后将要看到的,我们需要在调用映射类的方法周围添加事务或会话管理代码,但让我们先从映射类开始。如果你足够细心,mybatis-config.xml 文件在 mappers 部分定义了一个映射类。回去看看它。以下是一个这样的映射类的定义:

public interface MessageMapper {

 @Select("SELECT id, content FROM messages")
    List<Message> findAll();

 @Insert("INSERT INTO messages(content) VALUES (#{content})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void save(Message message);

}

如您所见,MessageMapper 是一个接口。您不必实现此接口;MyBatis 将在运行时为您提供实现。我们定义了两个方法:一个用于返回 List 类型的消息,另一个用于保存消息。注意 @Select@Insert 注解。这些注解用于定义当调用这些方法时将运行的 SQL。注意您如何将参数的值传递到 SQL 查询中。保存方法接受一个 Message 实例。在由 @Insert 注解定义的 SQL 查询中,我们使用 #{content}传递 Message.content 属性的值到查询中。您也可以传递一个包含该值的 String。在这种情况下,您可以直接使用参数的名称。然而,我们希望 MyBatis 在行插入后设置 id 属性的值。此值在数据库中是自动生成的,因此我们必须使用 @Options 注解来配置此行为。

实现服务类

如前所述,我们需要添加一些事务和会话处理代码,以便使用映射类。这可以在 服务类 中完成。服务类是一个执行某种业务逻辑的类(相比之下,映射类仅执行持久化逻辑)。以下是一个封装会话处理以避免将 UI 与 MyBatis 相关逻辑耦合的类的示例:

public class MessageService {

    public static List<Message> findAll() {
        try (SqlSession session =   
          MyBatisService.getSqlSessionFactory().openSession()) {
            MessageMapper mapper = 
             session.getMapper(MessageMapper.class);
            return mapper.findAll();
        }
    }

    public static void save(Message message) {
        try (SqlSession session = MyBatisService.getSqlSessionFactory().openSession()) {
            MessageMapper mapper = session.getMapper(MessageMapper.class);
            mapper.save(message);
 session.commit();        }
    }
}

每个工作单元的持久化操作都应该被一个活跃的会话所包围。此外,对于插入或更新操作,我们需要将事务提交到数据库。

MyBatis 是一个强大且成熟的框架,在决定技术时您应该牢记。它有许多其他功能,例如将方法映射到 SQL 存储过程或使用 XML 文件(甚至 Apache Velocity 脚本语言)来定义 SQL 查询的可能性,这在查询需要多行或需要动态形成时非常有用。

使用 jOOQ 连接到 SQL 数据库

jOOQ 是一个持久化框架,允许您使用 Java 编程语言定义 SQL 查询。它具有许多功能,在本节中,我们只展示其中的一些。

与往常一样,您可以通过添加所需的依赖项来开始使用 jOOQ:

<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq</artifactId>
    <version>3.9.5</version>
</dependency>
<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen</artifactId>
    <version>3.9.5</version>
</dependency>

您可以在 Data-centric-Applications-with-Vaadin-8/chapter-06/jooq-example Maven 项目中找到本节中开发的全部代码。

定义数据库连接

您可以使用您喜欢的任何连接池与 jOOQ 一起使用。本节的示例使用与我们使用纯 JDBC 相同的方法,因此连接属性可以在 datasource.properties 文件中定义:

datasource.url=jdbc:h2:tcp://localhost/~/h2-databases/demo
datasource.username=sa
datasource.password=

到目前为止,您应该熟悉如何使用 ServletContextListener 来初始化数据库连接池。让我们省略这部分(有关详细信息,请参阅关于 JDBC 的部分)并直接跳到更具体的话题。

反向工程数据库模式

假设您有一个用于管理书籍和作者的数据库模式。这样一个数据库的可能 SQL 查询可能如下所示:

SELECT AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME
FROM AUTHOR
ORDER BY AUTHOR.LAST_NAME ASC

jOOQ 允许您用 Java 编写相同的 SQL 查询:

dslContext.select(AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
      .from(AUTHOR)
      .orderBy(AUTHOR.LAST_NAME.asc()

如您所见,语法与实际的 SQL 非常接近。您可能想知道 AUTHOR 对象及其属性从何而来。它们来自 jOOQ 生成的代码。代码生成过程可以用 Maven 自动化。以下代码展示了如何在 pom.xml 中配置 jooq-codegen-maven 插件:

<plugin>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen-maven</artifactId>
    <version>3.9.5</version>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <jdbc>
            <url>${datasource.url}</url>
            <user>${datasource.username}</user>
            <password>${datasource.password}</password>
        </jdbc>
        <generator>
            <database>
                <name>org.jooq.util.h2.H2Database</name>
            </database>
            <target>
               <packageName>packt.vaadin.datacentric.chapter06.jooq
               </packageName>
                <directory>target/generated-sources/jooq</directory>
            </target>
        </generator>
    </configuration>
</plugin>

您必须配置连接属性,以便生成器可以扫描数据库模式。您还必须配置数据库以使用它(在这个例子中是 H2)。最后,您必须配置用于生成代码的包以及该项目中该包所在的目录。

虽然有一个小细节。我们使用表达式(如 ${datasource.url})来指定数据库连接属性。您如何在 pom.xml 文件中使用来自 .properties 文件中的值?通过使用 properties-maven-plugin Maven 插件:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>properties-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
        <execution>
            <phase>initialize</phase>
            <goals>
                <goal>read-project-properties</goal>
            </goals>
            <configuration>
                <files>

                <file>src/main/resources/datasource.properties</file>
                </files>
            </configuration>
        </execution>
    </executions>
</plugin>

在之前的配置中,Maven 将能够读取 datasource.properties 文件中的属性,并替换 pom.xml 文件中相应的表达式。

在配置这两个 Maven 插件之后,您可以通过运行 mvn clean package 来逆向工程数据库模式并生成相应的代码。

运行查询

您可以通过创建一个 DSLContext 实例来使用 jOOQ 运行查询。获取此实例的一种方法是通过 DSL.using 方法:

Connection connection = pool.getConnection();
DSLContext dslContext = DSL.using(connection);

通过这种方式,您可以使用 jOOQ 提供的流畅 API 轻松运行查询。例如,要获取消息表中的所有行,您可以使用以下代码:

List<MessagesRecord> messages = dslContext.select()
        .from(MESSAGES)
        .fetchInto(MessagesRecord.class);

MessagesRecord 类和 MESSAGES 实例是由 jOOQ 生成的代码提供的。这使得之前的查询具有类型安全性。

如果由于某种原因,您的数据库模式发生变化,您将遇到编译错误,并在将其部署到生产之前有机会修复问题。这是 jOOQ 的一个优点。

从这里,您可以想象如何使用 jOOQ 实现一个 MessageRepository 类。以下是解决这个谜题的方案:

public class MessageRepository {

    public static List<MessagesRecord> findAll() {
        try {
            return JooqService.runWithDslContext(context ->
                    context.select()
                            .from(MESSAGES)
                            .fetchInto(MessagesRecord.class)
            );

        } catch (SQLException e) {
            e.printStackTrace();
            return Collections.emptyList();
        }
    }

    public static void save(MessagesRecord message) {
        try {
            JooqService.runWithDslContext(context ->
                    context.insertInto(MESSAGES, MESSAGES.CONTENT)
                            .values(message.getContent())
                            .execute()
            );

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

}

以及方便的 JooqService.runWithDslContext 方法:

public class JooqService {
    ...

    public static <T> T runWithDslContext(Function<DSLContext, T> 
 function) throws SQLException {
        try (Connection connection = pool.getConnection(); DSLContext 
 dslContext = DSL.using(connection)) {
            T t = function.apply(dslContext);
            return t;
        }
    }
}

如果您对 jOOQ 感兴趣,您可能想评估 Ebean (ebean-orm.github.io) 和 Querydsl (www.querydsl.com),这两个都是允许您在 Java 中实现类型安全查询的 ORM 框架。

摘要

本章充满了新技术!我们讨论了对象关系映射框架是什么,并研究了如何使用 Java 的三个流行持久化技术:JPA、MyBatis 和 jOOQ 的实践示例。我们看到了 Vaadin 框架如何允许我们直接消费任何类型的 Java API,通常是通过封装细节(如服务和仓库类)的自定义抽象来实现。我们还学习了 Vaadin 中最基本的数据绑定形式,它包括直接从领域对象设置和获取值到 UI 组件。我们还学习了如何将构建 UI 的代码与添加行为以改进其可维护性的代码分开。

在第七章《实现 CRUD 用户界面》中,我们将探讨更多与 Vaadin 相关的主题,并更深入地讨论数据绑定。

第七章:实现 CRUD 用户界面

大多数商业应用程序必须处理数据操作。用户能够查看、更改、删除和添加数据。所有这些操作都是根据业务规定的一组规则在上下文中执行的。在其更基本的形式中,商业应用程序包括图形用户界面来执行对数据的 CRUD 操作。CRUD创建、读取、更新和删除的缩写。本章探讨了 CRUD 视图的设计和实现。

我们将从从用户体验UX)的角度对 CRUD 视图进行简要讨论。然后,我们将继续探讨如何使用两种不同的 UI 设计来设计和实现 CRUD 用户界面。本章还解释了数据绑定的基础知识,展示了如何使用 Java Bean 验证 API,并演示了如何在Grid组件内部渲染 UI 组件。

本章涵盖了以下主题:

  • CRUD 用户界面设计

  • 数据绑定

  • 使用 JSR-303 进行验证

  • 网格渲染器

  • 过滤

技术要求

你需要拥有 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,你需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-07

查看以下视频,看看代码的实际效果:

goo.gl/szGaRy

CRUD 用户界面设计

用户界面UI)的上下文中,UX(用户体验)指的是用户与 UI 之间交互的质量程度。一个考虑到 UX 的应用程序通过提高其可用性来增强用户满意度。在 UX 设计过程中,简洁是关键,但应避免陷入极简主义设计,否则可能会损害可用性。

你可以在uxmyths.com上找到更多关于简洁、极简主义以及 UX 设计的一般神话的信息。

UX 设计可能包括多个学科,包括线框图、原型设计、测试和验证设计。在本节中,我们将探讨典型 CRUD 视图的变体。这类视图的例子包括管理注册用户的管理员视图、内部应用程序配置视图或DevOps成员使用的视图。

DevOps 是一种软件开发学科,它统一了软件开发和软件操作(部署和基础设施管理)。

我们将避免使用 CRUD 这个术语,因为它可能包括所有的 CRUD 操作。一般来说,这些视图是业务特定的,开发者应根据每个案例的特定情况来设计它们。

CRUD 视图关乎记录编辑。记录通常被理解为整体的项目。有些适合表格展示,而有些则不适合;例如,日历上的事件。在设计 CRUD 视图时,考虑以下因素:

  • 记录复杂性:记录包含多少字段?字段是否会根据其他字段的状态而变化?是否存在如地图或日历等复杂字段?验证规则有多复杂?

  • 编辑频率:用户需要多频繁地编辑记录?他们是否需要快速编辑某些字段的方法?

  • 上下文感知:在编辑记录时,用户是否需要额外的数据?他们是否需要,或者从查看其他记录中获益?

作为经验法则,考虑用户将如何频繁地在视图中执行操作,以及他们是否可以从一次看到多个记录中受益。如果任何操作的频率都很高,并且他们不会从在视图中看到其他记录中受益,那么不要使用通用 CRUD 接口。实现针对用例定制的视图。

让我们分析三种 CRUD 用户界面设计:原位字段、模态弹出窗口和分层菜单。

原位字段

使用这种设计,用户可以激活一个字段来编辑单个值。数据可以以表格格式呈现,在这种情况下,点击单元格将激活一个输入字段,允许用户直接编辑值。以下图显示了这种类型界面的一个示例:

图片

Vaadin 框架通过Grid.addComponentColumn方法允许这样做。以下行向现有的Grid添加了一个Button

grid.addComponentColumn(user -> new Button("Delete", e -> deleteClicked(user)));

使用此选项有优点和缺点。主要优点是速度。用户可以快速编辑一个值,而且无需导航到其他视图来编辑数据;然而,实现添加操作需要额外的考虑。当用户点击添加按钮时,会添加一个新空行;然而,很难知道何时可以保存该行(例如,在数据库中)。一个完全空的行是否是一个有效的记录?解决这一问题的方法之一是在所有值都有效时才持久化记录。另一个缺点是当记录包含许多字段时明显,在这种情况下,意味着一个有许多列的网格。在滚动出视图的列中编辑数据需要用户进行额外的交互,这抵消了快速编辑数据的优势。

模态弹出窗口

这种用户界面在用户想要创建、修改或删除记录时始终显示一个模态窗口。对此的一种方法是结合原地编辑器和模态窗口。当用户点击或双击一行时,编辑器会放置在该行上方,显示所有用于编辑数据的输入字段以及取消操作或保存数据的按钮。这正是 Vaadin 框架中的 Grid 编辑器,如下面的截图所示:

图片

这可以通过以下方式实现:

grid.getEditor().setEnabled(true);

第二种方法是实际显示一个阻止与页面其他部分任何其他交互的模态窗口。以下是这样一种界面的图示:

图片

这种方法有几个优点。窗口允许对包含的表单进行任何类型的设计。如果输入字段相关,则可以分组;可以添加帮助文本或说明,并且可以通过多种方式显示验证错误。它也是一个直观的死胡同视图;用户无法导航到任何其他地方,只能返回,这使得它易于使用。

分层菜单

当数据可以以分层的方式表示时,它可以作为 CRUD 中的读取部分,以及作为导航工具。以下图显示了组织部门员工记录的 CRUD:

图片

这种设计的关键元素是使用大部分可用空间来容纳包含输入字段的表单。表单以查看模式显示,并在用户点击编辑按钮时变为可编辑。在前面的图中,记录的简短表示出现在导航菜单本身中。为了编辑一条记录,用户可以从菜单中点击它。当一条记录可以与另一类型的记录中的一个或多个记录相关联时,它们会被分组并以分层的方式在菜单中显示。菜单中的顶级项不一定是记录本身,因为它们可以作为不同类型的分组。例如,顶级项可以显示包含所有实际组织记录的子项

这种设计对于配置选项来说效果很好;然而,它同时显示许多选项的缺点可能会分散最终用户的注意力。例如,用户在编辑了一些字段之后可能会忘记点击保存按钮。

领域模型

以下部分展示了如何使用两种不同的设计实现 CRUD 视图:可编辑模式的 Grid 和模态窗口。但首先,我们需要实现一个领域模型。我们将使用 JPA 和仓库类,这在之前的章节中已经解释过。领域模型由简单的类组成,用于建模基于角色的模式:UserRole。它还包括相应的UserRepositoryRoleRepository类。

让我们从最简单的类Role开始。以下是这个类的完整实现:

@Entity
@Data public class Role {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private Boolean module1Authorized;

    private Boolean module2Authorized;

    @Override
    public String toString() {
        return name;
    }
}

除了通常的 JPA 配置内容(如@Entity@Id@GeneratedValue注解)之外,这个类中最有趣的事情是它没有getterssetters。尽管如此,类中每个 Java 字段的getterssetters都存在!这要归功于Project Lombok,这是一个减少 Java 程序中所需样板代码量的库。Lombok 在类级别生成代码。在前一个类中,我们使用了@Data注解来告诉 Lombok 生成getterssetterstoStringequalshashCode方法。由于 Lombok 生成的toString方法不符合我们的要求,我们重写了它并提供了自定义的一个。

为了使用 Lombok,你需要在你的 IDE 中安装它,并将依赖项添加到pom.xml文件中:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.18</version>
    <scope>provided</scope>
</dependency>

你可以在projectlombok.org找到关于 IntelliJ IDEA、NetBeans、Eclipse 和其他 IDE 的安装说明。安装 Lombok 后,你将能够使用 IDE 的自动完成和其他功能来使用生成的代码,即使你不在 Java 类中看到它。例如,以下截图显示了IntelliJ IDEA在使用自动完成功能时建议生成的getName方法:

图片

你可以使用自己的实现来编写getterssettersequalshashCode方法,而不是使用 Lombok。大多数 IDE(如果不是所有 IDE)都有在源代码级别生成这些方法的功能;然而,使用 Lombok 的源代码文件会变得非常短,这在大多数情况下使得它们更容易维护。@Data不是 Lombok 提供的唯一有用的注解。有关其功能的更多信息,请参阅projectlombok.org的文档。

以下是User类的实现,它也使用了 Lombok:

@Entity
@Data
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String firstName;

    private String lastName;

    private String email;

    private String password;

    private boolean blocked;

    @ManyToMany(fetch = FetchType.EAGER)
    private Set<Role> roles;

 @ManyToOne    private Role mainRole;
}

注意到roles字段中的@ManyToMany注解。@ManyToMany@OneToMany之间有什么区别?-ToMany部分意味着每个User都可以与多个Role对象相关联。@Many-部分意味着每个Role可以有多个User实例。如果使用@OneToMany,则@One-部分将意味着每个Role只能与一个User相关联,这显然不是我们在这个模型中想要的。

为什么 @ManyToMany 注解为获取策略指定了 FetchType.EAGER?Hibernate 默认使用 Fetch.LAZY,这可能会导致 LazyInitializationException。如果想在实际需要时加载数据,则 Lazy 获取可能很有用。然而,这需要在访问集合时有一个打开的 Hibernate 会话。在 Web 环境中,通常在处理请求后关闭会话。由于我们需要在视图中显示 Role 数据,最佳方法是积极获取数据。在许多应用程序中,使用 Open Session in View 模式是一种常见做法;然而,这也可能被视为一种反模式。始终考虑使用 DTO 投影而不是在视图中打开会话模式。有关此主题的更详细讨论,请访问 vladmihalcea.com/2016/05/30/the-open-session-in-view-anti-pattern

领域模型的最后一部分是仓库类。对于 RoleRepository 类,我们只需要一个方法来查找所有的 Role 对象,以及另一个方法来保存一个新的对象,如下面的代码片段所示:

public class RoleRepository {

    public static List<Role> findAll() { ... }

    public static Role save(Role role) { ... }
}

为了完整性,以下是在 UserRepository 类中的方法:

public class UserRepository {

    public static List<User> findAll() { ... }

    public static User findById(Long id) { ... }

    private static User getById(Long id, EntityManager em) { ... }

    public static User save(User user) { ... }

    public static void delete(User user) { ... }
}

为了简化,这里省略了方法的实际实现,但你可以在这个章节的示例的源代码中找到完整的源代码,该源代码位于 Data-centric-Applications-with-Vaadin-8\chapter-07 Maven 项目中。

使用可编辑的 Grid 组件实现 CRUD

在本节中,我们将实现一个包含可编辑 Grid 的组件。以下是一个显示编辑模式的 Grid 组件的应用程序截图:

为了简单起见,在这个例子中,我们将暂时省略 adddelete CRUD 操作。让我们首先创建一个类来封装组件,如下所示:

public class EditableGridCrud extends Composite {

    private Grid<User> grid = new Grid<>();

    public EditableGridCrud() {
        initLayout();
        initBehavior();
    }

    private void initLayout() {
        grid.setSizeFull();
        VerticalLayout layout = new VerticalLayout(grid);

        setCompositionRoot(layout);
        setSizeFull();
    }

    private void initBehavior() {
    }
}

扩展 Composite 的类声明了一个 Grid 来显示 User 实例。Grid 类中有几个构造函数可用:

  • Grid(): 创建一个新的 Grid,没有列。列需要手动添加。

  • Grid(String caption): 与 Grid() 相同,但设置了一个标题。

  • Grid(Class<T> beanType): 创建一个新的 Grid,并自动为指定类中的每个属性(具有 gettersetter)创建列。可以通过 getColumn(String) 方法按名称检索列。

  • Grid(DataProvider<T, ?> dataProvider): 创建一个新的 Grid,没有列。它接受一个 DataProvider,这是一个抽象,用于从任何类型的后端提供数据。你可以实现这个接口,或者使用框架中可用的实现。如果你不熟悉数据提供者,请参阅官方文档:vaadin.com/docs/v8/framework/datamodel/datamodel-providers.html

  • Grid(String caption, DataProvider<T, ?> dataProvider): 与 Grid(DataProvider) 相同,但设置一个标题。

  • Grid(String caption, Collection<T> items): 创建一个新的没有列的 Grid 并设置一个标题。提供的集合用于获取将要渲染在 Grid 中的数据(在幕后使用 DataProvider)。

到目前为止,我们有一个没有列和行(数据)的 Grid 组件。

实现读取操作

可以将 读取 CRUD 操作视为在 Grid 中显示所有 User 实例的动作。

由于 Grid 目前没有任何列,向其中添加行不会产生任何影响,所以让我们先添加列。向 Grid 添加列的最简单方法是将 bean 的类型(User)传递给 Grid 构造函数:

Grid grid = new Grid(User.class);

在此之后,我们可以通过使用 bean 中的属性名来添加列。例如:

grid.setColumns("firstName", "lastName");

然而,这并不类型安全。当手动向 Grid 添加列时,更好的方法不是使用 Grid(Class<T> beanType) 构造函数,而是使用 ValueProvider。让我们在示例应用程序中这样做:

public class EditableGridCrud extends Composite {
    ...

    private void initBehavior() {
        grid.addColumn(User::getFirstName).setCaption("First name");
        grid.addColumn(User::getLastName).setCaption("Last name");
        grid.addColumn(User::getEmail).setCaption("Email");
        grid.addColumn(User::getPassword).setCaption("Password");
        grid.addColumn(User::isBlocked).setCaption("Blocked");
    }
 }

这是一个更好的方法,因为它完全类型安全。addColumn 方法接受一个 ValueProvider,这是一个与 bean 类型中任何 getter 兼容的功能接口。addColumn 方法返回一个 Grid.Column 实例,我们可以从中配置任何额外的属性。在上面的代码片段中,我们配置了列的标题。所有的 setXX 方法都返回相同的 Column 实例,这允许你链式调用以进一步配置列。例如,你可以设置列的标题和宽度如下:

grid.addColumn(User::getFirstName)
    .setCaption("First name")
    .setWidth(150);

在列就位后,我们现在可以向 Grid 中添加行。这就像调用 setItems(Collection) 方法,并传递一个 User 实例的 Collection。由于我们将在编辑行后需要重新加载 Grid 的内容,所以将 setItems 的调用封装起来是一个好主意:

public class EditableGridCrud extends Composite {
    ...

    public EditableGridCrud() {
        initLayout();
        initBehavior();
        refresh();
    }

    private void refresh() {
        grid.setItems(UserRepository.findAll());
    }
    ...

}

目前存在一个轻微的安全问题,而且我所说的“轻微”实际上是“重大”。密码在 Grid 中以纯文本形式显示。我们希望保留密码列,以便它与 Grid 编辑器良好地协同工作,但我们希望显示一系列星号(********)而不是实际的密码。这可以通过 Renderer 接口来完成。Renderer 是一个扩展,它 绘制 值的客户端表示。我们可以使用提供的 TextRenderer 实现来更改密码列中显示的文本,如下所示:

grid.addColumn(User::getPassword)
        .setCaption("Password")
        .setRenderer(user -> "********", new TextRenderer());

setRenderer方法接受一个ValueProvider和一个Renderer。我们不再返回user.getPassword(),而是无论密码的值是什么,都返回"********"字符串。TextRenderer将接受这个字符串,并将其作为文本绘制出来。还有许多其他的Renderer可以接受值并以多种形式绘制;例如,作为一个ButtonHTML。以下图显示了框架中包含的实现:

实现更新操作

更新 CRUD 操作是通过Grid.Editor类实现的。启用编辑器就像调用以下代码一样简单:

grid.getEditor().setEnabled(true);

然而,Editor需要一种方式来知道每个列应该使用哪种输入组件,以及如何获取这些输入组件的值,以及用户编辑后如何将这些值设置回对象中。这是通过两个方法完成的:Grid.Editor.getBinderGrid.Column.setEditorBinding。你应该熟悉 Vaadin 框架中的Binder类;它是一个实用工具类,允许你将settersgetters与输入组件以及验证器、转换器和其他数据绑定配置连接起来。你可以通过调用getBinder方法来获取Binder实例:

Binder<User> binder = grid.getEditor().getBinder();

Binder类的基本思想是你可以指定一个输入组件,并绑定一个获取器和设置器:

binder.bind(textField, User::getFirstName, User::setLastName);

如果你不太熟悉Binder类,请阅读以下必读文档:vaadin.com/docs/v8/framework/datamodel/datamodel-forms.html

启用Editor后,我们可以为每一列设置一个输入组件。例如,我们可以使用setEditorBinding方法将TextField用于“名字”列,如下所示:

grid.addColumn(User::getFirstName)
        .setCaption("First Name")
        .setEditorBinding(binder
                .forField(new TextField())
                .bind(User::getFirstName, User::setFirstName));

setEditorBinding接受一个Binding实例,我们可以很容易地从binder中获取它。我们使用Binder中的forField方法指定一个新的TextField,并使用返回Binding实例的bind方法来配置User对象中的相应gettersetter。最终结果是,当你双击Grid中的行时,Editor将在名字单元格中呈现一个新的TextField,并将其值设置为User::getFirstName返回的值,并在点击保存按钮时调用User::setFirstName,传递TextField中的值。

当你设置多个编辑器绑定并复制粘贴代码时,请注意。你可能会忘记更改三个方法引用中的一个,这会导致出现奇怪的行为,例如值没有更新或值在对象中更新到错误的字段。

为了持久化编辑后的User实例,我们需要添加一个EditorSaveListener,它方便地是一个函数式接口。我们使用addSaveListener方法添加此监听器,如下所示:

grid.getEditor().addSaveListener(e -> save(e.getBean()));

save方法可以简单地实现如下:

public class EditableGridCrud extends Composite {
    ...

    private void save(User user) {
        UserRepository.save(user);
        refresh();
    }
    ...
}

使用 JSR-303 添加 Bean 验证

JSR-303 是Java Bean 验证的规范。它使得使用@NotNull@Email@Size等注解来指示 Java Bean 中的约束成为可能。Java Bean 验证是一个规范,并且有几种实现,其中两个最受欢迎的是Hibernate ValidationApache Bean Validation。由于我们已经在本章的示例中使用了 Hibernate,因此使用 Hibernate Validation 也是合理的。这是通过在pom.xml文件中添加hibernate-validator依赖项来完成的:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.2.Final</version>
</dependency>

在此之后,我们可以在User类中使用javax.validation.constraints包中可用的注解。以下代码为firstNamelastNamepassword字段添加了非空约束,为email字段添加了电子邮件格式约束,以及为password字段添加了大小(或长度)约束:

...
public class User {
    ...

 @NotNull    private String firstName;

    @NotNull
    private String lastName;

    @Email
    private String email;

    @NotNull
 @Size(min = 8, max = 100)
    private String password;

    ...
}

为了使其与Editor一起工作,我们需要将其Binder中的BeanValidators添加。以下代码片段显示了如何将其添加到第一个名称列:

grid.addColumn(User::getFirstName)
        .setCaption("First Name")
        .setEditorBinding(binder
                .forField(new TextField())
 .withNullRepresentation("")
 .withValidator(new BeanValidator(User.class, "firstName"))
                .bind(User::getFirstName, User::setFirstName));

注意,我们还调用了withNullRepresentation。这允许我们在客户端使用空String,并在服务器端将它们解释为 null 值。BeanValidator的实现由框架提供,并将负责为User类中的每个 JavaBean 验证注解运行验证。

不幸的是,你必须指定属性的名称为一个String字面量,这并不是类型安全的,如果在重构属性名称时忘记更新它,可能会导致问题。另一方面,现代 IDE 能够在你使用 Java 标识符重构的工具时建议这样的更改。

你可以使用相同的方法来配置Grid中其余列的Editor

作为练习,尝试通过创建一个按钮来添加一个新空行来实现add操作。当按钮被点击时,你可以创建并持久化一个新的User(你将不得不为User类的firstNamelastNamepassword字段设置默认值),刷新网格,并使用grid.getEditor().editRow(rowIndex)方法打开新User的编辑器。

使用网格和表单实现 CRUD

在本节中,我们将开发一个使用模态弹出窗口显示添加和编辑User实例表单的 CRUD 用户界面。以下是为完成表单的截图:

图片

让我们从以下组件开始:

public class CustomCrud extends Composite {

    private Button refresh = new Button("", VaadinIcons.REFRESH);
    private Button add = new Button("", VaadinIcons.PLUS);
    private Button edit = new Button("", VaadinIcons.PENCIL);

    private Grid<User> grid = new Grid<>(User.class);

    public CustomCrud() {
        initLayout();
        initBehavior();
        refresh();
    }

    private void initLayout() {
        CssLayout header = new CssLayout(refresh, add, edit);
        header.addStyleName(ValoTheme.LAYOUT_COMPONENT_GROUP);

        grid.setSizeFull();

        VerticalLayout layout = new VerticalLayout(header, grid);
        layout.setExpandRatio(grid, 1);
        setCompositionRoot(layout);
        setSizeFull();
    }

    private void initBehavior() {
    }

    public void refresh() {
    }
}

在这里有一些需要注意的事情。我们使用 Grid(Class<T> beanType) 构造函数,这意味着列是自动创建的,我们稍后可以通过名称引用它们。我们使用 VaadinIcons 类为刷新(读取)、添加和更新按钮设置图标而不是文本。这个类包含在 Vaadin 框架中。最后,我们使用具有 LAYOUT_COMPONENT_GROUP 样式的 CssLayout,这使得按钮看起来像工具栏。以下是这个组件的截图:

图片

实现读取操作

我们可以先配置我们实际上想在 Grid 中显示的列。由于列是由构造函数自动创建的,我们可以使用 setColumns 方法按名称设置它们的可见性:

...
    private void initLayout() {
        ...
        grid.setColumns("firstName", "lastName", "email", "mainRole");
        ...
    }
...

与之前的可编辑 Grid 相比,这里我们不需要 密码 列,因为我们没有使用 Editor

我们可以继续通过向 refresh 按钮添加点击监听器并实现 refresh 方法。这相当直接:

...
    private void initBehavior() {
 grid.asSingleSelect().addValueChangeListener(e -> updateHeader());
 refresh.addClickListener(e -> refresh());
    }

    public void refresh() {
 grid.setItems(UserRepository.findAll());
        updateHeader();
    }

    private void updateHeader() {
        boolean selected = !grid.asSingleSelect().isEmpty();
        edit.setEnabled(selected);
    }
...

我们引入了一个新的 updateHeader 方法,用于根据 Grid 中的选择状态启用或禁用 edit 按钮。只有当有行被选中时,启用 edit 按钮才有意义。我们需要在刷新列表和 Grid 中选中的值发生变化时调用此方法(参见 Grid.addValueChangeListener 方法)。

实现创建和更新操作

创建 CRUD 操作在用户点击 add 按钮时开始。同样,更新 CRUD 操作在用户点击 update 按钮时开始。我们需要以下 基础设施 代码:

...
private void initBehavior() {
    ...
 add.addClickListener(e -> showAddWindow());
    edit.addClickListener(e -> showEditWindow()); }

private void showAddWindow() {
    UserFormWindow window = new UserFormWindow("Add", new User());
    getUI().addWindow(window);
}

private void showEditWindow() {
    UserFormWindow window = new UserFormWindow("Edit", grid.asSingleSelect().getValue());
    getUI().addWindow(window);
}

当任何按钮被点击时,我们会显示一个 UserFormindow(稍后实现)。对于 add 按钮,我们传递一个新的 User 实例。对于 update 按钮,我们传递在 Grid 中选中的 User 实例。我们可以在 CustomCrud 内部实现 UserWindow 作为内部类。我们将省略布局配置的细节,并专注于数据绑定部分。让我们从以下内容开始:

private class UserFormWindow extends Window { // inner to CustomCrud

    private TextField firstName = new TextField("First name");
    private TextField lastName = new TextField("Last name");
    private TextField email = new TextField("Email");
    private PasswordField password = new PasswordField("Password");
    private CheckBoxGroup<Role> roles = new CheckBoxGroup<>("Roles", RoleRepository.findAll());
    private ComboBox<Role> mainRole = new ComboBox<>("Main Role", RoleRepository.findAll());
    private CheckBox blocked = new CheckBox("Blocked");

    private Button cancel = new Button("Cancel");
    private Button save = new Button("Save", VaadinIcons.CHECK);

    public UserFormWindow(String caption, User user) {
        initLayout(caption);
        initBehavior(user);
    }

    private void initLayout(String caption) {
        ...
    }

    private void initBehavior(User user) {
    }
}

表单中的所有输入字段都是 UserFormWindow 类的成员,并在 initLayout 方法(未显示)中添加到某种布局中。initBehaviour 方法应配置 User 实例和输入字段之间的数据绑定。它还应向 cancelsave 按钮添加行为。在我们开始编码之前,让我们考虑一下需要什么:

  • 我们需要数据绑定。在 Vaadin 框架中,这通常意味着使用 Binder

  • 我们需要将 UserFormWindow 类中的字段绑定到 User 类中的字段。

  • 我们需要确保输入字段最初显示正确的值。

  • 我们需要确保在点击保存按钮时,输入字段的值被写入 User 实例。

  • 我们需要确保在点击取消按钮时,User 实例中没有写入任何值。

现在,我们可以开始编码了:

private void initBehavior(User user) { // inside UserFormWindow
    Binder<User> binder = new Binder<>(User.class);
    binder.bindInstanceFields(this);
    binder.readBean(user);
}

在前面的代码中发生了两件重要的事情:一是 UserFormWindow 类中也是输入字段的 Java 字段都绑定到了 User 类中的 Java 字段(通过 bindIntanceFields 调用);二是 User 类中的所有值都设置到了 UserFormWindow 类中相应的输入字段(通过 readBean 调用)。

最后,以下代码为按钮添加了行为:

private void initBehavior(User user) { // inside UserFormWindow
    ...

    cancel.addClickListener(e -> close());
    save.addClickListener(e -> {
        try {
            binder.writeBean(user);
            UserRepository.save(user);
            close();
            refresh();
            Notification.show("User saved");

        } catch (ValidationException ex) {
            Notification.show("Please fix the errors and try again");
        }
    });
}

cancel 按钮的监听器只需调用 Window.close()(继承)。save 按钮的监听器调用 writeBean 以将输入字段中的值写入 user 实例。

注意,writeBean 会抛出 ValidationException。目前还没有验证。添加 User 类中已有的 JavaBean 验证约束与更改 Binder 实现一样简单。

private void initBehavior(User user) { // inside UserFormWindow
    BeanValidationBinder<User> binder = new BeanValidationBinder<>(User.class);
    ...
}

实现删除操作

让我们使用不同的方法来实现 删除 CRUD 操作。而不是简单地添加一个用于操作的单独按钮,我们将在 Grid 的每一行上添加一个删除按钮。在 Grid 中添加 UI 组件的最简单方法是通过使用 addComponentColumn 方法:

public class CustomCrud extends Composite {
    ...

    private void initLayout() {
        ...

        grid.addComponentColumn(user -> new Button("Delete", e -> deleteClicked(user)));
        ...
    }
    ...

    private void deleteClicked(User user) {
        showRemoveWindow(user);
        refresh();
    }

    private void showRemoveWindow(User user) {
        Window window = new RemoveWindow(user);
        window.setModal(true);
        window.center();
        getUI().addWindow(window);
    }
}

addComponentColumn 方法接受一个 ValueProvider,用于获取 UI 组件。创建 Button 时使用的构造函数接受一个点击监听器,该监听器反过来调用 showRemoveWindow 方法,并传递按钮所在的行对应的 User 实例。RemoveWindow 类的实际实现留作练习。

addComponentColumn 方法是 addColumn(user -> new Button("Delete", e -> deleteClicked(user)), new ComponentRenderer()) 的快捷方式。

使用 Crud UI 插件

由于其开源性质,有数百个第三方组件和实用工具在 vaadin.com/directory 上发布可用。其中之一几乎完成了本章中我们所做的大部分工作。以下类展示了如何使用在 vaadin.com/directory/component/crud-ui-add-on 可用的 Crud UI 插件 来实现 CRUD 用户界面,该插件由本书的作者维护:

public class CrudAddOn extends Composite {

    private GridCrud<User> crud = new GridCrud<>(User.class, new HorizontalSplitCrudLayout());

    public CrudAddOn() {
        initLayout();
        initBehavior();
    }

    private void initLayout() {
        crud.getGrid().setColumns("firstName", "lastName", "email", "mainRole");
        crud.getCrudFormFactory().setVisibleProperties("firstName", "lastName", "email", "password", "roles", "mainRole", "blocked");

        crud.getCrudFormFactory().setFieldType("password", PasswordField.class);
        crud.getCrudFormFactory().setFieldProvider("roles", new CheckBoxGroupProvider<>(RoleRepository.findAll()));
        crud.getCrudFormFactory().setFieldProvider("mainRole", new ComboBoxProvider<>("Main Role", RoleRepository.findAll()));

        VerticalLayout layout = new VerticalLayout(crud);
        setCompositionRoot(layout);
        setSizeFull();
    }

    private void initBehavior() {
        crud.setFindAllOperation(() -> UserRepository.findAll());
        crud.setAddOperation(user -> UserRepository.save(user));
        crud.setUpdateOperation(user -> UserRepository.save(user));
        crud.setDeleteOperation(user -> UserRepository.delete(user));
        crud.getCrudFormFactory().setUseBeanValidation(true);
    }
}

该插件提供了一些配置选项,例如配置布局、设置字段提供者以及使用 JavaBean 验证。它还将 CRUD 操作委托给您的代码,允许您使用任何类型的 Java 后端技术。以下是用 Crud UI 插件创建的 CRUD 组件的截图:

过滤

过滤可以通过添加 UI 组件来实现,例如带有值监听器的TextFieldComoboBox。当用户更改过滤组件时,值监听器通过将它们的值传递到后端并相应地更新视图来更新数据。例如,为了按姓氏过滤,UserRepository.findAll方法应该接受一个包含要匹配值的字符串:

public class UserRepository {

    public static List<User> findAll(String lastName) {
        return JPAService.runInTransaction(em ->
                em.createQuery("select u from User u where u.lastName like :lastName")
                        .setParameter("lastName", lastName)
                        .getResultList()
        );
    }
    ...
}

总是记住,当findAll方法返回少量结果时,它们是有用且安全的。当这种情况不成立时,你应该添加像在第九章中讨论的延迟加载能力。

假设有一个lastNameFilter输入组件(例如,类型为TextField),Grid应该使用新方法填充,并传递过滤中的值:

grid.setItems(UserRepository.findAll(lastNameFilter.getValue()));

摘要

在本章中,我们学习了如何实现通用 CRUD 用户界面。我们研究了三种不同的 CRUD 用户界面的 UI 设计:原地字段、模态弹出窗口和分层菜单。我们了解了 Project Lombok,它允许我们在 Java 程序中减少所需的样板代码量,并使用 JPA 和 JavaBean 验证约束实现了领域模型。我们还涵盖了使用Binder类、Grid渲染器和过滤的数据绑定。

在下一章中,我们将探讨另一个在许多商业应用中非常有用的有趣主题:生成和可视化报告。

第八章:添加报告功能

许多商业应用程序需要生成报告作为其功能的一部分。报告是以特定格式向特定受众展示数据的一种表示。报告生成器(或报告查看器)是一个应用程序或应用程序模块,允许最终用户可视化并下载报告。通常,报告生成器从数据库中获取数据,并生成适合打印在纸上的文档。在本章中,我们将关注这种类型的报告生成器。有许多具有高级功能(如商业智能和分析)的现成报告生成器,但这些系统超出了本书的范围。

在本章中,我们将学习如何在 Vaadin 应用程序中渲染JasperReports,而无需处理报告设计人员或 XML 设计格式。相反,我们将使用 Java API 来设计报告,类似于使用 Vaadin 框架用 Java 设计 Web UI 的方式。我们还将讨论后台报告生成和服务器推送,这是一种允许我们从服务器上运行的单独线程更新客户端的机制。

本章涵盖了以下主题:

  • JasperReports与 Vaadin 集成

  • 渲染运行时生成的 HTML

  • 长运行的后台任务

  • 服务器推送

技术要求

您需要具备 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。您还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,您需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-08

查看以下视频以查看代码的实际效果:

goo.gl/9sdD5q

示例应用程序

在本章中,我们将开发一个报告查看器。以下是为完成的应用程序的截图:

数据模型

数据模型基于一个简单的 SQL 表,Call,其中包含 ID、客户名称、电话号码、城市、开始时间、持续时间以及状态列。以下是一个代表此表的 JPA 实体:

@Entity
@Data
public class Call {

    @Id
    @GeneratedValue
    private Long id;

    private String client;

    private String phoneNumber;

    @Enumerated(EnumType.STRING)
    private City city;

    private LocalDateTime startTime;

    private Integer duration;

    @Enumerated(EnumType.STRING)
    private Status status;
}

StatusCity是简单的 Java enum,定义了一些测试值:

public enum Status {
    RECEIVED, MISSED
}

public enum City {
    BOGOTA, TURKU, LONDON, BERLIN, HELSINKI, TOKYO, SAN_FRANCISCO, SIDNEY, LAGOS, VANCOUVER, SANTIAGO, BEIJING
}

注意Call类的citystatus字段中的@Enumerated注解。这用于将值作为字符串而不是表示值的整数持久化到数据库中,这使得我们可以为报告使用更简单的 SQL 查询。

在这个应用中,我们将使用两个持久化框架。对于需要保存数据或运行业务逻辑的应用部分,我们将使用 JPA。对于报表数据,我们将使用 MyBatis。当然,在你的应用中你也可以只使用一个框架。选择 MyBatis 进行报表生成的原因是它非常适合构建和维护复杂的 SQL 查询。SQL 反过来又是一种强大的语言,非常适合报表。能够从你的代码中复制 SQL 查询并在 SQL 客户端直接运行它,简化了实现和维护,因为你可以快速看到报告中会得到的数据,而无需编译或执行应用程序。每个报表都有自己的数据传输模型DTO),这是一个封装在方便格式中的类,用于在报表中呈现数据。这种做法的优势在于我们不必查询报表中未使用的额外数据,从而在一定程度上减轻了 Web 服务器的数据处理负担。

两个框架的配置都实现在JPAServiceMyBatisService类以及persistence.xmlmybatis-config.xml文件中。默认使用基于文件的 H2 数据库,但在配置文件中你可以找到 MySQL 和 PostgreSQL 的配置示例作为注释。

你可以在本书附带的源代码的Data-centric-Applications-with-Vaadin-8\chapter-08 Maven 项目中找到本章示例的完整源代码。

由于没有数据,报表查看器就没有意义,因此示例应用包含一个随机数据生成器,用于向Call表填充随机数据。当表为空时,生成器将以每年一千万个电话的速率填充过去 6 个月内的电话数据。如果表不为空,生成器将使用相同的速率“填充”表中最后一条通话时间和当前时间之间的时间段。此外,生成器还运行一个后台线程,在运行时插入随机数据。这个生成器旨在模拟现实生活中的情况,数据不断被插入到数据库中,有时甚至在应用程序未运行时也是如此。你可以在DataGenerator类中找到其实现。DataGenerator功能是通过在WebConfig类中定义的ServletContextListener调用的。生成器中使用的初始时间范围和速率可以通过参数进行配置,以防你想使用不同的值。

Vaadin UI

VaadinServletWebConfig类中进行配置。UI实现是在VaadinUI类中完成的。为了参考,以下代码片段显示了示例应用的布局实现:

@Title("Report Viewer")
public class VaadinUI extends UI {

    private HorizontalLayout header = new HorizontalLayout();
    private Panel panel = new Panel();
    private MenuBar.MenuItem annualLegalReportItem;

    @Override
    protected void init(VaadinRequest vaadinRequest) {
        MenuBar menuBar = new MenuBar();
        menuBar.addStyleName(ValoTheme.MENUBAR_BORDERLESS);
        MenuBar.MenuItem reportsMenuItem = menuBar.addItem("Reports", VaadinIcons.FILE_TABLE, null);
        reportsMenuItem.addItem("Worldwide Calls in the Last Hour", VaadinIcons.PHONE_LANDLINE,
                i -> showLastHourCallReport());
        reportsMenuItem.addItem("Monthly Capacity Report", VaadinIcons.BAR_CHART_H,
                i -> showMonthlyCapacityReport());
        annualLegalReportItem = reportsMenuItem.addItem("Annual Legal Report", VaadinIcons.FILE_TEXT_O,
                i -> generateAnnualLegalReport());

        header.addComponents(menuBar);

        panel.addStyleName(ValoTheme.PANEL_WELL);

        VerticalLayout mainLayout = new VerticalLayout(header);
        mainLayout.addComponentsAndExpand(panel);
        setContent(mainLayout);
    }
    ...
}

我们将开发三个不同的报告。showLastHourCallReportshowMonthlyCapacityReportgenerateAnnualLegalReport 方法包含修改 UI 的逻辑,以便在 panel 组件内显示相应的报告。

将 JasperReports 与 Vaadin 集成

JasperReports 是一个开源的报告引擎,可以生成可以在多种格式中渲染的报告,例如 HTML、PDF、Microsoft Excel、ODT(OpenOffice)等。通常,报告是在视觉编辑器(iReport Designer)或 XML 文件(JRXML)中设计的。设计被编译成 Jasper 文件(*.jasper),填充数据,并导出为所需的格式。

DynamicJasperDynamicReports 是两个开源库,它们抽象了 JRXML 格式并提供 API 以在 Java 中设计报告。这与 Vaadin 框架的哲学相吻合,该框架允许您使用 Java 实现基于 HTML 的网络应用程序。在本章中,我们将使用 DynamicJasper,但如果您更喜欢 DynamicReports,概念是相似的。如果您计划直接在 JRXML 文件中或通过 iReport 设计师工具设计报告,一些概念也可以使用。

您可以通过在 pom.xml 文件中添加以下依赖项来包含 DynamicJasper

<dependency>
    <groupId>ar.com.fdvs</groupId>
    <artifactId>DynamicJasper</artifactId>
    <version>5.1.0</version>
</dependency>

为了导出 Microsoft Office 格式,您需要添加 Apache POI 作为依赖项:

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>3.10-FINAL</version>
</dependency>

获取数据

报告生成的一个关键部分是数据收集。数据可以说是报告中最重要的输入。拥有良好的“基础设施代码”进行数据收集将极大地提高报告模块的可维护性。在本章中,我们将使用 SQL 数据库,因为它可能是使用中最常见的数据库类型。这意味着报告是用 SQL 查询填充的。报告不需要保存数据,只需读取。报告中的 SQL 查询通常有多行,有时是动态生成的。MyBatis 似乎是报告模块的一个很好的选择。MyBatis 允许在 XML 文件中定义查询,与 Java 字符串不同,这有助于处理长多行 SQL 查询和动态查询定义。

要使用基于 XML 的 MyBatis 映射器,请在 MyBatis 配置文件中 mapper 元素的 resource 属性中指定 XML 文件的名称:

<configuration>
    ...
    <mappers>
        <mapper resource="mappers/ReportsMapper.xml"/>
    </mappers>
</configuration>

ReportsMapper.xml 文件定义如下:

<?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="packt.vaadin.datacentric.chapter08.reports.ReportsMapper">
    ...
</mapper>

此文件定义了要使用的映射 Java 接口。在 mapper 元素内部定义的所有查询都映射到 ReportsMapper 类中的相应方法。例如,我们可以定义一个方法来获取给定时间之前的所有调用,如下所示:

public interface ReportsMapper {

    List<CallDto> findCallsBefore(LocalDateTime time);
}

注意,我们不是使用 JPA 实体作为结果类型。相反,我们使用 DTO,并且仅使用所需的 Java 字段来存储 SQL 查询中的数据:

@Data
public class CallDto {
    private String client;
    private String phoneNumber;
    private City city;
    private LocalDateTime startTime;
    private Integer duration;
    private Status status;
}

我们可以将 SQL 查询映射到 findCallsBefore 方法,如下所示:

<mapper namespace="packt.vaadin.datacentric.chapter08.reports.ReportsMapper">

    <select id="findCallsBefore" resultType="packt.vaadin.datacentric.chapter08.reports.CallDto">
        SELECT
          city,
          client,
          phoneNumber,
          startTime,
          duration,
          status
        FROM Call
        WHERE startTime >= #{time}
        ORDER BY startTime DESC
    </select>

</mapper>

UI 不直接消耗映射接口。相反,我们可以在服务类中定义更高级的方法。例如,过去一小时的全球通话 报告,该报告的数据来自之前的查询,它使用了 ReportsService 类中的 lastHourCalls 方法:

public class ReportsService {

    public static List<CallDto> lastHourCalls() {
        try (SqlSession session = MyBatisService.getSqlSessionFactory().openSession()) {
            LocalDateTime startOfHour = LocalDateTime.now().minusHours(1);
            ReportsMapper mapper = session.getMapper(ReportsMapper.class);
            return mapper.findCallsBefore(startOfHour);
        }
    }
}

这允许在数据相同但需要不同处理(如格式化或计算输入参数)时重用查询。

设计报告

让我们从实现一个简单的报告开始,即 过去一小时的全球通话 报告,如下面的截图所示:

截图

要使用 DynamicJasper 创建报告,你必须创建一个 DynamicReport 类型的对象。这是通过使用 DynamicReportBuilder 类来完成的,它提供了添加标题、页眉、列和其他构成报告的元素的方法。DynamicReportBuilder 类实现了 建造者模式,允许逐步创建报告,并提供了一个构建 DynamicReport 实例的方法。DynamicReportBuilder 有几个子类;我们将遵循官方文档中给出的示例,并使用 FastReportBuilder 类。

我们可以先配置标题和页眉信息,启用全页宽,设置无数据时显示的文本,并启用奇数行的背景色:

DynamicReport report = new FastReportBuilder()
        .setTitle("Worldwide Calls in the Last Hour")
        .addAutoText("CONFIDENTIAL", AutoText.POSITION_HEADER, AutoText.ALIGMENT_LEFT, 200, new Style())
        .addAutoText(LocalDateTime.now().toString(), AutoText.POSITION_HEADER, AutoText.ALIGNMENT_RIGHT, 200, new Style())
        .setUseFullPageWidth(true)
        .setWhenNoData("(no calls)", new Style())
        .setPrintBackgroundOnOddRows(true)
        .build();

注意,在配置报告后,我们通过调用 build 方法来结束句子,该方法返回一个 DynamicReport 实例。所有配置调用都发生在实例化(new FastReportBuilder())和调用 build() 之间。

报告数据由列定义。列通过 addColumn 方法进行配置。addColumn 方法接受一个类型为 AbstractColumn 的实例,我们可以通过使用 ColumnBuilder(也是一个构建类)来创建它。以下代码片段演示了如何创建构成报告的七个列:

DynamicReport report = new FastReportBuilder()
        ...
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("city", City.class)
                .setTitle("City")
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("client", String.class)
                .setTitle("Client")
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("phoneNumber", String.class)
                .setTitle("Phone number")
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("startTime", LocalDateTime.class)
                .setTitle("Date")
                .setTextFormatter(DateTimeFormatter.ISO_DATE.toFormat())
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("startTime", LocalDateTime.class)
                .setTextFormatter(DateTimeFormatter.ISO_LOCAL_TIME.toFormat())
                .setTitle("Start time")
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("duration", Integer.class)
                .setTitle("Minutes")
                .build())
        .addColumn(ColumnBuilder.getNew()
                .setColumnProperty("status", Status.class)
                .setTitle("Status").build())
        .build();

对于每一列,我们必须指定 CallDto 类中相应的 Java 属性的名称及其类型。在需要时,我们还可以指定标题和 文本格式化器

DynamicReport 实例定义了报告的视觉结构。有了这个基础,我们可以创建一个 JasperPrint 对象,它代表一个面向页面的文档,可以稍后导出为多种格式。我们首先需要从服务类中获取数据,然后将 DynamicReport 实例和数据传递给 DynamicJasperHelper 类的 generateJasperPrint 方法:

List<CallDto> calls = ReportsService.lastHourCalls();
JasperPrint print = DynamicJasperHelper.generateJasperPrint(report, new ClassicLayoutManager(), calls);

将报告渲染为 HTML

JasperPrint 实例可以导出为多种格式。由于我们感兴趣的是在 Vaadin 网络应用程序中渲染报告,我们可以将报告导出为 HTML,并使用配置了 ContentMode.HTMLLabel,如下所示:

ByteArrayOutputStream outputStream = new ByteArrayOutputStream()
HtmlExporter exporter = new HtmlExporter();
exporter.setExporterOutput(new SimpleHtmlExporterOutput(outputStream));
exporter.setExporterInput(new SimpleExporterInput(print));
exporter.exportReport();

outputStream.flush();
Label htmlLabel = new Label("", ContentMode.HTML);
htmlLabel.setValue(outputStream.toString("UTF-8"));

HtmlExporter 类将输出发送到 OutputStream,我们可以将其转换为 String 并设置为 Label 的值。这个 Label 可以添加到任何 Vaadin 布局中,如以下代码片段所示,同时也考虑了异常处理和资源管理:

public class LastHourCallReport extends Composite {

    public LastHourCallReport() {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            DynamicReport report = new FastReportBuilder()
                    ...
                    .build();
            ...

            Label htmlLabel = new Label("", ContentMode.HTML);
            htmlLabel.setValue(outputStream.toString("UTF-8"));
            setCompositionRoot(htmlLabel);

        } catch (JRException | IOException e) {
            throw new RuntimeException(e);
        }
    }
}

添加图表

使用 DynamicJasper 添加图表是通过 FastReportBuilder 类的 addChart 方法完成的。以下代码片段显示了 月度容量报告 的完整配置:

DynamicReportBuilder reportBuilder = new FastReportBuilder()
        .setUseFullPageWidth(true)
        .setTitle("Monthly Capacity Report")
        .setWhenNoData("(no data)", new Style())
        .addAutoText("CONFIDENTIAL", AutoText.POSITION_HEADER, AutoText.ALIGMENT_LEFT, 200, new Style())
        .addAutoText(LocalDateTime.now().toString(), AutoText.POSITION_HEADER, AutoText.ALIGNMENT_RIGHT, 200, new Style())
        .addColumn(monthColumn = ColumnBuilder.getNew()
                .setColumnProperty("monthName", String.class)
                .setTitle("Month")
                .build())
        .addColumn(callsColumn = ColumnBuilder.getNew()
                .setColumnProperty("calls", Integer.class)
                .setTitle("Calls")
                .build())
        .addChart(new DJBar3DChartBuilder()
                .setCategory((PropertyColumn) monthColumn)
                .addSerie(callsColumn)
                .build());

注意我们如何需要引用包含我们想要在图表中使用的数据的列。setCategoryaddSeries 方法接受这些引用。

为了渲染图表,我们必须配置一个 ImageServlet,这是由 JasperReports 库提供的。这个 servlet 提供了构成图表的图像。在本章的示例应用程序中,servlet 在 WebConfig 类中声明为一个静态内部类:

@WebServlet("/image")
public static class ReportsImageServlet extends ImageServlet {
}

你可以使用任何合适的 URL。这需要在导出类(例如,HTMLExporter)使用的输出中进行配置。此外,JasperPrint 实例必须在 HTTP 会话中设置。以下代码片段显示了在渲染图表时所需的额外配置:

...
JasperPrint print = ...
VaadinSession.getCurrent().getSession().setAttribute(ImageServlet.DEFAULT_JASPER_PRINT_SESSION_ATTRIBUTE, print);

SimpleHtmlExporterOutput exporterOutput = ...
exporterOutput.setImageHandler(new WebHtmlResourceHandler("image?image={0}"));

HtmlExporter exporter = new HtmlExporter();
exporter.setExporterOutput(exporterOutput); ...

WebHtmlResourceHandler 构造函数接受一个字符串,该字符串用于导出器内部图像处理程序使用的 URL 模式。注意模式以 image 开头。这是在 ImageServlet 映射中使用的相同值。

在后台任务中生成报告

由于大量数据、与外部系统的连接以及数据处理,报告生成可能涉及昂贵的计算。在许多情况下,报告数据直接从原始来源收集,通常是 SQL 数据库。这有两个明显的缺点。第一个问题是随着应用程序的运行,越来越多的数据被添加到数据库中,使得报告随着时间的推移运行速度变慢。第二个问题是报告生成可能在某些时候过度使用数据库,干扰应用程序其他部分的用法。

改善这种情况的一个步骤是逐步和持续地生成报告所需的数据。例如,考虑以下查询,它计算某一列的平均值:

SELECT AVG(column_name) FROM table_name

而不是使用这个查询,你可以使用以下公式来连续计算每次新值 (x[n]) 持久化时的平均值 (an),从上一个平均值值 (a[n-1]) 开始:

图片

当然,这没有考虑到 删除 操作,并且需要在数据库中持久化新值时计算平均值,但这个简单示例的关键思想是尝试 帮助 应用程序预先生成报告数据,因为数据被添加、修改或删除,以最大限度地减少报告生成时间所需的计算能力。

在预处理数据时,或者当有依赖于时间或外部数据源的计算时,报告生成可能比正常的应用程序请求花费更长的时间。在这些情况下,您可以使用后台线程生成报告,并在报告准备就绪时通知用户。在示例应用程序中,您可以在报告菜单中看到一个年度法律报告选项。生成此报告在应用程序时间上代价高昂,因此,而不是锁定应用程序的使用直到报告准备就绪,应用程序显示一个通知,说明报告正在生成,并在后台线程中启动此过程,同时允许用户在此期间可视化其他报告:

当报告准备就绪时,应用程序会再次通知您,并显示一个按钮,允许您下载报告:

下一个章节将解释如何实现这种行为。

将报告导出为 PDF

HTML 是在浏览器中渲染报告的最佳选择。然而,JasperReportsDynamicJasper 支持许多其他格式。这些格式作为 JRExporter 接口的实现而可用。其中之一是 JRPdfExporter 类。示例应用程序包括 LastHourCallReport 类,与之前的报告实现不同,它不是一个 Vaadin UI 组件。由于我们希望允许用户下载此报告,我们实际上不需要为它创建 UI 组件。相反,LastHourCallReport 是一个辅助类,用于配置报告,将其导出为 PDF,并通过适合 FileDownloader 类的 OutputStream 暴露文件内容,FileDownloader 类是 Vaadin 框架的一部分。

省略了关于报告配置的细节,这些细节我们在前面的章节中已经介绍过,以下是对 LastHourCallReport 类的实现:

public class AnnualLegalReport {

    public static ByteArrayOutputStream getOutputStream() {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            DynamicReport report = new FastReportBuilder()
                    ... configure report ...
                    .build();

            List<ClientCountDto> clients = ReportsService.countYearCallsByClient();
            JasperPrint print = DynamicJasperHelper.generateJasperPrint(report, new ClassicLayoutManager(), clients);

            JRPdfExporter exporter = new JRPdfExporter();
            exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(outputStream));
            exporter.setExporterInput(new SimpleExporterInput(print));
            exporter.exportReport();

            outputStream.flush();
 return outputStream;

        } catch (JRException | IOException e) {
            throw new RuntimeException(e);
        }
    }
}

我们需要从新线程中调用 getOutputStream 方法,并从同一新线程修改 UI,以添加一个下载 PDF 文件的按钮。为了从单独的线程修改 UI,我们需要启用并使用服务器推送。

服务器推送

让我们看看如果我们不使用服务器推送,从单独的线程修改 UI 会发生什么:

@Title("Report Viewer")
public class VaadinUI extends UI {

    private HorizontalLayout header = new HorizontalLayout();
    private MenuBar.MenuItem annualLegalReportItem;
    ...

    private void generateAnnualLegalReport() {
        Notification.show("Report generation started",
                "You'll be notified once the report is ready.", Notification.Type.TRAY_NOTIFICATION);
        annualLegalReportItem.setEnabled(false);

 new Thread(() -> {
            ByteArrayOutputStream outputStream = AnnualLegalReport.getOutputStream();
            ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
            Button button = new Button("Download Annual Legal Report", VaadinIcons.DOWNLOAD_ALT);
            header.addComponent(button);

            FileDownloader downloader = new FileDownloader(new StreamResource(() -> {
                header.removeComponent(button);
                annualLegalReportItem.setEnabled(true);
                return inputStream;
            }, "annual-legal-report.pdf"));
            downloader.extend(button);

            Notification.show("Report ready for download", Notification.Type.TRAY_NOTIFICATION);
        }).start();
    }
}

当用户点击菜单中的相应选项时,会调用generateAnnualLegalReport方法。该方法启动一个新线程,所以我们最终有两个线程;一个是在 HTTP 请求发生时启动的(菜单选项被点击),另一个是由generateAnnualLegalReport方法启动的。当 HTTP 请求完成后,用户能够继续在浏览器中使用应用程序。在稍后的某个时刻,AnnualLegalReport.getOutputStream()方法完成,应用程序尝试修改 UI。然而,这发生在服务器的一个单独的后台线程中。所有对 UI 的更改都会丢失或可能失败,因为线程没有与 UI 实例关联,并且框架可能会抛出NullPointerExceptions(这是Notification.show方法的情况)。

您可以通过锁定会话来确保有一个 UI 实例可用,并通过使用UI.access(Runnable)方法将任何修改 UI 的代码(从请求处理线程外部)包装起来,以避免NullPointerExceptions

new Thread(() -> {
    ByteArrayOutputStream outputStream = AnnualLegalReport.getOutputStream();
    ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
 access(() -> {
        Button button = new Button("Download Annual Legal Report", VaadinIcons.DOWNLOAD_ALT);
        header.addComponent(button);

        FileDownloader downloader = new FileDownloader(new StreamResource(() -> {
            header.removeComponent(button);
            annualLegalReportItem.setEnabled(true);
            return inputStream;
        }, "annual-legal-report.pdf"));
        downloader.extend(button);

        Notification.show("Report ready for download", Notification.Type.TRAY_NOTIFICATION);
 });
}).start(); 

仍然有一个问题;服务器需要将更改发送到浏览器,我们可以通过启用服务器推送来实现这一点。服务器推送是一种从服务器到客户端启动通信过程的技术,与典型的 HTTP 请求相反,其中通信是由客户端(网页浏览器)发起的。为了使用服务器推送,您需要在pom.xml文件中添加vaadin-push依赖项:

<dependency>
    <groupId>com.vaadin</groupId>
    <artifactId>vaadin-push</artifactId>
</dependency>

要启用服务器推送,您可以使用@Push注解 UI 实现类:

@Push
@Title("Report Viewer")
public class VaadinUI extends UI { ... }

Push注解接受两个可选参数:valuetransport。第一个参数,value,配置要使用的推送模式。主要有两种选项:PushMode.AUTOMATICPushMode.MANUALAUTOMATIC表示一旦UI.access方法完成(技术上,一旦会话锁释放),所有对 UI 的更改都会自动发送到客户端。MANUAL表示您必须调用UI.push来使 UI 更改在浏览器中可用。第二个参数,transport,配置要使用的传输类型。有三个选项:Transport.WEBSOCKET(使用标准WebSockets协议,与 HTTP 不同的协议,用于服务器和客户端之间的所有通信),Transport.WEBSOCKET_XHR(使用 WebSockets 进行服务器到客户端通信,使用 XHR 进行客户端到服务器通信),以及Transport.LONG_POLLING(一种使用标准 HTTP 协议的技术,客户端请求服务器数据,服务器保持请求直到有新数据可用,然后再次重复此过程)。

您还必须为VaadinServlet启用异步操作模式,以优化资源并允许在 WebSockets 不可用时将 XHR 作为后备机制:

@WebServlet(value = "/*", asyncSupported = true)
@VaadinServletConfiguration(ui = VaadinUI.class, productionMode = false)
public static class chapter08VaadinServlet extends VaadinServlet {
}

摘要

在本章中,我们学习了如何在 Vaadin 应用程序中渲染 JasperReports。我们使用了 DynamicJasper,这使得我们可以使用 Java 编程语言来设计报告。我们还学习了如何在服务器上运行的背景线程中生成报告,并通过使用服务器推送在报告准备好后通知客户端。

在下一章中,你将学习如何通过使用懒加载来在 UI 中处理大量数据。

第九章:懒加载

懒加载是一种降低内存消耗(可能还有处理时间)的技术。这种技术将数据的加载延迟到实际需要在 UI 中使用的时刻。例如,如果你有一个Grid组件,有 10,000 行数据,但在任何给定时间只有其中一部分可见,那么加载全部 10,000 行可能是一种资源浪费。懒加载背后的理念与懒人的行为相同:如果你将做某事推迟到最后一刻,如果因为某些原因你最终不需要完成这项任务,你将节省时间。在 Web 应用中也是如此。例如,如果一个用户在未滚动数据的情况下离开某些视图,应用程序就不需要加载除了几个可见项目之外的其他任何内容,从而节省了从数据源加载可能成千上万或数百万项内容的需要;当许多用户同时查看同一视图时,这可能会成为一个严重的问题。

在本章中,我们将讨论如何使用Grid组件实现懒加载。然而,同样的原则也适用于任何其他显示来自大型数据集数据的 UI 组件。

本章涵盖以下主题:

  • 为后端服务添加懒加载功能

  • 使用 lambda 表达式实现DataProvider

  • 过滤

  • 排序

  • 无限懒加载

技术要求

你需要安装 Java SE 开发工具包和 Java EE SDK 版本 8 或更高版本。你还需要 Maven 版本 3 或更高版本。建议使用具有 Maven 支持的 Java IDE,例如 IntelliJ IDEA、Eclipse 或 NetBeans。最后,为了使用本书的 Git 仓库,你需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Data-centric-Applications-with-Vaadin-8/tree/master/chapter-09

查看以下视频以查看代码的实际运行情况:

goo.gl/GLTkjq

示例应用程序

我们将开发一个简单的应用程序来测试 Vaadin 在Grid组件中显示数十万行数据的能力。用户可以通过输入一个过滤文本来在Grid中过滤数据,应用程序将此文本与三列(客户端、电话号码和城市)进行匹配。用户还可以通过拖动标题来更改列的位置,并通过点击列标题来排序行。以下是一个示例应用程序的截图:

图片

数据模型

本章使用与第八章中相同的添加报告功能所使用的数据模型。数据模型基于一个简单的 SQL 表,Call。我们将使用 JPA 连接到基于文件的 H2 数据库。JPA 逻辑封装在CallRepository类中。有关数据模型的更多详细信息,请参阅第八章,添加报告功能

你可以在本书附带源代码的Data-centric-Applications-with-Vaadin-8\chapter-09 Maven 项目中找到本章示例的完整源代码。

Vaadin UI

VaadinServletWebConfig类中进行配置。UI实现由VaadinUI类实现。为了参考,以下为VaadinUI类的实现:

@Title("Call Browser")
public class VaadinUI extends UI {

    @Override
    protected void init(VaadinRequest vaadinRequest) {
        VerticalLayout mainLayout = new VerticalLayout();
        mainLayout.addComponentsAndExpand(new CallsBrowser());
        setContent(mainLayout);
    }
}

注意 UI 由一个只包含CallsBrowser组件的VerticalLayout组成。我们将从以下CallsBrowser自定义组件的实现开始:

public class CallsBrowser extends Composite {

    public CallsBrowser() {
        TextField filter = new TextField();
        filter.setPlaceholder("Client / Phone / City");
        filter.focus();

        Button search = new Button(VaadinIcons.SEARCH);
        search.setClickShortcut(ShortcutAction.KeyCode.ENTER);

        Button clear = new Button(VaadinIcons.CLOSE_SMALL);

        CssLayout filterLayout = new CssLayout(filter, search, clear);
        filterLayout.addStyleName(ValoTheme.LAYOUT_COMPONENT_GROUP);

        Label countLabel = new Label();
        countLabel.addStyleNames(
                ValoTheme.LABEL_LIGHT, ValoTheme.LABEL_SMALL);

        HorizontalLayout headerLayout = new HorizontalLayout(
                filterLayout, countLabel);
        headerLayout.setComponentAlignment(countLabel, Alignment.MIDDLE_LEFT);

        Grid<Call> grid = new Grid<>(Call.class);
        grid.setColumns("id", "client", "phoneNumber", "city", "startTime",
                "duration", "status");
        grid.setSizeFull();

        VerticalLayout mainLayout = new VerticalLayout(headerLayout);
        mainLayout.setMargin(false);
        mainLayout.addComponentsAndExpand(grid);
        setCompositionRoot(mainLayout);
    }
}

如果你想自己实现本章的概念,这个类可以用作起点。在这个阶段,UI 在Grid中不显示任何数据,并且没有行为。

为懒加载准备后端

懒加载(和过滤)功能应尽可能委托给后端。尽管Grid类本身能够缓存一些数据,并且仅在需要时将其发送到客户端,但它无法阻止你查询整个数据库,例如。为了支持懒加载,后端服务应提供懒加载数据的手段。

通常,UI 从服务或仓库类获取数据。让我们看看一个仓库类如何提供具有懒加载功能的方法的示例。CallRepository类可以定义一个findAll方法,查询Call表中的部分行,如下所示:

public class CallRepository {

    public static List<Call> findAll(int offset, int limit) {
        ...
    }

    public static int count() {
        ...
    }
}

在之前的代码中,limit用于限制应返回的行数(实际上是User实例的实例)的数量。当使用 SQL 时,这可以用作 SQL 查询中的LIMIT子句。offset用于跳过一定数量的行,这相当于起始行号。例如,如果 SQL 表有 100 行,我们使用offset=10limit=5,则该方法应仅返回第 10 到 15 行。如果我们使用offset=98limit=5,则该方法应返回第 98 到 100 行(在 98 之后没有足够的行来完成一组五行)。

为了参考,以下是一个 JPA 实现这些方法的示例:

public class CallRepository {
    ...

    public static List<Call> find(int offset, int limit) {
        return JPAService.runInTransaction(em -> {
            Query query = em.createQuery("select c from Call c");
            query.setFirstResult(offset);
            query.setMaxResults(limit);

            List<Call> resultList = query.getResultList();
            return resultList;
        });
    }

    public static int count() {
        return JPAService.runInTransaction(em -> {
            Query query = em.createQuery("select count(c.id) from Call c");

            Long count = (Long) query.getSingleResult();
            return count.intValue();
        });
    }
}

注意我们如何在之前的代码片段中包含了count方法。在某些情况下,例如在使用Grid组件的懒加载时,这是必需的。

使用 Grid 组件进行懒加载

Grid组件可以通过使用setDataProvider方法利用之前章节中描述的offsetlimit参数,如下所示:

grid.setDataProvider(
    (sortOrders, offset, limit) ->
            CallRepository.findAll(offset, limit).stream(),
    () -> CallRepository.count()
);

之前的代码定义了两个 lambda 表达式:

  • (sortOrders, offset, limit) -> service.find(...): 这个 lambda 表达式应该返回由 offsetlimit 参数定义的 slice 中使用的所有项目(我们稍后会看到如何使用 sortOrders 参数)

  • () -> service.count(): 这个 lambda 表达式应该返回没有 slices 的可用项目总数

在上一个示例中我们使用的 setDataProvider 方法接收一个 FetchItemsCallback 的实例,这是一个定义了获取项目(或行)的方法的功能接口:

@FunctionalInterface
public interface FetchItemsCallback<T> extends Serializable {

    public Stream<T> fetchItems(
            List<QuerySortOrder> sortOrder, int offset, int limit);
}

您还可以使用另一个版本的 setDataProvider 方法,该方法接受 DataProvider 的实例。在 DataProvider 接口中有一个静态辅助方法,允许您使用类似于我们之前使用的 lambda 表达式来实现它:

DataProvider<Call, Void> dataProvider = DataProvider.fromCallbacks(
        query -> CallRepository.find(
                query.getOffset(),
                query.getLimit()).stream(),
        query -> CallRepository.count()
);

grid.setDataProvider(dataProvider);

与上一个版本的不同之处在于,我们从 Query 实例中获取 offsetlimit 值,因此我们需要使用相应的获取器。

添加过滤器

过滤也应该在后台服务的帮助下进行。我们可以以与我们在第七章实现 CRUD 用户界面中相同的方式实现它。首先,后台服务方法应该接受过滤输入。在示例应用程序中,过滤值是一个 String,但在其他情况下,您可能需要一个包含所有可用于过滤的值的自定义对象。以下是新的 find 方法,它接受一个过滤 String

public static List<Call> find(int offset, int limit, String filter,
        Map<String, Boolean> sort) {
    return JPAService.runInTransaction(em -> {
        Query query = em.createQuery("select c from Call c where lower(c.client) like :filter or c.phoneNumber like :filter or lower(c.city) like :filter");
        query.setParameter("filter",
 "%" + filter.trim().toLowerCase() + "%");
        query.setFirstResult(offset);
        query.setMaxResults(limit);

        List<Call> resultList = query.getResultList();
        return resultList;
    });
}

注意我们如何通过使用 lower JPQL 函数和 toLowerCase 方法将过滤值转换为小写来使过滤不区分大小写。我们还在数据库值中间使用 % 运算符来允许匹配。我们必须对 count 方法做类似的事情:

public static int count(String filter) {
    return JPAService.runInTransaction(em -> {
        Query query = em.createQuery("select count(c.id) from Call c where lower(c.client) like :filter or c.phoneNumber like :filter or lower(c.city) like :filter");
        query.setParameter("filter", "%" + filter.trim().toLowerCase() + "%");

        Long count = (Long) query.getSingleResult();
        return count.intValue();
    });
}

在实现的用户界面方面,我们需要将过滤值发送到服务方法。这个值来自 filter 文本字段:

DataProvider<Call, Void> dataProvider = DataProvider.fromFilteringCallbacks(
        query -> CallRepository.find(query.getOffset(), query.getLimit(),
 filter.getValue()).stream(),
        query -> CallRepository.count(filter.getValue())
);

当点击搜索按钮时,我们还需要刷新 DataProvider。这可以通过使用 ClickListenerDataProvider 接口的 refreshAll 方法来实现:

search.addClickListener(e -> dataProvider.refreshAll());

对于 clear 按钮,也可以执行类似的操作,该按钮用于移除用户引入的过滤器:

clear.addClickListener(e -> {
    filter.clear();
    dataProvider.refreshAll();
});

当调用 refreshAll 方法时,我们之前定义的 lambda 表达式将被再次调用,并从服务类中获取新的数据。

通常,在应用程序用于过滤数据的列上添加数据库索引是一个好主意。在示例应用程序中,我们允许对 clientphoneNumbercity 列进行过滤。您可以通过使用 @Index 注解让 JPA 创建这些索引,如下所示:

@Entity
@Table(indexes = {
        @Index(name = "client_index", columnList = "client"),
        @Index(name = "phoneNumber_index", columnList = "phoneNumber"),
        @Index(name = "city_index", columnList = "city")
})
@Data
public class Call {
    ...
}

默认情况下,示例应用程序在Call表中生成大约 500,000 行。不幸的是,Grid类无法处理这么多的行。有关这些限制的更多信息,请参阅以下 GitHub 上的问题:github.com/vaadin/framework/issues/6290,以及github.com/vaadin/framework/issues/9751。克服这些问题的方法之一是在查询返回的行数少于一个设定的阈值时,仅在Grid中显示过滤结果。

在网格组件中排序行

如你所猜,排序(或排序)是另一个应该尽可能委托给后端服务的任务。此外,当你正在后端服务中实现分页(即使用limitoffset参数的懒加载)时,这可能是必需的。

服务方法应包含一个指定如何执行排序的参数。Grid组件允许用户点击列标题以激活按该列排序。需要排序的这些列通过一个Query对象传递给DataProvider。你可以通过调用Query.getSortOrders()方法来获取这些列,该方法返回一个QuerySortOrder对象的List。你可以将这个List传递给服务方法,但总是避免将后端服务与前端技术耦合是一个好主意。QuerySortOrder是 Vaadin 框架中的一个类,所以如果你将它们部署在单独的组件中,你将需要在后端服务中包含 Vaadin 依赖。为了避免这种耦合,我们可以实现一个将QuerySortOrder对象转换为框架无关对象的实用方法。在后端服务中,我们可以使用一个Map<String, Boolean>,其中键是一个包含属性名称的String,值是一个Boolean,它告诉方法是否按升序排序。

准备后端服务

那么,让我们先从向示例应用程序中CallRepositoryfind方法添加一个排序配置参数开始:

public static List<Call> find(int offset, int limit,
        String filter, Map<String, Boolean> order) {
    return JPAService.runInTransaction(em -> {
        String jpql = "select c from Call c where lower(c.client) like :filter or c.phoneNumber like :filter or lower(c.city) like :filter" + buildOrderByClause(sort);
        Query query = em.createQuery(jpql);
        query.setParameter("filter", "%" + filter.trim().toLowerCase() + "%");
        query.setFirstResult(offset);
        query.setMaxResults(limit);

        List<Call> resultList = query.getResultList();
        return resultList;
    });
}

order参数包含我们需要按其排序的属性名称。我们需要将这个Map转换为 JPQL 中的order by子句(以字符串形式)。这是在buildOrderByClause方法中完成的:

private static String buildOrderByClause(Map<String, Boolean> order) {
    StringBuilder orderBy = new StringBuilder();
    order.forEach((property, isAscending) -> orderBy.append(property + (isAscending ? "" : " desc") + ","));

    if (orderBy.length() > 0) {
        orderBy.delete(orderBy.length() - 1, orderBy.length());
        return " order by " + orderBy.toString();
    } else {
        return "";
    }
}

如果用户在Grid中点击客户端标题,buildOrderByClause方法将返回以下字符串:

" order by client"

这个字符串将被连接到 JPQL 查询的末尾,然后在该find方法中执行。

Grid 组件还支持按多列排序。要向排序配置中添加列,用户必须按住 Shift 键不放,然后点击列头。例如,如果用户点击 Client 表头,并在点击 City 表头时按住 Shift 键不放,则 buildOrderByClause 方法将返回以下字符串:

" order by client,city"

在 UI 中启用排序

正如我们之前讨论的,DataProvider 接口使用类型为 List<QuerySortOrder> 的对象来提供排序配置。然而,后端服务需要一个类型为 Map<String, Boolean> 的对象。我们必须实现一个帮助方法来在这两种类型之间进行转换。我们可以将此方法添加到单独的 DataUtils 类中,并按如下方式实现:

public class DataUtils {

    public static <T, F> Map<String, Boolean> getOrderMap(
            Query<T, F> query) {
        Map<String, Boolean> map = new LinkedHashMap<>();

        for (QuerySortOrder order : query.getSortOrders()) {
            String property = order.getSorted();
            boolean isAscending = SortDirection.ASCENDING.equals(
                    order.getDirection());
            map.put(property, isAscending);
        }

        return map;
    }
}

getOrderMap 方法遍历由 query.getSortOrders() 方法返回的 QuerySortOrder 对象,并将它们映射到类型为 Map<String, Boolean> 的映射中的条目。注意我们使用了 LinkedHasMap 类型。这允许我们按照从 query 对象提供的 List 中来的顺序保持映射中的条目,如果我们想支持 Grid 中的多列排序(order by 子句应反映用户在浏览器中点击表头时使用的顺序),这是我们所需要的。

我们可以在 DataProvider 中使用此实用方法,如下所示:

DataProvider<Call, Void> dataProvider = DataProvider.fromFilteringCallbacks(
        query -> CallRepository.find(query.getOffset(), query.getLimit(), filter.getValue(), DataUtils.getOrderMap(query)).stream(),
        query -> {
            int count = CallRepository.count(filter.getValue());
            countLabel.setValue(count + " calls found");
            return count;
        }
);

最终结果如下截图所示:

图片

要完成本章的示例,我们可以启用列排序(用户可以在浏览器中拖动列以重新定位它们),如下所示:

grid.setColumnReorderingAllowed(true);

用户体验与大数据集

为了结束本章,让我分享一下拥有 10,000 行(或更多)的 Grid 的便利性(或不便之处)的一些想法。

懒加载网格与直接搜索

在我用来开发本章示例的屏幕上,我可以在 Grid 组件中一次看到大约 15 行。如果我想看到第 5,390 行,例如,我必须向下滚动并尝试找到大约 5,390 行的行。如果幸运的话,这需要 1 或 2 秒钟。之后,我必须进行一些精细的滚动才能到达确切的行。这又可能需要 1 或 2 秒钟。在这个示例应用程序中,通过搜索数据来实现这种滚动查找是可能的,因为演示数据是用连续数字为字段中的值生成的。没有缺失的数字。在其他情况下,这可能根本不可能。即使在可能的情况下,滚动数千行也不是一个好的用户体验。

过滤器旨在提供帮助;点击TextField并输入 5,390 比滚动浏览数据要快。然而,如果用户需要输入 5,390,我们可以争论渲染数千行甚至不是必需的。整个 UI 可能需要重新设计以更好地适应用例。当你遇到这种包含数千行Grid的界面时,换位思考;站在用户的角度。考虑类似向导的界面、滚动时的无限懒加载(如 Facebook 或 Twitter),或任何其他事件,并将视图分割成几个视图,每个视图针对更具体的用例。

无限懒加载

尽管我们已经通过使用Grid组件解释了懒加载,但我们也可以使用相同的后端服务方法来实现支持懒加载的自定义 UI 组件。例如,你可以在用户点击布局底部的“加载更多”按钮时,使用VerticalLayout来添加一组,比如 10 个组件。在这种情况下,你需要跟踪当前偏移量并持续增加它,直到服务方法返回少于 10 个项目。

以下是一个简单的 UI 组件,展示了如何实现这种类型的无限懒加载:

public class LazyLoadingVerticalLayout extends Composite {

    private CssLayout content = new CssLayout();
    private Button button = new Button("Load more...");

    private int offset;
    private int pageSize;

    public LazyLoadingVerticalLayout(int pageSize) {
        this.pageSize = pageSize;

        button.setStyleName(ValoTheme.BUTTON_BORDERLESS_COLORED);

        VerticalLayout mainLayout = new VerticalLayout(content, button);
        setCompositionRoot(mainLayout);

        button.addClickListener(e -> loadMore());
        loadMore();
    }

    public void loadMore() {
        List<Call> calls = CallRepository.find(
                offset, pageSize, "", new HashMap<>());

        if (calls.size() < pageSize) {
            button.setVisible(false);
        }

        calls.stream()
                .map(call -> new Label(call.toString()))
                .forEach(content::addComponent);

        offset += pageSize;
    }
}

注意loadMore方法如何持续向content布局添加组件,直到没有更多结果可以添加,此时“加载更多...”按钮从 UI 中隐藏。

以下截图显示了此组件的实际应用:

图片

概述

在本章中,我们学习了如何通过增强后端服务方法以支持它来实现懒加载。我们学习了如何使用具有过滤和排序功能的懒加载Grid组件。我们通过提供两个 lambda 表达式来实现DataProvider:一个用于获取数据切片,另一个用于计算项目总数。我们还讨论了处理大型数据集时需要考虑的 UX 方面,并学习了如何实现无限懒加载,作为拥有数千行Grid的替代方案。

本章结束了通过许多与模块化、API 设计、UI 设计和在 Vaadin 开发的应用程序中的数据管理相关有趣主题的旅程。关于这个主题,我们在这本书中无法涵盖的内容还有很多。希望这本书能激发你找到解决在用 Vaadin 开发以数据为中心的 Web 应用程序时可能遇到的某些挑战的好方法。编码愉快!

posted @ 2025-09-12 13:56  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报