JavaEE8-REST-Web-应用构建指南-全-

JavaEE8 REST Web 应用构建指南(全)

原文:zh.annas-archive.org/md5/e6c11d661096de6d526ba9caa3a68203

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 企业版是领先的 Java 企业级开发应用程序编程平台之一。随着 Java EE 8 的最终发布和第一个应用程序服务器的可用,现在是更深入地了解如何使用最新的 API 新增和改进开发现代和轻量级网络服务的时候了。

这本书是一本全面的指南,展示了如何使用最新的 Java EE 8 API 开发最先进的 RESTful 网络服务。我们首先概述 Java EE 8 以及最新的 API 新增和改进。然后,你将实现、构建和打包你的第一个工作网络服务作为本书剩余部分的原型。它深入探讨了使用 JAX-RS 实现同步 RESTful 网络服务和客户端的细节。接下来,你将了解使用 JSON-B 1.0 和 JSON-P 1.1 API 进行数据绑定和内容序列化的具体细节。你还将学习如何利用服务器和客户端异步 API 的强大功能,以及如何使用服务器端事件SSEs)进行PUSH通信。最后一章涵盖了某些高级网络服务主题,例如验证、JWT 安全和诊断网络服务。

在本书结束时,你将彻底理解用于轻量级网络服务开发的 Java EE 8 API。此外,你将实现几个工作网络服务,为你提供所需的实际经验。

本书面向对象

本书旨在为想要学习如何使用最新的 Java EE 8 API 实现网络服务的 Java 开发者编写。不需要 Java EE 8 的先验知识;然而,具有先前 Java EE 版本的实践经验将是有益的。

为了充分利用这本书

为了充分利用这本书,你需要以下内容:

  • 你需要具备基本的编程技能,并且需要一些 Java 知识

  • 你需要一个配备现代操作系统的计算机,例如 Windows 10、macOS 或 Linux

  • 你需要一个有效的 Java 8 语言安装;我们将使用 Maven 3.5.x 作为我们的构建工具

  • 我们将使用 Payara Server 5.x 作为我们的 Java 8 应用程序服务器

  • 你需要 Windows、macOS 或 Linux 上的 Docker

  • 你需要一个支持 Java EE 8 的 IDE,例如 IntelliJ IDEA 2017.3,以及一个 REST 客户端,例如 Postman 或 SoapUI

下载示例代码文件

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

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

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

  2. 选择“支持”标签。

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

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

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-RESTful-Web-Services-with-Java-EE-8。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供使用,请访问github.com/PacktPublishing/。查看它们吧!

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在先前的Dockerfile中,我们提到正在使用payara/server-full。”

代码块应如下设置:

        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-api</artifactId>
            <version>8.0</version>
            <scope>provided</scope>
        </dependency>

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

    @PUT
    @Path("/{isbn}")
    public Response update(@PathParam("isbn") String isbn, Book book) {
        if (!Objects.equals(isbn, book.getIsbn())) {

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

>docker build -t hello-javaee8:1.0 .

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“让我们检查我们的浏览器,你应该看到Hello World.消息。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们欢迎读者的反馈。

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

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

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

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

评论

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

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

第一章:开始使用 Java EE 8

在本章的第一部分,你将了解为什么 Java EE 是一个构建轻量级、最前沿微服务的优秀平台。你将学习 Java EE 8 的不同 API 的最新进展,重点关注与微服务更相关的 API。然后,你将学习如何使用 Java EE 8 开发、构建、运行和打包你的第一个微服务。

本章包括以下部分:

  • 为什么 Java EE 是微服务的良好平台?

  • Java EE 8 的新特性是什么?

  • 开始使用 Java EE 8 微服务

  • 使用 Docker 容器化 Java EE 8 微服务

技术要求

你需要基本的编程技能和一些 Java 知识。除此之外,你还需要一台装有现代操作系统的计算机,例如 Windows 10、macOS 或 Linux。我们将使用 Maven 3.5 作为我们的构建工具,并使用 Payara Server 作为我们的应用服务器。对于 Java 8 应用服务器,你需要 Docker for Windows、Mac 或 Linux,一个支持 Java EE 8 的 IDE,例如 IntelliJ,以及一个 REST 客户端,例如 Postman 或 SoapUI。

为什么 Java EE 是微服务的良好平台?

好吧,这就是问题所在,为什么?简短的答案是简单的:因为 Java EE 8 是目前最轻量级的企业框架。让我给你提供一些更多细节。首先,Java EE 是一个行业标准。它由一个供应商中立的委员会开发,由于 Java EE 已经存在几年了,所以广泛的知识已经存在。Java EE 由几个规范组成,这些规范有非常紧密的集成。此外,Java EE 采用了一种约定配置编程模型,这意味着你不再需要繁琐的 XML 描述符;只需添加几个注解即可完成。对于你将要开发的大多数服务,你将不需要任何外部依赖项,这导致部署工件很薄。最后,你还有现代应用服务器的可用性,这些服务器适合云时代。

Java EE 版本历史

如果你查看 Java EE 版本历史,你可以在en.wikipedia.org/wiki/Java_Platform,_Enterprise_Edition找到它,你会发现自从 1999 年 12 月 12 日 J2EE 1.2 首次发布以来,我们已经走了很长的路。如果你查看以下图表的右侧,你可以看到 Java EE 8 于 2017 年 9 月 21 日发布,这意味着我们有 18 年的经验和 18 年的社区构建的知识。因此,它肯定是一个非常成熟且稳定的 API,一直在持续改进:

图片

Java EE 版本历史

Java EE 8 概述

在以下图中,您可以查看当前状态的 Java EE 8 概览,这里有大量的 API 可以编程使用,应该能满足任何企业级 Web 服务开发的绝大多数需求。您有 JPA 用于持久化,JMS 用于消息传递,结构良好的 JAX-WS 用于 Web 服务,JAX-RS 用于 REST 服务,以及许多其他可用于您现代企业级应用开发的 API:

Java EE 8 概述

您所需的所有代码如下,这是唯一的依赖项;这是 Java EE 8 API 的 Maven 依赖项,并且没有外部依赖项。您只需要 Java EE 8 和 Java 8;这导致生成的工件非常薄,从而加快了您的日常开发和部署周期,并且因为您有这些薄的 WAR 文件,这使得它非常适合 Docker:

        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-api</artifactId>
            <version>8.0</version>
            <scope>provided</scope>
        </dependency>

现在有些人说 Java EE 8,特别是应用服务,不应该放入 Docker 容器中,但那些沉重的时代已经过去了;现代应用服务器非常轻量级,看看 Payara 服务器或 WildFly Swarm,或者可能是 Open Liberty 或 Apache TomEE,以及其他各种应用服务器。这些服务器非常轻量级,并且肯定适合在 Docker 容器中运行。我希望到现在您已经相信 Java EE 8 确实是目前最轻量级的企业级框架。在下一节中,我们将查看 Java EE 8 的新特性。

Java EE 8 的新特性是什么?

在本节中,我们将探讨 Java EE 8 的不同 API 和最新进展,重点关注与微服务相关的 API。我们将查看 JSR 370,即 JAX-RS 2.1;JSR 367,即 JSON 绑定;以及 JSR 374,即 JSON 处理的 Java API。

我们在“Java EE 8 概述”部分看到了 Java EE 8 中的不同 API。蓝色的是新增或改进的 API。我们看到 CDI 已升级到 2.0 版本,主要关注异步事件,Servlet API 已升级到 4.0 版本,增加了 HTTP2 支持。JSF 2.3,这是一个用于构建服务器端 UI 的 API,旧的 JSF 实例管理模型已被移除,并且已完全集成到 CDI 中。在上一个章节的图例右侧,您可以看到 Bean Validation API,它已升级到 2.0 版本。它与 CDI 紧密集成,并已重新设计以完全支持 Java 8 特性,如流和 lambda 表达式。还有一个全新的安全 API 用于云安全和过去的安全,增加了标准化的授权、认证机制和 API。在这里,我们想关注 JAX-RS 2.1、JSON-P 1.1 和 JSON-B 1.0。

让我们开始使用 JAX-RS 2.1。首先,它改善了与 CDI 的集成,因此所有的资源 bean 都得到了适当的 CDI 管理。它还与 JSON-B 紧密集成,用于 JSON 序列化,以及与 JSON-P 集成,用于 JSON 处理。此外,还添加了服务器端事件以实现推送通知。它们支持非阻塞 I/O,以及所有提供者,例如 JAX-RS 的过滤器拦截器。还有一个改进的 JAX-RS,它是一个支持完成阶段的同步客户端 API。如果您查看 Java API for JSON Processing,它已更新到版本 1.1 以支持 JSON Pointer 和 JSON Patch。它允许您编辑和转换您的 JSON 对象模型中的操作,并且 API 已更新以与 Java SE 8 功能一起工作,例如 lambda 和流。

新兴的 JSON-B,即 JSON 绑定 1.0 API,是转换 JSON 到 Java 对象以及反向转换的新标准方法。长期以来,我们已有 JSON-B 来执行同样的 XML 转换,而 JSON-B 就是用于 JSON 转换的 API。JSON-B 利用 JSON-P 并提供在其之上的转换层。它为将现有的 Java 类转换为 JSON 提供了默认的映射算法。通过使用 Java 注解,映射可以高度自定义,并且你可以插入不同的 JSON-B 运行时来将 Java 对象转换为 JSON 以及从 JSON 转换回来,例如 Jackson。这些都是与 Java EE 8 相关的最相关的 API,尤其是在开发 Web 服务方面。在下一节中,我们将开始学习 Java EE 8 微服务开发。

开始使用 Java EE 8 微服务

在本节中,我们将查看以下内容:

  • 如何开发、构建和运行您的第一个 Java-EE-8 驱动的微服务

  • 基本 Web 服务开发所需的 Java EE 8 依赖项

  • 任何基于 JAX-RS 的 Web 服务的基本组件

  • 使用 Payara Server 5 部署瘦 WAR 工件

让我们开始并深入代码。我已经准备好了我的 IDE 和一个原始的 Maven 项目框架。您在这里看到的是一个非常基础的 POM 文件:

图片

然而,还有一些事情需要补充;首先,我们需要定义 Java EE 8 API 所需的依赖项。让我们来做这件事:

        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-api</artifactId>
            <version>8.0</version>
            <scope>provided</scope>
        </dependency>

我们指定version8.0。我们还应该为这个定义适当的scope,在这种情况下是provided,因为 Java EE 8 API 将在以后由我们的应用服务器提供,并且我们已经完成了我们的依赖项。接下来,我们应该将一个beans.xml描述符添加到我们的 Web 应用的WEB-INF目录中:

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

       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
       bean-discovery-mode="all">
</beans>

我们这样做就完成了,接下来是什么?嗯,接下来我们应该启动我们的 JAX-RS 应用程序。现在让我们创建一个名为 JAXRSConfiguration 的类。名字实际上并不重要。重要的是这个类从 Application 基类扩展。记住在选择 Applicationjavax.ws.rs.core 包。同样重要的是您需要指定 @ApplicationPath 注解。这将是我们 REST API 可以访问的基础路径,因此我们称之为 "api"

package com.packtpub.javaee8;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

/**
 * Configures a JAX-RS endpoint.
 */
@ApplicationPath("api")
public class JAXRSConfiguration extends Application {
}

一旦我们启动了 JAX-RS,缺少的就是一个合适的 REST 资源。让我们创建一个名为 HelloWorldResouce 的类。我们使用了 @Path 注解,这将是我们可以在其下访问的资源路径。我们将称之为 "hello"。接下来,我们创建一个方法,一旦调用,将产生适当的响应,我们称之为 helloWorld。我们在这里使用适当的 Response。我们使用 @GET 注解来注释它,因为我们稍后将会发出 GET 请求,并且我们说它产生 MediaType.APPLICATION_JSON。然后我们返回 Response.ok,其中 ok 是当我们调用 build 时的 HTTP 响应状态 200。那么应该使用什么作为响应呢?我们将使用 Map<String, String> 作为我们的响应,并返回带有 message 键和 Hello World 值的 singletonMap

package com.packtpub.javaee8;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Map;

import static java.util.Collections.singletonMap;

/**
 * The REST resource implementation class.
 */
@Path("hello")
public class HelloWorldResource {
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response helloWorld() {
        Map<String, String> response = singletonMap("message", 
          "Building Web Services with Java EE 8.");
        return Response.ok(response).build();
    }
}

我们应该已经有一个非常简单的运行中的微服务。现在让我们将其部署到我们的 Payara Server 5 并运行它。我们将部署 WAR 文件,它已经被构建;您可以看到它已经被部署,部署耗时 5.1 毫秒。

让我们检查我们的浏览器。您应该看到 "Hello World." 消息,如下面的截图所示:

如果您不相信我,让我们只需将这里的值修改为 "Building Web Services with Java EE 8."。我们再次部署并更新我们的工件。新版本已经部署。让我们回到我们的浏览器中检查我们是否得到了适当的响应消息,如下面的截图所示:

本节内容到此结束;在下一节中,我将向您展示如何将您的 Java EE 8 微服务容器化。

容器化 Java EE 8 微服务

在本节中,我们将探讨如何使用 Docker 容器化和运行我们的 Java EE 8 微服务。我们将学习如何编写基本的 Dockerfile,我们还将看到如何使用 Payara Server 完整版和 Payara Server 微版构建和运行 Docker 镜像。让我们再次打开我们的 IDE,回到上一节中的微服务项目;这里缺少的是 Dockerfile,因此让我们创建一个。

现在的问题是:我们应该使用什么基础镜像?在使用 Payara 时,我们有两个基本选项:您可以使用完整的服务器镜像或 Payara 微版。让我们首先使用 Payara 的完整版本。Dockerfile 将如下所示:

FROM payara/server-full:5-SNAPSHOT

COPY target/hello-javaee8.war $DEPLOY_DIR

在前面的 Dockerfile 中,我们提到我们正在使用 payara/server-full。我们需要使用正确的版本,在我们的例子中这是版本 5-SNAPSHOT,然后将我们的微服务的 hello-javaee8.war 文件复制到生成的镜像的正确位置。我们需要从 target/hello-javaee8.war 发出 COPY 命令,然后将其复制到部署目录,应该就是这样,让我们看看是否成功。我们打开控制台,确保我们处于正确的目录。我们检查一切是否打包得很好,为此我们调用 mvn package 以确保 WAR 文件处于正确的状态。如果是这样,你将看到我的东西在编译时运行,没有测试,WAR 文件是最新的:

图片

我们使用 -t 构建 Docker,它指定了我们想要使用的标签,我们通过调用 hello-javaee8 并给它一个版本号,1.0 来做这件事:

>docker build -t hello-javaee8:1.0 .

使用以下命令,让我们看看服务器是否启动:

>docker run -it -p 8080:8080 hello-javaee8:1.0

我们将容器的端口 8080 映射到我们的 Docker 主机。你将看到 Payara GlassFish 服务器正在控制台中启动——这应该只需要几秒钟——然后我们应该看到我们的应用程序已部署。为了检查我们可以访问我们的 Web 服务,点击以下截图所示的 IP 地址。这是我的 Docker 主机端口 8080 的 IP 地址,我们可以访问我们的服务,这是成功的:

图片

现在让我们停止它并删除此 Dockerfile 的内容。我想向你展示如何使用 Payara micro 版本。首先,我们需要更改 FROM。为此,我们使用这个镜像的不同基础标签(payara/micro:5-SNAPSHOT),然后将 hello-javaee8.war 文件复制到这个基础镜像的正确位置。接下来,我们将我们的 WAR 文件复制到 target 目录,并将其放置到 /opt/payara/deployments。这是 micro 版本基础容器的默认 deployments 目录。Dockerfile 应该如下所示:

FROM payara/micro:5-SNAPSHOT

COPY target/hello-javaee8.war /opt/payara/deployments

切换回控制台并再次运行 Docker 构建命令:

>docker build -t hello-javaee8:1.0 .

再次启动容器:

>docker run -it -p 8080:8080 hello-javaee8:1.0

你可以看到控制台输出已改变,这次我们使用的是 Payara micro 运行时。这需要几秒钟来启动我们的 Web 服务,几秒钟后应该就完成了。我们可以看到我们的 REST 端点 可用。让我们再次检查。我们进入我们的管理控制台,可以看到我们有一个正在运行的容器。尝试从浏览器调用 Web 服务,如下面的截图所示:

图片

我们可以看到一切运行正常,并且我们已经有一个正在运行的 Docker 化的 Web 服务版本。

摘要

在本章中,我们讨论了 Java EE 以及它是一个构建现代和轻量级 Web 服务的优秀平台的事实。我们查看了一下 Java EE 8 的不同 API 以及最新的进展,重点关注与微服务更相关的 API,例如 JAX-RS、JSON-B 和 JSON-P。然后我们开发了、构建并运行了我们的 Java EE 8 微服务,并将其本地部署到 Payara 服务器上。在最后一节中,我们使用 Docker 容器化和运行了我们的 Java EE 8 微服务。

在下一章中,我们将深入探讨使用相关的 JAX-RS API 构建同步 Web 服务和客户端。

第二章:构建 Synchronous Web 服务和客户端

在本章中,我们将深入了解使用 Java EE 8 构建同步微服务的细节。我们将学习如何使用基本的 JAX-RS 注解实现服务器端 REST API,实现嵌套 REST API 的子资源定位器,并使用 HTTP 状态码和异常映射器进行异常处理。你还将学习如何使用 JAX-RS 客户端 API 实现客户端,最后,我们将探讨 Java EE 网络服务的不同测试策略。

本章将涵盖以下部分:

  • 使用 JAX-RS 实现基本 REST API

  • 使用子资源

  • JAX-RS 中的错误处理

  • 使用 Java EE 8 实现网络服务客户端

  • 测试 Java EE 8 网络服务

到本章结束时,我们将实现一个小型库微服务,该服务提供书籍、作者和借阅的 REST API。我们将实现库客户端作为一个独立的应用程序,并使用 Jersey 测试框架和 Test Containers 框架来测试我们的 REST API。

使用 JAX-RS 实现基本 REST API

在本节中,我们将探讨如何使用基本的 JAX-RS 注解实现 REST 资源。我会向你展示如何在 JAX-RS 资源实现中注入和使用 CDI 容器,并展示如何正确使用 HTTP 方法来模拟 CRUD 语义,当然我们将在 Docker 容器中运行我们的网络服务:

图片

本节的概念视图

我们将实现一个 REST API 来获取书籍列表,这样我们就能创建新的书籍,通过 ISBN 获取书籍,更新书籍,以及删除书籍。

我们将创建一个基本的项目骨架,并准备一个简单的类,称为 BookResource,我们将使用它来实现我们书籍的 CRUD REST API。所以首先,我们需要使用适当的注解来注解我们的类。我们将使用 @Path 注解来指定书籍 API 的路径,即 "books",并创建一个 @RequestScoped 的 CDI 容器。现在,为了实现我们的业务逻辑,我们想要使用另一个 CDI 容器,因此我们需要将其注入到这个容器中。这个其他 CDI 容器被称为 bookshelf,我们将使用常规的 CDI @Inject 注解来获取对 bookshelf 的引用。接下来,我们想要实现一个方法来获取所有书籍的列表,让我们来做这件事。你在这里看到的是我们有一个 books 方法,它被 @GET 注解,并产生 MediaType.APPLICATION_JSON,返回一个 JAX-RS 响应。你可以看到我们构建了一个 ok 的响应,即 HTTP 200;作为主体,我们使用 bookshelf.findAll,这是一个书籍集合,然后我们构建响应。BookResource.java 文件应该如下所示:

@Path("books")
@RequestScoped
public class BookResource {

    @Inject
    private Bookshelf bookshelf;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response books() {
        return Response.ok(bookshelf.findAll()).build();
    }

接下来,我们想要实现一个GET消息来获取特定的书籍。为了做到这一点,我们再次有一个@GET注解的方法,但这次我们有一个@Path注解,带有"/{isbn}"参数。为了获取这个参数,即所谓的isbn,我们使用@PathParam注解来传递值。我们使用bookshelf通过 ISBN 查找我们的书籍,并使用 HTTP 状态码 200,即ok返回找到的书籍:

    @GET
    @Path("/{isbn}")
    public Response get(@PathParam("isbn") String isbn) {
        Book book = bookshelf.findByISBN(isbn);
        return Response.ok(book).build();
    }

接下来,我们想要创建书籍。为了创建某些内容,按照惯例,我们使用 HTTP POST作为方法。我们消费应用程序 JSON,并期望得到书籍的 JSON 结构,我们使用bookshelf.createbook参数一起调用,然后我们使用UriBuilder来构造新创建的book的 URI;这也是一个惯例。然后我们使用Response.created返回这个 URI,这与 HTTP 状态码201相匹配,我们将调用build()来构建最终的响应:

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(Book book) {
        if (bookshelf.exists(book.getIsbn())) {
            return Response.status(Response.Status.CONFLICT).build();
        }

        bookshelf.create(book);
        URI location = UriBuilder.fromResource(BookResource.class)
                .path("/{isbn}")
                .resolveTemplate("isbn", book.getIsbn())
                .build();
        return Response.created(location).build();
    }

接下来,我们将实现现有书籍的更新方法。为了更新内容,再次按照惯例使用 HTTP 方法PUT。我们通过指定位置来更新,再次使用@Path参数,其值为"/{isbn}"。我们在update方法参数中给出这个isbn的引用,并准备好我们书籍的 JSON 结构。我们使用bookshelf.update来更新书籍,并在最后返回状态码ok


    @PUT
    @Path("/{isbn}")
    public Response update(@PathParam("isbn") String isbn, Book book) {
        bookshelf.update(isbn, book);
        return Response.ok().build();
    }

最后,我们将实现删除消息,正如你所预期的,我们在已识别的 ISBN 路径上使用 HTTP 方法DELETE。在这里,我们再次使用@PathParam注解,我们调用bookshelf.delete,如果一切顺利,我们返回ok

    @DELETE
    @Path("/{isbn}")
    public Response delete(@PathParam("isbn") String isbn) {
        bookshelf.delete(isbn);
        return Response.ok().build();
    }

这是我们对书籍资源的 CRUD 实现。我告诉过你们,我们将使用 Docker 容器和 Payara Server 微版来运行一切。我们将把我们的 WAR 文件复制到deployments目录,然后我们就可以启动运行了:

FROM payara/micro:5-SNAPSHOT

COPY target/library-service.war /opt/payara/deployments

让我们看看我们的 REST 客户端(Postman)上是否一切运行正常。

首先,我们获取书籍列表。如你所见,这按预期工作:

图片

如果我们想要创建一本新书,我们发出POST和创建新书籍请求,你会看到一个状态码 OK 200。我们通过使用GET 新书籍来获取新书籍;这就是我们刚刚创建的书籍,如以下截图所示:

图片

我们可以通过使用“更新新书籍”来更新书籍,我们将得到状态码 OK 200。我们可以再次使用GET 新书籍来获取更新后的书籍;我们得到更新后的标题,如以下截图所示:

图片

最后,我们可以删除书籍。当我们再次获取书籍列表时,我们新创建的书籍不再包含在书籍列表中。

在下一节中,我们将探讨如何使用子资源和子资源定位器。

使用子资源

在本节中,我们将探讨如何实现简单的子资源定位器方法。我们将看看如何从根资源获取 CDI 子资源实例,以及我们将探讨如何从根资源传递上下文信息到子资源:

图片

本节的概念视图

书籍有作者,并且可以被借出。在本节中,我们将提供特定的 REST 端点来获取书籍的作者和书籍的借阅详情。我们已经准备了项目的骨架,如下面的截图所示:

图片

让我们从作者开始。在BookResource.java中添加一个资源定位器方法。资源定位器方法是一个简单的只使用@Path注解的方法。在这种情况下,我们使用@Path("/{isbn}/author")。资源定位器方法的return类型是另一个资源。在这种情况下,它是AuthorResource定位器方法。因此,我们创建了AuthorResource定位器:

    @Path("/{isbn}/author")
    public AuthorResource author(@PathParam("isbn") String isbn) {
        Book book = bookshelf.findByISBN(isbn);
        return new AuthorResource(book);
    }

它生成APPLICATION_JSON。我们在构造函数中获取我们书籍的引用。接下来在这个子资源中,我们可以再次添加常用的GETPOSTPUT注解的 HTTP 方法。在这种情况下,我们有一个注解的GET方法,它获取我们书籍的作者:

@Produces(MediaType.APPLICATION_JSON)
public class AuthorResource {
    private final Book book;

    AuthorResource(Book book) {
        this.book = book;
    }

    @GET
    public Author get() {
        return book.getAuthor();
    }
}

对于简单的资源来说,这非常直接,但如果我们想使用 CDI 注入呢?如果我们想这么做,我们需要采取不同的方法。首先,我们需要获取ResourceContext的引用;确保你使用的是正确的。通过使用这个ResourceContext,我们可以获取一个完全 CDI 注入的引用。再次,我们使用@Path注解,返回loanResource,这次我们使用了LoanResource.class中的context.getResource。这返回了一个完全注入的loanResource实例:

@RequestScoped
public class BookResource {

    @Inject
    private Bookshelf bookshelf;
    @Context
    private ResourceContext context;

    @Path("/{isbn}/loans")
    public LoanResource loans(@PathParam("isbn") String isbn) {
        LoanResource loanResource =     
        context.getResource(LoanResource.class);
        loanResource.setIsbn(isbn);

        return loanResource;
    }
}

然后,我们使用@Path("/{isbn}")参数填充LoanResource。现在,重要的一点是:因为我们做了这件事,你真的需要确保这个实例是@RequestScoped。这是因为我们传递了isbn,在这里你可以实现我们需要的常规 REST 资源方法LoanResource

在这个例子中,例如,我们将获取特定的贷款,我们可以返回一本书,我们可以借出一本书来创建一笔贷款。

如果我们切换到 REST 客户端(Postman),并想通过使用 GET book author 请求来获取书籍作者,点击发送后,只返回作者信息,如下面的截图所示:

图片

我们可以获取贷款列表,如下面的截图所示:

图片

我们可以删除一笔贷款,这意味着这本书已经被归还,我们还可以添加新的贷款等等。

在下一节中,我们将介绍如何在 JAX-RS 中执行错误处理。

JAX-RS 中的错误处理

在本节中,我们将通过使用适当的 HTTP 状态码以 RESTful 方式处理用户和服务器端错误,例如,对于无效和格式错误的请求,使用 HTTP 状态码 400 Bad Request,如果找不到某些内容,则使用状态 404 Not Found,如果发生意外情况,则使用 HTTP 状态码 500 Internal Server Error。我们还将看到如何使用WebApplicationException及其子类来模拟错误处理,最后我们将实现自定义的ExceptionMappers来处理运行时异常并返回自定义错误响应:

本节的概念视图

在本节中,我们将从概念上做以下事情:我们将通过几个 HTTP 状态码扩展我们的库微服务,例如,如果你发布了一个无效的书籍更新,则为 HTTP 400,对于创建已存在的书籍,为 409,对于未知书籍和借阅,为 404,对于具有自定义响应的一般错误,为 HTTP 状态码 500。

让我们切换到我们的 IDE,并再次回到BookResource。在这里,我们还没有对适当的错误处理给予太多关注。例如,在bookshelf.create(book)中,我们看到我们没有检查书籍是否已经存在。最简单的方法是在做任何工作之前进行检查。为此,我们使用bookshelf来检查书籍——更准确地说,是书籍的 ISBN——是否已经存在,如果存在,我们返回一个自定义状态码,并将状态设置为CONFLICT,即 409,错误响应将立即返回。这是最基本的错误处理形式,通过返回带有适当状态码的响应:

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(Book book) {
        if (bookshelf.exists(book.getIsbn())) {
            return Response.status(Response.Status.CONFLICT).build();
        }

对于update来说,我们也应该检查更新的 ISBN 是否与书的 ISBN 相匹配。我们在这里可以再次设置适当的状态码,在这种情况下是BAD_REQUEST,状态码为 400:

 @PUT
 @Path("/{isbn}")
 public Response update(@PathParam("isbn") String isbn, Book book) {
 if (!Objects.equals(isbn, book.getIsbn())) {
            // return    
            Response.status(Response.Status.BAD_REQUEST).build();

还有其他方法可以做到这一点。你可以选择的一个不同方法是抛出一个WebApplicationException,它是 JAX-RS 的一部分。你给出一个原因,并给出一个状态码,再次是BAD_REQUEST。对于最常见的WebApplicationException类型,有预定义的子类;在这个WebApplicationException中,你可以看到有几个子类可用,并且已经有一个BadRequestException

让我们使用BadRequestException代替,这样就完成了。这个BadRequestException会自动为我们设置状态码为 400:

    @PUT
    @Path("/{isbn}")
    public Response update(@PathParam("isbn") String isbn, Book book) {
        if (!Objects.equals(isbn, book.getIsbn())) {
            // throw new WebApplicationException(
            "ISBN must match path parameter.", 
            Response.Status.BAD_REQUEST);
            throw new BadRequestException(
            "ISBN must match path parameter.");
        }

当然,还有许多其他可能发生的异常,例如自定义运行时异常和持久性异常,可能会抛出我们的 JPA 提供者。那么我们如何处理这些异常呢?最方便的方法是有一个PersistenceExceptionMapper实现。创建一个类并实现ExceptionMapper,并使用你想要处理的异常作为泛型类型。在这种情况下,是PersistenceException

你需要做的第一件事是使用@Provider注解对其进行注释。完成这个步骤后,你可以实现自定义转换逻辑,将PersistenceException映射到实际的Response和期望的 HTTP 状态码。例如,如果异常是EntityNotFoundException的实例,我们将返回 404,即NOT_FOUND。在发生其他任何情况时,我们希望返回一个自定义的错误响应结构。在这种情况下,我们使用一个普通的Map,一个HashMap,并可能设置一个code和一个type。我们包括message,并将Response返回为INTERNAL_SERVER_ERROR,状态码为 500,并使用type作为MediaType.APPLICATION_JSON

@Provider
public class PersistenceExceptionMapper implements ExceptionMapper<PersistenceException> {
    @Override
    public Response toResponse(PersistenceException exception) {
        if (exception instanceof EntityNotFoundException) {
            return Response.status(Status.NOT_FOUND).build();
        } else {
            Map<String, String> response = new HashMap<>();
            response.put("code", "ERR-4711");
            response.put("type", "DATABASE");
            response.put("message", exception.getMessage());

            return Response.status(Status.INTERNAL_SERVER_ERROR)
                    .entity(response)
                    .type(MediaType.APPLICATION_JSON).build();
        }
    }
}

如果我们切换到我们的 REST 客户端,我们就可以看到这些功能在实际中的运用。如果我们收到一本未知书籍,这应该会触发实体 404 NOT_FOUND异常,这是我们期望的结果。例如,如果我们对一个书籍发出错误的更新请求,我们期望 HTTP 状态码 400 Bad Request,如下截图所示:

这就是错误处理的内容。

在下一节中,我们将讨论使用 Java EE 8 实现 Web 服务客户端。

使用 Java EE 8 实现 Web 服务客户端

在本节中,我们将探讨 JAX-RS 客户端 API 以及如何实现 Web 服务客户端。我将向您展示如何设置和配置一个 JAX-RS 客户端实例。我们将使用WebTarget及其构建器来指定请求行为,解析 URI 模板参数,在响应处理中进行调用,并使用GenericType实现来获取未反序列化的类型集合:

本节的概念视图

到目前为止,我们已经实现了我们的小型图书馆服务,它通过 REST API 支持书籍、作者和借阅。然后我们将实现一个图书馆客户端,这是一个独立客户端,用于获取书籍列表、未知书籍、创建书籍、获取带有返回 URI 的书籍等等。

让我们切换到我们的 IDE。我们将创建一个名为LibraryServiceClient的小类,这是我们独立的应用程序。我们首先需要做的是激活一些依赖项。最重要的是,我们希望使用jersey-client依赖项,我们还将使用jersey-media-json-binding依赖项。这是实现我们的独立应用程序所必需的:

    <dependency>
        <groupId>org.glassfish.jersey.core</groupId>
        <artifactId>jersey-client</artifactId>
        <version>${jersey.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.glassfish.jersey.inject</groupId>
        <artifactId>jersey-hk2</artifactId>
        <version>${jersey.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.glassfish.jersey.media</groupId>
        <artifactId>jersey-media-json-binding</artifactId>
        <version>${jersey.version}</version>
        <scope>test</scope>
    </dependency>

我们需要做的第一件事是构建一个 JAX-RS 客户端实例,我们使用 ClientBuilder 来完成这个任务。在这里,当我们使用 ClientBuilder.newBuilder 时,我们指定了如 connectTimeoutreadTimeout 等参数,我们还注册了 JsonBindingFeature,然后最后在构建器上调用 build() 方法。一旦我们有了 client,我们就可以用它来构建所谓的 WebTargetWebTarget 基本上是我们将要与之通信的端点。我们使用 client.target 并提到 localhost:8080,因为我们的服务在本地运行在 localhost:8080。我们给出 path("/library-service/api"),这是我们的 REST API 的根:

public class LibraryServiceClient {

    private static final Logger LOGGER = Logger.getAnonymousLogger();

    public static void main(String[] args) {
        // construct a JAX-RS client using the builder
        Client client = ClientBuilder.newBuilder()
                .connectTimeout(5, TimeUnit.SECONDS)
                .readTimeout(5, TimeUnit.SECONDS)
                .register(JsonBindingFeature.class)
                .build();

        // construct a web target for the library service
        WebTarget api = client
          .target("http://localhost:8080")
          .path("/library-service/api");

要获取书籍列表,我们可以使用这个 WebTarget 进行调用,在这里导入一些东西。我们做的是使用 api.path("/books").request,接受 MediaType.APPLICATION_JSON,然后我们获取一个书籍列表。因为这是一个泛型类型的列表,我们需要使用 GenericType 构造。

然后,我们创建一个 GenericType 子类,并将 List<Book> 指定为 GenericType 参数。如果我们想获取一本书,可能是一本未知的书呢?如果我们获取到一本未知的书,我们期望状态码为 404。同样,我们使用 api.path("/books").path("/{isbn}");这是一个 path 参数。因此,我们使用特定的参数和值来解析模板。我们使用 requestacceptget()。通过调用 get(),我们只获取实际的 response,在 response 中,我们使用 getStatus(),它是 404

        LOGGER.log(Level.INFO, "Get list of books.");
        List<Book> books =  api.path("/books").request()
          .accept(MediaType.APPLICATION_JSON).get(bookList());
        books.forEach(book -> LOGGER.log(Level.INFO, "{0}", book));

        LOGGER.log(Level.INFO, "Get unknown book by ISBN.");
        Response response = api.path("/books")
          .path("/{isbn}").resolveTemplate("isbn", "1234567890")
          .request().accept(MediaType.APPLICATION_JSON).get();
        assert response.getStatus() == 404;
    private static GenericType<List<Book>> bookList() {
        return new GenericType<List<Book>>() {
        };
    }
}

如果我们想要创建书籍,我们可以以类似的方式完成。同样,我们创建一个新的书籍,在这里我们使用 api.path("/books").requestMediaType.APPLICATION_JSON,这指定了我们的有效负载的内容类型。我们使用 post(Entity.json(book)) 并期望状态码为 201。如果您想要获取刚刚创建的书籍,我们可以做的是获取 responseURI。我们获取位置,然后再次使用客户端针对目标 URI,我们 request(),我们接受 MediaType.APPLICATION_JSON,并获取 Book 类的 POJO。在这种情况下,我们自动获取反序列化的书籍:

        LOGGER.log(Level.INFO, "Creating new {0}.", book);
        response = api.path("/books")
          .request(MediaType.APPLICATION_JSON)
          .post(Entity.json(book));
        assert response.getStatus() == 201;

        URI bookUri = response.getLocation();
        LOGGER.log(Level.INFO, "Get created book with URI {0}.", 
          bookUri);
        Book createdBook = client.target(bookUri)
          .request().accept(MediaType.APPLICATION_JSON)
          .get(Book.class);
        assert book.equals(createdBook);

这就是 JAX-RS 客户端 API 的基本工作原理。最后但同样重要的是,您不应该忘记关闭客户端(client.close())以释放任何资源。如果您愿意,可以进行一些清理。也许我们想要删除之前创建的书籍。我们需要以下代码来删除书籍:

        LOGGER.log(Level.INFO, "Delete book with URI {0}.", bookUri);
        response = client.target(bookUri).request().delete();
        assert response.getStatus() == 200;

        client.close();
    }

这一节的内容就到这里。在下一节,我们将讨论测试 Java EE 8 网络服务。

测试 Java EE 8 网络服务

在本节中,我们将探讨 Java EE 8 网络服务的不同测试策略。我们将讨论使用纯单元测试和模拟测试简单的 CDI 组件,使用 Jersey 测试框架测试 REST 资源,以及我们将看到如何使用 Test Containers 框架进行黑盒集成测试。

到目前为止,我们通过提供书籍、作者和借阅的 REST API 来实现我们的图书馆服务库。我们还实现了图书馆客户端。在本节中,我们将讨论测试。你可以在下面的图中看到测试金字塔。在底部是单元测试。中间层是服务层测试。

在最高层,你有 UI 层测试。Java EE 中的单元测试非常简单;你可以使用你的标准测试框架,如 JUnit 测试,并且你可能使用 Mojito 或其他模拟框架来模拟任何依赖项:

本节的概念视图

我们在这里不会关注单元测试。Java EE 中真正令人愉快的是我们可以如何进行服务级别测试。有两个框架:

  • Jersey Test Framework

  • Test Containers 框架

让我们同时使用它们。

Jersey Test Framework

让我们从Jersey Test Framework开始。我们将切换到我们的 IDE,为了使用 Jersey Test Framework,你需要添加一个简单的依赖项,如下所示:

    <dependency>
        <groupId>org.glassfish.jersey.test-  
          framework.providers</groupId>
        <artifactId>jersey-test-framework-provider-
          grizzly2</artifactId>
        <version>${jersey.version}</version>
        <scope>test</scope>
    </dependency>

Jersey 提供了几个测试提供者;在这种情况下,我们将使用grizzly2提供者。要使用这个测试框架,你需要做什么?首先,你需要实现一个测试类,比如VersionResourceTest,并且你需要让它继承JerseyTest超类。接下来你需要做的是重写configure方法,在这里你做的是构建一个ResourceConfig,并传递你想要测试的资源。在我们的例子中,我们想要测试VersionResource

public class VersionResourceTest extends JerseyTest {

    @Override
    protected Application configure() {
        ResourceConfig config = new  
          ResourceConfig(VersionResource.class);

下一步你可以做的是配置用于测试我们资源的客户端。这里的客户端实际上是我们在上一节中使用过的同一个,即JsonBindingFeature,一旦你完成了这个,你就可以实现实际的测试:

    @Override
    protected void configureClient(ClientConfig config) {
        // for JSON-B marshalling
        config.register(JsonBindingFeature.class);
    }

如果我们要测试v1资源,我们可以做的是指定目标版本为v1。我们使用requesttarget,然后在返回的response上,我们可以指定我们通常的断言。然后,断言响应状态码为 200,并且字符串类型的实体包含字符串"v1.0"

    @Test
    public void v1() {
        Response response = target("/version/v1").request().get();
        assertThat(response.getStatus(), is(200));
        assertThat(response.readEntity(String.class), is("v1.0"));
    }

现在,让我们运行我们的测试。你可以看到这里,测试实际上启动了一个小的grizzly服务器,它部署了我们的资源,然后它实际上对资源发起了 HTTP 请求。这些都是适当的集成测试:

Test Containers

让我们来看看下一个测试框架,它被称为Test Containers。我们需要添加以下两个依赖项来激活 Test Containers 框架:

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>1.5.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.25</version>
            <scope>test</scope>
        </dependency>

Test Containers 背后的理念非常简单。由于我们无论如何都要在 Docker 容器中部署我们的 Web 服务,为什么不在测试期间也使用 Docker 容器呢?您需要添加的只是一个@ClassRule。我们创建GenericContainer,将其传递给Dockerfile,传递给我们要打包的 WAR 文件(library-service.war),我们可以指定一个Wait策略,一个LogConsumer,并暴露端口。所有这些代码所做的就是在测试开始时启动一个 Docker 容器:

    @ClassRule
    public static GenericContainer container = 
      new GenericContainer(new ImageFromDockerfile()
            .withFileFromFile("Dockerfile", 
              new File(basePath(), "Dockerfile"))
            .withFileFromFile("target/library-service.war", 
              new File(basePath(), 
              "target/library-service.war")))
            .waitingFor(Wait.forHttp("
              /library-service/api/application.wadl")
                    .withStartupTimeout(Duration.ofSeconds(90)))
            .withLogConsumer(new Slf4jLogConsumer(
              LoggerFactory.getLogger(
              LibraryServiceContainerTest.class)))
            .withExposedPorts(8080)
            .withExtraHost("localhost", "127.0.0.1");

setUp阶段,我们可以做的是像之前做的那样设置一个 JAX-RS 客户端实例,一旦我们有了客户端,我们就可以针对容器 URI 设置一个网络目标。我们可以请求我们的服务容器的 IP 地址和映射端口,一旦我们有了网络target,我们就可以使用 JAX-RS 客户端 API 与我们的微服务进行交互:

    @Before
    public void setUp() {
        client = ClientBuilder.newBuilder()
                .connectTimeout(5, TimeUnit.SECONDS)
                .readTimeout(5, TimeUnit.SECONDS)
                .register(JsonBindingFeature.class)
                .build();

        String uri = String.format("http://%s:%s/library-service/api",
                container.getContainerIpAddress(),   
                  container.getMappedPort(8080));
        api = client.target(uri);
    }

现在,从您的控制台运行以下命令:

>mvn integration-test

这所做的基本上是运行单元测试和集成测试。首先,Surefire 插件测试我们的版本资源,我们看到一切都在启动,它运行其他单元测试,打包 WAR 文件。Failsafe 插件将运行我们的容器集成测试。它将创建并启动容器;这可能需要相当长的时间。您可以在下面的屏幕截图中看到测试已成功完成:

图片

摘要

让我们总结一下本章所学的内容。我们查看了一些基本的 JAX-RS 注解,以便实现具有 CRUD 功能的 REST API。我们使用了顶级资源定位器来优雅地建模嵌套 REST API。我们使用了 HTTP 状态码和异常映射来进行错误处理。我们使用了 JAX-RS 服务客户端 API 来实现 Web 服务客户端。最后,我们探讨了使用几种方法测试基于 Java EE 8 的 Web 服务。我希望你喜欢这一章。在下一章中,我们将讨论使用 JSON-B 和 JSON-P 进行内容序列化。

第三章:使用 JSON-B 和 JSON-P 进行内容序列化

在本章中,我们将重点关注 Web 服务的数据结构和有效载荷。您将学习如何正确使用内容类型和内容协商来为您的 Web 服务,如何使用新的 JSON-B API 进行简单的数据绑定,如何使用 JSON-P API 进行非常灵活的 JSON 处理,以及如何使用它来实现由超媒体驱动的 REST API。

本章将涵盖以下主题:

  • 内容类型和内容协商简介

  • 使用 JSON-B 进行简单数据绑定

  • 使用 JSON-P 进行灵活的 JSON 处理

  • 实现由超媒体驱动的 REST API

内容类型和内容协商简介

在本节中,我们将探讨如何使用@Produces@Consumes注解来指定内容类型。我们还将了解使用自定义内容类型进行 API 版本控制,使用服务器质量因子进行智能内容协商,以及如何提供和上传二进制内容。

让我们切换到代码并打开我们的 IDE。让我们看看我们准备的小型 REST 服务。如您所知,您可以使用@Produces@Consumes注解来指定您的 REST 服务将作为内容类型消费什么,以及您的 REST 服务将产生什么内容类型。我们在这里做的是指定application/json。这是我们通常做的事情。我们实现这个方法并返回一个包含状态码ok的简单Map。使用 JAX-RS,我们将确保这个Map被序列化为正确的 JSON:

    @GET
    @Produces("application/json")
    public Response v1() {
        Map<String, String> version = 
          Collections.singletonMap("version", "v1");
        return Response.ok(version).build();
    }

如果我们想实现这个的第二版,我们可以通过实现名为v2的方法并返回不同的内容来实现:

    @GET
    @Produces("application/json")
    public Response v2() {
        Map<String, String> version =    
          Collections.singletonMap("version", "v2");
        return Response.ok(version).build();
    }

现在,这里将无法正确工作,因为我们有两种方法产生相同的内容类型。我们可以做的是指定一个自定义的MediaType。我们将指定MediaType v1MediaType v2的类型为"application",并且我们将使用自定义子类型。我们有一个子类型用于版本一和 JSON 格式("vnd.version.v1+json"),另一个子类型用于版本二和 JSON 格式("vnd.version.v2+json"):

public class VersionResource {

    /**
     * MediaType implementation for the version resource in v1.
     */
    public static final MediaType V1 = new MediaType(
 "application", "vnd.version.v1+json");

    /**
     * MediaType implementation for the version resource in v2.
     */
    public static final MediaType V2 = new MediaType(
 "application", "vnd.version.v2+json");

我们可以使用这些自定义内容类型v1v2。既然这样,我们就有了使用内容类型的v1v2的 API 版本。如果我们使用 API 版本,这就是它应该的样子。v1支持应用 JSON,也支持 JSON 格式的v1内容类型:

    @GET
    @Produces({"application/json, 
      "application/vnd.version.v1+json"})
    public Response v1() {
        Map<String, String> version = 
          Collections.singletonMap("version", "v1");
        return Response.ok(version).build();
    }

如何让客户端指定或知道它接受哪种内容类型?基本上,他们可以为此特定内容类型指定接受头。如果他们没有指定,我们指定一个称为服务器质量的因子,即qsqs=0.75qs=1。如果客户端没有指定内容类型,"application/vnd.version.v1+json"将始终获胜,因为它具有更高的因子:

    @GET
    @Produces({"application/json; qs=0.75", 
      "application/vnd.version.v1+json; qs=1.0"})

让我们来看看如何使用二进制内容。我们将准备两种方法:提供 JPEG 图像和 GIF 图像。我们只需打开一个文件并将文件发送回去:

    @GET
    @Path("/me.jpg")
    @Produces("image/jpeg")
    public Response jpg() {
        String path = context.getRealPath("/me.jpg");
        return Response.ok(new File(path))
                .header("Content-Disposition", "attachment; 
                  filename=me.jpg")
                .build();
    }

    @GET
    @Path("/magic.gif")
    @Produces("image/gif")
    public Response gif() {
        String path = context.getRealPath("/magic.gif");
        return Response.ok(new File(path)).build();
    }

我们还可以实现并上传一个机制,使用 HTTP POST方法。我们将消费MULTIPART_FORM_DATA。当你用一个名为"file"的参数引用表单,它是一个输入流以获取文件名时,你也可以引用@FormDataParam并使用FormDataContentDisposition

    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    public Response upload(
      @FormDataParam("file") InputStream inputStream,
      @FormDataParam("file") FormDataContentDisposition fileInfo) {

        String fileName = fileInfo.getFileName();
        saveFile(inputStream, fileName);

        URI uri = uriInfo.getBaseUriBuilder()
          .path(DocumentsResource.class)
          .path(fileName).build();
        return Response.created(uri).build();
    }

现在,让我们打开我们的 REST API。我们不指定任何内容;我们只发送版本号并接收"v1",这是默认设置:

图片

我们还得到了"v1",因为我在这里明确设置了 Accept 头:

图片

要获得"v2",我们必须指定包含application/vnd.version.v2+json内容类型的 Accept 头:

图片

最后,我们可以看到 GIF 图像和 JPEG 的提供也是正常工作的。我们可以请求一个 GIF 图像,正如你所看到的,我们有内容类型的魔力:

图片

在下一节中,我们将讨论使用 JSON-B 的简单数据绑定。

使用 JSON-B 的简单数据绑定

在本节中,我们将探讨如何使用 JSON-B 进行 JSON 和 POJO 数据结构的序列化和反序列化,如何在 POJO 上使用 JSON-B 注解,例如@JsonbProperty@JsonbNumberFormat@JsonbDateFormat@JsonbTransient@JsonbPropertyOrder,以及最后如何显式创建JsonbConfigJsonb实例使用JsonbBuilder

让我们开始并切换到代码。我们将创建一个JsonResource类。这是我们基本的 REST 资源,我们想要实现我们的基本方法,从 POJO 返回 JSON 结构,以及从 JSON 结构反序列化我们的 POJO。

第一部分很简单;首先,我们实现一个基本的@GET方法。我们称之为marshall,它返回一个JsonbPojoJsonbPojo是一个简单的 POJO 对象:它是一个简单的类。我们将使用@Produces应用 JSON 和 JAX-RS,Java EE 8 将确保这个 POJO 使用 JSON-B 正确地序列化为 JSON:

@Produces(MediaType.APPLICATION_JSON)
public class JsonbResource {
    @GET
    public JsonbPojo marshall() {
        return jsonbPojo;
    }

反序列化也是如此。假设我们想要将一个JsonbPojo通过这个 REST 资源POST。在类级别上,我们确保使用@Consumes应用 JSON。如果你向这个 REST 资源POST一个合适的 JSON,JAX-RS 和 Java EE 8 将确保这个 JSON 结构被反序列化和反序列化为JsonbPojo

    @POST
    public void unmarshall(JsonbPojo pojo) {
        LOGGER.log(Level.INFO, "Default Unmarshalled {0}", pojo);
        this.jsonbPojo = pojo;
    }

如果你不喜欢默认的序列化,你可以自己处理序列化,使用jsonb实例。它提供了一个名为toJson的方法,你可以传递给任何对象,它将返回一个字符串作为输出,反之亦然。你可以这样说,它期望 JSON 字符串作为第一个参数,最终 POJO 的类作为第二个参数。如果你一切顺利,你会收到反序列化的对象:

    @GET
    @Path("/custom")
    public String marshallCustom() {
        return jsonb.toJson(customJsonbPojo);
    }

让我们更详细地看看如何使用这个jsonb。在这里,我们将准备简单的单元测试。我们总能做的是创建并使用 JSON-B 独立,而不需要任何 JAX-RS 资源。你应该做的一件事是使用JsonbConfig并确保我们导入那里的一切。我们将创建一个新的JsonbConfig,在这个JsonbConfig上,我们可以设置几个参数。例如,你可以指定一个属性排序策略,其中我们使用LEXICOGRAPHICAL。你也可以在任何情况下指定REVERSEANY。我们可以指定是否要序列化 null 值,使用属性命名策略,在这种情况下,LOWERCASE_CASE_WITH_DASHES,我们可以指定生成的 JSON 是否格式化,可以指定默认的日期格式,可以指定如何处理二进制数据,并且可以指定全局区域设置。使用jsonbConfig非常直接;我们使用 JSON-B 构建器(JsonbBuilder)并调用其上的create方法,并传递createjsonbConfig):

    @Before
    public void setUp() throws Exception {
        JsonbConfig jsonbConfig = new JsonbConfig()
                .withPropertyOrderStrategy(
                  PropertyOrderStrategy.LEXICOGRAPHICAL)
                .withNullValues(true)
                .withPropertyNamingStrategy(PropertyNamingStrategy
                   .LOWER_CASE_WITH_DASHES)
                .withFormatting(false)
                .withDateFormat("dd.MM.yyyy", Locale.GERMANY)
                .withBinaryDataStrategy(BinaryDataStrategy.BASE_64)
                .withLocale(Locale.GERMANY);

        jsonb = JsonbBuilder.create(jsonbConfig);
    }

一旦我们获得了这个 JSON-B 实例,在测试方法中,我们使用jsonb.toJson(pojo)并从 JSON 中获得字符串 JSON-B。传递字符串数据和想要转换成的类,pojo将被返回:

    @Test
    public void testToJsonWithPlainPojo() {
        PlainPojo pojo = PlainPojo.create();
        String json = jsonb.toJson(pojo);
        assertThat(json).isEqualTo(PLAIN_POJO_JSON);
    }

这适用于普通的 POJO 以及未被特别注解的 POJO。如果我们想覆盖这些默认配置,我们可以像之前一样使用@JsonbPropertyOrder注解我们的 POJO。例如,为了指定非常明确的属性顺序,我们可以使用@JsonbProperty来给它一个不同的名称,使用@JNumberFormat来指定要使用的数字格式,使用@JsonbDateFormat来指定不同的日期,或者使用@JsonbTransient,这告诉 JSON-B 在序列化和反序列化过程中忽略这个属性:

    @JsonbPropertyOrder(value = {"message", 
      "answerToEverything", "today"})
    public static class AnnotatedPojo {
        @JsonbProperty(value = "greeting", nillable = true)
        public String message;

        @JsonbNumberFormat("#,##0.00")
        public Integer answerToEverything;

        @JsonbDateFormat("MM/dd/yyyy")
        public LocalDate today;

        @JsonbTransient
        public BigDecimal invisible = BigDecimal.TEN;

让我们运行这个测试。我们的测试应该如以下截图所示,希望是绿色的:

图片

在本节中,我们看到了使用 JSON-B 实际上非常简单直接。在下一节中,我们将讨论使用 JSON-P 的灵活 JSON 处理。

使用 JSON-P 进行灵活的 JSON 处理

在本节中,我们将探讨使用 JSON-P 构建器来构建 JSON 数组和对象。我们将看到如何在 REST 资源中使用 JSON-P 进行序列化和反序列化数据,如何使用 JSON 指针访问 JSON 结构,并更详细地看看 JSON Patch 和 JSON Diff 如何修改 JSON 结构。我们还将使用@PATCH注解和application/json-patch+json内容类型在我们的 REST 资源中应用补丁,所以接下来有很多内容。

让我们开始吧。像往常一样,我们准备一个小型的 REST 资源作为模板来开始。我们首先做的事情是使用相关的构建器创建 JSON 和 JSON 对象的数组,所以让我们来做这件事:

    public void testJsonBuilder() {
        JsonArray values = Json.createArrayBuilder()
                .add(Json.createObjectBuilder()
                        .add("aString", "Hello Json-P 1")
                        .add("aInteger", 42)
                        .add("aBoolean", false)
                        .add("aNullValue", JsonValue.NULL)
                        .build())

在这里,我们使用 createArrayBuilder 创建一个数组构建器,并使用 add 方法添加 JSON 对象。在这里,你可以使用 Json.createObjectBuilder 获取一个对象构建器。在这个对象构建器上,我们然后调用不同的 add 方法来添加一个字符串、一个整数、一个布尔值或者可能使用特殊的 JsonValue 添加一个 null 值。就是这样。使用这两个构建器,你可以相当容易地创建复杂的 JSON 结构。

我们该如何处理这个呢?我们首先做的事情是返回 jsonArray;这实际上是非常直接的。你可以明确并直接返回这个 jsonArray 以进行序列化。为此,我们将生成一个 APPLICATION_JSON 作为我们的内容类型,JAX-RS 将确保我们的 jsonArray 被序列化和打包到相应的 JSON 结构中:

@Produces(MediaType.APPLICATION_JSON) 
   @GET
    public JsonArray marshall() {
        return jsonArray;
    }

如果我们想使用 JSON-P 反序列化数据,情况也是一样的。我们将消费 APPLICATION_JSON,获取基本为 jsonBodyInputStream,然后我们将使用 JsonReader。在这里,我们将从 InputStream 使用 Json.CreateReader(jsonBody) 获取一个 JsonReader,并在 reader 上读取数组:

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public void unmarshall(InputStream jsonBody) {
        JsonReader reader = Json.createReader(jsonBody);
        this.jsonArray = reader.readArray();

        LOGGER.log(Level.INFO, "Unmarshalled JSON-P {0}.", jsonArray);
    }

让我们看看 JSON-P 中还有什么。首先,有 JSON 指针。让我们来看看 JSON 指针。假设我们有一个简单的 JSON 结构。在这个测试中,我们将使用一个字符串创建一个读取器,并从读取器获取一个 JsonObject 和一个 JsonArray

    @Test
    public void testJsonPointer() {
        JsonReader jsonReader = Json.createReader(new StringReader("
          {\"aString\":\"Hello Json-P\",\"arrayOfInt\":[1,2,3]}"));
        JsonObject jsonObject = jsonReader.readObject();
        JsonArray jsonArray = jsonObject.getJsonArray("arrayOfInt");

如果我们想通过索引访问数组值怎么办?为此,我们使用 JSON 指针。我们将使用 Json.createPointer,并且使用这个注解基本上指定了我们想要引用的路径和值的索引。我们还将创建一个 jsonPointer,并在 jsonPointer 上设置 getValue 并传递 JsonObject

这样做,我们将得到一个 jsonValue,我们可以做的是检查 JsonNumberjsonArray 实例的值是否正确:

        // access an array value by index
        JsonPointer jsonPointer = Json.createPointer("/arrayOfInt/1");
        JsonValue jsonValue = jsonPointer.getValue(jsonObject);
        assertThat(jsonValue).isInstanceOf(JsonNumber.class);
        assertThat(jsonValue).isEqualTo(jsonArray.get(1));

我们还可以使用 JSON 指针替换数组中的对象,例如,或者在 JSON 结构中。我们使用 jsonPointer.replace,给它原始的 jsonObject,并指定我们想要用 createValue(42) 替换指针值的新的 createValue(42) 值:

        // replace the array value by index
        jsonObject = jsonPointer.replace(jsonObject, 
          Json.createValue(42));
        jsonArray = jsonObject.getJsonArray("arrayOfInt");
        assertThat(jsonArray.getInt(1)).isEqualTo(42);

我们还可以使用 JSON 指针从 JSON 的结构中删除内容。在这里,我们可以在 remove(jsonObject) 上使用 JSON 指针,并返回一个新的 JSON 对象。如果我们检查 JSON 数组,其大小将小于之前:

        // remove the array value by index
        jsonObject = jsonPointer.remove(jsonObject);
        jsonArray = jsonObject.getJsonArray("arrayOfInt");
        assertThat(jsonArray.size()).isEqualTo(2);
    }

关于 JSON-P 的另一件事是 JSON Patch。我们首先创建一个 JSON 对象,一个 jsonReader,传递一个字符串,并从读取器中读取对象。我们将创建一个 JsonPatch。为此,我们将使用 createPatchBuilder,在 patch 中我们想要说的是,请将元素 "/aString" 替换为 "Patched Json-P." 值,并请从 "/arrayOfInt/1" 中删除它。因此,你可以使用 JSON Patch 来指定修改操作,例如替换、删除和向 JSON 结构中添加值。在 patch 上,我们调用 apply 方法,并将其作为参数传递给我们要应用补丁的 JSON 结构。这将返回一个新的和修改后的 JSON 对象。在这里,我们可以确保修改是正确完成的:

    @Test
    public void testJsonPatch() {
        JsonReader jsonReader = Json.createReader(
          new StringReader("{\"aString\":
            \"Hello Json-P\",\"arrayOfInt\":[1,2,3]}"));
        JsonObject jsonObject = jsonReader.readObject();

        JsonPatch patch = Json.createPatchBuilder()
                .replace("/aString", "Patched Json-P.")
                .remove("/arrayOfInt/1")
                .build();

        jsonObject = patch.apply(jsonObject);
        assertThat(jsonObject.getString("aString"))
          .isEqualTo("Patched Json-P.");
        assertThat(jsonObject.getJsonArray("arrayOfInt")
          .size()).isEqualTo(2);
    }

另一件相当不错的事情是 JSON Diff 功能。假设我们有一个 source 和一个 target 对象。如你所见,它们都有一种相似之处——都有一个名为 "aString" 的元素,但值不同。我们接下来要做的是创建一个 diff,比如 Json.createDiff(source, target),我们得到的是一个 JsonPatch,它描述了需要应用到 source 上的必要更改,以便我们可以得到 target 对象。如果我们查看我们的 JSON Diff,我们可以看到所需的所有操作只是一个 replace 操作。对于以下路径,我们对 "/aString" 做同样的操作,我们需要将 "value" 替换为 "xyz"。如果我们将这个补丁应用到 source 对象上,我们得到 target 对象,我们通过将 diff 应用到 source 上来实现这一点。我们得到一个新的对象,并断言 source 等于 target

    @Test
    public void testJsonDiff() {
        JsonObject source = Json.createObjectBuilder()
          .add("aString", "abc").build();
        JsonObject target = Json.createObjectBuilder()
          .add("aString", "xyz").build();

        JsonPatch diff = Json.createDiff(source, target);
        JsonObject replace = diff.toJsonArray().getJsonObject(0);
        assertThat(replace.getString("op")).isEqualTo("replace");
        assertThat(replace.getString("path")).isEqualTo("/aString");
        assertThat(replace.getString("value")).isEqualTo("xyz");

        source = diff.apply(source);
        assertThat(source).isEqualTo(target);
    }

你也可以在你的 JAX-RS 资源中使用 JSON Patch。为此,我们必须使用以下两个注解。首先,我们使用 @PATCH 并指定 @Consumes 的媒体类型为 APPLICATION_JSON_PATCH_JSON。我们发送的结构是一个 JsonArray,然后从该 JsonArray 中创建一个 jsonPatch,我们可以用它来应用到我们的数据结构中:

    @PATCH
    @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)
    public void patch(JsonArray jsonPatchArray) {
        LOGGER.log(Level.INFO, "Unmarshalled JSON-P Patch {0}.", 
          jsonPatchArray);

        JsonPatch jsonPatch = Json.createPatchBuilder(jsonPatchArray)
          .build();
        this.jsonArray = jsonPatch.apply(jsonArray);
        LOGGER.log(Level.INFO, "Patched {0}.", jsonArray);
    }
}

这一部分涵盖了 JSON-P 的内容很多。在下一节中,我们将使用我们迄今为止所学的内容来实现一个超媒体驱动的 REST API。

实现超媒体驱动的 REST API

在本节中,我们将探讨如何使用超媒体(带有链接和 URI)遍历 REST 资源。我们将看到如何使用 JSON-P 构建超媒体启用的 JSON 结构。我们将使用 @ContextUriInfo 对象来程序化地构建资源 URI。我们还将查看如何在 HTTP 响应中设置带有 URI 的链接头。

让我们开始吧,切换到我们的 IDE。我们将准备一个资源,这个资源将提供书籍和作者;它们都是独立的 REST 资源。显然,书籍是由作者写的,所以我们应该能够从书籍导航到作者,反之亦然。这就是我们可以使用超媒体的地方。

导航到我们的书籍资源。在这里,我们有为特定书籍提供的方法。首先,我们将获取书籍,然后我们可以为这本书构造 URI。createBookResourceUri 是用于引用这本书的 URI:

        @GET
        @Path("/{isbn}")
        public Response book(@PathParam("isbn") String isbn) {
            Book book = books.get(isbn);
            URI bookUri = createBookResourceUri(isbn, uriInfo);
            URI authorUri = createAuthorResourceUri(book
              .authorId, uriInfo);
            return null;
        }

我们还想要为这本书构造作者 URI。如果您查看这里的一个方法,您会看到我们使用uriInfo对象,并从中获取一个基本 URI 构建器。然后,我们使用path方法来实际构建最终的 URI。使用这些path方法,我们可以从资源的@Path注解和资源方法中构建路径:

    static URI createAuthorResourceUri(Integer authorId, 
      UriInfo uriInfo) {
        return uriInfo.getBaseUriBuilder()
                .path(HateosResource.class)
                .path(HateosResource.class, "author")
                .path(AuthorResource.class, "author")
                .build(authorId);
    }

在最终结果中,我们有一个引用实际资源的 URI:

            URI authorUri = createAuthorResourceUri(book
              .authorId, uriInfo);

接下来,我们要做的是从bookUri创建一个 JSON 对象,这正是超媒体发挥作用的地方。让我们看看它们。我们将使用 JSON-P 创建一个对象构建器,并向其中添加"isbn""title"。然而,这里缺少一个部分,这个部分使得最终的超媒体启用 JSON 结构。我们将添加一个额外的对象,称为"_links",这是一个 JSON 对象,它包含两个其他 JSON 对象,分别称为"self""author""self"描述了 REST 资源的 URI 本身,在这种情况下是书籍类型。然后,我们指定"author",并给它一个指向authorUri"href"属性:

        private JsonObject asJsonObject(Book book, URI bookUri, 
          URI authorUri) {
            return Json.createObjectBuilder()
                    .add("isbn", book.isbn)
                    .add("title", book.title)
                    .add("_links", Json.createObjectBuilder()
                            .add("self", Json.createObjectBuilder()
                                    .add("href", bookUri.toString()))
                            .add("author", Json.createObjectBuilder()
                                    .add("href", 
                                      authorUri.toString())))
                    .build();
        }

最后,我们返回 JSON 对象,在response对象上,您也可以设置 HTTP 链接头,这样您就有两种选择。您可以在 HTTP response中指定链接头,或者嵌入 URI,这在这里添加了一个链接 JSON 结构。我们已经完成了书籍。

我们几乎可以对作者做同样的事情;这段代码几乎是复制粘贴的。这遵循相同的程序:我们获取作者,然后为作者和书籍构造 URI。我们在将要嵌入"self"链接的 JSON 对象中构建我们的 JSON 对象,"self""books"本身,并为booksUri做同样的事情。最后,您返回这个 JSON 对象的响应,我们还可以嵌入link HTTP 头。

现在,让我们测试这个 API。打开我们的 Postman,对书籍列表发出GET请求。在这里,您将看到书籍有一个标题,它还包含一个链接列表。对于"self",这是书籍本身,对于"author",我们得到书籍的作者,如下面的截图所示:

图片

让我们点击书籍,即"self"中存在的 URI,以获取这本书的特定信息。如您所见,这返回了一个单独的书籍结构。如果您想导航到作者,我们可以使用作者链接。在这里,我们有这本书的作者,如下面的截图所示:

图片

如果我们想获取这位作者所写的书籍列表,我们可以使用"books"链接,该链接从这位作者的 ID 获取所有书籍,如前一张截图所示。

如果你想要再次查看这本书,你可以从这两本书中进行导航。你还可以在这里的头部看到,我们有两个链接,一个是作者的链接,另一个是书籍本身的链接:

图片

摘要

让我们总结一下本章所学的内容。首先,我们探讨了如何在我们的 Web 服务中使用自定义内容类型和内容协商。接下来,我们了解了 JSON-B 以及如何使用它来轻松地将你的 POJOs 与 JSON 进行数据绑定。我们还探讨了 JSON-P,这是一种非常灵活的 JSON 处理方式,以及如何使用 JSON-P 创建 JSON 结构和回溯这些结构。然后,我们研究了如何使用 JSON Pointers、JSON Patch 和 JSON Diff 进行更灵活的 JSON 处理,最后,我们探讨了使用 JSON-P 和UriInfo实现具有超媒体功能的 REST API。

在下一章中,我们将讨论构建异步 Web 服务。

第四章:构建异步 Web 服务

在本章中,我们将讨论异步处理的动机和原因。然后,我们将看到使用 JAX-RS 的基本异步 Web 服务实现。然后,我们将探讨使用ManagedExecutorService和服务器端回调来改进我们的实现。最后,我们将使用异步 JAX-RS API 客户端进行 REST 调用,并探讨异步处理的好处和用例。

本章包括以下部分:

  • 异步处理的好处和用例

  • 实现异步 Web 服务

  • 使用ManagedExecutorService和服务器端回调

  • 实现异步 Web 服务客户端

异步处理的好处和用例

在本节中,我们将探讨异步请求处理的动机和原因以及为什么这对您很重要。我需要告诉您的是,免费午餐结束了!并发很重要

让我们看看以下图表:

图片

我们可以看到处理器上的晶体管数量一直在增加;然而,自 2004 年以来,时钟速度基本上保持不变。这意味着您需要更并发才能获得更多速度,我们通常通过使用线程来实现这一点。

默认情况下,服务器上的请求处理通常以同步模式工作,这意味着每个请求都在单个 HTTP 线程中处理。这是我们习惯的;我们有一个线程,并在其中执行请求响应。不幸的是,线程非常昂贵,所以在高负载和大量并发连接的情况下,有很多浪费的资源,服务器扩展得并不好。幸运的是,我们有异步处理选项。

基本思想

异步处理的基本思想是使用不同的线程池来分离我们的请求 I/O 线程和请求处理线程。这基本上让我们的 I/O 线程在处理不同线程上的处理时接收新的连接。

目标

最终目标是通过对或减少上下文切换的使用来节省内存并提高我们应用程序的性能,我们还可以通过基本上将请求 I/O 与请求处理分离来提高吞吐量。

这些是主要动机和原因。在下一节中,我们将讨论实现异步 Web 服务。

实现异步 Web 服务

在本节中,我们将探讨实现异步 REST 资源。我们将看到@Suspended注解和AsyncResponse类的基本用法。我们将查看在另一个线程中处理和恢复AsyncResponse实例,并且我们还将讨论异步响应的基本超时处理。

让我们开始并切换到代码。像往常一样,我们准备了一些模板以开始。首先,我想向你展示异步资源的基本结构——看看签名。你所需要做的就是实现一个至少有一个参数使用@Suspended注解的public void方法。作为类型,它使用由 JAX-RS API 提供的AsyncResponse类:

    @GET
    public void calculate(@Suspended final AsyncResponse 
      asyncResponse) {

让我们从实现开始。

我们想在单独的线程中做一些重量级的处理。首先,我们将启动一个新线程,并在该线程中进行计算。为了模拟一些重量级的处理,我们将让它休眠三秒钟,然后产生一些输出。为此,我们返回请求线程(requestThreadName)。我们还需要当前线程的名称,我们使用getCurrentThreadName来获取它:

        asyncResponse.setTimeout(5, TimeUnit.SECONDS);

        final String requestThreadName = getCurrentThreadName();

        new Thread(() -> {
            try {
                // simulate heavy processing here
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                LOGGER.log(Level.WARNING, "Could not wait for 3s.", e);
            }
            final String responseThreadName = getCurrentThreadName();

最后,我们从requestThreadresponseThread构建一个response

            Map<String, String> response = new HashMap<>();
            response.put("requestThread", requestThreadName);
            response.put("responseThread", responseThreadName);

            asyncResponse.resume(Response.ok(response)
              .build());
        }).start();
    }

这是异步 REST 资源的结构基础。我们启动一个新线程,处理它,构建一个response,最后,我们在response上调用resume方法。

为了稍微复杂一些,我们可以使用BlockingQueue,并且有一个名为lock的方法,它接受带有@Suspended注解的异步响应。我们希望将异步响应保存到队列中:

    private LinkedBlockingQueue<AsyncResponse> responses = 
      new LinkedBlockingQueue<>();

    @GET
    public void lock(@Suspended final AsyncResponse asyncResponse) 
      throws InterruptedException {
        String currentThreadName = getCurrentThreadName();
        LOGGER.log(Level.INFO, "Locking {0} with thread {1}.", 
          new Object[]{asyncResponse, currentThreadName});

        responses.put(asyncResponse);

AsyncResource类中,我们有一个unlock方法,我们想在当前锁定响应上恢复处理。我们从队列中获取asyncResponse——这将从队列中拉出异步响应——然后我们在response上调用resume方法。这将基本上恢复之前锁定的请求:

    @DELETE
    public Response unlock() {
        String currentThreadName = getCurrentThreadName();
        AsyncResponse asyncResponse = responses.poll();

        if (asyncResponse != null) {
            LOGGER.log(Level.INFO, "Unlocking {0} with thread {1}.", 
              new Object[]{asyncResponse, currentThreadName});
            asyncResponse.resume(Response.ok(Collections.singletonMap(
              "currentThread", currentThreadName)).build());
        }

        return Response.noContent().build();
    }

最后,我们想添加一些超时行为,并且我们可以在asyncResponse上设置一个特定的超时。如果超时被超过,将返回 HTTP 403 状态码:

        asyncResponse.setTimeout(5, TimeUnit.SECONDS);
        asyncResponse.setTimeoutHandler((response) -> {
            responses.remove(response);
            response.resume(Response.status(Response.Status
              .SERVICE_UNAVAILABLE).build());
        });

如果你想测试这个,切换到你的 REST 客户端。首先,我们向 API 线程发送一个GET请求,然后我们实现它。正如我们所看到的,"requestThread"http-listener(3),而"responseThread"是另一个完全不同的线程:

图片

对于异步GET请求,我们做同样的事情;我们发出一个GET请求,这将导致阻塞。我们调用解锁(DELETE)方法,并得到如以下截图所示的 204 无内容:

图片

如果我们切换到已经发出的GET请求,我们会得到 503 服务不可用,因为在这种情况下我们等待时间过长,如以下截图所示:

图片

现在,如果我们调用DELETE方法和GET方法,我们会看到"currentThread"http-listener(6),如以下截图所示:

图片

这节的内容就到这里。

在下一节中,我们将看到如何使用ManagedExecutorService和服务器端回调。

使用ManagedExecutorService和服务器端回调

在本节中,我们将探讨使用ManagedExecutorService实例进行异步请求处理。我将向你展示如何使用CompletableFuture来运行和恢复异步请求。我们将讨论使用TimeoutHandler实例进行细粒度计时器控制,并且我们将使用CompletionCallbackConnectionCallback实例来进一步控制请求处理。

让我们开始并切换到代码。像往常一样,我们准备一个模板项目开始。我们首先想做的事情是使用一个ManagedExecutorService实例。因此,我们将把这个实例注入到我们的 REST 资源中:

@Resource
private ManagedEcecutorService executorService;

然后,我们想使用这个ManagedExecutorService实例来进行一些重处理,比如处理斐波那契数。我们将使用ManagedExecutorService实例并调用它的execute方法。在这个execute方法中,我们调用asyncResponse.resume来恢复异步响应,并提供Response,在我们的例子中是请求的斐波那契数:

        executorService.execute(() -> {
            asyncResponse.resume(Response.ok(fibonacci(i)).build());
            LOGGER.log(Level.INFO, "Calculated Fibonacci for {0}.", 
              asyncResponse);
        });

我们还能做什么呢?我们应该提供并指定要使用的超时时间,就像我们在第三章,“使用 JSON-B 和 JSON-P 进行内容打包”中看到的那样。在这种情况下,我们指定了 10 秒的超时时间。我们还想指定特定的超时行为,因为我们可能不希望在这种情况下用 HTTP 状态码 503 来回答。由于我们想指定不同的东西,我们可以使用一个setTimeoutHandler实例。我们将setTimeoutHandler注册在异步响应上,如果超时触发,我们将使用 HTTP 状态码 202 恢复响应,这是可接受的,我们只需发送一个随机的UUID

        asyncResponse.setTimeout(10, TimeUnit.SECONDS);
        asyncResponse.setTimeoutHandler((r) -> {
            r.resume(Response.accepted(UUID.randomUUID()
              .toString()).build()); //sending HTTP 202 (Accepted)
        });

我们还可以注册额外的回调。有两种类型的回调:

  • CompletionCallback

  • ConnectionCallback

我们将详细探讨这两种类型。

CompletionCallback

CompletionCallback是第一个回调。当请求完成时,JAX-RS 运行时会调用它。你需要实现的方法只有一个,即onComplete。在出错的情况下,你会收到throwable错误和"Completed processing."参数,我们可以在其中执行所需的逻辑:

    static class LoggingCompletionCallback implements 
      CompletionCallback {

        @Override
        public void onComplete(Throwable throwable) {
            LOGGER.log(Level.INFO, "Completed processing.", throwable);
        }
    }

ConnectionCallback

可选支持的第二种回调类型是ConnectionCallback。在这里,你可以指定一个自定义实现。目前,你需要实现的方法只有一个,即onDisconnect方法,它接收实际的AsyncResponse。如果客户端提前连接,这个方法会被调用。根据 JSR 339,对ConnectionCallback的支持是可选的:

    static class LoggingConnectionCallback implements 
      ConnectionCallback {

        @Override
        public void onDisconnect(AsyncResponse disconnected) {
            LOGGER.log(Level.INFO, "Client disconnected on {0}.", 
              disconnected);
        }

注册回调

一旦我们实现了这两个回调,你就可以将它们注册到异步响应上。你可以调用asyncResponse.register并将这些回调的类传递给它:

        asyncResponse.register(LoggingCompletionCallback.class);
        asyncResponse.register(LoggingConnectionCallback.class);

CompletableFuture

最后,我们可以使用CompletableFuture作为使用那些异步 REST API 的替代语法糖。再次强调,这里我们使用一个ManagedExecutorService实例。接下来,我们想要做的是使用CompletableFuture来异步运行斐波那契计算,然后应用asyncResponse::resume方法。代码如下。使用CompletableFuture,我们调用runAsync方法,使用提供的executorService运行斐波那契计算,然后应用asyncResponse::resume方法:

    @GET
    @Path("/{i}")
    public void completable(@Suspended final AsyncResponse 
      asyncResponse, @PathParam("i") final int i) {
        CompletableFuture
                .runAsync(() -> fibonacci(i), executorService)
                .thenApply(asyncResponse::resume);
    }

让我们看看这是如何在实际中运行的。让我们切换到我们的 REST 客户端。首先,我们调用 9 的斐波那契数,即 34,如下截图所示:

图片

同样适用于 17 的斐波那契数,即 1,597,等等。42 的斐波那契数稍微长一些,因为它是一个非常长的数字。我们可以看到,如果我们调用 49 的斐波那契数会发生什么;这是一个非常大的数字,它应该触发 10 秒的超时——我们期望得到 202 已接受的状态码,您可以看到这里,并且我们得到一个随机的 UUID 响应:

图片

在下一节中,我们将讨论实现异步 Web 服务客户端的方法。

实现异步 Web 服务客户端

在本节中,我们将探讨异步 JAX-RS 客户端 API 的基本用法。我们使用InvocationCallback实例来响应完成和失败的调用。我们还将看到如何使用CompletableFuture进行异步客户端请求的调用链。

让我们开始并切换到代码。像往常一样,我们准备了一个小模板项目来开始。我们将使用 JUnit 测试来展示 JAX-RS 客户端 API。我们将设置 JAX-RS client实例和 JAX-RS webTarget实例,用于之前实现的自异步服务 API。如您所记,在上一节中,我们使用了斐波那契数计算的异步方式。我们将使用异步 JAX-RS 客户端 API 重写测试,针对我们的 REST API。

让我们打开AsyncWebServiceClientIntegrationTest类并开始我们的测试;第一个测试应该相当简单。我们想要构建一个异步请求,我们像以前一样这样做。我们使用webTarget.path并请求TEXT_PLAIN_TYPE。现在来真正不同的一点:我们调用.async()方法,然后调用.get(Long.class)。如您所见,这个调用的返回类型是Future<long>。让我们将其重命名为fibonacci并在其上调用assertEquals方法:

    @Test
    public void fibonacci17() throws Exception {
        Future<Long> fibonacci = webTarget.path("/fibonacci/17")
          .request(MediaType.TEXT_PLAIN_TYPE).async()
          .get(Long.class);
        assertEquals(1597, (long) fibonacci.get());

    }

这基本上就是使用异步 API 的所有内容,尽管还有一些其他内容。您可以使用get注册调用回调,以便在完成和失败事件上接收通知。然后,我们将看到如何实现这些回调。如您所见,我们不再像之前那样为实际的(Long.class)类型调用get,而是调用InvocationCallback<Long>get。我们可以为成功的执行实现completed方法,为失败实现failed方法。再次强调,我们将为斐波那契数返回Future<Long>,然后我们可以在这个Future上调用get方法:

    @Test
    public void fibonacci17WithCallback() throws Exception {
        Future<Long> fibonacci = webTarget.path("/fibonacci/17")
          .request(MediaType.TEXT_PLAIN_TYPE).async()
          .get(new InvocationCallback<Long>() {
            @Override
            public void completed(Long aLong) {
                LOGGER.log(Level.INFO, 
                  "Completed Fibonacci 17 with {0}.", aLong);
            }

            @Override
            public void failed(Throwable throwable) {
                LOGGER.log(Level.WARNING, 
                  "Completed Fibonacci 17 with error.", throwable);
            }
        });
        assertEquals(1597, (long) fibonacci.get());
    }

最后,我们将看到如何使用CompletableFuture进行调用链。这很有趣,因为我们可以使用CompletableFuture流畅 API 链式调用几个 JAX-RS 客户端调用。想象一下,我们想要计算斐波那契数 3、4、5、6、8 和 21,并且在一个链式调用中完成所有这些。这可能看起来是这样的:

    @Test
    public void fibonacci3_4_5_6_8_21() throws Exception {

        CompletableFuture<Long> fibonacci =
                Futures.toCompletable(webTarget.path("/fibonacci/{i}")
                  .resolveTemplate("i", 3)
                        .request(MediaType.TEXT_PLAIN_TYPE)
                           .async().get(Long.class))
                        .thenApply(i -> webTarget
                           .path("/fibonacci/{i}")
                           .resolveTemplate("i", i + 2)
                                .request(MediaType.TEXT_PLAIN_TYPE)
                                  .get(Long.class))
                        .thenApply(i -> webTarget
                          .path("/fibonacci/{i}")
        ...
        ...
        ...
        assertEquals(10946, (long) fibonacci.get());
    }

如您所见,我们执行了第一次调用,并使用了.async()方法,它返回Future。我们将这个Future转换为CompletableFuture,然后在接下来的调用中,我们使用thenApply,我们将对下一个进行同样的操作,以此类推。这最终将进行七次调用。

让我们运行这个测试以确保一切准备就绪并且测试能够编译。我们可以看到前三个已经成功;Fibonacci49WithCallback应该得到一个 202,然后我们就完成了。

这就是 JAX-RS 异步行 API 背后的所有魔法,如下面的截图所示:

图片

输出显示异步测试运行成功

摘要

在本章中,我们讨论了发光 Web 服务的动机和好处,以及它们如何最终节省内存并提高我们 REST API 的性能和吞吐量。然后,我们讨论了@Suspended注解和AsyncResponse类的基本用法。我们学习了如何使用TimeoutHandler和服务器端回调实例进行细粒度控制。然后,我们使用了ManagedExecutorServiceCompletableFuture来添加一些语法糖。最后,我们讨论了异步 JAX-RS 客户端 API 的用法。

在下一章,我们将讨论使用服务器发送事件。

第五章:使用服务器发送事件(SSEs)

在本章中,我们将探讨 服务器发送事件SSE)。我们将查看一些使用场景的特点,然后我们将使用 JAX-RS 在服务器端实现和发送简单的 SSE。接下来,我们将使用 JAX-RS 引擎 HTML 实现 SSE,最后我们将查看发送和接收服务器发送的广播事件,以实现类似简单 HTML 聊天客户端的功能。

本章包括以下部分:

  • 什么是 SSEs?

  • 在服务器端实现 SSE

  • 实现 SSE REST 客户端

  • 实现和发送 SSE 广播

什么是 SSEs?

在本节中,我们将探讨 SSEs,并查看一些其使用场景。然后,我们将使用 JAX-RS 在服务器端实现和发送一个简单的 SSE。接下来,我们将使用 JAX-RS 和 HTML 在客户端实现 SSE。最后,我们将查看发送和接收服务器发送的广播事件,以实现类似简单 HTML 聊天客户端的功能。

我们将探讨 SSEs(服务器发送事件),它们是什么,以及一些使用场景。我们还将查看与其他相关技术(如 WebSockets、轮询和长轮询)的一些区别。

那么,SSEs(服务器发送事件)究竟是什么呢?它们是一个非常简单的基于 HTTP 的 API,专门用于推送通信,目前 SSEs 已在大多数最新的浏览器中实现,如 Firefox、Chrome、Safari 和 Opera。不幸的是,SSEs 目前在 Internet Explorer 或 Edge 中尚未实现。

SSE 允许您从服务器向客户端发送简单的文本数据。一个重要的事情是:SSEs 在通信中是单向的。您可能会想,“嘿,我过去使用过轮询和长轮询,它们似乎做的是同一件事。”主要区别在于,使用轮询和长轮询时,是客户端偶尔尝试加载新数据。而使用 SSE,不是客户端轮询,而是服务器始终向客户端推送数据。

您可能之前听说过 WebSockets,但 WebSockets 是一个完全不同的东西。首先,它们是基于 TCP 的。它们在客户端和服务器之间提供了一个全双工的通信链路。使用 WebSocket,客户端可以始终向服务器发送数据,服务器也可以始终向客户端发送数据。在 SSE 中,您可以将事件流想象成非常简单的文本数据。文本数据必须使用 UTF-8 编码。在事件流中,我们可以发送简单的消息并对这些消息进行编码。这些消息甚至可以是 JSON 格式,或者您可能只能发送纯字符串或原始数据。事件流中的消息由一对换行符("\n")分隔。

记住:SSEs 是简单的推送通信机制,您可以从服务器向客户端发送事件流,而无需客户端轮询。

在下一节中,我们将查看使用 JAX-RS 在服务器端实现 SSE。

在服务器端实现 SSE

在本节中,我们将探讨使用 text/event-stream 媒体类型打开 SSE 接收器。我们将发送简单的数据和 JSON 数据事件。最后,我们将关闭并断开之前打开的 SSE 接收器。

让我们开始吧,深入代码,打开我们的 IDE。像往常一样,我们准备一个小模板来开始。打开EventsResource.java文件。我们首先需要做的是实现事件流的打开。我们可以通过实现一个普通的 HTTP @GET方法来实现,但首先需要处理的是参数,这是我们传递类型为SseEventSink@Context

这是我们可以稍后用来向客户端发送事件的对象。您还可以看到@Produces注解,这是我们使用text/event-stream作为MediaType的地方。这是用来指定 SSE 的特殊媒体类型:

    @GET
    @Produces(MediaType.SERVER_SENT_EVENTS)
    public void openEventStream(
      @Context final SseEventSink eventSink) {
        this.eventSink = eventSink;
    }

一旦我们打开了 SSE 事件流,我们就可以实现事件的发送。首先,我们从一个简单的@POST方法开始。同样,请注意第二个参数,它是一个类型为Sse@Context对象。我们稍后使用这个Sse接口来构建新的事件。让我们通过事件流发送第一个简单的事件。我们这样做是通过使用sse上下文,并使用字符串message构建一个newEvent,然后我们在eventSink上使用send方法,并将它发送到event

    @POST
    public void sendEvent(String message, @Context Sse sse) {
        final SseEventSink localSink = eventSink;
        if (localSink == null) return;

        // send simple event
        OutboundSseEvent event = sse.newEvent(message);
        localSink.send(event);

我们还可以发送命名的事件,这可以为您的事件赋予一些名称。同样,我们使用sse上下文来构建一个newEvent。在这里我们可以看到我们给它取了一个名字(stringEvent),并且将message作为数据传递。同样,我们使用了localSinksend方法来发送这个event

        // send simple string event
        OutboundSseEvent stringEvent = sse.newEvent(
          "stringEvent", message + " From server.");
        localSink.send(stringEvent);

这也适用于其他原始数据。也许我们想要发送当前时间的毫秒数。如您所见,sse上下文中也有一个newEventBuilder可用。我们使用sse.newEventBuildernamedata,并在其上调用build方法。然后我们按照如下方式调用send方法:

        // send primitive long event using builder
        OutboundSseEvent primitiveEvent = sse.newEventBuilder()
                .name("primitiveEvent")
                .data(System.currentTimeMillis()).build();
        localSink.send(primitiveEvent);

最后,我们还可以发送 JSON 事件。例如,我们有一个简单的 POJO 实现,使用了@JsonbPropertyOrder注解:

    @JsonbPropertyOrder({"time", "message"})
    public static class JsonbSseEvent {
        String message;
        LocalDateTime today = LocalDateTime.now();
        public JsonbSseEvent(String message) {
            this.message = message;}
        public String getMessage() {
            return message;}
        public void setMessage(String message) {
            this.message = message;}
        public LocalDateTime getToday() {
            return today;}
        public void setToday(LocalDateTime today) {
            this.today = today;}
    }
}

让我们将其发送出去。我们使用了newEventBuilder,给它一个name,并传递我们 POJO 的一个实例作为data。我们可以指定这个事件的mediaType,在我们的例子中是 application JSON。我们可以使用.build并将其发送到事件流:

        // send JSON-B marshalling to send event
        OutboundSseEvent jsonbEvent = sse.newEventBuilder()
                .name("jsonbEvent")
                .data(new JsonbSseEvent(message))
                .mediaType(MediaType.APPLICATION_JSON_TYPE)
                .build();
        localSink.send(jsonbEvent);
    }

发送事件就是这样。我们最后需要做的是关闭事件流。我们可以使用 HTTP 的DELETE方法来做这件事。如果我们对这个资源调用 HTTP DELETE,我们可以在eventSink上简单地调用一个close方法,然后我们就完成了:

    @DELETE
    public void closeEventStream() throws IOException {
        final SseEventSink localSink = eventSink;
        if (localSink != null) {
            this.eventSink.close();
        }
        this.eventSink = null;
    }

让我们对其进行测试。打开浏览器并导航到GET端点。正如我们所见,这个调用不会返回,因为它正在等待事件,如下面的截图所示:

图片

现在,我们打开 Postman 发送一些事件,点击几次“发送”。让我们回到我们的浏览器。正如我们所看到的,我们的事件已经到达,如下面的截图所示:

图片

这就是客户端实现服务和事件的所有内容。

在下一节中,我们将讨论实现服务和事件 REST 客户端以及 HTTP 客户端。

实现 SSE REST 客户端

在本节中,我们将探讨如何注册 JAX-RS 客户端实例以接收 SSE。我们将向 SSE 服务端点发送消息,并希望在 JAX-RS 客户端中接收这些消息。最后,我们将看看如何使用 JSP 实现一个简单的 HTML 客户端。

在本节中,我们需要涵盖很多内容。让我们开始,并切换到我们的 IDE。像往常一样,我们将准备一个模板项目以开始。我们需要做的是实现一个小型的 JUnit 集成测试,我们可以将其用作我们的 JAX-RS 客户端。在setUp方法中,我们将首先构建一个执行器(你很快就会看到我们为什么需要它)。为了做到这一点,我们将使用 JAX-RS 的clientBuilder并构建一个newBuilder。我们将指定connectTimeoutreadTimeout并调用.build

    @Before
    public void setUp() {
        client = ClientBuilder.newBuilder()
                .connectTimeout(5, TimeUnit.SECONDS)
                .readTimeout(30, TimeUnit.SECONDS)
                .build();

        executorService = Executors.newSingleThreadScheduledExecutor();
    }

剩下的就是我们需要构造并打开我们的 REST 端点的webTarget。我们在这里做的是使用client,在这种情况下,我们将target指定为localhost:8080,并在events端点中实现path,就像我们在在服务器端实现 SSE部分所做的那样:

        webTarget = client.target("http://localhost:8080")
                    .path("/sse-service/api/events");

tearDown方法中,我们关闭客户端并调用executorService

    @After
    public void tearDown() {
        client.close();
        executorService.shutdown();
    }

让我们实现接收 SSE。首先,我们需要发送一些稳定的事件 SSE,这样我们就可以尝试实现一个事件循环。我们向一个端点发送消息,并接收发送的消息。这就是我们使用executorService的原因。因此,在executorService中,我们偶尔每 500 毫秒发送一次事件。我们使用executorService.scheduleWithFixedDelaywebTarget.requests,我们调用post,并向我们的 JAX-RS 端点输入一些纯文本数据。正如你所看到的,我们有250毫秒的初始延迟,并且每500毫秒就做一次:

    @Test
    public void receiveSse() throws Exception {

        executorService.scheduleWithFixedDelay(() -> {
            webTarget.request().post(Entity.entity(
              "Hello SSE JAX-RS client.", 
              MediaType.TEXT_PLAIN_TYPE));
        }, 250, 500, TimeUnit.MILLISECONDS);

现在来到了有趣的部分:让我们接收这些事件。首先,我们需要做的是获取一个SseEventSource。我们使用SseEventSource.target,给它我们之前构造的webTarget,然后调用.build。这给了我们一个SseEventSource的实例。让我们与eventSource进行交互。我们首先需要做的是注册一个处理程序,每当收到事件时都会被调用。为了做到这一点,我们使用eventSource.register,我们只做的是记录事件名称并读取事件的资料。剩下的事情就是我们需要开始接收这些事件。为了做到这一点,我们需要在eventSource上调用open方法。为了确保这个测试不会立即返回,我们在里面放了一个5秒的袖子:

        try (SseEventSource eventSource = SseEventSource
          .target(webTarget).build()) {
            eventSource.register((e) -> LOGGER.log(Level.INFO, 
                    "Recieved event {0} with data {1}.",
                    new Object[]{e.getName(), e.readData()}));
            eventSource.open();

            TimeUnit.SECONDS.sleep(5);
        }
    }

让我们看看这行不行。我们将构建并运行这个集成测试,这需要一些时间来编译。我们可以看到它正在接收 JsonbEvent,一个简单的消息,一个 primitiveEvent,以及我们刚刚期望的 stringEvent,如下面的截图所示:

集成测试输出的事件

这就是使用 JAX-RS 客户端发送和接收 SSE 的全部内容。让我们再看一点,那就是关于如何使用纯 HTML 和 JSP 消费这些 SSE。我们将准备一个小型的 JSP 展示,由于大多数现代浏览器都支持这个功能,这将是一个很好的展示。首先,你需要添加一些 JavaScript,并且需要打开 EventSource。在 JavaScript 中,你可以通过 new EventSource 来打开 EventSource,我们给它我们的 events 端点的端点。为了接收普通未命名的事件,我们必须使用 onmessage。我们在 source.onmessage 中注册 function 并将 event.data 附加到我们在文件开头定义的 div 标签:

<h2>Messages</h2>
<div id="messages"></div>
<script>
    if (typeof(EventSource) !== "undefined") {
        var source = new EventSource(
          "http://localhost:8080/sse-service/api/events");
        source.onmessage = function (event) {
            document.getElementById("messages").innerHTML += 
              event.data + "<br>";
        };

对于任何命名的事件,我们都需要以稍微不同的方式进行操作。在这里,我们必须使用一个不同的方法,即在我们的 source 上使用 addEventListener 方法。我们为 "sringEvent""primitiveEvent"jsonEvent 注册 addEventListener。我们将从事件接收到的数据附加到我们在 JSP 文件顶部定义的 div 标签:

<script>
    if (typeof(EventSource) !== "undefined") {
        var source = new EventSource(
           "http://localhost:8080/sse-service/api/events");
        source.onmessage = function (event) {
            document.getElementById("messages")
            .innerHTML += event.data + "<br>";
        };
    ...
    ...
        source.addEventListener("jsonbEvent", function (e) {
            document.getElementById("jsonbEvents")
            .innerHTML += e.data + "<br>";
        }, false);
    } else {
        document.getElementById("jsonbEvents").innerHTML = 
        "Sorry, your browser does not support server-sent events...";
    }

现在,让我们打开一个浏览器并访问 URL (localhost:8080/sse-service/events.jsp)。这就是我们简单的 UI 应该看起来像的样子:

我知道这看起来不太美观,但它确实完成了任务。在后台,我们已经打开了到我们服务器的 SSE 通道。如果我们打开 Postman 并开始 POST 一些事件,然后回到我们的浏览器页面,我们可以看到这些事件将出现,如下面的截图所示:

我们通过发送它们三次来调用这些事件,你可以看到这里有三条不同的消息。

这一节就到这里。在下一节中,我们将讨论实现和发送 SSE 广播。

实现和发送 SSE 广播

在本节中,我们将探讨创建 SSE 广播器实例。我们将使用这个 SSE 广播器注册 SSE 事件接收器,然后我们将向所有已注册的接收器广播事件。最后,我们将实现一个简单的基于 SSE 的 HTML 聊天功能。

在这一节中有很多内容需要介绍。让我们开始并打开我们的 IDE。像往常一样,为了开始,我们将准备一个小型的骨架项目。首先,我们将实现BroadcastResource类,这是发送 SSE 广播的服务器端。我们有一些事情要做。我们将注入 SSE @Context,我们需要用它来构造新的事件。接下来,我们需要初始化一个 SSE 广播器。为此,我们将使用我们刚刚注入的@Context并定义一个SseBroadcaster,这样它就是主要的实例。我们还将使用一个@PostConstruct初始化器来创建一个newBroadcaster,使用sse上下文:

    @Context
    private Sse sse;
    private SseBroadcaster sseBroadcaster;

    @PostConstruct
    public void initialize() {
        sseBroadcaster = sse.newBroadcaster();
    }

我们接下来需要实现的是将 SSE 事件接收器注册到这个广播器上。我们将为此实现一个简单的@GET方法,但请记住它被注解为媒体类型text/event-stream。我们只需要调用sseBroadcaster.register方法并传入sseEventSink实例:

    @GET
    @Produces(MediaType.SERVER_SENT_EVENTS)
    public void fetch(@Context SseEventSink sseEventSink) {
        sseBroadcaster.register(sseEventSink);
    }

最后缺失的部分是我们需要能够广播并发送 SSE 事件。我们在这里定义了一个POST方法,我们可以使用它来消费一个 HTML 表单(APPLICATION_FORM_URLENCODED)和名为"message"@FormParam。我们使用这个来构造一个出站 SSE 事件(OutboundSseEvent),就像我们在前面的部分所做的那样。我们通过使用sse.newEvent,给它一个名字,并传递message,然后我们使用sseBroadcaster实例上的broadcast方法来广播事件,并返回noContent

    @POST
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Response broadcast(@FormParam("message") String message) {
        OutboundSseEvent broadcastEvent = sse.newEvent(
          "message", message);
        sseBroadcaster.broadcast(broadcastEvent);
        return Response.noContent().build();
    }

服务器端的操作就到这里。让我们快速看一下 HTML 端。同样,我们将会使用一个非常简单的纯 JSP 文件,就像我们在前面的部分所做的那样。我们将构造一个EventSource并为我们的消息注册一个事件监听器(addEventListener)。就是这样,只有一个小的细节缺失——我们需要一个简单的form来向我们的BroadcastResource发送消息:

<form action="/sse-service/api/broadcast" method="post">
    Message <input type="text" name="message"/>
    <input type="submit" value="Submit"/>
</form>

让我们来测试一下。我们将打开几个浏览器实例来查看我们的超级酷炫的 HTML 聊天客户端:

图片

让我们输入Hello Window 1并点击提交,如下所示:

图片

我们期望在第二个窗口中看到这条消息。你应该在第二个窗口中看到相同的消息。从第二个窗口,我们将输入一条消息Hello from Window 2并点击提交。现在,如果我们切换到第一个窗口的标签页,我们应该能够看到从第二个窗口发送的Hello from Window 2消息,如下所示:

图片

你可能会认为这是伪造的,但事实并非如此。让我们打开 Postman 并使用它来向这个广播资源发送消息。如果你查看头部信息,我们已经有了一个之前提到的Content-Type,在Body标签下有一个message。我们将使用 Postman 发送这些事件,我们期望在浏览器窗口中看到这条消息。让我们点击一次,也许两次,也许三次。然后让我们切换回我们的浏览器,你将看到来自 Postman 的“Hello SSE 广播”,如下面的截图所示:

图片

这证明了我们已经验证了这是一个在多个客户端类型上工作的真实广播。

摘要

这就是本章的全部内容。那么,我们探讨了什么?首先,我们必须查看服务和事件的用例,并讨论了 SSE 是什么以及其他相关技术。接下来,我们使用 JAX-RS 在服务器端实现了服务事件的发送。然后,我们查看使用 JAX-RS 客户端 API 以及 HTML 实现 SSE 客户端和接收这些事件。最后,我们在服务器端实现了 SSE 广播的发送,并实现了一个小的 HTML 聊天客户端。

在下一章和最后一章中,我们将探讨高级 REST API。

第六章:高级 REST API

在本章中,我们将探讨如何使用合同和验证向 REST API 添加设计。然后,我们将学习如何使用 JSON Web Tokens 进行身份验证。最后,我们将探索可诊断性,即 REST API 的日志记录、指标和跟踪。在本章中,我们将涵盖以下主题:

  • 应用设计规范——添加验证

  • 使用 JSON Web Tokens 进行身份验证

  • 可诊断性——日志记录、指标和跟踪

应用设计规范——添加验证

在本节中,我们将探讨如何使用 Javax 验证注解向 @PathParam 注解添加验证。我们将添加使用 Javax 验证注解的验证 POJO 和 POST 主体。我将向您展示如何发送 HTTP 404 状态码以表示错误和无效的请求。

让我们切换到我们的 IDE。像往常一样,我们将准备一个小型模板项目以开始。我们创建了一个 BookResource,类似于在上一章 第五章,使用服务器发送事件 (SSEs) 中创建的。不过,有一件事是缺失的:没有告诉 API ISBN 是有效的。让我们假设我们想要为 ISBN 添加一个验证,并确保它总是 10 个字符长,并且只包含数字。当然,我们可以手动编写这个程序,但有一个更好的方法。

相反,我们可以使用 Javax 验证来实现这一点。让我们通过添加 @Pattern 注解来完成它。如果你在 IDE 中悬停在 @Pattern 注解上,你会看到这个注解来自 Javax 验证约束包。我们想使用 @Pattern 来表示我们想要一个只包含 [0-9] 的正则表达式(regexp),并且它需要是 10 位长;这就是在这种情况下验证 ISBN 所需要的一切:

    @GET
    @Path("/{isbn}")
    public Response book(@PathParam("isbn") @Pattern(
      regexp = "[0-9]{10}") String isbn) {
        Book book = Optional.ofNullable(books.get(isbn))
                    .orElseThrow(NotFoundException::new);
        return Response.ok(book).build();
    }

我们有 @POST 方法来创建一个新的书籍,并且我们不确定发送的书籍是否有效。首先,我们添加一个 Javax 验证 (@Valid) 注解。在这种情况下,我们使用 @Valid,它将验证指向 Book 类内的注解:

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(@Valid Book book, 
      @Context UriInfo uriInfo) {
        books.put(book.isbn, book);

        URI uri = uriInfo.getBaseUriBuilder()
          .path(BooksResource.class).path(book.isbn).build();
        return Response.created(uri).build();
    }

Book 类有一个 isbn 和一个 title,但没有 Javax 验证注解。ISBN 应该是有效的 ISBN,标题既不能为空也不能为空白。我们为 title 添加了 @NotBlank@NotNull 注解,为 isbn 添加了 @Pattern

public static class Book {

        @Pattern(regexp = "[0-9]{10}")
        private String isbn;
        @NotNull
        @NotBlank
        private String title;

让我们进行测试。让我们打开我们的 Postman 客户端并尝试 GET Books 列表 API。我们在这里看到我们返回了一本书:

图片

现在,让我们尝试获取一个无效 ISBN 的书籍并看看会发生什么;点击 GET Invalid Book API (http://localhost:8080/advanced/books/4711)。你会在我们传递的 URI 中看到 4711,这不是一个有效的 ISBN,因为它不是 10 位长。如果我们发送这个请求,我们期望服务器会返回一个错误请求;这告诉我们我们发送的请求是不有效的。如果我们请求一个有效的书籍(GET Valid Book),我们得到状态码 200 OK,这意味着第一次验证是有效的。

让我们通过请求 POST 新有效书籍来创建一个新的有效书籍;我们可以看到它有一个有效的 ISBN——10 位长——和一个标题。我们发送这个请求,并得到状态码 201 Created,如下截图所示:

现在,让我们通过请求 POST 无效书籍来创建一个无效的书籍。看看 Body;你会看到一个空的title和一个无效的isbn

{
    "isbn": "1234560".
    "title": ""
}

如果我们发送这个请求,我们会得到 400 Bad Request;服务器将不接受任何不符合我们验证注释的无效书籍。

在下一节中,我们将查看使用 JSON Web Tokens 进行身份验证。

使用 JSON Web Tokens 进行身份验证

在本节中,我们将查看使用 Auth0 库解码JSON Web TokensJWTs)。我们将了解如何实现和使用ContainerRequestContainerResponse过滤器来处理我们的 REST 资源的 JWT 身份验证。最后,我们将在我们的 REST 资源中注入和使用解码后的 JWT。

让我们开始吧。如果你访问 JWT 网站(jwt.io/),你可以找到关于 JWT 的相关信息。在调试器下,你可以看到 JWT 的样子。我们可以看到在编码部分下的令牌——它由一个 HEADER、PAYLOAD 和一个 VERIFY SIGNATURE 组成。这个 JWT 使用对称加密来生成签名。因此,这个值将随后通过 HTTP 授权头传输:

如同往常,我们准备一个小型模板项目以开始。首先,我们激活一个第三方库,该库是处理 JWT 和解码所必需的。我们通过在 POM 文件中添加以下依赖项来完成此操作:

    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.3.0</version>
    </dependency>

接下来,我们需要实现ContainerResponseContainerRequest过滤器;我们在JwtAuthzVerifier类中这样做。我们让JwtAuthzVerifier类实现ContainerRequestFilterContainerResponseFilter

@Provider
public class JwtAuthzVerifier implements ContainerRequestFilter, ContainerResponseFilter {

让我们实现这两个方法。我们需要实现filter。要做到这一点,我们需要从requestContext中提取授权头,然后解码 bearer 令牌(decodeBearerToken):

    @Override
    public void filter(ContainerRequestContext requestContext) {
        try {
            String header = getAuthorizationHeader(requestContext);
            decodeBearerToken(header);
        }

要获取请求头,我们使用ContainerRequestContext并提取AUTHORIZATION_HEADER

    private String getAuthorizationHeader(
      ContainerRequestContext requestContext) {
        return requestContext.getHeaderString(AUTHORIZATION_HEADER);
    }

一旦我们有了这个,我们就可以解码 bearer 令牌。这就是我们将使用 Auth0 库的地方。我们提供了一些验证代码,这基本上是针对所使用的库进行编程。最后,在响应中,我们将丢弃解码后的 JWT:

    private void decodeBearerToken(String authorization) {
        String token = extractJwtToken(authorization);
        Verification verification = 
          JWT.require(getSecret()).acceptLeeway(1L);
        DecodedJWT jwt = verify(token, verification);
        decodedJWT.set(jwt);
    }

几乎就是这样,但还有一些事情需要补充。我们需要在@ApplicationScoped下注释@Provider。我们还需要一个激活的注释,所以我们调用@JwtAuthz

@ApplicationScoped
@JwtAuthz
@Provider

让我们看看@JwtAuthz注解。到目前为止,这是一个非常基础的注解,但我们需要一个特殊的注解。我们需要@NameBinding注解。基本上,这个注解将注解的@Provider绑定,我们在JwtAuthzVerifier类中已经做到了这一点。在这种情况下,我们可以在TYPE上放置@Target注解,即 REST 资源或 REST 方法:

@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface JwtAuthz {
}

接下来,我们需要激活我们的资源和验证器。让我们跳转到AuthenticationResource和最后的最后。我们需要为这个资源激活我们的 JWT 过滤器。我们通过在资源上直接使用@JwtAuthz注解来实现这一点:

@Path("/jwt")
@JwtAuthz
public class AuthenticationResource {

然后我们注入解码后的 JWT:

    @Inject
    private DecodedJWT decodedJWT;

最后,我们添加一个authenticate方法。我们获取解码 JWT 的声明(decodedJWT.getClaims())。我们构建一个response并回显namesubject声明:

    @GET
    @Path("/authenticate")
    public Response authenticate() {
        Map<String, Claim> claims = decodedJWT.getClaims();

        JsonObject response = Json.createObjectBuilder()
                .add("name", claims.get("name").asString())
                .add("subject", claims.get("sub").asString())
                .build();

        return Response.ok(response).build();
    }
}

让我们切换到我们的 REST 客户端。如果你想访问这个资源,请访问我们的authenticate资源路径。在这里,我们准备了一个Authorization头,在值中使用Bearer作为令牌类型,后面跟着编码格式的 JSON Web Token。当我们发送这个请求时,我们应该期望得到 200 OK 状态。你应该能看到解码后的namesubject声明:

图片

假设我们将Authorization头的值更改为Bearer notvalid。如果我们发送这个请求,我们应该得到 401 未授权和Invalid JWT token.消息。我们刚刚使用简单的 JWT 身份验证保护了我们的 REST API:

图片

在下一节中,我们将讨论可诊断性,并将日志、指标和跟踪添加到你的 REST API 中。

可诊断性 – 日志、指标和跟踪

在本节中,我们将探讨如何使用 Jersey 的日志功能添加请求和响应日志。我们将查看使用 MicroProfile 1.2 API 添加指标和健康端点。最后,我们将看看如何使用OpenTracing API 和 Jaeger 来包含跟踪。

在我们开始之前,让我们快速看一下可诊断性的三角形。当涉及到开发 Web 服务和分布式应用时,可诊断性非常重要。当人们谈论指标时,你可能听说过 Prometheus;当涉及到日志时,你可能听说过 Fluentd;而对于跟踪,OpenTracing 是当前最先进的 API。确保你查看这些技术和它们的堆栈。

让我们探索一个小型 Web 服务的可诊断性三角形:

图片

可诊断性三角形

让我们打开集成开发环境(IDE)开始吧。打开 POM 文件并添加一些依赖项。我们想要启用的第一个依赖项是jersey.corejersey-common依赖项。这是日志功能和日志过滤器所在的位置:


    <dependency>
        <groupId>org.glassfish.jersey.core</groupId>
        <artifactId>jersey-common</artifactId>
        <version>2.26</version>
        <scope>provided</scope>
    </dependency>

要为每个请求启用请求和响应日志,我们激活JAXRSConfiguration类中的LoggingFeature

        classes.add(MetricsResource.class);
        classes.add(LoggingFeature.class);

        return classes;
    }

如果你在我们 IDE 中悬停在LoggingFeature上,你可以看到它来自jersey-common模块;通常这已经提供了,所以我们不需要编写它——不需要添加额外的依赖。我们最后要做的就是修改LoggingFeature。在这里,我们添加一些额外的属性,然后我们就完成了:

        properties.put(LoggingFeature.LOGGING_FEATURE_LOGGER_NAME,   
          "RequestLogger");
        properties.put(LoggingFeature.LOGGING_FEATURE_LOGGER_LEVEL, 
          Level.INFO.getName());

这将记录每个请求和每个响应到你的日志文件中。小心;它将生成一些非常庞大的日志。接下来,我们看看如何使用 MicroProfile API 添加指标和健康检查。

让我们切换到我们的 POM 并激活 MicroProfile API。由于我们使用 Payara micro edition,这些 API 对我们也是可用的。有健康检查、指标、容错(如果你需要的话)以及 JWT 认证(如果你不想自己实现的话)的 API。我们需要在我们的 POM 文件中添加以下依赖项(整个代码可以在github.com/PacktPublishing/Building-RESTful-Web-Services-with-Java-EE-8找到):


    <dependency>
        <groupId>org.eclipse.microprofile.health</groupId>
        <artifactId>microprofile-health-api</artifactId>
        <version>1.0</version>
        <scope>provided</scope> 
    </dependency>
    ...
    ...
    <dependency>
        <groupId>org.eclipse.microprofile.jwt</groupId>
        <artifactId>microprofile-jwt-auth-api</artifactId>
        <version>1.0</version>
        <scope>provided</scope>
    </dependency>

让我们去MetricsResource添加一些指标。这实际上非常简单。想象一下,你有一个 REST 资源,你感兴趣的是POST请求的调用耗时。为此,你可以添加@Timed注解。我们指定unit"milliseconds",MicroProfile 将确保每次调用都会计时:

    @POST
    @Path("/timed")
    @Timed(displayName = "Timed invocation", unit = "milliseconds")

如果你只想计数调用,那就更容易了。为此,我们可以使用@Counted注解:

    @POST
    @Path("/counted")
    @Counted(monotonic = true)

最后,如果你对当前绝对值感兴趣,可以使用@Gauge

    @Gauge(displayName = "Gauge invocation", unit = "seconds")
    public long gauge() {
        return poolSize.get();

所以@Counted@Gauge@Timed是你可以使用的三个指标注解。

也许我们还想添加一些健康检查,因为一个好的微服务应该提供健康检查。我们可以指定一个@ApplicationScoped的 bean。我们使用@Health注解它,它实现了HealthCheck;这来自 MicroProfile API。然后我们实现我们的基本健康检查逻辑:

public class EverythingOkHealthCheck implements HealthCheck {
    @Override
    public HealthCheckResponse call() {
        return HealthCheckResponse
                .named("everythingOk")
                .up()
                .withData("message", "Everything is OK!")
                .build();
    }
}

最后一件事情是跟踪——这是一个非常复杂的问题。我想向你展示如何将跟踪添加到你的 Web 服务中。首先,我们添加跟踪 API,然后我们添加 Jaeger 作为跟踪实现。我们还使用一个特殊的注解将 OpenTracing 添加到 JAX-RS 2:


    <dependency>
        <groupId>io.opentracing</groupId>
        <artifactId>opentracing-api</artifactId>
        <version>0.31.0</version>
    </dependency>
    <dependency>
        <groupId>com.uber.jaeger</groupId>
        <artifactId>jaeger-core</artifactId>
        <version>0.25.0</version>
    </dependency>
    <dependency>
        <groupId>io.opentracing.contrib</groupId>
        <artifactId>opentracing-jaxrs2</artifactId>
        <version>0.1.3</version>
    </dependency>

这些是所需的依赖项。之后我们只需要激活 tracer。这只需要几行代码。我们从环境构造一个跟踪Configuration。我们使用GlobalTracer注册这个ConfigurationgetTracer

@WebListener
public class OpenTracingContextInitializer implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        Configuration configuration = Configuration.fromEnv();
        GlobalTracer.register(configuration.getTracer());
    }
}

让我们看看我们的 Web 服务看起来像什么。

打开 Postman 并发出一些请求,例如 POST Timed Metric 和 POST Counted Metric。POST Timed Metric 调用@Timed请求。POST Counted Metric 调用@Counted请求;我们调用这个请求几次。

我们调用 GET 指标端点,这是 MicroProfile 实现自动提供的。我们发布我们的指标,我们可以看到我们的自定义指标,例如 MetricsResource.gaugeMetricsResource.timedMetricsResource.timed

如果我们不想使用 JSON 而想使用 Prometheus,我们可以通过调用 Prometheus 指标 GET 请求来实现。我们可以看到自动提供的 Prometheus 指标数据。

您还可以调用 GET 健康检查请求来查看一切是否正常,我们应该在 Postman 中获得以下 Body:

{
    "outcome": "UP"/
    "checks": [
        {
            "name": "everythingOk",
            "state": "UP",
            "data": {
                "message": "Everything is OK"
            }
        }
    ]
}

一切正常,我们已经完成了。

摘要

在本章中,我们探讨了使用 Javax 验证来验证 REST API 参数和有效载荷。我们学习了如何将 JWT 验证添加到 REST 服务中,并手动解码 JSON Web Tokens。最后,我们讨论了使用 Jersey、MicroProfile 和其他开源组件添加、记录、指标和跟踪。

posted @ 2025-09-10 15:08  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报