Vaadin-实践教程-全-

龙哥盟 / 2024-10-06 / 原文

Vaadin 实践教程(全)

原文:Practical Vaadin

协议:CC BY-NC-SA 4.0

一、Vaadin 的世界

本章是对 web 开发和 Vaadin 相关技术的一般性介绍。我保证,这是你会看到 HTML 和 JavaScript 代码(甚至是 Python 解释器)的少数章节之一。

一行代码中的垃圾

当我开始我的 web 开发生涯时,我加入了南美最大的大学之一的招生部门的一个开发人员小组。部门操作由一个用 Java 编写的 web 应用提供支持,该应用包含 Struts 2(一个 Java web 框架)、Hibernate(一个数据库持久性框架)、Spring Framework(企业配置框架,或者如我们过去所描述的,应用的粘合剂)和许多其他库。

应用中的许多 web 页面共享一个通用的 UI(用户界面)模式:它们都有一个搜索数据的表单、一个显示数据的表格和操作数据的选项。换句话说,应用有大量的创建、读取、更新和删除( CRUD )视图。应用的代码包含了实现这些视图的助手。然而,这涉及到复制代码——这是我不太喜欢的。

当我发现 Google Web Toolkit (GWT)时,我开始研究 Java web 框架,希望找到帮助我解决这个问题的思路。GWT 包括一个 Java 到 JavaScript 的编译器,它允许开发者用 Java 而不是 JavaScript 来实现 web 用户界面。我喜欢这种创新的方法,随着我对它了解的越来越多,我发现有一个更成熟的 web 框架使用了同样的理念,只是它没有将 Java 代码编译成 JavaScript。它的名字,Vaadin

在玩了几天 Vaadin 之后,我相对快速地实现了一个可重用的库,用一行 Java 动态地创建 CRUD 视图。这里有一个例子:

GridCrud<User> crud = new GridCrud<>(User.class);

图 1-1 显示了可以用这个库创建的 CRUD 视图的类型。

img/506498_1_En_1_Fig1_HTML.jpg

图 1-1

用 Vaadin 实现的 CRUD 视图

Vaadin 允许我使用运行在服务器端的 Java 编写 web 用户界面,这是我决定在未来的许多项目中采用它的主要原因。能够在应用的所有层中使用相同的编程语言消除了上下文转换的相关工作。类似地,当开发人员加入一个项目时,他们必须经历的学习曲线几乎是平坦的——如果他们了解 jva,他们几乎立即就可以使用 Vaadin 进行生产。

当你阅读这本书时,同样的事情也会发生在你身上——当你学习 Vaadin 时,你将很快能够为你的 Java 项目实现 web UIs。到本书结束时,您将拥有实现和维护 Vaadin 应用的技能,并且,为什么不像我对 CRUD 库所做的那样,创建和发布您自己的可重用库。

Note

如果你很好奇,CRUD 库是开源的,可以在 https://vaadin.com/directory/component/crud-ui-add-on 免费获得。

网络平台

有时候,用 Vaadin 开发感觉像是魔术。你输入一段 Java 代码,它应该在浏览器上显示一个按钮,这个按钮确实神奇地出现在那里。我想告诉你在软件开发中没有魔法这种东西。如果你理解了潜在的机制,你会发现没有什么窍门,你会处于一个更好的位置来掌握任何技术。

除了 Java 编程语言,基础技术是那些在网络平台中的技术。Web 平台是一组主要由万维网联盟开发并由 Web 浏览器实现的编程语言和 API 标准。这包括 HTTP、HTML、ECMAScript(管理 JavaScript 的标准)、DOM Events、XMLHttpRequest、CSS、Web 组件、Web Workers、WebSocket、WebAssembly、地理位置 API、Web 存储以及其他一些组件。

掌握所有这些技术可能令人望而生畏,但事实是,在日常 web 开发中,您将主要直接使用其中的三种:HTML、JavaScript 和 CSS。Vaadin 抽象掉了 HTML 和 JavaScript(以及相关的 API),所以大多数时候你只能使用 Java 和 CSS。然而,至少在某种程度上理解底层技术总是好的。

超文本标记语言

HTML(超文本标记语言)是浏览器在呈现网页时使用的源。超文本是指带有超链接的文本。当您从一个页面导航到另一个页面时,您可能已经单击了许多超链接。当您看到网页时,您看到的是 HTML 文档的渲染版本。HTML 文档是由标签和文本组成的文件(在内存或硬盘中),从 HTML5 开始,以文档类型声明开始:

<!DOCTYPE html>

大多数标签成对使用。例如:

<h1>It works!</h1>

在这个例子中,<h1>是开始标记,</h1>是结束标记。标签之间的文本是标签的内容,也可以包含其他 HTML 标签。在前面的例子中,文本Web 平台由浏览器使用标题样式呈现。标题有几个层次,例如,<h2><h3>等。

HTML 标签不仅格式化代码,而且呈现 UI 控件,如按钮和文本字段。以下代码片段呈现了一个按钮:

<button>Time in the client</button>

HTML 文档的主要部分由三个标签构成:

  • <html>:文档的根或顶层元素

  • <head>:关于文档的元数据,用于添加资源(图像、JavaScript、CSS)或配置页面标题等内容(使用<title>标签)

  • <body>:文档的可呈现内容

开始标签可以包括属性。例如,您可以通过使用<html>标签的lang属性来指定页面的语言:

<html lang="en"></html>

如果我们将前面的代码片段放在<body>元素中,我们就可以形成一个所有浏览器都可以呈现的完整有效的 HTML 文档。清单 1-1 显示了一个完整有效的 HTML 文档。

<!DOCTYPE html>
<html lang="en">
<head>
  <title>The Web platform</title>
  <link rel="stylesheet" href="browser-time.css">
</head>
<body>

<h1>It works!</h1>
<button>Time in the client</button>

</body>
</html>

Listing 1-1A complete HTML document

Note

HTML 不关心缩进。开发人员会这样做,一些人选择缩进<html><body>标签的内容,而另一些人则不愿意这样做。我不喜欢缩进,因为它们出现在几乎所有的文档中,它们只是把所有的东西都向右移动。然而,为了可读性,我在<body>标签中缩进了 HTML 标签的内容。在前面的例子中,这些标签都没有其他标签作为内容,所以在<body>标签中没有缩进的内容。此外,大多数 ide 都按照我在示例中使用的样式缩进。

如果您使用纯文本编辑器(下一章将介绍如何设置开发环境)创建一个browser-time.html文件,并在 web 浏览器中打开该文件,您将看到类似于图 1-2 中的截图。

img/506498_1_En_1_Fig2_HTML.jpg

图 1-2

在 web 浏览器中呈现的简单 HTML 文档

我鼓励您在您的计算机上尝试这种方法,并用代码进行实验。尝试添加标签,如<input>,并用<b><i><code>格式化文本。

Note

你可以在 Mozilla Developer Network (MDN)网站的 https://developer.mozilla.org/en-US/docs/Web/HTML/Element 找到 HTML 中所有标签的完整列表。事实上,MDN 是学习有关 Web 平台技术的一切的极好资源。

JavaScript 和 DOM

JavaScript 是一种多用途的、基于原型的(允许在没有预先定义类的情况下创建对象)、单线程的、具有一流功能的脚本编程语言。除了它的名字和Date对象(是 Java 的java.util.Date类的直接端口),JavaScript 和 Java 语言本身没有任何关系。然而,JavaScript 经常与 Java 一起用于开发 web 应用——Java 在服务器上,JavaScript 在客户机上。JavaScript 是网络浏览器的编程语言。

DOM (文档对象模型)是一个独立于语言的 API,它将 HTML(或者更一般地,XML)文档表示为一棵树。Web 浏览器将 DOM 实现为 JavaScript API。图 1-3 描述了上一节开发的 HTML 文档的 DOM 层次结构。

img/506498_1_En_1_Fig3_HTML.jpg

图 1-3

HTML 文档的文档对象模型示例

使用 JavaScript DOM API,开发人员可以添加、更改和删除 HTML 元素及其属性,从而创建动态网页。要将 JavaScript 逻辑添加到 HTML 文档中,可以使用<script>标签:

<!DOCTYPE html>
<html>
...
<body>
...
<script>
  ... JavaScript code goes here ...
</script>

</body>
</html>

我喜欢将 JavaScript 代码放在单独的文件中。添加 JavaScript 的另一种方法是将<script>标签的内容留空,并使用src属性来指定文件的位置:

<script src="time-button.js"></script>

回到上一节的 HTML 文档,使按钮工作的 JavaScript 逻辑可以放在 time-button.js 文件中(browser-time.html 文件旁边的文件),内容如下:

let buttons = document.getElementsByTagName("button");

buttons[0].addEventListener("click", function() {
  let paragraph = document.createElement("p");
  paragraph.textContent = "The time is: " + Date();
  document.body.appendChild(paragraph);
});

我尽量把前面的 JavaScript 代码写得对 Java 开发者来说尽可能清晰。这个脚本将文档中的所有<button>元素作为一个数组,并向第一个元素添加一个 click 监听器(顺便说一下,这里只有一个按钮)。click listener 实现为一个函数,当用户单击按钮时调用该函数。这个监听器使用 DOM API 创建一个新的<p>元素,并设置它的文本内容来显示当前时间。然后,它将新创建的元素附加到<body>元素的末尾。结果如图 1-4 所示。

img/506498_1_En_1_Fig4_HTML.jpg

图 1-4

在 web 浏览器中运行的简单 JavaScript 应用

半铸钢ˌ钢性铸铁(Cast Semi-Steel)

CSS(级联样式表)是一种允许配置字体、颜色、间距、对齐和其他样式特征的语言,这些特征决定了 HTML 文档应该如何呈现。向 HTML 文档添加 CSS 代码的一个简单方法是在<head>元素中使用<style>标记:

<!DOCTYPE html>
<html>
<head>
  ...
  <style>
    ... CSS code goes here ...
  </style>
</head>
  ...
</html>

和 JavaScript 文件一样,我喜欢用单独的文件来定义 CSS 样式。这是通过在<head>部分使用一个<link>标签来完成的:

<head>
  <link rel="stylesheet" href="browser-time.css">
<head>

Tip

<link>是没有结束标签(</link>)的标签之一。在 HTML5 中,不允许使用结束标记;然而,浏览器只是忽略了</link>或者在渲染页面时在>前添加一个/的货运惯例。

CSS 规则将样式应用于 HTML 文档。每个 CSS 规则都被写成一个针对 HTML 元素的选择器和带有应用于这些元素的样式的声明。例如,以下 CSS 规则更改整个 HTML 文档的字体:

html {
  font: 15px Arial;
}

html部分是选择器。大括号内是声明。这个规则中只有一个声明,但是也可以定义多个声明。以下 CSS 规则将所有<h1>元素更改为全角(100%)、半透明蓝色背景色和 10 像素的填充(元素文本周围的空间):

h1 {
  width: 100%;
  background-color: rgba(22, 118, 243, 0.1);
  padding: 10px;
}

选择器允许通过标记名(像前面的例子一样)、元素 ID、属性值等进行定位。最重要的选择器之一是类选择器。一个类选择器允许目标元素在它们的class属性中有一个指定的值。下面的代码片段显示了如何将time-button CSS 类添加到示例中的按钮:

<button class="time-button">Time in the client</button>

CSS 类选择器以句点开头,后跟目标类的名称。要设置上例中按钮的样式,可以使用如下规则:

.time-button {
  font-size: 15px;
  padding: 10px;
  border: 0px;
  border-radius: 4px;
}

此规则将字体大小更改为 15 像素,在按钮中的文本周围添加 10 像素的填充,移除边框,并使其边角略微变圆。结合这些概念,可以在一个单独的 browser-time.css 文件中设计整个 HTML 文档的样式:

html {
  font: 15px Arial;
}

body {
  margin: 30px;
}

h1 {
  width: 100%;
  background-color: rgba(22, 118, 243, 0.1);
  padding: 10px;
}

.time-button {
  font-size: 15px;
  padding: 10px;
  border: 0px;
  border-radius: 4px;
}

图 1-5 显示了之前应用于 HTML 文档的 CSS 规则。

img/506498_1_En_1_Fig5_HTML.jpg

图 1-5

用自定义 CSS 样式呈现的 HTML 文档

Web 组件

Web 组件是一组允许创建可重用的自定义 HTML 元素的技术。在本节中,我将向您介绍主要技术:自定义元素。这应该足以让你理解关键的网络平台概念,并看到没有真正的魔法。

Web 组件是一个可重用的封装自定义标签。示例中的“客户机中的时间”按钮是这种组件的一个很好的候选。如果能够通过一个自定义标记在多个 HTML 文档中使用该组件,将会非常方便:

<time-button></time-button>

定制元素的名字中必须有一个破折号,这样浏览器(和你)就知道它不是一个标准的 HTML 元素。使用自定义元素需要两件事情:

  • 实现一个扩展HTMLElement(或者一个特定元素)的类。

  • 使用customElements.define(name, constructor)注册新元素。

以下是如何:

class TimeButtonElement extends HTMLElement {

  constructor() {
    super();
    ...
  }
}

customElements.define("time-button", TimeButtonElement);

在构造函数中,您可以通过使用this.innerHTML或 DOM API 中的任何可用功能来定义元素的内容:

let button = document.createElement("button");
button.textContent = "Time in the client";
button.classList.add("time-button");

button.addEventListener("click", function () {
  let paragraph = document.createElement("p");
  paragraph.textContent = "The time is: " + Date();
  document.body.appendChild(paragraph);
});

this.appendChild(button);

这会以编程方式创建按钮,并将其追加到自定义元素中。为了使元素在重用时更加灵活,允许指定像按钮中显示的文本这样的值而不是硬编码它们是一个好主意("Time in the client"):

button.textContent = this.getAttribute("text");

有了它,按钮可以按如下方式使用:

<time-button text="Time in the client"></time-button>

只需在文档中添加更多的<time-button>标签,就可以多次使用该组件。清单 1-2 展示了一个完整的 HTML 文档,其中包含两个不同文本的按钮,清单 1-3 展示了配套的 time-button.js 文件,该文件实现并注册了定制元素。

class TimeButtonElement extends HTMLElement {

  constructor() {
    super();
    let button = document.createElement("button");
    button.textContent = this.getAttribute("text");
    button.classList.add("time-button");

    button.addEventListener("click", function () {
      let paragraph = document.createElement("p");
      paragraph.textContent = "The time is: " + Date();
      document.body.appendChild(paragraph);
    });

    this.appendChild(button);
  }
}

customElements.define("time-button", TimeButtonElement);

Listing 1-3A custom element implemented in JavaScript (time-button.js)

<!DOCTYPE html>
<html lang="en">
<head>
  <title>The Web platform</title>
  <link rel="stylesheet" href="browser-time.css">
</head>
<body>

<h1>It works!</h1>
<time-button text="Time in the client"></time-button>
<time-button text="What time is it?"></time-button>
<script src="time-button.js"></script>

</body>
</html>

Listing 1-2An HTML document reusing a custom element

您只需要一个文本编辑器和一个浏览器来尝试这一点。如果您是 web 开发新手,我建议您这样做。尝试创建这些文件,将它们放在同一个目录中,并在 web 浏览器中打开 HTML 文件。在继续之前,请确保您了解正在发生的事情。旅程的客户端步骤以图 1-6 结束,它显示了到目前为止开发的最终纯 HTML/JavaScript 应用的屏幕截图。

img/506498_1_En_1_Fig6_HTML.jpg

图 1-6

最终的纯客户端 web 应用

服务器端技术

有了 Web 平台的基础,您现在可以接近等式中同样激动人心的服务器端了。简而言之,这意味着理解什么是 web 服务器,如何向 web 服务器添加自定义功能,以及如何将客户端(浏览器)与 web 服务器连接起来。

网络服务器

术语网络服务器用于指代硬件和软件实体。在硬件领域,web 服务器是一台包含 web 服务器软件和资源的机器,这些资源包括 HTML 文档、JavaScript 文件、CSS 文件、图像、音频、视频甚至 Java 程序。在软件领域,web 服务器是通过 HTTP(浏览器理解的协议)向客户端(web 浏览器)提供主机(硬件 web 服务器)中的资源的软件。本书使用术语 web 服务器的软件定义。图 1-7 显示了客户端-服务器架构中的主要组件以及通过 HTTP 请求和响应的数据流。

img/506498_1_En_1_Fig7_HTML.jpg

图 1-7

HTTP 上的客户机-服务器体系结构

通常,web 服务器被称为 HTTP 服务器或应用服务器,这取决于所提供的内容是静态的还是动态的。静态 web 服务器将 URL 映射到主机中的文件,并在浏览器请求时发送它们。动态 web 服务器是静态 web 服务器,但它为开发人员提供了在提供托管文件之前处理它们的可能性。

web 服务器是可以在您的机器上安装和运行的软件。您的计算机中可能已经安装了一个(或几个)。事实上,几乎所有的 Unix 平台(Linux、macOs、FreeBSD 和其他平台)都自带 Python,而 Python 又包含一个模块,可以轻松地运行 HTTP web 服务器来为当前目录中的文件提供服务。在 Windows 系统上,您必须安装 Python,或者更好的是,启用 WSL(Linux 的 Windows 子系统)并使用 Windows store 安装 Linux 发行版,例如 Ubuntu,默认情况下它包含 Python。

根据 Python 的版本,您必须运行以下命令之一来启动允许通过 HTTP 访问当前目录中的文件的 web 服务器:

> python -m SimpleHTTPServer 8080
> python3 -m http.server 8080

Tip

如果您的计算机上安装了 Node.js,您也可以使用npm install -g http-server安装一个 HTTP 服务器,并使用http-server -p 8080运行它。

您可以使用 URL 从连接到您的网络甚至互联网的任何设备请求 HTML 文档(假设防火墙和其他安全机制不会阻止对您的 web 服务器的访问)。图 1-8 显示了我从手机上请求 HTML 文档时的示例(根据您使用的浏览器和操作系统,您得到的结果可能略有不同)。请注意我是如何使用我的 IP 地址来访问文件,而不是直接在浏览器中打开它的。

img/506498_1_En_1_Fig8_HTML.jpg

图 1-8

从 web 服务器提供的 HTML 文档

公共网关接口

CGI(公共网关接口)是从网络服务器提供动态内容的最简单的方法之一。我就避开 CGI 死了还是好不好的讨论。我的目的是让您向服务器端技术迈进一步,从实用的角度来看,这项技术很容易理解。

CGI 定义了一种方法,允许 web 服务器与服务器中的外部程序进行交互。这些程序可以用任何编程语言实现。CGI 将 URL 映射到这些程序。外部程序使用标准输入(STDIN)和标准输出(STDOUT)与客户端通信,这在 Java 中可以通过System.inSystem.out对象获得。标准程序和 CGI 程序的主要区别在于输出应该以包含Content-Type标题的一行开始。例如,要提供纯文本,输出必须以Content-Type: text/html开头,后面跟一个空行,后面跟要提供的文件的内容。

Python HTTP 服务器模块包括 CGI。要启用它,使用--cgi参数启动服务器:

> python -m SimpleHTTPServer 8080 --cgi
> python3 -m http.server 8080 --cgi

有了这个服务器,当你使用这个服务器的时候,CGI 程序应该放在 cgi-bin 目录下。其他 web 服务器可能使用不同的位置,可能需要安装额外的模块才能工作。

让我们看看如何用 Java 实现一个 CGI 程序。从版本 11 开始,可以创建一个可以直接从命令行执行的 Java 程序:

#!/usr/bin/java --source 11
public class ServerTime {
  public static void main(String[] args) {
    System.out.println("Content-Type: text/plain\n");
    System.out.println("It works!");
  }
}

Note

上例中的第一行是一个 she bang——一个基于 Unix 的系统识别的幻数,用于确定文件是脚本还是可执行的二进制文件。记得使用完整路径到 java 命令,并用 chmod +x server-time 使文件可执行。在 Windows 系统中,你可以创建一个。bat 文件来调用 java 程序。在这种情况下,您需要一个单独的文件来放置 Java 代码。

如果您将这个文件命名为 server-time (不要使用。java 扩展)并将其放在 cgi-bin 目录中(相对于您启动 Python web 服务器的位置),您将能够在http://localhost:8080/CGI-bin/server-time从浏览器访问该程序。图 1-9 显示了结果。

img/506498_1_En_1_Fig9_HTML.jpg

图 1-9

用 Java 编写的 CGI 程序返回的纯文本

前面的例子并没有真正创建动态内容——每次请求页面时,浏览器都显示完全相同的内容。然而,很容易理解 Java 程序可以做的不仅仅是返回硬编码的字符串。它可以读取一个 HTML 文件,并通过替换一个占位符来处理它以添加动态内容,然后将结果发送给STDOUT。这是许多 web 框架使用的一种技术。下面是一个可能的实现:

#!/usr/bin/java --source 11

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Date;

public class ServerTime {

  public static void main(String[] args) throws IOException {
    Path path = Path.of("cgi-bin/template.html");
    String template = Files.readString(path,
        Charset.defaultCharset());

    String content = "Time in the server: " + new Date();
    String output = template.replace("{{placeholder}}", content);

    System.out.println("Content-Type: text/html\n");
    System.out.println(output);
  }
}

这个程序获取 cgi-bin/template.html 文件,读取其内容,并用包含服务器中计算的时间的字符串替换{{placeholder}}。模板文件可能是这样的:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>CGI Example</title>
</head>
<body>

<h1>It works!</h1>
{{placeholder}}

</body>
</html>

图 1-10 显示了在浏览器中调用程序的结果。现在,这是一个动态页面,每次你请求时,它都会显示不同的内容。

img/506498_1_En_1_Fig10_HTML.jpg

图 1-10

由 CGI 程序生成的动态 HTML 文档

小型应用

Jakarta Servlet (以前称为 Java Servlet)是 Java 的 API 规范,允许开发人员使用 Java 编程语言扩展服务器的功能。虽然在上一节中我已经向您展示了如何用 Java 实现这一点,但是与 Java servlets 相比,CGI 有几个缺点。CGI 的主要挑战与这样一个事实有关,即每次用户请求一个页面时,服务器都会启动一个新的进程(或者服务器中的 CGI 程序)。这降低了对服务器的请求速度,潜在地消耗了更多的内存,使得使用内存中的数据缓存更加困难,并且降低了程序的可移植性。

Servlets 是用于 web 开发的 Java 解决方案,它通过一个可靠的、久经考验的 API 提供了请求-响应协议(如 HTTP)的面向对象的抽象。servlet 是一个 Java 程序(或类),它由一个名为 servlet 容器的软件组件管理。servlet 容器是 Jakarta Servlet API 的具体实现。有些 web 服务器包括现成的 servlet 容器实现。最流行的是 Apache Tomcat 和 Eclipse Jetty。

Tip

如何在 Tomcat 和 Jetty 之间抉择?我在这里没有一个好的答案,但是有一个快速的指南可以帮助你开始你自己的研究。两者都可以投入生产,并且经过了许多严肃项目的测试。Tomcat 更受欢迎,更快地结合了最新版本的规范。Jetty 似乎用在高性能是关键的项目中,优先考虑整合社区所需的修复,而不是支持最新版本的规范。

简而言之,操作系统运行进程。JVM 作为执行字节码编译的程序(用 Java、Kotlin、Scala、Groovy 或其他 JVM 语言编写)的进程运行。Tomcat 和 Jetty Java 服务器都是用 Java 实现的(尽管 servlet 容器可以用任何语言实现,只要它们符合 Jakarta Servlet 规范)。Java 服务器读取组成 Java web 应用的文件,而 Java web 应用又与 servlet API 交互来处理请求并产生响应。图 1-11 显示了该堆栈的概况。

img/506498_1_En_1_Fig11_HTML.jpg

图 1-11

服务器端 Java 堆栈

下面是一个简单的 servlet 实现:

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/*")
public class ServletExample extends HttpServlet {
  private String replace;

  public void doGet(HttpServletRequest request,
                    HttpServletResponse response) throws
      IOException, ServletException {

    response.setContentType("text/plain");
    response.getWriter().println("It works!");
  }
}

这个类使用 servlet API 将纯文本写入响应,类似于 CGI 程序将响应写入STDOUT的方式。@WebServlet注释配置了服务器用来向这个 servlet 发送请求的 URL。在这种情况下,对应用的所有请求都将被发送到由 servlet 容器创建和管理的ServletExample类的一个实例。

要编译这个类,您需要将 servlet API 添加到 JVM 类路径中(下一章将展示如何用 Maven 完成这一步)。Java 服务器附带了这个 API。例如,下载 Apache Tomcat 9.0 的 ZIP 文件,并提取其中的内容。可以在 http://tomcat.apache.org 下载服务器。您将在 Tomcat 安装的 lib 目录中找到一个包含 servlet API 的 servlet-api.jar 文件。复制该文件的完整路径,并如下编译ServletExample类(更改您自己的 JAR 文件的位置):

javac ServletExample.java -cp /apache-tomcat/lib/servlet-api.jar

Java web 应用需要特定的目录结构,以便供 Java 服务器使用。在 Tomcat 安装的 webapps 目录中创建一个新的 java-web-development 目录。这是您的应用的根目录。编译好的 Java 类文件应该放在你的应用根目录下的子目录中: WEB-INF/classes 。创建这个目录并将 ServletExample.class 文件复制到其中。

使用 bin/startup.shbin/startup.bat 脚本启动服务器。例如,您可能需要使用chmod +x *.sh使这些文件可执行。现在可以在http://localhost:8080/Java-we b-development调用 servlet。图 1-12 显示了结果。

img/506498_1_En_1_Fig12_HTML.jpg

图 1-12

由运行在 Tomcat 上的 Java servlet 生成的文本文档

您还可以使用 Java 服务器提供静态 HTML、CSS 和 JavaScript 文件。作为一个练习,尝试将前面几节中开发的示例文件复制到应用的根目录下。您需要将 servlet 映射到不同的 URL 以使文件可用(例如,@WebServlet("/example")),重新编译并重启服务器。你可以通过运行 shutdown.sh 或者 shutdown.bat 来停止服务器。

使用 Vaadin 进行 Web 开发

至此,您应该清楚,不管 HTML、JavaScript 和 CSS 文件是由 Java 程序、Java servlet 还是文件系统生成的,提供这些文件都没有什么神奇之处。大多数 Java web 框架使用这些技术来简化 web 开发。Vaadin 就是其中之一。

Vaadin 的核心包括一个 Java servlet ( VaadinServlet),它为您处理一切(或几乎一切)。这个 servlet 扫描您的类并构建一个组成 web 用户界面的组件树。这个组件树与浏览器中的 DOM 相似(但不相同)。您使用一个名为 Vaadin 流的 Java API 来构建这个树。

当在浏览器中调用 Vaadin 应用时,Vaadin servlet 用一个轻量级客户端 JavaScript 引擎进行响应。当用户在浏览器中与应用交互时,客户端引擎通过发送请求和接收来自 Vaadin servlet 的响应,动态地添加、删除或修改 DOM 中的元素(实现为 Web 组件)。

客户端引擎是一组静态资源,包括在您构建应用时由 Vaadin 自动生成的配置文件、Web 组件和 HTML 文档。这些资源是由一个 Maven 插件创建的,你将在下一章中了解到。

摘要

通过教你 web 开发的基础知识,这一章让你处于一个很好的位置,开始学习 Vaadin 的细节。您看到了 web 平台如何允许您使用 HTML 文档呈现 Web 页面,这些文档可以使用 CSS 规则进行样式化,并使用 JavaScript 进行动态修改。您不仅通过使用可以用任何编程语言编写的 CGI 程序,还通过创建部署到实现 Jakarta servlet API 的 Servlet 容器的 Servlet,学习了什么是 web 服务器以及如何为它们添加功能。您还了解了 Vaadin 如何包含一个 servlet 实现,该实现与客户端引擎通信以在浏览器中呈现 Web 组件。

下一章将教你如何设置你的开发环境,以及如何创建和调试 Vaadin 应用。在此期间,您将学习框架中的关键基本概念。

二、设置开发环境

本章解释了如何设置开始用 Java 和 Vaadin 编码所需的所有工具。简而言之,您需要 Java 开发工具包和 Java 集成开发环境。您将学习 Vaadin 的基础知识,以及如何编译、运行和调试您自己的程序以及本书中包含的示例。

安装 JDK

Java 开发工具包(JDK)是开发 Java 应用所需的工具和库的集合。这包括 Java 编译器,它将您的纯文本源代码文件转换成 Java 运行时环境(JRE)可以运行的二进制文件。任何需要运行编译后的 Java 程序的设备都需要 JRE。对于 Vaadin 应用,设备很可能是 Java 服务器。JRE 是几年前 Java 出现在市场上时创造的著名和经典的“编译一次,在任何地方运行”口号的来源。JDK 包括 JRE 和附加工具。

JDK 有几个高质量的发行版。Vaadin 推荐亚马逊 Corretto,这是一个由亚马逊提供支持的免费开源发行版。Amazon Corretto 基于 JDK 的参考实现 OpenJDK。我个人推荐改为采用 OpenJDK 。在写这本书的时候,这个项目正在以 Adoptium 的名字转移到 Eclipse Foundation,所以你可能不得不寻找这个名字而不是 AdoptOpenJDK,这取决于你何时阅读本章。AdoptOpenJDK(或 Adoptium)是 OpenJDK 的高质量、企业就绪版本,符合 Oracle 的技术兼容性工具包(从迁移到 Eclipse Foundation 开始)。该项目是开源的,让社区可以使用所有的构建和测试脚本。

Tip

可以把 JDK 想象成由全球开发者社区开发的一套标准,通过所谓的 Java 社区过程(JCP)来实现整个过程,通过 Java 规范请求(JSR)来实现 JDK 的特定特性或部分。任何人都可以采用这些标准并执行它们。OpenJDK 是一个实际上是源代码的实现。发行版获取源代码并生成二进制文件,然后将它们打包到一个归档文件中,开发人员可以将它们安装到自己的机器上。

要安装 JDK,进入 https://adoptopenjdk.net (或 https://adoptium.net ),选择 OpenJDK 16(或你在那里找到的最新版本),点击页面底部的大按钮下载安装程序。网页截图如图 2-1 所示。

img/506498_1_En_2_Fig1_HTML.jpg

图 2-1

adoptopenjdk 下载页面

Linux、Windows 和 macOS 的安装包都是可用的。点击其他平台按钮可以看到所有平台。选择与您的机器相匹配的一个,运行安装程序,并按照显示的步骤操作。

要再次检查 JDK 是否可以使用,请打开终端或命令行窗口,并运行以下命令来确认编译器的版本与您下载并安装的版本相匹配:

> javac -version
javac 16.0.1

您还可以通过在命令行中运行java -version来检查 JVM 是否准备好被调用。该命令应该报告您刚刚安装的 JDK 的版本和发行版。例如:

> java -version
openjdk version "16.0.1" 2021-04-20
OpenJDK Runtime Environment AdoptOpenJDK-16.0.1+9 (build 16.0.1+9)
OpenJDK 64-Bit Server VM AdoptOpenJDK-16.0.1+9 (build 16.0.1+9, mixed mode, sharing)

Tip

如果你安装了旧版本的 JDK,你可以使用像 SDKMAN 这样的工具!你可以在 https://sdkman.io 了解更多。

安装 IDE

集成开发环境(IDE)是一种应用,它在单个图形用户界面中包含软件开发所需的工具。一个好的 IDE 应该包括带有语法高亮的源代码编辑器、构建工具和调试器。Java 的主要 IDE 是 Eclipse IDE、Visual Studio 代码、Apache NetBeans,以及本书使用的免费 IntelliJ IDEA 社区版。您可以使用任何 IDE,但是本书中的屏幕截图和说明是为 IntelliJ IDEA 量身定制的,因为本章没有足够的篇幅来涵盖所有内容。我实际上已经使用 Eclipse IDE 开发了这本书的例子,但是我决定展示 IntelliJ IDEA,因为它提供了一种快速运行 Maven 项目的方法(稍后将详细介绍)。

要安装 IntelliJ IDEA,请转到 www.jetbrains.com/idea 并点击下载选项。网页截图如图 2-2 所示。

img/506498_1_En_2_Fig2_HTML.jpg

图 2-2

IntelliJ IDEA 网站

选择您的操作系统,下载免费的社区版安装程序,并运行它。第一次运行 IDE 时,可以使用它提供的默认配置选项。

使用本书中的例子

这本书包含多个独立的例子。为了方便起见,所有示例都聚集在一个项目中,您可以将该项目导入到您的 IDE 中。通过位于 www.apress.com/9781484271780 的图书产品页面下载代码。在这个页面上,您会找到一个 GitHub 上的 Git 资源库的链接,您可以在这里下载 ZIP 文件形式的代码。将 ZIP 文件的内容解压到硬盘中的任意位置。

Tip

或者,如果您是 Git 用户,您可以使用 Git 来克隆存储库。

在 IDE 中导入代码

下载并提取源代码后,运行 IntelliJ IDEA,在图 2-3 所示的欢迎屏幕中点击打开或导入选项。如果您已经是 IntelliJ IDEA 用户,并且打开了项目,要么关闭所有项目以查看欢迎屏幕,要么改为选择文件打开

img/506498_1_En_2_Fig3_HTML.jpg

图 2-3

IntelliJ IDEA 的欢迎屏幕

IntelliJ IDEA 显示一个窗口,用于从硬盘上的文件中选择一个项目。转到解压源代码的目录(或文件夹),选择该目录下的 pom.xml 文件,如图 2-4 所示。出现提示时,确保选择打开为项目选项。

img/506498_1_En_2_Fig4_HTML.jpg

图 2-4

聚合器项目 pom.xml 文件

项目导入后,检查它是否与您安装的 JDK 相关联。选择包含项目视图中所有其他子目录的根(最顶层)目录,并选择文件项目结构...。在项目结构窗口的左侧,点击项目并确保在窗口右侧的项目 SDK项目语言级别部分选择了 JDK 版本 16 或更高版本。图 2-5 显示了该配置。

img/506498_1_En_2_Fig5_HTML.jpg

图 2-5

IntelliJ IDEA 中的 JDK 配置

运行示例

由于 Maven 和 IntelliJ IDEA 提供的良好集成,构建和运行示例变得轻而易举。点击 IDE 窗口右侧的 Maven 或者在菜单栏中选择视图工具窗口Maven 。您会看到书签中包含的所有示例都带有一个蓝色的“m”图标。从第二章开始的所有示例均可在此视图中找到。

Caution

第一章的例子不是基于 Maven 的。你必须按照第一章中的说明来构建和运行这些例子。

从运行我为你准备的欢迎项目开始。在 Maven 视图中,选择 welcome-to-vaadin 项目并点击上面带有“播放”图标的 Run Maven Build 按钮。见图 2-6 。

img/506498_1_En_2_Fig6_HTML.jpg

图 2-6

运行欢迎项目

第一次构建 Vaadin(或者一般来说,Maven)项目时,花费的时间可能比您预期的要长。这是因为 Maven 必须将所有依赖项(JAR 文件形式的 Java 库)下载到您的本地 Maven 存储库中。一旦这些依赖项位于您的本地存储库中,运行项目就会变得更快。

Caution

Vaadin 自动在中安装 Node.js。您的主目录中的 vaadin 目录。下载所需的客户端依赖项需要 Node.js。你不需要担心这个问题,除非你已经安装了 Node.js。如果是这样,请检查您使用的是 Node.js 版本 10 或更高版本。

运行完项目后,在运行窗口(应用的日志)中等待一条消息,显示“前端编译成功”,然后转到 http://localhost:8080 。你应该会看到我为你准备的欢迎信息和关于 Vaadin 的有趣事实。

Note

Maven 是一个项目管理工具。它允许你使用一个名为 pom.xml 的文件来管理你的项目。在这个文件中,您定义了 Java 项目的依赖项(例如,Vaadin、Spring、Hibernate)以及有助于构建项目的插件。IntelliJ IDEA 对 Maven 有很好的支持,所以你很少需要使用命令行来运行 Maven。解释 Maven 超出了本书的范围。如果你想了解更多,请访问官方网站 https://maven.apache.org

您的第一个 Vaadin 应用

创建新的 Vaadin 项目有多种方法。Vaadin 建议使用在 https://start.vaadin.com 可用的工具来创建新项目。不幸的是,在撰写本文时,这个在线工具不包括创建没有生成视图和 Spring Boot 的最小项目的选项。当你读这本书的时候,这可能会改变,但是为了你的方便,我已经创建了一个最小的空项目,你可以用它作为模板开始编写你的应用。您可以在本书附带的源代码的 ch02/empty-project/ 目录中找到这个空项目(参见 www.apress.com/9781484271780 处的说明)。

要创建一个新项目,复制 empty-project 目录及其内容,并将该目录的名称更改为您希望用作新项目名称的名称。例如,如果您在 Linux 计算机上,请在命令行中运行以下命令:

cd ch02
cp -r empty-project ~/my-first-vaadin-app

这将把新项目放在主目录中,但是可以随意使用任何其他位置。以与包含本书示例的聚合器项目相同的方式在 IDE 中导入项目,并在 pom.xml 文件中将artifactID更改为更合适的名称。例如:

<project ...>

    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-first-vaadin-app</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    ...

<project>

项目视图应该类似于图 2-7 中的截图。

img/506498_1_En_2_Fig7_HTML.jpg

图 2-7

一个最小的 Vaadin 项目结构

一切就绪,开始编写您的第一个 Vaadin 应用!

Note

Vaadin 允许在服务器端 Java 或客户端 TypeScript 中实现视图。服务器端 Java 是指在你的服务器上运行的 Java 代码。使用 Java 编写视图,这些视图构成了应用的 web 用户图形界面。客户端 TypeScript 意味着用 TypeScript 编程语言实现的视图。这段代码被编译成 JavaScript 并在 web 浏览器中运行。这本书的重点是服务器端 Java 视图。

路线和视图

通过右键单击com.example包并选择 NewJava Class 来创建一个名为HelloWorldView的新 Java 类。IntelliJ IDEA 将生成以下类:

public class HelloWorldView {
}

Note

从现在开始,您将看到的大多数代码片段中都省略了包声明。

您将在这个类中实现 Java 应用的 web 用户界面(UI)。这个类叫做视图,在同一个项目中你可以有多个。每个视图应该映射到不同的 URL。视图的完整 URL 由 web 服务器的位置(例如,开发环境中的 http://localhost:8080/ )和路由后缀组成。要将后缀映射到视图,使用@Route注释。例如:

import com.vaadin.flow.router.Route;

@Route("hello-world")
public class HelloWorldView {
}

这定义了一个路由,允许您使用类似http://localhost:8080/hello-world的 URL 访问视图。如果您指定一个空字符串(@Route("")),那么您将使用 http://localhost:8080 来访问视图。

Caution

通过将光标放在要导入的类上并点击 Ctrl+space ,确保从com.vaadin包中导入正确的类。一个常见的错误是从包含与 Vaadin 的类同名的类的java.awt包中导入类。请注意这一点,以避免编译错误。

用户界面组件和布局

Vaadin 包括一组 UI 组件。UI 组件是可以添加到用户界面中的可重用元素。例如,您可以分别使用Button类和TextField类来添加按钮和文本字段。

一种特殊的 UI 组件是布局。布局允许您向其中添加其他 UI 组件(包括其他布局),以便垂直、水平或以您需要的任何其他方式排列它们。

@Route标注的类必须是 UI 组件。要使HelloWorldView成为一个 UI 组件,您必须扩展一个现有的组件。因为视图通常包含许多 UI 组件,所以视图扩展某种布局类是有意义的。

Tip

或者,您可以扩展Composite类,它提供了一种更好的方法来封装视图中的逻辑。下一章将展示如何使用这个类。

当你阅读这本书和使用这个框架时,你会了解到 Vaadin 中许多可用的 UI 组件。现在,只要延长VerticalLayout:

import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;

@Route("hello-world")
public class HelloWorldView extends VerticalLayout {
}

这应该已经可以工作了,但是在浏览器中看起来会很无聊。实现一个将段落添加到视图的构造函数,如下所示:

public HelloWorldView() {
  add(new Paragraph("Hello, World!"));
}

按照前面的说明运行应用,并将浏览器指向http://localhost:8080/hello-world。结果如图 2-8 所示。

img/506498_1_En_2_Fig8_HTML.jpg

图 2-8

用 Vaadin 实现的“Hello,World”应用

恭喜你!您已经成功实现了您的第一个 Vaadin 应用——用 Java 和 Vaadin 编写的经典“Hello,World”应用。

Caution

在运行一个新的应用之前,确保通过点击工具栏中的停止按钮或 IntelliJ IDEA 中的运行视图来停止以前运行的应用。否则,服务器会抱怨端口 8080 不可用。

事件和侦听器

可以想象,Vaadin 包含了商业应用中常见的各种 UI 组件。当您开发 Vaadin 应用时,您可以组合这些 UI 组件来组成更复杂的视图。例如,将文本字段、组合框和按钮等组件添加到垂直或水平布局中,以构建用于数据输入的表单。

输入组件使用事件编程模型,该模型允许您对诸如单击或 UI 中所选值的更改之类的事情做出反应。为了向您展示这个概念,请尝试将以下类添加到项目中:

@Route("greeting")
public class GreetingView extends VerticalLayout {

  public GreetingView() {
    var button = new Button("Hi");
    button.addClickListener(event ->
        Notification.show("Hello!")
    );

    add(button);
  }

}

这段代码创建了一个按钮,并添加了一个事件侦听器来对点击做出反应。侦听器被实现为显示通知的 lambda 表达式。试试看。通过在 IDE 中选择构建构建项目来编译项目,并使用浏览器转到http://localhost:8080/greeting

排除故障

调试是发现和修复软件缺陷的过程,也称为 bug。调试器是一种允许您暂停正在运行的程序的执行以检查其状态的工具。它还允许您执行任意代码,一次一步地运行您的程序(逐行)。

调试器通常在用特定编程语言实现的应用上工作。IntelliJ IDEA 包括一个用于 Java 应用的调试器。Web 浏览器包括 HTML、CSS 和 JavaScript 代码的调试器。您必须尽早学习如何使用这些调试器来查找您遇到的错误,并帮助您理解程序是如何工作的。

服务器端调试

如果您想在示例应用中添加断点,您必须在调试模式下运行它们。停止应用,在 Maven 视图中右键单击项目,并在上下文菜单中选择Debug‘my-first-vaa din-app…’选项。

尝试在应用中添加一个断点,方法是单击显示通知的代码行的行号右侧。参见图 2-9 。

img/506498_1_En_2_Fig9_HTML.jpg

图 2-9

IntelliJ 思想中的一个断点

现在转到浏览器并单击按钮。程序执行暂停,调试器显示在调试视图中。您可以看到当前范围内的变量并检查它们的值。例如,您可以通过检查event变量中的值来查看屏幕上发生点击的位置。您也可以逐行继续执行程序,或者直到遇到另一个断点(如果有的话)。熟悉此视图中的选项。它们会在项目开发的某个时候变得有用。

客户端调试

在客户端,您会遇到需要查看浏览器中 DOM 的当前状态来调整布局问题的情况。所有主要的网络浏览器都包含了对开发者来说很好的工具。在谷歌浏览器的情况下,你可以通过按 F12 来激活这个工具。

找到 UI 特定部分的 HTML 的一个好方法是右键单击您感兴趣的 UI 组件并选择 Inspect 。用你的应用的按钮试试这个。图 2-10 显示了 Chrome DevTools 和在浏览器中形成按钮的 HTML。

img/506498_1_En_2_Fig10_HTML.jpg

图 2-10

检查完浏览器中的元素后

您还可以修改 HTML 和 CSS 样式,这在您实现视图并尝试微调应用的外观时非常方便。

摘要

本章使您能够有效地开始使用 Vaadin。您学习了如何安装 JDK 和 Java IDE。您下载并导入了本书中包含的示例,并学习了如何使用 IntelliJ IDEA 运行它们。您还编写了自己的第一个 Vaadin 应用,并首次尝试了在服务器端用 Java 实现 web 用户界面的方法。您了解了使用@Route注释可以将视图映射到 URL,并且理解了 Vaadin 如何允许您通过将 UI 组件添加到布局中来编写视图,以及通过实现事件监听器来对浏览器中的用户操作做出反应。最后但同样重要的是,您快速地看了一下服务器端和客户端上可用的调试器。

下一章将更深入地介绍布局的概念。开发 Vaadin 应用时,掌握布局是关键。你必须确保你能使视图在屏幕上的位置和大小看起来像你想要的那样。

三、布局

布局是在屏幕上组织 UI 组件的主要机制。例如,您可能希望创建一个带有垂直对齐的文本字段和组合框的表单。或者您可能想要一个带有文本字段和水平对齐的按钮的搜索栏。Vaadin 包括这些场景的布局和更多。本章教你如何使用 Vaadin 中包含的所有布局组件。

垂直和水平布局

可以说,最明显的排列 UI 组件的方式是从上到下或者从左到右。VerticalLayoutHorizontalLayout类就是为这些场景设计的,是大多数 Vaadin 应用的基本构建模块。

您可以通过创建一个新实例并向其中添加 UI 组件来使用布局。然后,布局负责如何在屏幕上显示所包含的组件。看一下下面的例子:

@Route("vertical-layout")
public class VerticalLayoutView extends VerticalLayout {

  public VerticalLayoutView() {
    add(
        new Paragraph("Paragraph 1"),
        new Paragraph("Paragraph 2"),
        new Button("Button")
    );
  }

}

这个视图扩展了VerticalLayout并添加了两个段落和一个按钮。如图 3-1 所示,这些组件以垂直方式展示,形成一列。

img/506498_1_En_3_Fig1_HTML.jpg

图 3-1

以垂直布局排列的组件

通过更改视图扩展的布局类型,可以更改组件的排列方式。例如,切换到HorizontalLayout,下面的视图将组件水平排列成一行,如图 3-2 所示:

img/506498_1_En_3_Fig2_HTML.jpg

图 3-2

以水平布局排列的组件

@Route("horizontal-layout")
public class HorizontalLayoutView extends HorizontalLayout {

  public HorizontalLayoutView() {
    add(
        new H1("Horizontal layout"),
        new Paragraph("Paragraph 1"),
        new Paragraph("Paragraph 2"),
        new Button("Button")
    );
  }

}

成分组成

因为布局是 UI 组件,所以可以将布局添加到布局中。假设您想创建一个包含按钮的工具栏。您可以使用一个HorizontalLayout作为工具栏,并将它放在一个VerticalLayout中,在这里您还可以添加其他组件。这里有一个例子:

@Route("composition")
public class CompositionView extends VerticalLayout {

  public CompositionView() {
    var toolbar = new HorizontalLayout(
        new Button("Button 1"),
        new Button("Button 2"),
        new Button("Button 3")
    );
    add(
        toolbar,
        new Paragraph("Paragraph 1"),
        new Paragraph("Paragraph 2"),
        new Paragraph("Paragraph 3")
    );
  }

}

您可以根据需要设置任意多级嵌套。图 3-3 显示了结果。

img/506498_1_En_3_Fig3_HTML.jpg

图 3-3

垂直布局中的水平布局

当您实现一个视图(或者一般的 UI 组件)时,您正在定义一个组件树。树的根是视图本身。在前面的例子中,根是CompositionView(也是一个VerticalLayout)。作为直接孩子,根包含工具栏(HorizontalLayout)和三个段落。该组件树的可视化表示如图 3-4 所示。

img/506498_1_En_3_Fig4_HTML.jpg

图 3-4

组件树的一个例子

当您请求一个视图时,Vaadin 使用这个组件树在浏览器中显示相应的 HTML。

复合类

前面例子中的CompositionView类并没有隐藏它是一个VerticalLayout的事实。该类的客户将知道这一点,并将能够访问VerticalLayout中的方法。实际上,扩展布局很好,因为您很少直接实例化视图——当视图在浏览器中被调用时,Vaadin 扫描用@Route注释的类并根据需要创建实例。然而,如果你想隐藏扩展类,你可以使用Composite类。下面是对上一个例子的重构,它使用了Composite类:

@Route("composition")
public class CompositionView extends Composite<VerticalLayout> {

  public CompositionView() {
    var toolbar = new HorizontalLayout(
        new Button("Button 1"),
        new Button("Button 2"),
        new Button("Button 3")
    );

    var mainLayout = getContent(); // returns a VerticalLayout
    mainLayout.add(
        toolbar,
        new Paragraph("Paragraph 1"),
        new Paragraph("Paragraph 2"),
        new Paragraph("Paragraph 3")
    );
  }

}

注意Composite类如何接受一个指定要封装的组件类型的参数。因为CompositionView不再是VerticalLayout,所以不能直接调用add方法。相反,您应该使用getContent()方法获得一个VerticalLayout的实例,然后使用这个实例构建 UI。

这个类的客户也不能直接访问VerticalLayout中的方法。然而,getContent()方法是公共的,CompositionView类的客户端可以使用它来访问底层的VerticalLayout。要解决这个问题,您可以用Component替换Composite类中的参数(VerticalLayout),覆盖initContent()方法,并将构建 UI 的所有逻辑移到那里:

@Route("composition")
public class CompositionView extends Composite<Component> {

  @Override
  protected Component initContent() {
    var toolbar = new HorizontalLayout(
        new Button("Button 1"),
        new Button("Button 2"),
        new Button("Button 3")
    );

    return new VerticalLayout(

        toolbar,
        new Paragraph("Paragraph 1"),
        new Paragraph("Paragraph 2"),
        new Paragraph("Paragraph 3")
    );
  }

}

getContent()方法仍然是公共的,但是它返回一个类型为Component的对象,而不是VerticalLayout的对象。现在,您可以更改实现,例如,使用不同类型的布局,而不会破坏应用的其他部分。当您开发的可重用组件不是映射到 URL 的视图时(例如,介绍客户或订单数据的表单),这尤其有用。

Tip

Vaadin 中的所有 UI 组件都直接或间接地从Component扩展而来。

实现可重用的 UI 组件

您可以使用所有面向对象的技术,通过继承来实现 UI 组件,方法是在Composite类的帮助下扩展现有组件、封装和数据隐藏。

前一个例子中的工具栏是视图的一部分,您可能希望将其实现为可重用的 UI 组件。您可以将代码移动到单独的类中,而不是在视图本身中实现工具栏:

public class Toolbar extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new HorizontalLayout(
        new Button("Button 1"),
        new Button("Button 2"),
        new Button("Button 3")
    );
  }

}

注意,这个类没有用@Route注释。最有可能的是,当浏览器中请求某个 URL 时,您不想只显示一个工具栏,对吗?当您需要工具栏时,您所要做的就是创建一个新的实例并将其添加到布局中:

var toolbar = new Toolbar();
someLayout.add(toolbar);

Tip

你不必总是延长Composite。有些情况下,扩展VerticalLayoutHorizontalLayoutButton或任何其他类是更好的选择。使用 is-a 关系测试来帮助你决定。例如,Toolbar 不是 HorizontalLayout,因为布局可能甚至在运行时被改变为VerticalLayout。另一方面,假设的SendButton 是一个 Button,因此在这种情况下扩展Button类是有意义的。

访问组件树

Vaadin 的一个优点是它使得在运行时动态构建视图变得容易。例如,CRUD 组件可以使用 Java 反射 API 来检查给定域类的属性(如UserOrderCustomer等)。)并根据 Java 属性的类型创建匹配的 UI 组件(例如,String属性的文本字段和Boolean的复选框)。另一个例子是根据用户拥有的特权显示某些组件所需的逻辑。

当实现这种动态 ui 时,能够修改布局中的组件树是很有用的。让我们来看看VerticalLayoutHorizontalLayout中的一些方法,它们将帮助你做到这一点。

您可以使用getChildren()方法将布局中的组件作为 Java Stream获取:

toolbar.getChildren().forEach(component -> {
  ... do something with component ...
});

类似地,您可以使用getParent()方法获得父组件:

toolbar.getParent().ifPresent(component -> {
  CompositionView view = (CompositionView) component;
  ... do something with view ...
});

您可以分别使用remove(Component...)removeAll()方法移除单个组件或所有包含的组件:

var button = new Button();
toolbar.add(button);
toolbar.remove(button); // removes only button
toolbar.removeAll(); // removes all the contained components

当动态构建视图时,replace(Component, Component)方法会很有用:

var button = new Button();
toolbar.add(button);
toolbar.replace(button, new Button("New!"));

填充、边距和间距

关于布局区域中的空间,您可以控制三个功能:

  • 填充:布局内边框周围的空间

  • 边距:版面外边框周围的空间

  • 间距:布局中组件之间的间距

您可以使用setPadding(boolean)setMargin(boolean)setSpacing(boolean)方法激活或停用这三个功能。有相应的方法返回这些方法中的任何一个是否被激活。以下视图允许您切换这些值,以查看它对 UI 的影响:

@Route("padding-margin-spacing")
public class PaddingMarginSpacingView extends Composite<Component> {

  @Override
  protected Component initContent() {
    var layout = new HorizontalLayout();
    layout.getStyle().set("border", "1px solid");
    layout.setPadding(false);
    layout.setMargin(false);
    layout.setSpacing(false);
    layout.add(
        new Paragraph("Toggle:"),
        new Button("Padding", e ->
            layout.setPadding(!layout.isPadding())),
        new Button("Margin", e ->
            layout.setMargin(!layout.isMargin())),
        new Button("Spacing", e ->
            layout.setSpacing(!layout.isSpacing()))
    );

    return layout;
  }

}

这个视图创建了HorizontalLayout并使用getStyle()方法设置了一个可见的边框,该方法返回一个对象来设置 CSS 属性。

Tip

您可以使用浏览器的开发工具来检查布局,并在选择 HTML 元素时查看边框。我选择用 CSS 在代码中设置边框,这样你就可以在书的截图中看到我在解释什么。

所有功能都被停用,然后三个按钮被添加到布局中。每个按钮切换一个单独的功能。图 3-5 显示了所有功能被禁用时的视图。

img/506498_1_En_3_Fig5_HTML.jpg

图 3-5

禁用填充、边距和间距的HorizontalLayout

将图 3-5 与图 3-6 进行比较,其中所有的填充、边距和间距都被激活。

img/506498_1_En_3_Fig6_HTML.jpg

图 3-6

边距、填充和间距已激活

Tip

对于这些特性,布局有合理的默认值。默认情况下,VerticalLayoutHorizontalLayout都激活了间距并取消了边距。此外,VerticalLayout启用填充,而HorizontalLayout禁用。

胶料

要调整 UI 组件的大小,可以使用setWidth(String)setHeight(String)方法,并以 CSS 绝对或相对长度单位指定一个值(见表 3-1 中最常用单位的快速参考)。例如:

表 3-1

CSS 中常用的长度单位

|

单位

|

描述

|
| --- | --- |
| cm | 厘米 |
| mm | 毫米 |
| in | 英寸 |
| px | 像素 |
| % | 父规模的百分比 |
| em | 父级的字体大小 |
| rem | 根字体的大小 |
| lh | 行高 |

button1.setWidth("100%");
button2.setWidth("80%");
button3.setWidth("300px");

或者,您可以将长度与单位分开:

button4.setWidth(10, Unit.EM);

通过设置一个null值,也可以有一个未定义的宽度或高度。如果您想为宽度和高度设置未定义的尺寸,您可以使用setSizeUndefined()方法。同样,您可以使用setWidthFull()setHeightFull()setSizeFull()方法分别设置 100%的宽度、高度或两者。未定义的大小会使组件缩小到只使用显示其内容所需的空间。图 3-7 显示了不同宽度的按钮示例。

img/506498_1_En_3_Fig7_HTML.jpg

图 3-7

相对、绝对和未定义的宽度

默认情况下,像Button这样的组件具有未定义的大小。对于HorizontalLayout也是如此;然而,VerticalLayout默认配置为 100%宽度。

Tip

您还可以使用setMaxWidth(String)setMinWidth(String)setMaxHeight(String)setMinHeight(String)方法配置组件的最大和最小宽度和高度。

生长

实际上,grow 属性决定了组件的大小。但是,此属性是在布局中配置的。grow 属性设置组件在布局中所占的空间比例。您可以通过调用setFlexGrow(double, HasElement)方法来进行配置。例如:

var layout = new HorizontalLayout(
    button1,
    button2
);
layout.setWidthFull();
layout.setFlexGrow(1, button1);
layout.setFlexGrow(2, button2);

在这段代码中,button1使用 1 个单位的空间,而button2使用 2 个单位的空间。布局本身使用 1 + 2 = 3 个单元。因为它是全宽的,所以布局占用的空间和窗口中的一样多,这被测量为 3。图 3-8 有助于形象化这个概念。

img/506498_1_En_3_Fig8_HTML.jpg

图 3-8

不同生长构型

如果将零(0)设置为增长值,则组件不会调整大小,并将占用容纳其内容所需的空间。

对齐

把一个VerticalLayout想象成一个有许多行的列,把一个HorizontalLayout想象成一个有许多列的行。事实上,我希望 Vaadin 用ColumnLayoutRowLayout来代替,但那是另一个话题了。对齐意味着元件如何放置在VerticalLayout的每一行或HorizontalLayout的每一列。副轴的种类:a VerticalLayout中的 x 轴和 a HorizontalLayout中的 y 轴。

若要对齐组件,请使用布局中的方法,而不是对齐的组件中的方法。例如,如果您认为按钮并不真正“关心”它被放在哪里,这是有意义的——另一方面,布局关心它,以便它可以适当地调整所包含的组件。setAlignSelf(Alignment, Component...)方法在指定组件的副轴上设置对齐。例如,假设你有一堆按钮在一个VerticalLayout(一个ColumnLayout,我只能希望…也许我可以扩展VerticalLayout并使用更好的名字来代替…不,我们不要这样做…至少现在…抱歉,回到正题):

var buttons = new VerticalLayout(
    button1,
    button2,
    button3,
    button4
);

然后,您可以调整VerticalLayout副轴(水平轴)上每个按钮的对齐方式,如下所示:

buttons.setAlignSelf(FlexComponent.Alignment.CENTER, button1);
buttons.setAlignSelf(FlexComponent.Alignment.START, button2);
buttons.setAlignSelf(FlexComponent.Alignment.END, button3);

图 3-9 显示了结果。你能看到那里的一排排吗?

img/506498_1_En_3_Fig9_HTML.jpg

图 3-9

在 a 的副轴(水平)上对齐VerticalLayout

如果您将布局类型更改为HorizontalLayout,为其设置 100%的高度(默认情况下VerticalLayout的宽度为 100%,因此您不需要这样做),并保持相同的对齐方式,您将获得如图 3-10 所示的效果。你能看到那里的柱子吗?

img/506498_1_En_3_Fig10_HTML.jpg

图 3-10

HorizontalLayout的副轴(垂直)上对齐

您可以使用setAlignItems(Alignment)方法为您没有单独指定对齐的组件设置默认对齐。

Tip

有更容易记住的方法来设置副轴中的对准:使用VerticalLayout,您可以使用setHorizontalComponentAlignmentsetDefaultHorizontalComponentAlignment。有了HorizontalLayout,就可以使用setVerticalComponentAlignmentsetDefaultHorizontalComponentAlignment。不过,我更喜欢较短的名字,因为这样可以很容易地改变类型布局,至少不会破坏代码。

调整内容模式

主轴上的对齐(y 轴在VerticalLayout中,x 轴在HorizontalLayout中)称为调整内容模式。在主轴中,在VerticalLayout中只有一列,在HorizontalLayout中只有一行。使用setJustifyContentMode(JustifyContentMode)方法设置该列或行在主轴上的对齐方式(内容对齐模式)。例如:

var layout = new HorizontalLayout(
    new Button(justifyContentMode.name())
);
layout.setWidthFull();
layout.getStyle().set("border", "1px solid");
layout.setJustifyContentMode(JustifyContentMode.START);

这段代码创建了一个HorizontalLayout,向其添加了一个按钮,使其全幅显示,显示了一个边框,并将其 justify-content 模式设置为START。图 3-11 显示了三个这样的布局,带有不同的调整内容模式。

img/506498_1_En_3_Fig11_HTML.jpg

图 3-11

具有不同调整内容模式的三个VerticalLayout

在第二个轴中,您看到在HorizontalLayout中只有一行。添加更多的组件不会增加更多的行,而是增加更多的列。这些列之间(或VerticalLayout中的行之间)可以有空格。您可以通过三种额外的内容对齐模式来控制如何使用行(或列)中的空白区域:BETWEENAROUNDEVENLY。图 3-12 显示了这些模式。

img/506498_1_En_3_Fig12_HTML.jpg

图 3-12

在三个不同的HorizontalLayout中调整主轴中组件之间的间距

BETWEEN如果看图 3-12 的截图就不言自明了。另外两个的区别在于,AROUND单独增加每个组件之间的空间(在HorizontalLayout的情况下是左和右),而不考虑那里是否已经有空间,而EVENLY在组件之间分配空间,使这些空间具有相同的大小。

Caution

在撰写本文时,有一个问题阻止了布局按预期工作。作为一种变通方法,当您使用影响组件间距的调整内容模式时,您必须在布局调用setSpacing(false)中取消间距。访问 https://github.com/vaadin/vaadin-ordered-layout/issues/66 了解更多关于这个问题的信息。

卷动

假设您有一个包含 100 个按钮的布局,并且您希望该布局的高度为 200 像素(并显示边框,以便我们可以看到我们在做什么):

VerticalLayout layout = new VerticalLayout();
for (int i = 1; i <= 100; i++) {
  layout.add(new Button("Button " + i));
}
layout.setHeight("200px");
layout.getStyle().set("border", "1px solid");

当您运行这段代码时,您并没有得到预期的结果。见图 3-13 。

img/506498_1_En_3_Fig13_HTML.jpg

图 3-13

组件泄漏到布局之外

此外,滚动发生在页面级别,而不是布局级别。组件允许你控制滚动的方式和位置。您可以控制方向(水平或垂直),并根据需要禁用滚动。Scroller组件通过构造函数或setContent(Component方法获取要滚动的组件。例如:

VerticalLayout layout = new VerticalLayout();
for (int i = 1; i <= 100; i++) {
  layout.add(new Button("Button " + i));
}
layout.setHeight("200px");

Scroller scroller = new Scroller(layout);
scroller.setHeight("200px");
scroller.getStyle().set("border", "1px solid");

现在滚动条只在 200 像素高的布局中显示。见图 3-14 。

img/506498_1_En_3_Fig14_HTML.jpg

图 3-14

Scroller组件

灵活布局

VerticalLayoutHorizontalLayout不足以满足您的需求时,您可以通过使用FlexLayout类来访问 CSS flexbox 布局的所有特性。

方向

FlexLayout类的主要特点是它允许你设置包含的组件在主轴上显示的方向。使用setFlexDirection(FlexDirection)方法并指定以下选项之一:

  • ROW:组件从左到右排成一行。

  • ROW_REVERSE:组件从右到左排成一行。

  • COLUMN:组件从上到下排成一列。

  • COLUMN_REVERSE:组件自下而上排成一列。

例如,以下代码片段使用 ROW_REVERSE 显示按钮,如图 3-15 所示:

img/506498_1_En_3_Fig15_HTML.jpg

图 3-15

一个方向为ROW_REVERSEFlexLayout

var layout = new FlexLayout(
    new Button("1"),
    new Button("2"),
    new Button("3")
);
layout.setFlexDirection(FlexLayout.FlexDirection.ROW_REVERSE);

包装

默认情况下,组件排成一行。您可以使用 wrap 属性来更改此行为:

layout.setFlexWrap(FlexLayout.FlexWrap.WRAP);

图 3-16 显示了ROW方向和环绕激活的布局。

img/506498_1_En_3_Fig16_HTML.jpg

图 3-16

FlexLayout中激活包裹

对齐

当环绕激活时,您可以使用setAlignContent(ContentAlignment)方法对齐行或列(取决于方向设置为ROW还是COLUMN)。例如:

layout.setAlignContent(
    FlexLayout.ContentAlignment.SPACE_BETWEEN);
layout.setSizeFull();

结果如图 3-17 所示。

img/506498_1_En_3_Fig17_HTML.jpg

图 3-17

一个FlexLayoutSPACE_BETWEEN内容对齐

收缩

shrink 属性类似于 grow 属性,只是它控制组件在容器中如何收缩。您可以按如下方式配置该属性:

button1.setWidth("200px");
button2.setWidth("200px");

layout.setWidth("300px");
layout.setFlexShrink(1d, button1);
layout.setFlexShrink(2d, button2);

注意按钮的组合宽度是 400 像素,而布局只有 300 像素。收缩值的总和为 1 + 2 = 3(单位),因此整个空间为 3。button1使用 1 个单位,而button2使用 2 个单位。图 3-18 显示了结果。

img/506498_1_En_3_Fig18_HTML.jpg

图 3-18

具有不同收缩值的两个按钮

Tip

收缩定义了当没有足够的空间容纳所包含的组件时如何使用空间。Grow 定义了当容器大于所包含组件的大小时如何使用空间。

其他布局

Vaadin 中有更多可用的布局。当我们在本书后面讨论响应式设计时,我们会看到它们的实际应用。作为一个快速的介绍,让我们简单地提一下他们:SplitLayoutFormLayoutAppLayout

SplitLayout允许您将组件添加到两个可调整大小的区域,用户可以在浏览器中通过滑动拆分条来调整这些区域。您可以设定方向以显示垂直或水平分割。

是一个响应式布局,用于显示带有输入组件的表单,如文本字段、组合框和按钮。布局对其大小的变化做出反应,根据配置显示更多或更少的列。

AppLayout是一种快速获得屏幕上典型应用区域分布的方法。它在顶部定义了一个导航栏,在左侧定义了一个抽屉(例如,适用于菜单)和一个内容区域。

摘要

这一章是你 Vaadin 旅程中的重要一步。在开发 ui 时,理解如何定位和调整组件是关键。首先,您学习了如何使用Composite类创建 UI 组合。然后,您了解了VerticalLayoutHorizontalLayout,以及它们如何通过让您在主轴和副轴上对齐组件来覆盖广泛的用例。您了解了如何启用和禁用边距、填充和间距,以及如何使用 grow 属性设置组件的大小和布局中的可用空间。最后,您了解了强大的FlexLayout组件及其附加特性,比如配置方向和环绕模式的可能性。

现在你知道了如何在布局中放置组件和确定组件的大小,下一章将会非常有趣!您将了解 Vaadin 中所有的输入、交互和可视化组件。

四、UI 组件

“组件”这个词在软件开发中可能有点超载,以至于感觉任何东西都可以是组件:一个类、一个模块、一个 JAR、一个 HTML 元素、用户界面中的一个输入字段……这个列表还在继续。然而,采用这个词的含义并没有错,在本书中,我使用术语“UI 组件”来指代 UI 的一部分,它可以封装在 Java 类中,其实例可以添加到 Vaadin 布局中。当上下文清楚时,我也使用术语“组件”来指代 UI 组件。

本章是关于数据输入(字段)、动作调用或交互(按钮和菜单)和可视化(图标、图像等)的 UI 组件。Vaadin 包括一组 UI 组件,涵盖了您在开发业务应用时会遇到的大多数需求,所以请准备好查看最常用的 UI 组件。

Note

有一个 UI 组件您在这里看不到:组件Grid。别担心。第六章深入探讨了这个强大的组件。

输入组件

输入组件允许用户以多种格式输入数据(例如,字符串、数字、日期,甚至自定义类型)。这些组件中的大多数都包括setValue(...)getValue()方法。使用这些方法,您可以分别修改和返回组件中的值。它们还包括一个设置标题的setLabel(String)方法、一个设置组件内部提示文本的setPlaceholder(String)方法、一个设置大小的setWidth(String)setHeight(String)方法,以及几个使用特定于每个组件的特性的附加方法。

Tip

本章没有足够的篇幅来介绍 Vaadin 中每个组件的所有特性。有关最新列表、示例和 API 文档,请访问 https://vaadin.com/components

文本输入

最常用的 UI 组件之一是TextField。下面是一个基本的用法示例:

TextField textField = new TextField();
textField.setLabel("Name");
textField.setPlaceholder("enter your full name");

图 4-1 显示了浏览器中的文本字段。

img/506498_1_En_4_Fig1_HTML.jpg

图 4-1

浏览器中呈现的一个TextField

大多数输入字段(或简称为字段)都包含控制字段中数据的功能。看看这段代码:

textField.setAutoselect(true);
textField.setAutofocus(true);
textField.setClearButtonVisible(true);
textField.setValue("John Doe");

这段代码将文本字段配置为在获得焦点时自动选择组件内的所有文本(光标准备好允许输入)。它将焦点设置在字段上,以便用户可以立即开始输入,显示一个按钮来清除包含的值(字段内的 X 图标),并在代码示例中用一个流行的名称预设文本值(至少在我的书和讲座中,尤其是讲座中)。图 4-2 显示了结果。

img/506498_1_En_4_Fig2_HTML.jpg

图 4-2

带有自动选择、自动对焦、清除按钮和默认值的TextField

这里,有另一套有用的方法:

textField.setRequired(true);
textField.setMinLength(2);
textField.setMaxLength(10);
textField.setPattern("^[a-zA-Z\\s]+");
textField.setErrorMessage("Letters only. Min 2 chars");

这段代码激活一个可视指示器,让用户知道该字段需要一个值,并且不能留空。它还设置字段中允许的最小和最大长度,以及该值必须匹配的模式。如果模式与正则表达式不匹配,组件会显示一条错误消息。

Note

Java API for regular expressions 是在 Java 1.4 中引入的,已经成为搜索和操作文本和数据的强大工具。有很多关于这个主题的好资源。Oracle 的 Java 教程有一堂关于正则表达式的精彩课程。您可以在 https://docs.oracle.com/javase/tutorial/essential/regex 访问该课程。 Java 正则表达式 (Apress,2004)是一本讲述 Java 正则表达式引擎的经典之作。JRebel 的备忘单(可从 www.jrebel.com/resources/java-regular-expressions-cheat-sheet 获得)是我在使用正则表达式时参考的另一个资源。

Vaadin 组件使用事件侦听器与服务器进行交互。例如,假设我们想要检查前面示例的文本字段中的值,并在值无效时显示通知。实现这一点的一种方法是在字段中添加一个值改变监听器:

textField.addValueChangeListener(event -> {
    if (textField.isInvalid()) {
        Notification.show("Invalid name");
    }
});

当您按 ENTER 键或通过单击字段外部或按 TAB 键来移除焦点时,将执行 lambda 表达式。例如,当输入中有数字或少于两个字符时,lambda 表达式本身检查字段中的值是否无效,如果是,则显示一个通知。见图 4-3 。

img/506498_1_En_4_Fig3_HTML.jpg

图 4-3

TextField中输入验证

如果您希望在每次值发生变化时都进行处理,而不是等待回车键或焦点丢失发生,您可以按如下方式进行配置:

textField.setValueChangeMode(ValueChangeMode.EAGER);

查看ValueChangeMode中每个常量的 Javadoc,了解所有可用的配置选项。

Tip

虽然大多数 ide 都能够显示 Javadoc 文档和外部库的源代码,但是你也可以在网上的 https://vaadin.com/api 找到。有关如何显示 Javadoc 或导航外部库的源代码的详细信息,请参考 IDE 文档。

密码输入

PasswordField类包含了TextField的大部分功能,但是提供了一种方便的方式来启用密码输入。这里有一个例子:

PasswordField passwordField = new PasswordField();
passwordField.setLabel("Password");
passwordField.setRevealButtonVisible(true);

在浏览器中,输入字符串显示为一组点。参见图 4-4 。

img/506498_1_En_4_Fig4_HTML.jpg

图 4-4

PasswordField

如果启用了“显示”按钮(如前面的示例),用户可以按“眼睛”按钮来查看输入的密码。参见图 4-5 。

img/506498_1_En_4_Fig5_HTML.jpg

图 4-5

一个PasswordField随着它的内容显露出来

当您通过调用getValue()方法获得值时,您获得了字段中引入的实际文本(显然不是一堆点):

passwordField.addValueChangeListener(event -> {
    String password = passwordField.getValue();
    System.out.println("Password: " + password);
});

您还可以从event参数中获取字段值,这样就可以将代码从包含该值的实际字段中分离出来:

passwordField.addValueChangeListener(event -> {
    String password = event.getValue(); // like this
    System.out.println("Password: " + password);
});

这种技术适用于本章描述的所有输入字段。

布尔输入

Checkbox类封装了一个布尔值。这里有一个基本的例子:

Checkbox checkbox = new Checkbox();
checkbox.setLabelAsHtml("I'm <b>learning</b> Vaadin!");

checkbox.addValueChangeListener(event -> {
    Boolean value = event.getValue();
    Notification.show("Value: " + value);
});

这一次,代码使用 HTML 字符串设置标签。如果不需要显示 HTML,仍然可以使用常规的setLabel(String)方法。

Caution

建议尽可能使用纯文本,以避免跨站点脚本攻击(也称为 XSS 攻击)。确保在将包含 HTML 的字符串传递给 UI 之前对其进行净化,尤其是当它们来自用户输入或外部服务时。开放 Web 应用安全项目(OWASP)为此提供了一个 Java 库。可以在 https://owasp.org/www-project-java-html-sanitizer 了解更多。

图 4-6 显示了点击组件后上一个例子的截图。

img/506498_1_En_4_Fig6_HTML.jpg

图 4-6

浏览器中呈现的一个Checkbox

Checkbox类可以采取一个初始的未确定状态来直观地提示用户他们还没有点击复选框。这不会影响复选框中存储的值。这符合复选框的 HTML5 标准。例如,以下代码将该值设置为 true,激活未确定状态,并在通知中显示该值:

checkbox.setValue(true);
checkbox.setIndeterminate(true);
Boolean initialValue = checkbox.getValue();
Notification.show("Initial value: " + initialValue);

注意图 4-7 中显示的值是true(不是null,不是false)。

img/506498_1_En_4_Fig7_HTML.jpg

图 4-7

一个状态不确定的Checkbox

因为初始值是true,一旦你点击复选框,它的值就会变成false

日期和时间输入

DatePicker类允许你显示一个日期输入的输入框。让我们使用类中的几个构造函数之一,而不是使用缺省的构造函数(当你需要它的时候它是可用的)。研究下面的例子:

@Route("datepicker")
public class DatePickerView extends Composite<Component> {

    @Override
    protected Component initContent() {
        DatePicker datePicker = new DatePicker(
                "Enter a memorable date",
                LocalDate.now(),
                event -> showMessage(event.getValue())
        );

        return new VerticalLayout(datePicker);
    }

    private void showMessage(LocalDate date) {
        Notification.show(
                date + " is great!"
        );
    }
}

构造函数接受一个字段标签(相当于使用setLabel(String)方法),后跟一个最初显示的日期值(相当于使用setValue(LocalDate)方法),以及一个值更改监听器(相当于使用addValueChangeListener(ValueChangeListener)方法)。这一次,侦听器被实现为一个 lambda 表达式,该表达式调用一个方法并从event对象获取值(用户选择的日期)。图 4-8 显示了这个例子的截图。

img/506498_1_En_4_Fig8_HTML.jpg

图 4-8

浏览器中呈现的一个DatePicker

以下是一些可用于进一步配置日期选取器的方法:

// shows the calendar only when clicking on the calendar icon
// not when clicking the field
datePicker.setAutoOpen(false);

// shows an X button to clear the value
datePicker.setClearButtonVisible(true);

// sets the date that's visible when the calendar is opened
// * works only when no date value is set
datePicker.setInitialPosition(LocalDate.now().minusMonths(1));

// sets the minimum and maximum dates

datePicker.setMin(LocalDate.now().minusMonths(3));
datePicker.setMax(LocalDate.now().plusMonths(3));

web 应用中的一个常见需求是配置日期格式,不仅在输入组件中使用,而且在显示日期的任何地方都使用。实现这一点的最佳方式是设置组件的区域设置。例如,如果应用将由加拿大官员或政府机构使用,您可以按如下方式设置区域设置:

datePicker.setLocale(Locale.CANADA);

如果未设置语言环境,Vaadin 会在创建新会话时配置浏览器报告的语言环境。

Caution

在写这本书的时候,没有办法直接设置一个DatePicker的日期格式。更多信息见 https://github.com/vaadin/vaadin-date-picker-flow/issues/156 期。

如果需要用户输入时间,可以使用TimePicker类:

TimePicker timePicker = new TimePicker("Pick a time");
timePicker.addValueChangeListener(event -> {
    LocalTime value = event.getValue();
    Notification.show("Time: " + value);
});

存储在字段中的值属于类型LocalTime。当您需要获取日期和时间时,您可以使用DateTimePicker类:

DateTimePicker dateTimePicker = new DateTimePicker("When?");

图 4-9 显示了时间组件。DateTimePicker显示的是DatePicker旁边的TimePicker

img/506498_1_En_4_Fig9_HTML.jpg

图 4-9

浏览器中呈现的一个TimePicker和一个DateTimePicker

数字输入

NumberField 类允许您从用户那里获取一个Double值。这里有一个例子:

NumberField numberField = new NumberField("Rating");
numberField.setHasControls(true);
numberField.setMin(0.0);
numberField.setMax(5.0);
numberField.setStep(0.5);
numberField.setClearButtonVisible(true);
numberField.setHelperText("From 0.0 to 5.0");

numberField.addValueChangeListener(event -> {
    Double value = event.getValue();
    Notification.show("Your rating: " + value);
});

大多数方法都是不言自明的。您可以启用控件的可视化,以允许用户使用字段中的+和–按钮调整数值,方法是根据步长值增加或减少数值。方法允许你显示如何使用这个字段的提示。见图 4-10 。

img/506498_1_En_4_Fig10_HTML.jpg

图 4-10

浏览器中呈现的一个NumberField

如果你需要用Integer而不是Double输入,那就用IntegerField类来代替。它有一个类似的 API。

单项选择输入

有几个组件以在定义的值之间进行选择的形式获得输入。例如,您可以使用ComboBox类询问用户他们在公司的哪个部门工作:

ComboBox<String> comboBox = new ComboBox<>("Department");
comboBox.setItems("R&D", "Marketing", "Sales", "HR");

结果如图 4-11 所示。

img/506498_1_En_4_Fig11_HTML.jpg

图 4-11

浏览器中的一个ComboBox

可用值通过使用String作为参数类型的setItems(T...)方法来设置。您可以使用任何类型作为参数。例如,您可能有一个封装部门的类或枚举,而不是将它们作为字符串:

public class Department {
    private Integer id;
    private String name;

    public Department(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

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

在这种情况下,您可以如下使用ComboBox类:

ComboBox<Department> comboBox = new ComboBox<>("Department");
comboBox.setItems(
        new Department(1, "R&D"),
        new Department(2, "Marketing"),
        new Department(3, "Sales")
);

有时,您必须配置每个选项在浏览器中显示的字符串。默认情况下,ComboBox类使用被指定为参数的类的toString()方法(本例中为Department)。您可以按如下方式覆盖它:

comboBox.setItemLabelGenerator(Department::toString);

此外,您可以包含任何自定义逻辑来生成每个字符串值:

comboBox.setItemLabelGenerator(department -> {
    String text = department.getName() + " department";
    return text;
});

结果如图 4-12 所示。

img/506498_1_En_4_Fig12_HTML.jpg

图 4-12

自定义项目标签生成器

在前面的例子中,我通过创建Department类的新实例来设置项目。这只是为了向您展示在输入字段中使用 JavaBean 作为条目的想法。在实际的应用中,您最有可能得到一个对象的Collection,您可以将它传递给setItems方法的一个版本。例如,如果您有一个服务类,代码可能如下所示:

List<Department> list = service.getDepartments();
ComboBox<Department> comboBox = new ComboBox<>("Department");
comboBox.setItems(list);

这种可能性是使 Vaadin 如此强大的特性之一——您可以用普通 Java 直接从表示层访问您的后端。服务类可以使用 Hibernate/JPA(Jakarta Persistence API)、Spring、CDI(Contexts and Dependency Injection)等技术,以及在庞大且久经考验的 Java 生态系统中可用的许多其他技术。

Vaadin 中还有两个单选输入字段— RadioButtonGroupListBox。这里有一个RadioButtonGroup的例子:

RadioButtonGroup<Department> radio = new RadioButtonGroup<>();
radio.setItems(list);

ListBox组件以类似的方式工作:

ListBox<Department> listBox = new ListBox<>();
listBox.setItems(list);

这两个类的 API 类似于ComboBox中的 API,这使得它们几乎完全可以互换。图 4-13 显示了浏览器中的这些组件。

img/506498_1_En_4_Fig13_HTML.jpg

图 4-13

Vaadin 中的单选输入字段

多重选择输入

要允许用户从列表中选择一个或多个项目,可以使用CheckboxGroupMultiSelectListBox类。这里有一个例子:

CheckboxGroup<Department> checkboxes = new CheckboxGroup<>();
checkboxes.setItems(service.getDepartments());

MultiSelectListBox<Department> listBox = new MultiSelectListBox<>();
listBox.setItems(service.getDepartments());

因为这些组件允许用户选择几个值,getValue()方法返回一个包含所选值的Set:

Set<Department> departments = listBox.getValue();

图 4-14 显示了浏览器中的这些组件。

img/506498_1_En_4_Fig14_HTML.jpg

图 4-14

Vaadin 中的多选输入字段

文件上传

Upload类允许你显示一个输入域,将文件从用户的机器传输到服务器。它支持单个或多个文件,并从桌面拖放。下面是基本用法:

MemoryBuffer receiver = new MemoryBuffer();
Upload upload = new Upload(receiver);
upload.addSucceededListener(event -> {
    InputStream in = receiver.getInputStream();
    ... read the data from in ...
});

MemoryBuffer类是Receiver接口的一个实现。该接口用于向Upload组件提供一种写入上传数据的方式。有几种实现方式:

  • MemoryBuffer:将数据存储在存储器中

  • FileBuffer:使用File.createTempFile(String, String)方法创建一个File

  • MultiFileMemoryBuffer:多个文件上传,将数据存储在内存中

  • MultiFileBuffer:使用File.createTempFile(String, String)方法创建多个File

假设我们想做一个程序来计算字母 A 在一个纯文本文件中出现的次数。这需要限制接受文件的类型并处理其内容。让我们从处理部分开始:

upload.addSucceededListener(event -> {
    InputStream in = receiver.getInputStream();
    long count = new Scanner(in).findAll("[Aa]").count();
    Notification.show("A x " + count + " times");
});

当用户上传一个文件时,监听器从Receiver获取InputStream,并使用 Java Scanner查找字母 a 的所有出现次数,然后在通知中显示结果。参见图 4-15 。

img/506498_1_En_4_Fig15_HTML.jpg

图 4-15

浏览器中呈现的一个Upload组件

当用户点击上传文件时...点击按钮,会出现一个标准的文件选择器,用户可以在他们的硬盘中选择一个文件。默认情况下允许所有文件。要对此加以限制,您可以设置想要接受的 MIME 类型。例如,纯文本文件的 MIME 类型是text/plain:

upload.setAcceptedFileTypes("text/plain");

图 4-16 显示了如何在 macOS 系统上禁用(灰显)图像文件(JPG)供选择。

img/506498_1_En_4_Fig16_HTML.jpg

图 4-16

macOS 上仅接受文本文件的文件选择器示例

交互组件

交互组件允许用户触发应用中的动作。例如,在表单中保存数据或导航到另一个 URL。

小跟班

在前一章我们已经看到了按钮,但是我们并没有真正研究它们。到目前为止,您很可能已经猜到了按钮的基本用法(如果我已经很好地完成了我的工作,至少,我真心希望我做到了)。无论如何,这就是:

Button button = new Button("Time in the server, please");
button.addClickListener(event ->
    Notification.show("Sure: " + LocalTime.now())
);

代码很容易理解。当单击按钮时,lambda 表达式中的代码在服务器中执行。您可以使用构造函数添加点击侦听器:

Button button = new Button("Time in the server", event ->
    Notification.show("Sure: " + LocalTime.now())
);

Tip

我更喜欢通过使用相应的方法而不是构造函数来显式地添加点击侦听器和任何其他类型的事件侦听器。在书籍、教程和演示中,我使用更紧凑的版本,因为我在那里(虚拟地或亲自地)解释代码。然而,在现实生活中的项目中,代码的大小和复杂性可能会增加,我喜欢代码为我说话的清晰性。不仅帮助其他开发者,也帮助我自己。我记得有一次,我试图找到某个按钮被点击时执行的代码。我用我的 IDE 在一堆源文件中搜索“clickListener”。幸运的是,我只有一根火柴。不幸的是,这是错误的按钮。有问题的按钮有一个来自数据库的标题,所以我无法通过它进行搜索。这个按钮可能被命名为“按钮”、“b”或类似的名称。再做一点工作,我找到了实例,并注意到 click listener 是使用构造函数添加的。

假设您希望应用只显示一次时间(为了简单起见,每次页面刷新)。您可以使用setEnabled(boolean)方法隐藏点击监听器中的按钮,如下所示:

Button button = new Button("Time in the server", event -> {
    Notification.show("Sure: " + LocalTime.now());
    event.getSource().setVisible(false);
});

我们不能使用button实例,因为它还没有被定义。相反,您可以使用getSource()方法来获取触发事件的组件。

或者,您可以调用setEnabled(false)来禁用按钮,而不是完全隐藏它,以避免改变视图的整体外观。如果是这种情况,在点击后禁用按钮的更好方法是使用setDisableOnClick(boolean)方法:

button.setDisableOnClick(true);

图 4-17 显示了浏览器中的示例。

img/506498_1_En_4_Fig17_HTML.jpg

图 4-17

浏览器中呈现的禁用的Button

事件是如何发送到服务器的?

当用户单击添加了侦听器的组件时,单击侦听器允许您在服务器中运行代码。事实上,当浏览器中发生某个动作时,所有侦听器都会调用服务器中的代码,例如,单击、获得焦点、按键,甚至当组件附加到视图时。您将在 Vaadin 的许多 UI 组件中找到这些事件的方法。

我们知道所有与 web 应用的交互都是通过 HTTP 请求发生的(或者我们将在第八章看到的 WebSocket)。如果我们使用网络浏览器的开发工具,我们可以看到当你点击一个按钮时发出的请求。图 4-18 显示了当你点击前一个例子中的按钮时,Vaadin 发送给服务器的请求。

img/506498_1_En_4_Fig18_HTML.jpg

图 4-18

点击事件后的 HTTP 请求

请求 URL 包括两个参数。您可以按如下方式读取这些参数及其值:

  • V-R = uidl:AVaadinRequest of typeUserIinterfaceD定义 L 语言这表明该请求旨在处理 UI 状态的变化。

  • V-uiId = 11:VaadinUserI接口 Id 标识符。这指示已更改的用户界面的数字标识符;在这种情况下,UI 的标识符被指定为 11。如果您在不同的浏览器选项卡中打开视图,您应该得到不同的值。

这些参数指向VaadinServlet类,该类又委托给更专门的类。然后,Vaadin 获取请求的有效负载,并相应地对其进行处理。有效载荷以 JSON 格式发送,如图 4-19 所示。

img/506498_1_En_4_Fig19_HTML.jpg

图 4-19

uidl 请求的负载

有效负载包括 UI 中关于更改的所有细节。在这种情况下,单击事件。它还包括事件发生的节点(或组件)(在本例中,按钮被分配了 ID 5)。有了这些信息,Vaadin 就可以在组件树中导航,找到 ID 为 5 的组件,并调用任何可能已经添加到其中的 click 侦听器。

如果您检查响应(在图 4-19 所示的响应选项卡中),您将会看到如下内容(出于清晰和间隔的原因,大部分内容已被省略):

for (;;);
 [{
      "syncId": 2,
      "clientId": 2,
      ...
      "changes": [{
            "node": 5,
            "type": "put",
            "key": "disabled",
            "feat": 3,
            "value": ""
      },
      ...
      {
            "node": 8,
            "type": "put",
            "key": "innerHTML",
            "feat": 1,
            "value": "Sure: 17:19:17.323399"
      }, {
            "node": 9,
            "type": "attach"
      }, {
            "node": 9,
            "type": "put",
            "key": "tag",
            "feat": 0,
            "value": "vaadin-notification"
      },
      ...
      ],
      "timings": [668, 2]
}]

响应也是 JSON 格式的,包括请求处理后应该在视图中进行的更改的信息。例如,响应声明应该禁用节点 ID 5(按钮),并且应该附加文本为“Sure: 17:19:17.323399”的通知(如图所示)。这些正是我们在前一节的例子中在点击监听器中编程的东西。

链接

链接(或锚点)允许用户请求不同的 URL。它可以是应用中的 URL,也可以是外部网站。这里有一个不要脸塞的例子:

Anchor blogLink = new Anchor("https://www.programmingbrain.com",
        "Visit my technical blog");

您可以使用 UI 组件来代替字符串:

Anchor vaadinLink = new Anchor("https://vaadin.com",
        new Button("Visit vaadin.com"));

最后,您可以在运行时生成内容。例如,以下代码创建一个链接,该链接指向运行时在服务器中生成的文本文件:

Anchor textLink = new Anchor(new StreamResource(
        "text.txt",
        () -> {
            String content = "Time: " + LocalTime.now();
            return new StringInputStream(
                    content, Charset.defaultCharset());
        }
), "Server-generated text");

当用户点击最后一个锚点时,会生成一个名为 text.txt 的新文件,并将其返回给浏览器。Chrome 将这个文件下载到客户机的硬盘上。图 4-20 显示了之前示例中的锚。

img/506498_1_En_4_Fig20_HTML.jpg

图 4-20

浏览器中的Anchor组件

菜单

虽然在移动优先的应用中并不流行,但菜单在商业应用中有很大的用途,这些应用通常几乎只在桌面浏览器上使用。即使在移动优先的应用中,你也可以找到显示“...”的典型按钮这告诉用户有更多的选择。Vaadin 包括在 web 应用中显示顶级、多级和上下文菜单的组件。

从顶层开始,使用以下结构构建菜单:

MenuBar > MenuItem > SubMenu > MenuItem > SubMenu > MenuItem > ...

一旦你研究了一个例子,这就变得更有意义了:

MenuBar menuBar = new MenuBar();

MenuItem file = menuBar.addItem("File");
file.getSubMenu().addItem("New");
file.getSubMenu().addItem("Open");

MenuItem edit = menuBar.addItem("Edit");
edit.getSubMenu().addItem("Copy");
edit.getSubMenu().addItem("Paste");

结果如图 4-21 所示。

img/506498_1_En_4_Fig21_HTML.jpg

图 4-21

两级菜单

每个包含文本的选项都是一个MenuItem。如果你想创建更多的关卡,你必须从一个MenuItem中获取SubMenu并向其添加更多的MenuItem对象。您可以根据需要设置多个级别。

使用MenuItem的构造函数或addClickListener(ComponentEventListener)方法添加一个点击监听器,当用户点击一个项目时执行代码:

edit.getSubMenu().addItem("Copy", event ->
        Notification.show("Copy selected"));

MenuItem paste = edit.getSubMenu().addItem("Paste");
paste.addClickListener(event ->
        Notification.show("Paste selected"));

您还可以使用setEnable(boolean)方法启用或禁用项目,或者使用setCheckable(boolean)方法使它们可切换。

要添加上下文菜单(当用户右键单击一个组件时显示的菜单),可以使用ContextMenu类并指定一个目标,通常是一个布局组件,用户可以右键单击它来查看菜单:

HorizontalLayout target = new HorizontalLayout(
        new Text("Right click here")
);
ContextMenu contextMenu = new ContextMenu(target);
contextMenu.addItem("Copy");
contextMenu.addItem("Paste");

您可以将 click listeners 添加到项目中,并根据需要在上下文菜单中设置多个级别。图 4-22 显示了浏览器中的上下文菜单。

img/506498_1_En_4_Fig22_HTML.jpg

图 4-22

浏览器中的一个ContextMenu

可视化组件

本章的最后一节介绍了交互式组件,最后一节介绍了一些交互性不强的组件,它们有助于丰富向用户呈现数据的方式。让我们来看看它们。

通知和对话框

在这一章中,我们一直在使用Notification类,但是它比我们目前所看到的要多。为了了解它的更多特性,让我们定义一些需求。假设我们必须显示一个通知

  • 单击视图中的按钮时会显示

  • 如果是第一次显示,则保持可见

  • 可以通过点击里面的按钮来关闭

  • 如果之前显示过,2 秒钟后消失

  • 显示在页面的中央

事实是,我认为你有知识和经验来实现这一点。你敢自己去实施吗?试试看!

当你决定尝试这个练习时,让我给你讲个小故事。我在芬兰图尔库的一个下雨的周六晚上写下了这篇文章。我的全职工作没有让我在工作日有太多时间来写这本书,所以我决定在周六全力以赴完成这一章。公式很简单——我从一个代码示例开始,然后将我对它的抽象想法转化为文字。棘手的部分是实际开始写代码和段落,但一旦我开始,想法和文字就流动起来,我就把事情做完了。然后就是练习,到最后,练习总会有回报。现在去打开你的 IDE,用 Vaadin 做一些练习。

我接受的不做这个练习的唯一借口是,你正在飞机上读这篇文章,没有笔记本电脑或互联网连接。因此,清单 4-1 给出了一个可能的解决方案。我希望你的比我的更好。

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.Composite;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;

@Route("notification")
public class NotificationView extends Composite<Component> {

  private boolean firstTime = true;

  @Override
  protected Component initContent() {
    return new VerticalLayout(
        new Button("Notification", event -> {
          Notification notification = new Notification();
          notification.add(new VerticalLayout(
              new Text("Here it is!")));
          notification.setPosition(Notification.Position.MIDDLE);

          if (firstTime) {
            notification.setDuration(0);
            notification.add(new Button("Close", e ->
                notification.close()));
          } else {
            notification.setDuration(2000);
          }

          firstTime = false;
          notification.open();
        })
    );
  }

}

Listing 4-1Some of the features of the Notification class

弹出对话框类似于通知,但是它们有更多的功能。这里有一个基本的例子:

new Dialog(
    new VerticalLayout(
        new H2("Title"),
        new Text("Text!"),
        new Button("Button!!!")
    )
).open();

默认情况下,用户可以在对话框打开时与应用的其他部分进行交互。您可以通过使其模式化来改变这一点:

dialog.setModal(true);

如果您想避免当用户在对话框外单击时对话框消失,您可以使用以下方法:

dialog.setCloseOnOutsideClick(false);

在这种情况下,您可能应该提供一种关闭对话框的方法。一种方法是使用 ESC 键关闭它:

dialog.setCloseOnEsc(true);

最后,您可以使对话框可拖动,如下所示:

dialog.setDraggable(true);

图 4-23 显示了浏览器中的一个对话框。

img/506498_1_En_4_Fig23_HTML.jpg

图 4-23

浏览器中呈现的一个Dialog

制表符

选项卡是允许用户在与共享主题相关的视图之间移动的好方法。在 Vaadin 中,选项卡只是一种以选项卡样式显示一组“按钮”的方式,由程序员决定对这些按钮上的单击做出反应,最有可能的是,当每个选项卡按钮被单击(或选择)时,在视图中显示不同的布局。这里有一个基本的例子:

Tab order = new Tab("Order");
Tab delivery = new Tab("Delivery");
Tabs tabs = new Tabs(order, delivery);

结果如图 4-24 所示。

img/506498_1_En_4_Fig24_HTML.jpg

图 4-24

在浏览器中呈现的Tabs组件

标签的内容在哪里?就像我之前提到的,Tabs组件不包含这个功能。不幸的是。

Tip

在 Vaadin 8 和更早的版本中,选项卡组件的 API 包括一个容器和所有逻辑,当选择一个选项卡时,这些逻辑可以在“页面”之间自动切换。如果您来自这些版本的 Vaadin,并且错过了这种行为,您可能想看看由本书作者开发和维护的位于 https://vaadin.com/directory/component/paged-tabs 的 Vaadin 目录中的开源免费分页标签组件。哈!两个无耻的插头在一个章节里。

要使标签有用,您必须实现逻辑,使它们按照您的意愿工作。最常见的是,这意味着当选项卡被选中时显示不同的组件。这可以使用侦听器来实现。这里有一个例子:

VerticalLayout tabsContainer = new VerticalLayout();

Tab order = new Tab("Order");
Tab delivery = new Tab("Delivery");
Tabs tabs = new Tabs(order, delivery);

tabs.addSelectedChangeListener(event -> {
  Tab selected = event.getSelectedTab();
  tabsContainer.removeAll();

  if (order.equals(selected)) {
    tabsContainer.add(buildOrderTab());
  } else if (delivery.equals(selected)) {
    tabsContainer.add(buildDeliveryTab());
  }
});

buildOrderTab()buildDeliveryTab()方法可以返回包含您想要显示的组件的任何布局。该代码使用 if else if 结构,您可以根据需要向其中添加更多事例。或者,您可以使用一个Map来匹配标签和布局,以显示或实现您想要在布局之间切换的任何算法。你可以在 https://vaadin.com/components/vaadin-tabs/java-examples 找到例子。

Caution

前面的例子有一个错误。当您请求查看时,订单选项卡不显示其内容。选择更改侦听器尚未启动。你能解决这个问题吗?提示:将逻辑提取到一个单独的方法中。

核标准情报中心

Vaadin 带有一套 600 多个现成的图标。您可以创建图标并将其添加到任何布局中:

Icon icon = VaadinIcon.YOUTUBE.create();
layout.add(icon);

像大多数 UI 组件(包括我们在本章中看到的所有组件)一样,您可以设置它的大小:

icon.setSize("4em");

许多 UI 组件都包含了setIcon(Icon)方法。例如,下面是如何将图标添加到按钮:

Button button = new Button("Edit");
button.setIcon(VaadinIcon.EDIT.create());

您可以将这两行合并成一行:

Button button = new Button("Edit", VaadinIcon.EDIT.create());

图 4-25 显示了这些组件的屏幕截图。

img/506498_1_En_4_Fig25_HTML.jpg

图 4-25

浏览器中呈现的 Vaadin 图标

Tip

https://vaadin.com/components/vaadin-icons/java-examples 可以看到所有图标。

形象

在 Vaadin 应用中有两种显示图像的方式。如果您有一个指向图像文件的 URL(内部或外部),您可以简单地创建一个图像组件,如下所示:

Image photo = new Image(
    "https://live.staticflickr.com/65535/50969482201_be1163c6f1_b.jpg",
    "Funny dog"
);
photo.setWidthFull();

在构造函数中的 URL 之后,您可以(也应该)传递一个替换文本,如果图像无法显示,web 浏览器可以使用该文本。前面的示例还设置了图像的宽度,以使用尽可能多的水平空间。

如果图像文件来自数据库或文件系统,您可以使用InputStream来读取数据。例如,如果您将一个文件放在 Maven 项目的标准 resources 目录中,您可以创建一个如下所示的映像:

StreamResource source = new StreamResource("logo", () ->
    getClass().getClassLoader()
        .getResourceAsStream("vaadin-logo.png")
);

Image logo = new Image(source, "Logo");
logo.setWidth("20%");

图 4-26 可以看到 Vaadin 的 logo 和搞笑狗。

img/506498_1_En_4_Fig26_HTML.jpg

图 4-26

浏览器中呈现的两个Image组件

摘要

本章向您介绍了在 Vaadin 中用于数据输入、交互和可视化的大多数 UI 组件的关键知识。您了解了输入组件如何工作,如何从用户那里获得不同类型的值,包括字符串、日期、文件等等。当用户与应用交互时,JSON 消息是如何在客户机和服务器之间发送的。您还了解了如何显示通知、弹出对话框、图标和图像,但是请记住,Vaadin 中的 UI 组件比本章的篇幅所能显示的要多。Vaadin 在不断发展,因此新版本中会添加新的 UI 组件。您可以在 https://vaadin.com/components 浏览所有组件及其 API。

下一章将探讨业务应用中的一个关键概念:数据绑定——一种将 Java bean 中的值与输入 UI 组件中的值连接起来的机制。

五、数据绑定

Vaadin 经常用于实现业务应用的表示层。这种应用处理通常被建模为一组域类的数据。例如,在一个电子商务应用中,您可能会发现诸如ItemCartOrderCustomerPayment等类似的类。表示层作为一种媒介来呈现来自域对象的数据,并将来自用户的数据捕获到域对象中,后端可以使用这些对象来运行任何业务逻辑。

数据从外部服务或用户界面到达应用。在最后一种情况下,输入组件中的值需要在业务对象的属性中设置。例如,TextField中客户姓名的值(在用户注册用例中)应该设置在Customer类的name属性中。正如您在前一章中了解到的,您可以使用getValue()方法来实现这一点。同样的事情发生在相反的方向。例如,为了允许用户更新他们的名字,您可以从数据库中读取存储的值,并使用setValue(String)方法将其显示在一个TextField中。

数据绑定是将 Java 属性中的值与输入组件中的值连接起来的过程,以及该过程中可能出现的所有错综复杂的情况。这包括验证输入和将数据从表示层支持的格式转换成后端服务所需的格式。

手动实现数据绑定

让我们将我们目前所学的一些概念应用到一个假想的销售点(POS)软件中,实现一个简单的视图来管理产品。这类软件的中心思想是产品。假设我们被指派实现一个 UI 来管理产品。UI 应该包括所有产品的列表,每个产品都有一个编辑和删除它的选项,以及一个创建新产品的按钮。

实现领域模型

让我们省略任何连接数据库的逻辑,使用一个Set将产品保存在内存中。你会从哪里开始?在我的例子中,我会从域模型开始,所以为了简单起见,这里有一个域类的实现来封装 POS 软件所需的数据:

public class Product {

  private String name;
  private boolean available = true;
  private Manufacturer manufacturer;
  public Product() {
  }

  ... getters and setters ...

}

public class Manufacturer {

  private String name;
  private String phoneNumber;
  private String email;

  public Manufacturer() {
  }

  public Manufacturer(String name, String phoneNumber,
      String email) {
    this.name = name;
    this.phoneNumber = phoneNumber;
    this.email = email;
  }

  ... getters and setters ...

}

实现视图

下一步是什么?说到 UI,我通常从视图级开始——用@Route注释的类。我们需要产品的静态Set,制造商的Set,以及展示产品的布局。静态集,因为我们希望确保所有用户使用相同的数据。为了简单起见,让我们在一个静态块中定义所有的制造商(这个版本的 POS 软件不包括制造商管理)。那么,下面呢?

public class ProductManagementView extends Composite<Component> {

  private static Set<Product> products = new HashSet<>();
  private static Set<Manufacturer> manufacturers =
      new HashSet<>();
  private VerticalLayout productsLayout = new VerticalLayout();

  static {
    manufacturers.add(new Manufacturer("Unix Beers", "555111",
        "beer@example.com"));
    manufacturers.add(new Manufacturer("Whisky Soft", "555222",
        "whisky@example.com"));
    manufacturers.add(new Manufacturer("Wines Java", "555333",
        "wine@example.com"));
  }

}

在我们开始编码之前,让我们停下来想一想。这门课我们需要哪些操作?我们需要

  • 初始化内容

  • 用户创建、编辑或删除产品时,随时更新产品列表

  • 当用户单击给定产品中的“新建”按钮或“更新”按钮时显示表单

  • 保存和删除产品

考虑到这一点,我们可以向ProductManagementView类添加以下方法:

@Override
protected Component initContent() {
}

private void updateList() {
}

private void showProductForm(Product product) {
}

private void delete(Product product) {
}

private void save(Product product) {
}

现在让我们开始一次编写一个方法。方法应该返回视图的布局。它还应该更新产品列表,以便它们在页面刷新时可见:

@Override
protected Component initContent() {
  updateList();
  return new VerticalLayout(
      new Button("New product", VaadinIcon.PLUS.create(),
          event -> showProductForm(new Product())),
      productsLayout
  );
}

这将显示一个创建新产品的按钮(注意我们如何调用相应的方法传递新产品)和带有产品列表的布局(productsLayout)。接下来是updateList()法。为了“刷新”产品列表,我们可以通过删除productsLayout组件中的所有组件来重建列表,并一次添加一个产品:

private void updateList() {
  productsLayout.removeAll();
  products.stream()
      .map(product -> new Details(
          product.getName() +
              (product.isAvailable() ? "" : " (not available)"),
          new HorizontalLayout(
              new Button(VaadinIcon.PENCIL.create(),
                  event -> showProductForm(product)),
              new Button(VaadinIcon.TRASH.create(),
                  event -> delete(product))
          )
      ))
      .forEach(productsLayout::add);
}

这段代码使用一个 Java 流来获取products集合中的每个产品,并将其“转换”成一个我们还没有涉及到的 UI 组件。惊喜!Details组件是一个可扩展的面板,用于显示和隐藏内容。在这个例子中,带有两个ButtonHorizontalLayout是隐藏的,直到用户点击标题(见图 5-1 )。每个产品都被映射到一个带有产品和动作按钮信息的Details组件,然后被添加到productsLayout实例中。

img/506498_1_En_5_Fig1_HTML.jpg

图 5-1

Details组件。点击标题(啤酒)显示按钮

让我们继续展示表单。为此,我们将把表单的实际实现委托给一个单独的类(ProductForm),我们将很快开发这个类。这样做是有意义的,因为我们可能希望在应用的其他部分重用该表单。下面是showProduct(Product)方法的实现:

private void showProductForm(Product product) {
  Dialog dialog = new Dialog();
  dialog.setModal(true);
  dialog.open();

  dialog.add(new ProductForm(product, manufacturers, () -> {
    dialog.close();
    save(product);
  }));
}

该方法首先创建并配置一个新的Dialog并添加下一个即将诞生的ProductForm类的实例。我实现这段代码的方式和我现在描述的完全一样。我通常倾向于在实现类本身之前编写使用类(该类的客户端)的代码。这样,我可以专注于我需要的那个类的 API。我认为 ProductForm 类的构造函数可以接受我希望在表单中显示的产品(新产品或现有产品)、可用制造商列表,以及当用户在表单中保存产品时执行我的代码的回调。因此,当用户完成表单时,我可以关闭对话框,并使用作为 lambda 表达式实现的回调来保存产品。在我们进入实际的ProductForm类之前,这里是save(Product)delete(Product)方法的实现:

private void save(Product product) {
  products.add(product);
  updateList();
  Notification.show("Product saved: " + product.getName());
}

private void delete(Product product) {
  products.remove(product);
  updateList();
  Notification.show("Product deleted");
}

这里没有惊喜成分。这两个方法在静态Set中添加或删除产品,更新列表以反映 UI 中的变化,并显示一个通知,通知用户操作已成功完成。

实现表单

现在让我们在实现ProductForm类时讨论数据绑定。这个类连接(绑定)到一个Product实例(bean)。更具体地说,我们希望将Product的属性与表单中的输入字段连接起来。当我们创建一个ProductForm时,输入字段应该显示 bean 中属性的值。例如,我们需要一个TextField来引入或编辑Product类中的name属性。类似地,当TextField中的值改变时,我们需要更新name属性。

让我们从定义我们需要的输入组件和数据开始。我们将它们作为属性添加到类中,因为我们以后需要访问它们:

public class ProductForm extends Composite<Component> {

  private final SerializableRunnable saveListener;
  private Product product;

  private TextField name = new TextField("Name");
  private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
  private Checkbox available = new Checkbox("Available");

}

顾名思义,SerializableRunnableRunnable的可序列化版本。这允许我们对这个类(比如我们在上一节开发的ProductManagementView类)的客户端执行回调,并给它们机会处理product实例中的数据。我们还有一个代表名称的TextField,一个代表制造商的ComboBox,以及一个代表产品可用性的Checkbox

Tip

当你在 UI 类中保存对对象的引用时,使用Serializable类。例如,当您在 Apache Tomcat 等服务器中使用会话持久性来允许会话在重启后存储在硬盘中时,这是必需的。Vaadin 中包含的所有 UI 组件和其他助手类都实现了Serializable

在上一节中,我们决定这个类应该在构造函数中接受一个Product实例(以SerializableRunnable的形式与侦听器一起)。为了实现从Product到输入字段方向的绑定,我们可以从构造函数中的Product bean 的相应 Java 属性中设置每个输入字段的值,如下所示:

public ProductForm(Product product,
    Set<Manufacturer> manufacturers,
    SerializableRunnable saveListener) {
  this.product = product;
  this.saveListener = saveListener;

  manufacturer.setItems(manufacturers);
  manufacturer.setItemLabelGenerator(Manufacturer::getName);

  if (product.getName() != null) {
    name.setValue(product.getName());
    manufacturer.setValue(product.getManufacturer());
    available.setValue(product.isAvailable());
  }
}

构造函数中的前两行将值赋给类的实例字段,这样我们以后可以在其他方法中使用它们。然后,ComboBox被填充并配置为显示每个项目的制造商名称。因为输入字段中的setValue(T)方法会抛出一个NullPointerException,如果你传递了null,我们必须在调用它之前做一个空检查。通过将值从 bean 设置到输入字段,我们已经在那个方向上实现了数据绑定。

Note

当我谈到输入字段时,我指的是用于数据输入的 UI 组件,例如TextFieldCheckbox。当我谈到字段(或 Java 字段,或实例字段)时,我指的是 Java 类中的成员变量。

准备好输入组件后,我们可以实现如下的表单布局:

@Override
protected Component initContent() {
  return new VerticalLayout(
      new H1("Product"),
      name,
      manufacturer,
      available,
      new Button("Save", VaadinIcon.CHECK.create(),
          event -> saveClicked())
  );
}

这将创建一个新的布局,带有一个标题(H1)、输入字段(nameavailable),以及一个允许用户调用保存操作的按钮。在saveClicked()方法中,我们可以实现相反方向的数据绑定——使用 bean 的 setters 和字段的getValue()方法将字段输入到Product:

private void saveClicked() {
  product.setName(name.getValue());
  product.setManufacturer(manufacturer.getValue());
  product.setAvailable(available.getValue());
  saveListener.run();
}

我们已经成功地实现了一个带有数据绑定的表单。结果如图 5-2 所示。

img/506498_1_En_5_Fig2_HTML.jpg

图 5-2

带有手动数据绑定的表单

活页夹助手类

尽管这种技术是可行的,但是在前面的部分中,数据绑定的实现存在一个问题。当您向Product类添加属性时,您必须记住在一个地方设置输入字段值(构造函数),在另一个地方设置 bean 属性值(saveClicked()方法)。这使得代码很难维护,尤其是当您向表单添加更多的输入字段时。

由于在数据源(如域对象)和输入字段之间同步值是业务应用中的一项常见任务,Vaadin 提供了简化这一过程的功能——Binder类。这个类不是一个 UI 组件,而是一个帮助器类,它根据一个可定制的配置来保持值的同步,这个配置告诉我们哪个输入字段被绑定到哪个 bean 属性。

以编程方式定义绑定

让我们修改ProductForm类,让 Vaadin 为我们进行数据绑定。下面是该表单的完整实现:

public class ProductForm extends Composite<Component> {

  private final SerializableRunnable saveListener;
  private TextField name = new TextField("Name");
  private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
  private Checkbox available = new Checkbox("Available");

  public ProductForm(Product product,
      Set<Manufacturer> manufacturers,
      SerializableRunnable saveListener) {
    this.saveListener = saveListener;

    manufacturer.setItems(manufacturers);
    manufacturerComboBox.setItemLabelGenerator(
        Manufacturer::getName);

    Binder<Product> binder = new Binder<>();
    binder.bind(name, Product::getName, Product::setName);
    binder.bind(manufacturer,
        Product::getManufacturer, Product::setManufacturer);
    binder.bind(available, Product::isAvailable,
        Product::setAvailable);
    binder.setBean(product);
  }

  @Override
  protected Component initContent() {
    return new VerticalLayout(
        new H1("Product"),
        name,
        manufacturer,
        available,
        new Button("Save", VaadinIcon.CHECK.create(),
            event -> saveListener.run())
    );
  }

}

Binder类的帮助下,我们能够大幅减少代码量。我们不再需要保存对Product实例的引用,因为所有的数据绑定逻辑都发生在构造函数中。这里,我们创建了一个用Product参数化的Binder类的新实例,因为我们想将表单绑定到产品。然后,我们通过在域类中指定输入字段以及 Java 属性的 getter 和 setter,将输入字段绑定到它们在Product类中对应的 Java 属性。稍后,Binder类使用 getter 从 bean 中获取值,并将其设置为输入字段中的值。setter 用于根据输入字段中的值设置 bean 属性中的值。一旦我们定义了绑定,我们通过调用setBean(BEAN)方法告诉Binder类使用哪个 bean。此时,Binder类从 bean 中读取值,并将它们写入匹配的输入字段。从现在开始,当用户在表单中引入数据时,Binder类还将从输入字段设置 bean 中的值。

Note

在内部,Binder 类向每个输入字段添加值更改侦听器,以便有机会将值写回 bean。没有魔法。只是为你工作的框架。

Binder级提供了大量的多功能性。在前面的例子中,我们使用方法引用来指定在绑定中使用的 getter 和 setter。我们也可以使用 lambda 表达式并包含任何我们想要的逻辑。假设我们的客户要求我们更改 UI,将产品标记为不可用,而不是可用。也就是说,当产品不可用时,用户希望看到复选标记。由于种种原因,我们不想修改数据模型(例如,这是这个需求有意义的唯一视图,但是在这个假设的应用的其他 174 个视图中没有)。这是会让我们的客户满意的变化:

private Checkbox unavailable = new Checkbox("Unavailable");
...
  binder.bind(unavailable,
      prod -> !prod.isAvailable(),
      (prod, booleanValue) -> prod.setAvailable(!booleanValue));

我们更改了引用Checkbox的变量的名称,以使代码更加清晰,并使用 lambda 表达式对 bean(Product类型的prod)和输入字段(unavailable)中的值求反。在这种情况下需要简单的逻辑,但是您已经明白了——当在属性和 UI 中相应的输入字段之间同步值时,您可以运行任何业务或 UI 逻辑(简单的或复杂的)。

当您需要对何时运行数据绑定逻辑进行更细粒度的控制时,请使用readBean(BEAN)writeBean(BEAN)方法。第一个读取属性并设置输入字段中的值。第二个获取输入字段中的值,并将它们写入 bean 的属性中。见https://vaadin.com/api

使用属性名定义绑定

在 Java 中,当您有一个带有匹配的 getter 和 setter 的字段时,您可以将该变量称为属性Binder类允许您使用域类中属性的名称来定义绑定,而不是指定 getter 和 setter 函数。例如,如果我们想使用属性名为Product类的manufacturer属性定义绑定,我们必须做两件事。第一种是使用Binder(Class)构造函数而不是默认构造函数来创建实例:

Binder<Product> binder = new Binder<>(Product.class);

通过这种改变,我们可以通过字符串的形式引用属性的名称来定义绑定。例如:

binder.bind(manufacturer, "manufacturer");

我们还可以更改输入字段的名称,使示例更加清晰:

private ComboBox<Manufacturer> comboBox = new ComboBox<>();

现在绑定看起来像这样:

binder.bind(comboBox, "manufacturer");

Caution

使用属性名定义绑定的好处是可以使用 Jakarta Bean 验证。然而,您的代码将不再是类型安全的,如果以后您在您的域模型中重命名一个属性,您也必须在您的绑定逻辑中重命名包含该属性名称的字符串。否则会产生运行时错误。通过方法引用和 lambda 表达式,您的代码是类型安全的,并且您可以使用 IDE 的重构工具来重命名属性,因为您知道代码不会中断。这两种方法都没有对错;你必须根据你的需求和设计来决定什么是最好的。正如在下一节中所讨论的,自动绑定包含了一个特性,这个特性减少了与通过属性名进行数据绑定相关的风险。

使用自动绑定

我们可以让 Vaadin 为我们定义绑定。这种方法在很多情况下都很方便,尤其是当您想要使用 Jakarta Bean 验证时(将在本章后面介绍)。让我们看一个自动绑定的例子:

public class AutoBindingProductForm extends Composite<Component> {

  private TextField name = new TextField("Name");
  private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
  private Checkbox available = new Checkbox("Available");

  public AutoBindingProductForm(Product product, ...) {
    ...

    Binder<Product> binder = new Binder<>(Product.class);
    binder.bindInstanceFields(this);
    binder.setBean(product);
  }

  ...

}

首先,请注意,我使用省略号(...)来表示我们目前不感兴趣的额外代码。其次,注意我们使用的Binder构造函数。这在使用自动绑定时是必需的,因为属性的名称用于将输入字段与 domain 类中的 Java 属性相匹配,就像我们在上一节中看到的那样。第三,看看我们如何通过调用bindInstanceFields(Object)方法来替换绑定定义。

通过自动绑定,Vaadin 检查您传递给bindInstanceFields(Object)方法的类,以找到该类中的所有 Java 字段。如果被检查的字段也是HasValue(所有 Vaadin UI 输入组件实现的接口)的一个实例,那么它试图在您传递给setBean(BEAN)的 bean 中找到一个与被检查字段同名的属性,然后使用相应的 getter 和 setter 来执行数据绑定。

在前面的例子中,我们有三个类型为HasValue的 Java 字段,它们在Product类的属性中具有匹配的名称。因此,Vaadin 绑定

  • AutoBindingProductForm::nameProduct::name

  • AutoBindingProductForm::manufacturersProduct:manufacturers

  • AutoBindingProductForm::availableProduct::available

为了提高可维护性,我们可以使用@PropertyId注释来覆盖命名约定。这允许我们在 UI 类中为输入字段使用任何标识符:

@PropertyId("name")
private TextField nameTextField = new TextField("Name");

@PropertyId("manufacturer")
private ComboBox<Manufacturer> manufacturerComboBox
    = new ComboBox<>();

@PropertyId("available")
private Checkbox availableCheckbox = new Checkbox("Available");

例如,看看我们如何将Checkbox的标识符改为availableCheckbox。我们可以用任何我们喜欢的名字。如果没有注释,绑定就不会发生,因为两个类中 Java 字段的名称不匹配。

Tip

当使用自动绑定时,总是使用@PropertyId来使你的代码更容易维护。您可以在不破坏代码的情况下自由更改输入字段的名称。当您在您的域模型中重命名一个属性时,您可以搜索@PropertyId("nameOfTheProperty")并相应地进行替换。

定义嵌套属性的绑定

假设在一次在线会议后,很明显我们的客户希望能够以编辑产品的相同形式编辑制造商的电话号码和电子邮件。他们声称这将节省大量时间,因为他们目前拥有的系统(用 Fortran 实现)没有这个选项。

Note

我从来没用过 Fortran。然而,当我还是个孩子的时候,当我父亲讲述在大学使用编程的鼓舞人心的故事时,它间接地激励了我去学习更多的编程知识。我接触过的最接近 Fortran 的是一个用 C 实现的在线文本游戏,但它是基于克罗泽和伍兹用 Fortran 为传说中的 PDP-10 主机编写的原版(Adventure)。你可以在 https://quuxplusone.github.io/Advent 玩游戏。

需求表明,当用户想要创建新产品时,我们必须显示组合框来选择制造商。然而,当他们想要编辑一个时,我们必须禁用组合框并显示两个额外的文本字段来更新制造商的电话号码和电子邮件。因此,我们必须向AutoBindingForm类添加两个字段:

private TextField phoneNumber = new TextField(
    "Manufacturer phone number");
private TextField email = new TextField("Manufacturer email");

这些也应该添加到布局中:

@Override
protected Component initContent() {
  return new VerticalLayout(
      new H1("Product"),
      name,
      available,
      manufacturer,
      phoneNumber,
      email,
      new Button("Save", VaadinIcon.CHECK.create(),
          event -> saveListener.run())
  );
}

实现的关键部分在构造函数中。我们有两种场景:表单用于新产品或现有产品。如果产品名称为空,则它一定是新产品。我们可以根据需要使用一个if...else语句来隐藏和禁用输入字段。为了创建绑定,我们可以使用“点符号”来指定我们想要绑定的属性。代码如下:

Binder<Product> binder = new Binder<>(Product.class);
binder.bindInstanceFields(this);

if (product.getName() == null) {
  phoneNumber.setVisible(false);
  email.setVisible(false);
} else {
  manufacturer.setEnabled(false);
  binder.bind(phoneNumber, "manufacturer.phoneNumber");
  binder.bind(email, "manufacturer.email");
}

binder.setBean(product);

我们使用自动绑定来创建对Product类属性的绑定。但是请注意我们是如何创建到Manufacturer类的嵌套属性的绑定的。不幸的是,在撰写本文时,Vaadin 不支持使用@PropertyId注释的嵌套绑定。我们也可以使用类型安全绑定。因为我们可以使用 lambda 表达式来获取和设置任何我们想要的 Java 逻辑的值,所以我们可以选择这样的方式:

binder.bind(email,
    p -> p.getManufacturer().getEmail(),
    (p, e) -> p.getManufacturer().setEmail(e));

在这里,p是一个Producte是一个String

Caution

更复杂的场景可能需要在创建这种类型的嵌套数据绑定时进行空检查,以避免NullPointerException s。

图 5-3 显示了编辑模式下的表单。

img/506498_1_En_5_Fig3_HTML.jpg

图 5-3

具有嵌套数据绑定的表单

数据转换和验证

为了有效,活页夹工具应该包括管理数据转换和验证的功能。Vaadin 的Binder类包含了以灵活和健壮的方式处理这两个方面的方法。

数据转换是将数据从输入字段支持的格式(如TextField中的String)转换为领域模型中的格式并返回的过程。例如,某个产品的可用商品数量可以通过一个TextField进行编辑(尽管在这种情况下,我建议使用NumberField来代替),并存储在域模型的一个int属性中。

数据验证是确保输入字段中的数据在存储到域模型之前根据一组业务规则是有效的过程。例如,在将产品保存到数据库中之前,您可能希望保证产品的名称不为 null 并且不是空字符串。

使用转换器

为了说明转换器的使用,假设客户告诉我们 POS 软件必须包含产品代码。他们坚持认为,他们希望能够在文本字段中引入产品代码,即使部分代码被固定为一组预定义的选项,这些选项从来没有机会,而且他们肯定在可预见的未来不会改变。他们称这部分代码为“类型”,其余部分为“数字”(即使它可以包含字母)。这其实是根据一个真实的故事改编的。

总之…在一次软件设计会议之后,很明显我们需要一个枚举来存储类型和一个数字字符串。这是领域模型:

public enum Type {
  DRINK, SNACK
}

public class Code {

  private Type type;
  private String number;

  public Code(Type type, String number) {
    this.type = type;
    this.number = number;
  }

  ... getters and setters ...
}

public class Product {
  ...

  private Code code = new Code(Type.DRINK, "");
  ...
}

我们需要一个新的输入字段,格式如下:

public class AutoBindingProductForm extends Composite<Component> {

  ...

  private TextField code = new TextField("Code");
  ...
}

我们当然将文本字段添加到布局中(此处省略)。如果您在没有其他更改的情况下尝试该应用,您将得到如下错误消息:

Property type 'com.apress.practicalvaadin.ch05.Code' doesn't match the field type 'java.lang.String'. Binding should be configured manually using converter.

这是因为 Vaadin 不知道如何将Code实例转换为TextFieldString。尽管错误消息确认我们需要一个转换器,但是我们可以使用定义绑定时接受的 getter 和 setter 函数来实现转换:

binder.bind(codeTextField,
    (p) -> p.getCode().getType().toString() +
        p.getCode().getNumber(),
    (p, s) -> {
      for (Type t : Type.values()) {
        if (s.startsWith(t.toString())) {
          p.setCode(
            new Code(t, s.substring(t.toString().length()))
          );
          return;
        }
      }
    }
);

这里,p是类型Product的,s是类型String的。第一个 lambda 表达式是 getter,所以我们只需要连接类型和数字并返回结果字符串。第二个 lambda 表达式需要解析TextField中的字符串,以正确设置Code实例中的类型和数量。

虽然这种方法可行,但您可能希望使用转换器来代替。以下是如何:

public class StringToCodeConverter
    implements Converter<String, Code> {

  @Override
  public Result<Code> convertToModel(String value,
      ValueContext context) {
    for (Type t : Type.values()) {
      if (value.startsWith(t.toString())) {
        Code code =
            new Code(t, value.substring(t.toString().length()));
        return Result.ok(code);
      }
    }

    return Result.error("Error parsing the code");
  }

  @Override
  public String convertToPresentation(Code code,
      ValueContext context) {
    return code.getType().toString() + code.getNumber();
  }

}

这个类实现了Converter及其两个方法。第一个采用一个String值并创建一个新的Code实例。注意使用Result类来告诉 Vaadin 转换是否成功。第二种方法采用一个Code实例,并将连接的值作为一个String返回。这可能看起来是额外的工作,因为它实际上比以前的方法需要更多的代码行。但是,当您希望在应用的多个部分中重用转换逻辑时,这是很有用的。使用转换器,您可以绑定属性并指定转换器,如下所示:

binder.forField(codeTextField)
    .withConverter(new StringToCodeConverter())
    .bind(Product::getCode, Product::setCode);

这一次,我们使用不同的方式来定义绑定。首先,我们用forField(HasValue)指定输入字段,然后我们使用Binder类的 fluent API 通过链接方法调用来配置绑定。

Vaadin 包括最常见数据类型的转换器。以下是他们的名单:

  • StringToBooleanConverter

  • StringToIntegerConverter

  • StringToLongConverter

  • StringToFloatConverter

  • StringToDoubleConverter

  • StringToBigDecimalConverter

  • StringToBigIntegerConverter

  • StringToDateConverter

  • LocalDateToDateConverter

  • LocalDateTimeToDateConverter

  • DateToSqlDateConverter

  • StringToUuidConverter

实施验证规则

我们的客户打电话告诉我们,在保存数据之前,产品表单需要检查以下内容:

  1. 产品名称是必需的。

  2. 产品代码是必需的。

  3. 制造商的电话号码应多于七个字符。

  4. 制造商的电子邮件应该是格式正确的电子邮件地址。

为了在使用Binder类时添加自定义验证逻辑,我们必须在不使用自动绑定的情况下以编程方式定义绑定。因此,让我们将表单中的输入字段重构如下(没有@PropertyId注释):

private TextField name = new TextField("Name");
private TextField code = new TextField("Code");
private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
private Checkbox available = new Checkbox("Available");
private TextField phoneNumber =
    new TextField("Manufacturer phone number");
private TextField email = new TextField("Manufacturer email");

现在让我们来解决验证 1(姓名是必需的):

Binder<Product> binder = new Binder<>(Product.class);
binder.forField(name)
    .asRequired("The name of the product is required")

通过调用asRequired(String),我们告诉 Vaadin 可视地标记所需的字段,并在字段为空时显示指定的错误。见图 5-4 。

img/506498_1_En_5_Fig4_HTML.jpg

图 5-4

必填字段

实现验证 2(需要代码)非常相似:

binder.forField(code)
    .asRequired("Please introduce a code")
    .withConverter(new StringToCodeConverter())
    .bind(Product::getCode, Product::setCode);

在定义绑定时,我们将调用链接到转换器和验证器。验证 3(超过七个字符的电话号码)需要调用不同的方法:

binder.forField(phoneNumber)
    .withValidator(
        value -> value.length() > 7,
        "Invalid phone number"
    ).bind("manufacturer.phoneNumber");

我们使用 lambda 表达式来实现验证。事实上,我们可以使用框架提供的众多验证器之一:

  • EmailValidator

  • LongRangeValidator

  • DateTimeRangeValidator

  • BigDecimalRangeValidator

  • FloatRangeValidator

  • ShortRangeValidator

  • BigIntegerRangeValidator

  • IntegerRangeValidator

  • DoubleRangeValidator

  • DateRangeValidator

  • ByteRangeValidator

  • StringLengthValidator

  • RangeValidator

  • RegexpValidator

让我们使用其中一个来实现验证 4(正确的电子邮件格式):

binder.forField(email)
    .withValidator(new EmailValidator("Invalid email address"))
    .bind("manufacturer.email");

虽然验证是在值被发送到服务器时运行的(例如,当用户编辑一个字段时),但是我们应该在单击 save 按钮时调用验证。我们使用Binder类调用验证,这并不奇怪。所以首先我们需要确保可以从点击监听器访问binder对象。我们可以如下移动对象的定义:

 public class AutoBindingProductForm extends Composite<Component> {

  ...

  private Binder<Product> binder = new Binder<>(Product.class);
  ...
}

现在,我们可以使用 binder 首先验证字段,使错误在 UI 中可见,然后检查是否有任何错误:

new Button("Save", VaadinIcon.CHECK.create(),
    event -> {
      binder.validate();
      if (binder.isValid()) {
        saveListener.run();
      } else {
        Notification.show("Please fix the errors");
      }
    })

使用 Jakarta Bean 验证

Jakarta Bean Validation 是由 ?? JSR 380 ?? 定义的 Java 规范。它允许您在域类的属性中使用注释来表达验证规则。通过从Binder切换到BeanValidationBinder类,我们可以使用注释而不是手动设置验证器。

要开始使用 Jakarta Bean 验证,我们需要添加 API 的一个实现。Hibernate 提供了最流行的实现之一。我们可以将其添加到pom.xml文件的<dependencies>部分:

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.5.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.el</artifactId>
    <version>     3.0.3</version>
</dependency>

Caution

在撰写本文时,Vaadin 与使用javax.*名称空间的 Jakarta EE 规范兼容。许多 Jakarta 规范实现的最新版本,包括 Hibernate Validator 版本 7.0.0 和更高版本,都使用了新的jakarta.*名称空间。这种变化被称为“大爆炸”,是将 Java 企业版迁移到 Eclipse Foundation 所需的变化的一部分。您可以在 https://eclipse-foundation.blog/2020/12/08/jakarta-ee-9-delivers-the-big-bang 了解更多相关信息。

让我们定义验证 1(名称是必需的):

public class Product {

  @NotNull
  @NotBlank
  private String name;
  ...
}

现在是验证 2(需要代码):

public class Code {
  ...

  @NotNull
  @NotBlank
  private String number;
  ...
}

最后,验证 3(超过七个字符的电话号码)和 4(正确的电子邮件格式):

public class Manufacturer {
  ...
  @Size(min = 8)
  private String phoneNumber;

  @Email
  private String email;
  ...
}

为了实现这一点,我们需要删除使用binder对象时添加的所有验证器,并替换它的类型:

private BeanValidationBinder<Product> binder =
    new BeanValidationBinder<>(Product.class);

完成了。我们的 POS 软件版本 1 已经完成。尝试通过添加更多验证来改进它。例如,制造商也应该是必填字段。实验并探索Binder类的 API,以了解更多信息。

Note

我写了一篇关于 Jakarta Bean 验证的快速教程,展示了如何向普通 Servlet、Spring 和 Jakarta EE 应用添加不同的提供者,如何定制错误消息,以及有哪些可用的注释。可以在 https://vaadin.com/learn/tutorials/introduction-to-java-bean-validation 找到教程。位于 https://beanvalidation.org 的 Jakarta Bean 验证项目的官方网站也提供了多种学习资源。

摘要

在本章中,你学习了什么是数据绑定,以及如何使用Binder类自动或手动定义绑定。您了解了如何使用转换器将数据从输入字段支持的格式转换成领域模型中的格式。您还了解了如何添加验证器来确保表单中的值在发送之前是正确的,例如,发送到后端服务。

在下一章中,您将了解大多数业务应用使用的强大组件——Grid组件。

六、网格组件

显示和操作表格数据是大多数业务应用的基础。Vaadin 的Grid组件是一个高质量的 UI 组件,用于高效地显示和操作以行和列布局的数据。

这个Grid组件是 Vaadin 的研发团队努力改进 Vaadin 7 和更早版本中已经很强大的Table组件的结果。该组件包括过滤、排序、组件呈现、延迟加载等特性,是业界最先进的表格组件之一。

添加列

Grid组件总是用您希望在每行中呈现数据的域类来参数化。比如要显示订单,用Grid<Order>;如果你想展示产品,你用Grid<Product>;诸如此类。这允许您在向Grid组件添加列和行时直接使用自己的域模型。添加行总是手动完成的(您必须告诉Grid组件要显示哪些行或数据)。但是,对于列,Grid组件支持两种模式:自动或手动列定义。使用的模式取决于您使用的Grid构造函数:

// scans Product and automatically adds a column per property
var grid1 = new Grid<>(Product.class);

// no scan, you have to add the columns manually
var grid2 = new Grid<Product>();

按键管理列

假设您有以下域类:

public class Book {

  private Integer id;
  private String title;
  private String author;
  private int quantity;

  ... constructors, getters, and setters ...

}

通过将类类型传递给Grid构造函数,可以让 Vaadin 自动添加列来显示Book类中的每个属性(参见图 6-1 ):

img/506498_1_En_6_Fig1_HTML.jpg

图 6-1

带有自动创建的列的空Grid

var grid = new Grid<>(Book.class);

每个列都有一个String键,您可以使用它来进一步配置该列。您可以通过使用getColumnByKey(String)方法来实现。例如,可以按如下方式设置标题:

grid.getColumnByKey("title").setHeader("Book");

此外,您可以链接配置:

grid.getColumnByKey("title")
    .setHeader("Book")
    .setFooter("text here")
    .setAutoWidth(true);

setAutoWidth(boolean)方法计算宽度,为内容留出空间。

您可以通过属性名添加更多的列,这在您想要显示嵌套属性时非常有用。例如,如果我们在Book类中添加一个属性,比如类型Publisher,并且您希望在另一列中显示发布者的姓名,那么您可以按如下方式添加:

grid.removeColumnByKey("publisher");
grid.addColumn("publisher.name");

我们移除了使用Grid(Class)构造函数时自动生成的publisher列。您也可以使用列键来定义显示哪些属性以及显示顺序(参见图 6-2 ):

img/506498_1_En_6_Fig2_HTML.jpg

图 6-2

设置列的可见性(id 不可见)和顺序

grid.setColumns("title", "publisher.name", "author", "quantity");
grid.getColumnByKey("publisher.name").setHeader("Publisher");

Caution

在为指定的属性添加新列之前,setColumns(String...)方法删除所有现有的列。

使用 ValueProvider 定义列

我们已经看到了一种在使用Grid(Class)构造函数时通过属性名手动定义列的方法。当您使用默认构造函数(Grid())时,Vaadin 不会向组件添加任何列。相反,您必须定义和配置每一列,传递一个叫做ValueProvider的东西。实现ValueProvider的一个简单方法是使用对域类中 getter 的方法引用或 lambda 表达式来显示任何想要显示的内容(例如,通过导航 getter 来嵌套属性)。与使用自动生成的列相比,这种方法的一个优点是它的类型安全特性:

var grid = new Grid<Book>(); // default constructor used
grid.addColumn(Book::getTitle)
    .setHeader("Book").setAutoWidth(true);
grid.addColumn(book -> book.getPublisher().getName())

    .setHeader("Publisher");
grid.addColumn(Book::getAuthor).setHeader("Author");
grid.addColumn(Book::getQuantity).setHeader("Quantity");

Tip

如果希望代码显式且类型安全,请使用手动列定义。自动列适用于您喜欢较短代码的情况,或者在高级场景中,Grid在运行时从您在编译时不知道的域对象生成,并且可能由第三方库提供。

添加行

Grid组件添加行是由许多具有不同签名的setItems方法处理的。在我们使用这些方法之前,让我们定义一个服务层。通常,web 应用使用后端服务来提供对数据的操作。对于 Vaadin,您可以简单地调用一个 Java 方法来运行后端逻辑。例如,我们可以实现一个服务类来添加任何逻辑以获取数据,而无需向视图层公开实现细节。服务类可以连接到 SQL 数据库,读写硬盘上的文件,连接到外部 web 服务,甚至是所有这些的组合。例如:

public class BookService {

  ...

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

}

实现细节在这里并不重要,因此省略了。如果你想看实现(它只包含一个 Java Collection来保存内存中的对象),可以通过本书位于 www.apress.com/ISBN 的产品页面来看看 GitHub 上的代码。

内存中的数据

准备好后端服务后,我们就可以开始消费数据了。要在一个Grid中显示所有的书,我们只需做如下的事情:

List<Book> books = BookService.findAll();
grid.setItems(books);

就这么简单。你可以把这想象成Grid中的每一行都有一个Book。每个单元格中的值取决于定义列时的配置。

Caution

Grid类中没有删除项目的方法。相反,您必须传递一个没有要移除的元素的新集合或数组。

setItems(Collection)方法重载了一个接受数组或传递单个对象的版本(见图 6-3 ):

img/506498_1_En_6_Fig3_HTML.jpg

图 6-3

用数据填充的Grid

Book[] booksArray = ...
grid.setItems(booksArray);
gridSetItems(book1, book2, book3, book4);

一旦设置了项目,Grid组件就将对象保存在内存中。在开发应用时,请记住这一点。请记住,看到视图的每个用户在服务器中都有自己的视图实例,反过来,还有一个Grid实例。每个Grid实例都有自己的一组条目,也就是自己的一组域对象。只有当你确定Grid中的行数不大并且不会随时间增长时,这才是可以接受的。在其他情况下,您应该使用延迟加载。

Tip

在您的应用中,您应该考虑使用数据传输对象(dto)在Grid中显示数据。想象一下Book类有 20 多个属性。其中之一是二进制数据。当您查询后端时,您会得到一个由Book实例组成的集合,其中包含大量您并不真正需要在Grid中显示的数据。这将消耗比所需更多的内存。相反,创建一个新的类来封装视图需要的数据。如果您正在使用持久性框架,请查阅文档以了解如何只请求您需要的数据。

惰性装载

如果后端服务返回一百万本书会怎么样?假设一个Book实例需要的内存量大约是 100 字节。这相当于 100 兆字节的内存来保存后端服务返回的数据。有了分配给 JVM 的 1gb 内存,您可以处理大约 10 个并发用户。可能更少,因为您需要内存来存储其他资源。当您在开发环境中测试您的应用时,您可能不会注意到这一点,因为Grid组件能够处理如此大量的数据。

Tip

之前的估计是基于对存储类的每个字段所需的字节数以及 JVM 可能增加的开销的快速分析。如果您的项目需要更好的大小估计,看看 Java 中可用的Instrumentation接口。在 https://docs.oracle.com/en/java/javase 查阅 API 文档。

尽管您应该回顾并考虑在一个Grid中显示一百万行的其他方法,但是如果必须的话,总是使用延迟加载。我们来看看怎么做。

后端服务应该能够以“切片”的形式提供数据如果您熟悉 SQL,您可能知道LIMITOFFSET子句,例如:

SELECT * FROM book OFFSET 300 LIMIT 100

该查询在开始返回 100 行之前跳过了 300 行。在我们的示例应用中,我们不处理 SQL,我们仍然将所有数据保存在内存中。然而,表示层并不知道这一点,您应该能够只更改底层技术来使用 SQL、NoSQL 或任何其他数据库解决方案存储数据。最后,后端服务应该包括如下所示的方法:

public static List<Book> findAll(int offset, int limit) {
  ...
}

Grid组件可以如下使用这个方法:

grid.setItems(query -> BookService.findAll(
    query.getOffset(), query.getLimit()).stream()
);

提供一个Query对象,该对象具有Grid的当前状态所需的偏移量和限制(具体由滚动条的位置决定)。我们可以将值传递给后端,并将Collection转换成Stream

Note

实现延迟加载的另一种方法是创建带编号的页面来显示切片中的数据。这个概念类似于我们在这里实现的内容,但是需要您设置 UI 组件来显示页码(例如,使用Button),并添加逻辑来设置构成所选页面的Grid项。

整理

Grid组件允许用户通过单击列的标题来对行进行排序。当您让 Vaadin 通过使用Grid(Class)构造函数生成列时,所有的列都被映射到实现Comparable的属性,然后该列就可以排序了。当您手动定义列时,可以按如下方式启用排序:

grid.addColumn(Book::getTitle). setSortable(true);

可以通过falsesetSortable(boolean)来禁用排序。使用列键时,可以在多列中启用排序,如下所示:

grid.setSortableColumns("title", "author");

当一个属性不是Comparable或者您想要调整比较逻辑时,您可以提供一个Comparator:

grid.addColumn(Book::getTitle).setComparator(
    (book1, book2) ->
        book1.getTitle().compareToIgnoreCase(book2.getTitle()));

前面的示例忽略大小写对标题列进行排序。请记住,您可以将对配置列的多个方法的调用链接起来。例如,您可以配置标题和宽度,并添加一个比较器,如下所示:

grid.addColumn(Book::getTitle)
    .setHeader("Book")
    .setAutoWidth(true)
    .setComparator((book1, book2) ->
        book1.getTitle().compareToIgnoreCase(book2.getTitle()));

我们在这里使用 lambda 表达式,但是当代码比这个简单的例子更复杂时,没有什么可以阻止您创建一个新的类来封装逻辑。

Tip

当您使用延迟加载时,您可以将排序委托给后端。Query类包含如何根据用户点击的列对数据进行排序的信息。排序逻辑的实际实现取决于您的特定后端。例如,如果您正在使用 SQL 查询,您可以根据Query::getSorted()Query::getDirection()返回的内容添加由ASCDESC修改的ORDER BY子句。

处理行选择

既然我们已经介绍了如何在Grid中显示数据,让我们来探索如何使组件更具交互性。您可能想知道的第一件事是如何处理行选择,这是一个在数据编辑、数据下钻以及一般的数据消费和操作等领域实现功能的特性。

Grid组件有三种选择模式,您可以使用setSelectionMode(SelectionMode)方法进行配置:

  • grid.setSelectionMode(Grid.SelectionMode.NONE):不允许选择。

  • grid.setSelectionMode(Grid.SelectionMode.SINGLE):一次可以选择一行。

  • grid.setSelectionMode(Grid.SelectionMode.MULTI):可以同时选择多行。

默认情况下,Grid组件允许对行进行单项选择,因此如果您以前没有更改过这个选项,就不必显式地配置它。

假设我们需要添加一个按钮,将在Grid中选择的书的quantity属性增加 1。我们希望只有在选中一行时才启用按钮。在视图的构造器中,让我们创建按钮,禁用它(因为在构建视图时没有选择行),并将其添加到布局中(参见图 6-4 ):

img/506498_1_En_6_Fig4_HTML.jpg

图 6-4

Grid组件中选择的一行

var increaseQuantity = new Button("Increase quantity");
increaseQuantity.setEnabled(false);
...
return new VerticalLayout(increaseQuantity, grid);

现在,我们需要一个侦听器,当选择或取消选择一行时调用它:

grid.addSelectionListener(event -> {
  ... logic here ...
});

按照这里的逻辑,我们需要检查是否有一行被选中。我们可以得到Grid的“单一选择”视图,并得到选择的值:

Book selectedBook = grid.asSingleSelect().getValue();

当用户取消选择一行时,引用是null,因此我们可以使用它来启用或禁用按钮(参见图 6-5 ):

img/506498_1_En_6_Fig5_HTML.jpg

图 6-5

禁用选择更改时的按钮

grid.addSelectionListener(event -> {
  Book selectedBook = grid.asSingleSelect().getValue();
  increaseQuantity.setEnabled(selectedBook != null);
});

或者我们可以使用event对象将选中的书包装在Optional中,在我看来这是一个更好的解决方案,因为它将块中的代码从grid实例中分离出来:

grid.addSelectionListener(event -> {
  boolean enabled = event.getFirstSelectedItem().isPresent();
  increaseQuantity.setEnabled(enabled);
});

现在缺少的部分是单击按钮时运行的逻辑。我们需要更新所选Book实例的quantity属性中的值,并更新Grid。由于我们需要从两个地方(当我们创建Grid和当用户点击按钮时)更新(用数据填充)Grid,为此创建一个方法是有意义的:

private void updateGrid(Grid<Book> grid) {
  List<Book> books = BookService.findAll();
  grid.setItems(books);
}

您可以使grid实例成为类中的一个字段,或者简单地将它传递给方法。我们将它传递给前面代码片段中的方法。准备就绪后,我们可以在按钮上实现点击监听器,如下所示:

increaseQuantity.addClickListener(event ->
    grid.asSingleSelect().getOptionalValue().ifPresent(
        book -> {
          BookService.increaseQuantity(book);
          updateGrid(grid);
        }
    )
);

我们可以直接更新侦听器中的quantity属性,但是如果我们处理的是一个具有外部服务和数据库连接的真实应用,您最有可能将逻辑委托给服务类。服务方法逻辑作为一个(极其简单的)练习(给指定的Bookquantity属性加一)。

注意使用了getOptionalValue()方法,而不是前面解释的getValue()。这很方便,因为我们只关心值的存在,而不是执行时的值。

多重选择以类似的方式处理,除了您可以将选择的行作为一个Set:

Set<Book> selectedBooks = grid.asMultiSelect().getValue();

或者从选择监听器:

Set<Book> selectedBooks = event.getAllSelectedItems();

向单元格添加 UI 组件

一个显著增加Grid组件灵活性的特性是可以添加其他 UI 组件。让我们探索一下选择。

组件列

有一种特殊的列,它的单元格中可以有组件。假设我们想去掉在上一节中实现的按钮。相反,我们希望每行每本书都有一个按钮,允许用户通过单击Grid中的相应按钮来增加数量。以下是如何:

grid.addComponentColumn(
    book -> new Button(VaadinIcon.PLUS.create(), event -> {
      BookService.increaseQuantity(book);
      updateGrid(grid);
    })
);

使用addComponentColumn(ValueProvider)方法,我们可以获取一个Book实例并返回一个 UI 组件。在前面的例子中,我们返回了一个Button,它运行逻辑来增加该行中的图书数量(参见图 6-6 )。

img/506498_1_En_6_Fig6_HTML.jpg

图 6-6

组件列

需要时,我们可以使用Book实例中的属性。例如,我们可以用进度条替换数量列中的数值。让我们假设每本书我们最多能有 50 本。我们可以这样配置该列:

grid.addComponentColumn(
    book -> new ProgressBar(0, 50, book.getQuantity())
).setHeader("Quantity").setSortable(true);

我们还为该列设置了一个标题,并使其可排序。但是,如果您尝试单击标题,排序将不起作用。在这种情况下,我们需要设置一个Comparator:

.setComparator(Comparator.comparingInt(Book::getQuantity));

我们将这个实现中的比较逻辑委托给 Java。图 6-7 显示的是点击两次表头进行降序排序后的结果。

img/506498_1_En_6_Fig7_HTML.jpg

图 6-7

Grid组件中的有序行

这个实现有一个问题。你能看见吗?尝试多次单击其中一个按钮。调查问题。在 IDE 中查看应用的日志,并尝试修复它(本书在 www.apress.com/ISBN 的源代码示例中提供了修复方法)。如果你喜欢编码,试着在每一行增加另一个按钮来减少相应的数量。您可以尝试为“新建”按钮添加一个新列,或者创建一个在同一列中包含两个按钮的布局。

项目详细信息

有时,表中的一行并不是显示与域对象相关的所有数据的最佳位置。通常,应用包含一个选项,可以深入到表中某一行的详细信息。当用户单击一行时,Grid组件允许您在定制组件中显示更多信息(项目细节)。

假设我们向Book类添加了一个description属性。该书的描述可能太长而不能直接显示在新列中,用户可能希望在列表中识别出该书后阅读该书的描述。因此,让我们添加当用户单击一行时显示描述的功能:

grid.setItemDetailsRenderer(
    new ComponentRenderer<>(book -> new VerticalLayout(
        new Text(book.getDescription())
    ))
);

这段代码添加了一个ComponentRenderer(一个负责在单元格中呈现组件的对象),它使用指定的 lambda 表达式构建一个布局,其中包含与被单击的行相关联的图书描述。图 6-8 显示了结果。

img/506498_1_En_6_Fig8_HTML.jpg

图 6-8

单击行时显示的项目详细信息

我们可以向细节部分添加任何组件和布局。例如,我们添加一个按钮来删除或编辑一本书,甚至删除我们为增加数量而创建的 component 列,并将按钮移到 details 行。让我们试着移动按钮。我们需要做的就是将创建Button的代码从组件列定义移动到细节部分的VerticalLayout,并删除创建列的代码:

grid.setItemDetailsRenderer(
    new ComponentRenderer<>(book -> new VerticalLayout(
        new Text(book.getDescription()),
        new Button(VaadinIcon.PLUS.create(), event -> {
          BookService.increaseQuantity(book);
          updateGrid(grid);
          grid.select(book);
        })
    ))
);

看对grid.select(book)的调用。这就是如何以编程方式选择行。但是为什么这里需要它呢?任何时候单击某一行,都会选中或取消选中该行。选中该行时,将显示详细信息区域。再次单击该行时,该行将被取消选择,并且详细信息区域将被隐藏。当细节区域可见时,单击我们添加的按钮会传播到取消选中它的行,并导致细节区域隐藏。为了避免这种情况,我们简单地再次“重新选择”同一行(书)。这样,用户可以在需要时多次单击该按钮,而不必每次都选择该行。

此外,看看我们如何改变按钮的图标和添加文本,以使用户清楚这个按钮是什么。当按钮在数量栏旁边时,很容易理解该按钮增加了数量,但是如果我们将它移到细节区域,情况就不同了。图 6-9 显示了结果。

img/506498_1_En_6_Fig9_HTML.jpg

图 6-9

详细信息行中的组件

Tip

注意不要将复杂的布局添加到详细信息行中。这可能会使用户界面变得混乱,并对 UX 产生负面影响,尤其是在小型视口(如移动设备上的视口)中使用该应用时。

导出到 CSV

为了结束这一章,让我们探索一下业务应用中的一个常见用例——将Grid的内容导出到逗号分隔值文件(CSV)。也可以导出到许多其他格式,但 CSV 似乎是软件行业的一个流行要求。我们将使用一个Anchor组件和 Opencsv ,一个用于读取、写入和处理 csv 文件的免费库。

让我们从编码Anchor开始。一个Anchor组件可以指向一个定义好的 URL,您可以将它指定为一个字符串(href)或者指向一个StreamResource,它是一个允许您将动态数据从服务器传输到客户端的类。实施一个StreamResources很容易:

var streamResource = new StreamResource("books.csv",
    () -> {
      // TODO
      return new ByteArrayInputStream(null);
    }
);

当单击浏览器中的链接时,lambda 表达式运行,您有机会返回 Vaadin 用来传输数据的InputStream。我们还没有数据,所以我们现在传递一个null。现在我们可以将这个StreamResource传递给一个新的Anchor组件,我们可以将它添加到 UI 中:

var download = new Anchor(streamResource, "Download");

return new VerticalLayout(download, grid);

生成数据的时间。在编码之前,我们需要将 Opencsv 库添加到 pom.xml 文件中(在 http://opencsv.sourceforge.net 查看最新版本):

<dependency>
    <groupId>com.opencsv</groupId>
    <artifactId>opencsv</artifactId>
    <version>5.4</version>
</dependency>

现在让我们来处理TODO部分。我们需要Grid中的Book实例。我们可以通过以下方式获得它们:

var books = grid.getGenericDataView().getItems();

这将返回一个类型为Stream<Book>的对象(books)。数据准备好了。我们需要改变它。为此,我们可以使用一个StatefulBeanToCsvBuilder(来自 Opencsv 库)。下面是它的使用方法:

StringWriter output = new StringWriter();
var beanToCsv =
    new StatefulBeanToCsvBuilder<Book>(output).build();
beanToCsv.write(books);

在调用write(Stream)之后,output对象将保存一个包含 CSV 格式数据的String。我们可以将构成这个String的字节传递给InputStream(代替我们在前面的代码片段中使用的null):

return new ByteArrayInputStream(output.toString().getBytes());

为了让您全面了解,下面是完整的StreamResource实现:

var streamResource = new StreamResource("books.csv",
    () -> {
      try {
        var books = grid.getGenericDataView().getItems();
        StringWriter output = new StringWriter();
        var beanToCsv =
            new StatefulBeanToCsvBuilder<Book>(output).build();
        beanToCsv.write(books);
        return new ByteArrayInputStream(
            output.toString().getBytes());

      } catch (CsvDataTypeMismatchException |
          CsvRequiredFieldEmptyException e) {
        e.printStackTrace();
        return null;
      }
    }
);

如果您运行应用并下载文件,您将会看到Book类中的所有属性都包含在文件中,而Grid只显示了其中的一部分。当我们转换网格中包含的Book对象时,这是意料之中的。我们可以排除如下属性:

var beanToCsv = new StatefulBeanToCsvBuilder<Book>(output)
    .withIgnoreField(Book.class,
        Book.class.getDeclaredField("id"))
    .withIgnoreField(Book.class,
        Book.class.getDeclaredField("nextId"))

    .build();

另一个问题是,Publisher列显示了来自Object类的默认toString()方法的结果,而不是发布者的名字。解决这个问题最简单的方法是覆盖Book类中的toString()方法来返回发布者的名字:

public class Publisher {

  private String name;

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

  ...
}

Note

Opencsv 是一个灵活的库,具有比这里描述的更多的功能。要了解更多信息,请参见 http://opencsv.sourceforge.net

摘要

在这一章中,您学习了如何在 Vaadin 的一个更复杂的 UI 组件中使用最重要的功能:组件Grid。您了解了如何自动和手动添加列,以及如何添加在内存中保存数据的行,或者使用延迟加载将数据提供给Grid组件以节省内存。您看到了如何启用排序,以及如何对Grid行中的选择更改做出反应。您了解了如何在Grid的单元格中添加 UI 组件,以及如何在用户单击一行时显示“细节视图”,以便显示关于某个项目的更多信息。您还了解了 web 应用中的一个常见用例——将数据从Grid导出到 CSV 文件。同时探索通过使用事件侦听器组合和连接 UI 组件来组合视图的方法。Grid类提供了比我们在这里讨论的更多的功能。例如,您可以冻结标题或添加拖放功能。详见 Vaadin 官方文档了解更多( https://vaadin.com/docs )。

下一章涵盖了 Vaadin 的一个中心主题:导航和路由。大多数应用需要多个视图,所以不要错过这个主题!

七、多视图导航和路由

尽管 Vaadin 抽象出了许多 web 平台和 Java Servlet 技术细节,但是用该框架开发的应用仍然是 Web 应用。这听起来显而易见,但值得重申。当您开始使用 Vaadin 实现应用时,您可能会被代码所吸引,而忘记您正在开发一个 web 应用。我去过那里。

web 应用的核心部分是请求-响应模型。Vaadin 隐藏了与该模型相关的复杂性,同时允许您使用其优势。您已经知道在同一个应用中可以有多个视图,每个视图都可以通过不同的 URL 访问。您还可以通过编程方式从一个视图导航到另一个视图,在用户进入视图之前或之后运行自定义逻辑,配置错误视图,在 URL 中包含参数,等等。

路线

路由将 Java 类与 URL 连接起来。这个 Java 类通常是一个视图,必须实现Component。我们已经用@Route注释定义了路线:

@Route("hello")
public class Hello extends VerticalLayout {
  public Hello() {
    this.add(new Text("Hello!");
  }
}

当应用启动时(在 Servlet 初始化时),Vaadin 扫描这些类以识别那些用@Route注释的类。对于每个类,它会创建一个包含每个路由配置的注册表。当用户请求由VaadinServlet管理的 URL 时,会用到这个配置,例如,在您的开发机器上的http://localhost:8080/hello,或者在生产机器上的 https://example.com/businessapp/hello

Tip

您可以使用@RouteAlias注释为单个视图定义多条路线。如果你决定使用这个注释,我建议你关注 https://github.com/vaadin/flow/issues/7862 的问题。如果你想看看在像 Vaadin 这样的开源产品的开发过程中发生的讨论,请阅读这个帖子。

运行时定义路线

除了让 Vaadin 自动检测来自您的类的路由,您还可以在运行时以编程方式定义它们。让我们开发一个带有登录表单的基本应用。根据登录表单中引入的凭证,我们授予对两个视图之一的访问权限。我们将使这变得简单,但是你将得到如何使它适应你的应用的想法。假设我们只有两个用户:

  • 用户名:user,密码:user。只能访问UserView组件。

  • 用户名:admin,密码:admin。只能访问AdminView组件。

视图是显示消息的简单的 Vaadin 组件。我们现在不会用@Route来注释实现:

public class UserView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new Text("Welcome, user.");
  }

}

public class AdminView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new Text("Hello, admin.");
  }

}

如果我们用@Route注释这些类,我们将允许任何人访问。相反,我们想告诉 Vaadin 哪些路由是可用的,这取决于登录表单显示后运行的逻辑。在 Vaadin 应用中显示登录表单最简单的方法是使用LoginOverlay(或者,LoginForm)组件:

@Route("login")
public class AppLoginForm extends Composite<Component> {

  @Override
  protected Component initContent() {
    LoginI18n i18n = LoginI18n.createDefault();

    LoginOverlay loginOverlay = new LoginOverlay(i18n);
    loginOverlay.setTitle("Chapter 7");
    loginOverlay.setDescription("Navigation and Routing");
    loginOverlay.setOpened(true);

    loginOverlay.addForgotPasswordListener(event ->
        Notification.show("Use admin/admin or user/user"));

    loginOverlay.addLoginListener(event -> {
      ... login logic here ...
    });

    return new VerticalLayout(loginOverlay);
  }

}

这个类用@Route进行了注释,因为我们希望每个人都能使用它。

LoginI18n是 Vaadin 提供的一个类,用于定制LoginOverlay组件中的文本。LoginOverlay组件在页面中央显示一个登录表单,如图 7-1 所示。该组件是完全响应的,这意味着它会根据显示它的屏幕大小进行调整。尝试实现这个例子或者从本书的源代码运行这个项目(可以在 GitHub 上的 www.apress.com/ISBN 获得),并尝试不同的窗口大小。

img/506498_1_En_7_Fig1_HTML.jpg

图 7-1

LoginOverlay组件

当您创建一个LoginOverlay时,您可以传递一个LoginI18n对象,该对象允许您定制诸如错误消息、标题和要使用的表单之类的东西。我们暂时不考虑这个。在 https://vaadin.com/api 浏览 IDE 或 API 文档中可用的方法。

看看我们是如何打开组件的,也就是说我们让它在页面上可见。您可以使用LoginOverlay组件作为任何视图的覆盖图。在这里,除了登录表单本身,我们没有其他任何东西。例如,在其他应用中,您可以添加一个按钮来显示登录表单。

另外,请注意我们添加的侦听器。第一个处理表单中显示的忘记密码选项,它只显示一个通知,告诉您可用的用户和密码。我不建议在您的应用中这样做。第二个响应登录按钮的点击事件。让我们实现这个按钮的逻辑。

我们需要根据用户引入的凭证来定义或注册可用的视图。为此,我们可以使用RouteConfiguration类。例如,要使AdminView组件作为通过admin路径的一条路径可用,我们可以运行

RouteConfiguration.forSessionScope().setRoute(
    "admin", AdminView.class
);

这告诉 Vaadin,对于当前会话,并且仅对于当前会话,用户可以看到AdminView组件,就好像该类已经用@Route("admin")进行了注释。我们可以用这个来根据登录表单中的凭证设置相应的路由,如下所示:

loginOverlay.addLoginListener(event -> {
  if ("user".equals(event.getUsername())
      && "user".equals(event.getPassword())) {
    RouteConfiguration.forSessionScope().setRoute(
        "user", UserView.class
    );
    UI.getCurrent().navigate(UserView.class);

  } else if ("admin".equals(event.getUsername())
      && "admin".equals(event.getPassword())) {
    RouteConfiguration.forSessionScope().setRoute(
        "admin", AdminView.class
    );
    UI.getCurrent().navigate(AdminView.class);

  } else {
    loginOverlay.setError(true);
  }
});

Caution

使用硬编码的字符串来定义用户和密码远不是一个好的做法。这些信息应该安全地存储在外部数据源(如 SQL 数据库)中,并对密码进行适当的加密。

我们在登录监听器中检查来自event对象的用户名和密码值,并相应地设置路由。我们还使用来自UI类的navigate(Class)方法导航到适当的视图。这段代码应该进行重构,调用一个方法来设置路由,而不是我在本例中编写的基于复制粘贴的版本。或者,我们可以使用一个String来指定我们想要导航到的路径。例如,为了导航到管理视图,我们可以使用

UI.getCurrent().navigate("admin");

在两种情况下(用户和管理员),我们都必须关闭LoginOverlay组件;否则,它将继续可见,我们将看不到我们导航到的视图。

要检查实施是否有效,请参见图 7-2 。在屏幕截图中,请注意只有登录视图可用。此时,Vaadin 只知道登录视图。

img/506498_1_En_7_Fig2_HTML.jpg

图 7-2

在引入有效凭证之前,仅设置登录视图

一旦我们进入登录视图,输入有效的凭证(例如,用户 / 用户,并在浏览器中手动请求空的视图( http://localhost:8080/ ),我们看到一个新的视图(用户)可用(参见图 7-3 )。Vaadin 现在知道了这个观点。它不知道管理视图,而这正是我们想要的——我们想要限制对该视图的访问。如果你尝试请求管理视图,Vaadin 将不能显示它,因为它还没有被设置。

img/506498_1_En_7_Fig3_HTML.jpg

图 7-3

运行时添加的视图(用户)

这个例子有一个错误。如果您以用户身份登录,然后以管理员身份再次登录,您将可以访问这两个视图。这个怎么解决?我就当是给你的一个练习。也许删除以前的视图?让登录视图只对未经认证的用户可用怎么样?实现一个注销选项怎么样?探索RouteConfigurationUI类的 API,并尝试实现一个解决方案。

Tip

在更复杂的应用中,您可以灵活地在运行时创建路线,例如,从外部系统读取可用的视图及其路径。例如,SQL 数据库可以存储实现视图的 Java 类的完全限定名及其路径。然后,应用在运行时利用这些信息来注册路线。甚至关于哪些用户有权访问哪些路线的规则也可以存储在数据库中。此外,新视图可能由第三方实现,并在部署时而不是编译时添加到系统中。

路由器布局

路由器布局允许你装饰视图。我们来研究一个例子。web 应用中一个常见的 UI 模式是在所有“页面”,或者用 Vaadin 术语来说,视图中使用相同的标题。我们可以创建一个名为Header的可重用 UI 组件,所有视图实现者(比如你、我或我们的同事)都可以手动将其添加到他们的视图中:

@Route("my-fancy-view")
public class MyFancyView extends VerticalLayout {
  public MyFancyView() {
    ...
    var header = new Header();
    add(header, ... and other components ...);
  }
}

如果我们确定永远不需要添加页脚或菜单之类的东西,并且所有视图都共享这些东西,那么这样做就很好。对于那些我们不能 100%确定的情况,我们可以使用路由器布局。

路由器布局是一个实现RouterLayout的类,它设置由一组视图共享的所有组件。每个视图都可以使用@Route注释来指定要使用的路由器布局。下面是一个简单的路由器布局实现,它显示了一个标头:

public class MainLayout extends Composite<Component>
    implements RouterLayout {

  @Override
  protected Component initContent() {
    var header = new Div(new Text("Chapter 7"));
    header.setWidthFull();
    header.getStyle().set("font-size", "2em");
    header.getStyle().set("font-weight", "bold");
    header.getStyle().set("color", "white");
    header.getStyle().set("background-color", "#002211");

    return new VerticalLayout(header);
  }

}

现在请不要被getStyle()方法分散注意力。这几行代码只是设置了 CSS 属性,使标题看起来更有趣,便于您稍后看到截图。我们将在第十章中讨论造型。重要的一点是,这个类看起来像任何其他定制 UI 组件,除了它实现了RouterLayout。组件本身由一个Div组成,里面有一个Text表示 7 。以下是当 Vaadin 必须在浏览器中显示管理视图时,我们如何指示 vaa din 使用此路由器布局(参见图 7-4 ):

img/506498_1_En_7_Fig4_HTML.jpg

图 7-4

路由器布局中实现的报头

@Route(layout = MainLayout.class)
public class AdminView extends Composite<Component> {
  @Override
  protected Component initContent() {
    return new Text("Hello, admin.");
  }
}

因为在这个示例应用中,我们是以编程方式定义路线的,所以我们不能使用@Route注释。相反,我们必须在定义路由时指定路由器布局,如下所示:

RouteConfiguration.forSessionScope().setRoute(
  "admin", AdminView.class, MainLayout.class
);

RouterLayout接口包含一个默认方法,负责将视图添加到布局的末尾。我们可以覆盖实现来处理更复杂的布局。为了说明这一点,让我们尝试在路由器布局中添加一个页脚:

public class MainLayout extends Composite<Component>
    implements RouterLayout {

  @Override
  protected Component initContent() {
    ...

    var footer = new Div(
        new Text("Building UIs in Java is awesome!"));
    footer.setWidthFull();
    footer.getStyle().set("color", "white");
    footer.getStyle().set("background-color", "#002211");

    return new VerticalLayout(header, footer);
  }
}

由于默认情况下是在布局的末尾追加视图,我们将在VerticalLayout中以标题、容器、视图的顺序结束(见图 7-5 )。

img/506498_1_En_7_Fig5_HTML.jpg

图 7-5

路由器布局的默认行为

我们想要的是在页面底部显示页脚,而不是在页眉之后。为了解决这个问题,我们可以覆盖RouterLayout接口的showRouterLayoutContent(HasElement)方法。但是首先,我们需要创建一个组件作为视图的占位符。姑且称之为container:

public class MainLayout extends Composite<Component>
    implements RouterLayout {

  private VerticalLayout container = new VerticalLayout();

  @Override
  protected Component initContent() {
    ...

    return new VerticalLayout(header, container, footer);
  }

}

注意我们是如何将headercontainerfooter实例依次添加到VerticalLayout中的。每当一个视图将要显示在路由器布局中时,我们需要清空container并将视图添加到其中:

public class MainLayout extends Composite<Component>
    implements RouterLayout {

  private VerticalLayout container = new VerticalLayout();

  ...

  @Override
  public void showRouterLayoutContent(HasElement content) {
    container.removeAll();
    container.getElement().appendChild(content.getElement());
  }
}

这里我们再次使用了我们还没有涉及到的东西——元素 API。这个 API 允许你添加和删除 HTML 元素。出于某种原因,在路由器布局中,Vaadin 使用这个 API,而不是我们熟悉的组件 API。简而言之,每个组件都有一个元素(浏览器中 HTML 元素的 Java 表示)。我们使用这个更低级的 API 将视图添加到布局中。在第九章中你会学到更多关于元素 API 的知识。实际上,代码的效果就像我们使用了VerticalLayoutadd方法一样。结果如图 7-6 所示。

img/506498_1_En_7_Fig6_HTML.jpg

图 7-6

定制的路由器布局

导航生命周期

当您请求一个视图时,Vaadin 会寻找一个匹配的类,要么已经用@Route进行了注释,要么已经手动注册到了RouterConfiguration类。如果找到匹配的类,Vaadin 会在浏览器中呈现它,您可以开始使用该视图。稍后,您可能希望导航到另一个视图。您通过 URL 或应用本身的链接请求视图,然后重复这个过程。我们称这个过程为导航生命周期,您可以主要在两点上连接定制逻辑:

  • 在用户进入视图之前

  • 在用户离开视图之前

Note

在用户进入视图后(或者在导航事件发生后)运行逻辑还有第三点,我们在这里不讨论。如果您想了解更多,请参见位于 https://vaadin.com/apiAfterNavigationEvent类的 Javadoc。

在进入观察者之前

“在用户进入视图之前”场景的一个典型用例是,如果没有要可视化的数据,您希望将用户重定向到不同的视图。让我们看看如何实现这一点。

假设我们有这个视图,它是我们应用的“主页”:

@Route("")
public class HomeView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new VerticalLayout(
        new H1("Welcome!"),
        new RouterLink("Go to my data", DataView.class)
    );
  }

}

该视图映射到空航路("")并包括两个部分。第一个是标题(H1),第二个是到另一个视图的链接(接下来我们将实现这个视图)。RouterLink组件对于在应用中创建菜单选项或视图链接很有用。当你点击转到我的数据时,Vaadin 渲染DataView类。图 7-7 显示了浏览器中的HomeView组件。

img/506498_1_En_7_Fig7_HTML.jpg

图 7-7

RouterLink组件

为了实现DataView类,我们假设数据是存储在VaadinSession中的一个简单的String,并且这个数据显示在一个TextArea组件中以允许改变它。大概是这样的:

@Route("data")
public class DataView extends Composite<Component> {

  private TextArea textArea;

  @Override
  protected Component initContent() {
    textArea = new TextArea("Data", getData().orElse(""),
        "type your data here");

    return new VerticalLayout(
        new H1("Data view"),
        new RouterLink("Home", HomeView.class),
        textArea,
        new Button("Save", event -> {
          setData(textArea.getValue());
          Notification.show("Thanks for your data");
        })
    );
  }

  private Optional<String> getData() {

    String data = (String) VaadinSession.getCurrent()
        .getAttribute("data");
    return Optional.ofNullable(data);
  }

  private void setData(String data) {
    VaadinSession.getCurrent().setAttribute("data", data);
  }
}

图 7-8 将帮助您理解代码。

img/506498_1_En_7_Fig8_HTML.jpg

图 7-8

显示存储在VaadinSession中的数据的视图

VaadinSession是一个可以存储键值对的映射。应用的每个用户都有自己的VaadinSession对象。getData()方法从会话中读取一个带有键data的值。setData(String)方法在会话中用键data设置一个值。这些方法分别被textArea对象和Button组件用来可视化和存储值。

如果没有数据,我们希望将用户重定向到不同的视图。我们将这个新视图称为NoDataView,稍后我们将实现它。首先,我们需要连接逻辑来检查是否有数据,并在需要时进行重定向。这是HomeView班的职责。Vaadin 使用观察者模式来实现这一点。我们需要做的就是在HomeView类中实现BeforeEnterObserver接口:

@Route("data")
public class DataView extends Composite<Component>
    implements BeforeEnterObserver {

  ...

  @Override
  public void beforeEnter(BeforeEnterEvent event) {
    if (getData().isEmpty() || getData().get().isEmpty()) {
      event.rerouteTo(NoDataView.class);
    }
  }

  ...
}

我们必须用我们需要的逻辑实现beforeEnter(BeforeEnterEvent)方法。我们检查是否没有数据,如果有,我们使用rerouteTo(Class)方法将用户重定向到NoDataView组件。下面是该组件的实现:

@Route("no-data")
public class NoDataView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new VerticalLayout(
        new H1("Oops! There's no data \uD83D\uDE31"),
        new Button("Create data \uD83E\uDDEF", event -> {
          VaadinSession.getCurrent()
              .setAttribute("data", "This is the default data");
          UI.getCurrent().navigate(DataView.class);
          Notification.show("Default data created");
        })
    );
  }

}

我们使用 Unicode 代码添加了一些有趣的表情符号。但是我们不要被这些分散注意力!让我们了解一下我们实现了什么。如果转到 http://localhost:8080 ,Vaadin 会创建一个HomeView类的实例,并在浏览器中呈现出来(图 7-7 )。这个类包含一个RouterLinkDataView组件。你点击这个链接,Vaadin 创建一个DataView的实例。然而,在浏览器中呈现组件之前,它调用我们实现的beforeEnter(BeforeEnterEvent)方法。该方法意识到会话中没有数据,并重定向(或重新路由)到NoDataView组件,而不是DataView组件(图 7-9 )。

img/506498_1_En_7_Fig9_HTML.jpg

图 7-9

由“输入前”处理程序导致的重新路由

单击 Create data 按钮后,带有关键数据的默认值被存储在会话中,代码将用户再次引导到DataView组件。这一次,beforeEnter(BeforeEnterEvent)方法没有重定向,因为现在它可以看到数据了。视图最终使用TextArea组件中的默认数据进行渲染(图 7-10 )。

img/506498_1_En_7_Fig10_HTML.jpg

图 7-10

TextArea组件中呈现的默认数据

离开观察者之前

业务应用中的另一个常见用例是警告用户,如果他们离开视图,更改可能会丢失。我们可以在用户即将离开视图之前的事件中实现这一点。这是通过实现BeforeLeaveObserver来完成的。假设我们想问用户,当他们更改了TextArea中的值时,他们是否真的想离开视图。以下是如何:

@Route("data")
public class DataView extends Composite<Component>
    implements BeforeEnterObserver, BeforeLeaveObserver {

  ...

  @Override
  public void beforeLeave(BeforeLeaveEvent event) {
    if (!getData().get().equals(textArea.getValue())) {
      ContinueNavigationAction action = event.postpone();
      Dialog dialog = new Dialog();
      dialog.add(
          new Text("Are you sure?"),
          new Button("Yeah", clickEvent -> {
            dialog.close();
            action.proceed();
          })
      );
      dialog.open();
    }
  }

  ...
}

beforeLeave(BeforeLeaveEvent)方法检查textArea组件中的值是否与存储在VaadinSession中的值不同,如果是这样,它通过调用postpone()方法暂停导航操作,并显示一个Dialog让用户确认。如果用户确认,对话框关闭,通过调用proceed()方法继续导航操作。您可以通过导航到DataView路线、编辑数据并点击 Home 链接来测试这一点。图 7-11 显示了结果。

img/506498_1_En_7_Fig11_HTML.jpg

图 7-11

BeforeLeaveObserver推迟的导航动作

URL 参数

URL 参数是通过请求视图的 URL 传递给视图的值。例如,用@Route("users")注释的视图实现通过http://localhost:8080/users访问。类似的网址 http://localhost:8080/users?selectedId=3 包含一个值为 3 的 URL 参数( selectedId )。URL 参数可以作为 URL 中路径的一部分传递。例如,在http://localhost:8080/users/3中,数字 3 可以是一个 URL 参数,视图可以用它来选择 ID 为 3 的用户。

URL 模板

Vaadin 中处理 URL 参数的最强大的特性叫做 URL 模板。URL 模板用@Route注释指定,匹配的 URL 参数用BeforeEnterObserver处理。看一下这个例子:

@Route("template-parameter/:value")
public class TemplateParameterView extends Composite<Component>
    implements BeforeEnterObserver {

  private H1 text = new H1();

  @Override
  protected Component initContent() {
    return new VerticalLayout(text);
  }

  @Override
  public void beforeEnter(BeforeEnterEvent event) {
    Optional<String> value = event.getRouteParameters()
        .get("value");
    setValue(value.orElse("(no value)"));
  }

  private void setValue(String value) {
    text.setText(value);
  }

}

请密切注意@Route("template-parameter/:value")中的语法。 :value 部分表示视图期望在 URL 的那个位置有一个String。该字符串稍后在beforeEnter(BeforeEnterEvent)方法中被检索,并被设置为H1组件的文本内容(text)。图 7-12 显示了一个例子。密切注意浏览器中的 URL。

img/506498_1_En_7_Fig12_HTML.jpg

图 7-12

使用 URL 模板处理的 URL 参数

如果您请求不带参数的 URL(http://localhost:8080/template-parameter),您将得到一个错误,尽管视图用一个Optional对此进行了检查。如果您希望 URL 参数可以不存在(或为空),您必须在 URL 模板中使用?字符:

@Route("template-parameter/:value?")

您可以声明多个 URL 参数,并将它们放在 URL 模板中的任何位置。例如:

@Route("companies/:companyId/:employeeId/edit")

http://localhost:8080/companies/6/7/edit这样的 URL 会匹配这个路由。

您还可以使用通配符(*)来匹配 URL 的最后一段。例如:

@Route("api/:path*")

您可以从BeforeEnterEvent对象获取值,如下所示:

var path = event.getRouteParameters().get("path").orElse("");

如果请求 URLhttp://localhost:8080/API/com/company/list,那么path变量将包含字符串com/company/list

也可以使用正则表达式。例如:

@Route("companies/:companyId?([0-9]/edit")

这将只匹配在 /edit 前包含一位数的 URL。

Tip

使用getRouteParameters()返回的RouteParameters对象的getInteger(String)getLong(String)方法可以得到IntegerLong值。

类型化参数

HasUrlParameter接口是 URL 模板的替代,它允许您获取特定类型的 URL 参数。以下示例显示了如何从 URL 获取一个Integer值:

@Route("typed-parameter")
public class TypedParameterView extends Composite<Component>
    implements HasUrlParameter<Integer> {

  private H1 text = new H1();

  @Override
  protected Component initContent() {
    return new VerticalLayout(text);
  }

  @Override
  public void setParameter(BeforeEvent beforeEvent,
      Integer number) {
    text.setText("" + number);
  }

}

请参见图 7-13 注意 URL 和 UI 中呈现的值。

img/506498_1_En_7_Fig13_HTML.jpg

图 7-13

HasUrlParameter处理的类型化参数

Tip

您可以在setParameter(BeforeEvent, T)方法的参数中使用@OptionalParameter注释,使参数可选。如果使用这个注释,记得检查空值。

查询参数

查询参数(或查询字符串)是一组键值对,包含在 URL 中的问号字符(?)之后。例如,我们可以使用 URL http://localhost:8080/query-parameter?userId=13userId参数中传递一个类似于13的值。以下示例显示了如何使用此查询参数:

@Route("query-parameter")
public class QueryParameterView extends Composite<Component>
    implements BeforeEnterObserver {

  private H1 text = new H1("(no user ID)");

  @Override
  protected Component initContent() {
    return new VerticalLayout(text);
  }

  @Override
  public void beforeEnter(BeforeEnterEvent event) {
    Location location = event.getLocation();
    Map<String, List<String>> list = location
        .getQueryParameters().getParameters();
    List<String> userIds = list.get("userId");
    if (!list.isEmpty()) {
      text.setText(userIds.get(0));
    }
  }

}

注意我们是如何获得参数userId的值列表的。这是因为 URL 可能多次包含该参数,因此在同一个键下获得多个值。在前面的例子中,我们只使用列表中的第一个值。

更新页面标题

为了结束这一章,让我们看看如何设置当你请求一个视图时浏览器显示的标题。您可能已经注意到,到目前为止,浏览器标签中显示的标题显示的是 URL。您可以使用@PageTitle注释轻松设置标题(参见图 7-14 ):

img/506498_1_En_7_Fig14_HTML.jpg

图 7-14

配置了页面标题的浏览器选项卡

@PageTitle("This is the title")
@Route("page-title")
public class PageTitleView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new H1("Hello!");
  }

}

要在运行时设置标题,可以使用HasDynamicTitle界面(见图 7-15 ):

img/506498_1_En_7_Fig15_HTML.jpg

图 7-15

动态页面标题

@Route("dynamic-page-title")
public class DynamicPageTitleView extends Composite<Component>
implements HasDynamicTitle {

  @Override
  protected Component initContent() {
    return new H1("Hello again, and bye for now!");
  }

  @Override
  public String getPageTitle() {
    return "Title at " + LocalDateTime.now();
  }

}

Caution

不要同时使用@PageTitleHasDynamicTitle。当您这样做时,会引发异常。

摘要

就在那里!在这一章中,你学习了在 Vaadin 中将 URL 和视图连接起来所需要的一切。您看到了如何使用@Route注释或者在运行时使用RouteConfiguration类动态地定义路由。您了解了如何使用路由器布局来修饰视图,路由器布局允许您实现 UI 结构,包括页眉、菜单、页脚和 web 页面中的任何其他区域。您还了解了导航生命周期,以及如何在用户进入视图之前和离开视图之前连接您的逻辑。您看到了如何使用 URL 参数来配置使用 URL 模板、类型化参数和查询参数的 UI。

下一章将介绍现代 web 应用中一个令人兴奋的特性:服务器推送。

八、服务器推送

服务器推送是 Vaadin 中的一个特性,它允许您在不需要用户交互的情况下更新 UI。例如,您可能希望显示服务器上正在运行的任务的进度,或者通知用户有新的工作项目可用。

在 Vaadin 中激活和使用服务器推送非常简单,只需要一个注释和一个对UI:access(Command)方法的调用。在本章中,您将了解什么是服务器推送,何时使用,如何使用,以及可供您使用的配置选项。

什么时候使用服务器推送?

当您希望从不同于服务器为处理来自客户端的请求而创建的线程的线程中对 UI 进行更改时,可以使用服务器推送。例如,如果您有一个在服务器中执行长期运行任务的按钮,您可能希望创建一个新的Thread来运行任务逻辑并立即将控件返回给浏览器,以便用户可以继续使用应用中的其他功能。任务完成后,您可以在 UI 中显示结果。您不能简单地从新线程更新 UI,但幸运的是,这是服务器推送解决的问题。

让我们看一个例子来理解为什么以及何时必须使用服务器推送。看看这个视图的实现:

@Route("no-push")
public class NoPushView extends Composite<Component> {

  private VerticalLayout layout;

  @Override
  protected Component initContent() {
    layout = new VerticalLayout(
        new Button("Run long task", event -> runLongTaks()),
        new Button("Does this work?", event -> addText()));
    return layout;
  }

  private void runLongTaks() {
    try {
      Thread.sleep(5000);
      Notification.show("Task completed.");
    } catch (InterruptedException ignored) {
    }
  }

  private void addText() {
    layout.add(new Paragraph("It works!"));
  }

}

该视图包含一个带有 click listener 的按钮,该按钮模拟一个需要 5 秒钟才能完成的长时间运行的任务。5 秒钟后,代码尝试在 UI 中显示通知。还有一个按钮可以简单地向布局添加文本。如果您运行这个应用,您将看到 UI 被锁定了 5 秒钟。Vaadin 的客户端引擎检测到请求耗时过长,并显示一个进度条(见图 8-1 )。

img/506498_1_En_8_Fig1_HTML.jpg

图 8-1

长时间运行的任务会导致浏览器中显示进度条

你还会注意到是这样工作的?显示进度条时,按钮似乎不起作用。然而,5 秒钟后,通知显示在浏览器中,与 UI 的其他交互的结果也发生了(参见图 8-2 中添加到布局中的文本)。这表明,即使客户端可以发送服务器成功处理的附加请求,长时间运行的任务也会阻止对 UI 的更改。

img/506498_1_En_8_Fig2_HTML.jpg

图 8-2

在服务器中长时间运行任务后,用户界面立即发生变化

让我们向改进应用迈出一步。由于任务需要很长时间才能完成,我们可以将逻辑转移到一个单独的线程中。这允许初始线程(由服务器启动)完成,并让 Vaadin 处理请求并立即返回响应。我们还可以在启动新线程之前通知用户任务正在运行:

private void runLongTaks() {
  Notification.show("Running the task...");
  new Thread(() -> {
    try {
      Thread.sleep(5000);
      Notification.show("Task completed.");
    } catch (InterruptedException ignored) {
    }
  }).start();
}

不幸的是,如果您尝试该应用,您将永远看不到任务已完成的通知。相反,您将在服务器日志中看到一个异常:

java.lang.IllegalStateException: UI instance is not available. It means that you are calling this method out of a normal workflow where it's always implicitly set. That may happen if you call the method from the custom thread without 'UI::access' or from tests without proper initialization.

服务器推送的工作原理

让我们更详细地研究一下上一节的例子。当您单击按钮时,会创建一个新线程来处理服务器中的请求。点击监听器中的代码在这个线程中执行。这段代码又创建了另一个线程。原始线程结束,浏览器和服务器都准备好处理来自用户的未来 UI 交互。稍后,5 秒任务完成,处理对Notification.show("Task completed.")的调用。但为时已晚。响应已关闭。浏览器不希望有任何更改,因为它已经在 5 秒钟前处理了响应。对用户界面的新更改会丢失。

只有在原始请求的线程中进行更新时,才会对 UI 进行更新。看一下图 8-3 。浏览器中的一个事件(比如单击按钮或更改文本字段的值)会向服务器发出一个请求。通常,这导致 UI 中的变化(例如,显示通知)。这是我们到目前为止使用框架的方式。

img/506498_1_En_8_Fig3_HTML.jpg

图 8-3

更改用户界面的请求和响应

服务器推送是一个使用 Vaadin 中的注释激活的特性(您很快就会了解到),它允许单独的线程更新浏览器中的 UI。图 8-4 说明了这个概念。UI 事件向服务器发送一个请求,服务器产生一个带有 UI 更新的响应。然而,对于服务器推送,如果启动了一个新的单独线程,这个新线程可以发送推送更新,导致 UI 中的可见变化。

img/506498_1_En_8_Fig4_HTML.jpg

图 8-4

从服务器中单独的线程推送更新

执行异步用户界面更新

为了能够使用服务器推送更新 UI,您必须使用@Push注释来启用它。这个注释需要放在实现AppShellConfigurator接口的类中:

@Push
public class AppConfiguration implements AppShellConfigurator {
}

Note

当用户在浏览器中请求应用时,Vaadin 检测并使用实例来配置客户端引擎。

启用服务器推送后,您现在可以向客户端发送更新。这些更新是异步 UI 更新,可以根据您使用的配置手动或自动发送到客户端。

自动服务器推送更新

默认情况下,如果您将更改 UI 的代码包含在一个Command对象中,并调用UI类的access(Command)方法,服务器推送更改会自动发送到客户端。例如,要使上一节中的示例起作用,我们需要这样做:

private void runLongTaks() {
  Notification.show("Running the task...");
  var ui = UI.getCurrent();
  new Thread(() -> {
    try {
      Thread.sleep(5000);
      ui.access(() -> {
        Notification.show("Task completed.");
      });
    } catch (InterruptedException ignored) {
    }
  }).start();
}

这段代码获取一个对当前UI的引用,我们在启动线程之前初始化这个引用。在原始线程(请求线程)的范围内获得这个引用很重要,因为 Vaadin 使用 Java 的ThreadLocal来存储这些引用。

Tip

总是将在单独线程中运行的任何代码包含在一个UI:access(Command)调用中,并确保从该线程外部获得UI实例。

如果您运行该应用,您不仅可以在长时间运行的任务运行时向布局添加文本,还可以在任务完成 5 秒后显示通知(参见图 8-5 )。

img/506498_1_En_8_Fig5_HTML.jpg

图 8-5

服务器推送更新

手动服务器推送更新

可以控制服务器推送更新发送到客户端的确切时间。为此,您必须使用@Push注释来配置服务器推送模式:

@Push(value = PushMode.MANUAL)
public class AppConfiguration implements AppShellConfigurator {
}

当您从一个单独的线程对 UI 执行更改时,您仍然需要使用access(Command)方法,但是现在您可以在任何时候调用 UI 类的push()方法来将 UI 更改发送到客户端:

doBusinessStuff();
ui.access(() -> {
  updateUI();
  ui.push();
}
doSomeMoreBusinessStuff();
ui.access(() -> {
  updateUI();
  ui.push();
}

下面是一个长期运行的任务示例版本,它随着任务的进展更新一个ProgressBar组件:

@Route("manual-push")
public class ManualPushView extends Composite<Component> {

  private VerticalLayout layout;
  private ProgressBar progressBar = new ProgressBar(0, 10);
  private Button button;

  @Override
  protected Component initContent() {
    button = new Button("Run long task", event -> runLongTaks());
    button.setDisableOnClick(true);
    layout = new VerticalLayout(button,
        new Button("Does this work?", event -> addText()),
        progressBar);
    return layout;
  }

  private void runLongTaks() {
    Notification.show("Running the task...");
    progressBar.setValue(0);
    var ui = UI.getCurrent();
    new Thread(() -> {
      try {
        for (int i = 0; i <= 10; i++) {
          Thread.sleep(1000);
          double progress = i;
          ui.access(() -> {
            progressBar.setValue(progress);
            ui.push();
          });
        }

        ui.access(() -> {
          Notification.show("Task completed.");
          button.setEnabled(true);
          ui.push();
        });
      } catch (InterruptedException ignored) {
      }
    }).start();
  }

  private void addText() {
    layout.add(new Paragraph("It works!"));
  }

}

我们添加了一个值在 0 到 10 之间的ProgressBarrunLongTask()方法中的一个循环每秒更新一次进度条,并使用push()方法将更改发送给客户端。当循环结束时,另一个服务器推送更新被发送到客户机,通知任务完成。

看看我们如何在调用长期运行任务的按钮上调用setDisableOnClick(boolean)方法。当您运行这种任务以防止用户多次启动作业时,这很方便。图 8-6 显示了应用运行任务时的屏幕截图。

img/506498_1_En_8_Fig6_HTML.jpg

图 8-6

从服务器端线程手动更新的用户界面

该示例还展示了如何仅在我们需要更新 UI 时调用access(Command )方法。一个典型的错误是在不需要的时候在Command中调用业务逻辑。例如:

ui.access(() -> {
  doBusinessStuff();
  updateUI();
  ui.push();
}

当业务逻辑需要相当长的时间来运行时,这种负面影响会更加明显。相反,将业务逻辑调用移到Command实现之外。

Caution

access(Command)方法锁定用户会话。这意味着当Command实现中的代码运行时,其他线程不能对 UI 进行更改。

使用线程池

在前面的例子中,我们通过直接创建新的Thread实例来使用线程。Java 线程很昂贵,而且消耗内存。为了强调这一点,让我们做一个实验(改编自 Petter Holmströ的演讲):

public class MaxThreadsExperiment extends Thread {

  public static void main(String... args) {
    new MaxThreadsExperiment().start();
  }

  public static final AtomicInteger count = new AtomicInteger();

  @Override
  public void run() {
    try {
      System.out.println(count.incrementAndGet());
      new MaxThreadsExperiment().start();
      sleep(Long.MAX_VALUE);

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

}

这个 Java 程序递归地创建线程,直到你得到一个OutOfMemoryError。在我用来运行这个实验的虚拟机中,我很快就发现了错误:

...
1994
1995
1996
1997
1998
1999
2000
Exception in thread "Thread-1999" java.lang.OutOfMemoryError: unable to create new native thread
      at java.lang.Thread.start0(Native Method)
      at java.lang.Thread.start(Thread.java:717)
      at MaxThreadsExperiment.run(MaxThreadsExperiment.java:15)

2000 个线程是极限。根据您部署应用的服务器,这个数字可能更小或更大。关键是,考虑到 Servlet 容器创建线程来处理请求,2000(或者当我在我的开发机器上运行它时大约是 4000)对于 web 应用来说可能太少了。线程是有限的资源,应该如此对待。

在软件设计中,是在使用资源之前创建和初始化资源的地方。池中的客户端可以请求资源、使用资源,然后将其返回到池中。资源可以是数据库连接、服务、文件、线程或任何其他资源。线程池是一个软件实体,它可以创建预先配置好数量的线程,这些线程可以执行提交者发送的任务。

Java 包含了ExecutorService接口以及现成的线程池实现。任务提交者(您的代码)可以提交(执行)任务(Runnable对象),这些任务在线程队列中运行,当池中有线程可用时,在线程中执行。图 8-7 描绘了该过程。

img/506498_1_En_8_Fig7_HTML.jpg

图 8-7

具有四个线程池的执行器服务

因为线程池应该在应用开始产生任务之前准备好,所以初始化它的好地方是一个ServletContextListener。当ServletContext被初始化和销毁时,ServletContextListener接口允许你运行逻辑。

Note

应用启动时会创建一个ServletContext对象。web 应用的每个实例只有一个ServletContext实例。

继续前面几节的例子,我们可以创建一个扩展ServletContextListener的新类,或者使用现有的类(如果有的话)。事实上,我们已经有了一个很好的候选对象,所以我们将使用之前创建的AppConfiguration类来启用服务器推送,而不是创建一个新的类(如果您愿意,您可以这样做)。我们需要做的就是将该类添加到extends列表中,实现这些方法,并用@WebListener标记该类,以允许 Servlet 容器检测该类:

@Push
@WebListener
public class AppConfiguration
    implements AppShellConfigurator, ServletContextListener {

  private static ScheduledExecutorService executorService;

  public static ExecutorService getExecutorService() {
    return executorService;
  }

  @Override
  public void contextInitialized(ServletContextEvent event) {
    executorService = Executors.newScheduledThreadPool(3);
  }

  @Override
  public void contextDestroyed(ServletContextEvent event) {
    executorService.shutdown();
  }

}

这里,我们使用了类型为ScheduledExecutorService的静态实例(它实现了ExecutorService)。公共 getter 允许应用的其他部分获得对服务的引用并使用它。我们使用Executors类创建一个新实例,并配置一个有三个线程的线程池。这个数字在这个演示应用中用于演示目的,以便您可以使用它并查看线程的行为,但是您可能应该在生产中使用一个更大的数字,或者甚至在运行时进行配置。此外,为了防止线程“永远”存在于 JVM 中,在应用停止时关闭 executor 服务也很重要

下一步是修改视图以使用线程池(或执行器服务),而不是手动创建和启动线程。以下是重构的结果:

private void runLongTaks() {
  Notification.show("Running the task...");
  var ui = UI.getCurrent();

  AppConfiguration.getExecutorService().execute(() -> {
    try {
      Thread.sleep(5000);
      ui.access(() -> {
        Notification.show("Task completed.");
      });
    } catch (InterruptedException ignored) {
    }
  });
}

我们获取对ExecutorService的引用,并调用execute(Runnable)方法来提交一个新任务。这个任务将被分配给池中的一个空闲线程(如果有的话),或者排队直到有一个空闲线程可用。尝试应用并点击运行长任务按钮,比如说,十次。查看任务是如何提交的,但三个一批地完成。图 8-8 显示了一个例子。

img/506498_1_En_8_Fig8_HTML.jpg

图 8-8

在三个线程的池中完成的任务

Note

图 8-8 中的结果并不意味着任务三个一批地同时执行。结果是应用使用方式的结果。几个任务被快速发送(通过点击按钮)。因为线程在服务器中休眠了 5 秒钟,所以池中的所有三个线程都变得繁忙。由于任务一个接一个地快速发送,所有的线程几乎同时完成它们的任务。

WebSocket 与长轮询

默认情况下,当您启用服务器推送时,Vaadin 使用 WebSocket 协议将更改发送到客户端。WebSocket 是一种通信协议,就像 HTTP 一样。通过 WebSocket,客户端与服务器建立永久连接,从而实现参与者之间的全双工通信。

作为 WebSocket 的替代方法,您可以通过在@Push注释中设置LONG_POLLING传输来使用 HTTP 进行服务器推送:

@Push(transport = Transport.LONG_POLLING)
public class AppConfiguration implements AppShellConfigurator {
}

轮询是一种技术,客户端通过这种技术不断向服务器发送请求,要求更改 UI。如果客户机-服务器是一个对话,那么常规轮询应该是这样的:

  • 客户:我有什么变化吗?

  • 服务器:

  • 客户:我有什么变化吗?

  • 服务器:

  • 客户:我有什么变化吗?

  • 服务器:是。添加文本为“Hello”的通知。

  • 客户:谢谢。对我来说有什么变化吗?

  • 服务器:

向服务器请求更改的客户端代表 HTTP 请求。这些请求是定期进行的,例如,每隔一定的秒数。如果他们完成了,比如说,每 30 秒,用户界面就会慢慢更新。如果将频率降低到 2 秒,UI 更新会更快。越快对用户越好,但对网络流量最不利(尤其是如果你使用的是收费的云提供商)。

长轮询是一种技术,通过这种技术,客户端以一种智能的方式轮询服务器,以减少请求的数量,同时保持快速的 UI 更新。对于长轮询,客户端向服务器发出请求,服务器保存请求,直到有更改返回。只有在这时,响应才会被发送到客户端,客户端一直在静静地等待,没有发出新的请求。对话应该是这样的:

  • 客户:我有什么变化吗?

  • (2 分钟后...)

  • 服务器:是。添加文本为“Hello”的通知。

  • 客户:谢谢。对我来说有什么变化吗?

在 WebSocket 和长轮询之间进行选择取决于您的应用的确切需求、部署它的基础设施以及它的用途。一般来说,WebSocket 效率更高,因此,根据经验,只有当 WebSocket 协议不可用时才使用长轮询,例如,当 web 代理阻止它时。

摘要

在这一章中,你学到了很多关于线程、异步 UI 更新以及客户端和服务器之间的对话的知识。您了解了何时需要使用@Push注释来启用服务器推送。您了解了如何自动向客户机发送服务器推送更新,以及如何手动发送它们。您还了解了线程池以及它们如何帮助您避免臭名昭著的OutOfMemoryError。最后,您大致了解了 WebSocket 和长轮询是如何工作的。

在下一章中,您将通过使用 Vaadin 的 Element API 从服务器控制浏览器中的文档对象模型。

九、元素 API

在第一章中,我们了解了网络平台及其核心技术。这些是在浏览器中驱动网络的技术。Vaadin 抽象出了 Web 平台中的许多概念,但当您需要进入下一个级别时,它不会妨碍您。

Vaadin 10 中引入了元素 API,以允许在浏览器中对 DOM 进行直接的服务器端操作。实际上,Element API 是一组 Java 类,包含读取、创建和修改网页中 HTML 元素的方法。

Vaadin 还包括在从服务器调用的浏览器中执行 JavaScript 的功能。它让您可以访问 HTML 历史 API,并获得浏览器配置的详细信息(供应商、版本、底层操作系统)和更多功能。

创建 DOM 元素

让我们直接进入代码。下面是如何创建一个<div>元素,而不需要输入任何 HTML 代码,只需要服务器端 Java:

@Route("creating-dom-elements")
public class CreatingDomElementsView extends Div {
}

Vaadin 包括诸如DivSpanH1H2H3等类,以及许多其他可以用作 UI 组件起点的类。如果您在浏览器中检查 DOM,您会看到它包含以下内容:

...
<div id="outlet">
  <flow-container-root-2521314 id="ROOT-2521314" style="">
    <div></div>
  </flow-container-root-2521314>
</div>
...

最里面的空<div>是我们的。我们用自己的双手创建了它(显然是通过代码)。为了证明这一点,让我们定义它的id属性:

@Route("creating-dom-elements")
public class CreatingDomElementsView extends Div {

  public CreatingDomElementsView () {
    Element div = getElement();
    div.setAttribute("id", "our-div");
  }

}

看看我们如何使用getElement()方法获得对元素的引用(div)。一个Element是浏览器中 HTML 元素的 Java 表示(在本例中是一个<div>元素)。准备好 Java 引用后,我们调用setAttribute(String, String)id属性设置为our-div。下面是我们在浏览器中看到的内容:

...
<div id="outlet">
  <flow-container-root-2521314 id="ROOT-2521314" style="">
    <div id="our-div"></div>
  </flow-container-root-2521314>
</div>
...

Note

<flow-container-root-X>元素是由 Vaadin 创建的,旁边还有页面中更多的元素和代码。这些都是 Vaadin 的客户端引擎需要的实现细节,用框架实现视图的时候不需要担心。

您不需要创建一个完整的类并扩展 Vaadin 提供的 HTML UI 组件之一来向页面添加更多元素。您可以创建Element类的新实例,并将它们附加到其他实例中。例如,我们可以向我们的<div>添加一个新的<span>元素,如下所示:

public CreatingDomElementsView () {
  Element div = getElement();
  div.setAttribute("id", "our-div");

  Element span = new Element("span");
  span.setText("Greetings from the low-level API!");
  div.appendChild(span);
}

Element(String)构造函数接收要创建的标签的名称。我们使用setText(String)方法设置了<span>元素的内部文本。浏览器中的 HTML 现在看起来像这样:

...
<flow-container-root-2521314 id="ROOT-2521314" style="">
  <div id="our-div">
    <span>Greetings from the low-level API!</span>
  </div>
</flow-container-root-2521314>
...

可以加个标题吗?没错。动手吧(结果见图 9-1 ):

img/506498_1_En_9_Fig1_HTML.jpg

图 9-1

用 Vaadin 的元素 API 实现的 UI

Element div = getElement();
div.setAttribute("id", "our-div");

Element h1 = new Element("h1");
h1.setText("Element API example");
div.appendChild(h1);

Element span = new Element("span");
span.setText("Greetings from the low-level API!");
div.appendChild(span);

我们可以用我们在前面章节中使用的高级 API(组件 API)来实现这个 UI。事实上,代码要短得多:

@Route("with-components")
public class WithComponentsView extends Div {

  public WithComponentsView() {
    setId("our-div");
    add(
        new H1("Component API example"),
        new Span("Greetings from the high-level API!")
    );
  }

}

这突出了使用组件 API 的优势。在你可能需要的情况下,元素 API 就在那里。也许你需要在 HTML 元素中设置一个组件 API 没有提供的属性。或者您正在创建一个新组件或集成一个现有组件。您总是可以从 Vaadin 中的任何一个Component获得一个代表浏览器中 HTML 元素(标签)的Element引用,包括您的定制组件。

Tip

Vaadin 包含了ElementFactory类和有用的静态方法来创建许多标准的 HTML 元素。例如,不是通过使用new Element("span")直接创建Element的实例来创建<span>元素,而是可以调用ElementFactory.createSpan()

创建自定义组件

如果您想要实现无缝连接到服务器端的新客户端 UI 组件,Element API 是一个有用的工具。我们来开发一个例子。

在前面的章节中,我们使用扩展来创建新的视图。同样的方法可以用来创建 UI 组件。您可以扩展DivSpanH1H2H3Input或其他在浏览器中呈现相应 HTML 元素的服务器端组件。另一个选择是使用@Tag注释。例如,如果我们想要创建一个定制的服务器端组件,在浏览器中呈现一个<img>元素,我们可以使用下面的代码:

@Tag("img")
public class PictureButton extends Component {
}

@Tag注释告诉 Vaadin,当您将组件的一个实例添加到一个布局中时,应该将哪个标签添加到浏览器中的 DOM 中:

var button = new PictureButton();
var layout = new VerticalLayout(button);

事实上,Vaadin 服务器端组件使用这个标签。例如,如果您检查它的类层次结构,您会发现Button类扩展了GeneratedVaadinButton,而后者又用

@Tag("vaadin-button")
...
public abstract class GeneratedVaadinButton ...

Note

vaadin-button标签是一个定制的 HTML 元素或 Web 组件。所有服务器端的 Vaadin 组件都作为 Web 组件实现。我们不会在本书中深入探讨 Web 组件。现在,知道 web 组件是一组 Web 标准就足够了,它允许开发人员定义新的 HTML 元素,如vaadin-button。可以在非 Java 应用中使用这些 Web 组件。有关这方面的更多细节,请参见 https://vaadin.com/components 中每个组件的 HTML 示例。

让我们回到我们正在开发的定制PictureButton组件。我们已经知道如何设置 HTML 元素的属性,所以让我们使用这些知识来设置<img>元素的src属性:

@Tag("img")
public class PictureButton extends Component {

  public PictureButton(String imageUrl) {
    getElement().setAttribute("src", imageUrl);
  }

}

我们可以创建一个使用这个组件的视图(见图 9-2 ):

img/506498_1_En_9_Fig2_HTML.jpg

图 9-2

用元素 API 实现的自定义组件

@Route("custom-component")
public class CustomComponentView extends Composite<Component> {

  @Override
  protected Component initContent() {
    var button = new PictureButton(
    "https://live.staticflickr.com/65535/51154022090_22fd569976_k.jpg");

    var layout = new VerticalLayout(button);
    layout.setAlignItems(Alignment.CENTER);
    return layout;
  }

}

好像图像太大了。图片中的生物是一个谜…

式样

Element API 包括Style类,允许您设置和取消设置 CSS 属性。您可以使用getStyle()方法获得对Style类的引用:

Style style = getElement().getStyle();

让我们使用这个对象在我们正在开发的PictureButton组件上设置一些 CSS 样式:

public PictureButton(String imageUrl) {
  getElement().setAttribute("src", imageUrl);

  Style style = getElement().getStyle();
  style.set("border", "1em solid #333");
  style.set("box-sizing", "border-box");
  style.set("box-shadow", "1em 1em 1em #777");
}

图 9-3 显示了浏览器中的结果,我一直向下滚动到右下角。谜团依然存在。

img/506498_1_En_9_Fig3_HTML.jpg

图 9-3

Style类设置 CSS 样式

Tip

boxing-sizing CSS 属性设置为border-box以允许图像周围的边框包含在元素的总宽度和高度中。如果没有它,您可能会在浏览器中看到不希望看到的水平滚动条。

Mixin 接口

通过使用Style类来设置宽度,我们可以马上解决这个谜:

style.set("width", "100%");

或者我可以在截图之前调整浏览器窗口的大小。但是那太无聊了!用前面的代码片段设置宽度可以解决这个问题,但是不会给PictureButton类增加灵活性。如果一个视图需要不同的宽度或大小呢?我们可以重构代码,添加一个公共方法来设置宽度:

public void setWidth(String width) {
  getElement().getStyle().set("width", width);
}

我们将不得不做一些类似的事情来获取宽度,获取和设置高度,获取和设置最大宽度和高度,设置全尺寸、全宽度和未定义尺寸的快捷方式…听起来像是很多工作。因为添加这种 API 来管理组件的大小是很常见的,所以 Vaadin 包含了一个带有默认方法的接口,这些方法实现了我们所需要的。这个接口是统称为 mixin 接口的一组接口的一部分。下面是我们如何轻松地向PictureButton类添加调整大小的方法:

@Tag("img")
public class PictureButton extends Component implements HasSize {
  ...
}

还有许多其他可用的 mixin 接口,无论是否使用@Tag注释,您都可以在任何定制组件中使用它们。这些是其中的一些:

  • HasSize:组件尺寸

  • HasStyle:组件样式

  • ClickNotifier:鼠标点击事件

  • HasEnabled:启用或禁用元素

  • HasElement:获取底层Element实例

  • HasText:获取并设置文本内容

    注意你可以在 https://vaadin.com/vaadin-reference-card 找到更多细节和一个更长的 mixin 接口列表。

使用 mixin 接口时,我们不需要实现任何方法。将接口添加到implements声明足以启用这些特性。有了HasSize,我们现在可以调用我们对VerticalLayoutHorizontalLayout这样的组件使用过的调整方法。例如,我们可以如下设置一个PictureButton组件的宽度:

var button = new PictureButton(
"https://live.staticflickr.com/65535/51154022090_22fd569976_k.jpg");
button.setWidth("65%");

多亏了HasSize界面,我们才能揭开谜底。见图 9-4 。

img/506498_1_En_9_Fig4_HTML.jpg

图 9-4

实现HasSize mixin 接口的定制组件

Note

如果你对图 9-4 中的生物(他也出现在图 4-26 中)感到好奇,他的名字叫德拉科——一只有趣、友好、有时狂热的英国牛头犬,喜欢晒太阳,透过他最喜欢的窗户看人,还喜欢打呼噜。

处理事件

为了处理与 web 浏览器中元素的交互,Element API 包含了addEventListener(String, DomEventListener)方法。第一个参数指定要处理的 DOM 事件的名称。例如,我们可以向Element添加一个点击监听器,如下所示:

getElement().addEventListener("click", event -> {
  ... server-side logic here ...
});

Note

有许多事件类型,不可能在本书中一一列举。要探索选项,请前往 https://developer.mozilla.org/en-US/docs/Web/Events

我们可以用这个方法给PictureButton组件添加一个按钮效果。当用户在图像上按下鼠标按钮时,我们可以移除阴影并缩小组件的大小,以产生按钮被按下的效果。

@Tag("img")
public class PictureButton extends Component implements HasSize {

  public PictureButton(String imageUrl) {
    getElement().setAttribute("src", imageUrl);

    Style style = getElement().getStyle();
    style.set("border", "1em solid #333");
    style.set("box-sizing", "border-box");

    String shadow = "1em 1em 1em #777";
    style.set("box-shadow", shadow);

    getElement().addEventListener("mousedown", event -> {
      style.set("transform", "scale(0.93)");
      style.remove("box-shadow");
    });
  }

}

我们使用 CSS 转换来缩小<img>元素的比例,并删除其box-shadow属性。当mousedown事件在元素上发生时,运行这个逻辑。该事件不同于click事件。当鼠标按钮被按下但在放开之前,触发mousedown事件。在完全单击鼠标按钮后,触发click事件。图 9-5 显示了mousedown事件触发时的组件。

img/506498_1_En_9_Fig5_HTML.jpg

图 9-5

处理一个mousedown事件

此时,PictureButton保持被按下的状态(没有阴影效果,尺寸缩小),所以不能再进行点击。当用户释放按钮时,我们需要重置元素的阴影并缩放回原始状态。这可以通过为mouseup事件添加一个监听器来实现:

getElement().addEventListener("mouseup", event -> {
  style.set("transform", "scale(1)");
  style.set("box-shadow", shadow);
});

这里仍然有一个边缘案例。如果用户在图像上按下鼠标按钮,将指针拖出图像,然后释放鼠标按钮,按钮将保持按下状态。为了解决这个问题,我们需要运行我们在mouseup上运行的相同逻辑,但是这一次,当鼠标指针离开图像时(pointerleaves)。由于逻辑是相同的,我们可以将事件监听器分配给一个变量,并将其用于mouseuppointerleaves事件:

DomEventListener listener = event -> {
  style.set("transform", "scale(1)");
  style.set("box-shadow", shadow);
};
getElement().addEventListener("mouseup", listener);
getElement().addEventListener("pointerleave", listener);

没有添加外部点击监听器选项的按钮不是一个好按钮。我们希望让PictureButton类的客户端添加一个监听器,这样它们就可以对点击事件做出反应。为此,我们可以接受一个SerializableConsumer形式的监听器,并在点击事件触发时调用它(通过调用它的accept(T)方法)。作为参考,下面是PictureButton类的完整实现,包括添加外部点击监听器的可能性:

@Tag("img")
public class PictureButton extends Component implements HasSize {

  public PictureButton(String imageUrl,
      SerializableConsumer<DomEvent> clickListener) {

    getElement().setAttribute("src", imageUrl);

    Style style = getElement().getStyle();
    style.set("border", "1em solid #333");
    style.set("box-sizing", "border-box");

    String shadow = "1em 1em 1em #777";
    style.set("box-shadow", shadow);

    getElement().addEventListener("click", clickListener::accept)
        .addEventData("event.clientX")
        .addEventData("event.clientY");

    getElement().addEventListener("mousedown", event -> {
      style.set("transform", "scale(0.93)");
      style.remove("box-shadow");
    });

    DomEventListener listener = event -> {
      style.set("transform", "scale(1)");
      style.set("box-shadow", shadow);
    };
    getElement().addEventListener("mouseup", listener);
    getElement().addEventListener("pointerleave", listener);

  }

}

看看我们是如何在添加事件监听器之后使用addEventData(String)方法添加事件数据的。在这种情况下,我们感兴趣的是获取点击事件发生时的水平和垂直坐标。下面是使用该组件的视图的完整实现:

@Route("custom-component")
public class CustomComponentView extends Composite<Component> {

  @Override
  protected Component initContent() {
    var button = new PictureButton(
        "https://live.staticflickr.com/65535/51154022090_22fd569976_k.jpg",
        event -> {
          JsonObject data = event.getEventData();
          var x = data.getNumber("event.clientX");
          var y = data.getNumber("event.clientY");
          Notification.show("Clicked at " + x + ", " + y);
        });
    button.setWidth("65%");

    var layout = new VerticalLayout(button);
    layout.setAlignItems(Alignment.CENTER);
    return layout;
  }

}

我们使用getEventData()方法从事件中获取数据,使用JsonObject类的getNumber(String)方法获取特定值。图 9-6 显示了一个click事件被触发后的组件(截图中的坐标与狗的鼻子相匹配,以防你想知道我点击了哪里)。

img/506498_1_En_9_Fig6_HTML.jpg

图 9-6

服务器端点击监听器

Caution

确保在读取值时使用JsonObject类中正确的 getter。例如,clientX属性是一个数值,所以您必须使用getNumber(String)方法。如果值是BooleanString,分别使用getBoolean(String)getString(String)

JavaScript 集成

Vaadin 应用可以在浏览器中集成 JavaScript 代码。这种集成允许您在浏览器中从服务器端 Java 调用 JavaScript 函数,从 JavaScript 调用服务器端 Java 方法。简而言之,有两种机制可以实现这一点:

  • PageElement类中的executeJs(String, Serializable...)方法,用于调用浏览器中运行的 JavaScript 表达式

  • 用于从浏览器调用服务器中的方法的@ClientClickable注释和element.$server JavaScript 对象

向 Vaadin 应用添加 JavaScript

您可以将自己的 JavaScript 文件添加到 Vaadin 项目中的两个位置,具体取决于您使用的打包方式:

  • 对于 JAR 包,使用 PROJECT_ROOT/frontend/

  • 对于 WAR 包,使用PROJECT _ ROOT/src/main/resources/META-INF/resources/frontend/

您可以为文件创建任何子目录结构。

Note

Vaadin(使用一个名为 Webpack 的工具)处理 frontend/ 目录中的文件,生成一个单独的,其中包含您的应用需要的所有客户端依赖项。

例如,让我们创建一个新的 JavaScript 文件,PROJECT _ ROOT/frontend/script . js,内容如下:

alert("Hello there! It's me. The script!");

我们可以将该文件包含在 Vaadin 视图中,如下所示:

@Route("javascript-integration")
@JsModule("script.js")
public class JavascriptIntegrationView extends Div {
}

很容易认为 script.js JavaScript 文件只有在浏览器请求JavascriptIntegrationView时才被加载。然而,正如您在图 9-7 的截图中看到的,当您请求应用的任何视图时,该文件被加载(截图中默认的视图)。如果您记得文件被编译成一个包,这就很容易理解了。

img/506498_1_En_9_Fig7_HTML.jpg

图 9-7

浏览器中加载的 JavaScript 文件

从 Java 调用 JavaScript

让我们开发一个显示按钮和图像的视图。当用户单击该按钮时,图像可见性被切换(可见/不可见)。让我们从管道开始:

@Route("javascript-integration")
@JsModule("script.js")
public class JavascriptIntegrationView extends Div {

  public JavascriptIntegrationView() {

      var image = new Image(
            "https://live.staticflickr.com/65535/51154022090_22fd569976_k.jpg",
            "dog");
      image.setMaxWidth("100%");

      Button button = new Button("Toggle visibility", event -> {
      });

      add(button, image);
      getElement().getStyle().set("display", "grid");
      getElement().getStyle().set("padding", "1em");
      getElement().getStyle().set("max-width", "700px");
  }

}

这里没什么新鲜的。结果如图 9-8 所示。

img/506498_1_En_9_Fig8_HTML.jpg

图 9-8

用于切换图像可见性的 UI

我们可以使用本书前几章介绍的组件来开发 UI 的所有功能。事实上,如果您正在实现这样的视图,我会建议您这样做。然而,我们在这里学习与 Web 平台的集成,因此我们将要实现的内容将帮助您了解如何集成现有的 JavaScript 组件和库,以及实现 Vaadin 核心可能无法提供的现成功能。

回到代码。如果您还记得之前的实验,脚本是在视图加载后立即执行的。相反,我们希望在点击按钮时运行一个 JavaScript 函数。下面是 script.js 文件中实现函数的错误方式:

function toggle() {
  ...
}

这不正确的原因与该函数的创建和调用范围有关。我们需要确保我们了解范围。实现这一点的一个简单方法是通过将一个 JavaScript 对象添加到 DOM 中的一个众所周知的对象来创建一个名称空间:

window.ns = {
  toggle: function() {
    ...
  }
}

该脚本将名为ns(您可以使用任何名称)的对象添加到浏览器中始终存在的window对象中。在这个对象中,我们可以定义 JavaScript 函数,调用如下:

ns.toggle();

下面是如何从 Vaadin 视图调用它:

UI.getCurrent().getPage().executeJs("ns.toggle()");

我们可以在 JavaScript 函数中接受参数。例如,我们可能需要想要显示/隐藏的 HTML 元素:

window.ns = {
  toggle: function(element) {
    ...
  }
}

从 Java 向函数传递一个参数如下所示:

UI.getCurrent().getPage().executeJs("ns.toggle($0)",
    image);

提醒一下,image对象是一个类型为Image的 Vaadin 组件。进行调用时,子字符串$0被替换为image

为了实现切换功能,我们可以使用现有的库——jQuery。我们可以下载库文件并将其放入项目中,但是 jQuery 也是通过内容交付网络(CDN)提供的,这实际上意味着我们可以获得托管在公共服务器上的 JavaScript 文件的链接。这对于我们的目的来说很实用,所以让我们将它添加到应用中:

@Route("javascript-integration")
@JsModule("script.js")
@JsModule("https://code.jquery.com/jquery-3.6.0.min.js")
public class JavascriptIntegrationView extends Div {
  ...
}

现在我们可以在script.js文件中使用 jQuery:

window.ns = {
  toggle: function(element) {
    jQuery(element).fadeToggle();
    return `Toggled at ${new Date().toLocaleString()}`;
  }
}

表达式jQuery(element).fadeToggle()是库中可用的众多函数之一。它使用渐隐动画隐藏或显示选定的元素(element)。如果您想看到淡入淡出的效果,您必须运行示例应用。

我们在浏览器中返回函数被调用的时间,只是为了学习如何在 Java 端使用返回值。下面是对该函数的调用以及如何使用返回值:

Button button = new Button("Toggle visibility", event -> {
  UI.getCurrent().getPage()
      .executeJs("return ns.toggle($0)", image)
      .then(value -> Notification.show(value.asString()));
});

因为对 JavaScript 函数的调用是异步的,所以我们必须使用then(SerializableConsumer)方法在返回值可用时使用它。

从 JavaScript 调用 Java

我们还可以从 JavaScript 函数调用服务器中的 Java 方法。例如,假设我们想要实现一个在客户端处理的 click listener,并从中调用一个服务器端方法。为了进行设置,我们可以在script.js文件中添加一个init方法:

window.ns = {
  init: function(element, view) {
  },

  toggle: function(element) {
    jQuery(element).fadeToggle();
    return `Toggled at ${new Date().toLocaleString()}`;
  }
}

我们可以从 Java 调用这个方法,如下所示:

public JavascriptIntegrationView() {
  ...

  UI.getCurrent().getPage()
      .executeJs("return ns.init($0, $1)", image, this);

  ...
}

当请求视图时,init(element, view)函数只被调用一次。我们正在传递我们想要初始化的元素(image)和视图本身。稍后我们可以使用view对象从脚本中调用 Java 方法。但是首先,让我们添加服务器端的方法。这个方法应该用@ClientCallable注释:

...
public class JavascriptIntegrationView extends Div {

  ...

  @ClientCallable
  public void showClickNotification(Integer x, Integer y) {
      var message = String.format("Clicked at %d, %d", x, y);
      Notification.show(message, 3000, Position.BOTTOM_END);
  }

}

现在,我们可以实现这个函数,看看如何从它调用 Java 方法:

window.ns = {
  init: function(element, view) {
    element.onclick = event =>
        view.$server.showClickNotification(event.clientX,
            event.clientY);
  },

  ...
}

$server对象是由 Vaadin 添加的。有了这个对象,我们就可以在相应的 Java 类中调用标有@ClientCallable的方法。图 9-9 显示了点击切换按钮后的结果和图像本身。

img/506498_1_En_9_Fig9_HTML.jpg

图 9-9

运行中的自定义 JavaScript 组件

摘要

本章为您提供了实现 Vaadin 核心中不包含的功能所需的工具。您了解了如何使用 Element API 在浏览器中创建和操作 HTML 元素。您看到了这个 API 如何允许您设置 CSS 样式。您还了解了如何在应用中包含 JavaScript 文件,以及如何从服务器端 Java 方法调用浏览器中的 JavaScript 函数,以及如何从浏览器中的 JavaScript 函数调用服务器端 Java 方法。

十、定制样式和响应能力

在第一章中,你学习了层叠样式表(CSS)的基础知识,以及如何编写规则来改变 HTML 文档的外观。Vaadin 不仅允许你通过元素 API 使用 CSS(正如你在第九章中所学的),还允许你在单独的中使用 CSS。可以添加到项目中的 css 文件。

除了 CSS 之外,Vaadin 还包括一些组件,可以简化快速实现响应式 ui。响应式 UI 根据呈现它的屏幕的大小来调整它的结构。当你想支撑宽度窄、高度长的手机等设备时,这是很有用的。

内置主题

Vaadin 管理主题中的应用样式。主题是一组 CSS 文件和相关资源,如字体和图像,它们定义了应用的外观。诸如主要的背景和前景颜色、字体、间距以及 UI 如何适应不同的视图大小之类的东西是在构成主题的 CSS 规则中定义的。Vaadin 有两个主题:

  • Lumo: 默认主题。我们已经在前几章的例子中使用了这个主题。

  • 材质:一个基于谷歌材质设计的 Vaadin 主题。访问 https://material.io 了解更多材料设计信息。

这两种主题都有两种变体——亮和暗。

应用可以在实现了AppShellConfigurator接口的类中使用@Theme注释来定义主题。例如,下面的代码片段显示了如何激活材质主题(参见图 10-1 ):

img/506498_1_En_10_Fig1_HTML.jpg

图 10-1

物质主题

@Theme(themeClass = Material.class)
public class AppConfiguration implements AppShellConfigurator {
}

Tip

您可以使用@NoTheme注释停用默认的 Lumo 主题。当您想要完全控制加载的 CSS 文件并从头开始设计应用时,这很有用。如果 Vaadin 没有找到@Theme@NoTheme注释,默认使用 Lumo 主题。

使用主题变体

像主题一样,主题变体是一组 CSS 文件和相关资源。不同之处在于,每个应用只能有一个主题,而可以有多个变体,其中一个每次都是活动的。Lumo 和 Material 主题都包含两种变体。以下是如何激活 Lumo 主题的黑暗版本(见图 10-2 ):

img/506498_1_En_10_Fig2_HTML.jpg

图 10-2

Lumo 主题的黑暗变体

@Theme(themeClass = Lumo.class, variant = Lumo.DARK)
public class AppConfiguration implements AppShellConfigurator {
}

Note

如果你一直在尝试这本书的例子或者编写你自己的 Vaadin 应用,试着改变它们的活动主题。就像加个注释那么简单!尝试材质主题的黑暗变体,看看它是什么样子的。请记住,不可能有多个主题。可以有几个主题变体(甚至是自定义的)并在运行时改变它们(见 https://vaadin.com/learn/tutorials/toggle-dark-theme )。

使用组件变体

几个 Vaadin 组件包括主题变体。组件主题变体只影响包含该变体的组件。例如,您可以通过添加ButtonVariant.LUMO_PRIMARY变体使按钮看起来更突出:

Button button = new Button("Primary ");
button.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

图 10-3 显示了具有不同主题变量的几个按钮和文本字段。

img/506498_1_En_10_Fig3_HTML.jpg

图 10-3

组件主题变体

Tip

使用 IDE 的自动完成功能探索可用的变体。

使用 CSS 设置样式

根据我们目前所看到的,我们有四种 Vaadin 应用的可能样式——两种主题,每种主题有两种变体。现在让我们看看如何通过添加修改可用主题的自定义 CSS 规则来扩展选项。

导入 CSS 文件

我们在第九章中看到了如何使用元素 API 为 UI 中的特定组件或 HTML 元素设置自定义 CSS 属性。例如,我们可以给一个Div组件添加一个带阴影的边框,如下所示:

Div div = new Div();
Style style = div.getStyle();
style.set("border", "1em solid #333");
style.set("box-shadow", "1em 1em 1em #777");

这是改变 UI 组件样式的一种快速简单的方法。但是,当您想要改变应用的整体外观时,最好拥有可以被多个视图使用的单独的 CSS 文件。

让我们看看如何向 Vaadin 应用添加自定义 CSS 规则。图 10-4 显示了一个简单的视图,有一个H1,一个TextField,一个Button,没有自定义 CSS。

img/506498_1_En_10_Fig4_HTML.jpg

图 10-4

具有默认主题和样式的视图

开发人员经常问我的一个问题是如何减少H1组件前后的空间。这是可以理解的,因为通常一个H1组件是一个 UI 的视图或部分中最顶层的组件,并且可能有必要优化空间使用。幸运的是,使用 CSS 很容易调整这一点:

h1 {
  margin-top: 0.15em;
  margin-bottom: 0;
}

我们可以将这个 CSS 放在前端/ 目录中的一个文件中,使用我们想要的任何文件名(例如 custom-styles.css ),并使用@CssImport注释将它加载到一个视图中:

@Route("custom-css")
@CssImport("./custom-styles.css")
public class CustomCss extends Composite<Component> {
}

我们可以使用元素 API 并为H1组件设置 CSS 属性,但是将样式放在一个单独的文件中允许我们更容易地重用应用中每个H1组件的样式。图 10-5 显示了结果。

img/506498_1_En_10_Fig5_HTML.jpg

图 10-5

添加到视图中的自定义 CSS 文件

Caution

使用@CssImport注释导入的文件包含在应用包中。这意味着一个视图会受到其他视图导入的 CSS 文件内容的影响。

使用 Lumo 主题属性

Lumo 主题包括一组 CSS 属性(或变量),允许快速对主题进行常规更改。这些属性可以被视为主题的参数,并在适用时调整所有组件的样式。

Tip

CSS 属性以--开头。

您可以在一个 CSS 文件中设置 Lumo 主题所使用的 CSS 属性的值,这个 CSS 文件可以使用前面小节中所示的@CssImport注释来导入。以下示例显示了如何更改 UI 组件的字体和圆度(参见图 10-6 中的结果):

img/506498_1_En_10_Fig6_HTML.jpg

图 10-6

用 CSS 属性自定义 Lumo 主题

html {
  --lumo-font-family: "Courier New", Courier, monospace;
  --lumo-border-radius: 0px;
}

Lumo 主题中定义了许多 CSS 属性。清单 10-1 展示了一些可用属性的例子。

html {
  --lumo-font-family: "Courier New", Courier, monospace;
  --lumo-font-size: 1rem;
  --lumo-font-size-xxxl: 3rem;
  --lumo-font-size-xxl: 2.25rem;
  --lumo-font-size-xl: 1.75rem;
  --lumo-font-size-l: 1.375rem;
  --lumo-font-size-m: 1.125rem;
  --lumo-font-size-s: 1rem;
  --lumo-font-size-xs: 0.875rem;
  --lumo-font-size-xxs: 0.8125rem;
  --lumo-line-height-m: 1.4;
  --lumo-line-height-s: 1.2;
  --lumo-line-height-xs: 1.1;
  --lumo-border-radius: 0px;
  --lumo-size-xl: 4rem;
  --lumo-size-l: 3rem;
  --lumo-size-m: 2.5rem;
  --lumo-size-s: 2rem;
  --lumo-size-xs: 1.75rem;
  --lumo-space-xl: 1.75rem;
  --lumo-space-l: 1.125rem;
  --lumo-space-m: 0.5rem;
  --lumo-space-s: 0.25rem;
  --lumo-space-xs: 0.125rem;
  --lumo-shade-5pct: rgba(26, 26, 26, 0.05);
  --lumo-shade-10pct: rgba(26, 26, 26, 0.1);
  --lumo-shade-20pct: rgba(26, 26, 26, 0.2);
  --lumo-shade-30pct: rgba(26, 26, 26, 0.3);
  --lumo-shade-40pct: rgba(26, 26, 26, 0.4);
  --lumo-shade-50pct: rgba(26, 26, 26, 0.5);
  --lumo-shade-60pct: rgba(26, 26, 26, 0.6);
  --lumo-shade-70pct: rgba(26, 26, 26, 0.7);
  --lumo-shade-80pct: rgba(26, 26, 26, 0.8);
  --lumo-shade-90pct: rgba(26, 26, 26, 0.9);
  --lumo-primary-text-color: rgb(235, 89, 5);
  --lumo-primary-color-50pct: rgba(235, 89, 5, 0.5);
  --lumo-primary-color-10pct: rgba(235, 89, 5, 0.1);
  --lumo-error-text-color: rgb(231, 24, 24);
  --lumo-error-color-50pct: rgba(231, 24, 24, 0.5);
  --lumo-error-color-10pct: rgba(231, 24, 24, 0.1);
  --lumo-success-text-color: rgb(62, 229, 170);
  --lumo-success-color-50pct: rgba(62, 229, 170, 0.5);
  --lumo-success-color-10pct: rgba(62, 229, 170, 0.1);
  --lumo-shade: hsl(0, 0%, 10%);
  --lumo-primary-color: hsl(22, 96%, 47%);
  --lumo-error-color: hsl(0, 81%, 50%);
  --lumo-success-color: hsl(159, 76%, 57%);
  --lumo-success-contrast-color: hsl(159, 29%, 10%);
}

Listing 10-1A custom Vaadin theme based on Lumo properties

Note

解释每个属性超出了本书的范围。这些特性在 https://vaadin.com/docs/latest/ds/foundation 的官方文档中有详细记载。在撰写本文时,在 https://demo.vaadin.com/lumo-editor 有一个在线主题编辑器。

向 UI 组件添加 CSS 类

您可以向任何组件添加 CSS 类来设置组件的样式。例如:

Div div = new Div();
div.addClassName("styled-div");

以及相应的 CSS 规则:

.styled-div {
  border: 1px solid red;
}

随着应用的增长,您需要为自己定义的 CSS 类制定一致的约定。提高可维护性的一个好方法是在 CSS 类名中使用 Java 类名(视图)。假设我们有以下观点:

@Route("css-classes")
public class CssClassesView extends Composite<Component> {

  @Override
  protected Component initContent() {
    var header = new Div(VaadinIcon.VAADIN_H.create(),
        new H1("Title"),
        new Anchor("https://vaadin.com?utm_source=apressbook",
            "Log out"));

    Grid<String> grid = new Grid<>(String.class);
    grid.setItems("item1", "item2", "item3", "");

    var content = new Div(grid);

    var layout = new Div();
    layout.add(header, content);

    return layout;
  }

}

我们将组件分组为连贯的部分(使用Div类),但除此之外,我们并不“关心”视图在浏览器中呈现时的样子。图 10-7 显示确实如此!

img/506498_1_En_10_Fig7_HTML.jpg

图 10-7

没有 CSS 样式的视图

然而,如果我们将 CSS 类名添加到重要的部分,我们——或者更好,一个掌握 CSS 的网页设计师——可以完全改变视图的外观。我们将在 CSS 类的名称中使用 Java 类的名称(CssClassesView),附加一个适当的字符串来区分我们想要样式化的组件:

layout.addClassName(getClass().getSimpleName());
header.addClassName(getClass().getSimpleName() + "-header");
content.addClassName(getClass().getSimpleName() + "-content");

在浏览器中,这些组件呈现为

<div class="CssClassView">
...
 <div class="CSSClassView-header">
...
<div class="CssClassView-content">
...

我们可以如下加载一个新的 CSS 文件:

@Route("css-classes")
@CssImport("./custom-classes.css")
public class CssClassesView extends Composite<Component> {
  ...
}

最后,我们可以在 custom-classes.css 文件中设置视图的样式。下面是一个后端 Java 开发者的尝试(结果见图 10-8 ):

img/506498_1_En_10_Fig8_HTML.jpg

图 10-8

使用 CSS 类设置视图样式

.CssClassesView {
    display: flex;
    flex-direction: column;
}

.CssClassesView-header {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    background: var(--lumo-primary-text-color);
    color: var(--lumo-primary-contrast-color);
    padding-left: 1em;
    padding-right: 1em;
}

.CssClassesView-header h1 {
    color: var(--lumo-primary-contrast-color);
}

.CssClassesView-header a {
    color: var(--lumo-primary-contrast-color);
}

样式阴影 DOM

Vaadin 组件作为 Web 组件实现。Web 组件是封装在单个可重用单元中的一组 HTML 资源。例如,Button在浏览器中呈现为<vaadin-button>。Web 组件包括影子 DOM ,它是不会“污染”页面中 HTML 文档的 HTML。这意味着 Web 组件中的样式不会泄露给页面的其他部分,页面中的样式也不会影响 Web 组件。您仍然可以设计 Web 组件的样式,但是您需要以一种特殊的方式来完成。

假设我们想要改变上一节示例中的Grid的头部样式。如果我们检查浏览器中的 DOM,我们会看到在一个<vaadin-grid> Web 组件的影子 DOM 中有一个<th>元素。我们可以使用下面的 CSS 选择器来设置这个元素的样式:

:host th {
  background: var(--lumo-primary-color-10pct);
}

:host选择阴影 DOM。我们在 Web 组件的影子 DOM 中选择了<th>元素。哪个 Web 组件?我们在@CssImport注解中回答了这个问题。假设我们将前面的 CSS 规则放在一个名为 vaadin-grid.css 的文件中(可以使用任何名称)。当我们加载这个文件时,我们可以指定想要样式化的 Vaadin Web 组件(图 10-9 显示了结果):

img/506498_1_En_10_Fig9_HTML.jpg

图 10-9

设计 Vaadin Web 组件的样式

...
@CssImport(value = "./vaadin-grid.css", themeFor = "vaadin-grid")
public class CssClassesView extends Composite<Component> {
  ...
}

响应式网页设计

响应式网页设计是利用技术使布局和组件适应不同的设备。在不同的屏幕尺寸下使用时,响应式 web 应用会改变它们的布局。

Vaadin 提供了一些特殊的组件来简化响应式 ui 的实现。当这些组件不符合您的要求时,您可以随时使用 CSS 来实现您的目标。

形状布局

FormLayout组件使得在根据屏幕宽度变化的许多列中显示其他组件变得容易。它还将输入组件的标签放在组件的顶部,而不是旁边。事实上,我们在图 10-3 中使用了FormLayout。下面是实现(请注意构造函数的结尾):

@Route("form-layout")
public class FormLayoutView extends Composite<Component> {

  @Override
  protected Component initContent() {
    Button increaseRadiation = new Button("Increase radiation",
        VaadinIcon.ARROW_UP.create());
    increaseRadiation
        .addThemeVariants(ButtonVariant.LUMO_ERROR);
    Button shutDownCooling = new Button("Shutdown cooling",
        VaadinIcon.POWER_OFF.create());
    shutDownCooling
        .addThemeVariants(ButtonVariant.LUMO_SUCCESS);
    NumberField temperature = new NumberField("Temperature");
    temperature
        .addThemeVariants(TextFieldVariant.LUMO_ALIGN_CENTER);
    NumberField pressure = new NumberField("Pressure");
    pressure
        .addThemeVariants(TextFieldVariant.LUMO_ALIGN_CENTER);
    NumberField hydrogen = new NumberField("Hydrogen");
    hydrogen
        .addThemeVariants(TextFieldVariant.LUMO_ALIGN_CENTER);
    NumberField oxygen = new NumberField("Oxygen");
    oxygen.addThemeVariants(TextFieldVariant.LUMO_ALIGN_CENTER);
    DatePicker shutdownDate = new DatePicker("Shutdown date");
    Button update = new Button("Update reactor",
        VaadinIcon.WARNING.create());
    update.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

    FormLayout form = new FormLayout(increaseRadiation,
        shutDownCooling, temperature, pressure, hydrogen,
        oxygen, shutdownDate);

    VerticalLayout layout = new VerticalLayout(
        new H1("Nuclear Reactor"), form, update);
    layout.setAlignItems(Alignment.CENTER);
    layout.setAlignSelf(Alignment.END, update);
    return layout;
  }

}

仅仅通过向一个FormLayout添加输入组件,我们就可以在宽屏中得到两列,在窄屏中得到一列。图 10-10 显示了狭窄窗口中的视图。

img/506498_1_En_10_Fig10_HTML.jpg

图 10-10

有反应的形式

您可以配置用于特定最小宽度的列数。这里有一个例子:

form.setResponsiveSteps(
    new ResponsiveStep("1px", 1),
    new ResponsiveStep("600px", 2),
    new ResponsiveStep("800px", 3)
);

如果一个视窗(屏幕)的宽度大于等于 800 像素,表单将显示三列。如果宽度为 600 像素,则使用两列,依此类推。图 10-11 以宽屏显示表单。

img/506498_1_En_10_Fig11_HTML.jpg

图 10-11

自定义响应步骤

应用布局

组件为 web 应用提供了一种流行的布局样式。它包括一个共享的导航条(表头)抽屉(菜单),以及内容区。该组件实现了RouterLayout,因此您可以在多个视图中使用该布局。要将其用作路由器布局,您需要扩展AppLayout。以下示例显示了如何向导航栏添加一个徽标,并向抽屉添加一个Tabs组件:

public class BusinessAppLayout extends AppLayout {

  public BusinessAppLayout() {
    Image logo = new Image("https://i.imgur.com/GPpnszs.png",
        "Vaadin Logo");
    logo.setHeight("44px");
    addToNavbar(new DrawerToggle(), logo);

    Tabs tabs = new Tabs(new Tab("Home"), new Tab("CRM"),
        new Tab("Financial"), new Tab("Marketing"),
        new Tab("Sales"), new Tab("Inventory"),
        new Tab("Manufacturing"), new Tab("Supply chain"),
        new Tab("HR"));
    tabs.setOrientation(Tabs.Orientation.VERTICAL);
    addToDrawer(tabs);
  }

}

您可以使用addToNavbar(Component...)addToDrawer(Component...)方法向每个部分添加组件。您的应用中的视图可以使用如下布局(参见图 10-12 ):

img/506498_1_En_10_Fig12_HTML.jpg

图 10-12

使用由AppLayout实现的路由器布局的视图

@Route(value = "app-layout", layout = BusinessAppLayout.class)
public class AppLayoutView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new VerticalLayout(new H1("Business Application"),
        new Button("Let's do business!",
            event -> Notification.show("Success!")));
  }

}

AppLayout组件响应迅速。如果您调整浏览器窗口的大小,您会看到抽屉会相应地显示或隐藏。用户可以通过点击视图左上角的DrawerToggle来切换抽屉的可见性。图 10-13 在一个更小的窗口中显示了相同的视图。

img/506498_1_En_10_Fig13_HTML.jpg

图 10-13

AppLayout在小视窗中隐藏抽屉

CSS 媒体查询

CSS 允许你针对不同的屏幕尺寸有选择地应用样式。当我们开发一个带有菜单和内容区域的响应式视图时,让我们来看看它的实际应用:

@Route("css")
@CssImport("./styles.css")
public class CssView extends Composite<Component> {

  @Override
  protected Component initContent() {
    Div menu = new Div(new RouterLink("Option 1", getClass()),
        new RouterLink("Option 2", getClass()),
        new RouterLink("Option 3", getClass()));

    menu.addClassName(getClass().getSimpleName() + "-menu");

    Div content = new Div(new H1("Hello!"), new Paragraph(
        "Try resizing the window to see the effect in the UI"));
    content
        .addClassName(getClass().getSimpleName() + "-content");

    Div layout = new Div(menu, content);
    layout.addClassName(getClass().getSimpleName());
    return layout;
  }

}

我们在重要的部分添加了 CSS 类名,并导入了 styles.css 文件。让我们使用移动优先的方法,并设计应用的样式,使其在小屏幕上看起来不错。目前,这是视图在所有屏幕尺寸下的外观。下面是 CSS(图 10-14 显示结果):

.CssView {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.CssView-menu {
  display: flex;
  flex-direction: row;
  background-color: var(--lumo-primary-color-10pct);
}

.CssView-menu a {
  margin-left: 1em;
  white-space: nowrap;
}

.CssView-content {
  margin-left: 1em;
}

由于我们的目标是最有可能以纵向模式(高度大于宽度)使用的移动设备,因此在一列中显示组件是有意义的。这就是我们在视图的 flex 显示中设置列方向的原因。该菜单被配置为一个灵活的行,以便所有选项以水平方式显示在屏幕顶部。我们还在每个选项(<a>元素)的左边添加了一个边距,并配置了nowrap以避免在某些浏览器中显示多行文本。对于内容区域,我们添加了一个小的边距,将它与视图的边界分开。

img/506498_1_En_10_Fig14_HTML.jpg

图 10-14

移动优先的设计

随着移动版本的准备就绪和默认设置,我们可以通过添加 CSS 媒体查询来针对更大的屏幕。这些查询允许您根据显示页面的设备的特征应用样式。例如,我们可以将最小宽度为 800 像素的屏幕作为目标,如下所示:

@media screen and (min-width: 800px) {
  .CssView {
    display: flex;
    flex-direction: row;
  }

  .CssView-menu {
    display: flex;
    flex-direction: column;
     padding: 1em;
  }

  .CssView-menu a {
    margin-bottom: 1em;
    margin-left: 0em;
  }
}

这些样式将覆盖在媒体查询之外设置的任何样式(即,我们已有的移动样式)。我们正在改变一些事情。首先,视图现在是一行而不是一列。我们可以在左侧显示菜单,而不是在大屏幕的顶部。第二,菜单中的选项是一列。第三,我们调整了选项的边距,在每个选项的底部增加了空间,并删除了我们为移动版本增加的左边空间(请记住,我们正在覆盖样式)。图 10-15 显示了一个更大的浏览器窗口对视图的影响。

img/506498_1_En_10_Fig15_HTML.jpg

图 10-15

使用 CSS 媒体查询的响应设计

摘要

这一章真的提高了你的 Vaadin 技能!您了解了 Vaadin 中可用的内置主题,以及如何使用主题和组件变体。您了解了如何通过导入自定义 CSS 文件来使用 CSS 设计应用的样式,以及如何使用 Lumo 主题属性快速调整外观。您看到了如何向单个 UI 组件添加 CSS 类,以及如何在 Vaadin Web 组件的 shadow DOM 中设置样式。

您还了解了使用FormLayoutAppLayout组件的响应式 web 设计,以及针对不同屏幕尺寸的 CSS 媒体查询。

下一章通过向您介绍 Vaadin 流——一种在 TypeScript 中实现视图的方法,继续探索客户端技术。

十一、使用 TypeScript 的客户端视图

在本书的前几章中,我们一直在编写 Java 代码。让我们休息一下,用另一种编程语言:TypeScript 来实现视图。

我要求流动与。我要求合并

术语 Vaadin 被广泛用于指代允许你用 Java 编写 web UIs 的服务器端类。但是,Vaadin 也允许您使用 TypeScript 编程语言实现 ui。在网上,你会找到诸如 Vaadin 平台、Vaadin 流、Vaadin 融合和(普通)Vaadin 之类的术语。让我们澄清一些定义:

  • Vaadin: 一套开发 Java web 应用的工具。这包括一个免费的开源 web 框架,这是一个生成新项目、文档和付费订阅的在线工具,除了免费提供的工具和服务之外,还有其他工具和服务。您可以使用 Java、TypeScript 或两者来实现 UI。通常,术语Vaadin用于指代Vaadin 流Vaadin 融合

  • Vaadin 平台:使用量下降的 Vaadin 的同义词。

  • Vaadin 流程:vaa din 的一部分,允许你用 Java 实现 ui。

  • va adin Fusion:va adin 的一部分,它允许您在 TypeScript 中实现 ui,本章将对此进行介绍。

Note

有人将 Vaadin Flow 和 Vaadin Fusion 称为 Vaadin 中的两个 web 框架。有些人使用了图书馆这个词。其他的,术语模块。我更愿意把它们看作是的特色。我把 Vaadin 流看成 Java,把 Vaadin Fusion 看成 TypeScript。

TypeScript 快速入门

TypeScript 是 JavaScript 的超集。每个 JavaScript 程序也是一个类型脚本程序。TypeScript 增加了静态类型并将程序编译成 JavaScript。

安装 TypeScript 编译器

如果您想在 Vaadin 应用中使用 TypeScript,您不必安装任何附加工具。然而,如果您想尝试下一节中的代码,您将需要 Node.js 。您可以在 https://nodejs.org 下载安装程序。Node.js 包含了NPM—一个管理 JavaScript 包的工具。您可以通过运行以下命令来检查该工具是否正常工作,并确认您获得了作为输出报告的版本:

> npm --version

使用 npm ,您可以安装 TypeScript 包,如下所示:

> npm install --global typescript

这个包包括一个 TypeScript 编译器。您可以通过运行以下命令来检查编译器是否准备好,并确认您得到了一个版本报告:

> tsc –version
Version 4.2.4

在 TypeScript 中实现“Hello,World”

TypeScript 中的“Hello,World”需要一行代码:

console.log("Hello, World!");

如果我们将这一行放到一个名为 hello.ts 的文件中,我们可以使用下面的命令来编译它:

> tsc hello.ts

默认情况下,TypeScript 编译器在同一目录中创建新的 hello.js 文件。要运行程序,我们可以执行以下命令:

> node hello.js

我们应该在终端中得到预期的输出:

Hello, World!

让我们与 Java 世界做一个比较。Java 编译器 javac 接受一个。java 源文件并产生一个。类字节码文件。java 工具启动一个 JVM 并运行。类文件。在类型脚本方面,类型脚本编译器 tsc 接受一个。ts 源文件并产生一个。js JavaScript 文件。节点工具运行。js 文件。Node.js 是 JavaScript 的运行时,就像 web 浏览器一样。

静态打字

如果您有 Java 经验,阅读 TypeScript 代码是很容易的。看一下这个例子:

class Bicycle {
  private color: string;
  private speed: number = 0;
  private gear: number = 1;

  public constructor(color: string) {
    this.color = color;
  }

  public speedUp(increase: number): void {
    this.speed += increase;
  }

  public applyBreak(decrease: number): number {
    return this.speed -= decrease;
  }

  public changeGear(newGear: number): number {
    return this.gear = newGear;
}

public print(): void {
    console.log(
`${this.color} bicycle: ${this.speed} Km/h (${this.gear})`);
  }
}

我很确定你理解了这门课的每一点。我用一种 Java 开发人员尽可能容易理解的方式对它进行了编码,但是 TypeScript 包含了使代码更加简洁的特性。

Note

讲授 TypeScript 的所有特性超出了本书的范围。你可以在网上找到优秀的学习资源。例如,官方 TypeScript 网站( www.typescriptlang.org )包括文档和一本手册。如果你想深入学习这门语言及其生态系统,我还推荐亚当·弗里曼的基本打字稿

我们可以通过在文件末尾添加一些代码来使用Bicycle类(我已经将其命名为 bicycle.ts ):

class Bicycle {
  ...
}

let redBicycle = new Bicycle("red");
redBicycle.print();
redBicycle.speedUp(10);
redBicycle.changeGear(2);
redBicycle.speedUp(10);
redBicycle.changeGear(3);
redBicycle.speedUp(8);
redBicycle.applyBreak(5);
redBicycle.print();

我们可以编译该文件,运行它,并查看输出:

> tsc bicycle.ts
> node bicycle.js
red bicycle: 0 Km/h (1)
red bicycle: 23 Km/h (3)

与普通 JavaScript 相比,TypeScript 代码的一个优点是它是类型安全的。如果我们在调用时在参数中使用了不正确的类型,比如说,speedUp函数错误地传递了一个字符串而不是一个数字,我们将看到一个编译错误:

> tsc bicycle.ts
bicycle.ts:38:20 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

38 redBicycle.speedUp("10");
                      ~~~~

Found 1 error.

类型安全语法在 TypeScript 中是可选的。如果需要,您可以随时随地使用普通 JavaScript。这使得在您的 TypeScript 程序中包含任何其他 JavaScript 库成为可能。

带 Lit 的 Web 组件

就像我们在第一章中看到的,Web 组件是一个具有封装的逻辑、样式和结构的定制元素。在客户端,Vaadin 组件被实现为 Web 组件。例如,一个Button在浏览器中被渲染为一个<vaadin-button>Lit 是一个用于实现 Web 组件的 JavaScript 库,它是使用 Vaadin Fusion 在 TypeScript 中创建客户端视图的基础。

创建新的 Lit 项目

为了帮助您使用 Lit 并试验您自己的 Web 组件,让我们用所需的最低配置创建一个新项目。该项目基于 npm,并将包括一个用于快速实验的网络服务器。我们可以从在硬盘上为项目创建一个新目录开始,命名为 lit-element/ (你可以使用任何你想要的名字)。在这个项目中,我们需要一个 package.json 文件,内容如下:

{
  "scripts": {
    "start": "tsc && wds"
  },
  "dependencies": {
    "lit": "*"
  },
  "devDependencies": {
    "@web/dev-server": "⁰.1.17"
  }
}

package.json 文件类似于 pom.xml 文件,它声明了 Java 项目的依赖关系。您可以在需要时向该文件添加更多的依赖项。在本例中,我们定义了一个名为start的脚本,它调用 TypeScript 编译器并启动 web 服务器。我们将 Lit 添加为运行时依赖项,将 web 服务器(dev-server)添加为开发依赖项。

接下来,我们需要配置 TypeScript 编译器。这可以通过一个 tsconfig.json 文件来完成。这个文件由 tsc 工具读取,并允许我们配置诸如源目录、目标目录和许多其他选项。我们现在需要的是:

{
  "compilerOptions": {
    "target": "es2018",
    "module": "esnext",
    "moduleResolution": "node",
    "experimentalDecorators": true
  }
}

这设置了我们要生成的编译后的 JavaScript 版本(ES2018),要使用的模块系统(ESNext),TypeScript 如何在文件中查找模块(Node),并启用实验性装饰器。

除此之外,我们需要通过创建一个包含以下内容的 web-dev-server.config 文件来配置 web 服务器:

export default ({
  nodeResolve: true
});

要安装依赖项,请运行

> npm install

npm 工具自动生成一个 package-lock.json 文件。这个文件可以看作是 Maven 中的有效 POM 的等价物,它包含了用于构建项目的确切的依赖树。此外, npm 工具创建一个 node_modules/ 目录,组成库的实际文件就在这个目录中。这类似于 Java WAR 文件中的 WEB-INF/libs

现在我们准备编码了!

创建“Hello,World”Web 组件

我们需要一个新的 TypeScript 文件来实现 Web 组件。姑且称之为 hello-web-component.ts 。在这个文件中,我们需要的第一件事是包含我们想要从 Lit 库中使用的对象和类。以下是如何:

import {LitElement, html} from 'lit';
import {customElement } from 'lit/decorators.js';

我们正在导入LitElement类来创建新的定制元素、html标记模板(一个可以处理模板文字的函数)和customElement装饰器(类似于 Java 中的注释)。

现在让我们将最简单的“Hello,World”编码为一个 Web 组件:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('hello-web-component')
export class HelloWebComponent extends LitElement {
  render() {
    return html`
      <div>Hello, World!</div>
    `;
  }
}

为了定义一个定制的 HTML 元素,我们需要一个扩展了LitElement并且用@customElement修饰的类。标签的名称应该总是包含一个破折号(-)。在render()函数中,我们可以使用html标记的模板函数返回将在浏览器中形成 Web 组件结构的 HTML。

要使用 Web 组件,我们只需要创建一个 HTML 文件,该文件导入由 TypeScript 编译器生成的 JavaScript 文件,并在文档中的某个位置添加<hello-web-component>元素。例如,我们可以创建下面的demo.html文件:

<html>
<head>
<meta charset="UTF-8">
<title>Web Component example</title>
</head>
<body>

<script type="module" src="hello-web-component.js"></script>
<hello-web-component></hello-web-component>

</body>
</html>

要启动 web 服务器,我们可以调用在 package.json 文件中定义的start脚本:

> npm run start

可以使用http://localhost:8000/demo . html请求页面。图 11-1 显示了浏览器中的结果。

img/506498_1_En_11_Fig1_HTML.jpg

图 11-1

用 Lit 实现的 Web 组件

使用 Vaadin Fusion 的客户端视图

现在我们对 TypeScript 和 Lit 有了基本的了解,让我们把所有的东西放在一起,用 Vaadin Fusion 实现一个客户端视图。当您想要引入水平扩展(添加更多服务器)或者需要 Vaadin Flow 目前不提供的客户端功能(例如,离线功能)时,客户端视图是一个不错的选择。

启用客户端引导

假设您有一个带有一个视图的常规 Vaadin 应用:

@Route("flow")
public class FlowView extends Composite<Div> {

  public FlowView() {
    getContent().add(new Text("Hello from Vaadin Flow!"));
  }

}

当您至少有一个用 Vaadin Flow 实现的视图时,框架会检测到它,并设置一个客户端引导来处理服务器和客户端之间的所有进一步的通信。如果你检查一个 Vaadin 应用的 target/ 目录,你会发现两个由 Vaadin 自动生成的文件(见图 11-2 )。

img/506498_1_En_11_Fig2_HTML.jpg

图 11-2

生成的 index.ts 和 index.ts 文件

当您在浏览器中请求应用时,index.htmlindex.ts 文件被提供,并且在您编译应用时被创建。为了能够使用 Vaadin Fusion 实现客户端视图,我们需要设置这些文件的自己的版本。最简单的方法是编译项目,简单地将两个文件从目标/ 目录复制到前端/ 目录。您可以使用 IDE 或命令行来完成此操作:

> mvn package
> cp target/index.* frontend/

index.html文件中,我们需要添加一个定制样式来确保客户端视图与应用的主题相匹配。我们只需在<style>部分添加以下内容:

<custom-style>
  <style include="lumo-typography"></style>
</custom-style>

并在 index.ts 文件中,导入 Lumo 主题:

import '@vaadin/vaadin-lumo-styles/all-imports';

就这样。该应用已准备好使用 Vaadin 流的客户端视图!

添加客户端视图

要添加用 TypeScript 和 Lit 实现的客户端视图,我们需要创建一个新的。ts 文件在前端/ 目录下,导入,在 index.ts 文件中定义路线。您可以为该文件使用任何想要的名称。作为一个可选的约定,让我们使用定制 HTML 元素的名称(用@customElement decorator 配置)作为添加了的文件的名称。ts 扩展( fusion-view.ts ):

import { LitElement, customElement, html } from 'lit-element';

@customElement('fusion-view')
export class FusionView extends LitElement {
  render() {
    return html`
      <div>Hello from Vaadin Fusion!</div>
    `;
  }
}

index.ts 文件中,我们可以如下导入该文件(无需指定文件扩展名):

...
import './fusion-view';
...

最后,我们需要为视图定义路线。客户端路由指定了一个路由(在 URL 中使用)和一个 HTML 元素,以显示何时调用该路由。为此,我们可以如下修改routes常数:

const routes = [
  { path: 'fusion', component: 'fusion-view'},
  // for server-side, the next magic line sends all unmatched routes:
  ...serverSideRoutes // IMPORTANT: this must be the last entry in the array
];

这将把fusion设置为呈现我们之前创建的fusion-view Web 组件的路径。这意味着可以使用http://localhost:8080/fusion调用视图。见图 11-3 。

img/506498_1_En_11_Fig3_HTML.jpg

图 11-3

使用 Vaadin Fusion 实现的客户端视图

添加 Vaadin 组件

我们在整本书中使用的 Java 组件是作为 Web 组件在客户端实现的。Web 也可以在客户端视图中使用这些 Web 组件,而无需在服务器中安装 Java 组件。要使用 Vaadin 组件,我们必须首先导入它。例如,要使用 Vaadin 按钮,我们可以在包含视图实现的 TypeScript 文件中添加以下导入声明:

import '@vaadin/vaadin-button/vaadin-button';

然后,我们可以在render()函数中使用<vaadin-button>组件:

@customElement('some-view')
export class SomeView extends LitElement {
  render() {
    return html`
      <vaadin-button>Click me!</vaadin-button>
    `;
  }
}

我们可以将相同的概念应用于 Vaadin 集合中的所有组件。下面的示例展示了如何在客户端视图中组合输入组件和布局:

import { LitElement, customElement, html } from 'lit-element';
import '@vaadin/vaadin-ordered-layout/vaadin-vertical-layout';
import '@vaadin/vaadin-ordered-layout/vaadin-horizontal-layout';
import '@vaadin/vaadin-combo-box/vaadin-combo-box';
import '@vaadin/vaadin-button/vaadin-button';
import '@vaadin/vaadin-text-field/vaadin-text-field';
import '@vaadin/vaadin-icons';

@customElement('vaadin-components-view')
export class VaadinComponentsView extends LitElement {
  render() {
    return html`
      <vaadin-vertical-layout theme="padding">
        <h1>Vaadin Components</h1>
        <vaadin-horizontal-layout>
          <vaadin-combo-box
            placeholder='Select a language...'
            items='[
              "Java", "TypeScript", "JavaScript"
            ]'
          ></vaadin-combo-box>
          <vaadin-button>
            <iron-icon icon="vaadin:check"></iron-icon>
            Select
          </vaadin-button>
        </vaadin-horizontal-layout>
      </vaadin-vertical-layout>
    `;
  }
}

始终记得导入文件并在 index.ts 文件中为视图定义一条路线(参见图 11-4 ):

img/506498_1_En_11_Fig4_HTML.jpg

图 11-4

Vaadin web 组件

import './vaadin-components-view';
...

const routes = [
  { path: 'fusion', component: 'fusion-view'},
  { path: 'vaadin-components', component: 'vaadin-components-view'},
  ...serverSideRoutes
];

事件监听器

为了响应用户交互,我们可以使用监听器。例如,我们可以向按钮添加一个点击监听器,如下所示:

<vaadin-button @click='${this.clickHandler}'>Click me!</vaadin-button>

然后在类级别定义 greet 函数:

clickHandler() {
  ... logic here ...
}

当我们需要修改其他 HTML 元素时,我们可以使用query装饰器。这里有一个视图,包含一个<vaadin-text-field>、一个<vaadin-button>和一个<vaadin-notification>,它们一起向用户显示个性化的问候(见图 11-5 ):

import { LitElement, customElement, html, query } from 'lit-element';
import '@vaadin/vaadin-ordered-layout/vaadin-vertical-layout';
import '@vaadin/vaadin-text-field/vaadin-text-field';
import '@vaadin/vaadin-button/vaadin-button';
import '@vaadin/vaadin-notification/vaadin-notification';

@customElement('greeting-view')
export class GreetingView extends LitElement {

  @query('#greeting-notification')
  private notification: any;

  private name: string = '';

  render() {
    return html`
      <vaadin-vertical-layout theme="padding">
        <h1>Greeting</h1>
        <vaadin-text-field
          id='name'
          label="What's your name?"
          @value-changed=
             '${(event:any) => this.setName(event.detail.value)}'
        ></vaadin-text-field>
        <vaadin-button @click='${this.greet}'>
          Send
        </vaadin-button>
      </vaadin-vertical-layout>
      <vaadin-notification id='greeting-notification'>
      </vaadin-notification>
    `;
  }

  setName(newName: string) {
    this.name = newName;
    this.notification.close();
  }

  greet() {
    let message:string = 'Hello, ' + this.name;
    this.notification.renderer = (root:any) =>
        root.textContent = message;
    this.notification.open();
  }
}

@query装饰器让notification对象保存<vaadin-notification>元素。稍后我们可以使用这个对象来设置一个定制的渲染器,以便在通知中向用户显示定制的消息。还要注意当文本字段中的值改变时,我们如何更新模型。在这个例子中,模型只是类中的一个字符串对象(name)。

被动观点

即使上一节的例子是有效的并且运行良好,Lit 最有趣的特性之一是可以将组件的 HTML 内容定义为封装在实现 Web 组件的类中的状态的函数。让我们通过以一种被动的方式重新实现前面的例子来看看这一点。

我们需要一组导入和一个类:

import { LitElement, customElement, html, state } from 'lit-element';
import '@vaadin/vaadin-ordered-layout/vaadin-vertical-layout';
import '@vaadin/vaadin-text-field/vaadin-text-field';
import '@vaadin/vaadin-button/vaadin-button';
import '@vaadin/vaadin-notification/vaadin-notification';

@customElement('reactive-view')
export class ReactiveView extends LitElement {
  // TODO
}

在类内部,我们可以定义视图的状态。这个视图的状态是由什么构成的?我们有一个文本字段和一个通知,其中都有我们无法预测的值。视图的状态由文本字段中的名称和通知的可见性(可见/隐藏)决定。我们可以将这两个值相加作为ReactiveView类的属性,并用state()修饰它们:

...

@customElement('reactive-view')
export class ReactiveView extends LitElement {

  @state()
  private notificationOpen = false;

  @state()
  private name = '';

}

state() decorator 将属性标记为 reactive。我们可以在render()方法中使用这些反应特性。但是在这样做之前,有必要考虑一下其他需要的操作。我们需要对视图做什么改变?当用户输入一个名字时,我们需要更新模型(?? 和 ?? 属性)。当用户点击按钮时,我们需要更新模型(notificationOpen属性)。此外,我们还需要设置通知中的文本,但我们将在稍后实现。现在让我们添加以下方法:

...

@customElement('reactive-view')
export class ReactiveView extends LitElement {

 ...

  setName(newName: string) {
    this.name = newName;
    this.notificationOpen = false;
  }

  greet() {
    this.notificationOpen = true;
  }
}

现在我们可以将render()方法实现为状态的函数:

...

@customElement('reactive-view')
export class ReactiveView extends LitElement {

  ...

  render() {
    return html`
      <vaadin-vertical-layout theme="padding">
        <h1>Greeting</h1>
        <vaadin-text-field
          label="What's your name?"
          @value-changed='${(event:CustomEvent) => this.setName(event.detail.value)}'
        ></vaadin-text-field>
        <vaadin-button @click='${this.greet}'>Send</vaadin-button>
      </vaadin-vertical-layout>
      <vaadin-notification
        .opened="${this.notificationOpen}"
      ></vaadin-notification>
    `;
  }

  setName(newName: string) {
    this.name = newName;
    this.notificationOpen = false;
  }

  greet() {
    this.notificationOpen = true;
  }
}

元素的工作方式要求我们定义一个渲染器来显示我们想要在通知中显示的文本。为此,我们可以按如下方式修改元素:

      <vaadin-notification
        .opened="${this.notificationOpen}"
        .renderer=${this.greetingRenderer}
      ></vaadin-notification>

greetingRenderer需要定义为类中的一个属性:

  greetingRenderer = (root: HTMLElement) => {
    let message = 'Hello, ' + this.name;
    root.textContent = message;
  }

就这样。我们现在有了一个反应视图!图 11-5 为截图。

img/506498_1_En_11_Fig5_HTML.jpg

图 11-5

被动的客户端视图

关于离线功能的一句话

到目前为止,我们还没有直接与服务器“对话”。所有的观点是 100%客户端。在浏览器中加载后,即使您停用网络连接,视图仍会继续运行。您不能再次请求视图,也不能请求其他视图,但是已经在浏览器中呈现的视图应该继续工作。客户端和服务器之间没有通信。图 11-6 显示了前一节的例子在没有网络连接的情况下工作的截图(在 Chrome 中,你可以使用开发者工具并在网络标签中选择离线来模拟这一点)。

img/506498_1_En_11_Fig6_HTML.jpg

图 11-6

客户端视图在没有网络连接的情况下仍然可以工作

实现离线功能超出了本书的范围。如果你想了解更多关于这个主题的知识,Vaadin 官方文档有很多关于这个主题的资源(参见 https://vaadin.com/docs/latest/fusion/tutorials/in-depth-course/installing-and-offline-pwa )。

Note

如果你好奇的话,Jussi 是我的一个朋友,他预测我将在今年(2021 年)写一本新书,远在 press 联系我之前。

摘要

在本章中,您学习了 Vaadin Fusion 的基础知识。您学习了如何编译 TypeScript 程序,以及如何使用 Lit 实现 Web 组件。您还了解了如何将客户端视图实现为用 TypeScript 和 Lit 库实现的 Web 组件。

我在这一章的目的是向你提供第一种 Vaadin 融合的方法。这里有许多我们没有涉及的主题。我相信深入解释 Vaadin 融合需要一本完整的书。然而,重要的是,您已经掌握了全局,这样您就可以接手更高级的教程和深入的文档。可以在 vaadin. com/ docs 找到好的学习资源。

下一章回到 Java 和一个令人兴奋的框架:Spring Boot。

十二、Spring Boot

Spring 是一组提高企业应用开发人员生产力的项目。Spring 中有许多项目和模块,但是您不必为了利用它们而全部掌握它们。Spring 中的一个关键项目是 Spring Framework ,这是一个面向企业应用的编程和配置模型。我们将使用 Spring Framework 和 Spring Boot ,这个项目简化了独立应用的创建,其中包括自动配置的库(比如用于连接 SQL 数据库的 JPA)。

本章展示了如何在 Spring Boot 应用的上下文中使用 Vaadin,以及如何快速实现连接到 MySQL 数据库的 CRUD 视图。

创建新的 Spring Boot 项目

创建 Spring Boot 项目最常见的方法是使用 https://start.spring.io 中的 Spring Initializr 工具。这是一个在线工具,您可以在其中填写项目详细信息,并添加要使用的依赖项。Vaadin 是与许多其他 Java 库一起可用的依赖项之一。

我们将创建一个名为 spring 的 Maven 项目,并使用 Java 11——撰写本文时 Java 的长期支持(LTS)版本。我们将使用以下依赖关系:

  • 这本书最喜欢的网络框架

  • Spring Data JPA: 使用 Jakarta 持久性 API(JPA;以前的 Java 持久性 API)

  • MySQL 驱动:连接 MySQL 数据库的 JDBC 驱动

Note

你需要一个运行在你的机器上的 MySQL 服务器来理解这一章。您可以在 https://dev.mysql.com/downloads 下载免费的 MySQL 社区服务器。如果要使用不同的数据库系统,请在创建项目时选择正确的驱动程序。如果您不想安装任何额外的软件,可以在 Spring Initializr 中添加 H2 数据库依赖项。H2 数据库可以配置为一个嵌入式服务器,与 Java 应用一起运行。

图 12-1 显示了我用来创建示例项目的 Spring Initializr 配置的截图。

img/506498_1_En_12_Fig1_HTML.jpg

图 12-1

使用 Spring Initializr 创建一个新的 Spring Boot 项目

当您点击 GENERATE 按钮时,您将得到一个包含 Maven 项目的 ZIP 文件。提取该文件,并将项目导入 IDE。

生成的项目中的主要文件是

  • pom.xml: 定义 Maven 项目的项目对象模型文件。您将看到没有<packaging>声明,这意味着使用了默认值(JAR)。默认情况下,Spring Boot 应用被打包成独立的 Java 应用,您可以使用 JDK 二进制文件提供的 java 工具直接运行这些应用。

  • Application.java: 定义了 java 应用的public static void main(String...)标准入口点。这允许使用mvn package将应用构建为 JAR 文件,然后您可以使用java -jar spring.jar运行它。在开发过程中,您可以像处理任何其他标准 Java 应用一样简单地运行入口点。

  • application.properties: 服务器端口、数据库连接字符串等应用配置属性。默认情况下,该文件为空。Spring Boot 使用的默认配置可以在该文件中被覆盖。

创建新数据库

在建立连接之前,我们需要运行数据库服务器。确保您安装了 MySQL,并使用用户名和密码连接到服务器。我将使用默认用户(root):

mysql -u root -p

引入密码后,为本章的示例应用创建一个新数据库:

CREATE DATABASE spring_example;

Caution

在生产环境中,请确保为您的应用创建了一个数据库用户,并配置了数据库所需的权限。

在这个数据库中,让我们创建一个新表来存储用户信息:

USE spring_example;

CREATE TABLE users(
  id INT NOT NULL AUTO_INCREMENT,
  email VARCHAR(255),
  user_password VARCHAR(255),
  birth_date DATE,
  favorite_number INT,
  PRIMARY KEY (id)
);

最后,我们可以在这个表中插入一些初始数据:

INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("marcus@test.com", "pass1", "1990-03-11", 888);
INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("sami@test.com", "pass2", "1991-05-13", 777);
INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("guillermo@test.com", "pass3", "1992-07-15", 666);

检查表中是否有一些行:

SELECT * FROM users;

数据库准备好了!

配置数据库连接

允许应用连接到数据库的 Java 技术被称为 Java 数据库连接 (JDBC)。JDBC 是一个 API,数据库供应商可以实现它来提供 Java 程序到数据库系统的连接。这里重要的概念是 JDBC 驱动。这是一个 JAR 文件,它封装了与特定数据库“对话”的逻辑。我们已经在上一节中添加了 MySQL 的驱动程序。

使用 JDBC,数据库连接是通过连接字符串指定的。我们需要为我们正在使用的数据库找到正确的连接字符串,并在 Spring Boot 使用的resources/application . properties文件的特定属性中配置它。我们还必须设置连接到数据库的用户和密码。以下是我们需要添加来连接到spring_example MySQL 数据库的属性(确保使用正确的用户和密码):

spring.datasource.url=jdbc:mysql://localhost:3306/spring_example
spring.datasource.username=root
spring.datasource.password=password

以下是一些其他流行数据库的 JDBC 连接字符串示例:

  • PostgreSQL: jdbc:postgresql://localhost:5432/spring_example

  • 甲骨文: jdbc:oracle:thin:@localhost:1521:spring_example

  • SQL Server: jdbc:sqlserver://localhost;databaseName=spring_example

  • H2(基于文件): jdbc:h2:~/some-directory/spring_example

  • H2(内存中): jdbc:h2:mem:spring_example

Spring 将建立一个连接池来连接到已配置的数据库。连接池是应用可用的一组数据库连接。不是在事务发生时创建连接,而是重用池中的连接以避免浪费计算资源。这是自动发生的,你现在不需要担心。

实现实体

我们将使用 Jakarta 持久性 API (JPA)来连接和读写数据。JPA 是一个规范,而不是一个实现。Spring Boot 使用 Hibernate(一个 JPA 实现)。JPA 建立在 JDBC 之上,允许您将 Java 类映射到 SQL 表。例如,我们可以创建一个User类,它与我们之前创建的users SQL 表相匹配。

Note

你可以在 https://eclipse-ee4j.github.io/jakartaee-tutorial/#persistence 了解更多关于 JPA 的信息。

让我们从简单地为users SQL 表定义一个具有匹配属性的 Java 类开始:

public class User {

  private Integer id;
  private String email;
  private String password;
  private LocalDate birthDate;
  private Integer favoriteNumber;

  ... getters and setters ...

}

可以通过 JPA 在数据库中持久化实例的类被称为实体。一个实体类必须用@Entity注释,并且必须包含一个用@Id标记的属性(对应于表的主键)。默认情况下,Hibernate 使用类名及其属性将camelCase转换为snake_case,在数据库中寻找匹配的 SQL 表和列。我们可以通过使用@Table@Column注释来定制它。下面是我们需要配置来匹配users表的内容:

@Entity
@Table(name = "users")
public class User {

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

  private String email;

  @Column(name = "user_password")
  private String password;

  private LocalDate birthDate;

  private Integer favoriteNumber;

  ...
}

我们正在更改表名和密码列的默认值。我们还配置了id属性,让 JPA 知道该值是由数据库生成的,因此我们在创建User类的实例时不必设置它的值。

基于 identity 属性(id)添加hashCode()equals(User)方法的正确实现也很重要。大多数 ide 都有生成这些方法的工具。这里有一个例子:

@Override
public int hashCode() {
  return Objects.hash(id);
}

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

    return false;
  User other = (User) obj;
  return Objects.equals(id, other.id);
}

这个类现在是持久化的了!

添加存储库

一个是一个用于从数据库中读取和写入数据的对象。它包含执行所有或部分 CRUD 操作的方法。使用 Spring,创建存储库很简单,因为它可以通过 Java 接口以声明方式完成,而您不必实现它。要创建一个存储库,我们必须使用@Repository注释并扩展一个可用的存储库接口。看一下这个存储库接口:

@Repository
public interface UserRepository
    extends JpaRepository<User, Integer> {
}

我们正在扩展的JpaRepository<T, ID>接口将我们想要使用的域类型或实体及其 id 属性的类型作为参数。该接口声明了用于数据操作的有用方法。以下是其中的一些:

  • List<T> findAll():返回表中对应于实体类型的所有实例或行(如User)

  • Optional<T> findById(ID id):按 id 返回实体

  • S save(S entity):保存给定的实体

  • void delete(T entity):删除给定的实体

  • long count():返回实体的数量

还有更多可用的方法。使用 IDE 的自动完成功能来熟悉您可以使用的功能。记住,我们不会实现这个接口。Spring 将在运行时提供实现。

Tip

通过添加与 Spring 使用的约定相匹配的方法,您可以将自己的查询添加到存储库中。在运行时,方法的名称用于创建适当的查询。 https://docs.spring.io/spring-data/jpa/docs/current/reference/html见。

控制反转和依赖注入

Spring 的核心部分是它的反转控制依赖注入特性。当 Spring 应用启动时,您运行 Spring 的代码(项目中的Application类),也就是说,您将应用执行的控制权交给了 Spring。然后,Spring 扫描您的项目,寻找用@Repository注释的类,并创建这些类的实例,您可以通过依赖注入机制使用它们。这个用一个例子就很好理解了。

让我们实现一个 Vaadin 视图,它使用 repository 类来显示数据库中的用户数量。让我们从这个开始:

@Route("user-count")
public class UserCountView extends Composite<Component> {

  @Override
  protected Component initContent() {
    long count = 999; // TODO!
    return new VerticalLayout(new H1("How many users?"),
        new Text(count + " users found."));
  }

}

目前,我们对用户数量进行硬编码(999)。为了解决这个问题,我们需要一个类型为UserRepository的实例,这是我们在上一节中编写的存储库接口。我们不能使用新的UserRepository(),因为这是一个接口。相反,我们可以在构造函数中接受这种类型的引用,并告诉 Spring 注入一个实例。由于UserRepository接口标有@Repository,Spring 知道如果另一个类在构造函数中需要它,它需要创建一个这种类型的新实例:

@Route("user-count")
public class UserCountView extends Composite<Component> {

  private final UserRepository userRepository;

  @Autowired
  public UserCountView(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  protected Component initContent() {
    long count = userRepository.count();
    return new VerticalLayout(new H1("How many users?"),
        new Text(count + " users found."));
  }

}

如你所见,我们用@Autowired标记构造函数。事实上,这个注释是可选的,但是为了清楚起见,我们将保留它。

Spring 如何将UserRepository的实例传递给UserCountView类?事实是,UserCountView也是春天创造的!这要归功于项目中包含的 Vaadin-Spring 集成(参见pom.xml文件)。在创建UserCountView的实例之前,Spring 发现它依赖于一个UserRepository,并使用依赖注入将它传递给构造函数。稍后在initContent()方法中,我们可以使用实例来获取数据库中的用户数量。

您可以通过执行Application类中的main(String...)方法来运行应用。大多数 ide 都包括一个选项,当你右击文件时就可以这样做。或者,你可以运行spring-boot:run Maven 目标。图 12-2 显示了该视图的截图。

img/506498_1_En_12_Fig2_HTML.jpg

图 12-2

vaadin 查看 sql 数据库消费者

Tip

在前面的例子中,我们直接在视图中使用了存储库接口。在更严肃的应用中,视图应该使用服务类。这个服务类可以使用存储库接口连接到数据库,有效地将表示层(视图)与持久性技术(JPA)分离。

实现 CRUD

到现在,你应该对如何结合整本书所学的知识有一个清晰的想法。您可以使用Binder类和存储库类通过 UI 将域模型连接到数据库。在这一节中,我想向您展示一种快速的方法(我所知道的最快的方法之一)来实现一个全功能的 CRUD,它使用我创建的开源 Vaadin 插件,可以在 https://vaadin.com/directory/component/crud-ui-add-on 免费获得。

Note

Vaadin 目录包含由 Vaadin 社区贡献的许多有用的附加组件。你也可以在那里发布你自己的插件!

首先,让我们将 Crud-UI 附加组件添加到pom.xml文件中:

<repositories>
      <repository>
            <id>vaadin-addons</id>
            <url>https://maven.vaadin.com/vaadin-addons</url>
      </repository>
</repositories>
<dependencies>
      ...
      <dependency>
            <groupId>org.vaadin.crudui</groupId>
            <artifactId>crudui</artifactId>
            <version>4.4.1</version>
      </dependency>
<dependencies>

这个附加组件包括GridCrud<T>类,它允许我们通过使用一行代码来呈现 CRUD UI 组件:

var crud = new GridCrud<>(User.class);

我们可以使用 lambda 表达式连接 CRUD 操作。例如,要使用存储库删除用户,我们可以配置以下操作:

crud.setDeleteOperation(userRepository::delete);

我们可以配置组件显示的列和字段的可见性:

crud.getGrid().setColumns("email", "birthDate", "favoriteNumber");
crud.getCrudFormFactory().setVisibleProperties("email",
    "password", "birthDate", "favoriteNumber");

最后,我们还可以配置用于特定属性的输入组件的类型。例如:

crud.getCrudFormFactory().setFieldType("password", PasswordField.class);

综上所述,我们可以实现一个连接到 Spring 存储库的全功能 CRUD 视图,如下所示:

@Route("user-crud")
public class UserCrudView extends Composite<Component> {

  private final UserRepository userRepository;

  public UserCrudView(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  protected Component initContent() {
    var crud = new GridCrud<>(User.class);
    crud.getGrid().setColumns("email", "birthDate",
        "favoriteNumber");
    crud.getCrudFormFactory().setVisibleProperties("email",
        "password", "birthDate", "favoriteNumber");
    crud.getCrudFormFactory().setFieldType("password",
        PasswordField.class);

    crud.setFindAllOperation(userRepository::findAll);
    crud.setAddOperation(userRepository::save);
    crud.setUpdateOperation(userRepository::save);
    crud.setDeleteOperation(userRepository::delete);

    return new VerticalLayout(crud);
  }

}

图 12-3 显示了视图的截图。

img/506498_1_En_12_Fig3_HTML.jpg

图 12-3

具有数据库连接的 CRUD 视图

该附件包括许多其他有用的功能。您可以激活 Jakarta Bean 验证、更改布局、添加动作按钮等等。为了说明其中一个特性,请参见图 12-4 中默认情况下编辑表单是如何可视化的。

img/506498_1_En_12_Fig4_HTML.jpg

图 12-4

CRUD 视图中的更新表单

通过设置一个新的CrudFormFactory实现,我们可以很容易地改变布局,在左侧的Grid旁边显示编辑和新表单。Crud-UI 插件包括几个备选方案。例如:

var crud = new GridCrud<>(User.class,
    new HorizontalSplitCrudLayout());

参见图 12-5 中这一变化的结果。

img/506498_1_En_12_Fig5_HTML.jpg

图 12-5

更改 Crud-UI 组件的默认布局

摘要

本章让您开始了解 Spring Boot 和数据库连接。您了解了如何创建新的 MySQL 数据库,以及如何通过 Spring 使用 JDBC 和 JPA 配置连接。您了解了什么是实体以及如何将其映射到数据库表。您看到了创建一个存储库类来读写数据是多么容易,以及如何使用控制反转和依赖注入来连接 Spring 应用中的对象。最后,您看到了当您使用 Vaadin 目录中可用的附加组件时,创建 Vaadin 应用是多么简单。

下一章使用与这里相似的方法来解释另一项强大的技术:Jakarta EE。

十三、Jakarta EE

Jakarta EE(以前的 Java 企业版)是一组帮助开发人员用 Java 实现企业软件的规范。在前面的章节中,我们已经使用了 Jakarta Servlet、Jakarta Bean 验证和 Jakarta 持久性。所有这些规范都是 Jakarta EE 的一部分。为了获得更好的视角,请快速浏览一下 https://jakarta.ee/specifications 的所有规格。

Jakarta EE 为您的 Java 应用提供了一个运行时环境,其编程模型基于容器的概念。容器通过拦截对类中方法的调用来封装代码,从而为代码添加功能。这种功能的例子是根据用户角色保护方法调用、在事务上下文中执行代码以将执行作为一个单元、方法的异步调用以及类所需的依赖项的注入。

Jakarta EE 环境可作为应用服务器使用,您可以在其中部署应用。您可以将运行时与您的应用代码一起打包在同一个工件(优步 JAR)中,或者将应用代码与运行时分开放在一个 WAR 文件中。

创建新的 Jakarta EE 项目

Jakarta EE 有几种兼容的实现:

  • Eclipse GlassFish

  • Apache Tomcat

  • 阿帕契·汤姆

  • 码头

  • Payara 平台

  • 开放自由

  • 野猫队

  • 小贱人

  • Eclipse 球衣

在写这本书的时候,Vaadin 支持 Jakarta EE 8(版本 9 是最新的),所以这限制了我们的选择。我们将使用 Apache TomEE 作为 Maven 插件来简化开发周期,但是您可以将示例应用部署到任何符合 Jakarta EE 8 的服务器上。

我们可以使用现有的 Vaadin 项目并添加以下依赖项,以开始使用 Jakarta EE 提供的 API:

<dependency>
    <groupId>jakarta.platform</groupId>
    <artifactId>jakarta.jakartaee-api</artifactId>
    <version>8.0.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.vaadin</groupId>
    <artifactId>vaadin-cdi</artifactId>
</dependency>

我们需要在 src/main/webapp/WEB-INF/ 目录中添加一个新的 beans.xml 文件。这个文件是激活上下文和依赖注入所必需的(稍后将详细介绍)。使用以下内容创建它:

<?xml version="1.0" encoding="UTF-8"?>
<beans
        xmlns:="http://xmlns.jcp.org/xml/ns/javaee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
        bean-discovery-mode="all">
</beans>

不用安装 Jakarta EE 服务器,我们可以使用 Maven 运行应用,并添加 Apache TomEE 插件,如下所示(这相当于安装应用服务器,只是服务器是通过 Maven 管理的):

<plugin>
    <groupId>org.apache.tomee.maven</groupId>
    <artifactId>tomee-maven-plugin</artifactId>
    <version>8.0.7</version>
    <configuration>
        <context>ROOT</context>

    </configuration>
</plugin>

Note

您可以在 https://tomee.apache.org 了解更多关于 Apache TomEE 的信息。

现在我们可以添加一个 Vaadin 视图来检查一切是否正常:

@Route("hello-jakarta-ee")
public class HelloJakartaEEView extends Composite<Component> {

  @Override
  protected Component initContent() {
    return new VerticalLayout(new Text("Hello Jakarta EE!"));
  }

}

您可以使用mvn package构建应用,并将 WAR 文件部署到任何 Jakarta EE 运行时,或者使用mvn tomee: run运行应用。图 13-1 显示了结果。

img/506498_1_En_13_Fig1_HTML.jpg

图 13-1

运行在 Apache TomEE 上的 Jakarta EE Vaadin 应用

创建新数据库

和前一章一样,在建立连接之前,我们需要数据库服务器运行。确保您安装了 MySQL,并使用用户名和密码连接到服务器。

Note

您可以在 https://dev.mysql.com/downloads 下载免费的 MySQL 社区服务器。

我将使用默认用户(root):

mysql -u root -p

引入密码后,为本章的示例应用创建一个新数据库:

CREATE DATABASE jakarta_ee_example;

在这个数据库中,让我们创建一个新表来存储用户信息:

USE jakarta_ee_example;

CREATE TABLE users(
  id INT NOT NULL AUTO_INCREMENT,
  email VARCHAR(255),
  user_password VARCHAR(255),
  birth_date DATE,
  favorite_number INT,
  PRIMARY KEY (id)
);

最后,我们可以在这个表中插入一些初始数据:

INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("marcus@test.com", "pass1", "1990-03-11", 888);
INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("sami@test.com", "pass2", "1991-05-13", 777);
INSERT INTO users(email, user_password, birth_date,
   favorite_number)
VALUES ("guillermo@test.com", "pass3", "1992-07-15", 666);

检查表中是否有一些行:

SELECT * FROM users;

配置数据库连接

如果您还记得上一章,Java 应用通过 JDBC 驱动程序连接到特定的数据库系统。数据库连接依赖于应用运行的环境。例如,当您开发一个应用时,您可能在同一台开发机器上运行一个数据库服务器。当您将应用部署到生产服务器时,应用并不连接到您的开发机器,而是连接到生产就绪的机器。因此,数据库连接最好在应用运行的环境中配置,而不是在应用代码中配置。

Jakarta EE 环境允许您在配置文件中定义资源,比如数据库连接。由于我们使用 Apache TomEE Maven 插件,我们的运行时环境驻留在我们工作的同一台机器上,甚至驻留在我们编码的项目中。在这种情况下,我们可以在项目内部的文件中定义连接的细节。然而,当您将应用部署到生产环境中时,您不会使用 Maven 插件。相反,您必须在生产环境中定义数据库连接。应用代码可以通过我们可以建立的名称来引用数据库连接资源。现在,我们将跳过生产设置,为 Apache TomEE Maven 插件配置数据库连接资源。

首先,我们需要将 JDBC 驱动程序添加到在 pom.xml 文件中定义的运行时(Apache TomEE)中。我们需要做的就是更新 Apache TomEE 插件声明,以包含 MySQL JDBC 驱动程序:

<plugin>
      <groupId>org.apache.tomee.maven</groupId>
      <artifactId>tomee-maven-plugin</artifactId>
      <version>8.0.7</version>
      <configuration>
            <context>ROOT</context>
            <libs>
                  <lib>mysql:mysql-connector-java:8.0.25</lib>
            </libs>
      </configuration>
</plugin>

现在,包含 JDBC 驱动程序的 JAR 文件在运行时是可用的。如果您想将应用部署到一个外部独立服务器上,那么您也必须在那里添加 JAR 文件。

现在我们可以配置数据库连接细节。我们可以在一个名为 tomee.xml 的新文件中进行设置,该文件位于 src/main/tomee/conf/ 目录中:

<tomee>
  <Resource id="mysqlDatasource" type="DataSource">
    JdbcDriver com.mysql.cj.jdbc.Driver
    JdbcUrl jdbc:mysql://localhost:3306/jakarta_ee_example
    UserName root
    Password password
  </Resource>
</tomee>

注意我们使用的id(mysqlDatasource)。我们将使用这个名称从应用代码中引用这个数据源。这允许我们从运行时环境中分离连接细节。例如,生产服务器可以将数据源定义为到 Oracle 数据库的连接,而我们不必对应用代码进行任何更改。

Jakarta EE 将建立一个连接池来连接到已配置的数据库。

Note

连接池是应用可用的一组数据库连接。不是在事务发生时创建连接,而是重用池中的连接以避免浪费计算资源。这是自动发生的,你现在不需要担心。

实现实体

Jakarta EE 包含 JPA,所以我们可以添加我们在上一章中编写的相同实体,这次在所有属性中使用显式列名,并添加 Jakarta Bean 验证注释:

@Entity
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  @NotNull
  private Integer id;

  @Column(name = "email")
  @NotNull
  @NotBlank
  @Email
  private String email;

  @Column(name = "user_password")
  @NotNull
  @NotBlank
  @Size(min = 5)
  private String password;

  @Column(name = "birth_date")
  @Past
  private LocalDate birthDate;

  @Column(name = "favorite_number")
  @PositiveOrZero
  private Integer favoriteNumber;

  @Override
  public int hashCode() {
    return Objects.hash(id);
  }

  @Override

  public boolean equals(Object obj) {
    if (this == obj)
      return true;
    if (obj == null)
      return false;
    if (getClass() != obj.getClass())
      return false;
    User other = (User) obj;
    return Objects.equals(id, other.id);
  }

  ... getters and setters ...

}

Note

Apache TomEE 使用 Apache OpenJPA 作为 JPA 实现。OpenJPA 用 SQL 列匹配属性名的方式不同于 Hibernate。OpenJPA 使用类和属性的确切名称在数据库中查找相应的表和列。

当在 Jakarta EE 应用中使用 JPA 时,我们需要定义一个持久性单元。持久性单元定义实体和与它们一起使用的数据源。为了定义一个持久性单元,我们需要在src/main/resources/META-INF/目录下创建一个名为 persistence.xml 的新文件:

<persistence xmlns:="http://java.sun.com/xml/ns/persistence"
      version="1.0">

      <persistence-unit name="jakarta-ee-example-pu"
            transaction-type="RESOURCE_LOCAL">
            <jta-data-source>mysqlDatasource</jta-data-source>
            <class>com.apress.practicalvaadin.ch13.User</class>
      </persistence-unit>

</persistence>

如您所见,我们使用了在 Jakarta EE 运行时(Apache TomEE)中定义的数据源名称。这个文件是应用代码的一部分,但是它不包含任何数据库连接细节。正如前面指出的,连接细节存在于运行时中。还要注意我们是如何将User类添加为托管实体的。如果我们需要更多的实体,我们可以在那里列出它们。如果我们有不止一个数据库,我们可以定义更多的持久性单元,并向其中添加相应的实体。

添加存储库

Jakarta EE 本身不包含声明性存储库实现的功能。然而,它确实包含了扩展机制,Apache DeltaSpike ( https://deltaspike.apache.org )项目包含了一个创建这种存储库类的库。我们需要在 pom.xml 文件的<dependencyManagement>部分添加 Apache DeltaSpike 物料清单(BOM ):

<dependency>
      <groupId>org.apache.deltaspike.distribution</groupId>
      <artifactId>distributions-bom</artifactId>
      <version>1.9.4     </version>
      <type>pom</type>
      <scope>import</scope>

</dependency>

我们需要在<dependencies>部分添加 Apache DeltaSpike 数据模块:

<dependency>
      <groupId>org.apache.deltaspike.core</groupId>
      <artifactId>deltaspike-core-api</artifactId>
      <scope>compile</scope>
</dependency>
<dependency>
      <groupId>org.apache.deltaspike.core</groupId>
      <artifactId>deltaspike-core-impl</artifactId>
      <scope>runtime</scope>
</dependency>
<dependency>
      <groupId>org.apache.deltaspike.modules</groupId>
      <artifactId>deltaspike-data-module-api</artifactId>
      <scope>compile</scope>
</dependency>
<dependency>
      <groupId>org.apache.deltaspike.modules</groupId>
      <artifactId>deltaspike-data-module-impl</artifactId>
      <scope>runtime</scope>
</dependency>

Apache DeltaSpike 数据需要一个EntityManager。图书馆寻找这个对象的方式是通过一个 CDI 生产者。现在不要太担心这些术语,把EntityManager类看作是一个助手类,作为 JPA 的接口,供 Apache DeltaSpike 数据内部使用。CDI 生产者只是一个生产EntityManager实例的工厂:

@ApplicationScoped
public class EntityManagerProducer {

  @PersistenceUnit(unitName = "jakarta-ee-example-pu")
  private EntityManagerFactory emf;

  @Produces
  public EntityManager create() {
    return emf.createEntityManager();
  }

  public void close(@Disposes EntityManager em) {
    if (em.isOpen()) {
      em.close();
    }
  }

}

Jakarta EE 运行时将自动创建该类的一个实例,Apache DeltaSpike 稍后可以使用该实例通过 JPA 与数据库进行通信。我们声明了在@PersistenceUnit注释中使用的持久性单元的名称。现在我们看到持久性逻辑是如何通过这个持久性单元和应用服务器中定义的数据源连接到数据源的。

有了这些,我们可以定义一个存储库接口:

@Repository
public interface UserRepository
    extends EntityRepository<User, Integer> {

}

和 Spring 一样,我们不需要实现这个接口。

上下文和依赖注入

Jakarta EE 和 Vaadin 是通过一个上下文和依赖注入(CDI)库集成的,我们在配置项目时已经将它添加为一个依赖项。CDI 是一个 Jakarta EE 规范,它允许对类的实例进行解耦。为了理解它是如何工作的,让我们回顾一下我们在上一节中实现的EntityManagerProducer类:

@ApplicationScoped
public class EntityManagerProducer {

  @PersistenceUnit(unitName = "jakarta-ee-example-pu")
  private EntityManagerFactory emf;

  ...

}

CDI 运行时将看到EntityManagerProducer类用@ApplicationScoped进行了注释,并创建了这个类的一个新实例。该实例将由应用中该类的所有可能的客户端共享。然而,在创建实例之前,CDI 运行时发现需要一个EntityManagerFactory,因为有一个用@PersistenceUnit注释的这种类型的属性。CDI 要求 JPA 准备这个类的一个实例。JPA 使用持久性单元的名称(jakarta-ee-example-pu)来定位配置并创建适当的对象。CDI 获取这个对象,并将其“注入”到它正在创建的EntityManagerProducer实例中。这是依赖注入在起作用!

我们可以使用这种机制来注入我们添加到应用代码中的类或接口的实例。事实上,Vaadin 视图是通过 CDI 创建的,这意味着我们可以将 CDI beans(由 CDI 运行时创建和管理的其他对象)注入到 Vaadin 视图中。例如,我们可以注入一个 UserRepository 类型的实例,并按如下方式调用其方法:

@Route("user-count")
public class UserCountView extends Composite<Component> {

  private final UserRepository userRepository;

  @Inject
  public UserCountView(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  protected Component initContent() {
    long count = userRepository.count();
    return new VerticalLayout(new H1("How many users?"),
        new Text(count + " users found."));
  }

}

如你所见,我们用@Inject标记构造函数。这标记了 CDI 在创建 Vaadin 视图实例之前检测到的注入点。

有了 Vaadin 视图后,我们可以使用tomee:run Maven 目标运行应用,以测试一切是否按预期运行。图 13-2 显示了该视图的截图。

img/506498_1_En_13_Fig2_HTML.jpg

图 13-2

vaadin 查看 sql 数据库消费者

实现 CRUD

同样,您应该清楚地知道如何结合您在整本书中获得的知识,使用Binder类将 Vaadin UI 组件与存储库类连接起来。在本节中,我们将使用我在 https://vaadin.com/directory/component/crud-ui-add-on 提供的用于 Vaadin 的 Crud-UI 插件实现一个全功能的 CRUD。

首先,让我们将 Crud-UI 附加组件添加到pom.xml文件中:

<repositories>
      <repository>
            <id>vaadin-addons</id>
            <url>https://maven.vaadin.com/vaadin-addons</url>
      </repository>
</repositories>
<dependencies>
      ...
      <dependency>
            <groupId>org.vaadin.crudui</groupId>
            <artifactId>crudui</artifactId>
            <version>4.4.1</version>
      </dependency>
<dependencies>

在前一章中,您看到了如何使用这个附加组件。这一次,让我们以不同的方式配置 CRUD 组件,以支持 Jakarta Bean 验证,并使 UX 更好一些。下面是该视图的完整实现:

@Route("user-crud")
public class UserCrudView extends Composite<Component> {

  private final UserRepository userRepository;

  @Inject
  public UserCrudView(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  protected Component initContent() {
    var crud = new GridCrud<>(User.class,
        new VerticalCrudLayout());
    crud.setSizeFull();
    crud.getGrid().setHeightByRows(true);
    crud.getCrudFormFactory().setUseBeanValidation(true);
    crud.setClickRowToUpdate(true);
    crud.setUpdateOperationVisible(false);

    crud.getGrid().setColumns("email", "birthDate",
        "favoriteNumber");
    crud.getCrudFormFactory().setVisibleProperties("email",
        "password", "birthDate", "favoriteNumber");
    crud.getCrudFormFactory().setFieldType("password",
        PasswordField.class);
    crud.setFindAllOperation(userRepository::findAll);
    crud.setAddOperation(userRepository::save);
    crud.setUpdateOperation(userRepository::save);
    crud.setDeleteOperation(userRepository::remove);

    VerticalLayout layout = new VerticalLayout(crud);
    layout.setSizeFull();
    return layout;
  }

}

这个实现类似于我们在上一章中使用的实现,但是它包括几个不同之处:

  • 我们使用一个VerticalCrudLayout来可视化页面底部的表单,而不是右边。

  • 我们将 CRUD 和包含它的布局设置为全尺寸。

  • 我们通过调用setHeightByRows(true)方法,配置网格使用尽可能多的垂直空间来可视化所有包含的行。

  • 我们将在添加和更新表单中激活 Jakarta Bean 验证。

  • 当用户通过调用setClickRowToUpdate(boolean)方法点击Grid中的一行时,我们激活一个选项使表单可编辑。

  • 我们在 UI 中隐藏了 update 按钮,因为它不再需要了(由于前面的原因)。

你可以在图 13-3 中看到应用的截图。

img/506498_1_En_13_Fig3_HTML.jpg

图 13-3

具有数据库连接的 CRUD

如果您在输入字段中引入无效值,CRUD 会显示错误消息。图 13-4 给出了一个例子。

img/506498_1_En_13_Fig4_HTML.jpg

图 13-4

显示 Jakarta Bean 验证错误消息的 CRUD 视图

摘要

本章让您开始学习 Jakarta EE 和使用 Apache DeltaSpike 数据的数据库连接。您了解了如何使用 JDBC 和 JPA 配置数据库连接。您了解了创建一个存储库类来读写数据是多么容易,以及如何使用依赖注入来连接 Jakarta EE 应用中的对象。最后,您看到了当您使用 Vaadin 目录中可用的附加组件时,创建 Vaadin 应用是多么简单。

这本书在它开始的地方结束。Crud-UI 插件总是完美地提醒人们为什么 Vaadin 如此强大,使用起来如此有趣。使用 Java 编程语言、Java 虚拟机和 Java 生态系统的力量来实现图形 web 用户界面是非常奢侈的。我希望您继续使用 Vaadin 和 Java 进行现代 web 开发。编码快乐!

第一部分:开始

第二部分:Vaadin 的基本原理

第三部分:高级功能

第四部分:集成和数据库连接