Jakarta-应用开发-全-

Jakarta 应用开发(全)

原文:zh.annas-archive.org/md5/91d76a553c63bb89a411c1a5d5da268f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Jakarta EE 提供了简化云应用程序以及更多传统 Web 应用程序(包括服务器端企业应用程序)开发的函数。本书涵盖了最受欢迎的 Jakarta EE 规范的最新版本,包括 Jakarta Faces、Jakarta Persistence、Jakarta Enterprise JavaBeans、Contexts and Dependency Injection(CDI)、Jakarta JSON Processing、Jakarta JSON Binding、Jakarta WebSocket、Jakarta Messaging、Jakarta Enterprise Web Services、Jakarta REST,以及通过 Jakarta EE Security 保护 Jakarta EE 应用程序的覆盖。

这本书面向的对象

本书是为已经熟悉 Java 语言的读者设计的。其目标受众包括希望学习 Jakarta EE 的现有 Java 开发人员,以及希望将他们的技能更新到 Jakarta EE 的现有 Java EE 开发人员。

本书涵盖的内容

第一章, Jakarta EE 简介,简要介绍了 Jakarta EE,包括它是如何作为一个社区努力开发的,同时也澄清了一些关于 Jakarta EE 的常见误解。

第二章, 上下文和依赖注入,包括 CDI 命名 bean 的覆盖,使用 CDI 进行依赖注入和 CDI 限定符,以及 CDI 事件功能。

第三章, Jakarta RESTful Web Services,讨论了如何使用 Jakarta REST 开发 RESTful Web 服务,以及如何通过 Jakarta REST 客户端 API 开发 RESTful Web 服务客户端。本章还涵盖了如何通过服务器端事件向 RESTful Web 服务客户端发送自动更新。

第四章, JSON 处理和 JSON 绑定,介绍了如何使用 Jakarta JSON Processing 和 Jakarta JSON Binding 生成和解析 JavaScript 对象表示法(JSON)数据。

第五章, 使用 Jakarta EE 进行微服务开发,解释了如何利用 Jakarta EE 功能开发基于微服务的架构。

第六章, Jakarta Faces,涵盖了使用 Jakarta Faces 开发 Web 应用程序的内容。

第七章, 额外的 Jakarta Faces 功能,涵盖了额外的 Jakarta Faces 功能,例如 HTML5 友好的标记、WebSocket 支持和 Faces Flows。

第八章, 使用 Jakarta Persistence 进行对象关系映射,讨论了如何通过 Jakarta Persistence 开发与关系数据库管理系统(RDBMS)如 Oracle 或 MySQL 交互的代码。

第九章, WebSocket,解释了如何开发具有浏览器和服务器之间全双工通信功能的基于 Web 的应用程序。

第十章确保 Jakarta EE 应用程序安全,介绍了如何使用 Jakarta Security 确保 Jakarta EE 应用程序的安全。

第十一章Servlet 开发和部署,解释了如何使用 Jakarta Servlet 在 Java EE 应用程序中开发服务器端功能。

第十二章Jakarta 企业 Bean,解释了如何使用会话和消息驱动 Bean 开发应用程序。涵盖了企业 Bean 功能,如事务管理、定时服务和安全。

第十三章Jakarta Messaging,讨论了如何使用 Jakarta Messaging 开发消息应用程序。

第十四章使用 Jakarta XML Web Services 开发 Web 服务,解释了如何使用 Jakarta Enterprise Web Services 开发基于 SOAP 的 Web 服务。

第十五章整合一切,解释了如何开发集成多个 Jakarta EE API 的应用程序。

为了充分利用本书

为了编译和执行本书中的示例,需要一些必需和推荐的工具。

必需或推荐软件 操作系统要求
需要 Java 17 或更高版本 Windows、macOS 或 Linux
Apache Maven 3.6 或更高版本必需 Windows、macOS 或 Linux
推荐使用 Java IDE 如 Eclipse IDE、IntelliJ IDEA 或 NetBeans Windows、macOS 或 Linux
需要符合 Jakarta EE 10 规范的实现,如 GlassFish、WildFly 或 Apache TomEE Windows、macOS 或 Linux

技术要求

为了编译和构建本书中的示例,需要以下工具:

  • 一个较新的 Java 开发工具包,本书中的示例使用 OpenJDK 17 构建。

  • Apache Maven 3.6 或更高版本

  • 推荐使用 Java 集成开发环境(IDE)如 Apache NetBeans、Eclipse IDE 或 IntelliJ IDEA,但不是必需的(本书中的示例使用 Apache NetBeans 开发,但鼓励读者使用他们偏好的 Java IDE)

  • 一个符合 Jakarta EE 10 规范的运行时(本书中的示例使用 Eclipse GlassFish 部署,但任何符合 Jakarta EE 10 规范的运行时都将工作)

如果您使用的是本书的电子版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Jakarta-EE-Application-Development。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供在github.com/PacktPublishing/上获取。查看它们吧!

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如示例所示,我们通过在JsonObjectBuilder实例上调用add()方法来生成一个JsonObject实例。”

代码块设置如下:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html;
      charset=UTF-8">
    <title>Login Error</title>
  </head>
  <body>
    There was an error logging in.
    <br />
    <a href="login.html">Try again</a>
  </body>
</html>

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

package com.ensode.jakartaeebook.security.basicauthexample;
//imports omitted for brevity
@BasicAuthenticationMechanismDefinition
@WebServlet(name = "SecuredServlet", urlPatterns = {"/securedServlet"})
@ServletSecurity(
        @HttpConstraint(rolesAllowed = "admin"))
public class SecuredServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request,
    HttpServletResponse response) throws ServletException, IOException {
    response.getOutputStream().print(
      "Congratulations, login successful.");
  }
}

任何命令行输入或输出都按以下方式编写:

appclient -client simplesessionbeanclient.jar

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“现在我们已经创建了一个客户,我们的客户列表页面显示了一个数据表,列出了我们刚刚创建的客户。”

提示或重要注意事项

看起来是这样的。

联系我们

我们读者的反馈总是受欢迎的。

一般反馈:如果您对此书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packtpub.com,并提供材料的链接。

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

分享您的想法

一旦您阅读了Jakarta EE 应用程序开发,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但又无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

福利远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

下载此书的免费 PDF 副本二维码(https://packt.link/free-ebook/978-1-83508-526-4)

packt.link/free-ebook/978-1-83508-526-4

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:Jakarta EE 简介

Jakarta EE 由一组用于开发服务器端企业 Java 应用程序的 应用程序编程接口 (API) 规范组成。本书的大部分章节将涵盖单个 Jakarta EE 规范,例如用于集成应用程序不同部分的 上下文和依赖注入 (CDI),或用于开发 RESTful Web 服务的 Jakarta RESTful Web 服务。我们还涵盖了用于处理 JSON 格式数据的 Jakarta EE API,以及用于开发基于 Web 的用户界面的 Jakarta Faces。我们还深入探讨了如何与关系数据库交互,实现 Web 应用程序中客户端和服务器之间的双向通信,安全性和消息传递。

在本章中,我们将涵盖以下主题:

  • Jakarta EE 简介

  • Jakarta EE、Java EE、J2EE 和 Spring 框架

Jakarta EE 简介

Jakarta EE 是一组 API 规范的集合,旨在在开发服务器端企业 Java 应用程序时协同工作。Jakarta EE 是一个有多种实现的标准。这一事实防止了供应商锁定,因为针对 Jakarta EE 规范开发的代码可以在任何符合 Jakarta EE 规范的实现中部署。

Jakarta EE 是一个 Eclipse 软件基金会 项目。由于 Jakarta EE 规范是开源的,任何希望贡献的组织或个人都可以自由地这样做。

贡献给 Jakarta EE

有许多贡献的方式,包括参与讨论并为 Jakarta EE 的未来版本提供建议。为此,只需订阅适当的邮件列表即可,这可以通过访问 jakarta.ee/connect/mailing-lists/ 来完成。

为了订阅邮件列表,您需要在 accounts.eclipse.org/user/register 创建一个免费的 Eclipse.org 账户。

要超越参与讨论并真正贡献代码或文档,必须签署 Eclipse 贡献者协议。Eclipse 贡献者协议可以在 www.eclipse.org/legal/ECA.php 找到。

Jakarta EE API

如前所述,Jakarta EE 是一组 API 规范的集合,旨在在开发服务器端企业 Java 应用程序时协同工作。

除了完整的 Jakarta EE 平台外,还有两个 Jakarta EE 配置文件,它们包含完整平台中包含的部分规范和 API。Jakarta EE 网络配置文件包含适合开发 Web 应用的部分规范和 API。Jakarta EE 核心配置文件包含更小的部分规范和 API,更适合开发微服务。

Jakarta EE 核心配置文件 API 包括以下内容:

  • Jakarta Context 和依赖注入轻量级 (CDI Lite)

  • Jakarta RESTful Web 服务

  • Jakarta JSON 处理

  • Jakarta JSON 绑定

核心配置文件中包含的 Contexts and Dependency Injection API 版本是完整规范的子集。Jakarta EE Web Profile API 包括完整的 CDI 规范而不是 CDI Lite,以及核心配置文件中的所有其他规范和 API,以及一些额外的 API:

  • Jakarta 上下文和依赖注入

  • Jakarta Faces

  • Jakarta Persistence

  • Jakarta WebSocket

  • Jakarta 安全性

  • Jakarta Servlet

  • Jakarta 企业 Bean Lite

Web Profile 中包含的 Jakarta 企业 Bean 版本是完整企业 Bean 规范的子集。

完整的 Jakarta EE 平台包括完整的企业 Bean 规范,以及 Web Profile 中包含的所有其他规范和 API,以及一些额外的 API:

  • Jakarta 企业 Bean

  • Jakarta 消息传递

  • Jakarta 企业 Web 服务

Jakarta EE API 的完整列表

前面的列表并不全面,仅列出了一些最流行的 Jakarta EE API。要获取 Jakarta EE API 的完整列表,请参阅jakarta.ee/specifications/

应用服务器供应商或开源社区需要为前面列表中的每个 Jakarta EE API 规范提供兼容的实现。

一个标准,多个实现

在其核心,Jakarta EE 是一个规范,如果你愿意,可以说是一张纸。Jakarta EE 规范的实现需要开发,以便应用开发者可以针对 Jakarta EE 标准开发服务器端企业 Java 应用程序。

每个 Jakarta EE API 可以有多个实现。例如,流行的 Hibernate 对象关系映射工具是 Jakarta Persistence 的实现,但绝不是唯一的。其他 Jakarta Persistence 实现包括 EclipseLink 和 Open JPA。同样,每个 Jakarta EE 规范都有多个实现。

Jakarta EE 应用程序通常部署到应用服务器。一些流行的应用服务器包括 JBoss、Websphere、Weblogic 和 GlassFish。每个应用服务器都被视为一个 Jakarta EE 实现。应用服务器供应商要么开发自己的一些 Jakarta EE 规范的实现,要么选择包含现有的实现。

应用开发者通过不受特定 Jakarta EE 实现的限制而受益。只要应用程序是针对标准 Jakarta EE API 开发的,它应该非常易于在不同应用服务器供应商之间移植。

应用服务器供应商然后将一组 Jakarta EE API 实现捆绑在一起,作为其应用服务器产品的一部分。由于每个实现都符合相应的 Jakarta EE 规范,针对一个实现开发的代码可以在任何其他实现上无修改地运行,从而避免供应商锁定。

以下表格列出了一些流行的 Jakarta EE 实现:

Jakarta EE 实现 供应商 许可 URL
Apache Tomee Tomitribe Apache License, Version 2.0 tomee.apache.org/
Eclipse GlassFish Eclipse Foundation Eclipse Public License - v 2.0 glassfish.org/
IBM Websphere Liberty IBM 商业 www.ibm.com/products/websphere-liberty
JBoss Enterprise Application Platform Red Hat 商业 www.redhat.com/en/technologies/jboss-middleware/application-platform
Open Liberty IBM Eclipse Public License 2.0 openliberty.io/
Payara Server Community Payara Services Ltd 双许可:CDDL 1.1 / GPL v2 + Classpath Exception www.payara.fish/products/payara-platform-community/
Payara Server Enterprise Payara Services Ltd 商业 www.payara.fish/products/payara-platform-community/
Wildfly Red Hat LGPL v2.1 www.wildfly.org/

表 1.1 – 流行的 Jakarta EE 实现

注意

对于完整的 Jakarta EE 兼容实现列表,请参阅 jakarta.ee/compatibility/

在本书的大部分示例中,我们将使用 GlassFish 作为我们的 Jakarta EE 运行时。这是因为它是一个高质量、最新的开源实现,不受任何特定供应商的约束;所有示例都应可部署到任何符合 Jakarta EE 的实现。

Jakarta EE、Java EE、J2EE 和 Spring 框架

2017 年,Oracle 将 Java EE 捐赠给了 Eclipse Foundation,作为该过程的一部分,Java EE 被更名为 Jakarta EE。向 Eclipse Foundation 的捐赠意味着 Jakarta EE 规范真正实现了供应商中立,没有任何单一供应商对规范拥有控制权。

Java EE 是由 Sun Microsystems 在 2006 年引入的。Java EE 的第一个版本是 Java EE 5。Java EE 取代了 J2EE;J2EE 的最后一个版本是 J2EE 1.4,于 2003 年发布。尽管 J2EE 可以被认为是过时的技术,因为它几年前已被 Java EE 取代,并更名为 Jakarta EE,但术语 J2EE 仍然没有消失。时至今日,许多人仍然将 Jakarta EE 称为 J2EE,许多公司在他们的网站和招聘公告上宣称正在寻找“J2EE 开发者”,似乎没有意识到他们所指的技术已经过时了好几年。当前该技术的正确术语是 Jakarta EE。

此外,术语 J2EE 已经成为任何服务器端 Java 技术的“万能”术语;通常 Spring 应用程序被称为 J2EE 应用程序。Spring并不是,也从未是 J2EE。事实上,Spring 是由 Rod Johnson 在 2002 年作为 J2EE 的替代品创建的。就像 Jakarta EE 一样,Spring 应用程序经常被错误地称为 J2EE 应用程序。

摘要

在本章中,我们介绍了 Jakarta EE,概述了包含在 Jakarta EE 中的几个技术和 API 列表:

  • 我们介绍了 Jakarta EE 是如何通过 Eclipse 软件基金会,由软件供应商和整个 Java 社区公开开发的

  • 我们解释了 Jakarta EE 有多个实现,这一事实避免了供应商锁定,并允许我们轻松地将我们的 Jakarta EE 应用程序从一个实现迁移到另一个实现

  • 我们澄清了 Jakarta EE、Java EE、J2EE 和 Spring 之间的混淆,解释了尽管 J2EE 已经过时多年,但 Jakarta EE 和 Spring 应用程序经常被错误地称为 J2EE 应用程序。

现在我们已经对 Jakarta EE 有了总体了解,我们准备开始学习如何使用 Jakarta EE 来开发我们的应用程序。

第二章:上下文和依赖注入

上下文和依赖注入CDI)是一个强大的依赖注入框架,它允许我们轻松地将我们 Jakarta EE 应用程序的不同部分集成在一起。CDI 豆可以有不同的作用域,允许 Jakarta EE 运行时自动管理它们的生命周期。它们可以通过简单的注解轻松地注入为依赖项。CDI 还包括一个事件机制,允许我们应用程序的不同部分之间进行解耦通信。

在本章中,我们将涵盖以下主题:

  • 命名豆类

  • 依赖注入

  • 限定符

  • CDI 豆作用域

  • CDI 事件

  • CDI Lite

注意

本章的代码示例可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch02_src

命名豆类

CDI 通过@Named注解为我们提供了命名我们的豆的能力。命名豆允许我们轻松地将我们的豆注入到依赖它们的其他类中(参见下一节),并且可以通过统一的表达式语言轻松地从 Jakarta Faces 中引用它们。

注意

Jakarta Faces 在第六章第七章中有详细说明。

以下示例显示了@Named注解的实际应用:

package com.ensode.jakartaeebook.cdidependencyinjection.beans;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Named;
@Named
@RequestScoped
public class Customer {
  private String firstName;
  private String lastName;
  public String getFirstName() {
    return firstName;
  }
  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }
  public String getLastName() {
    return lastName;
  }
  public void setLastName(String lastName) {
    this.lastName = lastName;
  }
}

正如我们所见,我们只需要用@Named注解装饰我们的类来命名我们的类。默认情况下,豆的名称将是类名,其首字母转换为小写。在我们的例子中,豆的名称将是customer。如果我们想使用不同的名称,我们可以通过设置@Named注解的value属性来实现。例如,如果我们想为我们的豆使用名称customerBean,我们可以通过修改@Named注解如下:

@Named(value="customerBean")

或者,我们也可以简单地使用以下方法:

@Named("customerBean")

由于value属性名称不需要指定,如果我们不使用属性名称,则默认value

此名称可以用来通过统一的表达式语言从 Jakarta Faces 页面访问我们的豆:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html 
      >
  <h:head>
    <title>Enter Customer Information</title>
  </h:head>
  <h:body>
    <h:form>
      <h:panelGrid columns="2">
        <h:outputLabel for="firstName" value="First Name"/>
        <h:inputText id="firstName"
          value="#{customer.firstName}"/>
        <h:outputLabel for="lastName" value="Last Name"/>
        <h:inputText id="lastName"
          value="#{customer.lastName}"/>
        <h:panelGroup/>
        <h:commandButton value="Submit"
          action="#{customerController.saveCustomer}"/>
      </h:panelGrid>
    </h:form>
  </h:body>
</html>

在我们的例子中,firstNamelastName属性或我们的Customer命名豆绑定到我们的 Jakarta Faces 页面中的两个文本输入字段。

当部署和执行时,我们的简单应用程序看起来像这样:

图 2.1 – CDI 命名豆的实际应用

图 2.1 – CDI 命名豆的实际应用

现在我们已经看到了如何命名我们的 CDI 豆,我们将关注 CDI 的依赖注入功能。

依赖注入

@Inject注解,可以用来将 CDI 豆的实例注入到任何依赖对象中。

Jakarta Faces 应用程序通常遵循模型-视图-控制器(MVC)设计模式。因此,通常情况下,一些 Jakarta Faces 管理 bean 在模式中扮演控制器的角色,而其他则扮演模型的角色。这种方法通常要求控制器管理 bean 能够访问一个或多个模型管理 bean。CDI 的依赖注入能力使得将 bean 注入到另一个 bean 中变得非常简单,如下面的示例所示:

package com.ensode.jakartaeebook.cdinamedbeans.beans;
//imports omitted for brevity
@Named
@RequestScoped
public class CustomerController {
  private static final Logger logger = Logger.getLogger(
      CustomerController.class.getName());
  @Inject
  private Customer customer;
  public String saveCustomer() {
    logger.info("Saving the following information \n" +
      customer.toString());
    //If this was a real application,
    //we would have code to save customer data to the
    //database here.
    return "confirmation";
  }
}

注意,我们初始化Customer实例所需要做的只是用@Inject注解来注解它。当 bean 由应用服务器构建时,Customer bean 的实例会自动注入到这个字段中。注意,注入的 bean 在saveCustomer()方法中被使用。

正如我们在本节中看到的,CDI 依赖注入非常简单。我们只需用@Inject注解来注解我们希望注入的类的实例变量。

限定符

在某些情况下,我们希望注入到我们的代码中的 bean 类型可能是一个接口或 Java 超类。然而,我们可能对注入特定的子类或实现该接口的类感兴趣。对于这种情况,CDI 提供了我们可以用来指示我们希望注入到代码中的特定类型的限定符。

CDI 限定符是一个必须用@Qualifier注解装饰的注解。然后,我们可以使用这个注解来装饰我们希望限定的特定子类或接口实现。此外,客户端代码中注入的字段也需要用限定符进行装饰。

假设我们的应用程序可能有一种特殊的客户类型;例如,常客可能会被赋予高级客户的地位。为了处理这些高级客户,我们可以扩展我们的Customer命名 bean,并用以下限定符进行装饰:

package package com.ensode.jakartaeebook.qualifiers;
import jakarta.inject.Qualifier;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@Qualifier
@Retention(RUNTIME)
@Target({METHOD, FIELD, PARAMETER, TYPE})
public @interface Premium {
}

如前所述,限定符是标准注解。它们通常具有运行时保留,可以针对方法、字段、参数或类型,如前例所示。然而,限定符和标准注解之间的唯一区别是,限定符是用@Qualifier注解装饰的。

一旦我们设置了我们的限定符,我们需要用它来装饰特定的子类或接口实现,如下面的示例所示:

package com.ensode.jakartaeebook.cdidependencyinjection.beans;
import com.ensode.jakartaeebook.qualifiers.Premium;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Named;
@Named
@RequestScoped
@Premium
public class PremiumCustomer extends Customer {
  private Integer discountCode;
  public Integer getDiscountCode() {
    return discountCode;
  }
  public void setDiscountCode(Integer discountCode) {
    this.discountCode = discountCode;
  }
}

一旦我们装饰了特定的实例,我们需要对其进行限定。我们可以在控制器中使用我们的限定符来指定所需的精确依赖类型:

package com.ensode.jakartaeebook.cdidependencyinjection.beans;
import java.util.logging.Logger;
import com.ensode.jakartaeebook.qualifiers.Premium;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
@Named
@RequestScoped
public class CustomerController {
  private static final Logger logger = Logger.getLogger(
      CustomerController.class.getName());
  @Inject
  @Premium
  private Customer customer;
  public String saveCustomer() {
    PremiumCustomer premiumCustomer =
      (PremiumCustomer) customer;
    logger.info("Saving the following information \n"
        + premiumCustomer.getFirstName() + " "
        + premiumCustomer.getLastName()
        + ", discount code = "
        + premiumCustomer.getDiscountCode());
    //If this was a real application, we would have code
    // to save customer data to the database here.
    return "confirmation";
  }
}

由于我们使用了@Premium限定符来注解customer字段,因此将PremiumCustomer的实例注入到该字段,因为这个类也用@Premium限定符进行了装饰。

就我们的 Jakarta Faces 页面而言,我们只需像下面示例中那样使用其名称来通常访问我们的命名 bean:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html 
      >
    <h:head>
        <title>Enter Customer Information</title>
    </h:head>
    <h:body>
        <h:form>
            <h:panelGrid columns="2">
                <h:outputLabel for="firstName" value="First Name"/>
                <h:inputText id="firstName"
                   value="#{premiumCustomer.firstName}"/>
                <h:outputLabel for="lastName"
                  value="Last Name"/>
                <h:inputText id="lastName"
                   value="#{premiumCustomer.lastName}"/>
                <h:outputLabel for="discountCode"
                  value="Discount Code"/>
                <h:inputText id="discountCode"
value="#{premiumCustomer.discountCode}"/>
                <h:panelGroup/>
                <h:commandButton value="Submit" action=
                  "#{customerController.saveCustomer}"/>
            </h:panelGrid>
        </h:form>
    </h:body>
</html>

在此示例中,我们使用 Bean 的默认名称,即类名,首字母转换为小写。

就用户而言,我们的简单应用程序渲染和操作就像一个“普通”的 Jakarta Faces 应用程序。请参见图 2.2

图 2.2 – 浏览器中显示的优质客户数据

图 2.2 – 浏览器中显示的优质客户数据

现在我们已经看到了如何使用 CDI 限定符注入同一类型的不同实现,我们将关注 CDI 作用域。

CDI Bean 作用域

CDI 代表“上下文和依赖注入”,CDI Bean 有一个作用域,它定义了其生命周期,其作用域决定了 Jakarta EE 运行时何时创建和销毁 CDI Bean。“上下文”在“上下文和依赖注入”中指的是 CDI 作用域。当需要 CDI Bean 时,无论是由于注入还是因为它被 Jakarta Faces 页面引用,CDI 都会在其所属的作用域中查找 Bean 的实例,并将其注入到依赖的代码中。如果没有找到实例,就会创建一个并存储在适当的作用域中供将来使用。不同的作用域是 Bean 存在的上下文。

下表列出了不同的有效 CDI 作用域:

作用域 注解 描述
请求 @``RequestScoped 请求作用域的 Bean 在整个单个请求期间共享。单个请求可能指一个 HTTP 请求、对 EJB 中方法的调用、Web 服务调用,或者向消息驱动 Bean 发送 JMS 消息。
会话 @``ConversationScoped 会话作用域可以跨越多个请求,但通常比会话作用域短。
会话 @``SessionScoped 会话作用域的 Bean 在 HTTP 会话的所有请求之间共享。应用程序的每个用户都会获得一个会话作用域 Bean 的实例。
应用 @``ApplicationScoped 应用作用域的 Bean 在整个应用生命周期中存活。此作用域中的 Bean 在用户会话之间共享。
依赖 @``Dependent 依赖作用域的 Bean 不共享。每次注入依赖作用域的 Bean 时,都会创建一个新的实例。

表 2.1 – CDI 作用域

CDI 的请求作用域不一定指 HTTP 请求;它可能只是一个对 EJB 方法的调用、Web 服务调用,或者向消息驱动 Bean 发送 JMS 消息。

注入了jakarta.enterprise.context.Conversation。在我们要开始会话的点,必须在这个对象上调用begin()方法。在我们要结束会话的点,必须在这个对象上调用其end()方法。

CDI 的会话作用域将 CDI Bean 的生命周期绑定到 HTTP 会话。会话作用域的 CDI Bean 在首次注入时创建,并持续存在,直到 HTTP 会话被销毁,通常发生在用户从 Web 应用程序注销或关闭浏览器时。

CDI 的应用作用域将 CDI bean 的生命周期与应用程序的生命周期绑定。每个应用程序只有一个应用程序作用域 bean 的实例,这意味着相同的实例对所有 HTTP 会话都是可访问的。

如果没有明确指定,CDI 的依赖作用域是默认作用域。每次需要时,都会实例化一个新的依赖作用域 bean,通常是在将其注入依赖于它的类时。

对于 CDI 的大多数作用域,只需用所需的作用域注解注解我们的 CDI bean 即可。然后,Jakarta EE 运行时在幕后管理 bean 的生命周期。会话作用域需要我们做更多的工作,即我们需要指示会话何时开始和结束。因此,我们将使用会话作用域来展示 CDI 作用域的使用。

假设我们希望让用户输入一些将被存储在单个命名 bean 中的数据;然而,这个 bean 有几个字段。因此,我们希望将数据输入分成几个页面。对于这种情况,CDI 的会话作用域是一个很好的解决方案。以下示例说明了如何使用 CDI 的会话作用域:

package com.ensode.jakartaeebook.conversationscope.model;
import jakarta.enterprise.context.ConversationScoped;
import jakarta.inject.Named;
import java.io.Serializable;
@Named
@ConversationScoped
public class Customer implements Serializable {
  private String firstName;
  private String middleName;
  private String lastName;
  private String addrLine1;
  private String addrLine2;
  private String addrCity;
  private String state;
  private String zip;
  private String phoneHome;
  private String phoneWork;
  private String phoneMobile;
  //getters and setters omitted for brevity
  @Override
  public String toString() {
    StringBuilder sb = new StringBuilder();
    sb.append("Customer{");
    sb.append("firstName=").append(firstName);
    sb.append(", middleName=").append(middleName);
    sb.append(", lastName=").append(lastName);
    sb.append(", addrLine1=").append(addrLine1);
    sb.append(", addrLine2=").append(addrLine2);
    sb.append(", addrCity=").append(addrCity);
    sb.append(", state=").append(state);
    sb.append(", zip=").append(zip);
    sb.append(", phoneHome=").append(phoneHome);
    sb.append(", phoneWork=").append(phoneWork);
    sb.append(", phoneMobile=").append(phoneMobile);
    sb.append('}');
    return sb.toString();
  }
}

我们通过使用@ConversationScoped注解来装饰我们的 bean,声明我们的 bean 是会话作用域的。会话作用域 bean 还需要实现java.io.Serializable接口。除了这两个要求之外,我们的代码没有特别之处;它是一个简单的 Java 类,具有私有属性和相应的 getter 和 setter 方法。

除了注入我们的会话作用域 bean 之外,我们的客户端代码还必须注入jakarta.enterprise.context.Conversation的实例,如下例所示:

package com.ensode.jakartaeebook.conversationscope.controller;
import jakarta.enterprise.context.Conversation;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import com.ensode.jakartaeebook.conversationscope.model.Customer;
@Named
@RequestScoped
public class CustomerInfoController implements Serializable {
    @Inject
    private Conversation conversation;
    @Inject
    private Customer customer;
    public String customerInfoEntry() {
        conversation.begin();
        System.out.println(customer);
        return "page1";
    }
    public String navigateToPage1() {
        System.out.println(customer);
        return "page1";
    }
    public String navigateToPage2() {
        System.out.println(customer);
        return "page2";
    }
    public String navigateToPage3() {
        System.out.println(customer);
        return "page3";
    }
    public String navigateToConfirmationPage() {
        System.out.println(customer);
        conversation.end();
        return "confirmation";
    }
}

会话可以是长运行短暂的。短暂的会话在请求结束时结束。长运行会话跨越多个请求。在大多数情况下,我们将使用长运行会话来在 Web 应用程序中跨越多个 HTTP 请求保持对会话作用域 bean 的引用。

长运行会话在注入的Conversation实例上调用begin()方法时开始,并在我们在这个相同对象上调用end()方法时结束。

Jakarta Faces 页面像往常一样访问我们的 CDI bean:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html 
      >
    <h:head>
        <title>Customer Information</title>
    </h:head>
    <h:body>
        <h3>Enter Customer Information (Page 1 of 3)</h3>
        <h:form>
            <h:panelGrid columns="2">
                <h:outputLabel for="firstName"
                  value="First Name"/>
                <h:inputText  id="firstName"
                  value="#{customer.firstName}"/>
                <h:outputLabel for="middleName"
                  value="Middle Name"/>
                <h:inputText id="middleName"
                  value="#{customer.middleName}"/>
                <h:outputLabel for="lastName"
                  value="Last Name"/>
                <h:inputText id="lastName"
                  value="#{customer.lastName}"/>
                <h:panelGroup/>
                <h:commandButton value="Next"
                  action=
               "#{customerInfoController.navigateToPage2}"/>
            </h:panelGrid>
        </h:form>
    </h:body>
</html>

当我们从一页导航到下一页时,我们保持我们的会话作用域 bean 的同一实例;因此,所有用户输入的数据都保持不变。当在会话 bean 上调用end()方法时,会话结束,我们的会话作用域 bean 被销毁。

将我们的 bean 保持在会话作用域中简化了实现“向导式”用户界面的任务,其中数据可以在多个页面中输入。参见图 2**.3

图 2.3 – CDI 会话作用域示例的第 1 页

图 2.3 – CDI 会话作用域示例的第 1 页

在我们的示例中,在点击第一页上的下一步按钮后,我们可以在应用服务器日志中看到我们的部分填充的 bean。

[2023-05-28T08:33:35.113817-04:00] [GF 7.0.4] [INFO] [] [jakarta.enterprise.logging.stdout] [tid: _ThreadID=35 _ThreadName=http-listener-1(2)] [levelValue: 800] [[
  Customer{firstName=Daniel, middleName=, lastName=Jones, addrLine1=null, addrLine2=null, addrCity=null, state=null, zip=null, phoneHome=null, phoneWork=null, phoneMobile=null}]]

在这一点上,我们简单向导的第二页被显示,如图图 2**.4所示。

图 2.4 – CDI 会话作用域示例的第 2 页

图 2.4 – CDI 会话作用域示例的第 2 页

当点击下一步时,我们可以看到在我们的会话作用域 bean 中填充了额外的字段。

[2023-05-28T08:44:23.434029-04:00] [GF 7.0.4] [INFO] [] [jakarta.enterprise.logging.stdout] [tid: _ThreadID=36 _ThreadName=http-listener-1(3)] [levelValue: 800] [[
  Customer{firstName=Daniel, middleName=, lastName=Jones, addrLine1=123 Basketball Ct, addrLine2=, addrCity=Montgomery, state=AL, zip=36101, phoneHome=, phoneWork=, phoneMobile=}]]

当我们在向导中提交第三页(未显示)时,与该页面上字段对应的额外 bean 属性将被填充。

当我们到达不再需要将客户信息保留在内存中的点时,我们需要在注入到我们代码中的会话 bean 上调用end()方法。这正是我们在显示确认页面之前在代码中所做的:

public String navigateToConfirmationPage() {
        System.out.println(customer);
        conversation.end();
        return "confirmation";
    }

在完成显示确认页面的请求后,我们的会话作用域 bean 被销毁,因为我们调用了注入的Conversation类中的end()方法。

现在我们已经看到了 CDI 支持的所有作用域,我们将把注意力转向如何通过 CDI 事件实现松散耦合的通信。

CDI 事件

CDI 提供了事件处理功能。事件允许不同 CDI bean 之间的松散耦合通信。一个 CDI bean 可以触发一个事件,然后一个或多个事件监听器处理该事件。

触发 CDI 事件

下面的示例是我们在前一节中讨论的CustomerInfoController类的新版本。该类已被修改为每次用户导航到新页面时触发一个事件:

package com.ensode.jakartaeebook.cdievents.controller;
import jakarta.enterprise.context.Conversation;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import com.ensode.jakartaeebook.cdievents.event.NavigationInfo;
import com.ensode.jakartaeebook.cdievents.model.Customer;
import jakarta.enterprise.event.Event;
@Named
@RequestScoped
public class CustomerInfoController implements Serializable {
    @Inject
    private Conversation conversation;
    @Inject
    private Customer customer;
    @Inject
    private Event<NavigationInfo> navigationInfoEvent;
    public String customerInfoEntry() {
        conversation.begin();
        NavigationInfo navigationInfo =
          new NavigationInfo();
        navigationInfo.setPage("1");
        navigationInfo.setCustomer(customer);
        navigationInfoEvent.fire(navigationInfo);
        return "page1";
    }
    public String navigateToPage1() {
        NavigationInfo navigationInfo =
          new NavigationInfo();
        navigationInfo.setPage("1");
        navigationInfo.setCustomer(customer);
        navigationInfoEvent.fire(navigationInfo);
        return "page1";
    }
    public String navigateToPage2() {
        NavigationInfo navigationInfo =
          new NavigationInfo();
        navigationInfo.setPage("2");
        navigationInfo.setCustomer(customer);
        navigationInfoEvent.fire(navigationInfo);
        return "page2";
    }
    public String navigateToPage3() {
        NavigationInfo navigationInfo =
          new NavigationInfo();
        navigationInfo.setPage("3");
        navigationInfo.setCustomer(customer);
        navigationInfoEvent.fire(navigationInfo);
        return "page3";
    }
    public String navigateToConfirmationPage() {
        NavigationInfo navigationInfo =
          new NavigationInfo();
        navigationInfo.setPage("confirmation");
        navigationInfo.setCustomer(customer);
        navigationInfoEvent.fire(navigationInfo);
        conversation.end();
        return "confirmation";
    }
}

如我们所见,要创建一个事件,我们需要注入一个jakarta.enterprise.event.Event实例。这个类使用泛型;因此,我们需要指定其类型。Event类的类型可以是任何实现java.io.Serializable的类。在我们的情况下,我们正在传递一个简单的 POJO 的实例作为类型参数,我们的 POJO 被称为NavigationInfo并有两个属性,一个是Customer类型,另一个是包含用户正在导航到的页面的String。回想一下,在前几节中,我们CustomerInfoController类上的每个方法都会触发从应用的一个页面到另一个页面的导航。在这个控制器的这个版本中,每次我们导航到新页面时都会触发一个 CDI 事件。在这种情况下,我们创建NavigationInfo的新实例,填充它,然后通过在jakarta.enterprise.event.Event的实例上调用fire()方法来触发事件。

处理 CDI 事件

要处理 CDI 事件,处理事件的 CDI bean 需要实现一个NavigationInfo,正如在前一节中我们事件声明的声明中可以看到。为了处理事件,观察方法需要用@Observes注解标注相应的参数,如下面的示例所示:

package com.ensode.jakartaeebook.cdievents.eventlistener;
import jakarta.enterprise.context.SessionScoped;
import jakarta.enterprise.event.Observes;
import java.io.Serializable;
import com.ensode.jakartaeebook.cdievents.event.NavigationInfo;
import java.util.logging.Level;
import java.util.logging.Logger;
@SessionScoped
public class NavigationEventListener implements Serializable {
     private static final Logger LOG =
       Logger.getLogger(
       NavigationEventListener.class.getName());
    public void handleNavigationEvent(
      @Observes NavigationInfo navigationInfo) {
        LOG.info("Navigation event fired");
        LOG.log(Level.INFO, "Page: {0}",
          navigationInfo.getPage());
        LOG.log(Level.INFO, "Customer: {0}",
          navigationInfo.getCustomer());
    }
}

在此事件处理器示例中,handleNavigationEvent()方法接受一个NavigationInfo实例作为参数。请注意,此参数被注解为@Observes。这导致 CDI 在每次触发类型为NavigationInfo的事件时自动调用该方法。请注意,我们从未显式调用此方法;每当导航事件被触发时,Jakarta EE 运行时会自动调用它。

CDI 事件允许我们在 CDI beans 之间实现松散耦合的通信。在我们的示例中,请注意我们的CustomerController CDI bean 没有直接引用NavigationEventListener。一般来说,触发事件的 CDI bean 不需要了解任何关于监听器的细节;它只需触发事件,然后 CDI 接管细节。

注意

在我们的示例中,我们只有一个事件监听器,但在实践中,我们可以有我们需要的任意多个事件监听器。

异步事件

CDI 具有异步触发事件的能力。异步触发事件可以帮助提高性能,因为各种观察者方法可以并发调用。异步触发事件与同步触发事件非常相似。唯一的语法区别是我们不是在Event实例中调用fire()方法,而是调用其fireAsync()方法。以下示例说明了如何做到这一点:

public class EventSource{
  @Inject Event<MyEvent> myEvent;
  public void fireEvent(){
    myEvent.fireAsync(myEvent);
  }
}

处理异步事件的观察者方法与其同步对应者相同。

事件排序

CDI 2.0 中引入的另一个新功能是能够指定我们的观察者方法处理 CDI 事件的顺序。这可以通过@Priority注解实现,如下面的示例所示:

import jakarta.enterprise.context.SessionScoped;
import jakarta.enterprise.event.Observes;
import jakarta.annotation.Priority;
import jakarta.interceptor.Interceptor;
@SessionScoped
public class EventHandler{
    void handleIt (
@Observes @Priority(
       Interceptor.Priority.APPLICATION) MyEvent me){
    //handle the event
  }
}

@Priority注解接受一个类型为int的参数。此参数指定了观察者方法的优先级。最高优先级由Interceptor.Priority类中定义的APPLICATION常量定义。这是我们给示例中观察者方法赋予的优先级。较低优先级的值具有优先权,默认优先级是Interceptor.Priority.APPLICATION + 100

第一章所述,除了完整的 Jakarta EE 规范外,如果我们开发的是不需要 Jakarta EE 全部功能的应用程序,我们还可以使用两个 Jakarta EE 配置文件。一个是 Web Profile,适用于 Web 应用程序;另一个是 Core Profile,适用于微服务。Core Profile 包括 CDI 支持,但不支持 CDI 的所有功能。包含在 Jakarta EE Core Profile 中的这个轻量级 CDI 版本被称为 CDI Lite。

CDI Lite

Jakarta EE Core Profile 包含完整的 CDI 规范的一个子集,恰当地命名为 CDI Lite。CDI Lite 的大部分更改都在实现层面;也就是说,一些在运行时由完整 CDI 实现执行的功能被移动到构建时间,使得利用 CDI Lite 的应用程序可以更快地初始化。

CDI Lite 主要适用于微服务应用程序,实现 RESTful Web 服务的功能。由于 REST 应用程序通常是无状态的,因此在开发此类应用程序时,并非所有 CDI 作用域都适用。因此,当使用 CDI Lite 时,会话和会话作用域不可用。这是与完整的 CDI 规范相比,CDI Lite 的主要限制。

当我们将代码部署到 Jakarta EE Core Profile 实现时,我们只需要关注 CDI Lite 的限制。Jakarta EE Web Profile 和完整的 Jakarta EE 平台包含完整的 CDI 功能。

摘要

在本章中,我们介绍了 CDI,它是 Jakarta EE 规范的一个核心部分。我们探讨了以下内容:

  • 我们还介绍了 Jakarta Faces 页面如何通过统一表达式语言访问 CDI 命名的 Bean。

  • 我们还介绍了如何通过@Inject注解使 CDI(控制反转)使我们的代码注入依赖变得简单。

  • 此外,我们还解释了如何使用限定符来确定将哪种特定的依赖注入到我们的代码中。

  • 我们还讨论了 CDI Bean 可以放置的所有作用域,使我们能够将 CDI Bean 的生命周期委托给 Jakarta EE 运行时。

  • 我们讨论了如何通过 CDI 的事件处理实现 CDI Bean 之间的松耦合通信。

  • 最后,我们讨论了 CDI Lite,这是 CDI 的一个轻量级版本,适合微服务开发。

CDI 是 Jakarta EE 的一个核心部分,因为它被用来整合我们的 Jakarta EE 应用程序的不同层。

第三章:Jakarta RESTful Web 服务

表示性状态转移 (REST) 是一种架构风格,在这种风格中,Web 服务被视为资源,并且可以通过 统一资源标识符 (URIs) 来识别。

使用 REST 风格开发的 Web 服务被称为 RESTful Web 服务。我们可以在 Jakarta EE 中通过 Jakarta RESTful Web Services API(通常称为 Jakarta REST)来开发 RESTful Web 服务。在本章中,我们将介绍如何使用 Jakarta REST 开发 RESTful Web 服务。

本章将涵盖以下主题:

  • RESTful Web 服务简介

  • 开发简单的 RESTful Web 服务

  • 开发 RESTful Web 服务客户端

  • 无缝地在 Java 和 JSON 之间转换

  • 查询和路径参数

  • 服务器发送事件

注意

本章的代码示例可以在 github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch03_src 找到。

RESTful Web 服务简介

RESTful Web 服务非常灵活。虽然它们通常被编写为消费和/或生成 JavaScript 对象表示法 (JSON) 格式的数据,但 RESTful Web 服务可以消费多种不同类型的 MIME 类型。

MIME 类型

MIME 代表多用途互联网邮件扩展;它用于指示 RESTful Web 服务消费或生成的数据类型。

Web 服务必须支持以下六种 HTTP 方法中的一种或多种:

  • GET – 按照惯例,GET 请求用于检索现有资源

  • POST – 按照惯例,POST 请求用于更新现有资源

  • PUT – 按照惯例,PUT 请求用于创建或替换新资源

  • DELETE – 按照惯例,DELETE 请求用于删除现有资源

  • HEAD – 按照惯例,HEAD 请求返回一个没有主体的 HTTP 头

  • PATCH – 按照惯例,PATCH 请求用于部分修改资源

我们通过创建一个类,并使用注解方法来装饰 RESTful Web 服务资源类,这些方法在 Web 服务接收到上述 HTTP 请求方法之一时被调用,来使用 Jakarta REST 开发 RESTful Web 服务。一旦我们开发和部署了我们的 RESTful Web 服务,我们就需要开发一个客户端来向我们的服务发送请求。前端 Web 技术通常用于开发 RESTful Web 服务客户端。然而,Jakarta REST 包含了一个标准的客户端 API,我们可以使用它来用 Java 开发 RESTful Web 服务客户端。

开发简单的 RESTful Web 服务

在本节中,我们将开发一个简单的 Web 服务来展示我们如何使我们的服务中的方法响应不同的 HTTP 请求方法。

使用 Jakarta REST 开发 RESTful Web 服务简单直接。我们的每个 RESTful Web 服务都需要通过其 @Path 注解来调用,我们需要使用它来装饰我们的 RESTful Web 服务资源类。

在开发 RESTful Web 服务时,我们需要开发当我们的 Web 服务收到 HTTP 请求时将被调用的方法。我们需要实现方法来处理 RESTful Web 服务处理的六种请求类型之一或多个——GET、POST、PUT、DELETE、HEAD 和/或 PATCH。

注意

我们将只介绍最常用的 HTTP 请求类型,因为实现所有 HTTP 请求类型非常相似。

Jakarta REST 提供了我们可以用来装饰我们 Web 服务中的方法的注解。例如,@GET@POST@PUT@DELETE用于实现相应的 HTTP 方法。在我们的 Web 服务中用这些注解装饰一个方法,将使其能够响应相应的 HTTP 方法请求。

此外,我们服务中的每个方法都必须产生和/或消费一个特定的 MIME 类型。需要产生的 MIME 类型需要用@Produces注解来指定。同样,需要消费的 MIME 类型必须用@Consumes注解来指定。

以下示例说明了我们刚刚解释的概念:

package com.ensode.jakartaeebook.jakartarestintro.service;
//imports omitted for brevity
@Path("customer")
public class CustomerResource {
  private static final Logger LOG =
      Logger.getLogger(CustomerResource.class.getName());
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public String getCustomer() {
    LOG.log(Level.INFO, "{0}.getCustomer() invoked",
        this.getClass().getCanonicalName());
    return """
              {
                "customer": {
                  "id": 123,
                  "firstName": "Joseph",
                  "middleName": "William",
                  "lastName": "Graystone"
                }
              }
              """;
  }
  @PUT
  @Consumes(MediaType.APPLICATION_JSON)
  public void createCustomer(String customerJson) {
    LOG.log(Level.INFO, "{0}.createCustomer() invoked",
        this.getClass().getCanonicalName());
    LOG.log(Level.INFO, "customerJson = {0}", customerJson);
  }
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  public void updateCustomer(String customerJson) {
    LOG.log(Level.INFO, "{0}.updateCustomer() invoked",
      this.getClass().getCanonicalName());
    LOG.log(Level.INFO, "customerJson = {0}", customerJson);
  }
}

注意

请注意,这个示例实际上并没有做什么。示例的目的是说明如何使我们的 RESTful Web 服务资源类中的不同方法响应不同的 HTTP 方法。

注意,这个类使用了@Path注解。这个注解指定了我们的 RESTful Web 服务的 URI。我们服务的完整 URI 将包括协议、服务器名称、端口、上下文根、REST 资源路径(见下一小节)以及传递给此注解的值。

假设我们的 Web 服务部署到了一个名为example.com的服务器上,使用 HTTP 协议在端口8080上,并且有一个上下文根为jakartarestintro和 REST 资源路径为resources,那么我们服务的完整 URI 将是以下内容:

http://example.com:8080/jakartarestintro/resources/customer

注意

由于 Web 浏览器在指向 URL 时会生成 GET 请求,因此我们可以通过将浏览器指向我们的服务 URI 来简单地测试我们服务的 GET 方法。

注意到我们课程中的每个方法都使用@GET@POST@PUT注解之一进行了标注。这些注解使得我们的方法能够响应相应的 HTTP 方法。

注意

HTTP DELETE 请求通常需要一个参数。我们将在本章后面的“路径和查询参数”部分介绍它们。

此外,如果我们的方法需要向客户端返回数据,我们可以在 @Produces 注解中声明要返回的数据的 MIME 类型。在我们的例子中,只有 getCustomer() 方法向客户端返回数据。我们希望以 JSON 格式返回数据;因此,我们将 @Produces 注解的值设置为 Jakarta REST 提供的 MediaType.APPLICATION_JSON 常量,其值为 "application/json"。同样,如果我们的方法需要从客户端消耗数据,我们需要指定要消耗的数据的 MIME 类型;这是通过 @Consumes 注解来完成的。在我们的服务中,除了 getCustomer() 之外的所有方法都消耗数据。在所有情况下,我们期望数据以 JSON 格式;因此,我们再次指定 MediaType.APPLICATION_JSON 作为要消耗的 MIME 类型。

在继续之前,值得提一下的是,@Path 注解可以在类级别和方法级别同时使用。在方法级别使用 @Path 注解允许我们在单个 RESTful Web 服务中编写多个处理相同 HTTP 请求类型的方法。例如,如果我们需要在我们的示例 RESTful Web 服务中添加第二个方法,我们只需简单地将 @Path 注解添加到它上面:

  @GET
  @Produces(MediaType.TEXT_PLAIN)
  @Path("customername")
  public String getCustomerName() {
    return "Joseph Graystone";
  }

这个特定端点的 URI 将是我们在方法级 @Path 注解中使用的值,附加到我们 RESTful Web 服务的 URI 上。

在我们的例子中,我们的 RESTful Web 服务的 URI 可能类似于 localhost:8080/jakartarestintro/resources/customer/,假设我们部署到本地工作站,并且服务器正在监听端口 8080

该方法级注解的端点 URI 将是 curl http://localhost:8080/jakartarestintro/resources/customer/customername。请注意,方法级 @Path 注解的值被附加到了我们 RESTful Web 服务的“根”URI 上。

配置我们应用的 REST 资源路径

如前一小节简要提到的,在成功部署使用 Jakarta REST 开发的 RESTful Web 服务之前,我们需要为我们的应用配置 REST 资源路径。我们可以通过开发一个扩展 jakarta.ws.rs.core.Application 的类,并用 @ApplicationPath 注解来装饰它来实现这一点。

通过 @ApplicationPath 注解进行配置

要配置我们的 REST 资源路径,我们只需要编写一个扩展 jakarta.ws.rs.core.Application 的类,并用 @ApplicationPath 注解来装饰它;传递给这个注解的值是我们服务的 REST 资源路径。

以下代码示例说明了这个过程:

package com.ensode.jakartaeebook.jakartarestintro.service.config;
//imports omitted for brevity
@ApplicationPath("resources")
public class JakartaRestConfig extends Application {
}

注意,该类不需要实现任何方法。它只需要扩展 jakarta.ws.rs.Application 并用 @ApplicationPath 注解来注解;该类必须是公开的,可以有任何名称,并且可以放在任何包中。

测试我们的 Web 服务

正如我们之前提到的,网络浏览器会将 GET 请求发送到我们指向的任何 URL。因此,测试我们的服务 GET 请求的最简单方法是将浏览器指向我们的服务 URI,如图图 3.1所示。

图 3.1 – 来自网络浏览器的 HTTP GET 请求

图 3.1 – 来自网络浏览器的 HTTP GET 请求

注意

Firefox 包含一个默认解析 JSON 数据并以用户友好的方式显示的 JSON 查看器。要在 Firefox 中查看我们的服务发送的实际 JSON 字符串,我们需要点击原始数据标签。

网络浏览器仅支持 HTTP GET 和 POST 请求。要通过浏览器测试 POST 请求,我们必须编写一个包含具有我们的服务 URI 作为 action 属性值的 HTML 表单的 Web 应用程序。虽然对于一个单一的服务来说这很 trivial,但对我们开发的每个 RESTful Web 服务来说,这样做可能会变得繁琐。

幸运的是,有一个流行的开源命令行工具curl,我们可以用它来测试我们的 Web 服务。curl包含在大多数 Linux 发行版中,并且可以轻松地下载到 Windows、macOS 和几个其他平台。curl可以在curl.haxx.se/下载。

curl可以向我们的服务发送所有 HTTP 请求方法类型(GET、POST、PUT、DELETE 等)。我们的服务器响应将简单地显示在命令行控制台上。curl有一个-X命令行选项,允许我们指定要发送的请求方法。要发送 GET 请求,我们只需在命令行中输入以下内容:

curl -XGET http://localhost:8080/jakartarestintro/resources/customer

这样做会产生以下输出:

{
  "customer": {
    "id": 123,
    "firstName": "Joseph",
    "middleName": "William",
    "lastName": "Graystone"
  }
}

这并不令人惊讶,这是我们指向服务 URI 时看到的相同输出。

curl的默认请求方法是 GET;因此,我们前面示例中的-X参数是多余的。我们可以通过从命令行调用以下命令来达到相同的结果:

curl http://localhost:8080/jakartarestintro/resources/customer

在提交上述两个命令之一并检查应用程序服务器日志后,我们应该看到我们添加到getCustomer()方法中的日志语句的输出:

com.ensode.jakartaeebook.jakartarestintro.service.CustomerResource.getCustomer() invoked|#]

注意

应用程序服务器日志的确切位置取决于我们使用的 Jakarta EE 实现。对于 GlassFish,当使用默认域时,它可以在[glassfish 安装目录]/glassfish/domains/domain1/logs/server.log找到。

对于所有其他请求方法类型,我们需要向我们的服务发送一些数据。这可以通过curl--data命令行参数来完成:

curl -XPUT -HContent-type:application/json --data "{
  "customer": {
    "id": 321,
    "firstName": "Amanda",
    "middleName": "Zoe",
    "lastName": "Adams"
  }
}" http://localhost:8080/jakartarestintro/resources/customer

我们需要通过curl-H命令行参数指定 MIME 类型,格式如前例所示。

我们可以通过检查应用程序服务器日志来验证前面的命令是否按预期工作:

com.ensode.jakartaeebook.jakartarestintro.service.CustomerResource.createCustomer() invoked|#]
  customerJson = {
  customer: {
    id: 321,
    firstName: Amanda,
    middleName: Zoe,
    lastName: Adams
  }
}|#]

我们可以像测试其他请求方法类型一样轻松地测试:

curl -XPOST -HContent-type:application/json --data "{
  "customer": {
    "id": 321,
    "firstName": "Amanda",
    "middleName": "Tamara",
    "lastName": "Adams"
  }
}" http://localhost:8080/jakartarestintro/resources/customer

这导致应用程序服务器日志中出现以下输出:

com.ensode.jakartaeebook.jakartarestintro.service.CustomerResource.updateCustomer() invoked|#]
  customerJson = {
  customer: {
    id: 321,
    firstName: Amanda,
    middleName: Tamara,
    lastName: Adams
  }
}|#]

curl允许我们快速测试我们的 RESTful 网络服务。然而,在实际应用中,我们需要开发 RESTful 网络服务客户端来调用我们的 RESTful 网络服务并从中检索数据。Jakarta REST 提供了一个客户端 API,我们可以用它来实现这个目的。

开发 RESTful 网络服务客户端

虽然curl允许我们快速测试我们的 RESTful 网络服务,并且是一个开发者友好的工具,但我们需要一种方法让我们的 Java 应用程序调用我们开发的 RESTful 网络服务。Jakarta REST 包括一个客户端 API,我们可以用它来开发 RESTful 网络服务客户端。

以下示例说明了如何使用 Jakarta REST 客户端 API:

package com.ensode.jakartaeebook.jakartarestintroclient;
//imports omitted for brevity
public class App {
  public static void main(String[] args) {
    App app = new App();
    app.insertCustomer();
  }
  public void insertCustomer() {
    String customerJson = """
                          {
                            "customer": {
                              "id": 234,
                              "firstName": "Tamara",
                              "middleName": "Adeline",
                              "lastName": "Graystone"
                            }
                          }
                          """;
        Client client = ClientBuilder.newClient();
        client.target(
          "http://localhost:8080/" +
          "jakartarestintro/resources/customer").
          request().put(
            Entity.entity(customerJson,
              MediaType.APPLICATION_JSON),
              String.class);
    }
}

我们需要做的第一件事是通过在jakarta.ws.rs.client.ClientBuilder类上调用静态的newClient()方法来创建一个jakarta.ws.rs.client.Client实例。

然后,我们在Client实例上调用target()方法,将我们的 RESTful 网络服务的 URI 作为参数传递。target()方法返回一个实现jakarta.ws.rs.client.WebTarget接口的类的实例。

在这一点上,我们在我们的WebTarget实例上调用request()方法。此方法返回jakarta.ws.rs.client.Invocation.Builder接口的实现。

在这个特定的例子中,我们正在向我们的 RESTful 网络服务发送一个 HTTP PUT 请求;因此,在这个点上,我们调用我们的Invocation.Builder实现的put()方法。

put()方法的第一个参数是一个jakarta.ws.rs.client.Entity实例;我们可以通过在Entity类上调用静态的entity()方法即时创建一个。此方法的第一个参数是我们希望传递给我们的 RESTful 网络服务的对象,第二个参数是我们将传递给 RESTful 网络服务的数据的 MIME 类型的字符串表示。在我们的例子中,我们使用MediaType.APPLICATION_JSON常量,它解析为"application/json"put()方法的第二个参数是客户端期望从服务中获取的响应类型。在我们的情况下,我们期望一个String;因此,我们为这个参数使用了String.class。在我们调用put()方法后,一个 HTTP PUT 请求被发送到我们的 RESTful 网络服务,并且被@Put注解装饰的方法(在我们的例子中是createCustomer())被调用。我们还可以调用类似的get()post()delete()方法来向我们的 RESTful 网络服务发送相应的 HTTP 请求。

现在我们已经看到了如何开发 RESTful 网络服务和客户端,我们将探讨 Jakarta EE 如何无缝地在 Java 和 JSON 之间转换。

无缝地在 Java 和 JSON 之间转换

RESTful 网络服务通常以纯文本形式传输数据,但不仅限于 JSON 格式的数据。在我们之前的例子中,我们一直在我们的 RESTful 服务和它们的客户端之间发送和接收 JSON 字符串。

经常情况下,我们希望从接收到的 JSON 数据中填充 Java 对象,以某种方式处理数据,然后构建一个 JSON 字符串作为响应。从 JSON 填充 Java 对象以及从 Java 对象生成 JSON 数据是如此常见,以至于 Jakarta REST 实现提供了一种无缝自动执行此操作的方法。

在本章前面的示例中,我们一直是发送和接收原始 JSON 数据作为字符串。我们的样本数据包含客户信息,如名字、中间名和姓氏。为了使这些数据更容易处理,我们通常会使用这些数据填充一个 Java 对象;例如,我们可以解析 JSON 数据并填充以下 Java 类的实例:

package com.ensode.jakartaeebook.javajson.entity;
public class Customer {
  private Long id;
  private String firstName;
  private String middleName;
  private String lastName;
  public Customer() {
  }
  public Customer(Long id, String firstName,
    String middleName, String lastName) {
    //constructor body omitted for brevity
  }
  //getters,setters and toString() method omitted for brevity
}

注意我们的Customer类的实例变量名称与我们的 JSON 数据的属性名称相匹配;Jakarta REST 足够智能,能够自动将每个变量填充为相应的 JSON 属性。只要属性名称和变量名称匹配,Jakarta REST 就可以自动填充我们的 Java 对象(不言而喻,类型也必须匹配;例如,尝试使用文本值填充Integer类型的变量将引发错误)。

以下示例说明了我们如何实现一个 RESTful Web 服务,该服务可以无缝地将接收到的 JSON 数据转换为 Java 对象:

package com.ensode.jakartaeebook.javajson.service;
//imports omitted for brevity
@Path("customer")
public class CustomerResource {
  private static final Logger LOG =
    Logger.getLogger(CustomerResource.class.getName());
  private final Customer customer;
  public CustomerResource() {
    customer = new Customer(1L, "David",
      "Raymond", "Heffelfinger");
  }
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public Customer getCustomer() {
    LOG.log(Level.INFO, "{0}.getCustomer() invoked",
      this.getClass().getCanonicalName());
    return customer;
  }
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  public void updateCustomer(Customer customer) {
    LOG.log(Level.INFO, "got the following customer: {0}",
      customer);
  }
  @PUT
  @Consumes(MediaType.APPLICATION_JSON)
  public void createCustomer(Customer customer) {
    LOG.log(Level.INFO, "customer = {0}", customer);
  }
}

注意我们唯一需要做的就是将我们方法参数的类型更改为Customer类型(我们的简单示例 POJO);之前它们是String类型。同样,我们将返回值的类型从String更改为Customer

注意在我们的代码中,为了填充Customer对象或生成要作为响应发送的 JSON 字符串,我们不需要做任何特殊的事情;这一切都是由 Jakarta REST 在幕后处理的。

为了说明这种无缝转换,让我们使用curl向我们的修改后的服务发送一个请求:

curl -XPUT -HContent-type:application/json --data '{
  "id": 1,
  "firstName": "Bruce",
  "middleName": "Arnold",
  "lastName": "Stallone"
}' http://localhost:8080/jakartarestjavajson/resources/customer

注意在我们的curl命令中,我们正在向我们的 Jakarta REST 服务发送 JSON 数据。如果我们检查应用程序服务器日志,我们可以看到以下输出:

  com.ensode.jakartaeebook.javajson.service.CustomerResource.createCustomer() invoked|#]
  toString() method of our Customer class, illustrating that the createCustomer(Customer customer) method in our service was invoked, and it seamlessly populated its customer parameter with the raw JSON data we sent it with our curl command.
When using non-Java clients, such as `curl`, or RESTful web service clients written in languages other than Java, we need to send raw JSON data to our service from the client side, as illustrated in our example. When writing clients in Java, we can take advantage of the Jakarta REST client API, which allows seamless conversion from Java to JSON on the client side as well. The following example illustrates how to do this:

package com.ensode.jakartarestjavajsonclient;

//省略导入以节省空间

public class App {

public static void main(String[] args) {

App app = new App();

app.insertCustomer();

}

public void insertCustomer() {

Customer customer = new Customer(456L, "Daniel",

"Robert","Hanson");

Client client = ClientBuilder.newClient();

client.target(

"http://localhost:8080/"

  • "jakartarestjavajson/resources/customer").

request().put(

Entity.entity(customer, MediaType.APPLICATION_JSON),

Customer.class);

}

}


 In this updated client, we simply create an instance of our `Customer` class. Then we can pass it as a parameter to the static `entity()` method of the `Entity` class, and pass the corresponding type (`Customer.class`, in our example) to the `put()` method.
After running our client code, we can see the following output in the application server log:

com.ensode.jakartaeebook.javajson.service.CustomerResource.createCustomer() invoked|#]

customer = Customer{id=456, firstName=Daniel, middleName=Robert, lastName=Hanson}|#]


 Our client seamlessly converted our `Customer` instance to a JSON string, and invoked our RESTful web service, which in turn converted the sent JSON back to a `Customer` instance; all behind the scenes, saving us a lot of drudge work.
So far, in all of our examples, we have been passing a JSON string (either directly or indirectly) as a body to the HTTP requests we have been sending to our RESTful web services. It is also possible to pass parameters to our RESTful web services. The following section illustrates how to do that.
Query and path parameters
In our previous examples, we have been working with a RESTful web service to manage a single customer object. In real life, this would obviously not be very helpful. The common case is to develop a RESTful web service to handle a collection of objects (customers, in our example). To determine what specific object in the collection we are working with, we can pass parameters to our RESTful web services. There are two types of parameters we can use: **query** and **path** parameters.
Query parameters
We can add parameters to methods that will handle HTTP requests in our web service. Parameters decorated with the `@QueryParam` annotation will be retrieved from the request URL.
The following example illustrates how to use query parameters in RESTful web services using Jakarta REST:

package com.ensode.jakartaeebook.queryparams.service;

//省略导入以节省空间

@Path("customer")

public class CustomerResource {

private static final Logger LOG =

Logger.getLogger(CustomerResource.class.getName());

private final Customer customer;

public CustomerResource() {

customer = new Customer(1L, "Samuel",

"Joseph", "Willow");

}

@GET

@Produces(MediaType.APPLICATION_JSON)

public Customer getCustomer(@QueryParam("id") Long id) {

LOG.log(Level.INFO,

"{0}.getCustomer() invoked, id = {1}", new Object[]

{this.getClass().getCanonicalName(), id});

return new Customer(id, "Dummy", null, "Customer");

}

@DELETE

@Consumes(MediaType.APPLICATION_JSON)

public void deleteCustomer(@QueryParam("id") Long id) {

LOG.log(Level.INFO,

"{0}.deleteCustomer() invoked, id = {1}",

new Object[]

{this.getClass().getCanonicalName(), id});

}

//additional methods deleted for brevity

}


 In our updated example, we added a parameter to the `getCustomer()` method, which, as we may recall, is decorated with the `@GET` annotation so that it is invoked when our RESTful web service receives an HTTP GET request. We annotated the parameter with the `@QueryParam` annotation; the value of this annotation (`id` in our example) is the name of the parameter to use when sending a request to our service.
We can pass a query parameter to the web service’s URL just like we pass query parameters to any URL. For HTTP GET requests, we could simply type our RESTful web service’s URL into the browser, or we could use `curl` as follows:

curl -XGET -HContent-type:application/json http://localhost:8080/queryparams/resources/customer?id=1


 Either way, we should see the corresponding output in the application server log:

com.ensode.jakartaeebook.queryparams.service.CustomerResource.getCustomer() invoked, id = 1|#]


 Plus, we should see the following response from our RESTful web service:

{"firstName":"Dummy","id":1,"lastName":"Customer"}


 Notice the response is a JSON representation of the `Customer` object we return in our `getCustomer()` object. We didn’t have to explicitly create the JSON data; Jakarta REST took care of it for us.
We added a `deleteCustomer()` method to our RESTful web service, and we annotated this method with `@DELETE` so that it responds to HTTP DELETE requests. Just like with `getCustomer()`, we added a parameter to this method and annotated it with the `@``QueryParam` annotation.
We can send our RESTful web service using `curl` as follows:

curl -XDELETE -HContent-type:application/json http://localhost:8080/queryparams/resources/customer?id=2


 Our `deleteCustomer()` will be invoked as expected, as evidenced by the output in the application server log:

com.ensode.jakartaeebook.queryparams.service.CustomerResource.deleteCustomer() invoked, id = 2|#]


 Sending query parameters via the Jakarta REST client API
The Jakarta REST client API provides a straightforward way of sending query parameters to RESTful web services. The following example illustrates how to do this:

package com.ensode.jakartaeebook.queryparamsclient;

//imports omitted for brevity

public class App {

public static void main(String[] args) {

App app = new App();

app.getCustomer();

}

public void getCustomer() {

Client client = ClientBuilder.newClient();

Customer customer = client.target(

"http://localhost:8080/"

  • "queryparams/resources/customer").

queryParam("id", 1L).

request().get(Customer.class);

System.out.println(String.format(

"Received the following customer information:\n%s",

customer));

}

}


 As we can see, all we need to do to pass a parameter is to invoke the `queryParam()` method on the `jakarta.ws.rs.client.WebTarget` instance returned by the `target()` method invocation on our `Client` instance. The first argument to this method is the parameter name and must match the value of the `@QueryParam` annotation on the web service. The second parameter is the value that we need to pass to the web service. If our web service accepts multiple parameters, we can chain `queryParam()` method invocations, using one for each parameter our RESTful web service expects.
Path parameters
Another way we can pass parameters to our RESTful web services is via path parameters. The following example illustrates how to develop a Jakarta REST web service that accepts path parameters:

package com.ensode.jakartaeebook.pathparams.service;

//imports omitted for brevity

@Path("/customer/")

public class CustomerResource {

private static final Logger LOG =

Logger.getLogger(CustomerResource.class.getName());

private Customer customer;

public CustomerResource() {

customer = new Customer(1L, "William",

"Daniel", "Graystone");

}

@GET

@Produces(MediaType.APPLICATION_JSON)

@Path("{id}/")

public Customer getCustomer(@PathParam("id") Long id) {

return customer;

}

@PUT

@Consumes(MediaType.APPLICATION_JSON)

public void createCustomer(Customer customer) {

LOG.log(Level.INFO, "customer = {0}", customer);

}

@POST

@Consumes(MediaType.APPLICATION_JSON)

public void updateCustomer(Customer customer) {

LOG.log(Level.INFO, "customer= {0}", customer);

}

@DELETE

@Consumes(MediaType.APPLICATION_JSON)

@Path("{id}/")

public void deleteCustomer(@PathParam("id") Long id) {

LOG.log(Level.INFO, "customer = {0}", customer);

}

}


 Any method that accepts a path parameter must be annotated with the `@Path` annotation. The value attribute of this annotation must be formatted as `"{paramName}/"`, where `paramName` is the parameter the method expects to receive. Additionally, method parameters must be decorated with the `@PathParam` annotation. The value of this annotation must match the parameter name declared in the `@Path` annotation for the method.
We can pass path parameters from the command line by adjusting our web service’s URI as appropriate; for example, to pass an id parameter of `1` to the `getCustomer()` method (which handles HTTP GET requests), we could do it from the command line as follows:

curl -XGET -HContent-type:application/json http://localhost:8080/pathparams/resources/customer/1


 This returns the expected output of a JSON representation of the `Customer` object returned by the `getCustomer()` method, as seen in the following:

{"firstName":"William","id":1,"lastName":"Graystone","middleName":"Daniel"}


 Sending path parameters via the Jakarta REST client API
Sending path parameters to a web service via the Jakarta REST client API is straightforward; all we need to do is add a couple of method invocations to specify the path parameter and its value. The following example illustrates how to do this:

package com.ensode.jakartaeebook.pathparamsclient;

//imports omitted for brevity

public class App {

public static void main(String[] args) {

App app = new App();

app.getCustomer();

}

public void getCustomer() {

Client client = ClientBuilder.newClient();

Customer customer = client.target(

http://localhost:8080/pathparams/resources/customer

"http://localhost:8080/"

  • "pathparams/resources/customer").

路径 ("{id}".

resolveTemplate("id", 1L).

request().get(Customer.class);

System.out.println("收到以下事件:");

  • "客户信息:");

System.out.println("ID: " + customer.getId());

System.out.println("名字: " +

customer.getFirstName());

System.out.println("中间名: " +

customer.getMiddleName());

System.out.println("姓氏: " +

customer.getLastName());

}

}


 In this example, we invoke the `path()` method on the `WebTarget` instance returned by `client.target()`; this method appends the specified path to our `WebTarget` instance. The value of this method must match the value of the `@Path` annotation in our RESTful web service.
After invoking the `path()` method on our `WebTarget` instance, we then need to invoke `resolveTemplate()`. The first parameter for this method is the name of the parameter (without the curly braces), and the second parameter is the value we wish to pass as a parameter to our RESTful web service.
If we need to pass more than one parameter to one of our web services, we simply need to use the following format for the `@Path` parameter at the method level:

@Path("/{paramName1}/{paramName2}/")


 Then annotate the corresponding method arguments with the `@PathParam` annotation as follows:

public String someMethod(

@PathParam("paramName1") String param1,

@PathParam("paramName2") String param2)


 The web service can then be invoked by modifying the web service’s URI to pass the parameters in the order specified in the `@Path` annotation. For example, the following URI would pass the values `1` and `2` for `paramName1` and `paramName2`:
http://localhost:8080/contextroot/resources/customer/1/2
This URI will work either from the command line or through a web service client we develop with the Jakarta REST client API.
All examples we’ve seen so far involve RESTful web services responding to an HTTP request from the client. We can have our RESTful web services send data to clients without necessarily having to respond to a request; this can be achieved via server-sent events.
Server-sent events
Typically, every interaction between a web service and its client is initiated by the client. The client sends a request (typically GET, POST, PUT, or DELETE), and then receives a response from the server. Server-sent events technology allows RESTful web services to “take the initiative” to send messages to clients; that is, to send data that is not a response to a client request. Server-sent events are useful for sending data continuously to a client for applications such as stock tickers, newsfeeds, and sports scores.
The following example illustrates how to implement this functionality into our Jakarta REST web services:

包含 com.ensode.jakartaeebook.serversentevents

//省略导入以节省空间

@ApplicationScoped

@Path("serversentevents")

public class SseResource {

private   SseBroadcaster sseBroadcaster;

private OutboundSseEvent.Builder eventBuilder;

private ScheduledExecutorService scheduledExecutorService;

private Double stockValue = 10.0;

//省略初始化和清理方法以节省空间

@Context

public void setSse(Sse sse) {

this.eventBuilder = sse.newEventBuilder();

this.sseBroadcaster = sse.newBroadcaster();

}

@GET

@Path("subscribe")

@Produces(MediaType.SERVER_SENT_EVENTS)

public void subscribe(@Context SseEventSink sseEventSink) {

LOG.info(String.format("%s.subscribe() 被调用",

this.getClass().getName()));

this.sseBroadcaster.register(sseEventSink);

}

public void sendEvents() {

scheduledExecutorService.scheduleAtFixedRate(() -> {

final OutboundSseEvent outboundSseEvent = eventBuilder

.name("ENSD 股票行情价值")

.data(String.class, String.format("%.2f",

stockValue))

.build();

LOG.info(String.format("广播事件: %.2f",

stockValue));

sseBroadcaster.broadcast(outboundSseEvent);

stockValue += 0.9;

}, 5, 5, TimeUnit.SECONDS);

}

}


 The preceding example simulates sending stock prices for a fictitious company to the client.  To send server-sent events to the client, we need to utilize instances of the `SseEventSink` and `Sse` classes, as illustrated in our example.
We can inject an instance of `Sse` by creating a setter method and annotating it with the `@Context` annotation. We never invoke this setter method directly; instead, the Jakarta EE runtime invokes it, passing an instance of `Sse` we can use to send our events. We then invoke `newEventBuilder()` and `newBroadCaster()` methods on the injected `Sse` instance to obtain `OutboundSseEvent.Builder` and `SseBroadcaster` instances, which we will need to create and broadcast events.
In order to receive events, clients need to register with our Jakarta REST service; we implemented an endpoint in the `subscribe()` method of our example for this purpose. This endpoint has a “subscribe” path; clients sending an HTTP GET request to this endpoint will be subscribed to receive events from our service. Notice we annotated the `SseEventSink` parameter in our `subscribe()` method with `@Context`; this results in the Jakarta EE runtime injecting an `SseEventSink` instance we can use to register the client. We accomplish this by invoking the `broadcast()` method on our `SseBroadCaster` instance, and passing the injected `SseEventSink` instance as a parameter.
To broadcast an event, we first need to build an instance of `OutboundSseEvent` via our instance of `OutboundSseEvent.Builder`.
We give our event a name by invoking the `name()` method on our `OutboundSseEvent.Builder` instance, then set the data to send to the client via its `data()` method. The `data()` method takes two arguments; the first one is the type of data we are sending to the client (`String`, in our case), and the second one is the actual data we send to the client.
Once we have set our event’s name and data via the corresponding method, we build an `OutboundSseEvent` instance by invoking the `build()` method on `OutboundSseEvent.Builder`.
Once we have built our `OutboundSseEvent` instance, we send it to the client by passing it as a parameter to the `broadcast()` method of `SseBroadcaster`. In our example, we calculate a new value for the simulated stock price (for simplicity, we simply increase the value by 0.9), and broadcast a new event with the updated value every five seconds.
Testing server-sent events
We can make sure our server-sent events are working properly by using `curl`; we can simply send an HTTP GET request to the endpoint we created for clients to subscribe to our events as follows:

curl -XGET http://localhost:8080/serversentevents/resources/serversentevents/subscribe


 As soon as we run this, we should see the output in the application server log confirming that the `subscribe()` method was invoked:

com.ensode.jakartaeebook.serversentevents.SseResource.subscribe() 被调用|#]


 Within five seconds, we should start seeing output from `curl` indicating it is receiving events:

事件: ENSD 股票行情价值

data: 10.00

事件: ENSD 股票行情价值

data: 10.90

事件: ENSD 股票行情价值

数据: 11.80


 The value next to the `event` label is the name we gave to our event. `data` is the actual value we sent from our service as the event data.
By testing our server-sent events code with `curl`, we can rest assured that our server-side code is working properly. That way, if things are not working properly when developing a client, we can eliminate the server as a “suspect” when debugging our code.
Developing a server-sent events client
The Jakarta REST client API provides a way to consume server-sent events. The following example illustrates how to do this:

包含 com.ensode.jakartaeebook.serversenteventsclient;

//省略导入以节省空间

public class App {

public static void main(String[] args) {

App app = new App();

app.listenForEvents();

}

public void listenForEvents() {

final SseEventSource.Builder sseEventSourceBuilder;

final SseEventSource sseEventSource;

final Client client = ClientBuilder.newClient();

final WebTarget webTarget = client.target(

"http://localhost:8080/serversentevents/"

  • "resources/serversentevents/subscribe");

sseEventSourceBuilder =

SseEventSource.target(webTarget);

sseEventSource = sseEventSourceBuilder.build();

sseEventSource.register((inboundSseEvent) -> {

System.out.println("收到以下事件:");

System.out.println(String.format("事件名称: %s",

inboundSseEvent.getName()));

System.out.println(String.format("事件数据: %s\n",

inboundSseEvent.readData()));

});

sseEventSource.open();

}

}


 First, we need to obtain a `jakarta.ws.rs.client.Client` instance by invoking the static  `ClientBuilder.newClient()` method.
We then obtain an instance of `jakarta.ws.rs.client.WebTarget` by invoking the `target()` method on the `Client` instance we retrieved in the previous step, and passing the URI of the endpoint we created for clients to subscribe as a parameter.
The next step is to obtain an `SseEventSource.Builder` instance by invoking the static `SseEventSource.target()` method, and passing our `WebTarget` instance as a parameter.
We then get an `SseEventSource` instance by invoking the `build()` method on our instance of `SseEventSource.Builder`.
Next, we register our client by invoking the `register()` method on our `sseEventSource` instance. This method takes an implementation of the functional interface `java.util.function.Consumer` as a parameter; for convenience, we implement this interface inline as a lambda expression. In our example, we simply output the event name and data we received to the console, via simple `System.out.println()` invocations.
Finally, we open the connection to our RESTful web service by invoking the `open()` method on our `SseEventSource` instance.
When we run our client, we see the expected output:

接收到以下事件:

事件名称:ENSD 股票代码值

事件数据:10.00

接收到以下事件:

事件名称:ENSD 股票代码值

事件数据:10.90

接收到以下事件:

事件名称:ENSD 股票代码值

事件数据:11.80


 For simplicity, our example is a stand-alone Java application we can run on the command line; the same principles apply when developing RESTful web services that act as clients for other RESTful web services.
JavaScript server-sent events client
So far, all of our client examples have either used the `curl` command-line utility or the Jakarta REST client API. It is very common to use JavaScript code running on a browser as a RESTful web service; therefore, in this section, we will take that approach. The following example illustrates an HTML/JavaScript client receiving server-sent events:

<head> <title>股票代码监控器</title>

<meta http-equiv="Content-Type" content="text/html;

charset=UTF-8">

</head>

超级华丽的股票代码监控器

ENSD 股票代码值:  
</body>

 The `getStockTickerValues()` JavaScript function creates an `EventSource` object. This constructor takes a `String` representing the URL used to subscribe to receive events as a parameter. In our case, we used a relative URL since the preceding HTML/JavaScript code is hosted in the same server as the server code; if this wasn’t the case, we would have needed to use a complete URL.
We implement the functionality to be executed when the client receives an event by adding an event listener to our `EventSource` instance via its `addEventListener()` function. This function takes the event name (notice that the value matches the name we sent in the Java code for our RESTful web service), and a function to be executed when an event is received. In our example, we simply update the contents of a `<span>` tag with the data of the received message.
Summary
In this chapter, we discussed how to easily develop RESTful web services using Jakarta REST.
We covered the following topics:

*   How to develop a RESTful web service by adding a few simple annotations to our code
*   How to automatically generate JSON data
*   How to automatically parse JSON data it receives as a request
*   How to pass parameters to our RESTful web services via the `@PathParam` and `@``QueryParam` annotations
*   How to implement server-sent events and server-sent event clients

RESTful web services have become immensely popular in recent years; they are now the preferred way of developing web applications and are also heavily used when developing applications utilizing a microservices architecture. As seen in this chapter, Jakarta EE allows us to implement RESTful web services by adding a few simple annotations to our Java classes.

第四章:JSON 处理和 JSON 绑定

JavaScript 对象表示法JSON)是一种人类可读的数据交换格式。正如其名称所暗示的,JSON 源自 JavaScript。Jakarta EE 提供了对两种不同的 JSON 操作 API 的支持,即Jakarta JSON Processing,这是一个低级 API,允许细粒度控制,以及Jakarta JSON Binding,这是一个高级 API,允许我们轻松地从 JSON 数据填充 Java 对象,以及快速从 Java 对象生成 JSON 格式的数据。在本章中,我们将介绍 JSON 处理和 JSON 绑定。

JSON 处理包括两个用于处理 JSON 的 API:模型 API流式 API。这两个 API 都将在本章中介绍。JSON 绑定透明地将 Java 对象从 JSON 字符串中填充,以及轻松地从 Java 对象生成 JSON 字符串。

在本章中,我们将涵盖以下主题:

  • Jakarta JSON 处理

  • Jakarta JSON 绑定

注意

本章的示例源代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch04_src

Jakarta JSON 处理

在以下章节中,我们将讨论如何使用 Jakarta JSON Processing 提供的两个 API(即模型 API 和流式 API)来处理 JSON 数据。我们还将讨论如何使用 JSON 指针从 JSON 数据中检索值,以及如何通过 JSON 补丁部分修改 JSON 数据。

JSON 处理模型 API

JSON 处理模型 API 允许我们生成 JSON 对象的内存表示。与本章后面讨论的流式 API 相比,此 API 更灵活。然而,它较慢且需要更多内存,这在处理大量数据时可能是一个问题。

使用模型 API 生成 JSON 数据

JSON 处理模型 API 的核心是JsonObjectBuilder类。此类有几个重载的add()方法,可用于将属性及其对应值添加到生成的 JSON 数据中。

以下代码示例说明了如何使用模型 API 生成 JSON 数据:

package com.ensode.jakartaeebook.jsonpobject;
//imports omitted for brevity
@Path("jsonpmodel")
public class JsonPModelResource {
  private static final Logger LOG =
    Logger.getLogger(JsonPModelResource.class.getName());
  @Path("build")
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public String jsonpModelBuildJson() {
    JsonObject jsonObject = Json.createObjectBuilder().
      add("firstName", "Scott").
      add("lastName", "Gosling").
      add("email", "sgosling@example.com").
      build();
    StringWriter stringWriter = new StringWriter();
    try (JsonWriter jsonWriter = Json.createWriter(
      stringWriter)) {
      jsonWriter.writeObject(jsonObject);
    }
    return stringWriter.toString();
  }
}

如示例所示,我们通过在JsonObjectBuilder实例上调用add()方法来生成JsonObject实例。在我们的示例中,我们看到如何通过在JsonObjectBuilder上调用add()方法将String值添加到我们的JsonObject中。add()方法的第一参数是生成的 JSON 对象的属性名,第二个参数对应于该属性的值。add()方法的返回值是另一个JsonObjectBuilder实例;因此,可以对add()方法进行链式调用,如示例所示。

注意

上述示例是对应于更大的 Jakarta RESTful Web Services 应用程序的 RESTful 网络服务。由于它们与讨论无关,因此未显示应用程序的其他部分。完整的示例应用程序可以从本书的 GitHub 仓库中获取,网址为 github.com/PacktPublishing/Jakarta-EE-Application-Development

一旦我们添加了所有所需的属性,我们需要调用 JsonObjectBuilderbuild() 方法,它返回一个实现 JsonObject 接口的类的实例。

在许多情况下,我们可能希望生成我们创建的 JSON 对象的 String 表示形式,以便它可以被另一个进程或服务处理。我们可以通过创建一个实现 JsonWriter 接口的类的实例,通过调用 Json 类的静态 createWriter() 方法,并将 StringWriter 的实例作为其唯一参数来实现这一点。一旦我们有了 JsonWriter 实现的实例,我们需要调用其 writeObject() 方法,并将我们的 JsonObject 实例作为其唯一参数传递。

在这一点上,我们的 StringWriter 实例将包含我们 JSON 对象的字符串表示形式作为其值,因此调用其 toString() 方法将返回一个包含我们 JSON 对象的 String。|

我们的具体示例将生成一个看起来像这样的 JSON 字符串:

{"firstName":"Scott","lastName":"Gosling","email":"sgosling@example.com"}

尽管在我们的示例中我们只向 JSON 对象添加了 String 对象,但我们并不局限于这种类型的值;JsonObjectBuilder 有几个重载的 add() 方法版本,允许我们向 JSON 对象添加几种不同类型的值。|

下表总结了所有可用的 add() 方法版本:

JsonObjectBuilder.add() 方法 描述
add(String name, BigDecimal value) BigDecimal 值添加到我们的 JSON 对象中。
add(String name, BigInteger value) BigInteger 值添加到我们的 JSON 对象中。
add(String name, JsonArrayBuilder value) 将数组添加到我们的 JSON 对象中。JsonArrayBuilder 实现允许我们创建 JSON 数组。
add(String name, JsonObjectBuilder value) 将另一个 JSON 对象添加到我们的原始 JSON 对象中(JSON 对象的属性值可以是其他 JSON 对象)。添加的 JsonObject 实现是从提供的 JsonObjectBuilder 参数构建的。
add(String name, JsonValue value) 将另一个 JSON 对象添加到我们的原始 JSON 对象中(JSON 对象的属性值可以是其他 JSON 对象)。
add(String name, String value) String 值添加到我们的 JSON 对象中。
add(String name, boolean value) boolean 值添加到我们的 JSON 对象中。
add(String name, double value) double 值添加到我们的 JSON 对象中。
add(String name, int value) int 值添加到我们的 JSON 对象中。
add(String name, long value) 向我们的 JSON 对象添加一个long值。

表 4.1 – JsonObjectBuilder add()方法

在所有情况下,add()方法的第一参数对应于我们 JSON 对象中的属性名称,第二个参数对应于属性的值。

使用 Model API 解析 JSON 数据

在上一节中,我们看到了如何使用对象模型 API 从我们的 Java 代码中生成 JSON 数据。在本节中,我们将看到如何读取和解析现有的 JSON 数据。以下代码示例说明了如何进行此操作:

package com.ensode.jakartaeebook.jsonpobject;
//imports omitted for brevity
@Path("jsonpmodel")
public class JsonPModelResource {
  @Path("parse")
  @POST
  @Produces(MediaType.TEXT_PLAIN)
  @Consumes(MediaType.APPLICATION_JSON)
  public String jsonpModelParseJson(String jsonStr) {
    LOG.log(Level.INFO, String.format(
      "received the following JSON string: %s", jsonStr));
    Customer customer = new Customer();
    JsonObject jsonObject;
    try (JsonReader jsonReader = Json.createReader(new
    StringReader(jsonStr))) {
      jsonObject = jsonReader.readObject();
    }
    customer.setFirstName(
      jsonObject.getString("firstName"));
    customer.setLastName(jsonObject.getString("lastName"));
    customer.setEmail(jsonObject.getString("email"));
    return customer.toString();
  }
}

要解析现有的 JSON 字符串,我们需要创建一个StringReader对象,将包含要解析的 JSON 的String对象作为参数传递。然后,我们将生成的StringReader实例传递给Json类的静态createReader()方法。此方法调用将返回一个JsonReader实例。然后,我们可以通过调用其上的readObject()方法来获取JsonObject实例。

在这个例子中,我们使用getString()方法来获取我们 JSON 对象中所有属性的值。此方法唯一的第一个参数是我们希望检索的属性的名称;不出所料,返回值是该属性的值。

除了getString()方法之外,还有其他几个类似的方法可以获取其他类型的数据值。以下表格总结了这些方法:

JsonObject 方法 描述
get(Object key) 获取实现JsonValue接口的类的实例
getBoolean(String name) 获取与给定键对应的boolean
getInt(String name) 获取与给定键对应的int
getJsonArray(String name) 获取与给定键对应的实现JsonArray接口的类的实例
getJsonNumber(String name) 获取与给定键对应的实现JsonNumber接口的类的实例
getJsonObject(String name) 获取与给定键对应的实现JsonObject接口的类的实例
getJsonString(String name) 获取与给定键对应的实现JsonString接口的类的实例
getString(String Name) 获取与给定键对应的String

表 4.2 – 从 JSON 数据中检索值的 JsonObject 方法

在所有情况下,方法的String参数对应于键名,返回值是我们希望检索的 JSON 属性值。

JSON 处理流式 API

JSON 处理流式 API 允许从流(java.io.OutputStream 的子类或 java.io.Writer 的子类)中顺序读取 JSON 对象。它比模型 API 更快、更节省内存;然而,与模型 API 相比,直接访问特定的 JSON 属性不那么直接。当使用流式 API 时,我们需要使用 JSON Pointer 和 JSON Patch 来检索或修改 JSON 数据中的特定值。

使用流式 API 生成 JSON 数据

JSON 流式 API 有一个 JsonGenerator 类,我们可以使用它来生成 JSON 数据并将其写入流。此类有几个重载的 write() 方法,可以用来向生成的 JSON 数据中添加属性及其对应的值。

以下代码示例说明了如何使用流式 API 生成 JSON 数据:

package com.ensode.jakartaeebook.jsonpstreaming;
//imports omitted for brevity
@Path("jsonpstreaming")
public class JsonPStreamingResource {
  @Path("build")
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public String buildJson() {
    StringWriter stringWriter = new StringWriter();
    try (JsonGenerator jsonGenerator =
      Json.createGenerator(stringWriter)) {
      jsonGenerator.writeStartObject().
        write("firstName", "Larry").
        write("lastName", "Gates").
        write("email", "lgates@example.com").
        writeEnd();
    }
    return stringWriter.toString();
  }
}

我们通过调用 Json 类的 createGenerator() 静态方法来创建一个 JsonGenerator 实例。JSON 处理 API 提供了此方法的两个重载版本;一个接受一个扩展 java.io.Writer 类(例如 StringWriter,我们在示例中使用)的类的实例,另一个接受一个扩展 java.io.OutputStream 类的类的实例。

在我们开始向生成的 JSON 流添加属性之前,我们需要在 JsonGenerator 上调用 writeStartObject() 方法。此方法写入 JSON 起始对象字符(在 JSON 字符串中表示为开括号 {)并返回另一个 JsonGenerator 实例,允许我们将 write() 调用链式添加到我们的 JSON 流中。

JsonGenerator 上的 write() 方法允许我们向正在生成的 JSON 流中添加属性;它的第一个参数是我们添加的属性的名称对应的 String,第二个参数是属性的值。

在我们的示例中,我们只向创建的 JSON 流添加 String 值。然而,我们并不局限于字符串;流式 API 提供了几个重载的 write() 方法,允许我们向 JSON 流添加多种不同类型的数据。以下表格总结了所有可用的 write() 方法版本:

JsonGenerator.write() 方法 描述
write(String name, BigDecimal value) 将一个 BigDecimal 值写入我们的 JSON 流
write(String name, BigInteger value) 将一个 BigInteger 值写入我们的 JSON 流
write(String name, JsonValue value) 将一个 JSON 对象写入我们的 JSON 流(JSON 流的属性值可以是其他 JSON 对象)
write(String name, String value) 将一个 String 值写入我们的 JSON 流
write(String name, boolean value) 将一个 boolean 值写入我们的 JSON 流
write(String name, double value) 将一个 double 值写入我们的 JSON 流
write(String name, int value) 将一个 int 值写入我们的 JSON 流
write(String name, long value) long 值写入我们的 JSON 流

表 4.3 – JsonGenerator write() 方法

在所有情况下,write() 方法的第一个参数对应于我们添加到 JSON 流中的属性名称,第二个参数对应于属性的值。

一旦我们完成向 JSON 流添加属性,我们需要在 JsonGenerator 上调用 writeEnd() 方法;此方法添加 JSON 结束对象字符(在 JSON 字符串中由一个关闭花括号(})表示)。

在这一点上,我们的流或读取器被我们生成的 JSON 数据填充。我们如何处理它取决于我们的应用程序逻辑。在我们的例子中,我们简单地调用了 StringReadertoString() 方法来获取我们创建的 JSON 数据的字符串表示。

使用流式 API 解析 JSON 数据

在本节中,我们将介绍如何解析从流中接收到的 JSON 数据。

以下示例说明了我们如何使用流式 API 从 JSON 数据中填充 Java 对象。

package com.ensode.jakartaeebook.jsonpstreaming;
//imports omitted for brevity
@Path("jsonpstreaming")
public class JsonPStreamingResource {
  @Path("parse")
  @POST
  @Produces(MediaType.TEXT_PLAIN)
  @Consumes(MediaType.APPLICATION_JSON)
  public String parseJson(String jsonStr) {
    Customer customer = new Customer();
    StringReader stringReader = new StringReader(jsonStr);
    JsonParser jsonParser = Json.createParser(stringReader);
    Map<String, String> keyValueMap = new HashMap<>();
    String key = null;
    String value = null;
    while (jsonParser.hasNext()) {
      JsonParser.Event event = jsonParser.next();
      if (event.equals(Event.KEY_NAME)) {
        key = jsonParser.getString();
      } else if (event.equals(Event.VALUE_STRING)) {
        value = jsonParser.getString();
      }
      keyValueMap.put(key, value);
    }
  customer.setFirstName(keyValueMap.get("firstName"));
    customer.setLastName(keyValueMap.get("lastName"));
    customer.setEmail(keyValueMap.get("email"));
    return customer.toString();
  }
}

要使用流式 API 读取 JSON 数据,我们首先需要通过在 Json 类上调用静态 createJsonParser() 方法来创建一个 JsonParser 实例。createJsonParser() 方法有两种重载版本:一个接受一个扩展 java.io.InputStream 的类的实例,另一个接受一个扩展 java.io.Reader 的类的实例。在我们的例子中,我们使用后者,传递一个 java.io.StringReader 的实例,它是 java.io.Reader 的一个子类。

下一步是遍历 JSON 数据以获取要解析的数据。我们可以通过在 JsonParser 上调用 hasNext() 方法来实现这一点,该方法在还有更多数据要读取时返回 true,否则返回 false。

然后,我们需要读取流中的下一份数据,JsonParser.next() 方法返回一个 JsonParser.Event 实例,该实例指示我们刚刚读取的数据类型。在我们的例子中,我们只检查键名称(即 firstNamelastNameemail),以及相应的字符串值。我们通过将 JsonParser.next() 返回的事件与 JsonParser.Event 中定义的几个值进行比较来检查我们刚刚读取的数据类型。

下表总结了 JsonParser.next() 可以返回的所有可能的事件:

JsonParser Event 描述
Event.START_OBJECT 表示 JSON 对象的开始
Event.END_OBJECT 表示 JSON 对象的结束
Event.START_ARRAY 表示数组的开始
Event.END_ARRAY 表示数组的结束
Event.KEY_NAME 表示读取了 JSON 属性的名称;我们可以通过在 JsonParser 上调用 getString() 来获取键名称
Event.VALUE_TRUE 表示读取了布尔值 true
Event.VALUE_FALSE 表示读取了布尔值 false
Event.VALUE_NULL 表示读取到了空值
Event.VALUE_NUMBER 表示读取到了数值
Event.VALUE_STRING 表示读取到了字符串值

表 4.4 – JsonParser 事件

如示例所示,可以通过在 JsonParser 上调用 getString() 来检索 String 类型的值。数值可以以几种不同的格式检索。以下表格总结了 JsonParser 中可以用来检索数值的方法:

JsonParser method 描述
getInt() int 类型检索数值
getLong() long 类型检索数值
getBigDecimal() java.math.BigDecimal 实例的形式检索数值

表 4.5 – 用于检索数值的 JsonParser 方法

JsonParser 还提供了一个方便的 isIntegralNumber() 方法,如果数值可以安全地转换为 intlong 类型,则返回 true

我们对从流中获取的值所采取的操作取决于我们的应用程序逻辑;在我们的示例中,我们将它们放入一个 Map 中,然后使用该 Map 来填充一个 Java 类。

从数据中检索 JSON Pointer 值

Jakarta JSON Processing 支持 JSON Pointer,这是一个 互联网工程任务组 (IETF) 标准,它定义了一种字符串语法,用于在 JSON 文档中标识特定的值,类似于 XPath 为 XML 文档提供的功能。

JSON Pointer 的语法很简单。例如,假设我们有一个以下 JSON 文档:

{
  "dateOfBirth": "1997-03-03",
  "firstName": "David",
  "lastName": "Heffelfinger",
  "middleName": "Raymond",
  "salutation": "Mr"
}

如果我们想要获取文档中 lastName 属性的值,所使用的 JSON Pointer 表达式应该是 "/lastName"

如果我们的 JSON 文档由一个数组组成,那么我们必须在属性前加上数组中的索引;例如,假设我们想要获取以下 JSON 数组中第二个元素的 lastName 属性:

[
  {
     "dateOfBirth": "1997-01-01",
     "firstName": "David",
     "lastName": "Delabassee",
     "salutation": "Mr"
   },
   {
      "dateOfBirth": "1997-03-03",
      "firstName": "David",
      "lastName": "Heffelfinger",
      "middleName": "Raymond",
      "salutation": "Mr"
   }
]

要这样做,所使用的 JSON Pointer 表达式应该是 "/1/lastName"。表达式开头的 "/1" 指的是数组中的元素索引。就像在 Java 中一样,JSON 数组是从 0 开始索引的;因此,在这个例子中,我们正在获取数组第二个元素中 lastName 属性的值。现在让我们看看如何使用 JSON Pointer API 来执行此任务的示例:

package com.ensode.jakartaeebook.jsonpointer;
//imports omitted for brevity
@Path("jsonpointer")
public class JsonPointerDemoService {
  private String jsonString; //initialization omitted
  @GET
  @Produces(MediaType.TEXT_PLAIN)
  public String jsonPointerDemo() {
    JsonReader jsonReader = Json.createReader(
    new StringReader(jsonString));
    JsonArray jsonArray = jsonReader.readArray();
    JsonPointer jsonPointer = Json.createPointer("/1/lastName");
    return jsonPointer.getValue(jsonArray).toString();
  }
}

以下代码示例是一个使用 Jakarta RESTful Web Services 编写的 RESTful 网络服务。为了从 JSON 文档中读取属性值,我们首先需要通过在 jakarta.json.Json 上调用静态的 createReader() 方法来创建一个 jakarta.json.JsonReader 实例。createReader() 方法接受任何实现 java.io.Reader 接口的对象实例作为参数。在我们的示例中,我们动态创建了一个新的 java.io.StringReader 实例,并将我们的 JSON 字符串作为参数传递给其构造函数。

注意

JSON.createReader() 有一个重载版本,它接受任何实现 java.io.InputStream 类的实例。

在我们的例子中,我们的 JSON 文档由一个对象数组组成;因此,我们通过在创建的 JsonReader 对象上调用 readArray() 方法来填充 jakarta.json.JsonArray 实例。(如果我们的 JSON 文档由一个单独的 JSON 对象组成,我们将调用 JsonReader.readObject() 而不是 readArray()。)

现在我们已经填充了 JsonArray 变量,我们创建了一个 jakarta.json.JsonPointer 实例,并用我们想要使用的 JSON 指针表达式初始化它。回想一下,我们正在寻找数组第二个元素中 lastName 属性的值;因此,适当的 JSON 指针表达式是 /1/lastName

现在我们已经使用适当的 JSON 指针表达式创建了一个 JsonPointer 实例,我们只需调用它的 getValue() 方法,并将我们的 JsonArray 对象作为参数传递;然后,我们在结果上调用 toString(),这个调用的返回值将是 JSON 文档中 "Heffelfinger"lastName 属性的值(在我们的例子中)。

使用 JSON 补丁更新 JSON 数据值

Jakarta JSON Processing 包括对 JSON 补丁的支持,这是另一个 IETF 标准。它提供了一系列可以应用于 JSON 文档的操作。JSON 补丁允许我们对 JSON 对象执行部分更新。

JSON 补丁支持以下操作:

JSON 补丁操作 描述
添加 向 JSON 文档中添加一个元素
删除 从 JSON 文档中删除一个元素
替换 将 JSON 文档中的一个值替换为新值
移动 将 JSON 文档中的一个值从其在文档中的当前位置移动到新位置
复制 将 JSON 文档中的一个值复制到文档中的新位置
测试 验证 JSON 文档中特定位置的值是否等于指定的值

表 4.6 – JSON 补丁操作

Jakarta JSON Processing 支持所有上述 JSON 补丁操作,这些操作依赖于 JSON 指针表达式来定位 JSON 文档中的源和目标位置。

以下示例说明了我们如何使用 Jakarta JSON Processing 的 JSON 补丁:

package com.ensode.jakartaeebook.jsonpatch
//imports omitted for brevity
@Path("jsonpatch")
public class JsonPatchDemoService {
  private String jsonString; //initialization omitted
  @GET
  public Response jsonPatchDemo() {
    JsonReader jsonReader = Json.createReader(
      new StringReader(jsonString));
    JsonArray jsonArray = jsonReader.readArray();
    JsonPatch jsonPatch = Json.createPatchBuilder()
      .replace("/1/dateOfBirth", "1977-01-01")
      .build();
    JsonArray modifiedJsonArray = jsonPatch.apply(
jsonArray);
    return Response.ok(modifiedJsonArray.toString(),
      MediaType.APPLICATION_JSON).build();
  }
}

在这个例子中,让我们假设我们正在处理与上一个例子相同的 JSON 文档,一个包含两个单独 JSON 对象的数组,每个对象都有一个 dateOfBirth 属性(以及其他属性)。

在我们的示例中,我们像之前一样创建了一个 JsonArray 实例,然后修改了数组中第二个元素的 dateOfBirth。为了做到这一点,我们通过 jakarta.json.Json 类中的静态 createPatchBuilder() 方法创建了一个 jakarta.json.JsonPatchBuilder 实例。在我们的示例中,我们用新值替换了一个属性的值;我们使用 JsonPatch 实例的 replace() 方法来完成这个操作。方法中的第一个参数是一个 JSON Pointer 表达式,指示我们要修改的属性的定位,第二个参数是属性的新值。正如其名称所暗示的,JsonPatchBuilder 遵循 Builder 设计模式,这意味着其大多数方法返回另一个 JsonPatchBuilder 实例;这允许我们在 JsonPatchBuilder 的结果实例上链式调用方法(在我们的示例中,我们只执行了一个操作,但这不必是这种情况)。一旦我们指定了要在我们的 JSON 对象上执行的操作,我们通过在 JsonPatchBuilder 上调用 build() 方法创建一个 jakarta.json.JsonPatch 实例。

一旦我们创建了补丁,我们通过调用其 patch() 方法并将 JSON 对象(在我们的示例中为 JsonArray 实例)作为参数传递,将其应用到我们的 JSON 对象上。

我们的示例展示了如何在 Jakarta JSON-Processing 中通过 JSON Patch 支持,用另一个值替换 JSON 属性的值。Jakarta JSONProcessing 支持所有标准的 JSON Patch 操作。有关如何使用 JSON Processing 与其他 JSON Patch 操作的详细信息,请参阅 jakarta.ee/specifications/platform/10/apidocs/ 的 Jakarta EE API 文档。

现在我们已经看到了如何直接使用 JSON Processing 操作 JSON 数据,我们将关注如何使用 Jakarta JSON Binding 将 JSON 数据绑定,这是一个更高级的 API,它允许我们快速轻松地完成常见任务。

Jakarta JSON Binding

Jakarta JSON Binding 是一个高级 API,它允许我们几乎无缝地从 JSON 数据填充 Java 对象,以及轻松地从 Java 对象生成 JSON 格式的数据。

使用 JSON Binding 从 JSON 填充 Java 对象

一个常见的编程任务是填充 Java 对象来自 JSON 字符串。这是一个如此常见的任务,以至于已经创建了几个库来透明地填充 Java 对象来自 JSON,从而让应用程序开发者免于手动编写此功能。存在几个完成此任务的非标准 Java 库,例如 Jackson (github.com/FasterXML/jackson)、json-simple (code.google.com/archive/p/json-simple/) 或 Gson (github.com/google/gson)。Jakarta EE 包含一个提供此功能的标准化 API,即 JSON Binding。在本节中,我们将介绍如何从 JSON 字符串透明地填充 Java 对象。

以下示例展示了使用 Jakarta RESTful Web Services 编写的 RESTful Web 服务。该服务在其 addCustomer() 方法中响应 HTTP POST 请求。addCustomer() 方法接受一个 String 参数;预期此字符串包含有效的 JSON:

package com.ensode.jakartaeebook.jsonbjsontojava.service;
//imports omitted for brevity
@Path("/customercontroller")
public class CustomerControllerService {
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  public String addCustomer(String customerJson) {
    Jsonb jsonb = JsonbBuilder.create();
    Customer customer = jsonb.fromJson(customerJson,
      Customer.class);
    return customer.toString();
  }
}

我们的应用服务器提供的 JSON Binding 实现提供了一个实现 JsonbBuilder 接口的类的实例;这个类提供了一个静态的 create() 方法,我们可以使用它来获取 Jsonb 的实例。

一旦我们有了 Jsonb 实例,我们可以用它来解析 JSON 字符串并自动填充 Java 对象。这是通过它的 fromJson() 方法完成的。fromJson() 方法接受一个包含需要解析的 JSON 数据的 String 作为其第一个参数,以及我们希望填充的对象类型作为其第二个参数。在我们的例子中,我们正在填充一个包含 firstNamemiddleNamelastNamedateOfBirth 等字段的简单 Customer 类。Jakarta JSON Binding 将寻找与 Java 对象中的属性名称匹配的 JSON 属性名称,并自动用相应的 JSON 属性填充 Java 对象。这再简单不过了!

一旦我们填充了我们的 Java 对象,我们就可以用它做任何我们需要做的事情。在我们的例子中,我们只是将 Customer 对象的 String 表示形式返回给客户端。

使用 JSON Binding 从 Java 对象生成 JSON 数据

除了从 JSON 数据填充 Java 对象之外,JSON Binding 还可以从 Java 对象生成 JSON 字符串。以下示例说明了如何做到这一点:

package com.ensode.jakartaeebook.jsonbjavatojson.service;
//imports omitted for brevity
@Path("/customercontroller")
public class CustomerControllerService {
  @GET
  public String getCustomerAsJson() {
    String jsonString;
    DateTimeFormatter dateTimeFormatter =
      DateTimeFormatter.ofPattern("d/MM/yyyy");
    Customer customer = new Customer("Mr", "David",
      "Raymond", "Heffelfinger",
      LocalDate.parse("03/03/1997", dateTimeFormatter));
    Jsonb jsonb = JsonbBuilder.create();
    jsonString = jsonb.toJson(customer);
    return jsonString;
  }
}

在这个例子中,我们正在从 Customer 对象生成 JSON 数据。

就像之前一样,我们通过调用静态方法 jakarta.json.bind.JsonbBuilder.create() 来创建一个 jakarta.json.bind.Jsonb 实例。一旦我们有了 Jsonb 实例,我们只需调用它的 toJson() 方法,将对象列表转换为等效的 JSON 表示形式。

摘要

在本章中,我们介绍了如何使用两个 Jakarta EE API(JSON Processing 和 JSON Binding)来处理 JSON 数据。

我们涵盖了以下主题:

  • 我们看到了如何使用 JSON Processing 的模型 API 生成和解析 JSON 数据

  • 我们还探讨了如何使用 JSON Processing 的流式 API 生成和解析 JSON 数据

  • 此外,我们还介绍了如何使用 JSON Pointer 从 JSON 数据中提取值

  • 同样,我们也看到了如何使用 JSON Patch 在 JSON 数据中更新特定值

  • 最后,我们介绍了如何使用 Jakarta JSON Binding 轻松地从 JSON 数据填充 Java 对象,以及如何轻松地从 Java 对象生成 JSON 数据

在处理 RESTful Web 服务和微服务时,JSON 格式的数据已经成为一种事实上的标准。Jakarta JSON Processing 和 JSON Binding API 为此提供了出色的支持,正如本章所示。

第五章:使用 Jakarta EE 进行微服务开发

微服务是一种将代码部署在小而粒度化的模块中的架构风格。微服务架构减少了耦合并增加了内聚。通常,微服务被实现为 RESTful Web 服务,通过在彼此上调用 HTTP 方法(GETPOSTPUTDELETE)来相互传递数据,使用 JSON 进行数据交换。由于微服务之间的通信是通过 HTTP 方法完成的,因此用不同编程语言编写的微服务可以相互交互。在本章中,我们将介绍如何使用 Jakarta EE 来实现微服务。

在本章中,我们将介绍以下主题:

  • 微服务简介

  • 微服务和 Jakarta EE

  • 使用 Jakarta EE 开发微服务

注意

本章的示例源代码可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch05_src

微服务简介

将应用程序设计为一系列微服务,与传统设计的应用程序相比,具有一些优势以及一些缺点。在考虑为我们的应用程序采用微服务架构时,我们必须在做出决定之前仔细权衡利弊。

优势

将应用程序作为一系列微服务开发具有比传统设计应用程序更多的优势,如下所示:

  • 更小的代码库:由于每个微服务都是一个小的、独立的单元,因此微服务的代码库通常比传统设计的应用程序更小,更容易管理。

  • 微服务鼓励良好的编码实践:微服务架构鼓励松耦合和高内聚。

  • 更高的弹性:传统设计的应用程序作为一个单一故障点;如果应用程序的任何组件出现故障或不可用,整个应用程序将不可用。由于微服务是独立的模块,一个组件(即一个微服务)的故障并不一定导致整个应用程序不可用。

  • 可伸缩性:由于作为一系列微服务开发的应用程序由多个不同的模块组成,因此可伸缩性变得更容易。我们可以只关注可能需要扩展的服务,而无需在不需要扩展的应用程序部分上浪费精力。

微服务架构的缺点

开发和部署遵循微服务架构的应用程序会带来一系列挑战,无论使用什么编程语言或应用程序框架来开发应用程序:

  • 额外的操作和工具开销:每个微服务实现都需要自己的(可能是自动化的)部署、监控系统等。

  • 调试微服务可能比调试传统企业应用程序更复杂:如果最终用户报告了应用程序的问题,并且该应用程序内部使用了多个微服务,那么并不总是清楚哪个微服务可能是问题的根源。如果涉及的微服务是由不同团队开发且优先级不同的,这可能尤其困难。

  • 分布式事务可能是一个挑战:涉及多个微服务的回滚事务可能很难。一种常见的解决方案是尽可能地将微服务隔离,将它们视为单一单元,并为每个微服务提供本地事务管理。例如,如果微服务 A 调用微服务 B,并且后者存在问题,微服务 B 的本地事务将回滚。然后,它将返回 500 HTTP 状态码(服务器错误)给微服务 A。微服务 A 可以使用这个 HTTP 状态码作为信号来启动补偿事务,将系统恢复到初始状态。

  • 网络延迟:由于微服务依赖于 HTTP 方法调用进行通信,性能可能会因为网络延迟而受到影响。

  • 潜在的复杂依赖性:虽然独立的微服务往往很简单,但它们相互依赖。微服务架构可能创建一个复杂的依赖图。如果我们的某些服务依赖于其他团队开发的微服务,而这些微服务的优先级可能存在冲突(例如,我们在他们的微服务中找到一个错误,但修复这个错误可能不是其他团队的优先事项),这种情况可能会令人担忧。

  • 易受分布式计算谬误的影响:遵循微服务架构开发的应用程序可能会做出一些不正确的假设,例如网络可靠性、零延迟和无限带宽。

现在我们已经讨论了微服务的一般概念,接下来我们将关注如何利用 Jakarta EE 来开发遵循微服务架构的应用程序。

微服务和 Jakarta EE

有些人可能认为 Jakarta EE 对于微服务开发来说“太重了”。这根本不是事实。由于这种误解,有些人也可能认为 Jakarta EE 可能不适合微服务架构,而实际上,Jakarta EE 非常适合微服务开发。在不久前,Java EE 应用程序被部署到“重量级”的应用服务器上。如今,大多数 Jakarta EE 应用服务器供应商都提供轻量级的应用服务器,它们使用的内存或磁盘空间非常少。这些 Jakarta EE 兼容的轻量级应用服务器的例子包括 IBM 的 Open Liberty、Red Hat 的 WildFly Swarm、Apache TomEE 和 Payara Micro。Jakarta EE 10 引入了核心配置文件,这对于使用 Jakarta EE 进行微服务开发来说非常理想。

使用 Jakarta EE 核心配置文件开发微服务涉及编写标准的 Jakarta EE 应用程序,同时将自己限制在核心配置文件支持的 Jakarta EE API 子集内,即 Jakarta REST、JSON-P、JSON-B 和 CDI。如果与关系型数据库交互,我们可能需要事务支持,并且可能希望有一个对象关系映射 API,例如 Jakarta Persistence。为了与关系型数据库交互,我们需要 Jakarta EE 网络配置文件,因为核心配置文件不包括 Jakarta Persistence 或事务支持。只有需要直接与关系型数据库交互的微服务才需要网络配置文件;其他微服务可以针对核心配置文件进行开发。

当开发微服务时,Jakarta EE 开发者可以利用他们现有的专业知识。在开发微服务时,主要要求是开发 RESTful 网络服务,这可以通过使用 Jakarta REST 轻松实现。这些 RESTful 网络服务将被打包在一个 WAR 文件中,并部署到轻量级的 Jakarta EE 运行时环境中。

当使用现代、可嵌入的 Jakarta EE 实现时,通常每个应用服务器实例只部署一个应用程序,在某些情况下,可以说“形势逆转”,将 Jakarta EE 实现仅仅作为一个库,应用程序将其作为依赖项使用。这些现代的 Jakarta EE 实现通常会将多个 Jakarta EE 运行时实例部署到服务器上,这使得现代 Jakarta EE 非常适合微服务开发。许多现代的轻量级 Jakarta EE 应用服务器都是可嵌入的,允许创建一个“uber jar”,它包含应用程序代码和应用服务器库。然后,这个“uber jar”被传输到服务器上并作为一个独立的应用程序运行。除了“uber jars”之外,现代应用服务器还可以添加到容器镜像(如 Docker)中。然后,我们的应用程序可以作为一个瘦 WAR 部署,通常只有几 KB 大小;这种方法具有非常快速部署的优势,通常在两秒以内。

通过部署到符合 Jakarta EE 核心配置文件规范的现代应用服务器(或者,如前一段所述,创建一个“uber jar”),Jakarta EE 开发者当然可以利用他们现有的专业知识来开发遵循微服务架构的应用程序。

使用 Jakarta EE 开发微服务

现在我们已经简要地向您介绍了微服务,我们准备展示一个使用 Jakarta EE 编写的微服务应用程序的示例。我们的示例应用程序对于大多数 Jakarta EE 开发者来说应该非常熟悉。它是一个简单的CRUD创建、读取、更新、删除)应用程序,作为一系列微服务开发的。该应用程序将遵循熟悉的 MVC 设计模式,其中“视图”和“控制器”作为微服务开发。该应用程序还将利用非常常见的数据访问对象DAO模式,我们的 DAO 也作为微服务开发。

DAO 模式

DAO设计模式是一种允许我们将数据访问代码与我们的应用程序的其他部分分离的模式。这使我们能够在不影响应用程序的其他代码的情况下切换数据访问代码的实现。

我们的应用程序将作为三个模块开发 – 首先,一个微服务客户端,然后是 MVC 设计模式中控制器微服务的实现,最后是实现为微服务的 DAO 设计模式。

注意

示例代码不是一个完整的 CRUD 应用程序。为了简单起见,我们只实现了 CRUD 应用程序的“创建”部分。

开发微服务客户端代码

在深入开发我们的服务之前,我们首先将使用纯 HTML 和 JavaScript 开发一个微服务客户端。JavaScript 代码将调用控制器微服务,传递用户输入数据的 JSON 表示。然后,控制器服务将调用持久化服务并将数据保存到数据库。每个微服务将返回一个 HTTP 代码,指示成功或错误状态。

我们客户端代码中最相关的部分是 HTML 表单和将其提交到我们的控制器微服务的 JavaScript 代码。

我们 HTML 页面中的表单包含以下输入字段:

<form id="customerForm">
  <!-- layout markup omitted for brevity -->
  <label for="salutation">Salutation</label>
  <select id="salutation" name="salutation" >
    <option value=""> </option>
    <option value="Mr">Mr</option>
    <option value="Mrs">Mrs</option>
    <option value="Miss">Miss</option>
    <option value="Ms">Ms</option>
    <option value="Dr">Dr</option>
  </select>
  <label for="firstName">First Name</label>
  <input type="text" maxlength="10" id="firstName"
    name="firstName" placeholder="First Name">
  <label for="middleName">Middle Name</label>
  <input type="text" maxlength="10"  id="middleName"
    name="middleName" placeholder="Middle Name">
  <label for="lastName">Last Name</label>
  <input type="text" maxlength="20"  id="lastName"
    name="lastName" placeholder="Last Name">
  <button type="submit" id="submitBtn" >Submit</button>
</form>

我们的 Web 客户端表单包含多个输入字段,用于收集用户数据。它是使用纯 HTML 实现的,没有使用额外的 CSS 或 JavaScript 库。我们的页面还有一个脚本,使用 JavaScript 将表单数据发送到控制器微服务,如下面的代码块所示:

<script>
  async function createCustomer(json) {
    try {
      const response = await fetch(  'http://localhost:8080/
CrudController/resources/customercontroller/', {
        method: 'POST',
        body: json,
        headers: {
          'Content-Type': 'application/json'
}
      });
      document.querySelector("#msg").innerHTML =
        "Customer saved successfully."
    } catch (error) {
      document.querySelector("#msg").innerHTML =
        "There was an error saving customer data.";
    }
  }
  function handleSubmit(event) {
    event.preventDefault();
    console.log("form submitted");
    const formData = new FormData(event.target);
    var formDataObject = {};
    formData.forEach(function (value, key) {
      formDataObject[key] = value;
    });
    var json = JSON.stringify(formDataObject);
    createCustomer(json);
  }
  const form = document.querySelector('#customerForm');
  form.addEventListener('submit', handleSubmit);
</script>

当表单提交时,我们的脚本生成用户输入数据的 JSON 格式表示,然后使用 JavaScript fetch API 向我们的控制器服务发送 HTTP POST请求。在我们的示例中,我们的控制器服务部署到我们本地工作站上的 Jakarta EE 运行时,监听端口8080;因此,我们的客户端代码向 http://localhost:8080/CrudController/resources/customercontroller/发送POST请求。

现在,我们可以将我们的浏览器指向我们的CrudView应用程序 URL(在我们的示例中为 http://localhost:8080/CrudView)。在输入一些数据后,页面将看起来如下所示。

图 5.1 – HTML/JavaScript RESTful Web 服务客户端

图 5.1 – HTML/JavaScript RESTful Web 服务客户端

当用户点击提交按钮时,客户端将用户输入数据的 JSON 表示传递给控制器服务。

控制器服务

控制器服务是 MVC 设计模式中控制器的一个标准 RESTful Web 服务实现,使用 Jakarta REST 实现:

package com.ensode.jakartaeebook.microservices.crudcontroller.service;
//imports omitted for brevity
@Path("/customercontroller")
public class CustomerControllerService {
  @OPTIONS
  public Response options() {
    LOG.log(Level.INFO, "CustomerControllerService.options() 
invoked");
    return Response.ok("")
      .header("Access-Control-Allow-Origin",
        "http://localhost:8080")
      .header("Access-Control-Allow-Headers",
        "origin, content-type, accept, authorization")
      .header("Access-Control-Allow-Credentials", "true")
      .header("Access-Control-Allow-Methods",
        "GET, POST, PUT, DELETE, OPTIONS, HEAD")
      .header("Access-Control-Max-Age", "1209600")
        .build();
  }
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  public Response addCustomer(Customer customer) {
    Response response;
    Response persistenceServiceResponse;
    CustomerPersistenceClient client = new 
      CustomerPersistenceClient();
    persistenceServiceResponse = client.create(customer);
    client.close();
    if (persistenceServiceResponse.getStatus() == 201) {
      response = Response.ok("{}").
        header("Access-Control-Allow-Origin",
        "http://localhost:8080").build();
    } else {
      response = Response.serverError().
        header("Access-Control-Allow-Origin",
        "http://localhost:8080").build();
    }
    return response;
  }
}

带有jakarta.ws.rs.OPTIONS注解的options()方法是必要的,因为浏览器在调用包含我们服务器主要逻辑的实际请求之前会自动调用它。在这个方法中,我们设置了一些头部值,允许http://localhost:8080,这是我们客户端代码部署的主机和端口。

控制器服务的主要逻辑在addCustomer()方法中。此方法接收一个Customer类的实例作为参数;Jakarta REST 自动将客户端发送的 JSON 格式数据填充到Customer参数中。

注意

Customer类是一个简单的数据传输对象DTO),包含一些与客户端表单中的输入字段匹配的属性,以及相应的 getter 和 setter。由于该类相当简单,我们决定不展示它。

addCustomer()方法中,我们创建了一个CustomerPersistenceClient()实例,这是一个持久化服务的客户端,使用 Jakarta REST 客户端 API 实现。

然后,我们的addCustomer()方法通过在CustomerPersistenceClient上调用create()方法来调用持久化服务,检查持久化服务返回的 HTTP 状态码,然后向客户端发送适当的响应。

现在,让我们看看我们的 Jakarta REST 客户端代码的实现:

package com.ensode.jakartaeebook.microservices.crudcontroller.restclient;
//imports omitted
public class CustomerPersistenceClient {
  private final WebTarget webTarget;
  private final Client client;
  private static final String BASE_URI =
   "http://localhost:8080/CrudPersistence/resources";
  public CustomerPersistenceClient() {
    client = ClientBuilder.newClient();
    webTarget = client.target(BASE_URI).path(
      "customerpersistence");
  }
  public Response create(Customer customer)
    throws ClientErrorException {
    return webTarget.request(
      MediaType.APPLICATION_JSON).post(
      Entity.entity(customer,
      MediaType.APPLICATION_JSON), Response.class);
  }
  public void close() {
    client.close();
  }
}

如我们所见,我们的客户端代码是一个相当简单的类,它使用了 Jakarta REST 客户端 API。我们声明了一个包含我们正在调用的服务的基础 URI 的常量(我们的持久化服务)。在其构造函数中,我们创建了一个新的jakarta.ws.rs.client.ClientBuilder实例。然后我们设置其基础 URI 和路径,匹配我们持久化服务的适当值。我们的客户端类有一个单一的方法,该方法向持久化服务提交一个 HTTP POST请求,然后返回从它那里返回的响应。

现在我们已经成功开发出我们的控制器服务,我们准备探索我们应用程序的最后一个组件——持久化服务。

持久化服务

我们的持久化服务使用 Jakarta REST 实现为一个简单的 RESTful Web 服务。其create()方法在服务接收到 HTTP POST请求时被调用:

package com.ensode.jakartaeebook.microservices.crudpersistence.service;
//imports omitted for brevity
@ApplicationScoped
@Path("customerpersistence")
public class CustomerPersistenceService {
  private static final Logger LOG =
    Logger.getLogger(
      CustomerPersistenceService.class.getName());
  @Context
  private UriInfo uriInfo;
  @Inject
  private CrudDao customerDao;
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  public Response create(Customer customer) {
    try {
      customerDao.create(customer);
    } catch (Exception e) {
      LOG.log(Level.SEVERE, "Exception caught", e);
      return Response.serverError().build();
    }
    return Response.created(uriInfo.getAbsolutePath()).build();
  }
}

当控制器服务向持久化服务发送 HTTP POST请求时,我们的create()方法被调用。此方法简单地在一个实现 DAO 设计模式的类上调用create()方法。我们的持久化服务返回一个 HTTP 响应,201(已创建)。如果一切顺利且 DAO 的create()方法抛出异常,则我们的服务返回500 HTTP 错误(内部服务器错误)。

我们的 DAO 实现为一个 CDI 管理的 Bean,使用 JPA 将数据插入数据库:

package com.ensode.jakartaeebook.microservices.crudpersistence.dao;
//imports omitted for brevity
@ApplicationScoped
@DataSourceDefinition(name = 
    "java:app/jdbc/microservicesCrudDatasource",
        className = "org.h2.jdbcx.JdbcDataSource",
        url = "jdbc:h2:tcp://127.0.1.1:9092/mem:microservicescrud",
        user = "sa",
        password = "")
public class CrudDao {
  @PersistenceContext(unitName = "CustomerPersistenceUnit")
  private EntityManager em;
  @H2DatabaseWrapper
  public void create(Customer customer) {
    em.persist(customer);
  }
}

我们的 DAO 实现非常简单;它实现了一个方法,该方法在注入的 EntityManager 实例上调用 persist() 方法。请注意,我们利用了 @DataSourceDefinition 注解来创建一个指向我们数据库的数据源。这个注解是一个标准的 Jakarta EE 注解,它允许我们以实现无关的方式定义数据源。

注意

在我们的持久化服务项目中,Customer 类是一个简单的 JPA 实体。

现在我们已经开发了我们应用程序的所有三个组件,我们准备看到它的实际效果。

当用户输入一些数据并点击提交按钮后,我们应该在我们的页面顶部看到一条“成功”消息(见图 图 5**.2)。

图 5.2 – 用户输入的数据

图 5.2 – 用户输入的数据

如果我们查看数据库,我们应该看到用户输入的数据已成功持久化,如图 图 5**.3 所示。

图 5.3 – 插入数据库中的数据

图 5.3 – 插入数据库中的数据

如我们的示例代码所示,在 Jakarta EE 中遵循微服务架构开发应用程序非常简单。它不需要任何特殊知识。微服务使用标准的 Jakarta EE API 开发,并部署到轻量级的 Jakarta EE 运行时。

摘要

如本章所示,Jakarta EE 非常适合微服务开发。

本章涵盖了以下主题:

  • 我们向您介绍了微服务,并列出了微服务架构的优缺点

  • 我们解释了如何使用标准的 Jakarta EE 技术开发微服务,例如 Jakarta REST

Jakarta EE 开发者可以利用他们现有的知识来开发微服务架构——部署现代、轻量级的应用服务器。传统的 Jakarta EE 应用程序可以很好地与微服务交互,并且当有需要时,也可以迭代地重构为微服务架构。无论是开发遵循微服务架构的新应用程序,重构现有应用程序为微服务,还是修改现有应用程序以与微服务交互,Jakarta EE 开发者都可以利用他们现有的技能来完成这项任务。

第六章:Jakarta Faces

在本章中,我们将介绍自 Java EE 6 以来web.xml已变为可选,这意味着在许多情况下,我们可以不写一行 XML 代码就编写完整的 Web 应用程序。

在本章中,我们将介绍以下主题:

  • Jakarta Faces 简介

  • 开发我们的第一个 Faces 应用程序

  • 自定义数据验证

  • 自定义默认消息

注意

本章的示例源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch06_src

Jakarta Faces 简介

在本节中,我们将概述使用 Jakarta Faces 开发 Web 应用程序涉及的内容,并提供在深入研究 Jakarta Faces 的细节之前必要的背景信息。

Facelets

Facelets是 Jakarta Faces 的默认视图技术。Facelets 使用标准的可扩展超文本标记语言XHTML)编写,使用 Jakarta Faces 特定的 XML 命名空间,这些命名空间提供了我们可以用来开发 Web 应用程序用户界面的 Jakarta Faces 特定标签。

可选的faces-config.xml

在大多数情况下,配置 Jakarta Faces 应用程序是不必要的,因为它遵循约定优于配置的方法。

对于某些特定情况,例如覆盖 Jakarta Faces 的默认错误消息时,我们仍然需要通过faces-config.xml配置文件来配置 Jakarta Faces。

标准资源位置

资源是页面或 Jakarta Faces 组件需要正确渲染的工件。资源示例包括 CSS 样式表、JavaScript 文件和图像。

当使用 Jakarta Faces 时,资源可以放置在名为resources的文件夹中的子目录中,这个文件夹位于 WAR 文件的根目录或其META-INF目录中。按照惯例,Jakarta Faces 组件知道它们可以从这两个位置之一检索资源。

为了避免资源目录杂乱,资源通常放置在子目录中。这个子目录通过 Faces 组件的library属性进行引用。

例如,我们可以在/resources/css/styles.css中放置一个名为styles.css的 CSS 样式表。

在我们的 Faces 页面中,我们可以使用以下<h:outputStylesheet>标签检索此 CSS 文件:

<h:outputStylesheet library="css"  name="styles.css"/>

library属性的值必须与我们的样式表所在的子目录相匹配。

同样,我们可以在/resources/scripts/somescript.js中有一个 JavaScript 文件,以及一个图像在/resources/images/logo.png中,我们可以按照以下方式访问这些资源:

<h:graphicImage library="images" name="logo.png"/>
<h:outputScript library="scripts" name="somescript.js"/>

注意,在每种情况下,library属性的值都与resources目录中相应的子目录名称相匹配,而name属性的值与资源的文件名相匹配。

开发我们的第一个 Faces 应用程序

为了说明基本的 Jakarta Faces 概念,我们将开发一个由两个 Facelet 页面和一个名为 Contexts and Dependency Injection (CDI) 的命名 bean 组成的简单应用程序。

Facelets

正如我们在本章引言中提到的,Jakarta Faces 的默认视图技术被称为 Facelets。Facelets 需要使用标准的 XML 编写。开发 Facelet 页面最流行的方式是结合使用 XHTML 和 Jakarta Faces 特定的 XML 命名空间。以下示例展示了典型的 Facelet 页面看起来是什么样子:

<!-- XML declaration and doctype omitted -->
<html 

>
  <h:head>
    <title>Enter Customer Data</title>
  </h:head>
  <h:body>
    <h:outputStylesheet library="css" name="styles.css"/>
    <h:form id="customerForm">
      <h:messages/>
      <h:panelGrid columns="2"
        columnClasses="rightAlign,leftAlign">
        <h:outputLabel for="firstName" value="First Name:">
        </h:outputLabel>
        <h:inputText id="firstName"
          label="First Name"
          value="#{customer.firstName}"
          required="true">
          <f:validateLength minimum="2" maximum="30"/>
        </h:inputText>
        <h:outputLabel for="lastName" value="Last Name:">
        </h:outputLabel>
        <h:inputText id="lastName"
                     label="Last Name"
                     value="#{customer.lastName}"
                     required="true">
          <f:validateLength minimum="2" maximum="30"/>
        </h:inputText>
        <h:outputLabel for="email" value="Email:">
        </h:outputLabel>
        <h:inputText id="email"
          label="Email"
          value="#{customer.email}">
          <f:validateLength minimum="3" maximum="30"/>
        </h:inputText>
        <h:panelGroup></h:panelGroup>
        <h:commandButton action="confirmation" value="Save">
        </h:commandButton>
      </h:panelGrid>
    </h:form>
  </h:body>
</html>

图 6**.1 展示了在部署我们的代码并输入一些数据后,我们的 Facelets 页面如何在浏览器中渲染。

图 6.1 – 渲染后的 Facelets 页面

图 6.1 – 渲染后的 Facelets 页面

几乎任何 Facelet 页面都会包含示例中展示的两个命名空间。第一个命名空间()用于渲染 HTML 组件的标签。按照惯例,当使用这个标签库时,前缀 **h** (代表 HTML) 被使用。

**第二个命名空间()是核心的 Faces 标签库。按照惯例,当使用这个标签库时,前缀 **f** (代表 Faces) 被使用。

**在前面的示例中,我们首先看到的 Faces 特定标签是 <h:head><h:body> 标签。这些标签类似于标准的 HTML <head><body> 标签,当页面在浏览器中显示时,它们会被渲染成这样。

<h:outputStylesheet> 标签用于从一个已知位置(Jakarta Faces 标准化了资源的位置,如 CSS 样式表和 JavaScript 文件,如本章之前所述)加载 CSS 样式表。library 属性的值必须对应于 CSS 文件所在的目录(这个目录必须在 resources 目录中)。name 属性必须对应于我们希望加载的 CSS 样式表的名称。

接下来我们看到的是 <h:form> 标签。当页面渲染时,这个标签会生成一个 HTML 表单。如示例所示,与常规 HTML 不同,这个标签不需要指定 actionmethod 属性。实际上,这个标签没有 action 属性或 method 属性。渲染的 HTML 表单的 action 属性将自动生成,而 method 属性始终是 "post"<h:form>id 属性是可选的;然而,始终添加它是一个好主意,因为它使得调试 Faces 应用程序变得更加容易。

我们接下来看到的标签是 <h:messages> 标签。正如其名称所暗示的,这个标签用于显示任何消息。正如我们很快就会看到的,Faces 可以自动生成验证消息;它们将显示在这个标签内。此外,可以通过 jakarta.faces.context.FacesContext 中定义的 addMessage() 方法程序化地添加任意消息。

我们接下来看到的下一个 Jakarta Faces 标签是<h:panelGrid>。这个标签大致相当于 HTML 表格,但它的工作方式略有不同。<h:panelGrid>标签有一个columns属性,而不是声明行(<tr>)和单元格(<td>)。此属性的值表示由此标签渲染的表格中的列数。当我们在这个标签内放置组件时,它们将放置在一行中,直到达到columns属性中定义的列数,然后下一个组件将被放置在下一行中。在示例中,columns属性的值是2,因此前两个标签将放置在第一行,接下来的两个将放置在第二行,依此类推。

注意

在 Web 开发的早期,使用 HTML 表格进行页面布局是一种流行的做法。随着 CSS 的出现,这种做法逐渐不再受欢迎。大多数现代 Facelets 页面使用 CSS 进行布局,但我们认为指出 Jakarta Faces 提供的布局功能是值得的。

<h:panelGrid>的另一个有趣属性是columnClasses属性。此属性将 CSS 类分配给渲染的表格中的每一列。在示例中,使用了两个 CSS 类(由逗号分隔)作为此属性的值。这会产生将第一个 CSS 类分配给第一列,第二个分配给第二列的效果。如果有三列或更多列,第三列将获得第一个 CSS 类,第四列获得第二个,依此类推,交替进行。

查看生成的 HTML 标记

我们可以通过在浏览器窗口上右键单击并选择查看 页面源代码来查看我们 Facelets 页面的生成 HTML 标记。

为了阐明其工作原理,下面的代码片段展示了前一个页面生成的 HTML 标记源代码的一部分:

<form id="customerForm" name="customerForm" method="post" action="/faces_intro/faces/index.xhtml" enctype="application/x-www-form-urlencoded">
  <table>
    <tbody>
      <tr>
        <td class="rightAlign">
          <label for="customerForm:firstName">
            First Name:</label>
        </td>
        <td class="leftAlign">
         <input id="customerForm:firstName" type="text"
           name="customerForm:firstName" value="" />
        </td>
      </tr>
      <tr>
        <td class="rightAlign">
          <label for="customerForm:lastName">
            Last Name:</label></td>
        <td class="leftAlign">
<input id="customerForm:lastName"
          type="text" name="customerForm:lastName"
            value="" /></td>
      </tr>
      <!-- Additional table rows omitted for brevity -->
    </tbody>
  </table>
</form>

注意到每个<td>标签都有一个交替的 CSS 标签"rightAlign""leftAlign",我们通过将值"rightAlign,leftAlign"分配给<h:panelGrid>columnClasses属性来实现这一点。我们应该注意,我们示例中使用的 CSS 类是在我们之前讨论的通过<h:outputStylesheet>加载的 CSS 样式表中定义的。生成的 HTML 标签的所有nameid属性的值是赋予<h:form>组件的 ID 和每个单独组件的 ID 的组合。注意,我们在页面末尾附近的<h:commandButton>组件没有分配 ID,因此 Faces 运行时自动分配了一个 ID,并使用它来填充生成的提交按钮的name属性。

在这个示例的这个阶段,我们开始在 <h:panelGrid> 内部添加组件。这些组件将被渲染在由 <h:panelGrid> 渲染的表格内。正如我们之前提到的,渲染表格的列数由 <h:panelGrid>columns 属性定义。因此,我们不需要担心列(或行),我们只需开始添加组件,它们将被放置在正确的位置。

我们接下来看到的标签是 <h:outputLabel> 标签。此标签被渲染为 HTML label 元素。标签通过 for 属性与其他组件相关联,其值必须与标签所针对的组件的 ID 匹配。

接下来,我们看到 <h:inputText> 标签。此标签在渲染的页面上生成一个文本字段;其 label 属性用于任何验证消息。它让用户知道信息所指的字段。

注意

尽管 <h:inputText> 标签的 label 属性值与页面显示的标签匹配不是必需的,但强烈建议这样做。如果出现错误,这将使用户确切知道错误信息所指的具体字段。

特别值得注意的是该标签的 value 属性。我们看到的这个属性的值是在名为 customer 的命名 Bean 中的 firstName。当用户为此文本字段输入一个值并提交表单时,命名 Bean 中相应的属性将使用此值进行更新。该标签的 required 属性是可选的,其有效值是 truefalse。如果将此属性设置为 true,容器将不允许用户在为文本字段输入一些数据之前提交表单。如果用户尝试不输入必需的值提交表单,页面将被重新加载,并在 <h:messages> 标签内显示错误信息,如图 图 6**.2 所示。

图 6.2 – 必填字段数据验证

图 6.2 – 必填字段数据验证

图 6**.2 展示了当用户尝试在示例中保存表单而没有输入客户姓氏的值时显示的默认错误信息。信息的开头部分(对应 <h:inputTextField> 标签的 label 属性)。信息的文本可以自定义,以及其样式(字体、颜色等)。我们将在本章后面介绍如何做到这一点。

项目阶段

在每个 Jakarta Faces 页面上都有一个<h:messages>标签是个好主意。如果没有它,用户可能看不到验证消息,并且不知道为什么表单提交没有成功。默认情况下,Jakarta Faces 验证消息不会在应用程序服务器日志中生成任何输出。新 Jakarta Faces 开发者常犯的一个错误是未能将<h:messages>标签添加到他们的页面上。如果没有它,如果验证失败,则导航似乎没有原因失败(如果导航失败,则渲染相同的页面,如果没有<h:messages>标签,浏览器中不会显示错误消息)。

为了避免这种情况,我们可以利用 Jakarta Faces 的项目阶段

在 Jakarta Faces 中定义了以下项目阶段:

  • 生产

  • 开发

  • 单元测试

  • 系统测试

我们可以将项目阶段定义为web.xml中 Faces servlet 的初始化参数,或者作为自定义web.xml在环境之间需要。

如何设置自定义 JNDI 资源取决于你的应用程序服务器。请查阅你的应用程序服务器文档以获取详细信息。例如,如果我们使用GlassFish来部署我们的应用程序,我们可以通过登录到 Web 控制台,导航到JNDI | 自定义资源,然后点击新建...按钮来设置自定义 JNDI 资源,如图图 6**.3所示。

图 6.3 – 将 GlassFish 中的 Jakarta Faces 项目阶段定义为 JNDI 资源

图 6.3 – 将 GlassFish 中的 Jakarta Faces 项目阶段定义为 JNDI 资源

要定义 Jakarta Faces 项目阶段,我们需要输入以下信息:

JNDI 名称 jakarta.faces.PROJECT_STAGE
资源类型 java.lang.String

表 6.1 – 在 GlassFish 中设置 Jakarta Faces 项目阶段

输入前两个值后,工厂类字段将自动填充以下值:

org.glassfish.resources.custom.factory.PrimitivesAndStringFactory.

输入值后,我们需要添加一个名为value的新属性,其值对应我们希望使用的项目阶段(开发,本例中)。

一旦我们添加了自定义的 JNDI 资源,我们需要更新我们的web.xml配置文件以读取它,这一步骤在 Jakarta EE 实现中是相同的。

以下示例web.xml配置文件说明了如何设置 Jakarta Faces 项目阶段,以便我们的应用程序可以成功使用它:

<?xml version="1.0" encoding="UTF-8"?>
<web-app 

  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
  https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
  version="6.0">
  <resource-ref>
    <res-ref-name>faces/ProjectStage</res-ref-name>
    <res-type>java.lang.String</res-type>
    <mapped-name>jakarta.faces.PROJECT_STAGE</mapped-name>
  </resource-ref>
  <servlet>
    <servlet-name>Faces Servlet</servlet-name>
    <servlet-class>
       jakarta.faces.webapp.FacesServlet
    </servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>Faces Servlet</servlet-name>
    <url-pattern>/faces/*</url-pattern>
  </servlet-mapping>
  <welcome-file-list>
    <welcome-file>faces/index.xhtml</welcome-file>
  </welcome-file-list>
</web-app>

web.xml中的<resource-ref>标签允许我们访问在应用程序服务器中定义的 JNDI 资源。在我们的情况下,我们想要访问我们的 Faces 应用程序的项目阶段。

<res-ref-name>为我们 JNDI 资源提供了一个名称,我们的代码可以使用它来查找我们的 JNDI 资源。我们的 Jakarta Faces 实现将寻找一个名为faces/ProjectStage的 JNDI 资源,如果找到了,就会使用它来确定我们的项目阶段。

<res-type> 允许我们指定我们正在寻找的资源类型,因为可以通过 JNDI 查询任意 Java 对象。当设置 Jakarta Faces 项目阶段时,此标签的值必须始终为 java.lang.String

我们通过 <mapped-name> 标签指定应用程序服务器 JNDI 树中的资源名称。按照惯例,当通过 JNDI 设置 Jakarta Faces 项目阶段时,此值必须始终为 jakarta.faces.PROJECT_STAGE

设置项目阶段允许我们在特定阶段运行时执行一些逻辑。例如,在我们的一个命名 Bean 中,我们可能有如下代码:

    FacesContext facesContext =
        FacesContext.getCurrentInstance();
    Application application = facesContext.getApplication();
    if (application.getProjectStage().equals(
        ProjectStage.Production)) {
      //do production stuff
    } else if (application.getProjectStage().equals(
        ProjectStage.Development)) {
      //do development stuff
    } else if (application.getProjectStage().equals(
        ProjectStage.UnitTest)) {
      //do unit test stuff
    } else if (application.getProjectStage().equals(
        ProjectStage.SystemTest)) {
      //do system test stuff
    }

如我们所见,项目阶段允许我们根据不同的环境修改代码的行为。更重要的是,设置项目阶段允许 Jakarta Faces 根据项目阶段设置表现出不同的行为。在这种情况下,将项目阶段设置为开发会导致在渲染的页面上显示额外的调试信息。因此,如果我们忘记在我们的页面上添加 <h:messages> 标签;我们的项目阶段是开发,并且验证失败,即使我们省略了 <h:messages> 组件,页面上也会显示验证错误。这如图 图 6**.4 所示。

图 6.4 – 当项目阶段处于开发状态时显示的调试信息

图 6.4 – 当项目阶段处于开发状态时显示的调试信息

在默认的生产阶段,此错误消息不会在页面上显示,这让我们困惑,为什么我们的页面导航似乎不起作用。

验证

注意到每个 <h:inputField> 标签都有一个嵌套的 <f:validateLength> 标签。正如其名称所暗示的,此标签验证输入文本字段的值是否在最小和最大长度之间。最小和最大值由标签的 minimummaximum 属性定义。<f:validateLength> 是 Jakarta Faces 包含的标准验证器之一。就像 <h:inputText>required 属性一样,当用户尝试提交一个包含无效值的表单时,Jakarta Faces 将自动显示默认错误消息,如图 图 6**.5 所示。

图 6.5 – 长度验证

图 6.5 – 长度验证

可以覆盖任何 Jakarta Faces 验证消息的默认文本和 CSS 样式;我们将在本章后面介绍如何做到这一点。

除了 <f:validateLength> 之外,Jakarta Faces 还包括其他标准验证器,这些验证器列在以下表中:

验证标签 描述
<f:validateBean> 通过在命名 Bean 中使用注解进行验证,Bean 验证允许我们验证命名 Bean 的值,而无需向我们的 Jakarta Faces 标签添加验证器。如果需要,此标签允许我们微调 Bean 验证。
<f:validateDoubleRange> 验证输入是一个有效的 Double 值,在标签的 minimummaximum 属性指定的两个值之间,包括这两个值
<f:validateLength> 验证输入的长度在标签的 minimummaximum 值之间,包括这两个值
<f:validateLongRange> 验证输入是一个有效的 Double 值,在标签的 minimummaximum 属性指定的值之间,包括这两个值
<f:validateRegex> 验证输入与标签的 pattern 属性中指定的正则表达式模式匹配
<f:validateRequired> 验证输入不为空。此标签等同于在父输入字段中将 required 属性设置为 true

表 6.2 – Jakarta Faces 验证标签

注意,在 <f:validateBean> 的描述中,我们简要提到了 Bean Validation。Bean Validation API 旨在标准化 JavaBean 验证。JavaBeans 被几个其他 API 使用,这些 API 之前必须实现自己的验证逻辑。Jakarta Faces 利用 Bean Validation 来帮助验证命名 bean 属性。

如果我们想利用 Bean Validation,我们只需要用适当的 Bean Validation 注解标注所需的字段,而无需显式使用 Jakarta Faces 验证器。

注意

有关 Bean Validation 注解的完整列表,请参阅 Jakarta EE 10 API 文档中的 jakarta.validation.constraints 包,网址为 jakarta.ee/specifications/platform/10/apidocs/

组件分组

<h:panelGroup> 是示例中的下一个新标签。通常,<h:panelGroup> 用于将多个组件组合在一起,以便它们在 <h:panelGrid> 中占用单个单元格。这可以通过在 <h:panelGroup> 内添加组件并将 <h:panelGroup> 添加到 <h:panelGrid> 中来实现。如示例所示,这个 <h:panelGroup> 实例没有子组件。在这种情况下,<h:panelGroup> 的目的是有一个空单元格,并使下一个组件 <h:commandButton> 与表单中的所有其他输入字段对齐。

表单提交

<h:commandButton> 在浏览器中渲染一个 HTML 提交按钮。就像标准 HTML 一样,它的目的是提交表单。它的 value 属性简单地设置按钮的标签。此标签的 action 属性用于导航,要显示的下一页基于此属性的值。action 属性可以是一个字符串常量或一个 方法绑定表达式,这意味着它可以指向一个返回字符串的命名 bean 中的方法。

如果我们应用程序中页面的基本名称与<h:commandButton>标签的action属性的值相匹配,那么点击按钮时我们将导航到该页面。在我们的示例中,我们的确认页面名为confirmation.xhtml,因此按照惯例,当点击按钮时,将显示此页面,因为其action属性的值("confirmation")与页面的基本名称相匹配。

注意

尽管按钮的标签读作保存,但在我们的简单示例中,点击按钮实际上不会保存任何数据。

命名 bean

Jakarta Faces 与类级别的@Named注解紧密集成。以下是我们的示例中的命名 bean:

package com.ensode.jakartaeebook.faces;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Named;
@Named
@RequestScoped
public class Customer {
  private String firstName;
  private String lastName;
  private String email;
  //getters and setters omitted for brevity
}

@Named类注解指定此 bean 为 CDI 命名 bean。此注解有一个可选的value属性,我们可以使用它为我们的 bean 提供一个逻辑名称,以便在 Jakarta Faces 页面中使用。然而,按照惯例,此属性的值与类名(在我们的例子中是Customer)相同,其第一个字符被转换为小写。在我们的示例中,我们允许这种默认行为发生,因此我们通过customer逻辑名称访问我们的 bean 属性。注意,在我们的示例页面中任何输入字段的value属性,以查看此逻辑名称的实际应用。

注意,除了@Named@RequestScoped注解之外,此 bean 没有特殊之处。它是一个标准的 JavaBean,具有私有属性和相应的 getter 和 setter 方法。@RequestScoped注解指定 bean 应该在一个请求中存活。CDI 命名 bean 可用的不同作用域将在下一节中介绍。

命名 bean 作用域

命名 bean 始终有一个作用域。命名 bean 作用域定义了应用程序的生命周期。命名 bean 作用域由类级别的注解定义。以下表格列出了所有有效的命名 bean 作用域。

命名 bean 作用域注解 描述
@``ApplicationScoped 应用程序作用域的命名 bean 的同一实例对所有我们的应用程序客户端都是可用的。如果一个客户端修改了应用程序作用域的命名 bean 的值,则更改将在客户端之间反映出来。
@``SessionScoped 每个会话作用域的命名 bean 实例都被分配给我们的应用程序的每个客户端。会话作用域的命名 bean 可以用来在请求之间保持客户端特定的数据。
@``RequestScoped 请求作用域的命名 bean 只存在于单个 HTTP 请求中。
@``Dependent 依赖作用域的命名 bean 被分配给它们注入的 bean 的作用域。
@``ConversationScoped 对话作用域可以跨越多个请求,但通常比会话作用域短。
@``ClientWindowScoped 客户端窗口作用域的 bean 将保留在内存中,直到当前的网络浏览器窗口或标签页被关闭。

表 6.3 – CDI 作用域注解

命名 bean 作用域允许我们指定 CDI 命名 bean 的生命周期。通过使用前面表格中列出的命名 bean 作用域之一,我们可以控制我们的命名 bean 何时创建和销毁。

静态导航

如我们的输入页面所示,当点击customer_data_entry.xhtml页面时,我们的应用程序将导航到一个名为confirmation.xhtml的页面。这是因为我们正在利用 Jakarta Faces 的约定优于配置功能,其中如果命令按钮或链接的action属性值与另一个页面的基本名称匹配,则此导航将带我们到该页面。这种行为被称为静态导航。Jakarta Faces 还支持动态导航,其中着陆页面可以根据某些业务逻辑确定,我们将在下一节讨论动态导航。

点击按钮或链接时是否会导致同一页面重新加载,而该按钮或链接应该导航到另一个页面?

如果导航看起来没有正常工作,那么很可能是这个属性的值有拼写错误。记住,按照惯例,Jakarta Faces 会寻找一个与命令按钮或链接的action属性值匹配的基本名称的页面。

confirmation.xhtml的源代码如下:

<!-- XML declaration and doctype omitted -->
<html 
      >
  <h:head>
    <title>Customer Data Entered</title>
  </h:head>
  <h:body>
    <h:panelGrid columns="2"
      columnClasses="rightAlign,leftAlign">
      <h:outputText value="First Name:"/>
      <h:outputText value="#{customer.firstName}"/>
      <h:outputText value="Last Name:"></h:outputText>
      <h:outputText value="#{customer.lastName}"/>
      <h:outputText value="Email:"/>
      <h:outputText value="#{customer.email}"/>
    </h:panelGrid>
  </h:body>
</html>

我们在确认页面上看到的<h:outputText>是我们之前没有覆盖的唯一标签。这个标签简单地将其value属性的值显示到渲染的页面上,其value属性可以是简单的字符串或值绑定表达式。由于我们<h:outputText>标签中的值绑定表达式与之前页面中<h:inputText>标签使用的相同表达式,它们的值将对应于用户输入的数据。我们可以在图 6.6中看到页面的渲染方式。

图 6.6 – 示例确认页面

图 6.6 – 示例确认页面

在传统的(即非 Jakarta Faces)Java Web 应用程序中,我们定义 URL 模式,以便由特定的 servlet 处理。对于 Jakarta Faces 来说,通常使用.faces后缀。另一个常用的 Faces URL 映射是/faces前缀。在特定条件下,现代应用程序服务器会自动将所有这些映射添加到faces servlet 中。如果满足这些条件,我们根本不需要指定任何 URL 映射。

如果以下任何条件成立,则FacesServlet将被自动映射:

  • 在我们 Web 应用程序的WEB-INF目录中有一个faces-config.xml文件

  • 在我们 Web 应用程序的一个依赖项的META-INF目录中有一个faces-config.xml文件

  • 在我们 Web 应用程序的一个依赖项的META-INF目录中有一个以.faces-config.xml结尾的文件名

  • 我们在web.xml或我们 Web 应用程序依赖项中的一个web-fragment.xml中声明了一个名为jakarta.faces.CONFIG_FILES的上下文参数

  • 在调用ServletContextInitializeronStartup()方法时,我们传递一个非空类集。

当这些条件都不满足时,我们需要在web.xml部署描述符中显式映射 Jakarta Faces servlet,如图所示:

<?xml version="1.0" encoding="UTF-8"?>
<web-app 

  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
  https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
  version="6.0">
  <servlet>
    <servlet-name>Faces Servlet</servlet-name>
    <servlet-class>
      jakarta.faces.webapp.FacesServlet
    </servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>Faces Servlet</servlet-name>
    <url-pattern>/faces/*</url-pattern>
  </servlet-mapping>
</web-app>

我们在应用程序中的页面使用的 URL 是 Facelets 页面的名称,前面加上/faces。我们在web.xml配置文件中的<servlet-mapping>标签中指定这一点。

动态导航

在某些情况下,我们可能事先不知道应用程序工作流程中的下一页是什么,我们需要运行一些业务逻辑来确定显示下一页。例如,当提交数据时,如果一切如预期进行,我们可能希望导航到确认页面,或者我们可能希望导航到错误页面,甚至在处理数据时出现错误,我们可能希望导航回输入页面。

以下示例说明了我们如何在 Jakarta Faces 中实现动态导航:

<!-- XML declaration and doctype omitted -->
<html 

      >
  <h:head>
    <title>Enter Customer Data</title>
  </h:head>
  <h:body>
    <h:outputStylesheet library="css" name="styles.css"/>
    <h:form id="customerForm">
      <h:messages/>
      <h:panelGrid columns="2"
                   columnClasses="rightAlign,leftAlign">
        <!- input fields omitted for brevity -->
        <h:commandButton
          action="#{customerController.saveCustomer()}"
          value="Save"/>
      </h:panelGrid>
    </h:form>
  </h:body>
</html>

为了实现动态导航,我们需要修改命令按钮的动作属性,使其成为一个 Jakarta 表达式语言方法表达式,即映射到我们 CDI 命名 bean 中某个方法的表达式。对于动态导航,表达式中的方法必须不接受任何参数并返回一个字符串。在我们的示例中,我们正在使用名为customerController的 CDI 命名 bean 中的saveCustomer()方法,因此我们的表达式是#{customerController.saveCustomer}。我们将在下一节中查看saveCustomer()方法的实现:

package com.ensode.jakartaeebook.faces;
//imports omitted for brevity
@Named
@RequestScoped
public class CustomerController {
 //instance variables omitted for brevity
  public String saveCustomer() throws IOException {
    String landingPage = "confirmation";
    String tmpDir = System.getProperty("java.io.tmpdir");
    Path customerFile;
    try {
      customerFile = Path.of(tmpDir, "customer-file.txt");
      if (!Files.exists(customerFile)) {
        customerFile = Files.createFile(Path.of(tmpDir,
                "customer-file.txt"));
      }
      //force an exception every other run
      customerFile.toFile().setWritable(
        !customerFile.toFile().canWrite());
      Files.writeString(customerFile, customer.toString(),
              StandardOpenOption.APPEND);
    } catch (IOException ex) {
      landingPage = "index";
      FacesMessage facesMessage = new FacesMessage(
              "Error saving customer");
      facesContext.addMessage(null, facesMessage);
    }
    return landingPage;
  }
}

我们的示例应用程序遵循模型-视图-控制器设计模式。我们的 CDI 命名 bean 充当控制器,并在用户提交表单时决定导航到哪个页面。如果一切顺利,我们将像往常一样导航到确认页面。如果出现问题(捕获到异常),我们将导航回输入页面(命名为index.xhtml)并向用户显示错误消息。

为了说明目的,我们的saveCustomer()方法简单地将Customer对象字符串表示保存到文件系统中,以强制抛出异常并允许我们展示动态导航。我们的代码翻转了相关文件的只读属性(如果它是可写的,则使其只读,反之亦然),然后尝试写入文件。当尝试在文件为只读时写入文件时,将抛出异常,并像图6**.7所示的那样导航回输入页面。

图 6.7 – 动态导航

图 6.7 – 动态导航

有时我们需要提供自定义的业务逻辑验证或实现标准 Jakarta Faces 验证器中未包含的验证规则。在这种情况下,我们可以利用 Jakarta Faces 自定义验证功能。

自定义数据验证

除了为我们提供标准验证器之外,Jakarta Faces 允许我们创建自定义验证器。这可以通过两种方式之一完成:创建一个自定义验证器类或将验证方法添加到我们的命名豆中。

创建自定义验证器

除了标准验证器之外,Jakarta Faces 允许我们通过创建一个实现 jakarta.faces.validator.Validator 接口的 Java 类来创建自定义验证器。

以下类实现了一个电子邮件验证器,我们将使用它来验证客户数据输入屏幕中的电子邮件文本输入字段:

package com.ensode.jakartaeebook.facescustomval;
//imports ommitted for brevity
@FacesValidator(value = "emailValidator")
public class EmailAddressValidator implements Validator {
  @Override
  public void validate(FacesContext facesContext,
          UIComponent uiComponent,
          Object value) throws ValidatorException {
    EmailValidator emailValidator =
      EmailValidator.getInstance();
    HtmlInputText htmlInputText =
      (HtmlInputText) uiComponent;
    String emailAddress = (String) value;
    if (!StringUtils.isEmpty(emailAddress)) {
      if (!emailValidator.isValid(emailAddress)) {
        FacesMessage facesMessage =
          new FacesMessage(htmlInputText.getLabel()
          + ": email format is not valid");
        throw new ValidatorException(facesMessage);
      }
    }
  }
}

@FacesValidator 注解将我们的类注册为 Jakarta Faces 自定义验证器类。其 value 属性的值是 Facelets 页面用来引用它的逻辑名称。

如示例所示,在实现 Validator 接口时,我们只需要实现一个名为 validate() 的方法。该方法接受三个参数,分别是 jakarta.faces.context.FacesContext 的一个实例、一个 jakarta.faces.component.UIComponent 的实例和一个对象。通常,应用程序开发者只需关注后两个参数。第二个参数是我们正在验证数据的组件,第三个参数是实际值。在我们的示例验证器中,我们将 uiComponent 强制转换为 jakarta.faces.component.html.HtmlInputText。这样,我们可以访问其 getLabel() 方法,我们可以将其用作错误消息的一部分。

如果输入的值不是有效的电子邮件地址格式,则创建一个新的 jakarta.faces.application.FacesMessage 实例,将要在浏览器中显示的错误消息作为其构造函数参数传递。然后我们抛出一个新的 jakarta.faces.validator.ValidatorException。错误消息随后在浏览器中显示。它是如何到达那里的由 Jakarta Faces API 在幕后完成。

Apache Commons Validator

我们的自定义验证器使用 Apache Commons Validator 来执行实际的验证。这个库包括许多常见的验证,如日期、信用卡号码、ISBN 号码和电子邮件。在实现自定义验证器时,值得调查这个库是否已经有一个我们可以使用的验证器。

为了在我们的页面上使用我们的验证器,我们需要在我们的 Facelets 页面上使用 <f:validator> 标签,将其嵌套在我们希望验证的字段内部。以下代码片段说明了我们为了将我们的自定义验证器集成到电子邮件输入字段中而必须做出的更改:

<h:inputText label="Email" value="#{customer.email}">
  <f:validator validatorId="emailValidator" />
</h:inputText>

为了验证我们的电子邮件输入字段,我们在其标记内部添加了一个嵌套的 <f:validator> 标签。请注意,<f:validator>validatorId 属性的值与我们自定义电子邮件验证器中使用的 @FacesValidator 注解中的值相匹配。

在编写我们的自定义验证器并修改我们的页面以利用它之后,我们可以看到我们的验证器在 图 6.8 中的实际应用。

图 6.8 – 自定义 Jakarta Faces 验证器在行动

图 6.8 – 自定义 Jakarta Faces 验证器在实际中的应用

现在我们已经看到了如何编写自定义验证器,我们将关注另一种在 Jakarta Faces 中实现自定义验证的方法,即通过编写验证方法。

验证方法

我们还可以通过向应用程序的一个或多个命名 Bean 中添加验证方法来实现自定义验证。以下 Java 类展示了如何使用验证方法进行 Faces 验证:

package com.ensode.jakartaeebook.facescustomval;
//imports omitted for brevity
@Named
@RequestScoped
public class AlphaValidator {
  public void validateAlpha(FacesContext facesContext,
    UIComponent uiComponent,
    Object value) throws ValidatorException {
    if (!StringUtils.isAlphaSpace((String) value)) {
      HtmlInputText htmlInputText =
        (HtmlInputText) uiComponent;
      FacesMessage facesMessage =
        new FacesMessage(htmlInputText.
        getLabel()
        + ": only alphabetic characters are allowed.");
      throw new ValidatorException(facesMessage);
    }
  }
}

在这个例子中,该类只包含验证方法,但这并不总是必须的。我们可以给我们的验证方法取任何我们想要的名称,但它的返回值必须是 void,并且它必须按照示例中的顺序接受三个参数。换句话说,除了方法名外,验证方法的签名必须与jakarta.faces.validator.Validator接口中定义的validate()方法的签名相同。

如我们所见,前面验证方法的主体几乎与我们的自定义验证器的validate()方法的主体相同。我们检查用户输入的值,以确保它只包含字母字符和/或空格。如果不满足条件,则抛出一个包含适当错误消息字符串的ValidatorException

StringUtils

在示例中,我们使用了org.apache.commons.lang3.StringUtils来执行实际的验证逻辑。除了示例中使用的该方法外,此类还包含几个用于验证string是否为数字或字母数字的方法。这个类是 Apache Commons Lang 库的一部分,在编写自定义验证器时非常有用。

由于每个验证方法都必须在命名 Bean 中,我们需要确保包含我们的验证方法的类被@Named注解标注,正如我们在示例中所展示的那样。

我们需要做的最后一件事是将我们的验证方法绑定到我们的组件上,通过标签的validator属性:

<h:outputText value="First Name:"/>
<h:inputText label="First Name"
  value="#{customer.firstName}"
  required="true"
  validator="#{alphaValidator.validateAlpha}">
  <f:validateLength minimum="2" maximum="30"/>
</h:inputText>
<h:outputText value="Last Name:"/>
<h:inputText label="Last Name"
  value="#{customer.lastName}"
  required="true"
  validator="#{alphaValidator.validateAlpha}">
  <f:validateLength minimum="2" maximum="30"/>
</h:inputText>

由于姓名字段和姓氏字段都不应接受除字母字符和空格之外的内容,我们将我们的自定义验证方法添加到了这两个字段。

注意到<h:inputText>标签的验证器属性值是一个用 Jakarta 表达式语言编写的表达式,它使用包含我们的验证方法的 Bean 的默认命名 Bean 名称。alphaValidator是我们 Bean 的名称,而validateAlpha是我们验证方法的名称。

在修改我们的页面以使用我们的自定义验证器后,我们现在可以看到它在图 6.9中的实际应用。

图 6.9 – 通过验证方法进行自定义验证

图 6.9 – 通过验证方法进行自定义验证

注意到对于姓名字段,我们的自定义验证消息和标准长度验证器都已被执行。

实现验证方法的优势在于不需要为单个验证方法创建整个类(我们的示例就是这样做的,但在许多情况下,验证方法被添加到包含其他方法的现有命名 bean 中)。然而,缺点是每个组件只能由单个验证方法进行验证。当使用验证类时,可以在要验证的标签内部嵌套多个<f:validator>标签,因此可以在字段上执行多个验证,包括自定义和标准验证。

现在我们已经看到了如何创建自己的自定义验证,我们将看到如何自定义 Jakarta Faces 验证消息,我们将看到如何更改它们的格式(字体、颜色等),以及如何为标准 Jakarta Faces 验证器自定义错误消息文本。

自定义默认消息

正如我们之前提到的,可以自定义 Jakarta Faces 默认验证消息的样式(字体、颜色、文本等)。此外,还可以修改默认 Jakarta Faces 验证消息的文本。在接下来的几节中,我们将解释如何修改错误消息的格式和文本。

自定义消息样式

可以通过<h:message>标签的stylestyleClass属性来自定义消息样式。当我们要声明内联 CSS 样式时使用style属性。当我们要在 CSS 样式表中或在我们页面的<style>标签中使用预定义样式时使用styleClass属性。

以下markup展示了如何使用styleClass属性来改变错误消息的样式。这是我们在上一节中看到的输入页面的修改版本。

  <h:body>
    <h:outputStylesheet library="css" name="styles.css" />
    <h:form>
      <h:messages styleClass="errorMsg"/>
      <!-- additional markup omitted for brevity →
    </h:form>
  </h:body>
</html>

与上一页相比,唯一的区别是使用了<h:messages>标签的styleClass属性。正如我们之前提到的,styleClass属性的值必须与页面可访问的级联样式表中定义的 CSS 样式的名称相匹配。

在我们的案例中,我们定义了如下 CSS 样式用于消息:

.errorMsg {
  color: red;
}

然后将此样式作为我们<h:messages>标签的styleClass属性的值。

图 6**.10展示了在实施此更改后验证错误消息的外观。

图 6.10 – 验证消息的自定义样式

图 6.10 – 验证消息的自定义样式

在这个特定的情况下,我们只是将错误消息文本的颜色设置为红色,但我们只能通过 CSS 的能力来设置错误消息的样式。

注意

几乎任何标准的 Jakarta Faces 组件都包含一个style属性和一个styleClass属性,可以用来改变其样式。前者用于预定义的 CSS 样式,后者用于内联 CSS。

自定义消息文本

有时可能需要覆盖 Jakarta Faces 默认验证消息的文本。默认验证消息定义在名为Messages.properties的资源包中。此文件通常位于应用程序服务器包含的 Faces JAR 文件之一中。例如,GlassFish将其包含在名为jakarta.faces.jar的 JAR 文件中,位于[glassfish 安装目录]/glassfish/modules下。该文件包含多个消息;在此阶段,我们只对验证错误感兴趣。默认验证错误消息定义如下:

jakarta.faces.validator.DoubleRangeValidator.MAXIMUM={1}: Validation Error: Value is greater than allowable maximum of "{0}"
jakarta.faces.validator.DoubleRangeValidator.MINIMUM={1}: Validation Error: Value is less than allowable minimum of ''{0}''
jakarta.faces.validator.DoubleRangeValidator.NOT_IN_RANGE={2}: Validation Error: Specified attribute is not between the expected values of {0} and {1}.
jakarta.faces.validator.DoubleRangeValidator.TYPE={0}: Validation Error: Value is not of the correct type
jakarta.faces.validator.LengthValidator.MAXIMUM={1}: Validation Error: Length is greater than allowable maximum of ''{0}''
jakarta.faces.validator.LengthValidator.MINIMUM={1}: Validation Error: Length is less than allowable minimum of ''{0}''
jakarta.faces.validator.LongRangeValidator.MAXIMUM={1}: Validation Error: Value is greater than allowable maximum of ''{0}''
jakarta.faces.validator.LongRangeValidator.MINIMUM={1}: Validation Error: Value is less than allowable minimum of ''{0}''
jakarta.faces.validator.LongRangeValidator.NOT_IN_RANGE={2}: Validation Error: Specified attribute is not between the expected values of {0} and {1}.
jakarta.faces.validator.LongRangeValidator.TYPE={0}: Validation Error: Value is not of the correct type.
jakarta.faces.validator.NOT_IN_RANGE=Validation Error: Specified attribute is not between the expected values of {0} and {1}.
jakarta.faces.validator.RegexValidator.PATTERN_NOT_SET=Regex pattern must be set.
jakarta.faces.validator.RegexValidator.PATTERN_NOT_SET_detail=Regex pattern must be set to non-empty value.
jakarta.faces.validator.RegexValidator.NOT_MATCHED=Regex Pattern not matched
jakarta.faces.validator.RegexValidator.NOT_MATCHED_detail=Regex pattern of ''{0}'' not matched
jakarta.faces.validator.RegexValidator.MATCH_EXCEPTION=Error in regular expression.
jakarta.faces.validator.RegexValidator.MATCH_EXCEPTION_detail=Error in regular expression, ''{0}''
jakarta.faces.validator.BeanValidator.MESSAGE={0}

为了覆盖默认的错误消息,我们需要创建自己的资源包,使用与默认资源包相同的键,但更改值以适应我们的需求。以下是我们应用程序的一个非常简单的自定义资源包示例。例如,要覆盖最小长度验证的消息,我们将在我们的自定义资源包中添加以下属性:

jakarta.faces.validator.LengthValidator.MINIMUM={1}: minimum allowed length is ''{0}''

在此资源包中,我们覆盖了当使用<f:validateLength>标签验证的字段输入的值小于允许的最小值时的错误消息。为了让我们的应用程序知道我们有一个包含消息属性的自定义资源包,我们需要修改应用程序的faces-config.xml文件:

<?xml version='1.0' encoding='UTF-8'?>
<faces-config version="4.0"

  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
  https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd">
  <application>
    <message-bundle>com.ensode.Messages</message-bundle>
  </application>
</faces-config>

如我们所见,我们只需要对应用程序的faces-config.xml文件进行修改,添加一个<message-bundle>元素,指示包含我们的自定义消息的资源包的名称和位置。

在添加我们的自定义消息资源包并修改应用程序的

faces-config.xml 文件,我们可以看到我们的自定义验证消息正在发挥作用,如图 图 6**.11 所示。

图 6.11 – 自定义验证错误消息

图 6.11 – 自定义验证错误消息

如果我们没有覆盖验证消息,默认消息仍然会显示。在我们的资源包中,我们只覆盖了最小长度验证错误消息,因此我们的自定义错误消息会显示在姓名文本字段中。由于我们没有覆盖其他标准 Jakarta Faces 验证器的错误消息,因此每个验证器都会显示默认错误消息。电子邮件验证器是我们在本章中先前开发的自定义验证器,因为它是一个自定义验证器,其错误消息不受影响。

摘要

在本章中,我们介绍了如何使用 Jakarta Faces 开发基于 Web 的应用程序,Jakarta Faces 是 Jakarta EE 的标准组件框架。

在本章中,我们介绍了如何通过使用 Facelets 作为视图技术以及 CDI 命名豆来创建简单应用程序。我们看到了如何使用 Jakarta Faces 实现静态和动态导航。我们还介绍了如何通过使用 Faces 标准验证器、创建我们自己的自定义验证器或编写验证器方法来验证用户输入。此外,我们还介绍了如何自定义标准 Faces 错误消息,包括消息文本和消息样式(字体、颜色等)。

Jakarta Faces 与 CDI 的紧密集成使我们能够高效地为我们的 Jakarta EE 应用程序开发基于 Web 的界面。

第七章:额外的 Jakarta Faces 功能

在本章中,我们将介绍 Jakarta EE 的标准组件框架 Jakarta Faces 的附加功能。这些附加功能使我们能够使我们的 Web 应用程序用户友好,同时为应用程序开发者提供方便的功能。

在本章中,我们将涵盖以下主题:

  • Ajax 启用的前端应用

  • Jakarta Faces 对 HTML5 的支持

  • Faces Flows

  • Faces WebSocket 支持

  • 额外的 Faces 组件库

注意

本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch07_src

Ajax 启用的前端应用

Jakarta Faces 允许我们轻松实现<f:ajax>标签和 CDI 命名豆,而无需实现任何 JavaScript 代码或解析 JSON 字符串。

以下标记展示了<f:ajax>标签的典型用法。

    <h:form>
      <h:messages/>
      <h:panelGrid columns="2">
        <h:outputText value="Echo input:"/>
        <h:inputText id="textInput"
          value="#{controller.text}">
          <f:ajax render="textVal" event="keyup"/>
        </h:inputText>
        <h:outputText value="Echo output:"/>
        <h:outputText id="textVal"
           value="#{controller.text}"/>
      </h:panelGrid>
      <hr/>
      <h:panelGrid columns="2">
        <h:outputText value="First Operand:"/>
        <h:inputText id="first"
          value="#{controller.firstOperand}" size="3"/>
        <h:outputText value="Second Operand:"/>
        <h:inputText id="second"
          value="#{controller.secondOperand}" size="3"/>
        <h:outputText value="Total:"/>
        <h:outputText id="sum" vaue="#{controller.total}"/>
        <h:commandButton
          actionListener="#{controller.calculateTotal}"
          value="Calculate Sum">
          <f:ajax execute="first second" render="sum"/>
        </h:commandButton>
      </h:panelGrid>
    </h:form>

在部署我们的应用程序后,前面的标记渲染如图图 7.1所示。

图 7.1:Faces Ajax 功能演示

图 7.1 – Faces Ajax 功能演示

我们的示例说明了<f:ajax>标签的两个用途。在页面顶部,我们通过实现一个典型的 Ajax Echo 示例来使用这个标签,其中有一个<h:outputText>组件通过输入文本组件的值来更新自己。每当输入字段中输入任何字符时,<h:outputText>组件的值会自动更新。

为了实现上一段描述的功能,我们在一个<h:inputText>标签内放置了一个<f:ajax>标签。<f:ajax>标签的render属性值必须与 Ajax 请求完成后要更新的组件的 ID 相对应。在我们的特定示例中,我们希望更新 ID 为textVal<h:outputText>组件,因此这就是我们为<f:ajax>标签的render属性使用的值。

注意

在某些情况下,我们可能需要在 Ajax 事件完成后渲染多个 Faces 组件。为了适应这种情况,我们可以将多个 ID 作为render属性的值。我们只需用空格将它们分开即可。

在这个实例中,我们使用的另一个<f:ajax>属性是event属性。此属性指示触发 Ajax 事件的 JavaScript 事件。在这种情况下,我们需要在用户在输入字段中键入时释放任何键时触发事件。因此,适当的事件是keyup

以下表格列出了所有支持的 JavaScript 事件:

事件 描述
blur 组件失去焦点
change 组件失去焦点且其值已更改
click 组件被点击
dblclick 组件被双击
focus 组件获得焦点
keydown 当组件获得焦点时按下键
keypress 当组件获得焦点时按下或保持按键
keyup 当组件获得焦点时释放键
mousedown 当组件获得焦点时鼠标按钮被按下
mousemove 鼠标指针在组件上移动
mouseout 鼠标指针离开组件
mouseover 鼠标指针放置在组件上
mouseup 当组件获得焦点时释放鼠标按钮
select 选择组件的文本
valueChange 等同于更改,组件失去焦点且其值已被修改

表 7.1 – <f:ajax>标签 JavaScript 事件属性

我们再次在页面下方使用<f:ajax>来启用一个命令按钮组件的 Ajax 功能。在这种情况下,我们想要根据两个输入组件的值重新计算一个值。为了将服务器上的值更新为最新的用户输入,我们使用了<f:ajax>execute属性。此属性接受一个用空格分隔的组件 ID 列表,用作输入。然后,我们像之前一样使用render属性来指定在 Ajax 请求完成后需要重新渲染哪些组件。

注意我们正在使用<h:commandButton>actionListener属性。此属性通常在我们点击按钮后不需要导航到另一个页面时使用。此属性的值是我们在一个名为 bean 中编写的动作监听器方法。动作监听器方法必须返回 void,并接受一个jakarta.faces.event.ActionEvent实例作为其唯一参数。

我们的应用程序中名为 bean 看起来像这样:

package com.ensode.jakartaeebook.facesajax;
//imports ommitted for brevity
@Named
@ViewScoped
public class Controller implements Serializable {
  private String text;
  private int firstOperand;
  private int secondOperand;
  private int total;
  @Inject
  private FacesContext facesContext;
  public void calculateTotal(ActionEvent actionEvent) {
    total = firstOperand + secondOperand;
  }
  //getters and setters omitted for brevity
}

我们commandButton上的actionListener属性的值是一个 Jakarta 表达式语言方法表达式,解析到我们的 CDI 名为 bean 的calculateTotal()方法。因此,当用户点击标有firstOperandsecondOperand变量的按钮时,此方法会自动调用;这些变量绑定到这些字段的value属性。因此,这些变量被页面上的用户输入值填充;我们的方法只是将这些值相加并将它们分配给total变量。然后,页面上的outputText组件自动更新,该组件绑定到这个变量。

注意,我们不需要在我们的名为 bean 中做任何特殊操作来在我们的应用程序中启用 Ajax。所有这些都由页面上的<f:ajax>标签控制。

如此例所示,启用了 Ajax 的 Faces 应用程序非常简单。我们只需使用一个标签就能在我们的页面上启用 Ajax,无需编写任何 JavaScript、JSON 或 XML 代码。

Jakarta Faces HTML5 支持

HTML5 是 HTML 规范的最新版本。它包括对之前 HTML 版本的多项改进。Jakarta Faces 包含了几个特性,使得 Faces 页面能够很好地与 HTML5 一起工作。Jakarta Faces 对 HTML5 的支持包括在 HTML5 中开发我们的 Jakarta Faces 页面而不使用特定于 Faces 的标签,以及将任意 HTML5 属性添加到我们的 Jakarta Faces 页面中。

HTML5 兼容的标记

通过使用透传元素,我们可以使用 HTML5 而不是使用特定于 Faces 的标签来开发我们的 Faces 页面。使用 HTML5 开发页面有一个优点,那就是我们可以在不将应用程序部署到 Jakarta EE 运行时的情况下预览页面在浏览器中的渲染效果。我们只需在网页浏览器中打开页面即可。

要实现这一点,我们需要使用jakarta.faces XML 命名空间指定至少一个元素属性。以下示例展示了这一方法在实际中的应用:

<!DOCTYPE html>
<html 
      >
  <head faces:id="head">
    <title>Jakarta Faces Page with HTML5 Markup</title>
    <link rel="stylesheet" type="text/css"
      href="resources/css/styles.css"/>
  </head>
  <body faces:id="body">
    <form faces:prependId="false">
      <div class="table">
        <div class="table-row">
          <div class="table-cell">
            <label faces:for="firstName">First Name</label>
          </div>
          <div class="table-cell">
            <input type="text" faces:id="firstName"
                   faces:value="#{customer.firstName}"/>
          </div>
        </div>
        <div class="table-row">
          <div class="table-cell">
            <label faces:for="lastName">Last Name</label>
          </div>
          <div class="table-cell">
            <input type="text" faces:id="lastName"
                   faces:value="#{customer.lastName}"/>
          </div>
        </div>
        <div class="table-row">
          <div class="table-cell">
            <label faces:for="email">Email Address</label></div>
          <div class="table-cell">
            <input type="email" faces:id="email"
                   faces:value="#{customer.email}"/>
          </div>
        </div>
        <div class="table-row">
          <div class="table-cell"></div>
          <div class="table-cell">
            <input type="submit" faces:action= "confirmation"
                   value="Submit"/>
          </div>
        </div>
      </div>
    </form>
  </body>
</html>

我们首先应该注意到的关于前面示例的是页面顶部附近的带有faces前缀的 XML 命名空间。这个命名空间允许我们向 HTML5 页面添加 Jakarta Faces 特定的属性。当 Faces 运行时遇到页面上的任何标签中带有faces前缀的属性时,它会自动将 HTML5 标签转换为等效的 Faces 组件。特定于 Faces 的属性与常规 Faces 页面中的属性相同,只是它们以faces为前缀。因此,在这个阶段,它们应该是自解释的,不会进行详细讨论。

上述示例将渲染和表现与本章的第一个示例完全相同。

本节中描述的技术如果我们的团队中有经验丰富的 HTML 网页设计师,他们更喜欢对页面外观有完全控制权时非常有用。页面使用标准的 HTML5 和特定于 Faces 的属性开发,以便 Faces 运行时可以管理用户输入。

如果我们的团队主要由对 CSS/HTML 知识有限的 Java 开发者组成,那么使用 Faces 组件开发我们的 Web 应用程序的网页是更可取的。

HTML 是一个不断发展的标准;偶尔,属性会被添加到 HTML 标签中。为了确保 Faces 组件的未来兼容性,Jakarta Faces 支持透传属性,可以使用它向 Jakarta Faces 组件添加任意属性。这项技术将在下一节中讨论。

透传属性

Jakarta Faces 允许定义任何任意属性(不被 Faces 引擎处理)。这些属性在浏览器上显示的生成的 HTML 中简单地按原样渲染。以下示例是本章早期示例的新版本,经过修改以利用 HTML5 透传属性:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html 

      >
    <!-- additional markup omitted for brevity -->
    <h:form id="customerForm">
      <h:messages/>
      <h:panelGrid columns="2"
                   columnClasses="rightAlign,leftAlign">
        <h:outputLabel for="firstName" value="First Name:">
        </h:outputLabel>
        <h:inputText id="firstName"
          label="First Name" value="#{customer.firstName}"
          required="true" pt:placeholder="First Name">
          <f:validateLength minimum="2" maximum="30"/>
        </h:inputText>
        <h:outputLabel for="lastName" value="Last Name:"/>
        <h:inputText id="lastName"
          label="Last Name" value="#{customer.lastName}"
          required="true" pt:placeholder="Last Name">
          <f:validateLength minimum="2" maximum="30"/>
        </h:inputText>
        <h:outputLabel for="email" value="Email:"/>
        <h:inputText id="email"
          label="Email" value="#{customer.email}"
          pt:placeholder="Email Address">
          <f:validateLength minimum="3" maximum="30"/>
        </h:inputText>
        <h:panelGroup/>
        <h:commandButton action="confirmation"
          value="Save"/>
      </h:panelGrid>
    </h:form>
    <!-- additional markup omitted -->
</html>

我们首先应该注意到的这个示例是命名空间的增加。这个命名空间允许我们向我们的 Faces 组件添加任何任意属性。

在我们的例子中,我们在页面上所有输入文本字段上添加了 HTML5 的placeholder属性。正如我们所看到的,透传属性需要以应用程序顶部定义的命名空间的前缀(在我们的情况下是pt)为前缀。placeholder HTML 属性简单地向输入字段添加一些占位文本,一旦用户开始在输入字段中键入,这些文本就会自动删除(在placeholder属性添加到 HTML5 之前,这种技术通常使用 JavaScript 实现)。

以下截图显示了我们的更新页面在实际操作中的效果:

图 7.2:面部透传属性

图 7.2 – 面部透传属性

如果我们检查生成的 HTML(通过在网页浏览器上右键单击并选择查看源代码或类似操作),我们可以看到 HTML 占位符属性被添加到了其中。例如,姓氏输入字段的生成标记如下:

<input id="customerForm:firstName" type="text"
 name="customerForm:firstName" value=""
 placeholder="First Name" />

占位符属性是由于我们的透传属性而放置在那里的。

我们接下来要讨论的主题是面部流程,它提供了一个比请求作用域长但比会话作用域短的定制 Jakarta Faces 作用域。

面部流程

面部流程定义了一个可以跨越多个页面的作用域。当用户进入流程(一组网页)时创建流程作用域的 bean,当用户离开流程时销毁。

面部流程采用了 Jakarta Faces 的约定优于配置原则。在开发使用面部流程的应用程序时,通常使用以下约定:

  • 流程中的所有页面都必须放置在一个以定义流程名称的目录中。

  • 必须在包含流程页面的目录内存在一个以目录名称命名并附加-flow后缀的 XML 配置文件(该文件可能为空,但必须存在)

  • 流程中的第一个页面必须以包含流程的目录名称命名

  • 流程中的最后一页不得位于包含流程的目录内,并且必须以目录名称命名并附加-return后缀

图 7.3 说明了这些约定:

图 7.3:面部流程约定

图 7.3 – 面部流程约定

在我们的例子中,我们定义了一个名为"customerinfo"的流程。按照惯例,这些文件位于名为"customerinfo"的目录中,流程的第一个页面命名为customerinfo.xhtml(流程中其他页面的名称没有限制)。当我们退出流程时,我们将导航到一个名为"flowname-return.xml"的页面 – 在我们的情况下,由于我们的流程名为"customerinfo",相关页面的名称是customerinfo-return.xhtml,它遵循命名约定并使我们退出流程。

页面的标记没有展示我们之前没有见过的内容,因此我们在这里不会对其进行检查 – 如果您想自己查看,所有示例代码都作为本书代码下载包的一部分提供。

所有的前几页都将数据存储在名为Customer的命名 bean 中,该 bean 具有流程作用域:

@Named
@FlowScoped("customerinfo")
public class Customer implements Serializable {
  //class body omitted
}

@FlowScoped注解有一个值属性,它必须与 bean 打算与之一起工作的流程的名称匹配(在这个例子中是"customerinfo")。

此示例创建了一个向导风格的页面集,其中用户数据在流程中的多个页面上输入。

在第一页,我们输入客户的姓名信息:

图 7.4:我们 Faces Flow 示例的第一页

图 7.4 – 我们 Faces Flow 示例的第一页

在第二页,我们输入客户的地址信息:

图 7.5:我们 Faces Flow 示例的第二页

图 7.5 – 我们 Faces Flow 示例的第二页

在下一页,我们输入客户的电话联系信息:

图 7.6:我们 Faces Flow 示例的第三页

图 7.6 – 我们 Faces Flow 示例的第三页

最后,我们显示一个确认页面:

图 7.7:我们 Faces Flow 示例的最后一页

图 7.7 – 我们 Faces Flow 示例的最后一页

如果用户验证信息正确,我们将导航到customerinfo-return.xhtml以跳出流程;否则,我们将返回流程中的第一页,以便用户进行任何必要的更正。

Faces WebSocket 支持

在典型的 Web 应用程序中,服务器总是响应来自浏览器的请求。服务器没有在不响应请求的情况下向客户端浏览器发送数据的方法。WebSocket技术提供了浏览器和服务器之间的全双工通信,允许服务器在不响应请求的情况下独立地向客户端发送数据。WebSocket 允许为 Web 开发出无数新的应用程序,包括更新股票行情、多人在线游戏和聊天应用程序。

注意

虽然一些这类 Web 应用程序是在 WebSocket 出现之前开发的,但它们依赖于黑客手段来绕过 HTTP 的限制。有了 WebSocket,这些黑客手段就不再必要了。

传统上,在编写应用程序时利用 WebSocket 协议需要大量的 JavaScript 代码。Faces 的 WebSocket 支持抽象出了大部分 JavaScript 基础设施,使我们能够专注于开发我们应用程序的业务逻辑。

以下示例展示了使用 Faces WebSocket 支持开发的一个简单的聊天应用程序。

注意,Faces WebSocket 支持需要显式启用。这可以通过向我们的web.xml配置文件添加一个上下文参数来完成:

<?xml version="1.0" encoding="UTF-8"?>
<web-app 

         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                  https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">
  <servlet>
    <servlet-name>Faces Servlet</servlet-name>
    <servlet-class>
     jakarta.faces.webapp.FacesServlet
    </servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>Faces Servlet</servlet-name>
    <url-pattern>/faces/*</url-pattern>
  </servlet-mapping>
  <context-param>
    <param-name>
     jakarta.faces.ENABLE_WEBSOCKET_ENDPOINT
    </param-name>
    <param-value>true</param-value>
  </context-param>
</web-app>

如我们的示例web.xml配置文件所示,为了在我们的 Faces 应用中启用 WebSocket 支持,我们需要设置一个名为jakarta.faces.ENABLE_WEBSOCKET_ENDPOINT的上下文参数,并将其值设置为true

让我们现在看看我们如何开发一个负责向所有浏览器客户端发送消息的应用作用域 CDI 名为 bean:

package com.ensode.jakartaeebook.faceswebsocket;
//imports omitted for brevity
@Named
@ApplicationScoped
public class FacesWebSocketMessageSender implements Serializable {
  private static final Logger LOG = Logger.getLogger(
          FacesWebSocketMessageSender.class.getName());
  @Inject
  @Push(channel = "websocketdemo")
  private PushContext pushContext;
  public void send(String message) {
    LOG.log(Level.INFO, String.format(""
            + "Sending message: %s", message));
    pushContext.send(message);
  }
}

如前例所示,为了通过 WebSockets 向客户端发送数据,我们需要注入一个实现jakarta.faces.push.PushContext接口的实例,并用@Push注解标注它。我们可以通过注解的channel属性指定一个通道。如果我们没有指定通道名称,则默认使用标注了@Push的变量的名称作为通道名称。

为了将消息发送到 WebSocket 客户端,我们需要调用注入的PushContext实现中的send()方法。在我们的例子中,这是在 CDI 名为 bean 的send()方法中完成的。

此外,在我们的例子中,有一个会话作用域的 CDI 名为 bean,它从用户那里获取输入并将其传递给前面应用作用域的 CDI 名为 bean 的send()方法。我们的会话作用域 CDI bean 看起来如下所示:

package com.ensode.jakartaeebook.faceswebsocket;
//imports omitted for brevity
@Named
@SessionScoped
public class FacesWebSocketController implements Serializable {
  @Inject
private FacesWebSocketMessageSender facesWebSocketMessageSender;
  private String userName;
  private String message;
  public void sendMessage() {
    facesWebSocketMessageSender.send(
            String.format("%s: %s", userName, message));
  }
  public String navigateToChatPage() {
    return "chat";
  }
  //setters and getters omitted for brevity
}

前面的类中的sendMessage()方法调用我们之前讨论的应用作用域 CDI bean 的send()方法,传递用户的名称和要广播给所有浏览器的消息。上述sendMessage()方法通过 Ajax 在用户点击相应页面上的按钮时被调用,如下所示:

  <h:body>
    <h:form prependId="false">
      <h:panelGrid columns="2">
        <h:outputLabel for="chatWindow"
          value="Chat Window:"/>
        <textarea id="chatWindow" rows="10"/>
        <h:outputLabel for="chatInput"
          value="Type something here:"/>
        <h:inputText id="chatInput"
          value="#{facesWebSocketController.message}"/>
        <h:panelGroup/>
        <h:commandButton actionListener=
          "#{facesWebSocketController.sendMessage()}"
          value="Send message">
          <f:ajax execute="chatInput" render="chatWindow"/>
        </h:commandButton>
      </h:panelGrid>
    </h:form>
    <script type="text/javascript">
        function socketListener(message, channel, event) {
          var textArea =
            document.getElementById('chatWindow');
          var textAreaValue = textArea.value;
          if (textAreaValue.trim() !== '') {
            textAreaValue += "\n";
          }
          textAreaValue += message;
          textArea.value = textAreaValue;
          textArea.scrollTop = textArea.scrollHeight;
        }
    </script>
    <f:websocket id="webSocketTag" channel="websocketdemo"
                 onmessage="socketListener" />
  </h:body>

前述标记中的<f:websocket>标签是启用我们页面 WebSocket 支持所必需的。其channel属性的值将页面链接到服务器上相应的PushContext实例(在我们的例子中,它定义在应用作用域的FacesWebSocketMessageSender CDI 名为 bean 中)。此属性的值必须与 CDI bean 上@Push注解中相应的属性值匹配(在我们的例子中是"websocketdemo")。

在构建和部署我们的应用程序后,我们可以看到它的实际运行情况。

图 7.8: Jakarta Faces WebSocket 支持

图 7.8 – Jakarta Faces WebSocket 支持

WebSocket 技术使我们能够在 Web 客户端和服务器之间开发双向通信。Jakarta Faces 对 WebSocket 的支持使得在我们的 Jakarta EE 应用中实现 WebSocket 技术变得容易。

其他 Faces 组件库

除了标准的 Jakarta Faces 组件库之外,还有许多第三方库可供选择。以下表格列出了其中两个最受欢迎的。

标签库 分发商 许可 URL
ICEfaces ICEsoft MPL 1.1 www.icefaces.org
Primefaces Prime Technology Apache 2.0 www.primefaces.org

表 7.2 – Jakarta Faces 组件库

使用第三方 Jakarta Faces 库,我们可以开发出外观优雅的应用程序,而无需使用很多(如果有的话)CSS。大多数第三方 Jakarta Faces 库都包含标准 Jakarta Faces 组件的即插即用替代品,例如<h:inputText><h:commandButton>,并且还提供了额外的组件,使我们能够以最小的努力实现复杂的功能。例如,大多数第三方库都包含一个具有内置分页和排序功能的表格组件,从而让我们免于自己开发该功能。大多数现实生活中的 Jakarta Faces 项目都利用了第三方组件库,其中 PrimeFaces 是最受欢迎的一个。

摘要

在本章中,我们介绍了如何使用 Jakarta Faces 开发基于 Web 的应用程序,Jakarta EE 的标准组件框架:

  • 我们介绍了如何开发启用 Ajax 的 Faces 页面

  • 我们解释了如何集成 Faces 和 HTML5

  • 我们介绍了如何使用 Faces Flows 开发类似向导的界面

  • 我们展示了如何将 WebSocket 技术集成到我们的 Jakarta Faces 应用程序中

  • 我们讨论了第三方 Jakarta Faces 组件库,我们可以利用这些库使我们的工作更轻松

在本章中,我们超越了基本的 Jakarta Faces 功能,涵盖了高级 Jakarta Faces 特性,如 Ajax 和 WebSocket 支持。

第八章:使用 Jakarta Persistence 进行对象关系映射

Jakarta EE 应用程序通常需要在关系型数据库中持久化数据。在本章中,我们将介绍如何通过Jakarta Persistence连接到数据库并执行创建、读取、更新和删除CRUD操作

Jakarta Persistence 是 Jakarta EE 标准的对象关系映射ORM)工具。我们将在本章中详细讨论此 API。

本章涵盖了以下主题:

  • CUSTOMERDB 数据库

  • 配置 Jakarta Persistence

  • 使用 Jakarta Persistence 持久化数据

  • 实体关系

  • 复合主键

  • Jakarta Persistence 查询语言

  • 条件 API

  • Bean 验证支持

注意

本章中使用的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch08_src

CUSTOMERDB 数据库

本章中的示例使用一个名为CUSTOMERDB的数据库。这个数据库包含跟踪虚构商店客户和订单信息的表。为了简化,数据库使用内存中的 H2 数据库。

本书示例代码中包含一个简单的实用程序,该实用程序可以自动启动数据库并填充所有参考表。该实用程序位于ch08_src/customerdb下。它是一个 Maven 应用程序。因此,可以通过命令行通过mvn install构建它。它创建了一个包含所有依赖项的可执行 JAR 文件。创建的 JAR 文件位于target目录下,可以通过以下命令在命令行中运行:

java -jar customerdb-jar-with-dependencies.jar

CUSTOMERDB数据库的模式如图8**.1所示。

图 8.1 – CUSTOMERDB 数据库模式(此模式的目的是展示布局;标题下方框中文字的可读性不是必需的。)

图 8.1 – CUSTOMERDB 数据库模式(此模式的目的是展示布局;标题下方框中文字的可读性不是必需的。)

如图中所示,数据库包含存储客户信息的表,包括他们的姓名、地址和电子邮件地址。它还包含存储订单和项目信息的表。

ADDRESS_TYPES表存储诸如“家庭”、“邮寄”和“运输”等值,以区分ADDRESSES表中的地址类型;同样,TELEPHONE_TYPES表存储“手机”、“家庭”和“工作”等值。这两个表在创建数据库时预先填充,以及US_STATES表。

注意

为了简化,我们的数据库仅处理美国地址。

配置 Jakarta Persistence

在我们的代码能够正常工作之前,Jakarta Persistence 需要一些配置。需要定义一个数据源。数据源指定了我们连接到的关系数据库管理系统(RDBMS)系统的信息(服务器、端口、数据库用户凭据等)。有两种设置方式。可以通过 Jakarta EE 实现配置来完成,但具体如何操作取决于特定的实现。

还可以通过使用@DataSourceDefinition注解来注解一个应用范围的 CDI bean 来完成。

每种方法都有其优缺点。将数据源定义为 Jakarta EE 运行时配置的一部分,允许我们将代码部署到不同的环境(开发、测试、生产)而无需对代码进行任何修改。它还可以防止将任何用户凭据添加到我们的源中。使用@DataSourceDefinition可以在 Jakarta EE 实现之间工作,并允许我们在不配置 Jakarta EE 运行时的情况下测试和部署我们的代码。

为了简单起见,我们的示例使用@DataSourceDefinition,但对于生产代码来说,配置 Jakarta EE 实现可能是一个更好的选择。

通常,我们在应用范围的 CDI bean 中使用@DataSourceDefinition,如下例所示:

package com.ensode.jakartaeebook.beanvalidation.init;
//imports omitted for brevity
@ApplicationScoped
@DataSourceDefinition(name =
  "java:app/jdbc/customerdbDatasource",
  className = "org.h2.jdbcx.JdbcDataSource",
  url = "jdbc:h2:tcp://127.0.1.1:9092/mem:customerdb",
  user = "sa",
  password = "")
public class DbInitializer {
  private void init(@Observes @Initialized(ApplicationScoped.class) Object object) {
    //This method will be invoked when the CDI application scope is initialized, during deployment
    //No logic necessary, class level @DataSourceDefinition will create a data source to be used by the application.
  }
}

@DataSourceDefinitionname属性值定义了 JNDI 名称或我们的数据源。@DataSourceDefinitionurl属性值定义了userpassword属性,这些属性定义了登录到我们的数据库所需的用户凭据。

定义数据源后,必须在包含上述 bean 的 WAR 文件中部署一个名为persistence.xml的 XML 配置文件。此文件必须放置在 WAR 文件内的WEB-INF/classes/META-INF/目录中。下面是一个示例persistence.xml配置文件:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="3.0" 

         xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
           https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd">
  <persistence-unit name="customerPersistenceUnit">
    <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
    <jta-data-source>java:app/jdbc/customerdbDatasource</jta-data-source>
    <exclude-unlisted-classes>false</exclude-unlisted-classes>
  </persistence-unit>
</persistence>

persistence.xml必须至少包含一个<persistence-unit>元素。每个<persistence-unit>元素必须为其name属性提供一个值,并且必须包含一个<jta-data-source>子元素,其值是要用于持久化单元的数据源的 JNDI 名称。

<jta-data-source>标签的值必须来自我们在 Jakarta EE 实现中配置的数据源。注意,在我们的示例中,<jta-data-source>标签的值与使用@DataSourceDefinition定义的数据源中name属性值相匹配。

<provider>标签的值必须是jakarta.persistence.spi.PersistenceProvider接口的实现。确切的值取决于所使用的 Jakarta Persistence 实现。在我们的示例中,我们使用 GlassFish 作为我们的 Jakarta EE 实现,它包括 EclipseLink 作为其 Jakarta Persistence 实现。因此,我们使用 EclipseLink 提供的 PersistenceProvider 实现。

允许多个 <persistence-unit> 元素的原因是,一个应用程序可能需要访问多个数据库。对于应用程序将要访问的每个数据库,都需要一个 <persistence-unit> 元素。如果应用程序定义了多个 <persistence-unit> 元素,那么用于注入 EntityManager@PersistenceContext 注解必须为其 unitName 元素提供一个值。此元素的值必须与 persistence.xml 中相应 <persistence-unit> 元素的 name 属性匹配。

如果我们的 persistence.xml 配置类定义了多个持久化单元,我们需要通过在每个 <persistence-unit> 标签内使用 <class> 标签列出该持久化单元管理的 Jakarta Persistence 实体。在 <persistence-unit> 内列出每个 Jakarta EE 实体是一个繁琐的任务,但幸运的是,大多数项目只定义了一个持久化单元。我们可以通过使用 <exclude-unlisted-classes> 标签并设置值为 false 来避免列出每个 Jakarta Persistence 实体,如我们的示例所示。

使用 Jakarta Persistence 持久化数据

Jakarta Persistence 用于将数据持久化到 RDBMS。Jakarta Persistence 实体是常规的 Java 类;Jakarta EE 运行时知道这些类是实体,因为它们被 @Entity 注解装饰。让我们看看一个映射到 CUSTOMERDB 数据库中 CUSTOMER 表的 Jakarta Persistence 实体映射示例:

package com.ensode.jakartaeebook.persistenceintro.entity
//imports omitted for brevity
@Entity
@Table(name = "CUSTOMERS")
public class Customer implements Serializable {
  @Id
  @Column(name = "CUSTOMER_ID")
  private Long customerId;
  @Column(name = "FIRST_NAME")
  private String firstName;
  @Column(name = "LAST_NAME")
  private String lastName;
  private String email;
  //getters and setters omitted for brevity
}

在我们的示例代码中,@Entity 注解让任何其他 Jakarta EE 兼容的运行时知道这个类是一个 Jakarta Persistence 实体。

@Table(name = "CUSTOMERS") 注解让应用程序服务器知道将实体映射到哪个表。名称元素的值包含实体映射到的数据库表名称。此注解是可选的。如果类的名称映射到数据库表的名称(不区分大小写),则不需要指定实体映射到的表。

@Id 注解表示 customerId 字段映射到主键。

@Column 注解将每个字段映射到表中的列。如果字段的名称与数据库列的名称匹配,则不需要此注解。这就是为什么 email 字段没有被注解的原因。

EntityManager 接口用于将实体持久化到数据库。以下示例说明了其用法:

package com.ensode.jakartaeebook.persistenceintro.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class JakartaPersistenceDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction;
  public String updateDatabase() {
    String retVal = "confirmation";
    Customer customer = new Customer();
    Customer customer2 = new Customer();
    Customer customer3;
    customer.setCustomerId(3L);
    customer.setFirstName("James");
    customer.setLastName("McKenzie");
    customer.setEmail("jamesm@example.com");
    customer2.setCustomerId(4L);
    customer2.setFirstName("Charles");
    customer2.setLastName("Jonson");
    customer2.setEmail("cjohnson@example.org");
    try {
      userTransaction.begin();
      entityManager.persist(customer);
      entityManager.persist(customer2);
      customer3 = entityManager.find(Customer.class, 4L);
      customer3.setLastName("Johnson");
      entityManager.persist(customer3);
      entityManager.remove(customer);
      userTransaction.commit();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
}

我们的示例 CDI 命名豆通过依赖注入获取实现 jakarta.persistence.EntityManager 接口类的实例。这是通过使用 @PersistenceContext 注解装饰 EntityManager 变量来完成的。

然后,通过 @Resource 注解注入实现 jakarta.transaction.UserTransaction 接口的一个实例。这个对象是必要的,因为没有它,代码在调用持久化实体到数据库时将抛出 jakarta.persistence.TransactionRequiredException

EntityManager实例执行许多数据库相关任务,包括在数据库中查找实体、更新它们和删除它们。

由于 Jakarta Persistence 实体是普通的 Java 对象POJOs),它们可以通过 new 运算符进行实例化。

EntityManager上调用persist()方法必须在事务中进行,因此需要通过调用UserTransaction上的begin()方法来启动一个事务。

然后,我们通过在EntityManager上调用persist()方法,为之前在代码中填充的两个Customer类实例插入CUSTOMERS表中的两个新行。

在将customercustomer2对象中的数据持久化后,我们在CUSTOMERS表中搜索具有键值 4 的行。我们通过在EntityManager上调用find()方法来完成此操作。此方法将我们要搜索的Entity类的类作为其第一个参数,以及我们想要获取的对象对应的行的键值。

我们为customer2对象设置的键是 4,因此我们现在有一个该对象的副本。当我们将这位客户的数据最初插入数据库时,他的姓氏拼写错误,所以我们现在通过在customer3上调用setLastName()方法来纠正约翰逊先生的姓氏,然后通过调用entityManager.persist()来更新数据库中的信息。

然后,通过调用entityManager.remove()并传递customer对象作为参数,我们删除客户对象的信息。

最后,通过在userTransaction上调用commit()方法将更改提交到数据库。

无法持久化分离对象异常

通常,应用程序会通过EntityManager.find()方法检索 Jakarta Persistence 实体,然后将该实体传递到业务或用户界面层,在那里它可能会被修改,之后将更新与实体对应的数据库数据。在这种情况下,调用EntityManager.persist()将导致异常。为了以这种方式更新 Jakarta Persistence 实体,我们需要调用EntityManager.merge()。此方法接受一个 Jakarta Persistence 实体实例作为其唯一参数,并使用其中存储的数据更新数据库中的对应行。

现在我们已经看到了如何处理单个 Jakarta Persistence 实体,我们将关注如何定义实体关系。

实体关系

在上一节中,我们看到了如何从数据库中检索、插入、更新和删除单个实体。实体很少是孤立的 - 在绝大多数情况下,它们与其他实体相关联。

实体可以有一对一、一对多、多对一和多对多关系。

例如,在 CUSTOMERDB 数据库中,有一个LOGIN_INFO表和CUSTOMERS表。这意味着每个客户在登录信息表中恰好有一行对应。还有一个CUSTOMERS表和ORDERS表。这是因为一个客户可以下很多订单,但每个订单只属于一个客户。还有一个ORDERS表和ITEMS表。这是因为一个订单可以包含多个项目,而一个项目可以出现在多个订单中。

在接下来的几节中,我们将讨论如何建立 Jakarta Persistence 实体之间的关系。

一对一关系

当一个实体的实例可以对应零个或一个另一个实体的实例时,就会发生一对一关系。

一对一实体关系可以是双向的(每个实体都了解这种关系)或单向的(只有其中一个实体了解这种关系)。在 CUSTOMERDB 数据库中,LOGIN_INFOCUSTOMERS表之间的一对一映射是单向的,因为LOGIN_INFO表有一个指向CUSTOMERS表的外键,但反过来没有。我们将很快看到,这个事实并不会阻止我们在这两个实体之间创建一个双向的一对一关系。

映射到LOGIN_INFO表的LoginInfo实体的源代码如下:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "LOGIN_INFO")
public class LoginInfo {
  @Id
  @Column(name = "LOGIN_INFO_ID")
  private Long loginInfoId;
  @Column(name = "LOGIN_NAME")
  private String loginName;
  private String password;
  @OneToOne
  @JoinColumn(name = "CUSTOMER_ID")
  private Customer customer;
  //getters and setters omitted for brevity
}

这个实体的代码与Customer实体的代码非常相似;它定义了映射到数据库列的字段。每个名称与数据库列名称不匹配的字段都带有@Column注解,除此之外,主键还带有@Id注解。

前面的代码变得有趣的地方在于customer字段的声明。如代码所示,customer字段带有@OneToOne注解,这使 Jakarta EE 运行时知道这个实体与Customer实体之间存在一对一的关系。customer字段还带有@JoinColumn注解。这个注解让容器知道LOGIN_INFO表中的哪一列是CUSTOMER表上主键的外键。由于LOGIN_INFO表(LoginInfo实体映射到的表)有一个指向CUSTOMER表的外键,因此LoginInfo实体拥有这个关系。如果这个关系是单向的,我们就不必对Customer实体做任何修改。然而,由于我们希望这两个实体之间有一个双向关系,我们需要在Customer实体中添加一个LoginInfo字段,以及相应的 getter 和 setter 方法。

正如我们之前提到的,为了使CustomerLoginInfo实体之间的一对一关系双向化,我们需要对Customer实体做一些简单的修改:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "CUSTOMERS")
public class Customer implements Serializable {
  @Id
  @Column(name = "CUSTOMER_ID")
  private Long customerId;
  @Column(name = "FIRST_NAME")
  private String firstName;
  @Column(name = "LAST_NAME")
  private String lastName;
  private String email;
  @OneToOne(mappedBy = "customer")
  private LoginInfo loginInfo;
  public LoginInfo getLoginInfo() {
    return loginInfo;
  }
  public void setLoginInfo(LoginInfo loginInfo) {
    this.loginInfo = loginInfo;
  }
  //additional setters and getters omitted for brevity
}

我们需要做的唯一更改是将LoginInfo字段添加到Customer实体中,以及相应的 setter 和 getter 方法。loginInfo字段被注解为@OneToOne。由于Customer实体不拥有这个关系(它映射的表没有对应表的外键),因此需要添加@OneToOne注解的mappedBy元素。此元素指定对应实体中具有关系另一端字段是什么。在这种情况下,LoginInfo实体中的customer字段对应于这个一对一关系的另一端。

以下 Java 类说明了前面实体的使用:

package com.ensode.jakartaeebook.entityrelationship.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class OneToOneRelationshipDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction;
  public String updateDatabase() {
    String retVal = "confirmation";
    Customer customer;
    LoginInfo loginInfo = new LoginInfo();
    loginInfo.setLoginInfoId(1L);
    loginInfo.setLoginName("charlesj");
    loginInfo.setPassword("iwonttellyou");
    try {
      userTransaction.begin();
      customer = entityManager.find(Customer.class, 4L);
      loginInfo.setCustomer(customer);
      entityManager.persist(loginInfo);
      userTransaction.commit();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
}

在这个例子中,我们首先创建一个LoginInfo实体的实例,并用一些数据填充它。然后,我们通过调用EntityManagerfind()方法从数据库中获取一个Customer实体的实例(此实体的数据在之前的某个例子中已插入到CUSTOMERS表中)。然后,我们在LoginInfo实体上调用setCustomer()方法,将客户对象作为参数传递。最后,我们调用EntityManager.persist()方法将数据保存到数据库中。

在幕后发生的事情是,LOGIN_INFO表的CUSTOMER_ID列被填充了CUSTOMERS表中相应行的主键。这可以通过查询 CUSTOMERDB 数据库来轻松验证。

注意

注意到调用EntityManager.find()以获取客户实体是在调用EntityManager.persist()的同一事务中进行的。如果不是这样,数据库将无法成功更新。

一对多关系

Jakarta Persistence 的一对多实体关系可以是双向的(即,一个实体包含一个多对一关系,相应的实体包含一个反向的一对多关系)或单向的(一个实体包含对另一个实体的多对一关系,而另一个实体没有定义相应的一对多关系)。

使用 SQL,一对一关系通过一个表中的外键来定义。关系的“多”部分是包含对关系的“一”部分的外键的部分。在 RDBMS 中定义的一对多关系通常是单向的,因为使它们双向通常会导致数据非规范化。

就像在关系型数据库管理系统(RDBMS)中定义单向一对一关系一样,在 Jakarta Persistence 中,关系的“多”部分是指具有对关系的“一”部分的引用的部分,因此用于定义关系的注解是@ManyToOne

在 CUSTOMERDB 数据库中,客户和订单之间存在单向一对一关系。我们在Order实体中定义此关系:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "ORDERS")
public class Order {
  @Id
  @Column(name = "ORDER_ID")
  private Long orderId;
  @Column(name = "ORDER_NUMBER")
  private String orderNumber;
  @Column(name = "ORDER_DESCRIPTION")
  private String orderDescription;
  @ManyToOne
  @JoinColumn(name = "CUSTOMER_ID")
  private Customer customer;
  //setters and getters omitted for brevity
}

如果我们要在Orders实体和Customer实体之间定义一个单向多对一关系,我们就不需要对Customer实体进行任何更改。为了在两个实体之间定义双向一对一关系,需要在Customer实体中添加一个用@OneToMany注解装饰的新字段,如下一个示例所示:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "CUSTOMERS")
public class Customer implements Serializable {
  @Id
  @Column(name = "CUSTOMER_ID")
  private Long customerId;
  @Column(name = "FIRST_NAME")
  private String firstName;
  @Column(name = "LAST_NAME")
  private String lastName;
  private String email;
  @OneToOne(mappedBy = "customer")
  private LoginInfo loginInfo;
  @OneToMany(mappedBy = "customer")
  private Set<Order> orders;
  public Set<Order> getOrders() {
    return orders;
  }
  public void setOrders(Set<Order> orders) {
    this.orders = orders;
  }
  //additional getters and setters omitted for brevity
}

与之前的Customer实体版本相比,唯一的区别是添加了订单字段和相关 getter 和 setter 方法。特别值得注意的是装饰此字段的@OneToMany注解。mappedBy属性必须与对应实体中关系“多”部分的相应字段名称匹配。简单来说,mappedBy属性值必须与关系另一侧的 bean 中用@ManyToOne注解装饰的字段名称匹配。

以下示例代码说明了如何将一对一关系持久化到数据库中:

package com.ensode.jakartaeebook.entityrelationship.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class OneToManyRelationshipDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction;
  public String updateDatabase() {
    String retVal = "confirmation";
    Customer customer;
    Order order1;
    Order order2;
    order1 = new Order();
    order1.setOrderId(1L);
    order1.setOrderNumber("SFX12345");
    order1.setOrderDescription("Dummy order.");
    order2 = new Order();
    order2.setOrderId(2L);
    order2.setOrderNumber("SFX23456");
    order2.setOrderDescription("Another dummy order.");
    try {
      userTransaction.begin();
      customer = entityManager.find(Customer.class, 4L);
      order1.setCustomer(customer);
      order2.setCustomer(customer);
      entityManager.persist(order1);
      entityManager.persist(order2);
      userTransaction.commit();
    }catch (NotSupportedException |
      SystemException |
      SecurityException |
      IllegalStateException |
      RollbackException |
      HeuristicMixedException |
      HeuristicRollbackException e) {
      retVal = "error";
      e.printStackTrace();
     }
      return retVal;
  }
}

我们的示例创建了两个Order实体的实例,用一些数据填充它们,然后在事务中找到Customer实体的一个实例,并将其用作两个Order实体实例的setCustomer()方法的参数。然后我们通过为每个Order实体调用EntityManager.persist()来持久化这两个Order实体。

就像处理一对一关系时一样,幕后发生的事情是,CUSTOMERDB 数据库中ORDERS表的CUSTOMER_ID列被填充了对应于CUSTOMERS表中相关行的主键。

由于关系是双向的,我们可以通过在Customer实体上调用getOrders()方法来获取与客户相关的所有订单。

多对多关系

在 CUSTOMERDB 数据库中,ORDERS表和ITEMS表之间存在多对多关系。我们可以通过向Order实体添加一个新的Collection<Item>字段并使用@ManyToMany注解来映射这种关系:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "ORDERS")
public class Order {
  @Id
  @Column(name = "ORDER_ID")
  private Long orderId;
  @Column(name = "ORDER_NUMBER")
  private String orderNumber;
  @Column(name = "ORDER_DESCRIPTION")
  private String orderDescription;
  @ManyToOne
  @JoinColumn(name = "CUSTOMER_ID")
  private Customer customer;
  @ManyToMany
  @JoinTable(name = "ORDER_ITEMS",
    joinColumns = @JoinColumn(name = "ORDER_ID",
      referencedColumnName = "ORDER_ID"),
    inverseJoinColumns = @JoinColumn(name = "ITEM_ID",
      referencedColumnName = "ITEM_ID"))
  private Collection<Item> items;
  public Collection<Item> getItems() {
    return items;
  }
  public void setItems(Collection<Item> items) {
    this.items = items;
  }
  //additional getters and setters omitted
}

如前述代码所示,除了被注解为@ManyToMany外,items字段还被注解为@JoinTable。正如其名称所暗示的,这个注解让应用服务器知道哪个表被用作连接表来在两个实体之间创建多对多关系。

@JoinTable有三个相关元素:name元素,它定义了连接表的名字;以及joinColumnsinverseJoinColumns元素,它们定义了在连接表中作为外键的列,这些列指向实体的主键。joinColumnsinverseJoinColumns元素的值是另一个注解,即@JoinColumn注解。这个注解有两个相关元素,name元素,它定义了连接表中的列名;以及referencedColumnName元素,它定义了实体表中的列名。

Item实体是一个简单的实体,映射到 CUSTOMERDB 数据库中的ITEMS表:

package com.ensode.jakartaeebook.entityrelationship.entity;
//imports omitted for brevity
@Entity
@Table(name = "ITEMS")
public class Item {
  @Id
  @Column(name = "ITEM_ID")
  private Long itemId;
  @Column(name = "ITEM_NUMBER")
  private String itemNumber;
  @Column(name = "ITEM_SHORT_DESC")
  private String itemShortDesc;
  @Column(name = "ITEM_LONG_DESC")
  private String itemLongDesc;
  @ManyToMany(mappedBy = "items")
  private Collection<Order> orders;
  public Collection<Order> getOrders() {
    return orders;
  }
  public void setOrders(Collection<Order> orders) {
    this.orders = orders;
  }
  //addtional getters and setters omitted for brevity
}

就像一对一和多对一关系一样,多对多关系可以是单向的或双向的。由于我们希望OrderItem实体之间的多对多关系是双向的,所以我们添加了一个Collection<Order>字段,并用@ManyToMany注解装饰它。由于Order实体中相应的字段已经定义了连接表,因此在这里不需要再次定义。包含@JoinTable注解的实体被称为拥有关系,在多对多关系中,任一实体都可以拥有这个关系。在我们的例子中,Order实体拥有这个关系,因为它的Collection<Item>字段被@JoinTable注解装饰。

就像一对一和多对一关系一样,双向多对多关系非拥有方上的@ManyToMany注解必须包含一个mappedBy元素,指明在拥有实体中定义关系的字段。

现在我们已经看到了在OrderItem实体之间建立双向多对多关系所需的更改,我们可以在下面的示例中看到这个关系在实际中的应用:

package com.ensode.jakartaeebook.entityrelationship.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class ManyToManyRelationshipDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction;
  public String updateDatabase() {
    String retVal = "confirmation";
    Order order;
    Collection<Item> items = new ArrayList<Item>();
    Item item1 = new Item();
    Item item2 = new Item();
    item1.setItemId(1L);
    item1.setItemNumber("BCD1234");
    item1.setItemShortDesc("Notebook Computer");
    item2.setItemId(2L);
    item2.setItemNumber("CDF2345");
    item2.setItemShortDesc("Cordless Mouse");
    items.add(item1);
    items.add(item2);
    try {
      userTransaction.begin();
      entityManager.persist(item1);
      entityManager.persist(item2);
      order = entityManager.find(Order.class, 1L);
      order.setItems(items);
      entityManager.persist(order);
      userTransaction.commit();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
}

上述代码创建了两个Item实体的实例,并填充了一些数据。然后,将这些实例添加到一个集合中。接着开始一个事务,将这两个Item实例持久化到数据库中。然后从数据库中检索Order实体的一个实例。随后调用Order实体实例的setItems()方法,并将包含两个Item实例的集合作为参数传递。然后,将Customer实例持久化到数据库中。此时,在ORDER_ITEMS表中创建了两个行,这是ORDERSITEMS表之间的连接表。

组合主键

在 CUSTOMERDB 数据库中的大多数表中,都有一个列的唯一目的是作为主键(这种类型的主键有时被称为代理主键或人工主键)。然而,一些数据库并不是这样设计的。相反,数据库中一个已知在行之间是唯一的列被用作主键。如果没有列的值在行之间不是保证唯一的,那么两个或更多列的组合被用作表的主键。可以通过使用主键类来将这种类型的主键映射到 Jakarta Persistence 实体。

在 CUSTOMERDB 数据库中有一个表没有代理主键,这个表是ORDER_ITEMS表。这个表作为ORDERSITEMS表之间的连接表。除了为这两个表有外键之外,这个表还有一个额外的列叫做ITEM_QTY,用于存储订单中每个项目的数量。由于这个表没有代理主键,映射到它的 Jakarta Persistence 实体必须有一个自定义主键类。在这个表中,ORDER_IDITEM_ID列的组合必须是唯一的。因此,这是一个复合主键的好组合,如下面的示例所示:

package com.ensode.jakartaeebook.compositeprimarykeys.entity;
import java.io.Serializable;
public class OrderItemPK implements Serializable {
  public Long orderId;
  public Long itemId;
  public OrderItemPK() {
  }
  public OrderItemPK(Long orderId, Long itemId) {
    this.orderId = orderId;
    this.itemId = itemId;
  }
  @Override
  public boolean equals(Object obj) {
    boolean returnVal = false;
    if (obj == null) {
      returnVal = false;
    } else if (!obj.getClass().equals(this.getClass())) {
      returnVal = false;
    } else {
      OrderItemPK other = (OrderItemPK) obj;
      if (this == other) {
        returnVal = true;
      } else if (orderId != null && other.orderId != null
          && this.orderId.equals(other.orderId)) {
        if (itemId != null && other.itemId != null
            && itemId.equals(other.itemId)) {
          returnVal = true;
        }
      } else {
        returnVal = false;
      }
    }
    return returnVal;
  }
  @Override
  public int hashCode() {
    if (orderId == null || itemId == null) {
      return 0;
    } else {
      return orderId.hashCode() ^ itemId.hashCode();
    }
  }
}

一个自定义主键类必须满足以下要求:

  • 该类必须是公开的

  • 它必须实现java.io.Serializable

  • 它必须有一个不接受任何参数的公开构造函数

  • 它的字段必须是publicprotected

  • 它的字段名称和类型必须与实体的匹配

  • 它必须重写java.lang.Object类中定义的默认hashCode()equals()方法

前面的OrderPK类满足所有这些要求。它还有一个方便的构造函数,接受两个Long对象,用于初始化其orderIditemId字段。这个构造函数是为了方便而添加的;这不是将类用作主键类的必要条件。

当一个实体使用自定义主键类时,它必须注解了@IdClass。由于OrderItem类使用OrderItemPK作为其自定义主键类,因此它被注解了:

package com.ensode.jakartaeebook.compositeprimarykeys.entity;
//imports omitted
@Entity
@Table(name = "ORDER_ITEMS")
@IdClass(value = OrderItemPK.class)
public class OrderItem {
  @Id
  @Column(name = "ORDER_ID")
  private Long orderId;
  @Id
  @Column(name = "ITEM_ID")
  private Long itemId;
  @Column(name = "ITEM_QTY")
  private Long itemQty;
  //getters and setters omitted
}

OrderItem实体与之前我们看到的实体有两个不同之处。第一个不同之处在于这个实体被注解了@IdClass,表示与它对应的主键类。第二个不同之处在于OrderItem有多个字段被注解了@Id。由于这个实体有一个复合主键,每个作为主键一部分的字段都必须注解这个注解。

获取具有复合主键的实体的引用与获取具有单字段主键的实体的引用没有太大区别。以下示例演示了如何做到这一点:

package com.ensode.jakartaeebook.compositeprimarykeys.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class CompositePrimaryKeyDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  private OrderItem orderItem;
  public String findOrderItem() {
    String retVal = "confirmation";
    try {
      orderItem = entityManager.find(OrderItem.class,
     new OrderItemPK(1L, 2L));
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
  //getters and setters omitted
}

如此示例所示,使用复合主键定位实体与使用单字段主键的实体之间的唯一区别是,必须将自定义主键类的实例作为EntityManager.find()方法的第二个参数传递;对于此实例的字段必须填充主键组成部分的每个字段适当的值。

Jakarta Persistence Query Language

到目前为止,我们所有的例子都方便地假设在事先知道实体的主键。我们都知道,这通常并不是情况。每当我们需要通过除实体主键之外的字段来搜索实体时,我们可以使用Jakarta Persistence Query LanguageJPQL)。

JPQL 是一种类似于 SQL 的语言,用于在数据库中检索、更新和删除实体。以下示例说明了如何使用 JPQL 从 CUSTOMERDB 数据库中的US_STATES表检索状态子集:

package com.ensode.jakartaeebook.jpql.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class SelectQueryDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  private List<UsState> matchingStatesList;
  public String findStates() {
    String retVal = "confirmation";
    try {
      Query query = entityManager.createQuery(
          "SELECT s FROM UsState s WHERE s.usStateNm "
            + "LIKE :name");
      query.setParameter("name", "New%");
      matchingStatesList = query.getResultList();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
  //getters and setters omitted for brevity
}

前述代码调用了EntityManager.createQuery()方法,传递一个包含 JPQL 查询的String参数。此方法返回一个jakarta.persistence.Query实例。查询检索所有以单词“New”开头的UsState实体。

如前述代码所示,JPQL 类似于 SQL,然而,有一些差异可能会让有 SQL 知识的人感到困惑。代码中查询的等效 SQL 代码如下:

SELECT * from US_STATES s where s.US_STATE_NM like 'New%'

JPQL 与 SQL 之间的第一个区别在于,在 JPQL 中,我们总是引用实体名称,而在 SQL 中引用的是表名。JPQL 查询中实体名称后面的s是实体的别名。在 SQL 中表别名是可选的,但在 JPQL 中实体别名是必需的。记住这些区别,JPQL 查询现在应该会少一些令人困惑。

查询中的:nameEntityManager.createQuery()调用返回的jakarta.persistence.Query实例中的setParameter()方法。JPQL 查询可以有多个命名参数。

要实际运行查询并从数据库检索实体,我们可以在从EntityManager.createQuery()获得的jakarta.persistence.Query实例上调用getResultList()方法。此方法返回实现java.util.List接口的类的实例,该列表包含符合查询标准的实体。如果没有实体符合标准,则返回空列表。

如果我们确定查询将返回恰好一个实体,那么可以在Query上调用getSingleResult()方法,这个方法返回一个Object,必须将其转换为适当的实体。

之前的例子使用了LIKE运算符来查找名称以“New”开头的实体。这是通过将查询的命名参数替换为值New%来实现的。参数值末尾的百分号表示“New”词之后的任意数量的字符都将与表达式匹配。百分号可以在参数值中的任何位置使用,例如,值为%Dakota将匹配任何以“Dakota”结尾的实体,而值为A%a将匹配任何以大写“A”开头并以小写“a”结尾的州。参数值中可以有多个百分号。下划线符号(_)可以用来匹配单个字符,所有关于百分号的规则也适用于下划线。

除了LIKE运算符之外,还有其他可以用来从数据库中检索实体的运算符:

  • =运算符将检索运算符左侧的字段与运算符右侧的值完全匹配的实体

  • >运算符将检索运算符左侧的字段大于运算符右侧的值的实体

  • <运算符将检索运算符左侧的字段小于运算符右侧的值的实体

  • >=运算符将检索运算符左侧的字段大于或等于运算符右侧的值的实体

  • <=运算符将检索运算符左侧的字段小于或等于运算符右侧的值的实体

所有的上述运算符与 SQL 中等效运算符的工作方式相同。就像在 SQL 中一样,这些运算符可以与“AND”和“OR”运算符结合使用。与“AND”运算符结合的条件如果两个条件都为真则匹配,而与“OR”运算符结合的条件如果至少有一个条件为真则匹配。

如果我们打算多次使用查询,它可以通过@NamedQuery注解来存储。这个注解有两个元素,一个name元素用于设置查询的名称;另一个query元素定义查询本身。要执行命名查询,必须在EntityManager的实例上调用createNamedQuery()方法。此方法接受一个包含查询名称的String作为其唯一参数,并返回一个jakarta.persistence.Query实例。

除了检索实体之外,JPQL 还可以用来修改或删除实体。然而,可以通过EntityManager接口以编程方式执行实体修改和删除,这样做产生的代码通常比使用 JPQL 时更易于阅读。因此,我们将不会介绍通过 JPQL 进行实体修改和删除的内容。对编写 JPQL 查询以修改和删除实体感兴趣的读者,以及对 JPQL 有更多了解愿望的读者,应查阅 Jakarta Persistence 3.1 规范。该规范可在jakarta.ee/specifications/persistence/3.1/jakarta-persistence-spec-3.1找到。

除了 JPQL 之外,Jakarta Persistence 提供了一个我们可以用来创建查询的 API,恰当地命名为 Criteria API。

Criteria API

Jakarta Persistence 的Criteria API旨在作为 JPQL 的补充。Criteria API 允许我们以编程方式编写 Jakarta Persistence 查询,而无需依赖于 JPQL。

Criteria API 相对于 JPQL 提供了一些优势——例如,JPQL 查询被存储为字符串,编译器无法验证 JPQL 语法。此外,JPQL 不是类型安全的;我们可能编写一个 JPQL 查询,其中where子句可能有一个字符串值用于数值属性,而我们的代码可以编译并顺利部署。

以下代码示例说明了如何在我们的 Jakarta EE 应用程序中使用 Criteria API:

package com.ensode.jakartaeebook.criteriaapi.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class CriteriaApiDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  private List<UsState> matchingStatesList;
  public String findStates() {
    String retVal = "confirmation";
    try {
      CriteriaBuilder criteriaBuilder = entityManager.
          getCriteriaBuilder();
      CriteriaQuery<UsState> criteriaQuery =
        criteriaBuilder.createQuery(UsState.class);
      Root<UsState> root = criteriaQuery.from(
          UsState.class);
      Metamodel metamodel = entityManager.getMetamodel();
      EntityType<UsState> usStateEntityType =
        metamodel.entity(UsState.class);
      SingularAttribute<UsState, String> usStateAttribute =
       usStateEntityType.getDeclaredSingularAttribute(
         "usStateNm",String.class);
      Path<String> path = root.get(usStateAttribute);
      Predicate predicate = criteriaBuilder.like(
        path, "New%");
      criteriaQuery = criteriaQuery.where(predicate);
      TypedQuery typedQuery = entityManager.createQuery(
              criteriaQuery);
      matchingStatesList = typedQuery.getResultList();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
  //getters and setters omitted for brevity
}

前面的示例与我们在本章前面看到的 JPQL 示例等效。然而,这个示例利用了 Criteria API 而不是依赖于 JPQL。

当使用 Criteria API 编写代码时,我们首先需要做的是获取一个实现jakarta.persistence.criteria.CriteriaBuilder接口的类的实例。正如我们可以在前面的示例中看到的那样,我们需要通过在EntityManager上调用getCriteriaBuilder()方法来获取这个实例。

从我们的CriteriaBuilder实现中,我们需要获取一个实现jakarta.persistence.criteria.CriteriaQuery接口的类的实例。我们通过在CriteriaBuilder实现中调用createQuery()方法来完成这个操作。请注意,CriteriaQuery是泛型类型的。泛型类型参数决定了我们的CriteriaQuery实现执行后将返回的结果类型。通过这种方式利用泛型,Criteria API 允许我们编写类型安全的代码。

一旦我们获取了CriteriaQuery实现,我们可以从中获取一个实现jakarta.persistence.criteria.Root接口的类的实例。Root 实现决定了我们将从哪个 Jakarta Persistence 实体进行查询。它与 JPQL(和 SQL)中的FROM查询类似。

在我们的示例中的下一行和下一行,我们利用了 Jakarta Persistence 规范的另一个特性,即通过在我们的 EntityManager 上调用 getMetamodel() 方法来使用 jakarta.persistence.metamodel.Metamodel 接口。

从我们的 Metamodel 实现中,我们可以获得一个泛型类型的 jakarta.persistence.metamodel.EntityType 接口实例。泛型类型参数表示我们的 EntityType 实现对应的 Jakarta Persistence 实体。EntityType 允许我们在运行时浏览 Jakarta Persistence 实体的持久属性。这正是我们在示例中的下一行所做的事情。在我们的情况下,我们正在获取一个 SingularAttribute 的实例,它映射到我们的 Jakarta Persistence 实体中的一个简单、单一属性。EntityType 有方法可以获取映射到集合、集合、列表和映射的属性。获取这些类型的属性与获取 SingularAttribute 非常相似,因此我们不会直接介绍这些。有关更多信息,请参阅 jakarta.ee/specifications/platform/10/apidocs/ 上的 Jakarta EE API 文档。

如我们示例中所示,SingularAttribute 包含两个泛型类型参数。第一个参数指定了我们正在处理的 Jakarta Persistence 实体,第二个参数表示属性的类型。我们通过在我们的 EntityType 实现上调用 getDeclaredSingularAttribute() 方法并传递属性名称(如在我们 Jakarta Persistence 实体中声明的那样)作为字符串来获取我们的 SingularAttribute

一旦我们获得了我们的 SingularAttribute 实现,我们需要通过在我们的 Root 实例中调用 get() 方法来获取一个导入的 jakarta.persistence.criteria.Path 实现,并将我们的 SingularAttribute 作为参数传递。

在我们的示例中,我们将获取美国所有“新”州(即所有以“New”开头的州)的列表。当然,这是一个“like”条件的任务。我们可以通过在 CriteriaBuilder 实现上调用 like() 方法使用 Criteria API 来完成这项工作。like() 方法将我们的 Path 实现作为其第一个参数,将搜索值作为其第二个参数。

CriteriaBuilder 有许多类似于 SQL 和 JPQL 子句的方法,如 equals(), greaterThan(), lessThan(), and(), or() 等(完整列表请参阅在线 Jakarta EE 文档)。这些方法可以通过 Criteria API 组合起来创建复杂的查询。

CriteriaBuilder 中的 like() 方法返回一个 jakarta.persistence.criteria.Predicate 接口的实现,我们需要将其传递给我们的 CriteriaQuery 实现中的 where() 方法。此方法返回一个新的 CriteriaBuilder 实例,我们将其分配给我们的 criteriaBuilder 变量。

到这一点,我们已准备好构建我们的查询。当使用 Criteria API 时,我们处理的是jakarta.persistence.TypedQuery接口,这可以被认为是与 JPQL 一起使用的Query接口的一个类型安全的版本。我们通过在EntityManager中调用createQuery()方法并传递我们的CriteriaQuery实现作为参数来获取一个TypedQuery实例。

要将我们的查询结果作为列表获取,我们只需在TypedQuery实现上调用getResultList()。值得注意的是,Criteria API 是类型安全的,因此尝试将getResultList()的结果分配给错误类型的 List 会导致编译错误。

让我们看看如何使用 Criteria API 来更新数据。

使用 Criteria API 更新数据

我们可以使用CriteriaUpdate接口使用 Criteria API 来更新数据库数据。以下示例展示了如何做到这一点:

package com.ensode.jakartaeebook.criteriaupdate.namedbean;
//imports omitted for brevity
@Named
@RequestScoped
public class CriteriaUpdateDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction
  private int updatedRows;
  public String updateData() {
    String retVal = "confirmation";
    try {
      userTransaction.begin();
      insertTempData();
      CriteriaBuilder criteriaBuilder =
        entityManager.getCriteriaBuilder();
      CriteriaUpdate<Address> criteriaUpdate =
        criteriaBuilder.createCriteriaUpdate(Address.class);
      Root<Address> root = criteriaUpdate.from(
          Address.class);
      criteriaUpdate.set("city", "New York");
      criteriaUpdate.where(criteriaBuilder.equal(
          root.get("city"), "New Yorc"));
      Query query = entityManager.createQuery(
          criteriaUpdate);
      updatedRows = query.executeUpdate();
      userTransaction.commit();
    } catch (Exception e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
 //getters and setters omitted
  private void insertTempData() throws Exception {
    //method body omitted
  }
}

这个例子所做的是找到所有包含名为“New Yorc”(一个拼写错误)的城市的数据库行,并将其值替换为正确的拼写“New York”。

就像上一个例子一样,我们通过在EntityManager实例上调用getCriteriaBuilder()方法来获取一个实现CriteriaBuilder接口的类的实例。

然后,我们通过在CriteriaBuilder实例上调用createCriteriaUpdate()来获取一个实现CriteriaUpdate的类的实例。

下一步是获取一个实现Root的类的实例,这是通过在CriteriaUpdate实例上调用from()方法来完成的。

然后,我们在CriteriaUpdate上调用set()方法来指定更新后我们的行将具有的新值。set()方法的第一参数必须是一个与实体类中的属性名称匹配的String,第二个参数必须是新值。

在这一点上,我们通过在CriteriaUpdate上调用where()方法并传递由CriteriaBuilder中调用的equal()方法返回的Predicate来构建where子句。

然后,我们通过在EntityManager上调用createQuery()并传递我们的CriteriaUpdate实例作为参数,来获取一个Query实现。

最后,我们通过在Query实现上调用executeUpdate()来像往常一样执行我们的查询。

使用 Criteria API 删除数据

我们可以使用 Jakarta Persistence Criteria API 来删除数据库数据。这可以通过CriteriaDelete接口来完成。以下代码片段展示了其用法:

package com.ensode.jakartaeebook.criteriadelete.namedbean;
//imports omitted
@Named
@RequestScoped
public class CriteriaDeleteDemoBean {
  @PersistenceContext
  private EntityManager entityManager;
  @Resource
  private UserTransaction userTransaction;
  private int deletedRows;
  public String deleteData() {
    String retVal = "confirmation";
    try {
      userTransaction.begin();
      CriteriaBuilder criteriaBuilder =
        entityManager.getCriteriaBuilder();
      CriteriaDelete<Address> criteriaDelete
= criteriaBuilder.createCriteriaDelete(
          Address.class);
      Root<Address> root = criteriaDelete.from(
        Address.class);
      criteriaDelete.where(criteriaBuilder.or(
        criteriaBuilder.equal(
          root.get("city"), "New York"),
          criteriaBuilder.equal(root.get("city"),
              "New York")));
      Query query = entityManager.createQuery(
criteriaDelete);
      deletedRows = query.executeUpdate();
      userTransaction.commit();
    } catch (HeuristicMixedException
            | HeuristicRollbackException
            | NotSupportedException
            | RollbackException
            | SystemException
            | IllegalStateException
            | SecurityException e) {
      retVal = "error";
      e.printStackTrace();
    }
    return retVal;
  }
  //getters and setters omitted
}

要使用CriteriaDelete,我们首先像往常一样获取一个CriteriaBuilder实例,然后在我们的CriteriaBuilder实例上调用createCriteriaDelete()方法来获取一个CriteriaDelete实现。

一旦我们有了CriteriaDelete的一个实例,我们就像通常使用 Criteria API 那样构建where子句。

一旦我们构建了where子句,我们就获得了一个Query接口的实现,并像往常一样在其上调用executeUpdate()

现在我们已经看到了如何插入和检索数据库数据,我们将转向通过 Bean Validation 进行数据验证。

Bean Validation 支持

Bean Validation 是 Jakarta EE 规范,由一系列用于简化数据验证的注解组成。Jakarta Persistence Bean Validation 支持允许我们使用 Bean Validation 注解来注解我们的实体。这些注解使我们能够轻松验证用户输入并执行数据清理。

利用 Bean Validation 非常简单。我们只需要用jakarta.validation.constraints包中定义的任何验证注解来注解我们的 Jakarta Persistence 实体字段或 getter 方法。一旦我们的字段被适当地注解,EntityManager将阻止非验证数据被持久化。

以下代码示例是本章前面看到的Customer Jakarta Persistence 实体的修改版本。它已被修改以利用其某些字段中的 Bean Validation:

package com.ensode.jakartaeebook.beanvalidation.entity;
//imports omitted for brevity
@Entity
@Table(name = "CUSTOMERS")
public class Customer implements Serializable {
  @Id
  @Column(name = "CUSTOMER_ID")
  private Long customerId;
  @Column(name = "FIRST_NAME")
  @NotNull
  @Size(min = 2, max = 20)
  private String firstName;
  @Column(name = "LAST_NAME")
  @NotNull
  @Size(min = 2, max = 20)
  private String lastName;
  private String email;
  // getters and setters omitted for brevity
}

在这个示例中,我们使用了@NotNull注解来防止我们的实体的firstNamelastName字段以null值被持久化。我们还使用了@Size注解来限制这些字段的最低和最高长度。

这就是我们利用 Jakarta Persistence 中的 bean 验证所需要做的全部。如果我们的代码尝试持久化或更新一个未通过声明的验证的实体实例,将抛出一个类型为jakarta.validation.ConstraintViolationException的异常,并且实体将不会被持久化。

正如我们所见,Bean Validation 几乎自动化了数据验证,使我们免于手动编写验证代码。

除了前面示例中讨论的两个注解之外,jakarta.validation.constraints包还包含几个额外的注解,我们可以使用它们来自动化 Jakarta Persistence 实体的验证。请参阅在线 Jakarta EE 10 API 以获取完整列表。

最后的注意事项

在本章的示例中,我们展示了从作为控制器的 CDI 命名豆直接访问数据库。我们这样做是为了清晰地传达观点,而不被细节所困扰;然而,在一般情况下,这并不是一个好的做法。数据库访问代码应该封装在数据访问对象DAOs)中。

DAO 设计模式

有关 DAO 设计模式的更多信息,请参阅www.oracle.com/technetwork/java/dao-138818.html

命名豆类通常在采用模型-视图-控制器MVC)设计模式时扮演控制器和/或模型的角色,这种做法非常普遍,以至于已经成为 Jakarta EE 应用程序的事实标准。

MVC 设计模式

有关 MVC 设计模式的更多信息,请参阅en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller

此外,我们选择不在示例中展示任何用户界面代码,因为这与当前主题无关。然而,本章的代码下载包括调用本章中命名的 Bean 的 Jakarta Faces 页面,并在命名 Bean 调用完成后显示确认页面。

摘要

本章介绍了如何通过 Jakarta Persistence 访问数据库中的数据,Jakarta Persistence 是 Jakarta EE 的标准对象关系映射 API。

在本章中,我们涵盖了以下主题:

  • 如何通过使用 @Entity 注解将 Java 类标记为 Jakarta Persistence 实体,以及如何通过 @Table 注解将其映射到数据库表。我们还介绍了如何通过 @Column 注解将实体字段映射到数据库列。

  • 使用 jakarta.persistence.EntityManager 接口查找、持久化和更新 Jakarta Persistence 实体。

  • 如何在 Jakarta Persistence 实体之间定义单向和双向的一对一、一对多和多对多关系。

  • 如何通过开发自定义主键类来使用复合主键。

  • 如何使用 Jakarta Persistence 查询语言JPQL)和 Criteria API 从数据库中检索实体。

  • Bean Validation,它允许我们通过简单地注解 Jakarta Persistence 实体字段来轻松验证输入。

Jakarta Persistence 抽象了数据库访问代码,并允许我们针对 Java 对象进行编码,而不是数据库表。它还与每个流行的关系数据库管理系统(RDBMS)系统兼容,使得我们的代码可以轻松地在不同的关系数据库管理系统之间移植。

第九章:WebSockets

传统上,Web 应用程序是使用 HTTP 之后的请求/响应模型开发的。在这个传统的请求/响应模型中,请求始终由客户端发起,然后服务器将响应发送回客户端。

服务器从未有过独立向客户端发送数据的方式,也就是说,不需要等待请求,直到现在。WebSocket 协议允许客户端(浏览器)和服务器之间进行全双工、双向通信。

Jakarta API for WebSocket 允许我们在 Java 中开发 WebSocket 端点。

在本章中,我们将涵盖以下主题:

  • 开发 WebSocket 服务器端点

  • 使用 JavaScript 开发 WebSocket 客户端

  • 使用 Java 开发 WebSocket 客户端

注意

本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch09_src

开发 WebSocket 服务器端点

我们可以通过两种方式使用 Jakarta API 为 WebSocket 实现 WebSocket 服务器端点:我们既可以编程式地开发端点,在这种情况下,我们需要扩展jakarta.websocket.Endpoint类,或者我们可以使用 WebSocket 特定的注解来注解普通 Java 对象POJOs)。这两种方法非常相似,因此我们只将详细讨论注解方法,并在本章后面简要解释如何编程式地开发 WebSocket 服务器端点。

在本章中,我们将开发一个简单的基于 Web 的聊天应用程序,充分利用 Jakarta API for WebSocket。

开发注解 WebSocket 服务器端点

下面的 Java 类演示了我们可以通过注解 Java 类来开发 WebSocket 服务器端点:

package com.ensode.jakartaeebook.websocketchat.serverendpoint;
//imports omitted
@ServerEndpoint("/websocketchat")
public class WebSocketChatEndpoint {
  private static final Logger LOG =
    Logger.getLogger(WebSocketChatEndpoint.class.getName());
  @OnOpen
  public void connectionOpened() {
    LOG.log(Level.INFO, "connection opened");
  }
  @OnMessage
  public synchronized void processMessage(Session session,
    String message) {
    LOG.log(Level.INFO, "received message: {0}", message);
    session.getOpenSessions().forEach(sess -> {
      if (sess.isOpen()) {
        try {
          sess.getBasicRemote().sendText(message);
        } catch (IOException ex) {
          LOG.log(Level.SEVERE, ex.getMessage(), ex);
        }
      }
    });
  }
  @OnClose
  public void connectionClosed() {
    LOG.log(Level.INFO, "connection closed");
  }
}

类级别的@ServerEndpoint注解表示该类是一个 WebSocket 服务器端点。在这个例子中,"/websocketchat"(即)。WebSocket 客户端将使用此 URI 与我们的端点进行通信。

@OnOpen注解用于指示每当从任何客户端打开 WebSocket 连接时需要执行的方法。在我们的例子中,我们只是向服务器日志发送一些输出,但当然,任何有效的服务器端 Java 代码都可以放在这里。

任何带有@OnMessage注解的方法将在我们的服务器端点从任何客户端接收到消息时被调用。由于我们正在开发一个聊天应用程序,我们的代码只是将接收到的消息广播给所有已连接的客户端。

在我们的示例中,processMessage()方法被注解为@OnMessage,它接受两个参数,一个实现jakarta.websocket.Session接口的类的实例,以及一个包含接收到的消息的String。由于我们正在开发一个聊天应用程序,我们的 WebSocket 服务器端点只是将接收到的消息广播给所有已连接的客户端。

Session接口的getOpenSessions()方法返回一个包含所有打开会话的Set集合,我们遍历这个集合,通过在每个Session实例上调用getBasicRemote()方法,然后调用此调用返回的RemoteEndpoint.Basic实现上的sendText()方法,将接收到的消息广播回所有已连接的客户端。

Session接口上的getOpenSessions()方法在调用该方法时返回所有打开的会话。在方法调用之后,一个或多个会话可能已经关闭,因此建议在尝试向客户端发送数据之前,在Session实现上调用isOpen()方法。

最后,我们需要使用@OnClose注解来标注一个方法,以处理客户端从服务器端点断开连接的事件。在我们的示例中,我们只是简单地将一条消息记录到服务器日志中。

我们在示例中没有使用的一个附加注解。@OnError注解用于指示在向客户端发送或从客户端接收数据时发生错误时需要调用的方法。

如我们所见,开发注解 WebSocket 服务器端点很简单。我们只需添加几个注解,应用程序服务器就会根据需要调用我们的注解方法。

如果我们希望以编程方式开发 WebSocket 服务器端点,我们需要编写一个扩展jakarta.websocket.Endpoint的 Java 类。这个类有onOpen()onClose()onError()方法,这些方法在端点生命周期中的适当时间被调用。没有与@OnMessage注解等效的方法——要处理来自客户端的传入消息,需要在会话中调用addMessageHandler()方法,传递一个实现jakarta.websocket.MessageHandler接口(或其子接口)的类的实例作为其唯一参数。

通常,与它们的程序化对应物相比,开发注解 WebSocket 端点更为直接。因此,我们建议尽可能使用注解方法。

现在我们已经看到了如何开发 WebSocket 端点,我们将把注意力集中在开发 WebSocket 客户端上。

在 JavaScript 中开发 WebSocket 客户端

大多数 WebSocket 客户端都是作为利用 JavaScript WebSocket API 的网页实现的。我们将在下一节中介绍如何实现这一点。

Jakarta API 为 WebSocket 提供了一个客户端 API,允许我们开发作为独立 Java 应用程序的 WebSocket 客户端。我们将在本章后面介绍这一功能。

开发 JavaScript 客户端 WebSocket 代码

在本节中,我们将介绍如何开发客户端 JavaScript 代码,以与我们在上一节中开发的 WebSocket 端点交互。

我们 WebSocket 示例的客户端页面是使用 HTML5 友好的标记实现的 JSF 页面(如第七章中所述)。

图 9.1所示,我们的客户端页面由一个文本区域组成,我们可以在这里看到我们应用的用户在说什么(毕竟,这是一个聊天应用),以及一个文本输入框,我们可以用它向其他用户发送消息。

图 9.1 – JavaScript WebSocket 客户端

图 9.1 – JavaScript WebSocket 客户端

我们客户端页面的 JavaScript 代码如下所示:

<script type="text/javascript">
  var websocket;
  function init() {
    websocket = new WebSocket(
     'ws://localhost:8080/websocketchat/websocketchat');
     websocket.onopen = function (event) {
      websocketOpen(event)
    };
    websocket.onmessage = function (event) {
      websocketMessage(event)
    };
    websocket.onerror = function (event) {
      websocketError(event)
    };
   }
   function websocketOpen(event) {
    console.log("webSocketOpen invoked");
  }
  function websocketMessage(event) {
    console.log("websocketMessage invoked");
    document.getElementById('chatwindow').value += '\r' +
      event.data;
  }
  function websocketError(event) {
    console.log("websocketError invoked");
  }
  function sendMessage() {
    var userName = document.getElementById('userName').value;
    var msg = document.getElementById('chatinput').value;
     websocket.send(userName + ": " + msg);
  }
  function closeConnection() {
    websocket.close();
  }
  window.addEventListener("load", init, false);
</script>

我们 JavaScript 代码的最后一行(window.addEventListener("load", init);)将我们的 JavaScript init()函数设置为在页面加载时立即执行。

init()函数中,我们初始化一个新的 JavaScript WebSocket 对象,将我们的服务器端点 URI 作为参数传递。这使我们的 JavaScript 代码知道我们的服务器端点的位置。

JavaScript WebSocket 对象有几种函数类型,用于处理不同的事件,如打开连接、接收消息和处理错误。我们需要将这些类型设置到我们自己的 JavaScript 函数中,以便我们可以处理这些事件,这正是我们在调用 JavaScript WebSocket 对象构造函数后立即在init()方法中做的。在我们的例子中,我们分配给 WebSocket 对象的函数只是将它们的功能委托给独立的 JavaScript 函数。

当 WebSocket 连接打开时,会调用我们的websocketOpen()函数。在我们的例子中,我们只是向浏览器 JavaScript 控制台发送一条消息。

当浏览器从我们的 WebSocket 端点接收到 WebSocket 消息时,会调用我们的webSocketMessage()函数。在我们的例子中,我们使用消息的内容更新具有聊天窗口 ID 的文本区域的内文。

当发生与 WebSocket 相关的错误时,会调用我们的websocketError()函数。在我们的例子中,我们只是向浏览器 JavaScript 控制台发送一条消息。

我们的sendMessage()JavaScript 函数向 WebSocket 服务器端点发送一条消息,其中包含用户名和具有chatinput ID 的文本输入的内容。当用户点击具有sendBtn ID 的按钮时,会调用此函数。

我们的closeConnection()JavaScript 函数关闭了与我们的 WebSocket 服务器端点的连接。

从这个例子中我们可以看出,编写客户端 JavaScript 代码与 WebSocket 端点交互相当简单。

在 Java 中开发 WebSocket 客户端

尽管目前开发基于 Web 的 WebSocket 客户端是开发 WebSocket 客户端最常见的方式,但 Jakarta API 为 WebSocket 提供了一个客户端 API,我们可以使用它来开发 Java WebSocket 客户端。

在本节中,我们将使用 Jakarta API for WebSocket 的客户端 API 开发一个简单的图形 WebSocket 客户端。图 9.2展示了我们的 Java WebSocket 客户端的 GUI。

图 9.2 – 使用 Java 开发的 WebSocket 客户端

图 9.2 – 使用 Java 开发的 WebSocket 客户端

注意

我们不会介绍 GUI 代码,因为它与讨论无关。示例的完整代码,包括 GUI 代码,可以从本书的 GitHub 仓库下载。

就像 WebSocket 服务器端点一样,Java WebSocket 客户端可以通过编程方式或使用注解来开发。再次强调,我们只介绍注解方法。开发程序客户端的方式与开发程序服务器端点的方式非常相似,也就是说,程序客户端必须扩展jakarta.websocket.Endpoint并重写适当的方法。

不再赘述,以下是我们的 Java WebSocket 客户端代码:

package com.ensode.websocketjavaclient;
//imports omitted
@ClientEndpoint
public class WebSocketClient {
  private static final Logger LOG =
    Logger.getLogger(WebSocketClient.class.getName());
  private String userName;
  private Session session;
  private final WebSocketJavaClientFrame webSocketJavaClientFrame;
  public WebSocketClient(WebSocketJavaClientFrame
    webSocketJavaClientFrame) {
    this.webSocketJavaClientFrame = webSocketJavaClientFrame;
    try {
      WebSocketContainer webSocketContainer =
     ContainerProvider.getWebSocketContainer();
      webSocketContainer.connectToServer(this, new URI(
     "ws://localhost:8080/websocketchat/websocketchat"));
    } catch (DeploymentException | IOException | URISyntaxException ex) {
      ex.printStackTrace();
    }
  }
  @OnOpen
  public void onOpen(Session session) {
 this.session = session;
  }
  @OnClose
  public void onClose(CloseReason closeReason) {
    LOG.log(Level.INFO, String.format(
      "Connection closed, reason: %s",
      closeReason.getReasonPhrase()));
  }
  @OnError
public void onError(Throwable throwable) {
    throwable.printStackTrace();
  }
  @OnMessage
  public void onMessage(String message, Session session) {
    webSocketJavaClientFrame.getChatWindowTextArea().setText(
            webSocketJavaClientFrame.getChatWindowTextArea().getText()
            + ""
            + "\n" + message);
  }
  public void sendMessage(String message) {
    try {
      session.getBasicRemote().sendText(userName + ": " + message);
    } catch (IOException ex) {
      ex.printStackTrace();
    }
  }
  //setters and getters omitted
}

类级别的@ClientEndPoint注解将我们的类标记为 WebSocket 客户端。所有 Java WebSocket 客户端都必须使用此注解。

建立与 WebSocket 服务器端点连接的代码位于我们的类构造函数中。首先,我们需要调用ContainerProvider.getWebSocketContainer()来获取jakarta.websocket.WebSocketContainer的实例。然后,通过在WebSocketContainer实例上调用connectToServer()方法来建立连接,将一个被@ClientEndpoint注解的类作为第一个参数(在我们的例子中,我们使用this,因为连接代码位于我们的 WebSocket Java 客户端代码中),以及包含 WebSocket 服务器端点 URI 的URI对象作为第二个参数。

连接建立后,我们就可以准备响应 WebSocket 事件了。细心的读者可能已经注意到,我们在客户端代码中再次使用了我们用于开发服务器端点相同的注解。

任何被@OnOpen注解的方法,在 WebSocket 服务器端点连接建立时都会自动被调用,该方法必须返回 void,并且可以有一个可选的jakarta.websocket.Session类型参数。在我们的例子中,我们使用接收到的Session实例初始化一个类变量。

@OnClose注解的方法在 WebSocket 会话关闭时被调用。被注解的方法可以有一个可选的jakarta.websocket.Session类型参数和一个CloseReason类型参数。在我们的例子中,我们选择只使用CloseReason可选参数,因为这个类有一个方便的getReasonPhrase()方法,可以提供会话关闭的简短解释。

@OnError注解用于指示当发生错误时将调用该方法。带有@OnError注解的方法必须有一个类型为java.lang.Throwablejava.lang.Exception的父类)的参数,并且可以有一个类型为Session的可选参数。在我们的示例中,我们只是将Throwable参数的堆栈跟踪发送到stderr

当接收到传入的 WebSocket 消息时,带有@OnMessage注解的方法将被调用。@OnMessage方法可以根据接收到的消息类型以及我们希望如何处理它来具有不同的参数。在我们的示例中,我们使用了最常见的情况,接收文本消息。在这种情况下,我们需要一个String参数来保存消息的内容,以及一个可选的Session参数。

注意

有关如何处理其他类型消息的信息,请参阅@OnMessage的 JavaDoc 文档,网址为jakarta.ee/specifications/platform/10/apidocs/jakarta/websocket/onmessage

在我们的示例中,我们只是更新了聊天窗口的文本区域,将接收到的消息追加到其内容中。

要发送 WebSocket 消息,我们在 Session 实例上调用getBasicRemote()方法,然后在该调用返回的RemoteEndpoint.Basic实现上调用sendText()方法(如果这看起来很熟悉,那是因为我们在 WebSocket 服务器端点代码中确实做了完全相同的事情)。在我们的示例中,我们在sendMessage()方法中这样做。

关于 Jakarta API for WebSocket 的附加信息

在本章中,我们介绍了 Jakarta API for WebSocket 提供的功能的大部分。有关更多信息,请参阅 Tyrus 的用户指南,这是一个流行的开源 Jakarta API for WebSocket 实现,网址为eclipse-ee4j.github.io/tyrus-project.github.io/documentation/latest/index/

摘要

在本章中,我们介绍了 Jakarta API for WebSocket,这是一个用于开发 WebSocket 服务器端点和客户端的 Jakarta EE API:

  • 我们首先了解了如何利用 Jakarta API for WebSockets 开发 WebSocket 服务器端点

  • 然后,我们介绍了如何使用 JavaScript 开发基于 Web 的 WebSocket 客户端

  • 最后,我们解释了如何在 Java 中开发 WebSocket 客户端应用程序

WebSockets 使我们能够实现网页浏览器和网页服务器之间的实时、双向通信。正如我们在本章中看到的,Jakarta EE WebSocket API 负责处理底层细节,使我们能够通过几个简单的注解来开发 WebSocket 端点。

第十章:保护 Jakarta EE 应用程序

Jakarta EE 标准化了所有 Jakarta EE 兼容应用程序服务器中的应用程序安全。API 包括对身份存储的标准化访问,允许以统一的方式从关系型或 轻量级目录访问协议LDAP)数据库中检索用户凭证,并允许我们实现自定义身份存储的访问。Jakarta EE 安全包括认证机制支持,允许我们以标准方式认证用户。支持多种认证机制,例如大多数浏览器支持的基线认证、客户端证书和 HTML 表单。

本章将涵盖以下主题:

  • 身份存储

  • 认证机制

注意

本章的示例源代码可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch10_src

身份存储

身份存储提供了对持久存储系统的访问,例如关系型或 LDAP 数据库,其中存储了用户凭证。Jakarta EE 安全 API 直接支持关系型和 LDAP 数据库,并且允许我们在必要时与自定义身份存储集成。

在关系型数据库中设置身份存储

要对存储在关系型数据库中的凭证进行认证,例如 servlet 或 RESTful Web 服务,请将具有 @DatabaseIdentityStoreDefinition 注解的应用程序范围 CDI bean 注解,如下例所示:

package net.ensode.javaee8book.httpauthdatabaseidentitystore.security;
//imports omitted for brevity
@DatabaseIdentityStoreDefinition(
  dataSourceLookup = "java:global/jdbc/userauthdbDatasource",
    callerQuery =
      "select password from users where USERNAME = ?",
    groupsQuery =
      "select g.GROUP_NAME from USER_GROUPS ug, users u, "
        + "GROUPS g where ug.USER_ID = u.user_id and "
        + "g.GROUP_ID= ug.GROUP_ID and u.USERNAME=?"
)
@ApplicationScoped
public class ApplicationConfig {
}

在我们的示例中,包含用户凭证的关系型数据库的 JNDI 名称为 java:global/jdbc/userauthdbDatasource,这是我们提供给 @DatabaseIdentityStoreDefinition 注解的 dataSourceLookup 属性的值。

@DatabaseIdentityStoreDefinitioncallerQuery 参数用于指定用于检索我们正在验证的用户用户名和密码的 SQL 查询。从数据库检索的值必须与用户提供的值(通过认证机制,我们将在下一节讨论)匹配。

大多数受保护的应用程序都有不同类型的用户,分为不同的角色;例如,一个应用程序可以有“普通”用户和管理员。管理员将能够执行普通用户无法执行的操作。例如,管理员可以重置用户密码并从系统中添加或删除用户。@DatabaseIdentityStoreDefinitiongroupsQuery 属性允许我们检索用户的全部角色。

在 LDAP 数据库中设置身份存储

为了保护存储在 LDAP 数据库中的凭据,我们需要使用@LdapIdentityStoreDefinition注解来注解要保护的资源(如 servlet 或 RESTful Web 服务);以下示例说明了如何进行此操作:

package com.ensode.jakartaeebook.httpauthdatabaseidentitystore.servlet;
//imports omitted for brevity
@LdapIdentityStoreDefinition(
        url = "ldap://myldapserver:33389/",
        callerBaseDn = "ou=caller,dc=packtpub,dc=com",
        groupSearchBase = "ou=group,dc=packtpub,dc=com")
@WebServlet(name = "ControllerServlet", urlPatterns = {"/controller"})
public class ControllerServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req,
      HttpServletResponse res)
            throws ServletException, IOException {
        System.out.println("doGet() invoked");
    }
}

@LdapIdentityStoreDefinitionurl属性用于指定包含我们应用程序用户凭据的 LDAP 服务器的 URL;其callerBaseDn属性用于指定用于验证用户提供的用户凭据的 LDAP 基本区分名称,最后,其groupSearchBase属性用于检索用户的角色。

自定义标识存储

在某些情况下,我们可能需要将我们的应用程序安全性与 Security API 不直接支持的标识存储系统集成。例如,我们可能需要与现有的商业安全产品集成。对于此类情况,Jakarta EE 安全 API 允许我们推出自己的标识存储定义。

为了处理自定义标识存储,我们需要创建一个应用程序范围的 CDI bean(参考第二章),并且该 bean 必须实现IdentityStore接口,如下面的示例所示:

package com.ensode.jakartaeebook.security.basicauthexample;
//imports omitted for brevity
@ApplicationScoped
public class DummyIdentityStore implements IdentityStore {
  Set<String> adminRoleSet;
  Set userRoleSet;
  Set userAdminRoleSet;
  @PostConstruct
  public void init() {
    adminRoleSet = new HashSet<>(Arrays.asList("admin"));
    userRoleSet = new HashSet<>(Arrays.asList("user"));
    userAdminRoleSet = new HashSet<>(Arrays.asList("user",
      "admin"));
  }
  @Override
  public CredentialValidationResult validate(
   Credential credential) {
   UsernamePasswordCredential usernamePasswordCredential =
     (UsernamePasswordCredential) credential;
    CredentialValidationResult credentialValidationResult;
    if (usernamePasswordCredential.compareTo(
      "david", "secret")) {
      credentialValidationResult = new
        CredentialValidationResult("david", adminRoleSet);
    }
    else if (usernamePasswordCredential.compareTo(
      "alan", "iforgot")) {
      credentialValidationResult =
        new CredentialValidationResult("alan", userAdminRoleSet);
    }
    else if (usernamePasswordCredential.compareTo("alice",
     "password")) {
      credentialValidationResult = new
        CredentialValidationResult("alice", userRoleSet);
    }
    else {
      credentialValidationResult =
        CredentialValidationResult.INVALID_RESULT;
    }
    return credentialValidationResult;
  }
}

在我们示例中,安全 API 提供的IdentityStore接口中定义了validate()方法。我们实现此方法以便在我们的应用程序中使用自定义验证。

注意

在我们的示例中,我们将有效的凭据硬编码到代码中;不要在实际应用程序中这样做,因为这会是一个重大的安全风险。

定义在IdentityStore接口中的validate()方法接受一个实现Credential接口的类的实例作为其唯一参数。在我们的方法体中,我们将其向下转换为UserNamePasswordCredential,然后调用其compareTo()方法,传递预期的用户名和密码。如果提供的凭据与预期的任何一组凭据匹配,则允许用户成功登录。我们通过返回一个包含用户名和包含用户在我们应用程序中所有角色的SetCredentialValidationResult实例来完成此操作。

如果提供的凭据与预期的任何凭据都不匹配,那么我们通过返回CredentialValidationResult.INVALID_RESULT来阻止用户登录。

现在我们已经看到了如何通过标识存储访问用户凭据信息,我们将关注 Jakarta EE 提供的不同认证机制。

认证机制

认证机制提供了一种让用户提供其凭据的方法,以便它们可以与标识存储进行认证。

Jakarta EE 安全 API 提供了对大多数浏览器提供的 HTTP Basic 认证机制以及表单认证的支持,后者是最常见的认证机制,其中用户通过 HTML 表单提供其凭据。

默认情况下,表单认证会将表单提交给 Jakarta EE 实现提供的安全 servlet。如果我们需要更多的灵活性或更好地与其他 Jakarta EE 技术对齐,安全 API 还提供了自定义表单认证,这允许我们作为应用程序开发者对尝试访问我们应用程序的用户进行认证有更多的控制。

基本认证机制

通过将资源(例如 servlet 或 RESTful Web 服务)标注为安全(即使用 @BasicAuthenticationMechanismDefinition 注解),可以实现基本认证机制:

package com.ensode.jakartaeebook.security.basicauthexample;
//imports omitted for brevity
@BasicAuthenticationMechanismDefinition
@WebServlet(name = "SecuredServlet", urlPatterns = {"/securedServlet"})
@ServletSecurity(
        @HttpConstraint(rolesAllowed = "admin"))
public class SecuredServlet extends HttpServlet {
  @Override
   protected void doGet(HttpServletRequest request,
      HttpServletResponse response) throws Exception {
      response.getOutputStream().print(
        "Congratulations, login successful.");
  }
}

我们通过 @HttpConstraint 注解声明允许访问受保护资源的用户角色,该注解是 @ServletSecurity 注解的属性。在我们的示例中,只有具有 admin 角色的用户才能访问受保护的资源。

使用基本认证将在浏览器中弹出一个窗口,要求输入用户名和密码,如图 图 10.1 所示:

图 10.1 – 基本认证登录提示

图 10.1 – 基本认证登录提示

如果用户输入了正确的凭据并且具有必要的角色,则允许访问受保护的资源,如图 图 10.2 所示:

图 10.2 – 成功的基本认证

图 10.2 – 成功的基本认证

如果用户输入了错误的凭据,登录弹出窗口将再次出现,允许用户重新输入他们的凭据。

如果用户输入了正确的凭据但没有适当的角色来访问受保护的资源,服务器将返回 HTTP 403 错误代码,表示用户被禁止访问受保护的资源。

表单认证机制

我们还可以通过开发一个 HTML 表单来收集用户的凭据,然后将认证委托给 Jakarta EE 安全 API 来认证我们的用户。采用这种方法的第一步是开发一个包含表单的 HTML 页面,用户可以通过该表单登录到应用程序,如下面的示例所示:

<form method="POST" action="j_security_check">
  <table cellpadding="0" cellspacing="0" border="0">
    <tr>
      <td align="right">Username:&nbsp;</td>
      <td><input type="text" name="j_username"></td>
    </tr>
    <tr>
      <td align="right">Password:&nbsp;</td>
      <td><input type="password" name="j_password"></td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Login"></td>
    </tr>
  </table>
</form>

如示例所示,用于登录的 HTML 表单必须提交 HTTP POST 请求,并且其 action 属性的值必须是 j_security_check。现在,j_security_check 映射到由 Jakarta EE 安全 API 提供的 servlet。我们不需要自己开发任何验证逻辑。表单必须包含几个输入字段,一个用于用户名,一个用于密码。这些字段的名称必须分别是 j_usernamej_password;由 Jakarta EE API 提供的安全 servlet 将检索这些值并自动验证用户。

此外,我们还需要提供一个 HTML 页面,当登录失败时用户将被重定向到该页面。该页面可以包含任何有效的 HTML 标记。在我们的示例中,我们只是提供了一个错误消息和一个链接,以便将用户引导回登录页面,以便他们可以再次尝试登录:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; 
      charset=UTF-8">
    <title>Login Error</title>
  </head>
  <body>
    There was an error logging in.
    <br />
    <a href="login.html">Try again</a>
  </body>
</html>

在服务器端,我们只需要使用@FormAuthenticationMechanismDefinition注解来标记受保护的资源,这将让 Jakarta EE 安全 API 知道我们正在使用基于表单的认证,以及用于登录或登录失败时显示的 HTML 页面:

package com.ensode.jakartaeebook.httpauthdbidentitystore;
//imports omitted for brevity
@FormAuthenticationMechanismDefinition(
  loginToContinue = @LoginToContinue(
  loginPage = "/login.html",
  errorPage = "/loginerror.html")
)
@DatabaseIdentityStoreDefinition(
  //attributes omitted for brevity
)
@WebServlet("/securedServlet")
@ServletSecurity(
        @HttpConstraint(rolesAllowed = {"admin"}))
public class SecuredServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse 
    response)
          throws ServletException, IOException {
    response.getWriter().write("Congratulations, login successful.");
  }
}

@FormAuthenticationMechanismDefinition注解有一个必需的loginToContinue属性。此属性的值必须是@LoginToContinue注解的一个实例。@LoginToContinue有两个必需的属性:loginPageerrorPage。这些属性的值必须分别指示登录页面的路径和认证失败时显示的页面路径。

在构建和部署我们的代码后,尝试访问受保护资源时,用户将被自动重定向到我们的登录页面:

图 10.3 – 表单认证机制

图 10.3 – 表单认证机制

如果用户输入正确的凭据并且具有适当的角色,则可以访问受保护的资源,如图图 10.4所示:

图 10.4 – 表单认证成功

图 10.4 – 表单认证成功

如果输入了无效的凭据,则用户将被引导到我们的自定义错误页面,如图图 10.5所示:

图 10.5 – 表单认证失败

图 10.5 – 表单认证失败

自定义表单认证机制

我们可以在应用程序中通过使用自定义表单认证机制来认证用户。当我们要将应用程序与 Web 框架(如 Jakarta Faces)集成时,这种认证机制非常有用。在我们的下一个示例中,我们将展示如何做到这一点:通过自定义表单认证将 Jakarta EE 安全 API 与 Jakarta Faces 集成。

要在我们的应用程序中使用自定义表单认证,我们需要使用名为@CustomFormAuthenticationMechanismDefinition的注解,如下例所示:

package com.ensode.jakartaeebook.httpauthdbidentitystore;
//imports omitted for brevity
@CustomFormAuthenticationMechanismDefinition(
    loginToContinue = @LoginToContinue(
        loginPage="/faces/login.xhtml",
        errorPage=""
    )
)
@DatabaseIdentityStoreDefinition(
  //attributes omitted for brevity
)
@WebServlet("/securedServlet")
@ServletSecurity(
        @HttpConstraint(rolesAllowed = {"admin"}))
public class SecuredServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request,
      HttpServletResponse response)
      throws ServletException, IOException {
        response.getWriter().write(
          "Congratulations, login successful.");
    }
}

就像之前看到的@FormAuthenticationMechanismDefinition一样,@CustomFormAuthenticationMechanismDefinition注解有一个loginToContinue属性,它接受一个@LoginToContinue注解的实例作为其值。在这种情况下,由于我们正在与 Jakarta Faces 集成,@LoginToContinueloginPage属性值必须指向用户登录所使用的 Facelets 页面的路径。当使用 Jakarta Faces 进行用户认证时,预期登录页面会在认证失败时显示错误消息。因此,我们需要将@LoginToContinueerrorPage属性留空。

我们的登录页面是一个标准的 Facelets 页面,用于收集用户凭据并将请求重定向到一个充当控制器的 CDI bean:

<h:form>
  <h:messages/>
  <h:panelGrid columns="2">
    <h:outputLabel for="userName" value="User Name:"/>
    <h:inputText id="userName" value="#{user.userName}"/>
    <h:outputLabel for="password" value="Password: "/>
    <h:inputSecret id="password" value="#{user.password}"/>
    <h:panelGroup/>
    <h:commandButton action="#{loginController.login()}"
    value="Login"/>
  </h:panelGrid>
</h:form>

我们的登录页面有userNamepassword的输入字段,并且通过值绑定表达式将这些值存储在一个 CDI 命名 bean 中(未显示,因为它很 trivial)。当用户点击执行实际认证的loginController CDI 命名 bean 时:

package com.ensode.jakartaeebook.httpauthdbidentitystore.customauth;
//imports omitted for brevity
@Named
@RequestScoped
public class LoginController {
    @Inject
    private SecurityContext securityContext;
    @Inject
    private User user;
    public void login() {
        FacesContext facesContext = FacesContext.getCurrentInstance();
        ExternalContext externalContext = facesContext.
          getExternalContext();
        HttpServletRequest httpServletRequest =
          HttpServletRequest) externalContext.getRequest();
        HttpServletResponse httpServletResponse =
          (HttpServletResponse) externalContext.getResponse();
        UsernamePasswordCredential usernamePasswordCredential =
      new UsernamePasswordCredential(user.getUserName(),
user.getPassword());
    AuthenticationParameters authenticationParameters =
      AuthenticationParameters.withParams().credential(
      usernamePasswordCredential);
      AuthenticationStatus authenticationStatus =
        securityContext.authenticate
        (httpServletRequest, httpServletResponse,
          authenticationParameters);
    if (authenticationStatus.equals(
      AuthenticationStatus.SEND_CONTINUE)) {
        facesContext.responseComplete();
    } else if(authenticationStatus.equals(
        AuthenticationStatus.SEND_FAILURE)) {
        FacesMessage facesMessage = new FacesMessage(
          "Login error");
            facesContext.addMessage(null, facesMessage);
      }
    }
}

在我们的LoginController类中,我们需要注入一个jakarta.security.enterprise.SecurityContext的实例,因为我们将在认证中使用它。我们在login()方法中实现认证逻辑。我们需要做的第一件事是创建一个UsernamePasswordCredential的实例,将用户输入的用户名和密码作为参数传递给其构造函数。

我们通过在AuthenticationParameters上调用静态的withParams()方法来创建一个jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters的实例,然后在该实例上调用credential()方法,并将我们刚刚创建的UserNamePasswordCredential实例作为参数传递。这会返回另一个AuthenticationParameters的实例,我们可以使用它来实际验证用户输入的凭据。

我们通过在SecurityContext实例上调用authenticate()方法来验证用户输入的凭据,将 HTTP RequestResponse对象作为参数传递,以及包含用户输入凭据的AuthenticationParameters实例。这个方法调用将返回一个AuthenticationStatus的实例。我们需要检查返回的实例以确定用户是否输入了有效的凭据。

如果SecurityContext.authenticate()返回AuthenticationStatus.SEND_CONTINUE,则用户输入的凭据有效,我们可以允许用户访问请求的资源。如果该方法返回AuthenticationStatus.SEND_FAILURE,则用户输入的凭据无效,我们需要阻止用户访问受保护的资源。

在部署和运行我们的应用程序后,当用户尝试访问受保护的资源时,他们将被自动重定向到登录页面,在这种情况下,由于我们使用自定义表单认证,它是使用 Jakarta Faces 实现的。这如图图 10.6所示:

图 10.6 – 自定义表单认证

图 10.6 – 自定义表单认证

输入正确的凭证将用户引导到受保护的资源(未显示),而输入错误的凭证将用户引导回登录页面,该页面应显示如图图 10.7所示的适当错误消息。

图 10.7 – 自定义表单认证失败

图 10.7 – 自定义表单认证失败

值得注意的是,自定义表单认证足够灵活,可以与任何 Web 应用程序框架集成,尽管如本节所述,它最常与 Jakarta Faces 一起使用。

摘要

在本章中,我们介绍了 Jakarta Security API。本章讨论了以下主题:

  • 如何访问不同类型的身份存储以检索用户凭证,例如关系数据库或 LDAP 数据库

  • 安全 API 如何提供与自定义身份存储集成的能力,以防我们需要访问一个直接不支持的身份存储,以及如何使用不同的认证机制来允许访问我们的受保护 Jakarta EE 应用程序

  • 如何实现所有 Web 浏览器提供的基本认证机制

  • 如何实现基于表单的认证机制,其中我们提供用于认证的自定义 HTML 页面

  • 如何使用自定义表单认证,以便我们可以将我们的应用程序安全性与 Web 框架(如 Jakarta Faces)集成

使用 Jakarta EE 提供的安全功能,我们可以开发安全的应用程序。API 足够灵活,允许与任意数据存储进行集成,以及任何 Java Web 应用程序框架。

第十一章:Servlet 开发和部署

在本章中,我们将讨论如何开发和部署 Java EE Servlets。Servlets 允许我们作为应用开发者,在 Java Web 和企业应用中实现服务器端逻辑。

本章涵盖以下主题:

  • 什么是 Servlet?

  • 请求转发和响应重定向

  • 在请求之间持久化应用数据

  • 通过注解将初始化参数传递给 Servlet

  • Servlet 过滤器

  • Servlet 监听器

  • 可插拔性

  • 通过编程配置 Web 应用

  • 异步处理

  • HTTP/2 服务器推送支持

注意

本章的示例源代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch11_src

什么是 Servlet?

一个jakarta.servlet.GenericServlet。这个类定义了一个通用的、协议无关的 Servlet。

最常见的 Servlet 类型是 HTTP Servlet。这种类型的 Servlet 用于处理 HTTP 请求和生成 HTTP 响应。HTTP Servlet 是一个扩展了jakarta.servlet.http.HttpServlet类的类,而jakarta.servlet.http.HttpServletjakarta.servlet.GenericServlet的子类。

Servlet 必须实现一个或多个方法来响应特定的 HTTP 请求类型。这些方法是从父类HttpServlet中重写的。如表 11.1所示,这些方法的命名使得知道使用哪个方法是很直观的。

HTTP 请求 HttpServlet 方法
GET doGet(HttpServletRequest request, HttpServletResponse response)
POST doPost(HttpServletRequest request, HttpServletResponse response)
PUT doPut(HttpServletRequest request, HttpServletResponse response)
DELETE doDelete(HttpServletRequest request, HttpServletResponse response)

表 11.1 – 不同 HTTP 请求类型的 Servlet 方法

这些方法都接受相同的两个参数,即实现了jakarta.servlet.http.HttpServletRequest接口的类的实例和实现了jakarta.servlet.http.HttpServletResponse接口的类的实例。这些接口将在本章后面详细讨论。

注意

应用开发者永远不会直接调用上述方法。当应用服务器接收到相应的 HTTP 请求时,这些方法会自动被调用。

在上述四种方法中,doGet()doPost()到目前为止是最常用的。

当用户在浏览器中输入 Servlet 的 URL、点击指向 Servlet URL 的链接或用户提交使用生成 HTTP GET 方法的 HTML 表单(表单的 action 指向 Servlet 的 URL)时,都会生成一个 HTTP GET 请求。在这些情况下,Servlet 的doGet()方法中的代码会被执行。

当用户提交一个生成 HTTP POST 方法和指向 servlet URL 的操作的 HTML 表单时,通常会生成一个 HTTP POST 请求。在这种情况下,doPost()方法内部的 servlet 代码将被执行。

现在我们已经解释了 servlet 是如何工作的,让我们看看如何开发 servlet。

编写我们的第一个 servlet

在本节中,我们将开发一个简单的 servlet 来展示如何使用 servlet API。我们 servlet 的代码如下:

package com.ensode.jakartaeebook.simpleapp;
//imports omitted for brevity
@WebServlet(urlPatterns = {"/simpleservlet"})
public class SimpleServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest req,
   HttpServletResponse res) {
    try {
      res.setContentType("text/html");
      PrintWriter printWriter = res.getWriter();
      printWriter.println("<h2>");
printWriter
        .println("Hello servlet world!");
      printWriter.println("</h2>");
    } catch (IOException ioException) {
      ioException.printStackTrace();
    }
  }
}

@WebServlet注解指定我们的类是一个 servlet。它的urlPatterns属性指定了我们的 servlet 的相对 URL。

注意

Servlet 也可以通过web.xml部署描述符进行配置。然而,自从 Java EE 6 以来,基于注解的配置更受欢迎。

由于这个 servlet 旨在用户在浏览器窗口中输入其 URL 时执行,我们需要重写父HttpServlet类中的doGet()方法。正如解释的那样,此方法接受两个参数,一个是实现jakarta.servlet.http.HttpServletRequest接口的类的实例,另一个是实现jakarta.servlet.http.HttpServletResponse接口的类的实例。

注意

尽管HttpServletRequestHttpServletResponse是接口,但应用程序开发者通常不会编写实现它们的类。当控制从 HTTP 请求传递到 servlet 时,应用程序服务器提供实现这些接口的对象。

我们doGet()方法的第一件事是为HttpServletResponse对象设置内容类型为"text/html"。如果我们忘记这样做,将使用默认的内容类型"text/plain",这意味着几行以下的 HTML 标签将在浏览器上显示,而不是被解释为 HTML 标签。

然后我们通过调用HttpServletResponse.getWriter()方法来获取java.io.PrintWriter的实例。然后我们可以通过调用PrintWriter.print()PrintWriter.println()方法(前面的例子只使用println())将文本输出到浏览器。由于我们将内容类型设置为"text/html",任何 HTML 标签都将被浏览器正确解释。

测试 Web 应用程序

为了验证 servlet 是否已正确部署,我们需要将我们的浏览器指向我们的应用程序的 URL,例如,http://localhost:8080/simpleapp/simpleservlet。这样做之后,我们应该看到一个类似于图 11**.1所示的页面。

图 11.1 – 简单的 servlet 响应 HTTP GET 请求

图 11.1 – 简单的 servlet 响应 HTTP GET 请求

在这个例子中,我们只是在浏览器中显示了一些静态文本。Servlet 通常用于处理用户在 HTML 表单中输入的数据,如下一节所示。

处理 HTML 表单

Servlets 很少通过在浏览器中直接键入它们的 URL 来访问。servlet 最常见的用途是处理用户在 HTML 表单中输入的数据。在本节中,我们将说明这个过程。

包含我们应用程序表单的 HTML 文件的相应标记如下所示:

<form method="post" action="formhandlerservlet">
  <table cellpadding="0" cellspacing="0" border="0">
    <tr>
      <td>Please enter some text:</td>
      <td><input type="text" name="enteredValue" /></td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Submit"></td>
    </tr>
  </table>
</form>

表单的 action 属性的值必须与 servlet 的 urlPatterns 属性在其 @WebServlet 注解中的值匹配。由于表单的 method 属性的值是 "post",因此当表单提交时,我们的 servlet 的 doPost() 方法将被执行。

现在让我们来看看我们的 servlet 代码:

package com.ensode.jakartaeebook.formhandling;
//imports omitted for brevity
@WebServlet(urlPatterns = {"/formhandlerservlet"})
public class FormHandlerServlet extends HttpServlet {
  @Override
  protected void doPost(HttpServletRequest request,
    HttpServletResponse response) {
    String enteredValue;
    enteredValue = request.getParameter("enteredValue");
    response.setContentType("text/html");
    PrintWriter printWriter;
    try {
      printWriter = response.getWriter();
      printWriter.println("<p>");
      printWriter.print("You entered: ");
      printWriter.print(enteredValue);
      printWriter.print("</p>");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

如此例所示,我们通过调用 request.getParameter() 方法来获取用户输入的值的引用。此方法仅接受一个 String 对象作为其唯一参数。此字符串的值必须与 HTML 文件中输入字段的名称匹配。在这种情况下,HTML 文件有一个名为 "enteredValue" 的文本字段:

<input type="text" name="enteredValue" />

因此,servlet 有相应的这一行:

enteredValue = request.getParameter("enteredValue");

这是为了获取用户输入的文本并将其存储在名为 enteredValueString 变量中(变量的名称不需要与输入字段名称匹配,但这样命名是一种良好的实践,以便于记住变量所持有的值)。

在将前三个文件打包成名为 formhandling.war 的 WAR 文件后,接着部署该 WAR 文件,我们可以在浏览器中输入以下类似的 URL 来查看渲染后的 HTML 文件(确切的 URL 将取决于所使用的 Jakarta EE 应用服务器):http://localhost:8080/formhandling

HTML 表单将渲染成如图 图 11.2 所示。

图 11.2 – HTML 表单

图 11.2 – HTML 表单

用户在文本字段中输入 some text 并提交表单(无论是按 Enter 键还是点击 提交 按钮)后,我们应该看到 servlet 的输出,如图 图 11.3 所示。

图 11.3 – Servlet 表单处理

图 11.3 – Servlet 表单处理

可以使用 HttpServletRequest.getParameter() 方法来获取任何只能返回一个值的 HTML 输入字段的值(文本框、文本区域、单选、单选按钮、隐藏字段等)。获取这些字段值的过程是相同的。换句话说,servlet 不关心用户是在文本字段中键入值,还是从一组单选按钮中选择值,等等。只要输入字段的名称与传递给 getParameter() 方法的值匹配,前面的代码就会工作。

注意

当处理单选按钮时,所有相关的单选按钮必须具有相同的名称。通过调用 HttpServletRequest.getParameter() 方法并传递单选按钮的名称,将返回所选单选按钮的值。

一些 HTML 输入字段,如复选框和多个选择框,允许用户选择多个值。对于这些字段,而不是使用 HttpServletRequest.getParameter() 方法,使用 HttpServletRequest.getParameterValues() 方法。此方法也接受一个包含输入字段名称的 String 作为其唯一参数,并返回一个包含用户所选所有值的字符串数组。

以下示例说明了这种情况。我们新的 HTML 标记的相关部分如下所示:

<form method="post" action="multiplevaluefieldhandlerservlet">
<p>Please enter one or more options.</p>
<table cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td>
      <input name="options" type="checkbox" value="option1" />
      Option 1</td>
  </tr>
  <tr>
    <td><input name="options" type="checkbox" value="option2" />
    Option 2</td>
  </tr>
  <tr>
    <td><input name="options" type="checkbox" value="option3" />
    Option 3</td>
  </tr>
  <tr>
    <td><input type="submit" value="Submit" /></td>
  </tr>
</table>
</form>

新的 HTML 文件包含一个简单的表单,有三个复选框和一个提交按钮。注意每个复选框的 name 属性值都相同。正如我们之前提到的,用户点击的任何复选框都将发送到 servlet。

让我们现在看看将处理 HTML 表单的 servlet 的 doPost() 方法:

@Override
protected void doPost(HttpServletRequest request,
  HttpServletResponse response) {
  String[] selectedOptions =
    request.getParameterValues("options");
  response.setContentType("text/html");
  try {
    PrintWriter printWriter = response.getWriter();
    printWriter.println("<p>");
    printWriter.print("The following options were selected:");
    printWriter.println("<br/>");
    if (selectedOptions != null) {
      for (String option : selectedOptions) {
        printWriter.print(option);
        printWriter.println("<br/>");
      }
    } else {
      printWriter.println("None");
}
    printWriter.println("</p>");
  } catch (IOException e) {
    e.printStackTrace();
   }
}

上述代码调用 request.getParameterValues() 方法,并将返回值赋给 selectedOptions 变量。在 doPost() 方法的更下方,代码遍历 selectedOptions 数组,并在浏览器中打印所选值。

如果没有复选框被点击,request.getParameterValues() 方法将返回 null,因此在尝试遍历此方法的返回值之前检查 null 是一个好主意。

在将我们的新 servlet 打包成 WAR 文件并部署后,我们可以在浏览器窗口中输入其 URL 来看到实际的变化。在大多数应用服务器中,URL 将是 http://localhost:8080/multiplevaluefields/。表单将如图 图 11.4 所示渲染。

图 11.4 – 具有多个值字段的 HTML 表单

图 11.4 – 具有多个值字段的 HTML 表单

提交表单后,控制权转到我们的 servlet,浏览器窗口应类似于 图 11.5 中所示:

图 11.5 – Servlet 处理具有多个值的字段

图 11.5 – Servlet 处理具有多个值的字段

当然,浏览器窗口中实际看到的消息将取决于用户点击了哪些复选框。

现在我们已经看到了如何处理 HTML 表单数据,我们将关注通过 HTTP 请求转发和 HTTP 响应重定向自动导航到不同页面。

请求转发和响应重定向

在许多情况下,一个 servlet 处理表单数据,然后转移到另一个 servlet 或 JSP 进行更多处理或显示屏幕上的确认消息。有两种方法可以实现这一点:要么请求可以转发,要么响应可以重定向到另一个 servlet 或页面。

请求转发

注意,之前章节示例中显示的文本与之前页面中点击的复选框的值属性值相匹配,而不是之前页面显示的标签。这可能会让用户感到困惑。让我们修改 servlet,将这些值修改为与标签匹配,然后转发请求到另一个 servlet,该 servlet 将在浏览器中显示确认消息。

新版MultipleValueFieldHandlerServletdoPost()方法如下所示:

protected void doPost(HttpServletRequest request, HttpServletResponse response) {
  String[] selectedOptions =
    request.getParameterValues("options");
  ArrayList<String> selectedOptionLabels = null;
  if (selectedOptions != null) {
    selectedOptionLabels =
      new ArrayList<String>(selectedOptions.length);
    for (String selectedOption : selectedOptions) {
      if (selectedOption.equals("option1")) {
        selectedOptionLabels.add("Option 1");
      } else if (selectedOption.equals("option2")) {
        selectedOptionLabels.add("Option 2");
      } else if (selectedOption.equals("option3")) {
        selectedOptionLabels.add("Option 3");
      }
    }
  }
  request.setAttribute("checkedLabels", selectedOptionLabels);
  try {
    request.getRequestDispatcher("confirmationservlet").
      forward(request,response);
  } catch (ServletException | IOException e) {
    e.printStackTrace();
  }
}

这个版本的 servlet 会遍历选中的选项,并将相应的标签添加到一个字符串ArrayList中。然后通过调用request.setAttribute()方法将这个字符串附加到请求对象上。这个方法用于将任何对象附加到请求上,以便任何其他代码在转发请求后都可以访问它。

在将ArrayList附加到请求后,我们然后在以下代码行中转发请求到新的 servlet:

request.getRequestDispatcher("confirmationservlet").forward(
    request, response);

这个方法的String参数必须与目标 servlet 的@WebServlet注解中的urlPatterns标签的值相匹配。

在这个阶段,控制权转交给了我们新的 servlet。由于我们正在转发一个 HTTP POST 请求,其doPost()方法会自动被调用。以下示例展示了这个新 servlet 的代码:

package com.ensode.jakartaeebook.requestforward;
//imports omitted for brevity
@WebServlet(urlPatterns = {"/confirmationservlet"})
public class ConfirmationServlet extends HttpServlet {
  @Override
  protected void doPost(HttpServletRequest request,
    HttpServletResponse response) {
    try {
      PrintWriter printWriter;
      List<String> checkedLabels = (List<String>) request
        .getAttribute("checkedLabels");
      response.setContentType("text/html");
      printWriter = response.getWriter();
      printWriter.println("<p>");
      printWriter.print("The following options were selected:");
      printWriter.println("<br/>");
      if (checkedLabels != null) {
        for (String optionLabel : checkedLabels) {
          printWriter.print(optionLabel);
          printWriter.println("<br/>");
        }
      } else {
        printWriter.println("None");
      }
      printWriter.println("</p>");
    } catch (IOException ioException) {
      ioException.printStackTrace();
    }
  }
}

此代码通过调用request.getAttribute()方法获取之前 servlet 附加到请求上的ArrayList。这个方法的参数必须与用于将对象附加到请求的值相匹配。

一旦上述 servlet 获取到选项标签列表,它就会遍历这个列表并在浏览器中显示它们。

图 11.6 – 请求转发的实际操作

图 11.6 – 请求转发的实际操作

如前所述的请求转发仅适用于与执行转发的代码相同上下文中的其他资源(servlet 和 JSP 页面)。简单来说,我们想要转发的 servlet 或 JSP 必须打包在与调用request.getRequestDispatcher().forward()方法的代码相同的 WAR 文件中。如果我们需要将用户引导到另一个上下文中的页面(甚至另一个服务器),我们可以通过重定向响应对象来实现。

响应重定向

前一节中描述的请求转发的一个缺点是,请求只能转发到与执行转发的代码相同上下文中的其他 servlet 或 JSP。如果我们需要将用户引导到不同上下文中的页面(部署在同一服务器上的另一个 WAR 文件中或部署在不同的服务器上),我们需要使用HttpServletResponse.sendRedirect()方法。

为了说明响应重定向,让我们开发一个简单的 Web 应用程序,该程序要求用户选择他们最喜欢的搜索引擎,然后将用户引导到他们选择的搜索引擎。这个应用程序的 HTML 表单看起来如下所示:

<form method="post" action="responseredirectionservlet">
  <p>Please indicate your favorite search engine.</p>
  <table>
    <tr>
      <td><input type="radio" name="searchEngine"
            value="http://www.google.com"/>Google</td>
    </tr>
    <tr>
      <td><input type="radio" name="searchEngine"
            value="http://www.bing.com"/>Bing</td>
    </tr>
    <tr>
      <td><input type="radio" name="searchEngine"
            value="http://www.yahoo.com"/>Yahoo!</td>
    </tr>
    <tr>
      <td><input type="submit" value="Submit" /></td>
    </tr>
  </table>
</form>

上面的标记代码中的 HTML 表单包含三个单选按钮。每个按钮的值是对应用户选择的搜索引擎的 URL。注意,每个单选按钮的 name 属性值相同,即 "searchEngine"。servlet 将通过调用 request.getParameter() 方法并传递 "searchEngine" 字符串作为参数来获取所选单选按钮的值,如以下代码所示:

package com.ensode.jakartaeebook.responseredirection;
//imports omitted
@WebServlet(urlPatterns = {"/responseredirectionservlet"})
public class ResponseRedirectionServlet extends HttpServlet {
  @Override
  protected void doPost(HttpServletRequest request,
    HttpServletResponse response)
    throws IOException {
    String url = request.getParameter("searchEngine");
    if (url != null) {
      response.sendRedirect(url);
    } else {
      PrintWriter printWriter = response.getWriter();
      printWriter.println("No search engine was selected.");
    }
  }
}

通过调用 request.getParameter("searchEngine"),以下代码将所选搜索引擎的 URL 分配给 url 变量。然后(在检查 null 之后,以防用户点击了 response.sendRedirect() 并将 url 变量作为参数传递)。

在打包代码并部署后,我们可以在浏览器中通过输入类似以下 URL 的地址来查看其效果:http://localhost:8080/responseredirection/

图 11.7 – 响应重定向示例

图 11.7 – 响应重定向示例

点击提交按钮后,用户将被引导到他们喜欢的搜索引擎。

应当注意,如图所示的重定向响应会创建一个新的 HTTP 请求到我们要重定向到的页面,因此任何请求参数和属性都会丢失。

在请求之间持久化应用程序数据

在上一节中,我们看到了如何通过调用 HttpRequest.setAttribute() 方法将对象存储在请求中,以及如何通过调用 HttpRequest.getAttribute() 方法稍后检索该对象。这种方法仅在请求被转发到调用 getAttribute() 方法的 servlet 时才有效。如果不是这种情况,getAttribute() 方法将返回 null。

有可能使对象在请求之间持久化。除了将对象附加到请求对象之外,对象还可以附加到会话对象或 servlet 上下文中。这两种方法之间的区别在于,附加到会话的对象对不同的用户不可见,而附加到 servlet 上下文的对象是可见的。

将对象附加到会话和 servlet 上下文与将对象附加到请求非常相似。要将对象附加到会话,必须调用 HttpServletRequest.getSession() 方法。此方法返回一个 jakarta.servlet.http.HttpSession 实例。然后我们调用 HttpSession.setAttribute() 方法将对象附加到会话。以下代码片段说明了这个过程:

protected void doPost(HttpServletRequest request,
  HttpServletResponse response) {
  .
  .
  .
  Foo foo = new Foo(); //theoretical object
  HttpSession session = request.getSession();
  session.setAttribute("foo", foo);
  .
  .
  .
}

然后,我们可以通过调用 HttpSession.getAttribute() 方法从会话中检索对象:

protected void doPost(HttpServletRequest request, HttpServletResponse response)
{
  HttpSession session = request.getSession();
  Foo foo = (Foo)session.getAttribute("foo");
}

注意,session.getAttribute() 的返回值需要转换为适当的数据类型。这是必要的,因为此方法的返回值是 java.lang.Object

将对象附加到和从 servlet 上下文中检索对象的程序非常相似。servlet 需要调用getServletContext()方法(该方法定义在名为GenericServlet的类中,它是HttpServlet的父类,而HttpServlet又是我们 servlets 的父类)。此方法返回一个jakarta.servlet.ServletContext实例,它定义了setAttribute()getAttribute()方法。这些方法的工作方式与它们的HttpServletRequestHttpSessionResponse对应方法相同。

以下代码片段展示了将对象附加到 servlet 上下文的程序:

protected void doPost(HttpServletRequest request,
  HttpServletResponse response) {
  //The getServletContext() method is defined higher in
  //the inheritance hierarchy.
  ServletContext servletContext = getServletContext();
  Foo foo = new Foo();
  servletContext.setAttribute("foo", foo);
  .
  .
  .
}

上述代码将foo对象附加到 servlet 上下文。此对象将可用于我们应用程序中的任何 servlet,并且将在会话之间保持相同。可以通过调用ServletContext.getAttribute()方法来检索它,如下所示:

protected void doPost(HttpServletRequest request,
  HttpServletResponse response){
  ServletContext servletContext = getServletContext();
  Foo foo = (Foo)servletContext.getAttribute("foo");
  .
  .
  .
}

此代码从请求上下文中获取foo对象。同样,需要一个转换,因为ServletContext.getAttribute()方法,就像它的对应方法一样,返回一个java.lang.Object实例。

注意

附加到 servlet 上下文的对象被称为具有应用程序范围。同样,附加到会话的对象被称为具有会话范围,而附加到请求的对象被称为具有请求范围。

通过注解将初始化参数传递给 servlet

有时将一些初始化参数传递给 servlet 是有用的。这样,我们可以根据发送给它的参数使 servlet 表现不同。例如,我们可能希望配置 servlet 在开发和生产环境中表现不同。

在过去,servlet 初始化参数是通过web.xml中的<init-param>参数发送的。从 servlet 3.0 开始,初始化参数可以作为@WebServlet注解的initParams属性的值传递给 servlet。以下示例说明了如何这样做:

package com.ensode.jakartaeebook.initparams;
//imports omitted for brevity
@WebServlet(name = "InitParamsServlet", urlPatterns = {
  "/InitParamsServlet"}, initParams = {
  @WebInitParam(name = "param1", value = "value1"),
  @WebInitParam(name = "param2", value = "value2")})
public class InitParamsServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request,
          HttpServletResponse response)
          throws ServletException, IOException {
    ServletConfig servletConfig = getServletConfig();
    String param1Val = servletConfig.getInitParameter("param1");
    String param2Val = servletConfig.getInitParameter("param2");
    response.setContentType("text/html");
    PrintWriter printWriter = response.getWriter();
    printWriter.println("<p>");
    printWriter.println("Value of param1 is " + param1Val);
    printWriter.println("</p>");
    printWriter.println("<p>");
    printWriter.println("Value of param2 is " + param2Val);
    printWriter.println("</p>");
  }
}

正如我们所看到的,@WebServlet注解的initParams属性的值是一个@WebInitParam注解的数组。每个@WebInitParam注解有两个属性:name,对应于参数名称,和value,对应于参数值。

我们可以通过在jakarta.servlet.ServletConfig类上调用getInitParameter()方法来获取我们参数的值。此方法接受一个String类型的单个参数作为参数,对应于参数名称,并返回与参数值对应的String

每个 servlet 都有一个对应的ServletConfig实例分配给它。正如我们在这个示例中所看到的,我们可以通过调用getServletConfig()方法来获取这个实例,这是一个从jakarta.servlet.GenericServlet继承而来的方法,jakarta.servlet.GenericServletHttpServlet的父类,我们 servlets 扩展了这个类。

在打包和部署我们的 servlet 之后,然后指向 servlet 的 URL,我们将在浏览器中看到以下页面渲染,如图图 11.8所示。8*。

图 11.8 – Servlet 初始化参数

图 11.8 – Servlet 初始化参数

如我们所见,渲染的值对应于我们在每个@WebInitParam注解中设置的值。

现在我们已经看到了如何初始化一个 servlet,我们将把注意力转向通过 servlet 过滤器拦截 HTTP 请求。

Servlet 过滤器

doGet()doPost()方法完成,但在输出发送到浏览器之前。

在早期的 servlet 规范中配置过滤器的方法是使用web.xml中的<filter-mapping>标签。@WebFilter注解。

以下示例说明了如何进行此操作:

package com.ensode.jakartaeebook.servletfilter;
//imports omitted
@WebFilter(initParams = {
@WebInitParam(name = "filterparam1", value = "filtervalue1")},
        urlPatterns = {"/InitParamsServlet"})
public class SimpleFilter implements Filter {
  private static final Logger LOG
    = Logger.getLogger(SimpleFilter.class.getName());
  private FilterConfig filterConfig;
  @Override
  public void init(FilterConfig filterConfig) throws
    ServletException {
    this.filterConfig = filterConfig;
  }
  @Override
  public void doFilter(ServletRequest servletRequest,
    ServletResponse servletResponse, FilterChain filterChain)
    throws IOException, ServletException {
    LOG.log(Level.INFO, "Entering doFilter()");
    LOG.log(Level.INFO, "initialization parameters: ");
    Enumeration<String> initParameterNames = filterConfig.
      getInitParameterNames();
    String parameterName;
    String parameterValue;
    while (initParameterNames.hasMoreElements()) {
      parameterName = initParameterNames.nextElement();
      parameterValue =
        filterConfig.getInitParameter(parameterName);
      LOG.log(Level.INFO, "{0} = {1}",
        new Object[]{parameterName,
        parameterValue});
    }
    LOG.log(Level.INFO, "Invoking servlet...");
    filterChain.doFilter(servletRequest, servletResponse);
    LOG.log(Level.INFO, "Back from servlet invocation");
  }
  @Override
  public void destroy() {
    filterConfig = null;
  }
}

如我们在示例中看到的,@WebFilter注解有几个我们可以用来配置过滤器的属性。其中特别重要的是urlPatterns属性。此属性接受一个String对象数组作为其值。数组中的每个元素对应于我们的过滤器将拦截的 URL。在我们的示例中,我们正在拦截一个单个 URL 模式,它对应于我们在上一节中编写的 servlet。

@WebFilter注解中的其他属性包括可选的filterName属性,我们可以使用它来给我们的过滤器命名。如果我们没有为我们的过滤器指定名称,那么过滤器名称将默认为过滤器的类名。

如我们在示例 servlet 过滤器中看到的,我们可以向过滤器发送初始化参数。这就像我们向 servlet 发送初始化参数一样进行。@WebFilter注解有一个initParams属性,它接受一个@WebInitParam注解数组作为其值。我们可以通过在jakarta.servlet.FilterConfig上调用getInitParameter()方法来获取这些参数的值,如示例所示。

我们的过滤器相当简单;它只是在 servlet 被调用之前和之后向服务器日志发送一些输出。部署我们的应用程序后检查服务器日志并将浏览器指向 servlet 的 URL 应该会揭示我们的过滤器输出:

  Loading application [servletfilter] at [/servletfilter]|#]
  servletfilter was successfully deployed in 69 milliseconds.|#]
  Entering doFilter()|#]
  initialization parameters: |#]
  filterparam1 = filtervalue1|#]
  Invoking servlet…|#]
  Back from servlet invocation|#]

常见的 servlet 过滤器用途包括分析 Web 应用、应用安全性和压缩数据等。

Servlet 监听器

在典型 Web 应用的整个生命周期中,会发生许多事件,例如 HTTP 请求的创建或销毁,请求或会话属性的增加、删除或修改,等等。

servlet API 提供了一些我们可以实现以响应这些事件的监听器接口。所有这些接口都在jakarta.servlet包中。以下表格总结了它们。

监听器接口 描述
ServletContextListener 包含处理上下文初始化和销毁事件的方法。
ServletContextAttributeListener 包含用于响应在 servlet 上下文(应用范围)中添加、删除或替换的任何属性的的方法。
ServletRequestListener 包含用于处理请求初始化和销毁事件的方法。
ServletRequestAttributeListener 包含用于响应在请求中添加、删除或替换的任何属性的方法。
HttpSessionListener 包含用于处理 HTTP 会话初始化和销毁事件的方法。
HttpSessionAttributeListener 包含用于响应在 HTTP 会话中添加、删除或替换的任何属性的方法。

表 11.2 – Servlet 监听器接口

要处理前面表中描述的接口所处理的所有事件,我们只需实现其中一个接口,并使用@WebListener接口对其进行注解,或者通过web.xml部署描述符中的<listener>标签声明它。

所有前面接口的 API 相当直观和直观。我们将为其中一个接口提供一个示例。其他接口将非常相似。

注意

所有前面接口的 JavaDoc 可以在jakarta.ee/specifications/platform/10/apidocs/jakarta/servlet/package-summary找到。

以下示例说明了如何实现ServletRequestListener接口,该接口可以在创建或销毁 HTTP 请求时执行某些操作:

package com.ensode.jakartaeebook.listener;
//imports omitted
@WebListener
public class HttpRequestListener implements ServletRequestListener {
  @Override
  public void requestInitialized(
   ServletRequestEvent servletRequestEvent) {
    ServletContext servletContext = servletRequestEvent.
    getServletContext();
    servletContext.log("New request initialized");
  }
  @Override
  public void requestDestroyed(
   ServletRequestEvent servletRequestEvent) {
    ServletContext servletContext = servletRequestEvent.
    getServletContext();
    servletContext.log("Request destroyed");
  }
}

如我们所见,要激活我们的监听器类,我们只需使用@WebListener注解对其进行注解。我们的监听器还必须实现我们列出的监听器接口之一。在我们的示例中,我们选择实现jakarta.servlet.ServletRequestListener。该接口包含在初始化或销毁 HTTP 请求时自动调用的方法。

ServletRequestListener接口有两个方法,requestInitialized()requestDestroyed()。在前面的简单实现中,我们只是向日志发送了一些输出,但当然,我们可以在我们的实现中做任何我们需要做的事情。

使用我们的监听器监听本章前面开发的简单 servlet 处理请求,将在 Jakarta EE 运行时日志中产生以下输出:

Loading application [servletlistener] at [/servletlistener]|#]
  servletlistener was successfully deployed in 142 milliseconds.|#]
  New request initialized|#]
  Request destroyed|#]

实现其他监听器接口同样简单直接。

可插拔性

当原始 servlet API 在 20 世纪 90 年代末发布时,编写 servlet 是 Java 编写服务器端 Web 应用的唯一方式。从那时起,在 Servlet API 之上构建了几个 Jakarta EE 和第三方框架。这些框架的例子包括 JSP 和 JSF、Apache Struts、Apache Wicket、Spring Web MVC 以及几个其他框架。

现在,极少数(如果有的话)Java Web 应用是直接使用 Servlet API 构建的。相反,绝大多数项目都使用可用的 Java Web 应用框架之一。所有这些框架都在“幕后”使用 servlet API,因此设置一个应用以使用这些框架之一始终涉及到在应用的web.xml部署描述符中进行一些配置。在某些情况下,一些应用使用多个框架。这往往使得web.xml部署描述符相当大且难以维护。

Servlet 3.0 引入了可插拔性的概念。Web 应用框架的开发者现在有两个选择,而不是一个,以避免应用开发者必须修改web.xml部署描述符才能使用他们的框架。框架开发者可以选择使用注解而不是web.xml来配置他们的 servlet。完成此操作后,要使用框架,只需将框架开发者提供的library jar文件包含在应用的 WAR 文件中即可。或者,框架开发者可以选择将web-fragment.xml包含在 JAR 文件中,作为使用他们框架的 Web 应用的组成部分。

web-fragment.xml几乎与web.xml相同。主要区别在于web-fragment.xml的根元素是<web-fragment>,而不是<web-app>。以下是一个示例web-fragment.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-fragment 

  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
  https://jakarta.ee/xml/ns/jakartaee/web-fragment_5_0.xsd"
  version="5.0" metadata-complete="true">
  <servlet>
    <servlet-name>WebFragment</servlet-name>
    <servlet-class>
      com.ensode.jakartaeebook.webfragment.WebFragmentServlet
    </servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>WebFragment</servlet-name>
    <url-pattern>/WebFragment</url-pattern>
  </servlet-mapping>
</web-fragment>

如我们所见,web-fragment.xml几乎与典型的web.xml相同。在这个简单的示例中,我们只使用了<servlet><servlet-mapping>元素,但所有其他通常的web.xml元素,如<filter><filter-mapping><listener>也是可用的。

web-fragment.xml中指定,我们的 servlet 可以通过其 URL 模式/WebFragment被调用,因此一旦作为 Web 应用的一部分部署,执行我们的 servlet 的 URL 将是http://localhost:8080/webfragmentapp/WebFragment。当然,主机名、端口号和上下文根必须根据实际情况进行调整。

对于任何符合 Jakarta EE 规范的应用服务器,要获取web-fragment.xml中的设置,我们只需将文件放置在我们打包 servlet、过滤器以及/或监听器的库的META-INF文件夹中,然后将我们的库的jar文件放置在包含我们的应用的 WAR 文件的lib目录中。

以编程方式配置 Web 应用

除了可以通过注解和web-fragment.xml配置 Web 应用之外,Servlet 3.0 还允许我们在运行时以编程方式配置我们的 Web 应用。

ServletContext类有新的方法来以编程方式配置 servlet、过滤器(filter)和监听器(listener)。以下示例说明了如何在运行时以编程方式配置 servlet,而不需要使用@WebServlet注解或 XML:

package com.ensode.jakartaeebook.servlet;
//imports omitted
@WebListener()
public class ServletContextListenerImpl implements
  ServletContextListener {
  @Override
  public void contextInitialized(
    ServletContextEvent servletContextEvent) {
    ServletContext servletContext = servletContextEvent.
      getServletContext();
    try {
      ProgrammaticallyConfiguredServlet servlet = servletContext.
        createServlet(
          ProgrammaticallyConfiguredServlet.class);
      servletContext.addServlet(
        "ProgrammaticallyConfiguredServlet", servlet);
      ServletRegistration servletRegistration = servletContext.
        getServletRegistration(
          "ProgrammaticallyConfiguredServlet");
servletRegistration.addMapping(
        "/ProgrammaticallyConfiguredServlet");
    } catch (ServletException servletException) {
      servletContext.log(servletException.getMessage());
    }
  }
  //additional methods omitted for brevity
}

在这个例子中,我们调用ServletContextcreateServlet()方法来创建我们即将配置的 servlet。此方法接受一个与我们的 servlet 类对应的java.lang.Class实例。此方法返回一个实现jakarta.servlet.Servlet或其任何子接口的类。

一旦我们创建了我们的 servlet,我们需要在我们的ServletContext实例上调用addServlet()来将我们的 servlet 注册到 servlet 容器中。此方法接受两个参数,第一个是与 servlet 名称对应的String,第二个是由createServlet()调用返回的 servlet 实例。

一旦我们注册了我们的 servlet,我们需要向其添加 URL 映射。为了做到这一点,我们需要在我们的ServletContext实例上调用getServletRegistration()方法,并将 servlet 名称作为参数传递。此方法返回 servlet 容器对jakarta.servlet.ServletRegistration的实现。从这个对象中,我们需要调用它的addMapping()方法,传递我们希望我们的 servlet 处理的 URL 映射。

我们的示例 servlet 非常简单。它只是简单地显示一个文本消息在浏览器中。它的doGet()方法如下所示:

@Override
protected void doGet(HttpServletRequest request,
  HttpServletResponse response)
  throws ServletException, IOException {
  ServletOutputStream outputStream = response.getOutputStream();
  outputStream.println(
    "This message was generated from a servlet that was "
    + "configured programmatically.");
  }

在将我们的代码打包到 WAR 文件并部署到 Jakarta EE 运行时之后,然后指向适当的 URL(即http://localhost:8080/programmaticservletwebapp/ProgrammaticallyConfiguredServlet),我们应该在浏览器中看到以下消息:

此消息是由配置程序的 servlet 生成的

ServletContext接口有创建和添加 servlet 过滤器监听器的方法。它们的工作方式与addServlet()createServlet()方法非常相似,因此我们不会详细讨论它们。有关详细信息,请参阅jakarta.ee/specifications/platform/10/apidocs/的 Jakarta EE API 文档。

异步处理

传统上,servlet 在 Java Web 应用程序中为每个请求创建一个线程。请求处理完毕后,线程被提供给其他请求使用。这种模型对于传统 Web 应用程序来说效果相当好,因为 HTTP 请求相对较少且间隔较远。然而,大多数现代 Web 应用程序都利用了Ajax(异步 JavaScript 和 XML 的缩写),这是一种使 Web 应用程序比传统 Web 应用程序更具有响应性的技术。

Ajax 的一个副作用是生成比传统 Web 应用程序更多的 HTTP 请求。如果其中一些线程长时间阻塞等待资源就绪或进行任何需要很长时间处理的事情,那么我们的应用程序可能会遭受线程饥饿。

为了缓解前一段描述的情况,Servlet 3.0 规范引入了异步处理。使用这项新功能,我们不再受限于每个请求一个线程的限制。现在我们可以创建一个单独的线程,并将原始线程返回到线程池,以便其他客户端重用。

以下示例说明了如何利用 Servlet 3.0 中引入的新功能实现异步处理:

package com.ensode.jakartaeebook.asynchronousservlet;
//imports omitted for brevity
@WebServlet(name = "AsynchronousServlet", urlPatterns = {
  "/AsynchronousServlet"}, asyncSupported = true)
public class AsynchronousServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request,
    HttpServletResponse response) throws Exception {
    final AsyncContext ac = request.startAsync();
    ac.start(new Runnable() {
      @Override
      public void run() {
        try {
          Thread.sleep(10000);
          ac.getResponse().getWriter().
            println("You should see this after a brief wait");
          ac.complete();
        } catch (Exception ex) {
          //handle the exception
        }
      }
    });
  }
}

为了确保我们的异步处理代码按预期工作,我们首先需要将 @WebServlet 注解的 asyncSupported 属性设置为 true

要实际启动一个异步进程,我们需要在 servlet 的 doGet()doPost() 方法中作为参数接收到的 HttpServletRequest 实例上调用 startAsync() 方法。此方法返回一个 jakarta.servlet.AsyncContext 实例。这个类有一个 start() 方法,它接受一个实现 java.lang.Runnable 接口的类的实例作为唯一参数。在我们的示例中,我们使用匿名内部类在行内实现了 Runnable。当然,也可以使用实现 Runnable 的标准 Java 类,或者使用 lambda 表达式。

当我们调用 AsyncContextstart() 方法时,会创建一个新的线程,并执行 Runnable 实例的 run() 方法。这个线程在后台运行,doGet() 方法立即返回,请求线程立即可用于服务其他客户端。重要的是要注意,尽管 doGet() 方法立即返回,但响应直到创建线程的线程完成处理后才会提交。它可以通过在 AsyncContext 上调用 complete() 方法来表示处理完成。

在我们的示例中,消息 您将在短暂等待后看到此信息 在 10 秒后显示在浏览器中,这是我们创建的线程完成所需的时间。

现在我们已经了解了如何在 servlet 中执行异步处理,我们将关注如何实现 HTTP/2 服务器推送支持。

HTTP/2 服务器推送支持

HTTP/2 是 HTTP 协议的最新版本。它相较于 HTTP 1.1 提供了多项优势。例如,在 HTTP/2 中,浏览器和服务器之间只有一个连接,并且这个连接在用户导航到另一个页面之前保持开启状态。HTTP/2 还提供了多路复用功能,这意味着允许浏览器向服务器发送多个并发请求。此外,HTTP/2 还具有服务器推送功能,这意味着服务器可以在浏览器没有特别请求的情况下向浏览器发送资源。

HTTP/2 服务器推送支持在 Servlet 规范的第 4.0 版本中添加,该版本作为 Java EE 8 的一部分发布。在本节中,我们将看到如何编写代码以利用 HTTP/2 的服务器推送功能。以下示例说明了如何实现这一点:

package com.ensode.jakartaeebook.servlet;
//imports omitted
@WebServlet(name = "ServletPushDemoServlet", urlPatterns = {"/ServletPushDemoServlet"})
public class ServletPushDemoServlet extends HttpServlet {
  @Override
  protected void doPost(HttpServletRequest request,
      HttpServletResponse response) throws Exception {
    PushBuilder pushBuilder = request.newPushBuilder();
    if (pushBuilder != null) {
      pushBuilder.path("images/david_heffelfinger.png").
        addHeader("content-type", "image/png").push();
      response.sendRedirect("response.html");
    } else {
      //handle the case when the browser does not support HTTP/2.
    }
  }
}

我们可以通过在 servlet 规范的第 4 版中引入的PushBuilder接口将资源推送到浏览器。我们可以通过在doPost()方法中作为参数获得的HttpServletRequest实例上调用新的PushBuilder()方法来获取实现PushBuilder的类的实例。

如其名所示,PushBuilder接口实现了 Builder 模式,这意味着其大多数方法都返回一个新的PushBuilder实例,我们可以使用它,允许我们方便地将方法调用链式连接起来。

我们通过调用PushBuilder的相应命名的path()方法来指示我们希望推送到浏览器的资源的路径。此方法接受一个表示要推送的资源路径的单个String参数。以正斜杠("/")开头的路径表示绝对路径;所有其他路径表示相对于我们应用程序上下文根的路径。

一旦我们指定了资源的路径,我们可以选择性地设置一些 HTTP 头。在我们的例子中,我们正在推送一个 PNG 格式的图像,因此我们设置了适当的内容类型。

最后,我们在我们的PushBuilder实例上调用push()方法,实际上将我们的资源推送到浏览器。

在我们的示例中,我们完成了在浏览器提交对该资源的请求之前将其推送到浏览器的工作。在 HTTP/2 协议发布之前,这项任务是不可能的。

摘要

本章介绍了如何开发、配置、打包和部署 servlets。本章涵盖了以下主题:

  • 如何通过访问 HTTP 请求对象来处理 HTML 表单信息

  • 将 HTTP 请求从一个 servlet 转发到另一个 servlet,以及将 HTTP 响应重定向到不同的服务器

  • 通过将对象附加到 servlet 上下文和 HTTP 会话来在请求之间持久化对象

  • 通过注解配置 Web 应用程序

  • 通过web-fragment.xml实现可插拔性

  • 编程式 servlet 配置

  • 异步处理

  • HTTP/2 服务器推送

带着本章的知识,我们现在可以使用 Jakarta servlets 实现服务器端 Web 应用程序逻辑。

第十二章:Jakarta 企业 Bean

Jakarta 企业 Bean是封装应用业务逻辑的服务端组件。企业 Bean 通过自动处理事务管理和安全性来简化应用开发。有两种类型的企业 Bean:会话 Bean,执行业务逻辑,和消息驱动 Bean,作为消息监听器。

本章将涵盖以下主题:

  • 会话 Bean

  • 消息驱动 Bean

  • 企业 Bean 中的事务

  • 企业 Bean 生命周期

  • 企业 Bean 定时器服务

  • 企业 Bean 安全性

注意

本章的示例源代码可以在 GitHub 上找到:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch12_src

会话 Bean

如我们之前提到的,会话 Bean 通常封装业务逻辑。为了创建一个会话 Bean,需要创建一个或两个工件,包括 Bean 本身和一个可选的业务接口。这些工件需要适当地注解,以便让 Jakarta EE 运行时知道它们是会话 Bean。

一个简单的会话 Bean

以下示例说明了一个非常简单的会话 Bean:

package com.ensode.jakartaeebook;
import jakarta.ejb.Stateless;
@Stateless
public class SimpleSessionBean implements SimpleSession{
  private final String message =
    "If you don't see this, it didn't work!";
  @Override
  public String getMessage() {
    return message;
  }
}

@Stateless注解让 Jakarta EE 运行时知道这个类是一个无状态会话 Bean。有三种类型的会话 Bean:无状态、有状态和单例。在我们解释这些类型会话 Bean 之间的区别之前,我们需要明确一个企业 Bean 的实例是如何提供给客户端应用的。

当无状态或有状态会话 Bean 部署时,Jakarta EE 容器为每个会话 Bean 创建一系列实例。这通常被称为企业 Bean 池。当客户端应用获取一个企业 Bean 的实例时,池中的一个实例会被提供给这个客户端应用。

有状态会话 Bean 和无状态会话 Bean 之间的区别在于,有状态会话 Bean 与客户端保持对话状态,而无状态会话 Bean 则不保持。简单来说,这意味着当客户端应用获取一个有状态会话 Bean 的实例时,我们可以保证 Bean 中任何实例变量的值在方法调用之间是一致的。因此,在修改有状态会话 Bean 上的任何实例变量时是安全的,因为它们将在下一次方法调用中保留其值。Jakarta EE 运行时通过钝化有状态会话 Bean 来保存对话状态,并在 Bean 激活时检索该状态。对话状态是有状态会话 Bean 生命周期比无状态会话 Bean 或消息驱动 Bean 生命周期更复杂的原因(企业 Bean 的生命周期将在本章后面讨论)。

当客户端应用程序请求一个无状态会话 Bean 的实例时,Jakarta EE 运行时可能会在池中提供任何企业 Bean 的实例。由于我们无法保证每次方法调用都使用相同的实例,因此在无状态会话 Bean 中设置的任何实例变量可能会“丢失”(它们实际上并没有丢失;修改是在池中企业 Bean 的另一个实例中)。

除了用@Stateless注解装饰外,我们的示例类没有特别之处。请注意,它实现了一个名为SimpleSession的接口。这个接口是 Bean 的企业接口。SimpleSession接口如下所示:

package com.ensode.jakartaeebook;
import jakarta.ejb.Remote;
@Remote
public interface SimpleSession {
  public String getMessage();
}

这个接口唯一特别的地方是它被@Remote注解装饰。这个注解表示这是一个远程业务接口。这意味着接口可能位于调用它的客户端应用程序不同的 JVM 中。远程业务接口甚至可以在网络上被调用。

企业接口也可以用@Local接口装饰。这个注解表示业务接口是一个本地业务接口。本地业务接口的实现必须在与调用其方法的客户端应用程序相同的 JVM 中。

一旦我们编译了会话 Bean 及其对应的企业接口,我们需要将它们放入一个 JAR 文件中并部署它们。如何部署企业 Bean JAR 文件取决于我们使用的是哪种应用服务器。大多数现代应用服务器都有一个autodeploy目录。在大多数情况下,我们可以简单地复制我们的 JAR 文件到这个目录。请查阅您的应用服务器文档以找到其autodeploy目录的确切位置。

现在我们已经看到了会话 Bean 及其对应的企业接口,让我们看看一个客户端示例应用程序:

package com.ensode.jakartaeebook;
import jakarta.ejb.EJB;
import javax.naming.NamingException;
public class SessionBeanClient {
  @EJB
  private static SimpleSession simpleSession;
  private void invokeSessionBeanMethods() throws
    NamingException {
    System.out.println(simpleSession.getMessage());
    System.out.println("\nSimpleSession is of type: "
        + simpleSession.getClass().getName());
  }
  public static void main(String[] args)
    throws NamingException {
    new SessionBeanClient().invokeSessionBeanMethods();
  }
}

上述代码简单地声明了一个com.ensode.jakartaeebook.SimpleSession类型的实例变量,这是我们的会话 Bean 的企业接口。这个实例变量被注解为@EJB。这个注解让 Jakarta EE 运行时知道这个变量是一个会话 Bean 的企业接口。然后 Jakarta EE 运行时注入一个业务接口的实现,以便客户端代码可以使用。

由于我们的客户端是一个独立的应用程序(而不是 Jakarta EE 工件,如 WAR 文件或可部署的 JAR 文件),我们不需要部署到 Jakarta EE 运行时。为了让它能够访问服务器上部署的代码,它必须能够访问应用服务器的客户端库。如何实现这一点的步骤因应用服务器而异。当使用 GlassFish 时,我们的客户端代码必须放置在一个JAR文件中,并通过[glassfish 安装目录]/glassfish/bin/执行。假设这个目录在PATH环境变量中,并且假设我们将我们的客户端代码放置在一个名为simplesessionbeanclient.jar的 JAR 文件中,我们将在命令行中键入以下命令来执行前面的客户端代码:

appclient -client simplesessionbeanclient.jar 

执行此命令会产生以下控制台输出:

If you don't see this, it didn't work!
SimpleSession is of type: com.ensode.jakartaeebook._SimpleSession_Wrapper

这是执行SessionBeanClient类后的预期输出。

注意

我们正在使用 Maven 来构建我们的代码。在这个例子中,我们使用了 Maven Assembly Plugin (maven.apache.org/plugins/maven-assembly-plugin/) 来构建一个包含所有依赖项的客户端 JAR 文件。这使我们免去了在appclient-classpath命令行选项中指定所有依赖 JAR 文件的需要。要构建这个 JAR 文件,只需在命令行中执行mvn assembly:assembly

输出的第一行仅仅是我们在会话 Bean 中实现的getMessage()方法的返回值。第二行输出显示实现业务接口的类的完全限定名称。请注意,类名不是我们编写的会话 Bean 的完全限定名称;实际上,提供的是 Jakarta EE 运行时在幕后创建的业务接口的实现。

一个更现实的例子

在上一节中,我们看到了一个非常简单的“Hello World”类型的例子。在本节中,我们将展示一个更现实的例子。会话 Bean 通常用作数据访问对象DAO)。有时它们用作 JDBC 调用的包装器;有时它们用于包装获取或修改 Jakarta Persistence 实体的调用。在本节中,我们将采用后一种方法。

以下示例说明了如何在会话 Bean 中实现 DAO 设计模式。

现在我们来看看实现我们示例业务接口的会话 Bean。正如我们即将看到的,在会话 Bean 中实现 Jakarta Persistence 代码的方式与在普通的 Java 对象中实现的方式有一些不同:

package com.ensode.jakartaeebook;
//imports omitted for brevity
@Stateful
public class CustomerDaoBean implements CustomerDao {
  @PersistenceContext
  private EntityManager entityManager;
  @Override
  public void saveCustomer(Customer customer) {
    if (customer.getCustomerId() == null) {
      entityManager.persist(customer);
    } else {
      entityManager.merge(customer);
    }
  }
  @Override
  public Customer getCustomer(Long customerId) {
    Customer customer;
    customer = entityManager.find(Customer.class,
      customerId);
    return customer;
  }
  @Override
  public void deleteCustomer(Customer customer) {
    entityManager.remove(customer);
  }
}

值得指出的是,由于我们并不打算让我们的会话 Bean 远程调用,因此在这种情况下不需要远程业务接口。我们的客户端应用程序可以通过@EJB注解简单地注入会话 Bean 的一个实例。

如我们所见,我们的会话 Bean 实现了三个方法。saveCustomer()方法将客户数据保存到数据库,getCustomer()方法从数据库获取客户的资料,deleteCustomer()方法从数据库删除客户数据。所有这些方法都接受一个 Jakarta Persistence 实体 Bean 的实例或类型Customer

通常,当我们进行 Jakarta Persistence 调用时,我们需要通过UserTransaction.begin()UserTransaction.commit()启动和提交事务。我们需要这样做的原因是 Jakarta Persistence 调用需要被事务包装。如果没有在事务中,大多数 Jakarta Persistence 调用将抛出TransactionRequiredException。会话 Bean 方法隐式地是事务性的。我们不需要做任何事情来使它们成为那样。因此,当我们从会话 Bean 中调用 Jakarta Persistence 调用时,我们不需要手动启动和提交事务。这种默认行为被称为容器管理事务。容器管理事务将在本章后面详细讨论。

从 Web 应用程序调用会话 Bean

通常,Jakarta EE 应用程序由充当企业 Bean 客户端的 Web 应用程序组成。在本节中,我们将开发一个 Jakarta Faces Web 应用程序,其中有一个 CDI 命名 Bean 作为我们在上一节中讨论的 DAO 会话 Bean 的客户端。

为了使此应用程序充当企业 Bean 客户端,我们将开发一个名为CustomerController的 Bean,以便将保存新客户到数据库的逻辑委托给我们在上一节中开发的单例会话 Bean CustomerDaoBean,如下例所示:

package com.ensode.jakartaeebook.facesjpa;
//imports omitted for brevity
@Named
@RequestScoped
public class CustomerController implements Serializable {
  @EJB
  private CustomerDaoBean customerDaoBean;
 //variable declarations omitted for brevity
  public CustomerController() {
    customer = new Customer();
  }
  public String saveCustomer() {
    String returnValue = "customer_saved";
    try {
      populateCustomer();
      customerDaoBean.saveCustomer(customer);
    } catch (Exception e) {
      e.printStackTrace();
      returnValue = "error_saving_customer";
    }
    return returnValue;
  }
  private void populateCustomer() {
     //method implementation omitted for brevity
  }
  //getters and setters omitted for brevity
}

如我们所见,我们只需获取CustomerDaoBean会话 Bean 的一个实例,并用@EJB注解标注它,然后调用 Bean 的saveCustomer()方法。

注意,我们直接将单例 Bean 的实例注入到我们的客户端代码中。由于客户端代码与企业 Bean 运行在同一个 JVM 上,因此不需要远程接口。

现在我们已经为我们的会话 Bean 开发出了我们的 Web 应用程序客户端,我们需要将其打包成 WAR(Web 存档)文件并部署,以便使用它。

单例会话 Bean

另一种类型的会话 Bean 是单例会话 Bean。在 Jakarta EE 运行时中,每个单例会话 Bean 只有一个实例存在。

单例会话 Bean 对于缓存数据库数据很有用。在单例会话 Bean 中缓存常用数据可以提高性能,因为它大大减少了访问数据库的次数。常见的模式是在我们的 Bean 中有一个用@PostConstruct注解装饰的方法。在这个方法中,我们检索我们想要缓存的数据。然后,我们提供一个 setter 方法供 Bean 的客户端调用。以下示例说明了这种技术:

package com.ensode.jakartaeebook.singletonsession;
//imports omiktted for brevity
@Singleton
public class SingletonSessionBean implements
  SingletonSessionBeanRemote {
  @PersistenceContext
  private EntityManager entityManager;
  private List<UsStates> stateList;
  @PostConstruct
  public void init() {
    Query query = entityManager.createQuery(
      "Select us from UsStates us");
    stateList = query.getResultList();
  }
  @Override
  public List<UsStates> getStateList() {
    return stateList;
  }
}

@Singleton 注解表示我们的类是一个单例会话 bean。由于我们的 bean 是单例的,所以所有客户端都会访问同一个实例,从而避免在内存中有重复数据。此外,由于它是单例的,所以可以安全地有一个实例变量,因为所有客户端都访问同一个 bean 的实例。

异步方法调用

有时异步处理一些操作是有用的,即调用一个方法调用并立即将控制权返回给客户端,而无需客户端等待方法完成。

@Asynchronous 注解可以用来标记会话 bean 中的方法为异步。当企业 bean 客户端调用异步方法时,控制权立即返回给客户端,无需等待方法完成。

异步方法只能返回 voidjava.util.concurrent.Future 接口的实现。以下示例说明了这两种情况:

package com.ensode.jakartaeebook.asynchronousmethods;
//imports omitted for brevity
@Stateless
public class AsynchronousSessionBean implements
    AsynchronousSessionBeanRemote {
  private static Logger logger = Logger.getLogger(
    AsynchronousSessionBean.class.getName());
  @Asynchronous
  @Override
  public void slowMethod() throws InterruptedException{
    long startTime = System.currentTimeMillis();
    logger.log(Level.INFO, "entering slowMethod()");
    Thread.sleep(10000); //simulate processing for 10 seconds
    logger.log(Level.INFO, "leaving slowMethod()");
    long endTime = System.currentTimeMillis();
    logger.log(Level.INFO, "execution took {0} milliseconds",
      endTime - startTime)
  }
  @Asynchronous
  @Override
  public Future<Long> slowMethodWithReturnValue() throws
    InterruptedException{
    Thread.sleep(15000); //simulate processing for 15 seconds
    return new AsyncResult<>(42L);
  }
}

当我们的异步方法返回 void 时,我们只需要用 @Asynchronous 注解装饰方法,然后像往常一样从客户端代码中调用它。

如果我们需要返回值,这个值需要被包装在 jav.util.concurrent.Future 接口的实现中。Jakarta EE 以 jakarta.ejb.AsyncResult 类的形式提供了一个方便的实现。Future 接口和 AsyncResult 类都使用泛型。我们需要将这些工具的返回类型指定为类型参数。

Future 接口有几种方法我们可以用来取消异步方法的执行,检查方法是否已执行,获取方法的返回值,或检查方法是否已被取消。表 12.1 列出了这些方法:

方法 描述
cancel(boolean mayInterruptIfRunning) 取消方法执行。如果布尔参数为 true,则此方法将尝试取消方法执行,即使它已经在运行。
get() 将返回方法的“未包装”返回值。返回值将是方法返回的 Future 接口实现类型参数的类型。
get(long timeout, TimeUnit unit) 将尝试方法的“未包装”返回值。返回值将是方法返回的 Future 接口实现类型参数的类型。此方法将阻塞指定的时间。等待时间的单位由第二个参数确定。TimeUnit 枚举有 NANOSECONDS、MILLISECONDS、SECONDS、MINUTES 等常量。请参阅其 Javadoc 文档以获取完整列表。
isCancelled() 如果方法已被取消,则返回 true,否则返回 false
isDone() 如果方法已执行完成,则返回 true,否则返回 false

表 12.1 – 取消异步方法执行

如我们所见,@Asynchronous 注解使得在不设置消息队列或主题的开销下进行异步调用变得非常简单。

以下示例演示了如何调用异步 Jakara 企业 Bean 方法:

package com.ensode.jakarteebook.asynchronousmethodsclient;
//imports omitted for brevity
public class App {
  @EJB
  private static AsynchronousSessionBeanRemote async;
  public void invokeEjbMethods() {
    async.slowMethod();
    Future<Long> retVal
        = async.slowMethodWithReturnValue();
    if (!retVal.isDone()) {
      System.out.println("Canceling second method call");
      retVal.cancel(true);
    } else {
      try {
        System.out.println("second method call done, "
            + "return value is: " + retVal.get());
      } catch (Exception ex) {
        Logger.getLogger(App.class.getName()).
            log(Level.SEVERE, null, ex);
      }
    }
  }
}

如我们所见,调用返回 void 的异步 Jakara 企业 Bean 方法与调用常规方法没有区别。当调用返回值的函数时,事情会变得更有趣。异步调用返回一个 Future 实例。然后我们可以通过在 future 实例上调用 isDone() 来检查调用是否完成,如果它花费时间过长,可以通过调用 cancel() 来取消它,或者通过调用 get() 来从异步方法中获取值。

既然我们已经详细讨论了会话 Bean,我们将把注意力转向另一种企业 Bean,即消息驱动 Bean。

消息驱动 Bean

消息驱动 Bean 的目的是根据所使用的消息域从 Jakara 消息队列或 Jakara 消息主题中消费消息(参考第十三章)。消息驱动 Bean 必须使用 @MessageDriven 注解进行标注。此注解的 mappedName 属性必须包含 Bean 将从中消费消息的消息队列或消息主题的 Java 命名和目录接口JNDI)名称。以下示例演示了一个简单的消息驱动 Bean:

package com.ensode.jakartaeebook;
//imports omitted for brevity
@JMSDestinationDefinition(
    name = "java:global/queue/JakartaEEBookQueue",
    interfaceName = "jakarta.jms.Queue",
    destinationName = "JakartaEEBookQueue"
)
@MessageDriven(activationConfig = {
  @ActivationConfigProperty(propertyName = "destinationLookup»,
      propertyValue = "java:global/queue/JakartaEEBookQueue"),
  @ActivationConfigProperty(propertyName = "destinationType",
      propertyValue = "jakarta.jms.Queue")
})
public class ExampleMessageDrivenBean implements MessageListener {
  private static final Logger LOG = Logger.getLogger(
    ExampleMessageDrivenBean.class.getName());
  public void onMessage(Message message) {
    TextMessage textMessage = (TextMessage) message;
    try {
      LOG.log(Level.INFO, "Received message: ");
      LOG.log(Level.INFO, textMessage.getText());
    } catch (JMSException e) {
      e.printStackTrace();
    }
  }
}

@JMSDestinationDefinition 注解定义了一个 Jakara 消息队列,消息驱动 Bean 将使用它来消费消息。该队列可以是队列或主题;在我们的例子中,我们使用的是队列,因此 jakarta.jms.Queue 是注解的 interfaceName 属性的值。注解的 name 属性定义了一个 JNDI 名称,消息驱动 Bean 可以使用该名称来引用队列。

消息驱动 Bean 必须使用 @MessageDriven 注解进行装饰;它们在由 @ActivationConfigProperty 注解定义的 destinationLookup 属性中定义的队列或主题上监听消息。请注意,在我们的示例中,destinationLookup 属性的值与相应的 @JMSDestinationDefinition 注解中的 name 属性的值相匹配。在 @MessageDrivendestinationType 属性中必须指定 Jakara 消息队列的类型(jakarta.jmsQueuejakarta.jms.Topic),如我们的示例所示。

建议但不是必须要求消息驱动 Bean 实现 jakarta.jms.MessageListener 接口。然而,消息驱动 Bean 必须有一个名为 onMessage() 的方法,其签名与前面的示例相同。

客户端应用程序永远不会直接调用消息驱动 Bean 的方法。相反,它们将消息放入消息队列或主题,然后 Bean 消费这些消息并采取适当的行动。我们的示例只是将消息打印到 Jakarta EE 运行时日志中。

会话 Bean 和消息驱动 Bean 都支持事务管理,这将在以下章节中讨论。

企业 Bean 事务处理

如我们本章前面提到的,默认情况下,所有企业 Bean 方法都会自动包装在事务中。这种默认行为被称为容器管理事务,因为事务是由 Jakarta EE 运行时管理的。应用程序开发者也可以选择自己管理事务。这可以通过使用Bean 管理事务来实现。这两种方法将在以下章节中讨论。

容器管理事务

由于企业 Bean 方法默认是事务性的,当企业 Bean 方法从已经在事务中的客户端代码调用时,我们会遇到一个有趣的困境。Jakarta EE 运行时应该如何处理?它应该挂起客户端事务,在一个新的事务中执行其方法,然后恢复客户端事务?它应该不创建新事务,并将方法作为客户端事务的一部分执行?或者它应该抛出异常?

默认情况下,如果企业 Bean 方法被已经在事务中的客户端代码调用,Jakarta EE 运行时会简单地执行企业 Bean 方法作为客户端事务的一部分。如果这不是我们需要的操作,我们可以通过使用@TransactionAttribute注解来更改它。这个注解有一个value属性,它决定了当会话 Bean 方法在现有事务中调用或在外部调用时,Jakarta EE 运行时的行为。value属性的值通常是jakarta.ejb.TransactionAttributeType枚举中定义的常量。表 12.2列出了@TransactionAttribute注解的可能值:

@TransactionAttribute 值 描述
TransactionAttributeType.MANDATORY 强制方法作为客户端事务的一部分被调用。如果方法在没有任何事务的情况下被调用,它将抛出TransactionRequiredException异常。
TransactionAttributeType.NEVER 方法永远不会在事务中执行。如果方法作为客户端事务的一部分被调用,它将抛出RemoteException异常。如果方法不在客户端事务内部调用,则不会创建任何事务。
TransactionAttributeType.NOT_SUPPORTED 如果方法作为客户端事务的一部分被调用,客户端事务将被挂起。该方法将在任何事务之外执行。方法完成后,客户端事务将恢复。如果方法不是在客户端事务内部被调用,则不会创建任何事务。
TransactionAttributeType.REQUIRED 如果方法作为客户端事务的一部分被调用,该方法将作为该事务的一部分执行。如果方法在任何事务之外被调用,将为该方法创建一个新的事务。这是默认行为。
TransactionAttributeType.REQUIRES_NEW 如果方法作为客户端事务的一部分被调用,该事务将被挂起,并为该方法创建一个新的事务。一旦方法完成,客户端事务将恢复。如果方法在任何事务之外被调用,将为该方法创建一个新的事务。
TransactionAttributeType.SUPPORTS 如果方法作为客户端事务的一部分被调用,它将作为该事务的一部分执行。如果方法在事务之外被调用,不会为该方法创建新的事务。

表 12.2 – 容器管理的事务事务属性

尽管在大多数情况下默认事务属性是合理的,但能够在必要时覆盖此默认值是很好的。例如,事务有性能影响,因此能够关闭不需要事务的方法的事务是有益的。对于这种情况,我们可以在以下代码片段中注解我们的方法:|

@TransactionAttribute(value=TransactionAttributeType.NEVER)
public void doitAsFastAsPossible(){
  //performance critical code goes here.
}

其他事务属性类型可以通过在TransactionAttributeType枚举中用相应的常量注解方法来声明。

如果我们希望在会话 Bean 中的所有方法上始终一致地覆盖默认事务属性,我们可以用@TransactionAttribute注解装饰会话 Bean 类。其value属性的值将应用于会话 Bean 中的每个方法。

容器管理的事务在企业 Bean 方法内部抛出异常时自动回滚。此外,我们可以通过在对应于会话 Bean 的jakarta.ejb.EJBContext实例上调用setRollbackOnly()方法来程序化地回滚容器管理的事务。以下示例是本章前面看到的会话 Bean 的新版本,修改为在必要时回滚事务:

package com.ensode.jakartaeebook;
//imports omitted for brevity
@Stateless
public class CustomerDaoRollbackBean implements
  CustomerDaoRollback {
  @Resource
  private EJBContext ejbContext;
  @PersistenceContext
  private EntityManager entityManager;
  @Resource(name = "java:app/jdbc/customerdbDatasource")
  private DataSource dataSource;
  @Override
  public void saveNewCustomer(Customer customer) {
    if (customer == null ||
      customer.getCustomerId() != null) {
      ejbContext.setRollbackOnly();
    } else {
      customer.setCustomerId(getNewCustomerId());
      entityManager.persist(customer);
    }
  }
  //additional methods omitted for brevity
}

在这个版本的 DAO 会话 Bean 中,我们将 saveNewCustomer()updateCustomer() 方法设置为公共的。现在,这个方法会检查 customerId 字段是否为空。如果不为空,这意味着我们正在处理数据库中已经存在的客户。我们的方法还会检查要持久化的对象是否不为空。如果任何检查导致无效数据,该方法将简单地通过在注入的 EJBContext 实例上调用 setRollBackOnly() 方法来回滚事务,并且不更新数据库。

面向 Bean 管理的交易

正如我们所见,容器管理的交易使得编写被事务包裹的代码变得极其简单;毕竟,我们不需要做任何特别的事情来使它们成为这样。事实上,一些开发者在开发会话 Bean 时甚至可能没有意识到他们正在编写具有事务性的代码。容器管理的交易涵盖了我们将遇到的典型情况的大部分。然而,它们确实有一个限制。每个方法只能被单个事务包裹,或者不包裹任何事务。在容器管理的交易中,无法实现生成多个事务的方法,但可以通过使用Bean 管理的交易来完成:

package com.ensode.jakartaee;
//imports omitted
@Stateless
@TransactionManagement(value =
  TransactionManagementType.BEAN)
public class CustomerDaoBmtBean implements CustomerDaoBmt {
  @Resource private UserTransaction userTransaction;
  @PersistenceContext
  private EntityManager entityManager;
  @Resource(name = "java:app/jdbc/customerdbDatasource")
  private DataSource dataSource;
@Override
  public void saveMultipleNewCustomers(
    List<Customer> customerList)
    throws Exception {
      for (Customer customer : customerList) {
        userTransaction.begin();
        customer.setCustomerId(getNewCustomerId());
        entityManager.persist(customer);
        userTransaction.commit();
      }
    }
  //additional methods omitted for brevity
}

在这个例子中,我们实现了一个名为 saveMultipleNewCustomers() 的方法。这个方法接受一个 List 客户列表作为其唯一参数。这个方法的目的尽可能多地保存 ArrayList 中的元素。如果保存其中一个实体时抛出异常,不应该阻止方法尝试保存剩余的元素。使用容器管理的交易无法实现这种行为,因为保存其中一个实体时抛出的异常会导致整个事务回滚。实现这种行为的唯一方法是通过 Bean 管理的交易。

如示例所示,我们通过使用 @TransactionManagement 注解并使用 TransactionManagementType.BEAN 作为其 value 属性的值(此属性的另一个有效值是 TransactionManagementType.CONTAINER,但由于这是默认值,因此没有必要指定它)来声明会话 Bean 使用 Bean 管理的交易。

为了能够以编程方式控制事务,我们注入了一个 jakarta.transaction.UserTransaction 实例,然后在 saveMultipleNewCustomers() 方法内的 for 循环中使用它来开始和提交每个循环迭代的事务。

如果我们需要回滚一个 Bean 管理的交易,我们可以通过在适当的 jakarta.transaction.UserTransaction 实例上调用 rollback() 方法来实现。

在继续之前,值得注意的是,尽管本节中的所有示例都是会话 Bean,但所解释的概念也适用于消息驱动的 Bean。

我们现在将关注企业 bean 的生命周期。

企业 bean 生命周期

企业 bean 在其生命周期中会经历不同的状态。每种类型的企业 bean 都有不同的状态。以下各节将讨论每种类型企业 bean 特有的状态。

有状态会话 bean 生命周期

我们可以在会话 bean 中注解方法,以便 Jakarta EE 运行时在 bean 生命周期的特定点自动调用这些方法。例如,我们可以在 bean 创建后立即调用一个方法,或者在其销毁前调用。

在解释可用于实现生命周期方法的注解之前,有必要简要说明会话 bean 的生命周期。有状态会话 bean 的生命周期与无状态或单例会话 bean 的生命周期不同。

有状态会话 bean 生命周期包含三个状态:不存在就绪钝化,如图12.1所示。

图 12.1 – 有状态会话 bean 生命周期

图 12.1 – 有状态会话 bean 生命周期

在有状态会话 bean 部署之前,它处于不存在状态。部署成功后,Jakarta EE 运行时会对 bean 进行任何必要的依赖注入,并将其进入就绪状态。此时,bean 准备好被客户端应用程序调用其方法。

当有状态会话 bean 处于就绪状态时,Jakarta EE 运行时可能会决定将其钝化,即将其从主内存移动到二级存储。当这种情况发生时,bean 进入钝化状态。

如果一个有状态会话 bean 在一段时间内未被访问,Jakarta EE 运行时会将该 bean 设置为不存在状态。bean 在被销毁前在内存中停留的时间因应用服务器而异,通常是可以配置的。

在有状态会话 bean 中注解了@PostActivate的方法将在有状态会话 bean 激活后立即被调用。同样,注解了@PrePassivate的方法将在有状态会话 bean 钝化前被调用。

当一个处于@PreDestroy注解状态的有状态会话 bean 被执行时。如果会话 bean 处于@PreDestroy注解状态而没有被执行。此外,如果有状态会话 bean 的客户端执行了任何带有@Remove注解的方法,任何带有@PreDestroy注解的方法将被执行,并且 bean 将被标记为垃圾回收。

@PostActivate@PrePassivate@Remove注解仅适用于有状态会话 bean。@PreDestroy@PostConstruct注解适用于有状态会话 bean、无状态会话 bean 和消息驱动 bean。

无状态和单例会话 bean 生命周期

无状态或单例会话 bean 的生命周期只包含不存在就绪状态,如图图 12.2所示。

图 12.2 – 无状态和单例会话 bean 的生命周期

图 12.2 – 无状态和单例会话 bean 的生命周期

无状态和单例会话 bean 永远不会钝化。无状态或单例会话 bean 的方法可以被@PostConstruct@PreDestroy注解装饰。就像在有状态会话 bean 中一样,任何被@PostConstruct注解装饰的方法将在会话 bean 从@PreDestroy注解转换时执行,而在无状态会话 bean 中,从@PrePassivate@PostActivate注解转换时,这些方法将被 Jakarta EE 运行时简单地忽略。

大多数 Jakarta EE 运行时允许我们配置在销毁空闲的无状态或单例会话 bean 之前需要等待多长时间。

消息驱动 bean 的生命周期

就像无状态会话 bean 一样,消息驱动 bean 只包含不存在就绪状态,如图图 12.3所示。

图 12.3 – 消息驱动 bean 的生命周期

图 12.3 – 消息驱动 bean 的生命周期

消息驱动 bean 可以有被@PostConstruct@PreDestroy方法装饰的方法。被@PostConstruct装饰的方法将在 bean 执行到@PreDestroy注解之前执行,而被@PreDestroy注解的方法将在 bean 进入不存在状态之前执行。

现在我们已经涵盖了企业 bean 的生命周期,我们将把注意力转向另一个企业 bean 特性,即企业 bean 计时器服务。

企业 bean 计时器服务

无状态会话 bean 和消息驱动 bean 可以有一个在固定时间间隔定期执行的方法。这可以通过使用企业 bean 计时器服务来实现。以下示例说明了如何利用这个特性:

package com.ensode.jakartaeebook;
//imports omitted
@Stateless
public class JebTimerExampleBean implements
  JebTimerExample
  private static final Logger LOG =
    Logger.getLogger(JebTimerExampleBean.class.getName());
  @Resource
  TimerService timerService;
  @Override
  public void startTimer(Serializable info) {
    timerService.createTimer(new Date(), 5000, info);
  }
  @Override
  public void stopTimer(Serializable info) {
    Collection<Timer> timers = timerService.getTimers();
    timers.stream().filter(t -> t.getInfo().equals(info)).
      forEach(t -> t.cancel());
  }
  @Timeout
  @Override
  public void logMessage(Timer timer) {
    LOG.log(Level.INFO, "Message triggered by :{0} at {1}",
      new Object[]{timer.getInfo(),
        System.currentTimeMillis()});
  }
}

在这个例子中,我们通过使用@Resource注解来注解这个类型的实例变量,从而注入jakarta.ejb.TimerService接口的实现。然后我们可以通过调用这个TimerService实例的createTimer()方法来创建一个计时器。

createTimer()方法有几个重载版本。我们选择使用的方法接受一个java.util.Date实例作为其第一个参数。此参数用于指示计时器应该首次到期的第一个时间(“触发”)。在示例中,我们选择使用一个新的Date类实例,这实际上使得计时器立即到期。createTimer()方法的第二个参数是在计时器再次到期之前要等待的时间,以毫秒为单位。在我们的示例中,计时器每五秒到期一次。createTimer()方法的第三个参数可以是实现java.io.Serializable接口的任何类的实例。由于单个企业 Bean 可以同时执行多个计时器,因此此第三个参数用于唯一标识每个计时器。如果我们不需要标识计时器,则可以将 null 作为此参数的值。

我们可以通过调用其cancel()方法来停止计时器。没有直接获取与企业 Bean 相关联的单个计时器的方法。我们需要做的是在TimerService实例上调用getTimers()方法,该实例与企业 Bean 相关联。此方法将返回一个包含与企业 Bean 相关联的所有计时器的集合。然后我们可以从集合中获取一个流,过滤它,使其只包含具有所需getInfo()值的元素,然后对匹配的计时器调用cancel()

最后,任何带有@Timeout注解的企业 Bean 方法将在计时器到期时执行。带有此注解的方法必须返回void并接受一个类型为jakarta.ejb.Timer的单个参数。在我们的示例中,该方法只是将一条消息写入服务器日志。

以下类是我们示例企业 Bean 的独立客户端:

package com.ensode.jakartaeebook;
import jakarta.ejb.EJB;
public class Client {
  @EJB
  private static JebTimerExample jebTimerExample;
  public static void main(String[] args) {
    try {
      jebTimerExample.startTimer("Timer 1");
      Thread.sleep(2000);
      jebTimerExample.startTimer("Timer 2");
      Thread.sleep(30000);
      jebTimerExample.stopTimer("Timer 1");
      jebTimerExample.stopTimer("Timer 2");
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

示例只是启动一个计时器,等待几秒钟,然后启动第二个计时器。然后它睡眠 30 秒,然后停止两个计时器。在部署企业 Bean 并执行客户端后,我们应该在服务器日志中看到一些类似条目的条目:

This message was triggered by :Timer 1 at 1,699,468,776,716|#]
This message was triggered by :Timer 2 at 1,699,468,778,762|#]
This message was triggered by :Timer 1 at 1,699,468,781,716|#]
This message was triggered by :Timer 2 at 1,699,468,783,762|#]
This message was triggered by :Timer 1 at 1,699,468,786,716|#]
This message was triggered by :Timer 2 at 1,699,468,788,762|#]

这些条目在计时器每次到期时创建。

除了在本节示例中看到的使用编程方式启动计时器之外,我们还可以通过@Schedule注解来安排我们的计时器,该注解使用基于日历的表达式来安排企业 Bean 计时器。

基于日历的企业 Bean 计时器表达式

上一节中的示例有一个缺点:会话 Bean 中的startTimer()方法必须从客户端调用才能启动计时器。这种限制使得计时器在 Bean 部署后立即启动变得困难。

Java EE 6 引入了基于日历的企业 bean 定时器表达式。基于日历的表达式允许我们的会话 bean 中的一个或多个方法在特定的日期和时间执行。例如,我们可以配置我们的一个方法在每晚 8:10 p.m.执行,这正是我们下一个示例所做的事情:

package com.ensode.javaee8book.calendarbasedtimer;
//imports omitted for brevity
@Stateless
public class CalendarBasedTimerJebExampleBean {
  private static Logger logger = Logger.getLogger(
      CalendarBasedTimerJebExampleBean.class.getName());
  @Schedule(hour = "20", minute = "10")
  public void logMessage() {
    logger.log(Level.INFO,
      "This message was triggered at:{0}",
      System.currentTimeMillis());
  }
}

正如这个示例所示,我们通过jakarta.ejb.Schedule注释设置方法执行的时间。在这个特定的例子中,我们通过将@Schedule注释的hour属性设置为"20",并将其分钟属性设置为"10"(小时属性的值是基于 24 小时制的;小时 20 相当于下午 8:00)来设置方法在晚上 8:10 p.m.执行。

@Schedule注释有几个其他属性,这允许我们在指定方法何时执行时具有很大的灵活性。例如,我们可以让一个方法在每月的第三个周五、月底等执行。

下表列出了@Schedule注释中所有允许我们控制被注释方法执行时间的属性:

属性 描述 示例值 默认值
dayOfMonth 月份中的某一天 “3”:月份的第三天“Last”:月底“-2”:月底前两天“1st Tue”:月份的第一个周二 “*”
dayOfWeek 一周中的某一天 “3”:每周三“Thu”:每周四 “*”
hour 一天中的小时(基于 24 小时制) “14”:下午 2:00 “0”
minute 小时中的分钟 “10”:小时后的 10 分钟 “0”
month 年中的月份 “2”:二月“March”:三月 “*”
second 分钟中的秒 “5”:分钟后的 5 秒 “0”
timezone 时区 ID “America/New York” “”
year 四位年份 “2010” “*”

表 12.3 – @Schedule 注释属性

除了单个值之外,大多数属性接受星号(“*”)作为通配符,这意味着被注释的方法将在每个时间单位(每天、每小时等)执行。

此外,我们可以通过逗号分隔值来指定多个值。例如,如果我们需要一个方法在每周二和周四执行,我们可以将方法注释为@Schedule(dayOfWeek="Tue, Thu")

我们还可以指定一个值的范围。第一个值和最后一个值由破折号(-)分隔。要执行从周一到周五的方法,我们可以使用@Schedule(dayOfWeek="Mon-Fri")

此外,我们还可以指定方法需要每n个时间单位(例如,每天、每 2 小时或每 10 分钟)执行。要执行类似的操作,我们可以使用@Schedule(hour="*/12"),这将使方法每 12 小时执行一次。

如我们所见,@Schedule 注解在指定何时需要执行我们的方法方面提供了很多灵活性。此外,它还提供了不需要客户端调用即可激活调度的优势。此外,它还具有使用类似于 cron 的语法的优势,因此熟悉该 Unix 工具的开发者将感到使用此注解非常得心应手。

本章将要涵盖的最后一个主题是企业 Bean 安全。

企业 Bean 安全

企业 Bean 允许我们声明性地决定哪些用户可以访问其方法。例如,某些方法可能仅供具有特定角色的用户使用。一个典型的场景是,只有具有管理员角色的用户可以添加、删除或修改系统中的其他用户。

以下示例是本章前面看到的 DAO 会话 Bean 的略微修改版本。在这个版本中,一些之前是私有的方法已被改为公共。此外,会话 Bean 已被修改,以允许只有具有特定角色的用户访问其方法:

package com.ensode.jakartaeebook;
//imports omitted for brevity
@Stateless
@RolesAllowed("admin")
public class SecureCustomerDaoBean {
  @PersistenceContext
  private EntityManager entityManager;
  public Long saveCustomer(Customer customer) {
    if (customer.getCustomerId() == null) {
      entityManager.persist(customer);
    } else {
      entityManager.merge(customer);
    }
    return customer.getCustomerId();
  }
  @RolesAllowed({"user", "admin"})
  public Customer getCustomer(Long customerId) {
    Customer customer;
    customer = entityManager.find(Customer.class,
      customerId);
    return customer;
  }
  public void deleteCustomer(Customer customer) {
    entityManager.remove(customer);
  }
}

如我们所见,我们通过使用 @RolesAllowed 注解来声明哪些角色可以访问方法。此注解可以接受单个 StringString 数组作为参数。当使用单个 String 作为此注解的参数时,只有参数指定的角色的用户可以访问方法。如果使用 String 数组作为参数,则具有数组元素中指定的任何角色的用户都可以访问方法。

@RolesAllowed 注解可以在类级别使用,在这种情况下,其值适用于企业 Bean 中的所有方法。它也可以在方法级别使用,在这种情况下,其值仅适用于被注解装饰的方法。如果,像我们的例子一样,类及其一个或多个方法都被 @RolesAllowed 注解装饰,则方法级别的注解具有优先级。

在我们的例子中,只有具有“admin”角色的用户可以保存或更新客户数据,管理员和用户都可以检索客户数据。

调用受保护的企业 Bean 的客户端必须经过身份验证(请参阅第十章)并且必须具有适当的角色。

摘要

本章介绍了如何通过无状态和有状态会话 Bean 实现业务逻辑。此外,还介绍了如何实现消息驱动 Bean 以消费 Jakarta 消息消息。

本章涵盖了以下主题:

  • 如何利用企业 Bean 的事务性来简化实现 DAO 模式

  • 容器管理的交易以及如何使用适当的注解控制交易

  • 对于容器管理的交易不足以满足我们要求的情况,使用 Bean 管理的交易

  • 不同类型的企业 Java Bean 的生命周期,包括解释如何使企业 Bean 方法在生命周期中的特定点自动由 Jakarta EE 运行时调用

  • 如何利用定时器服务使企业 Bean 方法定期被运行时调用

  • 如何通过在企业 Bean 类和/或方法上使用适当的安全注解来确保企业 Bean 方法仅被授权用户调用

正如我们在本章中看到的,Jakarta Enterprise Beans 负责一些企业需求,例如事务和安全,这使我们作为应用程序开发者免于实现它们,并允许我们专注于实现业务逻辑。

第十三章:Jakarta Messaging

Jakarta Messaging为 Jakarta EE 应用程序之间发送消息提供了一种机制。Jakarta Messaging 应用程序不直接通信;相反,消息生产者将消息发送到目的地,而消息消费者从目的地接收消息。

当使用点对点PTP)消息域时,消息目的地是消息队列,或者当使用发布/订阅pub/sub)消息域时,消息目的地是消息主题。

在本章中,我们将介绍以下主题:

  • 与消息队列一起工作

  • 与消息主题一起工作

注意

本章的示例源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch13_src

与消息队列一起工作

如我们之前提到的,当我们的 Jakarta Messaging 代码使用 PTP 消息域时,会使用消息队列。对于 PTP 消息域,通常有一个消息生产者和一个消息消费者。消息生产者和消息消费者不需要同时运行以进行通信。消息生产者放入消息队列中的消息将保持在消息队列中,直到消息消费者执行并从队列中请求这些消息。

向消息队列发送消息

以下示例说明了如何向消息队列添加消息:

package com.ensode.jakartaeebook.ptpproducer;
//imports omitted for brevity
@JMSDestinationDefinition(
    name = "java:global/queue/JakartaEEBookQueue",
    interfaceName = "jakarta.jms.Queue"
)
@Named
@RequestScoped
public class MessageSender {
  @Resource
  private ConnectionFactory connectionFactory;
  @Resource(mappedName = "java:global/queue/JakartaEEBookQueue")
  private Queue queue;
  public void produceMessages() {
    JMSContext jmsContext = connectionFactory.createContext();
    JMSProducer jmsProducer = jmsContext.createProducer();
    String msg1 = "Testing, 1, 2, 3\. Can you hear me?";
    String msg2 = "Do you copy?";
    String msg3 = "Good bye!";
    jmsProducer.send(queue, msg1);
    jmsProducer.send(queue, msg2);
    jmsProducer.send(queue, msg3);
  }
}

注意

本章中的大多数示例都是作为上下文和依赖注入CDI)豆实现的。有关 CDI 的解释,请参阅第二章

类级别的@JMSDestinationDefinition注解定义了一个 Jakarta Messaging 目的地,我们的消息将被放置在这里。此注解有两个必需的属性,nameinterfaceName@JMSDestinationDefinitionname属性定义了一个interfaceName,它指定了 Jakarta Messaging 目的地接口;对于 PTP 消息,此值必须始终是produceMessages()方法,该方法从使用 Facelets 实现的 Jakarta Faces 页面上的commandButton调用前面的类。为了简洁,我们不会显示此页面的 XHTML 标记。本章的代码下载包包含完整的示例。

MessageSender类中的produceMessages()方法执行所有必要的步骤以将消息发送到消息队列。

此方法首先执行的操作是通过在注入的jakarta.jms.ConnectionFactory实例上调用createContext()方法来创建一个jakarta.jms.JMSContext实例。请注意,装饰连接工厂对象的@Resource注解的mappedName属性与@JMSDestinationDefinition注解的name属性相匹配。在幕后,使用此名称进行 JNDI 查找以获取连接工厂对象。

接下来,我们通过在刚刚创建的JMSContext实例上调用createProducer()方法来创建jakarta.jms.JMSProducer实例。

在获取JMSProducer实例后,代码通过调用其send()方法发送一系列文本消息。此方法将消息目的地作为其第一个参数,将包含消息文本的String作为其第二个参数。

JMSProducer中,send()方法有几个重载版本。我们在示例中使用的是一种方便的方法,它创建jakarta.jms.TextMessage实例并将其文本设置为方法调用中提供的第二个参数的String

虽然前例只向队列发送文本消息,但我们并不局限于这种消息类型。Jakarta Messaging 提供了多种类型的消息,这些消息可以被 Jakarta Messaging 应用程序发送和接收。所有消息类型都在jakarta.jms包中定义为接口。表 13.1列出了所有可用的消息类型:

消息类型 描述
BytesMessage 允许发送字节数组作为消息。JMSProducer有一个方便的send()方法,它将字节数组作为其参数之一。此方法在发送消息时即时创建jakarta.jms.BytesMessage实例。
MapMessage 允许发送实现java.util.Map接口的消息。JMSProducer有一个方便的send()方法,它将Map作为其参数之一。此方法在发送消息时即时创建jakarta.jms.MapMessage实例。
ObjectMessage 允许发送实现java.io.Serializable接口的任何 Java 对象作为消息。JMSProducer有一个方便的send()方法,它将实现java.io.Serializable接口的类的实例作为其第二个参数。此方法在发送消息时即时创建jakarta.jms.ObjectMessage实例。
StreamMessage 允许发送字节数组作为消息。与BytesMessage不同,它存储添加到流中的每个原始类型的类型。
TextMessage 允许发送java.lang.String作为消息。如前例所示,JMSProducer有一个方便的send()方法,它将String作为其第二个参数。此方法在发送消息时即时创建jakarta.jms.TextMessage实例。

表 13.1 – Jakarta Messaging 消息类型

注意

关于所有上述消息类型的更多信息,请参阅jakarta.ee/specifications/messaging/3.0/apidocs/jakarta/jms/package-summaryJavaDoc文档。

从消息队列中检索消息

当然,如果没有任何接收者,从队列中发送消息是没有意义的。以下示例说明了如何从消息队列中检索消息:

package com.ensode.jakartaeebook.ptpconsumer;
//imports omitted for brevity
@JMSDestinationDefinition(
  name = "java:global/queue/JakartaEEBookQueue",
  interfaceName = "jakarta.jms.Queue"
)
@Named
@RequestScoped
public class MessageReceiver implements Serializable {
  @Resource
  private ConnectionFactory connectionFactory;
  @Resource(mappedName =
    "java:global/queue/JakartaEEBookQueue")
private Queue queue;
  private static final Logger LOG =
    Logger.getLogger(MessageReceiver.class.getName());
  public void receiveMessages() {
    String message;
    boolean goodByeReceived = false;
    JMSContext jmsContext = connectionFactory.
      createContext();
    JMSConsumer jMSConsumer =
      jmsContext.createConsumer(queue);
    while (!goodByeReceived) {
      message = jMSConsumer.receiveBody(String.class);
      LOG.log(Level.INFO, "Received message: {0}", message);
      if (message.equals("Good bye!")) {
        goodByeReceived = true;
      }
    }
  }
}

就像在之前的示例中一样,我们通过@JMSDestinationDefinition注解定义了一个目的地,并且通过使用@Resource注解注入了jakarta.jms.ConnectionFactoryjakarta.jms.Queue的实例。

在我们的代码中,我们通过调用ConnectionFactorycreateContext()方法来获取jakarta.jms.JMSContext的实例,就像在之前的示例中一样。

在这个示例中,我们通过在我们的JMSContext实例上调用createConsumer()方法来获取jakarta.jms.JMSConsumer的实例。

通过在我们的JMSConsumer实例上调用receiveBody()方法来接收消息。此方法只接受我们期望的消息类型作为其唯一参数(在我们的示例中为String.class)。此方法返回其参数指定的类型的对象(在我们的示例中为java.lang.String的实例)。一旦消息被JMSConsumer.receiveBody()消费,它就会从队列中移除。

在这个特定的例子中,我们将这个方法调用放在了一个while循环中,因为我们期望一个消息会告诉我们没有更多的消息到来。具体来说,我们正在寻找包含文本“再见!”的消息。一旦我们收到这个消息,我们就跳出循环并继续处理。在这个特定的案例中,没有更多的处理要做,因此执行在跳出循环后结束。

执行代码后,我们应该在服务器日志中看到以下输出:

Waiting for messages...
Received the following message: Testing, 1, 2, 3\. Can you hear me?
Received the following message: Do you copy?
Received the following message: Good bye!

当然,这假设之前的示例已经执行,并且它已经将消息放入了消息队列。

注意

本节讨论的消息处理的一个缺点是消息处理是同步的。在 Jakarta EE 环境中,我们可以通过使用消息驱动豆(如第十二章中讨论的那样)来异步处理消息。

浏览消息队列

Jakarta Messaging 提供了一种方法来浏览消息队列,而实际上并不从队列中移除消息。以下示例说明了如何做到这一点:

package com.ensode.jakartaeebook.queuebrowser;
//imports omitted for brevity
//Messaging destination definition annotation omitted
@Named
@RequestScoped
public class MessageQueueBrowser {
  @Resource
  private ConnectionFactory connectionFactory;
  @Resource(mappedName =
    "java:global/queue/JakartaEEBookQueue")
  private Queue queue;
  private static final Logger LOG =
    Logger.getLogger(MessageQueueBrowser.class.getName());
  public void browseMessages() throws JMSException {
    Enumeration messageEnumeration;
    TextMessage textMessage;
    JMSContext jmsContext =
      connectionFactory.createContext();
    QueueBrowser browser = jmsContext.createBrowser(queue);
    messageEnumeration = browser.getEnumeration();
    LOG.log(Level.INFO, "messages in the queue:");
    while (messageEnumeration.hasMoreElements()) {
      textMessage = (TextMessage) messageEnumeration.
        nextElement();
      LOG.log(Level.INFO, textMessage.getText());
    }
  }
}

如我们所见,浏览消息队列的流程非常直接。我们以通常的方式获取连接工厂、队列和上下文,然后在上下文对象上调用createBrowser()方法。此方法返回jakarta.jms.QueueBrowser接口的实现。此接口包含一个getEnumeration()方法,我们可以调用它来获取包含队列中所有消息的Enumeration。要检查队列中的消息,我们只需遍历这个枚举并逐个获取消息。在我们讨论的示例中,我们简单地调用队列中每个消息的getText()方法。

现在我们已经看到了如何使用 PTP 消息域发送和接收队列中的消息,我们将把注意力集中在使用 pub/sub 消息域发送和接收消息主题上的消息。

与消息主题一起工作

当我们的 Jakarta Messaging 代码使用 pub/sub 消息域时,会使用消息主题。当使用此消息域时,相同的消息可以发送到主题的所有订阅者。

向消息主题发送消息

以下示例说明了如何向消息主题发送消息:

package com.ensode.jakartaeebook.pubsubproducer;
//imports omitted
@JMSDestinationDefinition(
    name = "java:global/topic/JakartaEEBookTopic",
    interfaceName = "jakarta.jms.Topic"
)
@Named
@RequestScoped
public class MessageSender {
  @Resource
  private ConnectionFactory connectionFactory;
  @Resource(mappedName =
    "java:global/topic/JakartaEEBookTopic")
  private Topic topic;
  public void produceMessages() {
    JMSContext jmsContext =
      connectionFactory.createContext();
    JMSProducer jmsProducer = jmsContext.createProducer();
    String msg1 = "Testing, 1, 2, 3\. Can you hear me?";
    String msg2 = "Do you copy?";
    String msg3 = "Good bye!";
    jmsProducer.send(topic, msg1);
    jmsProducer.send(topic, msg2);
    jmsProducer.send(topic, msg3);
  }
}

如我们所见,前面的代码几乎与我们在讨论 PTP 消息时看到的MessageSender类相同。Jakarta Messaging 被设计成可以使用相同的 API 来处理 PTP 和 pub/sub 域。

由于本例中的代码几乎与使用消息队列部分中的相应示例相同,我们只解释两个示例之间的差异。在这种情况下,@JMSDestinationDefinitionname属性值为jakarta.jms.Topic,这是在使用 pub/sub 消息域时所需的。此外,我们不是声明一个实现jakarta.jms.Queue类的实例,而是声明一个实现jakarta.jms.Topic类的实例。然后,我们将这个jakarta.jms.Topic的实例作为JMSProducer对象的send()方法的第一参数传递,同时传递我们希望发送的消息。

从消息主题接收消息

正如向消息主题发送消息几乎与向消息队列发送消息相同一样,从消息主题接收消息几乎与从消息队列接收消息相同:

package com.ensode.jakartaeebook.pubsubconsumer;
//imports omitted
@JMSDestinationDefinition(
    name = "java:global/topic/JakartaEEBookTopic",
    interfaceName = "jakarta.jms.Topic"
)
@Named
@RequestScoped
public class MessageReceiver {
  @Resource
  private ConnectionFactory connectionFactory;
  @Resource(mappedName = "java:global/topic/JakartaEEBookTopic")
  private Topic topic;
  private static final Logger LOG =
    Logger.getLogger(MessageReceiver.class.getName());
  public void receiveMessages() {
    String message;
    boolean goodByeReceived = false;
    JMSContext jmsContext = connectionFactory.createContext();
    JMSConsumer jMSConsumer = jmsContext.createConsumer(topic);
    while (!goodByeReceived) {
      message = jMSConsumer.receiveBody(String.class);
      LOG.log(Level.INFO, "Received message: {0}", message);
      if (message.equals("Good bye!")) {
        goodByeReceived = true;
      }
    }
  }
}

再次强调,此代码与 PTP 对应代码之间的差异微乎其微。我们不是声明一个实现jakarta.jms.Queue类的实例,而是声明一个实现jakarta.jms.Topic类的实例。我们使用@Resource注解将此类的实例注入到我们的代码中,使用我们在配置应用程序服务器时使用的 JNDI 名称。然后,我们像以前一样获取JMSContextJMSConsumer的实例,然后通过在JMSConsumer上调用receiveBody()方法来接收来自主题的消息。

如本节所示,使用 pub/sub 消息域的优点是消息可以发送到多个消息消费者。这可以通过同时执行本节中开发的MessageReceiver类的两个实例,然后执行上一节中开发的MessageSender类来轻松测试。我们应该看到每个实例的控制台输出,表明两个实例都接收到了所有消息。

创建持久订阅者

使用 pub/sub 消息域的缺点是,消息消费者必须在消息发送到主题时正在执行。如果消息消费者在那时没有执行,它将不会收到消息,而在 PTP 中,消息将保留在队列中,直到消息消费者执行。幸运的是,Jakarta Messaging 提供了一种方法,可以在 pub/sub 消息域中使用并保留消息在主题中,直到所有已订阅的消息消费者执行并接收消息。这可以通过创建对消息主题的持久订阅者来实现。

为了能够服务持久的订阅者,我们需要设置我们的 Jakarta Messaging 连接工厂的clientId属性。每个持久的订阅者都必须有一个唯一的客户端 ID,因此必须为每个潜在的持久订阅者声明一个唯一的连接工厂。

我们可以使用@JMSConnectionFactoryDefinition注解设置我们的连接工厂的clientId属性,如下面的示例所示:

package com.ensode.jakartaeebook.pubsubdurablesubscriber;
//imports omitted for brevity
@JMSConnectionFactoryDefinition(
    name = "java:global/messaging/DurableConnectionFactory",
    clientId = "DurableConnectionFactoryClientId"
)
//Messaging destination definition annotation omitted
@Named
@ApplicationScoped
public class MessageReceiver {
  @Resource(mappedName =
   "java:global/messaging/DurableConnectionFactory")
  private ConnectionFactory connectionFactory;
  @Resource(mappedName =
    "java:global/topic/JakartaEEBookTopic")
  private Topic topic;
  private static final Logger LOG =
   Logger.getLogger(MessageReceiver.class.getName());
  public void receiveMessages() {
    String message;
    boolean goodByeReceived = false;
    JMSContext jmsContext =
      connectionFactory.createContext();
    JMSConsumer jMSConsumer =
      jmsContext.createDurableConsumer(topic,"Subscriber1");
    while (!goodByeReceived) {
      message = jMSConsumer.receiveBody(String.class);
      LOG.log(Level.INFO, "Received message: {0}", message);
      if (message.equals("Good bye!")) {
        goodByeReceived = true;
      }
    }
  }
}

如我们所见,前面的代码与之前用于检索消息的示例没有太大区别:与之前的示例相比,只有几个不同之处:我们注入的ConnectionFactory实例是通过@JMSConnectionFactoryDefinition定义的,并通过其clientId属性分配了一个客户端 ID。注意,我们的连接工厂的@Resource注解有一个mappedName属性,其值与我们定义在@JMSConnectionFactoryDefinition中的名称属性相匹配。

另一个区别是,我们不是在JMSContext上调用createConsumer()方法,而是在调用createDurableConsumer()createDurableConsumer()方法接受两个参数,一个用于检索消息的消息Topic对象和一个指定此订阅名称的String。这个第二个参数必须在所有持久主题的订阅者之间是唯一的。

摘要

在本章中,我们详细讨论了如何使用 Jakarta Messaging 发送消息,既使用了 PTP 消息域,也使用了 pub/sub 消息域。

我们讨论的主题包括以下内容:

  • 如何通过jakarta.jms.JMSProducer接口向消息队列发送消息

  • 如何通过jakarta.jms.JMSConsumer接口从消息队列接收消息

  • 如何通过实现jakarta.jms.MessageListener接口异步从消息队列接收消息

  • 如何使用前面的接口发送和接收消息到和从消息主题

  • 如何通过jakarta.jms.QueueBrowser接口浏览消息队列中的消息而不从队列中移除消息

  • 如何设置和交互持久订阅到消息主题

带着本章的知识,我们现在可以实现在 Jakarta Messaging 之间进行异步通信。

第十四章:使用 Jakarta XML Web Services 的 Web 服务

Web 服务是可以远程调用的应用程序编程接口。Web 服务可以从任何语言的客户端调用。

Jakarta EE 将 XML Web Services API 作为其技术之一。我们可以在 Java 平台上使用 XML Web Services 来开发 SOAP简单对象访问协议)Web 服务。Jakarta XML Web Services 是一个高级 API;通过 Jakarta XML Web Services 调用 Web 服务是通过远程过程调用完成的。

基于 SOAP 的 Web 服务现在是一种过时的技术。在大多数情况下,对于新开发,人们更倾向于使用 RESTful Web 服务而不是基于 SOAP 的服务。对基于 SOAP 的 Web 服务的了解主要用于维护遗留应用程序。

在本章中,我们将涵盖以下主题:

  • 使用 Jakarta XML Web Services 开发 Web 服务

  • 将企业 Bean 公开为 Web 服务

注意

本章的示例源代码可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch14_src

使用 Jakarta XML Web Services 开发 Web 服务

Jakarta XML Web Services 是一个高级 API,它简化了基于 SOAP 的 Web 服务的开发。使用 Jakarta XML Web Services 开发 Web 服务包括编写一个具有公开方法的类,这些方法将被公开为 Web 服务。该类需要使用 @WebService 注解。类中的所有公共方法都自动公开为 Web 服务;它们可以选择性地使用 @WebMethod 注解。以下示例说明了这个过程:

package com.ensode.jakartaeebook.xmlws;
import jakarta.jws.WebMethod;
import jakarta.jws.WebService;
@WebService
public class Calculator {
  @WebMethod
  public int add(int first, int second) {
    return first + second;
  }
  @WebMethod
  public int subtract(int first, int second) {
    return first - second;
  }
}

前面的类将其两个方法公开为 Web 服务。add() 方法简单地将它接收的两个 int 原始参数相加并返回结果,而 substract() 方法从其两个参数中减去并返回结果。

我们通过使用 @WebService 注解来装饰类,表明该类实现了 Web 服务。我们希望公开为 Web 服务的任何方法都可以用 @WebMethod 注解来装饰,但这不是必需的。由于所有公共方法都自动公开为 Web 服务,我们仍然可以使用 @WebMethod 注解以提高清晰度,但这不是严格必要的。要部署我们的 Web 服务,我们只需像往常一样将其打包到 WAR 文件中即可。

Web 服务客户端需要一个 WSDLWeb 服务定义语言)文件来生成可执行代码,以便它们可以用来调用 Web 服务。WSDL 是一种基于 XML 的语言,用于描述基于 SOAP 的 Web 服务提供的功能。WSDL 文件通常放置在 Web 服务器上,并通过其 URL 被客户端访问。

当部署使用 Jakarta XML Web Services 开发的 Web 服务时,会自动为我们生成一个 WSDL。生成的 WSDL 的确切 URL 取决于我们使用的 Jakarta EE 运行时。当使用 GlassFish 时,生成的 WSDL 的 URL 遵循以下格式:

[http|https]://[server]:[port]/[context root]/[service name]?wsdl

在我们的示例中,我们的 Web 服务的 WSDL(当部署到 GlassFish 时)的 URL 将是http://localhost:8080/calculatorservice/CalculatorService?wsdl(假设 GlassFish 运行在我们的本地工作站上,并且 GlassFish 在其默认的8080端口上监听 HTTP 连接)。

我们可以通过将浏览器指向其 URL 来查看生成的 WSDL,如图 14.1 所示。

图 14.1 – 自动生成的 WSDL

图 14.1 – 自动生成的 WSDL

WSDL 的具体内容与讨论无关。它可以被认为是“幕后管道”,这对于基于 SOAP 的 Web 服务正确工作来说是必要的。不过,当开发 Web 服务客户端时,需要 WSDL 的 URL。

开发 Web 服务客户端

正如我们之前提到的,Web 服务客户端需要从 Web 服务的 WSDL 生成可执行代码。然后,Web 服务客户端将调用此可执行代码来访问 Web 服务。

为了从 WSDL 生成 Java 代码,我们需要使用一个名为wsimport的工具。

可以通过下载 Eclipse Metro 来获取wsimport工具,网址为eclipse-ee4j.github.io/metro-wsit/

wsimport的唯一必需参数是与 Web 服务对应的 WSDL 的 URL,如下所示:

wsimport http://localhost:8080/calculatorservice/CalculatorService?wsdl

此命令将生成多个编译后的 Java 类,允许客户端应用程序访问我们的 Web 服务:

  • Add.class

  • AddResponse.class

  • Calculator.class

  • CalculatorService.class

  • ObjectFactory.class

  • package-info.class

  • Subtract.class

  • SubtractResponse.class

注意

默认情况下,生成的类文件的源代码会自动删除。可以通过传递-keep参数来保留它。

这些类需要添加到客户端的CLASSPATH中,以便它们可以被客户端代码访问。

如果我们使用 Apache Maven 来构建我们的代码,我们可以利用 JAX-WS Maven 插件在构建客户端代码时自动调用wsimport。这种方法在下面的pom.xml文件中得到了说明:

<?xml version="1.0" encoding="UTF-8"?>
<project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <!-- Irrelevant markup omitted for brevity -->
  <build>
    <finalName>calculatorserviceclient</finalName>
    <plugins>
      <plugin>
        <groupId>com.sun.xml.ws</groupId>
        <artifactId>jaxws-maven-plugin</artifactId>
        <version>4.0.2</version>
        <executions>
          <execution>
            <goals>
              <goal>wsimport</goal>
            </goals>
            <configuration>
              <vmArgs>
                <vmArg>-Djavax.xml.accessExternalSchema=all</vmArg>
              </vmArgs>
              <wsdlUrls>
                <wsdlUrl>
       http://localhost:8080/calculatorservice/CalculatorService?wsdl
                </wsdlUrl>
              </wsdlUrls>
              <keep>true</keep>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

上述pom.xml Maven 构建文件将在我们通过mvn packagemvn install命令构建代码时自动调用wsimport实用程序。

到目前为止,我们已经准备好开发一个简单的客户端来访问我们的 Web 服务。我们将实现我们的客户端作为一个 Jakarta Faces 应用程序;客户端应用程序源代码中最相关的部分如下所示:

package com.ensode.jakartaeebook.calculatorserviceclient;
//imports omitted for brevity
@Named
@RequestScoped
public class CalculatorClientController {
  @WebServiceRef(wsdlLocation =
    "http://localhost:8080/calculatorservice/CalculatorService?wsdl")
  private CalculatorService calculatorService;
  @Inject
  private CalculatorServiceClientModel calculatorServiceClientModel;
  private Integer sum;
  private Integer difference;
  public void add(ActionEvent actionEvent) {
    Calculator calculator = calculatorService.getCalculatorPort();
    sum = calculator.add(calculatorServiceClientModel.getAddend1(),
      calculatorServiceClientModel.getAddend2());
  }
  public void subtract(ActionEvent actionEvent) {
    Calculator calculator = calculatorService.getCalculatorPort();
    difference =
      calculator.subtract(calculatorServiceClientModel.getMinuend(),
      calculatorServiceClientModel.getSubtrahend());
  }
  //getters and setters omitted for brevity
}

@WebServiceRef注解将 Web 服务的实例注入到我们的客户端应用程序中。它的wsdlLocation属性包含我们正在调用的 Web 服务对应的 WSDL 的 URL。

注意,Web 服务类是名为 CalculatorService 的类的实例。当我们调用 wsimport 工具时创建了此类,因为 wsimport 总是生成一个类,其名称是我们实现的类的名称加上“Service”后缀。我们使用这个服务类来获取我们开发的 Web 服务类的实例。在我们的例子中,我们通过在 CalculatorService 实例上调用 getCalculatorPort() 方法来完成此操作。一般来说,调用我们 Web 服务类实例的方法遵循 getNamePort() 模式,其中 Name 是我们编写的实现 Web 服务的类的名称。一旦我们获得了我们 Web 服务类的实例,我们就可以像调用任何常规 Java 对象一样调用其方法。

注意

严格来说,服务类的 getNamePort() 方法返回一个实现由 wsimport 生成的接口的类的实例。这个接口被命名为我们的 Web 服务类,并声明了我们声明的所有作为 Web 服务的函数。从所有实际目的来看,返回的对象等同于我们的 Web 服务类。

我们简单客户端应用程序的用户界面是使用 Facelets 开发的,这在开发 Jakarta Faces 应用程序时是惯例。以下代码片段显示了我们的 Jakarta Faces Facelets 客户端最相关的标记:

<h:form>
  <h:panelGrid columns="4">
    <h:inputText id="addend1"
      value="#{calculatorServiceClientModel.addend1}"/>
    <h:inputText id="addend2"
      value="#{calculatorServiceClientModel.addend2}"/>
    <h:commandButton value="Add"
      actionListener="#{calculatorClientController.add}">
      <f:ajax execute="addend1 addend2" render="sum"/>
    </h:commandButton>
    <h:panelGroup>
      Total: <h:outputText id="sum"
        value="#{calculatorClientController.sum}"/>
    </h:panelGroup>
  </h:panelGrid>
  <br/>
  <h:panelGrid columns="4">
    <h:inputText id="minuend"
      value="#{calculatorServiceClientModel.minuend}"/>
    <h:inputText id="subtrahend"
      value="#{calculatorServiceClientModel.subtrahend}"/>
    <h:commandButton value="Subtract"
      actionListener="#{calculatorClientController.subtract}">
      <f:ajax execute="minuend subtrahend" render="difference"/>
    </h:commandButton>
    <h:panelGroup>
      Difference: <h:outputText id="difference"
        value="#{calculatorClientController.difference}"/>
    </h:panelGroup>
  </h:panelGrid>
</h:form>

用户界面使用 Ajax 调用 CalculatorClientController CDI 命名豆(有关详细信息,请参阅第六章)上的相关方法。

在部署我们的代码后,我们的浏览器应该将页面渲染成如图 图 14**.2 所示(这是在输入一些数据并点击相应按钮后显示的)。

图 14.2 – XML Web 服务客户端运行时

图 14.2 – XML Web 服务客户端运行时

在这个例子中,我们传递了 Integer 对象作为参数和返回值。当然,也可以将原始类型作为参数和返回值传递。不幸的是,当使用 Jakarta XML Web Services 实现基于 SOAP 的 Web 服务时,并非所有标准 Java 类或原始类型都可以用作方法参数或返回值。这是因为幕后,方法参数和返回类型被映射到 XML 定义,并且并非所有类型都可以正确映射。

在此列出可用于 Jakarta XML Web 服务调用的有效类型:

  • java.awt.Image

  • java.lang.Object

  • Java.lang.String

  • java.math.BigDecimal

  • java.math.BigInteger

  • java.net.URI

  • java.util.Calendar

  • java.util.Date

  • java.util.UUID

  • jakarta.activation.DataHandler

  • javax.xml.datatype.Duration

  • javax.xml.datatype.XMLGregorianCalendar

  • javax.xml.namespace.QName

  • javax.xml.transform.Source

此外,以下原始类型也可以使用:

  • boolean

  • byte

  • byte[]

  • double

  • float

  • int

  • long

  • short

我们还可以将我们自己的自定义类用作 Web 服务方法的参数和/或返回值,但我们的类的成员变量必须是列出的类型之一。

此外,使用数组作为方法参数或返回值是合法的。然而,在执行wsimport时,这些数组会被转换为列表,导致 Web 服务中的方法签名与客户端调用的方法调用之间产生不匹配。因此,更倾向于使用列表作为方法参数和/或返回值,因为这也是合法的,并且不会在客户端和服务器之间产生不匹配。

注意

Jakarta XML Web Services 内部使用Jakarta XML Binding API从方法调用创建 SOAP 消息。我们允许用于方法调用和返回值的类型是 Jakarta XML Binding 支持的类型。有关更多信息,请参阅 https://jakarta.ee/specifications/xml-binding/。

向 Web 服务发送附件

除了发送和接受上一节中讨论的数据类型外,Web 服务方法还可以发送和接受文件附件。以下示例说明了如何做到这一点:

package com.ensode.jakartaeebook.xmlws;
//imports omitted for brevity
@WebService
public class FileAttachment {
  @WebMethod
  public void attachFile(DataHandler dataHandler) {
    FileOutputStream fileOutputStream;
    try {
      fileOutputStream = new FileOutputStream("/tmp/logo.png");
      dataHandler.writeTo(fileOutputStream);
      fileOutputStream.flush();
      fileOutputStream.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

为了编写一个接收一个或多个附件的 Web 服务方法,我们只需要为方法将接收到的每个附件添加一个jakarta.activation.DataHandler类型的参数。在我们的例子中,attachFile()方法接受一个此类参数并将其简单地写入文件系统。

就像任何标准 Web 服务一样,Web 服务代码需要打包在一个WAR文件中并部署。一旦部署,就会自动生成 WSDL。然后我们需要执行wsimport实用程序来生成我们的 Web 服务客户端可以用来访问 Web 服务的代码。如前所述,wsimport可以从命令行或通过 Apache Maven 插件调用。

一旦我们执行了wsimport来生成访问 Web 服务的代码,我们就可以编写和编译我们的客户端代码:

package com.ensode.jakartaeebook.fileattachmentserviceclient;
//imports omitted for brevity
@Named
@RequestScoped
public class FileAttachmentServiceClientController {
  @WebServiceRef(wsdlLocation =
    "http://localhost:8080/fileattachmentservice/"
    + "FileAttachmentService?wsdl")
  private FileAttachmentService fileAttachmentService;
  public void invokeWebService() {
    try {
      URL attachmentUrl = new URL(
        "http://localhost:8080/fileattachmentserviceclient/" +
        "resources/img/logo.png");
      FileAttachment fileAttachment = fileAttachmentService.
          getFileAttachmentPort();
      InputStream inputStream = attachmentUrl.openStream();
      byte[] fileBytes = inputStreamToByteArray(inputStream);
      fileAttachment.attachFile(fileBytes);
    } catch (IOException ioe) {
      ioe.printStackTrace();
    }
  }
  private byte[] inputStreamToByteArray(InputStream inputStream)
    throws IOException {
    //method body omitted for brevity
  }
}

Web 服务附件需要以字节数组的形式发送到 Web 服务;因此,Web 服务客户端需要将文件转换为这种类型。在我们的例子中,我们发送一个图像作为附件,我们通过创建一个java.net.URL实例并将图像的 URL 作为参数传递给其构造函数来将图像加载到内存中。然后,我们通过在 URL 实例上调用openStream()方法来获取与图像对应的InputStream实例,将我们的InputStream实例转换为字节数组,然后将这个字节数组传递给期望附件的 Web 服务方法。

注意,与传递标准参数不同,当客户端调用期望附件的方法时使用的参数类型与 Web 服务器代码中方法的参数类型不同。Web 服务器代码中的方法期望每个附件都是一个 jakarta.activation.DataHandler 实例;然而,由 wsimport 生成的代码期望每个附件都是一个字节数组。这些字节数组在 wsimport 生成的代码背后被转换为正确的类型(jakarta.activation.DataHandler)。我们作为应用程序开发者不需要关心为什么会发生这种情况的细节,我们只需要记住,当向 Web 服务方法发送附件时,参数类型在 Web 服务代码和客户端调用中将不同。

将企业 Bean 公开为 Web 服务

除了在上一节中描述的创建 Web 服务之外,通过简单地向企业 Bean 类添加注解,无状态会话 Bean 的公共方法可以轻松地公开为 Web 服务。以下示例说明了如何进行此操作:

package com.ensode.jakartaeebook.jebws;
import jakarta.ejb.Stateless;
import jakarta.jws.WebService;
@Stateless
@WebService
public class DecToHexBean {
  public String convertDecToHex(Integer i) {
    return Integer.toHexString(i);
  }
}

如我们所见,要将无状态会话 Bean 的公共方法公开为 Web 服务,我们只需要用 @WebService 注解装饰其类声明。不用说,由于该类是一个会话 Bean,它还需要用 @Stateless 注解装饰。

就像常规的无状态会话 Bean 一样,那些将方法公开为 Web 服务的需要部署在一个 JAR 文件中。

就像标准 Web 服务一样,企业 Bean Web 服务的 WSDL URL 依赖于所使用的应用服务器。您可以查阅您的应用服务器文档以获取详细信息。

企业 Bean Web 服务客户端

以下类说明了从客户端应用程序访问企业 Bean Web 服务的步骤:

package com.ensode.jakartaeebook.jebwsclient;
//imports omitted for brevity
@Named
@RequestScoped
public class JebClientController {
  @WebServiceRef(wsdlLocation =
    "http://localhost:8080/DecToHexBeanService/DecToHexBean?wsdl")
  private DecToHexBeanService decToHexBeanService;
  @Inject
  private JebClientModel jebClientModel;
  private String hexVal;
  public void convertIntToHex() {
    hexVal = decToHexBeanService.getDecToHexBeanPort().
        convertDecToHex(jebClientModel.getIntVal());
  }
  //getters and setters omitted for brevity
}

如我们所见,当从客户端访问企业 Bean Web 服务时,不需要做任何特别的事情。该过程与标准 Web 服务相同。

前面的类是一个 CDI 命名 Bean。图 14.3 展示了一个简单的 Jakarta Faces 用户界面,该界面使用前面的类来调用我们的 Web 服务。

图 14.3 – 企业 Bean Web 服务客户端

图 14.3 – 企业 Bean Web 服务客户端

点击 十进制转十六进制 按钮,会生成对 Web 服务的调用,该调用返回与用户在文本输入字段中输入的十进制值等效的十六进制值。

摘要

在本章中,我们介绍了如何通过 Jakarta XML Web 服务 API 开发 Web 服务和 Web 服务客户端。

本章涵盖了以下主题:

  • 如何使用 Jakarta XML Web 服务开发基于 SOAP 的 Web 服务

  • 如何在将 Maven 作为构建工具时,将 Web 服务客户端的代码生成集成到 Web 服务中

  • 可以用于通过 Jakarta XML Web 服务进行远程方法调用的有效数据类型

  • 如何向 Web 服务发送附件

  • 如何将企业 Bean 方法公开为 Web 服务

拥有本章的知识,我们现在可以开发基于 SOAP 的 Web 服务,以及维护现有的基于 SOAP 的应用程序。

第十五章:整合所有内容

在前面的章节中,我们分别介绍了 Jakarta EE API 和规范。然而,在本章中,我们将使用流行的 Jakarta EE API 开发一个应用,展示如何将它们一起使用。

在本章中,我们将涵盖以下主题:

  • 样例应用

  • 创建客户数据

  • 查看客户数据

  • 更新客户数据

  • 删除客户数据

  • 实现分页

到本章结束时,你将学会如何开发一个结合了几个流行的 Jakarta EE API 的完整应用。

注意

本章的示例源代码可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Jakarta-EE-Application-Development/tree/main/ch15_src

样例应用

本章我们将开发一个典型的 CRUD(创建、读取、更新和删除)应用示例。我们将使用 CDI 来开发我们的控制器,使用 Jakarta Persistence 作为我们的对象关系映射工具,利用 Jakarta Enterprise Beans 来处理事务,以及使用 Jakarta Faces 来开发用户界面。我们将介绍一些高级的 Jakarta Faces 功能,例如开发自定义转换器和实现自定义表达式语言EL)解析器。

该应用是一个基于 Web 的应用,用于在数据库中维护客户信息。它提供了查看所有客户、查看单个客户的详细信息以及更新和删除新客户的功能。

首页

我们的应用程序首页是一个非常简单的 Facelets 页面,它有一个简单的命令链接,该链接会在主控制器上调用方法,如下面的代码片段所示。

<h:body>
  <h:form>
    <h:commandLink action="#{customerController.listSetup}"
      value="View all customers"/>
  </h:form>
</h:body>

我们应用的主要控制器是一个名为 CustomerController 的类。它被实现为一个会话作用域的 CDI 命名豆。当用户点击页面上的链接时,我们的 Facelets 页面会调用控制器上的 listSetup() 方法。这个方法进行一些初始化,然后将用户引导到显示所有现有客户的页面,如下面的代码段所示。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted for brevity
@Named
@SessionScoped
public class CustomerController implements Serializable {
  //variable declarations omitted
  public String listSetup() {
    reset(true);
    return "/customer/List";
  }
  private void reset(boolean resetFirstItem) {
    customer = null;
    customerItems = null;
    pagingInfo.setItemCount(-1);
    if (resetFirstItem) {
      pagingInfo.setFirstItem(0);
    }
  }
  //additional methods omitted
}

我们控制器中的 listSetup() 方法调用一个 reset() 方法,用于分页(稍后会有更多介绍),然后返回一个与显示现有客户列表页面路径匹配的字符串。

第一次导航到显示客户列表的页面时,我们简单地显示一条消息,说明没有找到客户,因为数据库中的 CUSTOMERS 表是空的。

图 15.1 – 空客户列表

图 15.1 – 空客户列表

我们的 Facelets 页面有一个 <h:outputText> 标签,仅在客户列表为空时渲染。

<h:form styleClass="jsfcrud_list_form">
  <h:outputText escape="false" value="(No customers found)"
    rendered=
      "#{customerController.pagingInfo.itemCount == 0}" />
   <!-- Additional markup omitted →
   <h:commandButton
     action="#{customerController.createSetup}"
     value="New Customer"/>
</form>

我们的 Facelets 页面还有一个标记为 New Customer 的命令按钮,它会在控制器上调用逻辑以将客户数据插入数据库。

创建客户数据

显示客户列表的 Facelets 页面上的命令按钮调用 CustomerController 上的 createSetup() 方法,该方法在显示允许用户输入新客户数据的表单之前进行一些初始化。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  private Customer customer = null;
  private PagingInfo pagingInfo = null;
  //additional variable declarations omitted
  public String createSetup() {
    reset(false);
    customer = new Customer();
    List<Address> addressList = new ArrayList<>(1);
    Address address = new Address();
    List<Telephone> telephoneList = new ArrayList<>(1);
    Telephone telephone = new Telephone();
    address.setCustomer(customer);
    addressList.add(address);
    telephone.setCustomer(customer);
    telephoneList.add(telephone);
    customer.setAddressList(addressList);
    customer.setTelephoneList(telephoneList);
    return "/customer/New";
  }
  private void reset(boolean resetFirstItem) {
    customer = null;
    customerItems = null;
    pagingInfo.setItemCount(-1);
    if (resetFirstItem) {
      pagingInfo.setFirstItem(0);
    }
  }
}

如我们所见,createSetup() 方法调用了 reset() 方法,该方法简单地从内存中清除一些数据并执行一些分页逻辑,然后创建一个新的 Customer 对象。Customer 类是一个简单的 JPA 实体,它与两个额外的 JPA 实体 AddressTelephone 有一个多对多的关系。

package com.ensode.jakartaeealltogether.entity;
//imports omitted
@Entity
@Table(name = "CUSTOMERS")
public class Customer implements Serializable {
  private static final long serialVersionUID = 1L;
  @Id
  @Basic(optional = false)
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name = "CUSTOMER_ID")
  private Integer customerId;
  @Column(name = "FIRST_NAME")
  private String firstName;
  @Column(name = "LAST_NAME")
  private String lastName;
  @Column(name = "EMAIL")
  private String email;
  @OneToMany(mappedBy = "customer",
    cascade = CascadeType.ALL)
  private List<Address> addressList;
  @OneToMany(mappedBy = "customer",
    cascade = CascadeType.ALL)
  private List<Telephone> telephoneList;
  //methods omitted
}

createSetup() 方法初始化 Customer 新实例上的 AddressTelephone 列表,将其设置为包含相应类型单个元素的 ArrayList 实例,然后导航到用户可以输入新客户数据的 Facelets 页面。见 图 15**.2

图 15.2 – 输入新客户数据

图 15.2 – 输入新客户数据

我们页面的标记是一个相当标准的 Facelets 页面,它实现了一些自定义逻辑来填充页面上的所有下拉菜单,例如,获取 addressTypeItemsAvailableSelectOne() 上的选项,在一个名为 AddressTypeController 的 CDI 实体上。

 <h:selectOneMenu id="selectOneAddr" value=
   "#{customerController.customer.addressList[0].
      addressType}" required="true">
   <f:selectItems id="selectOneAddrOpts" value="#{addressTypeController.addressTypeItemsAvailableSelectOne}"/>
</h:selectOneMenu>

AddressTypeController.getAddressTypeItemsAvailableSelectOne() 方法在名为 JSFUtil 的实用类上的 getSelectItems() 上调用一个简单的方法。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class AddressTypeController implements Serializable {
  @EJB
  private AddressTypeDao dao;
  public SelectItem[]
        getAddressTypeItemsAvailableSelectOne() {
    return JsfUtil.getSelectItems(
        dao.findAddressTypeEntities(), true);
  }
}

JSFUtil.getSelectItems() 方法遍历返回的实体,并返回一个 SelectItem 数组,使用每个实体的 toString() 方法的返回值作为标签,实体本身作为值。

package com.ensode.jakartaeealltogether.faces.util;
//imports omitted
public class JsfUtil {
  public static SelectItem[] getSelectItems(List<?> entities, 
    boolean selectOne) {
    int size = selectOne ? entities.size() + 1 :
      entities.size();
    SelectItem[] items = new SelectItem[size];
    int i = 0;
    if (selectOne) {
      items[0] = new SelectItem("", "---");
      i++;
    }
    for (Object x : entities) {
      items[i++] = new SelectItem(x, x.toString());
    }
    return items;
  }
}

填充页面上的其他下拉菜单的逻辑非常相似。

新客户页面上的 保存 按钮实现为一个命令按钮:

<h:commandButton action="#{customerController.create}"
  value="Save"/>

命令按钮在 CustomerController 类中调用一个名为 create() 的方法。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  @EJB
  private CustomerDao dao;
  //additional methods and variable declarations omitted
  public String create() {
    try {
      dao.create(customer);
      JsfUtil.addSuccessMessage(
        "Customer was successfully created.");
    } catch (Exception e) {
      JsfUtil.ensureAddErrorMessage(e,
        "A persistence error occurred.");
      return null;
    }
    return listSetup();
  }
}

CustomerController.create() 方法简单地调用我们数据访问对象(DAO)中同名的 create() 方法,DAO 方法简单地在一个新的行中插入 CUSTOMERSADDRESSESTELEPHONES 数据库表,这些表分别对应于 CustomerAddressTelephone Jakarta Persistence 实体。

CustomerrController.create() 方法在操作成功时向用户显示一条成功消息,如果操作失败则显示一条错误消息。然后它将用户导向列出所有客户对象的页面。见 图 15**.3

图 15.3 – 已填充的客户列表

图 15.3 – 已填充的客户列表

现在我们已经创建了一个客户,我们的 客户列表 页面显示一个数据表,列出我们刚刚创建的客户。它包含用于查看、编辑或删除每个客户的命令链接。我们将在下一节中介绍如何查看现有客户数据。

查看客户数据

如前所述,客户列表页面上的数据表中的每一行都有一个查看命令链接。命令链接的标记如下:

<h:commandLink value="View" action="#{customerController.detailSetup}">
  <f:param name="jsfcrud.currentCustomer" value="#{jsfcrud_class['com.ensode.jakartaeealltogether.faces.util.JsfUtil'].jsfcrud_method['getAsConvertedString'][item1][customerController.converter].jsfcrud_invoke}"/>
</h:commandLink>

注意命令链接内的<f:param>标签。此标签在用户点击按钮时创建的 HTTP 请求中添加一个请求参数。

我们正在使用高级的 Jakarta Faces 技术来动态生成请求参数的值。我们使用自定义的表达式语言解析器,这样我们就可以在我们的 Jakarta Faces 表达式语言中实现自定义逻辑。

为了使用自定义的表达式语言解析器,我们需要在我们的应用程序的faces-config.xml配置文件中声明它。

<?xml version='1.0' encoding='UTF-8'?>
<faces-config version="4.0"

    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://
      jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd">
  <application>
    <el-resolver>
      com.ensode.jakartaeealltogether.faces.util.JsfCrudELResolver
    </el-resolver>
  </application>
</faces-config>

如我们所见,我们通过在 faces-config.xml 中的<el-resolver>标签内放置其完全限定名称来注册我们的自定义表达式语言解析器。

我们的表达式语言解析器的详细信息超出了范围,只需说,它的getValue()方法在解析<f:param>的值属性时自动调用,它使用 Java 的反射 API 来确定要调用哪个方法。在我们的具体示例中,它调用名为JsfUtil的类中的getConvertedAsString()方法,并将一个名为CustomerConverter的自定义转换器的实例作为参数传递。

以下代码片段显示了我们的自定义ELResolvergetValue()方法的签名,本书的 GitHub 仓库包含完整的源代码。

package com.ensode.jakartaeealltogether.faces.util;
//imports omitted
public class JsfCrudELResolver extends ELResolver {
  //variable declarations omitted
  @Override
  public Object getValue(ELContext context, Object base,
    Object property) {
    //use reflection to determine which method to invoke
  }
  //additional methods omitted
}

一切都完成后,我们的ELResolver返回相应Customer对象的键值。

当用户点击CustomerController.detailSetup()时,它被调用,在浏览器上显示客户信息之前执行一些初始化操作。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  @PostConstruct
  public void init() {
    converter = new CustomerConverter();
  }
  private Customer customer = null;
  private CustomerConverter converter = null;
  //additional variable declarations omitted
  public String detailSetup() {
    return scalarSetup("/customer/Detail");
  }
  private String scalarSetup(String destination) {
    reset(false);
    customer = (Customer) JsfUtil.
      getObjectFromRequestParameter(
       "jsfcrud.currentCustomer", converter, null);
    if (customer == null) {
     //error handling code omitted
    }
    return destination;
  }
  //additional methods omitted
}

CustomerController.detailSetup()方法简单地将其大部分逻辑委托给scalarSetup()方法,该方法在每次我们需要显示单个客户信息时使用。

CustomerController.scalarSetup()调用JsfUtil.getObjectFromRequestParameter(),传递请求参数名称和我们的自定义 Jakarta Faces 转换器。

JSFUtil.getObjectFromRequestParameter(),反过来,使用我们的自定义转换器来获取我们的Customer对象的一个实例。

package com.ensode.jakartaeealltogether.faces.util;
//imports omitted
public class JsfUtil {
  public static String getRequestParameter(String key) {
    return FacesContext.getCurrentInstance().getExternalContext().
    getRequestParameterMap().get(key);
  }
  public static Object getObjectFromRequestParameter(String requestParameterName, Converter converter, UIComponent component) {
    String theId =
     JsfUtil.getRequestParameter(requestParameterName);
    return converter.getAsObject(
      FacesContext.getCurrentInstance(), component, theId);
  }
  //additional methods omitted
}

如我们所见,JSFUtil调用了我们自定义的 Faces 转换器的getAsObject()方法。我们的转换器反过来,通过 Jakarta Faces API 获取我们的会话作用域的CustomerController实例,然后调用它的findCustomer()方法来获取相应的Customer Jakarta Persistence 实体实例,如下面的示例所示。

package com.ensode.jakartaeealltogether.faces.converter;
//imports omitted
@FacesConverter(forClass = Customer.class)
public class CustomerConverter implements Converter {
  @Override
  public Object getAsObject(FacesContext facesContext, UIComponent component, String string) {
    if (string == null || string.length() == 0) {
      return null;
    }
    Integer id = Integer.valueOf(string);
    CustomerController controller =
      (CustomerController) facesContext.getApplication().
     getELResolver().getValue(
     facesContext.getELContext(), null,
     "customerController");
    return controller.findCustomer(id);
  }
  //additional methods omitted
}

一旦我们获取了Customer实例,控制权传递到客户详情页面,这是一个基本的 Facelets 页面,用于显示客户信息。见图 15.4。

图 15.4 – 客户详情页面

图 15.4 – 客户详情页面

现在我们已经看到如何显示客户信息,我们将关注更新现有客户数据。

更新客户数据

在更新客户数据方面,我们还没有太多未讨论的内容。每行上的标记为 编辑 的命令链接导航到 编辑客户 页面。命令链接的标记如下:

<h:commandLink value="Edit" action="#{customerController.editSetup}">
  <f:param name="jsfcrud.currentCustomer" value=
"#{jsfcrud_class['com.ensode.jakartaeealltogether.faces.util.JsfUtil'].jsfcrud_method['getAsConvertedString'][item1][customerController.converter].jsfcrud_invoke}"/>
</h:commandLink>

命令链接使用与之前章节中讨论的相同的技术,包括一个自定义的表达式语言解析器,将需要更新的客户的 ID 作为请求参数传递,然后调用 CustomerController.editSetup() 方法,该方法执行一些初始化操作,然后引导用户到 CustomerController CDI 实例。以下代码片段显示了这些操作:

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  private Customer customer = null;
  //additional variable declarations omitted
  public String editSetup() {
    return scalarSetup("/customer/Edit");
  }
  private String scalarSetup(String destination) {
    reset(false);
    customer = (Customer) JsfUtil.
      getObjectFromRequestParameter(
      "jsfcrud.currentCustomer", converter, null);
    if (customer == null) {
      //error handling code omitted
    }
    return destination;
  }
  //additional methods omitted
}

如我们所见,editSetup() 方法遵循我们在介绍如何导航到只读的 scalarSetup() 方法以获取客户实体的适当实例时讨论的相同模式,然后引导用户到 编辑 客户 页面。

编辑客户 页面的标记相当简单。它包括多个输入字段,使用绑定表达式映射到客户实体的不同字段。它使用我们在创建客户数据部分讨论的技术来填充页面上的所有下拉列表。在成功导航后,编辑客户 页面如 图 15.5 所示渲染。

图 15.5 – 编辑客户页面

图 15.5 – 编辑客户页面

我们 CustomerController 类上的 edit() 方法。标记我们的 DAO 获取客户:

<h:commandButton action="#{customerController.edit}"
  value="Save">
  <f:param name="jsfcrud.currentCustomer"
  value=
"#{jsfcrud_class['com.ensode.jakartaeealltogether.faces.util.JsfUtil'].jsfcrud_method['getAsConvertedString'][customerController.customer][customerController.converter].jsfcrud_invoke}"/>
</h:commandButton>

CustomerController 上的 edit() 方法如下所示:

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  private Customer customer = null;
  private CustomerConverter converter = null;
  //additional variable declarations omitted
  @EJB
  private CustomerDao dao;
  public String edit() {
    String customerString = converter.getAsString(
      FacesContext.getCurrentInstance(), null, customer);
    String currentCustomerString =
      JsfUtil.getRequestParameter(
      "jsfcrud.currentCustomer");
    if (customerString == null ||
        customerString.length() == 0 ||
        !customerString.equals(currentCustomerString)) {
      String outcome = editSetup();
      if ("customer_edit".equals(outcome)) {
        JsfUtil.addErrorMessage(
          "Could not edit customer. Try again.");
      }
      return outcome;
    }
    try {
      dao.edit(customer);
      JsfUtil.addSuccessMessage(
        "Customer was successfully updated.");
    } catch (Exception e) {
      //exception handling code omitted
    }
    return detailSetup();
  }
  //additional methods omitted
}

CustomerController 上的 edit() 方法执行一个合理性检查,以确保内存中客户的 ID 与作为请求参数传递的 ID 匹配,如果不匹配则显示错误消息。如果合理性检查成功,该方法将调用 CustomerDao 数据访问对象上的 edit() 方法,如下面的代码片段所示:

package com.ensode.jakartaeealltogether.dao;
//imports omitted
@Stateless
public class CustomerDao implements Serializable {
  @Resource
  private EJBContext ejbContext;
  @PersistenceContext
  private EntityManager em;
  public void edit(Customer customer) throws Exception {
    try {
      customer = em.merge(customer);
    } catch (Exception ex) {
      ejbContext.setRollbackOnly();
      String msg = ex.getLocalizedMessage();
      if (msg == null || msg.length() == 0) {
        Integer id = customer.getCustomerId();
        if (findCustomer(id) == null) {
          throw new NonexistentEntityException(
            "The customer with id " + id +
            " no longer exists.");
        }
      }
      throw ex;
    }
  }
}

我们 DAO 上的 edit() 方法在其注入的 EntityManager 上调用 merge() 方法,从而更新数据库中相应的客户数据。如果发生异常,该方法将回滚事务然后尝试从数据库中检索客户。如果 edit() 方法在数据库中找不到客户,它将显示一条错误消息,指出客户已不再存在。这种逻辑之所以必要,是因为在我们用户更新客户信息的同时,另一个用户或进程可能已将我们的客户从数据库中删除。

如果客户数据成功更新,用户将被引导到 客户详情 页面,显示更新后的客户数据。见 图 15.6

图 15.6 – 显示更新客户数据的客户详情页面

图 15.6 – 显示更新客户数据的客户详情页面

在下一节中,我们将讨论我们的示例应用程序如何从数据库中删除客户数据。

删除客户数据

客户列表页面上的每个表元素都有一个标记为“删除”的链接。

图 15.7 – 客户列表页面

图 15.7 – 客户列表页面

删除命令链接的标记遵循之前讨论的模式,即设置一个带有要删除的客户 ID 的请求参数,如下例所示。

<h:commandLink value="Delete"
  action="#{customerController.destroy}">
  <f:param name="jsfcrud.currentCustomer" value=
"#{jsfcrud_class['com.ensode.jakartaeealltogether.faces.util.JsfUtil'].jsfcrud_method['getAsConvertedString'][item1][customerController.converter].jsfcrud_invoke}"/>
</h:commandLink>

当点击时,命令链接会在以下示例中调用CustomerController上的destroy()方法。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  @EJB
  private CustomerDao dao;
  //additional variable declarations omitted
  public String destroy() {
    String idAsString =
     JsfUtil.getRequestParameter("jsfcrud.currentCustomer");
    Integer id = Integer.valueOf(idAsString);
    try {
      dao.destroy(id);
      JsfUtil.addSuccessMessage(
        "Customer was successfully deleted.");
    } catch (Exception e){
      //exception handling logic omitted
    }
    return relatedOrListOutcome();
  }
  //additional methods omitted
}

CustomerController中的destroy()方法简单地调用CustomerDAO上的destroy()方法,传递从请求参数中获取的客户 ID。控制器随后导航回客户列表页面,并在成功删除后显示一个成功消息。如果在尝试删除客户时发生任何异常,它们将被适当地处理。

我们的 DAO 使用接收到的 ID 从数据库中检索客户,执行一个检查以确保数据没有被其他进程删除,然后通过在注入的EntityManager实例上调用remove()方法从数据库中删除客户,如下所示。

package com.ensode.jakartaeealltogether.dao;
//imports omitted
@Stateless
public class CustomerDao implements Serializable {
  @Resource
  private EJBContext ejbContext;
  @PersistenceContext
  private EntityManager em;
  public void destroy(Integer id) throws
    NonexistentEntityException, RollbackFailureException,
    Exception {
    try {
      Customer customer;
      try {
        customer = em.getReference(Customer.class, id);
        customer.getCustomerId();
      } catch (EntityNotFoundException enfe) {
        throw new NonexistentEntityException(
          "The customer with id " + id +
          " no longer exists.", enfe);
      }
      em.remove(customer);
    } catch (Exception ex) {
      try {
        ejbContext.setRollbackOnly();
      } catch (Exception re) {
        throw new RollbackFailureException("An error "
            + "occurred attempting to roll back", re);
      }
      throw ex;
    }
  }
  //additional methods omitted
}

在成功从数据库中删除客户后,显示的是客户列表,其中包含一个成功消息,表明删除操作成功。

图 15.8 – 删除成功

图 15.8 – 删除成功

我们已经看到了我们的示例应用程序如何创建、更新、显示和删除数据,包括它使用的某些高级技术,例如自定义转换器和自定义表达式语言解析器。

我们的示例应用程序还包含处理大量命令链接显示时分页的逻辑。我们将在下一节中讨论此功能。

实现分页

标准的 Jakarta Faces 数据表组件简单地显示页面上的列表中的所有元素。当我们需要显示少量元素时,这工作得很好,但当显示大量元素时,就会变得繁琐。在许多生产环境中,显示数百个元素并不罕见。

我们的示例应用程序实现了自定义分页逻辑,如果有超过五个客户需要显示,它一次只显示五个客户,然后显示适当的上一页和/或下一页链接以在所有客户之间导航。图 15.9展示了我们的自定义分页逻辑的实际应用。

图 15.9 – 自定义分页

图 15.9 – 自定义分页

我们的客户列表页面渲染一个Previous链接,用于导航回上一链接,或者渲染NextRemaining链接以导航到下一页。如果至少还有 5 个客户需要显示,则渲染Next链接;如果少于 5 个,则渲染Remaining链接。

这三个链接都是通过命令链接的rendered属性条件渲染的,该属性接受一个布尔表达式来决定链接是否应该被渲染。

 <h:commandLink action="#{customerController.prev}"
 value="Previous #{customerController.pagingInfo.batchSize}"
      rendered="#{customerController.renderPrevLink}"/>

我们控制器上的renderPrevLink属性。

next()prev()方法更新renderPrevLink属性的值。当使用renderPrevLink值来确定是否渲染Previous命令链接时,会调用next()方法;当调用prev()方法时,也会调用next()方法。

package com.ensode.jakartaeealltogether.faces.controller;
//imports omitted
@Named
@SessionScoped
public class CustomerController implements Serializable {
  @PostConstruct
  public void init() {
    pagingInfo = new PagingInfo();
  }
  private PagingInfo pagingInfo = null;
  private boolean renderPrevLink;
  @EJB
  private CustomerDao dao;
  //additional variable declarations omitted
  public PagingInfo getPagingInfo() {
    if (pagingInfo.getItemCount() == -1) {
      pagingInfo.setItemCount(dao.getCustomerCount());
    }
    return pagingInfo;
  }
  public boolean getRenderPrevLink() {
    return renderPrevLink;
  }
  public String next() {
    reset(false);
    getPagingInfo().nextPage();
    renderPrevLink = getPagingInfo().getFirstItem() >=
      getPagingInfo().getBatchSize();
    return "List";
  }
  public String prev() {
    reset(false);
    getPagingInfo().previousPage();
    renderPrevLink = getPagingInfo().getFirstItem() >=
      getPagingInfo().getBatchSize();
    return "List";
  }
  //additional methods omitted
}

当至少还有五个客户需要显示时,会显示Next命令链接。

<h:commandLink action="#{customerController.next}" value="Next #{customerController.pagingInfo.batchSize}" rendered="#{customerController.pagingInfo.lastItem + customerController.pagingInfo.batchSize le customerController.pagingInfo.itemCount}"

在这种情况下,rendered属性的逻辑被嵌入在页面上,而不是像Previous命令链接那样依赖于控制器。

当下一页显示的客户少于五个时,会显示Remaining命令链接。

<h:commandLink action="#{customerController.next}" value="Remaining #{customerController.pagingInfo.itemCount - customerController.pagingInfo.lastItem}"
                       rendered="#{customerController.pagingInfo.lastItem lt
  customerController.pagingInfo.itemCount and
  customerController.pagingInfo.lastItem +
  customerController.pagingInfo.batchSize gt
  customerController.pagingInfo.itemCount}"/>

此命令链接也将其rendered属性的逻辑作为页面上的一种表达式嵌入。

分页逻辑依赖于PagingInfo实用类,这个类有获取要显示的第一和最后元素的方法,以及确定在数据表的每一页上显示哪些元素的逻辑。

package com.ensode.jakartaeealltogether.faces.util;
public class PagingInfo {
  private int batchSize = 5;
  private int firstItem = 0;
  private int itemCount = -1;
  //trivial setters and getters omitted
  public int getFirstItem() {
    if (itemCount == -1) {
      throw new IllegalStateException(
        "itemCount must be set before invoking " +
        "getFirstItem");
    }
    if (firstItem >= itemCount) {
      if (itemCount == 0) {
        firstItem = 0;
      } else {
        int zeroBasedItemCount = itemCount - 1;
        double pageDouble = zeroBasedItemCount / batchSize;
        int page = (int) Math.floor(pageDouble);
        firstItem = page * batchSize;
      }
    }
    return firstItem;
  }
  public int getLastItem() {
    getFirstItem();
    int lastItem = firstItem + batchSize > itemCount ?
      itemCount : firstItem + batchSize;
    return lastItem;
  }
  public void nextPage() {
    getFirstItem();
    if (firstItem + batchSize < itemCount) {
      firstItem += batchSize;
    }
  }
  public void previousPage() {
    getFirstItem();
    firstItem -= batchSize;
    if (firstItem < 0) {
      firstItem = 0;
    }
  }
}

如前述代码示例所示,我们的控制器依赖于PaginationInfo实用类来实现分页。

摘要

在本章中,我们通过一个示例应用程序说明了如何通过整合几个 Jakarta EE 技术。我们涵盖了以下主题:

  • 如何通过整合 Jakarta Faces、CDI、Jakarta Enterprise Beans 和 Jakarta Persistence 来创建客户数据

  • 如何通过整合 Jakarta Faces、CDI、Jakarta Enterprise Beans 和 Jakarta Persistence 来查看客户数据

  • 如何通过整合 Jakarta Faces、CDI、Jakarta Enterprise Beans 和 Jakarta Persistence 来更新客户数据

  • 如何通过整合 Jakarta Faces、CDI、Jakarta Enterprise Beans 和 Jakarta Persistence 来删除客户数据

  • 如何在显示 Jakarta Faces 数据表组件中的表格数据时实现自定义分页逻辑

Jakarta EE API 设计为协同工作,正如本章所示,它们可以无缝集成来构建健壮的应用程序。

posted @ 2025-09-10 15:06  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报