Java9-健壮的模块化应用构建指南-全-

Java9 健壮的模块化应用构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 9 及其新特性丰富了语言——这是构建健壮软件应用程序最常用的语言之一。Java 9 特别强调模块化。Java 9 的一些新特性具有开创性,如果您是经验丰富的程序员,您可以通过实施这些新特性来使您的企业应用程序更加精简。您将获得关于 Java 9 的实际指导,以及有关 Java 平台未来发展的更多信息。您还将通过项目进行工作,从中您可以提取可用的示例,以解决您自己的独特挑战。

这条学习路径面向的人群

这条学习路径是为那些希望提升一个层次并学习如何在 Java 最新版本中构建健壮应用程序的 Java 开发者准备的。

本学习路径涵盖的内容

第一部分,精通 Java 9,概述并解释了 Java 9 中引入的新特性以及新 API 和增强的重要性。本模块将提高您的生产力,使您的应用程序运行更快。通过学习 Java 的最佳实践,您将成为您组织中 Java 9 的首选人员。

第二部分,Java 9 编程蓝图,带您了解书中 10 个综合项目,展示 Java 9 的各种特性。这些项目涵盖了各种库和框架,并介绍了一些补充和扩展 Java SDK 的框架。

要充分利用这条学习路径

  1. 一些基本的 Java 知识将有所帮助。

  2. 对更高级主题的熟悉,如网络编程和线程,将有所帮助,但不是必需的。

下载示例代码文件

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

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

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

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

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

  4. 在搜索框中输入学习路径的名称,并遵循屏幕上的说明。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Java-9-Building-Robust-Modular-Applications/。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!

使用的约定

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

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块应如下设置:

 module com.three19.irisScan 
    {
      // modules that com.three19.irisScan depends upon
      requires com.three19.irisCore;
      requires com.three19.irisData;
    }

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都应如下所示:

$ mkdir css
$ cd css

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

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

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

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

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

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

评论

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

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

第一章:掌握 Java 9

编写响应式、模块化、并发和安全的代码

第二章:Java 9 的全景

Java 自从第一个版本发布以来已经是一个成熟的成年人,超过二十年了。凭借令人惊叹的开发者社区和众多行业的广泛应用,该平台继续在性能、安全性和可扩展性方面与世界其他地区保持同步。我们将从探索 Java 9 中引入的最显著特性开始,探讨它们背后的最大推动力,以及我们可以在平台后续发展中期待什么,以及一些未包含在本版本中的内容。

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

  • 从 20,000 英尺的高度看 Java 9

  • 打破单体架构

  • 在 Java Shell 中玩耍

  • 控制外部进程

  • 利用 G1 提升性能

  • 使用 JMH 测量性能

  • 准备迎接 HTTP 2.0

  • 包含响应式编程

  • 扩展愿望清单

从 20,000 英尺的高度看 Java 9

你可能会问自己--Java 9 仅仅是一个包含了一些未能在 Java 8 中实现的功能的维护版本吗?Java 9 中有很多新内容,使其成为一个独特的版本。

毫无疑问,Java 平台的模块化(作为 Jigsaw 项目的一部分开发)是使其成功进入 Java 9 的最大工作之一。最初计划在 Java 8 中实现,但被推迟,Jigsaw 项目也是 Java 9 最终发布进一步推迟的主要原因之一。Jigsaw 还为 Java 平台引入了一些显著的变化,这也是 Java 9 被视为重大版本的原因之一。我们将在后续章节中详细探讨这些特性。

JCP(Java Community Process)提供了将一系列特性提案(也称为Java 增强提案JEPs)转化为正式规范的机制,这些规范为通过新功能扩展平台提供了基础。在这一点上,Java 9 并无不同。除了与 Jigsaw 相关的 Java 增强提案外,还有许多其他增强功能被纳入了 Java 9。在这本书中,我们将根据相应的增强提案,以逻辑组的形式讨论各种特性,包括以下内容:

  • Java Shell(也称为JShell)--Java 平台的交互式外壳

  • 以可移植的方式与操作系统进程协同工作的新 API

  • 在 Java 7 中引入的 Garbage-first(G1)垃圾收集器在 Java 9 中被设置为默认的垃圾收集器

  • Java Microbenchmark Harness(JMH)工具添加到 Java 分发中,该工具可以用于对 Java 应用程序进行性能基准测试

  • 通过新的客户端 API 支持 HTTP 2.0 和 WebSocket 标准

  • 并发增强,其中定义了 Flow 类,该类描述了 Java 平台中反应式流规范的接口

一些最初被接受用于发布 9 的提案并没有实现,而是被推迟到了以后的版本,以及其他开发者可能期待的未来有趣的事情。

您可以从 www.oracle.com/technetwork/java/javase/downloads/index.html 下载适用于您系统的 JDK 9 发行版,如果您在尝试阅读其他章节和尝试新引入的示例和概念之前,想先动手实践。

打破单体

几年来,Java 平台的实用性持续发展和增加,使其成为了一个庞大的单体。为了使平台更适合嵌入式和移动设备,有必要发布简化版,如 Java CDC 和 Java ME。然而,这些版本在满足 JDK 提供的功能性方面变化多端的应用需求时,证明还不够灵活。在这方面,模块化系统的需求变得迫切,不仅是为了解决 Java 工具的模块化(总体而言,超过 5000 个 Java 类和 1500 个 C++ 源文件,Hotspot 运行时超过 25,0000 行代码),而且还为了提供一个机制,让开发者可以使用与 JDK 中相同的模块系统来创建和管理模块化应用程序。Java 8 提供了一种中间机制,允许应用程序仅使用整个 JDK 提供的 API 子集,这种机制被命名为紧凑配置文件。实际上,紧凑配置文件还为后续工作奠定了基础,这些工作是为了打破 JDK 各个不同组件之间的依赖关系,从而实现 Java 中的模块化。

模块化系统本身是在名为 Jigsaw 的项目下开发的,基于此,形成了几个 Java 增强提案和一个目标 JSR(376)。为了满足 Jigsaw 项目的需求,做了很多工作——有概念实现的证据,提出的特性比成功进入 Java 9 的特性要多。除此之外,还对 JDK 代码库进行了完全重构,并对 JDK 可分发镜像进行了完全重组。

在社区中,关于是否应该采用现有的成熟 Java 模块系统,如 OSGi,作为 JDK 的一部分,而不是提供一个全新的模块系统,存在相当大的争议。然而,OSGi 针对的是运行时行为,例如模块依赖的解析、安装、卸载、模块(在 OSGi 术语中称为 bundle)的启动和停止、自定义模块类加载器等。然而,Project Jigsaw 的目标是一个编译时模块系统,其中依赖关系的解析发生在应用程序编译时。此外,将模块作为 JDK 的一部分进行安装和卸载,消除了在编译时显式将其包括为依赖项的需要。此外,通过现有的类加载器层次结构(引导类加载器、扩展类加载器和系统类加载器),可以实现模块类的加载,尽管,使用与 OSGi 模块类加载器相当的自定义模块类加载器也是可能的。然而,后者已被放弃;当我们讨论 Java 模块系统的细节时,我们将更详细地讨论 Java 模块类加载。

Java 模块系统带来的额外好处包括增强的安全性和性能。通过将 JDK 和应用程序模块化成 Jigsaw 模块,我们能够创建组件及其相应领域之间明确界定的边界。这种关注点的分离与平台的安全架构相一致,并且是更好地利用资源的能力。我们已经为所有上述点以及采用 Java 9 的主题(这也需要对将现有项目迁移到 Java 9 的可能方法有一定的理解)分配了两个详细的章节。

第三章:探索 Java 9

Java 9 是一个重大版本,包含对 Java 平台的大量内部更改。这些内部更改共同代表了一组巨大的新可能性,对于 Java 开发者来说,这些可能性有些来自开发者的请求,有些来自 Oracle 启发的增强。在本章中,我们将回顾 26 项最重要的更改。每个更改都与一个JDK 增强提案JEP)相关。JEPs 被索引并托管在openjdk.java.net/jeps/0。你可以访问这个网站获取每个 JEP 的更多信息。

JEP 项目是 Oracle 对开源、开放创新和开放标准支持的一部分。虽然可以找到其他开源 Java 项目,但 OpenJDK 是 Oracle 唯一支持的项目。

在本章中,我们将介绍 Java 平台的变化。这些变化具有几个令人印象深刻的含义,包括:

  • 堆空间效率

  • 内存分配

  • 编译过程改进

  • 类型测试

  • 注解

  • 自动化运行时编译器测试

  • 改进的垃圾收集

在 Java Shell 中玩耍

很长一段时间以来,Java 编程语言没有提供标准的 shell 来实验新的语言特性或库,或者用于快速原型设计。如果你想要这样做,你可以编写一个带有 main 方法的测试应用程序,使用javac编译它,然后运行。这可以在命令行或使用 Java IDE 完成;然而,在这两种情况下,这都不如拥有一个交互式 shell 那么方便。

在 JDK 9 中启动交互式 shell 就像运行以下命令一样简单(假设你的 JDK 9 安装的bin目录在当前路径中):

jshell

你可能会觉得有些困惑,为什么交互式 shell 没有在 Java 平台早期版本中引入,因为许多编程语言,如 Python、Ruby 以及其他一些语言,在它们的早期版本中已经包含了交互式 shell;然而,这仍然没有成为早期 Java 版本优先功能列表的一部分,直到现在,它已经出现并准备好使用。Java shell 利用了 JShell API,该 API 提供了一组功能,包括自动完成或评估表达式和代码片段等。

控制外部进程

使用 JDK 9 之前,如果您想创建一个 Java 进程并处理进程的输入/输出,您必须使用 Runtime.getRuntime.exec() 方法,该方法允许我们在单独的操作系统进程中执行一个命令,并获取一个 java.lang.Process 实例,通过它提供某些操作来管理外部进程,或者使用新的 java.lang.ProcessBuilder 类,它在与外部进程交互方面提供了一些增强,并创建一个 java.lang.Process 实例来表示外部进程。这两种机制都不灵活,也不可移植,因为外部进程执行的命令集高度依赖于操作系统(需要付出额外的努力才能使特定的进程操作在多个操作系统之间可移植)。有一个章节专门介绍新的进程 API,为开发者提供创建和管理外部进程的更简单方式的知识。

使用 G1 提升性能

G1 垃圾收集器已经在 JDK 7 中被引入,现在在 JDK 9 中默认启用。它针对的是具有多个处理核心和大量可用内存的系统。与之前的垃圾收集器类型相比,G1 的优势是什么?它是如何实现这些改进的?是否需要手动调整它,以及在什么场景下?这些问题以及更多关于 G1 的问题将在单独的章节中讨论。

使用 JMH 测量性能

在许多情况下,Java 应用程序可能会遭受性能下降。加剧这个问题的是缺乏性能测试,这些测试至少可以提供一组保证,即满足性能要求,而且某些功能的性能不会随时间退化。测量 Java 应用程序的性能并不简单,特别是由于存在许多可能影响性能统计信息的编译器和运行时优化。因此,必须使用额外的措施,如预热阶段和其他技巧,以提供更准确的性能测量。Java Microbenchmark Harness 是一个框架,它结合了多种技术,并附带一个方便的 API,可用于此目的。它不是一个新工具,但它包含在 Java 9 的发行版中。如果您还没有将 JMH 添加到您的工具箱中,请阅读关于在 Java 9 应用程序开发中使用 JMH 的详细章节。

开始使用 HTTP 2.0

HTTP 2.0 是 HTTP 1.1 协议的继任者,这个新版本的协议解决了之前版本的一些局限性和缺点。HTTP 2.0 通过多种方式提高了性能,并提供了诸如在单个 TCP 连接中进行请求/响应多路复用、服务器推送响应、流量控制和请求优先级等功能。

Java 提供了java.net.HttpURLConnection实用工具,可用于建立非安全的 HTTP 1.1 连接。然而,该 API 被认为难以维护,并且随着对 HTTP 2.0 的支持,进行了进一步的扩展。因此,引入了一个全新的客户端 API,以便通过 HTTP 2.0 或 WebSocket 协议建立连接。新的 HTTP 2.0 客户端及其提供的功能将在单独的章节中介绍。

涵盖响应式编程

响应式编程是一种用于描述系统变化传播特定模式的范式。响应性不是 Java 本身固有的,但可以使用第三方库如 RxJava 或 Reactor 项目(Spring 框架的一部分)建立响应式数据流。JDK 9 还通过提供java.util.concurrent.Flow类来满足开发围绕响应式流概念构建的高响应性应用程序的 API 需求。Flow类以及 JDK 9 中引入的其他相关更改将在单独的章节中介绍。

扩展愿望清单

除了 JDK 9 中的所有新特性外,平台未来版本预计还将引入一系列全新的功能。其中一些包括:

  • 原始类型上的泛型:这是 JDK 10 计划中 Valhalla 项目的一部分特性。其他语言增强,如值处理,已经是 Java 9 的一部分,将在本书的后续章节中介绍。

  • 具现泛型:这是 Valhalla 项目的一个特色部分,旨在提供在运行时保留泛型类型的能力。相关目标如下:

    • 外部功能接口旨在引入一个新的 API 来调用和管理本地函数。该 API 解决了 JNI 的一些缺点,特别是对于应用开发者来说缺乏简单性。外部功能接口是作为 JDK 生态系统中的 Panama 项目的一部分开发的。

    • 新的货币和货币 API(在 JSR 354 下开发)最初计划在 Java 9 中推出,但已推迟。

    • 新的轻量级 JSON API(在 JSR 353 下开发)也计划在 Java 9 中推出,但已推迟到 Java 10。

这些只是 JDK 后续版本中可能期待的一些新特性。Penrose 项目旨在弥合 Java 模块系统与 OSGi 模块系统之间的差距,并为两个系统之间的互操作性提供不同的方法。

Graal VM 是另一个有趣的研究项目,它可能是 Java 平台后续版本的一个潜在候选者。它的目标是提高 Java 的运行时性能,使其达到动态语言如 JavaScript 或 Ruby 的水平。

专门讨论 JDK 未来的章节详细讨论了所有这些点。

摘要

在本简要的介绍章节中,我们揭示了由 JDK 9 提供的强大功能的小宇宙。在这个平台版本中引入的模块系统无疑是 Java 应用程序开发的一个基石。我们还发现,JDK 9 中引入了许多其他重要特性和变更,这些特性和变更值得特别关注,将在后续章节中详细讨论。

在下一章中,我们将探讨 Java 平台的 26 个内部变更。

改进的竞争锁 [JEP 143]

JVM 使用堆空间来存储类和对象。每当创建一个对象时,JVM 都会在堆上分配内存。这有助于促进 Java 的垃圾回收,它释放了之前用于存储不再有引用的对象的内存。Java 栈内存有些不同,通常比堆内存小得多。

JVM 在管理多个线程共享的数据区域方面做得很好。它将监视器与每个对象和类关联;这些监视器有锁,在任何时候都由一个线程控制。这些由 JVM 控制的锁,本质上是在给予控制线程对象监视器的权限。

那么,什么是竞争锁?当一个线程正在等待当前已锁定对象的队列中时,它就被说成是在竞争该锁。以下图展示了这一竞争的高级视图:

图片

如前图所示,任何等待的线程在对象被释放之前都不能使用被锁定的对象。

改进目标

JEP 143 的一般目标是提高 JVM 管理锁定 Java 对象监视器竞争的整体性能。竞争锁的改进全部在 JVM 内部进行,并且不需要任何开发者的行动来从中受益。整体改进目标与更快操作相关。这包括:

  • 更快的监视器进入

  • 更快的监视器退出

  • 更快的通知

通知是当对象锁定状态改变时调用的 notify()notifyAll() 操作。测试这一改进并不是一件容易的事情。在任何级别上的更高效率都是受欢迎的,因此,即使没有容易观察到的测试,这一改进也是我们可以感激的。

分段代码缓存 [JEP 197]

分段代码缓存 JEP (197) 升级已完成,并导致更快的、更高效的执行时间。这一变化的核心理念是将代码缓存分割成三个不同的部分——非方法、已分析和未分析代码。

代码缓存是 Java 虚拟机存储生成的本地代码的内存区域。

如前所述的代码缓存分段将包含特定类型的编译代码。如以下图所示,代码堆区域按编译代码的类型进行分段:

图片

内存分配

包含非方法代码的代码堆是用于 JVM 内部代码的,由一个 3MB 的固定内存块组成。其余的代码缓存内存平均分配给已分析代码和非分析代码段。你可以通过命令行命令来控制这一点。

以下命令可以用来定义非方法编译代码的代码堆大小:

-XX:NonMethodCodeCodeHeapSize

以下命令可以用来定义已分析编译方法的代码堆大小:

-XX:ProfiledCodeHeapSize

以下命令可以用来定义非分析编译方法的代码堆大小:

-XX:NonProfiledCodeHeapSize

这个 Java 9 特性确实有助于提高 Java 应用程序的效率。它还影响了使用代码缓存的其他进程。

智能 Java 编译,第二阶段 [JEP 199]

JDK 增强提案 199 旨在改进代码编译过程。所有 Java 开发者都将熟悉用于将源代码编译成字节码的javac工具,这是 JVM 运行 Java 程序所使用的。智能 Java 编译,也称为 Smart Javac 和sjavac,在 javac 进程周围添加了一个智能包装。sjavac 可能带来的核心改进是只重新编译必要的代码。在这个上下文中,必要的代码是指自上次编译周期以来已更改的代码。

如果开发者只在小项目上工作,这个增强可能不会让他们感到兴奋。然而,考虑到在中等和大型项目中你不得不不断重新编译代码时在效率上的巨大提升,开发者可以节省的时间足以让他们接受 JEP 199。

这将如何改变你编译代码的方式?可能不会,至少目前不会。Javac 将继续作为默认编译器。虽然 sjavac 在增量构建方面提供了效率,但 Oracle 认为它没有足够的稳定性成为标准编译工作流程的一部分。

你可以在此处阅读有关智能 javac 包装工具的更多信息:cr.openjdk.java.net/~briangoetz/JDK-8030245/webrev/src/share/classes/com/sun/tools/sjavac/Main.java-.html

解决 Lint 和 Doclint 警告 [JEP 212]

如果你对 Java 中的 Lint 或 Doclint 不熟悉,请不要担心。正如标题所示,它们是向 javac 报告警告的来源。让我们逐一看看:

  • Lint分析 javac 的字节码和源代码。Lint 的目标是识别正在分析代码中的安全漏洞。Lint 还可以提供关于可扩展性和线程锁定问题的见解。Lint 还有更多功能,其总体目的是节省开发者的时间。

你可以在此处了解更多关于 Lint 的信息:en.wikipedia.org/wiki/Lint_(software)

  • Doclint 与 Lint 类似,且特定于 javadoc。Lint 和 Doclint 都在编译过程中报告错误和警告。这些警告的解决是 JEP 212 的重点。当使用核心库时,不应有任何警告。这种心态导致了 JEP 212,它已在 Java 9 中得到解决并实现。

可以在 bugs.openjdk.java.net JDK Bug 系统中查看 Lint 和 Doclint 警告的完整列表。

javac 的分层归因 [JEP 215]

JEP 215 代表了对 javac 类型检查架构进行简化的令人印象深刻的努力。让我们首先回顾一下 Java 8 中的类型检查是如何工作的;然后我们将探讨 Java 9 中的变化。

在 Java 8 中,多态表达式类型检查由一个推测性归因工具处理。

推测性归因是 javac 编译过程的一部分类型检查方法。它具有显著的处理开销。

使用推测性归因方法进行类型检查是准确的,但缺乏效率。这些检查包括参数位置,在递归、多态、嵌套循环和 lambda 表达式中测试时,速度呈指数级下降。因此,JEP 215 的目标是改变类型检查架构以创建更快的结果。使用推测性归因的结果本身并不不准确;只是生成速度不够快。

新的方法,随 Java 9 一起发布,使用分层归因工具。此工具为所有方法调用中的参数表达式类型检查实现分层方法。还允许方法重写。为了使此新架构工作,为以下列出的每种方法参数类型创建了新的结构化类型:

  • Lambda 表达式

  • 多态表达式

  • 正常方法调用

  • 方法引用

  • 钻石实例创建表达式

JEP 215 对 javac 的更改比本节所强调的更为复杂。除了更高效的 javac 和节省的时间外,对开发者的直接影响并不立即显现。

注解管道 2.0 [JEP 217]

Java 注解指的是位于您的 Java 源代码文件中的特殊类型的元数据。它们不会被 javac 移除,因此它们可以在运行时对 JVM 可用。

注解看起来与 JavaDocs 引用相似,因为它们以 @ 符号开头。有三种类型的注解。让我们逐一检查:

  • 最基本的注解形式是标记注解。这些是独立的注解,唯一的组件是动画的名称。以下是一个示例:
        @thisIsAMarkerAnnotation
        public double computeSometing(double x, double y) 
        {
          // do something and return a double
        }
  • 第二种类型的注解是包含一个单个值或数据的注解。正如您在以下代码中所看到的,以 @ 符号开始的注解后面跟着包含数据的括号:
        @thisIsAMarkerAnnotation (data="compute x and y 
         coordinates")
        public double computeSometing(double x, double y) 
        {
          // do something and return a double
        }

编码单值注解类型的另一种方法是省略 data= 组件,如下面的代码所示:

        @thisIsAMarkerAnnotation ("compute x and y coordinates")
        public double computeSometing(double x, double y) 
        {
          // do something and return a double
        }
  • 第三种类型的注解是当存在多个数据组件时。在这种类型的注解中,data=组件不能省略。以下是一个示例:
        @thisIsAMarkerAnnotation (data="compute x and y 
         coordinates", purpose="determine intersecting point")
         public double computeSometing(double x, double y) 
         {
           // do something and return a double
         }

那么,Java 9 中有什么变化?要回答这个问题,我们需要回顾一下 Java 8 中引入的一些变化,这些变化影响了 Java 注解:

  • Lambda 表达式

  • 重复注解

  • Java 类型注解

这些与 Java 8 相关的更改影响了 Java 注解,但并没有导致 javac 处理它们的方式发生变化。有一些硬编码的解决方案允许 javac 处理新的注解,但它们并不高效。此外,这种类型的编码(硬编码解决方案)难以维护。

因此,JEP 217 专注于重构 javac 注解管道。这次重构完全在 javac 内部进行,所以它不应该对开发者明显。

新的版本字符串方案 [JEP 223]

在 Java 9 之前,发布号没有遵循行业标准版本控制--语义版本控制。例如,在撰写本文时,最后四个 JDK 发布版是:

  • JDK 8 更新 131

  • JDK 8 更新 121

  • JDK 8 更新 112

语义版本控制使用主版本、次版本、补丁版本(0.0.0)的方案:

主版本等同于不向后兼容的新 API 变更。

次版本是指添加了向后兼容的功能。

补丁指的是向后兼容的错误修复或小更改。

Oracle 已经接受了 Java 9 及以后的语义版本控制。对于 Java,将使用主版本-次版本-安全方案来表示 Java 版本号的前三个元素:

  • 主版本:包含一组重大新功能的重大版本

  • 次版本:向后兼容的修订和错误修复

  • 安全:被认为对提高安全性至关重要的修复

JEP 223 的这个描述可能会使版本控制方案看起来很基础。相反,已经开发了一套非常详细的规则和实践来管理未来的版本号。为了展示复杂性,请参见以下示例:

    1.9.0._32.b19

自动生成运行时编译器测试 [JEP 233]

Java 可能是使用最广泛的编程语言,并且运行在日益多样化的平台上。这加剧了在高效方式下运行针对编译器的测试的问题。JEP 233 的目的是创建一个可以自动化运行时编译器测试的工具。

创建的工具首先生成一组随机的 Java 源代码和/或字节码。生成的代码将具有三个关键特征:

  • 语法正确

  • 语义正确

  • 使用一个允许重用相同随机生成代码的随机种子

随机生成的源代码将被保存在以下目录中:

    hotspot/test/testlibrary/jit-tester

这些测试用例将被存储以供以后重用。它们可以从j-treg目录或从工具的 makefile 中运行。重新运行保存的测试的好处之一是测试系统的稳定性。

测试由 Javac 生成的类文件属性 [JEP 235]

缺乏或能力不足创建用于类文件属性的测试是 JEP 235 的动力。目标是确保 javac 完全且正确地创建类文件的属性。这表明即使某些属性没有被类文件使用,所有类文件都应生成一个完整的属性集。还需要有一种方法来测试类文件是否正确创建,特别是关于文件的属性。

在 Java 9 之前,没有测试类文件属性的方法。运行一个类并测试代码以获得预期的结果是最常用的测试 javac 生成类文件的方法。这种技术不足以测试以验证文件的属性。

类文件属性有三个类别——JVM 使用的属性、可选属性和 JVM 未使用的属性。

JVM 使用的属性包括:

  • BootstrapMethods

  • Code

  • ConstantValue

  • Exceptions

  • StackMapTable

可选属性包括:

  • Deprecated

  • LineNumberTable

  • LocalVariableTable

  • LocalVariableTypeTable

  • SourceDebugExtension

  • SourceFile

JVM 未使用的属性包括:

  • AnnotationDefault

  • EnclosingMethod

  • InnerClasses

  • MethodParameters

  • RuntimeInvisibleAnnotations

  • RuntimeInvisibleParameterAnnotations

  • RuntimeInvisibleTypeAnnotations

  • RuntimeVisibleAnnotations

  • RuntimeVisibleParameterAnnotations

  • RuntimeVisibleTypeAnnotations

  • Signature

  • Synthetic

在 CDS 归档中存储内部字符串 [JEP 250]

存储和访问字符串以及从 类数据共享CDS)归档中存储字符串的方法效率低下,耗时过多,且浪费内存。以下图示说明了 Java 在 CDS 归档中存储内部字符串的方法:

低效源于当前的存储架构。特别是当类数据共享工具将类导出到共享归档文件时,包含CONSTANT_String项的常量池具有 UTF-8 字符串表示形式。

UTF-8 是一个 8 位可变长度字符编码标准。

问题

在当前使用 UTF-8 的情况下,字符串必须转换为字符串对象,即 java.lang.String 类的实例。这种转换是按需进行的,可能会导致系统运行速度变慢和不必要的内存使用。处理时间非常短,但内存使用不能忽视。每个内部字符串中的字符至少需要 3 字节内存,可能还需要更多。

相关问题是存储的字符串对所有 JVM 进程不可访问。

解决方案

CDS 归档现在在堆上为字符串分配特定空间:

字符串空间是通过共享字符串表、哈希表和去重来映射的。

去重是一种数据压缩技术,它消除了归档中的重复信息。

为模块化准备 JavaFX UI 控件和 CSS API [JEP 253]

JavaFX 是一组允许设计和开发富媒体图形用户界面的包。JavaFX 应用程序为开发者提供了一个优秀的 API,用于创建应用程序的一致界面。层叠样式表CSS)可以用来自定义界面。JavaFX 的伟大之处之一是编程和界面设计任务可以轻松分离。

JavaFX 概述

有一个叫做 Scene Builder 的出色的可视化脚本工具,它允许您通过拖放和属性设置来创建图形用户界面。Scene Builder 生成必要的 FXML 文件,这些文件由您的集成开发环境IDE)如 NetBeans 使用。

下面是使用 Scene Builder 创建的示例 UI:

并且,以下是 Scene Builder 创建的 FXML 文件:

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

    <?import java.lang.*?>
    <?import java.util.*?>
    <?import javafx.scene.control.*?>
    <?import javafx.scene.layout.*?>
    <?import javafx.scene.paint.*?>
    <?import javafx.scene.text.*?>

    <AnchorPane id="AnchorPane" maxHeight="-Infinity"
     maxWidth="-Infinity" minHeight="-Infinity"
     minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0"

     >
     <children>
       <TitledPane animated="false" collapsible="false"
        layoutX="108.0" layoutY="49.0" text="Sample">
       <content>
         <AnchorPane id="Content" minHeight="0.0" minWidth="0.0"
          prefHeight="180.0" prefWidth="200.0">
         <children>
           <CheckBox layoutX="26.0" layoutY="33.0" 
            mnemonicParsing="false" prefWidth="94.0" 
            text="CheckBox" />
           <ColorPicker layoutX="26.0" layoutY="65.0" />
           <Hyperlink layoutX="26.0" layoutY="103.0"
            text="Hyperlink" />
           <Label alignment="CENTER" layoutX="14.0" layoutY="5.0" 
            prefWidth="172.0" text="This is a Label"
            textAlignment="CENTER">
            <font>
              <Font size="14.0" />
            </font>
           </Label>
           <Button layoutX="81.0" layoutY="146.0" 
            mnemonicParsing="false" text="Button" />
         </children>
         </AnchorPane>
       </content>
       </TitledPane>
     </children>
    </AnchorPane>

Java 9 的影响

在 Java 9 之前,JavaFX 控件以及 CSS 功能只能通过接口内部 API 供开发者使用。Java 9 的模块化使得内部 API 不可访问。因此,JEP 253 被创建来定义公共 API,而不是内部 API。

这项工作比看起来要大得多。以下是作为此 JEP 一部分采取的一些行动:

  • 将 javaFX 控件皮肤从内部 API 移动到公共 API(javafx.scene.skin

  • 确保 API 一致性

  • 生成详尽的 javadoc

以下类已从内部包移动到公共javafx.scene.control.skin包:

AccordionSkin ButtonBarSkin ButtonSkin CellSkinBase
CheckBoxSkin ChoiceBoxSkin ColorPickerSkin ComboBoxBaseSkin
ComboBoxListViewSkin ComboBoxPopupControl ContextMenuSkin DateCellSkin
DatePickerSkin HyperLinkSkin LabelSkin LabeledSkinBase
ListCellSkin ListViewSkin MenuBarSkin MenuButtonSkin
MenuButtonSkinbase NestedTableColumHeader PaginationSkin ProgressBarSkin
ProgressIndicatorSkin RadioButtonSkin ScrollBarSkin ScrollPaneSkin
SeparatorSkin SliderSkin SpinnerSkin SplitMenuButtonSkin
SplitPaneSkin TabPaneSkin TableCellSkin TableCellSkinBase
TableColumnHeader TableHeaderRow TableHeaderSkin TableRowSkinBase
TableViewSkin TableViewSkinBase TextAreaSkin TextFieldSkin
TextInputControlSkin TitledPaneSkin ToggleButtonSkin TooBarSkin
TooltipSkin TreeCellSkin TreeTableCellSkin TreeTableRowSkin
TreeTableViewSkin TreeViewSkin VirtualContainerBase VirtualFlow

公共javafx.css包现在有额外的类:

  • CascadingStyle.java:public class CascadingStyle implements Comparable<CascadingStyle>

  • CompoundSelector.java:final public class CompoundSelector extends Selector

  • CssError.java:public class CssError

  • Declaration.java:final public class Declaration

  • Rule.java:final public class Rule

  • Selector.java:abstract public class Selector

  • SimpleSelector.java:final public class SimpleSelector extends Selector

  • Size.java:final public class Size

  • Style.java:final public class Style

  • Stylesheet.java:public class Stylesheet

  • CssParser.java:final public class CssParser

紧凑字符串 [JEP 254]

字符串数据类型是几乎所有 Java 应用程序的重要部分。虽然 JEP 254 的目标是使字符串更节省空间,但它采取了谨慎的态度,以确保现有的性能和兼容性不会受到负面影响。

Java 9 之前的状态

在 Java 9 之前,字符串数据存储为字符数组。每个字符需要 16 位。确定大多数 String 对象可以用仅 8 位,即 1 字节的空间存储。这是因为大多数字符串由 Latin-1 字符组成。

ISO Latin-1 字符集是一个单字节字符编码集合。

Java 9 新增

从 Java 9 开始,字符串现在使用字节数组内部表示,并带有编码引用的标志字段。

将选定的 Xerces 2.11.0 更新合并到 JAXP [JEP 255]

Xerces 是一个用于在 Java 中解析 XML 的库。它在 2010 年晚些时候更新到 2.11.0,因此 JEP 255 的目标是更新 JAXP 以包含 Xerces 2.11.0 的更改。

JAXP 是 Java 的 XML 处理 API。

在 Java 9 之前,JDK 关于 XML 处理的最新更新基于 Xerces 2.7.1。基于 Xerces 的 JDK 7 进行了一些额外的更改,版本为 2.10.0。JEP 255 是基于 Xerces 2.11.0 的 JAXP 的进一步改进。

Xerces 2.11.0 支持以下标准:

  • XML 1.0,第四版

  • XML 1.0 第二版中的命名空间

  • XML 1.1,第二版

  • XML 1.1 第二版中的命名空间

  • XML 包含 1.0,第二版

  • 文档对象模型 (DOM)

    • 第三级

      • 核心功能

      • 加载与保存

    • 第二级

      • 核心功能

      • 事件

  • 遍历与范围

  • 元素遍历,第一版

  • 简单 XML API 2.0.2

  • Java XML 处理 API (JAXP) 1.4

  • XML 流式 API 1.0

  • XML Schema 1.0

  • XML Schema 1.1

  • XML 模式定义语言

JDK 更新以包括以下 Xerces 2.11.0 类别:

  • 目录解析器

  • 数据类型

  • 文档对象模型第三级

  • XML 模式验证

  • XPointer

Java 9 中 JAXP 的公共 API 没有发生变化。

将 JavaFX/Media 更新到 GStreamer 的新版本 [JEP 257]

JavaFX 用于创建桌面和 Web 应用程序。JavaFX 是为了取代 Swing 而创建的,作为 Java 的标准 GUI 库。Media 类,javafx.scene.media.Media,用于实例化表示媒体资源的对象。JavaFX/Media 指的是以下类:

    public final class Media extends java.lang.Object

此类为媒体资源提供引用数据。javafx.scene.media 包为开发者提供了将媒体集成到他们的 JavaFX 应用程序中的能力。JavaFX/Media 使用 GStreamer 管道。

GStreamer 是一个多媒体处理框架,可用于构建从多种不同格式获取媒体,并在处理后将它们导出为选定格式的系统。

JEP 257 的目的是确保 JavaFX/Media 被更新以包含 GStreamer 的最新版本,以提供稳定性、性能和安全保证。

HarfBuzz 字体布局引擎 [JEP 258]

在 Java 9 之前,布局引擎用于处理字体复杂性;特别是具有超出常见拉丁字体渲染行为的字体。Java 使用统一客户端界面,也称为 ICU,作为默认文本渲染工具。ICU 布局引擎已被弃用,并在 Java 9 中被 HarfBuzz 字体布局引擎所取代。

HarfBuzz 是一个 OpenType 文本渲染引擎。这种布局引擎具有提供脚本感知代码的特性,以帮助确保文本按预期布局。

OpenType 是一种 HTML 格式的字体格式规范。

从 ICU 布局引擎到 HarfBuzz 字体布局引擎的变更动力源于 IBM 决定停止支持 ICU 布局引擎。因此,JDK 被更新以包含 HarfBuzz 字体布局引擎。

Windows 和 Linux 上的 HiDPI 图形 [JEP 263]

JEP 263 专注于确保屏幕组件的清晰度,相对于显示器的像素密度。以下术语与该 JEP 相关,并附带以下列出的描述性信息:

  • DPI 感知应用程序:能够检测和缩放图像以适应显示特定像素密度的应用程序

  • DPI 不可感知应用程序:不尝试检测和缩放图像以适应显示器特定像素密度的应用程序

  • HiDPI 图形:每英寸高点的图形

  • Retina 显示:这个术语是由苹果公司创造的,指的是至少每英寸有 300 个像素密度的显示器

向用户显示图形,无论是图像还是图形用户界面组件,通常具有至关重要的性能。以高质量显示这些图像可能有些问题。计算机监视器的 DPI 存在很大的差异。开发针对显示器的三种基本方法如下:

  • 开发不考虑潜在不同显示尺寸的应用程序。换句话说,创建一个 DPI 不可感知应用程序。

  • 开发一个选择性地使用预渲染图像大小以适应特定显示的 DPI 感知应用程序。

  • 开发一个 DPI 感知应用程序,能够适当地放大或缩小图像以适应应用程序运行的特定显示。

显然,前两种方法都有问题,原因各不相同。在第一种方法中,用户体验没有被考虑。当然,如果应用程序是为一个非常具体的显示而开发的,且没有预期的像素密度变化,那么这种方法可能是可行的。

第二种方法需要在设计和开发端进行大量工作,以确保为每个预期的显示密度创建并实现图像。除了大量的工作外,应用程序的大小将不必要地增加,并且没有考虑到新的和不同的像素密度。

第三种方法是通过创建具有高效和有效缩放能力的 DPI 感知应用程序。这种方法效果良好,并且已经在 Mac 视网膜显示器上得到验证。

在 Java 9 之前,Java 已经在 Mac OS X 操作系统上实现了自动缩放和尺寸调整功能。此功能在 Java 9 中添加到了 Windows 和 Linux 操作系统。

马林图形渲染器 [JEP 265]

JEP 265 在 Java 2D API 中将鱼鹰图形光栅化器替换为马林图形渲染器。此 API 用于绘制 2D 图形和动画。

目标是用一个效率更高且没有任何质量损失的光栅化器/渲染器替换鱼鹰。这个目标在 Java 9 中实现了。一个预期的附带好处是包括一个开发者可访问的 API。以前,与 AWT 和 Java 2D 交互的方式是内部的。

Unicode 8.0.0 [JEP 267]

Unicode 8.0.0 于 2015 年 6 月 17 日发布。JEP 267 专注于更新相关 API 以支持 Unicode 8.0.0。

新增于 Unicode 8.0.0

Unicode 8.0.0 增加了近 8000 个字符。以下是发布的高亮内容:

  • 泰阿洪语(印度)的阿洪文字

  • 阿尔威语,泰米尔语(阿拉伯语)

  • 切罗基符号

  • CJK 统一表意文字

  • 带有肤色符号修饰符的表情符号

  • 格鲁吉亚拉里货币符号

  • lk 语言(乌干达)

  • 库朗戈语(科特迪瓦)

Java 9 中更新的类

为了完全符合新的 Unicode 标准,几个 Java 类被更新。以下列出的类被更新以符合 Java 9 的新 Unicode 标准:

  • java.awt.font.NumericShaper

  • java.lang.Character

  • java.lang.String

  • java.text.Bidi

  • java.text.BreakIterator

  • java.text.Normalizer

为关键部分保留堆栈区域 [JEP 270]

JEP 270 的目标是在执行关键部分时减轻堆栈溢出问题。这种缓解措施是通过保留额外的线程堆栈空间来实现的。

Java 9 之前的情况

当 JVM 被要求在缺乏堆空间且没有权限分配额外空间的线程中执行数据计算时,它会抛出StackOverflowError异常。这是一个异步异常。当方法被调用时,JVM 也可以同步地抛出StackOverflowError异常。

当一个方法被调用时,会使用一个内部过程来报告栈溢出。虽然当前的架构足以报告错误,但调用应用程序无法轻易地从错误中恢复。这可能会给开发人员和用户带来更多的不便。如果在关键的计算操作期间抛出了StackOverflowError,数据可能会被损坏,导致额外的麻烦。

虽然不是这些问题的唯一原因,但ReentrantLock类的受影响状态是导致不理想结果的一个常见原因。这个问题在 Java 7 中很明显,因为ConcurrentHasMap代码实现了ReentrantLock类。ConcurrentHasMap代码在 Java 8 中进行了修改,但对于ReentrantLock类的任何实现,问题仍然存在。类似的问题超出了仅仅使用ReentrantLock类的情况。

下面的图表提供了一个关于StackOverflowError问题的广泛概述:

在下一节中,我们将探讨如何解决 Java 9 中的这个问题。

新增于 Java 9

通过 Java 9 的 JEP 270 更改,关键部分将自动获得额外的空间,以便它可以完成其执行而不会受到StackOverflowError的影响。这基于额外的空间分配需求很小。为了允许这个功能,JVM 已经做出了必要的更改。

JVM 实际上会延迟StackOverflowError,或者至少在执行关键部分时尝试这样做。为了利用这个新的架构,方法必须使用以下注解:

    jdk.internal.vm.annotation.ReservedStackAccess

当一个方法有这个注解并且存在StackOverflowError条件时,将授予对保留内存空间的临时访问权限。这个新过程在高度抽象的层面上可以表示如下:

语言定义的对象模型的动态链接 [JEP 276]

Java 互操作性通过 JEP 276 得到了增强。为了允许多种语言的运行时链接器在单个 JVM 实例中共存,已经做出了必要的 JDK 更改。正如您所期望的,这个更改适用于高级操作。一个相关的高级操作示例是使用访问器和修改器等元素读取或写入属性。

高级操作适用于未知类型的对象。它们可以使用INVOKEDYNAMIC指令来调用。以下是一个示例,当在编译时未知对象的类型时调用对象的属性:

    INVOKEDYNAMIC "dyn:getProp:age"

概念验证

Nashorn 是一个轻量级、高性能的 JavaScript 运行时,允许在 Java 应用程序中嵌入 JavaScript。这是为 Java 8 创建的,并取代了基于 Mozilla Rhino 的先前 JavaScript 脚本引擎。Nashorn 已经具有此功能。它提供未知类型任何对象的高级操作之间的链接,例如 obj.something,它产生以下结果:

    INVOKEDYNAMIC "dyn.getProp.something"

动态链接器迅速行动起来,并在可能的情况下提供适当的实现。

G1 中针对巨大对象的额外测试 [JEP 278]

Java 平台长期受欢迎的功能之一是幕后垃圾收集。JEP 278 的重点是创建针对巨大对象的额外白盒测试,作为 G1 垃圾收集器的一个功能。

白盒测试是一个用于查询 JVM 内部的 API。白盒测试 API 在 Java 7 中引入,并在 Java 8 和 Java 9 中进行了升级。

G1 垃圾收集器工作得非常好,但还有改进效率的空间。G1 垃圾收集器的工作方式是基于首先将堆划分为大小相等的区域,如下所示:

图片

G1 垃圾收集器的问题在于如何处理巨大对象。

在垃圾收集的上下文中,巨大对象是指占据堆上多个区域的任何对象。

巨大对象的问题在于,如果它们占据了堆上任何区域的一部分,剩余的空间就无法为其他对象分配。在 Java 9 中,WhiteBox API 通过四种新的方法进行了扩展:

  • 用于阻止完全垃圾收集并启动并发标记的方法。

  • 可以访问单个 G1 垃圾收集堆区域的方法。对这些区域的访问包括属性读取,例如读取区域的当前状态。

  • 直接访问 G1 垃圾收集内部变量的方法。

  • 可以确定巨大对象是否位于堆上以及(如果位于)位于哪些区域的方法。

改进测试故障排除 [JEP 279]

对于进行大量测试的开发者来说,阅读关于 JEP 279 的内容是值得的。Java 9 中添加了额外的功能,以自动收集信息以支持故障排除测试失败以及超时。在测试期间收集可用的诊断信息,可以为开发者和工程师提供更精确的日志和其他输出。

在测试的上下文中,有两种基本类型的信息——环境和进程。

环境信息

在运行测试时,测试环境信息对于故障排除工作可能很重要。以下信息包括:

  • CPU 负载

  • 磁盘空间

  • I/O 负载

  • 内存空间

  • 打开文件

  • 打开套接字

  • 正在运行的进程

  • 系统事件

  • 系统消息

Java 进程信息

在测试过程中,还可以直接获得与 Java 进程相关的信息。这些包括:

  • C 栈

  • 核心转储

  • 小型转储

  • 堆统计信息

  • Java 栈

有关此概念的更多信息,请阅读 JDK 的回归测试工具(jtreg)。

优化字符串连接 [JEP 280]

JEP 280 是 Java 平台的一个有趣增强。在 Java 9 之前,字符串连接被 javac 转换为 StringBuilder :: append 链。这是一种次优的转换方法,通常需要 StringBuilder 预分配大小。

该增强改变了 javac 生成的字符串连接字节码序列,使其使用 INVOKEDYNAMIC 调用。增强的目的是提高优化并支持未来的优化,而无需重新格式化 javac 的字节码。

有关 INVOKEDYNAMIC 的更多信息,请参阅 JEP 276。

使用 INVOKEDYAMIC 调用 java.lang.invoke.StringConcatFactory 允许我们使用类似于 lambda 表达式的技术,而不是使用 StringBuilder 的逐步过程。这导致字符串连接处理更加高效。

HotSpot C++ 单元测试框架 [JEP 281]

HotSpot 是 JVM 的名称。这个 Java 增强旨在支持为 JVM 开发 C++ 单元测试。以下是此增强的某些部分、非优先级列表的目标:

  • 命令行测试

  • 创建适当的文档

  • 调试编译目标

  • 框架弹性

  • IDE 支持

  • 单独和隔离的单元测试

  • 个性化测试结果

  • 与现有基础设施集成

  • 内部测试支持

  • 正面和负面测试

  • 短执行时间测试

  • 支持所有 JDK 9 构建平台

  • 测试编译目标

  • 测试排除

  • 测试分组

  • 需要初始化 JVM 的测试

  • 测试与源代码位于同一位置

  • 测试平台相关代码

  • 编写和执行单元测试(对于类和方法)

此增强是日益可扩展性的证据。

在 Linux 上启用 GTK 3 [JEP 283]

GTK+,正式名称为 GIMP 工具箱,是一个跨平台工具,用于创建 图形用户界面GUI)。该工具由其 API 可访问的小部件组成。JEP 283 的重点是确保在开发具有图形组件的 Java 应用程序时,Linux 上支持 GTK 2 和 GTK 3。实现支持使用 JavaFX、AWT 和 Swing 的 Java 应用程序。

我们可以使用 JavaFX、AWT 和 Swing 创建 Java 图形应用程序。以下是一个表格,总结这三个方法与 GTK 的关系,在 Java 9 之前:

方法 备注
JavaFX
  • 使用动态 GTK 函数查找

  • 通过 JFXPanel 与 AWT 和 Swing 交互

  • 使用 AWT 打印功能

|

AWT
  • 使用动态 GTK 函数查找

|

Swing
  • 使用动态 GTK 函数查找

|

那么,为了实现这个 JEP,需要做出哪些改变?对于 JavaFX,有三个具体的变化:

  • 为 GTK 2 和 GTK 3 添加了自动测试

  • 添加了动态加载 GTK 2 的功能

  • 添加了对 GTK 3 的支持

对于 AWT 和 Swing,实施了以下更改:

  • 为 GTK 2 和 GTK 3 添加了自动测试

  • AwtRobot 已迁移到 GTK 3

  • FileChooserDilaog 已更新以支持 GTK 3

  • 添加了动态加载 GTK 3 的功能

  • Swing GTK LnF 已修改以支持 GTK 3

Swing GTK LnF 是 Swing GTK look and feel 的缩写。

新的 HotSpot 构建系统 [JEP 284]

在 Java 9 之前使用的 Java 平台是一个充斥着重复代码、冗余和其他低效的构建系统。该构建系统已根据内置的 build-infra 框架进行了重构。在此上下文中,infra 是 infrastructure 的缩写。JEP 284 的总体目标是升级构建系统,使其简化。具体目标包括:

  • 利用现有的构建系统

  • 可维护的代码

  • 最小化重复代码

  • 简化

  • 支持未来的增强

您可以在此网站上了解更多关于 Oracle 基础设施框架的信息:www.oracle.com/technetwork/oem/frmwrk-infra-496656.html

摘要

在本章中,我们介绍了 Java 平台的一些令人印象深刻的新特性,特别关注 javac、JDK 库和各种测试套件。内存管理改进,包括堆空间效率、内存分配和改进的垃圾回收,代表了一组强大的 Java 平台增强。关于编译过程的更改,导致更高的效率,也是我们章节的一部分。我们还介绍了重要的改进,如编译过程、类型测试、注解和自动运行时编译器测试。

在下一章中,我们将探讨 Java 9 中引入的几个小的语言增强。

第四章:Java 9 语言增强

在上一章中,我们了解了 Java 9 中包含的一些令人兴奋的新特性。我们的重点是 javac、JDK 库和测试套件。我们学习了内存管理改进,包括内存分配、堆优化和增强的垃圾回收。我们还涵盖了编译过程、类型测试、注解和运行时编译器测试的更改。

本章涵盖了 Java 9 中的一些变化,这些变化影响了变量处理器、弃用警告、在 Java 7 中实施的 Project Coin 更改的改进以及导入语句处理。这些代表了 Java 语言本身的变更。

我们将在这里讨论的主题包括:

  • 变量处理器

  • 导入语句弃用警告

  • Project Coin

  • 导入语句处理

与变量处理器协同工作 [JEP 193]

变量处理器是对变量的类型引用,并由java.lang.invoke.VarHandle抽象类管理。VarHandle方法的签名是多态的。这为方法签名和返回类型提供了极大的可变性。以下是一个代码示例,展示了如何使用VarHandle

    . . . 

    class Example 
    {
      int myInt;
      . . . 
    }
    . . . 
    class Sample 
    {
      static final VarHandle VH_MYINT;

      static 
      {
        try 
        {
          VH_MYINT =  
            MethodHandles.lookup().in(Example.class)
            .findVarHandle(Example.class, "myInt", int.class);
        } 
        catch (Exception e) 
        {
          throw new Error(e);
        }
      }
    }

    . . . 
VarHandle.lookup() performs the same operation as those that are performed by a MethodHandle.lookup() method.

这个 JEP 的目的是标准化以下类的方法调用方式:

  • java.util.concurrent.atomic

  • sun.misc.Unsafe

特别是以下方法:

  • 访问/修改对象字段

  • 访问/修改数组元素

此外,这个 JEP 导致了两个用于内存排序和对象可达性的栅栏操作。本着尽职尽责的精神,特别关注确保 JVM 的安全性。重要的是要确保这些更改不会导致内存错误。数据完整性、可用性和当然,性能是上述尽职尽责的关键组成部分,以下将进行解释:

  • 安全性:必须不可能出现损坏的内存状态。

  • 数据完整性:确保访问对象字段使用与以下相同的规则:

    • getfield字节码

    • putfield字节码

  • 可用性:可用性的基准是sun.misc.Unsafe API。目标是使新的 API 比基准更容易使用。

  • 性能:与使用sun.misc.Unsafe API 相比,性能可能不会下降。目标是超越该 API。

在 Java 中,栅栏操作是 javac 强制对内存施加约束的形式,这种约束以屏障指令的形式出现。这些操作发生在屏障指令之前和之后,实际上是将它们围起来。

使用 AtoMiC 工具包协同工作

java.util.concurrent.atomic包是一组 12 个子类,支持对线程安全和无锁的单个变量的操作。在这种情况下,线程安全指的是访问或修改共享单个变量的代码,而不会妨碍同时在该变量上执行的其它线程。这个超类是在 Java 7 中引入的。

这里是 AtoMiC 工具包中的 12 个子类的列表。正如你所期望的,类名是自我描述的:

原子子类
java.util.concurrent.atomic.AtomicBoolean
java.util.concurrent.atomic.AtomicInteger
java.util.concurrent.atomic.AtomicIntegerArray
java.util.concurrent.atomic.AtomicIntegerFieldUpdater<T>
java.util.concurrent.atomic.AtomicLong
java.util.concurrent.atomic.AtomicLongArray
java.util.concurrent.atomic.AtomicLongFieldUpdater<T>
java.util.concurrent.atomic.AtomicMarkableReference<V>
java.util.concurrent.atomic.AtomicReference<V>
java.util.concurrent.atomic.AtomicReferenceArray<E>
java.util.concurrent.atomic.AtomicReferenceFieldUpdater<T,V>
java.util.concurrent.atomic.AtomicStampedReference<V>

可变变量、字段和数组元素可以被并发线程异步修改。

在 Java 中,volatile 关键字用于通知 javac 工具从主内存中读取值、字段或数组元素,而不是将其缓存。

这里有一个代码片段,演示了如何使用 volatile 关键字来对一个实例变量进行操作:

    public class Sample 
    {
      private static volatile Sample myVolatileVariable; // a
       volatile instance variable

      public static Sample getVariable() // getter method
      {
        if (myVolatileVariable != null) 
        {
          return myVolatileVariable;
        }
        // this section executes if myVolatileVariable == null
        synchronized(Sample.class)
        {
          if (myVolatileVariable == null)
          {
            myVolatileVariable =  new Sample();
          }
        }
    }

使用 sun.misc.Unsafe 类

sun.misc.Unsafe 类,像其他 sun 类一样,没有官方文档或支持。它被用来绕过 Java 内置的某些内存管理安全特性。虽然这可以被视为对我们代码中更大控制和灵活性的窗口,但它是一种糟糕的编程实践。

该类有一个单独的私有构造函数,因此无法轻松实例化该类的实例。因此,如果我们尝试使用 myUnsafe = new Unsafe() 实例化一个实例,在大多数情况下会抛出 SecurityException。这个相对难以触及的类有超过 100 个方法,允许对数组、类和对象进行操作。以下是这些方法的简要示例:

数组 对象
arrayBaseOffset defineAnonymousClass allocateInstance
arrayIndexScale defineClass objectFieldOffset
ensureClassInitialized
staticFieldOffset

这里是 sun.misc.Unsafe 类方法的次要分组,按信息、内存和同步进行分组:

信息 内存 同步
addressSize allocateMemory compareAndSwapInt
pageSize copyMemory monitorEnter
freeMemory monitorExit
getAddress putOrderedEdit
getInt tryMonitorEnter
putInt

sun.misc.Unsafe 类在 Java 9 中被标记为移除。实际上,在编程行业中对此决定有一些反对意见。为了平息他们的担忧,该类已被弃用,但不会完全移除。可以向 JVM 发送一个特殊标志来利用原始 API。

在导入语句中省略弃用警告 [JEP 211]

这是一些更简单的 Java 9 JEP 之一。在编译我们的程序时,我们经常会收到许多警告和错误。编译器错误必须修复,因为它们通常是语法性的。另一方面,警告应该被审查并适当处理。一些警告消息被开发者忽略。

此 JEP 在我们收到的警告数量上提供了一些缓解。具体来说,由导入语句引起的过时警告不再生成。在 Java 9 之前,我们可以使用以下注解来抑制过时警告消息:

    @SupressWarnings

现在,随着 Java 9 的推出,编译器将在以下情况之一为真时抑制已过时的警告:

  • 如果使用 @Deprecated 注解

  • 如果使用 @SuppressWarnings 注解

  • 如果生成警告的代码和声明在祖先类中

  • 如果生成警告的代码在导入语句中使用

列出的第四个条件是在 Java 9 中添加的。

Milling Project Coin [JEP 213]

Project Coin 是在 Java 7 中引入的微小更改的功能集。以下列出了这些更改:

  • switch 语句中的字符串

  • 二进制整数字面量

  • 在数字字面量中使用下划线

  • 实现多捕获

  • 允许更精确地重新抛出异常

  • 泛型实例创建改进

  • 添加 try-with-resources 语句

  • 调用 varargs 方法的改进

详细信息可以在以下 Oracle 演示文稿中找到:www.oracle.com/us/technologies/java/project-coin-428201.pdf

JEP 213 专注于 Project Coin 的改进。共有五个这样的改进,如下详细说明。

使用 @SafeVarargs 注解

在 Java 9 中,我们可以使用 @SafeVarargs 注解与私有实例方法。当我们使用此注解时,我们断言该方法不包含对作为方法参数传递的 varargs 执行任何有害操作。

使用语法为:

    @SafeVarargs // this is the annotation
    static void methodName(...) 
    {

      /*
      The contents of the method or constructor must not 
      perform any unsafe or potentially unsafe operations 
      on the varargs parameter or parameters.
      */

    }

@SafeVarargs 注解的使用限制为:

  • 静态方法

  • 最终实例方法

  • 私有实例方法

try-with-resource 语句

在 Java 9 之前,try-with-resource 语句在每次使用最终变量时,需要为语句中的每个资源声明一个新变量。以下是 Java 9(Java 7 或 8)之前 try-with-resource 语句的语法:

    try ( // open resources ) 
    {
      // use resources
    } catch (// error) 
    {  // handle exceptions
    }
    // automatically close resources

这里是一个使用上述语法的代码片段:

    try ( Scanner xmlScanner = new Scanner(new File(xmlFile));
    {
       while (xmlScanner.hasNext())
       {
          // read the xml document and perform needed operations
       }
      xmlScanner.close();
    } catch (FileNotFoundException fnfe)
      {
         System.out.println("Your XML file was not found.");
      }

现在,随着 Java 9 的推出,try-with-resource 语句可以管理最终变量,而无需声明新变量。因此,我们现在可以像下面这样重写之前的代码,这是在 Java 9 中的示例:

    Scanner xmlScanner = new Scanner(newFile(xmlFile));
    try ( while (xmlScanner.hasNext())
    {
       {
         // read the xml document and perform needed operations
       }
       xmlScanner.close();
    } catch (FileNotFoundException fnfe)
      {
         System.out.println("Your XML file was not found.");
      }

如您所见,xmlScanner 对象引用包含在 try-with-resource 语句块中,这提供了自动资源管理。资源将在退出 try-with-resource 语句块后自动关闭。

您还可以将finally块作为try-with-resource语句的一部分使用。

使用菱形运算符

在 Java 9 中引入的菱形运算符,如果推断的数据类型是可表示的,可以与匿名类一起使用。当推断数据类型时,它表明 Java 编译器可以确定方法调用中的数据类型。这包括声明和任何包含的参数。

菱形运算符是小于和大于符号对(<>)。它对 Java 9 来说并不新鲜;相反,它与匿名类的特定使用才是。

菱形运算符是在 Java 7 中引入的,它使泛型类的实例化变得更加简单。以下是一个 Java 7 之前的示例:

    ArrayList<Student> roster = new ArrayList<Student>();

然后,在 Java 7 中,我们可以这样重写它:

    ArrayList<Student> roster = new ArrayList<>();

问题在于这个方法不能用于匿名类。以下是一个 Java 8 中的示例,它运行良好:

    public interface Example<T> 
    {
      void aMethod()
      {
        // interface code goes here
      }
    }

    Example example = new Example<Integer>() 
    {
      @Override
      public void aMethod() 
      {
        // code
      }
    };

虽然前面的代码运行良好,但当我们将其更改为使用菱形运算符,如这里所示时,将发生编译器错误:

    public interface Example<T> 
    {
      void aMethod()
      {
        // interface code goes here
      }
    }

    Example example = new Example<>() 
    {
      @Override
      public void aMethod() 
      {
        // code
      }
    };

错误是由于使用菱形运算符与匿名内部类引起的。Java 9 拯救了这个问题。虽然前面的代码在 Java 8 中会导致编译时错误,但在 Java 9 中它运行良好。

停止使用下划线

下划线字符(_)不能再用作合法的标识符名称。之前尝试从标识符名称中删除下划线的尝试是不完整的。这样的使用会产生错误和警告的组合。在 Java 9 中,这些警告现在是错误。考虑以下示例代码:

    public class Java9Tests 
    { 
      public static void main(String[] args) 
      {
        int _ = 319;
        if ( _ > 300 )
        {
          System.out.println("Your value us greater than 300."); 
        } 
        else 
        {
          System.out.println("Your value is not greater than 300.");
        }
      }
    }

在 Java 8 中,前面的代码会导致int _ = 319;if (_ > 300)语句的编译器警告。警告是:从版本 9 开始,_是一个关键字,不能用作标识符。因此,在 Java 9 中,您将无法单独使用下划线作为合法的标识符。

被认为是不良的编程实践使用非自我描述的标识符名称。因此,将下划线字符单独用作标识符名称不应是一个有问题的变化。

使用私有接口方法

Lambda 表达式是 Java 8 发布的一个重要部分。作为对该改进的后续,接口中的私有方法现在变得可行。之前,我们无法在接口的非抽象方法之间共享数据。在 Java 9 中,这种数据共享成为可能。接口方法现在可以是私有的。让我们看看一些示例代码。

这个第一个代码片段是我们在 Java 8 中编写接口的方式:

    . . . 
    public interface characterTravel
    {
      pubic default void walk()
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        p = p +1;
      }
      public default void run()
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        p = p +4;
      }
      public default void fastWalk()
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        p = p +2;
      }
      public default void retreat()
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        p = p - 1;
      }
      public default void fastRetreat()
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        p = p - 4;
      }
    }

现在,在 Java 9 中,我们可以重写这段代码。正如您接下来可以看到的,冗余的代码已经被移动到一个名为characterTravel的单独私有方法中:

    . . . 
    public interface characterTravel
    {
      pubic default void walk()
      {
        characterTravel("walk");
      }
      public default void run()
      {
        characterTravel("run");
      }
      public default void fastWalk()
      {
        characterTravel("fastWalk");
      }
      public default void retreat()
      {
        characterTravel("retreat");
      }
      public default void fastRetreat()
      {
        characterTravel("fastRetreat");
      }
      private default void characterTravel(String pace)
      {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Enter desired pacing: ");
        int p = scanner.nextInt();
        if (pace.equals("walk"))
        {
          p = p +1;
        }
        else if (pace.equals("run"))
        {
          p = p + 4;
        }
        else if (pace.equals("fastWalk"))
        {
          p = p + 2;
        }
        else if (pace.equals("retreat"))
        {
          p = p - 1;
        }
        else if (pace.equals("fastRetreat"))
        {
          p = p - 4;
        }
        else
        {
          //
        }

正确处理导入语句 [JEP 216]

JEP 216 是为了修复 javac 在处理导入语句方面的缺陷而发布的。在 Java 9 之前,存在一些情况下导入语句的顺序会影响源代码是否被接受。

当我们在 Java 中开发应用程序时,我们通常会根据需要添加导入语句,从而产生一个无序的导入语句列表。IDEs 在对未使用的导入语句进行着色编码方面做得很好,同时也通知我们那些需要但尚未包含的导入语句。导入语句的顺序无关紧要;没有适用的层次结构。

javac 以两个主要步骤编译类。针对处理导入语句,这些步骤是类型解析和成员解析。类型解析包括对抽象语法树的审查,以识别类和接口的声明。成员解析包括确定类层次结构和单个类变量和成员。

在 Java 9 中,我们在类和文件中列出导入语句的顺序将不再影响编译过程。让我们来看一个例子:

    package samplePackage;

    import static SamplePackage.OuterPackage.Nested.*;
    import SamplePackage.Thing.*;

    public class OuterPackage 
    {
      public static class Nested implements Inner 
      { 
        // code
      }
    }

    package SamplePackage.Thing;

    public interface Inner 
    {
      // code
    }

在前面的例子中,类型解析发生并导致以下认识:

  • SamplePackage.OuterPackage 存在

  • SamplePackage.OuterPackage.Nested 存在

  • SamplePackage.Thing.Innner 存在

下一步是成员解析,这正是 Java 9 之前存在的问题所在。以下是 javac 为我们的示例代码执行成员解析的顺序步骤概述:

  1. SamplePackage.OuterPackage 的解析开始。

  2. 处理 SamplePackage.OuterPackage.Nested 的导入。

  3. SamplePackage.Outer.Nested 类的解析开始。

  4. 内部接口进行了类型检查,尽管由于它目前不在作用域内,内部无法解析。

  5. SamplePackage.Thing 的解析开始。这一步骤包括将 SamplePackage.Thing 的所有成员类型导入作用域。

因此,在我们的例子中,错误发生是因为在尝试解析时 Inner 超出了作用域。如果步骤 4 和 5 互换,就不会有问题。

Java 9 中实现的问题解决方案是将成员解析步骤分解为额外的子步骤。以下是这些步骤:

  1. 分析导入语句。

  2. 创建层次结构(类和接口)。

  3. 分析类头和类型参数。

摘要

在本章中,我们介绍了 Java 9 中关于变量处理器及其与 Atomic Toolkit 相关性的变化。我们还介绍了弃用警告及其为何在特定情况下被抑制的原因。还回顾了 Java 7 作为 Project Coin 部分引入的五个变化增强。最后,我们探讨了导入语句处理的改进。

在下一章中,我们将检查由 Project Jigsaw 指定的 Java 模块结构。我们将深入探讨 Project Jigsaw 作为 Java 平台一部分的实现方式。本章中使用了来自一个示例电子商务应用程序的代码片段,以展示 Java 9 的模块化系统。还讨论了 Java 平台在模块化系统方面的内部变化。

第五章:使用 Java 9 构建模块化应用程序

在上一章中,我们讨论了 Java 9 中变量处理程序的变化以及它们与 AtoMiC 工具包的关系。我们还讨论了弃用警告以及为什么在特定情况下现在被抑制。作为Project Coin的一部分,对 Java 7 引入的变化的五个增强功能也得到了回顾。最后,我们探讨了导入语句处理的改进。

在本章中,我们将根据Project Jigsaw的规定来检查 Java 模块的结构。我们将深入探讨Project Jigsaw是如何作为 Java 平台的一部分来实现的。我们还将回顾与模块化系统相关的 Java 平台的关键内部变化。

我们在这里将要讨论的主题包括:

  • Java 模块化简介

  • Java 平台模块系统的回顾

  • 模块化 JDK 源代码

  • 模块化运行时镜像

  • 了解模块化系统

  • 模块化 Java 应用程序打包

  • Java 链接器

  • 内部 API 的封装

模块化入门

在我们深入探讨本章中 Java 9 的增强功能之前,让我们先来了解一下在 Java 语境下什么是模块化。

我们可以将“模块化”这个术语定义为一种设计或构建类型,在我们的语境中,是指计算机软件。这种软件设计涉及一组模块,这些模块共同构成了整体。例如,一栋房子可以是作为一个单一结构建造,或者以模块化方式建造,其中每个房间都是独立建造并连接起来形成一个家。通过这个类比,你可以在建造你的家时选择性地添加或不添加模块。在我们的类比中,模块的集合就成为了你家的设计。你的设计不需要使用每个模块,只需要你想要的那些。所以,例如,如果有地下室和奖励室模块,而你的设计不包括这些模块化房间,那么这些模块就不会用来建造你的家。另一种选择是每个家都包括每个房间,而不仅仅是那些被使用的房间。这当然是浪费的。让我们看看这如何与软件相关联。

这个概念可以应用于计算机架构和软件系统。我们的系统可以由几个组件组成,而不是一个庞大的系统。正如你可能想象的那样,这为我们提供了一些特定的好处:

  • 我们应该能够扩展我们的 Java 应用程序以在小型设备上运行

  • 我们的 Java 应用程序将更小

  • 我们的模块化代码可以更加有针对性

  • 对面向对象编程模型的更多使用

  • 增加的封装机会

  • 我们的代码将更加高效

  • Java 应用程序的性能将提高

  • 整体系统复杂性降低

  • 测试和调试更加容易

  • 代码维护更加容易

Java 转向模块化系统是出于几个原因。以下是 Java 9 平台创建模块化系统的 Java 平台的主要条件:

  • Java 开发工具包JDK)太大,难以支持小型设备。即使在下一节中讨论的紧凑配置文件中,支持某些小型设备也困难重重,在某些情况下甚至不可能。

  • 由于 JDK 过大,难以使用我们的 Java 应用程序实现真正优化的性能。在这种情况下,越小越好。

  • Java 运行时环境JRE)太大,无法有效地测试和维护我们的 Java 应用程序。这导致耗时、低效的测试和维护操作。

  • Java 归档JAR)文件也太大。这使得支持小型设备变得有困难。

  • 由于 JDK 和 JRE 都是包罗万象的,因此安全性是一个很大的担忧。例如,内部 API,由于公共访问修饰符的性质,即使 Java 应用程序没有使用,仍然可用。

  • 最后,我们的 Java 应用程序是不必要的庞大。

模块化系统有以下要求:

  • 必须有一个公共接口,以允许所有连接的模块之间实现互操作性

  • 必须支持隔离和连接测试

  • 编译时操作必须能够识别正在使用的模块

  • 模块运行时支持

模块是 Java 9 中的一个新概念和组件;它是一组命名的数据和代码集合。具体来说,模块是一组:

  • 接口

  • 代码

  • 数据

  • 资源

成功实施的关键在于,Java 9 中的模块在其模块声明中自我描述。模块名称必须是唯一的,通常使用反向域名模式。以下是一个示例声明:

    module com.three19.irisScan { }

模块声明包含在一个module-info.java文件中,该文件应位于模块的根目录中。正如预期的那样,此文件被编译成一个module-info.class文件,并将放置在适当的输出目录中。这些输出目录是在模块源代码中建立的。

在下一节中,我们将查看 Java 9 在模块化方面的具体更改。

检查 Java 的平台模块系统 [JEP-200]

JEP-200 的核心目标是使用Java 平台模块系统JPMS)对Java 开发工具包JDK)进行模块化。在 Java 9 之前,我们对 JDK 的了解包括对其主要组件的认识:

  • Java 运行时环境(JRE)

  • 解释器(java)

  • 编译器(javac)

  • 归档器(jar)

  • 文档生成器(javadoc)

模块化 JDK 的任务是将它分解成可以在编译时或运行时组合的组件。模块结构基于以下模块配置文件,这些配置文件在 Java 8 中被作为紧凑配置文件建立。以下表格详细说明了这三个配置文件中的每一个:

紧凑配置文件 1

java.io java.lang.annotation java.lang.invoke
java.lang.ref java.lang.reflect java.math
java.net java.nio java.nio.channels
java.nio.channels.spi java.nio.charset java.nio.charset.spi
java.nio.file java.nio.file.attribute java.nio.file.spi
java.security java.security.cert java.security.interfaces
java.security.spec java.text java.text.spi
java.time java.time.chrono java.time.format
java.time.temporal java.time.zone java.util
java.util.concurrent java.util.concurrent.atomic java.util.concurrent.locks
java.util.function java.util.jar java.util.logging
java.util.regex java.util.spi java.util.stream
java.util.zip javax.crypto javax.crypto.interfaces
javax.crypto.spec javax.net javax.net.ssl
javax.script javax.security.auth javax.security.auth.callback
javax.security.auth.login javax.security.auth.spi javax.security.auth.spi
javax.security.auth.x500 javax.security.cert

紧凑配置文件 2:

java.rmi java.rmi.activation java.rmi.dgc
java.rmi.registry java.rmi.server java.sql
javax.rmi.ssl javax.sql javax.transaction
javax.transaction.xa javax.xml javax.xml.database
javax.xml.namespace javax.xml.parsers javax.xml.stream
javax.xml.stream.events javax.xml.stream.util javax.xml.transform
javax.xml.transform.dom javax.xml.transform.sax javax.xml.transform.stax
javax.xml.transform.stream javax.xml.validation javax.xml.xpath
org.w3c.dom org.w3c.dom.bootstrap org.w3c.dom.events
org.w3c.dom.ls org.xml.sax org.xml.sax.ext
org.xml.sax.helpers

紧凑配置文件 3:

java.lang.instrument java.lang.management java.security.acl
java.util.prefs javax.annotation.processing javax.lang.model
javax.lang.model.element javax.lang.model.type javax.lang.model.util
javax.management javax.management.loading javax.management.modelmbean
javax.management.monitor javax.management.openmbean javax.management.relation
javax.management.remote javax.management.remote.rmi javax.management.timer
javax.naming javax.naming.directory javax.naming.event
javax.naming.ldap javax.naming.spi javax.security.auth.kerberos
javax.security.sasl javax.sql.rowset javax.sql.rowset.serial
javax.sql.rowest.spi javax.tools javax.xml.crypto
javax.xml.crypto.dom javax.xml.crypto.dsig javax.xml.crypto.dsig.dom
javax.xml.crypto.dsig.keyinfo javax.xml.crypto.dsig.spec org.ieft.jgss

这三个紧凑模块配置文件代表了 Java 9 中标准化模块系统的基石。这种标准化的有效性依赖于以下六个原则:

  • 所有由 JCP 管理的模块都必须以字符串java.开头。因此,如果一个空间实用工具模块正在开发中,它将有一个类似于java.spatial.util的名称。

JCP 指的是 Java Community Process。JCP 允许开发者为 Java 创建技术规范。您可以在官方 JCP 网站上了解更多关于 JCP 的信息并成为会员--www.jcp.org

  • 非 JCP 模块被视为 JDK 的一部分,并且它们的名称必须以字符串 jdk. 开头。

  • 确保方法调用链正确工作。以下流程图可以最好地说明这一点:

如前述流程图所示,它仅适用于导出包的模块。

  • 第四个原则涉及在标准模块中使用标准和非标准 API 包。以下流程图说明了该原则的约定实施:

  • 第五个设计原则是标准模块可以依赖于多个非标准模块。虽然这种依赖是被允许的,但对非标准模块的隐式可读性访问是不允许的。

  • 最终的设计原则确保非标准模块不导出标准 API 包。

模块化 JDK 源代码 [JEP-201]

如前所述,Project Jigsaw 的目标是模块化。预期的标准模块化系统将应用于 Java SE 平台和 JDK。除了效率提升外,模块化转变还将带来更好的安全性和易于维护性。JEP-201 中详细说明的增强主要集中在 JDK 源代码重组上。让我们更详细地看看。

重组 JDK 的源代码是一项重大任务,并以下列子目标完成:

  • 为 JDK 开发者提供对新的 Java 9 模块化系统的见解和熟悉度。因此,这个目标针对的是 JDK 开发者,而不是主流开发者。

  • 确保在整个 JDK 构建过程中建立并维护模块边界。这是一项必要的预防措施,以确保模块化系统在 Java 9 的增强过程中以及更具体地说,在实现模块化系统时保持稳定。

  • 第三个目标是确保未来的增强,特别是与 Project Jigsaw 相关的增强,可以轻松地集成到新的模块化系统中。

这次源代码重组的重要性不容小觑。Java 9 之前的源代码组织已有 20 年历史。这次久拖不决的 JDK 源代码重组将使代码的维护变得更加容易。让我们先看看 JDK 源代码的先前组织,然后再检查其中的变化。

Java 9 之前 JDK 源代码组织

JDK 是代码文件、工具、库等代码的集合。以下插图提供了 JDK 组件的概述:

前述图示中 JDK 组件的 Java 9 之前组织将在接下来的七个子节中详细说明。

开发工具

开发工具位于bin目录中。这些工具包括七个广泛的分类,每个分类在后续章节中详细说明。

部署

这是一套旨在帮助部署 Java 应用程序的工具:

  • appletviewer:此工具允许您在没有网络浏览器的情况下运行和调试 Java 小程序。

  • extcheck:此工具允许您在 JAR 文件中找到冲突。

  • jar:此工具用于创建和操作 JAR 文件。JAR 文件是 Java 归档文件。

  • java:这是 Java 应用程序启动器。

  • javac:这是 Java 编译器。

  • javadoc:此工具生成 API 文档。

  • javah:此工具允许您编写本地方法;它生成 C 头文件。

  • javap:此工具用于反汇编类文件。

  • javapackager:用于签名和打包 Java 应用程序,包括 JavaFX。

  • jdb:这是 Java 调试器。

  • jdeps:这是一个用于分析 Java 类依赖关系的分析器。

  • pack200:这是一个将 JAR 文件压缩成pack200文件的工具。使用此工具的压缩率令人印象深刻。

  • unpack200:此工具解包pack200文件,生成 JAR 文件。

国际化

如果您对创建本地化应用程序感兴趣,以下工具可能很有用:

  • native2ascii:此工具将普通文本转换为 Unicode Latin-1。

监控

用于提供 JVM 性能数据的监控工具包括:

  • jps:这是JVM 进程状态工具jps)。它提供特定系统上 HotSpot JVM 的列表。

  • jstat:这是 JVM 统计监控工具。它从具有 HotSpot JVM 的机器收集日志数据和性能信息。

  • jstatd:这是jstat守护进程工具。它运行一个 RMI 服务器应用程序以监控 HotSpot JVM 操作。

RMI

RMI工具是远程方法调用工具。它们帮助开发者创建在网络(包括互联网)上运行的应用程序:

  • rmic:此工具可以为网络上的对象生成存根和骨架。

  • rmiregistry:这是一个远程对象的注册服务

  • rmid:此工具是 RMI 的激活系统守护进程。

  • serialver:此工具返回类的serialVersionUID值。

安全

这套安全工具使开发者能够创建可以在开发者的计算机系统以及远程系统上强制执行的安全策略:

  • keytool:此工具管理安全证书和密钥库

  • jarsigner:此工具用于生成和验证 JAR 签名,以创建/打开 JAR 文件

  • policytool:此工具具有图形用户界面,帮助开发者管理他们的安全策略文件

故障排除

这些实验性故障排除工具对于非常具体的故障排除很有用。它们是实验性的,因此不受官方支持:

  • jinfo:此工具为特定进程、文件或服务器提供配置信息。

  • jhat:这是一个堆转储工具。它实例化一个 Web 服务器,以便可以使用浏览器查看堆。

  • jmap:这个工具从进程、文件或服务器显示堆和共享对象内存映射。

  • jsadebugd:这是 Java 的服务性代理调试守护进程。它作为进程或文件的调试服务器。

  • jstack:这是一个 Java 堆栈跟踪工具,它为进程、文件或服务器提供线程堆栈跟踪。

互联网服务

这套工具提供了一组可以与Java Web Start和其他网络服务一起使用的实用工具:

  • javaws:这是一个命令行工具,用于启动 Java Web Start。

  • schemagen:这个工具用于生成 Java 架构的架构。这些架构用于 XML 绑定。

  • wsgen:这个工具用于生成可移植的 JAX-WS 组件。

  • wsimport:这个工具用于导入可移植的 JAX-WS 组件。

  • xjc:这是用于 XML 绑定的绑定编译器。

JavaFX 工具

JavaFX 工具位于几个不同的位置,包括binmanlib目录。

Java 运行时环境

Java 运行时环境JRE)位于jre目录中。关键内容包括Java 虚拟机JVM)和类库。

源代码

JDK 的源代码,在 Java 9 之前,具有以下基本组织结构:

    source code / [shared, OS-specific] / [classes / native] / Java API
     package name / [.file extension]

让我们更详细地看看这个。在源代码之后,我们有两种选择。如果代码是跨平台的,那么它是一个共享目录;否则,它是特定于操作系统的。例如:

    src/share/...
    src/windows/...

接下来,我们有类目录或本地语言目录。例如:

    src/share/classes/...
    src/share/classes/java/...

接下来,我们有 Java API 包的名称,后面跟着文件扩展名。文件扩展名取决于内容,如.java.c等。

lib目录包含了bin目录中一个或多个开发工具所需的类库。以下是一个典型的 Java 8 lib目录中的文件列表:

图片

检查目录列表并不能提供很高的粒度洞察力。我们可以使用以下命令列出任何.jar文件中的类--jar tvf fileName.jar。以下是一个示例,展示了在命令行中执行jar tvf javafx-mx.jar时生成的类列表:

图片

C 头文件

/include目录包含 C 头文件。这些文件主要支持以下内容:

  • Java 本地接口JNI):这是用于本地代码编程支持的。JNI 用于将 Java 本地方法和 JVM 嵌入到本地应用程序中。

  • JVM 工具接口JVM TI):这是由工具用于对运行 JVM 的应用进行状态检查和执行控制的。

数据库

Apache Derby 关系数据库存储在/db目录中。您可以在以下网站了解更多关于 Java DB 的信息:

docs.oracle.com/javadb/support/overview.html

db.apache.org/derby/manuals/#docs_10.11

JDK 源代码重新组织

在前面的章节中,你了解到 Java 9 之前的源代码组织架构如下:

    source code / [shared, OS-specific] / [classes / native] / Java API 
     package name / [.file extension]

在 Java 9 中,我们有一个新的模块化架构。该架构如下:

    source code / module / [shared, OS-specific] / [classes / native / 
    configuration] / [ package / include / library ] /
     [.file extension]

新架构中有一点不同,最值得注意的是模块名称。在共享或操作系统特定的目录之后,要么是类目录,要么是 C 或 C++源文件的本地目录,或者是一个配置目录。这种看似基本的组织架构的改变导致代码库的可维护性大大提高。

理解模块化运行时图像 [JEP-220]

Java 9 的模块化系统需要对运行时图像进行更改以实现兼容性。这些更改的好处包括以下领域的增强:

  • 可维护性

  • 性能

  • 安全性

这些变化的核心是一个用于资源命名的新的 URI 架构。这些资源包括模块和类。

统一资源标识符URI)与统一资源定位符URL)类似,因为它标识了某物的名称和位置。对于 URL,那“某物”是一个网页;对于 URI,它是一个资源。

JEP-220 有五个主要目标,这些目标在以下章节中详细说明。

运行时格式采用

为 Java 9 创建了一种运行时格式,用于存储类和其他资源文件。此格式适用于以下情况下的存储类和其他资源:

  • 当新的运行时格式比 Java 9 之前的 JAR 格式具有更高的效率(时间和空间)时。

JAR文件是一个Java 归档文件。这是一种基于传统 ZIP 格式的压缩文件格式。

  • 当存储类和其他资源可以单独隔离和加载时。

  • 当 JDK 和库类以及资源可以存储时。这包括应用程序模块。

  • 当它们以促进未来增强的方式设计时。这要求它们是可扩展的、有文档的并且灵活的。

运行时图像重构

Java 中有两种运行时图像类型--JDK 和 JRE。随着 Java 9 的推出,这两种图像类型都被重构,以区分用户可以使用和修改的文件与开发者及其应用程序可以使用的但不能修改的内部文件。

JDK 构建系统在 Java 9 之前产生了一个 JRE 和一个 JDK。JRE 是 Java 平台的完整实现。JDK 包括 JRE 以及其他工具和库。Java 9 的一个显著变化是 JRE 子目录不再是 JDK 图像的一部分。这一变化部分是为了确保这两种图像类型(JDK 和 JRE)具有相同的图像结构。有了共同和重新组织过的结构,未来的更改将更加高效地集成。

如果你之前在 Java 9 之前创建了针对特定结构的自定义插件,那么你的应用程序可能在 Java 9 中无法工作。如果你明确地引用tools.jar,这也同样适用。

以下图表提供了 Java 9 发布之前每个图像内容的概览:

下图展示了 Java 9 运行时图像。如图所示,完整的 JDK 图像包含与模块化运行时图像相同的目录,以及 demo、sample、man 和 includes 目录:

JRE 或 JDK 图像之间不再有区别。现在,使用 Java 9,JDK 图像是包含完整开发工具的 JRE 图像。

支持常见操作

开发者有时必须编写执行需要访问运行时图像的操作的代码。Java 9 包括对这些常见操作的支持。这是由于 JDK 和 JRE 运行时图像结构的重构和标准化而成为可能的。

撤销 JDK 类的权限

Java 9 允许撤销单个 JDK 类的权限。这一变化加强了系统安全性,因为它确保 JDK 类只接收系统操作所需的权限。

保留现有行为

JEP-220 的最终目标是确保现有类不受负面影响。这指的是没有依赖内部 JDK 或 JRE 运行时图像的应用程序。

了解模块系统 [JEP-261]

本 JEP 的目的是在 Java 平台上实现新的模块系统。您会记得,模块化系统是为了为 Java 程序提供可靠的配置和强大的封装而创建的。这一实现的关键是链接时间概念。如图所示,链接时间是编译时间和运行时间之间可选的阶段。这个阶段允许将适当的模块组装成优化的运行时图像。这在一定程度上是由于 jlink 链接工具,您将在本章后面了解更多关于它的信息:

模块路径

组织模块以便它们可以轻松找到是很重要的。模块路径,即一系列模块组件或目录,提供了搜索使用的组织结构。这些路径组件按顺序搜索,返回第一个包含模块的路径组件。

模块及其路径不应被视为与包或类路径相同。它们确实是不同的,并且具有更高的精确度。关键区别在于,在类路径中,搜索的是单个组件。模块路径搜索返回完整的模块。这种类型的搜索可以通过以下路径进行,按照提供的顺序搜索,直到返回一个模块:

  • 编译模块路径

  • 升级模块路径

  • 系统模块

  • 应用程序模块路径

让我们简要回顾一下这些路径。编译模块路径仅在编译时适用,包含模块定义。升级模块路径包含编译后的模块定义。系统模块是内置的,包括 Java SE 和 JDK 模块。最后一个路径,应用程序模块路径,包含来自应用程序模块以及库模块的编译后的模块定义。

访问控制边界违规

作为一名专业开发者,你总是希望你的代码是安全的、可移植的,并且没有错误,这需要严格遵守 Java 构造,如封装。在某些情况下,例如白盒测试,你需要打破 JVM 规定的封装。这个规定允许跨模块访问。

为了允许打破封装,你可以在你的模块声明中添加一个add-exports选项。以下是你会使用的语法:

    module com.three19.irisScan 
    {
      - - add-exports <source-module>/<package> = <target-module> 
      (, <target-module> )*
    }

让我们更仔细地看看前面的语法。《》和<target-module>是模块名称,<package>是包的名称。使用add-exports选项允许我们违反访问控制边界。

关于使用add-exports选项有两个规则:

  • 它可以在模块中使用多次

  • 每次使用都必须是<source-module><target-module>的唯一配对

不建议除非绝对必要,否则使用add-exports选项。它的使用允许对库模块内部 API 的危险访问。这种使用使得你的代码依赖于内部 API 不会改变,而这超出了你的控制。

运行时

HotSpot 虚拟机实现了jmodjlink命令行工具的<options>。以下是jmod命令行工具的<options>列表:

图片

以下是jlink命令行工具的<options>列表:

图片

模块化 Java 应用程序打包 [JEP-275]

Java 9 的一个重大改进是Java Packager生成的运行时二进制文件的大小。这在一定程度上是由于Java Linker,它将在下一节中介绍。Java Packager 的工作流程在 Java 9 中基本上与 Java 8 相同。正如你将在本节后面看到的那样,工作流程中添加了新的工具。

Java Packager 仅创建 JDK 9 应用程序。这种对 Java Packager 的改变旨在简化并使生成运行时映像的过程更高效。因此,Java Packager 将只为它关联的 SDK 版本创建运行时映像。

高级查看 Java Linker

在 Java Linker 工具jlink在 Java 9 中引入之前,运行时图像的创建包括复制整个 JRE。然后,删除未使用的组件。简单来说,jlink简化了仅包含所需模块的运行时图像的创建。jlink被 Java Packager 用于生成嵌入式运行时图像。

Java Packager 选项

Java Packager 的语法如下:

 javapackager -command [-options]

有五种不同的命令(-command)可以使用。它们如下所述:

command 描述
-createbss 此命令用于将文件从 CSS 转换为二进制
-createjar 此命令,与附加参数一起使用,创建一个 JAR 归档文件
-deploy 此命令用于生成 jnlp 和 HTML 文件
-makeall 结合-createjar-deploy和编译步骤
-signJar 此命令创建并签名一个 JAR 文件

-createbss命令的[-options]包括:

-createjar命令的[-options]包括:

-deploy命令的[-options]包括:

这里是-deploy命令剩余的[-options]

-makeall命令的[-options]包括:

-signJar[-options]包括:

Java Packager 分为两个模块:

    jdk.packager
    jdk.packager.services

JLink - Java 链接 [JEP-282]

Java 链接器,通常称为 JLink,是一个创建自定义运行时图像的工具。此工具收集适当的模块及其依赖项,然后对它们进行优化以创建图像。这代表了 Java 的一个重大变化,随着 Java 9 的发布。在 Java Linker 工具jlink可用之前,运行时图像的创建最初包括复制整个 JRE。在后续步骤中,删除了未使用的组件。在 Java 9 中,jlink仅使用所需模块创建运行时图像。jlink被 Java Packager 用于生成嵌入式运行时图像。

如前文所述,JEP-282 导致链接时间成为编译时间和运行时间之间可选的阶段。在这个阶段,适当的模块被组装成一个优化的运行时图像。

JLink 是一个命令行链接工具,允许创建包含 JDK 模块较小子集的运行时图像。这导致运行时图像更小。以下语法包括四个组件--jlink命令、选项、模块路径和输出路径:

$ jlink <options> ---module-path <modulepath> --output <path>

这里是jlink工具可以使用的选项列表,以及每个选项的简要描述:

模块路径告诉链接器在哪里找到模块。链接器将不会使用展开的模块或 JAR/JMOD 文件。

输出路径简单地告知链接器在哪里保存自定义运行时镜像。

封装大多数内部 API [JEP-260]

JEP-260 被实现是为了使 Java 平台更加安全。这个 JEP 的核心目标是封装大多数内部 API。具体来说,JDK 的大多数内部 API 默认情况下不再可访问。目前,被认为是关键广泛使用的内部 API 仍然可访问。在未来,我们可能会看到替代它们的功能,到那时,这些内部 API 将默认不可访问。

那么,为什么这个变化是必要的呢?有几个广泛使用的 API 是不稳定的,在某些情况下,还没有标准化。不受支持的 API 不应有权访问 JDK 的内部细节。因此,JEP-260 导致了 Java 平台安全性的提高。一般来说,你应在你的开发项目中避免使用不受支持的 API。

上述关键的 API(JDK 内部的)包括:

  • sun.misc

  • sun.misc.Unsafe

  • sun.reflect.Reflection

  • sun.reflect.ReflectionFactory.newConstrutorForSerialization

上述关键的内部 API 在 JDK 9 中仍然可访问。它们可以通过jdk.unsupported JDK 模块来访问。完整的 JRE 和 JDK 镜像将包含jdk.unsupported模块。

你可以使用 Java 依赖分析工具jdeps来帮助确定你的 Java 程序是否有任何对 JDK 内部 API 的依赖。

这是一个值得关注的有趣变化。很可能,当 Java 10 发布时,目前可访问的内部 API 将默认不可访问。

摘要

在本章中,我们检查了由Project Jigsaw指定的 Java 模块的结构,并深入研究了Project Jigsaw是如何实现以改进 Java 平台的。我们还回顾了与模块化系统相关的 Java 平台的关键内部更改。我们的回顾从模块入门开始,我们学习了 Java 9 的模块化系统在优势和需求方面的内容。

我们探讨了 Java 9 如何将模块化引入 JDK,包括其源代码和组织结构。我们还探讨了构成 JDK 的七个主要工具类别。正如我们所了解的,Java 9 的模块化也扩展到运行时镜像,从而提高了可维护性、性能和安全性。我们引入了链接时间的概念,作为编译时间和运行时间之间可选的阶段。我们在本章的结尾回顾了 Java 链接器和 Java 9 如何封装内部 API。

在下一章中,我们将探讨如何将我们的现有应用程序迁移到 Java 9 平台。我们将查看手动和半自动迁移过程。

第六章:将应用程序迁移到 Java 9

在上一章中,我们详细研究了 Project Jigsaw 规定的 Java 模块结构,并检查了 Project Jigsaw 如何实施以改进 Java 平台。我们还回顾了 Java 平台的关键内部更改,特别关注新的模块化系统。我们从模块入门开始,了解了 Java 9 的模块化系统在优势和需求方面的内容。接下来,我们探讨了 Java 9 如何将模块化引入 JDK。这包括查看 Java 9 的源代码是如何重新组织的。我们还探讨了 JDK 的七个主要工具类别,并了解到 Java 9 的模块化扩展到运行时镜像,从而提高了可维护性、性能和安全性。链接时间的概念被引入为编译时间和运行时间之间可选的阶段。我们以查看Java 链接器和 Java 9 如何封装内部 API 来结束本章。

在本章中,我们将探讨如何将现有的应用程序迁移到 Java 9 平台。我们将查看手动和半自动化的迁移过程。Java 9 是一个重大版本,对 JDK 进行了众多更改,因此如果开发者的 Java 8 代码不再与 Java 9 兼容,他们不应该感到惊讶。本章旨在为您提供深入了解和过程,以便使您的 Java 8 代码能够在 Java 9 上运行。

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

  • Project Jigsaw 快速回顾

  • 模块如何在 Java 生态系统中定位

  • 迁移规划

  • 来自 Oracle 的建议

  • 有用的工具

Project Jigsaw 快速回顾

Project Jigsaw 是一个包含对 Java 平台多个更改建议的 Java 项目。正如您在前面章节中读到的,Java 9 的最大变化涉及模块和模块化。将 Java 移向模块的倡议是由 Project Jigsaw 驱动的。模块化的需求源于 Java 的两个主要挑战:

  • 类路径

  • JDK

接下来,我们将回顾这两个挑战,并看看它们是如何在 Java 平台的新版本 Java 9 中得到解决和克服的。

类路径

在 Java 9 之前,类路径存在问题,是开发者痛苦的来源。这在众多开发者论坛中很明显,幸运的是,Oracle 在关注。以下是一些类路径可能成为问题的实例;以下是两个主要案例:

  • 第一个案例涉及在您的开发计算机上拥有两个或更多版本的库。以前,Java 系统处理这种方式的办法不一致。在类加载过程中使用哪个库是任何人都可以猜测的。这导致了一个不希望出现的缺乏具体性——关于加载了哪个库的细节不足。

  • 第二个案例是在使用类加载器的最先进功能。这种类型的类加载器使用往往会导致最多的错误和漏洞。这些问题并不总是容易检测到,并给开发者带来了大量的额外工作。

在 Java 9 之前,类路径几乎总是非常长。在最近的一次演讲中,Oracle 分享了一个包含 110 个 JAR 文件的类路径。这种难以管理的类路径使得检测冲突或确定是否缺少某些内容变得困难,如果缺少,可能缺少什么。将 Java 平台重新构想为模块化系统使得这些类路径问题成为过去式。

模块通过提供可靠的配置解决了 Java 9 之前的类路径问题。

JDK 的单体特性

自 1995 年以来,Java 一直在令人印象深刻地不断发展,并且随着每一次的进化步骤,JDK 变得越来越大。与 Java 8 一样,JDK 已经变得过于庞大。在 Java 9 之前,由于 JDK 的单体特性,存在几个问题,包括:

  • 由于 JDK 非常大,它不适合非常小的设备。在一些开发领域,这足以成为寻找非 Java 解决方案来解决软件工程问题的理由。

  • 过大的 JDK 导致了浪费。在设备、网络和云上运行时,在处理和内存方面都造成了浪费。这源于整个 JDK 被加载,即使只需要 JDK 的一小部分。

  • 虽然 Java 平台在运行时具有出色的性能,但在加载和启动时间方面,启动性能还有很大的提升空间。

  • 数量庞大的内部 API 也是一个痛点。由于存在许多内部 API 并被开发者使用,系统难以进化。

  • 内部 API 的存在使得使 JDK 安全且可扩展变得困难。由于存在许多内部依赖,隔离安全和可扩展性问题变得过于复杂。

解决 JDK 单体问题的答案是模块。Java 9 引入了模块及其自身的模块化系统。平台的一个重大更新是,只需编译所需的模块,而不是整个 JDK。这个模块化系统在本书中进行了全面介绍。

模块通过提供强封装解决了 Java 9 之前的 JDK 单体问题。

模块如何融入 Java 生态

如您从以下插图中所见,包由类和接口组成,模块由包组成。模块是包的容器。这是 Java 9 新模块化系统的一个非常基本的原理,从非常高的层面来看。重要的是将模块视为模块化系统的一部分,而不仅仅是包之上的一个新抽象级别,如图表可能暗示的那样。

图片

因此,模块是 Java 9 的新特性,并且正如你所预期的,在使用之前需要声明。模块的声明包括它所依赖的其他模块的名称。它还导出其他模块依赖的包。模块声明可能是你开始使用 Java 9 进行开发时需要解决的最重要模块问题之一。以下是一个示例:

    module com.three19.irisScan 
    {
      // modules that com.three19.irisScan depends upon
      requires com.three19.irisCore;
      requires com.three19.irisData;

      // export packages for other modules that are dependent
         upon com.three19.irisScan
      exports com.three19.irisScan.biometric;
    }

当编程一个 Java 9 应用程序时,你的模块声明将被放置在一个module-info.java文件中。一旦这个文件完成,你只需运行javac,Java 编译器,来生成module-info.class Java 类文件。你将以与当前将.java文件编译成.class文件相同的方式完成这项任务。

你还可以创建具有根目录中的module-info.class文件的模块化 JAR 文件。这代表了一个很高的灵活性。

基础模块

当编程 Java 9 应用程序或移植使用较旧版本的 Java 编写的现有应用程序时,必须使用基本模块(java.base)。每个模块都需要java.base模块,因为它定义了关键的或基础性的 Java 平台 API。以下是java.base模块的内容:

    module java.base 
    {
      exports java.io;
      exports java.lang;
      exports java.lang.annotation;
      exports java.lang.invoke;
      exports java.lang.module;
      exports java.lang.ref;
      exports java.lang.reflect;
      exports java.math;
      exports java.net;
      exports java.net.spi;
      exports java.nio;
      exports java.nio.channels;
      exports java.nio.channels.spi;
      exports java.nio.charset;
      exports java.nio.charset.spi;
      exports java.nio.file;
      exports java.nio.file.attribute;
      exports java.nio.file.spi;
      exports java.security;
      exports java.security.aci;
      exports java.security.cert;
      exports java.security.interfaces;
      exports java.security.spec;
      exports java.text;
      exports java.text.spi;
      exports java.time;
      exports java.time.chrono;
      exports java.time.format;
      exports java.time.temporal;
      exports java.time.zone;
      exports java.util;
      exports java.util.concurrent;
      exports java.util.concurrent.atomic;
      exports java.util.concurrent.locks;
      exports java.util.function;
      exports java.util.jar;
      exports java.util.regex;
      exports java.util.spi;
      exports java.util.stream;
      exports java.util.zip;
      exports java.crypto;
      exports java.crypto.interfaces;
      exports java.crytpo.spec;
      exports java.net;
      exports java.net,ssi;
      exports java.security.auth;
      exports java.security.auth.callbak;
      exports java.security.auth.login;
      exports java.security.auth.spi;
      exports java.security.auth.x500;
      exports java.security.cert;
    }

如你所见,java.base模块不需要任何模块,并导出许多包。在开始使用新的 Java 平台,Java 9 创建应用程序时,有一个这些导出包的列表会很有用。

你会注意到,在前一节中,我们没有在我们的com.three19.irisScan模块声明中包含requires java.base;代码行。更新后的代码如下,并现在包含了requires java.base;代码行:

    module com.three19.irisScan 
    {
      // modules that com.three19.irisScan depends upon
      requires java.base; // optional inclusion 
      requires com.three19.irisCore;
      requires com.three19.irisData;

      // export packages for other modules that are dependent
         upon com.three19.irisScan
      exports com.three19.irisScan.biometric;
    }

如果你没有在你的模块声明中包含requires java.base;代码行,Java 编译器将自动包含它。

可靠的配置

如本章前面所建议的,模块为我们提供了 Java 9 应用程序的可靠配置,解决了 Java 平台早期版本中的类路径问题。

Java 读取并解释模块声明,使得模块可读。这些可读的模块允许 Java 平台确定是否有任何模块缺失,是否有重复声明的库,或者存在其他冲突。在 Java 9 中,编译器或运行时会生成并输出非常具体的错误信息。以下是一个编译时错误的示例:

src/com.three19.irisScan/module-info.java: error: module not found: com.three19.irisScan 
requires com.three19.irisCore;
 ^
1 error

以下是一个示例,如果找不到模块com.three19.isrisCore但该模块被com.three19.irisScan应用程序所需要,将会发生运行时错误:

Error occurred during initialization of VM
java.lang.module.ResolutionException: Module com.three19.irisCore not found, required by com.three19.irisScan app

强封装

在本章早期,您了解到 Java 9 的强封装解决了 JDK 的单一问题。在 Java 9 中,封装是由 module-info.java 文件中的信息驱动的。该文件中的信息让 Java 知道哪些模块依赖于其他模块,以及每个模块导出了什么。这强调了确保我们的 module-info-java 文件正确配置的重要性。让我们看看一个用标准 Java 代码编写的示例,这种方式在 Java 9 中并没有什么新变化:

图片

在前一个示例中,com.three19.irisScan 模块有一个用于内部使用的 irisScanner 包和一个 irisScanResult 类。如果 com.three19.access 应用程序尝试导入和使用 irisScanResult 类,Java 编译器将产生以下错误信息:

src/com.three19.access/com/three19/access/Main.java: error: irisScanResult is not accessible because package com.three19.irisScanner.internal is not exported
 private irisSanResult scan1 = new irisScanResult();
 ^
1 error

如果出于某种原因编译器没有捕获这个错误,尽管这非常不可能,将会发生以下运行时错误:

Exception in thread "main" java.lang.IllegalAccessError: class com.three19.access.Main (in module: com.three19.access) cannot access class com.three19.irisScanner.internal.irisScanResult (in module: com.three19.irisScan), com.three19.irisScanner.internal is not exported to com.three19.access.

详细错误信息将使调试和故障排除变得更加容易。

迁移规划

Java 平台的变化是显著的,Java 9 被认为是一个重大版本。认为我们的当前 Java 应用程序将在 Java 9 上无缝运行是过于天真的。虽然这可能适用于简单的程序,但至少在简单程序中,提前规划和考虑您最可能遇到的问题是很明智的。在我们查看这些问题之前,让我们在下一节测试一个简单的 Java 应用程序。

测试一个简单的 Java 应用程序

以下代码由一个单独的 Java 类 GeneratePassword. 组成。该类提示用户输入所需的密码长度,然后根据用户的请求长度生成密码。如果用户请求的长度小于 8,则将使用默认长度 8。此代码是用 Java SE 1.7 JRE 系统库编写的:

    /*
    * This is a simple password generation app 
    */

    import java.util.Scanner;

    public class GeneratePassword 
    {
      public static void main(String[] args) 
      { 
        // passwordLength int set up to easily change the schema 
        int passwordLength = 8; //default value

        Scanner in = new Scanner(System.in); 
        System.out.println("How long would you like your
         password (min 8)?"); 
        int desiredLength; 
        desiredLength = in.nextInt(); 

        // Test user input 
        if (desiredLength >8) 
        { 
          passwordLength = desiredLength; 
        } 

        // Generate new password 
        String newPassword = createNewPassword(passwordLength); 

        // Prepare and provide output 
        String output = "nYour new " + passwordLength 
         + "-character password is: "; 
        System.out.println(output + newPassword); 
      }

      public static String createNewPassword(int lengthOfPassword) 
      { 
        // Start with an empty String 
        String newPassword = "";

        // Populate password  
        for (int i = 0; i < lengthOfPassword; i++) 
        { 
          newPassword = newPassword + randomizeFromSet(
            "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ
             0123456789+-*/?!@#$%&"); 
        } 

        return newPassword; 
      }

      public static String randomizeFromSet(String characterSet) 
      { 
        int len = characterSet.length();
        int ran = (int)(len * Math.random());
        return characterSet.substring(ran, ran + 1);
      }
    }

在下面的屏幕截图中,我们在运行 Java 8 的 Mac 上测试了 GeneratePassword 应用程序。如您所见,我们首先查询 Java 以验证当前版本。在这个测试中,使用了 Java 1.8.0_121。接下来,我们使用 javac 工具编译 GeneratePassword Java 文件。最后,我们运行了应用程序:

图片

如前文测试所示,GeneratePassword.java 编译成功,生成了 GeneratePassword.class 文件。应用程序通过 java GeneratePassword 命令运行。用户被提示输入所需的密码长度,并输入了 32。然后应用程序成功生成了一个 32 位的随机密码并提供了适当的输出。

这个测试证明了示例应用程序在 JDK 1.8 上成功运行。接下来,让我们使用 JDK 9 测试同一个应用程序。

我们从 java -version 命令开始,以表明我们在这台计算机上使用 JDK 9。以下截图显示我们成功将 .java 文件编译成 .class 文件。当应用程序运行时,它按预期工作并提供了正确的结果:

正如您所看到的,我们明确地证明了在 Java 9 之前的应用程序有可能在不进行任何修改的情况下成功运行在 Java 9 上。这是一个简单的案例研究,涉及一个非常基础的 Java 程序。这当然是最好的情况,但不能假设。您将想要测试您的应用程序,以确保它们在 Java 9 平台上按预期运行。

在下一节中,我们将回顾在测试您的 Java 9 之前的应用程序时可能会遇到的一些潜在问题,我们将使用新的 Java 平台和 JDK 9。

可能的迁移问题

本节中提到的潜在迁移问题包括直接访问 JRE、访问内部 API、访问内部 JAR、JAR URL 废弃、扩展机制以及 JDK 的模块化。让我们逐一查看这些潜在的迁移问题。

JRE

创建 Java 9 的模块化系统在开发工具和实用工具的数量和位置方面带来了一些简化。一个例子是 JDK 对 JRE 的消耗。在所有 Java 9 之前的版本中,Java 平台包括 JDK 和 JRE 作为两个单独的组件。在 Java 9 中,这些组件已经被合并。这是一个重大的变化,开发者应该密切关注。如果您有一个指向 JRE 目录的应用程序,您将需要做出更改以避免问题。JRE 内容如下所示:

内部 API 的访问

Java 9 平台已经封装了内部 API 以提高平台和用 Java 编写的应用程序的安全性。与 Java 平台之前的版本不同,用 Java 9 编写的应用程序将无法默认访问 JDK 的内部 API。Oracle 已经将一些内部 API 识别为关键;这些 API 通过 jdk.unsupported JDK 模块保持可访问。

上述关键 API(JDK 内部)包括:

  • sun.misc

  • sun.misc.Unsafe

  • sun.reflect.Reflection

  • sun.reflect.ReflectionFactory.newConstrutorForSerialization

如果您有在 Java 9 之前实现任何 sun.*com.sun.* 包的应用程序,您在迁移应用程序到 Java 9 时可能会遇到问题。为了解决这个问题,您应该检查您的类文件以确定是否使用了 sun.*com.sun.* 包。或者,您可以使用 Java 依赖分析工具 jdeps 来帮助确定您的 Java 程序是否依赖于 JDK 内部 API。

jdeps 工具是 Java 依赖分析工具,可用于帮助确定您的 Java 程序是否依赖于 JDK 内部 API。

访问内部 JAR 文件

Java 9 不允许访问内部 JAR 文件,如lib/ant-javax.jarlib/dt.jar以及在此处显示的lib目录中列出的其他文件:

图片

这里需要注意的关键点是,如果你有依赖于这些工具之一的 Java 应用程序,这些工具位于lib文件夹中,你需要相应地修改你的代码。

建议你在开始使用 Java 9 后测试一次你的 IDE,以确保 IDE 已更新并官方支持 Java 9。如果你使用多个 IDE 进行 Java 开发,测试每一个以避免意外。

JAR URL 弃用

在 Java 9 之前,JAR 文件 URL 被一些 API 用来标识运行时图像中的特定文件。这些 URL 包含一个jar:file:前缀和两个路径;一个指向jar,另一个指向jar内部的具体资源文件。以下是 Java 9 之前 JAR URL 的语法:

    jar:file:<path-to-jar>!<path-to-file-in-jar>

随着 Java 9 模块系统的出现,容器将容纳资源文件而不是单个 JAR 文件。访问资源文件的新语法如下:

    jrt:/<module-name>/<path-to-file-in-module>

现在有一个新的 URL 模式jrt用于在运行时图像内命名资源。这些资源包括类和模块。新的模式允许在不引入对运行时图像的安全风险的情况下识别资源。这种增强的安全性确保了运行时图像的形式和结构保持隐蔽。以下是新的模式:

    jrt:/[$MODULE[/$PATH]]

有趣的是,jrt URL 的结构决定了其含义,表明结构可以采取几种形式之一。以下是三种不同jrt URL 结构的示例:

  • jrt:/$MODULE/$PATH:这种结构提供了对由$PATH参数指定的资源文件的访问,该资源文件位于由$MODULE参数指定的模块中

  • jrt:/$MODULE:这种结构提供了对由$MODULE参数指定的模块中所有资源文件的引用

  • jrt:/:这种结构提供了对运行时图像中所有资源文件的引用

如果你有的现有代码使用 API 返回的 URL 实例,你应该不会有任何问题。另一方面,如果你的代码依赖于jar URL 结构,你将会有问题。

扩展机制

如下所示,Java 平台之前有一个扩展机制,它允许开发者将自定义 API 提供给所有应用程序。正如您在下面的插图中所看到的,扩展是某种插件,或者是 Java 平台的附加组件。每个扩展中的 API 和类默认情况下都是自动可用的:

图片

如插图所示,Java 应用程序可以访问 Java 平台和扩展,而无需要求类路径。这个特性在 Java 8 中被弃用,并且在 Java 9 中不再存在。

JDK 的模块化

到目前为止,您已经对 Java 9 的模块化有了深刻的理解。Java 及其它面向对象编程语言中的古老谚语是“万物皆类”。现在,随着 Java 9 的推出,“万物皆模块”成为了新的谚语。有三种类型的模块,如下所述:

模块类型 描述
自动 当 JAR 文件放置在新的模块路径上时,模块会自动创建
显式/命名 这些模块通过编辑 module-info.java 文件手动定义
无名 当 JAR 文件放置在类路径上时,会创建无名模块

当您将应用程序迁移到 Java 9 时,您的应用程序及其库将变为无名称模块。因此,您需要确保所有模块都在模块路径中。

另一件需要注意的事情是,您的运行时映像将不会包含整个 JDK。相反,它将只包含应用程序所需的模块。值得回顾 Java 9 中 JDK 的模块化方式。以下表格包含 Java 9 中 JDK 的 API 规范:

jdk.accessibility jdk.attach jdk.charsets jdk.compiler
jdk.crypto.cryptoki jdk.crypto.ec jdk.dynalink jdk.editpad
jdk.hotspot.agent jdk.httpserver jdk.incubator.httpclient jdk.jartool
jdk.javadoc jdk.jcmd jdk.jconsole jdk.jdeps
jdk.jdi jdk.jdwp.agent jdk.jlink jdk.jshell
jdk.jsobject jdk.jstatd jdk.localedata jdk.management
jdk.management.agent jdk.naming.dns jdk.naming.rmi jdk.net
jdk.pack jdk.packager.services jdk.policytool jdk.rmic
jdk.scripting.nashorn jdk.sctp jdk.security.auth jdk.security.jgss
jdk.snmp jdk.xml.dom jdk.zipfs

以下表格包含 Java 9 中 Java SE 的 API 规范:

java.activation java.base java.compiler java.cobra
java.datatransfer java.desktop java.instrument java.logging
java.management java.management.rmi java.naming java.prefs
java.rmi java.scripting java.se java.se.ee
java.security.jgss java.security.sasi java.sql java.sql.rowset
java.transaction java.xml java.xml.bind java.xml.crypto
java.xml.ws java.xml.ws java.xml.ws.annotation

记住,所有应用程序都将默认通过模块路径访问 java.base

以下表格包含 Java 9 中 JavaFX 的 API 规范:

javafx.base javafx.controls javafx.fxml javafx.graphics
javafx.media javafx.swing javafx.web

有两个额外的模块:

  • java.jnlp 定义了JNLPJava 网络启动协议)的 API

  • java.smartcardio 定义了 Java 智能卡输入/输出的 API

关于这些模块的详细信息,请访问 Oracle 的Java®平台,标准版和 Java 开发工具包版本 9 API 规范网站:download.java.net/java/jdk9/docs/api/overview-summary.html

来自 Oracle 的建议

Oracle 在将这个主要更新,版本 9,带到 Java 平台方面做得非常出色。他们对准备 Java 9 和如何迁移到新 JDK 的见解值得回顾。在本节中,我们将探讨准备步骤、打破封装、运行时图像的变化、已删除的工具和 API 组件、垃圾收集的变化以及部署。

准备步骤

Oracle 提供了一套五步流程,以帮助开发者将他们的 Java 应用程序迁移到版本 9。这些步骤如下,并在后续章节中进行详细说明:

  1. 获取 JDK 9 的早期访问构建版本。

  2. 在重新编译之前运行你的程序。

  3. 更新第三方库和工具。

  4. 编译你的应用程序。

  5. 在你的代码上运行jdeps

获取 JDK 9 早期访问构建版本

如果你在 Java 9 正式发布之前阅读这本书,你可以从这里获取 JDK 9 的早期访问构建版本——jdk.java.net/9/。早期发布版本适用于 Windows(32 位和 64 位)、macOS(64 位)、Linux(32 位和 64 位)以及各种 Linux ARM、Solaris 和 Alpine Linux 版本。

在 Java 9 正式发布之前,花时间测试你的应用程序并迁移它们,有助于确保你不会因为依赖于你的 Java 应用程序的服务而经历任何停机时间。

在重新编译之前运行你的程序

如本章前面所述,你的现有 Java 应用程序有可能在 Java 9 平台上无需修改即可运行。因此,在你做出任何更改之前,尝试在 Java 9 平台上运行你的当前应用程序。如果你的应用程序在 Java 9 上运行良好,那很好,但你的工作还没有完成。请回顾下一节关于更新第三方库和工具、编译你的应用程序以及在代码上运行jdeps的内容。

更新第三方库和工具

第三方库和工具可以帮助扩展我们的应用程序并缩短开发时间。为了 Java 9 的兼容性,确保你使用的每个第三方库和工具都与 JDK 9 兼容并支持该版本非常重要。在 Java 9 上运行你的应用程序不会为你提供确保未来没有兼容性问题的洞察力。建议你查看每个库和工具的官方网站,以验证其与 JDK 9 的兼容性和支持情况。

如果你使用的库或工具有一个支持 JDK 9 的版本,请下载并安装它。如果你找到一个还不支持 JDK 9 的版本,考虑寻找替代品。

在我们的上下文中,工具包括集成开发环境IDE)。NetBeans、Eclipse 和 IntelliJ 都有支持 JDK 9 的 IDE 版本。以下提供那些网站的链接:

编译您的应用程序

您的下一步是使用 JDK 9 的 javac 编译您的应用程序。即使您的应用程序在 JDK 9 上运行正常,这也是很重要的。您可能不会收到编译器错误,但也要注意警告。以下是您的应用程序可能无法使用 JDK 9 编译的最常见原因,假设它们在 Java 9 之前可以编译。

首先,如本章前面所述,大多数 JDK 9 内部 API 默认情况下不可访问。您的指示将是在运行时或编译时出现的 IllegalAccessErrors 错误。您需要更新您的代码,以便使用可访问的 API。

第二个原因可能是您的 Java 9 之前的程序无法使用 JDK 9 编译,如果您使用了下划线字符作为单个字符标识符。根据 Oracle 的说法,这种做法在 Java 8 中会生成警告,在 Java 9 中会生成错误。让我们看一个例子。以下 Java 类实例化了一个名为 _ 的对象,并向控制台打印了一条单数消息:

    public class Underscore 
    { 
      public static void main(String[] args) 
      {
        Object _ = new Object();
        System.out.println("This ran successfully.");
      }
    }

当我们使用 Java 8 编译此程序时,我们会收到一个警告,表明使用 '_' 作为标识符可能在 Java SE 8 之后的版本中不受支持:

如您在下面的屏幕截图中所见,这只是一个警告,应用程序运行正常:

现在,让我们尝试使用 JDK 9 编译相同的类:

如您所见,使用下划线作为单个字符标识符仍然只会产生警告,而不会产生错误。应用程序运行成功。这次测试是在 JDK 9 仍处于早期发布阶段时运行的。假设一旦 JDK 9 正式发布,运行此测试将产生错误而不是仅仅警告。可能会抛出的错误如下:

Underscore.java:2: error: as of release 9, '_' is a keyword, and may not be used as a legal identifier.

即使这个问题在 JDK 9 的正式发布中没有解决,使用下划线作为单个字符标识符也不是好的编程实践,因此您应该避免使用它。

第三个可能导致您的 Java 9 之前的程序无法使用 JDK 9 编译的可能原因是您正在使用 -source-target 编译器选项。让我们看看 Java 9 之前和 Java 9 中的 -source-target 编译器选项。

Java 9 之前的 -source 和 -target 选项

-source选项指定了 Java SE 版本,并具有以下可接受的值:

描述
1.3 javac不支持 Java SE 1.3 之后引入的特性。
1.4 javac接受 Java SE 1.4 中引入的语言特性。
1.5 或 5 javac接受 Java SE 1.5 中引入的语言特性。
1.6 或 6 javac将编码错误报告为错误而不是警告。值得注意的是,Java SE 1.6 中没有引入新的语言特性。
1.7 或 7 javac接受 Java SE 1.7 中引入的语言特性。如果没有使用-source选项,这是默认值。

-target选项告诉javac要针对哪个版本的 JVM。-target选项的可接受值是1.11.21.31.41.551.661.77。如果没有使用-target选项,默认的 JVM 目标是依赖于与-source选项一起使用的值。以下是-source值及其相关-target的表格:

-source 值 默认-target
未指定 1.7
1.2 1.4
1.3 1.4
1.4 1.4
1.5 或 5 1.7
1.6 或 6 1.7
1.7 1.7

Java 9 -source 和-target 选项

在 Java 9 中,支持值如下所示:

支持值 备注
9 这是默认值,如果没有指定值
8 设置支持为 1.8
7 设置支持为 1.7
6 设置支持为 1.6 并生成一个警告(不是错误)来指示 JDK 6 已过时

在你的代码上运行 jdeps

jdeps类依赖分析工具对 Java 9 来说并不陌生,但也许随着 Java 9 的到来,它对开发者来说从未如此重要。将你的应用程序迁移到 Java 9 的重要一步是运行jdeps工具,以确定你的应用程序及其库的依赖关系。如果代码依赖于任何内部 API,jdeps工具会很好地建议替代方案。

以下截图显示了使用jdeps分析器时你可以使用的选项:

让我们来看一个例子。这里有一个简单的 Java 类,名为DependencyTest

    import sun.misc.BASE64Encoder;

    public class DependencyTest 
    {
      public static void main(String[] args) throws
       InstantiationException, IllegalAccessException 
      {
        BASE64Encoder.class.newInstance();
        System.out.println("This Java app ran successfully.");
      }
    }

现在,让我们使用javac来使用 Java 8 编译这个类:

正如你所见,Java 8 成功编译了这个类,并且应用程序运行正常。编译器确实给出了一个DependencyTest.java:6: warning: BASE64Encoder 是内部专有 API,可能在未来的版本中删除警告。现在,让我们看看当我们尝试使用 Java 9 编译这个类时会发生什么:

在这种情况下,使用 Java 9,编译器给出了两个警告而不是一个。第一个警告是针对 import sun.misc.BASE64Encoder; 语句,第二个是针对 BASE64Encoder.class.newInstance(); 方法调用。正如你所见,这些只是警告而不是错误,所以 DependencyTest.java 类文件成功编译。接下来,让我们运行应用程序:

现在,我们可以清楚地看到 Java 9 不会允许我们运行应用程序。接下来,让我们使用 jdeps 分析器工具运行一个依赖性测试。我们将使用以下命令行语法——jdeps DependencyTest.class

正如你所见,我们有三个依赖项:java.iojava.langsun.misc。在这里,我们被建议将我们的 sun.misc 依赖项替换为 rt.jar

破坏封装

由于模块重组带来的封装性增强,Java 9 平台比其前辈版本更安全。尽管如此,你可能需要突破模块系统的封装。Java 9 允许突破这些访问控制边界。

如同你在本章前面所读到的,大多数内部 API 都被强封装。正如之前所建议的,在更新源代码时,你可能需要寻找替代 API。当然,这并不总是可行的。你可以采取三种额外的做法——在运行时使用 --add-opens 选项;使用 --add-exports 选项;以及 --permit-illegal-access 命令行选项。让我们看看这些选项中的每一个。

--add-opens 选项

你可以使用 --add-opens 运行时选项来允许你的代码访问非公共成员。这可以被称为深度反射。执行深度反射的库能够访问所有成员,包括私有和公共成员。为了授予这种访问权限给你的代码,你使用 --add-opens 选项。以下是语法:

    --add-opens module/package=target-module(,target-module)*

此选项允许指定的模块打开指定的包。当使用此选项时,编译器不会产生任何错误或警告。

--add-exports 选项

你可以使用 --add-exports 来破坏封装,以便你可以使用默认不可访问的内部 API。以下是语法:

    --add-exports <source-module>/<package>=<target-module>(
     ,<target-module>)*

此命令行选项允许 <target-module> 中的代码访问 <source-module> 包中的类型。

破坏封装的另一种方法是使用 JAR 文件的清单。以下是一个示例:

    --add-exports:java.management/sun.management

应仅在认为绝对必要时使用 --add-exports 命令行选项。不建议除了短期解决方案之外使用此选项。常规使用此选项的危险在于,任何对引用内部 API 的更新都可能导致你的代码无法正常工作。

--permit-illegal-access 选项

打破封装的第三种方法是使用--permit-illegal-access选项。当然,与第三方库创建者联系以查看他们是否有更新的版本是明智的。如果没有这个选项,您可以使用--permit-illegal-access来获取对类路径上要实现的操作的非法访问。由于这里的操作明显非法,每次这些操作发生时,您都会收到警告。

运行时图像更改

Java 9 对 JDK 和 JRE 进行了重大更改。其中许多更改与模块化相关,已在其他章节中介绍。还有一些其他事情您应该考虑。

Java 版本架构

使用 Java 9,Java 平台版本显示的方式已更改。以下是一个 Java 9 之前的版本格式的示例:

图片

现在,让我们看看 Java 9 如何报告其版本:

图片

正如您所看到的,随着 Java 9 的发布,版本架构现在为$MAJOR.$MINOR.$SECURITY.$PATCH。这与 Java 的先前版本有显著不同。这只会影响您的应用程序,如果您有解析java -version命令返回的字符串的代码。

JDK 和 JRE 布局

在 Java 的新版本中,JDK 和 JRE 中的文件组织方式已经发生了变化。花时间熟悉新的文件系统布局是值得的。以下截图显示了 JDK 的/bin文件夹的文件结构:

图片

这里是lib文件夹的布局:

图片

已移除的内容

对于 Java 平台的新版本,另一个变化领域是许多平台组件已被移除。以下各节表示最显著的组件。

显然,rt.jartools.jardt.jar已被移除。这些 JAR 文件包含类和其他资源文件,并且都位于/lib目录中。

e*ndorsed standards override mechanism已被移除。在 Java 9 中,如果javacjava检测到该机制,它们都会退出。该机制被用于应用程序服务器以覆盖一些 JDK 组件。在 Java 9 中,您可以使用可升级的模块来实现相同的结果。

如本章先前所述,e*xtension mechanism机制也已移除。

以下列出的 API 先前已被弃用,并已移除,在 Java 9 中不可访问。这些 API 的移除是 Java 平台模块化的结果:

  • apple.applescript

  • com.apple.concurrent

  • com.sun.image.codec.jpeg

  • java.awt.dnd.peer

  • java.awt.peer

  • java.rmi.server.disableHttp

  • java.util.logging.LogManager.addPropertyChangeListener

  • java.util.logging.LogManager.removePropertyChangeListener

  • java.util.jar.Pack200.Packer.addPropertyChangeListener

  • java.util.jar.Pack200.Packer.removePropertyChangeListener

  • java.util.jar.Pack200.Unpacker.addPropertyChangeListener

  • java.util.jar.Pack200.Unpacker.removePropertyChangeListener

  • javax.management.remote.rmi.RMIIIOPServerImpl

  • sun.misc.BASE64Encoder

  • sun.misc.BASE64Decoder

  • `sun.rmi.transport.proxy.connectTimeout`

  • sun.rmi.transport.proxy.eagerHttpFallback

  • sun.rmi.transport.proxy.logLevel

  • sun.rmi.transport.tcp.proxy

以下列出的一些工具已被删除。在每种情况下,该工具之前已被淘汰或其功能被更好的替代品所取代:

  • hprof

  • java-rmi.cgi

  • java-rmi.exe

  • JavaDB

  • jhat

  • native2ascii

Java 9 中删除的两个额外功能是:

  • AppleScript 引擎。这个引擎被认为不可用,并且没有替代品而被删除。

  • Windows 32 位客户端虚拟机。JDK 9 支持 32 位服务器 JVM,但不支持 32 位客户端 VM。这个变化是为了关注 64 位系统的性能提升。

更新的垃圾收集

垃圾收集一直是 Java 的伟大成就之一。在 Java 9 中,垃圾-首次G1)垃圾收集器现在是 32 位和 64 位服务器上的默认垃圾收集器。在 Java 8 中,默认的垃圾收集器是并行垃圾收集器。Oracle 报告称,有三种垃圾收集组合将在 Java 9 中阻止你的应用程序启动。这些组合是:

  • DefNew + CMS

  • 增量 CMS

  • ParNew + SerialOld

我们将在第七章“利用新的默认 G1 垃圾收集器”中深入探讨 Java 9 的垃圾收集。

部署

在将应用程序迁移到 Java 9 的背景下,你应该注意三个问题,这些问题是在部署你的应用程序时出现的。这些问题是 JRE 版本选择、序列化小程序和 JNLP 的更新。

JNLPJava 网络启动协议的缩写,将在本章的后续部分中介绍。

JRE 版本选择

在 Java 9 之前,开发人员可以在启动应用程序时请求除正在启动的版本之外的 JRE 版本。这可以通过命令行选项或适当的 JAR 文件清单配置来实现。由于我们通常部署应用程序的方式,这个特性在 JDK 9 中被删除。以下是三种主要方法:

  • 激活安装程序

  • Java Web Start使用 JNLP

  • 原生操作系统打包系统

序列化小程序

Java 9 不支持将小程序作为序列化对象部署的能力。在过去,小程序作为序列化对象部署以补偿缓慢的压缩和 JVM 性能问题。随着 Java 9 的推出,压缩技术更加先进,JVM 性能也更好。

如果你尝试将你的小程序作为序列化对象部署,当你的小程序启动时,你的对象属性和参数标签将被简单地忽略。从 Java 9 开始,你可以使用标准的部署策略来部署你的小程序。

JNLP 更新

JNLP 用于在桌面客户端使用位于 Web 服务器上的资源启动应用程序。JNLP 客户端包括 Java Web Start 和 Java Plug-in 软件,因为它们能够启动远程托管的小程序。此协议对于启动 RIAs 至关重要。

RIAs富互联网应用程序,当使用 JNLP 启动时,可以访问各种 JNLP API,在用户许可的情况下,可以访问用户的桌面。

在 Java 9 中,JNLP 规范已更新。以下几节详细介绍了四个具体的更新。

嵌套资源

在 Java 或 j2se 元素中使用具有嵌套资源的组件扩展之前已被支持,但在规范中未进行记录。规范现已更新以反映这一支持。之前的规范如下:

不能将任何 java 元素指定为资源的一部分。

Java 9 的更新规范现在如下所示:

组件扩展中的 java 元素不会控制使用哪个版本的 java,但可以包含嵌套资源元素,然后只有在使用与第 4.6 节中指定的版本匹配的 Java 版本时,才能使用这些资源。

这个特定的更改确保扩展 JLP 文件必须包含 javaj2se 资源,并且这些资源不会决定使用哪个 JRE。当使用指定版本时,允许嵌套资源。

FX XML 扩展

当使用 JNLP 时,你创建一个 JNLP 文件。以下是一个示例:

    <?xml version="1.0" encoding="UTF-8"?>
    <jnlp spec="1.0+" codebase="" href="">
      <information>
        <title>Sample/title>
        <vendor>The Sample Vendor</vendor>
        <icon href="sample-icon.jpg"/>
        <offline-allowed/>
     </information>
     <resources>
       <!-- Application Resources -->
       <j2se version="1.6+"  
        href="http://java.sun.com/products/autodl/j2se"/>
       <jar href="Sample-Set.jar" main="true" />
     </resources>
     <application-desc
       name="Sample Application"
       main-class="com.vendor.SampleApplication" 
       width="800" 
       height="500">
       <argument>Arg1</argument>
       <argument>Arg2</argument>
       <argument>Arg3</argument>
     </application-desc>
     <update check="background"/>
    </jnlp>

<application-desc> 元素进行了两项更改。首先,添加了可选的 type 属性,以便可以对应用程序类型进行注释。默认类型是 Java,因此如果你的程序是 Java 应用程序,你不需要包含 type 属性。或者,你可以如下指定 Java 作为你的类型:

    <application-desc 
      name="Another Sample Application"
      type="Java" main-class="com.vendor.SampleApplication2" 
      width="800" 
      height="500">
      <argument>Arg1</argument>
      <argument>Arg2</argument>
      <argument>Arg3</argument>
    </application-desc>

我们可以指示其他应用类型,例如在此处所示包含 JavaFX

    <application-desc 
      name="A Great JavaFX Application"
      type="JavaFX" main-class="com.vendor.GreatJavaFXApplication" 
      width="800" 
      height="500">
      <argument>Arg1</argument>
      <argument>Arg2</argument>
      <argument>Arg3</argument>
    </application-desc>

如果你指定了一个 JNLP 客户端不支持的应用程序类型,你的应用程序启动将失败。有关 JNLP 的更多信息,你可以查阅官方文档:docs.oracle.com/javase/7/docs/technotes/guides/javaws/developersguide/faq.html

Java 9 中 <application-desc> 元素的第二个更改是添加了 param 子元素。这允许我们使用 value 属性提供参数的名称及其值。以下是一个包含 param 子元素和 value 属性的 <application-desc> 元素的 JNLP 文件示例。此示例显示了三组参数:

    <application-desc
      name="My JRuby Application"
      type="JRuby"
      main-class="com.vendor.JRubyApplication" 
      width="800" 
      height="500">
      <argument>Arg1</argument>
      <argument>Arg2</argument>
      <argument>Arg3</argument>
      <param name="Parameter1" value="Value1"/>
      <param name="Parameter2" value="Value2"/>
      <param name="Parameter3" value="Value3"/>
    </application-desc>

如果应用的 type 是 Java,那么你使用的任何 param 子元素都将被忽略。

JNLP 文件语法

JNLP 文件语法现在完全符合 XML 规范。在 Java 9 之前,你可以使用 & 来创建复杂的比较。这不被标准 XML 支持。你仍然可以在 JNLP 文件中创建复杂的比较。现在你将使用 &amp; 而不是 &

数字版本比较

JNLP 规范已被更改,以反映数字版本元素与非数字版本元素之间的比较方式。在更改之前,版本元素通过 ASCII 值进行字典序比较。在 Java 9 和这个 JNLP 规范更改之后,元素仍然通过 ASCII 值进行字典序比较。当两个字符串长度不同时,这种变化是明显的。在新比较中,较短的字符串将被前导零填充,以匹配较长的字符串长度。

字典序比较使用基于字母顺序的数学模型。

有用的工具

在将你的应用程序迁移到 Java 9 之前,你需要做的第一件事是下载 JDK 9。你可以通过这个网址下载早期访问版本--jdk.java.net/9/。你需要接受许可协议,然后选择要下载的构建版本。正如你在以下截图中所看到的,根据你的操作系统,有几个选项:

现在你已经在你的开发计算机上安装了 JDK 9,让我们看看一些可以帮助你将应用程序迁移到 Java 9 的工具。

Java 环境 - jEnv

如果你在一个运行 Linux 或 macOS 的计算机上开发,你可能考虑使用 jEnv,这是一个开源的 Java 环境管理工具。这是一个命令行工具,所以不要期待有图形用户界面。你可以通过这个网址下载这个工具--github.com/gcuisinier/jenv

这是 Linux 的安装命令:

$ git clone https://github.com/gcuisinier/jenv.git ~/.jenv

要使用 macOS 和 Homebrew 下载,请使用以下命令:

$ brew install jenv

你也可以使用以下方式在 Linux 或 macOS 上使用 Bash 安装:

$ echo 'export PATH="$HOME/.jenv/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(jenv init -)"' >> ~/.bash_profile

或者,你可以使用以下方式在 Linux 或 macOS 上使用 Zsh 安装:

$ echo 'export PATH="$HOME/.jenv/bin:$PATH"' >> ~/.zshrc
$ echo 'eval "$(jenv init -)"' >> ~/.zshrc

在你安装了 jEnv 之后,你需要按照以下方式在你的系统上配置它。你需要修改脚本以反映你的实际路径:

$ jenv add /Library/Java/JavaVirtualMachines/jdk17011.jdk/Contents/Home

你将需要为系统上的每个 JDK 版本重复执行 jenv add 命令。对于每个 jenv add 命令,你将收到确认,具体 JDK 版本已被添加到 jEnv,如下所示:

$ jenv add /System/Library/Java/JavaVirtualMachines/1.6.0.jdk/Contents/Home
  oracle64-1.6.0.39 added
$ jenv add /Library/Java/JavaVirtualMachines/jdk17011.jdk/Contents/Home
  oracle64-1.7.0.11 added

你可以通过在命令提示符中使用 $ jenv versions 来检查你添加到 jEnv 的 JDK 版本。这将生成一个输出列表。

这里是三个额外的 jEnv 命令:

  • jenv global <version>:这将为全局设置版本

  • jenv local <version>:这将为本地设置版本

  • jenv shell <version>:这将为 shell 设置实例版本

Maven

Maven 是一个开源工具,可用于构建和管理基于 Java 的项目。它已经支持 Java 9,并作为Apache Maven 项目的一部分。如果您尚未使用 Maven,并且进行大量 Java 开发,您可能会被以下 Maven 目标所吸引:

  • 使构建过程变得简单

  • 提供统一的构建系统

  • 提供高质量的项目信息

  • 提供最佳实践开发的指南

  • 允许透明迁移到新功能

您可以在此站点阅读有关每个 Maven 目标的更多详细信息--maven.apache.org/what-is-maven.html。要下载 Maven,请访问此站点--maven.apache.org/download.cgi。Windows、macOS、Linux 和 Solaris 的安装说明可在以下位置找到--maven.apache.org/install.html

Maven 可以与 Eclipse(M2Eclipse)、JetBrains IntelliJ IDEA 和 Netbeans IDE 集成。以 M2Eclipse IDE 为例,它提供了丰富的 Apache Maven 集成,并具有以下功能:

  • 您可以从 Eclipse 内部启动 Maven 构建

  • 管理 Eclipse 构建路径的依赖项

  • 容易解决 Maven 依赖项(您可以直接从 Eclipse 操作,而无需安装本地 Maven 仓库)

  • 自动下载所需的依赖项(从远程 Maven 仓库)

  • 使用软件向导创建新的 Maven 项目,创建pom.xml文件,并启用对您的纯 Java 项目的 Maven 支持

  • Maven 远程仓库的快速依赖项搜索

获取 M2Eclipse IDE

要获取 M2Eclipse IDE,您必须首先安装 Eclipse。以下是步骤:

  1. 首先,打开您的当前 Eclipse IDE。接下来,选择首选项 | 安装/更新 | 可用软件站点,如以下截图所示:

  1. 下一个任务是向您的可用软件站点列表中添加 M2Eclipse 仓库站点。为此,请点击“添加”按钮,并在名称和位置文本输入框中输入值。对于名称,输入一些可以帮助您记住 M2Eclipse 位于此站点的信息。对于位置,输入 URL--http://download.eclipse.org/technology/m2e/releases。然后,点击“确定”按钮:

  1. 您现在应该看到 M2Eclipse 站点列在您的可用软件站点列表中,如以下截图所示。您的最后一步是点击“确定”按钮:

  1. 现在,当您开始一个新项目时,您将看到“Maven 项目”作为选项:

Maven 是 Java 开发者证明有效的工具。您可以考虑通过以下资源之一获取有关 Maven 的更多信息:

摘要

在本章中,我们探讨了将现有应用程序迁移到 Java 9 平台可能涉及的问题。我们考虑了手动和半自动迁移过程。本章为您提供了使您的 Java 8 代码与 Java 9 兼容的见解和流程。具体来说,我们对 Project Jigsaw 进行了快速回顾,探讨了模块如何融入 Java 生态系统,提供了迁移规划的建议,分享了 Oracle 关于迁移的建议,并分享了您在开始使用 Java 9 时可以使用的工具。

在下一章中,我们将仔细研究 Java Shell 和 JShell API。我们将展示 JShell API 和 JShell 工具交互式评估 Java 编程语言的声明、语句和表达式的功能。我们将演示这个命令行工具的功能和使用方法。

第七章:在 Java Shell 中进行实验

在上一章中,我们探讨了如何将 Java 9 之前的应用程序迁移到新的 Java 平台。我们检查了可能导致你的当前应用程序在 Java 9 上运行出现问题的几个问题。我们从 Project Jigsaw 的回顾开始,然后探讨了模块如何适应新的 Java 平台。我们为你提供了使你的 Java 8 代码与 Java 9 一起工作的见解和流程。具体来说,我们提供了迁移规划的建议、分享了 Oracle 关于迁移的建议,并分享了你可以使用的工具,以帮助你开始使用 Java 9。

在本章中,我们将首次了解 Java 9 中的新命令行工具,读取-评估-打印循环(也称为 REPL),即 Java ShellJShell)。我们将从有关工具、读取-评估-打印循环概念的基础信息开始,然后进入用于 JShell 的命令和命令行选项。我们将采用实践者的方法来审查 Java Shell,并包括你可以尝试的示例。

本章涵盖了以下主题:

  • 什么是 JShell?

  • 开始使用 JShell

  • JShell 的实际用途

  • 与脚本一起工作

什么是 JShell?

JShell 是 Java 9 中引入的一个新工具。它是一个交互式的读取-评估-打印循环工具,用于评估以下 Java 编程语言组件——声明、语句和表达式。它拥有自己的 API,因此可以被外部应用程序使用。

读取-评估-打印循环通常被称为 REPL,取自短语中每个单词的首字母。它也被称为语言壳或交互式顶层。

JShell 的引入是 Java Enhancement ProgramJEP) 222 的结果。以下是关于 Java Shell 命令行工具的 JEP 的声明目标:

  • 促进快速调查

  • 促进快速编码

  • 提供编辑历史

之前列出的快速调查和编码包括语句和表达式。令人印象深刻的是,这些语句和表达式不需要是方法的一部分。此外,变量和方法不需要是类的一部分,这使得这个工具特别灵活。

此外,以下列出的功能被包括进来,使 JShell 更易于使用,并使你在使用 JShell 的时间尽可能高效:

  • 制表符自动完成

  • 语句末尾分号的自动完成

  • 导入的自动完成

  • 定义自动完成

开始使用 JShell

JShell 是一个位于 /bin 文件夹中的命令行工具。此工具的语法是 jshell <options> <load files>。正如你所看到的,这个工具可以使用多个选项:

图片

你已经看到了 -h 选项,我们通过 jshell -h 执行了它。这提供了 JShell 选项的列表。

要登录 JShell,您只需使用jshell命令。您会看到命令窗口中的提示符相应地改变:

退出 shell 就像输入/exit一样简单。一旦进入 JShell,您就可以输入以下任何命令:

命令 功能

| /drop | 使用此命令通过nameid引用删除源条目。以下是语法:

    /drop <name or id>

|

| /edit | 使用此命令,您可以使用nameid引用编辑源条目。以下是语法:

    /edit <name or id>

|

| /env | 此强大命令允许您查看或更改评估上下文。以下是语法:

    /env [-class-path <path>] [-module-path <path>]
     [-add-modules <modules]

|

/exit 此命令用于退出 JShell。语法很简单,即/exit,没有可用的选项或参数。
/history 历史命令提供了您输入的历史记录。语法很简单,即/history,没有可用的选项或参数。
/<id>
id. Here is the syntax: /<id>

您也可以通过引用第n个之前的代码片段来运行特定的代码片段,使用/-<n>。|

/imports 您可以使用此命令列出导入的项目。语法是/imports,它不接受任何选项或参数。

| /list | 此命令将列出您输入的源。以下是语法:

    /list [<name or id> &#124; -all &#124; -start]

|

| /methods | 此命令列出所有声明的方法以及它们的签名。以下是语法:

    /methods [<name or id> &#124; -all &#124; -start]

|

| /open | 使用此命令,您可以打开一个文件作为源输入。以下是语法:

    /open <file>

|

| /reload | 重载命令允许您重置并重新播放相关历史记录。以下是语法:

    /reload [-restore] [-quiet]  [-class-path 
     <path>] [-module-path <path>]

|

| /reset | 此命令重置 JShell。以下是语法:

    /reset [-class-path <path>] [-module-path 
     <path>] [-add-modules <modules]

|

| /save | 此命令将代码片段源保存到您指定的文件中。以下是语法:

    /save [-all &#124; -history &#124; -start] <file>

|

| /set | 此命令用于设置 JShell 配置信息。以下是语法:

    /set editor &#124; start &#124; feedback &#124; mode &#124; prompt &#124; 
     truncation &#124; format

|

| /types | 此命令简单地列出声明的类型。以下是语法:

    /types [<name or id> &#124; -all &#124; -start]

|

| /vars | 此命令列出所有声明的变量以及它们的值。以下是语法:

    /vars [<name or id> &#124; -all &#124; -start]

|

/!
/!

|

列出的几个之前命令使用了术语代码片段。在 Java 9 和 JShell 的上下文中,代码片段是以下之一:

  • 类声明

  • 表达式

  • 字段声明

  • 导入声明

  • 接口声明

  • 方法声明

在 JShell 中输入/help/?命令会提供可以在 shell 中使用的完整命令和语法列表。该列表如下所示:

/help命令对于您刚开始使用 JShell 时特别有帮助。如您在以下屏幕截图中所见,只需输入/help intro命令,我们就可以获得 JShell 的介绍:

如果您经常使用 JShell,您可能从以下列出的快捷键中受益。您可以在 JShell 中使用/help shortcuts命令随时列出这些快捷键:

图片

您可以通过在 JShell 中使用/help命令后跟您想要获取额外帮助的命令来从 JShell 内部获得更多帮助。例如,输入/help reload将提供关于/reload命令的详细信息。该信息如下:

图片

JShell 的实际用途

无论您是新手开发者、经验丰富的开发者还是刚开始接触 Java,您都可能会发现 JShell 非常有用。在本节中,我们将探讨一些 JShell 的实际用途。具体来说,我们将涵盖:

  • 反馈模式

  • 列出您的资产

  • 在 JShell 中编辑

反馈模式

命令行工具通常提供相对稀疏的反馈,以避免屏幕过于拥挤或成为开发者的麻烦。JShell 除了给开发者提供创建自定义模式的能力外,还有几种反馈模式。

如您从以下屏幕截图中所见,有四种反馈模式--concisenormalsilentverbose。在这里,我们输入了/set feedback命令而没有任何参数,以列出反馈模式以及确定当前的反馈模式。输出第一行显示了将用于设置当前模式的命令行命令和参数集。因此,在以下屏幕截图中,当前的反馈模式设置为verbose,其他三种模式被列出:

图片

我们可以通过在启动 JShell 时包含一个选项来指定我们第一次进入 JShell 时想要进入的模式。以下是命令行选项:

命令行命令和选项 反馈模式
jshell -q concise
jshell -n normal
jshell -s silent
jshell -v verbose

您会注意到,我们使用-q来表示concise模式,而不是-c-c选项具有-c<flag>语法,用于将<flag>传递给编译器。

最好的方法是通过使用示例来回顾不同反馈模式之间的差异。从normal模式开始,我们将执行命令行命令来完成以下顺序的反馈演示:

  1. 创建变量。

  2. 更新变量的值。

  3. 创建方法。

  4. 更新方法。

  5. 运行方法。

为了开始我们的第一次测试,我们将在jshell>提示符下执行/set feedback normal命令,这将把 JShell 的反馈模式设置为normal。进入normal反馈模式后,我们将输入必要的命令来运行我们的演示:

图片

在进入正常反馈模式后,我们输入了int myVar = 3并收到了myVar ==> 3的反馈。在我们的下一个命令中,我们更改了相同变量的值,并收到了相同的新值输出。我们的下一个语句void quickMath() {System.out.println("Your result is " + (x*30 + 19));}使用了未声明的变量,您可以看到产生的两部分反馈——一部分指示方法已被创建,另一部分告知该方法在未声明的变量声明之前无法调用。接下来,我们将方法更改为包含myVar变量,反馈报告了方法的修改。我们的最后一步是使用quickMath();运行方法,结果正如我们所预期的。

让我们在简洁模式下尝试相同的反馈演示:

截图

如您从前面的截图中所见,简洁反馈模式为我们提供的反馈信息要少得多。我们创建了并修改了变量,但没有收到任何反馈。当我们使用未声明的变量创建方法时,我们收到的反馈与正常模式下的反馈相同。我们更新了方法,但没有确认或其他反馈。

我们下一次使用反馈演示将在静默模式下:

截图

当我们进入静默反馈模式时,如您在前面的截图中所见,JShell 提示符从jshell>变为->。当我们创建myVar变量、修改myVar变量或创建quickMath()方法时,没有提供任何反馈。我们故意创建了一个使用未声明变量的quickMath()方法。因为我们处于静默反馈模式,所以我们没有被通知该方法有一个未声明的变量。基于这种缺乏反馈的情况,我们运行了方法,但没有提供任何输出或反馈。接下来,我们更新了方法以包含已声明的myVar变量,然后运行了该方法。

静默反馈模式可能看起来没有意义,因为没有提供任何反馈,但这个模式非常有用。使用静默模式可能适合管道操作,或者当你想要最小化终端输出量时。例如,你可以使用隐式的System.out.println命令包含特定的、条件性的输出。

我们最后一次使用反馈演示是在详细反馈模式下。正如您从其名称所假设的,这种反馈模式提供了最多的反馈。以下是我们的测试结果:

截图

在我们的反馈演示中,使用详细反馈模式,我们收到了更多反馈以及更友好的反馈格式。

创建自定义反馈模式

虽然内部反馈模式(normalconcisesilentverbose)不能修改,但您可以创建自己的自定义反馈模式。这个过程的第一步是复制一个现有模式。以下示例演示了如何使用 /set mode myCustom verbose -command 命令字符串将 verbose 模式复制到 myCustom 模式:

图片

我们使用 -command 选项确保我们会收到命令反馈。您可以使用 /set 命令以及以下截图中的选项之一来对您的反馈模式进行各种更改:

图片

例如,让我们通过截断设置来了解它如何规定每行输出显示多少个字符。使用 /set truncation 命令,如下面的截图所示,显示了当前的截断设置:

图片

如您所见,我们的 myCustom 反馈模式具有 80 的截断。我们将使用 /set truncation myCustom 60 命令将其更改为 60,然后使用 /set truncation 命令进行验证:

图片

如前一个截图所示,我们的 myCustom 反馈模式的截断已成功从继承自 verbose 模式的 80 更改为 60,这是基于我们使用 /set truncation myCustom 60 JShell 命令的结果。

列出您的资产

有几个 JShell 命令方便列出您创建的资产。使用上一节中的反馈演示,我们执行了 /vars, /methods/list 命令,分别提供变量、方法和所有源列表:

图片

我们还可以使用 /list -all 命令和选项组合来查看 JShell 导入了哪些包。如下面的截图所示,JShell 导入了几个使我们在 shell 中工作更方便的包,节省了我们不得不在方法中导入这些标准包的时间:

图片

如果您只想列出启动导入,您可以使用 /list -start 命令和选项组合。如下面的截图所示,每个启动导入都有一个 "s" 前缀,并且是按数字顺序排列的:

图片

在 JShell 中编辑

JShell 不是一个功能齐全的文本编辑器,但在 shell 中您可以做几件事情。本节为您提供了编辑技巧,分为修改文本、基本导航、历史导航和高级编辑命令。

修改文本

默认的文本编辑/输入模式是您输入的文本将出现在当前光标位置。当您想要删除文本时,您有几个选项可供选择。以下是一个完整的列表:

删除动作 PC 键盘组合 Mac 键盘组合
删除当前光标位置处的字符 Delete Delete
删除光标左侧的字符 Backspace Backspace
从光标位置删除到行尾的文本 Ctrl + K Cmd + K
从光标位置删除到当前单词的末尾 Alt + D Alt/Opt + D
从光标位置删除到前一个空白字符 Ctrl + W Cmd + W
在光标位置粘贴最近删除的文本 Ctrl + Y Cmd + Y
当使用Ctrl + Y(或在 Mac 上cmd* - E)时,您可以使用Alt + Y键盘组合来循环浏览之前删除的文本 Alt + Y Alt/Opt + Y

基本导航

虽然在 JShell 内部进行导航控制与大多数命令行编辑器相似,但列出基本导航控制列表是有帮助的:

键/键组合 导航动作
左箭头 向后移动一个字符
右箭头 向前移动一个字符
上箭头 向上移动一行,通过历史记录
下箭头 向下移动一行,通过历史记录
Return 输入(提交)当前行
Ctrl + A(Mac 上的cmd* - A 跳到当前行的开头
Ctrl + E(Mac 上的cmd* - E 跳到当前行的末尾
Alt + B 跳回一个单词
Alt + F 跳向前一个单词

历史导航

JShell 会记住您输入的片段和命令。它维护这个历史记录,以便您可以重复使用已经输入的片段和命令。要循环浏览片段和命令,您可以按住Ctrl键(Mac 上的cmd* - E),然后使用上下箭头键,直到看到您想要的片段或命令。

高级编辑命令

还有更多编辑选项,包括搜索功能、宏的创建和使用等。JShell 的编辑器基于 JLine2,这是一个用于解析控制台输入和编辑的 Java 库。您可以在以下网址了解更多关于 JLine2 的信息:github.com/jline/jline2/wiki/JLine-2.x-Wiki

使用脚本

到目前为止,您已经直接从键盘将数据输入到 JShell 中。您还有能力使用 JShell 脚本,这些脚本是一系列 JShell 命令和片段。其格式与其他脚本格式相同,每行一个命令。

在本节中,我们将探讨启动脚本,检查如何加载脚本,如何保存脚本,然后以 JShell 的高级脚本结束。

启动脚本

每次启动 JShell 时,都会加载启动脚本。这也发生在使用/reset/reload/env命令时。

默认情况下,JShell 使用 DEFAULT 启动脚本。如果你想使用不同的启动脚本,只需使用/set start <script>命令。以下是一个示例--/set start MyStartupScript.jsh。或者,你可以在命令提示符下使用jshell --start MyStartupScript.jsh命令来启动 JShell 并加载MyStartupScript.jsh JShell 启动脚本。

当你使用带有-retain选项的/set start <script>命令时,你是在告诉 JShell,下次你启动 JShell 时使用新的启动脚本。

加载脚本

在 JShell 中加载脚本可以通过以下方法之一完成:

  • 你可以使用/open命令以及脚本的名称作为参数。例如,如果我们的脚本名称是MyScript,我们会使用/open MyScript

  • 加载脚本的第二种方法是使用命令提示符下的jshell MyScript.jsh。这将启动 JShell 并加载MyScript.jsh JShell 脚本。

保存脚本

除了在外部编辑器中创建 JShell 脚本,我们还可以在 JShell 环境中创建它们。采用这种方法时,你需要使用/save命令来保存你的脚本。正如以下截图所示,/save命令至少需要一个文件名参数:

图片

/save命令提供了三种选项供你选择:

  • -all选项可以将所有代码片段的源代码保存到指定的文件中。

  • -history选项保存自 JShell 启动以来输入的所有命令和代码片段的顺序历史。JShell 能够执行此操作表明它维护了你输入的所有内容的记录。

  • -start选项将当前启动定义保存到指定的文件中。

使用 JShell 进行高级脚本编写

JShell 的局限性是什么?你可以用这个工具做很多事情,实际上你只受限于你的想象力和编程能力。

让我们看看一个可以用于从 JShell 脚本编译和运行 Java 程序的先进代码库:

    import java.util.concurrent.*
    import java.util.concurrent.*
    import java.util.stream.*
    import java.util.*

    void print2Console(String thetext) 
    { 
      System.out.println(thetext); 
      System.out.println("");
    }

    void runSomeProcess(String... args) throws Exception 
    { 
      String theProcess = 
        Arrays.asList(args).stream().collect(
         Collectors.joining(" ")); 
        print2Console("You asked me to run: '"+theProcess+"'"); 
        print2Console(""); 
        ProcessBuilder compileBuilder = new 
          ProcessBuilder(args).inheritIO(); 
        Process compileProc = compileBuilder.start(); 
        CompletableFuture<Process> compileTask =
         compileProc.onExit(); 
        compileTask.get();
    }

    print2Console("JShell session launched.")
    print2Console("Preparing to compile Sample.java. . . ")

    // run the Java Compiler to complete Sample.java
    runSomeProcess("javac", "Sample.java")
    print2Console("Compilation complete.")
    print2Console("Preparing to run Sample.class...")

    // run the Sample.class file
    runSomeProcess("java", "Sample")
    print2Console("Run Cycle compete.")

    // exit JShell
    print2Console("JShell Termination in progress...)
    print2Console("Session ended.")

    /exit

正如这个脚本所示,我们创建了一个runSomeProcess()方法,并可以使用它来显式编译和运行外部 Java 文件。

摘要

在本章中,我们探讨了 JShell,Java 9 的新读-求值-打印循环命令行工具。我们从有关该工具的简介开始,并仔细研究了读-求值-打印循环的概念。我们花了大量时间回顾 JShell 命令和命令行选项。我们的内容涵盖了反馈模式、资产列表和 shell 中的编辑的实际指南。我们还获得了使用脚本的经验。

在下一章中,我们将探讨 Java 9 的新默认垃圾回收器。具体来说,我们将探讨默认垃圾回收、已弃用的垃圾回收组合,并检查垃圾回收日志。

第八章:利用新的默认 G1 垃圾回收器

在上一章中,我们考察了Java ShellJShell),Java 9 的新读取-评估-打印循环REPL)命令行工具。我们从关于该工具的简介开始,并仔细研究了读取-评估-打印循环的概念。我们花费了大量时间回顾 JShell 命令和命令行选项。我们的覆盖范围包括关于反馈模式、资产列出和 shell 中的编辑的实际指南。我们还获得了使用脚本的经验。

在本章中,我们将深入探讨垃圾回收及其在 Java 9 中的处理方式。我们将从垃圾回收的概述开始,然后查看 Java 9 之前的特定细节。有了这些基础知识,我们将查看 Java 9 平台中的特定垃圾回收更改。最后,我们将探讨一些即使在 Java 9 之后仍然存在的垃圾回收问题。

本章涵盖了以下主题:

  • 垃圾回收概述

  • Java 9 之前的垃圾回收方案

  • 使用新的 Java 平台收集垃圾

  • 持续性问题

垃圾回收概述

垃圾回收是 Java 中用来释放未使用内存的机制。本质上,当创建一个对象时,会分配内存空间并专门用于该对象,直到没有任何引用指向它。在那个时刻,系统会释放内存。Java 为我们自动执行垃圾回收,这可能导致对内存使用和内存管理及系统性能方面的编程实践缺乏关注。

Java 的垃圾回收被认为是自动内存管理方案,因为程序员不需要指定对象为准备释放的。垃圾回收在低优先级线程上运行,正如你将在本章后面读到的,它有可变的执行周期。

在我们关于垃圾回收的概述中,我们将探讨以下概念:

  • 对象生命周期

  • 垃圾回收算法

  • 垃圾回收选项

  • 与垃圾回收相关的 Java 方法

我们将在接下来的章节中逐一探讨这些概念。

对象生命周期

为了完全理解 Java 的垃圾回收,我们需要查看对象的整个生命周期。因为 Java 中垃圾回收的核心是自动的,所以看到“垃圾回收”和“内存管理”作为对象生命周期的假设组成部分并不罕见。

我们将开始回顾对象的生命周期,从对象的创建开始。

对象创建

对象被声明和创建。当我们编写对象声明或声明对象时,我们是在声明一个名称或标识符,以便我们可以引用对象。例如,以下代码行声明myObjectName为类型CapuchinMonkey的对象名称。在此点,没有创建对象,也没有为其分配内存:

    CapuchinMonkey myObjectName;

我们使用new关键字来创建对象。以下示例说明了如何调用new操作来创建对象。这个操作会导致:

    myObjectName = new CapuchinMonkey();

当然,我们可以通过使用CapuchinMonkey myObjectName = new CapuchinMonkey();而不是CapuchinMonkey myObjectName;myObjectName = new CapuchinMonkey();来合并声明和创建语句。在前面的例子中,它们被分开是为了说明目的。

当一个对象被创建时,为存储该对象分配了特定数量的内存。分配的内存量可能因架构和 JVM 而异。

接下来,看看一个对象的中期生命周期。

对象中期

对象被创建,Java 为存储该对象分配系统内存。如果对象未被使用,分配给它的内存被认为是浪费的。这是我们想要避免的。即使是小型应用程序,这种类型的内存浪费也可能导致性能下降,甚至出现内存不足的问题。

我们的目标是释放或释放我们不再需要的任何先前分配的内存。幸运的是,在 Java 中,有一个处理此问题的机制。它被称为垃圾回收。

当一个对象,比如我们的myObjectName示例,不再有任何引用指向它时,系统将重新分配相关的内存。

对象销毁

Java 拥有一个在代码的阴暗角落(通常是一个低优先级线程)运行的垃圾回收器,并释放当前分配给未引用对象的内存,这一想法很有吸引力。那么,它是如何工作的呢?垃圾回收系统会监控对象,并在可能的情况下,计算每个对象的引用数量。

当一个对象没有引用时,当前运行的代码无法访问它,因此释放相关的内存是合理的。

术语内存泄漏指的是那些可能丢失或不当释放的小内存块。这些泄漏可以通过 Java 的垃圾回收避免。

垃圾回收算法

Java 虚拟机有几种垃圾回收算法或类型可供使用。在本节中,我们将介绍以下垃圾回收算法:

  • 标记和清除

  • CMS 垃圾回收

  • 序列垃圾回收

  • 并行垃圾回收

  • G1 垃圾回收

标记和清除

Java 的初始垃圾回收算法,标记和清除,使用了一个简单的两步过程:

  1. Java 的第一步,标记,是遍历所有具有可访问引用的对象,将这些对象标记为存活。

  2. 第二步,清除,涉及扫描海洋中任何未标记的对象。

如您所容易确定的,标记和清除算法似乎很有效,但由于这种方法的两步性质,可能不是非常高效。这最终导致 Java 垃圾回收系统效率大幅提高。

并发标记清除(CMS)垃圾回收

垃圾回收的 并发标记清除CMS)算法使用多个线程扫描堆内存。类似于标记和清除方法,它标记要删除的对象,然后进行清除以实际删除这些对象。这种垃圾回收方法本质上是一种升级的标记和清除方法。它被修改以利用更快的系统并提高了性能。

要手动调用您应用程序的并发标记清除垃圾回收算法,请使用以下命令行选项:

-XX:+UseConcMarkSweepGC 

如果您想使用并发标记清除垃圾回收算法并指定要使用的线程数,可以使用以下命令行选项。在以下示例中,我们告诉 Java 平台使用八个线程的并发标记清除垃圾回收算法:

-XX:ParallelCMSThreads=8

串行垃圾回收

Java 的串行垃圾回收在单个线程上工作。在执行时,它会冻结所有其他线程,直到垃圾回收操作完成。由于串行垃圾回收的线程冻结特性,它仅适用于非常小的程序。

要手动调用您应用程序的串行垃圾回收算法,请使用以下命令行选项:

-XX:+UseSerialGC

并行垃圾回收

在 Java 9 之前,并行垃圾回收算法是默认的垃圾回收器。它使用多个线程,但直到垃圾回收功能完成,应用程序中的所有非垃圾回收线程都会被冻结,就像串行垃圾回收算法一样。

G1 垃圾回收

G1 垃圾回收算法是为与大型内存堆一起使用而创建的。这种方法涉及将内存堆分割成区域。使用 G1 算法进行的垃圾回收与每个堆区域并行进行。

G1 算法的另一部分是,当内存被释放时,堆空间会被压缩。不幸的是,压缩操作使用的是 停止世界 方法。

G1 垃圾回收算法还根据需要收集最多垃圾的区域进行优先级排序。

G1 名称指的是垃圾优先。

要手动调用您应用程序的 G1 垃圾回收算法,请使用以下命令行选项:

-XX:+UseG1GC

垃圾回收选项

这里是 JVM 大小选项列表:

大小描述 JVM 选项标志
设置初始堆大小(年轻代空间加老年代空间)。 -XX:InitialHeapSize=3g
设置最大堆大小(年轻代空间加老年代空间)。 -XX:MaxHeapSize=3g
设置初始和最大堆大小(年轻代空间加老年代空间)。 -Xms2048m -Xmx3g
设置年轻代的大小。 -XX:NewSize=128m
设置年轻代的最大大小。 -XX:MaxNewSize=128m
设置年轻空间大小。使用年轻空间与老年代空间的比例。在右侧的示例标志中,3表示年轻空间将是老年代空间的三分之一。 -XX:NewRation=3
设置单存活空间大小作为 Eden 空间大小的部分。 -XX:SurvivorRatio=15
设置永久空间初始大小。 -XX:PermSize=512m
设置永久空间的最大大小。 -XX:MaxPermSize=512m
设置每个线程专用的堆栈区域大小(以 bytes 为单位)。 -Xss512k
设置每个线程专用的堆栈区域大小(以 Kbytes 为单位)。 -XX:ThreadStackSize=512
设置 JVM 可用的堆外内存的最大大小。 -XX:MaxDirectMemorySize=3g

这里是年轻代垃圾收集选项列表:

年轻代垃圾收集调整选项 标志
设置对象从年轻代晋升到老年代之前收集的初始次数。这被称为存活阈值 -XX:InitialTenuringThreshold=16
设置最大存活阈值。 -XX:MaxTenuringThreshold=30
设置在年轻空间中允许分配的最大对象大小。如果一个对象大于最大大小,它将被分配到老年代空间,并绕过年轻空间。 -XX:PretenureSizeThreshold=3m
这可以用来将所有在年轻代收集中存活的年轻对象晋升到老年代空间。 -XX:+AlwaysTenure
使用此标志,只要存活空间有足够的空间,年轻空间的对象永远不会晋升到老年代空间。 -XX:+NeverTenure
我们可以在年轻空间中指示使用线程局部分配块。这是默认启用的。 -XX:+UseTLAB
切换此选项以允许 JVM 自适应调整线程的TLAB线程局部分配块)大小。 -XX:+ResizeTLAB
设置线程 TLAB 的初始大小。 -XX:TLABSize=2m
设置 TLAB 的最小允许大小。 -XX:MinTLABSize=128k

这里是并发标记清除CMS)调整选项列表:

CMS 调整选项 标志
指示您只想使用占用率作为启动 CMS 收集操作的准则。 -XX:+UseCMSInitiatingOccupancyOnly
设置 CMS 生成占用百分比以启动 CMS 收集周期。如果您指定一个负数,您是在告诉 JVM 您想使用CMSTriggerRatio -XX:CMSInitiatingOccupancyFraction=70
设置您想要启动 CMS 收集以进行引导收集统计信息的 CMS 生成占用百分比。 -XX:CMSBootstrapOccupancy=10
这是 CMS 生成中MinHeapFreeRatio的百分比,在 CMS 周期开始之前分配。 -XX:CMSTriggerRatio=70
设置在启动 CMS 收集周期之前分配到 CMS 永久生成中的MinHeapFreeRatio的百分比。 -XX:CMSTriggerPermRatio=90
这是触发 CMS 收集后的等待持续时间。使用此参数指定 CMS 被允许等待年轻收集的时间长度。-XX:CMSWaitDuration=2000
启用并行 remark。-XX:+CMSParallelRemarkEnabled
启用幸存空间的并行 remark。-XX:+CMSParallelSurvivorRemarkEnabled
你可以使用此选项在 remark 阶段之前强制进行年轻收集。-XX:+CMSScavengeBeforeRemark
使用此选项以防止 Eden 使用低于阈值时安排 remark。-XX:+CMSScheduleRemarkEdenSizeThreshold
设置你想要 CMS 尝试安排 remark 暂停的 Eden 占用百分比。-XX:CMSScheduleRemarkEdenPenetration=20
在年轻代占用达到你想要安排 remark 的大小(在我们的示例中为右侧的大小)的 1/4 之前,你想要开始采样 Eden 顶部。-XX:CMSScheduleRemarkSamplingRatio=4
你可以选择 remark 之后的验证的variant=1variant=2-XX:CMSRemarkVerifyVariant=1
选择使用并行算法进行年轻空间收集。-XX:+UseParNewGC
启用并发阶段的多线程使用。-XX:+CMSConcurrentMTEnabled
设置用于并发阶段的并行线程数。-XX:ConcGCThreads=2
设置你想要用于stop-the-world阶段的并行线程数。-XX:ParallelGCThreads=2
你可以启用增量 CMSiCMS)模式。-XX:+CMSIncrementalMode
如果此选项未启用,CMS 将不会清理永久空间。-XX:+CMSClassUnloadingEnabled
这允许System.gc()触发并发收集而不是完整的垃圾收集周期。-XX:+ExplicitGCInvokesConcurrent
这允许System.gc()触发永久空间的并发收集。-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses

iCMS(增量并发标记清除)模式适用于 CPU 数量较少的服务器。它不应在现代硬件上使用。

这里有一些杂项垃圾收集选项:

杂项垃圾收集选项 标志
这将导致 JVM 忽略应用程序对System.gc()方法的调用。-XX:+DisableExplicitGC
这是堆中每 MB 空闲空间中(软引用)存活时间的毫秒数。-XX:SoftRefLRUPolicyMSPerMB=2000
这是用于限制在抛出OutOfMemory错误之前垃圾收集所花费时间的使用策略-XX:+UseGCOverheadLimit
这限制了在抛出OutOfMemory错误之前垃圾收集所花费的时间比例。这通常与GCHeapFreeLimit一起使用。-XX:GCTimeLimit=95
这设置了在抛出OutOfMemory错误之前,在完全垃圾收集后堆中空闲空间的最低百分比。这通常与GCTimeLimit一起使用。-XX:GCHeapFreeLimit=5

最后,这里有一些 G1 特定的选项。请注意,这些选项从 JVM 6u26 开始都得到了支持:

G1 垃圾回收选项 标志
堆区域的大小。默认为 2,048,可接受的范围是 1 MiB 到 32 MiB。 -XX:G1HeapRegionSize=16m
这是信心系数暂停预测启发式算法。 -XX:G1ConfidencePercent=75
这决定了堆的最小保留量。 -XX:G1ReservePercent=5
这是每个 MMU 的垃圾回收时间——时间片(以毫秒为单位)。 -XX:MaxGCPauseMillis=100
这是每个 MMU 的暂停间隔时间片(以毫秒为单位)。 -XX:GCPauseIntervalMillis=200

MiB 代表 Mebibyte,它是数字信息的字节数的倍数。

与垃圾回收相关的 Java 方法

让我们看看与垃圾回收相关的两个具体方法。

系统的 gc() 方法

虽然在 Java 中垃圾回收是自动的,但你可以通过显式调用 java.lang.System.gc() 方法来帮助调试过程。这个方法不接受任何参数,也不返回任何值。这是一个显式调用,它会运行 Java 的垃圾回收器。以下是一个示例实现:

    System.gc();
    System.out.println("Garbage collected and unused 
     memory has been deallocated.");

让我们来看一个更深入的例子。在下面的代码中,我们首先通过 Runtime myRuntime = Runtime.getRuntime(); 创建 Runtime 的一个实例,它返回一个单例。这使我们能够访问 JVM。在打印一些标题信息和初始内存统计信息后,我们创建了一个大小为 300000ArrayList。然后,我们创建了一个循环,生成 100000 个数组列表对象。最后,我们通过三次输出,在每次之间暂停 1 秒,请求 JVM 调用垃圾回收器。以下是源代码:

    package MyGarbageCollectionSuite;

    import java.util.ArrayList;
    import java.util.concurrent.TimeUnit;

    public class GCVerificationTest 
    {
      public static void main(String[] args) throws 
       InterruptedException 
       {
         // Obtain a Runtime instance (to communicate
          with the JVM)
         Runtime myRuntime = Runtime.getRuntime();

         // Set header information and output initial 
          memory stats
         System.out.println("Garbage Collection
          Verification Test");
         System.out.println("-----------------------------
          -----------------------------");
         System.out.println("Initial JVM Memory: " + 
          myRuntime.totalMemory() + 
            "tFree Memory: " + myRuntime.freeMemory());

         // Use a bunch of memory
         ArrayList<Integer> AccountNumbers = new 
          ArrayList<>(300000);
         for (int i = 0; i < 100000; i++)
         {
           AccountNumbers = new ArrayList<>(3000);
           AccountNumbers = null;
         }

         // Provide update with with three passes
         for (int i = 0; i < 3; i++)
         {
           System.out.println("---------------------------
            -----------");
           System.out.println("Free Memory before
            collection number " + 
              (i+1) + ": " + myRuntime.freeMemory());
           System.gc();
           System.out.println("Free Memory after
            collection number " + 
              (i+1) + ": " + myRuntime.freeMemory());
           TimeUnit.SECONDS.sleep(1); // delay thread 
            1 second
         }

       }

    }

如您从以下输出中看到的,垃圾回收器在第一次甚至第二次遍历中并没有重新分配所有的 '垃圾':

图片

使用 System.gc() 方法调用垃圾回收器有一个替代方案。在我们的例子中,我们本可以使用 myRuntime.gc(),我们之前的单例示例。

finalize() 方法

你可以将 Java 的垃圾回收器想象成一个死神。当它从内存中移除某个东西时,它就消失了。这个所谓的死神并非没有同情心,因为它为每个方法提供了最后的遗言。对象通过 finalize() 方法来传达他们的 最后遗言。如果一个对象有 finalize() 方法,垃圾回收器在移除对象和释放相关内存之前会调用它。这个方法不接受任何参数,并且返回类型为 void

finalize()方法只会被调用一次,其运行时可能会有变化。当然,该方法在移除之前会被调用,但垃圾回收器何时运行取决于系统。例如,如果您有一个相对较小的应用程序,在运行内存丰富的系统上,垃圾回收器可能根本不会运行。那么,为什么还要包含一个finalize()方法呢?被认为是不良的编程实践去重写finalize()方法。尽管如此,如果需要,您仍然可以使用该方法。实际上,您可以在那里添加代码,为您的对象添加引用,以确保它不会被垃圾回收器移除。再次强调,这并不建议。

因为在 Java 中,所有对象,即使是您自己创建的对象,也都是java.lang.Object的子类,所以 Java 中的每个对象都有一个finalize()方法。

即使垃圾回收器非常复杂,它可能也不会像您希望的那样关闭数据库、文件或网络连接。如果您的应用程序在对象被收集时需要特定的考虑,您可以重写对象的finalize()方法。

这里有一个示例实现,演示了您可能想要重写对象finalize()方法的用例:

    public class Animal 
    {
      private static String animalName;
      private static String animalBreed;
      private static int objectTally = 0;

      // constructor
      public Animal(String name, String type) 
      {
        animalName = name;
        animalBreed = type;

       // increment count of object
        ++objectTally;
      }

      protected void finalize()
      {
        // decrement object count each time this method
        // is called by the garbage collector
        --objectTally;

        //Provide output to user
        System.out.println(animalName + " has been 
         removed from memory.");

        // condition for 1 animal (use singular form)
        if (objectTally == 1) 
        {
          System.out.println("You have " + objectTally + "
           animal remaining.");
        }

        // condition for 0 or greater than 1 
         animals (use plural form)
        else 
        {
          System.out.println("You have " + objectTally + "
           animals remaining.");
        }

      }

    }

如您在前面代码中看到的,每当创建一个Animal类型的对象时,objectTally计数就会增加,当垃圾回收器移除一个对象时,计数就会减少。

通常不建议重写对象的finalize()方法。finalize()方法通常应该声明为protected

Java 9 之前的垃圾回收

Java 的垃圾回收并非从 Java 9 开始,它自 Java 的初始发布以来就存在了。Java 长期以来一直拥有一个复杂的垃圾回收系统,它是自动的,在后台运行。我们所说的在后台运行,是指垃圾回收过程在空闲时间运行。

空闲时间指的是输入/输出之间的时间,例如键盘输入、鼠标点击和输出生成之间的时间。

这种自动垃圾回收一直是开发者选择 Java 作为编程解决方案的关键因素之一。其他编程语言,如 C#和 Objective-C,在 Java 平台成功之后也实现了垃圾回收。

在我们查看 Java 9 平台垃圾回收的更改之前,让我们先看看以下列出的概念:

  • 可视化垃圾回收

  • Java 8 中的垃圾回收升级

  • 案例研究 - 使用 Java 编写的游戏

可视化垃圾回收

Garbage:
    001 String var = new String("G");
    002 var += "a";
    003 var += "r";
    004 var += "b";
    005 var += "a";
    006 var += "g";
    007 var += "e";
    008 System.out.println("Your completed String
     is: " + var + ".");

显然,前面的代码生成了以下输出:

    Your completed String is Garbage.

可能不清楚的是,示例代码导致五个未引用的字符串对象。这部分原因是字符串是不可变的。如您在以下插图中所见,随着每行代码的连续执行,引用的对象被更新,而另一个对象则变为未引用:

列出的先前未引用的对象当然不会打破内存银行,但它表明大量未引用的对象可以迅速积累。

Java 8 的垃圾回收升级

截至 Java 8,默认的垃圾回收算法是并行垃圾回收器。Java 8 的发布带来了对 G1 垃圾回收系统的某些改进。这些改进之一是能够使用以下命令行选项通过删除重复的字符串值来优化堆内存:

-XX:+UseStringDeduplication

当 G1 垃圾回收器看到字符串时,它可以查看字符数组。然后它将值存储起来,并使用一个新的弱引用将字符数组存储起来。如果 G1 垃圾回收器找到一个具有相同哈希码的字符串,它将逐字符比较这两个字符串。如果找到匹配项,两个字符串最终都会指向同一个字符数组。具体来说,第一个字符串将指向第二个字符串的字符数组。

此方法可能需要大量的处理开销,并且只有在被认为有益或绝对必要时才应使用。

案例研究 - 使用 Java 编写的游戏

多玩家游戏需要广泛的管理技术,包括服务器和客户端系统。JVM 在一个低优先级的线程中运行垃圾回收线程,并定期运行。服务器管理员以前使用现在已过时的 -Xincgc 命令行选项来避免当服务器过载时发生的服务器停滞。目标是让垃圾回收更频繁地运行,并且每次执行周期都更短。

在考虑内存使用和垃圾回收时,尽可能在目标系统上使用最少的内存,并在可行范围内将垃圾回收的暂停时间限制到最小。这些提示对于游戏、模拟和其他需要实时性能的应用程序尤为重要。

JVM 管理存储 Java 内存的区域。JVM 默认以一个小堆开始,随着更多对象的创建而增长。堆有两个分区--年轻代和旧代。当对象最初创建时,它们在年轻代中创建。持久对象被移动到旧代。对象的创建通常非常快,只需指针增量即可。年轻代的处理速度比旧代快得多。这对于整体应用或在我们的情况下,游戏的效率来说非常重要。

对于我们来说,监控游戏的内存使用情况和垃圾回收发生的时间变得非常重要。为了监控垃圾回收,我们可以在启动游戏时添加详细标志(-verbose:gc),例如以下示例:

java -verbose:gc MyJavaGameClass

JVM 将为每次垃圾回收提供一行格式化的输出。以下是详细 GC 输出的格式:

    [<TYPE> <MEMORY USED BEFORE> -> MEMORY USED AFTER
     (TOTAL HEAP SIZE), <TIME>]

让我们看看两个例子。在这个第一个例子中,我们看到GC类型指的是我们之前讨论过的年轻代:

    [GC 31924K -> 29732K(42234K), 0.0019319 secs]

在这个第二个例子中,Full GC表示对内存堆的旧年代分区进行了垃圾收集操作:

    [Full GC 29732K -> 10911K(42234K), 0.0319319 secs]

您可以使用-XX:+PrintGCDetails选项从垃圾收集器获取更详细的信息,如下所示:

java -verbose:gc -XX:+PrintGCDetails MyJavaGameClass

使用新的 Java 平台收集垃圾

Java 从发布之初就提供了自动垃圾收集功能,使其成为许多程序员的开发平台选择。在其它编程语言中避免手动内存管理变得很常见。我们已经深入研究了垃圾收集系统,包括 JVM 使用的各种方法或算法。Java 9 对垃圾收集系统进行了一些相关更改,并成为三个Java 增强计划JEP)问题的焦点。这些问题如下所示:

  • 默认垃圾收集(JEP 248)

  • 废弃的垃圾收集组合(JEP 214)

  • 统一垃圾收集日志(JEP 271)

我们将在以下各节中回顾这些垃圾收集概念及其相应的Java 增强计划JEP)问题。

默认垃圾收集

我们之前详细介绍了 Java 9 之前 JVM 使用的以下垃圾收集方法。这些仍然是可能的垃圾收集算法:

  • CMS 垃圾收集

  • 串行垃圾收集

  • 并行垃圾收集

  • G1 垃圾收集

让我们简要回顾一下这些方法:

  • CMS 垃圾收集:CMS 垃圾收集算法使用多个线程扫描堆内存。使用这种方法,JVM 标记要删除的对象,然后进行清扫以实际删除它们。

  • 串行垃圾收集:这种方法在单个线程上使用无线程冻结模式。当垃圾收集正在进行时,它会冻结所有其他线程,直到垃圾收集操作完成。由于串行垃圾收集的线程冻结特性,它仅适用于非常小的程序。

  • 并行垃圾收集:这种方法使用多个线程,但在垃圾收集功能完成之前,应用程序中的所有非垃圾收集线程都会被冻结,就像串行垃圾收集算法一样。

  • G1 垃圾收集:这是具有以下特征的垃圾收集算法:

    • 与大内存堆一起使用

    • 包括将内存堆分割成区域

    • 与每个堆区域并行进行

    • 在内存释放时压缩堆空间

    • 使用停止世界方法进行压缩操作

    • 根据需要收集的垃圾量最多的区域进行优先级排序

在 Java 9 之前,并行垃圾收集算法是默认的垃圾收集器。在 Java 9 中,G1 垃圾收集器是 Java 内存管理系统的新的默认实现。这对于 32 位和 64 位的服务器配置都是正确的。

Oracle 评估认为,由于 G1 垃圾收集器具有低暂停时间的特点,它比并行方法是一个更好的垃圾收集方法。这一变化基于以下概念:

  • 限制延迟很重要

  • 最大化吞吐量不如限制延迟重要

  • G1 垃圾收集算法是稳定的

在将 G1 垃圾收集方法作为默认方法而不是并行方法时,涉及两个假设:

  • 将 G1 作为默认的垃圾回收方法将显著增加其使用率。这种增加的使用率可能会揭示之前在 Java 9 之前未意识到的性能或稳定性问题。

  • G1 方法比并行方法更占用处理器资源。在某些用例中,这可能会有些问题。

表面上看,这个变化可能对 Java 9 来说是一个巨大的进步,而且这完全可能是事实。然而,在盲目接受这个新的默认收集方法时,应该谨慎行事。建议在切换到 G1 时测试系统,以确保您的应用程序不会因性能下降或由 G1 使用引起的问题而受到影响。如前所述,G1 没有像并行方法那样受益于广泛的测试。

最后一点关于缺乏广泛测试的重要性是显著的。在 Java 9 中将 G1 作为默认的自动内存管理(垃圾收集)系统,相当于将开发者变成了不知情的测试者。虽然预期不会有重大问题,但知道在使用 Java 9 中的 G1 时可能存在性能和稳定性问题,这将更加重视测试您的 Java 9 应用程序。

弃用的垃圾回收组合

Oracle 在从 Java 平台的新版本中移除功能、API 和库之前,一直很擅长弃用它们。有了这个方案,Java 8 中弃用的语言组件将在 Java 9 中被移除。有几个被认为是很少使用并已在 Java 8 中弃用的垃圾回收组合。这些组合,如列表所示,已在 Java 9 中被移除:

  • DefNew + CMS

  • ParNew + SerialOld

  • 增量内容管理系统

这些组合,除了很少使用外,还为垃圾回收系统引入了一个不必要的复杂性级别。这导致了系统资源的额外消耗,而没有为用户或开发者提供相应的利益。

以下列出的垃圾回收配置受到了之前在 Java 8 平台上提到的弃用的影响:

垃圾收集配置 标志(s)
DefNew + CMS -XX:+UseParNewGC``-XX:UseConcMarkSweepGC
ParNew + SerialOld -XX:+UseParNewGC
ParNew + SerialOld -XX:+UseParNewGC
ParNew + iCMS -XX:+CMSIncrementalMode -XX:+UseConcMarkSweepGC
Defnew + ICMS -XX:+CMSIncrementalMode -XX:+UseConcMarkSweepGC -XX:-UseParNewGC

Java 增强计划 214JEP 214)移除了在 JDK 8 中过时的垃圾回收组合。这些组合在上文中列出,以及控制这些组合的标志。此外,移除了启用 CMS 前台收集的标志,并且这些标志不在 JDK 9 中。以下列出了这些标志:

垃圾回收组合 标志
CMS 前台 -XX:+UseCMSCompactAtFullCollection
CMS 前台 -XX+CMSFullGCsBeforeCompaction
CMS 前台 -XX+UseCMSCollectionPassing

移除已过时的垃圾回收组合的唯一缺点是,使用本节中列出的任何标志的 JVM 启动文件的应用程序,需要修改它们的 JVM 启动文件以删除或替换旧标志。

统一垃圾回收日志

标题为 统一垃圾回收日志Java 增强计划#271JEP-271)旨在重新实现垃圾回收日志,使用之前在 JEP-158 中引入的统一 JVM 日志框架。因此,让我们首先回顾一下统一 JVM 日志(JEP-158)。

统一 JVM 日志(JEP-158)

创建一个针对 JVM 所有日志操作的统一命令行选项集是 JEP-158 的核心目标。以下是 JEP 目标的高级列表:

  • 为所有日志操作创建一个 JVM 范围的命令行选项集

  • 使用分类标签进行日志记录

  • 提供六个日志级别:

    • Error

    • Warning

    • Information

    • Debug

    • Trace

    • Develop

这不是目标列表的详尽无遗。我们将在第十四章,命令行标志中更详细地讨论 JEP-158。

在日志的上下文中,对 JVM 的更改可以分为以下几类:

  • Tags

  • Levels

  • Decorations

  • Output

  • Command-line options

让我们简要地看看这些类别。

Tags

日志标签在 JVM 中标识,如果需要,可以在源代码中更改。标签应该是自我标识的,例如gc代表垃圾回收。

Levels

每个日志消息都有一个关联的级别。如前所述,级别是错误、警告、信息、调试、跟踪和开发。以下图表显示了级别如何根据记录的信息量具有递增的详细程度:

图片

Decorations

在 Java 9 的日志框架的上下文中,装饰是关于日志消息的元数据。以下是可用的装饰的字母顺序列表:

  • level

  • pid

  • tags

  • tid

  • time

  • timemillis

  • timenanos

  • uptime

  • uptimemillis

  • uptimenanos

关于这些装饰的解释,请参阅第十四章,命令行标志

Output

Java 9 日志框架支持三种类型的输出:

  • stderr: 提供输出到标准错误

  • stdout: 提供输出到标准输出

  • 文本文件:将输出写入文本文件

命令行选项

日志框架新增了一个命令行选项,以提供对 JVM 日志操作的总体控制。-Xlog命令行选项具有广泛的参数和可能性。以下是一个示例:

-Xlog:gc+rt*=debug

在此示例中,我们正在告诉 JVM 执行以下操作:

  • 至少记录带有 gcrt 标签的所有消息

  • 使用 debug 级别

  • 输出到 stdout

统一 GC 日志(JEP-271)

现在我们对 Java 9 日志框架的更改有了总体了解,让我们看看 JEP-271 引入了哪些变化。在本节中,我们将探讨以下领域:

  • 垃圾回收日志选项

  • gc 标签

  • 其他注意事项

垃圾回收日志选项

在 Java 9 日志框架引入之前,以下是我们可以使用的垃圾回收日志选项和标志列表:

垃圾回收日志选项 JVM 选项标志
这将打印基本的垃圾回收信息。 -verbose:gc-XX:+PrintGC
这将打印更详细的垃圾回收信息。 -XX:+PrintGCDetails
您可以为每次垃圾回收事件打印时间戳。秒数是连续的,并从 JVM 启动时间开始。 -XX:+PrintGCTimeStamps
您可以打印每次垃圾回收事件的日期戳。示例格式:2017-07-26T03:19:00.319+400:[GC . . . ] -XX:+PrintGCDateStamps
您可以使用此标志来打印单个垃圾回收工作线程任务的时戳。 -XX:+PrintGCTaskTimeStamps
使用此选项可以将垃圾回收输出重定向到文件而不是控制台。 -Xloggc:
您可以在每次收集周期后打印有关年轻空间的详细信息。 -XX:+PrintTenuringDistribution
您可以使用此标志来打印 TLAB 分配统计信息。 -XX:+PrintTLAB
使用此标志,您可以在 stop-the-world 暂停期间打印引用处理的时间(即弱、软等)。 -XX:+PrintReferenceGC
这将报告垃圾回收是否正在等待原生代码取消内存中对象的固定。 -XX:+PrintJNIGCStalls
这将在每次 stop-the-world 暂停后打印暂停摘要。 -XX:+PrintGCApplicationStoppedTime
此标志将打印垃圾回收每个并发阶段的时间。 -XX:+PrintGCApplicationConcurrentTime
使用此标志将在完全垃圾回收后打印类直方图。 -XX:+PrintClassHistogramAfterFullGC
使用此标志将在完全垃圾回收前打印类直方图。 -XX:+PrintClassHistogramBeforeFullGC
这将在完全垃圾回收后创建一个堆转储文件。 -XX:+HeapDumpAfterFullGC
这将在完全垃圾回收前创建一个堆转储文件。 -XX:+HeapDumpBeforeFullGC
这将在内存不足的情况下创建堆转储文件。 -XX:+HeapDumpOnOutOfMemoryError
您使用此标志来指定您希望在系统上保存堆转储文件的路径。 -XX:HeapDumpPath=<path>
您可以使用此来打印 CMS 统计信息,if n >= 1。仅适用于 CMS。 -XX:PrintCMSStatistics=2
这将打印 CMS 初始化详细信息。仅适用于 CMS。 -XX:+PrintCMSInitiationStatistics
您可以使用此标志来打印有关空闲列表的附加信息。仅适用于 CMS。 -XX:PrintFLSStatistics=2
您可以使用此标志来打印有关空闲列表的附加信息。仅适用于 CMS。 -XX:PrintFLSCensus=2
您可以使用此标志在晋升(年轻到老年代)失败后打印详细的诊断信息。仅适用于 CMS。 -XX:+PrintPromotionFailure
此标志允许您在晋升(年轻到老年代)失败时转储有关 CMS 老年代状态的有关信息。仅适用于 CMS。 -XX:+CMSDumpAtPromotionFailure
当使用 -XX:+CMSDumpAtPromotionFailure 标志时,您可以使用 -XX:+CMSPrintChunksInDump 来包含有关空闲块的其他详细信息。仅适用于 CMS。 -XX:+CMSPrintChunksInDump
当使用 -XX:+CMSPrintChunksInDump 标志时,您可以使用 -XX:+CMSPrintObjectsInDump 标志包含有关分配对象的其他信息。仅适用于 CMS。 -XX:+CMSPrintObjectsInDump

gc 标签

我们可以使用 gc 标签与 -Xlog 选项一起使用,以通知 JVM 只在 info 级别记录带有 gc 标签的项目。如您所回忆的那样,这与使用 -XX:+PrintGC 类似。使用这两个选项时,JVM 将为每次垃圾回收操作记录一行。

重要的是要注意,gc 标签并不是打算单独使用的;相反,建议与其他标签一起使用。

我们可以创建宏来向我们的垃圾回收日志添加逻辑。以下是日志宏的一般语法:

    log_<level>(Tag1[,...])(fmtstr, ...)

这里是一个日志宏的示例:

    log_debug(gc, classloading)("Number of objects
     loaded: %d.", object_count)

以下是一个示例日志宏框架,展示了您如何使用新的 Java 9 日志框架创建脚本以实现更精确的日志记录:

    LogHandle(gc, rt, classunloading) log;
    if (log.is_error())
    {
      // do something specific regarding the 'error' level
    }

    if (log.is_warning())
    {
      // do something specific regarding the 'warning'
      level
    }

    if (log.is_info())
    {
      // do something specific regarding the 'info' level
    }

    if (log.is_debug())
    {
      // do something specific regarding the 'debug' level
    }

    if (log.is_trace())
    {
      // do something specific regarding the 'trace' level
    }

其他注意事项

在垃圾收集日志方面,以下是一些需要考虑的附加项目:

  • 使用新的 -Xlog:gc 应该会产生与 -XX:+PrintGCDetails 命令行选项和标志配对类似的结果

  • 新的 trace 级别提供了与 verbose 标志之前提供的详细程度相同的信息

持续性问题

即使 Java 9 的出现,Java 的垃圾回收系统也有其缺点。因为它是一个自动过程,我们无法完全控制收集器何时运行。作为开发者,我们无法控制垃圾回收,JVM 才是。JVM 决定何时运行垃圾回收。正如您在本章前面所见,我们可以使用System.gc()方法请求 JVM 运行垃圾回收。尽管我们使用了这种方法,但我们保证我们的请求将被尊重,或者将及时得到遵守。

在本章的早期部分,我们回顾了几个垃圾回收的方法和算法。我们讨论了作为开发者,我们如何控制这个过程。这假设我们有能力控制垃圾回收。即使我们指定了特定的垃圾回收技术,例如使用-XX:+UseConcMarkSweepGC进行 CMS 垃圾回收,也不能保证 JVM 会使用该实现。因此,我们可以尽最大努力控制垃圾回收器的工作方式,但应记住 JVM 在如何、何时以及是否进行垃圾回收方面拥有最终决定权。

我们对垃圾回收的完全控制不足强调了编写考虑内存管理的有效代码的重要性。在接下来的几节中,我们将探讨如何编写代码,使对象明确地符合 JVM 的垃圾回收条件。

使对象符合垃圾回收条件

使对象可供垃圾回收的一个简单方法是将引用变量赋值为null,该引用变量指向对象。让我们回顾这个例子:

    package MyGarbageCollectionSuite;

    public class GarbageCollectionExperimentOne 
    {
      public static void main(String[] args) 
      {

        // Declare and create new object.
        String junk = new String("Pile of Junk");

        // Output to demonstrate that the object
        has an active reference
        // and is not eligible for garbage collection.
        System.out.println(junk);

        // Set the reference variable to null.
        junk = null;

        // The String object junk is now eligible
        for garbage collection.

      }

    }

如代码注释所示,一旦字符串对象引用变量被设置为 null,在这种情况下使用junk = null;语句,对象就可供垃圾回收。

在我们的下一个示例中,我们将通过将引用变量设置为指向不同的对象来放弃一个对象。正如您在以下代码中所见,这会导致第一个对象可用于垃圾回收:

    package MyGarbageCollectionSuite;

    public class GarbageCollectionExperimentTwo
    {
      public static void main(String[] args)
      {
        // Declare and create the first object.
        String junk1 = new String("The first pile of
         Junk");

        // Declare and create the second object.
        String junk2 = new String("The second pile of 
         Junk");

        // Output to demonstrate that both objects have
        active references
        // and are not eligible for garbage collection.
        System.out.println(junk1);
        System.out.println(junk2);

        // Set the first object's reference to the
         second object.
        junk1 = junk2;

        // The String "The first pile of Junk" is now
         eligible for garbage collection.

      }

    }

让我们回顾一种使对象可供垃圾回收的最终方法。在这个例子中,我们有一个单例变量(objectNbr),它是一个指向GarbageCollectionExperimentThree类实例的引用变量。该类除了创建指向GarbageCollectionExperimentThree类实例的额外引用变量外,没有做任何有趣的事情。在我们的例子中,我们将objectNbr2objectNbr3objectNbr4objectNbr5引用设置为null。尽管这些对象有实例变量并且可以相互引用,但通过将它们的引用设置为null,它们(objectNbr2objectNbr3objectNbr4objectNbr5)的可访问性在类外部已被终止。这使得它们(objectNbr2objectNbr3objectNbr4objectNbr5)符合垃圾回收条件:

    package MyGarbageCollectionSuite;
    {

      // instance variable
      GarbageCollectionExperimentThree objectNbr;

      public static void main(String[] args) 
      {
        GarbageCollectionExperimentThree objectNbr2 = new
         GarbageCollectionExperimentThree();
        GarbageCollectionExperimentThree objectNbr3 = new
         GarbageCollectionExperimentThree();
        GarbageCollectionExperimentThree objectNbr4 = new
         GarbageCollectionExperimentThree();
        GarbageCollectionExperimentThree objectNbr5 = new
         GarbageCollectionExperimentThree();
        GarbageCollectionExperimentThree objectNbr6 = new
         GarbageCollectionExperimentThree();
        GarbageCollectionExperimentThree objectNbr7 = new
         GarbageCollectionExperimentThree();

        // set objectNbr2 to refer to objectNbr3
        objectNbr2.objectNbr = objectNbr3;

        // set objectNbr3 to refer to objectNbr4
        objectNbr3.objectNbr = objectNbr4;

        // set objectNbr4 to refer to objectNbr5
        objectNbr4.objectNbr = objectNbr5;

        // set objectNbr5 to refer to objectNbr2
        objectNbr5.objectNbr = objectNbr2;

        // set selected references to null
        objectNbr2 = null;
        objectNbr3 = null;
        objectNbr4 = null;
        objectNbr5 = null;

      }

    }

摘要

在本章中,我们对垃圾回收作为一个关键的 Java 9 平台组件进行了深入回顾。我们的回顾包括对象生命周期、垃圾回收算法、垃圾回收选项以及与垃圾回收相关的方法。我们探讨了 Java 8 中垃圾回收的升级,并研究了一个案例研究以帮助我们理解现代垃圾回收。然后,我们将注意力转向了新 Java 9 平台对垃圾回收的变更。我们对 Java 9 中的垃圾回收的探索包括了对默认垃圾回收、已弃用的垃圾回收组合以及统一垃圾回收日志的考察。我们通过查看一些即使在 Java 9 之后仍然存在的垃圾回收问题来结束对垃圾回收的探索。

在下一章中,我们将探讨如何使用Java Microbenchmark HarnessJMH),一个用于编写 JVM 基准测试的 Java 工具库,来编写性能测试。

第九章:使用 JMH 进行微基准测试的应用

在上一章中,我们深入回顾了垃圾收集,包括对象生命周期、垃圾收集算法、垃圾收集选项以及与垃圾收集相关的方法。我们简要地看了 Java 8 中垃圾收集的升级,并专注于新 Java 9 平台的变化。我们对 Java 9 中的垃圾收集的探索包括默认垃圾收集、已弃用的垃圾收集组合、统一的垃圾收集日志以及即使在 Java 9 之后仍然存在的垃圾收集问题。

在本章中,我们将探讨如何使用Java 微基准工具JMH),这是一个用于编写针对Java 虚拟机JVM)的基准测试的 Java 工具库。我们将使用 Maven 和 JMH 来帮助说明使用新 Java 9 平台进行微基准测试的强大功能。

具体来说,我们将涵盖以下主题:

  • 微基准测试概述

  • 使用 Maven 进行微基准测试

  • 基准测试选项

  • 避免微基准测试陷阱的技术

微基准测试概述

微基准测试用于测试系统的性能。这与宏观基准测试不同,宏观基准测试在不同的平台上运行测试以比较效率和进行后续分析。在微基准测试中,我们通常针对一个系统上的特定代码片段进行测试,例如一个方法或循环。微基准测试的主要目的是在我们的代码中识别优化机会。

基准测试有多种方法,我们将重点介绍使用 JMH 工具。那么,为什么要进行基准测试呢?开发者并不总是关心性能问题,除非性能是一个明确的要求。这可能导致部署后的意外,如果微基准测试作为开发过程的一部分进行,这些意外本可以避免。

微基准测试发生在过程的几个阶段。如图所示,该过程包括设计、实现、执行、分析和增强:

图片

设计阶段,我们确定我们的目标并相应地设计微基准测试。在实现阶段,我们编写微基准测试,然后在执行阶段实际运行测试。有了微基准测试的结果,我们在分析阶段解释和分析结果。这导致在增强阶段对代码进行改进。一旦我们的代码被更新,我们重新设计微基准测试,调整实现,或者直接进入执行阶段。这是一个循环过程,直到我们达到目标中确定的性能优化。

使用 JMH 的方法

Oracle 的文档表明,最理想的 JMH 使用案例是使用依赖于应用程序 JAR 文件的 Maven 项目。他们还建议,微基准测试应通过命令行进行,而不是在集成开发环境IDE)内进行,因为这可能会影响结果。

Maven,也称为 Apache Maven,是一个项目管理和理解工具,我们可以用它来管理我们的应用程序项目构建、报告和文档。

要使用 JMH,我们将使用字节码处理器(注解)来生成基准代码。我们使用 Maven 原型来启用 JMH。

为了测试 JMH,我们需要一个支持 Maven 和 Java 9 的 IDE。如果您还没有 Java 9 或支持 Java 9 的 IDE,您可以按照下一节中的步骤进行操作。

安装 Java 9 和具有 Java 9 支持的 Eclipse

您可以从 JDK 9 早期访问构建页面下载并安装 Java 9--jdk.java.net/9/

一旦您安装了 Java 9,请下载 Eclipse 的最新版本。在撰写本书时,那是 Oxygen。以下是相关链接--www.eclipse.org/downloads/

下一步是在您的 IDE 中启用 Java 9 支持。启动 Eclipse Oxygen 并选择帮助 | Eclipse Marketplace... ,如下截图所示:

图片

在 Eclipse Marketplace 对话框窗口中,使用搜索框搜索“Java 9 支持”。如您在以下截图中所见,您将看到一个安装按钮:

图片

在安装过程中,您将需要接受许可协议,并在完成后,您将需要重新启动 Eclipse。

实践实验

现在我们已经将 Eclipse 更新以支持 Java 9,您可以对您的开发计算机进行快速测试以确定 JMH 是否正常工作。首先创建一个新的 Maven 项目,如下截图所示:

图片

接下来,我们需要添加一个依赖项。我们可以通过直接编辑pom.xml文件来完成此操作,如下代码所示:

    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>0.1</version>
    </dependency>

或者,我们可以使用依赖项选项卡在对话框窗口中输入数据,如下截图所示。使用此表单将更新pom.xml文件中的上述代码:

图片

接下来,我们需要编写一个包含 JMH 方法的类。这只是一个初始测试,以确认我们最近更新的开发环境。以下是您可以用于测试的示例代码:

    package com.packt.benchmark.test.com.packt.benchmark.test;

    import org.open.jdk.jmh.Main;

    public class Test 
    {

      public static void main(String[] args)
      {
        Main.main(args);
      }
    }

我们现在可以编译并运行我们的非常简单的测试程序。结果在控制台选项卡中提供,或者如果您使用的是命令行,则是实际的控制台。您将看到以下内容:

图片

你可以看到程序运行得足够好,足以让我们知道 JMH 正在运行。当然,正如输出所示,没有设置基准。我们将在下一节中处理这个问题。

使用 Maven 进行微基准测试

开始使用 JMH 的一种方法是通过 JMH Maven 架构。第一步是创建一个新的 JMH 项目。在我们的系统命令提示符中,我们将输入 mvn 命令,然后输入一系列长参数来创建一个新的 Java 项目和必要的 Maven pom.xml 文件:

mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh -java-benchmark-archetype -DgroupId=com.packt -DartifactId=chapter8-benchmark -Dversion=1.0

一旦你输入 mvn 命令和前面的详细参数,你将通过终端看到报告给你的结果。根据你的使用水平,你可能会看到从 repo.maven.apache.org/maven2/org/apache/mave/plugins 和其他类似仓库站点的大量下载。

你还将看到一个信息部分,它会告诉你有关项目构建过程的信息:

很可能还会从 repo.maven.apache.org 仓库下载额外的插件和其他资源。然后,你将看到一个信息反馈组件,它会告诉你项目正在批量模式下生成:

最后,你将看到一个参数集和一个说明,表明你的项目构建成功。正如以下示例所示,整个过程不到 21 秒就完成了:

根据我们在 -DartifactId 选项中包含的参数,将创建一个文件夹。在我们的示例中,我们使用了 -DartifactId=chapter8-benchmark,Maven 创建了一个 chapter8-benchmark 项目文件夹:

你将看到 Maven 创建了 pom.xml 文件以及一个源文件夹 (src)。在该文件夹中,在 C:chapter8-benchmarksrcmainjavacompackt 的子目录结构下是 MyBenchmark.java 文件。Maven 为我们创建了一个基准类:

这里是 JMH Maven 项目创建过程创建的 MyBenchmark.java 类的内容:

    /*
     * Copyright (c) 2014, Oracle America, Inc.
     * All rights reserved.
     *
     * Redistribution and use in source and binary forms, with or 
       without
     * modification, are permitted provided that the following 
       conditions are met:
     *
     * * Redistributions of source code must retain the above
         copyright notice,
     * this list of conditions and the following disclaimer.
     *
     * * Redistributions in binary form must reproduce the above 
         copyright
     * notice, this list of conditions and the following
       disclaimer in the
     * documentation and/or other materials provided with the 
       distribution.
     *
     * * Neither the name of Oracle nor the names of its 
         contributors may be used
     * to endorse or promote products derived from this software 
       without
     * specific prior written permission.
     *
     * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 
       CONTRIBUTORS "AS IS"
     * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
       LIMITED TO, THE
     * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 
       PARTICULAR PURPOSE
     * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 
       CONTRIBUTORS BE
     * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
       EXEMPLARY, 
       OR
     * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
       PROCUREMENT OF
     * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
       OR BUSINESS
     * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
       WHETHER IN
     * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 
       OTHERWISE)
     * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 
       ADVISED OF
     * THE POSSIBILITY OF SUCH DAMAGE.
     */

    package com.packt;

    import org.openjdk.jmh.annotations.Benchmark;

    public class MyBenchmark 
    {
      @Benchmark
      public void testMethod() 
      {

        // This is a demo/sample template for building your JMH 
           benchmarks. 
        //Edit as needed.
        // Put your benchmark code here.
      }
    }

我们下一步是修改 testMethod(),使其有可测试的内容。以下是我们将用于基准测试的修改后的方法:

    @Benchmark
    public void testMethod() 
    {
      int total = 0;
      for (int i=0; i<100000; i++)
      {
        total = total + (i * 2 );
      }
      System.out.println("Total: " + total);
    }

在我们编辑了代码之后,我们将导航回项目文件夹,例如在我们的例子中,回到 C:chapter8-benchmark,并在命令提示符下执行 mvn clean install

你将看到几个仓库下载、源编译、插件安装,最后是如这里所示的构建成功指示器:

现在,你将在项目目录中看到 .classpath.project 文件,以及新的 .settings 和目标子文件夹:

如果你导航到target子文件夹,你会看到我们的benchmarks.jar文件已被创建。这个 JAR 文件包含我们运行基准测试所需的内容。

我们可以在 IDE 中更新我们的MyBenchmark.java文件,例如 Eclipse。然后,我们可以再次执行mvn clean install来覆盖我们的文件。在初始时间之后,我们的构建将会更快,因为不需要下载任何内容。以下是除了第一次之外构建过程的输出:

我们的最后一步是运行基准工具。我们可以使用以下命令来执行--java -jar benchmarks.jar。即使是针对简单代码的小型基准测试,如我们的示例,基准测试也可能需要一些时间来运行。可能会有几个迭代,包括预热,以提供更简洁和有效的基准结果集。

我们提供的基准结果如下。如您所见,测试运行了 8 分钟 8 秒:

基准测试选项

在上一节中,你学习了如何运行基准测试。在本节中,我们将查看运行基准测试的以下可配置选项:

  • 模式

  • 时间单位

模式

我们基准结果输出的上一节中,包括了一个模式列,其值为thrpt,它是吞吐量的缩写。这是默认模式,还有另外四种模式。所有 JMH 基准模式都列出来并如下描述:

模式 描述
所有 测量所有其他模式,包括在内。
平均 此模式测量单个基准运行的平均时间。
样本时间 此模式测量基准执行时间,包括最小和最大时间。
单次射击时间 使用此模式时,没有 JVM 预热,测试是为了确定单个基准方法运行所需的时间。
吞吐量 这是默认模式,测量基准每秒可以运行的操作数。

要指定要使用的基准模式,您需要将代码中的@Benchmark行修改为以下之一:

    @Benchmark @BenchmarkMode(Mode.All)
    @Benchmark @BenchmarkMode(Mode.Average)
    @Benchmark @BenchmarkMode(Mode.SamplmeTime)
    @Benchmark @BenchmarkMode(Mode.SingleShotTime)
    @Benchmark @BenchmarkMode(Mode.Throughput)

时间单位

为了在基准输出中获得更高的保真度,我们可以指定一个特定的单位时间,以下是从最短到最长的列表:

  • NANOSECONDS

  • MICROSECONDS

  • MILLISECONDS

  • SECONDS

  • MINUTES

  • HOURS

  • DAYS

为了进行此指定,我们只需将以下代码添加到我们的@Benchmark行:

    @Benchmark @BenchmarkMode(Mode.Average) 
    @OutputTimeUnit(TimeUnit.NANOSECONDS)

在前面的示例中,我们指定了平均模式和纳秒作为时间单位。

避免微基准测试陷阱的技术

微基准测试不是每个开发者都必须担心的事情,但对于那些需要担心的人来说,有一些陷阱你应该注意。在本节中,我们将回顾最常见的陷阱,并提出避免它们的策略。

功率管理

有许多子系统可以帮助你管理功率和性能之间的平衡(即cpufreq)。这些系统可以改变基准测试期间时间的状态。

有两种建议的策略来避免这个陷阱:

  • 在运行测试之前禁用任何电源管理系统

  • 运行更长时间的基准测试

操作系统调度器

操作系统调度器,如 Solaris 调度器,有助于确定哪些软件进程可以访问系统资源。使用这些调度器可能会导致不可靠的基准测试结果。

有两种建议的策略来避免这个陷阱:

  • 精炼你的系统调度策略

  • 运行更长时间的基准测试

分时

分时系统用于帮助平衡系统资源。使用这些系统通常会导致线程启动和停止时间之间的不规则间隔。此外,CPU 负载将不会均匀,我们的基准测试数据对我们来说将不那么有用。

有两种建议的策略来避免这个陷阱:

  • 在运行基准测试之前测试所有代码,以确保一切按预期工作

  • 在所有线程启动或所有线程停止后使用 JMH 进行测量

消除死代码和常量折叠

死代码和常量折叠通常被称为冗余代码,而我们的现代编译器在消除它们方面相当出色。一个死代码的例子是永远不会被执行的代码。考虑以下示例:

    . . . 

    int value = 10;

    if (value != null)
    {
      System.out.println("The value is " + value + ".");
    } else 
      {
         System.out.println("The value is null."); // This is
         a line of Dead-Code
    }

    . . . 

在我们前面的例子中,被识别为死代码的行永远不会被执行,因为变量的值永远不会等于 null。它在条件if语句评估变量之前立即被设置为10

问题在于,在尝试消除死代码的过程中,有时可能会移除基准测试代码。

常量折叠是编译器操作,当编译时约束被替换为实际结果时发生。编译器执行常量折叠以消除任何冗余的运行时计算。在以下示例中,我们有一个final int,后面跟着一个基于涉及第一个int的数学计算的第二个int

    . . . 

    static final int value = 10;

    int newValue = 319 * value;

    . . . 

常量折叠操作会将前面代码的两行转换为以下内容:

    int newValue = 3190;

有一种建议的策略来避免这个陷阱:

  • 使用 JMH API 支持来确保你的基准测试代码不会被消除

运行到运行差异

有许多问题可能会严重影响基准测试的运行到运行差异。

有两种建议的策略来避免这个陷阱:

  • 在每个子系统内多次运行 JVM

  • 使用多个 JMH 人员

缓存容量

动态随机存取存储器DRAM)非常慢。这可能导致基准测试期间非常不同的性能结果。

有两种建议的策略来避免这个陷阱:

  • 使用不同的问题集运行多个基准测试。在测试期间跟踪你的内存占用。

  • 使用@State注解来指定 JMH 状态。这个注解用于定义实例的作用域。有三个状态:

    • Scope.Benchmark: 实例在运行相同测试的所有线程之间共享。

    • Scope.Group: 每个线程组分配一个实例。

    • Scope.Thread: 每个线程都将拥有自己的实例。这是默认状态。

摘要

在本章中,我们了解到 JMH 是一个用于为 JVM 编写基准测试的 Java 工具库。我们通过使用 Maven 和 JMH 来编写性能测试,帮助说明使用新 Java 9 平台进行微基准测试的流程。我们从微基准测试概述开始,然后深入探讨了使用 Maven 进行微基准测试,回顾了基准测试选项,并以一些避免微基准测试陷阱的技术结束。

在下一章中,我们将学习如何编写一个管理其他进程并利用 Java 9 平台现代进程管理 API 的应用程序。

第十章:使用 ProcessHandle API

在上一章中,我们发现了Java 微基准工具JMH)。我们探讨了性能测试以及如何使用 JMH 编写 Java 虚拟机的基准测试,JMH 是一个 Java 库。我们从微基准测试的概述开始,然后探讨了使用 Maven 进行微基准测试,回顾了基准测试选项,并以避免微基准测试陷阱的技术结束。

在本章中,我们将专注于 Process 类的更新和新的 java.lang.ProcessHandle API。在 Java 9 之前,在 Java 中管理进程从未容易过,因为 Java 很少被用来自动化控制其他进程。API 功能不足,一些功能缺失,一些任务需要以系统特定的方式解决。例如,在 Java 8 中,给进程访问其自己的进程标识符PID)是一个不必要的困难任务。

在本章中,读者将获得编写管理其他进程并利用 Java 现代进程管理 API 的应用程序所需的所有知识。

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

  • 什么是新的 ProcessHandle 接口以及如何使用它

  • 如何获取当前进程的 PID

  • 如何列出在操作系统中运行的不同进程

  • 如何有效地等待外部进程完成

  • 如何终止外部进程

什么是进程?

在本节中,我们将回顾在 Java 应用程序编程中进程的含义。如果您已经熟悉进程,您可能考虑跳过这一节。

进程是操作系统的执行单元。当您启动一个程序时,您启动一个进程。当机器启动代码时,它首先执行启动进程。然后,该进程启动其他进程,这些进程成为启动进程的子进程。这些子进程可能启动其他进程。这样,当机器运行时,会有进程树在运行。当机器执行某项操作时,它是在某个进程内部执行的代码中完成的。操作系统也作为几个同时执行的过程运行。应用程序作为一个或多个进程执行。大多数应用程序作为一个单独的进程运行,但以为例,Chrome 浏览器启动几个进程来完成所有的渲染和网络通信操作,最终作为一个浏览器工作。

要更好地了解进程是什么,请在 Windows 上启动任务管理器或在 OS X 上启动活动监视器,然后点击进程标签。您将看到机器上当前存在的不同进程。使用这些工具,您可以查看进程的参数,或者您可以终止单个进程。

单个进程为其工作分配了内存,并且不允许它们自由访问彼此的内存。

操作系统安排的执行单元是线程。一个进程由一个或多个线程组成。这些线程由操作系统调度器调度,并在时间槽中执行。

每个操作系统都有进程标识符,这是一个标识进程的数字。在任何时候,不会有两个进程同时活跃,共享相同的 PID。当我们想要在操作系统中识别一个活跃的进程时,我们使用 PID。在 Linux 和其他类 Unix 操作系统中,kill命令用于终止进程。传递给此程序的参数是要终止的进程的 PID。终止可以是优雅的。这就像请求进程退出。如果进程决定不退出,它可以继续运行。程序可以准备在收到此类请求时停止。例如,一个 Java 应用程序可能添加一个调用Runtime.getRuntime().addShutdownHook(Thread t)方法的Thread对象。传递的线程应该在进程被请求停止时启动,并且线程可以执行程序退出前必须完成的全部任务。然而,没有保证它一定会启动。这取决于实际的实现。

新的 ProcessHandle 接口

在 Java 9 中,有两个新的接口及其实现支持操作系统进程的处理。其中一个是ProcessHandle,另一个是ProcessHandle.Info,它是前者的嵌套接口。

ProcessHandle对象用于标识操作系统进程,并提供管理进程的方法。在 Java 的早期版本中,这只能通过使用特定于操作系统的方法,并使用 PID 来识别进程来实现。这种方法的主要问题是 PID 仅在进程活跃时是唯一的。当进程结束时,操作系统可以自由地重新使用 PID 为新进程分配。当我们只知道进程的 PID 并检查进程是否仍在运行时,我们实际上是在检查是否存在具有该 PID 的活跃进程。当我们检查时,我们的进程可能仍然存活,但当我们下次程序查询进程状态时,它可能是一个不同的进程。

桌面和服务器操作系统会尽可能长时间地不重用 PID 值。在一些嵌入式系统中,操作系统可能只使用 16 位来存储 PID。当只使用 16 位值时,PID 被重用的可能性更大。我们可以使用ProcessHandle API 来避免这个问题。我们可以接收一个ProcessHandle并调用handle.isAlive()方法。当进程结束时,此方法将返回false。即使 PID 被重用,这也同样有效。

获取当前进程的 PID

我们可以通过句柄访问进程的 PID。handle.getPid()方法返回一个表示 PID 数值的Long。由于通过句柄访问进程更安全,因此此方法的重要性有限。当我们的代码想要向其他管理工具提供有关自身的信息时,它可能很有用。对于程序来说,创建一个以数值 PID 为文件名的文件是一种常见做法。可能存在某些程序不能在多个进程中运行的要求。在这种情况下,代码将将其自己的 PID 文件写入特定目录。如果已存在同名 PID 文件,则处理停止。如果先前的进程崩溃并终止而没有删除 PID 文件,那么系统管理员可以轻松删除文件并启动新进程。如果程序挂起,那么如果系统管理员知道 PID,则可以轻松地杀死已死的进程。

要获取当前进程的 PID,可以使用调用链ProcessHandle.current().getPid()

获取进程信息

要获取进程信息,我们需要访问进程的Info对象。这可以通过ProcessHandle来实现。我们通过调用handle.info()方法来返回它。

Info接口定义了查询方法,用于提供有关进程的信息。这些方法是:

  • command()返回一个包含启动进程所使用的命令的Optional<String>

  • arguments()返回一个包含在启动进程的命令行之后使用的参数的Optional<String[]>

  • commandLine()返回一个包含整个命令行的Optional<String>

  • startInstant()返回一个Optional<Instant>,它本质上表示进程启动的时间

  • totalCpuDuration()返回一个Optional<Duration>,它表示进程自启动以来使用的 CPU 时间

  • user()返回一个包含进程所属用户名的Optional<String>

这些方法返回的值都是Optional,因为没有保证实际操作系统或 Java 实现可以返回信息。然而,在大多数操作系统上,它应该可以工作,并且返回的值应该存在。

以下示例代码显示了给定进程的信息:

    import java.io.IOException;
    import java.time.Duration;
    import java.time.Instant;
    public class ProcessHandleDemonstration
    {
      public static void main(String[] args) throws 
       InterruptedException, IOException
      {
        provideProcessInformation(ProcessHandle.current());
        Process theProcess = new
         ProcessBuilder("SnippingTool.exe").start();
        provideProcessInformation(theProcess.toHandle());
        theProcess.waitFor();
        provideProcessInformation(theProcess.toHandle());
      }
      static void provideProcessInformation(ProcessHandle theHandle)
      {
        // get id
        long pid = ProcessHandle.current().pid();
        // Get handle information (if available)
        ProcessHandle.Info handleInformation = theHandle.info();
        // Print header
        System.out.println("|=============================|");
        System.out.println("| INFORMATION ON YOUR PROCESS |");
        System.out.println("|=============================|n");
        // Print the PID
        System.out.println("Process id (PID): " + pid);
        System.out.println("Process Owner: " + 
          handleInformation.user().orElse(""));
        // Print additional information if available
        System.out.println("Command:" + 
         handleInformation.command().orElse(""));
        String[] args = handleInformation.arguments().orElse
         (new String[]{});
        System.out.println("Argument(s): ");
        for (String arg: args) System.out.printf("t" + arg);
        System.out.println("Command line: " + handleInformation.
         commandLine().orElse(""));
        System.out.println("Start time: " + 
         handleInformation.startInstant().
         orElse(Instant.now()).toString());
        System.out.printf("Run time duration: %sms%n",
         handleInformation.totalCpuDuration()
         .orElse(Duration.ofMillis(0)).toMillis());
      }
    }

以下是前面代码的控制台输出:

列出进程

在 Java 9 之前,我们没有获取活动进程列表的方法。现在,使用 Java 9,我们可以通过流来获取进程。有三个方法返回Stream<ProcessHandle>。一个列出子进程。另一个列出所有后代;包括子进程及其子进程。第三个列出所有进程。

列出子进程

要获取可以用来控制子进程的进程句柄流,应使用静态方法processHandle.children()。这将创建由processHandle表示的进程的后代进程的快照并创建Stream。由于进程是动态的,因此无法保证在代码执行期间,当我们的程序关注句柄时,所有子进程都仍然处于活动状态。其中一些可能已终止,我们的进程可能从不同的线程派生新的子进程。因此,代码不应假设流中的每个ProcessHandle元素都代表一个活跃且正在运行的进程。

以下程序在 Windows 中启动 10 个命令提示符,然后计算子进程的数量并将其打印到标准输出:

    package packt.mastering.java9.process;

    import java.io.IOException;

    public class ChildLister {
      public static void main(String[] args) throws IOException {
        for (int i = 0; i < 10; i++) {
          new ProcessBuilder().command("cmd.exe").start();
        }
        System.out.println("Number of children :" +
         ProcessHandle.current().children().count());
      }
    }

执行程序将产生以下结果:

图片

列出后代

列出后代与列出子进程非常相似,但如果我们调用processHandle.descendants()方法,则Stream将包含所有子进程以及这些子进程的后代进程,依此类推。以下程序以命令行参数启动命令提示符,以便它们也生成另一个终止的cmd.exe

    package packt.mastering.java9.process;

    import java.io.IOException;
    import java.util.stream.Collectors;

    public class DescendantLister {
      public static void main(String[] args) throws IOException {
        for (int i = 0; i < 10; i++) {
          new ProcessBuilder().command("cmd.exe","/K","cmd").                
           start();
        }
        System.out.println("Number of descendants: " +
         ProcessHandle.current().descendants().count();
      }
    }

运行命令几次将产生以下非确定性的输出:

图片

输出清楚地表明,当创建后代的Stream时,并非所有进程都处于活动状态。示例代码启动了 10 个进程,每个进程又启动了另一个。Stream中没有 20 个元素,因为其中一些子进程在处理过程中已终止。

列出所有进程

列出所有进程与列出后代和子进程略有不同。方法allProcess()是静态的,并在执行时返回操作系统所有活动进程的句柄Stream

以下示例代码将看起来像是 Java 进程的进程命令打印到控制台:

    package packt.mastering.java9.process;
    import java.lang.ProcessHandle.Info;
    public class ProcessLister {
      private static void out(String format, Object... params) {
        System.out.println(String.format(format, params));
      }
      private static boolean looksLikeJavaProcess(Info info) {
        return info.command().isPresent() &&
         info.command().get().
         toLowerCase().indexOf("java") != -1;
      }

      public static void main(String[] args) {
        ProcessHandle.allProcesses().
         map(ProcessHandle::info).
         filter(info -> looksLikeJavaProcess(info)).
         forEach(
           (info) -> System.out.println(
             info.command().orElse("---"))
         );
      }

    }

程序的输出列出了包含字符串java的所有进程命令:

图片

您的实际输出可能会有所不同。

等待进程

当一个进程启动另一个进程时,它可能需要多次等待该进程,因为它需要另一个程序的结果。如果可以将任务结构组织成父程序可以在等待子进程完成时做其他事情的方式,那么父进程就可以在进程句柄上调用isAlive()方法。很多时候,父进程直到派生的进程完成之前都没有事情可做。旧应用程序实现了循环,调用Thread.sleep()方法,这样 CPU 就不会过度浪费,并且不时检查进程是否仍然处于活动状态。Java 9 为等待进程提供了一种更好的方法。

ProcessHandle接口有一个名为onExit的方法,它返回一个CompletableFuture。这个类是在 Java 8 中引入的,使得在没有循环的情况下等待任务完成成为可能。如果我们有一个进程的句柄,我们可以简单地调用handle.onExit().join()方法来等待进程完成。返回的CompletableFutureget()方法将返回最初用于创建它的ProcessHandle实例。

我们可以在句柄上多次调用onExit()方法,每次都会返回一个不同的CompletableFuture对象,每个对象都与同一个进程相关。我们可以在对象上调用cancel()方法,但它只会取消CompletableFuture对象,而不会取消进程,也不会对从同一个ProcessHandle实例创建的其他CompletableFuture对象产生任何影响。

终止进程

要终止一个进程,我们可以在ProcessHandle实例上调用destroy()方法或destroyForcibly()方法。这两个方法都会终止进程。destroy()方法预期将以优雅的方式终止进程,执行进程关闭序列。在这种情况下,如果实际实现支持进程的优雅、正常终止,则会执行添加到运行时的关闭钩子。destroyForcibly()方法将强制终止进程,在这种情况下,不会执行关闭序列。

如果由句柄管理的进程已经不活跃,则在代码调用这些方法中的任何一个时,都不会发生任何操作。如果有任何创建于句柄上调用onExit()方法的CompletableFuture对象,那么在进程终止后,它们将在调用destroy()destroyForcefully()方法之后完成。这意味着CompletableFuture对象将在进程终止完成后的一段时间后从join()或类似方法返回,而不是在destroy()destroyForcefully()返回后立即返回。

还需要注意的是,进程的终止可能取决于许多因素。如果实际等待终止另一个进程的进程没有终止其他进程的权限,则请求将失败。在这种情况下,方法的返回值是false。同样重要的是要理解,返回值为true并不意味着进程实际上已经终止。它仅意味着操作系统已接受终止请求,操作系统将在未来的某个时刻终止进程。这实际上会很快发生,但不是瞬间发生,因此如果在destroy()destroyForcefully()返回true后的一段时间内,isAlive()方法返回true,这不应感到惊讶。

destroy()destroyForcefully()之间的区别是具体实现的。Java 标准并没有声明destroy()确实会终止进程,让关闭序列被执行。它只是请求终止进程。由这个ProcessHandle对象表示的进程是否正常终止是依赖于实现的(download.java.net/java/jdk9/docs/api/java/lang/ProcessHandle.html#supportsNormalTermination--)

要了解更多关于ProcessHandle接口的信息,请访问download.java.net/java/jdk9/docs/api/java/lang/ProcessHandle.html

这是因为某些操作系统没有实现优雅的进程终止功能。在这种情况下,destroy()的实现与调用destroyForcefully()相同。接口ProcessHandle的系统特定实现必须实现supportsNormalTermination()方法,该方法仅在实现支持正常(非强制)进程终止时才返回true。该方法预期在实现的实际调用中返回相同的值,并且在 JVM 实例执行期间不应更改返回值。不需要多次调用该方法。

以下示例演示了进程启动、进程终止和等待进程终止。在我们的例子中,我们使用两个类。第一个类演示了.sleep()方法:

    package packt.mastering.java9.process; 

    public class WaitForChildToBeTerminated  
    { 
      public static void main(String[] args) 
       throws InterruptedException  
      { 
        Thread.sleep(10_000); 
      } 
    } 

我们例子中的第二个类调用了WaitForChildToBeTerminated类:

    package packt.mastering.java9.process;

    import java.io.IOException;
    import java.util.Arrays;
    import java.util.concurrent.CompletableFuture;
    import java.util.stream.Collectors;

    public class TerminateAProcessAfterWaiting {
      private static final int N = 10;

      public static void main(String[] args)
       throws IOException, InterruptedException {  
         ProcessHandle ph[] = new ProcessHandle[N];

         for (int i = 0; i < N; i++)  
         {
           final ProcessBuilder pb = ew ProcessBuilder(). 
            command("java", "-cp", "build/classes/main",
            "packt.mastering.java9.process.
            WaitForChildToBeTerminated");
           Process p = pb.start();
           ph[i] = p.toHandle();
         }
         long start = System.currentTimeMillis();
         Arrays.stream(ph).forEach(ProcessHandle::destroyForcibly);

         CompletableFuture.allOf(Arrays.stream(ph).
          map(ProcessHandle::onExit).
          collect(Collectors.toList()).
          toArray(new CompletableFuture[ph.length])).
          join();
         long duration = System.currentTimeMillis() - start;
         System.out.println("Duration " + duration + "ms");
      }
    }

上述代码启动了 10 个进程,每个进程执行休眠10秒的程序。然后强制销毁这些进程,更具体地说,操作系统被要求销毁它们。我们的例子将CompletableFuture与由CompletableFuture对象数组组成的CompletableFuture连接起来,这些对象使用单个进程的句柄创建。

当所有进程完成后,它将打印出测量的时间(以毫秒为单位)。时间间隔从进程创建开始,直到进程创建循环结束。测量时间间隔的结束是当进程被 JVM 通过join()方法返回时被识别。

示例代码将休眠时间设置为 10 秒。这是一个更明显的时间段。运行代码两次并删除销毁进程的行可能会导致打印速度变慢。实际上,测量和打印的经过时间也会显示终止进程有影响。

一个小型进程控制器应用程序

为了总结并应用我们在本章中学到的所有内容,我们来看一个示例过程控制应用程序。该应用程序的功能非常简单。它从一系列配置文件中读取参数,以启动一些进程,然后如果其中任何一个停止,它将尝试重新启动该进程。

即使是从这个演示版本中也可以创建一个真实的应用程序。你可以通过环境变量规范扩展进程的参数集。你可以为进程添加默认目录、输入和输出重定向,甚至可以指定进程允许消耗多少 CPU,而无需控制应用程序杀死和重新启动它。

该应用程序由四个类组成。

  • Main: 这个类包含公共静态的main方法,用于启动守护进程。

  • Parameters: 这个类包含进程的配置参数。在这个简单的例子中,它将只包含一个字段,即commandLine。如果应用程序被扩展,这个类将包含默认目录、重定向和 CPU 使用限制数据。

  • ParamsAndHandle: 这个类实际上只是一个数据元组,它包含对Parameters对象的引用以及一个进程句柄。当一个进程死亡并被重新启动时,进程句柄将被新的句柄替换,但Parameters对象的引用永远不会改变,它是配置。

  • ControlDaemon: 这个类实现了Runnable接口,并作为一个独立的线程启动。

在代码中,我们将使用我们在上一节中讨论的大多数进程 API,终止进程,并且我们将使用大量的线程代码和流操作。理解 JVM 的线程工作对于进程管理来说也很重要。然而,当与进程 API 一起使用时,它的重要性被强调了。

主类

主方法从命令行参数中获取目录名称。它将此视为相对于当前工作目录的相对路径。它使用同一类中的另一个单独的方法从目录中的文件中读取配置集,然后启动控制守护进程。以下代码是该程序的main方法:

    public static void main(String[] args) throws IOException, 
     InterruptedException  
    {
      // DemoOutput.out() simulated - implementation no shown
      DemoOutput.out(new File(".").getAbsolutePath().toString());
      if (args.length == 0)    {
        System.err.println("Usage: daemon directory");
        System.exit(-1);
      }
      Set<Parameters> params = parametersSetFrom(args[0]);
      Thread t = new Thread(new ControlDaemon(params));
      t.start();
    }

虽然这是一个守护进程,但我们是以普通线程而不是守护线程的方式启动它的。当一个线程被设置为守护线程时,它不会保持 JVM 存活。当所有其他非守护线程停止时,JVM 将直接退出,守护线程也将停止。在我们的情况下,我们执行的守护线程是唯一一个保持代码运行的那个。在那之后,主线程就没有其他事情可做了,但 JVM 应该保持存活,直到被操作员发出的 Unix kill命令或按命令行上的Control + C键杀死。

使用新的FilesPaths类从 JDK 获取指定目录中的文件列表以及从文件中获取参数非常简单:

    private static Set<Parameters>  
     GetListOfFilesInDirectory(String directory) throws IOException  
    {
      return Files.walk(Paths.get(directory))
       .map(Path::toFile)
       .filter(File::isFile)
       .map(file -> Parameters.fromFile(file))
       .collect(Collectors.toSet());
    }

我们以 Path 对象的形式获取文件流,将其映射到 File 对象,然后如果配置目录中有目录,则过滤掉它们,并使用 Parameters 类的静态方法 fromFile 将剩余的普通文件映射到 Parameters 对象。最后,我们返回一个对象集的 Set

参数类

我们的 Parameters 类具有以下字段和构造函数:

    final String[] commandLine;

    public Parameters(String[] commandLine) {
      this.commandLine = commandLine;
    }

参数类有两个方法。第一个方法 getCommandLineStrings 从属性中获取命令行字符串。这个数组包含命令和命令行参数。如果没有在文件中定义,则返回一个空数组:

    private static String[] getCommandLineStrings(Properties props)  
    {
      return Optional
       .ofNullable(props.getProperty("commandLine"))
       .orElse("")
       .split("\s+");
    }

第二个方法是 static fromFile,它从属性文件中读取属性:

    public static Parameters fromFile(final File file)  
    {
      final Properties props = new Properties();
      try (final InputStream is = new FileInputStream(file)) {
        props.load(is);
      }  catch (IOException e) {
           throw new RuntimeException(e);
      }
      return new Parameters(getCommandLineStrings(props));
    }

如果程序处理的参数集被扩展,那么这个类也应该被修改。

ParamsAndHandle

ParamsAndHandle 是一个非常简单的类,它包含两个字段。一个用于参数,另一个是用于访问使用参数启动的进程的进程句柄:

    public class ParamsAndHandle  
    {
      final Parameters params;
      ProcessHandle handle;

      public ParamsAndHandle(Parameters params,
        ProcessHandle handle) {
          this.params = params;
          this.handle = handle;
      }

      public ProcessHandle toHandle() {
        return handle;
      }
    }

由于该类与使用的 ControlDaemon 类紧密相关,因此没有与字段关联的修改器或访问器。我们将这两个类视为同一封装边界内的东西。toHandle 方法存在是为了我们可以将其用作方法句柄,正如我们将在下一章中看到的。

ControlDaemon

ControlDaemon 类实现了 Runnable 接口,并作为一个单独的线程启动。构造函数获取从属性文件中读取的参数集,并将其转换为 ParamsAndHandle 对象集:

    private final Set<ParamsAndHandle> handlers;

    public ControlDaemon(Set<Parameters> params) {
      handlers = params
      .stream()
      .map( s -> new ParamsAndHandle(s,null))
      .collect(Collectors.toSet());
    }

因为此时进程尚未启动,所以所有的句柄都是 nullrun() 方法启动进程:

    @Override
    public void run() {
      try {
        for (ParamsAndHandle pah : handlers) {
          log.log(DEBUG, "Starting {0}", pah.params);
          ProcessHandle handle = start(pah.params);
          pah.handle = handle;
        }
        keepProcessesAlive();
        while (handlers.size() > 0) {
          allMyProcesses().join();
        } 
      } catch (IOException e)  
        {
          log.log(ERROR, e);
        }
    }

处理过程通过参数集进行,并使用(在此类中稍后实现的)方法启动进程。每个进程的句柄都到达 ParamsAndHandle 对象。之后,调用 keepProcessesAlive 方法并等待进程完成。当进程停止时,它将被重新启动。如果无法重新启动,它将被从集合中移除。

allMyProcesses 方法(也在此类中实现)返回一个 CompletableFuture,当所有启动的进程都停止时完成。在 join() 方法返回时,一些进程可能已经被重启。只要至少有一个进程在运行,线程就应该运行。

使用 CompletableFuture 等待进程和 while 循环,我们使用最少的 CPU 来保持线程存活,只要我们管理的至少有一个进程在运行,即使经过几次重启也是如此。即使这个线程大部分时间不使用 CPU 且不执行任何代码,我们也必须保持这个线程存活,以便让 keepProcessesAlive() 方法使用 CompletableFutures 来完成其工作。以下代码片段展示了该方法:

    private void keepProcessesAlive()  
    {
      anyOfMyProcesses()
       .thenAccept(ignore -> {
         restartProcesses();
         keepProcessesAlive();
       });
    }

keepProcessesAlive()方法调用anyOfMyProcesses()方法,该方法返回一个CompletableFuture,当任何受管理进程退出时完成。该方法安排在CompletableFuture完成时执行传递给thenAccept()方法的 lambda 表达式。lambda 执行两件事:

  • 重新启动已停止的进程(可能只有一个)

  • 调用keepProcessesAlive()方法

重要的是要理解这个调用不是从keepProcessesAlive()方法本身执行的。这不是一个递归调用。这被安排为一个CompletableFuture操作。我们不是在递归调用中实现循环,因为我们可能会耗尽堆栈空间。当进程重新启动时,我们要求 JVM 执行器再次执行此方法。

重要的是要知道 JVM 使用默认的ForkJoinPool来调度这些任务,并且此池包含守护线程。这就是我们必须等待并保持方法运行的原因,因为这是唯一非守护线程,它可以防止 JVM 退出。

下一个方法是restartProcesses()

    private void restartProcesses()  
    {
      Set<ParamsAndHandle> failing = new HashSet<>();
      handlers.stream()
       .filter(pah -> !pah.toHandle().isAlive())
       .forEach(pah -> {
         try {
           pah.handle = start(pah.params);
         } catch (IOException e) {
             failing.add(pah);
         }
       });
       handlers.removeAll(failing);
    }

此方法启动我们管理进程集中的非活动进程。如果任何重启失败,它会从集中移除失败的进程。(请注意,不要在循环中移除它,以避免ConcurrentModificationException。)

anyOfMyProcesses()allMyProcesses()方法使用辅助方法completableFuturesOfTheProcessesand(),并且很简单:

    private CompletableFuture anyOfMyProcesses()  
    {
      return CompletableFuture.anyOf(
        completableFuturesOfTheProcesses());
    }

    private CompletableFuture allMyProcesses() {
      return CompletableFuture.allOf(
        completableFuturesOfTheProcesses());
    }

completableFuturesOfTheProcesses()方法返回一个由当前运行的受管理进程调用其onExit()方法创建的CompletableFutures数组。这是以紧凑且易于阅读的函数式编程风格完成的,如下所示:

    private CompletableFuture[] completableFuturesOfTheProcesses()  
    {
      return handlers.stream()
       .map(ParamsAndHandle::toHandle)
       .map(ProcessHandle::onExit)
       .collect(Collectors.toList())
       .toArray(new CompletableFuture[handlers.size()]);
    }

集合被转换为stream,映射为ProcessHandle对象的stream(这就是为什么在ParamsAndHandle类中需要toHandle()方法的原因)。然后,使用onExit()方法将这些句柄映射到CompletableFuture流,最后我们将它收集到一个列表中并转换为数组。

完成我们的示例应用程序的最后一个方法是以下内容:

    private ProcessHandle start(Parameters params)
     throws IOException {
       return new ProcessBuilder(params.commandLine)
        .start()
        .toHandle();
    }

此方法使用ProcessBuilder启动进程,并返回ProcessHandle,以便我们可以用我们的集合中的旧进程替换它并管理新进程。

摘要

在本章中,我们讨论了 Java 9 如何更好地使我们能够管理进程。在 Java 9 之前,从 Java 内部进行进程管理需要特定于操作系统的实现,并且在 CPU 使用和编码实践方面并不理想。现代 API,如ProcessHandle等新类,使得处理几乎所有的进程方面成为可能。我们列出了新的 API,并为每个 API 提供了简单的示例代码。在章节的后半部分,我们组合了一个整个应用程序来管理进程,其中所学的 API 得到了实际应用。

在下一章中,我们将详细探讨与 Java 9 一同发布的 Java Stack Walking API。我们将通过代码示例来说明如何使用该 API。

第十一章:细粒度堆栈跟踪

Java 9 带来了一个新的栈遍历 API,允许程序遍历调用栈。这是一个非常特殊的功能,普通程序很少需要。该 API 对于一些非常特殊的情况非常有用——对于由框架提供的功能。因此,如果您想要一种高效的栈遍历方法,该方法可以提供可筛选的堆栈跟踪信息访问,您将喜欢这个新的栈遍历 API。

该 API 提供了对调用栈的快速和优化访问,实现了对单个帧的懒访问。

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

  • Java 栈概述

  • 栈信息的重要性

  • 使用StackWalker

  • StackFrame

  • 性能

Java 栈概述

在我们深入研究栈遍历之前,让我们先了解 Java 栈。这是基本的栈信息,不特定于栈遍历。

Java 运行时有名为 Stack 的类,可以使用后进先出LIFO)策略来存储对象。

当计算算术表达式时,它们是使用栈来完成的。如果我们首先在我们的代码中添加AB,那么A会被推送到操作数栈,然后B被推送到操作数栈,最后执行加法操作,该操作从操作数栈的顶部获取两个元素并将结果A + B推送到那里。

JVM 是用 C 编写的,并执行调用 C 函数和从那里返回。这个调用-返回序列是通过使用本地方法栈来维护的,就像任何其他 C 程序一样。

最后,当 JVM 创建一个新的线程时,它也会分配一个包含帧的调用栈,这些帧反过来又包含局部变量、对前一个帧的引用以及对包含执行方法的类的引用。当一个方法被调用时,会创建一个新的帧。当方法完成其执行时,帧会被销毁,换句话说,返回或抛出异常。这个栈,即 Java 虚拟机栈,是栈遍历 API 管理的栈。

栈信息的重要性

一般而言,当我们想要开发依赖于调用者的代码时,我们需要栈信息。有关调用者的信息允许我们的代码根据该信息做出决策。在一般实践中,使功能依赖于调用者不是一个好主意。影响方法行为的信息应通过参数提供。依赖于调用者的代码开发应该相当有限。

JDK 使用原生方法访问栈信息,这些原生方法对 Java 应用程序不可用。《SecurityManager》是一个定义应用程序安全策略的类。这个类检查反射 API 的调用者是否有权访问另一个类的非公共成员。为了做到这一点,它必须能够访问调用者类,它通过一个受保护的本地方法来实现。

这是一个在不遍历堆栈的情况下实施某些安全措施的示例。我们向外部开发者开放我们的代码,让他们将其用作库。我们还调用库用户提供的类的方法,他们可能反过来调用我们的代码。有一些代码我们希望允许库用户调用,但前提是他们不是从我们的代码中调用的。如果我们不希望允许某些代码被库使用代码直接访问,我们可以使用 Java 9 的模块结构,不导出包含不调用的类的包。这就是我们设置额外条件的原因,即代码对来自外部的调用者可用,除非它们是由我们的代码调用的:

图片

另一个例子是我们想要获取访问记录器的权限。Java 应用程序使用许多不同的记录器,日志系统通常非常灵活,可以根据实际需要切换不同记录器的输出。最常见的方法是为每个类使用不同的记录器,记录器的名称通常是类的名称。这种做法如此普遍,以至于日志框架甚至提供了接受类本身引用而不是名称的记录器访问方法。这本质上意味着获取记录器句柄的调用看起来像以下这样:

    private static final Logger LOG = Logger.getLogger(MyClass.class); 

当我们从现有类创建新类时,如果我们忘记更改获取新记录器时类名的调用,可能会出现问题。这不是一个严重的问题,但很常见。在这种情况下,我们的代码将使用其他类的记录器,并且实际上可以工作,但在分析日志文件时可能会造成混淆。如果我们有一个返回名为调用者类名的记录器的函数,那就更好了。

让我们继续在下一两个部分中通过代码片段示例探索堆栈信息。

示例 - 限制调用者

在本节中,我们开发了一个包含两个方法的示例库。hello()方法将hello打印到标准输出。callMe()方法接受一个Runnable作为参数并运行它。然而,第一个方法是受限的。它仅在调用者完全在库外部时执行。如果调用者以某种方式获得了控制权,而库正在调用出去,假设是通过第二个方法调用传递的Runnable,它将抛出IllegalCallerException。API 的实现很简单:

    package packt.java9.deep.stackwalker.myrestrictivelibrary; 
    public class RestrictedAPI { 
      public void hello(){ 
        CheckEligibility.itIsNotCallBack(); 
        System.out.println("hello"); 
      } 
      public void callMe(Runnable cb){ 
        cb.run(); 
      } 
    } 

执行资格检查的代码实现在一个单独的类中,以保持事情简单。我们很快就会检查那段代码,但在那之前,我们来看看我们用来启动演示的主要代码。我们用来演示功能的程序代码如下:

    package packt.java9.deep.stackwalker.externalcode; 

    import
     packt.java9.deep.stackwalker.myrestrictivelibrary.RestrictedAPI; 

    public class DirectCall { 

      public static void main(String[] args) { 
        RestrictedAPI api = new RestrictedAPI(); 
        api.hello(); 
        api.callMe(() -> { 
            api.hello(); 
        }); 
      } 
    } 

此代码创建了我们 API 类的实例,然后直接调用hello()方法。它应该能正常工作,并在屏幕上打印字符hello。下一行代码要求callMe()方法回调以 lambda 表达式形式提供的Runnable。在这种情况下,调用将失败,因为调用者在外部,但却是从库内部调用的。

现在我们来看看资格检查是如何实现的:

    package packt.java9.deep.stackwalker.myrestrictivelibrary; 

    import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; 

    public class CheckEligibility { 
      private static final String packageName 
        = CheckEligibility.class.getPackageName(); 

      private static boolean notInLibrary(StackWalker.StackFrame f) { 
        return !inLibrary(f); 
      } 

      private static boolean inLibrary(StackWalker.StackFrame f) { 
        return f.getDeclaringClass().getPackageName() 
         .equals(packageName); 
      } 

      public static void itIsNotCallBack() { 
        boolean eligible = StackWalker 
         .getInstance(RETAIN_CLASS_REFERENCE) 
         .walk(s -> s.dropWhile(CheckEligibility::inLibrary) 
           .dropWhile(CheckEligibility::notInLibrary) 
           .count() == 0 
         ); 
         if (!eligible) { 
           throw new IllegalCallerException(); 
         } 
      } 
    } 

itIsNotCallBack()方法是hello()方法中调用的方法。此方法创建一个堆栈遍历器并调用walk()方法。walk()方法的参数是一个Function,它将StackFrame对象的Stream转换为walk()方法将返回的其他值。

最初,这种参数设置可能看起来复杂且难以理解。更合理的方式是返回一个提供StackFrame对象的Stream,而不是强迫调用者定义一个将作为参数获取此信息的Function

示例代码使用 lambda 表达式定义Function作为walk()方法的参数。lambda 表达式的参数s是流。由于此流的第一个元素是实际调用,所以我们丢弃它。因为这些调用也应该在调用者不合格的情况下被拒绝,即使调用hello()方法是通过库内部的其他类和方法进行的,我们也丢弃属于CheckEligibility类包内的所有帧元素。这个包是packt.java9.deep.stackwalker.myrestrictivelibrary,在代码中这个字符串存储在字段packageName中。结果流只包含来自库外部的StackFrame对象。我们也丢弃这些,直到流耗尽或直到我们找到一个再次属于库的StackFrame。如果所有元素都被丢弃,我们就成功了。在这种情况下,count()的结果是零。如果我们找到StackFrame中的某个类属于库,这意味着外部代码是从库中调用的,在这种情况下,我们必须拒绝工作。在这种情况下,变量eligible将是false,我们将抛出一个异常,如下面的截图所示:

图片

示例 - 获取调用者的日志记录器

要获取日志记录器,Java 9 有一个新的 API。使用此 API,一个模块可以为服务LoggerFinder提供一个实现,该服务随后可以返回一个实现getLogger()方法的Logger。这消除了库对特定记录器或记录器外观的依赖,这是一个巨大的优势。但仍然存在一个较小但仍然令人烦恼的问题,即我们需要再次将类的名称作为getLogger()方法的参数来写入。这个问题仍然存在。

为了避免这项繁琐的任务,我们创建了一个辅助类,该类查找调用者类并检索适合调用者类和模块的记录器。因为在这种情况下,我们不需要堆栈跟踪中引用的所有类,我们将调用StackWalker类的getCallerClass()方法。我们在packt.java9.deep.stackwalker.logretriever包中创建了一个名为Labrador的类:

    package packt.java9.deep.stackwalker.logretriever; 

    import java.lang.System.Logger; 
    import java.lang.System.LoggerFinder; 

    import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; 

    public class Labrador { 
      public static Logger retrieve() { 
        final Class clazz = StackWalker 
          .getInstance(RETAIN_CLASS_REFERENCE) 
          .getCallerClass(); 
        return LoggerFinder.getLoggerFinder().getLogger( 
          clazz.getCanonicalName(), clazz.getModule()); 
      } 
    } 

在 Java 9 之前,解决此问题的方法是获取Thread类的StackTrace数组,并从那里查找调用者类的名称。另一种方法是扩展具有受保护方法getClassContext()SecurityManager,该方法返回堆栈上所有类的数组。这两种解决方案都会遍历堆栈并组成一个数组,尽管我们只需要数组中的一个元素。在日志检索的情况下,由于记录器通常存储在private static final字段中,因此每个类在类初始化期间只初始化一次,因此性能损失可能并不显著。在其他用例中,性能损失可能非常显著。

在我们看过两个示例之后,我们将查看StackWalker内部工作的细节。

使用StackWalker

在本节中,你将更熟悉如何使用StackWalker。在本节中,我们将探讨以下主题:

  • 获取StackWalker实例

  • 堆栈遍历选项

获取StackWalker实例

要遍历堆栈元素,我们需要一个堆栈遍历器的实例。为此,我们调用getInstance()方法。如所示,此方法有四个重载版本:

  • static StackWalker getInstance()

  • static StackWalker getInstance(StackWalker.Option option)

  • static StackWalker getInstance(Set<StackWalker.Option> options)

  • static StackWalker getInstance(Set<StackWalker.Option> options, int estimateDepth)

第一个版本不接受任何参数,并返回一个StackWalker实例,这将允许我们遍历正常的堆栈帧。这通常是我们感兴趣的。该方法的其他版本接受StackWalker.Option值或多个值。枚举StackWalker.Option,正如其名所示,位于StackWalker类内部,有三个值:

  • RETAIN_CLASS_REFERENCE

  • SHOW_REFLECT_FRAMES

  • SHOW_HIDDEN_FRAMES

这些enum选项具有自描述的名称,并在下一节中解释。

保留类引用

如果我们将第一个选项enum常量RETAIN_CLASS_REFERENCE作为getInstance()方法的参数,则返回的实例允许我们访问在遍历过程中各个堆栈帧引用的类。

显示反射帧

SHOW_REFLECT_FRAMES枚举常量将生成一个包括来自某些反射调用的帧的遍历器。

显示隐藏帧

最后,枚举常量选项 SHOW_HIDDEN_FRAMES 将包括所有隐藏框架,这些框架包含反射调用以及为 lambda 函数调用生成的调用框架。

这里是一个关于反射和隐藏框架的简单演示:

    package packt; 
    import static java.lang.StackWalker.Option.SHOW_HIDDEN_FRAMES; 
    import static java.lang.StackWalker.Option.SHOW_REFLECT_FRAMES; 
    public class Main { 

允许我们直接执行此代码的主要方法调用的是名为 simpleCall() 的方法:

    public static void main(String[] args) { 
      simpleCall(); 
    } 

如其名所示,simpleCall() 方法简单地调用:

    static void simpleCall() { 
      reflectCall(); 
    } 

链中的下一个方法稍微复杂一些。尽管这也只调用下一个方法,但它使用反射来这样做:

    static void reflectCall() { 
      try { 
        Main.class.getDeclaredMethod("lambdaCall", 
          new Class[0]) 
           .invoke(null, new Object[0]); 
      } catch (Exception e) { 
          throw new RuntimeException(); 
      } 
    } 

在下一个示例中,我们有一个使用 lambda 调用的方法:

    static void lambdaCall() { 
      Runnable r = () -> { 
        walk(); 
      }; 
      r.run(); 
    } 

实际跟踪之前最后调用的方法是 walk()

    static void walk() { 
      noOptions(); 
      System.out.println(); 
      reflect(); 
      System.out.println(); 
      hidden(); 
    } 

前面的 walk() 方法依次调用三个方法。这些方法彼此非常相似,在此提供:

    static void noOptions() { 
      StackWalker 
        .getInstance() 
        .forEach(System.out::println); 
    } 

    static void reflect() { 
      StackWalker 
        .getInstance(SHOW_REFLECT_FRAMES) 
        .forEach(System.out::println); 
    } 

    static void hidden() { 
      StackWalker 
        // shows also reflect frames 
        .getInstance(SHOW_HIDDEN_FRAMES) 
        .forEach(System.out::println); 
    } 

前面的三个方法将框架打印到标准输出。它们使用栈跟踪器的 forEach() 方法。以下是栈跟踪程序输出的内容:

stackwalker/packt.Main.noOptions(Main.java:45) 
stackwalker/packt.Main.walk(Main.java:34) 
stackwalker/packt.Main.lambda$lambdaCall$0(Main.java:28) 
stackwalker/packt.Main.lambdaCall(Main.java:30) 
stackwalker/packt.Main.reflectCall(Main.java:19) 
stackwalker/packt.Main.simpleCall(Main.java:12) 
stackwalker/packt.Main.main(Main.java:8) 

此输出仅包含属于我们代码中调用框架的框架。main() 方法调用 simpleCall(),它又调用 reflectCall(),然后 reflectCall() 调用 lambdaCall()lambdaCall() 调用一个 lambda 表达式,该表达式调用 walk(),依此类推。我们没有指定任何选项并不意味着从栈中删除 lambda 调用。我们执行了那个调用,因此它必须在那里。它删除的是 JVM 实现 lambda 所需要的额外栈框架。我们可以在下一个输出中看到,当选项为 SHOW_REFLECT_FRAMES 时,反射框架已经存在:

stackwalker/packt.Main.reflect(Main.java:58) 
stackwalker/packt.Main.walk(Main.java:36) 
stackwalker/packt.Main.lambda$lambdaCall$0(Main.java:28) 
stackwalker/packt.Main.lambdaCall(Main.java:30) 
java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 
java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 
java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 
java.base/java.lang.reflect.Method.invoke(Method.java:547) 
stackwalker/packt.Main.reflectCall(Main.java:19) 
stackwalker/packt.Main.simpleCall(Main.java:12) 
stackwalker/packt.Main.main(Main.java:8) 

在这种情况下,区别在于我们可以看到从 reflectCall() 方法到 lambdaCall() 方法的调用不是直接的。reflectCall() 方法调用 invoke() 方法,该方法调用另一个在另一个类中定义的同名方法,然后该同名方法调用 JVM 提供的本地方法 invoke0()。之后我们最终到达 lambdaCall() 方法。

在输出中,我们还可以看到这些反射调用属于模块 java.base,而不是我们的 stackwalker 模块。

如果我们在反射框架之外还包括隐藏框架,指定选项 SHOW_HIDDEN_FRAMES,那么我们将看到以下输出:

stackwalker/packt.Main.hidden(Main.java:52) 
stackwalker/packt.Main.walk(Main.java:38) 
stackwalker/packt.Main.lambda$lambdaCall$0(Main.java:28) 
stackwalker/packt.Main$$Lambda$46/269468037.run(Unknown Source) 
stackwalker/packt.Main.lambdaCall(Main.java:30) 
java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 
java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 
java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 
java.base/java.lang.reflect.Method.invoke(Method.java:547) 
stackwalker/packt.Main.reflectCall(Main.java:19) 
stackwalker/packt.Main.simpleCall(Main.java:12) 
stackwalker/packt.Main.main(Main.java:8) 

这包括 JVM 用于执行 lambda 调用的额外隐藏框架。此外,还包括反射框架。

枚举常量的最后思考

我们还可以指定多个选项,给出一个选项集。最简单的方法是使用 java.util.Set 接口的静态 of() 方法。这样,RETAIN_CLASS_REFERENCE 选项可以与 SHOW_REFLECT_FRAMES 选项或 SHOW_HIDDEN_FRAMES 选项组合。

虽然从技术上讲可以将 SHOW_REFLECT_FRAMESSHOW_HIDDEN_FRAMES 作为选项集组合,但这样做实际上并没有任何优势。后者包含前者,所以两者的组合与后者完全相同。

访问类

当我们想在堆栈遍历期间访问类对象时,我们必须指定RETAIN_CLASS_REFERENCE选项。尽管StackFrame接口定义了getClassName()方法,可以使用它通过Class.forName()方法访问具有该名称的类,但这样做并不能保证StackFrame对象引用的类是由调用Class.forName()的代码相同的类加载器加载的。在某些特殊情况下,我们可能会遇到两个不同类加载器加载的具有相同名称的两个不同类。

如果在创建StackWalker实例时没有使用选项,那么原本会返回类对象的那些方法将抛出UnsupportedOperationException异常。这样就不能在StackFrame上使用getDeclaringClass(),也不能在StackWalker上使用getCallerClass()

步骤方法

StackWalker定义了一个期望一个Consumer(最好是 lambda 表达式形式)的forEach()方法,该方法将对堆栈跟踪中的每个元素进行调用。Consumer方法的参数是一个StackFrame对象。

虽然Stream接口也定义了一个名为forEach的方法,并且walk()方法将一个Stream对象传递给作为参数的Function,但我们不应该混淆这两个方法。StackWalkerforEach()方法是一种更简单、大多数情况下效果较差的方法,用于遍历堆栈跟踪的所有元素。

在大多数情况下,它效果较差,因为它迫使StackWalker实例获取堆栈跟踪的所有元素,以便forEach()方法可以遍历每个元素到末尾。如果我们知道我们不会遍历堆栈跟踪到末尾,我们应该使用walk()方法,该方法以懒加载的方式访问堆栈,从而为性能优化留出更多空间。

StackWalker类有一个walk()方法,这是定义其作为遍历者的方法。该方法接受一个由StackWalker调用的Functionwalk()方法的返回值将是Function返回的对象。Function的参数是一个Stream<StackFrame>,它传递堆栈帧。第一个帧是包含walk()方法调用的帧,下一个是调用walk()的调用方法的帧,依此类推。

Function可以用来根据从流中得到的StackFrame对象计算一些值,并决定调用者是否有资格调用我们的代码。

在审查了需要Function作为参数且该Function又需要一个Stream<StackFrame>作为参数的walk()方法之后,你可能会感到困惑。我们可能希望直接从StackWalter实例中获取一个Stream<StackFrame>。最简单的方法是将流从Function返回。考虑以下示例:

    // EXAMPLE OF WHAT NOT TO DO!!!! 
    public static void itIsNotCallBack() { 
      Stream<StackWalker.StackFrame> stream = 
        StackWalker 
          .getInstance(RETAIN_CLASS_REFERENCE) 
          .walk(s -> s); 
      boolean eligible = // YOU GET EXCEPTION!!!! 
        stream.dropWhile(CheckEligibility::inLibrary) 
          .dropWhile(CheckEligibility::notInLibrary) 
          .count() == 0; 
      if (!eligible) { 
        throw new IllegalCallerException(); 
      } 
    } 

我们所做的是简单地从 walker 调用中直接返回流,并在之后遍历它执行相同的计算。我们的结果是IllegalStateException异常,而不是资格检查。

原因在于StackWalker的实现高度优化。它不会复制整个堆栈以提供流的信息。它从实际的、活动的堆栈工作。为了做到这一点,它必须确保在流使用期间堆栈不会被修改。这与我们在迭代集合时更改集合可能会得到的ConcurrentModificationException非常相似。如果我们把流传递到调用栈中,然后想要从中获取StackFrame,流将尝试从已经消失的堆栈帧中获取信息,因为我们已经从属于它的方法中返回了。这样,StackWalker不会对整个堆栈进行快照,而是从实际的堆栈工作,并且必须确保它需要的堆栈部分不会改变。我们可以从Function中调用方法,这样我们可以在调用链中深入挖掘,但在流使用时我们不能向上移动。

也不要尝试玩其他花招,比如扩展StackWalker类。这是不可能的,它是一个final类。

StackFrame

在前面的章节中,我们遍历了StackFrame元素,并提供了示例代码片段,但没有花时间仔细检查。StackFrame是在StackWalker类内部定义的一个接口。它定义了访问器,以及一个可以将信息转换为StackTraceElement的转换器。

接口定义的访问器如下:

  • getClassName()将返回由StackFrame表示的方法的类的二进制名称。

  • getMethodName()将返回由StackFrame表示的方法的名称。

  • getDeclaringClass()将返回由StackFrame表示的方法的类。如果在创建StackWalker实例时没有使用Option.RETAIN_CLASS_REFERENCE,则该方法将抛出UnsupportedOperationException

  • getByteCodeIndex()获取包含由StackFrame表示的方法的执行点的代码数组的索引。在查找由命令行工具javap提供的反汇编 Java 代码时,此值的程序化使用可能有助于调试。此值的程序化使用仅对具有直接访问代码的字节码的应用程序有价值,java 代理或库在运行时生成字节码。如果方法是本地的,该方法将返回一个负数。

  • getFileName()返回由StackFrame表示的方法定义的源文件名。

  • getLineNumber()返回源代码的行号。

  • isNativeMethod() 如果 StackFrame 表示的方法是本地的,则返回 true,否则返回 false

StackFrame 不提供访问方法所属对象的任何方式。您无法访问由 StackFrame 表示的方法的参数和局部变量,也没有其他方法可以完成这项任务。这一点很重要。此类访问会过于侵入性,并且不可能实现。

性能

如果不查看性能考虑因素,我们对 StackWalker 的介绍就不会完整。

StackWalker 高度优化,不会创建大量未使用的内存结构。这就是为什么我们必须使用传递给 walker() 方法的 Function 作为参数的原因。这也是为什么在创建时,StackTrace 不会自动转换为 StackTraceElement 的原因。这只会发生在我们查询特定 StackTraceElement 的方法名和行号时。重要的是要理解这种转换需要相当多的时间,如果它被用于代码中的某些调试目的,那么它不应该被留下。

为了使 StackWalker 更加快速,我们可以提供一个关于在流中将处理多少 StackFrame 元素的估计。如果我们不提供此类估计,JDK 中的当前实现将预先分配八个 StackFrame 对象,当这些用完时,JDK 将分配更多。JDK 将根据我们的估计分配元素的数量,除非我们估计的值大于 256。在这种情况下,JDK 将使用 256。

摘要

在本章中,我们学习了如何使用 StackWalker 并提供了示例代码。我们对 API 的详细审查包括不同的使用场景、选项和信息。我们解释了 API 的复杂性,并分享了如何以及如何不使用该类。我们以一些用户必须注意的相关性能问题结束。

在下一章中,我们将介绍十几个被纳入 Java 9 平台的 Java 增强提案。这些主要变更将涵盖广泛的工具和 API 更新,旨在使使用 Java 开发更容易,并能够创建优化的 Java 应用程序。我们将探讨新的 HTTP 客户端、Javadoc 和 Doclet API 的变更、新的 JavaScript 解析器、JAR 和 JRE 的变更、新的 Java 级 JVM 编译器接口、对 TIFF 图像的支持、平台日志记录、XML 目录支持、集合、新的平台特定桌面功能,以及方法处理和弃用注解的增强。

第十二章:新工具和工具增强

在上一章中,我们探讨了 Java 9 的新堆栈跟踪器 API,并学习了它如何使 Java 应用程序能够遍历调用堆栈。这是一个在 Java 应用程序中不常实现的专业功能。话虽如此,该 API 可能适用于一些非常特殊的情况,例如由框架提供的功能。您了解到,如果您开发支持框架的应用程序编程,并且希望代码依赖于调用上下文,那么堆栈跟踪器 API 就是您所需要的。我们还发现,该 API 提供了对调用堆栈的快速和优化访问,实现了对单个帧的懒访问。

在本章中,我们将介绍被纳入 Java 9 平台的 16 个Java 增强提案JEPs)。这些 JEPs 涵盖了广泛的工具和 API 更新,以使使用 Java 开发更容易,并为我们的程序提供更大的优化可能性。

我们对新工具和工具增强的审查将包括以下内容:

  • 新的 HTTP 客户端

  • Javadoc 和 Doclet API

  • mJRE 变更

  • JavaScript 解析器

  • 多版本 JAR 文件

  • Java 级别的 JVM 编译器接口

  • TIFF 支持

  • 平台日志记录

  • XML 目录

  • 集合

  • 平台特定的桌面功能

  • 增强的方法处理

  • 增强的弃用

新 HTTP 客户端[JEP-110]

在本节中,我们将回顾 Java 的超文本传输协议HTTP)客户端,从 Java 9 之前的视图开始,然后深入探讨 Java 9 平台中的新 HTTP 客户端。这种做法是为了支持对 Java 9 中做出的更改的理解。

Java 9 之前的 HTTP 客户端

JDK 版本 1.1 引入了支持 HTTP 特定功能的HttpURLConnection API。这是一个包含此处列出的字段的健壮类:

  • chunkLength

  • fixedContentLength

  • fixedContentLengthLong

  • HTTP_ACCEPTED

  • HTTP_BAD_GATEWAY

  • HTTP_BAD_METHOD

  • HTTP_BAD_REQUEST

  • HTTP_CLIENT_TIMEOUT

  • HTTP_CONFLICT

  • HTTP_CREATED

  • HTTP_ENTITY_TOO_LARGE

  • HTTP_FORBIDDEN

  • HTTP_GONE

  • HTTP_INTERNAL_ERROR

  • HTTP_LENGTH_REQUIRED

  • HTTP_MOVED_PERM

  • HTTP_MOVED_TEMP

  • HTTP_MULT_CHOICE

  • HTTP_NO_CONTENT

  • HTTP_NOT_ACCEPTABLE

  • HTTP_NOT_AUTHORITATIVE

  • HTTP_NOT_FOUND

  • HTTP_NOT_IMPLEMENTED

  • HTTP_NOT_MODIFIED

  • HTTP_OK

  • HTTP_PARTIAL

  • HTTP_PAYMENT_REQUIRED

  • HTTP_PRECON_FAILED

  • HTTP_PROXY_AUTH

  • HTTP_REQ_TOO_LONG

  • HTTP_RESET

  • HTTP_SEE_OTHER

  • HTTP_SERVER_ERROR

  • HTTP_UNAUTHORIZED

  • HTTP_UNAVAIABLE

  • HTTP_UNSUPPORTED_TYPE

  • HTTP_USE_PROXY

  • HTTP_VERSION

  • instanceFollowRedirects

  • method

  • responseCode

  • responseMessage

如您从字段列表中可以看到,对 HTTP 的支持非常广泛。除了构造函数之外,还有许多可用的方法,包括以下这些:

  • disconnect()

  • getErrorStream()

  • getFollowRedirects()

  • getHeaderField(int n)

  • getHeaderFieldDate(String name, long Default)

  • getHeaderFieldKey(int n)

  • getInstanceFollowRedirects()

  • getPermission()

  • getRequestMethod()

  • getResponseCode()

  • getResponseMessage()

  • setChunkedStreamingMode(int chunklen)

  • setFixedLengthStreamingMode(int contentLength)

  • setFixedlengthStreamingMode(long contentLength)

  • setFollowRedirects(boolean set)

  • setInstanceFollowRedircts(boolean followRedirects)

  • setRequestMethod(String method)

  • usingProxy()

列出的类方法是在继承自 java.net.URLConnection 类和 java.lang.Object 类的方法的基础上增加的。

原始 HTTP 客户端存在一些问题,使其适合使用新的 Java 平台进行更新。这些问题如下:

  • 基础 URLConnection API 有过时的协议,如 Gopher 和 FTP,随着时间的推移越来越多

  • HttpURLConnection API 早于 HTTP 1.1,过于抽象,使其不太可用

  • HTTP 客户端文档不足,使 API 令人沮丧且难以使用

  • 客户端一次只能在一个线程上运行

  • 由于它早于 HTTP 1.1 以及缺乏足够的文档,该 API 极难维护

既然我们已经知道了 HTTP 客户端的问题,那么让我们看看 Java 9 将带来什么。

Java 9 的新 HTTP 客户端

创建 Java 9 平台的新 HTTP 客户端有几个相关目标。JEP-110 是新 HTTP 客户端的组织提案。JEP-110 的主要目标在此列出,并展示了所提出的新 HTTP 客户端。这些目标按易用性、核心功能、附加功能和性能等广泛类别呈现:

  • 易用性:

    • API 设计旨在提供高达 90% 的 HTTP 相关应用程序需求。

    • 新的 API 对于最常见的使用案例来说,无需不必要的复杂性即可使用。

    • 包含简单的阻塞模式。

    • API 支持现代 Java 语言特性。Java 8 中发布的新特性 Lambda 表达式是一个例子。

  • 核心功能:

    • 支持 HTTPS/TLS

    • 支持 HTTP/2

    • 提供有关 HTTP 协议请求和响应的所有细节的可见性

    • 支持标准/常用认证机制

    • 提供接收到的头部事件通知

    • 提供接收到的响应体事件通知

    • 提供错误事件通知

  • 附加功能:

    • 新的 API 可以用于 WebSocket 握手

    • 它与当前的网络 API 协同执行安全检查

  • 性能:

    • 对于 HTTP/1.1:

      • 新的 API 必须至少与旧 API 一样高效。

      • 当作为客户端 API 使用时,内存消耗不得超过 Apache HttpClient、Netty 和 Jetty。

    • 对于 HTTP/2:

      • 性能必须超过 HTTP/1.1。

      • 当作为客户端 API 使用时,新的性能必须与 Netty 和 Jetty 相匹配或超过,新客户端不应导致性能下降。

      • 当作为客户端 API 使用时,内存消耗不得超过 Apache HttpClient、Netty 和 Jetty。

    • 避免运行定时线程

新 API 的限制

新 API 有一些故意的不足之处。虽然这听起来可能有些反直觉,但新 API 并不是要完全取代当前的HttpURLConnection API。相反,新 API 的目的是最终取代当前的 API。

HttpURLConnect class to open and read a URL in a Java application:
    /*
    import statements
    */

    public class HttpUrlConnectionExample
    {  
      public static void main(String[] args) 
      {
        new HttpUrlConnectionExample();
      }

      public HttpUrlConnectionExample()
      {
        URL theUrl = null;
        BufferedReader theReader = null;
        StringBuilder theStringBuilder;

        // put the URL into a String
        String theUrl = "https://www.packtpub.com/";

        // here we are creating the connection
        theUrl = new URL(theUrl);
        HttpURLConnection theConnection = (HttpURLConnection) 
         theUrl.openConnection();

        theConnection.setRequestedMethod("GET");

        // add a delay 
        theConnection.setReadTimeout(30000); // 30 seconds
        theConnection.connect();

        // next, we can read the output
        theReader = new BufferedReader(
          new InputStreamReader(theConnection.getInputStream()));
        theStringBuilder =  new StringBuilder();

        // read the output one line at a time
        String theLine = null;
        while ((theLine = theReader.readLine() != null)
        {
          theStringBUilder.append(line + "n");
        }

        // echo the output to the screen console
        System.out.println(theStringBuilder.toString());

        // close the reader
        theReader.close();
      }
    }
    . . . 

为了简洁,前面的代码没有包括异常处理。

以下是新 API 的一些具体限制:

  • 并非所有与 HTTP 相关的功能都得到支持。据估计,大约有 10%的 HTTP 协议没有被 API 暴露。

  • 标准的/常见的身份验证机制仅限于基本身份验证。

  • 新 API 的总体目标是使用简单,这意味着性能提升可能不会实现。当然,不会出现性能下降,但也不太可能有一个压倒性的改进水平。

  • 不支持对请求进行过滤。

  • 不支持对响应进行过滤。

  • 新的 API 不包括可插拔的连接缓存。

  • 缺乏一个通用的升级机制。

新 API 作为 Java 9 平台的一部分以孵化模式提供。这表明该 API 将在未来的 Java 平台中得到标准化,也许是在 Java 10。

简化的 Doclet API [JEP-221]

Doclet API 和 Javadoc 密切相关。Javadoc 是一个文档工具,而 Doclet API 提供了功能,使我们能够检查嵌入在库和程序源代码级别的 javadoc 注释。在本节中,我们将回顾 Java 9 之前的 Doclet API 状态,然后探讨 Java 9 平台引入的 Doclet API 的变化。在下节中,我们将回顾 Javadoc。

在 Java 9 之前的 Doclet API

在 Java 9 之前的 Doclet API,或com.sun.javadoc包,使我们能够查看位于源代码中的 javadoc 注释。通过使用start方法来调用 Doclet。该方法签名是public static boolean start(RootDoc root)。我们将使用RootDoc实例作为程序结构信息的容器。

为了调用 javadoc,我们需要传递以下内容:

  • 包名

  • 源文件名(对于类和接口)

  • 访问控制选项——以下之一:

    • 私有的

    • 受保护的

    • 公共的

当使用前面列出的项目来调用 javadoc 时,会提供一个已记录的集合作为过滤后的列表。如果我们目的是获取一个全面、未过滤的列表,我们可以使用allClasses(false)

让我们回顾一个示例 Doclet:

    // Mandatory import statement.
    import com.sun.javadoc.*;

    // We will be looking for all the @throws documentation tags.
    public class AllThrowsTags extends Doclet 
    {
      // This is used to invoke the Doclet.
      public static boolean start(Rootdoc myRoot) 
      {
        // "ClassDoc[]" here referes to classes and interfaces.
        ClassDoc[] classesAndInterfaces = 
         myRoot.classesAndInterfaces();
        for (int i = 0; i < classesAndInterfaces.length; ++i)
        {
          ClassDoc tempCD = classesAndInterfaces[i];
          printThrows(tempCD.contructors());
          printThrows(tempCD.methods());
        }
        return true;
      }

      static void printThrows(ExecutableMemberDoc[] theThrows)
      {
        for (int i = 0; i < theThrows.length; ++i)
        {
          ThrowsTag[] throws = theThrows[i].throwsTags();

          // Print the "qualified name" which will be a the
             class or 
          // interface name.
          System.out.println(theThrows[i].qualifiedName());

          // A loop to print all comments with the Throws Tag that 
          // belongs to the previously printed class or
             interface name
          for (int j = 0; j < throws.length; ++j)
          {
            // A println statement that calls three methods
               from the 
            // ThrowsTag Interface: exceptionType(),
               exceptionName(),
            // and exceptionComment().
            System.out.println("--> TYPE: " +
              throws[j].exceptionType() + 
              " | NAME: " + throws[j].exceptionName() + 
              " | COMMENT: " + throws[j].exceptionComment());
          }
        }
      }
    }

如您通过详细注释的代码所看到的,获取 javadoc 内容相对容易。在我们的前一个例子中,我们将在命令行中使用以下代码调用AllThrows类:

javadoc -doclet AllThrowsTags -sourcepath <source-location> java.util

我们的结果输出将包括以下结构:

<class or interface name>
 TYPE: <exception type> | NAME: <exception name> | COMMENT: <exception comment>
 TYPE: <exception type> | NAME: <exception name> | COMMENT: <exception comment>
 TYPE: <exception type> | NAME: <exception name> | COMMENT: <exception comment>
<class or interface name>
 TYPE: <exception type> | NAME: <exception name> | COMMENT: <exception comment>
 TYPE: <exception type> | NAME: <exception name> | COMMENT: <exception comment>

API 枚举

该 API 包含一个枚举类型LanguageVersion,它提供了 Java 编程语言的版本。该枚举类型的常量是Java_1_1Java_1_5

API 类

Doclet类提供了一个如何创建一个类以启动 Doclet 的示例。它包含一个空的Doclet()构造函数和以下方法:

  • languageVersion()

  • optionLength(String option)

  • start(RootDoc root)

  • validOptions(String[][] options, DocErrorReporter reporter)

API 接口

Doclet API 包含以下列出的接口。接口名称是自我描述的。您可以查阅文档以获取更多详细信息:

  • AnnotatedType

  • AnnotationDesc

  • AnnotationDesc.ElementValuePair

  • AnnotationTypeDoc

  • AnnotationTypeElementDoc

  • AnnotationValue

  • ClassDoc

  • ConstructorDoc

  • Doc

  • DocErrorReporter

  • ExecutableMemberDoc

  • FieldDoc

  • MemberDoc

  • MethodDoc

  • PackageDoc

  • Parameter

  • ParameterizedType

  • ParamTag

  • ProgramElementDoc

  • RootDoc

  • SeeTag

  • SerialFieldTag

  • SourcePosition

  • Tag

  • ThrowsTag

  • Type

  • TypeVariable

  • WildcardType

存在的 Doclet API 的问题

需要新的 Doclet API 的原因是几个与现有 Doclet API 相关的问题:

  • 它不适合测试或并发使用。这源于其静态方法的实现。

  • API 中使用的语言模型存在几个限制,并且随着每个后续的 Java 升级而变得更加有问题。

  • 该 API 效率低下,很大程度上是由于其大量使用子字符串匹配。

  • 没有提供有关任何给定注释特定位置的参考。这使得诊断和故障排除变得困难。

Java 9 的 Doclet API

现在您已经很好地掌握了 Java 9 之前存在的 Doclet API,让我们看看 Java 9 平台中已经做出的更改和交付的内容。新的 Doclet API 位于jdk.javadoc.doclet包中。

在高层次上,Doclet API 的更改如下:

  • 更新com.sun.javadoc Doclet API 以利用几个 Java SE 和 JDK API

  • 更新com.sun.tools.doclets.standard.Standard Doclet 以使用新的 API

  • 支持更新的 Taglet API,用于创建自定义 javadoc 标签

除了前面列出的更改之外,新的 API 还使用了以下列出的两个 API:

  • 编译器树 API

  • 语言模型 API

让我们在接下来的部分中逐一探索这些内容。

编译器树 API

编译器树 API 位于com.sun.source.doctree包中。它提供了几个接口来记录源级注释。这些 API 表示为抽象语法树ASTs)。

有两个枚举类型:

  • AttributeTree.ValueKind具有以下常量:

    • DOUBLE

    • EMPTY

    • SINGLE

    • UNQUOTED

  • DocTree.Kind具有以下常量:

    • ATTRIBUTE

    • AUTHOR

    • CODE

    • COMMENT

    • DEPRECATED

    • DOC_COMMENT

    • DOC_ROOT

    • END_ELEMENT

    • ENTITY

    • ERRONEOUS

    • EXCEPTION

    • IDENTIFIER

    • INHERIT_DOC

    • LINK

    • LINK_PLAIN

    • LITERAL

    • OTHER

    • PARAM

    • REFERENCE

    • RETURN

    • SEE

    • SERIAL

    • SERIAL_DATA

    • SERIAL_FIELD

    • SINCE

    • START_ELEMENT

    • TEXT

    • THROWS

    • UNKNOWN_BLOCK_TAG

    • UNKNOWN_INLINE_TAG

    • VALUE

    • VERSION

com.sun.source.doctree 包含了几个接口。它们在以下表格中详细说明:

接口名称 扩展 以下树节点用于: 非继承方法
AttributeTree DocTree HTML 元素 getName(), getValue(), getValueKind()
AuthorTree BlockTagTree, DocTree @author 块标签 getName()
BlockTagTree DocTree 不同类型块标签的基类 getTagName()
CommentTree DocTree 带有以下 HTML 标签的嵌入式 HTML 注释--<!--text--> getBody()
DeprecatedTree BlockTagTree @deprecated 块标签 getBody()
DocCommentTree DocTree 主体块标签 getBlockTags(), getBody(), getFirstSentence()
DocRootTree InlineTagTree @docroot 内联标签 N/A
DocTree N/A 所有类的通用接口 accept(DocTreeVisitor<R,D>visitor,Ddata), getKind()
DocTreeVisitor<R,P> N/A R = 访问者方法的返回类型; P = 额外参数的类型 visitAttribute(AttributeTree node, P p), visitAuthor(AuthorTree node, P p), visitComment(CommentTree node, P p), visitDeprecated(DeprecatedTree node, P p), visitDocComment(DocCommentTree node, P p), visitDocRoot(DocRootTree node, P p), visitEndElement(EndElementTree node, P p), visitEntity(EntityTree node, P p), visitErroneous(ErroneousTree node, P p), visitIdentifier(IdentifierTree node, P p), visitInheritDoc(InheritDocTree node, P p), visitLink(LinkTree node, P p), visitLiteral(LiteralTree node, P p), visitOther(DocTree node, P p), visitParam(ParamTree node, P p), visitReference(ReferenceTree node, P p), visitReturn(ReturnTree node, P p), visitSee(SeeTree node, P p), visitSerial(SerialTree node, P p), visitSerialData(SerialDataTree node, P p), visitSerialField(SerialFieldTree node, P p), visitSince(SinceTree node, P p), visitStartElement(StartElementTree node, P p), visitText(TextTree node, P p), visitThrows(ThrowsTree node, P p), visitUnknownBlockTag(UnknownBlockTagTree node, P p), visitUnknownInlineTag(UnknownInlineTagTree node, P p), visitValue(ValueTree node, P p), visitVersion(VersionTree node, P p)
EndElementTree DocTree HTML 元素的结束 </name> getName()
EntityTree DocTree HTML 实体 getName()
ErroneousTree TextTree 这用于格式错误的文本 getDiagnostic()
IdentifierTree DocTree 注释中的标识符 getName()
InheritDocTree InlineTagTree @inheritDoc 内联标签 N/A
InlineTagTree DocTree 内联标签的通用接口 getTagName()
LinkTree InlineTagTree @link@linkplan 内联标签 getLabel(), getReference()
LiteralTree InlineTagTree @literal@code 内联标签 getBody()
ParamTree BlockTagTree @param块标签 getDescription(), getName(), isTypeParameter()
ReferenceTree DocTree 用于引用 Java 语言元素 getSignature()
ReturnTree BlockTagTree @return块标签 getDescription()
SeeTree BlockTagTree @see块标签 getReference()
SerialDataTree BlockTagTree @serialData块标签 getDescription()
SerialFieldTree BlockTagTree @serialData块标签和@serialField字段名和描述 getDescription(), getName(), getType()
SerialTree BlockTagTree @serial块标签 getDescription()
SinceTree BlockTagTree @since块标签 getBody()
StartElementTree DocTree HTML 元素的开始 < name [attributes] [/] > getAttributes(), getName(), isSelfClosing()
TextTree DocTree 纯文本 getBody()
ThrowsTree BlockTagTree @exception@throws块标签 getDescription(), getExceptionname()
UnknownBlockTagTree BlockTagTree 不可识别的内联标签 getContent()
UnknownInlineTagTree InlineTagTree 不可识别的内联标签 getContent()
ValueTree InlineTagTree @value内联标签 getReference()
VersionTree BlockTagTree @version块标签 getBody()

语言模型 API

语言模型 API 位于java.lang.model包中。它包括用于语言处理和语言建模的包和类。它由以下组件组成:

  • AnnotatedConstruct接口

  • SourceVersion枚举

  • UnknownEntityException异常

每个这些语言模型 API 组件将在接下来的三个部分中进一步探讨。

AnnotatedConstruct接口

AnnotatedConstruction接口为语言模型 API 提供了一个可注解的构造,该接口自 Java 平台 1.8 版本以来一直是 Java 平台的一部分。它适用于元素(接口Element)或类型(接口TypeMirror)的构造。这些构造的注释各不相同,如表中所示:

Construct type Interface Annotation
element Element 声明
type TypeMirror 基于类型名的使用

AnnotatedConstruction接口有三个方法:

  • getAnnotation(Class<A> annotationType): 此方法返回构造注释的类型

  • getAnnotationMirrors(): 此方法返回构造上存在的注释列表

  • getAnnotationsByType(Class<A> annotationType): 此方法返回与构造关联的注释

SourceVersion枚举

SourceVersion枚举包含以下常量:

  • RELEASE_0

  • RELEASE_1

  • RELEASE_2

  • RELEASE_3

  • RELEASE_4

  • RELEASE_5

  • RELEASE_6

  • RELEASE_7

  • RELEASE_8

预计一旦 Java 9 平台正式发布,SourceVersion枚举将更新以包括RELEASE_9

此枚举还包含几个方法,如下所示:

方法名称: isIdentifier

public static boolean isIdentifier(CharSequence name)

如果参数字符串是 Java 标识符或关键字,则此方法返回true

方法名称: isKeyword

public static boolean isKeyword(CharSequence s)

如果给定的CharSequence是字面量或关键字,则此方法返回true

方法名称: isName

public static boolean isName(CharSequence name)

如果CharSequence是有效的名称,则此方法返回true

方法名称: latest

public static SourceVersion latest()

此方法返回用于建模目的的最新源版本。

方法名称: latestSupported

public static SourceVersion latestSupported()

此方法返回可以完全支持的最新源版本,用于建模。

方法名称: valueOf

public static SourceVersion valueOf(String name)

此方法根据提供的参数字符串返回枚举常量。

您应该知道,value(String name)方法会抛出两个异常:IllegalArgumentExceptionNullPointerException

方法名称: values

public static SourceVersion[] values()

此方法返回枚举常量的数组。

未知实体异常

UnknownEntityException类继承自RuntimeException,并且是未知异常的超类。类的构造函数如下:

    protected UnknownEntityException(String message)

构造函数使用提供的字符串参数作为消息创建UnknownEntityException的新实例。该方法不接受任何额外的参数。

此类没有自己的方法,但如所示,从java.lang.Throwableclass.java.lang.Object类继承方法:

java.lang.Throwable类的方法:

  • addSuppressed()

  • fillInStackTrace()

  • getCause()

  • getLocalizedMessage()

  • getMessage()

  • getStackTrace()

  • getSuppressed()

  • initCause()

  • printStackTrace()

  • setStackTrace()

  • toString()

java.lang.Object类的方法:

  • clone()

  • equals()

  • finalize()

  • getClass()

  • hashCode()

  • notify()

  • notifyAll()

  • wait()

HTML5 Javadoc [JEP-224]

Javadoc 工具已经针对 Java 9 平台进行了更新。现在它不仅可以生成 HTML 4 标记输出,还可以生成 HTML 5 标记输出。新的 Javadoc 工具提供了对 HTML 4 和 HTML 5 的支持。

即使 Java 9 平台出现,HTML 4 也将继续作为默认的 Javadoc 输出格式。HTML 5 将是一个选项,并且不会成为默认输出标记格式,直到 Java 10。

以下简短的 Java 应用程序简单地生成一个宽度和高度均为319的框架。这里显示的是没有 Javadoc 标签的版本,我们将在本节后面讨论:

    /import javax.swing.JFrame;
    import javax.swing.WindowConstants;

    public class JavadocExample 
    {

      public static void main(String[] args) 
      {
        drawJFrame();
      }

      public static void drawJFrame()
      {
        JFrame myFrame = new JFrame("Javadoc Example");
        myFrame.setSize(319,319);
        myFrame.setDefaultCloseOperation(
          WindowConstants.EXIT_ON_CLOSE);
        myFrame.setVisible(true);
      }
    }

一旦你的包或类完成,你可以使用 Javadoc 工具生成 Javadoc。你可以从命令行或从你的 JDK /bin 目录中的 集成开发环境IDE)内部运行 Javadoc 工具。每个 IDE 处理 Javadoc 生成的方式都不同。例如,在 Eclipse 中,你会从下拉菜单中选择“项目”,然后选择“生成 Javadoc”。在 IntelliJ IDEA IDE 中,你选择“工具”下拉菜单,然后选择“生成 Javadoc”。

以下截图显示了 IntelliJ IDEA 中生成 Javadoc 功能的界面。正如你所见,-html5 命令行参数已被包含:

图片

当点击“确定”按钮时,你会看到一系列状态消息,如下例所示:

"C:Program FilesJavajdk-9binjavadoc.exe" -public -splitindex -use -author -version -nodeprecated -html5 @C:UserselaviAppDataLocalTempjavadoc1304args.txt -d C:Chapter11JD-Output
Loading source file C:Chapter11srcJavadocExample.java...
Constructing Javadoc information...
Standard Doclet version 9
Building tree for all the packages and classes...
Generating C:Chapter11JD-OutputJavadocExample.html...
Generating C:Chapter11JD-Outputpackage-frame.html...
Generating C:Chapter11JD-Outputpackage-summary.html...
Generating C:Chapter11JD-Outputpackage-tree.html...
Generating C:Chapter11JD-Outputconstant-values.html...
Generating C:Chapter11JD-Outputclass-useJavadocExample.html...
Generating C:Chapter11JD-Outputpackage-use.html...
Building index for all the packages and classes...
Generating C:Chapter11JD-Outputoverview-tree.html...
Generating C:Chapter11JD-Outputindex-filesindex-1.html...
Generating C:Chapter11JD-Outputindex-filesindex-2.html...
Generating C:Chapter11JD-Outputindex-filesindex-3.html...
Building index for all classes...
Generating C:Chapter11JD-Outputallclasses-frame.html...
Generating C:Chapter11JD-Outputallclasses-frame.html...
Generating C:Chapter11JD-Outputallclasses-noframe.html...
Generating C:Chapter11JD-Outputallclasses-noframe.html...
Generating C:Chapter11JD-Outputindex.html...
Generating C:Chapter11JD-Outputhelp-doc.html...

javadoc exited with exit code 0

当 Javadoc 工具退出后,你就可以查看 Javadoc。以下是基于之前提供的代码生成的截图。正如你所见,它以与 Oracle 正式 Java 文档相同的方式格式化:

图片

当我们生成 Javadoc 时,会创建多个文档,如下面的截图提供的目录树所示:

图片

你还可以添加 Javadoc 工具可识别的可选标签。这些标签在此提供:

  • @author

  • @code

  • @deprecated

  • @docRoot

  • @exception

  • @inheritDoc

  • @link

  • @linkplain

  • @param

  • @return

  • @see

  • @serial

  • @serialData

  • @serialField

  • @since

  • @throws

  • @value

  • @version

关于如何为 Javadoc 工具编写文档注释的更多信息,你可以访问 Oracle 的官方说明www.oracle.com/technetwork/articles/java/index-137868.html

Javadoc 搜索 [JEP-225]

在 Java 9 之前,标准 Doclet 生成的 API 文档页面使得导航变得困难。除非你非常熟悉这些文档页面的布局,否则你可能会使用基于浏览器的查找功能来搜索文本。这被认为是不灵活且次优的。

Java 9 平台将搜索框作为 API 文档的一部分。这个搜索框由标准 Doclet 提供,可以用来在文档中搜索文本。这对开发者来说非常方便,可能会改变我们对 Doclet 生成文档的使用方式。

使用新的 Javadoc 搜索功能,我们可以搜索以下索引组件:

  • 模块名

  • 包名

  • 类型

  • 成员

  • 使用新 @index 内联标签索引的术语/短语

介绍驼峰式搜索

新的 Javadoc 搜索功能包括一个使用驼峰式搜索的快捷方式。例如,我们可以搜索 openED 来找到 openExternalDatabase() 方法。

移除启动时 JRE 版本选择 [JEP-231]

在 Java 9 之前,我们可以使用mJRE多个 JRE)功能来指定用于启动应用程序的特定 JRE 版本或版本范围。我们可以通过命令行选项-version或在 JAR 文件清单中添加条目来实现这一点。以下流程图展示了根据我们的选择会发生什么:

此功能是在 JDK 5 中引入的,在该版本或任何后续版本中都没有得到充分记录。

Java 9 平台引入了以下具体更改:

  • mJRE 功能已被移除。

  • 当使用-version命令行选项时,启动器现在将产生一个错误。这是一个终止错误,处理将不会继续。

  • 如果 JAR 的清单中有-version条目,将会产生一个警告。这个警告不会停止执行。

有趣的是,清单文件中存在-version条目只会生成一个警告。这是出于设计考虑,考虑到该条目可能存在于较旧的 JAR 文件中。预计当 Java 10 平台发布时,此警告将变为终止错误。

Nashorn 解析器 API [JEP-236]

JEP 236 的重点是创建 Nashorn 的 ECMAScript 抽象语法树的 API。在本节中,我们将分别查看 Nashorn、ECMAScript 以及解析器 API。

Nashorn

Oracle Nashorn 是 Oracle 在 Java 中开发的 JVM JavaScript 引擎。它与 Java 8 一起发布。它被创建为向开发者提供一个高效且轻量级的 JavaScript 运行时引擎。使用此引擎,开发者能够将 JavaScript 代码嵌入到他们的 Java 应用程序中。在 Java 8 之前,开发者可以访问由 Netscape 创建的 JavaScript 引擎。该引擎于 1997 年推出,由 Mozilla 维护。

Nashorn 既可以作为命令行工具,也可以作为 Java 应用程序中的嵌入式解释器使用。让我们看看这两个方面的例子。

Nashorn 是德语中犀牛的意思。这个名字来源于 Mozilla 基金会命名的 Rhino JavaScript 引擎。据说 Rhino 的名字来源于 JavaScript 书籍封面上的动物图片。把这个归入有趣的事实

将 Nashorn 用作命令行工具

Nashorn 的可执行文件jjs.exe位于bin文件夹中。要访问它,你可以导航到该文件夹,或者如果你的系统路径设置得当,你可以在系统的终端/命令提示符窗口中输入jjs命令来启动 shell:

在这里,你可以看到一个打开的终端窗口,首先检查 Java 版本,然后使用jjs -version命令启动 Nashorn shell。在这个例子中,Java 和 Nashorn 都是 1.8.0.121 版本。或者,我们也可以简单地使用jjs命令启动 Nashorn,shell 将打开而不显示版本标识:

接下来,让我们创建一个简短的 JavaScript 并使用 Nashorn 运行它。考虑以下具有三条简单输出行的简单 JavaScript 代码。

    var addtest = function()
    {
      print("Simple Test");
      print("This JavaScript program adds the numbers 300
       and 19.");
      print("Addition results = " + (300 + 19));
    }
    addtest();

要让 Java 运行这个 JavaScript 应用程序,我们将使用jjs address.js命令。以下是输出:

你可以用 Nashorn 做很多事情。从命令提示符/终端窗口,我们可以使用jjs-help选项来查看完整的命令行命令列表:

正如你所见,使用-scripting选项使我们能够使用 Nashorn 作为文本编辑器来创建脚本。当使用 Nashorn 时,有几个内置函数非常有用:

  • echo(): 这与 Java 方法System.out.print()类似

  • exit(): 这将退出 Nashorn

  • load(): 这将从给定的路径或 URL 加载一个脚本

  • print(): 这与 Java 方法System.out.print()类似

  • readFull(): 这将读取文件的内容

  • readLine(): 这从stdin读取一行

  • quit(): 这将退出 Nashorn

将 Nashorn 用作嵌入式解释器

与将其用作命令行工具相比,Nashorn 的一个更常见的用途是将其用作嵌入式解释器。javax.script API 是公开的,可以通过nashorn标识符访问。以下代码演示了如何在 Java 应用程序中访问 Nashorn,定义一个 JavaScript 函数,并获取结果:

    // required imports
    import javax.script.ScriptEngine;
    import javax.script.ScriptEngineManager;

    public class EmbeddedAddTest 
    {
      public static void main(String[] args) throws Throwable
      {
        // instantiate a new ScriptEngineManager
        ScriptEngineManager myEngineManager =
          new ScriptEngineManager();

        // instantiate a new Nashorn ScriptEngine
        ScriptEngine myEngine = myEngineManager.getEngineByName(
         "nashorn");

        // create the JavaScript function
        myEngine.eval("function addTest(x, y) { return x + y; }");

        // generate output including a call to the addTest function
           via the engine
        System.out.println("The addition results are:
         " + myEngine.eval("addTest(300, 19);"));
      }
    }

这里是控制台窗口提供的输出:

这是一个简单的例子,以给你一个关于如何使用 Nashorn 嵌入使用的一些可能性的概念。在 Oracle 的官方文档中有很多充足的例子。

EMCAScript

EMCA欧洲计算机制造商协会)成立于 1961 年,作为一个既为信息系统又为通信系统制定标准的一个组织。如今,EMCA 继续制定标准和发布技术报告,以帮助标准化消费电子、信息系统和通信技术的使用。他们有超过 400 个 ECMA 标准,其中大部分已被采用。

你会注意到 EMCA 不再使用全部大写字母拼写,因为它不再被视为一个缩写词。1994 年,欧洲计算机制造商协会正式将其名称更改为 EMCA。

EMCAScript,也称为 ES,于 1997 年作为一个脚本语言规范被创建。JavaScript 实现了这个规范。该规范包括以下内容:

  • 补充技术

  • 脚本语言语法

  • 语义

解析器 API

Java 平台在版本 9 中的一个变化是为 Nashorn 的 EMCAScript 抽象语法树提供特定的支持。新 API 的目标是提供以下内容:

  • 用于表示 Nashorn 语法树节点的接口

  • 能够创建可以配置命令行选项的解析器实例

  • 为与 AST 节点接口而设计的访问者模式 API

  • 使用 API 的测试程序

新的 API,jdk.nashorn.api.tree,被创建以允许对 Nashorn 类进行未来的更改。在新解析器 API 之前,IDE 使用 Nashorn 的内部 AST 表示进行代码分析。根据 Oracle 的说法,使用 idk.nashorn.internal.ir 包阻止了 Nashorn 内部类的现代化。

下面是查看新 jdk.nashorn.api.tree 包的类层次结构:

下图展示了新 API 的复杂性,包括完整的接口层次结构:

jdk.nashorn.api.tree 包的最后一个组件是枚举层次结构,如下所示:

多版本 JAR 文件 [JEP-238]

JAR 文件格式在 Java 9 平台上得到了扩展,现在允许在单个 JAR 文件中存在多个版本的类文件。类版本可以针对特定的 Java 发布版本。这种增强允许开发人员使用单个 JAR 文件来容纳他们软件的多个版本。

JAR 文件增强包括以下内容:

  • 支持 JarFile API

  • 支持标准类加载器

JAR 文件格式的更改导致了核心 Java 工具的必要更改,以便它们能够解释新的多版本 JAR 文件。这些核心工具包括以下内容:

  • javac

  • javap

  • jdeps

最后,新的 JAR 文件格式支持模块化作为 Java 9 平台的关键特性。对 JAR 文件格式的更改并没有导致相关工具或过程的性能降低。

识别多版本 JAR 文件

多版本 JAR 文件将有一个新的属性,Multi-Release: true。此属性位于 JAR MANIFEST.MF 的主部分。

目录结构将在标准 JAR 文件和多版本 JAR 文件之间有所不同。以下是典型 JAR 文件结构的示例:

此图展示了新的多版本 JAR 文件结构,其中包含针对 Java 8 和 Java 9 的特定版本类文件:

相关 JDK 更改

为了支持新的多版本 JAR 文件格式,必须对 JDK 进行一些更改。这些更改包括以下内容:

  • URLClassLoader 是基于 JAR 的,并且经过修改,可以读取指定版本的类文件。

  • 新的基于模块的类加载器,Java 9 的新特性,被编写成可以读取指定版本的类文件。

  • java.util.jar.JarFile 类已被修改,以便从多版本 JAR 文件中选择适当的类版本。

  • JAR URL 方案的协议处理程序已被修改,以便从多版本 JAR 文件中选择适当的类版本。

  • Java 编译器 javac 被修改为读取已识别的类文件版本。这些版本识别是通过使用 -target-release 命令行选项与 JavacFileManager API 和 ZipFileSystem API 来实现的。

  • 以下工具已被修改以利用对 JavacFileManager API 和 ZipFileSystem API 的更改:

    • javah:这会生成 C 头文件和源文件

    • schemagen:这是 Java 类中命名空间的模式生成器

    • wsgen:这是用于部署 Web 服务的解析器

  • javap 工具已更新以支持新的版本方案。

  • jdeps 工具已被修改以支持新的版本方案。

  • JAR 打包工具集已相应更新。此工具集包括 pack200unpack200

  • 当然,JAR 工具也得到了增强,可以创建多版本 JAR 文件。

所有相关文档都已更新,以支持建立和支持新的多版本 JAR 文件格式所涉及的所有更改。

Java 级 JVM 编译器接口 [JEP-243]

JEP-243 的目的是创建基于 Java 的 JVM 编译器接口JVMCI)。JVMCI 允许 JVM 使用(必须是用 Java 编写的)Java 编译器作为动态编译器。

对 JVMCI 的需求背后的推理是它将是一个高度优化的编译器,不需要低级语言特性。一些 JVM 子系统需要低级功能,例如垃圾收集和 bytemode 解释。因此,JVMCI 是用 Java 而不是 C 或 C++ 编写的。这带来了 Java 一些最伟大特性的附带好处,如下所示:

  • 异常处理

  • 既是免费又强大的 IDE

  • 内存管理

  • 运行时可扩展性

  • 同步

  • 单元测试支持

由于 JVMCI 是用 Java 编写的,因此可以说它更容易维护。

JVMCI API 有三个主要组件:

  • 虚拟机数据结构访问

  • 安装带有其元数据的编译代码

  • 使用 JVM 的编译系统

JVMCI 实际上在 Java 8 中就已经存在,某种程度上。JVMCI API 只可通过一个适用于启动类路径上代码的类加载器访问。在 Java 9 中,这将发生变化。它仍将在 Java 9 中保持实验性,但更易于访问。为了启用 JVMCI,必须使用以下一系列命令行选项:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler - Djvmci.Compiler=<name of compiler>

Oracle 在 Java 9 中将 JVMCI 保持为实验性,以允许进一步的测试,并为开发者提供最大的保护。

BeanInfo 注解 [JEP-256]

JEP-256 专注于用更合适的注解替换 @beanifo javadoc 标签。此外,这些新注解现在在运行时进行处理,以便可以动态生成 BeanInfo 类。Java 9 的模块化导致了这一变化。自定义 BeanInfo 类的创建已简化,客户端库也已模块化。

为了全面理解这个变化,在进一步探讨这个 JEP 之前,我们将回顾 JavaBeanBeanPropertySwingContainer

JavaBean

JavaBean 是一个 Java 类。与其他 Java 类一样,JavaBeans 是可重用的代码。它们在设计上独特,因为它们将多个对象封装到一个中。一个 JavaBean 类必须遵循以下三个约定:

  • 构造函数不应接受任何参数

  • 它必须是可序列化的

  • 它必须为其属性包含修改器和访问器方法

这里是一个 JavaBean 类的示例:

    public class MyBean implements java.io.Serializable 
    {
      // instance variables  
      private int studentId;
      private String studentName;

      // no-argument constructor
      public MyBean() 
      {

      }

      // mutator/setter 
      public void setStudentId(int theID)
      {
        this.studentId = theID;
      }

      // accessor/getter
      public int getStudentId()
      {
        return studentId;
      }

      // mutator/setter 
      public void setStudentName(String theName)
      {
        this.studentName = theName;
      }

      // accessor/getter
      public String getStudentName()
      {
        return studentName;
      }

    }

访问 JavaBean 类就像使用修改器和访问器方法一样简单。这可能对你来说并不陌生,但你可能不知道你精心编写的那些类被称为 JavaBean 类。

BeanProperty

BeanProperty 是一个注解类型。我们使用这个注解来指定一个属性,以便我们可以自动生成 BeanInfo 类。这是 Java 9 中的一个新注解。

BeanProperty 注解有以下可选元素:

  • boolean bound

  • String description

  • String[] enumerationValues

  • boolean expert

  • boolean hidden

  • boolean preferred

  • boolean required

  • boolean visualUpdate

SwingContainer

SwingContainer 是一个注解类型。我们使用这个注解来指定与 Swing 相关的属性,以便我们可以自动生成 BeanInfo 类。这是 Java 9 中的一个新注解。

SwingContainer 注解有以下可选元素:

  • String delegate

  • boolean value

现在我们已经回顾了 JavaBeanBeanPropertySwingContainer,让我们来看看 BeanInfo 类。

BeanInfo 类

大部分情况下,BeanInfo 类在运行时自动生成。例外的是 Swing 类。这些类基于 @beaninfo javadoc 标签生成 BeanInfo 类。这是在编译时完成的,而不是在运行时。在 Java 9 中,@beaninfo 标签已被 @interface JavaBean@interface BeanProperty@interface SwingContainer 注解所取代。

SwingContainer:
    package javax.swing;

    public @interface SwingContainer
    {
      boolean value() default false;
      String delegate() default "";
    }

这为我们提供了三个好处:

  • 在 Bean 类中指定属性将比创建单个 BeanInfo 类要容易得多

  • 我们将能够移除自动生成的类

  • 客户端库使用这种方法可以更容易地进行模块化

TIFF 图像输入/输出 [JEP-262]

JEP-262 非常直接。对于 Java 9,图像输入/输出插件已扩展以包括对 TIFF 图像格式的支持。ImageIO 类扩展了 Object 类,并是 Java SE 的一部分。该类包含用于编码和解码图像的几个方法。以下是一个静态方法列表:

方法 返回值
createImageInputStream(Object input) ImageInputStream
createImageOutputStream(Object output) ImageOutputStream
getCacheDirectory() 当前 CacheDirectory 的值
getImageReader(ImageWriter writer) ImageReader
getImageReaders(Object input) 当前ImageReaders的迭代器
getImageReadersByFormatName(String formatName) 具有指定格式名称的当前ImageReaders的迭代器
getImageReadersByMIMEType(String MIMEType) 指定 MIME 类型的当前ImageReaders的迭代器
getImageReadersBySuffix(String fileSuffix) 具有指定后缀的当前ImageReaders的迭代器。
getImageTranscoders(ImageReader reader) 当前ImageTranscoders的迭代器
getImageWriter(ImageReader reader) ImageWriter
getImageWriters(ImageTypeSpecifier type, String formatName) 可以编码到指定类型的当前ImageWriters的迭代器
getImageWritersByFormatName(String formatName) 具有指定格式名称的当前ImageWriters的迭代器
getImageWritersByMIMEType(String MIMEType) 指定 MIME 类型的当前ImageWriters的迭代器
getImageWritersBySuffix(String fileSuffix) 具有指定后缀的当前ImageWriters的迭代器
getReaderFileSuffixes() 当前读取器理解的文件后缀的字符串数组
getReaderFormatNames() 当前读取器理解的格式名称的字符串数组
getReaderMIMETypes() 当前读取器理解的 MIME 类型的字符串数组
getUseCache() UseCache
getWriterFileSuffixes() 当前写入器理解的文件后缀的字符串数组
getWriterFormatNames() 当前写入器理解的格式名称的字符串数组
getWriterMIMETypes() 当前写入器理解的 MIME 类型的字符串数组
read(File input) 带有ImageReaderBufferedImage
read(ImageInputStream stream) 带有ImageInputStreamImageReaderBufferedImage
read(InputStream input) 带有InputStreamImageReaderBufferedImage
read(URL input) 带有ImageReaderBufferedImage

也有一些不返回值或返回布尔值的静态方法:

方法 描述

| scanForPlugins() | 执行以下操作: |

  • 在应用程序类路径中扫描插件

  • 加载插件服务提供程序类

  • 在 IIORegistry 中注册服务提供程序实例

|

setCacheDirectory(File cacheDirectory) 缓存文件将存储在这里。
setUseCache(boolean useCache) 此方法切换缓存是否基于磁盘。这适用于ImageInputStreamImageOutputStream实例。
write(RenderedImage im, String formatName, File output) 将图像写入指定的文件。
write(RenderedImage im, String formatName, ImageOutputStream output) 将图像写入ImageOutputStream
write(RenderedImage im, String formatName, OutputStream output) 将图像写入OutputStream

从提供的方法中,您可以了解到图像输入/输出框架为我们提供了一个方便的方式来使用图像编解码器。截至 Java 7,以下图像格式插件是由 javax.imageio 实现的:

  • BMP

  • GIF

  • JPEG

  • PNG

  • WBMP

如您所见,TIFF 并不在图像文件格式的列表中。TIFFs 是一种常见的文件格式,在 2001 年,随着 MacOS X 的发布,macOS 广泛使用了这种格式。

Java 9 平台包括用于 TIFFs 的 ImageReaderImageWriter 插件。这些插件是用 Java 编写的,并打包在新的 javax.imageio.plugins.tiff 包中。

平台日志 API 和服务 [JEP-264]

Java 9 平台包括一个新的日志 API,允许平台类记录消息。它有一个相应的服务来操作日志。在我们深入探讨关于日志 API 和服务的新特性之前,让我们回顾一下在 Java 7 中引入的 java.util.logging.api

java.util.logging

java.util.logging 包包含类和接口,共同构成了 Java 的核心日志功能。该功能创建的目标如下:

  • 最终用户和系统管理员的问题诊断

  • 现场服务工程师的问题诊断

  • 开发组织的问题诊断

如您所见,主要目的是为了使远程软件的维护成为可能。

java.util.logging 包包含两个接口:

  • public interface Filter

    • 目的:这提供了对记录数据的精细控制

    • 方法:

      • isLoggable(LogRecord record)
  • public interface LoggingMXBean

    • 目的:这是日志设施的管理接口

    • 方法:

      • getLoggerLevel(String loggerName)

      • getLoggerNames()

      • getparentLoggerName(String loggerName)

      • setLoggerLevel(String loggerName, String levelName)

以下表格提供了 java.util.logging 包的类,以及每个类在日志功能和管理方面的简要描述:

定义 描述
ConsoleHandler public class ConsoleHandler extends StreamHandler 将日志记录发布到 System.err
ErrorManager public class ErrorManager extends Object 用于在记录过程中处理错误
FileHandler public class FileHandler extends StreamHandler 文件记录
Formatter public abstract class Formatter extends Object 用于格式化 LogRecords
Handler public abstract class Handler extends Object 导出 Logger 消息
Level public class Level extends Object implements Serializable 控制日志级别。级别按降序排列为--严重、警告、信息、配置、精细、更精细和最精细
Logger public class Logger extends Object 记录消息
LoggingPermission public final class LoggingPermission extends BasicPermission SecurityManager 检查此权限
LogManager public class LogManager 用于在日志记录器和日志服务之间维护共享状态
LogRecord public class LogRecord extends Object implements Serializable 在处理器之间传递
MemoryHandler public class MemoryHandler extends Handler 在内存中缓冲请求
SimpleFormatter public class SimpleFormatter extends Formatter 提供可读的 LogRecord 元数据
SocketHandler public class SocketHandler extends StreamHandler 网络日志处理器
StreamHandler public class StreamHandler extends Handler 基于流的日志处理器
XMLFormatter public class XMLFormatter extends Formatter 将日志格式化为 XML

接下来,让我们回顾一下 Java 9 中所做的更改。

Java 9 中的日志记录

在 Java 9 之前,有多个日志架构可供选择,包括 java.util.loggingSLF4JLog4J。后两者是具有独立门面和实现组件的第三方框架。这种模式在新 Java 9 平台上得到了复制。

Java 9 对 java.base 模块进行了修改,以便它将处理日志功能,而不是依赖于 java.util.logging API。它具有独立的门面和实现组件。这意味着当使用第三方框架时,JDK 只需提供实现组件并返回与请求的日志框架协同工作的平台日志记录器。

如以下插图所示,我们使用 java.util.ServiceLoader API 加载我们的 LoggerFinder 实现方式。如果使用系统类加载器找不到具体实现,JDK 将使用默认实现:

XML 目录 [JEP-268]

JEP 268,标题为“XML 目录”,专注于创建一个标准的 XML 目录 API 以支持 OASIS XML 目录标准 v1.1。新的 API 定义了目录和目录解析抽象,以便 JAXP 处理器可以使用它们。在本节中,我们将探讨以下内容:

  • OASIS XML 目录标准

  • JAXP 处理器

  • Java 9 之前的 XML 目录

  • Java 9 平台变更

OASIS XML 目录标准

XML (可扩展标记语言) 目录是由目录条目组成的 XML 文档。每个条目将一个标识符与另一个位置配对。OASIS 是一个非营利性联盟,其使命是推进开放标准。他们在 2005 年发布了 XML 目录标准,版本 1.1。此标准有两个基本用例:

  • 将外部标识符映射到 URI 引用

  • 将 URI 引用映射到另一个 URI 引用

下面是一个示例 XML 目录条目:

    <public publicId="-//Packt Publishing Limited//Mastering Java 9//EN"
     uri="https://www.packtpub.com/application-development/mastering-java-9"/>

完整的 OASIS XML 目录标准可以在官方网站找到:www.oasis-open.org/committees/download.php/14809/xml-catalogs.html

JAXP 处理器

Java API for XML processing 被称为 JAXP。正如其名称所暗示的,此 API 用于解析 XML 文档。有四个相关接口:

  • DOM:文档对象模型解析

  • SAX:XML 解析的简单 API

  • StAX:XML 解析的流式 API

  • XSLT:转换 XML 文档的接口

Java 9 之前的 XML 目录

Java 平台自 JDK 6 以来就有一个内部目录解析器。当时没有公开的 API,因此使用外部工具和库来访问功能。进入 Java 9,目标是使内部目录解析器成为通用 API 的标准,以简化支持和使用。

Java 9 平台变更

新的 XML 目录 API,随 Java 9 提供,遵循 OASIS XML 目录标准,v1.1。以下是功能和能力的高亮:

  • 实现EntityResolver

  • 实现URIResolver

  • 通过CatalogManager创建 XML 目录是可能的

  • 使用CatalogManager创建CatalogResolvers

  • 将遵循 OASIS 开放目录文件语义

    • 将外部标识符映射到 URI 引用

    • 将 URI 引用映射到另一个 URI 引用

  • CatalogResolvers将实现 JAXP EntityResolver接口

  • CatalogResolvers将实现 JAXP URIResolver接口

  • 解析器将支持 SAX XMLFilter

由于新的 XML 目录 API 将是公开的,因此 Java 9 之前的内部目录解析器将被移除,因为它将不再必要。

集合的便利工厂方法 [JEP-269]

Java 编程语言不支持集合字面量。在 2013 年提出将此功能添加到 Java 平台,并在 2016 年重新审视,但它仅作为研究提案曝光,而不是未来的实现。

Oracle 对集合字面量的定义是“一种评估为聚合类型的语法表达式形式,例如数组、列表或映射”(openjdk.java.net/jeps/186)。

当然,直到 Java 9 发布。在 Java 编程语言中实现集合字面量据报道有以下好处:

  • 性能提升

  • 提高安全性

  • 减少样板代码

即使不是研究小组的一部分,我们对 Java 编程语言的知识也使我们了解到额外的优势:

  • 能够编写更短的代码

  • 能够编写空间高效的代码

  • 使集合字面量不可变的能力

让我们看看两个案例——在 Java 9 之前使用集合,然后在新 Java 平台中对集合字面量的新支持。

在 Java 9 之前使用集合

这里是一个示例,说明我们如何在 Java 9 之前创建自己的集合。这个类定义了PlanetCollection的结构。它有以下组件:

  • 单个实例变量

  • 一个单参数构造函数

  • 修改器/设置方法

  • 访问器/获取方法

  • 打印对象的方法

这里是实现前面列出的构造函数和方法的代码:

    public class PlanetCollection 
    {
      // Instance Variable
      private String planetName;

      // constructor
      public PlanetCollection(String name)
      {
        setPlanetName(name);
      }

      // mutator
      public void setPlanetName(String name)
      {
        this.planetName = name;
      }

      // accessor
      public String getPlanetName()
      {
        return this.planetName;
      }

      public void print()
      {
        System.out.println(getPlanetName());
      }
    }

现在,让我们看看填充集合的驱动类:

    import java.util.ArrayList;

    public class OldSchool 
    {
      private static ArrayList<PlanetCollection> myPlanets =
        new ArrayList<>();

      public static void main(String[] args) 
      {
        add("Earth");
        add("Jupiter");
        add("Mars");
        add("Venus");
        add("Saturn");
        add("Mercury");
        add("Neptune");
        add("Uranus");
        add("Dagobah");
        add("Kobol");

        for (PlanetCollection orb : myPlanets)
        {
          orb.print();
        }

      }

      public static void add(String name)
      {
        PlanetCollection newPlanet = new PlanetCollection(name);
        myPlanets.add(newPlanet);
      }
    }

下面是此应用程序的输出:

很遗憾,这段代码非常冗长。我们使用静态初始化块而不是字段初始化来填充我们的集合。还有其他填充列表的方法,但它们都比应有的冗长。这些其他方法还有额外的问题,例如需要创建额外的类、使用晦涩的代码和隐藏的引用。

现在,让我们看看新 Java 9 平台提供的解决方案,我们将在下一节中查看新功能。

使用新的集合字面量

为了纠正目前创建集合所需的代码冗长性,我们需要用于创建集合实例的库 API。查看上一节中的 Java 9 之前的代码片段,然后考虑以下可能的重构:

    PlanetCollection<String> myPlanets = Set.of(
      "Earth",
      "Jupiter",
      "Mars",
      "Venus",
      "Saturn",
      "Mercury",
      "Neptune",
      "Uranus",
      "Dagobah",
      "Kobol");

这段代码高度可读,且不冗长。

新的实现将包括以下接口上的静态工厂方法:

  • List

  • Map

  • Set

因此,我们现在能够创建不可修改的 List 集合、Map 集合和 Set 集合的实例。它们可以使用以下语法进行实例化:

  • List.of(a, b, c, d, e);

  • Set.of(a, b, c, d, e);

  • Map.of();

Map 集合将有一组固定参数。

平台特定桌面功能 [JEP-272]

令人兴奋的 JEP-272 是创建一个新的公共 API,这样我们就可以编写具有访问平台特定桌面功能的应用程序。这些功能包括与任务栏/托盘交互以及监听应用程序和系统事件。

macOS X 的 com.apple.eawt 包是一个内部 API,从 Java 9 开始不再可访问。为了支持 Java 9 的新嵌入式平台特定桌面功能,apple.applescript 类将从 Java 平台中移除,而不提供替代方案。

此努力有几个目标:

  • 创建一个公共 API 来替代 com.apple.{east,eio} 中的功能

  • 确保 OS X 开发者不会丢失功能。为此,Java 9 平台为以下包提供了替代方案:

    • com.apple.eawt

    • com.apple.eio

  • 为开发者提供了一套接近通用的功能集,用于平台(即 Windows 和 Linux)以及 OS X。通用功能包括:

    • 具有事件监听器的登录/注销处理器

    • 具有事件监听器的屏幕锁定处理器

    • 包括任务栏/托盘操作:

      • 请求用户注意

      • 指示任务进度

      • 动作快捷键

新 API 将添加到 java.awt.Desktop 类中。

增强方法句柄 [JEP-274]

增强方法句柄 JEP-274 的目的是改进以下列出的类,以便通过改进的优化使常见用法更容易:

  • MethodHandle

  • MethodHandles

  • MethodHandles.Lookup

列出的类都是java.lang.invoke包的一部分,该包作为 Java 9 平台的一部分进行了更新。这些改进是通过使用查找细化以及MethodHandle组合for循环和try...finally块实现的。

在本节中,我们将探讨有关 JEP-274 的以下内容:

  • 增强的原因

  • 查找函数

  • 参数处理

  • 其他组合

增强的原因

此增强源于开发者的反馈和使MethodHandleMethodHandlesMethodHandles.Lookup类更易于使用的愿望。同时也有增加额外用例的呼吁。

这些变化带来了以下好处:

  • MethodHandle API 的使用中启用精度

  • 实例化减少

  • 增加 JVM 编译器优化

查找函数

关于 Java 9 平台中的查找函数的更改包括以下内容:

  • MethodHandles现在可以绑定到接口中的非抽象方法

  • 查找 API 允许从不同的上下文进行类查找

MethodHandles.Lookup.findSpecial(Class<?> refs, String name, MethodType type, Class<?> specialCaller)类已被修改,允许在接口上定位可调用的超方法。

此外,以下方法已被添加到MethodHandles.Lookup类中:

  • Class<?> findClass(String targetName)

  • Class<?> accessClass(Class<?> targetClass)

参数处理

对 Java 9 平台上的MethodHandle参数处理进行了三项更新。以下是对这些更改的概述:

  • 使用foldArguments(MethodHandle target, MethodHandle combinator)进行参数折叠之前没有位置参数。

    • 使用MethodHandle.asCollector(Class<?> arrayType, int arrayLength)方法进行参数收集之前不支持将参数收集到数组中,除了尾随元素。现在已进行更改,并新增了asCollector方法以支持 Java 9 中的该功能。
  • 使用MethodHandle.asSpreader(Class<?> arrayType, int arrayLength)方法通过反向收集参数的方式将尾随数组的内容扩展到多个参数中。参数扩展已被修改以支持在方法签名中的任何位置扩展数组。

下一个部分提供了更新后的asCollectorasSpreader方法的新方法定义。

其他组合

以下组合已被添加以支持 Java 9 平台中java.lang.invoke包的MethodHandleMethodHandlesMethodHandles.Lookup类的易用性和优化:

  • 通用循环抽象:

    • MethodHandle loop(MethodHandle[] . . . clauses)
  • While循环:

    • MethodHandle whileLoop(MethodHandle init, MethodHandle pred, MethodHandle body)
  • Do...while循环:

    • `MethodHandle doWhileLoop(MethodHandle init, MethodHandle body, MethodHandle pred)`
  • 计数循环:

    • MethodHandle countedLoop(MethodHandle iterations, MethodHandle init, MethodHandle body)
  • 数据结构迭代:

    • MethodHandle iteratedLoop(MethodHandle iterator, MethodHandle init, MethodHandle body)
  • Try...finally块:

    • MethodHandle tryFinally(MethodHandle target, MethodHandle cleanup)
  • 参数处理:

    • 参数展开:

      • MethodHandle asSpreader(int pos, Class<?> arrayType, int arrayLength)
    • 参数收集:

      • MethodHandle asCollector(int pos, Class<?> arrayType, int arrayLength)
    • 参数折叠:

      • MethodHandle foldArguments(MethodHandle target, int pos, MethodHandle combiner)

增强弃用[JEP-277]

表达弃用的有两种设施:

  • @Deprecated注解

  • @deprecated javadoc 标签

这些设施分别在 Java SE 5 和 JDK 1.1 中引入。@Deprecated注解的目的是注解不应使用因为它们被认为是有危险的和/或存在更好的选择程序组件。这是预期用途。实际使用各不相同,包括因为警告仅在编译时提供;几乎没有理由忽略注解的代码。

增强弃用 JEP-277 被采纳,以向开发者提供有关规范文档中 API 预期处置的更清晰信息。这项 JEP 的工作还导致了一个分析程序对弃用 API 使用的工具。

为了支持这种信息保真度,以下组件已被添加到java.lang.Deprecated注解类型中:

  • forRemoval()

    • 如果 API 元素已被计划未来删除,则返回布尔值true

    • 如果 API 元素尚未计划未来删除但已弃用,则返回布尔值false

    • 默认为false

  • since()

    • 返回一个包含发布或版本号的字符串,在该版本号下指定的 API 被标记为弃用

@Deprecated注解真正意味着什么

当 API 或 API 内的方法被标记为@Deprecated注解时,通常存在以下一个或多个条件:

  • API 中存在错误,但没有计划修复它们

  • 使用该 API 可能会导致错误

  • 该 API 已被另一个 API 取代

  • 该 API 是实验性的

摘要

在本章中,我们涵盖了 16 个被纳入 Java 9 平台的 JEP。这些 JEP 涵盖了广泛的工具和 API 更新,使使用 Java 更容易,并为我们的程序提供了更大的优化可能性。我们的审查包括对新的 HTTP 客户端、Javadoc 和 Doclet API 的更改、新的 JavaScript 解析器、JAR 和 JRE 更改、新的 Java 级别 JVM 编译器接口、对 TIFF 图像的新支持、平台日志记录、XML 目录支持、集合以及新的平台特定桌面功能。我们还审查了方法处理和弃用注解的增强。

在下一章中,我们将介绍 Java 9 平台引入的并发增强功能。我们的主要关注点将是流类 API 提供的响应式编程支持。我们还将探讨 Java 9 中引入的其他并发增强功能。

第十三章:并发与响应式编程

在上一章中,我们介绍了被纳入 Java 9 平台的几个Java 增强提案JEPs)。这些 JEPs 代表了一系列工具和 API 的更新,使得使用 Java 进行开发更加容易,并为我们的 Java 应用程序提供了更大的优化可能性。我们探讨了新的 HTTP 客户端、Javadoc 和 Doclet API 的更改、新的 JavaScript 解析器、JAR 和 JRE 的更改、新的 Java 级别 JVM 编译器接口、对 TIFF 图像的新支持、平台日志、XML 目录支持、集合以及新的平台特定桌面功能。我们还探讨了方法处理增强和弃用注解。

在本章中,我们将介绍 Java 9 平台引入的并发增强。我们的主要重点是支持响应式编程,这是由Flow类 API 提供的并发增强。响应式编程是 Java 9 的一个新概念,因此我们将对该主题采取探索性方法。我们还将探讨 Java 9 中引入的其他并发增强。

具体来说,我们将涵盖以下主题:

  • 响应式编程

  • 新的Flow API

  • 额外的并发更新

  • 自旋等待提示

响应式编程

响应式编程是指应用程序在异步数据流发生时对其进行响应。以下图像展示了流程:

响应式编程不是一个只有学者使用的花哨的软件工程术语。实际上,它是一种编程模型,与更常见的应用程序迭代内存中数据的方法相比,可以带来更高的效率。

响应式编程还有更多内容。首先,让我们考虑数据流是由发布者以异步方式提供给订阅者的。

数据流是字符串和原始数据类型的二进制输入/输出。DataInput接口用于输入流,DataOutput接口用于输出流。

处理器或处理器链可以用来转换数据流,而不会影响发布者或订阅者。在以下示例中,处理器在数据流上工作,而不涉及发布者订阅者,甚至没有意识到:

除了更高的效率外,响应式编程还代表了几项额外的优势,以下将重点介绍:

  • 代码库可以更简洁,使其:

    • 更易于编码

    • 更易于维护

    • 更易于阅读

  • 流处理导致内存效率提高

  • 这是一个适用于各种编程应用的解决方案

  • 需要编写的样板代码更少,因此可以将开发时间集中在编程核心功能上

  • 以下类型的编程需要更少的时间和代码:

    • 并发

    • 低级线程

    • 同步

响应式编程标准化

软件开发的许多方面都有标准,反应式编程也不例外。有一个反应式流倡议旨在标准化异步流处理。在 Java 的上下文中,具体关注的是 JVM 和 JavaScript。

反应式流倡议旨在解决如何管理线程之间数据流交换的问题。正如您在前一节中回忆的那样,处理器的概念基于对发布者或接收者没有影响。这一无影响的要求规定以下内容是不必要的:

  • 数据缓冲

  • 数据转换

  • 转换

标准的基本语义定义了数据流元素传输的规范。这个标准是专门为与 Java 9 平台一起交付而建立的。反应式流包括一个库,可以帮助开发者从org.reactivestreamsjava.util.concurrent.Flow命名空间进行转换。

在反应式编程和反应式流标准化中取得成功的关键是理解相关的术语:

术语 描述
需求 需求指的是订阅者请求更多元素,同时也指尚未由发布者满足的请求元素的总数。
需求 需求也指尚未由发布者满足的请求元素的总数。
外部同步 用于线程安全的外部访问协调。
非阻塞 如果方法快速执行而不需要大量计算,则称这些方法为非阻塞方法。非阻塞方法不会延迟订阅者的线程执行。
NOP NOP 执行是指可以反复调用而不影响调用线程的执行。
响应性 这个术语指的是组件的响应能力。
正常返回 正常返回指的是没有错误发生时的正常状态。onError 方法是标准允许的唯一一种通知订阅者失败的方式。

| 信号 | 以下方法之一:

  • cancel

  • onComplete

  • onError

  • onNext

  • onSubscribe

  • request

|

您可以从 Maven Central(search.maven.org)获取标准。以下是截至本书出版日期的 Maven Central 上的标准:

    <dependency>
      <groupId>org.reactivestreams</groupId>
      <artifactId>reative-streams</artifactId>
      <version>1.0.1</version>
    </dependency>

    <dependency>
      <groupId>org.reactivestreams</groupId>
      <artifact>reactive-streams-tck</artifactId>
      <version>1.0.0</version>
      <scope>test</scope>
    </dependency>

在下一节中,我们将探讨 Java 9 平台中的 Flow API,因为它们对应于反应式流规范。

新的流 API

Flow类是java.util.concurrent包的一部分。它帮助开发者将反应式编程融入他们的应用程序。该类有一个方法defaultBufferSize()和四个接口。

defaultBufferSize()是一个静态方法,它返回发布和订阅缓冲的默认缓冲区大小。这个默认值是256,并以int类型返回。让我们看看这四个接口。

Flow.Publisher接口

Flow.Publisher接口是一个功能接口。Publisher是向订阅者发送数据的生产者:

    @FunctionalInterface
    public static interface Flow.Publisher<T>

此功能接口可以作为 lambda 表达式赋值的目标。它只接受一个参数--订阅的项目类型<T>。它有一个方法:

  • void onSubscribe(Flow.Subscription subscription)

Flow.Subscriber接口

Flow.Subscriber接口用于接收消息,其实现如下:

    public static interface Flow.Subscriber<T>

此接口被设置为接收消息。它只接受一个参数--订阅的项目类型<T>。它具有以下方法:

  • void onComplete()

  • void onError(Throwable throwable)

  • void onNext(T item)

  • void onSubscribe(Flow.Subscription subscription)

Flow.Subscription接口

Flow.Subscription接口确保只有订阅者才能接收到请求的内容。同样,正如您将在这里看到的,订阅可以在任何时候取消:

    public static interface Flow.Subscription

此接口不接受任何参数,它是控制Flow.PublisherFlow.Subscriber实例之间消息的链接。它具有以下方法:

  • void cancel()

  • void request(long n)

Flow.Processor接口

Flow.Processor接口可以同时作为SubscriberPublisher。实现如下:

    static interface Flow.Processor<T,R> extends Flow.Subscriber<T>,
     Flow.Publisher<R>

此接口接受两个参数--订阅的项目类型<T>和发布的项目类型<R>。它没有自己的方法,但继承自java.util.concurrent.Flow.Publisher的以下方法:

  • void subscribe(Flow.Subscriber<? super T> subscriber)

Flow.Processor还从java.util.concurrent.Flow.Subscriber接口继承了以下方法:

  • void onComplete()

  • void onError(Throwable throwable)

  • void onNext(T item)

  • void onSubscribe(Flow.Subscription subscription)

示例实现

在任何给定的响应式编程实现中,我们都会有一个请求数据的Subscriber和一个提供数据的Publisher。让我们首先看看一个示例Subscriber实现:

    import java.util.concurrent.Flow.*;

    public class packtSubscriber<T> implements Subscriber<T>
    {
      private Subscription theSubscription;

      // We will override the four Subscriber interface methods

      @Override
      public void onComplete()
      {
        System.out.println("Data stream ended");
      }

      @Override
      public void onError(Throwable theError)
      {
        theError.printStackTrace();
      }

      @Override
      public void onNext(T theItem)
      {
        System.out.println("Next item received: " + theItem);
        theSubscription.request(19);  // arbitrary number for
         example purposes
      }

      @Override
      public void onSubscribe(Subscription theSubscription)
      {
        this.theSubscription = theSubscription;
        theSubscription.request(19);
      }

    } 

如您所见,实现Subscriber并不困难。繁重的工作是在SubscriberPublisher之间的处理器中完成的。让我们看看一个示例实现,其中Publisher向订阅者发布数据流:

    import java.util.concurrent.SubsmissionPublisher;

    . . . 

    // First, let's create a Publisher instance
    SubmissionPublisher<String> packtPublisher = new 
     SubmissionPublisher<>();

    // Next, we will register a Subscriber
    PacktSubscriber<String> currentSubscriber = new 
     PacktSubscriber<>();
    packtPublisher.subscribe(currentSubscriber);

    // Finally, we will publish data to the Subscriber and 
       close the publishing effort
    System.out.println("||---- Publishing Data Stream ----||");
    . . . 
    packtPublisher.close();
    System.out.println("||---- End of Data Stream Reached ----||");

其他并发更新

更多并发更新 Java 增强提案,JEP 266,旨在改善 Java 中的并发使用。在本节中,我们将简要探讨 Java 并发的概念,并查看对 Java 9 平台的有关增强:

  • Java 并发

  • 支持响应式流

  • CompletableFuture API 增强

Java 并发

在本节中,我们将从并发的简要解释开始,然后查看系统配置,涵盖 Java 线程,然后查看并发改进。

并发解释

并发处理自 1960 年代以来就已经存在。在那些形成年份,我们已经有允许多个进程共享单个处理器的系统。这些系统更明确地定义为伪并行系统,因为它只显示出多个进程正在同时执行。我们今天的计算机仍然以这种方式运行。1960 年代和当今的区别在于,我们的计算机可以有多个 CPU,每个 CPU 有多个核心,这更好地支持了并发。

并发和并行性经常被互换使用。并发是指多个进程重叠,尽管开始和结束时间可能不同。并行性发生在任务同时开始、运行和停止时。

系统配置

需要考虑几种不同的处理器配置。本节介绍了两种常见配置。第一种配置是共享内存,如图所示:

图片

如您所见,共享内存系统配置具有多个处理器,这些处理器都共享一个公共的系统内存。第二个特色系统配置是分布式内存系统:

图片

在分布式内存系统中,每个处理器都有自己的内存,每个单独的处理器与其他处理器完全连接,从而形成一个完全连接的分布式系统。

Java 线程

Java 中的线程是一个程序执行,它是 JVM 内置的。Thread 类是 java.lang 包的一部分(java.lang.Thread)。线程有优先级,控制 JVM 执行它们的顺序。虽然这个概念很简单,但实现并不简单。让我们先仔细看看 Thread 类。

Thread 类有两个嵌套类:

  • public static enum Thread.State

  • public static interface Thread.UncaughtExceptionHandler

有三个实例变量用于管理线程优先级:

  • public static final int MAX_PRIORITY

  • public static final int MIN_PRIORITY

  • public static final int NORM_PRIORITY

Thread 类有八个构造函数,所有这些构造函数都会分配一个新的 Thread 对象。以下是构造函数签名:

  • public Thread()

  • public Thread(Runnable target)

  • public Thread(Runnable target, String name)

  • public Thread(String name)

  • public Thread(ThreadGroup group, Runnable target)

  • public Thread(ThreadGroup group, Runnable target, String name)

  • public Thread(ThreadGroup group, Runnable target, String name, long stackSize)

  • public Thread(ThreadGroup group, String name)

Thread 类还有 43 个方法,其中 6 个已被弃用。其余的方法在此列出,除了访问器和修改器,它们单独列出。您可以查阅文档以了解每个方法的详细信息:

  • public static int activeCount()

  • public final void checkAccess()

  • protected Object clone() throws CloneNotSupportedException

  • public static Thread currentThread()

  • public static void dumpStack()

  • public static int enumerate(Thread[] array)

  • public static boolean holdsLock(Object obj)

  • public void interrupt()

  • public static boolean interrupted()

  • public final boolean isAlive()

  • public final boolean isDaemon()

  • public boolean isInterrupted()

  • join 方法:

    • public final void join() throws InterruptedException

    • public final void join(long millis) throws InterruptedException

    • public final void join(long millis, int nano) throws InterruptedException

  • public void run()

  • sleep 方法:

    • public static void sleep(long mills) throws InterruptedException

    • public static void sleep(long mills, int nano) throws InterruptedException

  • public void start()

  • public String toString()

  • public static void yield()

以下是 Thread 类的访问器/获取器和修改器/设置器的列表:

  • 访问器/获取器:

    • public static Map<Thread, StackTraceElement[]> getAllStacktraces()

    • public ClassLoader getContextClassLoader()

    • public static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler()

    • public long getId()

    • public final String getName()

    • public final int getPriority()

    • public StackTraceElement[] getStackTrace()

    • public Thread.State getState()

    • public final ThreadGroup getThreadGroup()

    • public Thread.UncaughtExceptionHandler getUncaughtExceptionHandler()

  • mutators/setters:

    • public void setContextClassLoader(ClassLoader cl)

    • public final void setDaemon(boolean on)

    • public static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)

    • public final void setName(String name)

    • public final void setPriority(int newPriority)

    • public void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)

在 Java 中,并发通常被称为多线程。如前所述,管理线程,尤其是多线程,需要极高的控制精度。Java 使用了一些技术,包括锁的使用。代码段可以被锁定,以确保在任何给定时间只有一个线程可以执行该代码。我们可以使用 synchronized 关键字来锁定类和方法。以下是如何锁定整个方法的示例:

    public synchronized void protectedMethod()
    {
      . . . 
    }

下面的代码片段演示了如何使用 synchronized 关键字在方法内锁定代码块:

    . . . 
    public class unprotectedMethod()
    {
      . . . 
      public int doSomething(int tValue) 
      {
        synchronized (this)
        {
          if (tValue != 0)
          {
            // do something to change tValue
            return tValue;
          }
        }
      } 
    }

并发改进

在我们的 Java 应用程序中,使用多线程可以大大提高效率并利用现代计算机不断增长的处理器能力。Java 中的线程使用为我们提供了在并发控制方面的极大粒度。

线程是 Java 并发功能的核心。我们可以在 Java 中通过定义一个 run 方法并实例化一个 Thread 对象来创建一个线程。完成这一组任务有两种方法。我们的第一个选项是扩展 Thread 类并重写 Thread.run 方法。以下是一个示例:

    . . .
    class PacktThread extends Thread
    {
      . . .
      public void run()
      {
        . . . 
      }
    }

    . . . 

    Thread varT = new PacktThread();

    . . .

    // This next line is start the Thread by executing
       the run() method.
    varT.start();

    . . . 

第二种方法是创建一个实现 Runnable 接口的类,并将该类的实例传递给 Thread 的构造函数。以下是一个示例:

    . . . 
    class PacktRunner implements Runnable
    {
       . . .
      public void run()
      {
        . . .
      }
    }

    . . . 

    PacktRunner varR = new PacktRunner();
    Thread varT = new Thread(varR);

    . . .

    // This next line is start the Thread by executing the 
       run() method.
    varT.start();

    . . . 

这两种方法效果相同,您使用哪一种取决于开发者的选择。当然,如果您需要额外的灵活性,第二种方法可能是一个更好的选择。您可以尝试这两种方法来帮助您做出决定。

CompletableFuture API 增强功能

CompleteableFuture<T> 类是 java.util.concurrent 包的一部分。该类扩展了 Object 类并实现了 Future<T>CompletionStage<T> 接口。此类用于注释可以完成的线程。我们可以使用 CompletableFuture 类来表示未来的结果。当使用 complete 方法时,该未来结果可以被完成。

重要的是要意识到,如果有多个线程尝试同时完成(完成或取消),除了一个之外,其他都会失败。让我们先看看这个类,然后再看看增强功能。

课程详情

CompleteableFuture<T> 类有一个内部类用于标记异步任务:

    public static interface
     CompletableFuture.AsynchronousCompletionTask

CompleteableFuture<T> 类的构造函数必须与提供的构造函数签名同步,并且不接受任何参数。该类有以下方法,按它们返回的内容组织。

返回一个 CompletionStage:

  • public CompletableFuture<Void> acceptEither(CompletionStage<? extends T> other, Consumer<? super T> action)

  • public CompletableFuture<Void> acceptEitherAsync(CompletionStage<? extends T> other, Consumer<? super T> action)

  • public CompletableFuture<Void> acceptEitherAsync(CompletionStage<? extends T> other, Consumer<? super T> action, Executor executor)

  • public <U> CompletableFuture<U> applyToEither(CompletionStage<? extends T> other, Function<? super T, U> fn)

  • public <U> CompletableFuture<U> applyToEitherAsync(CompletionStage<? extends T> other, Function<? super T, U> fn)

  • public <U> CompletableFuture<U> applyToEitherAsync(CompletionStage<? extends T> other, Function<? super T, U> fn, Executor executor)

  • public static <U> CompletedStage<U> completedStage(U value)

  • public static <U> CompletionStage<U> failedStage(Throwable ex)

  • public <U> CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)

  • public <U> CompletableFuture<U> handleAsync(BiFunction<? super T, Throwable, ? extends U> fn)

  • public <U> CompletableFuture<U> handleAsync(BiFunction<? super T, Throwable, ? extends U> fn, Executor executor)

  • public CompletionStage<T> minimalCompletionStage()

  • `public CompletableFuture<Void> runAfterBoth(CompletionStage<?> other, Runnable action)`

  • public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other, Runnable action)

  • public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other, Runnable action, Executor executor)

  • public CompletableFuture<Void> runAfterEither(CompletionStage<?> other, Runnable action)

  • public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other, Runnable action)

  • public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other, Runnable action, Executor executor)

  • public CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)

  • public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T, ? super Throwable> action)

  • public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T, ? super Throwable> action, Executor executor)

这些方法返回一个CompletionStage

  • public CompletableFuture<Void> thenAccept(Consumer<? super T> action)

  • public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action)

  • public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action, Executor executor)

  • public <U> CompletableFuture<Void> thenAcceptBoth(CompletionStage<? extends U> other, BiConsumer<? super T, ? super U> action)

  • public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other, BiConsumer<? super T, ? super U> action)

  • public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other, BiConsumer<? super T, ? super U> action, Executor executor)

  • `public <U> CompletableFuture<U> thenApply(Function<? super T, ? extends U> fn)`

  • public <U> CompletableFuture<U> thenApplyAsync(Function<? super T, ? extends U> fn)

  • public <U> CompletableFuture<U> thenApplyAsync(Function<? super T, ? extends U> fn, Executor executor)

  • public <U, V> CompletableFuture<V> thenCombine(CompletionStage<? extends U> other, BiFunction<? super T, ? super U, ? extends V> fn)

  • public <U, V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other, BiFunction<? super T, ? super U, ? extends V> fn)

  • public <U, V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other, BiFunction<? super T, ? super U, ? extends V> fn, Executor executor)

  • public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn)

  • public <U> CompletableFuture<U> thenComposeAsync(Function<? super T, ? extends CompletionStage<U>> fn)

  • public <U> CompletableFuture<U> thenComposeAsync(Function<? super T, ? extends CompletionStage<U>> fn, Executor executor)

  • public CompletableFuture<Void> thenRun(Runnable action)

  • public CompletableFuture<Void>thenRunAsync(Runnable action)

  • public CompletableFuture<Void>thenRunAsync(Runnable action, Executor executor)

这些方法返回一个CompleteableFuture

  • public static CompletableFuture<Void> allOf(CompletableFuture<?>...cfs)

  • public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)

  • public CompletableFuture<T> completeAsync(Supplier<? extends T> supplier, Executor executor)

  • public CompletableFuture<T> completeAsync(Supplier<? extends T> supplier)

  • `public static <U> CompletableFuture<U> completedFuture(U value)`

  • public CompletableFuture<T> completeOnTimeout(T value, long timeout, TimeUnit unit)

  • public CompletableFuture<T> copy()

  • public CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)

  • public static <U> CompletableFuture<U> failedFuture(Throwable ex)

  • public <U> CompletableFuture<U> newIncompeteFuture()

  • public CompletableFuture<T> orTimeout(long timeout, TimeUnit unit)

  • public static ComletableFuture<Void> runAsync(Runnable runnable)

  • public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)

  • public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)

  • public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)

  • public CompletableFuture<T> toCompletableFuture()

这些方法返回一个Executor

  • public Executor defaultExecutor()

  • public static Executor delayedExecutor(long delay, Timeunit unit, Executor executor)

  • public static Executor delayedExecutor(long delay, Timeunit unit)

这些方法返回一个boolean

  • public boolean cancel(boolean mayInterruptIfRunning)

  • public boolean complete(T value)

  • public boolean completeExceptionally(Throwable ex)

  • public boolean isCancelled()

  • public boolean isCompletedExceptionally()

  • public boolean isDone()

没有返回类型:

  • public void obtrudeException(Throwable ex)

  • public void obtrudeValue(T value)

其他方法:

  • public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException

  • public T get() throws InterruptedException, ExecutionException

  • public T getNow(T valueIfAbsent)

  • public int getNumberOfDependents()

  • public T join()

  • public String toString()

增强功能

作为 Java 9 平台的一部分,CompleteableFuture<T>类获得了以下增强:

  • 添加了基于时间增强:

    • 这使得基于超时的完成成为可能

    • 现在也支持延迟执行

  • 对子类的重要增强:

    • 扩展CompletableFuture更加容易

    • 子类支持替代默认执行器

具体来说,以下方法是在 Java 9 中添加的:

  • newIncompleteFuture()

  • defaultExecutor()

  • copy()

  • minimalCompletionStage()

  • completeAsync()

  • orTimeout()

  • `completeOnTimeout()`

  • delayedExecutor()

  • completedStage()

  • failedFuture()

  • failedStage()

自旋等待提示

在并发情况下,我们需要确保等待执行的任务线程实际上能够得到执行。自旋等待的概念是一个不断检查真实条件的过程。Java 增强提案 285 的目的是创建一个 API,允许 Java 代码发出提示,表明当前正在执行自旋循环。

虽然这不是每个 Java 开发者都会使用的功能,但它对于底层编程可能很有用。提示系统仅发出提示——指示,并不执行其他操作。添加这些提示的理由包括以下假设:

  • 在使用自旋提示时,自旋循环的动作时间可以得到改善

  • 使用自旋提示将减少线程间的延迟

  • CPU 功耗将降低

  • 硬件线程将执行得更快

这个提示功能将包含在java.lang.Thread类的新onSpinWait()方法中。以下是如何实现onSpinWait()方法的示例:

    . . . 

    volatile boolean notInReceiptOfEventNotification; 

    . . . 

    while ( notInReceiptOfEventNotification );
    {
      java.lang.Thread.onSpinWait();
    }

    // Add functionality here to read and process the event

    . . . 

摘要

在本章中,我们介绍了 Java 9 平台引入的并发增强功能。我们深入探讨了并发,既作为 Java 的核心概念,也着眼于 Java 9 所提供的内容。我们还探讨了支持响应式编程的Flow类 API,这是 Java 9 中的新概念。此外,我们还探讨了 Java 9 中引入的并发增强和新自旋等待提示。

在下一章中,我们将突出介绍 Java 9 引入的安全增强功能,以及实际示例。

第十四章:安全增强

在上一章中,我们介绍了 Java 9 平台引入的并发增强功能。我们深入探讨了并发作为 Java 的核心概念以及作为 Java 9 的一系列增强。我们还探讨了支持响应式编程的Flow类 API,这是 Java 9 中的新概念。此外,我们还探讨了 Java 9 中引入的并发增强和新型的自旋等待提示。

在本章中,我们将探讨对 JDK 进行的一些涉及安全性的小改动。这些改动的大小并不反映它们的显著性。Java 9 平台引入的安全增强为开发者提供了编写和维护比以前更安全的应用程序的能力。

具体来说,在本章中,我们将回顾以下内容领域:

  • 数据报传输层安全

  • 创建 PKCS12 密钥库

  • 提高安全应用程序性能

  • TLS 应用层协议协商扩展

  • 利用 CPU 指令进行 GHASH 和 RSA

  • TLS 的 OCSP 粘合

  • 基于 DRBG 的SecureRandom实现

数据报传输层安全

数据报传输层安全DTLS)是一种通信协议。该协议为基于数据报的应用提供一层安全。DTLS 允许安全通信,并基于传输层安全TLS)协议。嵌入式安全有助于确保消息不被伪造、篡改或窃听。

让我们回顾一下相关的术语:

  • 通信协议:一组规则,用于规范信息的传输。

  • 数据报:一种结构化传输单元。

  • 窃听:对传输中的数据包进行未检测到的监听。

  • 伪造:发送伪造发送者的数据包。

  • 网络数据包:用于传输的格式化数据单元。

  • 篡改:在发送者发送数据包后和预期接收者接收它们之前更改数据包。

  • TLS 协议:最常见的网络安全协议。例如,用于电子邮件的 IMPA 和 POP。

DTLS Java 增强提案 219 旨在为 DTLS 版本 1.0 和 1.2 创建一个 API。

在接下来的章节中,我们将查看 DTLS 的每个版本,1.0 和 1.2,然后回顾 Java 9 平台的变化。

DTLS 协议版本 1.0

DTLS 协议版本 1.0 于 2006 年建立,为数据报协议提供通信安全。以下是基本特征:

  • 允许客户端/服务器应用程序通信,但不允许:

    • 窃听

    • 篡改

    • 消息伪造

  • 基于 TLS 协议

  • 提供安全保证

  • DLS 协议的数据报语义得到保留

以下图表说明了传输层SSL/TLS协议层和每一层协议的整体架构中的位置:

图片

DTLS 协议版本 1.0 提供了详细的规范,主要覆盖领域如下所示:

  • 密码:

    • 反重放块密码

    • 新密码套件

    • 标准或空流密码

  • 服务拒绝对策

  • 握手:

    • 消息格式

    • 协议

    • 可靠性

  • 消息:

    • 分片和重组

    • 对丢失不敏感的消息

    • 大小

    • 超时和重传

    • 数据包丢失

  • 路径最大传输单元PMTU)发现

  • 记录层

  • 记录有效载荷保护

  • 重新排序

  • 重放检测

  • 传输层映射

DTLS 协议版本 1.2

DTLS 协议版本 1.2 于 2012 年 1 月发布,并由 互联网工程任务组IETF)版权所有。本节分享了代码示例,说明了版本 1.2 的变化。

以下代码说明了 TLS 1.2 握手消息头。此格式支持:

  • 消息分片

  • 消息丢失

  • 重新排序

    // Copyright (c) 2012 IETF Trust and the persons identified as
       authors of the code. All rights reserved.

    struct 
    {
      HandshakeType msg_type;
      uint24 length;
      uint16 message_seq;                           // New field
      uint24 fragment_offset;                       // New field
      uint24 fragment_length;                       // New field
      select (HandshakeType) 
      {
        case hello_request: HelloRequest;
        case client_hello:  ClientHello;
        case hello_verify_request: HelloVerifyRequest;  // New type
        case server_hello:  ServerHello;
        case certificate:Certificate;
        case server_key_exchange: ServerKeyExchange;
        case certificate_request: CertificateRequest;
        case server_hello_done:ServerHelloDone;
        case certificate_verify:  CertificateVerify;
        case client_key_exchange: ClientKeyExchange;
        case finished: Finished;
      } body;
    } Handshake;

本节中展示的代码来自 DTLS 协议文档,并按照 IETF 的 关于 IETF 文档的法律条款 重新发布。

记录层包含我们打算放入记录中的信息。信息最初在 DTLSPlaintext 结构内部,然后,在握手发生后,记录被加密并可以由通信流发送。记录层格式在版本 1.2 中添加了新字段,并在代码注释中用 // 新字段 标注,如下所示:

    // Copyright (c) 2012 IETF Trust and the persons identified
       as authors of the code. All rights reserved.

    struct 
    {
      ContentType type;
      ProtocolVersion version;
      uint16 epoch;                                 // New field
      uint48 sequence_number;                       // New field
      uint16 length;
      opaque fragment[DTLSPlaintext.length];
    } DTLSPlaintext;

    struct 
    {
       ContentType type;
       ProtocolVersion version;
       uint16 epoch;                                 // New field
       uint48 sequence_number;                       // New field
       uint16 length;
       opaque fragment[DTLSCompressed.length];
    } DTLSCompressed;

    struct 
    {
       ContentType type;
       ProtocolVersion version;
       uint16 epoch;                                 // New field
       uint48 sequence_number;                       // New field
       uint16 length;
       select (CipherSpec.cipher_type) 
       {
          case block:  GenericBlockCipher;
          case aead:   GenericAEADCipher;             // New field
       } fragment;
    } DTLSCiphertext;

最后,以下是更新的握手协议:

    // Copyright (c) 2012 IETF Trust and the persons identified
       as authors of the code. All rights reserved.

    enum {
      hello_request(0), client_hello(1),
       server_hello(2),
      hello_verify_request(3),                       // New field
      certificate(11), server_key_exchange (12),
      certificate_request(13), server_hello_done(14),
      certificate_verify(15), client_key_exchange(16),
      finished(20), (255) } HandshakeType;

      struct {
        HandshakeType msg_type;
        uint24 length;
        uint16 message_seq;                            // New field
        uint24 fragment_offset;                        // New field
        uint24 fragment_length;                        // New field
        select (HandshakeType) {
          case hello_request: HelloRequest;
          case client_hello:  ClientHello;
          case server_hello:  ServerHello;
          case hello_verify_request: HelloVerifyRequest;  // New field
          case certificate:Certificate;
          case server_key_exchange: ServerKeyExchange;
          case certificate_request: CertificateRequest;
          case server_hello_done:ServerHelloDone;
          case certificate_verify:  CertificateVerify;
          case client_key_exchange: ClientKeyExchange;
          case finished: Finished;
        } body; } Handshake;

      struct {
        ProtocolVersion client_version;
        Random random;
        SessionID session_id;
        opaque cookie<0..2⁸-1>;                          // New field
        CipherSuite cipher_suites<2..2¹⁶-1>;
        CompressionMethod compression_methods<1..2⁸-1>; } ClientHello;

      struct {
        ProtocolVersion server_version;
        opaque cookie<0..2⁸-1>; } HelloVerifyRequest;

Java 9 中的 DTLS 支持

Java 9 对 DTLS API 的实现是传输无关且轻量级的。API 的设计考虑因素如下:

  • 读取超时将不会被管理

  • 实现将使用单个 TLS 记录进行每个封装/解封装操作

  • 应用程序(而非 API)将需要:

    • 确定超时值

    • 组装乱序的应用数据

DTLS 是一种协议,用于在将数据传递到传输层协议之前,从应用层安全数据。DTLS 是加密和传输实时数据的好解决方案。应谨慎行事,以确保我们不会在我们的应用程序实现中引入漏洞。以下是针对在 Java 9 应用程序中实现 DTLS 的特定安全考虑:

  • 实现 DTLS v1.2,因为这是 Java 9 支持的最新版本。

  • 避免使用 Rivest-Shamir-AdlemanRSA)加密。如果必须使用 RSA,请为您的私钥添加额外的安全性,因为这是 RSA 的弱点。

  • 在使用 椭圆曲线迪菲-赫尔曼ECDH)匿名密钥协商协议时,使用 192 位或更多位。192 位值基于 国家标准与技术研究院NIST)的建议。

  • 高度推荐使用 带关联数据的认证加密AEAD),这是一种加密形式。AEAD 为加密和解密的数据提供真实性、机密性和完整性保证。

  • 实现握手重协商时始终实现renegotiation_info扩展。

  • 在所有使用通信协议的 Java 应用程序中建立前向安全性FS)功能。实现 FS 确保当长期加密密钥被泄露时,过去的会话加密密钥不会被泄露。理想情况下,Java 应用程序将使用完美前向安全性PFS),其中每个密钥仅对单个会话有效,以实现传输数据的最大安全性。

创建 PKCS12 密钥库

Java 9 平台为密钥库提供了增强的安全性。为了理解 Java 增强提案 229 带来的变化,默认创建 PKCS12 密钥库,我们将首先回顾密钥库的概念,查看KeyStore类,然后查看这些变化。

密钥库简介

KeyStore的概念相对简单。它本质上是一个数据库文件,或数据存储文件,用于存储公钥证书和私钥。Keystore将存储在/jre/lib/security/cacerts文件夹中。正如您将在下一节中看到的那样,此数据库由 Java 的java.security.KeyStore类方法管理。

KeyStore功能包括:

  • 包含以下之一条目类型:

    • 私钥

    • 公钥证书

  • 每个条目都有唯一的别名字符串名称

  • 每个密钥的密码保护

Java 密钥库(JKS)

java.security.KeyStore类是加密密钥和证书的存储设施。此类扩展java.lang.Object,如下所示:

    public class KeyStore extends Object

KeyStore管理三种类型的条目,每个条目都实现了KeyStore.Entry接口,这是KeyStore类提供的三个接口之一。条目实现定义在以下表中:

实现 描述
KeyStore.PrivateKeyEntry
  • 包含PrivateKey并可以以受保护格式存储它

  • 包含公钥的证书链

|

KeyStore.SecretKeyEntry
  • 包含SecretKey并可以以受保护格式存储它

|

KeyStore.TrustedCertifcateEntry
  • 包含来自外部源的单个公钥证书

|

此类自 Java 1.2 版本以来就是 Java 平台的一部分。它有一个构造函数,三个接口,六个子类,以及几个方法。构造函数定义如下:

    protected KeyStore(KeyStoreSpi keyStoresSpi,
     Provider provider, String type)

KeyStore类包含以下接口:

  • public static interface KeyStore.Entry:

    • 此接口作为KeyStore条目类型的标记,不包含任何方法。
  • public static interface KeyStore.LoadStoreParameter:

    • 此接口作为加载和存储参数的标记,并具有以下返回 null 或用于保护KeyStore数据参数的方法:

      • getProtectionParameter()
  • public static interface KeyStore.ProtectionParameter:

    • 此接口作为KeyStore保护参数的标记,不包含任何方法。

java.security.KeyStore类还包含以下列出的六个嵌套类。

Builder

当您想要延迟KeyStore的实例化时使用KeyStore.Builder类:

    public abstract static class KeyStore.Builder extends Object

此类提供了实例化KeyStore对象所需的信息。该类具有以下方法:

  • public abstract KeyStore getKeyStore() throws KeyStoreException

  • public abstract KeyStore.ProtectionParameter getProjectionParameter(String alias) throws KeyStoreException

  • newInstance的三个选项:

    • public static KeyStore.Builder newInstance(KeyStore keyStore, KeyStore.ProtectionParameter protectionParameter)

    • public static KeyStore.Builder newInstance(String type, Provider provider, File file, KeyStore.ProtectionParameter protection)

    • public static KeyStore.Builder newInstance(String type, Provider provider, KeyStore.ProtectionParameter protection)

CallbackHandlerProtection

KeyStore.CallbackHandlerProtection类的定义如下:

    public static class KeyStore.CallbackHandlerProtection extends
     Object implements KeyStore.ProtectionParameter

此类提供ProtectionParameter以封装CallbackHandler,并具有以下方法:

    public CallbackHandler getCallbackHandler()

PasswordProtection

KeyStore.PasswordProtection类的定义如下:

    public static class KeyStore.PasswordProtection extends Object 
     implements KeyStore.ProtectionParameter, Destroyable

此调用提供了一个基于密码的ProtectionParameter实现。该类具有以下方法:

  • public void destroy() throws DestroyFailedException:

    • 此方法清除密码
  • public char[] getPassword():

    • 返回密码的引用
  • public boolean isDestroyed():

    • 如果密码已清除,则返回 true

PrivateKeyEntry

KeyStore.PrivateKeyEntry类的定义如下:

    public static final class KeyStore.PrivateKeyEntry extends
     Object implements KeyStore.Entry

此操作创建一个条目以保存私钥和相应的证书链。此类具有以下方法:

  • public Certificate getCertificate():

    • 证书链返回终端实体证书
  • public Certificate[] getCertificateChain():

    • 返回证书链作为证书数组
  • public PrivateKey getPrivateKey():

    • 从当前条目返回私钥
  • public String toString():

    • 返回PrivateKeyEntry作为String

SecretKeyEntry

KeyStore.SecretKeyEntry类的定义如下:

    public static final class KeyStore.SecretKeyEntry extends
     Object implements KeyStore.Entry

此类持有密钥并具有以下方法:

  • public SecretKey getSecretKey():

    • 返回条目的密钥
  • public String toString():

    • 返回SecretKeyEntry作为String

TrustedCertificateEntry

KeyStore.TrustedCertificateEntry类的定义如下:

    public static final class KeyStore.TrustedCertificateEntry extends
     Object implements KeyStore.Entry

此类持有受信任的证书并具有以下方法:

  • public Certificate getTrustedCertificate():

    • 返回条目的受信任证书
  • public String toString():

    • 将条目的受信任证书作为String返回

使用此类的关键是理解流程。首先,我们必须使用getInstance方法加载KeyStore。接下来,我们请求访问KeyStore实例。然后,我们可以读写Object

以下代码片段显示了加载-请求-访问实现:

    . . . 

    try {
      // KeyStore implementation will be returned for the default type
      KeyStore myKS = KeyStore.getInstance(KeyStore.getDefaultType());

      // Load
      myKS.load(null, null);

      // Instantiate a KeyStore that holds a trusted certificate
      TrustedCertificateEntry myCertEntry =
        new TrustedCertificateEntry(generateCertificate());

      // Assigns the trusted certificate to the "pack.pub" alias
      myKS.setCertificateEntry("packt.pub",
       myCertEntry.getTrustedCertificate());

      return myKS;
    } 
    catch (Exception e) {
      throw new AssertionError(e);
    }
  }
  . . .

Java 9 中的 PKCS12 默认设置

在 Java 9 之前,默认的KeyStore类型是Java 密钥存储JKS)。Java 9 平台现在使用 PKCS 作为默认的KeyStore类型,更具体地说,是 PKCS12。

PKCS公钥加密标准的缩写。

与 JKS 相比,此 PKCS 更改提供了更强的加密算法。正如你所期望的,JDK 9 仍然与 JKS 兼容,以支持先前开发的系统。

提高安全应用程序性能

Java 增强提案 232,标题为提高安全应用程序性能,专注于在安装了安全管理器的应用程序中运行时的性能改进。安全管理器可能导致处理开销和低于理想的应用程序性能。

这是一项令人印象深刻的任务,因为当前在运行安全管理器时,CPU 开销预计会导致 10-15%的性能下降。完全移除 CPU 开销是不可行的,因为运行安全管理器需要一些 CPU 处理。尽管如此,这个提案(JEP-232)的目的是尽可能减少开销百分比。

此项工作导致了以下优化,每个优化将在后续章节中详细说明:

  • 安全策略执行

  • 权限评估

  • 哈希码

  • 包检查算法

安全策略执行

JDK 9 使用ConcurrentHashMapProtectionDomain映射到PermissionCollectionConcurrentHashMap通常用于应用程序中的高并发。它具有以下特性:

  • 线程安全

  • 进入映射不需要同步

  • 快速读取

  • 写操作使用锁

  • 没有对象级别的锁定

  • 在非常细粒度的级别上锁定

ConcurrentHashMap 类定义如下:

    public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> 
     implements ConcurrentMap<K, V>, Serializable

在前面的类定义中,K 指的是哈希图中维护的键的类型,而 V 表示映射值的类型。存在一个 KeySetView 子类和几个方法。

与执行安全策略相关的有三个额外的类--ProtectionDomainPermissionCollectionSecureClassLoader

  • ProtectionDomain 类用于封装一组类,以便可以授予该域权限。

  • PermissionCollection 类表示一组权限对象。

  • SecureClassLoader 类扩展了ClassLoader类,为定义具有系统策略检索权限的类提供了额外的功能。在 Java 9 中,此类使用ConcurrentHashMap以增强安全性。

权限评估

在权限评估类别下,进行了三项优化:

  • identifyPolicyEntries列表之前有策略提供者代码用于同步。在 JDK 9 中已删除此代码。

  • PermissionCollection条目现在存储在ConcurrentHashMap中。之前它们作为Permission类中的HashMap存储。

  • 权限现在存储在PermissionCollection子类的并发集合中。

java.security.CodeSource

哈希码是一个由对象生成的数字,用于存储在哈希表中以实现快速存储和检索。Java 中的每个对象都有一个哈希码。以下是哈希码的一些特性和规则:

  • 在运行过程中,对于相等的对象,哈希码是相同的

  • 哈希码可以在执行周期之间改变

  • 哈希码不应用作键

Java 9 平台包括修改后的java.security.CodeSourcehashCode方法,以优化 DNS 查找。这些查找可能非常耗时,因此使用代码源 URL 的字符串版本来计算哈希码。

CodeSource类的定义如下:

    public class CodeSource extends Object implements Serializable

此类有以下方法:

  • public boolean equals(Object obj): 如果对象相等,则返回true。这覆盖了Object类中的equals方法。

  • public final Certificate[] getCertificates(): 返回一个证书数组。

  • public final CodeSigner[] getCodeSigners(): 返回与CodeSource关联的代码签名者数组。

  • public final URL getLocation(): 返回 URL。

  • public int hashCode(): 返回当前对象的哈希码值。

  • public boolean implies(CodeSource codesource): 如果给定的代码源满足以下标准,则返回 true:

    • 不为 null

    • 对象的证书不为 null

    • 对象的位置不为 null

  • public String toString(): 返回一个包含CodeSource信息的String,包括位置和证书。

包检查算法

当运行带有安全管理器的应用程序时,Java 9 的最终性能改进体现在java.lang.SecurityManager包的增强上。具体来说,checkPackageAccess方法的包检查算法被修改。

java.lang.SecurityManager类允许应用程序在特定操作上实现安全策略。此类的public void checkPackageAccess(String pkg)方法从getProperty方法接收一个逗号分隔的受限包列表。如图所示,根据评估结果,checkPackageAccess方法可以抛出两种异常之一:

图片

TLS 应用层协议协商扩展

Java 增强提案 244 简单地增强了javax.net.ssl包,使其支持传输层安全性TLSALPN应用层协议协商)扩展。此扩展允许 TLS 连接进行应用协议协商。

TLS ALPN 扩展

ALPN 是 TLS 扩展,可用于在安全连接中使用时协商要实现的协议。ALPN 代表了一种高效的协议协商方式。如图所示,TLS 握手有五个基本步骤:

图片

javax.net.ssl 包

java.net.ssl包包含与安全套接字包相关的类。这使得我们可以使用 SSL 作为示例,以可靠地检测网络字节流中引入的错误。它还提供了加密数据以及提供客户端和服务器身份验证的能力。

此包包括以下接口:

  • public interface HandshakeCompletedListener extends EventListener

  • public interface HostnameVerifier

  • public interface KeyManager

  • `public interface ManagerFactoryParameters`

  • public interface SSLSession

  • public interface SSLSessionBindingListener extends EventListener

  • public interface SSLSessionContext

  • public interace TrustManager

  • public interface X509KeyManager extends KeyManager

  • public interface X509TrustManager extends TrustManager

java.net.ssl包还具有以下子类:

  • public class CertPathTrustManagerParameters extends Object implements ManagerFactoryParameters

  • public abstract class ExtendedSSLSession extends Object implements SSLSession

  • public class HandshakeCompleteEvent extends EventObject

  • public abstract class HttpsURLConnection extends HttpURLConnection

  • public class KeyManagerFactory extends Object

  • public abstract class KeyManagerFactorySpi

  • public class KeyStoreBuilderParameters extends Object implements ManagerFactoryParameters

  • public class SSLContext extends Object

  • public abstract class SSLContextSpi extends Object

  • public abstract class SSLEngine extends Object

  • public class SSLEngineResult extends Object

  • public class SSLParameters extends Object

  • public final class SSLPermission extends BasicPermission

  • public abstract class SSLServerSocket extends ServerSocket

  • public abstract class SSLServerSocketFactory extends ServerSocketFactory

  • public class SSLSessionBindingEvent extends EventObject

  • public abstract class SSLSocket extends Socket

  • public abstract class SSLSocketFactory extends SocketFactory

  • public class TrustManagerFactory extends Object

  • `public abstract class TrustManagerFactorySpi extends Object`

  • public abstract class X509ExtendedKeyManager extends Object implements X509KeyManager

  • public abstract class X509ExtendedTrustManager extends Object implements x509TrustManager

java.net.ssl包的扩展

Java 9 平台中java.net.ssl包的更改是现在它支持 TLS ALPN 扩展。这一变化的关键好处包括:

  • TLS 客户端和服务器现在可以使用多个应用层协议,这些协议可能使用也可能不使用相同的传输层端口

  • ALPN 扩展允许客户端优先选择它支持的应用层协议

  • 服务器可以选择客户端协议和 TLS 连接

  • 支持 HTTP/2

以下插图之前作为 TLS 握手的五个基本步骤展示过。更新为 Java 9 并在此处展示,该插图表明客户端和服务器之间共享了协议名称:

图片

一旦收到客户端的应用层协议列表,服务器可以选择服务器首选的交集值,并外部扫描初始明文 ClientHellos 并选择一个 ALPN 协议。应用程序服务器将执行以下操作之一:

  • 选择任何支持的协议

  • 确定 ALPN 值(远程提供和本地支持)是互斥的

  • 忽略 ALPN 扩展

与 ALPN 扩展相关的其他关键行为:

  • 服务器可以更改连接参数

  • SSL/TLS 握手开始后,应用程序可以查询是否已选择 ALPN 值

  • SSL/TLS 握手结束后,应用程序可以检查使用了哪种协议

ClientHello 是 TLS 握手的第一个消息。它具有以下结构:

    struct {
      ProtocolVersion client_version;
      Random random;
      SessionID session_id;
      CipherSuite cipher_suites<2..2¹⁶-1>;
      CompressionMethod compression_methods<1..2⁸-1>;
      Extension extensions<0..2¹⁶-1>;
    } ClientHello;

利用 CPU 指令进行 GHASH 和 RSA

Java 增强提案(JEP)246 的自描述标题 利用 CPU 指令进行 GHASH 和 RSA,对其目标提供了深刻的见解。此 JEP 的目的是提高加密操作的性能,特别是 GHASH 和 RSA。性能改进是通过利用最新的 SPARC 和 Intel x64 CPU 指令在 Java 9 中实现的。

此增强不需要作为 Java 9 平台的一部分添加新或修改的 API。

散列

Galois 散列GHASH)和Rivest-Shamir-AdlemanRSA)是加密系统散列算法。散列是从文本字符串生成的固定长度字符串或数字。算法,特别是散列算法,被设计成结果散列无法被逆向工程。我们使用散列来存储带有盐生成的密码。

在密码学中,盐是作为散列函数输入的随机数据,用于生成密码。盐有助于防止彩虹表攻击和字典攻击。

以下图形说明了散列的基本工作原理:

图片

如您所见,散列算法被输入明文和盐,生成新的散列密码,并将盐存储起来。以下是具有示例输入/输出的相同图形,以演示其功能:

图片

验证过程,以下图表从用户输入明文密码开始。散列算法将明文与存储的盐重新散列。然后,将生成的散列密码与存储的密码进行比较:

图片

TLS 的 OCSP 拼接

在线证书状态协议OCSP)stapling 是一种检查数字证书撤销状态的方法。评估 SSL 证书有效性的 OCSP stapling 方法被认为是既安全又快速的。通过允许网站服务器在其有机证书上提供有效性信息,而不是从证书的发行商请求更长的验证信息过程,实现了确定速度。

在线证书状态协议OCSP)stapling 之前被称为传输层安全性TLS)证书状态请求扩展。

OCSP stapling 入门

OCSP stapling 过程涉及多个组件和有效性检查。以下图形说明了 OCSP stapling 过程:

正如您所看到的,当用户尝试通过浏览器打开一个 SSL 加密网站时,这个过程就开始了。浏览器会查询网站服务器以确保 SSL 加密网站有一个有效的证书。网站服务器会查询证书的供应商,并得到证书状态和数字签名的时戳。网站服务器将这两个组件(证书状态和数字签名的时戳)组合在一起,并将其作为组合集返回给请求的浏览器。然后浏览器可以检查时戳的有效性,并决定是否显示 SSL 加密网站或显示错误。

Java 9 平台的更改

Java 增强提案 249,TLS 的 OCSP Stapling,通过 TLS 证书状态请求扩展实现 OCSP stapling。OCSP stapling 检查 X.509 证书的有效性。

X.509 证书是使用 X509 公共密钥基础设施PKI)的数字证书。

在 Java 9 之前,证书有效性检查(实际上,是检查证书是否已被撤销)可以在客户端启用,并且有以下低效之处:

  • OCSP 响应者性能瓶颈

  • 多次遍历导致的性能下降

  • 如果在客户端执行 OCSP 检查,则会导致额外的性能下降

  • 当浏览器未连接到 OCSP 响应者时,False会失败

  • 对 OCSP 响应者的拒绝服务攻击的易损性

新的 TLS OCSP stapling 包括以下 Java 9 平台的系统属性更改:

  • jdk.tls.client.enableStatusRequestExtension:

    • 默认设置:true

    • 启用status_request扩展

    • 启用status_request_v2扩展

    • 启用处理来自服务器的CertificateStatus消息

  • jdk.tls.server.enableStatusRequestExtension:

    • 默认设置:false

    • 启用服务器端 OCSP stapling 支持

  • jdk.tls.stapling.responseTimeout:

    • 默认设置:5000 毫秒

    • 控制服务器分配的最大时间以获取 OCSP 响应

  • jdk.tls.stapling.cacheSize:

    • 默认设置:256

    • 控制缓存条目的最大数量

    • 可以将最大值设置为零以消除上限

  • jdk.tls.stapling.cacheLifetime:

    • 默认设置:3600 秒(1 小时)

    • 控制缓存的响应的最大寿命

    • 可以将值设置为 0 以禁用缓存寿命

  • jdk.tls.stapling.responderURI:

    • 默认设置:无

    • 可以设置没有权威信息访问AIA)扩展的证书的默认 URI

    • 除非设置jdk.tls.stapling.Override属性,否则不会覆盖 AIA 扩展

  • jdk.tls.stapling.respoderOverride:

    • 默认设置:false

    • 允许提供的jdk.tls.stapling.responderURI属性覆盖 AIA 扩展值

  • jdk.tls.stapling.ignoreExtensions:

    • 默认设置:false

    • 禁用status_requeststatus_request_v2 TLS 扩展中指定的 OCSP 扩展转发。

现在客户端和服务器端 Java 实现都支持status_requeststatus_request_v2 TLS 问候扩展。

基于 DRBG 的SecureRandom实现

在 Java 9 之前,JDK 有两种生成安全随机数的方法。一种方法是用 Java 编写的,基于 SHA1 的随机数生成,并不十分强大。另一种方法是平台相关的,并使用预配置的库。

确定性随机位生成器DRBG)是生成随机数的方法。它已获得美国商务部下属的国家标准与技术研究院NIST)的批准。DRBG 方法包括用于生成安全随机数的现代和更强的算法。

Java 增强提案 273,基于 DRBG 的 SecureRandom 实现旨在实现三种特定的 DRBG 机制。这些机制如下列出:

  • Hash_DRBG

  • HMAC_DRBG

  • CTR_DRBG

您可以在nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-90Ar1.pdf上了解每个 DRBG 机制的详细信息

这里是三个新的 API:

  • SecureRandom:新方法允许使用以下列出的可配置属性配置SecureRandom对象:

    • 种子

    • 重置

    • 随机位生成

  • SecureRandomSpi:实现SecureRandom方法的新方法

  • SecureRandomParameter:新的接口,以便可以将输入传递给新的SecureRandom方法

摘要

在本章中,我们探讨了 JDK 在安全方面的一些小而重要的变化。Java 9 平台中的特色安全增强功能为开发者提供了独特的编写和维护实现安全功能的应用程序的能力。具体来说,我们涵盖了 DTLS、密钥库、提高安全应用性能、TLS 应用层协议协商扩展、利用 CPU 指令进行 GHASH 和 RSA、TLS 的 OCSP stapling 以及基于 DRBG 的SecureRandom实现。

在下一章中,我们将探讨 Java 9 中使用的新的命令行标志以及各种命令行工具的变更。我们的内容将包括使用新的命令行选项和标志来管理 Java JVM 运行时和编译器。

第十五章:命令行标志

在上一章中,我们探讨了 JDK 的几个安全更改。Java 9 的安全增强为开发者提供了编写和维护实现安全的应用程序的能力。具体来说,我们涵盖了数据报传输层安全、密钥库、提高安全应用性能、TLS 应用层协议协商扩展、利用 CPU 指令进行 GHASH 和 RSA、TLS 的 OCSP stapling 以及基于 DRBG 的 SecureRandom 实现。

在本章中,我们将探讨 Java 9 平台的一些更改,这些更改具有命令行标志的共同主题。具体来说,我们将涵盖以下概念:

  • 统一 JVM 日志

  • 编译器控制

  • 诊断命令

  • 堆分析代理

  • 删除你的 JHAT

  • 命令行标志参数验证

  • 为旧平台版本编译

统一 JVM 日志 [JEP 158]

为 JVM 创建一个统一的日志架构是 JEP-158 的中心目标。以下是 JEP 目标的详细列表:

  • 为所有日志操作创建一个 JVM 范围的命令行选项集

  • 使用分类标签进行日志记录

  • 允许消息具有多个标签,也称为标签集

  • 提供六个日志级别:

    • 错误

    • 警告

    • 信息

    • 调试

    • 跟踪

    • 开发

  • 根据级别选择要记录的消息

  • 可选地将日志直接输出到控制台或文件

    • 每次打印一行,且不支持同一行内的交错
  • 允许输出多行日志(非交错)

  • 格式化所有日志消息,使其易于人类阅读

  • 添加如运行时间、级别和标签等装饰

  • 与级别类似,根据装饰选择要记录的消息

  • 将 Java 9 之前的 tty>print 日志转换为使用统一日志作为输出

  • 允许使用 jcmdMBeans 动态配置消息

  • 允许启用和禁用单个日志消息

  • 添加确定装饰打印顺序的能力

JVM 的统一日志更改可以归纳为以下列出的五个类别:

  • 命令行选项

  • 装饰

  • 级别

  • 输出

  • 标签

让我们简要地看一下这些类别。

命令行选项

新的命令行选项 -Xlog 在 Java 9 的日志框架中被添加。这个命令行选项有一系列广泛的参数和可能性。基本语法是简单地 -Xlog 后跟一个选项。以下是正式的基本语法:

-Xlog[:option]

这里是一个带有 all 选项的基本示例:

-Xlog:all

这里是用于配置新统一日志的详细命令行语法:

-Xlog[:option]

option          := [<what>][:[<output>][:[<decorators>][:<output-options>]]]
 'help'
 'disable'

what            := <selector>[,...]

selector        := <tag-set>[*][=<level>]

tag-set         := <tag>[+..]
 'all'

tag             := name of tag

level           := trace
 debug
 info
 warning
 error

output          := 'stderr'
 'stdout'
 [file=]<filename>

decorators      := <decorator>[,...]
 'none'

decorator       := time
 uptime
 timemillis
 uptimemillis
 timenanos
 uptimenanos
 pid
 tid
 level
 tags

output-options  := <output_option>[,...]

output-option   := filecount=<file count>
 filesize=<file size in kb>
 parameter=value

以下 -Xlog 示例后面跟着一个描述:

-Xlog:all

在前面的例子中,我们正在告诉 JVM 执行以下操作:

  • 记录所有消息

  • 使用 info 级别

  • 将输出提供给 stdout

使用此示例,所有 warning 消息仍然会输出到 stderr

下一个示例,如这里所示,记录 debug 级别的消息:

-Xlog:gc+rt*=debug

在前面的例子中,我们正在告诉 JVM 执行以下操作:

  • 记录至少带有 gcrt 标签的消息

  • 使用 debug 级别

  • 将输出提供给 stdout

以下示例将输出推送到外部文件:

-Xlog:disable - Xlog:rt=debug:rtdebug.txt

在前面的示例中,我们正在告诉 JVM 执行以下操作:

  • 禁用除带有 rt 标签的消息之外的所有消息

  • 使用 debug 级别

  • 将输出提供给名为 rtdebug.txt 的文件

装饰

在 Java 9 日志框架的上下文中,装饰是关于日志消息的元数据。以下是可用的装饰的字母顺序列表:

  • level: 与记录的消息关联的级别

  • pid: PID = 处理器标识符

  • tags: 与记录的消息关联的标签集

  • tid: TID = 线程标识符

  • time: 使用 ISO-8601 格式表示当前日期和时间

  • timemillis: 当前时间,以毫秒为单位

  • timenanos: 当前时间,以纳秒为单位

  • uptime: JVM 启动以来的时间,以秒和毫秒为单位

  • uptimemillis: JVM 启动以来的时间,以毫秒为单位

  • uptimenanos: JVM 启动以来的时间,以纳秒为单位

装饰可以超越或包含在统一日志输出中。无论使用哪种装饰,它们都会按照以下顺序出现在输出中:

  1. time

  2. uptime

  3. timemillis

  4. uptimemillis

  5. timenanos

  6. uptimenanos

  7. pid

  8. tid

  9. level

  10. tags

级别

记录的消息与一个详细级别单独关联。如前所述,级别是 errorwarninginformationdebugtracedevelop。以下图表显示了级别如何根据记录的信息量具有递增的详细程度。develop 级别仅用于开发目的,并且在产品应用程序构建中不可用:

图片

输出

Java 9 日志框架支持三种类型的输出,以下是用 -Xlog 命令行语法直接使用的示例:

在以下示例中,我们将输出提供给 stderr

-Xlog:all=warning:stderr:none

以下示例提供了输出到 stdout 的示例:

-Xlog:all=warning:stdout:none

以下示例将输出写入文本文件:

-Xlog:all=warning:file=logmessages.txt:none

标签

新的日志框架由 JVM 中标识的一组标签组成。如果需要,可以在源代码中更改这些标签。标签应该是自我标识的,例如 gc 表示垃圾回收。

当多个标签组合在一起时,它们形成一个标签集。当我们通过源代码添加自己的标签时,每个标签都应该与一个标签集相关联。这将有助于确保标签保持组织良好且易于人类阅读。

编译器控制 [JEP 165]

控制 Java 虚拟机编译器可能看起来是一项不必要的任务,但对于许多开发者来说,这是测试的重要方面。Java 增强提案 165 详细描述了实施 JVM 编译器运行时管理的计划。这是通过方法相关的编译器标志实现的。

在本节中,我们将从查看 JVM 编译模式开始,然后查看可以使用 Java 9 平台控制的编译器。

编译模式

Java 9 平台的变化包括对 c1 和 c2 JVM 编译器的细粒度控制。正如您在下面的插图中所见,Java HotSpot JVM 有两种即时编译模式——c1c2

C1C2 编译模式使用不同的编译技术,如果用于相同的代码库,可以产生不同的机器代码集。

C1 编译模式

Java HotSpot VM 内的 C1 编译模式通常用于具有以下特征的应用程序:

  • 快速启动

  • 增加优化

  • 客户端

C2 编译模式

第二种编译模式,C2,用于具有以下列出的特征的应用程序:

  • 长运行时间

  • 服务器端

分层编译

分层编译允许我们使用 c1c2 编译模式。从 Java 8 开始,分层编译是默认过程。如图所示,c1 模式在启动时使用,以帮助提供更大的优化。然后,一旦应用足够热身,就采用 c2 模式:

Java 9 中的编译器控制

Java 9 承诺能够对 JVM 编译器进行有限的控制,并在运行时进行更改。这些额外的功能不会降低性能。这允许我们进行更精确的测试和测试优化,因为我们可以在不重新启动整个 JVM 的情况下运行小的编译器测试。

为了控制编译器操作,我们需要创建一个指令文件。这些文件包含编译器指令,由一组带有值的选项组成。指令文件本质上使用 JSON 的一个子集:

JavaScript 对象表示法JSON)格式用于数据交换。与 JSON 相比,指令文件有以下格式差异:

  • intdoubles 是唯一支持的数字格式

  • 双斜杠(//)可用于注释行

  • 在数组和对象中可以使用尾随逗号(,

  • 不支持转义字符

  • 选项名称格式化为字符串,不需要引用

您可以在 www.json.org 上了解更多关于 JSON 的信息。

我们可以在命令行使用以下语法添加我们的指令文件:

-XX:CompilerDirectivesFile=<file>

这是一个指令文件的 shell 示例:

    [  // Open square bracket marks the start of the directives file

    { // Open curly brace marks the start of a directive block

      // A directives block that applies specifically to the C1 mode
      c1: {   
             // directives go here
          },

      // A directives block that applies specifically to the C2 mode
      c2: {   
             // directives go here
          },

      // Here we can put a directives that do not apply to
      // a specific compiler mode

    },

    {  // can have multiple directive blocks

       c1: {
             // directives go here
           }   

       c2: {
             // directives go here
           }    
    }

    ] // Close square bracket marks the start of the directives file

诊断命令 [JEP 228]

Java 增强提案 228,添加更多诊断命令,定义了七个额外的诊断命令以增强诊断 JDK 和 JVM 的能力。新的诊断命令在此详细说明。

print_codegenlist 命令打印当前排队编译的方法。由于 c1 和 c2 编译模式在不同的队列上,因此需要向特定队列发出此命令。

dump_codelist 诊断命令将为编译方法打印以下列出的信息:

  • 完整签名

  • 地址范围

  • 状态

    • 活跃

    • 非进入性

    • 僵尸进程

此外,dump_codelist诊断命令允许将输出定向到stdout或指定的文件。输出可以是 XML 形式或标准文本。

print_codeblocks命令允许我们打印:

  • 代码缓存大小

  • 代码缓存列表

  • 代码缓存中的块列表

  • 代码块的地址

Th datadump_request诊断命令向Java 虚拟机工具接口JVMTI)发送转储请求。这取代了Java 虚拟机调试接口JVMDI)和Java 虚拟机分析接口JVMPI)接口。

使用set_vmflag命令,我们可以在 JVM 或库中设置命令行标志或选项。

Th print_class_summary诊断命令打印所有已加载类的列表以及它们的继承结构。

print_utf8pool命令打印所有 UTF-8 字符串常量。

堆分析代理 [JEP 240]

Java 增强提案 240 的标题为移除 JVM TI hprof 代理。以下是与此 JEP 和标题相关的关键术语,这些术语可能对您来说是新的:

  • 工具接口(TI):这是一个本地编程接口,允许工具控制运行在 Java 虚拟机内部的应用程序的执行。该接口还允许状态查询。该工具的全称是 Java 虚拟机工具接口,或 JVM TI。

  • 堆分析(HPROF):这是一个内部 JDK 工具,用于分析 JVM 对 CPU 和堆的使用。开发者最常接触到的hprof是崩溃时生成的文件。生成的文件包含堆转储。

Java 9 JDK 不包含hprof代理。它被移除主要是因为有更好的替代方案。以下是与该功能相关的表格:

HPROF 功能 替代方案
分配分析器(heap=sites) Java VisualVM
CPU 分析器(cpu=samples)(cpu=times) Java VisualVMJava Flight Recorder

| 堆转储(heap=dump) | 内部 JVM 功能:

  • GC.heap_dump(icmd <pid> GC.heap_dump)

  • jmap -dump

|

有趣的是,当 HPROF 最初创建时,它并不是打算在生产环境中使用。事实上,它仅用于测试 JVM 工具接口的代码。因此,随着 Java 9 平台的推出,HPROF 库(libhprof.so)将不再包含在 JDK 中。

移除你的 JHAT [JEP 241]

Java 堆分析工具JHAT)用于解析 Java 堆转储文件。此堆转储文件解析工具的语法如下:

jhat 
     [-stack <bool>] 
     [-refs <bool>] 
     [-port <port>] 
     [-baseline <file>] 
     [-debug <int>] 
     [-version] 
     [-h|-help] 
     <file>

这里简要介绍一下与 JHAT 命令相关的选项:

选项 描述 默认
-J<flag> <flag>传递给运行时系统。 N/A
-stack<bool> 切换对对象分配调用堆栈的跟踪。 true
-refs<bool> 切换对对象引用的跟踪。 true
-port<port> 指示 JHAT HTTP 服务器的端口。 7000
-exclude<exclude-filename> 从可达对象查询中排除指定的文件。 N/A
-baseline<filename> 指定用于比较的基本堆转储。 N/A
-debug<int> 设置输出的详细程度。 N/A
-version 简单地输出 JHAT 版本号。 N/A
-h``-help 提供帮助文本。 N/A

JHAT 自 JDK-6 以来一直是 Java 平台的一部分,以实验形式存在。它不受支持,并被认为已过时。从 Java 9 开始,这个工具将不再包含在 JDK 中。

JVM 命令行标志参数验证 [JEP 245]

在本章中,您已经接触到了 Java 9 平台的大部分命令行标志用法。创建了一个名为 验证 JVM 命令行标志参数 的 Java 增强提案 245,以确保所有带有参数的 JVM 命令行标志都得到验证。这项工作的主要目标是:

  • 避免 JVM 崩溃

  • 提供错误消息以通知无效的标志参数

如您从以下图形中所示,没有尝试自动纠正标志参数错误;而是仅识别错误并防止 JVM 崩溃:

这里提供了一个示例错误消息,表明标志参数超出了范围。这个错误会在 JVM 初始化期间执行的标志参数范围检查期间显示:

exampleFlag UnguardOnExecutionViolation = 4 is outside the allowed range [ 0 . . . 3]

这里有一些关于这个 Java 平台更改的具体细节:

  • 扩展当前的 globals.hpp 源文件,以确保完全记录标志默认值和允许的范围

  • 定义一个框架以支持将来添加新的 JVM 命令行标志:

    • 这将包括值范围和值集

    • 这将确保有效性检查将应用于所有新添加的命令行标志

  • 修改宏表:

    • 添加可选范围的 min/max 值

    • 为以下内容添加约束条目:

      • 确保每次标志更改时都执行约束检查

      • 在 JVM 运行期间,所有可管理的标志将继续进行检查

为旧平台版本编译 [JEP 247]

Java 编译器 javac 已更新以支持 Java 9,以确保它可以用于编译 Java 程序以在用户选择的较旧版本的 Java 平台上运行。这是 Java 增强提案 247,为旧平台版本编译的焦点。

如您在以下屏幕截图中所见,javac 有几个选项,包括 -source-target。以下屏幕截图中的 javac 来自 Java 8:

-source选项用于指定编译器接受的 Java 版本。-target选项告知javac将生成哪个版本的类文件。默认情况下,javac会生成与最新 Java 版本和平台 API 相对应的类文件。这可能会在编译的应用程序使用仅在最新平台版本中可用的 API 时引起问题。这会使应用程序在较旧的平台版本上无法运行,尽管-source-target选项有明确指示。

为了解决上述问题,Java 9 平台引入了一个新的命令行选项。这个选项是--release选项,当使用时,会自动配置javac以生成链接到特定平台版本的类文件。以下截图显示了 Java 9 平台的javac选项。如您所见,新的--release选项已被包括在内:

图片

这是新选项的语法:

javac --release <release> <source files>

摘要

在本章中,我们探讨了 Java 9 平台的一些变化,这些变化具有命令行标志的共性。具体来说,我们涵盖了统一的 JVM 日志记录、编译器控制、新的诊断命令、移除 HPROF 堆分析代理、移除 JHAT、命令行标志参数验证以及为旧平台版本编译的能力。

在下一章中,我们将关注 Java 9 平台提供的附加实用工具的最佳实践。这包括 UTF-8、Unicode 7.0、Linux 等。

第十六章:Java 9 最佳实践

在上一章中,我们探讨了 Java 9 中有关命令行标志的几个更改。具体来说,我们涵盖了统一的 JVM 日志、编译器控制、新的诊断命令、移除 HPROF 堆分析代理、移除Java 堆分析工具JHAT)、命令行标志参数验证以及为旧平台版本编译的能力。

在本章中,我们将关注 Java 9 平台提供的附加实用工具的最佳实践。具体来说,我们将涵盖:

  • 对 UTF-8 的支持

  • Unicode 7.0.0

  • Linux/AArch64 端口

  • 多分辨率图像

  • 常用区域数据存储库

对 UTF-8 的支持

Unicode 转换格式-8UTF-8)是一个字符集,它使用一到四个 8 位字节封装所有 Unicode 字符。它是 Unicode 的字节编码形式。自 2009 年以来,UTF-8 一直是编码网页的主要字符集。以下是 UTF-8 的一些特性:

  • 可以编码所有 1,112,064 个 Unicode 代码点

  • 使用一到四个 8 位字节

  • 占据了几乎所有网页的近 90%

  • 与 ASCII 向后兼容

  • 可逆

UTF-8 的普遍使用强调了确保 Java 平台完全支持 UTF-8 的重要性。这种思维方式导致了 Java 增强提案 226,UTF-8 属性资源包。使用 Java 9 应用程序,我们可以指定具有 UTF-8 编码的属性文件。Java 9 平台对ResourceBundleAPI 进行了更改以支持 UTF-8。

让我们看看 Java 9 之前的ResourceBundle类,然后是 Java 9 平台对该类所做的更改。

ResourceBundle

以下类为开发者提供了从资源包中隔离特定区域设置资源的能力。这个类显著简化了本地化和翻译:

    public abstract class ResourceBundle extends Object

创建资源包需要一种有目的的方法。例如,让我们想象我们正在创建一个资源包,该资源包将支持商业应用程序的多种语言。我们的按钮标签(以及其他内容)将根据当前的区域设置而有所不同。因此,在我们的示例中,我们可以为我们的按钮创建一个资源包。我们可以将其称为buttonResources。然后,对于每个区域设置,我们可以创建一个buttonresource_<标识符>。以下是一些示例:

  • buttonResource_ja:用于日语

  • buttonResource_uk:用于英国英语

  • buttonResource_it:用于意大利语

  • buttonResource_lh:用于立陶宛语

我们可以使用与我们的默认包基本名称相同的名称的资源包。因此,buttonResource将包含我们的默认包。

要获取特定区域设置的对象,我们调用getBundle方法。以下是一个示例:

    . . . 

    ResourceBundle = buttonResource =
     ResourceBundle.getBundle("buttonResource", currentLocale);

    . . . 

在接下来的几节中,我们将通过查看ResourceBundle类的嵌套类、字段和构造函数以及包含的方法来检查ResourceBundle类。

嵌套类

ResourceBundle 类相关联的有一个嵌套类,即 ResourceBundle.Control 类。它提供了当使用 ResourceBundle.getBundle 方法时使用的回调方法:

    public static class ResourceBundle.Control extends Object

ResourceBundle.Control 类有以下字段:

  • public static final List<String> FORMAT_CLASS

  • public static final List<String> FORMAT_DEFAULT

  • public static final List<String> FORMAT_PROPERTIES

  • public static final long TTL_DONT_CACHE

  • public static final long TTL_NO_EXPIRATION_CONTROL

该类有一个单独的空构造函数和以下方法:

  • getCandidateLocales():
      public List<Locale> getCandidateLocales(String baseName,
       Locale locale)
组件 详情
抛出 NullPointerException(如果 baseNamelocale 为 null)
参数 baseName: 一个完全限定的类名locale: 期望的区域设置
返回 候选区域设置列表
  • getControl():
      public static final ResourceBundle.Control getControl(
       List<String> formats)
组件 详情
抛出 IllegalArgumentException(如果 formats 未知)NullPointerException(如果 formats 为 null)
参数 formats: 这些是 ResourceBundle.Control.getFormats 方法将返回的格式
返回 支持指定格式的 ResourceBundle.Control
  • getFallbackLocale():
      public Locale getFallbackLocale(String baseName, Locale locale)
组件 详情
抛出 NullPointerException(如果 baseNamelocale 为 null)
参数 baseName: 一个完全限定的类名locale: 使用 ResourceBundle.getBundle 方法无法找到的期望区域设置
返回 回退区域设置
  • getFormats():
      public List<String> getFormats(String baseName)
组件 详情
抛出 NullPointerException(如果 baseName 为 null)
参数 baseName: 一个完全限定的类名
返回 一系列字符串格式列表,以便资源包可以被加载
  • getNoFallbackControl():
      public static final ResourceBundle.Control   
      getNoFallbackControl(List<String> formats)
组件 详情
抛出 IllegalArgumentException(如果 formats 未知)NullPointerException(如果 formats 为 null)
参数 formats: 这些是 ResourceBundle.Control.getFormats 方法将返回的格式
返回 支持指定格式且没有回退区域设置的 ResourceBundle.Control
  • getTimeToLive():
      public long getTimeToLive(String baseName, Locale locale)
组件 详情
抛出 NullPointerException(如果 baseName 为 null)
参数 baseName: 一个完全限定的类名locale: 期望的区域设置
返回 从缓存时间偏移的零或正毫秒数
  • needsReload():
      public boolean needsReload(String baseName, Locale locale,
       String format, ClassLoader loader, ResourceBundle bundle,
       long loadTime)
组件 详情

| 抛出 | NullPointerException(如果以下列出的任何参数为 null): |

  • baseName

  • locale

  • format

  • loader

  • bundle

|

参数 baseName: 一个完全限定的类名locale: 期望的区域设置format: 资源包格式loader: 应用于加载包的 ClassLoader``bundle: 过期的包loadTime: 包被添加到缓存的时间
返回 true/false 以指示过期的资源包是否需要重新加载
  • newBundle():
      public ResourceBundle newBundle(String baseName, Locale locale,
       String format, ClassLoader loader, boolean reload)
组件 详细信息

| 抛出 | ClassCastException(如果加载的类不能转换为ResourceBundleExceptionInInitializerError(如果初始化失败)IllegalAccessException(如果类或构造函数不可访问)IllegalArgumentException(如果格式未知)InstantiationException(如果类实例化失败)IOException(资源读取错误)NullPointerException(如果以下列出的任何参数为 null):

  • baseName

  • locale

  • format

  • loader

SecurityException(如果拒绝访问新实例) |

参数 baseName: 完整的类名locale: 所需的区域设置format: 资源包格式loader: 应用于加载包的ClassLoader``reload: 表示资源包是否过期的true/false标志
返回 资源包的实例
  • toBundleName():
      public String toBundleName(String baseName, Locale locale)
组件 详细信息
抛出 NullPointerException(如果baseNamelocale为 null)
参数 baseName: 完整的类名locale: 所需的区域设置
返回 包名
  • toResourceName():
      public final String toResourceName(String bundleName,
       String suffix)
组件 详细信息
抛出 NullPointerException(如果bundleNamesuffix为 null)
参数 bundleName: 包名suffix: 文件名后缀
返回 转换后的资源名称

字段和构造函数

ResourceBundle类有一个字段,如下所述:

    protected Resourcebundle parent

当指定资源未找到时,getObject方法会搜索父包。

ResourceBundle 类的构造函数如下所示:

    public ResourceBundle()
    {
    }

方法

ResourceBundle类有 18 个方法,每个方法在此进行描述:

  • clearCache():
      public static final void clearCache()
组件 详细信息
抛出
参数
返回
      public static final void clearCache(ClassLoader loader)
组件 详细信息
抛出 NullPointerException(如果 loader 为 null)
参数 loader: 类加载器
返回
  • containsKey():
      public boolean containsKey(String key)
组件 详细信息
抛出 NullPointerException(如果key为 null)
参数 key: 资源键
返回 根据ResourceBundle或父包中是否存在键返回true/false
  • getBundle():
      public static final ResourceBundle getBundle(String baseName)
组件 详细信息
抛出 MissingResourceException(如果提供的baseName的资源包未找到)NullPointerException(如果baseName为 null)
参数 baseName: 完整的类名
返回 基于给定的baseName和默认区域设置的资源包
      public static final ResourceBundle getBundle(String baseName,
       Resourcebundle.Control control)
组件 详细信息
抛出 IllegalArgumentException(如果传递的控制操作不当)MissingResourceException(如果提供的baseName的资源包未找到)NullPointerException(如果baseName为 null)
参数 baseName: 完整的类名control: 控制提供信息,以便资源包可以被加载
返回 基于给定baseName和默认区域的资源包
      public static final ResourceBundle getBundle(String baseName,
       Locale locale)
组件 详细信息
抛出 MissingResourceException(如果提供的baseName的资源包在任何区域设置中找不到)NullPointerException(如果baseNamelocale为 null)
参数 baseName:完全限定类名locale:期望的区域
返回 基于给定baseNamelocale的资源包
      public static final ResourceBundle getBundle(String baseName,
       Locale targetLocale, Resourcebundle.Control control)
组件 详细信息
抛出 IllegalArgumentException(如果传递的控制执行不当)MissingResourceException(如果提供的baseName的资源包在任何区域设置中找不到)NullPointerException(如果baseNamecontrollocale为 null)
参数 baseName:完全限定类名control:控制提供信息以便资源包可以加载loader:类加载器targetLocale:期望的区域
返回 基于给定baseNamelocale的资源包
      public static final ResourceBundle getBundle(String baseName,
       Locale locale, ClassLoader loader)
组件 详细信息
抛出 MissingResourceException(如果提供的baseName的资源包在任何区域设置中找不到)NullPointerException(如果baseNameloaderlocale为 null)
参数 baseName:完全限定类名locale:期望的区域loader:类加载器
返回 基于给定baseNamelocale的资源包
      public static final ResourceBundle getBundle(String baseName,
       Locale targetLocale, ClassLoader loader,
       ResourceBundle.Control control)
组件 详细信息
抛出 IllegalArgumentException(如果传递的控制执行不当)MissingResourceException(如果提供的baseName的资源包在任何区域设置中找不到)NullPointerException(如果baseNamecontrolloadertargetLocale为 null)
参数 baseName:完全限定类名control:提供信息以便资源包可以加载的控制loader:类加载器targetLocale:期望的区域
返回 基于给定baseNamelocale的资源包
  • getKeys():
      public abstract Enumeration<String> getKeys()
组件 详细信息
抛出
参数
返回 ResourceBundle及其父包中的键的枚举
  • getLocale():
      public Locale getLocale()
组件 详细信息
抛出
参数
返回 当前资源包的locale
  • getObject():
      public final Object getObject(String key)
组件 详细信息
抛出 MissingResourceException(如果提供的键的资源找不到)NullPointerException(如果key为 null)
参数 key:这是期望的对象的键
返回 提供的键的对象
  • getString():
      public final String getString(String key)
组件 详细信息
抛出 ClassCastException(如果找到的对象不是键)MissingResourceException(如果提供的键的资源找不到)NullPointerException(如果key为 null)
参数 key:这是期望的String的键
返回 提供的键的String
  • getStringArray():
      public final String[] getStringArray(String key)
组件 详细信息
抛出 ClassCastException(如果找到的对象不是String数组)MissingResourceException(如果找不到提供的键的资源)NullPointerException(如果key为 null)
参数 key:这是所需String数组的键
返回 提供的键的String数组
  • handleGetObject():
      protected abstract Object handleGetObject(String key)
组件 详细信息
抛出 NullPointerException(如果key为 null)
参数 key:所需Object的键
返回 给定键的对象
  • handleKeySet():
      protected Set<String> handleKeySet()    
组件 详细信息
抛出
参数
返回 ResourceBundle中的键集
  • keySet():
      public Set<String> keySet()
组件 详细信息
抛出
参数
返回 ResourceBundle及其父包中的键集
  • setParent():
      protected void setParent(ResourceBundle parent)
组件 详细信息
抛出
参数 parent:当前包的父包
返回

Java 9 的变化

基于 ISO-8859-1 的属性文件格式以前由 Java 平台支持。该格式不易支持转义字符,尽管它确实提供了一个适当的转义机制。使用 ISO-8859-1 需要在文本字符及其转义形式之间进行转换。

Java 9 平台包括一个修改后的ResourceBundle类,默认文件编码设置为 UTF-8 而不是 ISO-8859-1。这节省了应用程序进行上述转义机制转换所需的时间。

Unicode 7.0.0

Java 增强提案 227,标题为 Unicode 7.0,旨在表明需要更新适当的 API 以支持 Unicode 7.0 版本。该版本的 Unicode 于 2014 年 6 月 16 日发布。在 Java 9 之前,Unicode 6.2 是支持的最新版本。

您可以在官方规范页面上了解更多关于Unicode 7.0.0 版本的信息:unicode.org/versions/Unicode7.0.0/

在本书出版时,最新的 Unicode 标准是 10.0.0 版本,于 2017 年 6 月 20 日发布。有趣的是,Java 9 平台将支持 Unicode 7.0.0 版本,但不会支持更近期的 Unicode 标准 10.0.0 版本。除了这里列出的两个 Unicode 规范之外,从 7.0.0 版本开始,Java 9 平台将不会实现以下内容:

  • Unicode 技术标准#10UTS #10

    • Unicode 排序算法:如何比较 Unicode 字符串的详细信息
  • Unicode 技术标准#46UTS #46

    • Unicode 国际域名应用IDNA兼容处理:对文本大小写和域名变体的全面映射

Java 9 平台针对 Unicode 7.0.0 支持的具体变化包括以下 Java 类:

  • java.lang package

    • 字符

    • 字符串

  • java.text.package

    • Bidi

    • BreakIterator

    • 正规化器

让我们快速查看这些类,以帮助我们巩固对 Unicode 7.0.0 对 Java 9 平台广泛影响的了解。

java.lang

java.lang.package提供了在几乎所有 Java 应用程序中使用的根本类。在本节中,我们将探讨CharacterString类。

Character类:

    public final class Character extends Object implements
     Serializable, Comparable<Character>

这是有史以来 Java 第一个版本就存在的许多核心类之一。Character类的对象包含一个类型为char的单个字段。

String类:

    public final class String extends Object implements
     Serializable, Comparable<String>, CharSequence

字符串,另一个核心起源类,是不可变的字符字符串。

修改CharacterString类以支持 Unicode 的新版本,Java 9 的 7.0 版本,是帮助 Java 作为首屈一指的编程语言保持相关性的重要步骤。

java.text

BidiBreakIteratorNormalizer类不像CharacterString类那样被广泛使用。以下是对这些类的简要概述。

Bidi类:

    public final class Bidi extends Object

这个类用于实现 Unicode 的双向算法。这用于支持阿拉伯语或希伯来语。

关于Unicode 双向算法的详细信息,请访问unicode.org/reports/tr9/

BreakIterator类:

    public abstract class BreakIterator extends Object
     implements Cloneable

这个类用于查找文本边界。

Normalizer类:

    public final class Normalizer extends Object

这个方法包含两个方法:

  • isNormalized:用于确定给定序列的char值是否已规范化

  • normalize:将一系列char值规范化

额外的意义

如前所述,JDK 8 支持 Unicode 6.2。6.3 版本于 2013 年 9 月 30 日发布,以下列出了一些亮点:

  • 双向行为改进

  • 改进了 Unihan 数据

  • 更好的希伯来语支持

7.0.0 版本于 2014 年 6 月 16 日发布,引入了以下变化:

  • 增加了 2,834 个字符

    • 增强了对阿塞拜疆语、俄语和德语方言的支持

    • 图形符号

    • 几个国家和地区的史前脚本

  • Unicode 双向算法的更新

  • 近 3000 个新的粤语发音条目

  • 印度语脚本属性的重大改进

Unicode 在 6.3 和 7.0.0 版本中的巨大变化突出了 Java 9 平台支持 7.0.0 而不是 6.2 的重要性,正如 Java 8 所做的那样。

Linux/AArch64 端口

Java 增强提案 237JEP 237)的唯一目标是把 JDK 9 移植到 Linux/AArch64。为了了解这对我们作为 Java 9 开发者意味着什么,让我们谈谈一些关于硬件的事情。

ARM 是一家英国公司,已经超过三十年在创建计算核心和架构。他们的原始名称是Acorn RISC Machine(ARM),其中RISC代表精简指令集计算。在某个时候,他们将其名称更改为Advanced RISC Machine(ARM),最终变为 ARM Holdings 或简称 ARM。他们向其他公司许可其架构。ARM 报告称,已经制造了超过 1000 亿个 ARM 处理器。

在 2011 年底,ARM 推出了一个新的 ARM 架构,称为 ARMv8。这个架构包括一个可选的 64 位架构 AArch64,正如你所预期的,它带来了一套新的指令集。以下是 AArch64 特性的简略列表:

  • A64 指令集:

    • 31 个通用 64 位寄存器

    • 专用零或堆栈指针寄存器

    • 能够接受 32 位或 64 位参数

  • 高级 SIMD(NEON)-增强:

    • 32 个 128 位寄存器

    • 支持双精度浮点数

    • AES 加密/解密和 SHA-1/SHA-2 哈希

  • 新的异常系统

Oracle 在确定这个架构需要在新的 Java 9 平台上得到支持方面做得很好。据说新的 AArch64 架构基本上是一个全新的设计。JDK 9 已经成功移植到 Linux/AArch64,以下是一些实现方式:

  • 模板解释器

  • C1 JIT 编译器

  • C2 JIT 编译器

关于 C1 和 C2 JIT 编译器的信息,请参阅第十四章,命令行标志

多分辨率图像

Java 增强提案 251 的目的是创建一个新的 API,支持多分辨率图像。具体来说,允许多分辨率图像封装同一图像的多个分辨率变体。这个新的 API 将位于java.awt.image包中。以下图表显示了多分辨率如何将不同分辨率的图像集封装到一个单独的图像中:

图片

这个新的 API 将使开发者能够检索所有图像变体或检索特定分辨率的图像。这是一组强大的功能。java.awt.Graphics类将被用来从多分辨率图像中检索所需的变体。

下面是 API 的快速浏览:

    package java.awt.image;

    public interface MultiResolutionImage  
    {
      Image getResolutionVariant(float destinationImageWidth,
       float destinationImageHeight);

      public List <Image> getResolutionVariants();
    }

如前述代码示例所示,API 包含getResolutionVariantgetResolutionVariants,分别返回一个Image和图像列表。由于MultiResolutionImage是一个接口,我们需要一个抽象类来实现它。

常见区域数据存储库(CLDR)

Java 增强提案 252,默认使用 CLDR 区域数据,实现了默认使用 Unicode 通用区域数据存储库中区域数据的决策。CLDR 是许多支持多种语言的软件应用的关键组件。它被誉为最大的区域数据存储库,并被苹果、谷歌、IBM 和微软等众多大型软件提供商所使用。CLDR 的广泛应用使其成为区域数据的不官方行业标准存储库。在 Java 9 平台中将它作为默认存储库进一步巩固了其在软件行业标准中的地位。

有趣的是,CLDR 已经是 JDK 8 的一部分,但不是默认库。在 Java 8 中,我们必须通过设置系统属性来启用 CLDR,如下所示:

    java.locale.providers=JRE,CLDR

因此,在 Java 9 中,我们不再需要启用 CLDR,因为它将是默认存储库。

Java 9 平台中还有其他区域数据存储库。它们按默认查找顺序列在这里:

  1. 通用区域数据存储库CLDR)。

  2. COMPAT - 之前是 JRE。

  3. 服务提供者接口SPI)。

要更改查找顺序,我们可以更改java.locale.providers设置,如下所示:

    java.locale.providers=SPI,COMPAT,CLDR

在前面的示例中,SPI将首先出现,然后是COMPAT,最后是CLDR

摘要

在本章中,我们重点介绍了 Java 9 平台提供的附加实用工具的最佳实践。具体来说,我们涵盖了 UTF-8 属性文件、Unicode 7.0.0、Linux/AArch64 端口、多分辨率图像和通用区域数据存储库。

在下一章,我们的最后一章,我们将通过展望 Java 10 中我们可以期待的内容来探讨 Java 平台的未来方向。

第十七章:未来方向

在上一章中,我们关注了 Java 9 平台提供的某些激动人心的实用工具的最佳实践。具体来说,我们涵盖了 UTF-8 属性文件、Unicode 7.0.0、Linux/AArch64 端口、多分辨率图像和常见区域数据仓库。

本章概述了 Java 平台在 Java 9 之后的未来开发。我们将探讨 Java 10 的计划以及我们未来可能看到的进一步变化。每个对 Java 平台的可能变更将被描述为针对的、提交的或草拟的。针对的指的是已标记为 Java 10 的变更。提交的指的是已提交但未针对 Java 平台特定版本的变更。草拟的变更仍在规划中,尚未准备好提交或指定为针对的。

本章具体介绍了按以下类别分组的 Java 平台的未来变更:

  • JDK 变更

  • Java 编译器

  • Java 虚拟机

  • JavaX

  • 特殊项目

JDK 的未来变更

Java 开发工具包是 Java 平台的核心,并且随着每个版本的发布,都在不断地更新,以实现新的功能和效率。展望 Java 9 之后的版本,我们可以看到 JDK 将会有许多可能的改变。其中许多改变将在 Java 10 中实现,而其他一些可能被保留到以后的版本中。

Java 10 及以后的 JDK 变更将在以下提案类别中呈现:

  • 面向 Java 10

  • 提交的提案

  • 草拟提案

面向 Java 10 的 JDK 变更

在本书出版时,以下列出的与 Java 开发工具包JDK)相关的改变被计划包含在 Java 10 平台中:

  • 仓库整合

  • 原生头文件工具移除

仓库整合

Java 9 平台由以下图中所示的八个不同的仓库组成。在 Java 10 中,我们应该看到所有这些仓库合并为一个单一仓库:

仓库整合应该有助于简化开发。此外,它应该增加维护和更新 Java 平台的便利性。

您可以提前查看这个仓库:hg.openjdk.java.net/jdk10/consol-proto/

原生头文件工具移除

javah 工具用于从 Java 类生成 C 头文件和 C 源文件。C 程序可以引用这些生成的头文件和源文件。

下面我们来了解一下 javah 工具的诞生与消亡:

如前所述,javah 工具是在 Java 7 中引入的,其功能包含在随 JDK8 提供的 javac 中。据报道,与原始工具相比,该功能更优越。在 JDK 9 中,每次使用 javah 工具时,开发者都会收到警告,告知他们该工具将从 JDK 中移除。该工具计划在 JDK 10 中移除。

JDK 相关的提交提案

以下 Java 增强提案已经提交,但尚未被纳入 Java 10 平台的部分。Oracle 设定了两年发布计划,因此可以合理假设,本节及以后列出的许多(如果不是全部)提案都有可能成为 Java 10 平台的一部分:

  • 在 CMS 中并行化完全 GC 阶段

  • JMX 的 REST API

  • 支持堆分配

在 CMS 中并行化完全 GC 阶段

在第七章,利用新的默认 G1 垃圾收集器中,我们回顾了并发标记清除CMS)垃圾收集器的变化。CMS 垃圾收集涉及扫描堆内存,标记要删除的对象,然后进行清除以实际删除这些对象。CMS 的垃圾收集方法本质上是一种升级的“标记和清除”方法;您可以参考第七章,利用新的默认 G1 垃圾收集器,以获取更多信息。

CMS 垃圾收集的当前缺点是,串行标记和清除使用单个线程实现。这导致了不希望的暂停时间。目前,完全垃圾收集发生在四个阶段:

  • 标记阶段:标记要收集的对象

  • 转发阶段:确定活动对象将被迁移的位置

  • 调整指针阶段:根据活动对象的新位置更新指针

  • 压缩阶段:将对象移动到指定的位置

CMS 的未来计划是实现标记和清除,以便它们可以并行执行。这种变化不是针对垃圾收集算法。相反,上述列出的每个阶段都将被并行化。这将使 CMS 垃圾收集更加高效,并有望消除或显著减少暂停时间。

JMX 的 REST API

表示状态转移REST),RESTful 编程和 RESTful API 使用客户端/服务器缓存通信协议,通常是 HTTP。REST 是开发网络应用的常见软件架构。

Java 平台未来的一个变化是提供 RESTful 网络接口给 MBeans。

托管 BeanMBean)是 Java 中表示要管理的资源的对象。这些资源可能包括特定的硬件设备、应用程序、服务或其他组件。

这些接口将允许 MBeans 使用以下 HTTP 方法:

  • CONNECT

  • DELETE

  • GET

  • HEAD

  • OPTIONS

  • POST

  • PUT

  • TRACE

MBeans 使用Java 管理扩展JMX)进行管理。JMX 架构有三个级别,如下面的图所示:

图片

如您所见,REST 适配器是分布式服务级别的一部分。该级别包含连接器和适配器。连接器提供代理级别接口到远程客户端的镜像。另一方面,适配器使用不同的协议转换接口。未来的更改将是将代理级别的服务转换为 REST API。

支持堆分配

提出的未来更改是允许开发者为 Java 堆指定替代内存设备。具体来说,建议允许开发者为 Java 堆指定非 DRAM 内存。这一变化利用了内存和内存设备的成本下降。

实施可能使用AllocateHeapAt标志。

JDK 相关的草案提案

本节涵盖了几个在本书出版时处于草案阶段的与 JDK 相关的提案。这表明它们可能还没有被完全分析,甚至可能被取消。尽管如此,这些提案中的每一个很可能将从草案阶段提交,然后针对 Java 10 平台进行目标定位。

本节涵盖的草案提案如下:

  • 终结化及时性

  • Java 内存模型

  • 外部函数接口

  • 隔离方法

  • 减少元空间浪费

  • 改善 IPv6 支持

  • 方法句柄的无包装参数列表

  • 使用值类型增强的 MandelblotSet 演示

  • 高效的数组比较内建函数

终结化及时性

Java 语言包括终结化(finalization)来清理垃圾回收无法触及的对象。建议的更改是使此过程更快,并将需要修改以下内容:

  • ReferenceHandleThread

  • FinalizerThread

  • java.lang.ref.Reference

与提高终结化及时性相关的其他更改包括创建一个新的 API。以下图形详细说明了 API 将如何实现 GC 和运行时操作,然后通知需要进行终结化。这肯定会导致处理速度更快:

图片

Java 内存模型

一直有努力保持Java 的内存模型JMM)更新。当前的工作重点包括几个领域,包括:

  • 共享内存并发

  • JVM 并发支持

  • JDK 组件

  • 工具

与 JMM(Java 内存模型)相关的工程努力的预期结果如下:

  • 改进的形式化

  • JVM 覆盖

  • 扩展范围

  • C11/C++11 兼容性

  • 实施指南

  • 测试支持

  • 工具支持

外部函数接口

外部函数接口FFI)是软件 API,允许程序从用不同语言编写的程序中调用方法/函数。在即将发布的 JDK 版本中,我们可能会看到一种 FFI,允许开发者在 Java 方法中直接调用共享库和操作系统内核。据称,提议的 FFI 还将使开发者能够管理本地内存块。

新的 FFI 将类似于 Java Native Access (JNA) 和 Java Native Runtime (JNR)。JNA 是一个库,允许在不使用 Java Native Interface (JNI) 的情况下访问本地共享库。JNR 是一个用于调用本地代码的 Java API。提议的 FFI 将允许并优化本地方法调用以及优化的本地内存管理。

独立的方法

MethodHandles.Lookup 类是 java.lang.invoke 包的一部分。我们使用查找对象来创建方法句柄,并使用查找类来访问它们。以下是查找类的头文件:

    public static final class MethodHandles.Lookup extends Object

MethodHandles.Lookup 类的未来更改将支持无需附加类即可加载方法字节码。此外,这些方法将使用方法句柄进行引用。该类将有一个新的 loadCode 方法。

减少元空间浪费

目前,当元空间块被释放时,它们不能用作不同大小的块。因此,如果元空间块 A 被释放且大小为 X,那么该空间不能被重用于大于或小于大小 X 的元空间块。这导致大量不可用的元空间浪费。这也可能导致内存不足错误。

JDK 的未来更改将通过增加元空间块的重用来解决这个问题。该更改将支持以下情况:

  • 允许相邻块形成一个更大的块

  • 允许较大的块被分割成较小的块

此提议的更改通过确保较小的块可以被重用,以及较大的块不会被浪费(因为它们可以被分割以支持较小块的重用)来解决这个问题。

改进 IPv6 支持

互联网协议版本 6 (IPv6) 是当前版本的互联网协议。互联网协议提供了识别和位置模式,使得互联网流量路由成为可能。IPv6 被视为一个用于分组交换网络的互联网层协议。

以下图表显示了互联网协议的历史:

图片

IPv6 是 IPv4 的替代品,并且有几个变化,Java 平台应该支持。从 IPv4 到 IPv6 的关键变化如下分类:

  • Jumbograms

  • 更大的地址空间

  • 移动性

  • 多播

  • 网络层安全

  • 选项可扩展性

  • 隐私

  • 简化的路由器处理

  • 无状态地址自动配置

随着互联网继续从 IPv4 过渡到 IPv6,以下情况是可能的,并且应该在 Java 10 平台上得到支持:

  • 存在多个版本的 IPv4

  • 存在一个版本的 IPv6

  • 存在多个版本的 IPv6

  • 存在多个版本的 IPv4 和一个版本的 IPv6

  • 存在多个版本的 IPv4 和 IPv6

方法句柄的无包装参数列表

当前处理无包装参数列表的方式可能导致处理效率低下。这在我们使用Object[]List<object>作为可变长度参数列表时尤其如此。Java 使用java.lang.invoke来转换使用装箱的方法调用。在 Java 中,自动装箱是编译器自动将原始类型及其相应的对象包装类进行转换。以下是包装类及其对应的原始类型的列表:

包装类 原始类型
布尔 boolean
字节 byte
字符 char
双精度浮点 double
浮点 float
整数 int
长整型 long
short

如以下插图所示,当从原始值到相关包装类的对象转换时发生自动装箱,当从包装类的对象到原始值转换时称为拆箱:

不效率是由于参数列表的实际类型与封装它们的数组或列表之间的不匹配。在未来的 Java 版本中,这些不效率将被消除。Java 平台将添加一个新的ArgumentList类,该类将多态地将有效的参数列表装箱到堆节点中。

使用值类型增强的 MandelblotSet 演示

这个低优先级的 Java 增强提案可能将在 Java 10 中实现,因为其范围有限。计划是开发一个示例 Java 应用程序,展示使用 Valhalla 项目组件、值类型和泛型而不是原始类型在内存和性能方面的改进。

Valhalla 项目组件指的是用户定义的自定义不可变原始类型为值类型。

你可以在本章的 Java 虚拟机部分中了解更多关于值类型的信息。

Mandelbrot 集是混沌理论中使用的分形数学的一个特定例子。随 JDK 8 附带的示例 MandelbrotSet 提供了并行和顺序数据流的比较。在 Java 10 或更高版本中,示例 MandelbrotSet 将被更新以展示使用 Valhalla 项目组件、值类型和泛型与使用原始类型之间的性能和内存效率。

高效的数组比较内建函数

Java 平台未来的一个变化是包括一个比较数组的函数。目前,这是开发者必须自己编写的。这个变化将通过添加类似于java.util.Arrays中的compareTo方法来实现。

虽然具体细节尚不可用,但能够使用原生功能比较数组的前景是令人兴奋的。这是一个将节省许多开发者时间的组件。这可能在 Java 10 平台版本中实现。

Java 编译器的未来变更

Java 平台有两个值得注意的草案变更,特别是 Java 编译器。这些 Java 增强提案如下所示,并在本节中详细说明:

  • 退役 javac -source-target 选项的政策

  • 可插拔静态分析器

退役 javac -source 和 -target 选项的政策

已提交一个正式的草案提案,以定义退役 -source-target 选项的政策。这项工作旨在帮助降低编译器的维护成本。-source-target 选项是为了简化开发工作而提供的,但并非任何标准所正式要求。从 Java 9 平台开始,这些目标选项不再被识别。

新政策被称为“一加三回”,这意味着当前版本将得到支持,以及之前的三个版本。此政策将延续至 JDK 10。

可插拔静态分析器

2013 年夏天启动了一个持续的研究 Java 增强提案(Java Enhancement Proposal),作为探索性措施,并为未来的完整 Java 增强提案提供支持,以使开发者能够定义在编译时可以执行任意静态分析的扩展。这项研究旨在了解如何为 Java 编译器实现一个可插拔的静态类型分析器框架。

研究的目标如下:

  • 收集静态分析器需求

  • 分析静态分析器

  • 确定支持静态分析器的框架需求

  • 实施和测试

持续研究的最终结果将是提交一个功能 Java 增强提案,或者建议停止追求该功能。

Java 虚拟机的未来更改

已提交和草拟了几个针对 Java 虚拟机(JVM)和核心库的新功能和增强。很可能至少这些功能和增强将在 Java 10 平台上实现,其他则将保留在后续版本中。

JVM 相关提交的提案

已提交了三个 Java 增强提案。虽然目前没有指定为 Java 10,但在 Java 10 发布时我们可能会看到这些变化。以下列出了这三个提案:

  • 容器感知 Java

  • 在 GPU 上启用 Java 方法的执行

  • Epsilon GC:任意低开销的垃圾(非)收集器

容器感知 Java

正在努力使 JVM 和核心库在运行在容器内时能够感知。此外,为了能够适应使用可用系统资源。这个特性与云计算的普遍性特别相关。

提出的功能有两个主要组成部分:

  • 检测:

    • 确定 Java 是否在容器内运行
  • 容器资源暴露:

    • 暴露容器资源限制

    • 暴露容器资源配置

已初步确定了几个配置状态点:

通用 CPU 相关 内存相关
isContainerized CPU 周期 块 I/O 设备权重
CPU 配额 块 I/O 权重
CPU 集内存节点 当前内存使用
CPU 集 设备 I/O 读取速率
CPU 使用率 设备 I/O 写入速率
每个 CPU 的 CPU 使用率 最大内存使用量
CPU 数量 最大内核内存
内存偏好
OOM 杀戮启用
OOM 分数调整
共享内存大小
软件内存限制
总内存限制

初始时,这个特性计划支持 Linux-64 上的 Docker。一个可能的场景是,这个特性将与 Java 10 一起发布,仅支持 Linux-64 上的 Docker。然后,在 Java 平台后续版本中,将扩展对特性的支持。

在 GPU 上启用 Java 方法的执行

使 Java 应用程序能够无缝地利用 GPU 是 Sumatra 项目的研究主题。目标是使用 Java 的 Stream API 并行和 lambda 编程模型。利用 GPU 的处理能力和效率对我们来说是非常有意义的。

总体目标是使这个特性对开发者来说易于使用。这个特性将以以下特性实现:

  • 不要更改 Java 并行流 API 的语法

  • 硬件和软件堆栈应自动检测

  • 自动检测和分析,以确定从性能标准来看使用 GPU 是否合理

  • 当将处理卸载到 GPU 失败时提供 CPU 执行

  • 将不会出现性能下降

  • 这个特性不会引入新的安全风险

  • CPU 和 GPU 之间将存在内存持久性

这个 Java 增强提案的主要好处将是提高我们的 Java 应用程序的性能。

Epsilon GC - 随意低开销的垃圾(非)收集器

在第七章“利用新的默认 G1 垃圾回收器”中,我们详细介绍了 Java 垃圾回收器在 Java 9 平台发布时的增强功能。为了持续改进,已经提交了一个 Java 增强提案,以开发一个专门处理内存分配的垃圾回收器。当 Java 堆上没有更多可用内存时,这个垃圾回收器将向 JVM 发送关闭信号。

这个垃圾回收器的目标是被动且使用非常有限的额外开销。引入这种垃圾回收的目的是不降低性能。

这个更改不会影响当前的垃圾回收器。

JVM 相关提案

以下 Java 增强提案已为 Java 平台的未来版本制定,并在本节中详细说明:

  • 在 JVM 编译方法上提供稳定的 USDT 探针点

  • 并发监视器膨胀

  • 以低开销的方式采样 Java 堆分配

  • 诊断命令框架

  • 增强类重定义

  • 在适当的情况下默认启用 NUMA 模式

  • 值对象

  • 对齐 JVM 访问检查

在 JVM 编译方法上提供稳定的 USDT 探针点

用户级统计定义跟踪USDT)用于插入探针点以标记方法的进入和退出。编译器然后允许与跟踪工具进行握手,以便这些工具可以发现探针点并对其进行操作。

常见的跟踪工具有 Dtrace 和伯克利数据包过滤器BPF)。

即使是 JVM 9,Java 虚拟机也不支持此技术集。当前缺乏支持源于 JVM 生成编译代码的方式;它以动态方式生成,没有任何静态可执行链接文件ELFs)。跟踪工具需要 ELFs 才能工作。另一个缓解因素是 JVM 动态地修补其生成的代码,这些代码不支持外部修补。

在未来的 Java 版本中,可能是 Java 10,JVMTIJVM 工具接口)将被修改以支持探针工具在 JVM 的动态编译代码上执行其标准操作。暂时确定的 JVMTI API 更改包括:

  • 添加补丁点或方法进入和退出

  • 编译方法的枚举

  • 编译方法加载时的状态变化通知

  • 查询支持

  • 切换跟踪点的开关

  • 使编译方法的块可检查

好消息是,不需要对 Java 代码的编译方式进行任何更改。它已经可以打补丁,因此所需的功能将通过修改 USDT API 以及 JVM 的一些更改来创建。

并发监视器膨胀

在我们的上下文中,监视器是一种同步机制,用于控制对对象的并发访问。监视器有助于防止多个线程同时访问被监视的对象。JVM 自动在三种监视器实现方法之间切换。三种实现方法如下所示:

图片

Java 对象的初始锁定使用偏向锁定。该方法确保只有锁定线程可以锁定对象。采用这种方法,JVM 在 Java 对象中安装一个线程指针。当第二个线程尝试锁定 Java 对象时,JVM 切换到基本的锁定监视器实现方法。第二种方法使用比较和交换CAS)操作。当 CAS 操作失败时,例如当第二个线程尝试锁定 Java 对象时,JVM 切换到第三种监视器实现方法。该方法是一个完整的监视器。该方法需要本地堆存储,被称为监视器膨胀。

并发监视器膨胀 Java 增强提案的目的是在线程运行时执行监视器膨胀。这将减少 JVM 引起的暂停时间。

提供一种低开销的方式来采样 Java 堆分配

管理不当的 Java 堆可能导致堆耗尽,以及由于内存碎片化(GC 抖动)导致的内存不足。在 Java 的未来版本中,很可能是 Java 10,我们将有一种方法来采样 Java 堆分配。这将通过增强Java 虚拟机工具接口JVMTI)来实现。结果功能将提供一个极低开销的解决方案。

诊断命令框架

Java 增强提案 137,诊断命令框架,提议创建一个框架,用于向 Java 虚拟机发送诊断命令。

该框架将包括一个Java 管理扩展(JMX)接口,它将允许通过 JMX 连接远程发出诊断命令。

JRocket 任务控制工具已经成功实现了这一功能。这证明了概念的正确性,因此这一增强功能很可能将成为 Java 10 平台的一部分。

增强类重新定义

Java 增强提案 159,增强类重新定义,呼吁在运行时进行类重新定义时增强 JVM 的能力。具体来说,该提案包括以下类重新定义操作:

  • 添加超类型

  • 添加方法

  • 添加静态字段

  • 添加实例字段

  • 移除方法

  • 移除静态字段

  • 移除实例字段

当前 JVM 类重新定义功能仅限于方法交换。这被视为非常限制性。在新的提议增强功能中,开发者无需在更改后重新启动他们的应用程序。这对于处理大型和分布式系统特别有益。

在适当的情况下默认启用 NUMA 模式

Java 增强提案 163,在适当的情况下默认启用 NUMA 模式。此提案仅适用于 NUMA 硬件。目的是当 JVM 检测到 NUMA 硬件时,启用以下标志:

    XX:+UseNUMA

此标志目前可以通过手动调用。在提议的增强功能中,当 JVM 检测到它正在 NUMA 硬件上运行时,将自动调用此标志。

非一致性内存访问NUMA)是计算机多处理中使用的内存模型。在这个内存模型中,访问时间取决于内存位置相对于处理器的位置。

这将是一个易于实现的增强功能,并可能成为 Java 10 平台发布的一部分。

值对象

Java 增强提案 169,值对象,旨在提供必要的 JVM 基础设施,以允许处理不可变对象以及无引用对象。这个新基础设施将允许使用非原始数据类型进行高效的按值计算。

此提案的目标包括以下内容:

  • 更好地与java.lang.Integerint语义对齐。

  • 使 Java 数据结构更具可移植性

  • 支持与 Java 原始数据类型性能相似的高级数据类型:

    • 用户定义的

    • 库定义的

  • 通过启用纯数据函数式计算来优化并行计算

  • 改进支持:

    • 复数

    • 向量值

    • 元组

  • 提高安全性和安全性

  • 减少“防御性复制”

提出的实现策略之一是添加一个lockPermanently操作。它将传递一个对象,然后将其标记为不可变且不可别名化。永久锁定对象的观念规定:

  • 字段无法更改

  • 数组的元素无法更改

  • 无法进行同步

  • “等待”方法不能被调用

  • “通知”方法不能被调用

  • 不允许查询身份哈希码

  • 无法执行指针相等性检查

这可能是 Java 10 平台最受欢迎的添加之一。

对齐 JVM 访问检查

Java 增强提案 181,将嵌套类的 JVM 检查与 Java 语言规则对齐,重点关注将 JVM 访问检查规则与 Java 语言规则对齐的需要,特别是对于嵌套类中的构造函数、字段和方法。这将通过在嵌套中分区相关类来实现。类文件将能够访问同一嵌套中其他类文件的私有名称。

嵌套将共享一个访问控制上下文。随着嵌套的出现,将不再需要访问桥。大部分的更改将针对 JVM 的访问规则。

JavaX 未来的更改

Javax.*包是两个特定于 Java 增强提案的主题,这些提案已提交给未来的 Java 平台发布。以下为这些提案:

  • 用于注册管理资源的 JMX 特定注解

  • 现代化 GTK3 的外观和感觉实现

JMX 特定注解用于注册管理资源

标题为“JMX 特定注解用于注册管理资源”的 Java 增强提案草案将为注册和配置MBeans管理 Bean)提供一组注解。

MBean 是一个表示可管理资源的 Java 对象(应用程序、服务、组件或设备)。

本提案的目标是减轻开发者在注册和配置 MBeans 时的负担。此外,通过确保所有 MBean 声明组件都位于同一位置,将提高源代码的可读性。

JMX 特定注解将位于javax.management.annotations包中。

本 Java 增强提案已特别规划用于 Java 11。尽管如此,它有可能被重新设计用于 Java 10。

现代化 GTK3 外观和感觉实现

GTK3 是一个用于创建图形用户界面的小部件工具包,正式名称为 GIMP 工具包。名为“现代化 GTK3 外观和感觉实现”的 Java 增强提案草案呼吁重写当前的 GTK2 外观和感觉,以便使用 GTK3。

GTK3 实现将不会取代 GTK2。需要注意的是,运行时只能使用其中一个,而不能同时使用这两个。

您可以在 developer.gnome.org/gtk3/stable/ 访问 GTK3 参考手册。

进行中的特别项目

Java 增强提案(JEP)提出了对 Java 平台的设计和实现更改。一个 JEP 被起草的标准是工作必须至少满足以下条件之一:

  • 至少需要两周的工程工作

  • 这标志着 JDK 的重要变化

  • 代表开发人员或客户的高需求问题

项目,另一方面,代表由以下某个小组资助的协作努力:

  • 2D 图形

  • 采用

  • AWT

  • 构建

  • 兼容性和规范审查

  • 编译器

  • 符合性

  • 核心库

  • 管理委员会

  • HotSpot

  • 国际化

  • JMX

  • 成员

  • 网络

  • NetBeans 项目

  • 移植者

  • 质量

  • 安全性

  • 可服务性

  • 音频

  • Swing

  • 网络

小组是正式的,新的小组可以被提议。

以下列出的活跃项目代表了 Java 平台可能的未来增强领域。本节后面将提供每个项目的简要信息,并提供了对未来变化的一般领域洞察:

  • 注解管道 2.0

  • 音频合成引擎

  • 卡西奥卡瓦洛

  • 常见 VM 接口

  • 编译器语法

  • 达芬奇机器

  • 设备 I/O

  • Graal

  • HarfBuzz 集成

  • 科纳

  • OpenJFX

  • 巴拿马

  • 沙南多亚

注解管道 2.0

该项目探讨了如何改进 Java 编译器管道中注解的处理方式。没有意图提出更改规范;相反,重点是性能提升。

音频合成引擎

该项目正在研究为 JDK 创建一个新的 midi 合成器。当前的 midi 合成器属于一个授权库。工作组希望新的 midi 合成器作为一个开源 JDK 资产。

卡西奥卡瓦洛

卡西奥卡瓦洛项目旨在改进 OpenJDK 的 抽象窗口工具包AWT)内部接口。这扩展到 2D 子系统。拟议的改进旨在简化 AWT 迁移到新平台的方式。

常见 VM 接口

常见 VM 接口项目旨在记录 OpenJDK 的 VM 接口。这应该会使 Classpath VM 和其他 VM 使用 OpenJDK 更容易。

编译器语法

编译器语法项目正在开发一个基于 ANTLR 语法的实验性 Java 编译器。ANTLRAnother Tool for Language Recognition)是一个解析器,它读取、处理和执行结构化文本或二进制文件。项目团队希望这个 Java 编译器能够取代当前的编译器,因为它使用的是手写的解析器,LALRLook-Ahead Left to Right)。项目组已将 LALR 解析器识别为脆弱且难以扩展。

达芬奇机器

达芬奇机器项目代表了将 JVM 扩展到支持非 Java 语言的努力。当前的工作重点在于允许新的语言与 Java 一起存在于 JVM 中。性能和效率是该努力的关键特性。

设备 I/O

该项目旨在通过 Java 级别的 API 提供对通用外围设备的访问。项目团队希望支持的初始外围设备列表包括:

  • GPIO通用输入/输出

  • I2C集成电路总线

  • SPI串行外围接口

  • UART通用异步收发传输器

Graal

Graal 项目的目标是通过 Java API 暴露 VM 功能。这种暴露将允许开发者在 Java 中为特定的语言运行时编写动态编译器。这项工作包括开发一个多语言解释器框架。

HarfBuzz 集成

HarfBuzz 集成项目希望将 HarfBuzz 布局引擎集成到 Java 开发工具包中。这是为了用 HarfBuzz 布局引擎替换 ICU 布局引擎。ICU 布局引擎已被弃用,这巩固了该项目未来成功的重要性。

Kona

Kona 项目正在努力定义和实现 Java API 以支持物联网IoT)领域。这包括网络技术和协议。尽管没有明确说明,但安全和安全性将是该努力实施成功的关键。

OpenJFX

关于 OpenJFX 项目的细节不多。该项目声明的目标是创建下一代 Java 客户端工具包。根据项目标题,可以假设该小组希望创建一个 OpenJFX 版本的 JavaFX,JavaFX 是一组用于创建富互联网应用的包。

Panama

Panama 项目专注于增强 JVM 和非 Java API 之间的连接。该项目包括以下选定的组件:

  • 原生函数调用

  • 从 JVM 原生数据访问

  • JVM 堆内的原生数据访问

  • JVM 堆中的新数据布局

  • 头文件 API 提取工具

项目团队已生成一个与 JDK 9 结构相匹配的仓库树。这显著增加了项目成功的可能性。

Shenandoah

Project Shenandoah 的目标是通过垃圾收集操作显著减少暂停时间。方法是让更多的垃圾收集操作与 Java 应用程序并发运行。在第七章《利用新的默认 G1 垃圾收集器》中,你了解了 CMS 和 G1。Shenandoah 项目打算将并发压缩添加到可能的垃圾收集方法中。

摘要

在本章中,我们概述了 Java 平台未来的发展,超出了 Java 9 的范围。我们探讨了 Java 10 的计划以及我们可能看到的 Java 10 以上的进一步变化。每个可能的 Java 平台变化都被描述为有针对性的、已提交的或草案。具体来说,我们涵盖了以下类别中 Java 平台未来的变化:JDK 更改、Java 编译器、Java 虚拟机、JavaX 和特殊项目。

第十八章:Java 9 编程蓝图

*实现新功能,如模块、进程

处理 API、REPL 以及更多功能以构建端到端

在 Java 9 中的应用*

第十九章:简介

在建造一座新建筑的过程中,一套蓝图帮助所有相关方进行沟通——建筑师、电工、木匠、水管工等等。它详细说明了形状、尺寸和材料等内容。没有它们,每个分包商都会被留下猜测要做什么,在哪里做,以及如何做。没有这些蓝图,现代建筑几乎是不可能的。

你手中的——或者在你面前的屏幕上——是一套不同类型的蓝图。这些蓝图并不是详细说明如何构建你的特定软件系统,因为每个项目和环境都有独特的约束和要求,这些蓝图提供了如何构建各种基于 Java 的系统的示例,提供了如何使用 Java 开发工具包JDK)中特定特性的示例,特别关注你可以应用于特定问题的 Java 9 的新特性。

由于仅使用新的 Java 9 特性构建应用程序是不可能的,因此我们还将使用并突出显示 JDK 中许多最新的特性。在我们深入探讨这涉及的内容之前,让我们简要地讨论一下最近主要 JDK 发布的一些这些出色的新特性。希望大多数 Java 商店已经使用 Java 7,因此我们将重点关注版本 8,当然还有版本 9。

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

  • Java 8 的新特性

  • Java 9 的新特性

  • 项目

Java 8 的新特性

Java 8,于 2014 年 3 月 8 日发布,可能是自 2004 年发布的 Java 5 以来最具争议的两个特性——lambda 和 streams。随着函数式编程在 JVM 世界中的流行,尤其是在 Scala 等语言的帮助下,Java 的拥护者已经呼吁了几年更多的函数式语言特性。最初计划在 Java 7 中发布,该特性被从那个版本中删除,最终在 Java 8 中稳定发布。

虽然可以希望每个人都熟悉 Java 的 lambda 支持,但经验表明,由于各种原因,许多公司对新的语言版本和特性的采用速度较慢,因此一个快速的介绍可能是有帮助的。

Lambda

术语 lambda,其根源在于由 Alonzo Church 在 1936 年开发的 lambda 演算,简单地说是指一个匿名函数。通常,一个函数(或方法,在更正式的 Java 术语中),是 Java 源代码中的一个静态命名的工件:

    public int add(int x, int y) { 
      return x + y; 
    } 

这种简单的方法被命名为 add,它接受两个 int 参数并返回一个 int 参数。随着 lambda 的引入,现在可以这样编写:

    (int x, int y) → x + y 

或者,更简单地说,如下所示:

    (x, y) → x + y 

这种缩写语法表示我们有一个接受两个参数并返回它们和的函数。根据 lambda 的使用位置,编译器可以推断出参数的类型,这使得第二个,甚至更简洁的格式成为可能。最重要的是,注意这个方法不再有名字。除非它被分配给变量或作为参数传递(稍后会更详细地讨论这一点),否则它不能在任何系统中的任何地方被引用或使用。

当然,这个例子过于简单。更好的例子可能出现在许多 API 中,其中方法的参数是实现所谓的 单抽象方法(SAM)接口的实现,至少在 Java 8 之前,这是一个只有一个方法的接口。Runnable 是 SAM 的一个典型例子。以下是一个 pre-lambda Runnable 使用的例子:

    Runnable r = new Runnable() { 
      public void run() { 
        System.out.println("Do some work"); 
      } 
    }; 
    Thread t = new Thread(r); 
    t.start(); 

使用 Java 8 的 lambda,这段代码可以大大简化为以下形式:

    Thread t = new Thread(() ->
      System.out.println("Do some work")); 
    t.start(); 

Runnable 方法的体仍然相当简单,但清晰度和简洁度的提升应该是相当明显的。

虽然 lambda 是匿名函数(也就是说,它们没有名字),但 Java 的 lambda,就像许多其他语言一样,也可以分配给变量并作为参数传递(实际上,如果没有这种功能,功能几乎毫无价值)。回顾前面代码中的 Runnable 方法,我们可以将 Runnable 的声明和使用分开,如下所示:

    Runnable r = () { 
      // Acquire database connection 
      // Do something really expensive 
    }; 
    Thread t = new Thread(r); 
    t.start(); 

这比前面的例子更冗长。Runnable 方法的占位符体旨在模仿现实世界中的 Runnable 可能的样子,以及为什么尽管 lambda 提供了简洁性,人们可能仍然希望将新定义的 Runnable 方法分配给变量。这种新的 lambda 语法允许我们声明 Runnable 方法的体,而无需担心方法名、签名等。诚然,任何不错的 IDE 都会帮助处理这种样板代码,但这种新的语法为您和将维护您的代码的无穷无尽的开发者提供了更少的噪音,以便在调试代码时解析。

任何 SAM 接口都可以写成 lambda。您有一个只需要使用一次的比较器吗?

    List<Student> students = getStudents(); 
    students.sort((one, two) -> one.getGrade() - two.getGrade()); 

那么 ActionListener 呢?

    saveButton.setOnAction((event) -> saveAndClose()); 

此外,您可以在 lambda 中使用自己的 SAM 接口,如下所示:

    public <T> interface Validator<T> { 
      boolean isValid(T value); 
    } 
    cardProcessor.setValidator((card) 
    card.getNumber().startsWith("1234")); 

这种方法的优点不仅使消费代码更加简洁,而且也减少了创建这些具体 SAM 实例的努力。也就是说,开发者不必在匿名类和具体命名类之间做出选择,而可以声明它,干净且简洁。

除了 Java 开发者多年来一直在使用的 SAMs(单抽象方法接口)之外,Java 8 引入了许多功能接口,以帮助促进更函数式风格的编程。Java 8 的 Javadoc 列出了 43 个不同的接口。在这些接口中,有一些基本的函数形状你应该了解,其中一些如下:

BiConsumer<T,U> 这表示一个接受两个输入参数且不返回结果的操作
BiFunction<T,U,R> 这表示一个接受两个参数并产生结果的函数
BinaryOperator<T> 这表示对两个相同类型的操作数进行操作,产生与操作数相同类型的结果
BiPredicate<T,U> 这表示一个接受两个参数的谓词(布尔值函数)
Consumer<T> 这表示一个接受单个输入参数且不返回结果的操作
Function<T,R> 这表示一个接受一个参数并产生结果的函数
Predicate<T> 这表示一个接受一个参数的谓词(布尔值函数)
Supplier<T> 这表示一个结果提供者

这些接口有无数的使用场景,但也许最好的方式是将注意力转向 Java 8 的下一个重要特性——Streams。

Java 8 的另一个主要新增功能,也许是最能体现 lambda 表达式优势的地方,就是新的Streams API。如果你要搜索 Java 流的定义,你会得到从“数据元素流”这样的相对循环定义到“Java 流是 monads”这样的更技术性的定义,两者可能都是正确的。Streams API 允许 Java 开发者通过一系列步骤与数据元素流进行交互。即使这样描述也不是非常清晰,所以让我们通过查看一些示例代码来了解它的含义。

假设你有一个特定班级的分数列表。你可能想知道班级中女生的平均分数。在 Java 8 之前,你可能写成这样:

    double sum = 0.0; 
    int count = 0; 
    for (Map.Entry<Student, Integer> g : grades.entrySet()) { 
      if ("F".equals(g.getKey().getGender())) { 
        count++; 
        sum += g.getValue(); 
      } 
    } 
    double avg = sum / count; 

我们初始化两个变量,一个用于存储总和,一个用于计数。然后我们遍历分数。如果学生的性别是女性,我们增加计数器并更新总和。当循环结束时,我们就有了计算平均分所需的信息。这可以工作,但有点冗长。新的 Streams API 可以帮助解决这个问题:

    double avg = grades.entrySet().stream() 
     .filter(e -> "F".equals(e.getKey().getGender())) // 1 
     .mapToInt(e -> e.getValue()) // 2 
     .average() // 3 
     .getAsDouble(); //4 

这个新版本并没有显著减小,但代码的目的更加清晰。在之前的非流代码中,我们必须像计算机一样解析代码,并推断其预期目的。使用流,我们有清晰、声明性的方式来表达应用逻辑。对于映射中的每个条目执行以下操作:

  1. 过滤掉所有gender不是F的条目。

  2. 将每个值映射到原始的 int 类型。

  3. 计算平均分数。

  4. 将值作为 double 类型返回。

使用基于流和 lambda 的方法,我们不需要声明临时、中间变量(成绩计数和总分),也不需要担心计算平均数。JDK 为我们做了所有的繁重工作。

新的 java.time 包

虽然 lambda 表达式和流式处理是极其重要的变革性更新,但在 Java 8 中,我们还得到了另一个长期期待的改变,至少在某些圈子中,它同样令人兴奋:一个新的日期/时间 API。任何在 Java 中处理日期和时间的开发者都知道java.util.Calendar及其公司的痛苦。显然,你可以完成你的工作,但并不总是那么美观。许多开发者发现 API 的使用过于痛苦,因此他们将其集成到他们的项目中。Java 架构师也同意这一点,并聘请了 Joda Time 的作者 Stephen Colebourne 领导 JSR 310,将 Joda Time(修复各种设计缺陷)的一个版本带到了平台上。我们将在本书的后面部分详细探讨如何使用这些新的 API。

默认方法

在我们将注意力转向 Java 9 之前,让我们先看看另一个重要的语言特性:默认方法。自从 Java 开始以来,接口被用来定义类的外观,暗示了一种特定的行为,但无法实现这种行为。这使得在许多情况下多态性变得更加简单,因为任何数量的类都可以实现给定的接口,并且消费代码将它们视为该接口,而不是它们实际是的具体类。

然而,多年来 API 开发者面临的一个问题是如何在不破坏现有代码的情况下演进 API 及其接口。例如,以 JavaServer Faces 1.1 规范中的ActionSource接口为例。当 JSF 1.2 专家小组在制定规范的下一个版本时,他们确定需要向接口添加一个新属性,这将导致两个新方法——获取器和设置器。他们不能简单地添加方法到接口中,因为这会破坏规范的所有实现,需要实现者更新他们的类。显然,这种破坏是不可接受的,因此 JSF 1.2 引入了ActionSource2,它扩展了ActionSource并添加了新方法。虽然这种方法被许多人认为很丑陋,但 1.2 专家小组有几个选择,而且没有一个选择是很好的。

然而,随着 Java 8 的到来,接口现在可以在接口定义上指定默认方法,如果扩展类没有提供该方法,编译器将使用该方法实现。以下代码片段可以作为例子:

    public interface Speaker { 
      void saySomething(String message); 
    } 
    public class SpeakerImpl implements Speaker { 
      public void saySomething(String message) { 
        System.out.println(message); 
      } 
    } 

我们已经开发了自己的 API 并将其公开,并且它证明非常受欢迎。然而,随着时间的推移,我们发现了一个我们想要改进的地方:我们希望添加一些便利方法,例如sayHello()sayGoodbye(),以节省我们的用户一些时间。然而,正如之前讨论的那样,如果我们只是将这些新方法添加到接口中,一旦用户更新到库的新版本,就会破坏他们的代码。默认方法允许我们扩展接口,并通过定义一个实现来避免破坏:

    public interface Speaker { 
      void saySomething(String message); 
      default public void sayHello() { 
        System.out.println("Hello"); 
      } 
      default public void sayGoodbye() { 
        System.out.println("Good bye"); 
      } 
    } 

现在,当用户更新他们的库 JAR 文件时,他们立即获得这些新方法和它们的行为,而无需做出任何更改。当然,要使用这些方法,用户将需要修改他们的代码,但他们不需要这样做,直到——如果——他们想要这样做。

Java 9 的新特性

就像任何新的 JDK 版本一样,这个版本包含了大量令人兴奋的新特性。当然,最吸引人的特性将根据您的需求而有所不同,但我们将特别关注一些与我们共同构建的项目最相关的几个新特性。首先是最大的一个,Java 模块系统。

Java 平台模块系统/Project Jigsaw

尽管 Java 8 是一个功能齐全的版本,但许多人认为它有些令人失望。它缺乏备受期待的Java 平台模块系统JPMS),虽然更通俗地被称为 Project Jigsaw,但并不完全准确。Java 平台模块系统最初计划于 2011 年与 Java 7 一起发布,但由于一些悬而未决的技术问题而被推迟到 Java 8。Project Jigsaw 不仅是为了完成模块系统,还为了模块化 JDK 本身,这将有助于 Java SE 缩小到更小的设备,如移动电话和嵌入式系统。Jigsaw 计划与 2014 年发布的 Java 8 一起发布,但它再次被推迟,因为 Java 架构师认为他们还需要更多时间来正确实施该系统。然而,最终,Java 9 将最终交付这个长期承诺的项目。

话虽如此,这究竟是什么呢?长期以来一直困扰着 API 开发者,包括 JDK 架构师的一个问题是无法隐藏公共 API 的实现细节。一个来自 JDK 的例子是开发者不应直接使用的私有类,例如com.sun.*/sun.*包和类。一个完美的例子——私有 API 被广泛用于公共领域——是sun.misc.Unsafe类。除了在 Javadoc 中有一个措辞强烈的警告,不要使用这些内部类之外,几乎没有其他方法可以阻止它们的使用。直到现在。

使用 JPMS,开发者将能够使实现类公开,以便它们可以在项目中轻松使用,但又不暴露给模块外部,这意味着它们不会暴露给 API 或库的消费者。为此,Java 架构师引入了一个新的文件,module-info.java,类似于现有的package-info.java文件,位于模块的根目录中,例如,在src/main/java/module-info.java。它被编译成module-info.class,并在运行时通过反射和新的java.lang.Module类可用。

那么这个文件的作用是什么,它的样子又是什么样的呢?Java 开发者可以使用这个文件来命名模块,列出其依赖项,并向系统表达,无论是编译时还是运行时,哪些包被导出到外部世界。例如,假设在我们前面的流示例中,我们有三个包:modelapiimpl。我们希望暴露模型和 API 类,但不暴露任何实现类。我们的module-info.java文件可能看起来像这样:

    module com.packt.j9blueprints.intro { 
      requires com.foo; 
      exports com.packt.j9blueprints.intro.model; 
      exports com.packt.j9blueprints.intro.api; 
    } 

这个定义暴露了我们想要导出的两个包,并且也声明了对com.foo模块的依赖。如果在编译时该模块不可用,项目将无法构建;如果在运行时不可用,系统将抛出异常并退出。请注意,requires语句没有指定版本。这是故意的,因为决定不将版本选择问题作为模块系统的一部分来处理,而是将其留给更合适的系统,例如构建工具和容器。

当然,关于模块系统还可以说很多,但对其所有功能和局限性的详尽讨论超出了本书的范围。不过,我们将以模块的形式实现我们的应用程序,因此我们将在整本书中看到系统的使用——也许会有更详细的解释。

想要更深入讨论 Java 平台模块系统的读者可以搜索马克·雷诺尔德的文章《模块系统现状》。

进程处理 API

在 Java 的早期版本中,与本地操作系统进程交互的开发者必须使用一个相当有限的 API,一些操作需要回退到本地代码。作为Java 增强提案JEP)102 的一部分,Java 进程 API 被扩展了以下功能(引用自 JEP 文本):

  • 获取当前 Java 虚拟机 pid 以及使用现有 API 创建的进程 pid 的能力。

  • 列举系统上进程的能力。每个进程的信息可能包括其 pid、名称、状态以及可能的使用资源。

  • 处理进程树的能力;特别是,一些销毁进程树的方法。

  • 处理数百个子进程的能力,可能通过复用输出或错误流来避免为每个子进程创建一个线程。

我们将在我们的第一个项目中探索这些 API 更改,即进程查看器/管理器(有关详细信息,请参阅以下章节)。

并发更改

正如 Java 7 所做的那样,Java 架构师重新审视了并发库,进行了一些必要的更改,这次是为了支持 reactive-streams 规范。这些更改包括一个新类java.util.concurrent.Flow,它包含几个嵌套接口:Flow.ProcessorFlow.PublisherFlow.SubscriberFlow.Subscription

REPL

似乎有很多令人兴奋的变化并不是语言上的变化。而是添加了一个REPL读取-评估-打印-循环),这是一个语言壳的高级术语。实际上,这个新工具的命令是jshell。这个工具允许我们输入或粘贴 Java 代码并立即获得反馈。例如,如果我们想实验前面章节中讨论的 Streams API,我们可以这样做:

$ jshell 
|  Welcome to JShell -- Version 9-ea 
|  For an introduction type: /help intro 

jshell> List<String> names = Arrays.asList(new String[]{"Tom", "Bill", "Xavier", "Sarah", "Adam"}); 
names ==> [Tom, Bill, Xavier, Sarah, Adam] 

jshell> names.stream().sorted().forEach(System.out::println); 
Adam 
Bill 
Sarah 
Tom 
Xavier 

这是一个非常受欢迎的添加,应该有助于 Java 开发者快速原型设计和测试他们的想法。

项目

在简要而高层次地概述了可用的新功能之后,我们将要覆盖的这些蓝图看起来是什么样子?我们将构建十个不同类型的应用程序,这些应用程序在复杂性和种类上有所不同,并涵盖广泛的问题。在每个项目中,我们将特别关注我们强调的新功能,但也会看到一些较旧的、经过验证的语言特性和库被广泛使用,任何有趣或新颖的使用都会被标记出来。因此,以下是我们的项目阵容。

进程查看器/管理器

在实现古老的 Unix 工具——top的 Java 版本时,我们将探索一些进程处理 API 的改进。结合这个 API 和 JavaFX,我们将构建一个图形工具,允许用户查看和管理系统上运行的过程。

本项目将涵盖以下内容:

  • Java 9 进程 API 增强

  • JavaFX

重复文件查找器

随着系统老化,文件系统中的杂乱无章,尤其是重复文件的可能性似乎呈指数增长。利用一些新的文件 I/O 库,我们将构建一个工具来扫描一组用户指定的目录以识别重复文件。将 JavaFX 重新放回工具箱中,我们将添加一个图形用户界面,它将提供更用户友好的方式来交互式地处理重复文件。

本项目将涵盖以下内容:

  • Java 文件 I/O

  • 哈希库

  • JavaFX

日期计算器

随着 Java 8 的发布,Oracle 将基于对 Joda Time 的重设计,或多或少地集成到一个新的库中,即 JDK。官方称为 JSR 310,这个新库解决了 JDK 长期存在的问题——官方日期库不足且难以使用。在本项目中,我们将构建一个简单的命令行日期计算器,它将接受一个日期,例如,并添加任意数量的时间。以下是一段示例代码:

$ datecalc "2016-07-04 + 2 weeks" 
2016-07-18 
$ datecalc "2016-07-04 + 35 days" 
2016-08-08 
$ datecalc "12:00CST to PST" 
10:00PST 

本项目将涵盖以下内容:

  • Java 8 日期/时间 API

  • 正则表达式

  • Java 命令行库

社交媒体聚合器

在这么多社交媒体网络上拥有账户的一个问题是跟踪每个平台上发生的事情。拥有 Twitter、Facebook、Google+、Instagram 等账户的活跃用户可能会花费大量时间在不同的网站或应用程序之间跳转,阅读最新的更新。在本章中,我们将构建一个简单的聚合器应用程序,它将从用户的每个社交媒体账户中提取最新的更新,并在一个地方显示它们。功能将包括以下内容:

  • 多个社交媒体网络的账户:

    • Twitter

    • Pinterest

    • Instagram

  • 只读的社交媒体帖子丰富列表

  • 链接到适当的网站或应用程序,以便快速轻松地跟进

  • 桌面和移动版本

本项目将涵盖以下内容:

  • REST/HTTP 客户端

  • JSON 处理

  • JavaFX 和 Android 开发

鉴于这项工作的规模和范围,我们将实际上分两章来完成:第一章是 JavaFX,第二章是 Android。

邮件过滤器

管理电子邮件可能很棘手,尤其是如果你有多个账户。如果你从多个位置(即从多个桌面或移动应用程序)访问你的邮件,管理你的邮件规则可能会更加棘手。如果你的邮件系统不支持存储在服务器上的规则,你将决定将规则放在哪里,以便它们最常运行。通过这个项目,我们将开发一个应用程序,允许我们编写各种规则,并通过可选的后台进程运行它们,以始终保持你的邮件得到适当的整理。

一个示例rules文件可能看起来像这样:

    [ 
      { 
        "serverName": "mail.server.com", 
        "serverPort": "993", 
        "useSsl": true, 
        "userName": "me@example.com", 
        "password": "password", 
        "rules": [ 
           {"type": "move", 
               "sourceFolder": "Inbox", 
               "destFolder": "Folder1", 
               "matchingText": "someone@example.com"}, 
            {"type": "delete", 
               "sourceFolder": "Ads", 
               "olderThan": 180} 
         ] 
      } 
    ] 

本项目将涵盖以下内容:

  • JavaMail

  • JavaFX

  • JSON 处理

  • 操作系统集成

  • 文件输入/输出

JavaFX 照片管理

Java 开发工具包提供了一套非常强大的图像处理 API。在 Java 9 中,这些 API 通过改进对 TIFF 规范的支持得到了增强。在本章中,我们将通过创建图像/照片管理应用程序来练习这个 API。我们将添加从用户指定的位置导入图像到配置的官方目录的支持。我们还将回顾重复文件查找器,并重用项目开发中的一些代码,以帮助我们识别重复的图像。

本项目将涵盖以下内容:

  • 新的javax.imageio

  • JavaFX

  • NetBeans 富客户端平台

  • Java 文件输入/输出

客户端/服务器笔记应用程序

你是否曾经使用过基于云的笔记应用?你是否想过要创建自己的应用?在本章中,我们将创建这样一个应用,包括完整的前端和后端。在服务器端,我们将数据存储在广受欢迎的文档数据库 MongoDB 中,并通过 REST 接口公开应用程序的业务逻辑的适当部分。在客户端,我们将使用 JavaScript 开发一个非常基本的用户界面,这将使我们能够实验并展示如何在 Java 项目中使用 JavaScript。

本项目将涵盖以下内容:

  • 文档数据库(MongoDB)

  • JAX-RS 和 RESTful 接口

  • JavaFX

  • JavaScript 和 Vue 2

无服务器 Java

无服务器,也称为函数即服务FaaS),是目前最热门的趋势之一。它是一种应用/部署模型,其中一个小型函数被部署到一个管理函数几乎所有方面的服务中——启动、关闭、内存等,从而让开发者从担心这些细节中解脱出来。在本章中,我们将编写一个简单的无服务器 Java 应用程序,看看它是如何完成的,以及你如何可能在自己的应用程序中使用这项新技术。

本项目将涵盖以下内容:

  • 创建亚马逊网络服务账户

  • 配置 AWS Lambda、简单通知服务、简单电子邮件服务和 DynamoDB

  • 编写和部署 Java 函数

安卓桌面同步客户端

通过这个项目,我们将稍微改变一下方向,专注于 Java 生态系统的一个不同部分:安卓。为此,我们将关注一些安卓用户仍然面临的问题——安卓设备和桌面(或笔记本电脑)系统的同步。虽然各种云服务提供商都在推动我们将越来越多的内容存储在云端并流式传输到设备上,但有些人仍然出于各种原因(从云资源的成本到不可靠的无线连接和隐私问题)更愿意直接在设备上存储照片和音乐。

在本章中,我们将构建一个系统,允许用户在他们的设备和桌面或笔记本电脑之间同步音乐和照片。我们将构建一个安卓应用程序,为用户提供从移动设备端配置和监控同步的用户界面,以及一个安卓服务,如果需要,将在后台执行同步。我们还将构建桌面上的相关组件——一个图形应用程序,用于从桌面配置和监控过程,以及一个后台进程,用于处理桌面端的同步。

本项目将涵盖以下内容:

  • 安卓

  • 用户界面

  • 服务

  • JavaFX

  • REST

入门

我们快速浏览了一些我们将使用的新语言特性。我们还快速概述了我们将要构建的项目。最后还有一个问题:我们将使用什么工具来完成我们的工作?

当涉及到开发工具时,Java 生态系统拥有丰富的资源,因此我们有大量的选择。我们面临的最基本的选择是构建工具。在我们的工作中,我们将使用 Maven。虽然有一个强大而积极的社区会提倡 Gradle,但 Maven 似乎是目前最常用的构建工具,并且似乎从主要的 IDE 中获得了更稳健、成熟和本地的支持。如果您还没有安装 Maven,您可以通过访问 maven.apache.org 下载适用于您操作系统的发行版,或者使用您的操作系统支持的任何包管理系统。

对于 IDE,所有截图、说明等都将使用 NetBeans——来自 Oracle 的免费开源 IDE。当然,IntelliJ IDEA 和 Eclipse 都有支持者,它们都是不错的选择,但 NetBeans 提供了开箱即用的完整和强大的开发环境,并且速度快、稳定、免费。要下载 NetBeans,请访问 netbeans.org 并下载适用于您操作系统的相应安装程序。由于我们使用 Maven,而 Maven 同时也支持 IDEA 和 Eclipse,因此您应该能够在您选择的任何 IDE 中打开这里展示的项目。尽管在 GUI 中显示了步骤,但您仍需要根据您选择的 IDE 进行调整。

在撰写本文时,NetBeans 的最新版本是 8.2,使用它进行 Java 9 开发的最佳方法是运行 Java 8 的 IDE,并添加 Java 9 作为 SDK。有一个基于 Java 9 运行的 NetBeans 开发版本,但作为一个开发版本,它有时可能不稳定。一个稳定的 NetBeans 9 应该与 Java 9 本身大致同时发布。在此期间,我们将继续使用 8.2:

  1. 要添加 Java 9 支持,我们需要添加一个新的 Java 平台,并且我们将通过点击“工具 | 平台”来完成这一操作。

  2. 这将打开 Java 平台管理器界面:

图片

  1. 在屏幕左下角点击“添加平台...”。

图片

  1. 我们希望添加一个 Java 标准版平台,因此我们将接受默认设置并点击“下一步”。

图片

  1. 在“添加 Java 平台”屏幕上,我们将导航到我们已安装 Java 9 的位置,选择 JDK 目录,然后点击“下一步”。

图片

  1. 我们需要为新 Java 平台命名(NetBeans 默认为非常合理的 JDK 9),因此我们将点击“完成”并现在可以看到我们新添加的 Java 9 选项。

图片

当项目 SDK 设置完成后,我们就准备好体验这些新的 Java 9 特性了,我们将在第十八章“Java 中的进程管理”中开始这一过程。链接。

如果你使用 Java 9 运行 NetBeans,这应该在本书出版时是可能的,那么你将已经配置了 Java 9。然而,如果你需要特定版本的 Java 8,你可以使用前面的步骤进行配置。

摘要

在本章中,我们快速浏览了 Java 8 的一些新特性,包括 lambda 表达式、流、新的日期/时间包和默认方法。从 Java 9 开始,我们快速了解了 Java 平台模块系统(JPMS)和 Project Jigsaw、进程处理 API、新的并发更改以及新的 Java REPL。对于每一个,我们都讨论了其是什么以及为什么,并查看了一些这些可能对我们编写系统产生影响的例子。我们还探讨了本书中我们将要构建的项目类型以及我们将使用的工具。

在我们继续之前,我想重申一个早期的观点——每个软件项目都是不同的,因此不可能以这种方式编写这本书,让你可以简单地复制粘贴大量代码到你的项目中。同样,每个开发者编写代码的方式也不同;我组织代码的方式可能与你的大相径庭。因此,在阅读这本书时,重要的是要记住这一点,不要纠结于细节。这里的目的是不是向你展示使用这些 API 的唯一正确方式,而是提供一个你可以参考的例子,以更好地了解它们可能的使用方式。从每个例子中学习你能学到的,根据你的需要修改,然后去构建一些令人惊叹的东西。

话虽如此,让我们将注意力转向我们的第一个项目,进程管理器,以及新的进程处理 API。

第二十章:Java 中的进程管理

通过快速浏览 Java 9 的一些新特性以及之前几个版本的一些特性,让我们将注意力转向以实际方式应用这些新 API。我们将从一个简单的进程管理器开始。

虽然让应用程序或实用程序内部处理所有用户的问题通常是理想的,但有时出于各种原因需要运行(或外部调用)外部程序。从 Java 的最初几天起,JDK 就通过Runtime类和各种 API 支持了这一点。以下是最简单的示例:

    Process p = Runtime.getRuntime().exec("/path/to/program"); 

一旦进程被创建,你可以通过Process类跟踪其执行,该类具有getInputStream()getOutputStream()getErrorStream()等方法。我们还可以通过destroy()waitFor()方法对进程进行基本的控制。Java 8 通过添加destroyForcibly()waitFor(long, TimeUnit)方法推动了事物的发展。引用自Java 增强提案JEP),我们看到了以下新功能的原因:

许多企业应用程序和容器涉及多个 Java 虚拟机和进程,并且长期存在以下需求:

  • 能够获取当前 Java 虚拟机的 pid 以及使用现有 API 创建的进程的 pid。

  • 能够枚举系统上的进程。每个进程的信息可能包括其 pid、名称、状态以及可能的使用资源。

  • 能够处理进程树,特别是提供一种销毁进程树的方法。

  • 能够处理数百个子进程,可能通过复用输出或错误流来避免为每个子进程创建一个线程。

在本章中,我们将构建一个简单的进程管理应用程序,类似于 Windows 任务管理器或*nix 的 top。当然,用 Java 编写的进程管理器几乎没有需求,但这将是我们探索这些新的进程处理 API 的绝佳途径。此外,我们还将花一些时间了解其他语言特性和 API,即 JavaFX 和Optional

本章涵盖了以下主题:

  • 创建项目

  • 启动应用程序

  • 定义用户界面

  • 初始化用户界面

  • 添加菜单

  • 更新进程列表

话虽如此,让我们开始吧。

创建项目

通常来说,如果构建可以在不使用特定 IDE 或其他专有工具的情况下重现,那就更好了。幸运的是,NetBeans 提供了创建基于 Maven 的 JavaFX 项目的功能。点击“文件”|“新建项目”,然后选择Maven,然后选择 JavaFX 应用程序:

接下来,执行以下步骤:

  1. 点击“下一步”。

  2. 将项目名称输入为ProcessManager

  3. 将组 ID 输入为com.steeplesoft

  4. 将包输入为com.steeplesoft.processmanager

  5. 选择项目位置。

  6. 点击完成。

以下截图作为示例:

图片

一旦创建了新项目,我们需要更新 Maven 的pom文件以使用 Java 9:

    <build> 
      <plugins> 
        <plugin> 
          <groupId>org.apache.maven.plugins</groupId> 
          <artifactId>maven-compiler-plugin</artifactId> 
          <version>3.6.1</version> 
          <configuration> 
            <source>9</source> 
            <target>9</target> 
          </configuration> 
        </plugin> 
      </plugins> 
    </build> 

现在,NetBeans 和 Maven 都配置好了使用 Java 9,我们准备开始编码。

启动应用

如介绍中所述,这将是一个基于 JavaFX 的应用,所以我们将首先创建应用的骨架。这是一个 Java 9 应用,我们打算使用 Java 模块系统。为此,我们需要创建模块定义文件module-info.java,它位于我们的源树根目录。这是一个基于 Maven 的项目,所以那将是src/main/java

    module procman.app { 
      requires javafx.controls; 
      requires javafx.fxml; 
    } 

这个小文件做了几件事情。首先,它定义了一个新的procman.app模块。接下来,它告诉系统这个模块requires两个 JDK 模块:javafx.controlsjavafx.fxml。如果我们没有指定这两个模块,那么我们下面将要看到的系统将无法编译,因为 JDK 不会将所需的类和包提供给我们的应用。这些模块是 Java 9 标准 JDK 的一部分,所以这不应该是一个问题。然而,这可能在 Java 未来的版本中发生变化,并且这个模块声明将帮助我们通过强制宿主 JVM 提供模块或失败启动来防止应用在运行时出现故障。也有可能通过J-Link工具构建自定义的 Java 运行时,所以在 Java 9 下缺少这些模块仍然是一个可能性。在我们的模块配置完成后,让我们转向应用。

新兴的标准目录布局似乎是这样的:src/main/java/*<module1>*src/main/java/*<module2>*,等等。在撰写本书时,虽然 Maven 可以被诱导采用这种布局,但插件本身,尽管它们在 Java 9 下运行,似乎并不足够模块化,以允许我们以这种方式组织我们的代码。因此,为了简单起见,我们将一个 Maven 模块视为一个 Java 模块,并保持项目的标准源布局。

我们将要创建的第一个类是Application的子类,这是 NetBeans 为我们创建的。它创建了Main类,我们将它重命名为ProcessManager

    public class ProcessManager extends Application { 
      @Override 
      public void start(Stage stage) throws Exception { 
        Parent root = FXMLLoader 
         .load(getClass().getResource("/fxml/procman.fxml")); 

        Scene scene = new Scene(root); 
        scene.getStylesheets().add("/styles/Styles.css"); 

        stage.setTitle("Process Manager"); 
        stage.setScene(scene); 
        stage.show(); 
      } 

      public static void main(String[] args) { 
        launch(args); 
      } 
    } 

我们的ProcessManager类扩展了 JavaFX 的基类Application,它提供了启动和停止应用的各种功能。我们在main()方法中看到我们只是委托给Application.launch(String[]),它为我们启动新应用做了繁重的工作。

这个类的更有趣的部分是start()方法,这是 JavaFX 生命周期回调到我们的应用的地方,给了我们构建用户界面的机会,我们将在下一部分完成。

定义用户界面

当构建 JavaFX 应用程序的用户界面时,你可以有两种方式:代码或标记。为了使我们的代码更小、更易读,我们将使用 FXML 构建用户界面——这是一种专门为 JavaFX 创建的基于 XML 的语言,用于表达用户界面。这给我们带来了另一个二选一的选择——我们是手动编写 XML,还是使用图形工具?同样,选择很简单——我们将使用一个工具,Scene Builder,这是一个最初由 Oracle 开发并由 Gluon 维护和支持的所见即所得工具。然而,我们还将查看 XML 源代码,以便了解正在进行的操作,所以如果你不喜欢使用 GUI 工具,你也不会被排除在外。

安装和使用 Scene Builder,正如你所预期的那样,相当简单。它可以从 gluonhq.com/labs/scene-builder/ 下载。一旦安装,你需要在 NetBeans 的设置窗口中指定其位置,这可以在 Java | JavaFX 下的设置窗口完成,如下面的截图所示:

截图

我们现在准备好创建 FXML 文件。在项目视图中的 resources 目录下,创建一个名为 fxml 的新文件夹,并在该文件夹中创建一个名为 procman.fxml 的文件,如下所示:

    <BorderPane  

      fx:controller="com.steeplesoft.procman.Controller"> 
    </BorderPane> 

BorderPane 是一个容器,定义了五个区域——topbottomleftrightcenter,这使我们能够相当粗略地控制控件在表单上的位置。通常,使用 BorderPane,每个区域都使用嵌套容器来提供通常必要的更细粒度的控制。对于我们的需求,这种级别的控制将非常完美。

用户界面的主要关注点是进程列表,所以我们将从这里开始,使用 Scene Builder,我们想要点击左侧手风琴上的“控件”部分,然后向下滚动到TableView。点击它并将其拖到表单上的CENTER区域,如 Scene Builder 中的这个截图所示:

截图

生成的 FXML 应该看起来像这样:

    <center> 
        <TableView fx:id="processList" 
               BorderPane.alignment="CENTER"> 
        </TableView> 
    </center> 

在其他区域没有组件的情况下,TableView 将扩展以填充窗口的全部区域,这是我们目前想要的。

初始化用户界面

虽然 FXML 定义了用户界面的结构,但我们确实需要一些 Java 代码来初始化各种元素、响应用户操作等。这个类被称为控制器,它只是一个扩展了 javafx.fxml.Initializable 的类:

    public class Controller implements Initializable { 
      @FXML 
      private TableView<ProcessHandle> processList; 
      @Override 
      public void initialize(URL url, ResourceBundle rb) { 
      } 
    } 

initialize() 方法来自接口,并由 JavaFX 运行时在从先前的 Application 类调用 FXMLLoader.load() 时创建控制器时使用。注意实例变量 processList 上的 @FXML 注解。当 JavaFX 初始化控制器时,在调用 initialize() 方法之前,系统会查找指定 fx:id 属性的 FXML 元素,并将该引用分配给控制器中适当的实例变量。为了完成这个连接,我们必须对我们的 FXML 文件进行最后一次更改:

    <TableView fx:id="processList" BorderPane.alignment="CENTER">
    ...

该更改也可以在 Scene Builder 中进行,如本截图所示:

图片

fx:id 属性的值必须与已用 @FXML 注释的实例变量的名称匹配。当调用 initialize 时,processList 将具有对 TableView 的有效引用,我们可以在 Java 代码中对其进行操作。

fx:id 的值也可以通过 Scene Builder 设置。要设置值,请点击表单编辑器中的控件,然后在右侧折叠面板中展开代码部分。在 fx:id 字段中,输入所需的变量名。

最后一部分是指定 FXML 文件的控制器。在 XML 源中,您可以通过用户界面根元素的 fx:controller 属性来设置此属性:

    <BorderPane  xmlns="http://javafx.com/javafx/8.0.60"
      xmlns:fx="http://javafx.com/fxml/1" 
      fx:controller="com.steeplesoft.procman.Controller">

这也可以通过 Scene Builder 设置。在左侧折叠面板的文档部分中,展开控制器部分,并在控制器类字段中输入所需的完全限定类名:

图片

在这些组件就绪后,我们可以开始初始化 TableView 的工作,这使我们回到了我们的主要兴趣点,即处理 API 的过程。我们的起点是 ProcessHandles.allProcesses()。从 Javadoc 中,你了解到这个方法返回 当前进程可见的所有进程的快照。从流中的每个 ProcessHandle,我们可以获取关于进程 ID、其状态、子进程、父进程等信息。每个 ProcessHandle 还有一个嵌套对象 Info,其中包含关于进程的信息快照。由于不是所有信息都在各种支持的平台上可用,并且受到当前进程权限的限制,Info 对象上的属性是 Optional<T> 实例,表示值可能已设置也可能未设置。花点时间快速了解一下 Optional<T> 是什么可能是有益的。

Javadoc 将 Optional<T> 描述为 一个可能包含或不包含非空值的容器对象。受 Scala 和 Haskell 的启发,Optional<T> 在 Java 8 中被引入,以允许 API 作者提供更安全的空值接口。在 Java 8 之前,ProcessHandle.Info 上的方法可能定义如下:

    public String command(); 

为了消费 API,开发者可能会编写类似以下的内容:

    String command = processHandle.info().command(); 
    if (command == null) { 
      command = "<unknown>"; 
    } 

如果开发者未能显式检查空值,则几乎肯定会在某个时刻发生 NullPointerException。通过使用 Optional<T>,API 作者向用户表明返回值可能为空,并且应该小心处理。因此,更新的代码可能如下所示:

    String command = processHandle.info().command() 
     .orElse("<unknown>"); 

现在,我们可以在一行简洁的代码中获取值,如果存在,或者如果没有值,则返回默认值。ProcessHandle.Info API 广泛使用这种结构,我们将在后面看到。

Optional 还为我们开发者提供了什么?有许多实例方法可以帮助澄清空值处理代码:

  • filter(Predicate<? super T> predicate): 使用此方法,我们可以过滤 Optional 的内容。而不是使用 if...else 块,我们可以将 Predicate 传递给 filter() 方法并直接进行测试。Predicate 是一个 @FunctionalInterface,它接受一个输入并返回一个布尔值。例如,一些 JavaFX Dialog 的使用可能返回 Optional<ButtonType>。如果我们只想在用户点击特定按钮(例如,确定)时执行某些操作,我们可以这样过滤 Optional
        alert.showAndWait() 
         .filter(b -> b instanceof ButtonType.OK) 
  • map(Function<? super T,? extends U> mapper): map 函数允许我们将 Optional 的内容传递给一个函数,该函数将对其进行一些处理,并返回它。然而,函数的返回值将被包装在 Optional 中:
        Optional<String> opts = Optional.of("hello"); 
        Optional<String> upper = opts.map(s ->  
         s.toUpperCase()); 
        Optional<Optional<String>> upper2 =  
         opts.map(s -> Optional.of(s.toUpperCase())); 

注意 Optionalupper2 的双重包装。如果 Function 返回 Optional,它将被另一个 Optional 包装,从而产生这种不希望的双重包装。幸运的是,我们有另一种选择。

  • flatMap(Function<? super T,Optional<U>> mapper): flatMap 函数结合了两个函数式思想--映射和扁平化。如果 Function 的结果是 Optional 对象,而不是双重包装值,它将被扁平化为单个 Optional 对象。回顾先前的示例,我们得到如下结果:
        Optional<String> upper3 = opts.flatMap(s ->      
         Optional.of(s.toUpperCase())); 

注意,与 upper2 不同,upper3 是一个单独的 Optional

  • get(): 如果存在,则返回包装的值。如果没有值,则抛出 NoSuchElementException 错误。

  • ifPresent(Consumer<? super T> action): 如果 Optional 对象包含一个值,则将其传递给 Consumer。如果没有值,则不执行任何操作。

  • ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction): 与 ifPresent() 类似,如果存在值,它将传递给 Consumer。如果没有值,则执行 Runnable emptyAction

  • isPresent(): 这个方法简单地返回 Optional 对象是否包含一个值。

  • or(Supplier<Optional<T>> supplier): 如果 Optional 对象有一个值,则描述该 Optional。如果没有值,则返回由 Supplier 产生的 Optional 对象。

  • orElse(T other): 如果 Optional 对象包含一个值,则返回该值。如果没有值,则返回 other

  • orElseGet(Supplier<? extends T> supplier): 这与前面提到的 orElse() 的工作方式相同,但如果不存在值,则返回 Supplier 的结果。

  • orElseThrow(Supplier<? extends X> exceptionSupplier): 如果存在值,则返回该值。如果不存在值,则抛出由 Supplier 提供的 Exception

Optional 还有一些静态方法,这些方法有助于创建 Optional 实例,其中一些如下:

  • empty(): 这返回一个空的 Optional 对象。

  • of(T value): 这返回一个描述非空值的 Optional 对象。如果值为空,则抛出 NullPointerException

  • ofNullable(T value): 这返回一个描述值的 Optional 对象。如果值为空,则返回一个空的 Optional

在我们掌握了 Optional<T> 的简要介绍之后,让我们看看它的存在如何影响我们的应用程序。

将注意力转回到 initialize() 方法,我们的第一步是获取要显示的进程列表。流 API 使得这一点非常简单:

    ProcessHandle.allProcesses() 
     .collect(Collectors.toList()); 

allProcesses() 方法返回 Stream<ProcessHandle>,这允许我们将新的流操作应用于我们的问题。在这种情况下,我们只想创建一个包含所有 ProcessHandle 实例的 List,因此我们调用 collect(),这是一个接受 Collector 的流操作。我们可以从多个选项中选择,但我们需要一个 List,所以我们使用 Collectors.toList(),它将收集流中的每个项目,并在流结束时返回一个 List。请注意,List 的参数化类型将与 Stream 相匹配,在这种情况下是 ProcessHandle

这一行代码,然后,为我们提供了一个 List<ProcessHandle>,其中包含当前进程可以看到的系统中每个进程,但这只完成了一半。TableView API 不接受 List<T>。它只支持 ObservableList<T>,但那是什么?它的 Javadoc 定义得非常简单--一个允许监听器在变化发生时跟踪更改的列表。换句话说,当这个列表发生变化时,TableView 将自动被告知并重新绘制自己。一旦我们将 TableView 与这个列表关联起来,我们只需要担心数据,控制将处理其余部分。创建 ObservableList 非常简单:

    @FXML 
    private TableView<ProcessHandle> processView; 
    final private ObservableList<ProcessHandle> processList =  
      FXCollections.observableArrayList(); 
    // ... 
    processView.setItems(processList);      
    processList.setAll(ProcessHandle.allProcesses() 
     .collect(Collectors.toList())); 

在我们的情况下,TableView 实例由运行时注入(此处包含以供参考),我们通过 FXCollections.observableArrayList() 创建 ObservableList。在 initialize() 中,我们通过 setItems()ObservableList 设置在 TableView 上,然后通过 setAll() 填充 ObservableList。这样,我们的 TableView 就拥有了渲染自身所需的所有数据。几乎是这样。它有渲染所需的数据,但它如何渲染呢?ProcessHandle.Info 的每个字段去哪里了?为了回答这个问题,我们必须定义表上的列,并告诉每个列从哪里获取其数据。

要做到这一点,我们需要创建几个 TableColumn<S,T> 实例。TableColumn 负责显示其列标题(如适当),以及每个单元格的值。然而,你必须告诉它如何显示单元格。这是通过单元格值工厂来完成的。在 Java 7 中,这个 API 会得到如下代码:

    TableColumn<ProcessHandle, String> commandCol =  
     new TableColumn<>("Command"); 
    commandCol.setCellValueFactory(new  
      Callback<TableColumn.CellDataFeatures<ProcessHandle, String>,  
       ObservableValue<String>>() { 
         public ObservableValue<String> call( 
          TableColumn.CellDataFeatures<ProcessHandle,  
           String> p) { 
             return new SimpleObjectProperty(p.getValue()
              .info() 
              .command() 
              .map(Controller::afterLast) 
              .orElse("<unknown>")); 
           } 
       }
    ); 

我先为你说出来:这真的很丑陋。幸运的是,我们可以利用 lambda 表达式和类型推断来简化代码,使其更容易阅读:

    TableColumn<ProcessHandle, String> commandCol =  
     new TableColumn<>("Command"); 
    commandCol.setCellValueFactory(data ->  
     new SimpleObjectProperty(data.getValue().info().command() 
      .map(Controller::afterLast) 
      .orElse("<unknown>"))); 

这将十四行代码替换为六行。看起来更漂亮了。现在,我们只需要再这样做五次,每次对应一个列。尽管前面的代码已经有所改进,但仍然有相当多的重复代码。再次强调,Java 8 函数式接口可以帮助我们进一步清理代码。对于每一列,我们想要指定标题、宽度和从 ProcessHandle.Info 中提取的内容。我们可以用这个方法来封装这些:

    private <T> TableColumn<ProcessHandle, T>  
      createTableColumn(String header, int width,  
       Function<ProcessHandle, T> function) { 
         TableColumn<ProcessHandle, T> column = 
          new TableColumn<>(header); 

         column.setMinWidth(width); 
         column.setCellValueFactory(data ->  
          new SimpleObjectProperty<T>( 
           function.apply(data.getValue()))); 
           return column; 
    } 

Function<T,R> 接口是 FunctionalInterface,它代表一个接受一个类型 T 并返回另一个类型 R 的函数。在我们的例子中,我们定义这个方法为接受一个 String、一个 int 以及一个接受 ProcessHandle 并返回泛型类型的函数作为参数。这可能有点难以想象,但有了这个方法定义,我们可以用对这个方法的调用替换前面的代码以及类似的代码。现在,前面的相同代码可以简化为这样:

    createTableColumn("Command", 250,  
      p -> p.info().command() 
      .map(Controller::afterLast) 
      .orElse("<unknown>")) 

现在我们只需要将这些列添加到控件中,我们可以用这个方法来完成:

    processView.getColumns().setAll( 
      createTableColumn("Command", 250,  
      p -> p.info().command() 
       .map(Controller::afterLast) 
       .orElse("<unknown>")), 
      createTableColumn("PID", 75, p -> p.getPid()), 
      createTableColumn("Status", 150,  
       p -> p.isAlive() ? "Running" : "Not Running"), 
      createTableColumn("Owner", 150,  
       p -> p.info().user() 
        .map(Controller::afterLast) 
        .orElse("<unknown>")), 
      createTableColumn("Arguments", 75,  
       p -> p.info().arguments().stream() 
        .map(i -> i.toString()) 
        .collect(Collectors.joining(", ")))); 

注意,我们在 ProcessHandle.Info 上使用的每个方法都返回我们在前面代码中看到的 Optional<T>。由于它这样做,我们有一个非常优雅且干净的 API 来获取我们想要的信息(或合理的默认值),而无需在生产环境中担心 NullPointerException

如果我们现在运行应用程序,我们应该得到类似这样的结果:

图片

目前看起来不错,但还不是完全准备好。我们希望能够启动新的进程以及终止现有的进程。这两者都需要菜单,所以我们将添加这些菜单。

添加菜单

JavaFX 中的菜单从 MenuBar 组件开始。我们当然希望这个菜单在窗口的顶部,所以我们将组件添加到我们的 BorderPanetop 部分。如果你使用 Scene Builder,你的 FXML 文件最终会变成这样:

    <MenuBar BorderPane.alignment="CENTER"> 
      <menus> 
        <Menu mnemonicParsing="false" text="File"> 
          <items> 
            <MenuItem mnemonicParsing="false" text="Close" /> 
          </items> 
        </Menu> 
        <Menu mnemonicParsing="false" text="Edit"> 
          <items> 
            <MenuItem mnemonicParsing="false" text="Delete" /> 
          </items> 
        </Menu> 
        <Menu mnemonicParsing="false" text="Help"> 
          <items> 
            <MenuItem mnemonicParsing="false" text="About" /> 
          </items> 
        </Menu> 
      </menus> 
    </MenuBar> 

我们不需要编辑菜单,所以我们可以从 FXML 文件中删除该部分(或者在 Scene Builder 中右键单击第二个 Menu 条目并点击删除)。要创建我们想要的菜单项,我们向 File 元素下的 item 元素添加适当的 MenuItem 条目:

    <Menu mnemonicParsing="true" text="_File"> 
      <items> 
        <MenuItem mnemonicParsing="true"  
          onAction="#runProcessHandler"  
          text="_New Process..." /> 
        <MenuItem mnemonicParsing="true"  
          onAction="#killProcessHandler"  
          text="_Kill Process..." /> 
        <MenuItem mnemonicParsing="true"  
          onAction="#closeApplication"  
          text="_Close" /> 
      </items> 
    </Menu> 

每个这些 MenuItem 条目都定义了三个属性:

  • mnemonicParsing:这指示 JavaFX 使用任何以下划线开头字母作为键盘快捷键

  • onAction:这标识了当 MenuItem 被激活/点击时将在控制器上被调用的方法

  • text:这定义了 MenuItem 的标签

最有趣的部分是 onAction 以及它与控制器的关系。JavaFX 当然知道这个形式是由 com.steeplesoft.procman.Controller 支持的,所以它会寻找具有以下签名的方:

    @FXML 
    public void methodName(ActionEvent event) 

ActionEvent 是一个在 JavaFX 中被用于多种场景的类。在我们的情况下,我们有针对每个菜单项的特定方法,所以事件本身并不太有趣。让我们看看每个处理程序,从最简单的 closeApplication 开始:

    @FXML 
    public void closeApplication(ActionEvent event) { 
      Platform.exit(); 
    } 

这里没有什么可看的;当菜单项被点击时,我们通过调用 Platform.exit() 来退出应用程序。

接下来,让我们看看如何终止一个进程:

    @FXML 
    public void killProcessHandler(final ActionEvent event) { 
      new Alert(Alert.AlertType.CONFIRMATION,  
      "Are you sure you want to kill this process?",  
      ButtonType.YES, ButtonType.NO) 
       .showAndWait() 
       .filter(button -> button == ButtonType.YES) 
       .ifPresent(response -> { 
         ProcessHandle selectedItem =  
          processView.getSelectionModel() 
           .getSelectedItem(); 
         if (selectedItem != null) { 
           selectedItem.destroy(); 
           processListUpdater.updateList(); 
         } 
       }); 
    } 

我们在这里有很多事情要做。我们首先做的是创建一个类型为 CONFIRMATIONAlert 对话框,该对话框要求用户确认请求。对话框有两个按钮:YESNO。一旦对话框创建完成,我们就调用 showAndWait(),正如其名称所暗示的那样——它会显示对话框并等待用户的响应。它返回 Optional<ButtonType>,该类型包含用户点击的按钮类型,这将是 ButtonType.YESButtonType.NO,这取决于我们创建的 Alert 对话框类型。使用 Optional,我们可以应用 filter() 来找到我们感兴趣的按钮类型,即 ButtonType.YES,这将返回另一个 Optional。如果用户点击了是,ifPresent() 将返回 true(多亏了我们的过滤器),然后我们传递的 lambda 将被执行。非常棒且简洁。

下一个感兴趣的区域是 lambda。一旦我们确定用户想要终止一个进程,我们需要确定要终止哪个进程。为此,我们通过 TableView.getSelectionModel() .getSelectedItem() 询问 TableView 哪一行被选中。如果我们需要检查 null(唉,这里没有 Optional),我们必须这样做,以防用户实际上没有选择任何行。如果它非空,我们可以在 TableView 给我们的 ProcessHandle 上调用 destroy()。然后我们调用 processListUpdater.updateList() 来刷新 UI。我们稍后再来看这个。

我们最终的动作处理程序必须运行以下命令:

    @FXML 
    public void runProcessHandler(final ActionEvent event) { 
      final TextInputDialog inputDlg = new TextInputDialog(); 
      inputDlg.setTitle("Run command..."); 
      inputDlg.setContentText("Command Line:"); 
      inputDlg.setHeaderText(null); 
      inputDlg.showAndWait().ifPresent(c -> { 
        try { 
          new ProcessBuilder(c).start(); 
        } catch (IOException e) { 
            new Alert(Alert.AlertType.ERROR,  
              "There was an error running your command.") 
              .show(); 
          } 
      }); 
    } 

在许多方面,这与前面的 killProcessHandler() 方法相似——我们创建一个对话框,设置一些选项,调用 showAndWait(),然后处理 Optional。不幸的是,对话框不支持构建器模式,这意味着我们没有漂亮的、流畅的 API 来构建对话框,所以我们分几个离散的步骤来做。处理 Optional 也类似。我们调用 ifPresent() 来查看对话框是否返回了一个命令行(即用户输入了一些文本并且按下了 OK),如果有的话,将其传递给 lambda。

让我们快速看一下 lambda。这是另一个多行 lambda 的例子。虽然我们之前看到的 lambda 大多数都是简单的单行函数,但请记住,lambda可以跨越多行。为了支持这一点,我们只需要像我们这样做,用大括号包裹代码块,然后一切照旧。对于这样的多行 lambda,必须小心,因为 lambda 给我们带来的可读性和简洁性的任何收益都可能很快被一个变得太大的 lambda 体所掩盖或消除。在这些情况下,将代码提取到方法中并使用方法引用可能是明智的选择。最终,决定权在你,但请记住 Uncle Bob Martin 的话--清晰是王道

关于菜单的一个最后项目。为了更加有用,应用程序应该提供一个上下文菜单,允许用户右键单击一个进程并从那里将其终止,而不是点击行,将鼠标移动到File菜单,等等。添加上下文菜单是一个简单的操作。我们只需要像这样修改我们的TableView定义在 FXML 中:

    <TableView fx:id="processView" BorderPane.alignment="CENTER"> 
      <contextMenu> 
        <ContextMenu> 
          <items> 
            <MenuItem onAction="#killProcessHandler"  
               text="Kill Process..."/> 
          </items> 
        </ContextMenu> 
      </contextMenu> 
    </TableView> 

在这里,我们向我们的TableView添加了一个contextMenu子项。与它的兄弟MenuBar类似,contextMenu有一个items子项,该子项反过来又有 0 个或多个MenuItem子项。在这种情况下,Kill Process...MenuItem看起来与File下的非常相似,唯一的区别是mnemonicProcessing信息。我们甚至重用了ActionEvent处理程序,所以没有额外的编码,无论你点击哪个菜单项,终止进程的行为总是相同的。

更新进程列表

如果应用程序启动并显示了一个进程列表,但从未更新过该列表,那么它将毫无用处。我们需要的,是能够定期更新列表的方法,为此,我们将使用一个Thread

如你所知或不知,Thread大致是一种在后台运行任务的方式(Javadoc 将其描述为程序中的执行线程)。一个系统可以是单线程或多线程的,这取决于系统的需求和运行时环境。多线程编程很难做对。幸运的是,我们的用例相当简单,但我们仍然必须谨慎行事,否则我们会看到一些非常意外的行为。

通常情况下,当你创建一个Thread时,你会得到这样的建议:实现一个Runnable接口,然后将它传递给线程的构造函数,这是一个非常好的建议,因为它使你的类层次结构更加灵活,因为你不会绑定到一个具体的基类(Runnable是一个interface)。然而,在我们的案例中,我们有一个相对简单的系统,从这个方法中获得的收益很小,所以我们将直接扩展Thread并简化我们的代码,同时封装我们期望的行为。让我们看看我们的新类:

    private class ProcessListUpdater extends Thread { 
      private volatile boolean running = true; 

      public ProcessListRunnable() { 
        super(); 
        setDaemon(true); 
      } 

      public void shutdown() { 
        running = false; 
      } 

      @Override 
      public void run() { 
        while (running) { 
          updateList(); 
          try { 
            Thread.sleep(5000); 
          } catch (InterruptedException e) { 
              // Ignored 
            } 
        } 
      }  

      public synchronized void updateList() { 
        processList.setAll(ProcessHandle.allProcesses() 
          .collect(Collectors.toList())); 
        processView.sort(); 
      } 
    } 

我们有一个相当基础的类,我们给它起了一个合理且具有意义的名字,它扩展了Thread类。在构造函数中,请注意我们调用了setDaemon(true)。这将允许我们的应用程序按预期退出,而不是阻塞等待线程终止。我们还定义了一个shutdown()方法,我们将从应用程序中使用它来停止线程。

Thread类确实有各种状态控制方法,如stop()suspend()resume()等,但这些都已被弃用,因为它们被认为固有不安全。搜索文章《为什么Thread.stopThread.suspendThread.resume被弃用?》,如果你想了解更多细节;然而,现在的建议最佳实践是使用控制标志,就像我们用running做的那样,向Thread类发出信号,表明它需要清理和关闭。

最后,我们来到了Thread类的核心,即run()方法,它无限循环(或者直到running变为 false),在执行完工作后休眠五秒钟。实际的工作是在updateList()中完成的,它构建进程列表,更新我们之前讨论过的ObservableList,然后指示TableView根据用户的排序选择重新排序(如果有的话)。这是一个公共方法,允许我们在需要时调用它,就像我们在killProcessHandler()中做的那样。这让我们有了以下代码块来设置它:

    @Override 
    public void initialize(URL url, ResourceBundle rb) { 
      processListUpdater = new ProcessListUpdater(); 
      processListUpdater.start(); 
      // ... 
    } 

以下代码将关闭它,这我们在closeHandler()中已经看到了:

    processListUpdater.shutdown(); 

留意细节的人会注意到updateList()上有synchronized关键字。这是为了防止由于从多个线程调用此方法而可能引起的任何类型的竞争条件。想象一下这样的场景:当用户决定终止一个进程,并在线程唤醒时点击确认对话框上的 OK 按钮(这类事情发生的频率可能比你想象的要高)。我们可能有两个线程同时调用updateList(),结果第一个线程在第二个线程击中processView.sort()时刚好触发了processList.setAll()。当另一个线程正在重建列表时调用sort()会发生什么?很难确定,但可能会造成灾难性的后果,所以我们想禁止这种情况。synchronized关键字指示 JVM 只允许一次只有一个线程执行该方法,导致其他所有线程排队等待轮到它们(请注意,它们的执行顺序是非确定性的,所以你不能根据线程运行synchronized方法的顺序来建立任何期望)。这避免了竞争条件的发生,并确保我们的程序不会崩溃。

虽然在这里是合适的,但在使用同步方法时必须小心,因为获取和释放锁可能代价高昂(尽管在现代化的 JVM 中要小得多),更重要的是,它强制线程在遇到这个方法调用时按顺序运行,这可能导致应用程序中出现非常不希望出现的延迟,尤其是在 GUI 应用程序中。在编写自己的多线程应用程序时,请记住这一点。

摘要

这样一来,我们的应用程序就完成了。虽然这不是一个特别复杂的应用程序,但它确实包括了几个有趣的技术,例如 JavaFX、Lambda 表达式、Streams、ProcessHandle以及相关的类,还有线程。

在下一章中,我们将构建一个简单的命令行工具来查找重复文件。通过这个过程,我们将亲身体验新的文件 I/O API、Java 持久化 API(JPA)、文件哈希以及一些 JavaFX。

第二十一章:重复文件查找器

任何运行了一段时间的系统都会开始受到硬盘杂乱的影响。例如,对于大型音乐和照片收藏来说,这一点尤其如此。除了最挑剔的文件被复制和移动之外,我们最终会在这里和那里都有副本。然而,问题是,这些副本中哪些是重复的,哪些不是?在本章中,我们将构建一个文件遍历实用程序,它将扫描一组目录以查找重复文件。我们将能够指定是否应该删除重复文件、隔离或只是报告。

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

  • Java 平台模块系统

  • Java NIO(新 I/O)文件 API

  • 文件哈希

  • Java 持久化 APIJPA

  • 新的 Java 日期/时间 API

  • 编写命令行实用程序

  • 更多 JavaFX

入门

这个应用程序在概念上相当简单,但比上一章我们讨论的要复杂一些,因为我们会有命令行和图形界面两个界面。经验丰富的程序员可能会立即看到在两个界面之间共享代码的需要,因为DRY(不要重复自己)是良好设计系统的一个许多标志之一。因此,为了促进这种代码共享,我们将引入第三个模块,该模块提供了一个可以被其他两个项目使用的库。我们将把这些模块称为libcligui。设置项目的第一步是创建各种 Maven POM 文件来描述项目的结构。父 POM 看起来可能像这样:

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

     <groupId>com.steeplesoft.dupefind</groupId> 
     <artifactId>dupefind-master</artifactId> 
     <version>1.0-SNAPSHOT</version> 
     <packaging>pom</packaging> 

     <modules> 
       <module>lib</module> 
       <module>cli</module> 
       <module>gui</module> 
     </modules> 

     <name>Duplicate Finder - Master</name> 
    </project> 

这是一个相当典型的 POM 文件。我们首先将确定项目的父项目,这样我们就可以继承许多设置、依赖项等,避免在这个项目中重复它们。接下来,我们将定义项目的 Maven 坐标。请注意,我们没有为这个项目定义版本,允许父项目的版本向下传递。这将允许我们在一个地方增加版本,并隐式更新所有子项目。

对于那些以前没有见过多模块项目的读者来说,这个 POM 的最后一个有趣的部分是modules部分。对于那些新手来说,需要注意的是,每个module元素都指的是一个目录名,它是当前目录的直接子目录,并且应该按照它们所需的顺序声明。在我们的情况下,CLI 和 GUI 都依赖于库,所以lib排在第一位。接下来,我们需要为每个模块创建 POM 文件。这些每个都是典型的 jar 类型 POM,所以没有必要在这里包含它们。每个都会有不同的依赖项,但我们将根据需要来讨论这些依赖项。

构建库

这个项目的基石是库,CLI 和 GUI 都将使用这个库,所以从这里开始是有意义的。在设计库时——它的输入、输出和一般行为——了解我们到底想要这个系统做什么,这很有帮助,所以让我们花点时间来讨论功能需求。

如介绍中所述,我们希望能够搜索任意数量的目录中的重复文件。我们还希望能够将搜索和比较限制在仅某些文件上。如果我们没有指定匹配模式,那么我们希望检查每个文件。

最重要的是如何识别匹配。当然,有无数种方法可以做到这一点,但我们将采用以下方法:

  • 识别具有相同文件名的文件。想想那些你可能已经从相机下载图片到电脑保存,然后后来可能忘记了你已经下载了这些图片,所以你又在其他地方复制了它们的情况。显然,你只需要一个副本,但是例如文件IMG_9615.JPG在临时目录中与你在图片备份目录中的文件是否相同?通过识别具有匹配名称的文件,我们可以测试它们以确保它们是相同的。

  • 识别具有相同大小的文件。这里的匹配可能性较小,但仍然存在机会。例如,一些照片管理软件在从设备导入图片时,如果发现具有相同名称的文件,会修改第二个文件的文件名并将两个文件都存储起来,而不是停止导入并要求立即用户干预。这可能导致大量文件,如IMG_9615.JPGIMG_9615-1.JPG。这个检查将有助于识别这些情况。

  • 对于上面的每个匹配项,为了确定文件是否实际上是匹配的,我们将根据文件内容生成一个哈希值。如果有多个文件生成了相同的哈希值,这些文件实际上是相同的可能性非常高。我们将这些文件标记为潜在的重复文件。

这是一个相当简单的算法,应该相当有效,但我们确实有一个问题,尽管这个问题可能不是立即显而易见的。如果你有大量的文件,特别是具有大量潜在重复文件的集合,处理所有这些文件可能是一个非常耗时的过程,我们希望尽可能减少这个过程,这导致我们有一些非功能性需求:

  • 程序应该以并发方式处理文件,以尽可能减少处理大量文件集所需的时间。

  • 这种并发性应该是有界的,这样系统就不会因为处理请求而超负荷。

  • 考虑到可能存在大量数据,系统必须设计得避免耗尽所有可用的 RAM 并导致系统不稳定。

在这个相当简短的函数性和非函数性需求列表中,我们应该准备好开始。就像上一个应用程序一样,让我们先定义我们的模块。在 src/main/java 中,我们将创建这个 module-info.java

    module com.steeplesoft.dupefind.lib { 
      exports com.steeplesoft.dupefind.lib; 
    } 

初始时,编译器——以及 IDE——会抱怨 com.steeplesoft.dupefind.lib 包不存在,并且不会编译项目。现在还不用担心这个问题,因为我们现在就会创建这个包。

在功能性需求中使用 并发 这个词,很可能会立刻让人联想到线程的概念。我们在第十八章 管理 Java 进程 中介绍了线程的概念,所以如果你不熟悉它们,请回顾一下上一章的相关内容。

在这个项目中,我们使用线程的方式与上一个项目不同,因为我们会有一些需要完成的工作,一旦完成,我们希望线程退出。我们还需要等待这些线程完成它们的工作,以便我们可以分析它们。在 java.util.concurrent 包中,JDK 提供了几个选项来完成这项任务。

带有 Future 接口的并发 Java

其中一个更常见且受欢迎的 API 是 Future<V> 接口。Future 是封装异步计算的一种方式。通常,Future 实例由 ExecutorService 返回,我们稍后会讨论它。一旦调用者获得 Future 的引用,它就可以继续在其他任务上工作,同时 Future 在另一个线程的背景中运行。当调用者准备好 Future 的结果时,它调用 Future.get()。如果 Future 已经完成其工作,调用将立即返回结果。然而,如果 Future 仍在工作,对 get() 的调用将阻塞,直到 Future 完成。

然而,对于我们的用途来说,Future 并不是最合适的选择。查看非功能性需求,我们看到避免通过显式耗尽可用内存而导致系统崩溃的愿望被明确列出。正如我们稍后将会看到的,这将通过将数据存储在轻量级的磁盘数据库中来实现,我们将会实现这一点——同样,正如我们稍后将会看到的——通过在检索文件信息时将其存储,而不是通过收集数据然后在后处理方法中保存它。鉴于这一点,我们的 Future 不会返回任何内容。虽然有一种方法可以使它工作(将 Future 定义为 Future<?> 并返回 null),但这并不是最自然的方法。

可能最合适的方案是ExecutorService,它是一个提供了额外功能(例如创建Future的能力,如前所述,以及管理队列的终止)的Executor。那么,Executor是什么呢?Executor是一个比简单地调用new Thread(runnable).start()更健壮的执行Runnable的机制。该接口本身非常基础,仅包含execute(Runnable)方法,因此仅从查看 Javadoc 中无法立即看出其价值。然而,如果你查看ExecutorService,这是所有由 JDK 提供的Executor实现以及各种Executor实现的接口,它们的价值就很容易显现出来。现在让我们快速浏览一下。

查看Executors类,我们可以看到五种不同类型的Executor实现:一个缓存线程池、一个固定大小的线程池、一个计划线程池、一个单线程执行器和一个工作窃取线程池。除了单线程Executor是唯一例外外,这些都可以直接实例化(ThreadPoolExecutorScheduledThreadPoolExecutorForkJoinPool),但 JDK 作者强烈建议用户使用Executors类上的便利方法。话虽如此,这些选项分别是什么,为什么你可能会选择其中一个?

  • Executors.newCachedThreadPool(): 此方法返回一个提供缓存线程池的Executor。随着任务的到来,Executor将尝试找到一个未使用的线程来执行任务。如果找不到,将创建一个新的Thread并开始工作。当任务完成时,Thread将返回到池中以等待重用。大约 60 秒后,未使用的线程将被销毁并从池中移除,这防止了资源被分配后从未释放。尽管如此,在使用此Executor时必须小心,因为线程池是无界的,这意味着在重负载下,系统可能会被活跃的线程压垮。

  • Executors.newFixedThreadPool(int nThreads): 此方法返回一个与之前提到的类似但有所不同的Executor,其区别在于线程池的大小被限制在最多nThreads

  • Executors.newScheduledThreadPool(int corePoolSize): 此Executor能够根据可选的初始延迟和基于延迟和TimeUnit值的周期性来调度任务。例如,查看schedule(Runnable command, long delay, TimeUnit unit)方法。

  • Executors.newSingleThreadExecutor(): 此方法将返回一个使用单个线程来执行提交给它的任务的Executor。任务将保证按照它们提交的顺序执行。

  • Executors.newWorkStealingExecutor(): 此方法将返回一个所谓的工作窃取Executor,它属于ForkJoinPool类型。提交给此Executor的任务将以一种方式编写,以便能够将工作分配给额外的工作线程,直到工作的大小低于用户定义的阈值。

考虑到我们的非功能性需求,固定大小的ThreadPoolExecutor似乎是最合适的选择。然而,我们需要支持的一个配置选项是强制为每个找到的文件生成哈希值。根据前面的算法,只有具有重复名称或大小的文件会被哈希化。但是,用户可能希望对他们的文件规范进行更彻底的分析,并希望对每个文件强制进行哈希处理。我们将使用工作窃取(或分叉/合并)池来实现这一点。

在选择了我们的线程方法后,让我们看看库的入口点,我们将称之为FileFinder的类。由于这是我们的入口点,它需要知道我们想要搜索的位置以及我们想要搜索的内容。这将给我们实例变量sourcePathspatterns

    private final Set<Path> sourcePaths = new HashSet<>(); 
    private final Set<String> patterns = new HashSet<>(); 

我们将变量声明为private,因为这是一种良好的面向对象实践。我们还将它们声明为final,以帮助避免这些变量被分配新值导致的微妙错误。一般来说,我发现将变量默认标记为final是一种良好的实践,以防止此类微妙错误。在这种情况下,此类类的实例变量只能声明为final,如果它要么立即被赋予值,就像我们在这里所做的那样,要么在类的构造函数中赋予值。

我们还希望在现在定义我们的ExecutorService

    private final ExecutorService es = 
      Executors.newFixedThreadPool(5); 

我们有些任意地将线程池限制为五个线程,因为它似乎在为重请求提供足够的工作线程和分配大量线程之间提供了一个公平的平衡,而这些线程在大多数情况下可能不会被使用。在我们的情况下,这可能是被夸大了一点的小问题,但确实是一件需要记住的事情。

接下来,我们需要提供一种存储任何找到的重复项的方法。以下代码行作为示例:

    private final Map<String, List<FileInfo>> duplicates =  
      new HashMap<>(); 

我们将在稍后看到更多细节,但,目前我们只需要注意的是,这是一个以文件哈希为键的Map,其中包含List<FileInfo>对象。

需要注意的最后一个变量可能有些出乎意料——一个EntityManagerFactory。你可能想知道那是什么?EntityManagerFactory是与由Java 持久化 APIJPA)定义的持久化单元交互的接口,它是 Java 企业版规范的一部分。幸运的是,该规范被编写得如此,以至于它可以在我们这样的标准版SE)环境中使用。

那么,我们用这样的 API 做什么呢?如果您回顾一下非功能性需求,我们指定了我们要确保搜索重复文件不会耗尽系统上的可用内存。对于非常大的搜索,文件及其哈希值的列表可能会增长到问题的大小。再加上生成哈希值所需的内存,我们稍后会讨论这一点,我们很可能遇到内存不足的情况。因此,我们将使用 JPA 将我们的搜索信息保存到一个简单、轻量级的数据库(SQLite)中,这将允许我们将数据保存到磁盘。它还将允许我们比反复迭代内存结构更有效地查询和过滤结果。

在我们能够使用那些 API 之前,我们需要更新我们的模块描述符,让系统知道我们现在需要持久化模块。以下代码片段可以作为示例:

    module dupefind.lib { 
      exports com.steeplesoft.dupefind.lib; 
      requires java.logging; 
      requires javax.persistence; 
    } 

我们已经向系统声明我们需要javax.persistencejava.logging,我们稍后会使用。正如我们在第十八章管理 Java 中的进程中讨论的,如果这些模块中的任何一个不存在,JVM 实例将无法启动。

模块定义中可能更为重要的部分是exports子句。通过这一行(可能有一行或多行),我们告诉系统我们正在导出指定包中的所有类型。这一行将允许我们的 CLI 模块(我们稍后会讨论)使用该模块中的类(以及如果我们添加任何的话,还包括接口、枚举等)。如果一个类型的包没有export,那么消费模块将无法看到该类型,我们稍后也会演示这一点。

理解这一点后,让我们看看我们的构造函数:

    public FileFinder() { 
      Map<String, String> props = new HashMap<>(); 
      props.put("javax.persistence.jdbc.url",  
       "jdbc:sqlite:" +  
       System.getProperty("user.home") +  
       File.separator +  
       ".dupfinder.db"); 
      factory = Persistence.createEntityManagerFactory 
       ("dupefinder", props); 
      purgeExistingFileInfo(); 
    } 

要配置持久化单元,JPA 通常使用一个persistence.xml文件。然而,在我们的情况下,我们希望对数据库文件的存储位置有更多的控制。正如您在前面的代码中所看到的,我们正在使用user.home环境变量来构建 JDBC URL。然后我们使用 JPA 定义的键将这个 URL 存储在一个Map中,以指定 URL。这个Map随后被传递给createEntityManagerFactory方法,该方法覆盖了persistence.xml中设置的任何内容。这允许我们将数据库放在适合用户操作系统的家目录中。

在我们的类构建和配置完成后,是时候看看我们如何找到重复的文件了:

    public void find() { 
      List<PathMatcher> matchers = patterns.stream() 
       .map(s -> !s.startsWith("**") ? "**/" + s : s) 
       .map(p -> FileSystems.getDefault() 
       .getPathMatcher("glob:" + p)) 
       .collect(Collectors.toList()); 

我们的第一步是根据用户指定的模式创建一个PathMatcher实例列表。PathMatcher实例是一个函数式接口,由尝试匹配文件和路径的对象实现。我们的实例是从FileSystems类中检索的。

当请求 PathMatcher 时,我们必须指定全局模式。正如在 map() 的第一次调用中可以看到的,我们必须调整用户指定的内容。通常,模式掩码简单地指定为类似 *.jpg 的内容。然而,这样的模式掩码不会按用户期望的方式工作,它只会查看当前目录,而不会遍历任何子目录。为了做到这一点,模式必须以 **/ 前缀开始,我们在 map() 调用中这样做。使用调整后的模式,我们从系统的默认 FileSystem 请求 PathMatcher 实例。请注意,我们指定匹配器模式为 "glob:" + p,因为我们确实需要表明我们正在指定一个 glob 文件。

在我们的匹配器准备就绪后,我们就可以开始搜索了。我们用以下代码来完成:

    sourcePaths.stream() 
     .map(p -> new FindFileTask(p)) 
     .forEach(fft -> es.execute(fft)); 

使用 Stream API,我们将每个源路径映射到一个 lambda 表达式,该表达式创建一个 FindFileTask 实例,并提供它将搜索的源路径。这些 FileFindTask 实例中的每一个都将通过 execute() 方法传递给我们的 ExecutorService

FileFindTask 方法是这个过程这一部分的工作马。它是一个 Runnable,因为我们将会将其提交给 ExecutorService,但它也是一个 FileVisitor<Path>,因为它将会在遍历文件树时使用,我们从 run() 方法中执行这一操作:

    @Override 
    public void run() { 
      final EntityTransaction transaction = em.getTransaction(); 
      try { 
        transaction.begin(); 
        Files.walkFileTree(startDir, this); 
        transaction.commit(); 
      } catch (IOException ex) { 
        transaction.rollback(); 
      } 
    } 

由于我们将会通过 JPA 将数据插入数据库,因此我们需要首先开始一个事务。由于这是一个应用程序管理的 EntityManager,我们必须手动管理事务。我们在 try/catch 块之外获取 EntityTransaction 实例的引用,以简化对其的引用。在 try 块内部,我们开始事务,通过 Files.walkFileTree() 开始文件遍历,如果过程成功,则提交事务。如果失败——如果抛出了 Exception——则回滚事务。

FileVisitor API 需要实现许多方法,其中大多数并不特别有趣,但为了清晰起见,我们将展示它们:

    @Override 
    public FileVisitResult preVisitDirectory(final Path dir,  
    final BasicFileAttributes attrs) throws IOException { 
      return Files.isReadable(dir) ?  
       FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; 
    } 

在这里,我们告诉系统如果目录可读,则继续遍历该目录。否则,我们跳过它:

    @Override 
    public FileVisitResult visitFileFailed(final Path file,  
     final IOException exc) throws IOException { 
       return FileVisitResult.SKIP_SUBTREE; 
    } 

API 要求实现这个方法,但我们并不非常关心文件读取失败,所以我们简单地返回一个跳过结果:

    @Override 
    public FileVisitResult postVisitDirectory(final Path dir,  
     final IOException exc) throws IOException { 
       return FileVisitResult.CONTINUE; 
    } 

与前面的方法类似,这个方法也是必需的,但我们对此特定事件不感兴趣,所以我们向系统发出继续的信号:

    @Override 
    public FileVisitResult visitFile(final Path file, final
     BasicFileAttributes attrs) throws IOException { 
       if (Files.isReadable(file) && isMatch(file)) { 
         addFile(file); 
       } 
       return FileVisitResult.CONTINUE; 
    } 

现在我们来到了一个我们感兴趣的方法。我们将检查文件是否可读,然后检查它是否匹配。如果是,我们添加该文件。无论如何,我们继续遍历树。我们如何测试文件是否匹配?以下代码片段可以作为示例:

    private boolean isMatch(final Path file) { 
      return matchers.isEmpty() ? true :  
       matchers.stream().anyMatch((m) -> m.matches(file)); 
    } 

我们遍历我们之前传递给类的PathMatcher实例列表。如果List为空,这意味着用户没有指定任何模式,那么该方法的结果始终是true。然而,如果有项目在List中,我们就在List上使用anyMatch()方法,传递一个检查Path是否与PathMatcher实例匹配的 lambda 表达式。

添加文件非常简单:

    private void addFile(Path file) throws IOException { 
      FileInfo info = new FileInfo(); 
      info.setFileName(file.getFileName().toString()); 
      info.setPath(file.toRealPath().toString()); 
      info.setSize(file.toFile().length()); 
      em.persist(info); 
    } 

我们创建一个FileInfo实例,设置属性,然后通过em.persist()将其持久化到数据库。

我们的任务定义并提交给ExecutorService后,我们需要坐下来等待。我们通过以下两个方法调用来实现这一点:

    es.shutdown(); 
    es.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS); 

第一步是要求ExecutorService关闭。shutdown()方法将立即返回,但它将指示ExecutorService拒绝任何新任务,并在它们空闲时关闭其线程。如果没有这一步,线程将继续无限期地运行。接下来,我们将等待服务关闭。我们指定最大等待时间以确保我们给任务足够的时间完成。一旦这个方法返回,我们就准备好处理结果,这将在下面的postProcessFiles()方法中完成:

    private void postProcessFiles() { 
      EntityManager em = factory.createEntityManager(); 
      List<FileInfo> files = getDuplicates(em, "fileName"); 

使用 JPA 的现代数据库访问

让我们在这里暂停一下。还记得我们关于Java 持久化 APIJPA)和数据库的讨论吗?这里就是我们看到它的地方。使用 JPA,与数据库的交互是通过EntityManager接口完成的,我们从巧妙命名的EntityManagerFactory中检索它。需要注意的是,EntityManager实例不是线程安全的,因此它们不应该在线程之间共享。这就是为什么我们没有在构造函数中创建一个并传递它的原因。这当然是一个局部变量,所以我们不需要太担心,直到我们决定将其作为参数传递给另一个方法,这正是我们现在正在做的。正如我们一会儿将看到的,所有事情都在同一个线程中发生,所以我们现在不需要担心线程安全问题。

使用我们的EntityManager,我们调用getDuplicates()方法,并传递管理器和字段名,fileName。这个方法看起来是这样的:

    private List<FileInfo> getDuplicates(EntityManager em,  
     String fieldName) { 
       List<FileInfo> files = em.createQuery( 
         DUPLICATE_SQL.replace("%FIELD%", fieldName), 
          FileInfo.class).getResultList(); 
       return files; 
    } 

这是对 Java 持久化 API 的一个相当直接的使用——我们创建一个查询,并告诉它我们想要什么,然后返回一个FileInfo引用的ListcreateQuery()方法创建一个TypedQuery对象,我们将调用getResultList()来检索结果,这给我们List<FileInfo>

在我们继续之前,我们需要对 Java 持久化 API 有一个简短的介绍。JPA 被称为对象关系映射ORM)工具。它提供了一种面向对象、类型安全和数据库无关的方式来存储数据,通常是在关系型数据库中。该规范/库允许应用程序作者使用具体的 Java 类来定义他们的数据模型,然后通过 JPA 接口持久化和/或读取它们,而无需过多考虑当前使用的数据库的具体机制。(开发者并不完全免于数据库的担忧——这一点是有争议的——但通过 JPA 接口抽象,这些担忧大大减少)。获取连接、创建 SQL 语句、将其发送到服务器、处理结果等过程都由库来处理,使得开发者可以更多地关注应用程序的业务,而不是底层架构。它还允许数据库之间有高度的便携性,因此应用程序(或库)可以轻松地从一种系统移动到另一种系统,只需进行最小更改(通常限于配置更改)。

JPA 的核心是Entity,它是业务对象(如果你更喜欢,可以称为领域模型)的数据模型。在 Java 代码中,这表现为一个普通的 Java 对象POJO),并用各种注解进行标记。对所有这些注解(或整个 API)的完整讨论超出了本书的范围,但我们将使用足够多的它们来帮助你入门。

在给出这个基本解释之后,让我们来看看我们唯一的实体——FileInfo类:

    @Entity 
    public class FileInfo implements Serializable { 
      @GeneratedValue 
      @Id 
      private int id; 
      private String fileName; 
      private String path; 
      private long size; 
      private String hash; 
    } 

这个类有五个属性。唯一需要特别注意的属性是id。这个属性包含每行的主键值,所以我们用@Id来标注它。我们还用@GeneratedValue来标注这个字段,表示我们有一个简单的主键,希望系统生成值。这个注解有两个属性:strategygenerator。策略的默认值是GenerationType.AUTO,我们在这里愉快地接受它。其他选项包括IDENTITYSEQUENCETABLE。在更复杂的使用中,你可能需要明确指定一个策略,这允许你微调键的生成方式(例如,起始数字、分配大小、序列或表名等)。通过选择AUTO,我们告诉 JPA 为我们的目标数据库选择合适的生成策略。如果你指定了除AUTO之外的其他策略,你还需要使用@SequenceGeneratorSEQUENCE@TableGeneratorTABLE指定生成器的详细信息。你还需要使用生成器属性将生成器的 ID 提供给@GeneratedValue注解。我们使用默认值,因此不需要指定此属性的值。

接下来的四个字段是我们已经识别出需要捕获的数据项。请注意,如果我们不需要对这些字段映射到数据库列的任何特殊指定,则不需要进行注释。然而,如果我们想更改默认值,我们可以应用@Column注释并设置适当的属性,这些属性可以是columnDefinition(用于帮助生成列的 DDL)、insertablelengthnamenullableprecisionscaletableuniqueupdatable中的一个或多个。再次强调,我们对默认值感到满意。

JPA 还要求每个属性都有一个 getter 和 setter;规范似乎措辞有些奇怪,这导致了对是否这是一个硬性要求存在一些歧义,不同的 JPA 实现处理方式不同,但出于实践考虑,提供这两个确实是更安全的。如果你需要一个只读属性,你可以尝试不设置 setter,或者简单地设置一个无操作方法。我们没有在这里展示 getter 和 setter,因为它们没有什么有趣的地方。我们还省略了 IDE 生成的equals()hashCode()方法。

为了帮助展示模块系统,我们将我们的实体放在了com.steeplesoft.dupefind.lib.model子包中。我们将提前透露一点,这个类将由我们的 CLI 和 GUI 模块共同使用,因此我们需要更新我们的模块定义如下:

    module dupefind.lib { 
      exports com.steeplesoft.dupefind.lib; 
      exports com.steeplesoft.dupefind.lib.model; 
      requires java.logging; 
      requires javax.persistence; 
    } 

这就是我们的实体所需要的一切,所以让我们将注意力转回到我们的应用程序逻辑上。createQuery()调用值得讨论。通常,当使用 JPA 时,查询是用所谓的JPAQLJava Persistence API Query Language)编写的。它看起来非常像 SQL,但它有更多的面向对象的感觉。例如,如果我们想查询数据库中每个FileInfo记录,我们会用这个查询来做:

 SELECT f FROM FileInfo f 

我将关键字全部大写,变量名小写,实体名驼峰式。这主要是一个风格问题,但尽管大多数标识符不区分大小写,JPA 确实要求实体名称的大小写与它所代表的 Java 类的大小写相匹配。你还必须指定一个别名,或识别变量,对于实体,我们简单地称之为f

要获取特定的FileInfo记录,你可以指定一个如下所示的WHERE子句:

 SELECT f from FileInfo f WHERE f.fileName = :name 

使用这个查询,我们可以像 SQL 一样过滤查询,并且,就像 SQL 一样,我们指定一个位置参数。参数可以是名称,就像我们在这里所做的那样,或者简单地是一个?。如果你使用名称,你将使用该名称在查询上设置参数值。如果你使用问号,你必须使用其在查询中的索引来设置参数。对于小型查询,这通常没问题,但对于更大、更复杂的查询,我建议使用名称,这样你就不必管理索引值,因为这几乎肯定会引起某个时刻的 bug。设置参数可能看起来像这样:

 Query query = em.createQuery( 
      "SELECT f from FileInfo f WHERE f.fileName = :name"); 
    query.setParameter("name", "test3.txt"); 
    query.getResultList().stream() //... 

话虽如此,让我们来看看我们的查询:

 SELECT f  
    FROM FileInfo f,  
      (SELECT s.%FIELD%  
        FROM FileInfo s  
        GROUP BY s.%FIELD%  
        HAVING (COUNT(s.%FIELD%) > 1)) g 
    WHERE f.%FIELD% = g.%FIELD%  
    AND f.%FIELD% IS NOT NULL  
    ORDER BY f.fileName, f.path 

这个查询相当复杂,所以让我们分解一下,看看发生了什么。首先,在我们的SELECT查询中,我们将只指定f,这是我们正在查询的实体的标识变量。接下来,我们从常规表和临时表中选择,这是由FROM子句中的子查询定义的。我们为什么要这样做呢?我们需要识别所有具有重复值(fileNamesizehash)的行。为了做到这一点,我们使用了一个带有COUNT聚合函数的HAVING子句,HAVING (COUNT(fieldName) > 1)实际上意味着给我所有这个字段出现超过一次的行。HAVING子句需要一个GROUP BY子句,一旦完成,所有具有重复值的行都会聚合到一行。一旦我们有了这些行的列表,我们就会将真实(或物理)表与这些结果连接起来,以过滤我们的物理表。最后,我们在WHERE子句中过滤掉空字段,然后按fileNamepath排序,这样我们就不需要在 Java 代码中这样做,这可能会比由数据库完成要低效——数据库是为这类操作设计的系统。

你还应该注意 SQL 中的%FIELD%属性。我们将为多个字段运行相同的查询,因此我们已经编写了一次查询,并在文本中放置了一个标记,我们将用所需的字段替换它,这有点像一种穷人的模板。当然,有各种各样的方法可以做到这一点(你可能有一个你认为更好的方法),但这种方法简单易用,所以在这个环境中完全可接受。

我们还应该注意的是,一般来说,将 SQL 与值连接或像我们这样做字符串替换是一个非常糟糕的想法,但我们的场景略有不同。如果我们接受用户输入并以这种方式将其插入 SQL 中,那么我们肯定会成为 SQL 注入攻击的目标。然而,在我们的使用中,我们并没有从用户那里获取输入,所以这种方法应该是完全安全的。在数据库性能方面,这也不应该有任何不利影响。虽然我们需要进行三次不同的硬解析(每个字段一次),但这与我们硬编码源文件中的查询没有区别。这两个问题以及许多其他问题,在你编写查询时总是值得考虑的(这就是为什么我说开发者主要被屏蔽在数据库问题之外)。

所有这些步骤都帮助我们完成了第一步,即识别所有具有相同名称的文件。我们现在需要识别具有相同大小的文件,这可以通过以下代码片段来完成:

    List<FileInfo> files = getDuplicates(em, "fileName"); 
    files.addAll(getDuplicates(em, "size")); 

在我们调用查找重复文件名时,我们声明了一个局部变量files来存储这些结果。在查找具有重复大小的文件时,我们调用相同的getDuplicates()方法,但使用正确的字段名,并通过List.addAll()方法将其添加到files中。

现在我们有一个所有可能重复项的完整列表,因此我们需要为这些中的每一个生成哈希,以查看它们是否真的是重复的。我们将使用这个循环来完成:

    em.getTransaction().begin(); 
    files.forEach(f -> calculateHash(f)); 
    em.getTransaction().commit(); 

简而言之,我们开始一个事务(因为我们将在数据库中插入数据),然后通过List.forEach()和调用calculateHash(f)的 lambda 表达式遍历每个可能的重复项,并传递FileInfo实例。一旦循环结束,我们就提交事务以保存我们的更改。

calculateHash()函数做什么?让我们看看:

    private void calculateHash(FileInfo file) { 
      try { 
        MessageDigest messageDigest =  
          MessageDigest.getInstance("SHA3-256"); 
        messageDigest.update(Files.readAllBytes( 
          Paths.get(file.getPath()))); 
        ByteArrayInputStream inputStream =  
          new ByteArrayInputStream(messageDigest.digest()); 
        String hash = IntStream.generate(inputStream::read) 
         .limit(inputStream.available()) 
         .mapToObj(i -> Integer.toHexString(i)) 
         .map(s -> ("00" + s).substring(s.length())) 
         .collect(Collectors.joining()); 
        file.setHash(hash); 
      } catch (NoSuchAlgorithmException | IOException ex) { 
        throw new RuntimeException(ex); 
      } 
    }  

这个简单的方法封装了读取文件内容并生成哈希所需的工作。它使用SHA3-256哈希请求MessageDigest实例,这是 Java 9 支持的四种新哈希之一(其他三种是SHA3-224SHA3-384SHA3-512)。许多开发者的第一反应是去抓取 MD-5 或 SHA-1,但它们现在不再被认为是可靠的。使用新的 SHA-3 应该可以保证我们避免任何假阳性。

该方法的其他部分在如何执行其工作方面非常有趣。首先,它读取指定文件的所有字节,并将它们传递给MessageDigest.update(),这将更新MessageDigest对象的内部状态,以给我们想要的哈希。接下来,我们创建一个ByteArrayInputStream,它包装了messageDigest.digest()的结果。

在我们的哈希准备好后,我们将基于这些字节生成一个字符串。我们将通过使用我们刚刚创建的InputStream作为源的IntStream.generate()方法来生成一个流。我们将流生成限制在inputStream中可用的字节。对于每个字节,我们将通过Integer.toHexString()将其转换为字符串;然后使用零到两个空格进行填充,这防止了例如单个十六进制字符EF被解释为EF;然后使用Collections.joining()将它们全部收集到一个字符串中。最后,我们更新FileInfo对象。

灵敏的读者可能会注意到一些有趣的地方:我们调用FileInfo.setHash()来改变对象的价值,但我们从未告诉系统持久化这些更改。这是因为我们的FileInfo实例是一个受管理的实例,意味着我们是从 JPA 获取的,JPA 在某种程度上正在关注它。由于我们是通过 JPA 检索的,当我们对其状态进行任何更改时,JPA 知道它需要持久化这些更改。当我们调用调用方法中的em.getTransaction().commit()时,JPA 会自动将这些更改保存到数据库中。

这种自动持久化的一个问题是:如果你通过 JPA 获取一个对象,然后通过某种序列化对象的方式(例如,通过远程 EJB 接口)传递它,那么 JPA 实体就被说成是“分离的”。为了将其重新附加到持久化上下文中,你需要调用 entityManager.merge(),之后这种行为将恢复。除非你需要将持久化上下文的内存状态与底层数据库同步,否则不需要调用 entityManager.flush()

一旦我们计算了潜在重复项的哈希值(在这个阶段,鉴于它们有重复的 SHA-3 哈希,它们几乎肯定是实际重复项),我们就准备好收集和报告它们:

    getDuplicates(em, "hash").forEach(f -> coalesceDuplicates(f)); 
    em.close(); 

我们调用相同的 getDuplicates() 方法来查找重复的哈希值,并将每个记录传递给 coalesceDuplicates() 方法,该方法将以适当的方式将这些分组,以便报告给我们的 CLI 或 GUI 层,或者,也许,给任何其他消费此功能的程序:

    private void coalesceDuplicates(FileInfo f) { 
      String name = f.getFileName(); 
      List<FileInfo> dupes = duplicates.get(name); 
      if (dupes == null) { 
        dupes = new ArrayList<>(); 
        duplicates.put(name, dupes); 
      } 
      dupes.add(f); 
    } 

这个简单的方法遵循了一个可能非常熟悉的模式:

  1. 根据 key,即文件名,从 Map 中获取 List

  2. 如果映射不存在,则创建它并将其添加到映射中。

  3. FileInfo 对象添加到列表中。

这完成了重复文件检测。回到 find(),我们将调用 factory.close() 以成为一个好的 JPA 公民,然后返回到调用代码。有了这个,我们就准备好构建我们的 CLI。

构建命令行界面

与我们新库交互的主要方式将是我们现在将要开发的命令行界面。不幸的是,Java SDK 内置的功能无法帮助我们创建复杂的命令行工具。如果你使用过 Java 任何时间,你一定见过以下的方法签名:

    public static void main(String[] args) 

显然,存在一种处理命令行参数的机制。public static void main 方法接收代表用户在命令行提供的字符串数组的参数,但这只是开始。为了解析选项,开发者需要遍历数组,分析每个条目。它可能看起来像这样:

    int i = 0; 
    while (i < args.length) { 
      if ("--source".equals(args[i])) { 
         System.out.println("--source = " + args[++i]); 
      } else if ("--target".equals(args[i])) { 
         System.out.println("--target = " + args[++i]); 
      } else if ("--force".equals(args[i])) { 
        System.out.println("--force set to true"); 
      } 
      i++; 
    } 

这是一个有效的解决方案,尽管非常简单且容易出错。它假设跟随 --source--target 的任何内容都是该参数的值。如果用户输入 --source --target /foo,那么我们的处理器就会崩溃。显然,我们需要更好的解决方案。幸运的是,我们有选择。

如果你搜索 Java 命令行库,你会找到很多(至少最后统计有 10 个)。我们的空间(和时间)是有限的,所以我们显然不能讨论所有这些库,所以我将提到我熟悉的第一个三个:Apache Commons CLI、Airline 和 Crest。它们中的每一个与竞争对手都有一些相当显著的不同。

Commons CLI 采用更程序化的方法;可用的选项列表、其名称、描述、是否有参数等等,都是通过 Java 方法调用来定义的。一旦创建了Options列表,然后手动解析命令行参数。前面的例子可以重写如下:

    public static void main(String[] args) throws ParseException { 
      Options options = new Options(); 
      options.addOption("s", "source", true, "The source"); 
      options.addOption("t", "target", true, "The target"); 
      options.addOption("f", "force", false, "Force"); 
      CommandLineParser parser = new DefaultParser(); 
      CommandLine cmd = parser.parse(options, args); 
      if (cmd.hasOption("source")) { 
        System.out.println("--source = " +  
          cmd.getOptionValue("source")); 
      } 
      if (cmd.hasOption("target")) { 
        System.out.println("--target = " +  
          cmd.getOptionValue("target")); 
      } 
      if (cmd.hasOption("force")) { 
         System.out.println("--force set to true"); 
      } 
    } 

它确实更加冗长,但我认为它也更清晰,更健壮。我们可以指定选项的长名和短名(例如--source-s),我们可以为其提供描述,而且最好的是,我们得到了内置的验证,确保选项有它所需的价值。尽管这是一个很大的改进,但根据经验,我了解到这里的程序方法在实践中会变得繁琐。让我们看看下一个候选人,看看它的表现如何。

Airline 是一个命令行库,最初是 GitHub 上 airlift 组织的一部分。经过一段时间的不活跃后,它被 Rob Vesse 分叉并赋予了新的生命(rvesse.github.io/airline)。Airline 对命令行定义的方法更基于类——要定义一个命令工具,你需要声明一个新的类,并使用多个注解来适当标记。让我们用 Airline 实现前面的简单命令行:

    @Command(name = "copy", description = "Copy a file") 
    public class CopyCommand { 
      @Option(name = {"-s", "--source"}, description = "The source") 
      private String source; 
      @Option(name = {"-t", "--target"}, description = "The target") 
      private String target; 
      @Option(name = {"-f", "--force"}, description = "Force") 
      private boolean force = false; 
      public static void main(String[] args) { 
        SingleCommand<CopyCommand> parser =  
          SingleCommand.singleCommand(CopyCommand.class); 
        CopyCommand cmd = parser.parse(args); 
        cmd.run(); 
      } 

      private void run() { 
        System.out.println("--source = " + source); 
        System.out.println("--target = " + target); 
        if (force) { 
          System.out.println("--force set to true"); 
        } 
      } 
    } 

选项处理在代码量上持续增长,但我们也越来越清晰地了解到支持哪些选项,以及它们各自的意义。我们的命令通过类声明中的@Command明确定义。可能的选项以@Option注解的实例变量来界定,而run()方法中的业务逻辑完全不含命令行解析代码。当这个方法被调用时,所有数据已经被提取,我们准备开始工作。这看起来非常不错,但让我们看看最后一个竞争者能提供什么。

Crest 是 Tomitribe 公司的一个库,该公司是 TomEE 背后的公司,TomEE 是基于备受尊敬的 Tomcat Servlet 容器的“所有 Apache Java EE Web Profile 认证堆栈”。Crest 对命令定义的方法是基于方法的,其中每个命令定义一个方法。它也使用注解,并提供开箱即用的 Bean Validation 以及可选的命令发现。因此,重新实现我们的简单命令可能看起来像这样:

    public class Commands { 
      @Command 
      public void copy(@Option("source") String source, 
        @Option("target") String target, 
        @Option("force") @Default("false") boolean force) { 
          System.out.println("--source = " + source); 
          System.out.println("--target = " + target); 
          if (force) { 
            System.out.println("--force set to true"); 
          } 
       } 
    } 

这似乎是两者的最佳结合:既简洁又清晰,并且仍然会保持命令的实际逻辑不受任何 CLI 解析问题的干扰,除非你介意方法上的注解。尽管实际实现逻辑的代码不受此类问题的干扰。虽然 Airline 和 Crest 都提供了对方没有的东西,但 Crest 对我来说是胜出的,所以我们将使用它来实现我们的命令行界面。

选择了一个库之后,让我们来看看我们的 CLI 可能的样子。最重要的是,我们需要能够指定我们想要搜索的路径(或路径)。很可能会发现这些路径中的大多数文件具有相同的扩展名,但这肯定不会总是如此,因此我们希望允许用户仅指定要匹配的文件模式(例如,.jpg)。一些用户可能也对运行扫描所需的时间感兴趣,所以让我们添加一个开关来开启该输出。最后,让我们添加一个开关来使过程更加详细。

在设置好功能需求后,让我们开始编写我们的命令。Crest 在命令声明中是基于方法的,但我们需要一个类来放置我们的方法。如果这个 CLI 更复杂(或者,例如,如果你正在为应用程序服务器编写 CLI),你可以很容易地将多个 CLI 命令放在同一个类中,或者将类似的命令分组在几个不同的类中。它们的结构完全取决于你,因为 Crest 对你的选择都很满意。

我们将从 CLI 接口声明开始,如下所示:

    public class DupeFinderCommands { 
      @Command 
      public void findDupes( 
        @Option("pattern") List<String> patterns, 
        @Option("path") List<String> paths, 
        @Option("verbose") @Default("false") boolean verbose, 
        @Option("show-timings")  
        @Default("false") boolean showTimings) { 

在我们讨论前面的代码之前,我们需要声明我们的 Java 模块:

    module dupefind.cli { 
      requires tomitribe.crest; 
      requires tomitribe.crest.api; 
    } 

我们定义了一个新的模块,其名称与我们的库模块名称相似。我们还声明了我们 require 两个 Crest 模块。

回到我们的源代码,我们有我们在功能需求中讨论的四个参数。请注意,patternspaths 被定义为 List<String>。当 Crest 解析命令行时,如果它发现这些中的一个有多个实例(例如,--path=/path/one--path=/path/two),它将收集所有这些值并将它们存储为一个 List。此外,请注意,verboseshowTimings 被定义为 boolean,因此我们看到了一个很好的例子,说明 Crest 将代表我们执行类型强制转换。我们为这两个都设置了默认值,因此当我们的方法执行时,我们确保有合理、可预测的值。

该方法的业务逻辑相当简单。我们将首先处理详细模式标志,按照以下方式打印出请求的操作摘要:

    if (verbose) { 
      System.out.println("Scanning for duplicate files."); 
      System.out.println("Search paths:"); 
      paths.forEach(p -> System.out.println("t" + p)); 
      System.out.println("Search patterns:"); 
      patterns.forEach(p -> System.out.println("t" + p)); 
      System.out.println(); 
    } 

然后,我们将执行实际的工作。多亏了我们构建库的工作,所有重复搜索的逻辑都隐藏在我们的 API 后面:

    final Instant startTime = Instant.now(); 
    FileFinder ff = new FileFinder(); 
    patterns.forEach(p -> ff.addPattern(p)); 
    paths.forEach(p -> ff.addPath(p)); 

    ff.find(); 

    System.out.println("The following duplicates have been found:"); 
    final AtomicInteger group = new AtomicInteger(1); 
    ff.getDuplicates().forEach((name, list) -> { 
      System.out.printf("Group #%d:%n", group.getAndIncrement()); 
      list.forEach(fileInfo -> System.out.println("t"  
        + fileInfo.getPath())); 
    }); 
    final Instant endTime = Instant.now(); 

这段代码最初无法编译,因为我们没有告诉系统我们需要它。我们现在可以这样做:

    module dupefind.cli { 
      requires dupefind.lib; 
      requires tomitribe.crest; 
      requires tomitribe.crest.api; 
    } 

我们现在可以导入 FileFinder 类。首先,为了证明这些模块确实在执行它们应该做的事情,让我们尝试导入一些未导出的内容:FindFileTask。让我们创建一个简单的类:

    import com.steeplesoft.dupefind.lib.model.FileInfo; 
    import com.steeplesoft.dupefind.lib.util.FindFileTask; 
    public class VisibilityTest { 
      public static void main(String[] args) { 
        FileInfo fi; 
        FindFileTask fft; 
      } 
    } 

如果我们尝试编译这个,Maven/javac 将会大声抱怨,错误信息如下:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.6.1:compile (default-compile) on project cli: Compilation failure: Compilation failure:
[ERROR] /C:/Users/jason/src/steeplesoft/DupeFinder/cli/src/main/java/com/
steeplesoft/dupefind/cli/VisibilityTest.java:[9,54] 
com.steeplesoft.dupefind.lib.util.FindFileTask is not visible because 
package com.steeplesoft.dupefind.lib.util is not visible 
[ERROR] /C:/Users/jason/src/steeplesoft/DupeFinder/cli/src/main/java/com/
steeplesoft/dupefind/cli/VisibilityTest.java:[13,9] cannot find symbol 
[ERROR] symbol:   class FindFileTask 
[ERROR] location: class com.steeplesoft.dupefind.cli.VisibilityTest 

我们已经成功隐藏了我们的实用工具类,同时公开了我们的公共 API。这种做法可能需要一段时间才能普及,但它应该能够神奇地防止私有 API 作为伪公共 API 的结晶。

回到任务,我们创建了一个FileFinder类的实例,使用String.forEach将我们的pathspatterns传递给查找器,然后通过调用find()开始工作。这项工作本身是线程化的,但我们提供了一个同步 API,所以这里的调用将会阻塞,直到工作完成。一旦返回,我们就开始向屏幕打印详细信息。由于FindFiles.getDuplicates()返回Map<String, List<FileInfo>>,我们在Map上调用forEach()来遍历每个键,然后我们在List上调用forEach()来打印每个文件的信息。我们还使用了一个AtomicInteger作为索引,因为这个变量必须是 final 或实际上是 final 的,所以我们只使用一个finalAtomicInteger实例。对于更有经验的开发者来说,可能会想到BigInteger,但它是不变的,所以它在这里不是一个好的选择。

运行命令的输出将类似于以下内容:

The following duplicates have been found: 
Group #1: 
     C:somepathtestset1file5.txt 
     C:somepathtestset2file5.txt 
Group #2: 
     C:somepathtestset1file11.txt 
     C:somepathtestset1file11-1.txt 
     C:somepathtestset2file11.txt 

接下来,我们处理showTimings。虽然我在前面的代码中没有提到它,但现在我会提到,我们在处理前后获取一个Instant实例(来自java.time库的 Java 8 日期/时间库)。只有当showTimings为 true 时,我们才会实际对它们进行处理。执行此操作的代码如下所示:

    if (showTimings) { 
      Duration duration = Duration.between(startTime, endTime); 
      long hours = duration.toHours(); 
      long minutes = duration.minusHours(hours).toMinutes(); 
      long seconds = duration.minusHours(hours) 
         .minusMinutes(minutes).toMillis() / 1000; 
      System.out.println(String.format( 
        "%nThe scan took %d hours, %d minutes, and %d seconds.%n",  
         hours, minutes, seconds)); 
    } 

使用我们的两个Instant,我们得到一个Duration,然后开始计算小时、分钟和秒。希望这永远不会超过一个小时,但准备好总是好的。这就是 CLI 的所有代码了。Crest 为我们处理了命令行参数解析的重活,让我们有一个简单且干净的逻辑实现。

我们还需要添加最后一件事,那就是 CLI 的帮助信息。对于最终用户来说,能够找到如何使用我们的命令是非常有帮助的。幸运的是,Crest 内置了支持来提供这些信息。要添加帮助信息,我们需要在命令类相同的包中创建一个名为OptionDescriptions.properties的文件(记住,由于我们使用 Maven,这个文件应该在src/main/resource下),如下所示:

 path = Adds a path to be searched. Can be specified multiple times. 
    pattern = Adds a pattern to match against the file names (e.g.,
    "*.png").
    Can be specified multiple times. 
    show-timings= Show how long the scan took 
    verbose = Show summary of duplicate scan configuration 

这样做将会产生如下输出:

 $ java -jar cli-1.0-SNAPSHOT.jar help findDupes 
    Usage: findDupes [options] 
    Options: 
      --path=<String[]>    Adds a path to be searched. Can be
                            specified multiple times. 
      --pattern=<String[]> Adds a pattern to match against
                            the file names
                           (e.g., "*.png"). Can be specified
                             multiple times. 
      --show-timings       Show how long the scan took 
      --verbose            Show summary of duplicate scan configuration 

你可以尽可能详细,而不会让你的源代码变得难以阅读。

到此为止,我们的 CLI 功能已经完整。在我们继续之前,我们需要查看一些 CLI 的构建问题,并看看 Crest 如何适应。显然,我们需要告诉 Maven 在哪里可以找到我们的 Crest 依赖项,如下面的代码片段所示:

    <dependency> 
      <groupId>org.tomitribe</groupId> 
      <artifactId>tomitribe-crest</artifactId> 
      <version>${crest.version}</version> 
    </dependency> 

我们还需要告诉它在哪里可以找到我们的重复查找库,如下所示:

    <dependency> 
      <groupId>${project.groupId}</groupId> 
      <artifactId>lib</artifactId> 
      <version>${project.version}</version> 
    </dependency> 

注意groupIdversion:由于我们的 CLI 和库模块是同一个父多模块构建的一部分,我们将groupIdversion设置为父模块的,这样我们就可以从单个位置管理它,这使得更改组或升级版本变得简单得多。

更有趣的部分是我们 POM 的build部分。首先,让我们从maven-compiler-plugin开始。虽然我们针对的是 Java 9,但我们将要讨论的crest-maven-plugin似乎不喜欢为 Java 9 生成的类,因此我们指示编译插件生成 Java 1.8 字节码:

    <plugin> 
      <groupId>org.apache.maven.plugins</groupId> 
      <artifactId>maven-compiler-plugin</artifactId> 
      <configuration> 
         <source>1.8</source> 
         <target>1.8</target> 
      </configuration> 
    </plugin> 

接下来,我们需要设置crest-maven-plugin。为了将我们的命令类暴露给 Crest,我们有两种选择:我们可以使用运行时扫描类,或者我们可以在构建时让 Crest 扫描命令。为了使这个实用程序尽可能小,以及尽可能减少启动时间,我们将选择后者,因此我们需要在构建中添加另一个插件,如下所示:

    <plugin> 
      <groupId>org.tomitribe</groupId> 
      <artifactId>crest-maven-plugin</artifactId> 
      <version>${crest.version}</version> 
      <executions> 
         <execution> 
            <goals> 
              <goal>descriptor</goal> 
            </goals> 
         </execution> 
      </executions> 
    </plugin> 

当这个插件运行时,它将生成一个名为crest-commands.txt的文件,Crest 将在启动时处理该文件以查找类。这在这里可能节省不了多少时间,但确实是一些值得记住的大项目。

最后,我们不希望用户每次都要担心设置类路径(或模块路径!),因此我们将引入 Maven Shade 插件,该插件将创建一个包含所有依赖项的单个 fat jar,包括传递依赖项:

    <plugin> 
      <artifactId>maven-shade-plugin</artifactId> 
      <version>2.1</version> 
      <executions> 
         <execution> 
             <phase>package</phase> 
             <goals> 
                <goal>shade</goal> 
              </goals> 
              <configuration> 
                 <transformers> 
                   <transformer implementation= 
                     "org.apache.maven.plugins.shade.resource
                      .ManifestResourceTransformer"> 
                     <mainClass> 
                       org.tomitribe.crest.Main 
                     </mainClass> 
                   </transformer> 
                 </transformers> 
              </configuration> 
         </execution> 
      </executions> 
    </plugin> 

构建完成后,我们可以使用以下命令运行搜索:

 java -jar targetcli-1.0-SNAPSHOT.jar findDupes 
      --path=../test/set1 --path=../test/set2 -pattern=*.txt 

显然,它还可以进一步改进,因此我们可能希望将其与脚本包装器(shell、批处理等)一起分发,但 jar 的数量从大约 18 个减少到 1 个,这是一个很大的改进。

在我们的 CLI 完成之后,让我们创建一个简单的 GUI 来消费我们的库。

构建图形用户界面

对于我们的 GUI,我们希望暴露与命令行相同的功能类型,但显然,有一个漂亮的图形界面。为此,我们再次选择 JavaFX。我们将为用户提供一种方式,通过选择对话框选择要搜索的目录,以及一个字段来添加搜索模式。一旦识别出重复项,我们将它们显示在列表中供用户浏览。所有重复组都将列出,并且当点击时,该组中的文件将在另一个列表中显示。用户可以在列表上右键单击并选择查看文件(或文件)或删除它(或它们)。当我们完成时,应用程序将看起来像这样:

图片

首先,让我们创建我们的项目。在 NetBeans 中,转到文件 | 新项目,然后选择 Maven | JavaFX 应用程序。你可以取任何你喜欢的名字,但我们使用了Duplicate Finder - GUI这个名字,groupIdcom.steeplesoft.dupefindartifactIdgui

一旦你有了你的项目,你应该有两个类,MainFXMLController,以及fxml/Scene.fxml资源。这听起来可能有些重复,但在我们进一步之前,我们需要按照以下方式设置我们的 Java 模块:

    module dupefind.gui { 
      requires dupefind.lib; 
      requires java.logging; 
      requires javafx.controls; 
      requires javafx.fxml; 
      requires java.desktop; 
    } 

然后,为了创建我们看到的界面,我们将使用BorderPane,并在其top部分添加MenuBar,如下所示:

    <top> 
      <MenuBar BorderPane.alignment="CENTER"> 
        <menus> 
          <Menu mnemonicParsing="false"  
            onAction="#closeApplication" text="File"> 
            <items> 
              <MenuItem mnemonicParsing="false" text="Close" /> 
            </items> 
          </Menu> 
          <Menu mnemonicParsing="false" text="Help"> 
            <items> 
              <MenuItem mnemonicParsing="false"  
                onAction="#showAbout" text="About" /> 
            </items> 
          </Menu> 
        </menus> 
      </MenuBar> 
    </top> 

当你使用 Scene Builder 添加MenuBar时,它会自动为你添加几个示例Menu条目。我们已经删除了不需要的条目,并将剩余的与控制器类中的 Java 方法绑定。具体来说,Close菜单将调用closeApplication(),而About将调用showAbout()。这看起来就像书中之前看到的菜单标记,所以没有太多可说的。

剩余的布局稍微复杂一些。在left部分,我们有一些控件垂直堆叠。JavaFX 有一个内置的容器,使得这样做变得容易:VBox。我们稍后会看到它的内容,但它的用法看起来像这样:

    <VBox BorderPane.alignment="TOP_CENTER"> 
      <children> 
         <HBox... /> 
         <Separator ... /> 
         <Label .../> 
         <ListView ... /> 
         <HBox ... /> 
         <Label ... /> 
         <ListView... /> 
         <HBox ... /> 
      </children> 
      <padding> 
         <Insets bottom="10.0" left="10.0" right="10.0" 
           top="10.0" /> 
      </padding> 
    </VBox> 

这不是有效的 FXML,所以不要尝试复制和粘贴。为了清晰起见,我省略了子元素的细节。正如你所见,VBox有多个子元素,每个子元素都将垂直堆叠,但是,正如我们从前面的截图中所见,有一些我们希望水平对齐。为了实现这一点,我们在需要的地方嵌套了一个HBox实例。它的标记看起来就像VBox

在这个 FXML 的部分没有太多值得注意的内容,但有两点需要注意。我们希望用户界面的某些部分在窗口大小调整时能够收缩和扩展,即ListView。默认情况下,每个组件的各种高度和宽度属性(最小、最大和首选)将使用计算大小,这意味着,大致上,它们将变得足够大以渲染自身,在大多数情况下这是可以的。在我们的情况下,我们希望两个ListView实例尽可能在其各自的容器内扩展,在这种情况下,是之前讨论过的VBox。为了实现这一点,我们需要像这样修改我们的两个ListView实例:

    <ListView fx:id="searchPatternsListView" VBox.vgrow="ALWAYS" /> 
    ... 
    <ListView fx:id="sourceDirsListView" VBox.vgrow="ALWAYS" /> 

当两个ListView实例都设置为ALWAYS增长时,它们将相互竞争可用空间,并最终共享它。可用空间,当然,取决于VBox实例的高度以及其他容器中其他组件的计算高度。设置了该属性后,我们可以增加或减少窗口的大小,并观察两个ListView实例如何增长和收缩,而其他一切保持不变。

对于用户界面的其余部分,我们将应用相同的策略来排列组件,但这次,我们将从一个HBox实例开始,并根据需要将其分割。我们有两个ListView实例,我们希望它们也填充所有可用空间,所以我们以相同的方式标记这些实例。每个ListView实例还有一个Label,因此我们将每个Label/ListView对包裹在一个VBox实例中,以获得垂直分布。在伪 FXML 中,这看起来像这样:

    <HBox> 
      <children> 
         <Separator orientation="VERTICAL"/> 
         <VBox HBox.hgrow="ALWAYS"> 
           <children> 
             <VBox VBox.vgrow="ALWAYS"> 
                <children> 
                  <Label ... /> 
                  <ListView ... VBox.vgrow="ALWAYS" /> 
                </children> 
             </VBox> 
           </children> 
         </VBox> 
         <VBox HBox.hgrow="ALWAYS"> 
           <children> 
             <Label ... /> 
             <ListView ... VBox.vgrow="ALWAYS" /> 
           </children> 
         </VBox> 
      </children> 
    </HBox> 

在用户界面的这部分有一个值得注意的项目,那就是我们之前讨论过的上下文菜单。为了给一个控件添加上下文,你需要在目标控件的 FXML 中嵌套一个contextMenu元素,如下所示:

    <ListView fx:id="matchingFilesListView" VBox.vgrow="ALWAYS"> 
      <contextMenu> 
        <ContextMenu> 
          <items> 
            <MenuItem onAction="#openFiles" text="Open File(s)..." /> 
            <MenuItem onAction="#deleteSelectedFiles"  
              text="Delete File(s)..." /> 
           </items> 
         </ContextMenu> 
      </contextMenu> 
    </ListView> 

我们定义了一个包含两个MenuItem的内容菜单:"Open File(s)...""Deleted File(s)..."。我们还使用onAction属性指定了这两个MenuItem的动作。我们将在接下来的方法中查看这些。

这标志着我们用户界面定义的结束,因此现在我们将注意力转向 Java 代码,在那里我们将完成用户界面的准备工作,以及实现我们应用程序的逻辑。

虽然我们没有展示完成这个任务的 FXML,但我们的 FXML 文件与我们的控制器类FXMLController相关联。当然,这个类可以命名为任何东西,但我们选择使用 IDE 生成的名称。在一个更大的应用程序中,需要对这个类的命名给予更多的关注。为了允许将我们的用户界面组件注入到我们的代码中,我们需要在我们的类中声明实例变量,并用@FXML注解标记它们。以下是一些示例:

    @FXML 
    private ListView<String> dupeFileGroupListView; 
    @FXML 
    private ListView<FileInfo> matchingFilesListView; 
    @FXML 
    private Button addPattern; 
    @FXML 
    private Button removePattern; 

还有其他几个,但这应该足以展示这个概念。请注意,我们不是声明了一个普通的ListView,而是将我们的实例参数化为ListView<String>ListView<FileInfo>。我们知道这是我们放入控制器的,所以指定类型参数在编译时提供了类型安全,同时也允许我们在与它们交互时避免每次都需要进行类型转换。

接下来,我们需要设置将包含用户输入的搜索路径和模式的集合。我们将使用ObservableList实例来完成这项工作。记住,在使用ObservableList实例时,容器可以自动根据需要重新渲染自身,当Observable实例被更新时:

    final private ObservableList<String> paths =  
      FXCollections.observableArrayList(); 
    final private ObservableList<String> patterns =  
      FXCollections.observableArrayList(); 

initialize()方法中,我们可以开始将事物联系起来。以下代码片段可以作为示例:

    public void initialize(URL url, ResourceBundle rb) { 
      searchPatternsListView.setItems(patterns); 
      sourceDirsListView.setItems(paths); 

在这里,我们将我们的ListView实例与我们的ObservableList实例关联起来。现在,在任何这些列表被更新的时候,用户界面将立即反映这一变化。

接下来,我们需要配置重复文件组的ListView。从我们的图书馆返回的数据是一个Map,其键是一个List<FileInfo>对象,通过重复的哈希值进行索引。显然,我们不希望向用户展示哈希列表,所以,就像命令行界面(CLI)一样,我们希望用更友好的标签来表示每个文件组。为了做到这一点,我们需要创建一个CellFactory,它将创建一个负责渲染单元格的ListCell。我们将按照以下方式完成:

    dupeFileGroupListView.setCellFactory( 
      (ListView<String> p) -> new ListCell<String>() { 
        @Override 
        public void updateItem(String string, boolean empty) { 
          super.updateItem(string, empty); 
          final int index = p.getItems().indexOf(string); 
          if (index > -1) { 
            setText("Group #" + (index + 1)); 
          } else { 
            setText(null); 
          } 
       } 
    }); 

虽然 lambda 表达式可以非常好,因为它们往往可以使代码更加简洁,但它们也可能隐藏一些细节。在非 lambda 代码中,上面的 lambda 表达式可能看起来像这样:

    dupeFileGroupListView.setCellFactory(new  
      Callback<ListView<String>, ListCell<String>>() { 
        @Override 
        public ListCell<String> call(ListView<String> p) { 
          return new ListCell<String>() { 
            @Override 
            protected void updateItem(String t, boolean bln) { 
             super.updateItem(string, empty); 
              final int index = p.getItems().indexOf(string); 
              if (index > -1) { 
                setText("Group #" + (index + 1)); 
              } else { 
                setText(null); 
              } 
            } 
          }; 
        } 
    }); 

你当然会得到更多的细节,但阅读起来也更困难。在这里包含两者的主要目的是双重的:一方面是为了展示为什么 lambda 表达式通常如此之好,另一方面是为了展示实际涉及到的类型,这有助于 lambda 表达式更有意义。有了对 lambda 表达式的这种理解,这个方法在做什么呢?

首先,我们调用super.updateItem(),因为这仅仅是良好的实践。接下来,我们找到正在渲染的字符串的索引。API 为我们提供了字符串(因为它是一个ListView<String>),所以我们找到它在我们的ObservableList<String>中的索引。如果找到了,我们将单元格的文本设置为Group #加上索引加一(因为 Java 中的索引通常是零基的)。如果字符串没有找到(ListView正在渲染一个空单元格),我们将文本设置为 null 以确保字段为空。

接下来,我们需要在matchingFilesListView上执行类似的程序:

    matchingFilesListView.getSelectionModel() 
      .setSelectionMode(SelectionMode.MULTIPLE); 
    matchingFilesListView.setCellFactory( 
      (ListView<FileInfo> p) -> new ListCell<FileInfo>() { 
        @Override 
        protected void updateItem(FileInfo fileInfo, boolean bln) { 
          super.updateItem(fileInfo, bln); 
          if (fileInfo != null) { 
             setText(fileInfo.getPath()); 
          } else { 
             setText(null); 
          } 
        } 
    }); 

这几乎完全相同,但有一些例外。首先,我们将ListView的选择模式设置为MULTIPLE。这将允许用户控制点击感兴趣的项,或者按住 shift 键点击一系列行。接下来,我们以相同的方式设置CellFactory。请注意,由于ListView实例的参数化类型是FileInfo,因此ListCell.updateItem()方法签名中的类型不同。

我们还有一个最后的用户界面设置步骤。如果你回顾一下截图,你会注意到查找重复项按钮的宽度与ListView相同,而其他按钮的宽度仅足够渲染其内容。我们通过将Button元素的宽度绑定到其容器的宽度来实现这一点,这是一个HBox实例:

    findFiles.prefWidthProperty().bind(findBox.widthProperty()); 

我们正在获取首选宽度属性,它是一个DoubleProperty,并将其绑定到findBox的宽度属性(也是一个DoubleProperty)上,这是控制器的容器。DoubleProperty是一个Observable实例,就像ObservableListView一样,所以我们告诉findFiles控制器观察其容器的宽度属性,并在其他属性变化时相应地设置自己的值。这让我们以一种方式设置属性,然后就可以忘记它了。除非我们想要打破这两个属性之间的绑定,否则我们再也不必考虑它了,我们当然不需要手动监视一个属性来更新作者。框架为我们做了这件事。

那么,那些按钮呢?我们如何让它们做些什么?我们通过将Button元素的onAction属性设置为控制器中的方法来实现:#someMethod转换为Controller.someMethod(ActionEvent event)。我们可以以至少两种方式处理这个问题:我们可以为每个按钮创建一个单独的处理程序方法,或者,就像我们在这里所做的那样,我们可以创建一个,然后根据需要委托给另一个方法;两种方式都可以:

    @FXML 
    private void handleButtonAction(ActionEvent event) { 
      if (event.getSource() instanceof Button) { 
        Button button = (Button) event.getSource(); 
        if (button.equals(addPattern)) { 
          addPattern(); 
        } else if (button.equals(removePattern)) { 
        // ... 

我们必须确保我们实际上得到了一个Button元素,然后我们将其转换为实例并比较注入的实例。每个按钮的实际处理程序如下:

    private void addPattern() { 
      TextInputDialog dialog = new TextInputDialog("*.*"); 
      dialog.setTitle("Add a pattern"); 
      dialog.setHeaderText(null); 
      dialog.setContentText("Enter the pattern you wish to add:"); 

      dialog.showAndWait() 
      .filter(n -> n != null && !n.trim().isEmpty()) 
      .ifPresent(name -> patterns.add(name)); 
    } 

要添加一个模式,我们创建一个带有适当文本的TextInputDialog实例,然后调用showAndWait()。在 JavaFX 8 中,这个方法的优点是它返回Optional<String>。如果用户在对话框中输入文本,并且如果用户点击了 OK,Optional将包含内容。我们通过调用ifPresent()来识别这一点,传递给它一个 lambda,该 lambda 将新模式添加到ObservableList<String>中,这会自动更新用户界面。如果用户没有点击 OK,Optional将是空的。如果用户没有输入任何文本(或者输入了一堆空格),filter()的调用将阻止 lambda 运行。

删除项与删除模式类似,尽管我们可以通过一个实用方法隐藏一些细节,因为我们有两个功能需求。我们确保有东西被选中,然后显示一个确认对话框,如果用户点击 OK,则从ObservableList<String>中删除模式:

    private void removePattern() { 
      if (searchPatternsListView.getSelectionModel() 
      .getSelectedIndex() > -1) { 
        showConfirmationDialog( 
          "Are you sure you want to remove this pattern?", 
          (() -> patterns.remove(searchPatternsListView 
          .getSelectionModel().getSelectedItem()))); 
      } 
    } 

让我们看看showConfirmationDialog方法:

    protected void showConfirmationDialog(String message, 
     Runnable action) { 
      Alert alert = new Alert(Alert.AlertType.CONFIRMATION); 
      alert.setTitle("Confirmation"); 
      alert.setHeaderText(null); 
      alert.setContentText(message); 
      alert.showAndWait() 
      .filter(b -> b == ButtonType.OK) 
      .ifPresent(b -> action.run()); 
    } 

再次,这与之前的对话框非常相似,应该很容易理解。这里有趣的部分是使用 lambda 作为方法参数,这使得它成为一个高阶函数——意味着它接受一个函数作为参数,返回一个函数作为其结果,或者两者都是。我们传递Runnable,因为我们想要一个不接受任何内容并返回任何内容的 lambda,而Runnable是一个FunctionalInterface,符合这个描述。在我们显示对话框并获取用户响应后,我们将只过滤出点击了按钮 OK 的响应,并且如果存在,我们将通过action.run()执行Runnable。我们必须指定b -> action.run(),因为ifPresent()接受一个Consumer<? super ButtonType>,所以我们创建一个并忽略传入的值,这样我们就可以保护我们的调用代码不受该细节的影响。

添加路径需要一个DirectoryChooser实例:

    private void addPath() { 
        DirectoryChooser dc = new DirectoryChooser(); 
        dc.setTitle("Add Search Path"); 
        dc.setInitialDirectory(new File(lastDir)); 
        File dir = dc.showDialog(null); 
        if (dir != null) { 
            try { 
                lastDir = dir.getParent(); 
                paths.add(dir.getCanonicalPath()); 
            } catch (IOException ex) { 
                Logger.getLogger(FXMLController.class.getName()).log(
                  Level.SEVERE, null, ex); 
            } 
        } 
    } 

在创建DirectoryChooser实例时,我们将初始目录设置为用户上次使用的目录,以便为用户提供便利。当应用程序启动时,这默认为用户的主目录,但一旦成功选择了一个目录,我们将lastDir设置为添加的目录的父目录,这样如果需要输入多个路径,用户就可以从上次停止的地方开始。DirectoryChooser.showDialog()返回一个文件,所以我们获取其规范路径并将其存储在 paths 中,这再次导致我们的用户界面自动更新。

删除路径看起来与删除模式非常相似,如以下代码片段所示:

    private void removePath() { 
      showConfirmationDialog( 
        "Are you sure you want to remove this path?", 
        (() -> paths.remove(sourceDirsListView.getSelectionModel() 
        .getSelectedItem()))); 
    } 

代码基本相同,只是 lambda 不同。难道 lambda 不是最酷的吗?

findFiles()按钮的处理程序与我们的 CLI 代码有点不同,但看起来很相似,如下所示:

    private void findFiles() { 
       FileFinder ff = new FileFinder(); 
       patterns.forEach(p -> ff.addPattern(p)); 
       paths.forEach(p -> ff.addPath(p)); 

       ff.find(); 
       dupes = ff.getDuplicates(); 
       ObservableList<String> groups =  
         FXCollections.observableArrayList(dupes.keySet()); 

       dupeFileGroupListView.setItems(groups); 
    } 

我们创建FileFinder实例,使用流和 lambda 表达式设置路径和模式,然后启动搜索过程。当它完成后,我们通过getDuplicates()获取重复文件信息列表,然后使用映射的键创建一个新的ObservableList<String>实例,并将其设置在dupeFileGroupListView上。

现在我们需要添加处理组列表鼠标点击的逻辑,因此我们将 FXML 文件中ListViewonMouseClicked属性设置为#dupeGroupClicked,如下面的代码块所示:

    @FXML 
    public void dupeGroupClicked(MouseEvent event) { 
      int index = dupeFileGroupListView.getSelectionModel() 
       .getSelectedIndex(); 
      if (index > -1) { 
        String hash = dupeFileGroupListView.getSelectionModel() 
        .getSelectedItem(); 
        matchingFilesListView.getItems().clear(); 
        matchingFilesListView.getItems().addAll(dupes.get(hash)); 
      } 
    } 

当控件被点击时,我们获取索引并确保它是非负的,以确保用户确实点击了某个东西。然后我们从ListView获取所选项目的哈希值。记住,虽然ListView可能显示类似Group #2的内容,但该行的实际内容是哈希值。我们只是使用自定义的CellFactory给它一个更漂亮的标签。有了哈希值,我们清除matchingFilesListView中的项目列表,然后获取控件的ObservableList并添加所有通过哈希值键入的FileInfo对象。再次,我们通过Observable的力量获得自动的用户界面更新。

我们还希望用户能够使用键盘导航重复组列表,以更新匹配文件列表。我们通过将我们的ListView上的onKeyPressed属性设置为指向这个相当简单的方法来实现:

    @FXML 
    public void keyPressed(KeyEvent event) { 
      dupeGroupClicked(null); 
    } 

恰好我们对这两个方法中的实际Event(它们实际上从未被使用)不太感兴趣,因此我们可以天真地委托给之前讨论的鼠标点击方法。

我们还需要实现两个更小的功能:查看匹配文件和删除匹配文件。

我们已经创建了上下文菜单和菜单项,所以我们只需要实现如下所示的处理方法:

    @FXML 
    public void openFiles(ActionEvent event) { 
      matchingFilesListView.getSelectionModel().getSelectedItems() 
      .forEach(f -> { 
        try { 
          Desktop.getDesktop().open(new File(f.getPath())); 
        } catch (IOException ex) { 
          // ... 
        } 
      }); 
    } 

匹配文件列表允许多选,因此我们需要从选择模型获取List<FileInfo>而不是我们之前看到的单个对象。然后我们调用forEach()来处理条目。我们希望以用户在操作系统中配置的任何应用程序打开文件。为此,我们使用 Java 6 中引入的 AWT 类:Desktop。我们通过getDesktop()获取实例,然后调用open(),传递指向我们的FileInfo目标的File

删除文件的过程类似:

    @FXML 
    public void deleteSelectedFiles(ActionEvent event) { 
      final ObservableList<FileInfo> selectedFiles =  
        matchingFilesListView.getSelectionModel() 
        .getSelectedItems(); 
      if (selectedFiles.size() > 0) { 
        showConfirmationDialog( 
          "Are you sure you want to delete the selected files", 
           () -> selectedFiles.forEach(f -> { 
            if (Desktop.getDesktop() 
            .moveToTrash(new File(f.getPath()))) {                         
              matchingFilesListView.getItems() 
              .remove(f); 
              dupes.get(dupeFileGroupListView 
               .getSelectionModel() 
               .getSelectedItem()).remove(f); 
            } 
        })); 
      } 
    } 

与打开文件类似,我们获取所有选定的文件。如果至少有一个文件,我们通过showConfirmationDialog()确认用户的意图,并传递一个处理删除的 lambda 表达式。我们再次使用Desktop类执行实际的文件删除操作,将文件移动到文件系统提供的垃圾桶中,为用户提供一个安全的删除选项。如果文件成功删除,我们从ObservableList中移除其条目,以及我们的缓存重复文件Map,这样在用户再次点击此文件组时就不会显示。

摘要

如此,我们的应用程序就完成了。那么,我们涵盖了哪些内容呢?从项目描述来看,这似乎是一个相当简单的应用程序,但随着我们开始分解需求并深入到实现中,我们最终覆盖了大量的领域——这种情况并不罕见。我们构建了一个多模块的 Maven 项目。我们介绍了 Java 并发,包括基本的Thread管理和ExecutorService的使用,以及 Java 持久化 API,展示了基本的@Entity定义、EntityManagerFactory/EntityManager的使用和 JPAQL 查询编写。我们还讨论了使用MessageDigest类创建文件哈希,并演示了新的文件 I/O API,包括目录树遍历 API。我们还使用嵌套容器、"链接"的ListView实例和绑定属性在 JavaFX 中构建了一个更复杂的用户界面。

对于这样一个“简单”的项目来说,内容已经相当丰富了。我们的下一个项目也将相对简单,因为我们将构建一个命令行日期计算器,这将使我们能够探索java.time包,并了解这个新的日期/时间 API 提供了哪些功能。

第二十二章:日期计算器

如果你认真开发 Java 有一段时间了,你知道一件事是真实的——处理日期是糟糕的。java.util.Date类及其相关类在 1.0 版本中发布,Calendar及其相关类在 1.1 版本中推出。即使在早期,问题就很明显。例如,Date的 Javadoc 说:“不幸的是,这些函数的 API 不适合国际化。”因此,Calendar在 1.1 版本中被引入。当然,多年来已经进行了其他增强,但鉴于 Java 对向后兼容性的严格遵循,语言架构师能做的事情是有限的。尽管他们可能想修复这些 API,但他们的手被束缚了。

幸运的是,Java 规范请求JSR 310)已被提交。由 Stephen Colebourne 领导,开始了一个创建新 API 的努力,该 API 基于非常流行的开源库 Joda-Time。在本章中,我们将深入探讨这个新 API,然后构建一个简单的命令行工具来执行日期和时间计算,这将给我们一个机会看到一些 API 的实际应用。

因此,本章将涵盖以下主题:

  • Java 8 日期/时间 API

  • 重新审视命令行工具

  • 文本解析

开始

与第十八章中的项目类似,管理 Java 中的进程,这个项目在概念上相当简单。最终目标是创建一个命令行工具,用于执行各种日期和时间计算。然而,既然我们在做这件事,将实际的日期/时间工作放入一个可重用的库中将会非常棒,所以这就是我们将要做的。这让我们剩下两个项目,我们将像上次一样,将其设置为一个多模块 Maven 项目。

父 POM 将看起来像这样:

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

      <artifactId>datecalc-master</artifactId> 
      <version>1.0-SNAPSHOT</version> 
      <packaging>pom</packaging> 
      <modules> 
        <module>datecalc-lib</module> 
        <module>datecalc-cli</module> 
      </modules> 
    </project> 

如果你阅读过第十八章,管理 Java 中的进程,或者之前使用过多模块 Maven 构建,这里没有什么新的内容。它只是为了完整性而包含的。如果你对此感到陌生,请在继续之前花点时间回顾第十八章的前几页。

构建库

由于我们希望能够在其他项目中重用这个工具,我们将首先构建一个暴露其功能的库。我们需要的所有功能都内置在平台中,所以我们的 POM 文件非常简单:

    <?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> 
      <parent> 
        <groupId>com.steeplesoft</groupId> 
          <artifactId>datecalc-master</artifactId> 
          <version>1.0-SNAPSHOT</version> 
      </parent> 
      <artifactId>datecalc-lib</artifactId> 
      <packaging>jar</packaging> 
      <dependencies> 
        <dependency> 
          <groupId>org.testng</groupId> 
          <artifactId>testng</artifactId> 
          <version>6.9.9</version> 
          <scope>test</scope> 
        </dependency> 
      </dependencies> 
    </project> 

几乎没有外部依赖。列出的唯一依赖是测试库 TestNG。在上一个章节中我们没有过多地讨论测试(请放心,项目中确实有测试)。在本章中,我们将介绍测试的话题,并展示一些示例。

现在我们需要定义我们的模块。记住,这些是 Java 9 项目,所以我们要利用模块功能来帮助保护我们的内部类免受意外公开暴露。我们的模块非常简单。我们需要给它一个名字,然后导出我们的公共 API 包,如下所示:

    module datecalc.lib { 
      exports com.steeplesoft.datecalc; 
    } 

由于我们所需的一切都已经包含在 JDK 中,所以我们不需要声明除了我们导出之外的内容。

在我们的项目设置完成后,让我们快速看一下功能需求。我们这个项目的意图是构建一个系统,允许用户提供一个表示日期或时间计算表达式的任意字符串,并得到一个响应。这个字符串可能看起来像"today + 2 weeks"来找出今天之后的 2 周日期,"now + 3 hours 15 minutes"来找出 3 小时 15 分钟后的时间,或者"2016/07/04 - 1776/07/04"来找出两个日期之间有多少年、月和日。这些表达式的处理将逐行进行,因此明确排除了例如传递包含多个表达式的文本文档并得到多个结果的能力。当然,任何消费应用程序或库都可以轻松实现这一点。

因此,现在我们的项目已经设置好并准备就绪,我们也对其相对简单的功能需求有一个大致的草图。我们准备开始编码。在我们这样做之前,让我们快速浏览一下新的java.time包,以便更好地了解在这个项目中我们将看到什么,以及在这个简单项目中我们将不会使用的一些功能。

一个及时的小憩

在 Java 8 之前,主要的两个日期相关类是DateCalendar(当然,还有GregorianCalendar)。新的java.time包提供了几个新的类,例如DurationPeriodClockInstantLocalDateLocalTimeLocalDateTimeZonedDateTime。有许多支持类,但这些都是主要的起点。让我们快速看一下每个类。

持续时间

Duration是一个基于时间的单位。虽然这样表述可能听起来有些奇怪,但这样的措辞是为了将其与基于日期的时间单位区分开来,我们将在下一节中讨论。用简单的话说,它是一种时间的度量,例如10 秒1 小时100 纳秒Duration是以秒为单位的,但有一些方法可以将持续时间表示为其他度量单位,如下所示:

  • getNano(): 这是基于纳秒的Duration

  • getSeconds(): 这是基于秒的Duration

  • get(TemporalUnit): 这是基于指定度量单位的Duration

此外,还有一些不同的算术方法,如下所述:

  • add/minus (int amount, TemporalUnit unit)

  • add/minus (Duration)

  • addDays/minusDays(long)

  • addHours/minusHours(long)

  • addMillis/minusMillis(long)

  • addMinutes/minusMinutes(long)

  • addNanos/minusNanos(long)

  • addSeconds/minusSeconds(long)

  • dividedBy/multipliedBy

我们还有许多方便的工厂和提取方法,如下所示:

  • ofDays(long)/toDays()

  • ofHours(long)/toHours()

  • ofMinutes(long)/toMinutes()

  • ofSeconds(long)/toSeconds()

还提供了一个parse()方法。不幸的是,也许对某些人来说,这个方法输入可能不是你预期的。因为我们处理的是通常以小时和分钟为单位的持续时间,你可能会期望这个方法接受类似于"1:37"的输入来表示 1 小时 37 分钟。然而,这将导致系统抛出DateTimeParseException。该方法期望接收的是符合 ISO-8601 格式的字符串,看起来像这样--PnDTnHnMn.nS。这相当神奇,不是吗?虽然一开始可能会感到困惑,但一旦理解了它,就不会太糟糕:

  • 第一个字符是一个可选的+(加号)或-(减号)符号。

  • 下一个字符是P,可以是大写或小写。

  • 接下来至少有一个四个部分,表示天数(D)、小时(H)、分钟(M)和秒(S)。同样,大小写无关紧要。

  • 它们必须按照这个顺序声明。

  • 每个部分都有一个数字部分,包括可选的+-符号,一个或多个 ASCII 数字,以及度量单位指示符。秒数可以是分数(以浮点数表示)并且可以使用点或逗号。

  • 字母T必须出现在小时、分钟或秒的第一个实例之前。

简单,对吧?它可能对非技术受众不太友好,但它支持将持续时间编码为字符串,允许无歧义的解析,这是一个巨大的进步。

期间

Period是一个基于日期的时间单位。而Duration是关于时间(小时、分钟、秒等),Period是关于年、周、月等。像Duration一样,它公开了几个算术方法来添加和减去,尽管这些处理的是年、月和日。它还提供了plus(long amount, TemporalUnit unit)(以及等效的minus)。

此外,与Duration类似,Period也有一个parse()方法,它也接受类似于PnYnMnDPnW的 ISO-8601 格式。根据前面的讨论,结构可能非常明显:

  • 字符串以可选的符号开头,后跟字母P

  • 之后,对于第一种形式,有三个部分,其中至少有一个必须存在--年(Y)、月(M)和日(D)。

  • 对于第二种形式,只有一个部分--周(W)。

  • 每个部分的量可以带有正号或负号。

  • W单位不能与其他单位组合。内部,量乘以7并作为天数处理。

时钟

Clock是一个抽象类,它提供了一个使用时区访问当前时刻(我们将在下一节看到)、日期和时间的接口。在 Java 8 之前,我们必须调用System.currentTimeInMillis()TimeZone.getDefault()来计算这些值。Clock提供了一个很好的接口,可以从一个对象中获取这些值。

Javadoc 表示使用 Clock 是纯粹可选的。实际上,主要的日期/时间类都有一个 now() 方法,它使用系统时钟来获取它们的值。然而,如果你需要提供替代实现(比如,在测试中,你需要另一个时区的 LocalTime),这个抽象类可以被扩展以提供所需的功能,然后可以传递给适当的 now() 方法。

Instant

An Instant 是一个单一、精确的时间点(或者在时间线上,你会在 Javadoc 中看到)。这个类提供了类似于 PeriodDuration 的算术方法。解析也是一个选项,字符串是一个 ISO-8601 即时格式,如 1977-02-16T08:15:30Z

LocalDate

LocalDate 是一个不带时区的日期。虽然这个类的值是一个日期(年、月和日),但还有其他值的访问器方法,如下所示:

  • getDayOfWeek(): 这返回由日期表示的星期的 DayOfWeek 枚举。

  • getDayOfYear(): 这返回由日期表示的年份(1 到 365,闰年为 366)。这是一个从指定年份 1 月 1 日起的 1 为基础的计数器。

  • getEra(): 这返回给定日期的 ISO 时代。

当然,本地日期可以从字符串解析,但这次,格式似乎更加合理--yyyy-mm-dd。如果你需要不同的格式,parse() 方法已被重写,允许你指定可以处理字符串格式的 DateTimeFormatter

LocalTime

LocalTimeLocalDate 的基于时间的等效物。它存储 HH:MM:SS,但存储时区。解析时间需要上述格式,但就像 LocalDate 一样,也允许你为不同的字符串表示指定 DateTimeFormatter

LocalDateTime

LocalDateTime 实际上是后两个类的组合。所有的算术、工厂和提取方法都按预期应用。解析文本也是两者的组合,但 T 必须分隔字符串的日期和时间部分--'2016-01-01T00:00:00'。这个类存储或表示时区。

ZonedDateTime

如果你需要表示日期/时间和时区,那么你需要的是 ZonedDateTime 类。正如你所期望的,这个类的接口是 LocalDateLocalTime 的组合,并添加了处理时区的一些额外方法。

如在持续时间 API 概述中详细展示(尽管在其他类中暗示,但并不那么明显),这个新 API 的一个强点是能够以数学方式操作和处理各种日期和时间元素。正是这个功能,我们将在这个项目中花费大部分时间来探索这个新库。

回到我们的代码

我们需要解决的过程的第一部分是将用户提供的字符串解析成我们可以编程使用的东西。如果你去搜索解析器生成器,你会找到许多选项,其中 Antlr 和 JavaCC 等工具通常会出现在顶部。转向这些工具之一很有吸引力,但我们的目的在这里相当简单,语法也不是特别复杂。我们的功能需求包括:

  • 我们希望能够向日期或时间添加/减去时间

  • 我们希望能够从一个日期或时间减去另一个日期或时间,以获取两个日期或时间的差值

  • 我们希望能够将一个时区的时间转换到另一个时区

对于这样简单的事情,解析器在复杂性和二进制大小方面都过于昂贵。我们可以轻松地使用 JDK 内置的工具编写解析器,这正是我们将要做的。

在我们进入代码之前,计划是这样的——我们将定义许多标记来表示日期计算表达式中的逻辑部分。使用正则表达式,我们将分解给定的字符串,返回一个这些标记的列表,然后这些标记将被从左到右处理以返回结果。

话虽如此,让我们列出我们需要标记的类型。我们需要一个用于日期,一个用于时间,一个用于运算符,任何数值量,度量单位和时区。显然,我们不需要在每一个表达式中都需要这些,但这应该涵盖了我们的所有给定用例。

让我们从我们的标记的基础类开始。在定义类型层次结构时,总是很好地问自己是否需要一个基础类或接口。使用接口给开发者提供了额外的灵活性,如果需要扩展不同的类,那么在类层次结构中。然而,基础类允许我们在类型层次结构中提供默认行为,但以牺牲一些刚性为代价。为了使我们的Token实现尽可能简单,我们希望尽可能地将内容放在基础类中,所以我们将以以下方式使用基础类:

    public abstract class Token<T> {
      protected T value;
      public interface Info {
        String getRegex();
        Token getToken(String text);
      }
      public T getValue() {
        return value;
      }
    }

Java 8 确实引入了一种从接口提供默认行为的方法,即默认方法。默认方法是一个接口上的方法,它提供了一个具体的实现,这与接口有显著的不同。在此更改之前,所有接口所能做的就是定义方法签名并强制实现类定义方法体。这允许我们在接口中添加方法并提供默认实现,这样现有的接口实现就不需要改变。在我们的情况下,我们提供的行为是存储一个值(实例变量value)及其访问器(getValue()),因此具有默认方法的接口是不合适的。

注意,我们还定义了一个嵌套接口,Info,当我们到达解析器时,我们将更详细地介绍它。

在定义了基本类之后,我们现在可以创建我们需要的标记,如下所示:

    public class DateToken extends Token<LocalDate> { 
      private static final String TODAY = "today"; 
      public static String REGEX = 
        "\d{4}[-/][01]\d[-/][0123]\d|today"; 

要开始这个类,我们定义了两个常量。TODAY是一个特殊字符串,我们将允许用户指定今天的日期。第二个是我们将用来识别日期字符串的正则表达式:

    "\d{4}[-/][01]\d[-/][0123]\d|today" 

没有人会否认正则表达式很丑陋,而且按照这些规则,这个并不太复杂。我们正在匹配 4 个数字(\d{4}),要么是一个破折号或斜杠([-/]),要么是一个 0 或 1 后面跟着任意数字([01]\d),然后是另一个破折号或斜杠,接着是一个 0、1、2 或 3 后面跟着任意数字。最后,最后一个部分,|today,告诉系统匹配前面的模式,或者文本today。这个正则表达式所能做的只是识别一个看起来像日期的字符串。在其当前形式下,它实际上不能确保它是有效的。我们可能可以创建一个可以做到这一点的正则表达式,但引入的复杂性并不值得。不过,我们可以让 JDK 为我们验证字符串,就像在这里的of方法中所示:

    public static DateToken of(String text) { 
      try { 
        return TODAY.equals(text.toLowerCase()) ? 
          new DateToken(LocalDate.now()) : 
          new DateToken( 
            LocalDate.parse(text.replace("/", "-"))); 
      } catch (DateTimeParseException ex) { 
          throw new DateCalcException( 
            "Invalid date format: " + text); 
        } 
    } 

在这里,我们定义了一个静态方法来处理DateToken实例的创建。如果用户提供了字符串today,我们提供LocalDate.now()的值,它可能做你想的事情。否则,我们将字符串传递给LocalDate.parse(),将任何正斜杠转换为破折号,因为这是该方法所期望的。如果用户提供了无效的日期,但正则表达式仍然匹配它,我们会在这里得到一个错误。由于我们有内置的支持来验证字符串,我们可以放心地让系统为我们做繁重的工作。

其他标记看起来非常相似。我们不会展示每个类,因为其中大部分都非常熟悉,我们将跳过大多数这些类,只看看正则表达式,因为其中一些相当复杂。看看以下代码:

    public class IntegerToken extends Token<Integer> { 
      public static final String REGEX = "\d+"; 

嗯,那个并不太糟糕,对吧?这里将匹配一个或多个数字:

    public class OperatorToken extends Token<String> { 
      public static final String REGEX = "\+|-|to"; 

另一个相对简单的,它将匹配一个加号、一个减号或to文本:

    public class TimeToken extends Token<LocalTime> { 
      private static final String NOW = "now"; 
      public static final String REGEX = 
        "(?:[01]?\d|2[0-3]):[0-5]\d *(?:[AaPp][Mm])?|now"; 

正则表达式分解如下:

  • (?:: 这是一个非捕获组。我们需要将一些规则组合在一起,但我们不希望在 Java 代码处理时将它们显示为单独的组。

  • [01]?: 这是一个零或一个一。?表示这可能发生一次或根本不发生。

  • |2[0-3]: 我们要么匹配前半部分,或者这个部分,它将是一个 2 后面跟着一个 0、1、2 或 3。

  • ): 这结束了非捕获组。这个组将允许我们匹配 12 小时或 24 小时的时间。

  • :: 这个位置需要一个冒号。它的存在不是可选的。

  • [0-5]\d: 接下来,模式必须匹配一个0-5的数字后面跟着另一个数字。这是时间的分钟部分。

  • ' *': 这很难看,所以我添加了引号来帮助指示,但我们想匹配 0 个或多个(如星号所示)空格。

  • (?:: 这是另一个非捕获组。

  • [AaPp][Mm]: 这些是AP字母(任意大小写)后面跟着一个M(也是任意大小写)。

  • )?:我们结束非捕获组,但用?标记它,表示它应该出现一次或不出现。这个组让我们能够捕获任何AM/PM标识。

  • |now:与上面提到的今天类似,我们允许用户指定此字符串以指示当前时间。

再次强调,这个模式可能会匹配一个无效的时间字符串,但我们将让LocalTime.parse()TimeToken.of()中为我们处理这个问题:

    public static TimeToken of(final String text) { 
      String time = text.toLowerCase(); 
      if (NOW.equals(time)) { 
        return new TimeToken(LocalTime.now()); 
      } else { 
          try { 
            if (time.length() <5) { 
                time = "0" + time; 
            } 
            if (time.contains("am") || time.contains("pm")) { 
              final DateTimeFormatter formatter = 
                new DateTimeFormatterBuilder() 
                .parseCaseInsensitive() 
                .appendPattern("hh:mma") 
                .toFormatter(); 
                return new 
                TimeToken(LocalTime.parse( 
                  time.replaceAll(" ", ""), formatter)); 
            } else { 
                return new TimeToken(LocalTime.parse(time)); 
            } 
          } catch (DateTimeParseException ex) { 
              throw new DateCalcException( 
              "Invalid time format: " + text); 
            } 
        }
    } 

这比其他模式复杂一些,主要是因为LocalTime.parse()期望的默认格式,这是一个 ISO-8601 时间格式。通常,时间以 12 小时格式指定,带有 am/pm 标识。不幸的是,API 并不是这样工作的,所以我们必须进行调整。

首先,如果需要,我们填充小时。其次,我们查看用户是否指定了"am""pm"。如果是这样,我们需要创建一个特殊的格式化器,这是通过DateTimeFormatterBuilder完成的。我们首先告诉构建器构建一个不区分大小写的格式化器。如果我们不这样做,"AM"将工作,但"am"将不会。接下来,我们附加我们想要的模式,即小时、分钟和 am/pm,然后构建格式化器。最后,我们可以解析我们的文本,这是通过将字符串和格式化器传递给LocalTime.parse()来完成的。如果一切顺利,我们将得到一个LocalTime实例。如果不顺利,我们将得到一个Exception实例,我们将处理它。注意,我们在我们的字符串上调用replaceAll()。我们这样做是为了去除时间和 am/pm 之间的任何空格。否则,解析将失败。

最后,我们来到我们的UnitOfMeasureToken。这个标记不一定复杂,但绝对不简单。对于我们的度量单位,我们希望支持单词yearmonthdayweekhourminutesecond,所有这些都可以是复数形式,并且大多数可以缩写为其首字母。这使得正则表达式变得有趣:

    public class UnitOfMeasureToken extends Token<ChronoUnit> { 
      public static final String REGEX =
        "years|year|y|months|month|weeks|week|w|days|
         day|d|hours|hour|h|minutes|minute|m|seconds|second|s"; 
      private static final Map<String, ChronoUnit> VALID_UNITS = 
        new HashMap<>(); 

这并不是那么复杂,而是丑陋。我们有一个可能的字符串列表,由逻辑“或”运算符,即竖线分隔。可能可以编写一个正则表达式来搜索每个单词或其部分,但这样的表达式可能非常难以正确编写,并且几乎肯定难以调试或更改。简单明了通常比巧妙复杂要好。

这里还有一个需要讨论的最后元素:VALID_UNITS。在静态初始化器中,我们构建一个Map以允许查找正确的ChronoUnit

    static { 
      VALID_UNITS.put("year", ChronoUnit.YEARS); 
      VALID_UNITS.put("years", ChronoUnit.YEARS); 
      VALID_UNITS.put("months", ChronoUnit.MONTHS); 
      VALID_UNITS.put("month", ChronoUnit.MONTHS); 

等等。

我们现在可以查看解析器了,如下所示:

    public class DateCalcExpressionParser { 
      private final List<InfoWrapper> infos = new ArrayList<>(); 

      public DateCalcExpressionParser() { 
        addTokenInfo(new DateToken.Info()); 
        addTokenInfo(new TimeToken.Info()); 
        addTokenInfo(new IntegerToken.Info()); 
        addTokenInfo(new OperatorToken.Info()); 
        addTokenInfo(new UnitOfMeasureToken.Info()); 
      } 
      private void addTokenInfo(Token.Info info) { 
        infos.add(new InfoWrapper(info)); 
      } 

当我们构建我们的解析器时,我们在一个List中注册每个Token类,但我们看到两种新的类型:Token.InfoInfoWrapperToken.Info是嵌套在Token类中的一个接口:

    public interface Info { 
      String getRegex(); 
      Token getToken(String text); 
    } 

我们添加了这个接口,以便我们能够方便地获取Token类的正则表达式以及Token,而无需求助于反射。例如,DateToken.Info看起来是这样的:

    public static class Info implements Token.Info { 
      @Override 
      public String getRegex() { 
        return REGEX; 
      } 

      @Override 
      public DateToken getToken(String text) { 
        return of(text); 
      } 
    } 

由于这是一个嵌套类,我们可以轻松访问包括静态成员在内的封装类成员。

下一个新类型InfoWrapper看起来如下:

    private class InfoWrapper { 
      Token.Info info; 
      Pattern pattern; 

      InfoWrapper(Token.Info info) { 
        this.info = info; 
        pattern = Pattern.compile("^(" + info.getRegex() + ")"); 
      } 
    } 

这是一个简单、私有的类,因此可以暂时忽略一些常规的封装规则(尽管,如果这个类将来被公开,这肯定需要清理)。不过,我们正在存储一个标记的正则表达式的编译版本。请注意,我们在正则表达式周围添加了一些额外的字符。第一个是尖括号(^),表示匹配必须位于文本的开头。我们还将正则表达式放在括号中。然而,这次这是一个捕获组。我们将在接下来的解析方法中看到原因:

    public List<Token> parse(String text) { 
      final Queue<Token> tokens = new ArrayDeque<>(); 

      if (text != null) { 
        text = text.trim(); 
        if (!text.isEmpty()) { 
          boolean matchFound = false; 
          for (InfoWrapper iw : infos) { 
            final Matcher matcher = iw.pattern.matcher(text); 
            if (matcher.find()) { 
              matchFound = true; 
              String match = matcher.group().trim(); 
              tokens.add(iw.info.getToken(match)); 
              tokens.addAll( 
                parse(text.substring(match.length()))); 
                break; 
            } 
          } 
          if (!matchFound) { 
            throw new DateCalcException( 
              "Could not parse the expression: " + text); 
          } 
        } 
      } 

      return tokens; 
    } 

我们首先确保text不是 null,然后trim()它,然后确保它不是空的。在完成这些合理性检查后,我们遍历List中的信息包装器以找到匹配项。请记住,编译的模式是一个捕获组,它查看文本的开始,所以我们遍历每个Pattern直到找到一个匹配项。如果我们找不到匹配项,我们抛出一个Exception

一旦我们找到匹配项,我们就从Matcher中提取匹配的文本,然后使用Token.Info调用getToken()来获取匹配PatternToken实例。我们将它存储在我们的列表中,然后递归调用parse()方法,传递从我们的匹配项之后开始的文本子串。这将从原始文本中移除匹配的文本,然后重复此过程,直到字符串为空。一旦递归结束并且事情展开,我们返回一个表示用户提供的字符串的Queue。我们使用Queue而不是,比如说,List,因为这会使处理更容易。我们现在有了解析器,但我们的工作还只完成了一半。现在我们需要处理这些标记。

在关注点分离的精神下,我们将这些标记的处理——实际的表达式计算——封装在一个单独的类DateCalculator中,该类使用我们的解析器。考虑以下代码:

    public class DateCalculator { 
      public DateCalculatorResult calculate(String text) { 
        final DateCalcExpressionParser parser = 
          new DateCalcExpressionParser(); 
        final Queue<Token> tokens = parser.parse(text); 

        if (tokens.size() > 0) { 
          if (tokens.peek() instanceof DateToken) { 
            return handleDateExpression(tokens); 
          } else if (tokens.peek() instanceof TimeToken) { 
              return handleTimeExpression(tokens); 
            } 
        } 
        throw new DateCalcException("An invalid expression
          was given: " + text); 
    } 

每次调用calculate()时,我们都会创建一个新的解析器实例。同时,请注意,当我们查看其余的代码时,我们在传递Queue。虽然这确实使方法签名更大,但它也使类线程安全,因为类本身不持有任何状态。

在我们的isEmpty()检查之后,我们可以看到Queue API 是如何派上用场的。通过调用poll(),我们得到集合中下一个元素的引用,但——这很重要——我们保留元素在集合中。这让我们可以查看它而不改变集合的状态。根据集合中第一个元素的类型,我们将任务委托给适当的方法。

对于处理日期,表达式语法是<date> <operator> <date | number unit_of_measure>。因此,我们可以通过提取DateTokenOperatorToken来开始我们的处理,如下所示:

    private DateCalculatorResult handleDateExpression( 
      final Queue<Token> tokens) { 
        DateToken startDateToken = (DateToken) tokens.poll(); 
        validateToken(tokens.peek(), OperatorToken.class); 
        OperatorToken operatorToken = (OperatorToken) tokens.poll(); 
        Token thirdToken = tokens.peek(); 

        if (thirdToken instanceof IntegerToken) { 
          return performDateMath(startDateToken, operatorToken,
            tokens); 
        } else if (thirdToken instanceof DateToken) { 
            return getDateDiff(startDateToken, tokens.poll()); 
          } else { 
              throw new DateCalcException("Invalid expression"); 
            } 
    } 

要从Queue中检索一个元素,我们使用poll()方法,并且我们可以安全地将它转换为DateToken,因为我们已经在调用方法中检查了这一点。接下来,我们peek()查看下一个元素,并通过validateToken()方法验证该元素不是 null 并且是所需类型。如果标记有效,我们可以安全地poll()并转换。接下来,我们peek()第三个标记。根据其类型,我们将任务委托给正确的方法以完成处理。如果我们发现意外的Token类型,我们抛出Exception

在查看这些计算方法之前,让我们看看validateToken()

    private void validateToken(final Token token,
      final Class<? extends Token> expected) { 
        if (token == null || ! 
          token.getClass().isAssignableFrom(expected)) { 
            throw new DateCalcException(String.format( 
              "Invalid format: Expected %s, found %s", 
               expected, token != null ? 
               token.getClass().getSimpleName() : "null")); 
        } 
    } 

这里没有什么特别激动人心的,但细心的读者可能会注意到我们正在返回我们的标记的类名,并且通过这样做,我们将非导出类的名称泄露给了最终用户。这可能不是最佳做法,但我们将把修复这个问题留给读者作为练习。

执行日期数学的方法如下:

    private DateCalculatorResult performDateMath( 
      final DateToken startDateToken, 
      final OperatorToken operatorToken, 
      final Queue<Token> tokens) { 
        LocalDate result = startDateToken.getValue(); 
        int negate = operatorToken.isAddition() ? 1 : -1; 

        while (!tokens.isEmpty()) { 
          validateToken(tokens.peek(), IntegerToken.class); 
          int amount = ((IntegerToken) tokens.poll()).getValue() *
            negate; 
          validateToken(tokens.peek(), UnitOfMeasureToken.class); 
          result = result.plus(amount, 
          ((UnitOfMeasureToken) tokens.poll()).getValue()); 
        } 

        return new DateCalculatorResult(result); 
    } 

由于我们已经有起始和操作标记,我们将它们传递进去,以及Queue,这样我们就可以处理剩余的标记。我们的第一步是确定操作符是加号还是减号,根据需要将正1或负-1赋值给negate。我们这样做是为了可以使用一个单一的方法LocalDate.plus()。如果操作符是减号,我们添加一个负数,得到的结果与减去原始数字相同。

最后,我们遍历剩余的标记,在处理之前验证每一个。我们获取IntegerToken;获取其值;将其乘以我们的负数修饰符negate;然后使用UnitOfMeasureToken来告知我们添加的是哪种类型的值,将该值加到LocalDate上。

计算日期之间的差异相当直接,正如我们所看到的:

    private DateCalculatorResult getDateDiff( 
      final DateToken startDateToken, final Token thirdToken) { 
        LocalDate one = startDateToken.getValue(); 
        LocalDate two = ((DateToken) thirdToken).getValue(); 
        return (one.isBefore(two)) ? new
          DateCalculatorResult(Period.between(one, two)) : new
            DateCalculatorResult(Period.between(two, one)); 
    } 

我们从两个DateToken变量中提取LocalDate,然后调用Period.between(),它返回一个Period,表示两个日期之间的时间差。我们检查哪个日期先到,以便我们返回一个正的Period给用户作为便利,因为大多数人通常不会考虑负的时间段。

基于时间的方 法在很大程度上是相同的。最大的区别是时间差方法:

    private DateCalculatorResult getTimeDiff( 
      final OperatorToken operatorToken, 
      final TimeToken startTimeToken, 
      final Token thirdToken) throws DateCalcException { 
        LocalTime startTime = startTimeToken.getValue(); 
        LocalTime endTime = ((TimeToken) thirdToken).getValue(); 
        return new DateCalculatorResult( 
          Duration.between(startTime, endTime).abs()); 
    } 

这里明显的不同之处在于使用了Duration.between()。它看起来与Period.between()相同,但Duration类提供了一个Period类没有的方法:abs()。这个方法允许我们返回Period的绝对值,因此我们可以以任何顺序将我们的LocalTime变量传递给between()

在我们离开之前还有一个最后的注意事项——我们正在将结果包装在DateCalculatorResult实例中。由于各种操作返回几种不同且不相关的类型,这使得我们可以从calculate()方法返回单一类型。这将由调用代码负责提取适当的价值。我们将在我们的命令行界面中这样做,我们将在下一节中查看。

简短的小憩:测试

在我们继续前进之前,我们需要讨论一个我们还没有讨论过的话题,那就是测试。任何在业界工作了一段时间的人可能都听说过术语测试驱动开发(或简称TDD)。这是一种软件开发方法,它认为首先应该编写的是测试,这个测试会失败(因为没有代码可以运行),然后应该编写代码使测试通过,这是一个对 IDE 和其他工具中给出的绿色指示器的引用,表示测试已经通过。这个过程会根据需要重复多次以构建最终系统,总是以小增量进行更改,并且始终从测试开始。关于这个主题已经写出了许多书籍,这个话题既被激烈争论,又往往具有很多细微差别。如果确实实施了这个方法,其具体实施方式几乎总是以不同的风味出现。

显然,在我们这里的工作中,我们没有严格遵循 TDD 原则,但这并不意味着我们没有进行测试。虽然 TDD 的纯粹主义者可能会挑剔,但我的总体方法在测试方面通常比较宽松,直到我的 API 开始稳固一些。这需要多长时间取决于我对所使用技术的熟悉程度。如果我对它们非常熟悉,我可能会快速勾勒出一个接口,然后基于这个接口构建一个测试,以此来测试 API 本身,然后进行迭代。对于新的库,我可能会编写一个非常广泛的测试,以帮助推动对新库的调查,使用测试框架作为在可以实验的运行环境中启动的途径。无论如何,在开发工作的最后,新的系统应该被全面测试(全面的确切定义是另一个激烈争论的概念),这正是我在这里努力追求的。不过,关于测试和测试驱动开发的全面论述超出了我们这里的范围。

当涉及到 Java 的测试时,你有许多选择。然而,最常见的是 TestNG 和 JUnit,其中 JUnit 可能是最受欢迎的。你应该选择哪一个?这取决于。如果你正在使用现有的代码库,你可能会使用已经存在的任何东西,除非你有很好的理由去做其他的事情。例如,库可能已经过时且不再受支持,它可能明显不足以满足你的需求,或者你被明确指示更新/替换现有的系统。如果这些条件中的任何一个,或者类似的情况是真实的,我们将回到这个问题——我应该选择哪一个? 再次,这取决于。JUnit 非常流行和常见,所以使用它可能会降低进入项目的门槛。然而,TestNG 被认为有一个更好、更干净的 API。例如,TestNG 不需要使用静态方法来执行某些测试设置方法。它还旨在成为不仅仅是一个单元测试框架,提供单元、功能、端到端和集成测试的工具。在我们的测试中,我们将使用 TestNG。

要开始使用 TestNG,我们需要将其添加到我们的项目中。为此,我们将在 Maven POM 文件中添加一个测试依赖项,如下所示:

    <properties>
      <testng.version>6.9.9</testng.version>
    </properties>
    <dependencies> 
      <dependency> 
        <groupId>org.testng</groupId>   
        <artifactId>testng</artifactId>   
        <version>${testng.version}</version>   
        <scope>test</scope> 
      </dependency>   
    </dependencies> 

编写测试非常简单。使用 TestNG Maven 插件的默认设置,类只需要位于src/test/java目录中,并以Test字符串结尾。每个测试方法都需要用@Test注解。

图书馆模块中有很多测试,所以让我们从一些非常基础的测试开始,这些测试用于检查标记符使用的正则表达式,以识别和提取表达式的相关部分。例如,考虑以下代码片段:

    public class RegexTest { 
      @Test 
      public void dateTokenRegex() { 
        testPattern(DateToken.REGEX, "2016-01-01"); 
        testPattern(DateToken.REGEX, "today"); 
      } 
      private void testPattern(String pattern, String text) { 
        testPattern(pattern, text, false); 
      } 

      private void testPattern(String pattern, String text, 
        boolean exact) { 
          Pattern p = Pattern.compile("(" + pattern + ")"); 
          final Matcher matcher = p.matcher(text); 

          Assert.assertTrue(matcher.find()); 
          if (exact) { 
            Assert.assertEquals(matcher.group(), text); 
          } 
      } 

这是对DateToken正则表达式的一个非常基础的测试。测试委托给testPattern()方法,传递要测试的正则表达式和一个用于测试的字符串。我们的功能通过以下步骤进行测试:

  1. 编译Pattern

  2. 创建一个Matcher

  3. 调用matcher.find()方法。

通过这样,我们测试了被测试系统的逻辑。剩下的是验证它是否按预期工作。我们通过调用Assert.assertTrue()来完成这个验证。我们断言matcher.find()返回true。如果正则表达式正确,我们应该得到一个true响应。如果正则表达式不正确,我们将得到一个false响应。在后一种情况下,assertTrue()将抛出一个Exception,测试将失败。

这个测试当然非常基础。它本可以——应该——更加健壮。它应该测试更多的字符串。它应该包括一些已知是错误的字符串,以确保我们的测试中没有得到错误的结果。可能还有无数的其他改进可以做出。然而,这里的目的是展示一个简单的测试,以演示如何设置基于 TestNG 的环境。在继续之前,让我们看看更多的一些例子。

这里有一个用于检查失败的测试(一个负面测试):

    @Test 
    public void invalidStringsShouldFail() { 
      try { 
        parser.parse("2016/12/25 this is nonsense"); 
        Assert.fail("A DateCalcException should have been
          thrown (Unable to identify token)"); 
      } catch (DateCalcException dce) { 
      } 
    } 

在这个测试中,我们期望parse()的调用会失败,并抛出DateCalcException。如果调用失败,我们将有一个调用Assert.fail()的调用,这将强制测试以提供的信息失败。如果抛出了Exception,它将被静默捕获,并且测试将成功完成。

捕获Exception是一种方法,但你也可以告诉 TestNG 预期会抛出Exception,就像我们在这里通过expectedExceptions属性所做的那样:

    @Test(expectedExceptions = {DateCalcException.class}) 
    public void shouldRejectBadTimes() { 
      parser.parse("22:89"); 
    } 

再次强调,我们向解析器传递了一个错误的字符串。然而,这次,我们通过注解告诉 TestNG 预期异常--@Test(expectedExceptions = {DateCalcException.class})

关于测试的一般性和 TestNG 的特定性可以写更多。这两个主题的彻底处理超出了我们的范围,但如果你不熟悉这两个主题中的任何一个,你最好找到许多可用的优秀资源并彻底研究它们。

现在,让我们把注意力转向命令行界面。

构建命令行界面

在上一章中,我们使用 Tomitribe 的 Crest 库构建了一个命令行工具,并且效果相当不错,所以我们将回到这个库来构建这个命令行。

要在我们的项目中启用 Crest,我们必须做两件事。首先,我们必须按照以下方式配置我们的 POM 文件:

    <dependency> 
      <groupId>org.tomitribe</groupId> 
      <artifactId>tomitribe-crest</artifactId> 
      <version>0.8</version> 
    </dependency> 

我们还必须更新我们的模块定义在src/main/java/module-info.java中,如下所示:

    module datecalc.cli { 
      requires datecalc.lib; 
      requires tomitribe.crest; 
      requires tomitribe.crest.api; 

      exports com.steeplesoft.datecalc.cli; 
    } 

我们现在可以定义我们的 CLI 类如下:

    public class DateCalc { 
      @Command 
      public void dateCalc(String... args) { 
        final String expression = String.join(" ", args); 
        final DateCalculator dc = new DateCalculator(); 
        final DateCalculatorResult dcr = dc.calculate(expression); 

与上一章不同,这个命令行将非常简单,因为我们需要的唯一输入是要评估的表达式。根据前面的方法签名,我们告诉 Crest 将所有命令行参数作为args值传递,然后我们通过String.join()将它们重新组合成expression。接下来,我们创建我们的计算器并计算结果。

现在我们需要调查我们的DateCalcResult以确定表达式的性质。以下代码片段作为示例:

    String result = ""; 
    if (dcr.getDate().isPresent()) { 
      result = dcr.getDate().get().toString(); 
    } else if (dcr.getTime().isPresent()) { 
      result = dcr.getTime().get().toString(); 
    } else if (dcr.getDuration().isPresent()) { 
      result = processDuration(dcr.getDuration().get()); 
    } else if (dcr.getPeriod().isPresent()) { 
      result = processPeriod(dcr.getPeriod().get()); 
    } 
    System.out.println(String.format("'%s' equals '%s'", 
      expression, result)); 

LocalDateLocalTime的响应相当直接--我们只需在它们上调用toString()方法,因为默认值对我们来说在这里是完全可以接受的。持续时间周期稍微复杂一些。两者都提供了一些方法来提取详细信息。我们将把这些细节隐藏在单独的方法中:

    private String processDuration(Duration d) { 
      long hours = d.toHoursPart(); 
      long minutes = d.toMinutesPart(); 
      long seconds = d.toSecondsPart(); 
      String result = ""; 

      if (hours > 0) { 
        result += hours + " hours, "; 
      } 
      result += minutes + " minutes, "; 
      if (seconds > 0) { 
        result += seconds + " seconds"; 
      } 

      return result; 
    } 

该方法本身相当简单--我们从Duration中提取各个部分,然后根据部分是否返回值来构建字符串。

与日期相关的方法processPeriod()类似:

    private String processPeriod(Period p) { 
      long years = p.getYears(); 
      long months = p.getMonths(); 
      long days = p.getDays(); 
      String result = ""; 

      if (years > 0) { 
        result += years + " years, "; 
      } 
      if (months > 0) { 
        result += months + " months, "; 
      } 
      if (days > 0) { 
        result += days + " days"; 
      } 
      return result; 
    } 

这些方法中的每一个都将结果作为字符串返回,然后我们将其写入标准输出。就是这样。这不是一个特别复杂的命令行工具,但这个练习的目的主要在于库。

摘要

我们的数据计算器现在已经完成了。这个实用工具本身并不太复杂,尽管如此,它确实按照预期工作,这为我们提供了一个实验 Java 8 的日期/时间 API 的平台。除了新的日期/时间 API 之外,我们还对正则表达式进行了初步探索,这是一个非常强大且复杂的工具,用于解析字符串。我们还回顾了上一章中的命令行实用工具库,并尝试了单元测试和测试驱动开发。

在下一章中,我们将变得更加雄心勃勃,进入社交媒体的世界,构建一个应用程序,帮助我们把我们的一些最喜欢的服务聚合到一个单一的应用程序中。

第二十三章:Sunago - 社交媒体聚合器

对于我们的下一个项目,我们将尝试做一些更具雄心的事情;我们将构建一个桌面应用程序,该程序从各种社交媒体网络中聚合数据,并在一次无缝交互中显示。我们还将尝试一些新事物,并给这个项目起一个名字,这个名字可能比我们迄今为止使用的干燥、尽管准确、但略显无趣的描述转变为名称更具吸引力。因此,这个应用程序,我们将称之为 Sunago,这是古希腊词(Koine)συνάγω的音译,其意为我聚集在一起收集集合

构建应用程序将涵盖几个不同的主题,有些熟悉,有些新颖。该列表包括以下内容:

  • JavaFX

  • 国际化和本地化

  • 服务提供者接口SPI

  • 消费 REST API

  • ClassLoader操作

  • Lambda,Lambda,还有更多的 Lambda

如同往常,这些只是亮点,其中穿插着许多有趣的项目。

开始

就像每个应用程序一样,在我们开始之前,我们需要考虑我们希望应用程序做什么。也就是说,功能需求是什么?从高层次上讲,描述告诉我们我们希望以广泛的方式实现什么,但更具体地说,我们希望用户能够做到以下事情:

  • 连接到几个不同的社交媒体网络

  • 在网络层面上确定要检索哪组数据(用户、列表等)

  • 在综合显示中查看每个网络的项目列表

  • 能够确定项目来自哪个网络

  • 点击一个项目,并在用户的默认浏览器中加载它

除了这个应用程序应该做的事情的列表之外,它不应该做的事情包括以下内容:

  • 对项目进行回应/回复

  • 对项目进行评论

  • 管理朋友/关注列表

这些功能将是应用程序的绝佳补充,但它们在基本应用程序的架构上并没有提供太多有趣的东西,所以为了保持简单——并且继续前进——我们将范围限制在给定的基本需求集。

那么,在应用程序中从哪里开始呢?就像前面的章节一样,我们将将其制作为一个桌面应用程序,所以让我们从这里开始,从 JavaFX 应用程序开始。我将在这里透露一点,以便稍后更容易:这将是一个多模块项目,因此我们首先需要创建父项目。在 NetBeans 中,点击文件 | 新建项目...,然后选择Maven类别,如下面的截图所示:

图片

当你点击“完成”时,你会看到一个空项目。一旦我们向这个项目添加模块,区分它们可能会变得困难,所以我作为一个习惯的做法是给每个模块一个独特、具有“命名空间”的名称。也就是说,每个模块都有自己的名称,当然,但我会在其前面加上项目的名称。例如,由于这是项目的基 POM,我称之为 Master。为了反映这一点,我修改了生成的 POM,使其看起来像这样:

    <?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> 
      <groupId>com.steeplesoft.sunago</groupId> 
      <artifactId>master</artifactId> 
      <version>1.0-SNAPSHOT</version> 
      <name>Sunago - Master</name> 
      <packaging>pom</packaging> 
    </project> 

目前这还没有什么。像这样的父 POM 给我们的优势是,如果我们愿意,我们可以用一个命令构建所有项目,并且我们可以将任何共享配置移动到这个共享父 POM 中以减少重复。不过,我们现在需要添加一个模块,NetBeans 会帮助我们做到这一点,如截图所示:

图片

在点击“创建新模块...”后,你会看到一个熟悉的“新建项目”窗口,从那里你需要选择 Maven | JavaFX 应用程序,然后点击“下一步”。在“新建 Java 应用程序”屏幕中,输入 app 作为项目名称,然后点击“完成”(所有其他默认值都可以接受)。

再次,我们希望给这个模块一个有意义的名称,所以让我们按照以下方式修改生成的 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> 
      <parent> 
        <groupId>com.steeplesoft.sunago</groupId> 
        <artifactId>master</artifactId> 
        <version>1.0-SNAPSHOT</version> 
      </parent> 
      <artifactId>sunago</artifactId> 
      <name>Sunago - App</name> 
      <packaging>jar</packaging> 
    </project> 

当 NetBeans 创建项目时,它会为我们生成几个工件——两个类,FXMLControllerMainApp,以及资源,fxml/Scene.xmlstyles/Styles.css。虽然这可能是显而易见的,但工件应该有能够清楚地传达其目的的名称,所以让我们重命名这些。

FxmlContoller 应该重命名为 SunagoController。可能最快、最简单的方法是在项目视图中双击打开它,然后在源编辑器中点击类声明中的类名,并按 Ctrl + R。应该会出现重命名类的对话框,在那里你需要输入新名称,然后按 Enter。这将为你重命名类和文件。现在重复这个过程,将 MainApp 重命名为 Sunago

我们还希望将生成的 FXML 文件,Scene.xml,重命名为 sunago.fxml。要做到这一点,请在项目视图中右键单击文件,并在上下文菜单中选择“重命名...”。在重命名对话框中输入新名称(不带扩展名),然后按 Enter。在此期间,我们还可以将 Styles.css 重命名为 styles.css,以确保大小写一致。这是一件小事,但代码的一致性可以帮助你在未来有人接管你的代码时增强信心。

不幸的是,重命名这些文件并不会调整 Java 源中对它们的引用,所以我们需要编辑 Sunago.java 来指向这些新名称,操作方法如下:

    @Override
    public void start(Stage stage) throws Exception {
      Parent root = fxmlLoader.load(
        getClass().getResource("/fxml/sunago.fxml"));

        Scene scene = new Scene(root);
        scene.getStylesheets().add("/styles/styles.css");

        stage.setTitle("Sunago, your social media aggregator");
        stage.setScene(scene);
        stage.show();
    }

注意,我们还把标题改成了更合适的内容。

设置用户界面

如果我们愿意,现在就可以运行我们的应用程序。这会很无聊,但它会运行。让我们试着解决无聊的部分。

默认创建的 FXML 只是一个带有两个子组件的 AnchorPane,一个按钮(Button)和一个标签(Label)。我们不需要这些,所以让我们去掉它们。我们的主要用户界面将会非常简单——基本上,只是一个组件的垂直堆叠——因此我们可以使用 VBox 作为我们的根组件。也许,将根组件从现有的 AnchorPane 更改为 VBox 的最简单方法就是使用 Scene Builder 将此组件包裹在 VBox 中,然后删除 AnchorPane:

图片

要做到这一点,通过双击文件(假设您已正确配置 NetBeans,使其知道 Scene Builder 的位置)在 Scene Builder 中打开 FXML 文件。在 Scene Builder 中,在左侧折叠面板的文档部分右键单击 AnchorPane,选择“包裹”(Wrap in),然后选择 VBox,如前面的截图所示。然后 Scene Builder 将修改 FXML 文件,使 AnchorPane 成为 VBox 的子组件,正如预期的那样。完成之后,您可以在 AnchorPane 上右键单击,然后点击“删除”以删除它及其子组件。这将给我们留下一个比我们开始时更无聊的空用户界面。现在我们可以通过添加一些控件——一个菜单栏(MenuBar)和一个列表视图(ListView)——来修复它。通过在折叠面板的控件部分单击每个组件并将它们拖动到 VBox 中来实现这一点。如果您将组件放在 VBox 上,它们将被添加到其子组件列表中。确保菜单栏(MenuBar)在列表视图(ListView)之前,否则您将得到一个非常奇怪的界面。

在我们返回代码之前,现在让我们先配置这些组件。在左侧的文档部分选择 VBox,然后我们需要在右侧的折叠面板中选择布局部分。对于最小宽度(Min Width)和最小高度(Min Height),分别输入640480。这将使窗口的默认尺寸更大,更用户友好。

对于菜单栏(MenuBar),我们需要展开其“文档”(Document)下的条目,然后展开其每个菜单子项(Menu children),这样每个菜单(Menu)都应该显示一个菜单项(MenuItem)。点击第一个菜单,然后在右侧,将“文本”(Text)设置为_File,并勾选“快捷键解析”。这将允许用户按下Alt + F来激活(或显示)此菜单。接下来,点击其MenuItem子项,将“文本”(Text)设置为_Exit,并勾选“快捷键解析”。(如果MenuMenuItemButton等文本中包含下划线,请确保已勾选“快捷键解析”。为了简洁起见,我不会再次明确指出这一点。)打开代码部分,并将“动作”(On Action)值设置为closeApplication

第二个Menu应该将其文本值设置为_Edit。其MenuItem应标记为_Settings,并具有showPreferences的操作值。最后,第三个Menu应标记为_Help,其MenuItem标记为About,操作值为showAbout

接下来,我们想要给ListView一个 ID,所以选择左侧的它,确保右侧的代码部分已展开,并输入entriesListView作为 fx:id。

我们最后需要做的编辑是将控制器设置好。我们在左侧的折页中这样做,在底部的控制器部分,然后展开它,确保控制器类值与我们在 NetBeans 中刚刚创建的 Java 类和包相匹配,然后保存文件。

设置控制器

回到 NetBeans,我们需要调整我们的控制器以反映我们在 FXML 中刚刚做出的更改。在SunagoController中,我们需要添加entriesListView属性,如下所示:

    @FXML 
    private ListView<SocialMediaItem> entriesListView; 

注意参数化类型是SocialMediaItem。这是我们将在接下来的几分钟内创建的自定义模型。在我们着手处理之前,我们需要完成用户界面的连接。我们在 FXML 中定义了三个onAction处理程序。相应的代码如下:

    @FXML 
    public void closeApplication(ActionEvent event) { 
      Platform.exit(); 
    } 

关闭应用程序就像在Platform类上调用exit方法一样简单。显示“关于”框也相当简单,正如我们在showAbout方法中看到的:

    @FXML 
    public void showAbout(ActionEvent event) { 
      Alert alert = new Alert(Alert.AlertType.INFORMATION); 
      alert.setTitle("About..."); 
      alert.setHeaderText("Sunago (συνάγω)"); 
      alert.setContentText("(c) Copyright 2016"); 
      alert.showAndWait(); 
    } 

使用内置的Alert类,我们构建一个实例,并设置适合关于屏幕的值,然后通过showAndWait()以模态方式显示它。

预设窗口的逻辑要复杂得多,因此我们将其封装在一个新的控制器类中,并调用其showAndWait()方法。

    @FXML 
    public void showPreferences(ActionEvent event) { 
      PreferencesController.showAndWait(); 
    } 

编写模型类

在我们查看它之前,主控制器中还有一些其他的项目需要我们处理。首先是之前提到的模型类,SocialMediaItem。正如你可能想象的那样,从社交网络返回的数据结构可能相当复杂,并且肯定多种多样。例如,推文的资料需求可能与 Instagram 帖子的需求大不相同。因此,我们希望能够在简单、可重用的接口后面隐藏这些复杂性和差异。在现实世界中,这样的简单抽象并不总是可能的,但在这里,我们有一个这样的接口在SocialMediaItem中,正如你在这段代码中可以看到的:

    public interface SocialMediaItem { 
      String getProvider(); 
      String getTitle(); 
      String getBody(); 
      String getUrl(); 
      String getImage(); 
      Date getTimestamp(); 
    } 

抽象的一个问题是,为了使它们可重用,你偶尔必须以某种方式结构化它们,以便它们暴露出可能不被每个实现使用的属性。这还不明显,但这里确实是这样。这是有些人认为不可接受的情况,他们可能是有道理的,但这实际上是一个权衡的问题。我们的选择包括一个略微膨胀的接口或一个复杂的系统,其中每个网络支持模块(我们将在稍后讨论)提供自己的渲染器,并且应用程序必须查询每个模块,寻找可以处理每个项目的渲染器,同时在绘制ListView。当然,可能还有其他选择,但面对(至少)那两个,为了简单和性能,我们将选择第一个选项。然而,在设计你自己的系统时遇到类似情况时,你需要评估你项目的各种要求,并做出适当的选择。对于我们的需求,简单的方法已经足够了。

无论如何,每个社交媒体网络模块都将实现该接口来封装其数据。这将给应用程序提供一个通用的接口,以便消费,而无需确切知道它来自何处。然而,我们现在需要告诉ListView如何绘制包含SocialMediaItem的单元格。我们可以通过在控制器的initialize()方法中添加以下代码行来实现这一点,如下所示:

    entriesListView.setCellFactory(listView ->  
      new SocialMediaItemViewCell()); 

显然,那是一个 lambda 表达式。对于好奇的人,前面方法的 lambda 版本看起来像这样:

    entriesListView.setCellFactory( 
      new Callback<ListView<SocialMediaItem>,  
      ListCell<SocialMediaItem>>() {  
        @Override 
        public ListCell<SocialMediaItem> call( 
          ListView<SocialMediaItem> param) { 
            return new SocialMediaItemViewCell(); 
          } 
    }); 

完成控制器

在我们查看SocialMediaItemViewCell之前,还有两个控制器项目。第一个是包含ListView数据的列表。记住,ListViewObservableList操作。这使得我们可以更改列表中的数据,并且它将自动反映在用户界面中。为了创建该列表,我们将在定义类属性时使用 JavaFX 辅助方法,如下所示:

    private final ObservableList<SocialMediaItem> entriesList =  
      FXCollections.observableArrayList(); 

然后,我们需要将那个List连接到我们的ListView。回到initialize(),我们有以下内容:

    entriesListView.setItems(entriesList); 

为了完成SocialMediaItem接口的渲染,让我们这样定义SocialMediaItemViewCell

    public class SocialMediaItemViewCell extends  
      ListCell<SocialMediaItem> { 
      @Override 
      public void updateItem(SocialMediaItem item, boolean empty) { 
        super.updateItem(item, empty); 
        if (item != null) { 
          setGraphic(buildItemCell(item)); 
          this.setOnMouseClicked(me -> SunagoUtil 
            .openUrlInDefaultApplication(item.getUrl())); 
        } else { 
            setGraphic(null); 
          } 
      } 

      private Node buildItemCell(SocialMediaItem item) { 
        HBox hbox = new HBox(); 
        InputStream resource = item.getClass() 
          .getResourceAsStream("icon.png"); 
        if (resource != null) { 
          ImageView sourceImage = new ImageView(); 
          sourceImage.setFitHeight(18); 
          sourceImage.setPreserveRatio(true); 
          sourceImage.setSmooth(true); 
          sourceImage.setCache(true); 
          sourceImage.setImage(new Image(resource)); 
          hbox.getChildren().add(sourceImage); 
        } 

        if (item.getImage() != null) { 
          HBox picture = new HBox(); 
          picture.setPadding(new Insets(0,10,0,0)); 
          ImageView imageView = new ImageView(item.getImage()); 
          imageView.setPreserveRatio(true); 
          imageView.setFitWidth(150); 
          picture.getChildren().add(imageView); 
          hbox.getChildren().add(picture); 
        } 

        Label label = new Label(item.getBody()); 
        label.setFont(Font.font(null, 20)); 
        label.setWrapText(true); 
        hbox.getChildren().add(label); 

        return hbox; 
      } 

    } 

这里发生了很多事情,但updateItem()是我们关注的第一个点。这是每次屏幕上行的更新都会调用的方法。注意,我们检查item是否为 null。我们这样做是因为ListView调用此方法不是针对其List中的每个项目,而是针对ListView中每个可见的行,无论是否有数据。这意味着,如果List有五个项目,但ListView足够高以显示十行,则此方法将被调用十次,最后五次调用将使用 null 的item。在这些情况下,我们调用setGraphic(null)来清除可能之前已渲染的任何项目。

然而,如果 item 不为空,我们需要构建用于显示项目的 Node,这通过 buildItemCell() 完成。对于每个项目,我们希望渲染三个项目——社交媒体网络图标(这样用户可以一眼看出项目来自哪里),项目内嵌入的任何图像,以及最后,项目中的任何文本/标题。为了帮助安排这些,我们从一个 HBox 开始。

接下来,我们尝试找到一个网络图标。如果我们有一个正式的合同,我们会在其中包含一些语言,规定模块应包含一个名为 icon.png 的文件,该文件与模块的 SocialMediaItem 实现位于同一包中。然后,使用实现用的 ClassLoader,我们尝试获取资源的 InputStream。我们检查 null,只是为了确保实际上找到了图像;如果是这样,我们创建一个 ImageView,设置一些属性,然后将资源包裹在一个 Image 中,将其交给 ImageView,然后将 ImageView 添加到 HBox

为项目添加图像

如果项目有一个图像,我们以与网络图标图像相同的方式处理它。不过,这次我们在将其添加到外部的 HBox 之前,实际上是将 ImageView 包裹在一个另一个 HBox 中。我们这样做是为了可以在图像周围添加填充(通过 picture.setPadding(new Insets())),给这个图像和网络图标之间留出一些空间。

最后,我们创建一个 Label 来持有项目的主体。我们通过 label.setFont(Font.font(null, 20)) 将文本的字体大小设置为 20 点,并将其添加到我们的 HBox 中,然后将其返回给调用者 updateItem()

每次当你有一个 ListView 时,你很可能会想要一个自定义的 ListCell 实现,就像我们这里所做的那样。在某些情况下,在 List 内容上调用 toString() 可能是合适的,但并不总是如此,而且你当然不能像我们这里这样拥有一个复杂的 ListCell 结构,而不自己实现 ListCell。如果你计划进行大量的 JavaFX 开发,那么熟悉这项技术是非常明智的。

构建首选项用户界面

我们最终完成了主要控制器,现在我们可以将注意力转向下一个大块,PreferencesController。我们的首选项对话框将像通常预期的那样是一个模态对话框。它将提供一个带有标签页的界面,其中一个标签页用于常规设置,然后为每个受支持的社会网络提供一个标签页。我们通过向我们的项目中添加一个新的 FXML 文件和控制器来开始这项工作,NetBeans 有一个很好的向导来完成这个任务。在所需的包上右键单击,然后点击新建 | 其他。从类别列表中,选择 JavaFX,然后从文件类型列表中选择 Empty FXML,如以下截图所示:

截图

点击下一步后,你应该会看到 FXML 名称和位置步骤。这将允许我们指定新文件的名字和创建它的包,如以下截图所示:

图片

点击“下一步”将带我们进入控制器类步骤。在这里,我们可以创建一个新的控制器类,或者将我们的文件附加到现有的一个上。由于这是一个我们应用的新对话框/窗口,我们需要创建一个新的控制器,如下所示:

图片

选择“使用 Java 控制器”复选框,输入PreferencesController作为名称,并选择所需的包。我们可以点击“下一步”,这将带我们进入层叠样式表步骤,但我们对此控制器不感兴趣,所以,我们通过点击“完成”结束向导,这将带我们到我们新创建的控制器类的源代码。

让我们从布局用户界面开始。双击新的prefs.fxml文件,在 Scene Builder 中打开它。像我们上一个 FXML 文件一样,默认的根元素是 AnchorPane。对于这个窗口,我们希望使用 BorderPane,所以我们使用上次同样的技术来替换 AnchorPane--右键单击组件,然后点击“包裹在 | BorderPane”。AnchorPane 现在嵌套在 BorderPane 中,所以我们再次右键单击它并选择删除。

要构建用户界面,我们现在从左侧的折叠面板中拖动一个 TabPane 控件,并将其放入 BorderPane 的中心区域。这将向我们的用户界面添加一个带有两个标签的 TabPane。我们现在只需要一个,所以删除第二个。我们希望给标签一个有意义的名称。我们可以通过在预览窗口中双击标签(或在检查器的属性部分选择文本属性)并输入“通用”来实现。最后,展开检查器的代码部分,输入tabPane作为 fx:id。

现在,我们需要提供一个用户可以关闭窗口并保存或丢弃更改的方法。我们通过将 ButtonBar 组件拖到我们的 border pane 的底部区域来实现这一点。这将添加一个带有单个按钮的 ButtonBar,但我们需要两个,所以我们将另一个按钮拖到 ButtonBar 上。这个控件的好处是它会为我们处理按钮位置和填充,所以,当我们放下新按钮时,它将自动添加到正确的右侧位置。(这种行为可以被覆盖,但它工作得正好符合我们的要求,所以我们只需接受默认设置。)

对于每个“按钮”,我们需要设置三个属性--textfx:idonAction。第一个属性在检查器的属性部分,最后两个在代码部分。第一个按钮的值是“保存”、savePrefssavePreferences。对于第二个按钮,值是“取消”、cancelcloseDialog。在检查器中选择ButtonBar的布局部分,并将右填充设置为 10,以确保“按钮”不会压在窗口的边缘上。

最后,我们将添加我们目前唯一的偏好设置。我们希望允许用户指定从每个社交媒体网络获取的请求的最大项目数。对于那些应用程序已经有一段时间(或从未)未被使用的情况,我们这样做。在这些情况下,我们不想尝试下载成千上万的推文。为了支持这一点,我们添加了两个控件,LabelTextField

获取标签控件的位置相当简单,因为它是第一个组件。Scene Builder 将提供红色指南线来帮助你将组件精确地放置到你想要的位置,如图所示:

图片

确保 TextField 与标签对齐可能会有些棘手。默认情况下,当你将组件拖放到 TabPane 上时,Scene Builder 会添加一个 AnchorPane 来容纳新组件。HBox 可能是一个更好的选择,但我们将继续使用 AnchorPane 来演示 Scene Builder 的这个功能。如果你将一个 TextField 拖放到 TabPane 上并尝试定位它,你应该会看到更多的红色线条出现。定位正确时,你应该会看到一条红色线条穿过标签和 TextField 的中间,表明两个组件垂直对齐。这正是我们想要的,所以确保 TextField 和标签之间有一个小空间,并将其放下。

我们需要给标签一些有意义的文本,所以在预览窗口中双击它,并输入 Number of items to retrieve。我们还需要给 TextField 一个 ID,以便我们可以与之交互,所以点击该组件,在检查器中展开代码部分,并将 fx:id 设置为 itemCount

我们的用户界面虽然基础,但现在我们已经尽可能完善了,所以保存文件,关闭 Scene Builder,并返回到 NetBeans。

保存用户偏好

为了使我们的新定义的用户界面能够连接到我们的控制器,我们需要创建实例变量来匹配控件与设置的 fx:id 属性,因此,我们将这些添加到 PreferencesController 中,如下所示:

    @FXML 
    protected Button savePrefs; 
    @FXML 
    protected Button cancel; 
    @FXML 
    protected TabPane tabPane; 

initialize() 方法中,我们需要为我们的 itemCount 字段添加支持加载已保存的值,因此我们需要稍微谈谈偏好设置。

Java 作为一种通用语言,使得编写任何你能够想象到的偏好存储策略成为可能。幸运的是,它还提供了一些不同的标准 API,允许你以更易于移植的方式实现,这些是 PreferencesProperties

java.util.Properties类自 JDK 1.0 版本以来就存在了,尽管其基本、无装饰的 API 可能让人一目了然,但它仍然是一个非常有用的抽象。在本质上,Properties是一个Hashtable实现,其中添加了从输入流和读取器加载数据的方法,以及将数据写入输出流和写入器的方法(除了其他一些相关方法)。所有的属性都被视为带有String键的String值。由于Properties是一个Hashtable,您仍然可以使用put()putAll()来存储非字符串数据,但如果你调用store(),这将导致ClassCastException,因此,最好避免这样做。

java.util.prefs.Preferences类是在 Java 1.4 中添加的,它是一个更加现代的 API。与属性不同,我们必须单独处理持久性,而偏好则为我们隐式地处理了这一点——我们不需要担心它是如何或何时被写入的。实际上,设置偏好的调用可能立即返回,而实际的持久化可能要过一段时间才会发生。Preferences API 的契约保证,即使在 JVM 关闭的情况下,偏好也会被持久化,前提是这是一个正常、有序的关闭(根据定义,如果 JVM 进程突然死亡,能做的事情很少)。

此外,用户也不必担心如何保存偏好设置。实际的底层存储是一个特定实现的细节。它可能是一个平面文件、特定于操作系统的注册表、数据库或某种目录服务器。对于好奇的人来说,实际的实现是通过在java.util.prefs.PreferencesFactory系统属性中指定类名来选择的。如果没有定义,系统将查找文件META-INF/services/java.util.prefs.PreferencesFactory(这是一种称为 SPI 的机制,我们将在稍后深入探讨),并使用那里定义的第一个类。最后,如果这些都失败了,将加载并使用底层平台的实现。

那么,应该选择哪一个呢?两者都可以像另一个一样工作,但你必须决定你是否想要控制信息存储的位置(Properties)或者实现上的简便(Preferences)。在某种程度上,可移植性也可能是一个考虑因素。例如,如果你有在某种移动或嵌入式设备上运行的 Java 代码,你可能没有权限写入文件系统,甚至可能根本不存在文件系统。为了展示这两个实现可能有多么相似,我们将实现这两个。

为了把我的观点摆得更加清楚,我希望尽可能多的代码可以在 Android 环境中重用。为了帮助实现这一点,我们将创建一个非常简单的接口,如下所示:

    public interface SunagoPreferences { 
      String getPreference(String key); 
      String getPreference(String key, String defaultValue); 
      Integer getPreference(String key, Integer defaultValue); 
      void putPreference(String key, String value); 
      void putPreference(String key, Integer value); 
    } 

我们只处理字符串和整数,因为应用的需求相当基础。接口定义好了,我们如何获取实现类的引用?为此,我们将使用我们已经简要提到过的技术——服务提供者接口(SPI)。

使用服务提供者接口的插件和扩展

当我们查看 Preferences 类以及实现的选择和加载方式时,我们已经提到了 SPI,但具体是什么呢?服务提供者接口是一个相对通用的术语,指的是第三方可以实现的接口(或者一个可以扩展的类,无论是否为抽象类),以提供额外功能、替换现有组件等。

简而言之,目标系统(例如,在我们之前的例子中就是 JDK 本身)的作者定义并发布了一个接口。理想情况下,这个系统会提供一个默认实现,但在所有情况下这并非必需。任何感兴趣的第三方都可以实现这个接口,注册它,然后目标系统就可以加载并使用它。这种方法的优点之一是目标系统可以很容易地扩展,而不需要与第三方耦合。也就是说,虽然第三方通过接口了解目标系统,但目标系统对第三方一无所知。它只是在操作它定义的接口。

这些第三方插件是如何在目标系统中注册的?第三方开发者会创建一个文本文件,使用特定目录中的特定文件。文件名与要实现的接口同名。以 Preferences 类的例子来说,将实现 java.util.prefs.PreferencesFactory 接口,因此文件名将是这个,它将位于库类路径根目录下的 META-INF/services 目录中。在一个基于 Maven 的项目中,该文件将在 src/main/resources/META-INF/services 中找到。文件只包含实现接口的类的名称。服务文件中也可以列出多个类,每个类占一行。但是否使用这些类取决于消费系统。

所有这些对我们来说意味着什么呢?如前所述,我们将难得地展示多个 Preferences 支持的实现。这两个类都足够小,我们可以展示 PropertiesPreferences 的用法,并使用 SPI 来选择一个使用。

让我们从基于 Properties 的实现开始:

    public class SunagoProperties implements SunagoPreferences { 
      private Properties props = new Properties(); 
      private final String FILE = System.getProperty("user.home")  
        + File.separator + ".sunago.properties"; 

      public SunagoProperties() { 
        try (InputStream input = new FileInputStream(FILE)) { 
          props.load(input); 
        } catch (IOException ex) { 
        } 
    } 

在前面的代码中,我们首先实现了我们的 SunagoPreferences 接口。然后我们创建了一个 Properties 类的实例,我们还定义了一个文件名和位置的常量,我们将它以系统无关的方式放在用户的家目录中。

使用 try-with-resources 处理资源

构造函数显示了之前没有讨论的有趣之处--try-with-resources。在 Java 8 之前,你可能写成这样:

    public SunagoProperties(int a) { 
      InputStream input = null; 
      try { 
        input = new FileInputStream(FILE); 
        props.load(input); 
      } catch  (IOException ex) { 
        // do something 
      } finally { 
          if (input != null) { 
            try { 
                input.close(); 
            } catch (IOException ex1) { 
                Logger.getLogger(SunagoProperties.class.getName()) 
                  .log(Level.SEVERE, null, ex1); 
            } 
          } 
        } 
    } 

之前的代码非常冗长,它在外部声明了一个InputStream,然后在try块中对其进行一些操作。在finally块中,我们尝试关闭InputStream,但首先必须检查它是否为 null。如果,比如说,文件不存在(这不会是第一次创建此类),将会抛出异常,并且input将为 null。如果不是 null,我们可以在它上面调用close(),但那可能会抛出IOException,因此我们必须将其包裹在一个try/catch块中。

Java 8 引入了 try-with-resources 构造,这使得代码更加简洁。如果一个对象是AutoCloseable的实例,那么它可以在try声明中定义,并且当try块的作用域结束时,无论是否抛出Exception,它都会自动关闭。这允许我们将原本可能需要十四行代码的功能,用四行代码表达,并且噪音更少。

除了AutoCloseable之外,请注意,我们通过Properties.load(InputStream)将文件中的任何现有值加载到我们的Properties实例中。

接下来,我们看到的是相当直接的 getter 和 setter 方法:

    @Override 
    public String getPreference(String key) { 
      return props.getProperty(key); 
    } 

    @Override 
    public String getPreference(String key, String defaultValue) { 
      String value = props.getProperty(key); 
      return (value == null) ? defaultValue : value; 
    } 

    @Override 
    public Integer getPreference(String key, Integer defaultValue) { 
      String value = props.getProperty(key); 
      return (value == null) ? defaultValue :  
        Integer.parseInt(value); 
    } 

    @Override 
    public void putPreference(String key, String value) { 
      props.put(key, value); 
      store(); 
    } 

    @Override 
    public void putPreference(String key, Integer value) { 
      if (value != null) { 
        putPreference(key, value.toString()); 
      } 
    } 

最终的方法是将我们的偏好写回的方法,具体如下:

    private void store() { 
      try (OutputStream output = new FileOutputStream(FILE)) { 
        props.store(output, null); 
      } catch (IOException e) { } 
    } 

最后这种方法看起来很像我们的构造函数,但我们创建了一个OutputStream,并调用Properties.store(OutputStream)将我们的值写入文件。请注意,我们从每个 put 方法中调用此方法,以确保尽可能忠实地将用户偏好持久化到磁盘。

基于偏好的实现看起来会是什么样子?并没有太大的不同。

    public class SunagoPreferencesImpl implements SunagoPreferences { 
      private final Preferences prefs = Preferences.userRoot() 
        .node(SunagoPreferencesImpl.class.getPackage() 
        .getName()); 
      @Override 
      public String getPreference(String key) { 
        return prefs.get(key, null); 
      } 
      @Override 
      public String getPreference(String key, String defaultValue) { 
        return prefs.get(key, defaultValue); 
      } 

      @Override 
      public Integer getPreference(String key,Integer defaultValue){ 
        return prefs.getInt(key, defaultValue); 
      } 
      @Override 
      public void putPreference(String key, String value) { 
        prefs.put(key, value); 
      } 
      @Override 
      public void putPreference(String key, Integer value) { 
        prefs.putInt(key, value); 
      } 
    } 

有两点需要注意。首先,我们不需要处理持久化,因为Preferences会为我们处理。其次,Preferences实例的创建需要一些注意。显然,我认为,我们希望这些偏好是针对用户的,所以我们从Preferences.userRoot()开始,以获取根偏好节点。然后我们请求存储我们偏好的节点,我们选择将其命名为我们的类的包名。

这会将事情置于何处?在 Linux 上,文件可能看起来像这样 ~/.java/.userPrefs/_!':!bw"t!#4!cw"0!'`!~@"w!'w!~@"z!'8!~g"0!#4!ag!5!')!c!!u!(:!d@"u!'%!~w"v!#4!}@"w!(!=/prefs.xml(是的,这是一个目录名)。在 Windows 上,这些偏好被保存在 Windows 注册表的HKEY_CURRENT_USERSOFTWAREJavaSoftPrefscom.steeplesoft.sunago.app键下。不过,除非你想直接与这些文件交互,否则它们的精确位置和格式仅仅是实现细节。有时,了解这些也是一件好事。

我们有两个实现,那么我们如何选择使用哪一个呢?在文件(包括为了清晰起见的源根)src/main/resources/META-INF/service/com.steeplesoft.sunago.api.SunagoPreferences中,我们可以放置以下两条中的一条:

    com.steeplesoft.sunago.app.SunagoPreferencesImpl 
    com.steeplesoft.sunago.app.SunagoProperties 

你可以列出两者,但只会选择第一个,我们现在就会看到这一点。为了简化问题,我们将其封装在一个实用方法中,如下所示:

    private static SunagoPreferences preferences; 
    public static synchronized 
          SunagoPreferences getSunagoPreferences() { 
        if (preferences == null) { 
          ServiceLoader<SunagoPreferences> spLoader =  
            ServiceLoader.load(SunagoPreferences.class); 
          Iterator<SunagoPreferences> iterator = 
            spLoader.iterator(); 
          preferences = iterator.hasNext() ? iterator.next() : null; 
        } 
        return preferences; 
    } 

在这里可能有点过度,我们通过将SunagoPreferences接口的实例声明为私有静态,并通过一个同步方法使其可用,该方法检查null并在需要时创建实例,来实现了一个单例。

虽然这很有趣,但不要让它分散你对方法核心的注意力。我们使用ServiceLoader.load()方法向系统请求SunagoPreferences接口的任何实现。再次强调,为了明确,它不会在系统中选择任何实现,而只有那些在我们之前描述的服务文件中列出的实现。使用ServiceLoader<SunagoPreferences>实例,我们获取一个迭代器,如果它有一个条目(iterator.hasNext()),我们就返回该实例(iterator.next())。如果没有,我们返回null。这里有一个NullPointerException的风险,因为我们正在返回null,但我们也在提供一个实现,因此避免了这种风险。然而,在你的代码中,你需要确保你有实现,就像我们在这里所做的那样,或者确保消费代码已经准备好处理null

添加网络 - Twitter

到目前为止,我们有一个相当基础的应用程序,它可以保存和加载其首选项,但让我们回到我们在这里的目的,开始连接到社交网络。我们希望开发一个框架,使其能够轻松地添加对不同社交网络的支持。技术上,正如我们很快就会看到的,网络甚至不需要是社交的,因为唯一表明特定类型源的是涉及的类和接口的名称。然而,实际上,我们将专注于社交网络,我们将使用几个不同的网络来展示一些多样性。为此,我们将从 Twitter 开始,这是一个广受欢迎的微博平台,以及 Instagram,这是一个越来越以照片为中心的网络,现在它是 Facebook 的一部分。

说到 Facebook,为什么我们不展示与该社交网络的集成呢?两个原因——一是它与 Twitter 没有显著的不同,所以没有太多新内容可以介绍;二是最重要的是,Facebook 提供的权限使得以我们感兴趣的方式与之集成几乎是不可能的。例如,读取用户主页时间线(或墙)的权限仅授予针对那些 Facebook 目前不可用的平台的应用程序,而完全不授予桌面应用程序,这是我们在这里的目标。

如前所述,我们希望提供一个方法来添加更多网络,而无需更改核心应用程序,因此我们需要开发一个 API。在这里我们将讨论这个 API 的大致完成状态(任何软件真的有完成的时候吗?)。然而,虽然你会看到一个相当完整的 API,但有一个警告——试图从那个抽象开始创建抽象——也就是说,从头开始编写抽象——很少会有好结果。通常最好编写一个具体的实现来更好地理解所需的细节,然后提取抽象。你在这里看到的是这个过程的结果,因此这个过程不会在这里进行深入讨论。

注册为 Twitter 开发者

要创建一个与 Twitter 集成的应用程序,我们需要创建一个 Twitter 开发者账户,然后创建一个 Twitter 应用程序。为了创建账户,我们需要访问dev.twitter.com,并点击加入按钮。一旦你创建了你的开发者账户,你可以点击“我的应用”链接进入apps.twitter.com。在这里,我们需要点击创建新应用按钮,这将带我们到一个看起来有点像这样的表单:

图片

虽然我们正在开发的应用程序名为Sunago,但你无法使用这个名字,因为它已经被占用;你必须创建一个你自己的独特名称,假设你打算自己运行这个应用程序。一旦你创建了应用程序,你将被带到新应用的应用管理页面。从该页面,你可以管理你应用的权限和密钥,如果需要,你也可以删除你的应用。

在这个页面上需要注意的一件事,因为我们很快就会用到,就是如何找到你应用的消费者密钥和密钥。这些是长数字字母字符串,你的应用将使用它们来与 Twitter 的服务进行身份验证。为了代表用户与 Twitter 交互——我们的最终目标——需要另一组令牌,我们很快就会获取。你的消费者密钥和密钥——特别是消费者密钥——应该保密。如果这个组合被公开泄露,其他用户将能够冒充你的应用,如果他们滥用服务,可能会给你带来严重的麻烦。因此,你不会在任何地方看到我生成的密钥/密钥组合,无论是这本书还是源代码中,这就是为什么你需要生成自己的原因。

现在我们已经拥有了消费者密钥和密钥,我们需要决定如何与 Twitter 进行通信。Twitter 提供了一个公共 REST API,他们在网站上进行了文档说明。如果我们愿意,我们可以选择某种 HTTP 客户端,并开始进行调用。然而,出于简单性、清晰性、健壮性、容错性等方面的考虑,我们可能更倾向于使用某种高级库。幸运的是,确实存在这样一个库,即 Twitter4J,它将使我们的集成变得更加简单和干净(对于好奇的人来说,Twitter4J 有 200 多个 Java 类。虽然我们不需要所有通过 REST API 表示并公开的功能,但它应该能给你一个编写 Twitter REST 接口合理包装所需工作范围的印象)。

如前所述,我们希望在不需要更改核心应用程序的情况下将网络添加到 Sunago 中,因此我们将编写我们的 Twitter 集成到一个单独的 Maven 模块中。这将要求我们将为 Sunago 编写的部分代码提取到另一个模块中。然后,我们的 Twitter 模块和主要应用程序模块将添加对这个新模块的依赖。由于我们将有多个模块在运行,我们将确保指出每个类属于哪个模块。完成之后,我们的项目依赖关系图将如下所示:

图片

从技术上讲,我们之所以在应用程序模块和 Instagram 以及 Twitter 模块之间显示依赖关系,是因为我们将它们作为同一项目的一部分构建。正如我们将看到的,第三方开发者可以轻松地开发一个独立的模块,将其添加到应用程序的运行时类路径中,并看到应用程序中的变化,而无需这种构建级别的依赖。然而,希望这个图能帮助解释模块之间的关系。

将 Twitter 偏好添加到 Sunago

让我们从向我们的偏好屏幕添加 Twitter 开始。在我们能够进行任何集成之前,我们需要能够配置应用程序,或者更准确地说,Twitter 模块,以便它可以以特定用户身份连接。为了实现这一点,我们将向 API 模块添加一个新接口,如下所示:

    public abstract class SocialMediaPreferencesController { 
      public abstract Tab getTab(); 
      public abstract void savePreferences(); 
    } 

此接口将为 Sunago 提供两个钩子进入模块——一个让模块有机会绘制自己的偏好用户界面,另一个允许它保存这些偏好。然后我们可以在我们的模块中实现这一点。在我们这样做之前,让我们看看应用程序将如何找到这些实现,以便它们可以被使用。为此,我们再次转向 SPI。在 Sunago 的PreferencesController接口中,我们添加以下代码:

    private List<SocialMediaPreferencesController> smPrefs =  
      new ArrayList<>(); 
    @Override 
    public void initialize(URL url, ResourceBundle rb) { 
      itemCount.setText(SunagoUtil.getSunagoPreferences() 
       .getPreference(SunagoPrefsKeys.ITEM_COUNT.getKey(), "50")); 
      final ServiceLoader<SocialMediaPreferencesController>  
       smPrefsLoader = ServiceLoader.load( 
         SocialMediaPreferencesController.class); 
       smPrefsLoader.forEach(smp -> smPrefs.add(smp)); 
       smPrefs.forEach(smp -> tabPane.getTabs().add(smp.getTab())); 
    } 

我们有一个实例变量来保存我们找到的任何SocialMediaPreferencesController实例。接下来,在initialize()中,我们调用熟悉的ServiceLoader.load()方法来查找任何实现,然后将它们添加到我们之前创建的List中。一旦我们有了控制器的列表,我们就对每个控制器调用getTab(),将返回的Tab实例添加到PreferencesController接口的tabPane中。

在澄清了加载部分之后,现在让我们看看 Twitter 首选项用户界面实现。我们首先实现将支持这个用户界面部分的控制器,如下所示:

    public class TwitterPreferencesController  
      extends SocialMediaPreferencesController { 
        private final TwitterClient twitter; 
        private Tab tab; 

        public TwitterPreferencesController() { 
          twitter = new TwitterClient(); 
        } 

        @Override 
        public Tab getTab() { 
          if (tab == null) { 
            tab = new Tab("Twitter"); 
            tab.setContent(getNode()); 
          } 

          return tab; 
    } 

我们将在稍后查看TwitterClient,但首先,关于getTab()的一个说明。请注意,我们创建了需要返回的Tab实例,但我们将其内容的创建委托给getNode()方法。Tab.setContent()允许我们通过单个调用完全替换标签页的内容,这是我们将在下面使用的东西。getNode()方法看起来像这样:

    private Node getNode() { 
      return twitter.isAuthenticated() ? buildConfigurationUI() : 
        buildConnectUI(); 
    } 

如果用户已经进行了身份验证,那么我们想要展示一些配置选项。如果没有,那么我们需要提供一种连接到 Twitter 的方法。

    private Node buildConnectUI() { 
      HBox box = new HBox(); 
      box.setPadding(new Insets(10)); 
      Button button = new Button(MessageBundle.getInstance() 
       .getString("connect")); 
      button.setOnAction(event -> connectToTwitter()); 

      box.getChildren().add(button); 

      return box; 
    } 

在这个简单的用户界面中,我们创建一个HBox主要是为了添加一些填充。如果没有传递给setPadding()方法的new Insets(10)实例,我们的按钮就会紧贴窗口的顶部和左侧边缘,这从视觉上并不吸引人。接下来,我们创建Button,并设置onAction处理程序(现在忽略那个构造函数参数)。

有趣的部分隐藏在connectToTwitter中,如下代码所示:

    private void connectToTwitter() { 
      try { 
        RequestToken requestToken =  
          twitter.getOAuthRequestToken(); 
        LoginController.showAndWait( 
          requestToken.getAuthorizationURL(), 
           e -> ((String) e.executeScript( 
             "document.documentElement.outerHTML")) 
              .contains("You've granted access to"), 
               e -> { 
                 final String html =  
                   "<kbd aria-labelledby="code-desc"><code>"; 
                    String body = (String) e.executeScript( 
                      "document.documentElement.outerHTML"); 
                    final int start = body.indexOf(html) +  
                     html.length(); 
                    String code = body.substring(start, start+7); 
                    saveTwitterAuthentication(requestToken, code); 
                    showConfigurationUI(); 
               }); 
      } catch (TwitterException ex) { 
        Logger.getLogger(getClass().getName()) 
          .log(Level.SEVERE, null, ex); 
      } 
    } 

OAuth 和登录到 Twitter

我们将在稍后绕道进入LoginController,但首先,让我们确保我们理解这里发生了什么。为了代表用户登录 Twitter,我们需要生成一个 OAuth 请求令牌,从中我们可以获取一个授权 URL。这些细节被 Twitter4J API 很好地隐藏起来,但基本上,这是在应用程序管理页面上列出的 OAuth 授权 URL,请求令牌作为查询字符串传递。正如我们将看到的,这个 URL 将在WebView中打开,提示用户对 Twitter 进行身份验证并授权应用程序(或拒绝):

图片

如果用户成功身份验证并授权应用程序,WebView将被重定向到成功页面,该页面显示一个我们需要捕获的数字代码,以完成收集所需的身份验证/授权凭据。成功页面可能看起来像这样:

图片

对于不熟悉 OAuth 的人来说,这允许我们作为用户进行认证,现在和未来的任意时刻,而无需存储用户的实际密码。我们应用程序与 Twitter 之间握手的结果是一个令牌和令牌密钥,我们将将其传递给 Twitter 以进行认证。只要这个令牌有效(用户可以通过 Twitter 的网页界面随时使其无效),我们就可以连接并代表该用户进行操作。如果密钥遭到破坏,用户可以撤销密钥,这只会影响预期的应用程序和任何试图使用被盗密钥的人。

LoginController 是 API 模块的一部分,为我们处理所有样板代码,如下代码所示:

    public class LoginController implements Initializable { 
      @FXML 
      private WebView webView; 
      private Predicate<WebEngine> loginSuccessTest; 
      private Consumer<WebEngine> handler; 

      public static void showAndWait(String url,  
       Predicate<WebEngine> loginSuccessTest, 
       Consumer<WebEngine> handler) { 
         try { 
           fxmlLoader loader = new fxmlLoader(LoginController 
             .class.getResource("/fxml/login.fxml")); 

           Stage stage = new Stage(); 
           stage.setScene(new Scene(loader.load())); 
           LoginController controller =  
              loader.<LoginController>getController(); 
           controller.setUrl(url); 
           controller.setLoginSuccessTest(loginSuccessTest); 
           controller.setHandler(handler); 

           stage.setTitle("Login..."); 
           stage.initModality(Modality.APPLICATION_MODAL); 

           stage.showAndWait(); 
         } catch (IOException ex) { 
           throw new RuntimeException(ex); 
         } 
    } 

上述代码是一个基本的 FXML 支持的 JavaFX 控制器,但我们确实有一个静态辅助方法来处理创建、配置和显示实例的细节。我们使用 FXML 加载场景,获取控制器(它是封装类的实例),设置 loginSuccessTesthandler 属性,然后显示对话框。

loginSuccessTesthandler 看起来奇怪吗?它们是 Java 8 函数式接口 Predicate<T>Consumer<T> 的实例。Predicate 是一个函数式接口,它接受一个类型,在我们的例子中是 WebEngine,并返回一个 boolean。它旨在检查给定指定类型的变量是否满足某种条件。在这个例子中,我们调用 WebEngine.executeScript().contains() 来提取文档的一部分,并查看它是否包含表示我们已被重定向到登录成功页面的特定文本。

Consumer<T> 是一个函数式接口(或者在我们的情况下,是一个 lambda),它接受指定类型的单个参数,并返回 void。我们的处理器是一个 Consumer,当 Predicate 返回 true 时被调用。lambda 从 HTML 页面提取代码,调用 saveTwitterAuthentication() 以完成用户认证,然后调用 showConfigurationUI() 来更改用户界面,以便用户可以配置与 Twitter 相关的设置。

方法 saveTwitterAuthentication() 非常直接,具体如下所示:

    private void saveTwitterAuthentication(RequestToken requestToken,
     String code) { 
       if (!code.isEmpty()) { 
         try { 
           AccessToken accessToken = twitter 
             .getAcccessToken(requestToken, code); 
           prefs.putPreference(TwitterPrefsKeys.TOKEN.getKey(),  
             accessToken.getToken()); 
           prefs.putPreference(TwitterPrefsKeys.TOKEN_SECRET.getKey(),  
             accessToken.getTokenSecret()); 
         } catch (TwitterException ex) { 
           Logger.getLogger(TwitterPreferencesController 
             .class.getName()).log(Level.SEVERE, null, ex); 
         } 
       } 
    } 

方法 twitter.getAccessToken() 接收我们的请求令牌和从网页中提取的代码,并向 Twitter REST 端点发送一个 HTTP POST 请求,生成我们需要的令牌密钥。当这个请求返回时,我们将令牌和令牌密钥存储到我们的 Preferences 存储中(同样,对此一无所知)。

方法 showConfigurationUI() 以及相关方法也应该很熟悉。

    private void showConfigurationUI() { 
      getTab().setContent(buildConfigurationUI()); 
    } 
    private Node buildConfigurationUI() { 
      VBox box = new VBox(); 
      box.setPadding(new Insets(10)); 

      CheckBox cb = new CheckBox(MessageBundle.getInstance() 
        .getString("homeTimelineCB")); 
      cb.selectedProperty().addListener( 
        (ObservableValue<? extends Boolean> ov,  
          Boolean oldVal, Boolean newVal) -> { 
            showHomeTimeline = newVal; 
          }); 

      Label label = new Label(MessageBundle.getInstance() 
        .getString("userListLabel") + ":"); 

      ListView<SelectableItem<UserList>> lv = new ListView<>(); 
      lv.setItems(itemList); 
      lv.setCellFactory(CheckBoxListCell.forListView( 
        item -> item.getSelected())); 
      VBox.setVgrow(lv, Priority.ALWAYS); 

      box.getChildren().addAll(cb, label, lv); 
      showTwitterListSelection(); 

      return box;
    } 

在此方法中新增的一项是添加到 CheckBoxselectedProperty 上的监听器。每次选中值改变时,我们的监听器都会被调用,这将设置 showHomeTimeline 布尔值的值。

ListView也需要特别注意。注意参数化类型,SelectableItem<UserList>。那是什么?那是一个我们创建的抽象类,用于包装用于CheckBoxListCell的条目,这在setCellFactory()调用中可以看到。这个类看起来是这样的:

    public abstract class SelectableItem<T> { 
      private final SimpleBooleanProperty selected =  
        new SimpleBooleanProperty(false); 
      private final T item; 
      public SelectableItem(T item) { 
        this.item = item; 
      } 
      public T getItem() { 
        return item; 
      } 
      public SimpleBooleanProperty getSelected() { 
        return selected; 
      } 
    } 

这个类位于 API 模块中,它是一个简单的包装器,围绕任意类型添加一个SimpleBooleanProperty。我们看到当设置单元格工厂时如何操作这个属性--lv.setCellFactory(CheckBoxListCell.forListView(item -> item.getSelected()))。我们通过getSelected()方法公开SimpleBooleanProperty,这个方法由CheckBoxListCell用来设置和读取每一行的状态。

我们最终的用户界面相关方法是这个:

    private void showTwitterListSelection() { 
      List<SelectableItem<UserList>> selectable =  
        twitter.getLists().stream() 
         .map(u -> new SelectableUserList(u)) 
         .collect(Collectors.toList()); 
      List<Long> selectedListIds = twitter.getSelectedLists(prefs); 
      selectable.forEach(s -> s.getSelected() 
        .set(selectedListIds.contains(s.getItem().getId()))); 
      itemList.clear(); 
      itemList.addAll(selectable); 
    } 

使用相同的SelectableItem类,我们从 Twitter 请求用户可能创建的所有列表,我们将它们包装在SelectableUserList中,这是一个覆盖toString()方法的SelectableItem子类,以在ListView中提供用户友好的文本。我们加载首选项中的任何已检查的列表,设置它们各自的布尔值/复选框,并更新我们的ObservableList以及用户界面。

我们需要实现的最后一个方法来满足SocialMediaPreferencesController契约是savePreferences(),如下所示:

    public void savePreferences() { 
      prefs.putPreference(TwitterPrefsKeys.HOME_TIMELINE.getKey(),  
       Boolean.toString(showHomeTimeline)); 
      List<String> selectedLists = itemList.stream() 
       .filter(s -> s != null) 
       .filter(s -> s.getSelected().get()) 
       .map(s -> Long.toString(s.getItem().getId())) 
       .collect(Collectors.toList()); 
      prefs.putPreference(TwitterPrefsKeys.SELECTED_LISTS.getKey(),  
       String.join(",", selectedLists)); 
    } 

这主要是将用户的选项保存到首选项中的直接操作,但列表处理值得指出。我们不是手动遍历列表中的每个项目,而是可以使用流并应用几个filter()操作来去除对我们无兴趣的条目,然后将每个通过筛选的SelectableUserList映射到Long(这是列表的 ID),最后将它们收集到一个List<String>中。我们使用String.join()连接这个List,并将其写入我们的首选项。

添加 Twitter 模型

我们还需要实现其他几个接口来完成我们的 Twitter 支持。第一个,也是更简单的一个是SocialMediaItem

    public interface SocialMediaItem { 
      String getProvider(); 
      String getTitle(); 
      String getBody(); 
      String getUrl(); 
      String getImage(); 
      Date getTimestamp(); 
    } 

这个先前的接口为我们提供了一个很好的抽象,可以覆盖社交网络可能返回的各种类型的数据,而不会因为大多数(或许多)网络未使用的字段而过于沉重。这个Tweet类的 Twitter 实现如下所示:

    public class Tweet implements SocialMediaItem { 
      private final Status status; 
      private final String url; 
      private final String body; 

      public Tweet(Status status) { 
        this.status = status; 
        body = String.format("@%s: %s (%s)",  
          status.getUser().getScreenName(), 
          status.getText(), status.getCreatedAt().toString()); 
        url = String.format("https://twitter.com/%s/status/%d", 
          status.getUser().getScreenName(), status.getId()); 
    } 

从 Twitter4J 类Status中提取我们感兴趣的信息,并将其存储在实例变量中(它们的 getter 没有显示,因为它们只是简单的 getter)。对于getImage()方法,我们尽力从推文中提取任何图像,如下所示:

    public String getImage() { 
      MediaEntity[] mediaEntities = status.getMediaEntities(); 
      if (mediaEntities.length > 0) { 
        return mediaEntities[0].getMediaURLHttps(); 
      } else { 
          Status retweetedStatus = status.getRetweetedStatus(); 
          if (retweetedStatus != null) { 
            if (retweetedStatus.getMediaEntities().length > 0) { 
              return retweetedStatus.getMediaEntities()[0] 
               .getMediaURLHttps(); 
            } 
          } 
        } 
      return null; 
    } 

实现 Twitter 客户端

第二个接口是SocialMediaClient。这个接口不仅作为 Sunago 与任意社交网络集成交互的抽象,而且还作为对感兴趣的开发者的指南,向他们展示集成的最低要求。它看起来是这样的:

    public interface SocialMediaClient { 
      void authenticateUser(String token, String tokenSecret); 
      String getAuthorizationUrl(); 
      List<? Extends SocialMediaItem> getItems(); 
      boolean isAuthenticated(); 
    } 

对于 Twitter 支持,这个前置接口是通过TwitterClient类实现的。类的大部分内容相当基础,所以我们在这里不会重复展示(如果您想了解详细信息,可以在源代码库中查看),但有一个实现细节可能值得花些时间研究。这个方法是processList(),如下所示:

    private List<Tweet> processList(long listId) { 
      List<Tweet> tweets = new ArrayList<>(); 

      try { 
        final AtomicLong sinceId = new AtomicLong( 
          getSinceId(listId)); 
        final Paging paging = new Paging(1,  
          prefs.getPreference(SunagoPrefsKeys. 
          ITEM_COUNT.getKey(), 50), sinceId.get()); 
        List<Status> statuses = (listId == HOMETIMELINE) ?  
          twitter.getHomeTimeline(paging) : 
           twitter.getUserListStatuses(listId, paging); 
        statuses.forEach(s -> { 
          if (s.getId() > sinceId.get()) { 
            sinceId.set(s.getId()); 
          } 
          tweets.add(new Tweet(s)); 
        }); 
        saveSinceId(listId, sinceId.get()); 
      } catch (TwitterException ex) { 
          Logger.getLogger(TwitterClient.class.getName()) 
           .log(Level.SEVERE, null, ex); 
        } 
        return tweets; 
    } 

在这个最后的方法中,有几件事情在进行。首先,我们希望限制实际检索的推文数量。如果这是第一次使用该应用,或者很长时间以来的第一次使用,可能会有大量的推文。检索所有这些推文在网络上、内存和可能处理时间上都会相当昂贵。我们使用 Twitter4J 的Paging对象来实现这个限制。

我们也不希望检索我们已经拥有的推文,因此,对于每个列表,我们保留一个sinceId,我们可以将其传递给 Twitter API。它将使用这个 ID 来查找 ID 大于sinceId的指定数量的推文。

将所有这些封装在Paging对象中,我们调用twitter.getHomeTimeline(),如果列表 ID 是-1(我们用来标识主时间线的内部 ID),或者调用twitter.getUserListStatus()来获取用户定义的列表。对于返回的每个Status,我们更新sinceId(我们使用AtomicLong来建模,因为任何在 lambda 内部使用的变量都必须是 final 或实际上是 final 的),并将推文添加到我们的List中。在退出之前,我们将列表的sinceId存储在我们的内存存储中,然后返回 Twitter 列表的推文。

简要了解国际化与本地化

虽然有些基础,但我们的 Twitter 集成现在已经完成,因为它满足了我们对网络的函数需求。然而,我们还需要快速查看一段代码。在之前的代码示例中,您可能已经注意到类似这样的代码:MessageBundle.getInstance().getString("homeTimelineCB")。这是什么,它做什么?

MessageBundle类是对 JDK 提供的国际化本地化设施(也称为 i18n 和 l10n,其中数字代表从单词中删除的字母数量)的一个小包装。这个类的代码如下:

    public class MessageBundle { 
      ResourceBundle messages =  
        ResourceBundle.getBundle("Messages", Locale.getDefault()); 

      private MessageBundle() { 
      } 

      public final String getString(String key) { 
        return messages.getString(key); 
      } 

      private static class LazyHolder { 
        private static final MessageBundle INSTANCE =  
          new MessageBundle(); 
      } 

      public static MessageBundle getInstance() { 
        return LazyHolder.INSTANCE; 
      } 
    } 

这里有两个主要的项目值得关注。我们将从类的末尾开始,即getInstance()方法。这是一个所谓的按需初始化持有者IODH)模式的例子。在 JVM 中有一个MessageBundle类的单个、静态实例。然而,它不会初始化,直到getInstance()方法被调用。这是通过利用 JVM 加载和初始化静态的方式实现的。一旦以任何方式引用了一个类,它就会被加载到ClassLoader中,此时该类上的任何静态都会被初始化。私有的静态类LazyHolder不会在 JVM 确定需要访问它之前初始化。一旦我们调用getInstance(),它引用LazyHolder.INSTANCE,类就会被初始化,并创建单例实例。

应该注意的是,有绕过我们试图实现的单例特性的方法(例如,通过反射),但我们的用例在这里不需要担心这种攻击。

实际功能是在类的第一行实现的,如下所示

    ResourceBundle messages =  
      ResourceBundle.getBundle("Messages", Locale.getDefault()); 

根据 Javadoc 的描述,ResourceBundle文件包含特定区域的对象。通常,这意味着字符串,正如在我们的案例中一样。getBundle()方法将尝试查找并加载一个具有指定区域名称的包。在我们的案例中,我们正在寻找一个名为Messages的包。技术上,我们正在寻找一个具有共享基础名称Messages的包族中的包。系统将使用指定的Locale来查找正确的文件。这种解析将遵循Locale使用的相同查找逻辑,因此getBundle()方法将返回具有最具体匹配名称的包。

假设我们在我的电脑上运行这个应用程序。我住在美国,所以我的系统默认的区域设置是en_US。根据Locale查找规则,那么getBundle()将尝试按以下顺序定位文件:

  1. Messages_en_US.properties.

  2. Messages_en.properties.

  3. Messages.properties.

系统将从最具体的文件开始,直到找到请求的键。如果任何文件中都没有找到,将抛出MissingResourceException。每个文件都由键/值对组成。我们的Messages.properties文件看起来如下:

    homeTimelineCB=Include the home timeline 
    userListLabel=User lists to include 
    connect=Connect 
    twitter=Twitter 

这只是键到本地化文本的简单映射。我们可以有一个Messages_es.properties文件,包含以下行:

    userListLabel=Listas de usuarios para incluir 

如果文件中只有这个条目,那么文件中的这个标签将是西班牙语,其余的都将是来自Message.properties的默认值,在我们的案例中是英语。

使我们的 JAR 文件变得庞大

这样,我们的实现现在就完成了。然而,在我们可以按照预期的方式使用它之前,我们需要进行一些构建更改。如果您还记得章节开头对需求进行的讨论,我们希望构建一个系统,该系统可以轻松地允许第三方开发者编写模块,这些模块将为任意社交网络提供支持,而无需修改核心应用程序。为了提供这种功能,这些开发者需要提供一个 Sunago 用户可以放入文件夹中的 JAR。当应用程序启动时,新的功能现在可用。

因此,我们的任务就是捆绑所有必需的代码。按照目前的项目状态,创建了一个单一的 JAR 文件,其中只包含我们的类。但这还不够,因为我们依赖于 Twitter4J jar。其他模块可能有更多的依赖项。要求用户放入,比如说,六个或更多的 jar 文件可能有点过分。幸运的是,Maven 有一个机制可以让我们完全避免这个问题:shade 插件。

通过在我们的构建中配置此插件,我们可以生成一个单一的 jar 文件,它包含我们的类和资源,以及项目中声明的每个依赖项。这通常被称为fat jar,如下所示:

    <build> 
      <plugins> 
        <plugin> 
          <artifactId>maven-shade-plugin</artifactId> 
            <version>${plugin.shade}</version> 
              <executions> 
                <execution> 
                  <phase>package</phase> 
                    <goals> 
                      <goal>shade</goal> 
                    </goals> 
                  </execution> 
              </executions> 
        </plugin> 
      </plugins> 
    </build> 

这是一个官方的 Maven 插件,因此我们可以省略groupId,并且我们在 POM 继承树的某个地方定义了一个属性,plugin.shade。当运行包阶段时,此插件的 shade 目标将执行并构建我们的 fat jar。

$ ll target/*.jar
  total 348
  -rwx------+ 1 jason None  19803 Nov 20 19:22 original-twitter-1.0-
  SNAPSHOT.jar
  -rwx------+ 1 jason None 325249 Nov 20 19:22 twitter-1.0-
  SNAPSHOT.jar  

原始的 jar 文件,体积相对较小,被重命名为original-twitter-1.0-SNAPSHOT.jar,而 fat jar 则使用配置的最终名称。正是这个 fat jar 被安装在本地的 Maven 仓库中,或者部署到如 Artifactory 这样的工件管理器。

尽管如此,还存在一个小问题。我们的推特模块依赖于 API 模块,以便能够看到应用程序暴露的接口和类。目前,这些内容也被包含在 fat jar 中,而我们并不希望这样,因为这可能会在某些情况下导致一些ClassLoader问题。为了防止这种情况,我们将这个依赖项标记为provided,如下所示:

    <dependency> 
      <groupId>${project.groupId}</groupId> 
      <artifactId>api</artifactId> 
      <version>${project.version}</version> 
      <scope>provided</scope> 
    </dependency> 

如果我们现在执行mvn clean install,我们将得到一个包含我们所需捆绑的类,并且已经准备好分发的 fat jar。

为了尽可能简化,我们只需在 Sunago 的应用模块中声明对这个 jar 的依赖,如下所示:

    <dependencies> 
      <dependency> 
        <groupId>${project.groupId}</groupId> 
        <artifactId>api</artifactId> 
        <version>${project.version}</version> 
      </dependency> 
      <dependency> 
        <groupId>${project.groupId}</groupId> 
        <artifactId>twitter</artifactId> 
        <version>${project.version}</version> 
      </dependency> 
    </dependencies> 

如果我们现在运行 Sunago,我们将看到推特被添加到我们的设置屏幕上,一旦连接并配置,我们将在主屏幕上看到推文。我们还会注意到主屏幕有点单调,更重要的是,它不提供刷新内容的方式,所以让我们来修复这个问题。

添加刷新按钮

在“项目”窗口中,找到sunago.fxml,右键单击它,然后选择“编辑”。我们将手动进行这个用户界面更改,只是为了积累经验。向下滚动,直到找到关闭的Menubar标签(</Menubar>)。在其后的行中,插入以下这些行:

    <ToolBar > 
      <items> 
        <Button fx:id="refreshButton" /> 
        <Button fx:id="settingsButton" /> 
      </items> 
    </ToolBar> 

SunagoController中,我们需要添加以下实例变量:

    @FXML 
    private Button refreshButton; 
    @FXML 
    private Button settingsButton; 

然后,在initialize()中,我们需要这样设置它们:

    refreshButton.setGraphic(getButtonImage("/images/reload.png")); 
    refreshButton.setOnAction(ae -> loadItemsFromNetworks()); 
    refreshButton.setTooltip(new Tooltip("Refresh")); 

    settingsButton.setGraphic(getButtonImage("/images/settings.png")); 
    settingsButton.setOnAction(ae -> showPreferences(ae)); 
    settingsButton.setTooltip(new Tooltip("Settings")); 

注意,我们做的不仅仅是设置动作处理器。我们首先调用setGraphic()。记得在我们讨论 Twitter 首选项标签时,调用setGraphic()会替换掉你指定的Node子节点。在这两个例子中,Node是一个ImageView,它来自getButtonImage()方法。

    private ImageView getButtonImage(String path) { 
      ImageView imageView = new ImageView( 
        new Image(getClass().getResourceAsStream(path))); 
      imageView.setFitHeight(32); 
      imageView.setPreserveRatio(true); 
      return imageView; 
    } 

在我们设置动作处理器之后,我们还设置了一个工具提示。当用户将鼠标悬停在按钮上时,这将给我们的图形按钮一个文本描述,就像在这个屏幕截图中所见:

图片

值得一看的是刷新按钮的动作处理器,如下所示:

    private void loadItemsFromNetworks() { 
      List<SocialMediaItem> items = new ArrayList<>(); 
      clientLoader.forEach(smc -> { 
        if (smc.isAuthenticated()) { 
            items.addAll(smc.getItems()); 
        } 
      }); 

      items.sort((o1, o2) ->  
        o2.getTimestamp().compareTo(o1.getTimestamp())); 
      entriesList.addAll(0, items); 
    } 

这是我们从initialize()中调用的相同方法。使用我们之前讨论的服务提供者接口,我们遍历系统中可用的每个SocialMediaClient。如果客户端已对其网络进行认证,我们调用getItems()方法,并将它可能返回的内容添加到本地变量items中。一旦我们查询了系统中配置的所有网络,我们就对列表进行排序。这将导致各种网络的条目相互交织,因为它们是按时间戳降序排列的。然后,我们将这个排序后的列表添加到我们的ObservableList的头部,即零元素,这样它们就会在用户界面中出现在列表的顶部。

添加另一个网络 - Instagram

为了我们可以看到另一种类型的集成,以及展示我们定义的接口如何使添加新网络相对快速和容易,让我们向 Sunago 添加一个额外的网络——Instagram。虽然 Instagram 属于 Facebook,但在撰写本文时,其 API 比社交媒体巨头更为宽松,因此我们可以相对容易地添加一个有趣的集成。

与 Twitter 类似,我们在处理与 Instagram API 交互的方式上有选择权。就像 Twitter 一样,Instagram 提供了一个使用 OAuth 进行安全保护的公共 REST API。同样,就像 Twitter 一样,由于所需的努力水平,手动实现一个客户端来消费这些 API 并不是一个吸引人的提议。再次强调,除非有充分的理由编写自己的客户端库,否则如果可用,我建议使用某种客户端包装器应该是首选路线。幸运的是,有一个——jInstagram。

注册为 Instagram 开发者

在开始编写我们的客户端之前,我们需要向服务注册一个新的 Instagram 客户端。我们通过首先创建(如果需要)一个 Instagram 开发者账户在www.instagram.com/developer来完成这个操作。一旦我们有了账户,我们需要通过点击页面上的“注册您的应用程序”按钮或直接访问www.instagram.com/developer/clients/manage/来注册我们的应用程序。从这里,我们需要点击“注册新客户端”,这将显示此表单:

一旦你注册了新的客户端,你可以在生成的网页上点击“管理”按钮来获取你的客户端 ID 和密钥。请保留这些信息,因为你很快就会需要它们。

接下来,我们将开始实际的客户端创建,就像我们为 Twitter 模块所做的那样。不过,我们将称之为Sunago - InstagramartifactIdinstagram。我们还将继续添加 jInstagram 依赖项,如下所示:

    <artifactId>instagram</artifactId> 
    <name>Sunago - Instagram</name> 
    <packaging>jar</packaging> 
    <dependencies> 
      <dependency> 
        <groupId>${project.groupId}</groupId> 
        <artifactId>api</artifactId> 
        <version>${project.version}</version> 
        <scope>provided</scope> 
      </dependency> 
      <dependency> 
        <groupId>com.sachinhandiekar</groupId> 
        <artifactId>jInstagram</artifactId> 
        <version>1.1.8</version> 
      </dependency> 
    </dependencies> 

注意,我们已将 Sunago api依赖项添加为已提供的作用域。我们还需要添加 Shade 插件配置,它看起来就像在 Twitter 模块中一样,所以这里没有展示。

实现 Instagram 客户端

在我们创建了新的模块之后,我们需要创建三个特定的项目来满足 Sunago API 模块提供的契约。我们需要SocialMediaPreferencesControllerSocialMediaClientSocialMediaItem

我们的SocialMediaPreferencesController实例是InstagramPreferencesController。它具有接口所需的相同getTab()方法,如下所示:

    public Tab getTab() { 
      if (tab == null) { 
        tab = new Tab(); 
        tab.setText("Instagram"); 
        tab.setContent(getNode()); 
      } 

      return tab; 
    } 

    private Node getNode() { 
      Node node = instagram.isAuthenticated() 
        ? buildConfigurationUI() : buildConnectUI(); 
      return node; 
    } 

为了节省时间和空间,在这个例子中,我们让 Instagram 的实现比为 Twitter 创建的实现要简单得多,所以用户界面定义没有太多兴趣。然而,身份验证处理很有趣,因为尽管它使用了与 Twitter 相同的 OAuth 流程,但数据返回的方式更容易消费。连接按钮调用此方法:

    private static final String CODE_QUERY_PARAM = "code="; 
    private void showConnectWindow() { 
      LoginController.showAndWait(instagram.getAuthorizationUrl(), 
        e -> e.getLocation().contains(CODE_QUERY_PARAM), 
        e -> { 
          saveInstagramToken(e.getLocation()); 
          showInstagramConfig(); 
        }); 
    } 

这使用了我们在 Twitter 中看到的LoginController,但我们的PredicateConsumer要简洁得多。用户被重定向到的页面将代码作为查询参数包含在 URL 中,因此不需要抓取 HTML。我们可以直接从 URL 中提取,如下所示:

    private void saveInstagramToken(String location) { 
      int index = location.indexOf(CODE_QUERY_PARAM); 
      String code = location.substring(index +  
        CODE_QUERY_PARAM.length()); 
      Token accessToken = instagram. 
        verifyCodeAndGetAccessToken(code); 
      instagram.authenticateUser(accessToken.getToken(),  
        accessToken.getSecret()); 
    } 

一旦我们有了代码,我们使用instagram对象上的 API 来获取访问令牌,然后我们使用它来验证用户。那么instagram对象看起来是什么样子呢?像TwitterClient一样,InstagramClient是一个SocialMediaClient,它封装了 jInstagram API。

    public final class InstagramClient implements
    SocialMediaClient { 

      private final InstagramService service; 
      private Instagram instagram; 

jInstagram API 有两个我们需要使用的对象。InstagramService封装了 OAuth 逻辑。我们使用以下方式通过构建器获取其实例:

    service = new InstagramAuthService() 
     .apiKey(apiKey) 
     .apiSecret(apiSecret) 
     .callback("http://blogs.steeplesoft.com") 
     .scope("basic public_content relationships follower_list") 
     .build(); 

如前所述,为了在本地运行应用程序,你需要提供自己的 API 密钥和密钥对。我们唯一需要回调 URL 的地方是提供一个 Instagram 重定向我们客户端的地方。一旦它这样做,我们就从查询参数中提取代码,就像我们之前看到的那样。最后,我们必须提供一个作用域列表,这是 Instagram 所说的权限,大致上。这个列表将允许我们获取认证用户关注的账户列表,我们将使用它来获取图片:

    @Override 
    public List<? extends SocialMediaItem> getItems() { 
      List<Photo> items = new ArrayList<>(); 
      try { 
        UserFeed follows = instagram.getUserFollowList("self"); 
        follows.getUserList().forEach(u ->  
          items.addAll(processMediaForUser(u))); 
      } catch (InstagramException ex) { 
        Logger.getLogger(InstagramClient.class.getName()) 
          .log(Level.SEVERE, null, ex); 
      } 

      return items; 
    } 

如果你阅读了 jInstagram 的文档,你可能会倾向于使用instagram.getUserFeeds()这个方法,如果你这样做,你会得到我得到的结果——一个404错误页面。Instagram 在其 API 上做了一些工作,而 jInstagram 还没有反映出来。那么,我们需要做的是实现我们自己的包装器,这 jInstagram 做得相当简单。在这里,我们得到了用户关注的用户列表。对于每个用户,我们调用processMediaForUser()来获取和存储任何挂起的图片。

    private List<Photo> processMediaForUser(UserFeedData u) { 
      List<Photo> userMedia = new ArrayList<>(); 
      try { 
        final String id = u.getId(); 
        instagram.getRecentMediaFeed(id, 
          prefs.getPreference(SunagoPrefsKeys.ITEM_COUNT 
            .getKey(), 50), 
          getSinceForUser(id), null, null, null).getData() 
            .forEach(m -> userMedia.add(new Photo(m))); 
        if (!userMedia.isEmpty()) { 
          setSinceForUser(id, userMedia.get(0).getId()); 
        } 
      } catch (InstagramException ex) { 
        Logger.getLogger(InstagramClient.class.getName()) 
          .log(Level.SEVERE, null, ex); 
      } 
      return userMedia; 
    } 

使用与 Twitter 客户端相同的since ID和最大计数方法,我们请求用户的任何最近媒体。每个返回的项目都通过 lambda 包装在一个Photo实例中,这是我们的 Instagram 的SocialMediaItem子类。一旦我们有了我们的列表,如果它不为空,我们就获取第一个Photo,我们知道这是最老的,因为这是 Instagram API 返回数据的方式,我们获取 ID,将其存储为下一次调用此方法时的 since ID。最后,我们返回List,以便它可以添加到之前给出的主Photo列表中。

在 Sunago 中加载我们的插件

这样,我们的新集成就完成了。要看到它的实际效果,我们将依赖项添加到 Sunago 的 POM 中,如下所示:

    <dependency> 
      <groupId>${project.groupId}</groupId> 
      <artifactId>instagram</artifactId> 
      <version>${project.version}</version> 
    </dependency> 

然后,我们运行应用程序。

显然,为每个新的集成添加依赖并不是一个理想的解决方案,至少不是因为用户不会从 IDE 或使用 Maven 运行应用程序。那么,我们需要的是一种方法,让应用程序能够在用户的机器上运行时找到任何模块(或者如果你更喜欢这个术语,插件)。最简单的解决方案是通过如下这样的 shell 脚本启动应用程序:

    #!/bin/bash 
    JARS=sunago-1.0-SNAPSHOT.jar 
    SEP=: 
    for JAR in `ls ~/.sunago/*.jar` ; do 
      JARS="$JARS$SEP$JAR" 
    done 

    java -cp $JARS com.steeplesoft.sunago.app.Sunago 

这个先前的 shell 脚本使用主 Sunago jar 创建了一个类路径,并加上在~/.sunago中找到的任何 JAR 文件,然后运行应用程序。这很简单且有效,但确实需要针对每个操作系统版本。幸运的是,这仅仅意味着为 Mac 和 Linux 编写这个 shell 脚本,以及为 Windows 编写一个批处理文件。这并不难做或难以维护,但确实需要你有权限访问这些操作系统来测试和验证你的脚本。

另一个选择是利用类加载器。虽然说出来可能听起来很简单,但ClassLoader实际上是一个负责加载类(以及其他资源)的对象。在任何给定的 JVM 中,都存在多个类加载器在工作,它们以分层的方式排列,从启动ClassLoader开始,然后是平台ClassLoader,最后是系统或应用ClassLoader。可能某个应用程序或运行时环境,例如Java 企业版Java EE)应用程序服务器,可能会将一个或多个ClassLoader实例作为应用程序ClassLoader的子类加载器添加。这些添加的ClassLoader实例本身可能是分层的,也可能是兄弟。无论如何,它们几乎肯定是应用程序ClassLoader的子类加载器。

对类加载器和所有相关内容的全面讨论超出了本书的范围,但可以简单地说,我们可以创建一个新的ClassLoader,以便应用程序能够找到我们插件jar 文件中的类和资源。为此,我们需要向我们的应用程序类 Sunago 添加几个方法——确切地说,是三个方法——我们首先从构造函数开始:

    public Sunago() throws Exception { 
      super(); 
      updateClassLoader(); 
    } 

通常(尽管并非总是如此),当 JavaFX 应用程序启动时,会运行public static void main方法,该方法在Application类上调用launch()静态方法,该类是我们子类化的。根据javafx.application.Application的 Javadoc,JavaFX 运行时在启动应用程序时执行以下步骤:

  1. 构造指定Application类的实例。

  2. 调用init()方法。

  3. 调用start(javafx.stage.Stage)方法。

  4. 等待应用程序完成,这发生在以下任何一种情况发生时:

    1. 应用程序调用Platform.exit()

    2. 最后一个窗口已关闭,平台上的implicitExit属性为 true。

  5. 调用stop()方法。

我们希望在步骤 1 中执行我们的ClassLoader工作,在Application的构造函数中,以确保后续的所有内容都有一个最新的ClassLoader。这项工作是在我们需要添加的第二个方法中完成的,即:

    private void updateClassLoader() { 
      final File[] jars = getFiles(); 
      if (jars != null) { 
        URL[] urls = new URL[jars.length]; 
        int index = 0; 
        for (File jar : jars) { 
          try { 
            urls[index] = jar.toURI().toURL(); 
            index++; 
          } catch (MalformedURLException ex) { 
              Logger.getLogger(Sunago.class.getName()) 
               .log(Level.SEVERE, null, ex); 
            } 
        } 
        Thread.currentThread().setContextClassLoader( 
          URLClassLoader.newInstance(urls)); 
      } 
    } 

我们首先获取 jar 文件的列表(我们将在稍后看到这段代码),然后,如果数组非空,我们需要构建一个URL数组,因此,我们遍历File数组,并调用.toURI().toURL()来完成。一旦我们有了URL数组,我们创建一个新的ClassLoaderURLClassLoader.newInstance(urls)),然后通过Thread.currentThread().setContextClassLoader()设置当前线程的ClassLoader

这是我们的最终附加方法getFiles()

    private File[] getFiles() { 
      String pluginDir = System.getProperty("user.home")  
       + "/.sunago"; 
      return new File(pluginDir).listFiles(file -> file.isFile() &&  
       file.getName().toLowerCase().endsWith(".jar")); 
    } 

这个最后的方法只是扫描$HOME/.sunago中的文件,寻找以.jar结尾的文件。返回一个包含零个或多个 jar 文件列表给我们的调用代码,以便包含在新的ClassLoader中,我们的工作就完成了。

因此,你有两种方法可以将插件 jar 动态地添加到运行时。每种方法都有其优点和缺点。第一种方法需要跨平台开发和维护,而第二种方法风险略高,因为类加载器可能会很复杂。我已经在 Windows 和 Linux 上测试了第二种方法,在 Java 8 和 9 上没有检测到错误。当然,你使用哪种方法将取决于你独特的环境和需求,但你至少有两个选项可以开始评估。

摘要

说了这么多,我们的应用程序已经完成。当然,几乎没有软件是完全完整的,Sunago 还有很多可以改进的地方。Twitter 支持可以扩展以包括直接消息。Instagram 模块需要添加一些配置选项。虽然通过 Facebook API 暴露的功能有限,但可以添加某种有意义的 Facebook 集成。Sunago 本身也可以修改,例如,添加支持在应用程序内查看社交媒体内容(而不是调用宿主操作系统的默认浏览器)。还有一些可以解决的轻微用户体验问题。而且这个列表可以一直继续下去。不过,我们确实拥有一个中等复杂度的网络应用程序,展示了 Java 平台的一些特性和功能。我们构建了一个可扩展的、国际化的 JavaFX 应用程序,展示了服务提供者接口和ClassLoader魔法的使用,并提供了许多关于 lambda、流操作和函数式接口的示例。

在下一章中,我们将基于这里提出的思想,构建 Sunago 的 Android 端口,这样我们就可以随身携带我们的社交媒体聚合功能。

第二十四章:Sunago - Android 版本

在上一章中,我们构建了 Sunago,一个社交媒体聚合应用程序。在那一章中,我们了解到 Sunago 是一个基于 JavaFX 的应用程序,可以从各种社交媒体网络中抓取帖子、推文、照片等,并在一个地方显示它们。该应用程序确实提供了一些有趣的架构和技术示例,但应用程序本身可能更加实用——我们倾向于从手机和平板电脑等移动设备与社交网络互动,因此一个移动版本会更有用。因此,在这一章中,我们将编写一个 Android 版本,尽可能重用代码。

尽管 Android 应用程序是用 Java 编写的,但它们看起来与桌面应用程序大不相同。虽然我们无法涵盖 Android 开发的各个方面,但我们将在本章中涵盖足够的内容,以帮助您入门,包括以下内容:

  • 设置 Android 开发环境

  • Gradle 构建

  • Android 视图

  • Android 状态管理

  • Android 服务

  • 应用程序打包和部署

与其他章节一样,会有太多的小项目无法一一列举,但我们将尽力在介绍时突出新的内容。

入门

第一步是设置 Android 开发环境。与常规Java 开发一样,IDE 不是必需的,但它确实很有帮助,所以我们将安装 Android Studio,它是一个基于 IntelliJ IDEA 的 IDE。如果您已经安装了 IDEA,您只需安装 Android 插件,就可以拥有所需的一切。但在这里,我们将假设您还没有安装。

  1. 要下载 Android Studio,请访问developer.android.com/studio/index.html,并下载适合您操作系统的包。当您第一次启动 Android Studio 时,您应该看到以下屏幕:

  1. 在我们开始新项目之前,让我们配置可用的 Android SDK。点击右下角的“配置”菜单,然后点击“SDK 管理器”以获取以下屏幕:

您选择的 SDK 将根据您的需求而有所不同。您可能需要支持追溯到 Android 5.0 的旧设备,或者您可能只想支持最新的 Android 7.0 或 7.1.1。

  1. 一旦您知道需要什么,请选择适当的 SDK(或者像我之前截图中所做的那样,从 5.0 开始选择所有内容),然后点击“确定”。在继续之前,您需要阅读并接受许可协议。

  2. 完成这些操作后,Android Studio 将开始下载所选的 SDK 及其依赖项。这个过程可能需要一些时间,所以请耐心等待。

  3. 当 SDK 安装完成时,点击“完成”按钮,这将带您进入欢迎屏幕。点击“开始新的 Android Studio 项目”以获取以下屏幕:

  1. 这里没有什么令人兴奋的——我们需要指定我们应用的名称、公司域名和项目位置:

  1. 接下来,我们需要指定我们应用的表单因子。我们的选项有手机、平板、可穿戴设备、电视、Android Auto 和 Glass。如前一个截图所示,我们对于这个应用所感兴趣的是手机和平板。

  2. 在下一个窗口中,我们需要为主Activity选择一个类型。在 Android 应用中,我们可能称之为屏幕(或者如果你来自 Web 应用背景,可能是页面)的是Activity。但并非每个Activity都是屏幕。

从 Android 开发者文档(developer.android.com/reference/android/app/Activity.html)中,我们了解到以下内容:

[a] 活动是用户可以执行的单个、专注的事情。几乎所有活动都与用户交互,因此 Activity 类会为你创建一个窗口...

对于我们的目的来说,可能可以将这两个概念等同起来,但这样做要宽松一些,并且始终要记住这个注意事项。巫师为我们提供了许多选项,如本截图所示:

  1. 如你所见,有几个选项:基本、空、全屏、Google AdMobs 广告、Google 地图、登录等等。选择哪个取决于你的应用需求。从用户界面的角度来看,我们最基本的需求是告诉用户应用名称,显示社交媒体项目列表,并提供更改应用设置的菜单。因此,从上述列表中,基本活动是最接近的匹配项,所以我们选择它,并点击下一步:

  1. 前一个屏幕中的默认设置大多是可以接受的(注意 Activity 名称已更改),但在我们点击完成之前,还有一些最后的话要说。在构建任何大小的 Android 应用时,你将会有很多布局、菜单、活动等等。我发现按照这里所示命名这些工件是有帮助的——Activity的布局命名为activity_加上Activity名称;菜单是menu_加上活动名称,或者对于共享菜单,是一个对其内容的合理总结。每种工件类型都以前缀表示其类型。这种通用模式将帮助你在文件数量增加时快速导航到源文件,因为这些文件的排列非常平坦和浅层。

  2. 最后,请注意“使用片段”复选框。片段是应用程序用户界面或行为的一部分,可以放置在 Activity 中。实际上,它是开发者将用户界面定义分解成多个部分(或片段,因此得名)的一种方式,这些部分可以根据应用程序的当前上下文以不同的方式组合成一个整体。例如,基于片段的用户界面可能为手机上的某些操作提供两个屏幕,但在平板电脑的大屏幕上可能将它们合并为一个 Activity。当然,这比那要复杂得多,但我只是简要地、不完整地描述一下,以便对复选框进行解释。在我们的应用程序中,我们不会使用片段,所以我们保留该选项未勾选,并点击“完成”。

经过一段时间处理之后,Android Studio 现在为我们创建了一个基本的应用程序。在我们开始编写应用程序代码之前,让我们运行它看看这个过程是什么样的。我们可以以几种方式运行应用程序——我们可以点击“运行”|“运行 'app'”;点击工具栏中间的绿色播放按钮,或者按 Shift + F10。所有三种方式都会弹出相同的“选择部署目标”窗口,如下所示:

图片

由于我们刚刚安装了 Android Studio,我们没有创建任何模拟器,所以我们需要现在创建。要创建模拟器,请按照以下步骤操作:

  1. 点击“创建新虚拟设备”按钮,我们会看到这个屏幕:

图片

  1. 让我们从一台相对现代的 Android 手机开始——选择 Nexus 6 配置文件,然后点击“下一步”:

图片

在前面的屏幕上,你的选项将取决于你已安装的哪些 SDK。你选择哪个 SDK,再次,取决于你的目标受众、应用需求等等。尽管使用最新和最好的总是令人愉快,但我们并不严格需要来自,比如说,牛轧糖(Nougat)的任何 API。选择 Android 7.x 将限制 Sunago 的可用性,仅限于非常新的手机,而且这样做没有充分的理由。因此,我们将以 Lollipop(Android 5.0)为目标,它在支持尽可能多的用户和提供访问最新 Android 功能之间取得了良好的平衡。

  1. 如果需要,点击下载链接以获取 x86_64 ABI,选择该版本,点击“下一步”,然后在“验证配置”屏幕上点击“完成”。

  2. 创建模拟器后,我们可以在“选择部署目标”屏幕中选择它,并通过点击“确定”来运行应用程序。如果你想在下次运行应用程序时跳过选择屏幕,可以在点击“确定”之前勾选“为未来的启动使用相同的选项”复选框。

第一次运行应用程序时,可能会花费更长的时间,因为应用程序需要构建、打包,并且模拟器需要启动。几分钟后,你应该会看到以下屏幕:

图片

这并没有什么特别之处,但它表明一切都在按预期工作。现在,我们已经准备好开始真正的移植 Sunago 的工作。

构建用户界面

简单来说,Android 用户界面基于 Activity,它们使用布局文件来描述用户界面的结构。当然,还有更多内容,但这个简单的定义应该足以满足我们在 Sunago 上的工作。那么,让我们首先看看我们的 ActivityMainActivity,如下所示:

    public class MainActivity extends AppCompatActivity { 
      @Override 
      protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.activity_main); 
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); 
        setSupportActionBar(toolbar); 

        FloatingActionButton fab =
            (FloatingActionButton) findViewById(R.id.fab); 
        fab.setOnClickListener(new View.OnClickListener() { 
            @Override 
            public void onClick(View view) { 
                Snackbar.make(view,
                        "Replace with your own action",
                        Snackbar.LENGTH_LONG) 
                    .setAction("Action", null).show(); 
            } 
        }); 
      } 

     @Override 
     public boolean onCreateOptionsMenu(Menu menu) { 
        getMenuInflater().inflate(R.menu.menu_main, menu); 
        return true; 
     } 

     @Override 
     public boolean onOptionsItemSelected(MenuItem item) { 
        int id = item.getItemId(); 

        if (id == R.id.action_settings) { 
            return true; 
        } 

        return super.onOptionsItemSelected(item); 
      } 
    } 

这段最后的代码是 Android Studio 生成的类的确切代码。它非常基础,但它包含了创建 Activity 所需的大部分内容。请注意,这个类扩展了 AppCompatActivity。虽然 Google 一直在积极推动 Android 平台,但他们也一直在不懈努力,确保旧设备不会比必要的时间更早被遗弃。为了实现这一点,Google 已经在 "compat"(或兼容性)包中回滚了许多新特性,这意味着许多新的 API 实际上可以在旧版本的 Android 上运行。然而,由于它们在单独的包中,这些更改不会破坏任何现有功能——它们必须被明确选择,这正是我们在做的事情。虽然我们并不打算支持旧版本的 Android,如 KitKat,但仍然建议你的 Activity 类扩展兼容性类,就像这个例子一样,因为这些类中内置了许多我们否则必须自己实现的功能。让我们按以下步骤逐步分析这个类,以了解其中发生了什么:

  1. 第一种方法是 onCreate(),这是一个 Activity 生命周期方法(我们稍后会详细讨论 Activity 生命周期)。当系统创建 Activity 类时,这个方法会被调用。正是在这里,我们初始化用户界面,设置值,连接到数据源等等。请注意,这个方法接受一个 Bundle。这就是 Android 传递 Activity 状态以便恢复的方式。

setContentView(R.layout.activity_main) 方法中,我们告诉系统我们想要为这个 Activity 使用哪个布局。一旦我们为 Activity 设置了内容 View,我们就可以开始获取各种元素的引用。注意,我们首先在视图中寻找定义的 Toolbar,使用 findViewById(R.id.toolbar),然后通过 setSupportActionBar() 告诉 Android 使用它作为我们的操作栏。这是一个通过 compat 类为我们实现的功能的例子。如果我们直接扩展了 Activity,那么我们需要做更多的工作来使操作栏工作。实际上,我们只需调用一个设置器,就完成了。

  1. 接下来,我们查找另一个用户界面元素,即FloatingActionButton。在上面的截图中,这个按钮位于右下角,带有电子邮件图标。我们实际上会移除这个按钮,但由于 Android Studio 已经生成了它,我们可以在移除之前从中学习到一些东西。一旦我们有了它的引用,我们就可以附加监听器。在这种情况下,我们通过创建一个类型为View.OnClickListener的匿名内部类来添加一个onClick监听器。这可以工作,但我们刚刚花了五章的时间来消除这些。

  2. Android 构建系统现在原生支持使用 Java 8,因此我们可以修改onClick监听器注册,使其看起来像这样:

    fab.setOnClickListener(view -> Snackbar.make(view,
        "Replace with your own action",
            Snackbar.LENGTH_LONG) 
        .setAction("Action", null).show()); 

当用户点击按钮时,会出现 Snackbar。根据谷歌文档,Snackbar 通过屏幕底部的消息提供关于操作的简短反馈。这正是我们得到的--一条消息告诉我们用我们自己的操作替换onClick结果。然而,如前所述,我们不需要浮动按钮,所以我们将移除此方法,稍后从布局中移除视图定义。

  1. 类中的下一个方法是onCreateOptionsMenu()。当选项菜单首次打开以填充项目列表时调用此方法。我们使用MenuInflater来填充菜单定义文件,并将其中定义的内容添加到系统传入的Menu中。然而,此方法只调用一次,因此如果您需要一个会变化的菜单,您应该重写onPrepareOptionsMenu(Menu)

  2. 最后一种方法是onOptionsItemSelected(),当用户点击选项菜单项时会被调用。所选择的特定MenuItem会被传入。我们获取其 ID,并调用适用于菜单项的方法。

那是一个基本的Activity,但布局看起来是什么样子呢?以下是activity_main.xml的内容:

    <?xml version="1.0" encoding="utf-8"?> 
     <android.support.design.widget.CoordinatorLayout  

      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:fitsSystemWindows="true" 
      tools:context="com.steeplesoft.sunago.MainActivity"> 

      <android.support.design.widget.AppBarLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:theme="@style/AppTheme.AppBarOverlay"> 

       <android.support.v7.widget.Toolbar 
            android:id="@+id/toolbar" 
            android:layout_width="match_parent" 
            android:layout_height="?attr/actionBarSize" 
            android:background="?attr/colorPrimary" 
            app:popupTheme="@style/AppTheme.PopupOverlay" /> 

      </android.support.design.widget.AppBarLayout> 

      <include layout="@layout/content_main" /> 

     <android.support.design.widget.FloatingActionButton 
        android:id="@+id/fab" 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:layout_gravity="bottom|end" 
        android:layout_margin="@dimen/fab_margin" 
        app:srcCompat="@android:drawable/ic_dialog_email" /> 

     </android.support.design.widget.CoordinatorLayout> 

这里有相当多的 XML,所以让我们快速浏览一下感兴趣的主要项目,如下所示:

  1. 根元素是CoordinatorLayout。它的 Java 文档将其描述为一个超级强大的FrameLayout。其预期用途之一是作为顶级应用程序装饰或 chrome 布局,这正是我们在这里使用的。类似于CoordinatorLayout的布局大致相当于 JavaFX 的容器。不同的布局(或ViewGroup)提供了各种功能,例如使用精确的 X/Y 坐标布局元素(AbsoluteLayout)、在网格中布局(GridLayout)、相对于彼此布局(RelativeLayout)等等。

  2. 除了提供我们的顶级容器外,该元素还定义了多个必需的 XML 命名空间。它还设置了控件的高度和宽度。此字段有三个可能的值--match_parent(在 SDK 的早期版本中,这被称为fill_parent,如果您遇到这种情况),这意味着控件应与其父控件匹配,wrap_content,这意味着控件应刚好足够容纳其内容;或者一个确切的数字。

  3. 下一个元素是AppBarLayout,这是一个实现了许多材料设计应用栏概念的ViewGroup材料设计是 Google 正在开发和支持的最新视觉语言。它为 Android 应用提供了现代、一致的外观和感觉。Google 鼓励使用它,幸运的是,新的Activity向导已经为我们设置了使用它,无需额外配置。布局的宽度设置为match_parent,以便填满屏幕,宽度设置为wrap_content,以便它刚好足够显示其内容,即一个单独的Toolbar

  4. 暂时跳过include元素,视图中的最后一个元素是FloatingActionButton。我们在这里的唯一兴趣是指出该小部件存在,如果其他项目中需要的话。尽管如此,我们像在Activity类中做的那样,需要移除这个小部件。

  5. 最后,是include元素。这个元素的作用正如你所想的那样——指定的文件被包含在布局定义中,就像其内容被硬编码到文件中一样。这允许我们保持我们的布局文件小巧,重用用户界面元素定义(这在复杂场景中尤其有帮助),等等。

包含的文件content_main.xml看起来是这样的:

        <RelativeLayout

          android:id="@+id/content_main" 
          android:layout_width="match_parent" 
          android:layout_height="match_parent" 
          android:paddingBottom="@dimen/activity_vertical_margin" 
          android:paddingLeft="@dimen/activity_horizontal_margin" 
          android:paddingRight="@dimen/activity_horizontal_margin" 
          android:paddingTop="@dimen/activity_vertical_margin" 
          app:layout_behavior="@string/appbar_scrolling_view_behavior" 
          tools:context="com.steeplesoft.sunago.MainActivity" 
          tools:showIn="@layout/activity_main"> 

         <TextView 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="Hello World!" /> 
        </RelativeLayout> 

上述视图使用RelativeLayout来包裹其唯一的子元素,一个TextView。请注意,我们可以设置控件的内边距。这控制了控件周围其子元素内部的多少空间。想象一下装箱——在箱子里,你可能有一个易碎的陶瓷古董,所以你需要填充箱子以保护它。你还可以设置控件的外边距,这是控件外部的空间,类似于我们经常喜欢的个人空间。

虽然TextView没有帮助,所以我们将其移除,并添加我们真正需要的,那就是ListView,如下所示:

    <ListView 
      android:id="@+id/listView" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:layout_alignParentTop="true" 
      android:layout_alignParentStart="true"/> 

ListView是一个显示垂直滚动列表中项的控件。在用户体验方面,这几乎与我们在 JavaFX 中看到的ListView一样。然而,它的工作方式却大不相同。要了解它是如何工作的,我们需要对活动的onCreate()方法做一些调整,如下所示:

    protected void onCreate(Bundle savedInstanceState) { 
       super.onCreate(savedInstanceState); 
       setContentView(R.layout.activity_main); 

      if (!isNetworkAvailable()) { 
         showErrorDialog( 
            "A valid internet connection can't be established"); 
      } else { 
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); 
        setSupportActionBar(toolbar); 
        findPlugins(); 

        adapter = new SunagoCursorAdapter(this, null, 0); 
        final ListView listView = (ListView)
            findViewById(R.id.listView); 
        listView.setAdapter(adapter); 
        listView.setOnItemClickListener( 
                new AdapterView.OnItemClickListener() { 
            @Override 
            public void onItemClick(AdapterView<?> adapterView,
                    View view, int position, long id) { 
                Cursor c = (Cursor)
                    adapterView.getItemAtPosition(position); 
                String url = c.getString(c.getColumnIndex( 
                    SunagoContentProvider.URL)); 
                Intent intent = new Intent(Intent.ACTION_VIEW,
                    Uri.parse(url)); 
                startActivity(intent); 
            } 
         }); 

         getLoaderManager().initLoader(0, null, this); 
       } 
    } 

这里正在进行几件事情,这为我们讨论 Android 中的数据访问做好了很好的准备。在我们详细讨论之前,一个快速的概述是必要的:

  1. 我们通过isNetworkAvailable()检查设备是否有可用的网络连接,我们将在本章稍后讨论它。

  2. 如果连接可用,我们配置用户界面,从设置工具栏开始。

  3. 接下来,我们创建了一个SunagoCursorAdapter的实例,我们将在稍后详细讨论。现在,尽管如此,请注意,AdapterListView连接到数据源的方式,它们可以由各种不同的东西支持,如 SQL 数据源或Array

  4. 我们将适配器传递给ListView,通过ListView.setAdapter()完成这个连接。就像 JavaFX 的Observable模型属性一样,我们将在数据变化时能够使用它来更新用户界面,而不需要直接交互。

  5. 接下来,我们为列表中的项目设置一个onClick监听器。我们将使用它来在外部浏览器中显示用户点击(或点击)的项目。简而言之,给定position参数,我们获取该位置的项目,一个Cursor,提取项目的 URL,然后使用设备的默认浏览器通过一个Intent(我们将在后面详细讨论)显示该 URL 对应的页面。

  6. 最后,完成我们的数据绑定,我们初始化LoaderManager,它将以异步方式处理加载和更新Adapter

在深入数据访问之前,我们来看看最后一段代码——isNetworkAvailable()——如下所示:

        public boolean isNetworkAvailable() { 
          boolean connected = false; 
          ConnectivityManager cm = (ConnectivityManager)  
            getSystemService(Context.CONNECTIVITY_SERVICE); 
          for (Network network : cm.getAllNetworks()) { 
            NetworkInfo networkInfo = cm.getNetworkInfo(network); 
            if (networkInfo.isConnected() == true) { 
                connected = true; 
                break; 
            } 
          } 
         return connected; 
        } 

        private void showErrorDialog(String message) { 
          AlertDialog alertDialog = new AlertDialog.Builder(this) 
            .create(); 
          alertDialog.setTitle("Error!"); 
          alertDialog.setMessage(message); 
          alertDialog.setIcon(android.R.drawable.alert_dark_frame); 
          alertDialog.setButton(DialogInterface.BUTTON_POSITIVE,
          "OK", new DialogInterface.OnClickListener() { 
            @Override 
            public void onClick(DialogInterface dialog, int which) { 
              MainActivity.this.finish(); 
            } 
          }); 

          alertDialog.show(); 
       } 

在前面的代码中,我们首先获取系统服务ConnectivityManager的引用,然后遍历系统所知的每个Network。对于每个Network,我们获取其NetworkInfo的引用并调用isConnected()。如果我们找到一个已连接的网络,我们返回true,否则返回false。在调用代码中,如果我们的返回值是false,我们显示一个错误对话框,该方法的实现也在此处展示。这是一个标准的 Android 对话框。然而,我们为 OK 按钮添加了一个onClick监听器,它将关闭应用程序。使用这个,我们告诉用户需要一个网络连接,然后在用户点击 OK 时关闭应用程序。当然,这种行为是否可取是有争议的,但确定设备网络状态的过程本身足够有趣,所以我将其包括在这里。

现在,让我们将注意力转向 Android 应用中数据访问通常是如何进行的——CursorAdapters

Android 数据访问

在任何平台上,都有多种方式来访问数据,从内置功能到自建 API。Android 也不例外,因此虽然你可以编写自己的方式从某些任意数据源加载数据,除非你有非常特定的要求,否则通常没有必要,因为 Android 内置了一个系统——ContentProvider

Android 文档会告诉你,内容提供者管理对数据中央存储库的访问,并且它提供了一个一致、标准的接口来访问数据,同时也处理进程间通信和安全的访问数据。如果你打算将你的应用程序数据暴露给外部来源(无论是读取还是写入),ContentProvider是一个很好的选择。然而,如果你不打算暴露你的数据,你完全可以自己编写所需的 CRUD 方法,手动发出各种 SQL 语句。在我们的案例中,我们将使用ContentProvider,因为我们有兴趣允许第三方开发者访问数据。

要创建一个ContentProvider,我们需要创建一个新的类,该类扩展了ContentProvider,如下所示:

    public class SunagoContentProvider extends ContentProvider { 

我们还需要在AndroidManifest.xml中注册提供者,我们将这样做:

    <provider android:name=".data.SunagoContentProvider 
      android:authorities="com.steeplesoft.sunago.SunagoProvider" /> 

ContentProvider的交互永远不会直接进行。客户端代码将指定要操作的数据的 URL,Android 系统将请求定向到适当的提供者。为了确保我们的ContentProvider按预期工作,因此,我们需要注册提供者的权限,这我们在之前的 XML 中已经看到。在我们的提供者中,我们将创建一些静态字段来帮助我们以 DRY(Don't Repeat Yourself)的方式管理权限的各个部分和相关的 URL。

    private static final String PROVIDER_NAME =  
     "com.steeplesoft.sunago.SunagoProvider"; 
    private static final String CONTENT_URL =  
     "content://" + PROVIDER_NAME + "/items"; 
    public static final Uri CONTENT_URI = Uri.parse(CONTENT_URL); 

上述代码中的前两个字段是私有的,因为它们在类外部不需要。不过,我们在这里将它们定义为单独的字段,以便于理解。第三个字段CONTENT_URI是公开的,因为我们将在我们的应用程序的其他地方引用该字段。第三方消费者显然无法访问该字段,但他们需要知道其值,即content://com.steeplesoft.sunago.SunagoProvider/items,我们将在某处为附加开发者记录此值。URL 的第一部分,协议字段,告诉 Android 我们正在寻找一个ContentProvider。下一部分是权限,它唯一地标识了一个特定的ContentProvider,最后一部分指定了我们感兴趣的数据类型或模型。对于 Sunago,我们有一个单一的数据类型,即items

接下来,我们需要指定我们想要支持的 URI。我们只有两个——一个用于项目集合,一个用于特定项目。请参考以下代码片段:

    private static final UriMatcher URI_MATCHER =  
      new UriMatcher(UriMatcher.NO_MATCH); 
    private static final int ITEM = 1; 
    private static final int ITEM_ID = 2; 
    static { 
      URI_MATCHER.addURI(PROVIDER_NAME, "items", ITEM); 
      URI_MATCHER.addURI(PROVIDER_NAME, "items/#", ITEM_ID); 
     } 

在最后一行代码中,我们首先创建了一个UriMatcher。请注意,我们将UriMatcher.NO_MATCH传递给构造函数。这个值的具体用途可能并不立即明了,但这是当用户传入一个与已注册的任何 URI 都不匹配的 URI 时将返回的值。最后,我们使用唯一的int标识符注册每个 URI。

接下来,像许多 Android 类一样,我们需要指定一个onCreate生命周期钩子,如下所示:

    public boolean onCreate() { 
      openHelper = new SunagoOpenHelper(getContext(), DBNAME,  
        null, 1); 
      return true; 
    } 

SunagoOpenHelperSQLiteOpenHelper的子类,它管理底层 SQLite 数据库的创建和/或更新。这个类本身相当简单,如下所示:

    public class SunagoOpenHelper extends SQLiteOpenHelper { 
      public SunagoOpenHelper(Context context, String name,  
            SQLiteDatabase.CursorFactory factory, int version) { 
          super(context, name, factory, version); 
      } 

      @Override 
      public void onCreate(SQLiteDatabase db) { 
        db.execSQL(SQL_CREATE_MAIN); 
      } 

      @Override 
      public void onUpgrade(SQLiteDatabase db, int oldVersion,  
        int newVersion) { 
      } 
    } 

我没有展示表创建 DDL,因为这是一个相当简单的表创建,但这个类就是你需要创建和维护你的数据库的全部。如果你有多个表,你将在onCreate中发出多个创建命令。当应用程序更新时,onUpgrade()被调用,以便你可以根据需要修改模式。

回到我们的ContentProvider中,我们需要实现两个方法,一个用于读取数据,另一个用于插入(鉴于应用的本质,我们现在对删除或更新不感兴趣)。对于读取数据,我们如下重写query()方法:

    public Cursor query(Uri uri, String[] projection,  
      String selection, String[] selectionArgs,  
      String sortOrder) { 
        switch (URI_MATCHER.match(uri)) { 
          case 2: 
            selection = selection + "_ID = " +  
              uri.getLastPathSegment(); 
              break; 
        } 
        SQLiteDatabase db = openHelper.getReadableDatabase(); 
        Cursor cursor = db.query("items", projection, selection,  
          selectionArgs, null, null, sortOrder); 
        cursor.setNotificationUri( 
          getContext().getContentResolver(), uri); 
        return cursor; 
    } 

这段最后的代码是我们 URI 及其int标识符所在的位置。使用UriMatcher,我们检查调用者传入的Uri。鉴于我们的提供者很简单,我们只需要对#2进行特殊处理,即查询特定项。在这种情况下,我们提取传入的 ID 作为最后一个路径段,并将其添加到调用者指定的选择条件中。

一旦我们按照要求配置了查询,我们就从openHelper获取一个可读的SQLiteDatabase实例,并使用调用者传递的值进行查询。这是ContentProvider契约派上用场的一个领域之一——我们不需要手动编写任何SELECT语句。

在返回游标之前,我们需要对其进行一些操作,如下所示:

    cursor.setNotificationUri(getContext().getContentResolver(), uri); 

通过这个前置调用,我们告诉系统当数据更新时希望通知游标。由于我们正在使用Loader,这将允许我们在数据插入时自动更新用户界面。

对于插入数据,我们如下重写insert()

    public Uri insert(Uri uri, ContentValues values) { 
      SQLiteDatabase db = openHelper.getWritableDatabase(); 
      long rowID = db.insert("items", "", values); 

      if (rowID > 0) { 
        Uri newUri = ContentUris.withAppendedId(CONTENT_URI,  
            rowID); 
        getContext().getContentResolver().notifyChange(newUri,  
            null); 
        return newUri; 
      } 

    throw new SQLException("Failed to add a record into " + uri); 
    } 

使用openHelper,这次我们获取数据库的可写实例,并调用insert()。插入方法返回刚刚插入的行的 ID。如果我们得到一个非零 ID,我们为该行生成一个 URI,我们最终将返回它。然而,在我们这样做之前,我们需要通知内容解析器数据的变化,这将在用户界面中触发自动重新加载。

我们还有一步来完成我们的数据加载代码。如果你回顾一下MainActivity.onCreate(),你会看到这一行:

    getLoaderManager().initLoader(0, null, this); 

这最后一行告诉系统我们想要初始化一个Loader,并且这个LoaderthisMainActivity。在我们的MainActivity定义中,我们已经指定它实现了LoaderManager.LoaderCallbacks<Cursor>接口。这要求我们实现一些方法,如下所示:

    public Loader<Cursor> onCreateLoader(int i, Bundle bundle) { 
      CursorLoader cl = new CursorLoader(this,  
        SunagoContentProvider.CONTENT_URI,  
        ITEM_PROJECTION, null, null, 
           SunagoContentProvider.TIMESTAMP + " DESC"); 
      return cl; 
    } 

    public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { 
      adapter.swapCursor(cursor); 
    } 

    public void onLoaderReset(Loader<Cursor> loader) { 
      adapter.swapCursor(null); 
    } 

onCreateLoader()中,我们指定要加载的内容以及加载的位置。我们传入我们刚刚创建的ContentProvider的 URI,通过ITEM_PROJECTION变量(它是一个String[],此处未显示)指定我们感兴趣的字段,最后指定排序顺序(我们指定为按时间戳降序排列,以便将最新项放在顶部)。onLoadFinished()方法是在自动重新加载发生的地方。一旦为更新后的数据创建了一个新的Cursor,我们就将其交换为Adapter当前使用的Cursor。虽然你可以编写自己的持久化代码,但这突出了为什么在可能的情况下使用平台功能是一个明智的选择。

在数据处理方面,还有一个大项目需要查看——SunagoCursorAdapter。再次查看 Android Javadocs,我们了解到一个 Adapter 对象充当 AdapterView 和该视图的底层数据之间的桥梁,并且CursorAdapter Cursor 中的数据暴露给 ListView 小部件。通常——如果不是大多数情况下——特定的ListView将需要一个自定义的CursorAdapter,以便正确渲染底层数据。Sunago 也不例外。因此,为了创建我们的Adapter,我们创建一个新的类,如下所示:

    public class SunagoCursorAdapter extends CursorAdapter { 
      public SunagoCursorAdapter(Context context, Cursor c,  
      int flags) { 
        super(context, c, flags); 
    } 

这相当标准。真正有趣的部分在于视图创建,这也是CursorAdapter的一个原因。当Adapter需要创建一个新的视图来持有由游标指向的数据时,它调用以下方法。这就是我们通过调用LayoutInflater.inflate()指定视图外观的地方:

    public View newView(Context context, Cursor cursor,  
        ViewGroup viewGroup) { 
          View view = LayoutInflater.from(context).inflate( 
          R.layout.social_media_item, viewGroup, false); 
          ViewHolder viewHolder = new ViewHolder(); 
          viewHolder.text = (TextView)
          view.findViewById(R.id.textView); 
          viewHolder.image = (ImageView) view.findViewById( 
          R.id.imageView); 

          WindowManager wm = (WindowManager) Sunago.getAppContext() 
            .getSystemService(Context.WINDOW_SERVICE); 
          Point size = new Point(); 
          wm.getDefaultDisplay().getSize(size); 
          viewHolder.image.getLayoutParams().width =  
            (int) Math.round(size.x * 0.33); 

          view.setTag(viewHolder); 
          return view; 
     } 

我们稍后会查看我们的布局定义,但首先,让我们看看ViewHolder

    private static class ViewHolder { 
      public TextView text; 
      public ImageView image; 
   } 

通过 ID 查找视图可能是一个昂贵的操作,因此一个非常常见的模式是这种ViewHolder方法。在视图被展开之后,我们立即查找我们感兴趣的字段,并将这些引用存储在ViewHolder实例中,然后将其作为View的标签存储。由于视图是由ListView类回收的(这意味着,当你滚动数据时,它们按需被重复使用),因此这个昂贵的findViewById()方法只对每个View调用一次并缓存,而不是对底层数据中的每个项目调用一次。对于大型数据集(和复杂视图),这可以显著提高性能。

在这个方法中,我们还设置了ImageView类的尺寸。Android 不支持通过 XML 标记设置视图的宽度为百分比(如下所示),因此我们在创建View时手动设置。我们从WindowManager系统服务中获取默认显示的大小。我们将显示的宽度乘以 0.33,这将限制(如果有)图像为显示宽度的 1/3,并将ImageView的宽度设置为该值。

那么,每一行的视图看起来是什么样子?

    <LinearLayout  

      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:orientation="horizontal"> 

      <ImageView 
        android:id="@+id/imageView" 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:layout_marginEnd="5dip" 
        android:layout_gravity="top" 
        android:adjustViewBounds="true"/> 

      <TextView 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:id="@+id/textView" 
        android:scrollHorizontally="false" 
        android:textSize="18sp" /> 
     </LinearLayout> 

正如ViewHolder所暗示的,我们的视图由一个ImageView和一个TextView组成,由于包含的LinearLayout而水平排列。

CursorAdapter调用newView()来创建View时,它调用bindView()来——如果你能想象的话——将View绑定到Cursor中的特定行。这就是View回收发挥作用的地方。Adapter缓存了多个View实例,并按需将一个传递给此方法。我们的方法如下所示:

    public void bindView(View view, Context context, Cursor cursor) { 
      final ViewHolder viewHolder = (ViewHolder) view.getTag(); 
      String image = cursor.getString(INDEX_IMAGE); 
      if (image != null) { 
        new DownloadImageTask(viewHolder.image).execute(image); 
      } else { 
        viewHolder.image.setImageBitmap(null); 
        viewHolder.image.setVisibility(View.GONE); 
      } 
      viewHolder.body.setText(cursor.getString(INDEX_BODY)); 
    } 

我们首先获取ViewHolder实例。如前所述,我们将使用这里存储的小部件引用来更新用户界面。接下来,我们从游标中拉取图像 URL。每个SocialMediaItem都必须决定如何填充这个字段,但它可能是一个推文中的图像或 Instagram 帖子中的照片。如果项目有一个,我们需要下载它以便显示。由于这需要网络操作,而我们正在用户界面线程上运行,所以我们把这个工作交给DownloadImageTask。如果没有这个项目的图像,我们需要将图像的位图设置为null(否则,上次使用这个视图实例时存在的图像将再次显示)。这样可以释放一些内存,这总是好的,但我们还设置了ImageView类的可见性为GONE,这样它就从用户界面中隐藏了。你可能想使用INVISIBLE,但这只会使其在保留其在用户界面中的空间的同时不可见。这样做的结果将是一个大空白方块,这不是我们想要的。最后,我们将TextView正文的文本设置为项目指定的文本。

图片下载由一个AsyncTask在后台线程处理,如下所示:

    private static class DownloadImageTask extends  
       AsyncTask<String, Void, Bitmap> { 
        private ImageView imageView; 

        public DownloadImageTask(ImageView imageView) { 
         this.imageView = imageView; 
        } 

安卓将为这个任务创建一个后台Thread。我们逻辑的主要入口点是doInBackground()。请参考以下代码片段:

    protected Bitmap doInBackground(String... urls) { 
      Bitmap image = null; 
      try (InputStream in = new URL(urls[0]).openStream()) { 
        image = BitmapFactory.decodeStream(in); 
      } catch (java.io.IOException e) { 
         Log.e("Error", e.getMessage()); 
         } 
        return image; 
    } 

这并不是最健壮的可想象下载代码(例如,重定向状态码被愉快地忽略),但它确实可用。使用 Java 7 的try-with-resources,我们在一个URL实例上调用openStream()。假设在这两个操作中都没有抛出Exception,我们调用BitmapFactory.decodeStream()将传入的字节转换为Bitmap,这正是该方法预期返回的内容。

那么,一旦我们返回Bitmap,会发生什么?我们像这样在onPostExecute()中处理它:

    protected void onPostExecute(Bitmap result) { 
      imageView.setImageBitmap(result); 
      imageView.setVisibility(View.VISIBLE); 
      imageView.getParent().requestLayout(); 
    } 

在这个最后的方法中,我们使用现在下载的Bitmap更新ImageView,使其VISIBLE,然后请求视图在屏幕上更新自己。

到目前为止,我们已经构建了一个能够显示SocialMediaItem实例的应用程序,但我们没有东西可以显示。现在我们将通过查看安卓服务来解决这个问题。

安卓服务

对于 Sunago 的桌面版本,我们定义了一个 API,允许第三方开发者(或我们自己)为 Sunago 添加对任意社交网络的支持。这是一个桌面上的伟大目标,也是移动端的伟大目标。幸运的是,Android 为我们提供了一个机制,可以用来实现这一点:服务。服务是一个应用程序组件,代表应用程序希望在不对用户进行交互的情况下执行长时间运行的操作,或者为其他应用程序提供功能使用。虽然服务是为更多扩展性而设计的,但我们可以利用这个设施来实现这一目标。

尽管有几种方式来实现和与服务交互,但我们将把服务绑定到我们的Activity上,以便它们的生命周期与我们的Activity绑定,并且我们将异步地向它们发送消息。我们将首先按照以下方式定义我们的类:

    public class TwitterService extends IntentService { 
      public TwitterService() { 
        super("TwitterService"); 
      } 

     @Override 
      protected void onHandleIntent(Intent intent) { 
    } 

从技术上讲,这些是创建服务所需的所有方法。显然,它并没有做什么,但我们将很快修复这个问题。在我们这样做之前,我们需要向 Android 声明我们的新Service,这通过AndroidManifest.xml完成,如下所示:

    <service android:name=".twitter.TwitterService"  
     android:exported="false"> 
      <intent-filter> 
        <action  
          android:name="com.steeplesoft.sunago.intent.plugin" /> 
        <category  
          android:name="android.intent.category.DEFAULT" /> 
       </intent-filter> 
    </service> 

注意,除了服务声明之外,我们还通过intent-filter元素指定了一个IntentFilter。我们将在MainActivity中稍后使用它来查找和绑定我们的服务。尽管我们在查看我们的服务,但让我们看看绑定过程的这一方面。我们需要实现这两个生命周期方法:

    public IBinder onBind(Intent intent) { 
      receiver = new TwitterServiceReceiver(); 
      registerReceiver(receiver,  
        new IntentFilter("sunago.service")); 
      return null; 
     } 

    public boolean onUnbind(Intent intent) { 
      unregisterReceiver(receiver); 
      return super.onUnbind(intent); 
    } 

这些方法在服务绑定和解除绑定时被调用,这为我们提供了注册我们的接收器的机会,这可能会引发以下问题:那是什么?Android 提供了进程间通信IPC),但它有一定的局限性,即有效负载大小不能超过 1 MB。尽管我们的有效负载只是文本,但我们(根据我的测试,肯定会的)会超过那个限制。因此,我们的方法将是使用通过接收器进行的异步通信,并通过我们的ContentProvider让服务持久化数据。

要创建一个接收器,我们按照以下方式扩展android.content.BroadcastReceiver

    private class TwitterServiceReceiver extends BroadcastReceiver { 
      @Override 
      public void onReceive(Context context, Intent intent) { 
        if ("REFRESH".equals(intent.getStringExtra("message"))) { 
            if (SunagoUtil.getPreferences().getBoolean( 
                getString(R.string.twitter_authd), false)) { 
                new TwitterUpdatesAsyncTask().execute(); 
            } 
          } 
       } 
     } 

我们的消息方案非常简单——Sunago 发送REFRESH消息,服务执行其工作,我们将这些工作封装在TwitterUpdatesAsyncTask中。在onBind()中,我们使用特定的IntentFilter注册接收器,该IntentFilter指定了我们感兴趣的Intent广播。在onUnbind()中,当服务释放时,我们注销我们的接收器。

我们服务的其余部分在我们的AsyncTask中,如下所示:

    private class TwitterUpdatesAsyncTask extends  
    AsyncTask<Void, Void, List<ContentValues>> { 
      @Override 
      protected List<ContentValues> doInBackground(Void... voids) { 
        List<ContentValues> values = new ArrayList<>(); 
        for (SocialMediaItem item :  
                TwitterClient.instance().getItems()) { 
            ContentValues cv = new ContentValues(); 
            cv.put(SunagoContentProvider.BODY, item.getBody()); 
            cv.put(SunagoContentProvider.URL, item.getUrl()); 
            cv.put(SunagoContentProvider.IMAGE, item.getImage()); 
            cv.put(SunagoContentProvider.PROVIDER,  
                item.getProvider()); 
            cv.put(SunagoContentProvider.TITLE, item.getTitle()); 
            cv.put(SunagoContentProvider.TIMESTAMP,  
                item.getTimestamp().getTime()); 
            values.add(cv); 
        } 
        return values; 
      } 

    @Override 
    protected void onPostExecute(List<ContentValues> values) { 
      Log.i(MainActivity.LOG_TAG, "Inserting " + values.size() +  
        " tweets."); 
      getContentResolver() 
        .bulkInsert(SunagoContentProvider.CONTENT_URI, 
           values.toArray(new ContentValues[0])); 
      } 
    }  

我们需要确保网络操作不在用户界面线程上执行,因此我们在AsyncTask中执行工作。我们不需要将任何参数传递到任务中,因此我们将ParamsProgress类型设置为Void。然而,我们对Result类型感兴趣,它是List<ContentValue>类型,这在类型声明和execute()的返回类型中都有体现。然后在onPostExecute()中,我们对ContentProvider执行批量插入以保存数据。这样,我们可以在不违反 1 MB 限制的情况下,通过IBinder使新检索到的数据对应用程序可用。

在定义了我们的服务之后,我们现在需要看看如何查找和绑定服务。回顾MainActivity,我们最终将查看一个已经提到过的方法,即findPlugins()

    private void findPlugins() { 
     Intent baseIntent = new Intent(PLUGIN_ACTION); 
     baseIntent.setFlags(Intent.FLAG_DEBUG_LOG_RESOLUTION); 
     List<ResolveInfo> list = getPackageManager() 
            .queryIntentServices(baseIntent, 
            PackageManager.GET_RESOLVED_FILTER); 
     for (ResolveInfo rinfo : list) { 
        ServiceInfo sinfo = rinfo.serviceInfo; 
        if (sinfo != null) { 
            plugins.add(new  
                ComponentName(sinfo.packageName, sinfo.name)); 
        } 
      } 
    } 

为了找到我们感兴趣的插件,我们创建一个具有特定操作的Intent。在这种情况下,该操作是com.steeplesoft.sunago.intent.plugin,我们已经在AndroidManifest.xml中的服务定义中看到过。使用此Intent,我们查询PackageManager以获取所有匹配 Intent 的IntentServices。接下来,我们遍历ResolveInfo实例的列表,获取ServiceInfo实例,并创建并存储一个代表插件的ComponentName

实际的服务绑定是在下面的bindPlugins()方法中完成的,我们从onStart()方法中调用它,以确保在活动生命周期中适当的时间进行绑定:

    private void bindPluginServices() { 
      for (ComponentName plugin : plugins) { 
        Intent intent = new Intent(); 
        intent.setComponent(plugin); 
        PluginServiceConnection conn =  
            new PluginServiceConnection(); 
        pluginServiceConnections.add(conn); 
        bindService(intent, conn, Context.BIND_AUTO_CREATE); 
      } 
    } 

对于找到的每个插件,我们使用之前创建的ComponentName创建一个Intent。每个服务绑定都需要一个ServiceConnection对象。为此,我们创建了PluginServiceConnection,它实现了该接口。其方法都是空的,所以我们在这里不会查看该类。有了我们的ServiceConnection实例,我们现在可以通过调用bindService()来绑定服务。

最后,当应用程序关闭时,我们需要解绑我们的服务。从onStop()方法中,我们调用此方法:

    private void releasePluginServices() { 
      for (PluginServiceConnection conn :  
            pluginServiceConnections) { 
        unbindService(conn); 
      } 
      pluginServiceConnections.clear(); 
    } 

在这里,我们只是简单地遍历我们的ServiceConnection插件,将每个传递给unbindService(),这将允许 Android 垃圾回收我们可能已启动的任何服务。

到目前为止,我们已经定义了一个服务,查找并绑定它。但我们如何与之交互呢?我们将走一条简单的路线,并添加一个选项菜单项。为此,我们按照以下方式修改res/menu/main_menu.xml

    <menu  

      > 
      <item android:id="@+id/action_settings"  
        android:orderInCategory="100"  
        android: 
        app:showAsAction="never" /> 
     <item android:id="@+id/action_refresh"  
        android:orderInCategory="100"  
        android: 
        app:showAsAction="never" /> 
    </menu> 

为了响应当前菜单项的选择,我们需要重新审视这里的onOptionsItemSelected()方法:

    @Override 
    public boolean onOptionsItemSelected(MenuItem item) { 
      switch (item.getItemId()) { 
        case R.id.action_settings: 
            showPreferencesActivity(); 
            return true; 
        case R.id.action_refresh: 
            sendRefreshMessage(); 
            break; 
       } 

     return super.onOptionsItemSelected(item); 
    } 

在前面代码的switch块中,我们为R.id.action_refresh添加了一个case标签,它与我们的新添加的菜单项的 ID 相匹配,在其中我们调用sendRefreshMessage()方法:

    private void sendRefreshMessage() { 
      sendMessage("REFRESH"); 
    } 

    private void sendMessage(String message) { 
      Intent intent = new Intent("sunago.service"); 
      intent.putExtra("message", message); 
      sendBroadcast(intent); 
    } 

第一种方法相当直接。实际上,鉴于其简单性,它可能甚至不是必需的,但它确实为消费代码增加了语义清晰度,所以我认为这是一个很好的方法来添加。

然而,有趣的部分是sendMessage()方法。我们首先创建一个指定我们的操作sunago.serviceIntent。这是一个我们定义的任意字符串,并且为任何第三方消费者进行文档说明。这将帮助我们的服务过滤掉无兴趣的消息,这正是我们在TwitterService.onBind()中通过调用registerReceiver(receiver, new IntentFilter("sunago.service"))所做的事情。然后,我们将我们的应用程序想要发送的消息(在这种情况下为REFRESH)作为Intent上的额外内容添加,然后通过sendBroadcast()广播。从这里开始,Android 将处理将消息传递到我们的服务,该服务已经运行(因为我们将其绑定到我们的Activity)并且正在监听(因为我们注册了一个BroadcastReceiver)。

Android 标签和片段

我们已经看到了很多,但还有很多我们没有看到,比如TwitterClient的实现,以及关于 Instagram 等网络集成的任何细节,这些我们在上一章中已经看到。就大部分而言,TwitterClient与我们看到的第二十一章中的内容相同,即Sunago - 社交媒体聚合器。唯一的重大区别在于流 API 的使用。某些 API 仅在特定的 Android 版本中可用,具体来说,是版本 24,也称为 Nougat。由于我们针对的是 Lollipop(SDK 版本 21),我们无法使用它们。除此之外,内部逻辑和 API 使用是相同的。您可以在源代码库中查看详细信息。在我们完成之前,我们还需要查看 Twitter 首选项屏幕,因为那里有一些有趣的项目。

我们将从标签布局活动开始,如下所示:

    public class PreferencesActivity extends AppCompatActivity { 
      private SectionsPagerAdapter sectionsPagerAdapter; 
      private ViewPager viewPager; 

      @Override 
      protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.activity_preferences); 

        setSupportActionBar((Toolbar) findViewById(R.id.toolbar)); 
        sectionsPagerAdapter =  
        new SectionsPagerAdapter(getSupportFragmentManager()); 

        viewPager = (ViewPager) findViewById(R.id.container); 
        viewPager.setAdapter(sectionsPagerAdapter); 

        TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs); 
        tabLayout.setupWithViewPager(viewPager); 
    } 

要创建标签界面,我们需要两个东西——FragmentPagerAdapterViewPagerViewPager是一个用户界面元素,实际上显示了标签。将其视为标签的ListView。那么,FragmentPagerAdapter就像标签的CursorAdapter。然而,与基于 SQL 的数据源不同,FragmentPagerAdapter是一个将页面表示为片段的适配器。在这个方法中,我们创建了一个SectionsPagerAdapter的实例,并将其设置为ViewPager上的适配器。我们还关联了ViewPager元素与TabLayout

SectionsPagerAdapter是一个简单的类,编写方式如下:

    public class SectionsPagerAdapter extends FragmentPagerAdapter { 
      public SectionsPagerAdapter(FragmentManager fm) { 
      super(fm); 
    } 

    @Override 
    public Fragment getItem(int position) { 
        switch (position) { 
            case 0 : 
                return new TwitterPreferencesFragment(); 
            case 1 : 
                return new InstagramPreferencesFragment(); 
            default: 
                throw new RuntimeException("Invalid position"); 
        } 
     } 

     @Override 
     public int getCount() { 
        return 2; 
     } 

     @Override 
     public CharSequence getPageTitle(int position) { 
        switch (position) { 
            case 0: 
                return "Twitter"; 
            case 1: 
                return "Instagram"; 
       } 
        return null; 
     } 
    } 

getCount()方法告诉系统我们支持多少个标签,每个标签的标题由getPageTitle()返回,而表示所选标签的Fragment则由getItem()返回。在这个例子中,我们根据需要创建一个Fragment实例。注意,我们在这里暗示了对 Instagram 的支持,但其实现与 Twitter 的实现非常相似,所以在这里我们不会详细介绍。

TwitterPreferencesFragment看起来如下:

    public class TwitterPreferencesFragment extends Fragment { 
      @Override 
       public View onCreateView(LayoutInflater inflater,  
       ViewGroup container, Bundle savedInstanceState) { 
       return inflater.inflate( 
        R.layout.fragment_twitter_preferences,  
        container, false); 
     } 

      @Override 
      public void onStart() { 
        super.onStart(); 
        updateUI(); 
      } 

Activity相比,片段(Fragments)具有稍微不同的生命周期。在这里,我们在onCreateView()中填充视图,然后从onStart()中更新用户界面以显示当前状态。视图看起来是什么样子?这由R.layout.fragment_twitter_preferences决定。

    <LinearLayout  

      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:paddingBottom="@dimen/activity_vertical_margin" 
      android:paddingLeft="@dimen/activity_horizontal_margin" 
      android:paddingRight="@dimen/activity_horizontal_margin" 
      android:paddingTop="@dimen/activity_vertical_margin" 
      android:orientation="vertical"> 

     <Button 
       android:text="Login" 
       android:layout_width="wrap_content" 
       android:layout_height="wrap_content" 
       android:id="@+id/connectButton" /> 

     <LinearLayout 
       android:orientation="vertical" 
       android:layout_width="match_parent" 
       android:layout_height="match_parent" 
       android:id="@+id/twitterPrefsLayout"> 

     <CheckBox 
       android:text="Include the home timeline" 
       android:layout_width="match_parent" 
       android:layout_height="wrap_content" 
       android:id="@+id/showHomeTimeline" /> 

     <TextView 
       android:text="User lists to include" 
       android:layout_width="match_parent" 
       android:layout_height="wrap_content" 
       android:id="@+id/textView2" /> 

     <ListView 
       android:layout_width="match_parent" 
       android:layout_height="match_parent" 
       android:id="@+id/userListsListView" /> 
     </LinearLayout> 
    </LinearLayout> 

简而言之,如您在前面代码中所见,我们有一个用于登录和注销的按钮,以及一个ListView,允许用户选择从哪些 Twitter 列表加载数据。

由于频繁使用网络与 Twitter 交互,以及 Android 对在用户界面线程上访问网络的厌恶,这里的代码变得有些复杂。我们可以在updateUI()中看到这一点,如下所示:

    private void updateUI() { 
      getActivity().runOnUiThread(new Runnable() { 
        @Override 
        public void run() { 
          final Button button = (Button)  
          getView().findViewById(R.id.connectButton); 
          final View prefsLayout =  
          getView().findViewById(R.id.twitterPrefsLayout); 
          if (!SunagoUtil.getPreferences().getBoolean( 
          getString(R.string.twitter_authd), false)) { 
            prefsLayout.setVisibility(View.GONE); 
            button.setOnClickListener( 
              new View.OnClickListener() { 
            @Override 
            public void onClick(View view) { 
             new TwitterAuthenticateTask().execute(); 
            } 
            }); 
            } else { 
              button.setText(getString(R.string.logout)); 
              button.setOnClickListener( 
              new View.OnClickListener() { 
                @Override 
                public void onClick(View view) { 
                 final SharedPreferences.Editor editor =  
                 SunagoUtil.getPreferences().edit(); 
                 editor.remove(getString( 
                 R.string.twitter_oauth_token)); 
                 editor.remove(getString( 
                 R.string.twitter_oauth_secret)); 
                 editor.putBoolean(getString( 
                 R.string.twitter_authd), false); 
                 editor.commit(); 
                 button.setText(getString(R.string.login)); 
                 button.setOnClickListener( 
                 new LoginClickListener()); 
               } 
              }); 

               prefsLayout.setVisibility(View.VISIBLE); 
               populateUserList(); 
              } 
            } 
        });  
      }

在最后一段代码中,最引人注目的是第一行。由于我们正在更新用户界面,我们必须确保此代码在用户界面线程上运行。为了实现这一点,我们将我们的逻辑包装在一个 Runnable 中,并将其传递给 runOnUiThread() 方法。在 Runnable 中,我们检查用户是否已登录。如果没有,我们将 prefsLayout 部分的可见性设置为 GONE,将 Button 的文本设置为登录,并将其 onClick 监听器设置为执行 TwitterAuthenticateTaskView.OnClickListener 方法。

如果用户未登录,我们执行相反的操作--使 prefsLayout 可见,将 Button 文本设置为注销,将 onClick 设置为一个匿名 View.OnClickListener 类,该类删除与身份验证相关的首选项,并递归调用 updateUI() 确保界面更新以反映注销操作。

TwitterAuthenticateTask 是另一个处理与 Twitter 进行身份验证的 AsyncTask。为了进行身份验证,我们必须获取一个 Twitter 请求令牌,这需要网络访问,因此必须在用户界面线程之外完成,这就是为什么使用 AsyncTask。请参考以下代码片段:

    private class TwitterAuthenticateTask extends  
        AsyncTask<String, String, RequestToken> { 
      @Override 
      protected void onPostExecute(RequestToken requestToken) { 
        super.onPostExecute(requestToken); 

        Intent intent = new Intent(getContext(),  
          WebLoginActivity.class); 
        intent.putExtra("url",  
          requestToken.getAuthenticationURL()); 
        intent.putExtra("queryParam", "oauth_verifier"); 
        startActivityForResult(intent, LOGIN_REQUEST); 
      } 

      @Override 
      protected RequestToken doInBackground(String... strings) { 
        try { 
          return TwitterClient.instance().getRequestToken(); 
        } catch (TwitterException e) { 
          throw new RuntimeException(e); 
        } 
      } 
    } 

一旦我们有了 RequestToken,我们就显示 WebLoginActivity,用户将在此处输入服务的凭据。我们将在下一个代码中查看这一点。

当该活动返回时,我们需要检查结果并相应地做出反应。

    public void onActivityResult(int requestCode, int resultCode,  
    Intent data) { 
      super.onActivityResult(requestCode, resultCode, data); 
      if (requestCode == LOGIN_REQUEST) { 
        if (resultCode == Activity.RESULT_OK) { 
            new TwitterLoginAsyncTask() 
                .execute(data.getStringExtra("oauth_verifier")); 
        } 
      } 
    } 

当我们启动 WebLoginActivity 时,我们指定我们想要获取一个结果,并指定了一个标识符,LOGIN_REQUEST,将其设置为 1,以唯一标识哪个 Activity 正在返回结果。如果 requestCodeLOGIN_REQUEST,并且结果代码是 Activity.RESULT_OK(见下面的 WebLoginActivity),那么我们有一个成功的响应,我们需要完成登录过程,我们将使用另一个 AsyncTask

    private class TwitterLoginAsyncTask  
    extends AsyncTask<String, String, AccessToken> { 
      @Override 
      protected AccessToken doInBackground(String... codes) { 
        AccessToken accessToken = null; 
        if (codes != null && codes.length > 0) { 
            String code = codes[0]; 
            TwitterClient twitterClient =  
              TwitterClient.instance(); 
            try { 
              accessToken = twitterClient.getAcccessToken( 
                twitterClient.getRequestToken(), code); 
            } catch (TwitterException e) { 
              e.printStackTrace(); 
            } 
            twitterClient.authenticateUser(accessToken.getToken(),  
              accessToken.getTokenSecret()); 
           } 

        return accessToken; 
       } 

      @Override 
      protected void onPostExecute(AccessToken accessToken) { 
        if (accessToken != null) { 
          SharedPreferences.Editor preferences =  
            SunagoUtil.getPreferences().edit(); 
          preferences.putString(getString( 
              R.string.twitter_oauth_token),  
            accessToken.getToken()); 
          preferences.putString(getString( 
              R.string.twitter_oauth_secret),  
            accessToken.getTokenSecret()); 
          preferences.putBoolean(getString( 
             R.string.twitter_authd), true); 
            preferences.commit(); 
          updateUI(); 
        } 
      } 
    } 

doInBackground() 中,我们执行网络操作。当我们有一个结果,即 AccessToken 时,我们使用它来验证我们的 TwitterClient 实例,然后返回令牌。在 onPostExecute() 中,我们将 AccessToken 的详细信息保存到 SharedPreferences。技术上,所有这些都可以在 doInBackground() 中完成,但我发现,特别是在学习新事物时,不偷工减料是有帮助的。一旦你对所有这些如何工作感到舒适,当然,你当然可以在你觉得舒服的时候和地方偷工减料。

我们还有最后一部分要检查,WebLoginActivity。从功能上讲,它与 LoginActivity 相同--它显示一个网页视图,显示给定网络的登录页面。当登录成功时,所需的信息将返回给调用代码。由于这是 Android 而不是 JavaFX,因此机制当然略有不同。

    public class WebLoginActivity extends AppCompatActivity { 
      @Override 
      protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.activity_web_view); 
        setTitle("Login"); 
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); 
        setSupportActionBar(toolbar); 
        Intent intent = getIntent(); 
        final String url = intent.getStringExtra("url"); 
        final String queryParam =  
            intent.getStringExtra("queryParam"); 
        WebView webView = (WebView)findViewById(R.id.webView); 
        final WebViewClient client =  
            new LoginWebViewClient(queryParam); 
        webView.setWebViewClient(client); 
        webView.loadUrl(url); 
      } 

之前的大部分代码看起来非常像我们编写的其他 Activity 类。我们进行了一些基本的用户界面设置,然后获取 Intent 的引用,提取两个感兴趣的参数——登录页面的 URL 和表示成功登录的查询参数。

要参与页面加载生命周期,我们扩展 WebViewClient(然后将其附加到 Activity 中的 WebView,如之前所见)。这是通过以下方式完成的:

    private class LoginWebViewClient extends WebViewClient { 
      private String queryParam; 

      public LoginWebViewClient(String queryParam) { 
        this.queryParam = queryParam; 
      } 

     @Override 
     public void onPageStarted(WebView view, String url,  
            Bitmap favicon) { 
        final Uri uri = Uri.parse(url); 
        final String value = uri.getQueryParameter(queryParam); 
        if (value != null) { 
            Intent resultIntent = new Intent(); 
            for (String name : uri.getQueryParameterNames()) { 
                resultIntent.putExtra(name,  
                    uri.getQueryParameter(name)); 
            } 
            setResult(Activity.RESULT_OK, resultIntent); 
            finish(); 
        } 
        super.onPageStarted(view, url, favicon); 
       } 
   } 

虽然 WebViewClient 提供了众多生命周期事件,但我们目前只关注其中一个,即 onPageStarted(),它会在页面开始加载时触发。正如预期的那样,在这里挂钩,我们可以在相关的网络活动开始之前查看 URL。我们可以检查所需的 URL 是否包含感兴趣的查询参数。如果存在,我们创建一个新的 Intent 来将数据传回调用者,将所有查询参数复制到其中,将 Activity 的结果设置为 RESULT_OK,并结束 Activity。如果你回顾一下 onActivityResult(),你现在应该可以看到 resultCode 的来源。

摘要

有了这些,我们的应用程序就完成了。它不是一个完美的应用程序,但它是一个完整的 Android 应用程序,展示了你可能在自己的应用程序中需要的许多功能,包括 Activities、服务、数据库创建、内容提供者、消息传递和异步处理。显然,应用程序中的一些错误处理可以更健壮,或者设计可以更通用一些,以便更容易重用。然而,在这个上下文中这样做会过多地掩盖应用程序的基本原理。因此,对这些更改进行修改将是一个很好的练习,供读者参考。

在下一章中,我们将查看一种完全不同类型的应用程序。我们将构建一个小型实用程序来处理可能是一个严重问题——邮件过多。这个应用程序将允许我们描述一系列规则来删除或移动邮件。这是一个简单的概念,但它将允许我们与 JSON API 和 JavaMail 包一起工作。你将学到一些东西,并最终得到一个有用的实用程序。

第二十五章:使用 MailFilter 进行电子邮件和垃圾邮件管理

在计算机科学中,我们有许多定律,其中最著名的可能是摩尔定律,它描述了计算机处理能力增加的速度。另一条定律,虽然不那么知名,而且肯定不那么严重,被称为扎文斯基定律。最著名的角色是 Netscape 和 Mozilla 的杰米·扎文斯基,他曾经指出:“每个程序都试图扩展,直到它可以阅读邮件。那些无法扩展的程序将被可以扩展的程序所取代。”尽管扎文斯基定律并没有像摩尔定律那样准确,但它似乎确实有一定的真实性,不是吗?

按照扎文斯基定律的精神,即使不完全符合字面意思,我们将在本章中将注意力转向电子邮件,并看看我们是否可以解决困扰我们所有人的问题:电子邮件杂乱。从垃圾邮件到邮件列表帖子,这些消息不断涌现,并且不断堆积。

我有几个电子邮件账户。作为我家中的负责人和首席技术专家,我经常被要求管理我们的数字资产,无论他们是否意识到这一点。虽然一小块垃圾邮件可能看起来微不足道,但时间久了,它可能成为一个真正的问题。在某个时刻,它几乎似乎难以处理。

在本章中,我们将探讨一个非常真实的问题,即使可能有些夸张,并尝试解决它。这将为我们使用标准的 Java 电子邮件 API,即 JavaMail,提供一个完美的借口。

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

  • JavaMail API

  • 电子邮件协议

  • 一些额外的 JavaFX 工作(当然)

  • 使用 Quartz 在 Java 中创建工作计划

  • 安装用 Java 编写的特定于操作系统的服务

可能你已很好地控制了你的电子邮件收件箱,在这种情况下,恭喜你!然而,无论你的邮件客户端多么整洁或令人不知所措,我们在这章中应该享受探索小巧但功能强大的 JavaMail API 和电子邮件美妙世界的乐趣。

入门指南

在我们深入应用之前,让我们停下来快速看一下电子邮件涉及的内容。对于这样一个无处不在的工具,它似乎对大多数人来说都是一个相当晦涩的话题,即使是那些可能倾向于阅读这本书的技术人员。如果我们打算与之合作,了解它将非常有帮助,即使只是了解一点。如果你对协议的细节不感兴趣,那么请随意跳到下一节。

简要回顾电子邮件协议的历史

就像许多伟大的计算概念一样,电子邮件——电子邮件——最早在 20 世纪 60 年代推出,尽管那时的样子大不相同。电子邮件的详尽历史,虽然无疑是一个伟大的技术好奇心,但超出了我们目的的范围,但我认为查看一些今天仍然相关的电子邮件协议会有所帮助,这些协议包括用于发送邮件的 SMTP,以及用于(从您的电子邮件客户端的角度看)接收邮件的 POP3 和 IMAP。(技术上,电子邮件是通过 SMTP 接收的,因为这是邮件传输代理(MTA)在服务器之间传输邮件时使用的有线协议。我们这些非 MTA 的作者从未从这些角度考虑过,所以我们不需要过分关注这个区别)。

我们将从发送电子邮件开始,因为在本章中,我们的重点将更多地放在文件夹管理上。SMTP简单邮件传输协议),于 1982 年创建,并于 1998 年最后更新,是发送电子邮件的主要协议。通常,在 SSL 和 TLS 加密连接的时代,客户端通过端口 587 连接到 SMTP 服务器。服务器与客户端之间的对话,通常被称为对话,可能看起来像这样(摘自 SMTP RFC tools.ietf.org/html/rfc5321):

    S: 220 foo.com Simple Mail Transfer Service Ready
    C: EHLO bar.com
    S: 250-foo.com greets bar.com
    S: 250-8BITMIME
    S: 250-SIZE
    S: 250-DSN
    S: 250 HELP
    C: MAIL FROM:<Smith@bar.com>
    S: 250 OK
    C: RCPT TO:<Jones@foo.com>
    S: 250 OK
    C: RCPT TO:<Green@foo.com>
    S: 550 No such user here
    C: RCPT TO:<Brown@foo.com>
    S: 250 OK
    C: DATA
    S: 354 Start mail input; end with <CRLF>.<CRLF>
    C: Blah blah blah...
    C: ...etc. etc. etc.
    C: .
    S: 250 OK
    C: QUIT
    S: 221 foo.com Service closing transmission channel

在这个简单的例子中,客户端与服务器握手,然后说明邮件来自谁以及将发送给谁。请注意,电子邮件地址被列出了两次,但只有这些第一次实例(MAIL FROMRCPT TO,后者为每个收件人重复)才是重要的。第二组只是为了格式化和显示电子邮件。注意这个特殊性,实际的电子邮件在DATA行之后,这应该相当容易理解。行上的单个句点标志着消息的结束,此时服务器确认收到消息,我们通过说QUIT来结束会话。这个例子看起来非常简单,确实如此,但当消息包含附件,如图片或办公文档,或者电子邮件以 HTML 格式格式化时,事情会变得更加复杂。

虽然 SMTP 用于发送邮件,但 POP3 协议用于检索邮件。POP,或邮局协议,首次于 1984 年推出。当前标准的主体,POP3,于 1988 年推出,并在 1996 年发布更新。POP3 服务器旨在通过客户端(如 Mozilla Thunderbird)接收或下载邮件。如果服务器允许,客户端可以在端口 110 上建立非安全连接,而安全连接通常在端口 995 上建立。

在某个时候,POP3 是用户下载邮件占主导地位的协议。它快速高效,一度是我们的唯一选择。文件夹管理必须在客户端完成,因为 POP3 将邮箱视为一个巨大的存储库,没有任何文件夹的概念(POP4 原本打算添加一些文件夹的概念,以及其他一些功能,但关于提议的 RFC 已经几年没有进展了)。POP3(RC 1939,可在tools.ietf.org/html/rfc1939找到)给出了以下示例对话:

    S: <wait for connection on TCP port 110>
    C: <open connection>
    S:    +OK POP3 server ready <1896.697170952@dbc.mtview.ca.us>
    C:    APOP mrose c4c9334bac560ecc979e58001b3e22fb
    S:    +OK mrose's maildrop has 2 messages (320 octets)
    C:    STAT
    S:    +OK 2 320
    C:    LIST
    S:    +OK 2 messages (320 octets)
    S:    1 120
    S:    2 200
    S:    .
    C:    RETR 1
    S:    +OK 120 octets
    S:    <the POP3 server sends message 1>
    S:    .
    C:    DELE 1
    S:    +OK message 1 deleted
    C:    RETR 2
    S:    +OK 200 octets
    S:    <the POP3 server sends message 2>
    S:    .
    C:    DELE 2
    S:    +OK message 2 deleted
    C:    QUIT
    S:    +OK dewey POP3 server signing off (maildrop empty)
    C:  <close connection>
    S:  <wait for next connection>

注意,客户端发送一个RETR命令来检索邮件,然后发送一个DELE命令将其从服务器上删除。这似乎是大多数 POP3 客户端的标准/默认配置。

尽管如此,许多客户端可以配置为将邮件保留在服务器上,要么是几天,要么是永远,当本地删除邮件时,可能会从服务器上删除邮件。如果你曾经这样管理过邮件,你亲自见证了这如何使电子邮件管理变得复杂。

例如,在笔记本电脑出现之前的日子里,想象一下你在办公室和家里各有一台台式电脑。你希望能够在两个地方阅读你的电子邮件,所以你在两台机器上设置了你的 POP3 客户端。你花费工作日阅读、删除,也许还会对邮件进行分类。当你回到家时,那些你在工作中处理的 40 封邮件现在都躺在你的收件箱里,用大号粗体字标明未读邮件。现在,如果你希望保持两个客户端的状态相似,你必须在家里重复你的电子邮件管理任务。这既繁琐又容易出错,这也导致了我们创建 IMAP。

IMAP互联网访问消息协议,于 1986 年创建,其中一个设计目标是允许多个客户端完整地管理邮箱、文件夹等。它多年来经历了多次修订,目前 IMAP 4 修订 1 是当前的标准。客户端通过 143 端口连接到 IMAP 服务器进行非安全连接,以及 993 端口进行基于 SSL/TLS 的连接。

由于 IMAP 提供了比 POP 更强大的功能,因此它是一个更复杂的协议。从 RFC(tools.ietf.org/html/rfc3501)中,我们可以查看以下示例对话:

    S:   * OK IMAP4rev1 Service Ready 
    C:   a001 login mrc secret 
    S:   a001 OK LOGIN completed 
    C:   a002 select inbox 
    S:   * 18 EXISTS 
    S:   * FLAGS (Answered Flagged Deleted Seen Draft) 
    S:   * 2 RECENT 
    S:   * OK [UNSEEN 17] Message 17 is the first unseen message 
    S:   * OK [UIDVALIDITY 3857529045] UIDs valid 
    S:   a002 OK [READ-WRITE] SELECT completed 
    C:   a003 fetch 12 full 
    S:   * 12 FETCH (FLAGS (Seen) INTERNALDATE 
         "17-Jul-1996 02:44:25 -0700" 
      RFC822.SIZE 4286 ENVELOPE ("Wed,
         17 Jul 1996 02:23:25 -0700 (PDT)" 
      "IMAP4rev1 WG mtg summary and minutes" 
      (("Terry Gray" NIL "gray" "cac.washington.edu")) 
      (("Terry Gray" NIL "gray" "cac.washington.edu")) 
      (("Terry Gray" NIL "gray" "cac.washington.edu")) 
      ((NIL NIL "imap" "cac.washington.edu")) 
      ((NIL NIL "minutes" "CNRI.Reston.VA.US") 
      ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL 
      "<B27397-0100000@cac.washington.edu>") 
       BODY ("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 3028 
       92)) 
    S:    a003 OK FETCH completed 
    C:    a004 fetch 12 body[header] 
    S:    * 12 FETCH (BODY[HEADER] {342} 
    S:    Date: Wed, 17 Jul 1996 02:23:25 -0700 (PDT) 
    S:    From: Terry Gray <gray@cac.washington.edu> 
    S:    Subject: IMAP4rev1 WG mtg summary and minutes 
    S:    To: imap@cac.washington.edu 
    S:    cc: minutes@CNRI.Reston.VA.US, John Klensin <KLENSIN@MIT.EDU> 
    S:    Message-Id: <B27397-0100000@cac.washington.edu> 
    S:    MIME-Version: 1.0 
    S:    Content-Type: TEXT/PLAIN; CHARSET=US-ASCII 
    S: 
    S:    ) 
    S:    a004 OK FETCH completed 
    C:    a005 store 12 +flags deleted 
    S:    * 12 FETCH (FLAGS (Seen Deleted)) 
    S:    a005 OK +FLAGS completed 
    C:    a006 logout 
    S:    * BYE IMAP4rev1 server terminating connection 
    S:    a006 OK LOGOUT completed 

如您所见,这里的细节比我们示例中的 POP3 对话要多得多。这也应该突出我们为什么使用像 JavaMail 这样的 API,而不是自己打开套接字直接与服务器通信。说到 JavaMail,让我们把注意力转向这个标准 API,看看它能为我们做什么。

JavaMail,标准的 Java 电子邮件 API

JavaMail API 是一组抽象,提供了一种协议和平台无关的方式来处理电子邮件。虽然它是Java 企业版Java EE)的一个必需部分,但它是对 Java SE 的附加库,这意味着你将不得不单独下载它,我们将通过我们的 POM 文件来处理。

我们对本章应用程序的主要兴趣是消息管理,但我们会花一点时间看看如何使用 API 发送电子邮件,这样你就可以在需要时有所作为。

要开始发送邮件,我们需要获取一个 JavaMail Session。为此,我们需要设置一些属性,如下所示:

    Properties props = new Properties(); 
    props.put("mail.smtps.host", "smtp.gmail.com"); 
    props.put("mail.smtps.auth", "true"); 
    props.put("mail.smtps.port", "465"); 
    props.put("mail.smtps.ssl.trust", "*"); 

我们将通过 Gmail 的服务器发送电子邮件,我们将使用 SSL 的 SMTP。使用这个Properties实例,我们可以创建我们的Session实例,如下所示:

    Session session = Session.getInstance(props,  
      new javax.mail.Authenticator() { 
      @Override 
      protected PasswordAuthentication getPasswordAuthentication() { 
        return new PasswordAuthentication(userName, password); 
      } 
    }); 

要登录到服务器,我们需要指定凭证,我们通过匿名的PasswordAuthentication实例来做到这一点。一旦我们有了Session实例,我们需要创建一个Transport,如下所示:

    transport = session.getTransport("smtps"); 
      transport.connect(); 

注意,对于协议参数,我们指定了smtps,这告诉 JavaMail 实现我们想要 SMTP over SSL/TLS。我们现在准备好使用以下代码块构建我们的消息:

    MimeMessage message = new MimeMessage(session); 
    message.setFrom("jason@steeplesoft.com"); 
    message.setRecipients(Message.RecipientType.TO, 
      "jason@steeplesoft.com"); 
    message.setSubject("JavaMail Example"); 

电子邮件消息是通过MimeMessage类建模的,因此我们使用我们的Session实例创建该实例。我们设置了发件人和收件人地址,以及主题。为了使事情更有趣,我们将使用MimeBodyPart附加一个文件,就像我们在这里看到的那样:

    MimeBodyPart text = new MimeBodyPart(); 
    text.setText("This is some sample text"); 

    MimeBodyPart attachment = new MimeBodyPart(); 
    attachment.attachFile("src/test/resources/rules.json"); 

    Multipart multipart = new MimeMultipart(); 
    multipart.addBodyPart(text); 
    multipart.addBodyPart(attachment); 
    message.setContent(multipart); 

我们的消息将包含两部分,使用MimeBodyPart进行建模,一部分是消息的主体,它是简单的文本,另一部分是附件。在这种情况下,我们只是从我们的测试中附加一个数据文件,我们稍后会看到。一旦我们定义了这些部分,我们就使用MimeMultipart将它们组合起来,然后将它设置为消息的内容,现在我们可以使用transport.sendMessage()方法发送:

    transport.sendMessage(message, new Address[] { 
      new InternetAddress("jason@steeplesoft.com")}); 
      if (transport != null) { 
        transport.close();   
      }  

只需几秒钟,你应该会在你的收件箱中看到以下电子邮件:

如果你想要发送带有文本替代的 HTML 电子邮件,你可以使用以下代码:

    MimeBodyPart text = new MimeBodyPart(); 
    text.setContent("This is some sample text", "text/plain");  
    MimeBodyPart html = new MimeBodyPart(); 
    html.setContent("<strong>This</strong> is some <em>sample</em>
      <span style="color: red">text</span>", "text/html"); 
    Multipart multipart = new MimeMultipart("alternative"); 
    multipart.addBodyPart(text); 
    multipart.addBodyPart(html); 
    message.setContent(multipart); 
    transport.sendMessage(message, new Address[]{ 
      new InternetAddress("jason@example.com")});

注意,我们在每个MimeBodyPart上设置了内容,指定了 MIME 类型,当我们创建Multipart时,我们将替代作为subtype参数传递。如果不这样做,将会导致电子邮件显示两个部分,一个接一个,这肯定不是我们想要的。如果我们正确编写了我们的应用程序,我们应该在我们的电子邮件客户端看到以下内容:

当然,在黑白印刷中你看不到红色文本,但你可以看到粗体和斜体文本,这意味着显示了 HTML 版本,而不是文本版本。任务完成!

发送电子邮件很有趣,但我们在这里是为了学习文件夹和消息管理,所以让我们把注意力转向那个,我们首先设置我们的项目。

构建 CLI

这个项目,就像其他项目一样,将是一个多模块 Maven 项目。我们将有一个模块用于所有核心代码,我们还将有一个用于我们将编写的 GUI 的模块,以帮助管理规则。

要创建项目,我们这次将做一些不同的事情。我们不会使用 NetBeans 创建项目,而是将从命令行使用 Maven 架构创建项目,这可以大致看作是项目模板,因此你可以看到如何这样做:

    $ mvn archetype:generate  -DarchetypeGroupId=
      org.codehaus.mojo.archetypes  -DarchetypeArtifactId=pom-root -
      DarchetypeVersion=RELEASE 
      ... 
    Define value for property 'groupId': com.steeplesoft.mailfilter 
    Define value for property 'artifactId': mailfilter-master 
    Define value for property 'version':  1.0-SNAPSHOT 
    Define value for property 'package':  com.steeplesoft.mailfilter 

一旦 Maven 完成处理,请切换到新项目的目录,mailfilter-master。从这里,我们可以创建我们的第一个项目,CLI:

    $ mvn archetype:generate  -DarchetypeGroupId=
      org.apache.maven.archetypes  -DarchetypeArtifactId=
      maven-archetype-quickstart  -DarchetypeVersion=RELEASE 
    Define value for property 'groupId': com.steeplesoft.mailfilter 
    Define value for property 'artifactId': mailfilter-cli 
    Define value for property 'version':  1.0-SNAPSHOT 
    Define value for property 'package':  com.steeplesoft.mailfilter 

这将在 mailfilter-master 下创建一个名为 mailfilter-cli 的新项目。现在我们可以打开 NetBeans 中的 mailfilter-cli 并开始工作。

我们需要做的第一件事是明确我们希望这个工具如何工作。从高层次来看,我们希望能够为账户指定任意数量的规则。这些规则将允许我们根据某些标准(如发件人或电子邮件的年龄)移动或删除电子邮件。为了保持简单,我们将所有规则范围限定在特定账户内,并将操作限制为移动和删除。

让我们先看看账户可能的样子:

    public class Account { 
      @NotBlank(message="A value must be specified for serverName") 
      private String serverName; 
      @NotNull(message = "A value must be specified for serverPort") 
      @Min(value = 0L, message = "The value must be positive") 
      private Integer serverPort = 0; 
      private boolean useSsl = true; 
      @NotBlank(message = "A value must be specified for userName") 
      private String userName; 
      @NotBlank(message = "A value must be specified for password") 
      private String password; 
      private List<Rule> rules; 

这基本上是一个非常简单的 POJO (Plain Old Java Object),具有六个属性:serverNameserverPortuseSsluserNamepasswordrules。那么这些注解是什么呢?这些注解来自一个名为 Bean Validation 的库,它提供了一些注解和支持代码,允许我们声明性地表达变量可以持有的值的约束。以下是我们在使用的注解及其含义:

  • @NotBlank: 这告诉系统该值不能为 null,也不能为空字符串(实际上,string != null && !string.trim() .equals("")

  • @NotNull: 这告诉系统该值不能为 null

  • @Min: 这描述了一个有效的最小值

当然,还有很多很多其他的,系统定义了一种方法让你定义自己的,因此它是一个非常简单但非常强大的框架,用于验证输入,这提出了一个重要的问题:这些约束仅在 Bean Validation 框架被要求这样做时才会被验证。我们很容易构建一个包含每个字段都持有无效数据的 Account 实例的大集合,JVM 对此会非常满意。应用 Bean Validation 约束的唯一方法是要求它检查我们提供给它的实例。简而言之,是 API 而不是 JVM 强制执行这些约束。这看起来可能很明显,但有时明确指出是有好处的。

在我们继续之前,我们需要将 Bean Validation 添加到我们的项目中。我们将使用参考实现:Hibernate Validator。我们还需要在项目中添加表达式语言 API 和一个实现。我们通过在 pom.xml 中添加以下依赖项来获取所有这些:

    <dependency> 
      <groupId>org.hibernate</groupId> 
      <artifactId>hibernate-validator</artifactId> 
      <version>5.3.4.Final</version> 
    </dependency> 
    <dependency> 
      <groupId>javax.el</groupId> 
      <artifactId>javax.el-api</artifactId> 
      <version>2.2.4</version> 
    </dependency> 
    <dependency> 
      <groupId>org.glassfish.web</groupId> 
      <artifactId>javax.el</artifactId> 
      <version>2.2.4</version> 
    </dependency> 

回到我们的模型,当然有一些 getter 和 setter,但那些并不很有趣。然而,有趣的是equals()hashCode()的实现。Josh Bloch 在他的开创性作品《Effective Java》中说道:

当您重写equals时,始终重写hashCode

他断言的主要观点是,不这样做违反了equals()契约,该契约指出相等的对象必须具有相等的哈希值,如果您的类被用于任何基于哈希的集合,如HashMap,这可能会导致不正确和/或不可预测的行为。Bloch 随后列出了一些创建良好的hashCode实现以及良好的equals实现的规则,但我的建议是:让 IDE 为您完成工作,这正是我们在以下代码块中为equals()所做的事情:

    public boolean equals(Object obj) { 
      if (this == obj) { 
        return true; 
      } 
      if (obj == null) { 
        return false; 
      } 
      if (getClass() != obj.getClass()) { 
        return false; 
      } 
      final Account other = (Account) obj; 
      if (this.useSsl != other.useSsl) { 
        return false; 
      } 
      if (!Objects.equals(this.serverName, other.serverName)) { 
        return false; 
      } 
      if (!Objects.equals(this.userName, other.userName)) { 
        return false; 
      } 
      if (!Objects.equals(this.password, other.password)) { 
        return false; 
      } 
      if (!Objects.equals(this.serverPort, other.serverPort)) { 
        return false; 
      } 
      if (!Objects.equals(this.rules, other.rules)) { 
         return false; 
      } 
      return true; 
    } 

我们在这里对hashCode()也做了同样的处理:

    public int hashCode() { 
      int hash = 5; 
      hash = 59 * hash + Objects.hashCode(this.serverName); 
      hash = 59 * hash + Objects.hashCode(this.serverPort); 
      hash = 59 * hash + (this.useSsl ? 1 : 0); 
      hash = 59 * hash + Objects.hashCode(this.userName); 
      hash = 59 * hash + Objects.hashCode(this.password); 
      hash = 59 * hash + Objects.hashCode(this.rules); 
      return hash; 
    } 

注意在equals()中测试的每个方法也在hashCode()中使用。确保您的实现遵循此规则至关重要,否则您将得到实际上并不按预期工作的方法。您的 IDE 可能在生成方法时提供帮助,但您必须确保您确实使用了相同的字段列表,并且如果您修改了其中一个方法,另一个方法也必须相应地更新。

现在我们有了Account,那么Rule看起来是什么样子呢?让我们看一下以下代码片段:

    @ValidRule 
    public class Rule { 
      @NotNull 
      private RuleType type = RuleType.MOVE; 
      @NotBlank(message = "Rules must specify a source folder.") 
      private String sourceFolder = "INBOX"; 
      private String destFolder; 
      private Set<String> fields = new HashSet<>(); 
      private String matchingText; 
      @Min(value = 1L, message = "The age must be greater than 0.") 
      private Integer olderThan; 

这个类的验证有两方面。首先,我们可以看到与Account上相同的字段级别约束:type不能为空,sourceFolder不能为空,olderThan必须至少为 1。虽然您可能不认识它是什么,但我们还在@ValidRule中有一个类级别的约束。

字段级别的约束只能看到它们所应用的字段。这意味着如果一个字段的合法值依赖于另一个字段的值,这些类型的约束就不适用了。然而,类级别的规则允许我们在验证时查看整个对象,因此我们可以在验证另一个字段时查看一个字段的值。这也意味着我们需要更多的代码,所以我们将从以下注解开始:

    @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) 
    @Retention(RetentionPolicy.RUNTIME) 
    @Constraint(validatedBy = ValidRuleValidator.class) 
    @Documented 
    public @interface ValidRule { 
      String message() default "Validation errors"; 
      Class<?>[] groups() default {}; 
      Class<? extends Payload>[] payload() default {}; 
    } 

如果您之前从未见过注解的源代码,这是一个相当典型的例子。我们不是声明对象的类型为classinterface,而是使用了@interface,这是一个微妙但重要的区别。注解的字段也有所不同,因为没有可见性修饰符,类型也不能是原始类型。注意default关键字的用法。

注解本身也有注解,如下所示:

  • @Target:这限制了此注解可以应用到的元素类型;在这种情况下,类型和其他注解。

  • @Retention:这指示编译器是否应该将注解写入类文件,并在运行时使其可用。

  • @Constraint:这是一个 Bean Validation 注解,用于标识我们的注解作为一个新的约束类型。这个注解的值告诉系统哪个ConstraintValidator处理这个约束的验证逻辑。

  • @Documented:这表示在任何类型上出现此注解应被视为该类型公共 API 的一部分。

我们处理这个新约束的ConstraintValidator实现稍微复杂一些。我们声明这个类如下:

    public class ValidRuleValidator implements  
      ConstraintValidator<ValidRule, Object> { 

Bean Validation 提供了一个参数化接口用于约束验证,它接受约束的类型和验证器中逻辑应用的对象类型。这允许你为不同的对象类型编写给定约束的不同验证器。在我们的情况下,我们可以指定Rule而不是Object。如果我们这样做,任何不是Rule的其他东西被@ValidRule注解并验证时,调用代码将看到抛出异常。但你所做的,正如你将看到的,是验证注解的类型,具体来说,如果需要,添加约束违规。

接口要求我们也要实现这个方法,但在这里我们没有工作要做,所以它有一个空的方法体,如下所示:

    @Override 
    public void initialize(ValidRule constraintAnnotation) { 
    } 

有趣的方法是isValid()。它有点长,所以让我们一步一步地分析它:

    public boolean isValid(Object value,  
      ConstraintValidatorContext ctx) { 
        if (value == null) { 
          return true; 
        } 

第一步是确保value不是 null。我们有两种选择:如果它是 null,返回true,表示没有问题;或者返回false,表示有问题。我们的选择取决于我们希望应用程序如何表现。对于这两种方法都可以提出合理的论据,但似乎将 null 的Rule视为无效是有意义的,所以让我们改变它的主体,使其看起来像这样:

    ctx.disableDefaultConstraintViolation(); 
    ctx.buildConstraintViolationWithTemplate( 
      "Null values are not considered valid Rules") 
      .addConstraintViolation(); 
    return false; 

我们使用指定的消息构建ConstraintViolation,将其添加到ConstraintValidatorContextctx中,并返回false以指示失败。

接下来,我们想要确保我们正在处理Rule的一个实例:

    if (!(value instanceof Rule)) { 
      ctx.disableDefaultConstraintViolation(); 
      ctx.buildConstraintViolationWithTemplate( 
        "Constraint valid only on instances of Rule.") 
      .addConstraintViolation(); 
      return false; 
    } 

一旦我们确定我们有一个非空的Rule实例,我们就可以进入验证逻辑的核心:

    boolean valid = true; 
    Rule rule = (Rule) value; 
    if (rule.getType() == RuleType.MOVE) { 
      valid &= validateNotBlank(ctx, rule, rule.getDestFolder(),  
      "A destination folder must be specified."); 
    } 

我们希望能够收集所有的违规项,所以我们创建一个boolean变量来保存当前状态,然后将值转换为Rule以使处理实例更加自然。在我们的第一次测试中,我们确保如果Rule的类型是RuleType.MOVE,它指定了目标文件夹。我们使用这个私有方法来完成:

    private boolean validateNotBlank(ConstraintValidatorContext ctx,  
      String value, String message) { 
      if (isBlank(value)) { 
        ctx.disableDefaultConstraintViolation(); 
        ctx.buildConstraintViolationWithTemplate(message) 
        .addConstraintViolation(); 
        return false; 
      } 
      return true; 
   } 

如果value为空,我们就像之前看到的那样,使用指定的消息添加ConstraintViolation,并返回false。如果不是空的,我们返回true。这个值随后与valid进行 AND 操作以更新Rule验证的当前状态。

isBlank()方法非常简单:

    private boolean isBlank(String value) { 
      return (value == null || (value.trim().isEmpty())); 
    } 

这是一个非常常见的检查,实际上与 Bean Validation 背后的@NotBlank验证器在逻辑上是相同的。

我们接下来的两个测试是相关的。逻辑是这样的:规则必须指定要匹配的文本或最大年龄(以天为单位)。对这个测试的验证看起来像这样:

     if (!isBlank(rule.getMatchingText())) { 
       valid &= validateFields(ctx, rule); 
     } else if (rule.getOlderThan() == null) { 
       ctx.disableDefaultConstraintViolation(); 
       ctx.buildConstraintViolationWithTemplate( 
         "Either matchingText or olderThan must be specified.") 
       .addConstraintViolation(); 
       valid = false; 
     } 

如果 Rule 指定了 matchingText,那么我们验证 fields 是否已正确设置。如果既没有设置 matchingText 也没有设置 olderThan,那么我们添加一个带有相应信息的 ConstraintViolation 并将 valid 设置为 false。我们的 fields 验证看起来像这样:

    private boolean validateFields(ConstraintValidatorContext ctx, Rule rule) { 
      if (rule.getFields() == null || rule.getFields().isEmpty()) { 
        ctx.disableDefaultConstraintViolation(); 
        ctx.buildConstraintViolationWithTemplate( 
          "Rules which specify a matching text must specify the field(s)
            to match on.") 
          .addConstraintViolation(); 
        return false; 
      } 
      return true; 
    } 

我们确保 fields 既不是 null 也不是空的。虽然我们当然可以,但我们在这里不对字段 Set 的实际内容进行任何验证。

现在,我们可能已经编写了我们的第一个自定义验证。你的反应可能类似于,“哇!对于一个‘简单’的验证来说,代码太多了”,你说得对。在你把婴儿和洗澡水一起倒掉之前,想想这个:Bean Validation 的价值在于你可以将可能复杂的验证逻辑隐藏在一个非常小的注解后面。然后,你只需将约束注解放置在适当的位置,就可以在任何地方重用这个逻辑。逻辑在一个地方表达,在一个地方维护,但在很多地方使用,都非常整洁和简洁。

所以,是的,这是一段相当多的代码,但你只需要写一次,并且约束的消费者永远不需要看到它。实际上并没有多少额外的工作要做,但这是否值得你花时间,取决于你自己的决定。

现在我们快速浏览了自定义 Bean Validation 约束后,让我们回到我们的数据模型。最后要展示的部分是 RuleType 枚举:

    public enum RuleType { 
      DELETE, MOVE; 
      public static RuleType getRuleType(String type) { 
        switch(type.toLowerCase()) { 
          case "delete" : return DELETE; 
          case "move" : return MOVE; 
          default : throw new IllegalArgumentException( 
            "Invalid rule type specified: " + type); 
        } 
      } 
    } 

这是一个基本的 Java 枚举,包含两个可能的值,DELETEMOVE,但我们还增加了一个辅助方法来返回给定字符串表示的适当 RuleType 实例。这将在我们从 JSON 中反序列化 Rule 时帮助我们。

在定义了我们的数据模型后,我们准备开始编写工具本身的代码。虽然 Maven 模块被命名为 mailfilter-cli,但在这里我们不会关注一个健壮的命令行界面,就像我们在前面的章节中看到的那样。相反,我们将提供与命令行的非常基本的交互,将操作系统服务(我们稍后会查看)作为首选的使用方式。

就在这个时候,我们将开始使用 JavaMail API,因此我们需要确保我们的项目设置正确,所以我们向 pom.xml 中添加以下代码行:

    <dependency> 
      <groupId>com.sun.mail</groupId> 
      <artifactId>javax.mail</artifactId> 
      <version>1.5.6</version> 
    </dependency> 

在我们的 IDE 中,我们创建一个新的类 MailFilter,并创建熟悉的 public static void main 方法,如下所示:

    public static void main(String... args) { 
      try { 
        final MailFilter mailFilter =  
          new MailFilter(args.length > 0 ? args[1] : null); 
        mailFilter.run(); 
        System.out.println("tDeleted count: "  
          + mailFilter.getDeleted()); 
        System.out.println("tMove count:    "  
          + mailFilter.getMoved()); 
      } catch (Exception e) { 
        System.err.println(e.getLocalizedMessage()); 
      } 
    } 

NetBeans 支持许多代码模板。这里感兴趣的模板是psvm,它将创建一个public static void main方法。要使用它,确保你在类定义内的空行上(以避免格式问题),然后输入psvm并按 Tab 键。NetBeans 会为你创建方法并将光标放在空方法的第 1 行,准备你开始编码。你可以通过导航到工具 | 选项 | 编辑器 | 代码模板找到成百上千的其他有用的代码模板。你甚至可以定义自己的模板。

在我们的main()方法中,我们创建一个MainFilter实例,传入可能已在命令行中指定的任何规则定义文件,并调用run()

    public void run() { 
      try { 
        AccountService service = new AccountService(fileName); 

        for (Account account : service.getAccounts()) { 
          AccountProcessor processor =  
            new AccountProcessor(account); 
          processor.process(); 
          deleted += processor.getDeleteCount(); 
          moved += processor.getMoveCount(); 
        } 
      } catch (MessagingException ex) { 
        Logger.getLogger(MailFilter.class.getName()) 
        .log(Level.SEVERE, null, ex); 
      } 
    } 

我们首先创建一个AccountService实例,它封装了读取和写入Rules文件的细节。对于指定文件中的每个账户,我们创建一个AccountProcessor,它封装了规则处理逻辑。

AccountService实例可能听起来并不令人兴奋,但在这个公开接口后面隐藏着一些相当有趣的技术细节。我们看到 Bean Validation 约束实际上是如何被检查的,我们还看到了 Jackson JSON 库用于读取和写入Rules文件的使用。在我们开始使用 Jackson 之前,我们需要将其添加到我们的项目中,这通过添加以下pom.xml来完成:

    <dependency> 
      <groupId>com.fasterxml.jackson.core</groupId> 
      <artifactId>jackson-databind</artifactId> 
      <version>2.8.5</version> 
    </dependency> 

你应该始终确保你使用的是库的最新版本。

这个类一开始并不大,但这里只有三个方法值得关注。我们将从最基本的一个开始,如下所示:

    private File getRulesFile(final String fileName) { 
      final File file = new File(fileName != null ? fileName 
        : System.getProperty("user.home") + File.separatorChar 
        + ".mailfilter" + File.separatorChar + "rules.json"); 
      if (!file.exists()) { 
        throw new IllegalArgumentException( 
          "The rules file does not exist: " + rulesFile); 
      } 
      return file; 
    } 

我在这里包含这个的原因是,从用户的家目录中读取文件是我自己经常做的事情,你可能也会。这个例子展示了如何做到这一点,尝试在用户没有明确指定文件的情况下找到~/.mailfilter/rules.json的规则文件。无论是生成还是指定,如果找不到规则文件,我们会抛出一个异常。

可能最有趣的方法是getAccounts()方法。我们将逐步分析这个方法:

    public List<Account> getAccounts() { 
      final Validator validator = Validation 
        .buildDefaultValidatorFactory().getValidator(); 
      final ObjectMapper mapper = new ObjectMapper() 
        .configure(DeserializationFeature. 
        ACCEPT_SINGLE_VALUE_AS_ARRAY, true); 
      List<Account> accounts = null; 

这三个语句正在设置一些处理账户所需的对象。第一个是Validator,这是一个 Bean Validation 类,它是我们应用和检查我们在数据模型上描述的约束的入口点。接下来是ObjectMapper,这是一个 Jackson 类,它将 JSON 数据结构映射到我们的 Java 数据模型上。我们需要指定ACCEPT_SINGLE_VALUE_AS_ARRAY以确保 Jackson 正确处理模型中的任何列表。最后,我们创建List来保存我们的Account实例。

使用 Jackson 将规则文件读入内存并将其作为我们数据模型的实例非常简单:

    accounts = mapper.readValue(rulesFile,  
      new TypeReference<List<Account>>() {}); 

由于我们 Java 类中的属性名与我们的 JSON 文件中使用的键匹配,ObjectMapper可以轻松地从 JSON 文件中读取数据,并仅用这一行就构建我们的内存模型。注意TypeReference实例。我们希望 Jackson 返回一个List<Account>实例,但由于 JVM 中的一些设计决策,在运行时直接访问参数化类型是不可能的。然而,TypeReference类可以帮助捕获这些信息,然后 Jackson 在创建数据模型时使用这些信息。如果我们传递List.class,我们将在运行时得到类型转换失败。

现在我们有了Account实例,我们准备开始验证:

    accounts.forEach((account) -> { 
      final Set<ConstraintViolation<Account>> violations =  
        validator.validate(account); 
      if (violations.size() > 0) { 
        System.out.println( 
          "The rule file has validation errors:"); 
        violations.forEach(a -> System.out.println("  "" + a)); 
        throw new RuntimeException("Rule validation errors"); 
      } 
      account.getRules().sort((o1, o2) ->  
        o1.getType().compareTo(o2.getType())); 
    }); 

使用List.forEach(),我们遍历List中的每个账户(这里没有显示 null 检查)。对于每个Account,我们调用validator.validate(),这时约束条件实际上被验证。到目前为止,它们只是存储在类中的注释,JVM 愉快地带着它们,但没有对它们做任何事情。Bean Validation,如我们之前讨论的,是约束条件的执行者,在这里我们看到手动 API 调用。

validator的调用返回时,我们需要查看是否有任何ConstraintViolations。如果有,我们相当天真地打印一条消息到标准输出,详细说明每个失败。由于我们编写了验证器,如果规则有多个违规,我们一次就能看到它们,这样用户就可以一次性修复它们,而无需多次尝试处理规则。将这些打印到控制台不一定是最优的方法,因为我们不能以编程方式处理它们,但这对我们目前的需求来说是足够的。

在 Bean Validation 真正发光发热的地方是那些代表你进行集成的框架。例如,JAX-RS,这是构建 REST 资源的标准 Java API,提供了这种类型的集成。我们在这个示例 REST 资源方法中看到了功能的使用:

@GET

public Response getSomething (

@QueryParam("foo") @NotNull Integer bar) {

当请求被路由到这个方法时,JAX-RS 确保如果可能的话,将查询参数foo转换为Integer,并且它不是null,因此在你的代码中,你可以假设你有一个有效的Integer引用。

我们在这个类中想要查看的最后一个方法是saveAccounts(),它可能听起来很疯狂,但它将Account实例保存到指定的规则文件中:

    public void saveAccounts(List<Account> accounts) { 
      try { 
        final ObjectMapper mapper =  
          new ObjectMapper().configure(DeserializationFeature. 
          ACCEPT_SINGLE_VALUE_AS_ARRAY, true); 
        mapper.writeValue(rulesFile, accounts); 
      } catch (IOException ex) { 
        // ... 
      } 
    } 

与读取文件类似,只要你的 Java 类和你的 JSON 结构匹配,写入它就极其简单。如果你确实有不同的名称(例如,Java 类可能有accountName属性,而 JSON 文件使用account_name),Jackson 提供了一些可以应用于 POJO 的注释,以说明如何正确映射字段。你可以在 Jackson 的网站上找到这些注释的完整详细信息(github.com/FasterXML/jackson)。

当我们将 Account 实例加载到内存并验证其正确性后,我们现在需要处理它们。入口点是 process() 方法:

    public void process() throws MessagingException { 
      try { 
        getImapSession(); 

        for (Map.Entry<String, List<Rule>> entry :  
          getRulesByFolder(account.getRules()).entrySet()) { 
          processFolder(entry.getKey(), entry.getValue()); 
        } 
      } catch (Exception e) { 
        throw new RuntimeException(e); 
      } finally { 
        closeFolders(); 
        if (store != null) { 
          store.close(); 
        } 
      } 
    } 

我们需要注意的三行是调用 getImapSession()getRulesByFolder()processFolder(),我们将现在详细查看:

    private void getImapSession()  
      throws MessagingException, NoSuchProviderException { 
      Properties props = new Properties(); 
      props.put("mail.imap.ssl.trust", "*"); 
      props.put("mail.imaps.ssl.trust", "*"); 
      props.setProperty("mail.imap.starttls.enable",  
        Boolean.toString(account.isUseSsl())); 
      Session session = Session.getInstance(props, null); 
      store = session.getStore(account.isUseSsl() ?  
        "imaps" : "imap"); 
      store.connect(account.getServerName(), account.getUserName(),  
        account.getPassword()); 
    } 

要获取 IMAP Session,正如我们在本章前面看到的,我们创建一个 Properties 实例并设置一些重要属性。我们使用规则文件中用户指定的协议获取 Store 引用:对于非基于 SSL 的连接使用 imap,对于基于 SSL 的连接使用 imaps

一旦我们有了会话,我们就遍历我们的规则,按源文件夹分组:

    private Map<String, List<Rule>> getRulesByFolder(List<Rule> rules) { 
      return rules.stream().collect( 
        Collectors.groupingBy(r -> r.getSourceFolder(), 
        Collectors.toList())); 
    } 

我们现在可以按以下方式处理文件夹:

    private void processFolder(String folder, List<Rule> rules)  
      throws MessagingException { 
      Arrays.stream(getFolder(folder, Folder.READ_WRITE) 
        .getMessages()).forEach(message -> 
        rules.stream().filter(rule ->  
        rule.getSearchTerm().match(message)) 
        .forEach(rule -> { 
          switch (rule.getType()) { 
            case MOVE: 
              moveMessage(message, getFolder( 
                rule.getDestFolder(),  
                Folder.READ_WRITE)); 
            break; 
            case DELETE: 
              deleteMessage(message); 
            break; 
          } 
      })); 
    } 

使用 Stream,我们遍历源文件夹中的每条消息,只过滤与 SearchTerm 匹配的消息,但这是什么,它从哪里来?

Rule 类中还有一些额外的项目我们没有查看过:

    private SearchTerm term; 
    @JsonIgnore 
    public SearchTerm getSearchTerm() { 
      if (term == null) { 
        if (matchingText != null) { 
          List<SearchTerm> terms = fields.stream() 
          .map(f -> createFieldSearchTerm(f)) 
          .collect(Collectors.toList()); 
          term = new OrTerm(terms.toArray(new SearchTerm[0])); 
        } else if (olderThan != null) { 
          LocalDateTime day = LocalDateTime.now() 
          .minusDays(olderThan); 
          term = new SentDateTerm(ComparisonTerm.LE, 
            Date.from(day.toLocalDate().atStartOfDay() 
            .atZone(ZoneId.systemDefault()).toInstant())); 
        } 
      } 
      return term; 
    } 

我们添加一个私有字段来缓存 SearchTerm,这样我们就不必为大型文件夹中的每条消息重新创建它。这是一个小的优化,但我们想避免因重新创建 SearchTerm 而带来的不必要的性能损失。如果规则设置了 matchingText,我们根据指定的字段创建一个 List<SearchTerm>。一旦我们有了这个列表,我们就用 OrTerm 包装它,这将指示 JavaMail 匹配任何指定的字段与文本匹配的消息。

如果设置了 olderThan,那么我们创建 SentDateTerm 来匹配至少 olderThan 天前发送的任何消息。我们将 SearchTerm 引用保存到我们的私有实例变量中,然后返回它。

注意到该方法有 @JsonIgnore 注解。我们使用这个注解来确保 Jackson 不会尝试将此 getter 返回的值序列化到 JSON 文件中。

对于好奇的人来说,createFieldSearchTerm() 的样子如下:

    private SearchTerm createFieldSearchTerm(String f) { 
      switch (f.toLowerCase()) { 
        case "from": 
          return new FromStringTerm(matchingText); 
        case "cc": 
          return new RecipientStringTerm( 
            Message.RecipientType.CC, matchingText); 
        case "to": 
          return new RecipientStringTerm( 
            Message.RecipientType.TO, matchingText); 
        case "body": 
          return new BodyTerm(matchingText); 
        case "subject": 
          return new SubjectTerm(matchingText); 
        default: 
            return null; 
      } 
    } 

那么,消息实际上是如何移动或删除的呢?当然,有一个 JavaMail API 可以做到这一点,其使用可能看起来像这样:

    private static final Flags FLAGS_DELETED =  
      new Flags(Flags.Flag.DELETED); 
    private void deleteMessage(Message toDelete) { 
      if (toDelete != null) { 
        try { 
          final Folder source = toDelete.getFolder(); 
          source.setFlags(new Message[]{toDelete},  
            FLAGS_DELETED, true); 
          deleteCount++; 
        } catch (MessagingException ex) { 
          throw new RuntimeException(ex); 
        } 
      } 
    } 

我们进行快速空值检查,然后获取消息 Folder 的引用。有了这个,我们指示 JavaMail 在文件夹中的消息上设置一个标志 FLAGS_DELETED。JavaMail API 通常在 Message 的数组 (Message[]) 上工作,所以我们需要将 Message 包装在数组中,然后将其传递给 setFlags()。当我们完成时,我们增加已删除消息计数器,以便我们完成时可以打印报告。

移动 Message 非常相似:

    private void moveMessage(Message toMove, Folder dest) { 
      if (toMove != null) { 
        try { 
          final Folder source = toMove.getFolder(); 
          final Message[] messages = new Message[]{toMove}; 
          source.setFlags(messages, FLAGS_DELETED, true); 
          source.copyMessages(messages, dest); 
          moveCount++; 
        } catch (MessagingException ex) { 
          throw new RuntimeException(ex); 
        } 
      } 
    } 

这个方法的大部分看起来就像 deleteMessage(),但有一个细微的差别。JavaMail 没有提供 moveMessages() API。我们不得不做的是调用 copyMessages() 来在目标文件夹中创建消息的副本,然后从源文件夹中删除消息。我们增加已移动计数器并返回。

最后两个感兴趣的方法是处理文件夹的。首先,我们需要获取文件夹,我们在这里做:

    final private Map<String, Folder> folders = new HashMap<>(); 
    private Folder getFolder(String folderName, int mode) { 
      Folder source = null; 
      try { 
        if (folders.containsKey(folderName)) { 
          source = folders.get(folderName); 
        } else { 
          source = store.getFolder(folderName); 
          if (source == null || !source.exists()) { 
            throw new IllegalArgumentException( 
             "Invalid folder: " + folderName); 
          } 
          folders.put(folderName, source); 
        } 
        if (!source.isOpen()) { 
          source.open(mode); 
        } 
      } catch (MessagingException ex) { 
        //... 
      } 
      return source; 
    } 

由于性能原因,我们按文件夹名称在Map中缓存每个Folder实例。如果我们找到FolderMap中,我们就使用它。如果没有,我们就从 IMAP Store请求所需Folder的引用,并将其缓存到Map中。最后,我们确保Folder是打开的,否则我们的移动和删除命令将抛出Exception

我们还需要确保在完成时关闭文件夹

    private void closeFolders() { 
      folders.values().stream() 
      .filter(f -> f.isOpen()) 
      .forEachOrdered(f -> { 
        try { 
          f.close(true); 
        } catch (MessagingException e) { 
        } 
      }); 
    } 

我们过滤我们的Folder流,只保留那些打开的,然后调用folder.close(),吞咽可能发生的任何失败。在处理这个阶段,没有太多可以做的事情。

我们现在技术上已经完成了邮件过滤器,但它并不像它本可以做到的那样易于使用。我们需要一种方式来按计划运行它,并且能够在 GUI 中查看和编辑规则将会非常方便,所以我们将构建这两者。由于如果没有要运行的内容,安排某件事就没有意义,所以我们将从 GUI 开始。

构建 GUI

由于我们希望使其尽可能易于使用,我们现在将构建一个 GUI 来帮助管理这些规则。为了创建项目,我们将使用在创建 CLI 时使用的相同 Maven 存档:

$ mvn archetype:generate  -DarchetypeGroupId=org.apache.maven.archetypes  -DarchetypeArtifactId=maven-archetype-quickstart  -DarchetypeVersion=RELEASE 
Define value for property 'groupId': com.steeplesoft.mailfilter 
Define value for property 'artifactId': mailfilter-gui 
Define value for property 'version':  1.0-SNAPSHOT 
Define value for property 'package':  com.steeplesoft.mailfilter.gui 

一旦创建了 POM,我们需要对其进行一些编辑。我们需要通过向pom.xml添加此元素来设置父元素:

    <parent> 
      <groupId>com.steeplesoft.j9bp.mailfilter</groupId> 
      <artifactId>mailfilter-master</artifactId> 
      <version>1.0-SNAPSHOT</version> 
    </parent> 

我们还将添加对 CLI 模块的依赖,如下所示:

    <dependencies> 
      <dependency> 
        <groupId>${project.groupId}</groupId> 
        <artifactId>mailfilter-cli</artifactId> 
        <version>${project.version}</version> 
      </dependency> 
    </dependencies> 

由于我们不依赖于 NetBeans 为我们生成 JavaFX 项目,我们还需要手动创建一些基本组件。让我们从应用程序的入口点开始:

    public class MailFilter extends Application { 
      @Override 
      public void start(Stage stage) throws Exception { 
        Parent root = FXMLLoader.load(getClass() 
        .getResource("/fxml/mailfilter.fxml")); 
        Scene scene = new Scene(root); 
        stage.setTitle("MailFilter"); 
        stage.setScene(scene); 
        stage.show(); 
      } 

      public static void main(String[] args) { 
        launch(args); 
      } 
    } 

这是一个非常典型的 JavaFX 主类,所以我们将直接跳到 FXML 文件。现在,我们将只创建一个使用以下代码片段的存根:

    <?xml version="1.0" encoding="UTF-8"?> 
    <?import java.lang.*?> 
    <?import java.util.*?> 
    <?import javafx.scene.*?> 
    <?import javafx.scene.control.*?> 
    <?import javafx.scene.layout.*?> 

    <AnchorPane id="AnchorPane" prefHeight="200" prefWidth="320"  

      fx:controller= 
        "com.steeplesoft.mailfilter.gui.Controller"> 
      <children> 
        <Button layoutX="126" layoutY="90" text="Click Me!"  
          fx:id="button" /> 
        <Label layoutX="126" layoutY="120" minHeight="16"  
          minWidth="69" fx:id="label" /> 
      </children> 
    </AnchorPane> 

最后,我们创建控制器:

    public class Controller implements Initializable { 
      @Override 
      public void initialize(URL url, ResourceBundle rb) { 
      } 
    } 

这给我们一个可以启动和运行的 JavaFX 应用程序,但除此之外没有做太多。在之前的章节中,我们已经详细介绍了如何构建 JavaFX 应用程序,所以在这里我们不会再次做这件事,但其中有一些有趣的挑战值得一看。

为了让你了解我们正在努力实现的目标,这里有一个最终用户界面的截图:

图片

在左侧,我们有ListView来显示规则文件中配置的账户。在ListView下方,我们有一些控件来编辑当前选中的账户。在右侧,我们有TableView来显示规则,以及下方类似区域用于编辑规则

当用户点击账户规则时,我们希望下面的表单区域填充相关内容。随着用户修改数据,账户/规则以及ListView/TableView都应该更新。

通常情况下,这是 JavaFX 真正发光发热的领域之一,即属性绑定。我们已经通过ObservableList看到了其中的一小部分:我们可以在List中添加一个项目,并且它会被自动添加到与之绑定的 UI 组件中。然而,我们现在所处的情境略有不同,因为我们的模型是一个 POJO,它没有使用任何 JavaFX API,所以我们并不能那么容易地获得这种功能。让我们看看将这些事物连接在一起需要做些什么。

首先,让我们看看Account列表。我们有ObservableList

    private final ObservableList<Account> accounts =  
      FXCollections.observableArrayList(); 

我们按照以下方式将我们的账户添加到这个ObservableList中:

    private void configureAccountsListView() { 
      accountService = new AccountService(); 
      accounts.addAll(accountService.getAccounts()); 

然后,我们按照以下方式绑定ListListView

    accountsListView.setItems(accounts); 
ListView clicks:
    accountProperty = new AccountProperty(); 
    accountsListView.setOnMouseClicked(e -> { 
      final Account account = accountsListView.getSelectionModel() 
      .getSelectedItem(); 
      if (account != null) { 
        accountProperty.set(account); 
      } 
    }); 

当用户点击ListView时,我们在AccountProperty实例上设置Account。在我们离开这个方法并查看AccountProperty之前,我们需要设置最后一项:

    final ChangeListener<String> accountChangeListener =  
      (observable, oldValue, newValue) ->  
      accountsListView.refresh(); 
    serverName.textProperty().addListener(accountChangeListener); 
    userName.textProperty().addListener(accountChangeListener); 

我们定义了ChangeListener,它简单地调用accountsListView.refresh(),这会指示ListView重新绘制自己。我们希望它在模型本身更新时这样做,这种更新ObservableList并不会冒泡到ListView。接下来的两行代码将Listener添加到serverNameuserName``TextField上。这两个控件通过相同的名称编辑Account上的属性,并且是用于生成ListView显示字符串的唯一两个控件,这里我们没有展示。

AccountProperty是一个自定义的 JavaFX 属性,因此我们按照以下方式扩展ObjectPropertyBase

    private class AccountProperty extends ObjectPropertyBase<Account> { 

这提供了一部分绑定解决方案,但繁重的工作是由来自优秀的 JFXtras 项目的BeanPathAdapter类来处理的:

    private final BeanPathAdapter<Account> pathAdapter; 

就在本书撰写时,JFXtras 库并不与 Java 9 兼容。我们只需要从库中获取这个类,所以我将 JFXtras 仓库中类的源代码复制到了这个项目中。一旦 JFXtras 在 Java 9 下运行,我们就可以移除这个副本。

文档描述这个类为“一个适配器,它接受一个 POJO bean,并在内部递归地将其字段绑定/解绑到其他Property组件”。这是一个非常强大的类,我们在这里无法全面介绍,所以我们只跳到我们的特定用法,如下所示:

    public AccountProperty() { 
        pathAdapter = new BeanPathAdapter<>(new Account()); 
        pathAdapter.bindBidirectional("serverName",  
            serverName.textProperty()); 
        pathAdapter.bindBidirectional("serverPort",  
            serverPort.textProperty()); 
        pathAdapter.bindBidirectional("useSsl",  
            useSsl.selectedProperty(), Boolean.class); 
        pathAdapter.bindBidirectional("userName",  
            userName.textProperty()); 
        pathAdapter.bindBidirectional("password",  
            password.textProperty()); 
        addListener((observable, oldValue, newValue) -> { 
            rules.setAll(newValue.getRules()); 
        }); 
    } 

BeanPathAdapter允许我们将 JavaFX 的Property绑定到 POJO 上的属性,这些属性可以嵌套到任意深度,并使用点分隔的路径表示法进行引用。在我们的例子中,属性是Account对象上的顶级属性,所以路径既短又简单。在我们将控件绑定到属性之后,我们添加一个Listener来更新当前账户的ObservableList规则。

在前述代码中,当在ListView中更改Account选择时调用的set()方法非常简单:

    @Override 
    public void set(Account newValue) { 
      pathAdapter.setBean(newValue); 
      super.set(newValue); 
    } 

在这些组件就位之后,当我们在各种控件中输入时,Account对象会得到更新,并且当serverName和/或userName字段被编辑时,ListView的标签也会得到更新。

现在,我们需要为将显示用户配置的每个 RuleTableView 做同样的事情。设置几乎相同:

    private void configureRuleFields() { 
        ruleProperty = new RuleProperty(); 
        fields.getCheckModel().getCheckedItems().addListener( 
          new RuleFieldChangeListener()); 
        final ChangeListener<Object> ruleChangeListener =  
            (observable, oldValue, newValue) ->  
                rulesTableView.refresh(); 
        sourceFolder.textProperty() 
           .addListener(ruleChangeListener); 
        destFolder.textProperty().addListener(ruleChangeListener); 
        matchingText.textProperty() 
            .addListener(ruleChangeListener); 
        age.textProperty().addListener(ruleChangeListener); 
        type.getSelectionModel().selectedIndexProperty() 
            .addListener(ruleChangeListener); 
    } 

在这里,我们看到相同的基本结构:实例化 RuleProperty,创建 ChangeListener 以请求 TableView 刷新自身,并将该监听器添加到相关的表单字段。

RuleProperty 也类似于 AccountProperty

    private class RuleProperty extends ObjectPropertyBase<Rule> { 
      private final BeanPathAdapter<Rule> pathAdapter; 

      public RuleProperty() { 
        pathAdapter = new BeanPathAdapter<>(new Rule()); 
        pathAdapter.bindBidirectional("sourceFolder",  
          sourceFolder.textProperty()); 
        pathAdapter.bindBidirectional("destFolder",  
          destFolder.textProperty()); 
        pathAdapter.bindBidirectional("olderThan",  
          age.textProperty()); 
        pathAdapter.bindBidirectional("matchingText",  
          matchingText.textProperty()); 
        pathAdapter.bindBidirectional("type",  
          type.valueProperty(), String.class); 
        addListener((observable, oldValue, newValue) -> { 
          isSelectingNewRule = true; 
          type.getSelectionModel().select(type.getItems() 
          .indexOf(newValue.getType().name())); 

          IndexedCheckModel checkModel = fields.getCheckModel(); 
          checkModel.clearChecks(); 
          newValue.getFields().forEach((field) -> { 
            checkModel.check(checkModel.getItemIndex(field)); 
          }); 
          isSelectingNewRule = false; 
      }); 
    } 

这里的最大不同之处在于创建的 Listener。鉴于使用了来自伟大的 ControlsFX 项目的自定义控件 CheckListView,值得注意的是逻辑:我们获取 IndexedCheckModel,然后将其清空,然后遍历每个字段,找到其在 CheckModel 中的索引并检查它。

我们通过 RuleFieldChangeListener 控制 Rule 上设置的值的更新:

    private class RuleFieldChangeListener implements ListChangeListener { 
      @Override 
      public void onChanged(ListChangeListener.Change c) { 
        if (!isSelectingNewRule && c.next()) { 
          final Rule bean = ruleProperty.getBean(); 
          bean.getFields().removeAll(c.getRemoved()); 
          bean.getFields().addAll(c.getAddedSubList()); 
        } 
      } 
    } 

ListChangeListener 通知我们哪些被移除以及哪些被添加,因此我们相应地处理了这些。

GUI 中还有其他几个动态部分,但在前面的章节中我们已经看到它们是如何相互关联的,所以在这里我们不会涉及它们。如果你对这些细节感兴趣,可以在本书的源代码仓库中找到它们。让我们将注意力转向我们项目的最后一部分:特定于操作系统的服务。

构建服务

本项目的其中一个目标是可以定义规则来管理和过滤电子邮件,并且希望它几乎一直运行,而不仅仅是当电子邮件客户端运行时。(当然,我们无法控制运行此机器的电源被关闭,因此我们无法保证始终如一的覆盖)。为了实现这一承诺的部分,我们需要一些额外的部分。我们已经有了执行实际工作的系统部分,但我们还需要一种方式来按计划运行这部分,我们还需要一个部分来启动计划任务。

对于调度方面,我们有多种选择,但我们将使用名为 Quartz 的库。Quartz 任务调度库是一个开源库,可以在 Java SE 以及 Java EE 应用程序中使用。它提供了一个干净且简单的 API,非常适合在这里使用。要将 Quartz 添加到我们的项目中,我们需要在 pom.xml 中做以下操作:

    <dependency> 
      <groupId>org.quartz-scheduler</groupId> 
      <artifactId>quartz</artifactId> 
      <version>2.2.3</version> 
    </dependency> 

API 简单到什么程度?以下是我们的 Job 定义:

    public class MailFilterJob implements Job { 
      @Override 
      public void execute(JobExecutionContext jec)  
        throws JobExecutionException { 
        MailFilter filter = new MailFilter(); 
        filter.run(); 
      } 
    } 

我们扩展 org.quartz.Job 并覆盖 execute() 方法,其中我们简单地实例化 MailFilter 并调用 run()。这就是全部内容。在我们的任务定义完成后,我们只需要对其进行调度,这将在 MailFilterService 中完成:

    public class MailFilterService { 
      public static void main(String[] args) { 
        try { 
          final Scheduler scheduler =  
            StdSchedulerFactory.getDefaultScheduler(); 
          scheduler.start(); 

          final JobDetail job =  
            JobBuilder.newJob(MailFilterJob.class).build(); 
          final Trigger trigger = TriggerBuilder.newTrigger() 
          .startNow() 
          .withSchedule( 
             SimpleScheduleBuilder.simpleSchedule() 
             .withIntervalInMinutes(15) 
             .repeatForever()) 
          .build(); 
          scheduler.scheduleJob(job, trigger); 
        } catch (SchedulerException ex) { 
          Logger.getLogger(MailFilterService.class.getName()) 
          .log(Level.SEVERE, null, ex); 
        } 
      } 
    } 

我们首先获取默认的 Scheduler 引用并启动它。接下来,我们使用 JobBuilder 创建一个新的任务,然后使用 TriggerBuilder 构建 Trigger。我们告诉 Trigger 立即开始执行,但请注意,它不会开始,直到它被实际构建并分配给 Scheduler。一旦发生这种情况,Job 将立即执行。最后,我们使用 SimpleScheduleBuilder 辅助类为 Trigger 定义 Schedule,指定十五分钟的间隔,这将无限期运行。我们希望它在计算机关闭或服务停止之前一直运行。

如果我们现在运行/调试MailFilterService,我们可以观察MailFilter的运行。如果你这样做,并且你不是极其有耐心,我建议你降低间隔到一个更合理的值。

这就留下了我们最后的一个部分:操作系统集成。简而言之,我们希望能够在操作系统启动时运行MailFilterService。理想情况下,我们更愿意不使用临时脚本拼凑来实现这一点。幸运的是,我们又面临了许多选择。

我们将使用来自 Tanuki Software 的优秀 Java Service Wrapper 库(详细信息可在wrapper.tanukisoftware.com找到)。虽然我们可以手动构建服务工件,但我们更愿意让我们的构建为我们做这项工作,当然,有一个名为appassembler-maven-plugin的 Maven 插件来完成这项工作。为了将它们集成到我们的项目中,我们需要通过添加以下代码片段修改我们的 POM 的build部分:

    <build> 
      <plugins> 
        <plugin> 
          <groupId>org.codehaus.mojo</groupId> 
          <artifactId>appassembler-maven-plugin</artifactId> 
          <version>2.0.0</version> 

此插件的传递依赖将引入我们需要的所有 Java Service Wrapper 组件,所以我们只需要配置我们的使用。我们首先添加一个执行步骤,告诉 Maven 在打包项目时运行generate-daemons目标:

    <executions> 
      <execution> 
        <id>generate-jsw-scripts</id> 
        <phase>package</phase> 
        <goals> 
          <goal>generate-daemons</goal> 
        </goals> 

接下来,我们需要配置插件,我们通过configuration元素来完成:

    <configuration> 
      <repositoryLayout>flat</repositoryLayout> 

repositoryLayout选项告诉插件构建一个lib风格的仓库,而不是 Maven 2 风格的布局,后者是一系列嵌套目录。这主要是一个风格问题,至少对我们这里的用途来说是这样,但我发现能够快速扫描生成的目录并查看包含的内容是有帮助的。

接下来,我们需要定义daemons(来自 Unix 世界的另一个术语,代表磁盘和执行监控器),如下所示:

    <daemons> 
      <daemon> 
        <id>mailfilter-service</id> 
        <wrapperMainClass> 
          org.tanukisoftware.wrapper.WrapperSimpleApp 
        </wrapperMainClass> 
        <mainClass> 
         com.steeplesoft.mailfilter.service.MailFilterService 
        </mainClass> 
        <commandLineArguments> 
          <commandLineArgument>start</commandLineArgument> 
        </commandLineArguments> 

Java Service Wrapper 是一个非常灵活的系统,提供多种方式来包装你的 Java 项目。我们的需求很简单,所以我们指示它使用WrapperSimpleApp并指向主类MailFilterService

此插件支持几种其他服务包装方法,但我们感兴趣的是 Java Service Wrapper,所以我们在这里指定,使用platform元素:

        <platforms> 
          <platform>jsw</platform> 
        </platforms> 

最后,我们需要配置生成器,告诉它支持哪个操作系统:

        <generatorConfigurations> 
          <generatorConfiguration> 
            <generator>jsw</generator> 
            <includes> 
              <include>linux-x86-64</include> 
              <include>macosx-universal-64</include> 
              <include>windows-x86-64</include> 
            </includes> 
          </generatorConfiguration> 
        </generatorConfigurations> 
      </daemon> 
    </daemons> 

每个操作系统定义都提供了一个 32 位选项,如果需要可以添加,但为了简洁起见,我在这里省略了它们。

当我们现在构建应用程序时,无论是通过mvn package还是mvn install,此插件将为我们的服务生成一个包装器,包括配置操作系统的适当二进制文件。好事是,它将为每个操作系统构建包装器,而不管构建实际运行在哪个操作系统下。例如,这是在 Windows 机器上构建此应用程序的输出(注意 Linux 和 Mac 的二进制文件):

图片

这个包装器功能强大得多,所以如果你感兴趣,可以阅读 Tanuki Software 网站上所有的详细信息。

摘要

就这样,我们的应用程序再次完成了。在这一章中,我们涵盖了相当多的内容。我们首先简要了解了几个电子邮件协议(SMTP、POP3 和 IMAP4)的历史和技术细节,然后学习了如何使用 JavaMail API 与基于这些协议的服务进行交互。在这个过程中,我们发现并使用了 Jackson JSON 解析器,用它来将 POJO 序列化和反序列化到磁盘。我们使用 ControlsFX 类BeanPathAdapter将非 JavaFX 感知的 POJO 绑定到 JavaFX 控件,并使用 Quartz 作业调度库按计划执行代码。最后,我们使用 Java Service Wrapper 包装我们的应用程序以创建安装包。

我们得到了一个我希望既有趣又实用的应用程序。当然,如果你有动力,有很多方法可以改进它。账户/规则数据结构可以扩展,以便定义跨账户共享的全局规则。GUI 可以支持在账户的文件夹中查看电子邮件并基于实时数据生成规则。构建可以扩展以创建应用程序的安装程序。你可能还能想到更多。随时可以查看代码并进行修改。如果你想出了一些有趣的东西,请务必分享,因为我很乐意看到你所做的一切。

在完成另一个项目(无意中开玩笑)之后,我们准备将注意力转向另一个项目。在下一章中,我们将把所有时间都花在 GUI 上,构建一个照片管理系统。这将给我们一个机会来查看 JDK 的一些图像处理能力,包括新添加的 TIFF 支持,这个特性应该会让图像爱好者非常高兴。翻到下一页,让我们开始吧!

第二十六章:使用 PhotoBeans 进行照片管理

到目前为止,我们编写了库。我们编写了命令行工具。我们还使用 JavaFX 编写了 GUI。在本章中,我们将尝试一些完全不同的事情。我们将构建一个照片管理系统,这当然需要一个图形应用程序,但我们将采取不同的方法。而不是使用纯 JavaFX 从头开始构建一切,我们将使用现有的应用程序框架。这个框架就是 NetBeans 富客户端平台RCP),这是一个成熟、稳定且强大的框架,不仅为我们在使用的 NetBeans IDE 提供动力,还为从石油和天然气到航空和太空的无数行业中的无数应用程序提供动力。

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

  • 如何启动 NetBeans RCP 项目

  • 如何将 JavaFX 集成到 NetBeans RCP 中

  • RCP 应用程序的基本要素,如节点、动作、查找、服务和 TopComponents

那么就不再多说了,让我们直接进入正题。

入门

可能你列表中排在最前面的问题就是,我为什么要使用 NetBeans RCP?在我们深入探讨应用程序的细节之前,让我们来回答这个非常合理的问题,并尝试理解我们为什么要以这种方式构建它。

当你开始研究 NetBeans 平台时,你首先会注意到的是模块化的强烈概念。鉴于 Java 模块系统是 Java 9 的一个突出特性,这可能会显得是一个小细节,但 NetBeans 将这个概念暴露给我们,使我们能够在应用层面进行操作,使得插件变得极其简单,同时也允许我们分块更新应用程序。

RCP 还提供了一个强大、经过良好测试的框架来处理窗口、菜单、动作、节点、服务和等等。如果我们像前几章中使用JavaFX 那样从头开始构建这个应用程序,我们就必须手动定义屏幕上的区域,然后手动处理窗口位置。有了 RCP,我们已经有了一个丰富的窗口规范,我们可以轻松地使用。它提供了诸如最大化/最小化窗口、滑动、分离和停靠窗口等功能。

RCP 还提供了一种对节点的强烈概念,这是在用户界面概念中对特定领域数据的封装,通常在应用程序左侧的树视图中看到,以及可以与这些节点(或菜单项)关联的动作来操作它们所代表的数据。再次强调,所有这些都可以在 JavaFX(或 Swing)中完成,但你必须自己编写所有这些功能。实际上,有一些开源框架提供这样做,例如 Canoo 的 Dolphin 平台(www.dolphin-platform.io),尽管它们都没有像 NetBeans RCP 那样经过多年的生产强化和测试,所以我们将保持关注点在这里。

启动项目

你如何创建 NetBeans RCP 项目将对整个项目的处理方式产生非常根本的影响。默认情况下,NetBeans 使用 Ant 作为所有 RCP 应用程序的构建系统。NetBeans 项目的大部分在线文档和博客条目通常也反映了这种偏好。我们一直在使用 Maven 进行其他所有项目,并且我们不会改变这一点。幸运的是,NetBeans 允许我们使用 Maven 创建 RCP 项目,这正是我们将要做的。

在新项目窗口中,我们选择 Maven,然后是 NetBeans 应用程序。在下一屏幕上,我们像往常一样配置项目,指定项目名称、photobeans、项目位置、包等。

当我们点击“下一步”时,我们将进入新项目向导的模块选项步骤。在此步骤中,我们配置 RCP 应用程序的一些基本方面。具体来说,我们需要指定我们将使用的 NetBeans API 版本,以及我们是否希望将 OSGi 打包作为依赖项,如以下截图所示:

在撰写本文时,最新平台版本是 RELEASE82。到 Java 9 发布时,我们可以合理地预期 NetBeans 9.0 和因此 RELEASE90 将会可用。我们希望使用可用的最新版本,但请注意,根据 NetBeans 项目的发布计划,它可能根本不是 9.0。对于允许 OSGi 打包作为依赖项的选项,我们可以安全地接受默认设置,尽管更改它不会给我们带来任何问题,如果需要,我们也可以轻松地稍后更改该值。

一旦创建项目,我们应该在项目窗口中看到三个新的条目:PhotoBeans-parentPhotoBeans-appPhotoBeans-branding-parent 项目没有实际的交付物。像其他章节的master项目一样,这只是为了组织相关模块、协调依赖关系等。

为应用程序添加品牌

-branding 模块是我们定义应用程序品牌细节的地方。正如你可能已经猜到的,你可以通过右键单击品牌模块,并在内容菜单底部选择Branding...来访问这些品牌属性。这样做后,你会看到一个类似于这样的屏幕:

在此 preceding 标签页中,你可以设置或更改应用程序的名称,以及指定应用程序图标。

在启动屏幕标签页中,你可以配置,最重要的是,当应用程序加载时在启动屏幕上显示的图像。你还可以启用或禁用进度条,并设置进度条和启动信息的颜色、字体大小和位置:

目前对我们来说,唯一感兴趣的另一个标签页是“窗口系统”标签页。在这个标签页中,我们可以配置许多功能,例如窗口拖放、窗口滑动、关闭等:

图片

很可能,默认设置对我们的目的来说是可接受的。然而,在你自己的 NetBeans RCP 应用程序中,这个屏幕可能要重要得多。

我们的主要兴趣是-app模块。这个模块将定义应用程序的所有依赖项,并将作为其入口点。然而,与我们在前几章中看到的 JavaFX 应用程序不同,我们不需要定义public static void main方法,因为 NetBeans 会为我们处理这个。实际上,-app模块根本不包含任何 Java 类,但应用程序可以立即运行,尽管它目前并没有做什么。我们现在将解决这个问题。

NetBeans 模块

NetBeans 平台的一个优势是其模块化。如果你曾经使用过 NetBeans IDE 本身(比如在阅读本书之前),你会在使用插件时看到这种模块化的实际应用:每个 NetBeans 插件都由一个或多个模块组成。实际上,NetBeans 本身由许多模块组成。这就是 RCP 应用程序设计成这样工作的原因。它促进了解耦,使得扩展和升级应用程序变得更加简单。

通常接受的模式是将 API 类放在一个模块中,将实现放在另一个模块中。这使得 API 类可以被其他实现者重用,有助于通过隐藏私有类来强制低耦合,等等。然而,为了在我们学习平台时保持简单,我们将创建一个模块,该模块将提供所有核心功能。为此,我们在父项目下的“模块”节点上右键单击,并选择“创建新模块...”,如下面的截图所示:

图片

一旦选择,你将看到“新建项目”窗口。在这里,你需要选择 Maven 类别,以及 NetBeans 模块项目类型,如下所示:

图片

点击“下一步”将带你去到我们在本书中已经多次见过的“名称和位置”步骤。在这个面板上,我们将模块命名为main,设置包为com.steeplesoft.photobeans.main,并接受其他字段的默认设置。在下一个面板“模块选项”中,我们将确保 NetBeans 版本与之前选择的版本相同,然后点击“完成”。

TopComponent - 标签页和窗口的类

我们现在有一个主要为空的模块。NetBeans 为我们创建了一些工件,但我们不需要担心这些,因为构建将为我们管理这些。不过,我们需要做的是创建我们的第一个 GUI 元素,这将是 NetBeans 称为 TopComponent 的东西。从 NetBeans Javadoc 中,网址为bits.netbeans.org/8.2/javadoc/,我们找到了以下定义:

可嵌入的视觉组件,将在 NetBeans 中显示。这是显示的基本单元--窗口不应直接创建,而应使用此类。顶级组件可能对应单个窗口,也可能是一个窗口中的标签页(例如)。它可以停靠或取消停靠,有选定的节点,提供操作等。

正如我们将看到的,这个类是 NetBeans RCP 应用程序的主要组件。它将持有并控制各种相关的用户界面元素。换句话说,它是用户界面组件层次结构中的顶层。要创建 TopComponent,我们可以通过在项目资源管理器树中右键单击现在为空的包,并选择“新建 | 窗口”来使用 NetBeans 向导。如果“窗口”不是选项,请选择“其他 | 模块开发 | 窗口”。

您现在应该看到以下基本设置窗口:

图片

在前面的窗口中,我们有多个选项。我们正在创建一个将显示照片列表的窗口,因此一些合理的设置可能是选择以下选项:

  • 应用程序启动时打开

  • 关闭不允许

  • 最大化不允许

这些选项看起来相当直接,但窗口位置是什么意思?与从头开始编写相比,使用 NetBeans RCP 的一个好处是,平台提供了一系列预定义的概念和功能,这样我们就不必担心它们。其中一个关注点是窗口定位和放置。NetBeans 用户界面规范(可在 NetBeans 网站上找到,网址为ui.netbeans.org/docs/ui/ws/ws_spec-netbeans_ide.html)定义了以下区域:

  • 资源管理器: 这用于提供对用户对象访问的所有窗口,通常在树浏览器中

  • 输出: 默认情况下,这用于输出窗口和 VCS 输出窗口

  • 调试器: 这用于所有调试窗口和其他需要水平布局的支持窗口

  • 调色板: 这用于组件调色板窗口

  • 检查器: 这用于组件检查器窗口

  • 属性: 这用于属性窗口

  • 文档: 这用于所有文档窗口

文档还提供了这个有用的插图:

图片

规范页面有大量的附加信息,但这对现在来说应该足够了,可以让你开始。我们希望我们的照片列表显示在应用程序窗口的左侧,所以我们选择窗口位置的编辑器。点击下一步,我们配置组件的名称和图标。严格来说,我们不需要为 TopComponent 指定图标,所以我们可以在“类名前缀”中输入PhotoList,然后点击完成:

当你在这里点击完成时,NetBeans 会为你创建几个文件,尽管只有一个会在项目资源管理器树中显示,那就是PhotoListTopComponent.java。还有一个名为PhotoListTopComponent.form的文件,你需要了解它,尽管你永远不会直接编辑它。NetBeans 提供了一个非常棒的WYSIWYG(所见即所得)编辑器来构建你的用户界面。用户界面定义存储在.form文件中,它只是一个 XML 文件。当你进行更改时,NetBeans 会为你修改这个文件,并在名为initComponents()的方法中生成等效的 Java 代码。你还会注意到 NetBeans 不会允许你修改这个方法。当然,你可以使用另一个编辑器来这样做,但如果你在 GUI 编辑器中进行更改,那么你通过这种方式所做的任何更改都将丢失,所以最好还是不要修改这个方法。TopComponent 的其他部分看起来是什么样子?

    @ConvertAsProperties( 
      dtd = "-//com.steeplesoft.photobeans.main//PhotoList//EN", 
      autostore = false 
    ) 
    @TopComponent.Description( 
      preferredID = "PhotoListTopComponent", 
      //iconBase="SET/PATH/TO/ICON/HERE", 
      persistenceType = TopComponent.PERSISTENCE_ALWAYS 
    ) 
    @TopComponent.Registration(mode = "editor",
     openAtStartup = true) 
    @ActionID(category = "Window", id =  
      "com.steeplesoft.photobeans.main.PhotoListTopComponent") 
    @ActionReference(path = "Menu/Window" /*, position = 333 */) 
    @TopComponent.OpenActionRegistration( 
      displayName = "#CTL_PhotoListAction", 
      preferredID = "PhotoListTopComponent" 
    ) 
    @Messages({ 
      "CTL_PhotoListAction=PhotoList", 
      "CTL_PhotoListTopComponent=PhotoList Window", 
      "HINT_PhotoListTopComponent=This is a PhotoList window" 
    }) 
    public final class PhotoListTopComponent 
     extends TopComponent { 

这有很多注解,但也是一个很好的提醒,说明 NetBeans 平台为你做了多少工作。在构建过程中,这些注解被处理以创建平台在运行时用于配置和连接你的应用程序的元数据。

一些亮点如下:

    @TopComponent.Registration(mode = "editor",
      openAtStartup = true) 

这注册了我们的TopComponent,并反映了我们放置它的位置和打开它的时间的选择。

我们还有一些国际化本地化工作正在进行,如下所示:

    @ActionID(category = "Window", id =  
      "com.steeplesoft.photobeans.main.PhotoListTopComponent") 
    @ActionReference(path = "Menu/Window" /*, position = 333 */) 
    @TopComponent.OpenActionRegistration( 
      displayName = "#CTL_PhotoListAction", 
      preferredID = "PhotoListTopComponent" 
    ) 
    @Messages({ 
      "CTL_PhotoListAction=PhotoList", 
      "CTL_PhotoListTopComponent=PhotoList Window", 
      "HINT_PhotoListTopComponent=This is a PhotoList window" 
    }) 

不深入细节,以免混淆,前三个注解注册了一个打开动作,并暴露了应用程序Window菜单中的一个项目。最后一个注解@Messages用于定义本地化键和字符串。当这个类编译时,在同一个包中创建了一个名为Bundle的类,它使用指定的键定义方法来返回本地化字符串。例如,对于CTL_PhotoListAction,我们得到以下:

    static String CTL_PhotoListAction() { 
      return org.openide.util.NbBundle.getMessage(Bundle.class,  
        "CTL_PhotoListAction"); 
    } 

以下代码在标准的 Java .properties文件中查找用于本地化消息的键。这些键/值对与 NetBeans 向导为我们生成的Bundle.properties文件中的任何条目合并。

我们TopComponent的以下构造函数也很有趣:

    public PhotoListTopComponent() { 
      initComponents(); 
      setName(Bundle.CTL_PhotoListTopComponent()); 
      setToolTipText(Bundle.HINT_PhotoListTopComponent()); 
      putClientProperty(TopComponent.PROP_CLOSING_DISABLED,  
       Boolean.TRUE); 
      putClientProperty(TopComponent.PROP_MAXIMIZATION_DISABLED,  
       Boolean.TRUE); 
    } 

在前面的构造函数中,我们可以看到组件的名称和工具提示是如何设置的,以及我们的窗口相关选项设置在哪里。

如果我们现在运行我们的应用程序,我们不会看到任何变化。那么,我们需要做的是将main模块的依赖项添加到应用程序中。我们通过在应用程序模块的“依赖项”节点上右键单击来完成此操作,如图所示:

图片

你现在应该能看到“添加依赖”窗口。选择“打开项目”选项卡,然后选择如图所示的main

图片

一旦我们添加了依赖项,我们需要构建这两个模块,首先是main,然后是app,然后我们就可以第一次运行 PhotoBeans 了:

图片

注意到前面屏幕窗口标题中的奇怪日期吗?那是 NetBeans 平台的构建日期,在我们的应用程序中看起来并不美观,所以,让我们来修复它。我们有两种选择。第一种是使用我们之前查看的品牌用户界面。另一种是直接编辑文件。为了保持事情有趣,并帮助理解磁盘上的位置,我们将使用第二种方法。

在品牌模块中,在“其他来源”|“nbm-branding”下,你应该能找到modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties文件。在这个文件中,你应该能看到以下几行:

    CTL_MainWindow_Title=PhotoBeans {0} 
    CTL_MainWindow_Title_No_Project=PhotoBeans {0} 

我们需要做的就是移除{0}部分,重新构建此模块和应用程序,我们的标题栏就会变得更加美观。虽然这样看起来更好,但我们的 TopComponent 怎么办?为了修复它,我们需要学习一些新的概念。

节点,NetBeans 演示对象

你已经听说过“节点”这个词。我已经多次用它来描述点击什么和在哪里点击。正式来说,节点代表对象(bean)层次结构中的一个元素。它提供了在探索视图和 bean 之间进行通信所需的所有方法。在我们的应用程序的探索部分,我们希望向用户展示照片列表。我们将每个照片以及它被拍摄的年份和月份表示为一个节点。为了显示这些节点,我们将使用一个名为BeanTreeView的 NetBeans 类,它将以树的形式显示这个节点层次结构。还有更多概念需要学习,但让我们先从我们已有的开始。

我们将从定义我们的节点开始,这些节点将作为我们应用程序的业务领域模型和 NetBeans API 之间的一种包装器或桥梁。当然,我们还没有定义这样的模型,所以我们需要现在确定它。我们的基本数据项是一张照片,一个存储图像的磁盘文件。在应用程序中,我们将以嵌套的树结构显示这些照片,按年份分组,然后按月份。如果你展开一个年份节点,你会看到一个月份节点列表,如果你展开一个月份节点,你会看到一个照片节点列表。这是一个非常基础、有些天真的数据模型,但它既足够有效以展示概念,又足够简单,以至于我们不会掩盖概念。

正如所有层次结构一样,我们需要一个根节点,所以我们将从这里开始:

    public class RootNode extends AbstractNode 

所有节点的基类在技术上来说是 Node,但扩展该类给我们带来了更大的负担,因此我们使用 NetBeans 提供的AbstractNode,它为我们实现了一部分节点的基本行为,并提供了合理的默认值。

接下来,我们定义了一些构造函数,如下所示:

    public RootNode() { 
      this(new InstanceContent()); 
    } 

    protected RootNode(InstanceContent ic) { 
      super(Children.create(new YearChildFactory(), true), 
       new AbstractLookup(ic)); 
      setDisplayName(Bundle.LBL_RootNode()); 
      setShortDescription(Bundle.HINT_RootNode()); 

      instanceContent = ic; 
    } 

注意,我们有两个构造函数,一个是public的,另一个是protected的。之所以这样,是因为我们想要创建和捕获一个InstanceContent的实例,这个实例可以被我们,即这个类 Lookup 的创建者,用来控制实际上在 lookup 中的内容。由于我们需要将Lookup传递给我们的类的父构造函数,所以我们采用了这种两步对象实例化的方法。

Lookup,NetBeans 的基本组成部分

什么是 Lookup?它是一个通用注册表,允许客户端查找服务实例(给定接口的实现)。换句话说,它是一种机制,我们可以通过它发布各种工件,而系统的其他部分可以通过键(可以是ClassLookup.Template,这里我们不讨论)查找这些工件,而模块之间没有耦合。

如我们所见,这通常用于查找服务接口的实现。你还记得我之前提到过,我们经常看到 API 在一个模块中定义,实现却在另一个模块中吗?这正是这种做法特别有用之处。假设你正在开发一个用于从在线服务检索照片的 API(这对这个应用程序来说将是一个很棒的功能!)。你计划为一种服务提供实现,比如 Google Photos,但希望第三方开发者能够为 Flickr 提供实现。如果你将所需的 API 接口、类等放在一个模块中,而将 Google Photos 的实现放在另一个模块中,第三方开发者就可以仅依赖于你的 API 模块,避免你的实现模块的负担。Flickr 模块将声明对照片服务 API 的实现,我们可以通过向 Lookup 发送请求来加载这两个实现。简而言之,该系统允许以非常干净、简单的方式解耦 API 定义、实现和实例获取。

那是 Lookup,但InstanceContent是什么?Lookup API 只公开了获取项的方法。没有将项添加到 Lookup 的机制,这是有道理的,因为 Lookup 实例被未知第三方使用,我们不希望他们随机更改 Lookup 的内容。然而,我们实际上可能想要更改这些内容,我们通过InstanceContent来实现,它公开了我们需要添加或删除项的方法。我们将在应用程序的后面部分看到这个概念的演示。

编写我们自己的节点

前一节介绍了这两个类,但YearChildFactory是什么?RootNode类为系统定义了将成为我们树根的根节点。然而,每个节点(如果它有子节点),都要负责加载和构建这些子节点,这是通过这个ChildFactory类来完成的。我们的实例看起来是这样的:

    public class YearChildFactory extends ChildFactory<String> { 
      private final PhotoManager photoManager; 
      private static final Logger LOGGER =  
        Logger.getLogger(YearChildFactory.class.getName()); 
      public YearChildFactory() { 
        this.photoManager =  
          Lookup.getDefault().lookup(PhotoManager.class); 
        if (photoManager == null) { 
          LOGGER.log(Level.SEVERE,  
          "Cannot get PhotoManager object"); 
          LifecycleManager.getDefault().exit(); 
        } 
      } 

      @Override 
      protected boolean createKeys(List<String> list) { 
        list.addAll(photoManager.getYears()); 
        return true; 
      } 

      @Override 
      protected Node createNodeForKey(String key) { 
        return new YearNode(Integer.parseInt(key)); 
      } 
    } 

我们正在创建一个ChildFactory接口,该接口将返回操作字符串的节点。如果你有一个更复杂的数据模型,例如使用 POJOs 的模型,你将指定该类作为参数化类型。

在我们的构造函数中,我们看到通过 Lookup 找到服务实现的一个例子,这是这样的:

    this.photoManager=Lookup.getDefault().lookup(
      PhotoManager.class); 

我们将在稍后定义服务,但,现在你需要理解的是,我们正在请求全局 Lookup(与之前创建的 Lookup 不同,它不绑定到特定类)的PhotoManager接口实例。也许有些天真,我们假设只有一个这样的接口实例,但由于我们没有导出接口,我们的假设是安全的。然而,我们确实检查了至少有一个实例,如果没有,则退出应用程序。

接下来的两个方法是如何使用工厂来创建子节点的。第一个方法createKeys(List<String> list)是由系统调用来生成子节点键列表的。在我们的实现中,我们向PhotoManager接口请求年份列表(正如我们将看到的,这是一个简单的数据库查询,以获取系统中照片的年份列表)。然后平台将这些键逐个传递给createNodeForKey(String key)来创建实际的节点。在这里,我们创建了一个YearNode实例来表示年份。

YearNode,像RootNode一样,扩展了AbstractNode

    public class YearNode extends AbstractNode { 
      public YearNode(int year) { 
        super(Children.create(new MonthNodeFactory(year), true),  
         Lookups.singleton(year)); 
        setName("" + year); 
        setDisplayName("" + year); 
      } 
    } 

前面的确实是更简单的节点,但基本原理是相同的--我们创建ChildFactory来创建我们的子节点,并创建一个 Lookup,在这个例子中,它持有一个单一值,即节点所代表的年份。

MonthNodeFactory几乎与YearNodeFactory完全相同,除了它为给定的年份加载月份,所以我们在这里不展示源代码。它还为列表中的每个月份创建MonthNode实例。像YearNode一样,MonthNode相当简单,如下面的代码片段所示:

    public class MonthNode extends AbstractNode { 
      public MonthNode(int year, int month) { 
        super(Children.create( 
          new PhotoNodeFactory(year, month), true),  
           Lookups.singleton(month)); 
          String display = month + " - " +  
           Month.values()[month-1].getDisplayName( 
             TextStyle.FULL, Locale.getDefault()); 
          setName(display); 
          setDisplayName(display); 
      } 
    } 

我们做了一些额外的工作来给节点一个有意义的名称和显示名称,但基本上是相同的。注意,我们还有一个ChildFactory,正如其名所示,将生成我们需要作为子节点的 PhotoNodes。工厂本身没有什么新奇的,但PhotoNode有,所以让我们看看:

    public class PhotoNode extends AbstractNode { 
      public PhotoNode(String photo) { 
        this(photo, new InstanceContent()); 
    } 

    private PhotoNode(String photo, InstanceContent ic) { 
      super(Children.LEAF, new AbstractLookup(ic)); 
      final String name = new File(photo).getName(); 
      setName(name); 
      setDisplayName(name); 

      ic.add((OpenCookie) () -> { 
        TopComponent tc = findTopComponent(photo); 
        if (tc == null) { 
          tc = new PhotoViewerTopComponent(photo); 
          tc.open(); 
        } 
        tc.requestActive(); 
      }); 
    } 

在这里,我们再次看到双重构造方法,尽管在这种情况下,我们确实使用了InstanceContent。注意,super()的第一个参数是Children.LEAF,表示这个节点没有子节点。我们还传递了现在熟悉的new AbstractLookup(ic)

在设置名称和显示名称之后,我们在 InstanceContent 对象中添加了一个 lambda 表达式。这个 lambda 表达式的非 lambda 版本看起来像这样:

    ic.add(new OpenCookie() { 
      @Override 
      public void open() { 
      } 
    }); 

什么是 OpenCookie?它是标记接口 Node.Cookie 的子类,而 cookie 是 一种用于向现有数据对象和节点添加行为,或将实现与主要对象分离的设计模式。使用这个 cookie,我们可以优雅地抽象出表示某物可以打开以及如何打开它的信号。

在这种情况下,当系统尝试打开节点表示的图片时,它将调用我们定义的 OpenCookie.open(),这将尝试找到打开的图片实例。无论它找到现有的实例还是需要创建一个新的实例,它都会指示系统使其活动(或给予它焦点)。

注意,打开的图片由另一个 TopComponent 表示。为了找到它,我们有这个方法:

    private TopComponent findTopComponent(String photo) { 
      Set<TopComponent> openTopComponents =  
        WindowManager.getDefault().getRegistry().getOpened(); 
      for (TopComponent tc : openTopComponents) { 
        if (photo.equals(tc.getLookup().lookup(String.class))) { 
          return tc; 
        } 
      } 
      return null; 
    } 

我们向 WindowManager 的 Lookup 请求所有打开的 TopComponent,然后遍历每个组件,比较 String photo,即图像的完整路径,与 TopComponent Lookup 中存储的任何 String。如果匹配,我们返回该 TopComponent。这种通过 String 的查找方法虽然有些简单,但在更复杂的应用中可能会导致意外的匹配。在这个应用中我们可能足够安全,但你需要确保在自己的应用中匹配标准严格且唯一,以避免错误匹配。

执行操作

我们稍后将查看 PhotoViewerTopComponent,但在继续之前,我们还需要查看一些其他项目。

PhotoNode 覆盖了两个额外的方法,具体如下:

    @Override 
    public Action[] getActions(boolean context) { 
      return new Action[]{SystemAction.get(OpenAction.class)}; 
    } 

    @Override 
    public Action getPreferredAction() { 
      return SystemAction.get(OpenAction.class); 
    } 

不足为奇,getActions() 方法返回一个包含该节点动作的数组。动作是一种抽象(来自 Swing,而非 NetBeans),它允许我们向菜单中添加项目,并为用户提供与系统交互的手段。主菜单或上下文菜单中的每个条目都由一个动作支持。在我们的例子中,我们将 NetBeans 定义的 OpenAction 与我们的节点关联,当点击时,将在节点的 Lookup 中查找 OpenCookie 实例并调用我们之前定义的 OpenCookie.open()

我们还覆盖了 getPreferredAction() 方法,这让我们可以定义当节点被双击时的行为。这两个方法的组合使得用户可以通过右键单击节点并选择“打开”,或者双击节点,最终结果是打开该节点的 TopComponent。

服务 - 暴露解耦的功能

在查看我们的 TopComponent 定义之前,让我们先看看 PhotoManager,并了解一下它的服务。PhotoManager 接口本身相当简单:

    public interface PhotoManager extends Lookup.Provider { 
      void scanSourceDirs(); 
      List<String> getYears(); 
      List<String> getMonths(int year); 
      List<String> getPhotos(int year, int month); 
    } 

在前面的代码中,除了extends Lookup.Provider部分外,没有太多有趣的内容。在这里添加它,我们可以强制实现该接口上的唯一方法,因为我们需要在以后使用它。有趣的部分来自实现,如下所示:

    @ServiceProvider(service = PhotoManager.class) 
    public class PhotoManagerImpl implements PhotoManager { 

注册服务到平台所需的就是这些。注解指定了所需的元数据,构建过程会处理其余部分。让我们看看其余的实现:

    public PhotoManagerImpl() throws ClassNotFoundException { 
      setupDatabase(); 

      Preferences prefs =  
        NbPreferences.forModule(PhotoManager.class); 
      setSourceDirs(prefs.get("sourceDirs", "")); 
      prefs.addPreferenceChangeListener(evt -> { 
        if (evt.getKey().equals("sourceDirs")) { 
          setSourceDirs(evt.getNewValue()); 
          scanSourceDirs(); 
        } 
      }); 

      instanceContent = new InstanceContent(); 
      lookup = new AbstractLookup(instanceContent); 
      scanSourceDirs(); 
    } 

在这个非常简单的实现中,我们将使用 SQLite 来存储我们找到的照片信息。该服务将提供扫描配置的源目录、存储找到的照片信息以及公开检索这些信息片段的方法。

首先,我们需要确保如果这是应用程序第一次运行,数据库已经正确设置。我们可以包含一个预构建的数据库,但在用户的机器上创建它为那些意外删除数据库的情况增加了一丝弹性。

    private void setupDatabase() { 
      try { 
       connection = DriverManager.getConnection(JDBC_URL); 
       if (!doesTableExist()) { 
         createTable(); 
       } 
      } catch (SQLException ex) { 
        Exceptions.printStackTrace(ex); 
      } 
    } 

    private boolean doesTableExist() { 
      try (Statement stmt = connection.createStatement()) { 
        ResultSet rs = stmt.executeQuery("select 1 from images"); 
        rs.close(); 
        return true; 
      } catch (SQLException e) { 
        return false; 
      } 
    } 

    private void createTable() { 
      try (Statement stmt = connection.createStatement()) { 
        stmt.execute( 
          "CREATE TABLE images (imageSource VARCHAR2(4096), " 
          + " year int, month int, image VARCHAR2(4096));"); 
          stmt.execute( 
            "CREATE UNIQUE INDEX uniq_img ON images(image);"); 
      } catch (SQLException e) { 
        Exceptions.printStackTrace(e); 
      } 
    } 

接下来,我们请求模块PhotoManager的 NetBeans 首选项引用。我们将在本章的后面部分讨论首选项管理,但现在我们只能说,我们将请求系统的sourceDirs首选项,然后我们将使用它来配置我们的扫描代码。

我们还创建了PreferenceChangeListener来捕捉用户更改首选项的情况。在这个监听器中,我们验证我们关心的首选项sourceDirs是否已更改,如果是,我们将新值存储在我们的PhotoManager实例中,并启动目录扫描。

最后,我们创建InstanceContent,创建并存储一个查找,并启动目录扫描以确保应用程序与磁盘上照片的状态保持最新。

getYears()getMonths()getPhotos()方法在很大程度上是相同的,只是在处理的数据类型上有所不同,因此我们将getYears()作为这三个方法的解释:

    @Override 
    public List<String> getYears() { 
      List<String> years = new ArrayList<>(); 
      try (Statement yearStmt = connection.createStatement(); 
      ResultSet rs = yearStmt.executeQuery( 
        "SELECT DISTINCT year FROM images ORDER BY year")) { 
          while (rs.next()) { 
            years.add(rs.getString(1)); 
          } 
        } catch (SQLException ex) { 
          Exceptions.printStackTrace(ex); 
        } 
      return years; 
    } 

如果你熟悉 JDBC,这应该不会让你感到惊讶。我们使用 Java 7 的try-with-resources语法来声明和实例化我们的StatementResultSet对象。对于那些不熟悉这个结构的人来说,它允许我们声明某些类型的资源,并且不必担心关闭它们,因为系统会在try的作用域结束时自动为我们关闭它们。然而,需要注意的是,类必须实现AutoCloseableCloseable将不起作用。其他两个get*方法在逻辑上是相似的,所以这里没有展示。

这里最后的主要功能是扫描源目录,这由scanSourceDirs()方法协调,如下所示:

    private final ExecutorService executorService =  
      Executors.newFixedThreadPool(5); 
    public final void scanSourceDirs() { 
      RequestProcessor.getDefault().execute(() -> { 
        List<Future<List<Photo>>> futures = new ArrayList<>(); 
        sourceDirs.stream() 
         .map(d -> new SourceDirScanner(d)) 
         .forEach(sds ->  
          futures.add((Future<List<Photo>>)  
          executorService.submit(sds))); 
        futures.forEach(f -> { 
          try { 
            final List<Photo> list = f.get(); 
            processPhotos(list); 
          } catch (InterruptedException|ExecutionException ex) { 
            Exceptions.printStackTrace(ex); 
          } 
        }); 
        instanceContent.add(new ReloadCookie()); 
      }); 
    } 

为了加快处理过程,我们为每个配置的源目录创建一个 Future,并将其传递给我们的ExecutorService。我们在池中将其配置为最多五个线程,这基本上是随意的。一个更复杂的方法可能会使这可配置,或者可能是自动调整的,但这应该足以满足我们的需求。

一旦创建了 Futures,我们就遍历列表,请求每个结果。如果源目录的数量超过线程池的大小,额外的 Futures 将等待直到有线程可用,此时ExecutorService将选择一个来运行。一旦它们都完成了,对.get()的调用将不再阻塞,应用程序可以继续。请注意,我们没有阻塞用户界面以允许这样做,因为我们把大部分这个方法作为 lambda 传递给RequestProcessor.getDefault().execute(),以请求在用户界面线程上运行。

当照片列表构建并返回后,我们使用此方法处理这些照片:

    private void processPhotos(List<Photo> photos) { 
      photos.stream() 
       .filter(p -> !isImageRecorded(p)) 
       .forEach(p -> insertImage(p)); 
    } 

isImageRecorded()方法检查图像路径是否已经在数据库中,如果是,则返回 true。我们根据这个测试的结果filter()流,所以forEach()只对之前未知的图像操作,然后通过insertImage()将它们插入到数据库中。这两个方法看起来是这样的:

    private boolean isImageRecorded(Photo photo) { 
      boolean there = false; 
      try (PreparedStatement imageExistStatement =  
        connection.prepareStatement( 
          "SELECT 1 FROM images WHERE image = ?")) { 
            imageExistStatement.setString(1, photo.getImage()); 
            final ResultSet rs = imageExistStatement.executeQuery(); 
            there = rs.next(); 
            close(rs); 
          } catch (SQLException ex) { 
            Exceptions.printStackTrace(ex); 
          } 
      return there; 
    } 

    private void insertImage(Photo photo) { 
      try (PreparedStatement insertStatement =  
       connection.prepareStatement( 
         "INSERT INTO images (imageSource, year, month, image)
          VALUES (?, ?, ?, ?);")) { 
            insertStatement.setString(1, photo.getSourceDir()); 
            insertStatement.setInt(2, photo.getYear()); 
            insertStatement.setInt(3, photo.getMonth()); 
            insertStatement.setString(4, photo.getImage()); 
            insertStatement.executeUpdate(); 
       } catch (SQLException ex) { 
         Exceptions.printStackTrace(ex); 
       } 
    } 

我们正在使用PreparedStatement,因为通常通过连接创建 SQL 语句是不明智的,这可能导致,并且经常导致 SQL 注入攻击,所以我们不能在第一个方法中完全使用try-with-resources,这需要我们手动关闭ResultSet

PhotoViewerTopComponent

我们现在可以找到图像,但我们仍然不知道系统在哪里查找。在将注意力转向使用 NetBeans 平台处理首选项之前,我们还有一个 TopComponent 要查看--PhotoViewerTopComponent

如果你回顾一下我们对 NetBeans 窗口系统提供的区域进行的讨论,当我们查看图像时,我们希望图像在Editor区域加载。为了创建一个 TopComponent,我们指示 NetBeans 通过在所需的包上右键单击并选择“新建 | 窗口”来创建一个新Window

在下一个面板中,我们为我们的新 TopComponent 指定一个类名前缀--如以下截图所示的PhotoViewer

NetBeans 现在将创建PhotoViewerTopComponent.javaPhotoViewerTopComponent.form文件,正如之前讨论的那样。然而,对于这个 TopComponent,我们需要做一些更改。当我们打开Window时,我们需要指定一个图像让它加载,因此我们需要提供一个接受图像路径的构造函数。但是,TopComponents 必须有一个无参构造函数,所以我们保留它,但让它调用我们的新构造函数,并使用空图像路径。

    public PhotoViewerTopComponent() { 
      this(""); 
    } 

    public PhotoViewerTopComponent(String photo) { 
      initComponents(); 
      this.photo = photo; 
      File file = new File(photo); 
      setName(file.getName()); 
      setToolTipText(photo); 
      associateLookup(Lookups.singleton(photo)); 
      setLayout(new BorderLayout()); 
      init(); 
    } 

虽然步骤看起来可能很多,但这些步骤很简单:我们将照片路径保存在一个实例变量中,我们从中创建一个File实例以更容易地获取文件名,将照片路径添加到 TopComponent 的 Lookup(这是我们找到给定照片的 TopComponent 的方式),更改布局,然后初始化窗口。

将 JavaFX 与 NetBeans RCP 集成

虽然init()方法很有趣,因为我们将要做一些稍微不同的事情;我们将使用 JavaFX 来查看图片。我们没有理由不能像我们在其他 TopComponent 中那样使用 Swing,但这也为我们提供了一个很好的机会来展示如何集成 JavaFX 和 Swing,以及 JavaFX 和 NetBeans 平台。

    private JFXPanel fxPanel; 
    private void init() { 
      fxPanel = new JFXPanel(); 
      add(fxPanel, BorderLayout.CENTER); 
      Platform.setImplicitExit(false); 
      Platform.runLater(this::createScene); 
    } 

JFXPanel是一个用于将 JavaFX 嵌入 Swing 的 Swing 组件。我们的窗口布局是BorderLayout,所以我们将其添加到CENTER区域,并让它填充整个Window。JavaFX 组件的任何复杂布局都将由我们JFXPanel内的另一个容器处理。然而,我们的用户界面相当简单。就像我们之前的 JavaFX 系统一样,我们通过以下方式定义我们的用户界面:

    <BorderPane fx:id="borderPane" prefHeight="480.0"  
      prefWidth="600.0"  

      fx:controller= 
        "com.steeplesoft.photobeans.main.PhotoViewerController"> 
      <center> 
        <ScrollPane fx:id="scrollPane"> 
          <content> 
            <Group> 
              <children> 
                <ImageView fx:id="imageView"  
                  preserveRatio="true" /> 
              </children> 
            </Group> 
          </content> 
        </ScrollPane> 
      </center> 
    </BorderPane> 

由于 FXML 需要一个根元素,我们指定了一个BorderLayout,正如讨论的那样,这在我们位于BorderLayout中的JFXPanel中提供了一个BorderLayout。这听起来可能真的很奇怪,但这就是嵌入 JavaFX 的方式。注意,我们仍然指定了一个控制器。在这个控制器中,我们的initialize()方法看起来是这样的:

    @FXML 
    private BorderPane borderPane; 
    @FXML 
    private ScrollPane scrollPane; 
    public void initialize(URL location,
     ResourceBundle resources) { 
       imageView.fitWidthProperty() 
        .bind(borderPane.widthProperty()); 
       imageView.fitHeightProperty() 
        .bind(borderPane.heightProperty()); 
    } 

在这个最后的方法中,我们只是在将宽度和高度属性绑定到边框面板的属性上。我们还在 FXML 中将preserveRatio设置为True,所以图片不会变形。当我们接下来旋转图片时,这将非常重要。

我们还没有看到旋转的代码,所以现在让我们看看。我们将首先添加一个按钮,如下所示:

    <top> 
      <ButtonBar prefHeight="40.0" prefWidth="200.0"  
         BorderPane.alignment="CENTER"> 
         <buttons> 
           <SplitMenuButton mnemonicParsing="false" 
             text="Rotate"> 
              <items> 
                <MenuItem onAction="#rotateLeft"  
                  text="Left 90°" /> 
                <MenuItem onAction="#rotateRight"  
                  text="Right 90°" /> 
              </items> 
            </SplitMenuButton> 
         </buttons> 
      </ButtonBar> 
    </top> 

我们将ButtonBar添加到BorderPanetop部分,然后向其中添加一个单独的SplitMenuButton。这给了我们一个像右边那样的按钮。在其非聚焦状态下,它看起来像一个正常的按钮。当用户点击箭头时,就像这里看到的那样,菜单会呈现给用户,提供旋转图片在列出的方向上的能力:

图片

我们已经将那些菜单项绑定到我们的 FXML 定义中控制器的相应方法:

    @FXML 
    public void rotateLeft(ActionEvent event) { 
      imageView.setRotate(imageView.getRotate() - 90); 
    } 
    @FXML 
    public void rotateRight(ActionEvent event) { 
      imageView.setRotate(imageView.getRotate() + 90); 
    } 

使用 JavaFX ImageView提供的 API,我们设置了图片的旋转。

我们可以找到图片,查看它们,并旋转它们,但我们仍然无法告诉系统在哪里寻找这些图片。是时候解决这个问题了。

NetBeans 首选项和选项面板

管理首选项的关键有两点:NbPreferences和选项面板。NbPreferences是存储和加载首选项的手段,而选项面板是向用户提供编辑这些首选项的用户界面的手段。我们将首先查看如何添加选项面板,这将自然地引出对NbPreferences的讨论。接下来是 NetBeans 选项窗口:

图片

在前面的窗口中,我们可以看到两种类型的选项面板——主选项面板和二级选项面板。主选项面板由顶部的图标表示:常规、编辑器、字体和颜色等。二级选项面板类似于我们在中间部分看到的标签:差异、文件、输出和终端。当添加选项面板时,您必须选择主选项面板或二级选项面板。我们希望添加一个新的主选项面板,因为它不仅可以从视觉上区分我们的首选项与其他面板,还给我们一个机会创建两种类型的面板。

添加主面板

要创建一个主选项面板,在所需的包或项目节点上右键单击,然后点击新建 | 选项面板。如果选项面板不可见,请选择新建 | 其他 | 模块开发 | 选项面板。接下来,选择创建主面板:

图片

我们必须指定一个标签,这是我们将在图标下看到的文本。我们还必须选择一个图标。系统将允许您选择除 32x32 图像之外的内容,但如果它的大小不正确,它将在用户界面中看起来很奇怪;因此,请仔细选择。系统还将要求您输入关键词,如果用户在选项窗口中应用过滤器,将使用这些关键词。最后,选择允许二级面板。主面板没有任何实际内容,仅用于显示我们即将创建的二级面板。

当您点击下一步时,您将被要求输入类前缀和包:

图片

当您点击完成时,NetBeans 将创建此单个文件,package-info.java

    @OptionsPanelController.ContainerRegistration(id = "PhotoBeans", 
      categoryName = "#OptionsCategory_Name_PhotoBeans",  
      iconBase = "com/steeplesoft/photobeans/main/options/
       camera-icon-32x32.png",  
       keywords = "#OptionsCategory_Keywords_PhotoBeans",  
       keywordsCategory = "PhotoBeans") 
    @NbBundle.Messages(value = { 
      "OptionsCategory_Name_PhotoBeans=PhotoBeans",  
      "OptionsCategory_Keywords_PhotoBeans=photo"}) 
    package com.steeplesoft.photobeans.main.options; 

    import org.netbeans.spi.options.OptionsPanelController; 
    import org.openide.util.NbBundle; 

添加二级面板

在定义了主选项面板后,我们准备创建二级面板,它将执行我们的工作。我们再次在包上右键单击,并选择新建 | 选项面板,这次选择创建二级面板:

图片

由于我们已经定义了自己的主选项面板,我们可以选择它作为父面板,并设置标题和关键词,就像之前做的那样。点击下一步,选择并/或验证类前缀和包,然后点击完成。这将创建三个工件——SourceDirectoriesOptionPanelController.javaSourceDirectoriesPanel.javaSourceDirectoriesPanel.form,NetBeans 将向您展示您的面板的 GUI 编辑器。

我们想在面板中添加四个元素——一个标签、一个列表视图和两个按钮。我们通过从右侧的调色板拖动它们并将它们按照下面的形式排列来实现这一点:

图片

为了使与这些用户界面元素一起工作更有意义,我们需要设置变量名。我们还需要设置用户界面的文本,以便每个元素对用户都有意义。我们可以通过在每个元素上右键单击来实现这两点,如以下截图所示:

图片

在前面的屏幕中,我们可以看到三个感兴趣的项目——编辑文本、更改变量名...和事件 | 操作 | actionPeformed [buttonAddActionPerformed]。对于我们的按钮,我们需要使用所有三个,所以我们设置文本为添加(或删除),更改变量名为buttonAdd/buttonRemove,并选择actionPerformed。回到我们的 Java 源代码中,我们看到为我们创建了一个方法,我们需要填写它:

    private void buttonAddActionPerformed(ActionEvent evt) {                                               
      String lastDir = NbPreferences 
       .forModule(PhotoManager.class).get("lastDir", null); 
      JFileChooser chooser = new JFileChooser(); 
      if (lastDir != null) { 
        chooser.setCurrentDirectory( 
          new java.io.File(lastDir)); 
      } 
      chooser.setDialogTitle("Add Source Directory"); 
      chooser.setFileSelectionMode(
        JFileChooser.DIRECTORIES_ONLY); 
      chooser.setAcceptAllFileFilterUsed(false); 
      if (chooser.showOpenDialog(null) ==  
        JFileChooser.APPROVE_OPTION) { 
          try { 
            String dir = chooser.getSelectedFile() 
            .getCanonicalPath(); 
            ensureModel().addElement(dir); 
            NbPreferences.forModule(PhotoManager.class) 
            .put("lastDir", dir); 
          } catch (IOException ex) { 
              Exceptions.printStackTrace(ex); 
            } 
        } else { 
            System.out.println("No Selection "); 
          } 
    } 

这里有很多事情在进行中:

  1. 我们首先检索lastDir首选项值。如果已设置,我们将使用它作为选择要添加的目录的起点。通常,至少在我的经验中,感兴趣的目录在文件系统中通常彼此很近,所以我们使用这个首选项来节省用户一些点击。

  2. 接下来我们创建JFileChooser,这是 Swing 类,它将允许我们选择目录。

  3. 如果lastDir不为空,我们将其传递给setCurrentDirectory()

  4. 我们将对话框的标题设置为有意义的名称。

  5. 我们指定对话框应只允许我们选择目录。

  6. 最后,我们禁用“全选”文件过滤器选项。

  7. 我们调用chooser.showOpenDialog()向用户展示对话框,并等待其关闭。

  8. 如果对话框的返回代码是APPROVE_OPTION,我们需要将所选目录添加到我们的模型中。

  9. 我们获取所选文件的规范路径。

  10. 我们调用ensureModel(),稍后我们将对其进行探讨,以获取我们的ListView模型,然后向其中添加这个新路径。

  11. 最后,我们将所选路径作为lastDir存储到我们的首选项中,以设置之前讨论的起始目录。

  12. 删除按钮的动作要简单得多,如下所示:

        private void buttonRemoveActionPerformed(ActionEvent evt) {                                              
          List<Integer> indexes = IntStream.of( 
            sourceList.getSelectedIndices()) 
            .boxed().collect(Collectors.toList()); 
          Collections.sort(indexes); 
          Collections.reverse(indexes); 
          indexes.forEach(i -> ensureModel().remove(i)); 
        } 

当我们从模型中删除项目时,我们通过项目索引来删除它们。然而,当我们删除一个项目时,之后任何内容的索引号都会改变。因此,我们在这里创建一个包含所选索引的列表,对其进行排序以确保它处于正确的顺序(这里可能有些过度,但这是一个相对低成本的运算,并且使下一个操作更安全),然后我们反转列表的顺序。现在我们的索引现在是降序的,我们可以遍历列表,从我们的模型中删除每个索引。

我们已经使用ensureModel()几次了,所以让我们看看它的样子:

    private DefaultListModel<String> ensureModel() { 
      if (model == null) { 
        model = new DefaultListModel<>(); 
        sourceList.setModel(model); 
      } 
      return model; 
    } 

重要的是我们以DefaultListModel而不是ListView期望的ListModel类型来处理模型,因为后者不公开任何用于修改模型内容的方法,而前者则公开。通过处理DefaultListModel,我们可以根据需要添加和删除项目,就像我们在这里所做的那样。

加载和保存首选项

在这个类中,我们还需要查看另外两个方法,即加载和存储面板中表示的选项。我们将从load()开始,如下所示:

    protected void load() { 
      String dirs = NbPreferences 
       .forModule(PhotoManager.class).get("sourceDirs", ""); 
      if (dirs != null && !dirs.isEmpty()) { 
        ensureModel(); 
        model.clear(); 
        Set<String> set = new HashSet<>( 
          Arrays.asList(dirs.split(";"))); 
        set.forEach(i -> model.addElement(i)); 
      } 
    } 

NbPreferences不支持存储字符串列表,因此,正如我们下面将要看到的,我们将源目录列表存储为一个分号分隔的字符串列表。在这里,我们加载sourceDirs的值,如果它不为 null,我们就用分号分割它,并将每个条目添加到我们的DefaultListModel中。

保存源目录也是相当直接的:

    protected void store() { 
      Set<String> dirs = new HashSet<>(); 
      ensureModel(); 
      for (int i = 0; i < model.getSize(); i++) { 
        final String dir = model.getElementAt(i); 
        if (dir != null && !dir.isEmpty()) { 
          dirs.add(dir); 
        } 
      } 
      if (!dirs.isEmpty()) { 
        NbPreferences.forModule(PhotoManager.class) 
        .put("sourceDirs", String.join(";", dirs)); 
      } else { 
        NbPreferences.forModule(PhotoManager.class) 
          .remove("sourceDirs"); 
      } 
    } 

我们遍历ListModel,将每个目录添加到本地的HashSet实例中,这有助于我们删除任何重复的目录。如果Set不为空,我们使用String.join()创建我们的分隔列表,并将其put()到我们的首选项存储中。如果它是空的,我们就从存储中删除首选项条目以清除可能之前持久化的任何旧数据。

对首选项更改做出反应

现在我们能够持久化更改,我们需要让应用程序对更改做出反应。幸运的是,NetBeans RCP 提供了一个整洁的解耦方式来处理这个问题。我们在这里不需要显式地调用代码中的方法。我们可以在系统中我们感兴趣更改的点附加一个监听器。我们已经在PhotoManagerImpl中看到了这段代码:

    prefs.addPreferenceChangeListener(evt -> { 
      if (evt.getKey().equals("sourceDirs")) { 
        setSourceDirs(evt.getNewValue()); 
        scanSourceDirs(); 
      } 
    }); 

当我们为PhotoManager模块保存任何首选项时,这个监听器就会被调用。我们只是简单地检查确保它是对我们感兴趣的关键,然后相应地采取行动,正如我们所看到的,这涉及到重新启动源目录扫描过程。

一旦新数据被加载,我们如何让用户界面反映这个变化?我们是否必须手动更新用户界面?再次感谢 RCP,答案是肯定的。我们在scanSourceDirs()的末尾看到了第一部分,如下所示:

    instanceContent.add(new ReloadCookie()); 

NetBeans 提供了一些 cookie 类来指示某些动作应该发生。虽然我们不共享类层次结构(由于不幸依赖于 Nodes API),但我们确实共享相同的命名约定,以便可以说“借鉴”一点熟悉感。那么ReloadCookie看起来是什么样子呢?它并不复杂;它看起来是这样的:

    public class ReloadCookie { 
    } 

在我们的情况下,我们只有一个空类。我们并不打算在其他地方使用它,所以我们不需要在类中编码任何功能。我们只是将其用作指示器,正如我们在RootNode的构造函数中看到的那样,如下所示:

    reloadResult = photoManager.getLookup().lookup( 
      new Lookup.Template(ReloadCookie.class)); 
    reloadResult.addLookupListener(event -> setChildren( 
      Children.create(new YearChildFactory(), true))); 

Lookup.Template 用于定义系统可以过滤我们的 Lookup 请求的模式。使用我们的模板,我们创建了一个 Lookup.Result 对象,reloadResult,并通过 lambda 表达式添加一个监听器。lambda 表达式使用 Children.create() 和之前查看过的 YearChildFactory 创建一组新的子节点,并将它们传递给 setChildren() 以更新用户界面。

这可能看起来为了在预设改变时更新用户界面而需要编写相当多的代码,但解耦的好处无疑是值得的。想象一个更复杂的应用或一个依赖的模块树。使用这种监听器方法,我们不需要向外界暴露方法,甚至不需要暴露类,这样我们的内部代码就可以在不破坏客户端代码的情况下进行修改。简而言之,这就是解耦代码的一个主要原因。

摘要

再次,我们又完成了一个应用的开发。你学习了如何启动基于 Maven 的 NetBeans 富客户端平台应用。你了解了 RCP 模块,以及如何在我们的应用构建中包含这些模块。你还学习了 NetBeans RCP 节点 API 的基础知识,如何创建我们自己的节点,以及如何嵌套子节点。我们解释了如何使用 NetBeans 预设 API,包括创建新的选项面板来编辑预设,如何加载和存储它们,以及如何对预设的变化做出反应。

最后关于 NetBeans RCP 的一点——虽然我们在这里构建了一个令人尊重的应用,但我们并没有完全发挥 RCP 的潜力。我试图涵盖足够多的平台内容以帮助你开始,但如果你要继续使用这个平台,你几乎肯定需要学习更多。虽然官方文档很有帮助,但全面覆盖的入门指南是 Jason Wexbridge 和 Walter Nyland 所著的《NetBeans 平台入门》(leanpub.com/nbp4beginners)。这是一本很好的书,我强烈推荐它。

在下一章中,我们将尝试涉足客户端/服务器编程的领域,并实现我们自己的记事应用。它可能不会像市场上已有的竞争对手那样功能强大和全面,但我们将在这个方向上取得良好的进展,并且有望在过程中学到很多。

第二十七章:使用 Monumentum 记笔记

对于我们的第八个项目,我们又将做一些新的事情——我们将构建一个 Web 应用程序。与其他所有项目都是命令行、GUI 或它们的某种组合不同,这个项目将是一个由一个 REST API 和一个 JavaScript 前端组成的单一模块,所有这些都是在关注当前的微服务趋势的情况下构建的。

为了构建应用程序,你将学习以下主题:

  • 一些用于构建微服务应用程序的 Java 选项

  • Payara Micro 和microprofile.io

  • Java API for RESTful Web Services

  • 文档数据存储和 MongoDB

  • OAuth 身份验证(针对 Google,具体而言)

  • JSON Web TokensJWT

正如你所见,这将是一个在很多方面与我们迄今为止所研究的项目截然不同的项目。

入门指南

大多数人可能都使用过某种笔记应用,如 EverNote、OneNote 或 Google Keep。它们是记录笔记和想法的极其方便的方式,并且几乎可以在任何可想象的环境中访问——桌面、移动和网页。在本章中,我们将构建这些行业巨头的相当基础的克隆版,以便练习许多概念。我们将把这个应用命名为 Monumentum,这是拉丁语中提醒或纪念的意思,对于这类应用来说是一个恰当的名字。

在我们深入探讨这些之前,让我们花些时间列出我们应用程序的需求:

  • 能够创建笔记

  • 能够列出笔记

  • 能够编辑笔记

  • 能够删除笔记

  • 笔记内容必须能够存储/显示富文本

  • 能够创建用户账户

  • 必须能够使用 OAuth2 凭据登录到现有系统中的应用程序

我们的非功能性需求相当适度:

  • 必须有一个 RESTful API

  • 必须有一个 HTML 5/JavaScript 前端

  • 必须有一个灵活、可扩展的数据存储

  • 必须能够在资源受限的系统上轻松部署

当然,这个非功能性需求的列表部分是基于它们反映了现实世界的需求,但它们也为我们讨论我在本章中想涵盖的一些技术提供了很好的基础。直截了当地说,我们将创建一个提供基于 REST 的 API 和 JavaScript 客户端的 Web 应用程序。它将使用文档数据存储,并使用 JVM 上可用的许多微服务库/框架之一构建。

那么,这个堆栈看起来是什么样子呢?在我们确定一个特定的选择之前,让我们快速浏览一下我们的选项。让我们先看看微服务框架。

JVM 上的微服务框架

虽然我不愿意花太多时间在解释微服务是什么上,因为大多数人对此已经很熟悉了,但我认为如果不至少简要描述一下,可能会有些失职,以防你对这个概念不熟悉。话虽如此,以下是来自 SmartBear(一个软件质量工具提供商,可能最出名的是他们维护 Swagger API 和相关库)的一个关于微服务的简洁定义:

实质上,微服务架构是一种将软件应用程序作为一套独立可部署的、小型、模块化服务的方法,其中每个服务运行一个独特的进程,并通过一个定义良好、轻量级的机制进行通信,以实现业务目标。

换句话说,与将几个相关系统捆绑在一个 Web 应用程序中并部署到大型应用程序服务器(如 GlassFish/Payara Server、Wildfly、WebLogic Server 或 WebSphere)的较旧、更成熟的方法相比,这些系统将分别在其自己的 JVM 进程中独立运行。这种方法的好处包括更容易的逐部分升级、通过进程隔离增加稳定性、更小的资源需求、更高的机器利用率等等。这个概念本身可能并不新颖,但它确实在近年来获得了流行,并且仍在以快速的速度增长。

那么,在 JVM 上我们的选择有哪些呢?我们有很多选择,包括但不限于以下内容:

  • Eclipse Vert.x:这是一个官方的在 JVM 上构建反应式应用程序的工具包。它提供了一个事件驱动的应用程序框架,非常适合编写微服务。Vert.x 可以使用多种语言,包括 Java、JavaScript、Kotlin、Ceylon、Scala、Groovy 和 Ruby。更多信息可以在vertx.io/找到。

  • Spring Boot:这是一个用于构建独立 Spring 应用程序的库。Spring Boot 应用程序可以完全访问整个 Spring 生态系统,并且可以使用单个 fat/uber JAR 运行。Spring Boot 的网站是projects.spring.io/spring-boot/

  • Java EE MicroProfile:这是一个由社区和供应商领导的努力,旨在为 Java EE 创建一个新的配置文件,特别针对微服务。在撰写本文时,该配置文件包括Java API for RESTful Web ServicesJAX-RS)、CDI 和 JSON-P,并由包括 Tomitribe、Payara、Red Hat、Hazelcast、IBM 和 Fujitsu 在内的多家公司以及如伦敦 Java 社区和 SouJava 等用户团体赞助。MicroProfile 的主页是microprofile.io/

  • Lagom:这是一个相对较新的框架,由 Scala 背后的公司 Lightbend 开发的响应式微服务框架。它被描述为一个有偏见的微服务框架,并使用 Lightbend 的两个更著名的库——Akka 和 Play 构建。Lagom 应用程序可以用 Java 或 Scala 编写。更多详细信息可以在www.lightbend.com/platform/development/lagom-framework找到。

  • Dropwizard:这是一个用于开发操作友好、高性能、RESTful Web 服务的 Java 框架。它提供了一个包含 Jetty(用于 HTTP)、Jersey(用于 REST 服务)和 Jackson(用于 JSON)的框架。它还支持其他库,如 Guava、Hibernate Validator、Freemarker 等。您可以在www.dropwizard.io/找到 Dropwizard。

还有其他一些选择,但应该很清楚,作为 JVM 开发者,我们有无数的选择,这几乎总是好的。由于我们只能使用一个,因此我选择了 MicroProfile。具体来说,我们将基于 Payara Micro,这是基于 GlassFish 源代码(加上 Payara 的 bug 修复、增强等)的实现。

通过选择 MicroProfile 和 Payara Micro,我们隐含地选择了 JAX-RS 作为我们 REST 服务的基石。当然,我们可以使用我们想要的任何东西,但偏离框架提供的功能会降低框架本身的价值。

这就让我们面临选择数据存储的问题。我们已经看到的一个选项是关系型数据库。这是一个经过考验且可靠的选项,为整个行业提供了广泛的支持。然而,它们并非没有局限性和问题。虽然数据库本身在分类和功能方面可能很复杂,但最流行的关系型数据库替代品可能是 NoSQL 数据库。尽管这些数据库已经存在了半个世纪,但直到大约十年前,随着Web 2.0的出现,这一理念才在市场上获得了显著的影响力。

虽然NoSQL这个术语非常广泛,但这类数据库的大多数例子往往是键值、文档或图数据存储,每种都提供独特的性能和行为特征。全面介绍每种 NoSQL 数据库及其各种实现超出了本书的范围,因此,为了节省时间和空间,我们将直接进入我们的选择——MongoDB。它在文档模式方面的可扩展性和灵活性非常适合我们的目标用例。

最后,在客户端,我们又有许多选择。其中最受欢迎的是来自 Facebook 的 ReactJS 和来自 Google 的 Angular。还有各种各样的其他框架,包括较老的选择,如 Knockout 和 Backbone,以及较新的选择,如 Vue.js。我们将使用后者。这不仅是一个非常强大和灵活的选择,而且它还提供了最少的启动摩擦。由于这本书专注于 Java,我觉得选择一个在满足我们需求的同时设置最少的选项是明智的。

创建应用程序

要使用 Payara Micro,我们创建一个像平常一样的 Java Web 应用程序。在 NetBeans 中,我们将选择文件 | 新项目 | Maven | Web 应用程序,然后点击下一步。对于项目名称,输入monumentum,选择适当的 Project Location,并按需设置 Group ID 和 Package:

图片

下一个窗口将要求我们选择一个服务器,我们可以将其留空,并选择 Java EE 版本,我们希望将其设置为 Java EE 7 Web:

图片

几分钟后,我们应该创建好项目并准备就绪。由于我们创建了一个 Java EE 7 Web 应用程序,NetBeans 已经将 Java EE API 依赖项添加到了项目中。在我们开始编码之前,让我们将 Payara Micro 添加到构建中,以便准备好这部分。为此,我们需要向构建中添加一个插件。它看起来可能像这样(尽管我们在这里只展示了高亮部分):

    <plugin>
      <groupId>org.codehaus.mojo</groupId>
      <artifactId>exec-maven-plugin</artifactId>
      <version>1.5.0</version>
      <dependencies>
        <dependency>
          <groupId>fish.payara.extras</groupId>
          <artifactId>payara-microprofile</artifactId>
          <version>1.0</version>
        </dependency>
      </dependencies>

这设置了 Maven exec 插件,用于执行外部应用程序,或者像我们在这里要做的那样,执行 Java 应用程序:

    <executions>
      <execution>
        <id>payara-uber-jar</id>
        <phase>package</phase>
        <goals>
          <goal>java</goal>
        </goals>

在这里,我们将此插件的执行与 Maven 的 package 阶段关联起来。这意味着当我们运行 Maven 来构建我们的项目时,插件的目标将在 Maven 开始打包项目时运行,这样我们就可以精确地改变 JAR 中包含的内容:

    <configuration>
      <mainClass>
        fish.payara.micro.PayaraMicro
      </mainClass>
      <arguments>
        <argument>--deploy</argument>
        <argument>
          ${basedir}/target/${warfile.name}.war
        </argument>
        <argument>--outputUberJar</argument>
        <argument>
          ${basedir}/target/${project.artifactId}.jar
        </argument>
      </arguments>
    </configuration>

这个最后一部分配置了插件。它将运行PayaraMicro类,传递--deploy <path> --outputUberJar ...命令。实际上,我们正在告诉 Payara Micro 如何运行我们的应用程序,但我们不想立即执行包,而是希望它创建一个稍后运行的 uber JAR。

通常,当你构建你的项目时,你会得到一个只包含直接包含在你的项目中的类和资源的 jar 文件。任何外部依赖项都作为执行环境必须提供的东西留下。在 uber JAR 中,所有的依赖项都包含在我们的项目 jar 中,然后以这种方式配置,以便执行环境可以按需找到它们。

设置的问题在于,如果保持原样,当我们构建时,我们会得到一个 uber JAR,但我们将没有简单的方法从 NetBeans 运行应用程序。为了解决这个问题,我们需要一个稍微不同的插件配置。具体来说,它需要这些行:

    <argument>--deploy</argument> 
    <argument> 
      ${basedir}/target/${project.artifactId}-${project.version} 
    </argument> 

这些选项替换了先前的 deployoutputUberJar 选项。为了加快我们的构建速度,我们也不希望在请求之前创建 uber JAR,因此我们可以将这些两个插件配置分别放入两个独立的配置文件中,如下所示:

    <profiles> 
      <profile> 
        <id>exploded-war</id> 
        <!-- ... --> 
      </profile> 
      <profile> 
        <id>uber</id> 
        <!-- ... --> 
      </profile> 
    </profiles> 

当我们准备构建部署工件时,在执行 Maven 时激活 uber 配置文件,我们将得到可执行的 jar 文件:

$ mvn -Puber install 

exploded-war 配置文件是我们将在 IDE 中使用的配置,它运行 Payara Micro,指向我们构建目录中的展开的 war 文件。为了指导 NetBeans 使用它,我们需要修改几个操作配置。为此,在 NetBeans 中右键单击项目,并从上下文菜单底部选择属性。在操作下,找到运行项目并选择它,然后在激活配置文件下输入 exploded-war

如果我们现在运行应用程序,NetBeans 会抱怨因为我们还没有选择服务器。虽然这是一个 Web 应用程序,通常需要服务器,但我们使用的是 Payara Micro,因此不需要定义应用程序服务器。幸运的是,NetBeans 会允许我们告诉它,如下面的截图所示:

选择忽略,我不想让 IDE 管理部署,然后点击确定,接着观察输出窗口。你应该会看到相当多的文本滚动过去,几秒钟后,你应该会看到类似这样的文本:

Apr 05, 2017 1:18:59 AM fish.payara.micro.PayaraMicro bootStrap 
INFO: Payara MicroProfile  4.1.1.164-SNAPSHOT (build ${build.number}) ready in 9496 (ms) 

一旦看到这一点,我们就准备好测试我们的应用程序,就像现在这样。在你的浏览器中打开 http://localhost:8080/monumentum-1.0-SNAPSHOT/index.html,你应该会在页面上看到一个大型且令人兴奋的 Hello World! 消息。如果你看到这个,你就已经成功启动了一个 Payara Micro 项目。花点时间祝贺自己,然后我们将让应用程序做一些有用的事情。

创建 REST 服务

虽然这基本上是一个 Java EE 应用程序,尽管它的打包和部署方式略有不同,但你可能学到的关于编写 Java EE 应用程序的大部分内容很可能仍然适用。当然,你可能从未编写过这样的应用程序,所以我们将一步步进行。

在 Java EE 中编写的 REST 应用程序使用 JAX-RS,我们 JAX-RS 的起点是一个 ApplicationApplication 是一种与部署无关的声明根级资源给运行时的方法。运行时如何找到 Application 当然取决于运行时本身。对于像我们这样的 MicroProfile 应用程序,我们将在 Servlet 3.0 环境中运行,所以我们不需要做任何特殊的事情,因为 Servlet 3.0 支持无描述符的部署选项。运行时将扫描带有 @ApplicationPath 注解的 Application 类,并使用它来配置 JAX-RS 应用程序,如下所示:

    @ApplicationPath("/api") 
      public class Monumentum extends javax.ws.rs.core.Application { 
      @Override 
      public Set<Class<?>> getClasses() { 
        Set<Class<?>> s = new HashSet<>(); 
        return s; 
      } 
    } 

使用 @ApplicationPath 注解,我们指定应用程序 REST 端点的根 URL,这当然是相对于 Web 应用程序的根上下文。Application 有三个我们可以覆盖的方法,但我们只对这里列出的一个感兴趣:getClasses()。我们将在稍后提供关于此方法的更多详细信息,但就目前而言,请记住这是我们向 JAX-RS 描述顶级资源的方式。

Monumentum 将有一个非常简单的 API,主要端点是用于与笔记交互。为了创建这个端点,我们创建一个简单的 Java 类,并用适当的 JAX-RS 注解标记它:

    @Path("/notes") 
    @RequestScoped 
    @Produces(MediaType.APPLICATION_JSON)  
    public class NoteResource { 
    } 

使用这个类,我们正在描述一个将位于 /api/notes 的端点,并将产生 JSON 结果。JAX-RS 支持,例如,XML,但大多数 REST 开发者习惯于 JSON,并期望没有其他内容,所以我们不需要支持除 JSON 之外的内容。当然,您应用程序的需求可能会有所不同,因此您可以根据需要调整支持的媒体类型列表。

虽然这将编译并运行,JAX-RS 将尝试处理对端点的请求,但我们还没有实际定义它。为了做到这一点,我们需要向端点添加一些方法来定义端点的输入和输出,以及我们将使用的 HTTP 动词/方法。让我们从笔记集合端点开始:

    @GET 
    public Response getAll() { 
      List<Note> notes = new ArrayList<>(); 
      return Response.ok( 
        new GenericEntity<List<Note>>(notes) {}).build(); 
    } 

现在我们有一个端点,它回答 /api/notesGET 请求,并返回 Note 实例的 List。在 REST 开发者中,关于这些方法的正确返回值有一些争议。有些人更喜欢返回客户端将看到的实际类型,例如我们案例中的 List<Note>,因为它使阅读源代码的开发者或由此生成的文档更清楚。其他人,像我们在这里所做的那样,更喜欢返回 JAX-RS Response 对象,因为它提供了对响应的更多控制,包括 HTTP 标头、状态码等。我倾向于更喜欢这种第二种方法,就像我们在这里所做的那样。当然,您当然可以自由选择使用任何一种方法。

最后要注意的一点是我们构建响应体的方式:

    new GenericEntity<List<Note>>(notes) {} 

通常,在运行时,由于类型擦除,List 的参数化类型会丢失。使用像这样的 GenericEntity 允许我们捕获参数化类型,从而使运行时能够序列化数据。使用这允许我们避免编写自己的 MessageBodyWriter。代码越少几乎总是好事。

如果我们现在运行我们的应用程序,我们将得到以下响应,尽管它非常无聊:

$ curl http://localhost:8080/monumentum-1.0-SNAPSHOT/api/notes/
[] 

这既令人满意,又并非如此,但它确实表明我们正在正确的轨道上。显然,我们希望这个端点返回数据,但我们没有添加注释的方法,所以现在让我们解决这个问题。

通过 REST 创建新实体是通过将其 POST 到其集合来完成的。该方法看起来像这样:

    @POST 
    public Response createNote(Note note) { 
      Document doc = note.toDocument(); 
      collection.insertOne(doc); 
      final String id = doc.get("_id",  
        ObjectId.class).toHexString(); 

      return Response.created(uriInfo.getRequestUriBuilder() 
        .path(id).build()) 
      .build(); 
    } 

@POST注解表示使用 HTTP POST 动词。该方法接受一个Note实例,并返回一个Response,正如我们在前面的代码中所看到的。注意,我们没有直接处理 JSON。通过在方法签名中指定Note,我们可以利用 JAX-RS 的一个伟大特性--POJO 映射。我们在之前的代码中已经看到了它的一个暗示,即GenericEntity。JAX-RS 将尝试反序列化--即,将序列化形式转换为模型对象--JSON 请求体。如果客户端发送了正确格式的 JSON 对象,我们将得到一个可用的Note实例。如果客户端发送了一个构建不当的对象,它将得到一个响应。这个特性允许我们只处理我们的领域对象,而不必担心 JSON 编码和解码,这可以节省大量的时间和精力。

添加 MongoDB

在方法体中,我们首次看到了与 MongoDB 的集成。为了使这个编译成功,我们需要添加对 MongoDB Java Driver 的依赖:

    <dependency> 
      <groupId>org.mongodb</groupId> 
      <artifactId>mongodb-driver</artifactId> 
      <version>3.4.2</version> 
    </dependency> 

MongoDB 处理文档,因此我们需要将我们的领域模型转换为Document,这是通过我们的模型类上的一个方法来实现的。我们还没有查看Note类的细节,所以现在让我们看看:

    public class Note { 
      private String id; 
      private String userId; 
      private String title; 
      private String body; 
      private LocalDateTime created = LocalDateTime.now(); 
      private LocalDateTime modified = null; 

      // Getters, setters and some constructors not shown 

      public Note(final Document doc) { 
        final LocalDateTimeAdapter adapter =  
          new LocalDateTimeAdapter(); 
        userId = doc.getString("user_id"); 
        id = doc.get("_id", ObjectId.class).toHexString(); 
        title = doc.getString("title"); 
        body = doc.getString("body"); 
        created = adapter.unmarshal(doc.getString("created")); 
        modified = adapter.unmarshal(doc.getString("modified")); 
      } 

      public Document toDocument() { 
        final LocalDateTimeAdapter adapter =  
           new LocalDateTimeAdapter(); 
        Document doc = new Document(); 
        if (id != null) { 
           doc.append("_id", new ObjectId(getId())); 
        } 
        doc.append("user_id", getUserId()) 
         .append("title", getTitle()) 
         .append("body", getBody()) 
         .append("created",  
           adapter.marshal(getCreated() != null 
           ? getCreated() : LocalDateTime.now())) 
         .append("modified",  
           adapter.marshal(getModified())); 
         return doc; 
      } 
    } 

这基本上只是一个普通的 POJO。我们添加了一个构造函数和一个实例方法来处理转换为和从 MongoDB 的Document类型。

这里有几个需要注意的地方。第一个是如何处理 MongoDB Document的 ID。存储在 MongoDB 数据库中的每个文档都会被分配一个_id。在 Java API 中,这个_id被表示为ObjectId。我们不希望这个细节暴露在我们的领域模型中,所以我们将其转换为String然后再转换回来。

我们还需要对我们日期字段进行一些特殊处理。我们选择将createdmodified属性表示为LocalDateTime实例,因为新的日期/时间 API 比旧的java.util.Date更优越。不幸的是,MongoDB Java Driver 目前还不支持 Java 8,因此我们需要自己处理转换。我们将这些日期存储为字符串,并在需要时进行转换。这种转换是通过LocalDateTimeAdapter类来处理的:

    public class LocalDateTimeAdapter  
      extends XmlAdapter<String, LocalDateTime> { 
      private static final Pattern JS_DATE = Pattern.compile 
        ("\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z"); 
      private static final DateTimeFormatter DEFAULT_FORMAT =  
        DateTimeFormatter.ISO_LOCAL_DATE_TIME; 
      private static final DateTimeFormatter JS_FORMAT =  
        DateTimeFormatter.ofPattern 
        ("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); 

      @Override 
      public LocalDateTime unmarshal(String date) { 
        if (date == null) { 
          return null; 
        } 
        return LocalDateTime.parse(date,  
          (JS_DATE.matcher(date).matches()) 
          ? JS_FORMAT : DEFAULT_FORMAT); 
      } 

      @Override 
      public String marshal(LocalDateTime date) { 
        return date != null ? DEFAULT_FORMAT.format(date) : null; 
      } 
    } 

这可能比你预期的要复杂一些,这是因为它所做的不仅仅是我们在之前讨论过的。我们现在正在查看的使用,即从我们的模型类中,并不是这个类的首要目的,但我们会稍后讨论这一点。除了这一点,类的行为相当简单--接受一个String,确定它代表的是两种支持格式中的哪一种,并将其转换为LocalDateTime。它也可以反过来操作。

这个类的主要目的是为了 JAX-RS 的使用。当我们通过线传递Note实例时,LocalDateTime也需要进行反序列化,我们可以通过一个XmlAdapter告诉 JAX-RS 如何进行这一操作。

类定义好后,我们需要告诉 JAX-RS 关于它。我们可以通过几种不同的方式做到这一点。我们可以在模型中的每个属性上使用注解,如下所示:

    @XmlJavaTypeAdapter(value = LocalDateTimeAdapter.class) 
    private LocalDateTime created = LocalDateTime.now(); 

虽然这可行,但就这类事情而言,这是一个相当大的注解,你必须将它放在每个 LocalDateTime 属性上。如果你有多个具有此类字段的模型,你将不得不触摸每个属性。幸运的是,有一种方法可以将类型与适配器关联一次。我们可以在一个特殊的 Java 文件 package_info.java 中做到这一点。大多数人从未听说过这个文件,甚至更少的人使用它,但它只是一个用于包级文档和注解的地方。正是这个后者的用例引起了我们的兴趣。在我们的模型类包中创建 package_info.java 并将其放入其中:

    @XmlJavaTypeAdapters({ 
      @XmlJavaTypeAdapter(type = LocalDateTime.class,  
        value = LocalDateTimeAdapter.class) 
    }) 
    package com.steeplesoft.monumentum.model; 

我们有前面代码中看到的相同注解,但它被 @XmlJavaTypeAdapters 包装。JVM 只允许在元素上注解特定类型,因此这个包装器允许我们绕过这个限制。我们还需要在 @XmlJavaTypeAdapter 注解上指定类型参数,因为它不再在目标属性上。有了这个,每个 LocalDateTime 属性都将被正确处理,而无需任何额外的工作。

这需要相当多的设置,但我们仍然还没有完全准备好。我们在 REST 侧已经设置了一切。现在我们需要将 MongoDB 类放在适当的位置。要连接到 MongoDB 实例,我们从一个 MongoClient 开始。然后从 MongoClient 获取一个 MongoDatabase 的引用,从而得到一个 MongoCollection

    private MongoCollection<Document> collection; 
    private MongoClient mongoClient; 
    private MongoDatabase database; 

    @PostConstruct 
    public void postConstruct() { 
      String host = System.getProperty("mongo.host", "localhost"); 
      String port = System.getProperty("mongo.port", "27017"); 
      mongoClient = new MongoClient(host, Integer.parseInt(port)); 
      database = mongoClient.getDatabase("monumentum"); 
      collection = database.getCollection("note"); 
    } 

@PostConstruct 方法在构造函数运行之后在 Bean 上运行。在这个方法中,我们初始化我们的各种 MongoDB 类并将它们存储在实例变量中。有了这些类就绪,我们可以重新访问,例如,getAll()

    @GET 
    public Response getAll() { 
      List<Note> notes = new ArrayList<>(); 
      try (MongoCursor<Document> cursor = collection.find() 
      .iterator()) { 
        while (cursor.hasNext()) { 
          notes.add(new Note(cursor.next())); 
        } 
      } 

      return Response.ok( 
        new GenericEntity<List<Note>>(notes) {}) 
      .build(); 
    } 

现在我们可以查询数据库中的笔记,并且根据前面代码中展示的 createNote() 实现,我们可以创建以下笔记:

$ curl -v -H "Content-Type: application/json" -X POST -d '{"title":"Command line note", "body":"A note from the command line"}' http://localhost:8080/monumentum-1.0-SNAPSHOT/api/notes/ 
*   Trying ::1... 
* TCP_NODELAY set 
* Connected to localhost (::1) port 8080 (#0) 
> POST /monumentum-1.0-SNAPSHOT/api/notes/ HTTP/1.1 
... 
< HTTP/1.1 201 Created 
... 
$ curl http://localhost:8080/monumentum-1.0-SNAPSHOT/api/notes/ | jq . 
[ 
  { 
    "id": "58e5d0d79ccd032344f66c37", 
    "userId": null, 
    "title": "Command line note", 
    "body": "A note from the command line", 
    "created": "2017-04-06T00:23:34.87", 
    "modified": null 
  } 
] 

要在你的机器上使这可行,你需要一个正在运行的 MongoDB 实例。你可以下载适用于你的操作系统的安装程序,也可以在 MongoDB 网站上找到安装说明(docs.mongodb.com/manual/installation/)。

在我们继续探讨其他资源方法之前,让我们最后再看看我们的 MongoDB API 实例。虽然像我们这样实例化实例是可行的,但它也给资源本身带来了一定的工作量。理想情况下,我们应该能够将这些关注点移至其他地方并注入实例。希望这对你来说很熟悉,因为这正是依赖注入(DI)或控制反转(IoC)框架被创建来解决的问题。

使用 CDI 进行依赖注入

Java EE 提供了一个框架,如 CDI。使用 CDI,我们可以以编译时类型安全的方式将任何容器控制的对象注入到另一个对象中。然而,问题在于相关的对象需要是容器控制的,而我们的 MongoDB API 对象不是。幸运的是,CDI 提供了一种方法,容器可以通过这种方法创建这些实例,这种设施被称为生产者方法。这会是什么样子呢?让我们从注入点开始,因为这是最简单的部分:

    @Inject 
    @Collection("notes") 
    private MongoCollection<Document> collection; 

当 CDI 容器看到@Inject时,它会检查注解所在的元素以确定类型。然后它将尝试查找一个可以满足注入请求的实例。如果有多个,注入通常会失败。尽管我们使用了限定符注解来帮助 CDI 确定要注入的内容。这个注解的定义如下:

    @Qualifier  
    @Retention(RetentionPolicy.RUNTIME)  
    @Target({ElementType.METHOD, ElementType.FIELD,  
      ElementType.PARAMETER, ElementType.TYPE})   
    public @interface Collection { 
      @Nonbinding String value() default "unknown";   
    } 

使用这个注解,我们可以向容器传递提示,帮助它选择注入的实例。正如我们提到的,MongoCollection不是容器管理的,因此我们需要修复这个问题,我们通过以下生产者方法来完成:

    @RequestScoped 
    public class Producers { 
      @Produces 
      @Collection 
      public MongoCollection<Document>  
        getCollection(InjectionPoint injectionPoint) { 
          Collection mc = injectionPoint.getAnnotated() 
          .getAnnotation(Collection.class); 
        return getDatabase().getCollection(mc.value()); 
      } 
    } 

@Produces方法告诉 CDI 此方法将产生容器需要的实例。CDI 从方法签名中确定可注入实例的类型。我们还把限定符注解放在方法上,作为对运行时的额外提示,当它尝试解决我们的注入请求时。

在方法本身中,我们在方法签名中添加InjectionPoint。当 CDI 调用此方法时,它将提供一个此类实例,我们可以从中获取每个特定注入点的信息,在它们被处理时。从InjectionPoint,我们获取Collection实例,从中我们可以获取我们感兴趣的 MongoDB 集合的名称。我们现在已经准备好获取之前看到的MongoCollection实例。MongoClientMongoDatabase的实例化是在类内部处理的,并且与我们之前的用法没有显著变化。

对于 CDI 有一个小的设置步骤。为了避免 CDI 容器进行可能昂贵的类路径扫描,我们需要告诉系统我们希望启用 CDI,换句话说。为了做到这一点,我们需要一个beans.xml文件,它可以包含 CDI 配置元素,也可以完全为空,这就是我们将要做的。对于 Java EE Web 应用程序,beans.xml需要放在WEB-INF目录中,所以我们创建文件在src/main/webapp/WEB-INF

确保文件确实是空的。即使只有一行空白,Weld,Payara 的 CDI 实现,也会尝试解析文件,导致 XML 解析错误。

完成笔记资源

在我们可以从Note资源继续前进之前,我们需要完成一些操作,即读取、更新和删除。读取单个笔记非常简单:

    @GET 
    @Path("{id}") 
    public Response getNote(@PathParam("id") String id) { 
      Document doc = collection.find(buildQueryById(id)).first(); 
      if (doc == null) { 
        return Response.status(Response.Status.NOT_FOUND).build(); 
      } else { 
        return Response.ok(new Note(doc)).build(); 
      } 
    } 

我们已经指定了使用 HTTP 动词GET,正如我们之前看到的,但我们对这个方法有一个额外的注解,@Path。使用这个注解,我们告诉 JAX-RS 这个端点有额外的路径段,请求需要与之匹配。在这种情况下,我们指定了一个额外的段,但我们将其括在花括号中。如果没有这些括号,匹配将是字面匹配,也就是说,“这个 URL 的末尾是否有字符串'id'?”然而,通过使用花括号,我们告诉 JAX-RS 我们想要匹配额外的段,但它的内容可以是任何东西,我们想要捕获这个值并将其命名为id。在我们的方法签名中,我们指示 JAX-RS 通过@PathParam注解注入值,这样我们就可以在我们的方法中访问用户指定的Note ID。

从 MongoDB 检索笔记,我们第一次真正了解了如何查询 MongoDB:

    Document doc = collection.find(buildQueryById(id)).first(); 

简而言之,将BasicDBObject传递给collection上的find()方法,该方法返回一个FindIterable<?>对象,然后我们调用first()来获取应该返回的唯一元素(当然,假设有一个)。这里有趣的部分隐藏在buildQueryById()中:

    private BasicDBObject buildQueryById(String id) { 
      BasicDBObject query =  
        new BasicDBObject("_id", new ObjectId(id)); 
      return query; 
    } 

我们的查询过滤器使用这个BasicDBObject定义,我们用键和值初始化它。在这种情况下,我们想要根据文档中的_id字段进行过滤,所以我们使用它作为键,但请注意,我们传递的是ObjectId作为值,而不是简单的String。如果我们想要根据更多字段进行过滤,我们将向BasicDBObject变量追加更多的键/值对,我们将在后面看到。

一旦我们查询了集合并获取了用户请求的文档,我们就使用Note上的辅助方法将其从Document转换为Note,并以状态码 200 或OK返回它。

在数据库中更新文档稍微复杂一些,但并不过分,如下所示:

    @PUT 
    @Path("{id}") 
    public Response updateNote(Note note) { 
      note.setModified(LocalDateTime.now()); 
      UpdateResult result =  
        collection.updateOne(buildQueryById(note.getId()), 
        new Document("$set", note.toDocument())); 
      if (result.getModifiedCount() == 0) { 
        return Response.status(Response.Status.NOT_FOUND).build(); 
      } else { 
        return Response.ok().build(); 
      } 
    } 

首先要注意的是 HTTP 方法--PUT。关于更新应该使用什么动词有一些争议。有些人,如 Dropbox 和 Facebook,说POST,而其他人,如 Google(取决于你查看哪个 API),说PUT。我会争辩说,选择在很大程度上取决于你。只要保持一致性。我们将完全用客户端传递的内容替换服务器上的实体,因此操作是幂等的。通过选择PUT,我们可以向客户端发出这个信号,使 API 对客户端来说更具自描述性。

在方法内部,我们首先将修改日期设置为反映操作。接下来,我们调用Collection.updateOne()来修改文档。语法有点奇怪,但这是在发生的——我们在查询集合以获取我们想要修改的笔记,然后告诉 MongoDB 用我们提供的新的文档替换加载的文档。最后,我们查询UpdateResult以查看更新了多少个文档。如果没有,则表示请求的文档不存在,因此我们返回NOT_FOUND404)。如果它是非零的,我们返回OK200)。

最后,我们的删除方法看起来是这样的:

    @DELETE 
    @Path("{id}") 
    public Response deleteNote(@PathParam("id") String id) { 
      collection.deleteOne(buildQueryById(id)); 
      return Response.ok().build(); 
    } 

我们告诉 MongoDB 使用我们之前看到的相同查询过滤器来过滤集合,然后删除一个文档,当然,考虑到我们的过滤器,它应该只找到这一个,但deleteOne()是一个合理的防护措施。我们可以在updateNote()中做像上面那样的检查,看看是否真的有东西被删除了,但几乎没有意义——无论文档在请求开始时是否存在,它最终都不存在,这就是我们的目标,所以返回错误响应几乎没有收获。

我们现在可以创建、读取、更新和删除笔记,但那些细心的你可能已经注意到,任何人都可以读取系统中的每一篇笔记。对于多用户系统来说,这并不是一个好事情,所以让我们来修复这个问题。

添加身份验证

身份验证系统可以很容易地变得极其复杂。从自建系统,包括定制的用户管理屏幕,到复杂的单点登录解决方案,我们有大量的选择。其中更受欢迎的选项之一是 OAuth2,有许多选择。对于 Monumentum,我们将使用 Google 进行登录。为此,我们需要在 Google 开发者控制台中创建一个应用程序,该控制台位于console.developers.google.com

登录后,点击页面顶部的项目下拉菜单,然后点击创建项目,这将显示此屏幕给你:

图片

提供项目名称,然后为接下来的两个问题做出选择,然后点击创建。一旦项目创建完成,你应该会被重定向到库页面。点击左侧的凭据链接,然后点击创建凭据并选择 OAuth 客户端 ID。如有需要,按照指示填写 OAuth 同意屏幕。选择 Web 应用程序作为应用程序类型,输入名称,并提供如截图所示的授权重定向 URI:

图片

在此内容移至生产环境之前,我们需要向此屏幕添加一个生产 URI,但此配置在开发环境中运行良好。当你点击保存时,你会看到你的新客户端 ID 和客户端密钥。请注意这些信息:

图片

使用这些数据(注意,这些不是我的实际 ID 和密钥,所以您需要生成自己的),我们就可以开始处理我们的认证资源了。我们将首先定义资源如下:

    @Path("auth") 
    public class AuthenticationResource { 

我们需要在我们的Application中注册此操作,如下所示:

    @ApplicationPath("/api") 
    public class Monumentum extends javax.ws.rs.core.Application { 
      @Override 
      public Set<Class<?>> getClasses() { 
        Set<Class<?>> s = new HashSet<>(); 
        s.add(NoteResource.class); 
        s.add(AuthenticationResource.class); 
        return s; 
      } 
    } 

要与 Google OAuth 提供者一起工作,我们需要声明一些实例变量并实例化一些 Google API 类:

    private final String clientId; 
    private final String clientSecret; 
    private final GoogleAuthorizationCodeFlow flow; 
    private final HttpTransport HTTP_TRANSPORT =  
      new NetHttpTransport(); 
    private static final String USER_INFO_URL =  
      "https://www.googleapis.com/oauth2/v1/userinfo"; 
    private static final List<String> SCOPES = Arrays.asList( 
      "https://www.googleapis.com/auth/userinfo.profile", 
      "https://www.googleapis.com/auth/userinfo.email"); 

变量clientIdclientSecret将保存 Google 刚刚给出的值。其他两个类对于我们将要执行的过程是必要的,而SCOPES保存了我们希望从 Google 获得的权限,即访问用户的个人资料和电子邮件。类构造函数完成这些项的设置:

    public AuthenticationResource() { 
      clientId = System.getProperty("client_id"); 
      clientSecret = System.getProperty("client_secret"); 
      flow = new GoogleAuthorizationCodeFlow.Builder(HTTP_TRANSPORT, 
        new JacksonFactory(), clientId, clientSecret, 
        SCOPES).build(); 
    } 

认证流程的第一部分是创建一个认证 URL,其创建方式如下:

    @Context 
    private UriInfo uriInfo; 
    @GET 
    @Path("url") 
    public String getAuthorizationUrl() { 
      return flow.newAuthorizationUrl() 
      .setRedirectUri(getCallbackUri()).build(); 
    } 
    private String getCallbackUri()  
      throws UriBuilderException, IllegalArgumentException { 
      return uriInfo.getBaseUriBuilder().path("auth") 
        .path("callback").build() 
        .toASCIIString(); 
    } 

使用 JAX-RS 类UriInfo,我们创建一个指向我们应用程序中另一个端点的URI,即/api/auth/callback。然后我们将它传递给GoogleAuthorizationCodeFlow以完成登录 URL 的构建。当用户点击链接时,浏览器将被导向 Google 的登录对话框。在成功认证后,用户将被重定向到我们的回调 URL,该 URL 由此方法处理:

    @GET 
    @Path("callback") 
    public Response handleCallback(@QueryParam("code")  
    @NotNull String code) throws IOException { 
      User user = getUserInfoJson(code); 
      saveUserInformation(user); 
      final String jwt = createToken(user.getEmail()); 
      return Response.seeOther( 
        uriInfo.getBaseUriBuilder() 
        .path("../loginsuccess.html") 
        .queryParam("Bearer", jwt) 
        .build()) 
      .build(); 
    } 

当 Google 重定向到我们的callback端点时,它将提供一个代码,我们可以使用它来完成认证。我们在getUserInfoJson()方法中这样做:

    private User getUserInfoJson(final String authCode)  
    throws IOException { 
      try { 
        final GoogleTokenResponse response =  
          flow.newTokenRequest(authCode) 
          .setRedirectUri(getCallbackUri()) 
          .execute(); 
        final Credential credential =  
          flow.createAndStoreCredential(response, null); 
        final HttpRequest request =  
          HTTP_TRANSPORT.createRequestFactory(credential) 
          .buildGetRequest(new GenericUrl(USER_INFO_URL)); 
        request.getHeaders().setContentType("application/json"); 
        final JSONObject identity =  
          new JSONObject(request.execute().parseAsString()); 
        return new User( 
          identity.getString("id"), 
          identity.getString("email"), 
          identity.getString("name"), 
          identity.getString("picture")); 
      } catch (JSONException ex) { 
        Logger.getLogger(AuthenticationResource.class.getName()) 
        .log(Level.SEVERE, null, ex); 
        return null; 
      } 
    } 

使用我们从 Google 刚刚获得的认证代码,我们向 Google 发送另一个请求,这次是为了获取用户信息。当请求返回时,我们取响应体中的 JSON 对象,并使用它来构建一个User对象,然后返回。

在我们的 REST 端点方法中,我们调用此方法以在需要时将用户保存到数据库中:

    private void saveUserInformation(User user) { 
      Document doc = collection.find( 
        new BasicDBObject("email", user.getEmail())).first(); 
      if (doc == null) { 
        collection.insertOne(user.toDocument()); 
      } 
    } 

一旦我们从 Google 获得了用户信息,我们就不再需要代码,因为我们不需要与任何其他 Google 资源交互,所以我们不会将其持久化。

最后,我们希望向客户端返回一些东西——某种类型的令牌——可以用来证明客户端的身份。为此,我们将使用一种名为 JSON Web Token(JWT)的技术,简称 JWT。JWT 是一种基于 JSON 的开放标准(RFC 7519),用于创建断言一定数量声明的访问令牌。我们将使用用户的电子邮件地址创建一个 JWT。我们将使用只有服务器使用的密钥对其进行签名,这样我们就可以安全地将它传递给客户端,客户端将在每次请求时将其传递回来。由于它必须使用服务器密钥进行加密/签名,不可信的客户端将无法成功修改或伪造令牌。

要创建 JWT,我们需要将库添加到我们的项目中,如下所示:

    <dependency> 
      <groupId>io.jsonwebtoken</groupId> 
      <artifactId>jjwt</artifactId> 
      <version>0.7.0</version> 
    </dependency> 

我们可以编写此方法:

    @Inject 
    private KeyGenerator keyGenerator; 
    private String createToken(String login) { 
      String jwtToken = Jwts.builder() 
      .setSubject(login) 
      .setIssuer(uriInfo.getAbsolutePath().toString()) 
      .setIssuedAt(new Date()) 
      .setExpiration(Date.from( 
        LocalDateTime.now().plusHours(12L) 
      .atZone(ZoneId.systemDefault()).toInstant())) 
      .signWith(SignatureAlgorithm.HS512,  
        keyGenerator.getKey()) 
      .compact(); 
      return jwtToken; 
    } 

令牌的主题是电子邮件地址,我们 API 的基本地址是发行者,过期日期和时间是未来的 12 小时,令牌由我们使用新类KeyGenerator生成的密钥签名。当我们调用compact()时,会生成一个 URL 安全的字符串,我们将其返回给调用者。我们可以使用jwt.io上的 JWT 调试器来查看令牌内部:

图片

显然,令牌中的声明是可读的,所以不要在其中存储任何敏感信息。使这变得安全的是在签名令牌时使用秘密密钥,从理论上讲,没有检测到的情况下不可能更改其内容。

用于给我们签名密钥的KeyGenerator类看起来像这样:

    @Singleton 
    public class KeyGenerator { 
      private Key key; 

      public Key getKey() { 
        if (key == null) { 
          String keyString = System.getProperty("signing.key",  
            "replace for production"); 
          key = new SecretKeySpec(keyString.getBytes(), 0,  
            keyString.getBytes().length, "DES"); 
        } 

        return key; 
      } 
    } 

该类被注解为@Singleton,因此容器保证系统中只存在一个此 bean 的实例。getKey()方法将使用系统属性signing.key作为密钥,允许用户在启动系统时指定一个唯一的秘密。当然,完全随机的密钥更安全,但如果我们试图水平扩展此系统,这会增加一些复杂性。我们需要所有实例使用相同的签名密钥,以便无论客户端被导向哪个服务器,JWT 都可以被验证。在这种情况下,Hazelcast 这样的数据网格解决方案将是一个合适的工具。就目前而言,这已经足够满足我们的需求了。

我们的认证资源现在已经完成,但我们的系统实际上还没有被保护。为了做到这一点,我们需要告诉 JAX-RS 如何认证请求,我们将使用一个新的注解和ContainerRequestFilter来实现。

如果我们安装一个没有额外信息的请求过滤器,它将应用于每个资源,包括我们的认证资源。这意味着我们必须先进行认证才能进行认证。显然,这没有意义,因此我们需要一种方法来区分请求,以便只有对某些资源的请求应用此过滤器,这意味着需要一个新注解:

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

我们定义了一个语义上有意义的注解。@NameBinding注解告诉 JAX-RS 只将注解应用于某些资源,这些资源通过名称绑定(而不是在运行时动态绑定)。注解定义后,我们需要定义事物的另一面,即请求过滤器:

    @Provider 
    @Secure 
    @Priority(Priorities.AUTHENTICATION) 
    public class SecureFilter implements ContainerRequestFilter { 
      @Inject 
      private KeyGenerator keyGenerator; 

      @Override 
      public void filter(ContainerRequestContext requestContext)  
       throws IOException { 
        try { 
          String authorizationHeader = requestContext 
          .getHeaderString(HttpHeaders.AUTHORIZATION); 
          String token = authorizationHeader 
          .substring("Bearer".length()).trim(); 
          Jwts.parser() 
          .setSigningKey(keyGenerator.getKey()) 
          .parseClaimsJws(token); 
        } catch (Exception e) { 
          requestContext.abortWith(Response.status 
          (Response.Status.UNAUTHORIZED).build()); 
        } 
      } 
    } 

我们首先定义一个实现ContainerRequestFilter接口的类。我们必须用@Provider注解它,以便 JAX-RS 能够识别和加载该类。我们将@Secure注解应用于将过滤器与注解关联。我们将在稍后将其应用于资源。最后,我们应用@Priority注解来指示系统,此过滤器应在请求周期中较早应用。

在过滤器内部,我们注入了之前查看过的相同 KeyGenerator。由于这是一个单例,我们保证这里使用的密钥和认证方法中使用的密钥是相同的。接口上唯一的方法是 filter(),在这个方法中,我们从请求中获取授权头,提取出 JWT 的 Bearer 令牌,并使用 JWT API 进行验证。如果我们能够解码并验证令牌,那么我们知道用户已经成功对系统进行了认证。为了告诉系统这个新的过滤器,我们需要修改我们的 JAX-RS Application 如下:

    @ApplicationPath("/api") 
    public class Monumentum extends javax.ws.rs.core.Application { 
      @Override 
      public Set<Class<?>> getClasses() { 
        Set<Class<?>> s = new HashSet<>(); 
        s.add(NoteResource.class); 
        s.add(AuthenticationResource.class); 
        s.add(SecureFilter.class); 
        return s; 
      } 
    } 

系统现在知道了过滤器,但在它做任何事情之前,我们需要将其应用于我们想要保护的资源。我们通过将 @Secure 注解应用于适当的资源来实现这一点。它可以在类级别应用,这意味着类中的每个端点都将被保护,或者可以在资源方法级别应用,这意味着只有那些特定的端点将被保护。在我们的情况下,我们希望每个 Note 端点都被保护,所以将以下注解放在类上:

    @Path("/notes") 
    @RequestScoped 
    @Produces(MediaType.APPLICATION_JSON) 
    @Secure 
    public class NoteResource { 

只需再走几步,我们的应用程序就会变得安全。我们需要对 NoteResource 进行一些修改,以便它知道谁登录了,并且使笔记与认证用户相关联。我们将从注入 User 开始:

    @Inject 
    private User user; 

这显然不是一个容器管理的类,因此我们需要编写另一个 Producer 方法。那里有一些工作要做,所以我们将它封装在自己的类中:

    @RequestScoped 
    public class UserProducer { 
      @Inject 
      private KeyGenerator keyGenerator; 
      @Inject 
      HttpServletRequest req; 
      @Inject 
      @Collection("users") 
      private MongoCollection<Document> users; 

我们将其定义为请求范围的 CDI 实例,并注入我们的 KeyGeneratorHttpServletRequest 和我们的用户集合。实际的工作是在 Producer 方法中完成的:

    @Produces 
    public User getUser() { 
      String authHeader = req.getHeader(HttpHeaders.AUTHORIZATION); 
      if (authHeader != null && authHeader.contains("Bearer")) { 
        String token = authHeader 
        .substring("Bearer".length()).trim(); 
        Jws<Claims> parseClaimsJws = Jwts.parser() 
        .setSigningKey(keyGenerator.getKey()) 
        .parseClaimsJws(token); 
        return getUser(parseClaimsJws.getBody().getSubject()); 
      } else { 
        return null; 
      }  
    } 

使用 Servlet 请求,我们检索 AUTHORIZATION 头。如果它存在并且包含 Bearer 字符串,我们可以处理令牌。如果该条件不成立,我们返回 null。要处理令牌,我们从头中提取令牌值,然后让 Jwts 为我们解析声明,它返回一个类型为 Jws<Claims> 的对象。我们在 getUser() 方法中按照以下方式构建用户:

    private User getUser(String email) { 
      Document doc = users.find( 
        new BasicDBObject("email", email)).first(); 
      if (doc != null) { 
        return new User(doc); 
      } else { 
        return null; 
      } 
    } 

解析了声明后,我们可以提取主题并使用它来查询我们的 Users 集合,如果找到则返回 User,如果没有找到则返回 null

回到我们的 NoteResource,我们需要修改我们的资源方法以使其 User-aware

    public Response getAll() { 
      List<Note> notes = new ArrayList<>(); 
      try (MongoCursor<Document> cursor =  
        collection.find(new BasicDBObject("user_id",  
        user.getId())).iterator()) { 
      // ... 
      @POST 
      public Response createNote(Note note) { 
        Document doc = note.toDocument(); 
        doc.append("user_id", user.getId()); 
        // ... 
      @PUT 
      @Path("{id}") 
      public Response updateNote(Note note) { 
        note.setModified(LocalDateTime.now()); 
        note.setUser(user.getId()); 
        // ... 
      private BasicDBObject buildQueryById(String id) { 
        BasicDBObject query =  
        new BasicDBObject("_id", new ObjectId(id)) 
         .append("user_id", user.getId()); 
        return query; 
    } 

现在我们有一个完整且安全的 REST API。除了像 curl 这样的命令行工具外,我们没有其他很好的方式来使用它,所以让我们构建一个用户界面。

构建用户界面

对于 UI,我们有多种选择。在这本书中,我们已经探讨了 JavaFX 和 NetBeans RCP。虽然这些是很好的选择,但我们将为这个应用做一些不同的处理,并构建一个基于 Web 的界面。即使在这里,我们也有许多、许多选项:JSF、Spring MVC、Google Web Toolkit、Vaadin 等等。在现实世界的应用中,虽然我们可能有 Java 后端,但我们可能有 JavaScript 前端,所以这就是我们将要做的,这也是你的选择变得非常令人眼花缭乱的地方。

在本书撰写时,该市场的两大主要玩家是来自 Facebook 的 React 和来自 Google 的 Angular。还有一些较小的竞争者,例如与 React API 兼容的 Preact、VueJS、Backbone、Ember 等等。你的选择将对应用产生重大影响,从架构到更琐碎的事情,比如构建项目本身,或者如果你有强烈的特定架构需求,你也可以让架构驱动框架。一如既往,你的特定环境将有所不同,并且应该比你在书中或在线上读到的内容更能驱动这个决定。

由于这是一本 Java 书,并且我想避免深入探讨 JavaScript 构建系统和替代JavaScript 虚拟机语言、转译等亲密细节,我选择使用 Vue,因为它是一个快速、现代且流行的框架,满足我们的需求,同时仍然允许我们构建一个简单的系统,无需复杂的构建配置。如果你有使用其他框架的经验或偏好,你应该能够相当简单地使用你选择的框架构建一个类似的系统。

注意,我不是一个 JavaScript 开发者。在本章的这一部分,我们将构建的应用不应被视为最佳实践的例子。这仅仅是一个尝试构建一个可用、尽管简单的 JavaScript 前端,以展示全栈应用。请查阅 Vue 或你选择的框架的文档,了解如何使用该工具构建惯用应用的相关细节。

让我们从索引页开始。在 NetBeans 的项目资源管理器窗口中,展开“其他源”节点,右键单击“webapp”节点,选择“新建 | 空文件”,将其命名为index.html。目前文件中我们需要的最基本内容如下:

    <!DOCTYPE html> 
      <html> 
        <head> 
          <title>Monumentum</title> 
          <meta charset="UTF-8"> 
          <link rel="stylesheet" href="monumentum.css"> 
          <script src="img/vue"></script> 
        </head> 
        <body> 
          <div id="app"> 
            {{ message }} 
          </div> 
          <script type="text/javascript" src="img/index.js"></script> 
        </body> 
      </html> 

目前这会显示一个空白页,但它确实导入了 Vue 的源代码,以及我们客户端应用的 JavaScript,即index.js,我们需要创建它:

    var vm = new Vue({ 
      el: '#app', 
      data: { 
        message : 'Hello, World!' 
      } 
    }); 

如果我们部署这些更改(提示:如果应用已经运行,只需按F11键告诉 NetBeans 构建;这不会使任何 Java 更改生效,但它会将这些静态资源复制到输出目录),并在浏览器中刷新页面,我们现在应该能在页面上看到Hello, World!

简而言之,所发生的事情是我们正在创建一个新的 Vue 对象,将其锚定到具有 app ID 的 (el) 元素。我们还在定义此组件的一些状态(data),其中包括单个属性 message。在页面上,app 元素内部的任何位置,我们可以使用我们在首页中看到的 Mustache 语法访问组件的状态--{{ message }}。让我们扩展一下我们的组件:

    var vm = new Vue({ 
      el: '#app', 
      store, 
      computed: { 
        isLoggedIn() { 
          return this.$store.state.loggedIn; 
        } 
      }, 
      created: function () { 
        NotesActions.fetchNotes(); 
      } 
    }); 

我们在这里添加了三项:

  • 我们引入了一个全局数据存储,恰当地命名为 store

  • 我们添加了一个名为 isLoggedIn 的新属性,它从方法调用中获取其值

  • 我们添加了一个生命周期方法 created,当组件在页面上创建时,将从服务器加载 Note

我们的数据存储基于 Vuex,这是一个为 Vue.js 应用程序提供状态管理模式和库。它作为应用程序中所有组件的集中存储,有规则确保状态只能以可预测的方式变异。(vuex.vuejs.org)。要将它添加到我们的应用程序中,我们需要在我们的页面中添加以下代码行:

    <script src="img/vuex"></script>

然后,我们在组件中添加了一个名为 store 的字段,您可以在前面的代码中看到。到目前为止的大部分工作都在 NotesActions 对象中完成:

    var NotesActions = { 
      buildAuthHeader: function () { 
        return new Headers({ 
          'Content-Type': 'application/json', 
          'Authorization': 'Bearer ' +    
          NotesActions.getCookie('Bearer') 
        }); 
      }, 
      fetchNotes: function () { 
        fetch('api/notes', { 
          headers: this.buildAuthHeader() 
        }) 
        .then(function (response) { 
          store.state.loggedIn = response.status === 200; 
          if (response.ok) { 
            return response.json(); 
          } 
        }) 
        .then(function (notes) { 
          store.commit('setNotes', notes); 
        }); 
      } 
    } 

当页面加载时,应用程序将立即向后端发送 Notes 的请求,如果有的话,在 Authorization 头部发送 bearer 令牌。当响应返回时,我们更新商店中 isLoggedIn 属性的状态,如果请求成功,我们更新页面上的 Notes 列表。请注意,我们正在使用 fetch()。这是在浏览器中发送 XHR 或 Ajax 请求的新实验性 API。截至本书编写时,它被所有主要浏览器支持,除了 Internet Explorer,所以如果您不能控制客户端的浏览器,请小心在生产应用程序中使用此功能。

我们已经看到商店被使用了几次,所以让我们来看看它:

    const store = new Vuex.Store({ 
      state: { 
        notes: [], 
        loggedIn: false, 
        currentIndex: -1, 
        currentNote: NotesActions.newNote() 
      } 
    }; 

该商店的类型为 Vuex.Store,我们在其 state 属性中指定了各种状态字段。如果处理得当,任何绑定到这些状态字段之一的 Vue 组件都会自动为您更新。您不需要跟踪和管理状态,手动在页面反映应用状态的变化。Vue 和 Vuex 会为您处理这些。大多数情况下是这样的。有些情况,例如数组变异(或替换),需要一些特殊处理。Vuex 提供了 mutations 来帮助处理这种情况。例如,NotesAction.fetchNotes() 在请求成功后,我们将执行以下调用:

     store.commit('setNotes', notes); 

前面的代码告诉商店提交一个名为 setNotes 的变异,其中 notes 作为负载。我们像这样定义变异:

    mutations: { 
      setNotes(state, notes) { 
        state.notes = []; 
        if (notes) { 
          notes.forEach(i => { 
            state.notes.push({ 
              id: i.id, 
              title: i.title, 
              body: i.body, 
              created: new Date(i.created), 
              modified: new Date(i.modified) 
            }); 
        }); 
      } 
    } 

我们传递给这个突变的(如果你这样想有助于理解,可以将其视为具有奇特调用语法的函数或方法)是一个 JSON 数组(希望这里没有类型检查),所以我们首先清除当前的笔记列表,然后迭代这个数组,创建和存储新的对象,并在这样做的同时重新格式化一些数据。严格地仅使用这个突变来替换笔记集,我们可以保证用户界面与应用程序状态的变化保持同步,这一切都是免费的。

那么,这些笔记是如何显示的呢?为了做到这一点,我们定义一个新的 Vue 组件并将其添加到页面上,如下所示:

    <div id="app"> 
      <note-list v-bind:notes="notes" v-if="isLoggedIn"></note-list> 
    </div> 

在这里,我们引用了一个名为 note-list 的新组件。我们将模板变量 notes 绑定到同名应用程序变量上,并指定只有当用户登录时才显示该组件。实际的组件定义发生在 JavaScript 中。回到 index.js,我们有如下代码:

    Vue.component('note-list', { 
      template: '#note-list-template', 
      store, 
      computed: { 
        notes() { 
          return this.$store.state.notes; 
        }, 
        isLoggedIn() { 
          return this.$store.state.loggedIn; 
        } 
      }, 
      methods: { 
        loadNote: function (index) { 
          this.$store.commit('noteClicked', index); 
        }, 
        deleteNote: function (index) { 
          if (confirm 
            ("Are you sure want to delete this note?")) { 
              NotesActions.deleteNote(index); 
            } 
        } 
      } 
    }); 

这个组件被命名为 note-list;其模板位于具有 note-list-template ID 的元素中;它有两个计算值:notesisLoggedIn;并且提供了两个方法。在一个典型的 Vue 应用程序中,我们会有许多文件,所有这些文件最终都会使用类似 Grunt 或 Gulp 之类的工具一起编译,其中一个文件就是我们的组件模板。由于我们试图通过避免 JS 构建过程来使这一切尽可能简单,所以我们直接在我们的页面上声明了一切。在 index.html 中,我们可以找到我们组件的模板:

    <script type="text/x-template" id="note-list-template"> 
      <div class="note-list"> 
        <h2>Notes:</h2> 
        <ul> 
          <div class="note-list"  
            v-for="(note,index) in notes" :key="note.id"> 
          <span : 
             v-on:click="loadNote(index,note);"> 
          {{ note.title }} 
          </span> 
            <a v-on:click="deleteNote(index, note);"> 
              <img src="img/x-225x225.png" height="20"  
                 width="20" alt="delete"> 
            </a> 
          </div> 
        </ul> 
        <hr> 
      </div>  
    </script> 

使用具有 text/x-template 类型的 script 标签,我们可以在不将其渲染到页面上时将模板添加到 DOM 中。在这个模板中,有趣的部分是具有 note-list 类的 div 标签。我们在这个标签上有一个 v- 属性,这意味着 Vue 模板处理器将使用这个 div 作为模板来迭代 notes 列表,并显示数组中的每个 note

每个笔记都将使用 span 标签进行渲染。通过使用模板标记 :title,我们能够使用我们的应用程序状态创建一个标题标签的值(我们无法说因为字符串插值在 Vue 2.0 中已被弃用)。span 标签的唯一子元素是 {{ note.title }} 表达式,它将 note 列表的标题作为字符串渲染。当用户在页面上点击笔记标题时,我们希望对此做出反应,因此我们将 onClick 处理程序绑定到 DOM 元素上,通过 v-on:click。这里引用的函数是我们定义在组件定义的 methods 块中的 loadNote() 函数。

loadNote() 函数调用了一个我们尚未查看的突变:

    noteClicked(state, index) { 
      state.currentIndex = index; 
      state.currentNote = state.notes[index]; 
      bus.$emit('note-clicked', state.currentNote); 
    } 

这种突变修改了状态以反映用户点击的笔记,然后触发(或发出)一个名为 note-clicked 的事件。事件系统实际上非常简单。它被设置为如下:

    var bus = new Vue(); 

就是这样。这只是一个基础、全局范围的 Vue 组件。我们通过调用 bus.$emit() 方法来触发事件,并通过调用 bus.$on() 方法来注册事件监听器。我们将在笔记形式中看到它的样子。

我们将像添加 note-list 组件一样将笔记表单组件添加到页面:

    <div id="app"> 
      <note-list v-bind:notes="notes" v-if="isLoggedIn"></note-list> 
      <note-form v-if="isLoggedIn"></note-form> 
    </div> 

再次强调,组件是在 index.js 中定义的,如下所示:

    Vue.component('note-form', { 
      template: '#note-form-template', 
      store, 
      data: function () { 
        return { 
          note: NotesActions.newNote() 
        }; 
      }, 
      mounted: function () { 
        var self = this; 
        bus.$on('add-clicked', function () { 
          self.$store.currentNote = NotesActions.newNote(); 
          self.clearForm(); 
        }); 
        bus.$on('note-clicked', function (note) { 
          self.updateForm(note); 
        }); 
        CKEDITOR.replace('notebody'); 
      } 
    }); 

模板也位于 index.html 中,如下所示:

    <script type="text/x-template" id="note-form-template"> 
      <div class="note-form"> 
        <h2>{{ note.title }}</h2> 
        <form> 
          <input id="noteid" type="hidden"  
            v-model="note.id"></input> 
          <input id="notedate" type="hidden"  
            v-model="note.created"></input> 
          <input id="notetitle" type="text" size="50"  
            v-model="note.title"></input> 
          <br/> 
          <textarea id="notebody"  
            style="width: 100%; height: 100%"  
            v-model="note.body"></textarea> 
          <br> 
          <button type="button" v-on:click="save">Save</button> 
        </form> 
      </div> 
    </script> 

这基本上是一个正常的 HTML 表单。有趣的部分是 v-model 将表单元素绑定到组件的属性。在表单上所做的更改将自动反映在组件中,而在组件中(例如,通过事件处理器)所做的更改将自动反映在 UI 中。我们还通过熟悉的 v-on:click 属性附加了一个 onClick 处理器。

你注意到了我们组件定义中关于 CKEDITOR 的引用吗?我们将使用富文本编辑器 CKEditor 来提供更好的体验。我们可以去 CKEditor 下载发行版包,但我们有更好的方法--WebJars。WebJars 项目将流行的客户端 Web 库打包成 JAR 文件。这使得将支持的库添加到项目中变得非常简单:

    <dependency> 
      <groupId>org.webjars</groupId> 
      <artifactId>ckeditor</artifactId> 
      <version>4.6.2</version> 
    </dependency> 

当我们打包应用程序时,这个二进制 jar 文件会被添加到 Web 归档中。然而,如果它仍然被归档,我们如何访问资源呢?这取决于你正在构建的应用程序类型,有多种选择。我们将利用 Servlet 3 的静态资源处理功能(任何打包在 Web 应用程序的 lib 目录下的 META-INF/resources 下的内容都会自动暴露)。在 index.html 中,我们通过以下简单行将 CKEditor 添加到页面:

    <script type="text/javascript"
      src="img/ckeditor.js"></script>

CKEditor 现在已准备好使用。

最后一个主要的前端功能是允许用户登录。为此,我们将创建另一个组件,如下所示:

    <div id="app"> 
      <navbar></navbar> 
      <note-list v-bind:notes="notes" v-if="isLoggedIn"></note-list> 
      <note-form v-if="isLoggedIn"></note-form> 
    </div> 

然后,我们将添加以下组件定义:

    Vue.component('navbar', { 
      template: '#navbar-template', 
      store, 
      data: function () { 
        return { 
          authUrl: "#" 
        }; 
      }, 
      methods: { 
        getAuthUrl: function () { 
          var self = this; 
          fetch('api/auth/url') 
          .then(function (response) { 
            return response.text(); 
          }) 
          .then(function (url) { 
            self.authUrl = url; 
          }); 
        } 
      }, 
      mounted: function () { 
        this.getAuthUrl(); 
      } 
    }); 

最后,我们将按照以下方式添加模板:

    <script type="text/x-template" id="navbar-template"> 
      <div id="nav" style="grid-column: 1/span 2; grid-row: 1 / 1;"> 
        <a v-on:click="add" style="padding-right: 10px;"> 
          <img src="img/plus-225x225.png" height="20"  
            width="20" alt="add"> 
        </a> 
        <a v-on:click="logout" v-if="isLoggedIn">Logout</a> 
        <a v-if="!isLoggedIn" :href="authUrl"  
         style="text-decoration: none">Login</a> 
      </div> 
    </script> 

当这个组件被 mounted(或附加到 DOM 中的元素)时,我们调用 getAuthUrl() 函数,该函数向服务器发送 Ajax 请求以获取我们的 Google 登录 URL。一旦获取到,登录锚标签将被更新以引用该 URL。

在 JavaScript 文件中还有一些我们没有在这里明确覆盖的细节,但有兴趣的人可以查看存储库中的源代码,并阅读剩余的细节。我们确实有一个用于笔记应用的运行中的 JavaScript 前端,它支持列出、创建、更新和删除笔记,以及支持多用户。它不是一个很漂亮的应用程序,但它能工作。对于一个 Java 程序员来说,这已经很不错了!

摘要

现在我们回到了熟悉的旋律——我们的应用程序完成了。在这一章中我们涵盖了什么内容?我们使用 JAX-RS 创建了一个 REST API,它不需要直接操作 JSON。我们学习了如何将请求过滤器应用于 JAX-RS 端点,以限制对认证用户的访问,我们使用 Google 的 OAuth2 工作流程通过他们的 Google 账户进行认证。我们使用 Payara Micro 打包了应用程序,这是一个开发微服务的优秀选择,并且我们使用 MongoDB Java API 将 MongoDB 集成到我们的应用程序中。最后,我们使用 Vue.js 构建了一个非常基本的 JavaScript 客户端来访问我们的应用程序。

在这个应用程序中,有很多新的概念和技术在相互作用,从技术角度来看,这使得它很有趣,但还有更多可以做的事情。应用程序需要大量的样式设计,并且支持嵌入图片和视频会很好,同样需要一个移动客户端。在应用程序中,有大量的改进和增强空间,但感兴趣的各方有一个坚实的基础可以从中开始。尽管如此,对我们来说,现在是时候转向下一章和新项目了,我们将跳入以服务为函数的云计算世界。

第二十八章:无服务器 Java

近年来,微服务概念已经席卷整个行业,迅速取代了经过实战考验的应用程序服务器,取而代之的是更小、更精简的解决方案。紧随微服务之后的是一个新的概念——函数即服务,更常见的是称为无服务器。在本章中,你将了解更多关于这种新的部署模型,并构建一个应用程序来演示如何使用它。

应用程序将是一个简单的通知系统,使用以下技术:

  • 亚马逊网络服务

    • 亚马逊 Lambda

    • 亚马逊身份和访问管理IAM

    • 亚马逊简单通知系统SNS

    • 亚马逊简单电子邮件系统SES

    • 亚马逊 DynamoDB

  • JavaFX

  • 云提供商提供的选项可能非常广泛,亚马逊网络服务也不例外。在本章中,我们将尝试使用 AWS 提供的一些基本服务来帮助我们构建一个引人注目的应用程序,同时我们涉足云原生应用程序开发。

开始使用

在我们进入应用程序之前,我们应该花些时间更好地理解术语函数即服务FaaS)。这个术语本身是我们已经看到几年的“即服务”趋势的延续。有一系列这样的术语和产品,但最大的三个是基础设施即服务IaaS)、平台即服务PaaS)和软件即服务SaaS)。通常,这三个是相互依赖的,如下面的图所示:

图片

云计算服务最低级别的基础设施即服务提供商,提供中的基础设施相关资产。通常,这可以简单到文件存储,但通常意味着虚拟机。通过使用基础设施即服务提供商,客户无需担心购买、维护或更换硬件,因为这由提供商处理。相反,客户只需根据使用的资源付费。

在堆栈中向上移动,平台即服务提供商提供云托管的应用程序执行环境。这可能包括应用程序服务器、数据库服务器、Web 服务器等。物理环境的细节被抽象化,客户指定存储和 RAM 需求。一些提供商还允许客户选择操作系统,因为这可能会影响应用程序堆栈、支持工具等。

软件即服务是一种更高层次的抽象,它根本不关注硬件,而是提供客户订阅的托管软件,通常是按用户订阅,通常是按月或按年订阅。这在复杂的商业软件中很常见,如财务系统或人力资源应用,但也见于更简单的系统,如博客软件。用户只需订阅并使用软件,将安装和维护(包括升级)留给提供商。虽然这可能会减少用户的灵活性(例如,通常无法自定义软件),但它也将维护成本推给了提供商,并在大多数情况下保证了访问软件的最新版本。

这类服务还有几种其他变体,例如移动后端即服务MBaas)和数据库即服务DBaaS)。随着市场对云计算的信心不断增强,以及互联网速度加快而价格下降,我们可能会看到越来越多这类系统的开发,这把我们带到了本章的主题。

函数即服务,或称为无服务器计算,是将一小段代码(非常直接地说,就是一个函数)部署到其他应用程序中,通常通过某种触发器来调用。用例包括图像转换、日志分析,以及我们将在本章中构建的通知系统。

尽管无服务器这个名字暗示了没有服务器,但实际上确实涉及到了服务器,这是合情合理的;然而,作为应用开发者,你不需要深入思考服务器。实际上,正如我们将在本章中看到的,我们唯一需要担心的是我们的函数需要多少内存。关于服务器的其他一切都将完全由函数即服务提供商处理——操作系统、存储、网络,甚至虚拟机的启动和停止都是由提供商为我们处理的。

对于无服务器的基本理解,我们需要选择一个提供商。正如预期的那样,有众多选择——亚马逊、甲骨文、IBM、红帽等等。不幸的是,目前还没有一种标准化的方法,我们可以用它来编写一个无服务器系统并将其部署到任意提供商,这意味着我们的解决方案将不可避免地与特定的提供商绑定,这个提供商将是亚马逊网络服务AWS),云计算服务的主要提供商。正如本章引言中提到的,我们使用了许多 AWS 服务,但核心将是 AWS Lambda,亚马逊的无服务器计算服务。

让我们开始吧。

应用程序规划

我们将要构建的应用程序是一个非常简单的 云通知 服务。简而言之,我们的函数将 监听 消息,然后将这些消息转发到系统中注册的电子邮件地址和电话号码。虽然我们的系统可能有些牵强,而且肯定非常简单,但希望更实际的使用案例是清晰的:

  • 我们的系统会提醒学生和/或家长有关即将发生的事件

  • 当孩子进入或离开某些地理边界时,家长会收到通知

  • 当某些事件发生时,系统管理员会收到通知

可能性相当广泛。就我们的目的而言,我们将开发不仅基于云的系统,还包括一个简单的桌面应用程序来模拟这些类型的场景。我们将从有趣的地方开始:在云中。

构建你的第一个函数

作为一项服务(Functions as a Service)的核心当然是函数。在亚马逊网络服务中,这些函数是通过 AWS Lambda 服务部署的。这并不是我们唯一会使用的 AWS 功能,因为我们已经提到过。一旦我们有了函数,我们需要一种执行它的方式。这是通过一个或多个触发器完成的,函数本身也有需要执行的任务,因此当我们最终编写函数时,我们将通过 API 调用来演示更多服务使用。

由于我们的应用程序的结构与我们之前看到的任何其他应用程序都显著不同,此时查看系统图可能会有所帮助:

图片

下面是大致流程:

  • 一条消息被发布到简单通知系统中的一个主题

  • 一旦验证了调用者的权限,消息就会被投递

  • 在消息投递后,会触发一个事件,将消息从主题传递到我们的函数

  • 在函数内部,我们将查询亚马逊的 DynamoDB 来获取已注册的接收者列表,这些接收者提供了电子邮件地址、手机号码或两者都有

  • 所有手机号码将通过 简单通知系统 发送短信

  • 所有电子邮件地址将通过 简单电子邮件服务 发送电子邮件

要开始构建函数,我们需要创建一个 Java 项目。像我们的大多数其他项目一样,这将是一个多模块 Maven 项目。在 NetBeans 中,点击文件 | 新建项目 | Maven | POM 项目。我们将命名 CloudNotice 项目。

该项目将包含三个模块——一个用于函数,一个用于测试/演示客户端,还有一个用于共享 API。要创建函数模块,在项目资源管理器中的 Modules 节点处右键单击,然后选择创建新模块。在窗口中,选择 Maven | Java 应用程序,点击下一步,并将项目名称设置为 function。重复这些步骤,创建一个名为 api 的模块。

在我们继续之前,我们必须指出,在撰写本文时,AWS 不支持 Java 9。因此,我们必须针对 Java 8(或更早版本)进行任何将发送到 Lambda 的内容。为此,我们需要像这样修改我们的 pom.xml 文件:

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

修改 apifunction 的 POM 文件。希望 AWS 能够在 Java 9 发布后尽快支持它。在此之前,我们只能以 JDK 8 为目标。

在我们的项目配置完成后,我们就可以编写我们的函数了。AWS Lambdas 是作为 RequestHandler 实例实现的:

    public class SnsEventHandler  
      implements RequestHandler<SNSEvent, Object> { 
        @Override 
        public Object handleRequest 
         (SNSEvent request, Context context) { 
           LambdaLogger logger = context.getLogger(); 
           final String message = request.getRecords().get(0) 
            .getSNS().getMessage(); 
           logger.log("Handle message '" + message + "'"); 
           return null; 
    } 

最终,我们希望我们的函数在消息被发送到 SNS 主题时被触发,因此我们将 SNSEvent 指定为输入类型。我们还指定了 Context。我们可以从 Context 中获取几件事情,例如请求 ID、内存限制等,但我们感兴趣的是获取一个 LambdaLogger 实例。我们只需将内容写入标准输出和标准错误,这些消息就会被保存在 Amazon CloudWatch 中,但 LambdaLogger 允许我们尊重系统权限和容器配置。

为了使这个编译通过,我们需要向我们的应用程序添加一些依赖项,因此我们在 pom.xml 中添加以下行:

    <properties> 
      <aws.java.sdk.version>1.11, 2.0.0)</aws.java.sdk.version> 
    </properties> 
    <dependencies> 
      <dependency> 
        <groupId>com.amazonaws</groupId> 
        <artifactId>aws-java-sdk-sns</artifactId> 
        <version>${aws.java.sdk.version}</version> 
      </dependency> 
      <dependency> 
        <groupId>com.amazonaws</groupId> 
        <artifactId>aws-lambda-java-core</artifactId> 
        <version>1.1.0</version> 
      </dependency> 
      <dependency> 
        <groupId>com.amazonaws</groupId> 
        <artifactId>aws-lambda-java-events</artifactId> 
        <version>1.3.0</version> 
      </dependency> 
    </dependencies> 

我们现在可以开始实现这个方法,如下所示:

    final List<Recipient> recipients =  new CloudNoticeDAO(false) 
      .getRecipients(); 
    final List<String> emailAddresses = recipients.stream() 
      .filter(r -> "email".equalsIgnoreCase(r.getType())) 
      .map(r -> r.getAddress()) 
      .collect(Collectors.toList()); 
    final List<String> phoneNumbers = recipients.stream() 
      .filter(r -> "sms".equalsIgnoreCase(r.getType())) 
      .map(r -> r.getAddress()) 
      .collect(Collectors.toList()); 

我们有一些新的类需要查看,但首先让我们回顾一下这段代码,我们将获取一个 Recipient 实例的列表,它代表了已订阅我们服务的号码和电子邮件地址。然后我们从这个列表中创建一个流,过滤每个接收者类型,SMSEmail,通过 map() 提取值,然后收集到一个 List 中。

我们将在稍后讨论 CloudNoticeDAORecipient,但让我们先完成我们的函数。一旦我们有了列表,我们就可以按照以下方式发送消息:

    final SesClient sesClient = new SesClient(); 
    final SnsClient snsClient = new SnsClient(); 

    sesClient.sendEmails(emailAddresses, "j9bp@steeplesoft.com", 
     "Cloud Notification", message); 
    snsClient.sendTextMessages(phoneNumbers, message); 
    sesClient.shutdown(); 
    snsClient.shutdown(); 

我们在我们的客户端类 SesClientSnsClient 后面封装了另外两个 AWS API。这看起来可能有点过度,但这些类型的东西往往会增长,而且这种做法使我们能够很好地管理这一点。

这样我们就有了三个 API 需要查看:DynamoDB、Simple Email Service 和 Simple Notification Service。我们将按顺序进行。

DynamoDB

Amazon DynamoDB 是一个 NoSQL 数据库,非常类似于我们在第二十五章 [Taking Notes with Monumentum 中查看的 MongoDB,尽管 DynamDB 支持文档和键值存储模型。对这两种模型的详细比较以及关于选择哪一种的建议超出了我们工作的范围。我们在这里选择了 DynamoDB,因为它已经在 Amazon Web Service 中配置好了,因此很容易为我们应用程序进行配置。

要开始使用 DynamoDB API,我们需要向我们的应用程序添加一些依赖项。在 api 模块中,将以下内容添加到 pom.xml 文件中:

    <properties> 
      <sqlite4java.version>1.0.392</sqlite4java.version> 
    </properties> 
    <dependency> 
      <groupId>com.amazonaws</groupId> 
      <artifactId>aws-java-sdk-dynamodb</artifactId> 
      <version>${aws.java.sdk.version}</version> 
    </dependency> 
    <dependency> 
      <groupId>com.amazonaws</groupId> 
      <artifactId>DynamoDBLocal</artifactId> 
      <version>${aws.java.sdk.version}</version> 
      <optional>true</optional> 
    </dependency> 
    <dependency> 
      <groupId>com.almworks.sqlite4java</groupId> 
      <artifactId>sqlite4java</artifactId> 
      <version>${sqlite4java.version}</version> 
      <optional>true</optional> 
    </dependency> 

在我们开始编写 DAO 类之前,让我们定义我们的简单模型。DynamoDB API 提供了一个对象关系映射功能,类似于 Java Persistence API 或 Hibernate,正如我们在这里看到的,它需要一个 POJO 和一些注解:

    public class Recipient { 
      private String id; 
      private String type = "SMS"; 
      private String address = ""; 

      // Constructors... 

      @DynamoDBHashKey(attributeName = "_id") 
      public String getId() { 
        return id; 
      } 

      @DynamoDBAttribute(attributeName = "type") 
      public String getType() { 
        return type; 
      } 

      @DynamoDBAttribute(attributeName="address") 
      public String getAddress() { 
        return address; 
      } 
      // Setters omitted to save space 
    } 

在我们的 POJO 中,我们声明了三个属性,idtypeaddress,然后使用 @DyanoDBAttribute 注解了 getters,以帮助库理解如何映射对象。

注意,尽管大多数属性名与表中的字段名匹配,但你也可以像我们对 id 所做的那样覆盖属性到字段名的映射。

在我们能够对我们的数据进行任何操作之前,我们需要声明我们的表。记住,DynamoDB 是一个 NoSQL 数据库,我们将像使用 MongoDB 一样将其用作文档存储。然而,在我们能够存储任何数据之前,我们必须定义在哪里放置它。在 MongoDB 中,我们会创建一个集合。但是,DynamoDB 仍然将其称为表,尽管它在技术上是无模式的,我们仍然需要定义一个主键,它由分区键和一个可选的排序键组成。

我们通过控制台创建表。一旦你登录到 AWS DynamoDB 控制台,你会点击创建表按钮,这将带你到一个类似这样的屏幕:

图片

我们将命名我们的表为 recipients,并指定 _id 作为分区键。点击创建表按钮,并给 AWS 时间来创建表。

我们现在可以开始编写我们的 DAO 了。在 API 模块中,创建一个名为 CloudNoticeDAO 的类,我们将向其中添加这个构造函数:

    protected final AmazonDynamoDB ddb; 
    protected final DynamoDBMapper mapper; 
    public CloudNoticeDAO(boolean local) { 
      ddb = local ? DynamoDBEmbedded.create().amazonDynamoDB() 
       : AmazonDynamoDBClientBuilder.defaultClient(); 
      verifyTables(); 
      mapper = new DynamoDBMapper(ddb); 
    } 

本地属性用于确定是否使用本地 DynamoDB 实例。这是为了支持测试(就像对 verifyTables() 的调用一样),我们将在稍后探讨。在生产中,我们的代码将调用 AmazonDynamoDBClientBuilder.defaultClient() 来获取 AmazonDynamoDB 的一个实例,它与 Amazon 托管的实例进行通信。最后,我们创建一个 DynamoDBMapper 的实例,我们将使用它进行对象映射。

为了便于创建一个新的 Recipient,我们将添加这个方法:

    public void saveRecipient(Recipient recip) { 
      if (recip.getId() == null) { 
        recip.setId(UUID.randomUUID().toString()); 
      } 
      mapper.save(recip); 
    } 

此方法将创建数据库中的新条目,或者如果主键已经存在,则更新现有条目。在某些场景中,可能有必要有单独的保存和更新方法,但我们的用例非常简单,所以我们不需要担心这一点。我们所需做的只是如果缺失,则创建键值。我们通过创建一个随机 UUID 来做到这一点,这有助于我们避免在存在多个进程或应用程序写入数据库时发生键冲突。

删除一个 Recipient 实例或获取数据库中所有 Recipient 实例的列表同样简单:

    public List<Recipient> getRecipients() { 
      return mapper.scan(Recipient.class,  
       new DynamoDBScanExpression()); 
    } 

    public void deleteRecipient(Recipient recip) { 
      mapper.delete(recip); 
    } 

在我们离开我们的 DAO 之前,让我们快速看一下我们如何测试它。早些时候,我们提到了 local 参数和 verifyTables() 方法,它们都存在于测试中。

一般而言,大多数人会对在生产类中添加仅用于测试的方法皱眉,这是有道理的。编写可测试的类和向类中添加测试方法之间是有区别的。我同意仅为了测试而向类中添加方法是一种应该避免的做法,但在这里为了简单和简洁,我稍微违反了这个原则。

verifyTables()方法检查表是否存在;如果表不存在,我们将调用另一个方法来为我们创建它。虽然我们手动使用前面的控制台创建了生产表,但我们可以让此方法为我们创建该表。您使用哪种方法完全取决于您。请注意,将会有性能和权限问题需要解决。话虽如此,该方法看起来像这样:

    private void verifyTables() { 
      try { 
        ddb.describeTable(TABLE_NAME); 
      } catch (ResourceNotFoundException rnfe) { 
          createRecipientTable(); 
      } 
    } 

    private void createRecipientTable() { 
      CreateTableRequest request = new CreateTableRequest() 
       .withTableName(TABLE_NAME) 
       .withAttributeDefinitions( 
         new AttributeDefinition("_id", ScalarAttributeType.S)) 
       .withKeySchema( 
         new KeySchemaElement("_id", KeyType.HASH)) 
       .withProvisionedThroughput(new  
         ProvisionedThroughput(10L, 10L)); 

      ddb.createTable(request); 
      try { 
        TableUtils.waitUntilActive(ddb, TABLE_NAME); 
      } catch (InterruptedException  e) { 
        throw new RuntimeException(e); 
      } 
    } 

通过调用describeTable()方法,我们可以检查表是否存在。在我们的测试中,这将在每次都失败,这将导致创建表。在生产环境中,如果您使用此方法创建表,则此调用仅在第一次调用时失败。在createRecipientTable()中,我们可以看到如何以编程方式创建表。我们还等待表处于活动状态,以确保在表创建过程中我们的读写操作不会失败。

我们进行的测试非常简单。例如,考虑以下代码片段:

    private final CloudNoticeDAO dao = new CloudNoticeDAO(true); 
    @Test 
    public void addRecipient() { 
      Recipient recip = new Recipient("SMS", "test@example.com"); 
      dao.saveRecipient(recip); 
      List<Recipient> recipients = dao.getRecipients(); 
      Assert.assertEquals(1, recipients.size()); 
    } 

这个测试帮助我们验证我们的模型映射是否正确,以及我们的 DAO 方法是否按预期工作。您可以在源包中的CloudNoticeDaoTest类中看到额外的测试。

简单电子邮件服务

要发送电子邮件,我们将使用亚马逊简单电子邮件服务(SES),我们将将其包装在api模块中的SesClient类中。

重要提示:在您发送电子邮件之前,您必须验证您的发送/接收地址或域名。验证过程相当简单,但如何操作最好留给亚马逊的文档,您可以通过以下链接阅读:docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html

简单电子邮件服务 API 相当简单。我们需要创建一个Destination,告诉系统将电子邮件发送给谁;一个Message,描述消息本身,包括主题、正文和收件人;以及一个SendEmailRequest,将所有这些内容结合起来:

    private final AmazonSimpleEmailService client =  
      AmazonSimpleEmailServiceClientBuilder.defaultClient(); 
    public void sendEmails(List<String> emailAddresses, 
      String from, 
      String subject, 
      String emailBody) { 
        Message message = new Message() 
         .withSubject(new Content().withData(subject)) 
         .withBody(new Body().withText( 
           new Content().withData(emailBody))); 
        getChunkedEmailList(emailAddresses) 
         .forEach(group -> 
           client.sendEmail(new SendEmailRequest() 
            .withSource(from) 
            .withDestination( 
              new Destination().withBccAddresses(group)) 
               .withMessage(message))); 
        shutdown(); 
    } 

    public void shutdown() { 
      client.shutdown(); 
    } 

然而,有一个重要的注意事项,那就是前面加粗的代码。SES 将每条消息的收件人数量限制为 50,因此我们需要将我们的电子邮件地址列表分成 50 个一组进行处理。我们将使用getChunkedEmailList()方法来完成这项工作:

    private List<List<String>> getChunkedEmailList( 
      List<String> emailAddresses) { 
        final int numGroups = (int) Math.round(emailAddresses.size() / 
         (MAX_GROUP_SIZE * 1.0) + 0.5); 
        return IntStream.range(0, numGroups) 
          .mapToObj(group ->  
            emailAddresses.subList(MAX_GROUP_SIZE * group, 
            Math.min(MAX_GROUP_SIZE * group + MAX_GROUP_SIZE, 
            emailAddresses.size()))) 
             .collect(Collectors.toList()); 
    } 

要找到组数,我们将地址数量除以 50 并向上取整(例如,254 个地址将给我们 6 组--5 组各 50 个,1 组 4 个)。然后,使用IntStream从 0 计数到组数(不包括),我们从原始列表中提取子列表。然后,将这些列表收集到另一个List中,给我们看到的方法签名中的嵌套Collection实例。

设计说明:许多开发者会避免使用这种嵌套Collection实例,因为它很快就会变得难以理解变量究竟代表什么。许多人认为在这种情况下创建一个新的类型来保存嵌套数据是一种最佳实践。例如,如果我们遵循这个建议,我们可以创建一个新的Group类,它有一个List<String>属性来保存组的电子邮件地址。我们没有这样做是为了简洁,但那绝对是对此代码的一个很好的改进。

一旦我们将列表分块,我们就可以向每个组发送相同的消息,从而满足 API 合约。

简单通知服务

我们已经看到了简单通知系统在工作,至少在理论上是这样,因为它是将出站消息传递给我们的函数的:某种客户端在特定的 SNS 主题中发布了一条消息。我们对该主题有一个订阅(我稍后会向你展示如何创建它),它会调用我们的方法,为我们传递消息。现在我们将使用 SNS API 向已将电话号码订阅到系统的用户发送文本(或短信)消息。

使用 SNS,要向多个电话号码发送消息,你必须通过每个号码已订阅的主题来发送。我们接下来要做的就是遵循以下步骤:

  1. 创建主题。

  2. 订阅所有电话号码。

  3. 将消息发布到主题。

  4. 删除主题。

如果我们使用持久性主题,当有多个该函数实例同时运行时,我们可能会得到不可预测的结果。负责所有这些工作的方法看起来是这样的:

    public void sendTextMessages(List<String> phoneNumbers,  
      String message) { 
        String arn = createTopic(UUID.randomUUID().toString()); 
        phoneNumbers.forEach(phoneNumber ->  
          subscribeToTopic(arn, "sms", phoneNumber)); 
        sendMessage(arn, message); 
        deleteTopic(arn); 
    } 

要创建主题,我们有以下方法:

    private String createTopic(String arn) { 
      return snsClient.createTopic( 
        new CreateTopicRequest(arn)).getTopicArn(); 
    } 

要将号码订阅到主题,我们有这个方法:

    private SubscribeResult subscribeToTopic(String arn, 
      String protocol, String endpoint) { 
        return snsClient.subscribe( 
          new SubscribeRequest(arn, protocol, endpoint)); 
    } 

发布消息同样简单,正如我们所看到的:

    public void sendMessage(String topic, String message) { 
      snsClient.publish(topic, message); 
    } 

最后,你可以使用这个简单的方法删除主题:

    private DeleteTopicResult deleteTopic(String arn) { 
      return snsClient.deleteTopic(arn); 
    } 

所有这些方法都非常简单,所以可以直接在调用代码中调用 SNS API,但这个包装器确实为我们提供了一种隐藏 API 细节的方法。这在例如createTopic()这样的情况下尤为重要,那里需要额外的类,但为了保持一致性,我们将一切封装在我们的自己的外观之后。

部署函数

我们现在已经完成了我们的函数,我们几乎准备好部署它了。为此,我们需要打包它。AWS 允许我们上传 ZIP 或 JAR 文件。我们将使用后者。然而,我们有一些外部依赖项,所以我们将使用Maven Shade插件来构建一个包含我们的函数及其所有依赖项的胖 JAR。在function模块中,将以下代码片段添加到pom.xml文件中:

    <plugin> 
      <groupId>org.apache.maven.plugins</groupId> 
      <artifactId>maven-shade-plugin</artifactId> 
      <version>3.0.0</version> 
      <executions> 
        <execution> 
            <phase>package</phase> 
            <goals> 
                <goal>shade</goal> 
            </goals> 
            <configuration> 
                <finalName> 
                    cloudnotice-function-${project.version} 
                </finalName> 
            </configuration> 
        </execution> 
      </executions> 
    </plugin> 

现在,当我们构建项目时,我们将在目标目录中获得一个大型文件(大约 9MB)。这个文件就是我们将要上传的文件。

创建角色

在我们可以上传函数之前,我们需要通过创建适当的角色来准备我们的 AWS 环境。登录 AWS 并导航到身份和访问管理控制台(console.aws.amazon.com/iam)。在左侧的导航面板中,点击角色,然后点击创建新角色:

图片

当提示选择角色时,我们想要选择 AWS Lambda。在下一页,我们将附加策略:

图片

点击下一步,将名称设置为 j9bp,然后点击创建角色。

创建主题

为了使创建函数和相关触发器更加简单,我们首先创建我们的主题。导航到 SNS 控制台。鉴于并非所有 AWS 功能在所有区域都始终可用,我们需要选择一个特定区域。我们可以在网页的左上角做到这一点。如果区域不显示为 N. Virginia,请选择它--US East (N. Virginia)--从下拉菜单中选择,然后再继续。

一旦设置正确的区域,点击左侧导航栏中的主题,然后点击创建新主题并指定名称为 cloud-notice

图片

部署函数

我们现在可以导航到 Lambda 控制台并部署我们的函数。我们将首先点击创建 Lambda 函数按钮。我们将被要求选择一个蓝图。适合基于 Java 的函数的唯一选项是空白函数。一旦我们点击该选项,我们就会看到配置触发器屏幕。当您点击空方框时,您将看到一个下拉菜单,如 AWS 控制台的此截图所示:

图片

您可以滚动到找到 SNS,或者在过滤器框中输入 SNS,如前面的截图所示。无论哪种方式,当您在列表中点击 SNS 时,您将被要求选择您想要订阅的主题:

图片

点击下一步。我们现在需要指定我们函数的详细信息:

图片

滚动到页面底部,我们还需要指定 Lambda 函数处理程序和角色。处理程序是完整的类名,后面跟着两个冒号,然后是方法名:

图片

现在我们需要通过点击上传按钮并选择由我们的 Maven 构建创建的 jar 文件来选择函数存档。点击下一步,验证函数的详细信息,然后点击创建函数。

我们现在有一个可用的 AWS Lambda 函数。我们可以使用 Lambda 控制台来测试它,但我们将构建一个小的 JavaFX 应用程序来完成这个任务,这将同时测试所有服务集成,并展示一个生产应用程序如何与该函数交互。

测试函数

为了帮助测试和演示系统,我们将在CloudNotice项目中创建一个新的模块,名为manager。为此,在 NetBeans 项目资源管理器中单击模块节点,然后单击创建新模块... | Maven | JavaFX 应用程序。将项目命名为Manager,然后单击完成。

我已将MainApp重命名为CloudNoticeManager,将FXMLController重命名为CloudNoticeManagerController,将Scene.fxml重命名为manager.fxml

我们的Application类将与之前的 JavaFX 应用程序略有不同。一些 AWS 客户端 API 要求在完成使用后显式关闭它们。未能这样做意味着我们的应用程序将无法完全退出,留下必须被杀死的僵尸进程。为了确保我们正确关闭 AWS 客户端,我们需要在我们的控制器中添加一个清理方法,然后从应用程序中的stop()方法调用它:

    private FXMLLoader fxmlLoader; 
    @Override 
    public void start(final Stage stage) throws Exception { 
      fxmlLoader = new FXMLLoader(getClass() 
       .getResource("/fxml/manager.fxml")); 
      Parent root = fxmlLoader.load(); 
      // ... 
    } 

    @Override 
    public void stop() throws Exception { 
      CloudNoticeManagerController controller =  
        (CloudNoticeManagerController) fxmlLoader.getController(); 
      controller.cleanup(); 
      super.stop();  
    } 

现在,无论用户是点击文件 | 退出还是点击窗口上的关闭按钮,我们的 AWS 客户端都可以被正确清理。

在布局方面,没有新的内容可讨论,因此我们不会在此处过多关注这一点。这是我们管理应用程序的外观:

图片

我们在左侧有一个订阅的收件人列表,在右上角有一个添加和编辑收件人的区域,在右下角有一个发送测试消息的区域。我们确实有一些有趣的绑定,所以让我们看看这个。

首先,在CloudNoticeManagerController中,我们需要声明一些用于我们的数据容器,所以我们声明了多个ObservableList实例:

    private final ObservableList<Recipient> recips =  
      FXCollections.observableArrayList(); 
    private final ObservableList<String> types =  
      FXCollections.observableArrayList("SMS", "Email"); 
    private final ObservableList<String> topics =  
      FXCollections.observableArrayList(); 

这三个ObservableList实例将支持与其名称匹配的 UI 控件。我们将在initialize()方法中填充其中两个列表(type是硬编码的),如下所示:

    public void initialize(URL url, ResourceBundle rb) { 
      recips.setAll(dao.getRecipients()); 
      topics.setAll(sns.getTopics()); 

      type.setItems(types); 
      recipList.setItems(recips); 
      topicCombo.setItems(topics); 

使用我们的 DAO 和 SES 客户端,我们获取任何已订阅的收件人以及账户中配置的任何主题。这将获取每个主题,所以如果你有很多,这可能会成为问题,但这是一个演示应用程序,所以这里应该没问题。一旦我们有了这两个列表,我们将它们添加到我们之前创建的ObservableList实例中,然后将List与适当的 UI 控件关联。

为了确保Recipient列表正确显示,我们需要创建一个CellFactory,如下所示:

    recipList.setCellFactory(p -> new ListCell<Recipient>() { 
      @Override 
      public void updateItem(Recipient recip, boolean empty) { 
        super.updateItem(recip, empty); 
        if (!empty) { 
          setText(String.format("%s - %s", recip.getType(),  
            recip.getAddress())); 
          } else { 
              setText(null); 
          } 
        } 
    }); 

记住,如果单元格为空,我们需要将文本设置为 null 以清除任何之前的值。未能这样做将导致在某个时刻出现带有幽灵条目的ListView

接下来,我们需要在用户点击列表中的Recipient时更新编辑控件。我们通过向selectedItemProperty添加监听器来实现这一点,该监听器在所选项目更改时运行:

    recipList.getSelectionModel().selectedItemProperty() 
            .addListener((obs, oldRecipient, newRecipient) -> { 
        type.valueProperty().setValue(newRecipient != null ?  
            newRecipient.getType() : ""); 
        address.setText(newRecipient != null ?  
            newRecipient.getAddress() : ""); 
    }); 

如果newRecipient不为 null,我们将控件值设置为适当的值。否则,我们将清除值。

我们现在需要为各种按钮添加处理程序——位于收件人列表上方的添加和删除按钮,以及右侧两个表单区域中的保存和取消按钮。

UI 控件的onAction属性可以通过直接编辑 FXML 绑定到类中的方法,如下所示:

    <Button mnemonicParsing="false"  
      onAction="#addRecipient" text="+" /> 
    <Button mnemonicParsing="false"  
      onAction="#removeRecipient" text="-" /> 

它也可以通过在 Scene Builder 中编辑属性来绑定到方法,如下面的截图所示:

图片

无论哪种方式,方法看起来都像这样:

    @FXML 
    public void addRecipient(ActionEvent event) { 
      final Recipient recipient = new Recipient(); 
      recips.add(recipient); 
      recipList.getSelectionModel().select(recipient); 
      type.requestFocus(); 
    } 

我们添加了一个Recipient,因此我们创建了一个新的,将其添加到我们的ObservableList中,然后告诉ListView选择这个条目。最后,我们要求type控件请求焦点,以便用户可以轻松地使用键盘更改值,如果需要的话。新的收件人只有在用户点击保存时才会保存到 DynamoDB,我们稍后会看到这一点。

当我们删除一个Recipient时,我们需要从 UI 以及从 DynamoDB 中删除它:

    @FXML 
    public void removeRecipient(ActionEvent event) { 
      final Recipient recipient = recipList.getSelectionModel() 
       .getSelectedItem(); 
      dao.deleteRecipient(recipient); 
      recips.remove(recipient); 
    } 

保存稍微复杂一些,但并不复杂:

    @FXML 
    public void saveChanges(ActionEvent event) { 
      final Recipient recipient =  
        recipList.getSelectionModel().getSelectedItem(); 
      recipient.setType(type.getValue()); 
      recipient.setAddress(address.getText()); 
      dao.saveRecipient(recipient); 
      recipList.refresh(); 
    } 

由于我们没有将编辑控件的值绑定到列表中选定的项目,我们需要获取项目的引用,然后将控件中的值复制到模型中。完成此操作后,我们通过我们的 DAO 将其保存到数据库,然后要求ListView刷新自己,以便任何模型更改都能反映在列表中。

我们没有将控件绑定到列表中的项目,因为这会导致用户体验略感困惑。如果我们绑定了,当用户对模型进行更改时,ListView会反映这些更改。可以想象,用户可能会认为这些更改正在被保存到数据库中,而实际上并不是。这种情况只有在用户点击保存时才会发生。为了避免这种困惑和数据丢失,我们没有绑定控件,而是手动管理数据。

要取消更改,我们只需要从ListView获取未更改模型的引用,然后将其值复制到编辑控件中:

    @FXML 
    public void cancelChanges(ActionEvent event) { 
      final Recipient recipient = recipList.getSelectionModel() 
        .getSelectedItem(); 
      type.setValue(recipient.getType()); 
      address.setText(recipient.getAddress()); 
    } 

这就留下了 UI 的发送消息部分。多亏了我们的 SNS 包装器 API,这些方法非常简单:

    @FXML 
    public void sendMessage(ActionEvent event) { 
      sns.sendMessage(topicCombo.getSelectionModel() 
        .getSelectedItem(), messageText.getText()); 
      messageText.clear(); 
    } 

    @FXML 
    public void cancelMessage(ActionEvent event) { 
      messageText.clear(); 
    } 

从我们的桌面应用程序中,我们现在可以添加、编辑和删除收件人,以及发送测试消息。

配置您的 AWS 凭证

那些非常关注细节的人可能会问一个非常重要的问题——AWS 客户端库是如何知道如何登录到我们的账户的?显然,我们需要告诉它们,我们有一些选择。

当 AWS SDK 在本地运行时,它会在三个地方检查凭证——环境变量(AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY)、系统属性(aws.accessKeyIdaws.secretKey)以及默认凭证配置文件($HOME/.aws/credentials)。您使用哪种凭证取决于您,但我会在这里向您展示如何配置配置文件。

就像 Unix 或 Windows 系统,您的 AWS 账户有一个具有对系统完全访问权限的 root 用户。以这个用户身份运行任何客户端代码都是非常不明智的。为了避免这种情况,我们需要创建一个用户,我们可以在身份和访问管理控制台(console.aws.amazon.com/iam)上完成这一操作。

登录后,点击左侧的“用户”,然后在顶部点击“添加用户”,结果如下截图所示:

点击“下一步:权限”,然后检查角色列表中我们的角色j9bp的条目。点击“下一步:审查”,然后创建用户。这将带您到添加用户屏幕,应该有一个成功消息框。重要的是屏幕底部列出的用户信息。在此表的右侧,你应该看到“访问密钥 ID”和“秘密访问密钥”这两列。点击“显示”访问密钥以显示其值。请记住这两个值,因为一旦离开此页面,就无法检索访问密钥。如果您丢失了它,您将不得不生成一组新的密钥,这将破坏使用旧凭证的任何其他应用程序。

在文本编辑器中,我们需要创建 ~/.aws/credentials 文件。在 Unix 系统上,这可能是 /home/jdlee/.aws,而在 Windows 机器上可能是 C:Usersjdleeaws。凭证文件应该看起来像这样:

    [default] 
    aws_access_key_id = AKIAISQVOILE6KCNQ7EQ 
    aws_secret_access_key = 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 

在同一目录下,我们还需要创建另一个名为 config 的文件。我们将使用此文件来告诉 SDK 我们想要在哪个区域工作:

    [default] 
    region = us-east-1 

现在,当 AWS 客户端启动时,它们将默认连接到 us-east-1 区域的 j9bp 用户。如果您需要覆盖此设置,您可以编辑此文件或设置在 配置您的 AWS 凭证 部分中提到的上述环境变量或系统属性。

摘要

我们做到了!我们中的许多人已经创建了我们的第一个 AWS Lambda 函数,这其实并没有那么困难。当然,这是一个简单的应用程序,但我希望你能看到这种类型的应用程序可能非常有用。以这个作为起点,你可以编写系统,借助移动应用程序的帮助,以跟踪你家人的位置。例如,使用嵌入式设备如 Raspberry PI,你可以构建跟踪货物在全国范围内运输的设备,报告位置、速度、环境条件、突然的下降或冲击等。在服务器上运行的软件可以持续报告有关系统的各种指标,例如 CPU 温度、可用磁盘空间、分配的内存、系统负载等。你的选择仅受你的想象力限制。

总结一下,让我们快速回顾一下我们所学的内容。我们了解了一些今天提供的各种...作为服务系统,以及无服务器真正意味着什么,以及为什么它可能对我们这些应用开发者有吸引力。我们学习了如何配置各种亚马逊云服务产品,包括身份和访问管理、简单通知系统、简单电子邮件服务,当然还有 Lambda,我们还学习了如何在 Java 中编写 AWS Lambda 函数以及如何将其部署到服务中。最后,我们学习了如何配置触发器,将 SNS 发布/订阅主题与我们的 Lambda 函数关联起来。

毫无疑问,我们的应用相对简单,在一个章节的空间内也不可能让你成为亚马逊云服务或任何其他云服务提供商所提供的一切的专家。希望这足以让你开始——并激发你对使用 Java 编写云应用的热情。对于那些想要深入研究的人,有许多优秀的书籍、网页等等可以帮助你更深入地了解这个快速变化和扩展的领域。在我们下一章中,我们将从云服务中返回,并将注意力转向 Java 开发者另一个伟大的空间——你的手机。

第二十九章:DeskDroid - 为您的 Android 手机提供的桌面客户端

我们终于来到了我们的最终项目。为了结束我们在这里的时光,我们将构建一个非常实用的应用程序,一个让我们能够从桌面舒适地发送和接收短信的应用程序。市场上现在有许多产品可以让你做这件事,但它们通常需要一个第三方服务,这意味着你的消息会通过别人的服务器。对于注重隐私的人来说,这可能是一个真正的问题。我们将构建一个 100%本地的系统。

构建应用将涵盖几个不同的主题,有些熟悉,有些新颖。以下是这个列表:

  • Android 应用程序

  • Android 服务

  • REST 服务器

  • 服务器发送的事件用于事件/数据流

  • 使用内容提供者进行数据访问

在我们共同度过的时间结束时,我们还将有其他许多小细节,以一个强大、高调的结尾。

入门

这个项目将有两个部分:

  • Android 应用程序/服务器(当然不要与应用程序服务器混淆)

  • 桌面/JavaFX 应用程序

没有服务器组件,桌面组件就有些无用了,所以我们将首先构建 Android 端。

创建 Android 项目

虽然我们到目前为止的大部分工作都在使用 NetBeans,但我们将再次使用 Android Studio 来完成这个项目部分。虽然 NetBeans 对 Android 有一些支持,但截至本文撰写时,该项目似乎已经停滞不前。另一方面,Android Studio 是由谷歌非常积极地开发的,事实上,它是 Android 开发的官方 IDE。如果需要,我将把它留给读者作为练习,安装 IDE 和 SDK。

要创建一个新项目,我们点击文件 | 新建项目,并指定应用程序名称、公司域名和项目位置,如下面的截图所示:

图片

接下来,我们需要指定我们想要的目标 API 版本。这是一个棘手的选择。一方面,我们希望走在前沿,能够使用 Android 提供的大多数新功能,但另一方面,我们不想针对如此新的 API 级别,以至于让更多的 Android 用户无法使用(或者说无法卸载)应用程序。在这种情况下,Android 6.0,或 Marshmallow,似乎是一个可接受的折衷方案:

图片

点击下一步,选择空白活动,然后点击下一步,完成,我们的项目就准备好开发了。

在 Android 端,我们不会在用户界面方面做太多工作。一旦我们完成项目,你可能会想到各种可以做的事情,这很好,但我们不会在这里花时间做这些。话虽如此,我们真正需要做的第一件事是请求用户允许访问他们手机上的短信。

请求权限

在 Android 的早期版本中,权限是一个全有或全无的提议。然而,从 Android 6 开始,对于应用程序请求的每个权限,用户都会被提示,这允许用户授予一些权限同时拒绝其他权限。我们需要请求一些权限——我们需要能够读取和写入短信消息,并且我们需要访问联系人(这样我们就可以尝试找出谁给我们发送了特定的消息)。Android 提供了一个非常容易请求这些权限的 API,我们将在 onCreate() 方法中实现,如下所示:

    public static final int PERMISSION_REQUEST_CODE = 42; 
    @Override 
    protected void onCreate(Bundle savedInstanceState) { 
      super.onCreate(savedInstanceState); 
     // ... 
     ActivityCompat.requestPermissions(this, 
            new String[]{ 
                    Manifest.permission.SEND_SMS, 
                    Manifest.permission.RECEIVE_SMS, 
                    Manifest.permission.READ_CONTACTS 
            }, 
            PERMISSION_REQUEST_CODE); 
    } 

当这段前置代码运行时,Android 会提示用户授予或拒绝请求的权限。这是异步进行的,因此,在你的应用程序中,你需要确保在用户有机会授予权限之前,不要尝试任何需要你请求的权限的操作(如果用户拒绝权限,应用程序应该优雅地降级或失败)。

为了允许应用程序响应权限授予,Android 提供了一个回调。在我们的回调中,我们想要确保用户授予我们两个权限:

    @Override 
    public void onRequestPermissionsResult(int requestCode, 
     String permissions[], int[] grantResults) { 
      switch (requestCode) { 
        case PERMISSION_REQUEST_CODE: { 
          if (grantResults.length != 3 
           || grantResults[0] !=  
                PackageManager.PERMISSION_GRANTED 
           || grantResults[1] !=  
                PackageManager.PERMISSION_GRANTED 
           || grantResults[2] !=  
                PackageManager.PERMISSION_GRANTED) { 
                  AlertDialog.Builder dialog =  
                    new AlertDialog.Builder(this); 
                  dialog.setCancelable(false); 
                  dialog.setTitle("Error"); 
                  dialog.setMessage("This app requires access
                   to text messages and contacts. Click OK
                   to close."); 
                  dialog.setPositiveButton("OK",  
                   new DialogInterface.OnClickListener() { 
                     @Override 
                     public void onClick(DialogInterface dialog,  
                      int id) { 
                        finish(); 
                      } 
                  }); 

                  final AlertDialog alert = dialog.create(); 
                  alert.show(); 
                } 
        } 
      } 
    } 

当 Android 回调到我们的应用程序时,我们需要确保 requestCode 是我们指定的——PERMISSION_REQUEST_CODE——以确保我们只响应我们自己的请求。

一旦我们确定了适当的响应,我们确保 grantResults 的长度正确,并且每个条目都是 PERMISSION_GRANTED。如果数组太短,或者如果任一数组元素不是正确的类型,我们将显示一个对话框通知用户需要这两个权限,然后退出应用程序。

在我们的例子中,我们同时请求这两个权限,因此我们同时响应。如果你有一组复杂的权限,例如,如果你的应用程序只能使用请求的一些权限,你可以多次调用 ActivityCompat.requestPermissions,为每个提供不同的 requestCode。然后你需要扩展 onRequestPermissionsResult() 中的 switch 块,以覆盖每个新的 requestCode

最后关于权限的问题。通常情况下,你应该始终检查以确保你有执行特定任务所需的权限。你可以使用以下方法来完成:

    protected boolean checkPermission(Permissions permission) { 
      return ContextCompat.checkSelfPermission(this,  
        permission.permission) ==  
        PackageManager.PERMISSION_GRANTED; 
   } 

在我们的例子中,如果我们没有得到所需的权限,我们就不允许应用程序运行,因此我们不需要担心额外的权限检查。

创建服务

项目中 Android 部分的精髓是我们的 REST 端点。我们希望这些端点在手机开启时始终可用,因此我们不能使用Activity来托管它们。我们想要的是一个Service。Android 文档将Service定义为一种可以在后台执行长时间运行操作的应用程序组件,并且它不提供用户界面。有三种类型的 Service--计划(按计划运行)、启动(可以被其他应用程序组件显式启动)和绑定(通过bindService()调用绑定到应用程序组件,直到所有绑定组件被销毁为止)。由于我们希望它始终可用,我们想要一个启动服务。

要创建服务,请点击文件 | 新建 | 服务 | 服务。为服务输入DeskDroidService,取消勾选导出,然后点击完成。这将生成以下示例代码:

    public class DeskDroidService extends Service { 
      public DeskDroidService() { 
      } 

     @Override 
     public IBinder onBind(Intent intent) { 
       throw new UnsupportedOperationException( 
           "Not yet implemented"); 
     } 
    } 

向导还会按如下方式更新AndroidManifest.xml

    <service 
      android:name=".DeskDroidService" 
      android:enabled="true" 
      android:exported="false" /> 

onBind()方法是抽象的,因此必须实现。我们不是创建一个绑定服务,所以我们可以将其留空不实现,尽管我们将更改它,使其返回null而不是抛出Exception。然而,我们对服务启动和停止的时间感兴趣,因此我们需要重写这两个相关生命周期方法:

    public int onStartCommand(Intent intent, int flags, int startId) { 
      super.onStartCommand(intent, flags, startId); 
    }  
    public void onDestroy() { 
    } 

我们将在这些方法中放置我们的 REST 服务代码。我们再次使用 Jersey,JAX-RS 的参考实现,它为在 Java SE 环境中启动服务器提供了一种很好的方式,就像我们在 Android 应用程序中找到的那样。我们将把这个逻辑封装在一个名为startServer()的新方法中,如下所示:

    protected static Server server; 
    protected void startServer() { 
      WifiManager WifiMgr = (WifiManager) getApplicationContext() 
       .getSystemService(Service.Wifi_SERVICE); 
      if (WifiMgr.isWifiEnabled()) { 
        String ipAddress = Formatter. 
         formatIpAddress(WifiMgr.getConnectionInfo() 
          .getIpAddress()); 
        URI baseUri = UriBuilder.fromUri("http://" + ipAddress) 
         .port(49152) 
         .build(); 
        ResourceConfig config =  
          new ResourceConfig(SseFeature.class) 
           .register(JacksonFeature.class); 
        server = JettyHttpContainerFactory.createServer(baseUri, 
         config); 
      } 
    } 

我们首先确保我们正在使用 Wi-Fi。这并不是绝对必要的,但似乎是一个谨慎的预防措施,以防止应用程序在无论网络状态如何的情况下监听连接。如果手机没有连接 Wi-Fi,那么目标笔记本电脑很可能也没有。然而,可能存在允许端点在蜂窝网络上监听的有效用例。将此限制设置为可配置的是偏好驱动选项的一个很好的候选。

为了使此代码能够正常工作,我们需要在清单文件中添加这个新的权限:

    <uses-permission android:name= 
      "android.permission.ACCESS_WIFI_STATE" /> 

一旦我们确定正在使用 Wi-Fi,我们查找我们的 IP 地址,并启动一个基于 Jetty 的 Jersey 服务器。向那些还记得这位计算先驱的尊敬的指挥官 64 致敬,对于那些足够老的人来说,我们在 Wi-Fi 网络接口上监听端口49152

接下来,我们创建一个ResourceConfig实例,提供我们感兴趣的两种功能引用--SseFeatureJacksonFeature。我们已经看到了JacksonFeature;这正是让我们能够处理 POJOs,将 JSON 问题留给 Jersey 的功能。那么,SseFeature是什么呢?

服务器端事件

SSE,或服务器发送事件,是我们可以从服务器向客户端流式传输数据的一种方式。通常,REST 请求的生命周期非常短暂——建立连接,发送请求,获取响应,关闭连接。有时,尽管如此,REST 服务器可能没有在请求时客户端所需的所有数据(例如,从另一个数据源读取数据,如日志文件或网络套接字)。因此,能够将那些数据在它们可用时推送到客户端将是非常好的。这正是 SSE 允许我们做到的。我们将在稍后更详细地探讨这一点。

最后,我们通过调用JettyHttpContainerFactory.createServer()来启动服务器实例。由于我们需要能够在以后停止服务器,我们捕获服务器实例,并将其存储在一个实例变量中。我们从onStartCommand()中调用startServer(),如下所示:

    private static final Object lock = new Object(); 
    public int onStartCommand(Intent intent, int flags, int startId) { 
      super.onStartCommand(intent, flags, startId); 

      synchronized (lock) { 
        if (server == null) { 
          startServer(); 
          messageReceiver = new BroadcastReceiver() { 
            @Override 
            public void onReceive(Context context,  
             Intent intent) { 
               String code = intent.getStringExtra("code"); 
               DeskDroidService.this.code = code; 
               Log.d("receiver", "Got code: " + code); 
            } 
          }; 
          LocalBroadcastManager.getInstance(this). 
           registerReceiver( 
             messageReceiver,  
              new IntentFilter(CODE_GENERATED)); 
        } 
      } 

      return Service.START_STICKY; 
    } 

注意,我们已经将startServer()的调用包裹在一个synchronized块中。对于那些可能不知道的人来说,synchronized是 Java 开发者可用的更基本的并发代码方法之一。这个关键字的效果是,尝试执行此代码块的多线程必须同步执行,或者一次一个。我们在这里这样做是为了确保如果有两个不同的进程试图启动服务器,我们可以保证最多只有一个正在运行。如果没有这个块,第一个线程可能会启动服务器并将实例存储在变量中,而第二个线程可能会做同样的事情,但它的服务器实例,存储在变量中的,无法启动。现在我们会有一个正在运行的服务器,但没有有效的引用,因此我们将无法停止它。

我们还注册了一个监听CODE_GENERATEDBroadcastReceiver。我们将在本章稍后解释这一点,所以现在不用担心。

控制服务状态

如果我们现在运行应用程序,我们的服务将不会运行,因此我们需要确保它能够运行。我们将通过几种不同的方式来实现这一点。第一种方式将来自我们的应用程序。我们想要确保在打开应用程序时服务正在运行,尤其是在它刚刚安装之后。为此,我们需要在MainActivity.onCreate()中添加一行,如下所示:

    startService(new Intent(this, DeskDroidService.class)); 

当应用程序现在启动时,它将保证服务正在运行。但我们不希望用户打开应用程序来运行服务。幸运的是,我们有一种在手机启动时启动应用程序的方法。我们可以通过安装一个监听启动事件的BroadcastReceiver来实现这一点,如下所示:

    public class BootReceiver extends BroadcastReceiver { 
      @Override 
      public void onReceive(Context context, Intent intent) { 
        context.startService(new Intent(context,  
         DeskDroidService.class)); 
      } 
    } 

上述方法的主体与我们在MainActivity中最近添加的内容相同。不过,我们需要注册服务并请求权限。在AndroidManifest.xml中,我们需要添加以下内容:

    <uses-permission android:name= 
      "android.permission.RECEIVE_BOOT_COMPLETED" /> 
    <receiver android:name=".BootReceiver" android:enabled="true"> 
      <intent-filter> 
        <action android:name= 
        "android.intent.action.BOOT_COMPLETED" /> 
      </intent-filter> 
    </receiver> 

现在我们有一个在设备启动或应用程序启动时启动的服务。然而,它并没有做任何有趣的事情,因此我们需要向我们的服务器添加一些端点。

向服务器添加端点

如在 第二十五章 中所述,使用 Monumentum 记笔记,一个 JAX-RS 资源存在于一个带有特定注解的 POJO 中。为了构建我们的端点类,我们可以从以下内容开始:

    @Path("/") 
    @Produces(MediaType.APPLICATION_JSON) 
    protected class DeskDroidResource { 
    } 

我们还需要将这个类注册到 JAX-RS 中,我们通过在 startServer() 方法中的这一行来完成:

    config.registerInstances(new DeskDroidResource()); 

通常,我们会将 DeskDroidResource.class 传递给 ResourceConfig 构造函数,就像我们处理 JacksonFeature.class 一样。我们将访问 Android 资源,为此,我们需要 ServiceContext 实例。互联网上有许多资源建议创建一个自定义的 Application 类并将其存储在 public static 中。虽然这似乎可行,但它也会导致内存泄漏,所以,例如,如果你在 Android Studio 中尝试这样做,它将会报错。然而,我们可以通过使用嵌套类来避免这种情况。这种方法可能会有些难以管理,但我们的类应该足够小,以至于仍然可以管理。

获取对话

让我们从添加一个端点开始,以获取手机上的所有对话,如下所示:

    @GET 
    @Path("conversations") 
    public Response getConversations() { 
      List<Conversation> conversations = new ArrayList<>(); 
      Cursor cur = getApplication().getContentResolver() 
      .query(Telephony.Sms.Conversations.CONTENT_URI,  
      null, null, null, null); 
      while (cur.moveToNext()) { 
        conversations.add(buildConversation(cur)); 
      } 

      Collections.sort(conversations, new ConversationComparator()); 

      return Response.ok(new GenericEntity<List<Conversation>>( 
      conversations) {}).build(); 
     } 

这里我们可以看到 Android 的组件开始显现——我们将使用 ContentProvider 来访问短信数据。ContentProvider 是一种方式,允许一个应用程序,或者在这个案例中是一个 Android 子系统,以可移植、不依赖于存储的方式向外部消费者公开数据。我们不在乎数据是如何存储的。我们只需指定我们想要的字段,我们想要对那些数据施加的过滤器或限制,然后 ContentProvider 会完成剩余的工作。

使用 ContentProvider,我们不是通过表名来指定数据类型,就像我们使用 SQL 一样,而是通过 Uri。在这种情况下,我们指定 Telephony.Sms.Conversations.CONTENT_URI。我们还向 query() 传递几个 null 值。这些代表投影(或字段列表)、选择(或过滤器)、选择参数和排序顺序。由于这些全部是 null,我们希望获取提供者自然排序顺序下的所有字段和每一行。这给我们一个 Cursor 对象,然后我们遍历它,创建 Conversation 对象,并将它们添加到我们的 List 中。

我们使用这个方法创建 Conversation 实例:

    private Conversation buildConversation(Cursor cur) { 
      Conversation conv = new Conversation(); 
      final int threadId =  
        cur.getInt(cur.getColumnIndex("thread_id")); 
      conv.setThreadId(threadId); 
      conv.setMessageCount( 
        cur.getInt(cur.getColumnIndex("msg_count"))); 
      conv.setSnippet(cur.getString(cur.getColumnIndex("snippet"))); 
      final List<Message> messages =  
        getSmsMessages(conv.getThreadId()); 
      Set<String> participants = new HashSet<>(); 
      for (Message message : messages) { 
        if (!message.isMine()) { 
          participants.add(message.getAddress()); 
        } 
      } 
      conv.setParticipants(participants); 
      conv.setMessages(messages); 
      return conv; 
    } 
getSmsMessages() as follows:
    private List<Message> getSmsMessages(int threadId) { 
      List<Message> messages = new ArrayList<>(); 
      Cursor cur = null; 
      try { 
        cur = getApplicationContext().getContentResolver() 
         .query(Telephony.Sms.CONTENT_URI, 
         null, "thread_id = ?", new String[] 
         {Integer.toString(threadId)}, 
         "date DESC"); 

        while (cur.moveToNext()) { 
          Message message = new Message(); 
          message.setId(cur.getInt(cur.getColumnIndex("_id"))); 
          message.setThreadId(cur.getInt( 
            cur.getColumnIndex("thread_id"))); 
          message.setAddress(cur.getString( 
            cur.getColumnIndex("address"))); 
          message.setBody(cur.getString( 
            cur.getColumnIndexOrThrow("body"))); 
          message.setDate(new Date(cur.getLong( 
            cur.getColumnIndexOrThrow("date")))); 
          message.setMine(cur.getInt( 
            cur.getColumnIndex("type")) ==  
              Telephony.Sms.MESSAGE_TYPE_SENT); 
          messages.add(message); 
        } 
      } catch (Exception e) { 
          e.printStackTrace(); 
      } finally { 
          if (cur != null) { 
            cur.close(); 
          } 
      } 
      return messages; 
    } 

这个方法和处理逻辑基本上与对话的相同。ContentProviderUri,即 Telephony.Sms.CONTENT_URI,当然不同,我们按照如下方式指定查询的过滤器:

    cur = getApplicationContext().getContentResolver().query( 
      Telephony.Sms.CONTENT_URI, 
       null, "thread_id = ?", new String[] 
       {Integer.toString(threadId)}, 
       "date DESC"); 

我们在这里进行了一些数据分析。我们需要知道哪些是我们发送的消息,哪些是我们接收的消息,以便我们可以更有意义地显示线程。在设备上,我们发送的消息类型为Telephony.Sms.MESSAGE_TYPE_SENT。这个字段的值大致对应于文件夹(已发送、已接收、草稿等)。我们不是通过共享常量的值将 Android API 的一部分泄露到我们的 API 中,而是有一个boolean类型的字段isMine,如果消息类型为MESSAGE_TYPE_SENT,则该字段为 true。诚然,这是一个稍微有些笨拙的替代方案,但它有效,并且应该足够清晰。

一旦我们返回消息列表,我们就遍历列表,获取唯一参与者的列表(由于我们处理的是短信消息,所以应该只有一个)。

最后,我们使用 Jersey 的 POJO-mapping 功能将这个List<Conversation>返回给客户端,如下所示:

    return Response.ok(new GenericEntity<List<Conversation>>( 
      conversations) {}).build();

如果我们点击运行或调试按钮(工具栏中的大三角形或三角形-over-a-bug 图标),你会被要求选择部署目标,如下截图所示:

由于我们需要 Wi-Fi,我选择了我的物理设备。如果你想要配置具有 Wi-Fi 的模拟器,那也可以。点击确定,经过几分钟,应用程序应该在你选择的设备上启动,然后我们可以进行我们的第一个 REST 请求,如下所示:

    $ curl http://192.168.0.2:49152/conversations | jq . 
    [ 
    { 
      "messageCount": 2, 
      "messages": [ 
        { 
          "address": "5551234567", 
          "body": "Demo message", 
          "date": 1493269498618, 
          "id": 301, 
          "mine": true, 
          "threadId": 89 
        }, 
        { 
          "address": "+15551234567", 
          "body": "Demo message", 
          "date": 1493269498727, 
          "id": 302, 
          "mine": false, 
          "threadId": 89 
        } 
      ], 
      "participants": [ "+15551234567" ], 
      "snippet": "Demo message", 
      "threadId": 89 
    } 
    ] 

以下示例代码显示了我和自己进行的对话。或许是因为太多的深夜,但你可以看到第一条消息,即最旧的消息,被标记为我的,这是我给自己发送的,第二条是我接收到的。非常酷,但你是如何发送消息的呢?实际上,这实际上非常简单。

发送短信消息

要发送消息,我们将创建一个 POST 端点,该端点接受一个Message对象,然后我们将将其拆分并传递给 Android 的电信 API。

    @POST 
    @Path("conversations") 
    public Response sendMessage(Message message)  
    throws InterruptedException { 
       final SmsManager sms = SmsManager.getDefault(); 
       final ArrayList<String> parts =  
       sms.divideMessage(message.getBody()); 
       final CountDownLatch sentLatch =  
       new CountDownLatch(parts.size()); 
       final AtomicInteger statusCode = new AtomicInteger( 
       Response.Status.CREATED.getStatusCode()); 
       final BroadcastReceiver receiver = new BroadcastReceiver() { 
       @Override 
       public void onReceive(Context context, Intent intent) { 
            if (getResultCode() != Activity.RESULT_OK) { 
                    statusCode.set( 
                        Response.Status.INTERNAL_SERVER_ERROR 
                            .getStatusCode()); 
            } 
             sentLatch.countDown(); 
          } 
        }; 
      registerReceiver(receiver,  
      new IntentFilter("com.steeplesoft.deskdroid.SMS_SENT")); 
      ArrayList<PendingIntent> sentPIs = new ArrayList<>(); 
      for (int i = 0; i < parts.size(); i++) { 
         sentPIs.add(PendingIntent.getBroadcast( 
            getApplicationContext(), 0, 
            new Intent("com.steeplesoft.deskdroid.SMS_SENT"), 0)); 
      } 
      sms.sendMultipartTextMessage(message.getAddress(), null,  
      parts, sentPIs, null); 

      sentLatch.await(5, TimeUnit.SECONDS); 
      unregisterReceiver(receiver); 
      return Response.status(statusCode.get()).build(); 
     } 

这个方法中有很多事情在进行。以下是分解:

  1. 我们获取到SmsManager类的引用。这个类将为我们完成所有工作。

  2. 我们要求SmsManager为我们分割消息。通常,短信文本限制在 160 个字符,所以这将根据需要分割消息。

  3. 我们创建一个计数与消息部分数量相匹配的CountDownLatch

  4. 我们创建一个AtomicInteger来存储状态码。正如我们一会儿将看到的,我们需要从匿名类内部更改这个变量的值。然而,为了让匿名类能够访问其封装作用域中的变量,这些变量必须是final的,这意味着我们不能有一个final int,因为那样我们就无法更改其值。不过,使用AtomicInteger,我们可以调用set()来更改值,同时保持实例引用不变,这就是变量将保持的内容。

  5. 我们创建一个新的 BroadcastReceiver,它将在消息发送时处理 Intent 广播(正如我们稍后看到的)。在 onReceive() 中,如果结果代码不是 ACTIVITY.RESULT_OK,我们调用 AtomicInteger.set() 来反映失败。然后我们调用 sentLatch.countDown() 来表示此消息部分已被处理。

  6. 通过调用 registerReceiver(),我们让操作系统知道我们的新接收器。我们提供一个 IntentFilter 来限制我们的接收器必须处理的 Intents

  7. 我们为消息的每一部分创建一个新的 PendingIntent。这将使我们能够单独对每一部分的发送尝试做出反应。

  8. 我们调用 sendMultipartTextMessage() 来发送消息部分。Android 会为我们处理多部分消息的细节,因此不需要额外的努力。

  9. 我们需要等待所有消息部分发送完成,因此我们调用 sentLatch.await() 给系统发送消息的时间。但我们不想永远等待,所以我们给它一个五秒的超时,这应该足够长。可以想象,某些网络在发送短信方面可能非常慢,因此这个值可能需要调整。

  10. 一旦我们通过 latch,我们就取消注册我们的接收器,并返回状态码。

再次使用 curl,我们现在可以测试发送消息(确保再次点击运行或调试以部署您的更新代码):

        $ curl -v -X POST -H 'Content-type: application/json'
        http://192.168.0.2:49152/conversations -d 
        '{"address":"++15551234567", "body":"Lorem ipsum dolor sit 
         amet..."}' 
        > POST /conversations HTTP/1.1 
        > Content-type: application/json 
        > Content-Length: 482 
        < HTTP/1.1 201 Created 

在前面的 curl 中,我们向我们的收件人发送一些 lorem ipsum 文本,这给我们提供了一个很长的消息(请求有效载荷总共有 482 个字符),它被正确地分块并发送到目标电话号码,如 201 Created 响应状态所示。

我们现在在手机上有一个工作的 REST 服务,它允许我们读取现有的消息并发送新的消息。使用 curl 与服务交互已经足够好,但现在是时候构建我们的桌面客户端,并给这个项目一个美好的外观。

创建桌面应用程序

要构建我们的应用程序,我们将回到 NetBeans 和 JavaFX。与前面的章节一样,我们将通过点击文件 | 新项目来创建一个新的基于 Maven 的 JavaFX 应用程序:

图片

在下一步中,将项目命名为 deskdroid-desktop,验证包名,然后点击完成。虽然不是严格必要的,但让我们稍微整理一下命名,将控制器改为 DeskDroidController,将 FXML 文件改为 deskdroid.fxml。我们还需要修改控制器中 FXML 和 CSS 的引用,以及 FXML 中控制器的引用。点击运行 | 运行项目以确保一切连接正确。一旦应用程序启动,我们就可以立即关闭它,以便开始进行更改。

定义用户界面

让我们从构建用户界面开始。以下是应用程序的外观:

图片

在前面的屏幕中,我们将有左边的会话列表,并将显示选定的会话在右侧。我们将添加自动刷新的机制,但刷新会话将允许手动刷新,如果需要的话。新消息应该很容易理解。

我们可以使用 Gluon 的场景构建器来构建用户界面,当然,但让我们先看看 FXML。我们将像往常一样,从一个 BorderPane 开始,如下所示:

    <BorderPane fx:id="borderPane" minWidth="1024" prefHeight="768"  

    fx:controller="com.steeplesoft.deskdroid.
    desktop.DeskDroidController"> 

对于 top 部分,我们将添加一个菜单栏,如下所示:

    <MenuBar BorderPane.alignment="CENTER"> 
      <menus> 
        <Menu text="_File"> 
            <items> 
                <MenuItem onAction="#connectToPhone"  
                    text="_Connect to Phone" /> 
                <MenuItem onAction="#disconnectFromPhone"  
                    text="_Disconnect from Phone" /> 
                <MenuItem onAction="#closeApplication"  
                    text="E_xit"> 
                    <accelerator> 
                        <KeyCodeCombination alt="ANY" code="F4"  
                            control="UP" meta="UP" shift="UP"  
                            shortcut="UP" /> 
                    </accelerator> 
                </MenuItem> 
              </items> 
          </Menu> 
       </menus> 
    </MenuBar> 

FileMenu 中,我们将有三个 MenuItemconnectToPhonedisconnectFromPhoneExit。每个菜单项都将有一个快捷键,如下划线所示。ExitMenuItem 有一个加速键,ALT-F4

我们将把大部分用户界面放在 center 部分。垂直分割允许我们调整用户界面的两侧大小。为此,我们使用以下 SplitPane

    <center> 
      <SplitPane dividerPositions="0.25"  
        BorderPane.alignment="CENTER"> 
      <items> 

使用 dividerPositions,我们将默认分割设置为水平规则上的 25% 标记处。SplitPane 有一个嵌套的 items 元素来持有其子元素,我们将左侧元素 ListView 添加到其中:

    <VBox> 
      <children> 
        <ListView fx:id="convList" VBox.vgrow="ALWAYS" /> 
      </children> 
    </VBox> 

我们将 ListView 包裹在一个 VBox 中,以便 ListView 可以更容易地根据需要增长和收缩。

最后,让我们构建用户界面的右侧:

     <VBox fx:id="convContainer"> 
       <children> 
        <HBox> 
            <children> 
                <Button mnemonicParsing="false"  
                        onAction="#refreshConversations"  
                        text="Refresh Conversations"> 
                    <HBox.margin> 
                        <Insets right="5.0" /> 
                    </HBox.margin> 
                </Button> 
                <Button fx:id="newMessageBtn"  
                    text="New Message" /> 
            </children> 
            <padding> 
                <Insets bottom="5.0" left="5.0"  
                    right="5.0" top="5.0" /> 
            </padding> 
        </HBox> 
        <ListView fx:id="messageList" VBox.vgrow="ALWAYS" /> 
      </children> 
    </VBox> 

在右侧,我们还有一个 VBox,我们使用它来排列我们的两个用户界面元素。第一个是 HBox,它包含两个按钮:刷新会话和新消息。第二个是我们用于显示选定会话的 ListView

定义用户界面行为

尽管我们可以在除最简单应用之外的所有应用中定义用户界面的结构,但用户界面仍然需要一些 Java 代码来最终定义其行为。我们现在将在 DeskDroidController.initialize() 中完成这项工作。我们将从用户界面的左侧开始,即会话列表,如下所示:

    @FXML 
    private ListView<Conversation> convList; 
    private final ObservableList<Conversation> conversations =  
    FXCollections.observableArrayList(); 
    private final SimpleObjectProperty<Conversation> conversation =  
    new SimpleObjectProperty<>(); 
    @Override 
    public void initialize(URL url, ResourceBundle rb) { 
      convList.setCellFactory(list ->  
      new ConversationCell(convList)); 
      convList.setItems(conversations); 
       convList.getSelectionModel().selectedItemProperty() 
            .addListener((observable, oldValue, newValue) -> { 
                conversation.set(newValue); 
                messages.setAll(newValue.getMessages()); 
                messageList.scrollTo(messages.size() - 1); 
     }); 

我们声明一个可注入的变量来保存对 ListView 的引用。JavaFX 将通过 @FXML 注解为我们设置该值。ListView 需要一个模型来显示,我们将其声明为 conversations,并声明 conversation 来保存当前选定的会话。

initialize() 方法中,我们将一切连接起来。由于 ListView 将显示我们的域对象,我们需要为它声明一个 CellFactory,我们通过传递给 setCellFactory() 的 lambda 来实现这一点。我们稍后会看看 ListCell

接下来,我们将 ListView 与其模型 conversations 相关联,并定义实际上是一个 onClick 监听器的内容。然而,我们是通过向 ListViewSelectionModel 添加监听器来实现这一点的。在这个监听器中,我们更新当前选中的会话,更新显示会话的消息 ListView,并将该 ListView 滚动到最底部,以便我们看到最新的消息。

初始化消息 ListView 要简单得多。我们需要这些实例变量:

    @FXML 
    private ListView<Message> messageList; 
    private final ObservableList<Message> messages =  
    FXCollections.observableArrayList(); 

我们还需要在 initialize() 中添加以下这些行:

    messageList.setCellFactory(list -> new MessageCell(messageList)); 
    messageList.setItems(messages); 

新消息按钮需要一个处理程序:

    newMessageBtn.setOnAction(event -> sendNewMessage()); 

ConversationCell告诉 JavaFX 如何显示一个Conversation实例。为了做到这一点,我们创建一个新的ListCell子项,如下所示:

    public class ConversationCell extends ListCell<Conversation> { 

然后我们重写updateItem()

    @Override 
    protected void updateItem(Conversation conversation,  
    boolean empty) { 
    super.updateItem(conversation, empty); 
    if (conversation != null) { 
        setWrapText(true); 
        final Participant participant =  
            ConversationService.getInstance() 
                .getParticipant(conversation 
                    .getParticipant()); 
        HBox hbox = createWrapper(participant); 

        hbox.getChildren().add( 
            createConversationSnippet(participant,  
                conversation.getSnippet())); 
        setGraphic(hbox); 
     } else { 
        setGraphic(null); 
     } 
    } 

如果单元格被赋予一个Conversation,我们就处理它。如果没有,我们将单元格的图形设置为 null。如果我们无法做到这一点,在滚动列表时可能会出现不可预测的结果。

要构建单元格内容,我们首先获取Participant并创建以下包装组件:

    protected HBox createWrapper(final Participant participant) { 
      HBox hbox = new HBox(); 
      hbox.setManaged(true); 
      ImageView thumbNail = new ImageView(); 
      thumbNail.prefWidth(65); 
      thumbNail.setPreserveRatio(true); 
      thumbNail.setFitHeight(65); 
      thumbNail.setImage(new Image( 
        ConversationService.getInstance() 
           .getParticipantThumbnail( 
               participant.getPhoneNumber()))); 
      hbox.getChildren().add(thumbNail); 
      return hbox; 
    } 

这相当标准化的 JavaFX 操作——创建一个HBox,并向其中添加一个ImageView。不过,我们正在使用我们尚未查看的类——ConversationService。我们稍后会查看这个类,但就目前而言,我们只需要知道我们将在这个类中封装我们的 REST 调用。在这里,我们正在调用一个端点(我们尚未看到)来获取这个对话另一端的电话号码的联系人信息。

我们还需要按照以下方式创建对话摘要:

    protected VBox createConversationSnippet( 
     final Participant participant, String snippet) { 
      VBox vbox = new VBox(); 
      vbox.setPadding(new Insets(0, 0, 0, 5)); 
      Label sender = new Label(participant.getName()); 
      sender.setWrapText(true); 
      Label phoneNumber = new Label(participant.getPhoneNumber()); 
      phoneNumber.setWrapText(true); 
      Label label = new Label(snippet); 
      label.setWrapText(true); 
      vbox.getChildren().addAll(sender, phoneNumber, label); 
      return vbox; 
    } 

使用VBox确保垂直对齐,我们创建两个标签,一个包含参与者的信息,另一个包含对话的摘要。

当那部分完成单元格定义后,如果我们现在就运行应用程序,ListCell的内容可能会被ListView本身的边缘裁剪。例如,看看以下截图中的顶部列表和底部列表之间的区别:

要使我们的ListCell表现得像我们在最后一屏底部看到的那样,我们需要对我们的代码进行一个额外的更改,如下所示:

    public ConversationCell(ListView list) { 
      super(); 
      prefWidthProperty().bind(list.widthProperty().subtract(2)); 
      setMaxWidth(Control.USE_PREF_SIZE); 
    } 

在我们之前的CellFactory中,我们传递了引用到封装的ListView

    convList.setCellFactory(list -> new ConversationCell(convList)); 

在构造函数中,我们将我们单元格的首选宽度绑定到列表的实际宽度(并减去一小部分以调整控件边框)。现在渲染时,我们的单元格将像我们预期的那样自动换行。

MessageCell的定义类似,如下所示:

    public class MessageCell extends ListCell<Message> { 
      public MessageCell(ListView list) { 
          prefWidthProperty() 
            .bind(list.widthProperty().subtract(20)); 
          setMaxWidth(Control.USE_PREF_SIZE); 
      } 

    @Override 
    public void updateItem(Message message, boolean empty) { 
        super.updateItem(message, empty); 
        if (message != null && !empty) { 
            if (message.isMine()) { 
                wrapMyMessage(message); 
            } else { 
                wrapTheirMessage(message); 
            } 
         } else { 
            setGraphic(null); 
        } 
    } 

对于我的消息,我们这样创建内容:

    private static final SimpleDateFormat DATE_FORMAT =  
     new SimpleDateFormat("EEE, MM/dd/yyyy hh:mm aa"); 
    private void wrapMyMessage(Message message) { 
     HBox hbox = new HBox(); 
     hbox.setAlignment(Pos.TOP_RIGHT); 
     createMessageBox(message, hbox, Pos.TOP_RIGHT); 
     setGraphic(hbox); 
    } 
    private void createMessageBox(Message message, Pane parent,  
     Pos alignment) { 
       VBox vbox = new VBox(); 
       vbox.setAlignment(alignment); 
       vbox.setPadding(new Insets(0,0,0,5)); 
       Label body = new Label(); 
       body.setWrapText(true); 
       body.setText(message.getBody()); 

       Label date = new Label(); 
       date.setText(DATE_FORMAT.format(message.getDate())); 

       vbox.getChildren().addAll(body,date); 
       parent.getChildren().add(vbox); 
    } 
my messages and *their* messages, so we use javafx.geometry.Pos to align the controls to the right or left, respectively.

对方的消息是这样创建的:

    private void wrapTheirMessage(Message message) { 
      HBox hbox = new HBox(); 
      ImageView thumbNail = new ImageView(); 
      thumbNail.prefWidth(65); 
      thumbNail.setPreserveRatio(true); 
      thumbNail.setFitHeight(65); 
      thumbNail.setImage(new Image( 
            ConversationService.getInstance() 
                .getParticipantThumbnail( 
                    message.getAddress()))); 
      hbox.getChildren().add(thumbNail); 
      createMessageBox(message, hbox, Pos.TOP_LEFT); 
      setGraphic(hbox); 
   } 

这与我的消息类似,但不同之处在于我们显示发送者的个人资料图片,如果手机上与联系人相关联的话,我们可以通过ConversationService类从手机中检索到。

我们还有更多的工作要做,但这就是有数据的应用程序的外观:

要获取数据,我们需要一个 REST 客户端,这可以在ConversationService中找到:

    public class ConversationService { 
      public static class LazyHolder { 
        public static final ConversationService INSTANCE =  
            new ConversationService(); 
      } 

     public static ConversationService getInstance() { 
        return LazyHolder.INSTANCE; 
      } 
     private ConversationService() { 
        Configuration configuration = new ResourceConfig() 
                .register(JacksonFeature.class) 
                .register(SseFeature.class); 
        client = ClientBuilder.newClient(configuration); 
     } 

使用所谓的 按需初始化持有者 习语,我们创建了一种类似穷人的单例。由于构造函数是私有的,所以它不能从类外部调用。嵌套的静态类 LazyHolder 只在最终引用时初始化,这发生在对 getInstance() 的第一次调用时。一旦该方法被调用,LazyHolder 就会被加载和初始化,此时构造函数会被运行。创建的实例存储在静态变量中,只要 JVM 运行,它就会存在。后续的每次调用都将返回相同的实例。这对我们来说很重要,因为我们有一些创建成本高昂的对象,以及一些简单的类内缓存:

    protected final Client client; 
    protected final Map<String, Participant> participants =  
      new HashMap<>(); 

在前面的代码中,我们初始化了客户端实例,注册了 JacksonFeature,这为我们提供了之前讨论过的 POJO 映射。我们还注册了 SseFeature,这是 Jersey 的一个更高级的功能,我们将在稍后详细讨论。

我们已经看到了会话列表。这是使用此方法的数据生成的:

    public List<Conversation> getConversations() { 
      List<Conversation> list; 
      try { 
       list = getWebTarget().path("conversations") 
                .request(MediaType.APPLICATION_JSON) 
                .header(HttpHeaders.AUTHORIZATION,  
                    getAuthorizationHeader()) 
                .get(new GenericType<List<Conversation>>() {}); 
       } catch (Exception ce) { 
        list = new ArrayList<>(); 
      } 
      return list; 
    } 
    public WebTarget getWebTarget() { 
    return client.target("http://" 
            + preferences.getPhoneAddress() + ":49152/"); 
    } 

WebTarget 是一个 JAX-RS 类,它表示由资源 URI 标识的 资源目标。我们从偏好设置中获取电话地址,这我们稍后讨论。一旦我们有了 WebTarget,我们就通过添加 conversations 来完成 URI 的构建,指定请求的 MIME 类型,并发出 GET 请求。请注意,我们这里的请求有些乐观,因为我们没有进行任何状态码检查。如果抛出 Exception,我们只需返回一个空的 List

我们已经看到的另一个方法是 getParticipant(),如下所示:

    public Participant getParticipant(String number) { 
      Participant p = participants.get(number); 
      if (p == null) { 
        Response response = getWebTarget() 
                .path("participants") 
                .path(number) 
                .request(MediaType.APPLICATION_JSON) 
                .header(HttpHeaders.AUTHORIZATION,  
                    getAuthorizationHeader()) 
                .get(Response.class); 
        if (response.getStatus() == 200) { 
            p = response.readEntity(Participant.class); 
            participants.put(number, p); 
            if (p.getThumbnail() != null) { 
                File thumb = new File(number + ".png"); 
                try (OutputStream stream =  
                        new FileOutputStream(thumb)) { 
                    byte[] data = DatatypeConverter 
                        .parseBase64Binary(p.getThumbnail()); 
                    stream.write(data); 
                } catch (IOException e) { 
                    e.printStackTrace(); 
                } 
             } 
          } 
       } 
     return p; 
   } 

在最后一个方法中,我们看到我们的缓存开始发挥作用。当请求 Participant 时,我们会查看是否已经获取了这些信息。如果是这样,我们就返回缓存的信息。如果不是,我们可以请求它。

getConversations() 类似,我们为适当的端点构建一个请求,并发送 GET 请求。不过,这次我们确实检查了状态码。只有当状态为 200 (OK) 时,我们才继续处理响应。在这种情况下,我们请求 JAX-RS 返回的 Participant 实例,JacksonFeature 会从 JSON 响应体中为我们构建它,并且我们立即将其添加到我们的缓存中。

如果服务器找到了联系人的缩略图,我们需要处理它。服务器部分,我们将在讨论完这个方法后立即查看,它将缩略图作为 base 64 编码的字符串发送到 JSON 对象的主体中,因此我们将其转换回二进制表示,并将其保存到文件中。请注意,我们正在使用 try-with-resources,所以我们不需要担心清理。

    try (OutputStream stream = new FileOutputStream(thumb)) 

我们还没有看到这个操作的客户端,所以现在让我们看看它。在我们的 Android 应用程序中,Android Studio 的 DeskDroidResource 上有这个方法:

    @GET 
    @Path("participants/{address}") 
    public Response getParticipant(@PathParam("address")  
    String address) { 
      Participant p = null; 
      try { 
        p = getContactsDetails(address); 
        } catch (IOException e) { 
        return Response.serverError().build(); 
       } 
      if (p == null) { 
        return Response.status(Response.Status.NOT_FOUND).build(); 
       } else { 
        return Response.ok(p).build(); 
       } 
    } 

我们尝试构建Participant实例。如果抛出异常,我们返回500(服务器错误)。如果返回null,我们返回404(未找到)。如果找到参与者,我们返回200(OK)和参与者。

要构建参与者,我们需要查询电话联系人。这与 SMS 查询的工作方式几乎相同:

    protected Participant getContactsDetails(String address) throws 
     IOException { 
      Uri contactUri = Uri.withAppendedPath( 
        ContactsContract.PhoneLookup.CONTENT_FILTER_URI,  
        Uri.encode(address)); 
        Cursor phones = deskDroidService.getApplicationContext() 
        .getContentResolver().query(contactUri, 
        new String[]{ 
          ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, 
          "number", 
          ContactsContract.CommonDataKinds.Phone 
            .PHOTO_THUMBNAIL_URI}, 
            null, null, null); 
        Participant participant = new Participant(); 
        if (phones.moveToNext()) { 
          participant.setName(phones.getString(phones 
          .getColumnIndex( 
          ContactsContract.CommonDataKinds.Phone 
           .DISPLAY_NAME))); 
          participant.setPhoneNumber(phones.getString( 
            phones.getColumnIndex("number"))); 
          String image_uri = phones.getString( 
            phones.getColumnIndex( 
              ContactsContract.CommonDataKinds.Phone 
               .PHOTO_THUMBNAIL_URI)); 
          if (image_uri != null) { 
            try (InputStream input = deskDroidService 
              .getApplicationContext().getContentResolver() 
              .openInputStream(Uri.parse(image_uri)); 
            ByteArrayOutputStream buffer = 
              new ByteArrayOutputStream()) { 
                int nRead; 
                byte[] data = new byte[16384]; 

                while ((nRead = input.read(data, 0,  
                        data.length)) != -1) { 
                    buffer.write(data, 0, nRead); 
                } 

                buffer.flush(); 
                participant.setThumbnail(Base64 
                    .encodeToString(buffer.toByteArray(),  
                        Base64.DEFAULT)); 
            } catch (IOException e) { 
                e.printStackTrace(); 
              } 
            } 
        } 
        phones.close(); 
        return participant; 
    } 

前面的查询和光标管理类型与之前我们看到的对话相同,但有一个例外。如果联系人有一个缩略图,查询将返回一个指向该图像的Uri。我们可以使用ContentResolver通过该Uri打开一个InputStream来读取内容,然后将其加载到ByteArrayOutputStream中。使用 Android 的Base64类,我们将这个二进制图像编码成一个String,并将其添加到我们的Participant模型中。我们之前已经看到了这个操作的解码部分。

发送消息

现在我们能够看到我们一直在进行的对话,我们需要添加参与这些对话的能力——发送新的文本消息。我们将从客户端开始。实际上,我们已经在New Message按钮的处理器上看到了。如下所示:

    newMessageBtn.setOnAction(event -> sendNewMessage()); 

我们现在需要查看这个sendNewMessage()方法本身:

    private void sendNewMessage() { 
      Optional<String> result = SendMessageDialogController 
        .showAndWait(conversation.get()); 
      if (result.isPresent()) { 
        Conversation conv = conversation.get(); 
        Message message = new Message(); 
        message.setThreadId(conv.getThreadId()); 
        message.setAddress(conv.getParticipant()); 
        message.setBody(result.get()); 
        message.setMine(true); 
        if (cs.sendMessage(message)) { 
            conv.getMessages().add(message); 
            messages.add(message); 
        } else { 
            Alert alert = new Alert(AlertType.ERROR); 
            alert.setTitle("Error"); 
            alert.setHeaderText( 
                "An error occured while sending the message."); 
            alert.showAndWait(); 
        } 
      } 
    } 

实际的对话框在另一个窗口中显示,因此我们有一个单独的 FXML 文件message_dialog.fxml和控制器SendMessageDialogController。当对话框关闭时,我们检查返回的Optional以查看用户是否输入了消息。如果是这样,按照以下方式处理消息:

  1. 获取所选Conversation的引用。

  2. 创建一条新消息,设置会话 ID、收件人和正文。

  3. 使用ConversationService尝试发送消息:

    1. 如果操作成功,我们将更新用户界面以显示新消息。

    2. 如果操作失败,我们将显示一个错误消息。

SendMessageController的工作方式与我们所查看的其他控制器一样。最有趣的是showAndWait()方法。我们将使用该方法来显示对话框,等待其关闭,并将任何用户响应返回给调用者。对话框如下所示:

该方法如下所示:

    public static Optional<String> showAndWait( 
      Conversation conversation) { 
      try { 
        FXMLLoader loader =  
            new FXMLLoader(SendMessageDialogController.class 
                .getResource("/fxml/message_dialog.fxml")); 
        Stage stage = new Stage(); 
        stage.setScene(new Scene(loader.load())); 
        stage.setTitle("Send Text Message"); 
        stage.initModality(Modality.APPLICATION_MODAL); 
        final SendMessageDialogController controller =  
            (SendMessageDialogController) loader.getController(); 
        controller.setConversation(conversation); 
        stage.showAndWait(); 
        return controller.getMessage(); 
      } catch (IOException ex) { 
          throw new RuntimeException(ex); 
      } 
    } 

在前面的方法中,前几行是我们通常看到的,即创建加载器和Stage。在显示Stage之前,我们设置模式,并传入当前的Conversation。最后,我们调用showAndWait(),此时方法会阻塞,直到用户关闭对话框,然后我们返回输入的消息:

    public Optional<String> getMessage() { 
      return Optional.ofNullable(message); 
    } 

Java 的Optional是一个可能包含或不包含非空值的容器对象message的值可能设置或不设置,这取决于在对话框中点击哪个按钮。使用Optional,我们可以返回一个可能为 null 的值,并在调用者中更安全地处理它——if (result.isPresent())

ConversationService中发送消息是一个简单的 POST 操作,如下所示:

    public boolean sendMessage(Message message) { 
      Response r = getWebTarget().path("conversations") 
        .request() 
        .header(HttpHeaders.AUTHORIZATION, 
          getAuthorizationHeader()) 
        .post(Entity.json(message)); 
      return r.getStatus() == Response.Status.CREATED 
        .getStatusCode(); 
    } 

客户端很简单,但服务器端呢?不出所料,复杂性就在这里:

    @POST 
    @Path("conversations") 
    public Response sendMessage(Message message) throws
    InterruptedException { 
      final SmsManager sms = SmsManager.getDefault(); 
      final ArrayList<String> parts =  
        sms.divideMessage(message.getBody()); 

要添加端点,我们定义一个新的方法,并使用正确的注解。这个方法将监听conversations路径的POST请求,并期望其有效载荷为Message。发送消息的实际工作由SmsManager处理,所以我们获取默认管理器的引用。下一步调用divideMessage(),但这究竟是怎么回事?

文本消息在技术上限制为 160 个字符。Twitter 用户可能已经对这一点有所了解。Twitter 将推文限制为 140 个字符,为发送者姓名留下 20 个字符。虽然 Twitter 一直坚持这个限制,但常规短信用户有更好的体验。如果消息超过 160 个字符,大多数现代手机在发送时会将消息分成 153 个字符的片段(使用 7 个字符的分割信息用于将片段重新组合),如果手机支持,这些片段在接收端会合并成一个消息。SmsManager API 通过divideMessage()为我们处理这个复杂性。

然而,一旦消息被分割,我们的工作就变得有点困难。我们希望能够返回一个状态码,指示消息是否成功发送。为此,我们需要检查消息的每个片段的状态,无论是单个还是十个。使用SmsManager发送短信时,Android 会广播一个带有结果的Intent。为了响应这个,我们需要注册一个接收器。把这些放在一起,我们就得到了以下代码:

    final CountDownLatch sentLatch = new CountDownLatch(parts.size()); 
    final AtomicInteger statusCode = 
      new AtomicInteger( 
        Response.Status.CREATED.getStatusCode()); 
    final BroadcastReceiver receiver = new BroadcastReceiver() { 
      @Override 
      public void onReceive(Context context, Intent intent) { 
        if (getResultCode() != Activity.RESULT_OK) { 
          statusCode.set(Response.Status. 
           INTERNAL_SERVER_ERROR.getStatusCode()); 
        } 
        sentLatch.countDown(); 
      } 
    }; 
    deskDroidService.registerReceiver(receiver,  
      new IntentFilter("com.steeplesoft.deskdroid.SMS_SENT")); 
    ArrayList<PendingIntent> sentPIs = new ArrayList<>(); 
    for (int i = 0; i < parts.size(); i++) { 
      sentPIs.add(PendingIntent.getBroadcast( 
        deskDroidService.getApplicationContext(), 0, 
        new Intent("com.steeplesoft.deskdroid.SMS_SENT"), 0)); 
    } 
    sms.sendMultipartTextMessage(message.getAddress(), null,
    parts, sentPIs, null); 
    sentLatch.await(5, TimeUnit.SECONDS); 
    deskDroidService.unregisterReceiver(receiver); 
    return Response.status(statusCode.get()).build(); 

为了确保我们已经收到了每个消息片段的Intent,我们首先创建一个与消息中片段数量相匹配的CountDownLatch。我们还创建了一个AtomicInteger来保存状态码。我们这样做的原因是我们需要一个可以从我们的BroadcastReceiver访问的最终变量,但我们还需要能够改变它的值。AtomicInteger允许我们这样做。

我们创建并注册了一个BroadcastReceiver,它分析Intent上的结果代码。如果不是Activity.RESULT_OK,我们将statusCode设置为INTERNAL_SERVER_ERROR。无论如何,我们都减少闩锁的计数。

我们的接收器准备就绪后,我们创建一个PendingIntentList,每个片段一个,然后我们将这个列表和我们的消息片段列表一起传递给SmsManager.sendMultipartTextMessage()。消息发送是异步的,所以我们调用sentLatch.await()等待结果返回。我们限制等待时间为五秒,以免永远等待。一旦等待时间到期或闩锁被清除,我们就注销我们的接收器并返回状态码。

获取更新

到目前为止,我们可以看到所有的对话,查看对话中的单个消息,并发送新的消息。但我们目前还无法在设备上收到新消息的更新,所以现在让我们来实现这个功能,从服务器端开始。

为了获得一个持续的事件流,我们将使用一个名为 Server-Sent Events 的功能,这是一个 W3C 规范,用于从服务器接收推送通知。我们在 Jersey 中通过在客户端和服务器设置步骤中注册SseFeature来启用此功能。为了创建一个 SSE 端点,我们指定方法返回的媒体类型为SERVER_SENT_EVENTS,并且我们将EventOutput作为有效负载返回:

    @GET 
    @Path("status") 
    @Produces(SseFeature.SERVER_SENT_EVENTS) 
    @Secure 
    public EventOutput streamStatus() { 
      final EventOutput eventOutput = new EventOutput(); 
      // ... 
      return eventOutput; 
    } 

从 Jersey 文档中,我们了解到如下内容:

在方法返回eventOutput之后,Jersey 运行时识别出这是一个 ChunkedOutput 扩展,并且不会立即关闭客户端连接。相反,它将 HTTP 头写入响应流,并等待发送更多块(SSE 事件)。此时,客户端可以读取头信息,并开始监听单个事件。

然后,服务器保持与客户端的套接字打开,并将数据推送到它。但是数据从哪里来?Server-sent Event 端点创建了一个Thread,将数据写入我们之前创建的EventOutput实例。当Thread完成后,它调用eventOutput.close(),这向运行时发出信号,表明适当地关闭客户端连接是合适的。为了流式传输更新,我们的Thread看起来如下所示:

    final Thread thread = new Thread() { 
      @Override 
      public void run() { 
        final LinkedBlockingQueue<SmsMessage> queue =  
          new LinkedBlockingQueue<>(); 
        BroadcastReceiver receiver = null; 
        try { 
          receiver = new BroadcastReceiver() { 
            @Override 
            public void onReceive(Context context,  
             Intent intent) { 
               Bundle intentExtras = intent.getExtras(); 
               if (intentExtras != null) { 
                 Object[] sms = (Object[])  
                  intentExtras.get("pdus"); 
                 for (int i = 0; i < sms.length; ++i) { 
                   SmsMessage smsMessage =  
                     SmsMessage.createFromPdu( 
                       (byte[]) sms[i]); 
                       queue.add(smsMessage); 
                 } 
               } 
            } 
          }; 
          deskDroidService.registerReceiver(receiver, 
           new IntentFilter( 
             "android.provider.Telephony.SMS_RECEIVED")); 
          while (!eventOutput.isClosed()) { 
            SmsMessage message = queue.poll(5,  
             TimeUnit.SECONDS); 
            while (message != null) { 
              JSONObject json = new JSONObject() 
               .put("participant", message. 
                getDisplayOriginatingAddress()) 
               .put("body", message. 
                getDisplayMessageBody()); 
              eventOutput.write(new OutboundEvent.Builder() 
               .name("new-message") 
               .data(json.toString()) 
               .build() 
              ); 
              message = queue.poll(); 
            } 
          } 
        } catch (JSONException | InterruptedException |  
           IOException e) { 
          } finally { 
              try { 
                if (receiver != null) { 
                  deskDroidService.unregisterReceiver(receiver); 
                } 
                eventOutput.close(); 
              } catch (IOException ioClose) { 
                  // ... 
                } 
            } 
      } 
    }; 
    thread.setDaemon(true); 
    thread.start(); 

正如我们之前看到的,我们设置了一个BroadcastReceiver,在这里注册并在Thread结束时注销,但这次,我们正在监听接收到的短信消息广播。为了确保我们的Thread不在一个小的、紧密的、快速的循环中,这样会迅速耗尽设备的电池,我们使用了LinkedBlockingQueue。当收到消息时,我们从Intent中提取SmsMessage(s),并将它们添加到queue中。在我们的 while 循环中,我们尝试从queuetake()一个项目。如果我们找到一个,我们处理它以及在我们处理过程中可能已经存在于队列中或被添加的项目。一旦queue为空,我们就返回等待。我们在take()上设置了一个超时,以确保线程可以响应退出条件,最值得注意的是,客户端断开连接。只要客户端保持连接,这个操作就会继续。那么,让我们看看客户端。

我们将细节封装在ConversationService.subscribeToNewMessageEvents()中,如下所示:

    public void subscribeToNewMessageEvents( 
      Consumer<Message> callback) { 
        Thread thread = new Thread() { 
          @Override 
          public void run() { 
            stopListening = false; 
            EventInput eventInput = getWebTarget().path("status") 
             .request() 
             .header(HttpHeaders.AUTHORIZATION,  
              getAuthorizationHeader()) 
               .get(EventInput.class); 
            while (!eventInput.isClosed() && !stopListening) { 
              final InboundEvent inboundEvent =  
                eventInput.read(); 
              if (inboundEvent == null) { 
                // connection has been closed 
                break; 
              } 
              if ("new-message".equals(inboundEvent.getName())){ 
                Message message =  
                  inboundEvent.readData(Message.class); 
                if (message != null) { 
                  callback.accept(message); 
                } 
              } 
            } 
          } 
        }; 
        thread.setDaemon(true); 
        thread.start(); 
    } 

在前面的代码中,我们创建了一个Thread,在其中调用 SSE 端点。客户端的返回类型是EventInput。我们循环处理每个传入的事件,我们将其作为InboundEvent获取。如果它是 null,那么连接已经关闭,因此我们退出处理循环。如果不是 null,我们确保事件名称与我们等待的匹配--new-message。如果找到,我们提取事件有效负载,一个Message,并调用我们的回调,我们将其作为Consumer<Message>传递。

从应用程序本身,我们这样订阅状态流:

    cs.subscribeToNewMessageEvents(this::handleMessageReceived); 

handleMessageReceived()看起来是这样的:

    protected void handleMessageReceived(final Message message) { 
      Platform.runLater(() -> { 
        Optional<Conversation> optional = conversations.stream() 
          .filter(c -> Objects.equal(c.getParticipant(),  
           message.getAddress())) 
          .findFirst(); 
        if (optional.isPresent()) { 
          Conversation c = optional.get(); 
          c.getMessages().add(message); 
          c.setSnippet(message.getBody()); 
          convList.refresh(); 
          if (c == conversation.get()) { 
            messages.setAll(c.getMessages()); 
            messageList.scrollTo(messages.size() - 1); 
          } 
        } else { 
            Conversation newConv = new Conversation(); 
            newConv.setParticipant(message.getAddress()); 
            newConv.setSnippet(message.getBody()); 
            newConv.setMessages(Arrays.asList(message)); 
            conversations.add(0, newConv); 
        } 
        final Taskbar taskbar = Taskbar.getTaskbar(); 
        if (taskbar.isSupported(Taskbar.Feature.USER_ATTENTION)) { 
          taskbar.requestUserAttention(true, false); 
        } 
        Toolkit.getDefaultToolkit().beep(); 
      }); 
    } 

处理这条新消息的第一步非常重要--我们向 Platform.runLater() 传递一个 Runnable。如果我们不这样做,任何尝试修改用户界面的操作都将失败。我们已经警告过您。在我们的 Runnable 中,我们创建一个 ConversationStream,使用 filter() 查找与 Message 发送者匹配的 Conversation,然后获取第一个(也是唯一一个)匹配项。

Conversation's last message body). We also ask the Conversation list to refresh() itself to make sure the user interface reflects these changes. Finally, if the Conversation is the currently selected one, we update the message list and scroll to the bottom to make sure the new message shows.

如果我们在列表中没有找到 Conversation,我们创建一个新的,并将其添加到 ConversationObservable 中,这会导致屏幕上的 List 自动更新。

最后,我们尝试执行一些桌面集成任务。如果 Taskbar 支持的 USER_ATTENTION 功能,我们将请求用户注意。从 Javadocs 中我们了解到,根据平台的不同,这可能会在任务区域中以弹跳或闪烁的图标来视觉上表示。无论如何,我们发出蜂鸣声以吸引用户的注意。

安全性

我们还没有讨论的最后一件重要的事情是安全性。目前,任何拥有桌面应用程序的人理论上都可以连接到您的手机,查看您的消息,发送给其他人等等。让我们现在解决这个问题。

保护端点

为了保护 REST 服务器,我们将使用一个与我们在 第二十五章,使用 Monumentum 记笔记 中使用的过滤器一样。我们将首先定义一个注解,该注解将指定哪些端点需要被保护,如下所示:

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

我们将应用此前的注解到每个受保护的端点(为了简洁,将注解压缩到一行):

    @GET @Path("conversations") @Secure 
    public Response getConversations() { 
      ... 
      @POST @Path("conversations") @Secure 
      public Response sendMessage(Message message)  
       throws InterruptedException { 
         ... 
         @GET @Path("status") @Produces(SseFeature.SERVER_SENT_EVENTS)  
         @Secure 
         public EventOutput streamStatus() { 
           ... 
           @GET @Path("participants/{address}") @Secure 
           public Response getParticipant( 
             @PathParam("address") String address) { 
               ... 

我们还需要一个过滤器来强制执行安全性,我们添加如下:

    @Provider 
    @Secure 
    @Priority(Priorities.AUTHENTICATION) 
    public class SecureFilter implements ContainerRequestFilter { 
      private DeskDroidService deskDroidService; 

      public SecureFilter(DeskDroidService deskDroidService) { 
        this.deskDroidService = deskDroidService; 
      } 

      @Override 
      public void filter(ContainerRequestContext requestContext)  
        throws IOException { 
          try { 
            String authorizationHeader = requestContext. 
             getHeaderString(HttpHeaders.AUTHORIZATION); 
            String token = authorizationHeader. 
             substring("Bearer".length()).trim(); 
            final Key key = KeyGenerator. 
             getKey(deskDroidService.getApplicationContext()); 
            final JwtParser jwtParser =  
              Jwts.parser().setSigningKey(key); 
            jwtParser.parseClaimsJws(token); 
          } catch (Exception e) { 
              requestContext.abortWith(Response.status( 
                Response.Status.UNAUTHORIZED).build()); 
            } 
      } 
    } 

类似于 第二十五章,使用 Monumentum 记笔记,我们将使用 JSON Web Tokens (JWT) 来帮助验证和授权客户端。在这个过滤器中,我们从请求头中提取 JWT 并通过以下步骤进行验证:

  1. KeyGenerator 获取签名密钥。

  2. 使用签名密钥创建 JwtParser

  3. 解析 JWT 中的声明。就我们这里的用途而言,这基本上只是对令牌本身的验证。

  4. 如果令牌无效,则使用 UNAUTHORIZED (401) 中断请求。

KeyGenerator 本身看起来有点像我们在 第二十五章,使用 Monumentum 记笔记 中看到的,但已经修改为使用 Android API 以这种方式:

    public class KeyGenerator { 
      private static Key key; 
      private static final Object lock = new Object(); 

      public static Key getKey(Context context) { 
        synchronized (lock) { 
          if (key == null) { 
            SharedPreferences sharedPref =  
              context.getSharedPreferences( 
                context.getString( 
                  R.string.preference_deskdroid),  
                   Context.MODE_PRIVATE); 
                  String signingKey = sharedPref.getString( 
                    context.getString( 
                      R.string.preference_signing_key), null); 
                  if (signingKey == null) { 
                    signingKey = UUID.randomUUID().toString(); 
                    final SharedPreferences.Editor edit =  
                      sharedPref.edit(); 
                    edit.putString(context.getString( 
                      R.string.preference_signing_key), 
                       signingKey); 
                    edit.commit(); 
                  } 
                  key = new SecretKeySpec(signingKey.getBytes(),
                   0, signingKey.getBytes().length, "DES"); 
          } 
        } 

        return key; 
      } 
    } 

由于我们可能同时接收到来自多个客户端的请求,我们需要小心地生成密钥。为了确保它只生成一次,我们将使用我们在服务器启动中看到的相同类型的同步/锁定。

一旦我们获得了锁,我们会进行一个空检查,以查看该过程是否已经生成了(或读取了)密钥。如果没有,然后我们从SharedPreferences读取签名密钥。如果它是 null,我们创建一个随机字符串(这里,只是一个 UUID),并将其保存到SharedPreferences以供下次重用。请注意,为了保存到 Android 首选项,我们必须获取SharedPreferences.Editor的一个实例,写入字符串,然后commit()。一旦我们有了签名密钥,我们就创建实际的SecretKeySpec,我们将使用它来签名和验证我们的 JWT。

处理授权请求

现在我们已经保护了端点,我们需要一种方式让客户端请求授权。为此,我们将公开一个新的未加密端点,如下所示:

    @POST 
    @Path("authorize") 
    @Consumes(MediaType.TEXT_PLAIN) 
    public Response getAuthorization(String clientCode) { 
      if (clientCode != null &&  
        clientCode.equals(deskDroidService.code)) { 
          String jwt = Jwts.builder() 
           .setSubject("DeskDroid") 
           .signWith(SignatureAlgorithm.HS512, 
            KeyGenerator.getKey( 
              deskDroidService.getApplicationContext())) 
               .compact(); 
          LocalBroadcastManager.getInstance( 
            deskDroidService.getApplicationContext()) 
           .sendBroadcast(new Intent( 
               DeskDroidService.CODE_ACCEPTED)); 
        return Response.ok(jwt).build(); 
      } 
      return Response.status(Response.Status.UNAUTHORIZED).build(); 
    } 

我们将实现一个简单的系统,只需要一个随机数,而不是要求一个更复杂的授权系统,可能需要用户名和密码或 OAuth2 提供者:

  1. 在手机上,用户请求添加一个新的客户端,并显示一个随机数。

  2. 在桌面应用程序中,用户输入数字,然后桌面应用程序将其 POST 到服务器。

  3. 如果数字匹配,客户端将获得一个 JWT,它将在每次请求中发送。

  4. 每次验证 JWT,以确保客户端有权访问目标资源。

在这个方法中,我们获取客户端 POST 的数字(我们让 JAX-RS 从请求体中提取),然后将其与手机上生成的数字进行比较。如果它们匹配,我们创建 JWT,并将其返回给客户端。在这样做之前,我们广播一个动作为CODE_ACCEPTED的意图。

这个数字从哪里来,为什么我们要广播这个意图?我们还没有详细查看,但在主布局activity_main.xml中有一个FloatingActionButton。我们将一个onClick监听器附加到它上,如下所示:

    FloatingActionButton fab =  
      (FloatingActionButton) findViewById(R.id.fab); 
    fab.setOnClickListener(new View.OnClickListener() { 
      @Override 
      public void onClick(View view) { 
        startActivityForResult(new Intent( 
          getApplicationContext(),  
          AuthorizeClientActivity.class), 1); 
      } 
    }); 

当用户点击按钮时,以下屏幕将显示:

图片

客户端将使用这些信息来连接并获得授权。Activity本身相当基础。它需要展示 IP 地址和代码,然后响应用户连接。所有这些都在我们的新AuthorizeClientActivity类的onCreate()方法中完成。我们从WifiManager获取 IP:

    WifiManager wifiMgr = (WifiManager) getApplicationContext(). 
     getSystemService(WIFI_SERVICE); 
    String ipAddress = Formatter.formatIpAddress(wifiMgr. 
     getConnectionInfo().getIpAddress()); 

请记住,我们要求客户端在 Wi-Fi 网络上。代码只是一个随机的 6 位数:

    String code = Integer.toString(100000 +  
     new Random().nextInt(900000)); 

为了监听我们之前看到的Intent,它表示客户端已被认证(这,据推测,将在此Activity显示后不久发生),我们注册另一个接收器,如下所示:

    messageReceiver = new BroadcastReceiver() { 
      @Override 
      public void onReceive(Context context, Intent intent) { 
        clientAuthenticated(); 
      } 
    }; 
    LocalBroadcastManager.getInstance(this).registerReceiver( 
      messageReceiver, new IntentFilter( 
        DeskDroidService.CODE_ACCEPTED)); 

我们还需要告诉Service这个新代码是什么,以便它可以验证它。为此,我们按照以下方式广播一个Intent

    Intent intent = new Intent(DeskDroidService.CODE_GENERATED); 
    intent.putExtra("code", code); 
    LocalBroadcastManager.getInstance(this).sendBroadcast(intent); 

我们之前已经在DeskDroidService.onStartCommand()中看到了这个广播的另一部分,其中代码从Intent中检索出来,并存储在服务中以供DeskDroidResource.getAuthorization()使用。

最后,这个处理认证通知的方法,只是简单地清理接收器并关闭Activity

    protected void clientAuthenticated() { 
      LocalBroadcastManager.getInstance(this). 
        unregisterReceiver(messageReceiver); 
      setResult(2, new Intent()); 
      finish(); 
    } 

因此,当客户端连接并成功认证后,Activity关闭,用户返回到主Activity

授权客户端

到目前为止,所有假设都是桌面已经连接到手机。现在我们已经有了足够的组件,可以有意义地讨论这一点。

在应用程序的主菜单中,我们有两个MenuItem连接到手机从手机断开连接连接到手机处理器的样子如下:

    @FXML 
    protected void connectToPhone(ActionEvent event) { 
      ConnectToPhoneController.showAndWait(); 
      if (!preferences.getToken().isEmpty()) { 
        refreshAndListen(); 
      } 
    } 

我们将使用现在熟悉的showAndWait()模式来显示模态对话框,并使用新的ConnectToPhoneController获取响应。用户界面非常简单,如下截图所示:

图片

当用户点击“确定”时,我们将地址和代码保存到应用程序的首选项中,然后尝试如下授权服务器:

    @FXML 
    public void connectToPhone(ActionEvent event) { 
      String address = phoneAddress.getText(); 
      String code = securityCode.getText(); 
      preferences.setPhoneAddress(address); 
      final ConversationService conversationService =  
        ConversationService.getInstance(); 

      conversationService.setPhoneAddress(address); 
      Optional<String> token = conversationService 
        .getAuthorization(code); 
      if (token.isPresent()) { 
        preferences.setToken(token.get()); 
        closeDialog(event); 
      } 
    } 

注意ConversationService.getAuthorization()的返回类型使用了Optional<String>。正如我们之前讨论的,使用Optional使处理可能为null的值更加安全。在这种情况下,如果Optional有值,那么我们已经成功认证。因此,我们将令牌保存到首选项中,并关闭对话框。

实际的认证由ConversationService处理:

    public Optional<String> getAuthorization(String code) { 
      Response response = getWebTarget().path("authorize") 
       .request(MediaType.APPLICATION_JSON) 
       .post(Entity.text(code)); 
      Optional<String> result; 
      if(response.getStatus()==Response.Status.OK.getStatusCode()) { 
        token = response.readEntity(String.class); 
        result = Optional.of(token); 
      } else { 
          result = Optional.empty(); 
      } 
      return result; 
    } 

这个最后的方法通过POST将代码发送到服务器,如果状态码是200,我们创建一个包含返回令牌的Optional。否则,我们返回一个空的Optional

摘要

在本章中,我们构建了一个不同类型的项目。我们之前有在 Android 上运行的应用程序,也有一些在桌面上运行。然而,这个项目同时运行在两个平台上。没有一个是好的,这需要我们以不同的方式构建东西,以确保两者同步。虽然有很多方法可以做到这一点,但我们选择在手机上使用 REST 服务器,桌面作为 REST 客户端。

到本章结束时,我们构建了一个 Android 应用程序,它不仅提供了一个用户界面,还包含一个后台进程(称为Service),并使用 Jersey 及其 Java SE 部署选项将 REST 服务器嵌入到 Android 应用程序中。你还学习了如何使用系统提供的 Content Providers 和平台 API 在 Android 上与文本(SMS)消息交互,并使用服务器发送事件将这些消息流式传输到客户端。我们展示了如何使用Intents、广播和BroadcastReceivers 在 Android 中在进程/线程之间发送消息。最后,在桌面端,我们构建了一个 JavaFX 客户端来显示和发送文本消息,该客户端通过 Jersey REST 客户端连接到手机上的 REST 服务器,并消费服务器发送事件流,根据需要更新用户界面。

由于涉及众多移动部件,这可能是我们项目中最为复杂的。这无疑是我们项目列表的一个圆满结束。在下一章中,我们将探讨 Java 的未来发展方向,以及一些可能值得关注的其它技术。

第三十章:下一步是什么?

最后,我们一起来到了最后一章。我们构建了多种不同类型的应用程序,试图突出和演示 Java 平台的不同部分,特别是那些新加入 Java 9 的部分。正如我们讨论的那样,仅使用 Java 9 的新技术和 API 来编写东西是不可能的,所以我们还看到了来自 Java 7 和 8 的许多有趣的项目。随着 Java 9 终于发布,展望 Java 的未来对我们来说是有意义的,但同时也明智地看看其他语言提供了什么,这样我们才能决定我们的下一个 Java 是否真的是 Java。在这一章中,我们将这样做。

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

  • 回顾我们之前讨论过的主题

  • 我们可以期待的未来

回顾

在展望 Java 10 及其以后之前,让我们快速回顾一下这本书中我们涵盖的一些内容:

  • Java 平台模块系统,可能是这次发布中平台最大的、最受期待的添加。我们看到了如何创建一个模块,并讨论了它对运行时系统的影响。

  • 我们探讨了 Java 9 中的新进程管理 API,学习了如何查看进程,并在需要时终止它们。

  • 我们查看了一些 Java 8 引入的主要功能接口,讨论了它们的使用方法,并展示了使用和未使用这些接口支持的 lambda 表达式时的代码可能看起来如何。

  • 我们详细讨论了 Java 8 的Optional<T>,展示了如何创建类的实例,它暴露的各种方法,以及如何使用它。

  • 我们花了很多时间构建基于 JavaFX 的应用程序,展示了各种技巧和窍门,解决了一些陷阱等问题。

  • 使用 Java NIO 文件和路径 API,我们在文件系统中漫步,寻找重复文件。

  • 我们使用 Java 持久化 API 实现了数据持久化,展示了如何在 Java SE 环境中使用该 API,如何定义实体等。

  • 我们使用 Java 8 日期/时间 API 构建了一个计算器,将功能作为库和命令行实用工具公开。

  • 作为这项工作的一部分,我们简要比较了几种命令行实用工具框架(特别是关注 Crest 和 Airline),然后在 Crest 上定居,并演示了如何创建和消费命令行选项。

  • 虽然我们并不是在每一章都关注它,但我们确实停下来讨论并演示了单元测试。

  • 我们了解了服务提供者接口SPIs)作为提供多个替代实现接口的手段,这些实现可以在运行时动态加载。

  • 我们实现了几个 REST 服务,不仅演示了 JAX-RS 的基本功能,如何在 Java SE 环境中部署它,以及 POJO 映射,还包括一些更高级的功能,如服务器端事件和通过Filter保护端点。

  • 我们构建了几个 Android 应用程序,讨论并展示了活动、片段、服务、内容提供者、异步消息和后台任务。

  • 我们看到了 OAuth2 认证流程的实际操作,包括如何使用 Google OAuth 提供者设置凭据以及驱动此过程的 Java 代码。

  • 我们发现了 JSON Web Tokens,这是一种在客户端和服务器之间安全传输数据的加密方式,例如,我们看到了它们作为认证系统一部分的基本用法。

  • 我们参观了 JavaMail API,了解了一些常见电子邮件协议(如 POP3 和 SMTP)的历史和工作原理。

  • 我们学习了如何使用 Quartz 调度器库进行工作调度。

  • 我们看到了如何以声明性方式指定我们数据中的约束,然后如何使用 Bean Validation API 根据这些约束验证数据。

  • 完全转变方向,我们使用功能丰富的 NetBeans Rich Client Platform 构建了一个中等复杂的应用程序。

  • 我们简要地了解了使用 MongoDB 的世界文档数据库。

  • 我们还学习了依赖注入以及如何使用 CDI 规范来实现它。

这已经是一个相当长的列表,而且还没有涵盖所有内容。本书的一个明确目的是讨论和展示 Java 9 的新特性。Java 9 的发布中包含了近 100 个Java 增强提案JEPs),这使得其中一些特性的展示变得相当困难,但我们已经尽力了。

展望未来

既然 Java 9 已经完成,那么自然而然的问题是,接下来是什么?正如你所预期的那样,在 Java 9 被规划和开发的同时,Oracle、Red Hat、IBM、Azul Systems 等公司的工程师们已经在思考这个问题。虽然很难确定 Java 10 将包含什么(记住,完成模块系统需要三个主要版本),但我们确实有一些目前正在讨论和设计中的项目,希望它们能在下一个版本中发布。在接下来的几页中,我们将探讨其中的一些,以便提前了解作为 Java 开发者,我们的生活可能在几年后是什么样的。

项目 Valhalla

项目 Valhalla 是高级语言-VM 协同开发项目的孵化地。它由 Oracle 工程师 Brian Goetz 领导。截至本文撰写时,Valhalla 有三个计划中的特性。它们是值类型、泛型特化和具现泛型。

值类型

这个努力的目标是更新 Java 虚拟机,如果可能的话,更新 Java 语言,以支持小型、不可变、无身份的值类型。目前,如果你实例化一个新的Object,JVM 会为其分配一个标识符,这使得变量实例可以被引用。

例如,如果你创建一个新的整数,new Integer(42),一个具有java.lang.Integer@68f29546身份的变量,但值为42,这个变量的值永远不会改变,这是我们作为开发者通常关心的所有。然而,JVM 实际上并不知道这一点,因此它必须维护变量的身份,以及所有相关的开销。根据戈茨的说法,这意味着每个对象的实例将需要多达 24 个额外的字节来存储实例。例如,如果你有一个这样的大型数组,那可能是一个需要管理的显著内存量,最终还需要进行垃圾回收。

那么,JVM 工程师希望实现的是一种方法,温和地扩展Java 虚拟机字节码和 Java 语言本身,以支持小而不可变的聚合类型(想象一下具有 0 个或更多属性的一个类)的概念,这种类型没有身份,希望这将导致“内存和局部性高效的编程习惯,而不牺牲封装性”。他们的希望是,Java 开发者能够创建这些新类型,并将它们视为另一个原始类型。戈茨说,如果他们正确地完成工作,这个特性可以总结为像类一样编码,像 int 一样工作!

到 2017 年 4 月为止的当前提案(cr.openjdk.java.net/~jrose/values/shady-values.html)提供了一个代码片段,作为如何定义值类型的示例:

    @jvm.internal.value.DeriveValueType 
    public final class DoubleComplex { 
      public final double re, im; 
      private DoubleComplex(double re, double im) { 
        this.re = re; this.im = im; 
      } 
      ... // toString/equals/hashCode, accessors,
       math functions, etc. 
    } 

当实例化时,这种类型的实例可以在栈上而不是在堆上创建,并且使用更少的内存。这是一个非常底层和技术的讨论,远远超出了本书的范围,但如果你对更多细节感兴趣,我建议阅读前面链接的页面,或者这个努力的初始公告在cr.openjdk.java.net/~jrose/values/values-0.html

泛型特殊化

泛型特殊化可能更容易理解。目前,泛型类型变量只能持有引用类型。例如,你可以创建一个List<Integer>,但不能创建一个List<int>。为什么会有这样的限制,背后有一些相当复杂的原因,但能够使用原始类型和值类型会使集合在内存和计算效率上更加高效。你可以在以下文档中了解更多关于这个特性的信息,再次提到,是布赖恩·戈茨的文档--cr.openjdk.java.net/~briangoetz/valhalla/specialization.html。Jesper de Jong 也在这里详细介绍了泛型类型变量中原始类型的复杂性:

www.jesperdj.com/2015/10/12/project-valhalla-generic-specialization/

实体化泛型

“具体化泛型”这个术语,似乎常常引起非常激烈、生动的反应。目前,如果你声明一个变量为List<Integer>类型,生成的字节码没有对参数化类型的真正理解,因此在运行时无法发现。如果你在运行时检查该变量,你将看不到Integer的提及。当然,你可以查看每个元素的类型,但即使如此,你也不能确定List的类型,因为没有东西强制只允许将Integer添加到List中。

自从 Java 5 引入泛型以来,Java 开发者一直迫切希望实现泛型的具体化,或者说,泛型在运行时保留其类型信息。正如你可能猜到的,使 Java 的泛型具体化并非易事,但最终,我们有了正式的努力来验证它是否可行,如果可行,还要找到一种向后兼容的方法,例如,不产生例如性能下降等负面影响。

项目 Panama

虽然目前尚未针对任何特定的 Java 版本进行目标定位,但项目 Panama 为那些使用或希望使用第三方本地库的人提供了一些希望。目前,将本地库(即用 C 或 C++编写的特定于操作系统的库)暴露给 JVM 的主要方式是通过Java Native Interface(JNI)。JNI 的问题之一(至少是其中之一)是它要求每个希望将本地库暴露给 JVM 的 Java 程序员也成为 C 程序员,这意味着不仅需要 C 语言本身,还需要为每个支持的平台的相关构建工具。

项目 Panama 希望通过为 Java 开发者提供一种新的方式来暴露本地库,而无需深入了解库语言生态系统或 JVM,从而改善上述问题。项目 Panama 的 JEP(openjdk.java.net/jeps/191)列出了以下设计目标:

  • 一个元数据系统,用于描述本地库调用(调用协议、参数列表结构、参数类型、返回类型)以及本地内存结构(大小、布局、类型、生命周期)。

  • 发现和加载本地库的机制。这些功能可能由当前的System.loadLibrary提供,或者可能包括为定位适合主机系统的平台或版本特定的二进制文件提供额外的增强。

  • 基于元数据将特定的库/函数坐标绑定到 Java 端点,很可能是通过用户定义的接口,并由管道支持来实现本地下调用。

  • 基于元数据将特定内存结构(布局、字节序、逻辑类型)绑定到 Java 端点,无论是通过用户定义的接口还是用户定义的类,在这两种情况下都由管道支持来管理实际的本地内存块。

  • 适当的代码来将 Java 数据类型转换为本地数据类型,反之亦然。在某些情况下,这需要创建 FFI 特定的类型来支持 Java 无法表示的位宽和数值符号。

JNI 已经存在一段时间了,它终于得到了一些迟到的关注。

项目 Amber

项目 Amber 的目标是探索和孵化更小、面向生产力的 Java 语言特性。当前列表包括局部变量类型推断、增强枚举和 lambda leftovers。

局部-变量类型推断

就像在这本书中我们无数次看到的那样,当你 Java 中声明一个变量时,你必须声明两次类型,一次在左侧,一次在右侧,加上一个变量名:

    AtomicInteger atomicInt = new AtomicInteger(42); 

这里的问题是这段代码冗长且重复。局部变量类型推断的努力希望解决这个问题,使得代码可以像这样:

    var atomicInt = new AtomicInteger(42); 

这段代码更加简洁,使其更易于阅读。注意val关键字的添加。通常,当编译器看到<type> <name> = ...这样的行时,它知道这是一条变量声明。由于这项工作将消除声明左侧类型的需求,我们需要一个提示编译器,这个提示由本 JEP 的作者提出为var

还有一些关于简化不可变或final变量声明的讨论。建议包括final var以及val,如 Scala 等语言中所示。在撰写本文时,尚未就哪个建议将最终被采纳做出决定。

增强枚举

增强枚举将通过允许枚举中使用类型变量(泛型枚举)并执行对枚举常量的更精确的类型检查来增强 Java 语言中枚举构造的表达能力。这意味着枚举最终将支持参数化类型,允许像这样(从之前提到的链接中的 JEP 中摘取):

    enum Primitive<X> { 
      INT<Integer>(Integer.class, 0) { 
        int mod(int x, int y) { return x % y; } 
        int add(int x, int y) { return x + y; } 
      }, 
      FLOAT<Float>(Float.class, 0f)  { 
        long add(long x, long y) { return x + y; } 
      }, ... ; 

      final Class<X> boxClass; 
      final X defaultValue; 

      Primitive(Class<X> boxClass, X defaultValue) { 
        this.boxClass = boxClass; 
        this.defaultValue = defaultValue; 
      } 
    } 

注意,除了为每个enum值指定一个泛型类型外,我们还可以为每个enum类型定义特定类型的函数。这将使定义一组预定义常量变得更加容易,同时也为每个常量定义类型安全和类型感知的方法。

Lambda leftovers

目前在 Java 8 的 lambda 工作中,有两个被标记为leftovers的项目。第一个是 lambda 声明中未使用参数使用下划线的用法。例如,在这个非常牵强的例子中,我们只关心Map的值:

    Map<String, Integer> numbers = new HashMap<>(); 
    numbers.forEach((k, v) -> System.out.println(v*2)); 

这在 IDE 中导致如下情况:

一旦允许使用下划线,这段代码将看起来像这样:

    numbers.forEach((_, v) -> System.out.println(v*2)); 

这允许更好地检查未使用的变量,使得工具(和开发者)可以更容易地识别这样的参数,并对其进行纠正或标记。

另一个遗留问题是允许 lambda 参数遮蔽封装作用域中的变量。如果你现在尝试这样做,你将得到与尝试在语句块内重新定义变量时相同的错误——变量已定义

    Map<String, Integer> numbers = new HashMap<>(); 
    String key = someMethod(); 
    numbers.forEach((key, value) ->  
      System.out.println(value*2)); // error 

随着这一变化,前面的代码将能够编译并正常运行。

环顾四周

JVM 多年来一直支持替代语言。其中一些较为知名的语言包括 Groovy 和 Scala。多年来,这两种语言在某种程度上都影响了 Java,但像任何语言一样,它们并非没有问题。许多人认为 Groovy 的性能不如 Java(尽管invokedynamic字节码指令本应解决这个问题),许多人发现 Groovy 更动态的特性不太吸引人。另一方面,Scala(无论是否公平,取决于你问谁)给人一种过于复杂的印象。编译时间也是常见的抱怨。此外,许多组织都很高兴地使用这两种语言,因此它们绝对值得考虑,看看它们是否适合你的环境和需求。

虽然这些语言可能很出色,但我们在这里花些时间来看看接下来是什么,至少有两种语言似乎脱颖而出——塞隆和科特林。我们无法对每种语言都进行详尽的介绍,但在接下来的几页中,我们将快速浏览这些语言,看看它们现在为 JVM 开发者提供了什么,也许还能看看它们可能会如何影响 Java 语言的未来变化。

塞隆

由红帽赞助的塞隆语言首次出现在 2011 年左右。由 Hibernate 和 Seam 框架的知名人物 Gavin King 领导的团队着手在语言和库层面解决他们在开发自己的框架和库过程中多年来的痛点。虽然他们承认自己是 Java 语言的无怨无悔的粉丝,但他们也乐于承认这种语言并不完美,特别是在一些标准库方面,并旨在在塞隆中修复这些缺陷。该语言的目标包括可读性、可预测性、可工具化、模块化和元编程(ceylon-lang.org/blog/2012/01/10/goals)。

当你开始使用塞隆时,你可能会注意到最大的不同之一是模块的概念已经内置于语言中。在许多方面,它看起来非常类似于 Java 9 的模块声明,如下所示:

    module com.example.foo "1.0" { 
      import com.example.bar "2.1"; 
    } 

然而,有一个非常明显的区别——塞隆模块确实有版本信息,这允许不同的模块依赖于系统中可能已经存在的模块的不同版本。

塞隆与,比如说,Java 之间至少还有一个相当显著的不同点——塞隆内置了一个构建工具。虽然例如有一个 Maven 插件,但首选的方法是使用塞隆的本地工具来构建和运行项目:

$ ceylonb new hello-world 
Enter project folder name [helloworld]: ceylon-helloworld 
Enter module name [com.example.helloworld]: 
Enter module version [1.0.0]: 
Would you like to generate Eclipse project files? (y/n) [y]: n 
Would you like to generate an ant build.xml? (y/n) [y]: n 
$ cd ceylon-helloworld 
$ ceylonb compile 
Note: Created module com.example.helloworld/1.0.0 
$ ceylonb run com.example.helloworld/1.0.0 
Hello, World! 

除了模块系统,Ceylon 还能为 Java 开发者提供什么?其中一个更直接有用且实用的特性是改进了 null 处理支持。就像我们在 Java 中必须做的那样,在 Ceylon 中我们仍然需要检查 null,但该语言提供了一种更优雅的方法,这一切都始于类型系统。

关于 Scala 的一个抱怨(无论其是否真正有理)是类型系统太复杂。无论你是否同意,似乎很清楚,在 Java 提供的基础上确实有改进的空间(例如,Java 语言架构师也同意,例如,通过提出的局部变量类型推断建议)。Ceylon 为类型系统提供了一个非常强大的补充——联合类型和交叉类型。

联合类型允许一个变量具有多个类型,但一次只能有一个。在讨论 null 时,这一点很重要,String? foo = ... ,这声明了一个类型为 String 的可空变量,实际上等同于 String|Null foo = ...

这声明了一个变量,foo,其类型可以是 StringNull,但不能同时是两者。? 语法只是联合类型声明(A | BAB)的语法糖。如果我们有一个方法,那么它接受这个联合类型;我们知道该变量是可空的,因此我们需要使用以下代码片段进行检查:

    void bar (String? Foo) { 
      if (exists foo) { 
        print (foo); 
      } 
    } 

由于这是一个联合类型,我们也可以这样做:

    void bar (String? Foo) { 
      if (is String foo) { 
        print (foo); 
      } 
    } 

注意,一旦我们使用 existsis 进行测试,我们就可以假设该变量不是 null,并且是 String 类型。编译器不会报错,我们也不会在运行时遇到意外的 NullPointerException(在 Ceylon 中实际上不存在,因为编译器要求你在处理可空变量时非常明确)。这种编译器对 null 和类型检查的感知称为 流敏感 类型。一旦你验证了某个类型,编译器就会知道并记住,换句话说,在整个作用域的剩余部分都会记住这个检查的结果,这样你可以编写更干净、更简洁的代码。

联合类型要么是 A 或 B,而交叉类型则是 A B。为了一个完全随机的例子,让我们假设你有一个方法,其参数必须是 Serializable Closeable。在 Java 中,你将不得不手动检查,如下所示:

    public void someMethod (Object object) { 
      if (!(object instanceof Serializable) ||  
        !(object instanceof Closeable)) { 
        // throw Exception 
      } 
    } 

使用交叉类型,Ceylon 允许我们这样写:

    void someMethod(Serializable&Closeable object) { 
      // ... 
    } 

如果我们尝试用没有实现 两个 接口的东西调用那个方法,或者说,扩展一个类并实现另一个接口,那么我们会在 编译时 得到错误。这非常强大。

在企业中采用新语言或库之前,人们通常会看看其他人是否在使用它。是否有值得注意的采用案例?是否有其他公司对这项技术有足够的信心,用它来构建生产系统?不幸的是,Ceylon 网站(在撰写本文时)在 Red Hat 之外的使用细节非常少,所以很难回答这个问题。然而,Red Hat 正在投入大量资金设计这种语言,并围绕它构建工具和社区,所以这应该是一个安全的赌注。当然,这是一个企业经过仔细考虑后必须做出的决定。你可以在ceylon-lang.org了解更多关于 Ceylon 的信息。

Kotlin

另一种新兴的语言是 Kotlin。它是由 IntelliJ IDEA 的制造商 JetBrains 开发的一种静态类型语言,旨在针对 JVM 和 JavaScript。它甚至为那些环境(如 iOS、嵌入式系统等)提供了将代码直接编译成机器代码的支持,在这些环境中不希望或允许使用虚拟机。

Kotlin 于 2010 年开始开发,并于 2012 年开源,旨在解决 JetBrains 在大型 Java 开发中遇到的一些常见问题。在调查了当时现有的语言环境后,他们的工程师认为当时没有哪种语言能够充分解决他们的担忧。多年来被许多人视为“下一个 Java”的 Scala,尽管功能集可接受,但在编译速度上被认为太慢,因此 JetBrains 开始设计他们自己的语言,最终于 2016 年 2 月发布了 1.0 版本。

Kotlin 团队的设计目标包括表达性、可扩展性和互操作性。他们希望通过语言和库功能,让开发者用更少的代码以更清晰的方式完成更多的工作,并且在一个 100%与 Java 互操作的语言中。他们添加了诸如协程等特性,以使基于 Kotlin 的系统能够快速且容易地扩展。

说了这么多,Kotlin 是什么样的,为什么作为 Java 开发者我们应该感兴趣呢?让我们从变量开始。

如你所回忆的,Java 既有原始类型(intdoublefloatchar等)也有引用类型或包装器类型(IntegerDoubleFloatString等)。正如我们在本章中讨论的,JVM 工程师正在研究方法来改善这种二分法带来的行为和能力差异。Kotlin 完全避免了这种情况,因为每个值都是一个对象,所以无需担心List<int>List<Integer>之间的区别。

此外,Kotlin 已经支持局部变量类型推断以及不可变性。例如,以下 Java 代码可以作为示例:

    Integer a = new Integer(1); 
    final String s = "This is a string literal"; 

上述代码行在 Kotlin 中可以写成这样:

    var a = 1; 
    val s = "This is a string literal"; 

注意varval关键字的使用。如前所述,关于未来 Java 语言的变化,这些关键字允许我们声明可变和不可变变量(分别)。此外,请注意,我们不需要声明变量的类型,因为编译器会为我们处理。在某些情况下,我们可能需要显式声明类型,例如,在编译器可能猜错或没有足够信息进行猜测的情况下,此时,它将停止编译并显示错误信息。在这些情况下,我们可以这样声明类型:

    var a: Int  = 1; 
    val s: String = "This is a string literal"; 

如我们所见,随着 Java 8 的推出,我们有Optional<T>来帮助处理 null 值。Kotlin 也有 null 支持,但它内置在语言中。默认情况下,Kotlin 中的所有变量都不是可空的。也就是说,如果你尝试将 null 值赋给变量,或者编译器无法确定值是否可能为 null(例如,Java API 的返回值),你将得到编译器错误。要表示一个值是可空的,你可以在变量声明中添加一个?,如下所示:

    var var1 : String = null; // error 
    var var2 : String? = null; // ok 

Kotlin 还在方法调用中提供了改进的 null 处理支持。例如,假设你想获取一个用户的 city。在 Java 中,你可能这样做:

    String city = null; 
    User user = getUser(); 
    if (user != null) { 
      Address address = user.getAddress(); 
      if (address != null) { 
        city address.getCity(); 
      } 
    } 

在 Kotlin 中,这可以表达为以下单行代码:

    var city : String? = getUser()?.getAddress()?.getCity(); 

如果在任何时候,其中一个方法返回 null,方法调用链将结束,并将 null 赋值给变量 city。Kotlin 在处理 null 方面并不止步于此。例如,它提供了一个let函数,可以作为 if-not-null 检查的快捷方式。例如,考虑以下代码行:

    if (city != null) { 
      System.out.println(city.toUpperCase()); 
    } 

上述代码行在 Kotlin 中变为以下形式:

    city?.let { 
      println(city.toUpperCase()) 
    } 

当然,这可以写成city?.toUpperCase()。但这应该展示的是在任意大、复杂的代码块中安全使用可空变量的能力。还值得注意的是,在let块内部,编译器知道city不是 null,因此不需要进一步的 null 检查。

在前面的例子中,可能隐藏着 Kotlin 对 lambda 的支持,没有这种支持,似乎现代语言就失去了考虑的价值。Kotlin 确实完全支持 lambda、高阶函数、下划线作为 lambda 参数名等。它的支持和语法与 Java 非常相似,因此 Java 开发者应该对 Kotlin 的 lambda 感到非常舒适。

当然,最大的问题是Kotlin 是否已经准备好进入主流使用阶段了?JetBrains 肯定是这样认为的,因为他们已经在许多内部和外部应用中使用它。其他知名用户包括 Pinterest、Gradle、Evernote、Uber、Pivotal、Atlassian 和 Basecamp。Kotlin 甚至被 Google(在 Android Studio 中)官方支持用于 Android 开发,所以它绝对是一种生产级语言。

当然,关于这门伟大的新语言,还有很多很多内容,空间有限,我们无法全部讨论,但你可以浏览kotlinlang.org来了解更多信息,并看看 Kotlin 是否适合你的组织。

摘要

当然,关于 Java 10 以及这两种语言,以及围绕 Java 虚拟机发生的众多其他项目,还有很多可以讨论的内容。经过超过 20 年的发展,Java——这门语言以及其环境——仍然势头强劲。在这本书的篇章中,我试图展示语言中的一些重大进步,为你自己的项目提供各种起点,提供供你学习和重用的示例代码,以及解释各种库、API 和技术,这些可能在你的日常工作中有所帮助。我希望你像我准备这些例子和解释一样喜欢它们,更重要的是,我希望它们能帮助你构建下一个大项目。

祝你好运!

第三十一章:参考书目

这条学习路径结合了 Packt 提供的一些最佳内容,以一个完整、精心挑选的套餐形式呈现。它包括以下 Packt 产品的内容:

  • 精通 Java 9,作者:爱德华·拉维耶里博士和彼得·韦哈斯

  • Java 9 编程蓝图,作者:杰森·李

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