Java9-编程蓝图-全-

Java9 编程蓝图(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

世界已经等待 Java 9 很长时间了。更具体地说,我们一直在等待 Java 平台模块系统,而 Java 9 终于要推出了。如果一切顺利,我们最终将拥有真正的隔离,这可能会带来更小的 JDK 和更稳定的应用程序。当然,Java 9 提供的不仅仅是这些;在这个版本中有大量的重大变化,但这无疑是最令人兴奋的。话虽如此,这本书并不是一本关于模块系统的书。有很多优秀的资源可以让您深入了解 Java 平台模块系统及其许多影响。不过,这本书更多地是一个对 Java 9 的实际观察。与其讨论发布的细枝末节,尽管那样也很令人满意,但在接下来的几百页中,我们将看到最近 JDK 发布中的所有重大变化--特别是 Java 9--如何以实际的方式应用。

当我们完成时,您将拥有十个不同的项目,涵盖了许多问题领域,您可以从中获取可用的示例,以解决您自己独特的挑战。

本书内容

第一章,《介绍》,快速概述了 Java 9 的新功能,并介绍了 Java 7 和 8 的一些主要功能,为我们后面章节的使用做好铺垫。

第二章,《在 Java 中管理进程》,构建了一个简单的进程管理应用程序(类似于 Unix 的 top 命令),我们将探索 Java 9 中新的操作系统进程管理 API 的变化。

第三章,《重复文件查找器》,演示了在应用程序中使用新的文件 I/O API,包括命令行和 GUI,用于搜索和识别重复文件。大量使用了文件哈希、流和 JavaFX 等技术。

第四章,《日期计算器》,展示了一个库和命令行工具来执行日期计算。我们将大量使用 Java 8 的日期/时间 API。

第五章,《Sunago-社交媒体聚合器》,展示了如何与第三方系统集成以构建一个聚合器。我们将使用 REST API、JavaFX 和可插拔应用程序架构。

第六章,《Sunago-Android 移植》,让我们回到了第五章中的应用程序,《Sunago-社交媒体聚合器》。

第七章,《使用 MailFilter 管理电子邮件和垃圾邮件》,构建了一个邮件过滤应用程序,解释了各种电子邮件协议的工作原理,然后演示了如何使用标准的 Java 电子邮件 API--JavaMail 与电子邮件进行交互。

第八章,《使用 PhotoBeans 管理照片》,当我们使用 NetBeans Rich Client Platform 构建一个照片管理应用程序时,我们将走向完全不同的方向。

第九章,《使用 Monumentum 记笔记》,又开辟了一个新方向。在这一章中,我们构建了一个提供基于 Web 的记笔记的应用程序和微服务,类似于一些流行的商业产品。

第十章,《无服务器 Java》,将我们带入云端,我们将在 Java 中构建一个函数作为服务系统,用于发送基于电子邮件和短信的通知。

第十一章,《DeskDroid-用于 Android 手机的桌面客户端》,演示了一个与 Android 设备交互的桌面客户端的简单方法,我们将构建一个应用程序,从桌面查看并发送短信。

第十二章,接下来是什么?,讨论了 Java 的未来可能会带来什么,并且还涉及了 Java 在 JVM 上的两个最近的挑战者-Ceylon 和 Kotlin。

您需要为这本书做好准备

您需要 Java 开发工具包(JDK)9、NetBeans 8.2 或更新版本,以及 Maven 3.0 或更新版本。一些章节将需要额外的软件,包括 Gluon 的 Scene Builder 和 Android Studio。

这本书是为谁准备的

这本书适用于初学者到中级开发人员,他们有兴趣在实际示例中看到新的和多样化的 API 和编程技术。不需要深入了解 Java,但假定您对语言及其生态系统、构建工具等有基本了解。

约定

在本书中,您会发现一些区分不同信息种类的文本样式。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“Java 架构师引入了一个新文件,module-info.java,类似于现有的 package-info.java 文件,位于模块的根目录,例如在 src/main/java/module-info.java。”

代码块设置如下:

    module com.steeplesoft.foo.intro {
      requires com.steeplesoft.bar;
      exports com.steeplesoft.foo.intro.model;
      exports com.steeplesoft.foo.intro.api;
    }

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

$ mvn -Puber install

新术语重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会出现在文本中,如下所示:“在新项目窗口中,我们选择 Maven 然后 NetBeans 应用程序。”

警告或重要说明会出现在这样的地方。

提示和技巧会出现如下。

读者反馈

我们始终欢迎读者的反馈。让我们知道您对这本书的看法-您喜欢或不喜欢什么。读者的反馈对我们很重要,因为它有助于我们开发出您真正受益的标题。

要向我们发送一般反馈,只需发送电子邮件至 feedback@packtpub.com,并在主题中提及书名。

如果您在某个专题上有专长,并且有兴趣撰写或为一本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

既然您已经是 Packt 图书的自豪所有者,我们有一些东西可以帮助您充分利用您的购买。

下载示例代码

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

您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”选项卡上。

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

  4. 在搜索框中输入书名。

  5. 选择您要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的地点。

  7. 点击“代码下载”。

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

  • WinRAR / 7-Zip 适用于 Windows

  • Zipeg / iZip / UnRarX 适用于 Mac

  • 7-Zip / PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/Java-9-Programming-Blueprints。我们还有其他丰富的图书和视频代码包可供下载github.com/PacktPublishing/。快去看看吧!

下载本书的彩色图片

我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/Java9ProgrammingBlueprints_ColorImages下载此文件。

勘误

尽管我们已经非常小心确保内容的准确性,但错误是难免的。如果您在我们的书中发现错误,也许是文本或代码中的错误,我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata报告,选择您的书,点击勘误提交表链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的勘误部分下的任何现有勘误列表中。

查看先前提交的勘误表,请访问www.packtpub.com/books/content/support并在搜索框中输入书名。所需信息将显示在勘误部分下方。

问题

盗版

请通过copyright@packtpub.com与我们联系,并附上涉嫌盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

在互联网上盗版受版权保护的材料是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上以任何形式发现我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

如果您对本书的任何方面有问题,可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:介绍

在建造新建筑的过程中,一套蓝图帮助所有相关方进行沟通--建筑师、电工、木工、管道工等等。它详细说明了形状、大小和材料等细节。如果没有这些蓝图,每个分包商都将被迫猜测该做什么、在哪里做以及如何做。没有这些蓝图,现代建筑几乎是不可能的。

你手中的--或者你面前屏幕上的--是一套不同类型的蓝图。与其详细说明如何构建你特定的软件系统,因为每个项目和环境都有独特的约束和要求,这些蓝图提供了如何构建各种基于 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 和流。随着函数式编程在 JVM 世界中日益流行,尤其是在 Scala 等语言的帮助下,Java 的拥护者多年来一直在呼吁更多的函数式语言特性。最初计划在 Java 7 中发布,该功能在那个版本中被删除,最终在 Java 8 中稳定发布。

虽然可以希望每个人都熟悉 Java 的 lambda 支持,但经验表明,出于各种原因,许多公司都很慢地采用新的语言版本和特性,因此快速介绍可能会有所帮助。

Lambda

lambda 这个术语源自 1936 年由阿隆佐·邱奇开发的λ演算,简单地指的是匿名函数。通常,函数(或者更正式的 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。以下是使用 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可能看起来的样子,以及为什么人们可能希望将新定义的Runnable方法分配给一个变量,尽管 lambda 提供了简洁性。这种新的 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 开发人员多年来一直在使用的 SAM 之外,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 流是单子的答案,它们可能都是正确的。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 和 streams 是非常重要的改变性更新,但是在 Java 8 中,我们得到了另一个期待已久的改变,至少在某些领域中同样令人兴奋:一个新的日期/时间 API。任何在 Java 中使用日期和时间的人都知道java.util.Calendar等的痛苦。显然,你可以完成工作,但并不总是美观的。许多开发人员发现 API 太痛苦了,所以他们将极其流行的 Joda Time 库集成到他们的项目中。Java 的架构师们同意了,并邀请了 Joda Time 的作者 Stephen Colebourne 来领导 JSR 310,这将 Joda Time 的一个版本(修复了各种设计缺陷)引入了平台。我们将在本书后面详细介绍如何在我们的日期/时间计算器中使用一些这些新的 API。

默认方法

在我们将注意力转向 Java 9 之前,让我们再看看另一个重要的语言特性:默认方法。自 Java 开始以来,接口被用来定义类的外观,暗示一种特定的行为,但无法实现该行为。在许多情况下,这使得多态性变得更简单,因为任意数量的类都可以实现给定的接口,并且消费代码将它们视为该接口,而不是它们实际上是什么具体类。

多年来,API 开发人员面临的问题之一是如何在不破坏现有代码的情况下发展 API 及其接口。例如,考虑 JavaServer Faces 1.1 规范中的ActionSource接口。当 JSF 1.2 专家组在制定规范的下一个修订版时,他们确定需要向接口添加一个新属性,这将导致两个新方法——getter 和 setter。他们不能简单地将方法添加到接口中,因为那样会破坏规范的每个实现,需要实现者更新他们的类。显然,这种破坏是不可接受的,因此 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 平台模块系统/项目 Jigsaw

尽管 Java 8 是一个功能丰富的稳定版本,但许多人认为它有点令人失望。它缺乏备受期待的Java 平台模块系统JPMS),也更为通俗,尽管不太准确地称为项目 Jigsaw。Java 平台模块系统最初计划在 2011 年的 Java 7 中发布,但由于一些悬而未决的技术问题,它被推迟到了 Java 8。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 Enhancement ProposalJEP)102 的一部分,Java 进程 API 被扩展了以下功能(引用自 JEP 文本):

  • 获取当前 Java 虚拟机的 pid(或等效值)以及使用现有 API 创建的进程的 pid。

  • 枚举系统上的进程的能力。每个进程的信息可能包括其 pid、名称、状态,以及可能的资源使用情况。

  • 处理进程树的能力;特别是一些销毁进程树的方法。

  • 处理数百个子进程的能力,可能会将输出或错误流多路复用,以避免为每个子进程创建一个线程。

我们将在我们的第一个项目中探索这些 API 的变化,即进程查看器/管理器(详细信息请参见以下各节)。

并发变化

与 Java 7 中所做的一样,Java 架构师重新审视了并发库,做出了一些非常需要的改变,这一次是为了支持反应式流规范。这些变化包括一个新的类,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 开发人员快速原型和测试他们的想法。

项目

通过这个简短而高层次的概述,我们可以看到有哪些新功能可以使用,那么我们将要涵盖的这些蓝图是什么样的呢?我们将构建十个不同的应用程序,涉及各种复杂性和种类,并涵盖各种关注点。在每个项目中,我们将特别关注我们正在突出的新功能,但我们也会看到一些旧的、经过验证的语言特性和广泛使用的库,其中任何有趣或新颖的用法都会被标记出来。因此,这是我们的项目阵容。

进程查看器/管理器

当我们实现一个 Java 版本的古老的 Unix 工具——top时,我们将探索一些进程处理 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 处理

  • 操作系统集成

  • 文件 I/O

JavaFX 照片管理

Java 开发工具包有一个非常强大的图像处理 API。在 Java 9 中,这些 API 得到了改进,增强了对 TIFF 规范的支持。在本章中,我们将使用这个 API 创建一个图像/照片管理应用程序。我们将添加支持从用户指定的位置导入图像到配置的官方目录。我们还将重新访问重复文件查找器,并重用作为项目一部分开发的一些代码,以帮助我们识别重复的图像。

这个项目将涵盖以下内容:

  • 新的javax.imageio

  • JavaFX

  • NetBeans 丰富的客户端平台

  • Java 文件 I/O

客户端/服务器笔记应用程序

您是否曾经使用过基于云的笔记应用?您是否想知道制作自己的笔记应用需要什么?在本章中,我们将创建这样一个应用程序,包括完整的前端和后端。在服务器端,我们将把数据存储在备受欢迎的文档数据库 MongoDB 中,并通过 REST 接口公开应用程序的业务逻辑的适当部分。在客户端,我们将使用 JavaScript 开发一个非常基本的用户界面,让我们可以尝试并演示如何在我们的 Java 项目中使用 JavaScript。

该项目将涵盖以下内容:

  • 文档数据库(MongoDB)

  • JAX-RS 和 RESTful 接口

  • JavaFX

  • JavaScript 和 Vue 2

无服务器 Java

无服务器,也被称为函数即服务FaaS),是当今最热门的趋势之一。这是一种应用/部署模型,其中一个小函数部署到一个服务中,该服务几乎管理函数的每个方面——启动、关闭、内存等,使开发人员不必担心这些细节。在本章中,我们将编写一个简单的无服务器 Java 应用程序,以了解如何完成,以及如何在自己的应用程序中使用这种新技术。

该项目将涵盖以下内容:

  • 创建 Amazon Web Services 账户

  • 配置 AWS Lambda、简单通知服务、简单邮件服务和 DynamoDB

  • 编写和部署 Java 函数

Android 桌面同步客户端

通过这个项目,我们将稍微改变方向,专注于 Java 生态系统的另一个部分:Android。为了做到这一点,我们将专注于一个仍然困扰一些 Android 用户的问题——Android 设备与桌面(或笔记本电脑)系统的同步。虽然各种云服务提供商都在推动我们将更多内容存储在云端并将其流式传输到设备上,但一些人仍然更喜欢直接在设备上存储照片和音乐,原因各种各样,从云资源成本到不稳定的无线连接和隐私问题。

在本章中,我们将构建一个系统,允许用户在他们的设备和桌面或笔记本电脑之间同步音乐和照片。我们将构建一个 Android 应用程序,提供用户界面来配置和监视从移动设备端进行同步,以及在后台执行同步的 Android 服务(如果需要)。我们还将在桌面端构建相关组件——一个图形应用程序来配置和监视来自桌面端的同步过程,以及一个后台进程来处理来自桌面端的同步。

该项目将涵盖以下内容:

  • Android

  • 用户界面

  • 服务

  • JavaFX

  • REST

入门

我们已经快速浏览了一些我们将要使用的新语言特性。我们也简要概述了我们将要构建的项目。最后一个问题仍然存在:我们将使用什么工具来完成我们的工作?

当涉及到开发工具时,Java 生态系统拥有丰富的选择,因此我们有很多选择。我们面临的最基本的选择是构建工具。在这里,我们将使用 Maven。虽然有一个强大而有声望的社区支持 Gradle,但 Maven 似乎是目前最常见的构建工具,并且似乎得到了主要 IDE 的更健壮、更成熟和更本地的支持。如果您尚未安装 Maven,您可以访问maven.apache.org并下载适合您操作系统的分发版,或者使用您的操作系统支持的任何软件包管理系统。

对于 IDE,所有的截图、指导等都将使用 NetBeans——来自 Oracle 的免费开源 IDE。当然,也有 IntelliJ IDEA 和 Eclipse 的支持者,它们都是不错的选择,但是 NetBeans 提供了一个完整而强大的开发工具,并且快速、稳定且免费。要下载 NetBeans,请访问netbeans.org并下载适合您操作系统的安装程序。由于我们使用 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、streams、新的日期/时间包和默认方法。从 Java 9 开始,我们快速浏览了 Java 平台模块系统和项目 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 9 开始,这些功能将得到扩展。引用Java Enhancement ProposalJEP)中的内容,我们可以看到为此新功能的以下原因:

许多企业应用程序和容器涉及多个 Java 虚拟机和进程,并且长期以来一直需要以下功能:

  • 获取当前 Java 虚拟机的 pid(或等效值)以及使用现有 API 创建的进程的 pid 的能力。

  • 枚举系统上的进程的能力。每个进程的信息可能包括其 pid、名称、状态,以及可能的资源使用情况。

  • 处理进程树的能力,特别是一些销毁进程树的方法。

  • 处理数百个子进程的能力,可能是复用输出或错误流以避免为每个子进程创建一个线程。

在本章中,我们将构建一个简单的进程管理器应用程序,类似于 Windows 任务管理器或*nix 的 top。当然,在 Java 中没有必要编写进程管理器,但这将是我们探索这些新的进程处理 API 的绝佳途径。此外,我们还将花一些时间研究其他语言功能和 API,即 JavaFX 和Optional

本章涵盖以下主题:

  • 创建项目

  • 引导应用程序

  • 定义用户界面

  • 初始化用户界面

  • 添加菜单

  • 更新进程列表

说了这么多,让我们开始吧。

创建项目

通常来说,如果可以在不需要特定 IDE 或其他专有工具的情况下重现构建,那将会更好。幸运的是,NetBeans 提供了创建基于 Maven 的 JavaFX 项目的能力。点击文件 | 新建项目,然后选择Maven,然后选择 JavaFX 应用程序:

接下来,执行以下步骤:

  1. 点击下一步。

  2. 将项目名称输入为ProcessManager

  3. 将 Group 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()方法来自接口,并且在调用FXMLLoader.load()时由 JavaFX 运行时初始化控制器。请注意@FXML注解在实例变量processList上。当 JavaFX 初始化控制器时,在调用initialize()方法之前,系统会查找指定了fx:id属性的 FXML 元素,并将该引用分配给控制器中适当的实例变量。为了完成这种连接,我们必须对我们的 FXML 文件进行一些更改:

    <TableView fx:id="processList" BorderPane.alignment="CENTER">
    ...

更改也可以在 Scene Builder 中进行,如下面的截图所示:

fx:id 属性的值必须与已用@FXML注释注释的实例变量的名称匹配。当调用initialize时,processList将具有对我们在 Java 代码中可以操作的TableView的有效引用。

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>"; 
    } 

如果开发人员未明确检查 null,几乎肯定会在某个时候发生NullPointerException。通过使用Optional<T>,API 作者向用户发出信号,表明返回值可能为 null,应该小心处理。然后,更新后的代码可能看起来像这样:

    String command = processHandle.info().command() 
     .orElse("<unknown>"); 

现在,我们可以用一行简洁的代码来获取值,如果存在的话,或者获取默认值,如果不存在的话。正如我们将在后面看到的,ProcessHandle.Info API 广泛使用了这种构造方式。

作为开发人员,Optional还为我们提供了一些实例方法,可以帮助澄清处理 null 的代码:

  • filter(Predicate<? super T> predicate): 使用这个方法,我们可以过滤Optional的内容。我们可以将filter()方法传递一个Predicate,而不是使用if...else块,并在内联进行测试。Predicate是一个接受输入并返回布尔值的@FunctionalInterface。例如,JavaFX 的Dialog的一些用法可能返回Optional<ButtonType>。如果我们只想在用户点击了特定按钮时执行某些操作,比如 OK,我们可以这样过滤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())); 

请注意,在upper2Optional的双重包装。如果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对象包含一个值,则简单地返回 true。

  • 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对象。如果该值为 null,则抛出NullPointerException

  • ofNullable(T value): 这返回一个描述该值的Optional对象。如果该值为 null,则返回一个空的Optional

通过这个非常简短的介绍,我们可以看到Optional<T>的存在是如何影响我们的应用程序的。

然后,我们的第一步是获取要显示的进程列表。流 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()TableView上设置ObservableList,然后通过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条目,然后单击删除)。要创建我们想要的菜单项,我们将适当的MenuItem条目添加到File元素下的item元素中:

    <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(); 
         } 
       }); 
    } 

我们这里有很多事情要做。我们首先要做的是创建一个CONFIRMATION类型的Alert对话框,询问用户确认请求。对话框有两个按钮:YESNO。一旦对话框被创建,我们调用showAndWait(),它会显示对话框并等待用户的响应。它返回Optional<ButtonType>,其中包含用户点击的按钮的类型,可能是ButtonType.YESButtonType.NO,根据我们创建的Alert对话框的类型。有了Optional,我们可以应用filter()来找到我们感兴趣的按钮类型,即ButtonType.YES,其结果是另一个Optional。如果用户点击了 yes,ifPresent()将返回 true(感谢我们的过滤器),并且我们传递的 lambda 将被执行。非常好而简洁。

接下来感兴趣的是 lambda。一旦我们确定用户想要终止一个进程,我们需要确定哪个进程要终止。为此,我们通过TableView.getSelectionModel().getSelectedItem()询问TableView选择了哪一行。我们确实需要检查是否为 null(遗憾的是,这里没有Optional),以防用户实际上没有选择行。如果它不是 null,我们可以在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 体所掩盖或抹去。在这些情况下,将代码提取到一个方法中并使用方法引用可能是明智的做法。最终,决定权在你手中,但请记住鲍勃·马丁叔叔的话--清晰是王道

关于菜单的最后一项。为了更加实用,应用程序应该提供一个上下文菜单,允许用户右键单击一个进程并从那里结束它,而不是点击行,将鼠标移动到“文件”菜单等。添加上下文菜单是一个简单的操作。我们只需要修改我们在 FXML 中的TableView定义如下:

    <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关键字。这是为了防止由于从多个线程调用此方法而可能引起的任何竞争条件。想象一下,用户决定终止一个进程并在线程在恢复时点击确认对话框的确切时刻(这种情况比你想象的要常见)。我们可能会有两个线程同时调用updateList(),导致第一个线程在第二个线程调用processList.setAll()时刚好调用processView.sort()。当在另一个线程重建列表时调用sort()会发生什么?很难说,但可能是灾难性的,所以我们要禁止这种情况。synchronized关键字指示 JVM 一次只允许一个线程执行该方法,导致其他线程排队等待(请注意,它们的执行顺序是不确定的,所以你不能根据线程运行synchronized方法的顺序来做任何期望)。这避免了竞争条件的可能性,并确保我们的程序不会崩溃。

虽然在这里是合适的,但在使用synchronized方法时必须小心,因为获取和释放锁可能是昂贵的(尽管在现代 JVM 中要少得多),更重要的是,它强制线程在调用这个方法时按顺序运行,这可能会导致应用程序出现非常不希望的延迟,特别是在 GUI 应用程序中。在编写自己的多线程应用程序时要记住这一点。

摘要

有了这个,我们的应用程序就完成了。虽然不是一个非常复杂的应用程序,但它包括了一些有趣的技术,比如 JavaFX、Lambda、Streams、ProcessHandle以及相关的类和线程。

在下一章中,我们将构建一个简单的命令行实用程序来查找重复文件。通过这样做,我们将亲身体验新的文件 I/O API、Java 持久化 API(JPA)、文件哈希和一些更多的 JavaFX。

第三章:重复文件查找器

任何运行了一段时间的系统都会开始受到硬盘杂乱的影响。例如,大型音乐和照片收藏品尤其如此。除了最一丝不苟地复制和移动文件之外,我们最终会在这里复制一份,在那里复制一份。问题是,这些中哪些是重复的,哪些不是?在本章中,我们将构建一个文件遍历实用程序,它将扫描一组目录,寻找重复的文件。我们将能够指定是否应删除重复项,将其隔离,或者只是报告。

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

  • Java 平台模块系统

  • Java NIO(New I/O)文件 API

  • 文件哈希

  • Java 持久性 API(JPA)

  • 新的 Java 日期/时间 API

  • 编写命令行实用程序

  • 更多的 JavaFX

入门

这个应用程序在概念上相当简单,但比我们在上一章中看到的要复杂一些,因为我们将同时拥有命令行和图形界面。有经验的程序员很可能会立即意识到需要在这两个界面之间共享代码,因为“不要重复自己”是一个良好设计系统的许多标志之一。为了促进代码的共享,我们将引入第三个模块,提供一个可以被其他两个项目使用的库。我们将称这些模块为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 的最后一个有趣的部分是“模块”部分。对于那些对此不熟悉的人来说,唯一需要注意的是,每个“模块”元素都指的是一个目录名称,它是当前目录的直接子目录,并且应该按照需要声明的顺序进行声明。在我们的情况下,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.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,它是提供额外功能的Executor,例如创建Future(如前所述)和管理队列的终止。那么,Executor是什么?Executor是一个执行Runnable的机制,比简单调用new Thread(runnable).start()更健壮。接口本身非常基本,只包括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子句。通过这一行(可以有 0 个或多个),我们告诉系统我们正在导出指定包中的所有类型。此行将允许我们的 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。然后我们将其存储在Map中,使用 JPA 定义的键来指定 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时,我们必须指定 globbing 模式。正如在第一个调用map()中所看到的,我们必须对用户指定的内容进行调整。通常,模式掩码被简单地指定为*.jpg之类的东西。然而,这样的模式掩码不会按照用户的期望工作,因为它只会在当前目录中查找,而不会遍历任何子目录。为了做到这一点,模式必须以**/为前缀,我们在调用map()时这样做。有了我们调整后的模式,我们从系统的默认FileSystem中请求PathMatcher实例。请注意,我们将匹配模式指定为"glob:" + p,因为我们需要指示我们确实正在指定glob文件。

准备好我们的匹配器后,我们准备开始搜索。我们用这段代码来做到这一点:

    sourcePaths.stream() 
     .map(p -> new FindFileTask(p)) 
     .forEach(fft -> es.execute(fft)); 

使用Stream API,我们将每个源路径映射到一个 lambda,该 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()方法,传递一个检查PathPathMatcher实例匹配的 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 Persistence 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 Persistence API 的相当简单的使用--我们正在创建一个查询,并告诉它我们想要,并获得一个ListFileInfo引用。createQuery()方法创建一个TypedQuery对象,我们将调用getResultList()来检索结果,这给我们List<FileInfo>

在我们进一步进行之前,我们需要对 Java 持久化 API 进行简要介绍。JPA 是一种被称为对象关系映射ORM)工具的东西。它提供了一种面向对象、类型安全和与数据库无关的方式来存储数据,通常是在关系数据库中。该规范/库允许应用程序作者使用具体的 Java 类来定义他们的数据模型,然后以很少考虑当前使用的数据库的具体机制来持久化和/或读取它们。(开发人员并没有完全屏蔽数据库问题——是否应该这样做还有争议——但这些问题被抽象到 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,您还需要使用@SequenceGenerator来为SEQUENCE指定细节,使用@TableGenerator来为TABLE指定细节。您还需要使用生成器属性将生成器的 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 持久化 API 查询语言)编写的。它看起来很像 SQL,但更具面向对象的感觉。例如,如果我们想查询数据库中的每个FileInfo记录,我们可以使用以下查询:

 SELECT f FROM FileInfo f 

我已经将关键字都大写了,变量名都小写了,实体名都是驼峰式写法。这主要是一种风格问题,但大多数标识符是不区分大小写的,JPA 确实要求实体名的大小写与它所代表的 Java 类的大小写匹配。你还必须为实体指定一个别名或标识变量,我们简单地称之为f

要获取特定的FileInfo记录,可以指定一个WHERE子句,如下所示:

 SELECT f from FileInfo f WHERE f.fileName = :name 

通过这个查询,我们可以像 SQL 一样过滤查询,并且,就像 SQL 一样,我们指定了一个位置参数。参数可以是一个名称,就像我们在这里做的一样,或者简单地是一个?。如果你使用一个名称,你可以使用该名称在查询中设置参数值。如果你使用问号,你必须使用其在查询中的索引设置参数。对于小型查询,这通常是可以的,但对于更大、更复杂的查询,我建议使用名称,这样你就不必管理索引值,因为这几乎肯定会在某个时候导致错误。设置参数可能看起来像这样:

 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对象的内部状态,以给我们想要的哈希值。接下来,我们创建一个包装messageDigest.digest()结果的ByteArrayInputStream

有了我们的哈希值准备好了,我们将基于这些字节生成一个字符串。我们将通过使用IntStream.generate()方法生成一个流,使用我们刚刚创建的InputStream作为源。我们将限制流生成到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. 从基于键的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),我们可以给它一个描述,并且最重要的是,我们获得了内置验证,以确保选项具有其所需的值。尽管这是一个改进,但我从经验中学到,这里的程序化方法在实践中变得乏味。让我们看看我们的下一个候选者如何表现。

航空公司是一个命令行库,最初作为 GitHub 上 airlift 组织的一部分编写。在经过一段时间的停滞后,Rob Vesse 对其进行了分叉,并赋予了新的生命(http://rvesse.github.io/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 验证,以及可选的命令发现。重新实现我们的简单命令可能看起来像这样:

    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 解析的影响,除非您对方法上的注释感到困扰。尽管实际的逻辑实现代码不受这些影响。虽然航空公司和 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; 
    } 

我们定义了一个新模块,其名称与我们的库模块名称类似。我们还声明了我们需要两个 Crest 模块。

回到我们的源代码,我们有我们在功能需求中讨论过的四个参数。请注意,patternspaths被定义为List<String>。当 Crest 解析命令行时,如果它找到其中一个的多个实例(例如,--path=/path/one--path=/path/two),它将收集所有这些值并将它们存储为List。另外,请注意,verboseshowTimings被定义为boolean,所以我们看到了 Crest 将代表我们执行的类型强制转换的一个很好的例子。我们还为这两个参数设置了默认值,所以当我们的方法执行时,我们肯定会得到明智、可预测的值。

该方法的业务逻辑非常简单。我们将处理 verbose 标志,打印所请求操作的摘要如下:

    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 结晶为伪公共方面发挥奇迹。

回到任务上,我们创建了FileFinder类的一个实例,使用String.forEach将我们的pathspatterns传递给查找器,然后通过调用find()开始工作。工作本身是多线程的,但我们暴露了一个同步 API,所以我们的调用会阻塞,直到工作完成。一旦返回,我们开始在屏幕上打印细节。由于FindFiles.getDuplicates()返回Map<String, List<FileInfo>>,我们在Map上调用forEach()来遍历每个键,然后在List上调用forEach()来打印有关每个文件的信息。我们还使用AtomicInteger作为索引,因为变量必须是 final 或有效 final,所以我们只使用了AtomicIntegerfinal实例。对于更有经验的开发人员来说,可能会想到BigInteger,但它是不可变的,所以在这里使用它是一个不好的选择。

运行命令的输出将类似于这样:

The following duplicates have been found: 
Group #1: 
     C:\some\path\test\set1\file5.txt 
     C:\some\path\test\set2\file5.txt 
Group #2: 
     C:\some\path\test\set1\file11.txt 
     C:\some\path\test\set1\file11-1.txt 
     C:\some\path\test\set2\file11.txt 

接下来,我们处理showTimings。我在前面的代码中没有提到它,但现在我会提到,我们在处理之前和之后得到了一个Instant实例(来自 Java 8 的日期/时间库java.time)。只有当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设置为父模块的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 插件,它将创建一个包含所有依赖项的单个大型 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 target\cli-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 - GUIgroupIdcom.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,并将MenuBar添加到top部分,如下所示:

    <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的内容菜单:“打开文件…”“删除文件…”。我们还使用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。从我们的库返回的数据是一个由重复哈希键控的List<FileInfo>对象的Map。显然,我们不想向用户显示哈希列表,因此,就像 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-click。接下来,我们以相同的方式设置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>。如果用户在对话框中输入文本,并且用户点击确定,Optional将包含内容。我们通过调用ifPresent()来识别,传递一个 lambda,将新模式添加到ObservableList<String>中,这将自动更新用户界面。如果用户没有点击确定,Optional将为空。如果用户没有输入任何文本(或输入了一堆空格),则调用filter()将阻止 lambda 运行。

删除项目类似,尽管我们需要隐藏一些细节在一个实用方法中,因为我们对功能有两个需求。我们确保已选择某些内容,然后显示确认对话框,如果用户点击确定,则从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()返回一个文件,所以我们获取其规范路径并将其存储在路径中,这将再次自动更新我们的用户界面。

删除路径看起来与删除模式非常相似,如下面的代码片段所示:

    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并添加由哈希键控的List中的所有FileInfo对象。再次,由于Observable的强大功能,我们获得了自动用户界面更新。

我们还希望用户能够使用键盘浏览重复组列表以更新匹配文件列表。我们通过将ListViewonKeyPressed属性设置为指向这个相当简单的方法来实现:

    @FXML 
    public void keyPressed(KeyEvent event) { 
      dupeGroupClicked(null); 
    } 

恰好我们对这两种方法中的实际“事件”并不是特别感兴趣(它们实际上从未被使用),所以我们可以天真地委托给之前讨论过的鼠标点击方法。

我们还需要实现两个较小的功能:查看匹配文件和删除匹配文件。

我们已经创建了上下文菜单和菜单条目,所以我们需要做的就是实现以下处理程序方法:

    @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。我们还使用 JavaFX 构建了一个更复杂的用户界面,使用了嵌套容器,“链接”了ListView实例,并绑定了属性。

这对于一个“简单”的项目来说已经相当多了。我们的下一个项目也将相对简单,因为我们将构建一个命令行日期计算器,它将允许我们探索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,基于非常流行的开源库 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()方法,它也采用 ISO-8601 格式,看起来像这样--PnYnMnDPnW。根据前面的讨论,结构可能是相当明显的:

  • 字符串以一个可选的符号开头,后面跟着字母P

  • 在第一种形式之后,有三个部分,至少一个必须存在--年(Y)、月(M)和日(D)。

  • 对于第二种形式,只有一个部分--周(W)。

  • 每个部分的金额可以有正数或负数的符号。

  • W单位不能与其他单位组合在一起。在内部,金额乘以7并视为天数。

时钟

Clock是一个抽象类,它提供了使用时区的当前时刻(我们将在下面看到),日期和时间的访问。在 Java 8 之前,我们需要调用System.currentTimeInMillis()TimeZone.getDefault()来计算这些值。Clock提供了一个很好的接口,可以从一个对象中获取这些值。

Javadoc 声明使用Clock纯粹是可选的。事实上,主要的日期/时间类有一个now()方法,它使用系统时钟来获取它们的值。然而,如果您需要提供一个替代实现(比如在测试中,您需要另一个时区的LocalTime),这个抽象类可以被扩展以提供所需的功能,然后可以传递给适当的now()方法。

Instant

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]?:这是 0 或 1。?表示这应该发生一次或根本不发生。

  • |2[0-3]:我们要么想匹配前半部分,这一部分,它将是 2 后面跟着 0、1、2 或 3。

  • ): 这结束了非捕获组。这个组将允许我们匹配 12 或 24 小时制的时间。

  • ::这个位置需要一个冒号。它的存在是不可选的。

  • [0-5]\\d:接下来,模式必须匹配一个 0-5 后面跟着另一个数字。这是时间的分钟部分。

  • ' *': 很难看到,所以我添加了引号来帮助指示,但我们想匹配 0 个或更多个(由星号表示)空格。

  • (?:: 这是另一个非捕获组。

  • [AaPp][Mm]:这些是AP字母(任何大小写)后面跟着一个M(也是任何大小写)。

  • ):我们结束了非捕获组,但用?标记它,以指示它应该发生一次或根本不发生。这个组让我们捕获任何AM/PM指定。

  • |现在:与上面的 today 一样,我们允许用户指定此字符串以指示当前时间。

同样,这个模式可能匹配一个无效的时间字符串,但我们将让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 小时制和上午/下午指定的。不幸的是,这不是 API 的工作方式,所以我们必须进行调整。

首先,如果需要,我们填充小时。其次,我们查看用户是否指定了"am""pm"。如果是这样,我们需要创建一个特殊的格式化程序,这是通过DateTimeFormatterBuilder完成的。我们首先告诉构建器构建一个不区分大小写的格式化程序。如果我们不这样做,"AM"将起作用,但"am"将不起作用。接下来,我们附加我们想要的模式,即小时、分钟和上午/下午,然后构建格式化程序。最后,我们可以解析我们的文本,方法是将字符串和格式化程序传递给LocalTime.parse()。如果一切顺利,我们将得到一个LocalTime实例。如果不行,我们将得到一个Exception实例,我们将处理它。请注意,我们在字符串上调用replaceAll()。我们这样做是为了去除时间和上午/下午之间的任何空格。否则,解析将失败。

最后,我们来到我们的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<>(); 

这并不是很复杂,而是很丑陋。我们有一个可能的字符串列表,由逻辑OR运算符竖线分隔。可能可以编写一个正则表达式来搜索每个单词,或者它的部分,但这样的表达式很可能很难编写正确,几乎肯定很难调试或更改。简单和清晰几乎总是比聪明和复杂更好。

这里还有一个需要讨论的最后一个元素: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不为空,然后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()方法验证元素不为空且为所需类型。如果令牌有效,我们可以安全地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,以便我们可以处理剩余的令牌。我们的第一步是确定操作符是加号还是减号,根据需要给negate分配正数1或负数-1。我们这样做是为了能够使用一个方法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()方法,因为默认值对于我们的目的来说是完全可以接受的。Durationperiods则更加复杂。两者都提供了许多提取细节的方法。我们将把这些细节隐藏在单独的方法中:

    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,一个按钮和一个标签。我们不需要这些,所以让我们摆脱它们。我们的主用户界面将非常简单--基本上只是一堆垂直的组件--所以我们可以使用 VBox 作为我们的根组件。也许,将根组件从那里的 AnchorPane 更改为 VBox 的最简单方法是使用 Scene Builder 将该组件包装在 VBox 中,然后删除 AnchorPane:

要做到这一点,通过双击文件在 Scene Builder 中打开 FXML 文件(假设您已经正确配置了 NetBeans,以便它知道在哪里找到 Scene Builder。如果没有,请参考第一章,介绍)。在 Scene Builder 中,在左侧手风琴的文档部分中右键单击 AnchorPane,选择 Wrap in,然后选择 VBox,如前面的屏幕截图所示。然后,Scene Builder 将修改 FXML 文件,使 AnchorPane 作为预期的 VBox 的子元素。完成后,您可以右键单击 AnchorPane,然后单击删除以删除它及其子元素。这样我们就得到了一个比开始时更无聊的空用户界面。现在我们可以通过添加一些控件来修复它--一个菜单栏和一个列表视图。我们可以通过单击手风琴中控件部分中的每个组件,并将它们拖动到 VBox 中来实现。如果您将组件放在 VBox 上,它们将被追加到其子元素列表中。确保 MenuBar 在 ListView 之前,否则您将得到一个非常奇怪的用户界面。

现在在返回代码之前,让我们稍微配置一下这些组件。从左侧的文档部分选择 VBox,然后需要在右侧手风琴中选择布局部分。对于最小宽度和最小高度,分别输入640480。这将使窗口的默认大小更大和更用户友好。

对于 MenuBar,我们需要展开其在 Document 下的条目,然后展开其每个 Menu 子项,这样应该会显示每个 Menu 的一个 MenuItem。点击第一个 Menu,然后在右侧将Text设置为_File,并勾选 Mnemonic Parsing。这将允许用户按下Alt + F来激活(或显示)此菜单。接下来,点击其MenuItem子项,将Text设置为_Exit,并勾选 Mnemonic Parsing。(如果MenuMenuItemButton等的文本中有下划线,请确保勾选了 Mnemonic Parsing。出于简洁起见,我不会再明确标记这一点。)打开 Code 部分,将 On Action 值设置为closeApplication

第二个Menu的 Text 值应设置为_Edit。它的MenuItem应标记为_Settings,并具有showPreferences的 On Action 值。最后,第三个Menu应标记为_Help,其MenuItem标记为About,具有showAbout的 On Action 值。

接下来,我们想要给ListView一个 ID,所以在左侧选择它,确保右侧展开了 Code 部分,然后输入entriesListView作为 fx:id。

我们需要做的最后一个编辑是设置控制器。我们在左侧的手风琴中进行,找到最底部的 Controller 部分。展开它,并确保 Controller 类的值与我们在 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数据的列表。请记住,ListView是从ObservableList操作的。这使我们能够对列表中的数据进行更改,并自动反映在用户界面中。为了创建该列表,我们将在定义类属性时使用 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是否为空。我们这样做是因为ListView不是为其List中的每个项目调用此方法,而是为ListView中可见的每一行调用,无论是否有数据。这意味着,如果List有五个项目,但ListView足够高以显示十行,此方法将被调用十次,最后五次调用将使用空的item进行。在这种情况下,我们调用setGraphic(null)来清除先前呈现的任何项目。

但是,如果item不为空,我们需要构建用于显示项目的Node,这是在buildItemCell()中完成的。对于每个项目,我们希望呈现三个项目--社交媒体网络图标(用户可以一眼看出项目来自哪里)、项目中嵌入的任何图像,以及最后,项目中的任何文本/标题。为了帮助安排,我们从HBox开始。

接下来,我们尝试查找网络的图标。如果我们有一份正式的合同书,我们将在其中包含语言,规定模块包含一个名为icon.png的文件,该文件与模块的SocialMediaItem实现在同一个包中。然后,使用实现的ClassLoader,我们尝试获取资源的InputStream。我们检查是否为空,只是为了确保实际找到了图像;如果是,我们创建一个ImageView,设置一些属性,然后将资源包装在Image中,将其交给ImageView,然后将ImageView添加到HBox中。

为项目添加图像

如果该项目有图片,我们会以与网络图标图片相同的方式处理它。不过,这一次,我们实际上是在将ImageView添加到外部HBox之前将其包装在另一个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 有一个很好的向导。右键单击所需的包,然后单击 New | Other。从类别列表中,选择JavaFX,然后从文件类型列表中选择Empty FXML,如下面的屏幕截图所示:

点击“下一步”后,您应该会看到 FXML 名称和位置步骤。这将允许我们指定新文件的名称和创建它的包,就像在这个屏幕截图中看到的那样:

点击“下一步”将带我们到控制器类步骤。在这里,我们可以创建一个新的控制器类,或将我们的文件附加到现有的控制器类。由于这是我们应用程序的一个新对话框/窗口,我们需要创建一个新的控制器,如下所示:

勾选“使用 Java 控制器”复选框,输入PreferencesController作为名称,并选择所需的包。我们可以点击“下一步”,这将带我们到层叠样式表步骤,但我们对于这个控制器不感兴趣,所以我们通过点击“完成”来结束向导,这将带我们到我们新创建的控制器类的源代码。

让我们从布局用户界面开始。双击新的prefs.fxml文件以在 Scene Builder 中打开它。与我们上一个 FXML 文件一样,默认的根元素是 AnchorPane。对于这个窗口,我们想要使用 BorderPane,所以我们使用了与上次替换 AnchorPane 相同的技术--右键单击组件,然后单击 Wrap in | BorderPane。AnchorPane 现在嵌套在 BorderPane 中,所以我们再次右键单击它,然后选择删除。

为了构建用户界面,我们现在从左侧的手风琴中拖动一个 TabPane 控件,并将其放在 BorderPane 的 CENTER 区域。这将在我们的用户界面中添加一个具有两个选项卡的 TabPane。我们现在只想要一个,所以删除第二个。我们想要给我们的选项卡一个有意义的标签。我们可以通过双击预览窗口中的选项卡(或在检查器的属性部分中选择 Text 属性)并输入General来实现。最后,展开检查器的代码部分,并输入tabPane作为 fx:id。

现在我们需要提供一种方式,让用户可以关闭窗口,并保存或放弃更改。我们通过将 ButtonBar 组件拖动到边界面的 BOTTOM 区域来实现这一点。这将添加一个带有一个按钮的 ButtonBar,但我们需要两个,所以我们将另一个按钮拖到 ButtonBar 上。这个控件的好处是它会为我们处理按钮的放置和填充,所以当我们放置新按钮时,它会自动添加到正确的位置。 (这种行为可以被覆盖,但它正是我们想要的,所以我们可以接受默认值。)

对于每个Button,我们需要设置三个属性--textfx:idonAction。第一个属性在 Inspector 的 Properties 部分中,最后两个在 Code 部分。第一个按钮的值分别是SavesavePrefssavePreferences。对于第二个按钮,值分别是CancelcancelcloseDialog。在 Inspector 中选择ButtonBar的 Layout 部分,并将右填充设置为 10,以确保Button不会紧贴窗口边缘。

最后,我们将在这一点上添加我们唯一的偏好设置。我们希望允许用户指定从每个社交媒体网络中检索的最大项目数。我们这样做是为了那些应用程序长时间未被使用(或从未被使用)的情况。在这些情况下,我们不希望尝试下载成千上万条推文。为了支持这一点,我们添加两个控件,LabelTextField

将 Label 控件的位置设置正确非常简单,因为它是第一个组件。Scene Builder 将提供红色指南线,以帮助您将组件放置在所需位置,如下截图所示:

确保TextField与标签对齐可能会更加棘手。默认情况下,当您将组件放置在 TabPane 上时,Scene Builder 会添加一个 AnchorPane 来容纳新组件。HBox 可能是一个更好的选择,但我们将继续使用 AnchorPane 来演示 Scene Builder 的这个特性。如果您将 TextField 拖放到 TabPane 并尝试定位它,您应该会看到更多的红线出现。定位正确后,您应该会看到一条红线穿过标签和TextField的中间,表示这两个组件在垂直方向上对齐。这正是我们想要的,所以确保TextField和标签之间有一小段空间,然后放置它。

我们需要给 Label 一些有意义的文本,所以在预览窗口中双击它,输入要检索的项目数量。我们还需要为TextField设置一个 ID,以便与之交互,所以点击组件,在 Inspector 中展开 Code 部分,将 fx:id 设置为itemCount

我们的用户界面虽然基本,但在这里已经尽可能完整,所以保存文件,关闭 Scene Builder,并返回到 NetBeans。

保存用户偏好设置

为了让我们新定义的用户界面与我们的控制器连接,我们需要创建与设置了fx:id属性的控件相匹配的实例变量,因此,我们将这些添加到PreferencesController中,如下所示:

    @FXML 
    protected Button savePrefs; 
    @FXML 
    protected Button cancel; 
    @FXML 
    protected TabPane tabPane; 

initialize()方法中,我们需要添加对加载保存值的支持,因此我们需要稍微讨论一下偏好设置。

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); 
            } 
          } 
        } 
    } 

上面的代码在 try 块外声明了一个InputStream,然后在try块中对其进行了一些处理。在finally块中,我们尝试关闭InputStream,但首先必须检查它是否为 null。例如,如果文件不存在(因为这是该类创建的第一次),将抛出Exception,并且input将为 null。如果它不为 null,我们可以在其上调用close(),但这可能会引发IOException,因此我们还必须将其包装在try/catch块中。

Java 8 引入了 try-with-resources 结构,使得代码变得更加简洁。如果一个对象是AutoCloseable的实例,那么它可以在try声明中被定义,无论是否抛出Exception,当try块范围终止时,它都会被自动关闭。这使我们可以用更少的噪音将通常需要十四行代码来表达的功能表达为四行代码。

除了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_USER\SOFTWARE\JavaSoft\Prefs\com.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-ready。

添加一个网络 - Twitter

到目前为止,我们有一个非常基本的应用程序,可以保存和加载其偏好设置,但让我们开始连接社交网络,这才是我们在这里的目的。我们希望开发一个框架,使得轻松添加对不同社交网络的支持成为可能。从技术上讲,正如我们很快会看到的那样,网络甚至不需要是社交的,因为唯一会暗示特定类型来源的是所涉及的类和接口的名称。然而,事实上,我们将专注于社交网络,并且我们将使用一些不同的社交网络来展示一些多样性。为此,我们将从 Twitter 开始,这是一个非常受欢迎的微博平台,以及 Instagram,这是一个越来越注重照片的网络,现在已经成为 Facebook 的一部分。

说到 Facebook,为什么我们不演示与该社交网络的集成?有两个原因--一,它与 Twitter 没有显著不同,因此没有太多新内容需要涵盖;二,更重要的是,Facebook 提供的权限几乎不可能以对我们感兴趣的方式集成。例如,读取用户的主页时间线(或墙)的权限仅授予针对那些 Facebook 当前不可用的平台的应用程序,而且根本不授予桌面应用程序,而这正是我们的目标。

如前所述,我们希望能够在不更改核心应用程序的情况下公开添加更多网络的方法,因此我们需要开发一个 API。我们将在这里介绍一个或多或少完成状态的 API(任何软件真的会完成吗?)。然而,虽然您将看到一个相当完整的 API,但需要注意一点——试图从头开始创建一个抽象的尝试很少有好的结果。最好是编写一个具体的实现来更好地理解所需的细节,然后提取一个抽象。您在这里看到的是这个过程的最终结果,因此这个过程不会在这里深入讨论。

注册为 Twitter 开发者

要创建一个与 Twitter 集成的应用程序,我们需要创建一个 Twitter 开发者帐户,然后创建一个 Twitter 应用程序。要创建帐户,我们需要访问dev.twitter.com,然后点击加入按钮。创建开发者帐户后,您可以点击我的应用链接转到apps.twitter.com。在这里,我们需要点击创建新应用程序按钮,这将为我们提供一个看起来有点像这样的表单:

虽然我们正在开发的应用程序被称为Sunago,但您将无法使用该名称,因为它已经被使用;您将需要创建一个自己独特的名称,假设您打算自己运行该应用程序。创建应用程序后,您将被带到新应用程序的应用程序管理页面。从这个页面,您可以管理应用程序的权限和密钥,如果需要,还可以删除应用程序。

在这个页面上需要注意的一件事是,我们很快就会需要的应用程序的 Consumer Key 和 Secret 的位置。这些是长的,包含字母和数字的字符串,您的应用程序将使用它们来验证 Twitter 的服务。代表用户与 Twitter 互动的最终目标需要一组不同的令牌,我们很快就会获取。您的 Consumer Key 和 Secret,尤其是 Consumer Secret,应该保密。如果这个组合被公开,其他用户就可以冒充您的应用程序,如果他们滥用服务,可能会给您带来严重的麻烦。因此,您不会在本书或源代码中看到我生成的密钥/秘钥组合,这就是为什么您需要生成自己的组合。

现在拥有了我们的 Consumer Key 和 Secret,我们需要决定如何与 Twitter 交流。Twitter 提供了一个公共的 REST API,在他们的网站上有文档。如果我们愿意,我们可以选择某种 HTTP 客户端,并开始调用。然而,出于简单和清晰的考虑,更不用说健壮性、容错性等等,我们可能更好地使用某种更高级的库。幸运的是,有这样一个库,Twitter4J,它将使我们的集成更简单、更清晰(对于好奇的人来说,Twitter4J 有 200 多个 Java 类。虽然我们不需要所有这些功能,但它应该让您了解编写 Twitter 的 REST 接口的合理封装所需的工作范围)。

如前所述,我们希望能够在不更改核心应用程序的情况下向 Sunago 添加网络,因此我们将在一个单独的 Maven 模块中编写我们的 Twitter 集成。这将需要将我们已经为 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()方法公开SimpleBooleanPropertyCheckBoxListCell使用它来设置和读取每行的状态。

我们最后一个与用户界面相关的方法是这样的:

    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中,这是SelectableItem的子类,覆盖toString()方法以在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 大于sinceId的指定数量的推文。

将所有这些封装在Paging对象中,如果列表 ID 为-1(我们用来标识主页时间线的内部 ID),我们调用twitter.getHomeTimeline(),或者对于用户定义的列表,我们调用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()); 

ResourceBundle文件在 Javadoc 的话语中包含特定于区域设置的对象。通常,这意味着字符串,就像在我们的情况下一样。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 文件变得臃肿

有了这个,我们的实现现在已经完成。但在可以按照我们的意图使用之前,我们需要进行构建更改。如果您回忆一下本章开头对需求的讨论,我们希望构建一个系统,可以轻松让第三方开发人员编写模块,以添加对任意社交网络的支持,而无需修改核心应用程序。为了提供这种功能,这些开发人员需要提供一个 JAR 文件,Sunago 用户可以将其放入文件夹中。启动应用程序后,新功能现在可用。

然后,我们需要打包所有所需的代码。目前,项目创建了一个单一的 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的属性。当运行 package 阶段时,该插件的 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 这样的构件管理器中。

不过有一个小 bug。我们的 twitter 模块依赖于 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,我们将看到 Twitter 添加到我们的设置屏幕上,并且一旦连接和配置,我们将看到推文显示在主屏幕上。我们还会注意到主屏幕有点单调,更重要的是,没有提供任何刷新内容的方式,所以让我们来解决这个问题。

添加一个刷新按钮

在项目窗口中,找到sunago.fxml,右键单击它,然后选择Edit。我们将手动进行这个用户界面的更改,只是为了体验。向下滚动,直到找到关闭的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 一样,我们需要考虑如何处理与 Instragram API 的交互。就像 Twitter 一样,Instagram 提供了一个使用 OAuth 进行保护的公共 REST API。同样,手动实现一个客户端来消费这些 API 并不是一个吸引人的选择,因为需要付出大量的努力。除非有充分的理由编写自己的客户端库,否则我建议如果有可用的客户端包装器,应该优先使用。幸运的是,有--jInstagram。

注册为 Instagram 开发者

在开始编写我们的客户端之前,我们需要在服务中注册一个新的 Instagram 客户端。我们可以通过首先在www.instagram.com/developer创建(如果需要)一个 Instagram 开发者帐户。一旦有了帐户,我们需要通过单击页面上的“注册您的应用程序”按钮或直接访问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是一个包装 jInstagram API 的SocialMediaClient

    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. 等待应用程序完成,当发生以下任何一种情况时:

  5. 应用程序调用Platform.exit()

  6. 最后一个窗口已经关闭,平台上的implicitExit属性为 true。

  7. 调用stop()方法。

我们希望在第 1 步,在我们的Application的构造函数中执行我们的ClassLoader工作,以确保后续的一切都有最新的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。点击右下角的 Configure 菜单,然后点击 SDK Manager,以获取以下屏幕:

您选择的 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 和眼镜。如前面的屏幕截图所示,我们对这个应用程序感兴趣的是手机和平板电脑。

  2. 在下一个窗口中,我们需要为应用程序的主Activity选择一个类型。在 Android 应用程序中,我们可能称之为“屏幕”(或者如果您来自 Web 应用程序背景,可能是“页面”)的东西被称为Activity。不过,并非每个Activity都是一个屏幕。

从 Android 开发者文档(developer.android.com/reference/android/app/Activity.html)中,我们了解到以下内容:

[a]活动是用户可以执行的单一、专注的事情。几乎所有的活动都与用户进行交互,因此活动类会为您创建一个窗口...

对于我们的目的,可能可以将两者等同起来,但要松散地这样做,并始终牢记这一警告。向导为我们提供了许多选项,如在此截图中所示:

  1. 正如您所看到的,有几个选项:基本、空白、全屏、Google AdMobs 广告、Google 地图、登录等。选择哪个取决于应用程序的要求。就用户界面而言,我们的最低要求是告诉用户应用程序的名称,显示社交媒体项目列表,并提供一个菜单来更改应用程序设置。因此,从上面的列表中,基本活动是最接近的匹配,因此我们选择它,然后点击下一步:

  1. 前面屏幕中的默认值大多是可以接受的(请注意,活动名称已更改),但在点击完成之前,还有一些最后的话。构建任何规模的 Android 应用程序时,您将拥有许多布局、菜单、活动等。我发现将这些工件命名为您在此处看到的名称很有帮助--活动的布局命名为activity_加上活动名称;菜单为活动名称加上menu_,或者对于共享菜单,是其内容的有意义的摘要。每种工件类型都以其类型为前缀。这种一般模式将帮助您在文件数量增加时快速导航到源文件,因为这些文件的排列非常扁平和浅。

  2. 最后,请注意使用片段复选框。片段是应用程序用户界面或行为的一部分,可以放置在活动中。实际上,这是您作为开发人员将用户界面定义分解为多个片段(或片段,因此名称)的一种方式,这些片段可以根据应用程序当前上下文以不同的方式组合成一个整体在活动中。例如,基于片段的用户界面可能在手机上有两个屏幕用于某些操作,但在平板上可能将这些组合成一个活动。当然,情况比这更复杂,但我包含了这个简短而不完整的描述,只是为了解释复选框。我们不会在我们的应用程序中使用片段,因此我们将其取消选中,然后点击完成。

处理一段时间后,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 用户界面是基于 Activities 的,它使用布局文件来描述用户界面的结构。当然,还有更多内容,但这个简单的定义对我们在 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,我们就可以开始获取对各种元素的引用。请注意,我们首先寻找视图中定义的ToolbarfindViewById(R.id.toolbar),然后我们告诉 Android 使用它作为我们的操作栏,通过setSupportActionBar()。这是一个通过compat类为我们实现的功能的例子。如果我们直接扩展了,比如说,Activity,我们将需要做更多的工作来使操作栏工作。现在,我们只需调用一个 setter,就完成了。

  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材料设计是谷歌正在开发和支持的最新视觉语言。它为 Android 应用程序提供了现代、一致的外观和感觉。谷歌鼓励使用它,并且幸运的是,新的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. 最后,完成我们的数据绑定,我们初始化将以异步方式处理加载和更新AdapterLoaderManager

在深入数据访问之前,我们要看的最后一点代码是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 对话框。不过,我们添加了一个onClick监听器到 OK 按钮,它关闭应用程序。使用这个,我们告诉用户需要网络连接,然后当用户点击 OK 时关闭应用程序。当然,这种行为是否可取是值得商榷的,但是确定设备的网络状态的过程是足够有趣的,所以我在这里包含了它。

现在让我们把注意力转向 Android 应用中经常进行的数据访问--CursorAdapters

Android 数据访问

在任何平台上,都有多种访问数据的方式,从内置设施到自制 API。安卓也不例外,因此,虽然你可以编写自己的方式从任意数据源加载数据,但除非你有非常特殊的要求,通常是没有必要的,因为安卓内置了一个系统——ContentProvider

安卓文档会告诉你,内容提供者管理对数据的中央存储库的访问,并且它提供了一个一致的、标准的数据接口,还处理进程间通信和安全数据访问。如果你打算向外部来源(无论是读取还是写入)公开应用程序的数据,ContentProvider是一个很好的选择。然而,如果你不打算公开你的数据,你完全可以自己编写所需的 CRUD 方法,手动发出各种 SQL 语句。在我们的情况下,我们将使用ContentProvider,因为我们有兴趣允许第三方开发人员访问数据。

要创建一个ContentProvider,我们需要创建一个新的类,继承ContentProvider,如下所示:

    public class SunagoContentProvider extends ContentProvider { 

我们还需要在AndroidManfest.xml中注册提供者,我们将这样做:

    <provider android:name=".data.SunagoContentProvider 
      android:authorities="com.steeplesoft.sunago.SunagoProvider" /> 

ContentProvider的交互永远不是直接进行的。客户端代码将指定要操作的数据的 URL,安卓系统将把请求转发给适当的提供者。因此,为了确保我们的ContentProvider按预期运行,我们需要注册提供者的权限,这已经在之前的 XML 中看到了。在我们的提供者中,我们将创建一些静态字段来帮助我们以 DRY 的方式管理我们权限的部分和相关的 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 的第一部分,协议字段,告诉安卓我们正在寻找一个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,将返回这个值。最后,我们为每个 URI 注册一个唯一的int标识符。

接下来,像许多安卓类一样,我们需要指定一个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和该视图的基础数据之间的桥梁,而CursorAdapterCursor中的数据暴露给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时手动设置。我们从中获取默认显示的大小,将显示的宽度乘以 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回收利用的地方。适配器有许多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; 
        } 

Android 将创建一个后台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,使其可见,然后请求视图在屏幕上更新自己。

到目前为止,我们已经构建了一个能够显示SocialMediaItem实例的应用程序,但我们没有任何内容可以显示。现在我们将通过查看 Android 服务来解决这个问题。

Android 服务

对于 Sunago 的桌面版本,我们定义了一个 API,允许第三方开发者(或我们自己)为 Sunago 添加对任意社交网络的支持。这对于桌面来说是一个很好的目标,对于移动设备也是一个很好的目标。幸运的是,Android 为我们提供了一个可以实现这一目标的机制:服务。服务是一个应用组件,代表应用程序要执行长时间操作而不与用户交互,或者为其他应用程序提供功能。虽然服务的设计不仅仅是为了可扩展性,但我们可以利用这个功能来实现这一目标。

虽然有许多实现和与服务交互的方法,我们将把服务绑定到我们的Activity,以便它们的生命周期与我们的Activity绑定,并且我们将以异步方式向它们发送消息。我们将首先定义我们的类如下:

    public class TwitterService extends IntentService { 
      public TwitterService() { 
        super("TwitterService"); 
      } 

     @Override 
      protected void onHandleIntent(Intent intent) { 
    } 

从技术上讲,这些是创建服务所需的唯一方法。显然,它并没有做太多事情,但我们将在片刻之后解决这个问题。在我们这样做之前,我们需要在AndroidManifest.xml中声明我们的新Service,如下所示:

    <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),但它在有效载荷大小上有一定限制,不能超过 1MB。虽然我们的有效载荷只是文本,但我们可以(并且根据我的测试肯定会)超过这个限制。因此,我们的方法将是通过接收器使用异步通信,并让服务通过我们的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注册接收器,指定我们感兴趣的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进行批量插入以保存数据。通过这种方式,我们可以使新检索到的数据在不违反IBinder的 1MB 限制的情况下对应用程序可用。

定义了我们的服务之后,我们现在需要看看如何找到和绑定服务。回顾一下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()方法。我们首先创建一个指定我们动作的Intentsunago.service。这是一个我们定义的任意字符串,然后为任何第三方消费者进行文档化。这将帮助我们的服务过滤掉没有兴趣的消息,这正是我们在TwitterService.onBind()中使用registerReceiver(receiver, new IntentFilter("sunago.service"))所做的。然后,我们将我们的应用程序想要发送的消息(在这种情况下是REFRESH)作为Intent的额外部分添加,然后通过sendBroadcast()进行广播。从这里,Android 将处理将消息传递给我们的服务,该服务已经在运行(因为我们已将其绑定到我们的Activity)并且正在监听(因为我们注册了BroadcastReceiver)。

Android 选项卡和片段

我们已经看了很多,但还有一些我们没有看到的,比如TwitterClient的实现,以及任何关于网络集成的细节,比如我们在上一章中看到的 Instagram。在很大程度上,TwitterClient与我们在第五章中看到的 Sunago - A Social Media Aggregator 是相同的。唯一的主要区别在于流 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()返回,所选选项卡的FragmentgetItem()返回。在这个例子中,我们根据需要创建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略有不同。在这里,我们在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 进行电子邮件和垃圾邮件管理

在计算机科学中,我们有许多定律,其中最著名的可能是摩尔定律,它涉及计算机处理能力增加的速度。另一条定律,虽然不那么著名,当然也不那么严肃,被称为Zawinski 定律。杰米·扎温斯基,以其在网景和 Mozilla 的角色而闻名,曾指出“每个程序都试图扩展到可以读取邮件的程度。那些无法扩展到这一程度的程序将被可以的程序所取代。”尽管 Zawinski 定律并不像摩尔定律那样准确,但似乎确实有一定的真实性,不是吗?

本章将关注电子邮件,看看我们是否能解决困扰我们所有人的问题:电子邮件杂乱。从垃圾邮件到邮件列表的帖子,这些消息不断涌现,不断堆积。

我有几个电子邮件账户。作为家里的负责人和极客,我经常被委托管理我们的数字资产,即使他们没有意识到,而一小部分垃圾邮件可能看起来微不足道,但随着时间的推移,它可能成为一个真正的问题。在某个时候,处理起来似乎几乎不可能。

在本章中,我们将解决这个非常真实的问题,尽管可能有些夸张。这将给我们一个完美的借口来使用标准的 Java 电子邮件 API,适当地称为 JavaMail。

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

    • JavaMail API
    • 电子邮件协议
    • 一些更多的 JavaFX 工作(当然)
    • 使用 Quartz 在 Java 中创建作业计划
  • 安装 Java 编写的特定于操作系统的服务

也许你已经很好地控制了你的电子邮件收件箱,如果是这样,恭喜你!然而,无论你的邮件客户端是多么整洁或令人不知所措,我们在本章中应该在探索小而强大的 JavaMail API 和电子邮件的美妙世界时玩得开心。

入门

在我们深入了解应用程序之前,让我们停下来快速看一下电子邮件涉及的内容。尽管电子邮件是如此普遍的工具,似乎对大多数人来说,甚至是技术上有心的人来说,它似乎是一个相当不透明的话题。如果我们要使用它,了解它将非常有帮助,即使只是一点点。如果你对协议的细节不感兴趣,那么可以跳到下一节。

- 电子邮件协议的简要历史

像许多伟大的计算概念一样,电子邮件--电子邮件--最早是在 1960 年代引入的,尽管当时看起来大不相同。电子邮件的详细历史,虽然当然是一个很大的技术好奇心,但超出了我们在这里的目的范围,但我认为看一看今天仍然相关的一些电子邮件协议会很有帮助,其中包括用于发送邮件的 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。

IMAPInternet Access Message Protocol,创建于 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 的服务器发送电子邮件,并且我们将使用 SMTP over SSL。有了这个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时,我们将 alternative 作为subtype参数传递。如果不这样做,将会导致电子邮件显示两个部分,一个接一个,这显然不是我们想要的。如果我们正确编写了应用程序,我们应该在我们的电子邮件客户端中看到以下内容:

你当然看不到红色文本,在黑白打印中,但你可以看到粗体和斜体文本,这意味着显示的是 HTML 版本,而不是文本版本。任务完成!

发送电子邮件非常有趣,但我们在这里是为了学习文件夹和消息管理,所以让我们把注意力转向那里,并且我们将从设置我们的项目开始。

构建 CLI

这个项目,就像其他项目一样,将是一个多模块的 Maven 项目。我们将有一个模块用于所有核心代码,另一个模块用于我们将编写的 GUI 来帮助管理规则。

要创建项目,这次我们将做一些不同的事情。我们将使用 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; 

这基本上是一个非常简单的POJOPlain Old Java Object),有六个属性:serverNameserverPortuseSsluserNamepasswordrules。那些注释是什么呢?那些来自一个名为 Bean Validation 的库,它提供了一些注释和支持代码,允许我们以声明方式表达对值的约束,变量可以保存。这里是我们正在使用的注释及其含义:

  • @NotBlank:这告诉系统该值不能为空,也不能是空字符串(实际上,string != null && !string.trim() .equals("")

  • @NotNull:这告诉系统该值不能为空

  • @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 验证注解,标识我们的注解作为一个新的约束类型。这个注解的值告诉系统哪个ConstraintValidator处理这个约束的验证逻辑。

  • @Documented:这表明在任何类型上存在这个注解应该被视为该类型的公共 API 的一部分。

我们的ConstraintValidator实现来处理这个新的约束有点复杂。我们声明了这个类如下:

    public class ValidRuleValidator implements  
      ConstraintValidator<ValidRule, Object> { 

Bean 验证为约束验证提供了一个参数化接口,该接口接受约束的类型和验证逻辑适用的对象类型。这允许您为不同的对象类型编写给定约束的不同验证器。在我们的情况下,我们可以指定Rule而不是Object。如果我们这样做,任何时候除了Rule之外的东西被注解为@ValidRule并且实例被验证,调用代码将看到一个异常被抛出。相反,我们所做的是验证被注解的类型,特别是在需要时添加约束违规。

接口要求我们也实现这个方法,但是我们这里没有工作要做,所以它有一个空的方法体,如下所示:

    @Override 
    public void initialize(ValidRule constraintAnnotation) { 
    } 

有趣的方法叫做isValid()。它有点长,所以让我们一步一步地来看:

    public boolean isValid(Object value,  
      ConstraintValidatorContext ctx) { 
        if (value == null) { 
          return true; 
        } 

第一步是确保value不为空。我们有两种选择:如果它是空的,返回true,表示没有问题,或者返回false,表示有问题。我们的选择取决于我们希望应用程序的行为。对于任何一种方法都可以提出合理的论点,但似乎认为将空的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 enum,有两个可能的值,DELETEMOVE,但我们还添加了一个辅助方法,以返回给定字符串表示的适当的RuleType实例。这将在我们从 JSON 中解组Rule时帮助我们。

有了我们定义的数据模型,我们准备开始编写实用程序本身的代码。虽然 Maven 模块被称为mailfilter-cli,但我们在这里不会关心一个健壮的命令行界面,就像我们在前几章中看到的那样。相反,我们将提供一个与命令行的非常基本的交互,将 OS 服务留作首选的使用方式,我们稍后会看到。

在这一点上,我们将开始使用 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 会为你创建方法,并将光标放在空方法的第一行上,准备让你开始编码。你可以通过导航到工具 | 选项 | 编辑器 | 代码模板找到其他几十个有用的代码模板。你甚至可以定义自己的模板。

在我们的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中的每个账户(这里没有显示空值检查)。对于每个Account,我们调用validator.validate(),这是实际验证约束的时候。到目前为止,它们只是存储在类中的注释,JVM 很高兴地将它们一起携带,但不做其他任何事情。正如我们之前讨论的那样,Bean Validation 是注释描述的约束的执行者,在这里我们看到了手动 API 调用。

当对“验证器”进行调用时返回,我们需要查看是否有任何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()); 
    } 

要获得 IMAPSession,就像我们在本章前面看到的那样,我们创建一个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 有一个用于此目的的 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 更多地使用MessageMessage[])数组,所以我们需要将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中,以文件夹名称为键。如果我们在Map中找到“文件夹”,我们就使用它。如果没有,那么我们向 IMAP“存储”请求对所需的“文件夹”的引用,并将其缓存在Map中。最后,我们确保“文件夹”是打开的,否则我们的移动和删除命令将抛出异常。

当我们完成时,我们还需要确保关闭“文件夹”:

    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来显示规则文件中配置的Account。在ListView下方,我们有一些控件来编辑当前选定的Account。在右侧,我们有TableView来显示Rule,以及其下方类似的区域来编辑Rule

当用户点击AccountRule时,我们希望下方的表单区域填充相关信息。当用户修改数据时,Account/Rule以及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); 

这里有一点变化。为了封装我们的 POJO 绑定设置,我们将创建一个名为AccountProperty的新类,我们很快会看到。尽管,让我们首先添加以下代码片段来处理ListView的点击:

    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添加到serverNameuserNameTextField。这两个控件编辑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允许我们将 JavaFXProperty绑定到 POJO 上的属性,这些属性可以嵌套到任意深度,并使用点分隔路径表示。在我们的情况下,这些属性是Account对象上的顶级属性,因此路径是简短而简单的。在我们将控件绑定到属性之后,我们添加了一个Listener来使用Rule更新ObservableList规则。

在前面的代码中,当ListView中的Account选择发生变化时调用的set()方法非常简单:

    @Override 
    public void set(Account newValue) { 
      pathAdapter.setBean(newValue); 
      super.set(newValue); 
    } 

有了这些部分,Account对象在我们在各种控件中输入时得到更新,ListView标签在编辑serverName和/或userName字段时得到更新。

现在我们需要为将显示用户配置的每个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,指定每 15 分钟运行一次,将永远运行。我们希望它在计算机关闭或服务停止之前一直运行。

如果现在运行/调试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 风格的布局,后者是一些嵌套目录。至少对于我们在这里的目的来说,这主要是一个样式问题,但我发现能够扫描生成的目录并一目了然地看到包含了什么是很有帮助的。

接下来,我们需要按照以下方式定义守护进程(来自 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 从磁盘转换为 POJO,并从磁盘转换为 POJO。我们使用了 ControlsFX 类BeanPathAdapter,将非 JavaFX 感知的 POJO 绑定到 JavaFX 控件,以及 Quartz 作业调度库来按计划执行代码。最后,我们使用 Java Service Wrapper 来创建安装工件,完成了我们的应用程序。

我希望我们留下的应用程序既有趣又有帮助。当然,如果你感到有动力,还有几种方法可以改进它。账户/规则数据结构可以扩展,以允许定义跨账户共享的全局规则。GUI 可以支持在账户的文件夹中查看电子邮件,并根据实时数据生成规则。构建可以扩展为创建应用程序的安装程序。你可能还能想到更多。随时随地查看代码并进行修改。如果你想到了有趣的东西,一定要分享出来,因为我很想看看你做了什么。

完成另一个项目(不是故意的),我们准备把注意力转向另一个项目。在下一章中,我们将在 GUI 中花费全部时间,构建一个照片管理系统。这将让我们有机会了解一些 JDK 的图像处理能力,包括新增的 TIFF 支持,这个功能应该会让图像爱好者非常高兴。翻页,让我们开始吧!

第八章:使用 PhotoBeans 进行照片管理

到目前为止,我们已经编写了库。我们编写了命令行实用程序。我们还使用 JavaFX 编写了 GUI。在本章中,我们将尝试完全不同的东西。我们将构建一个照片管理系统,当然,它需要是一个图形应用程序,但我们将采取不同的方法。我们将使用现有的应用程序框架。该框架是 NetBeans Rich Client PlatformRCP),这是一个成熟、稳定和强大的框架,不仅支持我们使用的 NetBeans IDE,还支持从石油和天然气到航空航天等各行各业的无数应用程序。

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

  • 如何启动 NetBeans RCP 项目

  • 如何将 JavaFX 与 NetBeans RCP 集成

  • RCP 应用程序的基本原理,如节点、操作、查找、服务和顶级组件

那么,话不多说,让我们开始吧。

入门

也许您的问题清单中排在前面或附近的问题是,我为什么要使用 NetBeans RCP?在我们深入了解应用程序的细节之前,让我们回答这个非常公平的问题,并尝试理解为什么我们要以这种方式构建它。

当您开始研究 NetBeans 平台时,您会注意到的第一件事是模块化的强烈概念。由于 Java 9 的 Java 模块系统是 Java 的一个突出特性,这可能看起来像一个细节,但 NetBeans 在应用程序级别向我们公开了这个概念,使插件变得非常简单,并允许我们以逐步更新应用程序。

RCP 还提供了一个强大、经过充分测试的框架,用于处理窗口、菜单、操作、节点、服务等。如果我们要像在前几章中使用JavaFX 一样从头开始构建这个应用程序,我们将不得不手动定义屏幕上的区域,然后手动处理窗口放置。使用 RCP,我们已经定义了丰富的窗口规范,可以轻松使用。它提供了诸如最大化/最小化窗口、滑动、分离和停靠窗口等功能。

RCP 还提供了节点的强大概念,将特定领域的数据封装在用户界面概念中,通常在应用程序的左侧树视图中看到,以及可以与这些节点(或菜单项)关联的操作,以对它们代表的数据进行操作。再次强调,所有这些都可以在 JavaFX(或 Swing)中完成,但您需要自己编写所有这些功能。实际上,有许多开源框架提供了这样的功能,例如 Canoo 的 Dolphin Platform(www.dolphin-platform.io),但没有一个像 NetBeans RCP 那样经过多年的生产硬化和测试,因此我们将保持关注在这里。

启动项目

您如何创建 NetBeans RCP 项目将对项目的其余部分的处理方式产生非常基本的影响。默认情况下,NetBeans 使用 Ant 作为所有 RCP 应用程序的构建系统。几乎所有来自 NetBeans 项目的在线文档和 NetBeans 传道者的博客条目也经常反映了这种偏好。我们一直在使用 Maven 进行其他项目,这里也不会改变。幸运的是,NetBeans 确实允许我们使用 Maven 创建 RCP 项目,这就是我们要做的。

在新项目窗口中,我们选择 Maven,然后选择 NetBeans Application。在下一个屏幕上,我们像往常一样配置项目,指定项目名称、photobeans、项目位置、包等。

当我们点击“下一步”时,将会出现“新项目向导”的“模块选项”步骤。在这一步中,我们配置 RCP 应用程序的一些基本方面。具体来说,我们需要指定我们将使用的 NetBeans API 版本,以及是否要将 OSGi 捆绑包作为依赖项,如下面的屏幕截图所示:

在撰写本文时,最新的平台版本是 RELEASE82。到 Java 9 发布时,可以合理地期望 NetBeans 9.0,因此 RELEASE90 将可用。我们希望使用最新版本,但请注意,根据 NetBeans 项目的发布计划,它很可能 是 9.0。对于“允许将 OSGi 捆绑包作为依赖项”选项,我们可以安全地接受默认值,尽管更改它不会给我们带来任何问题,而且如果需要,我们可以很容易地稍后更改该值。

创建项目后,我们应该在项目窗口中看到三个新条目:PhotoBeans-parentPhotoBeans-appPhotoBeans-branding-parent 项目没有真正的可交付成果。与其他章节的 master 项目一样,它仅用于组织相关模块、协调依赖关系等。

为您的应用程序进行品牌定制

-branding 模块是我们可以定义应用程序品牌细节的地方,正如你可能已经猜到的那样。您可以通过右键单击品牌模块并在内容菜单底部附近选择 品牌... 来访问这些品牌属性。这样做后,您将看到一个类似于这样的屏幕:

在上述选项卡中,您可以设置或更改应用程序的名称,并指定应用程序图标。

在“启动画面”选项卡中,您可以配置最重要的是在应用程序加载时显示在启动画面上的图像。您还可以启用或禁用进度条,并设置进度条和启动消息的颜色、字体大小和位置:

目前对我们感兴趣的唯一其他选项卡是“窗口系统”选项卡。在这个选项卡中,我们可以配置一些功能,比如窗口拖放、窗口滑动、关闭等等:

很可能,默认值对我们的目的是可以接受的。但是,在您自己的 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" 
    }) 

不要过多涉及细节并冒险混淆事情,前三个注释注册了一个开放的操作,并在我们的应用程序的“窗口”菜单中公开了一个项目。最后一个注释@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模块的依赖。我们可以通过右键单击应用程序模块的“Dependencies”节点来实现这一点,如下图所示:

现在您应该看到“添加依赖项”窗口。选择“打开项目”选项卡,然后选择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 演示对象

您已经听过 Node 这个术语。我已经多次使用它来描述点击的内容和位置。正式地说,一个 Node 代表对象(bean)层次结构中的一个元素。它提供了在资源管理器视图和 bean 之间进行通信所需的所有方法。在我们的应用程序的资源管理器部分,我们希望向用户表示照片列表。我们将每张照片以及拍摄日期和月份表示为一个 Node。为了显示这些节点,我们将使用一个名为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 照片,但希望让第三方开发人员为 Flickr 提供实现。如果您将所需的 API 接口、类等放在一个模块中,将 Google 照片的实现放在另一个模块中,第三方开发人员可以仅依赖于您的 API 模块,避免依赖于您的实现模块。Flickr 模块将声明照片服务 API 的实现,我们可以通过查找请求加载 Flickr 和我们自己的 Google 照片实现。简而言之,该系统允许在一个非常干净、简单的 API 中解耦 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 版本的代码如下:

    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的查找器获取所有打开的 TopComponents,然后遍历每一个,将String photo(即图像的完整路径)与 TopComponent 的查找中存储的任何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与我们的节点关联起来,当点击时,它将在节点的查找中查找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,创建并存储一个 Lookup,并开始扫描目录,以确保应用程序与磁盘上的照片状态保持最新。

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区域。为此,我们指示 NetBeans 通过右键单击所需的包,并选择 New | Window 来创建一个 TopComponent:

在下一个窗格中,我们为新的 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是一个 Swing 组件,用于将 JavaFX 嵌入 Swing 中。我们的窗口布局是BorderLayout,所以我们将JFXPanel添加到CENTER区域,并让它扩展以填充Window。JavaFX 组件的任何复杂布局将由我们JFXPanel内的另一个容器处理。不过,我们的用户界面相当简单。与我们之前的 JavaFX 系统一样,我们通过 FXML 定义用户界面如下:

    <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,正如讨论的那样,这给了我们在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> 

BorderPanetop部分,我们添加了ButtonBar,然后添加了一个单独的SplitMenuButton。这给了我们一个像右侧的按钮。在非焦点状态下,它看起来像一个普通按钮。当用户点击箭头时,菜单会呈现给用户,提供了在列出的方向中旋转图片的能力:

我们已经将这些 MenuItems 绑定到了 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 选项窗口:

在前面的窗口中,我们可以看到两种类型的选项面板--主选项和次要选项。主选项面板由顶部的图标表示:常规、编辑器、字体和颜色等。次要选项面板是一个选项卡,就像我们在中间部分看到的:Diff、Files、Output 和 Terminal。在添加选项面板时,您必须选择主选项或次要选项。我们想要添加一个新的主要面板,因为它将在视觉上将我们的首选项与其他面板分开,并且让我们有机会创建两种类型的面板。

添加一个主要面板

要创建一个主选项面板,请右键单击所需的包或项目节点,然后单击“新建|选项面板”。如果选项面板不可见,请选择“新建|其他|模块开发|选项面板”。接下来,选择“创建主选项面板”:

我们必须指定一个标签,这是我们将在图标下看到的文本。我们还必须选择一个图标。系统将允许您选择除 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]。对于我们的按钮,我们需要使用所有三个,因此我们将文本设置为Add(或Remove),将变量名称更改为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的值,如果不为空,我们在分号上拆分,并将每个条目添加到我们的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 类来指示应该执行某些操作。虽然我们不共享类层次结构(由于不幸的依赖于节点 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 Node API 的基础知识,如何创建我们自己的节点,以及如何嵌套子节点。我们解释了如何使用 NetBeans Preferences API,包括创建用于编辑首选项的新选项面板,如何加载和存储它们,以及如何对首选项的更改做出反应。

关于 NetBeans RCP 的最后一句话——虽然我们在这里构建了一个体面的应用程序,但我们并没有完全挖掘 RCP 的潜力。我尝试覆盖平台的足够部分来让你开始,但如果你要继续使用这个平台,你几乎肯定需要学到更多。虽然官方文档很有帮助,但全面覆盖的首选来源是 Jason Wexbridge 和 Walter Nyland 的 NetBeans Platform for Beginnersleanpub.com/nbp4beginners)。这是一本很棒的书,我强烈推荐它。

在下一章中,我们将开始涉足客户端/服务器编程,并实现我们自己的记事应用程序。它可能不像市场上已经存在的竞争对手那样健壮和功能齐全,但我们将朝着那个方向取得良好进展,并希望在这个过程中学到很多东西。

第九章:使用 Monumentum 做笔记

对于我们的第八个项目,我们将再次做一些新的事情--我们将构建一个 Web 应用程序。而我们所有其他的项目都是命令行、GUI 或两者的组合,这个项目将是一个单一模块,包括一个 REST API 和一个 JavaScript 前端,所有这些都是根据当前的微服务趋势构建的。

要构建这个应用程序,你将学习以下主题:

  • 构建微服务应用程序的一些 Java 选项

  • Payara Micro 和microprofile.io

  • 用于 RESTful Web 服务的 Java API

  • 文档数据存储和 MongoDB

  • OAuth 身份验证(针对 Google,具体来说)

  • JSON Web Tokens (JWT)

正如你所看到的,从许多方面来看,这将是一个与我们到目前为止所看到的项目类型大不相同的项目。

入门

我们大多数人可能都使用过一些记事应用程序,比如 EverNote、OneNote 或 Google Keep。它们是一种非常方便的方式来记录笔记和想法,并且可以在几乎所有环境中使用--桌面、移动和网络。在本章中,我们将构建一个相当基本的这些行业巨头的克隆版本,以便练习一些概念。我们将称这个应用程序为 Monumentum,这是拉丁语,意思是提醒或纪念,这种类型的应用程序的一个合适的名字。

在我们深入讨论这些之前,让我们花点时间列出我们应用程序的需求:

  • 能够创建笔记

  • 能够列出笔记

  • 能够编辑笔记

  • 能够删除笔记

  • 笔记正文必须能够存储/显示富文本

  • 能够创建用户账户

  • 必须能够使用 OAuth2 凭据登录到现有系统的应用程序

我们的非功能性需求相当温和:

  • 必须有一个 RESTful API

  • 必须有一个 HTML 5/JavaScript 前端

  • 必须有一个灵活的、可扩展的数据存储

  • 必须能够轻松部署在资源受限的系统上

当然,这个非功能性需求列表的选择部分是因为它们反映了现实世界的需求,但它们也为我们提供了一个很好的机会来讨论我想在本章中涵盖的一些技术。简而言之,我们将创建一个提供基于 REST 的 API 和 JavaScript 客户端的 Web 应用程序。它将由一个文档数据存储支持,并使用 JVM 可用的许多微服务库/框架之一构建。

那么这个堆栈是什么样的?在我们选择特定选择之前,让我们快速调查一下我们的选择。让我们从微服务框架开始。

JVM 上的微服务框架

虽然我不愿意花太多时间来解释微服务是什么,因为大多数人对这个话题都很熟悉,但我认为至少应该简要描述一下,以防你不熟悉这个概念。话虽如此,这里有一个来自 SmartBear 的简洁的微服务定义,SmartBear 是一家软件质量工具提供商,也许最为人所知的是他们对 Swagger API 及相关库的管理:

基本上,微服务架构是一种开发软件应用程序的方法,它作为一套独立部署的、小型的、模块化的服务,每个服务运行一个独特的进程,并通过一个定义良好的、轻量级的机制进行通信,以实现业务目标。

换句话说,与将几个相关系统捆绑在一个 Web 应用程序中并部署到大型应用服务器(如 GlassFish/Payara 服务器、Wildfly、WebLogic 服务器或 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 创建一个新的配置文件,专门针对微服务。在撰写本文时,该配置文件包括用于 RESTful Web 服务的 Java APIJAX-RS),CDI 和 JSON-P,并得到了包括 Tomitribe、Payara、Red Hat、Hazelcast、IBM 和 Fujitsu 在内的多家公司以及伦敦 Java 社区和 SouJava 等用户组的赞助。MicroProfile 的主页是microprofile.io/

  • Lagom:这是一个相当新的框架,是 Lightbend 公司(Scala 背后的公司)推出的反应式微服务框架。它被描述为一种有主见的微服务框架,并使用了 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 构建我们的应用程序,Payara Micro 是基于 GlassFish 源代码(加上 Payara 的错误修复、增强等)的实现。

通过选择 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,选择适当的项目位置,并根据需要修复 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 的打包阶段相关联。这意味着当我们运行 Maven 构建项目时,插件的 java 目标将在 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 如何运行我们的应用程序,但是,而不是立即执行包,我们希望它创建一个超级 JAR,以便稍后运行应用程序。

通常,当您构建项目时,您会得到一个仅包含直接包含在项目中的类和资源的 jar 文件。任何外部依赖项都留作执行环境必须提供的内容。使用超级 JAR,我们的项目的 jar 中还包括所有依赖项,然后以这样的方式配置,以便执行环境可以根据需要找到它们。

设置的问题是,如果保持不变,当我们构建时,我们将得到一个超级 JAR,但我们将没有任何简单的方法从 NetBeans 运行应用程序。为了解决这个问题,我们需要稍微不同的插件配置。具体来说,它需要这些行:

    <argument>--deploy</argument> 
    <argument> 
      ${basedir}/target/${project.artifactId}-${project.version} 
    </argument> 

这些替换了之前的deployoutputUberJar选项。为了加快我们的构建速度,我们也不希望在我们要求之前创建超级 JAR,因此我们可以将这两个插件配置分成两个单独的配置文件,如下所示:

    <profiles> 
      <profile> 
        <id>exploded-war</id> 
        <!-- ... --> 
      </profile> 
      <profile> 
        <id>uber</id> 
        <!-- ... --> 
      </profile> 
    </profiles> 

当我们准备构建部署工件时,我们在执行 Maven 时激活超级配置文件,然后我们将获得可执行的 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 中,使用 JAX-RS 编写 REST 应用程序,我们的起点是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/notes处回答GET请求,并返回一个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 { 

我们需要在我们的“应用程序”中注册这个,如下所示:

    @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,我们创建一个指向我们应用程序中另一个端点/api/auth/callbackURI。然后将其传递给GoogleAuthorizationCodeFlow以完成构建我们的登录 URL。当用户点击链接时,浏览器将被重定向到 Google 的登录对话框。成功认证后,用户将被重定向到我们的回调 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 是用于创建断言某些声明的访问令牌的基于 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(),在这个方法中,我们从请求中获取 Authorization 头,提取 Bearer 令牌(即 JWT),并使用 JWT API 对其进行验证。如果我们可以解码和验证令牌,那么我们就知道用户已经成功对系统进行了身份验证。为了告诉系统这个新的过滤器,我们需要修改我们的 JAX-RSApplication如下:

    @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 bean,并注入我们的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,我们需要修改我们的资源方法以使其“用户感知”:

    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 这样的命令行工具,我们没有任何好的方法来使用它,所以让我们构建一个用户界面。

构建用户界面

对于用户界面,我们有许多选择。在本书中,我们已经看过 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 VM语言、转译等细节,因此我选择使用 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 的源代码,以及我们需要创建的客户端应用程序index.js的 JavaScript 代码:

    var vm = new Vue({ 
      el: '#app', 
      data: { 
        message : 'Hello, World!' 
      } 
    }); 

如果我们部署这些更改(提示:如果应用程序已经在运行,只需按下F11告诉 NetBeans 进行构建;这不会使任何 Java 更改生效,但它会将这些静态资源复制到输出目录),并在浏览器中刷新页面,我们现在应该在页面上看到Hello, World!

大致上,正在发生的是我们正在创建一个新的Vue对象,将其锚定到具有app ID 的(el)元素。我们还为这个组件(data)定义了一些状态,其中包括单个属性message。在页面上,我们可以使用 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); 
        }); 
      } 
    } 

页面加载时,应用程序将立即向后端发送一个请求以获取笔记,如果有的话,将在Authorization标头中发送令牌。当响应返回时,我们会更新存储中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); 

前面的代码告诉存储器commit一个名为setNotes的 mutation,并将notes作为有效载荷。我们像这样定义 mutations:

    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) 
            }); 
        }); 
      } 
    } 

我们传递给此 mutation 的是一个 JSON 数组(希望我们在这里没有显示类型检查),因此我们首先清除当前的笔记列表,然后遍历该数组,创建和存储新对象,并在此过程中重新格式化一些数据。严格使用此 mutation 来替换笔记集,我们可以保证用户界面与应用程序状态的变化保持同步,而且是免费的。

那么这些笔记是如何显示的呢?为了做到这一点,我们定义了一个新的 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-templateID 的元素中;它具有两个计算值: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作为显示数组中每个note的模板进行迭代。

每个笔记将使用span标签进行渲染。使用模板标记:title,我们能够使用我们的应用程序状态为标题标签创建一个值(我们不能说因为字符串插值在 Vue 2.0 中已被弃用)。span标签的唯一子元素是{{ note.title }}表达式,它将note列表的标题呈现为字符串。当用户在页面上点击笔记标题时,我们希望对此做出反应,因此我们通过v-on:clickonClick处理程序绑定到 DOM 元素。这里引用的函数是我们在组件定义的methods块中定义的loadNote()函数。

loadNote()函数调用了一个我们还没有看过的 mutation:

    noteClicked(state, index) { 
      state.currentIndex = index; 
      state.currentNote = state.notes[index]; 
      bus.$emit('note-clicked', state.currentNote); 
    } 

这个 mutation 修改状态以反映用户点击的笔记,然后触发(或发出)一个名为note-clicked的事件。事件系统实际上非常简单。它是这样设置的:

    var bus = new Vue(); 

就是这样。这只是一个基本的、全局范围的 Vue 组件。我们通过调用bus.$emit()方法来触发事件,并通过调用bus.$on()方法来注册事件监听器。我们将在 note 表单中看到这是什么样子的。

我们将像我们对note-list组件做的那样,将 note 表单组件添加到页面中:

    <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> 

当这个组件被挂载(或附加到 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 服务器等。物理环境的细节被抽象化,客户可以指定存储和内存需求。一些提供商还允许客户选择操作系统,因为这可能会对应用程序堆栈、支持工具等产生影响。

软件即服务是一个更高级的抽象,根本不关注硬件,而是提供订阅的托管软件,通常按用户订阅,通常按月或年计费。这通常出现在复杂的商业软件中,如财务系统或人力资源应用程序,但也出现在更简单的系统中,如博客软件。用户只需订阅并使用软件,安装和维护(包括升级)都由提供商处理。虽然这可能会减少用户的灵活性(例如,通常无法定制软件),但它也通过将维护成本推给提供商以及在大多数情况下保证访问最新版本的软件来降低运营成本。

这种类型的服务还有几种其他变体,比如移动后端作为服务MBaas)和数据库作为服务DBaaS)。随着市场对云计算的信心增强,互联网速度加快,价格下降,我们很可能会看到更多这类系统的开发,这也是本章的主题所在。

函数即服务,或者无服务器计算,是部署一个小段代码,非常字面上的一个函数,可以被其他应用程序调用,通常通过某种触发器。使用案例包括图像转换、日志分析,以及我们将在本章中构建的通知系统。

尽管无服务器这个名字暗示着没有服务器,实际上确实有一个服务器参与其中,这是理所当然的;然而,作为应用程序开发人员,你不需要深入思考服务器。事实上,正如我们将在本章中看到的,我们唯一需要担心的是我们的函数需要多少内存。关于服务器的其他一切都完全由服务提供商处理--操作系统、存储、网络,甚至虚拟机的启动和停止都由提供商为我们处理。

有了对无服务器的基本理解,我们需要选择一个提供商。可以预料到,有许多选择--亚马逊、甲骨文、IBM、红帽等。不幸的是,目前还没有标准化的方式可以编写一个无服务器系统并将其部署到任意提供商,因此我们的解决方案将必然与特定的提供商绑定,这将是亚马逊网络服务AWS),云计算服务的主要提供商。正如在本章的介绍中提到的,我们使用了许多 AWS 的产品,但核心将是 AWS Lambda,亚马逊的无服务器计算产品。

让我们开始吧。

规划应用程序

我们将构建的应用程序是一个非常简单的云通知服务。简而言之,我们的函数将监听消息,然后将这些消息转发到系统中注册的电子邮件地址和电话号码。虽然我们的系统可能有些牵强,当然非常简单,但希望更实际的用例是清楚的:

  • 我们的系统提醒学生和/或家长即将到来的事件

  • 当孩子进入或离开某些地理边界时,家长会收到通知

  • 系统管理员在发生某些事件时会收到通知

可能性非常广泛。对于我们在这里的目的,我们将开发不仅基于云的系统,还将开发一个简单的桌面应用程序来模拟这些类型的场景。我们将从有趣的地方开始:在云中。

构建你的第一个函数

作为服务的功能的核心当然是函数。在亚马逊网络服务中,这些函数是使用 AWS Lambda 服务部署的。这并不是我们将使用的唯一的 AWS 功能,正如我们已经提到的。一旦我们有了一个函数,我们需要一种执行它的方式。这是通过一个或多个触发器来完成的,函数本身有它需要执行的任务,所以当我们最终编写函数时,我们将通过 API 调用来演示更多的服务使用。

在这一点上,鉴于我们的应用程序的结构与我们所看到的任何其他东西都有很大不同,看一下系统图可能会有所帮助:

以下是大致的流程:

  • 一条消息将被发布到简单通知系统的主题中

  • 一旦验证了呼叫者的权限,消息就会被传送

  • 消息传送后,将触发一个触发器,将主题中的消息传送到我们的函数

  • 在函数内部,我们将查询亚马逊的DynamoDB,获取已注册的收件人列表,提供电子邮件地址、手机号码,或两者都提供

  • 所有的手机号码都将通过简单通知系统收到一条短信

  • 所有的电子邮件地址都将通过简单电子邮件服务发送电子邮件

要开始构建函数,我们需要创建一个 Java 项目。和我们的其他项目一样,这将是一个多模块的 Maven 项目。在 NetBeans 中,点击文件 | 新建项目 | Maven | POM 项目。我们将称之为CloudNotice项目。

该项目将有三个模块--一个用于函数,一个用于测试/演示客户端,一个用于共享 API。要创建函数模块,请在项目资源管理器中右键单击Modules节点,然后选择创建新模块。在窗口中,选择 Maven | Java Application,然后单击下一步,将项目名称设置为function。重复这些步骤,创建一个名为api的模块。

在我们继续之前,我们必须解决一个事实,即在撰写本文时,AWS 不支持 Java 9。因此,我们必须将我们将要交付给 Lambda 的任何东西都定位到 Java 8(或更早)。为此,我们需要修改我们的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,简单邮件服务和简单通知服务。我们将按顺序进行。

DynamoDB

Amazon DynamoDB 是一个 NoSQL 数据库,非常类似于我们在第九章中看到的 MongoDB,使用 Monumentum 进行笔记,尽管 DynamoDB 支持文档和键值存储模型。对这两者进行彻底比较,以及推荐选择哪一个,远远超出了我们在这里的工作范围。我们选择了 DynamoDB,因为它已经在亚马逊网络服务中预配,因此很容易为我们的应用程序进行配置。

要开始使用 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注释了 getter,以帮助库理解如何映射对象。

请注意,虽然大多数属性名称与表中的字段名称匹配,但您可以覆盖属性到字段名称的映射,就像我们在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); 
    } 

local属性用于确定是否使用本地 DynamoDB 实例。这是为了支持测试(就像调用verifyTables一样),我们将在下面探讨。在生产中,我们的代码将调用AmazonDynamoDBClientBuilder.defaultClient()来获取AmazonDynamoDB的实例,它与托管在亚马逊的实例进行通信。最后,我们创建了一个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 个组--50 个中的 5 个和 4 个中的 1 个)。然后,使用IntStream从 0 到组数(不包括)进行计数,我们从原始列表中提取子列表。然后,将这些列表中的每一个收集到另一个List中,从而得到我们在方法签名中看到的嵌套的Collection实例。

设计说明:许多开发人员会避免像这样使用嵌套的Collection实例,因为很快就会变得难以理解变量到底代表什么。在这种情况下,许多人认为最好的做法是创建一个新类型来保存嵌套数据。例如,如果我们在这里遵循这个建议,我们可以创建一个新的Group类,它具有一个List<String>属性来保存组的电子邮件地址。出于简洁起见,我们没有这样做,但这绝对是对这段代码的一个很好的增强。

一旦我们对列表进行了分块,我们可以将相同的Message发送给每个组,从而满足 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插件来构建一个包含函数及其所有依赖项的 fat 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重命名为CloudNoticeManagerFXMLController重命名为CloudNoticeManagerControllerScene.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 控件。我们将在initalize()中填充其中两个列表(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不为空,我们将控件的值设置为适当的值。否则,我们清除值。

现在我们需要为各种按钮添加处理程序--在Recipient列表上方的添加和删除按钮,以及右侧两个表单区域中的SaveCancel按钮。

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,因此我们创建一个新的Recipient,将其添加到我们的ObservableList,然后告诉ListView选择此条目。最后,我们要求type控件请求焦点,以便用户可以轻松地使用键盘更改值。新的 Recipient 直到用户点击保存才保存到 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:\Users\jdlee\aws。凭证文件应该看起来像这样:

    [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 函数,而且真的并不那么困难。当然,这是一个简单的应用程序,但我希望你能看到这种类型的应用程序可能非常有用。以此为起点,你可以编写系统,借助移动应用程序,帮助跟踪你家人的位置。例如,你可以使用树莓派等嵌入式设备,构建设备来跟踪随着货物在全国范围内的运输情况,报告位置、速度、环境条件、突然的下降或冲击等。在服务器上运行的软件可以不断报告系统的各种指标,如 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 由 Google 积极开发,并且实际上是 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定义为可以在后台执行长时间操作的应用程序组件,并且不提供用户界面。有三种类型的服务--scheduled(按计划运行),started(可以由另一个应用程序组件显式启动),和bound(通过bindService()调用绑定到应用程序组件,并在所有绑定的组件被销毁之前运行)。由于我们希望这个服务一直可用,我们需要一个已启动的服务。

要创建服务,点击文件 | 新建 | 服务 | 服务。输入DeskDroidService作为服务,取消选中 Exported,然后点击完成。这将为您提供以下存根代码:

    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而不是抛出异常。然而,我们对服务何时启动和停止感兴趣,因此我们需要重写这两个相关的生命周期方法:

    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 服务器。向值得尊敬的 Commodore 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,我们希望提供程序中的每个字段和每一行都按自然排序顺序。这会得到一个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; 
    } 

每个对话只是一个线程 ID、消息计数和片段,这是最后收到的消息。要获取实际的消息,我们调用getSmsMessages()如下:

    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; 
    } 

这种方法和处理逻辑与对话的大部分相同。当然,ContentProviderUriTelephony.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 映射功能将List<Conversation>返回给客户端,如下所示:

    return Response.ok(new GenericEntity<List<Conversation>>( 
      conversations) {}).build();

如果我们点击运行或调试按钮(工具栏中的大三角形或三角形上的带有错误图标),您将被要求选择部署目标,如下截图所示:

由于我们需要 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对象,然后我们将其拆分并传递给安卓的电话 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来限制我们的接收器必须处理哪些Intent

  7. 然后我们为消息的每个部分创建一个新的PendingIntent。这将允许我们对每个部分的发送尝试做出反应。

  8. 我们调用sendMultipartTextMessage()来发送消息的部分。安卓会为我们处理多部分消息的细节,所以不需要额外的努力。

  9. 我们需要等待所有消息部分被发送,所以我们调用sentLatch.await()来给系统发送消息的时间。然而,我们不想永远等下去,所以我们给了它一个五秒的超时时间,这应该足够长了。可以想象,有些网络可能在发送短信方面非常慢,所以这个值可能需要调整。

  10. 一旦我们通过了门闩,我们就unregister我们的接收器,并返回状态码。

再次使用 curl,我们现在可以测试发送消息(确保再次点击RunDebug来部署您更新的代码):

        $ 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 的 Scene Builder 来构建用户界面,但让我们来看看 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,用于显示所选对话。

定义用户界面行为

虽然我们可以在 FXML 中定义用户界面的结构,但除了最简单的应用程序外,用户界面仍然需要一些 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 将为我们设置该值,感谢注解@FXMLListView将需要一个要显示的模型,我们将其声明为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); 
     } 
    } 

如果单元格给定了“对话”,我们会处理它。如果没有,我们将单元格的图形设置为 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); 
    } 

消息框与之前的对话片段非常相似——消息的垂直显示,后跟其日期和时间。这种格式将被用于我的消息和他们的消息,因此我们使用javafx.geometry.Pos将控件对齐到右侧或左侧。

他们的消息是这样创建的:

    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 Studio 中的我们的 Android 应用中,我们在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)和参与者。

要构建参与者,我们需要查询电话联系人。这与短信查询的方式非常相似:

    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打开一个InputStream,使用该Uri读取内容,然后将其加载到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,我们尝试发送消息:

  4. 如果成功,我们用新消息更新用户界面。

  5. 如果不成功,我们显示一个错误消息。

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,我们可以返回一个可能为空的值,并在调用者中更安全地处理它--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()等待结果返回。我们将等待时间限制为五秒,以免永远等待。一旦等待时间到期或 latch 被清除,我们取消注册我们的接收器并返回状态码。

获取更新

到目前为止,我们可以看到所有的对话,查看对话中的单个消息,并发送新消息。但是,我们还不能在设备上收到新消息时获取更新,所以让我们从这次开始实现,从服务器部分开始。

为了获得一系列事件,我们将使用一个名为 Server-Sent Events 的功能,这是 W3C 规范,用于从服务器接收推送通知。我们通过在客户端和服务器设置步骤中注册SseFeature来启用 Jersey 中的此功能。要创建一个 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 事件)。此时,客户端可以读取头并开始监听各个事件。

然后,服务器会保持与客户端的套接字打开,并向其推送数据。但数据从哪里来呢?服务器发送事件端点创建一个“线程”,该线程将数据写入我们之前创建的EventOutput实例。当“线程”完成时,它调用eventOutput.close(),这表示运行时可以关闭客户端连接。为了流式更新,我们的“线程”如下所示:

    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,我们在这里注册并在“线程”结束之前注销,但这次,我们正在监听广播,即已收到短信消息。为了确保我们的“线程”不会陷入一个小的、紧凑的、快速的循环中,这将迅速耗尽设备的电池,我们使用了LinkedBlockingQueue。当收到消息时,我们从Intent中提取SmsMessage,并将它们添加到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(); 
    } 

在上述代码中,我们创建一个“线程”,在其中调用 SSE 端点。客户端的返回类型是EventInput。我们循环处理每个传入事件,我们将其作为InboundEvent获取。如果它为空,则连接已关闭,因此我们退出处理循环。如果它不为空,我们确保事件名称与我们正在等待的内容匹配--new-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(); 
      }); 
    } 

处理这条新消息的第一步非常重要--我们将一个Runnable传递给Platform.runLater()。如果我们不这样做,任何修改用户界面的尝试都将失败。你已经被警告了。在我们的Runnable中,我们创建一个ConversationStream,对其进行filter(),寻找与“消息”发送者匹配的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 TokensJWT)来帮助验证和授权客户端。在这个过滤器中,我们从请求头中提取 JWT,并通过以下步骤验证它:

  1. KeyGenerator获取签名密钥。

  2. 使用签名密钥创建JwtParser

  3. 解析 JWT 中的声明。在这里,基本上只是对令牌本身进行验证。

  4. 如果令牌无效,使用UNAUTHORIZED401)中止请求。

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中读取签名密钥。如果它为空,我们就创建一个随机字符串(这里只是一个 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

授权客户端

直到这一点,一切都假定桌面已经连接到手机。我们现在已经有足够的部件,可以有意义地讨论这个问题。

在应用程序的主Menu中,我们有两个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); 
      } 
    } 

请注意,Optional<String>被用作ConversationService.getAuthorization()的返回类型。正如我们之前讨论过的,使用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 客户端。

在本章结束时,我们构建了一个安卓应用程序,它不仅提供了用户界面,还提供了一个后台进程(称为“服务”),并使用 Jersey 及其 Java SE 部署选项在安卓应用程序中嵌入了我们的 REST 服务器。您还学会了如何在安卓上使用系统提供的内容提供程序和平台 API 与文本(短信)进行交互,并使用服务器发送事件将这些消息流式传输到客户端。我们演示了如何在安卓中使用“意图”、“广播”和“广播接收器”在进程/线程之间发送消息。最后,在桌面端,我们构建了一个 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 的新特性。随着发布,将有近 100 个Java 增强提案JEPs),其中一些很难,甚至无法演示,但我们已经尽力了。

展望未来

那么,Java 9 完成后,自然的问题是,接下来是什么?正如你所期望的,甲骨文、红帽、IBM、Azul Systems 等公司的工程师们在 Java 9 规划和开发期间就一直在思考这个问题。虽然几乎不可能确定 Java 10 会包含什么(记住,需要三个主要版本才能完成模块系统),但我们目前正在讨论和设计一些项目,希望能在下一个版本中发布。在接下来的几页中,我们将探讨其中一些,提前了解一下未来几年作为 Java 开发人员的生活可能会是什么样子。

瓦哈拉项目

瓦哈拉项目是一个高级语言-虚拟机共同开发项目的孵化基地。它由甲骨文工程师 Brian Goetz 领导。截至目前,瓦哈拉计划有三个特性。它们是值类型、泛型特化和具体化泛型。

值类型

这一努力的目标是更新 Java 虚拟机,如果可能的话,还有 Java 语言,以支持小型、不可变、无标识的值类型。目前,如果你实例化一个新的Object,JVM 会为其分配一个标识符,这允许对变量实例进行引用。

例如,如果你创建一个新的整数,new Integer(42),一个带有java.lang.Integer@68f29546标识的变量,但值为42,这个变量的值永远不会改变,这通常是我们作为开发人员关心的。然而,JVM 并不真正知道这一点,所以它必须维护变量的标识,带来了所有的开销。根据 Goetz 的说法,这意味着这个对象的每个实例将需要多达 24 个额外字节来存储实例。例如,如果你有一个大数组,那么这可能是一个相当大的内存管理量,最终需要进行垃圾回收。

然后,JVM 工程师们希望实现的是一种温和地扩展 Java 虚拟机字节码和 Java 语言本身的方式,以支持一个小型、不可变的聚合类型(想象一个具有 0 个或更多属性的类),它缺乏标识,这将带来“内存和局部性高效的编程习惯,而不会牺牲封装”。他们希望 Java 开发人员能够创建这些新类型并将它们视为另一种原始类型。如果他们做得正确,Goetz 说,这个特性可以总结为像类一样编码,像 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>。为什么会这样有一些相当复杂的原因,但能够使用原始类型和值类型将使集合在内存和计算方面更有效率。你可以在这篇文章中了解更多关于这个特性的信息,再次是 Brian Goetz 的文章--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 本地接口JNI)。JNI 的问题之一,或者至少是其中之一,是它要求每个想要将本地库暴露给 JVM 的 Java 程序员也成为 C 程序员,这意味着不仅要了解 C 语言本身,还要了解每个支持的平台的相关构建工具。

Panama 项目希望通过提供一种新的方式来暴露本地库,而无需深入了解库语言的生态系统或 JVM,来改善这个问题。Panama 项目的 JEP(openjdk.java.net/jeps/191)列出了这些设计目标。

  • 描述本地库调用的元数据系统(调用协议、参数列表结构、参数类型、返回类型)和本地内存结构(大小、布局、类型、生命周期)。

  • 发现和加载本地库的机制。这些功能可能由当前的System.loadLibrary提供,也可能包括额外的增强功能,用于定位适合主机系统的平台或特定版本的二进制文件。

  • 基于元数据的机制,将给定库/函数坐标绑定到 Java 端点,可能通过由管道支持的用户定义接口。

  • 基于元数据的机制,将特定的内存结构(布局、字节序、逻辑类型)绑定到 Java 端点,无论是通过用户定义的接口还是用户定义的类,在这两种情况下都由管道支持来管理真实的本地内存块。

  • 适当的支持代码,将 Java 数据类型转换为本地数据类型,反之亦然。在某些情况下,这将需要创建特定于 FFI 的类型,以支持 Java 无法表示的位宽和数值符号。

JNI 已经可用了相当长一段时间,现在终于得到了一些早就该得到的关注。

Project Amber

Project Amber 的目标是探索和孵化更小、以提高生产力为导向的 Java 语言特性。目前的列表包括局部变量类型推断、增强枚举和 lambda 遗留问题。

局部变量类型推断

正如我们在本书中多次看到的那样,在 Java 中声明变量时,您必须在左侧和右侧各声明一次类型,再加上一个变量名:

    AtomicInteger atomicInt = new AtomicInteger(42); 

问题在于这段代码冗长而重复。局部变量类型推断工作希望解决这个问题,使得像这样的东西成为可能:

    var atomicInt = new AtomicInteger(42); 

这段代码更加简洁,更易读。请注意val关键字的添加。通常,编译器知道代码行是变量声明时,例如<type> <name> = ...。由于这项工作将消除声明左侧的类型的需要,我们需要一个提示编译器的线索,这就是这个 JEP 的作者提出的var

还有一些关于简化不可变或final变量声明的讨论。提议中包括final var以及val,就像其他语言(如 Scala)中所见的那样。在撰写本文时,尚未就哪项提议最终确定做出决定。

增强枚举

增强枚举将通过允许枚举中的类型变量(通用枚举)和对枚举常量进行更严格的类型检查来增强 Java 语言中枚举结构的表现力。(openjdk.java.net/jeps/301)。这意味着枚举最终将支持参数化类型,允许像这样的东西(取自先前提到的 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 遗留问题

目前有两个项目被标记为 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(公平与否取决于你问谁)受到了太过复杂的认知。编译时间也是一个常见的抱怨。此外,许多组织都很乐意同时使用这两种语言,因此绝对值得考虑它们是否适合您的环境和需求。

虽然这些可能是很棒的语言,但我们在这里花点时间来看看接下来会发生什么,至少有两种语言似乎脱颖而出——锡兰语和 Kotlin。我们无法对这些语言进行详尽的介绍,但在接下来的几页中,我们将快速浏览这些语言,看看它们现在为 JVM 开发人员提供了什么,也许还能看到它们如何影响未来对 Java 语言的改变。

锡兰

锡兰语是由红帽赞助的一种语言,最早出现在 2011 年左右。由 Hibernate 和 Seam Framework 的知名人物 Gavin King 领导,团队着手解决他们多年来在开发自己的框架和库时所经历的一些痛点,从语言和库的层面上解决这些问题。他们承认自己是 Java 语言的忠实粉丝,但也承认这种语言并不完美,特别是在一些标准库方面,他们希望在锡兰语中修复这些缺陷。该语言的目标包括可读性、可预测性、可工具化、模块化和元编程能力(https://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! 

除了模块系统,锡兰可能为 Java 开发人员提供什么?其中一个立即有用和实用的功能是改进的空值处理支持。就像在 Java 中一样,我们仍然必须在锡兰中检查空值,但该语言提供了一种更好的方法,一切都始于类型系统。

关于 Scala 的一个抱怨(无论是否真正有根据)是其类型系统过于复杂。不管你是否同意,似乎很明显,相对于 Java 提供的内容,肯定有改进的空间(即使 Java 语言的设计者们也同意,例如提出的局部变量类型推断提案)。锡兰为类型系统提供了一个非常强大的补充——联合类型和交集类型。

联合类型允许变量具有多种类型,但一次只能有一种。在讨论空值时,这就体现在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进行测试,我们可以假定该变量不为空且为String。编译器不会抱怨,我们也不会在运行时遇到意外的NullPointerException(它们实际上在锡兰中不存在,因为编译器要求您对可空变量的处理非常明确)。这种对空值和类型检查的编译器感知称为流敏感类型。一旦您验证了某个东西的类型,编译器就知道并记住了这个检查的结果,这样说来,对于该范围的剩余部分,您可以编写更清晰、更简洁的代码。

联合类型要么是 A 要么是 B,而交集类型是 AB。举个完全随意的例子,假设您有一个方法,其参数必须是SerializableCloseable。在 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。这是来自 JetBrains 的静态类型语言,他们是 IntelliJ IDEA 的制造商,旨在同时针对 JVM 和 Javascript。它甚至具有初步支持,可以通过 LLVM 直接编译为机器代码,用于那些不希望或不允许使用虚拟机的环境,例如 iOS、嵌入式系统等。

Kotlin 于 2010 年开始,并于 2012 年开源,旨在解决 JetBrains 在大规模 Java 开发中面临的一些常见问题。在调查了当时的语言格局后,他们的工程师们认为这些语言都没有充分解决他们的问题。例如,被许多人认为是“下一个 Java”的 Scala,尽管具有可接受的功能集,但编译速度太慢,因此 JetBrains 开始设计他们自己的语言,并于 2016 年 2 月发布了 1.0 版本。

Kotlin 团队的设计目标包括表达性、可扩展性和互操作性。他们的目标是允许开发人员通过语言和库功能以更清晰的方式编写更少的代码,以及使用与 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>来帮助处理空值。Kotlin 也有空值支持,但它内置在语言中。默认情况下,Kotlin 中的所有变量都可为空。也就是说,如果编译器能够确定您试图将空值赋给变量,或者无法确定值是否可能为空(例如来自 Java API 的返回值),则会收到编译器错误。要指示值可以为空,您需要在变量声明中添加?,如下所示:

    var var1 : String = null; // error 
    var var2 : String? = null; // ok 

Kotlin 还在方法调用中提供了改进的空值处理支持。例如,假设您想要获取用户的城市。在 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 在处理空值方面并不止于此。例如,它提供了let函数,可以作为 if-not-null 检查的快捷方式。例如,考虑以下代码行:

    if (city != null) { 
      System.out.println(city.toUpperCase()); 
    } 

在 Kotlin 中,前面的代码行变成了这样:

    city?.let { 
      println(city.toUpperCase()) 
    } 

当然,这可以写成city?.toUpperCase()。然而,这应该证明的是在任意大的复杂代码块中安全使用可空变量的能力。值得注意的是,在let块内,编译器知道city不为空,因此不需要进一步的空值检查。

也许在前面的例子中隐藏着 Kotlin 对 lambda 的支持,没有 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 和技术的解释,这些可能对您的日常工作有所帮助。我希望您喜欢这些示例和解释,就像我喜欢准备它们一样,更重要的是,我希望它们能帮助您构建下一个大事件。

祝你好运!

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